Validation¶
4.0 — 2 error(s), 0 warning(s), 0 info
- ERROR XAF5009 transactions totalDebit 38260.42 ≠ totalCredit 38261.42
line 223 ERROR XAF5010 transaction '1' in journal 'MEM' does not balance: debit 3612.16, credit 3613.16
pyxaf.validate() checks an auditfile, or a multi-file set, in
one streaming pass, so it works on files of several GB. Memory does not grow with the number
of lines; it grows with the master data (accounts, relations, …), which is held in memory, and
with the transaction numbers checked for uniqueness, which move to a temporary file on disk beyond
500,000 (see Memory). It needs no dependencies; only the optional XSD layer uses lxml. The official validation service of the
Belastingdienst (VTS) is available to subscribers only and accepts files up to 5 MB; pyxaf runs
locally on files of several GB and never sends data anywhere.
Layers¶
| Layer | Checks | Codes |
|---|---|---|
| L1 encoding | container (gzip/zip), BOM, XML declaration, allowed encodings, windows-1252 bytes in ISO-8859-1, applied repairs | XAF1xxx |
| L2 xml | well-formedness, refused DOCTYPE/ENTITY, safety limits | XAF2xxx |
| L3 version | version and namespace detection; the files of a multi-file set belong together | XAF3001–XAF3009 |
| L4 structure | the version's field catalogue: required and unknown elements, elements in a foreign namespace, cardinality, order, complete xs:choice branches, lengths, code lists, patterns, dates, decimals, doubles, integers, ranges; XSD-like, without dependencies |
XAF3010–XAF3021, XAF3030, XAF3050 (ADF) |
| L4x xsd | validation against the bundled official XSD with lxml (optional) | XAF3040 |
| L5 references | accounts, customers/suppliers, VAT codes and periods referenced by lines and master data exist; when an XAF file has no such section at all, one XAF4008 warning says how many references could not be checked |
XAF4xxx |
| L6 totals | control totals and balance, incl. official rules [0004]–[0010]; line counts | XAF5xxx |
| L7 uniqueness | IDs and numbers, incl. official rules [0001]–[0003] | XAF6xxx |
| L8 data quality | dates outside the fiscal year or period, negative amounts, opening-balance conventions, unusual codes; RGS references | XAF7xxx, XAF8xxx |
The code reference lists every code with its default severity.
report.checked names the layers that actually ran, for example
('L1 encoding', 'L2 xml', 'L3 version', 'L4 structure', 'L5 references', 'L6 totals',
'L7 uniqueness', 'L8 data quality'). ADF has no element structure, so L4 does not run for it;
its fixed-width field checks are reported as XAF3050. For XAF 3.0 and 3.1 the label reads
L4 structure (derived catalogue), see below.
If the XML turns out not to be well-formed, or a limit is exceeded, the problem becomes a finding
(XAF2001, XAF2002, XAF2003) and the report contains what was checked up to that point; the
later layers are not listed in checked. validate() raises only when the input is not an
auditfile at all (NotAnAuditfileError) or cannot be opened.
Official rules¶
The XAF 4.0 functional description defines ten numbered consistency rules. pyxaf implements each of them as a code ending in the rule number:
| Rule | Content | Code |
|---|---|---|
| [0001] | custSupID unique |
XAF6001 |
| [0002] | accID unique |
XAF6002 |
| [0003] | RGScode unique (4.0 only; WARNING, because accounts sharing a code are common) |
XAF6003 |
| [0004] / [0005] | opening balance totalDebit / totalCredit = sum of obLine amounts with D / C |
XAF5004 / XAF5005 |
| [0006] | opening balance totalDebit = totalCredit |
XAF5006 |
| [0007] / [0008] | transactions totalDebit / totalCredit = sum of trLine amounts with D / C |
XAF5007 / XAF5008 |
| [0009] | transactions totalDebit = totalCredit |
XAF5009 |
| [0010] | each transaction: sum of D = sum of C | XAF5010 |
pyxaf applies the same checks to the equivalent elements of the other versions (CLAIR2
numberEntries/totalDebit/totalCredit, the ADF header totals). Beyond the numbered rules it
checks the textual rules of the specifications: transaction numbers unique within a journal
(XAF6004), line numbers unique within a transaction (XAF6005), linesCount equal to the number
of lines (XAF5011, XAF5012), and references to defined master data (XAF4xxx). Amounts are
summed exactly as written (amount per written side), as the rules are worded, independent of the
negative-amount policy.
The report¶
validate() returns a ValidationReport:
| Member | Meaning |
|---|---|
ok |
True when there is no ERROR finding, including findings suppressed by limits |
findings |
all kept findings, most severe first, then in file order |
errors, warnings |
the kept ERROR and WARNING findings |
max_severity |
the highest severity of all findings (kept or suppressed), or None without findings |
counts |
occurrences per code, including findings suppressed by limits |
severity_counts |
occurrences per severity, including findings suppressed by limits |
suppressed |
number of findings dropped because of limits |
checked |
the layers that ran |
stats |
counts gathered during the pass: transactions, lines, accounts, relations, vat_codes, periods, journals, opening_balance_lines |
format |
the FormatInfo of the (first) file |
rules |
the rule set used |
to_dict(), to_json(**kwargs) |
JSON-serialisable form; to_json passes kwargs such as indent to json.dumps |
str(report) |
the text summary shown above |
Each Finding has code, severity (Severity.INFO < WARNING < ERROR), message (English),
file (index in a multi-file set), line (1-based) and column where known, path (element
path, or the ADF field), value (the offending raw value, truncated to 200 characters) and
rule_ref (e.g. "[0009]").
report = pyxaf.validate("unbalanced.xaf")
if not report.ok:
for f in report.errors:
print(f.code, f.line, f.message, f.value)
print(report.counts) # {'XAF5010': 1, 'XAF5009': 1}
print(report.stats["lines"]) # 36
to_dict() has a stable layout with schema_version 1:
{
"schema_version": 1,
"format": {
"family": "xaf", "version": "4.0",
"namespace": "http://www.odb.belastingdienst.nl/Belastingdienst/BCPP/1.1/structures/XmlauditfileXAF_4.0",
"namespace_status": "official", "encoding": "utf-8", "bom": false,
"confidence": 1.0, "reasons": ["official namespace of XAF 4.0"]
},
"ok": false,
"rules": "spec",
"checked": ["L1 encoding", "L2 xml", "L3 version", "L4 structure", "L5 references",
"L6 totals", "L7 uniqueness", "L8 data quality"],
"stats": {"transactions": 12, "lines": 36, "accounts": 11, "relations": 3, "vat_codes": 2,
"periods": 12, "journals": 3, "opening_balance_lines": 3},
"counts": {"XAF5010": 1, "XAF5009": 1},
"severity_counts": {"ERROR": 2},
"suppressed": 0,
"findings": [
{"code": "XAF5009", "severity": "ERROR",
"message": "transactions totalDebit 38260.42 ≠ totalCredit 38261.42",
"file": 0, "line": null, "column": null, "path": null, "value": null, "rule_ref": "[0009]"}
]
}
Options¶
pyxaf.validate(
source, # path, bytes, binary stream, or a list for a multi-file set
xsd=False, # also validate against the official XSD (pyxaf[xsd])
rules="spec", # or "vts"
rgs=None, # an RgsSchema to check RGS codes against
encoding=None, # override the declared encoding
repair=(), # opt-in repairs, see the reading guide
negative_amounts="flip", # sign policy, see the reading guide
severity_overrides=None, # {"XAF7002": Severity.INFO, "XAF7001": None}
max_findings_per_code=100,
max_findings=10_000,
)
Rule sets¶
rules= |
Reports |
|---|---|
"spec" (default) |
everything pyxaf checks, per the detected version |
"vts" |
only what the Belastingdienst's validation service checks: encoding and XML (XAF1xxx, XAF2xxx), schema structure (XAF3010–XAF3021, XAF3040) and the numbered 4.0 rules (XAF5004–XAF5010, XAF6001–XAF6003) |
The vts rule set approximates VTS so that you can predict its verdict before uploading; the
consistency rules VTS applies are not published in full, so it cannot be an exact replica. The
same checks run in both rule sets; vts filters the report.
Severities and limits¶
Each code has a default severity (code reference). The validator adjusts
some of them to the detected version: for XAF 3.0/3.1, unknown and missing elements are WARNING
instead of ERROR; a missing opening balance (XAF7006) is a WARNING for 4.0 and INFO otherwise;
an unverified namespace (XAF3004) is INFO. Your overrides win over both:
from pyxaf import Severity
report = pyxaf.validate(
"2024.xaf",
severity_overrides={
"XAF5010": Severity.WARNING, # downgrade
"XAF7010": None, # silence
},
)
max_findings_per_code (default 100) and max_findings (default 10,000) keep reports of files
with millions of problems small. Suppressed findings are still counted: report.counts has the
real number per code, report.severity_counts the real number per severity and
report.suppressed the number dropped. The verdict (ok, max_severity and the exit code of
pyxaf validate) is based on all findings, so a limit can never turn an invalid file into a
valid one. Severity overrides apply first: a silenced code is not counted at all.
XSD validation¶
With pyxaf[xsd] installed, xsd=True adds layer L4x: validation against the bundled official
XSD of CLAIR2, 3.2, 3.2.1 or 4.0 with lxml, using hardened parser options (see
Security). The XSD sees the same text as pyxaf itself: an
encoding= override, transcoding and opt-in repairs apply to both. Inputs up to 64 MiB are
validated as a whole and every schema error is reported; larger inputs are validated streaming,
which stops at the first schema error. No XSD exists for ADF, 3.0 and 3.1 (an INFO XAF3040 says so). A file whose
namespace is not the XSD's target namespace, such as the 4.0 Toelichting variant or a bogus
namespace, cannot validate and gets one ERROR XAF3040 instead of hundreds of follow-up errors.
The dependency-free L4 layer covers the same structural rules (required elements, cardinality, order, choices, namespaces, lengths, enumerations, patterns, types) from catalogues generated from the same XSDs, so the XSD layer is mostly a second opinion with the official tool chain. Two differences in the XSDs themselves: the CLAIR2 XSD declares keys, so lxml keeps every transaction and line key in memory while validating; and the 21 key/keyref constraints of the 3.2 XSD never take effect (their paths lack the namespace prefix), so duplicate or undefined IDs in a 3.2 file pass the XSD. pyxaf's own L5 and L7 layers check those.
Memory¶
The reader holds the master data in memory and streams transactions, so memory does not depend on
the number of lines. Validation adds a little state per transaction: transaction numbers (and
CLAIR2 record IDs) must be unique across the whole file, so they are remembered. Up to 500,000 of
them are kept in memory (a few tens of MB); beyond that they move to a SQLite database in a
temporary file, so even files with tens of millions of transactions validate in bounded memory.
Retained findings are bounded by max_findings. Streaming XSD validation releases each element
once it has been validated, except for CLAIR2 (see above).
XAF 3.0 and 3.1¶
No official XSD of 3.0 or 3.1 is publicly available; pyxaf's catalogues for them are derived (see
Versions). Unknown elements (XAF3011) and missing required
elements (XAF3010) are reported as WARNING, and element order (XAF3013) is not checked.
RGS¶
Pass an RgsSchema to check every account's RGS code against an official RGS release
(XAF8003–XAF8006); report.checked then includes L8 RGS. Placeholder and unrecognisable RGS
codes (XAF8001, XAF8002) are reported without a schema.
Interpreting results¶
A report full of findings does not necessarily mean the file is wrong. Experience with real files:
- Official test files violate the official rules. The 3.2, 3.2.1 and 4.0 test files published with the specifications only exercise the XSD: they contain wrong totals, unbalanced transactions and references to undefined accounts, VAT codes and customers/suppliers.
- Control totals are usually right; structure varies wildly. Real files are internally consistent on totals far more often than they agree on opening-balance conventions, period numbering or encodings.
- Opening balances often do not balance (
XAF5013), are missing (XAF7006), or are given both as element and as period-0 transactions (XAF7004). Check before adding them to balances. - Dates: effective dates outside the fiscal year (
XAF7010) are common and often legitimate (invoice dates of the previous year). AfiscalYearthat matches neither the start nor the end date's year is reported asXAF7007; broken fiscal years (February to January) are often written as a single year, which is accepted. - Known vendor quirks (from public exporter code and sample files):
- Uniconta (4.0): opening-balance
linesCountone too high (XAF5011),RGSVersionRGS-1(XAF8005with a schema),RGScode0000fallback (XAF8001), offset account9999(XAF4005when it is not defined),-for an empty commerce number. - Odoo (Enterprise and OCA): customer/supplier types C and S swapped (not detectable), period
numbers such as
01or501,accTpMfor equity, no XML declaration (XAF1002). - Microsoft Business Central: windows-1252 encoding (
XAF1003), nojrnTp. - Exact Online: negative quantities that fail the XSD, UTF-8 BOM (
XAF1001). - Banana (CLAIR2):
numberEntriescounts transactions instead of lines (XAF5012, with a hint), negative debit/credit amounts. - osFinancials: no XML declaration, header elements out of order,
custSupTpD. - AFAS: amounts with fewer than two decimals (
121,609.9; valid), period numbers without aperiodselement.
- Uniconta (4.0): opening-balance
- Encoding problems are the most common reason a file cannot be read at all; see Encodings for the opt-in repairs.
A practical approach: start with rules="vts" to see what the Belastingdienst would object to,
then read the spec report for what affects your analysis, and silence codes you have decided to
accept with severity_overrides.