Munchable Premium is ten pounds a month. It is also 12.99 euros, and some number of yen, and some number of forint, and each of those is a fixed figure rather than a conversion performed at the moment somebody reaches for their card.
Getting from "£10" to a charge in a currency written with no decimal places turns out to involve three parties who each have their own opinion about how many digits that currency has, and they do not agree. One of those disagreements, unhandled, charges a customer a hundred times the price without any error at all.
You can see the output at munchable.app/#pricing. It shows the price for wherever you are, and it is the same figure the app's paywall shows and the same figure the Checkout Session is created for.
Party one: Intl
Intl.NumberFormat knows how a currency is written:
export function minorUnitDigits(currency: DisplayCurrency): number {
const { code, locale } = CURRENCIES[currency];
return (
new Intl.NumberFormat(locale, { style: 'currency', currency: code })
.resolvedOptions().maximumFractionDigits ?? 2
);
}
Euros get 2. Yen get 0. Icelandic krónur get 0. Hungarian forint get 0. This is correct: nobody in Reykjavík writes half a króna.
Party two: the shelf
Once you know the digits you can round a converted amount up to something that reads like a price rather than like arithmetic. For a two-decimal currency that is the next .99. For a zero-decimal currency, .99 is meaningless and the numbers are much bigger, so the step has to scale:
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;
}
A yen price that came out of the conversion at about ¥2,245 would be a giveaway that a machine did it. ¥2,300 is a price. Forint land on 4,500 Ft the same way.
The epsilon is there because a value already sitting exactly on a boundary would otherwise get nudged up a whole step by binary float error, and a whole step in a zero-decimal currency is a hundred units.
The guarantee this function makes is that it always returns a value greater than or equal to its input. That property is load bearing: we are paid in the customer's currency and settle in sterling, so a conversion fee comes out on the way back, and the round-up plus a small buffer is what keeps ten pounds of list price worth about ten pounds of revenue rather than nine sixty.
Discounts use the opposite rounding for the opposite reason. A price rounded up is covered; a discount rounded up is a promise you then fail to keep. So discounts convert at the plain mid-market rate and round down. Twenty pence of reward credit is "£0.20 off" at home and "€0.23 off" in Dublin, never the "€0.99" the retail round-up would produce from the same 20p. One rule covers both directions: err towards the customer.
Party three: Stripe, who disagrees with Intl about three currencies
Here is the part I did not know before building this.
Stripe takes unit_amount in the currency's smallest unit. For most currencies that is hundredths. For its zero-decimal list it is whole units: unit_amount: 1299 is ¥1,299, not ¥12.99.
So far Intl and Stripe agree about yen. They do not agree about forint, Icelandic krónur, Taiwan dollars or Ugandan shillings. Those are written with no decimals, which is what Intl tells you, but Stripe takes them in hundredths that must divide evenly by 100. A 4,500 forint price is unit_amount: 450000.
Trusting Intl for the Stripe amount would send 4500 for a 4,500 Ft price, which Stripe would happily read as 45 Ft. Trusting Stripe's zero-decimal intuition and sending whole units for yen while sending hundredths elsewhere gets you the reverse error somewhere else. Either way the failure is silent: no exception, no validation error, a Checkout page that renders a plausible number and a customer whose statement does not match your pricing page.
So the conversion is one function with its own tests, and it carries both of Stripe's lists rather than only the currencies we happen to sell in:
export function checkoutPrice(
pence: number,
currency: DisplayCurrency,
): { currency: DisplayCurrency; unitAmount: number } {
const { code } = CURRENCIES[currency];
const displayed = priceIn(pence, currency);
const stripeDigits = STRIPE_ZERO_DECIMAL.has(code) ? 0 : 2;
const unitAmount = Math.round(displayed * Math.pow(10, stripeDigits - minorUnitDigits(currency)));
if (STRIPE_DIVISIBLE_BY_100.has(code) && unitAmount % 100 !== 0) {
throw new Error(`checkoutPrice: ${code} amounts must divide by 100, got ${unitAmount}`);
}
return { currency, unitAmount };
}
stripeDigits - minorUnitDigits(currency) is the whole trick: the exponent is the difference of opinion between the two parties, so the correct multiplier falls out for all three cases without a special case per currency.
Carrying Stripe's full zero-decimal list, including fifteen currencies we do not price in, is deliberate. Adding a new currency to the table then cannot silently miss its case, because the set already knows about it.
The divisibility check throws. It is the only throw in the module, and it is there because this is the class of mistake that has to be impossible rather than caught in review.
Why this lives in its own package
All of the above is imported by two applications that share nothing else: a Next.js marketing site and an Expo app that also compiles to the web.
It used to live next to the Stripe client. That put the Stripe SDK in the module graph of every surface that merely wants to print a price, which is most of them: the landing page, the post-signup plan chooser, the rewards maths, the app's paywall. None of those charge anybody.
packages/pricing <- the price list, zero dependencies
^ ^
| |
apps/web apps/mobile
The package is consumed as TypeScript source, with its exports pointing at src/index.ts, so Next has to be told to compile it:
transpilePackages: ['@munchable/rules-engine', '@munchable/pricing'],
Metro compiles workspace source by default, so the mobile side needed nothing. That asymmetry is worth knowing before you spend an afternoon on it: the same package shape is a one-line config change in one bundler and free in the other.
The old module in the web app now re-exports the package, so every existing import kept working and the change was not a 40-file rename.
The thing all of this is in aid of
There is exactly one price per currency, and it does not move between the page and the payment.
We used to charge in sterling and let Stripe's adaptive pricing convert on its own checkout page. That is much less code and it produces a different local amount every time the rate moves, which means nobody in the company could answer "what does this cost in Ireland?" with a number. A price that changes on the last screen is a thing people abandon a checkout over.
Every figure is also tax inclusive, so whatever VAT is due comes out of the amount rather than going on top. Which makes the small print one sentence:
export const VAT_NOTICE = 'VAT is included, so the price you see is the price you pay.';
That sentence used to be a paragraph, with a conversion caveat and an exchange rate beside it. Both existed only because the amount could still change at checkout. It cannot now, so the caveat would be inventing doubt about something that is no longer true. Deleting reassuring small print because the product got better is one of the more satisfying commits available.
Go and look: munchable.app/#pricing. If you have a VPN handy, point it somewhere in the euro area and reload, and the figure changes to that currency's fixed price rather than to a conversion of ten pounds.
Top comments (0)