Skip to content

ADR-0005 — Represent money as bigint minor units + ISO-4217 alpha currency

Accepted2026-05-02

Every domain operation, ledger entry, balance, and budget limit deals in money. We need a representation that is:

  1. Lossless under arithmetic. No rounding surprises across add/sub/sum-of-many.
  2. Type-safe. Adding a UAH to a USD must be a hard error, not a silent merge.
  3. Database-friendly. The Postgres ledger needs to store amounts in a column type that survives precision and round-trips through asyncpg. The future TigerBeetle adapter (ADR-0001) needs a u128 integer.
  4. Boundary-friendly. User input arrives as decimal strings (“250.50”); display goes back as decimal strings. We need clean conversion at the edges without polluting the middle.
OptionSummaryProsConsOutcome
A — float everywhereOne Python float per amountSimplestNo money library uses floats. 0.1 + 0.2 ≠ 0.3. Catastrophic at scalerejected outright
B — Decimal everywhereUse stdlib Decimal as the sole money typeLossless under arithmetic; familiar to fintech engineersVariable precision (Decimal("1") + Decimal("1.0") == Decimal("2.0") — context-dependent quantization); serializing through asyncpg requires extra registration; TigerBeetle has no Decimal type so the swap requires a conversion layer in adapters; harder to express “currency-tagged” in the type systemrejected
C (chosen) — Money(amount_minor: int, currency: str)Frozen dataclass: integer amount in minor units (kopecks/cents), ISO-4217 alpha currency. Decimal only at parse/display boundaryLossless (int math is exact); type carries currency, so cross-currency arithmetic raises CurrencyMismatchError at the type level; native serialization to BIGINT in PG and to u128 (lossless) in TigerBeetle; the SCALE constant locks decimal placesManual scale conversion at the boundary (2-line helpers); changing scale later is a hard migrationselected

We will represent money as the value object domain.money.Money:

@dataclass(frozen=True, slots=True)
class Money:
amount_minor: int
currency: str # ISO-4217 alpha (3 uppercase letters)
SCALE: ClassVar[int] = 100 # locked

Locked invariants:

  • SCALE = 100 (two decimal places — kopecks for UAH, cents for USD). Increasing this is treated as a breaking change and would require a separate ADR.
  • Currency is the ISO-4217 alpha code (e.g. "UAH"). The numeric code (e.g. 980) is mapped only at the TigerBeetle boundary (see ADR-0010) — alpha is what lives in the schema, in logs, and in serialized payloads.
  • amount_minor may be negative. The Money type does not distinguish balance vs transfer-amount semantics; the LedgerPort write side enforces amount > 0 separately (AmountNotPositiveError).
  • All arithmetic and comparison raise CurrencyMismatchError on mixed currencies.
  • Money.from_decimal(d, currency) and Money.to_decimal() are the ONLY allowed conversion path between Money and decimal.Decimal. Domain code never touches Decimal directly.
  • Arithmetic is exact and never surprises.
  • The currency-mismatch check fires at the type system level, not in application logic.
  • Postgres column type is BIGINT (Plan 1’s app.user.default_currency is TEXT already; ledger schema in Plan 3 follows the same pattern).
  • TigerBeetle migration (ADR-0001) is mechanically lossless: copy amount_minor: int to Transfer.amount: u128; map currency alpha to ledger u32 numeric (ADR-0010).
  • Hypothesis property tests for arithmetic invariants (commutativity, associativity, identity, negation) are trivial to write.
  • Boundaries (parser input, display output, CSV import in Tier-3) need explicit from_decimal/to_decimal calls. Two lines, but enforce-able only by code review.
  • The locked SCALE means crypto-style microtransactions (8+ decimal places) are not supported on this ledger. A new ledger with a different SCALE would be a separate Money subtype — out of scope for MVP-1 through v1.
  • Currency is a str (validated by regex) rather than a Currency enum. We deliberately don’t enumerate ISO-4217 in code (180+ currencies; would bloat the codebase). Validation is by regex; the set of currencies actually used is bounded by what the ledger accepts on open_account.
  • When (if) the bot supports a second active currency, a Hypothesis test should generate currency pairs and assert CurrencyMismatchError fires on every cross-currency op (the test_cross_currency_addition_always_raises test seeds this pattern).
  • Plan 3’s PostgresLedgerAdapter must round-trip Money through the DB without scale drift; an integration test should pin this.
  • Stripe / Square / PayPal: all expose money as { amount: int, currency: str } with minor-unit semantics. Industry convention.
  • domain.money.Money — implementation.
  • tests/unit/domain/test_money*.py — contract + property tests.
  • ADR-0001 (Defer TigerBeetle) — the swap is the load-bearing reason for keeping the boundary integer-clean.
  • ADR-0009 (UUIDv7 for ledger IDs) — sister-decision about TB-friendly primitives.
  • ADR-0010 (ISO-4217 numeric mapped to TB ledger) — where the alpha currency code is converted to a numeric ledger id at the TB boundary.