rechnungskit — German electronic invoicing in Python
A typed library to build, serialise, embed and validate XRechnung and ZUGFeRD / Factur-X on the EN 16931 semantic model, with machine-readable validation reports.
217 rules implemented — 185 from EN 16931 and 32 BR-DE — with 214 tests and mypy strict, and a named list of what is not covered instead of a silent pass.
The problem
Since January 2025 receiving a structured electronic invoice is mandatory for
German B2B, and issuing one is being phased in. In practice an invoice has to
satisfy three layers at once: the EN 16931 semantic model, a national narrowing
of it (XRechnung 3.0 and its BR-DE-* rules), and a syntax — the same model
written as either UBL 2.1 or CII D16B, with different element names for every
field. It can arrive as bare XML or as a hybrid PDF, in whichever syntax the
sender preferred. Getting it wrong is not a soft failure: the invoice is
rejected at the portal by an automated validator quoting a rule identifier like
BR-CO-13, and you need to know what that means and where it happened.
A layered package whose dependencies point one way only. domain reads no file,
opens no socket, parses no XML and looks at no clock; syntax, validation and
pdf sit above it, and the CLI and the HTTP service are thin shells over both.
Rules run against the model, not against XPath. Most tooling here wraps the
official Schematron, which is written per syntax — so the same rule exists twice
and an invoice can only be checked after it has been serialised. A rule here is
a small pure function from Invoice to the problems it finds. One rule serves
both UBL and CII, an invoice can be validated before it exists as a document,
and GET /v1/rules is generated from the rules rather than being a second list
to keep in step.
Mandatory-ness is a property of the standard, not of the type system.
Invoice.number is str | None even though BR-02 requires it: to report
BR-02 you must first be able to hold a document that has no invoice number. A
model that refused could only raise a parse error, which tells a caller nothing
about which rule broke. The same reasoning keeps codes as plain strings — an
invoice carrying "XX" as a VAT category has to survive long enough to be
reported as BR-CL-18.
Money is Decimal, and a float is refused rather than coerced. An invoice
one cent out is a rejected invoice. The subtler reason: a Decimal remembers
the scale it was written with, which is exactly what the BR-DEC-* rules ask
about, so amounts are parsed from the lexical form and never normalised —
normalising would erase the defect those rules exist to find.
Each syntax is one module holding both directions. The mapping between a business term and an element path is one fact; splitting the writer and the reader across two files is how they come to disagree. The round-trip tests compare the whole model rather than a handful of fields, and earned their keep immediately by catching a field the CII writer dropped and another lost on a UBL credit note.
Every input is treated as hostile: <!DOCTYPE> is refused before parsing, no
network and no DTD, byte/depth/element ceilings, and one configured call to
etree.fromstring in the whole package. Errors are RFC 9457 problem details,
and the two conventions are deliberate — generation refuses to write a
non-conformant invoice, and validation never refuses: finding fifty errors is a
successful request, so it is 200 with valid: false.
The known limitations are named rather than left to be discovered — the rule families that are not implemented, the absence of XSD validation, no Peppol — because a validator that quietly passes what it did not check is worse than no validator.