137 lines
3.4 KiB
TypeScript
137 lines
3.4 KiB
TypeScript
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<string, string>;
|
|
};
|
|
|
|
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<Record<string, string[]>>((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;
|
|
}
|