Files
orion/app/api/v1/vendor/billing.py
Samir Boulahtit cad862f469 refactor(api): introduce UserContext schema for API dependency injection
Replace direct User database model imports in API endpoints with UserContext
schema, following the architecture principle that API routes should not import
database models directly.

Changes:
- Create UserContext schema in models/schema/auth.py with from_user() factory
- Update app/api/deps.py to return UserContext from all auth dependencies
- Add _get_user_model() helper for functions needing User model access
- Update 58 API endpoint files to use UserContext instead of User
- Add noqa comments for 4 legitimate edge cases (enums, internal helpers)

Architecture validation: 0 errors (down from 61), 11 warnings remain

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 20:47:33 +01:00

526 lines
15 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.schema.auth import UserContext
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
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
# ============================================================================
@router.get("/subscription", response_model=SubscriptionStatusResponse)
def get_subscription_status(
current_user: UserContext = 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: UserContext = 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: UserContext = 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: UserContext = 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: UserContext = 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: UserContext = 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: UserContext = 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: UserContext = 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: UserContext = 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
@router.get("/upcoming-invoice", response_model=UpcomingInvoiceResponse)
def get_upcoming_invoice(
current_user: UserContext = 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: UserContext = 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: UserContext = 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: UserContext = 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"],
)