← Blog
How it works AegisPDF

A hybrid invoice is two documents, and nothing checks that they agree

“We received an e-invoice through the platform and the PDF we can read and the XML our accounting software imported do not say the same thing. Both of them validated. I do not know which one is the invoice, or how we were supposed to have noticed.”

On 28 October someone opened an issue against a French e-invoicing module about an invoice that had arrived through the national platform and said two different things. Not ambiguously, and not through a rendering quirk. The file carried an invoice number, an invoicing period, a buyer VAT number and a VAT exemption, and the version a person reads and the version a machine reads disagreed about all four.

The issue is akretion/fr-einvoicing#108, opened and closed on 28 October 2026. Most of it is a question about what to do next, which is a legal question and is not what this post is about. The part worth a post is the thing the author had to establish before asking: that the file was, in the sense that matters to every validator in the chain, entirely valid.

This post is about why. The short version is that a hybrid invoice is two documents in one file, the entire conformance regime is defined over one of them, and the agreement between the two is not a property anything in the format can express.

One file, two invoices

Factur-X is a PDF/A-3 file with an XML invoice attached to it. The page is for people. The attachment is for software. They are supposed to be the same invoice. The publisher's own summary describes the PDF as "a full readable invoice", useful "especially in case of discrepancy or absence of automatic matching with orders and / or receptions", which is to say that the page is the half people fall back on when the data does not line up.

Here is what that looks like in a file, read out of one built for this post. The PDF declares its own conformance in its XMP packet:

<pdfaid:part>3</pdfaid:part>
<pdfaid:conformance>B</pdfaid:conformance>
<fx:DocumentFileName>factur-x.xml</fx:DocumentFileName>
<fx:Version>1.0</fx:Version>
<fx:ConformanceLevel>EN 16931</fx:ConformanceLevel>

and the attachment itself is announced in the catalogue's associated-files array and the embedded-files name tree:

factur-x.xml  {'/AFRelationship': '/Data', '/Desc': 'Factur-X XML file',
               '/F': 'factur-x.xml', '/UF': 'factur-x.xml'}

Every line of that is a declaration. ConformanceLevel says which profile the XML claims to follow. /AFRelationship says how the attachment relates to the page: the library that wrote this file accepts data, source or alternative for an invoice XML, and forces data for the two smallest profiles. None of it is a statement that anything checked, and that distinction turns out to be the whole subject.

The same XML, two different pages

So I built two of them. Same embedded XML in both: the EN 16931 example that ships in the factur-x library's own test fixtures, untouched. The only difference is the page drawn in front of it.

The first file's page mirrors the XML. The second one does not. This is what pdftotext -layout gets off the second page, next to what the XML says:

on the page in the XML
invoice number F-2026-0023 F20260023
billing period 1 October 2026 to 31 October 2026 20260101 to 20261231
buyer VAT number not shown FR 05 987 654 321
VAT exemption "Supply not subject to French VAT" DEBOURS, LIVRAISON INTRACOMMUNAUTAIRE
total excluding VAT 1000.00 EUR 100.00
total with VAT 1049.00 EUR 104.90

The last row is the one to keep hold of. The page asks for 1049.00 and the XML asks for 104.90. One of those is going into somebody's accounting system and the other is going in front of somebody's eyes.

Two independent extractors agree there is only one XML and that it is the same XML in both files. pypdf and poppler's pdfdetach, which do not share a PDF parser (one is Python, the other C++), pull out the same 65,132 bytes with the same SHA-256 prefix a1170c61c250e653 from each file. There is nothing subtle going on: one attachment, byte for byte identical, two different pages in front of it.

What 810 assertions do not look at

The official EN 16931 validation artefacts are published by CEN/TC 434 as Schematron. I fetched the CII binding at release tag validation-1.3.17, which its own README dates 2026-10-09, and counted its assertions: 810 of them, across the abstract model (200), the syntax binding (587) and the code lists (23).

They are good rules. This is one of them, quoted from abstract/EN16931-CII-model.sch:

[BR-CO-15]-Invoice total amount with VAT (BT-112) = Invoice total amount without
VAT (BT-109) + Invoice total VAT amount (BT-110).

That is exactly the relationship my second file breaks. The page says 1000.00 plus 49.00 is 1049.00, which is arithmetic nobody would accept, and the invoice it is printed on is the same invoice the XML describes.

Searching all 810 for any mention of the readable document finds three hits. One is a code list, and it is the only place in the entire rule set where the string application/pdf appears:

[BR-CL-24]-For Mime code in attribute use MIMEMediaType.

That rule's context is ram:AttachmentBinaryObject[@mimeCode], and what it checks is that an attached document's declared media type is one of six allowed values. A second hit is a warning that one CII field should be absent:

[CII-SR-097] - PresentationSpecifiedBinaryFile should not be present

which is one of several hundred "should not be present" warnings that narrow CII down to the EN 16931 model, rather than a statement about renditions. The third is the same rule's parameter.

