Skip to content

Issue ticket invoices as ZUGFeRD / Factur-X e-invoices (EN 16931) - #284

Draft
claude[bot] wants to merge 4 commits into
mainfrom
invoice-zugferd
Draft

claude[bot] wants to merge 4 commits into
mainfrom
invoice-zugferd

Conversation

@claude

@claude claude Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Richard Hartmann · Slack thread

Before: Ticket invoices, corrections (Rechnungsberichtigung) and cancellations (Stornorechnung) were plain PDFs. From 2027 B2B invoices must be structured e-invoices (§ 14 UStG as amended by the Wachstumschancengesetz). A plain PDF no longer counts as an invoice for business buyers.

After: Every invoice document is a hybrid ZUGFeRD 2.x / Factur-X e-invoice in profile EN 16931 (COMFORT). The file is a PDF/A-3b with the CII XML embedded as factur-x.xml (AFRelationship Alternative, Factur-X XMP extension schema, DocumentType INVOICE, Version 1.0, ConformanceLevel EN 16931). Private buyers still get the same readable PDF; the rendered page is pixel-identical to before. Business buyers get the same file, and it is a valid E-Rechnung. No schema changes.

How:

  • Library: none added. pdfkit (already a dependency, MIT, ~6.6M weekly downloads) supports subset: 'PDF/A-3b' (sRGB output intent and pdfaid XMP), file attachments with AFRelationship plus the catalog /AF entry, and custom XMP through appendXML. That is everything Factur-X needs. The npm options all re-render or post-process with pdf-lib (MIT, but no release since 2022): node-zugferd (MIT, 0.1.x beta), @stafyniaksacha/facturx (MIT, ~2k weekly), and @e-invoice-eu/core, whose WTFPL licence is unusual. The XML itself is a small hand-written CII template in shared/einvoice.ts. Museo Sans is embedded as a subset CFF font. With the old Helvetica fallback (font file missing) the PDF would not be PDF/A, as before.

  • Mapping: the XML is built from the same InvoiceData the page is rendered from, and it carries only printed amounts (no recomputation):

    • Line: ticket type × count at the printed net price.
    • "Rabatt": document-level allowance BG-20 (reason code 95).
    • VAT: category S, 19 %.
    • Leistungsdatum: BG-14 for a multi-day conference, BT-72 for a single day.
    • Seller: BT-31 USt-IdNr. and BT-32 Steuernummer.
    • Buyer: company or name, address, BT-48 USt-IdNr. when it has a valid country prefix, BT-49 e-mail (EM).
    • Payment: means 68 (online payment), paid amount = total, amount due 0.
    • Data whose amounts don't reconcile (line total ≠ Zwischensumme, net + VAT ≠ total, VAT more than 1.00 off 19 %) raises EInvoiceDataError. The lifecycle endpoint returns that as a 400. Orders priced by the checkout can't trigger it.
  • Type codes:

    • 380: Rechnung.
    • 384: Rechnungsberichtigung (corrected invoice).
    • 381: Stornorechnung, as a credit note with positive amounts, i.e. the printed negative amounts with the sign flipped. Most German e-invoicing software sends a Storno this way. 389 (self-billed invoice) would be the UStG "Gutschrift", which a Storno is not.
    • Corrections and stornos reference the original invoice via BT-25/BT-26 and repeat the printed reference sentence as BT-22 (§ 31 Abs. 5 UStDV).
  • Buyer country (BT-55): taken from normalizeBillingCountry. When an order has no mappable country (private buyers may skip the address), the XML says DE and a warning is logged.

  • Validation: npm run einvoice:samples renders every document type, plus a private buyer, a 0 € invoice and a rounding case, through the real snapshot → render path. npm run einvoice:validate runs each PDF through Mustang 2.26.0 (Apache-2.0; embedded veraPDF 1.30.2 for PDF/A-3b, the CII XSD, and the EN 16931 + Factur-X Schematron). This runs in the new einvoice-validation job (Java 21, jar cached, SHA-256 pinned, samples uploaded as an artifact). All 6 samples pass, with 0 errors and 0 PDF/A failures. Locally they also pass the official CEN validation artefacts 1.3.17 (CII XSD + EN16931-CII-validation.xslt) with 0 failed asserts. The remaining Mustang warnings:

    • PEPPOL-R008, about the empty ApplicableHeaderTradeDelivery when the service period goes into BG-14.
    • One rounding warning, see below.
  • Rounding (BR-CO-17): the checkout rounds the gross price per ticket. Printed VAT is therefore n × round(19 % × net), not round(19 % × n × net). With whole-euro net prices the two are always equal (verified for 1–1000 € × 1–10 tickets). With cent prices they can differ by up to 5 cents per order. Examples:

    • 3 × 99,99 €: printed 57,00 €, exact 56,99 €.
    • 2 × 149,50 €: 56,82 € vs. 56,81 €.
    • 10 × 99,99 €: 190,00 € vs. 189,98 €.

    The CEN schematron tolerates up to 1.00, so these still validate. PDF and XML stay identical because the XML uses the printed VAT. Removing the deviation would change the gross amount customers pay, so it is left for a decision: compute VAT on the order net total in the checkout, which changes nothing for whole-euro prices.

Depends on #280 and #281.

A tax advisor should sign off one sample invoice of each document type before this goes live. The open points are 381 for the Storno, the DE fallback for buyers without a country, and the payment-terms text on the Storno. CI uploads the samples as the einvoice-samples artifact.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FBHiR6NFRe6QR5SBjSv4nw

Every Rechnung, Rechnungsberichtigung and Stornorechnung is now a hybrid
e-invoice: a PDF/A-3b with the CII XML (profile EN 16931) embedded as
factur-x.xml (AFRelationship Alternative, Factur-X XMP extension schema).
The XML is built from the same InvoiceData as the printed page and only
carries printed amounts; data that would contradict them is refused.

Type codes: 380 invoice, 384 corrected invoice, 381 credit note with
positive amounts for the storno; corrections and stornos reference the
original via BT-25/BT-26. The rendered page is pixel-identical.

pdfkit already supports PDF/A-3b and file attachments, so no new runtime
dependency. Samples of every document type are validated with Mustang
(veraPDF, CII XSD, EN 16931 + Factur-X Schematron) in a new CI job.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBHiR6NFRe6QR5SBjSv4nw
@vercel

vercel Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
programmierbar-website Ready Ready Preview Oct 21, 2026 6:52pm UTC

Request Review

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBHiR6NFRe6QR5SBjSv4nw
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBHiR6NFRe6QR5SBjSv4nw

This branch was successfully deployed

1 active deployment
Preview — ffd2bace Deployed Oct 21, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant