627 lines
23 KiB
TypeScript
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 }
|
|
);
|
|
}
|