DEV Community

Daniel Pertu
Daniel Pertu

Posted on

22 yen when you meant 2,246: five money-formatting bugs Intl will let you ship

A companion to an earlier post on shipping geo-localised pricing, which mentioned the zero-decimal trap in passing. This is that trap and its four relatives, in full.

We advertise a subscription at £10 a month and show it in the visitor's local currency. Here is the price in Japanese yen:

¥2,246
Enter fullscreen mode Exit fullscreen mode

Here is what the obvious implementation prints instead:

¥22
Enter fullscreen mode Exit fullscreen mode

That is not a rounding error. That is two orders of magnitude, on a price, silently, and every test you wrote against dollars and euros passes.

You can check our working on munchable.app/#pricing. Point a VPN at Japan, Hungary or Iceland and reload, and compare it against Germany or the US. Those first three are where money formatting stops being boring.

Bug one: not every currency has two decimal places

Almost every money tutorial tells you to store integer minor units and divide by 100 to display. That advice is correct for the vast majority of currencies and catastrophically wrong for the rest.

Japanese yen, Hungarian forint and Icelandic króna have zero decimal places. There is no such thing as a sub-yen unit in circulation. The minor unit is the major unit. So when your code confidently does amount / 100, a price of 2,246 yen becomes 22 yen.

Some currencies have three. Others officially have two but are conventionally quoted with none. You do not want to maintain that list, and you do not have to, because Intl already ships it:

export function minorUnitDigits(currency: DisplayCurrency): number {
  const { code, locale } = CURRENCIES[currency];
  return (
    new Intl.NumberFormat(locale, { style: 'currency', currency: code })
      .resolvedOptions().maximumFractionDigits ?? 2
  );
}
Enter fullscreen mode Exit fullscreen mode

resolvedOptions() is the underused half of the Intl API. You are asking the runtime's own CLDR data what it thinks the shape of this currency is, rather than asserting it. It knows. Stop guessing.

Here is the same £10 across a handful of currencies, with the number of decimal places the currency actually has, and what you would have printed had you assumed two:

Currency Digits Minor units Correct Assuming 2dp
GBP 2 1000 £10 £10.00
EUR 2 1299 €12.99 €12.99
USD 2 1499 $14.99 $14.99
JPY 0 2246 ¥2,246 ¥22
HUF 0 4403 4403 Ft 44 Ft
ISK 0 1730 1.730 kr. 17 kr.

Note that a developer in the UK, the US or the euro area will never once see this bug locally. Everything looks perfect right up until you start taking money in Tokyo.

Bug two: rounding the wrong way is a promise you break at checkout

We charge in GBP and Stripe does the real conversion at checkout, so the number on our pricing page is an approximation. That means the direction of the approximation error is something we have to choose deliberately.

Stripe's presented rate embeds a conversion fee of roughly 2 to 4 percent. So a straight mid-market conversion under-quotes what the customer will actually be asked to pay. Someone who saw €12.16 on the page and €12.60 at checkout has been mildly lied to, which is precisely the problem we were trying to solve.

So we buffer at the top of that band and always round up:

const CONVERSION_FEE_BUFFER = 1.04;
Enter fullscreen mode Exit fullscreen mode

The property the whole module rests on: the figure we display is never lower than the amount Stripe will ask for. Quoting slightly high and charging slightly less is the only safe direction for this error to point.

Bug three: rounding up to a number that looks like a price

Rounding up to the next whole unit gives you €13. Correct, and it looks like nothing any company has ever charged. Real list prices end in .99, and in zero-decimal currencies they end in a whole unit because there is nothing else to end in.

function ceilToRetail(amount: number, digits: number): number {
  const EPSILON = 1e-9;
  if (digits === 0) return Math.ceil(amount - EPSILON);

  const step = 1 - Math.pow(10, -digits); // 0.99 for 2dp, 0.9 for 1dp
  return Math.ceil(amount - step - EPSILON) + step;
}
Enter fullscreen mode Exit fullscreen mode

The epsilon is not superstition. Without it, a value that is already exactly on a .99 boundary gets nudged up a whole unit by binary floating point error, so £10 in a currency at parity displays as 11.99 instead of 10.99 and you cannot reproduce it because it depends on the rate to the last bit.

Worked example, £10 to USD at 1.3559:

10 * 1.3559 * 1.04  = 14.101...
ceilToRetail(14.10, 2):
  step = 0.99
  Math.ceil(14.101 - 0.99 - 1e-9) = Math.ceil(13.111) = 14
  14 + 0.99 = 14.99
Enter fullscreen mode Exit fullscreen mode

And to yen, at 215.89:

10 * 215.89 * 1.04 = 2245.256
ceilToRetail(2245.256, 0) = Math.ceil(2245.256) = 2246
Enter fullscreen mode Exit fullscreen mode

Bug four: free is free in every currency

The one that made us laugh, then wince.

Our free tier is £0. Run £0 through a conversion and a round-up and you advertise the free plan at €0.99, which puts a price on the product whose entire selling point is that it does not have one.

if (pence === 0) return 0;
Enter fullscreen mode Exit fullscreen mode

One line, and it has to come before the rounding rather than after it. Any pipeline with a "round up to something that looks like a price" step needs an explicit answer for zero, because zero is the one input where "looks like a price" is exactly wrong.

Bug five: .00 on a round number reads as false precision

Every list price we show is a round number. £10.00 / month implies we thought about the pence. £10 / month is what a human would write.

const major = minorUnits / Math.pow(10, digits);
const isWhole = Number.isInteger(major);

return new Intl.NumberFormat(locale, {
  style: 'currency',
  currency: code,
  minimumFractionDigits: isWhole ? 0 : digits,
  maximumFractionDigits: isWhole ? 0 : digits,
}).format(major);
Enter fullscreen mode Exit fullscreen mode

Note this drops the decimals only when the amount genuinely is whole. £12.99 keeps both digits. This is not "strip trailing zeros", it is "do not manufacture precision the number does not have".

The one rule to take away

Ask Intl what it knows instead of asserting what you assume, and pick the direction of your rounding error on purpose. Both of those are one line of code and both of them are the difference between a price that is slightly generous and a price that is wrong by 100x in a market you were not testing in.

The live version is at munchable.app/#pricing, and it is worth a VPN hop to Tokyo just to watch the yen figure come out with no decimal point at all. If you want to see the app the prices are attached to, the free tier needs no card.

Top comments (0)