EN 16931 e-invoicing for Python. You can build European e-invoices as typed models, serialize them to UBL or CII, validate them against the official rule sets, parse them back, and embed them in Factur-X / ZUGFeRD PDFs.
Status: 0.2.0. Pre-1.0: the API can still change; see
CHANGELOG.mdand the known limitations.
EU e-invoicing mandates are arriving country by country, and the reference tooling is mostly Java. euinvoice is a small, typed, MIT-licensed Python library with three principles:
- One semantic model. The EN 16931 business terms form a frozen,
Decimal-only Pydantic model. UBL and CII are just two ways of writing it down. - The official rules are the judge. Validation runs the pinned official XSD and Schematron artifacts (CEN, Peppol, KoSIT XRechnung) on SaxonC-HE, not a reimplementation that drifts after the next rule release.
- Framework-free and side-effect-free. Bytes go in and bytes come out, with no Django, no network and no file system in the core.
| Profile | Syntax | Generate | Parse | Validate |
|---|---|---|---|---|
| EN 16931 core | UBL 2.1, CII D16B | ✅ | ✅ | ✅ XSD + CEN |
| Peppol BIS Billing 3.0 | UBL 2.1, CII D16B | ✅ | ✅ | ✅ XSD + CEN + Peppol |
| XRechnung 3.0 (CIUS) | UBL 2.1, CII D16B | ✅ | ✅ | ✅ XSD + CEN + XRechnung |
| XRechnung 3.0 Extension | UBL 2.1, CII D16B | ✅ EN 16931 content only | ✅ extension content listed as unmapped; content outside the CEN code lists (e.g. ICD XR03, testsuite 04.05a CII) raises ParseError |
ok is False for 2 of the 6 official instances, kosit.accepted is True; see below |
| XRechnung 3.0 CVD | UBL 2.1, CII D16B | ❌ | ❌ conforming documents (BR-CL-13) | ok is False for every CVD document, kosit.accepted is True for the official instances; see below |
| Factur-X 1.0 / ZUGFeRD 2.1+ EN 16931, XRECHNUNG | CII in PDF/A-3 | ✅ | ✅ | |
| Factur-X 1.0 / ZUGFeRD 2.1+ BASIC, EXTENDED | CII in PDF/A-3 | ❌ | ✅ EXTENDED-only content listed as unmapped | ❌ |
| Factur-X 1.0 / ZUGFeRD 2.1+ MINIMUM, BASIC WL | CII in PDF/A-3 | ❌ | ❌ detected and extracted only | ❌ |
| FatturaPA 1.2.3, Italy | FatturaPA XML, FPR12 / FPA12 | ✅ FPR12 only: TD01, TD04, TD24, TD17; one invoice per file; unsigned | ✅ FPR12 and FPA12, 1..n invoices (parse_all); Italian data in Invoice.it |
✅ XSD 1.2.3 + offline SdI checks; see below |
| ZUGFeRD 2.0 MINIMUM, BASIC, EXTENDED | CII in PDF/A-3 | ❌ | ✅ BASIC, EXTENDED (EXTENDED-only content listed as unmapped; corpus EXTENDED samples with codes outside the CEN lists, ABK BR-CL-19 and 9958 BR-CL-25, raise ParseError like their 2.1 twins, #69); ❌ MINIMUM |
❌ |
-
Parse means read into the EN 16931 model. Readers never drop input silently: every element or attribute without a business term is listed in
parse_detailed(...).unmapped. -
Validate reports the raw severities of the official rule sets in
report.findingsandreport.ok. For the XRechnung profiles,report.kositadds the verdict of the KoSIT validator, which applies its per-scenario severity overrides (#49): for example, 2 of the 6 official XRechnung Extension instances get fatal findings that KoSIT downgrades, soreport.okisFalseandreport.kosit.acceptedisTrue. -
XRechnung CVD: BR-DE-CVD-03 (fatal,
XRechnung-UBL-validation.schlines 560-562) needs an item classification with list idCVD, which BR-CL-13 (fatal, CENEN16931-UBL-codes.schlines 67-68) and therefore the model refuse. No conforming CVD invoice can be built or read.validate()rejects every CVD document: BR-CL-13 when it carries theCVDitem classification, BR-DE-CVD-03 (fatal) when it does not. KoSIT downgrades BR-CL-13 in its CVD scenarios, soreport.kosit.acceptedisTruefor the official CVD instances (#49). -
Factur-X: the rows above cover the Factur-X 1.0 / ZUGFeRD 2.1+ BT-24s.
facturx.embed/facturx.extractwrite and read the PDF container (the[pdf]extra). The Factur-X XSD and Schematron are not pinned yet (#42), sovalidate()checks the embedded XML of the EN 16931 and XRECHNUNG levels against the EN 16931 / XRechnung rules only, and raisesArtifactsNotAvailableErrorfor MINIMUM, BASIC WL, BASIC and EXTENDED. MINIMUM and BASIC WL carry no invoice lines, so they cannot become anInvoice:parse()raisesParseError(#69). The library does not check PDF/A-3 conformance (the test suite runs veraPDF onembed()output). ZUGFeRD 2.0 PDFs are extract-only (not generated). Their MINIMUM, BASIC and EXTENDED BT-24s (urn:zugferd.de:2p0:*) and the colon spellings of BASIC and EXTENDED (urn:cen.eu:en16931:2017:compliant:factur-x.eu:1p0:*) are not registered as profiles, butvalidate()raisesArtifactsNotAvailableErrorfor them as for the levels above (CLI exit 2; #98). A ZUGFeRD 2.0 EN 16931 invoice carries the core BT-24 and is validated as EN 16931 core. ZUGFeRD 1.0 PDFs are extract-only (parse()raisesUnsupportedDocumentError). -
FatturaPA (Italy's SdI format) is not an EN 16931 syntax and has no BT-24, so it takes no profile. The data with no business term lives in
Invoice.it/InvoiceLine.it; the UBL and CII writers refuse it unless it is dropped witheuinvoice.model.without_extensions().to_xml(invoice, syntax="fatturapa", fatturapa_transmission=...)writes FPR12.validate()runs the XSD 1.2.3 and euinvoice's encoding of the offline SdI checks (Allegato A 1.9.1, the SdI code asrule_id). It does not run the registry, SdI-state, file-name, file-size or signature checks, so SdI can still reject a filevalidate()accepts. Where Allegato A is explicit its scope is kept, sovalidate()can also report 00418 or 00471 for a document SdI accepts (#130). Signing and sending to SdI are out of scope. See docs/fatturapa.md.
Planned later: ebInterface, more national CIUSes (RO, HR, FR, DK, …), KSeF, Facturae, and clearance/transport integrations.
uv add euinvoice # model + UBL/CII serialize/parse
uv add 'euinvoice[validate]' # + official Schematron validation (SaxonC-HE)
uv add 'euinvoice[pdf]' # + Factur-X / ZUGFeRD PDF embeddingThe official validation artifacts are downloaded, not bundled, because of their licences. Fetch them once (pinned and sha256-verified):
python -m euinvoice artifacts fetch # cache: $EUINVOICE_ARTIFACTS_DIR or ~/.cache/euinvoicedraft is an euinvoice.InvoiceDraft: the invoice without its derived totals. These examples run as tests
(tests/_readme.py builds the synthetic XRechnung draft they use); see the quickstart for
building a draft.
>>> from euinvoice import calc, detect, parse, parse_detailed, profiles, to_xml, validate
>>> invoice = calc.complete(draft) # derive line totals, document totals and the VAT breakdown (EN 16931)
>>> xml = to_xml(invoice, profile=profiles.XRECHNUNG, syntax="cii") # raises PreflightError on blocking findings
>>> parse(xml) == profiles.XRECHNUNG.prepare(invoice) # lossless; to_xml wrote the profile's BT-24 (prepare)
True
>>> parse_detailed(xml).unmapped # XPaths of input with no business term; parse() discards them
()
>>> found = detect(xml) # syntax and profile from the root element and BT-24
>>> found.syntax, found.profile.id
(<Syntax.CII: 'cii'>, 'xrechnung')Validation needs the [validate] extra and the fetched artifacts. validate() takes XML; for a PDF, pass
facturx.extract(pdf).xml or use the CLI.
>>> report = validate(xml) # profile auto-detected from BT-24
>>> report.ok
True
>>> for finding in report.findings:
... print(finding.rule_id, finding.severity, finding.message)Factur-X / ZUGFeRD needs the [pdf] extra:
>>> from euinvoice import facturx
>>> # your renderer produces the human-readable PDF/A-3 (e.g. WeasyPrint pdf_variant="pdf/a-3b")
>>> hybrid_pdf = facturx.embed(rendered_pdf, invoice, profile=profiles.FACTURX_EN16931)
>>> found = facturx.extract(hybrid_pdf)
>>> found.filename, found.conformance_level, found.profile.id
('factur-x.xml', 'EN 16931', 'facturx-en16931')
>>> parse(hybrid_pdf) == profiles.FACTURX_EN16931.prepare(invoice) # parse() reads PDFs too
Truepython -m euinvoice wraps the same functions. FILE is UBL, CII, FatturaPA or a Factur-X / ZUGFeRD PDF (-
reads stdin), and --profile takes a profile id such as xrechnung or peppol.
python -m euinvoice validate invoice.xml [--profile ID] [--json] # one line per finding, then the verdict
python -m euinvoice convert invoice.xml --to ubl|cii [--profile ID] [--drop-extensions] [-o OUT]
python -m euinvoice convert fattura.xml --to fatturapa --transmitter IT01234567890 --transmission-number 00001 \
[--recipient-code CODE] [--pec ADDRESS] [-o OUT]
python -m euinvoice info invoice.pdf [--json] # syntax, BT-24, profile, container, totals
python -m euinvoice artifacts fetch [--only NAME]Exit codes: 0 success (warnings allowed); 1 the document was rejected (a fatal or error finding, a
refused conversion, an unreadable invoice) or an artifact failed its integrity check; 2 no verdict (usage
error, unknown profile, unreadable file, failed download, missing artifacts or extras, a Factur-X level whose
rules are not pinned, a convert flag that does not apply to --to). --help on each subcommand lists them.
convert --to ubl|cii refuses an invoice read from FatturaPA unless --drop-extensions is given, and then lists
each dropped Invoice.it path on stderr. info and convert refuse a FatturaPA lotto (several invoices in one
file); validate checks all of them.
The docs/ folder holds the guide: quickstart,
concepts, validation, Factur-X,
FatturaPA, a
mapping guide with synthetic freelancer and ticketing examples, and the
BT mapping. It builds with make docs (mkdocs-material). The GitHub Pages site goes live
once the maintainer enables Pages for the repository and sets the repository variable DOCS_DEPLOY=true.
Limitations and open questions (some wait for a maintainer decision, needs-human):
- #42: the Factur-X / ZUGFeRD XSD and Schematron have no pinnable official download, so Factur-X levels are not validated against Factur-X rules (see above).
- #69: Factur-X MINIMUM and BASIC WL cannot be read into the model.
- FatturaPA: #133 (the writer refuses what the SdI rules do not settle, e.g. generic document level allowances and charges and VAT categories O, L, M), and #132 (the reader's mapping policies, e.g. it refuses the generic Natura N2, N3 and N6).
- FatturaPA SdI checks (#130, decided):
validate()can report 00418 or 00471 for a document SdI accepts (Allegato A's wider scope is kept where it is explicit), and parts of 00472 and 00473 are not checked. See docs/fatturapa.md. - #40: the hardened parser rejects a single text node over 10,000,000 bytes, i.e. a BT-125 attachment over about 7.5 MB.
make setup # uv sync --all-extras --group dev
make check # ruff + mypy --strict + file length
make test # unit tests, ≥95% branch coverage
make artifacts # fetch official artifacts + corpora
make conformance # run themSee CONTRIBUTING.md. Security reports: SECURITY.md.
MIT. Official validation artifacts are third-party works under their own licences (EUPL-1.2, Apache-2.0 and others). They are fetched at runtime and never redistributed by this package.