refactor(arch): eliminate all cross-module model imports in service layer
Some checks failed
Some checks failed
Enforce MOD-025/MOD-026 rules: zero top-level cross-module model imports remain in any service file. All 66 files migrated using deferred import patterns (method-body, _get_model() helpers, instance-cached self._Model) and new cross-module service methods in tenancy. Documentation updated with Pattern 6 (deferred imports), migration plan marked complete, and violations status reflects 84→0 service-layer violations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,7 @@ import logging
|
||||
from math import ceil
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.exceptions import (
|
||||
BusinessLogicException,
|
||||
@@ -27,7 +27,6 @@ from app.modules.billing.models import (
|
||||
SubscriptionStatus,
|
||||
SubscriptionTier,
|
||||
)
|
||||
from app.modules.tenancy.models import Merchant
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -143,8 +142,9 @@ class AdminSubscriptionService:
|
||||
) -> dict:
|
||||
"""List merchant subscriptions with filtering and pagination."""
|
||||
query = (
|
||||
db.query(MerchantSubscription, Merchant)
|
||||
.join(Merchant, MerchantSubscription.merchant_id == Merchant.id)
|
||||
db.query(MerchantSubscription)
|
||||
.join(MerchantSubscription.merchant)
|
||||
.options(joinedload(MerchantSubscription.merchant))
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
@@ -155,20 +155,35 @@ class AdminSubscriptionService:
|
||||
SubscriptionTier, MerchantSubscription.tier_id == SubscriptionTier.id
|
||||
).filter(SubscriptionTier.code == tier)
|
||||
if search:
|
||||
query = query.filter(Merchant.name.ilike(f"%{search}%"))
|
||||
from app.modules.tenancy.services.merchant_service import merchant_service
|
||||
|
||||
merchants, _ = merchant_service.get_merchants(db, search=search, limit=10000)
|
||||
merchant_ids = [m.id for m in merchants]
|
||||
if not merchant_ids:
|
||||
return {
|
||||
"results": [],
|
||||
"total": 0,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": 0,
|
||||
}
|
||||
query = query.filter(MerchantSubscription.merchant_id.in_(merchant_ids))
|
||||
|
||||
# Count total
|
||||
total = query.count()
|
||||
|
||||
# Paginate
|
||||
offset = (page - 1) * per_page
|
||||
results = (
|
||||
subs = (
|
||||
query.order_by(MerchantSubscription.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(per_page)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Return (sub, merchant) tuples for backward compatibility with callers
|
||||
results = [(sub, sub.merchant) for sub in subs]
|
||||
|
||||
return {
|
||||
"results": results,
|
||||
"total": total,
|
||||
@@ -181,9 +196,9 @@ class AdminSubscriptionService:
|
||||
self, db: Session, merchant_id: int, platform_id: int
|
||||
) -> tuple:
|
||||
"""Get subscription for a specific merchant on a platform."""
|
||||
result = (
|
||||
db.query(MerchantSubscription, Merchant)
|
||||
.join(Merchant, MerchantSubscription.merchant_id == Merchant.id)
|
||||
sub = (
|
||||
db.query(MerchantSubscription)
|
||||
.options(joinedload(MerchantSubscription.merchant))
|
||||
.filter(
|
||||
MerchantSubscription.merchant_id == merchant_id,
|
||||
MerchantSubscription.platform_id == platform_id,
|
||||
@@ -191,13 +206,13 @@ class AdminSubscriptionService:
|
||||
.first()
|
||||
)
|
||||
|
||||
if not result:
|
||||
if not sub:
|
||||
raise ResourceNotFoundException(
|
||||
"Subscription",
|
||||
f"merchant_id={merchant_id}, platform_id={platform_id}",
|
||||
)
|
||||
|
||||
return result
|
||||
return sub, sub.merchant
|
||||
|
||||
def update_subscription(
|
||||
self, db: Session, merchant_id: int, platform_id: int, update_data: dict
|
||||
@@ -242,10 +257,7 @@ class AdminSubscriptionService:
|
||||
status: str | None = None,
|
||||
) -> dict:
|
||||
"""List billing history across all merchants."""
|
||||
query = (
|
||||
db.query(BillingHistory, Merchant)
|
||||
.join(Merchant, BillingHistory.merchant_id == Merchant.id)
|
||||
)
|
||||
query = db.query(BillingHistory)
|
||||
|
||||
if merchant_id:
|
||||
query = query.filter(BillingHistory.merchant_id == merchant_id)
|
||||
@@ -255,13 +267,29 @@ class AdminSubscriptionService:
|
||||
total = query.count()
|
||||
|
||||
offset = (page - 1) * per_page
|
||||
results = (
|
||||
invoices = (
|
||||
query.order_by(BillingHistory.invoice_date.desc())
|
||||
.offset(offset)
|
||||
.limit(per_page)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Batch-fetch merchant names for display
|
||||
from app.modules.tenancy.services.merchant_service import merchant_service
|
||||
|
||||
merchant_ids = {inv.merchant_id for inv in invoices if inv.merchant_id}
|
||||
merchants_map = {}
|
||||
for mid in merchant_ids:
|
||||
m = merchant_service.get_merchant_by_id_optional(db, mid)
|
||||
if m:
|
||||
merchants_map[mid] = m
|
||||
|
||||
# Return (invoice, merchant) tuples for backward compatibility
|
||||
results = [
|
||||
(inv, merchants_map.get(inv.merchant_id))
|
||||
for inv in invoices
|
||||
]
|
||||
|
||||
return {
|
||||
"results": results,
|
||||
"total": total,
|
||||
@@ -276,16 +304,20 @@ class AdminSubscriptionService:
|
||||
|
||||
def get_platform_names_map(self, db: Session) -> dict[int, str]:
|
||||
"""Get mapping of platform_id -> platform_name."""
|
||||
from app.modules.tenancy.models import Platform
|
||||
from app.modules.tenancy.services.platform_service import platform_service
|
||||
|
||||
return {p.id: p.name for p in db.query(Platform).all()}
|
||||
platforms = platform_service.list_platforms(db, include_inactive=True)
|
||||
return {p.id: p.name for p in platforms}
|
||||
|
||||
def get_platform_name(self, db: Session, platform_id: int) -> str | None:
|
||||
"""Get platform name by ID."""
|
||||
from app.modules.tenancy.models import Platform
|
||||
from app.modules.tenancy.services.platform_service import platform_service
|
||||
|
||||
p = db.query(Platform).filter(Platform.id == platform_id).first()
|
||||
return p.name if p else None
|
||||
try:
|
||||
p = platform_service.get_platform_by_id(db, platform_id)
|
||||
return p.name
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# =========================================================================
|
||||
# Merchant Subscriptions with Usage
|
||||
@@ -359,9 +391,9 @@ class AdminSubscriptionService:
|
||||
Convenience method for admin store detail page. Resolves
|
||||
store -> merchant -> all platform subscriptions.
|
||||
"""
|
||||
from app.modules.tenancy.models import Store
|
||||
from app.modules.tenancy.services.store_service import store_service
|
||||
|
||||
store = db.query(Store).filter(Store.id == store_id).first()
|
||||
store = store_service.get_store_by_id_optional(db, store_id)
|
||||
if not store or not store.merchant_id:
|
||||
raise ResourceNotFoundException("Store", str(store_id))
|
||||
|
||||
|
||||
@@ -155,8 +155,8 @@ class BillingService:
|
||||
trial_days = settings.stripe_trial_days
|
||||
|
||||
# Get merchant for Stripe customer creation
|
||||
from app.modules.tenancy.models import Merchant
|
||||
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
|
||||
from app.modules.tenancy.services.merchant_service import merchant_service
|
||||
merchant = merchant_service.get_merchant_by_id_optional(db, merchant_id)
|
||||
|
||||
session = stripe_service.create_checkout_session(
|
||||
db=db,
|
||||
@@ -494,8 +494,8 @@ class BillingService:
|
||||
if not addon.stripe_price_id:
|
||||
raise BillingException(f"Stripe price not configured for add-on '{addon_code}'")
|
||||
|
||||
from app.modules.tenancy.models import Store
|
||||
store = db.query(Store).filter(Store.id == store_id).first()
|
||||
from app.modules.tenancy.services.store_service import store_service
|
||||
store = store_service.get_store_by_id_optional(db, store_id)
|
||||
|
||||
session = stripe_service.create_checkout_session(
|
||||
db=db,
|
||||
|
||||
@@ -115,21 +115,15 @@ class FeatureService:
|
||||
Returns:
|
||||
Tuple of (merchant_id, platform_id), either may be None
|
||||
"""
|
||||
from app.modules.tenancy.models import Store, StorePlatform
|
||||
from app.modules.tenancy.services.platform_service import platform_service
|
||||
from app.modules.tenancy.services.store_service import store_service
|
||||
|
||||
store = db.query(Store).filter(Store.id == store_id).first()
|
||||
store = store_service.get_store_by_id_optional(db, store_id)
|
||||
if not store:
|
||||
return None, None
|
||||
|
||||
merchant_id = store.merchant_id
|
||||
# Get primary platform_id from StorePlatform junction
|
||||
sp = (
|
||||
db.query(StorePlatform.platform_id)
|
||||
.filter(StorePlatform.store_id == store_id, StorePlatform.is_active == True) # noqa: E712
|
||||
.order_by(StorePlatform.is_primary.desc())
|
||||
.first()
|
||||
)
|
||||
platform_id = sp[0] if sp else None
|
||||
platform_id = platform_service.get_primary_platform_id_for_store(db, store_id)
|
||||
|
||||
return merchant_id, platform_id
|
||||
|
||||
@@ -142,19 +136,14 @@ class FeatureService:
|
||||
Returns all active platform IDs for the store's merchant,
|
||||
ordered with the primary platform first.
|
||||
"""
|
||||
from app.modules.tenancy.models import Store, StorePlatform
|
||||
from app.modules.tenancy.services.platform_service import platform_service
|
||||
from app.modules.tenancy.services.store_service import store_service
|
||||
|
||||
store = db.query(Store).filter(Store.id == store_id).first()
|
||||
store = store_service.get_store_by_id_optional(db, store_id)
|
||||
if not store:
|
||||
return None, []
|
||||
|
||||
platform_ids = [
|
||||
sp[0]
|
||||
for sp in db.query(StorePlatform.platform_id)
|
||||
.filter(StorePlatform.store_id == store_id, StorePlatform.is_active == True) # noqa: E712
|
||||
.order_by(StorePlatform.is_primary.desc())
|
||||
.all()
|
||||
]
|
||||
platform_ids = platform_service.get_active_platform_ids_for_store(db, store_id)
|
||||
return store.merchant_id, platform_ids
|
||||
|
||||
def _get_subscription(
|
||||
|
||||
@@ -11,7 +11,8 @@ import logging
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.tenancy.models import Store, StorePlatform
|
||||
from app.modules.tenancy.services.platform_service import platform_service
|
||||
from app.modules.tenancy.services.store_service import store_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -34,56 +35,20 @@ class StorePlatformSync:
|
||||
- Missing + is_active=True → create (set is_primary if store has none)
|
||||
- Missing + is_active=False → no-op
|
||||
"""
|
||||
stores = (
|
||||
db.query(Store)
|
||||
.filter(Store.merchant_id == merchant_id)
|
||||
.all()
|
||||
)
|
||||
stores = store_service.get_stores_by_merchant_id(db, merchant_id)
|
||||
|
||||
if not stores:
|
||||
return
|
||||
|
||||
for store in stores:
|
||||
existing = (
|
||||
db.query(StorePlatform)
|
||||
.filter(
|
||||
StorePlatform.store_id == store.id,
|
||||
StorePlatform.platform_id == platform_id,
|
||||
)
|
||||
.first()
|
||||
result = platform_service.ensure_store_platform(
|
||||
db, store.id, platform_id, is_active, tier_id
|
||||
)
|
||||
|
||||
if existing:
|
||||
existing.is_active = is_active
|
||||
if tier_id is not None:
|
||||
existing.tier_id = tier_id
|
||||
if result:
|
||||
logger.debug(
|
||||
f"Updated StorePlatform store_id={store.id} "
|
||||
f"Synced StorePlatform store_id={store.id} "
|
||||
f"platform_id={platform_id} is_active={is_active}"
|
||||
)
|
||||
elif is_active:
|
||||
# Check if store already has a primary platform
|
||||
has_primary = (
|
||||
db.query(StorePlatform)
|
||||
.filter(
|
||||
StorePlatform.store_id == store.id,
|
||||
StorePlatform.is_primary.is_(True),
|
||||
)
|
||||
.first()
|
||||
) is not None
|
||||
|
||||
sp = StorePlatform(
|
||||
store_id=store.id,
|
||||
platform_id=platform_id,
|
||||
is_active=True,
|
||||
is_primary=not has_primary,
|
||||
tier_id=tier_id,
|
||||
)
|
||||
db.add(sp)
|
||||
logger.info(
|
||||
f"Created StorePlatform store_id={store.id} "
|
||||
f"platform_id={platform_id} is_primary={not has_primary}"
|
||||
)
|
||||
|
||||
db.flush()
|
||||
|
||||
|
||||
@@ -10,7 +10,10 @@ Provides:
|
||||
- Webhook event construction
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import stripe
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -23,7 +26,9 @@ from app.modules.billing.exceptions import (
|
||||
from app.modules.billing.models import (
|
||||
MerchantSubscription,
|
||||
)
|
||||
from app.modules.tenancy.models import Store
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.modules.tenancy.models import Store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -294,10 +299,10 @@ class StripeService:
|
||||
self._check_configured()
|
||||
|
||||
# Get or create Stripe customer
|
||||
from app.modules.tenancy.models import StorePlatform
|
||||
from app.modules.tenancy.services.platform_service import platform_service
|
||||
from app.modules.tenancy.services.team_service import team_service
|
||||
|
||||
sp = db.query(StorePlatform.platform_id).filter(StorePlatform.store_id == store.id).first()
|
||||
platform_id = sp[0] if sp else None
|
||||
platform_id = platform_service.get_primary_platform_id_for_store(db, store.id)
|
||||
subscription = None
|
||||
if store.merchant_id and platform_id:
|
||||
subscription = (
|
||||
@@ -313,16 +318,7 @@ class StripeService:
|
||||
customer_id = subscription.stripe_customer_id
|
||||
else:
|
||||
# Get store owner email
|
||||
from app.modules.tenancy.models import StoreUser
|
||||
|
||||
owner = (
|
||||
db.query(StoreUser)
|
||||
.filter(
|
||||
StoreUser.store_id == store.id,
|
||||
StoreUser.is_owner == True,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
owner = team_service.get_store_owner(db, store.id)
|
||||
email = owner.user.email if owner and owner.user else None
|
||||
|
||||
customer_id = self.create_customer(store, email or f"{store.store_code}@placeholder.com")
|
||||
|
||||
@@ -53,17 +53,16 @@ class SubscriptionService:
|
||||
Raises:
|
||||
ResourceNotFoundException: If store not found or has no platform
|
||||
"""
|
||||
from app.modules.tenancy.models import Store, StorePlatform
|
||||
from app.modules.tenancy.services.platform_service import platform_service
|
||||
from app.modules.tenancy.services.store_service import store_service
|
||||
|
||||
store = db.query(Store).filter(Store.id == store_id).first()
|
||||
store = store_service.get_store_by_id_optional(db, store_id)
|
||||
if not store or not store.merchant_id:
|
||||
raise ResourceNotFoundException("Store", str(store_id))
|
||||
sp = db.query(StorePlatform.platform_id).filter(
|
||||
StorePlatform.store_id == store_id
|
||||
).first()
|
||||
if not sp:
|
||||
platform_id = platform_service.get_primary_platform_id_for_store(db, store_id)
|
||||
if not platform_id:
|
||||
raise ResourceNotFoundException("StorePlatform", f"store_id={store_id}")
|
||||
return store.merchant_id, sp[0]
|
||||
return store.merchant_id, platform_id
|
||||
|
||||
def get_store_code(self, db: Session, store_id: int) -> str:
|
||||
"""Get the store_code for a given store_id.
|
||||
@@ -71,9 +70,9 @@ class SubscriptionService:
|
||||
Raises:
|
||||
ResourceNotFoundException: If store not found
|
||||
"""
|
||||
from app.modules.tenancy.models import Store
|
||||
from app.modules.tenancy.services.store_service import store_service
|
||||
|
||||
store = db.query(Store).filter(Store.id == store_id).first()
|
||||
store = store_service.get_store_by_id_optional(db, store_id)
|
||||
if not store:
|
||||
raise ResourceNotFoundException("Store", str(store_id))
|
||||
return store.store_code
|
||||
@@ -175,9 +174,10 @@ class SubscriptionService:
|
||||
The merchant subscription, or None if the store, merchant,
|
||||
or platform cannot be resolved.
|
||||
"""
|
||||
from app.modules.tenancy.models import Store
|
||||
from app.modules.tenancy.services.platform_service import platform_service
|
||||
from app.modules.tenancy.services.store_service import store_service
|
||||
|
||||
store = db.query(Store).filter(Store.id == store_id).first()
|
||||
store = store_service.get_store_by_id_optional(db, store_id)
|
||||
if not store:
|
||||
return None
|
||||
|
||||
@@ -185,17 +185,7 @@ class SubscriptionService:
|
||||
if merchant_id is None:
|
||||
return None
|
||||
|
||||
# Get platform_id from store
|
||||
platform_id = getattr(store, "platform_id", None)
|
||||
if platform_id is None:
|
||||
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
|
||||
|
||||
platform_id = platform_service.get_primary_platform_id_for_store(db, store_id)
|
||||
if platform_id is None:
|
||||
return None
|
||||
|
||||
@@ -394,5 +384,60 @@ class SubscriptionService:
|
||||
return subscription
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Cross-module public API methods
|
||||
# =========================================================================
|
||||
|
||||
def get_active_subscription_platform_ids(
|
||||
self, db: Session, merchant_id: int
|
||||
) -> list[int]:
|
||||
"""
|
||||
Get platform IDs where merchant has active subscriptions.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
merchant_id: Merchant ID
|
||||
|
||||
Returns:
|
||||
List of platform IDs with active subscriptions
|
||||
"""
|
||||
active_statuses = [
|
||||
SubscriptionStatus.ACTIVE,
|
||||
SubscriptionStatus.TRIAL,
|
||||
]
|
||||
results = (
|
||||
db.query(MerchantSubscription.platform_id)
|
||||
.filter(
|
||||
MerchantSubscription.merchant_id == merchant_id,
|
||||
MerchantSubscription.status.in_(active_statuses),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return [r[0] for r in results]
|
||||
|
||||
def get_all_active_subscriptions(
|
||||
self, db: Session
|
||||
) -> list[MerchantSubscription]:
|
||||
"""
|
||||
Get all active/trial subscriptions with tier and feature limits.
|
||||
|
||||
Returns:
|
||||
List of MerchantSubscription objects with eager-loaded tier data
|
||||
"""
|
||||
active_statuses = [
|
||||
SubscriptionStatus.ACTIVE,
|
||||
SubscriptionStatus.TRIAL,
|
||||
]
|
||||
return (
|
||||
db.query(MerchantSubscription)
|
||||
.options(
|
||||
joinedload(MerchantSubscription.tier)
|
||||
.joinedload(SubscriptionTier.feature_limits),
|
||||
)
|
||||
.filter(MerchantSubscription.status.in_(active_statuses))
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
subscription_service = SubscriptionService()
|
||||
|
||||
@@ -14,12 +14,10 @@ and feature_service for limit resolution.
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.billing.models import MerchantSubscription, SubscriptionTier
|
||||
from app.modules.billing.services.feature_aggregator import feature_aggregator
|
||||
from app.modules.tenancy.models import StoreUser
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -222,12 +220,9 @@ class UsageService:
|
||||
|
||||
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
|
||||
)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user