""" app/billing/webhooks.py ------------------------ Stripe webhook event dispatcher. Receives a verified stripe.Event object (signature already checked by the route) and mutates the control DB accordingly. Each event handler is self-contained and catches its own exceptions so that one malformed event cannot abort processing of subsequent events in the same delivery batch. Public API: handle_event(event) -> None """ import logging from datetime import datetime, timezone logger = logging.getLogger(__name__) # Maps Stripe subscription status strings to our internal subscription_status values. _STRIPE_STATUS_MAP = { 'trialing': 'trial', 'active': 'active', 'past_due': 'past_due', 'canceled': 'cancelled', # Stripe uses single 'l' 'cancelled': 'cancelled', 'incomplete': 'past_due', 'incomplete_expired': 'cancelled', 'unpaid': 'past_due', 'paused': 'past_due', } def _ts_to_dt(ts) -> datetime | None: """Convert a Unix timestamp (int or None) to a naive UTC datetime.""" if ts is None: return None return datetime.fromtimestamp(int(ts), tz=timezone.utc).replace(tzinfo=None) def _lookup_tenant_by_customer(stripe_customer_id: str): """Return the Tenant ORM object (within an open session) or None.""" from control.models import Tenant return None # placeholder — caller must query within a control_session def _get_base_url() -> str: from flask import current_app return (current_app.config.get('APP_BASE_URL') or '').rstrip('/') def handle_event(event) -> None: """Dispatch a verified Stripe event to the appropriate handler.""" event_type = event['type'] data_obj = event['data']['object'] handlers = { 'checkout.session.completed': _on_checkout_completed, 'customer.subscription.updated': _on_subscription_updated, 'customer.subscription.deleted': _on_subscription_deleted, 'invoice.payment_succeeded': _on_payment_succeeded, 'invoice.payment_failed': _on_payment_failed, 'customer.subscription.trial_will_end': _on_trial_will_end, } handler = handlers.get(event_type) if handler is None: logger.debug('BILLING WEBHOOK | unhandled | type=%s', event_type) return try: handler(data_obj) except Exception as exc: logger.error('BILLING WEBHOOK | handler_error | type=%s | err=%s', event_type, exc, exc_info=True) # ── Individual event handlers ────────────────────────────────────────────────── def _on_checkout_completed(obj): """checkout.session.completed — customer completed Stripe Checkout.""" customer_id = obj.get('customer') subscription_id = obj.get('subscription') if not customer_id or not subscription_id: logger.warning('BILLING | checkout_completed_missing_fields | obj=%s', obj.get('id')) return from control.base import control_session from control.models import Tenant, TenantAudit from control.time_utils import now_eastern with control_session() as s: t = s.query(Tenant).filter_by(stripe_customer_id=customer_id).first() if t is None: logger.warning('BILLING | checkout_completed_no_tenant | customer=%s', customer_id) return t.stripe_subscription_id = subscription_id t.subscription_status = 'active' t.trial_ends_at = None # trial period ended at checkout s.add(TenantAudit( action='billing_checkout_completed', tenant_id=t.id, details=f'subscription_id={subscription_id}', )) logger.info('BILLING | checkout_completed | tenant_id=%s sub=%s', t.id, subscription_id) def _on_subscription_updated(obj): """customer.subscription.updated — sync status and plan from Stripe.""" customer_id = obj.get('customer') subscription_id = obj.get('id') stripe_status = obj.get('status', '') period_end_ts = obj.get('current_period_end') trial_end_ts = obj.get('trial_end') # Extract the price ID from the first subscription item (plan change detection). items = obj.get('items', {}).get('data', []) new_price_id = items[0].get('price', {}).get('id') if items else None our_status = _STRIPE_STATUS_MAP.get(stripe_status) if our_status is None: logger.warning('BILLING | subscription_updated_unknown_status | status=%s', stripe_status) return from control.base import control_session from control.models import Tenant, Plan, TenantAudit with control_session() as s: t = s.query(Tenant).filter_by(stripe_customer_id=customer_id).first() if t is None: logger.warning('BILLING | subscription_updated_no_tenant | customer=%s', customer_id) return old_status = t.subscription_status old_plan_id = t.plan_id t.subscription_status = our_status t.stripe_subscription_id = subscription_id t.current_period_end = _ts_to_dt(period_end_ts) if trial_end_ts: t.trial_ends_at = _ts_to_dt(trial_end_ts) # Sync plan when the price changes (upgrade / downgrade via portal). plan_detail = '' if new_price_id: matched_plan = s.query(Plan).filter_by(stripe_price_id=new_price_id).first() if matched_plan and matched_plan.id != old_plan_id: t.plan_id = matched_plan.id plan_detail = f' plan={old_plan_id}→{matched_plan.id}({matched_plan.code})' logger.info('BILLING | plan_changed | tenant_id=%s plan=%s→%s', t.id, old_plan_id, matched_plan.id) s.add(TenantAudit( action='billing_subscription_updated', tenant_id=t.id, details=f'stripe_status={stripe_status} our_status={our_status} prev={old_status}{plan_detail}', )) logger.info('BILLING | subscription_updated | tenant_id=%s %s→%s', t.id, old_status, our_status) def _on_subscription_deleted(obj): """customer.subscription.deleted — subscription was cancelled.""" customer_id = obj.get('customer') from control.base import control_session from control.models import Tenant, TenantAudit from control.time_utils import now_eastern from app.billing.emails import send_billing_email with control_session() as s: t = s.query(Tenant).filter_by(stripe_customer_id=customer_id).first() if t is None: logger.warning('BILLING | subscription_deleted_no_tenant | customer=%s', customer_id) return t.subscription_status = 'cancelled' billing_email = t.billing_email s.add(TenantAudit( action='billing_subscription_cancelled', tenant_id=t.id, details=f'customer={customer_id}', )) logger.info('BILLING | subscription_deleted | tenant_id=%s', t.id) if billing_email: base_url = _get_base_url() send_billing_email(billing_email, 'subscription_cancelled', { 'portal_url': f'{base_url}/billing/portal', }) def _on_payment_succeeded(obj): """invoice.payment_succeeded — payment went through, clear past_due.""" customer_id = obj.get('customer') period_end = obj.get('lines', {}).get('data', [{}])[0].get('period', {}).get('end') from control.base import control_session from control.models import Tenant, TenantAudit with control_session() as s: t = s.query(Tenant).filter_by(stripe_customer_id=customer_id).first() if t is None: return if t.subscription_status == 'past_due': t.subscription_status = 'active' t.past_due_since = None t.dunning_stage = 0 t.dunning_sent_at = None if period_end: t.current_period_end = _ts_to_dt(period_end) s.add(TenantAudit( action='billing_payment_recovered', tenant_id=t.id, details=f'customer={customer_id}', )) logger.info('BILLING | payment_succeeded_recovered | tenant_id=%s', t.id) def _on_payment_failed(obj): """invoice.payment_failed — mark past_due and send dunning email.""" customer_id = obj.get('customer') from control.base import control_session from control.models import Tenant, TenantAudit from app.billing.emails import send_billing_email with control_session() as s: t = s.query(Tenant).filter_by(stripe_customer_id=customer_id).first() if t is None: logger.warning('BILLING | payment_failed_no_tenant | customer=%s', customer_id) return t.subscription_status = 'past_due' if t.past_due_since is None: from control.time_utils import now_eastern t.past_due_since = now_eastern() billing_email = t.billing_email s.add(TenantAudit( action='billing_payment_failed', tenant_id=t.id, details=f'customer={customer_id}', )) logger.info('BILLING | payment_failed | tenant_id=%s', t.id) if billing_email: base_url = _get_base_url() send_billing_email(billing_email, 'payment_failed', { 'portal_url': f'{base_url}/billing/portal', }) def _on_trial_will_end(obj): """customer.subscription.trial_will_end — sent 3 days before trial ends.""" customer_id = obj.get('customer') trial_end_ts = obj.get('trial_end') from control.base import control_session from control.models import Tenant from app.billing.emails import send_billing_email with control_session() as s: t = s.query(Tenant).filter_by(stripe_customer_id=customer_id).first() if t is None: return billing_email = t.billing_email if billing_email: trial_dt = _ts_to_dt(trial_end_ts) trial_str = trial_dt.strftime('%B %d, %Y') if trial_dt else 'soon' base_url = _get_base_url() send_billing_email(billing_email, 'trial_ending', { 'trial_ends_at': trial_str, 'subscribe_url': f'{base_url}/billing/subscribe', }) logger.info('BILLING | trial_will_end_email_sent | customer=%s', customer_id)