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>
386 lines
14 KiB
Python
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"])
|