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>
This commit is contained in:
@@ -32,9 +32,6 @@ from app.modules.billing.exceptions import (
|
||||
from app.modules.billing.services.feature_service import (
|
||||
FeatureService,
|
||||
feature_service,
|
||||
FeatureInfo,
|
||||
FeatureUpgradeInfo,
|
||||
FeatureCode,
|
||||
)
|
||||
from app.modules.billing.services.capacity_forecast_service import (
|
||||
CapacityForecastService,
|
||||
@@ -62,9 +59,6 @@ __all__ = [
|
||||
"SubscriptionNotCancelledError",
|
||||
"FeatureService",
|
||||
"feature_service",
|
||||
"FeatureInfo",
|
||||
"FeatureUpgradeInfo",
|
||||
"FeatureCode",
|
||||
"CapacityForecastService",
|
||||
"capacity_forecast_service",
|
||||
"PlatformPricingService",
|
||||
|
||||
@@ -1,590 +1,438 @@
|
||||
# app/modules/billing/services/feature_service.py
|
||||
"""
|
||||
Feature service for tier-based access control.
|
||||
Feature-agnostic billing service for merchant-level access control.
|
||||
|
||||
Provides:
|
||||
- Feature availability checking with caching
|
||||
- Vendor feature listing for API/UI
|
||||
- Feature metadata for upgrade prompts
|
||||
- Cache invalidation on subscription changes
|
||||
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 vendor has feature
|
||||
if feature_service.has_feature(db, vendor_id, FeatureCode.ANALYTICS_DASHBOARD):
|
||||
# Check if merchant has feature
|
||||
if feature_service.has_feature(db, merchant_id, platform_id, "analytics_dashboard"):
|
||||
...
|
||||
|
||||
# Get all features available to vendor
|
||||
features = feature_service.get_vendor_features(db, vendor_id)
|
||||
# Check quantitative limit
|
||||
allowed, msg = feature_service.check_resource_limit(
|
||||
db, "products_limit", store_id=store_id
|
||||
)
|
||||
|
||||
# Get feature info for upgrade prompt
|
||||
info = feature_service.get_feature_upgrade_info(db, "analytics_dashboard")
|
||||
# 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 functools import lru_cache
|
||||
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.modules.billing.exceptions import (
|
||||
FeatureNotFoundError,
|
||||
InvalidFeatureCodesError,
|
||||
TierNotFoundError,
|
||||
from app.modules.billing.models import (
|
||||
MerchantFeatureOverride,
|
||||
MerchantSubscription,
|
||||
SubscriptionTier,
|
||||
TierFeatureLimit,
|
||||
)
|
||||
from app.modules.billing.models import Feature, FeatureCode
|
||||
from app.modules.billing.models import SubscriptionTier, VendorSubscription
|
||||
from app.modules.contracts.features import FeatureScope, FeatureType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeatureInfo:
|
||||
"""Feature information for API responses."""
|
||||
class FeatureSummary:
|
||||
"""Summary of a feature for merchant portal display."""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
description: str | None
|
||||
name_key: str
|
||||
description_key: str
|
||||
category: str
|
||||
ui_location: str | None
|
||||
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
|
||||
ui_route: str | None
|
||||
ui_badge_text: str | None
|
||||
is_available: bool
|
||||
minimum_tier_code: str | None
|
||||
minimum_tier_name: str | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeatureUpgradeInfo:
|
||||
"""Information for upgrade prompts."""
|
||||
|
||||
feature_code: str
|
||||
feature_name: str
|
||||
feature_description: str | None
|
||||
required_tier_code: str
|
||||
required_tier_name: str
|
||||
required_tier_price_monthly_cents: int
|
||||
|
||||
|
||||
class FeatureCache:
|
||||
"""
|
||||
In-memory cache for vendor features.
|
||||
In-memory cache for merchant features.
|
||||
|
||||
Caches vendor_id -> set of feature codes with TTL.
|
||||
Invalidated when subscription changes.
|
||||
Caches (merchant_id, platform_id) -> set of feature codes with TTL.
|
||||
"""
|
||||
|
||||
def __init__(self, ttl_seconds: int = 300):
|
||||
self._cache: dict[int, tuple[set[str], float]] = {}
|
||||
self._cache: dict[tuple[int, int], tuple[set[str], float]] = {}
|
||||
self._ttl = ttl_seconds
|
||||
|
||||
def get(self, vendor_id: int) -> set[str] | None:
|
||||
"""Get cached features for vendor, or None if not cached/expired."""
|
||||
if vendor_id not in self._cache:
|
||||
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[vendor_id]
|
||||
features, timestamp = self._cache[key]
|
||||
if time.time() - timestamp > self._ttl:
|
||||
del self._cache[vendor_id]
|
||||
del self._cache[key]
|
||||
return None
|
||||
|
||||
return features
|
||||
|
||||
def set(self, vendor_id: int, features: set[str]) -> None:
|
||||
"""Cache features for vendor."""
|
||||
self._cache[vendor_id] = (features, time.time())
|
||||
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, vendor_id: int) -> None:
|
||||
"""Invalidate cache for vendor."""
|
||||
self._cache.pop(vendor_id, None)
|
||||
def invalidate(self, merchant_id: int, platform_id: int) -> None:
|
||||
self._cache.pop((merchant_id, platform_id), None)
|
||||
|
||||
def invalidate_all(self) -> None:
|
||||
"""Invalidate entire cache."""
|
||||
self._cache.clear()
|
||||
|
||||
|
||||
class FeatureService:
|
||||
"""
|
||||
Service for feature-based access control.
|
||||
Feature-agnostic service for merchant-level billing.
|
||||
|
||||
Provides methods to check feature availability and get feature metadata.
|
||||
Uses in-memory caching with TTL for performance.
|
||||
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) # 5 minute cache
|
||||
self._feature_registry_cache: dict[str, Feature] | None = None
|
||||
self._feature_registry_timestamp: float = 0
|
||||
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, vendor_id: int, feature_code: str) -> bool:
|
||||
def has_feature(
|
||||
self, db: Session, merchant_id: int, platform_id: int, feature_code: str
|
||||
) -> bool:
|
||||
"""
|
||||
Check if vendor has access to a specific feature.
|
||||
Check if merchant has access to a specific feature.
|
||||
|
||||
Checks:
|
||||
1. MerchantFeatureOverride (force enable/disable)
|
||||
2. TierFeatureLimit (tier assignment)
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
feature_code: Feature code (use FeatureCode constants)
|
||||
merchant_id: Merchant ID
|
||||
platform_id: Platform ID
|
||||
feature_code: Feature code to check
|
||||
|
||||
Returns:
|
||||
True if vendor has access to the feature
|
||||
True if merchant has access to the feature
|
||||
"""
|
||||
vendor_features = self._get_vendor_feature_codes(db, vendor_id)
|
||||
return feature_code in vendor_features
|
||||
# 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
|
||||
|
||||
def get_vendor_feature_codes(self, db: Session, vendor_id: int) -> set[str]:
|
||||
# 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:
|
||||
"""
|
||||
Get set of feature codes available to vendor.
|
||||
Check if a store has access to a feature (resolves store -> merchant).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
|
||||
Returns:
|
||||
Set of feature codes the vendor has access to
|
||||
Convenience method for backwards compatibility.
|
||||
"""
|
||||
return self._get_vendor_feature_codes(db, vendor_id)
|
||||
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_vendor_feature_codes(self, db: Session, vendor_id: int) -> set[str]:
|
||||
"""Internal method with caching."""
|
||||
# Check cache first
|
||||
cached = self._cache.get(vendor_id)
|
||||
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
|
||||
|
||||
# Get subscription with tier relationship
|
||||
subscription = (
|
||||
db.query(VendorSubscription)
|
||||
.options(joinedload(VendorSubscription.tier_obj))
|
||||
.filter(VendorSubscription.vendor_id == vendor_id)
|
||||
.first()
|
||||
)
|
||||
features: set[str] = set()
|
||||
|
||||
if not subscription:
|
||||
logger.warning(f"No subscription found for vendor {vendor_id}")
|
||||
return 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()
|
||||
|
||||
# Get features from tier
|
||||
tier = subscription.tier_obj
|
||||
if tier and tier.features:
|
||||
features = set(tier.features)
|
||||
else:
|
||||
# Fallback: query tier by code
|
||||
tier = (
|
||||
db.query(SubscriptionTier)
|
||||
.filter(SubscriptionTier.code == subscription.tier)
|
||||
.first()
|
||||
# Apply overrides
|
||||
overrides = (
|
||||
db.query(MerchantFeatureOverride)
|
||||
.filter(
|
||||
MerchantFeatureOverride.merchant_id == merchant_id,
|
||||
MerchantFeatureOverride.platform_id == platform_id,
|
||||
)
|
||||
features = set(tier.features) if tier and tier.features else set()
|
||||
.all()
|
||||
)
|
||||
for override in overrides:
|
||||
if override.is_enabled:
|
||||
features.add(override.feature_code)
|
||||
else:
|
||||
features.discard(override.feature_code)
|
||||
|
||||
# Cache and return
|
||||
self._cache.set(vendor_id, features)
|
||||
self._cache.set(merchant_id, platform_id, features)
|
||||
return features
|
||||
|
||||
# =========================================================================
|
||||
# Feature Listing
|
||||
# Effective Limits
|
||||
# =========================================================================
|
||||
|
||||
def get_vendor_features(
|
||||
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,
|
||||
vendor_id: int,
|
||||
category: str | None = None,
|
||||
include_unavailable: bool = True,
|
||||
) -> list[FeatureInfo]:
|
||||
feature_code: str,
|
||||
store_id: int | None = None,
|
||||
merchant_id: int | None = None,
|
||||
platform_id: int | None = None,
|
||||
) -> tuple[bool, str | None]:
|
||||
"""
|
||||
Get all features with availability status for vendor.
|
||||
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
|
||||
vendor_id: Vendor ID
|
||||
category: Optional category filter
|
||||
include_unavailable: Include features not available to vendor
|
||||
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:
|
||||
List of FeatureInfo with is_available flag
|
||||
(allowed, error_message) tuple
|
||||
"""
|
||||
vendor_features = self._get_vendor_feature_codes(db, vendor_id)
|
||||
from app.modules.billing.services.feature_aggregator import feature_aggregator
|
||||
|
||||
# Query all active features
|
||||
query = db.query(Feature).filter(Feature.is_active == True) # noqa: E712
|
||||
# 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 category:
|
||||
query = query.filter(Feature.category == category)
|
||||
if merchant_id is None or platform_id is None:
|
||||
return False, "No subscription found"
|
||||
|
||||
if not include_unavailable:
|
||||
# Only return features the vendor has
|
||||
query = query.filter(Feature.code.in_(vendor_features))
|
||||
# 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"
|
||||
|
||||
features = (
|
||||
query.options(joinedload(Feature.minimum_tier))
|
||||
.order_by(Feature.category, Feature.display_order)
|
||||
.all()
|
||||
# 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
|
||||
|
||||
result = []
|
||||
for feature in features:
|
||||
result.append(
|
||||
FeatureInfo(
|
||||
code=feature.code,
|
||||
name=feature.name,
|
||||
description=feature.description,
|
||||
category=feature.category,
|
||||
ui_location=feature.ui_location,
|
||||
ui_icon=feature.ui_icon,
|
||||
ui_route=feature.ui_route,
|
||||
ui_badge_text=feature.ui_badge_text,
|
||||
is_available=feature.code in vendor_features,
|
||||
minimum_tier_code=feature.minimum_tier.code if feature.minimum_tier else None,
|
||||
minimum_tier_name=feature.minimum_tier.name if feature.minimum_tier else None,
|
||||
)
|
||||
if current >= limit:
|
||||
return False, (
|
||||
f"Limit reached ({current}/{limit} {decl.unit_key or feature_code}). "
|
||||
f"Upgrade to increase your limit."
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def get_available_feature_codes(self, db: Session, vendor_id: int) -> list[str]:
|
||||
"""
|
||||
Get list of feature codes available to vendor (for frontend).
|
||||
|
||||
Simple list for x-feature directive checks.
|
||||
"""
|
||||
return list(self._get_vendor_feature_codes(db, vendor_id))
|
||||
return True, None
|
||||
|
||||
# =========================================================================
|
||||
# Feature Metadata
|
||||
# Feature Summary
|
||||
# =========================================================================
|
||||
|
||||
def get_feature_by_code(self, db: Session, feature_code: str) -> Feature | None:
|
||||
"""Get feature by code."""
|
||||
return (
|
||||
db.query(Feature)
|
||||
.options(joinedload(Feature.minimum_tier))
|
||||
.filter(Feature.code == feature_code)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_feature_upgrade_info(
|
||||
self, db: Session, feature_code: str
|
||||
) -> FeatureUpgradeInfo | None:
|
||||
def get_merchant_features_summary(
|
||||
self, db: Session, merchant_id: int, platform_id: int
|
||||
) -> list[FeatureSummary]:
|
||||
"""
|
||||
Get upgrade information for a feature.
|
||||
Get complete feature summary for merchant portal display.
|
||||
|
||||
Used for upgrade prompts when a feature is not available.
|
||||
Returns all features with current status, limits, and usage.
|
||||
"""
|
||||
feature = self.get_feature_by_code(db, feature_code)
|
||||
from app.modules.billing.services.feature_aggregator import feature_aggregator
|
||||
|
||||
if not feature or not feature.minimum_tier:
|
||||
return None
|
||||
declarations = feature_aggregator.get_all_declarations()
|
||||
merchant_features = self.get_merchant_feature_codes(db, merchant_id, platform_id)
|
||||
|
||||
tier = feature.minimum_tier
|
||||
return FeatureUpgradeInfo(
|
||||
feature_code=feature.code,
|
||||
feature_name=feature.name,
|
||||
feature_description=feature.description,
|
||||
required_tier_code=tier.code,
|
||||
required_tier_name=tier.name,
|
||||
required_tier_price_monthly_cents=tier.price_monthly_cents,
|
||||
)
|
||||
# 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()
|
||||
}
|
||||
|
||||
def get_all_features(
|
||||
self,
|
||||
db: Session,
|
||||
category: str | None = None,
|
||||
active_only: bool = True,
|
||||
) -> list[Feature]:
|
||||
"""Get all features (for admin)."""
|
||||
query = db.query(Feature).options(joinedload(Feature.minimum_tier))
|
||||
# Get all usage at once
|
||||
store_usage = {}
|
||||
merchant_usage = feature_aggregator.get_merchant_usage(db, merchant_id, platform_id)
|
||||
|
||||
if active_only:
|
||||
query = query.filter(Feature.is_active == True) # noqa: E712
|
||||
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
|
||||
|
||||
if category:
|
||||
query = query.filter(Feature.category == category)
|
||||
current = None
|
||||
remaining = None
|
||||
percent_used = None
|
||||
|
||||
return query.order_by(Feature.category, Feature.display_order).all()
|
||||
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
|
||||
|
||||
def get_features_by_tier(self, db: Session, tier_code: str) -> list[str]:
|
||||
"""Get feature codes for a specific tier."""
|
||||
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first()
|
||||
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,
|
||||
))
|
||||
|
||||
if not tier or not tier.features:
|
||||
return []
|
||||
|
||||
return tier.features
|
||||
|
||||
# =========================================================================
|
||||
# Feature Categories
|
||||
# =========================================================================
|
||||
|
||||
def get_categories(self, db: Session) -> list[str]:
|
||||
"""Get all unique feature categories."""
|
||||
result = (
|
||||
db.query(Feature.category)
|
||||
.filter(Feature.is_active == True) # noqa: E712
|
||||
.distinct()
|
||||
.order_by(Feature.category)
|
||||
.all()
|
||||
)
|
||||
return [row[0] for row in result]
|
||||
|
||||
def get_features_grouped_by_category(
|
||||
self, db: Session, vendor_id: int
|
||||
) -> dict[str, list[FeatureInfo]]:
|
||||
"""Get features grouped by category with availability."""
|
||||
features = self.get_vendor_features(db, vendor_id, include_unavailable=True)
|
||||
|
||||
grouped: dict[str, list[FeatureInfo]] = {}
|
||||
for feature in features:
|
||||
if feature.category not in grouped:
|
||||
grouped[feature.category] = []
|
||||
grouped[feature.category].append(feature)
|
||||
|
||||
return grouped
|
||||
return summaries
|
||||
|
||||
# =========================================================================
|
||||
# Cache Management
|
||||
# =========================================================================
|
||||
|
||||
def invalidate_vendor_cache(self, vendor_id: int) -> None:
|
||||
"""
|
||||
Invalidate cache for a specific vendor.
|
||||
|
||||
Call this when:
|
||||
- Vendor's subscription tier changes
|
||||
- Tier features are updated (for all vendors on that tier)
|
||||
"""
|
||||
self._cache.invalidate(vendor_id)
|
||||
logger.debug(f"Invalidated feature cache for vendor {vendor_id}")
|
||||
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.
|
||||
|
||||
Call this when tier features are modified in admin.
|
||||
"""
|
||||
"""Invalidate entire cache."""
|
||||
self._cache.invalidate_all()
|
||||
logger.debug("Invalidated all feature caches")
|
||||
|
||||
# =========================================================================
|
||||
# Admin Operations
|
||||
# =========================================================================
|
||||
|
||||
def get_all_tiers_with_features(self, db: Session) -> list[SubscriptionTier]:
|
||||
"""Get all active tiers with their features for admin."""
|
||||
return (
|
||||
db.query(SubscriptionTier)
|
||||
.filter(SubscriptionTier.is_active == True) # noqa: E712
|
||||
.order_by(SubscriptionTier.display_order)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_tier_by_code(self, db: Session, tier_code: str) -> SubscriptionTier:
|
||||
"""
|
||||
Get tier by code, raising exception if not found.
|
||||
|
||||
Raises:
|
||||
TierNotFoundError: If tier not found
|
||||
"""
|
||||
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first()
|
||||
if not tier:
|
||||
raise TierNotFoundError(tier_code)
|
||||
return tier
|
||||
|
||||
def get_tier_features_with_details(
|
||||
self, db: Session, tier_code: str
|
||||
) -> tuple[SubscriptionTier, list[Feature]]:
|
||||
"""
|
||||
Get tier with full feature details.
|
||||
|
||||
Returns:
|
||||
Tuple of (tier, list of Feature objects)
|
||||
|
||||
Raises:
|
||||
TierNotFoundError: If tier not found
|
||||
"""
|
||||
tier = self.get_tier_by_code(db, tier_code)
|
||||
feature_codes = tier.features or []
|
||||
|
||||
features = (
|
||||
db.query(Feature)
|
||||
.filter(Feature.code.in_(feature_codes))
|
||||
.order_by(Feature.category, Feature.display_order)
|
||||
.all()
|
||||
)
|
||||
|
||||
return tier, features
|
||||
|
||||
def update_tier_features(
|
||||
self, db: Session, tier_code: str, feature_codes: list[str]
|
||||
) -> SubscriptionTier:
|
||||
"""
|
||||
Update features for a tier (admin operation).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
tier_code: Tier code
|
||||
feature_codes: List of feature codes to assign
|
||||
|
||||
Returns:
|
||||
Updated tier
|
||||
|
||||
Raises:
|
||||
TierNotFoundError: If tier not found
|
||||
InvalidFeatureCodesError: If any feature codes are invalid
|
||||
"""
|
||||
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first()
|
||||
|
||||
if not tier:
|
||||
raise TierNotFoundError(tier_code)
|
||||
|
||||
# Validate feature codes exist
|
||||
# noqa: SVC-005 - Features are platform-level, not vendor-scoped
|
||||
valid_codes = {
|
||||
f.code for f in db.query(Feature.code).filter(Feature.is_active == True).all() # noqa: E712
|
||||
}
|
||||
invalid = set(feature_codes) - valid_codes
|
||||
if invalid:
|
||||
raise InvalidFeatureCodesError(invalid)
|
||||
|
||||
tier.features = feature_codes
|
||||
|
||||
# Invalidate all caches since tier features changed
|
||||
self.invalidate_all_cache()
|
||||
|
||||
logger.info(f"Updated features for tier {tier_code}: {len(feature_codes)} features")
|
||||
return tier
|
||||
|
||||
def update_feature(
|
||||
self,
|
||||
db: Session,
|
||||
feature_code: str,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
category: str | None = None,
|
||||
ui_location: str | None = None,
|
||||
ui_icon: str | None = None,
|
||||
ui_route: str | None = None,
|
||||
ui_badge_text: str | None = None,
|
||||
minimum_tier_code: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
is_visible: bool | None = None,
|
||||
display_order: int | None = None,
|
||||
) -> Feature:
|
||||
"""
|
||||
Update feature metadata.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
feature_code: Feature code to update
|
||||
... other optional fields to update
|
||||
|
||||
Returns:
|
||||
Updated feature
|
||||
|
||||
Raises:
|
||||
FeatureNotFoundError: If feature not found
|
||||
TierNotFoundError: If minimum_tier_code provided but not found
|
||||
"""
|
||||
feature = (
|
||||
db.query(Feature)
|
||||
.options(joinedload(Feature.minimum_tier))
|
||||
.filter(Feature.code == feature_code)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not feature:
|
||||
raise FeatureNotFoundError(feature_code)
|
||||
|
||||
# Update fields if provided
|
||||
if name is not None:
|
||||
feature.name = name
|
||||
if description is not None:
|
||||
feature.description = description
|
||||
if category is not None:
|
||||
feature.category = category
|
||||
if ui_location is not None:
|
||||
feature.ui_location = ui_location
|
||||
if ui_icon is not None:
|
||||
feature.ui_icon = ui_icon
|
||||
if ui_route is not None:
|
||||
feature.ui_route = ui_route
|
||||
if ui_badge_text is not None:
|
||||
feature.ui_badge_text = ui_badge_text
|
||||
if is_active is not None:
|
||||
feature.is_active = is_active
|
||||
if is_visible is not None:
|
||||
feature.is_visible = is_visible
|
||||
if display_order is not None:
|
||||
feature.display_order = display_order
|
||||
|
||||
# Update minimum tier if provided
|
||||
if minimum_tier_code is not None:
|
||||
if minimum_tier_code == "":
|
||||
feature.minimum_tier_id = None
|
||||
else:
|
||||
tier = (
|
||||
db.query(SubscriptionTier)
|
||||
.filter(SubscriptionTier.code == minimum_tier_code)
|
||||
.first()
|
||||
)
|
||||
if not tier:
|
||||
raise TierNotFoundError(minimum_tier_code)
|
||||
feature.minimum_tier_id = tier.id
|
||||
|
||||
logger.info(f"Updated feature {feature_code}")
|
||||
return feature
|
||||
|
||||
def update_feature_minimum_tier(
|
||||
self, db: Session, feature_code: str, tier_code: str | None
|
||||
) -> Feature:
|
||||
"""
|
||||
Update minimum tier for a feature (for upgrade prompts).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
feature_code: Feature code
|
||||
tier_code: Tier code or None
|
||||
|
||||
Raises:
|
||||
FeatureNotFoundError: If feature not found
|
||||
TierNotFoundError: If tier_code provided but not found
|
||||
"""
|
||||
feature = db.query(Feature).filter(Feature.code == feature_code).first()
|
||||
|
||||
if not feature:
|
||||
raise FeatureNotFoundError(feature_code)
|
||||
|
||||
if tier_code:
|
||||
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first()
|
||||
if not tier:
|
||||
raise TierNotFoundError(tier_code)
|
||||
feature.minimum_tier_id = tier.id
|
||||
else:
|
||||
feature.minimum_tier_id = None
|
||||
|
||||
logger.info(f"Updated minimum tier for feature {feature_code}: {tier_code}")
|
||||
return feature
|
||||
|
||||
|
||||
# Singleton instance
|
||||
feature_service = FeatureService()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Convenience Exports
|
||||
# ============================================================================
|
||||
# Re-export FeatureCode for easy imports
|
||||
|
||||
__all__ = [
|
||||
"feature_service",
|
||||
"FeatureService",
|
||||
"FeatureInfo",
|
||||
"FeatureUpgradeInfo",
|
||||
"FeatureCode",
|
||||
"FeatureSummary",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user