Skip to content

Build1 publisher2 min readPublished

A deliberately misplaced XML block proves Poland's FA(3) validator can still fail

Poland's KSeF cannot unaccept an invoice, so the structural checks on an FA(3) builder run offline against the Ministry's published schema, and the run only counts once a broken control document makes it fail.

The Engineer · Build desk

Illustration accompanying A deliberately misplaced XML block proves Poland's FA(3) validator can still fail

What happened

  • Polish business-to-business invoices have been XML documents filed to the national KSeF system in the FA(3) schema since 2026, and the last exemptions end on 1 January 2027.
  • An invoice KSeF has accepted cannot be edited or withdrawn, and the only remedy is a correcting invoice filed after the fact.
  • The Ministry of Finance publishes FA(3) as a 180 KB XSD on crd.gov.pl declaring 331 elements plus two chained imports, and Python's lxml compiles all three with no special setup.
  • Every one of the four sample invoices generated straight from the application's serializer validated on the first run.
  • The author then moved the Podmiot3 block from before Fa to after it and the run printed INVALID z-control.xml, with the error at line 80 naming the Podmiot3 element.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • decision Demo access needs a national ID login, a generated token and an open session, and the author's token expires daily, so acceptance testing can only be a release-time step and everything in between has to run locally.
  • constraint A local schema pass narrows the risk without clearing it, because the demo session is still what decides acceptance, so the filing decision waits on that session.
  • exposure A green from an empty glob or unloaded imports is indistinguishable from a real pass, which means a builder change can ship behind a validator that checked nothing at all.

FA(3) is built from `xsd:sequence`, so where a block sits is part of the contract. Under `Faktura` the order runs `Naglowek`, `Podmiot1` for the seller, `Podmiot2` for the buyer, up to 100 `Podmiot3` third parties, an optional `PodmiotUpowazniony`, then `Fa`, an optional `Stopka` and an optional `Zalacznik`, with `Platnosc` inside `Fa` after the rows. The change under test added three blocks to every invoice: a third party, a payment section with bank accounts, and a footer. They land in three different places, one between the buyer and the invoice body, one deep inside the body after the line items, one after the body. A builder that emits the right content one slot early produces an invalid document.

The XSD declares limits the guides omit. A factor may have at most 20 bank accounts. An account number is any string of 10 to 34 characters. The third party's role is a number from a closed list, where 1 means factor. "When I read these rules in the XSD instead of in a PDF guide, I found them faster and trusted them more," the author wrote on dev.to.

Four documents, including fetching the two imported schema files, take about 1.3 seconds. That is roughly 0.33 seconds per document, and since the imports are fetched once, the steady-state cost per invoice is lower than that. For the figure to transfer you need lxml compiling the same three files locally, the schema already cached, and invoices of comparable size; there is no token, no login and no network round trip per invoice in that path. The script downloads the XSD from a hardcoded URL to `tmp/fa3/schemat.xsd` only if the file is missing.

The samples come out of the application's own code. A hand-written valid invoice proves that a person can write one, so a small script calls the same serializer function the application calls and writes the output to disk. Four cases cover the new paths: a baseline, one with everything at once (a foreign buyer in euros with two factor accounts and a footer), one with an own bank account only, one with a footer only. The baseline carries as much weight as the rest: a change that adds optional blocks must not change the document when those blocks are empty.

What to watch

  • A new FA(3) schema version at a different crd.gov.pl path would strand the hardcoded XSD_URL in the download step.
  • Any change to the KSeF demo token lifetime would change whether acceptance testing can sit inside CI at all.
  • Wiring the offline check into CI turns the one-off schema download into a build-time network dependency on crd.gov.pl.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories