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 { sendMail } from "@/lib/mail";
|
||||||
import { shipmentEmail, pickupReadyEmail } from "@/lib/email-templates";
|
import { shipmentEmail, pickupReadyEmail } from "@/lib/email-templates";
|
||||||
import { getCarrierTrackingUrl } from "@/lib/shipment-tracking";
|
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 { checkOrderDeletable, needsRestockOnDelete } from "@/lib/order-delete";
|
||||||
import { getStripeClient } from "@/lib/stripe";
|
import { getStripeClient } from "@/lib/stripe";
|
||||||
import { isLocale, defaultLocale } from "@/i18n/config";
|
import { isLocale, defaultLocale } from "@/i18n/config";
|
||||||
@@ -144,6 +144,13 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
|||||||
});
|
});
|
||||||
if (!previous) return null;
|
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.
|
// Nur wahr, wenn DIESER Aufruf die Bestellung tatsaechlich storniert hat.
|
||||||
// Steuert weiter unten Erstattung und Schliessen der Zahlungssitzung —
|
// Steuert weiter unten Erstattung und Schliessen der Zahlungssitzung —
|
||||||
// beides darf bei einem zweiten Klick auf eine bereits stornierte
|
// beides darf bei einem zweiten Klick auf eine bereits stornierte
|
||||||
@@ -170,7 +177,15 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} 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;
|
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) {
|
if (!result) {
|
||||||
return fail(detailPath, "Die Bestellung wurde nicht gefunden — möglicherweise wurde sie zwischenzeitlich gelöscht.");
|
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.
|
// undefined = keine E-Mail faellig; false = faellig, aber nicht versendet.
|
||||||
let mailSent: boolean | undefined;
|
let mailSent: boolean | undefined;
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-ink/65 mt-1">{formatDate(order.createdAt)}</p>
|
<p className="text-sm text-ink/65 mt-1">{formatDate(order.createdAt)}</p>
|
||||||
</div>
|
</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">
|
<form action={updateOrderStatusAction} className="flex items-center gap-2">
|
||||||
<input type="hidden" name="orderId" value={order.id} />
|
<input type="hidden" name="orderId" value={order.id} />
|
||||||
<select
|
<select
|
||||||
@@ -74,6 +76,7 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
|||||||
{t.adminOrderDetailPage.saveStatus}
|
{t.adminOrderDetailPage.saveStatus}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Wo steht die Bestellung? Dieselbe Waben-Zeitleiste wie im Kundenkonto. */}
|
{/* 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 whitespace-nowrap tabular-nums">{formatPrice(order.totalCents, order.currency)}</td>
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
<OrderStatusBadge status={order.status} label={statusLabels[order.status] ?? order.status} />
|
<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">
|
<form action={updateOrderStatusAction} className="mt-2 flex items-center gap-2">
|
||||||
<input type="hidden" name="orderId" value={order.id} />
|
<input type="hidden" name="orderId" value={order.id} />
|
||||||
<select
|
<select
|
||||||
@@ -160,6 +162,7 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
|
|||||||
{t.adminOrdersPage.save}
|
{t.adminOrdersPage.save}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
{order.fulfillmentMethod === "VERSAND" ? t.adminOrdersPage.shipping : t.adminOrdersPage.pickup}
|
{order.fulfillmentMethod === "VERSAND" ? t.adminOrdersPage.shipping : t.adminOrdersPage.pickup}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, test, expect } from "vitest";
|
import { describe, test, expect } from "vitest";
|
||||||
import { needsRestockOnCancel, planInventoryEdit } from "./inventory";
|
import { isOrderStatusChangeAllowed, needsRestockOnCancel, planInventoryEdit } from "./inventory";
|
||||||
|
|
||||||
describe("planInventoryEdit", () => {
|
describe("planInventoryEdit", () => {
|
||||||
test("laesst den Bestand unberuehrt, wenn das Feld nicht geaendert wurde", () => {
|
test("laesst den Bestand unberuehrt, wenn das Feld nicht geaendert wurde", () => {
|
||||||
@@ -43,3 +43,21 @@ describe("needsRestockOnCancel", () => {
|
|||||||
expect(needsRestockOnCancel("ERSTATTET")).toBe(false);
|
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
@@ -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?
|
* Muss beim Stornieren einer Bestellung die Ware zurueckgebucht werden?
|
||||||
@@ -42,6 +42,18 @@ export function needsRestockOnCancel(returnStatus: ReturnStatus | null): boolean
|
|||||||
return returnStatus !== "ERSTATTET";
|
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
|
* Bucht reservierten/verkauften Bestand zurueck — genutzt beim Stornieren
|
||||||
* einer Bestellung (admin/actions.ts, api/checkout/webhook/route.ts) und bei
|
* einer Bestellung (admin/actions.ts, api/checkout/webhook/route.ts) und bei
|
||||||
|
|||||||
Reference in new issue
Block a user