import { prisma } from "@/lib/prisma"; import { restockOrderItems } from "@/lib/inventory"; export interface ReleasedOrder { orderNumber: string; email: string; firstName: string; locale: string; } /** * 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 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 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, mode: ReleaseMode, paidAmountCents = 0 ): Promise { return prisma.$transaction(async (tx) => { // 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, locale: true, 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); return { orderNumber: previous.orderNumber, email: previous.email, firstName: previous.firstName, locale: previous.locale, }; }); }