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.

Outcome

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.