DEV Community

Daniel Pertu
Daniel Pertu

Posted on

There are no Prices in our Stripe Dashboard, only 24 rates in a table

Our pricing page used to be a claim and our Stripe Dashboard used to be the fact. The doc comment on top of lib/pricing/plans.ts admitted it in as many words: the prices in the file and the prices on the Stripe Price objects were two copies of one fact and could drift, the file was what the site advertised, the Stripe Price was what was charged, and when you changed a price you had to change it in both places in the same sitting.

That comment is gone, and so are the Price objects. There is nothing to set up in our Stripe Dashboard now: no Products, no Prices, no currency variants. Checkout builds every line item inline from one table, in the visitor's own currency, at the figure the page printed.

You can see the output before reading any of the mechanism:

curl -s https://pub-trivia.app/pricing | grep -o "Prices in [A-Z]\{3\}[^<]*"
Enter fullscreen mode Exit fullscreen mode

From a London IP that prints Prices in GBP, including VAT where it applies. You pay the price shown. From where I am sitting it prints Prices in EUR, and the page body contains no pound sign anywhere. Switch a VPN exit to Tokyo and the yen prices come back with no decimal places at all. The page is at pub-trivia.app/pricing if you would rather just look at it.

One table, two consumers

The currency table has 24 entries. Each is a lowercase id (which is also the code Stripe takes), an ISO code, a locale to format with, and a mid-market rate expressed as units per one pound:

export const CURRENCIES = {
  gbp: { code: 'GBP', locale: 'en-GB', rate: 1 },
  eur: { code: 'EUR', locale: 'en-IE', rate: 1.1696 },
  sek: { code: 'SEK', locale: 'sv-SE', rate: 12.8667 },
  // ... 21 more
} as const satisfies Record<string, CurrencyDefinition>
Enter fullscreen mode Exit fullscreen mode

The rates are fixed, not live, and that is the whole design rather than laziness. A live feed would move a customer's price between two page loads, put a network call in the render path of the one number they came to read, and make the figure shown at 10:59 differ from the figure charged at 11:00. They were sourced from the ECB reference rates on a dated afternoon, and the note in the file says to refresh one when it has moved more than about three per cent.

A second map takes a two letter country code to one of those currencies, with anything unlisted falling back to pounds. It leans towards Europe because that is where the venues are. Every EU-27 member is priced in its own currency on purpose, which matters later.

One detail in the lookup that is easy to get wrong: the country comes from a request header, so it is attacker influenced even if only through a spoofed IP, and reading the map directly would let a country code of constructor or __proto__ return a function. The lookup goes through Object.prototype.hasOwnProperty.call for that reason and returns pounds for anything it does not own.

What a local price actually is

A plan is an integer number of GBP pence. Pro is 3000. The local price is that, converted, plus a margin, rounded up to a retail figure:

const major = (pence / 100) * CURRENCIES[currency].rate * LOCAL_PRICE_MARGIN
return Math.round(ceilToRetail(major, digits) * Math.pow(10, digits))
Enter fullscreen mode Exit fullscreen mode

LOCAL_PRICE_MARGIN is 1.04. It is not a markup for its own sake: Stripe charges us to convert a payout in another currency back into pounds, and the fixed rates drift between refreshes, so without it a sale in krona can net less than the same sale in pounds. Four per cent covers both.

ceilToRetail rounds up to the next .99, or to the next whole unit in a currency written without decimals. Pro is £30, so euros come out as 30 x 1.1696 x 1.04 = 36.49, which becomes €36.99. Ultimate is £100, which lands on 121.63 and becomes €121.99. Those are the two figures on the live page right now from an EU address, and they are the two figures Checkout charges, which is the point of the exercise.

The rounding has an epsilon in it, because a value sitting exactly on a boundary would otherwise be nudged up by a whole unit by binary float error.

The free plan cost 99 cents

Here is the bug that came out of the rounding rule, and it is my favourite kind, because every individual step is correct.

