Every engineer knows 0.1 + 0.2 !== 0.3. Fewer have watched that fact spread through a billing system, where a credit balance flows through four stores. An in-memory store for enforcement, a wide-column key-value store for dashboards, a relational database for the ledger, and JSON for the API. Each hop is a chance for a float to drift a millionth of a cent away from the truth. Drift between the enforcement store and the ledger isn't a rounding quirk. It's the balance reconciliation incident you get paged for at month's end.
There is a second trap specific to the Node and Postgres world. The driver returns numeric columns as strings, because JavaScript numbers can't represent them safely. Code that "just parses" them reintroduces the float problem at the exact boundary the database was protecting.
The layered integer model
On one of the billing engines I've built, the fix is that money is integers everywhere. But different integers per layer, each scaled to the layer's job:
- The in-memory store holds micro-credits. Credit values scaled by 1,000,000 into plain integers. Atomic scripts do integer arithmetic on them, and no float goes anywhere near the hot path.
- The ledger holds whole credits as bigint. An audit trail in the relational database of every wallet movement, in the unit humans reason about.
- Consumed usage accrues as numeric(20,6). Fractional credits incremented atomically by the settlement worker, precise to the token.
- Dashboards read integer cents from the key-value store. Denormalized at write time, so read paths display cost without ever recomputing pricing.
That last clause carries more weight than it looks like it does. Pricing logic exists in exactly one place, where charges are computed. Every other layer stores the result in its own unit. Nothing downstream multiplies tokens by rates, so nothing downstream can disagree about what something cost.
Conventions, codified
The model only holds if every read and write respects it, so the coercion rules are written into the repository's conventions rather than tribal memory. Numeric columns are declared as strings in the schema types, converted at exactly one boundary, and never touched by parseFloat. New code gets reviewed against the rule, and the rule doesn't rely on everyone remembering the incident that created it.
This is close to the position banks landed on decades ago, translated to a polyglot store. What makes it work is the discipline: pick the integer unit each layer needs, write down who converts where, and treat any float touching money as a bug even when the test still passes.