diff --git a/README.md b/README.md index 863cdd9..98fea4c 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,7 @@ Extract ZUGFeRD data from a base64-encoded PDF file. "city": "Berlin", "country": "DE", "vat_id": "DE123456789", + "tax_number": null, "email": "billing@acme-corp.de" }, "buyer": { @@ -276,7 +277,7 @@ The service supports four validation checks: Validates that all critical invoice fields are present and non-empty: -- **Critical errors:** invoice_number, invoice_date, supplier.name, supplier.vat_id, buyer.name, totals.net, totals.gross, totals.vat_total, line_items array, line item fields +- **Critical errors:** invoice_number, invoice_date, supplier.name, supplier.vat_id (or supplier.tax_number), buyer.name, totals.net, totals.gross, totals.vat_total, line_items array, line item fields - **Warnings:** due_date, payment_terms.iban ### 2. betraege (Amount Calculations) @@ -470,7 +471,7 @@ PORT=8000 python -m src.main Check that all required fields are present: - invoice_number - invoice_date (YYYY-MM-DD format) -- supplier.name and supplier.vat_id +- supplier.name and supplier.vat_id (or supplier.tax_number — EN 16931 BR-CO-26 requires one of both) - buyer.name - Non-zero totals (net, gross, vat_total) - At least one line item with description, quantity, and unit_price diff --git a/src/extractor.py b/src/extractor.py index 92ded8a..b724eb7 100644 --- a/src/extractor.py +++ b/src/extractor.py @@ -104,11 +104,23 @@ def parse_supplier(xml_root: etree._Element) -> Supplier: "ram:ID[@schemeID='VA']/text()", namespaces=NAMESPACES, ) + tax_number = xml_root.xpath( + "//ram:ApplicableHeaderTradeAgreement/ram:SellerTradeParty/ram:SpecifiedTaxRegistration/" + "ram:ID[@schemeID='FC']/text()", + namespaces=NAMESPACES, + ) email = xml_root.xpath( "//ram:ApplicableHeaderTradeAgreement/ram:SellerTradeParty/" "ram:URIUniversalCommunication/ram:URIID/text()", namespaces=NAMESPACES, ) + # Fallback: email address from the seller's defined trade contact (BT-42) + if not email: + email = xml_root.xpath( + "//ram:ApplicableHeaderTradeAgreement/ram:SellerTradeParty/" + "ram:DefinedTradeContact/ram:EmailURIUniversalCommunication/ram:URIID/text()", + namespaces=NAMESPACES, + ) return Supplier( name=name[0] if name else "", @@ -117,6 +129,7 @@ def parse_supplier(xml_root: etree._Element) -> Supplier: city=city[0] if city else None, country=country[0] if country else None, vat_id=vat_id[0] if vat_id else None, + tax_number=tax_number[0] if tax_number else None, email=email[0] if email else None, ) @@ -159,6 +172,11 @@ def parse_buyer(xml_root: etree._Element) -> Buyer: "ram:ID[@schemeID='VA']/text()", namespaces=NAMESPACES, ) + tax_number = xml_root.xpath( + "//ram:ApplicableHeaderTradeAgreement/ram:BuyerTradeParty/ram:SpecifiedTaxRegistration/" + "ram:ID[@schemeID='FC']/text()", + namespaces=NAMESPACES, + ) return Buyer( name=name[0] if name else "", @@ -167,6 +185,7 @@ def parse_buyer(xml_root: etree._Element) -> Buyer: city=city[0] if city else None, country=country[0] if country else None, vat_id=vat_id[0] if vat_id else None, + tax_number=tax_number[0] if tax_number else None, ) @@ -209,6 +228,11 @@ def parse_line_items(xml_root: etree._Element) -> list[LineItem]: "./ram:SpecifiedLineTradeAgreement/ram:NetPriceProductTradePrice/ram:ChargeAmount/text()", namespaces=NAMESPACES, ) + basis_quantity = item_node.xpath( + "./ram:SpecifiedLineTradeAgreement/ram:NetPriceProductTradePrice/" + "ram:BasisQuantity/text()", + namespaces=NAMESPACES, + ) line_total = item_node.xpath( "./ram:SpecifiedLineTradeSettlement/ram:SpecifiedTradeSettlementLineMonetarySummation/" "ram:LineTotalAmount/text()", @@ -225,6 +249,13 @@ def parse_line_items(xml_root: etree._Element) -> list[LineItem]: unit = unit_code[0] if unit_code else "Stück" + # Basis quantity (BT-149): charge amount applies to N units (e.g. price per 100) + effective_unit_price = float(unit_price[0]) if unit_price else None + if effective_unit_price is not None and basis_quantity: + base = float(basis_quantity[0]) + if base > 0: + effective_unit_price = effective_unit_price / base + items.append( LineItem( position=position, @@ -235,7 +266,7 @@ def parse_line_items(xml_root: etree._Element) -> list[LineItem]: description=description[0] if description else "", quantity=float(quantity[0]) if quantity else 0.0, unit=unit, - unit_price=float(unit_price[0]) if unit_price else None, + unit_price=effective_unit_price, line_total=float(line_total[0]) if line_total else None, vat_rate=float(vat_rate[0]) if vat_rate else None, vat_amount=float(vat_amount[0]) if vat_amount else None, diff --git a/src/models.py b/src/models.py index 7901578..67baae3 100644 --- a/src/models.py +++ b/src/models.py @@ -24,6 +24,9 @@ class Supplier(BaseModel): city: str | None = Field(default=None, description="City") country: str | None = Field(default=None, description="Country code") vat_id: str | None = Field(default=None, description="VAT ID") + tax_number: str | None = Field( + default=None, description="Tax number (Steuernummer, BT-32)" + ) email: str | None = Field(default=None, description="Email address") @@ -36,6 +39,9 @@ class Buyer(BaseModel): city: str | None = Field(default=None, description="City") country: str | None = Field(default=None, description="Country code") vat_id: str | None = Field(default=None, description="VAT ID") + tax_number: str | None = Field( + default=None, description="Tax number (Steuernummer, BT-49)" + ) class VatBreakdown(BaseModel): diff --git a/src/validator.py b/src/validator.py index 9728ddd..75e5b1a 100644 --- a/src/validator.py +++ b/src/validator.py @@ -19,13 +19,13 @@ def validate_pflichtfelder(xml_data: XmlData) -> list[ErrorDetail]: """Check required fields are present.""" errors = [] - def add_error(field: str, severity: str) -> None: + def add_error(field: str, severity: str, message: str | None = None) -> None: errors.append( ErrorDetail( check="pflichtfelder", field=field, error_code="missing_required", - message=f"Required field '{field}' is missing or empty", + message=message or f"Required field '{field}' is missing or empty", severity=severity, ) ) @@ -40,8 +40,20 @@ def validate_pflichtfelder(xml_data: XmlData) -> list[ErrorDetail]: if not xml_data.supplier.name or not xml_data.supplier.name.strip(): add_error("supplier.name", "critical") - if not xml_data.supplier.vat_id or not xml_data.supplier.vat_id.strip(): - add_error("supplier.vat_id", "critical") + # EN 16931 BR-CO-26: seller identified by VAT ID (BT-31) OR tax number (BT-32) + has_vat_id = bool(xml_data.supplier.vat_id and xml_data.supplier.vat_id.strip()) + has_tax_number = bool( + xml_data.supplier.tax_number and xml_data.supplier.tax_number.strip() + ) + if not has_vat_id and not has_tax_number: + add_error( + "supplier.vat_id", + "critical", + message=( + "Supplier must have a VAT ID (supplier.vat_id) " + "or a tax number (supplier.tax_number)" + ), + ) if not xml_data.buyer.name or not xml_data.buyer.name.strip(): add_error("buyer.name", "critical") @@ -163,7 +175,8 @@ def validate_ustid(vat_id: str) -> ErrorDetail | None: severity="critical", ) - vat_id = vat_id.strip() + # Whitespace inside VAT IDs is formatting only (e.g. "DE 140 978 617") + vat_id = re.sub(r"\s+", "", vat_id) # German VAT ID: DE followed by 9 digits if vat_id.startswith("DE"): diff --git a/tests/test_extractor.py b/tests/test_extractor.py index a09bcac..faeda23 100644 --- a/tests/test_extractor.py +++ b/tests/test_extractor.py @@ -307,12 +307,11 @@ class TestXRechnungExtraction: """Test extraction from XRechnung PDFs with non-standard filenames.""" def test_xrechnung_by_xml_extension_fallback(self): - """Test XRechnung with 'xrechnung.xml' filename is detected via fallback. + """Test XRechnung with 'xrechnung.xml' filename is extracted correctly. - This test verifies the fallback mechanism when factur-x library - doesn't recognize non-standard filenames like 'xrechnung.xml'. - We create a synthetic PDF with a non-standard attachment name to - exercise the fallback code path. + Depending on the factur-x version, the library recognizes non-standard + attachment names itself or our fallback kicks in — both paths must + yield the same result, so we only assert our own behavior here. """ from io import BytesIO @@ -337,20 +336,14 @@ class TestXRechnungExtraction: pdf_writer.write(output) pdf_bytes = output.getvalue() - # Verify factur-x does NOT recognize it (returns False/None) - from facturx import get_xml_from_pdf - - facturx_result = get_xml_from_pdf(pdf_bytes, check_xsd=False) - assert facturx_result[0] is False, "factur-x should not recognize xrechnung.xml" - - # Verify our fallback function DOES find it + # Verify our fallback function finds it on its own fallback_result = _find_xml_attachment_fallback(pdf_bytes) assert fallback_result[0] == "xrechnung.xml", ( "Fallback should find xrechnung.xml" ) assert fallback_result[1] is not None, "Fallback should return XML content" - # Verify full extraction works via fallback + # Verify full extraction works, whichever path finds the XML result = extract_zugferd(pdf_bytes) assert result.is_zugferd is True @@ -358,3 +351,170 @@ class TestXRechnungExtraction: assert result.xml_data is not None assert result.xml_data.invoice_number is not None assert result.extraction_meta.xml_attachment_name == "xrechnung.xml" + + +TAX_REGISTRATION_XML_TEMPLATE = """ + + + + Test Seller GmbH + {seller_tax_registrations} + {seller_contact} + + + Test Buyer AG + {buyer_tax_registrations} + + + +""" + +VA_REGISTRATION = ( + "" + 'DE123456789' + "" +) +FC_REGISTRATION = ( + "" + '2281410000697' + "" +) +BUYER_FC_REGISTRATION = ( + "" + '9876543210' + "" +) +CONTACT_WITH_EMAIL = ( + "" + "" + "seller@example.de" + "" + "" +) + + +class TestTaxRegistrationExtraction: + """Test extraction of tax registrations (VAT ID vs. tax number).""" + + def _parse(self, seller_regs: str, buyer_regs: str, seller_contact: str = ""): + from lxml import etree + + from src.extractor import parse_buyer, parse_supplier + + xml = TAX_REGISTRATION_XML_TEMPLATE.format( + seller_tax_registrations=seller_regs, + buyer_tax_registrations=buyer_regs, + seller_contact=seller_contact, + ) + root = etree.fromstring(xml.encode("utf-8")) + return parse_supplier(root), parse_buyer(root) + + def test_seller_vat_id_and_tax_number(self): + """Seller with VA and FC registrations extracts both fields.""" + supplier, _ = self._parse(VA_REGISTRATION + FC_REGISTRATION, "") + + assert supplier.vat_id == "DE123456789" + assert supplier.tax_number == "2281410000697" + + def test_seller_tax_number_only(self): + """Seller with only FC registration (e.g. Kleinunternehmer/Werkstatt).""" + supplier, _ = self._parse(FC_REGISTRATION, "") + + assert supplier.vat_id is None + assert supplier.tax_number == "2281410000697" + + def test_seller_vat_id_only(self): + """Seller with only VA registration leaves tax_number empty.""" + supplier, _ = self._parse(VA_REGISTRATION, "") + + assert supplier.vat_id == "DE123456789" + assert supplier.tax_number is None + + def test_buyer_tax_number(self): + """Buyer with FC registration extracts tax_number.""" + _, buyer = self._parse(VA_REGISTRATION, BUYER_FC_REGISTRATION) + + assert buyer.vat_id is None + assert buyer.tax_number == "9876543210" + + def test_seller_email_from_trade_contact(self): + """Seller email is found in DefinedTradeContact (BT-42).""" + supplier, _ = self._parse( + VA_REGISTRATION, "", seller_contact=CONTACT_WITH_EMAIL + ) + + assert supplier.email == "seller@example.de" + + def test_seller_email_absent(self): + """No email elements leaves email as None.""" + supplier, _ = self._parse(VA_REGISTRATION, "") + + assert supplier.email is None + + +LINE_ITEM_XML_TEMPLATE = """ + + + + Flanschsicherungsset + + + + {charge_amount} + {basis_quantity} + + + + {billed_quantity} + + + + {line_total} + + + + +""" + + +class TestBasisQuantityExtraction: + """Test unit price normalization via BasisQuantity (BT-149).""" + + def _parse_items(self, basis_quantity: str): + from lxml import etree + + from src.extractor import parse_line_items + + xml = LINE_ITEM_XML_TEMPLATE.format( + charge_amount="75.85", + basis_quantity=basis_quantity, + billed_quantity="140", + line_total="106.19", + ) + root = etree.fromstring(xml.encode("utf-8")) + return parse_line_items(root) + + def test_unit_price_divided_by_basis_quantity(self): + """Charge amount per 100 units is normalized to per-unit price.""" + items = self._parse_items("100") + + assert len(items) == 1 + assert items[0].unit_price == pytest.approx(0.7585) + + def test_unit_price_without_basis_quantity(self): + """Without BasisQuantity the charge amount stays as-is.""" + items = self._parse_items("") + + assert len(items) == 1 + assert items[0].unit_price == pytest.approx(75.85) + + def test_unit_price_with_zero_basis_quantity(self): + """Zero BasisQuantity is ignored (division guard).""" + items = self._parse_items("0") + + assert len(items) == 1 + assert items[0].unit_price == pytest.approx(75.85) diff --git a/tests/test_models.py b/tests/test_models.py index 0c752bc..ba9def7 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -40,6 +40,7 @@ class TestSupplier: assert supplier.name == "ACME GmbH" assert supplier.street is None assert supplier.vat_id is None + assert supplier.tax_number is None def test_full_supplier(self): """Test Supplier with all fields.""" @@ -52,6 +53,7 @@ class TestSupplier: city="Musterstadt", country="DE", vat_id="DE123456789", + tax_number="2281410000697", email="info@acme.de", ) assert supplier.name == "ACME GmbH" @@ -60,6 +62,7 @@ class TestSupplier: assert supplier.city == "Musterstadt" assert supplier.country == "DE" assert supplier.vat_id == "DE123456789" + assert supplier.tax_number == "2281410000697" assert supplier.email == "info@acme.de" @@ -85,10 +88,12 @@ class TestBuyer: city="Kundenstadt", country="DE", vat_id="DE987654321", + tax_number="1234567890", ) assert buyer.name == "Customer AG" assert buyer.street == "Kundenweg 7" assert buyer.vat_id == "DE987654321" + assert buyer.tax_number == "1234567890" class TestVatBreakdown: diff --git a/tests/test_validator.py b/tests/test_validator.py index 3097008..c8eb3ff 100644 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -149,6 +149,58 @@ class TestValidatePflichtfelder: assert any(e.field == "supplier.vat_id" for e in errors) assert any(e.severity == "critical" for e in errors) + def test_tax_number_only_supplier_no_critical_error(self): + """Supplier with only a tax number (no VAT ID) should pass BR-CO-26.""" + xml_data = XmlData( + invoice_number="INV001", + invoice_date="2024-01-15", + supplier=Supplier( + name="Werkstatt gGmbH", + vat_id=None, + tax_number="2281410000697", + ), + buyer=Buyer(name="Test Buyer AG"), + line_items=[ + LineItem( + position=1, + description="Test Product", + quantity=2.0, + unit="HUR", + unit_price=5.0, + line_total=10.0, + vat_rate=7.0, + ) + ], + totals=Totals(line_total_sum=10.0, net=10.0, vat_total=0.7, gross=10.7), + ) + errors = validate_pflichtfelder(xml_data) + assert not any(e.field == "supplier.vat_id" for e in errors) + + def test_missing_vat_id_and_tax_number_critical(self): + """Supplier with neither VAT ID nor tax number stays critical.""" + xml_data = XmlData( + invoice_number="INV001", + invoice_date="2024-01-15", + supplier=Supplier(name="Test Supplier GmbH", vat_id="", tax_number=""), + buyer=Buyer(name="Test Buyer AG"), + line_items=[ + LineItem( + position=1, + description="Test Product", + quantity=2.0, + unit="HUR", + unit_price=5.0, + line_total=10.0, + vat_rate=7.0, + ) + ], + totals=Totals(line_total_sum=10.0, net=10.0, vat_total=0.7, gross=10.7), + ) + errors = validate_pflichtfelder(xml_data) + assert any(e.field == "supplier.vat_id" for e in errors) + assert any(e.severity == "critical" for e in errors) + assert any("tax number" in e.message for e in errors) + def test_missing_buyer_name_critical(self): """Missing buyer.name should produce critical error.""" xml_data = XmlData( @@ -720,6 +772,11 @@ class TestValidateUstid: error = validate_ustid("DE123456789") assert error is None + def test_valid_german_vat_id_with_spaces(self): + """German VAT ID with formatting spaces (e.g. 'DE 140 978 617') passes.""" + error = validate_ustid("DE 140 978 617") + assert error is None + def test_valid_austrian_vat_id(self): """Valid Austrian VAT ID should pass.""" error = validate_ustid("ATU12345678")