"""Billing API surface. Mounted under /billing. Public: list plans (hidden filtered), start checkout, get order/subscription/usage status. Trusted: the processor webhook (verified) that atomically activates a plan. Owner-only: reconciliation. """ from __future__ import annotations from fastapi import APIRouter, Depends, Header, HTTPException, Request from pydantic import BaseModel from backend.billing import metering, payments, store from backend.billing.gating import OWNER_USER_ID, require_owner, resolve_user_id from backend.billing.plans import get_plan, is_hidden, list_public_plans from backend.billing.subscription import (effective_plan, grant_plan_for_order, is_provisioning, reconcile) router = APIRouter() @router.get("/plans") async def get_plans(): # Hidden owner plan is filtered at the source (list_public_plans) — it can # never appear here. out = [] for p in list_public_plans(): out.append({"id": p.id, "display_name": p.display_name, "price_cents": p.price_cents, "currency": p.currency, "features": sorted(f.value for f in p.features), "model_call_cap": p.effective_model_cap, "provisioning_delay_hours": p.provisioning_delay_hours}) return {"plans": out} class CheckoutRequest(BaseModel): plan_id: str @router.post("/checkout") async def start_checkout(req: CheckoutRequest, user_id: str = Depends(resolve_user_id)): if is_hidden(req.plan_id): # Never let a normal caller select the owner plan, even by guessing its id. raise HTTPException(status_code=404, detail="unknown plan") plan = get_plan(req.plan_id) if not plan: raise HTTPException(status_code=404, detail="unknown plan") store.upsert_account(user_id) provider = payments.get_provider() order_id = store.create_order(user_id, plan.id, plan.price_cents, plan.currency, provider.name) session = provider.create_checkout(order_id, plan.price_cents, plan.currency, plan.id) store.set_order_state(order_id, "pending", processor_ref=session.processor_ref) return {"order": order_id, "checkout": session.to_dict(), "methods": provider.methods, "provisioning_delay_hours": plan.provisioning_delay_hours} @router.get("/orders/{order_id}") async def order_status(order_id: str): o = store.get_order(order_id) if not o: raise HTTPException(status_code=404, detail="unknown order") return o @router.post("/webhook") async def payment_webhook(request: Request): """The ONLY path that activates a paid plan. Verifies the processor signature, then atomically + idempotently grants. Never trusts a client 'succeeded'.""" raw = await request.body() provider = payments.get_provider() event = provider.verify_webhook(raw, dict(request.headers)) if not event: raise HTTPException(status_code=400, detail="unverified webhook") if event["status"] not in ("confirmed", "paid", "success"): store.set_order_state(event["order_id"], "failed") return {"handled": True, "granted": False, "reason": "not_a_success_event"} result = grant_plan_for_order(event["order_id"], event["event_id"]) return {"handled": True, **result} @router.get("/subscription") async def my_subscription(user_id: str = Depends(resolve_user_id)): plan = effective_plan(user_id) sub = store.get_subscription(user_id) return {"user_id": user_id, "plan_id": plan.id, "status": (sub or {}).get("status", "none"), "provisioning": is_provisioning(user_id), "features": sorted(f.value for f in plan.features)} @router.get("/usage") async def my_usage(user_id: str = Depends(resolve_user_id)): return metering.usage_status(user_id) @router.get("/reconcile") async def do_reconcile(user_id: str = Depends(resolve_user_id), _owner: str = Depends(require_owner)): # Owner-only operational check. require_owner demands the real operator token — # the previous guard passed when NO header was sent, which made this reachable # unauthenticated. return reconcile(user_id) @router.get("/audit") async def get_audit(limit: int = 100, _owner: str = Depends(require_owner)): """Owner-only: the operator audit trail (plan grants, flag flips).""" return {"audit": store.read_audit(limit)} class FlagRequest(BaseModel): name: str enabled: bool @router.get("/flags/{name}") async def get_flag(name: str): """Public read: is a risky surface currently enabled? (so a client can show a graceful 'temporarily unavailable' state instead of failing blindly).""" return {"name": name, "enabled": store.flag_enabled(name, default=True)} @router.post("/flags") async def set_flag(req: FlagRequest, _owner: str = Depends(require_owner)): """Owner-only kill-switch toggle. Leaves an audit record.""" store.set_flag(req.name, req.enabled) import json as _json store.record_audit(action="flag_set", actor="owner", target=req.name, detail=_json.dumps({"enabled": req.enabled})) return {"name": req.name, "enabled": req.enabled}