commit e0c15fc7f2d6ffa83725533d360101026f4aaaa3 Author: Johannes Rest Date: Mon Feb 16 17:02:03 2026 +0100 Initial version of the E-Invoice solution of JR IT Services diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7c40818 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.venv/ +**/__pycache__/ +*.pyc +*.pyo +*.log +# invoice artifacts: +**/out/ +# mustang jar is local: +tools/mustang/*.jar diff --git a/Documentation.md b/Documentation.md new file mode 100644 index 0000000..4ce2c53 --- /dev/null +++ b/Documentation.md @@ -0,0 +1,120 @@ +# Documentation + +## 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. + +## PDF Rendering +Priorität: +1) WeasyPrint (wenn installiert) +2) wkhtmltopdf (wenn vorhanden) + +Wenn beides fehlt -> klarer Fehler. + +## 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` + +Validate: +`java -jar Mustang-CLI.jar --no-notices --action validate --source invoice.facturx.pdf` + +## YAML Schema +Siehe `examples/invoice.sample.yaml` + +## 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. + + +## Init Command + +`jr-einvoice init ` erstellt: + +- `invoice.yaml` (Vorlage) +- optional `assets/` Ordner (für Logos etc.) +- `.gitkeep` Dateien damit Git die Ordner behält + +Damit kannst du pro Rechnung einen neuen Ordner anlegen und direkt editieren. + + +## Presets + +- `presets/seller.default.yaml`: deine Standard-Seller- und Payment-Daten +- `presets/customers.yaml`: Kunden-Vorlagen (case-insensitive Keys) + +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) + +## Assets / Logo + +`jr-einvoice init` kopiert standardmäßig `assets/logo.svg` nach `/assets/logo.svg`. +Das Template referenziert `assets/logo.svg` relativ – WeasyPrint bekommt dafür `base_url=`. + +## Setup Mustang + +`jr-einvoice setup-mustang` lädt das Jar von Maven Central (repo1.maven.org) und prüft optional die `.sha1` Datei. + + +## Auto-Rechnungsnummer + +Wenn du bei `init` **keine** `--invoice-number` angibst, aber `--invoices-root` setzt, dann wird die nächste Nummer vorgeschlagen. + +Standard-Pattern: `YYYY-NNNN` (z.B. 2026-0007). Gesucht wird in Ordnernamen unter `--invoices-root`. + +Implementierung: `jr_einvoice/invoice_number.py`. + +## Customer Presets (Datei pro Kunde) + +Bevorzugt: `presets/customers/.yaml` + +Fallback: `presets/customers.yaml` (Map `customers:`) + +## Mustang Update Check + +`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). + + +## Auto Folder Name + +Mit `jr-einvoice init invoices --auto-folder --customer unicredit` wird ein Unterordner erzeugt: + +`Rechnung__` + +Sanitizing/slugging in `jr_einvoice/folder_naming.py`. + +## Open Editor + +Mit `--open` wird `invoice.yaml` nach dem Init in `$EDITOR` geöffnet. +Fallback-Reihenfolge: `code`, `codium`, `nano`, `vim`, `vi`. + + +## Script output + +- `--print-path` prints the created folder path after the panel +- `--print-path-only` prints only the path (no rich output) + +## 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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..75a091c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Johannes Rest + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ecbf559 --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +VENV?=.venv +PY=$(VENV)/bin/python +JR=$(VENV)/bin/jr-einvoice + +.PHONY: venv install install-pdf demo + +venv: + python -m venv $(VENV) + +install: venv + $(PY) -m pip install -U pip + $(PY) -m pip install -r requirements.txt + $(PY) -m pip install -e . + +install-pdf: install + $(PY) -m pip install -r requirements-pdf.txt + +demo: + mkdir -p invoices/Demo_Invoice + cp examples/invoice.sample.yaml invoices/Demo_Invoice/invoice.yaml + $(JR) generate invoices/Demo_Invoice --log-level DEBUG diff --git a/README.md b/README.md new file mode 100644 index 0000000..d326bbb --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# jr-einvoice-toolchain (Folder-first Factur-X / ZUGFeRD EN16931) + +## New in v0.8 + +`generate` kann jetzt Artefakt-Pfade für Automatisierung ausgeben: + +- `--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 + +```bash +INV_DIR=$(jr-einvoice init invoices --auto-folder -c unicredit -p "Beratung" --print-path-only) +jr-einvoice generate "$INV_DIR" --json +``` + +Mehr: `Documentation.md` diff --git a/assets/logo.svg b/assets/logo.svg new file mode 100644 index 0000000..f44e6cb --- /dev/null +++ b/assets/logo.svg @@ -0,0 +1,4 @@ + + + JR IT Services + diff --git a/examples/invoice.init.yaml b/examples/invoice.init.yaml new file mode 100644 index 0000000..8a2042d --- /dev/null +++ b/examples/invoice.init.yaml @@ -0,0 +1,48 @@ +# Invoice input for jr-einvoice +# Edit this file, then run: +# jr-einvoice generate . +# or from repo root: +# jr-einvoice generate + +meta: + invoice_number: "2026-0001" + invoice_date: "2026-02-14" + service_date: "2026-02-14" + currency: "EUR" + language: "de" + notes: "Vielen Dank für Ihren Auftrag." + +seller: + name: "JR IT Services" + vat_id: "DE123456789" + tax_number: "123/456/78901" + email: "johannes@jr-it-services.de" + address: + street: "Musterstraße 1" + postal_code: "80331" + city: "München" + country: "DE" + +buyer: + name: "Kunde / Firma" + vat_id: null + address: + street: "Straße 1" + postal_code: "00000" + city: "Ort" + country: "DE" + +payment: + due_date: "2026-02-28" + reference: "Rechnung 2026-0001" + iban: "DE02120300000000202051" + bic: "BYLADEM1001" + bank_name: "Musterbank" + +items: + - name: "Leistung" + description: "Beschreibung" + quantity: 1 + unit: "C62" + unit_price_net: 100.00 + tax_rate: 19.0 diff --git a/examples/invoice.sample.yaml b/examples/invoice.sample.yaml new file mode 100644 index 0000000..9cb01d4 --- /dev/null +++ b/examples/invoice.sample.yaml @@ -0,0 +1,49 @@ +meta: + invoice_number: "2026-0001" + invoice_date: "2026-02-13" + service_date: "2026-02-13" + currency: "EUR" + language: "de" + notes: "Vielen Dank für Ihren Auftrag." + +seller: + name: "JR IT Services" + vat_id: "DE123456789" + tax_number: "123/456/78901" + email: "johannes@jr-it-services.de" + address: + street: "Musterstraße 1" + postal_code: "80331" + city: "München" + country: "DE" + +buyer: + name: "Beispielkunde GmbH" + vat_id: "DE987654321" + address: + street: "Kundenweg 5" + postal_code: "10115" + city: "Berlin" + country: "DE" + +payment: + due_date: "2026-02-27" + reference: "Rechnung 2026-0001" + iban: "DE02120300000000202051" + bic: "BYLADEM1001" + bank_name: "Musterbank" + +items: + - name: "Beratung (IT)" + description: "Technische Beratung und Umsetzung" + quantity: 8 + unit: "HUR" + unit_price_net: 120.00 + tax_rate: 19.0 + + - name: "Dokumentation" + description: "Projekt-Dokumentation und Übergabe" + quantity: 1 + unit: "C62" + unit_price_net: 180.00 + tax_rate: 19.0 diff --git a/jr_einvoice/__init__.py b/jr_einvoice/__init__.py new file mode 100644 index 0000000..a9a2c5b --- /dev/null +++ b/jr_einvoice/__init__.py @@ -0,0 +1 @@ +__all__ = [] diff --git a/jr_einvoice/calc.py b/jr_einvoice/calc.py new file mode 100644 index 0000000..bac3c66 --- /dev/null +++ b/jr_einvoice/calc.py @@ -0,0 +1,24 @@ +from __future__ import annotations +from dataclasses import dataclass +from typing import Dict +from .models import Invoice + +@dataclass(frozen=True) +class Totals: + net_total: float + tax_total: float + gross_total: float + tax_by_rate: Dict[float, float] + +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) diff --git a/jr_einvoice/cli.py b/jr_einvoice/cli.py new file mode 100644 index 0000000..8723826 --- /dev/null +++ b/jr_einvoice/cli.py @@ -0,0 +1,230 @@ +from __future__ import annotations +from pathlib import Path +import os +import sys +import json +import typer +from rich.console import Console +from rich.panel import Panel + +from .logging_utils import setup_logging +from .pipeline import build_in_folder +from .io import load_invoice_yaml +from .init import init_invoice_folder, init_invoice_under_root +from .setup import install_mustang, check_for_mustang_update +from .editor import open_in_editor + +app = typer.Typer(add_completion=False, no_args_is_help=True) +console = Console() + +def _repo_root() -> Path: + return Path(__file__).resolve().parent.parent + +def _default_template() -> Path: + return _repo_root() / "templates" / "invoice.html.j2" + +def _mustang_default_path() -> Path: + return _repo_root() / "tools" / "mustang" / "Mustang-CLI.jar" + +def _mustang_dir() -> Path: + return _mustang_default_path().parent + +def _default_mustang_jar() -> Path | None: + env = os.environ.get("JR_EINVOICE_MUSTANG_JAR") + if env: + return Path(env).expanduser() + return _mustang_default_path() + +def _is_gui_session() -> bool: + return bool(os.environ.get("WAYLAND_DISPLAY") or os.environ.get("DISPLAY")) + +def _fail(msg: str, exc: Exception | None = None, exit_code: int = 1): + console.print(Panel.fit(f"[bold red]ERROR[/bold red]: {msg}", border_style="red")) + if exc: + console.print(f"[red]{type(exc).__name__}: {exc}[/red]") + raise typer.Exit(exit_code) + +@app.command("setup-mustang") +def setup_mustang( + version: str = typer.Option("2.22.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"), +): + logger = setup_logging(log_level, None) + try: + install_mustang(version=version, target_path=dest, logger=logger, verify_sha1=verify_sha1) + console.print(Panel.fit(f"[bold green]OK[/bold green]\nInstalled Mustang CLI to:\n{dest}", border_style="green")) + except Exception as e: + _fail("setup-mustang failed", e) + +@app.command("mustang-update") +def mustang_update( + apply: bool = typer.Option(False, "--apply", help="If set, downloads and installs latest version when available"), + 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"), +): + logger = setup_logging(log_level, None) + try: + needs, installed, latest = check_for_mustang_update(_mustang_dir(), logger) + if not needs: + console.print(Panel.fit(f"[bold green]Up-to-date[/bold green]\nInstalled: {installed}\nLatest: {latest}", border_style="green")) + return + if not apply: + console.print(Panel.fit( + f"[bold yellow]Update available[/bold yellow]\nInstalled: {installed or '(none)'}\nLatest: {latest}\n\nRun with --apply to install.", + border_style="yellow" + )) + return + install_mustang(version=latest, target_path=_mustang_default_path(), logger=logger, verify_sha1=verify_sha1) + console.print(Panel.fit(f"[bold green]Updated[/bold green]\nNow installed: {latest}", border_style="green")) + except Exception as e: + _fail("mustang-update failed", e) + +@app.command() +def init( + invoice_dir: Path = typer.Argument(..., help="Target invoice folder OR invoices root (when --auto-folder is set)"), + customer: str | None = typer.Option(None, "--customer", "-c", help="Customer key/name, e.g. 'unicredit'"), + project: str | None = typer.Option(None, "--project", "-p", help="Project/subject to prefill first line item name"), + invoice_number: str | None = typer.Option(None, "--invoice-number", "-n", help="Invoice number to prefill. If omitted, can be auto-suggested."), + invoices_root: Path | None = typer.Option(None, "--invoices-root", help="Folder containing all invoice folders; used to auto-suggest the next invoice number"), + auto_folder: bool = typer.Option(False, "--auto-folder", help="If set, treat INVOICE_DIR as invoices root and create a subfolder Rechnung__ automatically"), + due_days: int = typer.Option(14, "--due-days", help="Default due date offset in days"), + force: bool = typer.Option(False, "--force", help="Overwrite invoice.yaml/assets if they already exist"), + open_editor: bool = typer.Option(False, "--open", help="Open invoice.yaml in $EDITOR after init"), + open_auto: bool = typer.Option(False, "--open-auto", help="Open invoice.yaml automatically if a GUI session is detected (Wayland/X11) and stdout is a TTY"), + print_path: bool = typer.Option(False, "--print-path", help="Print the created invoice folder path (useful for scripts)"), + print_path_only: bool = typer.Option(False, "--print-path-only", help="Only print the path (no rich panel); best for scripts"), + log_level: str = typer.Option("INFO", "--log-level", help="Console log level: DEBUG/INFO/WARNING/ERROR"), +): + logger = setup_logging(log_level, None) + try: + if auto_folder: + target = init_invoice_under_root( + invoices_root=invoice_dir, + repo_root=_repo_root(), + logger=logger, + customer=customer, + project=project, + invoice_number=invoice_number, + due_days=due_days, + force=force, + ) + else: + target = init_invoice_folder( + invoice_dir, + _repo_root(), + logger, + force=force, + customer=customer, + invoice_number=invoice_number, + invoices_root=invoices_root, + due_days=due_days, + project=project, + ) + + target_path = target.resolve() + invoice_yaml = target_path / "invoice.yaml" + + if print_path_only: + sys.stdout.write(str(target_path) + "\n") + else: + console.print(Panel.fit( + f"[bold green]OK[/bold green]\nInitialized: {target_path}\nEdit: {invoice_yaml}\nThen run: jr-einvoice generate {target_path}", + border_style="green" + )) + if print_path: + console.print(str(target_path)) + + do_open = open_editor or (open_auto and _is_gui_session() and sys.stdout.isatty()) + if do_open: + open_in_editor(invoice_yaml, logger) + + except Exception as e: + _fail("init failed", e) + +@app.command() +def generate( + invoice_dir: Path = typer.Argument(..., exists=True, file_okay=False, dir_okay=True, readable=True, help="Invoice folder containing invoice.yaml"), + invoice_filename: str = typer.Option("invoice.yaml", "--file", help="Invoice YAML filename inside the folder"), + out_subdir: str = typer.Option("out", "--out-subdir", help="Where to write artifacts inside the invoice folder"), + template: Path = typer.Option(_default_template(), "--template", exists=True, readable=True, help="Jinja2 HTML template"), + mustang_jar: Path | None = typer.Option(None, "--mustang-jar", help="Path to Mustang CLI jar (default: tools/mustang/Mustang-CLI.jar or env JR_EINVOICE_MUSTANG_JAR)"), + prefer_weasyprint: bool = typer.Option(True, "--prefer-weasyprint/--prefer-wkhtmltopdf", help="Prefer WeasyPrint if available"), + log_level: str = typer.Option("INFO", "--log-level", help="Console log level: DEBUG/INFO/WARNING/ERROR"), + print_artifacts: bool = typer.Option(False, "--print-artifacts", help="Print artifact paths after generation"), + 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)"), +): + try: + _ = load_invoice_yaml(invoice_dir / invoice_filename) + except Exception as e: + _fail(f"Failed to load/validate {invoice_filename} in {invoice_dir}", e) + + log_file = invoice_dir / out_subdir / "logs" / "run.log" + logger = setup_logging(log_level, log_file) + + jar = mustang_jar or _default_mustang_jar() + if jar is None or not Path(jar).exists(): + _fail("Mustang CLI jar is required (validation is always on). Run: jr-einvoice setup-mustang or jr-einvoice mustang-update --apply") + + try: + logger.info("jr-einvoice generate start") + res = build_in_folder( + invoice_dir=invoice_dir, + out_subdir=out_subdir, + template_path=template, + logger=logger, + mustang_jar=Path(jar), + combine=True, + do_validate=True, + prefer_weasyprint=prefer_weasyprint, + invoice_filename=invoice_filename, + ) + + artifacts = { + "invoice_dir": str(res.invoice_dir), + "out_dir": str(res.out_dir), + "invoice_pdf": str(res.invoice_pdf), + "facturx_xml": str(res.facturx_xml), + "facturx_pdf": str(res.facturx_pdf or ""), + "validate_report": str(res.validate_report or ""), + "log": str(log_file), + } + + if json_out: + sys.stdout.write(json.dumps(artifacts, ensure_ascii=False) + "\n") + return + + if print_artifacts_only: + # plain list, one per line + for k in ["invoice_pdf", "facturx_xml", "facturx_pdf", "validate_report", "log"]: + v = artifacts.get(k, "") + if v: + sys.stdout.write(v + "\n") + return + + console.print(Panel.fit( + f"[bold green]OK[/bold green]\n" + f"Invoice folder: {res.invoice_dir}\n" + f"Artifacts: {res.out_dir}\n" + f"PDF: {res.invoice_pdf}\n" + f"XML: {res.facturx_xml}\n" + f"Factur-X PDF: {res.facturx_pdf or '-'}\n" + f"Validate report: {res.validate_report or '-'}\n" + f"Log: {log_file}", + border_style="green" + )) + + if print_artifacts: + console.print(str(res.invoice_pdf)) + console.print(str(res.facturx_xml)) + if res.facturx_pdf: + console.print(str(res.facturx_pdf)) + if res.validate_report: + console.print(str(res.validate_report)) + console.print(str(log_file)) + + except Exception as e: + logger.exception("Generate failed") + _fail("Generate failed. See out/logs/run.log for details.", e) diff --git a/jr_einvoice/editor.py b/jr_einvoice/editor.py new file mode 100644 index 0000000..b3efedf --- /dev/null +++ b/jr_einvoice/editor.py @@ -0,0 +1,20 @@ +from __future__ import annotations +import os +import shutil +import subprocess +from pathlib import Path + +def open_in_editor(path: Path, logger) -> None: + # Prefer $EDITOR, then common editors + editor = os.environ.get("EDITOR") + if not editor: + for cand in ["code", "codium", "nano", "vim", "vi"]: + if shutil.which(cand): + editor = cand + break + if not editor: + raise RuntimeError("No editor found. Set $EDITOR or install nano/vim/code.") + logger.info("Opening editor: %s %s", editor, path) + # Split editor if it contains args (simple) + parts = editor.split() + subprocess.run(parts + [str(path)], check=False) diff --git a/jr_einvoice/facturx.py b/jr_einvoice/facturx.py new file mode 100644 index 0000000..02e54b8 --- /dev/null +++ b/jr_einvoice/facturx.py @@ -0,0 +1,22 @@ +from __future__ import annotations +from pathlib import Path +from decimal import Decimal +from pycheval import EN16931Invoice, Money, generate_xml +from .models import Invoice +from .calc import compute_totals + +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) + return out_xml diff --git a/jr_einvoice/folder_naming.py b/jr_einvoice/folder_naming.py new file mode 100644 index 0000000..91bd38b --- /dev/null +++ b/jr_einvoice/folder_naming.py @@ -0,0 +1,16 @@ +from __future__ import annotations +import re + +def slugify(value: str, max_len: int = 40) -> str: + v = value.strip() + v = re.sub(r"\s+", "_", v) + v = re.sub(r"[^A-Za-z0-9_\-]+", "", v) + v = v.strip("_-") + if len(v) > max_len: + v = v[:max_len].rstrip("_-") + return v or "Kunde" + +def default_invoice_folder_name(invoice_number: str, customer: str | None = None) -> str: + cust = slugify(customer or "Kunde") + inv = slugify(invoice_number, max_len=32) + return f"Rechnung_{inv}_{cust}" diff --git a/jr_einvoice/init.py b/jr_einvoice/init.py new file mode 100644 index 0000000..da0202d --- /dev/null +++ b/jr_einvoice/init.py @@ -0,0 +1,137 @@ +from __future__ import annotations +from pathlib import Path +import shutil +from datetime import date, timedelta + +from .presets import load_yaml, deep_merge, load_customer_preset +from .invoice_number import suggest_next_invoice_number +from .folder_naming import default_invoice_folder_name + +import yaml + +def _dump_yaml(data: dict, path: Path) -> None: + path.write_text(yaml.safe_dump(data, sort_keys=False, allow_unicode=True), encoding="utf-8") + +def init_invoice_folder( + target_dir: Path, + repo_root: Path, + logger, + force: bool = False, + customer: str | None = None, + invoice_number: str | None = None, + due_days: int = 14, + invoices_root: Path | None = None, + project: str | None = None, +) -> Path: + target_dir = target_dir.resolve() + target_dir.mkdir(parents=True, exist_ok=True) + + # Create standard subfolders + (target_dir / "assets").mkdir(parents=True, exist_ok=True) + for p in [target_dir / "out", target_dir / "out" / "logs", target_dir / "out" / "validation"]: + p.mkdir(parents=True, exist_ok=True) + (p / ".gitkeep").write_text("", encoding="utf-8") + + # Copy default logo if not present (or force) + src_logo = repo_root / "assets" / "logo.svg" + dst_logo = target_dir / "assets" / "logo.svg" + if src_logo.exists() and (force or not dst_logo.exists()): + shutil.copy2(src_logo, dst_logo) + logger.info("Copied default logo to %s", dst_logo) + + # Base invoice template + base_tpl = load_yaml(repo_root / "examples" / "invoice.init.yaml") + + # Apply seller defaults + seller_defaults = load_yaml(repo_root / "presets" / "seller.default.yaml") + merged = deep_merge(base_tpl, seller_defaults) + + # Apply customer preset + if customer: + preset = load_customer_preset(repo_root, customer) + if preset: + 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) + + # Smart defaults for meta/payment + today = date.today() + merged.setdefault("meta", {}) + merged["meta"]["invoice_date"] = str(today) + merged["meta"]["service_date"] = str(today) + + # invoice number + if not invoice_number and invoices_root: + try: + invoice_number = suggest_next_invoice_number(invoices_root) + logger.info("Suggested next invoice number: %s (root=%s)", invoice_number, invoices_root) + except Exception as e: + logger.warning("Failed to suggest invoice number from %s: %s", invoices_root, e) + + if invoice_number: + merged["meta"]["invoice_number"] = invoice_number + + # project -> default first item name + if project: + merged.setdefault("items", []) + if len(merged["items"]) == 0: + merged["items"] = [{ + "name": project, + "description": "Leistung/Beschreibung", + "quantity": 1, + "unit": "C62", + "unit_price_net": 100.00, + "tax_rate": 19.0 + }] + else: + if merged["items"][0].get("name") in ("Leistung", "Service", None, ""): + merged["items"][0]["name"] = project + logger.info("Applied project default: %s", project) + + merged.setdefault("payment", {}) + merged["payment"]["due_date"] = str(today + timedelta(days=due_days)) + inv_no_for_ref = merged["meta"].get("invoice_number", "") + merged["payment"]["reference"] = f"Rechnung {inv_no_for_ref}".strip() + + dst_yaml = target_dir / "invoice.yaml" + if dst_yaml.exists() and not force: + raise FileExistsError(f"{dst_yaml} already exists (use --force to overwrite)") + _dump_yaml(merged, dst_yaml) + + logger.info("Initialized invoice folder: %s", target_dir) + return target_dir + +def init_invoice_under_root( + invoices_root: Path, + repo_root: Path, + logger, + customer: str | None, + project: str | None, + invoice_number: str | None, + due_days: int, + force: bool, +) -> Path: + invoices_root = invoices_root.expanduser().resolve() + invoices_root.mkdir(parents=True, exist_ok=True) + + # suggest number if missing + if not invoice_number: + invoice_number = suggest_next_invoice_number(invoices_root) + logger.info("Auto invoice number: %s", invoice_number) + + folder_name = default_invoice_folder_name(invoice_number, customer) + target_dir = invoices_root / folder_name + logger.info("Auto folder name: %s", target_dir) + + return init_invoice_folder( + target_dir=target_dir, + repo_root=repo_root, + logger=logger, + force=force, + customer=customer, + invoice_number=invoice_number, + due_days=due_days, + invoices_root=invoices_root, + project=project, + ) diff --git a/jr_einvoice/invoice_number.py b/jr_einvoice/invoice_number.py new file mode 100644 index 0000000..e8f8188 --- /dev/null +++ b/jr_einvoice/invoice_number.py @@ -0,0 +1,28 @@ +from __future__ import annotations +from pathlib import Path +import re +from datetime import date + +DEFAULT_PATTERN = r"(?P\d{4})-(?P\d{4})" + +def suggest_next_invoice_number(invoices_root: Path, year: int | None = None, pattern: str = DEFAULT_PATTERN) -> str: + invoices_root = invoices_root.expanduser().resolve() + y = year or date.today().year + rx = re.compile(pattern) + + max_seq = 0 + if invoices_root.exists(): + for d in invoices_root.iterdir(): + if not d.is_dir(): + continue + m = rx.search(d.name) + if not m: + continue + try: + yr = int(m.group("year")) + seq = int(m.group("seq")) + except Exception: + continue + if yr == y and seq > max_seq: + max_seq = seq + return f"{y}-{max_seq+1:04d}" diff --git a/jr_einvoice/io.py b/jr_einvoice/io.py new file mode 100644 index 0000000..5256dfd --- /dev/null +++ b/jr_einvoice/io.py @@ -0,0 +1,8 @@ +from __future__ import annotations +from pathlib import Path +import yaml +from .models import Invoice + +def load_invoice_yaml(path: Path) -> Invoice: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + return Invoice.model_validate(data) diff --git a/jr_einvoice/logging_utils.py b/jr_einvoice/logging_utils.py new file mode 100644 index 0000000..6ab9835 --- /dev/null +++ b/jr_einvoice/logging_utils.py @@ -0,0 +1,30 @@ +from __future__ import annotations +import logging +from pathlib import Path +from typing import Optional + +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 + + fmt = logging.Formatter( + fmt="%(asctime)s.%(msecs)03d %(levelname)s [%(name)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + ch = logging.StreamHandler() + ch.setLevel(getattr(logging, log_level.upper(), logging.INFO)) + ch.setFormatter(fmt) + logger.addHandler(ch) + + if log_file is not None: + log_file.parent.mkdir(parents=True, exist_ok=True) + fh = logging.FileHandler(log_file, encoding="utf-8") + fh.setLevel(logging.DEBUG) + fh.setFormatter(fmt) + logger.addHandler(fh) + + return logger diff --git a/jr_einvoice/models.py b/jr_einvoice/models.py new file mode 100644 index 0000000..943d903 --- /dev/null +++ b/jr_einvoice/models.py @@ -0,0 +1,56 @@ +from __future__ import annotations +from datetime import date +from pydantic import BaseModel, Field, 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 Party(BaseModel): + name: str + vat_id: Optional[str] = None + tax_number: Optional[str] = None + email: Optional[str] = None + address: Address + + @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]: + raise ValueError("email does not look valid") + return v + +class Meta(BaseModel): + invoice_number: str = Field(min_length=1) + invoice_date: date + service_date: date + currency: str = Field(min_length=3, max_length=3, default="EUR") + language: Optional[str] = "de" + notes: Optional[str] = None + +class Payment(BaseModel): + 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): + 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) + +class Invoice(BaseModel): + meta: Meta + seller: Party + buyer: Party + payment: Payment + items: List[Item] diff --git a/jr_einvoice/mustang.py b/jr_einvoice/mustang.py new file mode 100644 index 0000000..ad7bc82 --- /dev/null +++ b/jr_einvoice/mustang.py @@ -0,0 +1,42 @@ +from __future__ import annotations +from pathlib import Path +import subprocess + +class MustangError(RuntimeError): + pass + +def _run(jar: Path, args: list[str], logger) -> subprocess.CompletedProcess[str]: + if not jar.exists(): + 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) + logger.debug("Mustang stdout:\n%s", proc.stdout.strip()) + logger.debug("Mustang stderr:\n%s", proc.stderr.strip()) + return proc + +def combine_facturx(jar: Path, source_pdf: Path, source_xml: Path, out_pdf: Path, logger, profile: str = "E") -> Path: + out_pdf.parent.mkdir(parents=True, exist_ok=True) + proc = _run(jar, [ + "--action", "combine", + "--source", str(source_pdf), + "--source-xml", str(source_xml), + "--out", str(out_pdf), + "--profile", profile + ], logger) + if proc.returncode != 0: + raise MustangError(f"Mustang combine failed (rc={proc.returncode}). See logs for details.") + return out_pdf + +def validate(jar: Path, source: Path, out_report: Path, logger, no_notices: bool = True) -> Path: + out_report.parent.mkdir(parents=True, exist_ok=True) + args = [] + if no_notices: + args += ["--no-notices"] + args += ["--action", "validate", "--source", str(source)] + proc = _run(jar, args, logger) + if proc.returncode != 0: + raise MustangError(f"Mustang validate failed (rc={proc.returncode}). See logs for details.") + content = proc.stdout.strip() or "empty stdout from Mustang; check logs" + out_report.write_text(content, encoding="utf-8") + return out_report diff --git a/jr_einvoice/pipeline.py b/jr_einvoice/pipeline.py new file mode 100644 index 0000000..f13547f --- /dev/null +++ b/jr_einvoice/pipeline.py @@ -0,0 +1,67 @@ +from __future__ import annotations +from pathlib import Path +from dataclasses import dataclass +from typing import Optional + +from .io import load_invoice_yaml +from .render import render_html, render_pdf +from .facturx import generate_facturx_xml +from .mustang import combine_facturx, validate + +@dataclass +class BuildResult: + invoice_dir: Path + out_dir: Path + invoice_pdf: Path + facturx_xml: Path + facturx_pdf: Optional[Path] = None + validate_report: Optional[Path] = None + +def build_in_folder( + invoice_dir: Path, + out_subdir: str, + template_path: Path, + logger, + mustang_jar: Optional[Path] = None, + combine: bool = False, + do_validate: bool = False, + prefer_weasyprint: bool = True, + invoice_filename: str = "invoice.yaml", +) -> BuildResult: + invoice_dir = invoice_dir.resolve() + invoice_yaml = invoice_dir / invoice_filename + if not invoice_yaml.exists(): + raise FileNotFoundError(f"Missing {invoice_filename} in invoice folder: {invoice_dir}") + + inv = load_invoice_yaml(invoice_yaml) + out_dir = invoice_dir / out_subdir + (out_dir / "logs").mkdir(parents=True, exist_ok=True) + + logger.info("Loaded invoice: %s (folder=%s)", inv.meta.invoice_number, invoice_dir) + logger.info("Artifacts directory: %s", out_dir) + + html = render_html(inv, template_path) + + pdf_path = out_dir / "invoice.pdf" + pdf_res = render_pdf(html, pdf_path, logger, prefer_weasyprint=prefer_weasyprint, base_url=str(invoice_dir)) + if not pdf_res.ok or not pdf_res.path: + raise RuntimeError(f"PDF rendering failed: {pdf_res.error}") + + xml_path = out_dir / "factur-x.xml" + generate_facturx_xml(inv, xml_path, logger) + + result = BuildResult(invoice_dir=invoice_dir, out_dir=out_dir, invoice_pdf=pdf_path, facturx_xml=xml_path) + + if combine or do_validate: + if not mustang_jar: + raise RuntimeError("Mustang jar required for --combine/--validate. Provide --mustang-jar path.") + facturx_pdf = out_dir / "invoice.facturx.pdf" + logger.info("Combining PDF + XML -> Factur-X PDF via Mustang") + result.facturx_pdf = combine_facturx(mustang_jar, pdf_path, xml_path, facturx_pdf, logger, profile="E") + + 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) + + return result diff --git a/jr_einvoice/presets.py b/jr_einvoice/presets.py new file mode 100644 index 0000000..46c5449 --- /dev/null +++ b/jr_einvoice/presets.py @@ -0,0 +1,27 @@ +from __future__ import annotations +from pathlib import Path +import yaml + +def load_yaml(path: Path) -> dict: + if not path.exists(): + return {} + return yaml.safe_load(path.read_text(encoding="utf-8")) or {} + +def deep_merge(a: dict, b: dict) -> dict: + out = dict(a) + for k, v in (b or {}).items(): + if isinstance(v, dict) and isinstance(out.get(k), dict): + out[k] = deep_merge(out[k], v) + else: + out[k] = v + return out + +def load_customer_preset(repo_root: Path, customer_key: str) -> dict: + key = customer_key.strip().lower() + # Prefer per-customer file + file_preset = repo_root / "presets" / "customers" / f"{key}.yaml" + if file_preset.exists(): + return load_yaml(file_preset) + # Fallback to shared customers.yaml + shared = load_yaml(repo_root / "presets" / "customers.yaml").get("customers", {}) + return shared.get(key, {}) diff --git a/jr_einvoice/render.py b/jr_einvoice/render.py new file mode 100644 index 0000000..e2a509f --- /dev/null +++ b/jr_einvoice/render.py @@ -0,0 +1,80 @@ +from __future__ import annotations +from pathlib import Path +import subprocess +import shutil + +from jinja2 import Environment, FileSystemLoader, select_autoescape + +from .models import Invoice +from .calc import compute_totals +from .types import RenderResult +from .utils import money_fmt + +def render_html(inv: Invoice, template_path: Path) -> str: + env = Environment( + loader=FileSystemLoader(str(template_path.parent)), + autoescape=select_autoescape(["html", "xml"]), + ) + env.globals["money"] = lambda x: money_fmt(x, inv.meta.currency) + tpl = env.get_template(template_path.name) + + totals = compute_totals(inv) + + items = [] + for it in inv.items: + d = it.model_dump() + d["line_total_net"] = float(it.quantity) * float(it.unit_price_net) + items.append(type("Obj", (), d)) + + ctx = { + "meta": inv.meta.model_dump(), + "seller": inv.seller.model_dump(), + "buyer": inv.buyer.model_dump(), + "payment": inv.payment.model_dump(), + "items": items, + "totals": totals, + } + return tpl.render(**ctx) + +def _weasyprint_available() -> bool: + try: + import weasyprint # noqa + return True + except Exception: + return False + +def render_pdf(html: str, out_pdf: Path, logger, prefer_weasyprint: bool = True, base_url: str | None = None) -> RenderResult: + out_pdf.parent.mkdir(parents=True, exist_ok=True) + + if prefer_weasyprint and _weasyprint_available(): + 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)) + return RenderResult(ok=True, backend="weasyprint", path=out_pdf) + except Exception as e: + logger.exception("WeasyPrint rendering failed; trying wkhtmltopdf if available: %s", e) + + wk = shutil.which("wkhtmltopdf") + if wk: + logger.info("Rendering PDF using wkhtmltopdf (%s)", wk) + tmp_html = out_pdf.with_suffix(".tmp.html") + tmp_html.write_text(html, encoding="utf-8") + try: + proc = subprocess.run( + [wk, "--quiet", "--enable-local-file-access", str(tmp_html), str(out_pdf)], + capture_output=True, + text=True, + check=False, + ) + if proc.returncode != 0: + logger.error("wkhtmltopdf failed (rc=%s). stderr=%s", proc.returncode, proc.stderr.strip()) + return RenderResult(ok=False, backend="wkhtmltopdf", path=None, error="wkhtmltopdf failed") + return RenderResult(ok=True, backend="wkhtmltopdf", path=out_pdf) + finally: + try: + tmp_html.unlink(missing_ok=True) + except Exception: + pass + + return RenderResult(ok=False, backend="none", path=None, error="No PDF backend available (install weasyprint or wkhtmltopdf).") diff --git a/jr_einvoice/setup.py b/jr_einvoice/setup.py new file mode 100644 index 0000000..14a9013 --- /dev/null +++ b/jr_einvoice/setup.py @@ -0,0 +1,89 @@ +from __future__ import annotations +from pathlib import Path +import urllib.request +import hashlib +import xml.etree.ElementTree as ET + +MAVEN_BASE = "https://repo1.maven.org/maven2/org/mustangproject/Mustang-CLI" +VERSION_FILE = "VERSION.txt" + +class SetupError(RuntimeError): + pass + +def _download(url: str, dest: Path, logger) -> None: + logger.info("Downloading: %s", url) + dest.parent.mkdir(parents=True, exist_ok=True) + try: + with urllib.request.urlopen(url, timeout=60) as r: + data = r.read() + dest.write_bytes(data) + except Exception as e: + raise SetupError(f"Failed to download {url}: {e}") from e + +def _sha1_of_file(path: Path) -> str: + h = hashlib.sha1() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + +def get_latest_mustang_version(logger) -> str: + url = f"{MAVEN_BASE}/maven-metadata.xml" + logger.info("Checking latest Mustang version from Maven metadata: %s", url) + try: + with urllib.request.urlopen(url, timeout=30) as r: + xml_data = r.read() + root = ET.fromstring(xml_data) + latest = root.findtext("./versioning/latest") or root.findtext("./versioning/release") + if not latest: + # fallback: last version in versions list + versions = [v.text for v in root.findall("./versioning/versions/version") if v.text] + if versions: + latest = versions[-1] + if not latest: + raise SetupError("Could not determine latest version from maven-metadata.xml") + return latest.strip() + except Exception as e: + raise SetupError(f"Failed to read Maven metadata: {e}") from e + +def read_installed_version(mustang_dir: Path) -> str | None: + vf = mustang_dir / VERSION_FILE + if vf.exists(): + return vf.read_text(encoding="utf-8").strip() or None + return None + +def write_installed_version(mustang_dir: Path, version: str) -> None: + mustang_dir.mkdir(parents=True, exist_ok=True) + (mustang_dir / VERSION_FILE).write_text(version.strip(), encoding="utf-8") + +def install_mustang(version: str, target_path: Path, logger, verify_sha1: bool = True) -> Path: + jar_name = f"Mustang-CLI-{version}.jar" + jar_url = f"{MAVEN_BASE}/{version}/{jar_name}" + sha1_url = f"{jar_url}.sha1" + + tmp = target_path.with_suffix(".download") + _download(jar_url, tmp, logger) + + if verify_sha1: + sha1_tmp = target_path.with_suffix(".sha1") + _download(sha1_url, sha1_tmp, logger) + expected = sha1_tmp.read_text(encoding="utf-8").strip().split()[0] + actual = _sha1_of_file(tmp) + if expected.lower() != actual.lower(): + tmp.unlink(missing_ok=True) + raise SetupError(f"SHA1 mismatch for Mustang jar. expected={expected} actual={actual}") + sha1_tmp.unlink(missing_ok=True) + logger.info("SHA1 verified for Mustang jar") + + target_path.parent.mkdir(parents=True, exist_ok=True) + tmp.replace(target_path) + write_installed_version(target_path.parent, version) + logger.info("Installed Mustang CLI: %s (version=%s)", target_path, version) + return target_path + +def check_for_mustang_update(mustang_dir: Path, logger) -> tuple[bool, str | None, str]: + installed = read_installed_version(mustang_dir) + latest = get_latest_mustang_version(logger) + if installed is None: + return True, None, latest + return (installed.strip() != latest.strip()), installed, latest diff --git a/jr_einvoice/types.py b/jr_einvoice/types.py new file mode 100644 index 0000000..907cbeb --- /dev/null +++ b/jr_einvoice/types.py @@ -0,0 +1,11 @@ +from __future__ import annotations +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +@dataclass +class RenderResult: + ok: bool + backend: str + path: Optional[Path] + error: Optional[str] = None diff --git a/jr_einvoice/utils.py b/jr_einvoice/utils.py new file mode 100644 index 0000000..5c761dc --- /dev/null +++ b/jr_einvoice/utils.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +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", ".") diff --git a/presets/customers.yaml b/presets/customers.yaml new file mode 100644 index 0000000..5823043 --- /dev/null +++ b/presets/customers.yaml @@ -0,0 +1,15 @@ +# Customer presets used by `jr-einvoice init --customer ` +# Prefer file presets/customers/.yaml if present; fallback to this file. +# Keys are case-insensitive lookups (we normalize to lowercase). +customers: + unicredit: + buyer: + name: "UniCredit" + vat_id: null + address: + street: "Platz der Republik 1" + postal_code: "00000" + city: "Ort" + country: "DE" + meta: + notes: "Bitte Rechnungsnummer im Betreff angeben." diff --git a/presets/customers/unicredit.yaml b/presets/customers/unicredit.yaml new file mode 100644 index 0000000..432f735 --- /dev/null +++ b/presets/customers/unicredit.yaml @@ -0,0 +1,10 @@ +buyer: + name: "UniCredit" + vat_id: null + address: + street: "Platz der Republik 1" + postal_code: "00000" + city: "Ort" + country: "DE" +meta: + notes: "Bitte Rechnungsnummer im Betreff angeben." diff --git a/presets/seller.default.yaml b/presets/seller.default.yaml new file mode 100644 index 0000000..ed2ad33 --- /dev/null +++ b/presets/seller.default.yaml @@ -0,0 +1,16 @@ +# Default seller settings used by `jr-einvoice init` (edit to your real data) +seller: + name: "JR IT Services" + vat_id: "DE123456789" + tax_number: "123/456/78901" + email: "johannes@jr-it-services.de" + address: + street: "Musterstraße 1" + postal_code: "80331" + city: "München" + country: "DE" + +payment: + iban: "DE02120300000000202051" + bic: "BYLADEM1001" + bank_name: "Musterbank" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..faf5c8a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,31 @@ +[build-system] +requires = ["hatchling>=1.21.0"] +build-backend = "hatchling.build" + +[project] +name = "jr-einvoice-toolchain" +version = "0.8.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" +license = { text = "MIT" } +authors = [{ name = "Johannes Rest" }] +dependencies = [ + "typer>=0.12.3", + "rich>=13.7.1", + "pydantic>=2.7.0", + "PyYAML>=6.0.1", + "Jinja2>=3.1.4", + "pycheval>=0.2.0" +] + +[project.optional-dependencies] +pdf = [ + "weasyprint>=62.3" +] + +[project.scripts] +jr-einvoice = "jr_einvoice.cli:app" + +[tool.hatch.build.targets.wheel] +packages = ["jr_einvoice"] diff --git a/requirements-pdf.txt b/requirements-pdf.txt new file mode 100644 index 0000000..ed597d4 --- /dev/null +++ b/requirements-pdf.txt @@ -0,0 +1 @@ +weasyprint>=62.3 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..05d2fe8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +typer>=0.12.3 +rich>=13.7.1 +pydantic>=2.7.0 +PyYAML>=6.0.1 +Jinja2>=3.1.4 +pycheval>=0.2.0 diff --git a/scripts/arch_install.sh b/scripts/arch_install.sh new file mode 100644 index 0000000..fb0d521 --- /dev/null +++ b/scripts/arch_install.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "[1/3] Installing Java (needed for Mustang combine/validate)" +sudo pacman -S --needed jre-openjdk-headless + +echo "[2/3] Installing libs for WeasyPrint (PDF rendering)" +sudo pacman -S --needed cairo pango gdk-pixbuf2 libffi + +echo "[3/3] Optional fallback: wkhtmltopdf" +echo "Run if you want fallback PDF renderer:" +echo " sudo pacman -S --needed wkhtmltopdf" diff --git a/scripts/download_mustang.sh b/scripts/download_mustang.sh new file mode 100644 index 0000000..5838c30 --- /dev/null +++ b/scripts/download_mustang.sh @@ -0,0 +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" diff --git a/templates/invoice.html.j2 b/templates/invoice.html.j2 new file mode 100644 index 0000000..014db81 --- /dev/null +++ b/templates/invoice.html.j2 @@ -0,0 +1,111 @@ + + + + + Rechnung {{ meta.invoice_number }} + + + +
+ Logo +
+ +
+
+

