Technique

Python et Factur-X : générer, attacher et valider un XML EN16931 (guide complet)

18 min de lecture Par FacturX API

Générer un XML CII EN16931, l'embarquer en PDF/A-3 avec la lib factur-x (Akretion), extraire et valider par XSD/Schematron en Python. Code complet, pièges et bonnes pratiques.

Python est le choix naturel pour automatiser la facturation électronique. Son écosystème couvre toute la chaîne : génération XML, manipulation PDF, validation XSD et Schematron, appels HTTP pour la validation distante. Les équipes qui traitent des volumes significatifs — batch de fin de mois, intégration ERP, migration de masse — trouvent en Python un langage suffisamment expressif pour du prototypage rapide et suffisamment robuste pour de la production.

Cet article est un guide code-along. Les blocs de code sont conçus pour être fonctionnels une fois les dépendances installées et les chemins de fichiers adaptés à votre environnement. On part d’un XML CII vide et on arrive à un pipeline complet : génération, embedding PDF/A-3, extraction, validation locale et distante.

Pour le contexte réglementaire de la réforme 2026, voir Facturation électronique 2026 : le guide technique complet pour développeurs. Pour le détail des profils Factur-X, voir Profils Factur-X : MINIMUM → EXTENDED.

Installation des dépendances

Le socle repose sur trois bibliothèques principales :

pip install factur-x lxml requests
BibliothèqueRôleMaintenu par
factur-xEmbedding XML dans PDF/A-3, extraction XML depuis PDFAkretion (Alexis de Lattre)
lxmlParsing XML, validation XSD, validation Schematronlxml.de
requestsAppels HTTP vers l’API de validationPSF

La bibliothèque factur-x utilise pypdf (anciennement PyPDF2) en interne pour la manipulation PDF. Elle s’installe automatiquement comme dépendance transitive.

Pour la génération de XML CII depuis zéro, une alternative est drafthorse — une bibliothèque Python spécialisée dans la construction programmatique de documents CII. Cet article utilise lxml directement pour montrer la structure XML sous-jacente, mais drafthorse est pertinent pour les projets qui préfèrent une API objet plutôt que la manipulation XML brute.

Comprendre la structure XML CII

Avant de générer quoi que ce soit, il faut comprendre ce qu’on construit. Un XML Factur-X est un document CII (Cross-Industry Invoice) basé sur le schéma UN/CEFACT D22B. L’élément racine et ses namespaces :

<rsm:CrossIndustryInvoice
  xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
  xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"
  xmlns:qdt="urn:un:unece:uncefact:data:qualified:UnqualifiedDataType:100"
  xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100">
  ...
</rsm:CrossIndustryInvoice>

Les quatre namespaces sont obligatoires :

  • rsm: — éléments racine et structurels
  • ram: — éléments de données réutilisables (le gros du contenu)
  • qdt: — types de données qualifiés
  • udt: — types de données non qualifiés (dates, identifiants)

L’arborescence suit une structure fixe :

CrossIndustryInvoice
├── ExchangedDocumentContext     → profil Factur-X, identifiant processus
├── ExchangedDocument            → numéro, date, type de facture
└── SupplyChainTradeTransaction
    ├── IncludedSupplyChainTradeLineItem[]    → lignes de facture
    ├── ApplicableHeaderTradeAgreement        → vendeur, acheteur
    ├── ApplicableHeaderTradeDelivery         → livraison
    └── ApplicableHeaderTradeSettlement       → paiement, TVA, totaux

Pour la cartographie complète des champs obligatoires par profil, voir Champs obligatoires EN16931 : cartographie et mapping ERP.

Générer un XML CII profil MINIMUM

Le profil MINIMUM contient le strict nécessaire : numéro, date, devise, montant, identifiants vendeur/acheteur. Pas de lignes de facture, pas de ventilation TVA.

from lxml import etree
from datetime import date

# Namespaces CII D22B
NS = {
    "rsm": "urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100",
    "ram": "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100",
    "qdt": "urn:un:unece:uncefact:data:qualified:UnqualifiedDataType:100",
    "udt": "urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100",
}

# GuidelineID pour chaque profil Factur-X
GUIDELINE_IDS = {
    "MINIMUM": "urn:factur-x.eu:1p0:minimum",
    "BASICWL": "urn:factur-x.eu:1p0:basicwl",
    "BASIC": "urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:basic",
    "EN16931": "urn:cen.eu:en16931:2017",
    "EXTENDED": "urn:cen.eu:en16931:2017#conformant#urn:factur-x.eu:1p0:extended",
}


def qn(prefix: str, local: str) -> str:
    """Qualified name — convertit prefix:local en {namespace}local."""
    return f"{{{NS[prefix]}}}{local}"


def sub(parent: etree._Element, prefix: str, local: str, text: str | None = None, **attribs) -> etree._Element:
    """Ajoute un sous-élément avec namespace et texte optionnel."""
    elem = etree.SubElement(parent, qn(prefix, local))
    if text is not None:
        elem.text = text
    for key, val in attribs.items():
        elem.set(key, val)
    return elem


