From f144ad25f261380e695881c7164e44e34bc380b2 Mon Sep 17 00:00:00 2001 From: Marco Date: Wed, 22 Jul 2026 10:35:54 +0000 Subject: [PATCH] Document the invoice redesign, correction-invoice downloads, bundles, per-product tax rates, and return reasons Keeps the README in sync with this round's shipped work. --- README.md | 161 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 129 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index a9649ae..7316dbe 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ explains what does have access instead. | Collection (slug) | Used for | Key fields | |---|---|---| -| `products` | `/shop` grid, homepage spotlight, cart, checkout | `name`, `slug` (cart item id — **not** Payload's numeric id, so existing localStorage carts survive catalog changes), `description`, `price`, `compareAtPrice` (optional strikethrough), `image`, `detailHref`, `sortOrder`, `active` (hides a product from the shop grid/spotlight/related-products only — cart/checkout/its own detail page still resolve it regardless, see Discount codes section below for the same opt-in-filtering principle), `spotlight` + `spotlightEyebrow`/`spotlightHeadline`/`spotlightText`/`spotlightImage` (homepage "Neu im Shop" section — falls back to `image` if no dedicated spotlight image is set; forced onto the sole active product when exactly 1 exists, see `getSpotlightProduct()`) | +| `products` | `/shop` grid, homepage spotlight, cart, checkout | `name`, `slug` (cart item id — **not** Payload's numeric id, so existing localStorage carts survive catalog changes), `description`, `price`, `compareAtPrice` (optional strikethrough), `image`, `detailHref`, `sortOrder`, `active` (hides a product from the shop grid/spotlight/related-products only — cart/checkout/its own detail page still resolve it regardless, see Discount codes section below for the same opt-in-filtering principle), `spotlight` + `spotlightEyebrow`/`spotlightHeadline`/`spotlightText`/`spotlightImage` (homepage "Neu im Shop" section — falls back to `image` if no dedicated spotlight image is set; forced onto the sole active product when exactly 1 exists, see `getSpotlightProduct()`), `taxRatePercent` (optional per-product VAT override, see "Product bundles & per-product tax rates" below), `bundleItems` (optional — makes this product a bundle) | | `discount-codes` | Cart discount input (`/cart`, display-only on `/checkout`) | `code`, `type` (`percent`/`fixed`), `value`, `validFrom`/`validUntil`, `minOrderValue`, `maxRedemptions`, `redemptionCount` (server-incremented only), `active`. **Not public-read** — see Discount codes section below | | `posts` | `/blog`, `/blog/[slug]` | `title`, `slug`, `category` (relation to `categories`), `excerpt`, `thumbnail`, `content` (richText), `readTime` (auto-calculated on save from word count), `featured` (shown as the `/blog` hero post; most-recently-published wins if several are marked), `publishedAt`, `quoteLabel` (label + icon + underline shown next to every blockquote in `content`, default `"Merke dir:"` — leave empty to hide that framing, the blockquote text itself still renders), `relatedProduct` (optional relation to `products`, powers the "Passend dazu" card at the end of the post — leave empty to hide that card, or empty if the linked product has no `detailHref`) | | `categories` | Blog post categorization | `name`, `slug` (unique per tenant, not globally) | @@ -110,7 +110,7 @@ explains what does have access instead. | `werkzeuge-cards` | Homepage "Meine Werkzeuge" 3-card grid | `title`, `description`, `icon`, `ctaLabel`, `ctaHref`, `sortOrder` | | `testimonials` | Customer testimonial grids on `/todo-cards`, `/newsletter`, `/challenge` | `quote`, `name`, `role`, `avatar`, `page` (`todo-cards`/`newsletter`/`challenge` — which page's grid this appears in), `sortOrder`. The single-quote "photo band" testimonials on `/not-found` and `/bestellbestaetigung` are a different shape (no avatar/role) and stay hardcoded, not part of this collection. | | `media` | Shared upload collection backing every `image`/`icon`/`thumbnail`/`attachment` field above | `alt` (required for images), `title` (optional display name for download links) | -| `orders` | Persisted checkout orders, `/konto/bestellungen*` | `orderNumber`, `invoiceNumber`/`invoiceIssuedAt`, `correctionInvoiceNumber`/`correctionInvoiceIssuedAt` (see "Invoice PDFs" below), `status` (`received`/`processing`/`shipped`/`delivered`/`cancelled`/`return_requested`/`returned` — the first 4 maintained by hand in the admin, no carrier API; the rest see "Order cancellation & returns"), full address/items/totals snapshot at order time. **Not public-read** — created only via `ORDER_SERVICE_SECRET`, read/updated by admin or the order's own customer | +| `orders` | Persisted checkout orders, `/konto/bestellungen*` | `orderNumber`, `invoiceNumber`/`invoiceIssuedAt`, `correctionInvoiceNumber`/`correctionInvoiceIssuedAt` (see "Invoice PDFs" below), `status` (`received`/`processing`/`shipped`/`delivered`/`cancelled`/`return_requested`/`returned` — the first 4 maintained by hand in the admin, no carrier API; the rest see "Order cancellation & returns"), `returnReason` (captured from the customer on a return request), full address/items (each with a snapshotted `taxRatePercent`/`bundleContents`)/totals at order time. **Not public-read** — created only via `ORDER_SERVICE_SECRET`, read/updated by admin or the order's own customer | | `customers` | Storefront accounts — register/login/order-history, a second `auth: true` collection separate from the Payload admin's own `users` login | `customerNumber`, `firstName`/`lastName`/`email`, one default address, `cart` (server-side mirror), `emailVerified` (non-blocking). **Not public-read** — see "Orders & customer accounts" below | | `number-ranges` | Admin-configurable prefix + running counter for customer/order/invoice numbers — one row per tenant | `customerPrefix`/`customerNext`/`customerPadding`, `orderPrefix`/`orderNext`/`orderPadding`, `invoicePrefix`/`invoiceNext`/`invoicePadding`. **Admin-only**, no frontend read at all — internal to the two `beforeChange` hooks that assign these numbers | | `email-templates` | Editable subject/heading/body/footer for all 6 transactional emails this shop sends (see "Email templates & Live Preview" and "Status-change emails" below) | `type` (`order-confirmation`/`password-reset`/`order-shipped`/`order-cancelled`/`order-return-requested`/`order-returned`), `subject`, `heading`, `bodyText`, `footerText`. Public-read, has a Live Preview button | @@ -129,15 +129,18 @@ in the edit view (though not yet the list view's column — see the infra README's Payload CMS section for why that one's a harder fix). **What an admin can actually configure without a code deploy, at a -glance:** product/shipping/payment catalog data and `active` toggles, +glance:** product/shipping/payment catalog data and `active` toggles +(incl. per-product tax-rate overrides and defining a product as a bundle), discount codes, all page content (blog/legal/testimonials/trust badges), delivery-time disclosure (`shipping-settings`), order/customer/invoice numbering schemes (`number-ranges`), all 6 email wordings -(`email-templates`, with Live Preview), and invoice seller data + VAT rate -(`invoice-settings`). What still requires a code change: adding a new -*field* to any collection (needs a migration), payment processing itself -(not built), and anything structural in `orders`/`customers` beyond -`status` and the profile fields already exposed on `/konto/profil`. +(`email-templates`, with Live Preview), and invoice seller data, +bank details, and VAT rate (`invoice-settings` — also what every email's +footer company line is sourced from). What still requires a code change: +adding a new *field* to any collection (needs a migration), payment +processing itself (not built), and anything structural in +`orders`/`customers` beyond `status`/`returnReason` and the profile fields +already exposed on `/konto/profil`. Adding a *new field* to any collection above requires editing the collection file in `docker/payload/src/collections/` and a migration, @@ -263,6 +266,30 @@ check against Payload's public API, unlike most content on this site. labelled "zahlungspflichtig" but nothing actually captures a payment yet. See `project_backend_checkout_plan` in the assistant's own memory. +### Product bundles & per-product tax rates + +Both resolved server-side in `/api/checkout/route.ts`, at the same point +prices are already being re-derived from live Payload data (never trusted +from the client): + +- **Tax rate**: `product.taxRatePercent ?? invoiceSettings.taxRatePercent` + — a product's own override if set, otherwise the tenant-wide default + from `invoice-settings` (fetched alongside the product catalog, + `Promise.all([fetchProductsBySlug(), getInvoiceSettings()])`). Snapshotted + onto `orders.items[].taxRatePercent` at order creation — see the Payload + README's "Per-product tax rates" section for why this has to be a + snapshot, not a live lookup. +- **Bundles**: `describeBundleContents()` resolves a product's + `bundleItems` (Payload relationship, populated via `fetchProductsBySlug()`'s + `depth: 2` fetch — one level deeper than the `depth: 1` `image` alone + needs, since `bundleItems.product` is a relationship nested inside an + array field) into a plain string like `"2× ToDo-Karten, 1× Wochenplaner"`, + snapshotted onto `orders.items[].bundleContents`. A bundle is otherwise + just a regular product everywhere else in this app — same cart/checkout/ + pricing code path, no special-casing needed, since it's just a product + with an extra field (see the Payload README's "Product bundles" section + for why it's modeled that way instead of a separate collection). + ## Invoice PDFs Generated **synchronously at checkout** and attached to the order @@ -278,39 +305,91 @@ inbox, not only in `/konto/bestellungen`. failure there would be one more way to silently lose the attachment for no real design benefit; brand color/spacing still carries the visual identity via `StyleSheet`. -- **`app/lib/invoiceData.ts`** — `generateInvoicePdf()`, the single place - both callers below go through: fetches `invoice-settings` (`getInvoiceSettings()` - in `app/lib/payload.ts`, service-secret authenticated — see below) for - seller data, then renders. Net/tax/gross are derived from each order's - already-stored gross line prices and the seller's `taxRatePercent` — not - a second, independently-tracked figure. -- **Called from two places, same render function:** `app/lib/orderEmail.ts` - (checkout attachment — a PDF-generation failure here does **not** sink - the confirmation email itself, it just sends without the attachment and - alerts admin) and `app/api/account/orders/[orderNumber]/invoice/route.ts` - (GET, customer's own order only, "Rechnung herunterladen" on +- **Layout**: a brand-tinted header band (wordmark + "RECHNUNG" label, + not a plain title line), seller/buyer addresses, invoice + number/date/order-reference/USt-IdNr. shown as small bordered "meta + boxes" rather than a plain text row, a rounded/bordered item table with + alternating row shading, and a shaded summary card for the totals — + deliberately closer to the site's own card-based UI language than a + generic invoice template. +- **"Bereits beglichen" badge**: shown next to the meta boxes whenever + `order.paymentMethodTitle` is anything other than `"Überweisung"` (bank + transfer) — Kreditkarte and PayPal both settle at checkout, so the + invoice says so explicitly (`isPaidImmediately()` in `invoicePdf.tsx` — + "Überweisung" is the one method named explicitly as the exception, + rather than hardcoding a list of "immediate" titles that would need + updating every time a new payment method is added in Payload). +- **Bank details**: `invoice-settings.bankDetails`, when set, prints in + the footer as "Bankverbindung (für Überweisung): …" — for the case a + customer paid (or still needs to pay) by bank transfer and needs the + account details to do so. Currently seeded with a placeholder IBAN/BIC, + same caveat as the rest of `invoice-settings`' seller data below. +- **Per-tax-rate summary**: line items are grouped by their own + snapshotted `taxRatePercent` (see the Payload README's "Per-product tax + rates" section) and the summary prints one "Netto (X%)" / "zzgl. X% + MwSt." pair per distinct rate actually present in that order — a plain + single pair in the common case (one rate for the whole order), a real + multi-rate breakdown the moment a product with a different rate is + involved. The order-level discount/shipping are distributed + proportionally across each rate group before computing net/tax, so the + grouped totals still reconcile exactly to `order.total`. +- **Bundle contents**: an item row for a bundle product also shows the + small muted `bundleContents` sub-line snapshotted at order time (see + the Payload README's "Product bundles" section). +- **`app/lib/invoiceData.ts`** — `generateInvoicePdf(order, seller)` / + `generateCorrectionInvoicePdf(kind, order, seller)`, the render + entrypoints every caller below goes through. `seller` (`invoice-settings` + data) is passed in rather than fetched inside these functions, so a + caller that also needs it for something else in the same request (e.g. + `orderEmail.ts`'s email footer, see "Configurable company data" below) + fetches it once via `getSellerForInvoice()`, not twice. +- **Original invoice — called from two places, same render function:** + `app/lib/orderEmail.ts` (checkout attachment — a PDF-generation failure + here does **not** sink the confirmation email itself, it just sends + without the attachment and alerts admin) and + `app/api/account/orders/[orderNumber]/invoice/route.ts` (GET, customer's + own order only, "Rechnung herunterladen" on `/konto/bestellungen/[orderNumber]`) — a re-download always matches what was originally emailed, since `invoiceNumber`/`invoiceIssuedAt` are assigned exactly once, server-side, at order creation (Payload's `orders.ts` `beforeChange` hook — see the Payload README) and never regenerated. +- **Correction invoice (Stornorechnung/Gutschrift) — same "no file + storage" approach.** The *real* document is generated once, Payload-side, + the moment an order reaches `cancelled`/`returned` (see the Payload + README's "How a Stornorechnung/Gutschrift relates to the original + invoice" section for the full legal/mechanical reasoning) and attached + to that status email. `app/lib/correctionInvoicePdf.tsx` is a **frontend + port** of that same renderer (visually identical, ported not shared — + two separate deployments, same relationship as `emailShell.ts`) used + purely so `/konto/bestellungen/[orderNumber]` can offer a "Stornorechnung/ + Gutschrift herunterladen" download button + (`app/api/account/orders/[orderNumber]/correction-invoice/route.ts`) + without storing the PDF as a file anywhere: `correctionInvoiceNumber`/ + `correctionInvoiceIssuedAt` are immutable once set (Payload's + `beforeChange` hook), so re-rendering from the order's own stored data + always reproduces the identical document. Deliberately not persisted to + disk/S3/Media — the underlying data is already durable in Postgres, and + deterministic regeneration needs no cleanup or storage cost, same + reasoning already applied to the original invoice. - **`invoice-settings`** (Payload collection, structured seller data — name/address/`vatId`/`taxRatePercent`/`bankDetails`) is fetched via - `getInvoiceSettings()`, authenticated the same way as order creation - (`x-order-service-secret` header, `ORDER_SERVICE_SECRET`) since it's not - public-read (holds bank details) but does need to be reachable from this - app's own server-side code, not just from inside Payload's admin. - **Currently seeded with placeholder data** ("Björn Wendt", "Musterstraße - 12", USt-IdNr. "DE123456789") mirroring the Impressum's own placeholder - content — real business details need to be entered in the Payload admin - before an invoice generated from this is legally valid. `taxRatePercent` - is deliberately a configurable admin field, not a hardcoded `19` in the + `getInvoiceSettings()`/`getSellerForInvoice()`, authenticated the same + way as order creation (`x-order-service-secret` header, + `ORDER_SERVICE_SECRET`) since it's not public-read (holds bank details) + but does need to be reachable from this app's own server-side code, not + just from inside Payload's admin. **Currently seeded with placeholder + data** ("Björn Wendt", "Musterstraße 12", USt-IdNr. "DE123456789", a + placeholder IBAN/BIC) mirroring the Impressum's own placeholder content + — real business details need to be entered in the Payload admin before + an invoice generated from this is legally valid. `taxRatePercent` is + deliberately a configurable admin field, not a hardcoded `19` in the renderer, per an explicit decision to keep the VAT rate editable without a code change. - §14 UStG line items: seller/buyer address, invoice number + date, order - reference, per-item quantity/price, net subtotal, tax rate + amount, - gross total — all on the PDF, not just the summary the confirmation - email's HTML already shows. + reference, per-item quantity/price, net subtotal per rate, tax rate + + amount per rate, gross total — all on the PDF, not just the summary the + confirmation email's HTML already shows. ## Orders & customer accounts @@ -497,7 +576,7 @@ mechanism as Posts/LegalPages/Testimonials (`useLivePreview()` from Preview edit never affects a live customer email until actually saved. - `npx payload run src/seed-email-templates.ts` (Payload repo) seeds defaults for all 6 rows — deliberately on-brand and a little playful - ("Geschafft!" / "Kein Drama." / "Unterwegs!" / "Storniert." / "Alles + ("Bestellt!" / "Kein Drama." / "Unterwegs!" / "Storniert." / "Alles klar." / "Alles erledigt.", not generic transactional-email boilerplate), matching this site's voice elsewhere (see e.g. the testimonial copy). Two intentional exceptions to that voice: the @@ -519,6 +598,16 @@ mechanism as Posts/LegalPages/Testimonials (`useLivePreview()` from option here). Not shared code with the React page — this is plain inline-styled HTML built for email-client compatibility (nested ``s, no flexbox) — just matched by eye. +- **Footer company line is configurable, not hardcoded.** `emailShell()` + takes a required `companyLine` parameter (" · ") + — `orderEmail.ts` fetches `invoice-settings` once (`getSellerForInvoice()`) + and derives it from there, same admin-editable business data the invoice + PDFs already use, rather than a literal `"einfach produktiv · + admin@mk360.de"` string. The Payload-side sends (password-reset, the 4 + status-change emails, verification) do the same via that repo's + `src/lib/sellerInfo.ts`. Live Preview uses `DEFAULT_COMPANY_LINE` (a + fallback constant) since there's no real order/tenant context there to + fetch against. ### GDPR self-service @@ -546,6 +635,14 @@ 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). + **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