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