Take a VAT category with a taxable amount of 100.00 at 19 percent, so the tax should be 19.00, and let the invoice declare 20.00. Run that document through the official EN 16931 validation artefacts as UN/CEFACT CII and rule BR-CO-17 passes. Encode the same numbers in OASIS UBL and BR-CO-17 fails. Both verdicts are right: the two XSLT files express the same one-unit tolerance with different comparison operators, and an invoice check written from the rule text alone will match one syntax and miss the other
Most of the pressure to get this right comes from Germany. Since January 2025 every domestic business there has to be able to receive structured B2B e-invoices, and the obligation to issue them follows in stages. An accounting or ERP application now has to decide whether the XML inside a Factur-X or ZUGFeRD PDF is a valid EN 16931 invoice, and the official tooling for that, Schematron compiled to XSLT 2.0 or the KoSIT validator, runs on the JVM. Shipping and patching a Java runtime beside every desktop install just to reject a bad invoice is a cost many teams would rather avoid
What does the built-in EN 16931 check cover?
PDFlibPas, the losLab PDF Library for Delphi, checks EN 16931, XRechnung 3.0.2 and Peppol BIS Billing 3.0 business rules in native Pascal code. ValidateInvoiceBusinessRulesBuiltIn reads the XML embedded in the loaded PDF, maps it onto the EN 16931 business terms, runs a documented subset of the rules from the EN 16931 validation artefacts 1.3.16, and returns a JSON report in which every finding carries its official rule ID. Two syntaxes are understood: CII D16B, which is what Factur-X and ZUGFeRD embed, and UBL 2.1 Invoice and CreditNote. The covered areas are:
- Mandatory terms: specification identifier, invoice number, issue date, type code, currency, seller and buyer names, addresses, document totals, VAT breakdown and lines (BR-01 to BR-16, BR-21 to BR-28, BR-CO-04, BR-CO-18, BR-CO-26, BR-45 to BR-48)
- Calculations: line, allowance, charge, tax and payable totals, plus the taxable and tax amount of each VAT category (BR-CO-10 to BR-CO-17, BR-xx-08, BR-xx-09)
- VAT category rules for S, Z, E, AE, K, G, O, L and M: line rates, exemption reasons and the VAT identifiers each category requires
- All code lists BR-CL-01 to BR-CL-26, including the 2100-plus UN/ECE Rec 20/21 unit codes behind BR-CL-23, and the two-decimal rules BR-DEC-01, -05, -09 to -20 and -23
- Profile rules: XRechnung 3.0.2 (BR-DE-1 to BR-DE-31, BR-DEX-01 to BR-DEX-14) on CII and UBL, and Peppol BIS Billing 3.0.20 core, common, code list and national rules on UBL
The word "subset" is in the report itself. The top-level coverage field says subset, each stage lists the rules it did not run in a notCovered array, and GetBuiltInInvoiceRuleCatalog returns the full map of rule IDs, syntaxes and descriptions as JSON. We would show that catalog to whoever reads a green result in your UI. Without a schema file the schema stage checks well-formedness and the root element, nothing more
Factur-X MINIMUM and BASIC WL documents get different treatment. They are not EN 16931 invoices, so the validator skips the EN 16931 rules for them and adds an informational BuiltIn.Coverage.Profile issue explaining why. Under the German rules those two profiles do not count as e-invoices anyway, so an inbound MINIMUM file deserves a closer look before anyone books it
Validating the embedded XML of a hybrid PDF
The invoice functions belong to the Enterprise edition, so the object you create is TPDFlibEnterprise from the PDFlibEnterprise unit. On a plain TPDFlib the same calls return an empty string, which ParseJSONValue turns into nil. ValidateInvoiceBusinessRulesBuiltIn takes a single argument, the name of the embedded file. An empty string is the normal choice: the library then picks the attachment that Factur-X detection identifies, and for a PDF without Factur-X metadata it falls back to the only .xml attachment
uses
System.SysUtils, System.JSON, PDFlibEnterprise;
procedure CheckInboundInvoice(const FileName: string);
var
PDF: TPDFlibEnterprise;
Report: TJSONObject;
begin
PDF := TPDFlibEnterprise.Create;
try
if PDF.LoadFromFile(FileName, '') <> 1 then
raise Exception.CreateFmt('Cannot open %s (error %d)',
[FileName, PDF.LastErrorCode]);
// Empty name: validate the detected Factur-X / ZUGFeRD attachment
Report := TJSONObject.ParseJSONValue(
PDF.ValidateInvoiceBusinessRulesBuiltIn('')) as TJSONObject;
if Report = nil then // guard added for this example
raise Exception.Create('No validation report returned');
try
Writeln('profile: ', Report.GetValue<string>('profile'));
Writeln('valid: ', Report.GetValue<Boolean>('valid'));
Writeln('coverage: ', Report.GetValue<string>('coverage')); // "subset"
Writeln('full: ', Report.GetValue<Boolean>('fullConformance')); // always False here
finally
Report.Free;
end;
finally
PDF.Free;
end;
end;
Attachment selection refuses to guess. Several candidate .xml attachments, or two attachments carrying the requested name, produce an Invoice.AttachmentAmbiguous error. Decoding stops at 64 MB. A PDF with no invoice at all sets LastErrorCode to 401, and since reading LastErrorCode resets it to zero, read it once and keep the value
Besides the fields printed above, the report carries a schema stage, a schematron stage and a package object that is null in built-in mode. Each stage has a status (passed, failed, error and so on), the engine, the executed rule count, its coverage and an issues array. An issue has ruleID, fieldPath, severity, category, line and message
Reading issues by category instead of by message
Route findings on category, because that field tells you who has to fix the problem. The values are syntax, schema, cardinality, calculation, vat, codelist, decimal, profile, coverage, processing and integrity. A calculation finding usually points at the sender's rounding code. A codelist finding points at master data, such as a unit of measure typed as free text where a Rec 20 code like H87 belongs. A profile finding points at an XRechnung field such as the buyer reference. Message texts are for people; the category is what your inbox logic should switch on
procedure ListIssues(Report: TJSONObject);
const
Stages: array[0..1] of string = ('schema', 'schematron');
var
StageName: string;
Item: TJSONValue;
Issue: TJSONObject;
begin
// A document that fails to parse never reaches the business-rule stage,
// so the schema stage has to be read too
for StageName in Stages do
for Item in Report.GetValue<TJSONArray>(StageName + '.issues') do
begin
Issue := Item as TJSONObject;
if Issue.GetValue<string>('severity') = 'info' then
Continue; // coverage notes such as BuiltIn.Coverage.Profile
Writeln(Format('%s [%s] %s at %s: %s', [
StageName,
Issue.GetValue<string>('category'),
Issue.GetValue<string>('ruleID'),
Issue.GetValue<string>('fieldPath'),
Issue.GetValue<string>('message')]));
end;
end;
The easy mistake here is to loop over schematron.issues only. Well-formedness and root-element failures land in schema.issues, and a file that breaks there shows an empty business-rule list. Skip info entries in the error view but log them: BuiltIn.Coverage.Profile is how you learn a Peppol rule set never ran
Why can the same invoice pass in CII and fail in UBL?
The official artefacts do not apply identical numeric tolerances in the two syntaxes, and the native rules copy each check from the XSLT of its own syntax instead of harmonizing them. BR-CO-17 checks that a VAT category's tax amount equals taxable amount times rate, rounded to two decimals. Both syntaxes allow a difference of one currency unit. The CII stylesheet writes that as <= and >=; the UBL stylesheet uses abs with a strict <
Now the opening numbers. In CII, 20.00 minus 1 is 19.00, which satisfies <=, so the rule passes. In UBL the difference of 1.00 has to be strictly less than 1, it is not, and the rule fails. A generator with a rounding bug can land exactly on that boundary, and a test suite that covers only one syntax will never see it
BR-CO-17 is the clearest case, and far from the only one:
- BR-xx-08 requires exact equality after rounding to two decimals in CII, while UBL allows ±1 and only fires when the category actually has lines, allowances or charges
- BR-O-08 is exact in both syntaxes
- The CII artefact defines BR-AF-09 and BR-AG-09 as
true(), so they can never fail there; UBL checks them with ±1 - When a CII VAT breakdown has no rate at all, the category rules other than O pass vacuously, because XPath
every $rateover an empty sequence is true
That last one bites anyone porting the rules by hand: evaluate the rate check eagerly and you report errors on the official sample files. The built-in rules skip those checks the same way the XPath does
Rule IDs hide a second trap. The prefix in a VAT category rule is not the VAT category code: K maps to BR-IC, L to BR-AF and M to BR-AG. Build the ID from the code and you get BR-K-09 and BR-K-10, IDs that do not exist anywhere in EN 16931. If your application attaches help texts to rule IDs, key them on the ruleID from the report and never derive them from the tax category
UBL names are strict as well. BR-06 and BR-07 accept only PartyLegalEntity/RegistrationName as the seller or buyer name. Falling back to PartyName/Name looks friendlier, but it hides genuine BR-06 failures that the official artefacts report, so the built-in check does not fall back
How do XRechnung and Peppol rules get switched on?
Profile rules are selected from the specification identifier, BT-24. An identifier containing xrechnung runs the XRechnung 3.0.2 CIUS rules on CII or UBL. The extension identifier urn:xeinkauf.de:kosit:extension:xrechnung_3.0 additionally enables the BR-DEX rules. The sub invoice line amount and VAT checks (BR-DEX-02, -03 and -09 to -14) run on UBL; on CII, BR-DEX-15 reports that sub invoice lines are not available
An identifier containing peppol runs the Peppol BIS Billing 3.0.20 rules, which Peppol defines only for UBL. A CII document that claims a Peppol customization gets an informational BuiltIn.Coverage.Profile note saying those rules were not checked. Other CIUS profiles belong with the official artefacts
Pinning a rule package when the result carries weight
Use the official Schematron through Saxon, or the KoSIT validator for XRechnung, whenever a result has legal or contractual weight: rejecting a supplier invoice, certifying your own generator, settling a dispute. The built-in report keeps fullConformance at false. That flag turns true only with a verified rule package, both stages passed, both stages run by an engine executing the package's own rules, and nothing left in notCovered
ValidateInvoiceBusinessRulesWithPackage runs that path. A rule package is a directory with a rulepackage.xml manifest, written by CreateInvoiceRulePackage, that names the standard, version, profile, syntax, schema and Schematron stylesheet and records the SHA-256 of every file. Each digest is checked when the package loads and again right before an engine reads the file, narrowing the window for a swap after verification. Paths may not leave the package directory, and the native XSD loader refuses imports the manifest does not list. If you configure xmllint, xmllint reads imported schemas itself, so only the load-time check protects those files
const
// SHA-256 of the rulepackage.xml you reviewed and approved
ApprovedManifestSHA256 = '<64 hex digits from your release review>';
procedure CheckWithRulePackage(PDF: TPDFlibEnterprise); // invoice already loaded
var
Report, Pkg: TJSONObject;
begin
Report := TJSONObject.ParseJSONValue(
PDF.ValidateInvoiceBusinessRulesWithPackage('',
'C:\Rules\EN16931-CII-1.3.16', ApprovedManifestSHA256,
'', // XMLLintExecutable empty: native XSD stage
'C:\Java\bin\java.exe', 'C:\Saxon\saxon-he.jar',
0)) as TJSONObject; // 0: default timeout per tool
try
if not (Report.FindValue('package') is TJSONObject) then
raise Exception.Create('No rule package in the report');
Pkg := Report.GetValue<TJSONObject>('package');
if not (Pkg.GetValue<Boolean>('pinned') and Pkg.GetValue<Boolean>('verified')) then
raise Exception.Create('Rule package failed its integrity check');
if Report.GetValue<Boolean>('fullConformance') then
Writeln('Conforms to the complete rule package');
finally
Report.Free;
end;
end;
Treat the pinned digest as mandatory. File digests prove the files match the manifest, but nothing inside a package proves the manifest is genuine: whoever can rewrite the rules can rewrite the manifest along with them. Only a digest fixed by the caller closes that gap, and the report keeps pinned and verified apart so you can see which guarantee you hold. Where to keep the digest is a trade-off. A constant in code is hardest to tamper with but turns every rules update into a release; signed configuration avoids the build. For a desktop product we would take the constant, since artefact updates are rare and a release review is where someone should look at them anyway
Pass an empty JavaExecutable and the schema stage still validates natively against the package XSD, facets and identity constraints included, while the business rules fall back to the built-in subset and fullConformance stays false. Schema constructs the native loader does not handle, such as substitution groups, make the load fail with XSD.Unsupported instead of being skipped
Where veraPDF and Mustang fit
None of these functions inspect the PDF container. PDF/A-3 conformance under ISO 19005-3, the Factur-X XMP extension schema and the associated-file relationship are separate questions, and veraPDF and Mustang remain the reference tools for them, as described in validating e-invoices with veraPDF and Mustang. The producing side, from attaching the XML to declaring the profile, is covered in building Factur-X and ZUGFeRD hybrid invoices in Delphi and PDF/A-3 extension schemas for Factur-X metadata
The split we recommend: the built-in check runs in process on every inbound invoice, with no JVM to start. The pinned Saxon run lives in CI and in the rejection path, where a wrong answer has consequences. veraPDF covers the container
The built-in validator, the rule catalog and the rule package tools are part of the Delphi PDF Library Enterprise edition for Delphi and C++Builder, next to the Factur-X, PDF/A-3 and embedded file APIs that produce the invoices it checks