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>
341 lines
9.9 KiB
Python
341 lines
9.9 KiB
Python
# 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}
|