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

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", ".")