Zahlungsreferenz (BT-83) im UI änderbar und bis zu drei Leistungsnachweise.

BT-83: Neues Feld "Verwendungszweck / Zahlungsreferenz" im Reiter Basis, mit
der Rechnungsnummer vorbelegt, aber frei überschreibbar - manche Empfänger
verlangen zur Zuordnung eine eigene Referenz. Ein leeres Feld bedeutet "Standard
nehmen", nicht "weglassen". BT-1 bleibt davon unberührt.

Leistungsnachweise: statt einer PDF jetzt bis zu drei (MAX_ATTACHMENT_PDFS),
nebeneinander angezeigt und in Upload-Reihenfolge angehängt. Bei mehr als drei
Dateien wird der Export gesperrt statt still abzuschneiden, damit kein bewusst
hochgeladener Nachweis unbemerkt fehlt.

Verifiziert: 162 Tests, XSD-Validierung mit 0/1/2/3 Anhängen sowie mit
abweichender Zahlungsreferenz.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dfuhrmann3 2026-07-30 15:49:33 +02:00
parent df015c9f97
commit c3fb9dc7cb
2 changed files with 108 additions and 41 deletions

View File

@ -172,6 +172,18 @@ def format_payment_term_text(payment_term_days):
return f"Zahlbar innerhalb von {days} Tagen ohne Abzug." return f"Zahlbar innerhalb von {days} Tagen ohne Abzug."
def resolve_payment_reference(data):
"""Zahlungsreferenz/Verwendungszweck (BT-83). Standard ist die Rechnungsnummer - so machen es
auch die Lieferanten, die das Feld ueberhaupt fuellen. Ist aber eine eigene Referenz
angegeben (z.B. eine SAP-Referenz, die die Buchhaltung des Empfaengers zur Zuordnung
verlangt), gewinnt diese. Ein leeres Feld bedeutet 'Standard nehmen', nicht 'leer lassen' -
ganz weglassen laesst sich BT-83 nur, wenn auch keine Rechnungsnummer vorliegt."""
explicit = str(data.get("payment_reference") or "").strip()
if explicit:
return explicit
return str(data.get("invoice_number") or "").strip()
def build_payment_terms_note(data): def build_payment_terms_note(data):
"""Kombinierter Freitext fuer die Zahlungsbedingungen (BT-20): das Zahlungsziel im Klartext """Kombinierter Freitext fuer die Zahlungsbedingungen (BT-20): das Zahlungsziel im Klartext
ODER ein Vorkasse-Hinweis, jeweils optional ergaenzt um eine Skonto-Vereinbarung. Fuer all ODER ein Vorkasse-Hinweis, jeweils optional ergaenzt um eine Skonto-Vereinbarung. Fuer all
@ -1073,11 +1085,12 @@ def generate_compliant_xrechnung(data):
# BT-81 Zahlungsart: Standard bleibt 58 (SEPA-Ueberweisung), aber ueber # BT-81 Zahlungsart: Standard bleibt 58 (SEPA-Ueberweisung), aber ueber
# PAYMENT_MEANS_CODES pro Rechnung waehlbar (siehe UN/EDIFACT 4461). # PAYMENT_MEANS_CODES pro Rechnung waehlbar (siehe UN/EDIFACT 4461).
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"))
# BT-83 Zahlungsreferenz (Verwendungszweck): die Rechnungsnummer, damit der Empfaenger die # BT-83 Zahlungsreferenz (Verwendungszweck), damit der Empfaenger die Zahlung automatisch
# Zahlung automatisch zuordnen kann. Muss laut UBL-Sequenz NACH PaymentMeansCode und VOR # zuordnen kann - standardmaessig die Rechnungsnummer, im UI aber ueberschreibbar. Muss laut
# PayeeFinancialAccount stehen. # UBL-Sequenz NACH PaymentMeansCode und VOR PayeeFinancialAccount stehen.
if str(data.get("invoice_number") or "").strip(): _payment_ref = resolve_payment_reference(data)
ET.SubElement(payment_means, f"{{{CBC}}}PaymentID").text = str(data["invoice_number"]).strip() if _payment_ref:
ET.SubElement(payment_means, f"{{{CBC}}}PaymentID").text = _payment_ref
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). # BT-86 BIC der Verkaeuferbank (optional, aber von vielen Empfaengern erwartet).
@ -1379,11 +1392,13 @@ def generate_compliant_zugferd_cii(data):
ET.SubElement(dn_ref, f"{{{RAM}}}IssuerAssignedID").text = str(data["delivery_note_id"]).strip() ET.SubElement(dn_ref, f"{{{RAM}}}IssuerAssignedID").text = str(data["delivery_note_id"]).strip()
header_settle = ET.SubElement(trade_trans, f"{{{RAM}}}ApplicableHeaderTradeSettlement") header_settle = ET.SubElement(trade_trans, f"{{{RAM}}}ApplicableHeaderTradeSettlement")
# BT-83 Zahlungsreferenz (Verwendungszweck): die Rechnungsnummer, damit der Empfaenger die # BT-83 Zahlungsreferenz (Verwendungszweck), damit der Empfaenger die Zahlung automatisch
# Zahlung automatisch zuordnen kann. Muss laut CII-Sequenz VOR InvoiceCurrencyCode stehen # zuordnen kann - standardmaessig die Rechnungsnummer, im UI aber ueberschreibbar. Muss laut
# (die Sequenz ist CreditorReferenceID -> PaymentReference -> ... -> InvoiceCurrencyCode). # CII-Sequenz VOR InvoiceCurrencyCode stehen (die Sequenz ist CreditorReferenceID ->
if str(data.get("invoice_number") or "").strip(): # PaymentReference -> ... -> InvoiceCurrencyCode).
ET.SubElement(header_settle, f"{{{RAM}}}PaymentReference").text = str(data["invoice_number"]).strip() _payment_ref = resolve_payment_reference(data)
if _payment_ref:
ET.SubElement(header_settle, f"{{{RAM}}}PaymentReference").text = _payment_ref
ET.SubElement(header_settle, f"{{{RAM}}}InvoiceCurrencyCode").text = "EUR" ET.SubElement(header_settle, f"{{{RAM}}}InvoiceCurrencyCode").text = "EUR"
pay_means = ET.SubElement(header_settle, f"{{{RAM}}}SpecifiedTradeSettlementPaymentMeans") pay_means = ET.SubElement(header_settle, f"{{{RAM}}}SpecifiedTradeSettlementPaymentMeans")
@ -1492,17 +1507,26 @@ def normalize_pdf_bytes(pdf_bytes: bytes) -> bytes:
return pdf_bytes return pdf_bytes
def append_pdf_pages(base_pdf_bytes: bytes, extra_pdf_bytes: bytes) -> bytes: # Wie viele Leistungsnachweise maximal an eine Rechnung gehaengt werden duerfen. Begrenzt,
"""Haengt alle Seiten von extra_pdf_bytes hinten an base_pdf_bytes an (z.B. einen # damit die Export-PDF handhabbar bleibt und ein versehentlicher Massen-Upload auffaellt.
Leistungsnachweis an die Rechnung) und gibt die zusammengefuehrte PDF zurueck. Aus der MAX_ATTACHMENT_PDFS = 3
angehaengten PDF wird nichts ausgelesen - sie ist reiner Seiteninhalt.
def append_pdf_pages(base_pdf_bytes: bytes, *extra_pdf_bytes: bytes) -> bytes:
"""Haengt die Seiten aller uebergebenen PDFs in Reihenfolge hinten an base_pdf_bytes an
(z.B. Leistungsnachweise an die Rechnung) und gibt die zusammengefuehrte PDF zurueck. Aus
den angehaengten PDFs wird nichts ausgelesen - sie sind reiner Seiteninhalt. Ohne Anhang
kommen die Basis-Bytes unveraendert zurueck.
Bewusst OHNE try/except: schlaegt das Zusammenfuehren fehl (kaputte oder passwortgeschuetzte Bewusst OHNE try/except: schlaegt das Zusammenfuehren fehl (kaputte oder passwortgeschuetzte
Datei), muss der Export sichtbar scheitern. Eine stillschweigend ohne Leistungsnachweis Datei), muss der Export sichtbar scheitern. Eine stillschweigend ohne Leistungsnachweis
exportierte Rechnung waere schlimmer als eine Fehlermeldung - anders als bei exportierte Rechnung waere schlimmer als eine Fehlermeldung - anders als bei
normalize_pdf_bytes, wo das Zurueckfallen auf die Eingabe harmlos ist.""" normalize_pdf_bytes, wo das Zurueckfallen auf die Eingabe harmlos ist."""
with pikepdf.open(io.BytesIO(base_pdf_bytes)) as base, \ if not extra_pdf_bytes:
pikepdf.open(io.BytesIO(extra_pdf_bytes)) as extra: return base_pdf_bytes
with pikepdf.open(io.BytesIO(base_pdf_bytes)) as base:
for extra_bytes in extra_pdf_bytes:
with pikepdf.open(io.BytesIO(extra_bytes)) as extra:
base.pages.extend(extra.pages) base.pages.extend(extra.pages)
buf = io.BytesIO() buf = io.BytesIO()
base.save(buf) base.save(buf)

