stripe: always bill in GBP, never geo-select a currency

/pricing renders "£7" and "£70" as static copy, but checkout sniffed
CF-IPCountry / Accept-Language and passed a matching currency, so Stripe
picked a currency_options rate. A US visitor was shown £7 and charged
$9.99; a German one was charged €7. The page's "Prices in GBP" line was
therefore untrue, and "two months free" only held in GBP and USD (the
EUR annual is €80 against €84, a 4.8% saving).

Displaying one price and billing another is what the UK CPRs and the EU
price-indication rules prohibit, so drop the currency selection rather
than patch the disclosure. Removes _sniff_currency and its country and
locale tables, the currency field on CheckoutRequest, and the now-unused
request parameter.

The currency_options configured on the live Prices are left in place but
unused. Reinstating geo-pricing requires making /pricing currency-aware
first — copy, buttons and the currency-specific saving claim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-07-28 18:59:48 +02:00
parent dd95353289
commit 4169a6767b
2 changed files with 73 additions and 112 deletions

View file

@ -19,7 +19,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
from typing import Any, Literal, Optional from typing import Any, Literal
import stripe import stripe
from fastapi import APIRouter, Body, Depends, HTTPException, Request from fastapi import APIRouter, Body, Depends, HTTPException, Request
@ -75,51 +75,21 @@ 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 # NOTE: we deliberately never pass `currency` to Stripe, so every
# rate for; everything else falls back to GBP (the home currency) and # checkout bills the Price's base currency — GBP. An earlier version
# Stripe handles the FX at checkout. Configure the per-currency # sniffed CF-IPCountry / Accept-Language and selected a matching
# unit_amount on each Price's `currency_options` in the Stripe Dashboard # `currency_options` entry, but /pricing renders £7 and £70 as static
# — we just signal which option to use here. # copy: a US visitor was shown £7 and charged $9.99. Showing one price
_COUNTRY_CURRENCY: dict[str, str] = { # and billing another is exactly what the UK CPRs and the EU
"US": "usd", "CA": "usd", # price-indication rules prohibit, so the sniffing was removed rather
"GB": "gbp", "IM": "gbp", "JE": "gbp", "GG": "gbp", # than the disclosure patched. The `currency_options` still configured
**dict.fromkeys(( # on the Prices in the Dashboard are simply unused.
"DE", "FR", "IT", "ES", "PT", "NL", "BE", "IE", "AT", "FI", #
"GR", "LU", "MT", "CY", "EE", "LV", "LT", "SI", "SK", "HR", # To reinstate geo-pricing, /pricing must render the matching currency
), "eur"), # in its copy, its buttons AND its annual-saving claim first (the claim
} # is currency-specific: "two months free" is true at £70/£84 and
# $94.99/$119.88, but not at €80/€84). See git history for the removed
# Accept-Language locale → currency, used when CF-IPCountry is absent. # _sniff_currency helper and its country/locale tables.
# 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:
@ -136,10 +106,6 @@ 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):
@ -149,7 +115,6 @@ 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:
@ -178,13 +143,10 @@ 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) # No `currency` kwarg — every checkout bills the Price's base
# we pass the detected/requested currency. Stripe picks the matching # currency (GBP), matching the static £7 / £70 copy on /pricing.
# `currency_options` rate configured on the Price in the Dashboard, # See the note above _stripe_client() before reintroducing one.
# 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

