fix: Bestell- und Abrechnungsprozess vor Go-Live abdichten

Fuenf Luecken zwischen Checkout, Zahlung und Stornierung geschlossen.

1. Bestellung ohne Stripe-Sitzung blieb dauerhaft haengen
   Schlug stripe.checkout.sessions.create fehl, blieb die Bestellung auf
   "NEU" ohne stripeCheckoutSessionId stehen. Damit griff kein einziger
   Aufraeumweg: der Abbruch-Klick braucht die Sitzungs-ID, der CronJob
   filterte solche Bestellungen sogar aktiv heraus, und ein Stripe-Event
   kann es ohne Sitzung nicht geben. Der reservierte Lagerbestand blieb
   fuer immer blockiert, und die Oberflaeche schickte die Kundschaft dabei
   auf die Erfolgsseite ("wir melden uns") — obwohl es keinen Weg gab,
   diese Bestellung je zu bezahlen.

   Die Route loest die Bestellung jetzt selbst auf (Bestand zurueck,
   Status STORNIERT mit Notiz) und antwortet mit einem ehrlichen Fehler,
   der ausdruecklich sagt, dass nichts abgebucht wurde. Laesst sich die
   Sitzungs-ID nicht speichern, wird die Sitzung sofort wieder
   geschlossen und derselbe Weg gegangen — eine "NEU"-Bestellung ohne
   Sitzungs-ID kann so nicht mehr entstehen. Der CronJob raeumt
   Altbestaende trotzdem ab (Filter entfernt).

2. Kaufabbrueche standen als Bestellungen im Kundenkonto
   getOrdersForCustomer lieferte auch den Status "NEU" aus. Ein gerade
   abgebrochener Kauf stand damit bis zu 45 Minuten lang mit
   Bestellnummer und Summe in der Liste und sah aus wie ein zustande
   gekommener Kauf. "NEU" wird dort jetzt ausgeblendet; in der
   Bestellverfolgung bleibt die Bestellung auffindbar, heisst aber
   "Zahlung ausstehend" statt "Neu".

3. Admin-Storno einer bezahlten Bestellung erstattete kein Geld
   Status wurde gesetzt, Ware zurueckgebucht, der Betrag blieb beim Shop.
   Der Storno erstattet jetzt ueber die Stripe Refunds API und setzt
   Payment.status auf REFUNDED.

4. Admin-Storno schloss die offene Stripe-Sitzung nicht
   Die Kundschaft konnte danach noch bezahlen; der Webhook fand die
   Bestellung nicht mehr auf "NEU" und brach still ab. Die Sitzung wird
   jetzt geschlossen, und der Webhook protokolliert eine Zahlung auf eine
   nicht mehr offene Bestellung laut, statt sie zu verschweigen.

5. Erstattungen und Rueckbuchungen kamen im Shop nicht an
   charge.refunded und charge.dispute.created werden jetzt verarbeitet:
   eine volle Erstattung aus dem Dashboard setzt Payment auf REFUNDED,
   eine Teilerstattung wird protokolliert, eine Rueckbuchung landet als
   Vermerk an der Bestellung. preflight prueft beide Abonnements mit.

Scheitert ein Stripe-Aufruf beim Storno, wird die Stornierung nicht
zurueckgenommen — der offene Punkt erscheint als Fehlermeldung im
Admin-Bereich und als order.cancel_incomplete im Audit-Log.

tsc: 0 Fehler. vitest: 294/294 gruen (ein Regressionstest fuer den
502-Fall neu). eslint: sauber.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5 committed 2026-09-18 16:57:18 +02:00
1 parent dda0a66c6c
commit 1fa8dc9ce2
11 files changed
+481 -35

No files matched your search

+8 -1
View File
@@ -238,6 +238,13 @@ const REQUIRED_WEBHOOK_EVENTS = [
"checkout.session.async_payment_succeeded",
"checkout.session.async_payment_failed",
"checkout.session.expired",
// Geld, das den Shop wieder verlaesst, ohne dass hier jemand etwas
// angeklickt hat: eine Erstattung von Hand im Stripe-Dashboard und eine
// Rueckbuchung durch die Bank. Ohne diese beiden Ereignisse steht die
// Zahlung im Shop weiter als erfolgreich da, und eine fristgebundene
// Rueckbuchung faellt nur auf, wenn jemand ins Dashboard sieht.
"charge.refunded",
"charge.dispute.created",
] as const;
async function checkStripeWebhookEvents(): Promise<void> {
@@ -266,7 +273,7 @@ async function checkStripeWebhookEvents(): Promise<void> {
}
must(
"Stripe-Webhook abonniert alle vier benoetigten Ereignisse",
`Stripe-Webhook abonniert alle ${REQUIRED_WEBHOOK_EVENTS.length} benoetigten Ereignisse`,
missing.length === 0,
`Nicht abonniert: ${missing.join(", ")}. Besonders checkout.session.expired: ohne dieses Ereignis bleibt der Lagerbestand jedes abgebrochenen Checkouts blockiert. Unter https://dashboard.stripe.com/webhooks am Endpunkt nachtragen.`
);
+24 -2
View File
@@ -44,11 +44,15 @@ const MAX_ORDERS_PER_RUN = 200;
async function main(): Promise<void> {
const cutoff = new Date(Date.now() - MIN_AGE_MINUTES * 60 * 1000);
// Bewusst OHNE Filter auf stripeCheckoutSessionId: genau die Bestellungen
// ohne hinterlegte Sitzung waren frueher das Loch in diesem Netz. Sie
// wurden hier uebergangen, der Abbruch-Klick kam ohne Sitzungs-ID ebenfalls
// nicht weiter, und ein Ereignis von Stripe konnte es ohne Sitzung gar
// nicht geben — ihr reservierter Lagerbestand blieb dauerhaft blockiert.
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" },
@@ -72,7 +76,25 @@ async function main(): Promise<void> {
for (const order of candidates) {
try {
const session = await stripe.checkout.sessions.retrieve(order.stripeCheckoutSessionId!);
if (!order.stripeCheckoutSessionId) {
// Seit api/checkout/route.ts die Sitzung wieder schliesst, wenn sich
// ihre ID nicht speichern laesst, sollte es das nicht mehr geben. Es
// bleibt aber der Fall, dass auch dieses Schliessen fehlschlug, und
// Altbestaende aus der Zeit davor. Beide sind aelter als MIN_AGE_MINUTES
// und damit aelter als CHECKOUT_SESSION_TTL_MINUTES: eine Sitzung, die
// es noch gaebe, waere zu diesem Zeitpunkt in jedem Fall abgelaufen.
// Der Bestand kann also gefahrlos zurueck.
const result = await releaseUnpaidOrder(order.id, "delete");
if (result) {
released += 1;
console.warn(
`[release-stale-orders] ${result.orderNumber}: Bestand freigegeben, Vormerkung entfernt — zu dieser Bestellung war KEINE Stripe-Sitzung hinterlegt, sie war also nie bezahlbar.`
);
}
continue;
}
const session = await stripe.checkout.sessions.retrieve(order.stripeCheckoutSessionId);
if (session.status === "complete") {
// Bezahlt, aber der Status steht noch auf "NEU": der