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?
/** Records a correction through the same normalization seam (ADR-0007). */
suspend fun rememberOverride(
sourcePackage: String,
merchant: String,
categoryId: Long,
) = Unit
}
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 map to the seeded income
category, resolved by seed identity (
kind + sortOrder), not by the display name. RenamingIncometoSalarytherefore keeps income capture working (issue #310). - User override —
correctionstable lookup keyed on(packageName, merchantNormalized)(unique index). Package-scoped: the same merchant from two banks can map differently. Overrides are written throughCategoryClassifier.rememberOverride, which normalizes once inside the module; the save use case never touches the corrections table itself (issue #322). A blank merchant never records an override — the display fallback (Uncategorised) is not a key (issue #317). - 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 resolved by seed identity (sortOrder), so renaming a default keeps 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)). Each configured keyword is folded throughMerchantNormalizerat read time, so case, accent and whitespace variants (STARBUCKS,Café,vet) match the normalized merchant; terms whose normalized form is empty (blank or punctuation-only) are dropped so they cannot match every merchant. - Fallback — the seeded
Othercategory, resolved by seed identity (kind + sortOrder), so renaming it does not remove the fallback.
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 → Categories & 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), committing inside the same transaction
as the category row and its limit (CategorySaveUseCase, issue #236).
Name uniqueness is case-insensitive within a kind and owned by
CategoryRepository (isNameTaken), shared by create and update (#235). The
check folds Kotlin equals(ignoreCase = true) (Unicode-aware, so École and
école collide) over the whole same-kind candidate set, not SQLite
COLLATE NOCASE (ASCII-only). Names are trimmed once at the seam, so the
stored row and the re-keyed keyword entry always share the same string. A
rejected rename writes neither the category row nor the keyword map, and the
editor keeps the draft with a name-field error.
Corrections
- Stored in the
correctionstable:(packageName, merchantNormalized, categoryId). - Written through
CategoryClassifier.rememberOverridewhen the user changes a transaction's category in the detail screen and the merchant field is non-blank (never keyed on the display fallback). - Read package-scoped at classify time.
- Category deletion cascades (
ON DELETE CASCADE).