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:
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,39 +4,35 @@ import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const LABEL = { cancel: "Bestellung stornieren", "request-return": "Rücksendung anfragen" } as const;
|
||||
const CONFIRM = {
|
||||
cancel: "Bestellung wirklich stornieren?",
|
||||
} as const;
|
||||
|
||||
export function OrderActionButton({ orderNumber, action }: { orderNumber: string; action: "cancel" | "request-return" }) {
|
||||
export type ReturnableItem = { product: number; productName: string; quantity: number };
|
||||
|
||||
export function OrderActionButton({
|
||||
orderNumber,
|
||||
action,
|
||||
items,
|
||||
}: {
|
||||
orderNumber: string;
|
||||
action: "cancel" | "request-return";
|
||||
items: ReturnableItem[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [returnReason, setReturnReason] = useState("");
|
||||
// Keyed by product id, string so the input can hold an empty/partial
|
||||
// value while typing — parsed to a number only on submit.
|
||||
const [quantities, setQuantities] = useState<Record<number, string>>({});
|
||||
|
||||
async function handleClick() {
|
||||
let returnReason: string | undefined;
|
||||
if (action === "cancel") {
|
||||
if (!window.confirm(CONFIRM.cancel)) return;
|
||||
} else {
|
||||
// A short reason helps quality/assortment decisions later (see the
|
||||
// Payload README's Orders.ts section) — prompt() rather than a
|
||||
// custom form, same "plain browser dialog" pattern already used for
|
||||
// cancel's confirm() above.
|
||||
const input = window.prompt("Kurz gesagt, warum möchtest du die Bestellung zurücksenden?");
|
||||
if (input === null) return;
|
||||
returnReason = input.trim();
|
||||
if (!returnReason) {
|
||||
setError("Bitte kurz einen Grund angeben.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
async function submit(body: Record<string, unknown>) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/account/orders/${encodeURIComponent(orderNumber)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action, returnReason }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
@@ -51,16 +47,104 @@ export function OrderActionButton({ orderNumber, action }: { orderNumber: string
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
async function handleCancel() {
|
||||
if (!window.confirm("Bestellung wirklich stornieren?")) return;
|
||||
await submit({ action: "cancel" });
|
||||
}
|
||||
|
||||
async function handleReturnSubmit() {
|
||||
const trimmedReason = returnReason.trim();
|
||||
if (!trimmedReason) {
|
||||
setError("Bitte kurz einen Grund angeben.");
|
||||
return;
|
||||
}
|
||||
const returnItems = Object.entries(quantities)
|
||||
.map(([product, value]) => ({ product: Number(product), returnQuantity: Number(value) || 0 }))
|
||||
.filter((line) => line.returnQuantity > 0);
|
||||
if (returnItems.length === 0) {
|
||||
setError("Bitte mindestens einen Artikel mit Menge auswählen.");
|
||||
return;
|
||||
}
|
||||
await submit({ action: "request-return", returnReason: trimmedReason, returnItems });
|
||||
}
|
||||
|
||||
if (action === "cancel") {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={loading}
|
||||
className={`px-5 py-3 rounded-sm border border-border hover:border-brand font-bold text-body-sm text-text-primary transition-colors ${loading ? "opacity-70 pointer-events-none" : ""}`}
|
||||
>
|
||||
{loading ? "…" : LABEL.cancel}
|
||||
</button>
|
||||
{error && <p className="text-label text-red-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// request-return: a short inline form, not window.prompt() — needs a
|
||||
// per-item quantity (partial returns are supported, see the Payload
|
||||
// README's "How a Stornorechnung/Gutschrift relates..." section), which
|
||||
// a single-line browser prompt can't reasonably capture.
|
||||
if (!formOpen) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
className={`px-5 py-3 rounded-sm border border-border hover:border-brand font-bold text-body-sm text-text-primary transition-colors ${loading ? "opacity-70 pointer-events-none" : ""}`}
|
||||
onClick={() => setFormOpen(true)}
|
||||
className="px-5 py-3 rounded-sm border border-border hover:border-brand font-bold text-body-sm text-text-primary transition-colors"
|
||||
>
|
||||
{loading ? "…" : LABEL[action]}
|
||||
{LABEL["request-return"]}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full border border-border rounded-md p-5">
|
||||
<p className="font-semibold text-body-sm text-text-primary">Welche Artikel möchtest du zurücksenden?</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{items.map((item) => (
|
||||
<div key={item.product} className="flex items-center gap-4">
|
||||
<span className="flex-1 text-body-sm text-text-primary">{item.productName}</span>
|
||||
<label className="flex items-center gap-2 text-label text-text-muted">
|
||||
Menge
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={item.quantity}
|
||||
value={quantities[item.product] ?? ""}
|
||||
onChange={(e) => setQuantities((prev) => ({ ...prev, [item.product]: e.target.value }))}
|
||||
placeholder="0"
|
||||
className="w-16 px-2 py-1 border border-border rounded-sm text-body-sm text-text-primary"
|
||||
/>
|
||||
</label>
|
||||
<span className="text-label text-text-muted">/ {item.quantity}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-label text-text-muted">Grund der Rücksendung</span>
|
||||
<textarea
|
||||
value={returnReason}
|
||||
onChange={(e) => setReturnReason(e.target.value)}
|
||||
rows={2}
|
||||
className="px-3 py-2 border border-border rounded-sm text-body-sm text-text-primary"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex gap-3 items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReturnSubmit}
|
||||
disabled={loading}
|
||||
className={`px-5 py-3 rounded-sm bg-brand font-bold text-body-sm text-text-primary transition-colors ${loading ? "opacity-70 pointer-events-none" : ""}`}
|
||||
>
|
||||
{loading ? "…" : "Rücksendung anfragen"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setFormOpen(false)} className="text-body-sm text-text-muted hover:text-brand transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-label text-red-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -73,6 +73,9 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
{item.quantity} × {item.productName}
|
||||
</p>
|
||||
{item.bundleContents && <p className="text-label text-text-muted">{item.bundleContents}</p>}
|
||||
{item.returnQuantity > 0 && (
|
||||
<p className="text-label text-text-muted">davon {item.returnQuantity} zurückgesendet</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-body-sm text-text-primary">{formatPrice(item.quantity * item.unitPrice)}</p>
|
||||
</div>
|
||||
@@ -137,7 +140,13 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
)}
|
||||
</div>
|
||||
|
||||
{action && <OrderActionButton orderNumber={order.orderNumber} action={action} />}
|
||||
{action && (
|
||||
<OrderActionButton
|
||||
orderNumber={order.orderNumber}
|
||||
action={action}
|
||||
items={order.items.map((item) => ({ product: item.product, productName: item.productName, quantity: item.quantity }))}
|
||||
/>
|
||||
)}
|
||||
</Reveal>
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
@@ -80,7 +80,14 @@ function formatPrice(amount: number): string {
|
||||
|
||||
export type CorrectionInvoiceKind = "storno" | "gutschrift";
|
||||
|
||||
export type CorrectionInvoiceItem = { productName: string; quantity: number; unitPrice: number; taxRatePercent: number; bundleContents?: string | null };
|
||||
export type CorrectionInvoiceItem = {
|
||||
productName: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
taxRatePercent: number;
|
||||
bundleContents?: string | null;
|
||||
returnQuantity?: number;
|
||||
};
|
||||
|
||||
export type CorrectionInvoiceOrder = {
|
||||
orderNumber: string;
|
||||
@@ -116,17 +123,31 @@ export type InvoiceSeller = {
|
||||
bankDetails?: string | null;
|
||||
};
|
||||
|
||||
// Stornorechnung: every item at its full ordered quantity (nothing
|
||||
// shipped, undo everything). Gutschrift: only items with a nonzero
|
||||
// returnQuantity, at that returned quantity — see this file's port source
|
||||
// (backend src/lib/correctionInvoicePdf.tsx) for the full policy
|
||||
// reasoning (no shipping refund, no discount reproration on a Gutschrift).
|
||||
function resolveLineItems(kind: CorrectionInvoiceKind, items: CorrectionInvoiceItem[]): { item: CorrectionInvoiceItem; effectiveQuantity: number }[] {
|
||||
if (kind === "storno") return items.map((item) => ({ item, effectiveQuantity: item.quantity }));
|
||||
return items
|
||||
.filter((item) => (item.returnQuantity ?? 0) > 0)
|
||||
.map((item) => ({ item, effectiveQuantity: item.returnQuantity as number }));
|
||||
}
|
||||
|
||||
function groupByTaxRate(
|
||||
kind: CorrectionInvoiceKind,
|
||||
lines: { item: CorrectionInvoiceItem; effectiveQuantity: number }[],
|
||||
order: CorrectionInvoiceOrder,
|
||||
defaultRate: number,
|
||||
): { rate: number; net: number; tax: number; gross: number }[] {
|
||||
const groups = new Map<number, number>();
|
||||
for (const item of order.items) {
|
||||
for (const { item, effectiveQuantity } of lines) {
|
||||
const rate = item.taxRatePercent ?? defaultRate;
|
||||
const lineGross = item.quantity * item.unitPrice;
|
||||
const lineGross = effectiveQuantity * item.unitPrice;
|
||||
groups.set(rate, (groups.get(rate) ?? 0) + lineGross);
|
||||
}
|
||||
const scale = order.subtotal > 0 ? (order.subtotal - order.discountAmount + order.shippingCost) / order.subtotal : 1;
|
||||
const scale = kind === "storno" && order.subtotal > 0 ? (order.subtotal - order.discountAmount + order.shippingCost) / order.subtotal : 1;
|
||||
return Array.from(groups.entries())
|
||||
.map(([rate, lineGross]) => {
|
||||
const gross = lineGross * scale;
|
||||
@@ -138,7 +159,13 @@ function groupByTaxRate(
|
||||
|
||||
function CorrectionInvoiceDocument({ kind, order, seller }: { kind: CorrectionInvoiceKind; order: CorrectionInvoiceOrder; seller: InvoiceSeller }) {
|
||||
const kindLabel = kind === "storno" ? "Stornorechnung" : "Gutschrift";
|
||||
const rateGroups = groupByTaxRate(order, seller.taxRatePercent);
|
||||
const lines = resolveLineItems(kind, order.items);
|
||||
const rateGroups = groupByTaxRate(kind, lines, order, seller.taxRatePercent);
|
||||
const grandTotal = rateGroups.reduce((sum, g) => sum + g.gross, 0);
|
||||
const refNote =
|
||||
kind === "storno"
|
||||
? "vollständige Stornierung des ursprünglichen Rechnungsbetrags (inkl. Versand)."
|
||||
: "Gutschrift für die zurückgesendeten Artikel — ohne Versandkosten, der ursprüngliche Rabatt bleibt unverändert bei den behaltenen Artikeln.";
|
||||
const deliveryLine =
|
||||
order.deliveryMethod === "address" ? order.street : `Packstation ${order.packstationNumber} · Postnummer ${order.postNumber}`;
|
||||
|
||||
@@ -152,8 +179,7 @@ function CorrectionInvoiceDocument({ kind, order, seller }: { kind: CorrectionIn
|
||||
|
||||
<View style={styles.body}>
|
||||
<Text style={styles.refLine}>
|
||||
{kindLabel} zu Rechnung Nr. {order.invoiceNumber} vom {formatDate(order.invoiceIssuedAt)} (Bestellung {order.orderNumber}) — vollständige
|
||||
Stornierung des ursprünglichen Rechnungsbetrags.
|
||||
{kindLabel} zu Rechnung Nr. {order.invoiceNumber} vom {formatDate(order.invoiceIssuedAt)} (Bestellung {order.orderNumber}) — {refNote}
|
||||
</Text>
|
||||
|
||||
<View style={styles.addressRow}>
|
||||
@@ -201,15 +227,15 @@ function CorrectionInvoiceDocument({ kind, order, seller }: { kind: CorrectionIn
|
||||
<Text style={[styles.colPrice, styles.headerCell]}>Einzelpreis</Text>
|
||||
<Text style={[styles.colTotal, styles.headerCell]}>Betrag</Text>
|
||||
</View>
|
||||
{order.items.map((item, i) => (
|
||||
{lines.map(({ item, effectiveQuantity }, i) => (
|
||||
<View style={[styles.tableRow, i % 2 === 1 ? styles.tableRowAlt : {}]} key={i}>
|
||||
<View style={styles.colName}>
|
||||
<Text>{item.productName}</Text>
|
||||
{item.bundleContents ? <Text style={styles.bundleLine}>{item.bundleContents}</Text> : null}
|
||||
</View>
|
||||
<Text style={styles.colQty}>{item.quantity}</Text>
|
||||
<Text style={styles.colQty}>{effectiveQuantity}</Text>
|
||||
<Text style={styles.colPrice}>{formatPrice(item.unitPrice)}</Text>
|
||||
<Text style={styles.colTotal}>-{formatPrice(item.quantity * item.unitPrice)}</Text>
|
||||
<Text style={styles.colTotal}>-{formatPrice(effectiveQuantity * item.unitPrice)}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
@@ -230,7 +256,7 @@ function CorrectionInvoiceDocument({ kind, order, seller }: { kind: CorrectionIn
|
||||
))}
|
||||
<View style={styles.grandTotalRow}>
|
||||
<Text style={styles.grandTotalLabel}>Gesamt</Text>
|
||||
<Text style={styles.grandTotalValue}>-{formatPrice(order.total)}</Text>
|
||||
<Text style={styles.grandTotalValue}>-{formatPrice(grandTotal)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -251,3 +277,7 @@ function CorrectionInvoiceDocument({ kind, order, seller }: { kind: CorrectionIn
|
||||
export async function renderCorrectionInvoicePdf(kind: CorrectionInvoiceKind, order: CorrectionInvoiceOrder, seller: InvoiceSeller): Promise<Buffer> {
|
||||
return renderToBuffer(<CorrectionInvoiceDocument kind={kind} order={order} seller={seller} />);
|
||||
}
|
||||
|
||||
// Exported for unit testing (see app/lib/__tests__/correctionInvoicePdf.test.ts)
|
||||
// — the actual money math, independent of the PDF rendering.
|
||||
export const __testables = { resolveLineItems, groupByTaxRate };
|
||||
|
||||
+35
-12
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user