feat(v3): BT-113 Vorauszahlung, BT-86 BIC + ZUGFeRD-Profil auf EN16931

Aus dem Vergleich mit eingegangenen Lieferanten-Rechnungen (ZUGFeRD/Factur-X)
übernommen:

1) BT-113 TotalPrepaidAmount/PrepaidAmount = 0,00 immer ausgeben (UBL + CII) -
   macht die Summensektion vollständig (4/4 Lieferanten hatten es).
2) BT-86 BIC der Verkäuferbank (DEUTDEDB160) ins XML: UBL
   FinancialInstitutionBranch/ID, CII PayeeSpecifiedCreditorFinancialInstitution/
   BICID. BIC in VERKAEUFER-Stammdaten ergänzt.
3) ZUGFeRD-Profil-Konsistenz: die CII deklariert jetzt das EN16931-Profil
   (urn:cen.eu:en16931:2017, B2B) statt des strengen KoSIT-XRechnung-Profils -
   das passt zum factur-x-Level 'en16931'. Damit entfällt der bisherige XMP-
   Override (fix_xrechnung_xmp_conformance): factur-x schreibt nativ
   'EN 16931'/'1.0', konsistent zum eingebetteten XML. Die reine XRechnung (UBL,
   B2G) bleibt beim strengen xrechnung_3.0-Profil.

Verifiziert: ZUGFeRD-PDF mit check_xsd=True erzeugt (Element-Reihenfolge korrekt),
XMP 'EN 16931' == eingebettetes Profil. Alle 7 Beispielrechnungen unverändert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
dfuhrmann3 2026-07-15 12:34:39 +02:00
parent 555272bcf6
commit 9ee56440f5
2 changed files with 24 additions and 32 deletions

View File

@ -58,6 +58,7 @@ VERKAEUFER = {
"country": "DE", # BT-40
"vat_id": "DE218436692", # BT-31 (Umsatzsteuer-ID)
"register_id": "HRB 6058 NP", # BT-30 (Handelsregisternummer, NICHT die USt-ID)
"bic": "DEUTDEDB160", # BT-86 (BIC der Hausbank, Deutsche Bank)
}
# UN/EDIFACT 4461 Zahlungsmittelcodes (BT-81), Auswahl der fuer B2B/B2G relevanten Codes.
@ -953,6 +954,11 @@ def generate_compliant_xrechnung(data):
ET.SubElement(payment_means, f"{{{CBC}}}PaymentMeansCode").text = str(data.get("payment_means_code", "58"))
payee_acc = ET.SubElement(payment_means, f"{{{CAC}}}PayeeFinancialAccount")
ET.SubElement(payee_acc, f"{{{CBC}}}ID").text = str(data["iban"]).replace(" ", "")
# BT-86 BIC der Verkaeuferbank (optional, aber von vielen Empfaengern erwartet).
_bic = str(data.get("bic") or VERKAEUFER["bic"]).replace(" ", "")
if _bic:
fin_branch = ET.SubElement(payee_acc, f"{{{CAC}}}FinancialInstitutionBranch")
ET.SubElement(fin_branch, f"{{{CBC}}}ID").text = _bic
# BT-20 Zahlungsbedingungen als Freitext in cac:PaymentTerms/cbc:Note (Vorkasse und/oder
# Skonto) - EN16931 kennt dafuer kein eigenes strukturiertes Feld. Muss laut UBL-Schema NACH
@ -1013,6 +1019,9 @@ def generate_compliant_xrechnung(data):
ET.SubElement(monetary_total, f"{{{CBC}}}TaxInclusiveAmount", {"currencyID": "EUR"}).text = f"{data['total_gross_amount']:.2f}"
if data.get("allowance_amount", 0.0) > 0:
ET.SubElement(monetary_total, f"{{{CBC}}}AllowanceTotalAmount", {"currencyID": "EUR"}).text = f"{data['allowance_amount']:.2f}"
# BT-113 Vorauszahlung: mangels Anzahlungsdaten immer 0,00 - macht die Summensektion
# vollstaendig (viele Empfaenger/Validatoren erwarten das Feld). Muss VOR PayableAmount stehen.
ET.SubElement(monetary_total, f"{{{CBC}}}PrepaidAmount", {"currencyID": "EUR"}).text = "0.00"
ET.SubElement(monetary_total, f"{{{CBC}}}PayableAmount", {"currencyID": "EUR"}).text = f"{data['total_gross_amount']:.2f}"
for index, item in enumerate(data["items"], start=1):
@ -1079,7 +1088,10 @@ def generate_compliant_zugferd_cii(data):
ET.SubElement(biz_process, f"{{{RAM}}}ID").text = "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0"
guideline = ET.SubElement(ex_doc_ctx, f"{{{RAM}}}GuidelineSpecifiedDocumentContextParameter")
ET.SubElement(guideline, f"{{{RAM}}}ID").text = "urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0"
# BT-24 Profil: ZUGFeRD/Factur-X wird B2B verschickt - daher das EN16931-Profil (COMFORT)
# deklarieren, NICHT das strengere KoSIT-XRechnung-Behoerdenprofil. So passt die
# Profildeklaration zum factur-x-Level 'en16931' und zur B2B-Ausrichtung (vgl. Lieferanten-XML).
ET.SubElement(guideline, f"{{{RAM}}}ID").text = "urn:cen.eu:en16931:2017"
ex_doc = ET.SubElement(root, f"{{{RSM}}}ExchangedDocument")
ET.SubElement(ex_doc, f"{{{RAM}}}ID").text = str(data["invoice_number"])
@ -1237,6 +1249,11 @@ def generate_compliant_zugferd_cii(data):
ET.SubElement(pay_means, f"{{{RAM}}}TypeCode").text = str(data.get("payment_means_code", "58"))
payee_acc = ET.SubElement(pay_means, f"{{{RAM}}}PayeePartyCreditorFinancialAccount")
ET.SubElement(payee_acc, f"{{{RAM}}}IBANID").text = str(data["iban"]).replace(" ", "")
# BT-86 BIC der Verkaeuferbank (nach dem Konto, laut CII-Schema).
_bic = str(data.get("bic") or VERKAEUFER["bic"]).replace(" ", "")
if _bic:
fin_inst = ET.SubElement(pay_means, f"{{{RAM}}}PayeeSpecifiedCreditorFinancialInstitution")
ET.SubElement(fin_inst, f"{{{RAM}}}BICID").text = _bic
# BG-23: pro Steuersatz-Bucket ein eigenes ram:ApplicableTradeTax (Mehrfach-Steuersaetze).
# Fallback fuer Aufrufer ohne tax_buckets (Einzelsteuersatz, byte-identisch zu vor Phase 1):
@ -1300,6 +1317,9 @@ def generate_compliant_zugferd_cii(data):
ET.SubElement(header_sum, f"{{{RAM}}}TaxBasisTotalAmount").text = f"{data['total_net_amount']:.2f}"
ET.SubElement(header_sum, f"{{{RAM}}}TaxTotalAmount", {"currencyID": "EUR"}).text = f"{data['total_vat_amount']:.2f}"
ET.SubElement(header_sum, f"{{{RAM}}}GrandTotalAmount").text = f"{data['total_gross_amount']:.2f}"
# BT-113 Vorauszahlung: mangels Anzahlungsdaten immer 0,00. Muss laut CII-Schema NACH
# GrandTotalAmount und VOR DuePayableAmount stehen (DuePayable = GrandTotal - Vorauszahlung).
ET.SubElement(header_sum, f"{{{RAM}}}TotalPrepaidAmount").text = "0.00"
ET.SubElement(header_sum, f"{{{RAM}}}DuePayableAmount").text = f"{data['total_gross_amount']:.2f}"
xml_str = ET.tostring(root, encoding="utf-8")
@ -1371,31 +1391,3 @@ def add_pdfa_output_intent(pdf_bytes: bytes, icc_path: str = SRGB_ICC_PATH) -> b
return buf.getvalue()
finally:
pdf.close()
def fix_xrechnung_xmp_conformance(pdf_bytes: bytes) -> bytes:
"""
Die factur-x-Bibliothek schreibt standardmäßig fx:ConformanceLevel = 'EN 16931' und
fx:Version = '1.0' in die XMP-Metadaten (generischer Factur-X-Standard). Da unsere CII-XML
aber explizit das KoSIT-XRechnung-Profil deklariert (GuidelineID .../xrechnung_3.0), erwarten
Validatoren hier stattdessen 'XRECHNUNG' und '3.0' - sonst schlagen z. B.
VD-Valitool-H-022/H-024 fehl, weil PDF-Metadaten und eingebettetes XML nicht zusammenpassen.
"""
pdf = pikepdf.open(io.BytesIO(pdf_bytes))
try:
if "/Metadata" in pdf.Root:
xmp_bytes = pdf.Root.Metadata.read_bytes()
xmp_str = xmp_bytes.decode("utf-8")
xmp_str = xmp_str.replace(
"<fx:ConformanceLevel>EN 16931</fx:ConformanceLevel>",
"<fx:ConformanceLevel>XRECHNUNG</fx:ConformanceLevel>",
).replace(
"<fx:Version>1.0</fx:Version>",
"<fx:Version>3.0</fx:Version>",
)
pdf.Root.Metadata.write(xmp_str.encode("utf-8"))
buf = io.BytesIO()
pdf.save(buf)
return buf.getvalue()
finally:
pdf.close()

