We show prices in the visitor's own currency on pub-trivia.app/pricing. The geolocation half of that was an afternoon. The rounding half was not, and every hour of it went on a bug that only exists because money is not a number, it is a number plus a set of local conventions about how that number is written.
Here are the five that bit us, in the order they were found. If you are about to build the same thing, this is the list to test against.
1. The free plan that cost 99 cents
Our trial tier is £0. Here is the conversion function it went through:
const major = (pence / 100) * rate * CONVERSION_FEE_BUFFER
return ceilToRetail(major, digits)
ceilToRetail rounds up to the next price ending in .99, because £24.37 looks like an accident and £24.99 looks like a price. Feed it zero and the next .99 above zero is 0.99.
So the one plan whose entire proposition is that it costs nothing displayed as €0.99 / month to every visitor outside the UK. Nobody in the UK could see it, because GBP skips the conversion path entirely. It sat there for a while.
The fix is four lines and a comment explaining why they cannot be removed:
// Free is free in every currency. Zero is not a price to make look retail,
// so it leaves before any of the arithmetic below.
if (pence === 0) return 0
The lesson is not "handle zero". It is that a formatter with a creative step in it, anything that nudges a number to look better, needs its identity cases carved out first. Retail rounding is a marketing transform, and marketing transforms should never touch a value whose meaning is "none".
2. Yen does not have pence
This is the expensive one. Our amounts are integer minor units, and the conversion back to a display string divides by 100.
JPY, ISK and HUF are written with no decimal places at all. ¥5,999 divided by 100 is ¥59.99, which is not a typo, it is an error of two orders of magnitude, and it is silent. Nothing throws. The page just quietly offers a plan for a hundredth of its price.
The temptation is a hardcoded list of zero-decimal currencies. Do not. That list goes stale the first time someone adds a currency and does not know the list exists. Ask Intl instead, since it already knows:
export function minorUnitDigits(currency: DisplayCurrency): number {
const { code, locale } = CURRENCIES[currency]
return new Intl.NumberFormat(locale, { style: 'currency', currency: code })
.resolvedOptions().maximumFractionDigits ?? 2
}
Then every scaling step reads Math.pow(10, digits) rather than 100, and the rounding function branches on it:
function ceilToRetail(amount: number, digits: number): number {
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
}
resolvedOptions() is the general trick here and it is underused. Anywhere you were about to hardcode a fact about a locale, ask Intl whether it already knows that fact.
3. The epsilon is not superstition
Look again at - EPSILON above, with EPSILON = 1e-9.
A value that is already exactly on a .99 boundary can arrive as 24.990000000000002 out of a float multiply. Math.ceil then dutifully pushes it to the next boundary and your £20 plan displays as €25.99 instead of €24.99. It is a whole unit of error produced by the last bit of a double.
The epsilon is a tolerance for "already there". Any ceiling applied to the output of floating-point money arithmetic needs one, and it should be a named constant with a comment, or the next person deletes it as noise.
4. Trailing zeros are a claim about precision
Every list price we have is a round number. Formatted naively, they all come out as £30.00, €37.99, ¥7,999. The .00 is the odd one out: it implies two significant decimals on a figure that has none, and stacked down a pricing table it makes the page look like an invoice.
const isWhole = Number.isInteger(major)
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: code,
minimumFractionDigits: isWhole ? 0 : digits,
maximumFractionDigits: isWhole ? 0 : digits,
}).format(major)
£30, and €37.99 when there genuinely is a 99. The decimals appear exactly when they carry information.
5. Showing your working, or the arithmetic that does not reconcile
We display the rate we used underneath the price:
£1 is about $1.41, including card conversion fees. Rounded up.
The first version of that line omitted "Rounded up." Someone checked it: £30 times 1.41 is $42.30, and the page says $42.99. Their conclusion was not "ah, retail rounding", it was that our maths was wrong, and a company whose maths is wrong on its pricing page is a company you look at more carefully everywhere else.
There is also a subtlety in the rate you quote. We do not show the mid-market rate, because we do not convert at it. We show the effective rate, mid-market times the fee buffer, because that is the number that reproduces the figure on screen:
export function effectiveRate(currency: DisplayCurrency): number {
if (!isApproximate(currency)) return 1
return CURRENCIES[currency].rate * CONVERSION_FEE_BUFFER
}
And the precision follows the currency, since a rate of "¥215.89 to the pound" wants no decimals while "$1.41" needs two.
What I would test first, next time
A property test, not a set of examples. There are two invariants that everything else is a consequence of:
- For every currency and every price in the table, the displayed figure is greater than or equal to the true converted amount. This is the promise to the customer: we never quote under what Stripe will ask for.
- Zero displays as zero in every currency.
fast-check over the cross product of currencies and plan prices catches the yen bug, the free-plan bug and the epsilon bug without any of them having to be imagined in advance.
Have a look
pub-trivia.app/pricing runs all of this live. With a VPN set to Japan or Iceland you get a currency with no decimal places, which is the interesting case: the paid plans come out as whole units and the free plan reads as a plain zero rather than ¥99. Sweden gets you a space as the thousands separator, and Germany a comma where you expect a point.
If you would rather see what the prices are actually for, the free tier signs up without a card.
Top comments (0)