Mustang's Phase 4 CI check flagged BR-CO-10/BR-CO-14/BR-S-08 once the Delivery-element fix let it get that far: invoice line amounts were being reported as this shop's normal gross (VAT-inclusive) prices instead of the net amounts EN16931 requires, and shipping/discount had no explicit cac:AllowanceCharge representation at all (silently folded into a scaled net total instead) — so the document totals didn't reconcile against the line items the way a validating AP system checks. De-grosses each line's own unit price per its own VAT rate for LineExtensionAmount/PriceAmount, and adds explicit per-rate AllowanceCharge entries (proportionally allocated the same way computeTaxBreakdown's scale factor already works internally, just split back into its two components instead of one combined adjustment). taxBreakdown.ts gained two additive fields (rawGross/rawNet) for this; the visual PDF renderers are unaffected, they only ever read rate/net/tax/gross.
@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:
- The backend's emailed correction invoice silently dropped each line item's
variantName(the frontend's re-download copy showed it). - The backend's correction-invoice footer wasn't
position: fixed, unlike the original invoice and the frontend's copy. - 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.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(), plusSAMPLE_INVOICE_ORDER(used by the frontend's Payload Live Preview for company-settings).correctionInvoicePdf.tsx— Stornorechnung/Gutschrift:renderCorrectionInvoicePdf().seller.ts— the sharedInvoiceSellertype 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— mapsInvoiceOrder/CorrectionInvoiceOrder+InvoiceSellerinto the raw UBL-shapedInvoiceobject@e-invoice-eu/coreexpects (the library converts UBL → CII internally for Factur-X output — this package only ever builds the UBL shape, regardless of target format). ReusescomputeTaxBreakdown()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
*@currencyIDkey 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 throughgenerate()and reading the ajv errors, not from the library's own docs. - Original invoice:
InvoiceTypeCode380("Commercial invoice"). Correction invoice:381("Credit note") — this library has no separate credit-note type, sameInvoiceshape either way, just the type code — with acac:BillingReferencepointing 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 owngroupByTaxRate()), 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 useS, with the actual percentage incbc:Percent); this shop has no exports/reverse-charge/exempt sales. - Payment means: included whenever
seller.ibanis set (matching the visual PDF footer's own "always show it" behavior since Phase 2), with aPaymentMeansCodemapped from the order's actualpaymentMethodTitle(Überweisung→30credit 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).
- 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
einvoice/countryCode.ts—sellerCountry/order.countryare 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 toDE.- 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 fromSAMPLE_INVOICE_ORDERand 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 correctCrossIndustryInvoiceXML, 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):
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 combinationSAMPLE_INVOICE_ORDERdoesn't cover), plus a Storno and a Gutschrift against the same order.- 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 floatinglatesttag, right in the workflow — no Docker image for Mustang is actively maintained by the upstream project itself, so downloading the jar directly into asetup-javastep 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.