Money
Scope: owns money math — currency tables, amount parsing, formatting, import codecs, FX conversion.
Invariant: money is stored, parsed, formatted and compared as Long minor
units — never floating point. A Long amount with currency code is the
canonical representation everywhere (TransactionEntity.amountMillis,
TransactionCandidate.amountMillis, BudgetEntity.amountMillis,
CorrectionEntity…).
The currency tables
common/Currency is the single home for currency knowledge — minor digits,
display symbols, and token detection. A currency is added in exactly one
place; everything else consumes the tables.
| ISO | Minor digits | Display symbol |
|---|---|---|
| MYR | 2 | RM |
| SGD | 2 | S$ |
| INR | 2 | ₹ |
| USD | 2 | $ |
| EUR | 2 | € |
| GBP | 2 | £ |
| JPY | 0 | ¥ |
| unknown | 2 (default) | <CODE> |
Three surfaces:
SPECS— code → digits + symbol (minorDigits(),symbol(),supportedCodes()).TOKENS— loose alternation consumed byAmountParser(amountTokenPattern,currencyOfToken):US$,₹,Rs.?,INR,USD,RM,MYR,SGD,S$,$,€,EUR,£,GBP,¥.DETECT— word-boundary-safe whole-text detection (detect,currencyNearestTo):\bRM\b,\bMYR\b,\bSGD\b,₹,\brs\.?,\bINR\b,S$,$(standalone, not part ofS$/US$),\bUS$\b,\bUSD\b,€,\bEUR\b,£,\bGBP\b,¥.
currencyNearestTo — proximity detection
Currency.currencyNearestTo(fullText, position) finds the currency token
physically nearest to a position in the notification body (typically the
amount's index). Unlike detect (global first-match-wins), it returns the
currency symbol closest to the amount, so a distant mention (e.g. a Wise
exchange-rate note like "1 SGD = 3.45 MYR") cannot mislabel the transaction.
AmountParser uses it as the fallback when the captured amount span itself
carries no currency symbol, so a foreign-currency charge (S$45.00) is
stored in its original currency even when the rule captured only the
number.
Consumers
flowchart LR
CURR[Currency tables]
CURR --> MF[MoneyFormatter]
CURR --> MT[MoneyText]
CURR --> AP[AmountParser]
CURR --> PE[ParserEngine: currencyNearestTo]
CURR --> SETTINGS[Settings currency picker]
AP -->|Result amountMillis, currency| PE
PE -->|TransactionCandidate.amountMillis| PIPE[CapturePipeline]
PIPE -->|TransactionEntity.amountMillis| DB[(Room)]
DB -->|amountMillis| MF
DB -->|amountMillis| MT
DB -->|amountMillis| EXP[ExportCodec rows]
| Component | Job | Example |
|---|---|---|
MoneyFormatter |
Display formatting, grouped thousands | format(123456, "MYR") → "RM 1,234.56"; formatCompact(500000, "MYR") → "RM 5k" |
MoneyText |
Editable number string (for input fields) | of(123456, "MYR") → "1234.56" |
AmountParser |
Text → Long minor units + currency |
"S$ 25.40" → 2540, SGD |
Currency.currencyNearestTo |
Proximity-based currency detection | "S$45.00 ... MYR", 3 → "SGD" |
Currency.supportedCodes |
Settings currency picker chips | [MYR, SGD, INR, USD, EUR, GBP, JPY] |
Itemization.sum |
Sum of editable item lines (signed minor units) in-memory | ["10.00", "-2.50"] → 750, MYR |
AmountParser
Parses the first amount-like token. The decimal separator is detected per value, never assumed:
- both
.and,present → last separator is the decimal one; - single separator with fewer than 3 digits after → decimal (
1.23,1,23); - single separator with exactly 3 digits after → thousands
(
500,000,1.234).
Handles currency before or after the number, optional symbol (default
currency), and sign. JPY amounts have zero minor digits.
MoneyFormatter vs MoneyText
MoneyFormatteradds the symbol and thousand separators — display only.MoneyTextproduces a plain number string so anOutlinedTextFieldcan round-trip an amount without format noise.MoneyText.reformat(text, from, to)re-renders an editable amount for a different currency, preserving the minor-unit value (no FX). Used when the editor changes currency — e.g. MYR12.34→ JPY1234. Unparseable text and same-currency calls pass through unchanged.
Both use Currency.minorDigits to place the decimal point, and neither
does floating-point math — MoneyFormatter scales via
BigDecimal.movePointLeft with HALF_UP; MoneyText needs no BigDecimal
at all (plain Long division by the digit factor).
Import path
ExportRepository.importJson re-parses imported amounts through
AmountParser with the row's currency, so minor digits apply on import too.
Import dedupe reuses the same NotificationKey scheme (source defaulted to
"import").
FX conversion (common/Fx)
Transactions keep their original currency; aggregation converts to the base
currency before summing. Fx.toBase(amountMillis, from, base, rates) converts
minor units to base minor units using BigDecimal + HALF_UP (no
floating-point money math, ADR-0009):
toBase(4500, "SGD", "MYR", { "SGD" -> "3.45" }) // S$45.00 -> 15525 (RM 155.25)
toBase(1000, "JPY", "MYR", { "JPY" -> "0.033" }) // ¥1000 (0 minor digits) -> 3300
Rates live in the encrypted settings table under SettingsRepository.KEY_FX_RATES
(JSON foreign code → rate string), edited from Settings (ADR-0025).
Same-currency amounts pass through unchanged. Unknown currency or missing rate
returns null — those rows are excluded from the aggregate and the UI shows a
"foreign-currency transactions excluded" footnote (hasUnconvertedForeign).
The Home, Reports, Budget and Transactions ViewModels use Fx.foldToBase to
convert each amount before sumOf/maxOfOrNull, so slices, totals, trends,
day headers and budget comparisons all land in base units (Transactions shows
the unconverted footnote per day header).