import { timingSafeEqual } from "node:crypto"; import { prisma } from "@/lib/prisma"; import { getStripeClient } from "@/lib/stripe"; import { rateLimit, getClientIp } from "@/lib/rate-limit"; const RETURN_LOOKUPS_PER_WINDOW = 60; const RETURN_WINDOW_MS = 15 * 60 * 1000; /** Stripe-Checkout-Sitzungs-IDs: "cs_test_..." bzw. "cs_live_...". */ const SESSION_ID_PATTERN = /^cs_(test|live)_[A-Za-z0-9]{1,255}$/; export type CheckoutReturn = /** Zahlung eingegangen (Karte, PayPal). */ | { kind: "paid"; orderNumber: string } /** Abgeschlossen, Geld aber noch unterwegs (z.B. SEPA-Lastschrift). */ | { kind: "processing"; orderNumber: string } /** Zahlung fehlgeschlagen oder bei PayPal abgebrochen — noch bezahlbar. */ | { kind: "open"; clientSecret: string; cancelToken: string } /** Abgelaufen, abgebrochen, unbekannt oder nicht pruefbar. */ | { kind: "notCompleted" }; const NOT_COMPLETED: CheckoutReturn = { kind: "notCompleted" }; function tokensMatch(a: string | undefined, b: string | null): boolean { if (!a || !b) return false; const left = Buffer.from(a); const right = Buffer.from(b); return left.length === right.length && timingSafeEqual(left, right); } /** * Wertet die Rueckkehr von Stripe auf /checkout/success aus. * * Die URL beweist nichts: jeder kann ?session_id=... von Hand aufrufen. Ob * bezahlt wurde, entscheidet deshalb allein die Abfrage bei Stripe, und die * Bestellnummer kommt aus der eigenen Datenbank — nie aus der Adresszeile. * Die eigentliche Verbuchung (Rechnung, Mail, Status) bleibt beim Webhook; * diese Seite zeigt nur an. * * Unbekannte, fremde oder fehlerhafte IDs landen ununterscheidbar bei * "notCompleted", damit sich ueber diese Seite nichts ausforschen laesst. * * browserCancelToken ist der Inhalt des CHECKOUT_COOKIE: nur wenn er zur * Bestellung passt, wird ein erneuter Zahlungsversuch angeboten (siehe dort). */ export async function resolveCheckoutReturn( sessionId: string, browserCancelToken?: string ): Promise { if (!SESSION_ID_PATTERN.test(sessionId)) return NOT_COMPLETED; // Jeder Aufruf kostet eine Stripe-Abfrage — die Bremse haelt Durchprobieren // und versehentliche Neulade-Schleifen klein. const ip = await getClientIp(); if (!rateLimit(`checkout-return:${ip}`, RETURN_LOOKUPS_PER_WINDOW, RETURN_WINDOW_MS)) { return NOT_COMPLETED; } // Zuerst die eigene Datenbank: eine ID, zu der es keine Bestellung gibt, // loest gar nicht erst eine Anfrage bei Stripe aus. const order = await prisma.order.findFirst({ where: { stripeCheckoutSessionId: sessionId }, select: { id: true, orderNumber: true, status: true, cancelToken: true }, }); if (!order || order.status === "STORNIERT") return NOT_COMPLETED; try { const session = await getStripeClient().checkout.sessions.retrieve(sessionId); // Zweite Absicherung: die Sitzung muss zu genau dieser Bestellung gehoeren. if (session.metadata?.orderId !== order.id) { console.error( `[checkout-return] Sitzung ${sessionId} gehoert laut Metadaten nicht zu Bestellung ${order.orderNumber}.` ); return NOT_COMPLETED; } if (session.status === "complete") { const isPaid = session.payment_status === "paid" || session.payment_status === "no_payment_required"; return { kind: isPaid ? "paid" : "processing", orderNumber: order.orderNumber }; } // Nur eine noch offene Bestellung, und nur fuer den Browser, der sie // angelegt hat, darf erneut bezahlt werden. if ( session.status === "open" && order.status === "NEU" && session.client_secret && order.cancelToken && tokensMatch(browserCancelToken, order.cancelToken) ) { return { kind: "open", clientSecret: session.client_secret, cancelToken: order.cancelToken }; } return NOT_COMPLETED; } catch (error: unknown) { console.error(`[checkout-return] Sitzung ${sessionId} konnte nicht abgefragt werden:`, error); // Hat der Webhook die Zahlung schon verbucht, ist das die verlaessliche // Auskunft — dann soll ein Stripe-Aussetzer die Freude nicht verderben. if (order.status !== "NEU") return { kind: "paid", orderNumber: order.orderNumber }; return NOT_COMPLETED; } }