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
+41 -11
View File
@@ -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
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" },