This commit is contained in:
@@ -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