DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Two features both wanted to pick the currency, so one of them is switched off in code

We price visitors in their own currency from a table of rates, and the figures on our pricing page are the figures Checkout charges. That claim survives only if nothing between the page and the card is allowed to change the number.

Stripe has a feature whose whole job is to change the number. Adaptive Pricing re-prices a Checkout session into the customer's local currency at Stripe's own rate, which is a genuinely good feature if your prices are in one currency and your customers are not. For us it meant the customer would read one price on our page and then see Stripe's conversion of it on the next, which is the exact arrangement we had just spent a release deleting.

So every session we create carries one of two shapes, and which one it gets is decided by something else entirely: whether the sale is routed through Stripe as merchant of record.

Off, or absent, never left alone

function withoutAdaptivePricing(params) {
  return { ...params, adaptive_pricing: { enabled: false } }
}

function withAdaptivePricingUnset(params) {
  const { adaptive_pricing: _adaptivePricing, ...rest } = params
  return rest
}
Enter fullscreen mode Exit fullscreen mode

Two functions that look like the same function. The difference matters because Managed Payments, Stripe's merchant-of-record mode, rejects the adaptive_pricing field outright and turns Adaptive Pricing on regardless. You cannot send it enabled: false. You cannot send it at all.

Which leaves the obvious question: if Adaptive Pricing is forced on for every merchant-of-record session, how is that safe when we have just said it must never be allowed to re-price anything?

It is safe only because of an invariant kept in a completely different file. Our merchant-of-record allowlist is the EU-27, and every EU-27 country is priced in its own currency by our own table, so a Dutch customer's session is already in euros and there is nothing for Stripe to convert. Adaptive Pricing is on and has no work to do.

That is a fragile kind of safety, so it is held down by a test rather than by a comment:

✓ prices every Managed Payments country in its own currency
✓ has exactly 21 euro members and six with their own currency
✓ covers exactly the EU-27, and never GB
Enter fullscreen mode Exit fullscreen mode

Add a country to the merchant-of-record allowlist without adding its currency to the pricing table, and that first test goes red before the mismatch can reach a customer.

Why merchant of record at all, and why only there

When Managed Payments is on for a session, Stripe becomes the legal seller: it calculates, collects, files and remits the VAT, and carries the liability. In exchange Stripe takes its published merchant-of-record fee on top of normal card processing, and the tax is withheld from the payout, so the money that arrives is net.

That is worth paying for where foreign tax is genuinely due from the first sale, and not worth paying anywhere else, so the routing is an allowlist rather than a global setting:

  • EU-27: on. VAT is due in the customer's country from the first sale of an automated digital service by a UK seller.
  • UK: off. We are under the £90,000 VAT registration threshold. Switching merchant of record on for UK sales would have Stripe charge 20% VAT immediately, which throws the threshold away for nothing.
  • US: off. No sales tax nexus at this volume.
  • Everything not on the list: off.

The country comes from the visitor's IP at session creation, which is a coarse signal and is openly treated as one in the code. It is the same header that chose the currency the customer was shown, which is the real reason to read it once: the price and the tax routing cannot disagree about where the customer is, because they are reading the same byte. Once merchant of record is on, the authoritative tax country is the billing address the customer types on Stripe's own page, and Stripe computes the tax from that.

Every line item carries tax_behavior: 'inclusive', merchant of record or not, because that is a property of the price we quote rather than of the routing: whatever tax applies sits inside the figure on the page. A test says it in those words, "keeps tax inside the price, so the price shown is the price charged".

The failure mode that would have taken EU checkout down

Managed Payments has to be activated on the Stripe account, with the merchant-of-record terms accepted and an eligible tax code. If any of that is not true, Stripe rejects every session that sets managed_payments. Not some. Every one.

Since the field is set from the customer's country, a misconfiguration would not fail loudly in one place. It would fail as "checkout is broken in the EU" while the UK and the US sold normally, which is the kind of outage you find out about from a support email.

So the session creation helper retries once, without merchant of record, and only when Stripe's rejection was specifically about that field:

