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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user