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