Support partial returns — per-item quantity, item-only Gutschrift (no shipping refund, no discount reproration)

Customers can now select which items and how many units to return
instead of only the whole order. The Gutschrift reflects only the
returned quantities, excludes shipping (already delivered), and leaves
the original discount untouched — confirmed policy, not an engineering
default. Stornorechnung (pre-shipping cancellation) is unaffected and
stays a full reversal including shipping.
This commit is contained in:
Marco
2026-07-22 11:30:46 +00:00
parent d249614027
commit 91f6fef6ea
5 changed files with 247 additions and 62 deletions
+49 -10
View File
@@ -4,13 +4,15 @@ import {
getCustomerOrderDetail,
requestOrderStatusChange,
customerOrderAction,
type CustomerOrderItem,
} from "../../../../lib/customerAuth";
// The real security boundary is Orders.ts's beforeChange hook in Payload
// (only `status` can change, only via an allowed transition) — the check
// against customerOrderAction() here is just for a friendlier error
// message than a bare 403 when the button's already stale (e.g. two tabs
// open, order shipped in the meantime).
// (only `status`/`returnReason`/items' `returnQuantity` can change, only
// via an allowed transition) — the checks here are just for a friendlier
// error message than a bare 403 when the request is malformed or stale
// (e.g. two tabs open, order shipped in the meantime, a quantity that no
// longer fits).
export async function PATCH(request: Request, { params }: { params: Promise<{ orderNumber: string }> }) {
const session = await getSessionCustomer();
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
@@ -21,10 +23,6 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ or
if (action !== "cancel" && action !== "request-return") {
return NextResponse.json({ ok: false, reason: "Ungültige Aktion." }, { status: 400 });
}
const returnReason = typeof body?.returnReason === "string" ? body.returnReason.trim() : "";
if (action === "request-return" && !returnReason) {
return NextResponse.json({ ok: false, reason: "Bitte kurz angeben, warum du zurücksenden möchtest." }, { status: 400 });
}
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber));
if (!order) return NextResponse.json({ ok: false, reason: "Bestellung nicht gefunden." }, { status: 404 });
@@ -32,6 +30,47 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ or
return NextResponse.json({ ok: false, reason: "Diese Aktion ist für diese Bestellung gerade nicht möglich." }, { status: 400 });
}
const result = await requestOrderStatusChange(session.token, order.id, action, returnReason || undefined);
return NextResponse.json(result, { status: result.ok ? 200 : 400 });
if (action === "cancel") {
const result = await requestOrderStatusChange(session.token, order.id, "cancel");
return NextResponse.json(result, { status: result.ok ? 200 : 400 });
}
// request-return: partial returns supported — the client sends
// { product, returnQuantity } per line it wants to return (0 or
// omitted for lines being kept). Reconstruct the order's FULL items
// array here (see requestOrderStatusChange's own comment on why a
// sparse patch doesn't work), validating each requested quantity
// against what was actually ordered.
const returnReason = typeof body?.returnReason === "string" ? body.returnReason.trim() : "";
if (!returnReason) {
return NextResponse.json({ ok: false, reason: "Bitte kurz angeben, warum du zurücksenden möchtest." }, { status: 400 });
}
const requestedQuantities = new Map<number, number>();
if (Array.isArray(body?.returnItems)) {
for (const line of body.returnItems) {
const product = Number(line?.product);
const returnQuantity = Number(line?.returnQuantity);
if (Number.isFinite(product) && Number.isFinite(returnQuantity) && returnQuantity > 0) {
requestedQuantities.set(product, returnQuantity);
}
}
}
if (requestedQuantities.size === 0) {
return NextResponse.json({ ok: false, reason: "Bitte mindestens einen Artikel mit Menge auswählen." }, { status: 400 });
}
const items: CustomerOrderItem[] = order.items.map((item) => {
const requested = requestedQuantities.get(item.product) ?? 0;
if (requested > item.quantity) {
throw Object.assign(new Error("returnQuantity exceeds ordered quantity"), { status: 400 });
}
return { ...item, returnQuantity: requested };
});
try {
const result = await requestOrderStatusChange(session.token, order.id, "request-return", { returnReason, items });
return NextResponse.json(result, { status: result.ok ? 200 : 400 });
} catch {
return NextResponse.json({ ok: false, reason: "Eine der Mengen übersteigt die bestellte Menge." }, { status: 400 });
}
}