Representing Money and Decimals in API Schemas
Symptom: the order service reports a total of 59.97, the invoicing service reports 59.970000000000006, and a reconciliation job flags the pair as a mismatch. Nothing validated incorrectly and no exception was thrown. This guide is part of Data Format and Precision Contracts and covers the money case specifically.
Root Cause: JSON Numbers Are Binary Floats
JSON’s grammar allows arbitrary decimal notation, but almost every runtime parses a number into an IEEE 754 double. Decimal fractions such as 0.1, 0.2 and 19.99 have no exact binary representation, so what arrives is the nearest representable value.
19.99 * 3 // 59.97000000000001
0.1 + 0.2 === 0.3 // false
(1.005).toFixed(2) // "1.00" — the stored value is just under 1.005
JSON.parse('{"a":19.99}').a // 19.99 when printed; not 19.99 in binary
The error is tiny per operation and compounds across a total, a tax calculation and a currency conversion — and it differs between languages, because their default rounding at print time differs.
Step 1: Choose Integer Minor Units for Currency Amounts
components:
schemas:
Money:
type: object
required: [amount, currency]
additionalProperties: false
description: |
A monetary value. `amount` is an integer count of the currency's minor
unit — cents for EUR, pence for GBP, whole yen for JPY. It is never a
decimal and never a string. 1999 with currency EUR is EUR 19.99.
properties:
amount:
type: integer
minimum: -1000000000000 # bounded so a bad value cannot overflow downstream
maximum: 1000000000000
currency:
type: string
pattern: '^[A-Z]{3}$'
description: ISO 4217 code; determines the exponent for `amount`.
Why the pair is one object: the amount alone is meaningless. 1999 is EUR 19.99, JPY 1,999, or BHD 1.999 depending on the currency’s exponent, and a schema that separates the two invites a consumer to display the wrong number.
// money.ts — the exponent table travels with the code, not in someone's head
const EXPONENT: Record<string, number> = { JPY: 0, KRW: 0, BHD: 3, KWD: 3, TND: 3 };
export const exponentFor = (currency: string) => EXPONENT[currency] ?? 2;
export function format(money: { amount: number; currency: string }, locale = "en-GB") {
const exp = exponentFor(money.currency);
return new Intl.NumberFormat(locale, {
style: "currency", currency: money.currency,
}).format(money.amount / 10 ** exp);
}
The division happens once, at the display boundary, and never in business logic.
Step 2: Use Decimal Strings Where Precision Exceeds the Currency
Unit prices, tax rates and exchange rates often need more precision than a currency’s minor unit. A decimal string keeps them exact.
LineItem:
type: object
required: [sku, quantity, unit_price, line_total]
additionalProperties: false
properties:
sku: { type: string, pattern: '^[A-Z0-9-]{3,32}$' }
quantity:
type: string
pattern: '^-?[0-9]{1,10}(\.[0-9]{1,6})?$'
description: Decimal string; up to six places for weighed goods.
example: "2.750"
unit_price:
type: string
pattern: '^-?[0-9]{1,10}(\.[0-9]{1,4})?$'
description: |
Price per unit as a decimal string, up to four places. NOT rounded to
the currency's minor unit — that happens only on `line_total`.
example: "12.3456"
line_total:
$ref: '#/components/schemas/Money'
description: quantity x unit_price, rounded ONCE, half away from zero.
The anchored pattern is doing real work: without it a consumer can send "12.3456e2", "12,3456" or " 12.3456 ", all of which are strings and none of which parse the way you expect. The reasoning is the same as in using pattern and format keywords safely.
Step 3: Name the Single Rounding Point
Rounding twice is how two correct implementations disagree. Fix one place and document the rule.
// pricing.ts — decimal arithmetic, one rounding, at the end
import { Decimal } from "decimal.js";
export function lineTotal(quantity: string, unitPrice: string, currency: string) {
const exact = new Decimal(quantity).times(new Decimal(unitPrice)); // exact, unrounded
const exp = exponentFor(currency);
// ROUND_HALF_UP, applied ONCE, at the moment the value becomes money.
const minorUnits = exact.times(10 ** exp).toDecimalPlaces(0, Decimal.ROUND_HALF_UP);
return { amount: minorUnits.toNumber(), currency };
}
// 2.750 x 12.3456 = 33.9504 -> 3395 cents (EUR 33.95)
State the rule in the contract so consumers can reproduce it:
info:
description: |
## Monetary arithmetic
Line totals are computed as `quantity x unit_price` at full precision and
rounded **once**, half away from zero, to the currency's minor unit. Order
totals are the **sum of already-rounded line totals** — we do not re-round
the sum. Reproducing our numbers requires applying the same order.
Step 4: Enforce It at the Edge
import { z } from "zod";
export const money = z.object({
amount: z.number().int(), // 19.99 is rejected, not truncated
currency: z.string().regex(/^[A-Z]{3}$/),
}).strict();
export const decimalString = (maxPlaces: number) =>
z.string().regex(new RegExp(`^-?\\d{1,10}(\\.\\d{1,${maxPlaces}})?$`));
export const lineItem = z.object({
sku: z.string().regex(/^[A-Z0-9-]{3,32}$/),
quantity: decimalString(6),
unit_price: decimalString(4),
line_total: money,
}).strict();
z.number().int() is the important line: without it, a client sending 19.99 where cents were expected has its value silently truncated to 19 somewhere downstream.
Verification
test("integer minor units are exact where floats are not", () => {
expect(1999 * 3).toBe(5997);
expect(19.99 * 3).not.toBe(59.97); // the reason this page exists
});
test("a decimal amount is rejected, not truncated", () => {
expect(() => money.parse({ amount: 19.99, currency: "EUR" })).toThrow();
});
test("zero-exponent currencies are handled", () => {
expect(format({ amount: 1999, currency: "JPY" }, "en-GB")).toBe("¥1,999");
expect(format({ amount: 1999, currency: "EUR" }, "en-GB")).toBe("€19.99");
});
test("line totals round once, half away from zero", () => {
expect(lineTotal("2.750", "12.3456", "EUR")).toEqual({ amount: 3395, currency: "EUR" });
expect(lineTotal("1", "0.005", "EUR")).toEqual({ amount: 1, currency: "EUR" }); // not 0
});
test("the order total is the sum of rounded lines", () => {
const lines = [3395, 1201, 749];
expect(lines.reduce((a, b) => a + b, 0)).toBe(5345);
});
The JPY test is the one that catches a hard-coded division by 100 — the most common bug in an otherwise correct minor-units implementation.
Edge Cases and Caveats
- Currencies without a minor unit. JPY and KRW have exponent zero; BHD, KWD and others have three. A hard-coded
/ 100is wrong for all of them. Keep the table and use it at every display boundary. - Negative zero.
-0is representable and serialises as-0in some encoders. Normalise to0before comparison, or a refund of exactly nothing will fail an equality check. - Percentages. Store as integer basis points — 7% is
700— for the same reason as money. A tax rate of0.07is not exactly representable. - Exchange rates. Carry the rate, the source amount and the converted amount together. Recomputing a conversion from a rounded target never reproduces the source, and disputes need the arithmetic to be visible.
- Database column types. A
NUMERIC/DECIMALcolumn read into a JavaScript number reintroduces the whole problem at the persistence boundary. Read it as a string and convert to minor units explicitly. - Aggregations in analytics. A reporting pipeline summing floats will disagree with your API’s integer totals. Feed the pipeline minor units too, and let it divide only for presentation.
Presenting Money to Users
The contract stops at the wire, but the decision it encodes has one more consequence worth stating: formatting is the consumer’s job, not the API’s.
An API that returns a preformatted string — "€19.99" — has made three decisions on the client’s behalf: the locale, the currency symbol placement, and the grouping separator. All three are wrong for some users, and none of them can be corrected without a new field. Returning the integer and the currency code lets each client format for its own audience with the platform’s own facilities, which is both more correct and less work for you.
The exception is a value the API computes and the client must display verbatim for legal reasons — a tax breakdown on an invoice, a regulated disclosure. There, send the formatted string alongside the structured value rather than instead of it, and document which is authoritative.
One Line to Remember
Money is an integer count of minor units travelling beside its currency code, rounded exactly once, at a point the contract names. Everything else on this page follows from that sentence.
Frequently Asked Questions
Minor units or decimal strings — which should I choose?
Minor units for anything that is a real currency amount: they are compact, exact, and arithmetic works with ordinary integers. Decimal strings for values needing more than the currency’s natural precision — unit prices to four places, tax rates, exchange rates.
How do I know how many minor units a currency has?
From ISO 4217, which assigns each currency an exponent: two for EUR and GBP, zero for JPY, three for BHD. Ship the table with your code rather than assuming two, and always transport the currency code alongside the amount.
Can I use multipleOf to constrain decimals?
For a JSON number, multipleOf: 0.01 does express the intent, but validators evaluate it in floating point and results near the boundary are unreliable. If you are already using integers or strings you do not need it.
Where should rounding happen?
In exactly one place, named in the contract. Rounding at several layers compounds: a half-up rounding after a percentage and again after a sum can differ from a single rounding at the end by a cent per line.
What about currency conversion?
Carry the rate, the source amount and the converted amount, all three. Recomputing a conversion from a rounded result never reproduces the original, and reconciliation depends on being able to show the arithmetic.