fix: Bestand bei Storno nach erstatteter Retoure nicht doppelt zurueckbuchen

Wurde eine Retoure erstattet und die Bestellung danach auf "STORNIERT"
gesetzt, buchten beide Schritte die Ware zurueck. Bei einer Testbestellung
stieg der Bestand so von 105 auf 106 Glaeser.

- Stornieren bucht nur noch zurueck, wenn keine Retoure erstattet ist
  (needsRestockOnCancel in lib/inventory.ts)
- Retoure einer bereits stornierten Bestellung laesst sich nicht mehr
  erstatten, auch nicht im Wettlauf (Bedingung im atomaren Claim)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-26 03:12:37 +02:00
1 parent fdb6456207
commit 5848eb6ad5
4 files changed
+47 -4

No files matched your search

+5 -2
View File
@@ -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 { restockOrderItems } from "@/lib/inventory"; import { 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";
@@ -139,6 +139,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
stripeCheckoutSessionId: true, stripeCheckoutSessionId: true,
payment: { select: { status: true, stripePaymentIntentId: true } }, payment: { select: { status: true, stripePaymentIntentId: true } },
items: { select: { productId: true, quantity: true } }, items: { select: { productId: true, quantity: true } },
returnRequest: { select: { status: true } },
}, },
}); });
if (!previous) return null; if (!previous) return null;
@@ -164,7 +165,9 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
}); });
if (claim.count === 1) { if (claim.count === 1) {
cancelledNow = true; cancelledNow = true;
await restockOrderItems(tx, previous.items); if (needsRestockOnCancel(previous.returnRequest?.status ?? null)) {
await restockOrderItems(tx, previous.items);
}
} }
} else { } else {
await tx.order.update({ where: { id: orderId }, data: { status } }); await tx.order.update({ where: { id: orderId }, data: { status } });
+9 -1
View File
@@ -87,8 +87,16 @@ export async function refundReturnRequestAction(formData: FormData): Promise<voi
// Stripe-Erstattungen ausloesen. Schlaegt der Stripe-Aufruf unten fehl, // Stripe-Erstattungen ausloesen. Schlaegt der Stripe-Aufruf unten fehl,
// wird der Status wieder zurueckgesetzt, damit die Erstattung erneut // wird der Status wieder zurueckgesetzt, damit die Erstattung erneut
// versucht werden kann. // versucht werden kann.
//
// Eine stornierte Bestellung ist ausgeschlossen: updateOrderStatusAction
// hat beim Stornieren die Ware schon zurueckgebucht und die Zahlung
// erstattet — hier noch einmal gutzuschreiben erhoehte den Bestand um
// Glaeser, die es nicht gibt (Gegenstueck zu needsRestockOnCancel).
if (returnRequest.order.status === "STORNIERT") {
return fail("/admin/retouren", "Die Bestellung wurde bereits storniert — Ware und Zahlung sind schon zurückgebucht.");
}
const claimed = await prisma.returnRequest.updateMany({ const claimed = await prisma.returnRequest.updateMany({
where: { id, status: "ANGENOMMEN" }, where: { id, status: "ANGENOMMEN", order: { status: { not: "STORNIERT" } } },
data: { status: "ERSTATTET" }, data: { status: "ERSTATTET" },
}); });
if (claimed.count === 0) return fail("/admin/retouren", "Diese Retoure wird bereits erstattet."); if (claimed.count === 0) return fail("/admin/retouren", "Diese Retoure wird bereits erstattet.");
+18
View File
@@ -0,0 +1,18 @@
import { describe, test, expect } from "vitest";
import { needsRestockOnCancel } from "./inventory";
describe("needsRestockOnCancel", () => {
test("bucht zurueck, wenn es keine Retoure gibt", () => {
expect(needsRestockOnCancel(null)).toBe(true);
});
test("bucht zurueck, solange die Retoure noch nicht erstattet ist", () => {
expect(needsRestockOnCancel("ANGEFRAGT")).toBe(true);
expect(needsRestockOnCancel("ANGENOMMEN")).toBe(true);
expect(needsRestockOnCancel("ABGELEHNT")).toBe(true);
});
test("bucht nicht noch einmal zurueck, wenn die Retoure die Ware schon zurueckgebucht hat", () => {
expect(needsRestockOnCancel("ERSTATTET")).toBe(false);
});
});
+15 -1
View File
@@ -1,4 +1,18 @@
import type { Prisma } from "@/generated/prisma/client"; import type { Prisma, ReturnStatus } from "@/generated/prisma/client";
/**
* Muss beim Stornieren einer Bestellung die Ware zurueckgebucht werden?
*
* Nein, wenn eine Retoure zu dieser Bestellung bereits erstattet wurde:
* refundReturnRequestAction (admin/retouren/actions.ts) hat die Ware dann
* schon zurueckgebucht. Ein zweites Gutschreiben wuerde den Bestand um Glaeser
* erhoehen, die es nicht gibt — genau das ist bei einer Testbestellung
* passiert (Retoure erstattet, danach "STORNIERT" gesetzt: +1 Glas zu viel).
* Gegenstueck zu needsRestockOnDelete in order-delete.ts.
*/
export function needsRestockOnCancel(returnStatus: ReturnStatus | null): boolean {
return returnStatus !== "ERSTATTET";
}
/** /**
* Bucht reservierten/verkauften Bestand zurueck — genutzt beim Stornieren * Bucht reservierten/verkauften Bestand zurueck — genutzt beim Stornieren