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?
}

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 always map to the Income category.
  2. User overridecorrections table lookup keyed on (packageName, merchantNormalized) (unique index). Package-scoped: the same merchant from two banks can map differently. Overrides are written from the UI via TransactionRepository.applyCategoryOverride, which also marks the transaction isReviewed = true.
  3. KeywordseffectiveKeywords() 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 in sortOrder, name order; the first category with any keyword that appears as a substring of the normalized merchant wins (normalized.contains(keyword)).
  4. Fallback — the Other category.

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 corrections table: (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).