diff --git a/alembic/versions/n2c3d4e5f6a7_add_features_table.py b/alembic/versions/n2c3d4e5f6a7_add_features_table.py new file mode 100644 index 00000000..b4fec6ae --- /dev/null +++ b/alembic/versions/n2c3d4e5f6a7_add_features_table.py @@ -0,0 +1,292 @@ +"""add features table and seed data + +Revision ID: n2c3d4e5f6a7 +Revises: ba2c0ce78396 +Create Date: 2025-12-31 10:00:00.000000 + +""" + +import json +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "n2c3d4e5f6a7" +down_revision: Union[str, None] = "ba2c0ce78396" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +# ============================================================================ +# Feature Definitions +# ============================================================================ +# category, code, name, description, ui_location, ui_icon, ui_route, display_order +FEATURES = [ + # Orders (category: orders) + ("orders", "order_management", "Order Management", "View and manage orders", "sidebar", "clipboard-list", "/vendor/{code}/orders", 1), + ("orders", "order_bulk_actions", "Bulk Order Actions", "Process multiple orders at once", "inline", None, None, 2), + ("orders", "order_export", "Order Export", "Export orders to CSV/Excel", "inline", "download", None, 3), + ("orders", "automation_rules", "Automation Rules", "Automatic order processing rules", "sidebar", "cog", "/vendor/{code}/automation", 4), + + # Inventory (category: inventory) + ("inventory", "inventory_basic", "Basic Inventory", "Track product stock levels", "sidebar", "cube", "/vendor/{code}/inventory", 1), + ("inventory", "inventory_locations", "Warehouse Locations", "Manage multiple warehouse locations", "inline", "map-pin", None, 2), + ("inventory", "inventory_purchase_orders", "Purchase Orders", "Create and manage purchase orders", "sidebar", "shopping-cart", "/vendor/{code}/purchase-orders", 3), + ("inventory", "low_stock_alerts", "Low Stock Alerts", "Get notified when stock is low", "inline", "bell", None, 4), + + # Analytics (category: analytics) + ("analytics", "basic_reports", "Basic Reports", "Essential sales and order reports", "sidebar", "chart-pie", "/vendor/{code}/reports", 1), + ("analytics", "analytics_dashboard", "Analytics Dashboard", "Advanced analytics with charts and trends", "sidebar", "chart-bar", "/vendor/{code}/analytics", 2), + ("analytics", "custom_reports", "Custom Reports", "Build custom report configurations", "inline", "document-report", None, 3), + ("analytics", "export_reports", "Export Reports", "Export reports to various formats", "inline", "download", None, 4), + + # Invoicing (category: invoicing) + ("invoicing", "invoice_lu", "Luxembourg Invoicing", "Generate compliant Luxembourg invoices", "sidebar", "document-text", "/vendor/{code}/invoices", 1), + ("invoicing", "invoice_eu_vat", "EU VAT Support", "Handle EU VAT for cross-border sales", "inline", "globe", None, 2), + ("invoicing", "invoice_bulk", "Bulk Invoicing", "Generate invoices in bulk", "inline", "document-duplicate", None, 3), + ("invoicing", "accounting_export", "Accounting Export", "Export to accounting software formats", "inline", "calculator", None, 4), + + # Integrations (category: integrations) + ("integrations", "letzshop_sync", "Letzshop Sync", "Sync orders and products with Letzshop", "settings", "refresh", None, 1), + ("integrations", "api_access", "API Access", "REST API access for custom integrations", "settings", "code", "/vendor/{code}/settings/api", 2), + ("integrations", "webhooks", "Webhooks", "Receive real-time event notifications", "settings", "lightning-bolt", "/vendor/{code}/settings/webhooks", 3), + ("integrations", "custom_integrations", "Custom Integrations", "Connect with any third-party service", "settings", "puzzle", None, 4), + + # Team (category: team) + ("team", "single_user", "Single User", "One user account", "api", None, None, 1), + ("team", "team_basic", "Team Access", "Invite team members", "sidebar", "users", "/vendor/{code}/team", 2), + ("team", "team_roles", "Team Roles", "Role-based permissions for team members", "inline", "shield-check", None, 3), + ("team", "audit_log", "Audit Log", "Track all user actions", "sidebar", "clipboard-check", "/vendor/{code}/audit-log", 4), + + # Branding (category: branding) + ("branding", "basic_shop", "Basic Shop", "Your shop on the platform", "api", None, None, 1), + ("branding", "custom_domain", "Custom Domain", "Use your own domain name", "settings", "globe-alt", None, 2), + ("branding", "white_label", "White Label", "Remove platform branding entirely", "settings", "color-swatch", None, 3), + + # Customers (category: customers) + ("customers", "customer_view", "Customer View", "View customer information", "sidebar", "user-group", "/vendor/{code}/customers", 1), + ("customers", "customer_export", "Customer Export", "Export customer data", "inline", "download", None, 2), + ("customers", "customer_messaging", "Customer Messaging", "Send messages to customers", "inline", "chat", None, 3), +] + +# ============================================================================ +# Tier Feature Assignments +# ============================================================================ +# tier_code -> list of feature codes +TIER_FEATURES = { + "essential": [ + "order_management", + "inventory_basic", + "basic_reports", + "invoice_lu", + "letzshop_sync", + "single_user", + "basic_shop", + "customer_view", + ], + "professional": [ + # All Essential features + "order_management", + "order_bulk_actions", + "order_export", + "inventory_basic", + "inventory_locations", + "inventory_purchase_orders", + "low_stock_alerts", + "basic_reports", + "invoice_lu", + "invoice_eu_vat", + "letzshop_sync", + "team_basic", + "basic_shop", + "customer_view", + "customer_export", + ], + "business": [ + # All Professional features + "order_management", + "order_bulk_actions", + "order_export", + "automation_rules", + "inventory_basic", + "inventory_locations", + "inventory_purchase_orders", + "low_stock_alerts", + "basic_reports", + "analytics_dashboard", + "custom_reports", + "export_reports", + "invoice_lu", + "invoice_eu_vat", + "invoice_bulk", + "accounting_export", + "letzshop_sync", + "api_access", + "webhooks", + "team_basic", + "team_roles", + "audit_log", + "basic_shop", + "custom_domain", + "customer_view", + "customer_export", + "customer_messaging", + ], + "enterprise": [ + # All features + "order_management", + "order_bulk_actions", + "order_export", + "automation_rules", + "inventory_basic", + "inventory_locations", + "inventory_purchase_orders", + "low_stock_alerts", + "basic_reports", + "analytics_dashboard", + "custom_reports", + "export_reports", + "invoice_lu", + "invoice_eu_vat", + "invoice_bulk", + "accounting_export", + "letzshop_sync", + "api_access", + "webhooks", + "custom_integrations", + "team_basic", + "team_roles", + "audit_log", + "basic_shop", + "custom_domain", + "white_label", + "customer_view", + "customer_export", + "customer_messaging", + ], +} + +# Minimum tier for each feature (for upgrade prompts) +# Maps feature_code -> tier_code +MINIMUM_TIER = { + # Essential + "order_management": "essential", + "inventory_basic": "essential", + "basic_reports": "essential", + "invoice_lu": "essential", + "letzshop_sync": "essential", + "single_user": "essential", + "basic_shop": "essential", + "customer_view": "essential", + # Professional + "order_bulk_actions": "professional", + "order_export": "professional", + "inventory_locations": "professional", + "inventory_purchase_orders": "professional", + "low_stock_alerts": "professional", + "invoice_eu_vat": "professional", + "team_basic": "professional", + "customer_export": "professional", + # Business + "automation_rules": "business", + "analytics_dashboard": "business", + "custom_reports": "business", + "export_reports": "business", + "invoice_bulk": "business", + "accounting_export": "business", + "api_access": "business", + "webhooks": "business", + "team_roles": "business", + "audit_log": "business", + "custom_domain": "business", + "customer_messaging": "business", + # Enterprise + "custom_integrations": "enterprise", + "white_label": "enterprise", +} + + +def upgrade() -> None: + # Create features table + op.create_table( + "features", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("code", sa.String(50), nullable=False), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("category", sa.String(50), nullable=False), + sa.Column("ui_location", sa.String(50), nullable=True), + sa.Column("ui_icon", sa.String(50), nullable=True), + sa.Column("ui_route", sa.String(100), nullable=True), + sa.Column("ui_badge_text", sa.String(20), nullable=True), + sa.Column("minimum_tier_id", sa.Integer(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, default=True), + sa.Column("is_visible", sa.Boolean(), nullable=False, default=True), + sa.Column("display_order", sa.Integer(), nullable=False, default=0), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["minimum_tier_id"], ["subscription_tiers.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_features_code", "features", ["code"], unique=True) + op.create_index("ix_features_category", "features", ["category"], unique=False) + op.create_index("idx_feature_category_order", "features", ["category", "display_order"]) + op.create_index("idx_feature_active_visible", "features", ["is_active", "is_visible"]) + + # Get connection for data operations + conn = op.get_bind() + + # Get tier IDs + tier_ids = {} + result = conn.execute(sa.text("SELECT id, code FROM subscription_tiers")) + for row in result: + tier_ids[row[1]] = row[0] + + # Insert features + now = sa.func.now() + for category, code, name, description, ui_location, ui_icon, ui_route, display_order in FEATURES: + minimum_tier_code = MINIMUM_TIER.get(code) + minimum_tier_id = tier_ids.get(minimum_tier_code) if minimum_tier_code else None + + conn.execute( + sa.text(""" + INSERT INTO features (code, name, description, category, ui_location, ui_icon, ui_route, + minimum_tier_id, is_active, is_visible, display_order, created_at, updated_at) + VALUES (:code, :name, :description, :category, :ui_location, :ui_icon, :ui_route, + :minimum_tier_id, 1, 1, :display_order, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """), + { + "code": code, + "name": name, + "description": description, + "category": category, + "ui_location": ui_location, + "ui_icon": ui_icon, + "ui_route": ui_route, + "minimum_tier_id": minimum_tier_id, + "display_order": display_order, + }, + ) + + # Update subscription_tiers with feature arrays + for tier_code, features in TIER_FEATURES.items(): + features_json = json.dumps(features) + conn.execute( + sa.text("UPDATE subscription_tiers SET features = :features WHERE code = :code"), + {"features": features_json, "code": tier_code}, + ) + + +def downgrade() -> None: + # Clear features from subscription_tiers + conn = op.get_bind() + conn.execute(sa.text("UPDATE subscription_tiers SET features = '[]'")) + + # Drop features table + op.drop_index("idx_feature_active_visible", table_name="features") + op.drop_index("idx_feature_category_order", table_name="features") + op.drop_index("ix_features_category", table_name="features") + op.drop_index("ix_features_code", table_name="features") + op.drop_table("features") diff --git a/app/api/v1/admin/__init__.py b/app/api/v1/admin/__init__.py index 4e9b14ac..93fbcb97 100644 --- a/app/api/v1/admin/__init__.py +++ b/app/api/v1/admin/__init__.py @@ -33,6 +33,7 @@ from . import ( content_pages, customers, dashboard, + features, images, inventory, letzshop, @@ -181,6 +182,9 @@ router.include_router( # Include subscription management endpoints router.include_router(subscriptions.router, tags=["admin-subscriptions"]) +# Include feature management endpoints +router.include_router(features.router, tags=["admin-features"]) + # ============================================================================ # Code Quality & Architecture diff --git a/app/api/v1/admin/features.py b/app/api/v1/admin/features.py new file mode 100644 index 00000000..7598edff --- /dev/null +++ b/app/api/v1/admin/features.py @@ -0,0 +1,357 @@ +# app/api/v1/admin/features.py +""" +Admin feature management endpoints. + +Provides endpoints for: +- Listing all features with their tier assignments +- Updating tier feature assignments +- Managing feature metadata +- Viewing feature usage statistics +""" + +import logging + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.api.deps import get_current_admin_api +from app.core.database import get_db +from app.services.feature_service import feature_service +from models.database.feature import Feature +from models.database.subscription import SubscriptionTier +from models.database.user import User + +router = APIRouter(prefix="/features") +logger = logging.getLogger(__name__) + + +# ============================================================================ +# Response Schemas +# ============================================================================ + + +class FeatureResponse(BaseModel): + """Feature information for admin.""" + + id: int + code: str + name: str + description: str | None = None + category: str + ui_location: str | None = None + ui_icon: str | None = None + ui_route: str | None = None + ui_badge_text: str | None = None + minimum_tier_id: int | None = None + minimum_tier_code: str | None = None + minimum_tier_name: str | None = None + is_active: bool + is_visible: bool + display_order: int + + +class FeatureListResponse(BaseModel): + """List of features.""" + + features: list[FeatureResponse] + total: int + + +class TierFeaturesResponse(BaseModel): + """Tier with its features.""" + + id: int + code: str + name: str + description: str | None = None + features: list[str] + feature_count: int + + +class TierListWithFeaturesResponse(BaseModel): + """All tiers with their features.""" + + tiers: list[TierFeaturesResponse] + + +class UpdateTierFeaturesRequest(BaseModel): + """Request to update tier features.""" + + feature_codes: list[str] + + +class UpdateFeatureRequest(BaseModel): + """Request to update feature metadata.""" + + name: str | None = None + description: str | None = None + category: str | None = None + ui_location: str | None = None + ui_icon: str | None = None + ui_route: str | None = None + ui_badge_text: str | None = None + minimum_tier_code: str | None = None + is_active: bool | None = None + is_visible: bool | None = None + display_order: int | None = None + + +class CategoryListResponse(BaseModel): + """List of feature categories.""" + + categories: list[str] + + +# ============================================================================ +# Endpoints +# ============================================================================ + + +@router.get("", response_model=FeatureListResponse) +def list_features( + category: str | None = Query(None, description="Filter by category"), + active_only: bool = Query(False, description="Only active features"), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """List all features with their tier assignments.""" + features = feature_service.get_all_features( + db, category=category, active_only=active_only + ) + + return FeatureListResponse( + features=[ + FeatureResponse( + id=f.id, + code=f.code, + name=f.name, + description=f.description, + category=f.category, + ui_location=f.ui_location, + ui_icon=f.ui_icon, + ui_route=f.ui_route, + ui_badge_text=f.ui_badge_text, + minimum_tier_id=f.minimum_tier_id, + minimum_tier_code=f.minimum_tier.code if f.minimum_tier else None, + minimum_tier_name=f.minimum_tier.name if f.minimum_tier else None, + is_active=f.is_active, + is_visible=f.is_visible, + display_order=f.display_order, + ) + for f in features + ], + total=len(features), + ) + + +@router.get("/categories", response_model=CategoryListResponse) +def list_categories( + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """List all feature categories.""" + categories = feature_service.get_categories(db) + return CategoryListResponse(categories=categories) + + +@router.get("/tiers", response_model=TierListWithFeaturesResponse) +def list_tiers_with_features( + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """List all tiers with their feature assignments.""" + tiers = ( + db.query(SubscriptionTier) + .filter(SubscriptionTier.is_active == True) # noqa: E712 + .order_by(SubscriptionTier.display_order) + .all() + ) + + return TierListWithFeaturesResponse( + tiers=[ + TierFeaturesResponse( + id=t.id, + code=t.code, + name=t.name, + description=t.description, + features=t.features or [], + feature_count=len(t.features or []), + ) + for t in tiers + ] + ) + + +@router.get("/{feature_code}", response_model=FeatureResponse) +def get_feature( + feature_code: str, + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Get a single feature by code.""" + feature = feature_service.get_feature_by_code(db, feature_code) + + if not feature: + raise HTTPException(status_code=404, detail=f"Feature '{feature_code}' not found") + + return FeatureResponse( + id=feature.id, + code=feature.code, + name=feature.name, + description=feature.description, + category=feature.category, + ui_location=feature.ui_location, + ui_icon=feature.ui_icon, + ui_route=feature.ui_route, + ui_badge_text=feature.ui_badge_text, + minimum_tier_id=feature.minimum_tier_id, + minimum_tier_code=feature.minimum_tier.code if feature.minimum_tier else None, + minimum_tier_name=feature.minimum_tier.name if feature.minimum_tier else None, + is_active=feature.is_active, + is_visible=feature.is_visible, + display_order=feature.display_order, + ) + + +@router.put("/{feature_code}", response_model=FeatureResponse) +def update_feature( + feature_code: str, + request: UpdateFeatureRequest, + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Update feature metadata.""" + feature = db.query(Feature).filter(Feature.code == feature_code).first() + + if not feature: + raise HTTPException(status_code=404, detail=f"Feature '{feature_code}' not found") + + # Update fields if provided + if request.name is not None: + feature.name = request.name + if request.description is not None: + feature.description = request.description + if request.category is not None: + feature.category = request.category + if request.ui_location is not None: + feature.ui_location = request.ui_location + if request.ui_icon is not None: + feature.ui_icon = request.ui_icon + if request.ui_route is not None: + feature.ui_route = request.ui_route + if request.ui_badge_text is not None: + feature.ui_badge_text = request.ui_badge_text + if request.is_active is not None: + feature.is_active = request.is_active + if request.is_visible is not None: + feature.is_visible = request.is_visible + if request.display_order is not None: + feature.display_order = request.display_order + + # Update minimum tier if provided + if request.minimum_tier_code is not None: + if request.minimum_tier_code == "": + feature.minimum_tier_id = None + else: + tier = ( + db.query(SubscriptionTier) + .filter(SubscriptionTier.code == request.minimum_tier_code) + .first() + ) + if not tier: + raise HTTPException( + status_code=400, + detail=f"Tier '{request.minimum_tier_code}' not found", + ) + feature.minimum_tier_id = tier.id + + db.commit() + db.refresh(feature) + + logger.info(f"Updated feature {feature_code} by admin {current_user.id}") + + return FeatureResponse( + id=feature.id, + code=feature.code, + name=feature.name, + description=feature.description, + category=feature.category, + ui_location=feature.ui_location, + ui_icon=feature.ui_icon, + ui_route=feature.ui_route, + ui_badge_text=feature.ui_badge_text, + minimum_tier_id=feature.minimum_tier_id, + minimum_tier_code=feature.minimum_tier.code if feature.minimum_tier else None, + minimum_tier_name=feature.minimum_tier.name if feature.minimum_tier else None, + is_active=feature.is_active, + is_visible=feature.is_visible, + display_order=feature.display_order, + ) + + +@router.put("/tiers/{tier_code}/features", response_model=TierFeaturesResponse) +def update_tier_features( + tier_code: str, + request: UpdateTierFeaturesRequest, + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Update features assigned to a tier.""" + try: + tier = feature_service.update_tier_features(db, tier_code, request.feature_codes) + db.commit() + + logger.info( + f"Updated tier {tier_code} features to {len(request.feature_codes)} features " + f"by admin {current_user.id}" + ) + + return TierFeaturesResponse( + id=tier.id, + code=tier.code, + name=tier.name, + description=tier.description, + features=tier.features or [], + feature_count=len(tier.features or []), + ) + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/tiers/{tier_code}/features") +def get_tier_features( + tier_code: str, + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Get features assigned to a specific tier.""" + tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first() + + if not tier: + raise HTTPException(status_code=404, detail=f"Tier '{tier_code}' not found") + + # Get full feature details for the tier's features + feature_codes = tier.features or [] + features = ( + db.query(Feature) + .filter(Feature.code.in_(feature_codes)) + .order_by(Feature.category, Feature.display_order) + .all() + ) + + return { + "tier_code": tier.code, + "tier_name": tier.name, + "features": [ + { + "code": f.code, + "name": f.name, + "category": f.category, + "description": f.description, + } + for f in features + ], + "feature_count": len(features), + } diff --git a/app/api/v1/vendor/__init__.py b/app/api/v1/vendor/__init__.py index 7d981ea0..7f297622 100644 --- a/app/api/v1/vendor/__init__.py +++ b/app/api/v1/vendor/__init__.py @@ -20,6 +20,7 @@ from . import ( content_pages, customers, dashboard, + features, info, inventory, invoices, @@ -36,6 +37,7 @@ from . import ( profile, settings, team, + usage, ) # Create vendor router @@ -77,6 +79,8 @@ router.include_router(notifications.router, tags=["vendor-notifications"]) router.include_router(messages.router, tags=["vendor-messages"]) router.include_router(analytics.router, tags=["vendor-analytics"]) router.include_router(billing.router, tags=["vendor-billing"]) +router.include_router(features.router, tags=["vendor-features"]) +router.include_router(usage.router, tags=["vendor-usage"]) # Content pages management router.include_router( diff --git a/app/api/v1/vendor/analytics.py b/app/api/v1/vendor/analytics.py index 99e42602..b3b9ca76 100644 --- a/app/api/v1/vendor/analytics.py +++ b/app/api/v1/vendor/analytics.py @@ -4,6 +4,10 @@ Vendor analytics and reporting endpoints. Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern). The get_current_vendor_api dependency guarantees token_vendor_id is present. + +Feature Requirements: +- basic_reports: Basic analytics (Essential tier) +- analytics_dashboard: Advanced analytics (Business tier) """ import logging @@ -13,7 +17,9 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api from app.core.database import get_db +from app.core.feature_gate import RequireFeature from app.services.stats_service import stats_service +from models.database.feature import FeatureCode from models.database.user import User from models.schema.stats import ( VendorAnalyticsCatalog, @@ -31,6 +37,7 @@ def get_vendor_analytics( period: str = Query("30d", description="Time period: 7d, 30d, 90d, 1y"), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), + _: None = Depends(RequireFeature(FeatureCode.BASIC_REPORTS, FeatureCode.ANALYTICS_DASHBOARD)), ): """Get vendor analytics data for specified time period.""" data = stats_service.get_vendor_analytics(db, current_user.token_vendor_id, period) diff --git a/app/api/v1/vendor/features.py b/app/api/v1/vendor/features.py new file mode 100644 index 00000000..8d1c9473 --- /dev/null +++ b/app/api/v1/vendor/features.py @@ -0,0 +1,340 @@ +# app/api/v1/vendor/features.py +""" +Vendor features API endpoints. + +Provides feature availability information for the frontend to: +- Show/hide UI elements based on tier +- Display upgrade prompts for unavailable features +- Load feature metadata for dynamic rendering + +Endpoints: +- GET /features/available - List of feature codes (for quick checks) +- GET /features - Full feature list with availability and metadata +- GET /features/{code} - Single feature details with upgrade info +- GET /features/categories - List feature categories +""" + +import logging + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.api.deps import get_current_vendor_api +from app.core.database import get_db +from app.services.feature_service import feature_service +from models.database.user import User + +router = APIRouter(prefix="/features") +logger = logging.getLogger(__name__) + + +# ============================================================================ +# Response Schemas +# ============================================================================ + + +class FeatureCodeListResponse(BaseModel): + """Simple list of available feature codes for quick checks.""" + + features: list[str] + tier_code: str + tier_name: str + + +class FeatureResponse(BaseModel): + """Full feature information.""" + + code: str + name: str + description: str | None = None + category: str + ui_location: str | None = None + ui_icon: str | None = None + ui_route: str | None = None + ui_badge_text: str | None = None + is_available: bool + minimum_tier_code: str | None = None + minimum_tier_name: str | None = None + + +class FeatureListResponse(BaseModel): + """List of features with metadata.""" + + features: list[FeatureResponse] + available_count: int + total_count: int + tier_code: str + tier_name: str + + +class FeatureDetailResponse(BaseModel): + """Single feature detail with upgrade info.""" + + code: str + name: str + description: str | None = None + category: str + ui_location: str | None = None + ui_icon: str | None = None + ui_route: str | None = None + is_available: bool + # Upgrade info (only if not available) + upgrade_tier_code: str | None = None + upgrade_tier_name: str | None = None + upgrade_tier_price_monthly_cents: int | None = None + + +class CategoryListResponse(BaseModel): + """List of feature categories.""" + + categories: list[str] + + +class FeatureGroupedResponse(BaseModel): + """Features grouped by category.""" + + categories: dict[str, list[FeatureResponse]] + available_count: int + total_count: int + + +# ============================================================================ +# Endpoints +# ============================================================================ + + +@router.get("/available", response_model=FeatureCodeListResponse) +def get_available_features( + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Get list of feature codes available to vendor. + + This is a lightweight endpoint for quick feature checks. + Use this to populate a frontend feature store on app init. + + Returns: + List of feature codes the vendor has access to + """ + vendor_id = current_user.token_vendor_id + + # Get subscription for tier info + from app.services.subscription_service import subscription_service + + subscription = subscription_service.get_or_create_subscription(db, vendor_id) + tier = subscription.tier_obj + + # Get available features + feature_codes = feature_service.get_available_feature_codes(db, vendor_id) + + return FeatureCodeListResponse( + features=feature_codes, + tier_code=subscription.tier, + tier_name=tier.name if tier else subscription.tier.title(), + ) + + +@router.get("", response_model=FeatureListResponse) +def get_features( + category: str | None = Query(None, description="Filter by category"), + include_unavailable: bool = Query(True, description="Include features not available to vendor"), + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Get all features with availability status and metadata. + + This is a comprehensive endpoint for building feature-gated UIs. + Each feature includes: + - Availability status + - UI metadata (icon, route, location) + - Minimum tier required + + Args: + category: Filter to specific category (orders, inventory, etc.) + include_unavailable: Whether to include locked features + + Returns: + List of features with metadata and availability + """ + vendor_id = current_user.token_vendor_id + + # Get subscription for tier info + from app.services.subscription_service import subscription_service + + subscription = subscription_service.get_or_create_subscription(db, vendor_id) + tier = subscription.tier_obj + + # Get features + features = feature_service.get_vendor_features( + db, + vendor_id, + category=category, + include_unavailable=include_unavailable, + ) + + available_count = sum(1 for f in features if f.is_available) + + return FeatureListResponse( + features=[ + FeatureResponse( + code=f.code, + name=f.name, + description=f.description, + category=f.category, + ui_location=f.ui_location, + ui_icon=f.ui_icon, + ui_route=f.ui_route, + ui_badge_text=f.ui_badge_text, + is_available=f.is_available, + minimum_tier_code=f.minimum_tier_code, + minimum_tier_name=f.minimum_tier_name, + ) + for f in features + ], + available_count=available_count, + total_count=len(features), + tier_code=subscription.tier, + tier_name=tier.name if tier else subscription.tier.title(), + ) + + +@router.get("/categories", response_model=CategoryListResponse) +def get_feature_categories( + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Get list of feature categories. + + Returns: + List of category names + """ + categories = feature_service.get_categories(db) + return CategoryListResponse(categories=categories) + + +@router.get("/grouped", response_model=FeatureGroupedResponse) +def get_features_grouped( + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Get features grouped by category. + + Useful for rendering feature comparison tables or settings pages. + """ + vendor_id = current_user.token_vendor_id + + grouped = feature_service.get_features_grouped_by_category(db, vendor_id) + + # Convert to response format + categories_response = {} + total = 0 + available = 0 + + for category, features in grouped.items(): + categories_response[category] = [ + FeatureResponse( + code=f.code, + name=f.name, + description=f.description, + category=f.category, + ui_location=f.ui_location, + ui_icon=f.ui_icon, + ui_route=f.ui_route, + ui_badge_text=f.ui_badge_text, + is_available=f.is_available, + minimum_tier_code=f.minimum_tier_code, + minimum_tier_name=f.minimum_tier_name, + ) + for f in features + ] + total += len(features) + available += sum(1 for f in features if f.is_available) + + return FeatureGroupedResponse( + categories=categories_response, + available_count=available, + total_count=total, + ) + + +@router.get("/{feature_code}", response_model=FeatureDetailResponse) +def get_feature_detail( + feature_code: str, + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Get detailed information about a specific feature. + + Includes upgrade information if the feature is not available. + Use this for upgrade prompts and feature explanation modals. + + Args: + feature_code: The feature code + + Returns: + Feature details with upgrade info if locked + """ + vendor_id = current_user.token_vendor_id + + # Get feature + feature = feature_service.get_feature_by_code(db, feature_code) + if not feature: + raise HTTPException(status_code=404, detail=f"Feature '{feature_code}' not found") + + # Check availability + is_available = feature_service.has_feature(db, vendor_id, feature_code) + + # Get upgrade info if not available + upgrade_tier_code = None + upgrade_tier_name = None + upgrade_tier_price = None + + if not is_available: + upgrade_info = feature_service.get_feature_upgrade_info(db, feature_code) + if upgrade_info: + upgrade_tier_code = upgrade_info.required_tier_code + upgrade_tier_name = upgrade_info.required_tier_name + upgrade_tier_price = upgrade_info.required_tier_price_monthly_cents + + return FeatureDetailResponse( + code=feature.code, + name=feature.name, + description=feature.description, + category=feature.category, + ui_location=feature.ui_location, + ui_icon=feature.ui_icon, + ui_route=feature.ui_route, + is_available=is_available, + upgrade_tier_code=upgrade_tier_code, + upgrade_tier_name=upgrade_tier_name, + upgrade_tier_price_monthly_cents=upgrade_tier_price, + ) + + +@router.get("/check/{feature_code}") +def check_feature( + feature_code: str, + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Quick check if vendor has access to a feature. + + Returns simple boolean response for inline checks. + + Args: + feature_code: The feature code + + Returns: + {"has_feature": true/false} + """ + vendor_id = current_user.token_vendor_id + has_feature = feature_service.has_feature(db, vendor_id, feature_code) + + return {"has_feature": has_feature, "feature_code": feature_code} diff --git a/app/api/v1/vendor/invoices.py b/app/api/v1/vendor/invoices.py index 2ae448a1..9c2bd728 100644 --- a/app/api/v1/vendor/invoices.py +++ b/app/api/v1/vendor/invoices.py @@ -16,6 +16,12 @@ Endpoints: - POST /invoices/settings - Create invoice settings - PUT /invoices/settings - Update invoice settings - GET /invoices/stats - Get invoice statistics + +Feature Requirements: +- invoice_lu: Basic Luxembourg invoicing (Essential tier) +- invoice_eu_vat: EU VAT support (Professional tier) +- invoice_bulk: Bulk invoicing (Business tier) +- accounting_export: Export to accounting software (Business tier) """ import logging @@ -27,6 +33,7 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api from app.core.database import get_db +from app.core.feature_gate import RequireFeature from app.exceptions.invoice import ( InvoiceNotFoundException, InvoicePDFGenerationException, @@ -34,6 +41,7 @@ from app.exceptions.invoice import ( InvoiceSettingsNotFoundException, ) from app.services.invoice_service import invoice_service +from models.database.feature import FeatureCode from models.database.user import User from models.schema.invoice import ( InvoiceCreate, @@ -61,11 +69,13 @@ logger = logging.getLogger(__name__) def get_invoice_settings( current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), + _: None = Depends(RequireFeature(FeatureCode.INVOICE_LU)), ): """ Get vendor invoice settings. Returns null if settings not yet configured. + Requires: invoice_lu feature (Essential tier) """ settings = invoice_service.get_settings(db, current_user.token_vendor_id) if settings: diff --git a/app/api/v1/vendor/usage.py b/app/api/v1/vendor/usage.py new file mode 100644 index 00000000..21e4df4d --- /dev/null +++ b/app/api/v1/vendor/usage.py @@ -0,0 +1,380 @@ +# app/api/v1/vendor/usage.py +""" +Vendor usage and limits API endpoints. + +Provides endpoints for: +- Current usage vs limits +- Upgrade recommendations +- Approaching limit warnings +""" + +import logging + +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.api.deps import get_current_vendor_api +from app.core.database import get_db +from app.services.subscription_service import subscription_service +from models.database.product import Product +from models.database.subscription import SubscriptionTier +from models.database.user import User +from models.database.vendor import VendorUser + +router = APIRouter(prefix="/usage") +logger = logging.getLogger(__name__) + + +# ============================================================================ +# Response Schemas +# ============================================================================ + + +class UsageMetric(BaseModel): + """Single usage metric.""" + + name: str + current: int + limit: int | None # None = unlimited + percentage: float # 0-100, or 0 if unlimited + is_unlimited: bool + is_at_limit: bool + is_approaching_limit: bool # >= 80% + + +class TierInfo(BaseModel): + """Current tier information.""" + + code: str + name: str + price_monthly_cents: int + is_highest_tier: bool + + +class UpgradeTierInfo(BaseModel): + """Next tier upgrade information.""" + + code: str + name: str + price_monthly_cents: int + price_increase_cents: int + benefits: list[str] + + +class UsageResponse(BaseModel): + """Full usage response with limits and upgrade info.""" + + tier: TierInfo + usage: list[UsageMetric] + has_limits_approaching: bool + has_limits_reached: bool + upgrade_available: bool + upgrade_tier: UpgradeTierInfo | None = None + upgrade_reasons: list[str] + + +class LimitCheckResponse(BaseModel): + """Response for checking a specific limit.""" + + limit_type: str + can_proceed: bool + current: int + limit: int | None + percentage: float + message: str | None = None + upgrade_tier_code: str | None = None + upgrade_tier_name: str | None = None + + +# ============================================================================ +# Endpoints +# ============================================================================ + + +@router.get("", response_model=UsageResponse) +def get_usage( + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Get current usage, limits, and upgrade recommendations. + + Returns comprehensive usage info for displaying in dashboard + and determining when to show upgrade prompts. + """ + vendor_id = current_user.token_vendor_id + + # Get subscription + subscription = subscription_service.get_or_create_subscription(db, vendor_id) + + # Get current tier + tier = subscription.tier_obj + if not tier: + tier = ( + db.query(SubscriptionTier) + .filter(SubscriptionTier.code == subscription.tier) + .first() + ) + + # Calculate usage metrics + usage_metrics = [] + + # Orders this period + orders_current = subscription.orders_this_period or 0 + orders_limit = subscription.orders_limit + orders_unlimited = orders_limit is None or orders_limit < 0 + orders_percentage = 0 if orders_unlimited else (orders_current / orders_limit * 100 if orders_limit > 0 else 100) + + usage_metrics.append( + UsageMetric( + name="orders", + current=orders_current, + limit=None if orders_unlimited else orders_limit, + percentage=orders_percentage, + is_unlimited=orders_unlimited, + is_at_limit=not orders_unlimited and orders_current >= orders_limit, + is_approaching_limit=not orders_unlimited and orders_percentage >= 80, + ) + ) + + # Products + products_count = ( + db.query(func.count(Product.id)) + .filter(Product.vendor_id == vendor_id) + .scalar() + or 0 + ) + products_limit = subscription.products_limit + products_unlimited = products_limit is None or products_limit < 0 + products_percentage = 0 if products_unlimited else (products_count / products_limit * 100 if products_limit > 0 else 100) + + usage_metrics.append( + UsageMetric( + name="products", + current=products_count, + limit=None if products_unlimited else products_limit, + percentage=products_percentage, + is_unlimited=products_unlimited, + is_at_limit=not products_unlimited and products_count >= products_limit, + is_approaching_limit=not products_unlimited and products_percentage >= 80, + ) + ) + + # Team members + team_count = ( + db.query(func.count(VendorUser.id)) + .filter(VendorUser.vendor_id == vendor_id, VendorUser.is_active == True) # noqa: E712 + .scalar() + or 0 + ) + team_limit = subscription.team_members_limit + team_unlimited = team_limit is None or team_limit < 0 + team_percentage = 0 if team_unlimited else (team_count / team_limit * 100 if team_limit > 0 else 100) + + usage_metrics.append( + UsageMetric( + name="team_members", + current=team_count, + limit=None if team_unlimited else team_limit, + percentage=team_percentage, + is_unlimited=team_unlimited, + is_at_limit=not team_unlimited and team_count >= team_limit, + is_approaching_limit=not team_unlimited and team_percentage >= 80, + ) + ) + + # Check for approaching/reached limits + has_limits_approaching = any(m.is_approaching_limit for m in usage_metrics) + has_limits_reached = any(m.is_at_limit for m in usage_metrics) + + # Get next tier for upgrade + all_tiers = ( + db.query(SubscriptionTier) + .filter(SubscriptionTier.is_active == True) # noqa: E712 + .order_by(SubscriptionTier.display_order) + .all() + ) + + current_tier_order = tier.display_order if tier else 0 + next_tier = None + for t in all_tiers: + if t.display_order > current_tier_order: + next_tier = t + break + + is_highest_tier = next_tier is None + + # Build upgrade info + upgrade_tier_info = None + upgrade_reasons = [] + + if next_tier: + # Calculate benefits + benefits = [] + if next_tier.orders_per_month and (not tier or (tier.orders_per_month and next_tier.orders_per_month > tier.orders_per_month)): + if next_tier.orders_per_month < 0: + benefits.append("Unlimited orders per month") + else: + benefits.append(f"{next_tier.orders_per_month:,} orders/month") + + if next_tier.products_limit and (not tier or (tier.products_limit and next_tier.products_limit > tier.products_limit)): + if next_tier.products_limit < 0: + benefits.append("Unlimited products") + else: + benefits.append(f"{next_tier.products_limit:,} products") + + if next_tier.team_members and (not tier or (tier.team_members and next_tier.team_members > tier.team_members)): + if next_tier.team_members < 0: + benefits.append("Unlimited team members") + else: + benefits.append(f"{next_tier.team_members} team members") + + # Add feature benefits + current_features = set(tier.features) if tier and tier.features else set() + next_features = set(next_tier.features) if next_tier.features else set() + new_features = next_features - current_features + + feature_names = { + "analytics_dashboard": "Advanced Analytics", + "api_access": "API Access", + "automation_rules": "Automation Rules", + "team_roles": "Team Roles & Permissions", + "custom_domain": "Custom Domain", + "webhooks": "Webhooks", + "accounting_export": "Accounting Export", + } + for feature in list(new_features)[:3]: # Show top 3 + if feature in feature_names: + benefits.append(feature_names[feature]) + + current_price = tier.price_monthly_cents if tier else 0 + upgrade_tier_info = UpgradeTierInfo( + code=next_tier.code, + name=next_tier.name, + price_monthly_cents=next_tier.price_monthly_cents, + price_increase_cents=next_tier.price_monthly_cents - current_price, + benefits=benefits, + ) + + # Build upgrade reasons + if has_limits_reached: + for m in usage_metrics: + if m.is_at_limit: + upgrade_reasons.append(f"You've reached your {m.name.replace('_', ' ')} limit") + elif has_limits_approaching: + for m in usage_metrics: + if m.is_approaching_limit: + upgrade_reasons.append(f"You're approaching your {m.name.replace('_', ' ')} limit ({int(m.percentage)}%)") + + return UsageResponse( + tier=TierInfo( + code=tier.code if tier else subscription.tier, + name=tier.name if tier else subscription.tier.title(), + price_monthly_cents=tier.price_monthly_cents if tier else 0, + is_highest_tier=is_highest_tier, + ), + usage=usage_metrics, + has_limits_approaching=has_limits_approaching, + has_limits_reached=has_limits_reached, + upgrade_available=not is_highest_tier, + upgrade_tier=upgrade_tier_info, + upgrade_reasons=upgrade_reasons, + ) + + +@router.get("/check/{limit_type}", response_model=LimitCheckResponse) +def check_limit( + limit_type: str, + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Check a specific limit before performing an action. + + Use this before creating orders, products, or inviting team members. + + Args: + limit_type: One of "orders", "products", "team_members" + + Returns: + Whether the action can proceed and upgrade info if not + """ + vendor_id = current_user.token_vendor_id + + if limit_type == "orders": + can_proceed, message = subscription_service.can_create_order(db, vendor_id) + subscription = subscription_service.get_subscription(db, vendor_id) + current = subscription.orders_this_period if subscription else 0 + limit = subscription.orders_limit if subscription else 0 + + elif limit_type == "products": + can_proceed, message = subscription_service.can_add_product(db, vendor_id) + subscription = subscription_service.get_subscription(db, vendor_id) + current = ( + db.query(func.count(Product.id)) + .filter(Product.vendor_id == vendor_id) + .scalar() + or 0 + ) + limit = subscription.products_limit if subscription else 0 + + elif limit_type == "team_members": + can_proceed, message = subscription_service.can_add_team_member(db, vendor_id) + subscription = subscription_service.get_subscription(db, vendor_id) + current = ( + db.query(func.count(VendorUser.id)) + .filter(VendorUser.vendor_id == vendor_id, VendorUser.is_active == True) # noqa: E712 + .scalar() + or 0 + ) + limit = subscription.team_members_limit if subscription else 0 + + else: + return LimitCheckResponse( + limit_type=limit_type, + can_proceed=True, + current=0, + limit=None, + percentage=0, + message=f"Unknown limit type: {limit_type}", + ) + + # Calculate percentage + is_unlimited = limit is None or limit < 0 + percentage = 0 if is_unlimited else (current / limit * 100 if limit > 0 else 100) + + # Get upgrade info if at limit + upgrade_tier_code = None + upgrade_tier_name = None + + if not can_proceed: + subscription = subscription_service.get_subscription(db, vendor_id) + current_tier = subscription.tier_obj if subscription else None + + if current_tier: + next_tier = ( + db.query(SubscriptionTier) + .filter( + SubscriptionTier.is_active == True, # noqa: E712 + SubscriptionTier.display_order > current_tier.display_order, + ) + .order_by(SubscriptionTier.display_order) + .first() + ) + + if next_tier: + upgrade_tier_code = next_tier.code + upgrade_tier_name = next_tier.name + + return LimitCheckResponse( + limit_type=limit_type, + can_proceed=can_proceed, + current=current, + limit=None if is_unlimited else limit, + percentage=percentage, + message=message, + upgrade_tier_code=upgrade_tier_code, + upgrade_tier_name=upgrade_tier_name, + ) diff --git a/app/core/feature_gate.py b/app/core/feature_gate.py new file mode 100644 index 00000000..98b034fd --- /dev/null +++ b/app/core/feature_gate.py @@ -0,0 +1,254 @@ +# app/core/feature_gate.py +""" +Feature gating decorator and dependencies for tier-based access control. + +Provides: +- @require_feature decorator for endpoints +- RequireFeature dependency for flexible usage +- FeatureNotAvailableError exception with upgrade info + +Usage: + # As decorator (simple) + @router.get("/analytics") + @require_feature(FeatureCode.ANALYTICS_DASHBOARD) + def get_analytics(...): + ... + + # As dependency (more control) + @router.get("/analytics") + def get_analytics( + _: None = Depends(RequireFeature(FeatureCode.ANALYTICS_DASHBOARD)), + ... + ): + ... + + # Multiple features (any one required) + @require_feature(FeatureCode.ANALYTICS_DASHBOARD, FeatureCode.BASIC_REPORTS) + def get_reports(...): + ... +""" + +import functools +import logging +from typing import Callable + +from fastapi import Depends, HTTPException, Request +from sqlalchemy.orm import Session + +from app.api.deps import get_current_vendor_api +from app.core.database import get_db +from app.services.feature_service import feature_service +from models.database.feature import FeatureCode +from models.database.user import User + +logger = logging.getLogger(__name__) + + +class FeatureNotAvailableError(HTTPException): + """ + Exception raised when a feature is not available for the vendor's tier. + + Includes upgrade information for the frontend to display. + """ + + def __init__( + self, + feature_code: str, + feature_name: str | None = None, + required_tier_code: str | None = None, + required_tier_name: str | None = None, + required_tier_price_cents: int | None = None, + ): + detail = { + "error": "feature_not_available", + "message": f"This feature requires an upgrade to access.", + "feature_code": feature_code, + "feature_name": feature_name, + "upgrade": { + "tier_code": required_tier_code, + "tier_name": required_tier_name, + "price_monthly_cents": required_tier_price_cents, + } + if required_tier_code + else None, + } + super().__init__(status_code=403, detail=detail) + + +class RequireFeature: + """ + Dependency class that checks if vendor has access to a feature. + + Can be used as a FastAPI dependency: + @router.get("/analytics") + def get_analytics( + _: None = Depends(RequireFeature("analytics_dashboard")), + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), + ): + ... + + Args: + *feature_codes: One or more feature codes. Access granted if ANY is available. + """ + + def __init__(self, *feature_codes: str): + if not feature_codes: + raise ValueError("At least one feature code is required") + self.feature_codes = feature_codes + + def __call__( + self, + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), + ) -> None: + """Check if vendor has access to any of the required features.""" + vendor_id = current_user.token_vendor_id + + # Check if vendor has ANY of the required features + for feature_code in self.feature_codes: + if feature_service.has_feature(db, vendor_id, feature_code): + return None + + # None of the features are available - get upgrade info for first one + feature_code = self.feature_codes[0] + upgrade_info = feature_service.get_feature_upgrade_info(db, feature_code) + + if upgrade_info: + raise FeatureNotAvailableError( + feature_code=feature_code, + feature_name=upgrade_info.feature_name, + required_tier_code=upgrade_info.required_tier_code, + required_tier_name=upgrade_info.required_tier_name, + required_tier_price_cents=upgrade_info.required_tier_price_monthly_cents, + ) + else: + # Feature not found in registry + raise FeatureNotAvailableError(feature_code=feature_code) + + +def require_feature(*feature_codes: str) -> Callable: + """ + Decorator to require one or more features for an endpoint. + + The decorated endpoint will return 403 with upgrade info if the vendor + doesn't have access to ANY of the specified features. + + Args: + *feature_codes: One or more feature codes. Access granted if ANY is available. + + Example: + @router.get("/analytics/dashboard") + @require_feature(FeatureCode.ANALYTICS_DASHBOARD) + async def get_analytics_dashboard( + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), + ): + ... + + # Multiple features (any one is sufficient) + @router.get("/reports") + @require_feature(FeatureCode.ANALYTICS_DASHBOARD, FeatureCode.BASIC_REPORTS) + async def get_reports(...): + ... + """ + if not feature_codes: + raise ValueError("At least one feature code is required") + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + async def async_wrapper(*args, **kwargs): + # Extract dependencies from kwargs + db = kwargs.get("db") + current_user = kwargs.get("current_user") + + if not db or not current_user: + # Try to get from request if not in kwargs + request = kwargs.get("request") + if request and hasattr(request, "state"): + db = getattr(request.state, "db", None) + current_user = getattr(request.state, "user", None) + + if not db or not current_user: + raise HTTPException( + status_code=500, + detail="Feature check failed: missing db or current_user dependency", + ) + + vendor_id = current_user.token_vendor_id + + # Check if vendor has ANY of the required features + for feature_code in feature_codes: + if feature_service.has_feature(db, vendor_id, feature_code): + return await func(*args, **kwargs) + + # None available - raise with upgrade info + feature_code = feature_codes[0] + upgrade_info = feature_service.get_feature_upgrade_info(db, feature_code) + + if upgrade_info: + raise FeatureNotAvailableError( + feature_code=feature_code, + feature_name=upgrade_info.feature_name, + required_tier_code=upgrade_info.required_tier_code, + required_tier_name=upgrade_info.required_tier_name, + required_tier_price_cents=upgrade_info.required_tier_price_monthly_cents, + ) + else: + raise FeatureNotAvailableError(feature_code=feature_code) + + @functools.wraps(func) + def sync_wrapper(*args, **kwargs): + # Extract dependencies from kwargs + db = kwargs.get("db") + current_user = kwargs.get("current_user") + + if not db or not current_user: + raise HTTPException( + status_code=500, + detail="Feature check failed: missing db or current_user dependency", + ) + + vendor_id = current_user.token_vendor_id + + # Check if vendor has ANY of the required features + for feature_code in feature_codes: + if feature_service.has_feature(db, vendor_id, feature_code): + return func(*args, **kwargs) + + # None available - raise with upgrade info + feature_code = feature_codes[0] + upgrade_info = feature_service.get_feature_upgrade_info(db, feature_code) + + if upgrade_info: + raise FeatureNotAvailableError( + feature_code=feature_code, + feature_name=upgrade_info.feature_name, + required_tier_code=upgrade_info.required_tier_code, + required_tier_name=upgrade_info.required_tier_name, + required_tier_price_cents=upgrade_info.required_tier_price_monthly_cents, + ) + else: + raise FeatureNotAvailableError(feature_code=feature_code) + + # Return appropriate wrapper based on whether func is async + import asyncio + + if asyncio.iscoroutinefunction(func): + return async_wrapper + else: + return sync_wrapper + + return decorator + + +# ============================================================================ +# Convenience Exports +# ============================================================================ + +__all__ = [ + "require_feature", + "RequireFeature", + "FeatureNotAvailableError", + "FeatureCode", +] diff --git a/app/handlers/stripe_webhook.py b/app/handlers/stripe_webhook.py index 9ac08638..6a530881 100644 --- a/app/handlers/stripe_webhook.py +++ b/app/handlers/stripe_webhook.py @@ -6,6 +6,7 @@ Processes webhook events from Stripe: - Subscription lifecycle events - Invoice and payment events - Checkout session completion +- Add-on purchases """ import logging @@ -15,10 +16,12 @@ import stripe from sqlalchemy.orm import Session from models.database.subscription import ( + AddOnProduct, BillingHistory, StripeWebhookEvent, SubscriptionStatus, SubscriptionTier, + VendorAddOn, VendorSubscription, ) @@ -108,15 +111,34 @@ class StripeWebhookHandler: def _handle_checkout_completed( self, db: Session, event: stripe.Event ) -> dict: - """Handle checkout.session.completed event.""" + """ + Handle checkout.session.completed event. + + Handles two types of checkouts: + 1. Subscription checkout - Updates VendorSubscription + 2. Add-on checkout - Creates VendorAddOn record + """ session = event.data.object vendor_id = session.metadata.get("vendor_id") + addon_code = session.metadata.get("addon_code") if not vendor_id: logger.warning(f"Checkout session {session.id} missing vendor_id") return {"action": "skipped", "reason": "no vendor_id"} vendor_id = int(vendor_id) + + # Check if this is an add-on purchase + if addon_code: + return self._handle_addon_checkout(db, session, vendor_id, addon_code) + + # Otherwise, handle subscription checkout + return self._handle_subscription_checkout(db, session, vendor_id) + + def _handle_subscription_checkout( + self, db: Session, session, vendor_id: int + ) -> dict: + """Handle subscription checkout completion.""" subscription = ( db.query(VendorSubscription) .filter(VendorSubscription.vendor_id == vendor_id) @@ -147,9 +169,112 @@ class StripeWebhookHandler: stripe_sub.trial_end, tz=timezone.utc ) - logger.info(f"Checkout completed for vendor {vendor_id}") + logger.info(f"Subscription checkout completed for vendor {vendor_id}") return {"action": "activated", "vendor_id": vendor_id} + def _handle_addon_checkout( + self, db: Session, session, vendor_id: int, addon_code: str + ) -> dict: + """ + Handle add-on checkout completion. + + Creates a VendorAddOn record for the purchased add-on. + """ + # Get the add-on product + addon_product = ( + db.query(AddOnProduct) + .filter(AddOnProduct.code == addon_code) + .first() + ) + + if not addon_product: + logger.error(f"Add-on product '{addon_code}' not found") + return {"action": "failed", "reason": f"addon '{addon_code}' not found"} + + # Check if vendor already has this add-on active + existing_addon = ( + db.query(VendorAddOn) + .filter( + VendorAddOn.vendor_id == vendor_id, + VendorAddOn.addon_product_id == addon_product.id, + VendorAddOn.status == "active", + ) + .first() + ) + + if existing_addon: + logger.info( + f"Vendor {vendor_id} already has active add-on {addon_code}, " + f"updating quantity" + ) + # For quantity-based add-ons, we could increment + # For now, just log and return + return { + "action": "already_exists", + "vendor_id": vendor_id, + "addon_code": addon_code, + } + + # Get domain name from metadata (for domain add-ons) + domain_name = session.metadata.get("domain_name") + if domain_name == "": + domain_name = None + + # Get subscription item ID from Stripe subscription + stripe_subscription_item_id = None + if session.subscription: + try: + stripe_sub = stripe.Subscription.retrieve(session.subscription) + if stripe_sub.items.data: + # Find the item matching our add-on price + for item in stripe_sub.items.data: + if item.price.id == addon_product.stripe_price_id: + stripe_subscription_item_id = item.id + break + except Exception as e: + logger.warning(f"Could not retrieve subscription items: {e}") + + # Get period dates from subscription + period_start = None + period_end = None + if session.subscription: + try: + stripe_sub = stripe.Subscription.retrieve(session.subscription) + period_start = datetime.fromtimestamp( + stripe_sub.current_period_start, tz=timezone.utc + ) + period_end = datetime.fromtimestamp( + stripe_sub.current_period_end, tz=timezone.utc + ) + except Exception as e: + logger.warning(f"Could not retrieve subscription period: {e}") + + # Create VendorAddOn record + vendor_addon = VendorAddOn( + vendor_id=vendor_id, + addon_product_id=addon_product.id, + status="active", + domain_name=domain_name, + quantity=1, # Default quantity, could be from session line items + stripe_subscription_item_id=stripe_subscription_item_id, + period_start=period_start, + period_end=period_end, + ) + db.add(vendor_addon) + + logger.info( + f"Add-on '{addon_code}' purchased by vendor {vendor_id}" + + (f" for domain {domain_name}" if domain_name else "") + ) + + return { + "action": "addon_created", + "vendor_id": vendor_id, + "addon_code": addon_code, + "addon_id": vendor_addon.id, + "domain_name": domain_name, + } + def _handle_subscription_created( self, db: Session, event: stripe.Event ) -> dict: @@ -240,7 +365,11 @@ class StripeWebhookHandler: def _handle_subscription_deleted( self, db: Session, event: stripe.Event ) -> dict: - """Handle customer.subscription.deleted event.""" + """ + Handle customer.subscription.deleted event. + + Cancels the subscription and all associated add-ons. + """ stripe_sub = event.data.object subscription = ( @@ -253,11 +382,37 @@ class StripeWebhookHandler: logger.warning(f"No subscription found for {stripe_sub.id}") return {"action": "skipped", "reason": "no subscription"} + vendor_id = subscription.vendor_id + + # Cancel the subscription subscription.status = SubscriptionStatus.CANCELLED subscription.cancelled_at = datetime.now(timezone.utc) - logger.info(f"Subscription deleted for vendor {subscription.vendor_id}") - return {"action": "cancelled", "vendor_id": subscription.vendor_id} + # Also cancel all active add-ons for this vendor + cancelled_addons = ( + db.query(VendorAddOn) + .filter( + VendorAddOn.vendor_id == vendor_id, + VendorAddOn.status == "active", + ) + .all() + ) + + addon_count = 0 + for addon in cancelled_addons: + addon.status = "cancelled" + addon.cancelled_at = datetime.now(timezone.utc) + addon_count += 1 + + if addon_count > 0: + logger.info(f"Cancelled {addon_count} add-ons for vendor {vendor_id}") + + logger.info(f"Subscription deleted for vendor {vendor_id}") + return { + "action": "cancelled", + "vendor_id": vendor_id, + "addons_cancelled": addon_count, + } def _handle_invoice_paid(self, db: Session, event: stripe.Event) -> dict: """Handle invoice.paid event.""" diff --git a/app/routes/admin_pages.py b/app/routes/admin_pages.py index b832e0b9..14cfe84f 100644 --- a/app/routes/admin_pages.py +++ b/app/routes/admin_pages.py @@ -1286,3 +1286,27 @@ async def admin_platform_health( "user": current_user, }, ) + + +# ============================================================================ +# FEATURE MANAGEMENT ROUTES +# ============================================================================ + + +@router.get("/features", response_class=HTMLResponse, include_in_schema=False) +async def admin_features_page( + request: Request, + current_user: User = Depends(get_current_admin_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render feature management page. + Shows all features with tier assignments and allows editing. + """ + return templates.TemplateResponse( + "admin/features.html", + { + "request": request, + "user": current_user, + }, + ) diff --git a/app/services/feature_service.py b/app/services/feature_service.py new file mode 100644 index 00000000..fd72f354 --- /dev/null +++ b/app/services/feature_service.py @@ -0,0 +1,451 @@ +# app/services/feature_service.py +""" +Feature service for tier-based access control. + +Provides: +- Feature availability checking with caching +- Vendor feature listing for API/UI +- Feature metadata for upgrade prompts +- Cache invalidation on subscription changes + +Usage: + from app.services.feature_service import feature_service + + # Check if vendor has feature + if feature_service.has_feature(db, vendor_id, FeatureCode.ANALYTICS_DASHBOARD): + ... + + # Get all features available to vendor + features = feature_service.get_vendor_features(db, vendor_id) + + # Get feature info for upgrade prompt + info = feature_service.get_feature_upgrade_info(db, "analytics_dashboard") +""" + +import logging +import time +from dataclasses import dataclass +from functools import lru_cache + +from sqlalchemy.orm import Session, joinedload + +from models.database.feature import Feature, FeatureCode +from models.database.subscription import SubscriptionTier, VendorSubscription + +logger = logging.getLogger(__name__) + + +@dataclass +class FeatureInfo: + """Feature information for API responses.""" + + code: str + name: str + description: str | None + category: str + ui_location: str | None + ui_icon: str | None + ui_route: str | None + ui_badge_text: str | None + is_available: bool + minimum_tier_code: str | None + minimum_tier_name: str | None + + +@dataclass +class FeatureUpgradeInfo: + """Information for upgrade prompts.""" + + feature_code: str + feature_name: str + feature_description: str | None + required_tier_code: str + required_tier_name: str + required_tier_price_monthly_cents: int + + +class FeatureCache: + """ + In-memory cache for vendor features. + + Caches vendor_id -> set of feature codes with TTL. + Invalidated when subscription changes. + """ + + def __init__(self, ttl_seconds: int = 300): + self._cache: dict[int, tuple[set[str], float]] = {} + self._ttl = ttl_seconds + + def get(self, vendor_id: int) -> set[str] | None: + """Get cached features for vendor, or None if not cached/expired.""" + if vendor_id not in self._cache: + return None + + features, timestamp = self._cache[vendor_id] + if time.time() - timestamp > self._ttl: + del self._cache[vendor_id] + return None + + return features + + def set(self, vendor_id: int, features: set[str]) -> None: + """Cache features for vendor.""" + self._cache[vendor_id] = (features, time.time()) + + def invalidate(self, vendor_id: int) -> None: + """Invalidate cache for vendor.""" + self._cache.pop(vendor_id, None) + + def invalidate_all(self) -> None: + """Invalidate entire cache.""" + self._cache.clear() + + +class FeatureService: + """ + Service for feature-based access control. + + Provides methods to check feature availability and get feature metadata. + Uses in-memory caching with TTL for performance. + """ + + def __init__(self): + self._cache = FeatureCache(ttl_seconds=300) # 5 minute cache + self._feature_registry_cache: dict[str, Feature] | None = None + self._feature_registry_timestamp: float = 0 + + # ========================================================================= + # Feature Availability + # ========================================================================= + + def has_feature(self, db: Session, vendor_id: int, feature_code: str) -> bool: + """ + Check if vendor has access to a specific feature. + + Args: + db: Database session + vendor_id: Vendor ID + feature_code: Feature code (use FeatureCode constants) + + Returns: + True if vendor has access to the feature + """ + vendor_features = self._get_vendor_feature_codes(db, vendor_id) + return feature_code in vendor_features + + def get_vendor_feature_codes(self, db: Session, vendor_id: int) -> set[str]: + """ + Get set of feature codes available to vendor. + + Args: + db: Database session + vendor_id: Vendor ID + + Returns: + Set of feature codes the vendor has access to + """ + return self._get_vendor_feature_codes(db, vendor_id) + + def _get_vendor_feature_codes(self, db: Session, vendor_id: int) -> set[str]: + """Internal method with caching.""" + # Check cache first + cached = self._cache.get(vendor_id) + if cached is not None: + return cached + + # Get subscription with tier relationship + subscription = ( + db.query(VendorSubscription) + .options(joinedload(VendorSubscription.tier_obj)) + .filter(VendorSubscription.vendor_id == vendor_id) + .first() + ) + + if not subscription: + logger.warning(f"No subscription found for vendor {vendor_id}") + return set() + + # Get features from tier + tier = subscription.tier_obj + if tier and tier.features: + features = set(tier.features) + else: + # Fallback: query tier by code + tier = ( + db.query(SubscriptionTier) + .filter(SubscriptionTier.code == subscription.tier) + .first() + ) + features = set(tier.features) if tier and tier.features else set() + + # Cache and return + self._cache.set(vendor_id, features) + return features + + # ========================================================================= + # Feature Listing + # ========================================================================= + + def get_vendor_features( + self, + db: Session, + vendor_id: int, + category: str | None = None, + include_unavailable: bool = True, + ) -> list[FeatureInfo]: + """ + Get all features with availability status for vendor. + + Args: + db: Database session + vendor_id: Vendor ID + category: Optional category filter + include_unavailable: Include features not available to vendor + + Returns: + List of FeatureInfo with is_available flag + """ + vendor_features = self._get_vendor_feature_codes(db, vendor_id) + + # Query all active features + query = db.query(Feature).filter(Feature.is_active == True) # noqa: E712 + + if category: + query = query.filter(Feature.category == category) + + if not include_unavailable: + # Only return features the vendor has + query = query.filter(Feature.code.in_(vendor_features)) + + features = ( + query.options(joinedload(Feature.minimum_tier)) + .order_by(Feature.category, Feature.display_order) + .all() + ) + + result = [] + for feature in features: + result.append( + FeatureInfo( + code=feature.code, + name=feature.name, + description=feature.description, + category=feature.category, + ui_location=feature.ui_location, + ui_icon=feature.ui_icon, + ui_route=feature.ui_route, + ui_badge_text=feature.ui_badge_text, + is_available=feature.code in vendor_features, + minimum_tier_code=feature.minimum_tier.code if feature.minimum_tier else None, + minimum_tier_name=feature.minimum_tier.name if feature.minimum_tier else None, + ) + ) + + return result + + def get_available_feature_codes(self, db: Session, vendor_id: int) -> list[str]: + """ + Get list of feature codes available to vendor (for frontend). + + Simple list for x-feature directive checks. + """ + return list(self._get_vendor_feature_codes(db, vendor_id)) + + # ========================================================================= + # Feature Metadata + # ========================================================================= + + def get_feature_by_code(self, db: Session, feature_code: str) -> Feature | None: + """Get feature by code.""" + return ( + db.query(Feature) + .options(joinedload(Feature.minimum_tier)) + .filter(Feature.code == feature_code) + .first() + ) + + def get_feature_upgrade_info( + self, db: Session, feature_code: str + ) -> FeatureUpgradeInfo | None: + """ + Get upgrade information for a feature. + + Used for upgrade prompts when a feature is not available. + """ + feature = self.get_feature_by_code(db, feature_code) + + if not feature or not feature.minimum_tier: + return None + + tier = feature.minimum_tier + return FeatureUpgradeInfo( + feature_code=feature.code, + feature_name=feature.name, + feature_description=feature.description, + required_tier_code=tier.code, + required_tier_name=tier.name, + required_tier_price_monthly_cents=tier.price_monthly_cents, + ) + + def get_all_features( + self, + db: Session, + category: str | None = None, + active_only: bool = True, + ) -> list[Feature]: + """Get all features (for admin).""" + query = db.query(Feature).options(joinedload(Feature.minimum_tier)) + + if active_only: + query = query.filter(Feature.is_active == True) # noqa: E712 + + if category: + query = query.filter(Feature.category == category) + + return query.order_by(Feature.category, Feature.display_order).all() + + def get_features_by_tier(self, db: Session, tier_code: str) -> list[str]: + """Get feature codes for a specific tier.""" + tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first() + + if not tier or not tier.features: + return [] + + return tier.features + + # ========================================================================= + # Feature Categories + # ========================================================================= + + def get_categories(self, db: Session) -> list[str]: + """Get all unique feature categories.""" + result = ( + db.query(Feature.category) + .filter(Feature.is_active == True) # noqa: E712 + .distinct() + .order_by(Feature.category) + .all() + ) + return [row[0] for row in result] + + def get_features_grouped_by_category( + self, db: Session, vendor_id: int + ) -> dict[str, list[FeatureInfo]]: + """Get features grouped by category with availability.""" + features = self.get_vendor_features(db, vendor_id, include_unavailable=True) + + grouped: dict[str, list[FeatureInfo]] = {} + for feature in features: + if feature.category not in grouped: + grouped[feature.category] = [] + grouped[feature.category].append(feature) + + return grouped + + # ========================================================================= + # Cache Management + # ========================================================================= + + def invalidate_vendor_cache(self, vendor_id: int) -> None: + """ + Invalidate cache for a specific vendor. + + Call this when: + - Vendor's subscription tier changes + - Tier features are updated (for all vendors on that tier) + """ + self._cache.invalidate(vendor_id) + logger.debug(f"Invalidated feature cache for vendor {vendor_id}") + + def invalidate_all_cache(self) -> None: + """ + Invalidate entire cache. + + Call this when tier features are modified in admin. + """ + self._cache.invalidate_all() + logger.debug("Invalidated all feature caches") + + # ========================================================================= + # Admin Operations + # ========================================================================= + + def update_tier_features( + self, db: Session, tier_code: str, feature_codes: list[str] + ) -> SubscriptionTier: + """ + Update features for a tier (admin operation). + + Args: + db: Database session + tier_code: Tier code + feature_codes: List of feature codes to assign + + Returns: + Updated tier + """ + tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first() + + if not tier: + raise ValueError(f"Tier '{tier_code}' not found") + + # Validate feature codes exist + valid_codes = { + f.code for f in db.query(Feature.code).filter(Feature.is_active == True).all() # noqa: E712 + } + invalid = set(feature_codes) - valid_codes + if invalid: + raise ValueError(f"Invalid feature codes: {invalid}") + + tier.features = feature_codes + + # Invalidate all caches since tier features changed + self.invalidate_all_cache() + + logger.info(f"Updated features for tier {tier_code}: {len(feature_codes)} features") + return tier + + def update_feature_minimum_tier( + self, db: Session, feature_code: str, tier_code: str | None + ) -> Feature: + """ + Update minimum tier for a feature (for upgrade prompts). + + Args: + db: Database session + feature_code: Feature code + tier_code: Tier code or None + """ + feature = db.query(Feature).filter(Feature.code == feature_code).first() + + if not feature: + raise ValueError(f"Feature '{feature_code}' not found") + + if tier_code: + tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first() + if not tier: + raise ValueError(f"Tier '{tier_code}' not found") + feature.minimum_tier_id = tier.id + else: + feature.minimum_tier_id = None + + logger.info(f"Updated minimum tier for feature {feature_code}: {tier_code}") + return feature + + +# Singleton instance +feature_service = FeatureService() + + +# ============================================================================ +# Convenience Exports +# ============================================================================ +# Re-export FeatureCode for easy imports + +__all__ = [ + "feature_service", + "FeatureService", + "FeatureInfo", + "FeatureUpgradeInfo", + "FeatureCode", +] diff --git a/app/templates/admin/features.html b/app/templates/admin/features.html new file mode 100644 index 00000000..31f8cb7a --- /dev/null +++ b/app/templates/admin/features.html @@ -0,0 +1,370 @@ +{% extends "admin/base.html" %} + +{% block title %}Feature Management{% endblock %} + +{% block alpine_data %}featuresPage(){% endblock %} + +{% block content %} +
+ Configure which features are available to each subscription tier. +
++ Complete list of all platform features with their minimum tier requirement. +
+| + Feature + | ++ Category + | ++ Minimum Tier + | ++ Status + | +
|---|---|---|---|
| + + + | ++ + | ++ + | ++ + Active + + + Inactive + + | +
+ {% if description %} + {{ description }} + {% else %} + + This feature requires a plan upgrade. + + {% endif %} +
+ + {# Tier badge #} ++ Available on + higher tier + plan +
+ + {% if show_upgrade_button %} + {# Upgrade button #} + + + Upgrade Plan + + {% endif %} ++ {% if message %} + {{ message }} + {% else %} + + Upgrade to unlock this feature + + {% endif %} +
+