Guides

Valider vos factures depuis Symfony

Votre application Symfony émet des factures ? Contrôlez chacune avant de l'envoyer, en appelant l'API de validation avec le client HTTP de Symfony. Voici le code complet, à copier tel quel : configuration, clé API, envoi du fichier, lecture du rapport et traitement des erreurs.

Avant de commencer

  • Un compte, et une clé API créée sur votre page de clés API. Copiez la clé complète dès sa création : elle ne s'affiche qu'une fois. Elle a la forme fxk_<préfixe>.<secret>.
  • Une application Symfony, dans laquelle vous installerez le client HTTP.
  • Une facture à contrôler : un PDF Factur-X, ou un XML CII ou UBL. Pour voir le rapport sans écrire de code, déposez d'abord votre fichier dans le validateur.

Installer le client HTTP

À la racine de votre projet, installez le client HTTP de Symfony et le composant Mime, qui construit l'envoi du fichier :

composer require symfony/http-client symfony/mime

Configurer un client dédié et ranger la clé

Créez un client HTTP réservé à valide-facture.fr. Il porte l'adresse du service, votre clé dans l'en-tête Authorization, et un délai d'attente de 35 secondes sur la durée totale de l'appel, la valeur que la documentation de l'API recommande :

framework:
    http_client:
        scoped_clients:
            valide_facture.client:
                base_uri: '%env(VALIDE_FACTURE_BASE_URI)%'
                auth_bearer: '%env(VALIDE_FACTURE_API_KEY)%'
                max_duration: 35

Ajoutez l'adresse du service à la fin de votre fichier .env :

VALIDE_FACTURE_BASE_URI=https://valide-facture.fr

Rangez la clé comme un secret, jamais dans un fichier versionné. La commande vous demande la valeur : collez la clé complète.

php bin/console secrets:set VALIDE_FACTURE_API_KEY

En production, rangez-la de la même façon avec php bin/console secrets:set VALIDE_FACTURE_API_KEY --env=prod, ou déclarez VALIDE_FACTURE_API_KEY en variable d'environnement du serveur. Ne versionnez jamais le fichier config/secrets/prod/prod.decrypt.private.php.

Écrire le service qui appelle l'API

Le service envoie le fichier dans le champ file et rend le rapport. Quand le service n'a pas pu juger la facture, il lève une exception qui dit quoi faire. Commencez par l'exception commune :

<?php

namespace App\ValideFacture;

class ValideFactureException extends \RuntimeException
{
    public function __construct(
        string $message,
        public readonly int $status,
        public readonly ?string $type = null,
        public readonly ?string $correlationId = null,
    ) {
        parent::__construct($message);
    }
}

Puis une exception par geste à faire : la clé refusée, le quota atteint, le document refusé, le service indisponible :

<?php

namespace App\ValideFacture;

final class ApiKeyRejectedException extends ValideFactureException
{
}
<?php

namespace App\ValideFacture;

final class QuotaExceededException extends ValideFactureException
{
}
<?php

namespace App\ValideFacture;

final class DocumentRejectedException extends ValideFactureException
{
}
<?php

namespace App\ValideFacture;

final class ServiceUnavailableException extends ValideFactureException
{
}

L'exception de débit porte en plus le délai d'attente que le service indique :

<?php

namespace App\ValideFacture;

final class RateLimitedException extends ValideFactureException
{
    public function __construct(
        string $message,
        int $status,
        ?string $type,
        ?string $correlationId,
        public readonly int $retryAfter,
    ) {
        parent::__construct($message, $status, $type, $correlationId);
    }
}

Enfin, le service lui-même :

<?php

namespace App\ValideFacture;

use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\Mime\Part\DataPart;
use Symfony\Component\Mime\Part\Multipart\FormDataPart;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;

final class InvoiceValidator
{
    public function __construct(
        #[Target('valide_facture.client')]
        private readonly HttpClientInterface $client,
    ) {
    }

    /**
     * Envoie la facture et rend le rapport de validation.
     *
     * @return array<string, mixed>
     *
     * @throws ValideFactureException si le service n'a pas pu juger la facture
     */
    public function validate(string $path): array
    {
        $form = new FormDataPart(['file' => DataPart::fromPath($path)]);

        $response = $this->client->request('POST', '/api/validate', [
            'headers' => $form->getPreparedHeaders()->toArray(),
            'body' => $form->bodyToIterable(),
        ]);

        // Sans réponse (hôte injoignable, délai de 35 secondes dépassé), le client lève une erreur de transport.
        try {
            return $this->read($response);
        } catch (TransportExceptionInterface $e) {
            throw new ServiceUnavailableException('Le service n\'a pas répondu : ' . $e->getMessage(), 0);
        }
    }

