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