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>
This commit is contained in:
Giorgio Gilestro 2026-07-30 12:13:47 +02:00
parent 1305aa77ff
commit 78b2c2be28
2 changed files with 201 additions and 2 deletions

View file

@ -341,6 +341,131 @@ def test_subscription_active_grants_paid(tmp_path):
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 ------------------------------------------------
@ -524,6 +649,30 @@ def test_checkout_rejects_currency_in_body(tmp_path):
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
@ -546,6 +695,10 @@ def test_checkout_uses_existing_customer_ref(tmp_path):
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)):