From 7d1a421826be6620da3e45ea091649abf20ecb70 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Wed, 31 Dec 2025 18:28:40 +0100 Subject: [PATCH] feat: add comprehensive tier-based feature management system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../n2c3d4e5f6a7_add_features_table.py | 292 ++++++++++++ app/api/v1/admin/__init__.py | 4 + app/api/v1/admin/features.py | 357 ++++++++++++++ app/api/v1/vendor/__init__.py | 4 + app/api/v1/vendor/analytics.py | 7 + app/api/v1/vendor/features.py | 340 +++++++++++++ app/api/v1/vendor/invoices.py | 10 + app/api/v1/vendor/usage.py | 380 +++++++++++++++ app/core/feature_gate.py | 254 ++++++++++ app/handlers/stripe_webhook.py | 165 ++++++- app/routes/admin_pages.py | 24 + app/services/feature_service.py | 451 ++++++++++++++++++ app/templates/admin/features.html | 370 ++++++++++++++ app/templates/shared/macros/feature_gate.html | 335 +++++++++++++ app/templates/vendor/base.html | 10 +- app/templates/vendor/dashboard.html | 30 +- models/database/__init__.py | 6 + models/database/feature.py | 191 ++++++++ static/shared/js/feature-store.js | 205 ++++++++ static/shared/js/upgrade-prompts.js | 361 ++++++++++++++ 20 files changed, 3786 insertions(+), 10 deletions(-) create mode 100644 alembic/versions/n2c3d4e5f6a7_add_features_table.py create mode 100644 app/api/v1/admin/features.py create mode 100644 app/api/v1/vendor/features.py create mode 100644 app/api/v1/vendor/usage.py create mode 100644 app/core/feature_gate.py create mode 100644 app/services/feature_service.py create mode 100644 app/templates/admin/features.html create mode 100644 app/templates/shared/macros/feature_gate.html create mode 100644 models/database/feature.py create mode 100644 static/shared/js/feature-store.js create mode 100644 static/shared/js/upgrade-prompts.js diff --git a/alembic/versions/n2c3d4e5f6a7_add_features_table.py b/alembic/versions/n2c3d4e5f6a7_add_features_table.py new file mode 100644 index 00000000..b4fec6ae --- /dev/null +++ b/alembic/versions/n2c3d4e5f6a7_add_features_table.py @@ -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") diff --git a/app/api/v1/admin/__init__.py b/app/api/v1/admin/__init__.py index 4e9b14ac..93fbcb97 100644 --- a/app/api/v1/admin/__init__.py +++ b/app/api/v1/admin/__init__.py @@ -33,6 +33,7 @@ from . import ( content_pages, customers, dashboard, + features, images, inventory, letzshop, @@ -181,6 +182,9 @@ router.include_router( # Include subscription management endpoints router.include_router(subscriptions.router, tags=["admin-subscriptions"]) +# Include feature management endpoints +router.include_router(features.router, tags=["admin-features"]) + # ============================================================================ # Code Quality & Architecture diff --git a/app/api/v1/admin/features.py b/app/api/v1/admin/features.py new file mode 100644 index 00000000..7598edff --- /dev/null +++ b/app/api/v1/admin/features.py @@ -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), + } diff --git a/app/api/v1/vendor/__init__.py b/app/api/v1/vendor/__init__.py index 7d981ea0..7f297622 100644 --- a/app/api/v1/vendor/__init__.py +++ b/app/api/v1/vendor/__init__.py @@ -20,6 +20,7 @@ from . import ( content_pages, customers, dashboard, + features, info, inventory, invoices, @@ -36,6 +37,7 @@ from . import ( profile, settings, team, + usage, ) # 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(analytics.router, tags=["vendor-analytics"]) 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 router.include_router( diff --git a/app/api/v1/vendor/analytics.py b/app/api/v1/vendor/analytics.py index 99e42602..b3b9ca76 100644 --- a/app/api/v1/vendor/analytics.py +++ b/app/api/v1/vendor/analytics.py @@ -4,6 +4,10 @@ Vendor analytics and reporting endpoints. 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. + +Feature Requirements: +- basic_reports: Basic analytics (Essential tier) +- analytics_dashboard: Advanced analytics (Business tier) """ import logging @@ -13,7 +17,9 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api from app.core.database import get_db +from app.core.feature_gate import RequireFeature from app.services.stats_service import stats_service +from models.database.feature import FeatureCode from models.database.user import User from models.schema.stats import ( VendorAnalyticsCatalog, @@ -31,6 +37,7 @@ def get_vendor_analytics( period: str = Query("30d", description="Time period: 7d, 30d, 90d, 1y"), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), + _: None = Depends(RequireFeature(FeatureCode.BASIC_REPORTS, FeatureCode.ANALYTICS_DASHBOARD)), ): """Get vendor analytics data for specified time period.""" data = stats_service.get_vendor_analytics(db, current_user.token_vendor_id, period) diff --git a/app/api/v1/vendor/features.py b/app/api/v1/vendor/features.py new file mode 100644 index 00000000..8d1c9473 --- /dev/null +++ b/app/api/v1/vendor/features.py @@ -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} diff --git a/app/api/v1/vendor/invoices.py b/app/api/v1/vendor/invoices.py index 2ae448a1..9c2bd728 100644 --- a/app/api/v1/vendor/invoices.py +++ b/app/api/v1/vendor/invoices.py @@ -16,6 +16,12 @@ Endpoints: - POST /invoices/settings - Create invoice settings - PUT /invoices/settings - Update invoice settings - 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 @@ -27,6 +33,7 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api from app.core.database import get_db +from app.core.feature_gate import RequireFeature from app.exceptions.invoice import ( InvoiceNotFoundException, InvoicePDFGenerationException, @@ -34,6 +41,7 @@ from app.exceptions.invoice import ( InvoiceSettingsNotFoundException, ) from app.services.invoice_service import invoice_service +from models.database.feature import FeatureCode from models.database.user import User from models.schema.invoice import ( InvoiceCreate, @@ -61,11 +69,13 @@ logger = logging.getLogger(__name__) def get_invoice_settings( current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), + _: None = Depends(RequireFeature(FeatureCode.INVOICE_LU)), ): """ Get vendor invoice settings. Returns null if settings not yet configured. + Requires: invoice_lu feature (Essential tier) """ settings = invoice_service.get_settings(db, current_user.token_vendor_id) if settings: diff --git a/app/api/v1/vendor/usage.py b/app/api/v1/vendor/usage.py new file mode 100644 index 00000000..21e4df4d --- /dev/null +++ b/app/api/v1/vendor/usage.py @@ -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, + ) diff --git a/app/core/feature_gate.py b/app/core/feature_gate.py new file mode 100644 index 00000000..98b034fd --- /dev/null +++ b/app/core/feature_gate.py @@ -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", +] diff --git a/app/handlers/stripe_webhook.py b/app/handlers/stripe_webhook.py index 9ac08638..6a530881 100644 --- a/app/handlers/stripe_webhook.py +++ b/app/handlers/stripe_webhook.py @@ -6,6 +6,7 @@ Processes webhook events from Stripe: - Subscription lifecycle events - Invoice and payment events - Checkout session completion +- Add-on purchases """ import logging @@ -15,10 +16,12 @@ import stripe from sqlalchemy.orm import Session from models.database.subscription import ( + AddOnProduct, BillingHistory, StripeWebhookEvent, SubscriptionStatus, SubscriptionTier, + VendorAddOn, VendorSubscription, ) @@ -108,15 +111,34 @@ class StripeWebhookHandler: def _handle_checkout_completed( self, db: Session, event: stripe.Event ) -> dict: - """Handle checkout.session.completed event.""" + """ + Handle checkout.session.completed event. + + Handles two types of checkouts: + 1. Subscription checkout - Updates VendorSubscription + 2. Add-on checkout - Creates VendorAddOn record + """ session = event.data.object vendor_id = session.metadata.get("vendor_id") + addon_code = session.metadata.get("addon_code") if not vendor_id: logger.warning(f"Checkout session {session.id} missing vendor_id") return {"action": "skipped", "reason": "no vendor_id"} vendor_id = int(vendor_id) + + # Check if this is an add-on purchase + if addon_code: + return self._handle_addon_checkout(db, session, vendor_id, addon_code) + + # Otherwise, handle subscription checkout + return self._handle_subscription_checkout(db, session, vendor_id) + + def _handle_subscription_checkout( + self, db: Session, session, vendor_id: int + ) -> dict: + """Handle subscription checkout completion.""" subscription = ( db.query(VendorSubscription) .filter(VendorSubscription.vendor_id == vendor_id) @@ -147,9 +169,112 @@ class StripeWebhookHandler: stripe_sub.trial_end, tz=timezone.utc ) - logger.info(f"Checkout completed for vendor {vendor_id}") + logger.info(f"Subscription checkout completed for vendor {vendor_id}") return {"action": "activated", "vendor_id": vendor_id} + def _handle_addon_checkout( + self, db: Session, session, vendor_id: int, addon_code: str + ) -> dict: + """ + Handle add-on checkout completion. + + Creates a VendorAddOn record for the purchased add-on. + """ + # Get the add-on product + addon_product = ( + db.query(AddOnProduct) + .filter(AddOnProduct.code == addon_code) + .first() + ) + + if not addon_product: + logger.error(f"Add-on product '{addon_code}' not found") + return {"action": "failed", "reason": f"addon '{addon_code}' not found"} + + # Check if vendor already has this add-on active + existing_addon = ( + db.query(VendorAddOn) + .filter( + VendorAddOn.vendor_id == vendor_id, + VendorAddOn.addon_product_id == addon_product.id, + VendorAddOn.status == "active", + ) + .first() + ) + + if existing_addon: + logger.info( + f"Vendor {vendor_id} already has active add-on {addon_code}, " + f"updating quantity" + ) + # For quantity-based add-ons, we could increment + # For now, just log and return + return { + "action": "already_exists", + "vendor_id": vendor_id, + "addon_code": addon_code, + } + + # Get domain name from metadata (for domain add-ons) + domain_name = session.metadata.get("domain_name") + if domain_name == "": + domain_name = None + + # Get subscription item ID from Stripe subscription + stripe_subscription_item_id = None + if session.subscription: + try: + stripe_sub = stripe.Subscription.retrieve(session.subscription) + if stripe_sub.items.data: + # Find the item matching our add-on price + for item in stripe_sub.items.data: + if item.price.id == addon_product.stripe_price_id: + stripe_subscription_item_id = item.id + break + except Exception as e: + logger.warning(f"Could not retrieve subscription items: {e}") + + # Get period dates from subscription + period_start = None + period_end = None + if session.subscription: + try: + stripe_sub = stripe.Subscription.retrieve(session.subscription) + period_start = datetime.fromtimestamp( + stripe_sub.current_period_start, tz=timezone.utc + ) + period_end = datetime.fromtimestamp( + stripe_sub.current_period_end, tz=timezone.utc + ) + except Exception as e: + logger.warning(f"Could not retrieve subscription period: {e}") + + # Create VendorAddOn record + vendor_addon = VendorAddOn( + vendor_id=vendor_id, + addon_product_id=addon_product.id, + status="active", + domain_name=domain_name, + quantity=1, # Default quantity, could be from session line items + stripe_subscription_item_id=stripe_subscription_item_id, + period_start=period_start, + period_end=period_end, + ) + db.add(vendor_addon) + + logger.info( + f"Add-on '{addon_code}' purchased by vendor {vendor_id}" + + (f" for domain {domain_name}" if domain_name else "") + ) + + return { + "action": "addon_created", + "vendor_id": vendor_id, + "addon_code": addon_code, + "addon_id": vendor_addon.id, + "domain_name": domain_name, + } + def _handle_subscription_created( self, db: Session, event: stripe.Event ) -> dict: @@ -240,7 +365,11 @@ class StripeWebhookHandler: def _handle_subscription_deleted( self, db: Session, event: stripe.Event ) -> dict: - """Handle customer.subscription.deleted event.""" + """ + Handle customer.subscription.deleted event. + + Cancels the subscription and all associated add-ons. + """ stripe_sub = event.data.object subscription = ( @@ -253,11 +382,37 @@ class StripeWebhookHandler: logger.warning(f"No subscription found for {stripe_sub.id}") return {"action": "skipped", "reason": "no subscription"} + vendor_id = subscription.vendor_id + + # Cancel the subscription subscription.status = SubscriptionStatus.CANCELLED subscription.cancelled_at = datetime.now(timezone.utc) - logger.info(f"Subscription deleted for vendor {subscription.vendor_id}") - return {"action": "cancelled", "vendor_id": subscription.vendor_id} + # Also cancel all active add-ons for this vendor + cancelled_addons = ( + db.query(VendorAddOn) + .filter( + VendorAddOn.vendor_id == vendor_id, + VendorAddOn.status == "active", + ) + .all() + ) + + addon_count = 0 + for addon in cancelled_addons: + addon.status = "cancelled" + addon.cancelled_at = datetime.now(timezone.utc) + addon_count += 1 + + if addon_count > 0: + logger.info(f"Cancelled {addon_count} add-ons for vendor {vendor_id}") + + logger.info(f"Subscription deleted for vendor {vendor_id}") + return { + "action": "cancelled", + "vendor_id": vendor_id, + "addons_cancelled": addon_count, + } def _handle_invoice_paid(self, db: Session, event: stripe.Event) -> dict: """Handle invoice.paid event.""" diff --git a/app/routes/admin_pages.py b/app/routes/admin_pages.py index b832e0b9..14cfe84f 100644 --- a/app/routes/admin_pages.py +++ b/app/routes/admin_pages.py @@ -1286,3 +1286,27 @@ async def admin_platform_health( "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, + }, + ) diff --git a/app/services/feature_service.py b/app/services/feature_service.py new file mode 100644 index 00000000..fd72f354 --- /dev/null +++ b/app/services/feature_service.py @@ -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", +] diff --git a/app/templates/admin/features.html b/app/templates/admin/features.html new file mode 100644 index 00000000..31f8cb7a --- /dev/null +++ b/app/templates/admin/features.html @@ -0,0 +1,370 @@ +{% extends "admin/base.html" %} + +{% block title %}Feature Management{% endblock %} + +{% block alpine_data %}featuresPage(){% endblock %} + +{% block content %} +
+ +
+

