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

@@ -647,6 +647,12 @@ app.register_blueprint(marketing_bp)
app.register_blueprint(billing_bp)
app.register_blueprint(legal_bp)
# B-2.8: CSRF-exempt the Stripe webhook (signature-verified server-to-server).
# Must be called AFTER billing_bp is registered (so the view function exists
# in app.view_functions) and AFTER csrf is initialized (already done above).
from src.billing import exempt_webhook_csrf as _exempt_webhook_csrf
_exempt_webhook_csrf(csrf)
# Initialize Microsoft + Google OAuth providers (B-2.4) — no-op if env vars absent.
# Must run AFTER blueprints are registered (Authlib's OAuth object needs to be
# attached to the running app instance).

View File

@@ -1,8 +1,8 @@
"""Billing blueprint - Stripe Checkout, webhook, subscription management.
Mounted at /checkout/* prefix for the customer-facing checkout flow. The
/webhooks/stripe route (added in B-2.8) bypasses the prefix and is also
csrf-exempted.
Mounted at /checkout/* prefix for the customer-facing checkout flow.
The webhook (B-2.8) is exposed at /checkout/webhooks/stripe and is
CSRF-exempted via `exempt_webhook_csrf` (signature-verified instead).
Routes added in Tasks B-2.7 (checkout) and B-2.8 (webhook).
"""
@@ -22,3 +22,15 @@ billing_bp = Blueprint(
# Import routes to register them on billing_bp. Must come after blueprint
# instantiation. Keep the # noqa comments — these guards exist for ruff/flake8.
from src.billing import routes # noqa: E402, F401
from src.billing import webhooks # noqa: E402, F401
def exempt_webhook_csrf(csrf_protect):
"""Exempt the Stripe webhook view from CSRF protection.
Called from app.py after CSRFProtect is initialized. Stripe webhooks have
no CSRF token (server-to-server). The `stripe_webhook` view validates
Stripe's signature header (`Stripe-Signature` + STRIPE_WEBHOOK_SECRET) instead.
"""
from src.billing.webhooks import stripe_webhook
csrf_protect.exempt(stripe_webhook)

View File

@@ -5,8 +5,8 @@ URL space (prefix `/checkout`, set on billing_bp):
- GET /checkout/success?session_id=... → confirmation page (async activation note)
- GET /checkout/cancel → friendly "no charge made" page
The webhook route (B-2.8) is registered separately at /webhooks/stripe outside
the /checkout prefix and is CSRF-exempt.
The webhook route (B-2.8) is registered at /checkout/webhooks/stripe (under
the same blueprint prefix) and is CSRF-exempt (signature-verified instead).
"""
import logging

320
src/billing/webhooks.py Normal file
View File

@@ -0,0 +1,320 @@
"""Stripe webhook handler (B-2.8) — subscription lifecycle.
Endpoint: POST /checkout/webhooks/stripe (CSRF-exempt; signature verified)
Handled events:
- checkout.session.completed: create Subscription row, set User.subscription_status
- customer.subscription.updated: update status + current_period_end
- customer.subscription.deleted: mark status='canceled', clear User.subscription_status
- invoice.payment_succeeded: touch updated_at (renewal confirmation)
- invoice.payment_failed: set status='past_due'
All other event types are acknowledged with 200 but ignored.
Idempotency: every processed event ID is recorded in WebhookEvent.
Duplicate deliveries return 200 immediately without re-processing.
"""
import logging
import os
from datetime import datetime, timezone
from typing import Optional
import stripe
from flask import jsonify, request
from src.billing import billing_bp
from src.billing.plans import VALID_PERIODS, get_plan
from src.billing.stripe_client import is_stripe_configured
from src.database import db
from src.models import Subscription, User, WebhookEvent
logger = logging.getLogger(__name__)
def get_webhook_secret() -> Optional[str]:
"""Return STRIPE_WEBHOOK_SECRET, or None if not configured."""
return os.environ.get('STRIPE_WEBHOOK_SECRET')
def is_webhook_configured() -> bool:
return bool(get_webhook_secret() and is_stripe_configured())
def _verify_event(payload: bytes, sig_header: str):
"""Validate Stripe signature and return the parsed event, or None on failure."""
secret = get_webhook_secret()
if not secret:
logger.error('STRIPE_WEBHOOK_SECRET not set; rejecting webhook')
return None
try:
return stripe.Webhook.construct_event(payload, sig_header, secret)
except ValueError:
logger.warning('Stripe webhook: invalid JSON payload')
return None
except stripe.error.SignatureVerificationError:
logger.warning('Stripe webhook: signature verification failed')
return None
def _is_duplicate(event_id: str) -> bool:
return WebhookEvent.query.filter_by(stripe_event_id=event_id).first() is not None
def _resolve_user_for_event(event_obj: dict) -> Optional[User]:
"""Resolve the DictIA User from a Stripe event object.
Trust order (anti-tamper per B-2.7 review note):
1. Look up by stripe_customer_id on the event object — this is server-set
by Stripe at customer creation, not user-controlled.
2. Fall back to event metadata 'dictia_user_id', re-validated against DB.
3. Fall back to customer_email lookup (last resort, rare for subscriptions).
"""
cust_id = event_obj.get('customer')
if cust_id:
user = User.query.filter_by(stripe_customer_id=cust_id).first()
if user:
return user
metadata = event_obj.get('metadata') or {}
raw_user_id = metadata.get('dictia_user_id')
if raw_user_id:
try:
uid = int(raw_user_id)
except (TypeError, ValueError):
uid = None
if uid is not None:
user = db.session.get(User, uid)
if user:
# Bind stripe_customer_id if missing (defensive)
if not user.stripe_customer_id and cust_id:
user.stripe_customer_id = cust_id
return user
email = event_obj.get('customer_email')
if email:
user = User.query.filter_by(email=email.lower().strip()).first()
if user and cust_id and not user.stripe_customer_id:
user.stripe_customer_id = cust_id
return user
return None
def _resolve_plan_period(event_obj: dict, default_period: str = 'monthly') -> tuple:
"""Extract plan_slug and period from event metadata, validating both."""
metadata = event_obj.get('metadata') or {}
plan_slug = metadata.get('dictia_plan_slug')
period = metadata.get('dictia_period', default_period)
if get_plan(plan_slug) is None:
plan_slug = None # invalid / missing — leave for handler to log
if period not in VALID_PERIODS:
period = default_period
return plan_slug, period
def _ts_to_dt(ts) -> Optional[datetime]:
if ts is None:
return None
try:
return datetime.fromtimestamp(int(ts), tz=timezone.utc).replace(tzinfo=None)
except (TypeError, ValueError, OSError):
return None
def _record_event(event, sub_id: Optional[str], cust_id: Optional[str]) -> None:
"""Insert a WebhookEvent row marking this event as processed."""
db.session.add(WebhookEvent(
stripe_event_id=event.id,
event_type=event.type,
stripe_subscription_id=sub_id,
stripe_customer_id=cust_id,
))
def _handle_checkout_session_completed(event) -> None:
obj = event.data.object # stripe.checkout.Session
user = _resolve_user_for_event(obj)
sub_id = obj.get('subscription')
cust_id = obj.get('customer')
plan_slug, period = _resolve_plan_period(obj)
if not user:
logger.warning('checkout.session.completed: no user for cust=%s sub=%s', cust_id, sub_id)
_record_event(event, sub_id, cust_id)
return
if not sub_id:
logger.warning('checkout.session.completed: missing subscription id for user %s', user.id)
_record_event(event, sub_id, cust_id)
return
if not plan_slug:
logger.warning('checkout.session.completed: missing/invalid plan_slug metadata for sub=%s', sub_id)
plan_slug = 'unknown'
# Look up the existing subscription row (defensive against duplicate webhooks)
existing = Subscription.query.filter_by(stripe_subscription_id=sub_id).first()
now = datetime.utcnow()
if existing:
existing.status = 'active'
existing.updated_at = now
else:
# We need current_period_end — pull it from the subscription object
# if the event includes it; otherwise leave None and let
# customer.subscription.updated fill it in.
period_end = None
# Fetch the subscription via Stripe API for accurate period_end
try:
from src.billing.stripe_client import _ensure_configured
_ensure_configured()
sub_obj = stripe.Subscription.retrieve(sub_id)
period_end = _ts_to_dt(sub_obj.get('current_period_end'))
except Exception as e:
logger.warning('Could not fetch subscription %s for period_end: %s', sub_id, e)
db.session.add(Subscription(
user_id=user.id,
stripe_customer_id=cust_id,
stripe_subscription_id=sub_id,
plan_slug=plan_slug,
period=period,
status='active',
current_period_end=period_end,
created_at=now,
updated_at=now,
))
user.subscription_status = 'active'
if cust_id and not user.stripe_customer_id:
user.stripe_customer_id = cust_id
_record_event(event, sub_id, cust_id)
def _handle_subscription_updated(event) -> None:
obj = event.data.object # stripe.Subscription
sub_id = obj.get('id')
cust_id = obj.get('customer')
new_status = obj.get('status')
period_end = _ts_to_dt(obj.get('current_period_end'))
sub = Subscription.query.filter_by(stripe_subscription_id=sub_id).first()
user = _resolve_user_for_event({'customer': cust_id, 'metadata': obj.get('metadata') or {}})
now = datetime.utcnow()
if sub:
if new_status:
sub.status = new_status
if period_end:
sub.current_period_end = period_end
sub.updated_at = now
else:
# Webhook arrived before we created the row (race) — create defensively
plan_slug, period = _resolve_plan_period(obj)
db.session.add(Subscription(
user_id=user.id if user else None,
stripe_customer_id=cust_id,
stripe_subscription_id=sub_id,
plan_slug=plan_slug or 'unknown',
period=period,
status=new_status or 'unknown',
current_period_end=period_end,
created_at=now,
updated_at=now,
))
if user and new_status:
user.subscription_status = new_status
_record_event(event, sub_id, cust_id)
def _handle_subscription_deleted(event) -> None:
obj = event.data.object
sub_id = obj.get('id')
cust_id = obj.get('customer')
sub = Subscription.query.filter_by(stripe_subscription_id=sub_id).first()
user = _resolve_user_for_event({'customer': cust_id, 'metadata': obj.get('metadata') or {}})
now = datetime.utcnow()
if sub:
sub.status = 'canceled'
sub.updated_at = now
if user:
user.subscription_status = 'canceled'
_record_event(event, sub_id, cust_id)
def _handle_invoice_payment_succeeded(event) -> None:
obj = event.data.object # stripe.Invoice
sub_id = obj.get('subscription')
cust_id = obj.get('customer')
if sub_id:
sub = Subscription.query.filter_by(stripe_subscription_id=sub_id).first()
if sub:
sub.updated_at = datetime.utcnow()
if sub.status == 'past_due':
sub.status = 'active'
user = _resolve_user_for_event({'customer': cust_id, 'metadata': {}})
if user:
user.subscription_status = 'active'
_record_event(event, sub_id, cust_id)
def _handle_invoice_payment_failed(event) -> None:
obj = event.data.object
sub_id = obj.get('subscription')
cust_id = obj.get('customer')
if sub_id:
sub = Subscription.query.filter_by(stripe_subscription_id=sub_id).first()
user = _resolve_user_for_event({'customer': cust_id, 'metadata': {}})
if sub:
sub.status = 'past_due'
sub.updated_at = datetime.utcnow()
if user:
user.subscription_status = 'past_due'
_record_event(event, sub_id, cust_id)
_HANDLERS = {
'checkout.session.completed': _handle_checkout_session_completed,
'customer.subscription.updated': _handle_subscription_updated,
'customer.subscription.deleted': _handle_subscription_deleted,
'invoice.payment_succeeded': _handle_invoice_payment_succeeded,
'invoice.payment_failed': _handle_invoice_payment_failed,
}
@billing_bp.route('/webhooks/stripe', methods=['POST'])
def stripe_webhook():
"""Stripe webhook endpoint. Signature-verified; CSRF-exempt.
Returns 400 on signature failure (Stripe will retry); 200 otherwise
(even for unhandled event types, to acknowledge receipt).
"""
payload = request.get_data()
sig_header = request.headers.get('Stripe-Signature', '')
event = _verify_event(payload, sig_header)
if event is None:
return jsonify({'error': 'invalid_signature'}), 400
# Idempotency check
if _is_duplicate(event.id):
logger.info('Stripe webhook: duplicate event %s ignored', event.id)
return jsonify({'received': True, 'duplicate': True})
handler = _HANDLERS.get(event.type)
if handler is None:
# Unhandled event type — record + ack so Stripe stops retrying
_record_event(event, None, None)
try:
db.session.commit()
except Exception:
db.session.rollback()
return jsonify({'received': True, 'handled': False})
try:
handler(event)
db.session.commit()
except Exception as e:
logger.exception('Stripe webhook: handler for %s failed: %s', event.type, e)
db.session.rollback()
# Return 500 so Stripe retries — but only for genuine handler failures,
# not for malformed/unhandled events
return jsonify({'error': 'handler_failed'}), 500
return jsonify({'received': True})

View File

@@ -34,6 +34,8 @@ from .processing_job import ProcessingJob
from .token_usage import TokenUsage
from .transcription_usage import TranscriptionUsage
from .consent import ConsentLog
from .subscription import Subscription
from .webhook_event import WebhookEvent
# Export all models
__all__ = [
@@ -72,4 +74,7 @@ __all__ = [
'TokenUsage',
'TranscriptionUsage',
'ConsentLog',
# Billing models (B-2.8)
'Subscription',
'WebhookEvent',
]

View File

@@ -0,0 +1,55 @@
"""DictIA subscription model — Stripe subscription state mirror (B-2.8).
This table is updated EXCLUSIVELY by the Stripe webhook handler. Never
write to it from user-facing routes (Checkout creates the Stripe
subscription; webhook reflects its state into our DB).
Each row corresponds to one Stripe Subscription object. A user can have
multiple historical subscriptions (renewed, cancelled, re-subscribed).
"""
from datetime import datetime
from src.database import db
class Subscription(db.Model):
"""One row per Stripe Subscription. The active row for a user is the
one with status in ('active', 'trialing', 'past_due') ordered by
created_at DESC."""
__tablename__ = 'subscription'
id = db.Column(db.Integer, primary_key=True)
# Use ondelete=SET NULL so we keep historical billing records even if
# the user deletes their account (Loi 25 art. 28.1 right-to-erasure +
# accounting/tax retention obligations are reconciled by anonymizing
# rather than dropping the row).
user_id = db.Column(
db.Integer,
db.ForeignKey('user.id', ondelete='SET NULL'),
nullable=True,
index=True,
)
stripe_customer_id = db.Column(db.String(120), nullable=False, index=True)
# Stripe subscription ID is unique — UNIQUE constraint also gives natural
# dedup against duplicate webhook deliveries of checkout.session.completed
stripe_subscription_id = db.Column(db.String(120), unique=True, nullable=False)
plan_slug = db.Column(db.String(40), nullable=False)
period = db.Column(db.String(10), nullable=False) # 'monthly' | 'yearly'
# Stripe subscription status: 'trialing' | 'active' | 'past_due' |
# 'canceled' | 'incomplete' | 'incomplete_expired' | 'unpaid' | 'paused'
status = db.Column(db.String(20), nullable=False, index=True)
# Period end: when next invoice will be billed (or when access expires
# if status='canceled' with cancel_at_period_end=True)
current_period_end = db.Column(db.DateTime, nullable=True)
# When the subscription was first created in Stripe
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
# Last time we received a webhook event updating this subscription
updated_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
user = db.relationship('User', backref='subscriptions')
def __repr__(self):
return f'<Subscription {self.stripe_subscription_id} {self.status} {self.plan_slug}/{self.period}>'

View File

@@ -0,0 +1,28 @@
"""Stripe webhook event ledger (B-2.8) — for idempotent processing.
Stripe delivers webhook events at least once. We record the event ID on
first successful processing; subsequent deliveries with the same ID are
no-op'd. Records are NOT garbage-collected automatically — operations
team can prune events older than 30 days if storage becomes a concern
(Stripe also has a 30-day delivery retry policy).
"""
from datetime import datetime
from src.database import db
class WebhookEvent(db.Model):
"""One row per processed Stripe webhook event."""
__tablename__ = 'webhook_event'
id = db.Column(db.Integer, primary_key=True)
# Stripe event ID (`evt_xxx`) — primary dedup key
stripe_event_id = db.Column(db.String(80), unique=True, nullable=False, index=True)
event_type = db.Column(db.String(80), nullable=False, index=True)
processed_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
# Optional: store the related stripe_subscription_id or stripe_customer_id
# for fast lookup during incident debugging. Both nullable.
stripe_subscription_id = db.Column(db.String(120), nullable=True, index=True)
stripe_customer_id = db.Column(db.String(120), nullable=True, index=True)
def __repr__(self):
return f'<WebhookEvent {self.stripe_event_id} {self.event_type}>'