How to test currency formatting

Format the same amount with Intl.NumberFormat(locale, { style: 'currency', currency }) and assert on formatToParts, not on the string. In this run JPY kept zero fraction digits and rounded 1234.5 to 1,235, KWD kept three, and nl-NL placed the minus sign after the euro symbol.

Why check this

Run this when a currency or a market is added, on any build that touches a price component, and in regression after a dependency bump that moves the ICU version. The two decimal places everyone assumes are a property of the currency, not of money.

The failure it catches is a price that reads as wrong to the customer while every backend number is right. A Japanese customer sees 1,234.50 yen, a unit that does not exist. A Kuwaiti total silently loses its third decimal in a rounding step. A Dutch refund shows the minus sign in a place a regular expression anchored to the start of the string never finds, so the test that checks for a negative passes on a positive amount.

Prerequisites

Steps

  1. Step 1.

    Ask each currency how many fraction digits it has, instead of assuming two.

    // c1.mjs
    console.log('currencies this build knows:', Intl.supportedValuesOf('currency').length);
    for (const c of ['USD', 'EUR', 'JPY', 'KRW', 'KWD', 'BHD', 'CLP', 'QQQ']) {
      const f = new Intl.NumberFormat('en-US', { style: 'currency', currency: c });
      const o = f.resolvedOptions();
      console.log(c, 'digits', o.minimumFractionDigits, o.maximumFractionDigits, '  1234.5 ->', f.format(1234.5));
    }
    
    currencies this build knows: 162
    USD digits 2 2   1234.5 -> $1,234.50
    EUR digits 2 2   1234.5 -> €1,234.50
    JPY digits 0 0   1234.5 -> ¥1,235
    KRW digits 0 0   1234.5 -> ₩1,235
    KWD digits 3 3   1234.5 -> KWD 1,234.500
    BHD digits 3 3   1234.5 -> BHD 1,234.500
    CLP digits 0 0   1234.5 -> CLP 1,235
    QQQ digits 2 2   1234.5 -> QQQ 1,234.50

    Three scales, not one. JPY, KRW and CLP round the half unit away. KWD and BHD keep a third decimal that a two-decimal column in the database cannot hold. QQQ is not a currency and was formatted anyway, with the default two digits.

  2. Step 2.

    Hold the currency fixed and change the locale, then read the order of the parts.

    // c2.mjs
    for (const l of ['en-US', 'de-DE', 'fr-FR', 'nl-NL', 'it-IT', 'fi-FI']) {
      const f = new Intl.NumberFormat(l, { style: 'currency', currency: 'EUR' });
      console.log(l.padEnd(6), JSON.stringify(f.format(1234.5)).padEnd(16),
                  f.formatToParts(1234.5).map((p) => p.type).join(' '));
    }
    
    en-US  "€1,234.50"      currency integer group integer decimal fraction
    de-DE  "1.234,50 €"     integer group integer decimal fraction literal currency
    fr-FR  "1 234,50 €"     integer group integer decimal fraction literal currency
    nl-NL  "€ 1.234,50"     currency literal integer group integer decimal fraction
    it-IT  "1234,50 €"      integer decimal fraction literal currency
    fi-FI  "1 234,50 €"     integer group integer decimal fraction literal currency

    One currency, three layouts. The symbol leads in en-US with nothing between, leads in nl-NL with a literal space, and trails in the other four. The it-IT row has no group part at all.

  3. Step 3.

    Chase that missing group with a larger amount before filing it.

    // c3.mjs
    for (const v of [1234.5, 12345.5]) {
      for (const l of ['it-IT', 'de-DE']) {
        console.log(String(v).padEnd(8), l, JSON.stringify(new Intl.NumberFormat(l, { style: 'currency', currency: 'EUR' }).format(v)));
      }
    }
    
    1234.5   it-IT "1234,50 €"
    1234.5   de-DE "1.234,50 €"
    12345.5  it-IT "12.345,50 €"
    12345.5  de-DE "12.345,50 €"

    Italian groups from five digits up. The separator is not missing, it starts later, and German starts at four.

  4. Step 4.

    Format a refund and compare the two negative forms.

    // c4.mjs
    for (const l of ['en-US', 'de-DE', 'fr-FR', 'nl-NL']) {
      const opt = { style: 'currency', currency: 'EUR' };
      console.log(l.padEnd(6),
        'standard', JSON.stringify(new Intl.NumberFormat(l, opt).format(-1234.5)).padEnd(18),
        'accounting', JSON.stringify(new Intl.NumberFormat(l, { ...opt, currencySign: 'accounting' }).format(-1234.5)));
    }
    
    en-US  standard "-€1,234.50"       accounting "(€1,234.50)"
    de-DE  standard "-1.234,50 €"      accounting "-1.234,50 €"
    fr-FR  standard "-1 234,50 €"      accounting "(1 234,50 €)"
    nl-NL  standard "€ -1.234,50"      accounting "(€ 1.234,50)"

    Under accounting three of the four locales drop the minus sign and use parentheses, while de-DE keeps the sign. In the standard form nl-NL puts the sign after the symbol, so text.startsWith('-') is false on a negative amount.

  5. Step 5.

    Render three dollar currencies through each currencyDisplay value.

    // c5.mjs
    for (const d of ['symbol', 'narrowSymbol', 'code', 'name']) {
      console.log(d.padEnd(13), ['CAD', 'AUD', 'USD'].map((c) =>
        new Intl.NumberFormat('en-US', { style: 'currency', currency: c, currencyDisplay: d }).format(5)).join('   '));
    }
    
    symbol        CA$5.00   A$5.00   $5.00
    narrowSymbol  $5.00   $5.00   $5.00
    code          CAD 5.00   AUD 5.00   USD 5.00
    name          5.00 Canadian dollars   5.00 Australian dollars   5.00 US dollars

    narrowSymbol renders all three as $5.00. A checkout that picks it to look tidy stops telling the customer which dollar is being charged.

  6. Step 6.

    Read the symbol and the digits as code points, for the two cases a screenshot cannot settle.

    // c6.mjs
    const cp = (s) => [...s].map((c) => 'U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')).join(' ');
    for (const l of ['en-US', 'de-DE', 'ja-JP']) {
      const sym = new Intl.NumberFormat(l, { style: 'currency', currency: 'JPY' })
        .formatToParts(1234).find((p) => p.type === 'currency').value;
      console.log('JPY symbol in', l.padEnd(6), JSON.stringify(sym), cp(sym));
    }
    const kw = new Intl.NumberFormat('ar-KW', { style: 'currency', currency: 'KWD' });
    const s = kw.format(1234.5);
    console.log('KWD in ar-KW  numberingSystem', kw.resolvedOptions().numberingSystem, 'length', s.length);
    console.log('KWD in ar-KW  code points     ', cp(s));
    
    JPY symbol in en-US  "¥" U+00A5
    JPY symbol in de-DE  "¥" U+00A5
    JPY symbol in ja-JP  "¥" U+FFE5
    KWD in ar-KW  numberingSystem arab length 16
    KWD in ar-KW  code points      U+200F U+0661 U+066C U+0662 U+0663 U+0664 U+066B U+0665 U+0660 U+0660 U+00A0 U+062F U+002E U+0643 U+002E U+200F

    The Japanese locale uses U+FFE5, the fullwidth yen sign, where English uses U+00A5. The Kuwaiti string carries Arabic-Indic digits, its own separators at U+066C and U+066B, and an invisible U+200F right-to-left mark at each end.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | digits 0 0 for a currency | The currency has no minor unit | Remove the decimal column from that market's invoices and check the storage type. | | digits 3 3 for a currency | Three minor digits, as for KWD and BHD | A two-decimal column truncates real money. Store minor units as integers. | | The currency part comes last | The symbol trails the amount in that locale | Any string built by concatenation in your code is in the wrong order there. | | A negative renders as € -1.234,50 | The sign sits after the symbol | Detect negatives from the value, never from the rendered text. | | narrowSymbol output is $5.00 for CAD | The distinguishing prefix was dropped | Use symbol or code on any screen that charges money. | | Digits outside 0 to 9 | The locale resolved a non-latn numbering system | Assert with formatToParts, or pin the system with the -u-nu-latn extension. |

