Overhaul storefront URL routing to be platform-aware:
- Dev: /platforms/{code}/storefront/{store_code}/
- Prod: subdomain.platform.lu/ (internally rewritten to /storefront/)
- Add subdomain detection in PlatformContextMiddleware
- Add /storefront/ path rewrite for prod mode (subdomain/custom domain)
- Remove all silent platform fallbacks (platform_id=1)
- Add require_platform dependency for clean endpoint validation
- Update route registration, templates, module definitions, base_url calc
- Update StoreContextMiddleware for /storefront/ path detection
- Remove /stores/ from FrontendDetector STOREFRONT_PATH_PREFIXES
Billing service improvements:
- Add store_platform_sync_service to keep store_platforms in sync
- Make tier lookups platform-aware across billing services
- Add tiers for all platforms in seed data
- Add demo subscriptions to seed
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
399 lines
14 KiB
Python
399 lines
14 KiB
Python
# app/modules/billing/services/subscription_service.py
|
|
"""
|
|
Subscription service for merchant-level subscription management.
|
|
|
|
Handles:
|
|
- MerchantSubscription creation and management
|
|
- Tier lookup and resolution
|
|
- Store → merchant → subscription resolution
|
|
|
|
Limit checks are now handled by feature_service.check_resource_limit().
|
|
Modules own their own limit checks (catalog, orders, tenancy, etc.).
|
|
|
|
Usage:
|
|
from app.modules.billing.services import subscription_service
|
|
|
|
# Get merchant subscription
|
|
sub = subscription_service.get_merchant_subscription(db, merchant_id, platform_id)
|
|
|
|
# Create merchant subscription
|
|
sub = subscription_service.create_merchant_subscription(db, merchant_id, platform_id, tier_code)
|
|
|
|
# Resolve store to merchant subscription
|
|
sub = subscription_service.get_subscription_for_store(db, store_id)
|
|
"""
|
|
|
|
import logging
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
from sqlalchemy.orm import Session, joinedload
|
|
|
|
from app.exceptions import ResourceNotFoundException
|
|
from app.modules.billing.exceptions import SubscriptionNotFoundException
|
|
from app.modules.billing.models import (
|
|
MerchantSubscription,
|
|
SubscriptionStatus,
|
|
SubscriptionTier,
|
|
TierCode,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class SubscriptionService:
|
|
"""Service for merchant-level subscription management."""
|
|
|
|
# =========================================================================
|
|
# Store Resolution
|
|
# =========================================================================
|
|
|
|
def resolve_store_to_merchant(self, db: Session, store_id: int) -> tuple[int, int]:
|
|
"""Resolve store_id to (merchant_id, platform_id).
|
|
|
|
Raises:
|
|
ResourceNotFoundException: If store not found or has no platform
|
|
"""
|
|
from app.modules.tenancy.models import Store, StorePlatform
|
|
|
|
store = db.query(Store).filter(Store.id == store_id).first()
|
|
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:
|
|
raise ResourceNotFoundException("StorePlatform", f"store_id={store_id}")
|
|
return store.merchant_id, sp[0]
|
|
|
|
def get_store_code(self, db: Session, store_id: int) -> str:
|
|
"""Get the store_code for a given store_id.
|
|
|
|
Raises:
|
|
ResourceNotFoundException: If store not found
|
|
"""
|
|
from app.modules.tenancy.models import Store
|
|
|
|
store = db.query(Store).filter(Store.id == store_id).first()
|
|
if not store:
|
|
raise ResourceNotFoundException("Store", str(store_id))
|
|
return store.store_code
|
|
|
|
# =========================================================================
|
|
# Tier Information
|
|
# =========================================================================
|
|
|
|
def get_tier_by_code(
|
|
self, db: Session, tier_code: str, platform_id: int | None = None
|
|
) -> SubscriptionTier | None:
|
|
"""Get subscription tier by code, optionally scoped to a platform."""
|
|
query = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code)
|
|
if platform_id is not None:
|
|
query = query.filter(SubscriptionTier.platform_id == platform_id)
|
|
return query.first()
|
|
|
|
def get_tier_id(
|
|
self, db: Session, tier_code: str, platform_id: int | None = None
|
|
) -> int | None:
|
|
"""Get tier ID from tier code. Returns None if tier not found."""
|
|
tier = self.get_tier_by_code(db, tier_code, platform_id=platform_id)
|
|
return tier.id if tier else None
|
|
|
|
def get_all_tiers(
|
|
self, db: Session, platform_id: int | None = None
|
|
) -> list[SubscriptionTier]:
|
|
"""
|
|
Get all active, public tiers.
|
|
|
|
If platform_id is provided, returns tiers for that platform
|
|
plus global tiers (platform_id=NULL).
|
|
"""
|
|
query = db.query(SubscriptionTier).filter(
|
|
SubscriptionTier.is_active == True, # noqa: E712
|
|
SubscriptionTier.is_public == True, # noqa: E712
|
|
)
|
|
|
|
if platform_id is not None:
|
|
query = query.filter(
|
|
(SubscriptionTier.platform_id == platform_id)
|
|
| (SubscriptionTier.platform_id.is_(None))
|
|
)
|
|
|
|
return query.order_by(SubscriptionTier.display_order).all()
|
|
|
|
# =========================================================================
|
|
# Merchant Subscription CRUD
|
|
# =========================================================================
|
|
|
|
def get_merchant_subscription(
|
|
self, db: Session, merchant_id: int, platform_id: int
|
|
) -> MerchantSubscription | None:
|
|
"""Get merchant subscription for a specific platform."""
|
|
return (
|
|
db.query(MerchantSubscription)
|
|
.options(
|
|
joinedload(MerchantSubscription.tier)
|
|
.joinedload(SubscriptionTier.feature_limits),
|
|
joinedload(MerchantSubscription.platform),
|
|
)
|
|
.filter(
|
|
MerchantSubscription.merchant_id == merchant_id,
|
|
MerchantSubscription.platform_id == platform_id,
|
|
)
|
|
.first()
|
|
)
|
|
|
|
def get_merchant_subscriptions(
|
|
self, db: Session, merchant_id: int
|
|
) -> list[MerchantSubscription]:
|
|
"""Get all subscriptions for a merchant across platforms."""
|
|
return (
|
|
db.query(MerchantSubscription)
|
|
.options(
|
|
joinedload(MerchantSubscription.tier)
|
|
.joinedload(SubscriptionTier.feature_limits),
|
|
joinedload(MerchantSubscription.platform),
|
|
)
|
|
.filter(MerchantSubscription.merchant_id == merchant_id)
|
|
.all()
|
|
)
|
|
|
|
def get_subscription_for_store(
|
|
self, db: Session, store_id: int
|
|
) -> MerchantSubscription | None:
|
|
"""
|
|
Convenience method that resolves the store -> merchant -> platform
|
|
hierarchy and returns the associated merchant subscription.
|
|
|
|
Looks up the store's merchant_id and platform_id, then delegates
|
|
to get_merchant_subscription().
|
|
|
|
Args:
|
|
db: Database session.
|
|
store_id: The store ID to resolve.
|
|
|
|
Returns:
|
|
The merchant subscription, or None if the store, merchant,
|
|
or platform cannot be resolved.
|
|
"""
|
|
from app.modules.tenancy.models import Store
|
|
|
|
store = db.query(Store).filter(Store.id == store_id).first()
|
|
if not store:
|
|
return None
|
|
|
|
merchant_id = store.merchant_id
|
|
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
|
|
|
|
if platform_id is None:
|
|
return None
|
|
|
|
return self.get_merchant_subscription(db, merchant_id, platform_id)
|
|
|
|
def get_subscription_or_raise(
|
|
self, db: Session, merchant_id: int, platform_id: int
|
|
) -> MerchantSubscription:
|
|
"""Get merchant subscription or raise exception."""
|
|
subscription = self.get_merchant_subscription(db, merchant_id, platform_id)
|
|
if not subscription:
|
|
raise SubscriptionNotFoundException(merchant_id)
|
|
return subscription
|
|
|
|
def create_merchant_subscription(
|
|
self,
|
|
db: Session,
|
|
merchant_id: int,
|
|
platform_id: int,
|
|
tier_code: str = TierCode.ESSENTIAL.value,
|
|
trial_days: int = 14,
|
|
is_annual: bool = False,
|
|
) -> MerchantSubscription:
|
|
"""
|
|
Create a new merchant subscription for a platform.
|
|
|
|
Args:
|
|
db: Database session
|
|
merchant_id: Merchant ID (the billing entity)
|
|
platform_id: Platform ID
|
|
tier_code: Tier code (default: essential)
|
|
trial_days: Trial period in days (0 = no trial)
|
|
is_annual: Annual billing cycle
|
|
|
|
Returns:
|
|
New MerchantSubscription
|
|
"""
|
|
# Check for existing
|
|
existing = self.get_merchant_subscription(db, merchant_id, platform_id)
|
|
if existing:
|
|
raise ValueError(
|
|
f"Merchant {merchant_id} already has a subscription "
|
|
f"on platform {platform_id}"
|
|
)
|
|
|
|
now = datetime.now(UTC)
|
|
|
|
# Calculate period
|
|
if trial_days > 0:
|
|
period_end = now + timedelta(days=trial_days)
|
|
trial_ends_at = period_end
|
|
status = SubscriptionStatus.TRIAL.value
|
|
elif is_annual:
|
|
period_end = now + timedelta(days=365)
|
|
trial_ends_at = None
|
|
status = SubscriptionStatus.ACTIVE.value
|
|
else:
|
|
period_end = now + timedelta(days=30)
|
|
trial_ends_at = None
|
|
status = SubscriptionStatus.ACTIVE.value
|
|
|
|
tier_id = self.get_tier_id(db, tier_code, platform_id=platform_id)
|
|
|
|
subscription = MerchantSubscription(
|
|
merchant_id=merchant_id,
|
|
platform_id=platform_id,
|
|
tier_id=tier_id,
|
|
status=status,
|
|
is_annual=is_annual,
|
|
period_start=now,
|
|
period_end=period_end,
|
|
trial_ends_at=trial_ends_at,
|
|
)
|
|
|
|
db.add(subscription)
|
|
db.flush()
|
|
db.refresh(subscription)
|
|
|
|
# Sync store_platforms for all merchant stores
|
|
from app.modules.billing.services.store_platform_sync_service import (
|
|
store_platform_sync,
|
|
)
|
|
|
|
store_platform_sync.sync_store_platforms_for_merchant(
|
|
db, merchant_id, platform_id, is_active=True, tier_id=subscription.tier_id
|
|
)
|
|
|
|
logger.info(
|
|
f"Created subscription for merchant {merchant_id} on platform {platform_id} "
|
|
f"(tier={tier_code}, status={status})"
|
|
)
|
|
|
|
return subscription
|
|
|
|
def get_or_create_subscription(
|
|
self,
|
|
db: Session,
|
|
merchant_id: int,
|
|
platform_id: int,
|
|
tier_code: str = TierCode.ESSENTIAL.value,
|
|
trial_days: int = 14,
|
|
) -> MerchantSubscription:
|
|
"""Get existing subscription or create a new trial subscription."""
|
|
subscription = self.get_merchant_subscription(db, merchant_id, platform_id)
|
|
if subscription:
|
|
return subscription
|
|
return self.create_merchant_subscription(
|
|
db, merchant_id, platform_id, tier_code, trial_days
|
|
)
|
|
|
|
def upgrade_tier(
|
|
self,
|
|
db: Session,
|
|
merchant_id: int,
|
|
platform_id: int,
|
|
new_tier_code: str,
|
|
) -> MerchantSubscription:
|
|
"""Upgrade merchant to a new tier."""
|
|
subscription = self.get_subscription_or_raise(db, merchant_id, platform_id)
|
|
|
|
old_tier_id = subscription.tier_id
|
|
new_tier = self.get_tier_by_code(db, new_tier_code, platform_id=platform_id)
|
|
if not new_tier:
|
|
raise ValueError(f"Tier '{new_tier_code}' not found")
|
|
|
|
subscription.tier_id = new_tier.id
|
|
subscription.updated_at = datetime.now(UTC)
|
|
|
|
# If upgrading from trial, mark as active
|
|
if subscription.status == SubscriptionStatus.TRIAL.value:
|
|
subscription.status = SubscriptionStatus.ACTIVE.value
|
|
|
|
db.flush()
|
|
db.refresh(subscription)
|
|
|
|
logger.info(
|
|
f"Upgraded merchant {merchant_id} on platform {platform_id} "
|
|
f"from tier_id={old_tier_id} to tier_id={new_tier.id} ({new_tier_code})"
|
|
)
|
|
return subscription
|
|
|
|
def cancel_subscription(
|
|
self,
|
|
db: Session,
|
|
merchant_id: int,
|
|
platform_id: int,
|
|
reason: str | None = None,
|
|
) -> MerchantSubscription:
|
|
"""Cancel a merchant subscription (access continues until period end)."""
|
|
subscription = self.get_subscription_or_raise(db, merchant_id, platform_id)
|
|
|
|
subscription.status = SubscriptionStatus.CANCELLED.value
|
|
subscription.cancelled_at = datetime.now(UTC)
|
|
subscription.cancellation_reason = reason
|
|
subscription.updated_at = datetime.now(UTC)
|
|
|
|
db.flush()
|
|
db.refresh(subscription)
|
|
|
|
logger.info(
|
|
f"Cancelled subscription for merchant {merchant_id} "
|
|
f"on platform {platform_id}"
|
|
)
|
|
return subscription
|
|
|
|
def reactivate_subscription(
|
|
self,
|
|
db: Session,
|
|
merchant_id: int,
|
|
platform_id: int,
|
|
) -> MerchantSubscription:
|
|
"""Reactivate a cancelled subscription."""
|
|
subscription = self.get_subscription_or_raise(db, merchant_id, platform_id)
|
|
|
|
subscription.status = SubscriptionStatus.ACTIVE.value
|
|
subscription.cancelled_at = None
|
|
subscription.cancellation_reason = None
|
|
subscription.updated_at = datetime.now(UTC)
|
|
|
|
db.flush()
|
|
db.refresh(subscription)
|
|
|
|
# Sync store_platforms for all merchant stores
|
|
from app.modules.billing.services.store_platform_sync_service import (
|
|
store_platform_sync,
|
|
)
|
|
|
|
store_platform_sync.sync_store_platforms_for_merchant(
|
|
db, merchant_id, platform_id, is_active=True
|
|
)
|
|
|
|
logger.info(
|
|
f"Reactivated subscription for merchant {merchant_id} "
|
|
f"on platform {platform_id}"
|
|
)
|
|
return subscription
|
|
|
|
|
|
# Singleton instance
|
|
subscription_service = SubscriptionService()
|