Skip to main content

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.

Where the cent goes The literal 19.99 is stored as a slightly different binary value; multiplying by three amplifies the difference into a visible discrepancy, while the same arithmetic in integer minor units stays exact. as a JSON number 19.99 stored: 19.989999999999998 x3 = 59.97000000000001 as integer minor units 1999 (EUR cents) stored: exactly 1999 x3 = exactly 5997

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.
Rounding order changes the answer Three line items are computed both ways: rounding each line then summing gives one total, while summing at full precision then rounding gives a total one cent different. round each line, then sum 33.9504 to 33.95 12.0050 to 12.01 7.4949 to 7.49 total 53.45 sum exactly, then round 33.9504 12.0050 7.4949 53.4503 to total 53.45 Both are defensible; only one can be yours, and it has to be written down.

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();
The same integer, three different amounts The value 1999 means 19.99 in EUR, 1999 in JPY which has no minor unit, and 1.999 in BHD which has three; a hard-coded division by one hundred is wrong for two of the three. amount: 1999 — what the user sees EUR exponent 2 19.99 the assumed case JPY exponent 0 1,999 a hundred times too small BHD exponent 3 1.999 ten times too large Which is why the currency travels in the same object as the amount.

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 / 100 is wrong for all of them. Keep the table and use it at every display boundary.
  • Negative zero. -0 is representable and serialises as -0 in some encoders. Normalise to 0 before 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 of 0.07 is 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/DECIMAL column 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.