+ Feature Management +

+

+ Configure which features are available to each subscription tier. +

+
+ + +
+ + + + +
+ + +
+ +
+ +
+ + +
+ +
+
+

+ Included Features +

+ +
+ +
+ + +
+ No features assigned to this tier +
+
+
+ + +
+
+

+ Available to Add +

+ +
+ +
+ + +
+ All features are assigned to this tier +
+
+
+
+ + +
+ +
+ + +
+
+

+ All Features +

+

+ Complete list of all platform features with their minimum tier requirement. +

+
+
+ + + + + + + + + + + + +
+ Feature + + Category + + Minimum Tier + + Status +
+
+
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/templates/shared/macros/feature_gate.html b/app/templates/shared/macros/feature_gate.html new file mode 100644 index 00000000..2dca46bf --- /dev/null +++ b/app/templates/shared/macros/feature_gate.html @@ -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") %} +
Analytics content here
+ {% 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) %} +
+ {{ caller() }} +
+{% if show_fallback %} +
+ {{ feature_locked(feature_code) }} +
+{% 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) %} +
+ {# Lock icon #} +
+ + + +
+ + {# Title #} +

+ {% if title %} + {{ title }} + {% else %} + Premium Feature + {% endif %} +

+ + {# Description #} +

+ {% if description %} + {{ description }} + {% else %} + + This feature requires a plan upgrade. + + {% endif %} +

+ + {# Tier badge #} +

+ Available on + higher tier + plan +

+ + {% if show_upgrade_button %} + {# Upgrade button #} + + + + + Upgrade Plan + + {% endif %} +
+{% 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) %} +
+
+
+ + + +

+ {% if message %} + {{ message }} + {% else %} + + Upgrade to unlock this feature + + {% endif %} +

+
+ + Upgrade + +
+
+{% endmacro %} + + +{# ============================================================================= + Feature Badge + Small badge to show feature tier requirement + + Parameters: + - feature_code: The feature code + ============================================================================= #} +{% macro feature_badge(feature_code) %} + + + + + Pro + +{% 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='') %} + + +{% 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) %} +
+
+{% 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) %} +
+
+ {{ label }} + +
+
+
+{% 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='') %} +
+
+{% 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='') %} + +{% endmacro %} + + +{# ============================================================================= + Tier Badge + Shows current tier as a badge + + Parameters: + - class: Additional CSS classes + ============================================================================= #} +{% macro tier_badge(class='') %} + + +{% endmacro %} + + +{# ============================================================================= + Quick Upgrade Link + Simple upgrade link that appears when limits are reached + ============================================================================= #} +{% macro quick_upgrade_link() %} + + + + + Upgrade to + +{% endmacro %} diff --git a/app/templates/vendor/base.html b/app/templates/vendor/base.html index a69c5930..ca5d3082 100644 --- a/app/templates/vendor/base.html +++ b/app/templates/vendor/base.html @@ -62,7 +62,13 @@ - + + + + + + + - + {% block extra_scripts %}{% endblock %} \ No newline at end of file diff --git a/app/templates/vendor/dashboard.html b/app/templates/vendor/dashboard.html index d0c39c4d..662f8516 100644 --- a/app/templates/vendor/dashboard.html +++ b/app/templates/vendor/dashboard.html @@ -1,16 +1,24 @@ {# app/templates/vendor/dashboard.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 alpine_data %}vendorDashboard(){% endblock %} {% block content %} + +{{ limit_warning("orders") }} +{{ limit_warning("products") }} +
-

- Dashboard -

+
+

+ Dashboard +

+ {{ tier_badge() }} +
+ +
+
+ {{ usage_bar("orders", "Monthly Orders") }} +
+
+ {{ usage_bar("products", "Products") }} +
+
+ {{ usage_bar("team_members", "Team Members") }} +
+
+
diff --git a/models/database/__init__.py b/models/database/__init__.py index 90283132..a922fdec 100644 --- a/models/database/__init__.py +++ b/models/database/__init__.py @@ -19,6 +19,7 @@ from .company import Company from .content_page import ContentPage from .customer import Customer, CustomerAddress from .email import EmailCategory, EmailLog, EmailStatus, EmailTemplate +from .feature import Feature, FeatureCategory, FeatureCode, FeatureUILocation from .inventory import Inventory from .invoice import ( Invoice, @@ -108,6 +109,11 @@ __all__ = [ "EmailLog", "EmailStatus", "EmailTemplate", + # Features + "Feature", + "FeatureCategory", + "FeatureCode", + "FeatureUILocation", # Product - Enums "ProductType", "DigitalDeliveryMethod", diff --git a/models/database/feature.py b/models/database/feature.py new file mode 100644 index 00000000..b151bc77 --- /dev/null +++ b/models/database/feature.py @@ -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"" + + 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" diff --git a/static/shared/js/feature-store.js b/static/shared/js/feature-store.js new file mode 100644 index 00000000..fad9f1ab --- /dev/null +++ b/static/shared/js/feature-store.js @@ -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: + *
+ * Analytics content here + *
+ * + * 2. Show upgrade prompt if not available: + *
+ *

Upgrade to access Analytics

+ *
+ * + * 3. Conditionally render with x-if: + * + * + * 4. Use feature data for upgrade prompts: + *

+ * + * 5. Get current tier info: + * + */ + +(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; + +})(); diff --git a/static/shared/js/upgrade-prompts.js b/static/shared/js/upgrade-prompts.js new file mode 100644 index 00000000..d9d63cfe --- /dev/null +++ b/static/shared/js/upgrade-prompts.js @@ -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): + *
+ * + * 2. Show limit warning banner: + * + * + * 3. Check before action: + * + * + * 4. Show upgrade CTA on dashboard: + * + */ + +(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 ` +
+
+ + + +
+

+ You've reached your ${name} limit (${metric.current}/${metric.limit}) +

+
+ + Upgrade + +
+
+ `; + } else if (metric.is_approaching_limit) { + return ` +
+
+ + + +
+

+ You're approaching your ${name} limit (${metric.current}/${metric.limit} - ${Math.round(metric.percentage)}%) +

+
+ + Upgrade + +
+
+ `; + } + + 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 ` +
+
+
+

Upgrade to ${tier.name}

+ ${reasons.length > 0 ? ` +
    + ${reasons.map(r => `
  • • ${r}
  • `).join('')} +
+ ` : ''} + ${tier.benefits.length > 0 ? ` +

Get access to:

+
    + ${tier.benefits.slice(0, 4).map(b => ` +
  • + + + + ${b} +
  • + `).join('')} +
+ ` : ''} +
+
+

€${(tier.price_monthly_cents / 100).toFixed(0)}

+

/month

+
+
+ + Upgrade Now + + + + +
+ `; + }, + + /** + * 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 ` +
+
+ ${metric.current} / ${metric.limit} + ${Math.round(percentage)}% +
+
+
+
+
+ `; + }, + + /** + * 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; + +})();