DEV Community

Cover image for Implementing EN 16931 national rules in PHP - and the trap that faked a bug on every credit note
Boris Stiner
Boris Stiner

Posted on • Edited on

Implementing EN 16931 national rules in PHP - and the trap that faked a bug on every credit note

Hi, I'm Boris - a PHP developer whose deepest experience is TYPO3, with Laravel and Filament a newer and growing focus. A lot of that work has been integrations that quietly need to just work: payments, webhooks, notifications.

This one is about e-invoicing, which is now mandatory in a growing list of EU countries and is coming for the rest. If you build anything that issues invoices B2B in Europe, this will land on your desk eventually.

The specifics below are Croatian, because that is the one I built. But the shape is identical in Germany (XRechnung), Poland (KSeF), Italy, France and everywhere else:
EN 16931 defines the semantic model, and each country layers its own rules on top as a Schematron file. The trap I hit is a property of Schematron, not of Croatia.

The setup

You send an invoice. It comes back rejected:

[HR-BR-9] - Račun mora sadržavati ispravan OIB operatera
Enter fullscreen mode Exit fullscreen mode

Somewhere there is a document that defines what HR-BR-9 means. In every EU implementation I have looked at, that document is a .sch file - ISO Schematron - and the tax authority publishes it.

Croatia's has 73 assertions under 62 distinct rule ids, every one of them flag="fatal". No warnings. Each broken rule is a rejected invoice.

A typical one is unremarkable:

<assert test="not(matches(/*/cbc:ID, '\s'))" flag="fatal" id="HR-BR-1">
  [HR-BR-1] - The invoice number must not contain whitespace
</assert>
Enter fullscreen mode Exit fullscreen mode

In PHP:

if (preg_match('/\s/u', $invoiceNumber) === 1) {
    // broken
}
Enter fullscreen mode Exit fullscreen mode

Of the 62, about 29 are that easy - a regex, a string length, a date range, a presence check. Sixteen more are four variants of one shape across four VAT categories. Only seven are genuinely hard, all of them arithmetic reconciliation.

So: reimplement them in PHP and move on. Which is where it gets interesting.

PHP cannot run the file

Schematron compiles to XSLT and runs against your document. Croatia's declares queryBinding="xslt2", so it needs XSLT 2.0.

PHP's XSLTProcessor uses libxslt, which is XSLT 1.0 only. There is no flag for this.
Your options:

  1. SaxonC as a PECL extension. Works, but now every server you deploy to needs a compiled extension. For a library people install with Composer, that is a non-starter.
  2. A remote validation service. A network dependency in the middle of issuing an invoice.
  3. Reimplement the rules in PHP. They are, once you take them apart, arithmetic and set membership.

Option 3 is the only sane one for production. It also has an obvious hole: how do you know you read them correctly?

The trap

Here is a rule that looks completely unambiguous:

<assert test="($payableAmount > 0)
              and (exists(cbc:DueDate) or exists(cac:PaymentMeans/cbc:PaymentDueDate))
              or (($payableAmount &lt;= 0))"
        flag="fatal" id="HR-BR-4">
  [HR-BR-4] - Where the payable amount (BT-115) is positive,
              the payment due date (BT-9) must be given
</assert>
Enter fullscreen mode Exit fullscreen mode

If the amount due is positive, there must be a due date. So:

$payable = $invoice->totals->payableAmount->toFloat();

if ($payable > 0 && $dueDate === null) {
    // broken
}
Enter fullscreen mode Exit fullscreen mode

That is wrong, and it took an independent check to find out.

Thirty lines above the assertion, in a place you will not look if you are scanning for <assert> elements, sits this:

<let name="payableAmount" value="
    if (/ubl-invoice:Invoice) then
        cac:LegalMonetaryTotal/cbc:PayableAmount
    else
        cac:LegalMonetaryTotal/cbc:PayableAmount * -1"/>
Enter fullscreen mode Exit fullscreen mode

For a credit note, the amount is multiplied by -1.

The reasoning is sound once you see it. A credit note carries its payable amount as a positive number, but the money moves the other way. After the sign flip the amount is negative, > 0 is false, and the rule never applies to credit notes at all.

