From 0dd9916079e5b7a06424841145e28414dd88a8ca Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Tue, 7 Jul 2026 15:10:35 +0200 Subject: [PATCH] feat: add document-level discount (BT-21) and Skonto payment terms (BT-20) --- invoice_logic.py | 89 ++++++++++++++++++++++++++++++++++--- main.py | 15 ++++++- tests/test_invoice_logic.py | 89 +++++++++++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+), 8 deletions(-) diff --git a/invoice_logic.py b/invoice_logic.py index 305228a..46c3058 100644 --- a/invoice_logic.py +++ b/invoice_logic.py @@ -89,6 +89,31 @@ def berechne_faelligkeit(invoice_date_str, term_days): return (base_date + timedelta(days=days)).strftime("%Y-%m-%d") +def format_skonto_text(skonto_percent, skonto_days, invoice_date_str): + """Baut den Freitext fuer eine Skonto-Vereinbarung. EN16931 kennt kein eigenes strukturiertes + Skonto-Feld - der Text wird daher als cbc:Note (UBL) bzw. ram:Description (CII) neben dem + regulaeren Faelligkeitsdatum in den Zahlungsbedingungen (BT-20) abgelegt. Gibt "" zurueck, + wenn kein gueltiges Skonto vereinbart ist (KEIN Rateraten bei fehlenden Angaben).""" + try: + percent = float(skonto_percent) + except (TypeError, ValueError): + return "" + if percent <= 0: + return "" + try: + days = int(skonto_days) + except (TypeError, ValueError): + return "" + if days <= 0: + return "" + + percent_str = f"{percent:.2f}".rstrip("0").rstrip(".") + skonto_date = berechne_faelligkeit(invoice_date_str, days) + if skonto_date: + return f"Bei Zahlung bis {skonto_date} gewähren wir {percent_str}% Skonto." + return f"Bei Zahlung innerhalb von {days} Tagen gewähren wir {percent_str}% Skonto." + + def kaufmaennisches_runden(betrag, nachkommastellen=2): if not betrag: return 0.0 @@ -384,6 +409,33 @@ 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: + payment_terms = ET.SubElement(root, f"{{{CAC}}}PaymentTerms") + ET.SubElement(payment_terms, f"{{{CBC}}}Note").text = _skonto_text + + # 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 + # eigener AllowanceCharge-Block mit dem anteilig zugeordneten Rabattbetrag erzeugt, damit jede + # Steuergruppe (BG-23) ihren eigenen Rabattanteil fuehrt (BR-CO-10/BR-DEC). + if data.get("allowance_amount", 0.0) > 0: + for bucket in data.get("tax_buckets") or []: + bucket_allowance = bucket.get("allowance_amount", 0.0) + if bucket_allowance <= 0: + continue + allowance_charge = ET.SubElement(root, f"{{{CAC}}}AllowanceCharge") + ET.SubElement(allowance_charge, f"{{{CBC}}}ChargeIndicator").text = "false" + ET.SubElement(allowance_charge, f"{{{CBC}}}AllowanceChargeReason").text = str(data.get("allowance_reason") or "Rabatt") + ET.SubElement(allowance_charge, f"{{{CBC}}}Amount", {"currencyID": "EUR"}).text = f"{bucket_allowance:.2f}" + ac_tax_cat = ET.SubElement(allowance_charge, f"{{{CAC}}}TaxCategory") + ET.SubElement(ac_tax_cat, f"{{{CBC}}}ID").text = bucket["category"] + ET.SubElement(ac_tax_cat, f"{{{CBC}}}Percent").text = f"{bucket['rate']:.2f}" + ac_tax_scheme = ET.SubElement(ac_tax_cat, f"{{{CAC}}}TaxScheme") + ET.SubElement(ac_tax_scheme, f"{{{CBC}}}ID").text = "VAT" + # --- STEUERN (Gezielt erzwungener CBC Namespace für Beträge) --- tax_total = ET.SubElement(root, f"{{{CAC}}}TaxTotal") ET.SubElement(tax_total, f"{{{CBC}}}TaxAmount", {"currencyID": "EUR"}).text = f"{data['total_vat_amount']:.2f}" @@ -644,15 +696,38 @@ def generate_compliant_zugferd_cii(data): end_pt = ET.SubElement(period, f"{{{RAM}}}EndDateTime") ET.SubElement(end_pt, f"{{{UDT}}}DateTimeString", {"format": "102"}).text = format_cii_date(data["service_end_date"]) - # BT-9 Fälligkeitsdatum, abgeleitet aus dem tatsächlichen Zahlungsziel der Rechnung. Erfüllt - # allein BR-CO-25 (Fälligkeitsdatum ODER Zahlungsbedingungen - wir nutzen nur das Datum). - # Kein fester Fallback: ohne gültiges Zahlungsziel wird der Block weggelassen (die UI blockiert - # dann den Export). + # BT-21 Dokumentenweiter Rabatt (BG-20). Muss laut CII-Schema NACH BillingSpecifiedPeriod und + # VOR SpecifiedTradePaymentTerms stehen. Voll EN16931-konform: bei gemischten Steuersaetzen wird + # JE Steuersatz ein eigener SpecifiedTradeAllowanceCharge-Block mit dem anteilig zugeordneten + # Rabattbetrag erzeugt (jede Steuergruppe fuehrt ihren eigenen Rabattanteil). + if data.get("allowance_amount", 0.0) > 0: + for bucket in data.get("tax_buckets") or []: + bucket_allowance = bucket.get("allowance_amount", 0.0) + if bucket_allowance <= 0: + continue + allowance_charge = ET.SubElement(header_settle, f"{{{RAM}}}SpecifiedTradeAllowanceCharge") + indicator = ET.SubElement(allowance_charge, f"{{{RAM}}}ChargeIndicator") + ET.SubElement(indicator, f"{{{UDT}}}Indicator").text = "false" + ET.SubElement(allowance_charge, f"{{{RAM}}}ActualAmount").text = f"{bucket_allowance:.2f}" + ET.SubElement(allowance_charge, f"{{{RAM}}}Reason").text = str(data.get("allowance_reason") or "Rabatt") + ac_tax = ET.SubElement(allowance_charge, f"{{{RAM}}}CategoryTradeTax") + ET.SubElement(ac_tax, f"{{{RAM}}}TypeCode").text = "VAT" + ET.SubElement(ac_tax, f"{{{RAM}}}CategoryCode").text = bucket["category"] + ET.SubElement(ac_tax, f"{{{RAM}}}RateApplicablePercent").text = f"{bucket['rate']:.2f}" + + # BT-9 Fälligkeitsdatum, abgeleitet aus dem tatsächlichen Zahlungsziel der Rechnung, sowie + # optional die BT-20 Skonto-Freitextbeschreibung. ram:Description muss laut Schema VOR + # 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")) - if _cii_due: + _skonto_text = format_skonto_text(data.get("skonto_percent"), data.get("skonto_days"), data["invoice_date"]) + if _cii_due or _skonto_text: payment_terms = ET.SubElement(header_settle, f"{{{RAM}}}SpecifiedTradePaymentTerms") - due_date_elem = ET.SubElement(payment_terms, f"{{{RAM}}}DueDateDateTime") - ET.SubElement(due_date_elem, f"{{{UDT}}}DateTimeString", {"format": "102"}).text = _cii_due.replace("-", "") + if _skonto_text: + ET.SubElement(payment_terms, f"{{{RAM}}}Description").text = _skonto_text + 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("-", "") header_sum = ET.SubElement(header_settle, f"{{{RAM}}}SpecifiedTradeSettlementHeaderMonetarySummation") ET.SubElement(header_sum, f"{{{RAM}}}LineTotalAmount").text = f"{data.get('line_net_total', data['total_net_amount']):.2f}" diff --git a/main.py b/main.py index c35c203..e522aed 100644 --- a/main.py +++ b/main.py @@ -330,7 +330,17 @@ with col2: # pro Position mit dem jeweils eingetragenen Steuersatz (Multi-Tax-Support). vat_rate = st.number_input("Standard-Steuersatz für neue Positionen (%)", value=float(data.get("vat_rate_percent") or 19.0)) - totals = compute_invoice_totals(edited_items) + c_disc1, c_disc2, c_disc3, c_disc4 = st.columns(4) + with c_disc1: + discount_percent = st.number_input("Rabatt (BT-21, %)", value=0.0, min_value=0.0, max_value=100.0, step=0.5) + with c_disc2: + discount_reason = st.text_input("Rabattgrund", value="") + with c_disc3: + skonto_percent = st.number_input("Skonto (BT-20, %)", value=0.0, min_value=0.0, max_value=100.0, step=0.5) + with c_disc4: + skonto_days = st.text_input("Skonto-Frist (Tage)", value="") + + totals = compute_invoice_totals(edited_items, discount_percent=discount_percent) computed_net_amount = totals["tax_basis_total"] computed_vat_amount = totals["total_vat_amount"] computed_gross_amount = totals["total_gross_amount"] @@ -360,6 +370,9 @@ with col2: "tax_buckets": totals["tax_buckets"], "line_net_total": totals["line_net_total"], "allowance_amount": totals["allowance_amount"], + "allowance_reason": discount_reason, + "skonto_percent": skonto_percent, + "skonto_days": skonto_days, "delivery_note_id": dn_id, "order_id": o_id, "contract_id": c_id, diff --git a/tests/test_invoice_logic.py b/tests/test_invoice_logic.py index 357b85e..b4b60db 100644 --- a/tests/test_invoice_logic.py +++ b/tests/test_invoice_logic.py @@ -14,6 +14,7 @@ from invoice_logic import ( PAYMENT_MEANS_CODES, aggregate_tax_subtotals, compute_invoice_totals, + format_skonto_text, ) @@ -215,3 +216,91 @@ def test_generate_compliant_zugferd_cii_mixed_rates_two_header_taxes(): header_taxes = root.findall( ".//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax", ns) assert len(header_taxes) == 2 + + +def test_format_skonto_text_with_valid_percent_and_days(): + text = format_skonto_text(2.0, 10, "2026-01-15") + assert "2026-01-25" in text + assert "2%" in text + + +def test_format_skonto_text_returns_empty_when_no_percent(): + assert format_skonto_text(0, 10, "2026-01-15") == "" + assert format_skonto_text(None, 10, "2026-01-15") == "" + + +def test_format_skonto_text_returns_empty_when_no_days(): + assert format_skonto_text(2.0, "", "2026-01-15") == "" + assert format_skonto_text(2.0, None, "2026-01-15") == "" + + +def _discounted_invoice(): + data = _single_rate_invoice() + items = [{"total_amount": 200.0, "vat_rate_percent": 19.0, "name": "Beratung", + "description": "", "quantity": 2.0, "unit": "Std.", "unit_price": 100.0}] + totals = compute_invoice_totals(items, discount_percent=10.0) + data["items"] = items + data["tax_buckets"] = totals["tax_buckets"] + data["line_net_total"] = totals["line_net_total"] + data["allowance_amount"] = totals["allowance_amount"] + data["allowance_reason"] = "Treuerabatt" + data["total_net_amount"] = totals["tax_basis_total"] + data["total_vat_amount"] = totals["total_vat_amount"] + data["total_gross_amount"] = totals["total_gross_amount"] + data["skonto_percent"] = 2.0 + data["skonto_days"] = "10" + return data + + +def test_generate_compliant_xrechnung_includes_allowance_charge_and_payment_terms(): + xml_str = generate_compliant_xrechnung(_discounted_invoice()) + root = ET.fromstring(xml_str.encode("utf-8")) + ns = {"cbc": "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2", + "cac": "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"} + allowance = root.find(".//cac:AllowanceCharge", ns) + assert allowance.find("cbc:AllowanceChargeReason", ns).text == "Treuerabatt" + assert allowance.find("cbc:Amount", ns).text == "20.00" + payment_terms_note = root.find(".//cac:PaymentTerms/cbc:Note", ns).text + assert "Skonto" in payment_terms_note + + +def test_generate_compliant_zugferd_cii_includes_allowance_charge_and_payment_terms_description(): + xml_str = generate_compliant_zugferd_cii(_discounted_invoice()) + root = ET.fromstring(xml_str.encode("utf-8")) + ns = {"ram": "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"} + allowance = root.find(".//ram:SpecifiedTradeAllowanceCharge", ns) + assert allowance.find("ram:ActualAmount", ns).text == "20.00" + assert allowance.find("ram:Reason", ns).text == "Treuerabatt" + description = root.find(".//ram:SpecifiedTradePaymentTerms/ram:Description", ns).text + assert "Skonto" in description + + +def test_generate_compliant_xrechnung_mixed_rate_discount_emits_allowance_per_rate(): + """Voll EN16931-konform: ein Rabatt auf eine Rechnung mit 19%- UND 7%-Positionen erzeugt + JE Steuersatz einen eigenen AllowanceCharge-Block mit anteiligem Rabattbetrag.""" + items = [ + {"total_amount": 100.0, "vat_rate_percent": 19.0, "name": "A", "description": "", + "quantity": 1.0, "unit": "Stk.", "unit_price": 100.0}, + {"total_amount": 100.0, "vat_rate_percent": 7.0, "name": "B", "description": "", + "quantity": 1.0, "unit": "Stk.", "unit_price": 100.0}, + ] + totals = compute_invoice_totals(items, discount_percent=10.0) + data = _single_rate_invoice() + data["items"] = items + data["tax_buckets"] = totals["tax_buckets"] + data["line_net_total"] = totals["line_net_total"] + data["allowance_amount"] = totals["allowance_amount"] + data["allowance_reason"] = "Aktionsrabatt" + data["total_net_amount"] = totals["tax_basis_total"] + data["total_vat_amount"] = totals["total_vat_amount"] + data["total_gross_amount"] = totals["total_gross_amount"] + xml_str = generate_compliant_xrechnung(data) + root = ET.fromstring(xml_str.encode("utf-8")) + ns = {"cbc": "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2", + "cac": "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"} + allowances = root.findall(".//cac:AllowanceCharge", ns) + assert len(allowances) == 2 + percents = sorted(a.find("cac:TaxCategory/cbc:Percent", ns).text for a in allowances) + assert percents == ["19.00", "7.00"] or percents == ["7.00", "19.00"] + total_alloc = sum(float(a.find("cbc:Amount", ns).text) for a in allowances) + assert abs(total_alloc - 20.0) < 0.001