Files
orion/app/modules/billing/services/admin_subscription_service.py
Samir Boulahtit 0b37274140 fix(subscriptions): fix subscription UI and API after store→merchant migration
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>
2026-02-10 19:17:51 +01:00

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()