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" applicationId = "com.ledga.app"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 9 versionCode = 10
versionName = "1.3.6" versionName = "1.4.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -26,6 +26,14 @@ data class TopMerchant(
val transactionCount: Int 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 * Every account-aware query takes a nullable `accountId` — null means
* "Combined view" (all accounts). The repository wires this from the * "Combined view" (all accounts). The repository wires this from the
@@ -102,12 +110,26 @@ interface TransactionDao {
""") """)
fun getLatestTransactionWithBalance(accountId: Long? = null): Flow<TransactionEntity?> 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(""" @Query("""
SELECT categoryId, SUM(amount) as totalAmount SELECT categoryId, SUM(amount) as totalAmount
FROM transactions FROM transactions
WHERE direction = 'OUTFLOW' WHERE direction = 'OUTFLOW'
AND timestamp BETWEEN :startTime AND :endTime AND timestamp BETWEEN :startTime AND :endTime
AND (:accountId IS NULL OR accountId = :accountId) 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 GROUP BY categoryId
""") """)
fun getSpendingByCategory(startTime: Long, endTime: Long, accountId: Long? = null): Flow<List<CategorySpending>> fun getSpendingByCategory(startTime: Long, endTime: Long, accountId: Long? = null): Flow<List<CategorySpending>>
@@ -117,6 +139,11 @@ interface TransactionDao {
WHERE direction = 'OUTFLOW' WHERE direction = 'OUTFLOW'
AND timestamp BETWEEN :startTime AND :endTime AND timestamp BETWEEN :startTime AND :endTime
AND (:accountId IS NULL OR accountId = :accountId) 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> fun getTotalSpending(startTime: Long, endTime: Long, accountId: Long? = null): Flow<Double>
@@ -158,6 +185,11 @@ interface TransactionDao {
WHERE direction = 'OUTFLOW' WHERE direction = 'OUTFLOW'
AND timestamp BETWEEN :startTime AND :endTime AND timestamp BETWEEN :startTime AND :endTime
AND (:accountId IS NULL OR accountId = :accountId) 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 GROUP BY dayTimestamp
ORDER BY dayTimestamp ASC ORDER BY dayTimestamp ASC
""") """)
@@ -169,6 +201,11 @@ interface TransactionDao {
WHERE direction = 'OUTFLOW' AND recipientName IS NOT NULL WHERE direction = 'OUTFLOW' AND recipientName IS NOT NULL
AND timestamp BETWEEN :startTime AND :endTime AND timestamp BETWEEN :startTime AND :endTime
AND (:accountId IS NULL OR accountId = :accountId) 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 GROUP BY recipientName
ORDER BY totalAmount DESC ORDER BY totalAmount DESC
LIMIT :limit LIMIT :limit
@@ -178,6 +215,30 @@ interface TransactionDao {
@Query("SELECT COUNT(*) FROM transactions WHERE type = 'UNKNOWN'") @Query("SELECT COUNT(*) FROM transactions WHERE type = 'UNKNOWN'")
fun getUnparsedCount(): Flow<Int> 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) ---- // ---- Goal progress helpers (Phase D) ----
// These are account-agnostic by design: a goal spans every line. // 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 { 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 reversedCodeRegex = Regex("""Transaction\s+([A-Z0-9]{10})\s+has been reversed""")
val match = reversedCodeRegex.find(sms) 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( return ParsedTransaction(
transactionCode = code, type = TransactionType.REVERSAL, amount = 0.0, transactionCode = code, type = TransactionType.REVERSAL, amount = credited,
transactionCost = 0.0, recipientName = null, recipientPhone = null, transactionCost = 0.0, recipientName = null, recipientPhone = null,
accountNumber = null, destinationCountry = null, balance = balance, accountNumber = null, destinationCountry = null, balance = balance,
direction = TransactionDirection.INFLOW, fulizaAmount = null, fulizaOutstanding = null, 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.CategoryRuleDao
import com.ledga.app.data.db.dao.CategorySpending import com.ledga.app.data.db.dao.CategorySpending
import com.ledga.app.data.db.dao.DailySpending 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.TopMerchant
import com.ledga.app.data.db.dao.TransactionDao import com.ledga.app.data.db.dao.TransactionDao
import com.ledga.app.data.db.entity.MatchType 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>> = fun getDailySpending(startTime: Long, endTime: Long): Flow<List<DailySpending>> =
selectedAccount.flatMapLatest { transactionDao.getDailySpending(startTime, endTime, it) } 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>> = fun getTopMerchants(startTime: Long, endTime: Long, limit: Int = 5): Flow<List<TopMerchant>> =
selectedAccount.flatMapLatest { transactionDao.getTopMerchants(startTime, endTime, it, limit) } 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LocalAtm 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.LedgaChip
import com.ledga.app.ui.components.v2.StatCard import com.ledga.app.ui.components.v2.StatCard
import com.ledga.app.ui.theme.LedgaAccentDeep 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.LedgaText
import com.ledga.app.ui.theme.Space import com.ledga.app.ui.theme.Space
import com.ledga.app.util.CurrencyFormatter 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.CartesianChartHost
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberBottom import com.patrykandpatrick.vico.compose.cartesian.axis.rememberBottom
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberStart 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 ---- // ---- Top merchants ----
if (state.topMerchants.isNotEmpty()) { if (state.topMerchants.isNotEmpty()) {
BentoCard( 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 @Composable
private fun MerchantRow(name: String, count: Int, amount: Double) { private fun MerchantRow(name: String, count: Int, amount: Double) {
Row( Row(
@@ -15,7 +15,10 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.util.Calendar import java.util.Calendar
import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
enum class TrendsPeriod(val label: String, val days: Int) { enum class TrendsPeriod(val label: String, val days: Int) {
@@ -31,7 +34,9 @@ data class TrendsUiState(
val totalSpending: Double = 0.0, val totalSpending: Double = 0.0,
val totalFees: Double = 0.0, val totalFees: Double = 0.0,
val topMerchants: List<TopMerchant> = emptyList(), 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( data class CategorySpendingWithName(
@@ -40,6 +45,20 @@ data class CategorySpendingWithName(
val totalAmount: Double 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) @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
@HiltViewModel @HiltViewModel
class TrendsViewModel @Inject constructor( class TrendsViewModel @Inject constructor(
@@ -89,8 +108,41 @@ class TrendsViewModel @Inject constructor(
} }
) { state, categorySpending -> ) { state, categorySpending ->
state.copy(categorySpending = categorySpending) state.copy(categorySpending = categorySpending)
}.combine(
transactionRepository.getMonthlySpending(limit = 12)
) { state, monthly ->
state.copy(monthlySpending = buildMonthlyBreakdown(monthly))
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrendsUiState()) }.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) { fun selectPeriod(period: TrendsPeriod) {
_selectedPeriod.value = period _selectedPeriod.value = period
} }
@@ -317,6 +317,25 @@ class MpesaSmsParserTest {
assertEquals("RJ12345678", t.reversedTransactionCode) assertEquals("RJ12345678", t.reversedTransactionCode)
assertEquals(2000.0, t.balance, 0.01) assertEquals(2000.0, t.balance, 0.01)
assertEquals(TransactionDirection.INFLOW, t.direction) 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 --- // --- 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])