Marco 48da8c069a
Validate e-invoices / mustang (push) Successful in 22s
Plain-text paid badge, uniform item rows, show B2B recipient fields
- The "Bereits beglichen" confirmation is now plain green Text, not a
  tinted pill/box — read as too heavy for a status note.
- Item table rows no longer alternate white/tinted (zebra striping);
  every row now shares the same tinted background for a more uniform
  table, on both the invoice and its Storno/Gutschrift corrections.
- InvoiceOrder/CorrectionInvoiceOrder gained optional companyName/
  vatId, rendered in the "An" recipient block when present — the
  buyer-side counterpart to the sellers' own fields already shown in
  the footer. Both consuming repos need their own commit to actually
  pass these through from Orders.companyName/vatId.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 18:16:27 +00:00

@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:

"@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.tscomputeTaxBreakdown(), the per-VAT-rate net/tax grouping math shared by every document type here.
  • formatters.tsformatPrice()/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 (Überweisung30 credit transfer, Kreditkarte48, PayPal68, 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.tssellerCountry/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 (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.

S
Description
Shared invoice/correction-invoice PDF generation + VAT breakdown, used by both einfach-produktiv (frontend) and payload (backend) — internal git dependency, not published to npm.
Readme 1.1 MiB
Languages
TypeScript 100%