Skip to content

Parser Engine

Scope: owns parsing — rules, candidates, amounts, currency detection, dedupe key, golden fixtures.

The parser turns notification text into a TransactionCandidate. It is pure Kotlin — no Android imports — and covered by golden fixtures that enforce ≥90% HIGH-confidence parses.

Model

flowchart LR
    subgraph Input
        TEXT[notification text]
        PKG[source package]
    end

    subgraph ParserEngine
        JSON[EmbeddedJsonParser]
        RULES[per-package ParserRules sorted by priority]
        AP[AmountParser]
        CUR[Currency detection]
        DD[DirectionDetector]
        SD[StatusDetector]
        CUT[cutAtStopWord merchant trimming]
    end

    subgraph Output
        CAND[TransactionCandidate]
        NONE[null → unmatched queue]
    end

    TEXT --> JSON
    JSON -->|EmbeddedTransaction found| AP
    TEXT --> RULES
    RULES -->|first matching rule| AP
    AP --> CAND
    CUR --> AP
    DD --> CAND
    SD --> CAND
    CUT --> CAND
    JSON -->|none| RULES
    RULES -->|no rule matches or no amount| NONE

TransactionCandidate

Field Meaning
amountMillis always in minor units of currency (e.g. sen/cents)
currency ISO code; from embedded payload, detected token, or rule default
merchant trimmed; null when absent (drives Confidence.PARTIAL)
account masked account/card reference
direction Direction.EXPENSE / INCOME
status Status.COMPLETED / PENDING / FAILED / REVERSED
confidence HIGH (merchant present), PARTIAL (no merchant), NONE (never produced; hook for stricter parsing)
rawText original text
sourcePackage source
occurredAtMillis always null in v1 — post time is used instead (ADR-0001)
ruleId the ParserRule.dbId that matched — provenance FK (ADR-0002)
parseSource ParseSource.JSON (embedded payload) or ParseSource.REGEX (regex rule)
rawJson exact {...} payload span; non-null only when parseSource == JSON

ParserRule

A rule is one regex per bank alert format:

data class ParserRule(
    val id: String,
    val dbId: Long? = null,   // DB row id for provenance
    val packageName: String,
    val label: String,
    val pattern: Regex,       // Java named groups: amount, merchant, account
    val amountGroup: String = "amount",
    val merchantGroup: String = "merchant",
    val accountGroup: String = "account",
    val directionKind: DirectionKind = AUTO,
    val statusKind: StatusKind = AUTO,
    val priority: Int = 0,
)
  • amount group is required; merchant/account are optional.
  • DirectionKind: AUTO (detector decides, defaults EXPENSE), DEBIT (→ EXPENSE), CREDIT (→ INCOME).
  • StatusKind: AUTO (detector decides, defaults COMPLETED) or an explicit status.
  • The static BankRuleSets table seeds the rules table on first launch (62 rules across 12 packages). Runtime matching always reads from the DB so in-app edits take effect immediately (ADR-0004). One exception: debug builds inject 12 static com.android.shell rules straight into the engine map for the fake-notification harness — they are never seeded into or read from the DB. Rules shipped after an install are re-seeded onto existing installs once per seed version, keyed on the stable sourceId (ADR-0019).

Match order

  1. Embedded JSONEmbeddedJsonParser lifts merchantName, transactionAmount, transactionCurrency, cardNumber from a balanced {...} object in the text (Maybank/MAE append machine-readable fields). More reliable than prose; wins over regex. Confidence HIGH. Produces parseSource=JSON and preserves the raw {...} span as rawJson.
  2. Regex rules — rules for the package sorted by priority; first rule whose pattern matches and whose amount group parses wins. Produces parseSource=REGEX, rawJson=null.

Rule amount groups are currency-agnostic: BankRuleSets defines a shared currencyAmount token (RM|MYR|S$|€|£|₹|¥|USD|SGD|EUR|GBP|INR|JPY) captured inside the amount group, so one rule matches both RM 50.00 and S$ 45.00 and AmountParser resolves the original currency from the symbol (see ADR-0009/money.md). The app-default currency is never assumed for a symbol-carrying amount.

Merchant names are trimmed at trailing qualifiers (cutAtStopWord): stop words like via, through, using, ref, on, for, with, … and trailing declined|failed|pending|completed|….

AmountParser