    /**
     * @return array<string, mixed>
     */
    private function read(ResponseInterface $response): array
    {
        $status = $response->getStatusCode();

        // Une facture non conforme est une facture jugée : elle arrive ici, en 200.
        if (200 === $status) {
            return $response->toArray();
        }

        $headers = $response->getHeaders(false);

        // Certains refus (fichier trop gros, trop de requêtes) peuvent arriver en HTML.
        if (!str_starts_with($headers['content-type'][0] ?? '', 'application/problem+json')) {
            throw new ValideFactureException(sprintf('Réponse inattendue du service (HTTP %d).', $status), $status);
        }

        $problem = $response->toArray(false);
        $type = $problem['type'] ?? '';
        $detail = $problem['detail'] ?? '';
        $correlationId = $problem['correlationId'] ?? null;

        throw match ($type) {
            'https://valide-facture.fr/problems/unauthenticated',
            'https://valide-facture.fr/problems/insufficient-scope' => new ApiKeyRejectedException($detail, $status, $type, $correlationId),
            'https://valide-facture.fr/problems/rate-limit-exceeded' => new RateLimitedException($detail, $status, $type, $correlationId, (int) ($headers['retry-after'][0] ?? 1)),
            'https://valide-facture.fr/problems/quota-exceeded' => new QuotaExceededException($detail, $status, $type, $correlationId),
            'https://valide-facture.fr/problems/malformed-request',
            'https://valide-facture.fr/problems/unreadable-document',
            'https://valide-facture.fr/problems/payload-too-large',
            'https://valide-facture.fr/problems/unsupported-media-type',
            'https://valide-facture.fr/problems/unsupported-syntax' => new DocumentRejectedException($detail, $status, $type, $correlationId),
            'https://valide-facture.fr/problems/validation-unavailable' => new ServiceUnavailableException($detail, $status, $type, $correlationId),
            default => new ValideFactureException($detail, $status, $type, $correlationId),
        };
    }
}

Le nom valide_facture.client de l'attribut #[Target] est celui du client déclaré dans la configuration : si vous renommez l'un, renommez l'autre.

Essayer depuis la console

Ajoutez une commande qui affiche le verdict et chaque anomalie :

<?php

namespace App\Command;

use App\ValideFacture\ApiKeyRejectedException;
use App\ValideFacture\DocumentRejectedException;
use App\ValideFacture\InvoiceValidator;
use App\ValideFacture\QuotaExceededException;
use App\ValideFacture\RateLimitedException;
use App\ValideFacture\ServiceUnavailableException;
use App\ValideFacture\ValideFactureException;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand('app:valider-facture', 'Valide une facture avec valide-facture.fr')]
final class ValiderFactureCommand
{
    public function __construct(
        private readonly InvoiceValidator $validator,
    ) {
    }

