Open our pricing page from an IP in the eurozone and the two plans read €36.99 and €121.99 a month. From a UK address they read £30 and £100. Press the button on either card and Stripe Checkout charges exactly the figure that was on the card, in that currency.
There is nothing in our Stripe Dashboard that makes that happen. No Products, no Prices, no price ids in an env var. The entire price list is this, in lib/pricing/plans.ts:
/** The free trial. Zero, and it must stay exactly zero in every currency. */
export const TRIAL_PRICE_PENCE = 0
/** Pro: one host account, £30/month. */
export const PRO_PRICE_PENCE = 3000
/** Ultimate: up to five host accounts, £100/month. */
export const ULTIMATE_PRICE_PENCE = 10000
Two integers in GBP pence. The file is 73 lines and most of them are comments. It imports nothing that touches the network, which is the other reason it exists: a marketing page has to be able to render a price without dragging the Stripe SDK into the render path.
The line item is built at request time
The checkout route does not reference a price id. It builds the whole thing:
line_items: [{ price_data: planPriceData(planType, currency, mp.productData), quantity: 1 }],
and planPriceData is twelve lines:
export function planPriceData(
planType: PaidPlanType,
currency: DisplayCurrency,
productData: { tax_code?: string } = {}
): Stripe.Checkout.SessionCreateParams.LineItem.PriceData {
return {
currency,
unit_amount: stripeUnitAmount(PLAN_PRICES_PENCE[planType], currency),
recurring: { interval: BILLING_INTERVAL },
product_data: { name: PLAN_PRODUCT_NAMES[planType], ...productData },
tax_behavior: MANAGED_PAYMENTS_TAX_BEHAVIOR,
}
}
stripeUnitAmount is the same function the pricing card renders through. One pound price, times a fixed rate, plus a 4% margin, rounded up to a retail figure, then converted to whatever minor unit Stripe counts that currency in. 24 currencies, mapped from 58 country codes, with yen in the set that Stripe counts in whole units.
So the number on the page and the number on the invoice are not two numbers that someone has to keep equal. They are one expression evaluated twice.
If we had used Prices instead, 24 currencies times 2 plans is 48 Price objects to create by hand, each of which has to be kept in step with a figure that lives in the repo anyway, and none of which can be reviewed in a pull request.
Stripe will re-price your session unless you tell it not to
The feature that would have broken this quietly is Adaptive Pricing. Left on, Stripe converts the session into the currency of the card at its own rate, so a customer who read €36.99 arrives at a Checkout page showing something else. Every session we create without Managed Payments switches it off explicitly:
function withoutAdaptivePricing(
params: Stripe.Checkout.SessionCreateParams
): Stripe.Checkout.SessionCreateParams {
return { ...params, adaptive_pricing: { enabled: false } }
}
The awkward part is that Managed Payments, the merchant-of-record mode we route EU customers through, always turns Adaptive Pricing on and rejects the adaptive_pricing field outright if you try to set it. So those sessions get the field deleted instead:
function withAdaptivePricingUnset(
params: Stripe.Checkout.SessionCreateParams
): Stripe.Checkout.SessionCreateParams {
const { adaptive_pricing: _adaptivePricing, ...rest } = params
return rest
}
That is only safe because of a property of our own pricing table rather than anything Stripe does: all 27 EU countries are already priced in euros, so Adaptive Pricing has nothing left to convert. A test holds them there. If somebody ever priced one EU country in something else, Stripe would start converting it and the only visible symptom would be a Checkout page that disagrees with the pricing page.
The retry that turns an outage into a flagged sale
Managed Payments has to be activated on the account, with terms accepted and an eligible tax code, before Stripe will take a session that sets it. If any of that is not in place, Stripe rejects every session carrying the field, which means every EU checkout, which means a tax optimisation has become a total loss of sales for half the addressable market.
So the helper that creates the session retries once, and only for that one class of rejection:
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
}
Stripping is deterministic because we know exactly which keys merchant-of-record mode added: the top level managed_payments object, and a tax_code inside each line item's product_data. tax_behavior stays, because that belongs to the price and keeps it tax inclusive.
The outcome of the retry is an EU sale with no VAT collected on it, which is a problem, but it is a problem on one row in a table we can go and fix. A failed checkout is a customer who went and did something else on a Tuesday evening and is not coming back.
What travels on the subscription instead of the price
If the plan is not a Stripe Product, nothing in Stripe knows which plan a subscription is for. So the plan rides on metadata:
const metadata = {
plan_type: planType,
list_price_gbp_pence: String(PLAN_PRICES_PENCE[planType]),
}
The webhook reads plan_type to decide what the venue gets. Reading it from metadata rather than from the price is what makes 24 currencies free: a subscription in forint and a subscription in pounds both say pro, and the grant logic never looks at money at all.
list_price_gbp_pence is there so that anything reading a sale later can account for it in the base currency without reverse engineering our rate table.
The one thing this design cannot do is change a live customer's currency, because Stripe holds a customer to one currency while a subscription of theirs is alive. The route detects that case and answers with a 409 and an email address rather than a broken Checkout page.
The stale comment is the real lesson
Here is what I found while writing this. Our .env.example still carries this, above the Managed Payments switch:
# Because our line items use a pre-created Price id, the accompanying tax
# settings live on the Stripe objects and must also be set there: tax_code
# txcd_10103000 on each Product, tax_behavior "exclusive" on each Price.
Every clause of that is now false. There are no Products to set a tax code on, no Prices to set a tax behaviour on, and both values ride on the price_data the route builds. The sentence was true for about three weeks.
Nothing caught it, and nothing could have. Code that describes a setup you have stops compiling when the setup changes. A comment that describes a setup in somebody else's dashboard just sits there, and the next person to read it goes and looks for objects that do not exist.
The plans, the limits each one buys and the two prices are all visible on the pricing page, what the subscription terms say about currency and tax is in section 4 of the terms, and the short answers about billing, trials and cancellation are at the bottom of the FAQ.
Top comments (0)