/** * Aufraeumlauf fuer liegengebliebene, nie bezahlte Bestellungen. * * Eine Bestellung entsteht vor der Zahlung und reserviert dabei sofort * Lagerbestand. Drei Wege geben ihn normalerweise wieder frei: * * 1. der Abbruch durch die Kundschaft (checkout/cancel), * 2. das Ereignis checkout.session.expired von Stripe (Webhook), * 3. dieser Lauf hier. * * Die ersten beiden sind die Regel, dieser hier ist das Netz darunter. Er * faengt genau die Faelle ab, in denen Weg 2 nicht funktioniert: das Ereignis * checkout.session.expired ist im Stripe-Dashboard nicht abonniert, der * Endpunkt war beim Zustellversuch nicht erreichbar, oder die Zustellversuche * sind abgelaufen. Ohne dieses Netz bliebe der reservierte Bestand dann fuer * immer blockiert — unsichtbar, denn im Admin-Bereich sieht eine solche * Bestellung aus wie jede andere offene. * * Gefragt wird immer Stripe, nie die Uhr allein: nur wenn die Sitzung dort * nachweislich abgelaufen ist, wird zurueckgebucht. Ein Lauf, der nur nach * dem Alter ginge, koennte eine gerade laufende Zahlung unter den Fuessen * wegstornieren. * * Aufruf: pnpm orders:release-stale * Im Cluster laeuft er als CronJob (siehe k8s/07-backup-cronjob.yaml). */ import { prisma } from "../src/lib/prisma"; import { getStripeClient } from "../src/lib/stripe"; import { releaseUnpaidOrder } from "../src/lib/order-release"; /** * Mindestalter, bevor eine Bestellung ueberhaupt betrachtet wird. * * Die Stripe-Sitzung laeuft nach 32 Minuten ab (siehe * CHECKOUT_SESSION_TTL_MINUTES in app/api/checkout/route.ts). 45 Minuten * lassen dem regulaeren Weg — Ablauf bei Stripe, Ereignis, Webhook — genug * Luft, seine Arbeit zuerst zu tun. */ const MIN_AGE_MINUTES = 45; /** Obergrenze je Lauf, damit ein Rueckstand die Datenbank nicht am Stueck belastet. */ const MAX_ORDERS_PER_RUN = 200; async function main(): Promise { const cutoff = new Date(Date.now() - MIN_AGE_MINUTES * 60 * 1000); const candidates = await prisma.order.findMany({ where: { status: "NEU", createdAt: { lt: cutoff }, stripeCheckoutSessionId: { not: null }, }, select: { id: true, orderNumber: true, stripeCheckoutSessionId: true }, orderBy: { createdAt: "asc" }, take: MAX_ORDERS_PER_RUN, }); if (candidates.length === 0) { console.log("[release-stale-orders] Keine liegengebliebenen Bestellungen."); return; } console.log( `[release-stale-orders] ${candidates.length} offene Bestellung(en) werden bei Stripe geprueft.` ); const stripe = getStripeClient(); let released = 0; let stillOpen = 0; let paid = 0; let failed = 0; for (const order of candidates) { try { const session = await stripe.checkout.sessions.retrieve(order.stripeCheckoutSessionId!); if (session.status === "complete") { // Bezahlt, aber der Status steht noch auf "NEU": der // checkout.session.completed-Webhook ist nicht angekommen. Diesen Fall // loest der Lauf hier bewusst NICHT auf — er wuerde Rechnung, // Bestaetigungsmail und Gutscheinzaehlung ueberspringen. Stattdessen // laut melden, damit die Zustellung im Stripe-Dashboard wiederholt // werden kann. paid += 1; console.error( `[release-stale-orders] Bestellung ${order.orderNumber} ist bei Stripe BEZAHLT, steht hier aber auf "NEU". Der Webhook ist nicht angekommen — bitte im Stripe-Dashboard erneut zustellen.` ); continue; } if (session.status === "open") { // Sitzung laeuft noch (moeglich bei Zahlungsarten mit langer Frist). // Nicht anfassen. stillOpen += 1; continue; } // "delete": nie bezahlt, nie bestaetigt — die Vormerkung verschwindet // mitsamt der Bestellung, statt als "STORNIERT" im Admin-Bereich wie // ein gescheiterter Verkauf auszusehen. const result = await releaseUnpaidOrder(order.id, "delete"); if (result) { released += 1; console.log( `[release-stale-orders] ${result.orderNumber}: Bestand freigegeben, Vormerkung entfernt (Sitzung abgelaufen).` ); } } catch (error: unknown) { // Eine einzelne fehlgeschlagene Abfrage darf den Lauf nicht beenden — // die uebrigen Bestellungen sollen trotzdem geprueft werden. failed += 1; console.error(`[release-stale-orders] ${order.orderNumber} konnte nicht geprueft werden:`, error); } } console.log( `[release-stale-orders] Fertig: ${released} freigegeben, ${stillOpen} noch offen, ${paid} bezahlt aber ohne Webhook, ${failed} fehlgeschlagen.` ); // Ein bezahlter, aber nicht verarbeiteter Fall ist ein echter Missstand und // soll den CronJob als fehlgeschlagen markieren, damit er im Cluster // auffaellt statt nur in einem Logeintrag zu verschwinden. if (paid > 0) process.exitCode = 1; } main() .catch((error: unknown) => { console.error("[release-stale-orders] Abgebrochen:", error); process.exitCode = 1; }) .finally(() => prisma.$disconnect());