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,
)
amountgroup is required;merchant/accountare optional.DirectionKind:AUTO(detector decides, defaults EXPENSE),DEBIT(→ EXPENSE),CREDIT(→ INCOME).StatusKind:AUTO(detector decides, defaults COMPLETED) or an explicit status.- The static
BankRuleSetstable seeds therulestable 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 staticcom.android.shellrules 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 stablesourceId(ADR-0019).
Match order
- Embedded JSON —
EmbeddedJsonParserliftsmerchantName,transactionAmount,transactionCurrency,cardNumberfrom a balanced{...}object in the text (Maybank/MAE append machine-readable fields). More reliable than prose; wins over regex. ConfidenceHIGH. ProducesparseSource=JSONand preserves the raw{...}span asrawJson. - Regex rules — rules for the package sorted by
priority; first rule whose pattern matches and whoseamountgroup parses wins. ProducesparseSource=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 anS$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
Longminor 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 → ruledirectionKinddecides.StatusDetector:failed/declined/unsuccessful→ FAILED,pending/processing/initiated→ PENDING,reversed/reversal→ REVERSED. Returns null → rulestatusKinddecides (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: nullproduce no candidate; - each parse's
parseSourceand (for JSON) the exactrawJsonspan; - ≥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).