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:
1 parent
de82ad0233
commit
dc1523aafd
5 files changed
+62
-5
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user