Files
api/services/billing/app/routes.py
Roberto Musso 57b5648915 feat(billing): extract Billing Service (Step 4)
- stripe_service: checkout sessions, webhook handling, subscription CRUD
- tier_manager: feature matrix (4 tiers), quota enforcement, rate limits
- routes: checkout, webhook (no auth), subscription, tier query, features
- Traefik header auth (X-User-Id) replaces get_current_user dependency
- /tier/{user_id} endpoint for internal service-to-service lookups
- /features and /features/{tier} for feature matrix queries
- Dockerfile: single worker, 30s timeout (lightweight service)
2026-04-06 23:07:46 +02:00

135 lines
4.7 KiB
Python

"""Billing routes: Stripe checkout, webhook, subscription, tier query.
Adapted for the Billing microservice:
- Authenticated routes use Traefik-injected headers (X-User-Id, X-User-Tier)
- Webhook route has NO auth (Stripe signature verification only)
- Added /tier/{user_id} for internal service-to-service tier lookups
- Added /features/{tier} for feature matrix queries
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Header, HTTPException, Request, status
from pydantic import BaseModel
from shared.db import async_session
from shared.schemas import BillingTier
from app.stripe_service import stripe_service
from app.tier_manager import tier_manager, FEATURES, RATE_LIMITS
router = APIRouter(prefix="/billing", tags=["billing"])
# ── Request bodies ─────────────────────────────────────────────────────
class _CheckoutRequest(BaseModel):
tier: BillingTier
# ── Checkout ───────────────────────────────────────────────────────────
@router.post("/checkout")
async def create_checkout(
body: _CheckoutRequest,
x_user_id: str = Header(..., alias="X-User-Id"),
) -> dict[str, str]:
"""Create a Stripe checkout session for a tier upgrade."""
url = stripe_service.create_checkout_session(x_user_id, body.tier)
return {"checkout_url": url}
# ── Webhook (NO auth — Stripe signature only) ─────────────────────────
@router.post("/webhook")
async def stripe_webhook(
request: Request,
stripe_signature: str = Header(default="", alias="Stripe-Signature"),
) -> dict[str, bool]:
"""Handle Stripe webhook events.
This endpoint is exposed without ForwardAuth in Traefik config
so Stripe can reach it directly.
"""
payload = await request.body()
async with async_session() as db:
await stripe_service.handle_webhook(payload, stripe_signature, db)
return {"ok": True}
# ── Subscription CRUD ─────────────────────────────────────────────────
@router.get("/subscription")
async def get_subscription(
x_user_id: str = Header(..., alias="X-User-Id"),
x_user_tier: str = Header("free", alias="X-User-Tier"),
) -> dict[str, Any]:
"""Return the current subscription info for the authenticated user."""
async with async_session() as db:
sub = await stripe_service.get_subscription(x_user_id, db)
if sub is None:
return {
"tier": x_user_tier,
"status": "free",
"stripe_subscription_id": None,
"current_period_end": None,
}
return sub
@router.delete("/subscription")
async def cancel_subscription(
x_user_id: str = Header(..., alias="X-User-Id"),
) -> dict[str, bool]:
"""Cancel the active subscription."""
async with async_session() as db:
await stripe_service.cancel_subscription(x_user_id, db)
return {"ok": True}
# ── Tier query (internal, service-to-service) ─────────────────────────
@router.get("/tier/{user_id}")
async def get_user_tier(user_id: str) -> dict[str, str]:
"""Return the billing tier for a given user_id.
Used by other services for tier lookups. Protected by Traefik
ForwardAuth — only internal services should call this.
"""
async with async_session() as db:
tier = await tier_manager.get_tier(user_id, db)
return {"user_id": user_id, "tier": tier}
# ── Feature matrix (public, cacheable) ────────────────────────────────
@router.get("/features/{tier}")
async def get_tier_features(tier: str) -> dict[str, Any]:
"""Return the feature matrix for a tier."""
if tier not in FEATURES:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Unknown tier: {tier}",
)
return {
"tier": tier,
"features": FEATURES[tier],
"rate_limit_rpm": RATE_LIMITS.get(tier, RATE_LIMITS["free"]),
}
@router.get("/features")
async def get_all_features() -> dict[str, Any]:
"""Return the full feature matrix for all tiers."""
return {
"tiers": {
tier: {
"features": features,
"rate_limit_rpm": RATE_LIMITS.get(tier, RATE_LIMITS["free"]),
}
for tier, features in FEATURES.items()
},
}