Files
Wabenhain/scripts/release-stale-orders.ts
T
Nick SzaunigandClaude Opus 5 dda0a66c6c fix: ein abgebrochener Checkout hinterlaesst keine Bestellung mehr
Bisher blieb nach einem Abbruch eine Bestellung im Status "STORNIERT"
stehen. Im Admin-Bereich sah damit jeder weggeklickte Warenkorb aus wie ein
echter, gescheiterter Verkauf — bei einem Shop, in dem die meisten Besuche
nicht im Kauf enden, verdecken diese Einträge irgendwann die tatsächlichen
Vorgänge.

Ein nie bezahlter Checkout wird deshalb gelöscht statt storniert. Es gab kein
Geschäft, keine Zahlung, keine Bestätigung und keine Rechnung, also auch
nichts aufzubewahren; die Positionen gehen mit (onDelete: Cascade). Betroffen
sind der Abbruch-Knopf, die abgelaufene Stripe-Sitzung und der Aufräumlauf.

Eine Ausnahme bleibt bewusst bestehen: checkout.session.async_payment_failed.
Dort wurde tatsächlich eine Zahlung versucht und ist gescheitert
(SEPA-Lastschrift, teils Klarna) — die Kundschaft hat den Checkout
abgeschlossen und rechnet mit einer Lieferung. Dieser Fall bleibt als
"STORNIERT" nachvollziehbar und wird weiterhin per E-Mail beantwortet. Beim
bloßen Abbruch entfällt die Mail: eine Nachricht über einen Vorgang, den man
selbst abgebrochen hat, verwirrt mehr als sie hilft.

releaseUnpaidOrder unterscheidet die beiden Fälle jetzt über einen Parameter.
Beim Löschen ist deleteMany mit der Statusbedingung der atomare Zugriff — es
entfernt entweder genau diese eine noch unbezahlte Bestellung oder nichts.
Hat inzwischen eine eingehende Zahlung den Status auf "BEZAHLT" gesetzt,
fängt count === 0 das ab: eine bezahlte Bestellung darf dieser Weg unter
keinen Umständen entfernen. Die Positionen werden vor dem Löschen gelesen,
sonst wäre der Bestand danach nicht mehr zurückzubuchen.

Die Reservierungsdauer bleibt bei 32 Minuten. Kürzer geht nicht: Stripe lässt
als Untergrenze 30 Minuten zu (die zwei Minuten sind Puffer gegen
auseinanderlaufende Uhren). Eine kürzere Reservierung ließe sich nur bauen,
indem die Sitzung vorzeitig per API geschlossen wird — das hieße, einer
Kundschaft, die gerade eine 3-D-Secure-Abfrage beantwortet, die Zahlung unter
den Händen wegzunehmen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-18 02:13:35 +02:00

132 lines
5.0 KiB
TypeScript

/**
* 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<void> {
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());