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

@@ -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<boolean> {
);
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",
};
}
// ---------------------------------------------------------------------------
// 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,
};
}