Skip to content

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 all Long minor units.
  • AmountParser converts text → minor units (never Double); 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 AmountParser so minor digits apply symmetrically.

Alternatives considered

  • Double/Float — rejected: rounding errors on sums and comparisons.
  • BigDecimal everywhere — 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:

  • AmountParser rejects a digit run or minor-unit scaling that leaves Long range (null, never 0 or a sign flip) and rejects structurally malformed separator runs (0..0, 1,,2, 12.50.60). Long.MIN_VALUE is representable; one unit past either end is not.
  • Fx.toBase catches the BigDecimal.longValueExact() overflow and returns null (unconvertible) instead of throwing; Fx.foldToBase uses checked addition and sets Totals.overflowed with a partial, never-wrapped amount; Itemization.sum returns 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 the footnote_amounts_unavailable note, 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.