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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user