From de1a7c14a0d38bb531f3497bc46745f3f19a3191 Mon Sep 17 00:00:00 2001 From: Marco Date: Fri, 31 Jul 2026 15:49:10 +0000 Subject: [PATCH] Trim README to current-state reference, drop changelog narrative "Why this exists" replaced a phase-by-phase dated bugfix log with a short summary of what the package does today. Git history is the actual changelog. Co-Authored-By: Claude Sonnet 5 --- README.md | 42 ++++++++---------------------------------- 1 file changed, 8 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index a5a5499..b0f79b6 100644 --- a/README.md +++ b/README.md @@ -22,39 +22,13 @@ This package is the reference example for "modular, separately licensable" piece Everything below this point is the module's own development history/changelog and technical reference — read it for the "why" behind any given behavior, not as a first-time setup guide. -## Why this exists +## What this package does -Before this package, `taxBreakdown.ts` and `correctionInvoicePdf.tsx` were hand-duplicated between both repos ("kept in sync by eye"). That drifted in three concrete, customer-visible ways before this package fixed it: +Renders the original invoice ("Rechnung") and its two correction types (Stornorechnung, Gutschrift) as PDF or e-invoice (ZUGFeRD/Factur-X), plus the shared VAT-breakdown math and a handful of small validation utilities (VIES, VAT-ID format, PLZ, carrier tracking) — one canonical implementation for both consuming repos instead of hand-duplicated copies drifting apart. -1. The backend's emailed correction invoice silently dropped each line item's `variantName` (the frontend's re-download copy showed it). -2. The backend's correction-invoice footer wasn't `position: fixed`, unlike the original invoice and the frontend's copy. -3. The backend's correction invoice used a numeric date format (`03.07.2025`); the original invoice and the frontend's re-download copy both used a spelled-out month (`03. Juli 2025`) — so a re-downloaded document didn't match what was originally emailed. +Supports three order-level VAT treatments a consumer can opt into: standard VAT, cross-border exemption (innergemeinschaftliche Lieferung, see "VAT exemption"), and Kleinunternehmerregelung (§19 UStG, see "Kleinunternehmerregelung") — the consuming app's checkout decides which applies per order; this package never guesses. -One canonical implementation, consumed by both repos, makes this class of drift structurally impossible instead of relying on manual vigilance. - -**2026-07-23, Phase 2:** `InvoiceSeller.bankDetails` (a free-text textarea on `company-settings`) became structured `iban`/`bic` fields — EN16931 e-invoicing wants discrete PaymentMeans data, not a paragraph a human formatted by hand. The footer's bank-details line also changed from "Bankverbindung (für Überweisung): …" to plain "Bankverbindung: IBAN … · BIC …", shown whenever either is set — it was never actually conditional on the order's payment method (that label was misleading), and there's no reason to hide it from a card/PayPal customer who might still want it (e.g. for a refund). - -**2026-07-23, Phase 3: actual e-invoicing.** `renderInvoiceEInvoice()`/`renderCorrectionInvoiceEInvoice()` (in `einvoice/`) produce a ZUGFeRD/Factur-X hybrid PDF/A-3 with an embedded EN16931 XML instead of a plain PDF — see "E-invoicing" below. - -**2026-07-23, Phase 4: CI validation.** `.gitea/workflows/validate-einvoice.yml` runs on every push/PR (via git.mk360.de's own self-hosted Gitea Actions runner) — see "CI validation (Mustang)" below. - -**2026-07-23, later the same day: B2B buyer fields + VAT exemption.** `InvoiceOrder`/`CorrectionInvoiceOrder` gained optional `companyName`/`vatId` (shown in the "An" recipient block) and `vatExempt` (innergemeinschaftliche Lieferung, §4 Nr. 1b UStG — decided by the consuming frontend's own checkout via a live VIES lookup, never guessed here) — see "VAT exemption" below. - -**2026-07-23, invoice-layout fixes.** The "Bereits beglichen" confirmation is now plain green text, not a tinted pill/box. Item-table rows share one uniform tinted background (no more alternating zebra striping). The summary card's Netto/MwSt rows were replaced with a genuinely additive Zwischensumme→Rabatt→Versand→Gesamt chain plus an "enthält X% MwSt." annotation below it — the previous layout showed Rabatt/Versand as their own rows *and* folded into the tax-rate groups below them (tax-correct, since ancillary costs are legally apportioned across rates — but double-counted visually), so the visible rows never actually summed to the printed Gesamt. Caught against a real production order, not a synthetic edge case. - -**2026-07-23, Netto row.** A dedicated "Netto" row (Gesamt minus every rate's own tax, summed) now sits between Gesamt and the "enthält X% MwSt." annotation, on every invoice type this package renders — original, Storno, and Gutschrift alike. First version skipped it on `vatExempt` orders (net and Gesamt are the same figure there, so it looked redundant) — corrected same day: it's shown unconditionally, for layout consistency across every invoice, not gated on the tax rate. - -**2026-07-24, Kleinunternehmerregelung (§19 UStG).** `InvoiceOrder`/`CorrectionInvoiceOrder` gained optional `kleinunternehmer`, a third order-level VAT treatment alongside `vatExempt` — see "Kleinunternehmerregelung" below. Takes precedence over `vatExempt` wherever both would otherwise apply (a Kleinunternehmer never charges VAT to begin with, so there's nothing left to "exempt" via the separate intra-community rule). - -**2026-07-25, Vorkasse fixes (v0.2.3).** `isPaidImmediately()` matched `paymentMethodTitle` with an exact `!==` check against the literal string `"Überweisung"` — the consuming shop renamed its Vorkasse row to `"Überweisung (Vorkasse)"` the same day (to make room for a possible future *automated* bank-transfer method, e.g. "Sofortüberweisung", routed through a real payment gateway), which would have silently made every unpaid Vorkasse invoice show "✓ Bereits beglichen". Changed to `startsWith("Überweisung")` — same "only Überweisung is the named exception" design, tolerant of a suffix qualifier on that one title. Also added an explicit instruction in the unpaid case (previously the *absence* of the paid confirmation was the only signal, no actual text): "Bitte überweisen Sie den Rechnungsbetrag unter Angabe der Bestellnummer … auf die unten stehende Bankverbindung. Die Bestellung wird nach Zahlungseingang bearbeitet (in der Regel innerhalb von 1–2 Werktagen)." — same plain-text treatment as the paid confirmation (`unpaidNoticeText`, muted rather than green — an unpaid Vorkasse invoice isn't a problem, just an expected pending state). - -**2026-07-25, unpaid-notice layout fix (v0.2.4).** The new unpaid-Vorkasse instruction (v0.2.3) reused `paidBadgeRow`, which lives inside `summary`'s `alignItems: "flex-end"` column (240pt-wide, designed for the short one-line paid confirmation) — so the longer two-sentence instruction shrank to content width and read as squeezed under the narrow summary card instead of a proper full-width note. Moved to its own sibling row (`unpaidNoticeRow`, `width: "100%"`, `marginTop: 20` vs. `paidBadgeRow`'s 10) outside `summary` entirely, left-aligned instead of right-aligned. - -**2026-07-28, embedded font (v0.2.6).** An E-Rechnung/PDF-A checker flagged that generated invoice PDFs don't embed their fonts — both `invoicePdf.tsx` and `correctionInvoicePdf.tsx` used react-pdf's built-in `Helvetica`/`Helvetica-Bold`, one of the "standard 14" PostScript fonts react-pdf never actually embeds (it just emits a `/BaseFont` reference and relies on the PDF viewer having a substitute installed). PDF/A-3 — already required by this package's own Factur-X/ZUGFeRD pipeline (see "E-invoicing" below) — has no exemption for standard fonts; every font actually used must be embedded. Fixed by vendoring `LiberationSans-Regular.ttf`/`LiberationSans-Bold.ttf` (SIL OFL-1.1, metrically identical to Helvetica/Arial — no layout shift) under `src/assets/fonts/`, registered once via `Font.register()` in the new `fonts.ts` (imported for its side effect by both PDF modules), with every `fontFamily: "Helvetica"`/`"Helvetica-Bold"` replaced by `fontFamily: "Liberation Sans"` (+ `fontWeight: "bold"` where the bold variant was used). Verified by inspecting the raw PDF bytes of a generated fixture: `/FontFile2` present, `/BaseFont` shows subset tags (e.g. `CWOPQE+LiberationSans`), `/Subtype /Type0` — i.e. actually embedded, not just referenced. - -**2026-07-30, font path broke every PDF render inside the Payload backend (v0.2.8).** `fonts.ts` (added in v0.2.6 above) resolved the vendored `.ttf` files via `new URL('./assets/fonts/...', import.meta.url).pathname` — that exact expression shape is what Next.js's Turbopack/webpack bundler statically detects and rewrites into its own hashed static-asset pipeline (`.next/server/assets/.ttf`). Harmless in the frontend's own Next.js build, but the Payload backend is *also* a Next.js app (Payload 3's standard architecture) consuming this package as a `node_modules` git dependency — there, the hash Turbopack's compiled server code referenced didn't match what it actually emitted to disk, so every single invoice/correction-invoice download in the admin failed with `ENOENT: .../LiberationSans-Regular..ttf`. Confirmed not a stale-build-cache issue: reproduced identically even after a from-scratch `docker compose build --no-cache`. Fixed by resolving the fonts directory via `fileURLToPath(import.meta.url)` + `path.dirname`/`path.join` instead — Turbopack's special-casing only triggers on the literal `new URL(x, import.meta.url)` pattern, not an equivalent built from `node:url`/`node:path` primitives, so this resolves to the same correct absolute path at runtime without ever entering the bundler's asset-hashing path. Since `Font.register()`'s `src` field only accepts a `string` (path/URL), not a `Buffer`, there was no way to sidestep this by embedding the font bytes directly instead. - -**2026-07-30, same day: that fix broke `fonts.ts` in the browser instead (v0.2.9).** The v0.2.8 fix above assumed "this file only runs server-side (a PDF renderer has no reason to ever reach a client bundle)" — wrong on both counts: einfach-produktiv's `LiveCompanySettingsPreviewClient.tsx` renders `` directly in the browser for its live preview, and several of its Client Components (`CartContent.tsx`, `CheckoutContent.tsx`, etc.) pull in `fonts.ts` transitively just by importing `computeTaxBreakdown` from this package's barrel `index.ts` (which also re-exports `invoicePdf.tsx`). Calling `fileURLToPath(import.meta.url)` unconditionally at module scope — fine in Node.js, but `node:url`'s `fileURLToPath` isn't a real function in a browser bundle's polyfilled shim (Next.js still resolves the *import* to some stub object rather than erroring; only *calling* the function throws) — crashed module evaluation for every client bundle that reached this module, breaking e.g. einfach-produktiv's entire `/cart` page (`Uncaught TypeError: fileURLToPath is not a function`). Fixed by branching on `typeof window === "undefined"`: the browser path keeps the original `new URL('./file', import.meta.url)` idiom (correctly asset-hashed by Turbopack for that consumer's own build — the v0.2.8 bug only ever applied to being consumed as a `node_modules` dependency by a *different* Next.js app), the Node.js path keeps the `fileURLToPath`/`path.join` resolution from v0.2.8. Both `fonts.ts` imports stay static at the top of the file either way — only the function *calls* are gated, since the import itself never threw. +Fonts are embedded (Liberation Sans, SIL OFL-1.1, metrically identical to Helvetica/Arial) rather than relying on react-pdf's unembedded standard-14 fonts — required for PDF/A-3 conformance in the e-invoicing pipeline. `fonts.ts` resolves its font files differently depending on `typeof window` — Node.js (`fileURLToPath`+`path.join`) vs. browser (`new URL(..., import.meta.url)`, correctly asset-hashed by whichever Next.js app's own bundler is consuming it) — since this package is consumed by two independent Next.js apps whose bundlers each need this resolved through their own asset pipeline, and the one shape that works for one breaks the other. ## How this is consumed @@ -74,7 +48,7 @@ Ships raw TypeScript/TSX source (no build step) via `main`/`types` pointing stra - `formatters.ts` — `formatPrice()`/`formatDate()`, canonical formatting for every document. - `invoicePdf.tsx` — the original invoice ("Rechnung"): `InvoiceDocument`, `renderInvoicePdf()`, plus `SAMPLE_INVOICE_ORDER` (used by the frontend's Payload Live Preview for company-settings). - `correctionInvoicePdf.tsx` — Stornorechnung/Gutschrift: `renderCorrectionInvoicePdf()`. -- `fonts.ts` — registers the embedded Liberation Sans font (`src/assets/fonts/`) once for both PDF modules — see the 2026-07-28 changelog entry above. +- `fonts.ts` — registers the embedded Liberation Sans font (`src/assets/fonts/`) once for both PDF modules. - `seller.ts` — the shared `InvoiceSeller` type both document types render in their footer. - `einvoice/` — the ZUGFeRD/Factur-X layer (see "E-invoicing" below). - `vies.ts` — `checkVatIdViaVies()`, a live check against the EU Commission's VIES API (server-only — import from `@einfach-produktiv/invoicing/vies`, not the main barrel). @@ -94,13 +68,13 @@ These four were unified from what used to be hand-duplicated, independently-drif - VAT category is `S` ("Standard rated") for any positive-rate order — 19% and 7% both use `S`, with the actual percentage in `cbc:Percent`. When `order.vatExempt` is set (innergemeinschaftliche Lieferung, §4 Nr. 1b UStG), category `K` ("VAT exempt for EEA intra-community supply of goods and services") is used instead, with a `VATEX-EU-IC` exemption reason (BT-120/BT-121). When `order.kleinunternehmer` is set (§19 UStG, takes precedence over `vatExempt`), category `E` ("Exempt from tax") is used with a free-text exemption reason only — §19 UStG is a national provision with no EU-wide VATEX code, and BR-E-10 accepts the reason text alone without a code. Both `K`/`E` fields live — but **only** on `cac:TaxTotal`'s own `TaxSubtotal.TaxCategory`, never on an `InvoiceLine`'s `ClassifiedTaxCategory` or an `AllowanceCharge`'s own `TaxCategory` — `@e-invoice-eu/core`'s generated ajv schema rejects `TaxExemptionReasonCode`/`TaxExemptionReason` as "additional properties" on those two despite them being conceptually the same UBL `TaxCategory` complex type. Caught locally (a full PDF/A-3 render + hand-inflated CII XML inspection) before ever reaching Mustang. This shop has no domestic reverse-charge (`AE`) sales — only these two exemption cases exist, and they're mutually exclusive (see `VatMode` in `buildEInvoiceData.ts`). - Payment means: included whenever `seller.iban` is set (matching the visual PDF footer's own "always show it" behavior since Phase 2), with a `PaymentMeansCode` mapped from the order's actual `paymentMethodTitle` (`Überweisung` → `30` credit transfer, `Kreditkarte` → `48`, `PayPal` → `68`, anything unrecognized → `1` "Instrument not defined" — a payment method added in Payload doesn't need a matching code deploy here to keep e-invoice generation working). **Known gotcha, not yet fixed**: when `seller.iban` is unset, `cac:PaymentMeans` is simply omitted (`undefined`) — but `@e-invoice-eu/core`'s UBL→CII conversion then throws (`Cannot read properties of undefined (reading 'length')` inside its own `format-cii.service.ts`), unrelated to anything in this package's own code. Doesn't affect this shop's production data (this tenant's `company-settings.iban` is always set), but would break e-invoice generation entirely for any future tenant/order whose seller has no IBAN configured — worth fixing (always pass an empty array rather than `undefined`?) before that ever happens. - **`einvoice/countryCode.ts`** — `sellerCountry`/`order.country` are free text ("Deutschland"), not an ISO-3166 select field, but EN16931 wants a fixed two-letter code. Small closed mapping (DACH region only, this shop's actual shipping footprint), falling back to `DE`. -- **Library: `@e-invoice-eu/core`**, format `'Factur-X-EN16931'` (the ZUGFeRD "Comfort" profile, the minimum EN16931-compliant level). Verified by actually generating a sample invoice from `SAMPLE_INVOICE_ORDER` and inflating the embedded XML stream out of the resulting PDF/A-3 by hand (the library ships no attachment-reading API of its own to check this against) — confirmed correct `CrossIndustryInvoice` XML, EN16931 guideline reference, per-rate tax breakdown, and payment means, not just "it didn't throw." Same manual-inflation verification repeated for the `vatExempt` path (2026-07-23) — confirmed `CategoryCode>K`, `ExemptionReasonCode>VATEX-EU-IC`, `ExemptionReason>Innergemeinschaftliche Lieferung` all present in the actual embedded XML, not just the pre-conversion UBL JSON. The `kleinunternehmer` path (2026-07-24) is covered by `__tests__/buildEInvoiceData.test.ts` (asserts the pre-conversion UBL `TaxCategory` shape: `E` + the §19 reason text, no code) and by its own Mustang CI fixture (`kleinunternehmer-invoice.pdf`, see "CI validation" below) — not yet manually re-verified against the hand-inflated CII XML the way `K` was; worth doing once this ships to a real Kleinunternehmer tenant. +- **Library: `@e-invoice-eu/core`**, format `'Factur-X-EN16931'` (the ZUGFeRD "Comfort" profile, the minimum EN16931-compliant level). Verified by generating a sample invoice from `SAMPLE_INVOICE_ORDER` and inflating the embedded XML stream out of the resulting PDF/A-3 by hand (the library ships no attachment-reading API of its own) — confirmed correct `CrossIndustryInvoice` XML, EN16931 guideline reference, per-rate tax breakdown, and payment means. The `vatExempt` path is confirmed the same way (`CategoryCode>K`, `ExemptionReasonCode>VATEX-EU-IC` present in the actual embedded XML). The `kleinunternehmer` path is covered by `__tests__/buildEInvoiceData.test.ts` (asserts the pre-conversion UBL `TaxCategory` shape: `E` + the §19 reason text, no code) and by its own Mustang CI fixture (`kleinunternehmer-invoice.pdf`) — not yet manually re-verified against hand-inflated CII XML the way `K` was; worth doing once this ships to a real Kleinunternehmer tenant. ## VAT exemption `InvoiceOrder.vatExempt`/`CorrectionInvoiceOrder.vatExempt` (optional, default falsy) — set by the consuming app after its own checkout confirms (live VIES lookup) that a sale qualifies as an innergemeinschaftliche Lieferung. This package never decides the exemption itself — by the time an order reaches these renderers, every item's `unitPrice` is already de-grossed (net) and `taxRatePercent` already `0`; `vatExempt` only controls **display**: -- Visual PDF (`invoicePdf.tsx`/`correctionInvoicePdf.tsx`): the "enthält X% MwSt." annotation under Gesamt becomes "Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)" instead — rendered as its own full-width row below the summary card (same treatment as the Vorkasse/unpaid notice), not squeezed into the card's narrow right-aligned column. Moved out 2026-07-25, at the user's explicit request. +- Visual PDF (`invoicePdf.tsx`/`correctionInvoicePdf.tsx`): the "enthält X% MwSt." annotation under Gesamt becomes "Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)" instead — rendered as its own full-width row below the summary card (same treatment as the Vorkasse/unpaid notice), not squeezed into the card's narrow right-aligned column. - E-invoice XML (`buildEInvoiceData.ts`): VAT category `K` + VATEX-EU-IC exemption reason — see "E-invoicing" above for exactly where those fields are (and aren't) allowed to live. See the frontend repo's own README ("VAT exemption" section) for the actual VIES lookup, de-grossing math, and checkout UI this feeds from, and the Payload backend's README ("B2B checkout & VAT exemption") for the persisted `Orders.vatExempt`/`vatIdValidatedAt` fields and audit-trail reasoning. @@ -113,7 +87,7 @@ Unlike `vatExempt`, this package does **not** expect `unitPrice` to be de-grosse - Visual PDF (`invoicePdf.tsx`/`correctionInvoicePdf.tsx`): the "enthält X% MwSt." annotation under Gesamt becomes "Gemäß § 19 UStG wird keine Umsatzsteuer berechnet." instead — takes precedence over the `vatExempt` note if (implausibly) both were ever set. - E-invoice XML (`buildEInvoiceData.ts`): VAT category `E` + a free-text exemption reason, no VATEX code — see "E-invoicing" above. -- **No Netto row** (added 2026-07-24, at the user's own request) — every other invoice type still shows Netto directly under the tax note/annotation, including `vatExempt` ones, where Netto = Gesamt is a *coincidence* worth keeping for layout consistency (0% happens to apply to that one sale). For `kleinunternehmer` it isn't a coincidence — §19 UStG means no tax component ever existed on any of this tenant's invoices, so the row would just repeat Gesamt directly under the §19 notice above it instead of adding information. +- **No Netto row** — every other invoice type still shows Netto directly under the tax note/annotation, including `vatExempt` ones, where Netto = Gesamt is a *coincidence* worth keeping for layout consistency (0% happens to apply to that one sale). For `kleinunternehmer` it isn't a coincidence — §19 UStG means no tax component ever existed on any of this tenant's invoices, so the row would just repeat Gesamt directly under the §19 notice above it instead of adding information. `InvoiceSeller.vatId` is now optional (`string | null`, was a plain required `string`) for the same reason — a Kleinunternehmer commonly never registers for an USt-IdNr. at all (no intra-EU trade). Every renderer treats a missing value as "omit the USt-IdNr. line/field" rather than printing an empty one: the visual PDF footer drops the whole `· USt-IdNr. …` segment, and `buildEInvoiceData.ts`'s `sellerParty()` omits `cac:PartyTaxScheme` entirely rather than emitting one with a blank `CompanyID`.