v1.3.4: fix Fuliza auto-pay balance + outstanding + reparse-all

Two parser bugs around Fuliza auto-pay that produced wrong balance and
phantom-outstanding alerts:

1. BALANCE_REGEX required the literal "Ksh" before the amount, but the
   full-clear auto-pay SMS ends "Your M-PESA balance is 8581.08." with
   no Ksh prefix. Balance was lost → stored as 0 → Home's
   getLatestTransactionWithBalance filter (balance > 0) fell back to a
   stale older transaction. Made the Ksh optional.

2. parseFulizaAutoPay was stuffing the *available Fuliza limit* into
   fulizaOutstanding. Those are opposites (available = ceiling - owed).
   After a full clear that meant FulizaRule saw the full 14k limit and
   raised "You owe Ksh 14,000" — incorrect. Now:
     - full clear  -> fulizaOutstanding = 0   (rule correctly bails)
     - partial     -> fulizaOutstanding = null (rule defers to the
                                                previous Fuliza tx;
                                                avoids reporting the
                                                available-limit value)

Reparse all transactions
- New "Re-parse ALL transactions" entry in You → Data. Walks every
  stored row through MpesaSmsParser and updates parser-derived fields
  in place, preserving user fields (categoryId, accountId, note, id).
- Needed because the broken regex above silently corrupted historical
  rows on import; this is the no-data-loss path back to correctness.
