import { prisma } from "@/lib/prisma"; import { getStripeClient } from "@/lib/stripe"; import { releaseUnpaidOrder } from "@/lib/order-release"; import { rateLimit, getClientIp } from "@/lib/rate-limit"; const CANCEL_ATTEMPTS_PER_WINDOW = 20; const CANCEL_WINDOW_MS = 15 * 60 * 1000; /** Format des Abbruch-Tokens aus api/checkout/route.ts: 32 Byte als Hex. */ export const CANCEL_TOKEN_PATTERN = /^[0-9a-f]{64}$/; /** * HttpOnly-Cookie mit dem Abbruch-Token der zuletzt angelegten Bestellung. * * Bindet den "erneut bezahlen"-Weg auf der Rueckkehrseite an den Browser, der * die Bestellung angelegt hat. Die session_id steht in der Adresszeile und * damit in Verlauf, Proxy-Logs und Screenshots — allein darf sie weder das * Zahlungsformular einer fremden Bestellung oeffnen noch sie abbrechen. * * SameSite=Lax statt Strict: nach PayPal kommt die Kundschaft per * Top-Level-Navigation von paypal.com bzw. stripe.com zurueck, und nur Lax * schickt das Cookie dabei mit. */ export const CHECKOUT_COOKIE = "wh_checkout_ref"; export const CHECKOUT_COOKIE_PATH = "/checkout"; /** Etwas laenger als die Sitzung (32 Minuten), nie laenger als noetig. */ export const CHECKOUT_COOKIE_MAX_AGE_SECONDS = 40 * 60; /** * Gibt den beim Checkout reservierten Lagerbestand sofort wieder frei, wenn * die Kundschaft die Zahlung abbricht — ueber "Zurueck zu deinen Angaben" im * eingebetteten Zahlungsschritt, ueber die Rueckkehrseite nach einer nicht * abgeschlossenen Zahlung oder ueber einen alten Abbruch-Link. * * Ohne das bliebe die Ware bis zum Ablauf der Stripe-Sitzung blockiert. Die * Sitzung laeuft zwar nur noch 32 Minuten statt 24 Stunden (siehe * api/checkout/route.ts), aber eine halbe Stunde ist immer noch eine halbe * Stunde, in der das letzte Glas fuer niemanden kaufbar ist, obwohl die * Person gerade ausdruecklich abgebrochen hat. * * Reihenfolge ist hier entscheidend: erst bei Stripe nachsehen, dann erst * etwas zurueckbuchen. Wuerden wir umgekehrt vorgehen, koennte eine bereits * bezahlte Bestellung storniert und ihr Bestand doppelt verkauft werden — * etwa wenn die Zahlung in einem zweiten Tab abgeschlossen wurde. * * Wirft nie: die aufrufende Oberflaeche soll in jedem Fall weiterkommen. Der * Bestand kommt im Fehlerfall spaetestens mit dem Ablauf der Sitzung zurueck. */ export async function releaseCheckout(cancelToken: string): Promise { if (!CANCEL_TOKEN_PATTERN.test(cancelToken)) return; // Der Token hat 32 Byte und ist praktisch nicht zu erraten; die Bremse ist // trotzdem sinnvoll, weil jeder Aufruf sonst eine Stripe-Abfrage ausloest. const ip = await getClientIp(); if (!rateLimit(`checkout-cancel:${ip}`, CANCEL_ATTEMPTS_PER_WINDOW, CANCEL_WINDOW_MS)) return; const order = await prisma.order.findUnique({ where: { cancelToken }, select: { id: true, orderNumber: true, status: true, stripeCheckoutSessionId: true }, }); // Unbekannter Token oder die Bestellung ist laengst nicht mehr offen // (bezahlt, storniert, versendet) — nichts zu tun. Bewusst ohne // unterscheidbare Rueckmeldung nach aussen: der Aufrufer sieht in jedem // Fall dasselbe und erfaehrt damit nicht, ob es den Token gibt. if (!order || order.status !== "NEU") return; if (!order.stripeCheckoutSessionId) { // Ohne Sitzungs-ID laesst sich bei Stripe nicht pruefen, ob doch bezahlt // wurde — also nichts zurueckbuchen und den Aufraeumlauf abwarten. console.warn( `[checkout-cancel] Bestellung ${order.orderNumber} hat keine Stripe-Sitzung hinterlegt; Freigabe erst durch den Aufraeumlauf.` ); return; } try { const stripe = getStripeClient(); const session = await stripe.checkout.sessions.retrieve(order.stripeCheckoutSessionId); // "complete" heisst bezahlt oder in Bearbeitung — dann ist dieser Aufruf // kein Abbruch mehr. Finger weg. if (session.status === "complete") return; // "open" heisst: noch bezahlbar. Erst die Sitzung schliessen, damit // niemand die bereits freigegebene Ware im naechsten Moment doch noch // bezahlt, und erst danach zurueckbuchen. if (session.status === "open") { await stripe.checkout.sessions.expire(order.stripeCheckoutSessionId); } // Uebrig bleiben "open" (gerade geschlossen) und "expired" — in beiden // Faellen kann nicht mehr bezahlt werden. Stripe schickt dazu ein // checkout.session.expired-Ereignis; der Webhook findet die Bestellung // dann nicht mehr und tut nichts weiter (siehe lib/order-release.ts). // // "delete": hier wurde nie bezahlt und nie etwas bestaetigt. Eine // stornierte Bestellung stehen zu lassen wuerde nur verwirren — im // Admin-Bereich saehe jeder weggeklickte Warenkorb aus wie ein echter, // gescheiterter Verkauf. const released = await releaseUnpaidOrder(order.id, "delete"); if (released) { console.info( `[checkout-cancel] Bestellung ${released.orderNumber} abgebrochen, Bestand freigegeben und Vormerkung entfernt.` ); } } catch (error: unknown) { console.error( `[checkout-cancel] Bestellung ${order.orderNumber} konnte nicht freigegeben werden:`, error ); } }