mirror of
https://github.com/andwati/ledga.git
synced 2026-10-18 03:12:55 +00:00
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:
@@ -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 ---
|
||||||
|
|||||||
@@ -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])
|
||||||
Reference in New Issue
Block a user