So: the rules that define what a European e-invoice is can check that an attachment says it is a PDF. They cannot check what is on it, and they do not try, because a Schematron is an assertion language over one XML document and the page is not in it.

Those CEN artefacts were read and counted here, not executed. What was executed below is the compiled Factur-X profile Schematron that carries the same BR rules, which is why the failures it reports further down come back with CEN rule identifiers on them.

The French rules do cross-check, and still cannot see the page

It would be easy to write this off as a thin validator, so here is the opposite case. The French transmission rules are much more than a schema check. The compiled CII Schematron that ships for them contains 58 rule patterns, and plenty of them are genuine cross-field coherence checks rather than field formats. Translating their own titles: the total excluding VAT on a group line must equal the sum of its child lines; the seller's legal identifier on a line must match its parent line; the invoice identifier at line level must match the invoice number; a credit note must reference an earlier invoice; a SIRET and a SIREN must be consistent with each other.

That is a validator that is willing to compare two things and complain. And exactly two of its 58 patterns touch the readable presentation at all. One checks that an attachment's qualification code is in an allowed list, a list whose members include LISIBLE. The other is this, fatal, quoted from the artefact:

BR-FR-18/BT-123 : Il ne peut y avoir qu'un seul document additionnel (BG-24)
dont la description (BT-123) est "LISIBLE".

There can be only one readable document. That is the extent of it. The rules know the readable presentation exists, they insist you do not declare two of them, and they have no opinion whatsoever about what it says. The author of #108 cites a clause of the French standard XP Z12-012 to the effect that factur-x.xml "ne doit contenir que des informations présentes dans la présentation lisible". Whether or not that is the binding wording, and that standard is behind a paywall and was not read for this post, nothing in the machine-checkable rules enforces it.

Both files pass. Identically.

Run the two files through everything available:

                                        facturx-agree.pdf      facturx-disagree.pdf
Factur-X 1.09 EN16931 XSD               PASS                   PASS
Factur-X EN16931 schematron             0 failed / 436 fired   0 failed / 436 fired
French CTC flux 2 schematron (BR-FR)    0 failed / 182 fired   0 failed / 182 fired

Rules fired six hundred and eighteen times against the file whose page asks for ten times the money the XML does (a rule fires once for each node it matches, so that is 207 distinct Factur-X rules and 58 French ones), and not one assertion failed, because every one of them was handed the XML and the XML is correct.

Now the control, which is the part that makes this an argument rather than an observation. Take the same error and write it inside the XML instead: change GrandTotalAmount from 104.90 to 1049.00 and leave everything else alone. The same Schematron, the same command:

Factur-X EN16931 schematron: 2 failed assertion(s) ['BR-CO-15', 'BR-CO-16']
    [BR-CO-15]-Invoice total amount with VAT (BT-112) = Invoice total amount
    without VAT (BT-109) + Invoice total VAT amount (BT-110).
    [BR-CO-16]-Amount due for payment (BT-115) = Invoice total amount with VAT
    (BT-112) -Paid amount (BT-113) +Rounding amount (BT-114).

Caught, named, with the formula. The identical commercial error is a fatal failure on one side of the file and invisible on the other. The dividing line is not severity or difficulty. It is which half of the document the error is in.

There is a smaller detail in the same direction. The PDF's own /Title and /Subject, which the generator wrote automatically, come out as LE FOURNISSEUR: Invoice F20260023 and Invoice F20260023 dated 2026-01-31 issued by LE FOURNISSEUR. The file's metadata was derived from the XML, so even the PDF's description of itself takes the side of the half nobody reads.

Writing the check is harder than it looks

The obvious conclusion is that the sender should compare the two halves, and the receiver should too. That is right, and it is worth saying how the first attempt at it fails, because the first attempt was mine.

Extract both halves, strip everything that is not a digit from each, and ask whether the XML's values appear on the page. It is four lines and it reads as obviously sufficient. It passes the bad file:

XML BT-1   F20260023      -> digits 20260023
page       F-2026-0023    -> digits 20260023    substring? True

XML BT-112 104.90         -> digits 10490
page       1049.00 EUR    -> digits 104900      substring? True

The invoice number needed the normalisation, because F-2026-0023 and F20260023 are plausibly the same number typeset two ways and a comparison that fails on that is useless. But the punctuation thrown away to make the identifiers match is the same punctuation that distinguishes 104.90 from 1049.00, and 10490 really is a substring of 104900. The check is not weak. It is normalising two fields that need opposite treatment, and it reports success.

Fixing it means treating the two kinds of field as different kinds of thing: compare identifiers with separators removed, and compare amounts as parsed decimals, never as text. Field by field, that gives:

