"""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, HTTPException, 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} @router.get("/invoices", response_model=list[dict]) async def list_invoices( current_user: UserProfile = Depends(get_current_user), db: AsyncSession = Depends(get_session), ) -> list[dict[str, Any]]: """Return billing history (invoices) from Stripe. Returns an empty list when Stripe is not configured. """ invoices = await stripe_service.list_invoices(current_user.id, db) return invoices # ── Quota check ──────────────────────────────────────────────────────── from app.billing.quota import check_folder_quota, QuotaExceeded # noqa: E402 class QuotaCheckRequest(BaseModel): feature: str estimated_files: int @router.post("/quota/check") async def quota_check( payload: QuotaCheckRequest, current_user: UserProfile = Depends(get_current_user), db: AsyncSession = Depends(get_session), ) -> dict: """Pre-flight folder quota check. 402 if tier limits would be exceeded.""" if payload.feature != "folder_index": raise HTTPException(status_code=400, detail="Unknown feature") try: await check_folder_quota( user_id=current_user.id, tier=current_user.tier, estimated_files=payload.estimated_files, db=db, ) except QuotaExceeded as exc: raise HTTPException( status_code=402, detail={"reason": exc.reason, "message": str(exc)}, ) return {"ok": True}