From a643cf5227e0b6f769656e12990de48fde5bb6e2 Mon Sep 17 00:00:00 2001 From: Johannes Rest Date: Fri, 16 Oct 2026 09:29:08 +0200 Subject: [PATCH] feat: create validated ZUGFeRD invoices in one console command --- .gitignore | 5 + Documentation.md | 256 +++++++++++++++++++++--------- README.md | 142 ++++++++++++++--- docs/e-rechnung-2027.md | 89 +++++++++++ jr_einvoice/calc.py | 38 +++-- jr_einvoice/cli.py | 64 +++++++- jr_einvoice/create.py | 185 +++++++++++++++++++++ jr_einvoice/delivery.py | 45 ++++++ jr_einvoice/facturx.py | 118 ++++++++++++-- jr_einvoice/init.py | 16 +- jr_einvoice/invoice_number.py | 16 ++ jr_einvoice/logging_utils.py | 5 +- jr_einvoice/models.py | 80 +++++++--- jr_einvoice/monthly.py | 57 +++++++ jr_einvoice/mustang.py | 24 ++- jr_einvoice/paths.py | 18 +++ jr_einvoice/pipeline.py | 16 +- jr_einvoice/presets.py | 7 + jr_einvoice/quick_cli.py | 103 ++++++++++++ jr_einvoice/render.py | 14 +- jr_einvoice/setup.py | 2 +- jr_einvoice/utils.py | 16 +- pyproject.toml | 12 +- requirements.txt | 1 - scripts/download_mustang.sh | 2 +- templates/invoice.html.j2 | 61 ++++--- tests/test_create.py | 118 ++++++++++++++ tests/test_invoices.py | 121 ++++++++++++++ tests/test_mustang_integration.py | 84 ++++++++++ tools/mustang/VERSION.txt | 1 + 30 files changed, 1509 insertions(+), 207 deletions(-) create mode 100644 docs/e-rechnung-2027.md create mode 100644 jr_einvoice/create.py create mode 100644 jr_einvoice/delivery.py create mode 100644 jr_einvoice/monthly.py create mode 100644 jr_einvoice/paths.py create mode 100644 jr_einvoice/quick_cli.py create mode 100644 tests/test_create.py create mode 100644 tests/test_invoices.py create mode 100644 tests/test_mustang_integration.py create mode 100644 tools/mustang/VERSION.txt diff --git a/.gitignore b/.gitignore index 7c40818..a1b1517 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,8 @@ **/out/ # mustang jar is local: tools/mustang/*.jar + +# Personal invoice inputs and customer/bank details +private/ +invoices/ +dist/ diff --git a/Documentation.md b/Documentation.md index 4ce2c53..94fcdc3 100644 --- a/Documentation.md +++ b/Documentation.md @@ -1,120 +1,218 @@ -# Documentation +# Bedienung und technische Dokumentation -## Resilienz & Logs -- 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. +Stand: 16.10.2026 · Version 1.0 -## PDF Rendering -Priorität: -1) WeasyPrint (wenn installiert) -2) wkhtmltopdf (wenn vorhanden) +## Einrichtung und persönliche Vorlagen -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 -Combine: -`java -jar Mustang-CLI.jar --action combine --source invoice.pdf --source-xml factur-x.xml --out invoice.facturx.pdf --profile E` +Vorlagen werden in dieser Reihenfolge zusammengeführt: -Validate: -`java -jar Mustang-CLI.jar --no-notices --action validate --source invoice.facturx.pdf` +1. `examples/invoice.init.yaml`. +2. Versionierte `presets/seller.default.yaml` mit Beispieldaten. +3. Lokale `private/presets/seller.default.yaml`. +4. Lokale `private/presets/customers/.yaml`, sonst die versionierte Kunden-Datei, + sonst `presets/customers.yaml`. +5. Rechnungsnummer, Datum, Zahlungsziel und Monats-/Stundenangaben des Befehls. -## YAML Schema -Siehe `examples/invoice.sample.yaml` +`JR_EINVOICE_HOME=/absoluter/pfad` setzt das private Datenverzeichnis. +`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 -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. +## Befehle +`jr-einvoice --help` und ` --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 ` 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) -- optional `assets/` Ordner (für Logos etc.) -- `.gitkeep` Dateien damit Git die Ordner behält +### Ein-Aufruf-Erstellung -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 -- `presets/customers.yaml`: Kunden-Vorlagen (case-insensitive Keys) +Nummern werden unter Dateisperre im gewählten `--root` automatisch als +`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: -1) `examples/invoice.init.yaml` (Basis) -2) `presets/seller.default.yaml` -3) `presets/customers.yaml` (wenn --customer gesetzt) -4) Smart defaults (heutiges Datum, due_days, reference) +Nach erfolgreicher PDF/A- und XML-Validierung wird das vollständige hybride PDF +zusätzlich als `Rechnung_.pdf` im Rechnungsordner bereitgestellt. Diese +Datei direkt an eine E-Mail anhängen. `out/invoice.pdf` ist lediglich die Sichtkopie. +`out/manifest.json` hält SHA-256-Prüfsummen einschließlich des Anhang-PDFs fest. +`--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 `/assets/logo.svg`. -Das Template referenziert `assets/logo.svg` relativ – WeasyPrint bekommt dafür `base_url=`. +### Älterer Monatsentwurf -## 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/.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`. -Mit `--apply` wird die neueste Version geladen und installiert (inkl. optional SHA1-Check). +Der frühere wkhtmltopdf-Fallback bleibt für eine Sichtkopie verfügbar, garantiert +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__` +`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. -Fallback-Reihenfolge: `code`, `codium`, `nano`, `vim`, `vi`. +```bash +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 -- `--print-path-only` prints only the path (no rich output) +## Bewusster Funktionsumfang -## Auto open - -- `--open` opens unconditionally (needs an editor available) -- `--open-auto` opens only when a GUI session is detected (DISPLAY/WAYLAND_DISPLAY) and stdout is a TTY - - -## Generate artifact output - -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 +Normale positive Dienstleistungsrechnungen in EUR mit positiver Umsatzsteuer und mehreren +Steuersätzen sind unterstützt. Nicht implementiert sind steuerfreie Positionen, +Kleinunternehmer, Reverse Charge, Abschläge/Anzahlungen, Skonto/Zuschläge/Rabatte, +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 +beide Formate EN16931 verwenden. Aufbewahrung/Backups und fachliche Freigabe müssen +in den betrieblichen Ablauf eingebunden werden. diff --git a/README.md b/README.md index d326bbb..0835ff8 100644 --- a/README.md +++ b/README.md @@ -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 -- `--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 +Auf diesem Rechner sind Python-Umgebung, Absenderdaten und Mustang eingerichtet. ```bash -INV_DIR=$(jr-einvoice init invoices --auto-folder -c unicredit -p "Beratung" --print-path-only) -jr-einvoice generate "$INV_DIR" --json +cd /home/johannes/localdev/einvoice +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_.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 ` 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) diff --git a/docs/e-rechnung-2027.md b/docs/e-rechnung-2027.md new file mode 100644 index 0000000..5adda39 --- /dev/null +++ b/docs/e-rechnung-2027.md @@ -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. diff --git a/jr_einvoice/calc.py b/jr_einvoice/calc.py index bac3c66..2b3c47e 100644 --- a/jr_einvoice/calc.py +++ b/jr_einvoice/calc.py @@ -1,24 +1,28 @@ -from __future__ import annotations from dataclasses import dataclass -from typing import Dict +from decimal import Decimal, ROUND_HALF_UP 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) class Totals: - net_total: float - tax_total: float - gross_total: float - tax_by_rate: Dict[float, float] + net_total: Decimal + tax_total: Decimal + gross_total: Decimal + tax_by_rate: dict[Decimal, Decimal] + basis_by_rate: dict[Decimal, Decimal] def compute_totals(inv: Invoice) -> Totals: - net = 0.0 - tax_total = 0.0 - tax_by_rate: Dict[float, float] = {} - for it in inv.items: - line_net = float(it.quantity) * float(it.unit_price_net) - net += line_net - tax = line_net * float(it.tax_rate) / 100.0 - 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) + bases = {} + for item in inv.items: + bases[item.tax_rate] = bases.get(item.tax_rate, Decimal(0)) + line_net(item) + taxes = {rate: rounded(base * rate / 100) for rate, base in bases.items()} + net = sum(bases.values(), Decimal(0)) + tax = sum(taxes.values(), Decimal(0)) + return Totals(net, tax, net + tax, taxes, bases) diff --git a/jr_einvoice/cli.py b/jr_einvoice/cli.py index 8723826..cef8abf 100644 --- a/jr_einvoice/cli.py +++ b/jr_einvoice/cli.py @@ -18,13 +18,17 @@ app = typer.Typer(add_completion=False, no_args_is_help=True) console = Console() 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: return _repo_root() / "templates" / "invoice.html.j2" 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: 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") 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"), 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"), @@ -156,6 +160,7 @@ def generate( 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)"), ): + invoice_dir = invoice_dir.resolve() try: _ = load_invoice_yaml(invoice_dir / invoice_filename) except Exception as e: @@ -228,3 +233,56 @@ def generate( except Exception as e: logger.exception("Generate failed") _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) diff --git a/jr_einvoice/create.py b/jr_einvoice/create.py new file mode 100644 index 0000000..54b4451 --- /dev/null +++ b/jr_einvoice/create.py @@ -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) diff --git a/jr_einvoice/delivery.py b/jr_einvoice/delivery.py new file mode 100644 index 0000000..f0d41af --- /dev/null +++ b/jr_einvoice/delivery.py @@ -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) diff --git a/jr_einvoice/facturx.py b/jr_einvoice/facturx.py index 02e54b8..176908e 100644 --- a/jr_einvoice/facturx.py +++ b/jr_einvoice/facturx.py @@ -1,22 +1,110 @@ -from __future__ import annotations +"""UN/CEFACT CII D16B, EN16931: positive, taxable service invoices.""" from pathlib import Path 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 .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: - out_xml.parent.mkdir(parents=True, exist_ok=True) totals = compute_totals(inv) - - invoice = EN16931Invoice( - invoice_number=inv.meta.invoice_number, - invoice_date=inv.meta.invoice_date, - currency=inv.meta.currency, - grand_total=Money(str(Decimal(str(totals.gross_total)).quantize(Decimal("0.01"))), inv.meta.currency), - ) - - xml_string = generate_xml(invoice) - out_xml.write_text(xml_string, encoding="utf-8") - logger.info("Wrote Factur-X XML: %s", out_xml) + root = ET.Element(f'{{{NS["rsm"]}}}CrossIndustryInvoice') + context = node(root, 'rsm:ExchangedDocumentContext') + ram(ram(context, 'GuidelineSpecifiedDocumentContextParameter'), 'ID', 'urn:cen.eu:en16931:2017') + doc = node(root, 'rsm:ExchangedDocument') + ram(doc, 'ID', inv.meta.invoice_number) + ram(doc, 'TypeCode', '380') + date_node(doc, 'IssueDateTime', inv.meta.invoice_date) + if inv.meta.notes: + ram(ram(doc, 'IncludedNote'), 'Content', inv.meta.notes) + transaction = node(root, 'rsm:SupplyChainTradeTransaction') + 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 diff --git a/jr_einvoice/init.py b/jr_einvoice/init.py index da0202d..19ef50e 100644 --- a/jr_einvoice/init.py +++ b/jr_einvoice/init.py @@ -1,10 +1,11 @@ from __future__ import annotations from pathlib import Path import shutil +from .paths import presets_root from datetime import date, timedelta 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 import yaml @@ -24,6 +25,8 @@ def init_invoice_folder( project: str | None = None, ) -> Path: 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) # Create standard subfolders @@ -44,6 +47,8 @@ def init_invoice_folder( # Apply seller defaults 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) # Apply customer preset @@ -53,7 +58,7 @@ def init_invoice_folder( merged = deep_merge(merged, preset) logger.info("Applied customer preset: %s", customer) 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 today = date.today() @@ -102,7 +107,7 @@ def init_invoice_folder( logger.info("Initialized invoice folder: %s", target_dir) return target_dir -def init_invoice_under_root( +def _init_invoice_under_root( invoices_root: Path, repo_root: Path, logger, @@ -135,3 +140,8 @@ def init_invoice_under_root( invoices_root=invoices_root, 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) diff --git a/jr_einvoice/invoice_number.py b/jr_einvoice/invoice_number.py index e8f8188..52653aa 100644 --- a/jr_einvoice/invoice_number.py +++ b/jr_einvoice/invoice_number.py @@ -26,3 +26,19 @@ def suggest_next_invoice_number(invoices_root: Path, year: int | None = None, pa if yr == y and seq > max_seq: max_seq = seq 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) diff --git a/jr_einvoice/logging_utils.py b/jr_einvoice/logging_utils.py index 6ab9835..fee397e 100644 --- a/jr_einvoice/logging_utils.py +++ b/jr_einvoice/logging_utils.py @@ -7,8 +7,9 @@ def setup_logging(log_level: str, log_file: Optional[Path]) -> logging.Logger: logger = logging.getLogger("jr_einvoice") logger.setLevel(logging.DEBUG) - if list(logger.handlers): - return logger + for handler in list(logger.handlers): + logger.removeHandler(handler) + handler.close() fmt = logging.Formatter( fmt="%(asctime)s.%(msecs)03d %(levelname)s [%(name)s] %(message)s", diff --git a/jr_einvoice/models.py b/jr_einvoice/models.py index 943d903..14f63d4 100644 --- a/jr_einvoice/models.py +++ b/jr_einvoice/models.py @@ -1,56 +1,98 @@ from __future__ import annotations 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 -class Address(BaseModel): - street: str - postal_code: str - city: str - country: str = Field(min_length=2, max_length=2) +class StrictModel(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True, allow_inf_nan=False) -class Party(BaseModel): - name: str +class Address(StrictModel): + 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 tax_number: Optional[str] = None email: Optional[str] = None 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") @classmethod def _email_shape(cls, v: Optional[str]): if v is None: 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") return v -class Meta(BaseModel): +class Meta(StrictModel): invoice_number: str = Field(min_length=1) invoice_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" notes: Optional[str] = None -class Payment(BaseModel): +class Payment(StrictModel): due_date: date reference: str = Field(min_length=1) iban: Optional[str] = None bic: 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) description: Optional[str] = None - quantity: float = Field(gt=0) - unit: str = Field(min_length=1) # e.g. HUR, C62 - unit_price_net: float = Field(ge=0) - tax_rate: float = Field(ge=0, le=100) + quantity: Decimal = Field(gt=0) + unit: str = Field(pattern=r"^[A-Z0-9]{2,3}$") # e.g. HUR, C62 + unit_price_net: Decimal = Field(ge=0) + tax_rate: Decimal = Field(gt=0, le=100) -class Invoice(BaseModel): +class Invoice(StrictModel): meta: Meta seller: Party buyer: Party 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 diff --git a/jr_einvoice/monthly.py b/jr_einvoice/monthly.py new file mode 100644 index 0000000..77021bd --- /dev/null +++ b/jr_einvoice/monthly.py @@ -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) diff --git a/jr_einvoice/mustang.py b/jr_einvoice/mustang.py index ad7bc82..c0870db 100644 --- a/jr_einvoice/mustang.py +++ b/jr_einvoice/mustang.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path import subprocess +import xml.etree.ElementTree as ET class MustangError(RuntimeError): pass @@ -10,7 +11,7 @@ def _run(jar: Path, args: list[str], logger) -> subprocess.CompletedProcess[str] raise MustangError(f"Mustang jar not found: {jar}") cmd = ["java", "-Xmx1G", "-Dfile.encoding=UTF-8", "-jar", str(jar)] + args 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 stderr:\n%s", proc.stderr.strip()) 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-xml", str(source_xml), "--out", str(out_pdf), - "--profile", profile + "--format", "fx", "--version", "1", + "--profile", profile, "--no-additional-attachments" ], logger) if proc.returncode != 0: 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 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 += ["--action", "validate", "--source", str(source)] proc = _run(jar, args, logger) - if proc.returncode != 0: - raise MustangError(f"Mustang validate failed (rc={proc.returncode}). See logs for details.") - content = proc.stdout.strip() or "empty stdout from Mustang; check logs" + # Persist reports even on failure; a zero exit code alone is insufficient. + content = proc.stdout.strip() 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 diff --git a/jr_einvoice/paths.py b/jr_einvoice/paths.py new file mode 100644 index 0000000..4b77b84 --- /dev/null +++ b/jr_einvoice/paths.py @@ -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() diff --git a/jr_einvoice/pipeline.py b/jr_einvoice/pipeline.py index f13547f..fac9055 100644 --- a/jr_einvoice/pipeline.py +++ b/jr_einvoice/pipeline.py @@ -1,5 +1,7 @@ from __future__ import annotations from pathlib import Path +import re +import shutil from dataclasses import dataclass from typing import Optional @@ -37,6 +39,12 @@ def build_in_folder( out_dir = invoice_dir / out_subdir (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("Artifacts directory: %s", out_dir) @@ -62,6 +70,12 @@ def build_in_folder( if do_validate: logger.info("Validating Factur-X PDF via Mustang") 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 diff --git a/jr_einvoice/presets.py b/jr_einvoice/presets.py index 46c5449..65495ab 100644 --- a/jr_einvoice/presets.py +++ b/jr_einvoice/presets.py @@ -1,6 +1,8 @@ from __future__ import annotations from pathlib import Path import yaml +from .paths import presets_root +import re def load_yaml(path: Path) -> dict: 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: 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 file_preset = repo_root / "presets" / "customers" / f"{key}.yaml" if file_preset.exists(): diff --git a/jr_einvoice/quick_cli.py b/jr_einvoice/quick_cli.py new file mode 100644 index 0000000..5509b55 --- /dev/null +++ b/jr_einvoice/quick_cli.py @@ -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() diff --git a/jr_einvoice/render.py b/jr_einvoice/render.py index e2a509f..eea6bdb 100644 --- a/jr_einvoice/render.py +++ b/jr_einvoice/render.py @@ -6,16 +6,20 @@ import shutil from jinja2 import Environment, FileSystemLoader, select_autoescape from .models import Invoice -from .calc import compute_totals +from .calc import compute_totals, line_net 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: env = Environment( 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["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) totals = compute_totals(inv) @@ -23,7 +27,7 @@ def render_html(inv: Invoice, template_path: Path) -> str: items = [] for it in inv.items: 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)) ctx = { @@ -50,7 +54,7 @@ def render_pdf(html: str, out_pdf: Path, logger, prefer_weasyprint: bool = True, logger.info("Rendering PDF using WeasyPrint") try: 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) except Exception as e: logger.exception("WeasyPrint rendering failed; trying wkhtmltopdf if available: %s", e) diff --git a/jr_einvoice/setup.py b/jr_einvoice/setup.py index 14a9013..2667518 100644 --- a/jr_einvoice/setup.py +++ b/jr_einvoice/setup.py @@ -4,7 +4,7 @@ import urllib.request import hashlib 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" class SetupError(RuntimeError): diff --git a/jr_einvoice/utils.py b/jr_einvoice/utils.py index 5c761dc..fe87e32 100644 --- a/jr_einvoice/utils.py +++ b/jr_einvoice/utils.py @@ -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: - try: - v = float(value) - except Exception: - v = 0.0 - # de-DE formatting (simple, stable) - return f"{v:,.2f} {currency}".replace(",", "X").replace(".", ",").replace("X", ".") + return f"{Decimal(str(value)):,.2f} {currency}".replace(",", "X").replace(".", ",").replace("X", ".") diff --git a/pyproject.toml b/pyproject.toml index faf5c8a..8456c20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] 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." readme = "README.md" requires-python = ">=3.11" @@ -15,8 +15,7 @@ dependencies = [ "rich>=13.7.1", "pydantic>=2.7.0", "PyYAML>=6.0.1", - "Jinja2>=3.1.4", - "pycheval>=0.2.0" + "Jinja2>=3.1.4" ] [project.optional-dependencies] @@ -26,6 +25,13 @@ pdf = [ [project.scripts] jr-einvoice = "jr_einvoice.cli:app" +einvoice = "jr_einvoice.quick_cli:main" [tool.hatch.build.targets.wheel] 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" diff --git a/requirements.txt b/requirements.txt index 05d2fe8..b5efbf9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,3 @@ rich>=13.7.1 pydantic>=2.7.0 PyYAML>=6.0.1 Jinja2>=3.1.4 -pycheval>=0.2.0 diff --git a/scripts/download_mustang.sh b/scripts/download_mustang.sh index 5838c30..aed8751 100644 --- a/scripts/download_mustang.sh +++ b/scripts/download_mustang.sh @@ -1,4 +1,4 @@ #!/usr/bin/env bash set -euo pipefail 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" diff --git a/templates/invoice.html.j2 b/templates/invoice.html.j2 index 014db81..0f52d0f 100644 --- a/templates/invoice.html.j2 +++ b/templates/invoice.html.j2 @@ -4,18 +4,32 @@ Rechnung {{ meta.invoice_number }} @@ -27,8 +41,12 @@

