From 8e7691d38a3777c720d1f65d630e69654469907b Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 27 May 2026 20:41:17 +0200 Subject: [PATCH] Phase8: Auto bill credit card --- src/app/[locale]/settings/billing/page.tsx | 44 ++- src/app/api/billing/auto-charge/route.ts | 51 ++++ src/app/api/billing/saved-card/route.ts | 46 ++++ src/app/api/billing/setup-card/route.ts | 71 +++++ src/app/api/stripe/webhook/route.ts | 107 ++++++- .../settings/saved-card-section.tsx | 260 ++++++++++++++++++ src/lib/db.ts | 146 ++++++++++ src/lib/stripe.ts | 125 +++++++++ src/messages/de.json | 21 +- src/messages/en.json | 19 +- src/messages/fr.json | 19 +- src/messages/it.json | 19 +- src/types/index.ts | 23 ++ 13 files changed, 944 insertions(+), 7 deletions(-) create mode 100644 src/app/api/billing/auto-charge/route.ts create mode 100644 src/app/api/billing/saved-card/route.ts create mode 100644 src/app/api/billing/setup-card/route.ts create mode 100644 src/components/settings/saved-card-section.tsx diff --git a/src/app/[locale]/settings/billing/page.tsx b/src/app/[locale]/settings/billing/page.tsx index 62e0462..91622af 100644 --- a/src/app/[locale]/settings/billing/page.tsx +++ b/src/app/[locale]/settings/billing/page.tsx @@ -18,6 +18,32 @@ import { BillingSettingsForm } from "@/components/settings/billing-form"; * shared upsert path; the row's existence drives whether the * monthly issuance cron will pick this org up. */ +import { redirect, notFound } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import { getSessionUser } from "@/lib/session"; +import { getOrgBilling, getOrgBillingConfig } from "@/lib/db"; +import { BillingSettingsForm } from "@/components/settings/billing-form"; +import { SavedCardSection } from "@/components/settings/saved-card-section"; + +/** + * /settings/billing — customer-side billing details management. + * + * Owner-only by visibility: non-owner members get a 404 (same + * response as if the page didn't exist). The link to this page + * is also hidden from non-owners on /billing and elsewhere, but + * the page itself enforces too — a non-owner who learns the URL + * still gets 404, not 403, so the page's existence doesn't leak. + * + * First-time visitors see an empty form. Subsequent visits see + * the current values, editable. Save creates or updates via the + * shared upsert path; the row's existence drives whether the + * monthly issuance cron will pick this org up. + * + * Phase 9: also renders the saved-card section (Set up auto-pay / + * Visa •••• 4242, expires 05/27 / Update card / Disable auto-pay / + * Remove card) when billing info is on file, plus a footer note + * explaining that bank transfer is available on request. + */ export default async function BillingSettingsPage() { const user = await getSessionUser(); if (!user) redirect("/login"); @@ -25,7 +51,10 @@ export default async function BillingSettingsPage() { if (!user.roles.includes("owner")) notFound(); const t = await getTranslations("settingsBilling"); - const existing = await getOrgBilling(user.orgId); + const [existing, config] = await Promise.all([ + getOrgBilling(user.orgId), + getOrgBillingConfig(user.orgId), + ]); return (
@@ -43,6 +72,19 @@ export default async function BillingSettingsPage() { isPersonal={user.isPersonal} /> + {/* Phase 9: saved-card section. Only shown once billing info + exists — without an address Stripe can't create the + customer object, so the "Set up auto-pay" button would + fail anyway. We give a clear hint up there if the form + is empty (no need to surface the card UI). */} + {existing && ( +
+ +
+ )}
); } diff --git a/src/app/api/billing/auto-charge/route.ts b/src/app/api/billing/auto-charge/route.ts new file mode 100644 index 0000000..5eb1701 --- /dev/null +++ b/src/app/api/billing/auto-charge/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getSessionUser } from "@/lib/session"; +import { setAutoChargeEnabled } from "@/lib/db"; +import { safeError } from "@/lib/errors"; + +/** + * POST /api/billing/auto-charge + * + * Phase 9. Toggle the auto_charge_enabled flag on the caller's + * org. The body is `{ enabled: boolean }`. + * + * When OFF: invoices issued for this org won't trigger an + * auto-charge against the saved card. The customer pays + * manually (or admin marks paid) — same flow as a bank-transfer + * customer. + * + * When ON: future invoice issuance attempts the auto-charge. + * No effect if there's no saved card on file. + * + * Idempotent: setting OFF on an already-OFF flag is a no-op + * (same outcome). + */ + +const bodySchema = z.object({ + enabled: z.boolean(), +}); + +export async function POST(request: Request) { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + 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 } + ); + } + try { + await setAutoChargeEnabled(user.orgId, parsed.data.enabled); + return NextResponse.json({ enabled: parsed.data.enabled }); + } catch (e) { + return NextResponse.json( + { error: safeError(e, "Failed to update auto-charge setting") }, + { status: 500 } + ); + } +} diff --git a/src/app/api/billing/saved-card/route.ts b/src/app/api/billing/saved-card/route.ts new file mode 100644 index 0000000..096a72f --- /dev/null +++ b/src/app/api/billing/saved-card/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from "next/server"; +import { getSessionUser } from "@/lib/session"; +import { clearSavedPaymentMethod, getOrgBillingConfig } from "@/lib/db"; +import { detachPaymentMethod } from "@/lib/stripe"; +import { safeError } from "@/lib/errors"; + +/** + * DELETE /api/billing/saved-card + * + * Phase 9. Remove the saved card for the caller's org. Detaches + * the PaymentMethod in Stripe (so it can't be charged again) and + * clears the four display columns + the pm_id reference locally. + * + * Idempotent: calling on an org with no saved card returns 200 + * (the desired end-state is already reached). + * + * Auth: any signed-in member of the org. Same reasoning as the + * setup endpoint — card removal is a customer-visible action; it + * doesn't leak anything, and a non-owner needing to remove a + * stolen-card-on-file shouldn't be blocked by role gating. + */ +export async function DELETE() { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + try { + const cfg = await getOrgBillingConfig(user.orgId); + if (!cfg || !cfg.stripeDefaultPaymentMethodId) { + // Already empty — no-op, return success. + return NextResponse.json({ removed: false }); + } + // Stripe detach first. If it fails for a real reason (network, + // 500 from Stripe), we don't clear the DB — admin can retry. + // 404 is treated as success by detachPaymentMethod (PM already + // gone), so we proceed to clear the DB regardless. + await detachPaymentMethod(cfg.stripeDefaultPaymentMethodId); + await clearSavedPaymentMethod(user.orgId); + return NextResponse.json({ removed: true }); + } catch (e) { + return NextResponse.json( + { error: safeError(e, "Failed to remove card") }, + { status: 500 } + ); + } +} diff --git a/src/app/api/billing/setup-card/route.ts b/src/app/api/billing/setup-card/route.ts new file mode 100644 index 0000000..c957629 --- /dev/null +++ b/src/app/api/billing/setup-card/route.ts @@ -0,0 +1,71 @@ +import { NextResponse } from "next/server"; +import { getSessionUser } from "@/lib/session"; +import { getOrgBilling } from "@/lib/db"; +import { + createSetupCheckoutSession, + ensureStripeCustomerForOrg, +} from "@/lib/stripe"; +import { safeError } from "@/lib/errors"; + +/** + * POST /api/billing/setup-card + * + * Phase 9. Customer-initiated "Set up auto-pay" / "Update card" + * flow. Creates a Checkout session in setup mode and returns its + * URL — the caller redirects the browser. On completion, the + * webhook handler saves the resulting PaymentMethod's display + * fields against this org's billing config. + * + * Auth: any signed-in member of the org. We don't owner-gate this + * because non-owners might legitimately need to update payment + * (e.g., for a team they administer). The actual card data is + * collected by Stripe, not us — there's nothing to leak from + * misuse here. + * + * Requires an existing billing snapshot (org_billing row). If + * absent, returns 400 — the customer hasn't set their billing + * address yet, and Stripe needs the address for the customer + * object. + */ +export async function POST(request: Request) { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const orgBilling = await getOrgBilling(user.orgId); + if (!orgBilling) { + return NextResponse.json( + { error: "Billing address required before saving a card." }, + { status: 400 } + ); + } + try { + // Ensure the Stripe customer exists. Idempotent — if we + // already created one for this org (e.g. from a prior + // "Pay by Card" Checkout), it's reused. + const customerId = await ensureStripeCustomerForOrg({ + zitadelOrgId: user.orgId, + companyName: orgBilling.companyName, + billingEmail: orgBilling.billingEmail, + address: { + line1: orgBilling.streetAddress, + postalCode: orgBilling.postalCode, + city: orgBilling.city, + 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; + const session = await createSetupCheckoutSession({ + customerId, + baseUrl: origin, + }); + return NextResponse.json({ url: session.url }); + } catch (e) { + return NextResponse.json( + { error: safeError(e, "Failed to start card setup") }, + { status: 500 } + ); + } +} diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts index 0f0f0a1..521bbcf 100644 --- a/src/app/api/stripe/webhook/route.ts +++ b/src/app/api/stripe/webhook/route.ts @@ -1,12 +1,18 @@ import { NextResponse } from "next/server"; import type Stripe from "stripe"; -import { getStripeClient, getWebhookSecret } from "@/lib/stripe"; +import { + getPaymentMethodDisplay, + getStripeClient, + getWebhookSecret, +} from "@/lib/stripe"; import { getInvoiceByStripePaymentIntent, + getOrgIdByStripeCustomerId, isStripeRefundRecorded, markInvoicePaid, markStripeEventProcessed, setInvoiceStripePaymentIntent, + setSavedPaymentMethod, tryRecordStripeEvent, } from "@/lib/db"; import { refundInvoice, RefundNotAllowedError } from "@/lib/billing"; @@ -161,6 +167,14 @@ export async function POST(request: Request) { async function handleCheckoutCompleted( session: Stripe.Checkout.Session ): Promise { + // Phase 9: setup-mode sessions don't pay anything — they + // authorize a card for off-session future charges. The + // PaymentMethod is attached to the customer and the session's + // setup_intent.payment_method holds the id we save. + if (session.mode === "setup") { + await handleSetupCompleted(session); + return; + } // Defensive: paid sessions are what we want; sessions can also // complete in "unpaid" state (rare for mode=payment, more common // for async/delayed methods like SEPA). Only flip the invoice @@ -211,6 +225,97 @@ async function handleCheckoutCompleted( ); } +/** + * Phase 9: handle setup-mode Checkout completion. The customer + * authorized a card for future off-session charges; persist the + * display fields against their org so the portal can show the + * saved card and use it for auto-charge. + * + * The session carries: + * - mode: 'setup' + * - customer: 'cus_xxx' (the Stripe customer id we created) + * - setup_intent: 'seti_xxx' (the SetupIntent — has payment_method) + * + * We look up which org owns the customer (via + * org_billing_config.stripe_customer_id), fetch the SetupIntent + * to find the resulting PaymentMethod id, then fetch the PM for + * its display fields. Three Stripe round-trips total — acceptable + * for a one-off setup event. + */ +async function handleSetupCompleted( + session: Stripe.Checkout.Session +): Promise { + const customerId = + typeof session.customer === "string" + ? session.customer + : session.customer?.id; + if (!customerId) { + console.error( + `Setup session ${session.id} completed without a customer; cannot link to org.` + ); + return; + } + const orgId = await getOrgIdByStripeCustomerId(customerId); + if (!orgId) { + console.error( + `Setup session ${session.id} for customer ${customerId} has no matching org.` + ); + return; + } + const setupIntentId = + typeof session.setup_intent === "string" + ? session.setup_intent + : session.setup_intent?.id; + if (!setupIntentId) { + console.error( + `Setup session ${session.id} completed without a setup_intent id.` + ); + return; + } + // Read the SetupIntent for the resulting PaymentMethod id. + const stripe = getStripeClient(); + const setupIntent = await stripe.setupIntents.retrieve(setupIntentId); + const paymentMethodId = + typeof setupIntent.payment_method === "string" + ? setupIntent.payment_method + : setupIntent.payment_method?.id; + if (!paymentMethodId) { + console.error( + `Setup session ${session.id}: setup_intent ${setupIntentId} has no payment_method.` + ); + return; + } + // Fetch the PM details for display columns. + const display = await getPaymentMethodDisplay(paymentMethodId); + await setSavedPaymentMethod({ + zitadelOrgId: orgId, + stripeCustomerId: customerId, + paymentMethodId, + brand: display.brand, + last4: display.last4, + expMonth: display.expMonth, + expYear: display.expYear, + }); + // Also tell Stripe this PM is the customer's default for invoice + // payments — so a future stripe.paymentIntents.create against + // this customer without an explicit payment_method picks it up. + // Best-effort: a failure here doesn't undo the save (we have the + // pm id, we can pass it explicitly when charging in Phase 9b). + try { + await stripe.customers.update(customerId, { + invoice_settings: { default_payment_method: paymentMethodId }, + }); + } catch (e) { + console.warn( + `Setup session ${session.id}: failed to set default_payment_method on customer ${customerId}; will pass pm id explicitly on charges.`, + e + ); + } + console.log( + `Saved PaymentMethod ${paymentMethodId} (${display.brand} ${display.last4}) for org ${orgId}.` + ); +} + async function handleChargeRefunded(charge: Stripe.Charge): Promise { // Phase 7: mirror Stripe refunds into the portal so credit notes // are issued for refunds initiated in the Stripe Dashboard. For diff --git a/src/components/settings/saved-card-section.tsx b/src/components/settings/saved-card-section.tsx new file mode 100644 index 0000000..a071564 --- /dev/null +++ b/src/components/settings/saved-card-section.tsx @@ -0,0 +1,260 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { Card, CardHeader } from "@/components/ui/card"; +import type { OrgBillingConfig } from "@/types"; + +interface Props { + config: OrgBillingConfig | null; + /** + * True when this org has been flipped to pay-by-invoice by admin. + * The card UI still renders (admin-set customers might also have + * a saved card as backup), but with an info note that auto-charge + * is disabled by their billing mode. + */ + isPayByInvoice: boolean; +} + +const BRAND_LABELS: Record = { + visa: "Visa", + mastercard: "Mastercard", + amex: "American Express", + discover: "Discover", + jcb: "JCB", + diners: "Diners Club", + unionpay: "UnionPay", +}; + +/** + * Saved-card management — Phase 9. + * + * State derives entirely from the OrgBillingConfig the server + * sends down. Actions are: set up (no card → Checkout setup + * mode), update (existing card → same Checkout flow, replaces), + * remove (DELETE the PM in Stripe + clear local fields), toggle + * auto-charge. + * + * The component watches for ?card_setup=success on mount and + * fires a router.refresh() — the success redirect from Stripe + * 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) { + const t = useTranslations("settingsBilling"); + const router = useRouter(); + const searchParams = useSearchParams(); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(""); + + // Refresh + clean the URL when Stripe redirects back. Stripe's + // webhook is what actually persists the card; the refresh just + // re-fetches the server-side config so the new fields appear. + useEffect(() => { + const status = searchParams.get("card_setup"); + if (status === "success") { + router.replace("/settings/billing"); + router.refresh(); + } else if (status === "cancelled") { + // Just clean the URL. No-op otherwise. + router.replace("/settings/billing"); + } + }, [searchParams, router]); + + const hasCard = !!config?.stripeDefaultPaymentMethodId; + const autoChargeOn = config?.autoChargeEnabled !== false; + + const startSetup = async () => { + setError(""); + setBusy("setup"); + try { + const res = await fetch("/api/billing/setup-card", { method: "POST" }); + const j = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`); + if (!j.url) throw new Error("No redirect URL returned"); + // Hard-redirect — Stripe Checkout doesn't run inside the SPA. + window.location.href = j.url; + } catch (e: any) { + setError(e.message); + setBusy(null); + } + }; + + const removeCard = async () => { + if (!confirm(t("savedCardRemoveConfirm"))) return; + setError(""); + setBusy("remove"); + try { + const res = await fetch("/api/billing/saved-card", { method: "DELETE" }); + 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); + } + }; + + const toggleAutoCharge = async () => { + setError(""); + setBusy("toggle"); + try { + const res = await fetch("/api/billing/auto-charge", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: !autoChargeOn }), + }); + 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); + } + }; + + // Empty state — no card on file. + if (!hasCard) { + return ( + + {t("savedCardHeading")} +
+

+ {t("savedCardEmptyBody")} +

+ {error && ( +
{error}
+ )} + +

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

+
+
+ ); + } + + // Card on file. + const brandLabel = + config?.stripePmBrand + ? BRAND_LABELS[config.stripePmBrand] ?? config.stripePmBrand + : t("savedCardBrandUnknown"); + const last4 = config?.stripePmLast4 ?? "????"; + const expMonth = config?.stripePmExpMonth; + const expYear = config?.stripePmExpYear; + const expLabel = + expMonth && expYear + ? `${String(expMonth).padStart(2, "0")}/${String(expYear).slice(-2)}` + : ""; + // Heuristic for "expiring soon" — if the card expires this calendar + // month or next. Stripe's pre-expiration emails handle the real + // notification, but a portal hint is friendly too. + const now = new Date(); + const expiringSoon = + expMonth && + expYear && + (expYear < now.getFullYear() || + (expYear === now.getFullYear() && expMonth <= now.getMonth() + 2)); + + return ( + + {t("savedCardHeading")} +
+
+
+ + {brandLabel} •••• {last4} + + {expLabel && ( + + {t("savedCardExpires", { date: expLabel })} + + )} +
+
+ + {autoChargeOn + ? t("savedCardAutoChargeOn") + : t("savedCardAutoChargeOff")} + +
+
+ + {isPayByInvoice && ( +
+ {t("savedCardPayByInvoiceNote")} +
+ )} + + {error &&
{error}
} + +
+ + + +
+ +

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

+
+
+ ); +} diff --git a/src/lib/db.ts b/src/lib/db.ts index bdf51ca..b857861 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -421,6 +421,28 @@ const MIGRATION_SQL = ` created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); + -- Phase 9: saved-card columns. The PaymentMethod id (`pm_xxx`) + -- is the handle for off-session charges; brand/last4/exp are + -- display fields. No PAN, CVV, or anything PCI-scope — Stripe + -- holds those. The columns are nullable because a fresh org has + -- no saved card; setting up auto-pay populates them via the + -- checkout.session.completed webhook in setup mode. + ALTER TABLE org_billing_config + ADD COLUMN IF NOT EXISTS stripe_default_payment_method_id TEXT; + ALTER TABLE org_billing_config + ADD COLUMN IF NOT EXISTS stripe_pm_brand TEXT; + ALTER TABLE org_billing_config + ADD COLUMN IF NOT EXISTS stripe_pm_last4 TEXT; + ALTER TABLE org_billing_config + ADD COLUMN IF NOT EXISTS stripe_pm_exp_month INTEGER; + ALTER TABLE org_billing_config + ADD COLUMN IF NOT EXISTS stripe_pm_exp_year INTEGER; + -- Phase 9: off-session auto-charge gate. Default TRUE — new orgs + -- pay by card automatically when an invoice is issued (assuming + -- they've also set up a saved card). Admin can flip OFF to pause + -- charging without removing the saved card. + ALTER TABLE org_billing_config + ADD COLUMN IF NOT EXISTS auto_charge_enabled BOOLEAN NOT NULL DEFAULT TRUE; -- Stripe payment methods. Populated by the Phase 4 webhook handler. -- Created in Phase 1 so all billing schema is together; rows are @@ -2250,6 +2272,15 @@ function rowToOrgBillingConfig(row: any): OrgBillingConfig { stripeCustomerId: row.stripe_customer_id ?? null, autoInvoiceEnabled: row.auto_invoice_enabled, autoRemindersEnabled: row.auto_reminders_enabled, + stripeDefaultPaymentMethodId: row.stripe_default_payment_method_id ?? null, + stripePmBrand: row.stripe_pm_brand ?? null, + stripePmLast4: row.stripe_pm_last4 ?? null, + stripePmExpMonth: + row.stripe_pm_exp_month != null ? Number(row.stripe_pm_exp_month) : null, + stripePmExpYear: + row.stripe_pm_exp_year != null ? Number(row.stripe_pm_exp_year) : null, + autoChargeEnabled: + row.auto_charge_enabled === undefined ? true : !!row.auto_charge_enabled, createdAt: row.created_at?.toISOString?.() ?? row.created_at, updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at, }; @@ -3985,3 +4016,118 @@ export async function deleteInvoiceDraft(id: string): Promise { ); return (result.rowCount ?? 0) > 0; } + +// --------------------------------------------------------------------------- +// Phase 9 — saved-card management for off-session auto-charge +// --------------------------------------------------------------------------- + +/** + * Persist a saved PaymentMethod against an org's billing config. + * Called from the webhook after a successful setup-mode Checkout + * session, and again when "Pay by Card" with setup_future_usage + * delivers a fresh PaymentMethod. Upserts the config row in case + * the org has none yet (rare — onboarding usually creates one, + * but defensive doesn't hurt). + * + * Only display fields (brand/last4/exp) are persisted. The full PAN + * is never seen by this code — Stripe holds it. + */ +export async function setSavedPaymentMethod(params: { + zitadelOrgId: string; + stripeCustomerId: string; + paymentMethodId: string; + brand: string | null; + last4: string | null; + expMonth: number | null; + expYear: number | null; +}): Promise { + await ensureSchema(); + await getPool().query( + `INSERT INTO org_billing_config ( + zitadel_org_id, stripe_customer_id, + stripe_default_payment_method_id, stripe_pm_brand, stripe_pm_last4, + stripe_pm_exp_month, stripe_pm_exp_year, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, now()) + ON CONFLICT (zitadel_org_id) DO UPDATE SET + stripe_customer_id = COALESCE(org_billing_config.stripe_customer_id, EXCLUDED.stripe_customer_id), + stripe_default_payment_method_id = EXCLUDED.stripe_default_payment_method_id, + stripe_pm_brand = EXCLUDED.stripe_pm_brand, + stripe_pm_last4 = EXCLUDED.stripe_pm_last4, + stripe_pm_exp_month = EXCLUDED.stripe_pm_exp_month, + stripe_pm_exp_year = EXCLUDED.stripe_pm_exp_year, + updated_at = now()`, + [ + params.zitadelOrgId, + params.stripeCustomerId, + params.paymentMethodId, + params.brand, + params.last4, + params.expMonth, + params.expYear, + ] + ); +} + +/** + * Clear the saved PaymentMethod fields. Used when the customer + * clicks "Remove card" — the Stripe-side detach happens in the + * caller (stripe.detachPaymentMethod); this just nulls the + * portal-side display fields and the pm id reference. + * + * Does not touch stripe_customer_id (the customer object survives), + * auto_charge_enabled, or any other config — only the four card + * fields and the pm id pointer. + */ +export async function clearSavedPaymentMethod( + zitadelOrgId: string +): Promise { + await getPool().query( + `UPDATE org_billing_config + SET stripe_default_payment_method_id = NULL, + stripe_pm_brand = NULL, + stripe_pm_last4 = NULL, + stripe_pm_exp_month = NULL, + stripe_pm_exp_year = NULL, + updated_at = now() + WHERE zitadel_org_id = $1`, + [zitadelOrgId] + ); +} + +/** + * Toggle the auto_charge_enabled flag. Used by the customer's + * "Disable auto-pay / Enable auto-pay" button in /settings/billing + * and (Phase 9b) the admin override on /admin/billing/orgs. + */ +export async function setAutoChargeEnabled( + zitadelOrgId: string, + enabled: boolean +): Promise { + await getPool().query( + `INSERT INTO org_billing_config (zitadel_org_id, auto_charge_enabled, updated_at) + VALUES ($1, $2, now()) + ON CONFLICT (zitadel_org_id) DO UPDATE SET + auto_charge_enabled = EXCLUDED.auto_charge_enabled, + updated_at = now()`, + [zitadelOrgId, enabled] + ); +} + +/** + * Look up the org id for a given Stripe customer id — used by the + * webhook when a checkout.session.completed in setup mode arrives + * and we need to find which org to save the card against. The + * customer id is the join key Stripe gives us in the session. + */ +export async function getOrgIdByStripeCustomerId( + stripeCustomerId: string +): Promise { + await ensureSchema(); + const result = await getPool().query( + `SELECT zitadel_org_id FROM org_billing_config + WHERE stripe_customer_id = $1 + LIMIT 1`, + [stripeCustomerId] + ); + return result.rows.length > 0 ? result.rows[0].zitadel_org_id : null; +} diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts index a6c5c4d..af0069b 100644 --- a/src/lib/stripe.ts +++ b/src/lib/stripe.ts @@ -318,3 +318,128 @@ export async function createInvoiceRefund(params: { status: refund.status ?? "unknown", }; } + +// --------------------------------------------------------------------------- +// Phase 9 — saved cards (SetupIntent / Checkout setup mode) +// --------------------------------------------------------------------------- + +/** + * Create a Checkout session in setup mode — Stripe collects card + * details and authorizes them for off-session future charges, + * without charging anything now. On success, Stripe attaches the + * resulting PaymentMethod to the customer object and fires + * `checkout.session.completed` with mode='setup'. + * + * The webhook handler reads the session's setup_intent, extracts + * the payment_method id, and persists the display fields + * (brand/last4/exp) via setSavedPaymentMethod. From that moment + * on, the customer has auto-charge wired up. + * + * Re-running this against a customer who already has a saved card + * is supported — Stripe attaches the new PaymentMethod and the + * webhook overwrites the old one in our DB. That's how "Update + * card" works. + */ +export async function createSetupCheckoutSession(params: { + customerId: string; + baseUrl: string; + locale?: "de" | "en" | "fr" | "it"; + /** + * Where to redirect after the customer completes / cancels the + * setup. Defaults to /settings/billing — the natural landing + * spot after saving a card. + */ + returnPath?: string; +}): Promise<{ url: string; sessionId: string }> { + const stripe = getStripeClient(); + const { customerId, baseUrl, locale } = params; + const returnPath = params.returnPath ?? "/settings/billing"; + const stripeLocale = + locale === "de" + ? ("de" as const) + : locale === "fr" + ? ("fr" as const) + : locale === "it" + ? ("it" as const) + : locale === "en" + ? ("en" as const) + : ("auto" as const); + + const successUrl = `${baseUrl}${returnPath}?card_setup=success&session_id={CHECKOUT_SESSION_ID}`; + const cancelUrl = `${baseUrl}${returnPath}?card_setup=cancelled`; + + const session = await stripe.checkout.sessions.create({ + mode: "setup", + customer: customerId, + locale: stripeLocale, + payment_method_types: ["card"], + success_url: successUrl, + cancel_url: cancelUrl, + // Stripe attaches the resulting PaymentMethod to the customer + // and the webhook fires with session.setup_intent populated. + // No extra setup_intent_data needed for the basic flow. + }); + if (!session.url) { + throw new Error( + `Stripe returned a setup session without a redirect URL (id=${session.id})` + ); + } + return { url: session.url, sessionId: session.id }; +} + +/** + * Detach a PaymentMethod from its customer. Used when the customer + * clicks "Remove card" — the PM is no longer usable for charges + * once detached. The Stripe Customer object survives (so future + * charges can still attach a new card to the same customer). + * + * Stripe permits detaching a PM that's already detached as a + * no-op; safe to retry. + */ +export async function detachPaymentMethod( + paymentMethodId: string +): Promise { + const stripe = getStripeClient(); + try { + await stripe.paymentMethods.detach(paymentMethodId); + } catch (e: any) { + // Stripe returns 404 if the PM is already detached or doesn't + // exist — treat as success since the intended end-state ("not + // attached") is already reached. Re-throw anything else. + if (e?.statusCode === 404) return; + throw e; + } +} + +/** + * Fetch the display fields for a PaymentMethod (brand, last4, + * exp). Used by the webhook to read out what to persist after a + * setup session completes; the session itself only carries the + * PM id, not the card details. + */ +export async function getPaymentMethodDisplay( + paymentMethodId: string +): Promise<{ + brand: string | null; + last4: string | null; + expMonth: number | null; + expYear: number | null; +}> { + const stripe = getStripeClient(); + const pm = await stripe.paymentMethods.retrieve(paymentMethodId); + // The card object is only present when type='card'. We don't + // anticipate non-card PMs in this codebase yet, but defensive + // null-handling avoids crashing if Stripe surfaces something + // unexpected (Apple Pay, link, etc. — all of which still + // resolve to a card under the hood). + const card = (pm as any).card; + if (!card) { + return { brand: null, last4: null, expMonth: null, expYear: null }; + } + return { + brand: card.brand ?? null, + last4: card.last4 ?? null, + expMonth: typeof card.exp_month === "number" ? card.exp_month : null, + expYear: typeof card.exp_year === "number" ? card.exp_year : null, + }; +} diff --git a/src/messages/de.json b/src/messages/de.json index 1534d55..0d4ccb6 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -501,7 +501,7 @@ "notesHint": "Referenznummern, Bestellnummern oder andere Angaben, die auf der Rechnung erscheinen sollen.", "saveChanges": "Änderungen speichern", "createBilling": "Rechnungsdaten speichern", - "saving": "Speichern…", + "saving": "Wird gespeichert…", "saved": "Gespeichert.", "missingRequired": "Bitte alle Pflichtfelder ausfüllen.", "invalidCountry": "Ländercode muss aus 2 Buchstaben bestehen (z.B. CH).", @@ -509,7 +509,24 @@ "fullNameLabel": "Vor- und Nachname", "subtitlePersonal": "Ihre Rechnungsadresse und Rechnungskontakt. Erforderlich, bevor Rechnungen ausgestellt werden können.", "contactNameLabel": "Ansprechperson (optional)", - "contactNameHint": "Erscheint als 'z.Hd. ' auf der Rechnung unter dem Firmennamen. Hilfreich für die Zuordnung in der Buchhaltung grösserer Firmen." + "contactNameHint": "Erscheint als 'z.Hd. ' auf der Rechnung unter dem Firmennamen. Hilfreich für die Zuordnung in der Buchhaltung grösserer Firmen.", + "savedCardHeading": "Hinterlegte Karte", + "savedCardEmptyBody": "Hinterlegen Sie eine Karte für die automatische Bezahlung von Rechnungen. Ihre Kartendaten werden sicher bei Stripe gespeichert — wir sehen nur Marke, letzte vier Ziffern und Ablaufdatum.", + "savedCardSetupBtn": "Auto-Zahlung einrichten", + "savedCardRedirecting": "Weiterleitung…", + "savedCardUpdateBtn": "Karte aktualisieren", + "savedCardRemoveBtn": "Karte entfernen", + "savedCardRemoving": "Entfernen…", + "savedCardRemoveConfirm": "Diese Karte entfernen? Sie müssen die Auto-Zahlung erneut einrichten, damit zukünftige Rechnungen automatisch belastet werden.", + "savedCardBrandUnknown": "Karte", + "savedCardExpires": "läuft ab {date}", + "savedCardAutoChargeOn": "Auto-Zahlung aktiv", + "savedCardAutoChargeOff": "Auto-Zahlung inaktiv", + "savedCardDisableAutoChargeBtn": "Auto-Zahlung deaktivieren", + "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." }, "support": { "title": "Support", diff --git a/src/messages/en.json b/src/messages/en.json index b130b45..ecd8f78 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -509,7 +509,24 @@ "fullNameLabel": "Full name", "subtitlePersonal": "Your billing address and invoice contact. Required before invoices can be issued.", "contactNameLabel": "Contact person (optional)", - "contactNameHint": "Prints as 'Attn: ' on the invoice below the company name. Useful for AP routing in larger organizations." + "contactNameHint": "Prints as 'Attn: ' on the invoice below the company name. Useful for AP routing in larger organizations.", + "savedCardHeading": "Saved card", + "savedCardEmptyBody": "Save a card for automatic invoice payments. Your card details are stored securely by Stripe — we only see the brand, last four digits, and expiration.", + "savedCardSetupBtn": "Set up auto-pay", + "savedCardRedirecting": "Redirecting…", + "savedCardUpdateBtn": "Update card", + "savedCardRemoveBtn": "Remove card", + "savedCardRemoving": "Removing…", + "savedCardRemoveConfirm": "Remove this card? You'll need to set up auto-pay again for future invoices to charge automatically.", + "savedCardBrandUnknown": "Card", + "savedCardExpires": "expires {date}", + "savedCardAutoChargeOn": "Auto-pay on", + "savedCardAutoChargeOff": "Auto-pay off", + "savedCardDisableAutoChargeBtn": "Disable auto-pay", + "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." }, "support": { "title": "Support", diff --git a/src/messages/fr.json b/src/messages/fr.json index 53f11b7..057248c 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -509,7 +509,24 @@ "fullNameLabel": "Nom et prénom", "subtitlePersonal": "Votre adresse de facturation et votre contact. Requis avant l'émission de toute facture.", "contactNameLabel": "Personne à contacter (facultatif)", - "contactNameHint": "S'imprime « À l'attention de » sur la facture, sous le nom de l'entreprise. Utile pour le routage en comptabilité dans les grandes organisations." + "contactNameHint": "S'imprime « À l'attention de » sur la facture, sous le nom de l'entreprise. Utile pour le routage en comptabilité dans les grandes organisations.", + "savedCardHeading": "Carte enregistrée", + "savedCardEmptyBody": "Enregistrez une carte pour le paiement automatique des factures. Les données de votre carte sont stockées de manière sécurisée par Stripe — nous ne voyons que la marque, les quatre derniers chiffres et la date d'expiration.", + "savedCardSetupBtn": "Configurer le paiement automatique", + "savedCardRedirecting": "Redirection…", + "savedCardUpdateBtn": "Mettre à jour la carte", + "savedCardRemoveBtn": "Supprimer la carte", + "savedCardRemoving": "Suppression…", + "savedCardRemoveConfirm": "Supprimer cette carte ? Vous devrez reconfigurer le paiement automatique pour que les futures factures soient prélevées automatiquement.", + "savedCardBrandUnknown": "Carte", + "savedCardExpires": "expire {date}", + "savedCardAutoChargeOn": "Paiement auto. actif", + "savedCardAutoChargeOff": "Paiement auto. inactif", + "savedCardDisableAutoChargeBtn": "Désactiver le paiement automatique", + "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." }, "support": { "title": "Support", diff --git a/src/messages/it.json b/src/messages/it.json index 2a29628..81e64f0 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -509,7 +509,24 @@ "fullNameLabel": "Nome e cognome", "subtitlePersonal": "Il tuo indirizzo di fatturazione e contatto. Necessari prima che possano essere emesse fatture.", "contactNameLabel": "Persona di contatto (facoltativa)", - "contactNameHint": "Stampato come 'c.a. ' sulla fattura, sotto il nome dell'azienda. Utile per l'instradamento contabile in grandi organizzazioni." + "contactNameHint": "Stampato come 'c.a. ' sulla fattura, sotto il nome dell'azienda. Utile per l'instradamento contabile in grandi organizzazioni.", + "savedCardHeading": "Carta salvata", + "savedCardEmptyBody": "Salvi una carta per il pagamento automatico delle fatture. I dati della sua carta sono memorizzati in modo sicuro da Stripe — vediamo solo la marca, le ultime quattro cifre e la scadenza.", + "savedCardSetupBtn": "Configura pagamento automatico", + "savedCardRedirecting": "Reindirizzamento…", + "savedCardUpdateBtn": "Aggiorna carta", + "savedCardRemoveBtn": "Rimuovi carta", + "savedCardRemoving": "Rimozione…", + "savedCardRemoveConfirm": "Rimuovere questa carta? Dovrà riconfigurare il pagamento automatico affinché le future fatture vengano addebitate automaticamente.", + "savedCardBrandUnknown": "Carta", + "savedCardExpires": "scade {date}", + "savedCardAutoChargeOn": "Pagamento auto. attivo", + "savedCardAutoChargeOff": "Pagamento auto. disattivo", + "savedCardDisableAutoChargeBtn": "Disattiva pagamento automatico", + "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." }, "support": { "title": "Supporto", diff --git a/src/types/index.ts b/src/types/index.ts index 745ea01..8f525d0 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -530,6 +530,29 @@ export interface OrgBillingConfig { stripeCustomerId: string | null; autoInvoiceEnabled: boolean; autoRemindersEnabled: boolean; + /** + * Phase 9: saved-card info for off-session auto-charge. + * Populated by the SetupIntent webhook when a customer completes + * the "Set up auto-pay" flow. Only display fields are stored + * locally — never the PAN. The Stripe PaymentMethod id + * (`pm_xxx`) is the handle the platform uses to charge against + * the card; the brand/last4/exp_month/exp_year fields are for + * showing "Visa •••• 4242, expires 05/27" without an API call. + */ + stripeDefaultPaymentMethodId: string | null; + stripePmBrand: string | null; + stripePmLast4: string | null; + stripePmExpMonth: number | null; + stripePmExpYear: number | null; + /** + * Phase 9: off-session auto-charge gate. Default TRUE for new + * customers (card is the default payment method). Admin can + * flip this off to pause auto-charging for a specific customer + * (e.g. during a dispute) without removing the saved card. With + * no saved PaymentMethod set, the flag is irrelevant — there's + * nothing to charge against. + */ + autoChargeEnabled: boolean; createdAt: string; updatedAt: string; }