Phase8: Auto bill credit card
Some checks failed
Build and Push / build (push) Failing after 43s

This commit is contained in:
2026-05-27 20:41:17 +02:00
parent 9939f75c03
commit 8e7691d38a
13 changed files with 944 additions and 7 deletions

View File

@@ -18,6 +18,32 @@ import { BillingSettingsForm } from "@/components/settings/billing-form";
* shared upsert path; the row's existence drives whether the * shared upsert path; the row's existence drives whether the
* monthly issuance cron will pick this org up. * 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() { export default async function BillingSettingsPage() {
const user = await getSessionUser(); const user = await getSessionUser();
if (!user) redirect("/login"); if (!user) redirect("/login");
@@ -25,7 +51,10 @@ export default async function BillingSettingsPage() {
if (!user.roles.includes("owner")) notFound(); if (!user.roles.includes("owner")) notFound();
const t = await getTranslations("settingsBilling"); const t = await getTranslations("settingsBilling");
const existing = await getOrgBilling(user.orgId); const [existing, config] = await Promise.all([
getOrgBilling(user.orgId),
getOrgBillingConfig(user.orgId),
]);
return ( return (
<main className="max-w-3xl mx-auto px-6 py-8"> <main className="max-w-3xl mx-auto px-6 py-8">
@@ -43,6 +72,19 @@ export default async function BillingSettingsPage() {
isPersonal={user.isPersonal} isPersonal={user.isPersonal}
/> />
</div> </div>
{/* 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 && (
<div className="animate-in animate-in-delay-2 mt-8">
<SavedCardSection
config={config}
isPayByInvoice={!!config?.payByInvoice}
/>
</div>
)}
</main> </main>
); );
} }

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -1,12 +1,18 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import type Stripe from "stripe"; import type Stripe from "stripe";
import { getStripeClient, getWebhookSecret } from "@/lib/stripe"; import {
getPaymentMethodDisplay,
getStripeClient,
getWebhookSecret,
} from "@/lib/stripe";
import { import {
getInvoiceByStripePaymentIntent, getInvoiceByStripePaymentIntent,
getOrgIdByStripeCustomerId,
isStripeRefundRecorded, isStripeRefundRecorded,
markInvoicePaid, markInvoicePaid,
markStripeEventProcessed, markStripeEventProcessed,
setInvoiceStripePaymentIntent, setInvoiceStripePaymentIntent,
setSavedPaymentMethod,
tryRecordStripeEvent, tryRecordStripeEvent,
} from "@/lib/db"; } from "@/lib/db";
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing"; import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
@@ -161,6 +167,14 @@ export async function POST(request: Request) {
async function handleCheckoutCompleted( async function handleCheckoutCompleted(
session: Stripe.Checkout.Session session: Stripe.Checkout.Session
): Promise<void> { ): Promise<void> {
// 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 // Defensive: paid sessions are what we want; sessions can also
// complete in "unpaid" state (rare for mode=payment, more common // complete in "unpaid" state (rare for mode=payment, more common
// for async/delayed methods like SEPA). Only flip the invoice // 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<void> {
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<void> { async function handleChargeRefunded(charge: Stripe.Charge): Promise<void> {
// Phase 7: mirror Stripe refunds into the portal so credit notes // Phase 7: mirror Stripe refunds into the portal so credit notes
// are issued for refunds initiated in the Stripe Dashboard. For // are issued for refunds initiated in the Stripe Dashboard. For

View File

@@ -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<string, string> = {
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 | "setup" | "remove" | "toggle">(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 (
<Card>
<CardHeader>{t("savedCardHeading")}</CardHeader>
<div className="p-5">
<p className="text-sm text-text-secondary mb-4">
{t("savedCardEmptyBody")}
</p>
{error && (
<div className="text-sm text-error mb-3">{error}</div>
)}
<button
onClick={startSetup}
disabled={busy !== null}
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
>
{busy === "setup" ? t("savedCardRedirecting") : t("savedCardSetupBtn")}
</button>
<p className="text-xs text-text-muted mt-4">
{t("savedCardBankTransferHint")}{" "}
<a
href="/support"
className="text-accent hover:underline"
>
{t("savedCardBankTransferLink")}
</a>
</p>
</div>
</Card>
);
}
// 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 (
<Card>
<CardHeader>{t("savedCardHeading")}</CardHeader>
<div className="p-5">
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
<div className="flex items-center gap-3">
<span className="font-mono text-sm">
{brandLabel} {last4}
</span>
{expLabel && (
<span
className={`text-xs ${
expiringSoon ? "text-warning" : "text-text-muted"
}`}
>
{t("savedCardExpires", { date: expLabel })}
</span>
)}
</div>
<div className="flex items-center gap-3 text-xs">
<span
className={`px-2 py-0.5 rounded text-xs ${
autoChargeOn
? "bg-success/15 text-success"
: "bg-text-muted/15 text-text-muted"
}`}
>
{autoChargeOn
? t("savedCardAutoChargeOn")
: t("savedCardAutoChargeOff")}
</span>
</div>
</div>
{isPayByInvoice && (
<div className="text-xs text-text-muted bg-surface-3 rounded-md px-3 py-2 mb-3">
{t("savedCardPayByInvoiceNote")}
</div>
)}
{error && <div className="text-sm text-error mb-3">{error}</div>}
<div className="flex gap-2 flex-wrap">
<button
onClick={startSetup}
disabled={busy !== null}
className="px-3 py-1.5 rounded-md border border-border text-sm disabled:opacity-50 hover:bg-surface-3"
>
{busy === "setup"
? t("savedCardRedirecting")
: t("savedCardUpdateBtn")}
</button>
<button
onClick={toggleAutoCharge}
disabled={busy !== null}
className="px-3 py-1.5 rounded-md border border-border text-sm disabled:opacity-50 hover:bg-surface-3"
>
{busy === "toggle"
? t("saving")
: autoChargeOn
? t("savedCardDisableAutoChargeBtn")
: t("savedCardEnableAutoChargeBtn")}
</button>
<button
onClick={removeCard}
disabled={busy !== null}
className="px-3 py-1.5 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10 ml-auto"
>
{busy === "remove"
? t("savedCardRemoving")
: t("savedCardRemoveBtn")}
</button>
</div>
<p className="text-xs text-text-muted mt-4">
{t("savedCardBankTransferHint")}{" "}
<a
href="/support"
className="text-accent hover:underline"
>
{t("savedCardBankTransferLink")}
</a>
</p>
</div>
</Card>
);
}

View File

@@ -421,6 +421,28 @@ const MIGRATION_SQL = `
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_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. -- Stripe payment methods. Populated by the Phase 4 webhook handler.
-- Created in Phase 1 so all billing schema is together; rows are -- 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, stripeCustomerId: row.stripe_customer_id ?? null,
autoInvoiceEnabled: row.auto_invoice_enabled, autoInvoiceEnabled: row.auto_invoice_enabled,
autoRemindersEnabled: row.auto_reminders_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, createdAt: row.created_at?.toISOString?.() ?? row.created_at,
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at, updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
}; };
@@ -3985,3 +4016,118 @@ export async function deleteInvoiceDraft(id: string): Promise<boolean> {
); );
return (result.rowCount ?? 0) > 0; 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<void> {
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<void> {
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<void> {
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<string | null> {
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;
}

View File

@@ -318,3 +318,128 @@ export async function createInvoiceRefund(params: {
status: refund.status ?? "unknown", 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<void> {
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,
};
}

View File

@@ -501,7 +501,7 @@
"notesHint": "Referenznummern, Bestellnummern oder andere Angaben, die auf der Rechnung erscheinen sollen.", "notesHint": "Referenznummern, Bestellnummern oder andere Angaben, die auf der Rechnung erscheinen sollen.",
"saveChanges": "Änderungen speichern", "saveChanges": "Änderungen speichern",
"createBilling": "Rechnungsdaten speichern", "createBilling": "Rechnungsdaten speichern",
"saving": "Speichern…", "saving": "Wird gespeichert…",
"saved": "Gespeichert.", "saved": "Gespeichert.",
"missingRequired": "Bitte alle Pflichtfelder ausfüllen.", "missingRequired": "Bitte alle Pflichtfelder ausfüllen.",
"invalidCountry": "Ländercode muss aus 2 Buchstaben bestehen (z.B. CH).", "invalidCountry": "Ländercode muss aus 2 Buchstaben bestehen (z.B. CH).",
@@ -509,7 +509,24 @@
"fullNameLabel": "Vor- und Nachname", "fullNameLabel": "Vor- und Nachname",
"subtitlePersonal": "Ihre Rechnungsadresse und Rechnungskontakt. Erforderlich, bevor Rechnungen ausgestellt werden können.", "subtitlePersonal": "Ihre Rechnungsadresse und Rechnungskontakt. Erforderlich, bevor Rechnungen ausgestellt werden können.",
"contactNameLabel": "Ansprechperson (optional)", "contactNameLabel": "Ansprechperson (optional)",
"contactNameHint": "Erscheint als 'z.Hd. <Name>' auf der Rechnung unter dem Firmennamen. Hilfreich für die Zuordnung in der Buchhaltung grösserer Firmen." "contactNameHint": "Erscheint als 'z.Hd. <Name>' 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": { "support": {
"title": "Support", "title": "Support",

View File

@@ -509,7 +509,24 @@
"fullNameLabel": "Full name", "fullNameLabel": "Full name",
"subtitlePersonal": "Your billing address and invoice contact. Required before invoices can be issued.", "subtitlePersonal": "Your billing address and invoice contact. Required before invoices can be issued.",
"contactNameLabel": "Contact person (optional)", "contactNameLabel": "Contact person (optional)",
"contactNameHint": "Prints as 'Attn: <name>' on the invoice below the company name. Useful for AP routing in larger organizations." "contactNameHint": "Prints as 'Attn: <name>' 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": { "support": {
"title": "Support", "title": "Support",

View File

@@ -509,7 +509,24 @@
"fullNameLabel": "Nom et prénom", "fullNameLabel": "Nom et prénom",
"subtitlePersonal": "Votre adresse de facturation et votre contact. Requis avant l'émission de toute facture.", "subtitlePersonal": "Votre adresse de facturation et votre contact. Requis avant l'émission de toute facture.",
"contactNameLabel": "Personne à contacter (facultatif)", "contactNameLabel": "Personne à contacter (facultatif)",
"contactNameHint": "S'imprime « À l'attention de <nom> » 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 <nom> » 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": { "support": {
"title": "Support", "title": "Support",

View File

@@ -509,7 +509,24 @@
"fullNameLabel": "Nome e cognome", "fullNameLabel": "Nome e cognome",
"subtitlePersonal": "Il tuo indirizzo di fatturazione e contatto. Necessari prima che possano essere emesse fatture.", "subtitlePersonal": "Il tuo indirizzo di fatturazione e contatto. Necessari prima che possano essere emesse fatture.",
"contactNameLabel": "Persona di contatto (facoltativa)", "contactNameLabel": "Persona di contatto (facoltativa)",
"contactNameHint": "Stampato come 'c.a. <nome>' sulla fattura, sotto il nome dell'azienda. Utile per l'instradamento contabile in grandi organizzazioni." "contactNameHint": "Stampato come 'c.a. <nome>' 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": { "support": {
"title": "Supporto", "title": "Supporto",

View File

@@ -530,6 +530,29 @@ export interface OrgBillingConfig {
stripeCustomerId: string | null; stripeCustomerId: string | null;
autoInvoiceEnabled: boolean; autoInvoiceEnabled: boolean;
autoRemindersEnabled: 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; createdAt: string;
updatedAt: string; updatedAt: string;
} }