This commit is contained in:
ngenohkevin
2026-05-16 21:56:10 +03:00
parent c5be379c8e
commit 6571cd0497
6 changed files with 81 additions and 6 deletions
+2 -2
View File
@@ -27,8 +27,8 @@ android {
applicationId = "com.ledga.app"
minSdk = 26
targetSdk = 34
versionCode = 6
versionName = "1.3.3"
versionCode = 7
versionName = "1.3.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -37,6 +37,12 @@ interface TransactionDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(transaction: TransactionEntity): Long
@androidx.room.Update
suspend fun update(transaction: TransactionEntity)
@Query("SELECT * FROM transactions ORDER BY timestamp DESC")
suspend fun getAllSync(): List<TransactionEntity>
@Query("UPDATE transactions SET categoryId = :categoryId WHERE id = :id")
suspend fun updateCategory(id: Long, categoryId: Long?)
@@ -43,8 +43,13 @@ object MpesaSmsParser {
private val TRANSACTION_CODE_REGEX = Regex("""^([A-Z0-9]{10})\s""")
// Date: handles both 2-digit and 4-digit year
private val DATE_REGEX = Regex("""on\s+(\d{1,2}/\d{1,2}/\d{2,4}\s+at\s+\d{1,2}:\d{2}\s+[AP]M)""", RegexOption.IGNORE_CASE)
// Balance: multiple formats
private val BALANCE_REGEX = Regex("""(?:M-PESA balance is|account balance is|M-PESA balance is|MPESA balance is)\s*Ksh\s?([\d,]+\.\d{2})""", RegexOption.IGNORE_CASE)
// Balance: multiple formats — note "Ksh" is optional. Fuliza auto-pay SMS
// reads "Your M-PESA balance is 8581.08" with no Ksh prefix on some
// ROMs / carrier formats, and we still need to capture it.
private val BALANCE_REGEX = Regex(
"""(?:M-PESA balance is|MPESA balance is|account balance is)\s*(?:Ksh)?\s?([\d,]+\.\d{2})""",
RegexOption.IGNORE_CASE,
)
private val COST_REGEX = Regex("""Transaction cost,?\s*Ksh\s?([\d,]+\.\d{2})""", RegexOption.IGNORE_CASE)
private val FULIZA_AMOUNT_REGEX = Regex("""Fuliza M-PESA amount is Ksh\s?([\d,]+\.\d{2})""", RegexOption.IGNORE_CASE)
private val FULIZA_OUTSTANDING_REGEX = Regex("""(?:Fuliza M-PESA outstanding amount is|Total Fuliza M-PESA outstanding amount is)\s*Ksh\s?([\d,]+\.\d{2})""", RegexOption.IGNORE_CASE)
@@ -398,9 +403,16 @@ object MpesaSmsParser {
// "Ksh X from your M-PESA has been used to fully/partially pay your outstanding Fuliza"
// Fuliza is M-Pesa's overdraft loan; when fresh money lands, the wallet
// auto-deducts to repay it.
//
// Note: the SMS exposes the *available* Fuliza limit (e.g. how much you
// can still borrow), NOT the remaining outstanding. For a full clear the
// outstanding is unambiguously 0. For a partial clear we don't know the
// user's maximum Fuliza limit from this SMS alone, so we leave it null —
// FulizaRule will then defer to the previous outstanding-bearing
// transaction instead of misreporting the available-limit value as
// "you owe X".
private fun parseFulizaAutoPay(sms: String, code: String, balance: Double, timestamp: Long): ParsedTransaction {
val amount = extractFirstAmount(sms) ?: 0.0
val fulizaLimit = FULIZA_LIMIT_REGEX.find(sms)?.groupValues?.get(1)?.let { parseAmount(it) }
val isFullPay = sms.contains("fully pay", ignoreCase = true)
return ParsedTransaction(
@@ -410,7 +422,8 @@ object MpesaSmsParser {
else "Fuliza overdraft (auto-partial)",
recipientPhone = null, accountNumber = null, destinationCountry = null,
balance = balance, direction = TransactionDirection.OUTFLOW,
fulizaAmount = null, fulizaOutstanding = fulizaLimit,
fulizaAmount = null,
fulizaOutstanding = if (isFullPay) 0.0 else null,
reversedTransactionCode = null, timestamp = timestamp, rawSms = sms
)
}
@@ -109,6 +109,45 @@ class TransactionRepository @Inject constructor(
suspend fun bulkAttributeByCodes(accountId: Long, codes: List<String>): Int =
if (codes.isEmpty()) 0 else transactionDao.updateAccountForCodes(accountId, codes)
/**
* Re-runs [MpesaSmsParser] over every stored transaction's rawSms and
* updates parser-derived fields in place. User-managed fields
* (categoryId, accountId, note, id, createdAt) are preserved. Use when a
* parser fix has landed and you want existing rows to inherit it without
* a destructive clear-and-reimport.
*/
suspend fun reparseAllTransactions(): ReparseResult {
val all = transactionDao.getAllSync()
var fixed = 0
var stillUnknown = 0
for (entity in all) {
val result = com.ledga.app.data.parser.MpesaSmsParser.parse(entity.rawSms, entity.timestamp)
if (result !is com.ledga.app.data.parser.ParseResult.Success) {
stillUnknown++
continue
}
val p = result.transaction
val updated = entity.copy(
type = p.type,
amount = p.amount,
transactionCost = p.transactionCost,
recipientName = p.recipientName,
recipientPhone = p.recipientPhone,
accountNumber = p.accountNumber,
destinationCountry = p.destinationCountry,
balance = p.balance,
direction = p.direction,
fulizaAmount = p.fulizaAmount,
fulizaOutstanding = p.fulizaOutstanding,
reversedTransactionCode = p.reversedTransactionCode,
// Deliberately keep id, categoryId, accountId, note, createdAt.
)
transactionDao.update(updated)
fixed++
}
return ReparseResult(total = all.size, fixed = fixed, stillUnknown = stillUnknown)
}
suspend fun reparseUnknownTransactions(): ReparseResult {
val unknowns = transactionDao.getUnparsedSync()
var fixed = 0
@@ -109,6 +109,16 @@ class SettingsViewModel @Inject constructor(
}
}
private val _reparseAllResult = MutableStateFlow<ReparseResult?>(null)
val reparseAllResult: StateFlow<ReparseResult?> = _reparseAllResult
/** Re-runs MpesaSmsParser over every stored row to pick up parser fixes. */
fun reparseAll() {
viewModelScope.launch {
_reparseAllResult.value = transactionRepository.reparseAllTransactions()
}
}
private val _exportResult = MutableStateFlow<ExportResult?>(null)
val exportResult: StateFlow<ExportResult?> = _exportResult
@@ -75,6 +75,7 @@ fun YouScreen(
val unparsedCount by viewModel.unparsedCount.collectAsState()
val importStatus by viewModel.importStatus.collectAsState()
val reparseResult by viewModel.reparseResult.collectAsState()
val reparseAllResult by viewModel.reparseAllResult.collectAsState()
val exportResult by viewModel.exportResult.collectAsState()
val fileImportResult by viewModel.fileImportResult.collectAsState()
val backupStatus by viewModel.backupStatus.collectAsState()
@@ -215,6 +216,12 @@ fun YouScreen(
}
TextButtonRow("View unparsed messages", onNavigateToUnparsed)
}
TextButtonRow("Re-parse ALL transactions") {
viewModel.reparseAll()
}
reparseAllResult?.let {
StatusLine("Re-parsed ${it.fixed} of ${it.total} (${it.stillUnknown} skipped)")
}
}
// ---- Backup ----