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
1192 lines
44 KiB
Python
1192 lines
44 KiB
Python
"""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
|