diff --git a/invoice_logic.py b/invoice_logic.py index cce428d..aa18a89 100644 --- a/invoice_logic.py +++ b/invoice_logic.py @@ -68,6 +68,21 @@ PAYMENT_MEANS_CODES = { "48": "Bankkarte (Kredit-/Debitkarte)", } +# Schema-Codes fuer die elektronische Adresse des Kaeufers (BT-49-1, Code-Liste EAS). "EM" (E-Mail) +# ist der Standard fuer den B2B-Versand per Mail; die weiteren gelten v.a. beim Peppol-Versand an +# Behoerden (B2G). Die Leitweg-ID hat ihr eigentliches "Zuhause" in BT-10, kann als elektronische +# Adresse aber ueber Schema 0204 zugestellt werden. +BUYER_ENDPOINT_SCHEMES = { + "EM": "E-Mail-Adresse (Standard, B2B)", + "0204": "Leitweg-ID (deutsche Behörden / B2G)", + "0088": "GLN (Global Location Number)", +} + +# Fester Hinweistext fuer Vorkasse. Vorkasse ist eine Zahlungs*bedingung* (BT-20), kein +# Zahlungs*mittel* - das Zahlungsmittel bleibt die Ueberweisung. Der Text wird daher zu den +# Zahlungsbedingungen (BT-20) gelegt, nicht in den Zahlungsmittelcode (BT-81). +VORKASSE_HINWEIS = "Zahlung erfolgt per Vorkasse vor Lieferung." + def berechne_faelligkeit(invoice_date_str, term_days): """Fälligkeitsdatum = Rechnungsdatum + term_days. Gibt 'YYYY-MM-DD' zurück, oder leer, @@ -114,6 +129,43 @@ def format_skonto_text(skonto_percent, skonto_days, invoice_date_str): return f"Bei Zahlung innerhalb von {days} Tagen gewähren wir {percent_str}% Skonto." +def build_payment_terms_note(data): + """Kombinierter Freitext fuer die Zahlungsbedingungen (BT-20): optionaler Vorkasse-Hinweis + und/oder eine Skonto-Vereinbarung. Beides sind Zahlungsbedingungen, fuer die EN16931 kein + eigenes strukturiertes Feld kennt. Gibt "" zurueck, wenn keiner von beiden gesetzt ist.""" + parts = [] + if data.get("vorkasse"): + parts.append(VORKASSE_HINWEIS) + skonto_text = format_skonto_text( + data.get("skonto_percent"), data.get("skonto_days"), data["invoice_date"] + ) + if skonto_text: + parts.append(skonto_text) + return " ".join(parts) + + +def sanitize_filename_part(value): + """Macht einen String dateisystem-sicher: entfernt in Dateinamen kritische Zeichen + (Pfadtrenner/Sonderzeichen) und fasst Whitespace zu einem Bindestrich zusammen.""" + s = str(value or "").strip() + s = re.sub(r'[\\/:*?"<>|]', "", s) + s = re.sub(r"\s+", "-", s) + return s.strip("-_") + + +def build_export_basename(invoice_number, recipient_name, invoice_date): + """Dateiname-Basis fuer die Exporte: '__'. + Leere Teile werden weggelassen (kein '__'); ist alles leer, bleibt 'Rechnung'.""" + date_norm = parse_to_strict_xml_date(invoice_date) or str(invoice_date or "").strip() + parts = [ + sanitize_filename_part(invoice_number), + sanitize_filename_part(recipient_name), + sanitize_filename_part(date_norm), + ] + base = "_".join(p for p in parts if p) + return base or "Rechnung" + + def kaufmaennisches_runden(betrag, nachkommastellen=2): if not betrag: return 0.0 @@ -447,7 +499,8 @@ def generate_compliant_xrechnung(data): # --- Empfänger --- customer = ET.SubElement(root, f"{{{CAC}}}AccountingCustomerParty") party_cust = ET.SubElement(customer, f"{{{CAC}}}Party") - ET.SubElement(party_cust, f"{{{CBC}}}EndpointID", {"schemeID": "EM"}).text = str(data.get("recipient_email", "")).strip() + # BT-49-1 Schema der elektronischen Kaeufer-Adresse: dynamisch (EM/0204/0088), Default EM. + ET.SubElement(party_cust, f"{{{CBC}}}EndpointID", {"schemeID": str(data.get("recipient_endpoint_scheme", "EM"))}).text = str(data.get("recipient_email", "")).strip() addr_cust = ET.SubElement(party_cust, f"{{{CAC}}}PostalAddress") ET.SubElement(addr_cust, f"{{{CBC}}}StreetName").text = str(data["recipient_street"]) ET.SubElement(addr_cust, f"{{{CBC}}}CityName").text = str(data["recipient_city"]) @@ -468,13 +521,13 @@ def generate_compliant_xrechnung(data): payee_acc = ET.SubElement(payment_means, f"{{{CAC}}}PayeeFinancialAccount") ET.SubElement(payee_acc, f"{{{CBC}}}ID").text = str(data["iban"]).replace(" ", "") - # BT-20 Skonto-Vereinbarung als Freitext in cac:PaymentTerms/cbc:Note - EN16931 kennt kein - # eigenes strukturiertes Skonto-Feld. Muss laut UBL-Schema NACH PaymentMeans und VOR - # AllowanceCharge/TaxTotal stehen. - _skonto_text = format_skonto_text(data.get("skonto_percent"), data.get("skonto_days"), data["invoice_date"]) - if _skonto_text: + # 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 + # PaymentMeans und VOR AllowanceCharge/TaxTotal stehen. + _payment_terms_note = build_payment_terms_note(data) + if _payment_terms_note: payment_terms = ET.SubElement(root, f"{{{CAC}}}PaymentTerms") - ET.SubElement(payment_terms, f"{{{CBC}}}Note").text = _skonto_text + ET.SubElement(payment_terms, f"{{{CBC}}}Note").text = _payment_terms_note # BT-21 Dokumentenweiter Rabatt (BG-20). Muss laut UBL-Schema NACH PaymentTerms und VOR # TaxTotal stehen. Voll EN16931-konform: bei gemischten Steuersaetzen wird JE Steuersatz ein @@ -697,7 +750,8 @@ def generate_compliant_zugferd_cii(data): # XSD nach PostalTradeAddress stehen. KEIN erfundener Fallback - fehlt die Adresse, wird das # (leere) Pflichtfeld sichtbar und die UI blockiert den Export mit Warnung. cust_uri = ET.SubElement(customer_party, f"{{{RAM}}}URIUniversalCommunication") - ET.SubElement(cust_uri, f"{{{RAM}}}URIID", {"schemeID": "EM"}).text = str(data.get("recipient_email", "")).strip() + # BT-49-1 Schema der elektronischen Kaeufer-Adresse: dynamisch (EM/0204/0088), Default EM. + ET.SubElement(cust_uri, f"{{{RAM}}}URIID", {"schemeID": str(data.get("recipient_endpoint_scheme", "EM"))}).text = str(data.get("recipient_email", "")).strip() if data.get("order_id") and str(data["order_id"]).strip() != "": # BT-14 "Sales order reference" - unsere EIGENE Auftrags-/Verkaufsauftragsnummer. @@ -785,11 +839,11 @@ def generate_compliant_zugferd_cii(data): # ram:DueDateDateTime stehen. Kein fester Fallback: ohne gültiges Zahlungsziel UND ohne # Skonto wird der Block weggelassen (die UI blockiert dann den Export bzgl. Zahlungsziel). _cii_due = berechne_faelligkeit(data["invoice_date"], data.get("payment_term_days")) - _skonto_text = format_skonto_text(data.get("skonto_percent"), data.get("skonto_days"), data["invoice_date"]) - if _cii_due or _skonto_text: + _payment_terms_note = build_payment_terms_note(data) + if _cii_due or _payment_terms_note: payment_terms = ET.SubElement(header_settle, f"{{{RAM}}}SpecifiedTradePaymentTerms") - if _skonto_text: - ET.SubElement(payment_terms, f"{{{RAM}}}Description").text = _skonto_text + if _payment_terms_note: + ET.SubElement(payment_terms, f"{{{RAM}}}Description").text = _payment_terms_note if _cii_due: due_date_elem = ET.SubElement(payment_terms, f"{{{RAM}}}DueDateDateTime") ET.SubElement(due_date_elem, f"{{{UDT}}}DateTimeString", {"format": "102"}).text = _cii_due.replace("-", "") diff --git a/main.py b/main.py index 50fe66c..9afb462 100644 --- a/main.py +++ b/main.py @@ -11,6 +11,8 @@ from invoice_logic import ( VERKAEUFER, VERKAEUFER_IBAN, PAYMENT_MEANS_CODES, + BUYER_ENDPOINT_SCHEMES, + build_export_basename, berechne_faelligkeit, kaufmaennisches_runden, clean_german_float, @@ -305,7 +307,22 @@ with col2: with c_r: st.markdown("**Empfänger (Kunde):**") r_name = st.text_input("Firma (BT-44) :red[*]", value=data.get("recipient_name", "")) - r_email = st.text_input("E-Mail (BT-49) :red[*]", value=data.get("recipient_email", "")) + r_email = st.text_input( + "Elektronische Adresse (BT-49) :red[*]", + value=data.get("recipient_email", ""), + help="Adresse, an die der Kunde die Rechnung empfängt. Standard: E-Mail (Schema EM). " + "Für Behörden ggf. Leitweg-ID (0204) oder GLN (0088) - dann rechts das passende Schema wählen.", + ) + _scheme_codes = list(BUYER_ENDPOINT_SCHEMES.keys()) + _scheme_labels = [f"{c} - {BUYER_ENDPOINT_SCHEMES[c]}" for c in _scheme_codes] + r_endpoint_scheme_label = st.selectbox( + "Adress-Schema (BT-49-1)", + options=_scheme_labels, + index=0, + help="EM = E-Mail (Standard, B2B). 0204 = Leitweg-ID für deutsche Behörden. " + "0088 = GLN. Muss zum Typ der oben eingetragenen Adresse passen.", + ) + r_endpoint_scheme = r_endpoint_scheme_label.split(" - ")[0] r_street = st.text_input("Straße (BT-50)", value=data.get("recipient_street", "")) r_zip = st.text_input("PLZ (BT-53)", value=data.get("recipient_zip", "")) r_city = st.text_input("Stadt (BT-52) :red[*]", value=data.get("recipient_city", "")) @@ -384,6 +401,13 @@ with col2: with c_disc4: skonto_days = st.text_input("Skonto-Frist (Tage)", value="") + vorkasse = st.checkbox( + "Vorkasse (Zahlung vor Lieferung)", + value=False, + help="Fügt den Hinweis 'Zahlung erfolgt per Vorkasse vor Lieferung.' zu den " + "Zahlungsbedingungen (BT-20) hinzu. Der Zahlungsmittel-Code bleibt unverändert.", + ) + # --- SUMMEN & EXPORT: bewusst AUSSERHALB der Tabs, damit sie unabhängig vom aktiven # Reiter immer sichtbar und ohne Scrollen erreichbar sind. --- st.markdown("---") @@ -421,6 +445,8 @@ with col2: "allowance_reason": discount_reason, "skonto_percent": skonto_percent, "skonto_days": skonto_days, + "vorkasse": vorkasse, + "recipient_endpoint_scheme": r_endpoint_scheme, "delivery_note_id": dn_id, "order_id": o_id, "contract_id": c_id, @@ -441,7 +467,7 @@ with col2: blocker.append("Rechnungsdatum (BT-2) fehlt oder ist unlesbar (erwartet: YYYY-MM-DD). " "Bitte korrigieren - es wird KEIN Datum automatisch eingesetzt.") if not str(r_email).strip(): - blocker.append("E-Mail des Kunden (BT-49) fehlt. Diese elektronische Adresse ist für " + blocker.append("Elektronische Adresse des Kunden (BT-49) fehlt. Diese ist für " "XRechnung/ZUGFeRD ein Pflichtfeld (PEPPOL-EN16931-R010) und wird NICHT " "mit einem Platzhalter befüllt. Bitte eintragen.") if inv_delivery and parse_to_strict_xml_date(inv_delivery) == "": @@ -461,6 +487,9 @@ with col2: export_disabled = len(blocker) > 0 + # Dateiname: __ (dateisicher aufbereitet). + export_basename = build_export_basename(inv_num, r_name, inv_date) + # Ein-Klick-Export: die Exportdatei wird vorab erzeugt und direkt in den Download-Button # gelegt - ein Klick startet also sofort den Download, ohne separaten Zwischen-Button. if "UBL XML" in export_format: @@ -468,7 +497,7 @@ with col2: st.download_button( "🔒 XRechnung exportieren & herunterladen", data=xml_data, - file_name=f"XRechnung_{inv_num}.xml", + file_name=f"{export_basename}.xml", mime="text/xml", type="primary", width="stretch", @@ -496,7 +525,7 @@ with col2: st.download_button( "🔒 ZUGFeRD PDF exportieren & herunterladen", data=zugferd_pdf_bytes if zugferd_pdf_bytes else b"", - file_name=f"ZUGFeRD_{inv_num}.pdf", + file_name=f"{export_basename}.pdf", mime="application/pdf", type="primary", width="stretch",