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>
This commit is contained in:
@@ -6,6 +6,7 @@ Processes webhook events from Stripe:
|
||||
- Subscription lifecycle events
|
||||
- Invoice and payment events
|
||||
- Checkout session completion
|
||||
- Add-on purchases
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -15,10 +16,12 @@ import stripe
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.database.subscription import (
|
||||
AddOnProduct,
|
||||
BillingHistory,
|
||||
StripeWebhookEvent,
|
||||
SubscriptionStatus,
|
||||
SubscriptionTier,
|
||||
VendorAddOn,
|
||||
VendorSubscription,
|
||||
)
|
||||
|
||||
@@ -108,15 +111,34 @@ class StripeWebhookHandler:
|
||||
def _handle_checkout_completed(
|
||||
self, db: Session, event: stripe.Event
|
||||
) -> dict:
|
||||
"""Handle checkout.session.completed event."""
|
||||
"""
|
||||
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)
|
||||
@@ -147,9 +169,112 @@ class StripeWebhookHandler:
|
||||
stripe_sub.trial_end, tz=timezone.utc
|
||||
)
|
||||
|
||||
logger.info(f"Checkout completed for vendor {vendor_id}")
|
||||
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:
|
||||
@@ -240,7 +365,11 @@ class StripeWebhookHandler:
|
||||
def _handle_subscription_deleted(
|
||||
self, db: Session, event: stripe.Event
|
||||
) -> dict:
|
||||
"""Handle customer.subscription.deleted event."""
|
||||
"""
|
||||
Handle customer.subscription.deleted event.
|
||||
|
||||
Cancels the subscription and all associated add-ons.
|
||||
"""
|
||||
stripe_sub = event.data.object
|
||||
|
||||
subscription = (
|
||||
@@ -253,11 +382,37 @@ class StripeWebhookHandler:
|
||||
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)
|
||||
|
||||
logger.info(f"Subscription deleted for vendor {subscription.vendor_id}")
|
||||
return {"action": "cancelled", "vendor_id": subscription.vendor_id}
|
||||
# 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."""
|
||||
|
||||
Reference in New Issue
Block a user