Files
orion/app/modules/billing/routes/api/admin.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

386 lines
14 KiB
Python

# app/modules/billing/routes/api/admin.py
"""
Billing module admin routes.
Provides admin API endpoints for subscription and billing management:
- Subscription tier CRUD
- Merchant subscription listing and management
- Billing history
- Subscription statistics
"""
import logging
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api, require_module_access
from app.core.database import get_db
from app.exceptions import ResourceNotFoundException
from app.modules.billing.services import admin_subscription_service, subscription_service
from app.modules.enums import FrontendType
from app.modules.billing.schemas import (
BillingHistoryListResponse,
BillingHistoryWithMerchant,
MerchantSubscriptionAdminCreate,
MerchantSubscriptionAdminResponse,
MerchantSubscriptionAdminUpdate,
MerchantSubscriptionListResponse,
MerchantSubscriptionWithMerchant,
SubscriptionStatsResponse,
SubscriptionTierCreate,
SubscriptionTierListResponse,
SubscriptionTierResponse,
SubscriptionTierUpdate,
)
from models.schema.auth import UserContext
logger = logging.getLogger(__name__)
# Admin router with module access control
admin_router = APIRouter(
prefix="/subscriptions",
dependencies=[Depends(require_module_access("billing", FrontendType.ADMIN))],
)
# ============================================================================
# Subscription Tier Endpoints
# ============================================================================
@admin_router.get("/tiers", response_model=SubscriptionTierListResponse)
def list_subscription_tiers(
include_inactive: bool = Query(False, description="Include inactive tiers"),
platform_id: int | None = Query(None, description="Filter tiers by platform"),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""List all subscription tiers."""
tiers = admin_subscription_service.get_tiers(db, include_inactive=include_inactive, platform_id=platform_id)
return SubscriptionTierListResponse(
tiers=[SubscriptionTierResponse.model_validate(t) for t in tiers],
total=len(tiers),
)
@admin_router.get("/tiers/{tier_code}", response_model=SubscriptionTierResponse)
def get_subscription_tier(
tier_code: str = Path(..., description="Tier code"),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Get a specific subscription tier by code."""
tier = admin_subscription_service.get_tier_by_code(db, tier_code)
return SubscriptionTierResponse.model_validate(tier)
@admin_router.post("/tiers", response_model=SubscriptionTierResponse, status_code=201)
def create_subscription_tier(
tier_data: SubscriptionTierCreate,
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Create a new subscription tier."""
tier = admin_subscription_service.create_tier(db, tier_data.model_dump())
db.commit()
db.refresh(tier)
return SubscriptionTierResponse.model_validate(tier)
@admin_router.patch("/tiers/{tier_code}", response_model=SubscriptionTierResponse)
def update_subscription_tier(
tier_data: SubscriptionTierUpdate,
tier_code: str = Path(..., description="Tier code"),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Update a subscription tier."""
update_data = tier_data.model_dump(exclude_unset=True)
tier = admin_subscription_service.update_tier(db, tier_code, update_data)
db.commit()
db.refresh(tier)
return SubscriptionTierResponse.model_validate(tier)
@admin_router.delete("/tiers/{tier_code}", status_code=204)
def delete_subscription_tier(
tier_code: str = Path(..., description="Tier code"),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Soft-delete a subscription tier."""
admin_subscription_service.deactivate_tier(db, tier_code)
db.commit()
# ============================================================================
# Merchant Subscription Endpoints
# ============================================================================
@admin_router.get("", response_model=MerchantSubscriptionListResponse)
def list_merchant_subscriptions(
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
status: str | None = Query(None, description="Filter by status"),
tier: str | None = Query(None, description="Filter by tier code"),
search: str | None = Query(None, description="Search merchant name"),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""List all merchant subscriptions with filtering."""
data = admin_subscription_service.list_subscriptions(
db, page=page, per_page=per_page, status=status, tier=tier, search=search
)
from app.modules.tenancy.models import Platform
subscriptions = []
for sub, merchant in data["results"]:
sub_resp = MerchantSubscriptionAdminResponse.model_validate(sub)
tier_name = sub.tier.name if sub.tier else None
platform = db.query(Platform).filter(Platform.id == sub.platform_id).first()
subscriptions.append(
MerchantSubscriptionWithMerchant(
**sub_resp.model_dump(),
merchant_name=merchant.name,
platform_name=platform.name if platform else "",
tier_name=tier_name,
)
)
return MerchantSubscriptionListResponse(
subscriptions=subscriptions,
total=data["total"],
page=data["page"],
per_page=data["per_page"],
pages=data["pages"],
)
@admin_router.post(
"/merchants/{merchant_id}/platforms/{platform_id}",
response_model=MerchantSubscriptionAdminResponse,
status_code=201,
)
def create_merchant_subscription(
create_data: MerchantSubscriptionAdminCreate,
merchant_id: int = Path(..., description="Merchant ID"),
platform_id: int = Path(..., description="Platform ID"),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Create a subscription for a merchant on a platform."""
sub = subscription_service.get_or_create_subscription(
db,
merchant_id=merchant_id,
platform_id=platform_id,
tier_code=create_data.tier_code,
trial_days=create_data.trial_days,
)
# Update status if not trial
if create_data.status != "trial":
sub.status = create_data.status
sub.is_annual = create_data.is_annual
db.commit()
db.refresh(sub)
logger.info(
f"Admin created subscription for merchant {merchant_id} "
f"on platform {platform_id}: tier={create_data.tier_code}"
)
return MerchantSubscriptionAdminResponse.model_validate(sub)
@admin_router.get(
"/merchants/{merchant_id}/platforms/{platform_id}",
response_model=MerchantSubscriptionAdminResponse,
)
def get_merchant_subscription(
merchant_id: int = Path(..., description="Merchant ID"),
platform_id: int = Path(..., description="Platform ID"),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Get subscription details for a specific merchant on a platform."""
sub, merchant = admin_subscription_service.get_subscription(
db, merchant_id, platform_id
)
return MerchantSubscriptionAdminResponse.model_validate(sub)
@admin_router.patch(
"/merchants/{merchant_id}/platforms/{platform_id}",
response_model=MerchantSubscriptionAdminResponse,
)
def update_merchant_subscription(
update_data: MerchantSubscriptionAdminUpdate,
merchant_id: int = Path(..., description="Merchant ID"),
platform_id: int = Path(..., description="Platform ID"),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Update a merchant's subscription."""
data = update_data.model_dump(exclude_unset=True)
sub, merchant = admin_subscription_service.update_subscription(
db, merchant_id, platform_id, data
)
db.commit()
db.refresh(sub)
return MerchantSubscriptionAdminResponse.model_validate(sub)
# ============================================================================
# Store Convenience Endpoint
# ============================================================================
@admin_router.get("/store/{store_id}")
def get_subscription_for_store(
store_id: int = Path(..., description="Store ID"),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Get subscriptions + feature usage for a store (resolves to merchant).
Convenience endpoint for the admin store detail page. Resolves
store -> merchant -> all platform subscriptions and returns a list
of subscription entries with feature usage metrics.
"""
from app.modules.billing.services.feature_service import feature_service
from app.modules.tenancy.models import Platform
# Resolve store to merchant + all platform IDs
merchant_id, platform_ids = feature_service._get_merchant_and_platforms_for_store(db, store_id)
if merchant_id is None or not platform_ids:
raise HTTPException(status_code=404, detail="Store not found or has no platform association")
results = []
for pid in platform_ids:
try:
sub, merchant = admin_subscription_service.get_subscription(db, merchant_id, pid)
except ResourceNotFoundException:
continue
# Get feature summary
features_summary = feature_service.get_merchant_features_summary(db, merchant_id, pid)
# Build tier info
tier_info = None
if sub.tier:
tier_info = {
"code": sub.tier.code,
"name": sub.tier.name,
"feature_codes": [fl.feature_code for fl in (sub.tier.feature_limits or [])],
}
# Build usage metrics (quantitative features only)
usage_metrics = []
for fs in features_summary:
if fs.feature_type == "quantitative" and fs.enabled:
usage_metrics.append({
"name": fs.name_key.replace("_", " ").title(),
"current": fs.current or 0,
"limit": fs.limit,
"percentage": fs.percent_used or 0,
"is_unlimited": fs.limit is None,
"is_at_limit": fs.remaining == 0 if fs.remaining is not None else False,
"is_approaching_limit": (fs.percent_used or 0) >= 80,
})
# Resolve platform name
platform = db.query(Platform).filter(Platform.id == pid).first()
results.append({
"subscription": MerchantSubscriptionAdminResponse.model_validate(sub).model_dump(),
"tier": tier_info,
"features": usage_metrics,
"platform_name": platform.name if platform else "",
})
return {"subscriptions": results}
# ============================================================================
# Statistics Endpoints
# ============================================================================
@admin_router.get("/stats", response_model=SubscriptionStatsResponse)
def get_subscription_stats(
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Get subscription statistics for admin dashboard."""
stats = admin_subscription_service.get_stats(db)
return SubscriptionStatsResponse(**stats)
# ============================================================================
# Billing History Endpoints
# ============================================================================
@admin_router.get("/billing/history", response_model=BillingHistoryListResponse)
def list_billing_history(
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
merchant_id: int | None = Query(None, description="Filter by merchant"),
status: str | None = Query(None, description="Filter by status"),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""List billing history (invoices) across all merchants."""
data = admin_subscription_service.list_billing_history(
db, page=page, per_page=per_page, merchant_id=merchant_id, status=status
)
invoices = []
for invoice, merchant in data["results"]:
invoices.append(
BillingHistoryWithMerchant(
id=invoice.id,
merchant_id=invoice.merchant_id,
stripe_invoice_id=invoice.stripe_invoice_id,
invoice_number=invoice.invoice_number,
invoice_date=invoice.invoice_date,
due_date=invoice.due_date,
subtotal_cents=invoice.subtotal_cents,
tax_cents=invoice.tax_cents,
total_cents=invoice.total_cents,
amount_paid_cents=invoice.amount_paid_cents,
currency=invoice.currency,
status=invoice.status,
invoice_pdf_url=invoice.invoice_pdf_url,
hosted_invoice_url=invoice.hosted_invoice_url,
description=invoice.description,
created_at=invoice.created_at,
merchant_name=merchant.name,
)
)
return BillingHistoryListResponse(
invoices=invoices,
total=data["total"],
page=data["page"],
per_page=data["per_page"],
pages=data["pages"],
)
# ============================================================================
# Aggregate Feature Management Routes
# ============================================================================
# Include the features router to aggregate all billing-related admin routes
from app.modules.billing.routes.api.admin_features import admin_features_router
admin_router.include_router(admin_features_router, tags=["admin-features"])