Files
einfach-produktiv-invoicing/README.md
T
Marco 2b4b2eb91f
Validate e-invoices / mustang (push) Successful in 23s
Add shared VIES/VAT-ID/PLZ/carrier-tracking modules (v0.3.0)
Unifies logic that was hand-duplicated (and, for PLZ, actually inconsistent) between the backend and frontend repos — both explicitly flagged this drift risk in their own code comments. vies.ts stays behind a separate ./vies subpath (server-only by convention, unlike the client-safe main barrel).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 15:32:55 +00:00

27 KiB
Raw Blame History

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

Onboarding a new client with this module

This package is the reference example for "modular, separately licensable" pieces of this stack — module + paid integration per client, not a hosted SaaS. To bring a new client onto it:

  1. Install as a git dependency, not from npm (see "How this is consumed" below):
    "@einfach-produktiv/invoicing": "git+https://git.mk360.de/Marco/einfach-produktiv-invoicing.git#main"
    
    Pin to #main unless the client needs a specific historical version — there's no separate release/tag process today, main is always the current shipping state.
  2. Add it to transpilePackages in the consumer's next.config.ts — it ships raw TS/TSX, no build step (see "How this is consumed").
  3. Provide react and @react-pdf/renderer yourself — both are peer dependencies, not bundled.
  4. Feed it structured seller/order data matching InvoiceSeller/InvoiceOrder/CorrectionInvoiceOrder (see src/seller.ts, src/invoicePdf.tsx) — this package renders PDFs and computes tax breakdowns, it does not fetch or own any of that data itself. Decide upfront: does this client need Kleinunternehmerregelung (§19 UStG)? Cross-border VAT exemption (innergemeinschaftliche Lieferung)? Both are supported but each needs the consuming app's own checkout logic to detect and pass the right flag (see "VAT exemption"/"Kleinunternehmerregelung" sections below) — this package never decides either on its own.
  5. Decide whether the client needs e-invoicing (ZUGFeRD/Factur-X, mandatory for German B2B from 2027 onward) — if yes, wire up renderInvoiceEInvoice/renderCorrectionInvoiceEInvoice (see "E-invoicing" below) instead of the plain PDF renderers; if the client only sells B2C for now, the plain renderers are sufficient and simpler.
  6. Known gotcha to check for this specific client: if their company-settings.iban might ever be unset, fix the @e-invoice-eu/core crash noted at the end of "E-invoicing" below before going live — it currently only doesn't affect the original tenant because their IBAN is always set.

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

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.

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 12 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/<hash>.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.<hash>.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 <InvoiceDocument> 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.

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().
  • fonts.ts — registers the embedded Liberation Sans font (src/assets/fonts/) once for both PDF modules — see the 2026-07-28 changelog entry above.
  • seller.ts — the shared InvoiceSeller type both document types render in their footer.
  • einvoice/ — the ZUGFeRD/Factur-X layer (see "E-invoicing" below).
  • vies.tscheckVatIdViaVies(), a live check against the EU Commission's VIES API (server-only — import from @einfach-produktiv/invoicing/vies, not the main barrel).
  • vatId.tsnormalizeVatId()/isValidVatId(), EU VAT-ID format validation (client- and server-safe).
  • plz.tsisValidPlz()/plzInputPattern(), postal-code digit-count validation keyed by a caller-supplied digit count per country.
  • carrierTracking.tsCARRIER_LABELS/buildTrackingUrl(), shipping-carrier tracking-link generation.

These four were unified from what used to be hand-duplicated, independently-drifting copies in both consuming repos (each had its own vies.ts/vatId.ts/PLZ-regex/tracking.ts) — see each consumer's own README for where they're used. The small in-memory rate limiter each repo also has (rateLimit.ts) is deliberately left duplicated — small enough (~15 lines) that a shared dependency isn't worth the coupling.

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 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 (Ü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). 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.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." 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.

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.
  • 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.

Kleinunternehmerregelung (§19 UStG)

InvoiceOrder.kleinunternehmer/CorrectionInvoiceOrder.kleinunternehmer (optional, default falsy) — a seller-level, not order-level, business fact (whether this tenant is a small business under §19 UStG), but still snapshotted onto each order at checkout time (mirroring how vatExempt is already frozen there) rather than read live off the seller when an invoice renders. This matters for a reason vatExempt doesn't have to worry about: vatExempt is inherently decided per order (a live VIES lookup against that specific sale), so there's no "past" value to protect. kleinunternehmer is a standing tenant setting that can be toggled on/off at any time — without the snapshot, a tenant switching it later would rewrite the tax treatment of every already-issued invoice the next time it's re-rendered (e.g. a customer's "Rechnung erneut herunterladen"), which is both legally wrong (the treatment at the moment of sale is what counts) and silent. The consuming frontend's checkout is the only place this ever gets read live, from its own company-settings.kleinunternehmer — see that repo's README.

Unlike vatExempt, this package does not expect unitPrice to be de-grossed for a kleinunternehmer order — a Kleinunternehmer never charged VAT in the first place, so the catalog gross price and the actual net charge are the same figure; only taxRatePercent becomes 0 on every item. Display:

  • 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.

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.

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 4 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), a Storno and a Gutschrift against that same order, and a separate single-rate Kleinunternehmer original invoice (kleinunternehmer-invoice.pdf, every item at 0%, category E) — its own fixture rather than a variant of the first, since a real Kleinunternehmer order never has a positive catalog rate to begin with.
  2. Mustang-CLI (the reference ZUGFeRD/Factur-X validator, --action validateExpectValid -d .mustang-fixtures) checks all 4 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.