Summary
The card-country regional-price enforcement (added in feat/regional-pricing, backend) runs only on the trial / SetupIntent checkout path. The non-trial default_incomplete path creates the subscription before a card is attached, so it can't read the card country and is not enforced. This leaves a narrow arbitrage gap for returning subscribers.
Background: the two checkout paths
StripeSubscribeMutation.mutate (backend/apps/account_payment/graphql.py) branches on trial eligibility:
- Trial-eligible (new to the product) → a Stripe SetupIntent collects the card; the
setup_intent.succeeded webhook (webhooks.py) then creates the subscription. The card is attached at that point, so _regional_price_id() reads the card country and swaps to the region-correct price before customer.subscribe(...). Enforced.
- Not trial-eligible (already used the trial for this product) → the mutation calls
customer.subscribe(price=price_id, payment_behavior="default_incomplete", ...) immediately, before a card exists. There is no card country to read, so no enforcement runs. Not enforced.
Impact
- Affects only returning subscribers (those who already consumed the trial for that product and are re-subscribing).
- Requires deliberately switching domains — the default experience already shows and charges the region-correct currency by domain (frontend id-based selection + trial-path enforcement).
- Exploit example: a US customer who already used the BD Pro trial, re-subscribing on basedosdados.org (pt) instead of data-basis.org, is shown R$47 and charged R$47 rather than US$19.
Low severity (narrow population, deliberate action), but worth closing for completeness and consistency with the trial path.
Implementation options
A. Post-payment correction (webhook). After the default_incomplete subscription's first payment resolves (e.g. payment_intent.succeeded / invoice.paid, where the card is now known), read the card country, and if it doesn't match the price's region, correct the subscription. Downsides: correcting an already-created subscription means a price swap with proration (or cancel-and-recreate), which is fiddly and risky, and the customer may already have been charged the wrong amount once.
B. Route everyone through the SetupIntent path (preferred long-term). Drop the default_incomplete branch and always collect the card via SetupIntent first, so there is a single enforced flow and the card country is always known before customer.subscribe(...). This is a checkout-flow change (mutation + frontend confirmation handling), but it removes the second path entirely and makes enforcement unconditional. Requires care around returning-subscriber UX (no trial, immediate charge).
Reference points
backend/apps/account_payment/webhooks.py — setup_intent_succeeded, _regional_price_id, _card_country (the trial-path enforcement).
backend/apps/account_payment/graphql.py — StripeSubscribeMutation.mutate (the branch that selects trial vs default_incomplete).
backend/apps/account_payment/regional_pricing.py — country_to_region, resolve_regional_price_id (pure, reusable by whichever path).
Acceptance
- A returning (non-trial) subscriber checking out with a card whose country maps to a different region than the selected price is charged the region-correct price.
- No regression on the trial path.
- Tests covering the non-trial path enforcement (mirroring the existing mock-based tests in
test_regional_pricing.py).
Summary
The card-country regional-price enforcement (added in
feat/regional-pricing, backend) runs only on the trial / SetupIntent checkout path. The non-trialdefault_incompletepath creates the subscription before a card is attached, so it can't read the card country and is not enforced. This leaves a narrow arbitrage gap for returning subscribers.Background: the two checkout paths
StripeSubscribeMutation.mutate(backend/apps/account_payment/graphql.py) branches on trial eligibility:setup_intent.succeededwebhook (webhooks.py) then creates the subscription. The card is attached at that point, so_regional_price_id()reads the card country and swaps to the region-correct price beforecustomer.subscribe(...). Enforced.customer.subscribe(price=price_id, payment_behavior="default_incomplete", ...)immediately, before a card exists. There is no card country to read, so no enforcement runs. Not enforced.Impact
Low severity (narrow population, deliberate action), but worth closing for completeness and consistency with the trial path.
Implementation options
A. Post-payment correction (webhook). After the
default_incompletesubscription's first payment resolves (e.g.payment_intent.succeeded/invoice.paid, where the card is now known), read the card country, and if it doesn't match the price's region, correct the subscription. Downsides: correcting an already-created subscription means a price swap with proration (or cancel-and-recreate), which is fiddly and risky, and the customer may already have been charged the wrong amount once.B. Route everyone through the SetupIntent path (preferred long-term). Drop the
default_incompletebranch and always collect the card via SetupIntent first, so there is a single enforced flow and the card country is always known beforecustomer.subscribe(...). This is a checkout-flow change (mutation + frontend confirmation handling), but it removes the second path entirely and makes enforcement unconditional. Requires care around returning-subscriber UX (no trial, immediate charge).Reference points
backend/apps/account_payment/webhooks.py—setup_intent_succeeded,_regional_price_id,_card_country(the trial-path enforcement).backend/apps/account_payment/graphql.py—StripeSubscribeMutation.mutate(the branch that selects trial vsdefault_incomplete).backend/apps/account_payment/regional_pricing.py—country_to_region,resolve_regional_price_id(pure, reusable by whichever path).Acceptance
test_regional_pricing.py).