286 lines
10 KiB
Python
286 lines
10 KiB
Python
"""
|
|
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)
|