From 88f93520ec7b7be7348da3265bdd9b83abbd9c81 Mon Sep 17 00:00:00 2001 From: ngenohkevin Date: Mon, 8 Jun 2026 08:19:47 +0300 Subject: [PATCH] v1.4.3: merge split Fuliza SMS + import progress M-PESA now splits a Fuliza-funded transaction into two SMS sharing one transaction code: the payment and a Fuliza companion carrying the outstanding/limit. The unique index on transactionCode silently dropped the companion (OnConflictStrategy.IGNORE), so outstanding read 0 while the user actually owed money. insertTransaction now merges a same-code companion onto the payment row (either arrival order); reparse preserves the merged Fuliza fields since the payment's own rawSms lacks them. Also: parse the 'Access Fee charged' phrasing as the Fuliza fee, and add a running spinner + 'Importing X of Y' progress to Import SMS history (previously it ran with no feedback). --- app/build.gradle.kts | 4 +- .../ledga/app/data/parser/MpesaSmsParser.kt | 5 +- .../data/repository/TransactionRepository.kt | 74 ++++++++++++++++++- .../app/ui/settings/SettingsViewModel.kt | 22 +++++- .../java/com/ledga/app/ui/you/YouScreen.kt | 28 ++++++- .../ledga/app/parser/MpesaSmsParserTest.kt | 24 ++++++ 6 files changed, 146 insertions(+), 11 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3e5c7c6..1088e1c 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 = 12 - versionName = "1.4.2" + versionCode = 13 + versionName = "1.4.3" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } 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 f5a4bba..1d48184 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 @@ -56,8 +56,9 @@ object MpesaSmsParser { 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) private val FULIZA_LIMIT_REGEX = Regex("""(?:Available |available )Fuliza M-PESA limit is Ksh\s?([\d,]+\.\d{2})""", RegexOption.IGNORE_CASE) - // "Interest charged Ksh 10.73" — the 1% Fuliza access fee on borrows. - private val FULIZA_INTEREST_REGEX = Regex("""Interest charged,?\s*Ksh\s?([\d,]+\.\d{2})""", RegexOption.IGNORE_CASE) + // The 1% Fuliza access fee on borrows. M-PESA phrases it either + // "Interest charged Ksh 10.73" or "Access Fee charged Ksh 0.30". + private val FULIZA_INTEREST_REGEX = Regex("""(?:Interest charged|Access Fee charged),?\s*(?:of\s*)?Ksh\s?([\d,]+\.\d{2})""", RegexOption.IGNORE_CASE) fun isMpesaMessage(sender: String): Boolean { // Fuliza borrow confirmations can arrive from a dedicated "FULIZA" 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 cff94e6..6f733dd 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 @@ -55,7 +55,70 @@ class TransactionRepository @Inject constructor( timestamp = parsed.timestamp, accountId = accountId, ) - return transactionDao.insert(entity) + + // M-PESA now splits a Fuliza-funded transaction into TWO SMS that + // share one transaction code: the payment ("…sent to X…") and a + // Fuliza companion ("…Total Fuliza M-PESA outstanding amount is…"). + // The unique index on transactionCode would otherwise drop the + // second one — silently losing the outstanding/limit. Merge instead. + val existing = transactionDao.getTransactionByCode(parsed.transactionCode) + if (existing == null) { + return transactionDao.insert(entity) + } + // Same code already stored. Return existing.id when we actually merged + // new Fuliza data (so callers count it), -1 for a genuine duplicate. + return if (mergeFulizaCompanion(existing, entity)) existing.id else -1L + } + + /** + * Reconcile two same-code SMS (payment + Fuliza companion) into one row. + * The "companion" is a FULIZA-typed SMS with no recipient that carries + * the outstanding/limit; the "payment" is the real spend with a recipient. + * Works regardless of which arrived first. Returns true if a merge was + * performed (new Fuliza data folded in), false for a genuine duplicate. + */ + private suspend fun mergeFulizaCompanion(existing: TransactionEntity, incoming: TransactionEntity): Boolean { + fun isCompanion(t: TransactionEntity) = + t.type == TransactionType.FULIZA && t.recipientName == null && + (t.fulizaOutstanding != null || t.fulizaAmount != null) + + val incomingIsCompanion = isCompanion(incoming) + val existingIsCompanion = isCompanion(existing) + + return when { + // Companion arrived after the payment: enrich the payment row. + incomingIsCompanion && !existingIsCompanion -> { + // Nothing new to add if the payment already carries this. + if (existing.fulizaOutstanding != null && existing.fulizaLimit != null) return false + transactionDao.update( + existing.copy( + fulizaAmount = incoming.fulizaAmount ?: existing.fulizaAmount, + fulizaOutstanding = incoming.fulizaOutstanding ?: existing.fulizaOutstanding, + fulizaLimit = incoming.fulizaLimit ?: existing.fulizaLimit, + ) + ) + true + } + // Payment arrived after a companion: adopt the payment identity, + // keep the companion's Fuliza facts + any user-set fields. + !incomingIsCompanion && existingIsCompanion -> { + transactionDao.update( + incoming.copy( + id = existing.id, + createdAt = existing.createdAt, + categoryId = existing.categoryId ?: incoming.categoryId, + accountId = existing.accountId ?: incoming.accountId, + note = existing.note, + fulizaAmount = existing.fulizaAmount ?: incoming.fulizaAmount, + fulizaOutstanding = existing.fulizaOutstanding ?: incoming.fulizaOutstanding, + fulizaLimit = existing.fulizaLimit ?: incoming.fulizaLimit, + ) + ) + true + } + // Otherwise a genuine duplicate — leave the stored row untouched. + else -> false + } } fun getRecentTransactions(limit: Int = 10): Flow> = @@ -145,9 +208,12 @@ class TransactionRepository @Inject constructor( destinationCountry = p.destinationCountry, balance = p.balance, direction = p.direction, - fulizaAmount = p.fulizaAmount, - fulizaOutstanding = p.fulizaOutstanding, - fulizaLimit = p.fulizaLimit, + // Preserve Fuliza facts merged in from a same-code companion + // SMS — the payment's own rawSms doesn't contain them, so a + // naive reparse would wipe the outstanding/limit back to null. + fulizaAmount = p.fulizaAmount ?: entity.fulizaAmount, + fulizaOutstanding = p.fulizaOutstanding ?: entity.fulizaOutstanding, + fulizaLimit = p.fulizaLimit ?: entity.fulizaLimit, reversedTransactionCode = p.reversedTransactionCode, // Deliberately keep id, categoryId, accountId, note, createdAt. ) 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 acf5005..71a0644 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 @@ -63,6 +63,13 @@ class SettingsViewModel @Inject constructor( private val _importStatus = MutableStateFlow(null) val importStatus: StateFlow = _importStatus + private val _importRunning = MutableStateFlow(false) + val importRunning: StateFlow = _importRunning + + /** (scanned, total) while an import is in progress; null otherwise. */ + private val _importProgress = MutableStateFlow?>(null) + val importProgress: StateFlow?> = _importProgress + fun setThemeMode(mode: ThemeMode) { viewModelScope.launch { settingsRepository.setThemeMode(mode) @@ -96,9 +103,20 @@ class SettingsViewModel @Inject constructor( } fun importSmsHistory() { + if (_importRunning.value) return + _importRunning.value = true + _importStatus.value = null + _importProgress.value = 0 to 0 viewModelScope.launch { - val result = smsImporter.importHistory() - _importStatus.value = result + try { + val result = smsImporter.importHistory { scanned, total -> + _importProgress.value = scanned to total + } + _importStatus.value = result + } finally { + _importRunning.value = false + _importProgress.value = null + } } } 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 fb2c432..13909d3 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 @@ -74,6 +74,8 @@ fun YouScreen( val largeTxnAlert by viewModel.largeTxnAlertEnabled.collectAsState() val unparsedCount by viewModel.unparsedCount.collectAsState() val importStatus by viewModel.importStatus.collectAsState() + val importRunning by viewModel.importRunning.collectAsState() + val importProgress by viewModel.importProgress.collectAsState() val reparseResult by viewModel.reparseResult.collectAsState() val reparseAllResult by viewModel.reparseAllResult.collectAsState() val reparseAllRunning by viewModel.reparseAllRunning.collectAsState() @@ -190,7 +192,31 @@ fun YouScreen( // ---- Data ---- BentoCard(title = "Data", icon = Icons.Filled.Storage) { - TextButtonRow("Import SMS history") { viewModel.importSmsHistory() } + if (importRunning) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = LedgaAccent, + ) + val p = importProgress + Text( + text = if (p != null && p.second > 0) + "Importing… ${p.first} of ${p.second}" + else "Importing SMS history…", + style = LedgaText.BodyL, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } else { + TextButtonRow("Import SMS history") { viewModel.importSmsHistory() } + } importStatus?.let { StatusLine("Imported ${it.imported} of ${it.total} messages") } diff --git a/app/src/test/java/com/ledga/app/parser/MpesaSmsParserTest.kt b/app/src/test/java/com/ledga/app/parser/MpesaSmsParserTest.kt index 8387c23..e3655b2 100644 --- a/app/src/test/java/com/ledga/app/parser/MpesaSmsParserTest.kt +++ b/app/src/test/java/com/ledga/app/parser/MpesaSmsParserTest.kt @@ -319,6 +319,30 @@ class MpesaSmsParserTest { assertEquals(1699.70, t.fulizaOutstanding!!, 0.01) } + @Test + fun `split fuliza companion parses outstanding and access fee`() { + // M-PESA's post-2026-06 format: a standalone companion SMS sharing the + // payment's code, carrying the outstanding + access fee. + val sms = "UF7IE77R23 Confirmed. Fuliza M-PESA amount is Ksh 30.00. Access Fee charged Ksh 0.30. Total Fuliza M-PESA outstanding amount is Ksh3249.74 due on 07/07/26. To check daily charges, Dial *334#OK Select Query Charges" + val t = assertSuccess(sms).transaction + assertEquals(TransactionType.FULIZA, t.type) + assertEquals(30.0, t.amount, 0.01) + assertEquals(0.30, t.transactionCost, 0.01) + assertEquals(3249.74, t.fulizaOutstanding!!, 0.01) + // No recipient in the companion — this is what marks it as a companion + // (vs the payment SMS) for the merge step. + assertEquals(null, t.recipientName) + } + + @Test + fun `clean payment SMS carries no fuliza fields`() { + val sms = "UF7IE77R23 Confirmed. Ksh30.00 sent to Kaps Parking Rupa Mall for account 120850178 on 7/6/26 at 8:59 PM New M-PESA balance is Ksh0.00. Transaction cost, Ksh0.00.Amount you can transact within the day is 488,636.33." + val t = assertSuccess(sms).transaction + assertEquals(30.0, t.amount, 0.01) + assertEquals(null, t.fulizaOutstanding) + assertEquals(TransactionDirection.OUTFLOW, t.direction) + } + @Test fun `fuliza sender id is accepted`() { assertTrue(MpesaSmsParser.isMpesaMessage("FULIZA"))