stripe: per-cadence cooling-off + manage-subscription button
Bundles three related pieces that came out of the operator's first
end-to-end test of the paid flow:
1. Manage subscription button on /settings (paid users with a real
Stripe sub — i.e. not credit-granted access). POSTs to the existing
/api/stripe/portal endpoint; Stripe-hosted customer portal handles
card updates, cancellation, monthly↔annual switch, invoice history.
Replaces the stale "Paid features unlock with Paddle (D.3) or
invite credits" hint for free users with a live link to /pricing.
2. Per-cadence cooling-off treatment:
- **Annual £70**: 14-day free trial via
subscription_data.trial_period_days=14. No money moves during
the trial, so the CCR 2013 14-day refund question doesn't arise
(nothing paid = nothing to refund). Card is still required at
checkout so Stripe can charge on day 15.
- **Monthly £7**: bills immediately. A 14-day trial there would
give away ~50% of cycle one. Instead, /pricing now carries a
required tick-box above the Subscribe buttons (subscribe stays
disabled until checked) — by ticking, the user expressly
consents to begin performance immediately and acknowledges that
this extinguishes their statutory 14-day right under Reg 36
CCR 2013. Consent collected on our own page (not via Stripe's
account-wide consent_collection.terms_of_service) so each
product can keep its own Terms URL as we add more.
3. T&C §6 clause 1 split into 1a (annual / trial substitute) +
1b (monthly / Reg 36 waiver via on-page tick-box). Clause 2
(post-cooling-off cancellation) unchanged.
Settings page shows "Free trial — N days remaining" while the
sub is in `trialing` status, falling back to "Paid subscription
active." once it transitions to active. Countdown is computed
server-side from User.stripe_trial_end_at (new column, migration
0020) populated by the subscription.created/updated webhook from
the Stripe trial_end timestamp; cleared on the trialing→active
transition and on revoke.
Drive-by: fixed a structlog kwarg-name collision on
`log.warning(..., event=event_type, ...)` in both polar_webhook.py
and stripe_billing.py — `event` is structlog's positional event
name and "got multiple values" crashed the user-not-found log
path. Renamed to `event_type=` everywhere it appeared. Caught by
the new trialing-stores-trial-end test.
Tests
- 4 new in test_stripe_billing.py covering monthly (no trial, no
consent_collection), annual (trial, no consent), trialing stores
trial_end, trialing→active clears trial_end.
- 1 existing test renamed + reworked for the consent split.
- Full suite: 224 passed, 5 skipped.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
62960d5bea
commit
a07fd144ea
10 changed files with 390 additions and 31 deletions
|
|
@ -190,6 +190,110 @@ def test_subscription_deleted_drops_tier_to_free(tmp_path):
|
|||
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
|
||||
|
|
@ -282,9 +386,9 @@ def test_unknown_event_is_acked(tmp_path):
|
|||
# --- /api/stripe/checkout (with Stripe SDK mocked) ------------------------
|
||||
|
||||
|
||||
def test_checkout_endpoint_creates_session_and_returns_url(tmp_path):
|
||||
client, _, session_cookie = _build_app(tmp_path)
|
||||
# Mock the Stripe SDK call so no real HTTP goes out.
|
||||
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",
|
||||
)
|
||||
|
|
@ -292,10 +396,7 @@ def test_checkout_endpoint_creates_session_and_returns_url(tmp_path):
|
|||
class _FakeSessions:
|
||||
@staticmethod
|
||||
def create(params): # noqa: ANN001
|
||||
assert params["mode"] == "subscription"
|
||||
assert params["line_items"][0]["price"] == _PRICE_MONTHLY
|
||||
assert params["client_reference_id"] == "1"
|
||||
assert params["customer_email"] == "buyer@x"
|
||||
asserter(params)
|
||||
return fake_session
|
||||
|
||||
class _FakeCheckout:
|
||||
|
|
@ -304,8 +405,28 @@ def test_checkout_endpoint_creates_session_and_returns_url(tmp_path):
|
|||
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=_FakeClient()):
|
||||
return_value=_fake_checkout_client(asserter)):
|
||||
r = client.post(
|
||||
"/api/stripe/checkout",
|
||||
json={"cadence": "monthly"},
|
||||
|
|
@ -315,6 +436,28 @@ def test_checkout_endpoint_creates_session_and_returns_url(tmp_path):
|
|||
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"})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue