# app/handlers/stripe_webhook.py """ Stripe webhook event handler. Processes webhook events from Stripe: - Subscription lifecycle events - Invoice and payment events - Checkout session completion - Add-on purchases """ import logging from datetime import UTC, datetime import stripe from sqlalchemy.orm import Session from app.modules.billing.models import ( AddOnProduct, BillingHistory, MerchantSubscription, StoreAddOn, StripeWebhookEvent, SubscriptionStatus, SubscriptionTier, ) from app.modules.tenancy.models import Store, StorePlatform logger = logging.getLogger(__name__) class StripeWebhookHandler: """Handler for Stripe webhook events.""" def __init__(self): self.handlers = { "checkout.session.completed": self._handle_checkout_completed, "customer.subscription.created": self._handle_subscription_created, "customer.subscription.updated": self._handle_subscription_updated, "customer.subscription.deleted": self._handle_subscription_deleted, "invoice.paid": self._handle_invoice_paid, "invoice.payment_failed": self._handle_payment_failed, "invoice.finalized": self._handle_invoice_finalized, } def handle_event(self, db: Session, event: stripe.Event) -> dict: """ Process a Stripe webhook event. Args: db: Database session event: Stripe Event object Returns: Dict with processing result """ event_id = event.id event_type = event.type # Check for duplicate processing (idempotency) existing = ( db.query(StripeWebhookEvent) .filter(StripeWebhookEvent.event_id == event_id) .first() ) if existing: if existing.status == "processed": logger.info(f"Skipping duplicate event {event_id}") return {"status": "skipped", "reason": "duplicate"} if existing.status == "failed": logger.info(f"Retrying previously failed event {event_id}") else: # Record the event webhook_event = StripeWebhookEvent( event_id=event_id, event_type=event_type, status="pending", ) db.add(webhook_event) db.flush() existing = webhook_event # Process the event handler = self.handlers.get(event_type) if not handler: logger.debug(f"No handler for event type {event_type}") existing.status = "processed" existing.processed_at = datetime.now(UTC) db.commit() return {"status": "ignored", "reason": f"no handler for {event_type}"} try: result = handler(db, event) existing.status = "processed" existing.processed_at = datetime.now(UTC) db.commit() logger.info(f"Successfully processed event {event_id} ({event_type})") return {"status": "processed", "result": result} except Exception as e: logger.error(f"Error processing event {event_id}: {e}") existing.status = "failed" existing.error_message = str(e) db.commit() raise # ========================================================================= # Event Handlers # ========================================================================= def _handle_checkout_completed( self, db: Session, event: stripe.Event ) -> dict: """ Handle checkout.session.completed event. Handles two types of checkouts: 1. Subscription checkout - Updates MerchantSubscription 2. Add-on checkout - Creates StoreAddOn record """ session = event.data.object store_id = session.metadata.get("store_id") addon_code = session.metadata.get("addon_code") if not store_id: logger.warning(f"Checkout session {session.id} missing store_id") return {"action": "skipped", "reason": "no store_id"} store_id = int(store_id) # Check if this is an add-on purchase if addon_code: return self._handle_addon_checkout(db, session, store_id, addon_code) # Otherwise, handle subscription checkout return self._handle_subscription_checkout(db, session, store_id) def _handle_subscription_checkout( self, db: Session, session, store_id: int ) -> dict: """Handle subscription checkout completion.""" # Resolve store_id to merchant_id and platform_id store = db.query(Store).filter(Store.id == store_id).first() if not store: logger.warning(f"No store found for store_id {store_id}") return {"action": "skipped", "reason": "no store"} merchant_id = store.merchant_id sp = ( db.query(StorePlatform.platform_id) .filter(StorePlatform.store_id == store_id) .first() ) if not sp: logger.warning(f"No platform found for store {store_id}") return {"action": "skipped", "reason": "no platform"} platform_id = sp[0] subscription = ( db.query(MerchantSubscription) .filter( MerchantSubscription.merchant_id == merchant_id, MerchantSubscription.platform_id == platform_id, ) .first() ) if not subscription: logger.warning(f"No subscription found for merchant {merchant_id}") return {"action": "skipped", "reason": "no subscription"} # Update subscription with Stripe IDs subscription.stripe_customer_id = session.customer subscription.stripe_subscription_id = session.subscription subscription.status = SubscriptionStatus.ACTIVE.value # Get subscription details to set period dates if session.subscription: stripe_sub = stripe.Subscription.retrieve(session.subscription) subscription.period_start = datetime.fromtimestamp( stripe_sub.current_period_start, tz=UTC ) subscription.period_end = datetime.fromtimestamp( stripe_sub.current_period_end, tz=UTC ) if stripe_sub.trial_end: subscription.trial_ends_at = datetime.fromtimestamp( stripe_sub.trial_end, tz=UTC ) logger.info(f"Subscription checkout completed for merchant {merchant_id}") return {"action": "activated", "merchant_id": merchant_id} def _handle_addon_checkout( self, db: Session, session, store_id: int, addon_code: str ) -> dict: """ Handle add-on checkout completion. Creates a StoreAddOn record for the purchased add-on. """ # Get the add-on product addon_product = ( db.query(AddOnProduct) .filter(AddOnProduct.code == addon_code) .first() ) if not addon_product: logger.error(f"Add-on product '{addon_code}' not found") return {"action": "failed", "reason": f"addon '{addon_code}' not found"} # Check if store already has this add-on active existing_addon = ( db.query(StoreAddOn) .filter( StoreAddOn.store_id == store_id, StoreAddOn.addon_product_id == addon_product.id, StoreAddOn.status == "active", ) .first() ) if existing_addon: logger.info( f"Store {store_id} already has active add-on {addon_code}, " f"updating quantity" ) # For quantity-based add-ons, we could increment # For now, just log and return return { "action": "already_exists", "store_id": store_id, "addon_code": addon_code, } # Get domain name from metadata (for domain add-ons) domain_name = session.metadata.get("domain_name") if domain_name == "": domain_name = None # Get subscription item ID from Stripe subscription stripe_subscription_item_id = None if session.subscription: try: stripe_sub = stripe.Subscription.retrieve(session.subscription) if stripe_sub.items.data: # Find the item matching our add-on price for item in stripe_sub.items.data: if item.price.id == addon_product.stripe_price_id: stripe_subscription_item_id = item.id break except Exception as e: logger.warning(f"Could not retrieve subscription items: {e}") # Get period dates from subscription period_start = None period_end = None if session.subscription: try: stripe_sub = stripe.Subscription.retrieve(session.subscription) period_start = datetime.fromtimestamp( stripe_sub.current_period_start, tz=UTC ) period_end = datetime.fromtimestamp( stripe_sub.current_period_end, tz=UTC ) except Exception as e: logger.warning(f"Could not retrieve subscription period: {e}") # Create StoreAddOn record store_addon = StoreAddOn( store_id=store_id, addon_product_id=addon_product.id, status="active", domain_name=domain_name, quantity=1, # Default quantity, could be from session line items stripe_subscription_item_id=stripe_subscription_item_id, period_start=period_start, period_end=period_end, ) db.add(store_addon) logger.info( f"Add-on '{addon_code}' purchased by store {store_id}" + (f" for domain {domain_name}" if domain_name else "") ) return { "action": "addon_created", "store_id": store_id, "addon_code": addon_code, "addon_id": store_addon.id, "domain_name": domain_name, } def _handle_subscription_created( self, db: Session, event: stripe.Event ) -> dict: """Handle customer.subscription.created event.""" stripe_sub = event.data.object customer_id = stripe_sub.customer # Find subscription by customer ID subscription = ( db.query(MerchantSubscription) .filter(MerchantSubscription.stripe_customer_id == customer_id) .first() ) if not subscription: logger.warning(f"No subscription found for customer {customer_id}") return {"action": "skipped", "reason": "no subscription"} # Update subscription subscription.stripe_subscription_id = stripe_sub.id subscription.status = self._map_stripe_status(stripe_sub.status) subscription.period_start = datetime.fromtimestamp( stripe_sub.current_period_start, tz=UTC ) subscription.period_end = datetime.fromtimestamp( stripe_sub.current_period_end, tz=UTC ) logger.info(f"Subscription created for merchant {subscription.merchant_id}") return {"action": "created", "merchant_id": subscription.merchant_id} def _handle_subscription_updated( self, db: Session, event: stripe.Event ) -> dict: """Handle customer.subscription.updated event.""" stripe_sub = event.data.object subscription = ( db.query(MerchantSubscription) .filter(MerchantSubscription.stripe_subscription_id == stripe_sub.id) .first() ) if not subscription: logger.warning(f"No subscription found for {stripe_sub.id}") return {"action": "skipped", "reason": "no subscription"} # Update status and period subscription.status = self._map_stripe_status(stripe_sub.status) subscription.period_start = datetime.fromtimestamp( stripe_sub.current_period_start, tz=UTC ) subscription.period_end = datetime.fromtimestamp( stripe_sub.current_period_end, tz=UTC ) # Handle cancellation if stripe_sub.cancel_at_period_end: subscription.cancelled_at = datetime.now(UTC) subscription.cancellation_reason = stripe_sub.metadata.get( "cancellation_reason", "user_request" ) elif subscription.cancelled_at and not stripe_sub.cancel_at_period_end: # Subscription reactivated subscription.cancelled_at = None subscription.cancellation_reason = None # Check for tier change via price if stripe_sub.items.data: new_price_id = stripe_sub.items.data[0].price.id # Look up new tier by Stripe price ID tier = ( db.query(SubscriptionTier) .filter(SubscriptionTier.stripe_price_monthly_id == new_price_id) .first() ) if tier: subscription.tier_id = tier.id logger.info( f"Tier changed to {tier.code} for merchant {subscription.merchant_id}" ) # Sync store_platforms based on subscription status from app.modules.billing.services.store_platform_sync_service import ( store_platform_sync, ) active_statuses = { SubscriptionStatus.TRIAL.value, SubscriptionStatus.ACTIVE.value, SubscriptionStatus.PAST_DUE.value, SubscriptionStatus.CANCELLED.value, } store_platform_sync.sync_store_platforms_for_merchant( db, subscription.merchant_id, subscription.platform_id, is_active=subscription.status in active_statuses, ) logger.info(f"Subscription updated for merchant {subscription.merchant_id}") return {"action": "updated", "merchant_id": subscription.merchant_id} def _handle_subscription_deleted( self, db: Session, event: stripe.Event ) -> dict: """ Handle customer.subscription.deleted event. Cancels the subscription and all associated add-ons for the merchant's stores. """ stripe_sub = event.data.object subscription = ( db.query(MerchantSubscription) .filter(MerchantSubscription.stripe_subscription_id == stripe_sub.id) .first() ) if not subscription: logger.warning(f"No subscription found for {stripe_sub.id}") return {"action": "skipped", "reason": "no subscription"} merchant_id = subscription.merchant_id # Cancel the subscription subscription.status = SubscriptionStatus.CANCELLED.value subscription.cancelled_at = datetime.now(UTC) # Find all stores for this merchant, then cancel their add-ons store_ids = [ s.id for s in db.query(Store.id) .filter(Store.merchant_id == merchant_id) .all() ] cancelled_addons = ( db.query(StoreAddOn) .filter( StoreAddOn.store_id.in_(store_ids), StoreAddOn.status == "active", ) .all() ) addon_count = 0 for addon in cancelled_addons: addon.status = "cancelled" addon.cancelled_at = datetime.now(UTC) addon_count += 1 if addon_count > 0: logger.info(f"Cancelled {addon_count} add-ons for merchant {merchant_id}") # Deactivate store_platforms for the deleted subscription's platform from app.modules.billing.services.store_platform_sync_service import ( store_platform_sync, ) store_platform_sync.sync_store_platforms_for_merchant( db, merchant_id, subscription.platform_id, is_active=False ) logger.info(f"Subscription deleted for merchant {merchant_id}") return { "action": "cancelled", "merchant_id": merchant_id, "addons_cancelled": addon_count, } def _handle_invoice_paid(self, db: Session, event: stripe.Event) -> dict: """Handle invoice.paid event.""" invoice = event.data.object customer_id = invoice.customer subscription = ( db.query(MerchantSubscription) .filter(MerchantSubscription.stripe_customer_id == customer_id) .first() ) if not subscription: logger.warning(f"No subscription found for customer {customer_id}") return {"action": "skipped", "reason": "no subscription"} # Record billing history billing_record = BillingHistory( merchant_id=subscription.merchant_id, stripe_invoice_id=invoice.id, stripe_payment_intent_id=invoice.payment_intent, invoice_number=invoice.number, invoice_date=datetime.fromtimestamp(invoice.created, tz=UTC), subtotal_cents=invoice.subtotal, tax_cents=invoice.tax or 0, total_cents=invoice.total, amount_paid_cents=invoice.amount_paid, currency=invoice.currency.upper(), status="paid", invoice_pdf_url=invoice.invoice_pdf, hosted_invoice_url=invoice.hosted_invoice_url, ) db.add(billing_record) # Reset payment retry count on successful payment subscription.payment_retry_count = 0 subscription.last_payment_error = None logger.info(f"Invoice paid for merchant {subscription.merchant_id}") return { "action": "recorded", "merchant_id": subscription.merchant_id, "invoice_id": invoice.id, } def _handle_payment_failed(self, db: Session, event: stripe.Event) -> dict: """Handle invoice.payment_failed event.""" invoice = event.data.object customer_id = invoice.customer subscription = ( db.query(MerchantSubscription) .filter(MerchantSubscription.stripe_customer_id == customer_id) .first() ) if not subscription: logger.warning(f"No subscription found for customer {customer_id}") return {"action": "skipped", "reason": "no subscription"} # Update subscription status subscription.status = SubscriptionStatus.PAST_DUE.value subscription.payment_retry_count = (subscription.payment_retry_count or 0) + 1 # Store error message if invoice.last_payment_error: subscription.last_payment_error = invoice.last_payment_error.get("message") logger.warning( f"Payment failed for merchant {subscription.merchant_id} " f"(retry #{subscription.payment_retry_count})" ) return { "action": "marked_past_due", "merchant_id": subscription.merchant_id, "retry_count": subscription.payment_retry_count, } def _handle_invoice_finalized( self, db: Session, event: stripe.Event ) -> dict: """Handle invoice.finalized event.""" invoice = event.data.object customer_id = invoice.customer subscription = ( db.query(MerchantSubscription) .filter(MerchantSubscription.stripe_customer_id == customer_id) .first() ) if not subscription: return {"action": "skipped", "reason": "no subscription"} # Check if we already have this invoice existing = ( db.query(BillingHistory) .filter(BillingHistory.stripe_invoice_id == invoice.id) .first() ) if existing: return {"action": "skipped", "reason": "already recorded"} # Record as pending invoice billing_record = BillingHistory( merchant_id=subscription.merchant_id, stripe_invoice_id=invoice.id, invoice_number=invoice.number, invoice_date=datetime.fromtimestamp(invoice.created, tz=UTC), due_date=datetime.fromtimestamp(invoice.due_date, tz=UTC) if invoice.due_date else None, subtotal_cents=invoice.subtotal, tax_cents=invoice.tax or 0, total_cents=invoice.total, amount_paid_cents=0, currency=invoice.currency.upper(), status="open", invoice_pdf_url=invoice.invoice_pdf, hosted_invoice_url=invoice.hosted_invoice_url, ) db.add(billing_record) return {"action": "recorded_pending", "merchant_id": subscription.merchant_id} # ========================================================================= # Helpers # ========================================================================= def _map_stripe_status(self, stripe_status: str) -> str: """Map Stripe subscription status to internal status string.""" status_map = { "active": SubscriptionStatus.ACTIVE.value, "trialing": SubscriptionStatus.TRIAL.value, "past_due": SubscriptionStatus.PAST_DUE.value, "canceled": SubscriptionStatus.CANCELLED.value, "unpaid": SubscriptionStatus.PAST_DUE.value, "incomplete": SubscriptionStatus.TRIAL.value, # Treat as trial until complete "incomplete_expired": SubscriptionStatus.EXPIRED.value, } return status_map.get(stripe_status, SubscriptionStatus.EXPIRED.value) # Create handler instance stripe_webhook_handler = StripeWebhookHandler()