Files
einfach-produktiv-invoicing/README.md
T
Marco 29ea9fafd8
Validate e-invoices / mustang (push) Failing after 1m30s
Phase 4: Mustang EN16931/PDF-A-3 validation in CI
Adds a Gitea Actions workflow that generates realistic e-invoice fixtures (multi-VAT-rate original invoice, Storno, Gutschrift) and validates them against the reference Mustang validator on every push, catching a broken EN16931 mapping before it reaches production instead of relying on manual spot-checks.
2026-07-23 12:12:51 +00:00

65 lines
8.9 KiB
Markdown

# @einfach-produktiv/invoicing
Shared invoice / correction-invoice (Stornorechnung, Gutschrift) PDF generation and VAT-breakdown math, used by both:
- `einfach-produktiv` (the Next.js storefront — generates the original invoice at checkout, plus on-demand re-downloads of both document types)
- `payload` (the Payload CMS backend — generates the authoritative Stornorechnung/Gutschrift the moment an order's status changes)
## Why this exists
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:
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.
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.
## How this is consumed
Not published to npm — installed as a git dependency:
```json
"@einfach-produktiv/invoicing": "git+https://git.mk360.de/Marco/einfach-produktiv-invoicing.git"
```
Ships raw TypeScript/TSX source (no build step) via `main`/`types` pointing straight at `src/index.ts`. Each consuming Next.js app must add this package to its own `next.config.ts`'s `transpilePackages` array so its own bundler compiles the source — the same pattern a monorepo tool like Turborepo uses for internal packages, just without the monorepo.
`react` and `@react-pdf/renderer` are peer dependencies — each consumer supplies its own copy rather than this package pinning a version that could conflict.
## Layout
- `taxBreakdown.ts``computeTaxBreakdown()`, the per-VAT-rate net/tax grouping math shared by every document type here.
- `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()`.
- `seller.ts` — the shared `InvoiceSeller` type both document types render in their footer.
- `einvoice/` — the ZUGFeRD/Factur-X layer (see "E-invoicing" below).
## E-invoicing
`renderInvoiceEInvoice(order, seller)` / `renderCorrectionInvoiceEInvoice(kind, order, seller)` (`einvoice/renderEInvoice.ts`) are the e-invoice equivalents of `renderInvoicePdf()`/`renderCorrectionInvoicePdf()` — same inputs, but the returned `Uint8Array` is a **Factur-X-EN16931 hybrid PDF/A-3** (a normal-looking PDF with a machine-readable `factur-x.xml` embedded), not a plain PDF. The plain renderers still exist unchanged and are still what Live Preview/etc. use — nothing about the existing visual templates changed, this only adds a post-processing step on top for the actual send/download paths.
- **`einvoice/buildEInvoiceData.ts`** — maps `InvoiceOrder`/`CorrectionInvoiceOrder` + `InvoiceSeller` into the raw UBL-shaped `Invoice` object `@e-invoice-eu/core` expects (the library converts UBL → CII internally for Factur-X output — this package only ever builds the UBL shape, regardless of target format). Reuses `computeTaxBreakdown()` for the per-rate VAT grouping, same as the visual PDFs — one tax-math implementation feeding both the human-readable and machine-readable side of the same document.
- Every EN16931 amount field turned out, at runtime (via the library's own ajv JSON-schema validation — **not visible in its TypeScript types at all**), to require a sibling `*@currencyID` key the moment the amount itself is present, and every quantity a `*@unitCode`. `amt()`/`qty()` return both keys at once via object spread so a call site can't add one without the other — found by actually running a sample invoice through `generate()` and reading the ajv errors, not from the library's own docs.
- Original invoice: `InvoiceTypeCode` `380` ("Commercial invoice"). Correction invoice: `381` ("Credit note") — this library has no separate credit-note type, same `Invoice` shape either way, just the type code — with a `cac:BillingReference` pointing back at the original invoice number. Amounts stay positive either way (the credited amount, not a negative number) — EN16931/UBL convention puts the polarity in the type code, not the sign; the PDF's own visual "-{amount}" is a *display* convention layered on top (`correctionInvoicePdf.tsx`'s own `groupByTaxRate()`), not something this XML mapper re-derives independently.
- VAT category is always `S` ("Standard rated") — correct for *any* positive VAT rate under EN16931/Peppol BIS convention (19% and 7% both use `S`, with the actual percentage in `cbc:Percent`); this shop has no exports/reverse-charge/exempt sales.
- 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).
- **`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."
## CI validation (Mustang)
Every push/PR runs `.gitea/workflows/validate-einvoice.yml` against git.mk360.de's own self-hosted Gitea Actions runner (`vps-runner`, registered on the same VPS as Gitea/Payload — see `/home/marco/dev/docker/docker-compose.yml`'s `act_runner` service):
1. `npm run fixtures:einvoice` (`scripts/generate-einvoice-fixtures.mts`) renders 3 PDFs into `.mustang-fixtures/` (gitignored, regenerated every run) — an original invoice with **two simultaneous VAT rates (19%+7%) plus a discount and shipping cost together** (the combination `SAMPLE_INVOICE_ORDER` doesn't cover), plus a Storno and a Gutschrift against the same order.
2. [Mustang-CLI](https://www.mustangproject.org/commandline/) (the reference ZUGFeRD/Factur-X validator, `--action validateExpectValid -d .mustang-fixtures`) checks all 3 for EN16931 + PDF/A-3 conformance in one call. Non-zero exit fails the job. The jar is downloaded pinned to a specific release + sha256 (`core-2.24.0`) rather than a floating `latest` tag, right in the workflow — no Docker image for Mustang is actively maintained by the upstream project itself, so downloading the jar directly into a `setup-java` step was simpler and more trustworthy than depending on a third-party wrapper image.
Run the same check locally with `npm run fixtures:einvoice`, then point a locally-downloaded `Mustang-CLI-*.jar` at `.mustang-fixtures/` yourself — useful for iterating on `buildEInvoiceData.ts` without waiting on a CI round-trip.