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

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

  1. 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,235

    The 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.

  2. Step 2.

    Convert one real amount per currency the wrong way and the right way, then read both back.

    node minor.mjs
    
    cur  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,250

    USD 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.

  3. Step 3.

    Count how much of the currency table the two decimal assumption covers.

    node census.mjs
    
    currencies 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.

  4. 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.999999999999996

    Of 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 * 100 is 28.999999999999996, and Math.trunc, parseInt and a bitwise | 0 all 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

39 of the 162 currencies in ICU 78.2 are not two decimal currencies: 33 have none and 6 have three Source: Measured by census.mjs on Node 22.23.2, ICU 78.2, CLDR 48, on 2026-09-12
4,586 of the 100,000 two decimal amounts up to 1000.00 lose one minor unit when x * 100 is truncated instead of rounded Source: Measured by the step 4 command on Node 22.23.2 on 2026-09-12

Common mistakes

Sign: Every amount is correct in USD and EUR, and Japanese orders are a hundred times too large.Cause: One hardcoded multiplier of 100. Step 3 counts 39 currencies where that constant is wrong, and the error runs both ways: a hundredfold overcharge where the exponent is 0, a tenfold undercharge where it is 3. Nothing in the type system separates 1250 yen from 1250 cents.
Sign: Intl reports 0 decimal places for HUF, and the payment API rejects the forint amount you send.Cause: CLDR digits describe customary practice, not the ISO 4217 minor unit. The Unicode spec says the value is based on the ISO 4217 minor unit but may deviate where there is compelling evidence for different customary practice. HUF and IDR are such deviations: CLDR says 0, ISO 4217 assigns 2. Read Intl for display and your provider's own table for the wire.
Sign: An amount arrives as 649.5 in a field documented as minor units.Cause: A fractional quantity or a percentage discount was applied after the conversion to integers, so the value is neither major nor minor units. It is not a rounding question: the record no longer has a scale. Validate on arrival that a minor unit field is an integer.
Sign: The same order total is right in the database and off by one unit in the export.Cause: The export converts with Math.trunc or parseInt on a float product. Step 4 counts 4,586 amounts under 1000.00 where that loses a unit, starting at 0.29. Rounding hides it, and truncating anywhere in the chain brings it back.

What to check next

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.

intermediate7 minpublished updated Maks Verny