Munchable has a contributor reward: if you add a product the catalogue does not have yet, you get a slice of your next subscription month. 2% per accepted product, 50 in a calendar month makes the next month free, and anything in between is proportional.

That is a sentence in a terms page (munchable.app/terms, the Rewards section) and three weeks of Stripe decisions underneath it. The hard part is not the counting. It is that "free" has to mean zero, in every currency, after tax, on two different payment topologies.

One price per country, fixed

Premium is authored once, as an integer:

export const PREMIUM_PRICE = { currency: 'gbp', unitAmount: 1000, interval: 'month', productName: 'Munchable Premium', } as const; 

Every other price is derived from those 1000 pence by a table of 24 currencies and 58 country codes. 27 of the country codes point at the euro, four at sterling, five at the dollar, and anything unlisted falls back to the currency we settle in. munchable.app/#pricing prints the one for where you are, so a VPN hop changes the figure on the page.

The important word is fixed. Each currency has exactly one price, chosen from a rate that is hardcoded with its source date in a comment:

eur: { code: 'EUR', locale: 'en-IE', rate: 1.1696 }, 

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.

£10 becomes €12.99 and stays €12.99 until somebody edits that line, which is a price change and gets treated as one. The multiplier that gets it there is mid-market times 1.04, the top of Stripe's stated 2 to 4 percent conversion band, because we are paid in the customer's currency and settle in pounds.

Why the reward cannot be a balance credit

The obvious implementation of "a free month" is a 1000-pence customer balance credit. It is wrong here twice.

First, topology. EU customers are routed through Stripe's Managed Payments, where Stripe is merchant of record. Coupons and discounts are documented as supported there. Customer balance credits are not, and "the reward works everywhere except the EU" is not a reward.

Second, tax. Every price in that table is tax-inclusive: whatever VAT applies comes out of the figure rather than being added to it. A percent-off coupon discounts the subtotal and the tax is computed on what remains, so 100% off is zero everywhere, in every currency, under every VAT rate. A fixed 1000-pence credit against a tax-inclusive 1000-pence line is a coin flip on rounding, and it would land badly for exactly the customers Managed Payments covers.

So a reward is a Stripe coupon, percent_off, duration: 'once', with an id derived from the percentage. Which also means the app can label it honestly without knowing anyone's currency: "4% off next month" is true in 24 currencies, and the pence figure now only exists for the admin view.

The rounding is the part I would get wrong again

The price table has to be readable as a price, not as the output of a conversion. Where a currency has decimals, the derived figure is pushed up to the next .99. Where it does not, it steps:

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; } 

Above a thousand the step is a hundred, so £10 lands on ¥2,300 and on 4,500 Ft, which read as prices rather than as the 2,245 and 4,402 the arithmetic produced. Rounding is always up, never down, and the function's contract is that its result is greater than or equal to its input. The epsilon is there because binary floats will otherwise nudge a value that is already exactly on a boundary up by a whole step, which turns ¥2,300 into ¥2,400 for no reason a customer could ever discover.

Then the one guard I am glad is a function with its own tests rather than a line in a checkout route:

export const STRIPE_ZERO_DECIMAL: ReadonlySet<string> = new Set([ 'BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW', 'MGA', 'PYG', 'RWF', 'VND', 'VUV', 'XAF', 'XOF', 'XPF', ]); export const STRIPE_DIVISIBLE_BY_100: ReadonlySet<string> = new Set([ 'HUF', 'ISK', 'TWD', 'UGX', ]); 

Three of the currencies here are written without decimals in their own locale, and Stripe disagrees with Intl about two of them. Yen are sent as yen. Forint and krónur are written decimal-free but sent to Stripe in hundredths that must divide evenly by 100, so a 4,500 Ft price is unit_amount: 450000. Get it the other way round and nothing fails: a customer is charged a hundred times the price. checkoutPrice throws rather than return an amount that would violate the divisibility rule, and the zero-decimal set is Stripe's full list rather than only the currencies in our table, so adding a currency cannot silently miss its case.

One more, which is the smallest and my favourite:

if (pence === 0) return 0; 

Without it the round-up turns the free tier's £0 into "€0.99", and the plan whose entire point is that it has no price gets one.

What the tests are actually for

28 cases over this module, and the ones that earn their keep are not the formatting ones. Two properties matter: that no price drifts from the rate it claims to use (every price is re-derived from the published rate and compared), and that every EU-27 member state resolves to its own currency. The second one has a reason in the test body, not just an assertion: Managed Payments always runs Adaptive Pricing, so an EU country accidentally left on sterling would be shown one figure on our page and re-priced to another on Stripe's. That is the exact failure the fixed-price design exists to prevent, so it is asserted country by country, with Croatia and Bulgaria's euro accession dates written down next to it.

And the last piece of honesty is a string. When the screen's country and the server's country disagree, checkout refuses to open a session in a currency the customer never saw, and says so:

The price where you are is €12.99 a month. Press again to continue at that price. 

No rate, no "converted from". It is not a conversion of anything. It is that country's price, which is the whole reason the table is shaped the way it is.

Terms for the reward are on munchable.app/terms, and the price you see at munchable.app/#pricing is the one the Checkout Session is built from.