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.
This commit is contained in:
ngenohkevin
2026-06-07 15:24:59 +03:00
parent f40a93e62a
commit 9600fa730c
8 changed files with 344 additions and 4 deletions
+2 -2
View File
@@ -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"
}
@@ -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<TransactionEntity?>
/*
* 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<List<CategorySpending>>
@@ -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<Double>
@@ -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<Int>
/**
* 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<List<MonthlySpending>>
// ---- Goal progress helpers (Phase D) ----
// These are account-agnostic by design: a goal spans every line.
@@ -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,
@@ -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<List<DailySpending>> =
selectedAccount.flatMapLatest { transactionDao.getDailySpending(startTime, endTime, it) }
fun getMonthlySpending(limit: Int = 12): Flow<List<MonthlySpending>> =
selectedAccount.flatMapLatest { transactionDao.getMonthlySpending(it, limit) }
fun getTopMerchants(startTime: Long, endTime: Long, limit: Int = 5): Flow<List<TopMerchant>> =
selectedAccount.flatMapLatest { transactionDao.getTopMerchants(startTime, endTime, it, limit) }
@@ -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(
@@ -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<TopMerchant> = emptyList(),
val categorySpending: List<CategorySpendingWithName> = emptyList()
val categorySpending: List<CategorySpendingWithName> = emptyList(),
/** Calendar-month totals, newest first. Independent of the rolling period chips. */
val monthlySpending: List<MonthlyBreakdownItem> = 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<com.ledga.app.data.db.dao.MonthlySpending>
): List<MonthlyBreakdownItem> {
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
}
@@ -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 ---
+104
View File
@@ -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 <ledga-export.zip | data.json>
"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])