A one-person UK company selling a subscription to someone in Germany owes German VAT on that sale from the first euro. There is no threshold to hide under. Do that across the EU and you are either registered for a scheme and filing returns, or you are relying on somebody else to be the legal seller.

Stripe sells the second option: Managed Payments, where Stripe Link becomes the merchant of record for the sale. It works out the tax, collects it, files it, pays it, sends the receipts and handles the disputes. It costs 3.5% on top of normal card processing, and the tax comes out of the payout rather than being added on top.

Nakodo's plans are sold by CogniPrep Ltd, as the terms say. The whole decision about when Stripe stands in front of that company is 27 country codes and one boolean.

The scope is a Set, and the reasoning is in the file

export const MANAGED_PAYMENTS_ENABLED = true; 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 a comment saying a country goes in only once its tax treatment is confirmed and Stripe actually covers it. The more useful part of that file is the list of places it is deliberately off:

  • The UK, while the company is under the £90k VAT registration threshold. Turning it on there would charge 20% UK VAT from the first sale, on sales that owe nothing. Revisit past the threshold.
  • The US. No sales tax nexus at this volume.
  • Norway, Switzerland, Australia and the others with their own digital services rules. Off until an accountant confirms the tax is owed and Stripe covers that country.

That is the part I would want to read in somebody else's repo: not what the switch does, but every place it is off and the reason, so the next person does not "fix" it by adding countries.

Two constants come with it:

export const MANAGED_PAYMENTS_TAX_CODE = "txcd_10103001"; export const MANAGED_PAYMENTS_TAX_BEHAVIOR = "inclusive" as const; 

Inclusive is a product decision, not a tax one. Tax sits inside the price, so the number on the pricing page is the number on the card. An EU sale nets the price less its VAT. Every plan price carries the behaviour even though it only does anything where Stripe charges tax, because a price whose meaning depends on where you are standing is a support ticket generator.

The IP decides the routing, not the tax

const inScope = (c?: string | null) => !!c && MANAGED_PAYMENTS_COUNTRIES.has(c.trim().toUpperCase()); export function shouldEnableManagedPayments(country: string | null | undefined): boolean { return MANAGED_PAYMENTS_ENABLED && inScope(country); } 

The country here is the visitor's IP country when Checkout opens, which is the same signal that picked the currency they were shown on the pricing page. That is a weak signal and it is fine, because it only decides routing. Stripe taxes by the billing address entered at Checkout. A German customer on a US VPN gets a session without Managed Payments and then types a German address, and Stripe's own tax logic is what notices.

Which leaves a gap, and the gap gets a function instead of a shrug:

// A completed sale with an EU billing address and no tax looks like one that // missed Managed Payments, through a VPN or a wrong IP lookup, or because // Stripe rejected Managed Payments and Checkout went ahead without it. A // business sale under the reverse charge also shows no tax, so this only // flags a sale for review. export function isPossibleMoRMisroute(country: string | null | undefined, taxAmount: number | null | undefined): boolean { return (taxAmount ?? 0) <= 0 && inScope(country); } 

The webhook calls it on every completed sale, against the billing address Stripe captured rather than the IP, and logs a warning naming the Checkout Session, the IP country and which branch the session took. It flags, it does not block. The reverse charge means a legitimate B2B sale to an EU company with a valid VAT number also shows zero tax, so a hard rule here would reject real customers. A warning somebody reads is the right amount of automation for something that happens rarely and needs judgement.

Two different Checkout sessions

Managed Payments does not compose with the fields you would normally set. It collects and stores the billing details itself, and it rejects the rest:

const params = (managed: boolean): Stripe.Checkout.SessionCreateParams => ({ mode: "subscription", customer, line_items: [{ price_data, quantity: 1 }], metadata: { app: APP_TAG, userId: user.id, country: country ?? "unknown", managedPayments: String(managed) }, ...(managed ? { managed_payments: { enabled: true } } : { tax_id_collection: { enabled: true }, customer_update: { name: "auto", address: "auto" }, custom_text: { submit: { message: `Nakodo contacts up to ${contactAllowance(limits)} for you. Cancel any time in Settings.` } }, }), success_url, cancel_url, }); 

custom_text is in there because Stripe rejects it alongside the others, which its docs do not currently mention. That cost a confusing afternoon in test mode. The non-managed branch keeps tax_id_collection, so business customers outside the EU can still put a tax number on their invoices.

The managedPayments flag goes into the session metadata as a string, so when a sale is being investigated months later there is a record of which branch it took rather than a re-derivation from an IP lookup nobody kept.

A rejected field must not become a wall

Managed Payments has to be switched on in the Stripe Dashboard, with its terms accepted, separately in test and live mode. Until that is done, Stripe rejects any session that asks for it. So the request that asks for it is allowed to fail:

const managed = shouldEnableManagedPayments(country); let session: Stripe.Checkout.Session; try { session = await stripe().checkout.sessions.create(params(managed)); } catch (e) { if (!managed || !isManagedPaymentsRejection(e)) throw e; console.error("Stripe rejected Managed Payments; opening Checkout without it", country, e); session = await stripe().checkout.sessions.create(params(false)); } 

Without that catch, one account-level setting being off takes down checkout for 27 countries at once, and the error the customer sees is a generic one. With it, the sale completes, the misroute check flags it in the webhook, and I find out from the flag rather than from nobody ever subscribing.

The predicate is narrow on purpose, because retrying is only safe for this one class of error:

export function isManagedPaymentsRejection(error: unknown): boolean { if (!error || typeof error !== "object") return false; const e = error as { type?: unknown; param?: unknown; message?: unknown }; if (e.type !== "StripeInvalidRequestError") return false; if (typeof e.param === "string" && e.param.startsWith("managed_payments")) return true; return typeof e.message === "string" && /managed[_\s]?payments/i.test(e.message); } 

Only StripeInvalidRequestError, and only when the offending param or the message names the field. A declined card, a rate limit or a dropped connection all return false, because retrying those without Managed Payments would either charge somebody twice or quietly sell them a sale the wrong legal entity owns.

The message regex is a fallback I am not proud of, kept because param is not always populated for a rejection at this level, and the alternative is a customer-facing failure.

What it adds up to

One boolean, one Set of 27 codes, two constants, a narrow error predicate and a webhook flag. Nothing in the product's own domain logic knows any of it exists. The plan cards read the prices, the subscription row records what Stripe says, and the question of who legally sold the thing is answered in one 96-line file whose comments are mostly about tax thresholds.

That ratio feels right for compliance code. It should be small, it should be loudly commented, and it should never be able to take the checkout page down.