feat: create validated ZUGFeRD invoices in one console command
This commit is contained in:
1 parent
e0c15fc7f2
commit
a643cf5227
30 files changed
+1509
-207
No files matched your search
+21
-17
@@ -1,24 +1,28 @@
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from .models import Invoice
|
||||
|
||||
CENT = Decimal('0.01')
|
||||
|
||||
def rounded(value: Decimal) -> Decimal:
|
||||
return value.quantize(CENT, rounding=ROUND_HALF_UP)
|
||||
|
||||
def line_net(item) -> Decimal:
|
||||
return rounded(item.quantity * item.unit_price_net)
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Totals:
|
||||
net_total: float
|
||||
tax_total: float
|
||||
gross_total: float
|
||||
tax_by_rate: Dict[float, float]
|
||||
net_total: Decimal
|
||||
tax_total: Decimal
|
||||
gross_total: Decimal
|
||||
tax_by_rate: dict[Decimal, Decimal]
|
||||
basis_by_rate: dict[Decimal, Decimal]
|
||||
|
||||
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)
|
||||
bases = {}
|
||||
for item in inv.items:
|
||||
bases[item.tax_rate] = bases.get(item.tax_rate, Decimal(0)) + line_net(item)
|
||||
taxes = {rate: rounded(base * rate / 100) for rate, base in bases.items()}
|
||||
net = sum(bases.values(), Decimal(0))
|
||||
tax = sum(taxes.values(), Decimal(0))
|
||||
return Totals(net, tax, net + tax, taxes, bases)
|
||||
+61
-3
@@ -18,13 +18,17 @@ app = typer.Typer(add_completion=False, no_args_is_help=True)
|
||||
console = Console()
|
||||
|
||||
def _repo_root() -> Path:
|
||||
return Path(__file__).resolve().parent.parent
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
return root if (root / "templates/invoice.html.j2").is_file() else Path(__file__).resolve().parent / "data"
|
||||
|
||||
def _default_template() -> Path:
|
||||
return _repo_root() / "templates" / "invoice.html.j2"
|
||||
|
||||
def _mustang_default_path() -> Path:
|
||||
return _repo_root() / "tools" / "mustang" / "Mustang-CLI.jar"
|
||||
root = _repo_root()
|
||||
if root.name == "data":
|
||||
return Path.home() / ".local/share/jr-einvoice/tools/Mustang-CLI.jar"
|
||||
return root / "tools" / "mustang" / "Mustang-CLI.jar"
|
||||
|
||||
def _mustang_dir() -> Path:
|
||||
return _mustang_default_path().parent
|
||||
@@ -46,7 +50,7 @@ def _fail(msg: str, exc: Exception | None = None, exit_code: int = 1):
|
||||
|
||||
@app.command("setup-mustang")
|
||||
def setup_mustang(
|
||||
version: str = typer.Option("2.22.0", "--version", help="Mustang-CLI version to download"),
|
||||
version: str = typer.Option("2.26.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"),
|
||||
@@ -156,6 +160,7 @@ def generate(
|
||||
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)"),
|
||||
):
|
||||
invoice_dir = invoice_dir.resolve()
|
||||
try:
|
||||
_ = load_invoice_yaml(invoice_dir / invoice_filename)
|
||||
except Exception as e:
|
||||
@@ -228,3 +233,56 @@ def generate(
|
||||
except Exception as e:
|
||||
logger.exception("Generate failed")
|
||||
_fail("Generate failed. See out/logs/run.log for details.", e)
|
||||
|
||||
@app.command("monthly")
|
||||
def monthly(
|
||||
root: Path = typer.Argument(..., help="Private invoice root"),
|
||||
customer: str = typer.Option(..., "--customer", "-c"),
|
||||
month: str = typer.Option(..., "--month", help="Service month YYYY-MM"),
|
||||
hours: str = typer.Option(..., "--hours", help="Hours, decimal point allowed"),
|
||||
number: str = typer.Option(..., "--number", "-n", help="Unique invoice number, including existing PDF invoices"),
|
||||
rate: str | None = typer.Option(None, "--rate", help="Override preset hourly net rate"),
|
||||
issued: str | None = typer.Option(None, "--date", help="Invoice date YYYY-MM-DD; default today"),
|
||||
due_days: int = typer.Option(30, "--due-days", min=0),
|
||||
):
|
||||
"""Monatsrechnung mit Stunden, Zeitraum, Kundenstammdaten und Zahlungsziel anlegen."""
|
||||
from decimal import Decimal
|
||||
from datetime import date
|
||||
from .monthly import monthly_invoice
|
||||
try:
|
||||
target = monthly_invoice(root, _repo_root(), setup_logging("INFO", None), customer,
|
||||
month, Decimal(hours), number, date.fromisoformat(issued) if issued else None,
|
||||
Decimal(rate) if rate else None, due_days)
|
||||
sys.stdout.write(str(target) + "\n")
|
||||
except Exception as e:
|
||||
_fail("Monatsrechnung konnte nicht angelegt werden", e)
|
||||
|
||||
@app.command("email")
|
||||
def email_draft(
|
||||
invoice_dir: Path = typer.Argument(..., exists=True, file_okay=False),
|
||||
recipient: str | None = typer.Option(None, "--to", help="Agreed invoice recipient"),
|
||||
mustang_jar: Path | None = typer.Option(None, "--mustang-jar"),
|
||||
):
|
||||
"""Rechnung neu erzeugen, validieren und Versandentwurf (.eml) mit PDF-Anhang vorbereiten."""
|
||||
from .delivery import prepare_email
|
||||
logger = setup_logging("INFO", invoice_dir / 'out/logs/run.log')
|
||||
try:
|
||||
path = prepare_email(invoice_dir.resolve(), mustang_jar or _default_mustang_jar(), logger, recipient)
|
||||
console.print(f"Versandentwurf: {path}\nIm Mailprogramm öffnen, prüfen und absenden.")
|
||||
except Exception as e:
|
||||
logger.exception("Email preparation failed")
|
||||
_fail("Versandentwurf konnte nicht erstellt werden", e)
|
||||
|
||||
@app.command("validate")
|
||||
def validate_file(
|
||||
source: Path = typer.Argument(..., exists=True, dir_okay=False),
|
||||
report: Path = typer.Option(Path("validation-report.xml"), "--report"),
|
||||
mustang_jar: Path | None = typer.Option(None, "--mustang-jar"),
|
||||
):
|
||||
"""Eingehende XML- oder ZUGFeRD-PDF-Rechnung lokal technisch prüfen."""
|
||||
from .mustang import validate
|
||||
try:
|
||||
validate(mustang_jar or _default_mustang_jar(), source.resolve(), report, setup_logging("INFO", None))
|
||||
console.print(f"Technisch gültig. Prüfbericht: {report}")
|
||||
except Exception as e:
|
||||
_fail("Validierung fehlgeschlagen", e)
|
||||
@@ -0,0 +1,185 @@
|
||||
"""One-command invoice creation with reusable private customer records."""
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from pathlib import Path
|
||||
import calendar
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import yaml
|
||||
|
||||
from .calc import compute_totals
|
||||
from .utils import money_fmt
|
||||
from .folder_naming import default_invoice_folder_name
|
||||
from .invoice_number import invoice_root_lock
|
||||
from .models import Invoice, Party
|
||||
from .pipeline import build_in_folder, BuildResult
|
||||
from .presets import load_yaml
|
||||
|
||||
MONTHS = 'januar februar märz april mai juni juli august september oktober november dezember'.split()
|
||||
|
||||
|
||||
def decimal_input(value: str) -> Decimal:
|
||||
"""Accept 85,00 €, 1.234,56 EUR or ungrouped decimal-point notation."""
|
||||
raw = re.sub(r'\s*(?:€|EUR)\s*$', '', value.strip(), flags=re.I)
|
||||
raw = raw.replace('\u00a0', '').strip()
|
||||
if ',' in raw:
|
||||
if not re.fullmatch(r'[+-]?(?:\d+|\d{1,3}(?:\.\d{3})+),\d+', raw):
|
||||
raise ValueError(f'Ungültige Dezimalzahl: {value!r}')
|
||||
raw = raw.replace('.', '').replace(',', '.')
|
||||
elif not re.fullmatch(r'[+-]?\d+(?:\.\d+)?', raw):
|
||||
raise ValueError(f'Ungültige Dezimalzahl: {value!r}')
|
||||
try:
|
||||
result = Decimal(raw)
|
||||
except InvalidOperation as exc:
|
||||
raise ValueError(f'Ungültige Dezimalzahl: {value!r}') from exc
|
||||
if not result.is_finite():
|
||||
raise ValueError('Zahl muss endlich sein')
|
||||
return result
|
||||
|
||||
|
||||
def customer_key(name: str) -> str:
|
||||
# Hash prevents punctuation/transliteration collisions and path traversal.
|
||||
readable = re.sub(r'[^a-z0-9]+', '-', name.casefold()).strip('-')[:32] or 'kunde'
|
||||
return f'{readable}-{hashlib.sha256(name.casefold().encode()).hexdigest()[:8]}'
|
||||
|
||||
|
||||
def find_customer(presets: Path, name: str) -> tuple[str, dict] | None:
|
||||
wanted = name.strip().casefold()
|
||||
matches = []
|
||||
for path in sorted((presets / 'customers').glob('*.yaml')):
|
||||
data = load_yaml(path)
|
||||
if wanted in (path.stem.casefold(), str(data.get('buyer', {}).get('name', '')).casefold()):
|
||||
matches.append((path.stem, data))
|
||||
if len(matches) > 1:
|
||||
raise ValueError('Kundenname ist mehrdeutig; bitte den eindeutigen Kundenschlüssel verwenden')
|
||||
return matches[0] if matches else None
|
||||
|
||||
|
||||
def save_customer(presets: Path, name: str, buyer: dict) -> tuple[str, dict]:
|
||||
buyer = Party.model_validate(buyer).model_dump(mode='json')
|
||||
key = customer_key(name)
|
||||
directory = presets / 'customers'
|
||||
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
target = directory / f'{key}.yaml'
|
||||
data = {'buyer': buyer}
|
||||
# Exclusive create; never replace an existing customer's stored details silently.
|
||||
with target.open('x', encoding='utf-8') as file:
|
||||
target.chmod(0o600)
|
||||
yaml.safe_dump(data, file, allow_unicode=True, sort_keys=False)
|
||||
return key, data
|
||||
|
||||
|
||||
def service_period(entry: str, month: str | None, service_date: date | None, issued: date):
|
||||
if month and service_date:
|
||||
raise ValueError('--Month und --ServiceDate können nicht kombiniert werden')
|
||||
if service_date:
|
||||
return service_date, None, None
|
||||
if month is None:
|
||||
match = re.search(r'\b(' + '|'.join(MONTHS) + r')\s+(\d{4})\b', entry.casefold())
|
||||
if match:
|
||||
month = f'{match[2]}-{MONTHS.index(match[1]) + 1:02d}'
|
||||
if month:
|
||||
if not re.fullmatch(r'\d{4}-\d{2}', month):
|
||||
raise ValueError('--Month muss YYYY-MM sein')
|
||||
year, m = map(int, month.split('-'))
|
||||
first = date(year, m, 1)
|
||||
last = date(year, m, calendar.monthrange(year, m)[1])
|
||||
return last, first, last
|
||||
return issued, None, None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CreatedInvoice:
|
||||
build: BuildResult
|
||||
attachment: Path
|
||||
invoice: Invoice
|
||||
|
||||
|
||||
def create_invoice(*, root: Path, resources: Path, presets: Path, customer: str,
|
||||
entry: str, volume: Decimal, price: Decimal, jar: Path, logger,
|
||||
number: str | None = None, issued: date | None = None,
|
||||
month: str | None = None, service_date: date | None = None,
|
||||
unit: str = 'HUR', tax: Decimal = Decimal('19'), due_days: int = 30,
|
||||
buyer_reference: str | None = None, order_reference: str | None = None,
|
||||
progress=lambda message: None) -> CreatedInvoice:
|
||||
if due_days < 0:
|
||||
raise ValueError('Zahlungsziel darf nicht negativ sein')
|
||||
if not jar.is_file():
|
||||
raise FileNotFoundError('Mustang fehlt. Einmalig jr-einvoice setup-mustang ausführen')
|
||||
seller_data = load_yaml(presets / 'seller.default.yaml')
|
||||
if not seller_data.get('seller') or not seller_data.get('payment'):
|
||||
raise ValueError(f'Eigene Absender- und Zahlungsdaten in {presets / "seller.default.yaml"} hinterlegen')
|
||||
match = find_customer(presets, customer)
|
||||
if match is None:
|
||||
raise ValueError(f'Kunde {customer!r} ist noch nicht angelegt')
|
||||
key, record = match
|
||||
issued = issued or date.today()
|
||||
delivery, start, end = service_period(entry, month, service_date, issued)
|
||||
root = root.expanduser().resolve()
|
||||
with invoice_root_lock(root):
|
||||
# Only dedicated invoice directories; unknown/corrupt records stop allocation.
|
||||
used = set()
|
||||
for path in root.glob('*/invoice.yaml'):
|
||||
existing = load_yaml(path).get('meta', {}).get('invoice_number')
|
||||
if not isinstance(existing, str) or not existing.strip():
|
||||
raise ValueError(f'Rechnungsnummer im vorhandenen Entwurf unklar: {path}')
|
||||
used.add(existing)
|
||||
if not number:
|
||||
prefix = f'JR-{issued.year}-'
|
||||
sequence = max((int(n[len(prefix):]) for n in used
|
||||
if n.startswith(prefix) and n[len(prefix):].isdigit()), default=0) + 1
|
||||
number = f'{prefix}{sequence:04d}'
|
||||
if number in used:
|
||||
raise ValueError(f'Rechnungsnummer bereits vergeben: {number}')
|
||||
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_-]{0,63}', number):
|
||||
raise ValueError('Rechnungsnummer: 1–64 Buchstaben/Ziffern, Bindestrich oder Unterstrich')
|
||||
target = root / default_invoice_folder_name(number, key)
|
||||
if target.exists():
|
||||
raise FileExistsError(f'Rechnungsordner existiert bereits: {target}')
|
||||
inv = Invoice.model_validate({
|
||||
'meta': {'invoice_number': number, 'invoice_date': issued, 'service_date': delivery,
|
||||
'service_period_start': start, 'service_period_end': end,
|
||||
'buyer_reference': buyer_reference, 'order_reference': order_reference,
|
||||
'notes': 'Vielen Dank für Ihren Auftrag.'},
|
||||
'seller': seller_data['seller'], 'buyer': record['buyer'],
|
||||
'payment': {**seller_data['payment'], 'due_date': issued + timedelta(days=due_days),
|
||||
'reference': f'Rechnung {number}'},
|
||||
'items': [{'name': entry, 'quantity': volume, 'unit': unit,
|
||||
'unit_price_net': price, 'tax_rate': tax}],
|
||||
})
|
||||
with tempfile.TemporaryDirectory(prefix='.create-', dir=root) as staging:
|
||||
work = Path(staging) / 'invoice'
|
||||
work.mkdir(mode=0o700)
|
||||
(work / 'invoice.yaml').write_text(yaml.safe_dump(inv.model_dump(mode='json'),
|
||||
allow_unicode=True, sort_keys=False), encoding='utf-8')
|
||||
(work / 'assets').mkdir()
|
||||
logo = presets / 'logo.svg'
|
||||
if not logo.is_file():
|
||||
logo = resources / 'assets/logo.svg'
|
||||
if logo.is_file():
|
||||
shutil.copy2(logo, work / 'assets/logo.svg')
|
||||
work.rename(target)
|
||||
# Retain the reserved number and input on build failures for diagnosis/retry.
|
||||
from .logging_utils import setup_logging
|
||||
logger = setup_logging('WARNING', target / 'out/logs/run.log')
|
||||
progress(f'Rechnungsdaten vollständig: {number} · {inv.buyer.name}')
|
||||
progress('PDF/A und strukturierte Rechnungsdaten werden erzeugt …')
|
||||
try:
|
||||
build = build_in_folder(target, 'out', resources / 'templates/invoice.html.j2', logger, jar, True, True)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f'Erstellung fehlgeschlagen. Entwurf: {target}. Log: {target / "out/logs/run.log"}. {exc}') from exc
|
||||
progress('Rechnung validiert: PDF/A und EN16931-XML technisch gültig.')
|
||||
attachment = target / f'Rechnung_{number}.pdf'
|
||||
shutil.copy2(build.facturx_pdf, attachment)
|
||||
manifest = {str(p.relative_to(target)): hashlib.sha256(p.read_bytes()).hexdigest()
|
||||
for p in [target / 'invoice.yaml', build.facturx_xml, build.facturx_pdf,
|
||||
build.validate_report, attachment]}
|
||||
(target / 'out/manifest.json').write_text(json.dumps(manifest, indent=2) + '\n', encoding='utf-8')
|
||||
totals = compute_totals(inv)
|
||||
progress(f'Rechnung erzeugt · Netto: {money_fmt(totals.net_total)} · USt: {money_fmt(totals.tax_total)} · Brutto: {money_fmt(totals.gross_total)}')
|
||||
return CreatedInvoice(build, attachment, inv)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Prepare a reviewed email draft; never sends mail automatically."""
|
||||
from email.message import EmailMessage
|
||||
from email.policy import SMTP
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import json
|
||||
from .io import load_invoice_yaml
|
||||
|
||||
def prepare_email(invoice_dir: Path, jar: Path, logger, recipient: str | None = None) -> Path:
|
||||
# Invalidate any previous draft before rebuilding.
|
||||
out = invoice_dir / 'out'
|
||||
(out / 'invoice.eml').unlink(missing_ok=True)
|
||||
(out / 'manifest.json').unlink(missing_ok=True)
|
||||
inv = load_invoice_yaml(invoice_dir / 'invoice.yaml')
|
||||
recipient = recipient or inv.buyer.email
|
||||
import re
|
||||
if not recipient or not re.fullmatch(r'[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+', recipient):
|
||||
raise ValueError('Set buyer.email or pass --to with the agreed invoice address')
|
||||
out = invoice_dir / 'out'
|
||||
# Rebuild from current YAML so the draft cannot attach an obsolete invoice.
|
||||
from .pipeline import build_in_folder
|
||||
template = Path(__file__).resolve().parent.parent / 'templates/invoice.html.j2'
|
||||
if not template.is_file():
|
||||
template = Path(__file__).resolve().parent / 'data/templates/invoice.html.j2'
|
||||
result = build_in_folder(invoice_dir, 'out', template, logger, jar, True, True)
|
||||
pdf = result.facturx_pdf
|
||||
msg = EmailMessage(policy=SMTP)
|
||||
msg['From'] = inv.seller.email or ''
|
||||
if not inv.seller.email:
|
||||
raise ValueError('seller.email is required for an email draft')
|
||||
msg['To'] = recipient
|
||||
msg['Subject'] = f'Rechnung {inv.meta.invoice_number} – {inv.seller.name}'
|
||||
msg['X-Unsent'] = '1'
|
||||
msg.set_content(f'Sehr geehrte Damen und Herren,\n\nanbei erhalten Sie unsere E-Rechnung {inv.meta.invoice_number} als ZUGFeRD/Factur-X-PDF mit eingebetteten strukturierten Rechnungsdaten.\n\nVielen Dank für Ihren Auftrag.\n\nFreundliche Grüße\n{inv.seller.name}\n')
|
||||
msg.add_attachment(pdf.read_bytes(), maintype='application', subtype='pdf', filename=f'Rechnung_{_safe_name(inv.meta.invoice_number)}.pdf')
|
||||
target = out / 'invoice.eml'
|
||||
target.write_bytes(msg.as_bytes())
|
||||
manifest = {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in
|
||||
[invoice_dir / 'invoice.yaml', result.facturx_xml, pdf, result.validate_report, target]}
|
||||
(out / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n', encoding='utf-8')
|
||||
return target
|
||||
|
||||
def _safe_name(number: str) -> str:
|
||||
import re
|
||||
return re.sub(r'[^A-Za-z0-9_-]', '_', number)
|
||||
+103
-15
@@ -1,22 +1,110 @@
|
||||
from __future__ import annotations
|
||||
"""UN/CEFACT CII D16B, EN16931: positive, taxable service invoices."""
|
||||
from pathlib import Path
|
||||
from decimal import Decimal
|
||||
from pycheval import EN16931Invoice, Money, generate_xml
|
||||
import xml.etree.ElementTree as ET
|
||||
from .calc import compute_totals, line_net
|
||||
from .models import Invoice
|
||||
from .calc import compute_totals
|
||||
|
||||
NS = {k: f'urn:un:unece:uncefact:data:standard:{v}:100' for k, v in {
|
||||
'rsm': 'CrossIndustryInvoice', 'ram': 'ReusableAggregateBusinessInformationEntity',
|
||||
'udt': 'UnqualifiedDataType'}.items()}
|
||||
for prefix, uri in NS.items():
|
||||
ET.register_namespace(prefix, uri)
|
||||
|
||||
def node(parent, name, value=None, **attrs):
|
||||
prefix, local = name.split(':')
|
||||
el = ET.SubElement(parent, f'{{{NS[prefix]}}}{local}', attrs)
|
||||
if value is not None:
|
||||
el.text = format(value, "f") if isinstance(value, Decimal) else str(value)
|
||||
return el
|
||||
|
||||
def ram(parent, name, value=None, **attrs):
|
||||
return node(parent, 'ram:' + name, value, **attrs)
|
||||
|
||||
def date_node(parent, name, value):
|
||||
node(ram(parent, name), 'udt:DateTimeString', value.strftime('%Y%m%d'), format='102')
|
||||
|
||||
def party(parent, name, data):
|
||||
p = ram(parent, name)
|
||||
ram(p, 'Name', data.name)
|
||||
address = ram(p, 'PostalTradeAddress')
|
||||
for key, value in [('PostcodeCode', data.address.postal_code), ('LineOne', data.address.street),
|
||||
('CityName', data.address.city), ('CountryID', data.address.country)]:
|
||||
ram(address, key, value)
|
||||
if data.email:
|
||||
ram(ram(p, 'URIUniversalCommunication'), 'URIID', data.email, schemeID='EM')
|
||||
for value, scheme in [(data.vat_id, 'VA'), (data.tax_number, 'FC')]:
|
||||
if value:
|
||||
ram(ram(p, 'SpecifiedTaxRegistration'), 'ID', value, schemeID=scheme)
|
||||
|
||||
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)
|
||||
root = ET.Element(f'{{{NS["rsm"]}}}CrossIndustryInvoice')
|
||||
context = node(root, 'rsm:ExchangedDocumentContext')
|
||||
ram(ram(context, 'GuidelineSpecifiedDocumentContextParameter'), 'ID', 'urn:cen.eu:en16931:2017')
|
||||
doc = node(root, 'rsm:ExchangedDocument')
|
||||
ram(doc, 'ID', inv.meta.invoice_number)
|
||||
ram(doc, 'TypeCode', '380')
|
||||
date_node(doc, 'IssueDateTime', inv.meta.invoice_date)
|
||||
if inv.meta.notes:
|
||||
ram(ram(doc, 'IncludedNote'), 'Content', inv.meta.notes)
|
||||
transaction = node(root, 'rsm:SupplyChainTradeTransaction')
|
||||
for index, item in enumerate(inv.items, 1):
|
||||
line = ram(transaction, 'IncludedSupplyChainTradeLineItem')
|
||||
ram(ram(line, 'AssociatedDocumentLineDocument'), 'LineID', index)
|
||||
product = ram(line, 'SpecifiedTradeProduct')
|
||||
ram(product, 'Name', item.name)
|
||||
if item.description:
|
||||
ram(product, 'Description', item.description)
|
||||
agreement = ram(line, 'SpecifiedLineTradeAgreement')
|
||||
ram(ram(agreement, 'NetPriceProductTradePrice'), 'ChargeAmount', item.unit_price_net)
|
||||
ram(ram(line, 'SpecifiedLineTradeDelivery'), 'BilledQuantity', item.quantity, unitCode=item.unit)
|
||||
settlement = ram(line, 'SpecifiedLineTradeSettlement')
|
||||
tax = ram(settlement, 'ApplicableTradeTax')
|
||||
ram(tax, 'TypeCode', 'VAT')
|
||||
ram(tax, 'CategoryCode', 'S')
|
||||
ram(tax, 'RateApplicablePercent', item.tax_rate)
|
||||
ram(ram(settlement, 'SpecifiedTradeSettlementLineMonetarySummation'), 'LineTotalAmount', line_net(item))
|
||||
agreement = ram(transaction, 'ApplicableHeaderTradeAgreement')
|
||||
if inv.meta.buyer_reference:
|
||||
ram(agreement, 'BuyerReference', inv.meta.buyer_reference)
|
||||
party(agreement, 'SellerTradeParty', inv.seller)
|
||||
party(agreement, 'BuyerTradeParty', inv.buyer)
|
||||
if inv.meta.order_reference:
|
||||
ram(ram(agreement, 'BuyerOrderReferencedDocument'), 'IssuerAssignedID', inv.meta.order_reference)
|
||||
delivery = ram(transaction, 'ApplicableHeaderTradeDelivery')
|
||||
date_node(ram(delivery, 'ActualDeliverySupplyChainEvent'), 'OccurrenceDateTime', inv.meta.service_date)
|
||||
settlement = ram(transaction, 'ApplicableHeaderTradeSettlement')
|
||||
ram(settlement, 'PaymentReference', inv.payment.reference)
|
||||
ram(settlement, 'InvoiceCurrencyCode', inv.meta.currency)
|
||||
if inv.payment.iban:
|
||||
means = ram(settlement, 'SpecifiedTradeSettlementPaymentMeans')
|
||||
ram(means, 'TypeCode', '58')
|
||||
ram(ram(means, 'PayeePartyCreditorFinancialAccount'), 'IBANID', inv.payment.iban.replace(' ', ''))
|
||||
if inv.payment.bic:
|
||||
ram(ram(means, 'PayeeSpecifiedCreditorFinancialInstitution'), 'BICID', inv.payment.bic)
|
||||
for rate, base in totals.basis_by_rate.items():
|
||||
tax = ram(settlement, 'ApplicableTradeTax')
|
||||
ram(tax, 'CalculatedAmount', totals.tax_by_rate[rate])
|
||||
ram(tax, 'TypeCode', 'VAT')
|
||||
ram(tax, 'BasisAmount', base)
|
||||
ram(tax, 'CategoryCode', 'S')
|
||||
ram(tax, 'RateApplicablePercent', rate)
|
||||
if inv.meta.service_period_start:
|
||||
period = ram(settlement, 'BillingSpecifiedPeriod')
|
||||
date_node(period, 'StartDateTime', inv.meta.service_period_start)
|
||||
date_node(period, 'EndDateTime', inv.meta.service_period_end)
|
||||
terms = ram(settlement, 'SpecifiedTradePaymentTerms')
|
||||
date_node(terms, 'DueDateDateTime', inv.payment.due_date)
|
||||
summary = ram(settlement, 'SpecifiedTradeSettlementHeaderMonetarySummation')
|
||||
for name, amount in [('LineTotalAmount', totals.net_total), ('ChargeTotalAmount', '0.00'),
|
||||
('AllowanceTotalAmount', '0.00'), ('TaxBasisTotalAmount', totals.net_total)]:
|
||||
ram(summary, name, amount)
|
||||
ram(summary, 'TaxTotalAmount', totals.tax_total, currencyID=inv.meta.currency)
|
||||
ram(summary, 'GrandTotalAmount', totals.gross_total)
|
||||
ram(summary, 'DuePayableAmount', totals.gross_total)
|
||||
ET.indent(root)
|
||||
out_xml.parent.mkdir(parents=True, exist_ok=True)
|
||||
ET.ElementTree(root).write(out_xml, encoding='utf-8', xml_declaration=True)
|
||||
logger.info('Wrote EN16931 XML: %s', out_xml)
|
||||
return out_xml
|
||||
+13
-3
@@ -1,10 +1,11 @@
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from .paths import presets_root
|
||||
from datetime import date, timedelta
|
||||
|
||||
from .presets import load_yaml, deep_merge, load_customer_preset
|
||||
from .invoice_number import suggest_next_invoice_number
|
||||
from .invoice_number import suggest_next_invoice_number, invoice_root_lock
|
||||
from .folder_naming import default_invoice_folder_name
|
||||
|
||||
import yaml
|
||||
@@ -24,6 +25,8 @@ def init_invoice_folder(
|
||||
project: str | None = None,
|
||||
) -> Path:
|
||||
target_dir = target_dir.resolve()
|
||||
if (target_dir / "invoice.yaml").exists() and not force:
|
||||
raise FileExistsError(f"{target_dir / 'invoice.yaml'} already exists")
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create standard subfolders
|
||||
@@ -44,6 +47,8 @@ def init_invoice_folder(
|
||||
|
||||
# Apply seller defaults
|
||||
seller_defaults = load_yaml(repo_root / "presets" / "seller.default.yaml")
|
||||
local = presets_root(repo_root)
|
||||
seller_defaults = deep_merge(seller_defaults, load_yaml(local / "seller.default.yaml"))
|
||||
merged = deep_merge(base_tpl, seller_defaults)
|
||||
|
||||
# Apply customer preset
|
||||
@@ -53,7 +58,7 @@ def init_invoice_folder(
|
||||
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)
|
||||
raise ValueError(f"Unknown customer preset: {customer}")
|
||||
|
||||
# Smart defaults for meta/payment
|
||||
today = date.today()
|
||||
@@ -102,7 +107,7 @@ def init_invoice_folder(
|
||||
logger.info("Initialized invoice folder: %s", target_dir)
|
||||
return target_dir
|
||||
|
||||
def init_invoice_under_root(
|
||||
def _init_invoice_under_root(
|
||||
invoices_root: Path,
|
||||
repo_root: Path,
|
||||
logger,
|
||||
@@ -135,3 +140,8 @@ def init_invoice_under_root(
|
||||
invoices_root=invoices_root,
|
||||
project=project,
|
||||
)
|
||||
|
||||
|
||||
def init_invoice_under_root(invoices_root: Path, **kwargs) -> Path:
|
||||
with invoice_root_lock(invoices_root):
|
||||
return _init_invoice_under_root(invoices_root, **kwargs)
|
||||
@@ -26,3 +26,19 @@ def suggest_next_invoice_number(invoices_root: Path, year: int | None = None, pa
|
||||
if yr == y and seq > max_seq:
|
||||
max_seq = seq
|
||||
return f"{y}-{max_seq+1:04d}"
|
||||
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
@contextmanager
|
||||
def invoice_root_lock(root: Path):
|
||||
"""Serialize local number allocation and folder creation on Linux."""
|
||||
import fcntl
|
||||
root = root.expanduser().resolve()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
with (root / '.jr-einvoice.lock').open('a') as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(lock, fcntl.LOCK_UN)
|
||||
@@ -7,8 +7,9 @@ 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
|
||||
for handler in list(logger.handlers):
|
||||
logger.removeHandler(handler)
|
||||
handler.close()
|
||||
|
||||
fmt = logging.Formatter(
|
||||
fmt="%(asctime)s.%(msecs)03d %(levelname)s [%(name)s] %(message)s",
|
||||
|
||||
+61
-19
@@ -1,56 +1,98 @@
|
||||
from __future__ import annotations
|
||||
from datetime import date
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from decimal import Decimal
|
||||
from pydantic import BaseModel, Field, ConfigDict, model_validator, 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 StrictModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True, allow_inf_nan=False)
|
||||
|
||||
class Party(BaseModel):
|
||||
name: str
|
||||
class Address(StrictModel):
|
||||
street: str = Field(min_length=1)
|
||||
postal_code: str = Field(min_length=1)
|
||||
city: str = Field(min_length=1)
|
||||
country: str = Field(pattern=r"^[A-Z]{2}$")
|
||||
|
||||
class Party(StrictModel):
|
||||
name: str = Field(min_length=1)
|
||||
vat_id: Optional[str] = None
|
||||
tax_number: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
address: Address
|
||||
|
||||
@field_validator("vat_id", "tax_number")
|
||||
@classmethod
|
||||
def nonempty_tax_id(cls, value):
|
||||
if value is not None and not value:
|
||||
raise ValueError("Tax identifiers must not be empty")
|
||||
return value
|
||||
|
||||
@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]:
|
||||
import re
|
||||
if not re.fullmatch(r"[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+", v):
|
||||
raise ValueError("email does not look valid")
|
||||
return v
|
||||
|
||||
class Meta(BaseModel):
|
||||
class Meta(StrictModel):
|
||||
invoice_number: str = Field(min_length=1)
|
||||
invoice_date: date
|
||||
service_date: date
|
||||
currency: str = Field(min_length=3, max_length=3, default="EUR")
|
||||
currency: str = Field(pattern=r"^EUR$", default="EUR")
|
||||
service_period_start: Optional[date] = None
|
||||
service_period_end: Optional[date] = None
|
||||
buyer_reference: Optional[str] = None
|
||||
order_reference: Optional[str] = None
|
||||
language: Optional[str] = "de"
|
||||
notes: Optional[str] = None
|
||||
|
||||
class Payment(BaseModel):
|
||||
class Payment(StrictModel):
|
||||
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):
|
||||
@field_validator("iban")
|
||||
@classmethod
|
||||
def valid_iban(cls, value):
|
||||
if value is None:
|
||||
return value
|
||||
import re
|
||||
value = value.replace(" ", "").upper()
|
||||
if not re.fullmatch(r"[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}", value):
|
||||
raise ValueError("Invalid IBAN format")
|
||||
rearranged = value[4:] + value[:4]
|
||||
digits = ''.join(str(ord(c) - 55) if c.isalpha() else c for c in rearranged)
|
||||
if int(digits) % 97 != 1:
|
||||
raise ValueError("Invalid IBAN checksum")
|
||||
return value
|
||||
|
||||
class Item(StrictModel):
|
||||
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)
|
||||
quantity: Decimal = Field(gt=0)
|
||||
unit: str = Field(pattern=r"^[A-Z0-9]{2,3}$") # e.g. HUR, C62
|
||||
unit_price_net: Decimal = Field(ge=0)
|
||||
tax_rate: Decimal = Field(gt=0, le=100)
|
||||
|
||||
class Invoice(BaseModel):
|
||||
class Invoice(StrictModel):
|
||||
meta: Meta
|
||||
seller: Party
|
||||
buyer: Party
|
||||
payment: Payment
|
||||
items: List[Item]
|
||||
items: List[Item] = Field(min_length=1)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_invoice(self):
|
||||
if not (self.seller.vat_id or self.seller.tax_number):
|
||||
raise ValueError("Seller requires vat_id or tax_number")
|
||||
if self.payment.due_date < self.meta.invoice_date:
|
||||
raise ValueError("Payment due_date precedes invoice_date")
|
||||
start, end = self.meta.service_period_start, self.meta.service_period_end
|
||||
if bool(start) != bool(end) or (start and end and start > end):
|
||||
raise ValueError("Service period requires ordered start and end")
|
||||
return self
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Fast monthly service billing from existing customer presets."""
|
||||
import calendar
|
||||
import tempfile
|
||||
from .invoice_number import invoice_root_lock
|
||||
import re
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from .init import init_invoice_folder
|
||||
from .presets import load_yaml
|
||||
from .models import Invoice
|
||||
from .init import _dump_yaml
|
||||
from .folder_naming import default_invoice_folder_name
|
||||
|
||||
MONTHS = ('Januar Februar März April Mai Juni Juli August September Oktober November Dezember').split()
|
||||
|
||||
def _monthly_invoice(root: Path, repo_root: Path, logger, customer: str, month: str,
|
||||
hours: Decimal, number: str, issued: date | None = None,
|
||||
rate: Decimal | None = None, due_days: int = 30) -> Path:
|
||||
if not re.fullmatch(r'\d{4}-\d{2}', month):
|
||||
raise ValueError('Month must be YYYY-MM')
|
||||
year, m = map(int, month.split('-'))
|
||||
start, end = date(year, m, 1), date(year, m, calendar.monthrange(year, m)[1])
|
||||
if not hours.is_finite() or hours <= 0 or due_days < 0:
|
||||
raise ValueError('Hours must be positive and due_days nonnegative')
|
||||
target = root.expanduser().resolve() / default_invoice_folder_name(number, customer)
|
||||
# Explicit number required: existing PDF invoices may live outside this root.
|
||||
if target.exists():
|
||||
raise FileExistsError(f'Invoice folder exists: {target}')
|
||||
for existing in root.expanduser().glob('*/invoice.yaml'):
|
||||
if str(load_yaml(existing).get('meta', {}).get('invoice_number', '')) == number:
|
||||
raise ValueError(f'Invoice number already used: {number}')
|
||||
with tempfile.TemporaryDirectory(prefix='.draft-', dir=root.expanduser().resolve()) as staging:
|
||||
work = Path(staging) / 'invoice'
|
||||
init_invoice_folder(work, repo_root, logger, customer=customer, invoice_number=number, due_days=due_days)
|
||||
path = work / 'invoice.yaml'
|
||||
data = load_yaml(path)
|
||||
if len(data['items']) != 1 or data['items'][0]['unit'] != 'HUR':
|
||||
raise ValueError('Monthly billing requires a preset with one HUR line')
|
||||
issued = issued or date.today()
|
||||
data['meta'].update(invoice_date=str(issued), service_date=str(end),
|
||||
service_period_start=str(start), service_period_end=str(end))
|
||||
data['payment']['due_date'] = str(issued + timedelta(days=due_days))
|
||||
item = data['items'][0]
|
||||
item['quantity'] = str(hours)
|
||||
item['name'] = f'Geleistete Stunden {MONTHS[m-1]} {year}'
|
||||
if rate is not None:
|
||||
item['unit_price_net'] = str(rate)
|
||||
Invoice.model_validate(data)
|
||||
_dump_yaml(data, path)
|
||||
work.rename(target)
|
||||
return target
|
||||
|
||||
|
||||
def monthly_invoice(root: Path, *args, **kwargs) -> Path:
|
||||
with invoice_root_lock(root):
|
||||
return _monthly_invoice(root, *args, **kwargs)
|
||||
+19
-5
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
class MustangError(RuntimeError):
|
||||
pass
|
||||
@@ -10,7 +11,7 @@ def _run(jar: Path, args: list[str], logger) -> subprocess.CompletedProcess[str]
|
||||
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)
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
|
||||
logger.debug("Mustang stdout:\n%s", proc.stdout.strip())
|
||||
logger.debug("Mustang stderr:\n%s", proc.stderr.strip())
|
||||
return proc
|
||||
@@ -22,10 +23,13 @@ def combine_facturx(jar: Path, source_pdf: Path, source_xml: Path, out_pdf: Path
|
||||
"--source", str(source_pdf),
|
||||
"--source-xml", str(source_xml),
|
||||
"--out", str(out_pdf),
|
||||
"--profile", profile
|
||||
"--format", "fx", "--version", "1",
|
||||
"--profile", profile, "--no-additional-attachments"
|
||||
], logger)
|
||||
if proc.returncode != 0:
|
||||
raise MustangError(f"Mustang combine failed (rc={proc.returncode}). See logs for details.")
|
||||
if not out_pdf.is_file() or out_pdf.stat().st_size == 0:
|
||||
raise MustangError("Mustang produced no PDF")
|
||||
return out_pdf
|
||||
|
||||
def validate(jar: Path, source: Path, out_report: Path, logger, no_notices: bool = True) -> Path:
|
||||
@@ -35,8 +39,18 @@ def validate(jar: Path, source: Path, out_report: Path, logger, no_notices: bool
|
||||
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>"
|
||||
# Persist reports even on failure; a zero exit code alone is insufficient.
|
||||
content = proc.stdout.strip()
|
||||
out_report.write_text(content, encoding="utf-8")
|
||||
try:
|
||||
root = ET.fromstring(content)
|
||||
summaries = root.findall(".//summary")
|
||||
valid = (root.tag == "validation"
|
||||
and root.find("xml/summary") is not None
|
||||
and root.find("summary") is not None
|
||||
and all(s.get("status") == "valid" for s in summaries))
|
||||
except ET.ParseError:
|
||||
valid = False
|
||||
if proc.returncode != 0 or not valid:
|
||||
raise MustangError(f"Validation failed; see {out_report} and logs")
|
||||
return out_report
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Bundled resources and writable, private user data locations."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def resource_root() -> Path:
|
||||
source = Path(__file__).resolve().parent.parent
|
||||
return source if (source / 'templates/invoice.html.j2').is_file() else Path(__file__).resolve().parent / 'data'
|
||||
|
||||
|
||||
def private_root(resources: Path | None = None) -> Path:
|
||||
root = resources or resource_root()
|
||||
default = root / 'private' if root.name != 'data' else Path.home() / '.local/share/jr-einvoice'
|
||||
return Path(os.environ.get('JR_EINVOICE_HOME', str(default))).expanduser().resolve()
|
||||
|
||||
|
||||
def presets_root(resources: Path | None = None) -> Path:
|
||||
return Path(os.environ.get('JR_EINVOICE_PRESETS', str(private_root(resources) / 'presets'))).expanduser().resolve()
|
||||
+15
-1
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
@@ -37,6 +39,12 @@ def build_in_folder(
|
||||
out_dir = invoice_dir / out_subdir
|
||||
(out_dir / "logs").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
attachment = invoice_dir / ('Rechnung_' + re.sub(r'[^A-Za-z0-9_-]', '_', inv.meta.invoice_number) + '.pdf')
|
||||
# A new build invalidates old delivery drafts and prior validation results.
|
||||
for stale in [attachment, out_dir / "invoice.eml", out_dir / "manifest.json",
|
||||
out_dir / "invoice.facturx.pdf", out_dir / "validation/validate_report.xml"]:
|
||||
stale.unlink(missing_ok=True)
|
||||
|
||||
logger.info("Loaded invoice: %s (folder=%s)", inv.meta.invoice_number, invoice_dir)
|
||||
logger.info("Artifacts directory: %s", out_dir)
|
||||
|
||||
@@ -62,6 +70,12 @@ def build_in_folder(
|
||||
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)
|
||||
try:
|
||||
result.validate_report = validate(mustang_jar, result.facturx_pdf, report, logger)
|
||||
except Exception:
|
||||
# Failed output must not be mistaken for a ready-to-send invoice.
|
||||
result.facturx_pdf.unlink(missing_ok=True)
|
||||
raise
|
||||
shutil.copy2(result.facturx_pdf, attachment)
|
||||
|
||||
return result
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
from .paths import presets_root
|
||||
import re
|
||||
|
||||
def load_yaml(path: Path) -> dict:
|
||||
if not path.exists():
|
||||
@@ -18,6 +20,11 @@ def deep_merge(a: dict, b: dict) -> dict:
|
||||
|
||||
def load_customer_preset(repo_root: Path, customer_key: str) -> dict:
|
||||
key = customer_key.strip().lower()
|
||||
if not re.fullmatch(r"[a-z0-9_-]+", key):
|
||||
raise ValueError("Customer key must contain only letters, digits, _ or -")
|
||||
local = presets_root(repo_root)
|
||||
if (local / "customers" / f"{key}.yaml").is_file():
|
||||
return load_yaml(local / "customers" / f"{key}.yaml")
|
||||
# Prefer per-customer file
|
||||
file_preset = repo_root / "presets" / "customers" / f"{key}.yaml"
|
||||
if file_preset.exists():
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Friendly CLI: einvoice --create --Customer ... --Entry ... --Volume ... --Price ..."""
|
||||
import argparse
|
||||
from datetime import date
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from .create import create_invoice, decimal_input, find_customer, save_customer
|
||||
from .logging_utils import setup_logging
|
||||
from .paths import private_root, presets_root, resource_root
|
||||
|
||||
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(description='Mit einem Aufruf eine validierte ZUGFeRD-Rechnung als PDF erzeugen.',
|
||||
allow_abbrev=False)
|
||||
mode = p.add_mutually_exclusive_group(required=True)
|
||||
mode.add_argument('--create', action='store_true', help='Rechnung anlegen, PDF erzeugen und validieren')
|
||||
mode.add_argument('--customers', action='store_true', help='Gespeicherte Kunden anzeigen')
|
||||
p.add_argument('--Customer', '--customer', dest='customer', help='Gespeicherter Kundenname oder neuer Kunde')
|
||||
p.add_argument('--Entry', '--entry', dest='entry', help='Leistungsbeschreibung')
|
||||
p.add_argument('--Volume', '--volume', dest='volume', type=decimal_input, help='Menge, z. B. 120 oder 72,5')
|
||||
p.add_argument('--Price', '--price', dest='price', type=decimal_input, help='Nettopreis je Einheit, z. B. "85,00 €"')
|
||||
p.add_argument('--Unit', '--unit', dest='unit', default='HUR', help='Einheit: HUR=Stunden (Standard), C62=Stück')
|
||||
p.add_argument('--Tax', '--tax', dest='tax', type=decimal_input, default=decimal_input('19'), help='USt in Prozent, Standard 19')
|
||||
p.add_argument('--Number', '--number', dest='number', help='Freie Nummer; Standard automatisch JR-JJJJ-NNNN')
|
||||
p.add_argument('--Date', '--date', dest='issued', type=date.fromisoformat, help='Rechnungsdatum YYYY-MM-DD, Standard heute')
|
||||
p.add_argument('--Month', '--month', dest='month', help='Leistungsmonat YYYY-MM; auch aus deutschem Monatsnamen in Entry erkannt')
|
||||
p.add_argument('--ServiceDate', '--service-date', dest='service_date', type=date.fromisoformat, help='Einzelnes Leistungsdatum YYYY-MM-DD')
|
||||
p.add_argument('--DueDays', '--due-days', dest='due_days', type=int, default=30, help='Zahlungsziel in Tagen, Standard 30')
|
||||
p.add_argument('--BuyerReference', '--buyer-reference', dest='buyer_reference', help='Kundenreferenz')
|
||||
p.add_argument('--OrderReference', '--order-reference', dest='order_reference', help='Bestellnummer')
|
||||
p.add_argument('--Street', '--street', dest='street', help='Straße/Hausnummer bei neuem Kunden')
|
||||
p.add_argument('--PostalCode', '--postal-code', dest='postal_code', help='Postleitzahl bei neuem Kunden')
|
||||
p.add_argument('--City', '--city', dest='city', help='Ort bei neuem Kunden')
|
||||
p.add_argument('--Country', '--country', dest='country', default='DE', help='Ländercode, Standard DE')
|
||||
p.add_argument('--Email', '--email', dest='email', help='Rechnungsadresse bei neuem Kunden (optional)')
|
||||
p.add_argument('--VatId', '--vat-id', dest='vat_id', help='USt-ID bei neuem Kunden (optional)')
|
||||
p.add_argument('--root', type=Path, default=private_root() / 'invoices', help='Stammordner für Rechnungen')
|
||||
p.add_argument('--mustang-jar', type=Path, help='Abweichender Mustang-CLI-Pfad')
|
||||
p.add_argument('--json', action='store_true', help='Ergebnisse als JSON; keine interaktiven Fragen')
|
||||
p.add_argument('--non-interactive', action='store_true', help='Fehlende Daten als Fehler melden statt fragen')
|
||||
return p
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
p = parser()
|
||||
args = p.parse_args(argv)
|
||||
presets = presets_root()
|
||||
try:
|
||||
if args.customers:
|
||||
for path in sorted((presets / 'customers').glob('*.yaml')):
|
||||
from .presets import load_yaml
|
||||
print(f'{path.stem}: {load_yaml(path).get("buyer", {}).get("name", "?")}')
|
||||
return
|
||||
missing = [flag for flag in ('customer', 'entry', 'volume', 'price') if getattr(args, flag) is None]
|
||||
if missing:
|
||||
p.error('Für --create fehlen: ' + ', '.join('--' + name.title() for name in missing))
|
||||
if args.volume <= 0 or args.price < 0 or not (0 < args.tax <= 100) or args.due_days < 0:
|
||||
p.error('Menge muss positiv sein; Preis/Zahlungsziel nicht negativ; Steuer > 0 und <= 100')
|
||||
if find_customer(presets, args.customer) is None:
|
||||
fields = {'street': args.street, 'postal_code': args.postal_code, 'city': args.city}
|
||||
labels = {'street': 'Straße und Hausnummer', 'postal_code': 'Postleitzahl', 'city': 'Ort'}
|
||||
absent = [name for name, value in fields.items() if not value]
|
||||
if absent and (args.non_interactive or args.json or not sys.stdin.isatty()):
|
||||
raise ValueError('Neuer Kunde: bitte ' + ', '.join('--' + {'street':'Street','postal_code':'PostalCode','city':'City'}[name] for name in absent) + ' angeben')
|
||||
for name in absent:
|
||||
fields[name] = input(f'Neuer Kunde {args.customer} – {labels[name]}: ').strip()
|
||||
save_customer(presets, args.customer, {'name': args.customer, 'email': args.email,
|
||||
'vat_id': args.vat_id, 'address': {**fields, 'country': args.country.upper()}})
|
||||
if not args.json:
|
||||
print(f'Kunde gespeichert: {args.customer}', flush=True)
|
||||
else:
|
||||
if any([args.street, args.postal_code, args.city, args.email, args.vat_id]) or args.country != 'DE':
|
||||
raise ValueError('Kunde bereits gespeichert. Adressoptionen gelten nur bei Neuanlage; Stammdaten separat bearbeiten')
|
||||
from .cli import _default_mustang_jar
|
||||
result = create_invoice(root=args.root, resources=resource_root(), presets=presets,
|
||||
customer=args.customer, entry=args.entry, volume=args.volume,
|
||||
price=args.price, jar=args.mustang_jar or _default_mustang_jar(),
|
||||
logger=setup_logging('WARNING', None), number=args.number,
|
||||
issued=args.issued, month=args.month, service_date=args.service_date,
|
||||
unit=args.unit, tax=args.tax, due_days=args.due_days,
|
||||
buyer_reference=args.buyer_reference, order_reference=args.order_reference,
|
||||
progress=(lambda message: None) if args.json else lambda message: print(message, flush=True))
|
||||
if args.json:
|
||||
print(json.dumps({'invoice_number': result.invoice.meta.invoice_number,
|
||||
'invoice_dir': str(result.build.invoice_dir), 'pdf': str(result.attachment),
|
||||
'validation_report': str(result.build.validate_report),
|
||||
'log': str(result.build.out_dir / 'logs/run.log'),
|
||||
'technical_validation': 'valid'}, ensure_ascii=False))
|
||||
else:
|
||||
inv = result.invoice
|
||||
print(f'Leistung: {inv.meta.service_period_start or inv.meta.service_date}' +
|
||||
(f' bis {inv.meta.service_period_end}' if inv.meta.service_period_end else ''))
|
||||
print(f'PDF zum Anhängen an deine E-Mail:\n{result.attachment}')
|
||||
print(f'Prüfbericht: {result.build.validate_report}')
|
||||
print('Vor Versand Leistungsdaten, Betrag und Empfänger prüfen.')
|
||||
except Exception as exc:
|
||||
print(f'FEHLER: {exc}', file=sys.stderr)
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -6,16 +6,20 @@ import shutil
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
from .models import Invoice
|
||||
from .calc import compute_totals
|
||||
from .calc import compute_totals, line_net
|
||||
from .types import RenderResult
|
||||
from .utils import money_fmt
|
||||
from .utils import money_fmt, number_fmt
|
||||
|
||||
def render_html(inv: Invoice, template_path: Path) -> str:
|
||||
env = Environment(
|
||||
loader=FileSystemLoader(str(template_path.parent)),
|
||||
autoescape=select_autoescape(["html", "xml"]),
|
||||
autoescape=select_autoescape(["html", "xml", "j2"]),
|
||||
)
|
||||
env.globals["money"] = lambda x: money_fmt(x, inv.meta.currency)
|
||||
env.globals["number"] = number_fmt
|
||||
env.filters["de_date"] = lambda d: d.strftime("%d.%m.%Y") if d else ""
|
||||
env.globals["unit_label"] = lambda unit: {"HUR": "Std.", "C62": "Stück", "DAY": "Tage"}.get(unit, unit)
|
||||
env.globals["price"] = lambda x: f"{number_fmt(x)} {inv.meta.currency}"
|
||||
tpl = env.get_template(template_path.name)
|
||||
|
||||
totals = compute_totals(inv)
|
||||
@@ -23,7 +27,7 @@ def render_html(inv: Invoice, template_path: Path) -> str:
|
||||
items = []
|
||||
for it in inv.items:
|
||||
d = it.model_dump()
|
||||
d["line_total_net"] = float(it.quantity) * float(it.unit_price_net)
|
||||
d["line_total_net"] = line_net(it)
|
||||
items.append(type("Obj", (), d))
|
||||
|
||||
ctx = {
|
||||
@@ -50,7 +54,7 @@ def render_pdf(html: str, out_pdf: Path, logger, prefer_weasyprint: bool = True,
|
||||
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))
|
||||
HTML(string=html, base_url=base_url).write_pdf(str(out_pdf), pdf_variant="pdf/a-3b")
|
||||
return RenderResult(ok=True, backend="weasyprint", path=out_pdf)
|
||||
except Exception as e:
|
||||
logger.exception("WeasyPrint rendering failed; trying wkhtmltopdf if available: %s", e)
|
||||
|
||||
@@ -4,7 +4,7 @@ import urllib.request
|
||||
import hashlib
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
MAVEN_BASE = "https://repo1.maven.org/maven2/org/mustangproject/Mustang-CLI"
|
||||
MAVEN_BASE = "https://repo.maven.apache.org/maven2/org/mustangproject/Mustang-CLI"
|
||||
VERSION_FILE = "VERSION.txt"
|
||||
|
||||
class SetupError(RuntimeError):
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
def number_fmt(value, precision: int = 2) -> str:
|
||||
value = Decimal(str(value))
|
||||
precision = max(precision, -value.normalize().as_tuple().exponent)
|
||||
return f"{value:,.{precision}f}".replace(",", "X").replace(".", ",").replace("X", ".")
|
||||
|
||||
|
||||
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", ".")
|
||||
return f"{Decimal(str(value)):,.2f} {currency}".replace(",", "X").replace(".", ",").replace("X", ".")
|
||||
Reference in new issue
Block a user