Files
orion/app/modules/billing/services/feature_service.py
Samir Boulahtit 1db7e8a087 feat(billing): migrate frontend templates to feature provider system
Replace hardcoded subscription fields (orders_limit, products_limit,
team_members_limit) across 5 frontend pages with dynamic feature
provider APIs. Add admin convenience endpoint for store subscription
lookup. Remove legacy stubs (StoreSubscription, FeatureCode, Feature,
TIER_LIMITS, FeatureInfo, FeatureUpgradeInfo) and schema aliases.

Pages updated:
- Admin subscriptions: dynamic feature overrides editor
- Admin tiers: correct feature catalog/limits API URLs
- Store billing: usage metrics from /store/billing/usage
- Merchant subscription detail: tier.feature_limits rendering
- Admin store detail: new GET /admin/subscriptions/store/{id} endpoint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 15:18:16 +01:00

439 lines
15 KiB
Python

# app/modules/billing/services/feature_service.py
"""
Feature-agnostic billing service for merchant-level access control.
Zero knowledge of what features exist. Works with:
- TierFeatureLimit (tier -> feature mappings)
- MerchantFeatureOverride (per-merchant exceptions)
- FeatureAggregatorService (discovers features from modules)
Usage:
from app.modules.billing.services.feature_service import feature_service
# Check if merchant has feature
if feature_service.has_feature(db, merchant_id, platform_id, "analytics_dashboard"):
...
# Check quantitative limit
allowed, msg = feature_service.check_resource_limit(
db, "products_limit", store_id=store_id
)
# Get feature summary for merchant portal
summary = feature_service.get_merchant_features_summary(db, merchant_id, platform_id)
"""
import logging
import time
from dataclasses import dataclass
from sqlalchemy.orm import Session, joinedload
from app.modules.billing.models import (
MerchantFeatureOverride,
MerchantSubscription,
SubscriptionTier,
TierFeatureLimit,
)
from app.modules.contracts.features import FeatureScope, FeatureType
logger = logging.getLogger(__name__)
@dataclass
class FeatureSummary:
"""Summary of a feature for merchant portal display."""
code: str
name_key: str
description_key: str
category: str
feature_type: str # "binary" or "quantitative"
scope: str # "store" or "merchant"
enabled: bool
limit: int | None # For quantitative: effective limit
current: int | None # For quantitative: current usage
remaining: int | None # For quantitative: remaining capacity
percent_used: float | None # For quantitative: usage percentage
is_override: bool # Whether an override is applied
unit_key: str | None
ui_icon: str | None
class FeatureCache:
"""
In-memory cache for merchant features.
Caches (merchant_id, platform_id) -> set of feature codes with TTL.
"""
def __init__(self, ttl_seconds: int = 300):
self._cache: dict[tuple[int, int], tuple[set[str], float]] = {}
self._ttl = ttl_seconds
def get(self, merchant_id: int, platform_id: int) -> set[str] | None:
key = (merchant_id, platform_id)
if key not in self._cache:
return None
features, timestamp = self._cache[key]
if time.time() - timestamp > self._ttl:
del self._cache[key]
return None
return features
def set(self, merchant_id: int, platform_id: int, features: set[str]) -> None:
self._cache[(merchant_id, platform_id)] = (features, time.time())
def invalidate(self, merchant_id: int, platform_id: int) -> None:
self._cache.pop((merchant_id, platform_id), None)
def invalidate_all(self) -> None:
self._cache.clear()
class FeatureService:
"""
Feature-agnostic service for merchant-level billing.
Resolves feature access through:
1. MerchantSubscription -> SubscriptionTier -> TierFeatureLimit
2. MerchantFeatureOverride (admin-set exceptions)
3. FeatureAggregator (for usage counts and declarations)
"""
def __init__(self):
self._cache = FeatureCache(ttl_seconds=300)
# =========================================================================
# Store -> Merchant Resolution
# =========================================================================
def _get_merchant_for_store(self, db: Session, store_id: int) -> tuple[int | None, int | None]:
"""
Resolve store_id to (merchant_id, platform_id).
Returns:
Tuple of (merchant_id, platform_id), either may be None
"""
from app.modules.tenancy.models import Store
store = db.query(Store).filter(Store.id == store_id).first()
if not store:
return None, None
merchant_id = store.merchant_id
# Get platform_id from store's platform association
platform_id = getattr(store, "platform_id", None)
if platform_id is None:
# Try StorePlatform junction
from app.modules.tenancy.models import StorePlatform
sp = (
db.query(StorePlatform.platform_id)
.filter(StorePlatform.store_id == store_id)
.first()
)
platform_id = sp[0] if sp else None
return merchant_id, platform_id
def _get_subscription(
self, db: Session, merchant_id: int, platform_id: int
) -> MerchantSubscription | None:
"""Get merchant subscription for a platform."""
return (
db.query(MerchantSubscription)
.options(joinedload(MerchantSubscription.tier).joinedload(SubscriptionTier.feature_limits))
.filter(
MerchantSubscription.merchant_id == merchant_id,
MerchantSubscription.platform_id == platform_id,
)
.first()
)
# =========================================================================
# Feature Availability
# =========================================================================
def has_feature(
self, db: Session, merchant_id: int, platform_id: int, feature_code: str
) -> bool:
"""
Check if merchant has access to a specific feature.
Checks:
1. MerchantFeatureOverride (force enable/disable)
2. TierFeatureLimit (tier assignment)
Args:
db: Database session
merchant_id: Merchant ID
platform_id: Platform ID
feature_code: Feature code to check
Returns:
True if merchant has access to the feature
"""
# Check override first
override = (
db.query(MerchantFeatureOverride)
.filter(
MerchantFeatureOverride.merchant_id == merchant_id,
MerchantFeatureOverride.platform_id == platform_id,
MerchantFeatureOverride.feature_code == feature_code,
)
.first()
)
if override is not None:
return override.is_enabled
# Check tier assignment
subscription = self._get_subscription(db, merchant_id, platform_id)
if not subscription or not subscription.is_active or not subscription.tier:
return False
return subscription.tier.has_feature(feature_code)
def has_feature_for_store(
self, db: Session, store_id: int, feature_code: str
) -> bool:
"""
Check if a store has access to a feature (resolves store -> merchant).
Convenience method for backwards compatibility.
"""
merchant_id, platform_id = self._get_merchant_for_store(db, store_id)
if merchant_id is None or platform_id is None:
return False
return self.has_feature(db, merchant_id, platform_id, feature_code)
def get_merchant_feature_codes(
self, db: Session, merchant_id: int, platform_id: int
) -> set[str]:
"""Get all feature codes available to merchant on a platform."""
# Check cache
cached = self._cache.get(merchant_id, platform_id)
if cached is not None:
return cached
features: set[str] = set()
# Get tier features
subscription = self._get_subscription(db, merchant_id, platform_id)
if subscription and subscription.is_active and subscription.tier:
features = subscription.tier.get_feature_codes()
# Apply overrides
overrides = (
db.query(MerchantFeatureOverride)
.filter(
MerchantFeatureOverride.merchant_id == merchant_id,
MerchantFeatureOverride.platform_id == platform_id,
)
.all()
)
for override in overrides:
if override.is_enabled:
features.add(override.feature_code)
else:
features.discard(override.feature_code)
self._cache.set(merchant_id, platform_id, features)
return features
# =========================================================================
# Effective Limits
# =========================================================================
def get_effective_limit(
self, db: Session, merchant_id: int, platform_id: int, feature_code: str
) -> int | None:
"""
Get the effective limit for a feature (override or tier default).
Returns:
Limit value, or None for unlimited
"""
# Check override first
override = (
db.query(MerchantFeatureOverride)
.filter(
MerchantFeatureOverride.merchant_id == merchant_id,
MerchantFeatureOverride.platform_id == platform_id,
MerchantFeatureOverride.feature_code == feature_code,
)
.first()
)
if override is not None:
return override.limit_value
# Get from tier
subscription = self._get_subscription(db, merchant_id, platform_id)
if not subscription or not subscription.tier:
return 0 # No subscription = no access
return subscription.tier.get_limit_for_feature(feature_code)
# =========================================================================
# Resource Limit Checks
# =========================================================================
def check_resource_limit(
self,
db: Session,
feature_code: str,
store_id: int | None = None,
merchant_id: int | None = None,
platform_id: int | None = None,
) -> tuple[bool, str | None]:
"""
Check if a resource limit allows adding more items.
Resolves store -> merchant if needed. Gets the declaration to
determine scope, then checks usage against limit.
Args:
db: Database session
feature_code: Feature code (e.g., "products_limit")
store_id: Store ID (if checking per-store)
merchant_id: Merchant ID (if already known)
platform_id: Platform ID (if already known)
Returns:
(allowed, error_message) tuple
"""
from app.modules.billing.services.feature_aggregator import feature_aggregator
# Resolve store -> merchant if needed
if merchant_id is None and store_id is not None:
merchant_id, platform_id = self._get_merchant_for_store(db, store_id)
if merchant_id is None or platform_id is None:
return False, "No subscription found"
# Check subscription is active
subscription = self._get_subscription(db, merchant_id, platform_id)
if not subscription or not subscription.is_active:
return False, "Subscription is not active"
# Get feature declaration
decl = feature_aggregator.get_declaration(feature_code)
if decl is None:
logger.warning(f"Unknown feature code: {feature_code}")
return True, None # Unknown features are allowed by default
# Binary features: just check if enabled
if decl.feature_type == FeatureType.BINARY:
if self.has_feature(db, merchant_id, platform_id, feature_code):
return True, None
return False, f"Feature '{feature_code}' requires an upgrade"
# Quantitative: check usage against limit
limit = self.get_effective_limit(db, merchant_id, platform_id, feature_code)
if limit is None: # Unlimited
return True, None
# Get current usage based on scope
usage = feature_aggregator.get_usage_for_feature(
db, feature_code,
store_id=store_id,
merchant_id=merchant_id,
platform_id=platform_id,
)
current = usage.current_count if usage else 0
if current >= limit:
return False, (
f"Limit reached ({current}/{limit} {decl.unit_key or feature_code}). "
f"Upgrade to increase your limit."
)
return True, None
# =========================================================================
# Feature Summary
# =========================================================================
def get_merchant_features_summary(
self, db: Session, merchant_id: int, platform_id: int
) -> list[FeatureSummary]:
"""
Get complete feature summary for merchant portal display.
Returns all features with current status, limits, and usage.
"""
from app.modules.billing.services.feature_aggregator import feature_aggregator
declarations = feature_aggregator.get_all_declarations()
merchant_features = self.get_merchant_feature_codes(db, merchant_id, platform_id)
# Preload overrides
overrides = {
o.feature_code: o
for o in db.query(MerchantFeatureOverride).filter(
MerchantFeatureOverride.merchant_id == merchant_id,
MerchantFeatureOverride.platform_id == platform_id,
).all()
}
# Get all usage at once
store_usage = {}
merchant_usage = feature_aggregator.get_merchant_usage(db, merchant_id, platform_id)
summaries = []
for code, decl in sorted(declarations.items(), key=lambda x: (x[1].category, x[1].display_order)):
enabled = code in merchant_features
is_override = code in overrides
limit = self.get_effective_limit(db, merchant_id, platform_id, code) if decl.feature_type == FeatureType.QUANTITATIVE else None
current = None
remaining = None
percent_used = None
if decl.feature_type == FeatureType.QUANTITATIVE:
usage_data = merchant_usage.get(code)
current = usage_data.current_count if usage_data else 0
if limit is not None:
remaining = max(0, limit - current)
percent_used = min(100.0, (current / limit * 100)) if limit > 0 else 0.0
summaries.append(FeatureSummary(
code=code,
name_key=decl.name_key,
description_key=decl.description_key,
category=decl.category,
feature_type=decl.feature_type.value,
scope=decl.scope.value,
enabled=enabled,
limit=limit,
current=current,
remaining=remaining,
percent_used=percent_used,
is_override=is_override,
unit_key=decl.unit_key,
ui_icon=decl.ui_icon,
))
return summaries
# =========================================================================
# Cache Management
# =========================================================================
def invalidate_cache(self, merchant_id: int, platform_id: int) -> None:
"""Invalidate cache for a specific merchant/platform."""
self._cache.invalidate(merchant_id, platform_id)
def invalidate_all_cache(self) -> None:
"""Invalidate entire cache."""
self._cache.invalidate_all()
# Singleton instance
feature_service = FeatureService()
__all__ = [
"feature_service",
"FeatureService",
"FeatureSummary",
]