feat: add comprehensive tier-based feature management system
Implement database-driven feature gating with contextual upgrade prompts: - Add Feature model with 30 features across 8 categories - Create FeatureService with caching for tier-based feature checking - Add @require_feature decorator and RequireFeature dependency for backend enforcement - Create vendor features API (6 endpoints) and admin features API - Add Alpine.js feature store and upgrade prompts store for frontend - Create Jinja macros: feature_gate, feature_locked, limit_warning, usage_bar - Add usage API for tracking orders/products/team limits with upgrade info - Fix Stripe webhook to create VendorAddOn records on addon purchase - Integrate upgrade prompts into vendor dashboard with tier badge and usage bars 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
292
alembic/versions/n2c3d4e5f6a7_add_features_table.py
Normal file
292
alembic/versions/n2c3d4e5f6a7_add_features_table.py
Normal file
@@ -0,0 +1,292 @@
|
||||
"""add features table and seed data
|
||||
|
||||
Revision ID: n2c3d4e5f6a7
|
||||
Revises: ba2c0ce78396
|
||||
Create Date: 2025-12-31 10:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "n2c3d4e5f6a7"
|
||||
down_revision: Union[str, None] = "ba2c0ce78396"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Feature Definitions
|
||||
# ============================================================================
|
||||
# category, code, name, description, ui_location, ui_icon, ui_route, display_order
|
||||
FEATURES = [
|
||||
# Orders (category: orders)
|
||||
("orders", "order_management", "Order Management", "View and manage orders", "sidebar", "clipboard-list", "/vendor/{code}/orders", 1),
|
||||
("orders", "order_bulk_actions", "Bulk Order Actions", "Process multiple orders at once", "inline", None, None, 2),
|
||||
("orders", "order_export", "Order Export", "Export orders to CSV/Excel", "inline", "download", None, 3),
|
||||
("orders", "automation_rules", "Automation Rules", "Automatic order processing rules", "sidebar", "cog", "/vendor/{code}/automation", 4),
|
||||
|
||||
# Inventory (category: inventory)
|
||||
("inventory", "inventory_basic", "Basic Inventory", "Track product stock levels", "sidebar", "cube", "/vendor/{code}/inventory", 1),
|
||||
("inventory", "inventory_locations", "Warehouse Locations", "Manage multiple warehouse locations", "inline", "map-pin", None, 2),
|
||||
("inventory", "inventory_purchase_orders", "Purchase Orders", "Create and manage purchase orders", "sidebar", "shopping-cart", "/vendor/{code}/purchase-orders", 3),
|
||||
("inventory", "low_stock_alerts", "Low Stock Alerts", "Get notified when stock is low", "inline", "bell", None, 4),
|
||||
|
||||
# Analytics (category: analytics)
|
||||
("analytics", "basic_reports", "Basic Reports", "Essential sales and order reports", "sidebar", "chart-pie", "/vendor/{code}/reports", 1),
|
||||
("analytics", "analytics_dashboard", "Analytics Dashboard", "Advanced analytics with charts and trends", "sidebar", "chart-bar", "/vendor/{code}/analytics", 2),
|
||||
("analytics", "custom_reports", "Custom Reports", "Build custom report configurations", "inline", "document-report", None, 3),
|
||||
("analytics", "export_reports", "Export Reports", "Export reports to various formats", "inline", "download", None, 4),
|
||||
|
||||
# Invoicing (category: invoicing)
|
||||
("invoicing", "invoice_lu", "Luxembourg Invoicing", "Generate compliant Luxembourg invoices", "sidebar", "document-text", "/vendor/{code}/invoices", 1),
|
||||
("invoicing", "invoice_eu_vat", "EU VAT Support", "Handle EU VAT for cross-border sales", "inline", "globe", None, 2),
|
||||
("invoicing", "invoice_bulk", "Bulk Invoicing", "Generate invoices in bulk", "inline", "document-duplicate", None, 3),
|
||||
("invoicing", "accounting_export", "Accounting Export", "Export to accounting software formats", "inline", "calculator", None, 4),
|
||||
|
||||
# Integrations (category: integrations)
|
||||
("integrations", "letzshop_sync", "Letzshop Sync", "Sync orders and products with Letzshop", "settings", "refresh", None, 1),
|
||||
("integrations", "api_access", "API Access", "REST API access for custom integrations", "settings", "code", "/vendor/{code}/settings/api", 2),
|
||||
("integrations", "webhooks", "Webhooks", "Receive real-time event notifications", "settings", "lightning-bolt", "/vendor/{code}/settings/webhooks", 3),
|
||||
("integrations", "custom_integrations", "Custom Integrations", "Connect with any third-party service", "settings", "puzzle", None, 4),
|
||||
|
||||
# Team (category: team)
|
||||
("team", "single_user", "Single User", "One user account", "api", None, None, 1),
|
||||
("team", "team_basic", "Team Access", "Invite team members", "sidebar", "users", "/vendor/{code}/team", 2),
|
||||
("team", "team_roles", "Team Roles", "Role-based permissions for team members", "inline", "shield-check", None, 3),
|
||||
("team", "audit_log", "Audit Log", "Track all user actions", "sidebar", "clipboard-check", "/vendor/{code}/audit-log", 4),
|
||||
|
||||
# Branding (category: branding)
|
||||
("branding", "basic_shop", "Basic Shop", "Your shop on the platform", "api", None, None, 1),
|
||||
("branding", "custom_domain", "Custom Domain", "Use your own domain name", "settings", "globe-alt", None, 2),
|
||||
("branding", "white_label", "White Label", "Remove platform branding entirely", "settings", "color-swatch", None, 3),
|
||||
|
||||
# Customers (category: customers)
|
||||
("customers", "customer_view", "Customer View", "View customer information", "sidebar", "user-group", "/vendor/{code}/customers", 1),
|
||||
("customers", "customer_export", "Customer Export", "Export customer data", "inline", "download", None, 2),
|
||||
("customers", "customer_messaging", "Customer Messaging", "Send messages to customers", "inline", "chat", None, 3),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Tier Feature Assignments
|
||||
# ============================================================================
|
||||
# tier_code -> list of feature codes
|
||||
TIER_FEATURES = {
|
||||
"essential": [
|
||||
"order_management",
|
||||
"inventory_basic",
|
||||
"basic_reports",
|
||||
"invoice_lu",
|
||||
"letzshop_sync",
|
||||
"single_user",
|
||||
"basic_shop",
|
||||
"customer_view",
|
||||
],
|
||||
"professional": [
|
||||
# All Essential features
|
||||
"order_management",
|
||||
"order_bulk_actions",
|
||||
"order_export",
|
||||
"inventory_basic",
|
||||
"inventory_locations",
|
||||
"inventory_purchase_orders",
|
||||
"low_stock_alerts",
|
||||
"basic_reports",
|
||||
"invoice_lu",
|
||||
"invoice_eu_vat",
|
||||
"letzshop_sync",
|
||||
"team_basic",
|
||||
"basic_shop",
|
||||
"customer_view",
|
||||
"customer_export",
|
||||
],
|
||||
"business": [
|
||||
# All Professional features
|
||||
"order_management",
|
||||
"order_bulk_actions",
|
||||
"order_export",
|
||||
"automation_rules",
|
||||
"inventory_basic",
|
||||
"inventory_locations",
|
||||
"inventory_purchase_orders",
|
||||
"low_stock_alerts",
|
||||
"basic_reports",
|
||||
"analytics_dashboard",
|
||||
"custom_reports",
|
||||
"export_reports",
|
||||
"invoice_lu",
|
||||
"invoice_eu_vat",
|
||||
"invoice_bulk",
|
||||
"accounting_export",
|
||||
"letzshop_sync",
|
||||
"api_access",
|
||||
"webhooks",
|
||||
"team_basic",
|
||||
"team_roles",
|
||||
"audit_log",
|
||||
"basic_shop",
|
||||
"custom_domain",
|
||||
"customer_view",
|
||||
"customer_export",
|
||||
"customer_messaging",
|
||||
],
|
||||
"enterprise": [
|
||||
# All features
|
||||
"order_management",
|
||||
"order_bulk_actions",
|
||||
"order_export",
|
||||
"automation_rules",
|
||||
"inventory_basic",
|
||||
"inventory_locations",
|
||||
"inventory_purchase_orders",
|
||||
"low_stock_alerts",
|
||||
"basic_reports",
|
||||
"analytics_dashboard",
|
||||
"custom_reports",
|
||||
"export_reports",
|
||||
"invoice_lu",
|
||||
"invoice_eu_vat",
|
||||
"invoice_bulk",
|
||||
"accounting_export",
|
||||
"letzshop_sync",
|
||||
"api_access",
|
||||
"webhooks",
|
||||
"custom_integrations",
|
||||
"team_basic",
|
||||
"team_roles",
|
||||
"audit_log",
|
||||
"basic_shop",
|
||||
"custom_domain",
|
||||
"white_label",
|
||||
"customer_view",
|
||||
"customer_export",
|
||||
"customer_messaging",
|
||||
],
|
||||
}
|
||||
|
||||
# Minimum tier for each feature (for upgrade prompts)
|
||||
# Maps feature_code -> tier_code
|
||||
MINIMUM_TIER = {
|
||||
# Essential
|
||||
"order_management": "essential",
|
||||
"inventory_basic": "essential",
|
||||
"basic_reports": "essential",
|
||||
"invoice_lu": "essential",
|
||||
"letzshop_sync": "essential",
|
||||
"single_user": "essential",
|
||||
"basic_shop": "essential",
|
||||
"customer_view": "essential",
|
||||
# Professional
|
||||
"order_bulk_actions": "professional",
|
||||
"order_export": "professional",
|
||||
"inventory_locations": "professional",
|
||||
"inventory_purchase_orders": "professional",
|
||||
"low_stock_alerts": "professional",
|
||||
"invoice_eu_vat": "professional",
|
||||
"team_basic": "professional",
|
||||
"customer_export": "professional",
|
||||
# Business
|
||||
"automation_rules": "business",
|
||||
"analytics_dashboard": "business",
|
||||
"custom_reports": "business",
|
||||
"export_reports": "business",
|
||||
"invoice_bulk": "business",
|
||||
"accounting_export": "business",
|
||||
"api_access": "business",
|
||||
"webhooks": "business",
|
||||
"team_roles": "business",
|
||||
"audit_log": "business",
|
||||
"custom_domain": "business",
|
||||
"customer_messaging": "business",
|
||||
# Enterprise
|
||||
"custom_integrations": "enterprise",
|
||||
"white_label": "enterprise",
|
||||
}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create features table
|
||||
op.create_table(
|
||||
"features",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("code", sa.String(50), nullable=False),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("category", sa.String(50), nullable=False),
|
||||
sa.Column("ui_location", sa.String(50), nullable=True),
|
||||
sa.Column("ui_icon", sa.String(50), nullable=True),
|
||||
sa.Column("ui_route", sa.String(100), nullable=True),
|
||||
sa.Column("ui_badge_text", sa.String(20), nullable=True),
|
||||
sa.Column("minimum_tier_id", sa.Integer(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, default=True),
|
||||
sa.Column("is_visible", sa.Boolean(), nullable=False, default=True),
|
||||
sa.Column("display_order", sa.Integer(), nullable=False, default=0),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["minimum_tier_id"], ["subscription_tiers.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_features_code", "features", ["code"], unique=True)
|
||||
op.create_index("ix_features_category", "features", ["category"], unique=False)
|
||||
op.create_index("idx_feature_category_order", "features", ["category", "display_order"])
|
||||
op.create_index("idx_feature_active_visible", "features", ["is_active", "is_visible"])
|
||||
|
||||
# Get connection for data operations
|
||||
conn = op.get_bind()
|
||||
|
||||
# Get tier IDs
|
||||
tier_ids = {}
|
||||
result = conn.execute(sa.text("SELECT id, code FROM subscription_tiers"))
|
||||
for row in result:
|
||||
tier_ids[row[1]] = row[0]
|
||||
|
||||
# Insert features
|
||||
now = sa.func.now()
|
||||
for category, code, name, description, ui_location, ui_icon, ui_route, display_order in FEATURES:
|
||||
minimum_tier_code = MINIMUM_TIER.get(code)
|
||||
minimum_tier_id = tier_ids.get(minimum_tier_code) if minimum_tier_code else None
|
||||
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
INSERT INTO features (code, name, description, category, ui_location, ui_icon, ui_route,
|
||||
minimum_tier_id, is_active, is_visible, display_order, created_at, updated_at)
|
||||
VALUES (:code, :name, :description, :category, :ui_location, :ui_icon, :ui_route,
|
||||
:minimum_tier_id, 1, 1, :display_order, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
"""),
|
||||
{
|
||||
"code": code,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"category": category,
|
||||
"ui_location": ui_location,
|
||||
"ui_icon": ui_icon,
|
||||
"ui_route": ui_route,
|
||||
"minimum_tier_id": minimum_tier_id,
|
||||
"display_order": display_order,
|
||||
},
|
||||
)
|
||||
|
||||
# Update subscription_tiers with feature arrays
|
||||
for tier_code, features in TIER_FEATURES.items():
|
||||
features_json = json.dumps(features)
|
||||
conn.execute(
|
||||
sa.text("UPDATE subscription_tiers SET features = :features WHERE code = :code"),
|
||||
{"features": features_json, "code": tier_code},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Clear features from subscription_tiers
|
||||
conn = op.get_bind()
|
||||
conn.execute(sa.text("UPDATE subscription_tiers SET features = '[]'"))
|
||||
|
||||
# Drop features table
|
||||
op.drop_index("idx_feature_active_visible", table_name="features")
|
||||
op.drop_index("idx_feature_category_order", table_name="features")
|
||||
op.drop_index("ix_features_category", table_name="features")
|
||||
op.drop_index("ix_features_code", table_name="features")
|
||||
op.drop_table("features")
|
||||
@@ -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
|
||||
|
||||
357
app/api/v1/admin/features.py
Normal file
357
app/api/v1/admin/features.py
Normal file
@@ -0,0 +1,357 @@
|
||||
# app/api/v1/admin/features.py
|
||||
"""
|
||||
Admin feature management endpoints.
|
||||
|
||||
Provides endpoints for:
|
||||
- Listing all features with their tier assignments
|
||||
- Updating tier feature assignments
|
||||
- Managing feature metadata
|
||||
- Viewing feature usage statistics
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.services.feature_service import feature_service
|
||||
from models.database.feature import Feature
|
||||
from models.database.subscription import SubscriptionTier
|
||||
from models.database.user import User
|
||||
|
||||
router = APIRouter(prefix="/features")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Response Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class FeatureResponse(BaseModel):
|
||||
"""Feature information for admin."""
|
||||
|
||||
id: int
|
||||
code: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
category: str
|
||||
ui_location: str | None = None
|
||||
ui_icon: str | None = None
|
||||
ui_route: str | None = None
|
||||
ui_badge_text: str | None = None
|
||||
minimum_tier_id: int | None = None
|
||||
minimum_tier_code: str | None = None
|
||||
minimum_tier_name: str | None = None
|
||||
is_active: bool
|
||||
is_visible: bool
|
||||
display_order: int
|
||||
|
||||
|
||||
class FeatureListResponse(BaseModel):
|
||||
"""List of features."""
|
||||
|
||||
features: list[FeatureResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class TierFeaturesResponse(BaseModel):
|
||||
"""Tier with its features."""
|
||||
|
||||
id: int
|
||||
code: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
features: list[str]
|
||||
feature_count: int
|
||||
|
||||
|
||||
class TierListWithFeaturesResponse(BaseModel):
|
||||
"""All tiers with their features."""
|
||||
|
||||
tiers: list[TierFeaturesResponse]
|
||||
|
||||
|
||||
class UpdateTierFeaturesRequest(BaseModel):
|
||||
"""Request to update tier features."""
|
||||
|
||||
feature_codes: list[str]
|
||||
|
||||
|
||||
class UpdateFeatureRequest(BaseModel):
|
||||
"""Request to update feature metadata."""
|
||||
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
category: str | None = None
|
||||
ui_location: str | None = None
|
||||
ui_icon: str | None = None
|
||||
ui_route: str | None = None
|
||||
ui_badge_text: str | None = None
|
||||
minimum_tier_code: str | None = None
|
||||
is_active: bool | None = None
|
||||
is_visible: bool | None = None
|
||||
display_order: int | None = None
|
||||
|
||||
|
||||
class CategoryListResponse(BaseModel):
|
||||
"""List of feature categories."""
|
||||
|
||||
categories: list[str]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Endpoints
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("", response_model=FeatureListResponse)
|
||||
def list_features(
|
||||
category: str | None = Query(None, description="Filter by category"),
|
||||
active_only: bool = Query(False, description="Only active features"),
|
||||
current_user: User = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List all features with their tier assignments."""
|
||||
features = feature_service.get_all_features(
|
||||
db, category=category, active_only=active_only
|
||||
)
|
||||
|
||||
return FeatureListResponse(
|
||||
features=[
|
||||
FeatureResponse(
|
||||
id=f.id,
|
||||
code=f.code,
|
||||
name=f.name,
|
||||
description=f.description,
|
||||
category=f.category,
|
||||
ui_location=f.ui_location,
|
||||
ui_icon=f.ui_icon,
|
||||
ui_route=f.ui_route,
|
||||
ui_badge_text=f.ui_badge_text,
|
||||
minimum_tier_id=f.minimum_tier_id,
|
||||
minimum_tier_code=f.minimum_tier.code if f.minimum_tier else None,
|
||||
minimum_tier_name=f.minimum_tier.name if f.minimum_tier else None,
|
||||
is_active=f.is_active,
|
||||
is_visible=f.is_visible,
|
||||
display_order=f.display_order,
|
||||
)
|
||||
for f in features
|
||||
],
|
||||
total=len(features),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/categories", response_model=CategoryListResponse)
|
||||
def list_categories(
|
||||
current_user: User = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List all feature categories."""
|
||||
categories = feature_service.get_categories(db)
|
||||
return CategoryListResponse(categories=categories)
|
||||
|
||||
|
||||
@router.get("/tiers", response_model=TierListWithFeaturesResponse)
|
||||
def list_tiers_with_features(
|
||||
current_user: User = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List all tiers with their feature assignments."""
|
||||
tiers = (
|
||||
db.query(SubscriptionTier)
|
||||
.filter(SubscriptionTier.is_active == True) # noqa: E712
|
||||
.order_by(SubscriptionTier.display_order)
|
||||
.all()
|
||||
)
|
||||
|
||||
return TierListWithFeaturesResponse(
|
||||
tiers=[
|
||||
TierFeaturesResponse(
|
||||
id=t.id,
|
||||
code=t.code,
|
||||
name=t.name,
|
||||
description=t.description,
|
||||
features=t.features or [],
|
||||
feature_count=len(t.features or []),
|
||||
)
|
||||
for t in tiers
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{feature_code}", response_model=FeatureResponse)
|
||||
def get_feature(
|
||||
feature_code: str,
|
||||
current_user: User = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get a single feature by code."""
|
||||
feature = feature_service.get_feature_by_code(db, feature_code)
|
||||
|
||||
if not feature:
|
||||
raise HTTPException(status_code=404, detail=f"Feature '{feature_code}' not found")
|
||||
|
||||
return FeatureResponse(
|
||||
id=feature.id,
|
||||
code=feature.code,
|
||||
name=feature.name,
|
||||
description=feature.description,
|
||||
category=feature.category,
|
||||
ui_location=feature.ui_location,
|
||||
ui_icon=feature.ui_icon,
|
||||
ui_route=feature.ui_route,
|
||||
ui_badge_text=feature.ui_badge_text,
|
||||
minimum_tier_id=feature.minimum_tier_id,
|
||||
minimum_tier_code=feature.minimum_tier.code if feature.minimum_tier else None,
|
||||
minimum_tier_name=feature.minimum_tier.name if feature.minimum_tier else None,
|
||||
is_active=feature.is_active,
|
||||
is_visible=feature.is_visible,
|
||||
display_order=feature.display_order,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{feature_code}", response_model=FeatureResponse)
|
||||
def update_feature(
|
||||
feature_code: str,
|
||||
request: UpdateFeatureRequest,
|
||||
current_user: User = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update feature metadata."""
|
||||
feature = db.query(Feature).filter(Feature.code == feature_code).first()
|
||||
|
||||
if not feature:
|
||||
raise HTTPException(status_code=404, detail=f"Feature '{feature_code}' not found")
|
||||
|
||||
# Update fields if provided
|
||||
if request.name is not None:
|
||||
feature.name = request.name
|
||||
if request.description is not None:
|
||||
feature.description = request.description
|
||||
if request.category is not None:
|
||||
feature.category = request.category
|
||||
if request.ui_location is not None:
|
||||
feature.ui_location = request.ui_location
|
||||
if request.ui_icon is not None:
|
||||
feature.ui_icon = request.ui_icon
|
||||
if request.ui_route is not None:
|
||||
feature.ui_route = request.ui_route
|
||||
if request.ui_badge_text is not None:
|
||||
feature.ui_badge_text = request.ui_badge_text
|
||||
if request.is_active is not None:
|
||||
feature.is_active = request.is_active
|
||||
if request.is_visible is not None:
|
||||
feature.is_visible = request.is_visible
|
||||
if request.display_order is not None:
|
||||
feature.display_order = request.display_order
|
||||
|
||||
# Update minimum tier if provided
|
||||
if request.minimum_tier_code is not None:
|
||||
if request.minimum_tier_code == "":
|
||||
feature.minimum_tier_id = None
|
||||
else:
|
||||
tier = (
|
||||
db.query(SubscriptionTier)
|
||||
.filter(SubscriptionTier.code == request.minimum_tier_code)
|
||||
.first()
|
||||
)
|
||||
if not tier:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Tier '{request.minimum_tier_code}' not found",
|
||||
)
|
||||
feature.minimum_tier_id = tier.id
|
||||
|
||||
db.commit()
|
||||
db.refresh(feature)
|
||||
|
||||
logger.info(f"Updated feature {feature_code} by admin {current_user.id}")
|
||||
|
||||
return FeatureResponse(
|
||||
id=feature.id,
|
||||
code=feature.code,
|
||||
name=feature.name,
|
||||
description=feature.description,
|
||||
category=feature.category,
|
||||
ui_location=feature.ui_location,
|
||||
ui_icon=feature.ui_icon,
|
||||
ui_route=feature.ui_route,
|
||||
ui_badge_text=feature.ui_badge_text,
|
||||
minimum_tier_id=feature.minimum_tier_id,
|
||||
minimum_tier_code=feature.minimum_tier.code if feature.minimum_tier else None,
|
||||
minimum_tier_name=feature.minimum_tier.name if feature.minimum_tier else None,
|
||||
is_active=feature.is_active,
|
||||
is_visible=feature.is_visible,
|
||||
display_order=feature.display_order,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/tiers/{tier_code}/features", response_model=TierFeaturesResponse)
|
||||
def update_tier_features(
|
||||
tier_code: str,
|
||||
request: UpdateTierFeaturesRequest,
|
||||
current_user: User = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update features assigned to a tier."""
|
||||
try:
|
||||
tier = feature_service.update_tier_features(db, tier_code, request.feature_codes)
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
f"Updated tier {tier_code} features to {len(request.feature_codes)} features "
|
||||
f"by admin {current_user.id}"
|
||||
)
|
||||
|
||||
return TierFeaturesResponse(
|
||||
id=tier.id,
|
||||
code=tier.code,
|
||||
name=tier.name,
|
||||
description=tier.description,
|
||||
features=tier.features or [],
|
||||
feature_count=len(tier.features or []),
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/tiers/{tier_code}/features")
|
||||
def get_tier_features(
|
||||
tier_code: str,
|
||||
current_user: User = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get features assigned to a specific tier."""
|
||||
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first()
|
||||
|
||||
if not tier:
|
||||
raise HTTPException(status_code=404, detail=f"Tier '{tier_code}' not found")
|
||||
|
||||
# Get full feature details for the tier's features
|
||||
feature_codes = tier.features or []
|
||||
features = (
|
||||
db.query(Feature)
|
||||
.filter(Feature.code.in_(feature_codes))
|
||||
.order_by(Feature.category, Feature.display_order)
|
||||
.all()
|
||||
)
|
||||
|
||||
return {
|
||||
"tier_code": tier.code,
|
||||
"tier_name": tier.name,
|
||||
"features": [
|
||||
{
|
||||
"code": f.code,
|
||||
"name": f.name,
|
||||
"category": f.category,
|
||||
"description": f.description,
|
||||
}
|
||||
for f in features
|
||||
],
|
||||
"feature_count": len(features),
|
||||
}
|
||||
4
app/api/v1/vendor/__init__.py
vendored
4
app/api/v1/vendor/__init__.py
vendored
@@ -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(
|
||||
|
||||
7
app/api/v1/vendor/analytics.py
vendored
7
app/api/v1/vendor/analytics.py
vendored
@@ -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)
|
||||
|
||||
340
app/api/v1/vendor/features.py
vendored
Normal file
340
app/api/v1/vendor/features.py
vendored
Normal file
@@ -0,0 +1,340 @@
|
||||
# app/api/v1/vendor/features.py
|
||||
"""
|
||||
Vendor features API endpoints.
|
||||
|
||||
Provides feature availability information for the frontend to:
|
||||
- Show/hide UI elements based on tier
|
||||
- Display upgrade prompts for unavailable features
|
||||
- Load feature metadata for dynamic rendering
|
||||
|
||||
Endpoints:
|
||||
- GET /features/available - List of feature codes (for quick checks)
|
||||
- GET /features - Full feature list with availability and metadata
|
||||
- GET /features/{code} - Single feature details with upgrade info
|
||||
- GET /features/categories - List feature categories
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from app.services.feature_service import feature_service
|
||||
from models.database.user import User
|
||||
|
||||
router = APIRouter(prefix="/features")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Response Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class FeatureCodeListResponse(BaseModel):
|
||||
"""Simple list of available feature codes for quick checks."""
|
||||
|
||||
features: list[str]
|
||||
tier_code: str
|
||||
tier_name: str
|
||||
|
||||
|
||||
class FeatureResponse(BaseModel):
|
||||
"""Full feature information."""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
category: str
|
||||
ui_location: str | None = None
|
||||
ui_icon: str | None = None
|
||||
ui_route: str | None = None
|
||||
ui_badge_text: str | None = None
|
||||
is_available: bool
|
||||
minimum_tier_code: str | None = None
|
||||
minimum_tier_name: str | None = None
|
||||
|
||||
|
||||
class FeatureListResponse(BaseModel):
|
||||
"""List of features with metadata."""
|
||||
|
||||
features: list[FeatureResponse]
|
||||
available_count: int
|
||||
total_count: int
|
||||
tier_code: str
|
||||
tier_name: str
|
||||
|
||||
|
||||
class FeatureDetailResponse(BaseModel):
|
||||
"""Single feature detail with upgrade info."""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
category: str
|
||||
ui_location: str | None = None
|
||||
ui_icon: str | None = None
|
||||
ui_route: str | None = None
|
||||
is_available: bool
|
||||
# Upgrade info (only if not available)
|
||||
upgrade_tier_code: str | None = None
|
||||
upgrade_tier_name: str | None = None
|
||||
upgrade_tier_price_monthly_cents: int | None = None
|
||||
|
||||
|
||||
class CategoryListResponse(BaseModel):
|
||||
"""List of feature categories."""
|
||||
|
||||
categories: list[str]
|
||||
|
||||
|
||||
class FeatureGroupedResponse(BaseModel):
|
||||
"""Features grouped by category."""
|
||||
|
||||
categories: dict[str, list[FeatureResponse]]
|
||||
available_count: int
|
||||
total_count: int
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Endpoints
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/available", response_model=FeatureCodeListResponse)
|
||||
def get_available_features(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get list of feature codes available to vendor.
|
||||
|
||||
This is a lightweight endpoint for quick feature checks.
|
||||
Use this to populate a frontend feature store on app init.
|
||||
|
||||
Returns:
|
||||
List of feature codes the vendor has access to
|
||||
"""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
# Get subscription for tier info
|
||||
from app.services.subscription_service import subscription_service
|
||||
|
||||
subscription = subscription_service.get_or_create_subscription(db, vendor_id)
|
||||
tier = subscription.tier_obj
|
||||
|
||||
# Get available features
|
||||
feature_codes = feature_service.get_available_feature_codes(db, vendor_id)
|
||||
|
||||
return FeatureCodeListResponse(
|
||||
features=feature_codes,
|
||||
tier_code=subscription.tier,
|
||||
tier_name=tier.name if tier else subscription.tier.title(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=FeatureListResponse)
|
||||
def get_features(
|
||||
category: str | None = Query(None, description="Filter by category"),
|
||||
include_unavailable: bool = Query(True, description="Include features not available to vendor"),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get all features with availability status and metadata.
|
||||
|
||||
This is a comprehensive endpoint for building feature-gated UIs.
|
||||
Each feature includes:
|
||||
- Availability status
|
||||
- UI metadata (icon, route, location)
|
||||
- Minimum tier required
|
||||
|
||||
Args:
|
||||
category: Filter to specific category (orders, inventory, etc.)
|
||||
include_unavailable: Whether to include locked features
|
||||
|
||||
Returns:
|
||||
List of features with metadata and availability
|
||||
"""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
# Get subscription for tier info
|
||||
from app.services.subscription_service import subscription_service
|
||||
|
||||
subscription = subscription_service.get_or_create_subscription(db, vendor_id)
|
||||
tier = subscription.tier_obj
|
||||
|
||||
# Get features
|
||||
features = feature_service.get_vendor_features(
|
||||
db,
|
||||
vendor_id,
|
||||
category=category,
|
||||
include_unavailable=include_unavailable,
|
||||
)
|
||||
|
||||
available_count = sum(1 for f in features if f.is_available)
|
||||
|
||||
return FeatureListResponse(
|
||||
features=[
|
||||
FeatureResponse(
|
||||
code=f.code,
|
||||
name=f.name,
|
||||
description=f.description,
|
||||
category=f.category,
|
||||
ui_location=f.ui_location,
|
||||
ui_icon=f.ui_icon,
|
||||
ui_route=f.ui_route,
|
||||
ui_badge_text=f.ui_badge_text,
|
||||
is_available=f.is_available,
|
||||
minimum_tier_code=f.minimum_tier_code,
|
||||
minimum_tier_name=f.minimum_tier_name,
|
||||
)
|
||||
for f in features
|
||||
],
|
||||
available_count=available_count,
|
||||
total_count=len(features),
|
||||
tier_code=subscription.tier,
|
||||
tier_name=tier.name if tier else subscription.tier.title(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/categories", response_model=CategoryListResponse)
|
||||
def get_feature_categories(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get list of feature categories.
|
||||
|
||||
Returns:
|
||||
List of category names
|
||||
"""
|
||||
categories = feature_service.get_categories(db)
|
||||
return CategoryListResponse(categories=categories)
|
||||
|
||||
|
||||
@router.get("/grouped", response_model=FeatureGroupedResponse)
|
||||
def get_features_grouped(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get features grouped by category.
|
||||
|
||||
Useful for rendering feature comparison tables or settings pages.
|
||||
"""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
grouped = feature_service.get_features_grouped_by_category(db, vendor_id)
|
||||
|
||||
# Convert to response format
|
||||
categories_response = {}
|
||||
total = 0
|
||||
available = 0
|
||||
|
||||
for category, features in grouped.items():
|
||||
categories_response[category] = [
|
||||
FeatureResponse(
|
||||
code=f.code,
|
||||
name=f.name,
|
||||
description=f.description,
|
||||
category=f.category,
|
||||
ui_location=f.ui_location,
|
||||
ui_icon=f.ui_icon,
|
||||
ui_route=f.ui_route,
|
||||
ui_badge_text=f.ui_badge_text,
|
||||
is_available=f.is_available,
|
||||
minimum_tier_code=f.minimum_tier_code,
|
||||
minimum_tier_name=f.minimum_tier_name,
|
||||
)
|
||||
for f in features
|
||||
]
|
||||
total += len(features)
|
||||
available += sum(1 for f in features if f.is_available)
|
||||
|
||||
return FeatureGroupedResponse(
|
||||
categories=categories_response,
|
||||
available_count=available,
|
||||
total_count=total,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{feature_code}", response_model=FeatureDetailResponse)
|
||||
def get_feature_detail(
|
||||
feature_code: str,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get detailed information about a specific feature.
|
||||
|
||||
Includes upgrade information if the feature is not available.
|
||||
Use this for upgrade prompts and feature explanation modals.
|
||||
|
||||
Args:
|
||||
feature_code: The feature code
|
||||
|
||||
Returns:
|
||||
Feature details with upgrade info if locked
|
||||
"""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
# Get feature
|
||||
feature = feature_service.get_feature_by_code(db, feature_code)
|
||||
if not feature:
|
||||
raise HTTPException(status_code=404, detail=f"Feature '{feature_code}' not found")
|
||||
|
||||
# Check availability
|
||||
is_available = feature_service.has_feature(db, vendor_id, feature_code)
|
||||
|
||||
# Get upgrade info if not available
|
||||
upgrade_tier_code = None
|
||||
upgrade_tier_name = None
|
||||
upgrade_tier_price = None
|
||||
|
||||
if not is_available:
|
||||
upgrade_info = feature_service.get_feature_upgrade_info(db, feature_code)
|
||||
if upgrade_info:
|
||||
upgrade_tier_code = upgrade_info.required_tier_code
|
||||
upgrade_tier_name = upgrade_info.required_tier_name
|
||||
upgrade_tier_price = upgrade_info.required_tier_price_monthly_cents
|
||||
|
||||
return FeatureDetailResponse(
|
||||
code=feature.code,
|
||||
name=feature.name,
|
||||
description=feature.description,
|
||||
category=feature.category,
|
||||
ui_location=feature.ui_location,
|
||||
ui_icon=feature.ui_icon,
|
||||
ui_route=feature.ui_route,
|
||||
is_available=is_available,
|
||||
upgrade_tier_code=upgrade_tier_code,
|
||||
upgrade_tier_name=upgrade_tier_name,
|
||||
upgrade_tier_price_monthly_cents=upgrade_tier_price,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/check/{feature_code}")
|
||||
def check_feature(
|
||||
feature_code: str,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Quick check if vendor has access to a feature.
|
||||
|
||||
Returns simple boolean response for inline checks.
|
||||
|
||||
Args:
|
||||
feature_code: The feature code
|
||||
|
||||
Returns:
|
||||
{"has_feature": true/false}
|
||||
"""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
has_feature = feature_service.has_feature(db, vendor_id, feature_code)
|
||||
|
||||
return {"has_feature": has_feature, "feature_code": feature_code}
|
||||
10
app/api/v1/vendor/invoices.py
vendored
10
app/api/v1/vendor/invoices.py
vendored
@@ -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:
|
||||
|
||||
380
app/api/v1/vendor/usage.py
vendored
Normal file
380
app/api/v1/vendor/usage.py
vendored
Normal file
@@ -0,0 +1,380 @@
|
||||
# app/api/v1/vendor/usage.py
|
||||
"""
|
||||
Vendor usage and limits API endpoints.
|
||||
|
||||
Provides endpoints for:
|
||||
- Current usage vs limits
|
||||
- Upgrade recommendations
|
||||
- Approaching limit warnings
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from app.services.subscription_service import subscription_service
|
||||
from models.database.product import Product
|
||||
from models.database.subscription import SubscriptionTier
|
||||
from models.database.user import User
|
||||
from models.database.vendor import VendorUser
|
||||
|
||||
router = APIRouter(prefix="/usage")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Response Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class UsageMetric(BaseModel):
|
||||
"""Single usage metric."""
|
||||
|
||||
name: str
|
||||
current: int
|
||||
limit: int | None # None = unlimited
|
||||
percentage: float # 0-100, or 0 if unlimited
|
||||
is_unlimited: bool
|
||||
is_at_limit: bool
|
||||
is_approaching_limit: bool # >= 80%
|
||||
|
||||
|
||||
class TierInfo(BaseModel):
|
||||
"""Current tier information."""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
price_monthly_cents: int
|
||||
is_highest_tier: bool
|
||||
|
||||
|
||||
class UpgradeTierInfo(BaseModel):
|
||||
"""Next tier upgrade information."""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
price_monthly_cents: int
|
||||
price_increase_cents: int
|
||||
benefits: list[str]
|
||||
|
||||
|
||||
class UsageResponse(BaseModel):
|
||||
"""Full usage response with limits and upgrade info."""
|
||||
|
||||
tier: TierInfo
|
||||
usage: list[UsageMetric]
|
||||
has_limits_approaching: bool
|
||||
has_limits_reached: bool
|
||||
upgrade_available: bool
|
||||
upgrade_tier: UpgradeTierInfo | None = None
|
||||
upgrade_reasons: list[str]
|
||||
|
||||
|
||||
class LimitCheckResponse(BaseModel):
|
||||
"""Response for checking a specific limit."""
|
||||
|
||||
limit_type: str
|
||||
can_proceed: bool
|
||||
current: int
|
||||
limit: int | None
|
||||
percentage: float
|
||||
message: str | None = None
|
||||
upgrade_tier_code: str | None = None
|
||||
upgrade_tier_name: str | None = None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Endpoints
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("", response_model=UsageResponse)
|
||||
def get_usage(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get current usage, limits, and upgrade recommendations.
|
||||
|
||||
Returns comprehensive usage info for displaying in dashboard
|
||||
and determining when to show upgrade prompts.
|
||||
"""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
# Get subscription
|
||||
subscription = subscription_service.get_or_create_subscription(db, vendor_id)
|
||||
|
||||
# Get current tier
|
||||
tier = subscription.tier_obj
|
||||
if not tier:
|
||||
tier = (
|
||||
db.query(SubscriptionTier)
|
||||
.filter(SubscriptionTier.code == subscription.tier)
|
||||
.first()
|
||||
)
|
||||
|
||||
# Calculate usage metrics
|
||||
usage_metrics = []
|
||||
|
||||
# Orders this period
|
||||
orders_current = subscription.orders_this_period or 0
|
||||
orders_limit = subscription.orders_limit
|
||||
orders_unlimited = orders_limit is None or orders_limit < 0
|
||||
orders_percentage = 0 if orders_unlimited else (orders_current / orders_limit * 100 if orders_limit > 0 else 100)
|
||||
|
||||
usage_metrics.append(
|
||||
UsageMetric(
|
||||
name="orders",
|
||||
current=orders_current,
|
||||
limit=None if orders_unlimited else orders_limit,
|
||||
percentage=orders_percentage,
|
||||
is_unlimited=orders_unlimited,
|
||||
is_at_limit=not orders_unlimited and orders_current >= orders_limit,
|
||||
is_approaching_limit=not orders_unlimited and orders_percentage >= 80,
|
||||
)
|
||||
)
|
||||
|
||||
# Products
|
||||
products_count = (
|
||||
db.query(func.count(Product.id))
|
||||
.filter(Product.vendor_id == vendor_id)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
products_limit = subscription.products_limit
|
||||
products_unlimited = products_limit is None or products_limit < 0
|
||||
products_percentage = 0 if products_unlimited else (products_count / products_limit * 100 if products_limit > 0 else 100)
|
||||
|
||||
usage_metrics.append(
|
||||
UsageMetric(
|
||||
name="products",
|
||||
current=products_count,
|
||||
limit=None if products_unlimited else products_limit,
|
||||
percentage=products_percentage,
|
||||
is_unlimited=products_unlimited,
|
||||
is_at_limit=not products_unlimited and products_count >= products_limit,
|
||||
is_approaching_limit=not products_unlimited and products_percentage >= 80,
|
||||
)
|
||||
)
|
||||
|
||||
# Team members
|
||||
team_count = (
|
||||
db.query(func.count(VendorUser.id))
|
||||
.filter(VendorUser.vendor_id == vendor_id, VendorUser.is_active == True) # noqa: E712
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
team_limit = subscription.team_members_limit
|
||||
team_unlimited = team_limit is None or team_limit < 0
|
||||
team_percentage = 0 if team_unlimited else (team_count / team_limit * 100 if team_limit > 0 else 100)
|
||||
|
||||
usage_metrics.append(
|
||||
UsageMetric(
|
||||
name="team_members",
|
||||
current=team_count,
|
||||
limit=None if team_unlimited else team_limit,
|
||||
percentage=team_percentage,
|
||||
is_unlimited=team_unlimited,
|
||||
is_at_limit=not team_unlimited and team_count >= team_limit,
|
||||
is_approaching_limit=not team_unlimited and team_percentage >= 80,
|
||||
)
|
||||
)
|
||||
|
||||
# Check for approaching/reached limits
|
||||
has_limits_approaching = any(m.is_approaching_limit for m in usage_metrics)
|
||||
has_limits_reached = any(m.is_at_limit for m in usage_metrics)
|
||||
|
||||
# Get next tier for upgrade
|
||||
all_tiers = (
|
||||
db.query(SubscriptionTier)
|
||||
.filter(SubscriptionTier.is_active == True) # noqa: E712
|
||||
.order_by(SubscriptionTier.display_order)
|
||||
.all()
|
||||
)
|
||||
|
||||
current_tier_order = tier.display_order if tier else 0
|
||||
next_tier = None
|
||||
for t in all_tiers:
|
||||
if t.display_order > current_tier_order:
|
||||
next_tier = t
|
||||
break
|
||||
|
||||
is_highest_tier = next_tier is None
|
||||
|
||||
# Build upgrade info
|
||||
upgrade_tier_info = None
|
||||
upgrade_reasons = []
|
||||
|
||||
if next_tier:
|
||||
# Calculate benefits
|
||||
benefits = []
|
||||
if next_tier.orders_per_month and (not tier or (tier.orders_per_month and next_tier.orders_per_month > tier.orders_per_month)):
|
||||
if next_tier.orders_per_month < 0:
|
||||
benefits.append("Unlimited orders per month")
|
||||
else:
|
||||
benefits.append(f"{next_tier.orders_per_month:,} orders/month")
|
||||
|
||||
if next_tier.products_limit and (not tier or (tier.products_limit and next_tier.products_limit > tier.products_limit)):
|
||||
if next_tier.products_limit < 0:
|
||||
benefits.append("Unlimited products")
|
||||
else:
|
||||
benefits.append(f"{next_tier.products_limit:,} products")
|
||||
|
||||
if next_tier.team_members and (not tier or (tier.team_members and next_tier.team_members > tier.team_members)):
|
||||
if next_tier.team_members < 0:
|
||||
benefits.append("Unlimited team members")
|
||||
else:
|
||||
benefits.append(f"{next_tier.team_members} team members")
|
||||
|
||||
# Add feature benefits
|
||||
current_features = set(tier.features) if tier and tier.features else set()
|
||||
next_features = set(next_tier.features) if next_tier.features else set()
|
||||
new_features = next_features - current_features
|
||||
|
||||
feature_names = {
|
||||
"analytics_dashboard": "Advanced Analytics",
|
||||
"api_access": "API Access",
|
||||
"automation_rules": "Automation Rules",
|
||||
"team_roles": "Team Roles & Permissions",
|
||||
"custom_domain": "Custom Domain",
|
||||
"webhooks": "Webhooks",
|
||||
"accounting_export": "Accounting Export",
|
||||
}
|
||||
for feature in list(new_features)[:3]: # Show top 3
|
||||
if feature in feature_names:
|
||||
benefits.append(feature_names[feature])
|
||||
|
||||
current_price = tier.price_monthly_cents if tier else 0
|
||||
upgrade_tier_info = UpgradeTierInfo(
|
||||
code=next_tier.code,
|
||||
name=next_tier.name,
|
||||
price_monthly_cents=next_tier.price_monthly_cents,
|
||||
price_increase_cents=next_tier.price_monthly_cents - current_price,
|
||||
benefits=benefits,
|
||||
)
|
||||
|
||||
# Build upgrade reasons
|
||||
if has_limits_reached:
|
||||
for m in usage_metrics:
|
||||
if m.is_at_limit:
|
||||
upgrade_reasons.append(f"You've reached your {m.name.replace('_', ' ')} limit")
|
||||
elif has_limits_approaching:
|
||||
for m in usage_metrics:
|
||||
if m.is_approaching_limit:
|
||||
upgrade_reasons.append(f"You're approaching your {m.name.replace('_', ' ')} limit ({int(m.percentage)}%)")
|
||||
|
||||
return UsageResponse(
|
||||
tier=TierInfo(
|
||||
code=tier.code if tier else subscription.tier,
|
||||
name=tier.name if tier else subscription.tier.title(),
|
||||
price_monthly_cents=tier.price_monthly_cents if tier else 0,
|
||||
is_highest_tier=is_highest_tier,
|
||||
),
|
||||
usage=usage_metrics,
|
||||
has_limits_approaching=has_limits_approaching,
|
||||
has_limits_reached=has_limits_reached,
|
||||
upgrade_available=not is_highest_tier,
|
||||
upgrade_tier=upgrade_tier_info,
|
||||
upgrade_reasons=upgrade_reasons,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/check/{limit_type}", response_model=LimitCheckResponse)
|
||||
def check_limit(
|
||||
limit_type: str,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Check a specific limit before performing an action.
|
||||
|
||||
Use this before creating orders, products, or inviting team members.
|
||||
|
||||
Args:
|
||||
limit_type: One of "orders", "products", "team_members"
|
||||
|
||||
Returns:
|
||||
Whether the action can proceed and upgrade info if not
|
||||
"""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
if limit_type == "orders":
|
||||
can_proceed, message = subscription_service.can_create_order(db, vendor_id)
|
||||
subscription = subscription_service.get_subscription(db, vendor_id)
|
||||
current = subscription.orders_this_period if subscription else 0
|
||||
limit = subscription.orders_limit if subscription else 0
|
||||
|
||||
elif limit_type == "products":
|
||||
can_proceed, message = subscription_service.can_add_product(db, vendor_id)
|
||||
subscription = subscription_service.get_subscription(db, vendor_id)
|
||||
current = (
|
||||
db.query(func.count(Product.id))
|
||||
.filter(Product.vendor_id == vendor_id)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
limit = subscription.products_limit if subscription else 0
|
||||
|
||||
elif limit_type == "team_members":
|
||||
can_proceed, message = subscription_service.can_add_team_member(db, vendor_id)
|
||||
subscription = subscription_service.get_subscription(db, vendor_id)
|
||||
current = (
|
||||
db.query(func.count(VendorUser.id))
|
||||
.filter(VendorUser.vendor_id == vendor_id, VendorUser.is_active == True) # noqa: E712
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
limit = subscription.team_members_limit if subscription else 0
|
||||
|
||||
else:
|
||||
return LimitCheckResponse(
|
||||
limit_type=limit_type,
|
||||
can_proceed=True,
|
||||
current=0,
|
||||
limit=None,
|
||||
percentage=0,
|
||||
message=f"Unknown limit type: {limit_type}",
|
||||
)
|
||||
|
||||
# Calculate percentage
|
||||
is_unlimited = limit is None or limit < 0
|
||||
percentage = 0 if is_unlimited else (current / limit * 100 if limit > 0 else 100)
|
||||
|
||||
# Get upgrade info if at limit
|
||||
upgrade_tier_code = None
|
||||
upgrade_tier_name = None
|
||||
|
||||
if not can_proceed:
|
||||
subscription = subscription_service.get_subscription(db, vendor_id)
|
||||
current_tier = subscription.tier_obj if subscription else None
|
||||
|
||||
if current_tier:
|
||||
next_tier = (
|
||||
db.query(SubscriptionTier)
|
||||
.filter(
|
||||
SubscriptionTier.is_active == True, # noqa: E712
|
||||
SubscriptionTier.display_order > current_tier.display_order,
|
||||
)
|
||||
.order_by(SubscriptionTier.display_order)
|
||||
.first()
|
||||
)
|
||||
|
||||
if next_tier:
|
||||
upgrade_tier_code = next_tier.code
|
||||
upgrade_tier_name = next_tier.name
|
||||
|
||||
return LimitCheckResponse(
|
||||
limit_type=limit_type,
|
||||
can_proceed=can_proceed,
|
||||
current=current,
|
||||
limit=None if is_unlimited else limit,
|
||||
percentage=percentage,
|
||||
message=message,
|
||||
upgrade_tier_code=upgrade_tier_code,
|
||||
upgrade_tier_name=upgrade_tier_name,
|
||||
)
|
||||
254
app/core/feature_gate.py
Normal file
254
app/core/feature_gate.py
Normal file
@@ -0,0 +1,254 @@
|
||||
# app/core/feature_gate.py
|
||||
"""
|
||||
Feature gating decorator and dependencies for tier-based access control.
|
||||
|
||||
Provides:
|
||||
- @require_feature decorator for endpoints
|
||||
- RequireFeature dependency for flexible usage
|
||||
- FeatureNotAvailableError exception with upgrade info
|
||||
|
||||
Usage:
|
||||
# As decorator (simple)
|
||||
@router.get("/analytics")
|
||||
@require_feature(FeatureCode.ANALYTICS_DASHBOARD)
|
||||
def get_analytics(...):
|
||||
...
|
||||
|
||||
# As dependency (more control)
|
||||
@router.get("/analytics")
|
||||
def get_analytics(
|
||||
_: None = Depends(RequireFeature(FeatureCode.ANALYTICS_DASHBOARD)),
|
||||
...
|
||||
):
|
||||
...
|
||||
|
||||
# Multiple features (any one required)
|
||||
@require_feature(FeatureCode.ANALYTICS_DASHBOARD, FeatureCode.BASIC_REPORTS)
|
||||
def get_reports(...):
|
||||
...
|
||||
"""
|
||||
|
||||
import functools
|
||||
import logging
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from app.services.feature_service import feature_service
|
||||
from models.database.feature import FeatureCode
|
||||
from models.database.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FeatureNotAvailableError(HTTPException):
|
||||
"""
|
||||
Exception raised when a feature is not available for the vendor's tier.
|
||||
|
||||
Includes upgrade information for the frontend to display.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
feature_code: str,
|
||||
feature_name: str | None = None,
|
||||
required_tier_code: str | None = None,
|
||||
required_tier_name: str | None = None,
|
||||
required_tier_price_cents: int | None = None,
|
||||
):
|
||||
detail = {
|
||||
"error": "feature_not_available",
|
||||
"message": f"This feature requires an upgrade to access.",
|
||||
"feature_code": feature_code,
|
||||
"feature_name": feature_name,
|
||||
"upgrade": {
|
||||
"tier_code": required_tier_code,
|
||||
"tier_name": required_tier_name,
|
||||
"price_monthly_cents": required_tier_price_cents,
|
||||
}
|
||||
if required_tier_code
|
||||
else None,
|
||||
}
|
||||
super().__init__(status_code=403, detail=detail)
|
||||
|
||||
|
||||
class RequireFeature:
|
||||
"""
|
||||
Dependency class that checks if vendor has access to a feature.
|
||||
|
||||
Can be used as a FastAPI dependency:
|
||||
@router.get("/analytics")
|
||||
def get_analytics(
|
||||
_: None = Depends(RequireFeature("analytics_dashboard")),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
...
|
||||
|
||||
Args:
|
||||
*feature_codes: One or more feature codes. Access granted if ANY is available.
|
||||
"""
|
||||
|
||||
def __init__(self, *feature_codes: str):
|
||||
if not feature_codes:
|
||||
raise ValueError("At least one feature code is required")
|
||||
self.feature_codes = feature_codes
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
) -> None:
|
||||
"""Check if vendor has access to any of the required features."""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
# Check if vendor has ANY of the required features
|
||||
for feature_code in self.feature_codes:
|
||||
if feature_service.has_feature(db, vendor_id, feature_code):
|
||||
return None
|
||||
|
||||
# None of the features are available - get upgrade info for first one
|
||||
feature_code = self.feature_codes[0]
|
||||
upgrade_info = feature_service.get_feature_upgrade_info(db, feature_code)
|
||||
|
||||
if upgrade_info:
|
||||
raise FeatureNotAvailableError(
|
||||
feature_code=feature_code,
|
||||
feature_name=upgrade_info.feature_name,
|
||||
required_tier_code=upgrade_info.required_tier_code,
|
||||
required_tier_name=upgrade_info.required_tier_name,
|
||||
required_tier_price_cents=upgrade_info.required_tier_price_monthly_cents,
|
||||
)
|
||||
else:
|
||||
# Feature not found in registry
|
||||
raise FeatureNotAvailableError(feature_code=feature_code)
|
||||
|
||||
|
||||
def require_feature(*feature_codes: str) -> Callable:
|
||||
"""
|
||||
Decorator to require one or more features for an endpoint.
|
||||
|
||||
The decorated endpoint will return 403 with upgrade info if the vendor
|
||||
doesn't have access to ANY of the specified features.
|
||||
|
||||
Args:
|
||||
*feature_codes: One or more feature codes. Access granted if ANY is available.
|
||||
|
||||
Example:
|
||||
@router.get("/analytics/dashboard")
|
||||
@require_feature(FeatureCode.ANALYTICS_DASHBOARD)
|
||||
async def get_analytics_dashboard(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
...
|
||||
|
||||
# Multiple features (any one is sufficient)
|
||||
@router.get("/reports")
|
||||
@require_feature(FeatureCode.ANALYTICS_DASHBOARD, FeatureCode.BASIC_REPORTS)
|
||||
async def get_reports(...):
|
||||
...
|
||||
"""
|
||||
if not feature_codes:
|
||||
raise ValueError("At least one feature code is required")
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
# Extract dependencies from kwargs
|
||||
db = kwargs.get("db")
|
||||
current_user = kwargs.get("current_user")
|
||||
|
||||
if not db or not current_user:
|
||||
# Try to get from request if not in kwargs
|
||||
request = kwargs.get("request")
|
||||
if request and hasattr(request, "state"):
|
||||
db = getattr(request.state, "db", None)
|
||||
current_user = getattr(request.state, "user", None)
|
||||
|
||||
if not db or not current_user:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Feature check failed: missing db or current_user dependency",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
# Check if vendor has ANY of the required features
|
||||
for feature_code in feature_codes:
|
||||
if feature_service.has_feature(db, vendor_id, feature_code):
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
# None available - raise with upgrade info
|
||||
feature_code = feature_codes[0]
|
||||
upgrade_info = feature_service.get_feature_upgrade_info(db, feature_code)
|
||||
|
||||
if upgrade_info:
|
||||
raise FeatureNotAvailableError(
|
||||
feature_code=feature_code,
|
||||
feature_name=upgrade_info.feature_name,
|
||||
required_tier_code=upgrade_info.required_tier_code,
|
||||
required_tier_name=upgrade_info.required_tier_name,
|
||||
required_tier_price_cents=upgrade_info.required_tier_price_monthly_cents,
|
||||
)
|
||||
else:
|
||||
raise FeatureNotAvailableError(feature_code=feature_code)
|
||||
|
||||
@functools.wraps(func)
|
||||
def sync_wrapper(*args, **kwargs):
|
||||
# Extract dependencies from kwargs
|
||||
db = kwargs.get("db")
|
||||
current_user = kwargs.get("current_user")
|
||||
|
||||
if not db or not current_user:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Feature check failed: missing db or current_user dependency",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
# Check if vendor has ANY of the required features
|
||||
for feature_code in feature_codes:
|
||||
if feature_service.has_feature(db, vendor_id, feature_code):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
# None available - raise with upgrade info
|
||||
feature_code = feature_codes[0]
|
||||
upgrade_info = feature_service.get_feature_upgrade_info(db, feature_code)
|
||||
|
||||
if upgrade_info:
|
||||
raise FeatureNotAvailableError(
|
||||
feature_code=feature_code,
|
||||
feature_name=upgrade_info.feature_name,
|
||||
required_tier_code=upgrade_info.required_tier_code,
|
||||
required_tier_name=upgrade_info.required_tier_name,
|
||||
required_tier_price_cents=upgrade_info.required_tier_price_monthly_cents,
|
||||
)
|
||||
else:
|
||||
raise FeatureNotAvailableError(feature_code=feature_code)
|
||||
|
||||
# Return appropriate wrapper based on whether func is async
|
||||
import asyncio
|
||||
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
return async_wrapper
|
||||
else:
|
||||
return sync_wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Convenience Exports
|
||||
# ============================================================================
|
||||
|
||||
__all__ = [
|
||||
"require_feature",
|
||||
"RequireFeature",
|
||||
"FeatureNotAvailableError",
|
||||
"FeatureCode",
|
||||
]
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
451
app/services/feature_service.py
Normal file
451
app/services/feature_service.py
Normal file
@@ -0,0 +1,451 @@
|
||||
# app/services/feature_service.py
|
||||
"""
|
||||
Feature service for tier-based access control.
|
||||
|
||||
Provides:
|
||||
- Feature availability checking with caching
|
||||
- Vendor feature listing for API/UI
|
||||
- Feature metadata for upgrade prompts
|
||||
- Cache invalidation on subscription changes
|
||||
|
||||
Usage:
|
||||
from app.services.feature_service import feature_service
|
||||
|
||||
# Check if vendor has feature
|
||||
if feature_service.has_feature(db, vendor_id, FeatureCode.ANALYTICS_DASHBOARD):
|
||||
...
|
||||
|
||||
# Get all features available to vendor
|
||||
features = feature_service.get_vendor_features(db, vendor_id)
|
||||
|
||||
# Get feature info for upgrade prompt
|
||||
info = feature_service.get_feature_upgrade_info(db, "analytics_dashboard")
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from models.database.feature import Feature, FeatureCode
|
||||
from models.database.subscription import SubscriptionTier, VendorSubscription
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeatureInfo:
|
||||
"""Feature information for API responses."""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
description: str | None
|
||||
category: str
|
||||
ui_location: str | None
|
||||
ui_icon: str | None
|
||||
ui_route: str | None
|
||||
ui_badge_text: str | None
|
||||
is_available: bool
|
||||
minimum_tier_code: str | None
|
||||
minimum_tier_name: str | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeatureUpgradeInfo:
|
||||
"""Information for upgrade prompts."""
|
||||
|
||||
feature_code: str
|
||||
feature_name: str
|
||||
feature_description: str | None
|
||||
required_tier_code: str
|
||||
required_tier_name: str
|
||||
required_tier_price_monthly_cents: int
|
||||
|
||||
|
||||
class FeatureCache:
|
||||
"""
|
||||
In-memory cache for vendor features.
|
||||
|
||||
Caches vendor_id -> set of feature codes with TTL.
|
||||
Invalidated when subscription changes.
|
||||
"""
|
||||
|
||||
def __init__(self, ttl_seconds: int = 300):
|
||||
self._cache: dict[int, tuple[set[str], float]] = {}
|
||||
self._ttl = ttl_seconds
|
||||
|
||||
def get(self, vendor_id: int) -> set[str] | None:
|
||||
"""Get cached features for vendor, or None if not cached/expired."""
|
||||
if vendor_id not in self._cache:
|
||||
return None
|
||||
|
||||
features, timestamp = self._cache[vendor_id]
|
||||
if time.time() - timestamp > self._ttl:
|
||||
del self._cache[vendor_id]
|
||||
return None
|
||||
|
||||
return features
|
||||
|
||||
def set(self, vendor_id: int, features: set[str]) -> None:
|
||||
"""Cache features for vendor."""
|
||||
self._cache[vendor_id] = (features, time.time())
|
||||
|
||||
def invalidate(self, vendor_id: int) -> None:
|
||||
"""Invalidate cache for vendor."""
|
||||
self._cache.pop(vendor_id, None)
|
||||
|
||||
def invalidate_all(self) -> None:
|
||||
"""Invalidate entire cache."""
|
||||
self._cache.clear()
|
||||
|
||||
|
||||
class FeatureService:
|
||||
"""
|
||||
Service for feature-based access control.
|
||||
|
||||
Provides methods to check feature availability and get feature metadata.
|
||||
Uses in-memory caching with TTL for performance.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._cache = FeatureCache(ttl_seconds=300) # 5 minute cache
|
||||
self._feature_registry_cache: dict[str, Feature] | None = None
|
||||
self._feature_registry_timestamp: float = 0
|
||||
|
||||
# =========================================================================
|
||||
# Feature Availability
|
||||
# =========================================================================
|
||||
|
||||
def has_feature(self, db: Session, vendor_id: int, feature_code: str) -> bool:
|
||||
"""
|
||||
Check if vendor has access to a specific feature.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
feature_code: Feature code (use FeatureCode constants)
|
||||
|
||||
Returns:
|
||||
True if vendor has access to the feature
|
||||
"""
|
||||
vendor_features = self._get_vendor_feature_codes(db, vendor_id)
|
||||
return feature_code in vendor_features
|
||||
|
||||
def get_vendor_feature_codes(self, db: Session, vendor_id: int) -> set[str]:
|
||||
"""
|
||||
Get set of feature codes available to vendor.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
|
||||
Returns:
|
||||
Set of feature codes the vendor has access to
|
||||
"""
|
||||
return self._get_vendor_feature_codes(db, vendor_id)
|
||||
|
||||
def _get_vendor_feature_codes(self, db: Session, vendor_id: int) -> set[str]:
|
||||
"""Internal method with caching."""
|
||||
# Check cache first
|
||||
cached = self._cache.get(vendor_id)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# Get subscription with tier relationship
|
||||
subscription = (
|
||||
db.query(VendorSubscription)
|
||||
.options(joinedload(VendorSubscription.tier_obj))
|
||||
.filter(VendorSubscription.vendor_id == vendor_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not subscription:
|
||||
logger.warning(f"No subscription found for vendor {vendor_id}")
|
||||
return set()
|
||||
|
||||
# Get features from tier
|
||||
tier = subscription.tier_obj
|
||||
if tier and tier.features:
|
||||
features = set(tier.features)
|
||||
else:
|
||||
# Fallback: query tier by code
|
||||
tier = (
|
||||
db.query(SubscriptionTier)
|
||||
.filter(SubscriptionTier.code == subscription.tier)
|
||||
.first()
|
||||
)
|
||||
features = set(tier.features) if tier and tier.features else set()
|
||||
|
||||
# Cache and return
|
||||
self._cache.set(vendor_id, features)
|
||||
return features
|
||||
|
||||
# =========================================================================
|
||||
# Feature Listing
|
||||
# =========================================================================
|
||||
|
||||
def get_vendor_features(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
category: str | None = None,
|
||||
include_unavailable: bool = True,
|
||||
) -> list[FeatureInfo]:
|
||||
"""
|
||||
Get all features with availability status for vendor.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
category: Optional category filter
|
||||
include_unavailable: Include features not available to vendor
|
||||
|
||||
Returns:
|
||||
List of FeatureInfo with is_available flag
|
||||
"""
|
||||
vendor_features = self._get_vendor_feature_codes(db, vendor_id)
|
||||
|
||||
# Query all active features
|
||||
query = db.query(Feature).filter(Feature.is_active == True) # noqa: E712
|
||||
|
||||
if category:
|
||||
query = query.filter(Feature.category == category)
|
||||
|
||||
if not include_unavailable:
|
||||
# Only return features the vendor has
|
||||
query = query.filter(Feature.code.in_(vendor_features))
|
||||
|
||||
features = (
|
||||
query.options(joinedload(Feature.minimum_tier))
|
||||
.order_by(Feature.category, Feature.display_order)
|
||||
.all()
|
||||
)
|
||||
|
||||
result = []
|
||||
for feature in features:
|
||||
result.append(
|
||||
FeatureInfo(
|
||||
code=feature.code,
|
||||
name=feature.name,
|
||||
description=feature.description,
|
||||
category=feature.category,
|
||||
ui_location=feature.ui_location,
|
||||
ui_icon=feature.ui_icon,
|
||||
ui_route=feature.ui_route,
|
||||
ui_badge_text=feature.ui_badge_text,
|
||||
is_available=feature.code in vendor_features,
|
||||
minimum_tier_code=feature.minimum_tier.code if feature.minimum_tier else None,
|
||||
minimum_tier_name=feature.minimum_tier.name if feature.minimum_tier else None,
|
||||
)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def get_available_feature_codes(self, db: Session, vendor_id: int) -> list[str]:
|
||||
"""
|
||||
Get list of feature codes available to vendor (for frontend).
|
||||
|
||||
Simple list for x-feature directive checks.
|
||||
"""
|
||||
return list(self._get_vendor_feature_codes(db, vendor_id))
|
||||
|
||||
# =========================================================================
|
||||
# Feature Metadata
|
||||
# =========================================================================
|
||||
|
||||
def get_feature_by_code(self, db: Session, feature_code: str) -> Feature | None:
|
||||
"""Get feature by code."""
|
||||
return (
|
||||
db.query(Feature)
|
||||
.options(joinedload(Feature.minimum_tier))
|
||||
.filter(Feature.code == feature_code)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_feature_upgrade_info(
|
||||
self, db: Session, feature_code: str
|
||||
) -> FeatureUpgradeInfo | None:
|
||||
"""
|
||||
Get upgrade information for a feature.
|
||||
|
||||
Used for upgrade prompts when a feature is not available.
|
||||
"""
|
||||
feature = self.get_feature_by_code(db, feature_code)
|
||||
|
||||
if not feature or not feature.minimum_tier:
|
||||
return None
|
||||
|
||||
tier = feature.minimum_tier
|
||||
return FeatureUpgradeInfo(
|
||||
feature_code=feature.code,
|
||||
feature_name=feature.name,
|
||||
feature_description=feature.description,
|
||||
required_tier_code=tier.code,
|
||||
required_tier_name=tier.name,
|
||||
required_tier_price_monthly_cents=tier.price_monthly_cents,
|
||||
)
|
||||
|
||||
def get_all_features(
|
||||
self,
|
||||
db: Session,
|
||||
category: str | None = None,
|
||||
active_only: bool = True,
|
||||
) -> list[Feature]:
|
||||
"""Get all features (for admin)."""
|
||||
query = db.query(Feature).options(joinedload(Feature.minimum_tier))
|
||||
|
||||
if active_only:
|
||||
query = query.filter(Feature.is_active == True) # noqa: E712
|
||||
|
||||
if category:
|
||||
query = query.filter(Feature.category == category)
|
||||
|
||||
return query.order_by(Feature.category, Feature.display_order).all()
|
||||
|
||||
def get_features_by_tier(self, db: Session, tier_code: str) -> list[str]:
|
||||
"""Get feature codes for a specific tier."""
|
||||
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first()
|
||||
|
||||
if not tier or not tier.features:
|
||||
return []
|
||||
|
||||
return tier.features
|
||||
|
||||
# =========================================================================
|
||||
# Feature Categories
|
||||
# =========================================================================
|
||||
|
||||
def get_categories(self, db: Session) -> list[str]:
|
||||
"""Get all unique feature categories."""
|
||||
result = (
|
||||
db.query(Feature.category)
|
||||
.filter(Feature.is_active == True) # noqa: E712
|
||||
.distinct()
|
||||
.order_by(Feature.category)
|
||||
.all()
|
||||
)
|
||||
return [row[0] for row in result]
|
||||
|
||||
def get_features_grouped_by_category(
|
||||
self, db: Session, vendor_id: int
|
||||
) -> dict[str, list[FeatureInfo]]:
|
||||
"""Get features grouped by category with availability."""
|
||||
features = self.get_vendor_features(db, vendor_id, include_unavailable=True)
|
||||
|
||||
grouped: dict[str, list[FeatureInfo]] = {}
|
||||
for feature in features:
|
||||
if feature.category not in grouped:
|
||||
grouped[feature.category] = []
|
||||
grouped[feature.category].append(feature)
|
||||
|
||||
return grouped
|
||||
|
||||
# =========================================================================
|
||||
# Cache Management
|
||||
# =========================================================================
|
||||
|
||||
def invalidate_vendor_cache(self, vendor_id: int) -> None:
|
||||
"""
|
||||
Invalidate cache for a specific vendor.
|
||||
|
||||
Call this when:
|
||||
- Vendor's subscription tier changes
|
||||
- Tier features are updated (for all vendors on that tier)
|
||||
"""
|
||||
self._cache.invalidate(vendor_id)
|
||||
logger.debug(f"Invalidated feature cache for vendor {vendor_id}")
|
||||
|
||||
def invalidate_all_cache(self) -> None:
|
||||
"""
|
||||
Invalidate entire cache.
|
||||
|
||||
Call this when tier features are modified in admin.
|
||||
"""
|
||||
self._cache.invalidate_all()
|
||||
logger.debug("Invalidated all feature caches")
|
||||
|
||||
# =========================================================================
|
||||
# Admin Operations
|
||||
# =========================================================================
|
||||
|
||||
def update_tier_features(
|
||||
self, db: Session, tier_code: str, feature_codes: list[str]
|
||||
) -> SubscriptionTier:
|
||||
"""
|
||||
Update features for a tier (admin operation).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
tier_code: Tier code
|
||||
feature_codes: List of feature codes to assign
|
||||
|
||||
Returns:
|
||||
Updated tier
|
||||
"""
|
||||
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first()
|
||||
|
||||
if not tier:
|
||||
raise ValueError(f"Tier '{tier_code}' not found")
|
||||
|
||||
# Validate feature codes exist
|
||||
valid_codes = {
|
||||
f.code for f in db.query(Feature.code).filter(Feature.is_active == True).all() # noqa: E712
|
||||
}
|
||||
invalid = set(feature_codes) - valid_codes
|
||||
if invalid:
|
||||
raise ValueError(f"Invalid feature codes: {invalid}")
|
||||
|
||||
tier.features = feature_codes
|
||||
|
||||
# Invalidate all caches since tier features changed
|
||||
self.invalidate_all_cache()
|
||||
|
||||
logger.info(f"Updated features for tier {tier_code}: {len(feature_codes)} features")
|
||||
return tier
|
||||
|
||||
def update_feature_minimum_tier(
|
||||
self, db: Session, feature_code: str, tier_code: str | None
|
||||
) -> Feature:
|
||||
"""
|
||||
Update minimum tier for a feature (for upgrade prompts).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
feature_code: Feature code
|
||||
tier_code: Tier code or None
|
||||
"""
|
||||
feature = db.query(Feature).filter(Feature.code == feature_code).first()
|
||||
|
||||
if not feature:
|
||||
raise ValueError(f"Feature '{feature_code}' not found")
|
||||
|
||||
if tier_code:
|
||||
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first()
|
||||
if not tier:
|
||||
raise ValueError(f"Tier '{tier_code}' not found")
|
||||
feature.minimum_tier_id = tier.id
|
||||
else:
|
||||
feature.minimum_tier_id = None
|
||||
|
||||
logger.info(f"Updated minimum tier for feature {feature_code}: {tier_code}")
|
||||
return feature
|
||||
|
||||
|
||||
# Singleton instance
|
||||
feature_service = FeatureService()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Convenience Exports
|
||||
# ============================================================================
|
||||
# Re-export FeatureCode for easy imports
|
||||
|
||||
__all__ = [
|
||||
"feature_service",
|
||||
"FeatureService",
|
||||
"FeatureInfo",
|
||||
"FeatureUpgradeInfo",
|
||||
"FeatureCode",
|
||||
]
|
||||
370
app/templates/admin/features.html
Normal file
370
app/templates/admin/features.html
Normal file
@@ -0,0 +1,370 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Feature Management{% endblock %}
|
||||
|
||||
{% block alpine_data %}featuresPage(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="py-6">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Feature Management
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Configure which features are available to each subscription tier.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="flex justify-center py-12">
|
||||
<svg class="animate-spin h-8 w-8 text-purple-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Tier Tabs -->
|
||||
<div x-show="!loading" x-cloak>
|
||||
<!-- Tab Navigation -->
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 mb-6">
|
||||
<nav class="-mb-px flex space-x-8">
|
||||
<template x-for="tier in tiers" :key="tier.code">
|
||||
<button
|
||||
@click="selectedTier = tier.code"
|
||||
:class="{
|
||||
'border-purple-500 text-purple-600 dark:text-purple-400': selectedTier === tier.code,
|
||||
'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300': selectedTier !== tier.code
|
||||
}"
|
||||
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors">
|
||||
<span x-text="tier.name"></span>
|
||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full"
|
||||
:class="selectedTier === tier.code ? 'bg-purple-100 text-purple-600 dark:bg-purple-900 dark:text-purple-300' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'"
|
||||
x-text="tier.feature_count"></span>
|
||||
</button>
|
||||
</template>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Features Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Available Features (for selected tier) -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
Included Features
|
||||
</h3>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400"
|
||||
x-text="`${getSelectedTierFeatures().length} features`"></span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 max-h-96 overflow-y-auto">
|
||||
<template x-for="featureCode in getSelectedTierFeatures()" :key="featureCode">
|
||||
<div class="flex items-center justify-between p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-green-500 mr-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white"
|
||||
x-text="getFeatureName(featureCode)"></p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400"
|
||||
x-text="getFeatureCategory(featureCode)"></p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="removeFeatureFromTier(featureCode)"
|
||||
class="text-red-500 hover:text-red-700 p-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div x-show="getSelectedTierFeatures().length === 0"
|
||||
class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
No features assigned to this tier
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- All Features (to add) -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
Available to Add
|
||||
</h3>
|
||||
<select
|
||||
x-model="categoryFilter"
|
||||
class="text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 rounded-md">
|
||||
<option value="">All Categories</option>
|
||||
<template x-for="cat in categories" :key="cat">
|
||||
<option :value="cat" x-text="cat.charAt(0).toUpperCase() + cat.slice(1)"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 max-h-96 overflow-y-auto">
|
||||
<template x-for="feature in getAvailableFeatures()" :key="feature.code">
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white"
|
||||
x-text="feature.name"></p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400"
|
||||
x-text="feature.category"></p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="addFeatureToTier(feature.code)"
|
||||
class="text-green-500 hover:text-green-700 p-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div x-show="getAvailableFeatures().length === 0"
|
||||
class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
All features are assigned to this tier
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button
|
||||
@click="saveTierFeatures"
|
||||
:disabled="saving || !hasChanges"
|
||||
:class="{
|
||||
'opacity-50 cursor-not-allowed': saving || !hasChanges,
|
||||
'hover:bg-purple-700': !saving && hasChanges
|
||||
}"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-purple-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500">
|
||||
<svg x-show="saving" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span x-text="saving ? 'Saving...' : 'Save Changes'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- All Features Table -->
|
||||
<div class="mt-8 bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
All Features
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Complete list of all platform features with their minimum tier requirement.
|
||||
</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Feature
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Category
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Minimum Tier
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<template x-for="feature in allFeatures" :key="feature.code">
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white" x-text="feature.name"></div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400" x-text="feature.code"></div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
|
||||
x-text="feature.category"></span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 py-1 text-xs rounded-full"
|
||||
:class="{
|
||||
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300': feature.minimum_tier_code === 'essential',
|
||||
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300': feature.minimum_tier_code === 'professional',
|
||||
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300': feature.minimum_tier_code === 'business',
|
||||
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300': feature.minimum_tier_code === 'enterprise',
|
||||
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300': !feature.minimum_tier_code
|
||||
}"
|
||||
x-text="feature.minimum_tier_name || 'N/A'"></span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span x-show="feature.is_active"
|
||||
class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300">
|
||||
Active
|
||||
</span>
|
||||
<span x-show="!feature.is_active"
|
||||
class="px-2 py-1 text-xs rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300">
|
||||
Inactive
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function featuresPage() {
|
||||
return {
|
||||
...data(),
|
||||
|
||||
// State
|
||||
loading: true,
|
||||
saving: false,
|
||||
tiers: [],
|
||||
allFeatures: [],
|
||||
categories: [],
|
||||
selectedTier: 'essential',
|
||||
categoryFilter: '',
|
||||
originalTierFeatures: {}, // To track changes
|
||||
currentTierFeatures: {}, // Current state
|
||||
|
||||
// Computed
|
||||
get hasChanges() {
|
||||
const original = this.originalTierFeatures[this.selectedTier] || [];
|
||||
const current = this.currentTierFeatures[this.selectedTier] || [];
|
||||
return JSON.stringify(original.sort()) !== JSON.stringify(current.sort());
|
||||
},
|
||||
|
||||
// Lifecycle
|
||||
async init() {
|
||||
// Call parent init
|
||||
const path = window.location.pathname;
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
this.currentPage = segments[segments.length - 1] || 'features';
|
||||
|
||||
await this.loadData();
|
||||
},
|
||||
|
||||
// Methods
|
||||
async loadData() {
|
||||
try {
|
||||
this.loading = true;
|
||||
|
||||
// Load tiers and features in parallel
|
||||
const [tiersResponse, featuresResponse, categoriesResponse] = await Promise.all([
|
||||
apiClient.get('/admin/features/tiers'),
|
||||
apiClient.get('/admin/features'),
|
||||
apiClient.get('/admin/features/categories'),
|
||||
]);
|
||||
|
||||
this.tiers = tiersResponse.tiers;
|
||||
this.allFeatures = featuresResponse.features;
|
||||
this.categories = categoriesResponse.categories;
|
||||
|
||||
// Initialize tier features tracking
|
||||
for (const tier of this.tiers) {
|
||||
this.originalTierFeatures[tier.code] = [...tier.features];
|
||||
this.currentTierFeatures[tier.code] = [...tier.features];
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load features:', error);
|
||||
this.showNotification('Failed to load features', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
getSelectedTierFeatures() {
|
||||
return this.currentTierFeatures[this.selectedTier] || [];
|
||||
},
|
||||
|
||||
getAvailableFeatures() {
|
||||
const tierFeatures = this.getSelectedTierFeatures();
|
||||
return this.allFeatures.filter(f => {
|
||||
const notIncluded = !tierFeatures.includes(f.code);
|
||||
const matchesCategory = !this.categoryFilter || f.category === this.categoryFilter;
|
||||
return notIncluded && matchesCategory && f.is_active;
|
||||
});
|
||||
},
|
||||
|
||||
getFeatureName(code) {
|
||||
const feature = this.allFeatures.find(f => f.code === code);
|
||||
return feature?.name || code;
|
||||
},
|
||||
|
||||
getFeatureCategory(code) {
|
||||
const feature = this.allFeatures.find(f => f.code === code);
|
||||
return feature?.category || 'unknown';
|
||||
},
|
||||
|
||||
addFeatureToTier(featureCode) {
|
||||
if (!this.currentTierFeatures[this.selectedTier].includes(featureCode)) {
|
||||
this.currentTierFeatures[this.selectedTier].push(featureCode);
|
||||
}
|
||||
},
|
||||
|
||||
removeFeatureFromTier(featureCode) {
|
||||
const index = this.currentTierFeatures[this.selectedTier].indexOf(featureCode);
|
||||
if (index > -1) {
|
||||
this.currentTierFeatures[this.selectedTier].splice(index, 1);
|
||||
}
|
||||
},
|
||||
|
||||
async saveTierFeatures() {
|
||||
if (!this.hasChanges) return;
|
||||
|
||||
try {
|
||||
this.saving = true;
|
||||
|
||||
await apiClient.put(`/admin/features/tiers/${this.selectedTier}/features`, {
|
||||
feature_codes: this.currentTierFeatures[this.selectedTier]
|
||||
});
|
||||
|
||||
// Update original to match current
|
||||
this.originalTierFeatures[this.selectedTier] = [...this.currentTierFeatures[this.selectedTier]];
|
||||
|
||||
// Update tier in tiers array
|
||||
const tier = this.tiers.find(t => t.code === this.selectedTier);
|
||||
if (tier) {
|
||||
tier.features = [...this.currentTierFeatures[this.selectedTier]];
|
||||
tier.feature_count = tier.features.length;
|
||||
}
|
||||
|
||||
this.showNotification('Features saved successfully', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to save features:', error);
|
||||
this.showNotification('Failed to save features', 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
showNotification(message, type = 'info') {
|
||||
// Simple alert for now - could be improved with toast notifications
|
||||
if (type === 'error') {
|
||||
alert('Error: ' + message);
|
||||
} else {
|
||||
console.log(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
335
app/templates/shared/macros/feature_gate.html
Normal file
335
app/templates/shared/macros/feature_gate.html
Normal file
@@ -0,0 +1,335 @@
|
||||
{# =============================================================================
|
||||
Feature Gate Macros
|
||||
|
||||
Provides macros for tier-based feature gating in templates.
|
||||
Uses Alpine.js $store.features for dynamic checking.
|
||||
|
||||
Usage:
|
||||
{% from "shared/macros/feature_gate.html" import feature_gate, feature_locked, upgrade_banner %}
|
||||
|
||||
{# Show content only if feature is available #}
|
||||
{% call feature_gate("analytics_dashboard") %}
|
||||
<div>Analytics content here</div>
|
||||
{% endcall %}
|
||||
|
||||
{# Show locked state with upgrade prompt #}
|
||||
{{ feature_locked("analytics_dashboard", "Analytics Dashboard", "View advanced analytics") }}
|
||||
|
||||
{# Show upgrade banner #}
|
||||
{{ upgrade_banner("analytics_dashboard") }}
|
||||
============================================================================= #}
|
||||
|
||||
|
||||
{# =============================================================================
|
||||
Feature Gate Container
|
||||
Shows content only if feature is available
|
||||
|
||||
Parameters:
|
||||
- feature_code: The feature code to check
|
||||
- fallback: Optional fallback content when feature is not available
|
||||
============================================================================= #}
|
||||
{% macro feature_gate(feature_code, show_fallback=true) %}
|
||||
<div x-data x-show="$store.features.has('{{ feature_code }}')" x-cloak>
|
||||
{{ caller() }}
|
||||
</div>
|
||||
{% if show_fallback %}
|
||||
<div x-data x-show="$store.features.loaded && !$store.features.has('{{ feature_code }}')" x-cloak>
|
||||
{{ feature_locked(feature_code) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{# =============================================================================
|
||||
Feature Locked Card
|
||||
Shows a card explaining the feature is locked with upgrade prompt
|
||||
|
||||
Parameters:
|
||||
- feature_code: The feature code
|
||||
- title: Optional title override
|
||||
- description: Optional description override
|
||||
- show_upgrade_button: Whether to show upgrade button (default true)
|
||||
============================================================================= #}
|
||||
{% macro feature_locked(feature_code, title=none, description=none, show_upgrade_button=true) %}
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600 p-6 text-center"
|
||||
x-data="{ feature: null }"
|
||||
x-init="
|
||||
await $store.features.loadFullFeatures();
|
||||
feature = $store.features.getFeature('{{ feature_code }}');
|
||||
">
|
||||
{# Lock icon #}
|
||||
<div class="mx-auto w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{# Title #}
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
{% if title %}
|
||||
{{ title }}
|
||||
{% else %}
|
||||
<span x-text="feature?.name || 'Premium Feature'">Premium Feature</span>
|
||||
{% endif %}
|
||||
</h3>
|
||||
|
||||
{# Description #}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
{% if description %}
|
||||
{{ description }}
|
||||
{% else %}
|
||||
<span x-text="feature?.description || 'This feature requires a plan upgrade.'">
|
||||
This feature requires a plan upgrade.
|
||||
</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{# Tier badge #}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300 mb-4">
|
||||
Available on
|
||||
<span class="font-semibold text-purple-600 dark:text-purple-400"
|
||||
x-text="feature?.minimum_tier_name || 'higher tier'">higher tier</span>
|
||||
plan
|
||||
</p>
|
||||
|
||||
{% if show_upgrade_button %}
|
||||
{# Upgrade button #}
|
||||
<a :href="`/vendor/${$store.features.getVendorCode()}/billing`"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md
|
||||
text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2
|
||||
focus:ring-offset-2 focus:ring-purple-500 transition-colors">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/>
|
||||
</svg>
|
||||
Upgrade Plan
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{# =============================================================================
|
||||
Upgrade Banner
|
||||
Inline banner for upgrade prompts
|
||||
|
||||
Parameters:
|
||||
- feature_code: The feature code
|
||||
- message: Optional custom message
|
||||
============================================================================= #}
|
||||
{% macro upgrade_banner(feature_code, message=none) %}
|
||||
<div x-data="{ feature: null }"
|
||||
x-init="
|
||||
await $store.features.loadFullFeatures();
|
||||
feature = $store.features.getFeature('{{ feature_code }}');
|
||||
"
|
||||
x-show="$store.features.loaded && !$store.features.has('{{ feature_code }}')"
|
||||
x-cloak
|
||||
class="bg-gradient-to-r from-purple-500 to-indigo-600 rounded-lg p-4 mb-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-white mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
<p class="text-white text-sm">
|
||||
{% if message %}
|
||||
{{ message }}
|
||||
{% else %}
|
||||
<span x-text="'Upgrade to ' + (feature?.minimum_tier_name || 'a higher plan') + ' to unlock ' + (feature?.name || 'this feature')">
|
||||
Upgrade to unlock this feature
|
||||
</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<a :href="`/vendor/${$store.features.getVendorCode()}/billing`"
|
||||
class="inline-flex items-center px-3 py-1.5 border border-white text-xs font-medium rounded
|
||||
text-white hover:bg-white hover:text-purple-600 transition-colors">
|
||||
Upgrade
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{# =============================================================================
|
||||
Feature Badge
|
||||
Small badge to show feature tier requirement
|
||||
|
||||
Parameters:
|
||||
- feature_code: The feature code
|
||||
============================================================================= #}
|
||||
{% macro feature_badge(feature_code) %}
|
||||
<span x-data="{ feature: null }"
|
||||
x-init="
|
||||
await $store.features.loadFullFeatures();
|
||||
feature = $store.features.getFeature('{{ feature_code }}');
|
||||
"
|
||||
x-show="!$store.features.has('{{ feature_code }}')"
|
||||
x-cloak
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
|
||||
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
|
||||
</svg>
|
||||
<span x-text="feature?.minimum_tier_name || 'Pro'">Pro</span>
|
||||
</span>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{# =============================================================================
|
||||
Sidebar Item with Feature Gate
|
||||
Sidebar navigation item that shows lock if feature not available
|
||||
|
||||
Parameters:
|
||||
- feature_code: The feature code
|
||||
- href: Link URL
|
||||
- icon: Icon name
|
||||
- label: Display label
|
||||
- current_page: Current page for active state
|
||||
============================================================================= #}
|
||||
{% macro sidebar_item_gated(feature_code, href, icon, label, current_page='') %}
|
||||
<template x-if="$store.features.has('{{ feature_code }}')">
|
||||
<a href="{{ href }}"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium rounded-lg transition-colors
|
||||
{{ 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white' if current_page == label|lower else 'text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white' }}">
|
||||
<span class="w-5 h-5 mr-3" x-html="window.icons?.['{{ icon }}'] || ''"></span>
|
||||
{{ label }}
|
||||
</a>
|
||||
</template>
|
||||
<template x-if="!$store.features.has('{{ feature_code }}')">
|
||||
<div class="flex items-center px-4 py-2 text-sm font-medium rounded-lg text-gray-400 dark:text-gray-500 cursor-not-allowed"
|
||||
title="Requires plan upgrade">
|
||||
<span class="w-5 h-5 mr-3" x-html="window.icons?.['{{ icon }}'] || ''"></span>
|
||||
{{ label }}
|
||||
<svg class="w-4 h-4 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{# =============================================================================
|
||||
UPGRADE PROMPT MACROS
|
||||
============================================================================= #}
|
||||
|
||||
|
||||
{# =============================================================================
|
||||
Usage Limit Warning
|
||||
Shows warning banner when approaching or at a limit
|
||||
|
||||
Parameters:
|
||||
- metric_name: One of "orders", "products", "team_members"
|
||||
============================================================================= #}
|
||||
{% macro limit_warning(metric_name) %}
|
||||
<div x-data
|
||||
x-init="$store.upgrade.loadUsage()"
|
||||
x-show="$store.upgrade.shouldShowLimitWarning('{{ metric_name }}')"
|
||||
x-cloak
|
||||
x-html="$store.upgrade.getLimitWarningHTML('{{ metric_name }}')">
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{# =============================================================================
|
||||
Usage Progress Bar
|
||||
Shows compact progress bar for a limit
|
||||
|
||||
Parameters:
|
||||
- metric_name: One of "orders", "products", "team_members"
|
||||
- label: Display label
|
||||
============================================================================= #}
|
||||
{% macro usage_bar(metric_name, label) %}
|
||||
<div x-data x-init="$store.upgrade.loadUsage()">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ label }}</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400"
|
||||
x-text="$store.upgrade.getUsageString('{{ metric_name }}')"></span>
|
||||
</div>
|
||||
<div x-html="$store.upgrade.getUsageBarHTML('{{ metric_name }}')"></div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{# =============================================================================
|
||||
Upgrade Card
|
||||
Shows upgrade recommendation card (for dashboard)
|
||||
Only shows if there are upgrade recommendations
|
||||
|
||||
Parameters:
|
||||
- class: Additional CSS classes
|
||||
============================================================================= #}
|
||||
{% macro upgrade_card(class='') %}
|
||||
<div x-data
|
||||
x-init="$store.upgrade.loadUsage()"
|
||||
x-show="$store.upgrade.hasUpgradeRecommendation"
|
||||
x-cloak
|
||||
class="{{ class }}"
|
||||
x-html="$store.upgrade.getUpgradeCardHTML()">
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{# =============================================================================
|
||||
Limit Check Button
|
||||
Button that checks limit before action
|
||||
|
||||
Parameters:
|
||||
- limit_type: One of "orders", "products", "team_members"
|
||||
- action: JavaScript to execute if limit allows
|
||||
- label: Button label
|
||||
- class: Additional CSS classes
|
||||
============================================================================= #}
|
||||
{% macro limit_check_button(limit_type, action, label, class='') %}
|
||||
<button
|
||||
@click="$store.upgrade.checkLimitAndProceed('{{ limit_type }}', () => { {{ action }} })"
|
||||
class="{{ class }}">
|
||||
{{ label }}
|
||||
</button>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{# =============================================================================
|
||||
Tier Badge
|
||||
Shows current tier as a badge
|
||||
|
||||
Parameters:
|
||||
- class: Additional CSS classes
|
||||
============================================================================= #}
|
||||
{% macro tier_badge(class='') %}
|
||||
<span x-data
|
||||
x-init="$store.upgrade.loadUsage()"
|
||||
x-show="$store.upgrade.currentTier"
|
||||
x-cloak
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ class }}"
|
||||
:class="{
|
||||
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300': $store.upgrade.currentTier?.code === 'essential',
|
||||
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300': $store.upgrade.currentTier?.code === 'professional',
|
||||
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300': $store.upgrade.currentTier?.code === 'business',
|
||||
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300': $store.upgrade.currentTier?.code === 'enterprise'
|
||||
}"
|
||||
x-text="$store.upgrade.currentTier?.name">
|
||||
</span>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{# =============================================================================
|
||||
Quick Upgrade Link
|
||||
Simple upgrade link that appears when limits are reached
|
||||
============================================================================= #}
|
||||
{% macro quick_upgrade_link() %}
|
||||
<a x-data
|
||||
x-init="$store.upgrade.loadUsage()"
|
||||
x-show="$store.upgrade.hasLimitsReached && $store.upgrade.nextTier"
|
||||
x-cloak
|
||||
:href="$store.upgrade.getBillingUrl()"
|
||||
class="inline-flex items-center text-sm text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-300">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/>
|
||||
</svg>
|
||||
<span>Upgrade to <span x-text="$store.upgrade.nextTier?.name"></span></span>
|
||||
</a>
|
||||
{% endmacro %}
|
||||
10
app/templates/vendor/base.html
vendored
10
app/templates/vendor/base.html
vendored
@@ -62,7 +62,13 @@
|
||||
<!-- 5. FIFTH: API Client (depends on Utils) -->
|
||||
<script src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
|
||||
|
||||
<!-- 6. SIXTH: Alpine.js v3 with CDN fallback (with defer) -->
|
||||
<!-- 6. SIXTH: Feature Store (depends on API Client, registers with Alpine) -->
|
||||
<script src="{{ url_for('static', path='shared/js/feature-store.js') }}"></script>
|
||||
|
||||
<!-- 7. SEVENTH: Upgrade Prompts (depends on API Client, registers with Alpine) -->
|
||||
<script src="{{ url_for('static', path='shared/js/upgrade-prompts.js') }}"></script>
|
||||
|
||||
<!-- 8. EIGHTH: Alpine.js v3 with CDN fallback (with defer) -->
|
||||
<script>
|
||||
(function() {
|
||||
var script = document.createElement('script');
|
||||
@@ -81,7 +87,7 @@
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- 7. LAST: Page-specific scripts -->
|
||||
<!-- 9. LAST: Page-specific scripts -->
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
30
app/templates/vendor/dashboard.html
vendored
30
app/templates/vendor/dashboard.html
vendored
@@ -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 Warnings -->
|
||||
{{ limit_warning("orders") }}
|
||||
{{ limit_warning("products") }}
|
||||
|
||||
<!-- Page Header with Refresh Button -->
|
||||
<div class="flex items-center justify-between my-6">
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Dashboard
|
||||
</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Dashboard
|
||||
</h2>
|
||||
{{ tier_badge() }}
|
||||
</div>
|
||||
<button
|
||||
@click="refresh()"
|
||||
:disabled="loading"
|
||||
@@ -40,6 +48,9 @@
|
||||
<!-- Vendor Info Card -->
|
||||
{% include 'vendor/partials/vendor_info.html' %}
|
||||
|
||||
<!-- Upgrade Recommendation Card (shows when approaching/at limits) -->
|
||||
{{ upgrade_card(class='mb-6') }}
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
|
||||
<!-- Card: Total Products -->
|
||||
@@ -103,6 +114,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Overview -->
|
||||
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-3">
|
||||
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
{{ usage_bar("orders", "Monthly Orders") }}
|
||||
</div>
|
||||
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
{{ usage_bar("products", "Products") }}
|
||||
</div>
|
||||
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
{{ usage_bar("team_members", "Team Members") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Orders Table -->
|
||||
<div x-show="!loading && recentOrders.length > 0" class="w-full mb-8 overflow-hidden rounded-lg shadow-xs">
|
||||
<div class="w-full overflow-x-auto">
|
||||
|
||||
@@ -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",
|
||||
|
||||
191
models/database/feature.py
Normal file
191
models/database/feature.py
Normal file
@@ -0,0 +1,191 @@
|
||||
# models/database/feature.py
|
||||
"""
|
||||
Feature registry for tier-based access control.
|
||||
|
||||
Provides a database-driven feature registry that allows:
|
||||
- Dynamic feature-to-tier assignment (no code changes needed)
|
||||
- UI metadata for frontend rendering
|
||||
- Feature categorization for organization
|
||||
- Upgrade prompts with tier info
|
||||
|
||||
Features are assigned to tiers via the SubscriptionTier.features JSON array.
|
||||
This model provides the metadata and acts as a registry of all available features.
|
||||
"""
|
||||
|
||||
import enum
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, Integer, String, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class FeatureCategory(str, enum.Enum):
|
||||
"""Feature categories for organization."""
|
||||
|
||||
ORDERS = "orders"
|
||||
INVENTORY = "inventory"
|
||||
ANALYTICS = "analytics"
|
||||
INVOICING = "invoicing"
|
||||
INTEGRATIONS = "integrations"
|
||||
TEAM = "team"
|
||||
BRANDING = "branding"
|
||||
CUSTOMERS = "customers"
|
||||
|
||||
|
||||
class FeatureUILocation(str, enum.Enum):
|
||||
"""Where the feature appears in the UI."""
|
||||
|
||||
SIDEBAR = "sidebar" # Main navigation item
|
||||
DASHBOARD = "dashboard" # Dashboard widget/section
|
||||
SETTINGS = "settings" # Settings page option
|
||||
API = "api" # API-only feature (no UI)
|
||||
INLINE = "inline" # Inline feature within a page
|
||||
|
||||
|
||||
class Feature(Base, TimestampMixin):
|
||||
"""
|
||||
Feature registry for tier-based access control.
|
||||
|
||||
Each feature represents a capability that can be enabled/disabled per tier.
|
||||
The actual tier assignment is stored in SubscriptionTier.features as a JSON
|
||||
array of feature codes. This table provides metadata for:
|
||||
- UI rendering (icons, labels, locations)
|
||||
- Upgrade prompts (which tier unlocks this?)
|
||||
- Admin management (description, categorization)
|
||||
|
||||
Example features:
|
||||
- analytics_dashboard: Full analytics with charts
|
||||
- api_access: REST API access for integrations
|
||||
- team_roles: Role-based permissions for team members
|
||||
- automation_rules: Automatic order processing rules
|
||||
"""
|
||||
|
||||
__tablename__ = "features"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Unique identifier used in code and tier.features JSON
|
||||
code = Column(String(50), unique=True, nullable=False, index=True)
|
||||
|
||||
# Display info
|
||||
name = Column(String(100), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# Categorization
|
||||
category = Column(String(50), nullable=False, index=True)
|
||||
|
||||
# UI metadata - tells frontend how to render
|
||||
ui_location = Column(String(50), nullable=True) # sidebar, dashboard, settings, api
|
||||
ui_icon = Column(String(50), nullable=True) # Icon name (e.g., "chart-bar")
|
||||
ui_route = Column(String(100), nullable=True) # Route pattern (e.g., "/vendor/{code}/analytics")
|
||||
ui_badge_text = Column(String(20), nullable=True) # Badge to show (e.g., "Pro", "New")
|
||||
|
||||
# Minimum tier that includes this feature (for upgrade prompts)
|
||||
# This is denormalized for performance - the actual assignment is in SubscriptionTier.features
|
||||
minimum_tier_id = Column(
|
||||
Integer, ForeignKey("subscription_tiers.id"), nullable=True, index=True
|
||||
)
|
||||
minimum_tier = relationship("SubscriptionTier", foreign_keys=[minimum_tier_id])
|
||||
|
||||
# Status
|
||||
is_active = Column(Boolean, default=True, nullable=False) # Feature available at all
|
||||
is_visible = Column(Boolean, default=True, nullable=False) # Show in UI even if locked
|
||||
display_order = Column(Integer, default=0, nullable=False) # Sort order within category
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index("idx_feature_category_order", "category", "display_order"),
|
||||
Index("idx_feature_active_visible", "is_active", "is_visible"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Feature(code='{self.code}', name='{self.name}', category='{self.category}')>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for API responses."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"code": self.code,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"category": self.category,
|
||||
"ui_location": self.ui_location,
|
||||
"ui_icon": self.ui_icon,
|
||||
"ui_route": self.ui_route,
|
||||
"ui_badge_text": self.ui_badge_text,
|
||||
"minimum_tier_code": self.minimum_tier.code if self.minimum_tier else None,
|
||||
"minimum_tier_name": self.minimum_tier.name if self.minimum_tier else None,
|
||||
"is_active": self.is_active,
|
||||
"is_visible": self.is_visible,
|
||||
"display_order": self.display_order,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Feature Code Constants
|
||||
# ============================================================================
|
||||
# These constants are used throughout the codebase for type safety.
|
||||
# The actual feature definitions and tier assignments are in the database.
|
||||
|
||||
|
||||
class FeatureCode:
|
||||
"""
|
||||
Feature code constants for use in @require_feature decorator and checks.
|
||||
|
||||
Usage:
|
||||
@require_feature(FeatureCode.ANALYTICS_DASHBOARD)
|
||||
def get_analytics(...):
|
||||
...
|
||||
|
||||
if feature_service.has_feature(db, vendor_id, FeatureCode.API_ACCESS):
|
||||
...
|
||||
"""
|
||||
|
||||
# Orders
|
||||
ORDER_MANAGEMENT = "order_management"
|
||||
ORDER_BULK_ACTIONS = "order_bulk_actions"
|
||||
ORDER_EXPORT = "order_export"
|
||||
AUTOMATION_RULES = "automation_rules"
|
||||
|
||||
# Inventory
|
||||
INVENTORY_BASIC = "inventory_basic"
|
||||
INVENTORY_LOCATIONS = "inventory_locations"
|
||||
INVENTORY_PURCHASE_ORDERS = "inventory_purchase_orders"
|
||||
LOW_STOCK_ALERTS = "low_stock_alerts"
|
||||
|
||||
# Analytics
|
||||
BASIC_REPORTS = "basic_reports"
|
||||
ANALYTICS_DASHBOARD = "analytics_dashboard"
|
||||
CUSTOM_REPORTS = "custom_reports"
|
||||
EXPORT_REPORTS = "export_reports"
|
||||
|
||||
# Invoicing
|
||||
INVOICE_LU = "invoice_lu"
|
||||
INVOICE_EU_VAT = "invoice_eu_vat"
|
||||
INVOICE_BULK = "invoice_bulk"
|
||||
ACCOUNTING_EXPORT = "accounting_export"
|
||||
|
||||
# Integrations
|
||||
LETZSHOP_SYNC = "letzshop_sync"
|
||||
API_ACCESS = "api_access"
|
||||
WEBHOOKS = "webhooks"
|
||||
CUSTOM_INTEGRATIONS = "custom_integrations"
|
||||
|
||||
# Team
|
||||
SINGLE_USER = "single_user"
|
||||
TEAM_BASIC = "team_basic"
|
||||
TEAM_ROLES = "team_roles"
|
||||
AUDIT_LOG = "audit_log"
|
||||
|
||||
# Branding
|
||||
BASIC_SHOP = "basic_shop"
|
||||
CUSTOM_DOMAIN = "custom_domain"
|
||||
WHITE_LABEL = "white_label"
|
||||
|
||||
# Customers
|
||||
CUSTOMER_VIEW = "customer_view"
|
||||
CUSTOMER_EXPORT = "customer_export"
|
||||
CUSTOMER_MESSAGING = "customer_messaging"
|
||||
205
static/shared/js/feature-store.js
Normal file
205
static/shared/js/feature-store.js
Normal file
@@ -0,0 +1,205 @@
|
||||
// static/shared/js/feature-store.js
|
||||
/**
|
||||
* Feature Store for Alpine.js
|
||||
*
|
||||
* Provides feature availability checking for tier-based access control.
|
||||
* Loads features from the API on init and caches them for the session.
|
||||
*
|
||||
* Usage in templates:
|
||||
*
|
||||
* 1. Check if feature is available:
|
||||
* <div x-show="$store.features.has('analytics_dashboard')">
|
||||
* Analytics content here
|
||||
* </div>
|
||||
*
|
||||
* 2. Show upgrade prompt if not available:
|
||||
* <div x-show="!$store.features.has('analytics_dashboard')">
|
||||
* <p>Upgrade to access Analytics</p>
|
||||
* </div>
|
||||
*
|
||||
* 3. Conditionally render with x-if:
|
||||
* <template x-if="$store.features.has('api_access')">
|
||||
* <a href="/settings/api">API Settings</a>
|
||||
* </template>
|
||||
*
|
||||
* 4. Use feature data for upgrade prompts:
|
||||
* <p x-text="$store.features.getUpgradeTier('analytics_dashboard')"></p>
|
||||
*
|
||||
* 5. Get current tier info:
|
||||
* <span x-text="$store.features.tierName"></span>
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Use centralized logger if available
|
||||
const log = window.LogConfig?.log || console;
|
||||
|
||||
/**
|
||||
* Feature Store
|
||||
*/
|
||||
const featureStore = {
|
||||
// State
|
||||
features: [], // Array of feature codes available to vendor
|
||||
featuresMap: {}, // Full feature info keyed by code
|
||||
tierCode: null, // Current tier code
|
||||
tierName: null, // Current tier name
|
||||
loading: true, // Loading state
|
||||
loaded: false, // Whether features have been loaded
|
||||
error: null, // Error message if load failed
|
||||
|
||||
/**
|
||||
* Initialize the feature store
|
||||
* Called automatically when Alpine starts
|
||||
*/
|
||||
async init() {
|
||||
log.debug('[FeatureStore] Initializing...');
|
||||
await this.loadFeatures();
|
||||
},
|
||||
|
||||
/**
|
||||
* Load features from API
|
||||
*/
|
||||
async loadFeatures() {
|
||||
// Don't reload if already loaded
|
||||
if (this.loaded) {
|
||||
log.debug('[FeatureStore] Already loaded, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get vendor code from URL
|
||||
const vendorCode = this.getVendorCode();
|
||||
if (!vendorCode) {
|
||||
log.warn('[FeatureStore] No vendor code found in URL');
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
// Fetch available features (lightweight endpoint)
|
||||
const response = await window.apiClient.get('/vendor/features/available');
|
||||
|
||||
this.features = response.features || [];
|
||||
this.tierCode = response.tier_code;
|
||||
this.tierName = response.tier_name;
|
||||
this.loaded = true;
|
||||
|
||||
log.debug(`[FeatureStore] Loaded ${this.features.length} features for ${this.tierName} tier`);
|
||||
|
||||
} catch (error) {
|
||||
log.error('[FeatureStore] Failed to load features:', error);
|
||||
this.error = error.message || 'Failed to load features';
|
||||
// Set empty array so checks don't fail
|
||||
this.features = [];
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load full feature details (with metadata)
|
||||
* Use this when you need upgrade info
|
||||
*/
|
||||
async loadFullFeatures() {
|
||||
const vendorCode = this.getVendorCode();
|
||||
if (!vendorCode) return;
|
||||
|
||||
try {
|
||||
const response = await window.apiClient.get('/vendor/features');
|
||||
|
||||
// Build map for quick lookup
|
||||
this.featuresMap = {};
|
||||
for (const feature of response.features) {
|
||||
this.featuresMap[feature.code] = feature;
|
||||
}
|
||||
|
||||
log.debug(`[FeatureStore] Loaded full details for ${response.features.length} features`);
|
||||
|
||||
} catch (error) {
|
||||
log.error('[FeatureStore] Failed to load full features:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if vendor has access to a feature
|
||||
* @param {string} featureCode - The feature code to check
|
||||
* @returns {boolean} - Whether the feature is available
|
||||
*/
|
||||
has(featureCode) {
|
||||
return this.features.includes(featureCode);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if vendor has access to ANY of the given features
|
||||
* @param {...string} featureCodes - Feature codes to check
|
||||
* @returns {boolean} - Whether any feature is available
|
||||
*/
|
||||
hasAny(...featureCodes) {
|
||||
return featureCodes.some(code => this.has(code));
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if vendor has access to ALL of the given features
|
||||
* @param {...string} featureCodes - Feature codes to check
|
||||
* @returns {boolean} - Whether all features are available
|
||||
*/
|
||||
hasAll(...featureCodes) {
|
||||
return featureCodes.every(code => this.has(code));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get feature info (requires loadFullFeatures first)
|
||||
* @param {string} featureCode - The feature code
|
||||
* @returns {object|null} - Feature info or null
|
||||
*/
|
||||
getFeature(featureCode) {
|
||||
return this.featuresMap[featureCode] || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the tier name required for a feature
|
||||
* @param {string} featureCode - The feature code
|
||||
* @returns {string|null} - Tier name or null
|
||||
*/
|
||||
getUpgradeTier(featureCode) {
|
||||
const feature = this.getFeature(featureCode);
|
||||
return feature?.minimum_tier_name || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get vendor code from URL
|
||||
* @returns {string|null}
|
||||
*/
|
||||
getVendorCode() {
|
||||
const path = window.location.pathname;
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
if (segments[0] === 'vendor' && segments[1]) {
|
||||
return segments[1];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Reload features (e.g., after tier change)
|
||||
*/
|
||||
async reload() {
|
||||
this.loaded = false;
|
||||
this.features = [];
|
||||
this.featuresMap = {};
|
||||
await this.loadFeatures();
|
||||
}
|
||||
};
|
||||
|
||||
// Register Alpine store when Alpine is available
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.store('features', featureStore);
|
||||
log.debug('[FeatureStore] Registered as Alpine store');
|
||||
});
|
||||
|
||||
// Also expose globally for non-Alpine usage
|
||||
window.FeatureStore = featureStore;
|
||||
|
||||
})();
|
||||
361
static/shared/js/upgrade-prompts.js
Normal file
361
static/shared/js/upgrade-prompts.js
Normal file
@@ -0,0 +1,361 @@
|
||||
// static/shared/js/upgrade-prompts.js
|
||||
/**
|
||||
* Upgrade Prompts System
|
||||
*
|
||||
* Provides contextual upgrade prompts based on:
|
||||
* - Usage limits approaching/reached
|
||||
* - Locked features
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* 1. Initialize the store (auto-loads usage on init):
|
||||
* <div x-data x-init="$store.upgrade.loadUsage()">
|
||||
*
|
||||
* 2. Show limit warning banner:
|
||||
* <template x-if="$store.upgrade.shouldShowLimitWarning('orders')">
|
||||
* <div x-html="$store.upgrade.getLimitWarningHTML('orders')"></div>
|
||||
* </template>
|
||||
*
|
||||
* 3. Check before action:
|
||||
* <button @click="$store.upgrade.checkLimitAndProceed('products', () => createProduct())">
|
||||
* Add Product
|
||||
* </button>
|
||||
*
|
||||
* 4. Show upgrade CTA on dashboard:
|
||||
* <template x-if="$store.upgrade.hasUpgradeRecommendation">
|
||||
* <div x-html="$store.upgrade.getUpgradeCardHTML()"></div>
|
||||
* </template>
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const log = window.LogConfig?.log || console;
|
||||
|
||||
/**
|
||||
* Upgrade Prompts Store
|
||||
*/
|
||||
const upgradeStore = {
|
||||
// State
|
||||
usage: null,
|
||||
loading: false,
|
||||
loaded: false,
|
||||
error: null,
|
||||
|
||||
// Computed-like getters
|
||||
get hasLimitsApproaching() {
|
||||
return this.usage?.has_limits_approaching || false;
|
||||
},
|
||||
|
||||
get hasLimitsReached() {
|
||||
return this.usage?.has_limits_reached || false;
|
||||
},
|
||||
|
||||
get hasUpgradeRecommendation() {
|
||||
return this.usage?.upgrade_available && (this.hasLimitsApproaching || this.hasLimitsReached);
|
||||
},
|
||||
|
||||
get upgradeReasons() {
|
||||
return this.usage?.upgrade_reasons || [];
|
||||
},
|
||||
|
||||
get currentTier() {
|
||||
return this.usage?.tier || null;
|
||||
},
|
||||
|
||||
get nextTier() {
|
||||
return this.usage?.upgrade_tier || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Load usage data from API
|
||||
*/
|
||||
async loadUsage() {
|
||||
if (this.loaded || this.loading) return;
|
||||
|
||||
try {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
const response = await window.apiClient.get('/vendor/usage');
|
||||
this.usage = response;
|
||||
this.loaded = true;
|
||||
|
||||
log.debug('[UpgradePrompts] Loaded usage data', this.usage);
|
||||
|
||||
} catch (error) {
|
||||
log.error('[UpgradePrompts] Failed to load usage:', error);
|
||||
this.error = error.message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get usage metric by name
|
||||
*/
|
||||
getMetric(name) {
|
||||
if (!this.usage?.usage) return null;
|
||||
return this.usage.usage.find(m => m.name === name);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if should show limit warning for a metric
|
||||
*/
|
||||
shouldShowLimitWarning(metricName) {
|
||||
const metric = this.getMetric(metricName);
|
||||
return metric && (metric.is_approaching_limit || metric.is_at_limit);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if at limit for a metric
|
||||
*/
|
||||
isAtLimit(metricName) {
|
||||
const metric = this.getMetric(metricName);
|
||||
return metric?.is_at_limit || false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get percentage used for a metric
|
||||
*/
|
||||
getPercentage(metricName) {
|
||||
const metric = this.getMetric(metricName);
|
||||
return metric?.percentage || 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get formatted usage string (e.g., "85/100")
|
||||
*/
|
||||
getUsageString(metricName) {
|
||||
const metric = this.getMetric(metricName);
|
||||
if (!metric) return '';
|
||||
if (metric.is_unlimited) return `${metric.current} (unlimited)`;
|
||||
return `${metric.current}/${metric.limit}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get vendor code from URL
|
||||
*/
|
||||
getVendorCode() {
|
||||
const path = window.location.pathname;
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
if (segments[0] === 'vendor' && segments[1]) {
|
||||
return segments[1];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get billing URL
|
||||
*/
|
||||
getBillingUrl() {
|
||||
const vendorCode = this.getVendorCode();
|
||||
return vendorCode ? `/vendor/${vendorCode}/billing` : '#';
|
||||
},
|
||||
|
||||
/**
|
||||
* Check limit before action, show modal if at limit
|
||||
*/
|
||||
async checkLimitAndProceed(limitType, onSuccess) {
|
||||
try {
|
||||
const response = await window.apiClient.get(`/vendor/usage/check/${limitType}`);
|
||||
|
||||
if (response.can_proceed) {
|
||||
if (typeof onSuccess === 'function') {
|
||||
onSuccess();
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
// Show upgrade modal
|
||||
this.showLimitReachedModal(limitType, response);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('[UpgradePrompts] Failed to check limit:', error);
|
||||
// Proceed anyway on error (fail open)
|
||||
if (typeof onSuccess === 'function') {
|
||||
onSuccess();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Show limit reached modal
|
||||
*/
|
||||
showLimitReachedModal(limitType, response) {
|
||||
const limitNames = {
|
||||
'orders': 'monthly orders',
|
||||
'products': 'products',
|
||||
'team_members': 'team members'
|
||||
};
|
||||
|
||||
const limitName = limitNames[limitType] || limitType;
|
||||
const message = response.message || `You've reached your ${limitName} limit.`;
|
||||
|
||||
// Use browser confirm for simplicity - could be replaced with custom modal
|
||||
const shouldUpgrade = confirm(
|
||||
`${message}\n\n` +
|
||||
`Current: ${response.current}/${response.limit}\n\n` +
|
||||
(response.upgrade_tier_name
|
||||
? `Upgrade to ${response.upgrade_tier_name} to get more ${limitName}.\n\nGo to billing page?`
|
||||
: 'Contact support for more capacity.')
|
||||
);
|
||||
|
||||
if (shouldUpgrade && response.upgrade_tier_code) {
|
||||
window.location.href = this.getBillingUrl();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get limit warning banner HTML
|
||||
*/
|
||||
getLimitWarningHTML(metricName) {
|
||||
const metric = this.getMetric(metricName);
|
||||
if (!metric) return '';
|
||||
|
||||
const names = {
|
||||
'orders': 'monthly orders',
|
||||
'products': 'products',
|
||||
'team_members': 'team members'
|
||||
};
|
||||
const name = names[metricName] || metricName;
|
||||
|
||||
if (metric.is_at_limit) {
|
||||
return `
|
||||
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-4">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-red-500 mr-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
You've reached your ${name} limit (${metric.current}/${metric.limit})
|
||||
</p>
|
||||
</div>
|
||||
<a href="${this.getBillingUrl()}"
|
||||
class="ml-4 px-3 py-1 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded">
|
||||
Upgrade
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (metric.is_approaching_limit) {
|
||||
return `
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-4">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-yellow-500 mr-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||
You're approaching your ${name} limit (${metric.current}/${metric.limit} - ${Math.round(metric.percentage)}%)
|
||||
</p>
|
||||
</div>
|
||||
<a href="${this.getBillingUrl()}"
|
||||
class="ml-4 px-3 py-1 text-sm font-medium text-yellow-800 bg-yellow-200 hover:bg-yellow-300 dark:bg-yellow-800 dark:text-yellow-200 dark:hover:bg-yellow-700 rounded">
|
||||
Upgrade
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return '';
|
||||
},
|
||||
|
||||
/**
|
||||
* Get upgrade card HTML for dashboard
|
||||
*/
|
||||
getUpgradeCardHTML() {
|
||||
if (!this.usage?.upgrade_tier) return '';
|
||||
|
||||
const tier = this.usage.upgrade_tier;
|
||||
const reasons = this.usage.upgrade_reasons || [];
|
||||
|
||||
return `
|
||||
<div class="bg-gradient-to-r from-purple-500 to-indigo-600 rounded-lg p-6 text-white">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-2">Upgrade to ${tier.name}</h3>
|
||||
${reasons.length > 0 ? `
|
||||
<ul class="text-sm opacity-90 mb-4 space-y-1">
|
||||
${reasons.map(r => `<li>• ${r}</li>`).join('')}
|
||||
</ul>
|
||||
` : ''}
|
||||
${tier.benefits.length > 0 ? `
|
||||
<p class="text-sm opacity-80 mb-2">Get access to:</p>
|
||||
<ul class="text-sm space-y-1 mb-4">
|
||||
${tier.benefits.slice(0, 4).map(b => `
|
||||
<li class="flex items-center">
|
||||
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
${b}
|
||||
</li>
|
||||
`).join('')}
|
||||
</ul>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-2xl font-bold">€${(tier.price_monthly_cents / 100).toFixed(0)}</p>
|
||||
<p class="text-sm opacity-80">/month</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="${this.getBillingUrl()}"
|
||||
class="mt-4 inline-flex items-center px-4 py-2 bg-white text-purple-600 font-medium rounded-lg hover:bg-gray-100 transition-colors">
|
||||
Upgrade Now
|
||||
<svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get compact usage bar HTML
|
||||
*/
|
||||
getUsageBarHTML(metricName) {
|
||||
const metric = this.getMetric(metricName);
|
||||
if (!metric || metric.is_unlimited) return '';
|
||||
|
||||
const percentage = Math.min(metric.percentage, 100);
|
||||
const colorClass = metric.is_at_limit
|
||||
? 'bg-red-500'
|
||||
: metric.is_approaching_limit
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-green-500';
|
||||
|
||||
return `
|
||||
<div class="w-full">
|
||||
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
<span>${metric.current} / ${metric.limit}</span>
|
||||
<span>${Math.round(percentage)}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div class="${colorClass} h-2 rounded-full transition-all" style="width: ${percentage}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Reload usage data
|
||||
*/
|
||||
async reload() {
|
||||
this.loaded = false;
|
||||
await this.loadUsage();
|
||||
}
|
||||
};
|
||||
|
||||
// Register Alpine store when Alpine is available
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.store('upgrade', upgradeStore);
|
||||
log.debug('[UpgradePrompts] Registered as Alpine store');
|
||||
});
|
||||
|
||||
// Also expose globally
|
||||
window.UpgradePrompts = upgradeStore;
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user