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>
589 lines
18 KiB
Python
589 lines
18 KiB
Python
# app/services/billing_service.py
|
|
"""
|
|
Billing service for subscription and payment operations.
|
|
|
|
Provides:
|
|
- Subscription status and usage queries
|
|
- Tier management
|
|
- Invoice history
|
|
- Add-on management
|
|
"""
|
|
|
|
import logging
|
|
from datetime import datetime
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.services.stripe_service import stripe_service
|
|
from app.services.subscription_service import subscription_service
|
|
from models.database.subscription import (
|
|
AddOnProduct,
|
|
BillingHistory,
|
|
SubscriptionTier,
|
|
VendorAddOn,
|
|
VendorSubscription,
|
|
)
|
|
from models.database.vendor import Vendor
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class BillingServiceError(Exception):
|
|
"""Base exception for billing service errors."""
|
|
|
|
pass
|
|
|
|
|
|
class PaymentSystemNotConfiguredError(BillingServiceError):
|
|
"""Raised when Stripe is not configured."""
|
|
|
|
def __init__(self):
|
|
super().__init__("Payment system not configured")
|
|
|
|
|
|
class TierNotFoundError(BillingServiceError):
|
|
"""Raised when a tier is not found."""
|
|
|
|
def __init__(self, tier_code: str):
|
|
self.tier_code = tier_code
|
|
super().__init__(f"Tier '{tier_code}' not found")
|
|
|
|
|
|
class StripePriceNotConfiguredError(BillingServiceError):
|
|
"""Raised when Stripe price is not configured for a tier."""
|
|
|
|
def __init__(self, tier_code: str):
|
|
self.tier_code = tier_code
|
|
super().__init__(f"Stripe price not configured for tier '{tier_code}'")
|
|
|
|
|
|
class NoActiveSubscriptionError(BillingServiceError):
|
|
"""Raised when no active subscription exists."""
|
|
|
|
def __init__(self):
|
|
super().__init__("No active subscription found")
|
|
|
|
|
|
class SubscriptionNotCancelledError(BillingServiceError):
|
|
"""Raised when trying to reactivate a non-cancelled subscription."""
|
|
|
|
def __init__(self):
|
|
super().__init__("Subscription is not cancelled")
|
|
|
|
|
|
class BillingService:
|
|
"""Service for billing operations."""
|
|
|
|
def get_subscription_with_tier(
|
|
self, db: Session, vendor_id: int
|
|
) -> tuple[VendorSubscription, SubscriptionTier | None]:
|
|
"""
|
|
Get subscription and its tier info.
|
|
|
|
Returns:
|
|
Tuple of (subscription, tier) where tier may be None
|
|
"""
|
|
subscription = subscription_service.get_or_create_subscription(db, vendor_id)
|
|
|
|
tier = (
|
|
db.query(SubscriptionTier)
|
|
.filter(SubscriptionTier.code == subscription.tier)
|
|
.first()
|
|
)
|
|
|
|
return subscription, tier
|
|
|
|
def get_available_tiers(
|
|
self, db: Session, current_tier: str
|
|
) -> tuple[list[dict], dict[str, int]]:
|
|
"""
|
|
Get all available tiers with upgrade/downgrade flags.
|
|
|
|
Returns:
|
|
Tuple of (tier_list, tier_order_map)
|
|
"""
|
|
tiers = (
|
|
db.query(SubscriptionTier)
|
|
.filter(
|
|
SubscriptionTier.is_active == True, # noqa: E712
|
|
SubscriptionTier.is_public == True, # noqa: E712
|
|
)
|
|
.order_by(SubscriptionTier.display_order)
|
|
.all()
|
|
)
|
|
|
|
tier_order = {t.code: t.display_order for t in tiers}
|
|
current_order = tier_order.get(current_tier, 0)
|
|
|
|
tier_list = []
|
|
for tier in tiers:
|
|
tier_list.append({
|
|
"code": tier.code,
|
|
"name": tier.name,
|
|
"description": tier.description,
|
|
"price_monthly_cents": tier.price_monthly_cents,
|
|
"price_annual_cents": tier.price_annual_cents,
|
|
"orders_per_month": tier.orders_per_month,
|
|
"products_limit": tier.products_limit,
|
|
"team_members": tier.team_members,
|
|
"features": tier.features or [],
|
|
"is_current": tier.code == current_tier,
|
|
"can_upgrade": tier.display_order > current_order,
|
|
"can_downgrade": tier.display_order < current_order,
|
|
})
|
|
|
|
return tier_list, tier_order
|
|
|
|
def get_tier_by_code(self, db: Session, tier_code: str) -> SubscriptionTier:
|
|
"""
|
|
Get a tier by its code.
|
|
|
|
Raises:
|
|
TierNotFoundError: If tier doesn't exist
|
|
"""
|
|
tier = (
|
|
db.query(SubscriptionTier)
|
|
.filter(
|
|
SubscriptionTier.code == tier_code,
|
|
SubscriptionTier.is_active == True, # noqa: E712
|
|
)
|
|
.first()
|
|
)
|
|
|
|
if not tier:
|
|
raise TierNotFoundError(tier_code)
|
|
|
|
return tier
|
|
|
|
def get_vendor(self, db: Session, vendor_id: int) -> Vendor:
|
|
"""
|
|
Get vendor by ID.
|
|
|
|
Raises:
|
|
VendorNotFoundException from app.exceptions
|
|
"""
|
|
from app.exceptions import VendorNotFoundException
|
|
|
|
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
|
|
if not vendor:
|
|
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
|
|
|
|
return vendor
|
|
|
|
def create_checkout_session(
|
|
self,
|
|
db: Session,
|
|
vendor_id: int,
|
|
tier_code: str,
|
|
is_annual: bool,
|
|
success_url: str,
|
|
cancel_url: str,
|
|
) -> dict:
|
|
"""
|
|
Create a Stripe checkout session.
|
|
|
|
Returns:
|
|
Dict with checkout_url and session_id
|
|
|
|
Raises:
|
|
PaymentSystemNotConfiguredError: If Stripe not configured
|
|
TierNotFoundError: If tier doesn't exist
|
|
StripePriceNotConfiguredError: If price not configured
|
|
"""
|
|
if not stripe_service.is_configured:
|
|
raise PaymentSystemNotConfiguredError()
|
|
|
|
vendor = self.get_vendor(db, vendor_id)
|
|
tier = self.get_tier_by_code(db, 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(tier_code)
|
|
|
|
# Check if this is a new subscription (for trial)
|
|
existing_sub = subscription_service.get_subscription(db, vendor_id)
|
|
trial_days = None
|
|
if not existing_sub or not existing_sub.stripe_subscription_id:
|
|
from app.core.config import settings
|
|
trial_days = settings.stripe_trial_days
|
|
|
|
session = stripe_service.create_checkout_session(
|
|
db=db,
|
|
vendor=vendor,
|
|
price_id=price_id,
|
|
success_url=success_url,
|
|
cancel_url=cancel_url,
|
|
trial_days=trial_days,
|
|
)
|
|
|
|
# Update subscription with tier info
|
|
subscription = subscription_service.get_or_create_subscription(db, vendor_id)
|
|
subscription.tier = tier_code
|
|
subscription.is_annual = is_annual
|
|
|
|
return {
|
|
"checkout_url": session.url,
|
|
"session_id": session.id,
|
|
}
|
|
|
|
def create_portal_session(self, db: Session, vendor_id: int, return_url: str) -> dict:
|
|
"""
|
|
Create a Stripe customer portal session.
|
|
|
|
Returns:
|
|
Dict with portal_url
|
|
|
|
Raises:
|
|
PaymentSystemNotConfiguredError: If Stripe not configured
|
|
NoActiveSubscriptionError: If no subscription with customer ID
|
|
"""
|
|
if not stripe_service.is_configured:
|
|
raise PaymentSystemNotConfiguredError()
|
|
|
|
subscription = subscription_service.get_subscription(db, vendor_id)
|
|
|
|
if not subscription or not subscription.stripe_customer_id:
|
|
raise NoActiveSubscriptionError()
|
|
|
|
session = stripe_service.create_portal_session(
|
|
customer_id=subscription.stripe_customer_id,
|
|
return_url=return_url,
|
|
)
|
|
|
|
return {"portal_url": session.url}
|
|
|
|
def get_invoices(
|
|
self, db: Session, vendor_id: int, skip: int = 0, limit: int = 20
|
|
) -> tuple[list[BillingHistory], int]:
|
|
"""
|
|
Get invoice history for a vendor.
|
|
|
|
Returns:
|
|
Tuple of (invoices, total_count)
|
|
"""
|
|
query = db.query(BillingHistory).filter(BillingHistory.vendor_id == vendor_id)
|
|
|
|
total = query.count()
|
|
|
|
invoices = (
|
|
query.order_by(BillingHistory.invoice_date.desc())
|
|
.offset(skip)
|
|
.limit(limit)
|
|
.all()
|
|
)
|
|
|
|
return invoices, total
|
|
|
|
def get_available_addons(
|
|
self, db: Session, category: str | None = None
|
|
) -> list[AddOnProduct]:
|
|
"""Get available add-on products."""
|
|
query = db.query(AddOnProduct).filter(AddOnProduct.is_active == True) # noqa: E712
|
|
|
|
if category:
|
|
query = query.filter(AddOnProduct.category == category)
|
|
|
|
return query.order_by(AddOnProduct.display_order).all()
|
|
|
|
def get_vendor_addons(self, db: Session, vendor_id: int) -> list[VendorAddOn]:
|
|
"""Get vendor's purchased add-ons."""
|
|
return (
|
|
db.query(VendorAddOn)
|
|
.filter(VendorAddOn.vendor_id == vendor_id)
|
|
.all()
|
|
)
|
|
|
|
def cancel_subscription(
|
|
self, db: Session, vendor_id: int, reason: str | None, immediately: bool
|
|
) -> dict:
|
|
"""
|
|
Cancel a subscription.
|
|
|
|
Returns:
|
|
Dict with message and effective_date
|
|
|
|
Raises:
|
|
NoActiveSubscriptionError: If no subscription to cancel
|
|
"""
|
|
subscription = subscription_service.get_subscription(db, vendor_id)
|
|
|
|
if not subscription or not subscription.stripe_subscription_id:
|
|
raise NoActiveSubscriptionError()
|
|
|
|
if stripe_service.is_configured:
|
|
stripe_service.cancel_subscription(
|
|
subscription_id=subscription.stripe_subscription_id,
|
|
immediately=immediately,
|
|
cancellation_reason=reason,
|
|
)
|
|
|
|
subscription.cancelled_at = datetime.utcnow()
|
|
subscription.cancellation_reason = reason
|
|
|
|
effective_date = (
|
|
datetime.utcnow().isoformat()
|
|
if immediately
|
|
else subscription.period_end.isoformat()
|
|
if subscription.period_end
|
|
else datetime.utcnow().isoformat()
|
|
)
|
|
|
|
return {
|
|
"message": "Subscription cancelled successfully",
|
|
"effective_date": effective_date,
|
|
}
|
|
|
|
def reactivate_subscription(self, db: Session, vendor_id: int) -> dict:
|
|
"""
|
|
Reactivate a cancelled subscription.
|
|
|
|
Returns:
|
|
Dict with success message
|
|
|
|
Raises:
|
|
NoActiveSubscriptionError: If no subscription
|
|
SubscriptionNotCancelledError: If not cancelled
|
|
"""
|
|
subscription = subscription_service.get_subscription(db, vendor_id)
|
|
|
|
if not subscription or not subscription.stripe_subscription_id:
|
|
raise NoActiveSubscriptionError()
|
|
|
|
if not subscription.cancelled_at:
|
|
raise SubscriptionNotCancelledError()
|
|
|
|
if stripe_service.is_configured:
|
|
stripe_service.reactivate_subscription(subscription.stripe_subscription_id)
|
|
|
|
subscription.cancelled_at = None
|
|
subscription.cancellation_reason = None
|
|
|
|
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()
|