import { NextRequest, NextResponse } from "next/server"; import { getSessionUser, canMutate } from "@/lib/session"; import { createTenantRequest, createTenantRequestPendingPayment, deletePendingPaymentRequest, getTenantRequestById, listTenantRequestsByOrgId, listActiveTenantRequestsByOrgId, getMostRecentApprovedRequestForOrg, getOrgBilling, getPlatformPricing, upsertOrgBilling, } from "@/lib/db"; import { getTenant, listTenants } from "@/lib/k8s"; import { listVisibleTenants, canUserSeeTenant, canSeeInflightRequests, } from "@/lib/visibility"; import { sendAdminNotificationEmail } from "@/lib/email"; import { encryptSecrets } from "@/lib/crypto"; import { isPersonalOrgName } from "@/lib/personal-org"; import { onboardingSchema, billingAddressSchema } from "@/lib/validation"; import { createSetupFeeCheckoutSession, ensureStripeCustomerForOrg, } from "@/lib/stripe"; import { createTenantSetupFeeInvoice, voidInvoice } from "@/lib/billing"; import { deriveTenantName } from "@/lib/tenant-naming"; import type { InvoiceBillingSnapshot, OnboardingInput, PiecedTenant, TenantRequest, } from "@/types"; import { z } from "zod"; /** * Helper: shape a TenantRequest row for client consumption. * Hides server-only fields (encryptedSecrets, internal db ids). */ /** * Helper: shape a TenantRequest row for client consumption. * Hides server-only fields (encryptedSecrets, internal db ids). * * Slice 7 / Bug 6: surfaces enough fields for the customer-side edit * flow to pre-fill the wizard. soulMd, agentsMd, billingAddress, * billingNotes were previously kept off the public shape because the * pre-Slice-3 dashboard didn't render them. Edit needs them. * * Bug 13: surfaces dismissedAt so the dashboard can distinguish * "freshly rejected, show prominently" from "rejected and acknowledged, * keep hidden" without an extra API call. */ function publicRequestShape(r: TenantRequest) { return { id: r.id, instanceName: r.instanceName, agentName: r.agentName, soulMd: r.soulMd, agentsMd: r.agentsMd, packages: r.packages, billingAddress: r.billingAddress, billingNotes: r.billingNotes, status: r.status, adminNotes: r.adminNotes, tenantName: r.tenantName, dismissedAt: r.dismissedAt ?? null, createdAt: r.createdAt, updatedAt: r.updatedAt, }; } function publicTenantShape(t: PiecedTenant) { return { name: t.metadata.name, displayName: t.spec.displayName, phase: t.status?.phase ?? "Pending", suspended: t.spec.suspend ?? false, packages: t.spec.packages ?? [], creationTimestamp: t.metadata.creationTimestamp, conditions: t.status?.conditions ?? [], }; } /** * GET /api/onboarding * * Two response shapes depending on the `?id=` query: * * - With `?id=`: returns the single request's status plus * the linked tenant's phase if approved. Used by ProvisioningStatus * to poll a specific request. The id is validated against the * caller's orgId so admins-and-only-admins can read across orgs. * * - Without `id`: returns lists of all in-flight requests and active * tenants for the caller's org. Used by the dashboard to render the * multi-tenant view. * * Slice 3 note: this replaces the old single-state response shape * (`{ state: "...", request: {...} }`). Pre-Slice-3 callers will see * the new shape and need to be updated. The only known caller is * ``, updated in lockstep. */ export async function GET(req: NextRequest) { const user = await getSessionUser(); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const requestedId = req.nextUrl.searchParams.get("id"); if (requestedId) { const tr = await getTenantRequestById(requestedId); if (!tr) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } // Customers may only read their own org's requests; platform // admins/operators may read any. if (!user.isPlatform && tr.zitadelOrgId !== user.orgId) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } // Slice 6: a `user`-role customer doesn't see in-flight requests // even within their own org — they can't act on them and showing // the row would be a permanent "pending" state with no exit. Owner // and platform skip this gate. if (!canSeeInflightRequests(user)) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } let tenant: PiecedTenant | null = null; if (tr.tenantName) { tenant = (await getTenant(tr.tenantName)) ?? null; // If a request is already linked to a tenant CR and the caller // can't see that tenant (assignment scope), don't expose it via // the request endpoint either. canSeeInflightRequests above // already shortcuts this for `user`-role, but defense in depth. if (tenant && !(await canUserSeeTenant(user, tenant))) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } } return NextResponse.json({ request: publicRequestShape(tr), tenant: tenant ? publicTenantShape(tenant) : null, }); } // List view: requests + tenants for this org, filtered by visibility. // For owner/platform, this returns the same data as pre-Slice-6. // For user-role, requests is forced to [] and tenants is narrowed to // assignments. const [requests, allTenants] = await Promise.all([ listActiveTenantRequestsByOrgId(user.orgId), listTenants(), ]); const visibleTenants = await listVisibleTenants(user, allTenants); const visibleRequests = canSeeInflightRequests(user) ? requests : []; return NextResponse.json({ requests: visibleRequests.map(publicRequestShape), tenants: visibleTenants.map(publicTenantShape), }); } /** * POST /api/onboarding * * Always creates a NEW tenant_request row, regardless of how many other * rows already exist for this org. The pre-Slice-3 409 ("you already * have a request") is gone — multi-tenant is the design now. * * For additional instances in an existing company, the customer's prior * approved row is used to seed billing/contact info, so the wizard * doesn't need to re-collect data already on file. The wizard *does* * still send a billingAddress payload (the field is required by the * schema), but in practice the client can pre-fill it from * `getMostRecentApprovedRequestForOrg`. * * Encrypted package secrets, if provided, are AES-256-GCM-sealed and * stored as a BYTEA blob. They are decrypted only during admin approval * to write to OpenBao. */ export async function POST(request: Request) { const user = await getSessionUser(); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } // Slice 5: only owners (or platform users) may create new instances. // A `user`-role member of an existing org cannot self-provision. if (!canMutate(user)) { return NextResponse.json( { error: "Only the organization owner can create new instances." }, { status: 403 } ); } const body = await request.json(); const parsed = onboardingSchema.safeParse(body); if (!parsed.success) { return NextResponse.json( { error: "Invalid input", details: parsed.error.flatten() }, { status: 400 } ); } const input: OnboardingInput & { packageSecrets?: Record>; } = parsed.data; // Look up an existing approved request for this org to inherit // company-level billing data. For brand-new orgs (first registration), // there is no prior row and we use the form-supplied billingAddress // verbatim. For follow-up requests, we ignore the form-supplied // company line in favour of the recorded company name. const prior = await getMostRecentApprovedRequestForOrg(user.orgId); // Slice 4: detect personal-account orgs by the canonical " (Personal)" // suffix on the ZITADEL org name. Set at registration, stable for the // lifetime of the org. Persisted on the row so admin views and the // approve handler don't have to re-derive it. // // If any prior row has is_personal set, prefer that — it's the same // org and the value can't change. (The prior-row check is defensive; // the org-name check should agree.) const isPersonal = prior?.isPersonal ?? isPersonalOrgName(user.orgName); // Bug 5: personal accounts are 1-instance by design. If there's // already an active tenant or an in-flight request for this user's // org, reject the submission outright. Server-side only check; // matching UI guards live on /dashboard (button hidden) and // /dashboard/new (server-redirect to /dashboard). if (isPersonal) { const [allTenants, activeRequests] = await Promise.all([ listTenants(), listActiveTenantRequestsByOrgId(user.orgId), ]); const ownTenants = allTenants.filter( (t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId ); if (ownTenants.length > 0 || activeRequests.length > 0) { return NextResponse.json( { error: "Personal accounts are limited to one instance. Cancel your existing request or contact support to change plan.", code: "personal_account_at_capacity", }, { status: 403 } ); } } // Encrypt package secrets if provided let encryptedSecrets: Buffer | undefined; if (input.packageSecrets && Object.keys(input.packageSecrets).length > 0) { try { encryptedSecrets = await encryptSecrets(input.packageSecrets); } catch (e: any) { console.error("Failed to encrypt package secrets:", e); return NextResponse.json( { error: "Failed to secure credentials. Please try again." }, { status: 500 } ); } } // The audit copy of company name on this request stays inherited // from the first request in the org — it's a historical snapshot // of the company name at the time the request was created, and // org_billing is now the canonical source for current values. // // Phase 6 fix4: contactName and contactEmail are NOT inherited. // They identify whoever submitted THIS specific request (drives // admin display, support ticket routing, and email greetings). // The previous "prior?.contactName ?? user.name" pattern locked // the contact to whoever first onboarded the org, which broke for // any subsequent submission by a different user — admin saw the // wrong name, support emails went to the wrong person, and the // actual submitter had no way to correct it because the wizard // doesn't expose a contact-name input. The fix is simply to use // the current session user every time. const companyName = prior?.companyName ?? user.orgName; const contactName = user.name; const contactEmail = user.email; // 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 } ); } // Look up the setup fee. If it's 0 we skip the Checkout flow // entirely and create a normal pending request (same as the // pre-Phase-9b behaviour). const platformPricing = await getPlatformPricing(); const setupFeeChf = platformPricing.tenantSetupFeeChf; // ZERO-FEE PATH --------------------------------------------------- // No payment to collect. Create the request directly in 'pending' // status (same as the pre-Phase-9b flow) and notify admin. The // wizard treats this response identically to its previous // success path. if (setupFeeChf <= 0) { const tenantRequest = await createTenantRequest({ zitadelOrgId: user.orgId, zitadelUserId: user.id, companyName, instanceName: input.instanceName, contactName, contactEmail, agentName: input.agentName, soulMd: input.soulMd, agentsMd: input.agentsMd, packages: input.packages ?? [], billingAddress, billingNotes, encryptedSecrets, isPersonal, }); try { await sendAdminNotificationEmail( tenantRequest.contactEmail, tenantRequest.contactName, tenantRequest.instanceName ? `${tenantRequest.companyName} (${tenantRequest.instanceName})` : tenantRequest.companyName ); } catch (e) { console.error("Failed to send admin notification:", e); } const allRequests = await listTenantRequestsByOrgId(user.orgId); return NextResponse.json( { message: "Request submitted.", request: publicRequestShape(tenantRequest), orgRequestCount: allRequests.length, }, { status: 201 } ); } // PAID-FEE PATH --------------------------------------------------- // Insert as 'pending_payment' (tenant_name stays NULL so abandoned // Checkout sessions don't block retries). Build the setup-fee // invoice, then start a Checkout session. The wizard follows the // returned URL; on completion the webhook flips the row to // 'pending' and admin sees it in their queue. const tenantRequest = await createTenantRequestPendingPayment({ zitadelOrgId: user.orgId, zitadelUserId: user.id, companyName, instanceName: input.instanceName, contactName, contactEmail, agentName: input.agentName, soulMd: input.soulMd, agentsMd: input.agentsMd, packages: input.packages ?? [], billingAddress, billingNotes, encryptedSecrets, isPersonal, }); // Derive the future tenant_name — needed on the invoice line so // tenantHasSetupFeeBilled() in the monthly cron dedup finds the // already-paid setup fee once the K8s tenant exists. The name is // request-id-suffix-derived, so abandoned Checkout retries each // get unique names. const derivedTenantName = deriveTenantName( isPersonal ? "personal" : "company", companyName, tenantRequest.id ); // Re-fetch orgBilling here: the variable at the top of POST was // captured BEFORE the upsertOrgBilling call upstream (which fires // when the wizard collected the address on first onboarding). For // a brand-new user that initial fetch returned null; only by // re-fetching now do we get the row we just wrote. Existing // customers get the same orgBilling back either way. const billingForOrder = await getOrgBilling(user.orgId); if (!billingForOrder) { console.error( `Paid-fee onboarding path: no org_billing for org ${user.orgId} even after upsert — wizard did not collect address?` ); await deletePendingPaymentRequest(tenantRequest.id).catch(() => undefined); return NextResponse.json( { error: "Billing record missing. Please re-save your billing details." }, { status: 500 } ); } const billingSnapshot: InvoiceBillingSnapshot = { companyName: billingForOrder.companyName, contactName: billingForOrder.contactName ?? null, streetAddress: billingForOrder.streetAddress, postalCode: billingForOrder.postalCode, city: billingForOrder.city, country: billingForOrder.country, vatNumber: billingForOrder.vatNumber ?? null, billingEmail: billingForOrder.billingEmail, notes: billingForOrder.notes ?? null, }; // Locale for the invoice + PDF — pick from the org's country // using the same heuristic the auto-cron uses. const c = (billingSnapshot.country ?? "").toUpperCase(); const invoiceLocale: "de" | "en" | "fr" | "it" = ["CH", "LI", "AT", "DE"].includes(c) ? "de" : ["FR", "BE", "LU"].includes(c) ? "fr" : c === "IT" ? "it" : "en"; let setupInvoice; try { setupInvoice = await createTenantSetupFeeInvoice({ zitadelOrgId: user.orgId, tenantName: derivedTenantName, billingSnapshot, locale: invoiceLocale, paymentMethod: "card", }); } catch (e) { console.error("Failed to create setup-fee invoice:", e); // Roll back the pending_payment row so the customer can retry // without an orphan record. await deletePendingPaymentRequest(tenantRequest.id).catch(() => undefined); return NextResponse.json( { error: "Failed to prepare setup-fee invoice. Please try again." }, { status: 500 } ); } // Create the Checkout session. The Stripe customer must exist // before this — ensureStripeCustomerForOrg returns the existing // one (idempotent) since the saved-card setup already created it. let checkoutUrl: string; try { const stripeCustomerId = await ensureStripeCustomerForOrg({ zitadelOrgId: user.orgId, companyName: billingSnapshot.companyName, billingEmail: billingSnapshot.billingEmail, address: { line1: billingSnapshot.streetAddress, postalCode: billingSnapshot.postalCode, city: billingSnapshot.city, country: billingSnapshot.country, }, }); const baseUrl = process.env.APP_BASE_URL ?? "https://app.pieced.ch"; const { url } = await createSetupFeeCheckoutSession({ invoice: setupInvoice, customerId: stripeCustomerId, baseUrl, tenantRequestId: tenantRequest.id, }); checkoutUrl = url; } catch (e) { console.error("Failed to create setup-fee Checkout session:", e); // Roll back BOTH the pending_payment row and the setup invoice // we already created. The invoice was issued in 'open' status // but no payment will ever arrive (Checkout never started), so // void it to keep the ledger clean — an open invoice with no // route to payment would otherwise linger and show up in // arrears reports. Void (not delete) preserves the audit trail // and the void reason. Best-effort: a void failure is logged // but doesn't change the 500 we return. await voidInvoice({ invoiceId: setupInvoice.id, reason: "Order abandoned before payment (Checkout could not be started)", voidedBy: user.id, }).catch((ve) => console.error( `Failed to void orphaned setup invoice ${setupInvoice.id}:`, ve ) ); await deletePendingPaymentRequest(tenantRequest.id).catch(() => undefined); return NextResponse.json( { error: "Failed to start payment. Please try again." }, { status: 500 } ); } // Don't notify admin yet — the request is invisible to admin // until the webhook flips it to 'pending'. Notification happens // there. return NextResponse.json( { message: "Redirecting to payment.", request: publicRequestShape(tenantRequest), checkoutUrl, }, { status: 201 } ); }