feat(billing): B-2.8 Stripe webhook handler (subscription lifecycle + idempotency)

Endpoint: POST /checkout/webhooks/stripe (CSRF-exempt; signature-verified)

Handles 5 Stripe events:
  - checkout.session.completed     -> create Subscription, activate user
  - customer.subscription.updated  -> sync status + current_period_end
  - customer.subscription.deleted  -> mark canceled
  - invoice.payment_succeeded      -> recover from past_due if applicable
  - invoice.payment_failed         -> mark past_due

Idempotency via WebhookEvent table (Stripe ID dedup) and Subscription
unique constraint on stripe_subscription_id (defends against duplicate
deliveries with distinct event IDs).

User resolution prefers stripe_customer_id (server-set, anti-tamper)
over event metadata.dictia_user_id over customer_email (per B-2.7
review note).

New tables created via db.create_all():
  - subscription (FK user.id ondelete=SET NULL for Loi 25 art. 28.1)
  - webhook_event (idempotency ledger)

CSRF exemption wired via src/billing/exempt_webhook_csrf(csrf) called
from src/app.py after billing_bp registration.

Tests: 17/17 pass via tests/_run_stripe_webhook_windows.py.
Existing 25 B-2.7 + 21 TOTP + 22 WebAuthn + 21 OAuth + 16 email tests
unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Allison
2026-04-28 08:41:03 -04:00
parent f1a5ad565f
commit 64738bfd1f
10 changed files with 1306 additions and 10 deletions

View File

@@ -61,8 +61,20 @@
# - One recurring yearly Price (CAD, = monthly × 12 × 0.85)
# For DictIA 8 and DictIA 16, also create a one-time Price for the setup fee.
#
# 5. Create a webhook endpoint (B-2.8) pointing at https://dictia.ca/webhooks/stripe
# with at least the events: checkout.session.completed,
# customer.subscription.created, customer.subscription.updated,
# customer.subscription.deleted, invoice.payment_failed.
# Copy the signing secret into STRIPE_WEBHOOK_SECRET above.
# 5. Create a webhook endpoint (B-2.8) pointing at
# https://your-domain.example/checkout/webhooks/stripe
# (the route lives under the /checkout/* prefix; CSRF-exempt; signature-
# verified via STRIPE_WEBHOOK_SECRET below).
#
# Subscribe at minimum to these 5 events (the only ones the handler
# processes; all others are acknowledged with 200 + ignored):
# - checkout.session.completed (creates Subscription row, sets
# User.subscription_status='active')
# - customer.subscription.updated (status / current_period_end sync)
# - customer.subscription.deleted (marks status='canceled')
# - invoice.payment_succeeded (renewal touch; recovers past_due)
# - invoice.payment_failed (marks status='past_due')
#
# Copy the signing secret (whsec_...) into STRIPE_WEBHOOK_SECRET above.
# Without that secret, the webhook endpoint returns 400 invalid_signature
# on every delivery (Stripe will retry for up to 30 days).