Miss that, and your validator reports an error on every credit note without a due date.
Which is nearly all of them - UBL's CreditNoteType has no cbc:DueDate element in the first place, so a credit note that needs one has to carry it in cac:PaymentMeans.

A false positive, on one of the most common documents in the system.

The lesson generalises to any Schematron you implement by hand: the assertion is the tip. Read the let variables. In this file they sit above the rule that uses them, in a different block, and they change the meaning of the test entirely.

How to know you got it right

This is the part I nearly skipped, and it is the part that mattered.

You do not need Saxon in production to use it in a test. Run it once, in Docker, and diff it against your own implementation:

# Compile the Schematron into an XSLT that emits SVRL, using SchXslt
docker run --rm -v "$PWD:/w" -w /w eclipse-temurin:21-jre \
  java -cp saxon.jar net.sf.saxon.Transform \
    -s:rules.sch \
    -xsl:schxslt/xslt/2.0/pipeline-for-svrl.xsl \
    -o:compiled.xsl

# Run it over a document
docker run --rm -v "$PWD:/w" -w /w eclipse-temurin:21-jre \
  java -cp saxon.jar net.sf.saxon.Transform \
    -s:invoice.xml -xsl:compiled.xsl
Enter fullscreen mode Exit fullscreen mode

The output is SVRL, where each <svrl:failed-assert> carries the id of the rule that failed. Extract those, compare with what your code reports, and any disagreement is a bug in your code until proven otherwise.

Two things that cost me time:

  • Use Saxon-HE 10.x. Version 12 wants xmlresolver on the classpath; 10.x is one self-contained jar.
  • Parse SVRL with an XML parser. It is pretty-printed with attributes across several lines, so grep returns nothing on a perfectly good report and you will spend twenty minutes convinced the pipeline is broken.

Update: a reader pointed out that this comparison had no business being a
command I remember to run, and he was right. The objection that kept it out of
CI was that the package must not need a JVM - which confused two machines. The
validator runs in the user's app and still touches no Java. The diff runs on
a CI runner, where Saxon costs nothing. It is now a workflow that fires on every
push and every tag, caching the compiled XSLT against the hash of the .sch
files, so the expensive step only repeats when the rules actually change. A cold
run is under a minute. If you take one thing from this section, take that one:
the JVM only has to exist where the comparison runs.

First run found HR-BR-4. After fixing it: 20 documents, 0 disagreements - and the same result again on those documents after a round-trip through my writer.

The other thing the check found

The tax authority publishes 20 reference invoices. The natural move is to turn them into fixtures and assert that they all validate.

Do not. None of them passes the current rules:

Rule Files Why
HR-BR-40 20/20 Every example is dated 2025; the rule requires 2026 onwards
HR-BR-9 20/20 The placeholder tax ID fails its checksum
HR-BR-53 19/20 Same placeholder in another field
HR-BR-25 1/20 One example omits a classification code it is not exempt from

The explanation is mundane. The examples were published in December 2025, the rules were revised in March 2026, and the date floor was introduced in between. Nobody refreshed the examples.

Worth internalising if you work with any national CIUS: the examples and the rules are different artifacts on different release cycles. Use the examples as input to test your reader and writer. Measure the expected validation result; do not assume it.

Things a national CIUS will add that EN 16931 does not have

Briefly, because these are the ones that surprise people:

  • A mandatory operator. Croatia requires the name and tax number of the person who issued the invoice, in cac:AccountingSupplierParty/cac:SellerContact. If your app has no concept of "who issued this", you now need one.
  • A mandatory issue time. EN 16931 has a date only.
  • No empty elements. <cbc:Note></cbc:Note> fails the document. Most XML builders happily emit an empty element for a null property, so the fix belongs in the writer: the helper that writes a value writes nothing when there is no value.
  • A classification code on every line, from a list of 3,359 permitted values - which is a subset of the national statistics catalogue's 5,828. Validate against the subset in the rules file, not the catalogue, or you will accept codes the authority rejects.

Postscript: what these rules do not check

I found this after publishing, and it belongs here.

The first version of my document builder computed VAT with a helper that takes currency units and scales them into cents - but the taxable amount was already in cents. Every VAT figure came out a hundred times too large. 204.00 became 20400.00.

The validator reported the document as valid.

