Skip to content

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:

  1. SPECS — code → digits + symbol (minorDigits(), symbol(), supportedCodes()).
  2. TOKENS — loose alternation consumed by AmountParser (amountTokenPattern, currencyOfToken): US$, , Rs.?, INR, USD, RM, MYR, SGD, S$, $, , EUR, £, GBP, ¥.
  3. DETECT — word-boundary-safe whole-text detection (detect, currencyNearestTo): \bRM\b, \bMYR\b, \bSGD\b, , \brs\.?, \bINR\b, S$, $ (standalone, not part of S$/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

  • MoneyFormatter adds the symbol and thousand separators — display only.
  • MoneyText produces a plain number string so an OutlinedTextField can 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. MYR 12.34 → JPY 1234. 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).