Store detail page now shows all platform subscriptions instead of always "No Subscription Found". Subscriptions listing page renamed from Store to Merchant throughout (template, JS, menu, i18n) with Platform column added. Tiers API supports platform_id filtering. Merchant detail page no longer hardcodes 'oms' platform — loads all platforms, shows subscription cards per platform with labels, and the Create Subscription modal includes a platform selector with platform-filtered tiers. Create button always accessible in Quick Actions. Edit modal on /admin/subscriptions loads tiers from API filtered by platform instead of hardcoded options, sends tier_code (not tier) to match PATCH schema, and shows platform context. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
346 lines
11 KiB
Python
346 lines
11 KiB
Python
# app/modules/billing/services/admin_subscription_service.py
|
|
"""
|
|
Admin Subscription Service.
|
|
|
|
Handles subscription management operations for platform administrators:
|
|
- Subscription tier CRUD
|
|
- Merchant subscription management
|
|
- Billing history queries
|
|
- Subscription analytics
|
|
"""
|
|
|
|
import logging
|
|
from math import ceil
|
|
|
|
from sqlalchemy import func
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.exceptions import (
|
|
BusinessLogicException,
|
|
ConflictException,
|
|
ResourceNotFoundException,
|
|
)
|
|
from app.modules.billing.exceptions import TierNotFoundException
|
|
from app.modules.billing.models import (
|
|
BillingHistory,
|
|
MerchantSubscription,
|
|
SubscriptionStatus,
|
|
SubscriptionTier,
|
|
)
|
|
from app.modules.tenancy.models import Merchant
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class AdminSubscriptionService:
|
|
"""Service for admin subscription management operations."""
|
|
|
|
# =========================================================================
|
|
# Subscription Tiers
|
|
# =========================================================================
|
|
|
|
def get_tiers(
|
|
self, db: Session, include_inactive: bool = False, platform_id: int | None = None
|
|
) -> list[SubscriptionTier]:
|
|
"""Get all subscription tiers, optionally filtered by platform."""
|
|
query = db.query(SubscriptionTier)
|
|
|
|
if not include_inactive:
|
|
query = query.filter(SubscriptionTier.is_active == 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()
|
|
|
|
def get_tier_by_code(self, db: Session, tier_code: str) -> SubscriptionTier:
|
|
"""Get a subscription tier by code."""
|
|
tier = (
|
|
db.query(SubscriptionTier)
|
|
.filter(SubscriptionTier.code == tier_code)
|
|
.first()
|
|
)
|
|
|
|
if not tier:
|
|
raise TierNotFoundException(tier_code)
|
|
|
|
return tier
|
|
|
|
def create_tier(self, db: Session, tier_data: dict) -> SubscriptionTier:
|
|
"""Create a new subscription tier."""
|
|
# Check for duplicate code
|
|
existing = (
|
|
db.query(SubscriptionTier)
|
|
.filter(SubscriptionTier.code == tier_data["code"])
|
|
.first()
|
|
)
|
|
if existing:
|
|
raise ConflictException(
|
|
f"Tier with code '{tier_data['code']}' already exists"
|
|
)
|
|
|
|
tier = SubscriptionTier(**tier_data)
|
|
db.add(tier)
|
|
|
|
logger.info(f"Created subscription tier: {tier.code}")
|
|
return tier
|
|
|
|
def update_tier(
|
|
self, db: Session, tier_code: str, update_data: dict
|
|
) -> SubscriptionTier:
|
|
"""Update a subscription tier."""
|
|
tier = self.get_tier_by_code(db, tier_code)
|
|
|
|
for field, value in update_data.items():
|
|
setattr(tier, field, value)
|
|
|
|
logger.info(f"Updated subscription tier: {tier.code}")
|
|
return tier
|
|
|
|
def deactivate_tier(self, db: Session, tier_code: str) -> None:
|
|
"""Soft-delete a subscription tier."""
|
|
tier = self.get_tier_by_code(db, tier_code)
|
|
|
|
# Check if any active subscriptions use this tier (by tier_id FK)
|
|
active_subs = (
|
|
db.query(MerchantSubscription)
|
|
.filter(
|
|
MerchantSubscription.tier_id == tier.id,
|
|
MerchantSubscription.status.in_([
|
|
SubscriptionStatus.ACTIVE.value,
|
|
SubscriptionStatus.TRIAL.value,
|
|
]),
|
|
)
|
|
.count()
|
|
)
|
|
|
|
if active_subs > 0:
|
|
raise BusinessLogicException(
|
|
f"Cannot delete tier: {active_subs} active subscriptions are using it"
|
|
)
|
|
|
|
tier.is_active = False
|
|
|
|
logger.info(f"Soft-deleted subscription tier: {tier.code}")
|
|
|
|
# =========================================================================
|
|
# Merchant Subscriptions
|
|
# =========================================================================
|
|
|
|
def list_subscriptions(
|
|
self,
|
|
db: Session,
|
|
page: int = 1,
|
|
per_page: int = 20,
|
|
status: str | None = None,
|
|
tier: str | None = None,
|
|
search: str | None = None,
|
|
) -> dict:
|
|
"""List merchant subscriptions with filtering and pagination."""
|
|
query = (
|
|
db.query(MerchantSubscription, Merchant)
|
|
.join(Merchant, MerchantSubscription.merchant_id == Merchant.id)
|
|
)
|
|
|
|
# Apply filters
|
|
if status:
|
|
query = query.filter(MerchantSubscription.status == status)
|
|
if tier:
|
|
query = query.join(
|
|
SubscriptionTier, MerchantSubscription.tier_id == SubscriptionTier.id
|
|
).filter(SubscriptionTier.code == tier)
|
|
if search:
|
|
query = query.filter(Merchant.name.ilike(f"%{search}%"))
|
|
|
|
# Count total
|
|
total = query.count()
|
|
|
|
# Paginate
|
|
offset = (page - 1) * per_page
|
|
results = (
|
|
query.order_by(MerchantSubscription.created_at.desc())
|
|
.offset(offset)
|
|
.limit(per_page)
|
|
.all()
|
|
)
|
|
|
|
return {
|
|
"results": results,
|
|
"total": total,
|
|
"page": page,
|
|
"per_page": per_page,
|
|
"pages": ceil(total / per_page) if total > 0 else 0,
|
|
}
|
|
|
|
def get_subscription(
|
|
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)
|
|
.filter(
|
|
MerchantSubscription.merchant_id == merchant_id,
|
|
MerchantSubscription.platform_id == platform_id,
|
|
)
|
|
.first()
|
|
)
|
|
|
|
if not result:
|
|
raise ResourceNotFoundException(
|
|
"Subscription",
|
|
f"merchant_id={merchant_id}, platform_id={platform_id}",
|
|
)
|
|
|
|
return result
|
|
|
|
def update_subscription(
|
|
self, db: Session, merchant_id: int, platform_id: int, update_data: dict
|
|
) -> tuple:
|
|
"""Update a merchant's subscription."""
|
|
result = self.get_subscription(db, merchant_id, platform_id)
|
|
sub, merchant = result
|
|
|
|
for field, value in update_data.items():
|
|
setattr(sub, field, value)
|
|
|
|
logger.info(
|
|
f"Admin updated subscription for merchant {merchant_id} "
|
|
f"on platform {platform_id}: {list(update_data.keys())}"
|
|
)
|
|
|
|
return sub, merchant
|
|
|
|
# =========================================================================
|
|
# Billing History
|
|
# =========================================================================
|
|
|
|
def list_billing_history(
|
|
self,
|
|
db: Session,
|
|
page: int = 1,
|
|
per_page: int = 20,
|
|
merchant_id: int | None = None,
|
|
status: str | None = None,
|
|
) -> dict:
|
|
"""List billing history across all merchants."""
|
|
query = (
|
|
db.query(BillingHistory, Merchant)
|
|
.join(Merchant, BillingHistory.merchant_id == Merchant.id)
|
|
)
|
|
|
|
if merchant_id:
|
|
query = query.filter(BillingHistory.merchant_id == merchant_id)
|
|
if status:
|
|
query = query.filter(BillingHistory.status == status)
|
|
|
|
total = query.count()
|
|
|
|
offset = (page - 1) * per_page
|
|
results = (
|
|
query.order_by(BillingHistory.invoice_date.desc())
|
|
.offset(offset)
|
|
.limit(per_page)
|
|
.all()
|
|
)
|
|
|
|
return {
|
|
"results": results,
|
|
"total": total,
|
|
"page": page,
|
|
"per_page": per_page,
|
|
"pages": ceil(total / per_page) if total > 0 else 0,
|
|
}
|
|
|
|
# =========================================================================
|
|
# Statistics
|
|
# =========================================================================
|
|
|
|
def get_stats(self, db: Session) -> dict:
|
|
"""Get subscription statistics for admin dashboard."""
|
|
# Count by status
|
|
status_counts = (
|
|
db.query(
|
|
MerchantSubscription.status,
|
|
func.count(MerchantSubscription.id),
|
|
)
|
|
.group_by(MerchantSubscription.status)
|
|
.all()
|
|
)
|
|
|
|
stats = {
|
|
"total_subscriptions": 0,
|
|
"active_count": 0,
|
|
"trial_count": 0,
|
|
"past_due_count": 0,
|
|
"cancelled_count": 0,
|
|
"expired_count": 0,
|
|
}
|
|
|
|
for sub_status, count in status_counts:
|
|
stats["total_subscriptions"] += count
|
|
if sub_status == SubscriptionStatus.ACTIVE.value:
|
|
stats["active_count"] = count
|
|
elif sub_status == SubscriptionStatus.TRIAL.value:
|
|
stats["trial_count"] = count
|
|
elif sub_status == SubscriptionStatus.PAST_DUE.value:
|
|
stats["past_due_count"] = count
|
|
elif sub_status == SubscriptionStatus.CANCELLED.value:
|
|
stats["cancelled_count"] = count
|
|
elif sub_status == SubscriptionStatus.EXPIRED.value:
|
|
stats["expired_count"] = count
|
|
|
|
# Count by tier (join with SubscriptionTier to get tier name)
|
|
tier_counts = (
|
|
db.query(SubscriptionTier.name, func.count(MerchantSubscription.id))
|
|
.join(
|
|
SubscriptionTier,
|
|
MerchantSubscription.tier_id == SubscriptionTier.id,
|
|
)
|
|
.filter(
|
|
MerchantSubscription.status.in_([
|
|
SubscriptionStatus.ACTIVE.value,
|
|
SubscriptionStatus.TRIAL.value,
|
|
])
|
|
)
|
|
.group_by(SubscriptionTier.name)
|
|
.all()
|
|
)
|
|
|
|
tier_distribution = {tier_name: count for tier_name, count in tier_counts}
|
|
|
|
# Calculate MRR (Monthly Recurring Revenue)
|
|
mrr_cents = 0
|
|
arr_cents = 0
|
|
|
|
active_subs = (
|
|
db.query(MerchantSubscription, SubscriptionTier)
|
|
.join(
|
|
SubscriptionTier,
|
|
MerchantSubscription.tier_id == SubscriptionTier.id,
|
|
)
|
|
.filter(MerchantSubscription.status == SubscriptionStatus.ACTIVE.value)
|
|
.all()
|
|
)
|
|
|
|
for sub, sub_tier in active_subs:
|
|
if sub.is_annual and sub_tier.price_annual_cents:
|
|
mrr_cents += sub_tier.price_annual_cents // 12
|
|
arr_cents += sub_tier.price_annual_cents
|
|
else:
|
|
mrr_cents += sub_tier.price_monthly_cents
|
|
arr_cents += sub_tier.price_monthly_cents * 12
|
|
|
|
stats["tier_distribution"] = tier_distribution
|
|
stats["mrr_cents"] = mrr_cents
|
|
stats["arr_cents"] = arr_cents
|
|
|
|
return stats
|
|
|
|
|
|
# Singleton instance
|
|
admin_subscription_service = AdminSubscriptionService()
|