E-Rechnung/tests/test_invoice_logic.py

218 lines
8.6 KiB
Python

from decimal import Decimal
import xml.etree.ElementTree as ET
from invoice_logic import (
berechne_faelligkeit,
kaufmaennisches_runden,
clean_german_float,
extract_invoice_items_pure_python,
map_unece_unit_code,
parse_to_strict_xml_date,
generate_compliant_xrechnung,
generate_compliant_zugferd_cii,
VERKAEUFER_IBAN,
PAYMENT_MEANS_CODES,
aggregate_tax_subtotals,
compute_invoice_totals,
)
def _single_rate_invoice():
"""Eine einfache Einzelsteuersatz-Rechnung, wie main.py sie vor Phase 1 erzeugt hat."""
items = [
{"name": "Beratung", "description": "", "quantity": 2.0, "unit": "Std.",
"unit_price": 100.0, "total_amount": 200.0},
]
return {
"invoice_type_code": "380",
"invoice_number": "R26-001",
"invoice_date": "2026-01-15",
"delivery_date": "",
"buyer_reference": "",
"customer_number": "",
"recipient_name": "Test GmbH",
"recipient_email": "kunde@example.com",
"recipient_street": "Musterweg 1",
"recipient_zip": "12345",
"recipient_city": "Berlin",
"items": items,
"total_net_amount": 200.0,
"total_vat_amount": 38.0,
"vat_rate_percent": 19.0,
"total_gross_amount": 238.0,
"iban": VERKAEUFER_IBAN,
"delivery_note_id": "",
"order_id": "",
"contract_id": "",
"service_start_date": "",
"service_end_date": "",
"payment_term_days": "14",
"invoice_note": "",
}
def test_generate_compliant_xrechnung_single_rate_unchanged():
xml_str = generate_compliant_xrechnung(_single_rate_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"}
assert root.find("cbc:ID", ns).text == "R26-001"
tax_amount = root.find(".//cac:TaxTotal/cbc:TaxAmount", ns).text
assert tax_amount == "38.00"
payable = root.find(".//cac:LegalMonetaryTotal/cbc:PayableAmount", ns).text
assert payable == "238.00"
def test_generate_compliant_zugferd_cii_single_rate_unchanged():
xml_str = generate_compliant_zugferd_cii(_single_rate_invoice())
root = ET.fromstring(xml_str.encode("utf-8"))
ns = {"ram": "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"}
grand_total = root.find(".//ram:SpecifiedTradeSettlementHeaderMonetarySummation/ram:GrandTotalAmount", ns).text
assert grand_total == "238.00"
def test_berechne_faelligkeit_basic():
assert berechne_faelligkeit("2026-01-15", "14") == "2026-01-29"
def test_kaufmaennisches_runden_half_up():
assert kaufmaennisches_runden(1.005) == 1.01
def test_clean_german_float_thousands_and_comma():
assert clean_german_float("1.234,56") == 1234.56
def test_extract_invoice_items_pure_python_basic():
text = "1\nBeratung\n2,00 Std.\n100,00\n200,00\n"
items = extract_invoice_items_pure_python(text)
assert items[0]["name"] == "Beratung"
assert items[0]["quantity"] == 2.0
assert items[0]["unit_price"] == 100.0
def test_map_unece_unit_code_hours():
assert map_unece_unit_code("Std.") == "HUR"
def test_parse_to_strict_xml_date_german_format():
assert parse_to_strict_xml_date("15.01.2026") == "2026-01-15"
def test_payment_means_codes_contains_expected_entries():
assert PAYMENT_MEANS_CODES["58"] == "SEPA-Überweisung"
assert set(["30", "31", "42", "48", "58"]).issubset(PAYMENT_MEANS_CODES.keys())
def test_generate_compliant_xrechnung_uses_dynamic_payment_means_code():
data = _single_rate_invoice()
data["payment_means_code"] = "31"
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"}
code = root.find(".//cac:PaymentMeans/cbc:PaymentMeansCode", ns).text
assert code == "31"
def test_generate_compliant_xrechnung_defaults_payment_means_code_to_58():
xml_str = generate_compliant_xrechnung(_single_rate_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"}
code = root.find(".//cac:PaymentMeans/cbc:PaymentMeansCode", ns).text
assert code == "58"
def test_generate_compliant_zugferd_cii_uses_dynamic_payment_means_code():
data = _single_rate_invoice()
data["payment_means_code"] = "48"
xml_str = generate_compliant_zugferd_cii(data)
root = ET.fromstring(xml_str.encode("utf-8"))
ns = {"ram": "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"}
code = root.find(".//ram:SpecifiedTradeSettlementPaymentMeans/ram:TypeCode", ns).text
assert code == "48"
def test_aggregate_tax_subtotals_single_rate():
items = [{"total_amount": 200.0, "vat_rate_percent": 19.0}]
buckets = aggregate_tax_subtotals(items)
assert buckets == [{"rate": 19.0, "category": "S", "taxable_amount": 200.0, "tax_amount": 38.0}]
def test_aggregate_tax_subtotals_mixed_rates():
items = [
{"total_amount": 100.0, "vat_rate_percent": 19.0},
{"total_amount": 50.0, "vat_rate_percent": 7.0},
{"total_amount": 30.0, "vat_rate_percent": 0.0},
]
buckets = aggregate_tax_subtotals(items)
assert buckets == [
{"rate": 0.0, "category": "E", "taxable_amount": 30.0, "tax_amount": 0.0},
{"rate": 7.0, "category": "S", "taxable_amount": 50.0, "tax_amount": 3.5},
{"rate": 19.0, "category": "S", "taxable_amount": 100.0, "tax_amount": 19.0},
]
def test_compute_invoice_totals_mixed_rates_no_discount():
items = [
{"total_amount": 100.0, "vat_rate_percent": 19.0},
{"total_amount": 50.0, "vat_rate_percent": 7.0},
]
totals = compute_invoice_totals(items)
assert totals["line_net_total"] == 150.0
assert totals["allowance_amount"] == 0.0
assert totals["tax_basis_total"] == 150.0
assert totals["total_vat_amount"] == 22.5
assert totals["total_gross_amount"] == 172.5
def test_compute_invoice_totals_single_rate_matches_old_behavior():
items = [{"total_amount": 200.0, "vat_rate_percent": 19.0}]
totals = compute_invoice_totals(items)
assert totals["tax_basis_total"] == 200.0
assert totals["total_vat_amount"] == 38.0
assert totals["total_gross_amount"] == 238.0
def _mixed_rate_invoice():
data = _single_rate_invoice()
items = [
{"name": "Beratung", "description": "", "quantity": 2.0, "unit": "Std.",
"unit_price": 100.0, "total_amount": 200.0, "vat_rate_percent": 19.0},
{"name": "Buch", "description": "", "quantity": 1.0, "unit": "Stk.",
"unit_price": 50.0, "total_amount": 50.0, "vat_rate_percent": 7.0},
]
totals = compute_invoice_totals(items)
data["items"] = items
data["tax_buckets"] = totals["tax_buckets"]
data["line_net_total"] = totals["line_net_total"]
data["allowance_amount"] = totals["allowance_amount"]
data["total_net_amount"] = totals["tax_basis_total"]
data["total_vat_amount"] = totals["total_vat_amount"]
data["total_gross_amount"] = totals["total_gross_amount"]
return data
def test_generate_compliant_xrechnung_mixed_rates_two_tax_subtotals():
xml_str = generate_compliant_xrechnung(_mixed_rate_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"}
subtotals = root.findall(".//cac:TaxTotal/cac:TaxSubtotal", ns)
assert len(subtotals) == 2
percents = sorted(st.find("cac:TaxCategory/cbc:Percent", ns).text for st in subtotals)
assert percents == ["19.00", "7.00"] or percents == ["7.00", "19.00"]
lines = root.findall(".//cac:InvoiceLine", ns)
line_percents = [ln.find(".//cbc:Percent", ns).text for ln in lines]
assert set(line_percents) == {"19.00", "7.00"}
def test_generate_compliant_zugferd_cii_mixed_rates_two_header_taxes():
xml_str = generate_compliant_zugferd_cii(_mixed_rate_invoice())
root = ET.fromstring(xml_str.encode("utf-8"))
ns = {"ram": "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"}
header_taxes = root.findall(
".//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax", ns)
assert len(header_taxes) == 2