Rechnung

Rechnungsnummer: {{ meta.invoice_number }}
-
Rechnungsdatum: {{ meta.invoice_date }}
-
Leistungsdatum: {{ meta.service_date }}
+
Rechnungsdatum: {{ meta.invoice_date | de_date }}
+ {% if meta.service_period_start %} +
Leistungszeitraum: {{ meta.service_period_start | de_date }} – {{ meta.service_period_end | de_date }}
+ {% else %}
Leistungsdatum: {{ meta.service_date | de_date }}
{% endif %} + {% if meta.buyer_reference %}
Kundenreferenz: {{ meta.buyer_reference }}
{% endif %} + {% if meta.order_reference %}
Bestellung: {{ meta.order_reference }}
{% endif %}
{{ seller.name }}
@@ -53,7 +71,7 @@
Zahlungsinformationen
-
Fällig am: {{ payment.due_date }}
+
Fällig am: {{ payment.due_date | de_date }}
Verwendungszweck: {{ payment.reference }}
{% if payment.iban %}
IBAN: {{ payment.iban }}
{% endif %} {% if payment.bic %}
BIC: {{ payment.bic }}
{% endif %} @@ -81,10 +99,10 @@ {{ it.name }}
{% if it.description %}{{ it.description }}{% endif %} - {{ "%.2f"|format(it.quantity) }} - {{ it.unit }} - {{ money(it.unit_price_net) }} - {{ "%.2f"|format(it.tax_rate) }}% + {{ number(it.quantity) }} + {{ unit_label(it.unit) }} + {{ price(it.unit_price_net) }} + {{ number(it.tax_rate) }}% {{ money(it.line_total_net) }} {% endfor %} @@ -93,6 +111,9 @@ + {% for rate, tax in totals.tax_by_rate.items() %} + + {% endfor %}
Zwischensumme (netto):{{ money(totals.net_total) }}
USt {{ rate }} % auf {{ money(totals.basis_by_rate[rate]) }}:{{ money(tax) }}
USt gesamt:{{ money(totals.tax_total) }}
Gesamt (brutto):{{ money(totals.gross_total) }}
@@ -104,8 +125,6 @@
{% endif %} - + diff --git a/tests/test_create.py b/tests/test_create.py new file mode 100644 index 0000000..9a1784f --- /dev/null +++ b/tests/test_create.py @@ -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')), []) diff --git a/tests/test_invoices.py b/tests/test_invoices.py new file mode 100644 index 0000000..cdb6c34 --- /dev/null +++ b/tests/test_invoices.py @@ -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), ('', 0), + ('', 0), + ('', 0), + ('', 1), + ('', 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 = '' + 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 = '' + html = render_html(self.inv, ROOT / 'templates/invoice.html.j2') + self.assertNotIn('