def generate_minimum_xml(
    invoice_number: str,
    invoice_date: date,
    currency: str,
    seller_name: str,
    seller_siret: str,
    seller_vat: str,
    buyer_name: str,
    buyer_siret: str,
    total_ttc: str,
    total_ht: str,
    total_tax: str,
    type_code: str = "380",
) -> bytes:
    """Génère un XML CII Factur-X profil MINIMUM."""

    root = etree.Element(qn("rsm", "CrossIndustryInvoice"), nsmap=NS)

    # --- ExchangedDocumentContext ---
    ctx = sub(root, "rsm", "ExchangedDocumentContext")
    guideline = sub(ctx, "ram", "GuidelineSpecifiedDocumentContextParameter")
    sub(guideline, "ram", "ID", GUIDELINE_IDS["MINIMUM"])

    # --- ExchangedDocument ---
    doc = sub(root, "rsm", "ExchangedDocument")
    sub(doc, "ram", "ID", invoice_number)
    sub(doc, "ram", "TypeCode", type_code)
    issue_dt = sub(doc, "ram", "IssueDateTime")
    sub(issue_dt, "udt", "DateTimeString", invoice_date.strftime("%Y%m%d"), format="102")

    # --- SupplyChainTradeTransaction ---
    transaction = sub(root, "rsm", "SupplyChainTradeTransaction")

    # ApplicableHeaderTradeAgreement (vendeur + acheteur)
    agreement = sub(transaction, "ram", "ApplicableHeaderTradeAgreement")

    # Vendeur
    seller_party = sub(agreement, "ram", "SellerTradeParty")
    sub(seller_party, "ram", "Name", seller_name)
    # BT-30 : SIRET (schemeID 0002 = SIRENE)
    seller_legal = sub(seller_party, "ram", "SpecifiedLegalOrganization")
    sub(seller_legal, "ram", "ID", seller_siret, schemeID="0002")
    # BT-31 : numéro de TVA intracommunautaire
    seller_tax = sub(seller_party, "ram", "SpecifiedTaxRegistration")
    sub(seller_tax, "ram", "ID", seller_vat, schemeID="VA")

    # Acheteur
    buyer_party = sub(agreement, "ram", "BuyerTradeParty")
    sub(buyer_party, "ram", "Name", buyer_name)
    buyer_legal = sub(buyer_party, "ram", "SpecifiedLegalOrganization")
    sub(buyer_legal, "ram", "ID", buyer_siret, schemeID="0002")

    # ApplicableHeaderTradeDelivery (vide mais requis par la structure)
    sub(transaction, "ram", "ApplicableHeaderTradeDelivery")

    # ApplicableHeaderTradeSettlement
    settlement = sub(transaction, "ram", "ApplicableHeaderTradeSettlement")
    sub(settlement, "ram", "InvoiceCurrencyCode", currency)

    # Totaux
    summation = sub(settlement, "ram", "SpecifiedTradeSettlementHeaderMonetarySummation")
    sub(summation, "ram", "TaxBasisTotalAmount", total_ht, currencyID=currency)
    sub(summation, "ram", "TaxTotalAmount", total_tax, currencyID=currency)
    sub(summation, "ram", "GrandTotalAmount", total_ttc, currencyID=currency)
    sub(summation, "ram", "DuePayableAmount", total_ttc, currencyID=currency)

    return etree.tostring(root, xml_declaration=True, encoding="UTF-8", pretty_print=True)


# --- Utilisation ---
xml_bytes = generate_minimum_xml(
    invoice_number="FA-2026-0042",
    invoice_date=date(2026, 6, 15),
    currency="EUR",
    seller_name="Acme SAS",
    seller_siret="12345678901234",
    seller_vat="FR12345678901",
    buyer_name="Client SARL",
    buyer_siret="98765432109876",
    total_ttc="1200.00",
    total_ht="1000.00",
    total_tax="200.00",
)

print(xml_bytes.decode("utf-8"))

Note : cet exemple MINIMUM est intentionnellement simplifié pour illustrer la structure XML de base. Un XML MINIMUM complet en production doit inclure au minimum les champs requis par le profil : montants, devise, et les informations de settlement. Le profil MINIMUM ne contient pas de lignes de facture ni de ventilation TVA — pour ces éléments, voir le profil BASIC ci-dessous.

Points importants :

  • Le format="102" sur DateTimeString indique le format CCYYMMDD (code UN/EDIFACT 2379). C’est obligatoire en CII — un oubli produit un XML structurellement valide mais rejeté par Schematron.
  • BT-30 (identifiant d’enregistrement légal, SIRET en France, schemeID 0002) utilise le code ICD correspondant au registre SIRENE dans la liste ISO 6523.
  • BT-31 (identifiant TVA) utilise schemeID="VA" (Value Added Tax).
  • Les montants sont des strings avec exactement deux décimales. Ne pas utiliser de float Python pour éviter les erreurs d’arrondi (1000.00 et non 1000.0).

Générer un XML CII profil BASIC avec lignes

Le profil BASIC ajoute les lignes de facture. C’est le premier profil qui permet au destinataire de reconstituer le détail de la facture sans lire le PDF.

from decimal import Decimal, ROUND_HALF_UP


def round_2(value: Decimal) -> Decimal:
    """Arrondi à 2 décimales, méthode banker-safe."""
    return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)


def fmt(value: Decimal) -> str:
    """Formate un Decimal en string 2 décimales."""
    return str(round_2(value))


