read.markets/tests/test_stripe_billing.py
Giorgio Gilestro 78b2c2be28 stripe: collect billing address, handle paused subscriptions
Two pre-launch gaps.

1. Checkout collected no address, so Stripe Customers carried no
   country. EU B2C digital-services VAT is due at the consumer's place
   of supply and we can't even scope that question without knowing
   where buyers are; the card's billing country is the evidence a tax
   authority accepts, an IP guess is not. It also gives AVS data to the
   fraud checks. Passing `customer` suppresses the write-back to the
   Customer record, so existing customers also need
   customer_update.address=auto — without it the country lands on the
   PaymentIntent and nowhere durable.

2. A paused subscription kept paid features while Stripe billed
   nothing. Both mechanisms were unhandled: status="paused" (trial
   ended with no usable card) had no entry in _HANDLERS at all, and
   `pause_collection` — what the customer portal's pause button uses —
   leaves status as "active", so the status check waved it through.
   Revoke on both, keeping stripe_subscription_id since the
   subscription still exists at Stripe and resumes under the same id.

Pause is disabled in our live portal configuration, so (2) is latent
rather than live — but it's a one-toggle mistake away from being real.

Verified against live Stripe: a Checkout Session with the new params is
accepted (gbp 700, billing_address_collection=required, livemode), and
the live webhook endpoint already subscribes both paused and resumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 12:13:47 +02:00

