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

+24 -3
View File
@@ -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<void>
});
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<void>
}
}
} 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<void>
});
}
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;
+3
View File
@@ -57,6 +57,8 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
</div>
<p className="text-sm text-ink/65 mt-1">{formatDate(order.createdAt)}</p>
</div>
{/* Storniert ist ein Endzustand (isOrderStatusChangeAllowed) — keine Auswahl anbieten, die der Server ablehnt. */}
{order.status !== "STORNIERT" ? (
<form action={updateOrderStatusAction} className="flex items-center gap-2">
<input type="hidden" name="orderId" value={order.id} />
<select
@@ -74,6 +76,7 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
{t.adminOrderDetailPage.saveStatus}
</Button>
</form>
) : null}
</div>
{/* Wo steht die Bestellung? Dieselbe Waben-Zeitleiste wie im Kundenkonto. */}
+3
View File
@@ -139,6 +139,8 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
<td className="p-4 whitespace-nowrap tabular-nums">{formatPrice(order.totalCents, order.currency)}</td>
<td className="p-4">
<OrderStatusBadge status={order.status} label={statusLabels[order.status] ?? order.status} />
{/* Storniert ist ein Endzustand (isOrderStatusChangeAllowed) — keine Auswahl anbieten, die der Server ablehnt. */}
{order.status !== "STORNIERT" ? (
<form action={updateOrderStatusAction} className="mt-2 flex items-center gap-2">
<input type="hidden" name="orderId" value={order.id} />
<select
@@ -160,6 +162,7 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
{t.adminOrdersPage.save}
</button>
</form>
) : null}
</td>
<td className="p-4">
{order.fulfillmentMethod === "VERSAND" ? t.adminOrdersPage.shipping : t.adminOrdersPage.pickup}