From ee6bb89fb633c1f8b86facc2cb8e3a775919bfdc Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 27 May 2026 22:06:32 +0200 Subject: [PATCH] Phase8: Auto bill credit card --- src/app/[locale]/admin/billing/orgs/page.tsx | 83 ++++ src/app/[locale]/admin/billing/page.tsx | 8 +- src/app/[locale]/settings/billing/page.tsx | 1 + .../orgs/[orgId]/payment-mode/route.ts | 72 +++ .../api/admin/requests/[id]/reject/route.ts | 78 ++- src/app/api/billing/setup-card/route.ts | 12 +- src/app/api/onboarding/route.ts | 218 ++++++++- src/app/api/stripe/webhook/route.ts | 127 +++++ .../admin/billing/org-payment-mode-list.tsx | 158 ++++++ src/components/onboarding/wizard.tsx | 59 +++ .../settings/saved-card-section.tsx | 80 +++- src/lib/billing.ts | 451 +++++++++++++++--- src/lib/db.ts | 163 ++++++- src/lib/email.ts | 139 ++++++ src/lib/stripe.ts | 207 ++++++++ src/messages/de.json | 27 +- src/messages/en.json | 27 +- src/messages/fr.json | 27 +- src/messages/it.json | 27 +- src/types/index.ts | 15 + 20 files changed, 1857 insertions(+), 122 deletions(-) create mode 100644 src/app/[locale]/admin/billing/orgs/page.tsx create mode 100644 src/app/api/admin/billing/orgs/[orgId]/payment-mode/route.ts create mode 100644 src/components/admin/billing/org-payment-mode-list.tsx diff --git a/src/app/[locale]/admin/billing/orgs/page.tsx b/src/app/[locale]/admin/billing/orgs/page.tsx new file mode 100644 index 0000000..b345c1e --- /dev/null +++ b/src/app/[locale]/admin/billing/orgs/page.tsx @@ -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(); + 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 ( +
+ +
+

+ {t("orgsPageTitle")} +

+

+ {t("orgsPageSubtitle")} +

+
+ +
+ ); +} diff --git a/src/app/[locale]/admin/billing/page.tsx b/src/app/[locale]/admin/billing/page.tsx index de06f6e..1f0b9c5 100644 --- a/src/app/[locale]/admin/billing/page.tsx +++ b/src/app/[locale]/admin/billing/page.tsx @@ -66,7 +66,7 @@ export default async function AdminBillingPage() { {/* Sub-tool cards */} -
+
{t("pricingTitle")}
@@ -85,6 +85,12 @@ export default async function AdminBillingPage() {
{t("invoicesDesc")}
+ + +
{t("orgsTitle")}
+
{t("orgsDesc")}
+
+
{/* Orgs with open balance */} diff --git a/src/app/[locale]/settings/billing/page.tsx b/src/app/[locale]/settings/billing/page.tsx index 0c73be5..72d1d93 100644 --- a/src/app/[locale]/settings/billing/page.tsx +++ b/src/app/[locale]/settings/billing/page.tsx @@ -62,6 +62,7 @@ export default async function BillingSettingsPage() {
)} diff --git a/src/app/api/admin/billing/orgs/[orgId]/payment-mode/route.ts b/src/app/api/admin/billing/orgs/[orgId]/payment-mode/route.ts new file mode 100644 index 0000000..667508e --- /dev/null +++ b/src/app/api/admin/billing/orgs/[orgId]/payment-mode/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/admin/requests/[id]/reject/route.ts b/src/app/api/admin/requests/[id]/reject/route.ts index bc514f6..835b644 100644 --- a/src/app/api/admin/requests/[id]/reject/route.ts +++ b/src/app/api/admin/requests/[id]/reject/route.ts @@ -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, }); } diff --git a/src/app/api/billing/setup-card/route.ts b/src/app/api/billing/setup-card/route.ts index c957629..efb03ea 100644 --- a/src/app/api/billing/setup-card/route.ts +++ b/src/app/api/billing/setup-card/route.ts @@ -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) { diff --git a/src/app/api/onboarding/route.ts b/src/app/api/onboarding/route.ts index 799e7ec..a65e4ff 100644 --- a/src/app/api/onboarding/route.ts +++ b/src/app/api/onboarding/route.ts @@ -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 } ); diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts index 521bbcf..14d87f0 100644 --- a/src/app/api/stripe/webhook/route.ts +++ b/src/app/api/stripe/webhook/route.ts @@ -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 + ); + } } /** diff --git a/src/components/admin/billing/org-payment-mode-list.tsx b/src/components/admin/billing/org-payment-mode-list.tsx new file mode 100644 index 0000000..327f883 --- /dev/null +++ b/src/components/admin/billing/org-payment-mode-list.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { Card } from "@/components/ui/card"; + +interface OrgEntry { + zitadelOrgId: string; + companyName: string | null; + country: string | null; + hasSavedCard: boolean; + cardLabel: string | null; + payByInvoice: boolean; + autoChargeEnabled: boolean; +} + +interface Props { + orgs: OrgEntry[]; +} + +/** + * Inline toggles for pay_by_invoice and auto_charge_enabled per + * org. Each toggle round-trips to /api/admin/billing/orgs/[orgId] + * /payment-mode and then router.refresh() so the server-fetched + * state stays canonical (avoids drift between optimistic UI and + * the DB). + * + * Phase 9b-2. + */ +export function OrgPaymentModeList({ orgs }: Props) { + const t = useTranslations("adminBilling"); + const router = useRouter(); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(""); + + const toggle = async ( + orgId: string, + patch: { payByInvoice?: boolean; autoChargeEnabled?: boolean } + ) => { + setError(""); + setBusy(orgId); + try { + const res = await fetch( + `/api/admin/billing/orgs/${encodeURIComponent(orgId)}/payment-mode`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + } + ); + const j = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`); + router.refresh(); + } catch (e: any) { + setError(e.message); + } finally { + setBusy(null); + } + }; + + if (orgs.length === 0) { + return ( + +
+ {t("orgsEmpty")} +
+
+ ); + } + + return ( + + {error && ( +
+ {error} +
+ )} + + + + + + + + + + + {orgs.map((o) => ( + + + + + + + ))} + +
{t("orgsColCustomer")}{t("orgsColCard")} + {t("orgsColPayByInvoice")} + + {t("orgsColAutoCharge")} +
+
+ {o.companyName ?? ( + {o.zitadelOrgId} + )} +
+ {o.country && ( +
{o.country}
+ )} +
+ {o.hasSavedCard ? ( + {o.cardLabel} + ) : ( + + {t("orgsNoSavedCard")} + + )} + + + + +
+
+ ); +} diff --git a/src/components/onboarding/wizard.tsx b/src/components/onboarding/wizard.tsx index 155a82e..511a06b 100644 --- a/src/components/onboarding/wizard.tsx +++ b/src/components/onboarding/wizard.tsx @@ -183,6 +183,11 @@ export function OnboardingWizard({ const [step, setStep] = useState(isEditing ? "configure" : "welcome"); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(""); + // Phase 9b: 402 from the onboarding endpoint indicates the org + // needs to set up auto-pay before ordering. We render a tailored + // error block with a clickable link to /settings/billing rather + // than the generic red message. + const [autoPayRequired, setAutoPayRequired] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false); // In edit mode we already have soulMd/agentsMd from the request; // skip the workspace-defaults round trip that would overwrite them. @@ -430,6 +435,7 @@ export function OnboardingWizard({ setSubmitting(true); setError(""); + setAutoPayRequired(false); try { // Build secrets payload — only for packages that require them @@ -476,11 +482,40 @@ export function OnboardingWizard({ }), }); + // Phase 9b: 402 means the org needs to set up auto-pay + // before ordering. Surface a friendly message with a link to + // /settings/billing instead of the generic submission error. + if (res.status === 402) { + const data = await res.json().catch(() => ({})); + if (data?.code === "auto_pay_required") { + setAutoPayRequired(true); + setError(t("autoPayRequiredError")); + return; + } + throw new Error(data.error || "Submission failed"); + } + if (!res.ok) { const data = await res.json(); throw new Error(data.error || "Submission failed"); } + // Phase 9b: if the server initiated a setup-fee Checkout, the + // response carries a `checkoutUrl`. Redirect the browser + // directly — Stripe Checkout is the next step. The + // tenant_requests row is already inserted in 'pending_payment' + // status; on successful Checkout, the webhook flips it to + // 'pending' and admin sees it. + const data = await res.json().catch(() => ({})); + if (data?.checkoutUrl) { + // Don't reset submitting=false — let the redirect happen + // with the spinner still active so the button stays + // disabled. + window.location.href = data.checkoutUrl; + return; + } + + // Zero-fee path or PATCH edit — same behaviour as before. onComplete(); } catch (err: any) { setError(err.message); @@ -1226,11 +1261,35 @@ export function OnboardingWizard({

{t("confirmNote")}

+ + {/* Phase 9b: order-time setup-fee notice. The exact + amount is determined server-side at submit (the + platform_pricing table is the authority), but the + customer should know that *some* charge happens on + the next click. Wording is neutral about the amount + — we don't want to mis-display a stale figure. */} +
+ + {t("setupFeeNoticeHeading")} + + {t("setupFeeNoticeBody")} +
{error && (
{error} + {autoPayRequired && ( + <> + {" "} + + {t("autoPaySetupLink")} + + + )}
)} diff --git a/src/components/settings/saved-card-section.tsx b/src/components/settings/saved-card-section.tsx index a071564..c71314b 100644 --- a/src/components/settings/saved-card-section.tsx +++ b/src/components/settings/saved-card-section.tsx @@ -15,6 +15,14 @@ interface Props { * is disabled by their billing mode. */ isPayByInvoice: boolean; + /** + * Personal-account flag from the session. Personal accounts are + * single-user B2C tenants and don't have the bank-transfer + * affordance — they pay by card or not at all. We hide the + * "Bank transfer is available on request" hint for these accounts + * to keep the messaging unambiguous. + */ + isPersonal: boolean; } const BRAND_LABELS: Record = { @@ -41,7 +49,11 @@ const BRAND_LABELS: Record = { * lands here and the new card info needs to load. We also strip * the query param so a page reload doesn't re-trigger. */ -export function SavedCardSection({ config, isPayByInvoice }: Props) { +export function SavedCardSection({ + config, + isPayByInvoice, + isPersonal, +}: Props) { const t = useTranslations("settingsBilling"); const router = useRouter(); const searchParams = useSearchParams(); @@ -125,6 +137,18 @@ export function SavedCardSection({ config, isPayByInvoice }: Props) {

{t("savedCardEmptyBody")}

+ {/* Phase 9: prominent policy notice. Auto-pay is the + expected default — emphasise that failure to keep a + chargeable card on file may result in tenant suspension. + Sits above the CTA so it's seen before the click. */} +
+ + {t("savedCardAutoPayRequiredHeading")} + + + {t("savedCardAutoPayRequiredBody")} + +
{error && (
{error}
)} @@ -135,15 +159,20 @@ export function SavedCardSection({ config, isPayByInvoice }: Props) { > {busy === "setup" ? t("savedCardRedirecting") : t("savedCardSetupBtn")} -

- {t("savedCardBankTransferHint")}{" "} - - {t("savedCardBankTransferLink")} - -

+ {/* Bank-transfer hint shown only for company accounts. + Personal (B2C) accounts pay by card only — surfacing + the alternative would only confuse. */} + {!isPersonal && ( +

+ {t("savedCardBankTransferHint")}{" "} + + {t("savedCardBankTransferLink")} + +

+ )} ); @@ -211,6 +240,16 @@ export function SavedCardSection({ config, isPayByInvoice }: Props) { )} + {/* If the card is on file but the customer has actively + disabled auto-pay, surface the suspension-risk reminder. + Not shown when admin has flipped them to pay-by-invoice — + that's a different deal and the note above explains it. */} + {!isPayByInvoice && !autoChargeOn && ( +
+ {t("savedCardAutoPayDisabledNote")} +
+ )} + {error &&
{error}
}
@@ -245,15 +284,18 @@ export function SavedCardSection({ config, isPayByInvoice }: Props) {
-

- {t("savedCardBankTransferHint")}{" "} - - {t("savedCardBankTransferLink")} - -

+ {/* Bank-transfer hint shown only for company accounts. */} + {!isPersonal && ( +

+ {t("savedCardBankTransferHint")}{" "} + + {t("savedCardBankTransferLink")} + +

+ )} ); diff --git a/src/lib/billing.ts b/src/lib/billing.ts index 60762e4..ff0d342 100644 --- a/src/lib/billing.ts +++ b/src/lib/billing.ts @@ -60,8 +60,10 @@ import { listSkillEventsForTenant, listSkillPricing, listSuspensionEventsForTenant, + markInvoicePaid, markInvoiceVoided, recordInvoiceRefund, + setInvoiceStripePaymentIntent, tenantHasSetupFeeBilled, tenantSkillHasBeenBilled, updateInvoicePdf, @@ -71,8 +73,12 @@ import { getTeamSpendLogsV2 } from "./litellm"; import { getUsage as getThreemaUsage } from "./threema-relay"; import { renderInvoicePdf } from "./billing-pdf"; import { renderCreditNotePdf } from "./credit-note-pdf"; -import { sendCreditNoteEmail, sendInvoiceIssuedEmail } from "./email"; -import { createInvoiceRefund } from "./stripe"; +import { + sendAutoChargeFailedEmail, + sendCreditNoteEmail, + sendInvoiceIssuedEmail, +} from "./email"; +import { chargeInvoiceOffSession, createInvoiceRefund } from "./stripe"; import { formatLineDescription } from "./billing-i18n"; // --------------------------------------------------------------------------- @@ -796,50 +802,90 @@ export async function generateInvoice(opts: { await updateInvoicePdf(placeholder.id, pdfBuffer, filename); const finalInvoice = await getInvoiceById(placeholder.id); - // Phase 3: best-effort notification to the billing contact. - // We send AFTER the PDF is fully persisted (so the deep link - // in the email immediately resolves to a downloadable PDF) but - // BEFORE returning, since the cron caller doesn't otherwise - // know to trigger this. Failure is logged, never thrown — a - // mail-server hiccup must not roll back an issued invoice. - // The recipient is the billing email captured in the invoice - // snapshot (immutable; reflects who was on file at issue time). - try { - const settled = finalInvoice ?? placeholder; - const snapshot = settled.billingSnapshot; - if (snapshot.billingEmail) { - const supportedLocales: Array<"en" | "de" | "fr" | "it"> = [ - "en", "de", "fr", "it", - ]; - const locale = supportedLocales.includes(settled.locale as any) - ? (settled.locale as "en" | "de" | "fr" | "it") - : "de"; - await sendInvoiceIssuedEmail({ - to: snapshot.billingEmail, - contactName: snapshot.companyName, // no separate contact-name field - companyName: snapshot.companyName, - invoiceNumber: settled.invoiceNumber, - totalChf: settled.totalChf, - currency: "CHF", - dueAt: settled.dueAt, - lineCount: draft.lines.length, - periodStart: settled.periodStart, - periodEnd: settled.periodEnd, - locale, - }); - } else { - console.warn( - `Invoice ${settled.invoiceNumber} issued but billing snapshot has no email — notification skipped.` + // Phase 9b-2: attempt off-session auto-charge BEFORE sending + // any email. This drives which email goes out: + // - Charge succeeded: skip the "your invoice is ready" email + // (would be misleading — invoice is already paid). Stripe + // sends an automated receipt to billingSnapshot.billingEmail. + // - Charge failed: send the auto-charge-failed email instead + // of the regular issued email (clear action: pay manually). + // - Charge skipped (pay_by_invoice / no card / disabled): + // send the regular "your invoice is ready" email — that's + // the only signal the customer gets. + const chargeOutcome = await chargeInvoiceIfPossible(placeholder.id); + const settled = + chargeOutcome.kind === "succeeded" + ? (await getInvoiceById(placeholder.id)) ?? finalInvoice ?? placeholder + : finalInvoice ?? placeholder; + const supportedLocales: Array<"en" | "de" | "fr" | "it"> = [ + "en", "de", "fr", "it", + ]; + const emailLocale = supportedLocales.includes(settled.locale as any) + ? (settled.locale as "en" | "de" | "fr" | "it") + : "de"; + const snapshot = settled.billingSnapshot; + + if (chargeOutcome.kind === "succeeded") { + console.log( + `Invoice ${settled.invoiceNumber} auto-charged successfully (intent ${chargeOutcome.paymentIntentId}); Stripe receipt handles customer email.` + ); + } else if (chargeOutcome.kind === "failed") { + // Send the auto-charge-failed email (not the regular issued + // email). The customer should be told the charge failed and + // pointed to the manual-pay flow. + try { + if (snapshot.billingEmail) { + await sendAutoChargeFailedEmail({ + to: snapshot.billingEmail, + contactName: snapshot.companyName, + companyName: snapshot.companyName, + invoiceNumber: settled.invoiceNumber, + totalChf: settled.totalChf, + currency: "CHF", + dueAt: settled.dueAt, + reasonForCustomer: chargeOutcome.reasonForCustomer, + locale: emailLocale, + }); + } + } catch (e) { + console.error( + `Invoice ${settled.invoiceNumber} auto-charge failed; failed-charge email also failed:`, + e + ); + } + } else { + // Skipped — pay-by-invoice / disabled / no card. Send the + // regular issued email so the customer knows there's + // something to pay. + try { + if (snapshot.billingEmail) { + await sendInvoiceIssuedEmail({ + to: snapshot.billingEmail, + contactName: snapshot.companyName, + companyName: snapshot.companyName, + invoiceNumber: settled.invoiceNumber, + totalChf: settled.totalChf, + currency: "CHF", + dueAt: settled.dueAt, + lineCount: draft.lines.length, + periodStart: settled.periodStart, + periodEnd: settled.periodEnd, + locale: emailLocale, + }); + } else { + console.warn( + `Invoice ${settled.invoiceNumber} issued but billing snapshot has no email — notification skipped.` + ); + } + } catch (e) { + console.error( + `Invoice ${placeholder.invoiceNumber} issued; notification email failed:`, + e ); } - } catch (e) { - console.error( - `Invoice ${placeholder.invoiceNumber} issued; notification email failed:`, - e - ); } - return { draft, invoice: finalInvoice ?? placeholder }; + return { draft, invoice: settled }; } catch (e) { // Render failed — leave the persisted row in place so admin can // inspect it, but surface the error. @@ -1435,29 +1481,67 @@ export async function issueCustomInvoiceDraft(params: { // future tool (Phase 8.5 or just by deleting+reissuing). } - // Best-effort email. - try { - const snap = invoiceDraft.billingSnapshot; - if (snap.billingEmail) { - await sendInvoiceIssuedEmail({ - to: snap.billingEmail, - contactName: snap.contactName || snap.companyName, - companyName: snap.companyName, - invoiceNumber: placeholder.invoiceNumber, - totalChf: placeholder.totalChf, - currency: "CHF", - dueAt: placeholder.dueAt, - lineCount: invoiceDraft.lines.length, - periodStart: null, - periodEnd: null, - locale: invoiceDraft.locale as "de" | "en" | "fr" | "it", - }); - } - } catch (e) { - console.error( - `Custom invoice ${placeholder.invoiceNumber} issued; email send failed.`, - e + // Phase 9b-2: same auto-charge + email branching as the cron + // path. Custom invoices go through the same gate: pay_by_invoice + // / auto_charge_enabled / saved card determine whether we attempt + // the charge. + const chargeOutcome = await chargeInvoiceIfPossible(placeholder.id); + const settledCustom = + chargeOutcome.kind === "succeeded" + ? (await getInvoiceById(placeholder.id)) ?? placeholder + : placeholder; + + if (chargeOutcome.kind === "succeeded") { + console.log( + `Custom invoice ${settledCustom.invoiceNumber} auto-charged successfully (intent ${chargeOutcome.paymentIntentId}); Stripe receipt handles customer email.` ); + } else if (chargeOutcome.kind === "failed") { + try { + const snap = invoiceDraft.billingSnapshot; + if (snap.billingEmail) { + await sendAutoChargeFailedEmail({ + to: snap.billingEmail, + contactName: snap.contactName || snap.companyName, + companyName: snap.companyName, + invoiceNumber: settledCustom.invoiceNumber, + totalChf: settledCustom.totalChf, + currency: "CHF", + dueAt: settledCustom.dueAt, + reasonForCustomer: chargeOutcome.reasonForCustomer, + locale: invoiceDraft.locale as "de" | "en" | "fr" | "it", + }); + } + } catch (e) { + console.error( + `Custom invoice ${settledCustom.invoiceNumber} auto-charge failed; failed-charge email also failed:`, + e + ); + } + } else { + // Skipped — send the regular issued email. + try { + const snap = invoiceDraft.billingSnapshot; + if (snap.billingEmail) { + await sendInvoiceIssuedEmail({ + to: snap.billingEmail, + contactName: snap.contactName || snap.companyName, + companyName: snap.companyName, + invoiceNumber: settledCustom.invoiceNumber, + totalChf: settledCustom.totalChf, + currency: "CHF", + dueAt: settledCustom.dueAt, + lineCount: invoiceDraft.lines.length, + periodStart: null, + periodEnd: null, + locale: invoiceDraft.locale as "de" | "en" | "fr" | "it", + }); + } + } catch (e) { + console.error( + `Custom invoice ${settledCustom.invoiceNumber} issued; email send failed.`, + e + ); + } } // Draft did its job — remove it. If this fails the issuance @@ -1471,7 +1555,7 @@ export async function issueCustomInvoiceDraft(params: { ); } - return placeholder; + return settledCustom; } /** @@ -1539,3 +1623,240 @@ export async function renderCustomDraftPreview( })) ); } + +// --------------------------------------------------------------------------- +// Phase 9b — tenant setup-fee invoice at order time +// --------------------------------------------------------------------------- + +/** + * Build and persist the one-line custom invoice that captures + * the tenant setup fee at order time. The customer is then + * redirected to Stripe Checkout to pay it. + * + * - source = 'custom' so the monthly cron's per-period uniqueness + * guard (partial index WHERE source='auto') doesn't interfere + * - line.kind = 'tenant_setup' so the monthly cron's setup-fee + * dedup (tenantHasSetupFeeBilled) sees this as the setup fee + * billing event for the future tenant + * - line.tenant_name = the derived name (computed from request id + * via deriveTenantName) so the dedup query finds the line + * - period_start / period_end stay null (no billing period) + * - issuedAt = now (no override) + * - dueAt = same day (charge happens immediately via Checkout) + * + * VAT uses the same vatRateForAddress() logic as the monthly cron + * and the admin custom-invoice flow. + */ +export async function createTenantSetupFeeInvoice(params: { + zitadelOrgId: string; + tenantName: string; + billingSnapshot: InvoiceBillingSnapshot; + locale: "de" | "en" | "fr" | "it"; + paymentMethod: InvoicePaymentMethod; +}): Promise { + const platformPricing = await getPlatformPricing(); + const setupFeeChf = platformPricing.tenantSetupFeeChf; + if (setupFeeChf <= 0) { + throw new Error( + "createTenantSetupFeeInvoice called but tenant_setup_fee_chf is 0 — caller should skip the charge flow entirely." + ); + } + + const vat = vatRateForAddress(params.billingSnapshot, platformPricing); + const subtotalChf = setupFeeChf; + const vatAmountChf = Math.round(subtotalChf * (vat.rate / 100) * 100) / 100; + const totalChf = Math.round((subtotalChf + vatAmountChf) * 100) / 100; + + // tenant_name on the line is the dedup anchor. metadata empty — + // tenant_setup lines from the monthly cron also carry no metadata + // beyond what billing-i18n needs, which is just the kind itself. + const lines: Omit[] = [ + { + tenantName: params.tenantName, + kind: "tenant_setup" as InvoiceLineKind, + description: formatLineDescription( + { kind: "tenant_setup", tenantName: params.tenantName, metadata: null }, + params.locale + ), + quantity: 1, + unitLabel: null, + unitPriceChf: setupFeeChf, + amountChf: setupFeeChf, + metadata: null, + displayOrder: 0, + }, + ]; + + const today = new Date().toISOString().slice(0, 10); + + const draft: InvoiceDraft = { + zitadelOrgId: params.zitadelOrgId, + source: "custom", + periodStart: null, + periodEnd: null, + issuedAt: undefined, // let createInvoice default to now() + dueAt: today, + locale: params.locale, + paymentMethod: params.paymentMethod, + billingSnapshot: params.billingSnapshot, + lines, + subtotalChf, + vatRate: vat.rate, + vatAmountChf, + totalChf, + warnings: [], + }; + + // Persist without PDF — the PDF render here would block the + // Checkout redirect path and isn't needed for the customer's + // payment step. Render lazily after payment succeeds (Phase 9c + // candidate); for now the invoice carries no PDF until then. + // It'll still appear on /billing for the customer; the download + // button will be disabled (hasPdf = false) until a render lands. + const invoice = await createInvoice(draft, null, null); + + // Best-effort: render the PDF asynchronously so the customer + // has it on /billing soon after paying. The async fire-and- + // forget pattern: failures only log, the invoice row stays + // valid either way. + renderInvoicePdf( + invoice, + lines.map((l, i) => ({ + ...l, + id: `tmp-${i}`, + invoiceId: invoice.id, + })) + ) + .then((pdf) => + updateInvoicePdf(invoice.id, pdf, `${invoice.invoiceNumber}.pdf`) + ) + .catch((e) => + console.error( + `Setup-fee invoice ${invoice.invoiceNumber} PDF render failed (async):`, + e + ) + ); + + return invoice; +} + +// --------------------------------------------------------------------------- +// Phase 9b-2 — recurring off-session auto-charge +// --------------------------------------------------------------------------- + +export type AutoChargeOutcome = + | { kind: "skipped"; reason: string } + | { kind: "succeeded"; paymentIntentId: string } + | { kind: "failed"; reasonForCustomer: string; code?: string }; + +/** + * Reduce a Stripe decline code into a short, locale-neutral string + * the customer can read. We never put the raw Stripe message in + * an email (it can leak BIN, country, etc.); this maps known codes + * to safe equivalents and falls back to a generic "card was + * declined" string for unknown codes. + * + * Phase 9b-2 keeps this in English only — the email template + * translates the surrounding copy, and the reason itself is short + * enough that admin can decide later whether to localize it. + */ +function describeDeclineCode(code: string | undefined, fallback: string): string { + if (!code) return fallback; + const map: Record = { + card_declined: "Card was declined by the issuer.", + expired_card: "Card has expired.", + insufficient_funds: "Insufficient funds.", + incorrect_cvc: "Card security code (CVC) was incorrect.", + processing_error: "Card processing error at the issuer.", + authentication_required: "Authentication required (3D Secure).", + do_not_honor: "Card was declined by the issuer (do not honor).", + pickup_card: "Card cannot be used — please contact the issuer.", + lost_card: "Card was reported lost.", + stolen_card: "Card was reported stolen.", + generic_decline: "Card was declined.", + }; + return map[code] ?? fallback; +} + +/** + * Decide whether an invoice can be auto-charged and attempt it. + * + * Gates (in order — first match wins): + * 1. Invoice not in 'open' status → skip ("not_open") + * 2. org_billing_config.pay_by_invoice = true → skip ("pay_by_invoice") + * (admin override for bank-transfer customers) + * 3. org_billing_config.auto_charge_enabled = false → skip ("disabled") + * 4. No saved payment method id → skip ("no_card") + * 5. No Stripe customer id → skip ("no_customer") — shouldn't happen + * if PM is saved (the setup flow creates one) but defensive + * + * On charge attempt: + * - succeeded: markInvoicePaid + return outcome + * - declined / requires_action: leave invoice open, return reason + * for the caller to send the auto-charge-failed email + * + * This function is idempotent on the invoice side (markInvoicePaid + * is a no-op if already paid). Calling twice in rapid succession + * may cause two Stripe charges if both attempts pass the gates — + * the caller (generateInvoice / issueCustomInvoiceDraft) only + * calls once per issuance and is the natural single-shot guard. + */ +export async function chargeInvoiceIfPossible( + invoiceId: string +): Promise { + const invoice = await getInvoiceById(invoiceId); + if (!invoice) { + return { kind: "skipped", reason: "invoice_not_found" }; + } + if (invoice.status !== "open") { + return { kind: "skipped", reason: `not_open (status=${invoice.status})` }; + } + + const cfg = await getOrgBillingConfig(invoice.zitadelOrgId); + if (cfg.payByInvoice) { + return { kind: "skipped", reason: "pay_by_invoice" }; + } + if (cfg.autoChargeEnabled === false) { + return { kind: "skipped", reason: "disabled" }; + } + if (!cfg.stripeDefaultPaymentMethodId) { + return { kind: "skipped", reason: "no_card" }; + } + if (!cfg.stripeCustomerId) { + return { kind: "skipped", reason: "no_customer" }; + } + + const outcome = await chargeInvoiceOffSession({ + invoice, + customerId: cfg.stripeCustomerId, + paymentMethodId: cfg.stripeDefaultPaymentMethodId, + receiptEmail: invoice.billingSnapshot.billingEmail ?? null, + }); + + if (outcome.status === "succeeded") { + // Persist the PI id + flip to paid in one shot. markInvoicePaid + // is idempotent (returns null if already paid). + await setInvoiceStripePaymentIntent(invoice.id, outcome.paymentIntentId); + await markInvoicePaid(invoice.id, { + paidBy: "stripe", + paidMethodDetail: `Auto-charge (${outcome.paymentIntentId})`, + }); + return { kind: "succeeded", paymentIntentId: outcome.paymentIntentId }; + } + + // Map outcome to a customer-safe reason string. + if (outcome.status === "requires_action") { + return { + kind: "failed", + reasonForCustomer: + "Authentication required (3D Secure). Please pay manually so your bank can complete verification.", + code: "authentication_required", + }; + } + // declined + return { + kind: "failed", + reasonForCustomer: describeDeclineCode(outcome.code, outcome.reason), + code: outcome.code, + }; +} diff --git a/src/lib/db.ts b/src/lib/db.ts index 03b05eb..85189b6 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -93,6 +93,18 @@ const MIGRATION_SQL = ` -- is only meaningful for rejected and cancelled rows. ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS dismissed_at TIMESTAMPTZ; + -- Phase 9b: link a provision request to the paid setup-fee invoice + -- it was charged against at order time. Null on requests created + -- before Phase 9b, on resume requests, and during the brief + -- 'pending_payment' window before the Stripe webhook fires. The + -- admin reject flow refunds this invoice via the existing + -- refundInvoice helper. + ALTER TABLE tenant_requests + ADD COLUMN IF NOT EXISTS setup_invoice_id UUID REFERENCES invoices(id); + CREATE INDEX IF NOT EXISTS idx_tenant_requests_setup_invoice + ON tenant_requests(setup_invoice_id) + WHERE setup_invoice_id IS NOT NULL; + -- Feature 6: free-form customer note attached to the request. -- Currently surfaced only by resume requests (where the customer -- explains why they want reactivation), but the column is generic @@ -1000,13 +1012,18 @@ export async function listTenantRequests( status?: TenantRequestStatus ): Promise { await ensureSchema(); + // Phase 9b: 'pending_payment' rows are pre-Checkout: the customer + // submitted the wizard but hasn't paid the setup fee yet. They're + // invisible to admin until the webhook flips them to 'pending'. + // The explicit filter path still allows querying them (e.g. + // ?status=pending_payment) for debugging. const result = status ? await getPool().query( "SELECT * FROM tenant_requests WHERE status = $1 ORDER BY created_at DESC", [status] ) : await getPool().query( - "SELECT * FROM tenant_requests ORDER BY created_at DESC" + "SELECT * FROM tenant_requests WHERE status <> 'pending_payment' ORDER BY created_at DESC" ); return result.rows.map(mapRow); } @@ -1431,6 +1448,7 @@ function mapRow(row: any): TenantRequest { status: row.status as TenantRequestStatus, adminNotes: row.admin_notes, tenantName: row.tenant_name, + setupInvoiceId: row.setup_invoice_id ?? null, encryptedSecrets: row.encrypted_secrets ?? null, isPersonal: row.is_personal ?? false, dismissedAt: @@ -4131,3 +4149,146 @@ export async function getOrgIdByStripeCustomerId( ); return result.rows.length > 0 ? result.rows[0].zitadel_org_id : null; } + +// --------------------------------------------------------------------------- +// Phase 9b — tenant order with setup-fee charge +// --------------------------------------------------------------------------- + +/** + * Phase 9b: invoked by the Stripe webhook when the setup-fee + * Checkout for a tenant order completes. Atomically: + * - flips the request status from 'pending_payment' → 'pending' + * (admin queue now sees it) + * - sets tenant_name to the derived value (so monthly cron's + * setup-fee dedup works) + * - links the paid invoice via setup_invoice_id (so admin reject + * can refund it via the existing refund flow) + * + * Idempotent on the request side: if the webhook re-fires after + * the row already has status='pending', the UPDATE is a no-op + * (same values). On the rare case of webhook retry happening after + * admin already approved/rejected, the WHERE clause guards against + * regressing the status. + */ +export async function linkTenantRequestSetupPayment(params: { + requestId: string; + tenantName: string; + setupInvoiceId: string; +}): Promise { + const result = await getPool().query( + `UPDATE tenant_requests + SET status = 'pending', + tenant_name = $2, + setup_invoice_id = $3, + updated_at = now() + WHERE id = $1 + AND status = 'pending_payment' + RETURNING id`, + [params.requestId, params.tenantName, params.setupInvoiceId] + ); + return (result.rowCount ?? 0) > 0; +} + +/** + * Look up a tenant request by id without restricting by status — + * used by the webhook + reject handler. Caller is responsible for + * any role-gating; this is a pure read. + */ +export async function getTenantRequestForSetupFlow( + requestId: string +): Promise { + await ensureSchema(); + const result = await getPool().query( + `SELECT * FROM tenant_requests WHERE id = $1`, + [requestId] + ); + return result.rows.length > 0 + ? rowToTenantRequest(result.rows[0]) + : null; +} + +/** + * Insert a tenant request row in the 'pending_payment' status — + * used at order time, before the Stripe Checkout completes. Once + * payment succeeds the webhook flips it to 'pending' via + * linkTenantRequestSetupPayment. + * + * tenant_name stays NULL throughout pending_payment so the unique + * partial index uniq_tenant_requests_tenant_name_provision + * (WHERE tenant_name IS NOT NULL) doesn't block retries from + * abandoned Checkout sessions. The derived tenant_name is computed + * by the caller from the inserted row's id and stored only at + * webhook time. + */ +export async function createTenantRequestPendingPayment(params: { + zitadelOrgId: string; + zitadelUserId: string; + companyName: string; + instanceName?: string | null; + contactName: string; + contactEmail: string; + agentName: string; + soulMd?: string; + agentsMd?: string | null; + packages: string[]; + billingAddress: Record; + billingNotes?: string; + encryptedSecrets?: Buffer | null; + isPersonal: boolean; +}): Promise { + await ensureSchema(); + const result = await getPool().query( + `INSERT INTO tenant_requests ( + zitadel_org_id, zitadel_user_id, + company_name, instance_name, contact_name, contact_email, + agent_name, soul_md, agents_md, packages, + billing_address, billing_notes, + encrypted_secrets, is_personal, + status, request_type + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, + $13, $14, 'pending_payment', 'provision' + ) + RETURNING *`, + [ + params.zitadelOrgId, + params.zitadelUserId, + params.companyName, + params.instanceName ?? null, + params.contactName, + params.contactEmail, + params.agentName, + params.soulMd ?? null, + params.agentsMd ?? null, + params.packages, + JSON.stringify(params.billingAddress), + params.billingNotes ?? null, + params.encryptedSecrets ?? null, + params.isPersonal, + ] + ); + return rowToTenantRequest(result.rows[0]); +} + +/** + * Delete a pending_payment row — used when admin or system needs + * to clean up an abandoned order (e.g. Checkout session expired + * before the customer completed payment). Guarded: only deletes + * if status is still 'pending_payment' so we never accidentally + * delete a request that admin has already approved. + * + * Also nulls any setup_invoice_id reference before deleting so we + * don't leave dangling FK refs (we don't have ON DELETE behavior + * defined on the column). + */ +export async function deletePendingPaymentRequest( + requestId: string +): Promise { + const result = await getPool().query( + `DELETE FROM tenant_requests + WHERE id = $1 AND status = 'pending_payment' + RETURNING id`, + [requestId] + ); + return (result.rowCount ?? 0) > 0; +} diff --git a/src/lib/email.ts b/src/lib/email.ts index 1c41100..8d7c821 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -1321,3 +1321,142 @@ export async function sendCreditNoteEmail(params: { console.error("Failed to send credit note email:", err); } } + +// --------------------------------------------------------------------------- +// Phase 9b-2 — auto-charge failure notice +// --------------------------------------------------------------------------- + +/** + * Sent when an off-session auto-charge attempt fails for an issued + * invoice (card declined, expired, 3DS required, etc.). Customer + * receives this in their billing-snapshot locale. Contains: + * - Invoice number + amount + due date + * - Failure reason (a short human-readable string from Stripe) + * - Manual-pay link to /billing/ where they can + * run the regular Pay-by-Card flow (which uses + * setup_future_usage to also refresh the saved card) + * + * Critical: the failure reason from Stripe can contain sensitive + * details (card BIN, country, etc.). We pass a sanitized short + * string from the caller — never the full raw error. + */ +export async function sendAutoChargeFailedEmail(params: { + to: string; + contactName: string; + companyName: string; + invoiceNumber: string; + totalChf: number; + currency: string; + dueAt: string; + /** + * Short, customer-safe reason. e.g. "Your card was declined." + * or "Your card has expired." Caller maps Stripe error codes to + * these strings; we never pass raw API error messages. + */ + reasonForCustomer: string; + locale: "de" | "en" | "fr" | "it"; +}): Promise { + const L = params.locale; + const totalFmt = `${params.currency} ${params.totalChf.toFixed(2)}`; + const dueFmt = params.dueAt.slice(0, 10); + const baseUrl = process.env.APP_BASE_URL ?? "https://app.pieced.ch"; + const link = `${baseUrl}/billing/${encodeURIComponent(params.invoiceNumber)}`; + + const subjectsByLocale: Record = { + en: `Auto-charge failed for invoice ${params.invoiceNumber} — please pay manually`, + de: `Auto-Abbuchung fehlgeschlagen für Rechnung ${params.invoiceNumber} — bitte manuell bezahlen`, + fr: `Échec du prélèvement automatique pour la facture ${params.invoiceNumber} — merci de régler manuellement`, + it: `Addebito automatico fallito per la fattura ${params.invoiceNumber} — la preghiamo di pagare manualmente`, + }; + const greetingsByLocale: Record = { + en: `Hello ${params.contactName},`, + de: `Sehr geehrte/r ${params.contactName},`, + fr: `Bonjour ${params.contactName},`, + it: `Gentile ${params.contactName},`, + }; + const introByLocale: Record = { + en: `We were unable to charge your saved card for invoice ${params.invoiceNumber} (${params.companyName}).`, + de: `Wir konnten die Rechnung ${params.invoiceNumber} (${params.companyName}) nicht über die hinterlegte Karte abbuchen.`, + fr: `Nous n'avons pas pu débiter votre carte enregistrée pour la facture ${params.invoiceNumber} (${params.companyName}).`, + it: `Non siamo riusciti ad addebitare la carta salvata per la fattura ${params.invoiceNumber} (${params.companyName}).`, + }; + const reasonLabel: Record = { + en: "Reason given by the card network", + de: "Vom Kartennetzwerk gemeldeter Grund", + fr: "Motif communiqué par le réseau de carte", + it: "Motivo comunicato dal circuito", + }; + const actionLineByLocale: Record = { + en: `Please pay this invoice manually before ${dueFmt} to avoid service interruption. The "Pay with card" button below will both charge the invoice and update the card we have on file for future charges.`, + de: `Bitte begleichen Sie diese Rechnung manuell vor dem ${dueFmt}, um eine Unterbrechung Ihres Dienstes zu vermeiden. Die Schaltfläche "Mit Karte bezahlen" unten begleicht die Rechnung und aktualisiert gleichzeitig die hinterlegte Karte für zukünftige Abbuchungen.`, + fr: `Veuillez régler cette facture manuellement avant le ${dueFmt} pour éviter toute interruption du service. Le bouton "Payer par carte" ci-dessous règle la facture et met à jour la carte enregistrée pour les futurs prélèvements.`, + it: `La preghiamo di saldare questa fattura manualmente entro il ${dueFmt} per evitare interruzioni del servizio. Il pulsante "Paga con carta" qui sotto salda la fattura e aggiorna allo stesso tempo la carta in archivio per gli addebiti futuri.`, + }; + const labels: Record> = { + en: { number: "Invoice", total: "Total", due: "Due by", cta: "Pay with card", signoff: "Best regards", brand: "PieCed IT" }, + de: { number: "Rechnung", total: "Gesamt", due: "Zahlbar bis", cta: "Mit Karte bezahlen", signoff: "Mit freundlichen Grüssen", brand: "PieCed IT" }, + fr: { number: "Facture", total: "Total", due: "À régler avant", cta: "Payer par carte", signoff: "Cordialement", brand: "PieCed IT" }, + it: { number: "Fattura", total: "Totale", due: "Scadenza", cta: "Paga con carta", signoff: "Cordiali saluti", brand: "PieCed IT" }, + }; + const l = labels[L]; + + const safeName = escapeHtml(params.contactName); + const safeCompany = escapeHtml(params.companyName); + const safeNumber = escapeHtml(params.invoiceNumber); + const safeReason = escapeHtml(params.reasonForCustomer); + const safeIntro = escapeHtml(introByLocale[L]); + const safeAction = escapeHtml(actionLineByLocale[L]); + + try { + await getTransporter().sendMail({ + from: getFrom(), + to: params.to, + subject: subjectsByLocale[L], + text: [ + greetingsByLocale[L], + "", + introByLocale[L], + "", + `${l.number}: ${params.invoiceNumber}`, + `${l.total}: ${totalFmt}`, + `${l.due}: ${dueFmt}`, + "", + `${reasonLabel[L]}: ${params.reasonForCustomer}`, + "", + actionLineByLocale[L], + "", + `${l.cta}:`, + link, + "", + `${l.signoff},`, + l.brand, + ].join("\n"), + html: ` +
+

${escapeHtml(subjectsByLocale[L])}

+

${escapeHtml(greetingsByLocale[L])}

+

${safeIntro}

+ + + + +
${l.number}${safeNumber}
${l.total}${escapeHtml(totalFmt)}
${l.due}${escapeHtml(dueFmt)}
+
+ ${escapeHtml(reasonLabel[L])}: ${safeReason} +
+

${safeAction}

+

+ + ${l.cta} + +

+

+ ${l.signoff},
${l.brand} +

+
+ `, + }); + } catch (err) { + console.error("Failed to send auto-charge-failed email:", err); + } +} diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts index af0069b..82615f2 100644 --- a/src/lib/stripe.ts +++ b/src/lib/stripe.ts @@ -250,6 +250,15 @@ export async function createCheckoutSessionForInvoice(params: { // since Stripe will prepend the merchant name from the // account anyway. Keep it short and recognisable. description: `Invoice ${invoice.invoiceNumber}`, + // Phase 9b-2: every manual Pay-by-Card refreshes the org's + // saved PaymentMethod. The webhook (payment-mode handler) is + // already wired to read setup_future_usage and persist the + // resulting PM's display fields against the org. Net effect: + // a customer whose auto-charge failed because their card + // expired pays manually once → fresh card is now saved → + // next month auto-charges work again. No separate "update + // card" step needed. + setup_future_usage: "off_session", }, success_url: successUrl, cancel_url: cancelUrl, @@ -443,3 +452,201 @@ export async function getPaymentMethodDisplay( expYear: typeof card.exp_year === "number" ? card.exp_year : null, }; } + +// --------------------------------------------------------------------------- +// Phase 9b — order-time setup-fee Checkout +// --------------------------------------------------------------------------- + +/** + * Create a Stripe Checkout session that charges the setup-fee + * invoice immediately AND saves/refreshes the customer's + * PaymentMethod for future off-session use (recurring monthly + * charges). + * + * Same `mode: 'payment'` as the regular pay-invoice Checkout — + * the difference is: + * - metadata.flow = 'setup_fee' so the webhook knows to flip + * the tenant_request row from 'pending_payment' to 'pending' + * and link the invoice to it + * - metadata.tenant_request_id is the row to update + * - payment_intent_data.setup_future_usage = 'off_session' so + * the resulting PaymentMethod gets saved against the customer. + * Phase 9b-2's recurring auto-charge reads that PM id + * + * Success URL routes to /dashboard?ordered=1 (vs. the regular + * pay flow which lands on /billing/). Cancel + * routes to /onboarding?cancelled=1 so the customer can retry. + */ +export async function createSetupFeeCheckoutSession(params: { + invoice: Invoice; + customerId: string; + baseUrl: string; + tenantRequestId: string; +}): Promise<{ url: string; sessionId: string }> { + const stripe = getStripeClient(); + const { invoice, customerId, baseUrl, tenantRequestId } = params; + + const stripeLocale = + invoice.locale === "de" + ? ("de" as const) + : invoice.locale === "fr" + ? ("fr" as const) + : invoice.locale === "it" + ? ("it" as const) + : invoice.locale === "en" + ? ("en" as const) + : ("auto" as const); + + const successUrl = `${baseUrl}/dashboard?ordered=1&session_id={CHECKOUT_SESSION_ID}`; + const cancelUrl = `${baseUrl}/onboarding?cancelled=1`; + + const session = await stripe.checkout.sessions.create({ + mode: "payment", + customer: customerId, + client_reference_id: invoice.id, + locale: stripeLocale, + line_items: [ + { + quantity: 1, + price_data: { + currency: "chf", + unit_amount: chfToRappen(invoice.totalChf), + product_data: { + name: `Setup fee — ${invoice.invoiceNumber}`, + description: `PieCed IT — tenant setup`, + }, + }, + }, + ], + payment_intent_data: { + // Save the resulting PaymentMethod against the customer for + // future off-session use (Phase 9b-2 recurring charges). + setup_future_usage: "off_session", + metadata: { + invoice_id: invoice.id, + invoice_number: invoice.invoiceNumber, + zitadel_org_id: invoice.zitadelOrgId, + }, + }, + metadata: { + invoice_id: invoice.id, + invoice_number: invoice.invoiceNumber, + zitadel_org_id: invoice.zitadelOrgId, + // Phase 9b discriminators — webhook reads these to do the + // tenant_request linkage on top of the regular invoice-paid + // flow. + flow: "setup_fee", + tenant_request_id: tenantRequestId, + }, + success_url: successUrl, + cancel_url: cancelUrl, + }); + if (!session.url) { + throw new Error( + `Stripe returned a setup-fee session without a redirect URL (id=${session.id})` + ); + } + return { url: session.url, sessionId: session.id }; +} + +// --------------------------------------------------------------------------- +// Phase 9b-2 — off-session auto-charge for issued invoices +// --------------------------------------------------------------------------- + +/** + * Attempt to charge an invoice off-session against the customer's + * saved PaymentMethod. Used by chargeInvoiceIfPossible() from + * generateInvoice (monthly) and issueCustomInvoiceDraft (admin + * custom). + * + * Stripe semantics with `off_session: true, confirm: true`: + * - On success: PaymentIntent.status = 'succeeded', card was + * charged. Returns 'succeeded'. + * - On 3DS required: PaymentIntent.status = 'requires_action'. + * We can't complete this off-session. Customer must pay + * manually via Checkout (which handles 3DS in-browser). + * Returns 'requires_action'. + * - On hard decline: thrown StripeCardError, code = 'card_declined' + * or 'insufficient_funds' etc. Returns 'declined' with the + * error code. + * - On expired card or other recoverable issue: thrown + * StripeCardError. Returns 'declined' with the code. + * + * The receipt_email is set to the org's billing email so Stripe + * sends the customer an automated receipt on success — we don't + * need to send our own "you've been charged" email. + */ +export type ChargeOutcome = + | { status: "succeeded"; paymentIntentId: string } + | { status: "requires_action"; paymentIntentId: string; reason: string } + | { status: "declined"; reason: string; code?: string }; + +export async function chargeInvoiceOffSession(params: { + invoice: Invoice; + customerId: string; + paymentMethodId: string; + /** + * If set, Stripe emails an automated receipt here on successful + * capture. We use the org's billing snapshot email so the receipt + * goes to the same address as the issued / failed emails. + */ + receiptEmail?: string | null; +}): Promise { + const stripe = getStripeClient(); + const { invoice, customerId, paymentMethodId, receiptEmail } = params; + try { + const pi = await stripe.paymentIntents.create({ + amount: chfToRappen(invoice.totalChf), + currency: "chf", + customer: customerId, + payment_method: paymentMethodId, + off_session: true, + confirm: true, + description: `Invoice ${invoice.invoiceNumber}`, + receipt_email: receiptEmail ?? undefined, + metadata: { + invoice_id: invoice.id, + invoice_number: invoice.invoiceNumber, + zitadel_org_id: invoice.zitadelOrgId, + flow: "auto_charge", + }, + }); + if (pi.status === "succeeded") { + return { status: "succeeded", paymentIntentId: pi.id }; + } + if (pi.status === "requires_action") { + return { + status: "requires_action", + paymentIntentId: pi.id, + reason: "Authentication required (3DS). Customer must pay via Checkout.", + }; + } + // Any other non-succeeded status (rare with off_session+confirm) + // is treated as a failure for our purposes. + return { + status: "declined", + reason: `Unexpected PaymentIntent status: ${pi.status}`, + }; + } catch (e: any) { + // Stripe's off-session declines surface as a StripeCardError + // with the PI on e.payment_intent. The 'code' (e.g. + // 'card_declined', 'expired_card', 'authentication_required') + // is the most actionable signal; e.message is human-readable. + const code: string | undefined = e?.code ?? e?.raw?.code; + const message: string = + e?.message ?? e?.raw?.message ?? "Card was declined."; + // authentication_required is technically a "decline" from the + // off-session path even though it could succeed on-session. + // Surface it distinctly so the caller can tell the customer to + // go pay manually (which will use Checkout + handle 3DS). + if (code === "authentication_required") { + const piId = e?.payment_intent?.id ?? ""; + return { + status: "requires_action", + paymentIntentId: piId, + reason: "Authentication required (3DS). Customer must pay via Checkout.", + }; + } + return { status: "declined", reason: message, code }; + } +} diff --git a/src/messages/de.json b/src/messages/de.json index 0d4ccb6..7078f4b 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -122,7 +122,11 @@ "billingVatNumber": "MWST-Nummer", "billingVatHelp": "Ihre registrierte MWST-Nummer. Falls Ihre Firma von der MWST befreit ist, leer lassen und in den Notizen erläutern.", "billingNotesPlaceholderPersonal": "Was wir wissen sollten — bevorzugte Zahlungsart, Rechnungsreferenz, etc.", - "reviewContactPersonPrefix": "z.Hd." + "reviewContactPersonPrefix": "z.Hd.", + "autoPayRequiredError": "Auto-Zahlung muss vor der Bestellung einer neuen Instanz eingerichtet sein. Richten Sie zuerst die Auto-Zahlung ein und senden Sie das Formular erneut.", + "autoPaySetupLink": "Auto-Zahlung einrichten →", + "setupFeeNoticeHeading": "Einrichtungsgebühr wird beim Senden belastet", + "setupFeeNoticeBody": "Mit dem nächsten Klick werden Sie zu Stripe weitergeleitet, um die einmalige Einrichtungsgebühr für diese Instanz zu bezahlen. Anschliessend gelangen Sie direkt zurück zum Dashboard. Die Instanz startet erst nach Admin-Freigabe — monatliche Gebühren beginnen ab dem Freigabedatum." }, "dashboard": { "title": "Dashboard", @@ -526,7 +530,10 @@ "savedCardEnableAutoChargeBtn": "Auto-Zahlung aktivieren", "savedCardPayByInvoiceNote": "Ihr Konto ist auf Banküberweisung eingestellt; die hinterlegte Karte wird nicht für automatische Abbuchungen verwendet. Wenden Sie sich an den Support, wenn Sie wieder per Karte bezahlen möchten.", "savedCardBankTransferHint": "Banküberweisung ist auf Anfrage ebenfalls möglich.", - "savedCardBankTransferLink": "Kontaktieren Sie uns dafür." + "savedCardBankTransferLink": "Kontaktieren Sie uns dafür.", + "savedCardAutoPayRequiredHeading": "Auto-Zahlung ist erforderlich", + "savedCardAutoPayRequiredBody": "PieCed IT arbeitet mit automatischer Kartenzahlung. Wir behalten uns das Recht vor, Tenants bis zur Begleichung offener Rechnungen zu sperren, falls die automatische Abrechnung fehlschlägt.", + "savedCardAutoPayDisabledNote": "Auto-Zahlung ist derzeit deaktiviert. Zukünftige Rechnungen müssen manuell beglichen werden — bei Nichtbezahlung behalten wir uns das Recht vor, die zugehörigen Tenants zu sperren." }, "support": { "title": "Support", @@ -781,7 +788,21 @@ "editorIssueConfirm": "Rechnung jetzt ausstellen? Eine Rechnungsnummer wird zugewiesen, das PDF wird dem Kunden zugesendet und dieser Entwurf wird entfernt.", "editorDeleteConfirm": "Diesen Entwurf verwerfen? Kann nicht rückgängig gemacht werden.", "previewing": "Wird geöffnet…", - "issuing": "Wird ausgestellt…" + "issuing": "Wird ausgestellt…", + "orgsTitle": "Kunden-Abrechnung", + "orgsDesc": "Zahlungsart + Auto-Zahlung pro Kunde", + "orgsPageTitle": "Kunden-Abrechnungsmodi", + "orgsPageSubtitle": "Überschreibung der Zahlungsart für einzelne Kunden. Zahlung per Rechnung ersetzt die automatische Kartenabbuchung durch manuelle Banküberweisung; das Pausieren der Auto-Zahlung behält die hinterlegte Karte, stoppt aber Abbuchungsversuche (nützlich bei Streitfällen).", + "orgsEmpty": "Noch keine Kunden-Organisationen.", + "orgsColCustomer": "Kunde", + "orgsColCard": "Hinterlegte Karte", + "orgsColPayByInvoice": "Zahlung per Banküberweisung", + "orgsColAutoCharge": "Auto-Zahlung", + "orgsNoSavedCard": "keine", + "orgsPayByInvoiceOn": "ein", + "orgsPayByInvoiceOff": "aus", + "orgsAutoChargeOn": "ein", + "orgsAutoChargeOff": "aus" }, "skillCostDialog": { "title": "Aktivierungskosten bestätigen", diff --git a/src/messages/en.json b/src/messages/en.json index ecd8f78..76e0949 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -122,7 +122,11 @@ "billingVatNumber": "VAT number", "billingVatHelp": "Your registered VAT identifier. If your company is VAT-exempt, leave blank and explain in the notes field.", "billingNotesPlaceholderPersonal": "Anything we should know — preferred payment method, billing reference, etc.", - "reviewContactPersonPrefix": "Attn:" + "reviewContactPersonPrefix": "Attn:", + "autoPayRequiredError": "Auto-pay is required before ordering a new instance. Set up auto-pay first, then submit again.", + "autoPaySetupLink": "Set up auto-pay →", + "setupFeeNoticeHeading": "Setup fee will be charged on submit", + "setupFeeNoticeBody": "On the next click you'll be redirected to Stripe to pay the one-time setup fee for this instance. You'll be brought back to your dashboard immediately afterwards. The instance starts running only after admin approval — monthly fees begin from the approval date." }, "dashboard": { "title": "Dashboard", @@ -526,7 +530,10 @@ "savedCardEnableAutoChargeBtn": "Enable auto-pay", "savedCardPayByInvoiceNote": "Your account is set to pay by bank transfer; the saved card is not used for automatic charges. Contact support if you'd like to switch back to card payment.", "savedCardBankTransferHint": "Bank transfer is also available on request.", - "savedCardBankTransferLink": "Contact us to arrange." + "savedCardBankTransferLink": "Contact us to arrange.", + "savedCardAutoPayRequiredHeading": "Auto-pay is required", + "savedCardAutoPayRequiredBody": "PieCed IT operates on automatic card payment. We reserve the right to suspend tenants until outstanding invoices are paid if automatic billing fails.", + "savedCardAutoPayDisabledNote": "Auto-pay is currently disabled. Future invoices will need to be paid manually — if they go unpaid we reserve the right to suspend the tenants associated with this account." }, "support": { "title": "Support", @@ -781,7 +788,21 @@ "editorIssueConfirm": "Issue this invoice now? An invoice number will be allocated, the PDF will be sent to the customer, and this draft will be removed.", "editorDeleteConfirm": "Discard this draft? This cannot be undone.", "previewing": "Opening…", - "issuing": "Issuing…" + "issuing": "Issuing…", + "orgsTitle": "Customer billing", + "orgsDesc": "Payment mode + auto-charge per customer", + "orgsPageTitle": "Customer billing modes", + "orgsPageSubtitle": "Override payment mode for individual customers. Pay-by-invoice replaces card auto-charge with manual bank transfer; pausing auto-charge keeps the saved card on file but stops attempting charges (useful during disputes).", + "orgsEmpty": "No customer orgs yet.", + "orgsColCustomer": "Customer", + "orgsColCard": "Saved card", + "orgsColPayByInvoice": "Pay by bank transfer", + "orgsColAutoCharge": "Auto-charge", + "orgsNoSavedCard": "none", + "orgsPayByInvoiceOn": "on", + "orgsPayByInvoiceOff": "off", + "orgsAutoChargeOn": "on", + "orgsAutoChargeOff": "off" }, "skillCostDialog": { "title": "Confirm activation cost", diff --git a/src/messages/fr.json b/src/messages/fr.json index 057248c..a7c4cd1 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -122,7 +122,11 @@ "billingVatNumber": "Numéro de TVA", "billingVatHelp": "Votre identifiant TVA enregistré. Si votre entreprise est exonérée de TVA, laissez vide et précisez dans les notes.", "billingNotesPlaceholderPersonal": "Tout ce que nous devons savoir — moyen de paiement préféré, référence de facturation, etc.", - "reviewContactPersonPrefix": "À l'attention de" + "reviewContactPersonPrefix": "À l'attention de", + "autoPayRequiredError": "Le paiement automatique est requis avant de commander une nouvelle instance. Configurez d'abord le paiement automatique, puis soumettez à nouveau.", + "autoPaySetupLink": "Configurer le paiement automatique →", + "setupFeeNoticeHeading": "Les frais de configuration seront facturés à l'envoi", + "setupFeeNoticeBody": "Au prochain clic vous serez redirigé vers Stripe pour régler les frais d'activation uniques de cette instance. Vous reviendrez immédiatement au tableau de bord. L'instance ne démarre qu'après validation par l'administrateur — les frais mensuels commencent à compter de la date de validation." }, "dashboard": { "title": "Tableau de bord", @@ -526,7 +530,10 @@ "savedCardEnableAutoChargeBtn": "Activer le paiement automatique", "savedCardPayByInvoiceNote": "Votre compte est configuré pour le paiement par virement ; la carte enregistrée n'est pas utilisée pour les prélèvements automatiques. Contactez le support si vous souhaitez revenir au paiement par carte.", "savedCardBankTransferHint": "Le paiement par virement est également possible sur demande.", - "savedCardBankTransferLink": "Contactez-nous pour l'organiser." + "savedCardBankTransferLink": "Contactez-nous pour l'organiser.", + "savedCardAutoPayRequiredHeading": "Le paiement automatique est requis", + "savedCardAutoPayRequiredBody": "PieCed IT fonctionne sur la base d'un paiement automatique par carte. Nous nous réservons le droit de suspendre les tenants jusqu'au règlement des factures impayées si la facturation automatique échoue.", + "savedCardAutoPayDisabledNote": "Le paiement automatique est actuellement désactivé. Les factures futures devront être réglées manuellement — en cas de non-paiement, nous nous réservons le droit de suspendre les tenants associés à ce compte." }, "support": { "title": "Support", @@ -781,7 +788,21 @@ "editorIssueConfirm": "Émettre cette facture maintenant ? Un numéro de facture sera attribué, le PDF sera envoyé au client et ce brouillon sera supprimé.", "editorDeleteConfirm": "Supprimer ce brouillon ? Cette action est irréversible.", "previewing": "Ouverture…", - "issuing": "Émission…" + "issuing": "Émission…", + "orgsTitle": "Facturation client", + "orgsDesc": "Mode de paiement + paiement auto. par client", + "orgsPageTitle": "Modes de facturation client", + "orgsPageSubtitle": "Surcharge du mode de paiement pour les clients individuels. Le paiement par virement remplace le prélèvement automatique par carte ; la pause du paiement automatique conserve la carte enregistrée mais cesse les tentatives de prélèvement (utile en cas de litige).", + "orgsEmpty": "Aucun client pour le moment.", + "orgsColCustomer": "Client", + "orgsColCard": "Carte enregistrée", + "orgsColPayByInvoice": "Paiement par virement", + "orgsColAutoCharge": "Paiement automatique", + "orgsNoSavedCard": "aucune", + "orgsPayByInvoiceOn": "actif", + "orgsPayByInvoiceOff": "inactif", + "orgsAutoChargeOn": "actif", + "orgsAutoChargeOff": "inactif" }, "skillCostDialog": { "title": "Confirmer le coût d'activation", diff --git a/src/messages/it.json b/src/messages/it.json index 81e64f0..bcfe8e4 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -122,7 +122,11 @@ "billingVatNumber": "Partita IVA", "billingVatHelp": "Il tuo identificativo IVA registrato. Se la tua azienda è esente IVA, lascia vuoto e spiega nelle note.", "billingNotesPlaceholderPersonal": "Qualsiasi cosa dovremmo sapere — metodo di pagamento preferito, riferimento per fatturazione, ecc.", - "reviewContactPersonPrefix": "c.a." + "reviewContactPersonPrefix": "c.a.", + "autoPayRequiredError": "Il pagamento automatico è obbligatorio prima di ordinare una nuova istanza. Configuri prima il pagamento automatico, poi invii nuovamente.", + "autoPaySetupLink": "Configura pagamento automatico →", + "setupFeeNoticeHeading": "Le spese di attivazione saranno addebitate all'invio", + "setupFeeNoticeBody": "Al clic successivo sarà reindirizzato a Stripe per pagare le spese di attivazione una tantum per questa istanza. Tornerà subito alla dashboard. L'istanza si avvia solo dopo l'approvazione dell'admin — i canoni mensili decorrono dalla data di approvazione." }, "dashboard": { "title": "Dashboard", @@ -526,7 +530,10 @@ "savedCardEnableAutoChargeBtn": "Attiva pagamento automatico", "savedCardPayByInvoiceNote": "Il suo account è impostato per il pagamento tramite bonifico; la carta salvata non viene utilizzata per gli addebiti automatici. Contatti l'assistenza se desidera tornare al pagamento con carta.", "savedCardBankTransferHint": "Il pagamento tramite bonifico è disponibile su richiesta.", - "savedCardBankTransferLink": "Ci contatti per organizzarlo." + "savedCardBankTransferLink": "Ci contatti per organizzarlo.", + "savedCardAutoPayRequiredHeading": "Il pagamento automatico è obbligatorio", + "savedCardAutoPayRequiredBody": "PieCed IT opera con pagamento automatico tramite carta. Ci riserviamo il diritto di sospendere i tenant fino al saldo delle fatture pendenti in caso di fallimento della fatturazione automatica.", + "savedCardAutoPayDisabledNote": "Il pagamento automatico è attualmente disattivato. Le fatture future dovranno essere saldate manualmente — in caso di mancato pagamento ci riserviamo il diritto di sospendere i tenant associati a questo account." }, "support": { "title": "Supporto", @@ -781,7 +788,21 @@ "editorIssueConfirm": "Emettere questa fattura ora? Verrà assegnato un numero di fattura, il PDF sarà inviato al cliente e questa bozza verrà rimossa.", "editorDeleteConfirm": "Scartare questa bozza? Operazione irreversibile.", "previewing": "Apertura…", - "issuing": "Emissione…" + "issuing": "Emissione…", + "orgsTitle": "Fatturazione cliente", + "orgsDesc": "Modalità di pagamento + pagamento auto. per cliente", + "orgsPageTitle": "Modalità di fatturazione clienti", + "orgsPageSubtitle": "Override della modalità di pagamento per singoli clienti. Il pagamento tramite bonifico sostituisce l'addebito automatico su carta; mettere in pausa il pagamento automatico mantiene la carta salvata ma interrompe i tentativi di addebito (utile in caso di contestazioni).", + "orgsEmpty": "Ancora nessun cliente.", + "orgsColCustomer": "Cliente", + "orgsColCard": "Carta salvata", + "orgsColPayByInvoice": "Pagamento tramite bonifico", + "orgsColAutoCharge": "Pagamento automatico", + "orgsNoSavedCard": "nessuna", + "orgsPayByInvoiceOn": "attivo", + "orgsPayByInvoiceOff": "disattivo", + "orgsAutoChargeOn": "attivo", + "orgsAutoChargeOff": "disattivo" }, "skillCostDialog": { "title": "Conferma costi di attivazione", diff --git a/src/types/index.ts b/src/types/index.ts index 8f525d0..435cc1a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -253,6 +253,13 @@ export interface OrgBilling { export type TenantRequestStatus = | "pending" // Submitted, awaiting admin approval + // Phase 9b: setup-fee Checkout pending. The row exists, has no + // tenant_name yet (set when payment succeeds), and is invisible + // to admin (the queue filters to status='pending'). On webhook + // success the row flips to 'pending'. On abandonment the row + // stays here harmlessly — each retry creates a fresh row with + // a different derived tenant_name. + | "pending_payment" | "approved" // Admin approved, provisioning will start | "provisioning" // PiecedTenant CR created, operator reconciling | "active" // Tenant running @@ -283,6 +290,14 @@ export interface TenantRequest { status: TenantRequestStatus; adminNotes?: string; tenantName?: string; + /** + * Phase 9b: the paid setup-fee invoice linked to this request. + * Set by the Stripe webhook when the order-time Checkout + * completes successfully. Null on requests that pre-date Phase 9b + * and on resume requests (which don't have a setup fee). Admin + * rejection refunds this invoice via the existing refund flow. + */ + setupInvoiceId?: string | null; encryptedSecrets?: Buffer | null; /** * Slice 4: true for personal accounts. Drives CR-naming (`p-{suffix}`