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")}
+
+
+ );
+}
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;
}