feat: add document-level discount (BT-21) and Skonto payment terms (BT-20)

This commit is contained in:
dfuhrmann3 2026-07-07 15:10:35 +02:00
parent d7b851ae36
commit 0dd9916079
3 changed files with 185 additions and 8 deletions

View File

@ -89,6 +89,31 @@ def berechne_faelligkeit(invoice_date_str, term_days):
return (base_date + timedelta(days=days)).strftime("%Y-%m-%d") 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): def kaufmaennisches_runden(betrag, nachkommastellen=2):
if not betrag: if not betrag:
return 0.0 return 0.0
@ -384,6 +409,33 @@ def generate_compliant_xrechnung(data):
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-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) --- # --- STEUERN (Gezielt erzwungener CBC Namespace für Beträge) ---
tax_total = ET.SubElement(root, f"{{{CAC}}}TaxTotal") tax_total = ET.SubElement(root, f"{{{CAC}}}TaxTotal")
ET.SubElement(tax_total, f"{{{CBC}}}TaxAmount", {"currencyID": "EUR"}).text = f"{data['total_vat_amount']:.2f}" 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") 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"]) 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 # BT-21 Dokumentenweiter Rabatt (BG-20). Muss laut CII-Schema NACH BillingSpecifiedPeriod und
# allein BR-CO-25 (Fälligkeitsdatum ODER Zahlungsbedingungen - wir nutzen nur das Datum). # VOR SpecifiedTradePaymentTerms stehen. Voll EN16931-konform: bei gemischten Steuersaetzen wird
# Kein fester Fallback: ohne gültiges Zahlungsziel wird der Block weggelassen (die UI blockiert # JE Steuersatz ein eigener SpecifiedTradeAllowanceCharge-Block mit dem anteilig zugeordneten
# dann den Export). # 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")) _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") payment_terms = ET.SubElement(header_settle, f"{{{RAM}}}SpecifiedTradePaymentTerms")
due_date_elem = ET.SubElement(payment_terms, f"{{{RAM}}}DueDateDateTime") if _skonto_text:
ET.SubElement(due_date_elem, f"{{{UDT}}}DateTimeString", {"format": "102"}).text = _cii_due.replace("-", "") 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") 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}" ET.SubElement(header_sum, f"{{{RAM}}}LineTotalAmount").text = f"{data.get('line_net_total', data['total_net_amount']):.2f}"

15
main.py
View File

@ -330,7 +330,17 @@ with col2:
# pro Position mit dem jeweils eingetragenen Steuersatz (Multi-Tax-Support). # 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)) 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_net_amount = totals["tax_basis_total"]
computed_vat_amount = totals["total_vat_amount"] computed_vat_amount = totals["total_vat_amount"]
computed_gross_amount = totals["total_gross_amount"] computed_gross_amount = totals["total_gross_amount"]
@ -360,6 +370,9 @@ with col2:
"tax_buckets": totals["tax_buckets"], "tax_buckets": totals["tax_buckets"],
"line_net_total": totals["line_net_total"], "line_net_total": totals["line_net_total"],
"allowance_amount": totals["allowance_amount"], "allowance_amount": totals["allowance_amount"],
"allowance_reason": discount_reason,
"skonto_percent": skonto_percent,
"skonto_days": skonto_days,
"delivery_note_id": dn_id, "delivery_note_id": dn_id,
"order_id": o_id, "order_id": o_id,
"contract_id": c_id, "contract_id": c_id,

View File

@ -14,6 +14,7 @@ from invoice_logic import (
PAYMENT_MEANS_CODES, PAYMENT_MEANS_CODES,
aggregate_tax_subtotals, aggregate_tax_subtotals,
compute_invoice_totals, 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( header_taxes = root.findall(
".//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax", ns) ".//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax", ns)
assert len(header_taxes) == 2 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