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

@ -19,7 +19,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
from typing import Any, Literal from typing import Any, Literal, Optional
import stripe import stripe
from fastapi import APIRouter, Body, Depends, HTTPException, Request from fastapi import APIRouter, Body, Depends, HTTPException, Request
@ -69,6 +69,53 @@ def _price_for(cadence: str) -> str:
raise HTTPException(status_code=400, detail="cadence must be 'monthly' or 'annual'") raise HTTPException(status_code=400, detail="cadence must be 'monthly' or 'annual'")
# Rough country → currency mapping. Covers the markets we have a stated
# rate for; everything else falls back to GBP (the home currency) and
# Stripe handles the FX at checkout. Configure the per-currency
# unit_amount on each Price's `currency_options` in the Stripe Dashboard
# — we just signal which option to use here.
_COUNTRY_CURRENCY: dict[str, str] = {
"US": "usd", "CA": "usd",
"GB": "gbp", "IM": "gbp", "JE": "gbp", "GG": "gbp",
**dict.fromkeys((
"DE", "FR", "IT", "ES", "PT", "NL", "BE", "IE", "AT", "FI",
"GR", "LU", "MT", "CY", "EE", "LV", "LT", "SI", "SK", "HR",
), "eur"),
}
# Accept-Language locale → currency, used when CF-IPCountry is absent.
# Ambiguous locales (e.g. plain "fr" without region) get EUR because
# that's the majority outcome.
_LOCALE_CURRENCY: dict[str, str] = {
"en-gb": "gbp", "en": "gbp",
"en-us": "usd", "en-ca": "usd",
"fr": "eur", "de": "eur", "it": "eur", "es": "eur",
"pt": "eur", "nl": "eur",
}
def _sniff_currency(request: Request) -> str:
"""Best-effort currency detection for new-customer checkouts.
Order: explicit Cloudflare country header, then Accept-Language
(exact match then language-only). GBP as the final fallback. Only
consulted when the user has no Stripe customer record yet Stripe
locks currency at customer creation, so an existing customer's
currency wins regardless of the request locale.
"""
cc = (request.headers.get("cf-ipcountry") or "").upper()
if cc in _COUNTRY_CURRENCY:
return _COUNTRY_CURRENCY[cc]
al = (request.headers.get("accept-language") or "").lower()
first = al.split(",", 1)[0].split(";", 1)[0].strip()
if first in _LOCALE_CURRENCY:
return _LOCALE_CURRENCY[first]
short = first.split("-", 1)[0]
if short in _LOCALE_CURRENCY:
return _LOCALE_CURRENCY[short]
return "gbp"
def _stripe_client() -> stripe.StripeClient: def _stripe_client() -> stripe.StripeClient:
"""Per-call client so we read the secret at request time (lets us """Per-call client so we read the secret at request time (lets us
rotate the key by editing .env + reloading without rebuilding any rotate the key by editing .env + reloading without rebuilding any
@ -83,6 +130,10 @@ def _stripe_client() -> stripe.StripeClient:
class CheckoutRequest(BaseModel): class CheckoutRequest(BaseModel):
cadence: Literal["monthly", "annual"] cadence: Literal["monthly", "annual"]
# Optional override; when omitted we sniff from request headers.
# Honoured only for first-time checkouts (Stripe locks currency
# to the customer at creation).
currency: Optional[Literal["gbp", "usd", "eur"]] = None
class CheckoutResponse(BaseModel): class CheckoutResponse(BaseModel):
@ -92,6 +143,7 @@ class CheckoutResponse(BaseModel):
@router.post("/api/stripe/checkout", response_model=CheckoutResponse) @router.post("/api/stripe/checkout", response_model=CheckoutResponse)
async def create_checkout( async def create_checkout(
body: CheckoutRequest, body: CheckoutRequest,
request: Request,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
cu: CurrentUser = Depends(require_auth), cu: CurrentUser = Depends(require_auth),
) -> CheckoutResponse: ) -> CheckoutResponse:
@ -120,6 +172,13 @@ async def create_checkout(
# referral redemption flow ships. # referral redemption flow ships.
"allow_promotion_codes": True, "allow_promotion_codes": True,
} }
# Multi-currency: for first-time buyers (no stripe_customer_id yet)
# we pass the detected/requested currency. Stripe picks the matching
# `currency_options` rate configured on the Price in the Dashboard,
# then locks that currency to the new customer record. Existing
# customers keep their original currency regardless.
if not user.stripe_customer_id:
create_kwargs["currency"] = body.currency or _sniff_currency(request)
# Per-cadence cooling-off treatment: # Per-cadence cooling-off treatment:
# #
# - Annual gets a 14-day free trial. No money moves during the # - Annual gets a 14-day free trial. No money moves during the

View file

@ -463,3 +463,97 @@ def test_checkout_endpoint_requires_login(tmp_path):
r = client.post("/api/stripe/checkout", json={"cadence": "monthly"}) r = client.post("/api/stripe/checkout", json={"cadence": "monthly"})
# No session cookie → require_auth bounces with 401. # No session cookie → require_auth bounces with 401.
assert r.status_code == 401, r.text 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"