feat: add admin frontend for subscription and billing management
Add admin pages for managing subscription tiers, vendor subscriptions, and billing history: - Subscription Tiers page: Create, edit, activate/deactivate tiers - Vendor Subscriptions page: View/edit subscriptions, custom limits - Billing History page: View invoices with filters and PDF links - Stats dashboard with MRR/ARR calculations Also includes: - Pydantic schemas for billing operations (models/schema/billing.py) - Admin subscription service layer for database operations - Security validation fixes (SEC-001, SEC-021, SEC-022) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -46,6 +46,7 @@ from . import (
|
||||
platform_health,
|
||||
products,
|
||||
settings,
|
||||
subscriptions,
|
||||
tests,
|
||||
users,
|
||||
vendor_domains,
|
||||
@@ -173,6 +174,14 @@ router.include_router(
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Billing & Subscriptions
|
||||
# ============================================================================
|
||||
|
||||
# Include subscription management endpoints
|
||||
router.include_router(subscriptions.router, tags=["admin-subscriptions"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Code Quality & Architecture
|
||||
# ============================================================================
|
||||
|
||||
268
app/api/v1/admin/subscriptions.py
Normal file
268
app/api/v1/admin/subscriptions.py
Normal file
@@ -0,0 +1,268 @@
|
||||
# app/api/v1/admin/subscriptions.py
|
||||
"""
|
||||
Admin Subscription Management API.
|
||||
|
||||
Provides endpoints for platform administrators to manage:
|
||||
- Subscription tiers (CRUD)
|
||||
- Vendor subscriptions (view, update, override limits)
|
||||
- Billing history across all vendors
|
||||
- Subscription analytics
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin, get_db
|
||||
from app.services.admin_subscription_service import admin_subscription_service
|
||||
from models.database.user import User
|
||||
from models.schema.billing import (
|
||||
BillingHistoryListResponse,
|
||||
BillingHistoryWithVendor,
|
||||
SubscriptionStatsResponse,
|
||||
SubscriptionTierCreate,
|
||||
SubscriptionTierListResponse,
|
||||
SubscriptionTierResponse,
|
||||
SubscriptionTierUpdate,
|
||||
VendorSubscriptionListResponse,
|
||||
VendorSubscriptionResponse,
|
||||
VendorSubscriptionUpdate,
|
||||
VendorSubscriptionWithVendor,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/subscriptions")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Subscription Tier Endpoints
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@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),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
List all subscription tiers.
|
||||
|
||||
Returns all tiers with their limits, features, and Stripe configuration.
|
||||
"""
|
||||
tiers = admin_subscription_service.get_tiers(db, include_inactive=include_inactive)
|
||||
|
||||
return SubscriptionTierListResponse(
|
||||
tiers=[SubscriptionTierResponse.model_validate(t) for t in tiers],
|
||||
total=len(tiers),
|
||||
)
|
||||
|
||||
|
||||
@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),
|
||||
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)
|
||||
|
||||
|
||||
@router.post("/tiers", response_model=SubscriptionTierResponse, status_code=201)
|
||||
def create_subscription_tier(
|
||||
tier_data: SubscriptionTierCreate,
|
||||
current_user: User = Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Create a new subscription tier."""
|
||||
tier = admin_subscription_service.create_tier(db, tier_data.model_dump())
|
||||
return SubscriptionTierResponse.model_validate(tier)
|
||||
|
||||
|
||||
@router.patch("/tiers/{tier_code}", response_model=SubscriptionTierResponse)
|
||||
def update_subscription_tier(
|
||||
tier_data: SubscriptionTierUpdate,
|
||||
tier_code: str = Path(..., description="Tier code"),
|
||||
current_user: User = Depends(get_current_admin),
|
||||
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)
|
||||
return SubscriptionTierResponse.model_validate(tier)
|
||||
|
||||
|
||||
@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),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Soft-delete a subscription tier.
|
||||
|
||||
Sets is_active=False rather than deleting to preserve history.
|
||||
"""
|
||||
admin_subscription_service.deactivate_tier(db, tier_code)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Vendor Subscription Endpoints
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("", response_model=VendorSubscriptionListResponse)
|
||||
def list_vendor_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),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
List all vendor subscriptions with filtering.
|
||||
|
||||
Includes vendor information for each subscription.
|
||||
"""
|
||||
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,
|
||||
"orders_limit": sub.orders_limit,
|
||||
"products_limit": sub.products_limit,
|
||||
"team_members_limit": sub.team_members_limit,
|
||||
}
|
||||
subscriptions.append(VendorSubscriptionWithVendor(**sub_dict))
|
||||
|
||||
return VendorSubscriptionListResponse(
|
||||
subscriptions=subscriptions,
|
||||
total=data["total"],
|
||||
page=data["page"],
|
||||
per_page=data["per_page"],
|
||||
pages=data["pages"],
|
||||
)
|
||||
|
||||
|
||||
@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),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get subscription details for a specific vendor."""
|
||||
sub, vendor = admin_subscription_service.get_subscription(db, vendor_id)
|
||||
|
||||
return VendorSubscriptionWithVendor(
|
||||
**VendorSubscriptionResponse.model_validate(sub).model_dump(),
|
||||
vendor_name=vendor.name,
|
||||
vendor_code=vendor.subdomain,
|
||||
orders_limit=sub.orders_limit,
|
||||
products_limit=sub.products_limit,
|
||||
team_members_limit=sub.team_members_limit,
|
||||
)
|
||||
|
||||
|
||||
@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),
|
||||
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)
|
||||
|
||||
return VendorSubscriptionWithVendor(
|
||||
**VendorSubscriptionResponse.model_validate(sub).model_dump(),
|
||||
vendor_name=vendor.name,
|
||||
vendor_code=vendor.subdomain,
|
||||
orders_limit=sub.orders_limit,
|
||||
products_limit=sub.products_limit,
|
||||
team_members_limit=sub.team_members_limit,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Billing History Endpoints
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@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),
|
||||
vendor_id: int | None = Query(None, description="Filter by vendor"),
|
||||
status: str | None = Query(None, description="Filter by status"),
|
||||
current_user: User = Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List billing history (invoices) across all vendors."""
|
||||
data = admin_subscription_service.list_billing_history(
|
||||
db, page=page, per_page=per_page, vendor_id=vendor_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))
|
||||
|
||||
return BillingHistoryListResponse(
|
||||
invoices=invoices,
|
||||
total=data["total"],
|
||||
page=data["page"],
|
||||
per_page=data["per_page"],
|
||||
pages=data["pages"],
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Statistics Endpoints
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/stats", response_model=SubscriptionStatsResponse)
|
||||
def get_subscription_stats(
|
||||
current_user: User = Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get subscription statistics for admin dashboard."""
|
||||
stats = admin_subscription_service.get_stats(db)
|
||||
return SubscriptionStatsResponse(**stats)
|
||||
Reference in New Issue
Block a user