← 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;
}