flowchart LR
    subgraph AmountParser
        PAT[amount token regex: symbol + number or number + symbol]
        TOK[Currency.currencyOfToken on amount span]
        NEAR[Currency.currencyNearestTo full-text fallback]
        SEP[per-value decimal separator detection]
        MIN[minor units via Currency.minorDigits]
    end
    TEXT --> PAT
    PAT -->|currency symbol present| TOK
    PAT -->|bare number| NEAR
    TOK --> SEP
    NEAR -->|default when none| SEP
    SEP --> MIN
    MIN --> RESULT[Result amountMillis, currency]
  • Handles ₹1,234.56, 1.234,56 €, $1,234.56, S$ 500.00, Rs 500, INR 500.00, + $12.50, RM 25.40
  • Currency resolution order: symbol in the amount span (currencyOfToken) → symbol nearest to the amount in the full notification body (currencyNearestTo) → app-default currency. The proximity fallback means a foreign-currency charge whose rule captured only the number is still stored in its original currency, and a distant "1 SGD = 3.45 MYR" note cannot mislabel an S$ amount as MYR.
  • The decimal separator is detected per value: the last separator is the decimal one when both kinds are present, or when fewer than three digits follow a single separator (500,000 / 1.234 → thousands).
  • Currency symbol optional (default currency used), may precede or follow the number. Sign handled (- → negative amount).
  • Never uses floating point — everything lands in Long minor units.

Currency (single source of truth)

common/Currency owns three tables consumed by parsing and everywhere else (formatter, settings, export): SPECS (code → minor digits + display symbol), TOKENS (loose symbol regexes matched inside amount spans), and DETECT (word-boundary-safe detection over the whole notification). money.md owns the full inventories — consult it before adding a currency; everything else consumes these tables automatically.

Direction & status detectors

  • DirectionDetector: sign on amount (-RM → EXPENSE, +RM → INCOME), then credit flags (credited, received, refund, cashback, cr, incoming, …) before debit flags (debited, paid to, spent, withdrawn, dr, …). Returns null → rule directionKind decides.
  • StatusDetector: failed/declined/unsuccessful → FAILED, pending/processing/initiated → PENDING, reversed/reversal → REVERSED. Returns null → rule statusKind decides (default COMPLETED).

NotificationKey (dedupe)

sourcePackage + ":" + occurredAtMillis + ":" + sha256(normalize(rawText))

normalize = trim + collapse whitespace. The key is computed per notification in CapturePipeline (with the post time as occurredAtMillis). Two dedupe layers sit on top: a bounded session-level set that skips rebind re-processing (ingestion-pipeline.md) and the unique DB index on transactions.notificationKey, which turns duplicate inserts into no-ops — the durable guarantee.

MerchantNormalizer

Collapses a merchant to a stable form for override matching:

lowercase → trim → collapse whitespace → drop chars outside [a-z0-9 .&'-] → trim

Used by KeywordClassifier and TransactionRepository.applyCategoryOverride — normalization happens exactly once, inside the classification module (ADR-0007).

Golden fixtures

app/src/test/resources/fixtures/*.json — one file per package (bigpay, boost, cimb, grab, mae, maybank, misc, pbb, rhb, shopee, tng, wallet, wise).

ParseGoldenTest asserts:

  • each fixture parses to exactly the expected amountMillis, currency, merchant, account, direction, status, confidence;
  • fixtures marked expected: null produce no candidate;
  • each parse's parseSource and (for JSON) the exact rawJson span;
  • ≥90% of parseable fixtures must parse with HIGH confidence.

Add new fixtures when adding rules — the ratio gate protects parse quality. The test enumerates the fixtures directory dynamically, so dropping a new file in is enough to include it.

Starter rule sets

Package Rules Direction kinds
Maybank2u my.com.maybank2u 6 debit/credit
MAE com.maybank2u.life 13 debit/credit (+ mae_spent_card priority −1)
TNG eWallet my.com.tngdigital.ewallet 7 debit/credit
Grab com.grabtaxi.passenger 5 debit/credit
Boost my.com.myboost 3 debit/credit
BigPay com.tpaay.bigpay 3 debit/credit
CIMB cimb.active 4 debit/credit
Public Bank my.com.pbb 3 debit/credit
RHB my.com.rhb 3 debit/credit
ShopeePay com.shopee.my 3 debit/credit
Wise com.transferwise.android 9 debit/credit
Google Wallet com.google.android.apps.walletnfcrel 3 debit/credit
com.android.shell (debug only) 12 both — fake-notification harness

Each seeded row carries its stable sourceId (equal to the rule id, e.g. mae_spent_card, pbb_cr); starter-rule re-seeding keys on it (ADR-0019).