Files
TimeTracker/app/integrations/peppol_as4.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

196 lines
6.4 KiB
Python

"""
PEPPOL AS4 message packaging and transmission.
EXPERIMENTAL: This native AS4 implementation provides basic message
packaging and HTTP POST to a recipient access point. It does NOT
implement full Peppol AS4 compliance:
- No WS-Security / XML digital signatures
- No AS4 receipt handling / reliability
- Payload is gzip-compressed as declared in the SOAP header
For production use, prefer the Generic transport with a standards-compliant
Peppol Access Point provider.
"""
from __future__ import annotations
import gzip
import os
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, Optional
import requests
from app.integrations.peppol import PEPPOL_BIS3_PROFILE_ID
PEPPOL_INVOICE_DOCUMENT_TYPE = (
"urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice"
"##urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0::2.1"
)
# Flag surfaced in settings UI so users know this is not production-grade
NATIVE_TRANSPORT_EXPERIMENTAL = True
_BOUNDARY = "as4boundary"
class PeppolAS4Error(RuntimeError):
"""AS4 build or send error."""
pass
def _soap_envelope(
message_id: str,
sender_id: str,
sender_scheme: str,
recipient_id: str,
recipient_scheme: str,
document_id: str,
process_id: str,
document_type_id: str,
) -> str:
"""Build minimal AS4/ebMS 3.0 SOAP envelope (PEPPOL profile)."""
return f"""<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope"
xmlns:eb3="http://docs.oasis-open.org/ebxml-msg/ebms/v3.0/ns/core/200704/">
<soap:Header>
<eb3:Messaging>
<eb3:UserMessage>
<eb3:MessageInfo>
<eb3:MessageId>{message_id}</eb3:MessageId>
<eb3:Timestamp>{datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}</eb3:Timestamp>
</eb3:MessageInfo>
<eb3:PartyInfo>
<eb3:From>
<eb3:PartyId type="{sender_scheme}">{sender_id}</eb3:PartyId>
<eb3:Role>http://docs.oasis-open.org/ebxml-msg/ebms/v3.0/ns/core/200704/initiator</eb3:Role>
</eb3:From>
<eb3:To>
<eb3:PartyId type="{recipient_scheme}">{recipient_id}</eb3:PartyId>
<eb3:Role>http://docs.oasis-open.org/ebxml-msg/ebms/v3.0/ns/core/200704/responder</eb3:Role>
</eb3:To>
</eb3:PartyInfo>
<eb3:CollaborationInfo>
<eb3:AgreementRef>urn:fdc:peppol.eu:2017:agreement</eb3:AgreementRef>
<eb3:Service type="bdxr-service">urn:fdc:peppol.eu:2017:poacc:billing:01:1.0</eb3:Service>
<eb3:Action>dispatch</eb3:Action>
<eb3:ConversationId>{document_id}</eb3:ConversationId>
</eb3:CollaborationInfo>
<eb3:PayloadInfo>
<eb3:PartInfo href="cid:payload@peppol.eu">
<eb3:Schema location="{document_type_id}"/>
<eb3:PartProperties>
<eb3:Property name="MimeType">application/xml</eb3:Property>
<eb3:Property name="CompressionType">application/gzip</eb3:Property>
</eb3:PartProperties>
</eb3:PartInfo>
</eb3:PayloadInfo>
</eb3:UserMessage>
</eb3:Messaging>
</soap:Header>
<soap:Body/>
</soap:Envelope>"""
def build_as4_message(
ubl_xml: str,
sender_endpoint_id: str,
sender_scheme_id: str,
recipient_endpoint_id: str,
recipient_scheme_id: str,
document_id: str,
process_id: str = PEPPOL_BIS3_PROFILE_ID,
document_type_id: str = PEPPOL_INVOICE_DOCUMENT_TYPE,
) -> bytes:
"""
Build AS4 multipart/related message (SOAP + gzip-compressed payload).
Returns raw bytes suitable for POST to recipient AP.
The payload is gzip-compressed to match the CompressionType declared
in the SOAP header (application/gzip).
"""
message_id = f"<{uuid.uuid4().hex}@peppol>"
soap = _soap_envelope(
message_id=message_id,
sender_id=sender_endpoint_id,
sender_scheme=sender_scheme_id,
recipient_id=recipient_endpoint_id,
recipient_scheme=recipient_scheme_id,
document_id=document_id,
process_id=process_id,
document_type_id=document_type_id,
)
payload_bytes = gzip.compress(ubl_xml.encode("utf-8"))
# Build MIME multipart/related manually for cross-Python-version compatibility
parts = []
parts.append(
f"--{_BOUNDARY}\r\n"
f"Content-Type: application/soap+xml; charset=utf-8\r\n"
f"Content-ID: <root.message@peppol.eu>\r\n"
f"\r\n"
)
parts.append(soap)
parts.append(
f"\r\n--{_BOUNDARY}\r\n"
f"Content-Type: application/gzip\r\n"
f"Content-ID: <payload@peppol.eu>\r\n"
f"Content-Transfer-Encoding: binary\r\n"
f"\r\n"
)
result = b""
for p in parts:
result += p.encode("utf-8") if isinstance(p, str) else p
result += payload_bytes
result += f"\r\n--{_BOUNDARY}--\r\n".encode("utf-8")
return result
def send_as4_message(
recipient_ap_url: str,
message_bytes: bytes,
timeout_s: float = 60.0,
cert_path: Optional[str] = None,
key_path: Optional[str] = None,
) -> Dict[str, Any]:
"""
POST AS4 message to recipient access point URL.
If cert_path and key_path are set, use client certificate for mTLS.
Returns dict with status_code and optional message_id / error from response.
"""
url = recipient_ap_url.strip().rstrip("/")
if not url.startswith("http"):
raise PeppolAS4Error("Recipient AP URL must be HTTP or HTTPS")
headers = {
"Content-Type": f"multipart/related; boundary={_BOUNDARY}; type=application/soap+xml",
"Accept": "application/xml",
}
cert = None
if cert_path and key_path and os.path.isfile(cert_path) and os.path.isfile(key_path):
cert = (cert_path, key_path)
try:
resp = requests.post(
url,
data=message_bytes,
headers=headers,
timeout=timeout_s,
cert=cert,
)
except requests.RequestException as e:
raise PeppolAS4Error(f"AS4 send failed: {e}") from e
result: Dict[str, Any] = {"status_code": resp.status_code}
if resp.status_code >= 400:
result["error"] = resp.text[:2000] if resp.text else f"HTTP {resp.status_code}"
raise PeppolAS4Error(f"Recipient AP returned {resp.status_code}: {result.get('error', '')}")
if resp.text and "MessageId" in resp.text:
result["message_id"] = resp.text
return result