fix(billing): resolve 3 IMPORT-001 architecture violations in billing module
Replace direct imports from optional modules (catalog, orders, analytics) with provider pattern calls (stats_aggregator, feature_aggregator) and move usage_service from analytics to billing where it belongs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,26 +9,9 @@ from app.modules.analytics.services.stats_service import (
|
||||
stats_service,
|
||||
StatsService,
|
||||
)
|
||||
from app.modules.analytics.services.usage_service import (
|
||||
usage_service,
|
||||
UsageService,
|
||||
UsageData,
|
||||
UsageMetricData,
|
||||
TierInfoData,
|
||||
UpgradeTierData,
|
||||
LimitCheckData,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Stats service
|
||||
"stats_service",
|
||||
"StatsService",
|
||||
# Usage service
|
||||
"usage_service",
|
||||
"UsageService",
|
||||
"UsageData",
|
||||
"UsageMetricData",
|
||||
"TierInfoData",
|
||||
"UpgradeTierData",
|
||||
"LimitCheckData",
|
||||
]
|
||||
|
||||
@@ -1,391 +0,0 @@
|
||||
# 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",
|
||||
]
|
||||
Reference in New Issue
Block a user