import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { getSessionUser, canMutate } from "@/lib/session"; import { getOrgBilling, upsertOrgBilling } from "@/lib/db"; import { safeError } from "@/lib/errors"; /** * Org-scoped billing API (Bug 35). * * GET — return the current billing record for the caller's org, or * 404 if none has been captured yet. The /settings/billing page * renders an empty form on 404 (first-time edit) and a pre-filled * form on 200. * * PUT — upsert the billing record. Required for any subsequent tenant * provisioning unless the caller is on a personal org. Validation: * - All address fields required. * - VAT number required for company orgs (where `user.isPersonal` * is false). Optional for personal orgs. * - billing_email validated as RFC-5322-ish. * * Authorization: * - GET: any authenticated user in the org. We expose only their * own org's billing — orgId is scoped from the session. * - PUT: owners and platform admins (canMutate check). Customers * in `user` role cannot edit billing. */ const billingSchema = z.object({ companyName: z.string().min(1).max(200), streetAddress: z.string().min(1).max(200), postalCode: z.string().min(1).max(20), city: z.string().min(1).max(100), country: z.string().min(2).max(3), // ISO 3166-1 alpha-2 or alpha-3 vatNumber: z .string() .max(50) .nullable() .optional() .transform((v) => (v && v.trim() !== "" ? v.trim() : null)), billingEmail: z.string().email().max(200), notes: z .string() .max(2000) .nullable() .optional() .transform((v) => (v && v.trim() !== "" ? v.trim() : null)), }); export async function GET() { const user = await getSessionUser(); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const billing = await getOrgBilling(user.orgId); if (!billing) { // 404 carries semantic meaning here — "no record yet". Callers // (settings page, wizard) treat this as the empty-form state. return NextResponse.json( { error: "No billing record for this org" }, { status: 404 } ); } return NextResponse.json({ billing }); } export async function PUT(req: NextRequest) { const user = await getSessionUser(); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } if (!canMutate(user)) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } const body = await req.json().catch(() => null); const parsed = billingSchema.safeParse(body); if (!parsed.success) { return NextResponse.json( { error: "Invalid input", details: parsed.error.flatten() }, { status: 400 } ); } // Company orgs (B2B) require companyName AND VAT. Personal orgs // (B2C — private individuals) need neither; their /settings/billing // form hides both fields and we don't ask the API to enforce them. if (!user.isPersonal) { const missing: Record = {}; if (!parsed.data.companyName || parsed.data.companyName.trim().length === 0) { missing.companyName = ["Required for companies"]; } if (!parsed.data.vatNumber) { missing.vatNumber = ["Required for companies"]; } if (Object.keys(missing).length > 0) { return NextResponse.json( { error: "Company name and VAT number are required for company accounts.", details: { fieldErrors: missing }, }, { status: 400 } ); } } try { const billing = await upsertOrgBilling({ zitadelOrgId: user.orgId, companyName: parsed.data.companyName, streetAddress: parsed.data.streetAddress, postalCode: parsed.data.postalCode, city: parsed.data.city, country: parsed.data.country, vatNumber: parsed.data.vatNumber, billingEmail: parsed.data.billingEmail, notes: parsed.data.notes, }); return NextResponse.json({ billing }); } catch (e: any) { console.error("Failed to upsert org billing:", e); return NextResponse.json( { error: safeError(e, "Failed to save billing") }, { status: 500 } ); } }