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:
SPECS— code → digits + symbol (minorDigits(),symbol(),supportedCodes(),isKnown()).TOKENS— loose alternation consumed byAmountParser(amountTokenPattern,currencyOfToken). Every catalog code has an ISO-code token plus its symbol(s), andcurrencyOfTokenreturns the token matching earliest in the amount span, breaking ties by the longest match — soRMBwins over itsRMprefix andCN¥over the bare¥without depending on list order.DETECT— word-boundary-safe whole-text detection (detect,currencyNearestTo). Prefixed-dollar entries precede the standalone$; the JPY entry is(?<!CN)¥soCN¥cannot be read as JPY;Rpuses a letter-lookbehind (not\b) becauseRp50has no word boundary. TheCN¥detector and the yen lookbehind are case-insensitive, matchingcurrencyOfToken, socn¥100cannot 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
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.- The display string keeps the authored order: the calling surface prefixes the
direction
+/-, andMoneyFormatteradds 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 storedLongvalues 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 leftLongrange as out of range rather than a missing rate. - Reports: category slices, the selected-month total and each trend month keep
their own
overflowedflag. Each trend month also carries anunconvertedflag (missing rate / unknown currency) that surfaces assixMonthUnconverted, 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 overflowsLong— 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 (slicesToPieDatawith anunavailableColor) 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, andniceTrendAxisbuilds its ceiling and ticks with checked/arbitrary-precision arithmetic so an exact-maximum month cannot wrap the axis negative or saturate a tick throughInt.mergeSmallSlicesskips 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 useamount_unavailablevisually andamount_unavailable_descriptionfor 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 isLong.MIN_VALUEbut 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 labelledbudget_spent_partialand is likewise never ranked or read as "within budget"; a saved limit that could not be converted showsbudget_limit_unavailableand stays clearable; andBudgetViewModel.percentuses exactBigDecimalmath (truncating toward zero, clamped toIntrange) so an exact-maximal spend-over-limit pair reads100instead of a wrapped0. 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.