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, Blob et AbortSignal.timeout() y sont disponibles sans import ni option : il n'y a rien à ajouter à votre package.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, en 429 : trop de requêtes rapprochées pour la clé. reponse.headers.get('retry-after') donne les secondes à attendre.
  • quota-exceeded, en 429 lui aussi, mais sans Retry-After : les validations de la période sont consommées, et attendre quelques secondes n'y change rien.
  • Un 429 en HTML : la limite de débit du serveur frontal. Sans le test du content-type, JSON.parse lèverait une SyntaxError sur 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 await oublié. Sans lui, reponse est une promesse, reponse.status vaut undefined, et le programme conclut à un refus alors que l'appel n'a pas encore abouti.
  • Un Content-Type posé à la main dans headers : la frontière du multipart est perdue, et le service refuse l'envoi d'emblée. La réponse est un 400 en HTML : le programme affiche « Refus HTTP 400, sans détail lisible ».
  • Le paquet form-data de npm mêlé au fetch natif. Ses objets ne sont pas ceux qu'attend fetch : le fichier n'arrive pas, et la réponse est un 400. Utilisez le FormData global.
  • require dans un fichier .js d'un projet CommonJS. Ce module utilise import et un await au premier niveau : gardez l'extension .mjs, ou appelez valider() depuis une fonction async de votre code.
  • Une clé lue trop tôt. process.env est lu au lancement : une clé ajoutée à un fichier .env n'y arrive que si vous le chargez, par exemple avec node --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