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:
@@ -365,6 +365,224 @@ class BillingService:
|
||||
|
||||
return {"message": "Subscription reactivated successfully"}
|
||||
|
||||
def get_upcoming_invoice(self, db: Session, vendor_id: int) -> dict:
|
||||
"""
|
||||
Get upcoming invoice preview.
|
||||
|
||||
Returns:
|
||||
Dict with amount_due_cents, currency, next_payment_date, line_items
|
||||
|
||||
Raises:
|
||||
NoActiveSubscriptionError: If no subscription with customer ID
|
||||
"""
|
||||
subscription = subscription_service.get_subscription(db, vendor_id)
|
||||
|
||||
if not subscription or not subscription.stripe_customer_id:
|
||||
raise NoActiveSubscriptionError()
|
||||
|
||||
if not stripe_service.is_configured:
|
||||
# Return empty preview if Stripe not configured
|
||||
return {
|
||||
"amount_due_cents": 0,
|
||||
"currency": "EUR",
|
||||
"next_payment_date": None,
|
||||
"line_items": [],
|
||||
}
|
||||
|
||||
invoice = stripe_service.get_upcoming_invoice(subscription.stripe_customer_id)
|
||||
|
||||
if not invoice:
|
||||
return {
|
||||
"amount_due_cents": 0,
|
||||
"currency": "EUR",
|
||||
"next_payment_date": None,
|
||||
"line_items": [],
|
||||
}
|
||||
|
||||
line_items = []
|
||||
if invoice.lines and invoice.lines.data:
|
||||
for line in invoice.lines.data:
|
||||
line_items.append({
|
||||
"description": line.description or "",
|
||||
"amount_cents": line.amount,
|
||||
"quantity": line.quantity or 1,
|
||||
})
|
||||
|
||||
return {
|
||||
"amount_due_cents": invoice.amount_due,
|
||||
"currency": invoice.currency.upper(),
|
||||
"next_payment_date": datetime.fromtimestamp(invoice.next_payment_attempt).isoformat()
|
||||
if invoice.next_payment_attempt
|
||||
else None,
|
||||
"line_items": line_items,
|
||||
}
|
||||
|
||||
def change_tier(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
new_tier_code: str,
|
||||
is_annual: bool,
|
||||
) -> dict:
|
||||
"""
|
||||
Change subscription tier (upgrade/downgrade).
|
||||
|
||||
Returns:
|
||||
Dict with message, new_tier, effective_immediately
|
||||
|
||||
Raises:
|
||||
TierNotFoundError: If tier doesn't exist
|
||||
NoActiveSubscriptionError: If no subscription
|
||||
StripePriceNotConfiguredError: If price not configured
|
||||
"""
|
||||
subscription = subscription_service.get_subscription(db, vendor_id)
|
||||
|
||||
if not subscription or not subscription.stripe_subscription_id:
|
||||
raise NoActiveSubscriptionError()
|
||||
|
||||
tier = self.get_tier_by_code(db, new_tier_code)
|
||||
|
||||
price_id = (
|
||||
tier.stripe_price_annual_id
|
||||
if is_annual and tier.stripe_price_annual_id
|
||||
else tier.stripe_price_monthly_id
|
||||
)
|
||||
|
||||
if not price_id:
|
||||
raise StripePriceNotConfiguredError(new_tier_code)
|
||||
|
||||
# Update in Stripe
|
||||
if stripe_service.is_configured:
|
||||
stripe_service.update_subscription(
|
||||
subscription_id=subscription.stripe_subscription_id,
|
||||
new_price_id=price_id,
|
||||
)
|
||||
|
||||
# Update local subscription
|
||||
old_tier = subscription.tier
|
||||
subscription.tier = new_tier_code
|
||||
subscription.tier_id = tier.id
|
||||
subscription.is_annual = is_annual
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
is_upgrade = self._is_upgrade(db, old_tier, new_tier_code)
|
||||
|
||||
return {
|
||||
"message": f"Subscription {'upgraded' if is_upgrade else 'changed'} to {tier.name}",
|
||||
"new_tier": new_tier_code,
|
||||
"effective_immediately": True,
|
||||
}
|
||||
|
||||
def _is_upgrade(self, db: Session, old_tier: str, new_tier: str) -> bool:
|
||||
"""Check if tier change is an upgrade."""
|
||||
old = db.query(SubscriptionTier).filter(SubscriptionTier.code == old_tier).first()
|
||||
new = db.query(SubscriptionTier).filter(SubscriptionTier.code == new_tier).first()
|
||||
|
||||
if not old or not new:
|
||||
return False
|
||||
|
||||
return new.display_order > old.display_order
|
||||
|
||||
def purchase_addon(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
addon_code: str,
|
||||
domain_name: str | None,
|
||||
quantity: int,
|
||||
success_url: str,
|
||||
cancel_url: str,
|
||||
) -> dict:
|
||||
"""
|
||||
Create checkout session for add-on purchase.
|
||||
|
||||
Returns:
|
||||
Dict with checkout_url and session_id
|
||||
|
||||
Raises:
|
||||
PaymentSystemNotConfiguredError: If Stripe not configured
|
||||
AddonNotFoundError: If addon doesn't exist
|
||||
"""
|
||||
if not stripe_service.is_configured:
|
||||
raise PaymentSystemNotConfiguredError()
|
||||
|
||||
addon = (
|
||||
db.query(AddOnProduct)
|
||||
.filter(
|
||||
AddOnProduct.code == addon_code,
|
||||
AddOnProduct.is_active == True, # noqa: E712
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not addon:
|
||||
raise BillingServiceError(f"Add-on '{addon_code}' not found")
|
||||
|
||||
if not addon.stripe_price_id:
|
||||
raise BillingServiceError(f"Stripe price not configured for add-on '{addon_code}'")
|
||||
|
||||
vendor = self.get_vendor(db, vendor_id)
|
||||
subscription = subscription_service.get_or_create_subscription(db, vendor_id)
|
||||
|
||||
# Create checkout session for add-on
|
||||
session = stripe_service.create_checkout_session(
|
||||
db=db,
|
||||
vendor=vendor,
|
||||
price_id=addon.stripe_price_id,
|
||||
success_url=success_url,
|
||||
cancel_url=cancel_url,
|
||||
quantity=quantity,
|
||||
metadata={
|
||||
"addon_code": addon_code,
|
||||
"domain_name": domain_name or "",
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"checkout_url": session.url,
|
||||
"session_id": session.id,
|
||||
}
|
||||
|
||||
def cancel_addon(self, db: Session, vendor_id: int, addon_id: int) -> dict:
|
||||
"""
|
||||
Cancel a purchased add-on.
|
||||
|
||||
Returns:
|
||||
Dict with message and addon_code
|
||||
|
||||
Raises:
|
||||
BillingServiceError: If addon not found or not owned by vendor
|
||||
"""
|
||||
vendor_addon = (
|
||||
db.query(VendorAddOn)
|
||||
.filter(
|
||||
VendorAddOn.id == addon_id,
|
||||
VendorAddOn.vendor_id == vendor_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not vendor_addon:
|
||||
raise BillingServiceError("Add-on not found")
|
||||
|
||||
addon_code = vendor_addon.addon_product.code
|
||||
|
||||
# Cancel in Stripe if applicable
|
||||
if stripe_service.is_configured and vendor_addon.stripe_subscription_item_id:
|
||||
try:
|
||||
stripe_service.cancel_subscription_item(vendor_addon.stripe_subscription_item_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cancel addon in Stripe: {e}")
|
||||
|
||||
# Mark as cancelled
|
||||
vendor_addon.status = "cancelled"
|
||||
vendor_addon.cancelled_at = datetime.utcnow()
|
||||
|
||||
return {
|
||||
"message": "Add-on cancelled successfully",
|
||||
"addon_code": addon_code,
|
||||
}
|
||||
|
||||
|
||||
# Create service instance
|
||||
billing_service = BillingService()
|
||||
|
||||
Reference in New Issue
Block a user