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

+60 -27
View File
@@ -9,43 +9,59 @@ export interface ReleasedOrder {
}
/**
* Gibt eine unbezahlte Bestellung wieder frei: Status auf "STORNIERT", der
* beim Checkout reservierte Lagerbestand zurueck ins Regal, Zahlung als
* "FAILED" vermerkt.
* Was mit einer unbezahlten Bestellung geschehen soll.
*
* "delete" — die Bestellung wird spurlos entfernt. Richtig fuer alles, was nie
* bezahlt und nie bestaetigt wurde: den Abbruch bei Stripe und die abgelaufene
* Sitzung. Eine stornierte Bestellung stehen zu lassen stiftet nur Verwirrung
* — im Admin-Bereich saehe jeder weggeklickte Warenkorb aus wie ein echter,
* gescheiterter Verkauf. Aufbewahrungspflichten entstehen dadurch nicht: es
* gab kein Geschaeft, keine Zahlung und keine Rechnung.
*
* "cancel" — die Bestellung bleibt als "STORNIERT" bestehen. Richtig, wenn
* tatsaechlich eine Zahlung versucht wurde und fehlschlug (SEPA-Lastschrift,
* teils Klarna: die Kundschaft hat den Checkout abgeschlossen und rechnet mit
* einer Lieferung). Hier gibt es etwas nachzuvollziehen, und die Kundschaft
* bekommt eine Nachricht dazu.
*/
export type ReleaseMode = "delete" | "cancel";
/**
* Gibt den beim Checkout reservierten Lagerbestand wieder frei.
*
* Eine Bestellung entsteht BEVOR die Stripe-Sitzung geoeffnet wird, und sie
* reserviert dabei sofort Bestand — sonst koennte zwischen Kaufentscheidung
* und Zahlung jemand anders dasselbe Glas kaufen. Der Preis dafuer: jeder
* nicht zu Ende gefuehrte Checkout haelt Ware fest, die niemand bezahlt hat.
* Diese Funktion ist die Gegenbuchung dazu und wird von allen drei Wegen
* benutzt, auf denen das auffallen kann: dem Abbruch durch die Kundschaft,
* dem Ablauf der Stripe-Sitzung (Webhook) und dem Aufraeumlauf
* (scripts/release-stale-orders.ts).
* Diese Funktion ist die Gegenbuchung dazu und wird von allen Wegen benutzt,
* auf denen das auffallen kann: dem Abbruch durch die Kundschaft, dem Ablauf
* der Stripe-Sitzung (Webhook), dem fehlgeschlagenen Einzug und dem
* Aufraeumlauf (scripts/release-stale-orders.ts).
*
* Der Statuswechsel ist ein einzelnes bedingtes UPDATE ("nur wenn noch NEU").
* Ein getrenntes Lesen und Schreiben wuerde bei zwei gleichzeitigen Ausloesern
* — etwa Abbruch-Klick und Webhook im selben Moment — beide Seiten noch "NEU"
* lesen lassen, und der Bestand wuerde doppelt gutgeschrieben. Genau deshalb
* gibt diese Funktion beim zweiten Aufruf null zurueck: nicht als Fehler,
* sondern als Aussage "hat schon jemand anders erledigt".
* Der Zugriff ist in beiden Betriebsarten ein einzelnes bedingtes Schreiben
* ("nur wenn noch NEU"). Ein getrenntes Lesen und Schreiben wuerde bei zwei
* gleichzeitigen Ausloesern — etwa Abbruch-Klick und Webhook im selben Moment
* — beide Seiten noch "NEU" lesen lassen, und der Bestand wuerde doppelt
* gutgeschrieben. Genau deshalb gibt diese Funktion beim zweiten Aufruf null
* zurueck: nicht als Fehler, sondern als Aussage "hat schon jemand anders
* erledigt".
*
* @returns die freigegebene Bestellung, oder null wenn sie nicht (mehr) im
* Status "NEU" war und deshalb nichts zu tun ist.
*/
export async function releaseUnpaidOrder(
orderId: string,
paidAmountCents: number
mode: ReleaseMode,
paidAmountCents = 0
): Promise<ReleasedOrder | null> {
return prisma.$transaction(async (tx) => {
const claim = await tx.order.updateMany({
where: { id: orderId, status: "NEU" },
data: { status: "STORNIERT" },
});
if (claim.count === 0) return null;
const previous = await tx.order.findUniqueOrThrow({
// Vor dem Schreiben lesen, nicht danach: beim Loeschen verschwinden die
// Positionen mit der Bestellung (onDelete: Cascade), und ohne sie liesse
// sich der Bestand nicht mehr zurueckbuchen.
const previous = await tx.order.findUnique({
where: { id: orderId },
select: {
status: true,
orderNumber: true,
email: true,
firstName: true,
@@ -53,15 +69,32 @@ export async function releaseUnpaidOrder(
items: { select: { productId: true, quantity: true } },
},
});
if (!previous || previous.status !== "NEU") return null;
if (mode === "delete") {
// deleteMany mit der Statusbedingung ist hier der atomare Zugriff: es
// loescht entweder genau diese eine noch unbezahlte Bestellung oder
// nichts. Hat inzwischen eine eingehende Zahlung den Status auf
// "BEZAHLT" gesetzt, faengt count === 0 das ab — eine bezahlte
// Bestellung darf dieser Weg unter keinen Umstaenden entfernen.
const deleted = await tx.order.deleteMany({ where: { id: orderId, status: "NEU" } });
if (deleted.count === 0) return null;
} else {
const claim = await tx.order.updateMany({
where: { id: orderId, status: "NEU" },
data: { status: "STORNIERT" },
});
if (claim.count === 0) return null;
await tx.payment.upsert({
where: { orderId },
create: { orderId, status: "FAILED", amountCents: paidAmountCents },
update: { status: "FAILED" },
});
}
await restockOrderItems(tx, previous.items);
await tx.payment.upsert({
where: { orderId },
create: { orderId, status: "FAILED", amountCents: paidAmountCents },
update: { status: "FAILED" },
});
return {
orderNumber: previous.orderNumber,
email: previous.email,