Munchable charges £10 a month. That number exists once, as an integer:
export const PREMIUM_PRICE = { currency: 'gbp', unitAmount: 1000, interval: 'month', productName: 'Munchable Premium', } as const; Everything a visitor sees is derived from it. The figure in the pricing section is derived from it, the app's paywall is derived from it, and the Checkout Session is created from it. The terms make the promise out loud: each currency we sell in has its own fixed price, and you pay the price shown to you in the currency it was shown in.
Open the pricing section now and you will see your own country's figure. That is one function away from the £10 above, and the rest of this post is about why that function has 25 tests.
The prices are authored, not converted
The earlier arrangement charged GBP everywhere and let Stripe convert on its own checkout page. That produces a different local amount every time the market moves, and a number nobody can quote in an email.
Now there is one price per currency, 24 of them, each derived once from the pound price and then frozen in a table. The exchange rate lives in that table too, and it is hardcoded:
/** * Mid-market units per one pound, used only to SET this currency's price. * * Hardcoded on purpose: the rate is how the price was chosen, not something * the price follows. A live feed would make the price move with the market, * which is exactly what a fixed price per currency exists to stop. * * Sourced from the ECB reference rates on 2026-08-17. Editing a rate changes * what that country is charged, so it is a price change, not a refresh. */ A multiplier covering Stripe's conversion fee sits on top, then the result is rounded up to something that reads like a price rather than like arithmetic. £10 becomes €12.99 and stays €12.99 until somebody edits the table.
Two consequences follow, and both are tests.
A missing EU country is not untidy, it is a different price
Country to currency is a plain map, and anything unlisted falls back to GBP. For most of the world that is fine: an unlisted country reads a pound price and is charged in pounds, which is still the same price twice.
Inside the EU it is not fine, and the test says why:
// Every EU member state has its own price in its own currency. Stripe's // Managed Payments (EU only) always runs Adaptive Pricing, which re-prices a // session into the buyer's local currency at Stripe's rate whenever the two // differ, so an EU country left on another currency would be shown one figure // on our page and a different one on Stripe's. test('every EU-27 country is priced in its own currency', () => { The test writes out all 27 member states with the currency each one must resolve to, asserts the table has 27 entries and that 21 of them are euro, then checks every single lookup. It is deliberately a second copy of the mapping rather than a loop over the first one, because the thing being protected is the list itself. A country quietly missing from the price table is the bug.
This is also the test to edit when a country adopts the euro. Bulgaria moved on 1 January 2026, and that change is two edits in two files that the test forces you to make together.
Stripe does not count in hundredths
Everyone knows amounts go to Stripe in minor units. The part that bites is that there are three shapes of currency, and we sell in all three.
Most are ordinary hundredths: €12.99 is 1299.
Zero-decimal currencies are whole units: ¥2,300 is 2300, not 230000.
And then there are the special cases, written without decimals in their own locale but sent to Stripe as hundredths that have to divide evenly by 100. A 4,500 forint price is unit_amount: 450000.
export const STRIPE_DIVISIBLE_BY_100: ReadonlySet<string> = new Set([ 'HUF', 'ISK', 'TWD', 'UGX', ]); We price in two of those and in yen, so Intl's idea of the decimal count and Stripe's idea of the unit disagree for three of our currencies. Getting it wrong does not throw. It charges a customer a hundred times the price, or a hundredth of it, and the receipt looks normal.
So the conversion to Stripe units is one function with its own tests, and it refuses rather than guesses:
if (STRIPE_DIVISIBLE_BY_100.has(code) && unitAmount % 100 !== 0) { throw new Error(`checkoutPrice: ${code} amounts must divide by 100, got ${unitAmount}`); } The zero-decimal set is Stripe's whole published list, not only the currencies we sell in, so adding a currency to the price table cannot silently miss its case. A separate test asserts the two sets never overlap, because the branch that reads them would otherwise be a coin toss.
My favourite of the 25 is the cheap one that would catch a unit error anywhere in the table at all:
test('every currency charges about what the pound price is worth', () => { // ... for each currency: read the amount back the way Stripe reads it, // divide by the rate, and assert the result is between £10 and £12. A hundredfold mistake in any row fails that in milliseconds. It does not care which row, which is the point.
The copy is tested too
Two more tests have nothing to do with arithmetic. One asserts the price small print says the price is the whole price. The other forbids a vocabulary:
const CONVERSION_WORDS = /convert|approx|estimat|≈|exchange|\brate\b|sterling|pounds?\b|GBP|charged in/i; No user-facing price string may contain any of those words. The reason is not style. There used to be a conversion caveat and an exchange rate beside the price, and it existed only because the amount could still change at checkout. It cannot now, so the caveat would be inventing doubt about a figure that is simply that country's price.
There is also a guard for the plan that has no price at all. Zero passes straight through in every currency, because the retail round-up would otherwise turn a free tier's £0 into "€0.99", which is the funniest possible way to lose a signup.
Go and look: open the pricing section from wherever you are, and if you have a VPN, read it from somewhere else. Whatever figure you get is the figure the card is charged, and the small print under it is the whole of the small print.