Skip to content
Insights

The column called amount_cents will overcharge Japan by 100x

5 min readPayments

Almost every payments schema starts the same way. Someone learns not to store money in floats — correctly — and writes:

amount_cents integer not null

This is right for dollars, euros, pounds and most of what you will ever invoice. It is wrong the first time you bill a Japanese client, and it is wrong by a factor of one hundred.

Zero-decimal currencies

The Japanese yen has no subunit. ¥1,000 is one thousand yen, not ten yen and some change. Korean won, Vietnamese dong, Chilean peso, Paraguayan guaraní and several others behave the same way. Stripe and most payment APIs handle this correctly — they take an amount in the currency's smallest unit, and for JPY the smallest unit IS the yen.

So a column named amount_cents holding 100000 means one thousand dollars, and one hundred thousand yen. If your code multiplies the human number by 100 on the way in, you have just invoiced ¥100,000 for a ¥1,000 job.

Three-decimal currencies exist too

Bahraini dinar, Kuwaiti dinar, Jordanian dinar and Omani rial use three decimal places. The smallest unit is a thousandth. A schema that assumes two decimals undercharges these by a factor of ten.

The fix is naming and one function

Store the amount in the currency's own minor unit, and name the column so nobody can misread it. Then centralise the conversion so there is exactly one place that knows the exponent:

const ZERO_DECIMAL = new Set(["JPY", "KRW", "VND", "CLP", "PYG", "ISK"]);
const THREE_DECIMAL = new Set(["BHD", "KWD", "JOD", "OMR", "TND"]);

function exponent(currency: string): 0 | 2 | 3 {
  const c = currency.toUpperCase();
  if (ZERO_DECIMAL.has(c)) return 0;
  if (THREE_DECIMAL.has(c)) return 3;
  return 2;
}

export function toMinorUnits(amount: number, currency: string): number {
  return Math.round(amount * 10 ** exponent(currency));
}

Two rules make this hold: the raw column is never read directly outside this module, and no display code divides by 100. If a template anywhere contains a literal 100, that is the bug waiting to happen.

Also: reject what you cannot represent

If someone enters ¥1,000.50, there is no such amount. Rounding silently is a decision your accounting will inherit. Validate on the way in and refuse it — an error at entry is cheaper than a discrepancy at reconciliation.

None of this is hard. It is just invisible until the first invoice in a currency nobody tested, and by then the money has moved.

Building something where these decisions matter?

Start a project