diff --git a/README.md b/README.md index d34ac7e..4323066 100644 --- a/README.md +++ b/README.md @@ -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