711 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Stripe billing endpoints: signature verification, idempotency, tier
flips, and checkout creation.
Same integration-style scaffold as test_polar_webhook.py — real router
over in-memory aiosqlite. Stripe SDK calls (sessions.create, portal
sessions.create) are mocked so the suite never makes a real HTTP call.
"""
from __future__ import annotations
import asyncio
import hashlib
import hmac
import json
import time
from types import SimpleNamespace
from unittest.mock import patch
import pytest
_API_KEY = "sk_test_dummy_for_unit_tests"
_WEBHOOK_SECRET = "whsec_dummy_test_secret_for_unit_tests"
_PRICE_MONTHLY = "price_test_monthly_xxxxxxxxxxxxxxxxxxxx"
_PRICE_ANNUAL = "price_test_annual_xxxxxxxxxxxxxxxxxxxxx"
def _stripe_sig(body: bytes, secret: str, ts: int | None = None) -> str:
"""Produce a Stripe-Signature header matching the bytes signed.
Format: `t=<ts>,v1=<hex hmac sha256>` over `<ts>.<body>`."""
ts = ts if ts is not None else int(time.time())
signed = f"{ts}.{body.decode('utf-8')}"
mac = hmac.new(secret.encode("utf-8"), signed.encode("utf-8"),
hashlib.sha256).hexdigest()
return f"t={ts},v1={mac}"
def _build_app(tmp_path):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app import db as db_mod
from app.auth import sign_session
from app.config import get_settings
from app.db import Base
from app.models import User
from app.routers import stripe_billing as stripe_router
s = get_settings()
s.STRIPE_API_KEY = _API_KEY # type: ignore[misc]
s.STRIPE_WEBHOOK_SECRET = _WEBHOOK_SECRET # type: ignore[misc]
s.STRIPE_PRICE_MONTHLY = _PRICE_MONTHLY # type: ignore[misc]
s.STRIPE_PRICE_ANNUAL = _PRICE_ANNUAL # type: ignore[misc]
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/stripe.db")
factory = async_sessionmaker(engine, expire_on_commit=False)
db_mod._engine = engine
db_mod._session_factory = factory
async def _seed():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with factory() as session:
session.add(User(id=1, email="buyer@x", tier="free"))
await session.commit()
asyncio.run(_seed())
app = FastAPI()
app.include_router(stripe_router.router)
return TestClient(app), factory, sign_session(1)
def _post_webhook(client, *, body: dict, secret: str = _WEBHOOK_SECRET,
sig: str | None = None):
# Stripe's SDK requires a top-level `object: "event"` field to know
# this is a v1 webhook envelope — tests that omit it fail in
# construct_event before the signature check matters. We inject the
# default here so individual tests can stay terse.
body.setdefault("object", "event")
raw = json.dumps(body).encode("utf-8")
sig = sig if sig is not None else _stripe_sig(raw, secret)
return client.post(
"/api/stripe/webhook",
content=raw,
headers={"stripe-signature": sig, "content-type": "application/json"},
)
# --- signature gate --------------------------------------------------------
def test_webhook_rejects_bad_signature(tmp_path):
client, _, _ = _build_app(tmp_path)
raw = json.dumps({"id": "evt_x", "type": "invoice.paid",
"data": {"object": {}}}).encode("utf-8")
r = client.post(
"/api/stripe/webhook",
content=raw,
headers={
"stripe-signature": "t=0,v1=deadbeef",
"content-type": "application/json",
},
)
assert r.status_code == 401, r.text
def test_webhook_rejects_missing_signature(tmp_path):
client, _, _ = _build_app(tmp_path)
r = client.post(
"/api/stripe/webhook",
content=b"{}",
headers={"content-type": "application/json"},
)
assert r.status_code == 400, r.text
# --- happy paths -----------------------------------------------------------
def test_checkout_session_completed_flips_tier_to_paid(tmp_path):
client, factory, _ = _build_app(tmp_path)
body = {
"id": "evt_checkout_1",
"type": "checkout.session.completed",
"data": {
"object": {
"client_reference_id": "1",
"customer": "cus_abc",
"subscription": "sub_xyz",
}
},
}
r = _post_webhook(client, body=body)
assert r.status_code == 200, r.text
assert r.json()["status"] == "ok"
async def _check():
from sqlalchemy import select
from app.models import User
async with factory() as session:
u = (await session.execute(
select(User).where(User.id == 1)
)).scalar_one()
return u.tier, u.stripe_customer_id, u.stripe_subscription_id
tier, cid, sid = asyncio.run(_check())
assert tier == "paid"
assert cid == "cus_abc"
assert sid == "sub_xyz"
def test_subscription_deleted_drops_tier_to_free(tmp_path):
client, factory, _ = _build_app(tmp_path)
# First, activate.
_post_webhook(client, body={
"id": "evt_act",
"type": "checkout.session.completed",
"data": {"object": {
"client_reference_id": "1",
"customer": "cus_abc",
"subscription": "sub_xyz",
}},
})
# Then, delete the subscription.
r = _post_webhook(client, body={
"id": "evt_del",
"type": "customer.subscription.deleted",
"data": {"object": {
"id": "sub_xyz",
"customer": "cus_abc",
"status": "canceled",
}},
})
assert r.status_code == 200, r.text
async def _check():
from sqlalchemy import select
from app.models import User
async with factory() as session:
u = (await session.execute(
select(User).where(User.id == 1)
)).scalar_one()
return u.tier, u.stripe_customer_id, u.stripe_subscription_id
tier, cid, sid = asyncio.run(_check())
assert tier == "free"
# Customer linkage preserved so a future resub matches this row.
assert cid == "cus_abc"
assert sid is None
def test_subscription_trialing_stores_trial_end(tmp_path):
"""customer.subscription.created with status=trialing + trial_end
should grant paid AND persist the trial_end timestamp so the
settings page can show 'N days remaining'.
Realistic flow: checkout.session.completed fires first (linking
customer_id to user.id via client_reference_id), then
subscription.created fires moments later carrying trial_end."""
import datetime as _dt
client, factory, _ = _build_app(tmp_path)
# First: link the user to the Stripe customer via checkout.
_post_webhook(client, body={
"id": "evt_link",
"type": "checkout.session.completed",
"data": {"object": {
"client_reference_id": "1",
"customer": "cus_trial",
"subscription": "sub_trial",
}},
})
# Then: the subscription event carrying trial_end (12 days out).
trial_end_ts = int((_dt.datetime.now(_dt.timezone.utc)
+ _dt.timedelta(days=12)).timestamp())
r = _post_webhook(client, body={
"id": "evt_trial",
"type": "customer.subscription.created",
"data": {"object": {
"id": "sub_trial",
"customer": "cus_trial",
"status": "trialing",
"trial_end": trial_end_ts,
}},
})
assert r.status_code == 200, r.text
async def _check():
from sqlalchemy import select
from app.models import User
async with factory() as session:
u = (await session.execute(
select(User).where(User.id == 1)
)).scalar_one()
return u.tier, u.stripe_trial_end_at
tier, end = asyncio.run(_check())
assert tier == "paid", "trial users must have paid features"
assert end is not None
# Stored value should match the trial_end we sent (within a second).
expected = _dt.datetime.fromtimestamp(trial_end_ts, tz=_dt.timezone.utc)
if end.tzinfo is None:
end = end.replace(tzinfo=_dt.timezone.utc)
assert abs((end - expected).total_seconds()) < 2
def test_subscription_active_clears_trial_end(tmp_path):
"""When the subscription transitions trialing -> active (day 15),
the trial_end marker should be cleared so settings stops showing
'trial — N days remaining'."""
import datetime as _dt
client, factory, _ = _build_app(tmp_path)
# Link the customer first via checkout, then plant a trial state.
_post_webhook(client, body={
"id": "evt_link2",
"type": "checkout.session.completed",
"data": {"object": {
"client_reference_id": "1",
"customer": "cus_t",
"subscription": "sub_t",
}},
})
trial_end_ts = int((_dt.datetime.now(_dt.timezone.utc)
+ _dt.timedelta(days=12)).timestamp())
_post_webhook(client, body={
"id": "evt_t1",
"type": "customer.subscription.created",
"data": {"object": {
"id": "sub_t", "customer": "cus_t",
"status": "trialing", "trial_end": trial_end_ts,
}},
})
# Now transition to active.
_post_webhook(client, body={
"id": "evt_t2",
"type": "customer.subscription.updated",
"data": {"object": {
"id": "sub_t", "customer": "cus_t",
"status": "active",
}},
})
async def _check():
from sqlalchemy import select
from app.models import User
async with factory() as session:
u = (await session.execute(
select(User).where(User.id == 1)
)).scalar_one()
return u.tier, u.stripe_trial_end_at
tier, end = asyncio.run(_check())
assert tier == "paid"
assert end is None, "trial_end_at must be cleared once active"
def test_subscription_active_grants_paid(tmp_path):
"""customer.subscription.updated with status=active should also
grant paid — covers the case where checkout.session.completed
arrives after subscription.created and we want either to work."""
client, factory, _ = _build_app(tmp_path)
# Seed the linkage first via checkout (so customer_id is known).
_post_webhook(client, body={
"id": "evt_ck",
"type": "checkout.session.completed",
"data": {"object": {
"client_reference_id": "1",
"customer": "cus_abc",
"subscription": "sub_xyz",
}},
})
# Drop to free manually so we can prove the updated event re-grants.
async def _reset():
from sqlalchemy import update
from app.models import User
async with factory() as session:
await session.execute(
update(User).where(User.id == 1).values(tier="free")
)
await session.commit()
asyncio.run(_reset())
r = _post_webhook(client, body={
"id": "evt_upd",
"type": "customer.subscription.updated",
"data": {"object": {
"id": "sub_xyz",
"customer": "cus_abc",
"status": "active",
}},
})
assert r.status_code == 200
async def _check_tier():
from sqlalchemy import select
from app.models import User
async with factory() as session:
return (await session.execute(
select(User.tier).where(User.id == 1)
)).scalar_one()
assert asyncio.run(_check_tier()) == "paid"
# --- pause / resume --------------------------------------------------------
def _activate(client, *, customer="cus_p", subscription="sub_p", evt="evt_a"):
"""Link user 1 to a Stripe customer and put them on paid."""
return _post_webhook(client, body={
"id": evt,
"type": "checkout.session.completed",
"data": {"object": {
"client_reference_id": "1",
"customer": customer,
"subscription": subscription,
}},
})
def _tier_and_sub(factory):
async def _check():
from sqlalchemy import select
from app.models import User
async with factory() as session:
u = (await session.execute(
select(User).where(User.id == 1)
)).scalar_one()
return u.tier, u.stripe_subscription_id
return asyncio.run(_check())
def test_subscription_paused_drops_tier(tmp_path):
"""status=paused means Stripe has stopped collecting (trial ended
with no usable card). Paid features must come off — otherwise the
customer keeps everything for free."""
client, factory, _ = _build_app(tmp_path)
_activate(client)
assert _tier_and_sub(factory)[0] == "paid"
r = _post_webhook(client, body={
"id": "evt_paused",
"type": "customer.subscription.paused",
"data": {"object": {
"id": "sub_p", "customer": "cus_p", "status": "paused",
}},
})
assert r.status_code == 200, r.text
assert r.json()["status"] == "ok", "must not fall through to 'ignored'"
tier, sub = _tier_and_sub(factory)
assert tier == "free"
# The subscription still exists at Stripe and resumes under the same
# id, so we keep our link to it.
assert sub == "sub_p"
def test_subscription_resumed_regrants_tier(tmp_path):
client, factory, _ = _build_app(tmp_path)
_activate(client)
_post_webhook(client, body={
"id": "evt_paused2",
"type": "customer.subscription.paused",
"data": {"object": {
"id": "sub_p", "customer": "cus_p", "status": "paused",
}},
})
assert _tier_and_sub(factory)[0] == "free"
r = _post_webhook(client, body={
"id": "evt_resumed",
"type": "customer.subscription.resumed",
"data": {"object": {
"id": "sub_p", "customer": "cus_p", "status": "active",
}},
})
assert r.status_code == 200, r.text
assert r.json()["status"] == "ok"
assert _tier_and_sub(factory) == ("paid", "sub_p")
def test_pause_collection_drops_tier_despite_active_status(tmp_path):
"""The portal's pause uses `pause_collection` and leaves status as
`active`, so the status check alone would keep the user on paid while
Stripe bills them nothing."""
client, factory, _ = _build_app(tmp_path)
_activate(client)
assert _tier_and_sub(factory)[0] == "paid"
r = _post_webhook(client, body={
"id": "evt_pause_coll",
"type": "customer.subscription.updated",
"data": {"object": {
"id": "sub_p",
"customer": "cus_p",
"status": "active",
"pause_collection": {"behavior": "void"},
}},
})
assert r.status_code == 200, r.text
assert _tier_and_sub(factory) == ("free", "sub_p")
def test_unpause_collection_regrants_tier(tmp_path):
"""Resuming collection sends subscription.updated with
pause_collection cleared to null — that must grant paid back."""
client, factory, _ = _build_app(tmp_path)
_activate(client)
_post_webhook(client, body={
"id": "evt_pc_on",
"type": "customer.subscription.updated",
"data": {"object": {
"id": "sub_p", "customer": "cus_p", "status": "active",
"pause_collection": {"behavior": "void"},
}},
})
assert _tier_and_sub(factory)[0] == "free"
_post_webhook(client, body={
"id": "evt_pc_off",
"type": "customer.subscription.updated",
"data": {"object": {
"id": "sub_p", "customer": "cus_p", "status": "active",
"pause_collection": None,
}},
})
assert _tier_and_sub(factory) == ("paid", "sub_p")
# --- idempotency + unknown ------------------------------------------------
def test_replayed_event_id_is_a_noop(tmp_path):
client, factory, _ = _build_app(tmp_path)
body = {
"id": "evt_dup",
"type": "checkout.session.completed",
"data": {"object": {
"client_reference_id": "1",
"customer": "cus_abc",
"subscription": "sub_xyz",
}},
}
r1 = _post_webhook(client, body=body)
r2 = _post_webhook(client, body=body)
assert r1.json()["status"] == "ok"
assert r2.json()["status"] == "duplicate"
async def _count_rows():
from sqlalchemy import select, func
from app.models import StripeEvent
async with factory() as session:
n = (await session.execute(
select(func.count(StripeEvent.id))
.where(StripeEvent.event_id == "evt_dup")
)).scalar_one()
return n
assert asyncio.run(_count_rows()) == 1
def test_unknown_event_is_acked(tmp_path):
client, _, _ = _build_app(tmp_path)
r = _post_webhook(client, body={
"id": "evt_unknown",
"type": "product.something.new",
"data": {"object": {}},
})
assert r.status_code == 200
assert r.json()["status"] == "ignored"
# --- /api/stripe/checkout (with Stripe SDK mocked) ------------------------
def _fake_checkout_client(asserter):
"""Build a fake Stripe client whose checkout.sessions.create calls
the supplied asserter on the params dict and returns a stub URL."""
fake_session = SimpleNamespace(
id="cs_test_123", url="https://checkout.stripe.com/test",
)
class _FakeSessions:
@staticmethod
def create(params): # noqa: ANN001
asserter(params)
return fake_session
class _FakeCheckout:
sessions = _FakeSessions()
class _FakeClient:
checkout = _FakeCheckout()
return _FakeClient()
def test_checkout_monthly_has_no_trial_and_no_stripe_consent(tmp_path):
"""Monthly checkout must NOT carry a free trial (£7 × 14 days would
halve cycle-1 revenue) AND must NOT use Stripe's account-wide
consent_collection — the Reg-36 waiver is collected on /pricing
so each product can use its own Terms URL."""
client, _, session_cookie = _build_app(tmp_path)
def asserter(params):
assert params["mode"] == "subscription"
assert params["line_items"][0]["price"] == _PRICE_MONTHLY
assert params["client_reference_id"] == "1"
assert params["customer_email"] == "buyer@x"
assert "subscription_data" not in params, "no trial on monthly"
assert "consent_collection" not in params, (
"consent is collected on /pricing, not via Stripe's account-wide setting"
)
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},
)
assert r.status_code == 200, r.text
assert r.json()["url"] == "https://checkout.stripe.com/test"
def test_checkout_annual_uses_trial_not_consent_collection(tmp_path):
"""Annual checkout gets the 14-day free trial (substitutes for the
statutory cooling-off right; no money moves during the trial)."""
client, _, session_cookie = _build_app(tmp_path)
def asserter(params):
assert params["mode"] == "subscription"
assert params["line_items"][0]["price"] == _PRICE_ANNUAL
assert params["subscription_data"]["trial_period_days"] == 14
assert "consent_collection" not in params, "annual relies on trial, not consent"
with patch("app.routers.stripe_billing._stripe_client",
return_value=_fake_checkout_client(asserter)):
r = client.post(
"/api/stripe/checkout",
json={"cadence": "annual"},
cookies={"cassandra_session": session_cookie},
)
assert r.status_code == 200, r.text
assert r.json()["url"] == "https://checkout.stripe.com/test"
def test_checkout_endpoint_requires_login(tmp_path):
client, _, _ = _build_app(tmp_path)
r = client.post("/api/stripe/checkout", json={"cadence": "monthly"})
# No session cookie → require_auth bounces with 401.
assert r.status_code == 401, r.text
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 "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", "currency": "usd"},
cookies={"cassandra_session": session_cookie},
)
assert r.status_code == 200, r.text
def test_checkout_requires_billing_address(tmp_path):
"""Every checkout must collect a billing address, so each Stripe
Customer ends up with a country. EU B2C digital-services VAT is due
at the consumer's place of supply, and the card's billing country is
the evidence for that — an IP guess is not."""
client, _, session_cookie = _build_app(tmp_path)
def asserter(params):
assert params["billing_address_collection"] == "required"
# New customer (no stored customer id): customer_update is only
# valid alongside `customer`, so it must be absent here.
assert "customer_update" not in params
for cadence in ("monthly", "annual"):
with patch("app.routers.stripe_billing._stripe_client",
return_value=_fake_checkout_client(asserter)):
r = client.post(
"/api/stripe/checkout",
json={"cadence": cadence},
cookies={"cassandra_session": session_cookie},
)
assert r.status_code == 200, r.text
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
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
assert params["customer"] == "cus_existing_xxxxxxxxxxxxxx"
assert "customer_email" not in params
# Without customer_update.address the collected address is
# attached to the payment only and the Customer record keeps a
# null address — i.e. still no country for the VAT question.
assert params["customer_update"] == {"address": "auto"}
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