From 20fe7ba5fd4c22739c86008f717eff0e57ae5c2f Mon Sep 17 00:00:00 2001 From: ngenohkevin Date: Mon, 8 Jun 2026 08:52:54 +0300 Subject: [PATCH] v1.4.4: correct Fuliza available-to-borrow + real zero balance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two display fixes: 1. 'Available to borrow' showed the raw ceiling (e.g. 18K) ignoring what's already drawn. M-PESA's 'Available Fuliza limit' is the headroom at that SMS, so ceiling = that reading + its outstanding (0 after a full clear); borrowable now = ceiling − current outstanding. Card also shows the limit. 2. Balance card had a 'balance > 0' filter that skipped genuine Ksh0.00 readings and surfaced a stale positive balance instead. A real zero (spent down / running on Fuliza) now shows; only parser-default zeros on repayment/reversal/companion/unknown rows are skipped. --- app/build.gradle.kts | 4 ++-- .../com/ledga/app/data/db/dao/TransactionDao.kt | 12 ++++++++++-- .../main/java/com/ledga/app/ui/home/HomeScreen.kt | 15 ++++++++++++--- .../java/com/ledga/app/ui/home/HomeViewModel.kt | 13 +++++++++++-- 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1088e1c..88edd84 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 = 13 - versionName = "1.4.3" + versionCode = 14 + versionName = "1.4.4" 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 fceb04b..087c2ae 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 @@ -102,10 +102,18 @@ interface TransactionDao { """) fun getLatestTransaction(accountId: Long? = null): Flow + /** + * Latest wallet balance for the Balance card. A genuine Ksh0.00 (you spent + * down to nothing / are running on Fuliza) is a REAL balance and must show. + * We only skip rows whose balance is a parser-default 0 — Fuliza + * repayment/reversal/companion and unparsed SMS carry no balance of their + * own — so a stale positive balance never masks a true zero. + */ @Query(""" SELECT * FROM transactions - WHERE balance > 0 - AND (:accountId IS NULL OR accountId = :accountId) + WHERE (:accountId IS NULL OR accountId = :accountId) + AND NOT (balance = 0 AND type IN + ('FULIZA_REPAYMENT','FULIZA_REVERSAL','REVERSAL','FULIZA','UNKNOWN')) ORDER BY timestamp DESC LIMIT 1 """) fun getLatestTransactionWithBalance(accountId: Long? = null): Flow diff --git a/app/src/main/java/com/ledga/app/ui/home/HomeScreen.kt b/app/src/main/java/com/ledga/app/ui/home/HomeScreen.kt index 5e38ac1..dcc59e0 100644 --- a/app/src/main/java/com/ledga/app/ui/home/HomeScreen.kt +++ b/app/src/main/java/com/ledga/app/ui/home/HomeScreen.kt @@ -230,6 +230,7 @@ fun HomeScreen( FulizaStatusCard( outstanding = state.fulizaOutstanding, available = state.fulizaAvailable, + ceiling = state.fulizaCeiling, asOf = state.fulizaOutstandingAt, ) } @@ -441,7 +442,7 @@ private fun UpdateBanner( } @Composable -private fun FulizaStatusCard(outstanding: Double?, available: Double?, asOf: Long?) { +private fun FulizaStatusCard(outstanding: Double?, available: Double?, ceiling: Double?, asOf: Long?) { BentoCard(title = "Fuliza") { Row( modifier = Modifier.fillMaxWidth(), @@ -478,11 +479,19 @@ private fun FulizaStatusCard(outstanding: Double?, available: Double?, asOf: Lon style = LedgaText.TitleM, color = MaterialTheme.colorScheme.onSurface, ) + ceiling?.let { + Text( + text = "of ${CurrencyFormatter.formatKshCompact(it)} limit", + style = LedgaText.Caption, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } // Usage bar — how deep into the limit the overdraft currently is. - if (outstanding != null && available != null && outstanding + available > 0) { - val usedFraction = (outstanding / (outstanding + available)).toFloat() + val barCeiling = ceiling ?: outstanding?.let { o -> available?.let { o + it } } + if (outstanding != null && barCeiling != null && barCeiling > 0) { + val usedFraction = (outstanding / barCeiling).toFloat() Box( modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/ledga/app/ui/home/HomeViewModel.kt b/app/src/main/java/com/ledga/app/ui/home/HomeViewModel.kt index f44b14a..afe767f 100644 --- a/app/src/main/java/com/ledga/app/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/ledga/app/ui/home/HomeViewModel.kt @@ -61,8 +61,10 @@ data class HomeUiState( val selectedAccountId: Long? = null, /** Latest known Fuliza outstanding (owed) — null when never seen in SMS. */ val fulizaOutstanding: Double? = null, - /** Latest known available Fuliza limit (borrowable) — null when never seen. */ + /** Headroom still borrowable now = ceiling − current outstanding. */ val fulizaAvailable: Double? = null, + /** Total Fuliza ceiling (limit), independent of what's currently drawn. */ + val fulizaCeiling: Double? = null, /** Timestamp of the SMS the outstanding figure came from — drives the "as of" caption. */ val fulizaOutstandingAt: Long? = null, ) @@ -178,7 +180,14 @@ class HomeViewModel @Inject constructor( fulizaOutstandingAt = tx?.timestamp, ) }.combine(transactionRepository.getLatestFulizaLimit()) { state, tx -> - state.copy(fulizaAvailable = tx?.fulizaLimit) + // M-PESA's "Available Fuliza limit" is the headroom remaining AT THAT + // SMS (ceiling − outstanding then). So ceiling = that reading + the + // outstanding it was reported alongside (0 after a full clear). The + // amount borrowable NOW = ceiling − current outstanding. + val ceiling = tx?.let { (it.fulizaLimit ?: 0.0) + (it.fulizaOutstanding ?: 0.0) } + val outstanding = state.fulizaOutstanding ?: 0.0 + val available = ceiling?.let { (it - outstanding).coerceAtLeast(0.0) } + state.copy(fulizaAvailable = available, fulizaCeiling = ceiling) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HomeUiState()) fun selectPeriod(period: Period) {