From 4e58bd8a8bfd30fdfb555a32d2e9e4426aebb263 Mon Sep 17 00:00:00 2001 From: ngenohkevin Date: Sun, 7 Jun 2026 15:57:40 +0300 Subject: [PATCH] v1.4.1: own-account transfers + Fuliza limit tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marking a recipient as 'my own account' reclassifies its transactions into a new transfer category excluded from all spending figures — bank/card top-ups are moved money, not spending. Fuliza coverage: accept the dedicated FULIZA sender id in the receiver and both importers (borrows were going untracked while repayments were captured), capture the available Fuliza limit from any SMS carrying it, record borrow interest as a fee, and surface an outstanding/available card on Home. DB v3->v4: additive ALTER TABLEs only (categories.isTransfer, transactions.fulizaLimit) + idempotent seed of the My Accounts category. --- app/build.gradle.kts | 4 +- .../java/com/ledga/app/data/db/AppDatabase.kt | 2 +- .../java/com/ledga/app/data/db/DefaultData.kt | 1 + .../java/com/ledga/app/data/db/Migrations.kt | 23 +++++ .../com/ledga/app/data/db/dao/CategoryDao.kt | 3 + .../ledga/app/data/db/dao/CategoryRuleDao.kt | 14 +++ .../ledga/app/data/db/dao/TransactionDao.kt | 45 ++++++++++ .../com/ledga/app/data/db/entity/Category.kt | 10 ++- .../app/data/db/entity/TransactionEntity.kt | 2 + .../ledga/app/data/parser/MpesaSmsParser.kt | 25 +++++- .../data/repository/ExportImportRepository.kt | 5 +- .../HistoricalBackfillRepository.kt | 5 +- .../ledga/app/data/repository/SmsImporter.kt | 5 +- .../data/repository/TransactionRepository.kt | 85 +++++++++++++++++-- .../java/com/ledga/app/di/DatabaseModule.kt | 6 +- .../java/com/ledga/app/ui/home/HomeScreen.kt | 67 +++++++++++++++ .../com/ledga/app/ui/home/HomeViewModel.kt | 8 ++ .../ui/transactions/TransactionDetailSheet.kt | 37 ++++++++ .../app/ui/transactions/TransactionsScreen.kt | 9 ++ .../ui/transactions/TransactionsViewModel.kt | 27 +++++- .../ledga/app/parser/MpesaSmsParserTest.kt | 34 ++++++++ 21 files changed, 394 insertions(+), 23 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b3b954e..b2a4199 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 = 10 - versionName = "1.4.0" + versionCode = 11 + versionName = "1.4.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/com/ledga/app/data/db/AppDatabase.kt b/app/src/main/java/com/ledga/app/data/db/AppDatabase.kt index 9fae8e0..d612070 100644 --- a/app/src/main/java/com/ledga/app/data/db/AppDatabase.kt +++ b/app/src/main/java/com/ledga/app/data/db/AppDatabase.kt @@ -30,7 +30,7 @@ import com.ledga.app.data.db.entity.TransactionEntity GoalContribution::class, Insight::class, ], - version = 3, + version = 4, exportSchema = true ) @TypeConverters(Converters::class) diff --git a/app/src/main/java/com/ledga/app/data/db/DefaultData.kt b/app/src/main/java/com/ledga/app/data/db/DefaultData.kt index 087bc6b..d83e94f 100644 --- a/app/src/main/java/com/ledga/app/data/db/DefaultData.kt +++ b/app/src/main/java/com/ledga/app/data/db/DefaultData.kt @@ -20,6 +20,7 @@ object DefaultData { Category(id = 11, name = "International", icon = "public", color = "#3F51B5"), Category(id = 12, name = "Savings & Loans", icon = "account_balance", color = "#009688"), Category(id = 13, name = "Other", icon = "category", color = "#9E9E9E"), + Category(id = 14, name = "My Accounts", icon = "swap_horiz", color = "#78909C", isTransfer = true), ) val DEFAULT_RULES = listOf( diff --git a/app/src/main/java/com/ledga/app/data/db/Migrations.kt b/app/src/main/java/com/ledga/app/data/db/Migrations.kt index 4382a8a..d01b808 100644 --- a/app/src/main/java/com/ledga/app/data/db/Migrations.kt +++ b/app/src/main/java/com/ledga/app/data/db/Migrations.kt @@ -15,6 +15,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase * accountId (FK → mpesa_accounts.id) and note columns. * 3 - Add insights table (Phase C — rule-engine derived suggestions, * with natural-key dedup + dismiss/snooze state). + * 4 - categories gains isTransfer (own-account transfer categories, + * excluded from spending) + seed the "My Accounts" category; + * transactions gains fulizaLimit (available Fuliza limit from SMS). */ object Migrations { @@ -181,4 +184,24 @@ object Migrations { ) } } + + val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + // Both changes are additive — no table rebuild, no FK risk. + db.execSQL( + "ALTER TABLE `categories` ADD COLUMN `isTransfer` INTEGER NOT NULL DEFAULT 0" + ) + db.execSQL( + "ALTER TABLE `transactions` ADD COLUMN `fulizaLimit` REAL" + ) + // Seed the transfer category for existing installs. New installs + // get it from DefaultData via the onCreate callback. + db.execSQL( + """ + INSERT OR IGNORE INTO `categories` (`id`, `name`, `icon`, `color`, `isDefault`, `isTransfer`) + VALUES (14, 'My Accounts', 'swap_horiz', '#78909C', 1, 1) + """.trimIndent() + ) + } + } } diff --git a/app/src/main/java/com/ledga/app/data/db/dao/CategoryDao.kt b/app/src/main/java/com/ledga/app/data/db/dao/CategoryDao.kt index b9b9943..b8e8831 100644 --- a/app/src/main/java/com/ledga/app/data/db/dao/CategoryDao.kt +++ b/app/src/main/java/com/ledga/app/data/db/dao/CategoryDao.kt @@ -16,6 +16,9 @@ interface CategoryDao { @Query("SELECT * FROM categories WHERE id = :id") suspend fun getCategoryById(id: Long): Category? + @Query("SELECT * FROM categories WHERE isTransfer = 1 LIMIT 1") + suspend fun getTransferCategory(): Category? + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(categories: List) } diff --git a/app/src/main/java/com/ledga/app/data/db/dao/CategoryRuleDao.kt b/app/src/main/java/com/ledga/app/data/db/dao/CategoryRuleDao.kt index 0fc895e..9b56bfa 100644 --- a/app/src/main/java/com/ledga/app/data/db/dao/CategoryRuleDao.kt +++ b/app/src/main/java/com/ledga/app/data/db/dao/CategoryRuleDao.kt @@ -21,4 +21,18 @@ interface CategoryRuleDao { @Query("SELECT * FROM category_rules") suspend fun getAllRulesSync(): List + + @Query(""" + DELETE FROM category_rules + WHERE categoryId = :categoryId AND matchValue = :matchValue COLLATE NOCASE + """) + suspend fun deleteByCategoryAndValue(categoryId: Long, matchValue: String) + + /** Recipient fragments the user marked as their own accounts. */ + @Query(""" + SELECT cr.matchValue FROM category_rules cr + JOIN categories c ON c.id = cr.categoryId + WHERE c.isTransfer = 1 AND cr.matchType = 'RECIPIENT_NAME' + """) + fun getOwnAccountFragments(): Flow> } 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 cb58519..fceb04b 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 @@ -126,6 +126,9 @@ interface TransactionDao { AND timestamp BETWEEN :startTime AND :endTime AND (:accountId IS NULL OR accountId = :accountId) AND type NOT IN ('FULIZA_REPAYMENT', 'FULIZA_AUTO_PAY', 'MSHWARI', 'KCB_MPESA') + AND (categoryId IS NULL OR categoryId NOT IN ( + SELECT id FROM categories WHERE isTransfer = 1 + )) AND transactionCode NOT IN ( SELECT reversedTransactionCode FROM transactions WHERE reversedTransactionCode IS NOT NULL @@ -140,6 +143,9 @@ interface TransactionDao { AND timestamp BETWEEN :startTime AND :endTime AND (:accountId IS NULL OR accountId = :accountId) AND type NOT IN ('FULIZA_REPAYMENT', 'FULIZA_AUTO_PAY', 'MSHWARI', 'KCB_MPESA') + AND (categoryId IS NULL OR categoryId NOT IN ( + SELECT id FROM categories WHERE isTransfer = 1 + )) AND transactionCode NOT IN ( SELECT reversedTransactionCode FROM transactions WHERE reversedTransactionCode IS NOT NULL @@ -186,6 +192,9 @@ interface TransactionDao { AND timestamp BETWEEN :startTime AND :endTime AND (:accountId IS NULL OR accountId = :accountId) AND type NOT IN ('FULIZA_REPAYMENT', 'FULIZA_AUTO_PAY', 'MSHWARI', 'KCB_MPESA') + AND (categoryId IS NULL OR categoryId NOT IN ( + SELECT id FROM categories WHERE isTransfer = 1 + )) AND transactionCode NOT IN ( SELECT reversedTransactionCode FROM transactions WHERE reversedTransactionCode IS NOT NULL @@ -202,6 +211,9 @@ interface TransactionDao { AND timestamp BETWEEN :startTime AND :endTime AND (:accountId IS NULL OR accountId = :accountId) AND type NOT IN ('FULIZA_REPAYMENT', 'FULIZA_AUTO_PAY', 'MSHWARI', 'KCB_MPESA') + AND (categoryId IS NULL OR categoryId NOT IN ( + SELECT id FROM categories WHERE isTransfer = 1 + )) AND transactionCode NOT IN ( SELECT reversedTransactionCode FROM transactions WHERE reversedTransactionCode IS NOT NULL @@ -229,6 +241,9 @@ interface TransactionDao { WHERE direction = 'OUTFLOW' AND (:accountId IS NULL OR accountId = :accountId) AND type NOT IN ('FULIZA_REPAYMENT', 'FULIZA_AUTO_PAY', 'MSHWARI', 'KCB_MPESA') + AND (categoryId IS NULL OR categoryId NOT IN ( + SELECT id FROM categories WHERE isTransfer = 1 + )) AND transactionCode NOT IN ( SELECT reversedTransactionCode FROM transactions WHERE reversedTransactionCode IS NOT NULL @@ -297,4 +312,34 @@ interface TransactionDao { @Query("DELETE FROM transactions WHERE id = :id") suspend fun deleteById(id: Long) + + // ---- Fuliza status (latest known values from SMS) ---- + + @Query(""" + SELECT * FROM transactions + WHERE fulizaLimit IS NOT NULL + ORDER BY timestamp DESC LIMIT 1 + """) + fun getLatestFulizaLimit(): Flow + + @Query(""" + SELECT * FROM transactions + WHERE fulizaOutstanding IS NOT NULL + ORDER BY timestamp DESC LIMIT 1 + """) + fun getLatestFulizaOutstanding(): Flow + + // ---- Own-account (transfer) recipient marking ---- + + @Query(""" + UPDATE transactions SET categoryId = :categoryId + WHERE recipientName LIKE '%' || :fragment || '%' COLLATE NOCASE + """) + suspend fun updateCategoryForRecipientFragment(fragment: String, categoryId: Long?): Int + + @Query(""" + SELECT * FROM transactions + WHERE recipientName LIKE '%' || :fragment || '%' COLLATE NOCASE + """) + suspend fun getByRecipientFragmentSync(fragment: String): List } diff --git a/app/src/main/java/com/ledga/app/data/db/entity/Category.kt b/app/src/main/java/com/ledga/app/data/db/entity/Category.kt index 3203b07..c72209b 100644 --- a/app/src/main/java/com/ledga/app/data/db/entity/Category.kt +++ b/app/src/main/java/com/ledga/app/data/db/entity/Category.kt @@ -1,5 +1,6 @@ package com.ledga.app.data.db.entity +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey @@ -10,5 +11,12 @@ data class Category( val name: String, val icon: String, val color: String, - val isDefault: Boolean = true + val isDefault: Boolean = true, + /** + * Transfer categories hold movements between the user's own accounts + * (bank, M-PESA card, …). Spending queries exclude them — moving your + * own money is not spending. + */ + @ColumnInfo(defaultValue = "0") + val isTransfer: Boolean = false ) diff --git a/app/src/main/java/com/ledga/app/data/db/entity/TransactionEntity.kt b/app/src/main/java/com/ledga/app/data/db/entity/TransactionEntity.kt index 9b3d8d2..bc6abc8 100644 --- a/app/src/main/java/com/ledga/app/data/db/entity/TransactionEntity.kt +++ b/app/src/main/java/com/ledga/app/data/db/entity/TransactionEntity.kt @@ -48,6 +48,8 @@ data class TransactionEntity( val categoryId: Long?, val fulizaAmount: Double?, val fulizaOutstanding: Double?, + /** "Available Fuliza M-PESA limit is Ksh X" — how much can still be borrowed. */ + val fulizaLimit: Double? = null, val reversedTransactionCode: String?, val rawSms: String, val timestamp: 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 da2e223..f5a4bba 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 @@ -27,7 +27,9 @@ data class ParsedTransaction( val fulizaOutstanding: Double?, val reversedTransactionCode: String?, val timestamp: Long, - val rawSms: String + val rawSms: String, + /** "Available Fuliza M-PESA limit is Ksh X" — set post-hoc in [MpesaSmsParser.parse]. */ + val fulizaLimit: Double? = null ) sealed interface ParseResult { @@ -54,9 +56,15 @@ 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) fun isMpesaMessage(sender: String): Boolean { - return sender.equals("MPESA", ignoreCase = true) + // Fuliza borrow confirmations can arrive from a dedicated "FULIZA" + // sender id rather than "MPESA" — without it, borrows go untracked + // while their repayments (sent from MPESA) are captured. + val s = sender.trim() + return s.equals("MPESA", ignoreCase = true) || s.equals("FULIZA", ignoreCase = true) } fun parse(smsBody: String, smsTimestamp: Long = System.currentTimeMillis()): ParseResult { @@ -114,7 +122,13 @@ object MpesaSmsParser { isSendMoney(sms) -> parseSendMoney(sms, code, balance, cost, timestamp) else -> parseUnknown(sms, code, balance, cost, timestamp) } - ParseResult.Success(transaction) + // The available Fuliza limit can ride along on any SMS type + // (auto-pay, borrow, even regular payments) — attach it post-hoc + // so every parser benefits without threading it through each one. + val fulizaLimit = FULIZA_LIMIT_REGEX.find(sms)?.groupValues?.get(1)?.let { parseAmount(it) } + ParseResult.Success( + if (fulizaLimit != null) transaction.copy(fulizaLimit = fulizaLimit) else transaction + ) } catch (e: Exception) { ParseResult.Failure(smsBody, "Parse error: ${e.message}") } @@ -385,6 +399,9 @@ object MpesaSmsParser { val amount = extractFirstAmount(sms) ?: 0.0 val fulizaAmt = FULIZA_AMOUNT_REGEX.find(sms)?.groupValues?.get(1)?.let { parseAmount(it) } val fulizaOut = FULIZA_OUTSTANDING_REGEX.find(sms)?.groupValues?.get(1)?.let { parseAmount(it) } + // The access fee ("Interest charged") is a real cost — fold it into + // transactionCost so the fees figures stop understating Fuliza. + val interest = FULIZA_INTEREST_REGEX.find(sms)?.groupValues?.get(1)?.let { parseAmount(it) } ?: 0.0 val sendToRegex = Regex("""sent to\s+(.+?)\s+(\d{10,})\s+on""", RegexOption.IGNORE_CASE) val match = sendToRegex.find(sms) @@ -392,7 +409,7 @@ object MpesaSmsParser { return ParsedTransaction( transactionCode = code, type = TransactionType.FULIZA, amount = fulizaAmt ?: amount, - transactionCost = cost, recipientName = match?.groupValues?.get(1)?.trim(), + transactionCost = cost + interest, recipientName = match?.groupValues?.get(1)?.trim(), recipientPhone = match?.groupValues?.get(2), accountNumber = null, destinationCountry = null, balance = balance, direction = TransactionDirection.OUTFLOW, fulizaAmount = fulizaAmt, fulizaOutstanding = fulizaOut, diff --git a/app/src/main/java/com/ledga/app/data/repository/ExportImportRepository.kt b/app/src/main/java/com/ledga/app/data/repository/ExportImportRepository.kt index 60bbafd..d650471 100644 --- a/app/src/main/java/com/ledga/app/data/repository/ExportImportRepository.kt +++ b/app/src/main/java/com/ledga/app/data/repository/ExportImportRepository.kt @@ -38,6 +38,7 @@ data class ExportTransaction( val categoryId: Long?, val fulizaAmount: Double?, val fulizaOutstanding: Double?, + val fulizaLimit: Double? = null, val reversedTransactionCode: String?, val rawSms: String, val timestamp: Long @@ -129,6 +130,7 @@ class ExportImportRepository @Inject constructor( destinationCountry = destinationCountry, balance = balance, direction = direction.name, categoryId = categoryId, fulizaAmount = fulizaAmount, fulizaOutstanding = fulizaOutstanding, + fulizaLimit = fulizaLimit, reversedTransactionCode = reversedTransactionCode, rawSms = rawSms, timestamp = timestamp ) @@ -142,7 +144,8 @@ class ExportImportRepository @Inject constructor( balance = balance, direction = try { TransactionDirection.valueOf(direction) } catch (e: Exception) { TransactionDirection.OUTFLOW }, categoryId = categoryId, fulizaAmount = fulizaAmount, - fulizaOutstanding = fulizaOutstanding, reversedTransactionCode = reversedTransactionCode, + fulizaOutstanding = fulizaOutstanding, fulizaLimit = fulizaLimit, + reversedTransactionCode = reversedTransactionCode, rawSms = rawSms, timestamp = timestamp ) diff --git a/app/src/main/java/com/ledga/app/data/repository/HistoricalBackfillRepository.kt b/app/src/main/java/com/ledga/app/data/repository/HistoricalBackfillRepository.kt index 3955e73..3744449 100644 --- a/app/src/main/java/com/ledga/app/data/repository/HistoricalBackfillRepository.kt +++ b/app/src/main/java/com/ledga/app/data/repository/HistoricalBackfillRepository.kt @@ -122,8 +122,9 @@ class HistoricalBackfillRepository @Inject constructor( val cursor: Cursor? = contentResolver.query( uri, projection, - "address = ?", - arrayOf("MPESA"), + // FULIZA: borrow confirmations can arrive from a dedicated sender id. + "address IN (?, ?)", + arrayOf("MPESA", "FULIZA"), "date DESC", ) cursor?.use { c -> diff --git a/app/src/main/java/com/ledga/app/data/repository/SmsImporter.kt b/app/src/main/java/com/ledga/app/data/repository/SmsImporter.kt index 64364b3..d416a07 100644 --- a/app/src/main/java/com/ledga/app/data/repository/SmsImporter.kt +++ b/app/src/main/java/com/ledga/app/data/repository/SmsImporter.kt @@ -49,8 +49,9 @@ class SmsImporter @Inject constructor( val cursor = contentResolver.query( uri, arrayOf("body", "date", "address"), - "address = ?", - arrayOf("MPESA"), + // FULIZA: borrow confirmations can arrive from a dedicated sender id. + "address IN (?, ?)", + arrayOf("MPESA", "FULIZA"), "date DESC" ) 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 703eb3e..cff94e6 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 @@ -1,11 +1,13 @@ package com.ledga.app.data.repository +import com.ledga.app.data.db.dao.CategoryDao import com.ledga.app.data.db.dao.CategoryRuleDao import com.ledga.app.data.db.dao.CategorySpending import com.ledga.app.data.db.dao.DailySpending import com.ledga.app.data.db.dao.MonthlySpending import com.ledga.app.data.db.dao.TopMerchant import com.ledga.app.data.db.dao.TransactionDao +import com.ledga.app.data.db.entity.CategoryRule import com.ledga.app.data.db.entity.MatchType import com.ledga.app.data.db.entity.TransactionEntity import com.ledga.app.data.db.entity.TransactionWithCategory @@ -22,6 +24,7 @@ import javax.inject.Singleton class TransactionRepository @Inject constructor( private val transactionDao: TransactionDao, private val categoryRuleDao: CategoryRuleDao, + private val categoryDao: CategoryDao, private val settingsRepository: SettingsRepository, ) { /** @@ -46,6 +49,7 @@ class TransactionRepository @Inject constructor( categoryId = categoryId, fulizaAmount = parsed.fulizaAmount, fulizaOutstanding = parsed.fulizaOutstanding, + fulizaLimit = parsed.fulizaLimit, reversedTransactionCode = parsed.reversedTransactionCode, rawSms = parsed.rawSms, timestamp = parsed.timestamp, @@ -143,6 +147,7 @@ class TransactionRepository @Inject constructor( direction = p.direction, fulizaAmount = p.fulizaAmount, fulizaOutstanding = p.fulizaOutstanding, + fulizaLimit = p.fulizaLimit, reversedTransactionCode = p.reversedTransactionCode, // Deliberately keep id, categoryId, accountId, note, createdAt. ) @@ -183,9 +188,77 @@ class TransactionRepository @Inject constructor( return ReparseResult(total = unknowns.size, fixed = fixed, stillUnknown = stillUnknown) } - private suspend fun autoCategorize(parsed: ParsedTransaction): Long? { + // ---- Own-account (transfer) recipients ---- + + /** Latest known available Fuliza limit / outstanding, from any SMS that carried them. */ + fun getLatestFulizaLimit(): Flow = transactionDao.getLatestFulizaLimit() + fun getLatestFulizaOutstanding(): Flow = transactionDao.getLatestFulizaOutstanding() + + /** Recipient fragments the user marked as their own accounts. */ + fun getOwnAccountFragments(): Flow> = categoryRuleDao.getOwnAccountFragments() + + /** + * Mark a recipient as the user's own account: create a recipient rule + * pointing at the transfer category and retroactively move every + * matching transaction into it — excluding them from spending. + * + * The stored fragment strips a trailing "for account …" so rows that + * embed per-transfer account numbers in the name still match + * (e.g. "CREDIT BANK LTD INVESTMENTS 1 for account 07…"). + */ + suspend fun markRecipientAsOwnAccount(recipientName: String): Int { + val transferCategory = categoryDao.getTransferCategory() ?: return 0 + val fragment = deriveRecipientFragment(recipientName) + if (fragment.isBlank()) return 0 + categoryRuleDao.insertAll( + listOf( + CategoryRule( + categoryId = transferCategory.id, + matchType = MatchType.RECIPIENT_NAME, + matchValue = fragment, + ) + ) + ) + return transactionDao.updateCategoryForRecipientFragment(fragment, transferCategory.id) + } + + /** Undo [markRecipientAsOwnAccount]: drop the rule, re-categorize matches. */ + suspend fun unmarkRecipientAsOwnAccount(recipientName: String) { + val transferCategory = categoryDao.getTransferCategory() ?: return + val fragment = deriveRecipientFragment(recipientName) + categoryRuleDao.deleteByCategoryAndValue(transferCategory.id, fragment) + // Re-run auto-categorization for the affected rows so they fall back + // to their type default or another matching rule. + transactionDao.getByRecipientFragmentSync(fragment).forEach { entity -> + if (entity.categoryId == transferCategory.id) { + val categoryId = autoCategorize(entity.type, entity.recipientName, entity.accountNumber) + transactionDao.updateCategory(entity.id, categoryId) + } + } + } + + companion object { + /** + * The match fragment for an own-account rule. Strips the + * "for account …" tail some bank-transfer SMS embed in the name. + */ + fun deriveRecipientFragment(recipientName: String): String = + recipientName + .split(Regex("""\s+for account\s+""", RegexOption.IGNORE_CASE)) + .first() + .trim() + } + + private suspend fun autoCategorize(parsed: ParsedTransaction): Long? = + autoCategorize(parsed.type, parsed.recipientName, parsed.accountNumber) + + private suspend fun autoCategorize( + type: TransactionType, + recipientName: String?, + accountNumber: String?, + ): Long? { // 1. Type-based defaults - val typeDefault = when (parsed.type) { + val typeDefault = when (type) { TransactionType.AIRTIME_SELF, TransactionType.AIRTIME_OTHER -> 4L // Airtime & Data TransactionType.SEND -> 6L // Send Money TransactionType.RECEIVED -> 7L // Received @@ -200,9 +273,9 @@ class TransactionRepository @Inject constructor( } // 2. Rule-based matching (overrides type default for BUY_GOODS, PAY_BILL, SEND) - if (parsed.recipientName != null) { + if (recipientName != null) { val rules = categoryRuleDao.getAllRulesSync() - val nameUpper = parsed.recipientName.uppercase() + val nameUpper = recipientName.uppercase() // Check recipient name rules for (rule in rules) { @@ -214,10 +287,10 @@ class TransactionRepository @Inject constructor( } // Check paybill rules - if (parsed.accountNumber != null) { + if (accountNumber != null) { for (rule in rules) { if (rule.matchType == MatchType.PAYBILL && - parsed.accountNumber == rule.matchValue + accountNumber == rule.matchValue ) { return rule.categoryId } diff --git a/app/src/main/java/com/ledga/app/di/DatabaseModule.kt b/app/src/main/java/com/ledga/app/di/DatabaseModule.kt index 9713300..7c53ff3 100644 --- a/app/src/main/java/com/ledga/app/di/DatabaseModule.kt +++ b/app/src/main/java/com/ledga/app/di/DatabaseModule.kt @@ -36,7 +36,11 @@ object DatabaseModule { AppDatabase::class.java, "ledga.db" ) - .addMigrations(Migrations.MIGRATION_1_2, Migrations.MIGRATION_2_3) + .addMigrations( + Migrations.MIGRATION_1_2, + Migrations.MIGRATION_2_3, + Migrations.MIGRATION_3_4, + ) .addCallback(object : RoomDatabase.Callback() { override fun onCreate(db: SupportSQLiteDatabase) { super.onCreate(db) diff --git a/app/src/main/java/com/ledga/app/ui/home/HomeScreen.kt b/app/src/main/java/com/ledga/app/ui/home/HomeScreen.kt index e277430..c20e528 100644 --- a/app/src/main/java/com/ledga/app/ui/home/HomeScreen.kt +++ b/app/src/main/java/com/ledga/app/ui/home/HomeScreen.kt @@ -6,8 +6,10 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState @@ -49,6 +51,7 @@ import com.ledga.app.ui.components.v2.onTonal import com.ledga.app.ui.theme.LedgaAccent import com.ledga.app.ui.theme.LedgaAccentDeep import com.ledga.app.ui.theme.LedgaAccentSoft +import com.ledga.app.ui.theme.LedgaDanger import com.ledga.app.ui.theme.LedgaInflow import com.ledga.app.ui.theme.LedgaInk import com.ledga.app.ui.theme.LedgaText @@ -221,6 +224,14 @@ fun HomeScreen( } } + // ---- Fuliza status (only when the SMS history has ever carried it) ---- + if (state.fulizaOutstanding != null || state.fulizaAvailable != null) { + FulizaStatusCard( + outstanding = state.fulizaOutstanding, + available = state.fulizaAvailable, + ) + } + // ---- Donut breakdown ---- if (state.donutSegments.isNotEmpty()) { BentoCard(title = "Where it went", onClick = onNavigateToActivity) { @@ -426,3 +437,59 @@ private fun UpdateBanner( } } } + +@Composable +private fun FulizaStatusCard(outstanding: Double?, available: Double?) { + BentoCard(title = "Fuliza") { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Space.s5), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "OUTSTANDING", + style = LedgaText.Overline, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = outstanding?.let { CurrencyFormatter.formatKshCompact(it) } ?: "—", + style = LedgaText.TitleM, + color = if ((outstanding ?: 0.0) > 0) LedgaDanger + else MaterialTheme.colorScheme.onSurface, + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = "AVAILABLE TO BORROW", + style = LedgaText.Overline, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = available?.let { CurrencyFormatter.formatKshCompact(it) } ?: "—", + style = LedgaText.TitleM, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + // Usage bar — how deep into the limit the overdraft currently is. + if (outstanding != null && available != null && outstanding + available > 0) { + val usedFraction = (outstanding / (outstanding + available)).toFloat() + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = Space.s4) + .height(6.dp) + .clip(RoundedCornerShape(3.dp)) + .background(MaterialTheme.colorScheme.outline), + ) { + Box( + modifier = Modifier + .fillMaxWidth(usedFraction.coerceIn(0f, 1f)) + .fillMaxHeight() + .clip(RoundedCornerShape(3.dp)) + .background(if (usedFraction > 0.5f) LedgaDanger else LedgaAccent), + ) + } + } + } +} diff --git a/app/src/main/java/com/ledga/app/ui/home/HomeViewModel.kt b/app/src/main/java/com/ledga/app/ui/home/HomeViewModel.kt index 3a64461..67cc85e 100644 --- a/app/src/main/java/com/ledga/app/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/ledga/app/ui/home/HomeViewModel.kt @@ -59,6 +59,10 @@ data class HomeUiState( val topInsight: Insight? = null, val accounts: List = emptyList(), val selectedAccountId: Long? = null, + /** Latest known Fuliza outstanding (owed) — null when never seen in SMS. */ + val fulizaOutstanding: Double? = null, + /** Latest known available Fuliza limit (borrowable) — null when never seen. */ + val fulizaAvailable: Double? = null, ) @OptIn(ExperimentalCoroutinesApi::class) @@ -166,6 +170,10 @@ class HomeViewModel @Inject constructor( state.copy(accounts = accounts) }.combine(settingsRepository.getSelectedAccountId()) { state, accountId -> state.copy(selectedAccountId = accountId) + }.combine(transactionRepository.getLatestFulizaOutstanding()) { state, tx -> + state.copy(fulizaOutstanding = tx?.fulizaOutstanding) + }.combine(transactionRepository.getLatestFulizaLimit()) { state, tx -> + state.copy(fulizaAvailable = tx?.fulizaLimit) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HomeUiState()) fun selectPeriod(period: Period) { diff --git a/app/src/main/java/com/ledga/app/ui/transactions/TransactionDetailSheet.kt b/app/src/main/java/com/ledga/app/ui/transactions/TransactionDetailSheet.kt index 7bbf552..5c5f947 100644 --- a/app/src/main/java/com/ledga/app/ui/transactions/TransactionDetailSheet.kt +++ b/app/src/main/java/com/ledga/app/ui/transactions/TransactionDetailSheet.kt @@ -17,6 +17,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Surface +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -48,6 +49,8 @@ fun TransactionDetailSheet( manualGoals: List = emptyList(), goalIdsForTransaction: List = emptyList(), onToggleGoal: ((goalId: Long, currentlyIn: Boolean) -> Unit)? = null, + isOwnAccount: Boolean = false, + onToggleOwnAccount: (() -> Unit)? = null, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val isInflow = transaction.direction == TransactionDirection.INFLOW @@ -170,6 +173,40 @@ fun TransactionDetailSheet( } } + // ---- Own-account (transfer) toggle ---- + // Only meaningful for outflows with a recipient: marking a bank, + // card, or savings paybill as "mine" reclassifies every matching + // transaction as a transfer and drops it from spending. + if (onToggleOwnAccount != null && + transaction.recipientName != null && + transaction.direction == TransactionDirection.OUTFLOW + ) { + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "My own account", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "Transfers to this recipient don't count as spending", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = isOwnAccount, + onCheckedChange = { onToggleOwnAccount() }, + ) + } + } + // ---- Account selector (only when 2+ accounts exist) ---- if (accounts.size >= 2 && onAccountChange != null) { Spacer(modifier = Modifier.height(16.dp)) diff --git a/app/src/main/java/com/ledga/app/ui/transactions/TransactionsScreen.kt b/app/src/main/java/com/ledga/app/ui/transactions/TransactionsScreen.kt index 6605ac3..eec2173 100644 --- a/app/src/main/java/com/ledga/app/ui/transactions/TransactionsScreen.kt +++ b/app/src/main/java/com/ledga/app/ui/transactions/TransactionsScreen.kt @@ -69,6 +69,15 @@ fun TransactionsScreen( currentlyIn = currentlyIn, ) }, + isOwnAccount = state.isOwnAccount(state.selectedTransaction!!.transaction.recipientName), + onToggleOwnAccount = state.selectedTransaction!!.transaction.recipientName?.let { name -> + { + viewModel.toggleOwnAccount( + recipientName = name, + currentlyMarked = state.isOwnAccount(name), + ) + } + }, ) } diff --git a/app/src/main/java/com/ledga/app/ui/transactions/TransactionsViewModel.kt b/app/src/main/java/com/ledga/app/ui/transactions/TransactionsViewModel.kt index 9489ca3..0551d74 100644 --- a/app/src/main/java/com/ledga/app/ui/transactions/TransactionsViewModel.kt +++ b/app/src/main/java/com/ledga/app/ui/transactions/TransactionsViewModel.kt @@ -35,8 +35,15 @@ data class TransactionsUiState( val manualGoals: List = emptyList(), /** Goal IDs the currently-selected transaction is already manually attributed to. */ val selectedTxGoalIds: List = emptyList(), + /** Recipient fragments marked as the user's own accounts (transfers). */ + val ownAccountFragments: List = emptyList(), val selectedTransaction: TransactionWithCategory? = null -) +) { + fun isOwnAccount(recipientName: String?): Boolean { + val name = recipientName ?: return false + return ownAccountFragments.any { name.contains(it, ignoreCase = true) } + } +} @OptIn(FlowPreview::class, kotlinx.coroutines.ExperimentalCoroutinesApi::class) @HiltViewModel @@ -92,8 +99,12 @@ class TransactionsViewModel @Inject constructor( accountsRepository.observeAll(), goalsRepository.observeManualGoals(), selectedTxGoalIds, - ) { sel, accts, manualGoals, txGoals -> - GoalsBundle(selected = sel, accounts = accts, manualGoals = manualGoals, txGoals = txGoals) + transactionRepository.getOwnAccountFragments(), + ) { sel, accts, manualGoals, txGoals, ownFragments -> + GoalsBundle( + selected = sel, accounts = accts, manualGoals = manualGoals, + txGoals = txGoals, ownFragments = ownFragments, + ) }, ) { txns, query, filter, categories, bundle -> val grouped = txns.groupBy { twc -> @@ -107,6 +118,7 @@ class TransactionsViewModel @Inject constructor( accounts = bundle.accounts, manualGoals = bundle.manualGoals, selectedTxGoalIds = bundle.txGoals, + ownAccountFragments = bundle.ownFragments, selectedTransaction = bundle.selected ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TransactionsUiState()) @@ -116,6 +128,7 @@ class TransactionsViewModel @Inject constructor( val accounts: List, val manualGoals: List, val txGoals: List, + val ownFragments: List, ) fun setSearchQuery(query: String) { @@ -155,4 +168,12 @@ class TransactionsViewModel @Inject constructor( else goalsRepository.addManualContribution(goalId, transactionId) } } + + /** Mark/unmark the selected transaction's recipient as the user's own account. */ + fun toggleOwnAccount(recipientName: String, currentlyMarked: Boolean) { + viewModelScope.launch { + if (currentlyMarked) transactionRepository.unmarkRecipientAsOwnAccount(recipientName) + else transactionRepository.markRecipientAsOwnAccount(recipientName) + } + } } 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 c9ca988..8387c23 100644 --- a/app/src/test/java/com/ledga/app/parser/MpesaSmsParserTest.kt +++ b/app/src/test/java/com/ledga/app/parser/MpesaSmsParserTest.kt @@ -293,6 +293,40 @@ class MpesaSmsParserTest { assertEquals(TransactionDirection.OUTFLOW, t.direction) } + // --- Fuliza limit + interest capture --- + + @Test + fun `fuliza limit captured from auto-pay full`() { + val sms = "UCHIE9OIM9 Confirmed. Ksh 1738.92 from your M-PESA has been used to fully pay your outstanding Fuliza M-PESA. Available Fuliza M-PESA limit is Ksh 11000.00. Your M-PESA balance is 8581.08." + val t = assertSuccess(sms).transaction + assertEquals(11000.0, t.fulizaLimit!!, 0.01) + } + + @Test + fun `fuliza limit captured from auto-pay partial`() { + val sms = "UC3IE8BHFR Confirmed. Ksh 200.00 from your M-PESA has been used to partially pay your outstanding Fuliza M-PESA. Your available Fuliza M-PESA limit is Ksh 8018.37. M-PESA balance is Ksh0.00." + val t = assertSuccess(sms).transaction + assertEquals(8018.37, t.fulizaLimit!!, 0.01) + } + + @Test + fun `fuliza borrow captures interest as cost`() { + val sms = "TGM53BTX2H Confirmed. Fuliza M-PESA amount is Ksh 1073.00. Interest charged Ksh 10.73. Total Fuliza M-PESA outstanding amount is Ksh 1699.70 due on 21/08/25. To check daily charges, Dial *334#OK Select Fuliza M-PESA." + val t = assertSuccess(sms).transaction + assertEquals(TransactionType.FULIZA, t.type) + assertEquals(1073.0, t.amount, 0.01) + assertEquals(10.73, t.transactionCost, 0.01) + assertEquals(1699.70, t.fulizaOutstanding!!, 0.01) + } + + @Test + fun `fuliza sender id is accepted`() { + assertTrue(MpesaSmsParser.isMpesaMessage("FULIZA")) + assertTrue(MpesaSmsParser.isMpesaMessage("MPESA")) + assertTrue(MpesaSmsParser.isMpesaMessage("mpesa")) + assertTrue(!MpesaSmsParser.isMpesaMessage("BANKSMS")) + } + // --- Fuliza Reversal --- @Test