Document invoice PDF generation and status-change emails in the README

Keeps the README consistent with the invoice/correction-invoice/
status-email work just shipped — new Invoice PDFs section, extended
Email templates and Order cancellation sections.
This commit is contained in:
Marco
2026-07-22 09:59:27 +00:00
parent 5232b14cdf
commit 6102fef6d1
+119 -25
View File
@@ -12,6 +12,8 @@ specific to this project.
image — see `AGENTS.md` before touching anything version-specific, this
Next.js release differs from older training-data conventions)
- **React 19.2.4**, **Tailwind CSS 4**, **Motion** for animation
- **`@react-pdf/renderer`** for invoice PDF generation (see "Invoice
PDFs" below) — no headless-browser dependency
- No local database — all editable content (and now orders/customer
accounts) lives in the shared Payload CMS at `payload.mk360.de` (see
below). Customer auth is Payload's own (a second, separate `auth: true`
@@ -40,9 +42,11 @@ Preview components should ever point somewhere else). `DISCOUNT_SERVICE_SECRET`
(no safe default — required for discount codes to validate/redeem at all;
must match the value set on the Payload backend). `ORDER_SERVICE_SECRET`
(no safe default — required for `/api/checkout` to persist an order in
Payload at all, and for `/api/account/verify-email` to look up a customer
by their verification token; must match the value set on the Payload
backend — also used there for the same header). `SMTP_USER`/`SMTP_PASSWORD`
Payload at all, for `/api/account/verify-email` to look up a customer
by their verification token, and for `getInvoiceSettings()` to read the
`invoice-settings` collection (seller data for invoice PDFs); must match
the value set on the Payload backend — also used there for the same
header). `SMTP_USER`/`SMTP_PASSWORD`
(no safe default — required for `app/lib/alertAdmin.ts`'s critical-failure
alerts and resend-verification emails; **does not** need to match anything
on the Payload side — this app's SMTP connection is deliberately
@@ -235,11 +239,62 @@ check against Payload's public API, unlike most content on this site.
persisted" alert, since the order itself is safe either way). Content
comes from the **published** `order-confirmation` row in Payload's
`email-templates` collection — see "Email templates & Live Preview"
below for how that's edited/previewed.
below for how that's edited/previewed. As of the invoice PDF feature
(see below), this same send also carries the order's invoice PDF as an
attachment.
- Still not built: real payment processing — the checkout button is
labelled "zahlungspflichtig" but nothing actually captures a payment
yet. See `project_backend_checkout_plan` in the assistant's own memory.
## Invoice PDFs
Generated **synchronously at checkout** and attached to the order
confirmation email — not just an on-demand download — per an explicit
product decision that a customer should always have the invoice in their
inbox, not only in `/konto/bestellungen`.
- **`app/lib/invoicePdf.tsx`** — a `@react-pdf/renderer` `Document`
(`InvoiceDocument`), not HTML-to-PDF or a headless browser (Puppeteer/
Chromium would be a heavier footprint on a VPS already running several
other containers). Built-in Helvetica rather than a registered web font
— this renders inside a fire-and-forget checkout step, and a font-fetch
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
`/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.
- **`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
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.
## Orders & customer accounts
An account is required to buy — there is no guest checkout. Registration
@@ -271,9 +326,10 @@ this check was skipped or raced.
doesn't just trust the caller), `cart` (GET/POST, see below),
`verify-email`, `resend-verification`, `delete`, `export` (see "Email
verification" and "GDPR self-service" below), `forgot-password`,
`reset-password` (see "Password reset" below), and
`reset-password` (see "Password reset" below),
`orders/[orderNumber]` (PATCH — cancel/return-request, see "Order
cancellation & returns" below).
cancellation & returns" below), and `orders/[orderNumber]/invoice`
(GET — invoice PDF download, see "Invoice PDFs" below).
- **`/konto/bestellungen`** lists a customer's own orders (status shown as
a colored `OrderStatusBadge.tsx`, plus an "Abmelden" link —
`LogoutButton.tsx`); **`/konto/bestellungen/[orderNumber]`** shows one
@@ -391,24 +447,27 @@ links to `/konto/passwort-vergessen`.
### Email templates & Live Preview
Both transactional emails (order confirmation, password reset) read their
subject/heading/body/footer wording from Payload's `email-templates`
collection — editable in the admin without a deploy, with a Live Preview
button using the exact same mechanism as Posts/LegalPages/Testimonials
(`useLivePreview()` from `@payloadcms/live-preview-react`, already a
dependency here for `LivePostContent.tsx`).
All 6 transactional emails (order confirmation, password reset, and the 4
status-change types below) read their subject/heading/body/footer wording
from Payload's `email-templates` collection — editable in the admin
without a deploy, with a Live Preview button using the exact same
mechanism as Posts/LegalPages/Testimonials (`useLivePreview()` from
`@payloadcms/live-preview-react`, already a dependency here for
`LivePostContent.tsx`).
- **`app/lib/emailTemplates.ts`** — pure string-building functions
(`renderOrderConfirmationHtml()`, `renderPasswordResetHtml()`), no
server-only or client-only imports. Used **both** server-side for the
real send (`orderEmail.ts`) **and** client-side for the Live Preview
page — same function, same inputs, so a Live Preview edit and the real
sent email are guaranteed to render identically for order-confirmation
(password-reset's actual send uses Payload's own simple inline template
instead — see that repo's README for why — so its Live Preview
approximates rather than pixel-matches). Inline-styled HTML (`<table>`
layout, `style` attributes, no Tailwind/`<style>` block) — most email
clients strip external/embedded CSS.
(`renderOrderConfirmationHtml()`, `renderPasswordResetHtml()`,
`renderOrderStatusHtml()`), no server-only or client-only imports. Used
**both** server-side for the real send (`orderEmail.ts`) **and**
client-side for the Live Preview page — same function, same inputs, so a
Live Preview edit and the real sent email are guaranteed to render
identically for order-confirmation, the only one of the 6 actually sent
from this repo (password-reset and all 4 status-change types are sent by
Payload itself, using its own inline templates — see that repo's README
for why — so their Live Previews approximate rather than pixel-match).
Inline-styled HTML (`<table>` layout, `style` attributes, no
Tailwind/`<style>` block) — most email clients strip external/embedded
CSS.
- **`/email-preview/[type]/page.tsx`** — entered exclusively from Payload's
admin iframe (`EmailTemplates.ts`'s `admin.livePreview.url`), never a
real visitor destination (`noindex`). Always reads with `draft: true` so
@@ -420,10 +479,14 @@ dependency here for `LivePostContent.tsx`).
(`getEmailTemplate()` in `app/lib/payload.ts`, `draft` unset) — a Live
Preview edit never affects a live customer email until actually saved.
- `npx payload run src/seed-email-templates.ts` (Payload repo) seeds
defaults for both rows — deliberately on-brand and a little playful
("Geschafft!" / "Kein Drama.", not generic transactional-email
defaults for all 6 rows — deliberately on-brand and a little playful
("Geschafft!" / "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). Editable in the admin afterward regardless.
testimonial copy). Two intentional exceptions to that voice: the
email-verification mail (Payload-side, plain functional copy — see that
README) and the Stornorechnung/Gutschrift PDFs (formal legal documents,
no brand voice by design). Editable in the admin afterward regardless.
`sendOrderConfirmationEmail()` also has a hardcoded fallback for the
rare case a fresh install's order arrives before that seed has run.
- **`emailShell()`'s visual design deliberately echoes `/bestellbestaetigung`**
@@ -483,6 +546,37 @@ manual admin review. No automatic refund (no payment provider exists yet)
arriving by email/phone; the admin still processes it by hand in the
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.
### Status-change emails
Four `orders.status` transitions trigger a customer email — `shipped`,
`cancelled`, `return_requested`, `returned` (deliberately not `delivered`,
redundant with the carrier's own notification; not `processing`/`received`,
not customer-actionable). **Sent entirely from Payload**, not this repo —
`orders.ts`'s `afterChange` hook there compares `doc.status` to
`previousDoc.status` and fires regardless of who made the change (an admin
setting `shipped`/`returned` in the Payload admin, or the customer's own
self-service cancel/return-request above land on the exact same hook). See
the Payload README's `orders.ts` section for the actual send logic,
including the Stornorechnung/Gutschrift attachment on cancelled/returned.
This repo's only involvement is the **Live Preview approximation** — same
established gap as password-reset (see below): `renderOrderStatusHtml()`
in `app/lib/emailTemplates.ts` and the corresponding entries in
`/email-preview/[type]`'s `VALID_TYPES` exist purely so an admin editing
`order-shipped`/`order-cancelled`/`order-return-requested`/`order-returned`
in the `email-templates` collection sees a reasonable preview — the actual
sent HTML is Payload's own `src/lib/emailShell.ts` render, not this file's.
### Monitoring & alerting
Base uptime (is the site/Payload reachable at all) is already covered by