Common mistakes

Sign: A yen price shows two decimal places and the totals still reconcile.Cause: The amount was divided by 100 for display on the assumption that every currency has a minor unit of two digits. JPY, KRW and CLP resolve to 0 fraction digits here, so ICU rounded 1234.5 to 1,235 while the hand-rolled divide produced 12.35. The backend is right and the screen is not.
Sign: A snapshot test of a Japanese price fails on a machine where it renders correctly.Cause: ja-JP emits U+FFE5, the fullwidth yen sign, and en-US emits U+00A5 for the same currency. The two glyphs differ in width and in nothing else a reviewer notices. Compare code points, not screenshots.
Sign: A test asserting that a refund is negative passes on a positive amount in Dutch.Cause: nl-NL formats minus one thousand two hundred thirty four and a half euro as an amount whose sign follows the symbol, so a check anchored to the first character never sees it. Under currencySign accounting three of the four locales tested drop the sign entirely and use parentheses.
Sign: An Arabic total fails a numeric assertion with no characters visible in the diff.Cause: ar-KW resolves the numbering system to arab. The 16-character string starts and ends with U+200F, a right-to-left mark that occupies no width, and its digits are U+0660 upward. Trimming whitespace does not remove U+200F.

What to check next

FAQ

How do I test currency formatting for every market at once?

Loop the locale and currency lists the product ships and print formatToParts for one amount and its negative. The output is small enough to read, and it makes the symbol position and the digit count visible without a browser.

Why does the same amount round differently per currency?

Intl.NumberFormat reads the minor unit count from CLDR. JPY resolved to 0 fraction digits here, so 1234.5 became 1,235. USD resolved to 2 and kept 1,234.50. The rounding follows the currency, not the locale.

Should the API send a formatted price?

Send an integer in minor units and the ISO code. The digit counts in step 1 show why a decimal string is ambiguous: 1234.500 is a valid KWD amount and an invalid USD one.

Which currencyDisplay should a checkout use?

symbol or code. Step 5 shows narrowSymbol rendering CAD, AUD and USD identically as $5.00, which removes the only thing telling the customer which currency is charged.

Does Intl reject a currency code it does not know?

No. This build knows 162 codes, and step 1 shows it formatting QQQ as QQQ 1,234.50 with the default two digits. Only a malformed code raises a RangeError: US returned Invalid currency code : US here.

Verified

Verified by Maks VernyNode 22.23.2ICU 78.2, CLDR 48

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.

intermediate8 minpublished updated Maks Verny