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

@@ -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"],
)