def generate_basic_xml(
    invoice_number: str,
    invoice_date: date,
    currency: str,
    seller_name: str,
    seller_siret: str,
    seller_vat: str,
    seller_address: dict,
    buyer_name: str,
    buyer_siret: str,
    buyer_address: dict,
    lines: list[dict],
    type_code: str = "380",
) -> bytes:
    """
    Génère un XML CII Factur-X profil BASIC avec lignes de facture.

    Chaque ligne dans `lines` : {
        "name": str,
        "quantity": Decimal,
        "unit_price": Decimal,
        "vat_rate": Decimal,       # ex: Decimal("20.00")
        "vat_category": str,       # ex: "S"
    }
    """

    root = etree.Element(qn("rsm", "CrossIndustryInvoice"), nsmap=NS)

    # --- ExchangedDocumentContext ---
    ctx = sub(root, "rsm", "ExchangedDocumentContext")
    guideline = sub(ctx, "ram", "GuidelineSpecifiedDocumentContextParameter")
    sub(guideline, "ram", "ID", GUIDELINE_IDS["BASIC"])

    # --- ExchangedDocument ---
    doc = sub(root, "rsm", "ExchangedDocument")
    sub(doc, "ram", "ID", invoice_number)
    sub(doc, "ram", "TypeCode", type_code)
    issue_dt = sub(doc, "ram", "IssueDateTime")
    sub(issue_dt, "udt", "DateTimeString", invoice_date.strftime("%Y%m%d"), format="102")

    # --- SupplyChainTradeTransaction ---
    transaction = sub(root, "rsm", "SupplyChainTradeTransaction")

    # -- Lignes de facture --
    total_ht = Decimal("0")
    tax_subtotals: dict[tuple[str, str], Decimal] = {}  # (category, rate) -> base

    for idx, line in enumerate(lines, start=1):
        qty = line["quantity"]
        price = line["unit_price"]
        net_amount = round_2(qty * price)
        total_ht += net_amount

        vat_key = (line["vat_category"], fmt(line["vat_rate"]))
        tax_subtotals[vat_key] = tax_subtotals.get(vat_key, Decimal("0")) + net_amount

        li = sub(transaction, "ram", "IncludedSupplyChainTradeLineItem")

        # Identification ligne
        li_doc = sub(li, "ram", "AssociatedDocumentLineDocument")
        sub(li_doc, "ram", "LineID", str(idx))

        # Produit
        product = sub(li, "ram", "SpecifiedTradeProduct")
        sub(product, "ram", "Name", line["name"])

        # Accord commercial ligne
        li_agreement = sub(li, "ram", "SpecifiedLineTradeAgreement")
        net_price = sub(li_agreement, "ram", "NetPriceProductTradePrice")
        sub(net_price, "ram", "ChargeAmount", fmt(price))

        # Livraison ligne (quantité)
        li_delivery = sub(li, "ram", "SpecifiedLineTradeDelivery")
        sub(li_delivery, "ram", "BilledQuantity", str(qty), unitCode="C62")

        # Règlement ligne (montant net + TVA)
        li_settlement = sub(li, "ram", "SpecifiedLineTradeSettlement")
        li_tax = sub(li_settlement, "ram", "ApplicableTradeTax")
        sub(li_tax, "ram", "TypeCode", "VAT")
        sub(li_tax, "ram", "CategoryCode", line["vat_category"])
        sub(li_tax, "ram", "RateApplicablePercent", fmt(line["vat_rate"]))
        li_summation = sub(li_settlement, "ram", "SpecifiedTradeSettlementLineMonetarySummation")
        sub(li_summation, "ram", "LineTotalAmount", fmt(net_amount))

    # -- ApplicableHeaderTradeAgreement --
    agreement = sub(transaction, "ram", "ApplicableHeaderTradeAgreement")

    seller_party = sub(agreement, "ram", "SellerTradeParty")
    sub(seller_party, "ram", "Name", seller_name)
    seller_addr = sub(seller_party, "ram", "PostalTradeAddress")
    sub(seller_addr, "ram", "PostcodeCode", seller_address["postcode"])
    sub(seller_addr, "ram", "LineOne", seller_address["line1"])
    sub(seller_addr, "ram", "CityName", seller_address["city"])
    sub(seller_addr, "ram", "CountryID", seller_address["country"])
    seller_leg = sub(seller_party, "ram", "SpecifiedLegalOrganization")
    sub(seller_leg, "ram", "ID", seller_siret, schemeID="0002")
    seller_tax_reg = sub(seller_party, "ram", "SpecifiedTaxRegistration")
    sub(seller_tax_reg, "ram", "ID", seller_vat, schemeID="VA")

    buyer_party = sub(agreement, "ram", "BuyerTradeParty")
    sub(buyer_party, "ram", "Name", buyer_name)
    buyer_addr = sub(buyer_party, "ram", "PostalTradeAddress")
    sub(buyer_addr, "ram", "PostcodeCode", buyer_address["postcode"])
    sub(buyer_addr, "ram", "LineOne", buyer_address["line1"])
    sub(buyer_addr, "ram", "CityName", buyer_address["city"])
    sub(buyer_addr, "ram", "CountryID", buyer_address["country"])
    buyer_leg = sub(buyer_party, "ram", "SpecifiedLegalOrganization")
    sub(buyer_leg, "ram", "ID", buyer_siret, schemeID="0002")

    # -- ApplicableHeaderTradeDelivery --
    sub(transaction, "ram", "ApplicableHeaderTradeDelivery")

    # -- ApplicableHeaderTradeSettlement --
    settlement = sub(transaction, "ram", "ApplicableHeaderTradeSettlement")
    sub(settlement, "ram", "InvoiceCurrencyCode", currency)

    # Ventilation TVA par catégorie
    total_tax = Decimal("0")
    for (cat, rate_str), base in tax_subtotals.items():
        rate = Decimal(rate_str)
        tax_amount = round_2(base * rate / Decimal("100"))
        total_tax += tax_amount

        tax_elem = sub(settlement, "ram", "ApplicableTradeTax")
        sub(tax_elem, "ram", "CalculatedAmount", fmt(tax_amount))
        sub(tax_elem, "ram", "TypeCode", "VAT")
        sub(tax_elem, "ram", "BasisAmount", fmt(base))
        sub(tax_elem, "ram", "CategoryCode", cat)
        sub(tax_elem, "ram", "RateApplicablePercent", rate_str)

    # Totaux
    total_ttc = total_ht + total_tax
    summation = sub(settlement, "ram", "SpecifiedTradeSettlementHeaderMonetarySummation")
    sub(summation, "ram", "LineTotalAmount", fmt(total_ht))
    sub(summation, "ram", "TaxBasisTotalAmount", fmt(total_ht), currencyID=currency)
    sub(summation, "ram", "TaxTotalAmount", fmt(total_tax), currencyID=currency)
    sub(summation, "ram", "GrandTotalAmount", fmt(total_ttc), currencyID=currency)
    sub(summation, "ram", "DuePayableAmount", fmt(total_ttc), currencyID=currency)

    return etree.tostring(root, xml_declaration=True, encoding="UTF-8", pretty_print=True)


