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

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