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:
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
120
app/api/v1/vendor/billing.py
vendored
120
app/api/v1/vendor/billing.py
vendored
@@ -179,6 +179,37 @@ class CancelResponse(BaseModel):
|
||||
effective_date: str
|
||||
|
||||
|
||||
class UpcomingInvoiceResponse(BaseModel):
|
||||
"""Upcoming invoice preview."""
|
||||
|
||||
amount_due_cents: int
|
||||
currency: str
|
||||
next_payment_date: str | None = None
|
||||
line_items: list[dict] = []
|
||||
|
||||
|
||||
class ChangeTierRequest(BaseModel):
|
||||
"""Request to change subscription tier."""
|
||||
|
||||
tier_code: str
|
||||
is_annual: bool = False
|
||||
|
||||
|
||||
class ChangeTierResponse(BaseModel):
|
||||
"""Response for tier change."""
|
||||
|
||||
message: str
|
||||
new_tier: str
|
||||
effective_immediately: bool
|
||||
|
||||
|
||||
class AddOnCancelResponse(BaseModel):
|
||||
"""Response for add-on cancellation."""
|
||||
|
||||
message: str
|
||||
addon_code: str
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Endpoints
|
||||
# ============================================================================
|
||||
@@ -403,3 +434,92 @@ def reactivate_subscription(
|
||||
db.commit()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/upcoming-invoice", response_model=UpcomingInvoiceResponse)
|
||||
def get_upcoming_invoice(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Preview the upcoming invoice."""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
result = billing_service.get_upcoming_invoice(db, vendor_id)
|
||||
|
||||
return UpcomingInvoiceResponse(
|
||||
amount_due_cents=result.get("amount_due_cents", 0),
|
||||
currency=result.get("currency", "EUR"),
|
||||
next_payment_date=result.get("next_payment_date"),
|
||||
line_items=result.get("line_items", []),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/change-tier", response_model=ChangeTierResponse)
|
||||
def change_tier(
|
||||
request: ChangeTierRequest,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Change subscription tier (upgrade/downgrade)."""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
result = billing_service.change_tier(
|
||||
db=db,
|
||||
vendor_id=vendor_id,
|
||||
new_tier_code=request.tier_code,
|
||||
is_annual=request.is_annual,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return ChangeTierResponse(
|
||||
message=result["message"],
|
||||
new_tier=result["new_tier"],
|
||||
effective_immediately=result["effective_immediately"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/addons/purchase")
|
||||
def purchase_addon(
|
||||
request: AddOnPurchaseRequest,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Purchase an add-on product."""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
vendor = billing_service.get_vendor(db, vendor_id)
|
||||
|
||||
# Build URLs
|
||||
base_url = f"https://{settings.platform_domain}"
|
||||
success_url = f"{base_url}/vendor/{vendor.vendor_code}/billing?addon_success=true"
|
||||
cancel_url = f"{base_url}/vendor/{vendor.vendor_code}/billing?addon_cancelled=true"
|
||||
|
||||
result = billing_service.purchase_addon(
|
||||
db=db,
|
||||
vendor_id=vendor_id,
|
||||
addon_code=request.addon_code,
|
||||
domain_name=request.domain_name,
|
||||
quantity=request.quantity,
|
||||
success_url=success_url,
|
||||
cancel_url=cancel_url,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/addons/{addon_id}", response_model=AddOnCancelResponse)
|
||||
def cancel_addon(
|
||||
addon_id: int,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Cancel a purchased add-on."""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
result = billing_service.cancel_addon(db, vendor_id, addon_id)
|
||||
db.commit()
|
||||
|
||||
return AddOnCancelResponse(
|
||||
message=result["message"],
|
||||
addon_code=result["addon_code"],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user