129 lines
4.1 KiB
TypeScript
129 lines
4.1 KiB
TypeScript
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<string, string[]> = {};
|
|
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 }
|
|
);
|
|
}
|
|
}
|