Overhaul storefront URL routing to be platform-aware:
- Dev: /platforms/{code}/storefront/{store_code}/
- Prod: subdomain.platform.lu/ (internally rewritten to /storefront/)
- Add subdomain detection in PlatformContextMiddleware
- Add /storefront/ path rewrite for prod mode (subdomain/custom domain)
- Remove all silent platform fallbacks (platform_id=1)
- Add require_platform dependency for clean endpoint validation
- Update route registration, templates, module definitions, base_url calc
- Update StoreContextMiddleware for /storefront/ path detection
- Remove /stores/ from FrontendDetector STOREFRONT_PATH_PREFIXES
Billing service improvements:
- Add store_platform_sync_service to keep store_platforms in sync
- Make tier lookups platform-aware across billing services
- Add tiers for all platforms in seed data
- Add demo subscriptions to seed
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
617 lines
22 KiB
Python
617 lines
22 KiB
Python
# 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()
|