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:
parent
555272bcf6
commit
9ee56440f5
|
|
@ -58,6 +58,7 @@ VERKAEUFER = {
|
||||||
"country": "DE", # BT-40
|
"country": "DE", # BT-40
|
||||||
"vat_id": "DE218436692", # BT-31 (Umsatzsteuer-ID)
|
"vat_id": "DE218436692", # BT-31 (Umsatzsteuer-ID)
|
||||||
"register_id": "HRB 6058 NP", # BT-30 (Handelsregisternummer, NICHT die USt-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.
|
# 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"))
|
ET.SubElement(payment_means, f"{{{CBC}}}PaymentMeansCode").text = str(data.get("payment_means_code", "58"))
|
||||||
payee_acc = ET.SubElement(payment_means, f"{{{CAC}}}PayeeFinancialAccount")
|
payee_acc = ET.SubElement(payment_means, f"{{{CAC}}}PayeeFinancialAccount")
|
||||||
ET.SubElement(payee_acc, f"{{{CBC}}}ID").text = str(data["iban"]).replace(" ", "")
|
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
|
# 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
|
# 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}"
|
ET.SubElement(monetary_total, f"{{{CBC}}}TaxInclusiveAmount", {"currencyID": "EUR"}).text = f"{data['total_gross_amount']:.2f}"
|
||||||
if data.get("allowance_amount", 0.0) > 0:
|
if data.get("allowance_amount", 0.0) > 0:
|
||||||
ET.SubElement(monetary_total, f"{{{CBC}}}AllowanceTotalAmount", {"currencyID": "EUR"}).text = f"{data['allowance_amount']:.2f}"
|
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}"
|
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):
|
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"
|
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")
|
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")
|
ex_doc = ET.SubElement(root, f"{{{RSM}}}ExchangedDocument")
|
||||||
ET.SubElement(ex_doc, f"{{{RAM}}}ID").text = str(data["invoice_number"])
|
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"))
|
ET.SubElement(pay_means, f"{{{RAM}}}TypeCode").text = str(data.get("payment_means_code", "58"))
|
||||||
payee_acc = ET.SubElement(pay_means, f"{{{RAM}}}PayeePartyCreditorFinancialAccount")
|
payee_acc = ET.SubElement(pay_means, f"{{{RAM}}}PayeePartyCreditorFinancialAccount")
|
||||||
ET.SubElement(payee_acc, f"{{{RAM}}}IBANID").text = str(data["iban"]).replace(" ", "")
|
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).
|
# BG-23: pro Steuersatz-Bucket ein eigenes ram:ApplicableTradeTax (Mehrfach-Steuersaetze).
|
||||||
# Fallback fuer Aufrufer ohne tax_buckets (Einzelsteuersatz, byte-identisch zu vor Phase 1):
|
# 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}}}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}}}TaxTotalAmount", {"currencyID": "EUR"}).text = f"{data['total_vat_amount']:.2f}"
|
||||||
ET.SubElement(header_sum, f"{{{RAM}}}GrandTotalAmount").text = f"{data['total_gross_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}"
|
ET.SubElement(header_sum, f"{{{RAM}}}DuePayableAmount").text = f"{data['total_gross_amount']:.2f}"
|
||||||
|
|
||||||
xml_str = ET.tostring(root, encoding="utf-8")
|
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()
|
return buf.getvalue()
|
||||||
finally:
|
finally:
|
||||||
pdf.close()
|
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()
|
|
||||||
|
|
|
||||||
6
main.py
6
main.py
|
|
@ -22,7 +22,6 @@ from invoice_logic import (
|
||||||
normalize_pdf_bytes,
|
normalize_pdf_bytes,
|
||||||
pdf_has_embedded_facturx,
|
pdf_has_embedded_facturx,
|
||||||
add_pdfa_output_intent,
|
add_pdfa_output_intent,
|
||||||
fix_xrechnung_xmp_conformance,
|
|
||||||
compute_invoice_totals,
|
compute_invoice_totals,
|
||||||
resolve_buyer_endpoint,
|
resolve_buyer_endpoint,
|
||||||
extract_service_period,
|
extract_service_period,
|
||||||
|
|
@ -60,7 +59,9 @@ def build_zugferd_pdf(invoice_json: str, original_pdf_bytes: bytes) -> bytes:
|
||||||
|
|
||||||
def _attempt(src_pdf_bytes):
|
def _attempt(src_pdf_bytes):
|
||||||
# factur-x: XSD-Validierung der CII-XML, Einbetten, PDF/A-3-konforme XMP-Struktur; danach
|
# 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(
|
pdf_bytes = facturx_generate_from_binary(
|
||||||
src_pdf_bytes,
|
src_pdf_bytes,
|
||||||
cii_bytes,
|
cii_bytes,
|
||||||
|
|
@ -71,7 +72,6 @@ def build_zugferd_pdf(invoice_json: str, original_pdf_bytes: bytes) -> bytes:
|
||||||
afrelationship="data",
|
afrelationship="data",
|
||||||
)
|
)
|
||||||
pdf_bytes = add_pdfa_output_intent(pdf_bytes)
|
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.
|
# Erfolgskontrolle: factur-x laesst bei manchen PDF-Strukturen die XML still weg bzw.
|
||||||
# produziert eine kaputte PDF - dann als Fehlschlag behandeln.
|
# produziert eine kaputte PDF - dann als Fehlschlag behandeln.
|
||||||
if not pdf_has_embedded_facturx(pdf_bytes):
|
if not pdf_has_embedded_facturx(pdf_bytes):
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue