feat(billing): migrate frontend templates to feature provider system
Replace hardcoded subscription fields (orders_limit, products_limit,
team_members_limit) across 5 frontend pages with dynamic feature
provider APIs. Add admin convenience endpoint for store subscription
lookup. Remove legacy stubs (StoreSubscription, FeatureCode, Feature,
TIER_LIMITS, FeatureInfo, FeatureUpgradeInfo) and schema aliases.
Pages updated:
- Admin subscriptions: dynamic feature overrides editor
- Admin tiers: correct feature catalog/limits API URLs
- Store billing: usage metrics from /store/billing/usage
- Merchant subscription detail: tier.feature_limits rendering
- Admin store detail: new GET /admin/subscriptions/store/{id} endpoint
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,39 +1,38 @@
|
||||
# app/modules/billing/routes/admin.py
|
||||
# app/modules/billing/routes/api/admin.py
|
||||
"""
|
||||
Billing module admin routes.
|
||||
|
||||
This module wraps the existing admin subscription routes and adds
|
||||
module-based access control. The actual route implementations remain
|
||||
in app/api/v1/admin/subscriptions.py for now, but are accessed through
|
||||
this module-aware router.
|
||||
|
||||
Future: Move all route implementations here for full module isolation.
|
||||
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, Path, Query
|
||||
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.modules.billing.services import admin_subscription_service, subscription_service
|
||||
from app.modules.enums import FrontendType
|
||||
from app.modules.tenancy.models import User
|
||||
from app.modules.billing.schemas import (
|
||||
BillingHistoryListResponse,
|
||||
BillingHistoryWithVendor,
|
||||
BillingHistoryWithMerchant,
|
||||
MerchantSubscriptionAdminCreate,
|
||||
MerchantSubscriptionAdminResponse,
|
||||
MerchantSubscriptionAdminUpdate,
|
||||
MerchantSubscriptionListResponse,
|
||||
MerchantSubscriptionWithMerchant,
|
||||
SubscriptionStatsResponse,
|
||||
SubscriptionTierCreate,
|
||||
SubscriptionTierListResponse,
|
||||
SubscriptionTierResponse,
|
||||
SubscriptionTierUpdate,
|
||||
VendorSubscriptionCreate,
|
||||
VendorSubscriptionListResponse,
|
||||
VendorSubscriptionResponse,
|
||||
VendorSubscriptionUpdate,
|
||||
VendorSubscriptionWithVendor,
|
||||
)
|
||||
from models.schema.auth import UserContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -52,14 +51,10 @@ admin_router = APIRouter(
|
||||
@admin_router.get("/tiers", response_model=SubscriptionTierListResponse)
|
||||
def list_subscription_tiers(
|
||||
include_inactive: bool = Query(False, description="Include inactive tiers"),
|
||||
current_user: User = Depends(get_current_admin_api),
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
List all subscription tiers.
|
||||
|
||||
Returns all tiers with their limits, features, and Stripe configuration.
|
||||
"""
|
||||
"""List all subscription tiers."""
|
||||
tiers = admin_subscription_service.get_tiers(db, include_inactive=include_inactive)
|
||||
|
||||
return SubscriptionTierListResponse(
|
||||
@@ -71,7 +66,7 @@ def list_subscription_tiers(
|
||||
@admin_router.get("/tiers/{tier_code}", response_model=SubscriptionTierResponse)
|
||||
def get_subscription_tier(
|
||||
tier_code: str = Path(..., description="Tier code"),
|
||||
current_user: User = Depends(get_current_admin_api),
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get a specific subscription tier by code."""
|
||||
@@ -82,7 +77,7 @@ def get_subscription_tier(
|
||||
@admin_router.post("/tiers", response_model=SubscriptionTierResponse, status_code=201)
|
||||
def create_subscription_tier(
|
||||
tier_data: SubscriptionTierCreate,
|
||||
current_user: User = Depends(get_current_admin_api),
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Create a new subscription tier."""
|
||||
@@ -96,7 +91,7 @@ def create_subscription_tier(
|
||||
def update_subscription_tier(
|
||||
tier_data: SubscriptionTierUpdate,
|
||||
tier_code: str = Path(..., description="Tier code"),
|
||||
current_user: User = Depends(get_current_admin_api),
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update a subscription tier."""
|
||||
@@ -110,52 +105,48 @@ def update_subscription_tier(
|
||||
@admin_router.delete("/tiers/{tier_code}", status_code=204)
|
||||
def delete_subscription_tier(
|
||||
tier_code: str = Path(..., description="Tier code"),
|
||||
current_user: User = Depends(get_current_admin_api),
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Soft-delete a subscription tier.
|
||||
|
||||
Sets is_active=False rather than deleting to preserve history.
|
||||
"""
|
||||
"""Soft-delete a subscription tier."""
|
||||
admin_subscription_service.deactivate_tier(db, tier_code)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Vendor Subscription Endpoints
|
||||
# Merchant Subscription Endpoints
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@admin_router.get("", response_model=VendorSubscriptionListResponse)
|
||||
def list_vendor_subscriptions(
|
||||
@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"),
|
||||
search: str | None = Query(None, description="Search vendor name"),
|
||||
current_user: User = Depends(get_current_admin_api),
|
||||
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 vendor subscriptions with filtering.
|
||||
|
||||
Includes vendor information for each subscription.
|
||||
"""
|
||||
"""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
|
||||
)
|
||||
|
||||
subscriptions = []
|
||||
for sub, vendor in data["results"]:
|
||||
sub_dict = {
|
||||
**VendorSubscriptionResponse.model_validate(sub).model_dump(),
|
||||
"vendor_name": vendor.name,
|
||||
"vendor_code": vendor.subdomain,
|
||||
}
|
||||
subscriptions.append(VendorSubscriptionWithVendor(**sub_dict))
|
||||
for sub, merchant in data["results"]:
|
||||
sub_resp = MerchantSubscriptionAdminResponse.model_validate(sub)
|
||||
tier_name = sub.tier.name if sub.tier else None
|
||||
subscriptions.append(
|
||||
MerchantSubscriptionWithMerchant(
|
||||
**sub_resp.model_dump(),
|
||||
merchant_name=merchant.name,
|
||||
platform_name="", # Platform name can be resolved if needed
|
||||
tier_name=tier_name,
|
||||
)
|
||||
)
|
||||
|
||||
return VendorSubscriptionListResponse(
|
||||
return MerchantSubscriptionListResponse(
|
||||
subscriptions=subscriptions,
|
||||
total=data["total"],
|
||||
page=data["page"],
|
||||
@@ -164,6 +155,154 @@ def list_vendor_subscriptions(
|
||||
)
|
||||
|
||||
|
||||
@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 subscription + feature usage for a store (resolves to merchant).
|
||||
|
||||
Convenience endpoint for the admin store detail page. Resolves
|
||||
store -> merchant -> subscription internally and returns subscription
|
||||
info with feature usage metrics.
|
||||
"""
|
||||
from app.modules.billing.services.feature_service import feature_service
|
||||
from app.modules.billing.schemas.subscription import FeatureSummaryResponse
|
||||
|
||||
# Resolve store to merchant
|
||||
merchant_id, platform_id = feature_service._get_merchant_for_store(db, store_id)
|
||||
if merchant_id is None or platform_id is None:
|
||||
raise HTTPException(status_code=404, detail="Store not found or has no merchant association")
|
||||
|
||||
# Get subscription
|
||||
try:
|
||||
sub, merchant = admin_subscription_service.get_subscription(
|
||||
db, merchant_id, platform_id
|
||||
)
|
||||
except Exception:
|
||||
return {
|
||||
"subscription": None,
|
||||
"tier": None,
|
||||
"features": [],
|
||||
}
|
||||
|
||||
# Get feature summary
|
||||
features_summary = feature_service.get_merchant_features_summary(db, merchant_id, platform_id)
|
||||
|
||||
# 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,
|
||||
})
|
||||
|
||||
return {
|
||||
"subscription": MerchantSubscriptionAdminResponse.model_validate(sub).model_dump(),
|
||||
"tier": tier_info,
|
||||
"features": usage_metrics,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Statistics Endpoints
|
||||
# ============================================================================
|
||||
@@ -171,7 +310,7 @@ def list_vendor_subscriptions(
|
||||
|
||||
@admin_router.get("/stats", response_model=SubscriptionStatsResponse)
|
||||
def get_subscription_stats(
|
||||
current_user: User = Depends(get_current_admin_api),
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get subscription statistics for admin dashboard."""
|
||||
@@ -188,39 +327,39 @@ def get_subscription_stats(
|
||||
def list_billing_history(
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(20, ge=1, le=100),
|
||||
vendor_id: int | None = Query(None, description="Filter by vendor"),
|
||||
merchant_id: int | None = Query(None, description="Filter by merchant"),
|
||||
status: str | None = Query(None, description="Filter by status"),
|
||||
current_user: User = Depends(get_current_admin_api),
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List billing history (invoices) across all vendors."""
|
||||
"""List billing history (invoices) across all merchants."""
|
||||
data = admin_subscription_service.list_billing_history(
|
||||
db, page=page, per_page=per_page, vendor_id=vendor_id, status=status
|
||||
db, page=page, per_page=per_page, merchant_id=merchant_id, status=status
|
||||
)
|
||||
|
||||
invoices = []
|
||||
for invoice, vendor in data["results"]:
|
||||
invoice_dict = {
|
||||
"id": invoice.id,
|
||||
"vendor_id": invoice.vendor_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,
|
||||
"vendor_name": vendor.name,
|
||||
"vendor_code": vendor.subdomain,
|
||||
}
|
||||
invoices.append(BillingHistoryWithVendor(**invoice_dict))
|
||||
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,
|
||||
@@ -231,112 +370,6 @@ def list_billing_history(
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Vendor Subscription Detail Endpoints
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@admin_router.post("/{vendor_id}", response_model=VendorSubscriptionWithVendor, status_code=201)
|
||||
def create_vendor_subscription(
|
||||
create_data: VendorSubscriptionCreate,
|
||||
vendor_id: int = Path(..., description="Vendor ID"),
|
||||
current_user: User = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Create a subscription for a vendor.
|
||||
|
||||
Creates a new subscription with the specified tier and status.
|
||||
Defaults to Essential tier with trial status.
|
||||
"""
|
||||
# Verify vendor exists
|
||||
vendor = admin_subscription_service.get_vendor(db, vendor_id)
|
||||
|
||||
# Create subscription using the subscription service
|
||||
sub = subscription_service.get_or_create_subscription(
|
||||
db,
|
||||
vendor_id=vendor_id,
|
||||
tier=create_data.tier,
|
||||
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)
|
||||
|
||||
# Get usage counts
|
||||
usage = admin_subscription_service.get_vendor_usage_counts(db, vendor_id)
|
||||
|
||||
logger.info(f"Admin created subscription for vendor {vendor_id}: tier={create_data.tier}")
|
||||
|
||||
return VendorSubscriptionWithVendor(
|
||||
**VendorSubscriptionResponse.model_validate(sub).model_dump(),
|
||||
vendor_name=vendor.name,
|
||||
vendor_code=vendor.subdomain,
|
||||
products_count=usage["products_count"],
|
||||
team_count=usage["team_count"],
|
||||
)
|
||||
|
||||
|
||||
@admin_router.get("/{vendor_id}", response_model=VendorSubscriptionWithVendor)
|
||||
def get_vendor_subscription(
|
||||
vendor_id: int = Path(..., description="Vendor ID"),
|
||||
current_user: User = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get subscription details for a specific vendor."""
|
||||
sub, vendor = admin_subscription_service.get_subscription(db, vendor_id)
|
||||
|
||||
# Get usage counts
|
||||
usage = admin_subscription_service.get_vendor_usage_counts(db, vendor_id)
|
||||
|
||||
return VendorSubscriptionWithVendor(
|
||||
**VendorSubscriptionResponse.model_validate(sub).model_dump(),
|
||||
vendor_name=vendor.name,
|
||||
vendor_code=vendor.subdomain,
|
||||
products_count=usage["products_count"],
|
||||
team_count=usage["team_count"],
|
||||
)
|
||||
|
||||
|
||||
@admin_router.patch("/{vendor_id}", response_model=VendorSubscriptionWithVendor)
|
||||
def update_vendor_subscription(
|
||||
update_data: VendorSubscriptionUpdate,
|
||||
vendor_id: int = Path(..., description="Vendor ID"),
|
||||
current_user: User = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Update a vendor's subscription.
|
||||
|
||||
Allows admins to:
|
||||
- Change tier
|
||||
- Update status
|
||||
- Set custom limit overrides
|
||||
- Extend trial period
|
||||
"""
|
||||
data = update_data.model_dump(exclude_unset=True)
|
||||
sub, vendor = admin_subscription_service.update_subscription(db, vendor_id, data)
|
||||
db.commit()
|
||||
db.refresh(sub)
|
||||
|
||||
# Get usage counts
|
||||
usage = admin_subscription_service.get_vendor_usage_counts(db, vendor_id)
|
||||
|
||||
return VendorSubscriptionWithVendor(
|
||||
**VendorSubscriptionResponse.model_validate(sub).model_dump(),
|
||||
vendor_name=vendor.name,
|
||||
vendor_code=vendor.subdomain,
|
||||
products_count=usage["products_count"],
|
||||
team_count=usage["team_count"],
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Aggregate Feature Management Routes
|
||||
# ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user