Files
orion/app/handlers/stripe_webhook.py
Samir Boulahtit 7d1a421826 feat: add comprehensive tier-based feature management system
Implement database-driven feature gating with contextual upgrade prompts:

- Add Feature model with 30 features across 8 categories
- Create FeatureService with caching for tier-based feature checking
- Add @require_feature decorator and RequireFeature dependency for backend enforcement
- Create vendor features API (6 endpoints) and admin features API
- Add Alpine.js feature store and upgrade prompts store for frontend
- Create Jinja macros: feature_gate, feature_locked, limit_warning, usage_bar
- Add usage API for tracking orders/products/team limits with upgrade info
- Fix Stripe webhook to create VendorAddOn records on addon purchase
- Integrate upgrade prompts into vendor dashboard with tier badge and usage bars

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 18:28:40 +01:00

567 lines
20 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 datetime, timezone
import stripe
from sqlalchemy.orm import Session
from models.database.subscription import (
AddOnProduct,
BillingHistory,
StripeWebhookEvent,
SubscriptionStatus,
SubscriptionTier,
VendorAddOn,
VendorSubscription,
)
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"}
elif 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(timezone.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(timezone.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 VendorSubscription
2. Add-on checkout - Creates VendorAddOn record
"""
session = event.data.object
vendor_id = session.metadata.get("vendor_id")
addon_code = session.metadata.get("addon_code")
if not vendor_id:
logger.warning(f"Checkout session {session.id} missing vendor_id")
return {"action": "skipped", "reason": "no vendor_id"}
vendor_id = int(vendor_id)
# Check if this is an add-on purchase
if addon_code:
return self._handle_addon_checkout(db, session, vendor_id, addon_code)
# Otherwise, handle subscription checkout
return self._handle_subscription_checkout(db, session, vendor_id)
def _handle_subscription_checkout(
self, db: Session, session, vendor_id: int
) -> dict:
"""Handle subscription checkout completion."""
subscription = (
db.query(VendorSubscription)
.filter(VendorSubscription.vendor_id == vendor_id)
.first()
)
if not subscription:
logger.warning(f"No subscription found for vendor {vendor_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
# 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=timezone.utc
)
subscription.period_end = datetime.fromtimestamp(
stripe_sub.current_period_end, tz=timezone.utc
)
if stripe_sub.trial_end:
subscription.trial_ends_at = datetime.fromtimestamp(
stripe_sub.trial_end, tz=timezone.utc
)
logger.info(f"Subscription checkout completed for vendor {vendor_id}")
return {"action": "activated", "vendor_id": vendor_id}
def _handle_addon_checkout(
self, db: Session, session, vendor_id: int, addon_code: str
) -> dict:
"""
Handle add-on checkout completion.
Creates a VendorAddOn 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 vendor already has this add-on active
existing_addon = (
db.query(VendorAddOn)
.filter(
VendorAddOn.vendor_id == vendor_id,
VendorAddOn.addon_product_id == addon_product.id,
VendorAddOn.status == "active",
)
.first()
)
if existing_addon:
logger.info(
f"Vendor {vendor_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",
"vendor_id": vendor_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=timezone.utc
)
period_end = datetime.fromtimestamp(
stripe_sub.current_period_end, tz=timezone.utc
)
except Exception as e:
logger.warning(f"Could not retrieve subscription period: {e}")
# Create VendorAddOn record
vendor_addon = VendorAddOn(
vendor_id=vendor_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(vendor_addon)
logger.info(
f"Add-on '{addon_code}' purchased by vendor {vendor_id}"
+ (f" for domain {domain_name}" if domain_name else "")
)
return {
"action": "addon_created",
"vendor_id": vendor_id,
"addon_code": addon_code,
"addon_id": vendor_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(VendorSubscription)
.filter(VendorSubscription.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=timezone.utc
)
subscription.period_end = datetime.fromtimestamp(
stripe_sub.current_period_end, tz=timezone.utc
)
logger.info(f"Subscription created for vendor {subscription.vendor_id}")
return {"action": "created", "vendor_id": subscription.vendor_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(VendorSubscription)
.filter(VendorSubscription.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=timezone.utc
)
subscription.period_end = datetime.fromtimestamp(
stripe_sub.current_period_end, tz=timezone.utc
)
# Handle cancellation
if stripe_sub.cancel_at_period_end:
subscription.cancelled_at = datetime.now(timezone.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
if subscription.stripe_price_id != new_price_id:
# Price changed, look up new tier
tier = (
db.query(SubscriptionTier)
.filter(SubscriptionTier.stripe_price_monthly_id == new_price_id)
.first()
)
if tier:
subscription.tier = tier.code
logger.info(
f"Tier changed to {tier.code} for vendor {subscription.vendor_id}"
)
subscription.stripe_price_id = new_price_id
logger.info(f"Subscription updated for vendor {subscription.vendor_id}")
return {"action": "updated", "vendor_id": subscription.vendor_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.
"""
stripe_sub = event.data.object
subscription = (
db.query(VendorSubscription)
.filter(VendorSubscription.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"}
vendor_id = subscription.vendor_id
# Cancel the subscription
subscription.status = SubscriptionStatus.CANCELLED
subscription.cancelled_at = datetime.now(timezone.utc)
# Also cancel all active add-ons for this vendor
cancelled_addons = (
db.query(VendorAddOn)
.filter(
VendorAddOn.vendor_id == vendor_id,
VendorAddOn.status == "active",
)
.all()
)
addon_count = 0
for addon in cancelled_addons:
addon.status = "cancelled"
addon.cancelled_at = datetime.now(timezone.utc)
addon_count += 1
if addon_count > 0:
logger.info(f"Cancelled {addon_count} add-ons for vendor {vendor_id}")
logger.info(f"Subscription deleted for vendor {vendor_id}")
return {
"action": "cancelled",
"vendor_id": vendor_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(VendorSubscription)
.filter(VendorSubscription.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(
vendor_id=subscription.vendor_id,
stripe_invoice_id=invoice.id,
stripe_payment_intent_id=invoice.payment_intent,
invoice_number=invoice.number,
invoice_date=datetime.fromtimestamp(invoice.created, tz=timezone.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
# Reset period counters if this is a new billing cycle
if subscription.status == SubscriptionStatus.ACTIVE:
subscription.orders_this_period = 0
subscription.orders_limit_reached_at = None
logger.info(f"Invoice paid for vendor {subscription.vendor_id}")
return {
"action": "recorded",
"vendor_id": subscription.vendor_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(VendorSubscription)
.filter(VendorSubscription.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
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 vendor {subscription.vendor_id} "
f"(retry #{subscription.payment_retry_count})"
)
return {
"action": "marked_past_due",
"vendor_id": subscription.vendor_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(VendorSubscription)
.filter(VendorSubscription.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(
vendor_id=subscription.vendor_id,
stripe_invoice_id=invoice.id,
invoice_number=invoice.number,
invoice_date=datetime.fromtimestamp(invoice.created, tz=timezone.utc),
due_date=datetime.fromtimestamp(invoice.due_date, tz=timezone.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", "vendor_id": subscription.vendor_id}
# =========================================================================
# Helpers
# =========================================================================
def _map_stripe_status(self, stripe_status: str) -> SubscriptionStatus:
"""Map Stripe subscription status to internal status."""
status_map = {
"active": SubscriptionStatus.ACTIVE,
"trialing": SubscriptionStatus.TRIAL,
"past_due": SubscriptionStatus.PAST_DUE,
"canceled": SubscriptionStatus.CANCELLED,
"unpaid": SubscriptionStatus.PAST_DUE,
"incomplete": SubscriptionStatus.TRIAL, # Treat as trial until complete
"incomplete_expired": SubscriptionStatus.EXPIRED,
}
return status_map.get(stripe_status, SubscriptionStatus.EXPIRED)
# Create handler instance
stripe_webhook_handler = StripeWebhookHandler()