# app/modules/billing/routes/api/admin_features.py """ Admin feature management endpoints (provider-based system). Provides endpoints for: - Browsing the discovered feature catalog from module providers - Managing per-tier feature limits (TierFeatureLimit) - Managing per-merchant feature overrides (MerchantFeatureOverride) All routes require module access control for the 'billing' module. """ import logging from fastapi import APIRouter, Depends, Path from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api, require_module_access from app.core.database import get_db from app.modules.billing.exceptions import InvalidFeatureCodesError from app.modules.billing.schemas import ( FeatureCatalogResponse, FeatureDeclarationResponse, MerchantFeatureOverrideEntry, MerchantFeatureOverrideResponse, TierFeatureLimitEntry, ) from app.modules.billing.services.feature_aggregator import feature_aggregator from app.modules.billing.services.feature_service import feature_service from app.modules.enums import FrontendType from app.modules.tenancy.schemas.auth import UserContext admin_features_router = APIRouter( prefix="/features", dependencies=[Depends(require_module_access("billing", FrontendType.ADMIN))], ) logger = logging.getLogger(__name__) def _declaration_to_response(decl) -> FeatureDeclarationResponse: """Convert a FeatureDeclaration dataclass to its Pydantic response schema.""" return FeatureDeclarationResponse( code=decl.code, name_key=decl.name_key, description_key=decl.description_key, category=decl.category, feature_type=decl.feature_type.value, scope=decl.scope.value, default_limit=decl.default_limit, unit_key=decl.unit_key, is_per_period=decl.is_per_period, ui_icon=decl.ui_icon, display_order=decl.display_order, ) # ============================================================================ # Feature Catalog Endpoints # ============================================================================ @admin_features_router.get("/catalog", response_model=FeatureCatalogResponse) def get_feature_catalog( current_user: UserContext = Depends(get_current_admin_api), ): """ Return all discovered features from module providers, grouped by category. Features are declared by modules via FeatureProviderProtocol and aggregated at startup. This endpoint does not require a database query. """ by_category = feature_aggregator.get_declarations_by_category() features: dict[str, list[FeatureDeclarationResponse]] = {} total_count = 0 for category, declarations in by_category.items(): features[category] = [_declaration_to_response(d) for d in declarations] total_count += len(declarations) return FeatureCatalogResponse(features=features, total_count=total_count) # ============================================================================ # Tier Feature Limit Endpoints # ============================================================================ @admin_features_router.get( "/tiers/{tier_id}/limits", response_model=list[TierFeatureLimitEntry], ) def get_tier_feature_limits( tier_id: int = Path(..., description="Tier ID"), current_user: UserContext = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """ Get the feature limits configured for a specific tier. Returns all TierFeatureLimit rows associated with the tier, each containing a feature_code and its optional limit_value. """ rows = feature_service.get_tier_feature_limits(db, tier_id) return [ TierFeatureLimitEntry( feature_code=row.feature_code, limit_value=row.limit_value, enabled=True, ) for row in rows ] @admin_features_router.put( "/tiers/{tier_id}/limits", response_model=list[TierFeatureLimitEntry], ) def upsert_tier_feature_limits( entries: list[TierFeatureLimitEntry], tier_id: int = Path(..., description="Tier ID"), current_user: UserContext = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """ Replace the feature limits for a tier. Deletes all existing TierFeatureLimit rows for this tier and inserts the provided entries. Only entries with enabled=True are persisted (disabled entries are simply omitted). """ # Validate feature codes against the catalog submitted_codes = {e.feature_code for e in entries} invalid_codes = feature_aggregator.validate_feature_codes(submitted_codes) if invalid_codes: raise InvalidFeatureCodesError(invalid_codes) new_rows = feature_service.upsert_tier_feature_limits( db, tier_id, [e.model_dump() for e in entries] ) db.commit() logger.info( "Admin %s replaced tier %d feature limits (%d entries)", current_user.id, tier_id, len(new_rows), ) return [ TierFeatureLimitEntry( feature_code=row.feature_code, limit_value=row.limit_value, enabled=True, ) for row in new_rows ] # ============================================================================ # Merchant Feature Override Endpoints # ============================================================================ @admin_features_router.get( "/merchants/{merchant_id}/overrides", response_model=list[MerchantFeatureOverrideResponse], ) def get_merchant_feature_overrides( merchant_id: int = Path(..., description="Merchant ID"), current_user: UserContext = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """ Get all feature overrides for a specific merchant. Returns MerchantFeatureOverride rows that allow per-merchant exceptions to the default tier limits (e.g. granting extra products). """ rows = feature_service.get_merchant_overrides(db, merchant_id) return [MerchantFeatureOverrideResponse.model_validate(row) for row in rows] @admin_features_router.put( "/merchants/{merchant_id}/overrides", response_model=list[MerchantFeatureOverrideResponse], ) def upsert_merchant_feature_overrides( entries: list[MerchantFeatureOverrideEntry], merchant_id: int = Path(..., description="Merchant ID"), current_user: UserContext = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """ Set feature overrides for a merchant. Upserts MerchantFeatureOverride rows: if an override already exists for the (merchant_id, platform_id, feature_code) triple, it is updated; otherwise a new row is created. The platform_id is derived from the admin's current platform context. """ from app.exceptions import ValidationException platform_id = current_user.token_platform_id if not platform_id: raise ValidationException( message="Platform context required. Select a platform first.", ) # Validate feature codes against the catalog submitted_codes = {e.feature_code for e in entries} invalid_codes = feature_aggregator.validate_feature_codes(submitted_codes) if invalid_codes: raise InvalidFeatureCodesError(invalid_codes) results = feature_service.upsert_merchant_overrides( db, merchant_id, platform_id, [e.model_dump() for e in entries] ) db.commit() # Refresh to populate server-generated fields (id, timestamps) for row in results: db.refresh(row) logger.info( "Admin %s upserted %d feature overrides for merchant %d on platform %d", current_user.id, len(results), merchant_id, platform_id, ) return [MerchantFeatureOverrideResponse.model_validate(row) for row in results]