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:
2025-12-31 18:28:40 +01:00
parent b61255f0c3
commit 7d1a421826
20 changed files with 3786 additions and 10 deletions

View File

@@ -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."""