# --- Utilisation ---
xml_bytes = generate_basic_xml(
    invoice_number="FA-2026-0042",
    invoice_date=date(2026, 6, 15),
    currency="EUR",
    seller_name="Acme SAS",
    seller_siret="12345678901234",
    seller_vat="FR12345678901",
    seller_address={
        "line1": "42 rue de la Paix",
        "postcode": "75002",
        "city": "Paris",
        "country": "FR",
    },
    buyer_name="Client SARL",
    buyer_siret="98765432109876",
    buyer_address={
        "line1": "10 avenue des Champs",
        "postcode": "69001",
        "city": "Lyon",
        "country": "FR",
    },
    lines=[
        {
            "name": "Prestation de conseil",
            "quantity": Decimal("10"),
            "unit_price": Decimal("150.00"),
            "vat_rate": Decimal("20.00"),
            "vat_category": "S",
        },
        {
            "name": "Formation technique",
            "quantity": Decimal("2"),
            "unit_price": Decimal("500.00"),
            "vat_rate": Decimal("20.00"),
            "vat_category": "S",
        },
    ],
)

print(xml_bytes.decode("utf-8"))

L’arrondi est le point critique ici. La règle Schematron BR-CO-14 impose que le total TVA de la facture soit exactement la somme des montants TVA par catégorie. L’utilisation de Decimal avec un arrondi explicite par ligne garantit la cohérence arithmétique. Utiliser des float Python provoque des écarts d’arrondi qui déclenchent BR-CO-14 ou BR-CO-15 en validation. Pour un approfondissement des erreurs d’arrondi, voir Valider EN16931/Factur-X : XSD vs Schematron, erreurs BR-*.

Embarquer le XML dans un PDF/A-3

La bibliothèque factur-x d’Akretion gère l’embedding du XML CII dans un PDF existant. Elle attache le XML dans le PDF et effectue une validation XSD, mais ne garantit pas à elle seule la conformité PDF/A-3 complète (ICC profiles, metadata XMP). Une validation veraPDF complémentaire est recommandée pour vérifier la conformité PDF/A-3 du fichier produit. La fonction principale est generate_from_binary().

from facturx import generate_from_binary


def embed_xml_in_pdf(
    pdf_bytes: bytes,
    xml_bytes: bytes,
    facturx_level: str = "basic",
) -> bytes:
    """
    Embarque un XML CII dans un PDF pour produire un PDF/A-3 Factur-X.

    Args:
        pdf_bytes: contenu du PDF source (facture visuelle)
        xml_bytes: XML CII généré
        facturx_level: profil Factur-X (minimum, basicwl, basic, en16931, extended)

    Returns:
        bytes du PDF/A-3 Factur-X
    """
    facturx_pdf_bytes = generate_from_binary(
        pdf_bytes,
        xml_bytes,
        facturx_level=facturx_level,
    )
    return facturx_pdf_bytes


# --- Utilisation ---
# Lire un PDF existant (votre facture visuelle)
with open("facture-visuelle.pdf", "rb") as f:
    pdf_source = f.read()

# XML généré précédemment
facturx_pdf = embed_xml_in_pdf(pdf_source, xml_bytes, facturx_level="basic")

with open("facture-facturx.pdf", "wb") as f:
    f.write(facturx_pdf)

print(f"PDF Factur-X généré : {len(facturx_pdf)} bytes")

Ce que fait generate_from_binary() en interne :

  1. Parse le PDF source avec pypdf
  2. Ajoute les métadonnées XMP Factur-X (namespace fx:, extension schema PDF/A)
  3. Attache le fichier factur-x.xml avec AFRelationship /Alternative
  4. Ajuste les métadonnées PDF/A-3 (pdfaid:part=3, pdfaid:conformance=B)
  5. Retourne le PDF modifié

Le paramètre facturx_level doit correspondre au profil déclaré dans le GuidelineSpecifiedDocumentContextParameter/ID du XML. Un mismatch entre le profil XMP et le profil XML est un piège courant — le fichier sera visuellement identique mais potentiellement rejeté par certaines PDP.

Pour les détails de la conformité PDF/A-3 et les pièges courants (ICC profiles, XMP, AFRelationship), voir PDF/A-3 pour Factur-X : checklist de conformité.

Extraire le XML depuis un PDF Factur-X existant

L’opération inverse — extraire le XML CII d’un PDF Factur-X — est couverte par la fonction get_facturx_xml_from_pdf() de la même bibliothèque.

from facturx import get_facturx_xml_from_pdf


def extract_xml_from_pdf(pdf_path: str) -> tuple[bytes, str]:
    """
    Extrait le XML CII et le profil Factur-X d'un PDF.

    Returns:
        Tuple (xml_bytes, facturx_level)
    """
    with open(pdf_path, "rb") as f:
        xml_bytes, facturx_level = get_facturx_xml_from_pdf(f)

    return xml_bytes, facturx_level


# --- Utilisation ---
xml_content, level = extract_xml_from_pdf("facture-facturx.pdf")

print(f"Profil détecté : {level}")
print(f"Taille XML : {len(xml_content)} bytes")

# Parser le XML extrait
tree = etree.fromstring(xml_content)
invoice_id = tree.find(
    ".//rsm:ExchangedDocument/ram:ID",
    namespaces=NS,
)
print(f"Numéro de facture : {invoice_id.text if invoice_id is not None else 'non trouvé'}")

La fonction retourne un tuple (xml_bytes, level)level est le profil Factur-X détecté (ex: "basic", "en16931"). L’extraction fonctionne pour les PDF Factur-X et ZUGFeRD (les noms de fichiers attachés diffèrent selon la version, mais la bibliothèque gère les deux).

Cas d’usage typiques de l’extraction :

  • Migration : extraire le XML de milliers de PDF archivés pour les réindexer
  • Audit : vérifier que le XML embarqué correspond aux données visuelles du PDF
  • Intégration : alimenter un ERP à partir de factures reçues en Factur-X

Validation XSD avec lxml

La validation XSD vérifie la structure du XML : éléments présents, cardinalités, types de données, namespaces. C’est le premier niveau de validation — nécessaire mais insuffisant.

Les fichiers XSD CII D22B sont publiés par UN/CEFACT. Pour Factur-X, le schéma racine est CrossIndustryInvoice_100pD22B.xsd, distribué dans le pack de conformité FNFE-MPE.

from lxml import etree
from pathlib import Path


def validate_xsd(xml_bytes: bytes, xsd_path: str) -> list[str]:
    """
    Valide un XML CII contre le schéma XSD.

    Args:
        xml_bytes: contenu XML à valider
        xsd_path: chemin vers CrossIndustryInvoice_100pD22B.xsd

    Returns:
        Liste d'erreurs (vide si valide)
    """
    xsd_doc = etree.parse(xsd_path)
    xsd_schema = etree.XMLSchema(xsd_doc)

    try:
        xml_doc = etree.fromstring(xml_bytes)
    except etree.XMLSyntaxError as e:
        return [f"Erreur de parsing XML : {e}"]

    is_valid = xsd_schema.validate(xml_doc)

    if is_valid:
        return []

    return [
        f"Ligne {err.line}, colonne {err.column}: {err.message}"
        for err in xsd_schema.error_log
    ]


# --- Utilisation ---
xsd_dir = Path("schemas/facturx/1.08")
errors = validate_xsd(xml_bytes, str(xsd_dir / "CrossIndustryInvoice_100pD22B.xsd"))

if errors:
    print(f"{len(errors)} erreur(s) XSD :")
    for err in errors:
        print(f"  - {err}")
else:
    print("Validation XSD : OK")

Erreurs XSD fréquentes et leurs causes :

  • This element is not expected — Ordre des éléments enfants incorrect dans la séquence XML (le XSD CII impose un ordre strict).
  • is not a valid value of the atomic type — Namespace manquant ou incorrect sur l’élément.
  • missing required attribute 'format' — Attribut format="102" absent sur udt:DateTimeString.
  • Not all fields of the keyref … evaluate to a node — Référence croisée brisée dans le schéma.

Un XML qui passe la validation XSD n’est pas nécessairement conforme EN16931. Le XSD vérifie la forme, pas le fond. Pour les règles métier (cohérence arithmétique, codes valides, champs conditionnels), il faut le Schematron.

Validation Schematron

Le Schematron est le second niveau de validation. Il exprime les Business Rules EN16931 (codes BR-*) sous forme d’assertions XPath. Les fichiers Schematron sont publiés par le CEN TC 434 sur GitHub (ConnectingEurope/eInvoicing-EN16931). Les extensions Factur-X sont publiées par FNFE-MPE.

Option 1 : lxml.etree.Schematron (local)

lxml supporte nativement Schematron via lxml.etree.Schematron. Cette approche fonctionne directement avec les fichiers .sch après compilation en XSLT (ou avec les fichiers .xslt pré-compilés fournis par le CEN).

from lxml import etree, isoschematron


def validate_schematron(xml_bytes: bytes, schematron_path: str) -> list[dict]:
    """
    Valide un XML CII contre un fichier Schematron.

    Args:
        xml_bytes: contenu XML
        schematron_path: chemin vers le fichier .sch ou .xslt

    Returns:
        Liste de dicts {id, text, location} pour chaque assertion échouée
    """
    xml_doc = etree.fromstring(xml_bytes)

    sch_doc = etree.parse(schematron_path)
    schematron = isoschematron.Schematron(
        sch_doc,
        store_report=True,
    )

    is_valid = schematron.validate(xml_doc)

    if is_valid:
        return []

    # Extraire les assertions échouées du rapport SVRL
    svrl = schematron.validation_report
    svrl_ns = {"svrl": "http://purl.oclc.org/dml/svrl"}

    errors = []
    for failed in svrl.findall(".//svrl:failed-assert", namespaces=svrl_ns):
        errors.append({
            "id": failed.get("id", "unknown"),
            "location": failed.get("location", ""),
            "text": failed.findtext("svrl:text", default="", namespaces=svrl_ns).strip(),
        })

    return errors


# --- Utilisation ---
sch_errors = validate_schematron(
    xml_bytes,
    "schemas/schematron/EN16931-CII-validation.sch",
)

if sch_errors:
    print(f"{len(sch_errors)} erreur(s) Schematron :")
    for err in sch_errors:
        print(f"  [{err['id']}] {err['text']}")
        print(f"    Location: {err['location']}")
else:
    print("Validation Schematron : OK")

Limites importantes de cette approche :

  • La validation Schematron via lxml (lxml.isoschematron) est limitée à XPath 1.0 / XSLT 1.0. Les artefacts Schematron EN16931 officiels peuvent utiliser des fonctions XPath 2.0 (comme xs:decimal), nécessitant un processeur compatible (Saxon, KoSIT validateur) pour une validation complète. En conséquence, certaines règles BR-* peuvent ne pas être correctement évaluées par lxml seul.
  • Si des erreurs de compilation apparaissent, la variante XSLT pré-compilée (option 2 ci-dessous) ou la validation via API (option 3) est plus fiable.
  • Les fichiers Schematron EN16931 du CEN sont mis à jour régulièrement. La version des artefacts doit correspondre à la version de la norme que vous ciblez.

Option 2 : XSLT pré-compilé (plus fiable)

Le CEN publie aussi les Schematron sous forme de feuilles XSLT pré-compilées. Cette approche contourne les limites du compilateur Schematron de lxml.

def validate_via_xslt(xml_bytes: bytes, xslt_path: str) -> list[dict]:
    """
    Valide via la transformation XSLT pré-compilée du Schematron.

    Le XSLT transforme le XML source en un rapport SVRL.
    Les failed-assert dans le SVRL indiquent les violations.
    """
    xml_doc = etree.fromstring(xml_bytes)
    xslt_doc = etree.parse(xslt_path)
    transform = etree.XSLT(xslt_doc)

    # La transformation produit un document SVRL
    svrl = transform(xml_doc)

    svrl_ns = {"svrl": "http://purl.oclc.org/dml/svrl"}
    errors = []
    for failed in svrl.findall(".//svrl:failed-assert", namespaces=svrl_ns):
        errors.append({
            "id": failed.get("id", "unknown"),
            "location": failed.get("location", ""),
            "text": failed.findtext("svrl:text", default="", namespaces=svrl_ns).strip(),
        })

    return errors

Option 3 : déléguer à l’API FacturX

La validation locale requiert de maintenir les fichiers XSD, Schematron, et leurs mises à jour. Pour les équipes qui préfèrent déléguer cette maintenance, l’API FacturX exécute la chaîne complète (XSD + Schematron EN16931 + Schematron Factur-X profil) en une requête.

import requests


def validate_via_api(
    file_path: str,
    api_key: str,
    base_url: str = "https://facturxapi.com/api/v1",
) -> dict:
    """
    Valide un fichier (PDF ou XML) via l'API FacturX.

    Returns:
        Résultat complet de validation (valid, errors, warnings, etc.)
    """
    with open(file_path, "rb") as f:
        response = requests.post(
            f"{base_url}/validate",
            headers={"X-API-Key": api_key},
            files={"file": (file_path, f)},
            timeout=30,
        )

    response.raise_for_status()
    return response.json()


# --- Utilisation ---
result = validate_via_api("facture-facturx.pdf", api_key="votre-cle-api")

if result["valid"]:
    print("Facture conforme EN16931")
else:
    print(f"{len(result['errors'])} erreur(s) :")
    for err in result["errors"]:
        print(f"  [{err['id']}] {err['message']}")

L’API accepte aussi bien un PDF Factur-X complet (validation PDF/A-3 + XSD + Schematron) qu’un XML CII seul (validation XSD + Schematron uniquement). Le rapport retourné est structuré en JSON avec les codes BR-*, les messages explicatifs et les chemins XPath.

Pipeline complet : générer, embarquer, valider

Voici un script qui orchestre les quatre étapes en séquence. C’est le squelette d’un pipeline de production — adaptable à un batch de fin de mois ou à un worker asynchrone.

"""
Pipeline complet Factur-X : génération → embedding → validation → rapport.
"""
import sys
import json
from datetime import date
from decimal import Decimal
from pathlib import Path

from lxml import etree
from facturx import generate_from_binary
import requests

# Réutiliser les fonctions définies précédemment :
# generate_basic_xml, validate_xsd, validate_via_api


def pipeline(
    invoice_data: dict,
    pdf_source_path: str,
    output_path: str,
    api_key: str,
    xsd_path: str | None = None,
) -> dict:
    """
    Pipeline complet : génération XML → embedding PDF → validation.

    Returns:
        dict avec le statut de chaque étape
    """
    report = {"steps": [], "success": False}

    # --- Étape 1 : Générer le XML CII ---
    try:
        xml_bytes = generate_basic_xml(**invoice_data)
        report["steps"].append({
            "step": "generate_xml",
            "status": "ok",
            "xml_size": len(xml_bytes),
        })
    except Exception as e:
        report["steps"].append({
            "step": "generate_xml",
            "status": "error",
            "message": str(e),
        })
        return report

    # --- Étape 2 : Validation XSD locale (optionnelle) ---
    if xsd_path:
        xsd_errors = validate_xsd(xml_bytes, xsd_path)
        report["steps"].append({
            "step": "validate_xsd",
            "status": "ok" if not xsd_errors else "error",
            "errors": xsd_errors,
        })
        if xsd_errors:
            # Inutile de continuer si le XML n'est pas structurellement valide
            return report

    # --- Étape 3 : Embarquer dans le PDF ---
    try:
        with open(pdf_source_path, "rb") as f:
            pdf_source = f.read()

        facturx_pdf = generate_from_binary(
            pdf_source,
            xml_bytes,
            facturx_level="basic",
        )

        output = Path(output_path)
        output.write_bytes(facturx_pdf)

        report["steps"].append({
            "step": "embed_pdf",
            "status": "ok",
            "output": str(output),
            "pdf_size": len(facturx_pdf),
        })
    except Exception as e:
        report["steps"].append({
            "step": "embed_pdf",
            "status": "error",
            "message": str(e),
        })
        return report

    # --- Étape 4 : Validation complète via l'API ---
    try:
        api_result = validate_via_api(output_path, api_key)
        report["steps"].append({
            "step": "validate_api",
            "status": "ok" if api_result["valid"] else "error",
            "valid": api_result["valid"],
            "errors": api_result.get("errors", []),
            "warnings": api_result.get("warnings", []),
        })
        report["success"] = api_result["valid"]
    except requests.RequestException as e:
        report["steps"].append({
            "step": "validate_api",
            "status": "error",
            "message": str(e),
        })

    return report


# --- Exécution ---
if __name__ == "__main__":
    result = pipeline(
        invoice_data={
            "invoice_number": "FA-2026-0042",
            "invoice_date": date(2026, 6, 15),
            "currency": "EUR",
            "seller_name": "Acme SAS",
            "seller_siret": "12345678901234",
            "seller_vat": "FR12345678901",
            "seller_address": {
                "line1": "42 rue de la Paix",
                "postcode": "75002",
                "city": "Paris",
                "country": "FR",
            },
            "buyer_name": "Client SARL",
            "buyer_siret": "98765432109876",
            "buyer_address": {
                "line1": "10 avenue des Champs",
                "postcode": "69001",
                "city": "Lyon",
                "country": "FR",
            },
            "lines": [
                {
                    "name": "Prestation de conseil",
                    "quantity": Decimal("10"),
                    "unit_price": Decimal("150.00"),
                    "vat_rate": Decimal("20.00"),
                    "vat_category": "S",
                },
                {
                    "name": "Formation technique",
                    "quantity": Decimal("2"),
                    "unit_price": Decimal("500.00"),
                    "vat_rate": Decimal("20.00"),
                    "vat_category": "S",
                },
            ],
        },
        pdf_source_path="facture-visuelle.pdf",
        output_path="output/FA-2026-0042-facturx.pdf",
        api_key="votre-cle-api",
        xsd_path="schemas/facturx/1.08/CrossIndustryInvoice_100pD22B.xsd",
    )

    print(json.dumps(result, indent=2, ensure_ascii=False, default=str))

    if not result["success"]:
        sys.exit(1)

Ce pipeline est conçu pour échouer le plus tôt possible. Si le XML est invalide en XSD, on ne tente pas l’embedding PDF (inutile de perdre du temps). Si l’embedding échoue, on ne tente pas la validation API. Le rapport structuré permet d’identifier exactement quelle étape a échoué et pourquoi.

Traitement par lots

En production, on génère rarement une seule facture. Voici un pattern pour traiter un batch de factures avec gestion d’erreurs par élément :

import concurrent.futures
from dataclasses import dataclass


@dataclass
class BatchResult:
    total: int
    success: int
    failed: int
    errors: list[dict]


