From 9600fa730c3473fd8c81130222e4d15e2b64e267 Mon Sep 17 00:00:00 2001 From: ngenohkevin Date: Sun, 7 Jun 2026 15:24:59 +0300 Subject: [PATCH] v1.4.0: true spending totals + monthly trends Spending figures were inflated: Fuliza repayments double-counted the already-counted borrow-backed purchase, M-Shwari/KCB deposits counted own-money savings as spending, and reversed transactions never came back out of the total. All spending queries now exclude these. Also adds a month-by-month breakdown card to Trends (12 months, delta vs previous month) and captures the credited amount on standard-format reversal SMS instead of recording 0.0. --- app/build.gradle.kts | 4 +- .../ledga/app/data/db/dao/TransactionDao.kt | 61 ++++++++++ .../ledga/app/data/parser/MpesaSmsParser.kt | 8 +- .../data/repository/TransactionRepository.kt | 4 + .../com/ledga/app/ui/trends/TrendsScreen.kt | 94 ++++++++++++++++ .../ledga/app/ui/trends/TrendsViewModel.kt | 54 ++++++++- .../ledga/app/parser/MpesaSmsParserTest.kt | 19 ++++ scripts/analyze_export.py | 104 ++++++++++++++++++ 8 files changed, 344 insertions(+), 4 deletions(-) create mode 100644 scripts/analyze_export.py diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a30a249..b3b954e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -27,8 +27,8 @@ android { applicationId = "com.ledga.app" minSdk = 26 targetSdk = 34 - versionCode = 9 - versionName = "1.3.6" + versionCode = 10 + versionName = "1.4.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/com/ledga/app/data/db/dao/TransactionDao.kt b/app/src/main/java/com/ledga/app/data/db/dao/TransactionDao.kt index 071ce9a..cb58519 100644 --- a/app/src/main/java/com/ledga/app/data/db/dao/TransactionDao.kt +++ b/app/src/main/java/com/ledga/app/data/db/dao/TransactionDao.kt @@ -26,6 +26,14 @@ data class TopMerchant( val transactionCount: Int ) +data class MonthlySpending( + /** Calendar month key in the device timezone, e.g. "2026-06". */ + val monthKey: String, + val totalAmount: Double, + val totalFees: Double, + val transactionCount: Int +) + /** * Every account-aware query takes a nullable `accountId` — null means * "Combined view" (all accounts). The repository wires this from the @@ -102,12 +110,26 @@ interface TransactionDao { """) fun getLatestTransactionWithBalance(accountId: Long? = null): Flow + /* + * Spending queries share three exclusions beyond direction = OUTFLOW: + * - reversed originals (the money came back) + * - Fuliza repayments/auto-pay (the Fuliza-backed purchase was already + * counted when it happened — counting the repayment doubles it) + * - M-Shwari / KCB M-Pesa deposits (own money moving to savings; goals + * track these as contributions, not spending) + */ + @Query(""" SELECT categoryId, SUM(amount) as totalAmount FROM transactions WHERE direction = 'OUTFLOW' AND timestamp BETWEEN :startTime AND :endTime AND (:accountId IS NULL OR accountId = :accountId) + AND type NOT IN ('FULIZA_REPAYMENT', 'FULIZA_AUTO_PAY', 'MSHWARI', 'KCB_MPESA') + AND transactionCode NOT IN ( + SELECT reversedTransactionCode FROM transactions + WHERE reversedTransactionCode IS NOT NULL + ) GROUP BY categoryId """) fun getSpendingByCategory(startTime: Long, endTime: Long, accountId: Long? = null): Flow> @@ -117,6 +139,11 @@ interface TransactionDao { WHERE direction = 'OUTFLOW' AND timestamp BETWEEN :startTime AND :endTime AND (:accountId IS NULL OR accountId = :accountId) + AND type NOT IN ('FULIZA_REPAYMENT', 'FULIZA_AUTO_PAY', 'MSHWARI', 'KCB_MPESA') + AND transactionCode NOT IN ( + SELECT reversedTransactionCode FROM transactions + WHERE reversedTransactionCode IS NOT NULL + ) """) fun getTotalSpending(startTime: Long, endTime: Long, accountId: Long? = null): Flow @@ -158,6 +185,11 @@ interface TransactionDao { WHERE direction = 'OUTFLOW' AND timestamp BETWEEN :startTime AND :endTime AND (:accountId IS NULL OR accountId = :accountId) + AND type NOT IN ('FULIZA_REPAYMENT', 'FULIZA_AUTO_PAY', 'MSHWARI', 'KCB_MPESA') + AND transactionCode NOT IN ( + SELECT reversedTransactionCode FROM transactions + WHERE reversedTransactionCode IS NOT NULL + ) GROUP BY dayTimestamp ORDER BY dayTimestamp ASC """) @@ -169,6 +201,11 @@ interface TransactionDao { WHERE direction = 'OUTFLOW' AND recipientName IS NOT NULL AND timestamp BETWEEN :startTime AND :endTime AND (:accountId IS NULL OR accountId = :accountId) + AND type NOT IN ('FULIZA_REPAYMENT', 'FULIZA_AUTO_PAY', 'MSHWARI', 'KCB_MPESA') + AND transactionCode NOT IN ( + SELECT reversedTransactionCode FROM transactions + WHERE reversedTransactionCode IS NOT NULL + ) GROUP BY recipientName ORDER BY totalAmount DESC LIMIT :limit @@ -178,6 +215,30 @@ interface TransactionDao { @Query("SELECT COUNT(*) FROM transactions WHERE type = 'UNKNOWN'") fun getUnparsedCount(): Flow + /** + * Calendar-month spending totals (device timezone), newest first. + * Same exclusions as [getTotalSpending]: outflows only, reversed + * originals removed. Fees ride along since inflow rows carry zero cost. + */ + @Query(""" + SELECT strftime('%Y-%m', timestamp / 1000, 'unixepoch', 'localtime') as monthKey, + SUM(amount) as totalAmount, + SUM(transactionCost) as totalFees, + COUNT(*) as transactionCount + FROM transactions + WHERE direction = 'OUTFLOW' + AND (:accountId IS NULL OR accountId = :accountId) + AND type NOT IN ('FULIZA_REPAYMENT', 'FULIZA_AUTO_PAY', 'MSHWARI', 'KCB_MPESA') + AND transactionCode NOT IN ( + SELECT reversedTransactionCode FROM transactions + WHERE reversedTransactionCode IS NOT NULL + ) + GROUP BY monthKey + ORDER BY monthKey DESC + LIMIT :limit + """) + fun getMonthlySpending(accountId: Long? = null, limit: Int = 12): Flow> + // ---- Goal progress helpers (Phase D) ---- // These are account-agnostic by design: a goal spans every line. diff --git a/app/src/main/java/com/ledga/app/data/parser/MpesaSmsParser.kt b/app/src/main/java/com/ledga/app/data/parser/MpesaSmsParser.kt index 735c129..da2e223 100644 --- a/app/src/main/java/com/ledga/app/data/parser/MpesaSmsParser.kt +++ b/app/src/main/java/com/ledga/app/data/parser/MpesaSmsParser.kt @@ -455,8 +455,14 @@ object MpesaSmsParser { private fun parseReversal(sms: String, code: String, balance: Double, timestamp: Long): ParsedTransaction { val reversedCodeRegex = Regex("""Transaction\s+([A-Z0-9]{10})\s+has been reversed""") val match = reversedCodeRegex.find(sms) + // Credited amount, when present ("...and Ksh500.00 is credited to your + // M-PESA account"). Deliberately NOT extractFirstAmount — in the short + // format the only Ksh figure is the balance, which must not be + // recorded as the reversal amount. + val creditedRegex = Regex("""Ksh\s?([\d,]+\.\d{2})\s+is credited""", RegexOption.IGNORE_CASE) + val credited = creditedRegex.find(sms)?.groupValues?.get(1)?.let { parseAmount(it) } ?: 0.0 return ParsedTransaction( - transactionCode = code, type = TransactionType.REVERSAL, amount = 0.0, + transactionCode = code, type = TransactionType.REVERSAL, amount = credited, transactionCost = 0.0, recipientName = null, recipientPhone = null, accountNumber = null, destinationCountry = null, balance = balance, direction = TransactionDirection.INFLOW, fulizaAmount = null, fulizaOutstanding = null, diff --git a/app/src/main/java/com/ledga/app/data/repository/TransactionRepository.kt b/app/src/main/java/com/ledga/app/data/repository/TransactionRepository.kt index 7b067d9..703eb3e 100644 --- a/app/src/main/java/com/ledga/app/data/repository/TransactionRepository.kt +++ b/app/src/main/java/com/ledga/app/data/repository/TransactionRepository.kt @@ -3,6 +3,7 @@ package com.ledga.app.data.repository import com.ledga.app.data.db.dao.CategoryRuleDao import com.ledga.app.data.db.dao.CategorySpending import com.ledga.app.data.db.dao.DailySpending +import com.ledga.app.data.db.dao.MonthlySpending import com.ledga.app.data.db.dao.TopMerchant import com.ledga.app.data.db.dao.TransactionDao import com.ledga.app.data.db.entity.MatchType @@ -83,6 +84,9 @@ class TransactionRepository @Inject constructor( fun getDailySpending(startTime: Long, endTime: Long): Flow> = selectedAccount.flatMapLatest { transactionDao.getDailySpending(startTime, endTime, it) } + fun getMonthlySpending(limit: Int = 12): Flow> = + selectedAccount.flatMapLatest { transactionDao.getMonthlySpending(it, limit) } + fun getTopMerchants(startTime: Long, endTime: Long, limit: Int = 5): Flow> = selectedAccount.flatMapLatest { transactionDao.getTopMerchants(startTime, endTime, it, limit) } diff --git a/app/src/main/java/com/ledga/app/ui/trends/TrendsScreen.kt b/app/src/main/java/com/ledga/app/ui/trends/TrendsScreen.kt index 555ca39..740cdd3 100644 --- a/app/src/main/java/com/ledga/app/ui/trends/TrendsScreen.kt +++ b/app/src/main/java/com/ledga/app/ui/trends/TrendsScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -14,6 +15,7 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.LocalAtm @@ -37,9 +39,13 @@ import com.ledga.app.ui.components.v2.HeatmapCalendar import com.ledga.app.ui.components.v2.LedgaChip import com.ledga.app.ui.components.v2.StatCard import com.ledga.app.ui.theme.LedgaAccentDeep +import com.ledga.app.ui.theme.LedgaDanger +import com.ledga.app.ui.theme.LedgaInflow import com.ledga.app.ui.theme.LedgaText import com.ledga.app.ui.theme.Space import com.ledga.app.util.CurrencyFormatter +import kotlin.math.abs +import kotlin.math.roundToInt import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost import com.patrykandpatrick.vico.compose.cartesian.axis.rememberBottom import com.patrykandpatrick.vico.compose.cartesian.axis.rememberStart @@ -112,6 +118,37 @@ fun TrendsScreen( ) } + // ---- Month by month ---- + if (state.monthlySpending.isNotEmpty()) { + BentoCard( + contentPadding = androidx.compose.foundation.layout.PaddingValues(0.dp), + ) { + Text( + text = "Month by month".uppercase(), + style = LedgaText.Overline, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = Space.Card, vertical = 14.dp), + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.padding(horizontal = Space.Card), + ) + val maxMonth = state.monthlySpending.maxOf { it.totalAmount } + state.monthlySpending.forEachIndexed { index, month -> + MonthRow( + month = month, + barFraction = if (maxMonth > 0) (month.totalAmount / maxMonth).toFloat() else 0f, + ) + if (index < state.monthlySpending.lastIndex) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.padding(horizontal = Space.Card), + ) + } + } + } + } + // ---- Top merchants ---- if (state.topMerchants.isNotEmpty()) { BentoCard( @@ -200,6 +237,63 @@ fun TrendsScreen( } } +@Composable +private fun MonthRow(month: MonthlyBreakdownItem, barFraction: Float) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Space.Card, vertical = 14.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = if (month.inProgress) "${month.label} · so far" else month.label, + style = LedgaText.BodyL, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "${month.transactionCount} ${if (month.transactionCount == 1) "transaction" else "transactions"}" + + if (month.totalFees > 0) " · ${CurrencyFormatter.formatKshCompact(month.totalFees)} fees" else "", + style = LedgaText.Caption, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Column(horizontalAlignment = Alignment.End) { + Text( + text = CurrencyFormatter.formatKshCompact(month.totalAmount), + style = LedgaText.TitleS, + color = MaterialTheme.colorScheme.onSurface, + ) + month.deltaPct?.let { delta -> + val rising = delta > 0 + Text( + text = "${if (rising) "↑" else "↓"} ${abs(delta).roundToInt()}%", + style = LedgaText.Caption, + // Spending up is bad, down is good. + color = if (rising) LedgaDanger else LedgaInflow, + ) + } + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp) + .height(6.dp) + .clip(RoundedCornerShape(3.dp)) + .background(MaterialTheme.colorScheme.outline), + ) { + Box( + modifier = Modifier + .fillMaxWidth(barFraction.coerceIn(0f, 1f)) + .fillMaxHeight() + .clip(RoundedCornerShape(3.dp)) + .background(MaterialTheme.colorScheme.primary), + ) + } + } +} + @Composable private fun MerchantRow(name: String, count: Int, amount: Double) { Row( diff --git a/app/src/main/java/com/ledga/app/ui/trends/TrendsViewModel.kt b/app/src/main/java/com/ledga/app/ui/trends/TrendsViewModel.kt index 43d7a08..1afadba 100644 --- a/app/src/main/java/com/ledga/app/ui/trends/TrendsViewModel.kt +++ b/app/src/main/java/com/ledga/app/ui/trends/TrendsViewModel.kt @@ -15,7 +15,10 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn +import java.time.YearMonth +import java.time.format.DateTimeFormatter import java.util.Calendar +import java.util.Locale import javax.inject.Inject enum class TrendsPeriod(val label: String, val days: Int) { @@ -31,7 +34,9 @@ data class TrendsUiState( val totalSpending: Double = 0.0, val totalFees: Double = 0.0, val topMerchants: List = emptyList(), - val categorySpending: List = emptyList() + val categorySpending: List = emptyList(), + /** Calendar-month totals, newest first. Independent of the rolling period chips. */ + val monthlySpending: List = emptyList() ) data class CategorySpendingWithName( @@ -40,6 +45,20 @@ data class CategorySpendingWithName( val totalAmount: Double ) +data class MonthlyBreakdownItem( + /** e.g. "2026-06" */ + val monthKey: String, + /** e.g. "Jun 2026" */ + val label: String, + val totalAmount: Double, + val totalFees: Double, + val transactionCount: Int, + /** % change vs previous month, null when there is no comparable previous month. */ + val deltaPct: Double?, + /** True for the current (incomplete) calendar month. */ + val inProgress: Boolean +) + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) @HiltViewModel class TrendsViewModel @Inject constructor( @@ -89,8 +108,41 @@ class TrendsViewModel @Inject constructor( } ) { state, categorySpending -> state.copy(categorySpending = categorySpending) + }.combine( + transactionRepository.getMonthlySpending(limit = 12) + ) { state, monthly -> + state.copy(monthlySpending = buildMonthlyBreakdown(monthly)) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrendsUiState()) + /** DAO emits newest-first; walk oldest-first to attach deltas, then restore order. */ + private fun buildMonthlyBreakdown( + monthly: List + ): List { + val currentKey = YearMonth.now().format(MONTH_KEY_FORMAT) + val chronological = monthly.asReversed() + return chronological.mapIndexed { index, m -> + val prev = chronological.getOrNull(index - 1) + MonthlyBreakdownItem( + monthKey = m.monthKey, + label = runCatching { + YearMonth.parse(m.monthKey).format(MONTH_LABEL_FORMAT) + }.getOrDefault(m.monthKey), + totalAmount = m.totalAmount, + totalFees = m.totalFees, + transactionCount = m.transactionCount, + deltaPct = prev?.takeIf { it.totalAmount > 0 }?.let { + (m.totalAmount - it.totalAmount) / it.totalAmount * 100 + }, + inProgress = m.monthKey == currentKey, + ) + }.asReversed() + } + + private companion object { + val MONTH_KEY_FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM") + val MONTH_LABEL_FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern("MMM yyyy", Locale.ENGLISH) + } + fun selectPeriod(period: TrendsPeriod) { _selectedPeriod.value = period } diff --git a/app/src/test/java/com/ledga/app/parser/MpesaSmsParserTest.kt b/app/src/test/java/com/ledga/app/parser/MpesaSmsParserTest.kt index 4b09a82..c9ca988 100644 --- a/app/src/test/java/com/ledga/app/parser/MpesaSmsParserTest.kt +++ b/app/src/test/java/com/ledga/app/parser/MpesaSmsParserTest.kt @@ -317,6 +317,25 @@ class MpesaSmsParserTest { assertEquals("RJ12345678", t.reversedTransactionCode) assertEquals(2000.0, t.balance, 0.01) assertEquals(TransactionDirection.INFLOW, t.direction) + // No "is credited" amount in this format — must stay 0.0, never the balance. + assertEquals(0.0, t.amount, 0.01) + } + + @Test + fun `parse reversal with credited amount`() { + val sms = "RK31B7X4ZQ Confirmed. Transaction RJ12345678 has been reversed on 21/3/26 at 3:00 PM and Ksh750.00 is credited to your M-PESA account. New M-PESA account balance is Ksh2,750.00." + val t = assertSuccess(sms).transaction + assertEquals(TransactionType.REVERSAL, t.type) + assertEquals("RJ12345678", t.reversedTransactionCode) + assertEquals(750.0, t.amount, 0.01) + assertEquals(TransactionDirection.INFLOW, t.direction) + } + + @Test + fun `reversal alt captures credited amount`() { + val sms = "SH37SPJZKZ confirmed. Reversal of transaction SH38SP5YUQ has been successfully reversed on 3/8/24 at 2:30 PM and Ksh200.00 is credited to your M-PESA account. New M-PESA account balance is Ksh1,770.00." + val t = assertSuccess(sms).transaction + assertEquals(200.0, t.amount, 0.01) } // --- M-Shwari --- diff --git a/scripts/analyze_export.py b/scripts/analyze_export.py new file mode 100644 index 0000000..a98f48f --- /dev/null +++ b/scripts/analyze_export.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Analyze a Ledga export zip: real vs shown spending, month by month. + +Usage: python3 analyze_export.py + +"Shown" replicates the app's OLD Total Spending (all OUTFLOW rows). +The breakdown columns show how much of that figure is: + - reversed: outflows later reversed + - fuliza: Fuliza repayments/auto-pay (double-counts the original spend) + - savings: M-Shwari / KCB M-Pesa deposits (your own money moving) + - unknown: unparsed SMS defaulted to spending + - withdraw: agent/ATM cash-out (cash in hand, not yet spent) +"real" = shown - reversed - fuliza - savings - unknown (withdrawals kept) +The app now excludes reversed + fuliza + savings, so the in-app figure +should equal REAL + unknown for any month. +""" + +import json +import sys +import zipfile +from collections import defaultdict +from datetime import datetime + +FULIZA_REPAY = {"FULIZA_REPAYMENT", "FULIZA_AUTO_PAY"} +SAVINGS = {"MSHWARI", "KCB_MPESA"} +WITHDRAW = {"WITHDRAW_AGENT", "WITHDRAW_ATM"} + + +def load(path): + if path.endswith(".zip"): + with zipfile.ZipFile(path) as z: + name = next(n for n in z.namelist() if n.endswith("data.json")) + with z.open(name) as f: + return json.load(f) + with open(path) as f: + return json.load(f) + + +def main(path): + data = load(path) + txns = data["transactions"] + print(f"Transactions: {len(txns)} (exported {datetime.fromtimestamp(data.get('exportedAt', 0) / 1000):%Y-%m-%d %H:%M})\n") + + reversed_codes = { + t["reversedTransactionCode"] + for t in txns + if t.get("reversedTransactionCode") + } + + months = defaultdict(lambda: defaultdict(float)) + counts = defaultdict(int) + for t in txns: + if t["direction"] != "OUTFLOW": + continue + key = datetime.fromtimestamp(t["timestamp"] / 1000).strftime("%Y-%m") + amt = t["amount"] + m = months[key] + m["shown"] += amt + m["fees"] += t.get("transactionCost", 0.0) + counts[key] += 1 + if t["transactionCode"] in reversed_codes: + m["reversed"] += amt + elif t["type"] in FULIZA_REPAY: + m["fuliza"] += amt + elif t["type"] in SAVINGS: + m["savings"] += amt + elif t["type"] == "UNKNOWN": + m["unknown"] += amt + elif t["type"] in WITHDRAW: + m["withdraw"] += amt + + cols = ["shown", "reversed", "fuliza", "savings", "unknown", "withdraw"] + header = f"{'Month':<9}{'Txns':>6}" + "".join(f"{c:>12}" for c in cols) + f"{'REAL':>12}{'fees':>9}" + print(header) + print("-" * len(header)) + totals = defaultdict(float) + for key in sorted(months): + m = months[key] + real = m["shown"] - m["reversed"] - m["fuliza"] - m["savings"] - m["unknown"] + row = f"{key:<9}{counts[key]:>6}" + "".join(f"{m[c]:>12,.0f}" for c in cols) + print(row + f"{real:>12,.0f}{m['fees']:>9,.0f}") + for c in cols + ["fees"]: + totals[c] += m[c] + totals["real"] += real + print("-" * len(header)) + print( + f"{'TOTAL':<9}{sum(counts.values()):>6}" + + "".join(f"{totals[c]:>12,.0f}" for c in cols) + + f"{totals['real']:>12,.0f}{totals['fees']:>9,.0f}" + ) + + if totals["shown"] > 0: + inflation = totals["shown"] - totals["real"] + print( + f"\nShown spending is inflated by Ksh {inflation:,.0f} " + f"({inflation / totals['shown'] * 100:.1f}% of the shown figure)." + ) + print("REAL keeps cash withdrawals; subtract the withdraw column too if you treat cash-out as a transfer.") + + +if __name__ == "__main__": + if len(sys.argv) != 2: + sys.exit(__doc__) + main(sys.argv[1])