Files
orion/app/modules/billing/routes/api/store_features.py
Samir Boulahtit 7c43d6f4a2 refactor: fix all architecture validator findings (202 → 0)
Eliminate all 103 errors and 96 warnings from the architecture validator:

Phase 1 - Validator rules & YAML:
- Add NAM-001/NAM-002 exceptions for module-scoped router/service files
- Fix API-004 to detect # public comments on decorator lines
- Add module-specific exception bases to EXC-004 valid_bases
- Exclude storefront files from AUTH-004 store context check
- Add SVC-006 exceptions for loyalty service atomic commits
- Fix _get_rule() to search naming_rules and auth_rules categories
- Use plain # CODE comments instead of # noqa: CODE for custom rules

Phase 2 - Billing module (5 route files):
- Move _resolve_store_to_merchant to subscription_service
- Move tier/feature queries to feature_service, admin_subscription_service
- Extract 22 inline Pydantic schemas to billing/schemas/billing.py
- Replace all HTTPException with domain exceptions

Phase 3 - Loyalty module (4 routes + points_service):
- Add 7 domain exceptions (Apple auth, enrollment, device registration)
- Add service methods to card_service, program_service, apple_wallet_service
- Move all db.query() from routes to service layer
- Fix SVC-001: replace HTTPException in points_service with domain exception

Phase 4 - Remaining modules:
- tenancy: move store stats queries to admin_service
- cms: move platform resolution to content_page_service, add NoPlatformSubscriptionException
- messaging: move user/customer lookups to messaging_service
- Add ConfigDict(from_attributes=True) to ContentPageResponse

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 18:49:24 +01:00

296 lines
9.7 KiB
Python

# 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 FeatureNotFoundError
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 models.schema.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 FeatureNotFoundError(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.
)