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