Munchable is a paid app, and for a while its price was one number in one currency with a conversion bolted on at the end. You read £10 on the pricing page, clicked through, and Stripe's Adaptive Pricing worked out a local figure on its own checkout page using whatever the rate was that morning.
That arrangement has one obvious defect and one subtle one.
The obvious one: the number on the page is not the number on the payment screen. That is the moment people abandon a checkout, and being right about the exchange rate does not help you.
The subtle one: there is no answer to "how much is Munchable in Sweden?". There is only "about this much, today". You cannot put it in an email, an ad, or an app store screenshot, because it was never a price. It was the output of a conversion.
So the price list became a table, one fixed figure per currency, and both the page and the charge read it.
You can see it on munchable.app/#pricing. If you have a VPN, switch country and reload: the figure changes and then stays put, including on the payment screen.
One function produces the number, twice
/**
* The price in `currency`, in that currency's minor units, derived from an
* amount of GBP pence.
*
* This is the real price, not a preview of one: the same call feeds the pricing
* table, the paywall and the Checkout Session. Changing it changes what people
* are charged.
*/
export function priceIn(pence: number, currency: DisplayCurrency): number
The comment is the design. There is no display path and no billing path, there is one function, and the Checkout Session is created for exactly what the landing page printed. A country resolves to a currency, that currency has one price, and that price does not move between the shelf and the till.
The table is authored in GBP pence and derived once per currency, so adding a market is two edits: a currency definition and the country codes that use it. Everything else falls out of those two maps.
Rounding up is not the same as rounding
An exchange rate produces a number like 12.16. Nobody prices anything at 12.16. Prices are a shape, and the shape is different per currency:
function ceilToRetail(amount: number, digits: number): number {
const EPSILON = 1e-9;
if (digits === 0) {
// 100 above a thousand, 10 above a hundred, whole units below that.
const step = amount >= 1000 ? 100 : amount >= 100 ? 10 : 1;
return Math.ceil((amount - EPSILON) / step) * step;
}
const step = 1 - Math.pow(10, -digits); // 0.99 for 2dp, 0.9 for 1dp
return Math.ceil(amount - step - EPSILON) + step;
}
Two branches, because currencies are written differently. A currency with decimals goes to the next .99, so 12.16 becomes 12.99. A currency without decimals runs into the thousands, and a yen price of 2,246 reads as the output of a conversion rather than as a price, so it steps to a round figure the way the prices on a shelf there do.
The epsilon is not decoration. Without it a value that is already exactly on a boundary gets nudged up by a whole step by binary float error, which is how a price quietly gains a hundred yen one deploy.
The property the rest of the module depends on is that this function never returns less than it was given. We are paid in the customer's currency and settle in sterling, so a conversion fee comes out on the way back, and the published rates drift between refreshes of the table. A small buffer plus the round-up absorb both, so ten pounds of list price stays roughly ten pounds of settled revenue rather than nine sixty.
Discounts round the other way
This is the part that surprised me when writing it. A discount is not a price, and applying the price rounding to it is a bug.
/**
* The opposite rounding to priceIn, for the opposite reason. A price is
* rounded up so the figure covers the conversion back to sterling; a discount
* rounded up is a promise we then fail to keep, so this converts at the plain
* mid-market rate and rounds DOWN.
*/
export function discountIn(pence: number, currency: DisplayCurrency): number
A price rounded up costs the customer a few cents and covers our fee. A discount rounded up means we advertised credit we do not honour. The buffer that protects the price is exactly the wrong thing to apply to a reward, so discountIn drops both the buffer and the round-up and truncates instead. Same table, same rate, opposite direction, because the two numbers are promises to different parties.
The unlisted country is the interesting default
Not every country is in the map, and the fallback is the base currency:
/**
* Any country not listed falls back to GBP, which is both the safe default (it
* is the currency we settle in) and correct for the UK audience that makes up
* most of our traffic. An unlisted country therefore reads a pound price and is
* charged in pounds: the same price twice, which is the promise that matters.
*/
The temptation with a fallback is to be clever: guess a currency from the locale, or convert at request time from a rate feed. Both reintroduce exactly the thing we removed, which is a number that exists only for the duration of one request. A visitor in an unlisted country seeing a pound price and paying a pound price has a worse currency experience and a correct one. Adding their country to the map is how that improves, and it is a code change with a review on it rather than an inference.
The same reasoning, incidentally, is why the rate feed stayed out. The rates are an input to authoring the table, not a runtime dependency: they are sourced from a dated set of reference rates and refreshed deliberately when one moves enough to matter. Rendering a pricing page does not make a network call to find out what to charge.
Zero is not a price
One guard worth stealing:
if (pence === 0) return 0;
Without it, the round-up turns the free tier's £0 into "€0.99", which advertises a price for the plan whose entire point is that it does not have one. We wrote about that family of bugs separately in Free is free everywhere, and four other rounding bugs in a price formatter, and about the trap of a formatter that assumes hundredths in Intl says zero decimals, Stripe wants hundredths, and the shelf wants ¥2,300.
What changed with this one is smaller than any of those and worth more: the price stopped being computed at the moment of payment. It is now a fact about a currency, written down, testable, and quotable in a sentence.
Go and look: munchable.app/#pricing. Whatever it says for your country is what the checkout will say, and it will still say it next month.
Top comments (0)