HR-BR-54 and HR-BR-55 compare the national VAT total against the document VAT total.
Both were consistently wrong, so they agreed with each other. And nothing in the national overlay checks that VAT equals taxable base × rate - that is BR-CO-17, an EN 16931 rule, not a national one.

Two things follow, and they generalise to any national CIUS:

Implementing the national rules is not implementing validation. The EN 16931 layer runs first and catches a different class of error. Skip it and arithmetic nonsense passes.

A validator that accepts your document is telling you less than you think. Mine accepts 62 rules' worth of correctness and is silent on everything else. That is worth knowing before you trust it to gate an invoice on its way out.

The package

All of the above is in stboris/laravel-eracun

  • MIT, PHP 8.3+, no framework dependency:
use Stboris\Eracun\Validation\Validator;

$result = Validator::default()->validateFile('invoice.xml');

$result->brokenCodes();   // ["HR-BR-9", "HR-BR-40"]
$result->messages();      // ["[HR-BR-9] HR-BT-5: ...", ...]
Enter fullscreen mode Exit fullscreen mode

Violations carry the official rule identifiers, so a message from the package matches the code in the rejection you got from your provider. All 62 rules are implemented, and the comparison harness above is in the repo so the claim is checkable rather than asserted:

Validator::default()->coverage();   // ['ratio' => '62/62', 'missing' => []]
Enter fullscreen mode Exit fullscreen mode

It is a business-rule validator, not a conformance validator, and it makes nobody compliant with anything. Signing, fiscalisation and transmission are deliberately out of scope - those need a certificate or a commercial contract with a provider.

Since publishing, I added a builder, because constructing the document turned out to be the harder half of the job:

$eracun = EracunBuilder::invoice('RN-2026-0001')
    ->issuedAt($now)->dueOn($due)
    ->seller($seller)->buyer($buyer)
    ->operator('11111111119', 'Boris')
    ->line('Consulting', quantity: '10', unitPrice: '80.00', kpd: '62.10.11')
    ->build();
Enter fullscreen mode Exit fullscreen mode

It derives the VAT breakdown, the monetary totals and the national extension from the lines, so the six reconciliation rules hold by construction rather than by luck. Four official reference invoices ship with the package as well, so there is something to try it against without hunting them down.

If you are building the same thing for another country, the structure should port straight across: typed document objects, one small class per rule, and Saxon in a container to keep yourself honest.


Curious whether others implementing a national CIUS have hit the let-variable problem, or found a cleaner way to stay in sync with the published rules - let me know in the comments.

Top comments (5)

Collapse
 
to21as profile image
Tobias

The let trap isn't Croatian, it's structural, and it bites everyone eventually. Schematron evaluates let bindings in the rule's context and the assert text almost never hints that a variable has been normalized. Sign flipping on credit notes is the classic one, because several CIUS overlays normalize amounts by document type code before asserting on them, so your document is fine and the rule is fine and the result still looks like a bug.

On staying in sync, the thing that helped most here was to stop treating the ruleset as a rule set and start treating it as a pinned artifact. Concretely:

  1. Pin the published Schematron by authority + release + checksum, as a dependency with a version, not a file someone dropped in resources/. Never transcribe a rule into application code, even a "trivial" one. The moment you do, you own it forever and it silently drifts.
  2. Keep a golden corpus of documents (valid, and one per rule you care about failing) with their expected SVRL outcome. On a ruleset bump you re-run the corpus and diff the SVRL. That diff is your real changelog, because the published release notes never fully cover behavioural changes in let bindings or context shifts.
  3. Compile Schematron to XSLT at build time, exactly as you're doing, and cache the compiled artifact keyed by ruleset version. Compilation is the expensive step, evaluation is cheap.

One more axis that the Schematron won't catch and that hurts once you go hybrid: for Factur-X/ZUGFeRD the PDF/A-3 conformance and the XMP metadata declaring the profile are a separate failure mode. A document can pass the full CIUS and still be rejected because the embedded-file relationship or the profile declaration in the XMP is wrong. Worth a second gate in your test suite.

Disclosure: I build beliq, which generates and validates EN 16931 documents against authority-pinned rulesets, so I have spent an unreasonable amount of time in exactly these files. Happy to compare notes on the Croatian overlay specifically if useful, and nice work publishing the package.

