Files
orion/app/modules/billing/dependencies/feature_gate.py
Samir Boulahtit 319900623a
Some checks failed
CI / ruff (push) Successful in 14s
CI / pytest (push) Failing after 50m12s
CI / validate (push) Successful in 25s
CI / dependency-scanning (push) Successful in 32s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
feat: add SQL query tool, platform debug, loyalty settings, and multi-module improvements
- Add admin SQL query tool with saved queries, schema explorer presets,
  and collapsible category sections (dev_tools module)
- Add platform debug tool for admin diagnostics
- Add loyalty settings page with owner-only access control
- Fix loyalty settings owner check (use currentUser instead of window.__userData)
- Replace HTTPException with AuthorizationException in loyalty routes
- Expand loyalty module with PIN service, Apple Wallet, program management
- Improve store login with platform detection and multi-platform support
- Update billing feature gates and subscription services
- Add store platform sync improvements and remove is_primary column
- Add unit tests for loyalty (PIN, points, stamps, program services)
- Update i18n translations across dev_tools locales

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 20:08:07 +01:00

228 lines
6.9 KiB
Python

# 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
platform_id = current_user.token_platform_id
for feature_code in self.feature_codes:
if feature_service.has_feature_for_store(
db, store_id, feature_code, platform_id=platform_id
):
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,
platform_id=current_user.token_platform_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
platform_id = current_user.token_platform_id
for feature_code in feature_codes:
if feature_service.has_feature_for_store(
db, store_id, feature_code, platform_id=platform_id
):
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
platform_id = current_user.token_platform_id
for feature_code in feature_codes:
if feature_service.has_feature_for_store(
db, store_id, feature_code, platform_id=platform_id
):
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",
]