PDFlibPas, the losLab Delphi PDF Library, checks EN 16931, XRechnung 3.0.2 and Peppol BIS Billing 3.0 business rules natively in Pascal. TPDFlib.ValidateInvoiceBusinessRulesBuiltIn reads the XML embedded in a Factur-X or ZUGFeRD PDF and returns a JSON report with official rule IDs, without a Java runtime, Saxon or a Schematron toolchain. The report labels itself as a subset, and that label is the most important field in it
The pressure behind this is mostly German. Since January 2025 every domestic business in Germany has to be able to receive structured B2B e-invoices, and the issuing obligation follows in stages. Suddenly the receiving side of a Delphi accounting or ERP application has to answer a question it never asked before: is the XML inside this PDF actually a valid EN 16931 invoice? The official answer comes from Schematron artefacts compiled to XSLT 2.0, or from the KoSIT validator for XRechnung, and both run on the JVM. Shipping and patching a JRE alongside every desktop install just to reject a bad invoice is a cost many teams would rather not carry
What does the built-in validator actually check?
The native validator maps the invoice XML onto the EN 16931 business terms and runs a documented subset of the rules from the EN 16931 validation artefacts 1.3.16, reporting each finding under its official rule ID. Both syntaxes are supported: UN/CEFACT CII D16B, which is what Factur-X and ZUGFeRD embed, and OASIS 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 scope is reported, not implied. Without a schema file, the schema stage confirms well-formedness and the root element. Every stage lists the rules outside the subset in a notCovered field, and GetBuiltInInvoiceRuleCatalog returns the whole map as JSON, so your UI can show a user exactly what a green result covers
One more boundary is easy to miss. Factur-X MINIMUM and BASIC WL documents are not EN 16931 invoices, so the validator does not apply EN 16931 rules to them. Instead it adds an informational BuiltIn.Coverage.Profile issue that says so, which is the honest answer and also a hint: under the German rules those two profiles do not count as e-invoices anyway
Validating the embedded XML of a hybrid PDF
ValidateInvoiceBusinessRulesBuiltIn takes one argument, the name of the embedded file, and an empty string is the normal choice. PDFlibPas then picks the attachment that Factur-X detection identifies; for a PDF without Factur-X metadata it falls back to the only .xml attachment. Several candidate .xml attachments, or two attachments with the requested name, produce an Invoice.AttachmentAmbiguous error rather than a guess, decoding is capped at 64 MB, and a missing invoice sets LastErrorCode to 401
uses
System.SysUtils, System.JSON, PDFlibrary;
procedure CheckInboundInvoice(const FileName: string);
var
PDF: TPDFlib;
Report: TJSONObject;
begin
PDF := TPDFlib.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;
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')); // never True here
finally
Report.Free;
end;
finally
PDF.Free;
end;
end;
The top level of the report carries standard, version, profile, complete, valid, fullConformance, coverage, a schema and a schematron stage, and a package object that is null in built-in mode. Each stage reports a status, the engine, the number of executed rules, its coverage and an issues array. Every issue has ruleID, fieldPath, severity, category, line and message
Reading issues by category instead of by message
The category field is what makes the report usable in a real inbox, because it 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 at master data, and a profile finding at an XRechnung field such as the buyer reference
procedure ListIssues(Report: TJSONObject);
var
Item: TJSONValue;
Issue: TJSONObject;
begin
for Item in Report.GetValue<TJSONArray>('schematron.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 at %s: %s', [
Issue.GetValue<string>('category'),
Issue.GetValue<string>('ruleID'),
Issue.GetValue<string>('fieldPath'),
Issue.GetValue<string>('message')]));
end;
end;
Run the same loop over schema.issues as well, since well-formedness and root-element failures land there and the business-rule stage never sees a document it could not parse
Why can the same invoice pass in CII and fail in UBL?
The official artefacts do not apply the same numeric tolerance in both syntaxes, and PDFlibPas copies each rule from the XSLT of its own syntax rather than harmonising them. BR-CO-17 is the clearest case. It checks that the VAT category tax amount equals taxable amount times rate, rounded to two decimals, and both syntaxes allow a difference of one currency unit. The CII artefact writes that as <= and >=; the UBL artefact uses abs with a strict <
Take a taxable amount of 100.00 at 19 per cent, so the expected tax is 19.00, and an invoice that declares 20.00. In CII, 20.00 minus 1 equals 19.00, which satisfies <=, and BR-CO-17 passes. In UBL the same comparison is strict, 19.00 is not less than 19.00, and BR-CO-17 fails. Nobody sends that invoice on purpose, but generators with a rounding bug sit exactly on such boundaries, and a test suite that only covers one syntax will not notice
The differences go further than BR-CO-17. 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. The CII artefact defines BR-AF-09 and BR-AG-09 as true(), so they never fail there, while UBL checks them with ±1. And when a CII VAT breakdown has no rate at all, the non-O category rules pass vacuously because XPath every $rate over an empty sequence is true. The native code has to skip them the same way, or it reports errors on official sample files
A second trap concerns rule IDs. The rule category prefix is not the VAT category code: K maps to BR-IC, L to BR-AF and M to BR-AG. Deriving the ID from the category code produces BR-K-09 and BR-K-10, IDs that do not exist in EN 16931. If your application maps rule IDs to help texts, take the ID from the report and never build it from the tax category code
How do XRechnung and Peppol rules get switched on?
The built-in validator selects profile rules 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 only; 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 claiming a Peppol customisation gets an informational BuiltIn.Coverage.Profile note saying those rules were not checked, instead of a silent pass. For other CIUS profiles, use the official artefacts
One UBL detail is worth knowing: BR-06 and BR-07 accept only PartyLegalEntity/RegistrationName as the seller or buyer name. Falling back to PartyName/Name looks forgiving but hides real BR-06 failures, which is why the check is strict
When do you still need the official Schematron?
Use the official artefacts, the EN 16931 Schematron through Saxon or the KoSIT validator for XRechnung, whenever a result carries legal or contractual weight: rejecting a supplier invoice, certifying your own generator, or settling a dispute. The built-in report never sets fullConformance to true. That flag requires 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, schema and Schematron stylesheet and records the SHA-256 of every file. PDFlibPas checks each digest when the package loads and again right before an engine reads the file, which narrows 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. With xmllint configured, imported schemas are read by xmllint itself, so only the load-time check protects them
const
// SHA-256 of the rulepackage.xml you reviewed and approved
ApprovedManifestSHA256 = '<64 hex digits from your release review>';
var
Report, Pkg: TJSONObject;
begin
// PDF: a TPDFlib with the invoice already loaded, as in the first example
Report := TJSONObject.ParseJSONValue(
PDF.ValidateInvoiceBusinessRulesWithPackage('',
'C:\Rules\EN16931-CII-1.3.16', ApprovedManifestSHA256,
'', // empty: native XSD stage
'C:\Java\bin\java.exe', 'C:\Saxon\saxon-he.jar',
0)) as TJSONObject; // 0: 30000 ms 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;
The pinned digest is not optional decoration. File digests prove the files match the manifest, but nothing inside a package proves the manifest itself is genuine: an attacker who can rewrite the rules can rewrite the manifest along with them. Only a digest fixed by the caller, in code or in signed configuration, closes that gap, and the report keeps pinned and verified separate so you can tell which guarantee you actually have
If you pass an empty JavaExecutable, the schema stage still validates natively against the package XSD, including facets and identity constraints since v3.672.0, but the business rules fall back to the built-in subset and fullConformance stays false. Schema constructs the native loader does not support, such as substitution groups, make the load fail with XSD.Unsupported instead of being skipped
Where veraPDF and Mustang fit
None of these functions look at 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
A sensible split runs the built-in check in-process on every inbound invoice, where it costs milliseconds: a UBL invoice of about 1 MB with base64 attachments validates in 15 ms, or 79 ms with the UBL 2.1 XSD. The packaged Saxon run goes into CI and into the rejection path, and veraPDF covers the container. Whatever the built-in result, it describes the covered subset, not the standard, which is exactly what the coverage field is there to remind you of
The invoice validation functions, the rule catalogue and the rule package tools ship with the Delphi PDF Library for Delphi and C++Builder, together with the Factur-X, PDF/A-3 and embedded file APIs used above