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, format_skonto_text, extract_service_period, detect_contract_type, extract_hardware_serial_numbers, ) 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_extract_invoice_items_pure_python_recognizes_person_days(): text = "1\nProjektarbeit\n5,00 Personentage\n800,00\n4000,00\n" items = extract_invoice_items_pure_python(text) assert items[0]["quantity"] == 5.0 assert items[0]["unit"] == "Personentage" def test_extract_invoice_items_pure_python_recognizes_license_months(): text = "1\nCloud-Lizenz\n3,00 Monat(e)\n49,00\n147,00\n" items = extract_invoice_items_pure_python(text) assert items[0]["unit"] == "Monat(e)" def test_map_unece_unit_code_extended_units(): assert map_unece_unit_code("Monat(e)") == "MON" assert map_unece_unit_code("Personentage") == "DAY" assert map_unece_unit_code("Lizenz(en)") == "C62" 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 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 def test_extract_service_period_finds_range(): text = "Managed Services\nLeistungszeitraum: 01.03.2026 bis 31.03.2026\n1\n..." start, end = extract_service_period(text) assert start == "2026-03-01" assert end == "2026-03-31" def test_extract_service_period_returns_empty_when_absent(): assert extract_service_period("Keine Zeitangabe hier.") == ("", "") def test_detect_contract_type_managed_services(): assert detect_contract_type("Wartungsvertrag Managed Services Tier 2") == "Managed Services" def test_detect_contract_type_cloud_abo(): assert detect_contract_type("Ihr Cloud-Abonnement für 2026") == "Cloud-Abonnement" def test_detect_contract_type_returns_empty_when_no_match(): assert detect_contract_type("Ganz normale Rechnung ohne Vertragstyp") == "" def test_extract_hardware_serial_numbers_finds_sn_pattern(): text = "Server Dell R750\nS/N: ABCD-1234-XYZ\nSwitch Cisco\nSeriennummer: SN9988771" serials = extract_hardware_serial_numbers(text) assert "ABCD-1234-XYZ" in serials assert "SN9988771" in serials def test_extract_hardware_serial_numbers_empty_when_absent(): assert extract_hardware_serial_numbers("Keine Seriennummern hier.") == [] def test_full_invoice_with_all_phase1_features_combined(): """Realistische IT-Systemhaus-Rechnung: gemischte Steuersaetze, Rabatt, Skonto, dynamischer Zahlungsmittelcode und IT-spezifische Abrechnungseinheiten - alles zusammen.""" items = [ {"name": "Managed-Services-Pauschale", "description": "", "quantity": 1.0, "unit": "Monat(e)", "unit_price": 500.0, "total_amount": 500.0, "vat_rate_percent": 19.0}, {"name": "Fachliteratur", "description": "", "quantity": 1.0, "unit": "Stk.", "unit_price": 40.0, "total_amount": 40.0, "vat_rate_percent": 7.0}, ] totals = compute_invoice_totals(items, discount_percent=5.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"] = "Neukundenrabatt" 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"] = "14" data["payment_means_code"] = "31" ubl_xml = generate_compliant_xrechnung(data) ubl_root = ET.fromstring(ubl_xml.encode("utf-8")) ubl_ns = {"cbc": "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2", "cac": "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"} assert len(ubl_root.findall(".//cac:TaxTotal/cac:TaxSubtotal", ubl_ns)) == 2 assert ubl_root.find(".//cac:PaymentMeans/cbc:PaymentMeansCode", ubl_ns).text == "31" ubl_allowances = ubl_root.findall(".//cac:AllowanceCharge", ubl_ns) assert len(ubl_allowances) == 2 # ein Rabatt-Block je Steuersatz (19% und 7%) assert abs(sum(float(a.find("cbc:Amount", ubl_ns).text) for a in ubl_allowances) - 27.0) < 0.001 assert "Skonto" in ubl_root.find(".//cac:PaymentTerms/cbc:Note", ubl_ns).text cii_xml = generate_compliant_zugferd_cii(data) cii_root = ET.fromstring(cii_xml.encode("utf-8")) cii_ns = {"ram": "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"} assert len(cii_root.findall(".//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax", cii_ns)) == 2 assert cii_root.find(".//ram:SpecifiedTradeSettlementPaymentMeans/ram:TypeCode", cii_ns).text == "31" cii_allowances = cii_root.findall(".//ram:SpecifiedTradeAllowanceCharge", cii_ns) assert len(cii_allowances) == 2 # ein Rabatt-Block je Steuersatz (19% und 7%) assert abs(sum(float(a.find("ram:ActualAmount", cii_ns).text) for a in cii_allowances) - 27.0) < 0.001