Classification
Scope: owns category assignment — the classifier seam, keywords, overrides.
Classification maps a parsed transaction to a category id. It is designed as a seam so the v1 keyword implementation can be swapped for ML later without touching the capture pipeline.
The seam
interface CategoryClassifier {
suspend fun classify(
merchant: String,
amountMillis: Long,
direction: Direction,
sourcePackage: String,
): Long?
}
Bound in AppModule:
@Binds @Singleton
abstract fun bindCategoryClassifier(impl: KeywordClassifier): CategoryClassifier
CapturePipeline calls classify only when the parsed candidate has a
merchant; a failure returns null and the transaction is inserted
uncategorized. Candidates without a merchant are stored without invoking the
classifier.
KeywordClassifier (v1)
- Income shortcut — INCOME transactions always map to the
Incomecategory. - User override —
correctionstable lookup keyed on(packageName, merchantNormalized)(unique index). Package-scoped: the same merchant from two banks can map differently. Overrides are written from the UI viaTransactionRepository.applyCategoryOverride, which also marks the transactionisReviewed = true. - Keywords —
effectiveKeywords()builds one keyword list per expense category in the DB (seeded defaults and user-created customs alike): stored edits win, seeded defaults are matched by category name (so any category named like a default inherits its keywords), custom categories use stored keywords only. Categories are tried insortOrder, nameorder; the first category with any keyword that appears as a substring of the normalized merchant wins (normalized.contains(keyword)). - Fallback — the
Othercategory.
Keyword defaults (Malaysian market)
A few examples per category (the authoritative list is
KeywordClassifier.DEFAULT_KEYWORDS in domain/category/KeywordClassifier.kt
— don't hand-copy it here):
| Category | Sample keywords |
|---|---|
| Food | grabfood, foodpanda, kfc, mcdonalds, starbucks, tealive, old town, kopitiam |
| Groceries | 99 speedmart, speedmart, mydin, tesco, aeon big, giant, village grocer |
| Transport | grabcar, maxim, petronas, petron, touch n go, tng, lrt, toll, parking |
| Shopping | shopee, lazada, uniqlo, padini, mr diy, daiso, watsons |
| Utilities | tnb, air selangor, syabas, unifi, maxis, astro, water bill |
| Subscriptions | netflix, spotify, disney, icloud, google play, subscription |
| Entertainment | gsc, tgv, mmcineplex, cinema, movie, steam, playstation |
| Health | pharmacy, clinic, hospital, doctor, dental, medicine |
Keywords are editable in Settings → Automation → Category keywords, stored
as JSON under KEY_CATEGORY_KEYWORDS ({"Food": ["grabfood", …], …}). One
field is rendered per expense category — including user-created ones.
Stored values replace defaults per category (not merge). Storage is centralized
in SettingsRepository (loadCategoryKeywords / saveCategoryKeywords /
renameCategoryKeywordKey); renaming a custom category re-keys its entry so
matching survives the rename (ADR-0022).
Corrections
- Stored in the
correctionstable:(packageName, merchantNormalized, categoryId). - Written when the user changes a transaction's category in the detail screen
(via
applyCategoryOverride). - Read package-scoped at classify time.
- Category deletion cascades (
ON DELETE CASCADE).