From 392b0991a55766a90beaf91948c96cdc8f06e084 Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 2 May 2026 00:04:23 +0200 Subject: [PATCH] Billing rework --- src/app/[locale]/dashboard/new/page.tsx | 5 +- src/app/[locale]/dashboard/page.tsx | 10 + src/app/[locale]/settings/billing/page.tsx | 46 +++ src/app/[locale]/settings/page.tsx | 76 +++++ src/app/api/billing/route.ts | 128 +++++++++ src/app/api/onboarding/route.ts | 137 ++++++++- src/components/layout/nav-shell.tsx | 15 + src/components/onboarding/onboarding-flow.tsx | 9 + src/components/onboarding/wizard.tsx | 93 +++++- .../settings/billing-settings-form.tsx | 264 ++++++++++++++++++ src/lib/db.ts | 113 +++++++- src/lib/validation.ts | 15 +- src/messages/de.json | 35 ++- src/messages/en.json | 35 ++- src/messages/fr.json | 35 ++- src/messages/it.json | 35 ++- src/types/index.ts | 35 +++ 17 files changed, 1070 insertions(+), 16 deletions(-) create mode 100644 src/app/[locale]/settings/billing/page.tsx create mode 100644 src/app/[locale]/settings/page.tsx create mode 100644 src/app/api/billing/route.ts create mode 100644 src/components/settings/billing-settings-form.tsx diff --git a/src/app/[locale]/dashboard/new/page.tsx b/src/app/[locale]/dashboard/new/page.tsx index 77c4129..1f09a22 100644 --- a/src/app/[locale]/dashboard/new/page.tsx +++ b/src/app/[locale]/dashboard/new/page.tsx @@ -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 (
@@ -73,6 +75,7 @@ export default async function NewInstancePage() { orgName={user.orgName} userName={user.name} userEmail={user.email} + hasOrgBilling={hasOrgBilling} />
diff --git a/src/app/[locale]/dashboard/page.tsx b/src/app/[locale]/dashboard/page.tsx index bacdd91..459571a 100644 --- a/src/app/[locale]/dashboard/page.tsx +++ b/src/app/[locale]/dashboard/page.tsx @@ -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} /> diff --git a/src/app/[locale]/settings/billing/page.tsx b/src/app/[locale]/settings/billing/page.tsx new file mode 100644 index 0000000..1c230d2 --- /dev/null +++ b/src/app/[locale]/settings/billing/page.tsx @@ -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 ( +
+
+

+ {t("title")} +

+

{t("subtitle")}

+
+ + +
+ ); +} diff --git a/src/app/[locale]/settings/page.tsx b/src/app/[locale]/settings/page.tsx new file mode 100644 index 0000000..4a160c9 --- /dev/null +++ b/src/app/[locale]/settings/page.tsx @@ -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 ( +
+
+

+ {t("title")} +

+

{t("subtitle")}

+
+ + {visibleSections.length === 0 && ( + +

{t("nothingForYou")}

+
+ )} + +
+ {visibleSections.map((s) => ( + +
{s.title}
+
+ {s.description} +
+ + ))} +
+
+ ); +} diff --git a/src/app/api/billing/route.ts b/src/app/api/billing/route.ts new file mode 100644 index 0000000..82f92f2 --- /dev/null +++ b/src/app/api/billing/route.ts @@ -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 = {}; + 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 } + ); + } +} diff --git a/src/app/api/onboarding/route.ts b/src/app/api/onboarding/route.ts index 0907981..b7efcd6 100644 --- a/src/app/api/onboarding/route.ts +++ b/src/app/api/onboarding/route.ts @@ -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 = {}; + 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, diff --git a/src/components/layout/nav-shell.tsx b/src/components/layout/nav-shell.tsx index d7f9065..70c116c 100644 --- a/src/components/layout/nav-shell.tsx +++ b/src/components/layout/nav-shell.tsx @@ -59,6 +59,21 @@ function NavBar() { {t("team")} )} + {/* Bug 35: /settings is shown to anyone who can mutate org-level + state — owners and platform admins. Personal accounts also + see it; their billing page is optional but the entry point + exists for consistency. `user`-role customers don't see it + (canMutate is false). */} + {user && + (user.isPlatform || + (Array.isArray(user.roles) && user.roles.includes("owner"))) && ( + + {t("settings")} + + )} {user?.isPlatform && ( {t("admin")} diff --git a/src/components/onboarding/onboarding-flow.tsx b/src/components/onboarding/onboarding-flow.tsx index 4bc33dc..b3c8f83 100644 --- a/src/components/onboarding/onboarding-flow.tsx +++ b/src/components/onboarding/onboarding-flow.tsx @@ -12,6 +12,13 @@ interface OnboardingFlowProps { */ userName?: string; userEmail?: string; + /** + * Bug 35: true if the org already has a billing record. The wizard + * uses this to skip the billing step on subsequent tenants — capture + * once at first onboarding, reuse afterwards. Editable later via + * /settings/billing. + */ + hasOrgBilling?: boolean; /** * Bug 6: when present, the wizard is rendered in edit mode against * the given pending request. See `OnboardingWizard` for the full @@ -37,6 +44,7 @@ export function OnboardingFlow({ orgName, userName, userEmail, + hasOrgBilling, editingRequest, }: OnboardingFlowProps) { const router = useRouter(); @@ -46,6 +54,7 @@ export function OnboardingFlow({ orgName={orgName} userName={userName} userEmail={userEmail} + hasOrgBilling={hasOrgBilling} editingRequest={editingRequest} onComplete={() => { // Navigate back to /dashboard and re-fetch on the server. The diff --git a/src/components/onboarding/wizard.tsx b/src/components/onboarding/wizard.tsx index 97808a3..521b1a4 100644 --- a/src/components/onboarding/wizard.tsx +++ b/src/components/onboarding/wizard.tsx @@ -16,7 +16,26 @@ import { type Step = "welcome" | "configure" | "billing" | "confirm"; -const STEPS: Step[] = ["welcome", "configure", "billing", "confirm"]; +// The step list. Composed once and used to compute "next/prev" arrows +// and progress indicator. Bug 35: the billing step is conditional — +// orgs that already have billing on file (subsequent tenants, or +// pre-filled via /settings/billing) skip it. The wizard's submit +// payload omits billingAddress in that case; the API picks up the +// existing org_billing row server-side. +function makeSteps(opts: { + hasOrgBilling: boolean; + isEditing: boolean; +}): Step[] { + const base: Step[] = ["welcome", "configure", "billing", "confirm"]; + // Edit mode currently still shows the billing step because we want + // the customer to be able to fix billing on a still-pending request + // BEFORE it reaches admin. Once approved, edits go through + // /settings/billing instead. Same step set for editing as new for now. + if (opts.hasOrgBilling && !opts.isEditing) { + return base.filter((s) => s !== "billing"); + } + return base; +} // Inline fallbacks — only used if the API call to /api/workspace-defaults fails const FALLBACK_SOUL = `# AI Assistant @@ -64,6 +83,18 @@ interface WizardProps { */ userName?: string; userEmail?: string; + /** + * Bug 35: when true, the wizard skips the billing step. The org + * already has billing on file (captured during a previous tenant's + * onboarding, or set directly via /settings/billing), and we don't + * re-prompt for it. The submit payload omits billingAddress in that + * case; the API picks up the existing record server-side. + * + * In edit mode this is ignored — the wizard re-renders the step + * with the request's original billingAddress so the customer can + * fix it before admin approves. + */ + hasOrgBilling?: boolean; /** * Bug 6: when present, the wizard renders in "edit" mode — fields * are pre-populated from the request, the SOUL.md auto-fetch is @@ -90,6 +121,7 @@ interface WizardProps { city?: string; postalCode?: string; country?: string; + vatNumber?: string; }; billingNotes: string; }; @@ -100,6 +132,7 @@ export function OnboardingWizard({ orgName, userName, userEmail, + hasOrgBilling, editingRequest, onComplete, }: WizardProps) { @@ -122,6 +155,13 @@ export function OnboardingWizard({ isPersonal, }); const isEditing = Boolean(editingRequest); + // STEPS is recomputed from props so toggling hasOrgBilling at the + // server level (e.g. between renders if the customer just saved + // billing on /settings/billing in another tab) flows through. Cheap. + const STEPS = makeSteps({ + hasOrgBilling: Boolean(hasOrgBilling), + isEditing, + }); // Edit mode jumps straight to the configure step — the welcome step // is a first-time onboarding affordance and only adds friction when @@ -148,6 +188,7 @@ export function OnboardingWizard({ city: editingRequest.billingAddress.city ?? "", postalCode: editingRequest.billingAddress.postalCode ?? "", country: editingRequest.billingAddress.country ?? "CH", + vatNumber: editingRequest.billingAddress.vatNumber ?? "", }, billingNotes: editingRequest.billingNotes, }; @@ -167,6 +208,7 @@ export function OnboardingWizard({ city: "", postalCode: "", country: "CH", + vatNumber: "", }, billingNotes: "", }; @@ -372,11 +414,25 @@ export function OnboardingWizard({ : "/api/onboarding"; const method = editingRequest ? "PATCH" : "POST"; + // Bug 35: when the org already has billing on file, the wizard + // skipped the billing step and `config.billingAddress` is the + // empty default. Strip it from the payload so the API picks up + // the existing org_billing record server-side rather than + // validating the empty form against billingStepSchema (which + // would reject for a company org). + const submitConfig = hasOrgBilling + ? (() => { + const { billingAddress: _bill, billingNotes: _notes, ...rest } = + config; + return rest; + })() + : config; + const res = await fetch(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - ...config, + ...submitConfig, packageSecrets: Object.keys(secretsPayload).length > 0 ? secretsPayload @@ -906,6 +962,39 @@ export function OnboardingWizard({ + {/* Bug 35: VAT identifier. Required for company customers + (B2B). Hidden entirely for personal customers (B2C — + private individuals don't have a VAT number); the API + enforces the same rule. Editable later via + /settings/billing for company customers if their VAT + id changes. */} + {!isPersonal && ( + + + { + clearError("billingAddress.vatNumber"); + setConfig((prev) => ({ + ...prev, + billingAddress: { + ...prev.billingAddress, + vatNumber: e.target.value, + }, + })); + }} + placeholder="CHE-123.456.789 MWST" + className={inputClass(errors["billingAddress.vatNumber"])} + /> +

+ {t("billingVatHelp")} +

+
+ )} +