import { NextResponse } from "next/server"; import { getSessionUser } from "@/lib/session"; import { computeInvoiceDraft } from "@/lib/billing"; import { listInvoices } from "@/lib/db"; /** * GET /api/billing/current * * Running total for the current calendar month — what the * customer will be billed if no further activity happens. Uses * the same compute pipeline as the final invoice (LiteLLM spend, * Threema usage, skill day-counting, proration) so the number * the customer sees matches what they'll eventually receive * within the limits of intra-month drift. * * If an invoice has ALREADY been issued for the current month * (e.g. cron ran early, admin manually generated), we return * that issued invoice instead — no point showing a draft that * duplicates a real invoice. * * Returns: * { issued: Invoice } // current-month invoice exists * { draft: InvoiceDraft } // still accruing * { error: ... } // org missing billing config * * Cost: 1 LiteLLM HTTP call + 1 Threema HTTP call + a handful of * DB queries per skill. Sub-second typically. No caching; called * on demand from the customer billing page. */ export async function GET() { const user = await getSessionUser(); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } // Resolve current calendar month from UTC. Billing is UTC-day // based throughout (see billing.ts iterDays comment), so the // running total inherits that same semantics. const now = new Date(); const year = now.getUTCFullYear(); const month = now.getUTCMonth() + 1; // 1-12 const periodMonth = `${year}-${String(month).padStart(2, "0")}`; // 1. Has the current month already been invoiced? const existing = await listInvoices({ zitadelOrgId: user.orgId, periodMonth, limit: 1, }); if (existing.length > 0) { return NextResponse.json({ issued: existing[0] }); } // 2. Otherwise compute the draft. Falls through to error if the // org doesn't have a billing config yet (no Address on file). try { const draft = await computeInvoiceDraft({ zitadelOrgId: user.orgId, year, month, }); return NextResponse.json({ draft }); } catch (e: any) { // Most likely: org_billing row missing. We surface a 200 with a // soft error code rather than 500 — the customer-side widget // displays a helpful "complete your billing details" message // instead of a stack trace. return NextResponse.json( { error: e?.message ?? "Could not compute running total.", code: e?.code ?? "COMPUTE_FAILED", }, { status: 200 } ); } }