From dc1523aafd021e3e16f04b5814e0931e510264e0 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Sun, 27 Sep 2026 00:45:31 +0200 Subject: [PATCH] fix(orders): cancelled orders cannot be reopened (stock restocked twice) After refund + cancellation the stock rose by 2 instead of 1. Cause: the status form allowed moving an order out of STORNIERT. Cancelling restocks the goods and refunds the payment; reopening did neither in reverse, and the next cancellation passed the "not already cancelled" claim again and restocked a second time (the refund was not repeated because the payment was already REFUNDED, so only the stock showed it). STORNIERT is now a final state: - isOrderStatusChangeAllowed() in lib/inventory.ts next to the other cancel rules, with tests - updateOrderStatusAction rejects the change up front with a clear message and moves non-cancel statuses with an atomic "status <> STORNIERT" update, so a cancellation from a second tab cannot be undone in between - the order list and detail page no longer offer the status select for cancelled orders Existing stock that was restocked twice has to be corrected by hand. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/admin/actions/orders.ts | 27 +++++++++++++++++++++--- src/app/admin/bestellungen/[id]/page.tsx | 3 +++ src/app/admin/bestellungen/page.tsx | 3 +++ src/lib/inventory.test.ts | 20 +++++++++++++++++- src/lib/inventory.ts | 14 +++++++++++- 5 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/app/admin/actions/orders.ts b/src/app/admin/actions/orders.ts index 5c66dff..2d84210 100644 --- a/src/app/admin/actions/orders.ts +++ b/src/app/admin/actions/orders.ts @@ -6,7 +6,7 @@ import { logAdminAction } from "@/lib/audit-log"; import { sendMail } from "@/lib/mail"; import { shipmentEmail, pickupReadyEmail } from "@/lib/email-templates"; import { getCarrierTrackingUrl } from "@/lib/shipment-tracking"; -import { needsRestockOnCancel, restockOrderItems } from "@/lib/inventory"; +import { isOrderStatusChangeAllowed, needsRestockOnCancel, restockOrderItems } from "@/lib/inventory"; import { checkOrderDeletable, needsRestockOnDelete } from "@/lib/order-delete"; import { getStripeClient } from "@/lib/stripe"; import { isLocale, defaultLocale } from "@/i18n/config"; @@ -144,6 +144,13 @@ export async function updateOrderStatusAction(formData: FormData): Promise }); if (!previous) return null; + // Stornierte Bestellungen bleiben storniert (siehe + // isOrderStatusChangeAllowed) — sonst bucht ein erneutes Stornieren die + // Ware ein zweites Mal zurueck. + if (!isOrderStatusChangeAllowed(previous.status, status)) { + return { ...previous, shipment: null, cancelledNow: false, blocked: true }; + } + // Nur wahr, wenn DIESER Aufruf die Bestellung tatsaechlich storniert hat. // Steuert weiter unten Erstattung und Schliessen der Zahlungssitzung — // beides darf bei einem zweiten Klick auf eine bereits stornierte @@ -170,7 +177,15 @@ export async function updateOrderStatusAction(formData: FormData): Promise } } } else { - await tx.order.update({ where: { id: orderId }, data: { status } }); + // Atomar gegen den aktuellen Zeilenstand: hat ein zweiter Tab die + // Bestellung zwischen Lesen und Schreiben storniert, bleibt sie das. + const moved = await tx.order.updateMany({ + where: { id: orderId, status: { not: "STORNIERT" } }, + data: { status }, + }); + if (moved.count === 0) { + return { ...previous, shipment: null, cancelledNow: false, blocked: true }; + } } let shipment: { carrier: string | null; trackingNumber: string | null } | null = null; @@ -197,12 +212,18 @@ export async function updateOrderStatusAction(formData: FormData): Promise }); } - return { ...previous, shipment, cancelledNow }; + return { ...previous, shipment, cancelledNow, blocked: false }; }); if (!result) { return fail(detailPath, "Die Bestellung wurde nicht gefunden — möglicherweise wurde sie zwischenzeitlich gelöscht."); } + if (result.blocked) { + return fail( + detailPath, + "Diese Bestellung ist storniert: die Ware ist zurückgebucht und die Zahlung erstattet. Sie lässt sich nicht wieder öffnen — soll die Ware doch raus, bitte eine neue Bestellung anlegen." + ); + } // undefined = keine E-Mail faellig; false = faellig, aber nicht versendet. let mailSent: boolean | undefined; diff --git a/src/app/admin/bestellungen/[id]/page.tsx b/src/app/admin/bestellungen/[id]/page.tsx index 3e4e791..2c6785d 100644 --- a/src/app/admin/bestellungen/[id]/page.tsx +++ b/src/app/admin/bestellungen/[id]/page.tsx @@ -57,6 +57,8 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best

{formatDate(order.createdAt)}

+ {/* Storniert ist ein Endzustand (isOrderStatusChangeAllowed) — keine Auswahl anbieten, die der Server ablehnt. */} + {order.status !== "STORNIERT" ? (