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 json
from typing import Any, Literal, Optional
from typing import Any, Literal
import stripe
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'")
# 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"
# NOTE: we deliberately never pass `currency` to Stripe, so every
# checkout bills the Price's base currency — GBP. An earlier version
# sniffed CF-IPCountry / Accept-Language and selected a matching
# `currency_options` entry, but /pricing renders £7 and £70 as static
# copy: a US visitor was shown £7 and charged $9.99. Showing one price
# and billing another is exactly what the UK CPRs and the EU
# price-indication rules prohibit, so the sniffing was removed rather
# than the disclosure patched. The `currency_options` still configured
# on the Prices in the Dashboard are simply unused.
#
# To reinstate geo-pricing, /pricing must render the matching currency
# 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
# _sniff_currency helper and its country/locale tables.
def _stripe_client() -> stripe.StripeClient:
@ -136,10 +106,6 @@ def _stripe_client() -> stripe.StripeClient:
class CheckoutRequest(BaseModel):
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):
@ -149,7 +115,6 @@ class CheckoutResponse(BaseModel):
@router.post("/api/stripe/checkout", response_model=CheckoutResponse)
async def create_checkout(
body: CheckoutRequest,
request: Request,
session: AsyncSession = Depends(get_session),
cu: CurrentUser = Depends(require_auth),
) -> CheckoutResponse:
@ -178,13 +143,10 @@ async def create_checkout(
# referral redemption flow ships.
"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)
# No `currency` kwarg — every checkout bills the Price's base
# currency (GBP), matching the static £7 / £70 copy on /pricing.
# See the note above _stripe_client() before reintroducing one.
#
# Per-cadence cooling-off treatment:
#
# - Annual gets a 14-day free trial. No money moves during the