Skip to content

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

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

claude[bot] wants to merge 6 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 23, 2026 11:36am 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
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBHiR6NFRe6QR5SBjSv4nw
@Jan0707

Jan0707 commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

LGTM on the infrastructure side - but we need accounting review for the actual feature ...

@Jan0707
Jan0707 marked this pull request as ready for review October 23, 2026 11:19
Copilot AI balanced review requested due to automatic review settings October 23, 2026 11:19
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-23T11:22:19.209755Z fed782c Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fed782c027

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Checkout-accepted pricing can block paid-order fulfillment, and VAT prefix validation and failure notifications need correction.

3 open issues
What changed in this PR

Adds hybrid ZUGFeRD / Factur-X e-invoices to the existing ticket invoice pipeline, building on the service-date and billing-country changes in #280 and #281.

Changes:

  • Embeds EN 16931 XML and Factur-X metadata in invoices, corrections, and cancellations.
  • Validates invoice amounts before rendering and exposes data errors through the lifecycle endpoint.
  • Adds sample documents, mapping tests, and a dedicated Mustang validation job.
File Description
docs/​tickets.md Documents hybrid invoices and PDF/A requirements.
directus-cms/​extensions/​directus-extension-programmierbar-bundle/​TESTING.md Explains sample generation and validation.
directus-cms/​extensions/​directus-extension-programmierbar-bundle/​src/​shared/​museo-font.ts Supports font loading from source and built locations.
directus-cms/​extensions/​directus-extension-programmierbar-bundle/​src/​shared/​invoice-service.ts Passes structured service dates and warns on country fallback.
directus-cms/​extensions/​directus-extension-programmierbar-bundle/​src/​shared/​invoice-generator.ts Embeds XML and metadata in PDF/A-3b output.
directus-cms/​extensions/​directus-extension-programmierbar-bundle/​src/​shared/​einvoice.ts Implements XML mapping, amount checks, and Factur-X metadata.
directus-cms/​extensions/​directus-extension-programmierbar-bundle/​src/​shared/​einvoice-samples.ts Defines six representative invoice samples.
directus-cms/​extensions/​directus-extension-programmierbar-bundle/​src/​shared/​cli/​validate-einvoices.ts Runs the pinned Mustang validator and reports results.
directus-cms/​extensions/​directus-extension-programmierbar-bundle/​src/​shared/​__tests__/​pdf-inspect.ts Adds helpers for inspecting PDF attachments and metadata.
directus-cms/​extensions/​directus-extension-programmierbar-bundle/​src/​shared/​__tests__/​invoice-service.test.ts Tests country warnings and corrects cancellation fixtures.
directus-cms/​extensions/​directus-extension-programmierbar-bundle/​src/​shared/​__tests__/​invoice-generator.test.ts Uses negative amounts for cancellation fixtures.
directus-cms/​extensions/​directus-extension-programmierbar-bundle/​src/​shared/​__tests__/​einvoice.test.ts Tests XML mapping, validation, and PDF embedding.
directus-cms/​extensions/​directus-extension-programmierbar-bundle/​src/​shared/​__tests__/​einvoice-samples.test.ts Renders and optionally exports samples with the embedded font.
directus-cms/​extensions/​directus-extension-programmierbar-bundle/​src/​invoice-lifecycle/​index.ts Returns HTTP 400 for e-invoice data errors.
directus-cms/​extensions/​directus-extension-programmierbar-bundle/​src/​invoice-lifecycle/​__tests__/​index.test.ts Tests rejection before upload for inconsistent amounts.
directus-cms/​extensions/​directus-extension-programmierbar-bundle/​package.json Adds sample and validation commands.
directus-cms/​extensions/​directus-extension-programmierbar-bundle/​.gitignore Excludes generated samples and validator cache.
.github/​workflows/​run_tests.yml Adds validation with cached Mustang and uploaded artifacts.

Review effort: Balanced

E-invoice data problems no longer block invoice issuance or the paid-order
flow: the document is rendered without the embedded XML instead, and Slack
is alerted with invoice/order number and reason.

- Unknown buyer country: no false BT-55 "DE" any more. Private buyers
  without address get a plain PDF without alert (no E-Rechnung needed).
- Negative Rabatt (discount code priced above the list price) and
  hand-edited amounts fall back the same way.
- Legacy orders missing total_gross_cents/subtotal/VAT: derive the missing
  amounts consistently (total_cents is the net total), so corrections and
  cancellations work.
- BT-48 only with a prefix BR-CO-09 accepts (ISO 3166-1 alpha-2, EL, XI, 1A).
- Paid-order hook: any processing failure alerts Slack with order/invoice.

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Legacy backfilling can change an already-issued invoice’s recorded total, causing its cancellation to exceed the original amount.

1 open issue

Review effort: Balanced

total_gross_cents: order.total_gross_cents ?? order.total_cents ?? 0,
discount_amount_cents: discountCents,
vat_amount_cents: vatCents,
total_gross_cents: grossCents ?? netCents + vatCents,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked prod read-only: of the 72 orders that already have an invoice, 0 have null total_gross_cents, vat_amount_cents, subtotal_cents or total_cents. So no legacy invoice exists whose backfill would derive a different total. The checkout (create-checkout buildOrderPayload) always writes all five amounts, and new invoices get their snapshot when issued, not through the backfill. Per the edge-case policy I left the code unchanged; leaving this thread open for Jan.


Generated by Claude Code

This branch was successfully deployed

1 active deployment
Preview — be45c957 Deployed Oct 23, 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.

3 participants