ADR-005: Money is NUMERIC(20, 0) in santim; floats forbidden
- Status: Accepted
- Date: 2026-05-23
- Deciders: CTO / Founding Engineering / Ledger team
Context
Money in DemozPay must be:
- Exact.
0.1 + 0.2 == 0.3always. - Round-trippable through JSON without precision loss.
- Comparable across systems (TS in
apps/api, Go inservices/ledger, Postgres). - Auditable — the same value in three systems must match byte-for-byte.
IEEE-754 floats fail (1). Prisma.Decimal mostly works inside Node but loses precision on JSON serialization and conflicts with bigint-aware downstream consumers. Storing money as Decimal(15, 2) in the database (the pre-restructure state) carries silent rounding bugs and forces every domain to re-derive math.
Decision
- Storage. Every money column is
NUMERIC(20, 0), units = minor (santim for ETB; 1 ETB = 100 santim). - Wire format (gRPC).
int64 santim+string currency. - Wire format (JSON).
{ "santim": "<int64 as string>", "currency": "ETB" }. Stringified because JSON numbers can lose precision past 2^53. - In TypeScript. Use
packages/shared/moneyMoneytype (bigint amount + currency tag). - In Go.
int64santim + string currency. - In SQL.
NUMERIC(20, 0), neverDECIMAL(n, m)withm > 0.
Alternatives considered
- Continue with
Decimal(15, 2). Rejected — see Context (1) and (2). Already produced silent rounding in the pre-restructure code path. - Use
bigintmajor units (e.g. 1.5 ETB stored as1.50). Rejected — collides with anyone who imports raw rows and forgets the implicit decimal. - Use a structured Money type in JSON with a
numberfield. Rejected — JSON numbers max safe integer is 2^53 ≈ 9 quadrillion santim; ETB amounts in billions of santim are fine today but a single JSON deserialization slip-up under high amount + the precision is silently gone.
Consequences
Positive
- One arithmetic model end-to-end. No "convert to decimal here, back to bigint there."
- Exact ledger reconciliation: the same value byte-equal across TS, Go, and Postgres.
Money.allocate(n)distributes remainders deterministically — no santim ever lost to rounding.
Negative
- A migration is required for existing
Decimal(15, 2)money columns. Tracked separately, not bundled into this restructure. Seedocs/architecture/restructure-2026-05.md"Non-goals". - Developers must remember "money is in santim, not birr" when reading data manually. Mitigated by
Money.format()showing major units in user-facing contexts.
Follow-ups
tooling/prisma-lint/script flagsDecimal/Float/Doublecolumns whose name matches*amount*,*balance*,*pay*,*price*,*fee*. Initial run reports today's violations as warnings; subsequent PRs adding new violations fail CI.- Separate migration PR converts existing money columns to
NUMERIC(20, 0); that PR also removes derived balance columns (see ADR-006). packages/shared/moneyis the only sanctioned Money type in TypeScript. Any other shape in code review is a blocker.