ADR-0009: Money as Long minor units
- Status: Accepted
- Date: 2026-08-11
- Deciders: maintainers
- Related: money.md
Context
Money appears in parsing, storage, formatting, budgets, export/import, and comparisons. Floating point introduces rounding errors; strings invite duplication and inconsistent formatting.
Decision
All money is stored, parsed, formatted, and compared as Long minor
units (sen/cents/paise) paired with an ISO currency code. Floating point is
banned in the money path.
A single common/Currency object owns the currency tables: minor-digit
count, display symbols, and detection tokens. MoneyFormatter,
MoneyText, AmountParser, and the parser's currency detection all consume
it — a currency is added in exactly one place.
Consequences
TransactionEntity.amountMillis,TransactionCandidate.amountMillis,BudgetEntity.amountMillis, corrections, and report slices are allLongminor units.AmountParserconverts text → minor units (neverDouble); JPY uses 0 minor digits.- Display formatting and editable-number round-tripping (
MoneyFormatter/MoneyText) are pure Kotlin and unit-tested. - Export rows carry a decimal string + currency; import re-parses through
AmountParserso minor digits apply symmetrically.
Alternatives considered
Double/Float— rejected: rounding errors on sums and comparisons.BigDecimaleverywhere — rejected: heavier, and DB storage would need text or scaled integers anyway.- Per-feature currency handling — rejected: drift; the tables centralize it.
Amendment (2026-10-19): checked arithmetic and unavailable aggregates
The representation is unchanged, but "money is Long minor units" now also
means arithmetic is checked rather than wrapping:
AmountParserrejects a digit run or minor-unit scaling that leavesLongrange (null, never0or a sign flip) and rejects structurally malformed separator runs (0..0,1,,2,12.50.60).Long.MIN_VALUEis representable; one unit past either end is not.Fx.toBasecatches theBigDecimal.longValueExact()overflow and returnsnull(unconvertible) instead of throwing;Fx.foldToBaseuses checked addition and setsTotals.overflowedwith a partial, never-wrapped amount;Itemization.sumreturns a null sum on overflow.- Consumers must not render a partial aggregate as a number: Home, Activity,
Budgets and Reports surface an explicit unavailable state (
—plus thefootnote_amounts_unavailablenote, with localized accessibility copy), and the Reports charts switch to a non-quantitative representation rather than deriving geometry, percentages or tooltips from a partial value.