Rechnung

+
Rechnungsnummer: {{ meta.invoice_number }}
+
Rechnungsdatum: {{ meta.invoice_date }}
+
Leistungsdatum: {{ meta.service_date }}
+
+
+
{{ seller.name }}
+
{{ seller.address.street }}
+
{{ seller.address.postal_code }} {{ seller.address.city }}
+
{{ seller.address.country }}
+ {% if seller.vat_id %}
USt-IdNr: {{ seller.vat_id }}
{% endif %} + {% if seller.tax_number %}
Steuernr: {{ seller.tax_number }}
{% endif %} + {% if seller.email %}
{{ seller.email }}
{% endif %} +
+
+ +
+
+
Rechnung an
+
{{ buyer.name }}
+
{{ buyer.address.street }}
+
{{ buyer.address.postal_code }} {{ buyer.address.city }}
+
{{ buyer.address.country }}
+ {% if buyer.vat_id %}
USt-IdNr: {{ buyer.vat_id }}
{% endif %} +
+ +
+
Zahlungsinformationen
+
Fällig am: {{ payment.due_date }}
+
Verwendungszweck: {{ payment.reference }}
+ {% if payment.iban %}
IBAN: {{ payment.iban }}
{% endif %} + {% if payment.bic %}
BIC: {{ payment.bic }}
{% endif %} + {% if payment.bank_name %}
Bank: {{ payment.bank_name }}
{% endif %} +
+
+ + + + + + + + + + + + + + + {% for it in items %} + + + + + + + + + + {% endfor %} + +
Pos.BeschreibungMengeEinheitEinzelpreis (netto)SteuerSumme (netto)
{{ loop.index }} + {{ it.name }}
+ {% if it.description %}{{ it.description }}{% endif %} +
{{ "%.2f"|format(it.quantity) }}{{ it.unit }}{{ money(it.unit_price_net) }}{{ "%.2f"|format(it.tax_rate) }}%{{ money(it.line_total_net) }}
+ + + + + +
Zwischensumme (netto):{{ money(totals.net_total) }}
USt gesamt:{{ money(totals.tax_total) }}
Gesamt (brutto):{{ money(totals.gross_total) }}
+ + {% if meta.notes %} + + {% endif %} + + + + diff --git a/tools/mustang/README.txt b/tools/mustang/README.txt new file mode 100644 index 0000000..1a99b8a --- /dev/null +++ b/tools/mustang/README.txt @@ -0,0 +1 @@ +Jar will be placed here by `jr-einvoice setup-mustang`.