This commit is contained in:
128
src/app/api/billing/route.ts
Normal file
128
src/app/api/billing/route.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user