== facturx-disagree.pdf
   on the page      BT-1 invoice number      'F20260023'
   NOT on the page  BT-112 total with VAT    104.90 vs page amounts [49.00, 1000.00, 1049.00]
   NOT on the page  BT-73/74 billing period  20260101..20261231
   NOT on the page  BT-48 buyer VAT number   'FR 05 987 654 321'
   NOT on the page  BT-120 exemption reason  ['DEBOURS', 'LIVRAISON INTRACOMMUNAUTAIRE']

Four of the five divergences caught, in about forty lines, by a check the standards cannot contain. The fifth is honest: F-2026-0023 and F20260023 carry the same digits in the same order, so in this file they are a formatting difference and the check is right to allow it. Whether they are the same invoice number is a question about a numbering policy, not about a file, and that is why it cannot be automated away. The author of #108 reports digits that genuinely differ, which is the case this check does catch.

The reusable shape is the one from an amount in PDF text has no edges: the moment you read a money amount back out of a page you have left the realm of data and entered the realm of string handling, and the normalisation that makes one comparison work will quietly break another.

Where AegisPDF fits

AegisPDF is a PDF tool, not an invoicing tool, and a hybrid invoice is a PDF with an attachment, which puts it squarely in the way. Read from source at origin/prod commit 6ec7a14, two things are worth a receiver's attention.

The useful one: AegisPDF's Sanitize screen audits a file before changing it, and the audit enumerates embedded files by name, byte size, declared MIME type and /Desc, with a per-attachment button that extracts the bytes for inspection (extractAttachment in backend/crates/aegis-engine-wasm/src/lib.rs). On a Factur-X invoice that lists factur-x.xml, with the description its generator wrote, and hands you the XML. Reading the other half of the invoice is the step this whole post is asking for, and it is a tool that does it on your own machine.

The caution, which matters more: that same screen's Embedded file attachments checkbox is on by default (removeAttachments = $state(true)), and sanitizing with it on removes the attachment for good. sanitize_pdf_with deletes the catalogue's /AF array, removes EmbeddedFiles from the document's name tree, strips page-level /AF, and then calls prune_objects(), so the orphaned stream is not written back out. On an ordinary document that is the point of the feature: an attachment is an independent copy of data that a metadata strip would otherwise leave behind, which is the PDF metadata post's argument. On a hybrid invoice it deletes the legally structured half of the document and leaves a file that still looks like an invoice. If you sanitize invoices, save the XML first, or clear that box.

What this post does not establish

  • No specification was read. Not the Factur-X specification, not EN 16931 itself, not XP Z12-012, not ISO 19005-3 or ISO 32000. The Factur-X 1.09.2 document is handed out by its publisher in exchange for an email address, and the others are sold. Every structural claim above comes from an artefact that was run or read here: the Factur-X 1.09 XSDs and compiled profile Schematron shipped in factur-x 7.9, the French CTC flux 2 Schematron shipped in the same package, and the CEN/TC 434 CII Schematron source fetched from its own repository at a named tag. Where a rule is quoted, it is quoted from the file that enforces it.
  • The XP Z12-012 clause is the issue author's. The French sentence above is quoted as their citation, not as verified standard text.
  • A second issue was dropped. A supporting report about a requeued invoice keeping its old PDF was on the list for this post. Its repository returns 404 today, so it is not cited and none of the above rests on it.
  • The disagreeing file is synthetic. It was built here, by me, to disagree. It is evidence about what the validators check, not evidence about how often real senders ship a mismatch, and this post makes no claim about that frequency.
  • Two toolchains, no platform, no Mustang. The pair of files was built twice, once with the factur-x library and once without it (a ReportLab page and an attachment added with pypdf). The attachment was read back with factur-x, two versions of pypdf and poppler's pdfdetach, and the Schematrons were run under SaxonC-HE 13.1 and again under Saxon-HE 9.9 on Java, with the same counts and the same two failures. Nothing was submitted to any real e-invoicing platform, and the Mustang validator was not run.
  • AegisPDF was read, not run. The sanitize and audit behaviour above is source at 6ec7a14, not a measurement, and it was not exercised against a Factur-X file.

The practical version

If you send hybrid invoices, generate both halves from one object, once, and compare them before the file leaves. Not because a standard requires it, but because nothing downstream can do it for you: the validator your customer runs will be handed the XML and will tell them the invoice is fine. One renderer and one serialiser over one source of truth is the only arrangement in which the two halves cannot drift, and the moment there are two code paths, the regenerated half and the cached half are one requeue apart from describing different invoices.

If you receive them, decide in advance which half is the invoice, write it down, and check the other against it on arrival. Three or four fields is enough to start: the number, the totals, the dates. Compare amounts as decimals and identifiers as separator-insensitive strings, never both with the same normalisation. And when the two halves disagree, the thing to escalate is not which number is right. It is that the sender has two code paths and does not know it.

Related: an invoice template is an HTML document, on what happens when the readable half is assembled by string substitution, and a PDF table has no cells, on the page itself losing text that was never in the file.