diff --git a/README.md b/README.md index 91e80d3..1795793 100644 --- a/README.md +++ b/README.md @@ -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