View File

@ -22,7 +22,6 @@ from invoice_logic import (
normalize_pdf_bytes,
pdf_has_embedded_facturx,
add_pdfa_output_intent,
fix_xrechnung_xmp_conformance,
compute_invoice_totals,
resolve_buyer_endpoint,
extract_service_period,
@ -60,7 +59,9 @@ def build_zugferd_pdf(invoice_json: str, original_pdf_bytes: bytes) -> bytes:
def _attempt(src_pdf_bytes):
# factur-x: XSD-Validierung der CII-XML, Einbetten, PDF/A-3-konforme XMP-Struktur; danach
# OutputIntent (sRGB-ICC) und XRechnung-Konformitaets-Fix in den XMP-Metadaten.
# nur noch OutputIntent (sRGB-ICC). Die CII deklariert jetzt das EN16931-Profil (B2B), das
# exakt zum factur-x-Level 'en16931' passt - daher KEIN XMP-Konformitaets-Override mehr
# noetig (frueher wurde XMP auf 'XRECHNUNG'/'3.0' gezwungen, um zum alten Profil zu passen).
pdf_bytes = facturx_generate_from_binary(
src_pdf_bytes,
cii_bytes,
@ -71,7 +72,6 @@ def build_zugferd_pdf(invoice_json: str, original_pdf_bytes: bytes) -> bytes:
afrelationship="data",
)
pdf_bytes = add_pdfa_output_intent(pdf_bytes)
pdf_bytes = fix_xrechnung_xmp_conformance(pdf_bytes)
# Erfolgskontrolle: factur-x laesst bei manchen PDF-Strukturen die XML still weg bzw.
# produziert eine kaputte PDF - dann als Fehlschlag behandeln.
if not pdf_has_embedded_facturx(pdf_bytes):