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,
|
content_pages,
|
||||||
customers,
|
customers,
|
||||||
dashboard,
|
dashboard,
|
||||||
|
features,
|
||||||
images,
|
images,
|
||||||
inventory,
|
inventory,
|
||||||
letzshop,
|
letzshop,
|
||||||
@@ -181,6 +182,9 @@ router.include_router(
|
|||||||
# Include subscription management endpoints
|
# Include subscription management endpoints
|
||||||
router.include_router(subscriptions.router, tags=["admin-subscriptions"])
|
router.include_router(subscriptions.router, tags=["admin-subscriptions"])
|
||||||
|
|
||||||
|
# Include feature management endpoints
|
||||||
|
router.include_router(features.router, tags=["admin-features"])
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Code Quality & Architecture
|
# Code Quality & Architecture
|
||||||
|
|||||||
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,
|
content_pages,
|
||||||
customers,
|
customers,
|
||||||
dashboard,
|
dashboard,
|
||||||
|
features,
|
||||||
info,
|
info,
|
||||||
inventory,
|
inventory,
|
||||||
invoices,
|
invoices,
|
||||||
@@ -36,6 +37,7 @@ from . import (
|
|||||||
profile,
|
profile,
|
||||||
settings,
|
settings,
|
||||||
team,
|
team,
|
||||||
|
usage,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create vendor router
|
# Create vendor router
|
||||||
@@ -77,6 +79,8 @@ router.include_router(notifications.router, tags=["vendor-notifications"])
|
|||||||
router.include_router(messages.router, tags=["vendor-messages"])
|
router.include_router(messages.router, tags=["vendor-messages"])
|
||||||
router.include_router(analytics.router, tags=["vendor-analytics"])
|
router.include_router(analytics.router, tags=["vendor-analytics"])
|
||||||
router.include_router(billing.router, tags=["vendor-billing"])
|
router.include_router(billing.router, tags=["vendor-billing"])
|
||||||
|
router.include_router(features.router, tags=["vendor-features"])
|
||||||
|
router.include_router(usage.router, tags=["vendor-usage"])
|
||||||
|
|
||||||
# Content pages management
|
# Content pages management
|
||||||
router.include_router(
|
router.include_router(
|
||||||
|
|||||||
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).
|
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
|
||||||
The get_current_vendor_api dependency guarantees token_vendor_id is present.
|
The get_current_vendor_api dependency guarantees token_vendor_id is present.
|
||||||
|
|
||||||
|
Feature Requirements:
|
||||||
|
- basic_reports: Basic analytics (Essential tier)
|
||||||
|
- analytics_dashboard: Advanced analytics (Business tier)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -13,7 +17,9 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.api.deps import get_current_vendor_api
|
from app.api.deps import get_current_vendor_api
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
|
from app.core.feature_gate import RequireFeature
|
||||||
from app.services.stats_service import stats_service
|
from app.services.stats_service import stats_service
|
||||||
|
from models.database.feature import FeatureCode
|
||||||
from models.database.user import User
|
from models.database.user import User
|
||||||
from models.schema.stats import (
|
from models.schema.stats import (
|
||||||
VendorAnalyticsCatalog,
|
VendorAnalyticsCatalog,
|
||||||
@@ -31,6 +37,7 @@ def get_vendor_analytics(
|
|||||||
period: str = Query("30d", description="Time period: 7d, 30d, 90d, 1y"),
|
period: str = Query("30d", description="Time period: 7d, 30d, 90d, 1y"),
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
_: None = Depends(RequireFeature(FeatureCode.BASIC_REPORTS, FeatureCode.ANALYTICS_DASHBOARD)),
|
||||||
):
|
):
|
||||||
"""Get vendor analytics data for specified time period."""
|
"""Get vendor analytics data for specified time period."""
|
||||||
data = stats_service.get_vendor_analytics(db, current_user.token_vendor_id, period)
|
data = stats_service.get_vendor_analytics(db, current_user.token_vendor_id, period)
|
||||||
|
|||||||
340
app/api/v1/vendor/features.py
vendored
Normal file
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
|
- POST /invoices/settings - Create invoice settings
|
||||||
- PUT /invoices/settings - Update invoice settings
|
- PUT /invoices/settings - Update invoice settings
|
||||||
- GET /invoices/stats - Get invoice statistics
|
- GET /invoices/stats - Get invoice statistics
|
||||||
|
|
||||||
|
Feature Requirements:
|
||||||
|
- invoice_lu: Basic Luxembourg invoicing (Essential tier)
|
||||||
|
- invoice_eu_vat: EU VAT support (Professional tier)
|
||||||
|
- invoice_bulk: Bulk invoicing (Business tier)
|
||||||
|
- accounting_export: Export to accounting software (Business tier)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -27,6 +33,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.api.deps import get_current_vendor_api
|
from app.api.deps import get_current_vendor_api
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
|
from app.core.feature_gate import RequireFeature
|
||||||
from app.exceptions.invoice import (
|
from app.exceptions.invoice import (
|
||||||
InvoiceNotFoundException,
|
InvoiceNotFoundException,
|
||||||
InvoicePDFGenerationException,
|
InvoicePDFGenerationException,
|
||||||
@@ -34,6 +41,7 @@ from app.exceptions.invoice import (
|
|||||||
InvoiceSettingsNotFoundException,
|
InvoiceSettingsNotFoundException,
|
||||||
)
|
)
|
||||||
from app.services.invoice_service import invoice_service
|
from app.services.invoice_service import invoice_service
|
||||||
|
from models.database.feature import FeatureCode
|
||||||
from models.database.user import User
|
from models.database.user import User
|
||||||
from models.schema.invoice import (
|
from models.schema.invoice import (
|
||||||
InvoiceCreate,
|
InvoiceCreate,
|
||||||
@@ -61,11 +69,13 @@ logger = logging.getLogger(__name__)
|
|||||||
def get_invoice_settings(
|
def get_invoice_settings(
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
_: None = Depends(RequireFeature(FeatureCode.INVOICE_LU)),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get vendor invoice settings.
|
Get vendor invoice settings.
|
||||||
|
|
||||||
Returns null if settings not yet configured.
|
Returns null if settings not yet configured.
|
||||||
|
Requires: invoice_lu feature (Essential tier)
|
||||||
"""
|
"""
|
||||||
settings = invoice_service.get_settings(db, current_user.token_vendor_id)
|
settings = invoice_service.get_settings(db, current_user.token_vendor_id)
|
||||||
if settings:
|
if settings:
|
||||||
|
|||||||
380
app/api/v1/vendor/usage.py
vendored
Normal file
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
|
- Subscription lifecycle events
|
||||||
- Invoice and payment events
|
- Invoice and payment events
|
||||||
- Checkout session completion
|
- Checkout session completion
|
||||||
|
- Add-on purchases
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -15,10 +16,12 @@ import stripe
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from models.database.subscription import (
|
from models.database.subscription import (
|
||||||
|
AddOnProduct,
|
||||||
BillingHistory,
|
BillingHistory,
|
||||||
StripeWebhookEvent,
|
StripeWebhookEvent,
|
||||||
SubscriptionStatus,
|
SubscriptionStatus,
|
||||||
SubscriptionTier,
|
SubscriptionTier,
|
||||||
|
VendorAddOn,
|
||||||
VendorSubscription,
|
VendorSubscription,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -108,15 +111,34 @@ class StripeWebhookHandler:
|
|||||||
def _handle_checkout_completed(
|
def _handle_checkout_completed(
|
||||||
self, db: Session, event: stripe.Event
|
self, db: Session, event: stripe.Event
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Handle checkout.session.completed event."""
|
"""
|
||||||
|
Handle checkout.session.completed event.
|
||||||
|
|
||||||
|
Handles two types of checkouts:
|
||||||
|
1. Subscription checkout - Updates VendorSubscription
|
||||||
|
2. Add-on checkout - Creates VendorAddOn record
|
||||||
|
"""
|
||||||
session = event.data.object
|
session = event.data.object
|
||||||
vendor_id = session.metadata.get("vendor_id")
|
vendor_id = session.metadata.get("vendor_id")
|
||||||
|
addon_code = session.metadata.get("addon_code")
|
||||||
|
|
||||||
if not vendor_id:
|
if not vendor_id:
|
||||||
logger.warning(f"Checkout session {session.id} missing vendor_id")
|
logger.warning(f"Checkout session {session.id} missing vendor_id")
|
||||||
return {"action": "skipped", "reason": "no vendor_id"}
|
return {"action": "skipped", "reason": "no vendor_id"}
|
||||||
|
|
||||||
vendor_id = int(vendor_id)
|
vendor_id = int(vendor_id)
|
||||||
|
|
||||||
|
# Check if this is an add-on purchase
|
||||||
|
if addon_code:
|
||||||
|
return self._handle_addon_checkout(db, session, vendor_id, addon_code)
|
||||||
|
|
||||||
|
# Otherwise, handle subscription checkout
|
||||||
|
return self._handle_subscription_checkout(db, session, vendor_id)
|
||||||
|
|
||||||
|
def _handle_subscription_checkout(
|
||||||
|
self, db: Session, session, vendor_id: int
|
||||||
|
) -> dict:
|
||||||
|
"""Handle subscription checkout completion."""
|
||||||
subscription = (
|
subscription = (
|
||||||
db.query(VendorSubscription)
|
db.query(VendorSubscription)
|
||||||
.filter(VendorSubscription.vendor_id == vendor_id)
|
.filter(VendorSubscription.vendor_id == vendor_id)
|
||||||
@@ -147,9 +169,112 @@ class StripeWebhookHandler:
|
|||||||
stripe_sub.trial_end, tz=timezone.utc
|
stripe_sub.trial_end, tz=timezone.utc
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Checkout completed for vendor {vendor_id}")
|
logger.info(f"Subscription checkout completed for vendor {vendor_id}")
|
||||||
return {"action": "activated", "vendor_id": vendor_id}
|
return {"action": "activated", "vendor_id": vendor_id}
|
||||||
|
|
||||||
|
def _handle_addon_checkout(
|
||||||
|
self, db: Session, session, vendor_id: int, addon_code: str
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Handle add-on checkout completion.
|
||||||
|
|
||||||
|
Creates a VendorAddOn record for the purchased add-on.
|
||||||
|
"""
|
||||||
|
# Get the add-on product
|
||||||
|
addon_product = (
|
||||||
|
db.query(AddOnProduct)
|
||||||
|
.filter(AddOnProduct.code == addon_code)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not addon_product:
|
||||||
|
logger.error(f"Add-on product '{addon_code}' not found")
|
||||||
|
return {"action": "failed", "reason": f"addon '{addon_code}' not found"}
|
||||||
|
|
||||||
|
# Check if vendor already has this add-on active
|
||||||
|
existing_addon = (
|
||||||
|
db.query(VendorAddOn)
|
||||||
|
.filter(
|
||||||
|
VendorAddOn.vendor_id == vendor_id,
|
||||||
|
VendorAddOn.addon_product_id == addon_product.id,
|
||||||
|
VendorAddOn.status == "active",
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing_addon:
|
||||||
|
logger.info(
|
||||||
|
f"Vendor {vendor_id} already has active add-on {addon_code}, "
|
||||||
|
f"updating quantity"
|
||||||
|
)
|
||||||
|
# For quantity-based add-ons, we could increment
|
||||||
|
# For now, just log and return
|
||||||
|
return {
|
||||||
|
"action": "already_exists",
|
||||||
|
"vendor_id": vendor_id,
|
||||||
|
"addon_code": addon_code,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get domain name from metadata (for domain add-ons)
|
||||||
|
domain_name = session.metadata.get("domain_name")
|
||||||
|
if domain_name == "":
|
||||||
|
domain_name = None
|
||||||
|
|
||||||
|
# Get subscription item ID from Stripe subscription
|
||||||
|
stripe_subscription_item_id = None
|
||||||
|
if session.subscription:
|
||||||
|
try:
|
||||||
|
stripe_sub = stripe.Subscription.retrieve(session.subscription)
|
||||||
|
if stripe_sub.items.data:
|
||||||
|
# Find the item matching our add-on price
|
||||||
|
for item in stripe_sub.items.data:
|
||||||
|
if item.price.id == addon_product.stripe_price_id:
|
||||||
|
stripe_subscription_item_id = item.id
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not retrieve subscription items: {e}")
|
||||||
|
|
||||||
|
# Get period dates from subscription
|
||||||
|
period_start = None
|
||||||
|
period_end = None
|
||||||
|
if session.subscription:
|
||||||
|
try:
|
||||||
|
stripe_sub = stripe.Subscription.retrieve(session.subscription)
|
||||||
|
period_start = datetime.fromtimestamp(
|
||||||
|
stripe_sub.current_period_start, tz=timezone.utc
|
||||||
|
)
|
||||||
|
period_end = datetime.fromtimestamp(
|
||||||
|
stripe_sub.current_period_end, tz=timezone.utc
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not retrieve subscription period: {e}")
|
||||||
|
|
||||||
|
# Create VendorAddOn record
|
||||||
|
vendor_addon = VendorAddOn(
|
||||||
|
vendor_id=vendor_id,
|
||||||
|
addon_product_id=addon_product.id,
|
||||||
|
status="active",
|
||||||
|
domain_name=domain_name,
|
||||||
|
quantity=1, # Default quantity, could be from session line items
|
||||||
|
stripe_subscription_item_id=stripe_subscription_item_id,
|
||||||
|
period_start=period_start,
|
||||||
|
period_end=period_end,
|
||||||
|
)
|
||||||
|
db.add(vendor_addon)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Add-on '{addon_code}' purchased by vendor {vendor_id}"
|
||||||
|
+ (f" for domain {domain_name}" if domain_name else "")
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"action": "addon_created",
|
||||||
|
"vendor_id": vendor_id,
|
||||||
|
"addon_code": addon_code,
|
||||||
|
"addon_id": vendor_addon.id,
|
||||||
|
"domain_name": domain_name,
|
||||||
|
}
|
||||||
|
|
||||||
def _handle_subscription_created(
|
def _handle_subscription_created(
|
||||||
self, db: Session, event: stripe.Event
|
self, db: Session, event: stripe.Event
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -240,7 +365,11 @@ class StripeWebhookHandler:
|
|||||||
def _handle_subscription_deleted(
|
def _handle_subscription_deleted(
|
||||||
self, db: Session, event: stripe.Event
|
self, db: Session, event: stripe.Event
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Handle customer.subscription.deleted event."""
|
"""
|
||||||
|
Handle customer.subscription.deleted event.
|
||||||
|
|
||||||
|
Cancels the subscription and all associated add-ons.
|
||||||
|
"""
|
||||||
stripe_sub = event.data.object
|
stripe_sub = event.data.object
|
||||||
|
|
||||||
subscription = (
|
subscription = (
|
||||||
@@ -253,11 +382,37 @@ class StripeWebhookHandler:
|
|||||||
logger.warning(f"No subscription found for {stripe_sub.id}")
|
logger.warning(f"No subscription found for {stripe_sub.id}")
|
||||||
return {"action": "skipped", "reason": "no subscription"}
|
return {"action": "skipped", "reason": "no subscription"}
|
||||||
|
|
||||||
|
vendor_id = subscription.vendor_id
|
||||||
|
|
||||||
|
# Cancel the subscription
|
||||||
subscription.status = SubscriptionStatus.CANCELLED
|
subscription.status = SubscriptionStatus.CANCELLED
|
||||||
subscription.cancelled_at = datetime.now(timezone.utc)
|
subscription.cancelled_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
logger.info(f"Subscription deleted for vendor {subscription.vendor_id}")
|
# Also cancel all active add-ons for this vendor
|
||||||
return {"action": "cancelled", "vendor_id": subscription.vendor_id}
|
cancelled_addons = (
|
||||||
|
db.query(VendorAddOn)
|
||||||
|
.filter(
|
||||||
|
VendorAddOn.vendor_id == vendor_id,
|
||||||
|
VendorAddOn.status == "active",
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
addon_count = 0
|
||||||
|
for addon in cancelled_addons:
|
||||||
|
addon.status = "cancelled"
|
||||||
|
addon.cancelled_at = datetime.now(timezone.utc)
|
||||||
|
addon_count += 1
|
||||||
|
|
||||||
|
if addon_count > 0:
|
||||||
|
logger.info(f"Cancelled {addon_count} add-ons for vendor {vendor_id}")
|
||||||
|
|
||||||
|
logger.info(f"Subscription deleted for vendor {vendor_id}")
|
||||||
|
return {
|
||||||
|
"action": "cancelled",
|
||||||
|
"vendor_id": vendor_id,
|
||||||
|
"addons_cancelled": addon_count,
|
||||||
|
}
|
||||||
|
|
||||||
def _handle_invoice_paid(self, db: Session, event: stripe.Event) -> dict:
|
def _handle_invoice_paid(self, db: Session, event: stripe.Event) -> dict:
|
||||||
"""Handle invoice.paid event."""
|
"""Handle invoice.paid event."""
|
||||||
|
|||||||
@@ -1286,3 +1286,27 @@ async def admin_platform_health(
|
|||||||
"user": current_user,
|
"user": current_user,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# FEATURE MANAGEMENT ROUTES
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/features", response_class=HTMLResponse, include_in_schema=False)
|
||||||
|
async def admin_features_page(
|
||||||
|
request: Request,
|
||||||
|
current_user: User = Depends(get_current_admin_from_cookie_or_header),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Render feature management page.
|
||||||
|
Shows all features with tier assignments and allows editing.
|
||||||
|
"""
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"admin/features.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"user": current_user,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
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) -->
|
<!-- 5. FIFTH: API Client (depends on Utils) -->
|
||||||
<script src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
|
<script src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
|
||||||
|
|
||||||
<!-- 6. SIXTH: Alpine.js v3 with CDN fallback (with defer) -->
|
<!-- 6. SIXTH: Feature Store (depends on API Client, registers with Alpine) -->
|
||||||
|
<script src="{{ url_for('static', path='shared/js/feature-store.js') }}"></script>
|
||||||
|
|
||||||
|
<!-- 7. SEVENTH: Upgrade Prompts (depends on API Client, registers with Alpine) -->
|
||||||
|
<script src="{{ url_for('static', path='shared/js/upgrade-prompts.js') }}"></script>
|
||||||
|
|
||||||
|
<!-- 8. EIGHTH: Alpine.js v3 with CDN fallback (with defer) -->
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
var script = document.createElement('script');
|
var script = document.createElement('script');
|
||||||
@@ -81,7 +87,7 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- 7. LAST: Page-specific scripts -->
|
<!-- 9. LAST: Page-specific scripts -->
|
||||||
{% block extra_scripts %}{% endblock %}
|
{% block extra_scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
30
app/templates/vendor/dashboard.html
vendored
30
app/templates/vendor/dashboard.html
vendored
@@ -1,16 +1,24 @@
|
|||||||
{# app/templates/vendor/dashboard.html #}
|
{# app/templates/vendor/dashboard.html #}
|
||||||
{% extends "vendor/base.html" %}
|
{% extends "vendor/base.html" %}
|
||||||
|
{% from "shared/macros/feature_gate.html" import limit_warning, usage_bar, upgrade_card, tier_badge %}
|
||||||
|
|
||||||
{% block title %}Dashboard{% endblock %}
|
{% block title %}Dashboard{% endblock %}
|
||||||
|
|
||||||
{% block alpine_data %}vendorDashboard(){% endblock %}
|
{% block alpine_data %}vendorDashboard(){% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<!-- Limit Warnings -->
|
||||||
|
{{ limit_warning("orders") }}
|
||||||
|
{{ limit_warning("products") }}
|
||||||
|
|
||||||
<!-- Page Header with Refresh Button -->
|
<!-- Page Header with Refresh Button -->
|
||||||
<div class="flex items-center justify-between my-6">
|
<div class="flex items-center justify-between my-6">
|
||||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
<div class="flex items-center gap-3">
|
||||||
Dashboard
|
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||||
</h2>
|
Dashboard
|
||||||
|
</h2>
|
||||||
|
{{ tier_badge() }}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="refresh()"
|
@click="refresh()"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
@@ -40,6 +48,9 @@
|
|||||||
<!-- Vendor Info Card -->
|
<!-- Vendor Info Card -->
|
||||||
{% include 'vendor/partials/vendor_info.html' %}
|
{% include 'vendor/partials/vendor_info.html' %}
|
||||||
|
|
||||||
|
<!-- Upgrade Recommendation Card (shows when approaching/at limits) -->
|
||||||
|
{{ upgrade_card(class='mb-6') }}
|
||||||
|
|
||||||
<!-- Stats Cards -->
|
<!-- Stats Cards -->
|
||||||
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
|
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<!-- Card: Total Products -->
|
<!-- Card: Total Products -->
|
||||||
@@ -103,6 +114,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Usage Overview -->
|
||||||
|
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-3">
|
||||||
|
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||||
|
{{ usage_bar("orders", "Monthly Orders") }}
|
||||||
|
</div>
|
||||||
|
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||||
|
{{ usage_bar("products", "Products") }}
|
||||||
|
</div>
|
||||||
|
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||||
|
{{ usage_bar("team_members", "Team Members") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Recent Orders Table -->
|
<!-- Recent Orders Table -->
|
||||||
<div x-show="!loading && recentOrders.length > 0" class="w-full mb-8 overflow-hidden rounded-lg shadow-xs">
|
<div x-show="!loading && recentOrders.length > 0" class="w-full mb-8 overflow-hidden rounded-lg shadow-xs">
|
||||||
<div class="w-full overflow-x-auto">
|
<div class="w-full overflow-x-auto">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from .company import Company
|
|||||||
from .content_page import ContentPage
|
from .content_page import ContentPage
|
||||||
from .customer import Customer, CustomerAddress
|
from .customer import Customer, CustomerAddress
|
||||||
from .email import EmailCategory, EmailLog, EmailStatus, EmailTemplate
|
from .email import EmailCategory, EmailLog, EmailStatus, EmailTemplate
|
||||||
|
from .feature import Feature, FeatureCategory, FeatureCode, FeatureUILocation
|
||||||
from .inventory import Inventory
|
from .inventory import Inventory
|
||||||
from .invoice import (
|
from .invoice import (
|
||||||
Invoice,
|
Invoice,
|
||||||
@@ -108,6 +109,11 @@ __all__ = [
|
|||||||
"EmailLog",
|
"EmailLog",
|
||||||
"EmailStatus",
|
"EmailStatus",
|
||||||
"EmailTemplate",
|
"EmailTemplate",
|
||||||
|
# Features
|
||||||
|
"Feature",
|
||||||
|
"FeatureCategory",
|
||||||
|
"FeatureCode",
|
||||||
|
"FeatureUILocation",
|
||||||
# Product - Enums
|
# Product - Enums
|
||||||
"ProductType",
|
"ProductType",
|
||||||
"DigitalDeliveryMethod",
|
"DigitalDeliveryMethod",
|
||||||
|
|||||||
191
models/database/feature.py
Normal file
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