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
+35 -12
View File
@@ -445,17 +445,33 @@ export type CustomerOrderDetail = CustomerOrder & {
discountCode: string | null;
discountAmount: number;
returnReason: string | null;
items: { productName: string; quantity: number; unitPrice: number; taxRatePercent: number; bundleContents: string | null }[];
items: CustomerOrderItem[];
};
export type CustomerOrderItem = {
product: number;
productName: string;
quantity: number;
unitPrice: number;
taxRatePercent: number;
bundleContents: string | null;
returnQuantity: number;
};
// Access control (Orders.ts) already scopes a customer's own JWT to only
// their own orders — the where[customer] filter here is redundant with
// that, kept only so a wrong/foreign orderNumber returns "not found"
// instead of leaking whether that order number exists for someone else.
// depth=0 — every field this type reads is already flat; keeping `product`
// as a plain id (not populated) is what lets requestOrderStatusChange's
// caller round-trip a full, valid items array back on a return request
// (Orders.ts's field-lock hook needs every required item field present,
// not just returnQuantity — see that hook's own comment).
export async function getCustomerOrderDetail(token: string, customerId: number, orderNumber: string): Promise<CustomerOrderDetail | null> {
const params = new URLSearchParams({
"where[orderNumber][equals]": orderNumber,
"where[customer][equals]": String(customerId),
depth: "0",
limit: "1",
});
const res = await fetch(`${PAYLOAD_URL}/api/orders?${params}`, {
@@ -470,23 +486,30 @@ export async function getCustomerOrderDetail(token: string, customerId: number,
}
// Called from app/api/account/orders/[orderNumber]/route.ts. Security
// lives in Orders.ts's beforeChange hook (only `status` can change, and
// only via an allowed transition) — this is just the authenticated call;
// a request the hook rejects comes back as a non-ok response here.
// lives in Orders.ts's beforeChange hook (only `status` can change, plus
// each item's `returnQuantity` alongside a return_requested transition —
// see that hook's own comment) — this is just the authenticated call; a
// request the hook rejects comes back as a non-ok response here.
//
// `items`, when provided, must be the order's FULL current items array
// with only `returnQuantity` adjusted on the returned lines — Payload's
// array field expects every required sub-field present on each row, not
// a sparse "just the changed key" patch (the field-lock hook's own diff
// also expects to see the untouched fields, not their absence). The
// caller (the API route, which already has the order loaded) builds this
// from getCustomerOrderDetail()'s own `items`.
export async function requestOrderStatusChange(
token: string,
orderId: number,
action: "cancel" | "request-return",
returnReason?: string,
extra?: { returnReason?: string; items?: CustomerOrderItem[] },
): Promise<{ ok: true } | { ok: false; reason: string }> {
const status = action === "cancel" ? "cancelled" : "return_requested";
// Orders.ts's beforeChange hook only allows a customer-authenticated
// update to touch `status` (plus `returnReason`, but only together with
// this exact transition — see that hook's own comment) — omitting the
// key entirely for `cancel` rather than sending `returnReason: undefined`
// keeps that request shaped exactly like before this field existed.
const body: { status: string; returnReason?: string } = { status };
if (action === "request-return" && returnReason) body.returnReason = returnReason;
const body: { status: string; returnReason?: string; items?: CustomerOrderItem[] } = { status };
if (action === "request-return") {
if (extra?.returnReason) body.returnReason = extra.returnReason;
if (extra?.items) body.items = extra.items;
}
const res = await fetch(`${PAYLOAD_URL}/api/orders/${orderId}`, {
method: "PATCH",
headers: { Authorization: `JWT ${token}`, "Content-Type": "application/json" },