DEV Community

Cover image for Four ways a broken Factur-X invoice stays green in veraPDF and Mustang
Vitalii Nemudryi
Vitalii Nemudryi

Posted on

Four ways a broken Factur-X invoice stays green in veraPDF and Mustang

Our release gate for Factur-X/ZUGFeRD hybrids used to end the way most do: veraPDF says the PDF/A-3 is compliant, Mustang says the invoice is valid, ship it. Then we started breaking our own files on purpose, and four of the breaks sailed through green. This post is the list of those four, with the exact checks that close them. Most of it got hashed out in public, in a SolidInvoice issue where several people building generators compared notes — worth reading if you want the raw exchange: https://github.com/SolidInvoice/SolidInvoice/issues/1439

Blind spot 1: the flipped /AFRelationship

A hybrid invoice is a PDF/A-3 with the invoice XML embedded as factur-x.xml. Every embedded file referenced from the document's /AF array carries an /AFRelationship key, and the Factur-X / ZUGFeRD spec cares about its value: Alternative for BASIC, EN 16931 and EXTENDED profiles as used in Germany, Data for MINIMUM and BASIC WL.

Take a known-good EN 16931 hybrid that both validators pass, and flip only that key:

import pikepdf

pdf = pikepdf.open("good-en16931.pdf")
pdf.Root.AF[0].AFRelationship = pikepdf.Name("/Data")  # was /Alternative
pdf.save("flipped.pdf")
Enter fullscreen mode Exit fullscreen mode

Re-run the gate on flipped.pdf:

  • veraPDF (1.31.167 local install; same result re-run in the verapdf/cli Docker image, 1.30.2): still isCompliant="true". Nothing wrong with that — /AFRelationship semantics are Factur-X domain, not PDF/A conformance. veraPDF is answering a different question than the one you think you asked.
  • Mustangproject 2.26.0, --action validate: still <summary status="valid"/>. The notices it did print on our file were unrelated content rules.

So the one key the spec makes profile-dependent is exactly the key neither tool will fail on. It gets better: the wrong value is the default outcome in popular libraries. Python's factur-x (akretion) defaults the attachment to afrelationship="data" in both generate functions, overridable via a parameter; horstoeko/zugferd in PHP has the same pattern. If you never set it explicitly, you ship Data on an EN 16931 invoice and every validator in your pipeline stays green.

The fix costs one line in CI:

import pikepdf

pdf = pikepdf.open("invoice.pdf")
assert pdf.Root.AF[0].AFRelationship == pikepdf.Name("/Alternative")
Enter fullscreen mode Exit fullscreen mode

Keep the pdf reference in a variable exactly like that: pikepdf closes the document when the object is garbage-collected, so the tempting one-liner pikepdf.open(...).Root.AF[0]... dies with "object of type destroyed" on current pikepdf (10.x).

One honest nuance, credit to podshalocef of invowerk.dev who added it in that same thread: the hybrid rule set does cover this as BR-HYBRID-11 — but at warning severity, so a wrong value never makes the invoice formally invalid. And the accepted value depends on the profile and on the seller and buyer countries:

Profile Seller / buyer Accepted /AFRelationship
MINIMUM, BASIC WL any Data
BASIC, EN 16931, EXTENDED, XRECHNUNG DE / DE Alternative
XRECHNUNG FR / FR none (always warns)
any other combination Alternative, Source or Data

(The reference implementation of that matrix is the HybridValidator in phax/kaltblut.) Two neighbouring rules from the same family are worth knowing: MINIMUM or BASIC WL between two German parties is an error (BR-HYBRID-DE-01/02), and between two German parties with valid XML a failed PDF/A check is downgraded to a warning (BR-FX-DE-03) — everywhere else it stays an error. Which is exactly why a structural assert in your own gate beats hoping a validator escalates a warning.

Blind spot 2: Mustang's last summary

Mustang's validation report contains several <summary> elements — one per part it checks, XML and PDF — and the overall verdict mirrors the XML validity. We proved this live on 2.26.0: strip the OutputIntent from the PDF, set a conformance level that is not on the list, or drop the level entirely, and the PDF-side summary goes invalid while the summary your script probably reads still says valid.

If your gate does this, it ships those files:

# WRONG: reads one summary, Mustang's overall verdict tracks xmlValidity
java -jar Mustang-CLI.jar --no-notice --action validate --source invoice.pdf \
  | grep '<summary' | tail -1
Enter fullscreen mode Exit fullscreen mode

Require every summary to be valid, and at least one to exist:

out=$(java -jar Mustang-CLI.jar --no-notice --action validate --source invoice.pdf)
echo "$out" | grep -q '<summary status=' || { echo "no summary at all"; exit 1; }
echo "$out" | grep '<summary status=' | grep -vq 'status="valid"' \
  && { echo "a part failed validation"; exit 1; }
Enter fullscreen mode Exit fullscreen mode

Same theme as blind spot 1: the tool is fine, the single-line read of its output is the bug.

Blind spot 3: raw CEN Schematron is not the XRechnung ruleset

If you target the German public sector, validating against the raw CEN Schematron packs gives you both false alarms and false passes, because the KoSIT validator scenarios override severities. Two live examples from the thread: a Stk unit code is an error under the CEN rules but only a warning under XRechnung (BR-CL-23), while a CII seller contact carrying both a person and a department is the reverse — fine in CEN, an error since the XRechnung rules 2.6.0 (CII-SR-465). Run the KoSIT validator with its scenario configuration for XRechnung targets, not the bare Schematron.

Blind spot 4: the rules have validity windows

The KoSIT packs are date-gated, and during a transition period two versions are in force at once. An invoice can pass the pack you happen to have installed while failing the one that actually applied on its issue date — or the other way round. Pin the Schematron and codelist versions per issue date, ideally by hash, and treat a rules update like any other dependency bump: re-run your whole fixture matrix against it before it reaches CI.

The gate that survives all four

Ordered, each step failing the build on its own:

  1. XSD of the declared profile at submit time. Cheapest rejection, before anything is rendered or charged.
  2. Structural asserts on the produced PDF via pikepdf/pypdf: /AFRelationship matches the profile, exactly one embedded file named factur-x.xml, XMP extension schema present with fx:ConformanceLevel spelled the way the spec wants it (for EN 16931 that is the string with the space).
  3. veraPDF against the PDF/A-3 profile, fail on any error.
  4. Mustang validate, fail unless every summary is valid.
  5. KoSIT validator with the scenario set matching the invoice's issue date, when XRechnung is in scope.

Pin the versions of all three tools and of the rule packs; when you bump any of them, re-run the matrix of known-bad fixtures first. Speaking of which: invowerk.dev/pruefstand publishes fixtures that cover both BR-HYBRID-11 values, the DE-01/02 pairs, a carrier that is not PDF/A, and PDF/A-4f — their own files CC0, the ones taken from mustangproject and the ZUGFeRD corpus under Apache-2.0. Plugging those straight into the fixture set is the fastest way to find out which of the four blind spots your current gate has.

Full disclosure: I'm the founder of PDFik, a PDF generation API that produces Factur-X hybrids, and the gate above is literally the one our release pipeline runs. Nothing in this post needs the hosted service — every check works the same against files from horstoeko, akretion or your own composer.

Top comments (0)