Zero decimal currency
Ask the runtime instead of assuming two decimal places. Intl.NumberFormat('en-US', { style: 'currency', currency: 'JPY' }).resolvedOptions().maximumFractionDigits returns 0, against 2 for USD and 3 for KWD. Multiply a JPY amount by 100 and the charge is a hundred times the price.
Why check this
Run this when a service adds a currency, when it starts storing amounts as integers, and on the first release that touches a market outside the two decimal set. The failure it prevents is a customer in Tokyo charged 125,000 yen for a 1,250 yen order, which is step 2 on this page.
The bug is usually one constant. Somebody wrote amount * 100 when amounts moved to minor units, tested it in USD and EUR, and shipped. Nothing in the code names a currency, so nothing in review looks wrong. The error only appears against a currency whose exponent is not 2, and the direction of the error changes with the exponent: a hundredfold overcharge in JPY, a tenfold undercharge in KWD.
Integer minor units are the right storage, and this check is what makes them safe. The pair that has to travel together is the integer and the currency code. An integer alone carries no scale.
Prerequisites
- Node 22 or later with full ICU.
node -p "process.versions.icu"printed78.2here, which knows 162 currencies. A small-ICU build knows far fewer and answers 2 for most of them. - The list of currency codes the service accepts, as ISO 4217 alpha codes.
- MDN on resolvedOptions and the CLDR supplemental currency data, which is where the digit counts come from.
Save this as minor.mjs for step 2. It converts one displayed amount per currency twice, by 100 and by the currency's own power of ten, and formats both results back.
// node minor.mjs
const digits = (cur) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: cur }).resolvedOptions().maximumFractionDigits;
const money = (cur, minor) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: cur }).format(minor / 10 ** digits(cur));
console.log('cur d displayed x100 reads back x10^d reads back');
for (const [cur, amount] of [['USD', 12.5], ['JPY', 1250], ['KWD', 12.5], ['ISK', 1250]]) {
const d = digits(cur);
const naive = Math.round(amount * 100);
const right = Math.round(amount * 10 ** d);
console.log(cur, ' ' + d, String(amount).padStart(10), String(naive).padStart(8),
money(cur, naive).padStart(12), String(right).padStart(8), money(cur, right).padStart(12));
}
Save this as census.mjs for step 3. It groups every currency the runtime knows by its decimal count.
// node census.mjs
const all = Intl.supportedValuesOf('currency');
const by = new Map();
for (const c of all) {
const d = new Intl.NumberFormat('en-US', { style: 'currency', currency: c }).resolvedOptions().maximumFractionDigits;
if (!by.has(d)) by.set(d, []);
by.get(d).push(c);
}
console.log('currencies known to this ICU:', all.length);
for (const d of [...by.keys()].sort()) {
const list = by.get(d);
console.log(d + ' decimal places:', String(list.length).padStart(3), list.slice(0, 12).join(' ') + (list.length > 12 ? ' …' : ''));
}
const odd = all.length - by.get(2).length;
console.log('not two decimal places :', odd, '(' + (100 * odd / all.length).toFixed(1) + '%)');
Steps
- Step 1.
Read the decimal count for every currency the service accepts.
node -e "for (const c of ['USD', 'EUR', 'JPY', 'KRW', 'KWD', 'BHD', 'ISK', 'HUF']) console.log(c, new Intl.NumberFormat('en-US', { style: 'currency', currency: c }).resolvedOptions().maximumFractionDigits, new Intl.NumberFormat('en-US', { style: 'currency', currency: c }).format(1234.5))"USD 2 $1,234.50 EUR 2 €1,234.50 JPY 0 ¥1,235 KRW 0 ₩1,235 KWD 3 KWD 1,234.500 BHD 3 BHD 1,234.500 ISK 0 ISK 1,235 HUF 0 HUF 1,235The middle column is the exponent your multiplier has to use. The third column shows the consequence in the other direction: 1234.5 in JPY has no place to put the half, so it formats as 1,235.
- Step 2.
Convert one real amount per currency the wrong way and the right way, then read both back.
node minor.mjscur d displayed x100 reads back x10^d reads back USD 2 12.5 1250 $12.50 1250 $12.50 JPY 0 1250 125000 ¥125,000 1250 ¥1,250 KWD 3 12.5 1250 KWD 1.250 12500 KWD 12.500 ISK 0 1250 125000 ISK 125,000 1250 ISK 1,250USD passes, which is why the bug ships. A 1,250 yen order becomes ¥125,000, a hundred times the price. A 12.500 dinar order becomes KWD 1.250, a tenth of it. Both integers are plausible, and neither conversion raises anything.
- Step 3.
Count how much of the currency table the two decimal assumption covers.
node census.mjscurrencies known to this ICU: 162 0 decimal places: 33 AFN ALL BIF CLP COP DJF GNF HUF IDR IQD IRR ISK … 2 decimal places: 123 AED AMD ANG AOA ARS AUD AWG AZN BAM BBD BDT BGN … 3 decimal places: 6 BHD JOD KWD LYD OMR TND not two decimal places : 39 (24.1%)Thirty-nine of 162 are not two decimal currencies. Keep this list beside the one your service accepts: the overlap is the set of currencies a hardcoded 100 charges wrongly.
- Step 4.
Check how the code converts, not only by how much. Truncation fails where rounding does not.
node -e "let n = 0; const first = []; for (let c = 1; c <= 100000; c++) { const x = c / 100; if (Math.trunc(x * 100) !== c) { n++; if (first.length < 6) first.push(x); } } console.log(n, first.join(' '), 0.29 * 100)"4586 0.29 0.57 0.58 1.13 1.14 1.15 28.999999999999996Of the 100,000 two place amounts up to 1000.00, 4,586 come out one minor unit short when the product is truncated rather than rounded.
0.29 * 100is 28.999999999999996, andMath.trunc,parseIntand a bitwise| 0all turn that into 28. Grep the codebase for those three before trusting the multiplier.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| maximumFractionDigits: 0 | The currency has no minor unit. The integer is the amount | Send 1250 for ¥1,250. Multiplying by 100 is a hundredfold overcharge |
| maximumFractionDigits: 3 | Three decimal places. The multiplier is 1000 | Send 12500 for KWD 12.500. A multiplier of 100 undercharges by ten times |
| The stored integer has no currency field beside it | The scale is unrecoverable from the record | Store the code with the amount. Reject any row that has one and not the other |
| An amount in minor units carries a decimal point | Something divided before storing, or a quantity was fractional | Trace it. See Price tampering for one way that happens |
Thresholds
Common mistakes
What to check next
- How to check currency rounding: once the scale is right, the half rule and the order of operations still decide the last unit.
- Price tampering: the same amount field, viewed as input that a client controls.
- How to test currency formatting: the display side, where the same integer has to come back as a readable figure.
- How to check the thousand separator and decimal separator: a formatted amount parsed back is a second place the scale is lost.
- How to check if API returns valid JSON: a minor unit field that arrives as a string or a float is visible in the response before it is visible in the ledger.
FAQ
Is JPY a zero decimal currency?
Yes. ICU 78.2 reports maximumFractionDigits: 0 for JPY, so ¥1,250 is sent as the integer 1250, not 125000. KRW, ISK, CLP and VND behave the same way. Step 1 prints the figure rather than relying on a remembered list.
What is a minor unit in currency?
The smallest unit the currency divides into: cents for USD, fils for KWD, nothing for JPY. The count of decimal places is the exponent, so the amount in minor units is the displayed amount times ten to that power.
Can I hardcode the multiplier per currency instead?
You can, and the table then needs maintaining. What matters is that the multiplier is looked up from the currency code rather than fixed at 100, and that the lookup matches the table your payment provider uses, which can differ from CLDR.
Why does 1234.5 format as 1,235 in JPY?
Intl.NumberFormat rounds to the currency's decimal count, and JPY has none. The display is correct and the input was not: a half yen cannot be charged. Reject the fractional amount at the boundary rather than letting the formatter decide.
Verified
Verified by Maks Vernynode 22.23.2icu 78.2
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate7 minpublished updated Maks Verny