Marco 9ff4d39de4
Validate e-invoices / mustang (push) Failing after 18s
Fix EN16931 line/allowance-charge amounts: report net, not gross
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.
2026-07-23 12:34:10 +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%