v1.4.4: correct Fuliza available-to-borrow + real zero balance

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.
This commit is contained in:
ngenohkevin
2026-06-08 08:52:54 +03:00
parent 88f93520ec
commit 20fe7ba5fd
4 changed files with 35 additions and 9 deletions
+2 -2
View File
@@ -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"
}
@@ -102,10 +102,18 @@ interface TransactionDao {
""")
fun getLatestTransaction(accountId: Long? = null): Flow<TransactionEntity?>
/**
* 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<TransactionEntity?>
@@ -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()
@@ -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) {