This commit is contained in:
83
src/app/[locale]/admin/billing/orgs/page.tsx
Normal file
83
src/app/[locale]/admin/billing/orgs/page.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getOrgBilling, getOrgBillingConfig } from "@/lib/db";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { OrgPaymentModeList } from "@/components/admin/billing/org-payment-mode-list";
|
||||
|
||||
/**
|
||||
* /admin/billing/orgs — list of orgs with their payment mode
|
||||
* settings.
|
||||
*
|
||||
* Phase 9b-2. The customer's /settings/billing only exposes the
|
||||
* saved-card flow (auto-pay). Bank-transfer mode is admin-only —
|
||||
* customer must contact support to request it, admin flips the
|
||||
* pay_by_invoice flag here. Also exposes the auto_charge_enabled
|
||||
* pause-switch for support situations.
|
||||
*
|
||||
* The page is intentionally minimal: org name, country, current
|
||||
* mode, has-saved-card indicator, and toggles. Detail-level work
|
||||
* (open balances, invoice list) is on the existing pages
|
||||
* (/admin/billing, /admin/billing/invoices).
|
||||
*/
|
||||
export default async function AdminOrgsPaymentModePage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
// Same org-discovery pattern as /api/admin/billing/orgs: tenant
|
||||
// labels are the source of truth for org membership. We dedupe by
|
||||
// org id since one org can own many tenants.
|
||||
const tenants = await listTenants().catch(() => []);
|
||||
const orgIds = new Set<string>();
|
||||
for (const tnt of tenants) {
|
||||
const oid = tnt.metadata.labels?.["pieced.ch/zitadel-org-id"];
|
||||
if (oid) orgIds.add(oid);
|
||||
}
|
||||
const orgs = await Promise.all(
|
||||
Array.from(orgIds).map(async (oid) => {
|
||||
const [billing, cfg] = await Promise.all([
|
||||
getOrgBilling(oid).catch(() => null),
|
||||
getOrgBillingConfig(oid),
|
||||
]);
|
||||
return {
|
||||
zitadelOrgId: oid,
|
||||
companyName: billing?.companyName ?? null,
|
||||
country: billing?.country ?? null,
|
||||
hasSavedCard: !!cfg.stripeDefaultPaymentMethodId,
|
||||
cardLabel:
|
||||
cfg.stripePmBrand && cfg.stripePmLast4
|
||||
? `${cfg.stripePmBrand} •••• ${cfg.stripePmLast4}`
|
||||
: null,
|
||||
payByInvoice: !!cfg.payByInvoice,
|
||||
autoChargeEnabled: cfg.autoChargeEnabled !== false,
|
||||
};
|
||||
})
|
||||
);
|
||||
// Sort: orgs with billing first (most actionable), then by name.
|
||||
orgs.sort((a, b) => {
|
||||
if (!!a.companyName !== !!b.companyName) {
|
||||
return a.companyName ? -1 : 1;
|
||||
}
|
||||
return (a.companyName ?? a.zitadelOrgId).localeCompare(
|
||||
b.companyName ?? b.zitadelOrgId
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="max-w-6xl mx-auto px-6 py-8">
|
||||
<BackLink href="/admin/billing" label={t("backToBilling")} />
|
||||
<div className="mb-6">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("orgsPageTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">
|
||||
{t("orgsPageSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<OrgPaymentModeList orgs={orgs} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export default async function AdminBillingPage() {
|
||||
</div>
|
||||
|
||||
{/* Sub-tool cards */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-8 animate-in animate-in-delay-2">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8 animate-in animate-in-delay-2">
|
||||
<Link href="/admin/billing/pricing">
|
||||
<Card interactive>
|
||||
<div className="font-semibold mb-1">{t("pricingTitle")}</div>
|
||||
@@ -85,6 +85,12 @@ export default async function AdminBillingPage() {
|
||||
<div className="text-sm text-text-muted">{t("invoicesDesc")}</div>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href="/admin/billing/orgs">
|
||||
<Card interactive>
|
||||
<div className="font-semibold mb-1">{t("orgsTitle")}</div>
|
||||
<div className="text-sm text-text-muted">{t("orgsDesc")}</div>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Orgs with open balance */}
|
||||
|
||||
@@ -62,6 +62,7 @@ export default async function BillingSettingsPage() {
|
||||
<SavedCardSection
|
||||
config={config}
|
||||
isPayByInvoice={!!config?.payByInvoice}
|
||||
isPersonal={user.isPersonal}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
72
src/app/api/admin/billing/orgs/[orgId]/payment-mode/route.ts
Normal file
72
src/app/api/admin/billing/orgs/[orgId]/payment-mode/route.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import {
|
||||
getOrgBillingConfig,
|
||||
setAutoChargeEnabled,
|
||||
updateOrgBillingConfig,
|
||||
} from "@/lib/db";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/admin/billing/orgs/[orgId]/payment-mode
|
||||
*
|
||||
* Phase 9b-2. Admin-only override of an org's billing mode:
|
||||
* - payByInvoice (boolean) — flip the customer's account to
|
||||
* bank-transfer billing. Auto-charge is skipped entirely for
|
||||
* these orgs; they receive the regular issued-invoice email
|
||||
* and pay manually. Switching ON also implicitly stops
|
||||
* attempting card charges even if a saved card exists.
|
||||
* - autoChargeEnabled (boolean) — pause auto-charge without
|
||||
* committing to pay-by-invoice. Useful during disputes or
|
||||
* billing investigations.
|
||||
*
|
||||
* Either flag may be omitted; the endpoint only writes what's
|
||||
* provided. Returns the updated config.
|
||||
*/
|
||||
const bodySchema = z.object({
|
||||
payByInvoice: z.boolean().optional(),
|
||||
autoChargeEnabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ orgId: string }> }
|
||||
) {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const { orgId } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const { payByInvoice, autoChargeEnabled } = parsed.data;
|
||||
if (payByInvoice === undefined && autoChargeEnabled === undefined) {
|
||||
return NextResponse.json(
|
||||
{ error: "Provide at least one of payByInvoice or autoChargeEnabled" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
if (payByInvoice !== undefined) {
|
||||
await updateOrgBillingConfig(orgId, { payByInvoice });
|
||||
}
|
||||
if (autoChargeEnabled !== undefined) {
|
||||
await setAutoChargeEnabled(orgId, autoChargeEnabled);
|
||||
}
|
||||
const cfg = await getOrgBillingConfig(orgId);
|
||||
return NextResponse.json({ config: cfg });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to update payment mode") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import { getTenantRequestById, updateTenantRequestStatus } from "@/lib/db";
|
||||
import {
|
||||
getInvoiceById,
|
||||
getTenantRequestById,
|
||||
updateTenantRequestStatus,
|
||||
} from "@/lib/db";
|
||||
import { setTenantAnnotation } from "@/lib/k8s";
|
||||
import { sendRejectionEmail, sendResumeRejectionEmail } from "@/lib/email";
|
||||
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
|
||||
import type { SessionUser } from "@/types";
|
||||
|
||||
/**
|
||||
* POST /api/admin/requests/[id]/reject
|
||||
@@ -14,13 +20,23 @@ import { sendRejectionEmail, sendResumeRejectionEmail } from "@/lib/email";
|
||||
* suspendedAt — rejection doesn't reset it. The customer can submit
|
||||
* a fresh resume request later if circumstances change, but that
|
||||
* starts a new pending row and re-stamps the annotation.
|
||||
*
|
||||
* Phase 9b: provision rejections that have a linked paid setup
|
||||
* invoice (setup_invoice_id) trigger an automatic full refund via
|
||||
* the existing refundInvoice flow. The refund creates a credit
|
||||
* note + Stripe refund + customer email — same paper trail any
|
||||
* post-payment refund would have. Best-effort: a refund failure
|
||||
* does NOT block the rejection (admin can re-refund manually via
|
||||
* the invoice detail page if needed), but it's logged and surfaced
|
||||
* in the response so admin sees what happened.
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
let user: SessionUser;
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
user = await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
@@ -65,6 +81,63 @@ export async function POST(
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 9b: refund the setup-fee invoice if one is linked. Only
|
||||
// applies to provision rejections; resume requests never have a
|
||||
// setup_invoice_id. Skip silently if no invoice is linked (e.g.
|
||||
// the request was created before Phase 9b shipped, or the setup
|
||||
// fee was 0).
|
||||
const refundSummary: {
|
||||
attempted: boolean;
|
||||
succeeded: boolean;
|
||||
error?: string;
|
||||
} = { attempted: false, succeeded: false };
|
||||
if (
|
||||
tenantRequest.requestType === "provision" &&
|
||||
tenantRequest.setupInvoiceId
|
||||
) {
|
||||
refundSummary.attempted = true;
|
||||
try {
|
||||
// refundInvoice expects an explicit CHF amount (no "full"
|
||||
// sentinel). Compute the remaining refundable amount as
|
||||
// total minus what's already been refunded. For a fresh
|
||||
// setup-fee invoice this is just totalChf, but the formula
|
||||
// is robust if admin had partially refunded earlier (rare
|
||||
// but possible — same invoice could in theory get a manual
|
||||
// partial refund, then a rejection).
|
||||
const inv = await getInvoiceById(tenantRequest.setupInvoiceId);
|
||||
if (!inv) {
|
||||
throw new Error(
|
||||
`Linked setup invoice ${tenantRequest.setupInvoiceId} not found`
|
||||
);
|
||||
}
|
||||
const remaining = Math.round(
|
||||
(inv.totalChf - (inv.refundedTotalChf ?? 0)) * 100
|
||||
) / 100;
|
||||
if (remaining <= 0) {
|
||||
refundSummary.succeeded = true; // nothing to refund — treat as success
|
||||
} else {
|
||||
await refundInvoice({
|
||||
invoiceId: tenantRequest.setupInvoiceId,
|
||||
amountChf: remaining,
|
||||
reason: adminNotes
|
||||
? `Tenant request rejected: ${adminNotes}`
|
||||
: "Tenant request rejected",
|
||||
refundedBy: user.id,
|
||||
});
|
||||
refundSummary.succeeded = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
refundSummary.error =
|
||||
e instanceof RefundNotAllowedError
|
||||
? e.message
|
||||
: (e?.message ?? "refund failed");
|
||||
console.error(
|
||||
`Setup-fee refund failed for request ${id} (invoice ${tenantRequest.setupInvoiceId}):`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify customer. Resume requests get a different email — the
|
||||
// tenant already exists; copy needs to mention "stays suspended" and
|
||||
// the 60-day retention deadline. Provision rejections use the
|
||||
@@ -88,5 +161,6 @@ export async function POST(
|
||||
return NextResponse.json({
|
||||
message: "Request rejected.",
|
||||
request: updated,
|
||||
refund: refundSummary,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -54,12 +54,16 @@ export async function POST(request: Request) {
|
||||
country: orgBilling.country,
|
||||
},
|
||||
});
|
||||
// Pick the base URL from the request's origin so redirects
|
||||
// work in dev (localhost), staging, and prod without env vars.
|
||||
const origin = new URL(request.url).origin;
|
||||
// Base URL for redirect targets — must be the public-facing
|
||||
// origin since Stripe redirects the browser back. Behind an
|
||||
// ingress (Cedric's setup) request.url is the internal pod
|
||||
// address ("0.0.0.0:3000" / cluster.svc), useless for the
|
||||
// browser. Same env-var pattern as the invoice pay endpoint.
|
||||
const baseUrl =
|
||||
process.env.APP_BASE_URL ?? "https://app.pieced.ch";
|
||||
const session = await createSetupCheckoutSession({
|
||||
customerId,
|
||||
baseUrl: origin,
|
||||
baseUrl,
|
||||
});
|
||||
return NextResponse.json({ url: session.url });
|
||||
} catch (e) {
|
||||
|
||||
@@ -2,11 +2,15 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import {
|
||||
createTenantRequest,
|
||||
createTenantRequestPendingPayment,
|
||||
deletePendingPaymentRequest,
|
||||
getOrgBillingConfig,
|
||||
getTenantRequestById,
|
||||
listTenantRequestsByOrgId,
|
||||
listActiveTenantRequestsByOrgId,
|
||||
getMostRecentApprovedRequestForOrg,
|
||||
getOrgBilling,
|
||||
getPlatformPricing,
|
||||
upsertOrgBilling,
|
||||
} from "@/lib/db";
|
||||
import { getTenant, listTenants } from "@/lib/k8s";
|
||||
@@ -19,7 +23,18 @@ import { sendAdminNotificationEmail } from "@/lib/email";
|
||||
import { encryptSecrets } from "@/lib/crypto";
|
||||
import { isPersonalOrgName } from "@/lib/personal-org";
|
||||
import { onboardingSchema, billingAddressSchema } from "@/lib/validation";
|
||||
import type { OnboardingInput, PiecedTenant, TenantRequest } from "@/types";
|
||||
import {
|
||||
createSetupFeeCheckoutSession,
|
||||
ensureStripeCustomerForOrg,
|
||||
} from "@/lib/stripe";
|
||||
import { createTenantSetupFeeInvoice } from "@/lib/billing";
|
||||
import { deriveTenantName } from "@/lib/tenant-naming";
|
||||
import type {
|
||||
InvoiceBillingSnapshot,
|
||||
OnboardingInput,
|
||||
PiecedTenant,
|
||||
TenantRequest,
|
||||
} from "@/types";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
@@ -402,7 +417,84 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const tenantRequest = await createTenantRequest({
|
||||
// Phase 9b: enforce auto-pay before accepting an order. If the
|
||||
// org has no saved card OR has explicitly disabled auto-charge,
|
||||
// the order can't proceed — return 402 with a link to the
|
||||
// settings page where they can set up auto-pay. The wizard
|
||||
// surfaces this as a friendly redirect rather than an error.
|
||||
const cfg = await getOrgBillingConfig(user.orgId);
|
||||
const hasSavedCard = !!cfg.stripeDefaultPaymentMethodId;
|
||||
const autoChargeOn = cfg.autoChargeEnabled !== false;
|
||||
if (!hasSavedCard || !autoChargeOn) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Auto-pay must be set up before ordering a new instance. " +
|
||||
"Please save a card and ensure auto-pay is enabled on /settings/billing.",
|
||||
code: "auto_pay_required",
|
||||
redirectTo: "/settings/billing",
|
||||
},
|
||||
{ status: 402 }
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -419,30 +511,120 @@ export async function POST(request: Request) {
|
||||
isPersonal,
|
||||
});
|
||||
|
||||
// Notify admin about the new request. For follow-up instances, include
|
||||
// the instance name in the notification so the admin sees what's
|
||||
// being requested without opening the panel.
|
||||
// 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
|
||||
);
|
||||
|
||||
// Build the billing snapshot from the org's address. The wizard
|
||||
// collected the address into billingAddress on first-ever orders;
|
||||
// for subsequent ones we read the org_billing row. Either way we
|
||||
// need a complete snapshot for the invoice + Stripe customer.
|
||||
const orgBilling = await getOrgBilling(user.orgId);
|
||||
const billingSnapshot: InvoiceBillingSnapshot = orgBilling
|
||||
? {
|
||||
companyName: orgBilling.companyName,
|
||||
contactName: orgBilling.contactName ?? null,
|
||||
streetAddress: orgBilling.streetAddress,
|
||||
postalCode: orgBilling.postalCode,
|
||||
city: orgBilling.city,
|
||||
country: orgBilling.country,
|
||||
vatNumber: orgBilling.vatNumber ?? null,
|
||||
billingEmail: orgBilling.billingEmail,
|
||||
notes: orgBilling.notes ?? null,
|
||||
}
|
||||
: {
|
||||
companyName,
|
||||
contactName: contactName,
|
||||
streetAddress: billingAddress.streetAddress,
|
||||
postalCode: billingAddress.postalCode,
|
||||
city: billingAddress.city,
|
||||
country: billingAddress.country,
|
||||
vatNumber: billingAddress.vatNumber ?? null,
|
||||
billingEmail: billingAddress.billingEmail,
|
||||
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 {
|
||||
await sendAdminNotificationEmail(
|
||||
tenantRequest.contactEmail,
|
||||
tenantRequest.contactName,
|
||||
tenantRequest.instanceName
|
||||
? `${tenantRequest.companyName} (${tenantRequest.instanceName})`
|
||||
: tenantRequest.companyName
|
||||
);
|
||||
setupInvoice = await createTenantSetupFeeInvoice({
|
||||
zitadelOrgId: user.orgId,
|
||||
tenantName: derivedTenantName,
|
||||
billingSnapshot,
|
||||
locale: invoiceLocale,
|
||||
paymentMethod: "card",
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to send admin notification:", 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 }
|
||||
);
|
||||
}
|
||||
|
||||
// For diagnostics: how many other in-flight requests does this org
|
||||
// already have? Useful for the admin queue.
|
||||
const allRequests = await listTenantRequestsByOrgId(user.orgId);
|
||||
// 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 the pending_payment row.
|
||||
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: "Request submitted.",
|
||||
message: "Redirecting to payment.",
|
||||
request: publicRequestShape(tenantRequest),
|
||||
orgRequestCount: allRequests.length,
|
||||
checkoutUrl,
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
|
||||
@@ -7,14 +7,18 @@ import {
|
||||
} from "@/lib/stripe";
|
||||
import {
|
||||
getInvoiceByStripePaymentIntent,
|
||||
getInvoiceDetail,
|
||||
getOrgIdByStripeCustomerId,
|
||||
getTenantRequestForSetupFlow,
|
||||
isStripeRefundRecorded,
|
||||
linkTenantRequestSetupPayment,
|
||||
markInvoicePaid,
|
||||
markStripeEventProcessed,
|
||||
setInvoiceStripePaymentIntent,
|
||||
setSavedPaymentMethod,
|
||||
tryRecordStripeEvent,
|
||||
} from "@/lib/db";
|
||||
import { sendAdminNotificationEmail } from "@/lib/email";
|
||||
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
|
||||
|
||||
/**
|
||||
@@ -223,6 +227,129 @@ async function handleCheckoutCompleted(
|
||||
console.log(
|
||||
`Invoice ${invoiceId} marked paid via Stripe (session ${session.id}, intent ${paymentIntentId}).`
|
||||
);
|
||||
|
||||
// Phase 9b: if this Checkout was the setup-fee flow for a tenant
|
||||
// order, flip the linked tenant_request row from 'pending_payment'
|
||||
// to 'pending' so admin sees it in the queue. The invoice line's
|
||||
// tenant_name has the derived name; we also stamp it on the
|
||||
// request row so admin can act on it. linkTenantRequestSetupPayment
|
||||
// is idempotent (no-op if status already advanced).
|
||||
const flow = session.metadata?.flow;
|
||||
const tenantRequestId = session.metadata?.tenant_request_id;
|
||||
if (flow === "setup_fee" && tenantRequestId) {
|
||||
try {
|
||||
// The derived tenant_name lives on the invoice line we just
|
||||
// marked paid. Fetch via getInvoiceDetail (existing helper).
|
||||
const detail = await getInvoiceDetail(invoiceId);
|
||||
const setupLine = detail?.lines.find(
|
||||
(l) => l.kind === "tenant_setup" && l.tenantName
|
||||
);
|
||||
if (!setupLine || !setupLine.tenantName) {
|
||||
console.error(
|
||||
`Setup-fee webhook for invoice ${invoiceId} has no tenant_setup line with tenant_name; cannot link request ${tenantRequestId}.`
|
||||
);
|
||||
} else {
|
||||
const linked = await linkTenantRequestSetupPayment({
|
||||
requestId: tenantRequestId,
|
||||
tenantName: setupLine.tenantName,
|
||||
setupInvoiceId: invoiceId,
|
||||
});
|
||||
if (linked) {
|
||||
console.log(
|
||||
`Tenant request ${tenantRequestId} flipped to 'pending' (tenant=${setupLine.tenantName}, setup invoice=${invoiceId}).`
|
||||
);
|
||||
// Notify admin now that the payment cleared. Best-effort —
|
||||
// a failure here doesn't undo the linkage.
|
||||
try {
|
||||
const req = await getTenantRequestForSetupFlow(tenantRequestId);
|
||||
if (req) {
|
||||
await sendAdminNotificationEmail(
|
||||
req.contactEmail,
|
||||
req.contactName,
|
||||
req.instanceName
|
||||
? `${req.companyName} (${req.instanceName})`
|
||||
: req.companyName
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to send admin notification for tenant request ${tenantRequestId}:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`Tenant request ${tenantRequestId} not in 'pending_payment' (likely already advanced); webhook is a no-op.`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Setup-fee webhook for invoice ${invoiceId} failed to link tenant request ${tenantRequestId}:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 9b: any payment-mode Checkout that set setup_future_usage
|
||||
// attaches the resulting PaymentMethod to the customer. Read it
|
||||
// back and save the display fields against the org's config —
|
||||
// same behaviour as the setup-mode webhook does. This is what
|
||||
// makes the setup-fee Checkout also "refresh saved card" without
|
||||
// an extra step, and it's also what Phase 9b-2's manual-pay
|
||||
// with setup_future_usage will rely on.
|
||||
try {
|
||||
if (paymentIntentId) {
|
||||
const stripe = getStripeClient();
|
||||
const pi = await stripe.paymentIntents.retrieve(paymentIntentId);
|
||||
const pmId =
|
||||
typeof pi.payment_method === "string"
|
||||
? pi.payment_method
|
||||
: pi.payment_method?.id;
|
||||
const customerId =
|
||||
typeof pi.customer === "string"
|
||||
? pi.customer
|
||||
: pi.customer?.id;
|
||||
// setup_future_usage on the PI tells us this payment also
|
||||
// saved the card. If it's not set, this was a one-off pay
|
||||
// and we shouldn't overwrite anything.
|
||||
if (pmId && customerId && pi.setup_future_usage === "off_session") {
|
||||
const orgId = await getOrgIdByStripeCustomerId(customerId);
|
||||
if (orgId) {
|
||||
const display = await getPaymentMethodDisplay(pmId);
|
||||
await setSavedPaymentMethod({
|
||||
zitadelOrgId: orgId,
|
||||
stripeCustomerId: customerId,
|
||||
paymentMethodId: pmId,
|
||||
brand: display.brand,
|
||||
last4: display.last4,
|
||||
expMonth: display.expMonth,
|
||||
expYear: display.expYear,
|
||||
});
|
||||
// Also tell Stripe this PM is the customer's default for
|
||||
// future invoice charges. Best-effort.
|
||||
try {
|
||||
await stripe.customers.update(customerId, {
|
||||
invoice_settings: { default_payment_method: pmId },
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`Failed to set default_payment_method on customer ${customerId}:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`Saved PaymentMethod ${pmId} (${display.brand} ${display.last4}) for org ${orgId} via payment-mode Checkout.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to save PaymentMethod from payment-mode Checkout (session ${session.id}):`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user