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
|
|
@ -465,50 +465,69 @@ def test_checkout_endpoint_requires_login(tmp_path):
|
|||
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."""
|
||||
def test_checkout_never_passes_currency(tmp_path):
|
||||
"""Every checkout bills the Price's base currency (GBP), whatever the
|
||||
visitor's geo headers say.
|
||||
|
||||
/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)
|
||||
|
||||
seen = []
|
||||
|
||||
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",
|
||||
return_value=_fake_checkout_client(asserter)):
|
||||
r = client.post(
|
||||
"/api/stripe/checkout",
|
||||
json={"cadence": "monthly"},
|
||||
cookies={"cassandra_session": session_cookie},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
assert len(seen) == 4
|
||||
|
||||
|
||||
def test_checkout_rejects_currency_in_body(tmp_path):
|
||||
"""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)
|
||||
|
||||
def asserter(params):
|
||||
assert params["currency"] == "usd"
|
||||
assert "currency" not in params
|
||||
|
||||
with patch("app.routers.stripe_billing._stripe_client",
|
||||
return_value=_fake_checkout_client(asserter)):
|
||||
r = client.post(
|
||||
"/api/stripe/checkout",
|
||||
json={"cadence": "monthly"},
|
||||
json={"cadence": "monthly", "currency": "usd"},
|
||||
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)."""
|
||||
def test_checkout_uses_existing_customer_ref(tmp_path):
|
||||
"""Existing customer: use the stored `customer` ref rather than
|
||||
`customer_email`, so repeat checkouts don't mint duplicate Stripe
|
||||
customers."""
|
||||
import asyncio
|
||||
|
||||
from app.models import User
|
||||
|
|
@ -524,36 +543,16 @@ def test_checkout_omits_currency_for_existing_customer(tmp_path):
|
|||
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 "currency" not in params
|
||||
assert params["customer"] == "cus_existing_xxxxxxxxxxxxxx"
|
||||
assert "customer_email" not in params
|
||||
|
||||
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"},
|
||||
json={"cadence": "monthly"},
|
||||
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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue