Some checks failed
- 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>
228 lines
6.9 KiB
Python
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",
|
|
]
|