# app/modules/billing/dependencies/feature_gate.py """ Feature gating decorator and dependencies for tier-based access control. Resolves store → merchant → subscription → tier → TierFeatureLimit. Provides: - @require_feature decorator for endpoints - RequireFeature dependency for flexible usage - RequireWithinLimit dependency for quantitative checks - FeatureNotAvailableError exception with upgrade info Usage: # As decorator (simple) @router.get("/analytics") @require_feature("analytics_dashboard") def get_analytics(...): ... # As dependency (more control) @router.get("/analytics") def get_analytics( _: None = Depends(RequireFeature("analytics_dashboard")), ... ): ... # Quantitative limit check @router.post("/products") def create_product( _: None = Depends(RequireWithinLimit("products_limit")), ... ): ... """ import asyncio import functools import logging from collections.abc import Callable from fastapi import Depends, HTTPException from sqlalchemy.orm import Session from app.api.deps import get_current_store_api from app.core.database import get_db from app.modules.billing.services.feature_service import feature_service from app.modules.tenancy.models import User logger = logging.getLogger(__name__) class FeatureNotAvailableError(HTTPException): """ Exception raised when a feature is not available for the merchant'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": "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 store's merchant has access to a feature. Resolves store → merchant → subscription → tier → TierFeatureLimit. 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_store_api), db: Session = Depends(get_db), ) -> None: """Check if store's merchant has access to any of the required features.""" store_id = current_user.token_store_id for feature_code in self.feature_codes: if feature_service.has_feature_for_store(db, store_id, feature_code): return # None of the features are available feature_code = self.feature_codes[0] raise FeatureNotAvailableError(feature_code=feature_code) class RequireWithinLimit: """ Dependency that checks a quantitative resource limit. Resolves store → merchant → subscription → tier → TierFeatureLimit, then checks current usage against the limit. Args: feature_code: The quantitative feature to check (e.g., "products_limit") """ def __init__(self, feature_code: str): self.feature_code = feature_code def __call__( self, current_user: User = Depends(get_current_store_api), db: Session = Depends(get_db), ) -> None: """Check if the resource limit allows adding more items.""" store_id = current_user.token_store_id allowed, message = feature_service.check_resource_limit( db, self.feature_code, store_id=store_id ) if not allowed: raise HTTPException( status_code=403, detail={ "error": "limit_exceeded", "message": message, "feature_code": self.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 if the store's merchant doesn't have access to ANY of the specified features. Args: *feature_codes: One or more feature codes. Access granted if ANY is available. """ 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): 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", ) store_id = current_user.token_store_id for feature_code in feature_codes: if feature_service.has_feature_for_store(db, store_id, feature_code): return await func(*args, **kwargs) raise FeatureNotAvailableError(feature_code=feature_codes[0]) @functools.wraps(func) def sync_wrapper(*args, **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", ) store_id = current_user.token_store_id for feature_code in feature_codes: if feature_service.has_feature_for_store(db, store_id, feature_code): return func(*args, **kwargs) raise FeatureNotAvailableError(feature_code=feature_codes[0]) if asyncio.iscoroutinefunction(func): return async_wrapper return sync_wrapper return decorator __all__ = [ "require_feature", "RequireFeature", "RequireWithinLimit", "FeatureNotAvailableError", ]