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) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-27 00:45:31 +02:00
1 parent de82ad0233
commit dc1523aafd
5 files changed
+62 -5

No files matched your search

+19 -1
View File
@@ -1,5 +1,5 @@
import { describe, test, expect } from "vitest";
import { needsRestockOnCancel, planInventoryEdit } from "./inventory";
import { isOrderStatusChangeAllowed, needsRestockOnCancel, planInventoryEdit } from "./inventory";
describe("planInventoryEdit", () => {
test("laesst den Bestand unberuehrt, wenn das Feld nicht geaendert wurde", () => {
@@ -43,3 +43,21 @@ describe("needsRestockOnCancel", () => {
expect(needsRestockOnCancel("ERSTATTET")).toBe(false);
});
});
describe("isOrderStatusChangeAllowed", () => {
test("a cancelled order cannot be reopened (else a second cancel restocks again: +2 instead of +1)", () => {
expect(isOrderStatusChangeAllowed("STORNIERT", "BEZAHLT")).toBe(false);
expect(isOrderStatusChangeAllowed("STORNIERT", "NEU")).toBe(false);
expect(isOrderStatusChangeAllowed("STORNIERT", "VERSENDET")).toBe(false);
});
test("re-selecting the cancelled status is a harmless no-op", () => {
expect(isOrderStatusChangeAllowed("STORNIERT", "STORNIERT")).toBe(true);
});
test("active orders can move between statuses and be cancelled", () => {
expect(isOrderStatusChangeAllowed("BEZAHLT", "VERSENDET")).toBe(true);
expect(isOrderStatusChangeAllowed("VERSENDET", "STORNIERT")).toBe(true);
expect(isOrderStatusChangeAllowed("NEU", "BEZAHLT")).toBe(true);
});
});
+13 -1
View File
@@ -1,4 +1,4 @@
import type { Prisma, ReturnStatus } from "@/generated/prisma/client";
import type { OrderStatus, Prisma, ReturnStatus } from "@/generated/prisma/client";
/**
* Muss beim Stornieren einer Bestellung die Ware zurueckgebucht werden?
@@ -42,6 +42,18 @@ export function needsRestockOnCancel(returnStatus: ReturnStatus | null): boolean
return returnStatus !== "ERSTATTET";
}
/**
* "STORNIERT" ist ein Endzustand: beim Stornieren wurde die Ware
* zurueckgebucht und die Zahlung erstattet. Liesse sich eine stornierte
* Bestellung wieder auf einen aktiven Status setzen, stuende sie ohne
* reservierte Ware und ohne Geld da — und ein erneutes Stornieren buchte die
* Ware ein ZWEITES Mal zurueck (so geschehen: +2 statt +1 Glas). Wer die
* Bestellung doch ausliefern will, legt eine neue an.
*/
export function isOrderStatusChangeAllowed(from: OrderStatus, to: OrderStatus): boolean {
return from !== "STORNIERT" || to === "STORNIERT";
}
/**
* Bucht reservierten/verkauften Bestand zurueck — genutzt beim Stornieren
* einer Bestellung (admin/actions.ts, api/checkout/webhook/route.ts) und bei