Files
orion/app/modules/billing/routes/api/store_features.py
Samir Boulahtit 4aa6f76e46
Some checks failed
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / ruff (push) Successful in 10s
refactor(arch): move auth schemas to tenancy module and add cross-module service methods
Move all auth schemas (UserContext, UserLogin, LoginResponse, etc.) from
legacy models/schema/auth.py to app/modules/tenancy/schemas/auth.py per
MOD-019. Update 84 import sites across 14 modules. Legacy file now
re-exports for backwards compatibility.

Add missing tenancy service methods for cross-module consumers:
- merchant_service.get_merchant_by_owner_id()
- merchant_service.get_merchant_count_for_owner()
- admin_service.get_user_by_id() (public, was private-only)
- platform_service.get_active_store_count()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:57:04 +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 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.
)