Files
zugferd-service/tests/test_validator.py
m3tm3re 4791c91f06 feat(api): add validator, FastAPI app structure, and health endpoint
Wave 3 tasks complete:
- Task 7: Validator with 4 checks (pflichtfelder, betraege, ustid, pdf_abgleich)
- Task 8: FastAPI app with CORS, exception handlers, JSON logging
- Task 9: Health endpoint returning status and version

Features:
- validate_invoice() runs selected validation checks
- Exception handlers for ExtractionError and generic errors
- GET /health returns {status: healthy, version: 1.0.0}

Tests: 52 validator tests covering all validation rules
2026-02-04 19:57:12 +01:00

1192 lines
44 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for ZUGFeRD validator using TDD approach."""
import pytest
from src.models import (
XmlData,
Supplier,
Buyer,
LineItem,
Totals,
VatBreakdown,
PaymentTerms,
ErrorDetail,
ValidationResult,
ValidateRequest,
)
from src.validator import (
validate_pflichtfelder,
validate_betraege,
validate_ustid,
validate_pdf_abgleich,
validate_invoice,
)
class TestValidatePflichtfelder:
"""Tests for validate_pflichtfelder function."""
def test_complete_data_no_errors(self):
"""Complete data should pass validation."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
due_date="2024-02-15",
supplier=Supplier(
name="Test Supplier GmbH",
vat_id="DE123456789",
),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Test Product",
quantity=10.0,
unit="Stück",
unit_price=99.99,
line_total=999.90,
vat_rate=19.0,
)
],
totals=Totals(
line_total_sum=999.90,
net=999.90,
vat_total=189.98,
gross=1189.88,
vat_breakdown=[VatBreakdown(rate=19.0, base=999.90, amount=189.98)],
),
payment_terms=PaymentTerms(iban="DE89370400440532013000"),
)
errors = validate_pflichtfelder(xml_data)
assert len(errors) == 0
def test_missing_invoice_number_critical(self):
"""Missing invoice_number should produce critical error."""
xml_data = XmlData(
invoice_number="",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Test Product",
quantity=1.0,
unit="Stück",
unit_price=10.0,
line_total=10.0,
)
],
totals=Totals(line_total_sum=10.0, net=10.0, vat_total=0.0, gross=10.0),
)
errors = validate_pflichtfelder(xml_data)
assert any(e.field == "invoice_number" for e in errors)
assert any(e.severity == "critical" for e in errors)
assert any(e.error_code == "missing_required" for e in errors)
def test_missing_invoice_date_critical(self):
"""Missing invoice_date should produce critical error."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Test Product",
quantity=1.0,
unit="Stück",
unit_price=10.0,
line_total=10.0,
)
],
totals=Totals(line_total_sum=10.0, net=10.0, vat_total=0.0, gross=10.0),
)
errors = validate_pflichtfelder(xml_data)
assert any(e.field == "invoice_date" for e in errors)
assert any(e.severity == "critical" for e in errors)
def test_missing_supplier_name_critical(self):
"""Missing supplier.name should produce critical error."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
supplier=Supplier(name="", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Test Product",
quantity=1.0,
unit="Stück",
unit_price=10.0,
line_total=10.0,
)
],
totals=Totals(line_total_sum=10.0, net=10.0, vat_total=0.0, gross=10.0),
)
errors = validate_pflichtfelder(xml_data)
assert any(e.field == "supplier.name" for e in errors)
assert any(e.severity == "critical" for e in errors)
def test_missing_supplier_vat_id_critical(self):
"""Missing supplier.vat_id should produce critical error."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id=""),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Test Product",
quantity=1.0,
unit="Stück",
unit_price=10.0,
line_total=10.0,
)
],
totals=Totals(line_total_sum=10.0, net=10.0, vat_total=0.0, gross=10.0),
)
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)
def test_missing_buyer_name_critical(self):
"""Missing buyer.name should produce critical error."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name=""),
line_items=[
LineItem(
position=1,
description="Test Product",
quantity=1.0,
unit="Stück",
unit_price=10.0,
line_total=10.0,
)
],
totals=Totals(line_total_sum=10.0, net=10.0, vat_total=0.0, gross=10.0),
)
errors = validate_pflichtfelder(xml_data)
assert any(e.field == "buyer.name" for e in errors)
assert any(e.severity == "critical" for e in errors)
def test_missing_totals_net_critical(self):
"""Missing totals.net should produce critical error."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Test Product",
quantity=1.0,
unit="Stück",
unit_price=10.0,
line_total=10.0,
)
],
totals=Totals(line_total_sum=10.0, net=0.0, vat_total=0.0, gross=10.0),
)
errors = validate_pflichtfelder(xml_data)
assert any(e.field == "totals.net" for e in errors)
assert any(e.severity == "critical" for e in errors)
def test_missing_totals_gross_critical(self):
"""Missing totals.gross should produce critical error."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Test Product",
quantity=1.0,
unit="Stück",
unit_price=10.0,
line_total=10.0,
)
],
totals=Totals(line_total_sum=10.0, net=10.0, vat_total=0.0, gross=0.0),
)
errors = validate_pflichtfelder(xml_data)
assert any(e.field == "totals.gross" for e in errors)
assert any(e.severity == "critical" for e in errors)
def test_missing_totals_vat_total_critical(self):
"""Missing totals.vat_total should produce critical error."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Test Product",
quantity=1.0,
unit="Stück",
unit_price=10.0,
line_total=10.0,
)
],
totals=Totals(line_total_sum=10.0, net=10.0, vat_total=0.0, gross=10.0),
)
errors = validate_pflichtfelder(xml_data)
assert any(e.field == "totals.vat_total" for e in errors)
assert any(e.severity == "critical" for e in errors)
def test_missing_due_date_warning(self):
"""Missing due_date should produce warning, not critical error."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
due_date="",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Test Product",
quantity=1.0,
unit="Stück",
unit_price=10.0,
line_total=10.0,
)
],
totals=Totals(line_total_sum=10.0, net=10.0, vat_total=0.0, gross=10.0),
)
errors = validate_pflichtfelder(xml_data)
assert any(e.field == "due_date" for e in errors)
assert any(e.severity == "warning" for e in errors)
def test_missing_payment_iban_warning(self):
"""Missing payment_terms.iban should produce warning."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Test Product",
quantity=1.0,
unit="Stück",
unit_price=10.0,
line_total=10.0,
)
],
totals=Totals(line_total_sum=10.0, net=10.0, vat_total=0.0, gross=10.0),
payment_terms=PaymentTerms(iban=""),
)
errors = validate_pflichtfelder(xml_data)
assert any(e.field == "payment_terms.iban" for e in errors)
assert any(e.severity == "warning" for e in errors)
def test_missing_line_items_critical(self):
"""Empty line_items should produce critical error."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[],
totals=Totals(line_total_sum=0.0, net=0.0, vat_total=0.0, gross=0.0),
)
errors = validate_pflichtfelder(xml_data)
assert any(e.field == "line_items" for e in errors)
assert any(e.severity == "critical" for e in errors)
def test_missing_line_item_description_critical(self):
"""Missing line_item.description should produce critical error."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="",
quantity=1.0,
unit="Stück",
unit_price=10.0,
line_total=10.0,
)
],
totals=Totals(line_total_sum=10.0, net=10.0, vat_total=0.0, gross=10.0),
)
errors = validate_pflichtfelder(xml_data)
assert any(e.field == "line_items[0].description" for e in errors)
assert any(e.severity == "critical" for e in errors)
def test_missing_line_item_quantity_critical(self):
"""Missing line_item.quantity (zero) should produce critical error."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Test Product",
quantity=0.0,
unit="Stück",
unit_price=10.0,
line_total=10.0,
)
],
totals=Totals(line_total_sum=10.0, net=10.0, vat_total=0.0, gross=10.0),
)
errors = validate_pflichtfelder(xml_data)
assert any(e.field == "line_items[0].quantity" for e in errors)
assert any(e.severity == "critical" for e in errors)
def test_missing_line_item_unit_price_critical(self):
"""Missing line_item.unit_price (zero) should produce critical error."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Test Product",
quantity=1.0,
unit="Stück",
unit_price=0.0,
line_total=10.0,
)
],
totals=Totals(line_total_sum=10.0, net=10.0, vat_total=0.0, gross=10.0),
)
errors = validate_pflichtfelder(xml_data)
assert any(e.field == "line_items[0].unit_price" for e in errors)
assert any(e.severity == "critical" for e in errors)
def test_missing_line_item_line_total_critical(self):
"""Missing line_item.line_total (zero) should produce critical error."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Test Product",
quantity=1.0,
unit="Stück",
unit_price=10.0,
line_total=0.0,
)
],
totals=Totals(line_total_sum=10.0, net=10.0, vat_total=0.0, gross=10.0),
)
errors = validate_pflichtfelder(xml_data)
assert any(e.field == "line_items[0].line_total" for e in errors)
assert any(e.severity == "critical" for e in errors)
def test_missing_line_item_vat_rate_warning(self):
"""Missing line_item.vat_rate should produce warning."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Test Product",
quantity=1.0,
unit="Stück",
unit_price=10.0,
line_total=10.0,
)
],
totals=Totals(line_total_sum=10.0, net=10.0, vat_total=0.0, gross=10.0),
)
errors = validate_pflichtfelder(xml_data)
assert any(e.field == "line_items[0].vat_rate" for e in errors)
assert any(e.severity == "warning" for e in errors)
def test_multiple_critical_errors(self):
"""Multiple missing critical fields should all be reported."""
xml_data = XmlData(
invoice_number="",
invoice_date="",
supplier=Supplier(name="", vat_id=""),
buyer=Buyer(name=""),
line_items=[],
totals=Totals(line_total_sum=0.0, net=0.0, vat_total=0.0, gross=0.0),
)
errors = validate_pflichtfelder(xml_data)
critical_errors = [e for e in errors if e.severity == "critical"]
assert len(critical_errors) > 0
# Should include at least: invoice_number, invoice_date, supplier.name, etc.
class TestValidateBetraege:
"""Tests for validate_betraege function."""
def test_correct_calculations_no_errors(self):
"""Correct calculations should pass validation."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Product A",
quantity=10.0,
unit="Stück",
unit_price=9.99,
line_total=99.90,
vat_rate=19.0,
),
LineItem(
position=2,
description="Product B",
quantity=5.0,
unit="Stück",
unit_price=19.99,
line_total=99.95,
vat_rate=19.0,
),
],
totals=Totals(
line_total_sum=199.85,
net=199.85,
vat_total=37.97,
gross=237.82,
vat_breakdown=[VatBreakdown(rate=19.0, base=199.85, amount=37.97)],
),
)
errors = validate_betraege(xml_data)
assert len(errors) == 0
def test_line_total_mismatch(self):
"""line_total should equal quantity × unit_price."""
line = LineItem(
position=1,
description="Test Product",
quantity=10,
unit="Stück",
unit_price=9.99,
line_total=100.00, # Wrong: 10 × 9.99 = 99.90
)
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[line],
totals=Totals(line_total_sum=100.0, net=100.0, vat_total=0.0, gross=100.0),
)
errors = validate_betraege(xml_data)
assert any(e.error_code == "calculation_mismatch" for e in errors)
assert any(
"line_total" in e.field
for e in errors
if e.error_code == "calculation_mismatch"
)
def test_line_total_within_tolerance(self):
"""line_total should match within tolerance."""
line = LineItem(
position=1,
description="Test Product",
quantity=10,
unit="Stück",
unit_price=9.99,
line_total=99.91, # Close enough to 99.90 (within 0.01)
)
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[line],
totals=Totals(line_total_sum=99.91, net=99.91, vat_total=0.0, gross=99.91),
)
errors = validate_betraege(xml_data)
# No error should be raised for small differences within tolerance
line_total_errors = [
e
for e in errors
if "line_total" in str(e.field) and e.error_code == "calculation_mismatch"
]
assert len(line_total_errors) == 0
def test_totals_net_mismatch(self):
"""totals.net should equal sum of line_items.line_total."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Product A",
quantity=10,
unit="Stück",
unit_price=10.0,
line_total=100.0,
),
LineItem(
position=2,
description="Product B",
quantity=5,
unit="Stück",
unit_price=10.0,
line_total=50.0,
),
],
totals=Totals(
line_total_sum=150.0,
net=160.0, # Wrong: 100 + 50 = 150
vat_total=0.0,
gross=160.0,
),
)
errors = validate_betraege(xml_data)
assert any(e.field == "totals.net" for e in errors)
assert any(e.error_code == "calculation_mismatch" for e in errors)
def test_vat_breakdown_amount_mismatch(self):
"""vat_breakdown.amount should equal base × (rate/100)."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Product",
quantity=1,
unit="Stück",
unit_price=100.0,
line_total=100.0,
)
],
totals=Totals(
line_total_sum=100.0,
net=100.0,
vat_total=20.0,
gross=120.0,
vat_breakdown=[
VatBreakdown(
rate=19.0, base=100.0, amount=25.0
) # Wrong: 100 × 0.19 = 19.0
],
),
)
errors = validate_betraege(xml_data)
assert any("vat_breakdown" in e.field for e in errors)
assert any(e.error_code == "calculation_mismatch" for e in errors)
def test_totals_vat_total_mismatch(self):
"""totals.vat_total should equal sum of vat_breakdown.amount."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Product",
quantity=1,
unit="Stück",
unit_price=100.0,
line_total=100.0,
)
],
totals=Totals(
line_total_sum=100.0,
net=100.0,
vat_total=25.0, # Wrong: 19.0
gross=125.0,
vat_breakdown=[VatBreakdown(rate=19.0, base=100.0, amount=19.0)],
),
)
errors = validate_betraege(xml_data)
assert any(e.field == "totals.vat_total" for e in errors)
assert any(e.error_code == "calculation_mismatch" for e in errors)
def test_totals_gross_mismatch(self):
"""totals.gross should equal totals.net + totals.vat_total."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Product",
quantity=1,
unit="Stück",
unit_price=100.0,
line_total=100.0,
)
],
totals=Totals(
line_total_sum=100.0,
net=100.0,
vat_total=19.0,
gross=130.0, # Wrong: 100 + 19 = 119
vat_breakdown=[VatBreakdown(rate=19.0, base=100.0, amount=19.0)],
),
)
errors = validate_betraege(xml_data)
assert any(e.field == "totals.gross" for e in errors)
assert any(e.error_code == "calculation_mismatch" for e in errors)
def test_multiple_calculation_errors(self):
"""Multiple calculation errors should all be reported."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Product",
quantity=10,
unit="Stück",
unit_price=10.0,
line_total=150.0, # Wrong: 10 × 10 = 100
)
],
totals=Totals(
line_total_sum=150.0,
net=200.0, # Wrong: should be 150
vat_total=30.0, # Wrong: 150 × 0.19 = 28.5
gross=250.0, # Wrong: 200 + 30 = 230
vat_breakdown=[
VatBreakdown(
rate=19.0, base=150.0, amount=50.0
) # Wrong: 150 × 0.19 = 28.5
],
),
)
errors = validate_betraege(xml_data)
calc_errors = [e for e in errors if e.error_code == "calculation_mismatch"]
assert len(calc_errors) > 1
class TestValidateUstid:
"""Tests for validate_ustid function."""
def test_valid_german_vat_id(self):
"""Valid German VAT ID should pass."""
error = validate_ustid("DE123456789")
assert error is None
def test_valid_austrian_vat_id(self):
"""Valid Austrian VAT ID should pass."""
error = validate_ustid("ATU12345678")
assert error is None
def test_valid_swiss_vat_id_mwst(self):
"""Valid Swiss VAT ID with MWST suffix should pass."""
error = validate_ustid("CHE123456789MWST")
assert error is None
def test_valid_swiss_vat_id_tva(self):
"""Valid Swiss VAT ID with TVA suffix should pass."""
error = validate_ustid("CHE123456789TVA")
assert error is None
def test_valid_swiss_vat_id_iva(self):
"""Valid Swiss VAT ID with IVA suffix should pass."""
error = validate_ustid("CHE123456789IVA")
assert error is None
def test_invalid_german_too_short(self):
"""German VAT ID too short should fail."""
error = validate_ustid("DE12345")
assert error is not None
assert error.error_code == "invalid_format"
def test_invalid_german_too_long(self):
"""German VAT ID too long should fail."""
error = validate_ustid("DE1234567890")
assert error is not None
assert error.error_code == "invalid_format"
def test_invalid_austrian_wrong_format(self):
"""Austrian VAT ID without U should fail."""
error = validate_ustid("AT12345678")
assert error is not None
assert error.error_code == "invalid_format"
def test_invalid_swiss_wrong_suffix(self):
"""Swiss VAT ID with wrong suffix should fail."""
error = validate_ustid("CHE123456789XXX")
assert error is not None
assert error.error_code == "invalid_format"
def test_invalid_country_code(self):
"""Unknown country code should fail."""
error = validate_ustid("FR123456789")
assert error is not None
assert error.error_code == "invalid_format"
def test_empty_vat_id(self):
"""Empty VAT ID should fail."""
error = validate_ustid("")
assert error is not None
assert error.error_code == "invalid_format"
class TestValidatePdfAbgleich:
"""Tests for validate_pdf_abgleich function."""
def test_exact_match_invoice_number(self):
"""Exact match for invoice_number should pass."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Product",
quantity=1,
unit="Stück",
unit_price=100.0,
line_total=100.0,
)
],
totals=Totals(line_total_sum=100.0, net=100.0, vat_total=19.0, gross=119.0),
)
pdf_values = {"invoice_number": "INV001", "totals.gross": "119.00"}
errors = validate_pdf_abgleich(xml_data, pdf_values)
# No error for exact match
invoice_errors = [e for e in errors if e.field == "invoice_number"]
assert len(invoice_errors) == 0
def test_mismatch_invoice_number(self):
"""Mismatch in invoice_number should produce error."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Product",
quantity=1,
unit="Stück",
unit_price=100.0,
line_total=100.0,
)
],
totals=Totals(line_total_sum=100.0, net=100.0, vat_total=19.0, gross=119.0),
)
pdf_values = {"invoice_number": "INV002"}
errors = validate_pdf_abgleich(xml_data, pdf_values)
assert any(e.field == "invoice_number" for e in errors)
def test_match_totals_gross(self):
"""Matching totals.gross within tolerance should pass."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Product",
quantity=1,
unit="Stück",
unit_price=100.0,
line_total=100.0,
)
],
totals=Totals(line_total_sum=100.0, net=100.0, vat_total=19.0, gross=119.0),
)
pdf_values = {"totals.gross": "119.00"}
errors = validate_pdf_abgleich(xml_data, pdf_values)
gross_errors = [e for e in errors if e.field == "totals.gross"]
assert len(gross_errors) == 0
def test_mismatch_totals_gross(self):
"""Mismatch in totals.gross should produce error."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Product",
quantity=1,
unit="Stück",
unit_price=100.0,
line_total=100.0,
)
],
totals=Totals(line_total_sum=100.0, net=100.0, vat_total=19.0, gross=119.0),
)
pdf_values = {"totals.gross": "150.00"}
errors = validate_pdf_abgleich(xml_data, pdf_values)
assert any(e.field == "totals.gross" for e in errors)
def test_match_within_tolerance(self):
"""Values within tolerance should pass."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Product",
quantity=1,
unit="Stück",
unit_price=100.0,
line_total=100.0,
)
],
totals=Totals(line_total_sum=100.0, net=100.0, vat_total=19.0, gross=119.0),
)
# 119.01 is within 0.01 of 119.0
pdf_values = {"totals.gross": "119.01"}
errors = validate_pdf_abgleich(xml_data, pdf_values)
gross_errors = [e for e in errors if e.field == "totals.gross"]
assert len(gross_errors) == 0
def test_missing_pdf_value(self):
"""Missing PDF value should not produce error."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Product",
quantity=1,
unit="Stück",
unit_price=100.0,
line_total=100.0,
)
],
totals=Totals(line_total_sum=100.0, net=100.0, vat_total=19.0, gross=119.0),
)
pdf_values = {}
errors = validate_pdf_abgleich(xml_data, pdf_values)
# No errors when PDF values are missing (can't compare)
assert len(errors) == 0
def test_multiple_mismatches(self):
"""Multiple mismatches should all be reported."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Product",
quantity=1,
unit="Stück",
unit_price=100.0,
line_total=100.0,
)
],
totals=Totals(line_total_sum=100.0, net=100.0, vat_total=19.0, gross=119.0),
)
pdf_values = {
"invoice_number": "INV002",
"totals.gross": "150.00",
"totals.net": "120.00",
"totals.vat_total": "25.00",
}
errors = validate_pdf_abgleich(xml_data, pdf_values)
# Should have errors for all mismatches
assert len(errors) >= 3
class TestValidateInvoice:
"""Tests for validate_invoice function."""
def test_run_single_check(self):
"""Run single validation check."""
xml_data = XmlData(
invoice_number="",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Product",
quantity=1,
unit="Stück",
unit_price=100.0,
line_total=100.0,
)
],
totals=Totals(line_total_sum=100.0, net=100.0, vat_total=19.0, gross=119.0),
)
request = ValidateRequest(
xml_data=xml_data.model_dump(),
checks=["pflichtfelder"],
)
result = validate_invoice(request)
assert result.is_valid is False # Missing invoice_number
assert len(result.errors) > 0
assert result.summary is not None
def test_run_multiple_checks(self):
"""Run multiple validation checks."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Product",
quantity=10,
unit="Stück",
unit_price=10.0,
line_total=150.0, # Wrong: 10 × 10 = 100
)
],
totals=Totals(
line_total_sum=150.0,
net=200.0,
vat_total=30.0,
gross=250.0,
vat_breakdown=[VatBreakdown(rate=19.0, base=150.0, amount=50.0)],
),
)
request = ValidateRequest(
xml_data=xml_data.model_dump(),
checks=["pflichtfelder", "betraege", "ustid"],
)
result = validate_invoice(request)
assert result.is_valid is False
assert result.summary is not None
assert result.summary["total_checks"] == 3
# Should have errors from multiple checks
total_issues = len(result.errors) + len(result.warnings)
assert total_issues > 0
def test_all_checks_pass(self):
"""All checks passing should return is_valid=True."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
due_date="2024-02-15",
supplier=Supplier(
name="Test Supplier GmbH",
vat_id="DE123456789",
),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Product A",
quantity=10.0,
unit="Stück",
unit_price=9.99,
line_total=99.90,
vat_rate=19.0,
),
],
totals=Totals(
line_total_sum=99.90,
net=99.90,
vat_total=18.98,
gross=118.88,
vat_breakdown=[VatBreakdown(rate=19.0, base=99.90, amount=18.98)],
),
payment_terms=PaymentTerms(iban="DE89370400440532013000"),
)
request = ValidateRequest(
xml_data=xml_data.model_dump(),
checks=["pflichtfelder", "betraege"],
)
result = validate_invoice(request)
assert result.is_valid is True
assert len(result.errors) == 0
assert result.summary["checks_passed"] == 2
def test_warnings_not_critical(self):
"""Warnings should not make is_valid=False."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Product",
quantity=1,
unit="Stück",
unit_price=100.0,
line_total=100.0,
)
],
totals=Totals(line_total_sum=100.0, net=100.0, vat_total=19.0, gross=119.0),
)
# Missing due_date and payment_terms.iban are warnings
request = ValidateRequest(
xml_data=xml_data.model_dump(),
checks=["pflichtfelder"],
)
result = validate_invoice(request)
# Should have warnings but be valid
assert len(result.warnings) > 0
assert result.is_valid is True # No critical errors
def test_summary_counts_correctly(self):
"""Summary should correctly count checks and errors."""
xml_data = XmlData(
invoice_number="",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Product",
quantity=1,
unit="Stück",
unit_price=100.0,
line_total=150.0,
)
],
totals=Totals(
line_total_sum=150.0,
net=200.0,
vat_total=30.0,
gross=250.0,
vat_breakdown=[VatBreakdown(rate=19.0, base=150.0, amount=50.0)],
),
)
request = ValidateRequest(
xml_data=xml_data.model_dump(),
checks=["pflichtfelder", "betraege", "ustid"],
)
result = validate_invoice(request)
assert result.summary is not None
assert result.summary["total_checks"] == 3
assert "checks_passed" in result.summary
assert "checks_failed" in result.summary
assert "critical_errors" in result.summary
assert "warnings" in result.summary
def test_with_pdf_abgleich(self):
"""PDF comparison should work with validate_invoice."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Product",
quantity=1,
unit="Stück",
unit_price=100.0,
line_total=100.0,
)
],
totals=Totals(line_total_sum=100.0, net=100.0, vat_total=19.0, gross=119.0),
)
request = ValidateRequest(
xml_data=xml_data.model_dump(),
pdf_text="Invoice INV001 Total: 150.00", # Mismatch
checks=["pdf_abgleich"],
)
result = validate_invoice(request)
# Should have errors from PDF comparison
assert result.summary["total_checks"] == 1
def test_validation_time_populated(self):
"""validation_time_ms should be populated."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Product",
quantity=1,
unit="Stück",
unit_price=100.0,
line_total=100.0,
)
],
totals=Totals(line_total_sum=100.0, net=100.0, vat_total=19.0, gross=119.0),
)
request = ValidateRequest(
xml_data=xml_data.model_dump(),
checks=["pflichtfelder"],
)
result = validate_invoice(request)
assert result.validation_time_ms >= 0
def test_invalid_check_name_ignored(self):
"""Invalid check names should be ignored."""
xml_data = XmlData(
invoice_number="INV001",
invoice_date="2024-01-15",
supplier=Supplier(name="Test Supplier GmbH", vat_id="DE123456789"),
buyer=Buyer(name="Test Buyer AG"),
line_items=[
LineItem(
position=1,
description="Product",
quantity=1,
unit="Stück",
unit_price=100.0,
line_total=100.0,
)
],
totals=Totals(line_total_sum=100.0, net=100.0, vat_total=19.0, gross=119.0),
)
request = ValidateRequest(
xml_data=xml_data.model_dump(),
checks=["invalid_check"],
)
result = validate_invoice(request)
# Should handle gracefully - no errors from invalid check
assert result.summary["total_checks"] == 0
assert result.is_valid is True # No critical errors