Bring README up to date with Phases 0-2 of the e-invoicing migration

Invoice PDFs section still described the pre-migration architecture
(local invoicePdf.tsx/correctionInvoicePdf.tsx/taxBreakdown.ts, shared
invoice numbering for corrections, free-text bankDetails) even though
the code moved to @einfach-produktiv/invoicing, got atomic/separate
numbering, and switched to structured bankName/iban/bic across the last
several commits. Also fixes the Tests section (those tests moved into
the shared package too) and the misleading "(für Überweisung)" bank
details wording, which was never actually conditional on payment method.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-23 10:52:36 +00:00
parent 05a3b009d3
commit 039b8ba28d
+107 -54
View File
@@ -112,9 +112,9 @@ explains what does have access instead.
| `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"), `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 |
| `number-ranges` | Admin-configurable prefix + running counter for customer/order/invoice/correction-invoice numbers — one row per tenant | `customerPrefix`/`customerNext`/`customerPadding`, `orderPrefix`/`orderNext`/`orderPadding`, `invoicePrefix`/`invoiceNext`/`invoicePadding`, `correctionInvoicePrefix`/`correctionInvoiceNext`/`correctionInvoicePadding` (Stornorechnung/Gutschrift — its own gapless sequence, not the same counter as `invoice*`, see "Invoice PDFs" below). **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 |
| `company-settings` | Structured business data for invoice PDFs *and* every email's legal footer (Anbieterkennzeichnung, see "Invoice PDFs" below) — one row per tenant, own **Company** admin group (not Commerce — this is business identity, not a storefront concern) | `sellerName`/`sellerStreet`/`sellerZip`/`sellerCity`/`sellerCountry`/`sellerEmail`, `vatId`, `taxRatePercent` (admin-editable, not hardcoded), `bankDetails`. **Not public-read** — admin or `ORDER_SERVICE_SECRET`. Has a Live Preview button — see "Company Settings & Live Preview" below |
| `company-settings` | Structured business data for invoice PDFs *and* every email's legal footer (Anbieterkennzeichnung, see "Invoice PDFs" below) — one row per tenant, own **Company** admin group (not Commerce — this is business identity, not a storefront concern) | `sellerName`/`sellerStreet`/`sellerZip`/`sellerCity`/`sellerCountry`/`sellerEmail`, `vatId`, `taxRatePercent` (admin-editable, not hardcoded), `bankName`/`iban`/`bic` (`iban`/`bic` format-validated + uppercase-normalized; `bankName` stays free text — replaced a single free-text `bankDetails` field). **Not public-read** — admin or `ORDER_SERVICE_SECRET`. Has a Live Preview button — see "Company Settings & Live Preview" below |
All of the above except `company-settings`, `media`, `users`, `tenants`
are grouped in the Payload admin sidebar under **Commerce** (`products`,
@@ -296,10 +296,12 @@ bank details matter; the display rate only needs the same freshness every
other public catalog fetch already has).
A cart/checkout/order-confirmation *total* additionally shows the actual
€ amount of VAT included, not just a percentage — `app/lib/taxBreakdown.ts`'s
`computeTaxBreakdown()` (extracted out of what used to be independently
duplicated `groupByTaxRate()` logic in `invoicePdf.tsx`/
`correctionInvoicePdf.tsx`, now shared by both the PDFs and this display)
€ amount of VAT included, not just a percentage — `computeTaxBreakdown()`,
imported from `@einfach-produktiv/invoicing` (a shared package consumed
by both this repo and the Payload backend as a git dependency — see
"Invoice PDFs" below; it used to be an independently-duplicated
`groupByTaxRate()` inside `invoicePdf.tsx`/`correctionInvoicePdf.tsx`,
both of which have since moved into that package too)
groups line items by their effective rate and reports each group's actual
tax amount; `app/components/VatBreakdown.tsx` renders a single "enthält
X% MwSt.: Y €" line when the cart/order has one rate, or one line per rate
@@ -492,14 +494,32 @@ 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`.
- **`@einfach-produktiv/invoicing`** — a small standalone package
(`git.mk360.de/Marco/einfach-produktiv-invoicing`, public repo, no
secrets in it) holding every invoice/correction-invoice renderer plus
`computeTaxBreakdown()` (see "VAT display" above) and shared
formatters, consumed here **and** by the Payload backend as a git
dependency (`"@einfach-produktiv/invoicing":
"git+https://git.mk360.de/Marco/einfach-produktiv-invoicing.git#main"`
in `package.json`). Ships raw TS/TSX source, no build step of its own —
this app's `next.config.ts` lists it under `transpilePackages` so this
app's own bundler compiles it, same as first-party code, the same
pattern a monorepo tool like Turborepo uses for internal packages
without actually needing a monorepo. Before this package existed
(2026-07-23), the correction-invoice renderer was hand-duplicated
between this repo and the backend, "kept in sync by eye" — that already
caused three real, customer-visible drifts (a silently dropped
`variantName`, a footer that wasn't `position: fixed`, a numeric vs.
spelled-out date format) before the dedup, see that package's own
README for the specifics.
- **`InvoiceDocument`** — a `@react-pdf/renderer` `Document`, 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`.
- **Layout**: a header separated by a bold brand-colored rule (not a
filled color band — a plain line reads cleaner than a solid block of
color across the top) with the wordmark + "RECHNUNG" label, seller/buyer
@@ -513,19 +533,33 @@ inbox, not only in `/konto/bestellungen`.
- **"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**: `company-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 `company-settings`' seller data below.
invoice says so explicitly (`isPaidImmediately()`, in the shared
package's `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). This one genuinely is conditional on the
order's own payment method — unlike bank details below, which just used
to be worded as if it were.
- **Bank details**: `company-settings.bankName`/`.iban`/`.bic``iban`/
`bic` structured and independently format-validated (uppercased/trimmed
on save too, so "de123..." doesn't fail validation just for being
lowercase — same normalization `discount-codes.code` already used),
`bankName` stays free text since there's no fixed format to validate a
bank's display name against. Not a single free-text `bankDetails`
textarea anymore, as of the 2026-07-23 e-invoicing migration's Phase 2 —
EN16931 wants discrete PaymentMeans data. When `iban` or `bic` is set,
the footer prints "Bankverbindung: [Bankname ·] IBAN … · BIC …" —
**always**, regardless of the order's payment method. It used to say
"Bankverbindung (für Überweisung): …",
which read as conditional on paying by bank transfer specifically, but
never actually was (the display was only ever gated on whether the
field was filled in) — the misleading wording got fixed instead of
adding the behavior it implied, since a card/PayPal customer might
still want the seller's bank details for other reasons (e.g. a refund).
- **Per-tax-rate summary**: line items are grouped by their own
snapshotted `taxRatePercent` (see the Payload README's "Per-product tax
rates" section) via the shared `app/lib/taxBreakdown.ts` (see "VAT
display" above) and the summary prints one plain "Netto" / "zzgl. X%
rates" section) via the shared package's `computeTaxBreakdown()` (see
"VAT display" above) and the summary prints one plain "Netto" / "zzgl. X%
MwSt." pair per distinct rate actually present in that order — no `%`
after "Netto" itself anymore, since the rate is already stated on the
"zzgl." line directly below it. A plain single pair in the common case
@@ -549,9 +583,12 @@ inbox, not only in `/konto/bestellungen`.
instead of two ~45%-width ones) — otherwise unchanged, two columns as
before. `USt-IdNr.` no longer repeats in a header meta box — it already
lives in the footer, printing it twice was redundant.
- **`app/lib/invoiceData.ts`** `generateInvoicePdf(order, seller)` /
- **`app/lib/invoiceData.ts`** (still local to this repo — a thin
server-only wrapper, not part of the shared package) — `generateInvoicePdf(order, seller)` /
`generateCorrectionInvoicePdf(kind, order, seller)`, the render
entrypoints every caller below goes through. `seller` (`company-settings`
entrypoints every caller below goes through; both just call straight
into `@einfach-produktiv/invoicing`'s `renderInvoicePdf()`/
`renderCorrectionInvoicePdf()`. `seller` (`company-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 legal email footer, see "Legal footer (Anbieterkennzeichnung) on every email" below)
@@ -572,27 +609,39 @@ inbox, not only in `/konto/bestellungen`.
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
to that status email. This repo's own `/konto/bestellungen/[orderNumber]`
"Stornorechnung/Gutschrift herunterladen" 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
calls the exact same `renderCorrectionInvoicePdf()` from
`@einfach-produktiv/invoicing` the backend used to generate the
original — not a ported approximation anymore (that used to be a
separate, hand-duplicated copy; see the shared package's README) — so a
re-download is now structurally guaranteed to match what was emailed,
not just guaranteed by careful manual syncing. No PDF is ever persisted
to disk/S3/Media: `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 — 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. Also gained product
reasoning already applied to the original invoice. Also has product
thumbnails (same resolution approach as the original invoice) and, for a
Stornorechnung specifically, an explicit "Versand" summary line — it was
previously only folded silently into the tax-rate groups' scaled gross
amounts, with no line stating how much of the reversed total was
shipping. A Gutschrift never shows this line, since it never reverses
shipping in the first place (see the reasoning below).
- **Numbering**: `invoiceNumber` and `correctionInvoiceNumber` each come
from their own gapless counter on Payload's `number-ranges` collection
(`invoicePrefix`/`Next`/`Padding` vs. `correctionInvoicePrefix`/`Next`/
`Padding` — a Stornorechnung/Gutschrift used to draw from the *same*
counter as ordinary invoices; split into its own sequence as of
2026-07-23). Both are assigned via a single atomic `UPDATE ...
RETURNING` against Postgres (Payload's backend `numberRange.ts`), not a
read-then-write across two separate calls — see the Payload README's
"Number ranges" section for why that distinction actually matters for
§14 UStG.
- **`company-settings`** (Payload collection, structured seller data —
name/address/`vatId`/`taxRatePercent`/`bankDetails`) is fetched via
name/address/`vatId`/`taxRatePercent`/`bankName`/`iban`/`bic`) is fetched via
`getCompanySettings()`/`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)
@@ -609,6 +658,11 @@ inbox, not only in `/konto/bestellungen`.
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.
- **E-invoicing (ZUGFeRD/EN16931) migration** — in progress as of
2026-07-23. Everything above is still a plain PDF; the shared package's
README and the memory notes behind this project track the phased plan
(hybrid PDF/A-3 + embedded XML via `@e-invoice-eu/core`, applied to
*every* invoice, not just B2B) — not yet built as of this writing.
### Company Settings & Live Preview
@@ -616,7 +670,7 @@ inbox, not only in `/konto/bestellungen`.
— but instead of an HTML page, it's a **live, in-browser rendered PDF**:
opening the document in the Payload admin shows the actual invoice layout
updating as the admin edits `sellerName`/address/`taxRatePercent`/
`bankDetails`, no save required.
`bankName`/`iban`/`bic`, no save required.
- **`app/company-settings-preview/page.tsx`** + **`components/LiveCompanySettingsPreviewClient.tsx`**
— same entrypoint pattern as `/email-preview/[type]` (Draft Mode via
@@ -636,11 +690,12 @@ updating as the admin edits `sellerName`/address/`taxRatePercent`/
Dynamically imported with `{ ssr: false }` (`next/dynamic`) since it
touches the DOM directly; the HTML-string email previews elsewhere don't
need that since they're just `dangerouslySetInnerHTML`.
- Renders `InvoiceDocument` (exported from `app/lib/invoicePdf.tsx`
- Renders `InvoiceDocument` (exported from `@einfach-produktiv/invoicing`
specifically for this — everywhere else only the async
`renderInvoicePdf()` buffer-generator is used) against a fixed
`SAMPLE_INVOICE_ORDER` — same "no real document to preview against
generically" reasoning as `email-templates`' own `SAMPLE_ORDER`.
`SAMPLE_INVOICE_ORDER` (also exported from that same package) — same "no
real document to preview against generically" reasoning as
`email-templates`' own `SAMPLE_ORDER`.
- No draft/published distinction here, unlike `email-templates`:
`company-settings` has no content-versioning concept, it's just the
current row — the page's initial (pre-postMessage) fetch is the same
@@ -1053,23 +1108,21 @@ order, not attempted exhaustive coverage:
math (`computeSubtotal`, `computeCartTotals`), incl. the fixed-discount
clamp and `compareAtPrice`-based savings display being independent of
the discount-code math.
- **`app/lib/__tests__/invoicePdf.test.ts`** — `isPaidImmediately()` and
the original invoice's per-rate `groupByTaxRate()` (both exported via an
`__testables` object specifically for this, same pattern the Payload
backend uses for its own correction-invoice tests).
- **`app/lib/__tests__/bundleContents.test.ts`** — `describeBundleContents()`,
extracted out of `app/api/checkout/route.ts` into its own module
(`app/lib/bundleContents.ts`) specifically so it's importable from a
test — Next.js `route.ts` files only allow HTTP-method (+ a few config)
exports, not arbitrary named ones.
The Gutschrift/Stornorechnung money math itself (`resolveLineItems()`,
`groupByTaxRate()` in `correctionInvoicePdf.tsx`) and the Payload-side
`orders.ts` field-lock security logic are tested in the **Payload
backend's** own `test:unit` instead — see that repo's README's own
"Tests" section — since that's where those functions actually live (this
repo's `correctionInvoicePdf.tsx` is only a port for the download button,
not the source of truth).
The original invoice's `isPaidImmediately()`/`groupByTaxRate()` and the
Gutschrift/Stornorechnung money math (`resolveLineItems()`,
`groupByTaxRate()`) both live in `@einfach-produktiv/invoicing` now (see
"Invoice PDFs" above), not in this repo — their tests moved with them
into that package's own `src/__tests__/`, run via that package's own
`vitest`, not this repo's `test:unit`. The Payload-side `orders.ts`
field-lock security logic is still tested in the **Payload backend's**
own `test:unit` — see that repo's README's own "Tests" section, since
that's where that logic actually lives.
## Deployment