Files
TimeTracker/tests/test_cii_invoice.py
T
Dries Peeters 2e1c18a345 feat(invoicing): add Factur-X CII export validation and transport guidance
Switch embedded invoice PDFs to Factur-X CII payloads and tighten the PDF/A-3 and AS4 handling so exports better match the standards they advertise. Document the experimental native Peppol transport path and cover the new validation and embedding behavior with focused tests.
2026-03-06 22:15:29 +01:00

236 lines
7.2 KiB
Python

"""Tests for CII (Cross-Industry Invoice) generator for Factur-X / ZUGFeRD."""
from datetime import date, timedelta
from decimal import Decimal
from types import SimpleNamespace
import pytest
from app.utils.cii_invoice import CIIParty, build_cii_invoice_xml, FACTURX_GUIDELINE_EN16931
from app.utils.invoice_validators import validate_cii_wellformed, validate_cii_en16931
def _make_invoice(**overrides):
defaults = dict(
id=1,
invoice_number="INV-CII-001",
issue_date=date(2024, 3, 15),
due_date=date(2024, 4, 14),
currency_code="EUR",
subtotal=Decimal("200.00"),
tax_rate=Decimal("21.00"),
tax_amount=Decimal("42.00"),
total_amount=Decimal("242.00"),
notes="Test invoice notes",
buyer_reference="PO-99",
project=None,
client=None,
client_name="Buyer Inc",
client_email=None,
client_address=None,
items=[
SimpleNamespace(description="Consulting", quantity=Decimal("2"), unit_price=Decimal("100.00"), total_amount=Decimal("200.00")),
],
expenses=[],
extra_goods=[],
)
defaults.update(overrides)
return SimpleNamespace(**defaults)
def _make_seller(**overrides):
defaults = dict(
name="Seller GmbH",
tax_id="DE123456789",
address_line="Hauptstr. 1",
city="Berlin",
postcode="10115",
country_code="DE",
email="seller@example.de",
phone="+49 30 12345",
endpoint_id="9930:DE123456789",
endpoint_scheme_id="9930",
)
defaults.update(overrides)
return CIIParty(**defaults)
def _make_buyer(**overrides):
defaults = dict(
name="Buyer BV",
tax_id="NL123456789B01",
address_line="Keizersgracht 1",
city="Amsterdam",
postcode="1015 AA",
country_code="NL",
email="buyer@example.nl",
)
defaults.update(overrides)
return CIIParty(**defaults)
@pytest.mark.unit
def test_build_cii_produces_valid_xml():
invoice = _make_invoice()
seller = _make_seller()
buyer = _make_buyer()
xml, sha256 = build_cii_invoice_xml(invoice, seller, buyer)
assert sha256
passed, msgs = validate_cii_wellformed(xml)
assert passed is True, f"CII well-formedness failed: {msgs}"
@pytest.mark.unit
def test_build_cii_passes_en16931_validation():
invoice = _make_invoice()
seller = _make_seller()
buyer = _make_buyer()
xml, _ = build_cii_invoice_xml(invoice, seller, buyer)
passed, issues = validate_cii_en16931(xml)
assert passed is True, f"CII EN 16931 validation failed: {issues}"
@pytest.mark.unit
def test_cii_contains_guideline_id():
invoice = _make_invoice()
xml, _ = build_cii_invoice_xml(invoice, _make_seller(), _make_buyer())
assert FACTURX_GUIDELINE_EN16931 in xml
@pytest.mark.unit
def test_cii_contains_invoice_number():
invoice = _make_invoice(invoice_number="INV-2024-999")
xml, _ = build_cii_invoice_xml(invoice, _make_seller(), _make_buyer())
assert "INV-2024-999" in xml
@pytest.mark.unit
def test_cii_contains_type_code_380():
xml, _ = build_cii_invoice_xml(_make_invoice(), _make_seller(), _make_buyer())
assert ">380<" in xml
@pytest.mark.unit
def test_cii_contains_issue_date_format_102():
invoice = _make_invoice(issue_date=date(2024, 3, 15))
xml, _ = build_cii_invoice_xml(invoice, _make_seller(), _make_buyer())
assert "20240315" in xml
assert 'format="102"' in xml
@pytest.mark.unit
def test_cii_contains_seller_and_buyer():
xml, _ = build_cii_invoice_xml(
_make_invoice(),
_make_seller(name="ACME Corp"),
_make_buyer(name="Widget Ltd"),
)
assert "ACME Corp" in xml
assert "Widget Ltd" in xml
@pytest.mark.unit
def test_cii_contains_tax_info():
xml, _ = build_cii_invoice_xml(
_make_invoice(tax_rate=Decimal("21"), tax_amount=Decimal("42.00")),
_make_seller(),
_make_buyer(),
)
assert "VAT" in xml
assert "42.00" in xml
assert "21.00" in xml
@pytest.mark.unit
def test_cii_contains_monetary_totals():
invoice = _make_invoice(
subtotal=Decimal("200.00"),
tax_amount=Decimal("42.00"),
total_amount=Decimal("242.00"),
)
xml, _ = build_cii_invoice_xml(invoice, _make_seller(), _make_buyer())
assert "200.00" in xml
assert "242.00" in xml
assert "DuePayableAmount" in xml
@pytest.mark.unit
def test_cii_contains_line_items():
items = [
SimpleNamespace(description="Design", quantity=Decimal("5"), unit_price=Decimal("80.00"), total_amount=Decimal("400.00")),
SimpleNamespace(description="Dev", quantity=Decimal("10"), unit_price=Decimal("100.00"), total_amount=Decimal("1000.00")),
]
invoice = _make_invoice(items=items)
xml, _ = build_cii_invoice_xml(invoice, _make_seller(), _make_buyer())
assert "Design" in xml
assert "Dev" in xml
assert "BilledQuantity" in xml
assert 'unitCode="C62"' in xml
@pytest.mark.unit
def test_cii_adds_placeholder_line_when_no_items():
invoice = _make_invoice(items=[], expenses=[], extra_goods=[])
xml, _ = build_cii_invoice_xml(invoice, _make_seller(), _make_buyer())
assert "IncludedSupplyChainTradeLineItem" in xml
@pytest.mark.unit
def test_cii_includes_buyer_reference():
invoice = _make_invoice(buyer_reference="REF-42")
xml, _ = build_cii_invoice_xml(invoice, _make_seller(), _make_buyer())
assert "BuyerReference" in xml
assert "REF-42" in xml
@pytest.mark.unit
def test_cii_includes_notes():
invoice = _make_invoice(notes="Payment within 14 days")
xml, _ = build_cii_invoice_xml(invoice, _make_seller(), _make_buyer())
assert "Payment within 14 days" in xml
assert "IncludedNote" in xml
@pytest.mark.unit
def test_cii_includes_due_date():
invoice = _make_invoice(due_date=date(2024, 4, 14))
xml, _ = build_cii_invoice_xml(invoice, _make_seller(), _make_buyer())
assert "20240414" in xml
assert "DueDateDateTime" in xml
@pytest.mark.unit
def test_cii_includes_seller_tax_registration():
seller = _make_seller(tax_id="BE0123456789")
xml, _ = build_cii_invoice_xml(_make_invoice(), seller, _make_buyer())
assert "SpecifiedTaxRegistration" in xml
assert "BE0123456789" in xml
assert 'schemeID="VA"' in xml
@pytest.mark.unit
def test_cii_includes_seller_legal_organization():
seller = _make_seller(endpoint_id="0088:123456", endpoint_scheme_id="0088")
xml, _ = build_cii_invoice_xml(_make_invoice(), seller, _make_buyer())
assert "SpecifiedLegalOrganization" in xml
assert "0088:123456" in xml
@pytest.mark.unit
def test_cii_sha256_changes_with_content():
inv1 = _make_invoice(invoice_number="A")
inv2 = _make_invoice(invoice_number="B")
_, sha1 = build_cii_invoice_xml(inv1, _make_seller(), _make_buyer())
_, sha2 = build_cii_invoice_xml(inv2, _make_seller(), _make_buyer())
assert sha1 != sha2
@pytest.mark.unit
def test_cii_handles_zero_tax():
invoice = _make_invoice(tax_rate=Decimal("0"), tax_amount=Decimal("0"))
xml, _ = build_cii_invoice_xml(invoice, _make_seller(), _make_buyer())
# Zero-rated tax should use category Z
assert "CategoryCode" in xml
# Should still produce valid CII
passed, issues = validate_cii_en16931(xml)
assert passed is True, f"Failed: {issues}"