91
main.py
View File

@ -25,6 +25,7 @@ from invoice_logic import (
pdf_has_embedded_facturx, pdf_has_embedded_facturx,
append_pdf_pages, append_pdf_pages,
count_pdf_pages, count_pdf_pages,
MAX_ATTACHMENT_PDFS,
add_pdfa_output_intent, add_pdfa_output_intent,
compute_invoice_totals, compute_invoice_totals,
resolve_buyer_endpoint, resolve_buyer_endpoint,
@ -110,21 +111,42 @@ with col1:
st.subheader("1. Dokument hochladen") st.subheader("1. Dokument hochladen")
uploaded_file = st.file_uploader("Wähle eine Rechnungs-PDF", type=["pdf"]) uploaded_file = st.file_uploader("Wähle eine Rechnungs-PDF", type=["pdf"])
# Optionaler Zusatzbeleg (i.d.R. Leistungsnachweis): wird beim ZUGFeRD-Export unveraendert # Optionale Zusatzbelege (i.d.R. Leistungsnachweise): werden beim ZUGFeRD-Export unveraendert
# hinten an die Rechnung gehaengt, sodass EINE PDF entsteht. Aus dieser PDF wird nichts # in Upload-Reihenfolge hinten an die Rechnung gehaengt, sodass EINE PDF entsteht. Aus diesen
# ausgelesen. # PDFs wird nichts ausgelesen.
# Steht bewusst DIREKT unter dem Rechnungs-Uploader und VOR dem Vorschau-Block: sonst # Steht bewusst DIREKT unter dem Rechnungs-Uploader und VOR dem Vorschau-Block: sonst
# schiebt die eingeblendete PDF-Vorschau das Feld nach dem Upload weit nach unten. # schiebt die eingeblendete PDF-Vorschau das Feld nach dem Upload weit nach unten.
# Ausserdem bewusst NICHT Teil der _last_file_id-Logik unten - sonst wuerde sein Upload # Ausserdem bewusst NICHT Teil der _last_file_id-Logik unten - sonst wuerde ihr Upload
# die bereits geprueften Rechnungsdaten verwerfen. # die bereits geprueften Rechnungsdaten verwerfen.
attachment_file = st.file_uploader( attachment_files = st.file_uploader(
"Leistungsnachweis anhängen (optional)", type=["pdf"], key="attachment_pdf" f"Leistungsnachweise anhängen (optional, max. {MAX_ATTACHMENT_PDFS})",
type=["pdf"],
accept_multiple_files=True,
key="attachment_pdf",
) )
st.caption( st.caption(
"Optional. Die Seiten dieser PDF werden beim ZUGFeRD-Export unverändert ans Ende der " f"Optional, bis zu {MAX_ATTACHMENT_PDFS} PDFs. Ihre Seiten werden beim ZUGFeRD-Export "
"Rechnung gehängt - es entsteht eine einzelne PDF. Es werden daraus keine Daten ausgelesen." "unverändert und in dieser Reihenfolge ans Ende der Rechnung gehängt - es entsteht eine "
"einzelne PDF. Es werden daraus keine Daten ausgelesen."
) )
# Zu viele Dateien: nicht stillschweigend abschneiden, sondern blockieren - sonst fehlt im
# Export ein Nachweis, den der Nutzer bewusst hochgeladen hat.
attachment_limit_exceeded = len(attachment_files) > MAX_ATTACHMENT_PDFS
if attachment_limit_exceeded:
st.error(
f"❌ Es sind maximal {MAX_ATTACHMENT_PDFS} Leistungsnachweise möglich, Sie haben "
f"{len(attachment_files)} hochgeladen. Bitte entfernen Sie "
f"{len(attachment_files) - MAX_ATTACHMENT_PDFS} Datei(en) - der ZUGFeRD-Export ist "
"so lange gesperrt."
)
elif attachment_files:
# Nebeneinander anzeigen, damit die Reihenfolge auf einen Blick erkennbar ist.
for col, att in zip(st.columns(MAX_ATTACHMENT_PDFS), attachment_files):
with col:
st.markdown(f"**{att.name}**")
st.caption(f"{att.size / 1024:.0f} KB")
pdf_text = "" pdf_text = ""
# Neue/andere Datei (oder Datei entfernt)? -> zuvor extrahierte Daten verwerfen, damit rechts # Neue/andere Datei (oder Datei entfernt)? -> zuvor extrahierte Daten verwerfen, damit rechts
@ -332,6 +354,18 @@ with col2:
) )
selected_payment_means_code = selected_payment_means_label.split(" - ")[0] selected_payment_means_code = selected_payment_means_label.split(" - ")[0]
# BT-83 Verwendungszweck: mit der Rechnungsnummer vorbelegt (so machen es auch die
# Lieferanten, die das Feld fuellen), aber frei ueberschreibbar - manche Empfaenger
# verlangen zur Zuordnung eine eigene Referenz. Bewusst NICHT fest an BT-1 gekoppelt.
payment_ref = st.text_input(
"Verwendungszweck / Zahlungsreferenz (BT-83)",
value=str(data.get("payment_reference", "") or inv_num or ""),
help="Erscheint als Verwendungszweck der Zahlung. Standard ist die "
"Rechnungsnummer - Sie können hier aber eine abweichende Referenz "
"eintragen (z.B. eine Referenznummer des Empfängers). Lassen Sie das Feld "
"leer, wird wieder die Rechnungsnummer verwendet.",
)
i_note = st.text_area("Globale Rechnungserläuterung / Freitext (BT-22)", value=data.get("invoice_note", ""), height=150) i_note = st.text_area("Globale Rechnungserläuterung / Freitext (BT-22)", value=data.get("invoice_note", ""), height=150)
with tab_parteien: with tab_parteien:
@ -548,6 +582,7 @@ with col2:
"items": edited_items, "total_net_amount": computed_net_amount, "total_vat_amount": computed_vat_amount, "items": edited_items, "total_net_amount": computed_net_amount, "total_vat_amount": computed_vat_amount,
"vat_rate_percent": vat_rate, "total_gross_amount": computed_gross_amount, "iban": VERKAEUFER_IBAN, "vat_rate_percent": vat_rate, "total_gross_amount": computed_gross_amount, "iban": VERKAEUFER_IBAN,
"payment_means_code": selected_payment_means_code, "payment_means_code": selected_payment_means_code,
"payment_reference": payment_ref,
"seller_contact_name": bearbeiter, "seller_contact_name": bearbeiter,
"seller_endpoint": seller_endpoint, "seller_endpoint": seller_endpoint,
"seller_contact_email": seller_contact_email, "seller_contact_email": seller_contact_email,
@ -608,7 +643,9 @@ with col2:
st.warning("⚠️ Export blockiert - folgende Pflichtangaben fehlen oder sind fehlerhaft:\n\n" st.warning("⚠️ Export blockiert - folgende Pflichtangaben fehlen oder sind fehlerhaft:\n\n"
+ "\n".join(f"- {b}" for b in blocker)) + "\n".join(f"- {b}" for b in blocker))
export_disabled = len(blocker) > 0 # Zu viele Leistungsnachweise sperren den Export ebenfalls - die Fehlermeldung dazu steht
# bereits am Upload-Feld, hier wird nur der Button gesperrt.
export_disabled = len(blocker) > 0 or attachment_limit_exceeded
# ZUGFeRD (B2B) ohne Kunden-Adresse: KEIN Blocker, aber statt eines leeren Pflichtfelds wird # ZUGFeRD (B2B) ohne Kunden-Adresse: KEIN Blocker, aber statt eines leeren Pflichtfelds wird
# ein Platzhalter "X" in BT-49 gesetzt, damit das Feld nicht komplett fehlt - dazu eine # ein Platzhalter "X" in BT-49 gesetzt, damit das Feld nicht komplett fehlt - dazu eine
@ -630,11 +667,13 @@ with col2:
# Eine XRechnung ist eine reine XML-Datei - dort laesst sich kein PDF-Anhang # Eine XRechnung ist eine reine XML-Datei - dort laesst sich kein PDF-Anhang
# unterbringen. Nicht blockieren (der Nutzer darf das bewusst wollen), aber # unterbringen. Nicht blockieren (der Nutzer darf das bewusst wollen), aber
# deutlich darauf hinweisen, damit der Nachweis nicht unbemerkt verloren geht. # deutlich darauf hinweisen, damit der Nachweis nicht unbemerkt verloren geht.
if attachment_file is not None: if attachment_files:
_n = len(attachment_files)
st.warning( st.warning(
"⚠️ Ihr Leistungsnachweis kann in eine XRechnung-XML nicht eingebettet werden " f"⚠️ {'Ihr Leistungsnachweis kann' if _n == 1 else f'Ihre {_n} Leistungsnachweise können'} "
"und entfällt bei diesem Export. Wählen Sie das ZUGFeRD-PDF-Format, wenn der " "in eine XRechnung-XML nicht eingebettet werden und "
"Nachweis mitgeliefert werden soll." f"{'entfällt' if _n == 1 else 'entfallen'} bei diesem Export. Wählen Sie das "
"ZUGFeRD-PDF-Format, wenn sie mitgeliefert werden sollen."
) )
xml_data = generate_compliant_xrechnung(final_invoice_dict) if not export_disabled else "" xml_data = generate_compliant_xrechnung(final_invoice_dict) if not export_disabled else ""
st.download_button( st.download_button(
@ -655,16 +694,19 @@ with col2:
uploaded_file.seek(0) uploaded_file.seek(0)
original_pdf_bytes = uploaded_file.read() original_pdf_bytes = uploaded_file.read()
# Optionalen Leistungsnachweis ZUERST anhaengen, DANN die factur-x.xml # Optionale Leistungsnachweise ZUERST anhaengen, DANN die factur-x.xml
# einbetten: so laeuft die Erfolgskontrolle (pdf_has_embedded_facturx) und # einbetten: so laeuft die Erfolgskontrolle (pdf_has_embedded_facturx) und
# die Zwei-Versuche-Logik in build_zugferd_pdf ueber das fertige Dokument. # die Zwei-Versuche-Logik in build_zugferd_pdf ueber das fertige Dokument.
# Umgekehrt (erst einbetten, dann anhaengen) bliebe ein beschaedigender # Umgekehrt (erst einbetten, dann anhaengen) bliebe ein beschaedigender
# Anhang unbemerkt. # Anhang unbemerkt.
if attachment_file is not None: if attachment_files:
try: try:
attachment_file.seek(0) _attachment_bytes = []
for att in attachment_files:
att.seek(0)
_attachment_bytes.append(att.read())
original_pdf_bytes = append_pdf_pages( original_pdf_bytes = append_pdf_pages(
original_pdf_bytes, attachment_file.read() original_pdf_bytes, *_attachment_bytes
) )
except Exception as e: except Exception as e:
# Eigene Meldung: sonst sieht es so aus, als sei die RECHNUNG kaputt. # Eigene Meldung: sonst sieht es so aus, als sei die RECHNUNG kaputt.
@ -683,17 +725,18 @@ with col2:
if attachment_error: if attachment_error:
st.error( st.error(
f"❌ Der Leistungsnachweis konnte nicht angehängt werden: {attachment_error}\n\n" f"❌ Die Leistungsnachweise konnten nicht angehängt werden: {attachment_error}\n\n"
"Die PDF ist möglicherweise beschädigt oder passwortgeschützt. Bitte entfernen " "Eine der PDFs ist möglicherweise beschädigt oder passwortgeschützt. Bitte "
"Sie die Datei oder laden Sie eine andere hoch. Der Export wurde abgebrochen, " "entfernen Sie die betroffene Datei oder laden Sie eine andere hoch. Der Export "
"damit keine Rechnung ohne den Nachweis entsteht." "wurde abgebrochen, damit keine Rechnung ohne die Nachweise entsteht."
) )
elif zugferd_error: elif zugferd_error:
st.error(f"Fehler bei der ZUGFeRD-Generierung: {zugferd_error}") st.error(f"Fehler bei der ZUGFeRD-Generierung: {zugferd_error}")
elif zugferd_pdf_bytes and attachment_file is not None: elif zugferd_pdf_bytes and attachment_files:
_n = len(attachment_files)
st.success( st.success(
f"✅ Leistungsnachweis angehängt - die exportierte PDF enthält " f"✅ {_n} {'Leistungsnachweis' if _n == 1 else 'Leistungsnachweise'} angehängt - "
f"{count_pdf_pages(zugferd_pdf_bytes)} Seiten." f"die exportierte PDF enthält {count_pdf_pages(zugferd_pdf_bytes)} Seiten."
) )
st.download_button( st.download_button(