Initial version of the E-Invoice solution of JR IT Services
This commit is contained in:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.venv/
|
||||||
|
**/__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.log
|
||||||
|
# invoice artifacts:
|
||||||
|
**/out/
|
||||||
|
# mustang jar is local:
|
||||||
|
tools/mustang/*.jar
|
||||||
120
Documentation.md
Normal file
120
Documentation.md
Normal file
@@ -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 <invoice_dir>` 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 `<invoice_dir>/assets/logo.svg`.
|
||||||
|
Das Template referenziert `assets/logo.svg` relativ – WeasyPrint bekommt dafür `base_url=<invoice_dir>`.
|
||||||
|
|
||||||
|
## 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/<customer>.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_<invoice_number>_<customer>`
|
||||||
|
|
||||||
|
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
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -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.
|
||||||
21
Makefile
Normal file
21
Makefile
Normal file
@@ -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
|
||||||
31
README.md
Normal file
31
README.md
Normal file
@@ -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`
|
||||||
4
assets/logo.svg
Normal file
4
assets/logo.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="420" height="120" viewBox="0 0 420 120">
|
||||||
|
<rect x="0" y="0" width="420" height="120" rx="14" fill="#111827"/>
|
||||||
|
<text x="24" y="70" font-family="Arial, Helvetica, sans-serif" font-size="44" fill="#ffffff">JR IT Services</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 282 B |
48
examples/invoice.init.yaml
Normal file
48
examples/invoice.init.yaml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Invoice input for jr-einvoice
|
||||||
|
# Edit this file, then run:
|
||||||
|
# jr-einvoice generate .
|
||||||
|
# or from repo root:
|
||||||
|
# jr-einvoice generate <this-folder>
|
||||||
|
|
||||||
|
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
|
||||||
49
examples/invoice.sample.yaml
Normal file
49
examples/invoice.sample.yaml
Normal file
@@ -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
|
||||||
1
jr_einvoice/__init__.py
Normal file
1
jr_einvoice/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
__all__ = []
|
||||||
24
jr_einvoice/calc.py
Normal file
24
jr_einvoice/calc.py
Normal file
@@ -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)
|
||||||
230
jr_einvoice/cli.py
Normal file
230
jr_einvoice/cli.py
Normal file
@@ -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_<nr>_<customer> 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)
|
||||||
20
jr_einvoice/editor.py
Normal file
20
jr_einvoice/editor.py
Normal file
@@ -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)
|
||||||
22
jr_einvoice/facturx.py
Normal file
22
jr_einvoice/facturx.py
Normal file
@@ -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
|
||||||
16
jr_einvoice/folder_naming.py
Normal file
16
jr_einvoice/folder_naming.py
Normal file
@@ -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}"
|
||||||
137
jr_einvoice/init.py
Normal file
137
jr_einvoice/init.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
28
jr_einvoice/invoice_number.py
Normal file
28
jr_einvoice/invoice_number.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
DEFAULT_PATTERN = r"(?P<year>\d{4})-(?P<seq>\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}"
|
||||||
8
jr_einvoice/io.py
Normal file
8
jr_einvoice/io.py
Normal file
@@ -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)
|
||||||
30
jr_einvoice/logging_utils.py
Normal file
30
jr_einvoice/logging_utils.py
Normal file
@@ -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
|
||||||
56
jr_einvoice/models.py
Normal file
56
jr_einvoice/models.py
Normal file
@@ -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]
|
||||||
42
jr_einvoice/mustang.py
Normal file
42
jr_einvoice/mustang.py
Normal file
@@ -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 "<report>empty stdout from Mustang; check logs</report>"
|
||||||
|
out_report.write_text(content, encoding="utf-8")
|
||||||
|
return out_report
|
||||||
67
jr_einvoice/pipeline.py
Normal file
67
jr_einvoice/pipeline.py
Normal file
@@ -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
|
||||||
27
jr_einvoice/presets.py
Normal file
27
jr_einvoice/presets.py
Normal file
@@ -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, {})
|
||||||
80
jr_einvoice/render.py
Normal file
80
jr_einvoice/render.py
Normal file
@@ -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).")
|
||||||
89
jr_einvoice/setup.py
Normal file
89
jr_einvoice/setup.py
Normal file
@@ -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
|
||||||
11
jr_einvoice/types.py
Normal file
11
jr_einvoice/types.py
Normal file
@@ -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
|
||||||
9
jr_einvoice/utils.py
Normal file
9
jr_einvoice/utils.py
Normal file
@@ -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", ".")
|
||||||
15
presets/customers.yaml
Normal file
15
presets/customers.yaml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Customer presets used by `jr-einvoice init --customer <name>`
|
||||||
|
# Prefer file presets/customers/<key>.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."
|
||||||
10
presets/customers/unicredit.yaml
Normal file
10
presets/customers/unicredit.yaml
Normal file
@@ -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."
|
||||||
16
presets/seller.default.yaml
Normal file
16
presets/seller.default.yaml
Normal file
@@ -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"
|
||||||
31
pyproject.toml
Normal file
31
pyproject.toml
Normal file
@@ -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"]
|
||||||
1
requirements-pdf.txt
Normal file
1
requirements-pdf.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
weasyprint>=62.3
|
||||||
6
requirements.txt
Normal file
6
requirements.txt
Normal file
@@ -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
|
||||||
12
scripts/arch_install.sh
Normal file
12
scripts/arch_install.sh
Normal file
@@ -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"
|
||||||
4
scripts/download_mustang.sh
Normal file
4
scripts/download_mustang.sh
Normal file
@@ -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"
|
||||||
111
templates/invoice.html.j2
Normal file
111
templates/invoice.html.j2
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="{{ meta.language or 'de' }}">
|
||||||
|
<head>
|
||||||
|
<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; }
|
||||||
|
.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; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div style="margin-bottom: 12px;">
|
||||||
|
<img src="assets/logo.svg" alt="Logo" style="height: 42px;" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<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>
|
||||||
|
<div class="box" style="min-width: 260px;">
|
||||||
|
<div><strong>{{ seller.name }}</strong></div>
|
||||||
|
<div>{{ seller.address.street }}</div>
|
||||||
|
<div>{{ seller.address.postal_code }} {{ seller.address.city }}</div>
|
||||||
|
<div>{{ seller.address.country }}</div>
|
||||||
|
{% if seller.vat_id %}<div>USt-IdNr: {{ seller.vat_id }}</div>{% endif %}
|
||||||
|
{% if seller.tax_number %}<div>Steuernr: {{ seller.tax_number }}</div>{% endif %}
|
||||||
|
{% if seller.email %}<div>{{ seller.email }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row" style="margin-top: 16px;">
|
||||||
|
<div class="box" style="flex: 1;">
|
||||||
|
<div class="muted">Rechnung an</div>
|
||||||
|
<div><strong>{{ buyer.name }}</strong></div>
|
||||||
|
<div>{{ buyer.address.street }}</div>
|
||||||
|
<div>{{ buyer.address.postal_code }} {{ buyer.address.city }}</div>
|
||||||
|
<div>{{ buyer.address.country }}</div>
|
||||||
|
{% if buyer.vat_id %}<div>USt-IdNr: {{ buyer.vat_id }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="box" style="flex: 1;">
|
||||||
|
<div class="muted">Zahlungsinformationen</div>
|
||||||
|
<div>Fällig am: {{ payment.due_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 %}
|
||||||
|
{% if payment.bank_name %}<div>Bank: {{ payment.bank_name }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Pos.</th>
|
||||||
|
<th>Beschreibung</th>
|
||||||
|
<th class="right">Menge</th>
|
||||||
|
<th>Einheit</th>
|
||||||
|
<th class="right">Einzelpreis (netto)</th>
|
||||||
|
<th class="right">Steuer</th>
|
||||||
|
<th class="right">Summe (netto)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for it in items %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ loop.index }}</td>
|
||||||
|
<td>
|
||||||
|
<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">{{ money(it.line_total_net) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table class="totals">
|
||||||
|
<tr><td class="right">Zwischensumme (netto):</td><td class="right"><strong>{{ money(totals.net_total) }}</strong></td></tr>
|
||||||
|
<tr><td class="right">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>
|
||||||
|
|
||||||
|
{% if meta.notes %}
|
||||||
|
<div class="footer">
|
||||||
|
<strong>Hinweise</strong><br>
|
||||||
|
{{ meta.notes }}
|
||||||
|
</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>
|
||||||
1
tools/mustang/README.txt
Normal file
1
tools/mustang/README.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Jar will be placed here by `jr-einvoice setup-mustang`.
|
||||||
Reference in New Issue
Block a user