"""Billing routes: Stripe checkout, webhook, subscription management. Subscription records are kept in-memory until Step 12 migrates them to PostgreSQL (subscriptions table). Stripe calls are gracefully stubbed when STRIPE_SECRET_KEY is not configured, allowing local development without keys. """ from __future__ import annotations from typing import Any import stripe as stripe_lib from fastapi import APIRouter, Depends, Header, HTTPException, Request, status from pydantic import BaseModel from app.api.deps import get_current_user from app.config.settings import settings from app.schemas import BillingTier, UserProfile router = APIRouter(prefix="/billing", tags=["billing"]) # In-memory subscriptions — replaced by PostgreSQL subscriptions table in Step 12 _subscriptions: dict[str, dict[str, Any]] = {} # user_id → subscription record _TIER_PRICE_IDS: dict[str, str] = { "pro": "price_pro_monthly", # replace with real Stripe price IDs "power": "price_power_monthly", "team": "price_team_monthly", } # ── Helpers ──────────────────────────────────────────────────────────── def _stripe_configured() -> bool: return bool(settings.STRIPE_SECRET_KEY) def _stripe() -> Any: stripe_lib.api_key = settings.STRIPE_SECRET_KEY return stripe_lib # ── 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. """ if body.tier == "free": raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot create a checkout session for the free tier", ) if _stripe_configured(): price_id = _TIER_PRICE_IDS.get(body.tier) if not price_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Unknown tier: {body.tier}", ) s = _stripe() session = s.checkout.Session.create( payment_method_types=["card"], mode="subscription", line_items=[{"price": price_id, "quantity": 1}], success_url=( "https://app.adiuva.app/billing/success" "?session_id={CHECKOUT_SESSION_ID}" ), cancel_url="https://app.adiuva.app/billing/cancel", metadata={"user_id": current_user.id, "tier": body.tier}, ) return {"checkout_url": session.url} return {"checkout_url": "https://stripe.com/stub-checkout"} @router.post("/webhook", response_model=dict) async def stripe_webhook( request: Request, stripe_signature: str = Header(default="", alias="Stripe-Signature"), ) -> 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() if not _stripe_configured(): return {"ok": True} try: s = _stripe() event = s.Webhook.construct_event( payload, stripe_signature, settings.STRIPE_WEBHOOK_SECRET ) except stripe_lib.error.SignatureVerificationError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid Stripe signature", ) event_type: str = event["type"] data: dict[str, Any] = event["data"]["object"] if event_type == "checkout.session.completed": user_id = data.get("metadata", {}).get("user_id") tier = data.get("metadata", {}).get("tier", "free") sub_id = data.get("subscription") if user_id: _subscriptions[user_id] = { "tier": tier, "stripe_subscription_id": sub_id, "status": "active", "current_period_end": None, } elif event_type == "customer.subscription.updated": # TODO(Step12): look up user_id from stripe_customer_id in DB, then update tier pass elif event_type == "customer.subscription.deleted": # TODO(Step12): look up user_id from stripe_customer_id in DB, set tier to free pass elif event_type == "invoice.payment_failed": # TODO(Step12): flag subscription as past_due, notify user pass return {"ok": True} @router.get("/subscription", response_model=dict) async def get_subscription( current_user: UserProfile = Depends(get_current_user), ) -> dict[str, Any]: """Return the current subscription info for the authenticated user.""" sub = _subscriptions.get(current_user.id) 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) async def cancel_subscription( current_user: UserProfile = Depends(get_current_user), ) -> dict[str, bool]: """Cancel the active subscription.""" sub = _subscriptions.get(current_user.id) if sub is None or not sub.get("stripe_subscription_id"): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="No active subscription found", ) if _stripe_configured(): s = _stripe() s.Subscription.cancel(sub["stripe_subscription_id"]) _subscriptions[current_user.id] = { **sub, "tier": "free", "status": "canceled", } return {"ok": True}