"""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})