try {
  return await stripe.checkout.sessions.create(
    context.managedPayments ? withAdaptivePricingUnset(params) : withoutAdaptivePricing(params)
  )
} catch (error) {
  if (context.managedPayments && isManagedPaymentsRejection(error)) {
    console.error('[checkout] Managed Payments session rejected by Stripe, retrying without MoR', {
      country: context.country ?? null,
      stripe: describeStripeError(error),
    })
    return await stripe.checkout.sessions.create(
      withoutAdaptivePricing(stripManagedPayments(params))
    )
  }
  throw error
}
Enter fullscreen mode Exit fullscreen mode

Two parts of that deserve the attention.

The detector has to be narrow. A retry that swallowed a card decline, a rate limit or a bad amount would be worse than the outage, because the sale would silently complete under the wrong tax treatment or fail twice for a reason nobody logged. isManagedPaymentsRejection only returns true for Stripe's request-validation error type, and then only when the param is managed_payments (or something nested under it), or the message mentions managed payments. It is duck typed rather than using instanceof, which keeps it testable without the SDK's error classes, and the tests spell out each refusal:

✓ recognises an explicit managed_payments param rejection
✓ recognises a nested managed_payments param
✓ recognises the phrase in an invalid-request message
✓ does NOT match a card decline, so a real failure is never retried away
✓ does NOT match transient network or API errors
✓ does NOT match an unrelated invalid-request error
✓ does NOT match an arbitrary Error that happens to mention the phrase
Enter fullscreen mode Exit fullscreen mode

The strip has to be exact. Removing merchant of record from a session means removing precisely the fields that merchant of record added, which is the top level managed_payments and the tax_code on each line item's product_data. tax_behavior stays, because it belongs to the price. A line item built from a Price id carries none of those fields and passes through untouched. The input is cloned rather than mutated, and there is a test for that alone, because a mutating strip would quietly change the params the caller still holds a reference to.

The result is a sale that completes with no tax collected where tax was due, which is not nothing. It is a flagged sale instead of a lost customer, and the comment says so out loud: a flagged sale we can fix is better than a customer we lost.

The flag, after the fact

Flagging happens in the Stripe webhook, on checkout.session.completed, and it is the only thing that event does. Provisioning is driven entirely by subscription events, so a tax routing miss can never delay anybody's plan.

const country = session.customer_details?.address?.country ?? null
const taxAmount = session.total_details?.amount_tax ?? null
if (!isPossibleMoRMisroute(country, taxAmount)) return
console.error('[stripe-webhook] EU sale completed with no tax collected, possible Managed Payments misroute', { ... })
Enter fullscreen mode Exit fullscreen mode

It compares the billing country Stripe actually captured against the tax Stripe actually collected. An EU billing country with zero tax is the signal, which catches both the misconfiguration retry above and the ordinary case of a customer whose IP exits in another country and who then types a French address on Stripe's page.

A genuine EU business-to-business sale with a valid VAT number also shows zero tax under reverse charge. That is a false positive, it is accepted in writing, and the handler is review-only, so the cost of being wrong is a log line rather than a refused sale. The log carries the session's own currency and amount alongside the GBP list price that checkout stamped on the session metadata, so the sale can be reconciled in pounds whatever it was charged in.

What a reader can check

Not much of this is visible from outside, which is the nature of billing code. Two things are:

  • curl -s https://pub-trivia.app/pricing | grep -o "Prices in [A-Z]\{3\}[^<]*" returns the currency your IP is priced in, and the note ends "including VAT where it applies. You pay the price shown." There is no second number later in the flow, which is what all of the above exists to protect.
  • Our terms state the billing currency rule, including that a plan stays in the currency it started in. A test asserts those sentences are still in the page, because the prose is now part of the contract the code implements.

The suites behind this are 27 tests on the merchant-of-record module and 22 on the session helper, including the four that say "does NOT retry" in various ways. Those four are the ones I would keep if I could only keep four.

Top comments (0)