feat: create validated ZUGFeRD invoices in one console command

This commit is contained in:
admin committed 2026-10-16 09:29:08 +02:00
1 parent e0c15fc7f2
commit a643cf5227
30 files changed
+1509 -207

No files matched your search

+21 -17
View File
@@ -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
View File
@@ -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)
+185
View File
@@ -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)
+45
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+16
View File
@@ -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)
+3 -2
View File
@@ -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
View File
@@ -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
+57
View File
@@ -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
View File
@@ -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
+18
View File
@@ -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
View File
@@ -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
+7
View File
@@ -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():
+103
View File
@@ -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()
+9 -5
View File
@@ -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)
+1 -1
View File
@@ -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):
+9 -7
View File
@@ -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", ".")