MIGRATION: - Delete app/api/v1/vendor/analytics.py (duplicate - analytics module already auto-discovered) - Move usage routes from app/api/v1/vendor/usage.py to billing module - Move onboarding routes from app/api/v1/vendor/onboarding.py to marketplace module - Move features routes to billing module (admin + vendor) - Move inventory routes to inventory module (admin + vendor) - Move marketplace/letzshop routes to marketplace module - Move orders routes to orders module - Delete legacy letzshop service files (moved to marketplace module) DOCUMENTATION: - Add docs/development/migration/module-autodiscovery-migration.md with full migration history - Update docs/architecture/module-system.md with Entity Auto-Discovery Reference section - Add detailed sections for each entity type: routes, services, models, schemas, tasks, exceptions, templates, static files, locales, configuration ARCHITECTURE VALIDATION: - Add MOD-016: Routes must be in modules, not app/api/v1/ - Add MOD-017: Services must be in modules, not app/services/ - Add MOD-018: Tasks must be in modules, not app/tasks/ - Add MOD-019: Schemas must be in modules, not models/schema/ - Update scripts/validate_architecture.py with _validate_legacy_locations method - Update .architecture-rules/module.yaml with legacy location rules These rules enforce that all entities must be in self-contained modules. Legacy locations now trigger ERROR severity violations. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
346 lines
12 KiB
Python
346 lines
12 KiB
Python
# app/modules/billing/routes/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.
|
|
"""
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Depends, 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 models.database.user import User
|
|
from app.modules.billing.schemas import (
|
|
BillingHistoryListResponse,
|
|
BillingHistoryWithVendor,
|
|
SubscriptionStatsResponse,
|
|
SubscriptionTierCreate,
|
|
SubscriptionTierListResponse,
|
|
SubscriptionTierResponse,
|
|
SubscriptionTierUpdate,
|
|
VendorSubscriptionCreate,
|
|
VendorSubscriptionListResponse,
|
|
VendorSubscriptionResponse,
|
|
VendorSubscriptionUpdate,
|
|
VendorSubscriptionWithVendor,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Admin router with module access control
|
|
admin_router = APIRouter(
|
|
prefix="/subscriptions",
|
|
dependencies=[Depends(require_module_access("billing"))],
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Subscription Tier Endpoints
|
|
# ============================================================================
|
|
|
|
|
|
@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),
|
|
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),
|
|
)
|
|
|
|
|
|
@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),
|
|
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: User = 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: User = 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: User = 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.
|
|
"""
|
|
admin_subscription_service.deactivate_tier(db, tier_code)
|
|
db.commit()
|
|
|
|
|
|
# ============================================================================
|
|
# Vendor Subscription Endpoints
|
|
# ============================================================================
|
|
|
|
|
|
@admin_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_api),
|
|
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,
|
|
}
|
|
subscriptions.append(VendorSubscriptionWithVendor(**sub_dict))
|
|
|
|
return VendorSubscriptionListResponse(
|
|
subscriptions=subscriptions,
|
|
total=data["total"],
|
|
page=data["page"],
|
|
per_page=data["per_page"],
|
|
pages=data["pages"],
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Statistics Endpoints
|
|
# ============================================================================
|
|
|
|
|
|
@admin_router.get("/stats", response_model=SubscriptionStatsResponse)
|
|
def get_subscription_stats(
|
|
current_user: User = 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),
|
|
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_api),
|
|
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"],
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# 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
|
|
# ============================================================================
|
|
# 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"])
|