import { createHmac, timingSafeEqual } from "node:crypto"; const STRIPE_API_BASE = "https://api.stripe.com/v1"; type StripeCheckoutSession = { id: string; url: string; mode: string; metadata?: Record; }; type CheckoutSessionParams = { secretKey: string; priceId: string; userId: string; email: string; planId: string; successUrl: string; cancelUrl: string; }; export class StripeRequestError extends Error { status: number; constructor(message: string, status: number) { super(message); this.status = status; } } function buildCheckoutBody(params: CheckoutSessionParams) { const body = new URLSearchParams(); body.set("mode", "subscription"); body.set("success_url", params.successUrl); body.set("cancel_url", params.cancelUrl); body.set("line_items[0][price]", params.priceId); body.set("line_items[0][quantity]", "1"); body.set("allow_promotion_codes", "true"); body.set("client_reference_id", params.userId); body.set("customer_email", params.email); body.set("metadata[userId]", params.userId); body.set("metadata[planId]", params.planId); body.set("subscription_data[metadata][userId]", params.userId); body.set("subscription_data[metadata][planId]", params.planId); return body; } export async function createStripeCheckoutSession(params: CheckoutSessionParams) { const response = await fetch(`${STRIPE_API_BASE}/checkout/sessions`, { method: "POST", headers: { Authorization: `Bearer ${params.secretKey}`, "Content-Type": "application/x-www-form-urlencoded", }, body: buildCheckoutBody(params).toString(), }); const data = await response.json(); if (!response.ok) { const message = typeof data?.error?.message === "string" ? data.error.message : "Stripe checkout session creation failed"; throw new StripeRequestError(message, response.status); } if (!data?.url || !data?.id) { throw new StripeRequestError("Stripe checkout response missing session URL", 502); } return data as StripeCheckoutSession; } export function verifyStripeSignature({ payload, signatureHeader, webhookSecret, toleranceSeconds = 300, }: { payload: string; signatureHeader: string; webhookSecret: string; toleranceSeconds?: number; }) { const parsed = signatureHeader.split(",").reduce>((acc, part) => { const [key, value] = part.split("="); if (!key || !value) { return acc; } if (!acc[key]) { acc[key] = []; } acc[key].push(value); return acc; }, {}); const timestamp = parsed.t?.[0]; const signatures = parsed.v1 || []; if (!timestamp || signatures.length === 0) { return false; } const signedPayload = `${timestamp}.${payload}`; const expected = createHmac("sha256", webhookSecret).update(signedPayload, "utf8").digest("hex"); const expectedBuffer = Buffer.from(expected, "hex"); const valid = signatures.some((signature) => { const candidateBuffer = Buffer.from(signature, "hex"); if (candidateBuffer.length !== expectedBuffer.length) { return false; } return timingSafeEqual(candidateBuffer, expectedBuffer); }); if (!valid) { return false; } const now = Math.floor(Date.now() / 1000); const eventTs = Number(timestamp); if (!Number.isFinite(eventTs)) { return false; } return Math.abs(now - eventTs) <= toleranceSeconds; }