Files
orion/app/services/billing_service.py
Samir Boulahtit 9d8d5e7138 feat: add subscription and billing system with Stripe integration
- Add database models for subscription tiers, vendor subscriptions,
  add-ons, billing history, and webhook events
- Implement BillingService for subscription operations
- Implement StripeService for Stripe API operations
- Implement StripeWebhookHandler for webhook event processing
- Add vendor billing API endpoints for subscription management
- Create vendor billing page with Alpine.js frontend
- Add limit enforcement for products and team members
- Add billing exceptions for proper error handling
- Create comprehensive unit tests (40 tests passing)
- Add subscription billing documentation

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 20:29:44 +01:00

371 lines
11 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"}
# Create service instance
billing_service = BillingService()