Files
TimeTracker/tests/test_zugferd.py
T
Dries Peeters 70510a6622 fix: quote create 500, line order, and Factur-X PDF parity
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
2026-04-12 13:34:58 +02:00

212 lines
7.2 KiB
Python

"""Tests for Factur-X / ZUGFeRD: embedding CII XML in invoice PDFs."""
from datetime import date, timedelta
from decimal import Decimal
import io
import pytest
from app import db
from app.models import Client, Invoice, InvoiceItem, Project, User
from app.utils.zugferd import FACTURX_EMBEDDED_FILENAME, embed_zugferd_xml_in_pdf
@pytest.mark.unit
def test_embed_facturx_xml_in_pdf_adds_cii_attachment(app):
"""Embed step adds factur-x.xml (CII) to PDF with correct structure."""
try:
import pikepdf
except ImportError:
pytest.skip("pikepdf not installed")
with app.app_context():
user = User(username="zugferduser", role="user", email="zugferd@example.com")
user.is_active = True
user.set_password("password123")
db.session.add(user)
client = Client(name="ZugFerd Client", email="client@example.com", address="Addr 1")
client.set_custom_field("peppol_endpoint_id", "9915:DE123456789")
client.set_custom_field("peppol_scheme_id", "9915")
client.set_custom_field("peppol_country", "DE")
db.session.add(client)
db.session.commit()
project = Project(
name="ZugFerd Project",
client_id=client.id,
billable=True,
hourly_rate=Decimal("80.00"),
)
project.status = "active"
db.session.add(project)
db.session.commit()
inv = Invoice(
invoice_number="INV-ZUG-001",
project_id=project.id,
client_name=client.name,
client_id=client.id,
issue_date=date.today(),
due_date=date.today() + timedelta(days=30),
created_by=user.id,
currency_code="EUR",
tax_rate=Decimal("20.00"),
)
db.session.add(inv)
db.session.commit()
db.session.add(
InvoiceItem(
invoice_id=inv.id,
description="Consulting",
quantity=Decimal("1"),
unit_price=Decimal("100.00"),
)
)
db.session.commit()
inv.calculate_totals()
db.session.commit()
settings = __import__("app.models", fromlist=["Settings"]).Settings.get_settings()
if not getattr(settings, "company_name", None):
settings.company_name = "Test Company"
if not getattr(settings, "peppol_sender_endpoint_id", None):
settings.peppol_sender_endpoint_id = "9915:BE111111111"
if not getattr(settings, "peppol_sender_scheme_id", None):
settings.peppol_sender_scheme_id = "9915"
db.session.commit()
# Minimal valid PDF (one blank page)
pdf = pikepdf.Pdf.new()
pdf.add_blank_page(page_size=(595, 842))
buf = io.BytesIO()
pdf.save(buf)
pdf.close()
pdf_bytes = buf.getvalue()
out_bytes, err = embed_zugferd_xml_in_pdf(pdf_bytes, inv, settings)
assert err is None
assert len(out_bytes) > len(pdf_bytes)
result = pikepdf.open(io.BytesIO(out_bytes))
assert FACTURX_EMBEDDED_FILENAME in result.attachments
attached = result.attachments[FACTURX_EMBEDDED_FILENAME].get_file()
xml_content = attached.read_bytes().decode("utf-8")
result.close()
# Must be CII (CrossIndustryInvoice), NOT UBL
assert "CrossIndustryInvoice" in xml_content
assert "INV-ZUG-001" in xml_content
# EN 16931 CII requires BilledQuantity with unitCode
assert "BilledQuantity" in xml_content
assert 'unitCode="C62"' in xml_content
# Grand total: 100 + 20% tax = 120
assert "GrandTotalAmount" in xml_content
assert "120.00" in xml_content
# Must contain seller and buyer party names
assert "ZugFerd Client" in xml_content
# Must contain the Factur-X guideline ID
assert "urn:cen.eu:en16931:2017" in xml_content
# ZUGFeRD / Factur-X: primary invoice XML uses AFRelationship Data and text/xml
fs = result.attachments[FACTURX_EMBEDDED_FILENAME]
assert fs.obj["/AFRelationship"] == pikepdf.Name("/Data")
emb = fs.obj["/EF"]["/F"]
assert emb.get("/Subtype") == pikepdf.Name("/text/xml")
@pytest.mark.unit
def test_embed_facturx_xml_has_correct_xmp_metadata(app):
"""Embedded PDF has Factur-X XMP metadata (not the old ZUGFeRD CII namespace)."""
try:
import pikepdf
except ImportError:
pytest.skip("pikepdf not installed")
with app.app_context():
from types import SimpleNamespace
settings = __import__("app.models", fromlist=["Settings"]).Settings.get_settings()
if not getattr(settings, "company_name", None):
settings.company_name = "Test Company"
db.session.commit()
inv = SimpleNamespace(
id=1,
invoice_number="INV-META-001",
issue_date=date.today(),
due_date=date.today() + timedelta(days=14),
currency_code="EUR",
subtotal=Decimal("50.00"),
tax_rate=Decimal("0"),
tax_amount=Decimal("0"),
total_amount=Decimal("50.00"),
notes=None,
buyer_reference=None,
project=None,
client=None,
client_name="Buyer",
client_email=None,
client_address=None,
items=[SimpleNamespace(description="Work", quantity=1, unit_price=50, total_amount=50)],
expenses=[],
extra_goods=[],
)
pdf = pikepdf.Pdf.new()
pdf.add_blank_page(page_size=(595, 842))
buf = io.BytesIO()
pdf.save(buf)
pdf.close()
pdf_bytes = buf.getvalue()
out_bytes, err = embed_zugferd_xml_in_pdf(pdf_bytes, inv, settings)
assert err is None
result = pikepdf.open(io.BytesIO(out_bytes))
xmp = result.Root.Metadata.read_bytes().decode("utf-8", errors="replace")
result.close()
# Must use Factur-X namespace, not the old ZUGFeRD CII namespace
assert "factur-x" in xmp.lower() or "fx:DocumentType" in xmp
assert "factur-x.xml" in xmp
@pytest.mark.unit
def test_embed_returns_original_pdf_on_failure(app):
"""When embedding fails (e.g. invalid PDF), return original bytes and error message."""
with app.app_context():
from types import SimpleNamespace
settings = __import__("app.models", fromlist=["Settings"]).Settings.get_settings()
inv = SimpleNamespace(
id=1,
invoice_number="INV-X",
issue_date=date.today(),
due_date=date.today(),
currency_code="EUR",
subtotal=Decimal("0"),
tax_rate=Decimal("0"),
tax_amount=Decimal("0"),
total_amount=Decimal("0"),
notes=None,
buyer_reference=None,
project=None,
client=None,
client_name="Test",
client_email=None,
client_address=None,
items=[],
expenses=[],
extra_goods=[],
)
invalid_pdf_bytes = b"not a valid pdf"
out_bytes, err = embed_zugferd_xml_in_pdf(invalid_pdf_bytes, inv, settings)
assert err is not None
assert out_bytes == invalid_pdf_bytes