Développeur
Valider une facture en Node.js
Votre moteur de facturation tourne sur Node.js ? Contrôlez chaque facture avant de l'envoyer, avec
fetch et FormData, que Node.js fournit sans aucun paquet à installer. Voici un
module complet : envoi du fichier, délai borné, lecture du verdict et traitement de chaque refus.
Avant de commencer
-
Node.js 22 ou plus récent.
fetch,FormData,BlobetAbortSignal.timeout()y sont disponibles sans import ni option : il n'y a rien à ajouter à votrepackage.json. -
Une clé API, créée sur votre page de clés API, copiée
dès sa création. Le programme la lit dans la variable d'environnement
VALIDE_FACTURE_API_KEY; ne l'écrivez pas dans le code source. - Une facture : un PDF Factur-X, ou un XML CII ou UBL.
Le module complet
Enregistrez-le sous le nom valider.mjs. L'extension .mjs en fait un module
ES, ce qui autorise import et await au premier niveau, quel que soit le
champ type de votre package.json.
// Valide une facture : node valider.mjs facture.pdf
// La clé API est lue dans la variable d'environnement VALIDE_FACTURE_API_KEY.
// Code de sortie : 0 conforme, 1 autre verdict, 3 refus ou erreur technique.
import { readFile } from 'node:fs/promises';
import { basename } from 'node:path';
async function valider(chemin, cle) {
let contenu;
try {
contenu = await readFile(chemin);
} catch (erreur) {
console.error(`Fichier illisible : ${erreur.message}`);
return 3;
}
const formulaire = new FormData();
formulaire.append('file', new Blob([contenu]), basename(chemin));
let reponse;
let corps;
try {
reponse = await fetch('https://valide-facture.fr/api/validate', {
method: 'POST',
headers: { Authorization: `Bearer ${cle}` },
body: formulaire,
signal: AbortSignal.timeout(35_000),
});
corps = await reponse.text();
} catch (erreur) {
console.error(`Pas de réponse du service (${erreur.name}) : réessayez plus tard.`);
return 3;
}
if (reponse.status === 200) {
const rapport = JSON.parse(corps);
console.log(`Verdict : ${rapport.verdict}`);
for (const constat of rapport.findings) {
console.log(`- [${constat.severity}] ${constat.code} : ${constat.messageFr}`);
}
return rapport.verdict === 'CONFORME' ? 0 : 1;
}
// Un refus du service est en application/problem+json. Un refus en amont (proxy,
// limite de débit du frontal) peut arriver en HTML : on ne le décode pas.
if (!(reponse.headers.get('content-type') ?? '').startsWith('application/problem+json')) {
console.error(`Refus HTTP ${reponse.status}, sans détail lisible : réessayez dans quelques instants.`);
return 3;
}
const probleme = JSON.parse(corps);
console.error(`Refus HTTP ${reponse.status} : ${probleme.detail}`);
switch (probleme.type) {
case 'https://valide-facture.fr/problems/rate-limit-exceeded':
console.error(`Trop de requêtes : réessayez dans ${reponse.headers.get('retry-after')} seconde(s).`);
break;
case 'https://valide-facture.fr/problems/quota-exceeded':
console.error('Quota de la période atteint : réessayer ne servira à rien avant la période suivante.');
break;
default:
console.error(`Type : ${probleme.type}`);
}
return 3;
}
const [chemin] = process.argv.slice(2);
const cle = process.env.VALIDE_FACTURE_API_KEY;
if (!chemin || !cle) {
console.error('Usage : VALIDE_FACTURE_API_KEY=... node valider.mjs <facture>');
process.exitCode = 3;
} else {
process.exitCode = await valider(chemin, cle);
}
Lancez-le sur une facture :
node valider.mjs facture.pdf
Dans votre application, gardez la fonction valider() et remplacez les
console.log par votre propre traitement : le rapport complet est dans
rapport, le corps d'erreur dans probleme.
L'envoi du fichier avec FormData
L'API attend un envoi multipart/form-data, avec le fichier dans le champ
file. Le module lit le fichier en mémoire avec readFile, l'enveloppe dans
un Blob et l'ajoute au formulaire. Le troisième argument de append() est le
nom de fichier annoncé dans l'envoi : sans lui, Node.js envoie filename="blob". Le
service reconnaît le type du document sur ses octets, mais un nom réel rend vos journaux
lisibles.
Passez le formulaire tel quel dans body, sans poser d'en-tête
Content-Type : fetch le construit lui-même, avec la frontière
qui sépare les parties. Écrit à la main, il perd cette frontière et le service ne trouve plus le
fichier. La facture faisant au plus 10 Mio, la lire entière en mémoire ne pose pas de
problème.
Le délai, avec AbortSignal.timeout()
fetch n'a pas de délai par défaut : sans signal, un appel peut attendre
indéfiniment. AbortSignal.timeout(35_000) interrompt l'appel au bout de 35 secondes,
la durée que recommande la documentation de l'API, et cette borne couvre l'appel entier, envoi du
fichier et lecture de la réponse compris. C'est pourquoi le module lit le corps avec
await reponse.text() dans le même try : l'interruption peut
survenir pendant la lecture.
Un délai dépassé lève une TimeoutError ; une coupure réseau ou un nom d'hôte
introuvable, une TypeError. Le module les traite de la même façon : le service n'a
pas répondu, la facture n'est pas jugée, renvoyez-la plus tard.
Lire la réponse : le statut d'abord, le type ensuite
response.ok ne dit rien du verdict. Une facture non conforme est une
facture jugée : elle arrive en 200, avec le rapport complet, et
response.ok vaut true. Le verdict se lit dans rapport.verdict.
Tout autre statut est un refus. Le module vérifie l'en-tête content-type avant de
décoder, puis branche sur probleme.type, jamais sur le seul statut :
-
rate-limit-exceeded, en429: trop de requêtes rapprochées pour la clé.reponse.headers.get('retry-after')donne les secondes à attendre. -
quota-exceeded, en429lui aussi, mais sansRetry-After: les validations de la période sont consommées, et attendre quelques secondes n'y change rien. -
Un
429en HTML : la limite de débit du serveur frontal. Sans le test ducontent-type,JSON.parselèverait uneSyntaxErrorsur ce corps.
Le module ne relance rien de lui-même. Si votre application relance après un
rate-limit-exceeded, attendez au moins la valeur de Retry-After et bornez le
nombre d'essais.
Les erreurs fréquentes en Node.js
-
Un
awaitoublié. Sans lui,reponseest une promesse,reponse.statusvautundefined, et le programme conclut à un refus alors que l'appel n'a pas encore abouti. -
Un
Content-Typeposé à la main dansheaders: la frontière du multipart est perdue, et le service refuse l'envoi d'emblée. La réponse est un400en HTML : le programme affiche « Refus HTTP 400, sans détail lisible ». -
Le paquet
form-datade npm mêlé aufetchnatif. Ses objets ne sont pas ceux qu'attendfetch: le fichier n'arrive pas, et la réponse est un400. Utilisez leFormDataglobal. -
requiredans un fichier.jsd'un projet CommonJS. Ce module utiliseimportet unawaitau premier niveau : gardez l'extension.mjs, ou appelezvalider()depuis une fonctionasyncde votre code. -
Une clé lue trop tôt.
process.envest lu au lancement : une clé ajoutée à un fichier.envn'y arrive que si vous le chargez, par exemple avecnode --env-file=.env valider.mjs facture.pdf.
Aller plus loin
La liste complète des refus et ce qu'il faut faire pour chacun est sur la page des guides du développeur. Chaque champ du rapport est décrit dans la documentation de l'API, et chaque code de règle a sa page parmi les codes d'erreur du validateur. Pour voir un rapport sans écrire de code, déposez une facture dans le validateur.
Portée de cette page
Ce module est un exemple à intégrer, pas une bibliothèque : aucun paquet npm n'est publié pour l'API. Le verdict porte sur la conformité technique du document au format et aux règles appliquées par le validateur ; il ne présume pas de l'acceptation de la facture par la plateforme de votre destinataire.
Développeur