lovart3/lib/billing/stripe.ts
Your Name 353c3d04ff
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add billing center and stripe portal management
2026-02-12 12:49:03 +08:00

223 lines
5.2 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 StripeCustomerListResponse = {
data?: Array<{
id: string;
email?: string | null;
}>;
};
type StripePortalSession = {
id: string;
url: 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 async function findStripeCustomerIdByEmail({
secretKey,
email,
}: {
secretKey: string;
email: string;
}) {
const url = new URL(`${STRIPE_API_BASE}/customers`);
url.searchParams.set("email", email);
url.searchParams.set("limit", "1");
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${secretKey}`,
},
});
const data = (await response.json()) as StripeCustomerListResponse & {
error?: { message?: string };
};
if (!response.ok) {
const message =
typeof data?.error?.message === "string"
? data.error.message
: "Failed to query Stripe customer";
throw new StripeRequestError(message, response.status);
}
return data.data?.[0]?.id ?? null;
}
export async function createStripeBillingPortalSession({
secretKey,
customerId,
returnUrl,
}: {
secretKey: string;
customerId: string;
returnUrl: string;
}) {
const body = new URLSearchParams();
body.set("customer", customerId);
body.set("return_url", returnUrl);
const response = await fetch(`${STRIPE_API_BASE}/billing_portal/sessions`, {
method: "POST",
headers: {
Authorization: `Bearer ${secretKey}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: body.toString(),
});
const data = await response.json();
if (!response.ok) {
const message =
typeof data?.error?.message === "string"
? data.error.message
: "Stripe billing portal session creation failed";
throw new StripeRequestError(message, response.status);
}
if (!data?.url || !data?.id) {
throw new StripeRequestError("Stripe portal response missing URL", 502);
}
return data as StripePortalSession;
}
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;
}