Skip to main content

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:

  1. Exact. 0.1 + 0.2 == 0.3 always.
  2. Round-trippable through JSON without precision loss.
  3. Comparable across systems (TS in apps/api, Go in services/ledger, Postgres).
  4. 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/money Money type (bigint amount + currency tag).
  • In Go. int64 santim + string currency.
  • In SQL. NUMERIC(20, 0), never DECIMAL(n, m) with m > 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 bigint major units (e.g. 1.5 ETB stored as 1.50). Rejected — collides with anyone who imports raw rows and forgets the implicit decimal.
  • Use a structured Money type in JSON with a number field. 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. See docs/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 flags Decimal/Float/Double columns 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/money is the only sanctioned Money type in TypeScript. Any other shape in code review is a blocker.