DEV Community

Daniel Pertu
Daniel Pertu

Posted on

We shipped geo-localised pricing three times before it worked

Our prices are set in GBP. Every Stripe Checkout session we create is denominated in pence, we settle in pounds, and our accounting never sees another currency. That is a completely reasonable thing for a small business to do.

It also meant that for months, a visitor in Chicago read "£10" on our pricing page, clicked Buy, and landed on a Stripe page quoting dollars. Stripe Adaptive Pricing was doing exactly what it promises: converting at the point of payment. The problem was not the conversion. The problem was that the number changed at the single moment in the funnel where a person is deciding whether to trust you with a card.

So we set out to show an approximate local price on the page itself. It took three attempts, and the second one was silently broken in production for longer than I would like to admit.

Attempt one: resolve it in the browser

The obvious version. Mount, figure out the visitor's country, set state, re-render the price.

const [currency, setCurrency] = useState('gbp')

useEffect(() => {
  setCurrency(currencyForCountry(detectCountry()))
}, [])
Enter fullscreen mode Exit fullscreen mode

This works, in the sense that the right number eventually appears. It also means every visitor sees £10 painted first and then watches it flip to $14.99. On a pricing page. A price that visibly changes after load is worse than a price in the wrong currency, because the first one looks like a bug or a trick and the second one just looks foreign.

Attempt two: render every currency, hide all but one with CSS

This is a genuinely clever pattern and I still think it is clever. Render all the currency variants into the markup, put a class on <html> before first paint using a blocking inline script, and let a generated stylesheet hide the ones that do not match:

<script>
  document.documentElement.dataset.cur = resolveCurrency()
</script>
<style>
  html:not([data-cur="usd"]) .cur-usd { display: none }
  html:not([data-cur="eur"]) .cur-eur { display: none }
  /* ...one rule per currency */
</style>
Enter fullscreen mode Exit fullscreen mode

No flicker, no JavaScript dependency at paint time, works with a fully static page. Lovely.

It shipped broken. The generated <style> block was dropped from the production HTML, so none of the hiding rules ever made it to a real visitor. What should have been an instant, obvious failure (every currency visible at once, stacked) was invisible to us locally, because a stale cached stylesheet was still supplying the rules on our own machines.

The lesson I took from that is not "CSS variants are bad". It is that this approach has a failure mode where the fallback is worse than the bug it replaced, and where local development is structurally incapable of showing you the failure. That is a bad trade for a page that takes money.

Attempt three: just resolve it on the server

Vercel puts the visitor's country on every request as x-vercel-ip-country. A server component can read it, and then there is exactly one price in the markup, correct in the first frame, with no script and no stylesheet in the critical path.

export async function getDisplayCurrency(): Promise<DisplayCurrency> {
  const headerList = await headers()

  if (isBotUserAgent(headerList.get('user-agent'))) {
    return BASE_CURRENCY
  }

  const country =
    headerList.get('x-vercel-ip-country') ??
    (await cookies()).get(COUNTRY_COOKIE)?.value ??
    null

  return currencyForCountry(country)
}
Enter fullscreen mode Exit fullscreen mode

The resolved currency goes into a context provider rendered on the server, so the client components that format prices read it on their very first render. There is no hydration correction to see because there is nothing to correct.

Three details in there are worth pulling out.

Bots get the base currency. Googlebot is geolocated to whatever datacentre fetched the page, so without that branch a UK business ends up with dollar prices in the search index. Quoting the currency we actually charge in is both more honest and better for search.

There is a cookie fallback. Middleware writes the country to a readable cookie on every request, which covers the paths middleware does not run on and local development, where no header exists and we correctly land on GBP.

We gave up ISR on the price pages. Reading the request makes those routes render per request. That is a real cost and we took it on purpose: a page that renders fast and shows the wrong number is worse than one that renders correctly.

Round up, never down

One piece of arithmetic matters more than the rest. Stripe's presented exchange rate embeds a card conversion fee of roughly two to four percent. If you display a naive mid market conversion, the customer sees a number at checkout that is higher than the one on your page. That is the same surprise we started out trying to remove, just smaller.

So the displayed figure is deliberately an upper bound:

const CONVERSION_FEE_BUFFER = 1.04

export function convertForDisplay(pence: number, currency: DisplayCurrency): number {
  if (currency === BASE_CURRENCY) return Math.round(pence)

  const digits = minorUnitDigits(currency)
  const major = (pence / 100) * CURRENCIES[currency].rate * CONVERSION_FEE_BUFFER

  return Math.round(ceilToRetail(major, digits) * Math.pow(10, digits))
}
Enter fullscreen mode Exit fullscreen mode

The invariant the whole module rests on: the number we display is never lower than the amount Stripe will ask for. Quoting slightly high and charging slightly less is the only safe direction for that error to point.

Because the figure is now an estimate rather than a charge, it has to say so. Every converted price carries one line of small print with the actual rate used, so anyone who checks our arithmetic finds it reconciles:

Approximate EUR (£1 ≈ €1.22, incl. card fees). Charged in GBP. Stripe shows the exact total at checkout. VAT added at checkout based on your country.

That line used to be three separate stacked notices. Collapsing them to one kept every disclosure while letting the price stay the loudest thing on the card.

Try it on the live page

Rather than take my word for any of this, go and poke at it.

  1. Open cogniprep.app/pricing. Note the currency and the small print under the price.
  2. Turn on a VPN and pick a different country. Japan, Iceland and Hungary are the interesting ones, for reasons in the next section.
  3. Reload. The price is in the new currency in the first paint, and the footnote shows the exact rate used to get there.
  4. Hard reload with the VPN off. It goes straight back.

If you want to see the bot path, request the page with a crawler user agent and you will get GBP regardless of where you are.

The bug waiting for you: zero decimal currencies

If you build this, here is the one that will get you. Not every currency has two decimal places. Yen, forint and Icelandic króna are all written without decimals in their own locales. Hardcoding toFixed(2) means quoting ¥2,999 as "¥29.99", which is wrong by two orders of magnitude and wrong in the direction that makes your product look free.

Do not keep a list of zero decimal currencies either. Ask Intl:

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

A hardcoded list is exactly the kind of duplicated knowledge that goes stale the moment someone adds a currency, and this one goes stale quietly.

What I would tell myself at the start

Display localisation is a rendering problem, not a payments problem. We never needed multi currency pricing, multiple Stripe prices, or per region price books. We needed the page to say, before first paint, roughly what the checkout page was going to say, plus one honest line admitting it was an approximation.

Resolve it on the server. Round up. Ask Intl how many decimals. Tell the crawler the truth.

Top comments (0)