"""Billing routes: Stripe checkout, webhook, subscription management. Business logic lives in ``app.billing.stripe_service.StripeService``. The route layer handles HTTP concerns (request parsing, response shaping) and delegates everything else to the service singleton. """ from __future__ import annotations from typing import Any from fastapi import APIRouter, Depends, Header, Request, status from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user from app.billing.stripe_service import stripe_service from app.db import get_session from app.schemas import BillingTier, UserProfile router = APIRouter(prefix="/billing", tags=["billing"]) # ── Request bodies ───────────────────────────────────────────────────── class _CheckoutRequest(BaseModel): tier: BillingTier # ── Routes ───────────────────────────────────────────────────────────── @router.post("/checkout", response_model=dict) async def create_checkout( body: _CheckoutRequest, current_user: UserProfile = Depends(get_current_user), ) -> dict[str, str]: """Create a Stripe checkout session for a tier upgrade. Returns a stub URL when ``STRIPE_SECRET_KEY`` is not configured. """ url = stripe_service.create_checkout_session(current_user.id, body.tier) return {"checkout_url": url} @router.post("/webhook", response_model=dict) async def stripe_webhook( request: Request, stripe_signature: str = Header(default="", alias="Stripe-Signature"), db: AsyncSession = Depends(get_session), ) -> dict[str, bool]: """Handle Stripe webhook events. No JWT auth — authenticated via Stripe signature verification instead. Returns 200 immediately when Stripe is not configured (local dev). """ payload = await request.body() await stripe_service.handle_webhook(payload, stripe_signature, db) return {"ok": True} @router.get("/subscription", response_model=dict) async def get_subscription( current_user: UserProfile = Depends(get_current_user), db: AsyncSession = Depends(get_session), ) -> dict[str, Any]: """Return the current subscription info for the authenticated user.""" sub = await stripe_service.get_subscription(current_user.id, db) if sub is None: return { "tier": current_user.tier, "status": "free", "stripe_subscription_id": None, "current_period_end": None, } return sub @router.delete("/subscription", response_model=dict, status_code=status.HTTP_200_OK) async def cancel_subscription( current_user: UserProfile = Depends(get_current_user), db: AsyncSession = Depends(get_session), ) -> dict[str, bool]: """Cancel the active subscription.""" await stripe_service.cancel_subscription(current_user.id, db) return {"ok": True}