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