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. The catalog is compile-time and available to the pure-Kotlin parser; which currencies appear in pickers is a separate user setting (ADR-0039).

ISO Minor digits Display symbol
MYR 2 RM
SGD 2 S$
INR 2 ₹
USD 2 $
EUR 2 €
GBP 2 £
JPY 0 ¥
PLN 2 PLN
AUD 2 A$
CAD 2 C$
CNY 2 CN¥
HKD 2 HK$
TWD 2 NT$
NZD 2 NZ$
THB 2 ฿
IDR 2 Rp
PHP 2 ₱
VND 0 ₫
KRW 0 ₩
CHF 2 CHF
AED 2 AED
CZK 2 Kč
unknown 2 (default) <CODE>

Three surfaces:

  1. SPECS — code → digits + symbol (minorDigits(), symbol(), supportedCodes(), isKnown()).
  2. TOKENS — loose alternation consumed by AmountParser (amountTokenPattern, currencyOfToken). Every catalog code has an ISO-code token plus its symbol(s), and currencyOfToken returns the token matching earliest in the amount span, breaking ties by the longest match — so RMB wins over its RM prefix and CN¥ over the bare ¥ without depending on list order.
  3. DETECT — word-boundary-safe whole-text detection (detect, currencyNearestTo). Prefixed-dollar entries precede the standalone $; the JPY entry is (?<!CN)¥ so CN¥ cannot be read as JPY; Rp uses a letter-lookbehind (not \b) because Rp50 has no word boundary. The CN¥ detector and the yen lookbehind are case-insensitive, matching currencyOfToken, so cn¥100 cannot resolve CNY from the token and JPY from the whole-text fallback (a different minor-digit scale).

amountAlternation() returns a curated alternation (catalog symbols + ISO codes, no bare $, no optional-whitespace suffixes) shared by BankRuleSets.currencyAmount and NotificationFilter.AMOUNT so parse rules and the capture filter stay in lockstep with the catalog. Order is load-bearing for regex alternation: the longer RMB precedes RM, and RM carries a trailing non-letter boundary, so a 100 RMB capture cannot be truncated to 100 RM and stored as MYR.

Supported-currencies subset

SettingsRepository stores the user-enabled subset as CSV under KEY_SUPPORTED_CURRENCIES (loadSupportedCurrencies, observeSupportedCurrencies, saveSupportedCurrencies). Unset/blank or all-unknown values fall back to DEFAULT_SUPPORTED_CURRENCIES (the original eight). The list is always returned in catalog order. Pickers show the enabled subset plus the currently-selected currency; removal is hide-only — stored transactions and FX rates for a hidden currency keep working. See ADR-0039.

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; compact chart axes drop the fraction below 1,000 major units format(123456, "MYR") → "RM 1,234.56"; formatCompact(500000, "MYR") → "RM 5k"; formatCompact(99_950, "MYR") → "RM 999"
MoneyText Editable number string (for input fields); normalizes the currency code case like MoneyFormatter of(123456, "MYR") → "1234.56"; of(1234, "myr") → "12.34"
AmountParser Text → Long minor units + currency "S$ 25.40" → 2540, SGD
StrictAmount Whole-token portable-file money gate over AmountParser; every explicit currency token must match the row, the original numeric run must be well-formed, and the value must be exact in the row currency's minor units "garbage 12.50 trailing" → null; "¥100" or "MYR 12.50 USD" in a MYR row → null; "12.50.." / "1,," → null; "12.3456" MYR or "100.50" JPY → null (never truncated)
Currency.currencyNearestTo Proximity-based currency detection "S$45.00 ... MYR", 3 → "SGD"
Currency.supportedCodes Full built-in catalog [MYR, SGD, INR, USD, EUR, GBP, JPY, PLN, AUD, …] (22)
SettingsRepository.loadSupportedCurrencies User-enabled picker subset (fallback: original eight) [MYR, USD, THB]
Itemization.sum Sum of editable item lines (signed minor units) in-memory; a line carrying another explicit currency is not comparable ["10.00", "-2.50"] → 750, MYR; ["S$10"] on MYR → null

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. Digits or scaling outside Long range make the parse fail (null) rather than yielding 0 or a wrapped sign; malformed separator runs (0..0, 1,,2, 12.50.60) also fail instead of being silently rewritten. The structure check is a linear scan (not a nested-quantifier regex, which recursed without bound on a long grouped run and raised StackOverflowError), so an oversized grouped field is rejected as out-of-range instead of crashing import validation. Long.MIN_VALUE is representable — only one unit past either end is out of range.

The integer part is digit-bounded (19 significant digits, after dropping leading zeros) before any BigInteger construction: a million-digit amount inside the 16 MiB read cap costs milliseconds instead of seconds spent proving it cannot fit.

