stripe: detect buyer currency at checkout (GBP/USD/EUR)

Pass `currency` to Stripe checkout for first-time buyers so Stripe
picks the matching `currency_options` rate configured on the Price
in the Dashboard (multi-currency Prices: one Price, per-currency
unit_amount). Operator configures the rates on existing Prices
prod_UaZ0xCpCboUGCN/price_*; this commit is the application-side
signal.

Currency precedence: explicit request body > Cloudflare cf-ipcountry
header > Accept-Language locale > GBP fallback. Only honoured when
the user has no stripe_customer_id yet — Stripe locks currency to
the customer record at first checkout, so existing customers keep
their original currency (they can switch via the portal).

Adds 4 tests: sniffed currency on new customer, body override beats
sniff, currency omitted for existing customer, and unit-tests for
the sniffing fallback chain.
This commit is contained in:
Giorgio Gilestro 2026-05-28 12:42:40 +02:00
parent c5fb4525f3
commit 83995e96c8
2 changed files with 154 additions and 1 deletions

View file

@ -463,3 +463,97 @@ def test_checkout_endpoint_requires_login(tmp_path):
r = client.post("/api/stripe/checkout", json={"cadence": "monthly"})
# No session cookie → require_auth bounces with 401.
assert r.status_code == 401, r.text
def test_checkout_passes_sniffed_currency_for_new_customer(tmp_path):
"""First-time buyer (no stripe_customer_id yet) gets the currency
sniffed from the request. CF-IPCountry=US 'usd', and Stripe will
look up the USD currency_option on the Price."""
client, _, session_cookie = _build_app(tmp_path)
def asserter(params):
assert params["currency"] == "usd"
with patch("app.routers.stripe_billing._stripe_client",
return_value=_fake_checkout_client(asserter)):
r = client.post(
"/api/stripe/checkout",
json={"cadence": "monthly"},
cookies={"cassandra_session": session_cookie},
headers={"cf-ipcountry": "US"},
)
assert r.status_code == 200, r.text
def test_checkout_body_currency_overrides_sniff(tmp_path):
"""Explicit `currency` in the request body beats header sniffing —
lets a UK-based buyer choose EUR if they want to."""
client, _, session_cookie = _build_app(tmp_path)
def asserter(params):
assert params["currency"] == "eur"
with patch("app.routers.stripe_billing._stripe_client",
return_value=_fake_checkout_client(asserter)):
r = client.post(
"/api/stripe/checkout",
json={"cadence": "monthly", "currency": "eur"},
cookies={"cassandra_session": session_cookie},
headers={"cf-ipcountry": "GB"},
)
assert r.status_code == 200, r.text
def test_checkout_omits_currency_for_existing_customer(tmp_path):
"""Existing customer: Stripe locked their currency at first
checkout, so passing `currency` again would error. Verify we omit
it (and also use the existing `customer` ref instead of
customer_email)."""
import asyncio
from app.models import User
client, factory, session_cookie = _build_app(tmp_path)
async def _link():
async with factory() as s:
u = await s.get(User, 1)
u.stripe_customer_id = "cus_existing_xxxxxxxxxxxxxx"
await s.commit()
asyncio.run(_link())
def asserter(params):
assert "currency" not in params, (
"currency must not be passed once a customer exists — "
"Stripe rejects mismatches against the locked customer currency"
)
assert params["customer"] == "cus_existing_xxxxxxxxxxxxxx"
with patch("app.routers.stripe_billing._stripe_client",
return_value=_fake_checkout_client(asserter)):
r = client.post(
"/api/stripe/checkout",
json={"cadence": "monthly", "currency": "usd"},
cookies={"cassandra_session": session_cookie},
headers={"cf-ipcountry": "US"},
)
assert r.status_code == 200, r.text
def test_sniff_currency_fallback_chain():
"""Unit-test the header-sniffing helper: CF country wins, then
Accept-Language exact, then language-only, then GBP default."""
from types import SimpleNamespace
from app.routers.stripe_billing import _sniff_currency
def _req(headers):
return SimpleNamespace(headers=headers)
assert _sniff_currency(_req({"cf-ipcountry": "DE"})) == "eur"
assert _sniff_currency(_req({"cf-ipcountry": "us"})) == "usd" # case-insensitive
assert _sniff_currency(_req({"accept-language": "fr-FR,fr;q=0.9"})) == "eur"
assert _sniff_currency(_req({"accept-language": "en-US,en;q=0.5"})) == "usd"
assert _sniff_currency(_req({"accept-language": "ja,ja-JP;q=0.5"})) == "gbp"
assert _sniff_currency(_req({})) == "gbp"