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:
Giorgio Gilestro 2026-05-26 20:06:19 +02:00
parent 62960d5bea
commit a07fd144ea
10 changed files with 390 additions and 31 deletions

View file

@ -120,6 +120,22 @@ async def create_checkout(
# referral redemption flow ships.
"allow_promotion_codes": True,
}
# Per-cadence cooling-off treatment:
#
# - Annual gets a 14-day free trial. No money moves during the
# trial, so the Consumer Contracts Regulations 14-day refund
# question is moot (nothing paid = nothing to refund). Card is
# still required at checkout so Stripe can charge on day 15.
#
# - Monthly bills immediately (a 14-day trial on a £7/month plan
# would give away ~50% of cycle one). The Reg-36 waiver lives
# on our own /pricing page as a required tick-box (see
# pricing.html); we deliberately do NOT use Stripe's
# consent_collection.terms_of_service here because that's an
# account-wide setting and we want per-product control (and
# per-product Terms URLs) as we grow.
if body.cadence == "annual":
create_kwargs["subscription_data"] = {"trial_period_days": 14}
if user.stripe_customer_id:
create_kwargs["customer"] = user.stripe_customer_id
else:
@ -220,17 +236,28 @@ async def _grant_paid(
*,
customer_id: str | None,
subscription_id: str | None,
trial_end: int | None = None,
status: str | None = None,
) -> None:
user.tier = "paid"
if customer_id and user.stripe_customer_id != customer_id:
user.stripe_customer_id = customer_id
if subscription_id and user.stripe_subscription_id != subscription_id:
user.stripe_subscription_id = subscription_id
# Track trial_end so the settings page can show "N days remaining".
# Only populated when Stripe reports the sub as trialing — once the
# status flips to active (paid for real), we clear the trial marker.
if status == "trialing" and trial_end:
from datetime import datetime, timezone
user.stripe_trial_end_at = datetime.fromtimestamp(trial_end, tz=timezone.utc)
elif status == "active":
user.stripe_trial_end_at = None
async def _revoke_paid(user: User) -> None:
user.tier = "free"
user.stripe_subscription_id = None
user.stripe_trial_end_at = None
# Keep stripe_customer_id so a re-subscription matches this row.
@ -243,8 +270,12 @@ async def _handle_checkout_completed(
customer_id=obj.get("customer"),
)
if user is None:
log.warning("stripe.user_not_found", event=event_type)
log.warning("stripe.user_not_found", event_type=event_type)
return
# checkout.session.completed doesn't carry trial_end on the session
# object itself — the subscription.created event that fires right
# after will carry it. We grant paid here without trial info and
# let the subscription event fill in trial_end_at moments later.
await _grant_paid(
user,
customer_id=obj.get("customer"),
@ -260,7 +291,7 @@ async def _handle_subscription_event(
to free if it's an end-state."""
user = await _find_user(session, customer_id=obj.get("customer"))
if user is None:
log.warning("stripe.user_not_found", event=event_type,
log.warning("stripe.user_not_found", event_type=event_type,
customer_id=obj.get("customer"))
return
status = obj.get("status")
@ -273,6 +304,8 @@ async def _handle_subscription_event(
user,
customer_id=obj.get("customer"),
subscription_id=obj.get("id"),
trial_end=obj.get("trial_end"),
status=status,
)
@ -281,7 +314,7 @@ async def _handle_subscription_deleted(
) -> None:
user = await _find_user(session, customer_id=obj.get("customer"))
if user is None:
log.warning("stripe.user_not_found", event=event_type,
log.warning("stripe.user_not_found", event_type=event_type,
customer_id=obj.get("customer"))
return
await _revoke_paid(user)