- 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)
135 lines
4.7 KiB
Python
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()
|
|
},
|
|
}
|