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.
This commit is contained in:
Marco
2026-07-22 10:35:54 +00:00
parent 179b59d73d
commit f144ad25f2
+129 -32
View File
@@ -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
`<table>`s, no flexbox) — just matched by eye.
- **Footer company line is configurable, not hardcoded.** `emailShell()`
takes a required `companyLine` parameter ("<sellerName> · <sellerEmail>")
`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