feat: create validated ZUGFeRD invoices in one console command
This commit is contained in:
1 parent
e0c15fc7f2
commit
a643cf5227
30 files changed
+1509
-207
No files matched your search
@@ -7,3 +7,8 @@
|
||||
**/out/
|
||||
# mustang jar is local:
|
||||
tools/mustang/*.jar
|
||||
|
||||
# Personal invoice inputs and customer/bank details
|
||||
private/
|
||||
invoices/
|
||||
dist/
|
||||
+177
-79
@@ -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/<kunde>.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 `<befehl> --help` zeigen alle Parameter.
|
||||
|
||||
## Init Command
|
||||
```bash
|
||||
# Vollständig erzeugen und validieren (normaler Ablauf):
|
||||
einvoice --create --Customer "Kundenfirma GmbH" \
|
||||
--Entry "Geleistete Stunden Oktober 2026" --Volume 120 --Price "85,00 €"
|
||||
|
||||
`jr-einvoice init <invoice_dir>` erstellt:
|
||||
# Expertenablauf für mehrere Positionen / manuell bearbeitete YAML-Dateien:
|
||||
jr-einvoice init private/invoices --auto-folder --open
|
||||
jr-einvoice generate /pfad/zum/rechnungsordner --json
|
||||
jr-einvoice email /pfad/zum/rechnungsordner --to rechnung@empfaenger.example
|
||||
```
|
||||
|
||||
- `invoice.yaml` (Vorlage)
|
||||
- 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_<Nummer>.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 `<invoice_dir>/assets/logo.svg`.
|
||||
Das Template referenziert `assets/logo.svg` relativ – WeasyPrint bekommt dafür `base_url=<invoice_dir>`.
|
||||
### Ä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/<customer>.yaml`
|
||||
Geld, Mengen und Steuersätze verwenden `Decimal`. Jede Nettoposition wird kaufmännisch
|
||||
auf Cent gerundet; je Steuersatz wird die Steuer aus der Summe der gerundeten
|
||||
Nettopositionen berechnet und auf Cent gerundet. PDF und XML verwenden dieselbe
|
||||
Berechnung. Dezimalpunkt verwenden; besonders genaue Mengen/Preise in YAML als
|
||||
Zeichenkette angeben (`quantity: '72.5'`). Leere Positionen, nicht endliche Zahlen,
|
||||
negative Preise, Nullsteuersätze, unbekannte YAML-Felder und ungültige IBAN-Prüfsummen
|
||||
werden abgewiesen. Der Validator prüft zusätzlich Format und Geschäftsregeln.
|
||||
|
||||
Fallback: `presets/customers.yaml` (Map `customers:`)
|
||||
## PDF/A, XML und Validierung
|
||||
|
||||
## Mustang Update Check
|
||||
WeasyPrint erzeugt PDF/A-3b. Mustang kombiniert diese Sichtkopie mit dem CII-D16B-XML,
|
||||
Profil EN16931 (`--format fx --version 1 --profile E`). Die konkrete Kombination
|
||||
wurde mit Mustang 2.26.0 einschließlich PDF/A und eingebettetem XML erfolgreich
|
||||
geprüft. Die CLI-Formatversion `1` bezeichnet Factur-X, nicht ZUGFeRD 1.
|
||||
Es wird keine Behauptung einer Zertifizierung oder vollständigen Unterstützung
|
||||
aller neuen ZUGFeRD-2.5.2-Funktionen abgeleitet.
|
||||
|
||||
`jr-einvoice mustang-update` liest Maven metadata und vergleicht mit `tools/mustang/VERSION.txt`.
|
||||
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_<invoice_number>_<customer>`
|
||||
`email` erzeugt die Standardausgabe erneut aus `invoice.yaml` und legt erst nach
|
||||
erfolgreicher Prüfung `invoice.eml` an. Absender kommt aus `seller.email`, Empfänger
|
||||
aus `buyer.email` oder `--to`. Die Mail enthält nur das hybride PDF als Rechnungsanhang;
|
||||
XML ist darin eingebettet. `X-Unsent: 1` kennzeichnet den Entwurf. Kein SMTP-Zugang,
|
||||
kein Passwort und kein automatischer Versand sind nötig. Das konkrete Öffnen als
|
||||
Entwurf hängt vom Mailprogramm ab. Versand und Zustellnachweis erfolgen dort.
|
||||
|
||||
Sanitizing/slugging in `jr_einvoice/folder_naming.py`.
|
||||
`manifest.json` enthält SHA-256-Prüfsummen von YAML, XML, hybridem PDF, Bericht und
|
||||
EML. Es unterstützt die Integritätskontrolle, schafft aber allein kein unveränderbares
|
||||
Archiv. Nach Versand den vollständigen Rechnungsordner und die gesendete Originalmail
|
||||
samt Versandnachweis in die bestehende gesicherte Ablage übernehmen. Versandte
|
||||
Rechnungen nicht durch erneutes `generate` überschreiben. Für Berichtigungen einen
|
||||
fachlich abgestimmten Prozess verwenden. Das Tool verwaltet keine Stornos.
|
||||
|
||||
## Open Editor
|
||||
## Prüfung der Überarbeitung
|
||||
|
||||
Mit `--open` wird `invoice.yaml` nach dem Init in `$EDITOR` geöffnet.
|
||||
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.
|
||||
@@ -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_<Nummer>.pdf` ist die vollständige E-Rechnung**, inklusive
|
||||
XML. Direkt anhängen; nicht über einen PDF-Drucker neu drucken, weil dabei das XML
|
||||
verloren gehen kann. Die Ausgabe bestätigt die erfolgreiche technische Prüfung.
|
||||
Sie garantiert nicht pauschal die sachliche oder steuerliche Richtigkeit deiner
|
||||
Eingaben. Details: [Rechtsstand und Grenzen](docs/e-rechnung-2027.md).
|
||||
|
||||
## Optionen für Alltag und Skripte
|
||||
|
||||
```bash
|
||||
# Neue Kundenanschrift ohne Rückfragen übergeben:
|
||||
einvoice --create --Customer "Neue Firma GmbH" \
|
||||
--Street "Kundenstraße 12" --PostalCode 10115 --City Berlin \
|
||||
--Email rechnung@empfaenger.example \
|
||||
--Entry "IT-Beratung" --Volume "7,5" --Price "120,00 EUR" \
|
||||
--ServiceDate 2026-10-15 --non-interactive
|
||||
|
||||
# Bekannter Kunde, expliziter Monat und Bestellbezug:
|
||||
einvoice --create --Customer "Meine Kundenfirma GmbH" \
|
||||
--Entry "Softwareentwicklung" --Volume 120 --Price "85,00 €" \
|
||||
--Month 2026-10 --OrderReference "PO-12345" --DueDays 14
|
||||
|
||||
# Beispiel für eine Stückposition:
|
||||
einvoice --create --Customer "Meine Kundenfirma GmbH" \
|
||||
--Entry "Dokumentationspaket" --Volume 1 --Unit C62 --Price "500,00 €"
|
||||
```
|
||||
|
||||
Weitere Optionen: `--Number`, `--Date`, `--Tax`, `--BuyerReference`, `--Country`,
|
||||
`--VatId`, `--root`, `--json`. Alle Fachoptionen haben auch kleingeschriebene
|
||||
Varianten (`--customer`, `--entry`, …); `einvoice --help` zeigt sie an.
|
||||
`--json` liefert Artefaktpfade und den technischen Prüfstatus ohne Fragen.
|
||||
|
||||
Die Nummer lautet automatisch `JR-JJJJ-NNNN` und wird im Rechnungsstamm unter
|
||||
Dateisperre vergeben. `--Number` setzt eine ausdrücklich gewählte freie Nummer.
|
||||
Vorhandene PDF-Rechnungen außerhalb dieses Stamms werden nicht automatisch
|
||||
eingelesen. Das Präfix JR trennt die neue Serie von früheren `JJJJ-MM`-Nummern.
|
||||
Bei einem technischen Fehler bleibt der reservierte Entwurf samt Log zur Diagnose
|
||||
erhalten; zum Wiederholen `jr-einvoice generate <Rechnungsordner>` verwenden.
|
||||
|
||||
## Daten und Einrichtung
|
||||
|
||||
- Absender: **johannes@jr-it-services.de**, lokale Vorlage `private/presets/seller.default.yaml`.
|
||||
- Kunden: `private/presets/customers/`; neue Kunden werden dort mit geschützter Dateiberechtigung gespeichert.
|
||||
- Rechnungen: `private/invoices/`, jeweils in einem eigenen geschützten Ordner.
|
||||
- Eigenes Logo optional: `private/presets/logo.svg`, sonst mitgeliefertes JR-Logo.
|
||||
- Persönliche Daten, Rechnungen und Ausgabeordner werden nicht mit Git veröffentlicht.
|
||||
|
||||
Andere Datenablage: `JR_EINVOICE_HOME=/absoluter/pfad`; nur die Vorlagen umleiten:
|
||||
`JR_EINVOICE_PRESETS=/absoluter/pfad`. Nach Wheel-Installation ist der Standard
|
||||
`~/.local/share/jr-einvoice/`. Bei einem frischen Checkout zuerst die vollständigen
|
||||
eigenen Absender-/Zahlungsdaten lokal konfigurieren; `create` verwendet keine
|
||||
versionierten Musteradressen als echte Geschäftsdaten.
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e '.[pdf]'
|
||||
jr-einvoice setup-mustang --version 2.26.0
|
||||
```
|
||||
|
||||
Java und die WeasyPrint-Systembibliotheken müssen installiert sein; für Arch siehe
|
||||
`scripts/arch_install.sh`. Die bisherigen `jr-einvoice`-Befehle bleiben verfügbar:
|
||||
YAML-Entwürfe, mehrere Positionen, Neuerzeugung, Eingangskontrolle und optionaler
|
||||
EML-Mailentwurf mit Absender aus den eigenen Stammdaten.
|
||||
|
||||
```bash
|
||||
jr-einvoice email /pfad/zum/rechnungsordner --to rechnung@empfaenger.example
|
||||
jr-einvoice validate /pfad/eingangsrechnung.xml --report private/eingang-pruefung.xml
|
||||
python -m unittest discover -s tests -v
|
||||
JR_EINVOICE_INTEGRATION=1 python -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
Unterstützt werden normale positive EUR-Rechnungen mit positiver Umsatzsteuer.
|
||||
Steuerbefreiungen/Kleinunternehmer, Reverse Charge, Gutschriften/Storno und
|
||||
XRechnung-Ausgabe sind noch nicht implementiert. Archivierung, DATEV-/Peppol-
|
||||
Anbindung und Versandnachweis bleiben im betrieblichen Ablauf. Nach dem Versand
|
||||
PDF, strukturierte Daten, Prüfbericht und Versandnachweis gesichert aufbewahren.
|
||||
|
||||
[Ausführliche Dokumentation](Documentation.md) ·
|
||||
[Rechtsstand und Abgleich mit der INTEGRA-Mail](docs/e-rechnung-2027.md)
|
||||
@@ -0,0 +1,89 @@
|
||||
# E-Rechnung 2027: Rechtsstand und Abgleich
|
||||
|
||||
Geprüft am 16.10.2026. Ziel dieses Projekts ist die Erstellung regulärer deutscher
|
||||
B2B-Dienstleistungsrechnungen als validierte ZUGFeRD/Factur-X-Rechnung.
|
||||
|
||||
## Einordnung der INTEGRA-E-Mail
|
||||
|
||||
Die Umstellung ist relevant; ein gewöhnliches PDF erfüllt die Anforderungen an
|
||||
strukturierte Rechnungsdaten nicht. Der Zeitplan lässt sich wie folgt einordnen:
|
||||
|
||||
| Zeitraum | Grundsätzlicher Stand für inländisches B2B |
|
||||
| --- | --- |
|
||||
| Seit 01.01.2025 | Empfang strukturierter E-Rechnungen ermöglichen; ein E-Mail-Postfach kann dafür genügen. |
|
||||
| Bis 31.12.2026 | Übergangsregel für sonstige Ausgangsrechnungen. |
|
||||
| Ab 01.01.2027 | Bei Vorjahresgesamtumsatz **über** 800.000 EUR endet die allgemeine Übergangsregel. |
|
||||
| Bis 31.12.2027 | Bei Vorjahresgesamtumsatz **bis einschließlich** 800.000 EUR besteht die verlängerte Übergangsregel; außerdem gesonderte EDI-Übergangsregel. |
|
||||
| Ab 01.01.2028 | Allgemeine Übergangsfristen beendet; gesetzliche Ausnahmen bleiben bestehen. |
|
||||
|
||||
Papier und sonstige elektronische Rechnungen sind bei der Zustimmung zu unterscheiden:
|
||||
Die Empfängerzustimmung betrifft im Übergang sonstige elektronische Rechnungen
|
||||
(etwa ein einfaches PDF), nicht generell Papier. Maßgeblich sind die gesetzlichen
|
||||
Tatbestände und der Zeitpunkt des ausgeführten Umsatzes, nicht nur das Ausstellungsdatum.
|
||||
Quelle: [§ 27 Abs. 38 UStG](https://www.gesetze-im-internet.de/ustg_1980/__27.html).
|
||||
|
||||
Die Umsatzgrenze ist keine Kleinunternehmergrenze. Sonderfälle wie Kleinbetragsrechnungen
|
||||
bis 250 EUR, Fahrausweise, bestimmte steuerfreie Umsätze und die Ausnahme für
|
||||
Kleinunternehmer sind getrennt zu beurteilen; B2G folgt zusätzlichen Vorgaben.
|
||||
Den maßgeblichen Gesamtumsatz nach den gesetzlichen Verweisen mit der Steuerberatung
|
||||
bestimmen, nicht aus der Summe der hier angelegten Rechnungen ableiten.
|
||||
Quelle: [BMF-FAQ, Fragen 4 und 11](https://www.bundesfinanzministerium.de/Content/DE/FAQ/e-rechnung.html).
|
||||
|
||||
## Formate, Prüfung und Vorsteuer
|
||||
|
||||
ZUGFeRD ab 2.0.1 ist grundsätzlich geeignet, aber die Profile MINIMUM und BASIC-WL
|
||||
reichen nicht aus. Dieses Projekt nutzt EN16931. Ein technischer Validierungslauf
|
||||
ist sinnvoll und hier vor dem Versandentwurf verbindlich. Laut BMF ist Validierung
|
||||
jedoch keine unmittelbare steuerliche Anerkennungsvoraussetzung; die pauschale
|
||||
Aussage „zwingend technisch validieren, sonst kein Vorsteuerabzug“ greift daher zu
|
||||
kurz. Leistung und Rechnungsinhalt müssen weiterhin geprüft werden.
|
||||
Quelle: [BMF-FAQ, Frage 7](https://www.bundesfinanzministerium.de/Content/DE/FAQ/e-rechnung.html).
|
||||
|
||||
Bei Widersprüchen im hybriden Format sind die strukturierten Daten entscheidend.
|
||||
Das Tool erzeugt Sichtkopie und XML deshalb aus derselben Datenquelle und Berechnung.
|
||||
Aktuelle Formate und Validatoren entwickeln sich weiter: FeRD veröffentlicht inzwischen
|
||||
ZUGFeRD 2.5.2. Eine ältere zulässige Version wird dadurch nicht automatisch unzulässig;
|
||||
die tatsächlich verwendete Implementierung muss aber mit passenden Regeln und den
|
||||
Empfängeranforderungen geprüft werden.
|
||||
Quellen: [BMF-FAQ](https://www.bundesfinanzministerium.de/Content/DE/FAQ/e-rechnung.html),
|
||||
[FeRD-Veröffentlichungen](https://www.ferd-net.de/downloads/veroeffentlichungen).
|
||||
|
||||
## Aufbewahrung
|
||||
|
||||
Rechnungen sind grundsätzlich acht Jahre aufzubewahren; Beginn und mögliche längere
|
||||
Fristen sind gesondert zu beachten. Das strukturierte Original erhalten und nicht
|
||||
nur ausdrucken. Quelle: [§ 14b UStG](https://www.gesetze-im-internet.de/ustg_1980/__14b.html).
|
||||
Prüfbericht und versandte Datei gemeinsam aufzubewahren erleichtert den Nachweis.
|
||||
Eine technische Prüfung ersetzt keine Vollständigkeits- und Richtigkeitskontrolle.
|
||||
Quelle: [BMF-Schreiben vom 15.10.2025, insbesondere Rn. 35a und 60](https://www.bundesfinanzministerium.de/Content/DE/Downloads/BMF_Schreiben/Steuerarten/Umsatzsteuer/Umsatzsteuer-Anwendungserlass/2025-10-15-einfuehrung-obligatorische-e-rechnung.pdf?__blob=publicationFile&v=5).
|
||||
|
||||
## Ergebnis der Codeprüfung
|
||||
|
||||
| Anforderung | Vorher | Nach Überarbeitung |
|
||||
| --- | --- | --- |
|
||||
| Strukturierte Parteien, Positionen, Steuern | Fehlten im XML | In CII-EN16931 enthalten |
|
||||
| Beträge in PDF/XML konsistent | Float-Berechnung | Gemeinsame Decimal-/Cent-Berechnung |
|
||||
| Leistungszeitraum, Bestellung, Kundenreferenz | Fehlten | Optionale strukturierte Felder und PDF-Anzeige |
|
||||
| PDF/A und eingebettetes XML | Keine PDF/A-Erzeugung sichergestellt | WeasyPrint-PDF/A, Mustang-Kombination und Prüfung |
|
||||
| Validierungsergebnis | Nur Prozess-Exitcode | XML-Bericht einschließlich Teilergebnissen ausgewertet |
|
||||
| Komfortable Erstellung | Allgemeines Init und manuelle Eingabe | Ein Aufruf mit beliebigem Kunden, Position, Menge, deutschem Nettopreis, automatischer Nummer und fertigem Anhang-PDF |
|
||||
| Versandvorbereitung | Fehlte | Neuaufbau + Prüfung + EML mit hybridem PDF |
|
||||
| Eingangsprüfung | Kein CLI-Befehl | Lokaler Prüfungsbefehl für XML/PDF |
|
||||
| Revisionssichere Archivierung / DATEV / Peppol | Fehlten | Weiterhin externe Prozesse, keine vorgetäuschte Integration |
|
||||
|
||||
Vor dem ersten echten Versand: lokale Stammdaten bestätigen, freie Rechnungsnummer
|
||||
wählen, vereinbarte Empfängeradresse und etwaige Bestellreferenz eintragen, Stunden
|
||||
und Zeitraum prüfen, validiertes PDF sichten und den Mailentwurf absenden. Danach
|
||||
Rechnung und Versandnachweis gesichert ablegen. Die Steuerberatung sollte die
|
||||
betrieblichen Sonderfälle und den Aufbewahrungsprozess bestätigen.
|
||||
|
||||
|
||||
## Ein-Aufruf-Bedienung ab Version 1.0
|
||||
|
||||
`einvoice --create` erzeugt das vollständige hybride PDF, validiert es und zeigt den
|
||||
Anhang-Pfad an. Neue Kunden werden einmalig mit Anschrift angelegt, bestehende Kunden
|
||||
über ihren Namen wiederverwendet. Es wird kein Kunde automatisch vorausgewählt;
|
||||
IDV ist historischer Bestand. Die Erfolgsmeldung bestätigt die technischen Regeln
|
||||
für PDF/A und EN16931. Ob Leistungsdaten, Steuersatz und Geschäftsvorfall richtig
|
||||
sind, kann der Validator nicht eigenständig feststellen. Deshalb gibt die Konsole
|
||||
keine pauschale Zusicherung „erfüllt alle gesetzlichen Vorgaben“ aus.
|
||||
+21
-17
@@ -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)
|
||||
+61
-3
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Prepare a reviewed email draft; never sends mail automatically."""
|
||||
from email.message import EmailMessage
|
||||
from email.policy import SMTP
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import json
|
||||
from .io import load_invoice_yaml
|
||||
|
||||
def prepare_email(invoice_dir: Path, jar: Path, logger, recipient: str | None = None) -> Path:
|
||||
# Invalidate any previous draft before rebuilding.
|
||||
out = invoice_dir / 'out'
|
||||
(out / 'invoice.eml').unlink(missing_ok=True)
|
||||
(out / 'manifest.json').unlink(missing_ok=True)
|
||||
inv = load_invoice_yaml(invoice_dir / 'invoice.yaml')
|
||||
recipient = recipient or inv.buyer.email
|
||||
import re
|
||||
if not recipient or not re.fullmatch(r'[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+', recipient):
|
||||
raise ValueError('Set buyer.email or pass --to with the agreed invoice address')
|
||||
out = invoice_dir / 'out'
|
||||
# Rebuild from current YAML so the draft cannot attach an obsolete invoice.
|
||||
from .pipeline import build_in_folder
|
||||
template = Path(__file__).resolve().parent.parent / 'templates/invoice.html.j2'
|
||||
if not template.is_file():
|
||||
template = Path(__file__).resolve().parent / 'data/templates/invoice.html.j2'
|
||||
result = build_in_folder(invoice_dir, 'out', template, logger, jar, True, True)
|
||||
pdf = result.facturx_pdf
|
||||
msg = EmailMessage(policy=SMTP)
|
||||
msg['From'] = inv.seller.email or ''
|
||||
if not inv.seller.email:
|
||||
raise ValueError('seller.email is required for an email draft')
|
||||
msg['To'] = recipient
|
||||
msg['Subject'] = f'Rechnung {inv.meta.invoice_number} – {inv.seller.name}'
|
||||
msg['X-Unsent'] = '1'
|
||||
msg.set_content(f'Sehr geehrte Damen und Herren,\n\nanbei erhalten Sie unsere E-Rechnung {inv.meta.invoice_number} als ZUGFeRD/Factur-X-PDF mit eingebetteten strukturierten Rechnungsdaten.\n\nVielen Dank für Ihren Auftrag.\n\nFreundliche Grüße\n{inv.seller.name}\n')
|
||||
msg.add_attachment(pdf.read_bytes(), maintype='application', subtype='pdf', filename=f'Rechnung_{_safe_name(inv.meta.invoice_number)}.pdf')
|
||||
target = out / 'invoice.eml'
|
||||
target.write_bytes(msg.as_bytes())
|
||||
manifest = {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in
|
||||
[invoice_dir / 'invoice.yaml', result.facturx_xml, pdf, result.validate_report, target]}
|
||||
(out / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n', encoding='utf-8')
|
||||
return target
|
||||
|
||||
def _safe_name(number: str) -> str:
|
||||
import re
|
||||
return re.sub(r'[^A-Za-z0-9_-]', '_', number)
|
||||
+103
-15
@@ -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
|
||||
+13
-3
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
+61
-19
@@ -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
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Fast monthly service billing from existing customer presets."""
|
||||
import calendar
|
||||
import tempfile
|
||||
from .invoice_number import invoice_root_lock
|
||||
import re
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from .init import init_invoice_folder
|
||||
from .presets import load_yaml
|
||||
from .models import Invoice
|
||||
from .init import _dump_yaml
|
||||
from .folder_naming import default_invoice_folder_name
|
||||
|
||||
MONTHS = ('Januar Februar März April Mai Juni Juli August September Oktober November Dezember').split()
|
||||
|
||||
def _monthly_invoice(root: Path, repo_root: Path, logger, customer: str, month: str,
|
||||
hours: Decimal, number: str, issued: date | None = None,
|
||||
rate: Decimal | None = None, due_days: int = 30) -> Path:
|
||||
if not re.fullmatch(r'\d{4}-\d{2}', month):
|
||||
raise ValueError('Month must be YYYY-MM')
|
||||
year, m = map(int, month.split('-'))
|
||||
start, end = date(year, m, 1), date(year, m, calendar.monthrange(year, m)[1])
|
||||
if not hours.is_finite() or hours <= 0 or due_days < 0:
|
||||
raise ValueError('Hours must be positive and due_days nonnegative')
|
||||
target = root.expanduser().resolve() / default_invoice_folder_name(number, customer)
|
||||
# Explicit number required: existing PDF invoices may live outside this root.
|
||||
if target.exists():
|
||||
raise FileExistsError(f'Invoice folder exists: {target}')
|
||||
for existing in root.expanduser().glob('*/invoice.yaml'):
|
||||
if str(load_yaml(existing).get('meta', {}).get('invoice_number', '')) == number:
|
||||
raise ValueError(f'Invoice number already used: {number}')
|
||||
with tempfile.TemporaryDirectory(prefix='.draft-', dir=root.expanduser().resolve()) as staging:
|
||||
work = Path(staging) / 'invoice'
|
||||
init_invoice_folder(work, repo_root, logger, customer=customer, invoice_number=number, due_days=due_days)
|
||||
path = work / 'invoice.yaml'
|
||||
data = load_yaml(path)
|
||||
if len(data['items']) != 1 or data['items'][0]['unit'] != 'HUR':
|
||||
raise ValueError('Monthly billing requires a preset with one HUR line')
|
||||
issued = issued or date.today()
|
||||
data['meta'].update(invoice_date=str(issued), service_date=str(end),
|
||||
service_period_start=str(start), service_period_end=str(end))
|
||||
data['payment']['due_date'] = str(issued + timedelta(days=due_days))
|
||||
item = data['items'][0]
|
||||
item['quantity'] = str(hours)
|
||||
item['name'] = f'Geleistete Stunden {MONTHS[m-1]} {year}'
|
||||
if rate is not None:
|
||||
item['unit_price_net'] = str(rate)
|
||||
Invoice.model_validate(data)
|
||||
_dump_yaml(data, path)
|
||||
work.rename(target)
|
||||
return target
|
||||
|
||||
|
||||
def monthly_invoice(root: Path, *args, **kwargs) -> Path:
|
||||
with invoice_root_lock(root):
|
||||
return _monthly_invoice(root, *args, **kwargs)
|
||||
+19
-5
@@ -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 "<report>empty stdout from Mustang; check logs</report>"
|
||||
# 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
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Bundled resources and writable, private user data locations."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def resource_root() -> Path:
|
||||
source = Path(__file__).resolve().parent.parent
|
||||
return source if (source / 'templates/invoice.html.j2').is_file() else Path(__file__).resolve().parent / 'data'
|
||||
|
||||
|
||||
def private_root(resources: Path | None = None) -> Path:
|
||||
root = resources or resource_root()
|
||||
default = root / 'private' if root.name != 'data' else Path.home() / '.local/share/jr-einvoice'
|
||||
return Path(os.environ.get('JR_EINVOICE_HOME', str(default))).expanduser().resolve()
|
||||
|
||||
|
||||
def presets_root(resources: Path | None = None) -> Path:
|
||||
return Path(os.environ.get('JR_EINVOICE_PRESETS', str(private_root(resources) / 'presets'))).expanduser().resolve()
|
||||
+15
-1
@@ -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
|
||||
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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", ".")
|
||||
+9
-3
@@ -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"
|
||||
@@ -3,4 +3,3 @@ rich>=13.7.1
|
||||
pydantic>=2.7.0
|
||||
PyYAML>=6.0.1
|
||||
Jinja2>=3.1.4
|
||||
pycheval>=0.2.0
|
||||
@@ -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"
|
||||
+40
-21
@@ -4,18 +4,32 @@
|
||||
<meta charset="utf-8">
|
||||
<title>Rechnung {{ meta.invoice_number }}</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; font-size: 12px; }
|
||||
.row { display: flex; justify-content: space-between; gap: 12px; }
|
||||
.muted { color: #555; }
|
||||
h1 { font-size: 22px; margin: 0 0 8px 0; }
|
||||
.box { border: 1px solid #ddd; padding: 10px; border-radius: 6px; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 14px; }
|
||||
th, td { border-bottom: 1px solid #eee; padding: 6px 6px; text-align: left; }
|
||||
th { background: #fafafa; }
|
||||
@page {
|
||||
size: A4; margin: 18mm 17mm 21mm;
|
||||
@bottom-left { content: "{{ seller.name | replace('"', '') }}"; font-family: sans-serif; font-size: 8pt; color: #64748b; }
|
||||
@bottom-right { content: "Seite " counter(page) " von " counter(pages); font-family: sans-serif; font-size: 8pt; color: #64748b; }
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: "DejaVu Sans", sans-serif; font-size: 9pt; color: #1e293b; line-height: 1.55; }
|
||||
.row { display: flex; justify-content: space-between; gap: 16px; }
|
||||
.muted { color: #64748b; }
|
||||
h1 { color: #123655; font-size: 30pt; font-weight: 500; letter-spacing: -1px; margin: 0 0 12px; }
|
||||
.box { border: 1px solid #e2e8f0; border-radius: 6px; padding: 14px; }
|
||||
.row:first-of-type { margin-top: 20px; }
|
||||
.box .muted:first-child { font-size: 8pt; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 7px; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 24px; }
|
||||
thead { display: table-header-group; }
|
||||
tr { break-inside: avoid; }
|
||||
th, td { padding: 10px 6px; text-align: left; vertical-align: top; border-bottom: 1px solid #e2e8f0; }
|
||||
th { color: white; background: #123655; font-size: 7pt; font-weight: 500; }
|
||||
tbody tr:nth-child(even) { background: #f8fafc; }
|
||||
.right { text-align: right; }
|
||||
.totals { margin-top: 12px; width: 44%; margin-left: auto; }
|
||||
.totals td { border: none; padding: 4px 6px; }
|
||||
.footer { margin-top: 18px; font-size: 11px; }
|
||||
td.right { white-space: nowrap; }
|
||||
.totals { margin-top: 16px; width: 58%; margin-left: auto; break-inside: avoid; }
|
||||
.totals td { border: none; padding: 5px 7px; font-size: 8.5pt; }
|
||||
.totals tr:last-child td { background: #edf4fa; color: #123655; border-top: 2px solid #123655; padding-top: 12px; padding-bottom: 12px; font-size: 11pt; }
|
||||
.footer { margin-top: 28px; font-size: 8.5pt; border-top: 1px solid #e2e8f0; padding-top: 12px; }
|
||||
.footer strong { color: #123655; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -27,8 +41,12 @@
|
||||
<div style="flex: 1;">
|
||||
<h1>Rechnung</h1>
|
||||
<div class="muted">Rechnungsnummer: <strong>{{ meta.invoice_number }}</strong></div>
|
||||
<div class="muted">Rechnungsdatum: {{ meta.invoice_date }}</div>
|
||||
<div class="muted">Leistungsdatum: {{ meta.service_date }}</div>
|
||||
<div class="muted">Rechnungsdatum: {{ meta.invoice_date | de_date }}</div>
|
||||
{% if meta.service_period_start %}
|
||||
<div class="muted">Leistungszeitraum: {{ meta.service_period_start | de_date }} – {{ meta.service_period_end | de_date }}</div>
|
||||
{% else %}<div class="muted">Leistungsdatum: {{ meta.service_date | de_date }}</div>{% endif %}
|
||||
{% if meta.buyer_reference %}<div>Kundenreferenz: {{ meta.buyer_reference }}</div>{% endif %}
|
||||
{% if meta.order_reference %}<div>Bestellung: {{ meta.order_reference }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="box" style="min-width: 260px;">
|
||||
<div><strong>{{ seller.name }}</strong></div>
|
||||
@@ -53,7 +71,7 @@
|
||||
|
||||
<div class="box" style="flex: 1;">
|
||||
<div class="muted">Zahlungsinformationen</div>
|
||||
<div>Fällig am: {{ payment.due_date }}</div>
|
||||
<div>Fällig am: {{ payment.due_date | de_date }}</div>
|
||||
<div>Verwendungszweck: {{ payment.reference }}</div>
|
||||
{% if payment.iban %}<div>IBAN: {{ payment.iban }}</div>{% endif %}
|
||||
{% if payment.bic %}<div>BIC: {{ payment.bic }}</div>{% endif %}
|
||||
@@ -81,10 +99,10 @@
|
||||
<strong>{{ it.name }}</strong><br>
|
||||
{% if it.description %}<span class="muted">{{ it.description }}</span>{% endif %}
|
||||
</td>
|
||||
<td class="right">{{ "%.2f"|format(it.quantity) }}</td>
|
||||
<td>{{ it.unit }}</td>
|
||||
<td class="right">{{ money(it.unit_price_net) }}</td>
|
||||
<td class="right">{{ "%.2f"|format(it.tax_rate) }}%</td>
|
||||
<td class="right">{{ number(it.quantity) }}</td>
|
||||
<td>{{ unit_label(it.unit) }}</td>
|
||||
<td class="right">{{ price(it.unit_price_net) }}</td>
|
||||
<td class="right">{{ number(it.tax_rate) }}%</td>
|
||||
<td class="right">{{ money(it.line_total_net) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -93,6 +111,9 @@
|
||||
|
||||
<table class="totals">
|
||||
<tr><td class="right">Zwischensumme (netto):</td><td class="right"><strong>{{ money(totals.net_total) }}</strong></td></tr>
|
||||
{% for rate, tax in totals.tax_by_rate.items() %}
|
||||
<tr><td class="right">USt {{ rate }} % auf {{ money(totals.basis_by_rate[rate]) }}:</td><td class="right">{{ money(tax) }}</td></tr>
|
||||
{% endfor %}
|
||||
<tr><td class="right">USt gesamt:</td><td class="right"><strong>{{ money(totals.tax_total) }}</strong></td></tr>
|
||||
<tr><td class="right">Gesamt (brutto):</td><td class="right"><strong>{{ money(totals.gross_total) }}</strong></td></tr>
|
||||
</table>
|
||||
@@ -104,8 +125,6 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="footer muted">
|
||||
Hinweis: Bei Nutzung von --combine wird ein Factur-X/ZUGFeRD XML-Datensatz in das PDF eingebettet.
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -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')), [])
|
||||
@@ -0,0 +1,121 @@
|
||||
import logging
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from subprocess import CompletedProcess
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from jr_einvoice.calc import compute_totals
|
||||
from jr_einvoice.io import load_invoice_yaml
|
||||
from jr_einvoice.models import Invoice
|
||||
from jr_einvoice.facturx import generate_facturx_xml, NS
|
||||
from jr_einvoice.mustang import validate, MustangError
|
||||
from jr_einvoice.monthly import monthly_invoice
|
||||
from jr_einvoice.render import render_html
|
||||
from jr_einvoice.delivery import prepare_email
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
LOGGER = logging.getLogger('test')
|
||||
|
||||
class InvoicesTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.inv = load_invoice_yaml(ROOT / 'examples/invoice.sample.yaml')
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = Path(self.tmp.name)
|
||||
|
||||
def test_tax_rounding_by_group_and_line(self):
|
||||
self.inv.items = [self.inv.items[0].model_copy(update={
|
||||
'quantity': Decimal('1'), 'unit_price_net': Decimal('0.03')}) for _ in range(3)]
|
||||
total = compute_totals(self.inv)
|
||||
self.assertEqual(total.net_total, Decimal('0.09'))
|
||||
self.assertEqual(total.tax_total, Decimal('0.02'))
|
||||
self.inv.items[0].unit_price_net = Decimal('1.005')
|
||||
self.assertEqual(compute_totals(self.inv).net_total, Decimal('1.07'))
|
||||
|
||||
def test_multiple_rates_and_complete_xml(self):
|
||||
self.inv.items[1].tax_rate = Decimal('7')
|
||||
total = compute_totals(self.inv)
|
||||
self.assertEqual(total.gross_total, Decimal('1335.00'))
|
||||
xml = generate_facturx_xml(self.inv, self.root / 'invoice.xml', LOGGER)
|
||||
root = ET.parse(xml).getroot()
|
||||
self.assertEqual(len(root.findall('.//ram:IncludedSupplyChainTradeLineItem', NS)), 2)
|
||||
self.assertEqual(root.findtext('.//ram:SellerTradeParty/ram:Name', namespaces=NS), self.inv.seller.name)
|
||||
self.assertEqual(root.findtext('.//ram:BuyerTradeParty/ram:Name', namespaces=NS), self.inv.buyer.name)
|
||||
self.assertEqual(root.findtext('.//ram:GrandTotalAmount', namespaces=NS), '1335.00')
|
||||
|
||||
def test_invalid_inputs(self):
|
||||
for change in [lambda d: d.update(items=[]),
|
||||
lambda d: d['items'][0].update(tax_rate=0),
|
||||
lambda d: d['items'][0].update(quantity='NaN'),
|
||||
lambda d: d['items'][0].update(unit_price_net='Infinity'),
|
||||
lambda d: d['meta'].update(unknown_field='typo'),
|
||||
lambda d: d['meta'].update(service_period_start='2026-01-01'),
|
||||
lambda d: d['payment'].update(iban='DE00000000000000000000'),
|
||||
lambda d: d['payment'].update(due_date='2020-01-01'),
|
||||
lambda d: d['seller'].update(email='a@example.com\nBcc: x@example.com')]:
|
||||
data = self.inv.model_dump()
|
||||
change(data)
|
||||
with self.assertRaises(ValueError):
|
||||
Invoice.model_validate(data)
|
||||
|
||||
def test_fail_closed_validator_retains_report(self):
|
||||
for content, rc in [('', 0), ('noise', 0), ('<validation/>', 0),
|
||||
('<validation><summary status="valid"/></validation>', 0),
|
||||
('<validation><summary status="invalid"/></validation>', 0),
|
||||
('<validation><summary status="valid"/></validation>', 1),
|
||||
('<validation><pdf><summary status="invalid"/></pdf><summary status="valid"/></validation>', 0)]:
|
||||
with patch('jr_einvoice.mustang._run', return_value=CompletedProcess([], rc, content, '')):
|
||||
with self.assertRaises(MustangError):
|
||||
validate(Path('fake.jar'), Path('fake.pdf'), self.root / 'report.xml', LOGGER)
|
||||
self.assertTrue((self.root / 'report.xml').exists())
|
||||
|
||||
def test_validator_accepts_valid_summary(self):
|
||||
content = '<validation><xml><summary status="valid"/></xml><summary status="valid"/></validation>'
|
||||
with patch('jr_einvoice.mustang._run', return_value=CompletedProcess([], 0, content, '')):
|
||||
validate(Path('fake.jar'), Path('fake.xml'), self.root / 'report.xml', LOGGER)
|
||||
|
||||
def test_display_preserves_price_and_quantity_precision(self):
|
||||
self.inv.items[0].quantity = Decimal('1.125')
|
||||
self.inv.items[0].unit_price_net = Decimal('0.125')
|
||||
html = render_html(self.inv, ROOT / 'templates/invoice.html.j2')
|
||||
self.assertIn('1,125', html)
|
||||
self.assertIn('0,125 EUR', html)
|
||||
self.assertIn('0,14 EUR', html)
|
||||
|
||||
def test_html_escapes_user_text(self):
|
||||
self.inv.items[0].name = '<script>alert(1)</script>'
|
||||
html = render_html(self.inv, ROOT / 'templates/invoice.html.j2')
|
||||
self.assertNotIn('<script>', html)
|
||||
self.assertIn('<script>', html)
|
||||
|
||||
def test_monthly_period_rate_and_duplicate_number(self):
|
||||
# Explicit repository presets, no personal data required.
|
||||
import yaml
|
||||
repo = self.root / 'repo'
|
||||
(repo / 'examples').mkdir(parents=True)
|
||||
(repo / 'examples/invoice.init.yaml').write_text((ROOT / 'examples/invoice.sample.yaml').read_text())
|
||||
(repo / 'presets/customers').mkdir(parents=True)
|
||||
(repo / 'presets/customers/test.yaml').write_text(yaml.safe_dump({'items': [self.inv.items[0].model_dump(mode='json')]}))
|
||||
target = monthly_invoice(self.root / 'invoices', repo, LOGGER, 'test', '2028-02', Decimal('75'), '2028-08', date(2028, 3, 1), Decimal('85'))
|
||||
inv = load_invoice_yaml(target / 'invoice.yaml')
|
||||
self.assertEqual(inv.meta.service_period_end, date(2028, 2, 29))
|
||||
self.assertEqual(inv.payment.due_date, date(2028, 3, 31))
|
||||
self.assertEqual(compute_totals(inv).gross_total, Decimal('7586.25'))
|
||||
with self.assertRaises(FileExistsError):
|
||||
monthly_invoice(self.root / 'invoices', repo, LOGGER, 'test', '2028-02', Decimal('75'), '2028-08')
|
||||
|
||||
def test_missing_recipient_cannot_leave_stale_draft(self):
|
||||
import shutil
|
||||
shutil.copy(ROOT / 'examples/invoice.sample.yaml', self.root / 'invoice.yaml')
|
||||
(self.root / 'out').mkdir()
|
||||
(self.root / 'out/invoice.eml').write_text('stale')
|
||||
with self.assertRaises(ValueError):
|
||||
prepare_email(self.root, Path('fake.jar'), LOGGER)
|
||||
self.assertFalse((self.root / 'out/invoice.eml').exists())
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Opt in: JR_EINVOICE_INTEGRATION=1 python -m unittest discover -s tests -v."""
|
||||
import os
|
||||
import io
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
import logging
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from email import policy
|
||||
from email.parser import BytesParser
|
||||
import xml.etree.ElementTree as ET
|
||||
import yaml
|
||||
|
||||
from jr_einvoice.pipeline import build_in_folder
|
||||
from jr_einvoice.delivery import prepare_email
|
||||
from jr_einvoice.mustang import validate, MustangError
|
||||
from jr_einvoice.facturx import NS
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
JAR = Path(os.environ.get('JR_EINVOICE_MUSTANG_JAR', ROOT / 'tools/mustang/Mustang-CLI.jar'))
|
||||
LOGGER = logging.getLogger('integration')
|
||||
|
||||
@unittest.skipUnless(os.environ.get('JR_EINVOICE_INTEGRATION') == '1', 'Opt in with JR_EINVOICE_INTEGRATION=1')
|
||||
class MustangIntegrationTest(unittest.TestCase):
|
||||
def test_build_email_and_reject_bad_xml_and_plain_pdf(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
root = Path(folder)
|
||||
data = yaml.safe_load((ROOT / 'examples/invoice.sample.yaml').read_text())
|
||||
data['buyer']['email'] = 'rechnung@example.invalid'
|
||||
data['items'][1]['tax_rate'] = 7
|
||||
(root / 'invoice.yaml').write_text(yaml.safe_dump(data))
|
||||
result = build_in_folder(root, 'out', ROOT / 'templates/invoice.html.j2', LOGGER, JAR, True, True)
|
||||
self.assertIn('status="valid"', result.validate_report.read_text())
|
||||
path = prepare_email(root, JAR, LOGGER)
|
||||
message = BytesParser(policy=policy.default).parsebytes(path.read_bytes())
|
||||
self.assertEqual(message['From'], data['seller']['email'])
|
||||
self.assertEqual(message['To'], 'rechnung@example.invalid')
|
||||
attachments = list(message.iter_attachments())
|
||||
self.assertEqual(len(attachments), 1)
|
||||
self.assertEqual(attachments[0].get_payload(decode=True), result.facturx_pdf.read_bytes())
|
||||
self.assertTrue((root / 'out/manifest.json').is_file())
|
||||
# A valid PDF/A without invoice XML must not be an accepted e-invoice.
|
||||
with self.assertRaises(MustangError):
|
||||
validate(JAR, result.invoice_pdf, root / 'plain-report.xml', LOGGER)
|
||||
xml = ET.parse(result.facturx_xml)
|
||||
xml.getroot().find('.//ram:GrandTotalAmount', NS).text = '0.01'
|
||||
xml.write(root / 'broken.xml', encoding='utf-8', xml_declaration=True)
|
||||
with self.assertRaises(MustangError):
|
||||
validate(JAR, root / 'broken.xml', root / 'bad-report.xml', LOGGER)
|
||||
self.assertTrue((root / 'bad-report.xml').is_file())
|
||||
|
||||
def test_one_call_new_existing_customer_and_rejected_invalid_unit(self):
|
||||
from jr_einvoice.quick_cli import main
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
root = Path(folder)
|
||||
presets = root / 'presets'
|
||||
presets.mkdir()
|
||||
sample = yaml.safe_load((ROOT / 'examples/invoice.sample.yaml').read_text())
|
||||
(presets / 'seller.default.yaml').write_text(yaml.safe_dump({
|
||||
'seller': sample['seller'], 'payment': sample['payment']}))
|
||||
base = ['--create', '--Customer', 'Beliebiger neuer / alter Kunde GmbH',
|
||||
'--Entry', 'Geleistete Stunden Oktober 2026', '--Volume', '120',
|
||||
'--Price', '85,00 €', '--Date', '2026-10-31', '--root', str(root / 'invoices'),
|
||||
'--mustang-jar', str(JAR), '--json']
|
||||
with patch.dict(os.environ, {'JR_EINVOICE_PRESETS': str(presets)}):
|
||||
for expected, extra in [('JR-2026-0001', ['--Street', 'Testweg 1', '--PostalCode', '10115', '--City', 'Berlin']),
|
||||
('JR-2026-0002', [])]:
|
||||
with patch('sys.stdout', new_callable=io.StringIO) as output:
|
||||
main(base + extra)
|
||||
result = json.loads(output.getvalue())
|
||||
self.assertEqual(result['invoice_number'], expected)
|
||||
self.assertEqual(result['technical_validation'], 'valid')
|
||||
self.assertTrue(Path(result['pdf']).is_file())
|
||||
xml = ET.parse(Path(result['invoice_dir']) / 'out/factur-x.xml')
|
||||
self.assertEqual(xml.getroot().findtext('.//ram:GrandTotalAmount', namespaces=NS), '12138.00')
|
||||
with patch('sys.stdout', new_callable=io.StringIO) as output, patch('sys.stderr', new_callable=io.StringIO):
|
||||
with self.assertRaises(SystemExit) as failure:
|
||||
main(base + ['--Unit', 'ZZZ'])
|
||||
self.assertEqual(failure.exception.code, 1)
|
||||
self.assertNotIn('technical_validation', output.getvalue())
|
||||
failed = next((root / 'invoices').glob('*JR-2026-0003*'))
|
||||
self.assertFalse((failed / 'Rechnung_JR-2026-0003.pdf').exists())
|
||||
self.assertTrue((failed / 'out/validation/validate_report.xml').exists())
|
||||
@@ -0,0 +1 @@
|
||||
2.26.0
|
||||
Reference in new issue
Block a user