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:
320
src/billing/webhooks.py
Normal file
320
src/billing/webhooks.py
Normal 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})
|
||||
Reference in New Issue
Block a user