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]>
This commit is contained in:
Nick SzaunigandClaude Opus 5 committed 2026-09-18 02:13:35 +02:00
1 parent a3f91b83fc
commit dda0a66c6c
5 files changed
+105 -50

No files matched your search

+23 -12
View File
@@ -3,7 +3,7 @@ import { getStripeClient } from "@/lib/stripe";
import { prisma } from "@/lib/prisma";
import { sendMail } from "@/lib/mail";
import { orderConfirmationEmail, paymentFailedEmail } from "@/lib/email-templates";
import { releaseUnpaidOrder } from "@/lib/order-release";
import { releaseUnpaidOrder, type ReleaseMode } from "@/lib/order-release";
import { getBaseUrl } from "@/lib/url";
import { getInvoiceByNumber } from "@/lib/account-data";
import { generateInvoicePdf } from "@/lib/invoice-pdf";
@@ -113,12 +113,20 @@ export async function POST(request: Request) {
if (event.type === "checkout.session.async_payment_failed") {
const session = event.data.object as Stripe.Checkout.Session;
await failOrder(session);
// Hier wurde tatsaechlich eine Zahlung versucht und ist gescheitert. Die
// Kundschaft hat den Checkout abgeschlossen und rechnet mit einer
// Lieferung — das gehoert nachvollziehbar festgehalten und beantwortet.
await failOrder(session, "cancel");
}
if (event.type === "checkout.session.expired") {
const session = event.data.object as Stripe.Checkout.Session;
await failOrder(session);
// Hier wurde nie bezahlt und nie etwas bestaetigt: jemand hat den
// Checkout stehen lassen. Die Bestellung verschwindet spurlos, statt als
// "STORNIERT" im Admin-Bereich wie ein gescheiterter Verkauf auszusehen.
// Ohne Zahlung gibt es keine Nachricht darueber: eine Mail zu einem
// Vorgang, den man selbst abgebrochen hat, verwirrt mehr als sie hilft.
await failOrder(session, "delete");
}
return NextResponse.json({ received: true });
@@ -313,19 +321,22 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
}
}
async function failOrder(session: Stripe.Checkout.Session): Promise<void> {
async function failOrder(session: Stripe.Checkout.Session, mode: ReleaseMode): Promise<void> {
const orderId = session.metadata?.orderId;
if (!orderId) return;
// Stornieren, Bestand zurueckbuchen und Zahlung als fehlgeschlagen
// vermerken liegt in lib/order-release.ts, weil derselbe Vorgang auch beim
// Abbruch durch die Kundschaft (checkout/cancel) und im Aufraeumlauf
// (scripts/release-stale-orders.ts) gebraucht wird. Kommt der Webhook
// nach einem dieser beiden an, liefert der Helfer null — die Bestellung ist
// dann nicht mehr "NEU" und der Bestand laengst zurueckgebucht.
const result = await releaseUnpaidOrder(orderId, session.amount_total ?? 0);
// Bestand zurueckbuchen und die Bestellung aufloesen liegt in
// lib/order-release.ts, weil derselbe Vorgang auch beim Abbruch durch die
// Kundschaft (checkout/cancel) und im Aufraeumlauf
// (scripts/release-stale-orders.ts) gebraucht wird. Kommt der Webhook nach
// einem dieser beiden an, liefert der Helfer null — die Bestellung ist dann
// nicht mehr "NEU" und der Bestand laengst zurueckgebucht.
const result = await releaseUnpaidOrder(orderId, mode, session.amount_total ?? 0);
if (!result) return;
if (result) {
// Nur beim tatsaechlich fehlgeschlagenen Einzug: dort wartet jemand auf
// Ware und muss erfahren, dass daraus nichts wird.
if (mode === "cancel") {
const { subject, html, text } = await paymentFailedEmail({
locale: isLocale(result.locale) ? result.locale : defaultLocale,
orderNumber: result.orderNumber,