"""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() }, }