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
212 lines
7.2 KiB
Python
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
|