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