ADR-0005 — Represent money as bigint minor units + ISO-4217 alpha currency
Цей контент ще не доступний вашою мовою.
Status
Section titled “Status”Accepted — 2026-05-02
Context
Section titled “Context”Every domain operation, ledger entry, balance, and budget limit deals in money. We need a representation that is:
- Lossless under arithmetic. No rounding surprises across add/sub/sum-of-many.
- Type-safe. Adding a UAH to a USD must be a hard error, not a silent merge.
- 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.
- 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.
Considered alternatives
Section titled “Considered alternatives”| Option | Summary | Pros | Cons | Outcome |
|---|---|---|---|---|
A — float everywhere | One Python float per amount | Simplest | No money library uses floats. 0.1 + 0.2 ≠ 0.3. Catastrophic at scale | rejected outright |
B — Decimal everywhere | Use stdlib Decimal as the sole money type | Lossless under arithmetic; familiar to fintech engineers | Variable 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 system | rejected |
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 boundary | Lossless (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 places | Manual scale conversion at the boundary (2-line helpers); changing scale later is a hard migration | selected |
Decision
Section titled “Decision”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 # lockedLocked 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_minormay be negative. The Money type does not distinguish balance vs transfer-amount semantics; the LedgerPort write side enforcesamount > 0separately (AmountNotPositiveError).- All arithmetic and comparison raise
CurrencyMismatchErroron mixed currencies. Money.from_decimal(d, currency)andMoney.to_decimal()are the ONLY allowed conversion path between Money anddecimal.Decimal. Domain code never touchesDecimaldirectly.
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- 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’sapp.user.default_currencyisTEXTalready; ledger schema in Plan 3 follows the same pattern). - TigerBeetle migration (ADR-0001) is mechanically lossless: copy
amount_minor: inttoTransfer.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.
Negative / trade-offs
Section titled “Negative / trade-offs”- Boundaries (parser input, display output, CSV import in Tier-3)
need explicit
from_decimal/to_decimalcalls. 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 aCurrencyenum. 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 onopen_account.
Neutral / follow-ups
Section titled “Neutral / follow-ups”- 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_raisestest seeds this pattern). - Plan 3’s PostgresLedgerAdapter must round-trip Money through the DB without scale drift; an integration test should pin this.
References
Section titled “References”- 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.