Notification parsing is deliberately tolerant and truncates a fraction beyond the currency's scale (12.3456 MYR → 1234); StrictAmount passes requireExactFraction, so an import accepts extra digits only when they are all zeros (12.5000 MYR → 1250) and rejects anything it would otherwise round away (12.3456 MYR, 100.50 JPY → null).

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.
  • The display string keeps the authored order: the calling surface prefixes the direction +/-, and MoneyFormatter adds the symbol and grouped digits; the string itself carries no bidi marks. The UI pins the money type roles to a left-to-right paragraph so an RTL layout renders that authored order unchanged (ADR-0079, #405). Formatting, parsing and stored Long values do not change with the layout direction.

Both uppercase the code before Currency.minorDigits, so myr and MYR share one scale, and neither does floating-point math — MoneyFormatter scales via BigDecimal.movePointLeft with HALF_UP; MoneyText takes the magnitude with BigInteger.abs() (a plain Math.abs(Long) is a no-op at Long.MIN_VALUE, so it must not be used). Both render Long.MIN_VALUE as -RM 92,233,720,368,547,758.08 / -92233720368547758.08 in a 2-digit currency, so the app's own backup stays importable.

Import/export path

ExportRepository projects each transaction onto an ExportRow (amount via MoneyText) and maps it back on import through StrictAmount, which anchors the whole field to one money token and requires any currency it names to equal the row's, so minor digits apply both ways. JSON ExportFile version 5 carries each row's stable notificationKey, so an own backup restored into its original ledger is a no-op through the unique index; the reported count is the number of rows actually inserted (ADR-0054). The version field is always emitted, so an older reader rejects a v5 backup as unsupported instead of applying its own version default and running the lossy legacy path. Version 5 also carries a portable category identity (categoryKind + category) and a complete account identity (accountRef: bank label + masked account + type + currency). On import, a missing custom category or account is created inside the same transaction, so a fresh installation keeps the association instead of storing a null; the metadata identity is stable, so repeating the import creates no duplicates. Import matches the complete account identity: two distinct accounts that share a bank and masked suffix but differ in type or currency stay distinct, and a destination that already holds more than one row with the same identity is not resolved — the association stays null and is reported. Legacy v1–v4 files have no portable metadata identity: a category name or masked account suffix is linked only when exactly one existing row matches. A missing or ambiguous match stays null and is reported as an unresolved count in ImportResult.Imported; it is never guessed and destination rows are never overwritten. Legacy v1–v3 files also have no transaction identity: each row gets a deterministic synthetic key (file position + visible attributes) that keeps distinct rows apart while repeated imports stay idempotent. Malformed, empty or future-version files are rejected before any write (ADR-0055).

On the way out the whole payload is read as one snapshot (issue #249): ExportDao.snapshot() reads the visible transactions, their items (in a single bulk IN query, not one per transaction) and the category/account metadata inside one Room transaction, so an export can never pair an old parent with newly saved children or drop a child deleted mid-export. Mapping to ExportRows and serialization to CSV/JSON happen after the transaction closes, so no file or output-stream work runs inside it.

Import is strict and atomic (ADR-0076). Every row is validated before the first write: currency must be in the full Currency catalog (not the enabled subset, ADR-0039), and an amount or items[].amount must be one whole StrictAmount token for the row's currency — a bare decimal, or a symbol/ISO-code form naming the same currency. Surrounding prose, a second amount, out-of-Long-range digits, or any explicit currency token that conflicts with the row (e.g. ¥100, or MYR 12.50 USD where both sides are checked) is rejected. The strict codec validates the original numeric run, so it does not inherit the notification parser's prose tolerance for a trailing separator: 12.50.. and 1,, are rejected rather than silently repaired. occurredAt is accepted across the whole Long range — a pre-epoch or epoch post time is valid and the export includes such rows (#247), so the app's own backup is always restorable. A direction/status/parseSource that does not map through its dbValue rejects the whole file with ImportError.InvalidPayload(rowIndex, field) — no silent skip or default. Only an absent v5 identity is legacy: a present but blank categoryKind or account reference field, or one that does not map through its dbValue, is rejected instead of being folded into legacy resolution. The surviving rows are then inserted inside one TransactionRunner transaction, so a failure part-way rolls everything back instead of leaving a partial write.

CSV output neutralizes spreadsheet formula injection (issue #240). CSV quoting delimits fields but does not stop a spreadsheet from evaluating a cell that starts with a formula marker; so every text cell — merchant, note, category, account, source, place, provider, raw JSON — whose first significant character (after whitespace, Unicode format characters and control characters) is =, +, -, @ or their full-width forms is prefixed with a single ' before normal quoting. The ' is part of the stored cell text (a documented interoperability tradeoff); a spreadsheet that honours it opens the rest as literal text. This is a defence, not a guarantee — importer versions and locale normalisation vary, and save/reopen may drop the prefix; see the manual smoke case in ExportCodecTest. Numeric columns (amount, coordinates, timestamps) are exempt, so -12.50 stays a number. JSON exports are unchanged.

The read itself is bounded (ADR-0077): the import path reads through ExportCodec.readImport, which stops at ExportCodec.MAX_IMPORT_BYTES (16 MiB) + 1 byte regardless of any provider-supplied length metadata, so an oversized document is rejected as ImportError.TooLarge before it is materialized, with no write to the ledger. Decoding is still whole-payload — streaming row-by-row parsing is a known limitation, not a claim.

The same cap guards the export (issue #239): ExportRepository.exportJson serializes every visible transaction and measures the payload with ExportCodec.utf8Size — UTF-8 bytes, not characters, so a note full of é or emoji is counted at 2–4 bytes per character — and returns JsonExportResult.TooLarge instead of content when it exceeds ExportCodec.MAX_IMPORT_BYTES. The comparison is inclusive, because the bounded read accepts exactly the cap. The Settings screen stages a JSON export only from JsonExportResult.Ready, so an over-limit ledger never reaches the destination picker: no empty or un-restorable file is written, the source data is untouched, and the export stays retryable with copy that names the limit. CSV is deliberately not size-guarded — the import path is JSON-only, so a CSV file has no restore limit to violate and stays usable in a spreadsheet. Every visible row is exported, including pre-epoch and epoch occurredAt rows (#247); import accepts the full Long range, so any payload that reports Ready is restorable.

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). A rate must be a positive plain decimal with at most 6 significant decimal places after trailing zeros are removed and a magnitude no greater than 1,000,000; Fx.validateRate returns a typed Valid/Invalid result (MALFORMED, NON_POSITIVE, TOO_PRECISE, OUT_OF_RANGE) and rejects scientific notation (1e30), symbols, grouping separators, and strings longer than Fx.MAX_RATE_LENGTH (32 characters) before parsing — so a pathological stored value cannot burn BigDecimal cycles and is never truncated into a different valid rate. Fx.parseRate consumes that policy, so any out-of-policy stored value is treated as no rate (null) rather than a conversion. The Settings editor shows the typed error against the affected currency and keeps Save disabled; it renders the enabled currencies plus any currency whose draft rate is invalid, so a bad rate stored for a hidden currency (or hidden while its draft was invalid) still has a field to fix and the screen cannot be stuck unsavable with no visible cause. SettingsRepository.saveFxRates re-validates before writing and refuses the whole map with SaveFxRatesResult.InvalidRates (defence in depth); the generic put/putAll path rejects KEY_FX_RATES outright so only the validated, base-guarded write can set rates (the atomic base-switch clear and a blank/map-clear stay allowed). A blank field removes that currency's rate, and the card explains that the currency's transactions are then excluded from totals until a rate is entered. A rate is only meaningful against the base it was entered as: changing the base currency writes the new base and clears the stored rates in one transaction (SettingsRepository.setBaseCurrency), so the previous base's numbers are never reinterpreted under the new one. The user re-enters rates for the new base. Consumers read the base and its rates as one immutable snapshot (SettingsRepository.observeCurrencyConfig, a single settings query), so a new base cannot be observed paired with the previous base's rate map; a rate save whose base changed under the editor draft is rejected (SaveFxRatesResult.StaleBase) instead of being committed under the new base. Same-currency amounts pass through unchanged. Unknown currency, a missing rate or a rate outside the policy above returns null — those rows are excluded from the aggregate and the UI shows a "foreign-currency transactions excluded" footnote (hasUnconvertedForeign). Catalog membership is checked explicitly (Currency.isKnown(from)), so an unknown code is never converted even when the rate map happens to carry an entry for it; only a same-currency passthrough is allowed for an unknown code. Each trend month also keeps its own unconverted flag, and Reports rolls them into sixMonthUnconverted, so a missing rate in an earlier month still discloses that the 6-month total is incomplete even when the selected month converts cleanly. A conversion whose result falls outside Long range also returns null (the BigDecimal.longValueExact() overflow is caught, never thrown), so that row is treated as unconverted and excluded too, never silently wrapped. overflowed is a distinct signal: Fx.foldToBase sets Totals.overflowed only when the running sum of already-converted base amounts would exceed Long range; the value that triggered the overflow is absent from both amount and items, so the two views of the fold never disagree. So a per-row conversion failure grows the unconverted set, while an aggregate that cannot be represented is flagged as incomplete — neither is ever shown as a plausible wrong total.

The Home, Reports and Budget ViewModels use Fx.foldToBase to convert each amount before sumOf/maxOfOrNull; Transactions converts each row with Fx.toBase first and only then inverts an expense's sign with Math.negateExact, then sums each day with checked addition. Converting before negating means an expense whose stored amount is Long.MIN_VALUE but whose converted base amount is representable is not falsely flagged unavailable; only a converted base amount that is itself Long.MIN_VALUE has no representable negation. Slices, totals, trends, day headers and budget comparisons all land in base units (Transactions shows the unconverted footnote per day header).

Each of those folds carries its overflowed flag into the screen state as a separate availability signal — never only a combined "something overflowed" flag. Keep the two gaps distinct: an unconverted amount (missing rate or unknown currency) still has a usable converted subtotal, which stays visible with a disclosure footnote, while an overflowed / unrepresentable amount has no usable figure at all and renders as unavailable:

  • Home: an overflowing spend/income fold renders the hero amount (and income stat) as an em dash (amount_unavailable); an unconverted amount instead keeps its converted subtotal with the foreign-currency footnote, which names a valid-rate conversion that left Long range as out of range rather than a missing rate.
  • Reports: category slices, the selected-month total and each trend month keep their own overflowed flag. Each trend month also carries an unconverted flag (missing rate / unknown currency) that surfaces as sixMonthUnconverted, so an unconvertible earlier month is disclosed rather than silently producing a plausible-but-incomplete 6-month total; its converted subtotal still draws a bar and still scales the axis. A genuinely zero-spend month stays unflagged (it has no unconverted rows). The 6-month snapshot goes unavailable (em dash) only when a month or the running 6-month sum overflows Long — an unconverted month keeps the sum visible under the disclosure footnote. An overflowed amount cannot define a wedge angle or a bar height, so: an overflowed month draws a distinct unavailable marker (never a zero-height bar, so it cannot read as a genuine zero spend) and the trend enters its no-data state only when no month is overflowed or unconverted and the exact values establish no spending; an overflowed donut renders a single neutral, non-quantitative ring (slicesToPieData with an unavailableColor) with a — centre and no percentages, while the legend still lists every category and marks the unavailable ones. Rings and bars never scale off an overflowed value, and niceTrendAxis builds its ceiling and ticks with checked/arbitrary-precision arithmetic so an exact-maximum month cannot wrap the axis negative or saturate a tick through Int. mergeSmallSlices skips merging whenever any source slice is unavailable or the total is unrepresentable, so an unavailable amount is never ranked against a ghost total. Unavailable chart values use amount_unavailable visually and amount_unavailable_description for screen readers.
  • Transactions: an expense is converted to base units first, then negated, so only a converted base amount of Long.MIN_VALUE (whose - cannot be represented) flags its day unavailable — the day header shows the — marker even though there is no numeric net — rather than keeping a negative refund. A foreign expense whose stored amount is Long.MIN_VALUE but whose converted base amount is representable stays available.
  • Budget: each row carries its own spend availability. A per-category spend overflow, or a category whose every expense was excluded, renders the row amount unavailable (—) and suppresses its derived percent/progress; a partially converted spend shows its converted subtotal labelled budget_spent_partial and is likewise never ranked or read as "within budget"; a saved limit that could not be converted shows budget_limit_unavailable and stays clearable; and BudgetViewModel.percent uses exact BigDecimal math (truncating toward zero, clamped to Int range) so an exact-maximal spend-over-limit pair reads 100 instead of a wrapped 0. Rows whose spend is unavailable trail the utilization order (with the no-limit rows) rather than ranking on a partial ratio.

amountsUnavailable = unconverted || overflowed gates the footnote_amounts_unavailable note. An out-of-range figure is the more severe gap, so when a month is both unconvertible and overflowed the overflow note is shown (the foreign-currency copy is not suppressed silently). Budget keeps that precedence but also names the reason (BudgetAmountsGap): a valid rate whose result leaves Long range is an unrepresentable amount (footnote_amounts_unavailable, never "add rates"), a missing rate is footnote_unconverted_foreign, and a limit-only failure is footnote_limit_unavailable rather than the transaction-exclusion copy. Home and Activity carry the same reason-aware choice (AmountsGap): an out-of-range fold, or a valid rate whose converted result leaves Long range, is unrepresentable (footnote_amounts_unavailable, never "add rates"), and only a missing rate shows footnote_unconverted_foreign. Reports picks between three notes: overflow, a missing rate in the selected month, and a missing rate in an earlier month of the window (footnote_trend_unconverted, which names the 6-month total) — so a complete current month is never described as incomplete.