    public function __invoke(SymfonyStyle $io, #[Argument('Chemin du fichier à valider')] string $fichier): int
    {
        try {
            $rapport = $this->validator->validate($fichier);
        } catch (ValideFactureException $e) {
            $io->error(match (true) {
                $e instanceof ApiKeyRejectedException => 'Clé API refusée : vérifiez VALIDE_FACTURE_API_KEY et qu\'elle autorise la validation.',
                $e instanceof RateLimitedException => sprintf('Trop de requêtes : réessayez dans %d seconde(s).', $e->retryAfter),
                $e instanceof QuotaExceededException => 'Quota de validations atteint.',
                $e instanceof DocumentRejectedException => 'Le fichier n\'a pas pu être jugé.',
                $e instanceof ServiceUnavailableException => 'Service momentanément indisponible : réessayez plus tard.',
                default => sprintf('Erreur inattendue (HTTP %d).', $e->status),
            });
            $io->text([$e->getMessage(), sprintf('Type : %s', $e->type ?? 'aucun')]);
            if (null !== $e->correlationId) {
                $io->text(sprintf('Référence à transmettre au support : %s', $e->correlationId));
            }

            return Command::FAILURE;
        }

        $io->section(sprintf('Verdict : %s', $rapport['verdict']));
        $io->text(match ($rapport['verdict']) {
            'CONFORME' => 'La facture est conforme.',
            'NON_CONFORME' => 'La facture n\'est pas conforme : corrigez les anomalies ci-dessous.',
            // Plus produit depuis la Story 5.42 : un profil non admis au flux 2 rend NON_CONFORME.
            'PROFIL_NON_SUPPORTE' => 'Le profil déclaré par la facture n\'est pas pris en charge.',
            'NON_EVALUABLE' => 'La facture n\'a pas pu être entièrement évaluée.',
            default => 'Verdict non reconnu par cette version de l\'intégration.',
        });

        foreach ($rapport['findings'] as $constat) {
            $io->writeln(sprintf('- [%s] %s : %s', $constat['severity'], $constat['code'], $constat['messageFr']));
            if (null !== ($constat['correctionHint'] ?? null)) {
                $io->writeln(sprintf('  Correction : %s', $constat['correctionHint']));
            }
        }

        return 'CONFORME' === $rapport['verdict'] ? Command::SUCCESS : Command::FAILURE;
    }
}

Placez une facture à la racine du projet sous le nom facture.pdf, puis lancez :

php bin/console app:valider-facture facture.pdf

Lire le rapport

Le membre verdict prend l'une de trois valeurs : CONFORME, NON_CONFORME et NON_EVALUABLE. Le contrat garde une quatrième valeur, PROFIL_NON_SUPPORTE, qui n'est plus produite.

Un NON_CONFORME n'est pas une erreur : la facture a été jugée, et la réponse arrive en 200 avec le rapport complet. Traitez-la dans votre logique métier, pas dans votre gestion d'erreurs.

Chaque anomalie de findings porte sa gravité (severity), le code de la règle (code), l'explication en français (messageFr), l'endroit du fichier (location) et une piste de correction (correctionHint). Chaque code a sa page parmi les codes d'erreur du validateur.

De nouvelles valeurs peuvent apparaître dans le rapport au fil des versions. Prévoyez toujours une branche par défaut, comme la commande ci-dessus, plutôt qu'une liste fermée qui lève une exception.

Traiter les erreurs

Quand le service n'a pas pu juger la facture, il répond en application/problem+json. Branchez votre traitement sur le membre type, jamais sur le seul code HTTP : deux refus de sens opposés partagent le 429.

  • unauthenticated (401) : vérifiez la clé. Elle est absente, révoquée ou expirée ; créez-en une nouvelle si besoin.
  • insufficient-scope (403) : créez une clé qui autorise la validation, puis remplacez celle de votre intégration.
  • rate-limit-exceeded (429) : attendez le nombre de secondes indiqué par l'en-tête Retry-After, puis renvoyez la facture.
  • quota-exceeded (429, sans Retry-After) : attendre quelques secondes n'y change rien. Changez d'abonnement, ou attendez la période suivante.
  • malformed-request, unreadable-document, payload-too-large, unsupported-media-type et unsupported-syntax : corrigez la requête ou le fichier. Renvoyer le même envoi donnera le même refus.
  • validation-unavailable (503) : renvoyez la facture plus tard, et gardez l'identifiant correlationId si vous contactez le support.
  • Aucune réponse dans les 35 secondes : InvoiceValidator lève aussi ServiceUnavailableException. Renvoyez la facture plus tard.

Chaque type est une adresse, sous https://valide-facture.fr/problems/ : ouvrez-la pour lire sa documentation. Lisez aussi l'en-tête Content-Type avant de décoder : un fichier très volumineux ou un afflux de requêtes peut être refusé en amont, par une page HTML.

Aller plus loin

Chaque réponse, chaque type d'erreur et le schéma du rapport sont décrits dans la documentation de l'API. Le nombre de validations compris dans votre abonnement figure sur la page des tarifs. Déposez une facture dans le validateur pour voir un rapport complet sans écrire de code.

Portée de cette page

Ce code est un exemple à intégrer dans votre application, pas une bibliothèque : aucun paquet Composer n'est à installer en dehors des composants de Symfony. Il envoie une facture et lit la réponse ; il ne relance rien de lui-même. Décidez dans votre application quand renvoyer une facture refusée.

Guides