Selling software across borders is a tax problem wearing a billing problem's clothes. We are a UK company selling an automated digital service to pubs and bars. The rules that follow from that one sentence are not symmetrical, and the asymmetry is the entire design.
- Sell to a business in Germany and VAT is due in Germany, from the first sale. There is no threshold to hide under.
- Sell to a pub down the road in the UK and nothing is due until we cross the £90,000 registration threshold.
Stripe sells a product for the first case. Managed Payments makes Stripe the legal seller of record for a transaction: it calculates the indirect tax, collects it, files it, remits it, and carries the liability. The fee is 3.5% on top of normal card processing, and the tax is withheld from the payout, so we only ever receive net.
The naive move is to switch it on. It is a tax feature, tax is scary, switching it on makes the scary thing somebody else's problem. It is also how you pay 3.5% on every sale you make, and charge 20% VAT on UK sales that owe nothing, which throws away the threshold benefit on your home market to buy insurance against a risk you do not have.
So ours is selective. Here is the shape of it.
An allowlist, and nothing outside it
export const MANAGED_PAYMENTS_COUNTRIES: ReadonlySet<string> = new Set([
'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR',
'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL',
'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE',
])
The EU-27, and that is it. Not a denylist of countries we have decided are fine, because a denylist is a promise about every country on earth including the ones nobody has looked at yet. An allowlist is a promise about twenty-seven.
Everything else is off by construction. The US is off: no sales tax nexus at our volume. Norway, Switzerland, Australia, Canada, Japan, Singapore and the UAE all run their own digital services regimes for foreign sellers, and every one of them is a conversation with an accountant plus a check against Stripe's own tax coverage list before it earns a line in that Set. Until then, absence is the safe answer.
GB is the entry that is deliberately missing, and it is the one the test file is loudest about:
it('covers exactly the EU-27, and never GB', () => {
expect(MANAGED_PAYMENTS_COUNTRIES.size).toBe(27)
expect(MANAGED_PAYMENTS_COUNTRIES.has('GB')).toBe(false)
expect(MANAGED_PAYMENTS_COUNTRIES.has('US')).toBe(false)
})
A test asserting a Set has 27 members looks like the sort of test people make fun of, a tautology dressed up as coverage. It is not. It is a tripwire on a number that only ever changes by accident, and the accident costs real money in a direction nobody notices for a quarter.
Unknown is not a guess
export function shouldEnableManagedPayments(country: string | null | undefined): boolean {
if (!isManagedPaymentsEnabled()) return false
if (!country) return false
return MANAGED_PAYMENTS_COUNTRIES.has(country.toUpperCase())
}
Three gates, all of which have to pass.
The master switch is an env var that must be exactly the string "true", read at call time rather than at import time so that runtime configuration and tests both behave predictably. It exists because Managed Payments has to be activated on the Stripe account and its terms accepted in the Dashboard before Stripe will accept the field at all, and code that assumes the account is ready is code that fails in production and nowhere else.
The second gate is the one worth copying. The country comes from an IP geolocation header, and that header is missing locally and absent on some edge cases. Missing has to mean "normal flow", never "best guess", because a guess here is either a tax you did not owe or a tax you failed to collect. There is a test for the empty string too, since '' and null arrive by different routes and only one of them is the one you remember.
The toUpperCase is not defensive padding. Case differences in a header value are real, and has('de') on a Set of uppercase codes is silently false, which is the worst kind of wrong: it fails in the direction that under-collects.
The IP header decides routing, not tax
This is the distinction that makes the coarse signal acceptable.
The IP tells us whether to create the session with Managed Payments on. That is all it does. Once it is on, the tax Stripe actually computes comes from the billing address the customer types on Stripe's hosted page. So a German pub owner on a VPN, or someone travelling, is handled correctly by Stripe regardless of what our header said, because the authoritative fact arrives after we have stopped having opinions.
We quote in GBP everywhere and convert for display only, which is a separate piece of machinery with its own post. The relevant part here is that the advertised price is tax exclusive: £30 a month for Pro, and a German buyer pays 19% on top of that at checkout, so £35.70. Showing tax inclusive prices would mean absorbing VAT out of margin at a different rate in every country, which turns one price into twenty-seven.
When MoR does not apply, the code does nothing at all
const mp = managedPaymentsCheckout(country)
await createCheckoutSession({
...mp.session,
customer: stripeCustomerId,
line_items: [{ price: priceId, quantity: 1 }],
mode: 'subscription',
...
})
There is no if at the call site. managedPaymentsCheckout returns either { managed_payments: { enabled: true } } or {}, and spreading an empty object is a no-op, so the non-MoR path is byte for byte the behaviour that existed before any of this was written. The test says so directly:
it('returns empty no-op objects for a UK sale', () => {
const mp = managedPaymentsCheckout('GB')
expect(mp.session).toEqual({})
})
That shape matters more than it looks. The alternative, a conditional that builds two different session objects, is two code paths to keep in step, and the one that runs for most of your customers is the one you test least.
The failure mode is a flagged sale, not an outage
If Stripe rejects managed_payments for a session, for instance because the account is not activated or the tax code is not eligible, the sale must still complete. So the field is stripped and the call retried once. The detection is narrow on purpose: it only fires for StripeInvalidRequestError, and only when the error names the managed_payments param or mentions Managed Payments in its message. A declined card is not a routing problem and must never be swallowed by a retry. That retry, and the thinking behind refusing to retry most things, is its own post.
What that leaves is an EU sale that completed with no tax on it, which is exactly the thing we wanted to avoid. So the webhook checks afterwards:
function flagPossibleMoRMisroute(session: Stripe.Checkout.Session): void {
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', { ... })
}
EU billing country, zero tax collected, log it loudly. It provisions nothing and blocks nothing: it is review only, and it accepts false positives, because a genuine B2B reverse charge sale with a valid VAT number also shows zero tax. An occasional false alarm you can dismiss in ten seconds is a much better trade than a quarter of quietly under-collected VAT.
The general principle: when the routing decision is made from a best effort signal, the thing that makes it safe is not a better signal, it is a cheap check after the fact against the authoritative one.
Have a look
pub-trivia.app/pricing renders the plan prices, and the small print under each one is the customer facing end of all of the above: the figure is approximate, the charge is in GBP, Stripe shows the exact total, and VAT is added based on your country. Those are four separate facts folded into one line rather than four stacked grey notices, which is a copy decision I would defend as firmly as the routing.
If you want to see the conversion move, open the page through a VPN exit in Germany, then in the US, and watch the currency and the rate note change while the structured data in the page source keeps quoting £30 in GBP to crawlers.
The free tier needs no card, so you can get as far as the checkout page and see what Stripe says the total is in your own country without buying anything.
Top comments (1)
The after-the-fact check against the billing country is the best part. Two ideas for it. If you collect tax IDs at checkout, you can skip sessions where customer_details.tax_ids holds a VAT number, so reverse-charge B2B sales stop tripping the alarm and what's left is mostly real misroutes. And the mirror case deserves a log line too: Managed Payments switched on (German IP) but the billing country comes back GB. That's the UK pub owner on a VPN, who costs you the 3.5% and may be charged VAT you didn't owe yet.