Document partial returns, the reconstructed-items PATCH, and the new test suite

This commit is contained in:
Marco
2026-07-22 11:42:44 +00:00
parent 51fee198f4
commit a801d41d79
+63 -18
View File
@@ -678,23 +678,35 @@ transition is still valid (friendlier error than a bare 403 if it's gone
stale — two tabs open, order shipped in the meantime) before calling
`requestOrderStatusChange()`.
Requesting a return also asks *why*, via `window.prompt()` — the same
plain-browser-dialog pattern already used for cancel's `confirm()`, not a
custom form. The reason is required client-side (empty input re-shows an
inline error instead of submitting) and stored on `orders.returnReason`
useful for quality/assortment decisions later, and shown back on the order
detail page. Cancel doesn't ask for one; it's a lower-stakes action
(before shipping, often just a change of mind).
Requesting a return opens an inline form (`OrderActionButton.tsx`), not
just a confirm dialog — **partial returns are supported**: a quantity
input per order line (0 up to that line's ordered quantity) plus a
required reason textarea. At least one line must have a nonzero quantity
to submit. Cancel stays a plain `window.confirm()` — it's a lower-stakes,
whole-order-only action (before shipping, often just a change of mind),
no quantity picker or reason needed there.
The route (`/api/account/orders/[orderNumber]` PATCH) reconstructs the
order's *full* `items` array before sending it to Payload — only the
requested lines' `returnQuantity` differs from what's already stored,
every other field (product/price/tax rate/etc.) is passed through
unchanged. This isn't optional: Payload's array field expects every
required sub-field present on each row, and the field-lock hook (see
below) specifically checks that only `returnQuantity` changed — a sparse
`{ returnQuantity: 2 }`-only patch would fail both.
**The real security boundary is in Payload**, not here: `orders.access.update`
already scoped a customer's JWT to their own order, but with no
field-level restriction — before this stage, a logged-in customer could in
principle PATCH *any* field of their own order (`total`, `items`,
anything), just because nothing in the frontend had ever exercised that
path yet. `Orders.ts`'s `beforeChange` hook now rejects a
customer-authenticated update unless the only changed field is `status`,
via an allowed transition. See the Payload README's own writeup for the
full detail.
path yet. `Orders.ts`'s `beforeChange` hook rejects a customer-authenticated
update unless the change is limited to `status` (plus `returnReason` and
each item's `returnQuantity`, bounded 0..quantity, alongside a
`return_requested` transition), via an allowed transition. See the
Payload README's own writeup for the full detail — including
`orderUpdateValidation.ts`, where this logic now lives as a unit-tested
pure function.
No hard 14-day return-window check in code (no separately tracked delivery
date exists yet) — relies on the existing `/widerruf` legal text plus
@@ -705,13 +717,17 @@ Payload admin.
Reaching `status: 'cancelled'` or `status: 'returned'` also auto-generates
a **Stornorechnung**/**Gutschrift** correction-invoice PDF, attached to
that status's customer email (Payload-side, see the Payload README's
`orders.ts` section — this is not frontend code) — a full reversal of the
original invoice, referencing its (immutable) invoice number, drawn from
the same invoice number range. This is a document only, not a money
movement: an actual refund still has to happen manually, same limitation
as above, since no payment provider exists yet to capture or reverse a
real charge.
that status's customer email (Payload-side, see the Payload README's "How
a Stornorechnung/Gutschrift relates to the original invoice" section —
this is not frontend code). Stornorechnung is always a full reversal
(cancellation is always pre-shipping, whole order, shipping included).
Gutschrift reflects only the returned quantities — full or partial —
**excludes shipping** (already delivered by the time a return is
possible) and **never reprorates the original discount** (confirmed
policy, not a default: the discount stays with whatever's kept). Either
way, this is a document only, not a money movement: an actual refund
still has to happen manually, since no payment provider exists yet to
capture or reverse a real charge.
### Status-change emails
@@ -754,6 +770,35 @@ sees.
monitor in the existing "Content & API" group (`~/dev/README.md`'s
documented `sqlite3`-insert method, Kuma 1.x has no REST API for this).
## Tests
`npm run test:unit` (Vitest, `node` environment, no jsdom/Next.js runtime
needed) — no test infrastructure existed in this repo before; started with
the pure logic most likely to silently produce wrong numbers on a live
order, not attempted exhaustive coverage:
- **`app/lib/__tests__/cartTotals.test.ts`** — subtotal/discount/shipping
math (`computeSubtotal`, `computeCartTotals`), incl. the fixed-discount
clamp and `compareAtPrice`-based savings display being independent of
the discount-code math.
- **`app/lib/__tests__/invoicePdf.test.ts`** — `isPaidImmediately()` and
the original invoice's per-rate `groupByTaxRate()` (both exported via an
`__testables` object specifically for this, same pattern the Payload
backend uses for its own correction-invoice tests).
- **`app/lib/__tests__/bundleContents.test.ts`** — `describeBundleContents()`,
extracted out of `app/api/checkout/route.ts` into its own module
(`app/lib/bundleContents.ts`) specifically so it's importable from a
test — Next.js `route.ts` files only allow HTTP-method (+ a few config)
exports, not arbitrary named ones.
The Gutschrift/Stornorechnung money math itself (`resolveLineItems()`,
`groupByTaxRate()` in `correctionInvoicePdf.tsx`) and the Payload-side
`orders.ts` field-lock security logic are tested in the **Payload
backend's** own `test:unit` instead — see that repo's README's own
"Tests" section — since that's where those functions actually live (this
repo's `correctionInvoicePdf.tsx` is only a port for the download button,
not the source of truth).
## Deployment
- **Dockerfile**: 3-stage build (`deps``builder``runner`) with