import type { APIEvent } from "@solidjs/start/server"; import { env } from "~/env/server"; import { TURNSTILE_CONFIG } from "~/config"; import { verifyTurnstileToken } from "~/server/fetch-utils"; import { json, error } from "./_lib"; import { trackRequest } from "~/server/track-download"; /** * POST /api/the-nook/checkout * Body: { turnstileToken } * * Verifies the Cloudflare Turnstile token, then creates a Stripe Checkout * session for the one-time $10 The Nook license. Returns the hosted * checkout URL for the client to redirect to. * * Analytics: a created session is the funnel's "checkout started" stage * (FRE-366). Until then the only trace was the client-side /checkout page * view — a page, not a started checkout. Recorded through the shared * `trackRequest` writer (referrer, campaign tags, country, real client * address, host) under this route's own path, best-effort: a failed * analytics write never blocks a checkout, and an attempt Turnstile or * Stripe rejects is correctly not a started checkout. * * Uses raw `fetch` (Node 24 / Vercel Node functions have global fetch) — no * `stripe` npm dependency added. */ export async function POST(event: APIEvent) { let body: unknown; try { body = await event.request.json(); } catch { return error("Invalid JSON", 400); } const b = (body ?? {}) as Record; const turnstileToken = typeof b.turnstileToken === "string" ? b.turnstileToken : ""; const turnstileValid = await verifyTurnstileToken( turnstileToken, env.TURNSTILE_SECRET_KEY, TURNSTILE_CONFIG.VERIFY_URL, TURNSTILE_CONFIG.RESPONSE_TIMEOUT_MS ); if (!turnstileValid) { return error("Security verification failed", 403); } const params = new URLSearchParams({ mode: "payment", "line_items[0][price]": env.NOOK_STRIPE_PRICE_ID, "line_items[0][quantity]": "1", success_url: "https://nook.freno.me/success?session_id={CHECKOUT_SESSION_ID}", cancel_url: "https://nook.freno.me/checkout" }); let stripeRes: Response; try { stripeRes = await fetch("https://api.stripe.com/v1/checkout/sessions", { method: "POST", headers: { Authorization: `Bearer ${env.NOOK_STRIPE_SK}`, "Content-Type": "application/x-www-form-urlencoded" }, body: params }); } catch { return error("Stripe unreachable", 502); } if (!stripeRes.ok) { const stripeError = await stripeRes.text(); console.error("Stripe checkout error:", stripeError); return error(`Stripe error: ${stripeError}`, 502); } const data = (await stripeRes.json()) as { url?: string }; if (!data.url) { return error("Stripe returned no checkout URL", 502); } // The session exists: that is "checkout started". Awaited so the row is // flushed before the response — a buffered write is not guaranteed to // survive the response on serverless. await trackRequest(event, { path: "/api/the-nook/checkout", method: "POST" }); return json({ checkoutUrl: data.url }); }