Dans un billet précédent Lire →, j'ai transformé une facture PDF produite par mon serveur en document hybride Factur-X : PDF/A-3, métadonnées XMP, XML embarqué et validation. Cette première étape était pragmatique : partir d'un XML modèle, remplacer quelques valeurs, insérer le fichier factur-x.xml dans le PDF. Elle permet de démarrer vite, mais elle a une limite : dès que les cas réels se multiplient (Chorus, exports, profils différents), la substitution de texte dans un template devient fragile. Elle est en outre inadaptée au profil EN16931, qui se profile comme obligatoire.
Ce billet traite uniquement du profil Factur-X Minimum. Le but est de construire proprement un XML Minimum avec ASP Classic, MSXML et VBScript, pour disposer d'une base robuste avant de passer aux profils plus riches.
Pourquoi abandonner le remplacement dans un template XML ?
La première version de mon script lisait un fichier XML modèle extrait d'une facture valide, puis remplaçait des fragments par les valeurs réelles. C'est une bonne méthode d'apprentissage — on part d'un fichier connu comme valide — mais en production les défauts apparaissent vite. Le script dépend de chaînes exactes : un espace, un retour à la ligne ou une variation du template peut casser le remplacement. On risque aussi de laisser des valeurs de démonstration dans le XML, d'embarquer des balises non pertinentes ou de déclencher des erreurs Schematron sur des balises facultatives vides. Voici un exemple typique de ce type de remplacement fragile :
sContent = Replace(sContent, "<ram:BuyerReference>SRVIT</ram:BuyerReference>", _
"<ram:BuyerReference>" & sChorus_service & "</ram:BuyerReference>")
sContent = Replace(sContent, "<ram:TaxTotalAmount currencyID=""EUR"">69.40</ram:TaxTotalAmount>", _
"<ram:TaxTotalAmount currencyID=""EUR"">" & TaxTotalAmount & "</ram:TaxTotalAmount>")
Ce code fonctionne tant que le modèle reste strictement identique. Il ne sait pas raisonner sur la structure XML : il ne peut pas omettre une balise si la valeur est absente.
La logique ex nihilo avec MSXML2.DOMDocument.6.0
Construire le XML ex nihilo signifie que le serveur crée lui-même l'arbre XML, nœud par nœud, avec MSXML2.DOMDocument.6.0. On ne manipule plus une chaîne de caractères, mais un document XML structuré. Les balises sont créées explicitement, les namespaces déclarés une seule fois, les montants normalisés avant insertion, et les balises facultatives vraiment facultatives. C'est aussi plus cohérent avec la logique serveur : une facture est déjà un objet de gestion présent en base SQL — le XML Factur-X n'en est qu'une autre représentation.
Set oXml = Server.CreateObject("MSXML2.DOMDocument.6.0")
oXml.async = False
oXml.validateOnParse = False
oXml.resolveExternals = False
oXml.preserveWhiteSpace = True
Const NS_RSM = "urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
Const NS_RAM = "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"
Const NS_UDT = "urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100"
Avec MSXML, createElement seul ne suffit pas toujours pour contrôler correctement les namespaces. La méthode createNode est plus explicite : on indique le type de nœud, le nom qualifié et l'URI du namespace.
Deux fonctions utilitaires : AddNode et AddTextNode
Pour éviter une forêt de appendChild, deux fonctions utilitaires font le travail : créer un nœud, l'ajouter au parent, renvoyer le nœud créé. Le vrai gain est dans AddTextNodeIfValue, qui évite de créer des balises vides — l'une des erreurs les plus pénibles en validation Schematron.
Function AddNode(ByRef oDoc, ByRef oParent, ByVal nsUri, ByVal qName)
Dim oNode
Set oNode = oDoc.createNode(1, qName, nsUri)
oParent.appendChild oNode
Set AddNode = oNode
End Function
Function AddTextNode(ByRef oDoc, ByRef oParent, ByVal nsUri, ByVal qName, ByVal sText)
Dim oNode
Set oNode = AddNode(oDoc, oParent, nsUri, qName)
oNode.Text = CStr(sText)
Set AddTextNode = oNode
End Function
Sub AddTextNodeIfValue(ByRef oDoc, ByRef oParent, ByVal nsUri, ByVal qName, ByVal sText)
If Len(Trim(CStr(Nz(sText)))) > 0 Then
Call AddTextNode(oDoc, oParent, nsUri, qName, sText)
End If
End Sub
Function AddAmountNode(ByRef oDoc, ByRef oParent, ByVal qName, ByVal amount, ByVal currencyId)
Dim oNode
Set oNode = AddTextNode(oDoc, oParent, NS_RAM, qName, XmlAmount(amount))
If Len(currencyId) > 0 Then oNode.setAttribute "currencyID", currencyId
Set AddAmountNode = oNode
End Function
Normaliser dates et montants
En ASP Classic français, les montants circulent souvent avec une virgule décimale et parfois des espaces insécables. Le XML Factur-X attend des nombres avec un point décimal. La date doit être au format YYYYMMDD (format 102) : une facture du 23 mai 2026 devient 20260523.
Function Nz(ByVal v)
If IsNull(v) Or IsEmpty(v) Then Nz = "" Else Nz = Trim(CStr(v))
End Function
Function XmlAmount(ByVal v)
' Normalise les montants ASP/FR : "1 234,56" -> "1234.56"
Dim s
s = Trim(CStr(v))
s = Replace(s, Chr(160), "")
s = Replace(s, " ", "")
s = Replace(s, ",", ".")
If InStr(1, s, ".", vbTextCompare) = 0 Then s = s & ".00"
XmlAmount = s
End Function
Function Date102(ByVal d)
' udt:DateTimeString format="102" = YYYYMMDD
Date102 = CStr(Year(d)) & Right("0" & Month(d), 2) & Right("0" & Day(d), 2)
End Function
Function HasValue(ByVal v)
HasValue = (Len(Trim(CStr(Nz(v)))) > 0)
End Function
Construire la racine du XML Minimum
La fonction suivante assemble l'ossature du XML Minimum : contexte documentaire, numéro de facture, date, vendeur, acheteur, références Chorus et sommation monétaire. C'est le cœur du profil.
Function BuildFacturXMinimumXml( _
ByVal numeroFacture, ByVal dateFacture, _
ByVal sellerName, ByVal sellerSiret, ByVal sellerVat, _
ByVal buyerName, ByVal buyerSiret, _
ByVal buyerReference, ByVal orderReference, _
ByVal totalHT, ByVal totalTVA, ByVal totalTTC)
Dim oXml, root, docContext, guideline
Dim exchangedDocument, issueDateTime
Dim transaction, agreement, seller, buyer, settlement, monetary
Set oXml = Server.CreateObject("MSXML2.DOMDocument.6.0")
oXml.async = False
oXml.validateOnParse = False
oXml.resolveExternals = False
oXml.preserveWhiteSpace = True
Set root = oXml.createNode(1, "rsm:CrossIndustryInvoice", NS_RSM)
root.setAttribute "xmlns:rsm", NS_RSM
root.setAttribute "xmlns:ram", NS_RAM
root.setAttribute "xmlns:udt", NS_UDT
oXml.appendChild root
Set docContext = AddNode(oXml, root, NS_RSM, "rsm:ExchangedDocumentContext")
Set guideline = AddNode(oXml, docContext, NS_RAM, "ram:GuidelineSpecifiedDocumentContextParameter")
Call AddTextNode(oXml, guideline, NS_RAM, "ram:ID", "urn:factur-x.eu:1p0:minimum")
Set exchangedDocument = AddNode(oXml, root, NS_RSM, "rsm:ExchangedDocument")
Call AddTextNode(oXml, exchangedDocument, NS_RAM, "ram:ID", numeroFacture)
Call AddTextNode(oXml, exchangedDocument, NS_RAM, "ram:TypeCode", "380")
Set issueDateTime = AddNode(oXml, exchangedDocument, NS_RAM, "ram:IssueDateTime")
Dim dateNode
Set dateNode = AddTextNode(oXml, issueDateTime, NS_UDT, "udt:DateTimeString", Date102(dateFacture))
dateNode.setAttribute "format", "102"
Set transaction = AddNode(oXml, root, NS_RSM, "rsm:SupplyChainTradeTransaction")
Set agreement = AddNode(oXml, transaction, NS_RAM, "ram:ApplicableHeaderTradeAgreement")
' BuyerReference est facultatif : si vide, on ne crée PAS la balise.
Call AddTextNodeIfValue(oXml, agreement, NS_RAM, "ram:BuyerReference", buyerReference)
Set seller = AddNode(oXml, agreement, NS_RAM, "ram:SellerTradeParty")
Call AddTextNode(oXml, seller, NS_RAM, "ram:Name", sellerName)
Call AddLegalOrg(oXml, seller, sellerSiret)
Call AddVatId(oXml, seller, sellerVat)
Set buyer = AddNode(oXml, agreement, NS_RAM, "ram:BuyerTradeParty")
Call AddTextNode(oXml, buyer, NS_RAM, "ram:Name", buyerName)
Call AddLegalOrgIfValue(oXml, buyer, buyerSiret)
If HasValue(orderReference) Then
Dim orderDoc
Set orderDoc = AddNode(oXml, agreement, NS_RAM, "ram:BuyerOrderReferencedDocument")
Call AddTextNode(oXml, orderDoc, NS_RAM, "ram:IssuerAssignedID", orderReference)
End If
Set settlement = AddNode(oXml, transaction, NS_RAM, "ram:ApplicableHeaderTradeSettlement")
Call AddTextNode(oXml, settlement, NS_RAM, "ram:InvoiceCurrencyCode", "EUR")
Set monetary = AddNode(oXml, settlement, NS_RAM, "ram:SpecifiedTradeSettlementHeaderMonetarySummation")
Call AddAmountNode(oXml, monetary, "ram:TaxBasisTotalAmount", totalHT, "")
Call AddAmountNode(oXml, monetary, "ram:TaxTotalAmount", totalTVA, "EUR")
Call AddAmountNode(oXml, monetary, "ram:GrandTotalAmount", totalTTC, "")
Call AddAmountNode(oXml, monetary, "ram:DuePayableAmount", totalTTC, "")
BuildFacturXMinimumXml = "<?xml version=""1.0"" encoding=""UTF-8""?>" & vbCrLf & oXml.xml
End Function
Vendeur, acheteur, SIRET et TVA
Pour une société française, le SIRET est posé avec schemeID="0002" et le numéro de TVA intracommunautaire avec schemeID="VA". Pour l'acheteur public, le SIRET et le service Chorus doivent provenir de la base : si une donnée est absente, la balise doit être omise — pas laissée vide.
Sub AddLegalOrg(ByRef oDoc, ByRef partyNode, ByVal siret)
Dim org, idNode
Set org = AddNode(oDoc, partyNode, NS_RAM, "ram:SpecifiedLegalOrganization")
Set idNode = AddTextNode(oDoc, org, NS_RAM, "ram:ID", siret)
idNode.setAttribute "schemeID", "0002" ' 0002 = SIRET
End Sub
Sub AddLegalOrgIfValue(ByRef oDoc, ByRef partyNode, ByVal siret)
If HasValue(siret) Then Call AddLegalOrg(oDoc, partyNode, siret)
End Sub
Sub AddVatId(ByRef oDoc, ByRef partyNode, ByVal vatNumber)
If HasValue(vatNumber) Then
Dim taxReg, idNode
Set taxReg = AddNode(oDoc, partyNode, NS_RAM, "ram:SpecifiedTaxRegistration")
Set idNode = AddTextNode(oDoc, taxReg, NS_RAM, "ram:ID", vatNumber)
idNode.setAttribute "schemeID", "VA"
End If
End Sub
Appel depuis le serveur ASP
Le serveur dispose déjà de toutes les informations — dossier client, panier, TVA, références Chorus. L'appel à la fonction reste simple. Attention à l'encodage : le XML annonce UTF-8, et en ASP Classic les conversions implicites peuvent surprendre avec les accents. Il faut tester avec des noms clients réels.
Dim sXml, xmlOutput
xmlOutput = Server.MapPath("/Factures/XML/factur-x.xml")
sXml = BuildFacturXMinimumXml( _
numero_facture, Date(), _
"GRANULOSHOP SAS", "91510597700010", "FR81915105977", _
RsDossier("Client_Nom").Value, RsDossier("Client_Siret").Value, _
RsDossier("Chorus_Service").Value, RsDossier("Engagement").Value, _
Basket_HT, Basket_TVA, Basket_TTC)
Set objFile = objFSO.OpenTextFile(xmlOutput, 2, True)
objFile.Write sXml
objFile.Close
Set objFile = Nothing
Le piège des balises facultatives vides
C'est probablement le point qui justifie à lui seul l'abandon du template. Une balise facultative absente est différente d'une balise vide : selon le contexte, le validateur Schematron peut refuser une balise vide ou mal positionnée. Exemple avec BuyerReference :
' Mauvaise idée : produire une balise vide.
Call AddTextNode(oXml, agreement, NS_RAM, "ram:BuyerReference", "")
' XML obtenu : <ram:BuyerReference></ram:BuyerReference>
' Bonne pratique : ne créer la balise que si elle a une vraie valeur.
Call AddTextNodeIfValue(oXml, agreement, NS_RAM, "ram:BuyerReference", buyerReference)
Valider d'abord le XML seul, puis le PDF
Avant d'insérer le XML dans un PDF/A-3, il faut le valider seul — sinon on mélange les problèmes de conformité XML et de conformité PDF. Une vérification rapide avec MSXML détecte immédiatement les erreurs de XML mal formé : guillemets, esperluettes, balises non fermées.
If Not oXml.load(xmlOutput) Then
Response.Write "<h3>Erreur XML</h3><pre>"
Response.Write Server.HTMLEncode(oXml.parseError.reason)
Response.Write "Ligne: " & oXml.parseError.line & vbCrLf
Response.Write "Position: " & oXml.parseError.linepos
Response.Write "</pre>"
Response.End
End If
Ensuite seulement, on soumet le fichier au validateur FNFE-MPE. Pour le profil Minimum, les messages sont en général directs : mauvais nœud, attribut absent, format de date incorrect, montant mal formé.
Le validateur de B2Brouteur a une fonction sympathique: il fait un rendu 'comme sur le papier' du XML. C'est très visuel. Lire →. On mentionne, pour mémoire, le xrechnung-validator.
Vérifier le PDF/A-3 et les métadonnées XMP
Un PDF Factur-X valide n'est pas seulement un PDF avec un XML attaché : les métadonnées XMP doivent annoncer correctement le profil, le nom du fichier XML embarqué et la conformité PDF/A-3. Pour cette couche, j'utilise veraPDF pour la conformité PDF/A, ExifTool pour inspecter les métadonnées, et FNFE-MPE pour la validation globale. Sous Windows, ExifTool donne un rapport texte exploitable et archivable :
@echo off
set EXIF=C:\Tools\exiftool-13.58_64\exiftool.exe
set PDF=C:\Temp\Facture_FacturX.pdf
"%EXIF%" -a -G1 -s "%PDF%" > C:\Temp\Facture_XMP.txt
echo Controle XMP termine. Voir C:\Temp\Facture_XMP.txt
pause
Extraire le XML réellement embarqué avec PDFdetach
Une erreur courante consiste à valider le bon XML, puis à embarquer un autre fichier dans le PDF final — cela arrive facilement avec plusieurs dossiers de tests ou d'archives. Il faut extraire le XML du PDF final et le comparer au XML source. L'utilitaire PDFdetach (suite Poppler) est idéal pour cela :
pdfdetach.exe -list Facture.pdf
pdfdetach.exe -saveall Facture.pdf
Poppler pour Windows est disponible sur GitHub ou en version CLI sur xpdfreader.com. Une fois extrait, la comparaison peut être faite programmatiquement :
sourceXml = ReadAllText("C:\Temp\factur-x-source.xml")
embeddedXml = ReadAllText("C:\Temp\factur-x-extrait-du-pdf.xml")
If sourceXml <> embeddedXml Then
Call Log("ATTENTION: le XML embarque n'est pas identique au XML source.")
Else
Call Log("OK: XML source = XML embarque.")
End If
Validation XML et validation métier : deux choses distinctes
Un XML conforme au schéma XSD, aux règles Schematron et au profil Factur-X Minimum peut parfaitement être rejeté par Chorus Pro lors de la validation métier. Chorus effectue souvent deux étapes successives : une validation technique immédiate, puis une validation métier différée. Le cas est déroutant au début — Chorus peut délivrer un accusé technique, puis envoyer plusieurs heures plus tard un mail de rejet :
Suivi des flux :
n°26015 du ... reçue le ... a été rejetée pour le(s) motif(s) suivants :
L'element FichierXml.SupplyChainTradeTransaction.ApplicableHeaderTradeAgreement.BuyerTradeParty.
SpecifiedLegalOrganization.ID.value est obligatoire.
L'identifiant debiteur [...] n'est pas reference dans notre systeme.
En pratique, les causes réelles de rejet restent limitées : SIRET incorrect ou inexistant dans l'annuaire Chorus, service destinataire inconnu, identifiant acheteur erroné, ou balise facultative laissée vide. La meilleure approche consiste à comparer le mail de rejet, le XML réellement envoyé et le message d'erreur exact — ChatGPT peut aider à analyser précisément le rejet et proposer une correction ciblée.
Debug : comparer un XML valide et un XML invalide
Quand un validateur refuse un XML, il ne faut pas corriger au hasard. Je conserve trois fichiers : le XML source produit par le serveur, le XML extrait du PDF final et un XML de référence déjà validé. Les différences les plus importantes sont rarement les valeurs visibles — ce sont plutôt une balise vide qui ne devrait pas exister, un namespace absent ou mal déclaré, l'attribut format="102" oublié, un montant avec virgule, ou une référence Chorus placée dans le mauvais bloc. Avec le XML ex nihilo, on remonte directement à la fonction qui crée la balise fautive.
Résultat : Chorus Pro reconnaît la facture
Une fois le XML Minimum correctement généré et embarqué, Chorus Pro identifie le PDF comme une facture hybride Factur-X et évite la page de correction manuelle. Le gain opérationnel est immédiat.
Bilan
Le remplacement de template permet de faire sa première Factur-X rapidement. La construction ex nihilo rend le serveur robuste et maintenable. Le point clé n'est pas la quantité de code, mais le contrôle : savoir exactement quelle balise est produite, pourquoi elle est là, et quand elle ne doit pas l'être. La prochaine étape — profil EN16931, lignes détaillées, TVA complexe, PEPPOL — fera l'objet d'un billet séparé.