feat(tests): fixture sweep, anonymized real-invoice regression corpus, CI
- test_fixture_sweep: extract+validate every fixture PDF on every run; known extractor/validator gaps are catalogued explicitly and the sweep fails on any deviation from that catalogue - tests/fixtures/real/: anonymized XML from real production invoices that revealed bugs (verified to contain no real-world data); first entry locks in the FC-tax-number/basis-quantity/spaced-VAT-ID regressions - CI workflow: uv sync --frozen from lockfile + pytest + docker build
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies from lockfile
|
||||
run: uv sync --extra dev --frozen
|
||||
|
||||
- name: Run tests
|
||||
run: uv run --extra dev pytest -q
|
||||
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker build -t zugferd-service:ci .
|
||||
Vendored
+11
@@ -18,6 +18,17 @@ This directory contains sample PDFs for testing ZUGFeRD extraction and validatio
|
||||
| ORDER-X_EX01_ORDER_FULL_DATA-COMFORT.pdf | ORDER-X | Order-X format (related to ZUGFeRD but for orders) |
|
||||
| EmptyPDFA1.pdf | None | Empty PDF/A-1 document - no ZUGFeRD data (negative test case) |
|
||||
|
||||
## Real-World Invoices (anonymized) - `real/`
|
||||
|
||||
Anonymized XML from real production invoices that once revealed service bugs.
|
||||
Each file must contain **no real names, addresses, tax numbers, bank details,
|
||||
or document numbers** (verified at creation time). Never commit original
|
||||
invoice PDFs here.
|
||||
|
||||
| Filename | Profile | Origin bug |
|
||||
|-----------|----------|------------|
|
||||
| extended_fc_steuernummer_basisqty.xml | EXTENDED | FC-only tax registration ignored, unit price with basis quantity 100 misread, spaced VAT ID rejected, contact email dropped (2026-09) |
|
||||
|
||||
## Profile Coverage
|
||||
|
||||
- **MINIMUM**: Not covered (future addition)
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
<?xml version="1.0"?>
|
||||
<rsm:CrossIndustryInvoice xmlns:a="urn:un:unece:uncefact:data:standard:QualifiedDataType:100" xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100" xmlns:qdt="urn:un:unece:uncefact:data:standard:QualifiedDataType:100" xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100">
|
||||
<rsm:ExchangedDocumentContext>
|
||||
<ram:GuidelineSpecifiedDocumentContextParameter>
|
||||
<ram:ID>urn:cen.eu:en16931:2017#conformant#urn:factur-x.eu:1p0:extended</ram:ID>
|
||||
</ram:GuidelineSpecifiedDocumentContextParameter>
|
||||
</rsm:ExchangedDocumentContext>
|
||||
<rsm:ExchangedDocument>
|
||||
<ram:ID>4711</ram:ID>
|
||||
<ram:TypeCode>380</ram:TypeCode>
|
||||
<ram:IssueDateTime>
|
||||
<udt:DateTimeString format="102">20260903</udt:DateTimeString>
|
||||
</ram:IssueDateTime>
|
||||
<ram:IncludedNote>
|
||||
<ram:Content>Beispielwerkstatt Musterland e.V.
|
||||
Werkstatt Musterstadt/Musterdorf
|
||||
Musterstraße 11
|
||||
01234 Musterstadt
|
||||
Deutschland
|
||||
Steuernummer: 1112223334444</ram:Content>
|
||||
<ram:SubjectCode>REG</ram:SubjectCode>
|
||||
</ram:IncludedNote>
|
||||
</rsm:ExchangedDocument>
|
||||
<rsm:SupplyChainTradeTransaction>
|
||||
<ram:IncludedSupplyChainTradeLineItem>
|
||||
<ram:AssociatedDocumentLineDocument>
|
||||
<ram:LineID>1</ram:LineID>
|
||||
</ram:AssociatedDocumentLineDocument>
|
||||
<ram:SpecifiedTradeProduct>
|
||||
<ram:SellerAssignedID>ART-0001</ram:SellerAssignedID>
|
||||
<ram:BuyerAssignedID>KUNDE-0001</ram:BuyerAssignedID>
|
||||
<ram:Name>Befestigungsset Typ M100</ram:Name>
|
||||
<ram:Description>KUNDE-0001</ram:Description>
|
||||
</ram:SpecifiedTradeProduct>
|
||||
<ram:SpecifiedLineTradeAgreement>
|
||||
<ram:BuyerOrderReferencedDocument>
|
||||
<ram:IssuerAssignedID>BEST-000001</ram:IssuerAssignedID>
|
||||
</ram:BuyerOrderReferencedDocument>
|
||||
<ram:NetPriceProductTradePrice>
|
||||
<ram:ChargeAmount>75.85</ram:ChargeAmount>
|
||||
<ram:BasisQuantity unitCode="C62">100</ram:BasisQuantity>
|
||||
</ram:NetPriceProductTradePrice>
|
||||
</ram:SpecifiedLineTradeAgreement>
|
||||
<ram:SpecifiedLineTradeDelivery>
|
||||
<ram:BilledQuantity unitCode="C62">140</ram:BilledQuantity>
|
||||
<ram:ActualDeliverySupplyChainEvent>
|
||||
<ram:OccurrenceDateTime>
|
||||
<udt:DateTimeString format="102">20260804</udt:DateTimeString>
|
||||
</ram:OccurrenceDateTime>
|
||||
</ram:ActualDeliverySupplyChainEvent>
|
||||
<ram:DeliveryNoteReferencedDocument>
|
||||
<ram:IssuerAssignedID>LS-000001</ram:IssuerAssignedID>
|
||||
<ram:LineID>1</ram:LineID>
|
||||
<ram:FormattedIssueDateTime>
|
||||
<qdt:DateTimeString format="102">20260804</qdt:DateTimeString>
|
||||
</ram:FormattedIssueDateTime>
|
||||
</ram:DeliveryNoteReferencedDocument>
|
||||
</ram:SpecifiedLineTradeDelivery>
|
||||
<ram:SpecifiedLineTradeSettlement>
|
||||
<ram:ApplicableTradeTax>
|
||||
<ram:TypeCode>VAT</ram:TypeCode>
|
||||
<ram:CategoryCode>S</ram:CategoryCode>
|
||||
<ram:RateApplicablePercent>7</ram:RateApplicablePercent>
|
||||
</ram:ApplicableTradeTax>
|
||||
<ram:SpecifiedTradeAllowanceCharge>
|
||||
<ram:ChargeIndicator>
|
||||
<udt:Indicator>true</udt:Indicator>
|
||||
</ram:ChargeIndicator>
|
||||
<ram:ActualAmount>0</ram:ActualAmount>
|
||||
<ram:Reason>GK-Zuschlag</ram:Reason>
|
||||
</ram:SpecifiedTradeAllowanceCharge>
|
||||
<ram:SpecifiedTradeAllowanceCharge>
|
||||
<ram:ChargeIndicator>
|
||||
<udt:Indicator>false</udt:Indicator>
|
||||
</ram:ChargeIndicator>
|
||||
<ram:ActualAmount>0</ram:ActualAmount>
|
||||
<ram:ReasonCode>71</ram:ReasonCode>
|
||||
<ram:Reason>Mengenrabatt</ram:Reason>
|
||||
</ram:SpecifiedTradeAllowanceCharge>
|
||||
<ram:SpecifiedTradeAllowanceCharge>
|
||||
<ram:ChargeIndicator>
|
||||
<udt:Indicator>false</udt:Indicator>
|
||||
</ram:ChargeIndicator>
|
||||
<ram:ActualAmount>0</ram:ActualAmount>
|
||||
<ram:Reason>Kundenrabatt</ram:Reason>
|
||||
</ram:SpecifiedTradeAllowanceCharge>
|
||||
<ram:SpecifiedTradeSettlementLineMonetarySummation>
|
||||
<ram:LineTotalAmount>106.19</ram:LineTotalAmount>
|
||||
</ram:SpecifiedTradeSettlementLineMonetarySummation>
|
||||
</ram:SpecifiedLineTradeSettlement>
|
||||
</ram:IncludedSupplyChainTradeLineItem>
|
||||
<ram:IncludedSupplyChainTradeLineItem>
|
||||
<ram:AssociatedDocumentLineDocument>
|
||||
<ram:LineID>2</ram:LineID>
|
||||
</ram:AssociatedDocumentLineDocument>
|
||||
<ram:SpecifiedTradeProduct>
|
||||
<ram:SellerAssignedID>ART-0001</ram:SellerAssignedID>
|
||||
<ram:BuyerAssignedID>KUNDE-0001</ram:BuyerAssignedID>
|
||||
<ram:Name>Befestigungsset Typ M100</ram:Name>
|
||||
<ram:Description>KUNDE-0001</ram:Description>
|
||||
</ram:SpecifiedTradeProduct>
|
||||
<ram:SpecifiedLineTradeAgreement>
|
||||
<ram:BuyerOrderReferencedDocument>
|
||||
<ram:IssuerAssignedID>BEST-000001</ram:IssuerAssignedID>
|
||||
</ram:BuyerOrderReferencedDocument>
|
||||
<ram:NetPriceProductTradePrice>
|
||||
<ram:ChargeAmount>75.85</ram:ChargeAmount>
|
||||
<ram:BasisQuantity unitCode="C62">100</ram:BasisQuantity>
|
||||
</ram:NetPriceProductTradePrice>
|
||||
</ram:SpecifiedLineTradeAgreement>
|
||||
<ram:SpecifiedLineTradeDelivery>
|
||||
<ram:BilledQuantity unitCode="C62">50</ram:BilledQuantity>
|
||||
<ram:ActualDeliverySupplyChainEvent>
|
||||
<ram:OccurrenceDateTime>
|
||||
<udt:DateTimeString format="102">20260804</udt:DateTimeString>
|
||||
</ram:OccurrenceDateTime>
|
||||
</ram:ActualDeliverySupplyChainEvent>
|
||||
<ram:DeliveryNoteReferencedDocument>
|
||||
<ram:IssuerAssignedID>LS-000001</ram:IssuerAssignedID>
|
||||
<ram:LineID>2</ram:LineID>
|
||||
<ram:FormattedIssueDateTime>
|
||||
<qdt:DateTimeString format="102">20260804</qdt:DateTimeString>
|
||||
</ram:FormattedIssueDateTime>
|
||||
</ram:DeliveryNoteReferencedDocument>
|
||||
</ram:SpecifiedLineTradeDelivery>
|
||||
<ram:SpecifiedLineTradeSettlement>
|
||||
<ram:ApplicableTradeTax>
|
||||
<ram:TypeCode>VAT</ram:TypeCode>
|
||||
<ram:CategoryCode>S</ram:CategoryCode>
|
||||
<ram:RateApplicablePercent>7</ram:RateApplicablePercent>
|
||||
</ram:ApplicableTradeTax>
|
||||
<ram:SpecifiedTradeAllowanceCharge>
|
||||
<ram:ChargeIndicator>
|
||||
<udt:Indicator>true</udt:Indicator>
|
||||
</ram:ChargeIndicator>
|
||||
<ram:ActualAmount>0</ram:ActualAmount>
|
||||
<ram:Reason>GK-Zuschlag</ram:Reason>
|
||||
</ram:SpecifiedTradeAllowanceCharge>
|
||||
<ram:SpecifiedTradeAllowanceCharge>
|
||||
<ram:ChargeIndicator>
|
||||
<udt:Indicator>false</udt:Indicator>
|
||||
</ram:ChargeIndicator>
|
||||
<ram:ActualAmount>0</ram:ActualAmount>
|
||||
<ram:ReasonCode>71</ram:ReasonCode>
|
||||
<ram:Reason>Mengenrabatt</ram:Reason>
|
||||
</ram:SpecifiedTradeAllowanceCharge>
|
||||
<ram:SpecifiedTradeAllowanceCharge>
|
||||
<ram:ChargeIndicator>
|
||||
<udt:Indicator>false</udt:Indicator>
|
||||
</ram:ChargeIndicator>
|
||||
<ram:ActualAmount>0</ram:ActualAmount>
|
||||
<ram:Reason>Kundenrabatt</ram:Reason>
|
||||
</ram:SpecifiedTradeAllowanceCharge>
|
||||
<ram:SpecifiedTradeSettlementLineMonetarySummation>
|
||||
<ram:LineTotalAmount>37.93</ram:LineTotalAmount>
|
||||
</ram:SpecifiedTradeSettlementLineMonetarySummation>
|
||||
</ram:SpecifiedLineTradeSettlement>
|
||||
</ram:IncludedSupplyChainTradeLineItem>
|
||||
<ram:ApplicableHeaderTradeAgreement>
|
||||
<ram:SellerTradeParty>
|
||||
<ram:ID>10001</ram:ID>
|
||||
<ram:Name>Beispielwerkstatt Musterland e.V.
|
||||
Werkstatt Musterstadt/Musterdorf</ram:Name>
|
||||
<ram:DefinedTradeContact>
|
||||
<ram:PersonName>Erika Mustermann</ram:PersonName>
|
||||
<ram:EmailURIUniversalCommunication>
|
||||
<ram:URIID>rechnung@beispielwerkstatt.example</ram:URIID>
|
||||
</ram:EmailURIUniversalCommunication>
|
||||
</ram:DefinedTradeContact>
|
||||
<ram:PostalTradeAddress>
|
||||
<ram:PostcodeCode>01234</ram:PostcodeCode>
|
||||
<ram:LineOne>Musterstraße 11</ram:LineOne>
|
||||
<ram:CityName>Musterstadt</ram:CityName>
|
||||
<ram:CountryID>DE</ram:CountryID>
|
||||
</ram:PostalTradeAddress>
|
||||
<ram:SpecifiedTaxRegistration>
|
||||
<ram:ID schemeID="FC">1112223334444</ram:ID>
|
||||
</ram:SpecifiedTaxRegistration>
|
||||
</ram:SellerTradeParty>
|
||||
<ram:BuyerTradeParty>
|
||||
<ram:Name>Beispiel Kauf GmbH</ram:Name>
|
||||
<ram:PostalTradeAddress>
|
||||
<ram:PostcodeCode>05432</ram:PostcodeCode>
|
||||
<ram:LineOne>Musterweg 2</ram:LineOne>
|
||||
<ram:CityName>Musterdorf</ram:CityName>
|
||||
<ram:CountryID>DE</ram:CountryID>
|
||||
</ram:PostalTradeAddress>
|
||||
<ram:SpecifiedTaxRegistration>
|
||||
<ram:ID schemeID="VA">DE 123 456 789</ram:ID>
|
||||
</ram:SpecifiedTaxRegistration>
|
||||
</ram:BuyerTradeParty>
|
||||
<ram:BuyerOrderReferencedDocument>
|
||||
<ram:IssuerAssignedID>BEST-000001</ram:IssuerAssignedID>
|
||||
</ram:BuyerOrderReferencedDocument>
|
||||
</ram:ApplicableHeaderTradeAgreement>
|
||||
<ram:ApplicableHeaderTradeDelivery>
|
||||
<ram:ShipToTradeParty>
|
||||
<ram:PostalTradeAddress>
|
||||
<ram:CountryID>DE</ram:CountryID>
|
||||
</ram:PostalTradeAddress>
|
||||
</ram:ShipToTradeParty>
|
||||
<ram:ActualDeliverySupplyChainEvent>
|
||||
<ram:OccurrenceDateTime>
|
||||
<udt:DateTimeString format="102">20260903</udt:DateTimeString>
|
||||
</ram:OccurrenceDateTime>
|
||||
</ram:ActualDeliverySupplyChainEvent>
|
||||
</ram:ApplicableHeaderTradeDelivery>
|
||||
<ram:ApplicableHeaderTradeSettlement>
|
||||
<ram:InvoiceCurrencyCode>EUR</ram:InvoiceCurrencyCode>
|
||||
<ram:SpecifiedTradeSettlementPaymentMeans>
|
||||
<ram:TypeCode>58</ram:TypeCode>
|
||||
<ram:PayeePartyCreditorFinancialAccount>
|
||||
<ram:IBANID>DE89370400440532013000</ram:IBANID>
|
||||
<ram:AccountName>Beispielwerkstatt Musterland e.V.
|
||||
Werkstatt Musterstadt/Musterdorf</ram:AccountName>
|
||||
</ram:PayeePartyCreditorFinancialAccount>
|
||||
<ram:PayeeSpecifiedCreditorFinancialInstitution>
|
||||
<ram:BICID>MARKDEF1100</ram:BICID>
|
||||
</ram:PayeeSpecifiedCreditorFinancialInstitution>
|
||||
</ram:SpecifiedTradeSettlementPaymentMeans>
|
||||
<ram:ApplicableTradeTax>
|
||||
<ram:CalculatedAmount>10.09</ram:CalculatedAmount>
|
||||
<ram:TypeCode>VAT</ram:TypeCode>
|
||||
<ram:BasisAmount>144.12</ram:BasisAmount>
|
||||
<ram:CategoryCode>S</ram:CategoryCode>
|
||||
<ram:RateApplicablePercent>7</ram:RateApplicablePercent>
|
||||
</ram:ApplicableTradeTax>
|
||||
<ram:SpecifiedTradePaymentTerms>
|
||||
<ram:Description>zahlbar innerhalb 14 Tage ab Rechnungsdatum netto</ram:Description>
|
||||
<ram:DueDateDateTime>
|
||||
<udt:DateTimeString format="102">20260917</udt:DateTimeString>
|
||||
</ram:DueDateDateTime>
|
||||
</ram:SpecifiedTradePaymentTerms>
|
||||
<ram:SpecifiedTradeSettlementHeaderMonetarySummation>
|
||||
<ram:LineTotalAmount>144.12</ram:LineTotalAmount>
|
||||
<ram:TaxBasisTotalAmount>144.12</ram:TaxBasisTotalAmount>
|
||||
<ram:TaxTotalAmount currencyID="EUR">10.09</ram:TaxTotalAmount>
|
||||
<ram:RoundingAmount>0</ram:RoundingAmount>
|
||||
<ram:GrandTotalAmount>154.21</ram:GrandTotalAmount>
|
||||
<ram:TotalPrepaidAmount>0</ram:TotalPrepaidAmount>
|
||||
<ram:DuePayableAmount>154.21</ram:DuePayableAmount>
|
||||
</ram:SpecifiedTradeSettlementHeaderMonetarySummation>
|
||||
</ram:ApplicableHeaderTradeSettlement>
|
||||
</rsm:SupplyChainTradeTransaction>
|
||||
</rsm:CrossIndustryInvoice>
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Regression sweep over all invoice fixtures.
|
||||
|
||||
Every fixture PDF is extracted and validated end-to-end on every test run.
|
||||
Known limitations of extractor and validator are catalogued explicitly in
|
||||
KNOWN_GAPS and VALIDATION_EXCLUDED below. The sweep fails when:
|
||||
|
||||
- any fixture stops extracting (exception),
|
||||
- a fixture that used to be clean produces new critical errors,
|
||||
- a fixture with known gaps produces more/different errors than catalogued,
|
||||
- a gap was fixed but its catalogue entry was not removed.
|
||||
|
||||
So: fix a gap, then remove its entry — the sweep enforces honest bookkeeping.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.extractor import extract_zugferd
|
||||
from src.models import ValidateRequest
|
||||
from src.validator import validate_invoice
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
ALL_CHECKS = ["pflichtfelder", "betraege", "ustid", "pdf_abgleich"]
|
||||
|
||||
EXPECTED_NON_ZUGFERD = {"EmptyPDFA1.pdf"}
|
||||
|
||||
VALIDATION_EXCLUDED = {
|
||||
"ORDER-X_EX01_ORDER_FULL_DATA-COMFORT.pdf": (
|
||||
"ORDER-X order document, not an invoice"
|
||||
),
|
||||
"zugferd_invoice.pdf": (
|
||||
"ZUGFeRD 1.0 uses a different XML namespace (not supported)"
|
||||
),
|
||||
}
|
||||
|
||||
KNOWN_GAPS: dict[str, set[str]] = {
|
||||
"EN16931_1_Teilrechnung.pdf": {
|
||||
"line_items[3].line_total",
|
||||
"totals.net",
|
||||
},
|
||||
"MustangBeispiel20221026.pdf": {
|
||||
"totals.net",
|
||||
},
|
||||
"validAvoir_FR_type380_BASICWL.pdf": {
|
||||
"line_items",
|
||||
"totals.net",
|
||||
"vat_id",
|
||||
},
|
||||
"zugferd_2p1_EXTENDED_PDFA-3A.pdf": {
|
||||
"totals.net",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _pdf_files() -> list[str]:
|
||||
return sorted(path.name for path in FIXTURES.glob("*.pdf"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename", _pdf_files())
|
||||
def test_fixture_sweep(filename: str) -> None:
|
||||
"""Extract and validate one fixture; deviations from the catalogue fail."""
|
||||
pdf_bytes = (FIXTURES / filename).read_bytes()
|
||||
result = extract_zugferd(pdf_bytes)
|
||||
|
||||
if filename in EXPECTED_NON_ZUGFERD:
|
||||
assert result.is_zugferd is False
|
||||
return
|
||||
|
||||
assert result.is_zugferd is True, f"{filename}: expected ZUGFeRD XML"
|
||||
assert result.xml_data is not None
|
||||
|
||||
if filename in VALIDATION_EXCLUDED:
|
||||
return
|
||||
|
||||
request = ValidateRequest(
|
||||
xml_data=result.xml_data.model_dump(),
|
||||
pdf_text=result.pdf_text,
|
||||
checks=ALL_CHECKS,
|
||||
)
|
||||
validation = validate_invoice(request)
|
||||
critical_fields = {error.field for error in validation.errors}
|
||||
expected_fields = KNOWN_GAPS.get(filename, set())
|
||||
|
||||
assert critical_fields == expected_fields, (
|
||||
f"{filename}: critical fields changed — update KNOWN_GAPS "
|
||||
f"(new state: {sorted(critical_fields)})"
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Regression tests from real-world invoices (anonymized).
|
||||
|
||||
Every test in this module originated from a production bug. When a new real
|
||||
invoice reveals a service bug: anonymize the invoice XML into
|
||||
tests/fixtures/real/, verify it contains no real-world data, and add a test
|
||||
here that locks in the correct behavior.
|
||||
"""
|
||||
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from pypdf import PdfWriter
|
||||
|
||||
from src.extractor import extract_zugferd
|
||||
from src.models import ValidateRequest
|
||||
from src.validator import validate_invoice
|
||||
|
||||
REAL_FIXTURES = Path(__file__).parent / "fixtures" / "real"
|
||||
ALL_CHECKS = ["pflichtfelder", "betraege", "ustid", "pdf_abgleich"]
|
||||
|
||||
|
||||
def _pdf_with_attachment(xml_bytes: bytes) -> bytes:
|
||||
"""Wrap invoice XML in a minimal PDF (no visual content)."""
|
||||
writer = PdfWriter()
|
||||
writer.add_blank_page(width=72, height=72)
|
||||
writer.add_attachment("factur-x.xml", xml_bytes)
|
||||
output = BytesIO()
|
||||
writer.write(output)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
class TestFcTaxNumberBasisQuantityInvoice:
|
||||
"""Anonymized EXTENDED invoice: FC-only tax registration (no VAT ID),
|
||||
unit price with basis quantity 100, spaced buyer VAT ID, seller email
|
||||
in DefinedTradeContact.
|
||||
|
||||
Regressions locked in (found 2026-09 on a real workshop invoice):
|
||||
supplier tax_number never extracted, unit price off by factor 100,
|
||||
spaced VAT ID rejected as invalid format, contact email dropped.
|
||||
"""
|
||||
|
||||
XML_PATH = REAL_FIXTURES / "extended_fc_steuernummer_basisqty.xml"
|
||||
|
||||
def test_extraction(self) -> None:
|
||||
pdf_bytes = _pdf_with_attachment(self.XML_PATH.read_bytes())
|
||||
result = extract_zugferd(pdf_bytes)
|
||||
|
||||
assert result.is_zugferd is True
|
||||
data = result.xml_data
|
||||
|
||||
assert data.invoice_number == "4711"
|
||||
assert data.supplier.tax_number == "1112223334444"
|
||||
assert data.supplier.vat_id is None
|
||||
assert data.supplier.email == "rechnung@beispielwerkstatt.example"
|
||||
assert data.buyer.vat_id == "DE 123 456 789"
|
||||
|
||||
assert len(data.line_items) == 2
|
||||
first, second = data.line_items
|
||||
assert first.quantity == 140
|
||||
assert first.unit_price == pytest.approx(0.7585)
|
||||
assert first.line_total == pytest.approx(106.19)
|
||||
assert second.quantity == 50
|
||||
assert second.unit_price == pytest.approx(0.7585)
|
||||
assert second.line_total == pytest.approx(37.93)
|
||||
|
||||
assert data.totals.net == pytest.approx(144.12)
|
||||
assert data.totals.vat_total == pytest.approx(10.09)
|
||||
assert data.totals.gross == pytest.approx(154.21)
|
||||
|
||||
def test_validation_all_checks_pass(self) -> None:
|
||||
pdf_bytes = _pdf_with_attachment(self.XML_PATH.read_bytes())
|
||||
result = extract_zugferd(pdf_bytes)
|
||||
|
||||
request = ValidateRequest(
|
||||
xml_data=result.xml_data.model_dump(),
|
||||
pdf_text=result.pdf_text,
|
||||
checks=ALL_CHECKS,
|
||||
)
|
||||
validation = validate_invoice(request)
|
||||
|
||||
assert validation.is_valid is True
|
||||
assert validation.errors == []
|
||||
Reference in New Issue
Block a user