Initial version of the E-Invoice solution of JR IT Services

This commit is contained in:
2026-02-16 17:02:03 +01:00
commit e0c15fc7f2
36 changed files with 1407 additions and 0 deletions

9
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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

View 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

View 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
View File

@@ -0,0 +1 @@
__all__ = []

24
jr_einvoice/calc.py Normal file
View 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
View 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
View 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
View 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

View 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
View 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,
)

View 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
View 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)

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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."

View 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."

View 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
View 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
View File

@@ -0,0 +1 @@
weasyprint>=62.3

6
requirements.txt Normal file
View 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
View 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"

View 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
View 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
View File

@@ -0,0 +1 @@
Jar will be placed here by `jr-einvoice setup-mustang`.