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