Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@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.
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.
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
S("Standard rated") for any positive-rate order — 19% and 7% both useS, with the actual percentage incbc:Percent. Whenorder.vatExemptis set (innergemeinschaftliche Lieferung, §4 Nr. 1b UStG), categoryK("VAT exempt for EEA intra-community supply of goods and services") is used instead, with aVATEX-EU-ICexemption reason (BT-120/BT-121) — but only oncac:TaxTotal's ownTaxSubtotal.TaxCategory, never on anInvoiceLine'sClassifiedTaxCategoryor anAllowanceCharge's ownTaxCategory—@e-invoice-eu/core's generated ajv schema rejectsTaxExemptionReasonCode/TaxExemptionReasonas "additional properties" on those two despite them being conceptually the same UBLTaxCategorycomplex 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 the one cross-border-EU exemption case exists. - 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). Known gotcha, not yet fixed: whenseller.ibanis unset,cac:PaymentMeansis simply omitted (undefined) — but@e-invoice-eu/core's UBL→CII conversion then throws (Cannot read properties of undefined (reading 'length')inside its ownformat-cii.service.ts), unrelated to anything in this package's own code. Doesn't affect this shop's production data (this tenant'scompany-settings.ibanis 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 thanundefined?) before that ever happens.
- 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." Same manual-inflation verification repeated for thevatExemptpath (2026-07-23) — confirmedCategoryCode>K,ExemptionReasonCode>VATEX-EU-IC,ExemptionReason>Innergemeinschaftliche Lieferungall present in the actual embedded XML, not just the pre-conversion UBL JSON.
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. - E-invoice XML (
buildEInvoiceData.ts): VAT categoryK+ 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.
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.