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(fromdecimal.js) — never JSnumber. - A small
Moneywrapper (domain/money/money.ts) pairs aDecimalwith aCurrencyand forbids cross-currency arithmetic at the type level. Every reachable boundary that builds aMoneycallsassertSameCurrencybefore adding / subtracting. - The wrapper exposes
add,subtract,multiply,divide, andformatAmountonly. 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 like0.333333cleanly. - We do not wrap
Moneyas a custom Sequelize column type. Each model exposes a plainDECIMALcolumn and converts to/fromDecimalinside the domain entity'sfromRecord(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 aDecimalround-trips throughDECIMAL(n, k)→Decimal→ service arithmetic → DB without drift. The JSnumberonly 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
currencyfield and pays out in the same currency.assertSameCurrencyis the only enforcement we need — no FX rates, no conversion table, no historical rate snapshots. - A
MoneySequelize column type. PlainDECIMAL+ a per-fieldnew Decimal(attrs.x)infromRecordis 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.jsdefaults (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)infromRecord→Decimalfield 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.