Skip to content

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)

  1. Income shortcut — INCOME transactions map to the seeded income category, resolved by seed identity (kind + sortOrder), not by the display name. Renaming Income to Salary therefore keeps income capture working (issue #310).
  2. User override — corrections table lookup keyed on (packageName, merchantNormalized) (unique index). Package-scoped: the same merchant from two banks can map differently. Overrides are written through CategoryClassifier.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).
  3. 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 in sortOrder, name order; the first category with any keyword that appears as a substring of the normalized merchant wins (normalized.contains(keyword)). Each configured keyword is folded through MerchantNormalizer at 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.
  4. Fallback — the seeded Other category, 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 corrections table: (packageName, merchantNormalized, categoryId).
  • Written through CategoryClassifier.rememberOverride when 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).