/** * Pure decision logic that sits between the plan catalog and Stripe. * * It lives apart from the route handler on purpose: the route imports * `next/server`, which cannot be loaded in a plain node test, and this is the * part that has to be provable. Nothing here touches the network or the env. */ import type { SkuTemplate } from "./types"; import type { TemplateCatalog } from "./client"; /** Codes the storefront maps to customer-facing copy. Never shown verbatim. */ export type CheckoutRefusalCode = "plan_not_found" | "catalog_degraded" | "plan_not_sellable"; export type CheckoutDecision = | { ok: true; plan: SkuTemplate } | { ok: false; status: number; code: CheckoutRefusalCode; error: string }; /** * A price is only chargeable when it is a whole, positive number of minor * units and the currency is a plain ISO-4217 code. The guest route builds the * Stripe line item inline from these two fields (price_data, no price id), so * Stripe validates nothing on our behalf — this is the only check there is. */ function isSellable(plan: SkuTemplate): boolean { return ( Number.isSafeInteger(plan.price_cents) && plan.price_cents > 0 && typeof plan.currency === "string" && /^[A-Za-z]{3}$/.test(plan.currency) && typeof plan.name === "string" && plan.name.trim().length > 0 ); } /** * Decide whether a plan may be charged for. * * The rule that matters: when Stripe is live, a catalog that fell back to the * built-in sample plans must never reach a charge. Those plans have invented * ids, names and prices; a payment against one takes real money for a product * that does not exist and cannot be fulfilled. Rendering them is fine — that is * what lets the template build with no credentials — but selling them is not. */ export function decideGuestCheckout(args: { catalog: TemplateCatalog; templateId: string; stripeConfigured: boolean; }): CheckoutDecision { const { catalog, templateId, stripeConfigured } = args; if (stripeConfigured && catalog.source !== "live") { return { ok: false, status: 503, code: "catalog_degraded", error: "Plans are temporarily unavailable. Please try again in a few minutes.", }; } const plan = catalog.templates.find((p) => p.id === templateId); if (!plan) { return { ok: false, status: 404, code: "plan_not_found", error: "That plan is no longer available.", }; } if (stripeConfigured && !isSellable(plan)) { return { ok: false, status: 503, code: "plan_not_sellable", error: "That plan cannot be purchased right now. Please try again in a few minutes.", }; } return { ok: true, plan }; } /** * Channel attribution carried into Stripe session metadata. * * Stripe metadata values are free text and this one is caller-supplied — it * arrives in the request body — so it is clamped to a short slug rather than * passed through. An unrecognisable value degrades to the default instead of * failing the purchase: attribution is never worth losing a sale over. */ export const DEFAULT_CHANNEL = "storefront"; export function normalizeChannel(raw: unknown): string { if (typeof raw !== "string") return DEFAULT_CHANNEL; const slug = raw .trim() .toLowerCase() .replace(/[^a-z0-9_.-]/g, "-") .replace(/-{2,}/g, "-"); // Bound the length BEFORE stripping, and strip with a loop rather than // /^-+|-+$/g. That pattern backtracks polynomially on a long run of dashes // (CodeQL js/polynomial-redos), and `raw` comes straight off the request — // "-" repeated a few hundred thousand times is a free CPU burn otherwise. // The loop is linear and stays correct even if the collapse above changes. const bounded = slug.slice(0, 64); let start = 0; let end = bounded.length; while (start < end && bounded[start] === "-") start += 1; while (end > start && bounded[end - 1] === "-") end -= 1; return bounded.slice(start, end) || DEFAULT_CHANNEL; }