Aller au contenu

ADR-001 — Money representation

Status: Accepted (2026-05-18). Supersedes nothing.

Context

The payments epic introduces commission splits, an escrow ledger, and Mobile-Money withdrawals. All of these are exact-arithmetic problems: a 0.30 commission rate on a 12 000 XOF payment must round-trip without drift, and the sum of every closer's split must equal the gross to the cent.

Today the codebase has zero money primitives. WSMembership.commissionRate is stored as FLOAT, which loses precision for ratios like 0.1 or 0.30 (0.1 + 0.2 !== 0.3 in IEEE-754). Doing escrow arithmetic on floats is a known source of off-by-one-cent bugs that we will not absorb.

Decision

Internal representation: Decimal everywhere

  • All monetary amounts and commission rates are manipulated in the codebase as Decimal (from decimal.js) — never JS number.
  • A small Money wrapper (domain/money/money.ts) pairs a Decimal with a Currency and forbids cross-currency arithmetic at the type level. Every reachable boundary that builds a Money calls assertSameCurrency before adding / subtracting.
  • The wrapper exposes add, subtract, multiply, divide, and formatAmount only. There is no FX conversion API — see "Out of scope" below.

Storage: plain DECIMAL columns, manual mapping

  • Amounts persist as DECIMAL(20, 4). 4 decimal places gives us sub-minor-unit precision for every currency we support (XOF/XAF have no minor unit at the user level but still need fractional arithmetic for commission splits).
  • Commission rates persist as DECIMAL(10, 6). Six decimal places lets us represent ratios like 0.333333 cleanly.
  • We do not wrap Money as a custom Sequelize column type. Each model exposes a plain DECIMAL column and converts to/from Decimal inside the domain entity's fromRecord(attrs). The amount of code is small (one line per field) and the absence of a custom type means migrations stay readable in plain SQL.

Currency scale at the boundary

  • For display only, each currency has a fixed minor-unit scale (XOF/XAF: 0; USD/EUR/KES/NGN/GHS/ZAR/EGP/MAD: 2). Money.formatAmount() rounds to that scale and produces a locale-agnostic string. Localised formatting (separators, currency symbol placement) belongs in the frontend.

Wire format

  • For backwards compatibility with the v1 API, monetary fields and commission rates are serialised as JSON number. Internally a Decimal round-trips through DECIMAL(n, k)Decimal → service arithmetic → DB without drift. The JS number only appears at the HTTP edge, where the loss of precision is the frontend's concern (and matches what the wire would carry anyway).

Out of scope

  • FX / currency conversion. Every wallet in v1 is single-currency: a workspace charges in its currency field and pays out in the same currency. assertSameCurrency is the only enforcement we need — no FX rates, no conversion table, no historical rate snapshots.
  • A Money Sequelize column type. Plain DECIMAL + a per-field new Decimal(attrs.x) in fromRecord is enough. Reintroduce a wrapper only when we have three or more models that need the same conversion logic.
  • Banker's rounding / per-jurisdiction rounding rules. decimal.js defaults (ROUND_HALF_UP) are fine for v1. Revisit when an accountant tells us otherwise.

Consequences

  • Adding a new monetary field is mechanical: DECIMAL(20, 4) migration column → DataTypes.DECIMAL(20, 4) on the model → new Decimal(attrs.x) in fromRecordDecimal field on the domain entity. The Money helper is only needed when arithmetic happens.
  • Every read path that exposes a money/rate value over HTTP must explicitly .toNumber() (or .toFixed(...) for display) — there is no transparent serialisation, on purpose.
  • A future migration to JSON-string wire format (when we ship a UI that wants precision) only changes the API boundary; storage and domain stay put.