Collapse
 
boris-stiner profile image
Boris Stiner

Thanks Tobias, this is the most useful comment I could get.

Agreed that let is structural. What made it worse in the HR overlay is that the normalisation and the assert sit far apart in the file, so reading the assert alone is actively misleading rather than merely incomplete. I now read every let in a rule's context before I trust what the assert appears to say.

On "never transcribe a rule into application code": you're right, and I'd follow it if I could. The constraint is that PHP has no XSLT 2.0, so running the pinned Schematron in-process isn't on the table - it needs Saxon, which means a JVM. The package has to validate inside the customer's Laravel app, at the moment the invoice is built, with no Docker, no JVM and no network call. The transcription isn't me routing around the Schematron; it's the only way to get the check to where the document actually is.

So I took your second point as the safety net rather than as an extra. The official Schematron (2026-03-13) is vendored in the repo, the authority's 20 example documents are the corpus, and Saxon runs in Docker to produce SVRL which is then diffed against the PHP validator per rule id. Any disagreement exits non-zero. Two independent implementations of the same 62 rules should agree on every document, and where they don't it's my bug until proven otherwise. That is what caught the credit note case - the PHP said one thing, the SVRL said another, and the SVRL was right.

The honest limitation is exactly the one you're pointing at: that diff is a release gate I run, not something that fires on every push, so drift is caught when I look rather than when it happens. Your third point is the lever there - compilation is the expensive step, evaluation is cheap, so the compiled artifact is the thing worth caching by ruleset version.

The PDF/A-3 and XMP failure mode doesn't bite in Croatia yet, because the exchange is XML through a "posrednik" (the licensed intermediary) and the PDF is a courtesy copy rather than the invoice. It bites immediately for anyone taking this cross-border, so it's worth having said out loud.

And a VeraPDF gate on the PDF/A-3b, as beliq does, is exactly the second gate you're describing. Different shape of problem to mine, though: that's a hosted API across eight format families, whereas this is one country, in-process, no network, MIT.

Collapse
 
to21as profile image
Tobias

The JVM constraint makes sense, and it changes where the first point applies rather than overriding it: you can't run Saxon at the moment the document is built, no JVM in a Composer package that has to run inside someone else's Laravel app, but the diff itself doesn't have to run there too. Right now it sounds like the Docker/Saxon command runs locally, when you remember to run it. Wiring those same two commands into a GitHub Actions job for example, on every push to the rules file or at minimum on every tag, turns "a release gate I run" into one that runs whether you remember or not, without the runtime validator ever touching a JVM. The JVM only needs to exist in CI, never in the customer's app.

The postscript is the more interesting failure mode to me, it's the one your own package structurally can't catch by design: HR-BR-54/55 compare the national VAT total against the document VAT total, and if both are wrong by the same factor they agree with each other. What actually caught it was BR-CO-17, an EN 16931 base rule, not a national one. That's the exact shape we designed around at beliq: national overlays sit on top of EN 16931, they never replace it, so 62/62 on the Croatian rules doesn't mean the document is EN 16931-valid, only that it's Croatia-valid on top of an assumed-valid base.

Thread Thread
 
boris-stiner profile image
Boris Stiner • Edited

Hi Tobias, both points land.

On CI: you're right, and the thing I was missing is that the JVM only has to exist where the diff runs, not where the validator runs. Those are two different machines and I was treating them as one. A workflow that runs the Saxon step and the diff on every tag costs nothing at runtime and takes out the "when I remember to" part entirely.

On the base rules: correct, and I checked rather than assumed. The validator implements 62 codes and every one of them is HR-BR-*. There's no BR-CO-17 in there, so what caught that document wasn't my package - it was the base layer my package quietly assumes is already valid. "62/62" means Croatia-valid on top of an assumed-valid EN 16931 document, and my README doesn't say so. It notes that the XSD layer is separate and stops there, which reads as though the rules layer is complete. That's a documentation bug and it's mine.

Overlays sitting on top of EN 16931 rather than replacing it is the right mental model, and it's the one thing in this thread I wouldn't have arrived at on my own. Thanks for both comments - they are very useful and i appreciate it a lot!

Thread Thread
 
to21as profile image
Tobias

Thanks Boris for your kind reply. Glad to hear it was helpful!