← back to Trademarks Copyright
src/lib/stripe.ts
113 lines
/**
* Stripe integration is fully optional.
* - Without keys: admin manually marks subscribers as tier='standard'|'pro' (comp tier).
* - With keys: Checkout session for signup/upgrade; webhook flips tier on checkout.session.completed.
*
* No runtime dependency on the stripe SDK — we hit their REST API directly to keep deps light.
*/
const STRIPE_API = "https://api.stripe.com/v1";
export function stripeEnabled(): boolean {
return !!process.env.STRIPE_SECRET_KEY;
}
export const TIERS = {
standard: {
name: "Standard",
monthlyUsd: 29,
priceEnv: "STRIPE_PRICE_STANDARD",
items: 3,
description: "Daily drop with 3 curated opportunities.",
},
pro: {
name: "Pro",
monthlyUsd: 99,
priceEnv: "STRIPE_PRICE_PRO",
items: 10,
description: "Daily drop with 10 opportunities + SWOT preview on each.",
},
} as const;
export type TierId = keyof typeof TIERS;
async function stripeFetch(path: string, init: RequestInit & { form?: Record<string, string> } = {}): Promise<unknown> {
const { form, ...rest } = init;
const headers = new Headers(rest.headers);
headers.set("authorization", `Bearer ${process.env.STRIPE_SECRET_KEY}`);
if (form) {
headers.set("content-type", "application/x-www-form-urlencoded");
rest.body = new URLSearchParams(form).toString();
}
const r = await fetch(`${STRIPE_API}${path}`, { ...rest, headers });
const j = await r.json();
if (!r.ok) throw new Error(`Stripe ${r.status}: ${JSON.stringify(j).slice(0, 300)}`);
return j;
}
export interface CheckoutArgs {
email: string;
tier: TierId;
successUrl: string;
cancelUrl: string;
subscriberToken: string; // passed through metadata so webhook knows who upgraded
}
export async function createCheckoutSession(a: CheckoutArgs): Promise<{ url: string; id: string }> {
const priceId = process.env[TIERS[a.tier].priceEnv];
if (!priceId) throw new Error(`${TIERS[a.tier].priceEnv} not set in env`);
const j = (await stripeFetch("/checkout/sessions", {
method: "POST",
form: {
mode: "subscription",
"line_items[0][price]": priceId,
"line_items[0][quantity]": "1",
customer_email: a.email,
success_url: a.successUrl,
cancel_url: a.cancelUrl,
"metadata[subscriber_token]": a.subscriberToken,
"metadata[tier]": a.tier,
},
})) as { id: string; url: string };
return { id: j.id, url: j.url };
}
import { createHmac, timingSafeEqual } from "node:crypto";
/**
* Full HMAC-SHA256 verification of a Stripe webhook signature header.
* Equivalent to `stripe.webhooks.constructEvent` without adding the SDK as a dep.
*
* header format: t=<unix>,v1=<hex>[,v1=<hex>...]
* signed payload: `${t}.${rawBody}`
*
* Returns true iff any v1 hash matches AND the timestamp is within `tolerance` seconds.
*/
export function verifyWebhookSignature(
rawBody: string,
header: string | null,
tolerance = 300
): boolean {
const secret = process.env.STRIPE_WEBHOOK_SECRET;
if (!secret || !header) return false;
const parts = header.split(",").map((p) => p.trim().split("="));
const t = parts.find((p) => p[0] === "t")?.[1];
const v1s = parts.filter((p) => p[0] === "v1").map((p) => p[1]);
if (!t || !v1s.length) return false;
const unixNow = Math.floor(Date.now() / 1000);
if (Math.abs(unixNow - Number(t)) > tolerance) return false;
const signedPayload = `${t}.${rawBody}`;
const expected = createHmac("sha256", secret).update(signedPayload).digest("hex");
const expectedBuf = Buffer.from(expected, "utf8");
for (const v of v1s) {
const vBuf = Buffer.from(v, "utf8");
if (vBuf.length !== expectedBuf.length) continue;
if (timingSafeEqual(vBuf, expectedBuf)) return true;
}
return false;
}