This commit is contained in:
146
src/lib/db.ts
146
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<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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user