Files
leocrm/app/plugins/builtins/report_generator/einvoice.py
T

340 lines
14 KiB
Python
Raw Normal View History

"""E-Invoice format layer — EN16931 / XRechnung CII XML generation (Phase L5).
This module is deliberately a pure FORMAT layer: it turns validated invoice
data (plain dicts) into Cross Industry Invoice XML. It knows nothing about
how invoices are stored — the future sales module will own the entities
and dock via the ``einvoice_data()`` contract hook
(``resolve_einvoice_data``).
Profiles:
- ``en16931``: GuidelineID ``urn:cen.eu:en16931:2017``
- ``xrechnung``: ``...#compliant#urn:xoev-de:kosit:standard:xrechnung_3.0``
All monetary math uses ``Decimal`` quantized to 2 places (commercial
rounding) so header sums are consistent with line tax calculations.
XML escaping is delegated to ElementTree — no manual string concatenation.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from datetime import date
from decimal import ROUND_HALF_UP, Decimal
from xml.etree import ElementTree as ET
# ─── CII namespaces (XRechnung 3.0 / D16B) ──────────────────────────────────
NS_RSM = "urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
NS_RAM = "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"
NS_UDT = "urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100"
GUIDELINE_EN16931 = "urn:cen.eu:en16931:2017"
GUIDELINE_XRECHNUNG = (
"urn:cen.eu:en16931:2017#compliant#urn:xoev-de:kosit:standard:xrechnung_3.0"
)
_CENT = Decimal("0.01")
def _money(value) -> Decimal:
"""Quantize to 2 decimal places (commercial rounding, BR-CL-16)."""
return Decimal(str(value)).quantize(_CENT, rounding=ROUND_HALF_UP)
def _fmt_date(value: str) -> str:
"""ISO date (YYYY-MM-DD) → CII format 102 (YYYYMMDD). Raises ValueError."""
parsed = date.fromisoformat(str(value).strip())
return parsed.strftime("%Y%m%d")
# ─── Validation ──────────────────────────────────────────────────────────────
@dataclass
class EInvoiceValidationError(ValueError):
"""Raised when mandatory EN16931 fields are missing or invalid.
``missing`` carries human-readable entries including BT/BG field codes
so the API can surface exactly which business terms fail.
"""
missing: list[str] = field(default_factory=list)
def _non_empty(data: dict, key: str) -> str | None:
value = data.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return None
def validate_einvoice_data(data: dict) -> None:
"""Validate mandatory EN16931 business terms (subset enforced today).
Raises EInvoiceValidationError with the collected BT/BG entries.
"""
if not isinstance(data, dict):
raise EInvoiceValidationError(missing=["payload: Objekt erwartet"])
missing: list[str] = []
if not _non_empty(data, "invoice_number"):
missing.append("BT-1: Rechnungsnummer fehlt")
if not _non_empty(data, "issue_date"):
missing.append("BT-2: Rechnungsdatum fehlt")
else:
try:
_fmt_date(data["issue_date"])
except ValueError:
missing.append("BT-2: Rechnungsdatum muss YYYY-MM-DD sein")
if not _non_empty(data, "type_code"):
missing.append("BT-3: Rechnungsart fehlt")
if not _non_empty(data, "currency"):
missing.append("BT-5: Währung fehlt")
# Seller: name + at least one tax registration (BT-31 USt-IdNr or
# BT-32 Steuernummer — BR-DE-16 for German invoices).
if not _non_empty(data, "seller_name"):
missing.append("BT-27: Verkäufername fehlt")
seller_vat = _non_empty(data, "seller_vat_id")
seller_tax = _non_empty(data, "seller_tax_id")
if not seller_vat and not seller_tax:
missing.append("BT-31: USt-IdNr. oder BT-32: Steuernummer des Verkäufers fehlt")
if not _non_empty(data, "buyer_name"):
missing.append("BT-10: Empfängername fehlt")
if data.get("due_date"):
try:
_fmt_date(str(data["due_date"]))
except ValueError:
missing.append("BT-9: Fälligkeitsdatum muss YYYY-MM-DD sein")
lines = data.get("line_items")
if not isinstance(lines, list) or len(lines) == 0:
missing.append("BG-25: mindestens eine Rechnungsposition erforderlich")
else:
for i, line in enumerate(lines, start=1):
if not isinstance(line, dict):
missing.append(f"Position {i}: Objekt erwartet")
continue
if not _non_empty(line, "name"):
missing.append(f"BT-126: Positionsname fehlt (Position {i})")
try:
Decimal(str(line.get("unit_net_price", "0")))
except Exception: # noqa: BLE001
missing.append(f"BT-146: Einzelpreis ungültig (Position {i})")
if missing:
raise EInvoiceValidationError(missing=missing)
# ─── XML rendering ───────────────────────────────────────────────────────────
def _sub(parent: ET.Element, tag: str, text: str | None = None, **attrib) -> ET.Element:
el = ET.SubElement(parent, f"{{{NS_RAM}}}{tag}", {k: str(v) for k, v in attrib.items()})
if text is not None:
el.text = str(text)
return el
def _date_el(parent: ET.Element, tag: str, iso_date: str) -> None:
wrapper = _sub(parent, tag)
dt = ET.SubElement(wrapper, f"{{{NS_UDT}}}DateTimeString")
dt.set("format", "102")
dt.text = _fmt_date(iso_date)
def _address(parent: ET.Element, addr: dict | None) -> None:
addr = addr or {}
postal = _sub(parent, "PostalTradeAddress")
if addr.get("street"):
_sub(postal, "LineOne", str(addr["street"]))
if addr.get("postal_code"):
_sub(postal, "PostcodeCode", str(addr["postal_code"]))
if addr.get("city"):
_sub(postal, "CityName", str(addr["city"]))
_sub(postal, "CountryID", str(addr.get("country") or "DE"))
def _line_sums(data: dict) -> tuple[list[dict], Decimal, dict[str, list[Decimal]]]:
"""Compute per-line and total sums.
Returns (line_amounts, line_total, taxes) where line_amounts[i] is the
net amount of line i (supplied ``line_net_amount`` wins over
quantity × unit price — discounts already applied), and taxes maps
vat_rate → [basis, tax_amount] aggregated for the header breakdown.
"""
line_amounts: list[Decimal] = []
line_total = Decimal("0.00")
taxes: dict[str, list[Decimal]] = {}
for line in data.get("line_items") or []:
qty = Decimal(str(line.get("quantity", 1)))
price = _money(line.get("unit_net_price", 0))
if line.get("line_net_amount") is not None:
net = _money(line["line_net_amount"])
else:
net = _money(qty * price)
line_amounts.append(net)
line_total = _money(line_total + net)
rate = str(line.get("vat_rate", 0))
basis, tax = taxes.get(rate, [Decimal("0.00"), Decimal("0.00")])
tax_amount = _money(net * Decimal(rate) / Decimal(100))
taxes[rate] = [_money(basis + net), _money(tax + tax_amount)]
return line_amounts, _money(line_total), taxes
def render_einvoice_xml(data: dict) -> str:
"""Render validated invoice data to CII XML (UTF-8, declaration header)."""
validate_einvoice_data(data)
profile = str(data.get("profile") or "en16931").lower()
guideline = GUIDELINE_XRECHNUNG if profile == "xrechnung" else GUIDELINE_EN16931
ET.register_namespace("rsm", NS_RSM)
ET.register_namespace("ram", NS_RAM)
ET.register_namespace("udt", NS_UDT)
root = ET.Element(f"{{{NS_RSM}}}CrossIndustryInvoice")
# ── ExchangedDocumentContext (BT-24) ──
ctx = ET.SubElement(root, f"{{{NS_RSM}}}ExchangedDocumentContext")
guideline_param = _sub(ctx, "GuidelineSpecifiedDocumentContextParameter")
_sub(guideline_param, "ID", guideline)
# ── ExchangedDocument (BT-1..BT-22) ──
doc = ET.SubElement(root, f"{{{NS_RSM}}}ExchangedDocument")
_sub(doc, "ID", data["invoice_number"])
_sub(doc, "TypeCode", data["type_code"])
_date_el(doc, "IssueDateTime", data["issue_date"])
if data.get("note"):
note = _sub(doc, "IncludedNote")
_sub(note, "Content", str(data["note"]))
# ── SupplyChainTradeTransaction ──
txn = ET.SubElement(root, f"{{{NS_RSM}}}SupplyChainTradeTransaction")
line_amounts, line_total, taxes = _line_sums(data)
for idx, (line, net) in enumerate(zip(data["line_items"], line_amounts, strict=True), start=1):
item = ET.SubElement(txn, f"{{{NS_RSM}}}IncludedSupplyChainTradeLineItem")
line_doc = _sub(item, "AssociatedDocumentLineDocument")
_sub(line_doc, "LineID", str(idx))
product = _sub(item, "SpecifiedTradeProduct")
_sub(product, "Name", line["name"])
agreement = _sub(item, "SpecifiedLineTradeAgreement")
net_price = _sub(agreement, "NetPriceProductTradePrice")
_sub(net_price, "ChargeAmount", _money(line.get("unit_net_price", 0)))
delivery = _sub(item, "SpecifiedLineTradeDelivery")
_sub(delivery, "BilledQuantity", Decimal(str(line.get("quantity", 1))), unitCode=line.get("unit") or "HUR")
settlement = _sub(item, "SpecifiedLineTradeSettlement")
line_tax = _sub(settlement, "ApplicableTradeTax")
_sub(line_tax, "TypeCode", "VAT")
rate = Decimal(str(line.get("vat_rate", 0)))
_sub(line_tax, "RateApplicablePercent", rate)
_sub(line_tax, "BasisAmount", net)
_sub(line_tax, "CalculatedAmount", _money(net * rate / Decimal(100)))
line_sum = _sub(settlement, "SpecifiedTradeSettlementLineMonetarySummation")
_sub(line_sum, "LineTotalAmount", net)
# ── ApplicableHeaderTradeAgreement ──
agreement_h = ET.SubElement(txn, f"{{{NS_RAM}}}ApplicableHeaderTradeAgreement")
if data.get("buyer_reference"):
_sub(agreement_h, "BuyerReference", str(data["buyer_reference"]))
seller = _sub(agreement_h, "SellerTradeParty")
_sub(seller, "Name", data["seller_name"])
_address(seller, data.get("seller_address"))
tax_reg = _sub(seller, "SpecifiedTaxRegistration")
if data.get("seller_vat_id"):
_sub(tax_reg, "ID", str(data["seller_vat_id"]), schemeID="VA")
elif data.get("seller_tax_id"):
_sub(tax_reg, "ID", str(data["seller_tax_id"]), schemeID="FC")
buyer = _sub(agreement_h, "BuyerTradeParty")
_sub(buyer, "Name", data["buyer_name"])
_address(buyer, data.get("buyer_address"))
# ── ApplicableHeaderTradeDelivery (BT-72) ──
delivery_h = ET.SubElement(txn, f"{{{NS_RAM}}}ApplicableHeaderTradeDelivery")
event = _sub(delivery_h, "ActualDeliverySupplyChainEvent")
_date_el(event, "OccurrenceDateTime", data.get("delivery_date") or data["issue_date"])
# ── ApplicableHeaderTradeSettlement ──
settlement_h = ET.SubElement(txn, f"{{{NS_RAM}}}ApplicableHeaderTradeSettlement")
currency = str(data["currency"])
_sub(settlement_h, "InvoiceCurrencyCode", currency)
if data.get("payment_means_code"):
means = _sub(settlement_h, "SpecifiedTradeSettlementPaymentMeans")
_sub(means, "TypeCode", str(data["payment_means_code"]))
# header tax breakdown per VAT rate (BG-23)
tax_total = Decimal("0.00")
for rate in sorted(taxes, key=Decimal):
basis, amount = taxes[rate]
header_tax = _sub(settlement_h, "ApplicableTradeTax")
_sub(header_tax, "TypeCode", "VAT")
_sub(header_tax, "BasisAmount", basis)
_sub(header_tax, "CalculatedAmount", amount)
_sub(header_tax, "RateApplicablePercent", Decimal(rate))
tax_total = _money(tax_total + amount)
grand_total = _money(line_total + tax_total)
terms = _sub(settlement_h, "SpecifiedTradePaymentTerms")
if data.get("payment_terms_text"):
_sub(terms, "Description", str(data["payment_terms_text"]))
if data.get("due_date"):
_date_el(terms, "DueDateDateTime", str(data["due_date"]))
sums = _sub(settlement_h, "SpecifiedTradeSettlementHeaderMonetarySummation")
_sub(sums, "LineTotalAmount", line_total)
_sub(sums, "TaxTotalAmount", tax_total, currencyID=currency)
_sub(sums, "GrandTotalAmount", grand_total)
_sub(sums, "DuePayableAmount", grand_total)
return ET.tostring(root, encoding="unicode", xml_declaration=False)
# ─── Contract resolution (sales module docking point) ───────────────────────
async def resolve_einvoice_data(
db,
tenant_id: uuid.UUID,
entity_type: str,
entity_id: uuid.UUID,
) -> dict | None:
"""Resolve invoice data for an entity via the ``einvoice_data()`` contract.
The future sales module will expose::
async def einvoice_data(db, tenant_id, entity_id, entity_type) -> dict
Returning validated-shaped invoice data (same fields as the inline
render endpoint). No contribution → None (caller answers 404).
"""
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.registry import get_registry
for plugin_name in get_registry().list_discovered():
contract = get_contract_registry().get_contract(plugin_name)
fn = getattr(contract, "einvoice_data", None)
if fn is None:
continue
try:
result = await fn(db, tenant_id, entity_id, entity_type)
except Exception: # noqa: BLE001 — broken contribution must not 500
continue
if result:
return result
return None