DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our prices stopped being conversions, and the minimum charge had to move layers for it

We show prices in the currency of the country a visit comes from, and we charge in that currency too. For a long time the first half was a display feature with a footnote attached: the number on the card was a conversion from our pound price, with a tooltip saying so, and a useFormatPrice() hook that returned an approximate: boolean so any surface could ask whether what it was rendering was real.

That boolean is now gone, along with the tooltip and the function that produced it. The reason is not a copy decision. It is that there was one place where the number on the page and the number on the card genuinely could differ, and the fix was to move a table between two layers.

The floor was in the wrong layer

Stripe enforces a minimum charge per currency. Ours, in pounds, is 30p, which is well under every list price but very much in play at the far end of a discount. Convert 30p into forint and you get roughly 133 Ft, and Stripe's Hungarian minimum is 175 Ft.

The old code clamped at the Stripe call:

const written = convertForDisplay(pence, currency);
const raw = currency === BASE_CURRENCY ? pence : toStripeMinorUnits(written, currency);
const unitAmount = Math.max(raw, STRIPE_MINIMUM_CHARGE[currency] ?? 0);
Enter fullscreen mode Exit fullscreen mode

convertForDisplay is what the price component renders. So the page said 133 Ft, the clamp ran afterwards, and the Checkout page asked for 175 Ft. Nothing was lost and nobody was overcharged relative to our own floor, but a buyer who reads one number and is shown another on the next screen has no way to tell which of the two is the bug.

The floor now lives inside the conversion:

export function convertForDisplay(pence: number, currency: DisplayCurrency): number {
  const minimum = MINIMUM_CHARGE[currency] ?? 0;
  if (currency === BASE_CURRENCY) return Math.max(Math.round(pence), minimum);

  const digits = minorUnitDigits(currency);
  const major = (pence / 100) * CURRENCIES[currency].rate * CONVERSION_FEE_BUFFER;
  return Math.max(Math.round(ceilToRetail(major, digits) * Math.pow(10, digits)), minimum);
}
Enter fullscreen mode Exit fullscreen mode

and the charge builder has an explicit comment saying there is deliberately nothing after the unit shift, because anything there could move the charge away from the figure the buyer read.

Two tables of the same numbers is a bug waiting

Stripe's API wants minor units: 175 forint is 17500. The display layer wants written units, because that is what gets formatted. Those are the same table in two shapes, and keeping both by hand is the sort of duplication that survives review and then diverges in a year.

So one is derived from the other:

export const STRIPE_MINIMUM_CHARGE: Partial<Record<DisplayCurrency, number>> = Object.fromEntries(
  (Object.entries(MINIMUM_CHARGE) as [DisplayCurrency, number][]).map(([currency, written]) => [
    currency,
    toStripeMinorUnits(written, currency),
  ])
);
Enter fullscreen mode Exit fullscreen mode

One table of 22 currencies is maintained, with a link to Stripe's documented minimums. The API shape is computed. Adding a currency is one line in one place, and the two can no longer disagree.

Stripe will also happily re-price you

Having made the page and the session agree, there is a third party with an opinion: Adaptive Pricing, which lets Stripe show the buyer a converted price in another currency on the Checkout page. That is exactly the divergence we had just removed, reintroduced by a Dashboard default.

The whole handling is four lines, applied in the wrapper every checkout route goes through rather than in the routes themselves:

function withFixedCurrency(
  params: Stripe.Checkout.SessionCreateParams
): Stripe.Checkout.SessionCreateParams {
  const { adaptive_pricing: _adaptivePricing, ...rest } = params;
  if (rest.managed_payments?.enabled) return rest;
  return { ...rest, adaptive_pricing: { enabled: false } };
}
Enter fullscreen mode Exit fullscreen mode

The early return is the interesting half. Adaptive Pricing is always on for Managed Payments sessions and Stripe rejects the parameter there, so we cannot switch it off. What makes that safe is a constraint in a completely different module: Managed Payments is restricted to the EU-27, and every EU-27 country is already priced in its own currency, so Stripe has nothing left to convert. Both halves are pinned by tests, because the next person to add a managed-payments country needs to find out from a red test rather than from a receipt.

There is also a retry path for the case where Stripe rejects the managed_payments field outright, so a misconfiguration does not fail every EU checkout. The retry strips the field, which drops the session back into the branch that switches Adaptive Pricing off, so it still charges what the page showed. Running the wrapper in one place instead of per route is what makes that true for free.

Then the words could change

Once the charge cannot differ from the display, the honest description changes. A price in euros is not a converted pound price presented for convenience, it is the price in that country and the amount the card is asked for. Every surface that hedged it was wrong, so the conversion note, the tooltip and the approximate flag came out.

One conversion note survives, and it is about other people's prices. Our comparison page quotes competitors' figures, and those really are converted from their published UK prices, so that page keeps a note saying exactly that.

See it

Both pages resolve currency from the same signal, so load them in one session from outside the UK:

  • cogniprep.app/pricing carries prices with no conversion note anywhere on the page, just "VAT included". From a euro-area visit today it reads 18.99, 12.99 and 6.99 euro.
  • cogniprep.app/comparison carries the one note that is left: "Other platforms' prices are converted from their UK prices in GBP."
  • cogniprep.app/terms is where the promise is now written down: prices "include any tax that applies, and you pay the price shown, in the currency shown". It previously said purchases were charged in pounds sterling with the local figure as a guide. That sentence is a commitment that only a test suite can keep, which is why the floor moved layers.

Top comments (0)