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:
parent
dd95353289
commit
4169a6767b
2 changed files with 73 additions and 112 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue