feat: add document-level discount (BT-21) and Skonto payment terms (BT-20)
This commit is contained in:
parent
d7b851ae36
commit
0dd9916079
|
|
@ -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}"
|
||||
|
|
|
|||
15
main.py
15
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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue