mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-17 10:29:49 -05:00
feat(invoicing): add ZugFerd/Factur-X support and document Peppol & ZugFerd
- Add optional embedding of EN 16931 UBL XML in invoice PDFs (ZugFerd/Factur-X) when 'Embed EN 16931 XML in invoice PDFs' is enabled in Admin > Peppol e-Invoicing. Exported PDFs then contain ZUGFeRD-invoice.xml for hybrid human- and machine-readable invoices; same UBL as Peppol, usable via Peppol or email. - New setting invoices_zugferd_pdf (migration 128), pikepdf dependency, and app.utils.zugferd helper (best-effort supplier/customer from Settings and client). - Wire embed in export_invoice_pdf (and fallback path); admin checkbox and persistence. - Docs: PEPPOL_EINVOICING.md retitled to 'Peppol and ZugFerd', new section for ZugFerd embedding; README and CHANGELOG updated; migration 128 noted. - Tests: test_zugferd.py (embed adds attachment with expected XML; invalid PDF returns original bytes and error).
This commit is contained in:
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **ZugFerd / Factur-X support for invoice PDFs** — When enabled in Admin → Settings → Peppol e-Invoicing, exported invoice PDFs embed EN 16931 UBL XML as `ZUGFeRD-invoice.xml`, producing hybrid human- and machine-readable invoices. Uses the same UBL as Peppol; these PDFs can be sent via Peppol or email. New setting `invoices_zugferd_pdf`, migration `128_add_invoices_zugferd_pdf`, dependency `pikepdf`, and [docs/admin/configuration/PEPPOL_EINVOICING.md](docs/admin/configuration/PEPPOL_EINVOICING.md) updated for both Peppol and ZugFerd.
|
||||
- **Subcontractor role and assigned clients** — Users with the Subcontractor role can be restricted to specific clients and their projects. Admins assign clients in Admin → Users → Edit user (section "Assigned Clients (Subcontractor)"). Scope is applied to clients, projects, time entries, reports, invoices, timer, and API v1; direct access to other clients/projects returns 403. New table `user_clients`, migration `127_add_user_clients_table`, and docs in [docs/SUBCONTRACTOR_ROLE.md](docs/SUBCONTRACTOR_ROLE.md).
|
||||
- Additional features and improvements in development
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ TimeTracker has been continuously enhanced with powerful new features! Here's wh
|
||||
- **Invoice Status Tracking** — Monitor draft, sent, paid, and overdue invoices
|
||||
- **Recurring Invoices** — Automate regular billing cycles
|
||||
- **Email Integration** — Send invoices directly to clients from the platform
|
||||
- **Peppol e-Invoicing (BIS Billing 3.0)** — Send invoices via Peppol through your access point ([setup guide](docs/admin/configuration/PEPPOL_EINVOICING.md))
|
||||
- **Peppol & ZugFerd e-Invoicing (EN 16931)** — Send invoices via Peppol; optionally embed EN 16931 XML in invoice PDFs (ZugFerd/Factur-X) for hybrid human- and machine-readable invoices ([setup guide](docs/admin/configuration/PEPPOL_EINVOICING.md))
|
||||
|
||||
#### 📋 **Advanced Task Management**
|
||||
- **Full Task System** — Create, assign, and track tasks with priorities and due dates
|
||||
@@ -235,7 +235,7 @@ TimeTracker includes **130+ features** across 13 major categories. See the [Comp
|
||||
- **Recurring Invoices** — Automate recurring billing
|
||||
- **Multi-Currency** — Support for multiple currencies with conversion
|
||||
- **Invoice Email** — Send invoices directly to clients
|
||||
- **Peppol e-Invoicing (BIS Billing 3.0)** — Send invoices electronically via Peppol ([Setup Guide](docs/admin/configuration/PEPPOL_EINVOICING.md))
|
||||
- **Peppol & ZugFerd e-Invoicing (EN 16931)** — Send invoices via Peppol; optionally embed EN 16931 XML in PDFs (ZugFerd/Factur-X) ([Setup Guide](docs/admin/configuration/PEPPOL_EINVOICING.md))
|
||||
|
||||
### 💰 **Financial Management**
|
||||
- **Expense Tracking** — Track business expenses with receipts and categories ([Guide](docs/EXPENSE_TRACKING.md))
|
||||
@@ -624,7 +624,7 @@ Comprehensive documentation is available in the [`docs/`](docs/) directory. See
|
||||
**Integrations & Apps:**
|
||||
- **[Mobile & Desktop Apps](docs/mobile-desktop-apps/README.md)** — Flutter mobile and Electron desktop apps
|
||||
- **[Build Guide (Mobile & Desktop)](BUILD.md)** — Build scripts for Android, iOS, Windows, macOS, Linux
|
||||
- **[Peppol e-Invoicing](docs/admin/configuration/PEPPOL_EINVOICING.md)** — Electronic invoicing
|
||||
- **[Peppol & ZugFerd e-Invoicing](docs/admin/configuration/PEPPOL_EINVOICING.md)** — Peppol sending and ZugFerd/Factur-X PDF embedding (EN 16931)
|
||||
- **[API Documentation](docs/api/REST_API.md)** — REST API reference
|
||||
- **[API Token Scopes](docs/api/API_TOKEN_SCOPES.md)** — Token permissions
|
||||
|
||||
|
||||
@@ -75,6 +75,8 @@ class Settings(db.Model):
|
||||
peppol_access_point_timeout = db.Column(db.Integer, default=30, nullable=True)
|
||||
peppol_provider = db.Column(db.String(50), default="generic", nullable=True)
|
||||
invoices_peppol_compliant = db.Column(db.Boolean, default=False, nullable=False)
|
||||
# When True, exported invoice PDFs embed EN 16931 UBL XML (ZugFerd/Factur-X)
|
||||
invoices_zugferd_pdf = db.Column(db.Boolean, default=False, nullable=False)
|
||||
|
||||
# Privacy and analytics settings
|
||||
allow_analytics = db.Column(db.Boolean, default=True, nullable=False) # Controls system info sharing for analytics
|
||||
@@ -208,6 +210,7 @@ class Settings(db.Model):
|
||||
self.peppol_access_point_timeout = kwargs.get("peppol_access_point_timeout", 30)
|
||||
self.peppol_provider = kwargs.get("peppol_provider", "generic")
|
||||
self.invoices_peppol_compliant = kwargs.get("invoices_peppol_compliant", False)
|
||||
self.invoices_zugferd_pdf = kwargs.get("invoices_zugferd_pdf", False)
|
||||
|
||||
# Kiosk mode defaults
|
||||
self.kiosk_mode_enabled = kwargs.get("kiosk_mode_enabled", False)
|
||||
@@ -420,6 +423,7 @@ class Settings(db.Model):
|
||||
"peppol_access_point_timeout": getattr(self, "peppol_access_point_timeout", None),
|
||||
"peppol_provider": getattr(self, "peppol_provider", "") or "",
|
||||
"invoices_peppol_compliant": getattr(self, "invoices_peppol_compliant", False),
|
||||
"invoices_zugferd_pdf": getattr(self, "invoices_zugferd_pdf", False),
|
||||
"invoice_pdf_template_html": self.invoice_pdf_template_html,
|
||||
"invoice_pdf_template_css": self.invoice_pdf_template_css,
|
||||
"invoice_pdf_design_json": self.invoice_pdf_design_json,
|
||||
|
||||
@@ -1246,6 +1246,7 @@ def settings():
|
||||
|
||||
settings_obj.peppol_provider = (request.form.get("peppol_provider", "") or "").strip() or "generic"
|
||||
settings_obj.invoices_peppol_compliant = request.form.get("invoices_peppol_compliant") == "on"
|
||||
settings_obj.invoices_zugferd_pdf = request.form.get("invoices_zugferd_pdf") == "on"
|
||||
except AttributeError:
|
||||
# Peppol columns don't exist yet (migration not run)
|
||||
pass
|
||||
|
||||
@@ -1149,6 +1149,12 @@ def export_invoice_pdf(invoice_id):
|
||||
pdf_generator = InvoicePDFGenerator(invoice, settings=settings, page_size=page_size)
|
||||
current_app.logger.info(f"[PDF_EXPORT] Starting PDF generation - PageSize: '{page_size}', InvoiceID: {invoice_id}")
|
||||
pdf_bytes = pdf_generator.generate_pdf()
|
||||
# Optionally embed ZugFerd/Factur-X EN 16931 XML in PDF
|
||||
if getattr(settings, "invoices_zugferd_pdf", False):
|
||||
from app.utils.zugferd import embed_zugferd_xml_in_pdf
|
||||
pdf_bytes, embed_err = embed_zugferd_xml_in_pdf(pdf_bytes, invoice, settings)
|
||||
if embed_err:
|
||||
current_app.logger.warning(f"[PDF_EXPORT] ZugFerd embed skipped - InvoiceID: {invoice_id}, Error: {embed_err}")
|
||||
pdf_size_bytes = len(pdf_bytes)
|
||||
current_app.logger.info(f"[PDF_EXPORT] PDF generation completed successfully - PageSize: '{page_size}', InvoiceID: {invoice_id}, PDFSize: {pdf_size_bytes} bytes")
|
||||
# Filename should be template+date+number (invoice number format)
|
||||
@@ -1166,6 +1172,11 @@ def export_invoice_pdf(invoice_id):
|
||||
settings = Settings.get_settings()
|
||||
pdf_generator = InvoicePDFGeneratorFallback(invoice, settings=settings)
|
||||
pdf_bytes = pdf_generator.generate_pdf()
|
||||
if getattr(settings, "invoices_zugferd_pdf", False):
|
||||
from app.utils.zugferd import embed_zugferd_xml_in_pdf
|
||||
pdf_bytes, embed_err = embed_zugferd_xml_in_pdf(pdf_bytes, invoice, settings)
|
||||
if embed_err:
|
||||
current_app.logger.warning(f"[PDF_EXPORT] ZugFerd embed skipped (fallback path) - InvoiceID: {invoice_id}, Error: {embed_err}")
|
||||
pdf_size_bytes = len(pdf_bytes)
|
||||
current_app.logger.info(f"[PDF_EXPORT] Fallback PDF generated successfully - PageSize: '{page_size}', InvoiceID: {invoice_id}, PDFSize: {pdf_size_bytes} bytes")
|
||||
# Filename should be template+date+number (invoice number format)
|
||||
|
||||
@@ -267,6 +267,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2 flex items-start">
|
||||
<input type="checkbox" name="invoices_zugferd_pdf" id="invoices_zugferd_pdf" {% if settings.invoices_zugferd_pdf %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 mt-1">
|
||||
<div class="ml-2">
|
||||
<label for="invoices_zugferd_pdf" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Embed EN 16931 XML in invoice PDFs (ZugFerd / Factur-X)</label>
|
||||
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
|
||||
When enabled, exported invoice PDFs contain an embedded XML file (ZUGFeRD-invoice.xml) so the file is both human-readable and machine-readable (EN 16931). These PDFs can also be sent via Peppol.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="peppol_sender_endpoint_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Sender Endpoint ID</label>
|
||||
<input type="text" name="peppol_sender_endpoint_id" id="peppol_sender_endpoint_id" value="{{ settings.peppol_sender_endpoint_id or '' }}" class="form-input" placeholder="e.g. 9915:BE0123456789">
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
ZugFerd / Factur-X: embed EN 16931 UBL XML into invoice PDFs.
|
||||
|
||||
When enabled, exported invoice PDFs contain an embedded XML file (ZUGFeRD-invoice.xml)
|
||||
so the file is both human-readable (PDF) and machine-readable (EN 16931). The same
|
||||
UBL used for Peppol is reused; embedding is done with pikepdf.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
from app.integrations.peppol import PeppolParty, build_peppol_ubl_invoice_xml
|
||||
|
||||
|
||||
# Standard embedded filename for ZUGFeRD/Factur-X (EN 16931)
|
||||
ZUGFERD_EMBEDDED_FILENAME = "ZUGFeRD-invoice.xml"
|
||||
|
||||
|
||||
def _get_sender_party_for_zugferd(settings: Any) -> PeppolParty:
|
||||
"""Build supplier party from Settings (best-effort; placeholders if missing)."""
|
||||
sender_endpoint_id = (
|
||||
(getattr(settings, "peppol_sender_endpoint_id", "") or os.getenv("PEPPOL_SENDER_ENDPOINT_ID") or "").strip()
|
||||
or "unknown"
|
||||
)
|
||||
sender_scheme_id = (
|
||||
(getattr(settings, "peppol_sender_scheme_id", "") or os.getenv("PEPPOL_SENDER_SCHEME_ID") or "").strip()
|
||||
or "0000"
|
||||
)
|
||||
sender_country = (
|
||||
(getattr(settings, "peppol_sender_country", "") or os.getenv("PEPPOL_SENDER_COUNTRY") or "").strip()
|
||||
or None
|
||||
)
|
||||
return PeppolParty(
|
||||
endpoint_id=sender_endpoint_id,
|
||||
endpoint_scheme_id=sender_scheme_id,
|
||||
name=(getattr(settings, "company_name", None) or "Company").strip(),
|
||||
tax_id=(getattr(settings, "company_tax_id", None) or "").strip() or None,
|
||||
address_line=(getattr(settings, "company_address", None) or "").strip() or None,
|
||||
country_code=sender_country,
|
||||
email=(getattr(settings, "company_email", None) or "").strip() or None,
|
||||
phone=(getattr(settings, "company_phone", None) or "").strip() or None,
|
||||
)
|
||||
|
||||
|
||||
def _get_customer_party_for_zugferd(invoice: Any) -> PeppolParty:
|
||||
"""Build customer party from invoice and client (best-effort; placeholders if missing)."""
|
||||
client = getattr(invoice, "client", None)
|
||||
endpoint_id = "unknown"
|
||||
scheme_id = "0000"
|
||||
country = None
|
||||
name = (getattr(invoice, "client_name", None) or "Customer").strip()
|
||||
tax_id = None
|
||||
address_line = None
|
||||
email = None
|
||||
phone = None
|
||||
|
||||
if client:
|
||||
endpoint_id = (client.get_custom_field("peppol_endpoint_id", "") or "").strip() or "unknown"
|
||||
scheme_id = (client.get_custom_field("peppol_scheme_id", "") or "").strip() or "0000"
|
||||
country = (client.get_custom_field("peppol_country", "") or "").strip() or None
|
||||
name = (getattr(client, "name", None) or getattr(invoice, "client_name", "") or "Customer").strip()
|
||||
tax_id = (client.get_custom_field("vat_id", "") or client.get_custom_field("tax_id", "") or "").strip() or None
|
||||
address_line = (getattr(client, "address", None) or getattr(invoice, "client_address", None) or "").strip() or None
|
||||
email = (getattr(client, "email", None) or getattr(invoice, "client_email", None) or "").strip() or None
|
||||
phone = (getattr(client, "phone", None) or "").strip() or None
|
||||
|
||||
return PeppolParty(
|
||||
endpoint_id=endpoint_id,
|
||||
endpoint_scheme_id=scheme_id,
|
||||
name=name,
|
||||
tax_id=tax_id,
|
||||
address_line=address_line,
|
||||
country_code=country,
|
||||
email=email,
|
||||
phone=phone,
|
||||
)
|
||||
|
||||
|
||||
def embed_zugferd_xml_in_pdf(pdf_bytes: bytes, invoice: Any, settings: Any) -> Tuple[bytes, Optional[str]]:
|
||||
"""
|
||||
Embed EN 16931 UBL XML into the given invoice PDF bytes (ZugFerd/Factur-X).
|
||||
|
||||
Builds supplier/customer from settings and invoice (best-effort), generates UBL,
|
||||
attaches it as ZUGFeRD-invoice.xml, and returns the new PDF bytes.
|
||||
|
||||
Returns:
|
||||
(new_pdf_bytes, None) on success, or (original_pdf_bytes, error_message) on failure.
|
||||
"""
|
||||
try:
|
||||
import pikepdf
|
||||
from pikepdf import AttachedFileSpec
|
||||
except ImportError as e:
|
||||
return pdf_bytes, f"pikepdf not available: {e}"
|
||||
|
||||
try:
|
||||
supplier = _get_sender_party_for_zugferd(settings)
|
||||
customer = _get_customer_party_for_zugferd(invoice)
|
||||
ubl_xml, _ = build_peppol_ubl_invoice_xml(invoice=invoice, supplier=supplier, customer=customer)
|
||||
except Exception as e:
|
||||
return pdf_bytes, f"Failed to build UBL for ZugFerd: {e}"
|
||||
|
||||
try:
|
||||
pdf = pikepdf.open(io.BytesIO(pdf_bytes))
|
||||
filespec = AttachedFileSpec(pdf, ubl_xml.encode("utf-8"), mime_type="application/xml")
|
||||
pdf.attachments[ZUGFERD_EMBEDDED_FILENAME] = filespec
|
||||
out = io.BytesIO()
|
||||
pdf.save(out, min_version="1.7")
|
||||
pdf.close()
|
||||
return out.getvalue(), None
|
||||
except Exception as e:
|
||||
return pdf_bytes, f"Failed to embed ZugFerd XML in PDF: {e}"
|
||||
@@ -1,6 +1,11 @@
|
||||
# Peppol e-invoicing (BIS Billing 3.0)
|
||||
# Peppol and ZugFerd e-invoicing (EN 16931)
|
||||
|
||||
TimeTracker can **send invoices via Peppol** by generating a UBL 2.1 Invoice (Peppol BIS Billing 3.0 profile) and forwarding it to your **Peppol Access Point**.
|
||||
TimeTracker supports **both**:
|
||||
|
||||
- **Peppol** – send invoices via the Peppol network (UBL 2.1, BIS Billing 3.0) to your **Peppol Access Point**.
|
||||
- **ZugFerd / Factur-X** – export invoice PDFs that contain **embedded EN 16931 XML** (one file that is both human-readable and machine-readable). These hybrid PDFs can also be sent via Peppol.
|
||||
|
||||
Peppol is the **transport**; ZugFerd is a **format** (PDF + embedded XML). The same UBL used for Peppol is reused when embedding (EN 16931 compliant).
|
||||
|
||||
## What you need
|
||||
|
||||
@@ -85,6 +90,16 @@ You can optionally set **Buyer reference (PEPPOL BT-10)** on each invoice (creat
|
||||
|
||||
When the setting is on **and** the client has Peppol endpoint details, the invoice view shows a **Download UBL** button to save the UBL 2.1 XML file.
|
||||
|
||||
## Embed EN 16931 XML in invoice PDFs (ZugFerd / Factur-X)
|
||||
|
||||
In **Admin → Settings → Peppol e-Invoicing** you can enable **Embed EN 16931 XML in invoice PDFs (ZugFerd / Factur-X)**. When this is on:
|
||||
|
||||
- **Exported invoice PDFs** (Export PDF) contain an embedded file `ZUGFeRD-invoice.xml` with the same EN 16931 UBL as used for Peppol.
|
||||
- The PDF remains human-readable; the embedded XML makes it machine-readable (e.g. for automated booking or archiving).
|
||||
- These hybrid PDFs can be sent via Peppol or by email; recipients can open the PDF and/or extract the XML.
|
||||
|
||||
Party data (seller/customer) is taken from Settings and the invoice’s client (including Peppol endpoint fields). For full EN 16931/ZugFerd compliance, configure sender and client data as for Peppol. If some data is missing, the app still embeds best-effort UBL so the file is usable.
|
||||
|
||||
## Migrations
|
||||
|
||||
After pulling these changes, run:
|
||||
@@ -93,10 +108,11 @@ After pulling these changes, run:
|
||||
flask db upgrade
|
||||
```
|
||||
|
||||
This applies:
|
||||
This applies (among others):
|
||||
|
||||
- `112_add_invoices_peppol_compliant` (adds `settings.invoices_peppol_compliant`)
|
||||
- `113_add_invoice_buyer_reference` (adds `invoices.buyer_reference`)
|
||||
- `128_add_invoices_zugferd_pdf` (adds `settings.invoices_zugferd_pdf` for ZugFerd PDF embedding)
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Add invoices_zugferd_pdf to settings
|
||||
|
||||
Revision ID: 128_add_invoices_zugferd_pdf
|
||||
Revises: 127_add_user_clients
|
||||
Create Date: 2026-02-16
|
||||
|
||||
When enabled, exported invoice PDFs embed EN 16931 UBL XML (ZugFerd/Factur-X).
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "128_add_invoices_zugferd_pdf"
|
||||
down_revision = "127_add_user_clients"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""Add invoices_zugferd_pdf column to settings table"""
|
||||
from sqlalchemy import inspect
|
||||
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
existing_tables = inspector.get_table_names()
|
||||
if "settings" not in existing_tables:
|
||||
return
|
||||
|
||||
settings_columns = {c["name"] for c in inspector.get_columns("settings")}
|
||||
if "invoices_zugferd_pdf" in settings_columns:
|
||||
print("✓ Column invoices_zugferd_pdf already exists in settings table")
|
||||
return
|
||||
|
||||
try:
|
||||
op.add_column(
|
||||
"settings",
|
||||
sa.Column("invoices_zugferd_pdf", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
print("✓ Added invoices_zugferd_pdf column to settings table")
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if "already exists" in error_msg.lower() or "duplicate" in error_msg.lower():
|
||||
print("✓ Column invoices_zugferd_pdf already exists in settings table (detected via error)")
|
||||
else:
|
||||
print(f"✗ Error adding invoices_zugferd_pdf column: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""Remove invoices_zugferd_pdf column from settings table"""
|
||||
from sqlalchemy import inspect
|
||||
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
existing_tables = inspector.get_table_names()
|
||||
if "settings" not in existing_tables:
|
||||
return
|
||||
|
||||
settings_columns = {c["name"] for c in inspector.get_columns("settings")}
|
||||
if "invoices_zugferd_pdf" not in settings_columns:
|
||||
print("⊘ Column invoices_zugferd_pdf does not exist in settings table, skipping")
|
||||
return
|
||||
|
||||
try:
|
||||
op.drop_column("settings", "invoices_zugferd_pdf")
|
||||
print("✓ Dropped invoices_zugferd_pdf column from settings table")
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if "does not exist" in error_msg.lower() or "no such column" in error_msg.lower():
|
||||
print("⊘ Column invoices_zugferd_pdf does not exist in settings table (detected via error)")
|
||||
else:
|
||||
print(f"⚠ Warning: Could not drop invoices_zugferd_pdf column: {e}")
|
||||
@@ -40,6 +40,7 @@ WeasyPrint==60.2
|
||||
pydyf==0.10.0
|
||||
Pillow==10.4.0
|
||||
reportlab==4.0.7
|
||||
pikepdf>=8.0.0
|
||||
|
||||
# Background tasks
|
||||
APScheduler==3.10.4
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Tests for ZugFerd/Factur-X: embedding EN 16931 UBL 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 ZUGFERD_EMBEDDED_FILENAME, embed_zugferd_xml_in_pdf
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_embed_zugferd_xml_in_pdf_adds_attachment_and_xml_content(app):
|
||||
"""Embed step adds ZUGFeRD-invoice.xml to PDF and XML contains invoice data."""
|
||||
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")
|
||||
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",
|
||||
subtotal=Decimal("100.00"),
|
||||
tax_rate=Decimal("20.00"),
|
||||
tax_amount=Decimal("20.00"),
|
||||
total_amount=Decimal("120.00"),
|
||||
)
|
||||
db.session.add(inv)
|
||||
db.session.add(
|
||||
InvoiceItem(
|
||||
invoice_id=inv.id,
|
||||
description="Consulting",
|
||||
quantity=Decimal("1"),
|
||||
unit_price=Decimal("100.00"),
|
||||
total_amount=Decimal("100.00"),
|
||||
)
|
||||
)
|
||||
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)
|
||||
|
||||
# Open result and check embedded file
|
||||
result = pikepdf.open(io.BytesIO(out_bytes))
|
||||
assert ZUGFERD_EMBEDDED_FILENAME in result.attachments
|
||||
attached = result.attachments[ZUGFERD_EMBEDDED_FILENAME].get_file()
|
||||
xml_content = attached.read().decode("utf-8")
|
||||
result.close()
|
||||
|
||||
assert "<Invoice" in xml_content or "Invoice" in xml_content
|
||||
assert "INV-ZUG-001" in xml_content
|
||||
assert "120" in xml_content
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_embed_zugferd_returns_original_pdf_on_embed_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()
|
||||
# Minimal invoice-like object; build_peppol_ubl_invoice_xml only needs a few attrs
|
||||
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,
|
||||
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
|
||||
Reference in New Issue
Block a user