def process_batch(
    invoices: list[dict],
    pdf_template_path: str,
    output_dir: str,
    api_key: str,
    max_workers: int = 4,
) -> BatchResult:
    """
    Traite un lot de factures en parallèle.

    Chaque facture est générée, embarquée et validée indépendamment.
    Une erreur sur une facture n'arrête pas le traitement des autres.
    """
    output = Path(output_dir)
    output.mkdir(parents=True, exist_ok=True)

    results = BatchResult(total=len(invoices), success=0, failed=0, errors=[])

    def process_one(invoice_data: dict) -> dict:
        inv_num = invoice_data["invoice_number"]
        out_path = str(output / f"{inv_num}-facturx.pdf")
        return pipeline(
            invoice_data=invoice_data,
            pdf_source_path=pdf_template_path,
            output_path=out_path,
            api_key=api_key,
        )

    with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
        future_to_invoice = {
            executor.submit(process_one, inv): inv
            for inv in invoices
        }

        for future in concurrent.futures.as_completed(future_to_invoice):
            inv = future_to_invoice[future]
            try:
                report = future.result()
                if report["success"]:
                    results.success += 1
                else:
                    results.failed += 1
                    results.errors.append({
                        "invoice": inv["invoice_number"],
                        "report": report,
                    })
            except Exception as e:
                results.failed += 1
                results.errors.append({
                    "invoice": inv.get("invoice_number", "unknown"),
                    "exception": str(e),
                })

    return results

Le max_workers doit être ajusté selon la capacité de votre plan API (nombre de requêtes par seconde). Pour les très gros volumes, un système de queue (Celery, RQ) est préférable au thread pool.

Pièges courants

1. Encodage UTF-8 et BOM

Le XML CII doit être encodé en UTF-8 sans BOM (Byte Order Mark). Certains éditeurs Windows ajoutent un BOM (\xef\xbb\xbf) en début de fichier. Ce BOM est invisible mais provoque des erreurs de parsing dans certains validateurs.

# Vérifier et supprimer un BOM éventuel
def strip_bom(xml_bytes: bytes) -> bytes:
    if xml_bytes.startswith(b"\xef\xbb\xbf"):
        return xml_bytes[3:]
    return xml_bytes

lxml.etree.tostring() avec encoding="UTF-8" produit un XML sans BOM. Le problème survient quand on lit un fichier XML externe écrit par un autre outil.

2. Namespace manquant dans les recherches XPath

L’erreur la plus fréquente avec lxml : oublier de passer les namespaces lors d’une recherche XPath.

# Incorrect — ne trouve rien car pas de namespace
tree.find(".//ExchangedDocument/ID")  # Retourne None

# Correct — namespaces explicites
tree.find(
    ".//rsm:ExchangedDocument/ram:ID",
    namespaces={
        "rsm": "urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100",
        "ram": "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100",
    },
)

C’est un piège silencieux : find() retourne None au lieu de lever une exception, donc le code continue avec une valeur manquante.

3. Format de date CII (format=“102”)

Le format de date en CII n’est pas ISO 8601. Le code 102 dans l’attribut format signifie CCYYMMDD — pas de tirets, pas de T.

# Incorrect — ISO 8601, rejeté par le XSD
"2026-06-15"

# Correct — CCYYMMDD avec format="102"
"20260615"

L’attribut format est obligatoire sur udt:DateTimeString. Son absence provoque une erreur XSD.

4. Confusion entre profil XML et profil d’embedding

Le GuidelineSpecifiedDocumentContextParameter/ID dans le XML déclare le profil Factur-X. Le paramètre facturx_level passé à generate_from_binary() contrôle les métadonnées XMP du PDF. Les deux doivent correspondre.

# Le GuidelineID dans le XML dit "basic"
GUIDELINE_IDS["BASIC"]  # "urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:basic"

# Le paramètre d'embedding doit aussi dire "basic"
generate_from_binary(pdf, xml, facturx_level="basic")  # Cohérent

# Erreur : XML dit "en16931" mais embedding dit "basic"
# → Les métadonnées XMP (fx:ConformanceLevel) ne correspondront pas au XML

5. Ordre des éléments XML

Le schéma XSD CII impose un ordre strict des éléments enfants (c’est une xs:sequence, pas un xs:all). En particulier :

  • Dans CrossIndustryInvoice : ExchangedDocumentContext avant ExchangedDocument avant SupplyChainTradeTransaction
  • Dans SupplyChainTradeTransaction : les IncludedSupplyChainTradeLineItem avant ApplicableHeaderTradeAgreement avant ApplicableHeaderTradeDelivery avant ApplicableHeaderTradeSettlement
  • Dans ApplicableHeaderTradeSettlement : InvoiceCurrencyCode avant ApplicableTradeTax avant SpecifiedTradeSettlementHeaderMonetarySummation

Inverser deux éléments produit une erreur XSD : This element is not expected. Expected is .... Le code de génération ci-dessus respecte cet ordre.

6. Montants float vs Decimal

# Dangereux — arrondi imprévisible
total = 33.33 * 3  # 99.99000000000001 en float

# Sûr — arrondi déterministe
total = Decimal("33.33") * 3  # Decimal("99.99") exact

Toujours utiliser decimal.Decimal pour les montants financiers. Un écart de 0,01 suffit à déclencher BR-CO-14 (total TVA incohérent) ou BR-CO-15 (montant TTC incohérent).

Ressources

La clé API gratuite permet de tester immédiatement votre pipeline de validation. Le rapport JSON structuré — codes BR-*, messages, chemins XPath — s’intègre directement dans les scripts Python ci-dessus pour un cycle de correction automatisé.

#python #factur-x #en16931 #cii #xml #validation #pdf-a3