Files
pieced-portal/src/app/api/onboarding/route.ts
admin d78f9f2696
All checks were successful
Build and Push / build (push) Successful in 1m44s
Phase8: Auto bill credit card
2026-05-28 21:49:59 +02:00

627 lines
23 KiB
TypeScript

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=<requestId>`: 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
* `<ProvisioningStatus>`, 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<string, Record<string, string>>;
} = 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<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 }
);
}
// 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 }
);
}