feat: create validated ZUGFeRD invoices in one console command

This commit is contained in:
admin committed 2026-10-16 09:29:08 +02:00
1 parent e0c15fc7f2
commit a643cf5227
30 files changed
+1509 -207

No files matched your search

+5
View File
@@ -7,3 +7,8 @@
**/out/ **/out/
# mustang jar is local: # mustang jar is local:
tools/mustang/*.jar tools/mustang/*.jar
# Personal invoice inputs and customer/bank details
private/
invoices/
dist/
+177 -79
View File
@@ -1,120 +1,218 @@
# Documentation # Bedienung und technische Dokumentation
## Resilienz & Logs Stand: 16.10.2026 · Version 1.0
- YAML wird strikt validiert (Pydantic) -> frühzeitige, klare Fehler.
- Pro Run: `out/logs/run.log` (DEBUG-level File Log, inkl. Stacktraces).
- Mustang stdout/stderr wird ebenfalls geloggt.
## PDF Rendering ## Einrichtung und persönliche Vorlagen
Priorität:
1) WeasyPrint (wenn installiert)
2) wkhtmltopdf (wenn vorhanden)
Wenn beides fehlt -> klarer Fehler. Die README enthält den sofort nutzbaren Ein-Aufruf-Ablauf mit `einvoice --create`. Installation aus dem Repository
mit `pip install -e '.[pdf]'`; Java wird für Mustang benötigt. `setup-mustang`
installiert die geprüfte Referenzversion 2.26.0 aus Maven Central mit SHA1-Abgleich.
Der Abgleich prüft die Downloadintegrität; er ist keine Signaturprüfung.
`mustang-update` zeigt neuere Versionen, `mustang-update --apply` installiert sie.
Danach eine Testrechnung erneut prüfen. `JR_EINVOICE_MUSTANG_JAR` überschreibt den
Standardpfad. Downloads erfolgen nur bei Setup/Update; Generierung und Validierung
laufen lokal ohne Upload der Rechnungsdaten.
## Combine + Validate (Mustang) – standardmäßig aktiv Vorlagen werden in dieser Reihenfolge zusammengeführt:
Combine:
`java -jar Mustang-CLI.jar --action combine --source invoice.pdf --source-xml factur-x.xml --out invoice.facturx.pdf --profile E`
Validate: 1. `examples/invoice.init.yaml`.
`java -jar Mustang-CLI.jar --no-notices --action validate --source invoice.facturx.pdf` 2. Versionierte `presets/seller.default.yaml` mit Beispieldaten.
3. Lokale `private/presets/seller.default.yaml`.
4. Lokale `private/presets/customers/<kunde>.yaml`, sonst die versionierte Kunden-Datei,
sonst `presets/customers.yaml`.
5. Rechnungsnummer, Datum, Zahlungsziel und Monats-/Stundenangaben des Befehls.
## YAML Schema `JR_EINVOICE_HOME=/absoluter/pfad` setzt das private Datenverzeichnis.
Siehe `examples/invoice.sample.yaml` `JR_EINVOICE_PRESETS=/absoluter/pfad` setzt ein anderes Verzeichnis für die lokalen
Vorlagen (gleiche Unterstruktur). Persönliche Daten nicht in die versionierten
Beispieldateien eintragen. `private/`, `invoices/`, die virtuelle Umgebung und
Ausgabeordner sind gitignoriert. Externe Rechnungsordner separat gegen versehentliches
Committen sichern. Die lokale IDV-Vorlage ist nur historischer Bestand; keine Empfängeradresse ist dafür erforderlich.
## Hinweis zum XML ## Befehle
Aktuell erzeugt `facturx.py` ein **minimal stabiles** EN16931 XML (Nummer/Datum/Währung/Gesamt).
Für maximale Empfänger-Kompatibilität erweitere `facturx.py` um Seller/Buyer/Lines/Tax breakdown etc.
`jr-einvoice --help` und `<befehl> --help` zeigen alle Parameter.
## Init Command ```bash
# Vollständig erzeugen und validieren (normaler Ablauf):
einvoice --create --Customer "Kundenfirma GmbH" \
--Entry "Geleistete Stunden Oktober 2026" --Volume 120 --Price "85,00 €"
`jr-einvoice init <invoice_dir>` erstellt: # Expertenablauf für mehrere Positionen / manuell bearbeitete YAML-Dateien:
jr-einvoice init private/invoices --auto-folder --open
jr-einvoice generate /pfad/zum/rechnungsordner --json
jr-einvoice email /pfad/zum/rechnungsordner --to rechnung@empfaenger.example
```
- `invoice.yaml` (Vorlage) ### Ein-Aufruf-Erstellung
- optional `assets/` Ordner (für Logos etc.)
- `.gitkeep` Dateien damit Git die Ordner behält
Damit kannst du pro Rechnung einen neuen Ordner anlegen und direkt editieren. `einvoice` ist ein eigener Konsolenbefehl. Die Optionen `--Customer`, `--Entry`,
`--Volume` und `--Price` sind erforderlich. Der Kunde wird über den Namen oder einen
angezeigten Schlüssel aus `einvoice --customers` gefunden. Für neue Kunden erfasst
der Befehl einmalig eine vollständige Rechnungsanschrift. Alternativ können diese
Daten mit `--Street`, `--PostalCode`, `--City` und optional `--Country`, `--Email`,
`--VatId` übergeben werden. In Skripten führen fehlende Daten zu einer konkreten
Fehlermeldung; es gibt keine erfundenen Adressen oder hängenden Eingabeaufforderungen.
Adressoptionen überschreiben keine bereits gespeicherten Kunden. Deren Stammdaten
gezielt in der lokalen Kundendatei bearbeiten. Der Kunde IDV ist ein ehemaliger
Kunde und hat keine besondere Rolle im Ablauf.
`--Price` ist der Nettopreis pro Einheit. Deutsche Schreibweise (`85,00 €`,
`1.234,56 EUR`) und ungegliederte Dezimalpunkt-Schreibweise (`85.00`) sind zulässig.
`--Volume` und `--Tax` verstehen dieselben Dezimalzahlen. Ohne Dezimalkomma gilt ein
Punkt als Dezimalpunkt; `1.234` bedeutet daher 1,234 und nicht 1234. Gemischte oder
ungültige Angaben werden abgewiesen. Standardwerte: `--Unit HUR`, `--Tax 19`,
`--DueDays 30`, Rechnungsdatum heute. Preis, Menge und Steuer werden ausdrücklich
angegeben bzw. verwenden diese dokumentierten Standardwerte; ein alter
kundenspezifischer Stundensatz wird nicht unbemerkt übernommen.
## Presets Ein deutscher Monatsname plus Jahr in `--Entry`, z. B. „Oktober 2026“, ergibt den
vollständigen Leistungsmonat. `--Month YYYY-MM` setzt ihn ausdrücklich;
`--ServiceDate YYYY-MM-DD` überschreibt die automatische Erkennung durch ein
Einzeldatum. Beide expliziten Optionen zusammen sind nicht zulässig. Ohne Monat
oder Leistungsdatum gilt das Rechnungsdatum als Leistungsdatum. Die Konsole zeigt
den verwendeten Zeitraum; tatsächliche Leistungsdaten vor Versand prüfen.
- `presets/seller.default.yaml`: deine Standard-Seller- und Payment-Daten Nummern werden unter Dateisperre im gewählten `--root` automatisch als
- `presets/customers.yaml`: Kunden-Vorlagen (case-insensitive Keys) `JR-JJJJ-NNNN` vergeben. Alle gespeicherten YAML-Rechnungen auf der ersten Ebene
werden auf bereits verwendete Nummern geprüft; `--Number` kann eine eigene freie
Nummer setzen. Bei fehlgeschlagenem PDF-Build bleibt der Entwurf erhalten und
reserviert seine Nummer. Der Befehl meldet einen Fehler und veröffentlicht kein
fertiges Anhang-PDF. In `out/logs/run.log` stehen Details. Zum Korrigieren und
Wiederholen den betreffenden Entwurf mit `jr-einvoice generate` aufbauen; keinen
bereits versandten Datensatz verändern.
Init merge order: Nach erfolgreicher PDF/A- und XML-Validierung wird das vollständige hybride PDF
1) `examples/invoice.init.yaml` (Basis) zusätzlich als `Rechnung_<Nummer>.pdf` im Rechnungsordner bereitgestellt. Diese
2) `presets/seller.default.yaml` Datei direkt an eine E-Mail anhängen. `out/invoice.pdf` ist lediglich die Sichtkopie.
3) `presets/customers.yaml` (wenn --customer gesetzt) `out/manifest.json` hält SHA-256-Prüfsummen einschließlich des Anhang-PDFs fest.
4) Smart defaults (heutiges Datum, due_days, reference) `--json` schreibt nur ein JSON-Ergebnis mit Nummer, Ordner, PDF, Prüfbericht, Log
und `technical_validation: valid` nach stdout. Keine automatische Mailübertragung.
## Assets / Logo Die Konsole bestätigt „PDF/A und EN16931-XML technisch gültig“. Sie behauptet
keine vollständige rechtliche oder steuerliche Prüfung der Geschäftsdaten;
siehe [BMF-FAQ zur Validierung](https://www.bundesfinanzministerium.de/Content/DE/FAQ/e-rechnung.html).
`jr-einvoice init` kopiert standardmäßig `assets/logo.svg` nach `<invoice_dir>/assets/logo.svg`. ### Älterer Monatsentwurf
Das Template referenziert `assets/logo.svg` relativ – WeasyPrint bekommt dafür `base_url=<invoice_dir>`.
## Setup Mustang Der ergänzende `jr-einvoice monthly`-Befehl bleibt für gespeicherte
Stundenvorlagen verfügbar; der neue Standard ist `einvoice --create`.
`jr-einvoice setup-mustang` lädt das Jar von Maven Central (repo1.maven.org) und prüft optional die `.sha1` Datei. `monthly` übernimmt genau eine Stundenposition (`HUR`) aus der Kundenvorlage,
setzt deren Menge und deutschen Monatsnamen sowie Anfang/Ende des Monats. `--date`
setzt ein abweichendes Rechnungsdatum, ansonsten gilt heute. `--rate` überschreibt
den Nettostundensatz. Ungültige Monatsentwürfe hinterlassen keinen Rechnungsordner.
Explizite Nummern dürfen noch nicht vergeben sein. `monthly` prüft YAML-Rechnungen
auf der ersten Ordnerebene desselben Stamms, auch bei anderen Kunden, und verhindert
Überschreiben. Das optionale Nummernvorschlagen von `init --auto-folder` verwendet
weiter das Schema `YYYY-NNNN` aus Ordnernamen. Es kennt die bisherigen `YYYY-MM`-PDFs
nicht. Für den Wechsel daher eine freie Nummer ausdrücklich setzen; keine
bereits versandte Rechnung unter gleicher Nummer neu ausstellen. Die lokale
Ordneranlage ist per Dateisperre serialisiert; kein verteiltes Rechnungsregister.
## Auto-Rechnungsnummer `init --print-path-only`, `generate --print-artifacts-only` und `generate --json`
schreiben maschinenlesbare Ergebnisse nach stdout; Logs laufen nach stderr.
`--open` verwendet `$EDITOR`, danach verfügbare Editoren. Ein unbekannter Kunde
führt zu einem Fehler statt zu einer Rechnung mit generischem Empfänger.
Wenn du bei `init` **keine** `--invoice-number` angibst, aber `--invoices-root` setzt, dann wird die nächste Nummer vorgeschlagen. ## YAML und Beträge
Standard-Pattern: `YYYY-NNNN` (z.B. 2026-0007). Gesucht wird in Ordnernamen unter `--invoices-root`. Die vollständige Ausgangsvorlage steht in `examples/invoice.sample.yaml`.
Zusätzlich werden folgende optionale Meta-Felder unterstützt:
Implementierung: `jr_einvoice/invoice_number.py`. ```yaml
meta:
# ... invoice_number, invoice_date, service_date, currency ...
service_period_start: '2026-10-01'
service_period_end: '2026-10-31'
buyer_reference: 'Kundenreferenz'
order_reference: 'Bestellnummer'
```
## Customer Presets (Datei pro Kunde) Alle Pflichtangaben einschließlich Leistungsbeschreibung und Zeitraum gelangen
in PDF und XML. Für Monatsrechnungen steht `service_date` auf dem Monatsende.
Bei Teilmonaten den tatsächlichen Zeitraum setzen. `payment.reference` ist der
Verwendungszweck, keine Bestellnummer. Kunden- und Bestellreferenzen stehen in den
eigenen Meta-Feldern. Ein B2B-Empfänger benötigt keine erfundene Leitweg-ID.
Bevorzugt: `presets/customers/<customer>.yaml` Geld, Mengen und Steuersätze verwenden `Decimal`. Jede Nettoposition wird kaufmännisch
auf Cent gerundet; je Steuersatz wird die Steuer aus der Summe der gerundeten
Nettopositionen berechnet und auf Cent gerundet. PDF und XML verwenden dieselbe
Berechnung. Dezimalpunkt verwenden; besonders genaue Mengen/Preise in YAML als
Zeichenkette angeben (`quantity: '72.5'`). Leere Positionen, nicht endliche Zahlen,
negative Preise, Nullsteuersätze, unbekannte YAML-Felder und ungültige IBAN-Prüfsummen
werden abgewiesen. Der Validator prüft zusätzlich Format und Geschäftsregeln.
Fallback: `presets/customers.yaml` (Map `customers:`) ## PDF/A, XML und Validierung
## Mustang Update Check WeasyPrint erzeugt PDF/A-3b. Mustang kombiniert diese Sichtkopie mit dem CII-D16B-XML,
Profil EN16931 (`--format fx --version 1 --profile E`). Die konkrete Kombination
wurde mit Mustang 2.26.0 einschließlich PDF/A und eingebettetem XML erfolgreich
geprüft. Die CLI-Formatversion `1` bezeichnet Factur-X, nicht ZUGFeRD 1.
Es wird keine Behauptung einer Zertifizierung oder vollständigen Unterstützung
aller neuen ZUGFeRD-2.5.2-Funktionen abgeleitet.
`jr-einvoice mustang-update` liest Maven metadata und vergleicht mit `tools/mustang/VERSION.txt`. Der frühere wkhtmltopdf-Fallback bleibt für eine Sichtkopie verfügbar, garantiert
Mit `--apply` wird die neueste Version geladen und installiert (inkl. optional SHA1-Check). aber kein PDF/A. Ohne gültiges PDF/A darf die abschließende Prüfung nicht bestehen.
Für den produktiven Weg WeasyPrint verwenden.
`generate` kombiniert und validiert immer. Die Python-Pipeline kann für interne
Anwendungsfälle auch nur Sichtkopie und XML erzeugen. Ein Erfolg erfordert einen
parsebaren Mustang-Bericht mit XML-Prüfabschnitt und ausschließlich gültigen
Zusammenfassungen einschließlich Gesamtergebnis. Ein Exitcode 0 allein genügt nicht.
Bei fehlgeschlagener Validierung bleibt der Bericht erhalten und das nicht freigegebene
hybride PDF wird entfernt. Alte Mailentwürfe werden bei einem neuen Build verworfen.
Pro Lauf stehen Details und Mustang-Ausgaben in `out/logs/run.log`.
## Auto Folder Name Für Eingänge kann `validate` XML und hybride PDF prüfen. Eine normale PDF-Datei
wird nicht als strukturierte E-Rechnung akzeptiert. Quellen bleiben unverändert;
Berichte an einem eigenen Pfad aufbewahren. Inhaltliche Prüfung (Leistung, Parteien,
Betrag, Steuersatz, Doppelrechnung) bleibt zusätzlich erforderlich.
Mit `jr-einvoice init invoices --auto-folder --customer unicredit` wird ein Unterordner erzeugt: ## Versand und Aufbewahrung
`Rechnung_<invoice_number>_<customer>` `email` erzeugt die Standardausgabe erneut aus `invoice.yaml` und legt erst nach
erfolgreicher Prüfung `invoice.eml` an. Absender kommt aus `seller.email`, Empfänger
aus `buyer.email` oder `--to`. Die Mail enthält nur das hybride PDF als Rechnungsanhang;
XML ist darin eingebettet. `X-Unsent: 1` kennzeichnet den Entwurf. Kein SMTP-Zugang,
kein Passwort und kein automatischer Versand sind nötig. Das konkrete Öffnen als
Entwurf hängt vom Mailprogramm ab. Versand und Zustellnachweis erfolgen dort.
Sanitizing/slugging in `jr_einvoice/folder_naming.py`. `manifest.json` enthält SHA-256-Prüfsummen von YAML, XML, hybridem PDF, Bericht und
EML. Es unterstützt die Integritätskontrolle, schafft aber allein kein unveränderbares
Archiv. Nach Versand den vollständigen Rechnungsordner und die gesendete Originalmail
samt Versandnachweis in die bestehende gesicherte Ablage übernehmen. Versandte
Rechnungen nicht durch erneutes `generate` überschreiben. Für Berichtigungen einen
fachlich abgestimmten Prozess verwenden. Das Tool verwaltet keine Stornos.
## Open Editor ## Prüfung der Überarbeitung
Mit `--open` wird `invoice.yaml` nach dem Init in `$EDITOR` geöffnet. ```bash
Fallback-Reihenfolge: `code`, `codium`, `nano`, `vim`, `vi`. python -m unittest discover -s tests -v
# Einschließlich echter PDF-/XML-/Mail-Prüfung durch Mustang:
JR_EINVOICE_INTEGRATION=1 python -m unittest discover -s tests -v
```
Die Tests decken deutschen Preisparser, neue/wiederverwendete Kunden, Ein-Aufruf-Nummernvergabe,
Abbruch ohne fertigen PDF-Anhang sowie dezimale Rundung, mehrere Steuersätze, Pflichtdaten und ungültige
Zahlen/IBAN, XML-Inhalte, HTML-Escaping, Monatsgrenzen einschließlich Schaltjahr,
Nummernkollisionen, unbrauchbare oder widersprüchliche Validierungsberichte sowie
veraltete Mailentwürfe ab. Der echte Mustang-Lauf ist ein zusätzlicher Integrationstest
und erfordert Java und das lokale Jar.
## Script output Lokal wurde eine Testrechnung mit den Juli-Beispielwerten erstellt: 75 Stunden zu
85 EUR → 6.375,00 EUR netto + 1.211,25 EUR USt = 7.586,25 EUR. Nummer `TEST-2026-07`,
keine neue echte Rechnung. Der Mailtest verwendet die nicht zustellbare Adresse
`rechnung@example.invalid`. Die alten PDF-Rechnungen wurden nicht verändert.
- `--print-path` prints the created folder path after the panel ## Bewusster Funktionsumfang
- `--print-path-only` prints only the path (no rich output)
## Auto open Normale positive Dienstleistungsrechnungen in EUR mit positiver Umsatzsteuer und mehreren
Steuersätzen sind unterstützt. Nicht implementiert sind steuerfreie Positionen,
- `--open` opens unconditionally (needs an editor available) Kleinunternehmer, Reverse Charge, Abschläge/Anzahlungen, Skonto/Zuschläge/Rabatte,
- `--open-auto` opens only when a GUI session is detected (DISPLAY/WAYLAND_DISPLAY) and stdout is a TTY Gutschriften/Storno, Fremdwährungs-Steuerumrechnung, XRechnung-CIUS-Ausgabe,
Peppol, DATEV-API und automatischer Postfacheingang. Diese Fälle nicht durch bloße
Freitexthinweise nachbilden. Das vorhandene CII-XML ist keine XRechnung, auch wenn
## Generate artifact output beide Formate EN16931 verwenden. Aufbewahrung/Backups und fachliche Freigabe müssen
in den betrieblichen Ablauf eingebunden werden.
Options:
- `--print-artifacts`: prints paths after the rich OK output
- `--print-artifacts-only`: prints only the paths (one per line) in this order:
1) invoice.pdf
2) factur-x.xml
3) invoice.facturx.pdf
4) validation/validate_report.xml
5) out/logs/run.log
- `--json`: prints a JSON object with keys:
- invoice_dir, out_dir, invoice_pdf, facturx_xml, facturx_pdf, validate_report, log
+118 -24
View File
@@ -1,31 +1,125 @@
# jr-einvoice-toolchain (Folder-first Factur-X / ZUGFeRD EN16931) # einvoice – Rechnung mit einem Konsolenaufruf
## New in v0.8 Kundenname, Leistung, Menge und Nettopreis eingeben: `einvoice --create` erledigt
Rechnungsnummer, Berechnung, PDF/A-Erstellung, Einbettung des EN16931-XML und
technische Validierung. Das fertige ZUGFeRD/Factur-X-PDF kannst du direkt an eine
E-Mail anhängen. Alles läuft lokal.
`generate` kann jetzt Artefakt-Pfade für Automatisierung ausgeben: ## Direkt loslegen
- `--print-artifacts` → druckt Artefakt-Pfade zusätzlich nach dem OK-Panel Auf diesem Rechner sind Python-Umgebung, Absenderdaten und Mustang eingerichtet.
- `--print-artifacts-only` → druckt **nur** Pfade (je Zeile), ohne Rich-Output
- `--json` → druckt ein JSON-Objekt (ideal für CI)
## Beispiele
### CI/Script: JSON
```bash
jr-einvoice generate invoices/Rechnung_2026-0007_unicredit --json > artifacts.json
cat artifacts.json
```
### Script: Liste
```bash
jr-einvoice generate invoices/Rechnung_2026-0007_unicredit --print-artifacts-only
```
## Init quickstart
```bash ```bash
INV_DIR=$(jr-einvoice init invoices --auto-folder -c unicredit -p "Beratung" --print-path-only) cd /home/johannes/localdev/einvoice
jr-einvoice generate "$INV_DIR" --json source .venv/bin/activate
einvoice --create \
--Customer "Meine Kundenfirma GmbH" \
--Entry "Geleistete Stunden Oktober 2026" \
--Volume 120 \
--Price "85,00 €"
``` ```
Mehr: `Documentation.md` Bei einem **neuen Kunden** fragt die Konsole einmalig Straße/Hausnummer, Postleitzahl
und Ort ab. Bei späteren Rechnungen genügt der gleiche Kundenname. Groß- und
Kleinschreibung sind beim Kundennamen gleichgültig. `einvoice --customers` listet
die gespeicherten Kunden samt Schlüssel auf. Es ist kein Standardkunde vorgewählt.
IDV ist ausschließlich ein historisches Beispiel aus früheren Rechnungen.
Der Preis ist **netto je Einheit**. Standard: Stunden (`HUR`), 19 % USt, heute als
Rechnungsdatum und 30 Tage Zahlungsziel. Im Beispiel entstehen 10.200,00 EUR netto,
1.938,00 EUR USt und **12.138,00 EUR brutto**. „Oktober 2026“ in der Beschreibung
wird als Leistungsmonat erkannt. Für Teilmonate/andere Leistungen `--Month` oder
`--ServiceDate` ausdrücklich setzen und die tatsächlichen Leistungsdaten verwenden.
```text
Rechnungsdaten vollständig: JR-2026-0001 · Meine Kundenfirma GmbH
PDF/A und strukturierte Rechnungsdaten werden erzeugt …
Rechnung validiert: PDF/A und EN16931-XML technisch gültig.
Rechnung erzeugt · Netto: 10.200,00 EUR · USt: 1.938,00 EUR · Brutto: 12.138,00 EUR
Leistung: 2026-10-01 bis 2026-10-31
PDF zum Anhängen an deine E-Mail:
…/Rechnung_JR-2026-0001_…/Rechnung_JR-2026-0001.pdf
Prüfbericht: …/out/validation/validate_report.xml
Vor Versand Leistungsdaten, Betrag und Empfänger prüfen.
```
**Die ausgegebene `Rechnung_<Nummer>.pdf` ist die vollständige E-Rechnung**, inklusive
XML. Direkt anhängen; nicht über einen PDF-Drucker neu drucken, weil dabei das XML
verloren gehen kann. Die Ausgabe bestätigt die erfolgreiche technische Prüfung.
Sie garantiert nicht pauschal die sachliche oder steuerliche Richtigkeit deiner
Eingaben. Details: [Rechtsstand und Grenzen](docs/e-rechnung-2027.md).
## Optionen für Alltag und Skripte
```bash
# Neue Kundenanschrift ohne Rückfragen übergeben:
einvoice --create --Customer "Neue Firma GmbH" \
--Street "Kundenstraße 12" --PostalCode 10115 --City Berlin \
--Email rechnung@empfaenger.example \
--Entry "IT-Beratung" --Volume "7,5" --Price "120,00 EUR" \
--ServiceDate 2026-10-15 --non-interactive
# Bekannter Kunde, expliziter Monat und Bestellbezug:
einvoice --create --Customer "Meine Kundenfirma GmbH" \
--Entry "Softwareentwicklung" --Volume 120 --Price "85,00 €" \
--Month 2026-10 --OrderReference "PO-12345" --DueDays 14
# Beispiel für eine Stückposition:
einvoice --create --Customer "Meine Kundenfirma GmbH" \
--Entry "Dokumentationspaket" --Volume 1 --Unit C62 --Price "500,00 €"
```
Weitere Optionen: `--Number`, `--Date`, `--Tax`, `--BuyerReference`, `--Country`,
`--VatId`, `--root`, `--json`. Alle Fachoptionen haben auch kleingeschriebene
Varianten (`--customer`, `--entry`, …); `einvoice --help` zeigt sie an.
`--json` liefert Artefaktpfade und den technischen Prüfstatus ohne Fragen.
Die Nummer lautet automatisch `JR-JJJJ-NNNN` und wird im Rechnungsstamm unter
Dateisperre vergeben. `--Number` setzt eine ausdrücklich gewählte freie Nummer.
Vorhandene PDF-Rechnungen außerhalb dieses Stamms werden nicht automatisch
eingelesen. Das Präfix JR trennt die neue Serie von früheren `JJJJ-MM`-Nummern.
Bei einem technischen Fehler bleibt der reservierte Entwurf samt Log zur Diagnose
erhalten; zum Wiederholen `jr-einvoice generate <Rechnungsordner>` verwenden.
## Daten und Einrichtung
- Absender: **johannes@jr-it-services.de**, lokale Vorlage `private/presets/seller.default.yaml`.
- Kunden: `private/presets/customers/`; neue Kunden werden dort mit geschützter Dateiberechtigung gespeichert.
- Rechnungen: `private/invoices/`, jeweils in einem eigenen geschützten Ordner.
- Eigenes Logo optional: `private/presets/logo.svg`, sonst mitgeliefertes JR-Logo.
- Persönliche Daten, Rechnungen und Ausgabeordner werden nicht mit Git veröffentlicht.
Andere Datenablage: `JR_EINVOICE_HOME=/absoluter/pfad`; nur die Vorlagen umleiten:
`JR_EINVOICE_PRESETS=/absoluter/pfad`. Nach Wheel-Installation ist der Standard
`~/.local/share/jr-einvoice/`. Bei einem frischen Checkout zuerst die vollständigen
eigenen Absender-/Zahlungsdaten lokal konfigurieren; `create` verwendet keine
versionierten Musteradressen als echte Geschäftsdaten.
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -e '.[pdf]'
jr-einvoice setup-mustang --version 2.26.0
```
Java und die WeasyPrint-Systembibliotheken müssen installiert sein; für Arch siehe
`scripts/arch_install.sh`. Die bisherigen `jr-einvoice`-Befehle bleiben verfügbar:
YAML-Entwürfe, mehrere Positionen, Neuerzeugung, Eingangskontrolle und optionaler
EML-Mailentwurf mit Absender aus den eigenen Stammdaten.
```bash
jr-einvoice email /pfad/zum/rechnungsordner --to rechnung@empfaenger.example
jr-einvoice validate /pfad/eingangsrechnung.xml --report private/eingang-pruefung.xml
python -m unittest discover -s tests -v
JR_EINVOICE_INTEGRATION=1 python -m unittest discover -s tests -v
```
Unterstützt werden normale positive EUR-Rechnungen mit positiver Umsatzsteuer.
Steuerbefreiungen/Kleinunternehmer, Reverse Charge, Gutschriften/Storno und
XRechnung-Ausgabe sind noch nicht implementiert. Archivierung, DATEV-/Peppol-
Anbindung und Versandnachweis bleiben im betrieblichen Ablauf. Nach dem Versand
PDF, strukturierte Daten, Prüfbericht und Versandnachweis gesichert aufbewahren.
[Ausführliche Dokumentation](Documentation.md) ·
[Rechtsstand und Abgleich mit der INTEGRA-Mail](docs/e-rechnung-2027.md)
+89
View File
@@ -0,0 +1,89 @@
# E-Rechnung 2027: Rechtsstand und Abgleich
Geprüft am 16.10.2026. Ziel dieses Projekts ist die Erstellung regulärer deutscher
B2B-Dienstleistungsrechnungen als validierte ZUGFeRD/Factur-X-Rechnung.
## Einordnung der INTEGRA-E-Mail
Die Umstellung ist relevant; ein gewöhnliches PDF erfüllt die Anforderungen an
strukturierte Rechnungsdaten nicht. Der Zeitplan lässt sich wie folgt einordnen:
| Zeitraum | Grundsätzlicher Stand für inländisches B2B |
| --- | --- |
| Seit 01.01.2025 | Empfang strukturierter E-Rechnungen ermöglichen; ein E-Mail-Postfach kann dafür genügen. |
| Bis 31.12.2026 | Übergangsregel für sonstige Ausgangsrechnungen. |
| Ab 01.01.2027 | Bei Vorjahresgesamtumsatz **über** 800.000 EUR endet die allgemeine Übergangsregel. |
| Bis 31.12.2027 | Bei Vorjahresgesamtumsatz **bis einschließlich** 800.000 EUR besteht die verlängerte Übergangsregel; außerdem gesonderte EDI-Übergangsregel. |
| Ab 01.01.2028 | Allgemeine Übergangsfristen beendet; gesetzliche Ausnahmen bleiben bestehen. |
Papier und sonstige elektronische Rechnungen sind bei der Zustimmung zu unterscheiden:
Die Empfängerzustimmung betrifft im Übergang sonstige elektronische Rechnungen
(etwa ein einfaches PDF), nicht generell Papier. Maßgeblich sind die gesetzlichen
Tatbestände und der Zeitpunkt des ausgeführten Umsatzes, nicht nur das Ausstellungsdatum.
Quelle: [§ 27 Abs. 38 UStG](https://www.gesetze-im-internet.de/ustg_1980/__27.html).
Die Umsatzgrenze ist keine Kleinunternehmergrenze. Sonderfälle wie Kleinbetragsrechnungen
bis 250 EUR, Fahrausweise, bestimmte steuerfreie Umsätze und die Ausnahme für
Kleinunternehmer sind getrennt zu beurteilen; B2G folgt zusätzlichen Vorgaben.
Den maßgeblichen Gesamtumsatz nach den gesetzlichen Verweisen mit der Steuerberatung
bestimmen, nicht aus der Summe der hier angelegten Rechnungen ableiten.
Quelle: [BMF-FAQ, Fragen 4 und 11](https://www.bundesfinanzministerium.de/Content/DE/FAQ/e-rechnung.html).
## Formate, Prüfung und Vorsteuer
ZUGFeRD ab 2.0.1 ist grundsätzlich geeignet, aber die Profile MINIMUM und BASIC-WL
reichen nicht aus. Dieses Projekt nutzt EN16931. Ein technischer Validierungslauf
ist sinnvoll und hier vor dem Versandentwurf verbindlich. Laut BMF ist Validierung
jedoch keine unmittelbare steuerliche Anerkennungsvoraussetzung; die pauschale
Aussage „zwingend technisch validieren, sonst kein Vorsteuerabzug“ greift daher zu
kurz. Leistung und Rechnungsinhalt müssen weiterhin geprüft werden.
Quelle: [BMF-FAQ, Frage 7](https://www.bundesfinanzministerium.de/Content/DE/FAQ/e-rechnung.html).
Bei Widersprüchen im hybriden Format sind die strukturierten Daten entscheidend.
Das Tool erzeugt Sichtkopie und XML deshalb aus derselben Datenquelle und Berechnung.
Aktuelle Formate und Validatoren entwickeln sich weiter: FeRD veröffentlicht inzwischen
ZUGFeRD 2.5.2. Eine ältere zulässige Version wird dadurch nicht automatisch unzulässig;
die tatsächlich verwendete Implementierung muss aber mit passenden Regeln und den
Empfängeranforderungen geprüft werden.
Quellen: [BMF-FAQ](https://www.bundesfinanzministerium.de/Content/DE/FAQ/e-rechnung.html),
[FeRD-Veröffentlichungen](https://www.ferd-net.de/downloads/veroeffentlichungen).
## Aufbewahrung
Rechnungen sind grundsätzlich acht Jahre aufzubewahren; Beginn und mögliche längere
Fristen sind gesondert zu beachten. Das strukturierte Original erhalten und nicht
nur ausdrucken. Quelle: [§ 14b UStG](https://www.gesetze-im-internet.de/ustg_1980/__14b.html).
Prüfbericht und versandte Datei gemeinsam aufzubewahren erleichtert den Nachweis.
Eine technische Prüfung ersetzt keine Vollständigkeits- und Richtigkeitskontrolle.
Quelle: [BMF-Schreiben vom 15.10.2025, insbesondere Rn. 35a und 60](https://www.bundesfinanzministerium.de/Content/DE/Downloads/BMF_Schreiben/Steuerarten/Umsatzsteuer/Umsatzsteuer-Anwendungserlass/2025-10-15-einfuehrung-obligatorische-e-rechnung.pdf?__blob=publicationFile&v=5).
## Ergebnis der Codeprüfung
| Anforderung | Vorher | Nach Überarbeitung |
| --- | --- | --- |
| Strukturierte Parteien, Positionen, Steuern | Fehlten im XML | In CII-EN16931 enthalten |
| Beträge in PDF/XML konsistent | Float-Berechnung | Gemeinsame Decimal-/Cent-Berechnung |
| Leistungszeitraum, Bestellung, Kundenreferenz | Fehlten | Optionale strukturierte Felder und PDF-Anzeige |
| PDF/A und eingebettetes XML | Keine PDF/A-Erzeugung sichergestellt | WeasyPrint-PDF/A, Mustang-Kombination und Prüfung |
| Validierungsergebnis | Nur Prozess-Exitcode | XML-Bericht einschließlich Teilergebnissen ausgewertet |
| Komfortable Erstellung | Allgemeines Init und manuelle Eingabe | Ein Aufruf mit beliebigem Kunden, Position, Menge, deutschem Nettopreis, automatischer Nummer und fertigem Anhang-PDF |
| Versandvorbereitung | Fehlte | Neuaufbau + Prüfung + EML mit hybridem PDF |
| Eingangsprüfung | Kein CLI-Befehl | Lokaler Prüfungsbefehl für XML/PDF |
| Revisionssichere Archivierung / DATEV / Peppol | Fehlten | Weiterhin externe Prozesse, keine vorgetäuschte Integration |
Vor dem ersten echten Versand: lokale Stammdaten bestätigen, freie Rechnungsnummer
wählen, vereinbarte Empfängeradresse und etwaige Bestellreferenz eintragen, Stunden
und Zeitraum prüfen, validiertes PDF sichten und den Mailentwurf absenden. Danach
Rechnung und Versandnachweis gesichert ablegen. Die Steuerberatung sollte die
betrieblichen Sonderfälle und den Aufbewahrungsprozess bestätigen.
## Ein-Aufruf-Bedienung ab Version 1.0
`einvoice --create` erzeugt das vollständige hybride PDF, validiert es und zeigt den
Anhang-Pfad an. Neue Kunden werden einmalig mit Anschrift angelegt, bestehende Kunden
über ihren Namen wiederverwendet. Es wird kein Kunde automatisch vorausgewählt;
IDV ist historischer Bestand. Die Erfolgsmeldung bestätigt die technischen Regeln
für PDF/A und EN16931. Ob Leistungsdaten, Steuersatz und Geschäftsvorfall richtig
sind, kann der Validator nicht eigenständig feststellen. Deshalb gibt die Konsole
keine pauschale Zusicherung „erfüllt alle gesetzlichen Vorgaben“ aus.
+21 -17
View File
@@ -1,24 +1,28 @@
from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Dict from decimal import Decimal, ROUND_HALF_UP
from .models import Invoice from .models import Invoice
CENT = Decimal('0.01')
def rounded(value: Decimal) -> Decimal:
return value.quantize(CENT, rounding=ROUND_HALF_UP)
def line_net(item) -> Decimal:
return rounded(item.quantity * item.unit_price_net)
@dataclass(frozen=True) @dataclass(frozen=True)
class Totals: class Totals:
net_total: float net_total: Decimal
tax_total: float tax_total: Decimal
gross_total: float gross_total: Decimal
tax_by_rate: Dict[float, float] tax_by_rate: dict[Decimal, Decimal]
basis_by_rate: dict[Decimal, Decimal]
def compute_totals(inv: Invoice) -> Totals: def compute_totals(inv: Invoice) -> Totals:
net = 0.0 bases = {}
tax_total = 0.0 for item in inv.items:
tax_by_rate: Dict[float, float] = {} bases[item.tax_rate] = bases.get(item.tax_rate, Decimal(0)) + line_net(item)
for it in inv.items: taxes = {rate: rounded(base * rate / 100) for rate, base in bases.items()}
line_net = float(it.quantity) * float(it.unit_price_net) net = sum(bases.values(), Decimal(0))
net += line_net tax = sum(taxes.values(), Decimal(0))
tax = line_net * float(it.tax_rate) / 100.0 return Totals(net, tax, net + tax, taxes, bases)
tax_total += tax
tax_by_rate[it.tax_rate] = tax_by_rate.get(it.tax_rate, 0.0) + tax
gross = net + tax_total
return Totals(net_total=net, tax_total=tax_total, gross_total=gross, tax_by_rate=tax_by_rate)
+61 -3
View File
@@ -18,13 +18,17 @@ app = typer.Typer(add_completion=False, no_args_is_help=True)
console = Console() console = Console()
def _repo_root() -> Path: def _repo_root() -> Path:
return Path(__file__).resolve().parent.parent root = Path(__file__).resolve().parent.parent
return root if (root / "templates/invoice.html.j2").is_file() else Path(__file__).resolve().parent / "data"
def _default_template() -> Path: def _default_template() -> Path:
return _repo_root() / "templates" / "invoice.html.j2" return _repo_root() / "templates" / "invoice.html.j2"
def _mustang_default_path() -> Path: def _mustang_default_path() -> Path:
return _repo_root() / "tools" / "mustang" / "Mustang-CLI.jar" root = _repo_root()
if root.name == "data":
return Path.home() / ".local/share/jr-einvoice/tools/Mustang-CLI.jar"
return root / "tools" / "mustang" / "Mustang-CLI.jar"
def _mustang_dir() -> Path: def _mustang_dir() -> Path:
return _mustang_default_path().parent return _mustang_default_path().parent
@@ -46,7 +50,7 @@ def _fail(msg: str, exc: Exception | None = None, exit_code: int = 1):
@app.command("setup-mustang") @app.command("setup-mustang")
def setup_mustang( def setup_mustang(
version: str = typer.Option("2.22.0", "--version", help="Mustang-CLI version to download"), version: str = typer.Option("2.26.0", "--version", help="Mustang-CLI version to download"),
dest: Path = typer.Option(_mustang_default_path(), "--dest", help="Where to place Mustang-CLI.jar"), dest: Path = typer.Option(_mustang_default_path(), "--dest", help="Where to place Mustang-CLI.jar"),
verify_sha1: bool = typer.Option(True, "--verify-sha1/--no-verify-sha1", help="Verify downloaded jar using Maven .sha1 file"), verify_sha1: bool = typer.Option(True, "--verify-sha1/--no-verify-sha1", help="Verify downloaded jar using Maven .sha1 file"),
log_level: str = typer.Option("INFO", "--log-level", help="Console log level: DEBUG/INFO/WARNING/ERROR"), log_level: str = typer.Option("INFO", "--log-level", help="Console log level: DEBUG/INFO/WARNING/ERROR"),
@@ -156,6 +160,7 @@ def generate(
print_artifacts_only: bool = typer.Option(False, "--print-artifacts-only", help="Only print artifact paths (no rich output)"), print_artifacts_only: bool = typer.Option(False, "--print-artifacts-only", help="Only print artifact paths (no rich output)"),
json_out: bool = typer.Option(False, "--json", help="Print artifacts as JSON to stdout (best for CI)"), json_out: bool = typer.Option(False, "--json", help="Print artifacts as JSON to stdout (best for CI)"),
): ):
invoice_dir = invoice_dir.resolve()
try: try:
_ = load_invoice_yaml(invoice_dir / invoice_filename) _ = load_invoice_yaml(invoice_dir / invoice_filename)
except Exception as e: except Exception as e:
@@ -228,3 +233,56 @@ def generate(
except Exception as e: except Exception as e:
logger.exception("Generate failed") logger.exception("Generate failed")
_fail("Generate failed. See out/logs/run.log for details.", e) _fail("Generate failed. See out/logs/run.log for details.", e)
@app.command("monthly")
def monthly(
root: Path = typer.Argument(..., help="Private invoice root"),
customer: str = typer.Option(..., "--customer", "-c"),
month: str = typer.Option(..., "--month", help="Service month YYYY-MM"),
hours: str = typer.Option(..., "--hours", help="Hours, decimal point allowed"),
number: str = typer.Option(..., "--number", "-n", help="Unique invoice number, including existing PDF invoices"),
rate: str | None = typer.Option(None, "--rate", help="Override preset hourly net rate"),
issued: str | None = typer.Option(None, "--date", help="Invoice date YYYY-MM-DD; default today"),
due_days: int = typer.Option(30, "--due-days", min=0),
):
"""Monatsrechnung mit Stunden, Zeitraum, Kundenstammdaten und Zahlungsziel anlegen."""
from decimal import Decimal
from datetime import date
from .monthly import monthly_invoice
try:
target = monthly_invoice(root, _repo_root(), setup_logging("INFO", None), customer,
month, Decimal(hours), number, date.fromisoformat(issued) if issued else None,
Decimal(rate) if rate else None, due_days)
sys.stdout.write(str(target) + "\n")
except Exception as e:
_fail("Monatsrechnung konnte nicht angelegt werden", e)
@app.command("email")
def email_draft(
invoice_dir: Path = typer.Argument(..., exists=True, file_okay=False),
recipient: str | None = typer.Option(None, "--to", help="Agreed invoice recipient"),
mustang_jar: Path | None = typer.Option(None, "--mustang-jar"),
):
"""Rechnung neu erzeugen, validieren und Versandentwurf (.eml) mit PDF-Anhang vorbereiten."""
from .delivery import prepare_email
logger = setup_logging("INFO", invoice_dir / 'out/logs/run.log')
try:
path = prepare_email(invoice_dir.resolve(), mustang_jar or _default_mustang_jar(), logger, recipient)
console.print(f"Versandentwurf: {path}\nIm Mailprogramm öffnen, prüfen und absenden.")
except Exception as e:
logger.exception("Email preparation failed")
_fail("Versandentwurf konnte nicht erstellt werden", e)
@app.command("validate")
def validate_file(
source: Path = typer.Argument(..., exists=True, dir_okay=False),
report: Path = typer.Option(Path("validation-report.xml"), "--report"),
mustang_jar: Path | None = typer.Option(None, "--mustang-jar"),
):
"""Eingehende XML- oder ZUGFeRD-PDF-Rechnung lokal technisch prüfen."""
from .mustang import validate
try:
validate(mustang_jar or _default_mustang_jar(), source.resolve(), report, setup_logging("INFO", None))
console.print(f"Technisch gültig. Prüfbericht: {report}")
except Exception as e:
_fail("Validierung fehlgeschlagen", e)
+185
View File
@@ -0,0 +1,185 @@
"""One-command invoice creation with reusable private customer records."""
from dataclasses import dataclass
from datetime import date, timedelta
from decimal import Decimal, InvalidOperation
from pathlib import Path
import calendar
import hashlib
import json
import re
import shutil
import tempfile
import yaml
from .calc import compute_totals
from .utils import money_fmt
from .folder_naming import default_invoice_folder_name
from .invoice_number import invoice_root_lock
from .models import Invoice, Party
from .pipeline import build_in_folder, BuildResult
from .presets import load_yaml
MONTHS = 'januar februar märz april mai juni juli august september oktober november dezember'.split()
def decimal_input(value: str) -> Decimal:
"""Accept 85,00 €, 1.234,56 EUR or ungrouped decimal-point notation."""
raw = re.sub(r'\s*(?:€|EUR)\s*$', '', value.strip(), flags=re.I)
raw = raw.replace('\u00a0', '').strip()
if ',' in raw:
if not re.fullmatch(r'[+-]?(?:\d+|\d{1,3}(?:\.\d{3})+),\d+', raw):
raise ValueError(f'Ungültige Dezimalzahl: {value!r}')
raw = raw.replace('.', '').replace(',', '.')
elif not re.fullmatch(r'[+-]?\d+(?:\.\d+)?', raw):
raise ValueError(f'Ungültige Dezimalzahl: {value!r}')
try:
result = Decimal(raw)
except InvalidOperation as exc:
raise ValueError(f'Ungültige Dezimalzahl: {value!r}') from exc
if not result.is_finite():
raise ValueError('Zahl muss endlich sein')
return result
def customer_key(name: str) -> str:
# Hash prevents punctuation/transliteration collisions and path traversal.
readable = re.sub(r'[^a-z0-9]+', '-', name.casefold()).strip('-')[:32] or 'kunde'
return f'{readable}-{hashlib.sha256(name.casefold().encode()).hexdigest()[:8]}'
def find_customer(presets: Path, name: str) -> tuple[str, dict] | None:
wanted = name.strip().casefold()
matches = []
for path in sorted((presets / 'customers').glob('*.yaml')):
data = load_yaml(path)
if wanted in (path.stem.casefold(), str(data.get('buyer', {}).get('name', '')).casefold()):
matches.append((path.stem, data))
if len(matches) > 1:
raise ValueError('Kundenname ist mehrdeutig; bitte den eindeutigen Kundenschlüssel verwenden')
return matches[0] if matches else None
def save_customer(presets: Path, name: str, buyer: dict) -> tuple[str, dict]:
buyer = Party.model_validate(buyer).model_dump(mode='json')
key = customer_key(name)
directory = presets / 'customers'
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
target = directory / f'{key}.yaml'
data = {'buyer': buyer}
# Exclusive create; never replace an existing customer's stored details silently.
with target.open('x', encoding='utf-8') as file:
target.chmod(0o600)
yaml.safe_dump(data, file, allow_unicode=True, sort_keys=False)
return key, data
def service_period(entry: str, month: str | None, service_date: date | None, issued: date):
if month and service_date:
raise ValueError('--Month und --ServiceDate können nicht kombiniert werden')
if service_date:
return service_date, None, None
if month is None:
match = re.search(r'\b(' + '|'.join(MONTHS) + r')\s+(\d{4})\b', entry.casefold())
if match:
month = f'{match[2]}-{MONTHS.index(match[1]) + 1:02d}'
if month:
if not re.fullmatch(r'\d{4}-\d{2}', month):
raise ValueError('--Month muss YYYY-MM sein')
year, m = map(int, month.split('-'))
first = date(year, m, 1)
last = date(year, m, calendar.monthrange(year, m)[1])
return last, first, last
return issued, None, None
@dataclass
class CreatedInvoice:
build: BuildResult
attachment: Path
invoice: Invoice
def create_invoice(*, root: Path, resources: Path, presets: Path, customer: str,
entry: str, volume: Decimal, price: Decimal, jar: Path, logger,
number: str | None = None, issued: date | None = None,
month: str | None = None, service_date: date | None = None,
unit: str = 'HUR', tax: Decimal = Decimal('19'), due_days: int = 30,
buyer_reference: str | None = None, order_reference: str | None = None,
progress=lambda message: None) -> CreatedInvoice:
if due_days < 0:
raise ValueError('Zahlungsziel darf nicht negativ sein')
if not jar.is_file():
raise FileNotFoundError('Mustang fehlt. Einmalig jr-einvoice setup-mustang ausführen')
seller_data = load_yaml(presets / 'seller.default.yaml')
if not seller_data.get('seller') or not seller_data.get('payment'):
raise ValueError(f'Eigene Absender- und Zahlungsdaten in {presets / "seller.default.yaml"} hinterlegen')
match = find_customer(presets, customer)
if match is None:
raise ValueError(f'Kunde {customer!r} ist noch nicht angelegt')
key, record = match
issued = issued or date.today()
delivery, start, end = service_period(entry, month, service_date, issued)
root = root.expanduser().resolve()
with invoice_root_lock(root):
# Only dedicated invoice directories; unknown/corrupt records stop allocation.
used = set()
for path in root.glob('*/invoice.yaml'):
existing = load_yaml(path).get('meta', {}).get('invoice_number')
if not isinstance(existing, str) or not existing.strip():
raise ValueError(f'Rechnungsnummer im vorhandenen Entwurf unklar: {path}')
used.add(existing)
if not number:
prefix = f'JR-{issued.year}-'
sequence = max((int(n[len(prefix):]) for n in used
if n.startswith(prefix) and n[len(prefix):].isdigit()), default=0) + 1
number = f'{prefix}{sequence:04d}'
if number in used:
raise ValueError(f'Rechnungsnummer bereits vergeben: {number}')
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_-]{0,63}', number):
raise ValueError('Rechnungsnummer: 1–64 Buchstaben/Ziffern, Bindestrich oder Unterstrich')
target = root / default_invoice_folder_name(number, key)
if target.exists():
raise FileExistsError(f'Rechnungsordner existiert bereits: {target}')
inv = Invoice.model_validate({
'meta': {'invoice_number': number, 'invoice_date': issued, 'service_date': delivery,
'service_period_start': start, 'service_period_end': end,
'buyer_reference': buyer_reference, 'order_reference': order_reference,
'notes': 'Vielen Dank für Ihren Auftrag.'},
'seller': seller_data['seller'], 'buyer': record['buyer'],
'payment': {**seller_data['payment'], 'due_date': issued + timedelta(days=due_days),
'reference': f'Rechnung {number}'},
'items': [{'name': entry, 'quantity': volume, 'unit': unit,
'unit_price_net': price, 'tax_rate': tax}],
})
with tempfile.TemporaryDirectory(prefix='.create-', dir=root) as staging:
work = Path(staging) / 'invoice'
work.mkdir(mode=0o700)
(work / 'invoice.yaml').write_text(yaml.safe_dump(inv.model_dump(mode='json'),
allow_unicode=True, sort_keys=False), encoding='utf-8')
(work / 'assets').mkdir()
logo = presets / 'logo.svg'
if not logo.is_file():
logo = resources / 'assets/logo.svg'
if logo.is_file():
shutil.copy2(logo, work / 'assets/logo.svg')
work.rename(target)
# Retain the reserved number and input on build failures for diagnosis/retry.
from .logging_utils import setup_logging
logger = setup_logging('WARNING', target / 'out/logs/run.log')
progress(f'Rechnungsdaten vollständig: {number} · {inv.buyer.name}')
progress('PDF/A und strukturierte Rechnungsdaten werden erzeugt …')
try:
build = build_in_folder(target, 'out', resources / 'templates/invoice.html.j2', logger, jar, True, True)
except Exception as exc:
raise RuntimeError(f'Erstellung fehlgeschlagen. Entwurf: {target}. Log: {target / "out/logs/run.log"}. {exc}') from exc
progress('Rechnung validiert: PDF/A und EN16931-XML technisch gültig.')
attachment = target / f'Rechnung_{number}.pdf'
shutil.copy2(build.facturx_pdf, attachment)
manifest = {str(p.relative_to(target)): hashlib.sha256(p.read_bytes()).hexdigest()
for p in [target / 'invoice.yaml', build.facturx_xml, build.facturx_pdf,
build.validate_report, attachment]}
(target / 'out/manifest.json').write_text(json.dumps(manifest, indent=2) + '\n', encoding='utf-8')
totals = compute_totals(inv)
progress(f'Rechnung erzeugt · Netto: {money_fmt(totals.net_total)} · USt: {money_fmt(totals.tax_total)} · Brutto: {money_fmt(totals.gross_total)}')
return CreatedInvoice(build, attachment, inv)
+45
View File
@@ -0,0 +1,45 @@
"""Prepare a reviewed email draft; never sends mail automatically."""
from email.message import EmailMessage
from email.policy import SMTP
from pathlib import Path
import hashlib
import json
from .io import load_invoice_yaml
def prepare_email(invoice_dir: Path, jar: Path, logger, recipient: str | None = None) -> Path:
# Invalidate any previous draft before rebuilding.
out = invoice_dir / 'out'
(out / 'invoice.eml').unlink(missing_ok=True)
(out / 'manifest.json').unlink(missing_ok=True)
inv = load_invoice_yaml(invoice_dir / 'invoice.yaml')
recipient = recipient or inv.buyer.email
import re
if not recipient or not re.fullmatch(r'[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+', recipient):
raise ValueError('Set buyer.email or pass --to with the agreed invoice address')
out = invoice_dir / 'out'
# Rebuild from current YAML so the draft cannot attach an obsolete invoice.
from .pipeline import build_in_folder
template = Path(__file__).resolve().parent.parent / 'templates/invoice.html.j2'
if not template.is_file():
template = Path(__file__).resolve().parent / 'data/templates/invoice.html.j2'
result = build_in_folder(invoice_dir, 'out', template, logger, jar, True, True)
pdf = result.facturx_pdf
msg = EmailMessage(policy=SMTP)
msg['From'] = inv.seller.email or ''
if not inv.seller.email:
raise ValueError('seller.email is required for an email draft')
msg['To'] = recipient
msg['Subject'] = f'Rechnung {inv.meta.invoice_number} – {inv.seller.name}'
msg['X-Unsent'] = '1'
msg.set_content(f'Sehr geehrte Damen und Herren,\n\nanbei erhalten Sie unsere E-Rechnung {inv.meta.invoice_number} als ZUGFeRD/Factur-X-PDF mit eingebetteten strukturierten Rechnungsdaten.\n\nVielen Dank für Ihren Auftrag.\n\nFreundliche Grüße\n{inv.seller.name}\n')
msg.add_attachment(pdf.read_bytes(), maintype='application', subtype='pdf', filename=f'Rechnung_{_safe_name(inv.meta.invoice_number)}.pdf')
target = out / 'invoice.eml'
target.write_bytes(msg.as_bytes())
manifest = {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in
[invoice_dir / 'invoice.yaml', result.facturx_xml, pdf, result.validate_report, target]}
(out / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n', encoding='utf-8')
return target
def _safe_name(number: str) -> str:
import re
return re.sub(r'[^A-Za-z0-9_-]', '_', number)
+103 -15
View File
@@ -1,22 +1,110 @@
from __future__ import annotations """UN/CEFACT CII D16B, EN16931: positive, taxable service invoices."""
from pathlib import Path from pathlib import Path
from decimal import Decimal from decimal import Decimal
from pycheval import EN16931Invoice, Money, generate_xml import xml.etree.ElementTree as ET
from .calc import compute_totals, line_net
from .models import Invoice from .models import Invoice
from .calc import compute_totals
NS = {k: f'urn:un:unece:uncefact:data:standard:{v}:100' for k, v in {
'rsm': 'CrossIndustryInvoice', 'ram': 'ReusableAggregateBusinessInformationEntity',
'udt': 'UnqualifiedDataType'}.items()}
for prefix, uri in NS.items():
ET.register_namespace(prefix, uri)
def node(parent, name, value=None, **attrs):
prefix, local = name.split(':')
el = ET.SubElement(parent, f'{{{NS[prefix]}}}{local}', attrs)
if value is not None:
el.text = format(value, "f") if isinstance(value, Decimal) else str(value)
return el
def ram(parent, name, value=None, **attrs):
return node(parent, 'ram:' + name, value, **attrs)
def date_node(parent, name, value):
node(ram(parent, name), 'udt:DateTimeString', value.strftime('%Y%m%d'), format='102')
def party(parent, name, data):
p = ram(parent, name)
ram(p, 'Name', data.name)
address = ram(p, 'PostalTradeAddress')
for key, value in [('PostcodeCode', data.address.postal_code), ('LineOne', data.address.street),
('CityName', data.address.city), ('CountryID', data.address.country)]:
ram(address, key, value)
if data.email:
ram(ram(p, 'URIUniversalCommunication'), 'URIID', data.email, schemeID='EM')
for value, scheme in [(data.vat_id, 'VA'), (data.tax_number, 'FC')]:
if value:
ram(ram(p, 'SpecifiedTaxRegistration'), 'ID', value, schemeID=scheme)
def generate_facturx_xml(inv: Invoice, out_xml: Path, logger) -> Path: def generate_facturx_xml(inv: Invoice, out_xml: Path, logger) -> Path:
out_xml.parent.mkdir(parents=True, exist_ok=True)
totals = compute_totals(inv) totals = compute_totals(inv)
root = ET.Element(f'{{{NS["rsm"]}}}CrossIndustryInvoice')
invoice = EN16931Invoice( context = node(root, 'rsm:ExchangedDocumentContext')
invoice_number=inv.meta.invoice_number, ram(ram(context, 'GuidelineSpecifiedDocumentContextParameter'), 'ID', 'urn:cen.eu:en16931:2017')
invoice_date=inv.meta.invoice_date, doc = node(root, 'rsm:ExchangedDocument')
currency=inv.meta.currency, ram(doc, 'ID', inv.meta.invoice_number)
grand_total=Money(str(Decimal(str(totals.gross_total)).quantize(Decimal("0.01"))), inv.meta.currency), ram(doc, 'TypeCode', '380')
) date_node(doc, 'IssueDateTime', inv.meta.invoice_date)
if inv.meta.notes:
xml_string = generate_xml(invoice) ram(ram(doc, 'IncludedNote'), 'Content', inv.meta.notes)
out_xml.write_text(xml_string, encoding="utf-8") transaction = node(root, 'rsm:SupplyChainTradeTransaction')
logger.info("Wrote Factur-X XML: %s", out_xml) for index, item in enumerate(inv.items, 1):
line = ram(transaction, 'IncludedSupplyChainTradeLineItem')
ram(ram(line, 'AssociatedDocumentLineDocument'), 'LineID', index)
product = ram(line, 'SpecifiedTradeProduct')
ram(product, 'Name', item.name)
if item.description:
ram(product, 'Description', item.description)
agreement = ram(line, 'SpecifiedLineTradeAgreement')
ram(ram(agreement, 'NetPriceProductTradePrice'), 'ChargeAmount', item.unit_price_net)
ram(ram(line, 'SpecifiedLineTradeDelivery'), 'BilledQuantity', item.quantity, unitCode=item.unit)
settlement = ram(line, 'SpecifiedLineTradeSettlement')
tax = ram(settlement, 'ApplicableTradeTax')
ram(tax, 'TypeCode', 'VAT')
ram(tax, 'CategoryCode', 'S')
ram(tax, 'RateApplicablePercent', item.tax_rate)
ram(ram(settlement, 'SpecifiedTradeSettlementLineMonetarySummation'), 'LineTotalAmount', line_net(item))
agreement = ram(transaction, 'ApplicableHeaderTradeAgreement')
if inv.meta.buyer_reference:
ram(agreement, 'BuyerReference', inv.meta.buyer_reference)
party(agreement, 'SellerTradeParty', inv.seller)
party(agreement, 'BuyerTradeParty', inv.buyer)
if inv.meta.order_reference:
ram(ram(agreement, 'BuyerOrderReferencedDocument'), 'IssuerAssignedID', inv.meta.order_reference)
delivery = ram(transaction, 'ApplicableHeaderTradeDelivery')
date_node(ram(delivery, 'ActualDeliverySupplyChainEvent'), 'OccurrenceDateTime', inv.meta.service_date)
settlement = ram(transaction, 'ApplicableHeaderTradeSettlement')
ram(settlement, 'PaymentReference', inv.payment.reference)
ram(settlement, 'InvoiceCurrencyCode', inv.meta.currency)
if inv.payment.iban:
means = ram(settlement, 'SpecifiedTradeSettlementPaymentMeans')
ram(means, 'TypeCode', '58')
ram(ram(means, 'PayeePartyCreditorFinancialAccount'), 'IBANID', inv.payment.iban.replace(' ', ''))
if inv.payment.bic:
ram(ram(means, 'PayeeSpecifiedCreditorFinancialInstitution'), 'BICID', inv.payment.bic)
for rate, base in totals.basis_by_rate.items():
tax = ram(settlement, 'ApplicableTradeTax')
ram(tax, 'CalculatedAmount', totals.tax_by_rate[rate])
ram(tax, 'TypeCode', 'VAT')
ram(tax, 'BasisAmount', base)
ram(tax, 'CategoryCode', 'S')
ram(tax, 'RateApplicablePercent', rate)
if inv.meta.service_period_start:
period = ram(settlement, 'BillingSpecifiedPeriod')
date_node(period, 'StartDateTime', inv.meta.service_period_start)
date_node(period, 'EndDateTime', inv.meta.service_period_end)
terms = ram(settlement, 'SpecifiedTradePaymentTerms')
date_node(terms, 'DueDateDateTime', inv.payment.due_date)
summary = ram(settlement, 'SpecifiedTradeSettlementHeaderMonetarySummation')
for name, amount in [('LineTotalAmount', totals.net_total), ('ChargeTotalAmount', '0.00'),
('AllowanceTotalAmount', '0.00'), ('TaxBasisTotalAmount', totals.net_total)]:
ram(summary, name, amount)
ram(summary, 'TaxTotalAmount', totals.tax_total, currencyID=inv.meta.currency)
ram(summary, 'GrandTotalAmount', totals.gross_total)
ram(summary, 'DuePayableAmount', totals.gross_total)
ET.indent(root)
out_xml.parent.mkdir(parents=True, exist_ok=True)
ET.ElementTree(root).write(out_xml, encoding='utf-8', xml_declaration=True)
logger.info('Wrote EN16931 XML: %s', out_xml)
return out_xml return out_xml
+13 -3
View File
@@ -1,10 +1,11 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
import shutil import shutil
from .paths import presets_root
from datetime import date, timedelta from datetime import date, timedelta
from .presets import load_yaml, deep_merge, load_customer_preset from .presets import load_yaml, deep_merge, load_customer_preset
from .invoice_number import suggest_next_invoice_number from .invoice_number import suggest_next_invoice_number, invoice_root_lock
from .folder_naming import default_invoice_folder_name from .folder_naming import default_invoice_folder_name
import yaml import yaml
@@ -24,6 +25,8 @@ def init_invoice_folder(
project: str | None = None, project: str | None = None,
) -> Path: ) -> Path:
target_dir = target_dir.resolve() target_dir = target_dir.resolve()
if (target_dir / "invoice.yaml").exists() and not force:
raise FileExistsError(f"{target_dir / 'invoice.yaml'} already exists")
target_dir.mkdir(parents=True, exist_ok=True) target_dir.mkdir(parents=True, exist_ok=True)
# Create standard subfolders # Create standard subfolders
@@ -44,6 +47,8 @@ def init_invoice_folder(
# Apply seller defaults # Apply seller defaults
seller_defaults = load_yaml(repo_root / "presets" / "seller.default.yaml") seller_defaults = load_yaml(repo_root / "presets" / "seller.default.yaml")
local = presets_root(repo_root)
seller_defaults = deep_merge(seller_defaults, load_yaml(local / "seller.default.yaml"))
merged = deep_merge(base_tpl, seller_defaults) merged = deep_merge(base_tpl, seller_defaults)
# Apply customer preset # Apply customer preset
@@ -53,7 +58,7 @@ def init_invoice_folder(
merged = deep_merge(merged, preset) merged = deep_merge(merged, preset)
logger.info("Applied customer preset: %s", customer) logger.info("Applied customer preset: %s", customer)
else: else:
logger.warning("Customer preset not found for: %s (continuing with generic buyer)", customer) raise ValueError(f"Unknown customer preset: {customer}")
# Smart defaults for meta/payment # Smart defaults for meta/payment
today = date.today() today = date.today()
@@ -102,7 +107,7 @@ def init_invoice_folder(
logger.info("Initialized invoice folder: %s", target_dir) logger.info("Initialized invoice folder: %s", target_dir)
return target_dir return target_dir
def init_invoice_under_root( def _init_invoice_under_root(
invoices_root: Path, invoices_root: Path,
repo_root: Path, repo_root: Path,
logger, logger,
@@ -135,3 +140,8 @@ def init_invoice_under_root(
invoices_root=invoices_root, invoices_root=invoices_root,
project=project, project=project,
) )
def init_invoice_under_root(invoices_root: Path, **kwargs) -> Path:
with invoice_root_lock(invoices_root):
return _init_invoice_under_root(invoices_root, **kwargs)
+16
View File
@@ -26,3 +26,19 @@ def suggest_next_invoice_number(invoices_root: Path, year: int | None = None, pa
if yr == y and seq > max_seq: if yr == y and seq > max_seq:
max_seq = seq max_seq = seq
return f"{y}-{max_seq+1:04d}" return f"{y}-{max_seq+1:04d}"
from contextlib import contextmanager
@contextmanager
def invoice_root_lock(root: Path):
"""Serialize local number allocation and folder creation on Linux."""
import fcntl
root = root.expanduser().resolve()
root.mkdir(parents=True, exist_ok=True)
with (root / '.jr-einvoice.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(lock, fcntl.LOCK_UN)
+3 -2
View File
@@ -7,8 +7,9 @@ def setup_logging(log_level: str, log_file: Optional[Path]) -> logging.Logger:
logger = logging.getLogger("jr_einvoice") logger = logging.getLogger("jr_einvoice")
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
if list(logger.handlers): for handler in list(logger.handlers):
return logger logger.removeHandler(handler)
handler.close()
fmt = logging.Formatter( fmt = logging.Formatter(
fmt="%(asctime)s.%(msecs)03d %(levelname)s [%(name)s] %(message)s", fmt="%(asctime)s.%(msecs)03d %(levelname)s [%(name)s] %(message)s",
+61 -19
View File
@@ -1,56 +1,98 @@
from __future__ import annotations from __future__ import annotations
from datetime import date from datetime import date
from pydantic import BaseModel, Field, field_validator from decimal import Decimal
from pydantic import BaseModel, Field, ConfigDict, model_validator, field_validator
from typing import List, Optional from typing import List, Optional
class Address(BaseModel): class StrictModel(BaseModel):
street: str model_config = ConfigDict(extra="forbid", str_strip_whitespace=True, allow_inf_nan=False)
postal_code: str
city: str
country: str = Field(min_length=2, max_length=2)
class Party(BaseModel): class Address(StrictModel):
name: str street: str = Field(min_length=1)
postal_code: str = Field(min_length=1)
city: str = Field(min_length=1)
country: str = Field(pattern=r"^[A-Z]{2}$")
class Party(StrictModel):
name: str = Field(min_length=1)
vat_id: Optional[str] = None vat_id: Optional[str] = None
tax_number: Optional[str] = None tax_number: Optional[str] = None
email: Optional[str] = None email: Optional[str] = None
address: Address address: Address
@field_validator("vat_id", "tax_number")
@classmethod
def nonempty_tax_id(cls, value):
if value is not None and not value:
raise ValueError("Tax identifiers must not be empty")
return value
@field_validator("email") @field_validator("email")
@classmethod @classmethod
def _email_shape(cls, v: Optional[str]): def _email_shape(cls, v: Optional[str]):
if v is None: if v is None:
return v return v
if "@" not in v or "." not in v.split("@")[-1]: import re
if not re.fullmatch(r"[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+", v):
raise ValueError("email does not look valid") raise ValueError("email does not look valid")
return v return v
class Meta(BaseModel): class Meta(StrictModel):
invoice_number: str = Field(min_length=1) invoice_number: str = Field(min_length=1)
invoice_date: date invoice_date: date
service_date: date service_date: date
currency: str = Field(min_length=3, max_length=3, default="EUR") currency: str = Field(pattern=r"^EUR$", default="EUR")
service_period_start: Optional[date] = None
service_period_end: Optional[date] = None
buyer_reference: Optional[str] = None
order_reference: Optional[str] = None
language: Optional[str] = "de" language: Optional[str] = "de"
notes: Optional[str] = None notes: Optional[str] = None
class Payment(BaseModel): class Payment(StrictModel):
due_date: date due_date: date
reference: str = Field(min_length=1) reference: str = Field(min_length=1)
iban: Optional[str] = None iban: Optional[str] = None
bic: Optional[str] = None bic: Optional[str] = None
bank_name: Optional[str] = None bank_name: Optional[str] = None
class Item(BaseModel): @field_validator("iban")
@classmethod
def valid_iban(cls, value):
if value is None:
return value
import re
value = value.replace(" ", "").upper()
if not re.fullmatch(r"[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}", value):
raise ValueError("Invalid IBAN format")
rearranged = value[4:] + value[:4]
digits = ''.join(str(ord(c) - 55) if c.isalpha() else c for c in rearranged)
if int(digits) % 97 != 1:
raise ValueError("Invalid IBAN checksum")
return value
class Item(StrictModel):
name: str = Field(min_length=1) name: str = Field(min_length=1)
description: Optional[str] = None description: Optional[str] = None
quantity: float = Field(gt=0) quantity: Decimal = Field(gt=0)
unit: str = Field(min_length=1) # e.g. HUR, C62 unit: str = Field(pattern=r"^[A-Z0-9]{2,3}$") # e.g. HUR, C62
unit_price_net: float = Field(ge=0) unit_price_net: Decimal = Field(ge=0)
tax_rate: float = Field(ge=0, le=100) tax_rate: Decimal = Field(gt=0, le=100)
class Invoice(BaseModel): class Invoice(StrictModel):
meta: Meta meta: Meta
seller: Party seller: Party
buyer: Party buyer: Party
payment: Payment payment: Payment
items: List[Item] items: List[Item] = Field(min_length=1)
@model_validator(mode="after")
def check_invoice(self):
if not (self.seller.vat_id or self.seller.tax_number):
raise ValueError("Seller requires vat_id or tax_number")
if self.payment.due_date < self.meta.invoice_date:
raise ValueError("Payment due_date precedes invoice_date")
start, end = self.meta.service_period_start, self.meta.service_period_end
if bool(start) != bool(end) or (start and end and start > end):
raise ValueError("Service period requires ordered start and end")
return self
+57
View File
@@ -0,0 +1,57 @@
"""Fast monthly service billing from existing customer presets."""
import calendar
import tempfile
from .invoice_number import invoice_root_lock
import re
from datetime import date, timedelta
from decimal import Decimal
from pathlib import Path
from .init import init_invoice_folder
from .presets import load_yaml
from .models import Invoice
from .init import _dump_yaml
from .folder_naming import default_invoice_folder_name
MONTHS = ('Januar Februar März April Mai Juni Juli August September Oktober November Dezember').split()
def _monthly_invoice(root: Path, repo_root: Path, logger, customer: str, month: str,
hours: Decimal, number: str, issued: date | None = None,
rate: Decimal | None = None, due_days: int = 30) -> Path:
if not re.fullmatch(r'\d{4}-\d{2}', month):
raise ValueError('Month must be YYYY-MM')
year, m = map(int, month.split('-'))
start, end = date(year, m, 1), date(year, m, calendar.monthrange(year, m)[1])
if not hours.is_finite() or hours <= 0 or due_days < 0:
raise ValueError('Hours must be positive and due_days nonnegative')
target = root.expanduser().resolve() / default_invoice_folder_name(number, customer)
# Explicit number required: existing PDF invoices may live outside this root.
if target.exists():
raise FileExistsError(f'Invoice folder exists: {target}')
for existing in root.expanduser().glob('*/invoice.yaml'):
if str(load_yaml(existing).get('meta', {}).get('invoice_number', '')) == number:
raise ValueError(f'Invoice number already used: {number}')
with tempfile.TemporaryDirectory(prefix='.draft-', dir=root.expanduser().resolve()) as staging:
work = Path(staging) / 'invoice'
init_invoice_folder(work, repo_root, logger, customer=customer, invoice_number=number, due_days=due_days)
path = work / 'invoice.yaml'
data = load_yaml(path)
if len(data['items']) != 1 or data['items'][0]['unit'] != 'HUR':
raise ValueError('Monthly billing requires a preset with one HUR line')
issued = issued or date.today()
data['meta'].update(invoice_date=str(issued), service_date=str(end),
service_period_start=str(start), service_period_end=str(end))
data['payment']['due_date'] = str(issued + timedelta(days=due_days))
item = data['items'][0]
item['quantity'] = str(hours)
item['name'] = f'Geleistete Stunden {MONTHS[m-1]} {year}'
if rate is not None:
item['unit_price_net'] = str(rate)
Invoice.model_validate(data)
_dump_yaml(data, path)
work.rename(target)
return target
def monthly_invoice(root: Path, *args, **kwargs) -> Path:
with invoice_root_lock(root):
return _monthly_invoice(root, *args, **kwargs)
+19 -5
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
import subprocess import subprocess
import xml.etree.ElementTree as ET
class MustangError(RuntimeError): class MustangError(RuntimeError):
pass pass
@@ -10,7 +11,7 @@ def _run(jar: Path, args: list[str], logger) -> subprocess.CompletedProcess[str]
raise MustangError(f"Mustang jar not found: {jar}") raise MustangError(f"Mustang jar not found: {jar}")
cmd = ["java", "-Xmx1G", "-Dfile.encoding=UTF-8", "-jar", str(jar)] + args cmd = ["java", "-Xmx1G", "-Dfile.encoding=UTF-8", "-jar", str(jar)] + args
logger.debug("Running Mustang: %s", " ".join(cmd)) logger.debug("Running Mustang: %s", " ".join(cmd))
proc = subprocess.run(cmd, capture_output=True, text=True) proc = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
logger.debug("Mustang stdout:\n%s", proc.stdout.strip()) logger.debug("Mustang stdout:\n%s", proc.stdout.strip())
logger.debug("Mustang stderr:\n%s", proc.stderr.strip()) logger.debug("Mustang stderr:\n%s", proc.stderr.strip())
return proc return proc
@@ -22,10 +23,13 @@ def combine_facturx(jar: Path, source_pdf: Path, source_xml: Path, out_pdf: Path
"--source", str(source_pdf), "--source", str(source_pdf),
"--source-xml", str(source_xml), "--source-xml", str(source_xml),
"--out", str(out_pdf), "--out", str(out_pdf),
"--profile", profile "--format", "fx", "--version", "1",
"--profile", profile, "--no-additional-attachments"
], logger) ], logger)
if proc.returncode != 0: if proc.returncode != 0:
raise MustangError(f"Mustang combine failed (rc={proc.returncode}). See logs for details.") raise MustangError(f"Mustang combine failed (rc={proc.returncode}). See logs for details.")
if not out_pdf.is_file() or out_pdf.stat().st_size == 0:
raise MustangError("Mustang produced no PDF")
return out_pdf return out_pdf
def validate(jar: Path, source: Path, out_report: Path, logger, no_notices: bool = True) -> Path: def validate(jar: Path, source: Path, out_report: Path, logger, no_notices: bool = True) -> Path:
@@ -35,8 +39,18 @@ def validate(jar: Path, source: Path, out_report: Path, logger, no_notices: bool
args += ["--no-notices"] args += ["--no-notices"]
args += ["--action", "validate", "--source", str(source)] args += ["--action", "validate", "--source", str(source)]
proc = _run(jar, args, logger) proc = _run(jar, args, logger)
if proc.returncode != 0: # Persist reports even on failure; a zero exit code alone is insufficient.
raise MustangError(f"Mustang validate failed (rc={proc.returncode}). See logs for details.") content = proc.stdout.strip()
content = proc.stdout.strip() or "<report>empty stdout from Mustang; check logs</report>"
out_report.write_text(content, encoding="utf-8") out_report.write_text(content, encoding="utf-8")
try:
root = ET.fromstring(content)
summaries = root.findall(".//summary")
valid = (root.tag == "validation"
and root.find("xml/summary") is not None
and root.find("summary") is not None
and all(s.get("status") == "valid" for s in summaries))
except ET.ParseError:
valid = False
if proc.returncode != 0 or not valid:
raise MustangError(f"Validation failed; see {out_report} and logs")
return out_report return out_report
+18
View File
@@ -0,0 +1,18 @@
"""Bundled resources and writable, private user data locations."""
import os
from pathlib import Path
def resource_root() -> Path:
source = Path(__file__).resolve().parent.parent
return source if (source / 'templates/invoice.html.j2').is_file() else Path(__file__).resolve().parent / 'data'
def private_root(resources: Path | None = None) -> Path:
root = resources or resource_root()
default = root / 'private' if root.name != 'data' else Path.home() / '.local/share/jr-einvoice'
return Path(os.environ.get('JR_EINVOICE_HOME', str(default))).expanduser().resolve()
def presets_root(resources: Path | None = None) -> Path:
return Path(os.environ.get('JR_EINVOICE_PRESETS', str(private_root(resources) / 'presets'))).expanduser().resolve()
+15 -1
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
import re
import shutil
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import Optional
@@ -37,6 +39,12 @@ def build_in_folder(
out_dir = invoice_dir / out_subdir out_dir = invoice_dir / out_subdir
(out_dir / "logs").mkdir(parents=True, exist_ok=True) (out_dir / "logs").mkdir(parents=True, exist_ok=True)
attachment = invoice_dir / ('Rechnung_' + re.sub(r'[^A-Za-z0-9_-]', '_', inv.meta.invoice_number) + '.pdf')
# A new build invalidates old delivery drafts and prior validation results.
for stale in [attachment, out_dir / "invoice.eml", out_dir / "manifest.json",
out_dir / "invoice.facturx.pdf", out_dir / "validation/validate_report.xml"]:
stale.unlink(missing_ok=True)
logger.info("Loaded invoice: %s (folder=%s)", inv.meta.invoice_number, invoice_dir) logger.info("Loaded invoice: %s (folder=%s)", inv.meta.invoice_number, invoice_dir)
logger.info("Artifacts directory: %s", out_dir) logger.info("Artifacts directory: %s", out_dir)
@@ -62,6 +70,12 @@ def build_in_folder(
if do_validate: if do_validate:
logger.info("Validating Factur-X PDF via Mustang") logger.info("Validating Factur-X PDF via Mustang")
report = out_dir / "validation" / "validate_report.xml" report = out_dir / "validation" / "validate_report.xml"
result.validate_report = validate(mustang_jar, result.facturx_pdf or (out_dir / "invoice.facturx.pdf"), report, logger) try:
result.validate_report = validate(mustang_jar, result.facturx_pdf, report, logger)
except Exception:
# Failed output must not be mistaken for a ready-to-send invoice.
result.facturx_pdf.unlink(missing_ok=True)
raise
shutil.copy2(result.facturx_pdf, attachment)
return result return result
+7
View File
@@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
import yaml import yaml
from .paths import presets_root
import re
def load_yaml(path: Path) -> dict: def load_yaml(path: Path) -> dict:
if not path.exists(): if not path.exists():
@@ -18,6 +20,11 @@ def deep_merge(a: dict, b: dict) -> dict:
def load_customer_preset(repo_root: Path, customer_key: str) -> dict: def load_customer_preset(repo_root: Path, customer_key: str) -> dict:
key = customer_key.strip().lower() key = customer_key.strip().lower()
if not re.fullmatch(r"[a-z0-9_-]+", key):
raise ValueError("Customer key must contain only letters, digits, _ or -")
local = presets_root(repo_root)
if (local / "customers" / f"{key}.yaml").is_file():
return load_yaml(local / "customers" / f"{key}.yaml")
# Prefer per-customer file # Prefer per-customer file
file_preset = repo_root / "presets" / "customers" / f"{key}.yaml" file_preset = repo_root / "presets" / "customers" / f"{key}.yaml"
if file_preset.exists(): if file_preset.exists():
+103
View File
@@ -0,0 +1,103 @@
"""Friendly CLI: einvoice --create --Customer ... --Entry ... --Volume ... --Price ..."""
import argparse
from datetime import date
import json
from pathlib import Path
import sys
from .create import create_invoice, decimal_input, find_customer, save_customer
from .logging_utils import setup_logging
from .paths import private_root, presets_root, resource_root
def parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description='Mit einem Aufruf eine validierte ZUGFeRD-Rechnung als PDF erzeugen.',
allow_abbrev=False)
mode = p.add_mutually_exclusive_group(required=True)
mode.add_argument('--create', action='store_true', help='Rechnung anlegen, PDF erzeugen und validieren')
mode.add_argument('--customers', action='store_true', help='Gespeicherte Kunden anzeigen')
p.add_argument('--Customer', '--customer', dest='customer', help='Gespeicherter Kundenname oder neuer Kunde')
p.add_argument('--Entry', '--entry', dest='entry', help='Leistungsbeschreibung')
p.add_argument('--Volume', '--volume', dest='volume', type=decimal_input, help='Menge, z. B. 120 oder 72,5')
p.add_argument('--Price', '--price', dest='price', type=decimal_input, help='Nettopreis je Einheit, z. B. "85,00 €"')
p.add_argument('--Unit', '--unit', dest='unit', default='HUR', help='Einheit: HUR=Stunden (Standard), C62=Stück')
p.add_argument('--Tax', '--tax', dest='tax', type=decimal_input, default=decimal_input('19'), help='USt in Prozent, Standard 19')
p.add_argument('--Number', '--number', dest='number', help='Freie Nummer; Standard automatisch JR-JJJJ-NNNN')
p.add_argument('--Date', '--date', dest='issued', type=date.fromisoformat, help='Rechnungsdatum YYYY-MM-DD, Standard heute')
p.add_argument('--Month', '--month', dest='month', help='Leistungsmonat YYYY-MM; auch aus deutschem Monatsnamen in Entry erkannt')
p.add_argument('--ServiceDate', '--service-date', dest='service_date', type=date.fromisoformat, help='Einzelnes Leistungsdatum YYYY-MM-DD')
p.add_argument('--DueDays', '--due-days', dest='due_days', type=int, default=30, help='Zahlungsziel in Tagen, Standard 30')
p.add_argument('--BuyerReference', '--buyer-reference', dest='buyer_reference', help='Kundenreferenz')
p.add_argument('--OrderReference', '--order-reference', dest='order_reference', help='Bestellnummer')
p.add_argument('--Street', '--street', dest='street', help='Straße/Hausnummer bei neuem Kunden')
p.add_argument('--PostalCode', '--postal-code', dest='postal_code', help='Postleitzahl bei neuem Kunden')
p.add_argument('--City', '--city', dest='city', help='Ort bei neuem Kunden')
p.add_argument('--Country', '--country', dest='country', default='DE', help='Ländercode, Standard DE')
p.add_argument('--Email', '--email', dest='email', help='Rechnungsadresse bei neuem Kunden (optional)')
p.add_argument('--VatId', '--vat-id', dest='vat_id', help='USt-ID bei neuem Kunden (optional)')
p.add_argument('--root', type=Path, default=private_root() / 'invoices', help='Stammordner für Rechnungen')
p.add_argument('--mustang-jar', type=Path, help='Abweichender Mustang-CLI-Pfad')
p.add_argument('--json', action='store_true', help='Ergebnisse als JSON; keine interaktiven Fragen')
p.add_argument('--non-interactive', action='store_true', help='Fehlende Daten als Fehler melden statt fragen')
return p
def main(argv=None):
p = parser()
args = p.parse_args(argv)
presets = presets_root()
try:
if args.customers:
for path in sorted((presets / 'customers').glob('*.yaml')):
from .presets import load_yaml
print(f'{path.stem}: {load_yaml(path).get("buyer", {}).get("name", "?")}')
return
missing = [flag for flag in ('customer', 'entry', 'volume', 'price') if getattr(args, flag) is None]
if missing:
p.error('Für --create fehlen: ' + ', '.join('--' + name.title() for name in missing))
if args.volume <= 0 or args.price < 0 or not (0 < args.tax <= 100) or args.due_days < 0:
p.error('Menge muss positiv sein; Preis/Zahlungsziel nicht negativ; Steuer > 0 und <= 100')
if find_customer(presets, args.customer) is None:
fields = {'street': args.street, 'postal_code': args.postal_code, 'city': args.city}
labels = {'street': 'Straße und Hausnummer', 'postal_code': 'Postleitzahl', 'city': 'Ort'}
absent = [name for name, value in fields.items() if not value]
if absent and (args.non_interactive or args.json or not sys.stdin.isatty()):
raise ValueError('Neuer Kunde: bitte ' + ', '.join('--' + {'street':'Street','postal_code':'PostalCode','city':'City'}[name] for name in absent) + ' angeben')
for name in absent:
fields[name] = input(f'Neuer Kunde {args.customer} – {labels[name]}: ').strip()
save_customer(presets, args.customer, {'name': args.customer, 'email': args.email,
'vat_id': args.vat_id, 'address': {**fields, 'country': args.country.upper()}})
if not args.json:
print(f'Kunde gespeichert: {args.customer}', flush=True)
else:
if any([args.street, args.postal_code, args.city, args.email, args.vat_id]) or args.country != 'DE':
raise ValueError('Kunde bereits gespeichert. Adressoptionen gelten nur bei Neuanlage; Stammdaten separat bearbeiten')
from .cli import _default_mustang_jar
result = create_invoice(root=args.root, resources=resource_root(), presets=presets,
customer=args.customer, entry=args.entry, volume=args.volume,
price=args.price, jar=args.mustang_jar or _default_mustang_jar(),
logger=setup_logging('WARNING', None), number=args.number,
issued=args.issued, month=args.month, service_date=args.service_date,
unit=args.unit, tax=args.tax, due_days=args.due_days,
buyer_reference=args.buyer_reference, order_reference=args.order_reference,
progress=(lambda message: None) if args.json else lambda message: print(message, flush=True))
if args.json:
print(json.dumps({'invoice_number': result.invoice.meta.invoice_number,
'invoice_dir': str(result.build.invoice_dir), 'pdf': str(result.attachment),
'validation_report': str(result.build.validate_report),
'log': str(result.build.out_dir / 'logs/run.log'),
'technical_validation': 'valid'}, ensure_ascii=False))
else:
inv = result.invoice
print(f'Leistung: {inv.meta.service_period_start or inv.meta.service_date}' +
(f' bis {inv.meta.service_period_end}' if inv.meta.service_period_end else ''))
print(f'PDF zum Anhängen an deine E-Mail:\n{result.attachment}')
print(f'Prüfbericht: {result.build.validate_report}')
print('Vor Versand Leistungsdaten, Betrag und Empfänger prüfen.')
except Exception as exc:
print(f'FEHLER: {exc}', file=sys.stderr)
raise SystemExit(1) from exc
if __name__ == '__main__':
main()
+9 -5
View File
@@ -6,16 +6,20 @@ import shutil
from jinja2 import Environment, FileSystemLoader, select_autoescape from jinja2 import Environment, FileSystemLoader, select_autoescape
from .models import Invoice from .models import Invoice
from .calc import compute_totals from .calc import compute_totals, line_net
from .types import RenderResult from .types import RenderResult
from .utils import money_fmt from .utils import money_fmt, number_fmt
def render_html(inv: Invoice, template_path: Path) -> str: def render_html(inv: Invoice, template_path: Path) -> str:
env = Environment( env = Environment(
loader=FileSystemLoader(str(template_path.parent)), loader=FileSystemLoader(str(template_path.parent)),
autoescape=select_autoescape(["html", "xml"]), autoescape=select_autoescape(["html", "xml", "j2"]),
) )
env.globals["money"] = lambda x: money_fmt(x, inv.meta.currency) env.globals["money"] = lambda x: money_fmt(x, inv.meta.currency)
env.globals["number"] = number_fmt
env.filters["de_date"] = lambda d: d.strftime("%d.%m.%Y") if d else ""
env.globals["unit_label"] = lambda unit: {"HUR": "Std.", "C62": "Stück", "DAY": "Tage"}.get(unit, unit)
env.globals["price"] = lambda x: f"{number_fmt(x)} {inv.meta.currency}"
tpl = env.get_template(template_path.name) tpl = env.get_template(template_path.name)
totals = compute_totals(inv) totals = compute_totals(inv)
@@ -23,7 +27,7 @@ def render_html(inv: Invoice, template_path: Path) -> str:
items = [] items = []
for it in inv.items: for it in inv.items:
d = it.model_dump() d = it.model_dump()
d["line_total_net"] = float(it.quantity) * float(it.unit_price_net) d["line_total_net"] = line_net(it)
items.append(type("Obj", (), d)) items.append(type("Obj", (), d))
ctx = { ctx = {
@@ -50,7 +54,7 @@ def render_pdf(html: str, out_pdf: Path, logger, prefer_weasyprint: bool = True,
logger.info("Rendering PDF using WeasyPrint") logger.info("Rendering PDF using WeasyPrint")
try: try:
from weasyprint import HTML # type: ignore from weasyprint import HTML # type: ignore
HTML(string=html, base_url=base_url).write_pdf(str(out_pdf)) HTML(string=html, base_url=base_url).write_pdf(str(out_pdf), pdf_variant="pdf/a-3b")
return RenderResult(ok=True, backend="weasyprint", path=out_pdf) return RenderResult(ok=True, backend="weasyprint", path=out_pdf)
except Exception as e: except Exception as e:
logger.exception("WeasyPrint rendering failed; trying wkhtmltopdf if available: %s", e) logger.exception("WeasyPrint rendering failed; trying wkhtmltopdf if available: %s", e)
+1 -1
View File
@@ -4,7 +4,7 @@ import urllib.request
import hashlib import hashlib
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
MAVEN_BASE = "https://repo1.maven.org/maven2/org/mustangproject/Mustang-CLI" MAVEN_BASE = "https://repo.maven.apache.org/maven2/org/mustangproject/Mustang-CLI"
VERSION_FILE = "VERSION.txt" VERSION_FILE = "VERSION.txt"
class SetupError(RuntimeError): class SetupError(RuntimeError):
+9 -7
View File
@@ -1,9 +1,11 @@
from __future__ import annotations from decimal import Decimal
def number_fmt(value, precision: int = 2) -> str:
value = Decimal(str(value))
precision = max(precision, -value.normalize().as_tuple().exponent)
return f"{value:,.{precision}f}".replace(",", "X").replace(".", ",").replace("X", ".")
def money_fmt(value, currency: str = "EUR") -> str: def money_fmt(value, currency: str = "EUR") -> str:
try: return f"{Decimal(str(value)):,.2f} {currency}".replace(",", "X").replace(".", ",").replace("X", ".")
v = float(value)
except Exception:
v = 0.0
# de-DE formatting (simple, stable)
return f"{v:,.2f} {currency}".replace(",", "X").replace(".", ",").replace("X", ".")
+9 -3
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "jr-einvoice-toolchain" name = "jr-einvoice-toolchain"
version = "0.8.0" version = "1.0.0"
description = "Folder-first Factur-X (ZUGFeRD/EN16931) invoice generator for Linux/Arch: invoice folder + YAML -> PDF + XML -> Factur-X PDF + validation." description = "Folder-first Factur-X (ZUGFeRD/EN16931) invoice generator for Linux/Arch: invoice folder + YAML -> PDF + XML -> Factur-X PDF + validation."
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
@@ -15,8 +15,7 @@ dependencies = [
"rich>=13.7.1", "rich>=13.7.1",
"pydantic>=2.7.0", "pydantic>=2.7.0",
"PyYAML>=6.0.1", "PyYAML>=6.0.1",
"Jinja2>=3.1.4", "Jinja2>=3.1.4"
"pycheval>=0.2.0"
] ]
[project.optional-dependencies] [project.optional-dependencies]
@@ -26,6 +25,13 @@ pdf = [
[project.scripts] [project.scripts]
jr-einvoice = "jr_einvoice.cli:app" jr-einvoice = "jr_einvoice.cli:app"
einvoice = "jr_einvoice.quick_cli:main"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["jr_einvoice"] packages = ["jr_einvoice"]
[tool.hatch.build.targets.wheel.force-include]
"templates" = "jr_einvoice/data/templates"
"assets" = "jr_einvoice/data/assets"
"examples" = "jr_einvoice/data/examples"
"presets" = "jr_einvoice/data/presets"
-1
View File
@@ -3,4 +3,3 @@ rich>=13.7.1
pydantic>=2.7.0 pydantic>=2.7.0
PyYAML>=6.0.1 PyYAML>=6.0.1
Jinja2>=3.1.4 Jinja2>=3.1.4
pycheval>=0.2.0
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
echo "Use the built-in installer instead:" echo "Use the built-in installer instead:"
echo " jr-einvoice setup-mustang --version 2.22.0" echo " jr-einvoice setup-mustang --version 2.26.0"
+40 -21
View File
@@ -4,18 +4,32 @@
<meta charset="utf-8"> <meta charset="utf-8">
<title>Rechnung {{ meta.invoice_number }}</title> <title>Rechnung {{ meta.invoice_number }}</title>
<style> <style>
body { font-family: sans-serif; font-size: 12px; } @page {
.row { display: flex; justify-content: space-between; gap: 12px; } size: A4; margin: 18mm 17mm 21mm;
.muted { color: #555; } @bottom-left { content: "{{ seller.name | replace('"', '') }}"; font-family: sans-serif; font-size: 8pt; color: #64748b; }
h1 { font-size: 22px; margin: 0 0 8px 0; } @bottom-right { content: "Seite " counter(page) " von " counter(pages); font-family: sans-serif; font-size: 8pt; color: #64748b; }
.box { border: 1px solid #ddd; padding: 10px; border-radius: 6px; } }
table { width: 100%; border-collapse: collapse; margin-top: 14px; } * { box-sizing: border-box; }
th, td { border-bottom: 1px solid #eee; padding: 6px 6px; text-align: left; } body { font-family: "DejaVu Sans", sans-serif; font-size: 9pt; color: #1e293b; line-height: 1.55; }
th { background: #fafafa; } .row { display: flex; justify-content: space-between; gap: 16px; }
.muted { color: #64748b; }
h1 { color: #123655; font-size: 30pt; font-weight: 500; letter-spacing: -1px; margin: 0 0 12px; }
.box { border: 1px solid #e2e8f0; border-radius: 6px; padding: 14px; }
.row:first-of-type { margin-top: 20px; }
.box .muted:first-child { font-size: 8pt; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 7px; }
table { width: 100%; border-collapse: collapse; margin-top: 24px; }
thead { display: table-header-group; }
tr { break-inside: avoid; }
th, td { padding: 10px 6px; text-align: left; vertical-align: top; border-bottom: 1px solid #e2e8f0; }
th { color: white; background: #123655; font-size: 7pt; font-weight: 500; }
tbody tr:nth-child(even) { background: #f8fafc; }
.right { text-align: right; } .right { text-align: right; }
.totals { margin-top: 12px; width: 44%; margin-left: auto; } td.right { white-space: nowrap; }
.totals td { border: none; padding: 4px 6px; } .totals { margin-top: 16px; width: 58%; margin-left: auto; break-inside: avoid; }
.footer { margin-top: 18px; font-size: 11px; } .totals td { border: none; padding: 5px 7px; font-size: 8.5pt; }
.totals tr:last-child td { background: #edf4fa; color: #123655; border-top: 2px solid #123655; padding-top: 12px; padding-bottom: 12px; font-size: 11pt; }
.footer { margin-top: 28px; font-size: 8.5pt; border-top: 1px solid #e2e8f0; padding-top: 12px; }
.footer strong { color: #123655; }
</style> </style>
</head> </head>
<body> <body>
@@ -27,8 +41,12 @@
<div style="flex: 1;"> <div style="flex: 1;">
<h1>Rechnung</h1> <h1>Rechnung</h1>
<div class="muted">Rechnungsnummer: <strong>{{ meta.invoice_number }}</strong></div> <div class="muted">Rechnungsnummer: <strong>{{ meta.invoice_number }}</strong></div>
<div class="muted">Rechnungsdatum: {{ meta.invoice_date }}</div> <div class="muted">Rechnungsdatum: {{ meta.invoice_date | de_date }}</div>
<div class="muted">Leistungsdatum: {{ meta.service_date }}</div> {% if meta.service_period_start %}
<div class="muted">Leistungszeitraum: {{ meta.service_period_start | de_date }} – {{ meta.service_period_end | de_date }}</div>
{% else %}<div class="muted">Leistungsdatum: {{ meta.service_date | de_date }}</div>{% endif %}
{% if meta.buyer_reference %}<div>Kundenreferenz: {{ meta.buyer_reference }}</div>{% endif %}
{% if meta.order_reference %}<div>Bestellung: {{ meta.order_reference }}</div>{% endif %}
</div> </div>
<div class="box" style="min-width: 260px;"> <div class="box" style="min-width: 260px;">
<div><strong>{{ seller.name }}</strong></div> <div><strong>{{ seller.name }}</strong></div>
@@ -53,7 +71,7 @@
<div class="box" style="flex: 1;"> <div class="box" style="flex: 1;">
<div class="muted">Zahlungsinformationen</div> <div class="muted">Zahlungsinformationen</div>
<div>Fällig am: {{ payment.due_date }}</div> <div>Fällig am: {{ payment.due_date | de_date }}</div>
<div>Verwendungszweck: {{ payment.reference }}</div> <div>Verwendungszweck: {{ payment.reference }}</div>
{% if payment.iban %}<div>IBAN: {{ payment.iban }}</div>{% endif %} {% if payment.iban %}<div>IBAN: {{ payment.iban }}</div>{% endif %}
{% if payment.bic %}<div>BIC: {{ payment.bic }}</div>{% endif %} {% if payment.bic %}<div>BIC: {{ payment.bic }}</div>{% endif %}
@@ -81,10 +99,10 @@
<strong>{{ it.name }}</strong><br> <strong>{{ it.name }}</strong><br>
{% if it.description %}<span class="muted">{{ it.description }}</span>{% endif %} {% if it.description %}<span class="muted">{{ it.description }}</span>{% endif %}
</td> </td>
<td class="right">{{ "%.2f"|format(it.quantity) }}</td> <td class="right">{{ number(it.quantity) }}</td>
<td>{{ it.unit }}</td> <td>{{ unit_label(it.unit) }}</td>
<td class="right">{{ money(it.unit_price_net) }}</td> <td class="right">{{ price(it.unit_price_net) }}</td>
<td class="right">{{ "%.2f"|format(it.tax_rate) }}%</td> <td class="right">{{ number(it.tax_rate) }}%</td>
<td class="right">{{ money(it.line_total_net) }}</td> <td class="right">{{ money(it.line_total_net) }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -93,6 +111,9 @@
<table class="totals"> <table class="totals">
<tr><td class="right">Zwischensumme (netto):</td><td class="right"><strong>{{ money(totals.net_total) }}</strong></td></tr> <tr><td class="right">Zwischensumme (netto):</td><td class="right"><strong>{{ money(totals.net_total) }}</strong></td></tr>
{% for rate, tax in totals.tax_by_rate.items() %}
<tr><td class="right">USt {{ rate }} % auf {{ money(totals.basis_by_rate[rate]) }}:</td><td class="right">{{ money(tax) }}</td></tr>
{% endfor %}
<tr><td class="right">USt gesamt:</td><td class="right"><strong>{{ money(totals.tax_total) }}</strong></td></tr> <tr><td class="right">USt gesamt:</td><td class="right"><strong>{{ money(totals.tax_total) }}</strong></td></tr>
<tr><td class="right">Gesamt (brutto):</td><td class="right"><strong>{{ money(totals.gross_total) }}</strong></td></tr> <tr><td class="right">Gesamt (brutto):</td><td class="right"><strong>{{ money(totals.gross_total) }}</strong></td></tr>
</table> </table>
@@ -104,8 +125,6 @@
</div> </div>
{% endif %} {% endif %}
<div class="footer muted">
Hinweis: Bei Nutzung von --combine wird ein Factur-X/ZUGFeRD XML-Datensatz in das PDF eingebettet.
</div>
</body> </body>
</html> </html>
+118
View File
@@ -0,0 +1,118 @@
import io
import logging
import os
from datetime import date
from decimal import Decimal
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
import yaml
from jr_einvoice.create import decimal_input, save_customer, find_customer, service_period, create_invoice
from jr_einvoice.quick_cli import main
from jr_einvoice.pipeline import BuildResult
from jr_einvoice.presets import load_yaml
ROOT = Path(__file__).resolve().parent.parent
class CreateTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.presets = self.root / 'presets'
self.presets.mkdir()
sample = load_yaml(ROOT / 'examples/invoice.sample.yaml')
(self.presets / 'seller.default.yaml').write_text(yaml.safe_dump({
'seller': sample['seller'], 'payment': sample['payment']}))
self.key, _ = save_customer(self.presets, 'Neuer / alter Kunde GmbH', sample['buyer'] | {'name': 'Neuer / alter Kunde GmbH'})
self.jar = self.root / 'test.jar'
self.jar.touch()
self.kwargs = dict(root=self.root / 'invoices', resources=ROOT, presets=self.presets,
customer='Neuer / alter Kunde GmbH', entry='Geleistete Stunden Oktober 2026',
volume=Decimal('120'), price=Decimal('85'), jar=self.jar,
issued=date(2026, 10, 31), logger=logging.getLogger('test'))
def fake_build(self, target, *args):
out = target / 'out'
(out / 'validation').mkdir(parents=True, exist_ok=True)
paths = [out / 'invoice.pdf', out / 'factur-x.xml', out / 'invoice.facturx.pdf', out / 'validation/report.xml']
for path in paths:
path.write_bytes(b'test artifact')
return BuildResult(target, out, *paths)
def test_german_numbers(self):
for text, expected in [('85,00 €', '85'), ('1.234,56 EUR', '1234.56'),
('120', '120'), ('72,5', '72.5'), ('85.00', '85'), ('0.125', '0.125')]:
self.assertEqual(decimal_input(text), Decimal(expected))
for text in ['NaN', 'Infinity', '1,2,3', '12.34,56', '85 USD', '', '1e9']:
with self.assertRaises(ValueError):
decimal_input(text)
def test_customer_reuse_and_no_overwrite(self):
self.assertEqual(find_customer(self.presets, 'NEUER / ALTER KUNDE GMBH')[0], self.key)
self.assertEqual(find_customer(self.presets, self.key)[0], self.key)
with self.assertRaises(FileExistsError):
save_customer(self.presets, 'Neuer / alter Kunde GmbH', find_customer(self.presets, self.key)[1]['buyer'])
self.assertEqual((self.presets / 'customers' / f'{self.key}.yaml').stat().st_mode & 0o777, 0o600)
def test_service_period_inference_and_override(self):
self.assertEqual(service_period('Geleistete Stunden Februar 2028', None, None, date(2028, 3, 1)),
(date(2028, 2, 29), date(2028, 2, 1), date(2028, 2, 29)))
self.assertEqual(service_period('Oktober 2026', None, date(2026, 10, 15), date.today()),
(date(2026, 10, 15), None, None))
with self.assertRaises(ValueError):
service_period('Test', '2026-13', None, date.today())
with self.assertRaises(ValueError):
service_period('Test', '2026-10', date.today(), date.today())
def test_create_allocates_numbers_and_attachment(self):
with patch('jr_einvoice.create.build_in_folder', side_effect=self.fake_build):
first = create_invoice(**self.kwargs)
second = create_invoice(**self.kwargs)
with self.assertRaises(ValueError):
create_invoice(**self.kwargs, number=first.invoice.meta.invoice_number)
self.assertEqual(first.invoice.meta.invoice_number, 'JR-2026-0001')
self.assertEqual(second.invoice.meta.invoice_number, 'JR-2026-0002')
self.assertEqual(first.invoice.meta.service_period_end, date(2026, 10, 31))
self.assertEqual(first.invoice.payment.due_date, date(2026, 11, 30))
self.assertEqual(first.attachment.read_bytes(), first.build.facturx_pdf.read_bytes())
self.assertEqual(first.build.invoice_dir.stat().st_mode & 0o777, 0o700)
self.assertTrue((first.build.out_dir / 'manifest.json').is_file())
def test_failed_build_retains_number_but_publishes_no_attachment(self):
with patch('jr_einvoice.create.build_in_folder', side_effect=RuntimeError('invalid XML')):
with self.assertRaises(RuntimeError):
create_invoice(**self.kwargs)
self.assertEqual(len(list((self.root / 'invoices').glob('*/invoice.yaml'))), 1)
self.assertEqual(list((self.root / 'invoices').glob('*/Rechnung_*.pdf')), [])
with patch('jr_einvoice.create.build_in_folder', side_effect=self.fake_build):
second = create_invoice(**self.kwargs)
self.assertEqual(second.invoice.meta.invoice_number, 'JR-2026-0002')
def test_unknown_customer_noninteractive_is_actionable(self):
with patch.dict(os.environ, {'JR_EINVOICE_PRESETS': str(self.presets)}), patch('sys.stderr', new_callable=io.StringIO) as err:
with self.assertRaises(SystemExit) as result:
main(['--create', '--Customer', 'Unbekannt', '--Entry', 'Beratung', '--Volume', '1', '--Price', '85,00 €', '--non-interactive'])
self.assertEqual(result.exception.code, 1)
self.assertIn('--Street', err.getvalue())
self.assertIsNone(find_customer(self.presets, 'Unbekannt'))
def test_interactive_customer_is_saved_once(self):
with patch.dict(os.environ, {'JR_EINVOICE_PRESETS': str(self.presets)}), \
patch('sys.stdin.isatty', return_value=True), \
patch('builtins.input', side_effect=['Neuer Weg 5', '12345', 'Berlin']) as ask, \
patch('jr_einvoice.quick_cli.create_invoice', side_effect=RuntimeError('stop before build')), \
patch('sys.stderr', new_callable=io.StringIO), patch('sys.stdout', new_callable=io.StringIO):
with self.assertRaises(SystemExit):
main(['--create', '--Customer', 'Neue Firma', '--Entry', 'Beratung', '--Volume', '1', '--Price', '85'])
self.assertEqual(ask.call_count, 3)
self.assertEqual(find_customer(self.presets, 'Neue Firma')[1]['buyer']['address']['street'], 'Neuer Weg 5')
def test_invalid_amount_creates_no_invoice(self):
with self.assertRaises(ValueError):
create_invoice(**(self.kwargs | {'volume': Decimal('-1')}))
self.assertEqual(list((self.root / 'invoices').glob('*/invoice.yaml')), [])
+121
View File
@@ -0,0 +1,121 @@
import logging
import tempfile
import unittest
from datetime import date
from decimal import Decimal
from pathlib import Path
from unittest.mock import patch
from subprocess import CompletedProcess
import xml.etree.ElementTree as ET
from jr_einvoice.calc import compute_totals
from jr_einvoice.io import load_invoice_yaml
from jr_einvoice.models import Invoice
from jr_einvoice.facturx import generate_facturx_xml, NS
from jr_einvoice.mustang import validate, MustangError
from jr_einvoice.monthly import monthly_invoice
from jr_einvoice.render import render_html
from jr_einvoice.delivery import prepare_email
ROOT = Path(__file__).resolve().parent.parent
LOGGER = logging.getLogger('test')
class InvoicesTest(unittest.TestCase):
def setUp(self):
self.inv = load_invoice_yaml(ROOT / 'examples/invoice.sample.yaml')
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
def test_tax_rounding_by_group_and_line(self):
self.inv.items = [self.inv.items[0].model_copy(update={
'quantity': Decimal('1'), 'unit_price_net': Decimal('0.03')}) for _ in range(3)]
total = compute_totals(self.inv)
self.assertEqual(total.net_total, Decimal('0.09'))
self.assertEqual(total.tax_total, Decimal('0.02'))
self.inv.items[0].unit_price_net = Decimal('1.005')
self.assertEqual(compute_totals(self.inv).net_total, Decimal('1.07'))
def test_multiple_rates_and_complete_xml(self):
self.inv.items[1].tax_rate = Decimal('7')
total = compute_totals(self.inv)
self.assertEqual(total.gross_total, Decimal('1335.00'))
xml = generate_facturx_xml(self.inv, self.root / 'invoice.xml', LOGGER)
root = ET.parse(xml).getroot()
self.assertEqual(len(root.findall('.//ram:IncludedSupplyChainTradeLineItem', NS)), 2)
self.assertEqual(root.findtext('.//ram:SellerTradeParty/ram:Name', namespaces=NS), self.inv.seller.name)
self.assertEqual(root.findtext('.//ram:BuyerTradeParty/ram:Name', namespaces=NS), self.inv.buyer.name)
self.assertEqual(root.findtext('.//ram:GrandTotalAmount', namespaces=NS), '1335.00')
def test_invalid_inputs(self):
for change in [lambda d: d.update(items=[]),
lambda d: d['items'][0].update(tax_rate=0),
lambda d: d['items'][0].update(quantity='NaN'),
lambda d: d['items'][0].update(unit_price_net='Infinity'),
lambda d: d['meta'].update(unknown_field='typo'),
lambda d: d['meta'].update(service_period_start='2026-01-01'),
lambda d: d['payment'].update(iban='DE00000000000000000000'),
lambda d: d['payment'].update(due_date='2020-01-01'),
lambda d: d['seller'].update(email='a@example.com\nBcc: x@example.com')]:
data = self.inv.model_dump()
change(data)
with self.assertRaises(ValueError):
Invoice.model_validate(data)
def test_fail_closed_validator_retains_report(self):
for content, rc in [('', 0), ('noise', 0), ('<validation/>', 0),
('<validation><summary status="valid"/></validation>', 0),
('<validation><summary status="invalid"/></validation>', 0),
('<validation><summary status="valid"/></validation>', 1),
('<validation><pdf><summary status="invalid"/></pdf><summary status="valid"/></validation>', 0)]:
with patch('jr_einvoice.mustang._run', return_value=CompletedProcess([], rc, content, '')):
with self.assertRaises(MustangError):
validate(Path('fake.jar'), Path('fake.pdf'), self.root / 'report.xml', LOGGER)
self.assertTrue((self.root / 'report.xml').exists())
def test_validator_accepts_valid_summary(self):
content = '<validation><xml><summary status="valid"/></xml><summary status="valid"/></validation>'
with patch('jr_einvoice.mustang._run', return_value=CompletedProcess([], 0, content, '')):
validate(Path('fake.jar'), Path('fake.xml'), self.root / 'report.xml', LOGGER)
def test_display_preserves_price_and_quantity_precision(self):
self.inv.items[0].quantity = Decimal('1.125')
self.inv.items[0].unit_price_net = Decimal('0.125')
html = render_html(self.inv, ROOT / 'templates/invoice.html.j2')
self.assertIn('1,125', html)
self.assertIn('0,125 EUR', html)
self.assertIn('0,14 EUR', html)
def test_html_escapes_user_text(self):
self.inv.items[0].name = '<script>alert(1)</script>'
html = render_html(self.inv, ROOT / 'templates/invoice.html.j2')
self.assertNotIn('<script>', html)
self.assertIn('&lt;script&gt;', html)
def test_monthly_period_rate_and_duplicate_number(self):
# Explicit repository presets, no personal data required.
import yaml
repo = self.root / 'repo'
(repo / 'examples').mkdir(parents=True)
(repo / 'examples/invoice.init.yaml').write_text((ROOT / 'examples/invoice.sample.yaml').read_text())
(repo / 'presets/customers').mkdir(parents=True)
(repo / 'presets/customers/test.yaml').write_text(yaml.safe_dump({'items': [self.inv.items[0].model_dump(mode='json')]}))
target = monthly_invoice(self.root / 'invoices', repo, LOGGER, 'test', '2028-02', Decimal('75'), '2028-08', date(2028, 3, 1), Decimal('85'))
inv = load_invoice_yaml(target / 'invoice.yaml')
self.assertEqual(inv.meta.service_period_end, date(2028, 2, 29))
self.assertEqual(inv.payment.due_date, date(2028, 3, 31))
self.assertEqual(compute_totals(inv).gross_total, Decimal('7586.25'))
with self.assertRaises(FileExistsError):
monthly_invoice(self.root / 'invoices', repo, LOGGER, 'test', '2028-02', Decimal('75'), '2028-08')
def test_missing_recipient_cannot_leave_stale_draft(self):
import shutil
shutil.copy(ROOT / 'examples/invoice.sample.yaml', self.root / 'invoice.yaml')
(self.root / 'out').mkdir()
(self.root / 'out/invoice.eml').write_text('stale')
with self.assertRaises(ValueError):
prepare_email(self.root, Path('fake.jar'), LOGGER)
self.assertFalse((self.root / 'out/invoice.eml').exists())
if __name__ == '__main__':
unittest.main()
+84
View File
@@ -0,0 +1,84 @@
"""Opt in: JR_EINVOICE_INTEGRATION=1 python -m unittest discover -s tests -v."""
import os
import io
import json
from unittest.mock import patch
import logging
import tempfile
import unittest
from pathlib import Path
from email import policy
from email.parser import BytesParser
import xml.etree.ElementTree as ET
import yaml
from jr_einvoice.pipeline import build_in_folder
from jr_einvoice.delivery import prepare_email
from jr_einvoice.mustang import validate, MustangError
from jr_einvoice.facturx import NS
ROOT = Path(__file__).resolve().parent.parent
JAR = Path(os.environ.get('JR_EINVOICE_MUSTANG_JAR', ROOT / 'tools/mustang/Mustang-CLI.jar'))
LOGGER = logging.getLogger('integration')
@unittest.skipUnless(os.environ.get('JR_EINVOICE_INTEGRATION') == '1', 'Opt in with JR_EINVOICE_INTEGRATION=1')
class MustangIntegrationTest(unittest.TestCase):
def test_build_email_and_reject_bad_xml_and_plain_pdf(self):
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
data = yaml.safe_load((ROOT / 'examples/invoice.sample.yaml').read_text())
data['buyer']['email'] = 'rechnung@example.invalid'
data['items'][1]['tax_rate'] = 7
(root / 'invoice.yaml').write_text(yaml.safe_dump(data))
result = build_in_folder(root, 'out', ROOT / 'templates/invoice.html.j2', LOGGER, JAR, True, True)
self.assertIn('status="valid"', result.validate_report.read_text())
path = prepare_email(root, JAR, LOGGER)
message = BytesParser(policy=policy.default).parsebytes(path.read_bytes())
self.assertEqual(message['From'], data['seller']['email'])
self.assertEqual(message['To'], 'rechnung@example.invalid')
attachments = list(message.iter_attachments())
self.assertEqual(len(attachments), 1)
self.assertEqual(attachments[0].get_payload(decode=True), result.facturx_pdf.read_bytes())
self.assertTrue((root / 'out/manifest.json').is_file())
# A valid PDF/A without invoice XML must not be an accepted e-invoice.
with self.assertRaises(MustangError):
validate(JAR, result.invoice_pdf, root / 'plain-report.xml', LOGGER)
xml = ET.parse(result.facturx_xml)
xml.getroot().find('.//ram:GrandTotalAmount', NS).text = '0.01'
xml.write(root / 'broken.xml', encoding='utf-8', xml_declaration=True)
with self.assertRaises(MustangError):
validate(JAR, root / 'broken.xml', root / 'bad-report.xml', LOGGER)
self.assertTrue((root / 'bad-report.xml').is_file())
def test_one_call_new_existing_customer_and_rejected_invalid_unit(self):
from jr_einvoice.quick_cli import main
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
presets = root / 'presets'
presets.mkdir()
sample = yaml.safe_load((ROOT / 'examples/invoice.sample.yaml').read_text())
(presets / 'seller.default.yaml').write_text(yaml.safe_dump({
'seller': sample['seller'], 'payment': sample['payment']}))
base = ['--create', '--Customer', 'Beliebiger neuer / alter Kunde GmbH',
'--Entry', 'Geleistete Stunden Oktober 2026', '--Volume', '120',
'--Price', '85,00 €', '--Date', '2026-10-31', '--root', str(root / 'invoices'),
'--mustang-jar', str(JAR), '--json']
with patch.dict(os.environ, {'JR_EINVOICE_PRESETS': str(presets)}):
for expected, extra in [('JR-2026-0001', ['--Street', 'Testweg 1', '--PostalCode', '10115', '--City', 'Berlin']),
('JR-2026-0002', [])]:
with patch('sys.stdout', new_callable=io.StringIO) as output:
main(base + extra)
result = json.loads(output.getvalue())
self.assertEqual(result['invoice_number'], expected)
self.assertEqual(result['technical_validation'], 'valid')
self.assertTrue(Path(result['pdf']).is_file())
xml = ET.parse(Path(result['invoice_dir']) / 'out/factur-x.xml')
self.assertEqual(xml.getroot().findtext('.//ram:GrandTotalAmount', namespaces=NS), '12138.00')
with patch('sys.stdout', new_callable=io.StringIO) as output, patch('sys.stderr', new_callable=io.StringIO):
with self.assertRaises(SystemExit) as failure:
main(base + ['--Unit', 'ZZZ'])
self.assertEqual(failure.exception.code, 1)
self.assertNotIn('technical_validation', output.getvalue())
failed = next((root / 'invoices').glob('*JR-2026-0003*'))
self.assertFalse((failed / 'Rechnung_JR-2026-0003.pdf').exists())
self.assertTrue((failed / 'out/validation/validate_report.xml').exists())
+1
View File
@@ -0,0 +1 @@
2.26.0