xrechnung 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xrechnung-0.1.0/.gitignore +5 -0
- xrechnung-0.1.0/LICENSE +21 -0
- xrechnung-0.1.0/PKG-INFO +134 -0
- xrechnung-0.1.0/README.md +111 -0
- xrechnung-0.1.0/pyproject.toml +37 -0
- xrechnung-0.1.0/src/xrechnung/__init__.py +186 -0
- xrechnung-0.1.0/src/xrechnung/py.typed +0 -0
- xrechnung-0.1.0/tests/test_client.py +167 -0
- xrechnung-0.1.0/zugferd/LICENSE +21 -0
- xrechnung-0.1.0/zugferd/README.md +26 -0
xrechnung-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xrechnung.dev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
xrechnung-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: xrechnung
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the xrechnung.dev API: generate, validate (official KoSIT rules) and parse XRechnung and ZUGFeRD e-invoices. Zero dependencies, no JVM required.
|
|
5
|
+
Project-URL: Homepage, https://xrechnung.dev
|
|
6
|
+
Project-URL: Documentation, https://xrechnung.dev/xrechnung-api/
|
|
7
|
+
Project-URL: API reference, https://xrechnung.dev/docs
|
|
8
|
+
Project-URL: Error codes, https://xrechnung.dev/xrechnung-fehlercodes/
|
|
9
|
+
Author-email: "xrechnung.dev" <dev@xrechnung.dev>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: e-rechnung,einvoice,en16931,factur-x,kosit,validator,xrechnung,zugferd
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Natural Language :: English
|
|
16
|
+
Classifier: Natural Language :: German
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Topic :: Office/Business :: Financial :: Accounting
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# xrechnung
|
|
25
|
+
|
|
26
|
+
Python client for the [xrechnung.dev](https://xrechnung.dev) REST API: **generate, validate and parse**
|
|
27
|
+
German EN 16931 e-invoices — XRechnung 3.0 XML and ZUGFeRD 2.x PDF/A-3 — with one function call.
|
|
28
|
+
|
|
29
|
+
- **No JVM required.** Validation runs server-side; you don't install the KoSIT validator or Java.
|
|
30
|
+
- **Always validated with the current official KoSIT rules (XRechnung 3.0.2, Schematron 2.6.0).**
|
|
31
|
+
Every generated document is checked before it is returned, so you never receive an invalid invoice.
|
|
32
|
+
- **Zero dependencies.** Standard library only (`urllib`), Python 3.9+, fully typed.
|
|
33
|
+
- **Free tier:** 10 documents per month without an API key.
|
|
34
|
+
- Every error links to a plain-language explanation, e.g. `BR-DE-15` → https://xrechnung.dev/fehler/br-de-15/
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install xrechnung
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Validate
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import xrechnung
|
|
44
|
+
|
|
45
|
+
result = xrechnung.validate("rechnung.xml") # path, or raw bytes; XML or ZUGFeRD PDF
|
|
46
|
+
print(result.valid, result.scenario)
|
|
47
|
+
for e in result.errors:
|
|
48
|
+
print(e.code, e.message, e.location)
|
|
49
|
+
print(" how to fix:", e.doc_url) # https://xrechnung.dev/fehler/br-de-15/
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`result.warnings` has the same shape; `result.raw` is the full JSON report.
|
|
53
|
+
|
|
54
|
+
## Generate
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
invoice = {
|
|
58
|
+
"number": "RE-2026-0001", "issue_date": "2026-09-07",
|
|
59
|
+
"seller": {"name": "Beispiel GmbH", "vat_id": "DE123456789", "email": "rechnung@beispiel.de",
|
|
60
|
+
"phone": "+49 30 1234567", "contact_name": "Erika Muster",
|
|
61
|
+
"address": {"street": "Musterstr. 1", "postcode": "10115", "city": "Berlin"}},
|
|
62
|
+
"buyer": {"name": "Kunde AG", "address": {"street": "Kundenweg 9", "postcode": "80331", "city": "München"}},
|
|
63
|
+
"buyer_reference": "04011000-12345-67",
|
|
64
|
+
"lines": [{"description": "Beratung", "quantity": "10", "unit": "HUR", "unit_price": "120.00", "vat_rate": "19"}],
|
|
65
|
+
"payment": {"iban": "DE02120300000000202051", "due_date": "2026-09-21", "terms": "14 Tage netto"},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
xml = xrechnung.generate(invoice) # XRechnung (CII XML) as bytes
|
|
69
|
+
pdf = xrechnung.generate(invoice, format="zugferd", language="en") # ZUGFeRD PDF/A-3 as bytes
|
|
70
|
+
report = xrechnung.generate(invoice, output="json") # dict: base64 file + validation report
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`Decimal` and `date` values are serialised as strings, so you can pass them directly.
|
|
74
|
+
The invoice fields follow the [OpenAPI schema](https://xrechnung.dev/docs) (tax categories S/Z/E/AE/K/G/O,
|
|
75
|
+
`small_business` for §19 UStG, credit notes via `type_code` 381 and `preceding_invoice_number`).
|
|
76
|
+
|
|
77
|
+
## Parse
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
data = xrechnung.parse("eingang.pdf") # {"syntax": "CII", "number": ..., "seller": {...}, "lines": [...], ...}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## API key, usage, errors
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
client = xrechnung.Client(api_key="xr_live_...") # or set XRECHNUNG_API_KEY
|
|
87
|
+
client.validate(b"<Invoice ...>")
|
|
88
|
+
client.usage() # this month's usage
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
| HTTP | Exception | Meaning |
|
|
92
|
+
|------|-----------|---------|
|
|
93
|
+
| 401 | `xrechnung.AuthenticationError` | invalid or inactive API key |
|
|
94
|
+
| 402 | `xrechnung.PaymentRequiredError` | payment / plan required |
|
|
95
|
+
| 429 | `xrechnung.RateLimitError` | monthly limit reached or too many requests |
|
|
96
|
+
| other | `xrechnung.XRechnungError` | base class; `.status`, `.message` (the server's text), `.body` |
|
|
97
|
+
|
|
98
|
+
Limits: 5 MB per document, 60 s timeout. Plans: Free 10 / Developer 200 / Business 1,000 / Unlimited
|
|
99
|
+
documents per month — see https://xrechnung.dev/#pricing.
|
|
100
|
+
|
|
101
|
+
The same client is also published as [`zugferd`](https://pypi.org/project/zugferd/) (`import zugferd`).
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Deutsch
|
|
106
|
+
|
|
107
|
+
Python-Client für die [xrechnung.dev](https://xrechnung.dev)-API: **E-Rechnungen erzeugen, prüfen und lesen**
|
|
108
|
+
— XRechnung 3.0 (XML) und ZUGFeRD 2.x (PDF/A-3).
|
|
109
|
+
|
|
110
|
+
- **Keine JVM nötig.** Sie installieren weder Java noch den KoSIT-Validator.
|
|
111
|
+
- **Immer mit den aktuellen offiziellen KoSIT-Regeln geprüft (XRechnung 3.0.2, Schematron 2.6.0).**
|
|
112
|
+
Jede erzeugte Rechnung wird vor der Auslieferung validiert.
|
|
113
|
+
- **Keine Abhängigkeiten**, nur Standardbibliothek, Python ab 3.9.
|
|
114
|
+
- **Kostenlos:** 10 Dokumente pro Monat ohne API-Schlüssel.
|
|
115
|
+
- Zu jedem Fehlercode gibt es eine Erklärung mit Lösung, z. B. https://xrechnung.dev/fehler/br-de-15/
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
import xrechnung
|
|
119
|
+
|
|
120
|
+
ergebnis = xrechnung.validate("rechnung.xml")
|
|
121
|
+
if not ergebnis.valid:
|
|
122
|
+
for f in ergebnis.errors:
|
|
123
|
+
print(f.code, f.message, f.doc_url)
|
|
124
|
+
|
|
125
|
+
pdf = xrechnung.generate(rechnung, format="zugferd") # ZUGFeRD-PDF
|
|
126
|
+
open("RE-2026-0001.pdf", "wb").write(pdf)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Fehler 401 (Schlüssel ungültig), 402 (Zahlung erforderlich) und 429 (Monatslimit erreicht) werden als
|
|
130
|
+
eigene Exceptions mit der Meldung des Servers ausgelöst.
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
MIT
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# xrechnung
|
|
2
|
+
|
|
3
|
+
Python client for the [xrechnung.dev](https://xrechnung.dev) REST API: **generate, validate and parse**
|
|
4
|
+
German EN 16931 e-invoices — XRechnung 3.0 XML and ZUGFeRD 2.x PDF/A-3 — with one function call.
|
|
5
|
+
|
|
6
|
+
- **No JVM required.** Validation runs server-side; you don't install the KoSIT validator or Java.
|
|
7
|
+
- **Always validated with the current official KoSIT rules (XRechnung 3.0.2, Schematron 2.6.0).**
|
|
8
|
+
Every generated document is checked before it is returned, so you never receive an invalid invoice.
|
|
9
|
+
- **Zero dependencies.** Standard library only (`urllib`), Python 3.9+, fully typed.
|
|
10
|
+
- **Free tier:** 10 documents per month without an API key.
|
|
11
|
+
- Every error links to a plain-language explanation, e.g. `BR-DE-15` → https://xrechnung.dev/fehler/br-de-15/
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install xrechnung
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Validate
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
import xrechnung
|
|
21
|
+
|
|
22
|
+
result = xrechnung.validate("rechnung.xml") # path, or raw bytes; XML or ZUGFeRD PDF
|
|
23
|
+
print(result.valid, result.scenario)
|
|
24
|
+
for e in result.errors:
|
|
25
|
+
print(e.code, e.message, e.location)
|
|
26
|
+
print(" how to fix:", e.doc_url) # https://xrechnung.dev/fehler/br-de-15/
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`result.warnings` has the same shape; `result.raw` is the full JSON report.
|
|
30
|
+
|
|
31
|
+
## Generate
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
invoice = {
|
|
35
|
+
"number": "RE-2026-0001", "issue_date": "2026-09-07",
|
|
36
|
+
"seller": {"name": "Beispiel GmbH", "vat_id": "DE123456789", "email": "rechnung@beispiel.de",
|
|
37
|
+
"phone": "+49 30 1234567", "contact_name": "Erika Muster",
|
|
38
|
+
"address": {"street": "Musterstr. 1", "postcode": "10115", "city": "Berlin"}},
|
|
39
|
+
"buyer": {"name": "Kunde AG", "address": {"street": "Kundenweg 9", "postcode": "80331", "city": "München"}},
|
|
40
|
+
"buyer_reference": "04011000-12345-67",
|
|
41
|
+
"lines": [{"description": "Beratung", "quantity": "10", "unit": "HUR", "unit_price": "120.00", "vat_rate": "19"}],
|
|
42
|
+
"payment": {"iban": "DE02120300000000202051", "due_date": "2026-09-21", "terms": "14 Tage netto"},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
xml = xrechnung.generate(invoice) # XRechnung (CII XML) as bytes
|
|
46
|
+
pdf = xrechnung.generate(invoice, format="zugferd", language="en") # ZUGFeRD PDF/A-3 as bytes
|
|
47
|
+
report = xrechnung.generate(invoice, output="json") # dict: base64 file + validation report
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`Decimal` and `date` values are serialised as strings, so you can pass them directly.
|
|
51
|
+
The invoice fields follow the [OpenAPI schema](https://xrechnung.dev/docs) (tax categories S/Z/E/AE/K/G/O,
|
|
52
|
+
`small_business` for §19 UStG, credit notes via `type_code` 381 and `preceding_invoice_number`).
|
|
53
|
+
|
|
54
|
+
## Parse
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
data = xrechnung.parse("eingang.pdf") # {"syntax": "CII", "number": ..., "seller": {...}, "lines": [...], ...}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## API key, usage, errors
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
client = xrechnung.Client(api_key="xr_live_...") # or set XRECHNUNG_API_KEY
|
|
64
|
+
client.validate(b"<Invoice ...>")
|
|
65
|
+
client.usage() # this month's usage
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
| HTTP | Exception | Meaning |
|
|
69
|
+
|------|-----------|---------|
|
|
70
|
+
| 401 | `xrechnung.AuthenticationError` | invalid or inactive API key |
|
|
71
|
+
| 402 | `xrechnung.PaymentRequiredError` | payment / plan required |
|
|
72
|
+
| 429 | `xrechnung.RateLimitError` | monthly limit reached or too many requests |
|
|
73
|
+
| other | `xrechnung.XRechnungError` | base class; `.status`, `.message` (the server's text), `.body` |
|
|
74
|
+
|
|
75
|
+
Limits: 5 MB per document, 60 s timeout. Plans: Free 10 / Developer 200 / Business 1,000 / Unlimited
|
|
76
|
+
documents per month — see https://xrechnung.dev/#pricing.
|
|
77
|
+
|
|
78
|
+
The same client is also published as [`zugferd`](https://pypi.org/project/zugferd/) (`import zugferd`).
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Deutsch
|
|
83
|
+
|
|
84
|
+
Python-Client für die [xrechnung.dev](https://xrechnung.dev)-API: **E-Rechnungen erzeugen, prüfen und lesen**
|
|
85
|
+
— XRechnung 3.0 (XML) und ZUGFeRD 2.x (PDF/A-3).
|
|
86
|
+
|
|
87
|
+
- **Keine JVM nötig.** Sie installieren weder Java noch den KoSIT-Validator.
|
|
88
|
+
- **Immer mit den aktuellen offiziellen KoSIT-Regeln geprüft (XRechnung 3.0.2, Schematron 2.6.0).**
|
|
89
|
+
Jede erzeugte Rechnung wird vor der Auslieferung validiert.
|
|
90
|
+
- **Keine Abhängigkeiten**, nur Standardbibliothek, Python ab 3.9.
|
|
91
|
+
- **Kostenlos:** 10 Dokumente pro Monat ohne API-Schlüssel.
|
|
92
|
+
- Zu jedem Fehlercode gibt es eine Erklärung mit Lösung, z. B. https://xrechnung.dev/fehler/br-de-15/
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
import xrechnung
|
|
96
|
+
|
|
97
|
+
ergebnis = xrechnung.validate("rechnung.xml")
|
|
98
|
+
if not ergebnis.valid:
|
|
99
|
+
for f in ergebnis.errors:
|
|
100
|
+
print(f.code, f.message, f.doc_url)
|
|
101
|
+
|
|
102
|
+
pdf = xrechnung.generate(rechnung, format="zugferd") # ZUGFeRD-PDF
|
|
103
|
+
open("RE-2026-0001.pdf", "wb").write(pdf)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Fehler 401 (Schlüssel ungültig), 402 (Zahlung erforderlich) und 429 (Monatslimit erreicht) werden als
|
|
107
|
+
eigene Exceptions mit der Meldung des Servers ausgelöst.
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "xrechnung"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for the xrechnung.dev API: generate, validate (official KoSIT rules) and parse XRechnung and ZUGFeRD e-invoices. Zero dependencies, no JVM required."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "xrechnung.dev", email = "dev@xrechnung.dev" }]
|
|
14
|
+
keywords = ["xrechnung", "zugferd", "factur-x", "e-rechnung", "einvoice", "en16931", "kosit", "validator"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Natural Language :: English",
|
|
19
|
+
"Natural Language :: German",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Topic :: Office/Business :: Financial :: Accounting",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
dependencies = []
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://xrechnung.dev"
|
|
29
|
+
Documentation = "https://xrechnung.dev/xrechnung-api/"
|
|
30
|
+
"API reference" = "https://xrechnung.dev/docs"
|
|
31
|
+
"Error codes" = "https://xrechnung.dev/xrechnung-fehlercodes/"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/xrechnung"]
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.sdist]
|
|
37
|
+
include = ["src/xrechnung", "tests", "README.md", "LICENSE"]
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Python client for the xrechnung.dev REST API.
|
|
2
|
+
|
|
3
|
+
Generate, validate (official KoSIT rules) and parse German EN 16931 e-invoices:
|
|
4
|
+
XRechnung 3.0 XML and ZUGFeRD 2.x PDF/A-3. Zero dependencies, no JVM required.
|
|
5
|
+
|
|
6
|
+
import xrechnung
|
|
7
|
+
result = xrechnung.validate("rechnung.xml")
|
|
8
|
+
for e in result.errors:
|
|
9
|
+
print(e.code, e.message, e.doc_url)
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import uuid
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.request
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Dict, List, Optional, Union
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Client", "Finding", "ValidationResult", "XRechnungError", "AuthenticationError",
|
|
26
|
+
"PaymentRequiredError", "RateLimitError", "validate", "parse", "generate", "usage", "doc_url",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
DEFAULT_BASE_URL = "https://xrechnung.dev"
|
|
30
|
+
FileInput = Union[bytes, bytearray, str, "os.PathLike[str]"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class XRechnungError(Exception):
|
|
34
|
+
"""Any non-2xx answer from the API. `message` is the server's own text."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, status: int, message: str, body: Any = None):
|
|
37
|
+
super().__init__(f"HTTP {status}: {message}")
|
|
38
|
+
self.status, self.message, self.body = status, message, body
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AuthenticationError(XRechnungError):
|
|
42
|
+
"""401: invalid or inactive API key."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PaymentRequiredError(XRechnungError):
|
|
46
|
+
"""402: plan or payment required."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class RateLimitError(XRechnungError):
|
|
50
|
+
"""429: monthly document limit reached or too many requests per second."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_ERRORS = {401: AuthenticationError, 402: PaymentRequiredError, 429: RateLimitError}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def doc_url(code: str) -> str:
|
|
57
|
+
"""Explanation page for a rule code, e.g. BR-DE-15 -> https://xrechnung.dev/fehler/br-de-15/"""
|
|
58
|
+
return f"https://xrechnung.dev/fehler/{re.sub(r'-[ab]$', '', code.strip().lower())}/"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Finding:
|
|
63
|
+
code: str
|
|
64
|
+
message: str
|
|
65
|
+
location: str = ""
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def doc_url(self) -> str:
|
|
69
|
+
return doc_url(self.code)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _findings(items: Any) -> List[Finding]:
|
|
73
|
+
return [Finding(str(i.get("code", "")), str(i.get("message", "")), str(i.get("location") or ""))
|
|
74
|
+
for i in items or []]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class ValidationResult:
|
|
79
|
+
valid: bool
|
|
80
|
+
scenario: str = ""
|
|
81
|
+
profile: str = ""
|
|
82
|
+
errors: List[Finding] = field(default_factory=list)
|
|
83
|
+
warnings: List[Finding] = field(default_factory=list)
|
|
84
|
+
raw: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
85
|
+
|
|
86
|
+
def __bool__(self) -> bool:
|
|
87
|
+
return self.valid
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def from_dict(cls, d: Dict[str, Any]) -> "ValidationResult":
|
|
91
|
+
return cls(bool(d.get("valid")), d.get("scenario", ""), d.get("profile", ""),
|
|
92
|
+
_findings(d.get("errors")), _findings(d.get("warnings")), d)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _read(file: FileInput) -> "tuple[bytes, str]":
|
|
96
|
+
if isinstance(file, (bytes, bytearray)):
|
|
97
|
+
data = bytes(file)
|
|
98
|
+
return data, "invoice.pdf" if data[:5] == b"%PDF-" else "invoice.xml"
|
|
99
|
+
p = Path(file)
|
|
100
|
+
return p.read_bytes(), p.name
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _multipart(data: bytes, filename: str) -> "tuple[bytes, str]":
|
|
104
|
+
boundary = uuid.uuid4().hex
|
|
105
|
+
name = filename.replace('"', "").replace("\r", "").replace("\n", "")
|
|
106
|
+
body = (f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{name}"\r\n'
|
|
107
|
+
f"Content-Type: application/octet-stream\r\n\r\n").encode() + data + f"\r\n--{boundary}--\r\n".encode()
|
|
108
|
+
return body, f"multipart/form-data; boundary={boundary}"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Client:
|
|
112
|
+
"""API client. `api_key` defaults to $XRECHNUNG_API_KEY; without a key the free tier
|
|
113
|
+
allows 10 documents per month per IP."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, api_key: Optional[str] = None, base_url: str = DEFAULT_BASE_URL, timeout: float = 90):
|
|
116
|
+
self.api_key = api_key if api_key is not None else os.environ.get("XRECHNUNG_API_KEY") or None
|
|
117
|
+
self.base_url = base_url.rstrip("/")
|
|
118
|
+
self.timeout = timeout
|
|
119
|
+
|
|
120
|
+
def _request(self, method: str, path: str, body: Optional[bytes] = None,
|
|
121
|
+
content_type: Optional[str] = None) -> "tuple[bytes, str]":
|
|
122
|
+
headers = {"Accept": "application/json, application/xml, application/pdf",
|
|
123
|
+
"User-Agent": f"xrechnung-python/{__version__}"}
|
|
124
|
+
if content_type:
|
|
125
|
+
headers["Content-Type"] = content_type
|
|
126
|
+
if self.api_key:
|
|
127
|
+
headers["X-Api-Key"] = self.api_key
|
|
128
|
+
req = urllib.request.Request(self.base_url + path, data=body, headers=headers, method=method)
|
|
129
|
+
try:
|
|
130
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as r:
|
|
131
|
+
return r.read(), r.headers.get("Content-Type", "")
|
|
132
|
+
except urllib.error.HTTPError as e:
|
|
133
|
+
raw = e.read()
|
|
134
|
+
try:
|
|
135
|
+
payload: Any = json.loads(raw)
|
|
136
|
+
detail = payload.get("detail", payload) if isinstance(payload, dict) else payload
|
|
137
|
+
except ValueError:
|
|
138
|
+
payload = detail = raw.decode("utf-8", "replace")
|
|
139
|
+
if isinstance(detail, list): # 422 from request validation
|
|
140
|
+
detail = "; ".join(f"{'.'.join(map(str, d.get('loc', [])))}: {d.get('msg')}"
|
|
141
|
+
if isinstance(d, dict) else str(d) for d in detail)
|
|
142
|
+
raise _ERRORS.get(e.code, XRechnungError)(e.code, str(detail) or str(e.reason), payload) from None
|
|
143
|
+
|
|
144
|
+
def _json(self, method: str, path: str, body: Optional[bytes] = None, ct: Optional[str] = None) -> Any:
|
|
145
|
+
return json.loads(self._request(method, path, body, ct)[0])
|
|
146
|
+
|
|
147
|
+
def validate(self, file: FileInput) -> ValidationResult:
|
|
148
|
+
"""Validate an XRechnung / ZUGFeRD / EN 16931 document (XML or PDF): bytes or a path."""
|
|
149
|
+
return ValidationResult.from_dict(self._json("POST", "/v1/validate", *_multipart(*_read(file))))
|
|
150
|
+
|
|
151
|
+
def parse(self, file: FileInput) -> Dict[str, Any]:
|
|
152
|
+
"""Read an e-invoice (XML or ZUGFeRD PDF) into a dict."""
|
|
153
|
+
return self._json("POST", "/v1/parse", *_multipart(*_read(file)))
|
|
154
|
+
|
|
155
|
+
def generate(self, invoice: Dict[str, Any], format: str = "xrechnung", language: str = "de",
|
|
156
|
+
output: str = "binary") -> Any:
|
|
157
|
+
"""Generate a KoSIT-validated invoice.
|
|
158
|
+
|
|
159
|
+
format: "xrechnung" (CII XML) or "zugferd" (PDF/A-3); language: "de"/"en" (zugferd PDF only).
|
|
160
|
+
output="binary" returns the file as bytes; output="json" returns a dict with base64 + validation report.
|
|
161
|
+
"""
|
|
162
|
+
body = json.dumps({"invoice": invoice, "format": format, "language": language, "output": output},
|
|
163
|
+
default=str).encode()
|
|
164
|
+
data, _ = self._request("POST", "/v1/invoices", body, "application/json")
|
|
165
|
+
return json.loads(data) if output == "json" else data
|
|
166
|
+
|
|
167
|
+
def usage(self) -> Dict[str, Any]:
|
|
168
|
+
"""Current month usage for your key (or your IP on the free tier)."""
|
|
169
|
+
return self._json("GET", "/v1/usage")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def validate(file: FileInput, api_key: Optional[str] = None) -> ValidationResult:
|
|
173
|
+
return Client(api_key).validate(file)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def parse(file: FileInput, api_key: Optional[str] = None) -> Dict[str, Any]:
|
|
177
|
+
return Client(api_key).parse(file)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def generate(invoice: Dict[str, Any], format: str = "xrechnung", language: str = "de",
|
|
181
|
+
output: str = "binary", api_key: Optional[str] = None) -> Any:
|
|
182
|
+
return Client(api_key).generate(invoice, format, language, output)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def usage(api_key: Optional[str] = None) -> Dict[str, Any]:
|
|
186
|
+
return Client(api_key).usage()
|
|
File without changes
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Offline tests: urllib.request.urlopen is mocked, no network calls."""
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
import unittest
|
|
7
|
+
import urllib.error
|
|
8
|
+
from email.message import Message
|
|
9
|
+
from unittest import mock
|
|
10
|
+
|
|
11
|
+
import xrechnung
|
|
12
|
+
|
|
13
|
+
REPORT = {"valid": False, "scenario": "EN16931 XRechnung (CII)",
|
|
14
|
+
"errors": [{"code": "BR-DE-15", "message": "Buyer reference fehlt", "location": "/rsm:CII"},
|
|
15
|
+
{"code": "BR-CO-10-a", "message": "Summe", "location": "/x"}],
|
|
16
|
+
"warnings": [{"code": "PEPPOL-EN16931-R005", "message": "w", "location": None}],
|
|
17
|
+
"counts": {"errors": 2, "warnings": 1}}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FakeResp(io.BytesIO):
|
|
21
|
+
def __init__(self, data, ctype="application/json"):
|
|
22
|
+
super().__init__(data)
|
|
23
|
+
self.headers = {"Content-Type": ctype}
|
|
24
|
+
|
|
25
|
+
def __enter__(self): return self
|
|
26
|
+
def __exit__(self, *a): self.close()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def http_error(code, body):
|
|
30
|
+
return urllib.error.HTTPError("https://xrechnung.dev/x", code, "err", Message(), io.BytesIO(body))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Base(unittest.TestCase):
|
|
34
|
+
def setUp(self):
|
|
35
|
+
p = mock.patch("urllib.request.urlopen")
|
|
36
|
+
self.urlopen = p.start()
|
|
37
|
+
self.addCleanup(p.stop)
|
|
38
|
+
os.environ.pop("XRECHNUNG_API_KEY", None)
|
|
39
|
+
|
|
40
|
+
def reply(self, data, ctype="application/json"):
|
|
41
|
+
self.urlopen.return_value = FakeResp(json.dumps(data).encode() if not isinstance(data, bytes) else data, ctype)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def req(self):
|
|
45
|
+
return self.urlopen.call_args[0][0]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TestValidate(Base):
|
|
49
|
+
def test_bytes_result_and_doc_urls(self):
|
|
50
|
+
self.reply(REPORT)
|
|
51
|
+
r = xrechnung.validate(b"<Invoice/>")
|
|
52
|
+
self.assertFalse(r.valid)
|
|
53
|
+
self.assertFalse(r)
|
|
54
|
+
self.assertEqual(r.scenario, "EN16931 XRechnung (CII)")
|
|
55
|
+
self.assertEqual([e.code for e in r.errors], ["BR-DE-15", "BR-CO-10-a"])
|
|
56
|
+
self.assertEqual(r.errors[0].location, "/rsm:CII")
|
|
57
|
+
self.assertEqual(r.errors[0].doc_url, "https://xrechnung.dev/fehler/br-de-15/")
|
|
58
|
+
self.assertEqual(r.errors[1].doc_url, "https://xrechnung.dev/fehler/br-co-10/")
|
|
59
|
+
self.assertEqual(r.warnings[0].location, "")
|
|
60
|
+
self.assertEqual(r.raw["counts"]["errors"], 2)
|
|
61
|
+
req = self.req
|
|
62
|
+
self.assertEqual(req.full_url, "https://xrechnung.dev/v1/validate")
|
|
63
|
+
self.assertEqual(req.get_method(), "POST")
|
|
64
|
+
self.assertIsNone(req.get_header("X-api-key")) # free tier: no key sent
|
|
65
|
+
ct = req.get_header("Content-type")
|
|
66
|
+
self.assertTrue(ct.startswith("multipart/form-data; boundary="))
|
|
67
|
+
boundary = ct.split("=", 1)[1]
|
|
68
|
+
self.assertIn(b'name="file"; filename="invoice.xml"', req.data)
|
|
69
|
+
self.assertIn(b"\r\n\r\n<Invoice/>\r\n--" + boundary.encode() + b"--\r\n", req.data)
|
|
70
|
+
|
|
71
|
+
def test_path_and_pdf_sniff_and_key(self):
|
|
72
|
+
self.reply({"valid": True, "errors": [], "warnings": []})
|
|
73
|
+
with tempfile.TemporaryDirectory() as d:
|
|
74
|
+
p = os.path.join(d, "rechnung.pdf")
|
|
75
|
+
with open(p, "wb") as f:
|
|
76
|
+
f.write(b"%PDF-1.7 x")
|
|
77
|
+
r = xrechnung.Client(api_key="xr_live_k").validate(p)
|
|
78
|
+
self.assertTrue(r.valid)
|
|
79
|
+
self.assertIn(b'filename="rechnung.pdf"', self.req.data)
|
|
80
|
+
self.assertEqual(self.req.get_header("X-api-key"), "xr_live_k")
|
|
81
|
+
self.reply({"valid": True})
|
|
82
|
+
xrechnung.validate(b"%PDF-1.7")
|
|
83
|
+
self.assertIn(b'filename="invoice.pdf"', self.req.data)
|
|
84
|
+
|
|
85
|
+
def test_env_key(self):
|
|
86
|
+
os.environ["XRECHNUNG_API_KEY"] = "envkey"
|
|
87
|
+
self.addCleanup(os.environ.pop, "XRECHNUNG_API_KEY", None)
|
|
88
|
+
self.reply({"valid": True})
|
|
89
|
+
xrechnung.validate(b"<x/>")
|
|
90
|
+
self.assertEqual(self.req.get_header("X-api-key"), "envkey")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class TestOther(Base):
|
|
94
|
+
def test_parse(self):
|
|
95
|
+
self.reply({"syntax": "CII", "number": "RE-1"})
|
|
96
|
+
self.assertEqual(xrechnung.parse(b"<x/>")["number"], "RE-1")
|
|
97
|
+
self.assertEqual(self.req.full_url, "https://xrechnung.dev/v1/parse")
|
|
98
|
+
|
|
99
|
+
def test_generate_binary(self):
|
|
100
|
+
self.reply(b"%PDF-1.7 data", "application/pdf")
|
|
101
|
+
inv = {"number": "RE-1", "issue_date": "2026-09-07"}
|
|
102
|
+
out = xrechnung.generate(inv, format="zugferd", language="en")
|
|
103
|
+
self.assertEqual(out, b"%PDF-1.7 data")
|
|
104
|
+
self.assertEqual(self.req.full_url, "https://xrechnung.dev/v1/invoices")
|
|
105
|
+
self.assertEqual(self.req.get_header("Content-type"), "application/json")
|
|
106
|
+
self.assertEqual(json.loads(self.req.data),
|
|
107
|
+
{"invoice": inv, "format": "zugferd", "language": "en", "output": "binary"})
|
|
108
|
+
|
|
109
|
+
def test_generate_json_and_decimal(self):
|
|
110
|
+
from decimal import Decimal
|
|
111
|
+
self.reply({"file": "PD94", "validation": {"valid": True}})
|
|
112
|
+
out = xrechnung.generate({"lines": [{"unit_price": Decimal("120.00")}]}, output="json")
|
|
113
|
+
self.assertEqual(out["validation"]["valid"], True)
|
|
114
|
+
self.assertEqual(json.loads(self.req.data)["invoice"]["lines"][0]["unit_price"], "120.00")
|
|
115
|
+
|
|
116
|
+
def test_usage_and_base_url(self):
|
|
117
|
+
self.reply({"used": 3, "limit": 10})
|
|
118
|
+
self.assertEqual(xrechnung.Client(base_url="http://localhost:8000/").usage()["used"], 3)
|
|
119
|
+
self.assertEqual(self.req.full_url, "http://localhost:8000/v1/usage")
|
|
120
|
+
self.assertEqual(self.req.get_method(), "GET")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class TestErrors(Base):
|
|
124
|
+
def check(self, code, cls, body, msg):
|
|
125
|
+
self.urlopen.side_effect = http_error(code, body)
|
|
126
|
+
with self.assertRaises(cls) as cm:
|
|
127
|
+
xrechnung.validate(b"<x/>")
|
|
128
|
+
self.assertIsInstance(cm.exception, xrechnung.XRechnungError)
|
|
129
|
+
self.assertEqual(cm.exception.status, code)
|
|
130
|
+
self.assertEqual(cm.exception.message, msg)
|
|
131
|
+
|
|
132
|
+
def test_401(self):
|
|
133
|
+
self.check(401, xrechnung.AuthenticationError, b'{"detail":"invalid or inactive API key"}',
|
|
134
|
+
"invalid or inactive API key")
|
|
135
|
+
|
|
136
|
+
def test_402(self):
|
|
137
|
+
self.check(402, xrechnung.PaymentRequiredError, b'{"detail":"payment required"}', "payment required")
|
|
138
|
+
|
|
139
|
+
def test_429(self):
|
|
140
|
+
m = "monthly limit reached (10 documents on the Free plan). Upgrade at https://xrechnung.dev/#pricing"
|
|
141
|
+
self.check(429, xrechnung.RateLimitError, json.dumps({"detail": m}).encode(), m)
|
|
142
|
+
|
|
143
|
+
def test_422_list_and_non_json(self):
|
|
144
|
+
self.check(422, xrechnung.XRechnungError,
|
|
145
|
+
b'{"detail":[{"loc":["body","invoice","number"],"msg":"Field required","type":"missing"}]}',
|
|
146
|
+
"body.invoice.number: Field required")
|
|
147
|
+
self.check(500, xrechnung.XRechnungError, b"Internal Server Error", "Internal Server Error")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class TestDocUrl(unittest.TestCase):
|
|
151
|
+
def test_slugs(self):
|
|
152
|
+
self.assertEqual(xrechnung.doc_url("BR-DE-15"), "https://xrechnung.dev/fehler/br-de-15/")
|
|
153
|
+
self.assertEqual(xrechnung.doc_url("BR-CO-10-b"), "https://xrechnung.dev/fehler/br-co-10/")
|
|
154
|
+
self.assertEqual(xrechnung.doc_url("BR-AE-10"), "https://xrechnung.dev/fehler/br-ae-10/")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class TestZugferdAlias(unittest.TestCase):
|
|
158
|
+
def test_reexport(self):
|
|
159
|
+
import sys
|
|
160
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "zugferd", "src"))
|
|
161
|
+
import zugferd
|
|
162
|
+
self.assertIs(zugferd.validate, xrechnung.validate)
|
|
163
|
+
self.assertIs(zugferd.Client, xrechnung.Client)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
if __name__ == "__main__":
|
|
167
|
+
unittest.main()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xrechnung.dev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# zugferd
|
|
2
|
+
|
|
3
|
+
Generate, validate and parse **ZUGFeRD 2.x / Factur-X** (PDF/A-3) and **XRechnung** e-invoices from Python
|
|
4
|
+
through the [xrechnung.dev](https://xrechnung.dev) API.
|
|
5
|
+
|
|
6
|
+
This package is a thin alias of [`xrechnung`](https://pypi.org/project/xrechnung/) — same API, same code.
|
|
7
|
+
Zero dependencies beyond it, **no JVM required**, and every document is always validated with the current
|
|
8
|
+
official KoSIT rules (XRechnung 3.0.2, Schematron 2.6.0).
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install zugferd
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
import zugferd
|
|
16
|
+
|
|
17
|
+
pdf = zugferd.generate(invoice, format="zugferd", language="de") # PDF/A-3 with embedded EN 16931 XML
|
|
18
|
+
open("rechnung.pdf", "wb").write(pdf)
|
|
19
|
+
|
|
20
|
+
result = zugferd.validate("rechnung.pdf")
|
|
21
|
+
for e in result.errors:
|
|
22
|
+
print(e.code, e.message, e.doc_url)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Free tier: 10 documents per month without an API key. See the full documentation in the
|
|
26
|
+
[`xrechnung` README](https://pypi.org/project/xrechnung/). MIT licensed.
|