@ -465,50 +465,69 @@ def test_checkout_endpoint_requires_login(tmp_path):
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): def test_checkout_never_passes_currency(tmp_path):
"""First-time buyer (no stripe_customer_id yet) gets the currency """Every checkout bills the Price's base currency (GBP), whatever the
sniffed from the request. CF-IPCountry=US 'usd', and Stripe will visitor's geo headers say.
look up the USD currency_option on the Price."""
/pricing renders "£7" and "£70" as static copy, so selecting a
`currency_options` rate would show one price and charge another
a US visitor saw £7 and was billed $9.99. Regression guard: if
geo-pricing is ever reinstated, the pricing page must become
currency-aware in the same change.
"""
client, _, session_cookie = _build_app(tmp_path) client, _, session_cookie = _build_app(tmp_path)
def asserter(params): seen = []
assert params["currency"] == "usd"
def asserter(params):
seen.append(params)
assert "currency" not in params, (
"no currency may be sent — /pricing advertises GBP only"
)
for headers in (
{"cf-ipcountry": "US"},
{"cf-ipcountry": "DE"},
{"accept-language": "en-US,en;q=0.5"},
{},
):
with patch("app.routers.stripe_billing._stripe_client", with patch("app.routers.stripe_billing._stripe_client",
return_value=_fake_checkout_client(asserter)): return_value=_fake_checkout_client(asserter)):
r = client.post( r = client.post(
"/api/stripe/checkout", "/api/stripe/checkout",
json={"cadence": "monthly"}, json={"cadence": "monthly"},
cookies={"cassandra_session": session_cookie}, cookies={"cassandra_session": session_cookie},
headers={"cf-ipcountry": "US"}, headers=headers,
) )
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
assert len(seen) == 4
def test_checkout_body_currency_overrides_sniff(tmp_path):
"""Explicit `currency` in the request body beats header sniffing — def test_checkout_rejects_currency_in_body(tmp_path):
lets a UK-based buyer choose EUR if they want to.""" """The `currency` field is gone from CheckoutRequest. A client that
still sends one must not silently get GBP under a USD label the
extra key is simply ignored by pydantic, so assert the call is still
currency-free rather than trusting the caller."""
client, _, session_cookie = _build_app(tmp_path) client, _, session_cookie = _build_app(tmp_path)
def asserter(params): def asserter(params):
assert params["currency"] == "eur" assert "currency" not in params
with patch("app.routers.stripe_billing._stripe_client", with patch("app.routers.stripe_billing._stripe_client",
return_value=_fake_checkout_client(asserter)): return_value=_fake_checkout_client(asserter)):
r = client.post( r = client.post(
"/api/stripe/checkout", "/api/stripe/checkout",
json={"cadence": "monthly", "currency": "eur"}, json={"cadence": "monthly", "currency": "usd"},
cookies={"cassandra_session": session_cookie}, cookies={"cassandra_session": session_cookie},
headers={"cf-ipcountry": "GB"},
) )
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
def test_checkout_omits_currency_for_existing_customer(tmp_path): def test_checkout_uses_existing_customer_ref(tmp_path):
"""Existing customer: Stripe locked their currency at first """Existing customer: use the stored `customer` ref rather than
checkout, so passing `currency` again would error. Verify we omit `customer_email`, so repeat checkouts don't mint duplicate Stripe
it (and also use the existing `customer` ref instead of customers."""
customer_email)."""
import asyncio import asyncio
from app.models import User from app.models import User
@ -524,36 +543,16 @@ def test_checkout_omits_currency_for_existing_customer(tmp_path):
asyncio.run(_link()) asyncio.run(_link())
def asserter(params): def asserter(params):
assert "currency" not in 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" assert params["customer"] == "cus_existing_xxxxxxxxxxxxxx"
assert "customer_email" not in params
with patch("app.routers.stripe_billing._stripe_client", with patch("app.routers.stripe_billing._stripe_client",
return_value=_fake_checkout_client(asserter)): return_value=_fake_checkout_client(asserter)):
r = client.post( r = client.post(
"/api/stripe/checkout", "/api/stripe/checkout",
json={"cadence": "monthly", "currency": "usd"}, json={"cadence": "monthly"},
cookies={"cassandra_session": session_cookie}, cookies={"cassandra_session": session_cookie},
headers={"cf-ipcountry": "US"}, headers={"cf-ipcountry": "US"},
) )
assert r.status_code == 200, r.text 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"