This commit is contained in:
@@ -4,7 +4,7 @@ import { redirect } from "next/navigation";
|
||||
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { listActiveTenantRequestsByOrgId } from "@/lib/db";
|
||||
import { listActiveTenantRequestsByOrgId, getOrgBilling } from "@/lib/db";
|
||||
import { personalAccountAtCapacity } from "@/lib/personal-org";
|
||||
|
||||
/**
|
||||
@@ -55,6 +55,8 @@ export default async function NewInstancePage() {
|
||||
}
|
||||
|
||||
const t = await getTranslations("dashboard");
|
||||
const orgBilling = await getOrgBilling(user.orgId);
|
||||
const hasOrgBilling = orgBilling !== null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -73,6 +75,7 @@ export default async function NewInstancePage() {
|
||||
orgName={user.orgName}
|
||||
userName={user.name}
|
||||
userEmail={user.email}
|
||||
hasOrgBilling={hasOrgBilling}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { listTenants } from "@/lib/k8s";
|
||||
import {
|
||||
listActiveTenantRequestsByOrgId,
|
||||
syncProvisioningStatuses,
|
||||
getOrgBilling,
|
||||
} from "@/lib/db";
|
||||
import {
|
||||
listVisibleTenants,
|
||||
@@ -184,6 +185,14 @@ export default async function DashboardPage() {
|
||||
? await listActiveTenantRequestsByOrgId(user.orgId)
|
||||
: [];
|
||||
|
||||
// Bug 35: orgs that already have a billing record skip the wizard's
|
||||
// billing step. Fetched here so the dashboard's empty-state mount of
|
||||
// OnboardingFlow knows what to do; for the additional-tenant flow at
|
||||
// /dashboard/new we fetch the same flag in that route's own server
|
||||
// component.
|
||||
const orgBilling = await getOrgBilling(user.orgId);
|
||||
const hasOrgBilling = orgBilling !== null;
|
||||
|
||||
// Pending requests that don't yet have a tenant CR. Once the CR
|
||||
// exists, the tenant card carries the live phase, so a separate
|
||||
// "request" card would just duplicate it. We compare against
|
||||
@@ -307,6 +316,7 @@ export default async function DashboardPage() {
|
||||
orgName={user.orgName}
|
||||
userName={user.name}
|
||||
userEmail={user.email}
|
||||
hasOrgBilling={hasOrgBilling}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
46
src/app/[locale]/settings/billing/page.tsx
Normal file
46
src/app/[locale]/settings/billing/page.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { getOrgBilling } from "@/lib/db";
|
||||
import { BillingSettingsForm } from "@/components/settings/billing-settings-form";
|
||||
|
||||
/**
|
||||
* /settings/billing — view and edit org-scoped billing (Bug 34/35).
|
||||
*
|
||||
* Server-side fetches the existing record (if any) and passes it to
|
||||
* the client form. The form posts to PUT /api/billing on submit.
|
||||
*
|
||||
* Access: same gate as the API — owners and platform admins. `user`
|
||||
* role redirects to /settings (which also wouldn't list billing for
|
||||
* them). 403 here would be friendlier than redirect, but the most
|
||||
* likely cause of a `user` landing on this URL is sharing a bookmark
|
||||
* with their owner — silent redirect is gentle.
|
||||
*/
|
||||
export default async function BillingSettingsPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!canMutate(user)) {
|
||||
redirect("/settings");
|
||||
}
|
||||
const t = await getTranslations("settingsBilling");
|
||||
|
||||
const billing = await getOrgBilling(user.orgId);
|
||||
|
||||
return (
|
||||
<main className="max-w-3xl mx-auto px-6 py-8">
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<BillingSettingsForm
|
||||
initial={billing}
|
||||
isPersonal={user.isPersonal}
|
||||
orgName={user.orgName}
|
||||
userName={user.name}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
76
src/app/[locale]/settings/page.tsx
Normal file
76
src/app/[locale]/settings/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
/**
|
||||
* /settings — landing page for user/org-level configuration (Bug 35
|
||||
* intentionally landed billing here rather than at /billing because we
|
||||
* expect more settings categories: notifications, API keys, default
|
||||
* workspace templates, etc.). Currently lists a single category card;
|
||||
* the layout scales to a sidebar nav once there are 3+.
|
||||
*
|
||||
* Access: any authenticated user (the cards themselves gate further;
|
||||
* non-owner users would not see "Billing" as actionable, etc.).
|
||||
*/
|
||||
export default async function SettingsPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
const t = await getTranslations("settings");
|
||||
|
||||
// Build the list of settings cards. Each entry has a stable key, a
|
||||
// route, and a visibility predicate. Currently only billing; this
|
||||
// shape leaves headroom for adding more without restructuring.
|
||||
const sections: Array<{
|
||||
key: string;
|
||||
href: string;
|
||||
title: string;
|
||||
description: string;
|
||||
visible: boolean;
|
||||
}> = [
|
||||
{
|
||||
key: "billing",
|
||||
href: "/settings/billing",
|
||||
title: t("billingTitle"),
|
||||
description: t("billingDescription"),
|
||||
// Owners and platform admins can edit billing. `user` role
|
||||
// can't even view it — billing details aren't useful to them.
|
||||
visible: canMutate(user),
|
||||
},
|
||||
];
|
||||
|
||||
const visibleSections = sections.filter((s) => s.visible);
|
||||
|
||||
return (
|
||||
<main className="max-w-4xl mx-auto px-6 py-8">
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
|
||||
</div>
|
||||
|
||||
{visibleSections.length === 0 && (
|
||||
<Card className="animate-in animate-in-delay-1">
|
||||
<p className="text-sm text-text-secondary">{t("nothingForYou")}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 animate-in animate-in-delay-1">
|
||||
{visibleSections.map((s) => (
|
||||
<Link
|
||||
key={s.key}
|
||||
href={s.href}
|
||||
className="block rounded-xl border border-border bg-surface-1 p-4 hover:border-text-secondary transition-colors"
|
||||
>
|
||||
<div className="font-medium text-text-primary">{s.title}</div>
|
||||
<div className="text-xs text-text-secondary mt-1">
|
||||
{s.description}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
listTenantRequestsByOrgId,
|
||||
listActiveTenantRequestsByOrgId,
|
||||
getMostRecentApprovedRequestForOrg,
|
||||
getOrgBilling,
|
||||
upsertOrgBilling,
|
||||
} from "@/lib/db";
|
||||
import { getTenant, listTenants } from "@/lib/k8s";
|
||||
import {
|
||||
@@ -16,7 +18,7 @@ import {
|
||||
import { sendAdminNotificationEmail } from "@/lib/email";
|
||||
import { encryptSecrets } from "@/lib/crypto";
|
||||
import { isPersonalOrgName } from "@/lib/personal-org";
|
||||
import { onboardingSchema } from "@/lib/validation";
|
||||
import { onboardingSchema, billingAddressSchema } from "@/lib/validation";
|
||||
import type { OnboardingInput, PiecedTenant, TenantRequest } from "@/types";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -255,8 +257,137 @@ export async function POST(request: Request) {
|
||||
const companyName = prior?.companyName ?? user.orgName;
|
||||
const contactName = prior?.contactName ?? user.name;
|
||||
const contactEmail = prior?.contactEmail ?? user.email;
|
||||
const billingAddress = prior?.billingAddress ?? input.billingAddress;
|
||||
const billingNotes = input.billingNotes ?? prior?.billingNotes;
|
||||
|
||||
// Bug 35: org-scoped billing.
|
||||
//
|
||||
// Resolution rules:
|
||||
// 1. If org_billing exists, use it (synthesise a BillingAddress
|
||||
// shape for the audit copy on tenant_requests). Wizard's
|
||||
// submitted billingAddress is ignored — the org has billing
|
||||
// on file, the wizard skipped that step.
|
||||
// 2. If no org_billing AND wizard supplied billingAddress, use
|
||||
// the wizard's data and save to org_billing for next time.
|
||||
// VAT is enforced by billingAddressSchema (required for
|
||||
// everyone).
|
||||
// 3. If no org_billing AND no wizard billingAddress: reject.
|
||||
// Billing is required for all customers regardless of
|
||||
// personal/company org structure — we're a commercial
|
||||
// product. Personal accounts (sole proprietors, individuals)
|
||||
// are still subject to billing capture.
|
||||
//
|
||||
// The synthetic BillingAddress for case 1 collapses fields that
|
||||
// org_billing has more granularly; good enough for audit, since
|
||||
// /settings/billing is the authoritative editor going forward.
|
||||
const orgBilling = await getOrgBilling(user.orgId);
|
||||
let billingAddress: TenantRequest["billingAddress"];
|
||||
let billingNotes = input.billingNotes ?? prior?.billingNotes;
|
||||
|
||||
if (orgBilling) {
|
||||
billingAddress = {
|
||||
company: orgBilling.companyName,
|
||||
street: orgBilling.streetAddress,
|
||||
postalCode: orgBilling.postalCode,
|
||||
city: orgBilling.city,
|
||||
country: orgBilling.country,
|
||||
vatNumber: orgBilling.vatNumber ?? undefined,
|
||||
};
|
||||
} else if (input.billingAddress) {
|
||||
// Wizard supplied billing — re-validate the strict shape (the
|
||||
// outer onboardingSchema marks it optional now, so we can't rely
|
||||
// on its enforcement of the inner required fields).
|
||||
const billingCheck = billingAddressSchema.safeParse(input.billingAddress);
|
||||
if (!billingCheck.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid billing address",
|
||||
details: billingCheck.error.flatten(),
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Company orgs (B2B) require companyName AND vatNumber.
|
||||
// Personal orgs (B2C — private individuals) require neither;
|
||||
// the wizard hides both fields for them and the API doesn't
|
||||
// enforce.
|
||||
if (!isPersonal) {
|
||||
const missing: Record<string, string[]> = {};
|
||||
if (
|
||||
!billingCheck.data.company ||
|
||||
billingCheck.data.company.trim().length === 0
|
||||
) {
|
||||
missing["billingAddress.company"] = ["Required for companies"];
|
||||
}
|
||||
if (
|
||||
!billingCheck.data.vatNumber ||
|
||||
billingCheck.data.vatNumber.length === 0
|
||||
) {
|
||||
missing["billingAddress.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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
billingAddress = billingCheck.data;
|
||||
|
||||
// Persist to org_billing. For personal customers (B2C, no
|
||||
// company line), fall back to their display name from the
|
||||
// session — invoices addressed to their actual name rather than
|
||||
// an opaque org id like "personal-3f2a8b1c". For companies the
|
||||
// wizard's company field is filled.
|
||||
const personalDisplayName = (user.name || user.email || "").trim();
|
||||
try {
|
||||
await upsertOrgBilling({
|
||||
zitadelOrgId: user.orgId,
|
||||
companyName:
|
||||
(billingCheck.data.company || "").trim() ||
|
||||
(isPersonal ? personalDisplayName : user.orgName) ||
|
||||
user.orgName,
|
||||
streetAddress: billingCheck.data.street,
|
||||
postalCode: billingCheck.data.postalCode,
|
||||
city: billingCheck.data.city,
|
||||
country: billingCheck.data.country,
|
||||
// Personal: undefined (no VAT). Company: enforced non-empty
|
||||
// by the check above.
|
||||
vatNumber: isPersonal ? null : billingCheck.data.vatNumber!,
|
||||
billingEmail: contactEmail,
|
||||
notes: billingNotes ?? null,
|
||||
});
|
||||
} catch (e) {
|
||||
// Non-fatal — the tenant_request still gets created with the
|
||||
// billingAddress audit copy. The customer can re-save via
|
||||
// /settings/billing if this failed.
|
||||
console.warn(
|
||||
"failed to save org_billing on first capture; tenant_request still created with audit copy",
|
||||
e
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// No billing supplied AND no org_billing record. Required for
|
||||
// everyone — commercial product, no personal-orgs-skip
|
||||
// shortcut. Customer must complete the wizard's billing step
|
||||
// or set up /settings/billing first.
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Billing information is required. Please complete the billing step or set it up at /settings/billing.",
|
||||
details: {
|
||||
fieldErrors: {
|
||||
billingAddress: ["Required"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const tenantRequest = await createTenantRequest({
|
||||
zitadelOrgId: user.orgId,
|
||||
|
||||
Reference in New Issue
Block a user