Files
orion/app/api/v1/vendor/billing.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

406 lines
11 KiB
Python

# app/api/v1/vendor/billing.py
"""
Vendor billing and subscription management endpoints.
Provides:
- Subscription status and usage
- Tier listing and comparison
- Stripe checkout and portal access
- Invoice history
- Add-on management
"""
import logging
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.config import settings
from app.core.database import get_db
from app.services.billing_service import billing_service
from app.services.subscription_service import subscription_service
from models.database.user import User
router = APIRouter(prefix="/billing")
logger = logging.getLogger(__name__)
# ============================================================================
# Schemas
# ============================================================================
class SubscriptionStatusResponse(BaseModel):
"""Current subscription status and usage."""
tier_code: str
tier_name: str
status: str
is_trial: bool
trial_ends_at: str | None = None
period_start: str | None = None
period_end: str | None = None
cancelled_at: str | None = None
cancellation_reason: str | None = None
# Usage
orders_this_period: int
orders_limit: int | None
orders_remaining: int | None
products_count: int
products_limit: int | None
products_remaining: int | None
team_count: int
team_limit: int | None
team_remaining: int | None
# Payment
has_payment_method: bool
last_payment_error: str | None = None
class Config:
from_attributes = True
class TierResponse(BaseModel):
"""Subscription tier information."""
code: str
name: str
description: str | None = None
price_monthly_cents: int
price_annual_cents: int | None = None
orders_per_month: int | None = None
products_limit: int | None = None
team_members: int | None = None
features: list[str] = []
is_current: bool = False
can_upgrade: bool = False
can_downgrade: bool = False
class TierListResponse(BaseModel):
"""List of available tiers."""
tiers: list[TierResponse]
current_tier: str
class CheckoutRequest(BaseModel):
"""Request to create a checkout session."""
tier_code: str
is_annual: bool = False
class CheckoutResponse(BaseModel):
"""Checkout session response."""
checkout_url: str
session_id: str
class PortalResponse(BaseModel):
"""Customer portal session response."""
portal_url: str
class InvoiceResponse(BaseModel):
"""Invoice information."""
id: int
invoice_number: str | None = None
invoice_date: str
due_date: str | None = None
total_cents: int
amount_paid_cents: int
currency: str
status: str
pdf_url: str | None = None
hosted_url: str | None = None
class InvoiceListResponse(BaseModel):
"""List of invoices."""
invoices: list[InvoiceResponse]
total: int
class AddOnResponse(BaseModel):
"""Add-on product information."""
id: int
code: str
name: str
description: str | None = None
category: str
price_cents: int
billing_period: str
quantity_unit: str | None = None
quantity_value: int | None = None
class VendorAddOnResponse(BaseModel):
"""Vendor's purchased add-on."""
id: int
addon_code: str
addon_name: str
status: str
domain_name: str | None = None
quantity: int
period_start: str | None = None
period_end: str | None = None
class AddOnPurchaseRequest(BaseModel):
"""Request to purchase an add-on."""
addon_code: str
domain_name: str | None = None # For domain add-ons
quantity: int = 1
class CancelRequest(BaseModel):
"""Request to cancel subscription."""
reason: str | None = None
immediately: bool = False
class CancelResponse(BaseModel):
"""Cancellation response."""
message: str
effective_date: str
# ============================================================================
# Endpoints
# ============================================================================
@router.get("/subscription", response_model=SubscriptionStatusResponse)
def get_subscription_status(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get current subscription status and usage metrics."""
vendor_id = current_user.token_vendor_id
usage = subscription_service.get_usage_summary(db, vendor_id)
subscription, tier = billing_service.get_subscription_with_tier(db, vendor_id)
return SubscriptionStatusResponse(
tier_code=subscription.tier,
tier_name=tier.name if tier else subscription.tier.title(),
status=subscription.status.value,
is_trial=subscription.is_in_trial(),
trial_ends_at=subscription.trial_ends_at.isoformat()
if subscription.trial_ends_at
else None,
period_start=subscription.period_start.isoformat()
if subscription.period_start
else None,
period_end=subscription.period_end.isoformat()
if subscription.period_end
else None,
cancelled_at=subscription.cancelled_at.isoformat()
if subscription.cancelled_at
else None,
cancellation_reason=subscription.cancellation_reason,
orders_this_period=usage.orders_this_period,
orders_limit=usage.orders_limit,
orders_remaining=usage.orders_remaining,
products_count=usage.products_count,
products_limit=usage.products_limit,
products_remaining=usage.products_remaining,
team_count=usage.team_count,
team_limit=usage.team_limit,
team_remaining=usage.team_remaining,
has_payment_method=bool(subscription.stripe_payment_method_id),
last_payment_error=subscription.last_payment_error,
)
@router.get("/tiers", response_model=TierListResponse)
def get_available_tiers(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get available subscription tiers for upgrade/downgrade."""
vendor_id = current_user.token_vendor_id
subscription = subscription_service.get_or_create_subscription(db, vendor_id)
current_tier = subscription.tier
tier_list, _ = billing_service.get_available_tiers(db, current_tier)
tier_responses = [TierResponse(**tier_data) for tier_data in tier_list]
return TierListResponse(tiers=tier_responses, current_tier=current_tier)
@router.post("/checkout", response_model=CheckoutResponse)
def create_checkout_session(
request: CheckoutRequest,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Create a Stripe checkout session for subscription."""
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?success=true"
cancel_url = f"{base_url}/vendor/{vendor.vendor_code}/billing?cancelled=true"
result = billing_service.create_checkout_session(
db=db,
vendor_id=vendor_id,
tier_code=request.tier_code,
is_annual=request.is_annual,
success_url=success_url,
cancel_url=cancel_url,
)
db.commit()
return CheckoutResponse(checkout_url=result["checkout_url"], session_id=result["session_id"])
@router.post("/portal", response_model=PortalResponse)
def create_portal_session(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Create a Stripe customer portal session."""
vendor_id = current_user.token_vendor_id
vendor = billing_service.get_vendor(db, vendor_id)
return_url = f"https://{settings.platform_domain}/vendor/{vendor.vendor_code}/billing"
result = billing_service.create_portal_session(db, vendor_id, return_url)
return PortalResponse(portal_url=result["portal_url"])
@router.get("/invoices", response_model=InvoiceListResponse)
def get_invoices(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get invoice history."""
vendor_id = current_user.token_vendor_id
invoices, total = billing_service.get_invoices(db, vendor_id, skip=skip, limit=limit)
invoice_responses = [
InvoiceResponse(
id=inv.id,
invoice_number=inv.invoice_number,
invoice_date=inv.invoice_date.isoformat(),
due_date=inv.due_date.isoformat() if inv.due_date else None,
total_cents=inv.total_cents,
amount_paid_cents=inv.amount_paid_cents,
currency=inv.currency,
status=inv.status,
pdf_url=inv.invoice_pdf_url,
hosted_url=inv.hosted_invoice_url,
)
for inv in invoices
]
return InvoiceListResponse(invoices=invoice_responses, total=total)
@router.get("/addons", response_model=list[AddOnResponse])
def get_available_addons(
category: str | None = Query(None, description="Filter by category"),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get available add-on products."""
addons = billing_service.get_available_addons(db, category=category)
return [
AddOnResponse(
id=addon.id,
code=addon.code,
name=addon.name,
description=addon.description,
category=addon.category,
price_cents=addon.price_cents,
billing_period=addon.billing_period,
quantity_unit=addon.quantity_unit,
quantity_value=addon.quantity_value,
)
for addon in addons
]
@router.get("/my-addons", response_model=list[VendorAddOnResponse])
def get_vendor_addons(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get vendor's purchased add-ons."""
vendor_id = current_user.token_vendor_id
vendor_addons = billing_service.get_vendor_addons(db, vendor_id)
return [
VendorAddOnResponse(
id=va.id,
addon_code=va.addon_product.code,
addon_name=va.addon_product.name,
status=va.status,
domain_name=va.domain_name,
quantity=va.quantity,
period_start=va.period_start.isoformat() if va.period_start else None,
period_end=va.period_end.isoformat() if va.period_end else None,
)
for va in vendor_addons
]
@router.post("/cancel", response_model=CancelResponse)
def cancel_subscription(
request: CancelRequest,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Cancel subscription."""
vendor_id = current_user.token_vendor_id
result = billing_service.cancel_subscription(
db=db,
vendor_id=vendor_id,
reason=request.reason,
immediately=request.immediately,
)
db.commit()
return CancelResponse(
message=result["message"],
effective_date=result["effective_date"],
)
@router.post("/reactivate")
def reactivate_subscription(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Reactivate a cancelled subscription."""
vendor_id = current_user.token_vendor_id
result = billing_service.reactivate_subscription(db, vendor_id)
db.commit()
return result