feat: complete subscription billing system phases 6-10

Phase 6 - Database-driven tiers:
- Update subscription_service to query database first with legacy fallback
- Add get_tier_info() db parameter and _get_tier_from_legacy() method

Phase 7 - Platform health integration:
- Add get_subscription_capacity() for theoretical vs actual capacity
- Include subscription capacity in full health report

Phase 8 - Background subscription tasks:
- Add reset_period_counters() for billing period resets
- Add check_trial_expirations() for trial management
- Add sync_stripe_status() for Stripe synchronization
- Add cleanup_stale_subscriptions() for maintenance
- Add capture_capacity_snapshot() for daily metrics

Phase 10 - Capacity planning & forecasting:
- Add CapacitySnapshot model for historical tracking
- Create capacity_forecast_service with growth trends
- Add /subscription-capacity, /trends, /recommendations endpoints
- Add /snapshot endpoint for manual captures

Also includes billing API enhancements from phase 4:
- Add upcoming-invoice, change-tier, addon purchase/cancel endpoints
- Add UsageSummary schema for billing page
- Enhance billing.js with addon management functions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-26 20:51:13 +01:00
parent b717c23787
commit c6e7f4087f
20 changed files with 1895 additions and 29 deletions

View File

@@ -144,3 +144,71 @@ async def get_capacity_metrics(
"""Get capacity-focused metrics for planning."""
metrics = platform_health_service.get_capacity_metrics(db)
return CapacityMetricsResponse(**metrics)
@router.get("/subscription-capacity")
async def get_subscription_capacity(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Get subscription-based capacity metrics.
Shows theoretical vs actual capacity based on all vendor subscriptions.
"""
return platform_health_service.get_subscription_capacity(db)
@router.get("/trends")
async def get_growth_trends(
days: int = 30,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Get growth trends over the specified period.
Returns growth rates and projections for key metrics.
"""
from app.services.capacity_forecast_service import capacity_forecast_service
return capacity_forecast_service.get_growth_trends(db, days=days)
@router.get("/recommendations")
async def get_scaling_recommendations(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Get scaling recommendations based on current capacity and growth.
Returns prioritized list of recommendations.
"""
from app.services.capacity_forecast_service import capacity_forecast_service
return capacity_forecast_service.get_scaling_recommendations(db)
@router.post("/snapshot")
async def capture_snapshot(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Manually capture a capacity snapshot.
Normally run automatically by daily background job.
"""
from app.services.capacity_forecast_service import capacity_forecast_service
snapshot = capacity_forecast_service.capture_daily_snapshot(db)
db.commit()
return {
"id": snapshot.id,
"snapshot_date": snapshot.snapshot_date.isoformat(),
"total_vendors": snapshot.total_vendors,
"total_products": snapshot.total_products,
"message": "Snapshot captured successfully",
}

View File

@@ -12,12 +12,16 @@ Provides endpoints for platform administrators to manage:
import logging
from fastapi import APIRouter, Depends, Path, Query
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.services.admin_subscription_service import admin_subscription_service
from models.database.product import Product
from models.database.user import User
from models.database.vendor import VendorUser
from app.services.subscription_service import subscription_service
from models.schema.billing import (
BillingHistoryListResponse,
BillingHistoryWithVendor,
@@ -26,6 +30,7 @@ from models.schema.billing import (
SubscriptionTierListResponse,
SubscriptionTierResponse,
SubscriptionTierUpdate,
VendorSubscriptionCreate,
VendorSubscriptionListResponse,
VendorSubscriptionResponse,
VendorSubscriptionUpdate,
@@ -144,9 +149,6 @@ def list_vendor_subscriptions(
**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))
@@ -231,6 +233,73 @@ def list_billing_history(
# ============================================================================
@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.
"""
from models.database.vendor import Vendor
# Verify vendor exists
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
from app.exceptions import ResourceNotFoundException
raise ResourceNotFoundException("Vendor", str(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
products_count = (
db.query(func.count(Product.id))
.filter(Product.vendor_id == vendor_id)
.scalar()
or 0
)
team_count = (
db.query(func.count(VendorUser.id))
.filter(
VendorUser.vendor_id == vendor_id,
VendorUser.is_active == True, # noqa: E712
)
.scalar()
or 0
)
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=products_count,
team_count=team_count,
)
@router.get("/{vendor_id}", response_model=VendorSubscriptionWithVendor)
def get_vendor_subscription(
vendor_id: int = Path(..., description="Vendor ID"),
@@ -240,13 +309,30 @@ def get_vendor_subscription(
"""Get subscription details for a specific vendor."""
sub, vendor = admin_subscription_service.get_subscription(db, vendor_id)
# Get usage counts
products_count = (
db.query(func.count(Product.id))
.filter(Product.vendor_id == vendor_id)
.scalar()
or 0
)
team_count = (
db.query(func.count(VendorUser.id))
.filter(
VendorUser.vendor_id == vendor_id,
VendorUser.is_active == True, # noqa: E712
)
.scalar()
or 0
)
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,
products_count=products_count,
team_count=team_count,
)
@@ -271,11 +357,28 @@ def update_vendor_subscription(
db.commit()
db.refresh(sub)
# Get usage counts
products_count = (
db.query(func.count(Product.id))
.filter(Product.vendor_id == vendor_id)
.scalar()
or 0
)
team_count = (
db.query(func.count(VendorUser.id))
.filter(
VendorUser.vendor_id == vendor_id,
VendorUser.is_active == True, # noqa: E712
)
.scalar()
or 0
)
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,
products_count=products_count,
team_count=team_count,
)

View File

@@ -19,6 +19,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.services.subscription_service import subscription_service
from app.services.vendor_product_service import vendor_product_service
from models.database.user import User
from models.schema.vendor_product import (
@@ -119,6 +120,9 @@ def create_vendor_product(
current_admin: User = Depends(get_current_admin_api),
):
"""Create a new vendor product."""
# Check product limit before creating
subscription_service.check_product_limit(db, data.vendor_id)
product = vendor_product_service.create_product(db, data.model_dump())
db.commit() # ✅ ARCH: Commit at API level for transaction control
return VendorProductCreateResponse(