# app/modules/billing/routes/api/store_features.py """ Store 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 - GET /features/check/{code} - Quick boolean feature check All routes require module access control for the 'billing' module. """ import logging from fastapi import APIRouter, Depends, Query from sqlalchemy.orm import Session from app.api.deps import get_current_store_api, require_module_access from app.core.database import get_db from app.modules.billing.exceptions import FeatureNotFoundException from app.modules.billing.schemas.billing import ( CategoryListResponse, FeatureCodeListResponse, FeatureDetailResponse, FeatureGroupedResponse, FeatureListResponse, FeatureResponse, StoreFeatureCheckResponse, ) from app.modules.billing.services.feature_aggregator import feature_aggregator from app.modules.billing.services.feature_service import feature_service from app.modules.billing.services.subscription_service import subscription_service from app.modules.enums import FrontendType from app.modules.tenancy.schemas.auth import UserContext store_features_router = APIRouter( prefix="/features", dependencies=[Depends(require_module_access("billing", FrontendType.STORE))], ) logger = logging.getLogger(__name__) # ============================================================================ # Internal Helpers # ============================================================================ def _get_tier_info(db: Session, store_id: int) -> tuple[str, str]: """Get (tier_code, tier_name) for a store's subscription.""" sub = subscription_service.get_subscription_for_store(db, store_id) if sub and sub.tier: return sub.tier.code, sub.tier.name return "unknown", "Unknown" def _declaration_to_feature_response( decl, is_available: bool ) -> FeatureResponse: """Map a FeatureDeclaration to a FeatureResponse.""" return FeatureResponse( code=decl.code, name=decl.name_key, description=decl.description_key, category=decl.category, feature_type=decl.feature_type.value if decl.feature_type else None, ui_icon=decl.ui_icon, is_available=is_available, ) # ============================================================================ # Endpoints # ============================================================================ @store_features_router.get("/available", response_model=FeatureCodeListResponse) def get_available_features( current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """ Get list of feature codes available to store. 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 store has access to """ store_id = current_user.token_store_id merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id) # Get available feature codes feature_codes = feature_service.get_merchant_feature_codes(db, merchant_id, platform_id) # Get tier info tier_code, tier_name = _get_tier_info(db, store_id) return FeatureCodeListResponse( features=sorted(feature_codes), tier_code=tier_code, tier_name=tier_name, ) @store_features_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 store"), current_user: UserContext = Depends(get_current_store_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) - Feature type (binary/quantitative) Args: category: Filter to specific category (orders, inventory, etc.) include_unavailable: Whether to include locked features Returns: List of features with metadata and availability """ store_id = current_user.token_store_id merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id) # Get all declarations and available codes all_declarations = feature_aggregator.get_all_declarations() available_codes = feature_service.get_merchant_feature_codes(db, merchant_id, platform_id) # Build feature list features = [] for code, decl in sorted( all_declarations.items(), key=lambda x: (x[1].category, x[1].display_order) ): # Filter by category if specified if category and decl.category != category: continue is_available = code in available_codes # Skip unavailable if not requested if not include_unavailable and not is_available: continue features.append(_declaration_to_feature_response(decl, is_available)) available_count = sum(1 for f in features if f.is_available) # Get tier info tier_code, tier_name = _get_tier_info(db, store_id) return FeatureListResponse( features=features, available_count=available_count, total_count=len(features), tier_code=tier_code, tier_name=tier_name, ) @store_features_router.get("/categories", response_model=CategoryListResponse) def get_feature_categories( current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """ Get list of feature categories. Returns: List of category names """ by_category = feature_aggregator.get_declarations_by_category() return CategoryListResponse(categories=sorted(by_category.keys())) @store_features_router.get("/grouped", response_model=FeatureGroupedResponse) def get_features_grouped( current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """ Get features grouped by category. Useful for rendering feature comparison tables or settings pages. """ store_id = current_user.token_store_id merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id) # Get declarations grouped by category and available codes by_category = feature_aggregator.get_declarations_by_category() available_codes = feature_service.get_merchant_feature_codes(db, merchant_id, platform_id) # Convert to response format categories_response: dict[str, list[FeatureResponse]] = {} total = 0 available = 0 for category, declarations in sorted(by_category.items()): category_features = [] for decl in declarations: is_available = decl.code in available_codes category_features.append( _declaration_to_feature_response(decl, is_available) ) total += 1 if is_available: available += 1 categories_response[category] = category_features return FeatureGroupedResponse( categories=categories_response, available_count=available, total_count=total, ) @store_features_router.get("/check/{feature_code}", response_model=StoreFeatureCheckResponse) def check_feature( feature_code: str, current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """ Quick check if store has access to a feature. Returns simple boolean response for inline checks. Uses has_feature_for_store which resolves store -> merchant internally. Args: feature_code: The feature code Returns: has_feature and feature_code """ store_id = current_user.token_store_id has = feature_service.has_feature_for_store(db, store_id, feature_code) return StoreFeatureCheckResponse(has_feature=has, feature_code=feature_code) @store_features_router.get("/{feature_code}", response_model=FeatureDetailResponse) def get_feature_detail( feature_code: str, current_user: UserContext = Depends(get_current_store_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 """ store_id = current_user.token_store_id merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id) # Get feature declaration decl = feature_aggregator.get_declaration(feature_code) if not decl: raise FeatureNotFoundException(feature_code) # Check availability is_available = feature_service.has_feature(db, merchant_id, platform_id, feature_code) # Build response return FeatureDetailResponse( code=decl.code, name=decl.name_key, description=decl.description_key, category=decl.category, feature_type=decl.feature_type.value if decl.feature_type else None, ui_icon=decl.ui_icon, is_available=is_available, # Upgrade info fields are left as None since the new service # does not provide tier-comparison upgrade suggestions. # This can be extended when upgrade flow is implemented. )