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