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).
This commit is contained in:
ngenohkevin
2026-06-08 08:19:47 +03:00
parent 6f88574165
commit 88f93520ec
6 changed files with 146 additions and 11 deletions
+2 -2
View File
@@ -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"
}
@@ -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"
@@ -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<List<TransactionWithCategory>> =
@@ -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.
)
@@ -63,6 +63,13 @@ class SettingsViewModel @Inject constructor(
private val _importStatus = MutableStateFlow<ImportResult?>(null)
val importStatus: StateFlow<ImportResult?> = _importStatus
private val _importRunning = MutableStateFlow(false)
val importRunning: StateFlow<Boolean> = _importRunning
/** (scanned, total) while an import is in progress; null otherwise. */
private val _importProgress = MutableStateFlow<Pair<Int, Int>?>(null)
val importProgress: StateFlow<Pair<Int, Int>?> = _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
}
}
}
@@ -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")
}
@@ -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"))