DEV Community

Daniel Pertu
Daniel Pertu

Posted on

We stopped letting Stripe convert our price, because nobody could quote it

For a while, Munchable charged everyone in pounds and let Stripe's Adaptive Pricing convert on its own checkout page. It is a checkbox. It works. It also meant that if you asked me what the app costs in Dublin, the honest answer was "about twelve and a half euro, depends when you click".

That is not a price. A price is a number you can print, quote in an email, put in an app store listing and still be right about next month. So we deleted the conversion and replaced it with a table.

One price per currency, and it is the same number twice

The rule the whole module exists to enforce: the figure on the pricing page, the figure on the app's paywall and the amount the Checkout Session is created for are produced by one function call.

export function priceIn(pence: number, currency: DisplayCurrency): number
Enter fullscreen mode Exit fullscreen mode

£10 in the UK, €12.99 in the euro area, and it stays €12.99 until somebody edits the table. It does not move between the page and the payment, which is the entire point. A price that changes on the last screen is the thing people abandon a checkout over, and they are right to.

A country resolves to a currency, a currency has exactly one price. Anything not in the country table falls back to GBP, which is both the safe default and correct for most of our traffic. An unlisted country reads a pound price and is charged in pounds: the same number twice, which is the promise that matters.

Rounding up, because we settle in a different currency

We are paid in the customer's currency and settle in GBP, so Stripe takes a conversion percentage on the way back, and the mid-market rates in our table drift between refreshes. Both are absorbed before the number is ever shown:

const CONVERSION_FEE_BUFFER = 1.04;
Enter fullscreen mode Exit fullscreen mode

Top of Stripe's stated 2 to 4 percent band, so the local price sits a little above the mid-market conversion rather than a little below it. Then the result is rounded up to something that reads like a price:

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 at two decimals
  return Math.ceil(amount - step - EPSILON) + step;
}
Enter fullscreen mode Exit fullscreen mode

Two shapes, because currencies are written differently. Where there are decimals, the next .99, so the output is €12.99 and not €12.16. Where there are none, the next round step scaled to the size of the number, because a yen or forint price runs into the thousands and ¥2,246 reads as the output of a conversion rather than as a price. Yen land on ¥2,300, forint on 4,500 Ft, the way prices on a shelf there do.

The epsilon is not decoration. Without it a value sitting exactly on a boundary gets nudged up a whole step by binary float error, and you find out when one currency in the table is a pound more expensive than it should be for no visible reason.

The function guarantees a result greater than or equal to its input. Every other property of this module rests on that one.

Zero is the case that bites

if (pence === 0) return 0;
Enter fullscreen mode Exit fullscreen mode

Without that line the round-up turns the free tier's £0 into €0.99, and the pricing page advertises a price for the plan whose entire point is that it does not have one. It is two words of code and it is the bug I would most expect a rewrite of this file to reintroduce.

Discounts round the other way

We give reward credit in pence. Running it through priceIn would be the obvious reuse and it would be wrong:

export function discountIn(pence: number, currency: DisplayCurrency): number {
  if (pence <= 0) return 0;
  if (currency === BASE_CURRENCY) return Math.round(pence);
  const digits = minorUnitDigits(currency);
  return Math.floor((pence / 100) * CURRENCIES[currency].rate * Math.pow(10, digits));
}
Enter fullscreen mode Exit fullscreen mode

No buffer, plain mid-market rate, rounded down. A price rounded up is a figure that covers the conversion; a discount rounded up is a promise we then fail to keep. Twenty pence of credit reads "£0.20 off" here and "€0.23 off" in Dublin, never the "€0.99" the retail round-up would have produced from the same 20p.

Same rule underneath both, applied in opposite directions: err towards the customer.

Intl and Stripe disagree, and the disagreement costs 100x

Three of the currencies we sell in are written without decimals in their own locale, and Stripe does not treat them all the same way.

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',
]);
Enter fullscreen mode Exit fullscreen mode

Yen is genuinely zero-decimal: unit_amount is yen. Forint and Icelandic krónur are written without decimals by Intl but are sent to Stripe as hundredths that must divide evenly by 100, so a 4,500 forint price is unit_amount: 450000.

Get that backwards and nothing fails. No exception, no validation error, no red log line. A customer is charged a hundred times the price. That is why it is one function with its own tests rather than three lines in the checkout route, and why the zero-decimal set is Stripe's complete list rather than only the currencies we price in today: adding a currency to the table cannot silently miss its case.

The same reasoning drives reading the decimal count from Intl instead of assuming two:

export function minorUnitDigits(currency: DisplayCurrency): number {
  const { code, locale } = CURRENCIES[currency];
  return new Intl.NumberFormat(locale, { style: 'currency', currency: code })
    .resolvedOptions().maximumFractionDigits ?? 2;
}
Enter fullscreen mode Exit fullscreen mode

Quoting 2999 yen as "¥29.99" is wrong by two orders of magnitude and looks completely plausible on the page.

Why it is a package and not a file

The price now lives in @munchable/pricing, imported by the Next.js marketing site and by the Expo app's paywall.

Two surfaces quote a price. Before the package they each had their own copy of the number, which meant the interesting question was not "will they disagree" but "how long until somebody edits one of them". A shared table makes that class of bug unrepresentable.

It is also the reason the list price sits in the package rather than next to the Stripe client:

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

There is no Stripe Price object and no environment variable. Checkout builds an inline price_data line item from this constant. Every surface that only needs to show a price can import it without pulling the Stripe SDK into its module graph, which matters when one of those surfaces is a React Native bundle.

Every figure in the table is tax-inclusive: whatever VAT is due comes out of the number, never on top of it. A separate module decides where Stripe acts as merchant of record and collects it, and one sentence says so wherever a price appears.

Go and check it

The pricing table on the site reads the visitor's country and prints the figure from this table:

  • The pricing section is the live output. Load it behind a VPN in Ireland, Japan or Hungary and watch the number change shape as well as size: not €12.16, not ¥2,246, but the price we would actually charge.
  • The terms state the tax-inclusive position.
  • The landing page is what the price buys, if you want the other half of the story.

The number you see in the pricing table is the number the Checkout Session gets created for. That was the whole exercise.

Top comments (0)