BOLD Tech Labscontact us
/ writing / storing-money-as-minor-units← all writing
date2026-02-18
tagengineering
length4 min read
byBOLD Tech Labs

Storing money as minor units, end to end

Why every amount in our systems is an integer, and the three places float arithmetic still tries to sneak back in.

Ask a floating-point number to hold 0.1 and it will hold 0.100000000000000005551... instead, because binary fractions cannot represent most decimal ones. That error is invisible until you add a few thousand of them, at which point an invoice total disagrees with the sum of its lines by a cent — and in an invoicing system, a cent of disagreement is not a small bug. It is a document that fails validation, a reconciliation that never closes, an auditor with a question.

So the rule in our systems is absolute: money is an integer count of the currency’s minor unit. Not 19.99 euros but 1999 cents; not 250.500 dinars but 250500 fils. Integers add, subtract, and compare exactly. Every amount travels with its currency code, arithmetic happens on integers, and conversion to a decimal string happens exactly once — at the rendering edge, on the way to a human’s screen or a formatted document.

Two refinements make the rule survive contact with reality.

The exponent is per-currency, not a constant. The habit of “multiply by 100” encodes an assumption that every currency has two decimals. The Japanese yen has zero. The Bahraini and Kuwaiti dinars have three — which matters rather a lot if you do business in the Gulf. ISO 4217 defines the exponent per currency, so a proper money value is really a triple: integer amount, currency code, and the exponent the code implies. Encapsulate it in a Money type and make raw-integer arithmetic on amounts a code-review offense; the type is small, and it is the only place allowed to know what a “cent” is.

Rounding is a policy, not an accident. Integer storage does not eliminate rounding — it forces you to decide where it happens. Divide 1000 cents three ways and something must absorb the remainder. Compute VAT per line and the document total may differ from VAT computed on the sum, which is exactly why e-invoicing schemas make you declare line-level versus document-level calculation. Our approach: every operation that can produce a fraction (percentages, division, FX) names its rounding mode explicitly, and allocations use largest-remainder distribution so the parts always sum to the whole. When a validator checks that your lines add up to your total, “always” is the only acceptable frequency.

That is the easy 90%. The remaining 10% is defending the boundary, because float arithmetic re-enters through three doors — reliably enough that we check all three in every integration review.

1. JSON, and everyone else’s APIs

A JSON number is, in nearly every parser, an IEEE 754 double. The moment {"amount": 1999.99} passes through JSON.parse, it is a float — regardless of how carefully the sender computed it. And you do not control the sender: accounting platforms’ APIs serialize amounts as decimal JSON numbers, so a faithful integration must catch them at the door. The defenses, in order of preference: parse amounts from the raw string with a decimal-aware parser; accept the number but convert to minor units immediately at the mapping layer with explicit rounding, before any arithmetic; and reconcile — after mapping an external invoice, re-add the lines and compare to the transmitted total, because a one-cent mismatch caught at import time is a log line, while the same mismatch caught at validation time is a rejected document. On the way out, serialize amounts as strings ("1999.99") when the counterparty allows it, and let the schema document that choice.

2. The database layer

Integer in the application means nothing if the column is FLOAT. The storage rules: amounts are BIGINT minor units (or NUMERIC where you must store decimals, such as staging raw API payloads — never REAL/DOUBLE). The subtler leak is the ORM: some drivers map NUMERIC to a native float type on read, quietly undoing the schema’s care, so check what type your amounts actually have in memory after a round trip — once, in a test that stays in the suite. Aggregation belongs in SQL (SUM over integers is exact and fast), and if analysts export to spreadsheets, remember that a spreadsheet cell is a double too: totals computed in the database, formatted amounts in the export.

3. Rates, percentages, and FX

The third door is arithmetic that is not itself money: a 5% VAT rate, a 2.5% discount, an exchange rate of 4.9752. Write amount * 0.05 and the float is back — the integer discipline dissolved by one innocent-looking literal. Rates get the same treatment as amounts: scaled integers with a declared scale (basis points for percentages, a fixed-scale integer or decimal type for FX rates), multiplication as integer math with one explicit rounding step at the end. A VAT computation is then fully deterministic: round_half_up(amount * rate_bp / 10_000), same inputs, same output, on every runtime and every machine — which is precisely what a tax authority’s validator assumes about your documents.

The shape of the solution

None of this is clever, and that is the point. One Money type owning amount, currency, and exponent; one boundary layer converting the outside world’s decimals at the door with reconciliation checks; rates as scaled integers; rounding always explicit and always named. The float never disappears from the ecosystem — JSON, spreadsheets, and other people’s APIs guarantee that — but it can be kept permanently outside the region where arithmetic happens. Draw that region deliberately, defend its three doors, and “the lines don’t sum to the total” becomes a class of bug your system simply does not have.

written by BOLD Tech Labs · bucharest / dubai← all writing