The free trial is a plan like any other, with a price of 0 pence. Convert zero and you get zero. Round zero up to the next .99 and you get 0.99. So the one plan whose entire proposition is that it costs nothing displayed as €0.99 everywhere outside the United Kingdom, while still saying "no card needed" next to it.

The fix is three lines and a comment longer than the code:

// Free is free in every currency. Without this guard the retail round-up turns
// the trial's £0 into "€0.99", because the next `.99` above zero is 0.99.
if (pence === 0) return 0
Enter fullscreen mode Exit fullscreen mode

Formatting is part of the charge now

When the price shown is also the price taken, the formatter stops being cosmetic.

Two currencies in our table are written without decimal places where Stripe still counts them in hundredths, and one is written and counted in whole units. Those are two different lists in the file, not one, because conflating them would be wrong by two orders of magnitude in whichever direction you picked. The forint is the interesting one: JavaScript engines disagree about it, V8 writing it without decimals while engines following ISO 4217 strictly give it two. Our page renders once on the server and again in the browser on hydration, so a price that depends on the engine is a price that can disagree with itself inside one page view. The list is hardcoded and a test checks it against the server's own Intl data so a new zero decimal currency cannot slip past.

Every Intl.NumberFormat call passes its fraction digits explicitly, so the engine's idea of a currency never changes the figure. Whole amounts drop the decimals, because .00 on every list price reads as more precision than a round number has.

The number that goes to Stripe gets its own function, because Stripe's units are not always the units the currency is written in:

export function stripeUnitAmount(pence: number, currency: DisplayCurrency): number {
  return Math.round(
    localPrice(pence, currency) * Math.pow(10, stripeDigits(currency) - minorUnitDigits(currency))
  )
}
Enter fullscreen mode Exit fullscreen mode

The line item

With all of that in one module, the Checkout call is dull, which is the goal:

line_items: [{ price_data: planPriceData(planType, currency, mp.productData), quantity: 1 }]
Enter fullscreen mode Exit fullscreen mode

and planPriceData is nine lines: the currency, stripeUnitAmount of the plan's pence, a monthly recurring, a product name for the invoice, and a tax behaviour of inclusive so any VAT sits inside the figure rather than appearing on top of it at the last step.

The plan rides on the subscription's metadata rather than being inferred from the price, so a subscription in any currency reads back the same, and the GBP list price rides along too so that a sale in forint can still be accounted for in pounds.

One more rule falls out of Stripe's own behaviour rather than ours: a subscription keeps the currency it started in for as long as it lives, because Stripe will not bill one customer in two currencies. So a signed in customer with a live subscription is priced in that subscription's currency, not in their country's, and a plan change has to be quoted in it. Our terms say this out loud, and a test asserts the sentence is still there: "billed in the currency it started in".

What the tests hold

The pricing and billing suites are 159 tests across six files, 43 of them on the currency module alone. The ones I would not want to lose:

  • A local price never nets less than the pound price, and never strays an unreasonable distance above the true conversion.
  • Prices are monotonic, so the cheaper plan can never round up past the dearer one.
  • The Stripe amount reads back as exactly the displayed price, in every currency.
  • Zero renders as a figure containing no non-zero digit, whatever the currency.
  • The hardcoded zero decimal list agrees with the runtime's Intl data.
  • The rendered pricing cards carry the exact displayed price in six currencies, and the purchase button says "Purchase €36.99/mo" rather than a pound figure.
  • No price surface describes our own price as a conversion, an estimate, or a guide to a pound figure. That one is a regex over the rendered text and over the source of eleven customer facing files, built from the words a weaselly price note tends to use: approx, convert, estimat, exchange, rounded up.

The last one is the test I like most, because it is the test that stops the design quietly rotting back into the old one. The moment a page says "approximately €36.99" we are back to advertising one number and charging another, and the suite goes red for a prose change.

Have a look at pub-trivia.app/pricing in whatever country you are in. If the note at the bottom of the cards names your currency and the figures end in .99, that is this table, and if you took a plan the figure on your statement would be the one you read.

Top comments (0)