Allow zero-euro line item amounts
This commit is contained in:
+4
-4
@@ -8,7 +8,7 @@ import io
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from facturx import get_flavor, get_level, get_xml_from_pdf
|
from facturx import get_flavor, get_level, get_xml_from_pdf
|
||||||
from lxml import etree
|
import lxml.etree as etree
|
||||||
from pypdf import PdfReader
|
from pypdf import PdfReader
|
||||||
from pypdf.errors import PdfReadError, PyPdfError
|
from pypdf.errors import PdfReadError, PyPdfError
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ def extract_text_from_pdf(pdf_bytes: bytes) -> str:
|
|||||||
except (PdfReadError, PyPdfError) as e:
|
except (PdfReadError, PyPdfError) as e:
|
||||||
raise ExtractionError(
|
raise ExtractionError(
|
||||||
error_code="corrupt_pdf", message="Failed to read PDF", details=str(e)
|
error_code="corrupt_pdf", message="Failed to read PDF", details=str(e)
|
||||||
)
|
) from e
|
||||||
|
|
||||||
|
|
||||||
def parse_supplier(xml_root: etree._Element) -> Supplier:
|
def parse_supplier(xml_root: etree._Element) -> Supplier:
|
||||||
@@ -235,8 +235,8 @@ def parse_line_items(xml_root: etree._Element) -> list[LineItem]:
|
|||||||
description=description[0] if description else "",
|
description=description[0] if description else "",
|
||||||
quantity=float(quantity[0]) if quantity else 0.0,
|
quantity=float(quantity[0]) if quantity else 0.0,
|
||||||
unit=unit,
|
unit=unit,
|
||||||
unit_price=float(unit_price[0]) if unit_price else 0.0,
|
unit_price=float(unit_price[0]) if unit_price else None,
|
||||||
line_total=float(line_total[0]) if line_total else 0.0,
|
line_total=float(line_total[0]) if line_total else None,
|
||||||
vat_rate=float(vat_rate[0]) if vat_rate else None,
|
vat_rate=float(vat_rate[0]) if vat_rate else None,
|
||||||
vat_amount=float(vat_amount[0]) if vat_amount else None,
|
vat_amount=float(vat_amount[0]) if vat_amount else None,
|
||||||
)
|
)
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,7 @@
|
|||||||
"""Pydantic models for ZUGFeRD service."""
|
"""Pydantic models for ZUGFeRD service."""
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
@@ -76,8 +77,8 @@ class LineItem(BaseModel):
|
|||||||
description: str = Field(description="Item description")
|
description: str = Field(description="Item description")
|
||||||
quantity: float = Field(description="Quantity")
|
quantity: float = Field(description="Quantity")
|
||||||
unit: str = Field(description="Unit (human-readable)")
|
unit: str = Field(description="Unit (human-readable)")
|
||||||
unit_price: float = Field(description="Unit price")
|
unit_price: float | None = Field(default=None, description="Unit price")
|
||||||
line_total: float = Field(description="Line total amount")
|
line_total: float | None = Field(default=None, description="Line total amount")
|
||||||
vat_rate: float | None = Field(default=None, description="VAT rate percentage")
|
vat_rate: float | None = Field(default=None, description="VAT rate percentage")
|
||||||
vat_amount: float | None = Field(
|
vat_amount: float | None = Field(
|
||||||
default=None, description="VAT amount for this line"
|
default=None, description="VAT amount for this line"
|
||||||
|
|||||||
+20
-9
@@ -5,6 +5,7 @@ import time
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from src.models import (
|
from src.models import (
|
||||||
ErrorDetail,
|
ErrorDetail,
|
||||||
ValidateRequest,
|
ValidateRequest,
|
||||||
@@ -78,10 +79,10 @@ def validate_pflichtfelder(xml_data: XmlData) -> list[ErrorDetail]:
|
|||||||
if item.quantity == 0:
|
if item.quantity == 0:
|
||||||
add_error(f"{field_prefix}.quantity", "critical")
|
add_error(f"{field_prefix}.quantity", "critical")
|
||||||
|
|
||||||
if item.unit_price == 0:
|
if item.unit_price is None:
|
||||||
add_error(f"{field_prefix}.unit_price", "critical")
|
add_error(f"{field_prefix}.unit_price", "critical")
|
||||||
|
|
||||||
if item.line_total == 0:
|
if item.line_total is None:
|
||||||
add_error(f"{field_prefix}.line_total", "critical")
|
add_error(f"{field_prefix}.line_total", "critical")
|
||||||
|
|
||||||
if item.vat_rate is None:
|
if item.vat_rate is None:
|
||||||
@@ -94,7 +95,7 @@ def validate_betraege(xml_data: XmlData) -> list[ErrorDetail]:
|
|||||||
"""Check amount calculations are correct."""
|
"""Check amount calculations are correct."""
|
||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
def add_mismatch(field: str, expected: float, actual: float) -> None:
|
def add_mismatch(field: str, expected: float, actual: float | None) -> None:
|
||||||
errors.append(
|
errors.append(
|
||||||
ErrorDetail(
|
ErrorDetail(
|
||||||
check="betraege",
|
check="betraege",
|
||||||
@@ -107,16 +108,24 @@ def validate_betraege(xml_data: XmlData) -> list[ErrorDetail]:
|
|||||||
|
|
||||||
# Check line_total = quantity × unit_price
|
# Check line_total = quantity × unit_price
|
||||||
for idx, item in enumerate(xml_data.line_items):
|
for idx, item in enumerate(xml_data.line_items):
|
||||||
expected_line_total = item.quantity * item.unit_price
|
unit_price = item.unit_price
|
||||||
if not amounts_match(item.line_total, expected_line_total):
|
line_total = item.line_total
|
||||||
|
if unit_price is None or line_total is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
expected_line_total = item.quantity * unit_price
|
||||||
|
if not amounts_match(line_total, expected_line_total):
|
||||||
add_mismatch(
|
add_mismatch(
|
||||||
f"line_items[{idx}].line_total",
|
f"line_items[{idx}].line_total",
|
||||||
expected_line_total,
|
expected_line_total,
|
||||||
item.line_total,
|
line_total,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check totals.net = sum(line_items.line_total)
|
# Check totals.net = sum(line_items.line_total)
|
||||||
line_total_sum = sum(item.line_total for item in xml_data.line_items)
|
line_total_sum = 0.0
|
||||||
|
for item in xml_data.line_items:
|
||||||
|
if item.line_total is not None:
|
||||||
|
line_total_sum += item.line_total
|
||||||
if not amounts_match(xml_data.totals.net, line_total_sum):
|
if not amounts_match(xml_data.totals.net, line_total_sum):
|
||||||
add_mismatch("totals.net", line_total_sum, xml_data.totals.net)
|
add_mismatch("totals.net", line_total_sum, xml_data.totals.net)
|
||||||
|
|
||||||
@@ -280,12 +289,14 @@ def validate_invoice(request: ValidateRequest) -> ValidationResult:
|
|||||||
xml_data = XmlData(**request.xml_data)
|
xml_data = XmlData(**request.xml_data)
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
# Convert Pydantic validation errors to ValidationResult
|
# Convert Pydantic validation errors to ValidationResult
|
||||||
validation_errors = []
|
validation_errors: list[ErrorDetail] = []
|
||||||
for error in e.errors():
|
for error in e.errors():
|
||||||
|
loc = error["loc"]
|
||||||
|
field = str(loc[0]) if loc else None
|
||||||
validation_errors.append(
|
validation_errors.append(
|
||||||
ErrorDetail(
|
ErrorDetail(
|
||||||
check="schema_validation",
|
check="schema_validation",
|
||||||
field=error["loc"][0] if error["loc"] else None,
|
field=field,
|
||||||
error_code=error["type"],
|
error_code=error["type"],
|
||||||
message=error["msg"],
|
message=error["msg"],
|
||||||
severity="critical",
|
severity="critical",
|
||||||
|
|||||||
+12
-12
@@ -4,8 +4,10 @@ Tests are written following TDD: FAILING TESTS FIRST (RED phase),
|
|||||||
then implementation makes them pass (GREEN phase).
|
then implementation makes them pass (GREEN phase).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
|
||||||
import base64
|
import base64
|
||||||
|
import binascii
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from pypdf import PdfReader, PdfWriter
|
from pypdf import PdfReader, PdfWriter
|
||||||
|
|
||||||
@@ -49,7 +51,7 @@ class TestFileSizeValidation:
|
|||||||
|
|
||||||
def test_file_size_limit_exactly_10mb(self):
|
def test_file_size_limit_exactly_10mb(self):
|
||||||
"""Test PDF exactly at 10MB limit passes size check but fails PDF parsing."""
|
"""Test PDF exactly at 10MB limit passes size check but fails PDF parsing."""
|
||||||
from src.extractor import extract_zugferd, ExtractionError
|
from src.extractor import ExtractionError, extract_zugferd
|
||||||
|
|
||||||
# 10MB = 10 * 1024 * 1024 bytes
|
# 10MB = 10 * 1024 * 1024 bytes
|
||||||
large_pdf = b"X" * (10 * 1024 * 1024)
|
large_pdf = b"X" * (10 * 1024 * 1024)
|
||||||
@@ -63,7 +65,7 @@ class TestFileSizeValidation:
|
|||||||
|
|
||||||
def test_file_size_limit_10mb_plus_one_byte(self):
|
def test_file_size_limit_10mb_plus_one_byte(self):
|
||||||
"""Test PDF one byte over 10MB limit is rejected."""
|
"""Test PDF one byte over 10MB limit is rejected."""
|
||||||
from src.extractor import extract_zugferd, ExtractionError
|
from src.extractor import ExtractionError, extract_zugferd
|
||||||
|
|
||||||
# 10MB + 1 byte
|
# 10MB + 1 byte
|
||||||
too_large = b"X" * (10 * 1024 * 1024 + 1)
|
too_large = b"X" * (10 * 1024 * 1024 + 1)
|
||||||
@@ -75,7 +77,7 @@ class TestFileSizeValidation:
|
|||||||
|
|
||||||
def test_file_size_under_10mb_accepted(self):
|
def test_file_size_under_10mb_accepted(self):
|
||||||
"""Test PDF under 10MB is accepted for processing."""
|
"""Test PDF under 10MB is accepted for processing."""
|
||||||
from src.extractor import extract_zugferd, ExtractionError
|
from src.extractor import ExtractionError, extract_zugferd
|
||||||
|
|
||||||
# Small PDF (9MB)
|
# Small PDF (9MB)
|
||||||
small_pdf = b"X" * (9 * 1024 * 1024)
|
small_pdf = b"X" * (9 * 1024 * 1024)
|
||||||
@@ -166,8 +168,8 @@ class TestEN16931Extraction:
|
|||||||
assert first_item.description is not None and len(first_item.description) > 0
|
assert first_item.description is not None and len(first_item.description) > 0
|
||||||
assert first_item.quantity > 0
|
assert first_item.quantity > 0
|
||||||
assert first_item.unit is not None and len(first_item.unit) > 0
|
assert first_item.unit is not None and len(first_item.unit) > 0
|
||||||
assert first_item.unit_price > 0
|
assert first_item.unit_price is not None and first_item.unit_price > 0
|
||||||
assert first_item.line_total > 0
|
assert first_item.line_total is not None and first_item.line_total > 0
|
||||||
|
|
||||||
# Totals
|
# Totals
|
||||||
assert xml_data.totals.line_total_sum > 0
|
assert xml_data.totals.line_total_sum > 0
|
||||||
@@ -181,7 +183,7 @@ class TestErrorHandling:
|
|||||||
|
|
||||||
def test_corrupt_pdf_raises_error(self):
|
def test_corrupt_pdf_raises_error(self):
|
||||||
"""Test corrupt PDF raises ExtractionError with correct code."""
|
"""Test corrupt PDF raises ExtractionError with correct code."""
|
||||||
from src.extractor import extract_zugferd, ExtractionError
|
from src.extractor import ExtractionError, extract_zugferd
|
||||||
|
|
||||||
# Invalid PDF data
|
# Invalid PDF data
|
||||||
corrupt_pdf = b"NOT A PDF FILE AT ALL"
|
corrupt_pdf = b"NOT A PDF FILE AT ALL"
|
||||||
@@ -194,14 +196,14 @@ class TestErrorHandling:
|
|||||||
|
|
||||||
def test_empty_pdf_raises_error(self):
|
def test_empty_pdf_raises_error(self):
|
||||||
"""Test empty PDF raises ExtractionError."""
|
"""Test empty PDF raises ExtractionError."""
|
||||||
from src.extractor import extract_zugferd, ExtractionError
|
from src.extractor import ExtractionError, extract_zugferd
|
||||||
|
|
||||||
with pytest.raises(ExtractionError):
|
with pytest.raises(ExtractionError):
|
||||||
extract_zugferd(b"")
|
extract_zugferd(b"")
|
||||||
|
|
||||||
def test_invalid_base64(self):
|
def test_invalid_base64(self):
|
||||||
"""Test invalid base64 raises ExtractionError."""
|
"""Test invalid base64 raises ExtractionError."""
|
||||||
from src.extractor import extract_zugferd, ExtractionError
|
from src.extractor import ExtractionError, extract_zugferd
|
||||||
|
|
||||||
# This would be called by API layer, but we can test the concept
|
# This would be called by API layer, but we can test the concept
|
||||||
# Invalid PDF that's not valid base64-encoded
|
# Invalid PDF that's not valid base64-encoded
|
||||||
@@ -210,7 +212,7 @@ class TestErrorHandling:
|
|||||||
# If API layer decodes invalid base64, it gets error
|
# If API layer decodes invalid base64, it gets error
|
||||||
decoded = base64.b64decode(invalid_base64, validate=True)
|
decoded = base64.b64decode(invalid_base64, validate=True)
|
||||||
extract_zugferd(decoded)
|
extract_zugferd(decoded)
|
||||||
except (base64.binascii.Error, ValueError):
|
except (binascii.Error, ValueError):
|
||||||
# base64 error is expected
|
# base64 error is expected
|
||||||
pass
|
pass
|
||||||
except ExtractionError as e:
|
except ExtractionError as e:
|
||||||
@@ -232,8 +234,6 @@ class TestPDFTextExtraction:
|
|||||||
|
|
||||||
assert result.pdf_text is not None
|
assert result.pdf_text is not None
|
||||||
assert len(result.pdf_text) > 0
|
assert len(result.pdf_text) > 0
|
||||||
# Should contain some common German invoice terms
|
|
||||||
text_lower = result.pdf_text.lower()
|
|
||||||
# PDF text may contain invoice-related terms in German or English
|
# PDF text may contain invoice-related terms in German or English
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+32
-9
@@ -1,6 +1,5 @@
|
|||||||
"""Tests for ZUGFeRD validator using TDD approach."""
|
"""Tests for ZUGFeRD validator using TDD approach."""
|
||||||
|
|
||||||
import pytest
|
|
||||||
from src.models import (
|
from src.models import (
|
||||||
XmlData,
|
XmlData,
|
||||||
Supplier,
|
Supplier,
|
||||||
@@ -9,8 +8,6 @@ from src.models import (
|
|||||||
Totals,
|
Totals,
|
||||||
VatBreakdown,
|
VatBreakdown,
|
||||||
PaymentTerms,
|
PaymentTerms,
|
||||||
ErrorDetail,
|
|
||||||
ValidationResult,
|
|
||||||
ValidateRequest,
|
ValidateRequest,
|
||||||
)
|
)
|
||||||
from src.validator import (
|
from src.validator import (
|
||||||
@@ -352,8 +349,8 @@ class TestValidatePflichtfelder:
|
|||||||
assert any(e.field == "line_items[0].quantity" for e in errors)
|
assert any(e.field == "line_items[0].quantity" for e in errors)
|
||||||
assert any(e.severity == "critical" for e in errors)
|
assert any(e.severity == "critical" for e in errors)
|
||||||
|
|
||||||
def test_missing_line_item_unit_price_critical(self):
|
def test_zero_euro_line_item_amounts_allowed(self):
|
||||||
"""Missing line_item.unit_price (zero) should produce critical error."""
|
"""Zero-valued line item prices should count as present."""
|
||||||
xml_data = XmlData(
|
xml_data = XmlData(
|
||||||
invoice_number="INV001",
|
invoice_number="INV001",
|
||||||
invoice_date="2024-01-15",
|
invoice_date="2024-01-15",
|
||||||
@@ -366,6 +363,29 @@ class TestValidatePflichtfelder:
|
|||||||
quantity=1.0,
|
quantity=1.0,
|
||||||
unit="Stück",
|
unit="Stück",
|
||||||
unit_price=0.0,
|
unit_price=0.0,
|
||||||
|
line_total=0.0,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
totals=Totals(line_total_sum=10.0, net=10.0, vat_total=19.0, gross=29.0),
|
||||||
|
)
|
||||||
|
errors = validate_pflichtfelder(xml_data)
|
||||||
|
assert not any(e.field == "line_items[0].unit_price" for e in errors)
|
||||||
|
assert not any(e.field == "line_items[0].line_total" for e in errors)
|
||||||
|
|
||||||
|
def test_missing_line_item_unit_price_critical(self):
|
||||||
|
"""Missing line_item.unit_price 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=None,
|
||||||
line_total=10.0,
|
line_total=10.0,
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
@@ -376,7 +396,7 @@ class TestValidatePflichtfelder:
|
|||||||
assert any(e.severity == "critical" for e in errors)
|
assert any(e.severity == "critical" for e in errors)
|
||||||
|
|
||||||
def test_missing_line_item_line_total_critical(self):
|
def test_missing_line_item_line_total_critical(self):
|
||||||
"""Missing line_item.line_total (zero) should produce critical error."""
|
"""Missing line_item.line_total should produce critical error."""
|
||||||
xml_data = XmlData(
|
xml_data = XmlData(
|
||||||
invoice_number="INV001",
|
invoice_number="INV001",
|
||||||
invoice_date="2024-01-15",
|
invoice_date="2024-01-15",
|
||||||
@@ -389,7 +409,7 @@ class TestValidatePflichtfelder:
|
|||||||
quantity=1.0,
|
quantity=1.0,
|
||||||
unit="Stück",
|
unit="Stück",
|
||||||
unit_price=10.0,
|
unit_price=10.0,
|
||||||
line_total=0.0,
|
line_total=None,
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
totals=Totals(line_total_sum=10.0, net=10.0, vat_total=0.0, gross=10.0),
|
totals=Totals(line_total_sum=10.0, net=10.0, vat_total=0.0, gross=10.0),
|
||||||
@@ -499,7 +519,7 @@ class TestValidateBetraege:
|
|||||||
errors = validate_betraege(xml_data)
|
errors = validate_betraege(xml_data)
|
||||||
assert any(e.error_code == "calculation_mismatch" for e in errors)
|
assert any(e.error_code == "calculation_mismatch" for e in errors)
|
||||||
assert any(
|
assert any(
|
||||||
"line_total" in e.field
|
e.field is not None and "line_total" in e.field
|
||||||
for e in errors
|
for e in errors
|
||||||
if e.error_code == "calculation_mismatch"
|
if e.error_code == "calculation_mismatch"
|
||||||
)
|
)
|
||||||
@@ -597,7 +617,7 @@ class TestValidateBetraege:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
errors = validate_betraege(xml_data)
|
errors = validate_betraege(xml_data)
|
||||||
assert any("vat_breakdown" in e.field for e in errors)
|
assert any(e.field is not None and "vat_breakdown" in e.field for e in errors)
|
||||||
assert any(e.error_code == "calculation_mismatch" for e in errors)
|
assert any(e.error_code == "calculation_mismatch" for e in errors)
|
||||||
|
|
||||||
def test_totals_vat_total_mismatch(self):
|
def test_totals_vat_total_mismatch(self):
|
||||||
@@ -1040,6 +1060,7 @@ class TestValidateInvoice:
|
|||||||
result = validate_invoice(request)
|
result = validate_invoice(request)
|
||||||
assert result.is_valid is True
|
assert result.is_valid is True
|
||||||
assert len(result.errors) == 0
|
assert len(result.errors) == 0
|
||||||
|
assert result.summary is not None
|
||||||
assert result.summary["checks_passed"] == 2
|
assert result.summary["checks_passed"] == 2
|
||||||
|
|
||||||
def test_warnings_not_critical(self):
|
def test_warnings_not_critical(self):
|
||||||
@@ -1134,6 +1155,7 @@ class TestValidateInvoice:
|
|||||||
)
|
)
|
||||||
result = validate_invoice(request)
|
result = validate_invoice(request)
|
||||||
# Should have errors from PDF comparison
|
# Should have errors from PDF comparison
|
||||||
|
assert result.summary is not None
|
||||||
assert result.summary["total_checks"] == 1
|
assert result.summary["total_checks"] == 1
|
||||||
|
|
||||||
def test_validation_time_populated(self):
|
def test_validation_time_populated(self):
|
||||||
@@ -1187,5 +1209,6 @@ class TestValidateInvoice:
|
|||||||
)
|
)
|
||||||
result = validate_invoice(request)
|
result = validate_invoice(request)
|
||||||
# Should handle gracefully - no errors from invalid check
|
# Should handle gracefully - no errors from invalid check
|
||||||
|
assert result.summary is not None
|
||||||
assert result.summary["total_checks"] == 0
|
assert result.summary["total_checks"] == 0
|
||||||
assert result.is_valid is True # No critical errors
|
assert result.is_valid is True # No critical errors
|
||||||
|
|||||||
Reference in New Issue
Block a user