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

@@ -0,0 +1,292 @@
"""add features table and seed data
Revision ID: n2c3d4e5f6a7
Revises: ba2c0ce78396
Create Date: 2025-12-31 10:00:00.000000
"""
import json
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "n2c3d4e5f6a7"
down_revision: Union[str, None] = "ba2c0ce78396"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# ============================================================================
# Feature Definitions
# ============================================================================
# category, code, name, description, ui_location, ui_icon, ui_route, display_order
FEATURES = [
# Orders (category: orders)
("orders", "order_management", "Order Management", "View and manage orders", "sidebar", "clipboard-list", "/vendor/{code}/orders", 1),
("orders", "order_bulk_actions", "Bulk Order Actions", "Process multiple orders at once", "inline", None, None, 2),
("orders", "order_export", "Order Export", "Export orders to CSV/Excel", "inline", "download", None, 3),
("orders", "automation_rules", "Automation Rules", "Automatic order processing rules", "sidebar", "cog", "/vendor/{code}/automation", 4),
# Inventory (category: inventory)
("inventory", "inventory_basic", "Basic Inventory", "Track product stock levels", "sidebar", "cube", "/vendor/{code}/inventory", 1),
("inventory", "inventory_locations", "Warehouse Locations", "Manage multiple warehouse locations", "inline", "map-pin", None, 2),
("inventory", "inventory_purchase_orders", "Purchase Orders", "Create and manage purchase orders", "sidebar", "shopping-cart", "/vendor/{code}/purchase-orders", 3),
("inventory", "low_stock_alerts", "Low Stock Alerts", "Get notified when stock is low", "inline", "bell", None, 4),
# Analytics (category: analytics)
("analytics", "basic_reports", "Basic Reports", "Essential sales and order reports", "sidebar", "chart-pie", "/vendor/{code}/reports", 1),
("analytics", "analytics_dashboard", "Analytics Dashboard", "Advanced analytics with charts and trends", "sidebar", "chart-bar", "/vendor/{code}/analytics", 2),
("analytics", "custom_reports", "Custom Reports", "Build custom report configurations", "inline", "document-report", None, 3),
("analytics", "export_reports", "Export Reports", "Export reports to various formats", "inline", "download", None, 4),
# Invoicing (category: invoicing)
("invoicing", "invoice_lu", "Luxembourg Invoicing", "Generate compliant Luxembourg invoices", "sidebar", "document-text", "/vendor/{code}/invoices", 1),
("invoicing", "invoice_eu_vat", "EU VAT Support", "Handle EU VAT for cross-border sales", "inline", "globe", None, 2),
("invoicing", "invoice_bulk", "Bulk Invoicing", "Generate invoices in bulk", "inline", "document-duplicate", None, 3),
("invoicing", "accounting_export", "Accounting Export", "Export to accounting software formats", "inline", "calculator", None, 4),
# Integrations (category: integrations)
("integrations", "letzshop_sync", "Letzshop Sync", "Sync orders and products with Letzshop", "settings", "refresh", None, 1),
("integrations", "api_access", "API Access", "REST API access for custom integrations", "settings", "code", "/vendor/{code}/settings/api", 2),
("integrations", "webhooks", "Webhooks", "Receive real-time event notifications", "settings", "lightning-bolt", "/vendor/{code}/settings/webhooks", 3),
("integrations", "custom_integrations", "Custom Integrations", "Connect with any third-party service", "settings", "puzzle", None, 4),
# Team (category: team)
("team", "single_user", "Single User", "One user account", "api", None, None, 1),
("team", "team_basic", "Team Access", "Invite team members", "sidebar", "users", "/vendor/{code}/team", 2),
("team", "team_roles", "Team Roles", "Role-based permissions for team members", "inline", "shield-check", None, 3),
("team", "audit_log", "Audit Log", "Track all user actions", "sidebar", "clipboard-check", "/vendor/{code}/audit-log", 4),
# Branding (category: branding)
("branding", "basic_shop", "Basic Shop", "Your shop on the platform", "api", None, None, 1),
("branding", "custom_domain", "Custom Domain", "Use your own domain name", "settings", "globe-alt", None, 2),
("branding", "white_label", "White Label", "Remove platform branding entirely", "settings", "color-swatch", None, 3),
# Customers (category: customers)
("customers", "customer_view", "Customer View", "View customer information", "sidebar", "user-group", "/vendor/{code}/customers", 1),
("customers", "customer_export", "Customer Export", "Export customer data", "inline", "download", None, 2),
("customers", "customer_messaging", "Customer Messaging", "Send messages to customers", "inline", "chat", None, 3),
]
# ============================================================================
# Tier Feature Assignments
# ============================================================================
# tier_code -> list of feature codes
TIER_FEATURES = {
"essential": [
"order_management",
"inventory_basic",
"basic_reports",
"invoice_lu",
"letzshop_sync",
"single_user",
"basic_shop",
"customer_view",
],
"professional": [
# All Essential features
"order_management",
"order_bulk_actions",
"order_export",
"inventory_basic",
"inventory_locations",
"inventory_purchase_orders",
"low_stock_alerts",
"basic_reports",
"invoice_lu",
"invoice_eu_vat",
"letzshop_sync",
"team_basic",
"basic_shop",
"customer_view",
"customer_export",
],
"business": [
# All Professional features
"order_management",
"order_bulk_actions",
"order_export",
"automation_rules",
"inventory_basic",
"inventory_locations",
"inventory_purchase_orders",
"low_stock_alerts",
"basic_reports",
"analytics_dashboard",
"custom_reports",
"export_reports",
"invoice_lu",
"invoice_eu_vat",
"invoice_bulk",
"accounting_export",
"letzshop_sync",
"api_access",
"webhooks",
"team_basic",
"team_roles",
"audit_log",
"basic_shop",
"custom_domain",
"customer_view",
"customer_export",
"customer_messaging",
],
"enterprise": [
# All features
"order_management",
"order_bulk_actions",
"order_export",
"automation_rules",
"inventory_basic",
"inventory_locations",
"inventory_purchase_orders",
"low_stock_alerts",
"basic_reports",
"analytics_dashboard",
"custom_reports",
"export_reports",
"invoice_lu",
"invoice_eu_vat",
"invoice_bulk",
"accounting_export",
"letzshop_sync",
"api_access",
"webhooks",
"custom_integrations",
"team_basic",
"team_roles",
"audit_log",
"basic_shop",
"custom_domain",
"white_label",
"customer_view",
"customer_export",
"customer_messaging",
],
}
# Minimum tier for each feature (for upgrade prompts)
# Maps feature_code -> tier_code
MINIMUM_TIER = {
# Essential
"order_management": "essential",
"inventory_basic": "essential",
"basic_reports": "essential",
"invoice_lu": "essential",
"letzshop_sync": "essential",
"single_user": "essential",
"basic_shop": "essential",
"customer_view": "essential",
# Professional
"order_bulk_actions": "professional",
"order_export": "professional",
"inventory_locations": "professional",
"inventory_purchase_orders": "professional",
"low_stock_alerts": "professional",
"invoice_eu_vat": "professional",
"team_basic": "professional",
"customer_export": "professional",
# Business
"automation_rules": "business",
"analytics_dashboard": "business",
"custom_reports": "business",
"export_reports": "business",
"invoice_bulk": "business",
"accounting_export": "business",
"api_access": "business",
"webhooks": "business",
"team_roles": "business",
"audit_log": "business",
"custom_domain": "business",
"customer_messaging": "business",
# Enterprise
"custom_integrations": "enterprise",
"white_label": "enterprise",
}
def upgrade() -> None:
# Create features table
op.create_table(
"features",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("code", sa.String(50), nullable=False),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("category", sa.String(50), nullable=False),
sa.Column("ui_location", sa.String(50), nullable=True),
sa.Column("ui_icon", sa.String(50), nullable=True),
sa.Column("ui_route", sa.String(100), nullable=True),
sa.Column("ui_badge_text", sa.String(20), nullable=True),
sa.Column("minimum_tier_id", sa.Integer(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, default=True),
sa.Column("is_visible", sa.Boolean(), nullable=False, default=True),
sa.Column("display_order", sa.Integer(), nullable=False, default=0),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["minimum_tier_id"], ["subscription_tiers.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_features_code", "features", ["code"], unique=True)
op.create_index("ix_features_category", "features", ["category"], unique=False)
op.create_index("idx_feature_category_order", "features", ["category", "display_order"])
op.create_index("idx_feature_active_visible", "features", ["is_active", "is_visible"])
# Get connection for data operations
conn = op.get_bind()
# Get tier IDs
tier_ids = {}
result = conn.execute(sa.text("SELECT id, code FROM subscription_tiers"))
for row in result:
tier_ids[row[1]] = row[0]
# Insert features
now = sa.func.now()
for category, code, name, description, ui_location, ui_icon, ui_route, display_order in FEATURES:
minimum_tier_code = MINIMUM_TIER.get(code)
minimum_tier_id = tier_ids.get(minimum_tier_code) if minimum_tier_code else None
conn.execute(
sa.text("""
INSERT INTO features (code, name, description, category, ui_location, ui_icon, ui_route,
minimum_tier_id, is_active, is_visible, display_order, created_at, updated_at)
VALUES (:code, :name, :description, :category, :ui_location, :ui_icon, :ui_route,
:minimum_tier_id, 1, 1, :display_order, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
"""),
{
"code": code,
"name": name,
"description": description,
"category": category,
"ui_location": ui_location,
"ui_icon": ui_icon,
"ui_route": ui_route,
"minimum_tier_id": minimum_tier_id,
"display_order": display_order,
},
)
# Update subscription_tiers with feature arrays
for tier_code, features in TIER_FEATURES.items():
features_json = json.dumps(features)
conn.execute(
sa.text("UPDATE subscription_tiers SET features = :features WHERE code = :code"),
{"features": features_json, "code": tier_code},
)
def downgrade() -> None:
# Clear features from subscription_tiers
conn = op.get_bind()
conn.execute(sa.text("UPDATE subscription_tiers SET features = '[]'"))
# Drop features table
op.drop_index("idx_feature_active_visible", table_name="features")
op.drop_index("idx_feature_category_order", table_name="features")
op.drop_index("ix_features_category", table_name="features")
op.drop_index("ix_features_code", table_name="features")
op.drop_table("features")

View File

@@ -33,6 +33,7 @@ from . import (
content_pages, content_pages,
customers, customers,
dashboard, dashboard,
features,
images, images,
inventory, inventory,
letzshop, letzshop,
@@ -181,6 +182,9 @@ router.include_router(
# Include subscription management endpoints # Include subscription management endpoints
router.include_router(subscriptions.router, tags=["admin-subscriptions"]) router.include_router(subscriptions.router, tags=["admin-subscriptions"])
# Include feature management endpoints
router.include_router(features.router, tags=["admin-features"])
# ============================================================================ # ============================================================================
# Code Quality & Architecture # Code Quality & Architecture

View File

@@ -0,0 +1,357 @@
# app/api/v1/admin/features.py
"""
Admin feature management endpoints.
Provides endpoints for:
- Listing all features with their tier assignments
- Updating tier feature assignments
- Managing feature metadata
- Viewing feature usage statistics
"""
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.services.feature_service import feature_service
from models.database.feature import Feature
from models.database.subscription import SubscriptionTier
from models.database.user import User
router = APIRouter(prefix="/features")
logger = logging.getLogger(__name__)
# ============================================================================
# Response Schemas
# ============================================================================
class FeatureResponse(BaseModel):
"""Feature information for admin."""
id: int
code: str
name: str
description: str | None = None
category: str
ui_location: str | None = None
ui_icon: str | None = None
ui_route: str | None = None
ui_badge_text: str | None = None
minimum_tier_id: int | None = None
minimum_tier_code: str | None = None
minimum_tier_name: str | None = None
is_active: bool
is_visible: bool
display_order: int
class FeatureListResponse(BaseModel):
"""List of features."""
features: list[FeatureResponse]
total: int
class TierFeaturesResponse(BaseModel):
"""Tier with its features."""
id: int
code: str
name: str
description: str | None = None
features: list[str]
feature_count: int
class TierListWithFeaturesResponse(BaseModel):
"""All tiers with their features."""
tiers: list[TierFeaturesResponse]
class UpdateTierFeaturesRequest(BaseModel):
"""Request to update tier features."""
feature_codes: list[str]
class UpdateFeatureRequest(BaseModel):
"""Request to update feature metadata."""
name: str | None = None
description: str | None = None
category: str | None = None
ui_location: str | None = None
ui_icon: str | None = None
ui_route: str | None = None
ui_badge_text: str | None = None
minimum_tier_code: str | None = None
is_active: bool | None = None
is_visible: bool | None = None
display_order: int | None = None
class CategoryListResponse(BaseModel):
"""List of feature categories."""
categories: list[str]
# ============================================================================
# Endpoints
# ============================================================================
@router.get("", response_model=FeatureListResponse)
def list_features(
category: str | None = Query(None, description="Filter by category"),
active_only: bool = Query(False, description="Only active features"),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""List all features with their tier assignments."""
features = feature_service.get_all_features(
db, category=category, active_only=active_only
)
return FeatureListResponse(
features=[
FeatureResponse(
id=f.id,
code=f.code,
name=f.name,
description=f.description,
category=f.category,
ui_location=f.ui_location,
ui_icon=f.ui_icon,
ui_route=f.ui_route,
ui_badge_text=f.ui_badge_text,
minimum_tier_id=f.minimum_tier_id,
minimum_tier_code=f.minimum_tier.code if f.minimum_tier else None,
minimum_tier_name=f.minimum_tier.name if f.minimum_tier else None,
is_active=f.is_active,
is_visible=f.is_visible,
display_order=f.display_order,
)
for f in features
],
total=len(features),
)
@router.get("/categories", response_model=CategoryListResponse)
def list_categories(
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""List all feature categories."""
categories = feature_service.get_categories(db)
return CategoryListResponse(categories=categories)
@router.get("/tiers", response_model=TierListWithFeaturesResponse)
def list_tiers_with_features(
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""List all tiers with their feature assignments."""
tiers = (
db.query(SubscriptionTier)
.filter(SubscriptionTier.is_active == True) # noqa: E712
.order_by(SubscriptionTier.display_order)
.all()
)
return TierListWithFeaturesResponse(
tiers=[
TierFeaturesResponse(
id=t.id,
code=t.code,
name=t.name,
description=t.description,
features=t.features or [],
feature_count=len(t.features or []),
)
for t in tiers
]
)
@router.get("/{feature_code}", response_model=FeatureResponse)
def get_feature(
feature_code: str,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Get a single feature by code."""
feature = feature_service.get_feature_by_code(db, feature_code)
if not feature:
raise HTTPException(status_code=404, detail=f"Feature '{feature_code}' not found")
return FeatureResponse(
id=feature.id,
code=feature.code,
name=feature.name,
description=feature.description,
category=feature.category,
ui_location=feature.ui_location,
ui_icon=feature.ui_icon,
ui_route=feature.ui_route,
ui_badge_text=feature.ui_badge_text,
minimum_tier_id=feature.minimum_tier_id,
minimum_tier_code=feature.minimum_tier.code if feature.minimum_tier else None,
minimum_tier_name=feature.minimum_tier.name if feature.minimum_tier else None,
is_active=feature.is_active,
is_visible=feature.is_visible,
display_order=feature.display_order,
)
@router.put("/{feature_code}", response_model=FeatureResponse)
def update_feature(
feature_code: str,
request: UpdateFeatureRequest,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Update feature metadata."""
feature = db.query(Feature).filter(Feature.code == feature_code).first()
if not feature:
raise HTTPException(status_code=404, detail=f"Feature '{feature_code}' not found")
# Update fields if provided
if request.name is not None:
feature.name = request.name
if request.description is not None:
feature.description = request.description
if request.category is not None:
feature.category = request.category
if request.ui_location is not None:
feature.ui_location = request.ui_location
if request.ui_icon is not None:
feature.ui_icon = request.ui_icon
if request.ui_route is not None:
feature.ui_route = request.ui_route
if request.ui_badge_text is not None:
feature.ui_badge_text = request.ui_badge_text
if request.is_active is not None:
feature.is_active = request.is_active
if request.is_visible is not None:
feature.is_visible = request.is_visible
if request.display_order is not None:
feature.display_order = request.display_order
# Update minimum tier if provided
if request.minimum_tier_code is not None:
if request.minimum_tier_code == "":
feature.minimum_tier_id = None
else:
tier = (
db.query(SubscriptionTier)
.filter(SubscriptionTier.code == request.minimum_tier_code)
.first()
)
if not tier:
raise HTTPException(
status_code=400,
detail=f"Tier '{request.minimum_tier_code}' not found",
)
feature.minimum_tier_id = tier.id
db.commit()
db.refresh(feature)
logger.info(f"Updated feature {feature_code} by admin {current_user.id}")
return FeatureResponse(
id=feature.id,
code=feature.code,
name=feature.name,
description=feature.description,
category=feature.category,
ui_location=feature.ui_location,
ui_icon=feature.ui_icon,
ui_route=feature.ui_route,
ui_badge_text=feature.ui_badge_text,
minimum_tier_id=feature.minimum_tier_id,
minimum_tier_code=feature.minimum_tier.code if feature.minimum_tier else None,
minimum_tier_name=feature.minimum_tier.name if feature.minimum_tier else None,
is_active=feature.is_active,
is_visible=feature.is_visible,
display_order=feature.display_order,
)
@router.put("/tiers/{tier_code}/features", response_model=TierFeaturesResponse)
def update_tier_features(
tier_code: str,
request: UpdateTierFeaturesRequest,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Update features assigned to a tier."""
try:
tier = feature_service.update_tier_features(db, tier_code, request.feature_codes)
db.commit()
logger.info(
f"Updated tier {tier_code} features to {len(request.feature_codes)} features "
f"by admin {current_user.id}"
)
return TierFeaturesResponse(
id=tier.id,
code=tier.code,
name=tier.name,
description=tier.description,
features=tier.features or [],
feature_count=len(tier.features or []),
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/tiers/{tier_code}/features")
def get_tier_features(
tier_code: str,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Get features assigned to a specific tier."""
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first()
if not tier:
raise HTTPException(status_code=404, detail=f"Tier '{tier_code}' not found")
# Get full feature details for the tier's features
feature_codes = tier.features or []
features = (
db.query(Feature)
.filter(Feature.code.in_(feature_codes))
.order_by(Feature.category, Feature.display_order)
.all()
)
return {
"tier_code": tier.code,
"tier_name": tier.name,
"features": [
{
"code": f.code,
"name": f.name,
"category": f.category,
"description": f.description,
}
for f in features
],
"feature_count": len(features),
}

View File

@@ -20,6 +20,7 @@ from . import (
content_pages, content_pages,
customers, customers,
dashboard, dashboard,
features,
info, info,
inventory, inventory,
invoices, invoices,
@@ -36,6 +37,7 @@ from . import (
profile, profile,
settings, settings,
team, team,
usage,
) )
# Create vendor router # Create vendor router
@@ -77,6 +79,8 @@ router.include_router(notifications.router, tags=["vendor-notifications"])
router.include_router(messages.router, tags=["vendor-messages"]) router.include_router(messages.router, tags=["vendor-messages"])
router.include_router(analytics.router, tags=["vendor-analytics"]) router.include_router(analytics.router, tags=["vendor-analytics"])
router.include_router(billing.router, tags=["vendor-billing"]) router.include_router(billing.router, tags=["vendor-billing"])
router.include_router(features.router, tags=["vendor-features"])
router.include_router(usage.router, tags=["vendor-usage"])
# Content pages management # Content pages management
router.include_router( router.include_router(

View File

@@ -4,6 +4,10 @@ Vendor analytics and reporting endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern). Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The get_current_vendor_api dependency guarantees token_vendor_id is present. The get_current_vendor_api dependency guarantees token_vendor_id is present.
Feature Requirements:
- basic_reports: Basic analytics (Essential tier)
- analytics_dashboard: Advanced analytics (Business tier)
""" """
import logging import logging
@@ -13,7 +17,9 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api from app.api.deps import get_current_vendor_api
from app.core.database import get_db from app.core.database import get_db
from app.core.feature_gate import RequireFeature
from app.services.stats_service import stats_service from app.services.stats_service import stats_service
from models.database.feature import FeatureCode
from models.database.user import User from models.database.user import User
from models.schema.stats import ( from models.schema.stats import (
VendorAnalyticsCatalog, VendorAnalyticsCatalog,
@@ -31,6 +37,7 @@ def get_vendor_analytics(
period: str = Query("30d", description="Time period: 7d, 30d, 90d, 1y"), period: str = Query("30d", description="Time period: 7d, 30d, 90d, 1y"),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: None = Depends(RequireFeature(FeatureCode.BASIC_REPORTS, FeatureCode.ANALYTICS_DASHBOARD)),
): ):
"""Get vendor analytics data for specified time period.""" """Get vendor analytics data for specified time period."""
data = stats_service.get_vendor_analytics(db, current_user.token_vendor_id, period) data = stats_service.get_vendor_analytics(db, current_user.token_vendor_id, period)

340
app/api/v1/vendor/features.py vendored Normal file
View File

@@ -0,0 +1,340 @@
# app/api/v1/vendor/features.py
"""
Vendor features API endpoints.
Provides feature availability information for the frontend to:
- Show/hide UI elements based on tier
- Display upgrade prompts for unavailable features
- Load feature metadata for dynamic rendering
Endpoints:
- GET /features/available - List of feature codes (for quick checks)
- GET /features - Full feature list with availability and metadata
- GET /features/{code} - Single feature details with upgrade info
- GET /features/categories - List feature categories
"""
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.services.feature_service import feature_service
from models.database.user import User
router = APIRouter(prefix="/features")
logger = logging.getLogger(__name__)
# ============================================================================
# Response Schemas
# ============================================================================
class FeatureCodeListResponse(BaseModel):
"""Simple list of available feature codes for quick checks."""
features: list[str]
tier_code: str
tier_name: str
class FeatureResponse(BaseModel):
"""Full feature information."""
code: str
name: str
description: str | None = None
category: str
ui_location: str | None = None
ui_icon: str | None = None
ui_route: str | None = None
ui_badge_text: str | None = None
is_available: bool
minimum_tier_code: str | None = None
minimum_tier_name: str | None = None
class FeatureListResponse(BaseModel):
"""List of features with metadata."""
features: list[FeatureResponse]
available_count: int
total_count: int
tier_code: str
tier_name: str
class FeatureDetailResponse(BaseModel):
"""Single feature detail with upgrade info."""
code: str
name: str
description: str | None = None
category: str
ui_location: str | None = None
ui_icon: str | None = None
ui_route: str | None = None
is_available: bool
# Upgrade info (only if not available)
upgrade_tier_code: str | None = None
upgrade_tier_name: str | None = None
upgrade_tier_price_monthly_cents: int | None = None
class CategoryListResponse(BaseModel):
"""List of feature categories."""
categories: list[str]
class FeatureGroupedResponse(BaseModel):
"""Features grouped by category."""
categories: dict[str, list[FeatureResponse]]
available_count: int
total_count: int
# ============================================================================
# Endpoints
# ============================================================================
@router.get("/available", response_model=FeatureCodeListResponse)
def get_available_features(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get list of feature codes available to vendor.
This is a lightweight endpoint for quick feature checks.
Use this to populate a frontend feature store on app init.
Returns:
List of feature codes the vendor has access to
"""
vendor_id = current_user.token_vendor_id
# Get subscription for tier info
from app.services.subscription_service import subscription_service
subscription = subscription_service.get_or_create_subscription(db, vendor_id)
tier = subscription.tier_obj
# Get available features
feature_codes = feature_service.get_available_feature_codes(db, vendor_id)
return FeatureCodeListResponse(
features=feature_codes,
tier_code=subscription.tier,
tier_name=tier.name if tier else subscription.tier.title(),
)
@router.get("", response_model=FeatureListResponse)
def get_features(
category: str | None = Query(None, description="Filter by category"),
include_unavailable: bool = Query(True, description="Include features not available to vendor"),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get all features with availability status and metadata.
This is a comprehensive endpoint for building feature-gated UIs.
Each feature includes:
- Availability status
- UI metadata (icon, route, location)
- Minimum tier required
Args:
category: Filter to specific category (orders, inventory, etc.)
include_unavailable: Whether to include locked features
Returns:
List of features with metadata and availability
"""
vendor_id = current_user.token_vendor_id
# Get subscription for tier info
from app.services.subscription_service import subscription_service
subscription = subscription_service.get_or_create_subscription(db, vendor_id)
tier = subscription.tier_obj
# Get features
features = feature_service.get_vendor_features(
db,
vendor_id,
category=category,
include_unavailable=include_unavailable,
)
available_count = sum(1 for f in features if f.is_available)
return FeatureListResponse(
features=[
FeatureResponse(
code=f.code,
name=f.name,
description=f.description,
category=f.category,
ui_location=f.ui_location,
ui_icon=f.ui_icon,
ui_route=f.ui_route,
ui_badge_text=f.ui_badge_text,
is_available=f.is_available,
minimum_tier_code=f.minimum_tier_code,
minimum_tier_name=f.minimum_tier_name,
)
for f in features
],
available_count=available_count,
total_count=len(features),
tier_code=subscription.tier,
tier_name=tier.name if tier else subscription.tier.title(),
)
@router.get("/categories", response_model=CategoryListResponse)
def get_feature_categories(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get list of feature categories.
Returns:
List of category names
"""
categories = feature_service.get_categories(db)
return CategoryListResponse(categories=categories)
@router.get("/grouped", response_model=FeatureGroupedResponse)
def get_features_grouped(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get features grouped by category.
Useful for rendering feature comparison tables or settings pages.
"""
vendor_id = current_user.token_vendor_id
grouped = feature_service.get_features_grouped_by_category(db, vendor_id)
# Convert to response format
categories_response = {}
total = 0
available = 0
for category, features in grouped.items():
categories_response[category] = [
FeatureResponse(
code=f.code,
name=f.name,
description=f.description,
category=f.category,
ui_location=f.ui_location,
ui_icon=f.ui_icon,
ui_route=f.ui_route,
ui_badge_text=f.ui_badge_text,
is_available=f.is_available,
minimum_tier_code=f.minimum_tier_code,
minimum_tier_name=f.minimum_tier_name,
)
for f in features
]
total += len(features)
available += sum(1 for f in features if f.is_available)
return FeatureGroupedResponse(
categories=categories_response,
available_count=available,
total_count=total,
)
@router.get("/{feature_code}", response_model=FeatureDetailResponse)
def get_feature_detail(
feature_code: str,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get detailed information about a specific feature.
Includes upgrade information if the feature is not available.
Use this for upgrade prompts and feature explanation modals.
Args:
feature_code: The feature code
Returns:
Feature details with upgrade info if locked
"""
vendor_id = current_user.token_vendor_id
# Get feature
feature = feature_service.get_feature_by_code(db, feature_code)
if not feature:
raise HTTPException(status_code=404, detail=f"Feature '{feature_code}' not found")
# Check availability
is_available = feature_service.has_feature(db, vendor_id, feature_code)
# Get upgrade info if not available
upgrade_tier_code = None
upgrade_tier_name = None
upgrade_tier_price = None
if not is_available:
upgrade_info = feature_service.get_feature_upgrade_info(db, feature_code)
if upgrade_info:
upgrade_tier_code = upgrade_info.required_tier_code
upgrade_tier_name = upgrade_info.required_tier_name
upgrade_tier_price = upgrade_info.required_tier_price_monthly_cents
return FeatureDetailResponse(
code=feature.code,
name=feature.name,
description=feature.description,
category=feature.category,
ui_location=feature.ui_location,
ui_icon=feature.ui_icon,
ui_route=feature.ui_route,
is_available=is_available,
upgrade_tier_code=upgrade_tier_code,
upgrade_tier_name=upgrade_tier_name,
upgrade_tier_price_monthly_cents=upgrade_tier_price,
)
@router.get("/check/{feature_code}")
def check_feature(
feature_code: str,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Quick check if vendor has access to a feature.
Returns simple boolean response for inline checks.
Args:
feature_code: The feature code
Returns:
{"has_feature": true/false}
"""
vendor_id = current_user.token_vendor_id
has_feature = feature_service.has_feature(db, vendor_id, feature_code)
return {"has_feature": has_feature, "feature_code": feature_code}

View File

@@ -16,6 +16,12 @@ Endpoints:
- POST /invoices/settings - Create invoice settings - POST /invoices/settings - Create invoice settings
- PUT /invoices/settings - Update invoice settings - PUT /invoices/settings - Update invoice settings
- GET /invoices/stats - Get invoice statistics - GET /invoices/stats - Get invoice statistics
Feature Requirements:
- invoice_lu: Basic Luxembourg invoicing (Essential tier)
- invoice_eu_vat: EU VAT support (Professional tier)
- invoice_bulk: Bulk invoicing (Business tier)
- accounting_export: Export to accounting software (Business tier)
""" """
import logging import logging
@@ -27,6 +33,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api from app.api.deps import get_current_vendor_api
from app.core.database import get_db from app.core.database import get_db
from app.core.feature_gate import RequireFeature
from app.exceptions.invoice import ( from app.exceptions.invoice import (
InvoiceNotFoundException, InvoiceNotFoundException,
InvoicePDFGenerationException, InvoicePDFGenerationException,
@@ -34,6 +41,7 @@ from app.exceptions.invoice import (
InvoiceSettingsNotFoundException, InvoiceSettingsNotFoundException,
) )
from app.services.invoice_service import invoice_service from app.services.invoice_service import invoice_service
from models.database.feature import FeatureCode
from models.database.user import User from models.database.user import User
from models.schema.invoice import ( from models.schema.invoice import (
InvoiceCreate, InvoiceCreate,
@@ -61,11 +69,13 @@ logger = logging.getLogger(__name__)
def get_invoice_settings( def get_invoice_settings(
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: None = Depends(RequireFeature(FeatureCode.INVOICE_LU)),
): ):
""" """
Get vendor invoice settings. Get vendor invoice settings.
Returns null if settings not yet configured. Returns null if settings not yet configured.
Requires: invoice_lu feature (Essential tier)
""" """
settings = invoice_service.get_settings(db, current_user.token_vendor_id) settings = invoice_service.get_settings(db, current_user.token_vendor_id)
if settings: if settings:

380
app/api/v1/vendor/usage.py vendored Normal file
View File

@@ -0,0 +1,380 @@
# app/api/v1/vendor/usage.py
"""
Vendor usage and limits API endpoints.
Provides endpoints for:
- Current usage vs limits
- Upgrade recommendations
- Approaching limit warnings
"""
import logging
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.services.subscription_service import subscription_service
from models.database.product import Product
from models.database.subscription import SubscriptionTier
from models.database.user import User
from models.database.vendor import VendorUser
router = APIRouter(prefix="/usage")
logger = logging.getLogger(__name__)
# ============================================================================
# Response Schemas
# ============================================================================
class UsageMetric(BaseModel):
"""Single usage metric."""
name: str
current: int
limit: int | None # None = unlimited
percentage: float # 0-100, or 0 if unlimited
is_unlimited: bool
is_at_limit: bool
is_approaching_limit: bool # >= 80%
class TierInfo(BaseModel):
"""Current tier information."""
code: str
name: str
price_monthly_cents: int
is_highest_tier: bool
class UpgradeTierInfo(BaseModel):
"""Next tier upgrade information."""
code: str
name: str
price_monthly_cents: int
price_increase_cents: int
benefits: list[str]
class UsageResponse(BaseModel):
"""Full usage response with limits and upgrade info."""
tier: TierInfo
usage: list[UsageMetric]
has_limits_approaching: bool
has_limits_reached: bool
upgrade_available: bool
upgrade_tier: UpgradeTierInfo | None = None
upgrade_reasons: list[str]
class LimitCheckResponse(BaseModel):
"""Response for checking a specific limit."""
limit_type: str
can_proceed: bool
current: int
limit: int | None
percentage: float
message: str | None = None
upgrade_tier_code: str | None = None
upgrade_tier_name: str | None = None
# ============================================================================
# Endpoints
# ============================================================================
@router.get("", response_model=UsageResponse)
def get_usage(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get current usage, limits, and upgrade recommendations.
Returns comprehensive usage info for displaying in dashboard
and determining when to show upgrade prompts.
"""
vendor_id = current_user.token_vendor_id
# Get subscription
subscription = subscription_service.get_or_create_subscription(db, vendor_id)
# Get current tier
tier = subscription.tier_obj
if not tier:
tier = (
db.query(SubscriptionTier)
.filter(SubscriptionTier.code == subscription.tier)
.first()
)
# Calculate usage metrics
usage_metrics = []
# Orders this period
orders_current = subscription.orders_this_period or 0
orders_limit = subscription.orders_limit
orders_unlimited = orders_limit is None or orders_limit < 0
orders_percentage = 0 if orders_unlimited else (orders_current / orders_limit * 100 if orders_limit > 0 else 100)
usage_metrics.append(
UsageMetric(
name="orders",
current=orders_current,
limit=None if orders_unlimited else orders_limit,
percentage=orders_percentage,
is_unlimited=orders_unlimited,
is_at_limit=not orders_unlimited and orders_current >= orders_limit,
is_approaching_limit=not orders_unlimited and orders_percentage >= 80,
)
)
# Products
products_count = (
db.query(func.count(Product.id))
.filter(Product.vendor_id == vendor_id)
.scalar()
or 0
)
products_limit = subscription.products_limit
products_unlimited = products_limit is None or products_limit < 0
products_percentage = 0 if products_unlimited else (products_count / products_limit * 100 if products_limit > 0 else 100)
usage_metrics.append(
UsageMetric(
name="products",
current=products_count,
limit=None if products_unlimited else products_limit,
percentage=products_percentage,
is_unlimited=products_unlimited,
is_at_limit=not products_unlimited and products_count >= products_limit,
is_approaching_limit=not products_unlimited and products_percentage >= 80,
)
)
# Team members
team_count = (
db.query(func.count(VendorUser.id))
.filter(VendorUser.vendor_id == vendor_id, VendorUser.is_active == True) # noqa: E712
.scalar()
or 0
)
team_limit = subscription.team_members_limit
team_unlimited = team_limit is None or team_limit < 0
team_percentage = 0 if team_unlimited else (team_count / team_limit * 100 if team_limit > 0 else 100)
usage_metrics.append(
UsageMetric(
name="team_members",
current=team_count,
limit=None if team_unlimited else team_limit,
percentage=team_percentage,
is_unlimited=team_unlimited,
is_at_limit=not team_unlimited and team_count >= team_limit,
is_approaching_limit=not team_unlimited and team_percentage >= 80,
)
)
# Check for approaching/reached limits
has_limits_approaching = any(m.is_approaching_limit for m in usage_metrics)
has_limits_reached = any(m.is_at_limit for m in usage_metrics)
# Get next tier for upgrade
all_tiers = (
db.query(SubscriptionTier)
.filter(SubscriptionTier.is_active == True) # noqa: E712
.order_by(SubscriptionTier.display_order)
.all()
)
current_tier_order = tier.display_order if tier else 0
next_tier = None
for t in all_tiers:
if t.display_order > current_tier_order:
next_tier = t
break
is_highest_tier = next_tier is None
# Build upgrade info
upgrade_tier_info = None
upgrade_reasons = []
if next_tier:
# Calculate benefits
benefits = []
if next_tier.orders_per_month and (not tier or (tier.orders_per_month and next_tier.orders_per_month > tier.orders_per_month)):
if next_tier.orders_per_month < 0:
benefits.append("Unlimited orders per month")
else:
benefits.append(f"{next_tier.orders_per_month:,} orders/month")
if next_tier.products_limit and (not tier or (tier.products_limit and next_tier.products_limit > tier.products_limit)):
if next_tier.products_limit < 0:
benefits.append("Unlimited products")
else:
benefits.append(f"{next_tier.products_limit:,} products")
if next_tier.team_members and (not tier or (tier.team_members and next_tier.team_members > tier.team_members)):
if next_tier.team_members < 0:
benefits.append("Unlimited team members")
else:
benefits.append(f"{next_tier.team_members} team members")
# Add feature benefits
current_features = set(tier.features) if tier and tier.features else set()
next_features = set(next_tier.features) if next_tier.features else set()
new_features = next_features - current_features
feature_names = {
"analytics_dashboard": "Advanced Analytics",
"api_access": "API Access",
"automation_rules": "Automation Rules",
"team_roles": "Team Roles & Permissions",
"custom_domain": "Custom Domain",
"webhooks": "Webhooks",
"accounting_export": "Accounting Export",
}
for feature in list(new_features)[:3]: # Show top 3
if feature in feature_names:
benefits.append(feature_names[feature])
current_price = tier.price_monthly_cents if tier else 0
upgrade_tier_info = UpgradeTierInfo(
code=next_tier.code,
name=next_tier.name,
price_monthly_cents=next_tier.price_monthly_cents,
price_increase_cents=next_tier.price_monthly_cents - current_price,
benefits=benefits,
)
# Build upgrade reasons
if has_limits_reached:
for m in usage_metrics:
if m.is_at_limit:
upgrade_reasons.append(f"You've reached your {m.name.replace('_', ' ')} limit")
elif has_limits_approaching:
for m in usage_metrics:
if m.is_approaching_limit:
upgrade_reasons.append(f"You're approaching your {m.name.replace('_', ' ')} limit ({int(m.percentage)}%)")
return UsageResponse(
tier=TierInfo(
code=tier.code if tier else subscription.tier,
name=tier.name if tier else subscription.tier.title(),
price_monthly_cents=tier.price_monthly_cents if tier else 0,
is_highest_tier=is_highest_tier,
),
usage=usage_metrics,
has_limits_approaching=has_limits_approaching,
has_limits_reached=has_limits_reached,
upgrade_available=not is_highest_tier,
upgrade_tier=upgrade_tier_info,
upgrade_reasons=upgrade_reasons,
)
@router.get("/check/{limit_type}", response_model=LimitCheckResponse)
def check_limit(
limit_type: str,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Check a specific limit before performing an action.
Use this before creating orders, products, or inviting team members.
Args:
limit_type: One of "orders", "products", "team_members"
Returns:
Whether the action can proceed and upgrade info if not
"""
vendor_id = current_user.token_vendor_id
if limit_type == "orders":
can_proceed, message = subscription_service.can_create_order(db, vendor_id)
subscription = subscription_service.get_subscription(db, vendor_id)
current = subscription.orders_this_period if subscription else 0
limit = subscription.orders_limit if subscription else 0
elif limit_type == "products":
can_proceed, message = subscription_service.can_add_product(db, vendor_id)
subscription = subscription_service.get_subscription(db, vendor_id)
current = (
db.query(func.count(Product.id))
.filter(Product.vendor_id == vendor_id)
.scalar()
or 0
)
limit = subscription.products_limit if subscription else 0
elif limit_type == "team_members":
can_proceed, message = subscription_service.can_add_team_member(db, vendor_id)
subscription = subscription_service.get_subscription(db, vendor_id)
current = (
db.query(func.count(VendorUser.id))
.filter(VendorUser.vendor_id == vendor_id, VendorUser.is_active == True) # noqa: E712
.scalar()
or 0
)
limit = subscription.team_members_limit if subscription else 0
else:
return LimitCheckResponse(
limit_type=limit_type,
can_proceed=True,
current=0,
limit=None,
percentage=0,
message=f"Unknown limit type: {limit_type}",
)
# Calculate percentage
is_unlimited = limit is None or limit < 0
percentage = 0 if is_unlimited else (current / limit * 100 if limit > 0 else 100)
# Get upgrade info if at limit
upgrade_tier_code = None
upgrade_tier_name = None
if not can_proceed:
subscription = subscription_service.get_subscription(db, vendor_id)
current_tier = subscription.tier_obj if subscription else None
if current_tier:
next_tier = (
db.query(SubscriptionTier)
.filter(
SubscriptionTier.is_active == True, # noqa: E712
SubscriptionTier.display_order > current_tier.display_order,
)
.order_by(SubscriptionTier.display_order)
.first()
)
if next_tier:
upgrade_tier_code = next_tier.code
upgrade_tier_name = next_tier.name
return LimitCheckResponse(
limit_type=limit_type,
can_proceed=can_proceed,
current=current,
limit=None if is_unlimited else limit,
percentage=percentage,
message=message,
upgrade_tier_code=upgrade_tier_code,
upgrade_tier_name=upgrade_tier_name,
)

254
app/core/feature_gate.py Normal file
View File

@@ -0,0 +1,254 @@
# app/core/feature_gate.py
"""
Feature gating decorator and dependencies for tier-based access control.
Provides:
- @require_feature decorator for endpoints
- RequireFeature dependency for flexible usage
- FeatureNotAvailableError exception with upgrade info
Usage:
# As decorator (simple)
@router.get("/analytics")
@require_feature(FeatureCode.ANALYTICS_DASHBOARD)
def get_analytics(...):
...
# As dependency (more control)
@router.get("/analytics")
def get_analytics(
_: None = Depends(RequireFeature(FeatureCode.ANALYTICS_DASHBOARD)),
...
):
...
# Multiple features (any one required)
@require_feature(FeatureCode.ANALYTICS_DASHBOARD, FeatureCode.BASIC_REPORTS)
def get_reports(...):
...
"""
import functools
import logging
from typing import Callable
from fastapi import Depends, HTTPException, Request
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.services.feature_service import feature_service
from models.database.feature import FeatureCode
from models.database.user import User
logger = logging.getLogger(__name__)
class FeatureNotAvailableError(HTTPException):
"""
Exception raised when a feature is not available for the vendor's tier.
Includes upgrade information for the frontend to display.
"""
def __init__(
self,
feature_code: str,
feature_name: str | None = None,
required_tier_code: str | None = None,
required_tier_name: str | None = None,
required_tier_price_cents: int | None = None,
):
detail = {
"error": "feature_not_available",
"message": f"This feature requires an upgrade to access.",
"feature_code": feature_code,
"feature_name": feature_name,
"upgrade": {
"tier_code": required_tier_code,
"tier_name": required_tier_name,
"price_monthly_cents": required_tier_price_cents,
}
if required_tier_code
else None,
}
super().__init__(status_code=403, detail=detail)
class RequireFeature:
"""
Dependency class that checks if vendor has access to a feature.
Can be used as a FastAPI dependency:
@router.get("/analytics")
def get_analytics(
_: None = Depends(RequireFeature("analytics_dashboard")),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
...
Args:
*feature_codes: One or more feature codes. Access granted if ANY is available.
"""
def __init__(self, *feature_codes: str):
if not feature_codes:
raise ValueError("At least one feature code is required")
self.feature_codes = feature_codes
def __call__(
self,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
) -> None:
"""Check if vendor has access to any of the required features."""
vendor_id = current_user.token_vendor_id
# Check if vendor has ANY of the required features
for feature_code in self.feature_codes:
if feature_service.has_feature(db, vendor_id, feature_code):
return None
# None of the features are available - get upgrade info for first one
feature_code = self.feature_codes[0]
upgrade_info = feature_service.get_feature_upgrade_info(db, feature_code)
if upgrade_info:
raise FeatureNotAvailableError(
feature_code=feature_code,
feature_name=upgrade_info.feature_name,
required_tier_code=upgrade_info.required_tier_code,
required_tier_name=upgrade_info.required_tier_name,
required_tier_price_cents=upgrade_info.required_tier_price_monthly_cents,
)
else:
# Feature not found in registry
raise FeatureNotAvailableError(feature_code=feature_code)
def require_feature(*feature_codes: str) -> Callable:
"""
Decorator to require one or more features for an endpoint.
The decorated endpoint will return 403 with upgrade info if the vendor
doesn't have access to ANY of the specified features.
Args:
*feature_codes: One or more feature codes. Access granted if ANY is available.
Example:
@router.get("/analytics/dashboard")
@require_feature(FeatureCode.ANALYTICS_DASHBOARD)
async def get_analytics_dashboard(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
...
# Multiple features (any one is sufficient)
@router.get("/reports")
@require_feature(FeatureCode.ANALYTICS_DASHBOARD, FeatureCode.BASIC_REPORTS)
async def get_reports(...):
...
"""
if not feature_codes:
raise ValueError("At least one feature code is required")
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
async def async_wrapper(*args, **kwargs):
# Extract dependencies from kwargs
db = kwargs.get("db")
current_user = kwargs.get("current_user")
if not db or not current_user:
# Try to get from request if not in kwargs
request = kwargs.get("request")
if request and hasattr(request, "state"):
db = getattr(request.state, "db", None)
current_user = getattr(request.state, "user", None)
if not db or not current_user:
raise HTTPException(
status_code=500,
detail="Feature check failed: missing db or current_user dependency",
)
vendor_id = current_user.token_vendor_id
# Check if vendor has ANY of the required features
for feature_code in feature_codes:
if feature_service.has_feature(db, vendor_id, feature_code):
return await func(*args, **kwargs)
# None available - raise with upgrade info
feature_code = feature_codes[0]
upgrade_info = feature_service.get_feature_upgrade_info(db, feature_code)
if upgrade_info:
raise FeatureNotAvailableError(
feature_code=feature_code,
feature_name=upgrade_info.feature_name,
required_tier_code=upgrade_info.required_tier_code,
required_tier_name=upgrade_info.required_tier_name,
required_tier_price_cents=upgrade_info.required_tier_price_monthly_cents,
)
else:
raise FeatureNotAvailableError(feature_code=feature_code)
@functools.wraps(func)
def sync_wrapper(*args, **kwargs):
# Extract dependencies from kwargs
db = kwargs.get("db")
current_user = kwargs.get("current_user")
if not db or not current_user:
raise HTTPException(
status_code=500,
detail="Feature check failed: missing db or current_user dependency",
)
vendor_id = current_user.token_vendor_id
# Check if vendor has ANY of the required features
for feature_code in feature_codes:
if feature_service.has_feature(db, vendor_id, feature_code):
return func(*args, **kwargs)
# None available - raise with upgrade info
feature_code = feature_codes[0]
upgrade_info = feature_service.get_feature_upgrade_info(db, feature_code)
if upgrade_info:
raise FeatureNotAvailableError(
feature_code=feature_code,
feature_name=upgrade_info.feature_name,
required_tier_code=upgrade_info.required_tier_code,
required_tier_name=upgrade_info.required_tier_name,
required_tier_price_cents=upgrade_info.required_tier_price_monthly_cents,
)
else:
raise FeatureNotAvailableError(feature_code=feature_code)
# Return appropriate wrapper based on whether func is async
import asyncio
if asyncio.iscoroutinefunction(func):
return async_wrapper
else:
return sync_wrapper
return decorator
# ============================================================================
# Convenience Exports
# ============================================================================
__all__ = [
"require_feature",
"RequireFeature",
"FeatureNotAvailableError",
"FeatureCode",
]

View File

@@ -6,6 +6,7 @@ Processes webhook events from Stripe:
- Subscription lifecycle events - Subscription lifecycle events
- Invoice and payment events - Invoice and payment events
- Checkout session completion - Checkout session completion
- Add-on purchases
""" """
import logging import logging
@@ -15,10 +16,12 @@ import stripe
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from models.database.subscription import ( from models.database.subscription import (
AddOnProduct,
BillingHistory, BillingHistory,
StripeWebhookEvent, StripeWebhookEvent,
SubscriptionStatus, SubscriptionStatus,
SubscriptionTier, SubscriptionTier,
VendorAddOn,
VendorSubscription, VendorSubscription,
) )
@@ -108,15 +111,34 @@ class StripeWebhookHandler:
def _handle_checkout_completed( def _handle_checkout_completed(
self, db: Session, event: stripe.Event self, db: Session, event: stripe.Event
) -> dict: ) -> 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 session = event.data.object
vendor_id = session.metadata.get("vendor_id") vendor_id = session.metadata.get("vendor_id")
addon_code = session.metadata.get("addon_code")
if not vendor_id: if not vendor_id:
logger.warning(f"Checkout session {session.id} missing vendor_id") logger.warning(f"Checkout session {session.id} missing vendor_id")
return {"action": "skipped", "reason": "no vendor_id"} return {"action": "skipped", "reason": "no vendor_id"}
vendor_id = int(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 = ( subscription = (
db.query(VendorSubscription) db.query(VendorSubscription)
.filter(VendorSubscription.vendor_id == vendor_id) .filter(VendorSubscription.vendor_id == vendor_id)
@@ -147,9 +169,112 @@ class StripeWebhookHandler:
stripe_sub.trial_end, tz=timezone.utc 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} 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( def _handle_subscription_created(
self, db: Session, event: stripe.Event self, db: Session, event: stripe.Event
) -> dict: ) -> dict:
@@ -240,7 +365,11 @@ class StripeWebhookHandler:
def _handle_subscription_deleted( def _handle_subscription_deleted(
self, db: Session, event: stripe.Event self, db: Session, event: stripe.Event
) -> dict: ) -> dict:
"""Handle customer.subscription.deleted event.""" """
Handle customer.subscription.deleted event.
Cancels the subscription and all associated add-ons.
"""
stripe_sub = event.data.object stripe_sub = event.data.object
subscription = ( subscription = (
@@ -253,11 +382,37 @@ class StripeWebhookHandler:
logger.warning(f"No subscription found for {stripe_sub.id}") logger.warning(f"No subscription found for {stripe_sub.id}")
return {"action": "skipped", "reason": "no subscription"} return {"action": "skipped", "reason": "no subscription"}
vendor_id = subscription.vendor_id
# Cancel the subscription
subscription.status = SubscriptionStatus.CANCELLED subscription.status = SubscriptionStatus.CANCELLED
subscription.cancelled_at = datetime.now(timezone.utc) subscription.cancelled_at = datetime.now(timezone.utc)
logger.info(f"Subscription deleted for vendor {subscription.vendor_id}") # Also cancel all active add-ons for this vendor
return {"action": "cancelled", "vendor_id": subscription.vendor_id} 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: def _handle_invoice_paid(self, db: Session, event: stripe.Event) -> dict:
"""Handle invoice.paid event.""" """Handle invoice.paid event."""

View File

@@ -1286,3 +1286,27 @@ async def admin_platform_health(
"user": current_user, "user": current_user,
}, },
) )
# ============================================================================
# FEATURE MANAGEMENT ROUTES
# ============================================================================
@router.get("/features", response_class=HTMLResponse, include_in_schema=False)
async def admin_features_page(
request: Request,
current_user: User = Depends(get_current_admin_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render feature management page.
Shows all features with tier assignments and allows editing.
"""
return templates.TemplateResponse(
"admin/features.html",
{
"request": request,
"user": current_user,
},
)

View File

@@ -0,0 +1,451 @@
# app/services/feature_service.py
"""
Feature service for tier-based access control.
Provides:
- Feature availability checking with caching
- Vendor feature listing for API/UI
- Feature metadata for upgrade prompts
- Cache invalidation on subscription changes
Usage:
from app.services.feature_service import feature_service
# Check if vendor has feature
if feature_service.has_feature(db, vendor_id, FeatureCode.ANALYTICS_DASHBOARD):
...
# Get all features available to vendor
features = feature_service.get_vendor_features(db, vendor_id)
# Get feature info for upgrade prompt
info = feature_service.get_feature_upgrade_info(db, "analytics_dashboard")
"""
import logging
import time
from dataclasses import dataclass
from functools import lru_cache
from sqlalchemy.orm import Session, joinedload
from models.database.feature import Feature, FeatureCode
from models.database.subscription import SubscriptionTier, VendorSubscription
logger = logging.getLogger(__name__)
@dataclass
class FeatureInfo:
"""Feature information for API responses."""
code: str
name: str
description: str | None
category: str
ui_location: str | None
ui_icon: str | None
ui_route: str | None
ui_badge_text: str | None
is_available: bool
minimum_tier_code: str | None
minimum_tier_name: str | None
@dataclass
class FeatureUpgradeInfo:
"""Information for upgrade prompts."""
feature_code: str
feature_name: str
feature_description: str | None
required_tier_code: str
required_tier_name: str
required_tier_price_monthly_cents: int
class FeatureCache:
"""
In-memory cache for vendor features.
Caches vendor_id -> set of feature codes with TTL.
Invalidated when subscription changes.
"""
def __init__(self, ttl_seconds: int = 300):
self._cache: dict[int, tuple[set[str], float]] = {}
self._ttl = ttl_seconds
def get(self, vendor_id: int) -> set[str] | None:
"""Get cached features for vendor, or None if not cached/expired."""
if vendor_id not in self._cache:
return None
features, timestamp = self._cache[vendor_id]
if time.time() - timestamp > self._ttl:
del self._cache[vendor_id]
return None
return features
def set(self, vendor_id: int, features: set[str]) -> None:
"""Cache features for vendor."""
self._cache[vendor_id] = (features, time.time())
def invalidate(self, vendor_id: int) -> None:
"""Invalidate cache for vendor."""
self._cache.pop(vendor_id, None)
def invalidate_all(self) -> None:
"""Invalidate entire cache."""
self._cache.clear()
class FeatureService:
"""
Service for feature-based access control.
Provides methods to check feature availability and get feature metadata.
Uses in-memory caching with TTL for performance.
"""
def __init__(self):
self._cache = FeatureCache(ttl_seconds=300) # 5 minute cache
self._feature_registry_cache: dict[str, Feature] | None = None
self._feature_registry_timestamp: float = 0
# =========================================================================
# Feature Availability
# =========================================================================
def has_feature(self, db: Session, vendor_id: int, feature_code: str) -> bool:
"""
Check if vendor has access to a specific feature.
Args:
db: Database session
vendor_id: Vendor ID
feature_code: Feature code (use FeatureCode constants)
Returns:
True if vendor has access to the feature
"""
vendor_features = self._get_vendor_feature_codes(db, vendor_id)
return feature_code in vendor_features
def get_vendor_feature_codes(self, db: Session, vendor_id: int) -> set[str]:
"""
Get set of feature codes available to vendor.
Args:
db: Database session
vendor_id: Vendor ID
Returns:
Set of feature codes the vendor has access to
"""
return self._get_vendor_feature_codes(db, vendor_id)
def _get_vendor_feature_codes(self, db: Session, vendor_id: int) -> set[str]:
"""Internal method with caching."""
# Check cache first
cached = self._cache.get(vendor_id)
if cached is not None:
return cached
# Get subscription with tier relationship
subscription = (
db.query(VendorSubscription)
.options(joinedload(VendorSubscription.tier_obj))
.filter(VendorSubscription.vendor_id == vendor_id)
.first()
)
if not subscription:
logger.warning(f"No subscription found for vendor {vendor_id}")
return set()
# Get features from tier
tier = subscription.tier_obj
if tier and tier.features:
features = set(tier.features)
else:
# Fallback: query tier by code
tier = (
db.query(SubscriptionTier)
.filter(SubscriptionTier.code == subscription.tier)
.first()
)
features = set(tier.features) if tier and tier.features else set()
# Cache and return
self._cache.set(vendor_id, features)
return features
# =========================================================================
# Feature Listing
# =========================================================================
def get_vendor_features(
self,
db: Session,
vendor_id: int,
category: str | None = None,
include_unavailable: bool = True,
) -> list[FeatureInfo]:
"""
Get all features with availability status for vendor.
Args:
db: Database session
vendor_id: Vendor ID
category: Optional category filter
include_unavailable: Include features not available to vendor
Returns:
List of FeatureInfo with is_available flag
"""
vendor_features = self._get_vendor_feature_codes(db, vendor_id)
# Query all active features
query = db.query(Feature).filter(Feature.is_active == True) # noqa: E712
if category:
query = query.filter(Feature.category == category)
if not include_unavailable:
# Only return features the vendor has
query = query.filter(Feature.code.in_(vendor_features))
features = (
query.options(joinedload(Feature.minimum_tier))
.order_by(Feature.category, Feature.display_order)
.all()
)
result = []
for feature in features:
result.append(
FeatureInfo(
code=feature.code,
name=feature.name,
description=feature.description,
category=feature.category,
ui_location=feature.ui_location,
ui_icon=feature.ui_icon,
ui_route=feature.ui_route,
ui_badge_text=feature.ui_badge_text,
is_available=feature.code in vendor_features,
minimum_tier_code=feature.minimum_tier.code if feature.minimum_tier else None,
minimum_tier_name=feature.minimum_tier.name if feature.minimum_tier else None,
)
)
return result
def get_available_feature_codes(self, db: Session, vendor_id: int) -> list[str]:
"""
Get list of feature codes available to vendor (for frontend).
Simple list for x-feature directive checks.
"""
return list(self._get_vendor_feature_codes(db, vendor_id))
# =========================================================================
# Feature Metadata
# =========================================================================
def get_feature_by_code(self, db: Session, feature_code: str) -> Feature | None:
"""Get feature by code."""
return (
db.query(Feature)
.options(joinedload(Feature.minimum_tier))
.filter(Feature.code == feature_code)
.first()
)
def get_feature_upgrade_info(
self, db: Session, feature_code: str
) -> FeatureUpgradeInfo | None:
"""
Get upgrade information for a feature.
Used for upgrade prompts when a feature is not available.
"""
feature = self.get_feature_by_code(db, feature_code)
if not feature or not feature.minimum_tier:
return None
tier = feature.minimum_tier
return FeatureUpgradeInfo(
feature_code=feature.code,
feature_name=feature.name,
feature_description=feature.description,
required_tier_code=tier.code,
required_tier_name=tier.name,
required_tier_price_monthly_cents=tier.price_monthly_cents,
)
def get_all_features(
self,
db: Session,
category: str | None = None,
active_only: bool = True,
) -> list[Feature]:
"""Get all features (for admin)."""
query = db.query(Feature).options(joinedload(Feature.minimum_tier))
if active_only:
query = query.filter(Feature.is_active == True) # noqa: E712
if category:
query = query.filter(Feature.category == category)
return query.order_by(Feature.category, Feature.display_order).all()
def get_features_by_tier(self, db: Session, tier_code: str) -> list[str]:
"""Get feature codes for a specific tier."""
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first()
if not tier or not tier.features:
return []
return tier.features
# =========================================================================
# Feature Categories
# =========================================================================
def get_categories(self, db: Session) -> list[str]:
"""Get all unique feature categories."""
result = (
db.query(Feature.category)
.filter(Feature.is_active == True) # noqa: E712
.distinct()
.order_by(Feature.category)
.all()
)
return [row[0] for row in result]
def get_features_grouped_by_category(
self, db: Session, vendor_id: int
) -> dict[str, list[FeatureInfo]]:
"""Get features grouped by category with availability."""
features = self.get_vendor_features(db, vendor_id, include_unavailable=True)
grouped: dict[str, list[FeatureInfo]] = {}
for feature in features:
if feature.category not in grouped:
grouped[feature.category] = []
grouped[feature.category].append(feature)
return grouped
# =========================================================================
# Cache Management
# =========================================================================
def invalidate_vendor_cache(self, vendor_id: int) -> None:
"""
Invalidate cache for a specific vendor.
Call this when:
- Vendor's subscription tier changes
- Tier features are updated (for all vendors on that tier)
"""
self._cache.invalidate(vendor_id)
logger.debug(f"Invalidated feature cache for vendor {vendor_id}")
def invalidate_all_cache(self) -> None:
"""
Invalidate entire cache.
Call this when tier features are modified in admin.
"""
self._cache.invalidate_all()
logger.debug("Invalidated all feature caches")
# =========================================================================
# Admin Operations
# =========================================================================
def update_tier_features(
self, db: Session, tier_code: str, feature_codes: list[str]
) -> SubscriptionTier:
"""
Update features for a tier (admin operation).
Args:
db: Database session
tier_code: Tier code
feature_codes: List of feature codes to assign
Returns:
Updated tier
"""
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first()
if not tier:
raise ValueError(f"Tier '{tier_code}' not found")
# Validate feature codes exist
valid_codes = {
f.code for f in db.query(Feature.code).filter(Feature.is_active == True).all() # noqa: E712
}
invalid = set(feature_codes) - valid_codes
if invalid:
raise ValueError(f"Invalid feature codes: {invalid}")
tier.features = feature_codes
# Invalidate all caches since tier features changed
self.invalidate_all_cache()
logger.info(f"Updated features for tier {tier_code}: {len(feature_codes)} features")
return tier
def update_feature_minimum_tier(
self, db: Session, feature_code: str, tier_code: str | None
) -> Feature:
"""
Update minimum tier for a feature (for upgrade prompts).
Args:
db: Database session
feature_code: Feature code
tier_code: Tier code or None
"""
feature = db.query(Feature).filter(Feature.code == feature_code).first()
if not feature:
raise ValueError(f"Feature '{feature_code}' not found")
if tier_code:
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first()
if not tier:
raise ValueError(f"Tier '{tier_code}' not found")
feature.minimum_tier_id = tier.id
else:
feature.minimum_tier_id = None
logger.info(f"Updated minimum tier for feature {feature_code}: {tier_code}")
return feature
# Singleton instance
feature_service = FeatureService()
# ============================================================================
# Convenience Exports
# ============================================================================
# Re-export FeatureCode for easy imports
__all__ = [
"feature_service",
"FeatureService",
"FeatureInfo",
"FeatureUpgradeInfo",
"FeatureCode",
]

View File

@@ -0,0 +1,370 @@
{% extends "admin/base.html" %}
{% block title %}Feature Management{% endblock %}
{% block alpine_data %}featuresPage(){% endblock %}
{% block content %}
<div class="py-6">
<!-- Header -->
<div class="mb-8">
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
Feature Management
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Configure which features are available to each subscription tier.
</p>
</div>
<!-- Loading State -->
<div x-show="loading" class="flex justify-center py-12">
<svg class="animate-spin h-8 w-8 text-purple-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<!-- Tier Tabs -->
<div x-show="!loading" x-cloak>
<!-- Tab Navigation -->
<div class="border-b border-gray-200 dark:border-gray-700 mb-6">
<nav class="-mb-px flex space-x-8">
<template x-for="tier in tiers" :key="tier.code">
<button
@click="selectedTier = tier.code"
:class="{
'border-purple-500 text-purple-600 dark:text-purple-400': selectedTier === tier.code,
'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300': selectedTier !== tier.code
}"
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors">
<span x-text="tier.name"></span>
<span class="ml-2 px-2 py-0.5 text-xs rounded-full"
:class="selectedTier === tier.code ? 'bg-purple-100 text-purple-600 dark:bg-purple-900 dark:text-purple-300' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'"
x-text="tier.feature_count"></span>
</button>
</template>
</nav>
</div>
<!-- Features Grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Available Features (for selected tier) -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
Included Features
</h3>
<span class="text-sm text-gray-500 dark:text-gray-400"
x-text="`${getSelectedTierFeatures().length} features`"></span>
</div>
<div class="space-y-2 max-h-96 overflow-y-auto">
<template x-for="featureCode in getSelectedTierFeatures()" :key="featureCode">
<div class="flex items-center justify-between p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div class="flex items-center">
<svg class="w-5 h-5 text-green-500 mr-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white"
x-text="getFeatureName(featureCode)"></p>
<p class="text-xs text-gray-500 dark:text-gray-400"
x-text="getFeatureCategory(featureCode)"></p>
</div>
</div>
<button
@click="removeFeatureFromTier(featureCode)"
class="text-red-500 hover:text-red-700 p-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</template>
<div x-show="getSelectedTierFeatures().length === 0"
class="text-center py-8 text-gray-500 dark:text-gray-400">
No features assigned to this tier
</div>
</div>
</div>
<!-- All Features (to add) -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
Available to Add
</h3>
<select
x-model="categoryFilter"
class="text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 rounded-md">
<option value="">All Categories</option>
<template x-for="cat in categories" :key="cat">
<option :value="cat" x-text="cat.charAt(0).toUpperCase() + cat.slice(1)"></option>
</template>
</select>
</div>
<div class="space-y-2 max-h-96 overflow-y-auto">
<template x-for="feature in getAvailableFeatures()" :key="feature.code">
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div class="flex items-center">
<svg class="w-5 h-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
</svg>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white"
x-text="feature.name"></p>
<p class="text-xs text-gray-500 dark:text-gray-400"
x-text="feature.category"></p>
</div>
</div>
<button
@click="addFeatureToTier(feature.code)"
class="text-green-500 hover:text-green-700 p-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
</svg>
</button>
</div>
</template>
<div x-show="getAvailableFeatures().length === 0"
class="text-center py-8 text-gray-500 dark:text-gray-400">
All features are assigned to this tier
</div>
</div>
</div>
</div>
<!-- Save Button -->
<div class="mt-6 flex justify-end">
<button
@click="saveTierFeatures"
:disabled="saving || !hasChanges"
:class="{
'opacity-50 cursor-not-allowed': saving || !hasChanges,
'hover:bg-purple-700': !saving && hasChanges
}"
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-purple-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500">
<svg x-show="saving" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span x-text="saving ? 'Saving...' : 'Save Changes'"></span>
</button>
</div>
<!-- All Features Table -->
<div class="mt-8 bg-white dark:bg-gray-800 rounded-lg shadow">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
All Features
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Complete list of all platform features with their minimum tier requirement.
</p>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Feature
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Category
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Minimum Tier
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Status
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="feature in allFeatures" :key="feature.code">
<tr>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900 dark:text-white" x-text="feature.name"></div>
<div class="text-xs text-gray-500 dark:text-gray-400" x-text="feature.code"></div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
x-text="feature.category"></span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 text-xs rounded-full"
:class="{
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300': feature.minimum_tier_code === 'essential',
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300': feature.minimum_tier_code === 'professional',
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300': feature.minimum_tier_code === 'business',
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300': feature.minimum_tier_code === 'enterprise',
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300': !feature.minimum_tier_code
}"
x-text="feature.minimum_tier_name || 'N/A'"></span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span x-show="feature.is_active"
class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300">
Active
</span>
<span x-show="!feature.is_active"
class="px-2 py-1 text-xs rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300">
Inactive
</span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function featuresPage() {
return {
...data(),
// State
loading: true,
saving: false,
tiers: [],
allFeatures: [],
categories: [],
selectedTier: 'essential',
categoryFilter: '',
originalTierFeatures: {}, // To track changes
currentTierFeatures: {}, // Current state
// Computed
get hasChanges() {
const original = this.originalTierFeatures[this.selectedTier] || [];
const current = this.currentTierFeatures[this.selectedTier] || [];
return JSON.stringify(original.sort()) !== JSON.stringify(current.sort());
},
// Lifecycle
async init() {
// Call parent init
const path = window.location.pathname;
const segments = path.split('/').filter(Boolean);
this.currentPage = segments[segments.length - 1] || 'features';
await this.loadData();
},
// Methods
async loadData() {
try {
this.loading = true;
// Load tiers and features in parallel
const [tiersResponse, featuresResponse, categoriesResponse] = await Promise.all([
apiClient.get('/admin/features/tiers'),
apiClient.get('/admin/features'),
apiClient.get('/admin/features/categories'),
]);
this.tiers = tiersResponse.tiers;
this.allFeatures = featuresResponse.features;
this.categories = categoriesResponse.categories;
// Initialize tier features tracking
for (const tier of this.tiers) {
this.originalTierFeatures[tier.code] = [...tier.features];
this.currentTierFeatures[tier.code] = [...tier.features];
}
} catch (error) {
console.error('Failed to load features:', error);
this.showNotification('Failed to load features', 'error');
} finally {
this.loading = false;
}
},
getSelectedTierFeatures() {
return this.currentTierFeatures[this.selectedTier] || [];
},
getAvailableFeatures() {
const tierFeatures = this.getSelectedTierFeatures();
return this.allFeatures.filter(f => {
const notIncluded = !tierFeatures.includes(f.code);
const matchesCategory = !this.categoryFilter || f.category === this.categoryFilter;
return notIncluded && matchesCategory && f.is_active;
});
},
getFeatureName(code) {
const feature = this.allFeatures.find(f => f.code === code);
return feature?.name || code;
},
getFeatureCategory(code) {
const feature = this.allFeatures.find(f => f.code === code);
return feature?.category || 'unknown';
},
addFeatureToTier(featureCode) {
if (!this.currentTierFeatures[this.selectedTier].includes(featureCode)) {
this.currentTierFeatures[this.selectedTier].push(featureCode);
}
},
removeFeatureFromTier(featureCode) {
const index = this.currentTierFeatures[this.selectedTier].indexOf(featureCode);
if (index > -1) {
this.currentTierFeatures[this.selectedTier].splice(index, 1);
}
},
async saveTierFeatures() {
if (!this.hasChanges) return;
try {
this.saving = true;
await apiClient.put(`/admin/features/tiers/${this.selectedTier}/features`, {
feature_codes: this.currentTierFeatures[this.selectedTier]
});
// Update original to match current
this.originalTierFeatures[this.selectedTier] = [...this.currentTierFeatures[this.selectedTier]];
// Update tier in tiers array
const tier = this.tiers.find(t => t.code === this.selectedTier);
if (tier) {
tier.features = [...this.currentTierFeatures[this.selectedTier]];
tier.feature_count = tier.features.length;
}
this.showNotification('Features saved successfully', 'success');
} catch (error) {
console.error('Failed to save features:', error);
this.showNotification('Failed to save features', 'error');
} finally {
this.saving = false;
}
},
showNotification(message, type = 'info') {
// Simple alert for now - could be improved with toast notifications
if (type === 'error') {
alert('Error: ' + message);
} else {
console.log(message);
}
}
};
}
</script>
{% endblock %}

View File

@@ -0,0 +1,335 @@
{# =============================================================================
Feature Gate Macros
Provides macros for tier-based feature gating in templates.
Uses Alpine.js $store.features for dynamic checking.
Usage:
{% from "shared/macros/feature_gate.html" import feature_gate, feature_locked, upgrade_banner %}
{# Show content only if feature is available #}
{% call feature_gate("analytics_dashboard") %}
<div>Analytics content here</div>
{% endcall %}
{# Show locked state with upgrade prompt #}
{{ feature_locked("analytics_dashboard", "Analytics Dashboard", "View advanced analytics") }}
{# Show upgrade banner #}
{{ upgrade_banner("analytics_dashboard") }}
============================================================================= #}
{# =============================================================================
Feature Gate Container
Shows content only if feature is available
Parameters:
- feature_code: The feature code to check
- fallback: Optional fallback content when feature is not available
============================================================================= #}
{% macro feature_gate(feature_code, show_fallback=true) %}
<div x-data x-show="$store.features.has('{{ feature_code }}')" x-cloak>
{{ caller() }}
</div>
{% if show_fallback %}
<div x-data x-show="$store.features.loaded && !$store.features.has('{{ feature_code }}')" x-cloak>
{{ feature_locked(feature_code) }}
</div>
{% endif %}
{% endmacro %}
{# =============================================================================
Feature Locked Card
Shows a card explaining the feature is locked with upgrade prompt
Parameters:
- feature_code: The feature code
- title: Optional title override
- description: Optional description override
- show_upgrade_button: Whether to show upgrade button (default true)
============================================================================= #}
{% macro feature_locked(feature_code, title=none, description=none, show_upgrade_button=true) %}
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600 p-6 text-center"
x-data="{ feature: null }"
x-init="
await $store.features.loadFullFeatures();
feature = $store.features.getFeature('{{ feature_code }}');
">
{# Lock icon #}
<div class="mx-auto w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center mb-4">
<svg class="w-6 h-6 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
</svg>
</div>
{# Title #}
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">
{% if title %}
{{ title }}
{% else %}
<span x-text="feature?.name || 'Premium Feature'">Premium Feature</span>
{% endif %}
</h3>
{# Description #}
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
{% if description %}
{{ description }}
{% else %}
<span x-text="feature?.description || 'This feature requires a plan upgrade.'">
This feature requires a plan upgrade.
</span>
{% endif %}
</p>
{# Tier badge #}
<p class="text-sm text-gray-600 dark:text-gray-300 mb-4">
Available on
<span class="font-semibold text-purple-600 dark:text-purple-400"
x-text="feature?.minimum_tier_name || 'higher tier'">higher tier</span>
plan
</p>
{% if show_upgrade_button %}
{# Upgrade button #}
<a :href="`/vendor/${$store.features.getVendorCode()}/billing`"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md
text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2
focus:ring-offset-2 focus:ring-purple-500 transition-colors">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/>
</svg>
Upgrade Plan
</a>
{% endif %}
</div>
{% endmacro %}
{# =============================================================================
Upgrade Banner
Inline banner for upgrade prompts
Parameters:
- feature_code: The feature code
- message: Optional custom message
============================================================================= #}
{% macro upgrade_banner(feature_code, message=none) %}
<div x-data="{ feature: null }"
x-init="
await $store.features.loadFullFeatures();
feature = $store.features.getFeature('{{ feature_code }}');
"
x-show="$store.features.loaded && !$store.features.has('{{ feature_code }}')"
x-cloak
class="bg-gradient-to-r from-purple-500 to-indigo-600 rounded-lg p-4 mb-4">
<div class="flex items-center justify-between">
<div class="flex items-center">
<svg class="w-5 h-5 text-white mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
<p class="text-white text-sm">
{% if message %}
{{ message }}
{% else %}
<span x-text="'Upgrade to ' + (feature?.minimum_tier_name || 'a higher plan') + ' to unlock ' + (feature?.name || 'this feature')">
Upgrade to unlock this feature
</span>
{% endif %}
</p>
</div>
<a :href="`/vendor/${$store.features.getVendorCode()}/billing`"
class="inline-flex items-center px-3 py-1.5 border border-white text-xs font-medium rounded
text-white hover:bg-white hover:text-purple-600 transition-colors">
Upgrade
</a>
</div>
</div>
{% endmacro %}
{# =============================================================================
Feature Badge
Small badge to show feature tier requirement
Parameters:
- feature_code: The feature code
============================================================================= #}
{% macro feature_badge(feature_code) %}
<span x-data="{ feature: null }"
x-init="
await $store.features.loadFullFeatures();
feature = $store.features.getFeature('{{ feature_code }}');
"
x-show="!$store.features.has('{{ feature_code }}')"
x-cloak
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
</svg>
<span x-text="feature?.minimum_tier_name || 'Pro'">Pro</span>
</span>
{% endmacro %}
{# =============================================================================
Sidebar Item with Feature Gate
Sidebar navigation item that shows lock if feature not available
Parameters:
- feature_code: The feature code
- href: Link URL
- icon: Icon name
- label: Display label
- current_page: Current page for active state
============================================================================= #}
{% macro sidebar_item_gated(feature_code, href, icon, label, current_page='') %}
<template x-if="$store.features.has('{{ feature_code }}')">
<a href="{{ href }}"
class="flex items-center px-4 py-2 text-sm font-medium rounded-lg transition-colors
{{ 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white' if current_page == label|lower else 'text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white' }}">
<span class="w-5 h-5 mr-3" x-html="window.icons?.['{{ icon }}'] || ''"></span>
{{ label }}
</a>
</template>
<template x-if="!$store.features.has('{{ feature_code }}')">
<div class="flex items-center px-4 py-2 text-sm font-medium rounded-lg text-gray-400 dark:text-gray-500 cursor-not-allowed"
title="Requires plan upgrade">
<span class="w-5 h-5 mr-3" x-html="window.icons?.['{{ icon }}'] || ''"></span>
{{ label }}
<svg class="w-4 h-4 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
</svg>
</div>
</template>
{% endmacro %}
{# =============================================================================
UPGRADE PROMPT MACROS
============================================================================= #}
{# =============================================================================
Usage Limit Warning
Shows warning banner when approaching or at a limit
Parameters:
- metric_name: One of "orders", "products", "team_members"
============================================================================= #}
{% macro limit_warning(metric_name) %}
<div x-data
x-init="$store.upgrade.loadUsage()"
x-show="$store.upgrade.shouldShowLimitWarning('{{ metric_name }}')"
x-cloak
x-html="$store.upgrade.getLimitWarningHTML('{{ metric_name }}')">
</div>
{% endmacro %}
{# =============================================================================
Usage Progress Bar
Shows compact progress bar for a limit
Parameters:
- metric_name: One of "orders", "products", "team_members"
- label: Display label
============================================================================= #}
{% macro usage_bar(metric_name, label) %}
<div x-data x-init="$store.upgrade.loadUsage()">
<div class="flex items-center justify-between mb-1">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ label }}</span>
<span class="text-sm text-gray-500 dark:text-gray-400"
x-text="$store.upgrade.getUsageString('{{ metric_name }}')"></span>
</div>
<div x-html="$store.upgrade.getUsageBarHTML('{{ metric_name }}')"></div>
</div>
{% endmacro %}
{# =============================================================================
Upgrade Card
Shows upgrade recommendation card (for dashboard)
Only shows if there are upgrade recommendations
Parameters:
- class: Additional CSS classes
============================================================================= #}
{% macro upgrade_card(class='') %}
<div x-data
x-init="$store.upgrade.loadUsage()"
x-show="$store.upgrade.hasUpgradeRecommendation"
x-cloak
class="{{ class }}"
x-html="$store.upgrade.getUpgradeCardHTML()">
</div>
{% endmacro %}
{# =============================================================================
Limit Check Button
Button that checks limit before action
Parameters:
- limit_type: One of "orders", "products", "team_members"
- action: JavaScript to execute if limit allows
- label: Button label
- class: Additional CSS classes
============================================================================= #}
{% macro limit_check_button(limit_type, action, label, class='') %}
<button
@click="$store.upgrade.checkLimitAndProceed('{{ limit_type }}', () => { {{ action }} })"
class="{{ class }}">
{{ label }}
</button>
{% endmacro %}
{# =============================================================================
Tier Badge
Shows current tier as a badge
Parameters:
- class: Additional CSS classes
============================================================================= #}
{% macro tier_badge(class='') %}
<span x-data
x-init="$store.upgrade.loadUsage()"
x-show="$store.upgrade.currentTier"
x-cloak
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ class }}"
:class="{
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300': $store.upgrade.currentTier?.code === 'essential',
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300': $store.upgrade.currentTier?.code === 'professional',
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300': $store.upgrade.currentTier?.code === 'business',
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300': $store.upgrade.currentTier?.code === 'enterprise'
}"
x-text="$store.upgrade.currentTier?.name">
</span>
{% endmacro %}
{# =============================================================================
Quick Upgrade Link
Simple upgrade link that appears when limits are reached
============================================================================= #}
{% macro quick_upgrade_link() %}
<a x-data
x-init="$store.upgrade.loadUsage()"
x-show="$store.upgrade.hasLimitsReached && $store.upgrade.nextTier"
x-cloak
:href="$store.upgrade.getBillingUrl()"
class="inline-flex items-center text-sm text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-300">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/>
</svg>
<span>Upgrade to <span x-text="$store.upgrade.nextTier?.name"></span></span>
</a>
{% endmacro %}

View File

@@ -62,7 +62,13 @@
<!-- 5. FIFTH: API Client (depends on Utils) --> <!-- 5. FIFTH: API Client (depends on Utils) -->
<script src="{{ url_for('static', path='shared/js/api-client.js') }}"></script> <script src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
<!-- 6. SIXTH: Alpine.js v3 with CDN fallback (with defer) --> <!-- 6. SIXTH: Feature Store (depends on API Client, registers with Alpine) -->
<script src="{{ url_for('static', path='shared/js/feature-store.js') }}"></script>
<!-- 7. SEVENTH: Upgrade Prompts (depends on API Client, registers with Alpine) -->
<script src="{{ url_for('static', path='shared/js/upgrade-prompts.js') }}"></script>
<!-- 8. EIGHTH: Alpine.js v3 with CDN fallback (with defer) -->
<script> <script>
(function() { (function() {
var script = document.createElement('script'); var script = document.createElement('script');
@@ -81,7 +87,7 @@
})(); })();
</script> </script>
<!-- 7. LAST: Page-specific scripts --> <!-- 9. LAST: Page-specific scripts -->
{% block extra_scripts %}{% endblock %} {% block extra_scripts %}{% endblock %}
</body> </body>
</html> </html>

View File

@@ -1,16 +1,24 @@
{# app/templates/vendor/dashboard.html #} {# app/templates/vendor/dashboard.html #}
{% extends "vendor/base.html" %} {% extends "vendor/base.html" %}
{% from "shared/macros/feature_gate.html" import limit_warning, usage_bar, upgrade_card, tier_badge %}
{% block title %}Dashboard{% endblock %} {% block title %}Dashboard{% endblock %}
{% block alpine_data %}vendorDashboard(){% endblock %} {% block alpine_data %}vendorDashboard(){% endblock %}
{% block content %} {% block content %}
<!-- Limit Warnings -->
{{ limit_warning("orders") }}
{{ limit_warning("products") }}
<!-- Page Header with Refresh Button --> <!-- Page Header with Refresh Button -->
<div class="flex items-center justify-between my-6"> <div class="flex items-center justify-between my-6">
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200"> <div class="flex items-center gap-3">
Dashboard <h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
</h2> Dashboard
</h2>
{{ tier_badge() }}
</div>
<button <button
@click="refresh()" @click="refresh()"
:disabled="loading" :disabled="loading"
@@ -40,6 +48,9 @@
<!-- Vendor Info Card --> <!-- Vendor Info Card -->
{% include 'vendor/partials/vendor_info.html' %} {% include 'vendor/partials/vendor_info.html' %}
<!-- Upgrade Recommendation Card (shows when approaching/at limits) -->
{{ upgrade_card(class='mb-6') }}
<!-- Stats Cards --> <!-- Stats Cards -->
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4"> <div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<!-- Card: Total Products --> <!-- Card: Total Products -->
@@ -103,6 +114,19 @@
</div> </div>
</div> </div>
<!-- Usage Overview -->
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-3">
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
{{ usage_bar("orders", "Monthly Orders") }}
</div>
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
{{ usage_bar("products", "Products") }}
</div>
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
{{ usage_bar("team_members", "Team Members") }}
</div>
</div>
<!-- Recent Orders Table --> <!-- Recent Orders Table -->
<div x-show="!loading && recentOrders.length > 0" class="w-full mb-8 overflow-hidden rounded-lg shadow-xs"> <div x-show="!loading && recentOrders.length > 0" class="w-full mb-8 overflow-hidden rounded-lg shadow-xs">
<div class="w-full overflow-x-auto"> <div class="w-full overflow-x-auto">

View File

@@ -19,6 +19,7 @@ from .company import Company
from .content_page import ContentPage from .content_page import ContentPage
from .customer import Customer, CustomerAddress from .customer import Customer, CustomerAddress
from .email import EmailCategory, EmailLog, EmailStatus, EmailTemplate from .email import EmailCategory, EmailLog, EmailStatus, EmailTemplate
from .feature import Feature, FeatureCategory, FeatureCode, FeatureUILocation
from .inventory import Inventory from .inventory import Inventory
from .invoice import ( from .invoice import (
Invoice, Invoice,
@@ -108,6 +109,11 @@ __all__ = [
"EmailLog", "EmailLog",
"EmailStatus", "EmailStatus",
"EmailTemplate", "EmailTemplate",
# Features
"Feature",
"FeatureCategory",
"FeatureCode",
"FeatureUILocation",
# Product - Enums # Product - Enums
"ProductType", "ProductType",
"DigitalDeliveryMethod", "DigitalDeliveryMethod",

191
models/database/feature.py Normal file
View File

@@ -0,0 +1,191 @@
# models/database/feature.py
"""
Feature registry for tier-based access control.
Provides a database-driven feature registry that allows:
- Dynamic feature-to-tier assignment (no code changes needed)
- UI metadata for frontend rendering
- Feature categorization for organization
- Upgrade prompts with tier info
Features are assigned to tiers via the SubscriptionTier.features JSON array.
This model provides the metadata and acts as a registry of all available features.
"""
import enum
from datetime import UTC, datetime
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, Integer, String, Text
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class FeatureCategory(str, enum.Enum):
"""Feature categories for organization."""
ORDERS = "orders"
INVENTORY = "inventory"
ANALYTICS = "analytics"
INVOICING = "invoicing"
INTEGRATIONS = "integrations"
TEAM = "team"
BRANDING = "branding"
CUSTOMERS = "customers"
class FeatureUILocation(str, enum.Enum):
"""Where the feature appears in the UI."""
SIDEBAR = "sidebar" # Main navigation item
DASHBOARD = "dashboard" # Dashboard widget/section
SETTINGS = "settings" # Settings page option
API = "api" # API-only feature (no UI)
INLINE = "inline" # Inline feature within a page
class Feature(Base, TimestampMixin):
"""
Feature registry for tier-based access control.
Each feature represents a capability that can be enabled/disabled per tier.
The actual tier assignment is stored in SubscriptionTier.features as a JSON
array of feature codes. This table provides metadata for:
- UI rendering (icons, labels, locations)
- Upgrade prompts (which tier unlocks this?)
- Admin management (description, categorization)
Example features:
- analytics_dashboard: Full analytics with charts
- api_access: REST API access for integrations
- team_roles: Role-based permissions for team members
- automation_rules: Automatic order processing rules
"""
__tablename__ = "features"
id = Column(Integer, primary_key=True, index=True)
# Unique identifier used in code and tier.features JSON
code = Column(String(50), unique=True, nullable=False, index=True)
# Display info
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
# Categorization
category = Column(String(50), nullable=False, index=True)
# UI metadata - tells frontend how to render
ui_location = Column(String(50), nullable=True) # sidebar, dashboard, settings, api
ui_icon = Column(String(50), nullable=True) # Icon name (e.g., "chart-bar")
ui_route = Column(String(100), nullable=True) # Route pattern (e.g., "/vendor/{code}/analytics")
ui_badge_text = Column(String(20), nullable=True) # Badge to show (e.g., "Pro", "New")
# Minimum tier that includes this feature (for upgrade prompts)
# This is denormalized for performance - the actual assignment is in SubscriptionTier.features
minimum_tier_id = Column(
Integer, ForeignKey("subscription_tiers.id"), nullable=True, index=True
)
minimum_tier = relationship("SubscriptionTier", foreign_keys=[minimum_tier_id])
# Status
is_active = Column(Boolean, default=True, nullable=False) # Feature available at all
is_visible = Column(Boolean, default=True, nullable=False) # Show in UI even if locked
display_order = Column(Integer, default=0, nullable=False) # Sort order within category
# Indexes
__table_args__ = (
Index("idx_feature_category_order", "category", "display_order"),
Index("idx_feature_active_visible", "is_active", "is_visible"),
)
def __repr__(self) -> str:
return f"<Feature(code='{self.code}', name='{self.name}', category='{self.category}')>"
def to_dict(self) -> dict:
"""Convert to dictionary for API responses."""
return {
"id": self.id,
"code": self.code,
"name": self.name,
"description": self.description,
"category": self.category,
"ui_location": self.ui_location,
"ui_icon": self.ui_icon,
"ui_route": self.ui_route,
"ui_badge_text": self.ui_badge_text,
"minimum_tier_code": self.minimum_tier.code if self.minimum_tier else None,
"minimum_tier_name": self.minimum_tier.name if self.minimum_tier else None,
"is_active": self.is_active,
"is_visible": self.is_visible,
"display_order": self.display_order,
}
# ============================================================================
# Feature Code Constants
# ============================================================================
# These constants are used throughout the codebase for type safety.
# The actual feature definitions and tier assignments are in the database.
class FeatureCode:
"""
Feature code constants for use in @require_feature decorator and checks.
Usage:
@require_feature(FeatureCode.ANALYTICS_DASHBOARD)
def get_analytics(...):
...
if feature_service.has_feature(db, vendor_id, FeatureCode.API_ACCESS):
...
"""
# Orders
ORDER_MANAGEMENT = "order_management"
ORDER_BULK_ACTIONS = "order_bulk_actions"
ORDER_EXPORT = "order_export"
AUTOMATION_RULES = "automation_rules"
# Inventory
INVENTORY_BASIC = "inventory_basic"
INVENTORY_LOCATIONS = "inventory_locations"
INVENTORY_PURCHASE_ORDERS = "inventory_purchase_orders"
LOW_STOCK_ALERTS = "low_stock_alerts"
# Analytics
BASIC_REPORTS = "basic_reports"
ANALYTICS_DASHBOARD = "analytics_dashboard"
CUSTOM_REPORTS = "custom_reports"
EXPORT_REPORTS = "export_reports"
# Invoicing
INVOICE_LU = "invoice_lu"
INVOICE_EU_VAT = "invoice_eu_vat"
INVOICE_BULK = "invoice_bulk"
ACCOUNTING_EXPORT = "accounting_export"
# Integrations
LETZSHOP_SYNC = "letzshop_sync"
API_ACCESS = "api_access"
WEBHOOKS = "webhooks"
CUSTOM_INTEGRATIONS = "custom_integrations"
# Team
SINGLE_USER = "single_user"
TEAM_BASIC = "team_basic"
TEAM_ROLES = "team_roles"
AUDIT_LOG = "audit_log"
# Branding
BASIC_SHOP = "basic_shop"
CUSTOM_DOMAIN = "custom_domain"
WHITE_LABEL = "white_label"
# Customers
CUSTOMER_VIEW = "customer_view"
CUSTOMER_EXPORT = "customer_export"
CUSTOMER_MESSAGING = "customer_messaging"

View File

@@ -0,0 +1,205 @@
// static/shared/js/feature-store.js
/**
* Feature Store for Alpine.js
*
* Provides feature availability checking for tier-based access control.
* Loads features from the API on init and caches them for the session.
*
* Usage in templates:
*
* 1. Check if feature is available:
* <div x-show="$store.features.has('analytics_dashboard')">
* Analytics content here
* </div>
*
* 2. Show upgrade prompt if not available:
* <div x-show="!$store.features.has('analytics_dashboard')">
* <p>Upgrade to access Analytics</p>
* </div>
*
* 3. Conditionally render with x-if:
* <template x-if="$store.features.has('api_access')">
* <a href="/settings/api">API Settings</a>
* </template>
*
* 4. Use feature data for upgrade prompts:
* <p x-text="$store.features.getUpgradeTier('analytics_dashboard')"></p>
*
* 5. Get current tier info:
* <span x-text="$store.features.tierName"></span>
*/
(function () {
'use strict';
// Use centralized logger if available
const log = window.LogConfig?.log || console;
/**
* Feature Store
*/
const featureStore = {
// State
features: [], // Array of feature codes available to vendor
featuresMap: {}, // Full feature info keyed by code
tierCode: null, // Current tier code
tierName: null, // Current tier name
loading: true, // Loading state
loaded: false, // Whether features have been loaded
error: null, // Error message if load failed
/**
* Initialize the feature store
* Called automatically when Alpine starts
*/
async init() {
log.debug('[FeatureStore] Initializing...');
await this.loadFeatures();
},
/**
* Load features from API
*/
async loadFeatures() {
// Don't reload if already loaded
if (this.loaded) {
log.debug('[FeatureStore] Already loaded, skipping');
return;
}
// Get vendor code from URL
const vendorCode = this.getVendorCode();
if (!vendorCode) {
log.warn('[FeatureStore] No vendor code found in URL');
this.loading = false;
return;
}
try {
this.loading = true;
this.error = null;
// Fetch available features (lightweight endpoint)
const response = await window.apiClient.get('/vendor/features/available');
this.features = response.features || [];
this.tierCode = response.tier_code;
this.tierName = response.tier_name;
this.loaded = true;
log.debug(`[FeatureStore] Loaded ${this.features.length} features for ${this.tierName} tier`);
} catch (error) {
log.error('[FeatureStore] Failed to load features:', error);
this.error = error.message || 'Failed to load features';
// Set empty array so checks don't fail
this.features = [];
} finally {
this.loading = false;
}
},
/**
* Load full feature details (with metadata)
* Use this when you need upgrade info
*/
async loadFullFeatures() {
const vendorCode = this.getVendorCode();
if (!vendorCode) return;
try {
const response = await window.apiClient.get('/vendor/features');
// Build map for quick lookup
this.featuresMap = {};
for (const feature of response.features) {
this.featuresMap[feature.code] = feature;
}
log.debug(`[FeatureStore] Loaded full details for ${response.features.length} features`);
} catch (error) {
log.error('[FeatureStore] Failed to load full features:', error);
}
},
/**
* Check if vendor has access to a feature
* @param {string} featureCode - The feature code to check
* @returns {boolean} - Whether the feature is available
*/
has(featureCode) {
return this.features.includes(featureCode);
},
/**
* Check if vendor has access to ANY of the given features
* @param {...string} featureCodes - Feature codes to check
* @returns {boolean} - Whether any feature is available
*/
hasAny(...featureCodes) {
return featureCodes.some(code => this.has(code));
},
/**
* Check if vendor has access to ALL of the given features
* @param {...string} featureCodes - Feature codes to check
* @returns {boolean} - Whether all features are available
*/
hasAll(...featureCodes) {
return featureCodes.every(code => this.has(code));
},
/**
* Get feature info (requires loadFullFeatures first)
* @param {string} featureCode - The feature code
* @returns {object|null} - Feature info or null
*/
getFeature(featureCode) {
return this.featuresMap[featureCode] || null;
},
/**
* Get the tier name required for a feature
* @param {string} featureCode - The feature code
* @returns {string|null} - Tier name or null
*/
getUpgradeTier(featureCode) {
const feature = this.getFeature(featureCode);
return feature?.minimum_tier_name || null;
},
/**
* Get vendor code from URL
* @returns {string|null}
*/
getVendorCode() {
const path = window.location.pathname;
const segments = path.split('/').filter(Boolean);
if (segments[0] === 'vendor' && segments[1]) {
return segments[1];
}
return null;
},
/**
* Reload features (e.g., after tier change)
*/
async reload() {
this.loaded = false;
this.features = [];
this.featuresMap = {};
await this.loadFeatures();
}
};
// Register Alpine store when Alpine is available
document.addEventListener('alpine:init', () => {
Alpine.store('features', featureStore);
log.debug('[FeatureStore] Registered as Alpine store');
});
// Also expose globally for non-Alpine usage
window.FeatureStore = featureStore;
})();

View File

@@ -0,0 +1,361 @@
// static/shared/js/upgrade-prompts.js
/**
* Upgrade Prompts System
*
* Provides contextual upgrade prompts based on:
* - Usage limits approaching/reached
* - Locked features
*
* Usage:
*
* 1. Initialize the store (auto-loads usage on init):
* <div x-data x-init="$store.upgrade.loadUsage()">
*
* 2. Show limit warning banner:
* <template x-if="$store.upgrade.shouldShowLimitWarning('orders')">
* <div x-html="$store.upgrade.getLimitWarningHTML('orders')"></div>
* </template>
*
* 3. Check before action:
* <button @click="$store.upgrade.checkLimitAndProceed('products', () => createProduct())">
* Add Product
* </button>
*
* 4. Show upgrade CTA on dashboard:
* <template x-if="$store.upgrade.hasUpgradeRecommendation">
* <div x-html="$store.upgrade.getUpgradeCardHTML()"></div>
* </template>
*/
(function () {
'use strict';
const log = window.LogConfig?.log || console;
/**
* Upgrade Prompts Store
*/
const upgradeStore = {
// State
usage: null,
loading: false,
loaded: false,
error: null,
// Computed-like getters
get hasLimitsApproaching() {
return this.usage?.has_limits_approaching || false;
},
get hasLimitsReached() {
return this.usage?.has_limits_reached || false;
},
get hasUpgradeRecommendation() {
return this.usage?.upgrade_available && (this.hasLimitsApproaching || this.hasLimitsReached);
},
get upgradeReasons() {
return this.usage?.upgrade_reasons || [];
},
get currentTier() {
return this.usage?.tier || null;
},
get nextTier() {
return this.usage?.upgrade_tier || null;
},
/**
* Load usage data from API
*/
async loadUsage() {
if (this.loaded || this.loading) return;
try {
this.loading = true;
this.error = null;
const response = await window.apiClient.get('/vendor/usage');
this.usage = response;
this.loaded = true;
log.debug('[UpgradePrompts] Loaded usage data', this.usage);
} catch (error) {
log.error('[UpgradePrompts] Failed to load usage:', error);
this.error = error.message;
} finally {
this.loading = false;
}
},
/**
* Get usage metric by name
*/
getMetric(name) {
if (!this.usage?.usage) return null;
return this.usage.usage.find(m => m.name === name);
},
/**
* Check if should show limit warning for a metric
*/
shouldShowLimitWarning(metricName) {
const metric = this.getMetric(metricName);
return metric && (metric.is_approaching_limit || metric.is_at_limit);
},
/**
* Check if at limit for a metric
*/
isAtLimit(metricName) {
const metric = this.getMetric(metricName);
return metric?.is_at_limit || false;
},
/**
* Get percentage used for a metric
*/
getPercentage(metricName) {
const metric = this.getMetric(metricName);
return metric?.percentage || 0;
},
/**
* Get formatted usage string (e.g., "85/100")
*/
getUsageString(metricName) {
const metric = this.getMetric(metricName);
if (!metric) return '';
if (metric.is_unlimited) return `${metric.current} (unlimited)`;
return `${metric.current}/${metric.limit}`;
},
/**
* Get vendor code from URL
*/
getVendorCode() {
const path = window.location.pathname;
const segments = path.split('/').filter(Boolean);
if (segments[0] === 'vendor' && segments[1]) {
return segments[1];
}
return null;
},
/**
* Get billing URL
*/
getBillingUrl() {
const vendorCode = this.getVendorCode();
return vendorCode ? `/vendor/${vendorCode}/billing` : '#';
},
/**
* Check limit before action, show modal if at limit
*/
async checkLimitAndProceed(limitType, onSuccess) {
try {
const response = await window.apiClient.get(`/vendor/usage/check/${limitType}`);
if (response.can_proceed) {
if (typeof onSuccess === 'function') {
onSuccess();
}
return true;
} else {
// Show upgrade modal
this.showLimitReachedModal(limitType, response);
return false;
}
} catch (error) {
log.error('[UpgradePrompts] Failed to check limit:', error);
// Proceed anyway on error (fail open)
if (typeof onSuccess === 'function') {
onSuccess();
}
return true;
}
},
/**
* Show limit reached modal
*/
showLimitReachedModal(limitType, response) {
const limitNames = {
'orders': 'monthly orders',
'products': 'products',
'team_members': 'team members'
};
const limitName = limitNames[limitType] || limitType;
const message = response.message || `You've reached your ${limitName} limit.`;
// Use browser confirm for simplicity - could be replaced with custom modal
const shouldUpgrade = confirm(
`${message}\n\n` +
`Current: ${response.current}/${response.limit}\n\n` +
(response.upgrade_tier_name
? `Upgrade to ${response.upgrade_tier_name} to get more ${limitName}.\n\nGo to billing page?`
: 'Contact support for more capacity.')
);
if (shouldUpgrade && response.upgrade_tier_code) {
window.location.href = this.getBillingUrl();
}
},
/**
* Get limit warning banner HTML
*/
getLimitWarningHTML(metricName) {
const metric = this.getMetric(metricName);
if (!metric) return '';
const names = {
'orders': 'monthly orders',
'products': 'products',
'team_members': 'team members'
};
const name = names[metricName] || metricName;
if (metric.is_at_limit) {
return `
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-4">
<div class="flex items-center">
<svg class="w-5 h-5 text-red-500 mr-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg>
<div class="flex-1">
<p class="text-sm font-medium text-red-800 dark:text-red-200">
You've reached your ${name} limit (${metric.current}/${metric.limit})
</p>
</div>
<a href="${this.getBillingUrl()}"
class="ml-4 px-3 py-1 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded">
Upgrade
</a>
</div>
</div>
`;
} else if (metric.is_approaching_limit) {
return `
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-4">
<div class="flex items-center">
<svg class="w-5 h-5 text-yellow-500 mr-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
<div class="flex-1">
<p class="text-sm font-medium text-yellow-800 dark:text-yellow-200">
You're approaching your ${name} limit (${metric.current}/${metric.limit} - ${Math.round(metric.percentage)}%)
</p>
</div>
<a href="${this.getBillingUrl()}"
class="ml-4 px-3 py-1 text-sm font-medium text-yellow-800 bg-yellow-200 hover:bg-yellow-300 dark:bg-yellow-800 dark:text-yellow-200 dark:hover:bg-yellow-700 rounded">
Upgrade
</a>
</div>
</div>
`;
}
return '';
},
/**
* Get upgrade card HTML for dashboard
*/
getUpgradeCardHTML() {
if (!this.usage?.upgrade_tier) return '';
const tier = this.usage.upgrade_tier;
const reasons = this.usage.upgrade_reasons || [];
return `
<div class="bg-gradient-to-r from-purple-500 to-indigo-600 rounded-lg p-6 text-white">
<div class="flex items-start justify-between">
<div>
<h3 class="text-lg font-semibold mb-2">Upgrade to ${tier.name}</h3>
${reasons.length > 0 ? `
<ul class="text-sm opacity-90 mb-4 space-y-1">
${reasons.map(r => `<li>• ${r}</li>`).join('')}
</ul>
` : ''}
${tier.benefits.length > 0 ? `
<p class="text-sm opacity-80 mb-2">Get access to:</p>
<ul class="text-sm space-y-1 mb-4">
${tier.benefits.slice(0, 4).map(b => `
<li class="flex items-center">
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
${b}
</li>
`).join('')}
</ul>
` : ''}
</div>
<div class="text-right">
<p class="text-2xl font-bold">€${(tier.price_monthly_cents / 100).toFixed(0)}</p>
<p class="text-sm opacity-80">/month</p>
</div>
</div>
<a href="${this.getBillingUrl()}"
class="mt-4 inline-flex items-center px-4 py-2 bg-white text-purple-600 font-medium rounded-lg hover:bg-gray-100 transition-colors">
Upgrade Now
<svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
</svg>
</a>
</div>
`;
},
/**
* Get compact usage bar HTML
*/
getUsageBarHTML(metricName) {
const metric = this.getMetric(metricName);
if (!metric || metric.is_unlimited) return '';
const percentage = Math.min(metric.percentage, 100);
const colorClass = metric.is_at_limit
? 'bg-red-500'
: metric.is_approaching_limit
? 'bg-yellow-500'
: 'bg-green-500';
return `
<div class="w-full">
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mb-1">
<span>${metric.current} / ${metric.limit}</span>
<span>${Math.round(percentage)}%</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div class="${colorClass} h-2 rounded-full transition-all" style="width: ${percentage}%"></div>
</div>
</div>
`;
},
/**
* Reload usage data
*/
async reload() {
this.loaded = false;
await this.loadUsage();
}
};
// Register Alpine store when Alpine is available
document.addEventListener('alpine:init', () => {
Alpine.store('upgrade', upgradeStore);
log.debug('[UpgradePrompts] Registered as Alpine store');
});
// Also expose globally
window.UpgradePrompts = upgradeStore;
})();