import { env } from "cloudflare:workers"; import Stripe from "stripe"; import { HttpError } from "../errors"; import { PACKS, PRO_PRICE_CENTS } from "~/lib/pricing"; import type { PackDef } from "~/lib/pricing"; /** * Stripe is optional: every billing path checks this first, and nothing else in the app requires Stripe. * Keys from different modes (a live secret with a test publishable key, say) disable billing entirely and * log the reason once per isolate; the rest of the forge keeps serving. */ export function stripeEnabled(): boolean { return Boolean(env.STRIPE_SECRET_KEY) && !reportedModeError(); } export function isTestKey(key: string | undefined): boolean { return Boolean(key && /^(sk|rk)_test_/.test(key)); } type KeyMode = "test" | "live"; /** The mode a Stripe key belongs to, from its prefix; null when it has no recognizable prefix. */ export function keyMode(key: string | undefined, kinds: string[]): KeyMode | null { const match = key ? new RegExp(`^(${kinds.join("|")})_(test|live)_`).exec(key) : null; return match ? (match[2] as KeyMode) : null; } /** * Why the Stripe configuration is unsafe to run, or null. The secret key must be sk_/rk_ test or live; the * publishable key, when set, must be the same mode; STRIPE_WEBHOOK_MODE (whsec_ secrets carry no mode), when * set, must match too. Mixing modes would accept events or show prices from the other mode. */ export function stripeModeError( vars: { secret?: string; publishable?: string; webhookMode?: string } = { secret: env.STRIPE_SECRET_KEY, publishable: env.STRIPE_PUBLISHABLE_KEY, webhookMode: env.STRIPE_WEBHOOK_MODE, }, ): string | null { if (!vars.secret) return null; const mode = keyMode(vars.secret, ["sk", "rk"]); if (!mode) return "STRIPE_SECRET_KEY must start with sk_test_, sk_live_, rk_test_ or rk_live_"; if (vars.publishable) { const publishable = keyMode(vars.publishable, ["pk"]); if (publishable !== mode) return `STRIPE_PUBLISHABLE_KEY must be a pk_${mode}_ key to match STRIPE_SECRET_KEY`; } const webhookMode = vars.webhookMode?.trim().toLowerCase(); if (webhookMode && webhookMode !== mode) { return `STRIPE_WEBHOOK_MODE is ${webhookMode} but STRIPE_SECRET_KEY is a ${mode} key`; } return null; } let reported: { config: string; error: string | null } | null = null; function reportedModeError(): string | null { const config = `${env.STRIPE_SECRET_KEY}|${env.STRIPE_PUBLISHABLE_KEY}|${env.STRIPE_WEBHOOK_MODE}`; if (reported?.config !== config) { const error = stripeModeError(); if (error) console.error(`Stripe billing disabled: ${error}`); reported = { config, error }; } return reported.error; } /** Whether the secret key is a live-mode key; webhook events must match it. */ export function liveMode(): boolean { return keyMode(env.STRIPE_SECRET_KEY, ["sk", "rk"]) === "live"; } /** BILLING_CHECKOUT_DISABLED: no new checkout or portal plan change starts; webhooks keep processing. */ export function checkoutDisabled(): boolean { const raw = env.BILLING_CHECKOUT_DISABLED?.trim().toLowerCase(); return raw === "true" || raw === "1"; } export function billingNotConfigured(): HttpError { return new HttpError(501, "Billing is not configured on this instance"); } let cached: { key: string; client: Stripe } | null = null; const STRIPE_TIMEOUT_MS = 10_000; /** The Stripe client, on the fetch HTTP client so it runs on Workers. Throws when Stripe is not configured. */ export function stripe(): Stripe { const key = env.STRIPE_SECRET_KEY; if (!key || reportedModeError()) throw billingNotConfigured(); if (cached?.key !== key) { cached = { key, client: new Stripe(key, { httpClient: Stripe.createFetchHttpClient(), maxNetworkRetries: 2, timeout: STRIPE_TIMEOUT_MS, }), }; } return cached.client; } export const webCrypto = Stripe.createSubtleCryptoProvider(); export type TopupPack = PackDef & { price: string }; /** * Prepaid packs on sale: the packs defined in src/lib/pricing.ts that STRIPE_TOPUP_PRICES maps to a Stripe * price id, as `{"": "price_..."}`. The legacy `{"": {"price": "price_..."}}` shape is accepted; * quantities and amounts always come from pricing.ts, never from the environment. */ export function topupPacks(raw = env.STRIPE_TOPUP_PRICES): TopupPack[] { if (!raw) return []; let parsed: unknown; try { parsed = JSON.parse(raw); } catch { console.warn("STRIPE_TOPUP_PRICES is not valid JSON"); return []; } if (!parsed || typeof parsed !== "object") return []; const prices = parsed as Record; const packs: TopupPack[] = []; for (const pack of PACKS) { const entry = prices[pack.id]; const price = typeof entry === "string" ? entry : (entry as { price?: unknown } | undefined)?.price; if (typeof price === "string" && price.startsWith("price_")) packs.push({ ...pack, price }); } return packs; } const PRICE_CACHE_MS = 10 * 60 * 1000; /** Failed lookups (outage, a restricted key without Prices read) are retried after this, not on every view. */ const PRICE_FAILURE_CACHE_MS = 60 * 1000; const priceCache = new Map(); /** A Stripe price's amount when it is active and in USD, cached briefly; null when unusable or unreachable. */ async function activePriceCents(id: string): Promise { const hit = priceCache.get(id); if (hit && Date.now() - hit.at < hit.ttl) return hit.cents; let cents: number | null = null; let ttl = PRICE_CACHE_MS; try { const price = await stripe().prices.retrieve(id); cents = price.active && price.currency === "usd" ? price.unit_amount : null; } catch (error) { console.warn("Could not load Stripe price", id, error instanceof Error ? error.message : error); ttl = PRICE_FAILURE_CACHE_MS; } priceCache.set(id, { cents, at: Date.now(), ttl }); return cents; } /** * Packs whose Stripe price charges exactly what pricing.ts says. A mismatch (an old price id after a price * change) hides the pack rather than showing one amount and charging another; rerun the setup script to fix. */ export async function verifiedPacks(): Promise { const packs = topupPacks(); const amounts = await Promise.all(packs.map((pack) => activePriceCents(pack.price))); return packs.filter((pack, index) => { if (amounts[index] === pack.cents) return true; console.warn(`Stripe price for ${pack.id} does not match pricing.ts; hiding it`); return false; }); } /** Whether STRIPE_PRICE_PRO charges PRO_PRICE_CENTS. */ export async function proPriceVerified(): Promise { if (!env.STRIPE_PRICE_PRO) return false; const ok = (await activePriceCents(env.STRIPE_PRICE_PRO)) === PRO_PRICE_CENTS; if (!ok) console.warn("STRIPE_PRICE_PRO does not charge PRO_PRICE_CENTS; hiding the upgrade"); return ok; }