mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-23 06:40:53 -05:00
70510a6622
Quotes (#583): - Add requires_approval, approval_level, and can_be_sent; wire create form - Migrations 145 (approval columns) and 146 (quote_items.position) - Order Quote.items by position; set positions on create/edit/duplicate/API - Fix view template approval branch (not_required); add web regression test Invoices / PEPPOL: - Use the same Factur-X embed and PDF/A-3 normalization for export and email attachments; Associated File Data + text/xml metadata - CII/UBL validators, pdfa3, zugferd, and invoice_pdf_postprocess helper - Bundle compact sRGB ICC (app/resources/icc/); INVOICE_SRGB_ICC_PATH override - Package data in setup.py; extend PEPPOL_EINVOICING.md and tests
247 lines
7.6 KiB
Python
247 lines
7.6 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
|
|
assert "ExemptionReason" in xml
|
|
assert "VATEX-EU-O" in xml
|
|
assert 'currencyID="EUR"' in xml
|
|
# Should still produce valid CII
|
|
passed, issues = validate_cii_en16931(xml)
|
|
assert passed is True, f"Failed: {issues}"
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_cii_monetary_elements_have_currency_id():
|
|
invoice = _make_invoice(currency_code="USD")
|
|
xml, _ = build_cii_invoice_xml(invoice, _make_seller(), _make_buyer())
|
|
assert 'currencyID="USD"' in xml
|
|
assert xml.count('currencyID="USD"') >= 5
|