# app/modules/billing/services/usage_service.py """ Usage and limits service. Provides methods for: - Getting current usage vs limits - Calculating upgrade recommendations - Checking limits before actions Uses the feature provider system for usage counting and feature_service for limit resolution. """ import logging from dataclasses import dataclass from sqlalchemy.orm import Session from app.modules.billing.models import MerchantSubscription, SubscriptionTier from app.modules.billing.services.feature_aggregator import feature_aggregator logger = logging.getLogger(__name__) @dataclass class UsageMetricData: """Usage metric data.""" name: str current: int limit: int | None percentage: float is_unlimited: bool is_at_limit: bool is_approaching_limit: bool @dataclass class TierInfoData: """Tier information.""" code: str name: str price_monthly_cents: int is_highest_tier: bool @dataclass class UpgradeTierData: """Upgrade tier information.""" code: str name: str price_monthly_cents: int price_increase_cents: int benefits: list[str] @dataclass class UsageData: """Full usage data.""" tier: TierInfoData usage: list[UsageMetricData] has_limits_approaching: bool has_limits_reached: bool upgrade_available: bool upgrade_tier: UpgradeTierData | None upgrade_reasons: list[str] @dataclass class LimitCheckData: """Limit check result.""" limit_type: str can_proceed: bool current: int limit: int | None percentage: float message: str | None upgrade_tier_code: str | None upgrade_tier_name: str | None class UsageService: """Service for usage and limits management.""" def _resolve_store_to_subscription( self, db: Session, store_id: int ) -> MerchantSubscription | None: """Resolve store_id to MerchantSubscription.""" from app.modules.billing.services.subscription_service import ( subscription_service, ) return subscription_service.get_subscription_for_store(db, store_id) def get_store_usage(self, db: Session, store_id: int) -> UsageData: """ Get comprehensive usage data for a store. Returns current usage, limits, and upgrade recommendations. """ subscription = self._resolve_store_to_subscription(db, store_id) # Get current tier tier = subscription.tier if subscription else None # Calculate usage metrics usage_metrics = self._calculate_usage_metrics(db, store_id, subscription) # Check for approaching/reached limits has_limits_approaching = any(m.is_approaching_limit for m in usage_metrics) has_limits_reached = any(m.is_at_limit for m in usage_metrics) # Get upgrade info next_tier = self._get_next_tier(db, tier) is_highest_tier = next_tier is None # Build upgrade info upgrade_tier_info = None upgrade_reasons = [] if next_tier: upgrade_tier_info = self._build_upgrade_tier_info(tier, next_tier) upgrade_reasons = self._build_upgrade_reasons( usage_metrics, has_limits_reached, has_limits_approaching ) tier_code = tier.code if tier else "unknown" tier_name = tier.name if tier else "Unknown" tier_price = tier.price_monthly_cents if tier else 0 return UsageData( tier=TierInfoData( code=tier_code, name=tier_name, price_monthly_cents=tier_price, is_highest_tier=is_highest_tier, ), usage=usage_metrics, has_limits_approaching=has_limits_approaching, has_limits_reached=has_limits_reached, upgrade_available=not is_highest_tier, upgrade_tier=upgrade_tier_info, upgrade_reasons=upgrade_reasons, ) def check_limit( self, db: Session, store_id: int, limit_type: str ) -> LimitCheckData: """ Check a specific limit before performing an action. Args: db: Database session store_id: Store ID limit_type: Feature code (e.g., "orders_per_month", "products_limit", "team_members") """ from app.modules.billing.services.feature_service import feature_service # Map legacy limit_type names to feature codes feature_code_map = { "orders": "orders_per_month", "products": "products_limit", "team_members": "team_members", } feature_code = feature_code_map.get(limit_type, limit_type) can_proceed, message = feature_service.check_resource_limit( db, feature_code, store_id=store_id ) # Get current usage for response current = 0 limit = None if feature_code == "products_limit": current = self._get_product_count(db, store_id) elif feature_code == "team_members": current = self._get_team_member_count(db, store_id) # Get effective limit subscription = self._resolve_store_to_subscription(db, store_id) if subscription and subscription.tier: limit = subscription.tier.get_limit_for_feature(feature_code) is_unlimited = limit is None percentage = 0 if is_unlimited else (current / limit * 100 if limit and limit > 0 else 100) # Get upgrade info if at limit upgrade_tier_code = None upgrade_tier_name = None if not can_proceed and subscription and subscription.tier: next_tier = self._get_next_tier(db, subscription.tier) if next_tier: upgrade_tier_code = next_tier.code upgrade_tier_name = next_tier.name return LimitCheckData( limit_type=limit_type, can_proceed=can_proceed, current=current, limit=None if is_unlimited else limit, percentage=percentage, message=message, upgrade_tier_code=upgrade_tier_code, upgrade_tier_name=upgrade_tier_name, ) # ========================================================================= # Private Helper Methods # ========================================================================= def _get_product_count(self, db: Session, store_id: int) -> int: """Get product count for store via feature aggregator.""" usage = feature_aggregator.get_store_usage(db, store_id) products = usage.get("products_limit") return products.current_count if products else 0 def _get_team_member_count(self, db: Session, store_id: int) -> int: """Get active team member count for store.""" from app.modules.tenancy.services.team_service import team_service return team_service.get_active_team_member_count(db, store_id) def _calculate_usage_metrics( self, db: Session, store_id: int, subscription: MerchantSubscription | None ) -> list[UsageMetricData]: """Calculate all usage metrics for a store using TierFeatureLimit.""" metrics = [] tier = subscription.tier if subscription else None # Define the quantitative features to track feature_configs = [ ("orders_per_month", "orders", lambda: self._get_orders_this_period(db, store_id, subscription)), ("products_limit", "products", lambda: self._get_product_count(db, store_id)), ("team_members", "team_members", lambda: self._get_team_member_count(db, store_id)), ] for feature_code, display_name, count_fn in feature_configs: current = count_fn() limit = tier.get_limit_for_feature(feature_code) if tier else 0 is_unlimited = limit is None percentage = ( 0 if is_unlimited else (current / limit * 100 if limit and limit > 0 else 100) ) metrics.append( UsageMetricData( name=display_name, current=current, limit=None if is_unlimited else limit, percentage=percentage, is_unlimited=is_unlimited, is_at_limit=not is_unlimited and limit is not None and current >= limit, is_approaching_limit=not is_unlimited and percentage >= 80, ) ) return metrics def _get_orders_this_period( self, db: Session, store_id: int, subscription: MerchantSubscription | None ) -> int: """Get order count for the current billing period via feature aggregator.""" usage = feature_aggregator.get_store_usage(db, store_id) orders = usage.get("orders_per_month") return orders.current_count if orders else 0 def _get_next_tier( self, db: Session, current_tier: SubscriptionTier | None ) -> SubscriptionTier | None: """Get next tier for upgrade.""" current_tier_order = current_tier.display_order if current_tier else 0 return ( db.query(SubscriptionTier) .filter( SubscriptionTier.is_active == True, # noqa: E712 SubscriptionTier.display_order > current_tier_order, ) .order_by(SubscriptionTier.display_order) .first() ) def _build_upgrade_tier_info( self, current_tier: SubscriptionTier | None, next_tier: SubscriptionTier ) -> UpgradeTierData: """Build upgrade tier information with benefits.""" benefits = [] current_features = current_tier.get_feature_codes() if current_tier else set() next_features = next_tier.get_feature_codes() new_features = next_features - current_features # Numeric limit improvements limit_features = [ ("orders_per_month", "orders/month"), ("products_limit", "products"), ("team_members", "team members"), ] for feature_code, label in limit_features: next_limit = next_tier.get_limit_for_feature(feature_code) current_limit = current_tier.get_limit_for_feature(feature_code) if current_tier else 0 if next_limit is None and (current_limit is not None and current_limit != 0): benefits.append(f"Unlimited {label}") elif next_limit is not None and (current_limit is None or next_limit > (current_limit or 0)): benefits.append(f"{next_limit:,} {label}") # Binary feature benefits feature_names = { "analytics_dashboard": "Advanced Analytics", "api_access": "API Access", "automation_rules": "Automation Rules", "team_roles": "Team Roles & Permissions", "custom_domain": "Custom Domain", "webhooks": "Webhooks", "accounting_export": "Accounting Export", } for feature in list(new_features)[:3]: if feature in feature_names: benefits.append(feature_names[feature]) current_price = current_tier.price_monthly_cents if current_tier else 0 return UpgradeTierData( code=next_tier.code, name=next_tier.name, price_monthly_cents=next_tier.price_monthly_cents, price_increase_cents=next_tier.price_monthly_cents - current_price, benefits=benefits, ) def _build_upgrade_reasons( self, usage_metrics: list[UsageMetricData], has_limits_reached: bool, has_limits_approaching: bool, ) -> list[str]: """Build upgrade reasons based on usage.""" reasons = [] if has_limits_reached: for m in usage_metrics: if m.is_at_limit: reasons.append(f"You've reached your {m.name.replace('_', ' ')} limit") elif has_limits_approaching: for m in usage_metrics: if m.is_approaching_limit: reasons.append( f"You're approaching your {m.name.replace('_', ' ')} limit ({int(m.percentage)}%)" ) return reasons # Singleton instance usage_service = UsageService() __all__ = [ "usage_service", "UsageService", "UsageData", "UsageMetricData", "TierInfoData", "UpgradeTierData", "LimitCheckData", ]