# app/modules/analytics/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 import func from sqlalchemy.orm import Session from app.modules.catalog.models import Product from app.modules.billing.models import MerchantSubscription, SubscriptionTier from app.modules.tenancy.models import StoreUser 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.""" return ( db.query(func.count(Product.id)) .filter(Product.store_id == store_id) .scalar() or 0 ) def _get_team_member_count(self, db: Session, store_id: int) -> int: """Get active team member count for store.""" return ( db.query(func.count(StoreUser.id)) .filter(StoreUser.store_id == store_id, StoreUser.is_active == True) # noqa: E712 .scalar() or 0 ) 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.""" from app.modules.orders.models import Order period_start = subscription.period_start if subscription else None if not period_start: from datetime import datetime, UTC period_start = datetime.now(UTC).replace(day=1, hour=0, minute=0, second=0, microsecond=0) return ( db.query(func.count(Order.id)) .filter( Order.store_id == store_id, Order.created_at >= period_start, ) .scalar() or 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", ]