/** * POST /api/checkout/guest * * Purchase-first flow — creates a Stripe Checkout session for * unauthenticated users. No Clerk auth required. * * Body: { templateId: string, channel?: string, uiMode?: "hosted" | "embedded" } * * Returns one of: * { url: string } — hosted: send the browser there * { clientSecret: string, sessionId } — embedded: mount it in our page * * Embedded is asked for only when the storefront has a Stripe publishable key, * and the route still answers "hosted" for anything else, so a deploy without * one keeps selling. The session is created with redirect_on_completion set to * `if_required`: a redirect-based payment method still returns through * `return_url`, and a plain card finishes in the iframe and hands control back * to the page, which navigates onward itself. * * The success / return URL encodes the templateId so the /checkout/success * page can display the correct plan and prompt sign-up / sign-in. * * The line item is built inline from the plan's own price (`price_data`, no * Stripe price id), which means Stripe accepts whatever number we send. So the * plan has to be real before we get here: with a live Stripe key, a catalog * that fell back to sample plans is refused outright. Charging for * "Visit — Calm" at EUR 4.99 when no such product exists is a real payment * against nothing, and no webhook downstream can fulfil it. */ import { NextRequest, NextResponse } from "next/server"; import { fetchTemplateCatalog } from "@/vendor/carrier/client"; import { decideGuestCheckout, normalizeChannel } from "@/vendor/carrier/catalog-guard"; interface GuestCheckoutBody { templateId?: string; channel?: string; uiMode?: string; /** * Set when this purchase adds data to an eSIM that already exists. Carried * into Stripe metadata so fulfilment tops up that ICCID instead of * provisioning a second eSIM the customer neither wants nor can install. */ topUpForOrderId?: string; } export async function POST(req: NextRequest): Promise { let body: GuestCheckoutBody; try { body = (await req.json()) as GuestCheckoutBody; } catch { return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); } const { templateId } = body; if (!templateId) { return NextResponse.json({ error: "templateId is required" }, { status: 400 }); } const channel = normalizeChannel(body.channel); const stripeKey = process.env.STRIPE_SECRET_KEY; const catalog = await fetchTemplateCatalog(); const decision = decideGuestCheckout({ catalog, templateId, stripeConfigured: Boolean(stripeKey), }); if (!decision.ok) { if (decision.code === "catalog_degraded") { // Loud on the server, calm to the customer. This is the state where a // storefront is live, taking traffic, and cannot tell what it sells. console.error( `[checkout] refusing to sell from a sample catalog (reason=${catalog.reason ?? "unknown"}) — ` + "NEXT_PUBLIC_CARRIER_API_URL is unset or the catalog request failed", ); } return NextResponse.json({ error: decision.error, code: decision.code }, { status: decision.status }); } const plan = decision.plan; if (!stripeKey) { // No Stripe configured — return a mock URL so the storefront still builds // and renders without credentials. No money moves on this path. const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? ""; const successUrl = `${appUrl}/checkout/success?templateId=${encodeURIComponent(templateId)}&session_id=mock`; return NextResponse.json({ url: successUrl }); } const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? ""; // success_url carries templateId so the success page knows which plan was purchased const successUrl = `${appUrl}/checkout/success?templateId=${encodeURIComponent(templateId)}&session_id={CHECKOUT_SESSION_ID}`; const cancelUrl = `${appUrl}/shop`; const embedded = body.uiMode === "embedded"; // Same charset gate the rest of the storefront applies to a Stripe session id. const topUpForOrderId = typeof body.topUpForOrderId === "string" && /^[A-Za-z0-9_-]{1,256}$/.test(body.topUpForOrderId) ? body.topUpForOrderId : null; const formBody = new URLSearchParams({ mode: "payment", "line_items[0][price_data][currency]": plan.currency.toLowerCase(), "line_items[0][price_data][unit_amount]": String(plan.price_cents), "line_items[0][price_data][product_data][name]": plan.name, "line_items[0][price_data][product_data][description]": plan.description, "line_items[0][quantity]": "1", // Embedded sessions take `return_url` and reject `success_url` / // `cancel_url`: there is no hosted page to come back from, and cancelling // is just closing the form. ...(embedded ? { ui_mode: "embedded", redirect_on_completion: "if_required", return_url: successUrl, } : { success_url: successUrl, cancel_url: cancelUrl }), // Store templateId in BOTH session and payment_intent metadata so the // provisioning webhook can read it. The `checkout.session.completed` event // exposes `session.metadata` (NOT `payment_intent.metadata`), so the // session-level copy is what fulfilment keys off to provision the eSIM — // without it the plan is lost and the order is stuck "activating". "metadata[templateId]": templateId, "payment_intent_data[metadata][templateId]": templateId, // Where the sale came from. Written alongside templateId and by the same // reasoning: attribution recovered later from Stripe reports is guesswork, // and the session is the only record that survives the redirect. "metadata[channel]": channel, "payment_intent_data[metadata][channel]": channel, // Allow guest checkout (no Stripe account required) "payment_method_collection": "always", // Session-level and payment-intent-level, for the same reason templateId // is: `checkout.session.completed` only exposes session metadata, and a // top-up that loses its target order provisions a brand-new eSIM. ...(topUpForOrderId ? { "metadata[topUpForOrderId]": topUpForOrderId, "payment_intent_data[metadata][topUpForOrderId]": topUpForOrderId, } : {}), }); const resp = await fetch("https://api.stripe.com/v1/checkout/sessions", { method: "POST", headers: { Authorization: `Bearer ${stripeKey}`, "Content-Type": "application/x-www-form-urlencoded", }, body: formBody.toString(), }); if (!resp.ok) { // Stripe's message names our parameters and our account. It goes to the // log, not to the customer. const err = (await resp.json().catch(() => ({}))) as { error?: { message?: string } }; console.error(`[checkout] Stripe session creation failed: ${err.error?.message ?? resp.status}`); return NextResponse.json( { error: "We could not start checkout. Please try again in a moment.", code: "stripe_error" }, { status: 502 }, ); } const session = (await resp.json()) as { id?: string; url?: string; client_secret?: string; }; if (embedded) { if (!session.client_secret || !session.id) { // Stripe accepted the session but did not return what an embedded form // needs. Say so rather than shipping an empty iframe — the client falls // back to the hosted page on this code. console.error("[checkout] embedded session has no client_secret"); return NextResponse.json( { error: "We could not start checkout. Please try again in a moment.", code: "embed_unavailable" }, { status: 502 }, ); } return NextResponse.json({ clientSecret: session.client_secret, sessionId: session.id }); } return NextResponse.json({ url: session.url }); }