diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 99c9f6d..32e4bab 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 = 6 - versionName = "1.3.3" + versionCode = 7 + versionName = "1.3.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 ad5ba42..071ce9a 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 @@ -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 + @Query("UPDATE transactions SET categoryId = :categoryId WHERE id = :id") suspend fun updateCategory(id: Long, categoryId: Long?) diff --git a/app/src/main/java/com/ledga/app/data/parser/MpesaSmsParser.kt b/app/src/main/java/com/ledga/app/data/parser/MpesaSmsParser.kt index d9d9b51..735c129 100644 --- a/app/src/main/java/com/ledga/app/data/parser/MpesaSmsParser.kt +++ b/app/src/main/java/com/ledga/app/data/parser/MpesaSmsParser.kt @@ -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 ) } diff --git a/app/src/main/java/com/ledga/app/data/repository/TransactionRepository.kt b/app/src/main/java/com/ledga/app/data/repository/TransactionRepository.kt index 7fd3d1b..7b067d9 100644 --- a/app/src/main/java/com/ledga/app/data/repository/TransactionRepository.kt +++ b/app/src/main/java/com/ledga/app/data/repository/TransactionRepository.kt @@ -109,6 +109,45 @@ class TransactionRepository @Inject constructor( suspend fun bulkAttributeByCodes(accountId: Long, codes: List): 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 diff --git a/app/src/main/java/com/ledga/app/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/ledga/app/ui/settings/SettingsViewModel.kt index 1555ecc..057a2c0 100644 --- a/app/src/main/java/com/ledga/app/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/ledga/app/ui/settings/SettingsViewModel.kt @@ -109,6 +109,16 @@ class SettingsViewModel @Inject constructor( } } + private val _reparseAllResult = MutableStateFlow(null) + val reparseAllResult: StateFlow = _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(null) val exportResult: StateFlow = _exportResult diff --git a/app/src/main/java/com/ledga/app/ui/you/YouScreen.kt b/app/src/main/java/com/ledga/app/ui/you/YouScreen.kt index cc5f9ec..7a98e47 100644 --- a/app/src/main/java/com/ledga/app/ui/you/YouScreen.kt +++ b/app/src/main/java/com/ledga/app/ui/you/YouScreen.kt @@ -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 ----