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)
This commit is contained in:
134
services/billing/app/routes.py
Normal file
134
services/billing/app/routes.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""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()
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user