logalysm / valide-facture
Client PHP de l'API valide-facture.fr : validation de factures électroniques Factur-X (PDF), CII et UBL (XML), rapport et erreurs typés.
Requires
- php: ^8.3
- php-http/discovery: ^1.20
- php-http/multipart-stream-builder: ^1.4.2
- psr/http-client: ^1.0.3
- psr/http-client-implementation: ^1.0
- psr/http-factory: ^1.1
- psr/http-factory-implementation: ^1.0
- psr/http-message: ^1.1 || ^2.0
- symfony/console: ^6.4 || ^7.4 || ^8.0
Requires (Dev)
- nyholm/psr7: ^1.8.2
- phpstan/phpstan: ^2.3
- phpunit/phpunit: ^12.5 || ^13.4
- symfony/http-client: ^7.4 || ^8.1
- symplify/easy-coding-standard: ^13.3
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Client PHP de l'API de validation de valide-facture.fr : il envoie une facture
électronique (PDF Factur-X, XML CII ou UBL) à POST /api/validate et rend un rapport typé, ou une exception typée en cas de
refus.
Prérequis
- PHP 8.3 ou plus récent ;
- une clé API, créée dans votre espace sur valide-facture.fr ;
- un client HTTP PSR-18 et des fabriques PSR-17 (par exemple
symfony/http-clientetnyholm/psr7). Si votre projet n'en a pas, le plugin Composerphp-http/discoveryen installe à l'installation du paquet.
Installation
composer config allow-plugins.php-http/discovery true
composer require logalysm/valide-facture
La première commande autorise le plugin php-http/discovery, dont dépend le paquet. Sans elle, Composer demande
l'autorisation pendant l'installation (ou refuse le plugin en mode non interactif).
Valider un fichier
use Logalysm\ValideFacture\ValideFactureClient;
$client = new ValideFactureClient((string)getenv('VALIDE_FACTURE_API_KEY'));
$report = $client->validateFile('/chemin/vers/facture.pdf');
Gardez la clé hors du code source (variable d'environnement, gestionnaire de secrets). Le client ne l'écrit dans aucun
message d'erreur ni aucun var_dump.
Lire le rapport
Une facture non conforme est une facture jugée : elle arrive dans le rapport, pas dans une exception.
use Logalysm\ValideFacture\Report\UnknownValue;
use Logalysm\ValideFacture\Report\Verdict;
$message = $report->verdict instanceof UnknownValue
? 'Verdict inconnu de cette version du client : ' . $report->verdict->value
: match ($report->verdict) {
Verdict::Compliant => 'Facture conforme.',
Verdict::NonCompliant => sprintf('%d constat(s) à corriger.', count($report->findings)),
Verdict::NotEvaluable => 'Facture non évaluée : le document n\'a pas pu être jugé.',
Verdict::UnsupportedProfile => 'Profil de facture non pris en charge.',
};
foreach ($report->findings as $finding) {
echo $finding->code, ' : ', $finding->messageFr, PHP_EOL;
}
Chaque champ énuméré du rapport (verdict, classification, findings[].layer, findings[].severity, coverage,
syntax) est typé Enum|UnknownValue. Une valeur ajoutée côté service après la sortie de votre version du client ne
lève pas d'exception : elle arrive en UnknownValue, avec la valeur reçue dans ->value. Les champs que le client
ne connaît pas encore sont ignorés.
$report->versions->rulesVersion porte la version des règles qui a produit le verdict.
Erreurs
Les erreurs d'appel au service implémentent Logalysm\ValideFacture\Exception\ValideFactureException.
| Exception | Cause |
|---|---|
\InvalidArgumentException | clé vide, fichier absent ou illisible (levée avant tout appel réseau) |
MalformedRequestException, UnreadableDocumentException | requête ou document refusé (400) |
PayloadTooLargeException | fichier trop volumineux (413) |
UnsupportedMediaTypeException, UnsupportedSyntaxException | type de fichier ou syntaxe de facture non pris en charge (415) |
UnauthenticatedException | clé absente, invalide ou révoquée (401) |
InsufficientScopeException | la clé n'autorise pas la validation (403) |
RateLimitExceededException | trop de requêtes en rafale (429) |
QuotaExceededException | quota de validations du mois atteint (429) |
ValidationUnavailableException | validation temporairement indisponible (503) |
ProblemException | refus d'un type que cette version du client ne connaît pas ; classe parente des refus ci-dessus |
UnexpectedResponseException | réponse hors contrat, par exemple une page HTML d'un frontal ou un rapport incomplet |
TransportException | aucune réponse obtenue (hôte injoignable, délai dépassé) |
Chaque refus (ProblemException et ses sous-classes) expose type, title, status, detail et correlationId.
Pour toute question sur une requête, écrivez-nous depuis la page Contact en
indiquant son correlationId : il permet de la retrouver.
Les deux 429 appellent deux gestes différents :
use Logalysm\ValideFacture\Exception\QuotaExceededException;
use Logalysm\ValideFacture\Exception\RateLimitExceededException;
try {
$report = $client->validateFile($path);
} catch (RateLimitExceededException $exception) {
// Attendre puis réessayer : le service indique le délai en secondes (null s'il ne l'a pas indiqué).
sleep($exception->retryAfterInSeconds ?? 5);
$report = $client->validateFile($path);
} catch (QuotaExceededException $exception) {
// Ne pas réessayer : le quota du mois est épuisé, $exception->detail dit jusqu'à quand.
throw $exception;
}
Délai
PSR-18 ne porte pas de délai : il se règle sur le client HTTP. Une durée totale de 35 secondes est recommandée. Avec
symfony/http-client :
use Logalysm\ValideFacture\ValideFactureClient;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\Psr18Client;
$client = new ValideFactureClient(
(string)getenv('VALIDE_FACTURE_API_KEY'),
httpClient: new Psr18Client(HttpClient::create([
'max_duration' => 35,
])),
);
Intégration continue
Le paquet installe la commande vendor/bin/valide-facture. Dans votre CI, elle valide un dossier de factures produites
par votre code et rend un code de sortie :
vendor/bin/valide-facture check tests/factures --junit rapport-junit.xml
La clé se lit dans la variable d'environnement VALIDE_FACTURE_API_KEY, à déclarer comme secret de votre CI. Aucune
option ne la reçoit : passée en argument, elle finirait dans l'historique du shell et dans les journaux du job.
Un dossier est parcouru récursivement et seuls ses fichiers .pdf et .xml sont envoyés ; un fichier nommé en argument
est envoyé quelle que soit son extension. Les fichiers sont validés un à la fois, triés par
chemin : comptez quelques secondes par fichier.
Chaque fichier validé consomme une unité de quota, à chaque exécution du pipeline. Un refus d'entrée (fichier vide, trop volumineux, ni PDF ni XML) et une indisponibilité du service n'en consomment pas.
Un code 0 dit que vos factures passent les contrôles du service : format du fichier, règles de la norme EN 16931 et
contrôles de la réforme française. C'est une aide au diagnostic, qui ne se substitue pas à la décision de votre
Plateforme Agréée.
Codes de sortie
| Code | Signification |
|---|---|
0 | tous les fichiers sont conformes |
1 | au moins un fichier est en échec : c'est votre facture qu'il faut corriger |
2 | avec --baseline : régression par rapport au fichier de référence (voir « Détection de régression ») |
3 | le test n'a pas pu se faire : il ne dit rien de vos factures |
Le code le plus élevé l'emporte. Détail par fichier :
| Réponse du service | Code |
|---|---|
rapport CONFORME | 0 |
tout autre verdict (NON_CONFORME, NON_EVALUABLE, verdict inconnu de votre version) | 1 |
document inanalysable (400 unreadable-document), trop volumineux (413), type ou syntaxe non pris en charge (415) | 1 |
requête mal formée (400 malformed-request), clé absente, invalide ou révoquée (401), clé sans droit de validation (403), quota atteint (429 quota-exceeded), service indisponible (503) | 3 |
| refus d'un type inconnu de votre version, réponse hors contrat, erreur réseau, délai dépassé | 3 |
La commande sort aussi en 3, avant tout appel, si la clé est absente, si un chemin est introuvable, si aucun fichier
.pdf ou .xml n'est trouvé, ou si une option est inconnue.
Sur un refus pour débit trop élevé (429 rate-limit-exceeded), la commande attend le délai indiqué par le service, puis
réessaie, trois fois au plus ; au-delà, le fichier sort en 3. Sur un refus qui vise la clé ou le compte (401, 403,
quota atteint), elle s'arrête : les fichiers suivants sont rapportés non exécutés. Chaque appel est borné à 35 secondes
quand symfony/http-client ou guzzlehttp/guzzle est installé ; sinon la commande le signale sur la sortie d'erreur.
Rapports
La sortie standard donne une ligne par fichier, avec le verdict ou le statut HTTP et le type de problème servis, puis les constats des fichiers en échec. La progression s'écrit sur la sortie d'erreur.
--junit <chemin> écrit un rapport JUnit XML : un cas de test par fichier, <failure> pour un code 1 (code de règle,
localisation et message de chaque constat) ou 2, <error> pour un code 3, <skipped> pour un fichier non exécuté. GitLab
l'affiche nativement (artifacts:reports:junit) ; sur GitHub, il s'affiche par une action de rapport JUnit.
--format=json remplace la sortie standard par un document JSON, et rien d'autre :
{
"schemaVersion": 1,
"exitCode": 1,
"files": [
{
"path": "tests/factures/avoir.xml",
"exitCode": 1,
"httpStatus": 200,
"problemType": null,
"verdict": "NON_CONFORME",
"findings": [
{
"code": "SCH/BR-03",
"severity": "ERROR",
"location": "/rsm:CrossIndustryInvoice",
"messageFr": "La date d'émission de la facture est absente."
}
],
"versions": {
"specVersion": "…",
"sidecarTag": "…",
"rulesVersion": "…",
"validatorVersion": "…"
},
"message": null
}
]
}
exitCode d'un fichier vaut 0, 1, 2 (avec --baseline), 3, ou null s'il n'a pas été exécuté. verdict, problemType et severity
sont les valeurs servies par l'API. httpStatus est null sans réponse ou pour un rapport hors contrat ; versions est null sans rapport ;
message explique un refus ou une erreur technique. Un ajout de champ reste en schemaVersion 1 ; un changement
incompatible incrémente schemaVersion.
VALIDE_FACTURE_BASE_URI change l'adresse du service (par défaut https://valide-facture.fr).
Détection de régression
Un dossier de jeux d'essai contient souvent, au départ, des factures déjà non conformes que vous ne corrigerez pas tout
de suite. Avec check seul, le pipeline resterait rouge en permanence. Le fichier de référence règle ce cas : il
photographie les résultats d'une exécution, se versionne dans votre dépôt, et chaque exécution suivante n'échoue que sur
ce qui a changé en pire.
# Une première fois, puis après chaque changement de résultat que vous acceptez :
vendor/bin/valide-facture baseline update tests/factures
git add valide-facture.baseline.json
# Dans le pipeline :
vendor/bin/valide-facture check --baseline valide-facture.baseline.json tests/factures --junit rapport-junit.xml
baseline update valide les fichiers comme check et écrit valide-facture.baseline.json dans le répertoire courant
(--baseline <chemin> pour un autre emplacement). Il consomme une unité de quota par fichier validé, comme check.
Si un fichier finit en erreur technique ou n'est pas exécuté, rien n'est écrit : la référence existante reste intacte et
la commande sort en 3. Régénérée sur les mêmes résultats, la référence est identique octet pour octet.
Pour chaque fichier, indexé par son chemin relatif au répertoire de la référence, le fichier de référence contient
l'empreinte SHA-256 de ses octets, l'issue servie (httpStatus, problemType, verdict), ses codes d'erreur (les
constats dont la sévérité n'est ni WARNING, ni NOTICE, ni INFO) et trois versions du rapport. Il ne contient
aucun message, aucune localisation, aucun contenu de facture : un message ou une localisation peut citer une valeur
de la facture.
{
"schemaVersion": 1,
"files": {
"tests/factures/avoir.xml": {
"sha256": "…",
"httpStatus": 200,
"problemType": null,
"verdict": "NON_CONFORME",
"errorCodes": [
"SCH/BR-03"
],
"versions": {
"specVersion": "…",
"sidecarTag": "…",
"rulesVersion": "…"
}
}
}
}
Avec --baseline, chaque fichier est comparé à son entrée :
| Résultat | Exemple | Code du fichier | Avec --strict |
|---|---|---|---|
| inchangé | même issue, mêmes codes d'erreur | 0, y compris pour un fichier non conforme connu de la référence | 0 |
| régression | conforme devenu non conforme, autre issue, code d'erreur nouveau | 2 | 2 |
| amélioré | devenu conforme, codes d'erreur en moins | 0, signalé (« lancez baseline update ») | 2 |
| nouveau | absent de la référence | celui de check (0 ou 1), signalé | 2 |
| manquant | présent dans la référence, absent des fichiers testés | 2, fichier nommé | 2 |
| erreur technique | service indisponible, réseau, clé refusée | 3, sans comparaison | 3 |
--strict fait échouer toute différence de résultat avec la référence, dans les deux sens ; il exige --baseline.
Une empreinte ou une version qui change à résultat égal n'est pas une différence : un PDF régénéré avec un autre
horodatage ne fait pas échouer le pipeline, même avec --strict. Les codes d'erreur se comparent comme un ensemble :
une seconde erreur d'un code déjà présent n'est pas vue.
La référence est lue avant le premier appel. Absente, au JSON invalide, d'une schemaVersion inconnue ou avec une
entrée malformée, elle est refusée en 3 par un message qui en nomme la cause, sans consommer de quota. Un fichier
testé hors du répertoire de la référence est refusé de même.
Cause probable
Devant une régression, la commande dit si elle vient plutôt de votre code ou du validateur. Elle compare à la référence
l'empreinte du fichier et les versions du rapport (rulesVersion, sidecarTag, specVersion) :
| Empreinte | Versions | Cause dite |
|---|---|---|
| différente | identiques | « Cause probable : votre code a changé. » |
| identique | identiques | « Cause : le validateur a changé (mêmes octets, autre résultat). » |
| identique | au moins une a changé | « Cause : le validateur a changé ; les règles sont passées de X à Y. » |
| différente | au moins une a changé | « Cause probable : votre code a changé, ou les règles sont passées de X à Y. » |
La cause n'est que probable quand l'empreinte a changé : rulesVersion change avec tout verdict modifié sur le
corpus de test du service, mais un changement qu'aucun fichier de ce corpus ne révèle peut survenir sans qu'elle bouge.
Quand un des deux côtés n'a pas de rapport (un refus 415, par exemple), les versions ne sont pas comparables et la
cause repose sur la seule empreinte.
Pour que la cause se lise, produisez des jeux d'essai déterministes (voir « Jeux d'essai »).
Sorties
La sortie texte marque chaque fichier comparé ([régression], [amélioré], [nouveau], [manquant],
[non conforme, connu de la référence]), écrit la cause ou le signalement sous la ligne du fichier, et le résumé compte
les fichiers manquants et les régressions.
Avec --format=json, chaque fichier porte deux champs de plus, baselineKey (sa clé dans la référence) et baseline :
"baselineKey": "tests/factures/avoir.xml",
"baseline": {
"status": "regression",
"fileChanged": false,
"versionChanges": [
{
"name": "rulesVersion",
"from": "…",
"to": "…"
}
],
"message": "Régression : CONFORME dans la référence, NON_CONFORME [SCH/BR-03] aujourd'hui. Cause : le validateur a changé ; les règles sont passées de … à …."
}
status vaut unchanged, regression, improved, new ou missing. fileChanged et versionChanges valent
null pour un fichier nouveau ou manquant, versionChanges aussi quand un côté n'a pas de rapport. baseline vaut null pour un
fichier en erreur technique ou non exécuté. Un fichier manquant est une entrée de files dont path est sa clé. Sans
--baseline, ces deux champs sont absents.
Dans le rapport JUnit, une régression et un fichier manquant sont des <failure type="regression"> dont le message dit
l'issue et la cause ; sous --strict, une autre différence est un <failure type="strict">. Un signalement qui ne fait
pas échouer le fichier (amélioré, nouveau) est écrit dans <system-out>.
Jeux d'essai
Produisez des jeux d'essai déterministes : horloge figée, numéros de facture fixes, aucun identifiant aléatoire. Sinon l'empreinte de chaque fichier change à chaque génération, et une régression due à de nouvelles règles se lit « votre code a changé, ou les règles sont passées de X à Y ».
Dix factures de test, poussées vingt fois dans le mois, consomment deux cents unités de quota.
GitLab CI
Le job valide tests/factures et compare le résultat au fichier de référence valide-facture.baseline.json,
versionné à la racine du dépôt :
valide-facture:
stage: test
image: composer:2 # ou l'image PHP de votre projet, avec Composer
script:
- composer install --no-interaction --no-progress
# Les factures de tests/factures sont produites par votre code, ou versionnées.
- vendor/bin/valide-facture check --baseline valide-facture.baseline.json tests/factures --junit rapport-junit.xml
artifacts:
when: always
reports:
junit: rapport-junit.xml
Déclarez la clé en variable CI/CD du projet (Settings > CI/CD > Variables), nommée VALIDE_FACTURE_API_KEY, en
visibilité Masked (ou Masked and hidden, qui ne se choisit qu'à la création). Laissez Protect variable décoché,
sauf si le job ne tourne que sur des branches protégées : une variable protégée n'est transmise qu'à leurs pipelines,
et ailleurs la commande sort en 3, clé absente.
when: always envoie le rapport même quand le job échoue ; par défaut, GitLab ne l'envoie qu'en cas de succès. Le
rapport s'affiche dans l'onglet Tests du pipeline et dans la demande de fusion.
GitHub Actions
Le même contrôle, dans .github/workflows/valide-facture.yml :
name: valide-facture
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
valide-facture:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0
with:
php-version: '8.3' # la version de PHP de votre projet
- run: composer install --no-interaction --no-progress
# Les factures de tests/factures sont produites par votre code, ou versionnées.
- run: vendor/bin/valide-facture check --baseline valide-facture.baseline.json tests/factures --junit rapport-junit.xml
env:
VALIDE_FACTURE_API_KEY: ${{ secrets.VALIDE_FACTURE_API_KEY }}
- uses: mikepenz/action-junit-report@6ef3fdcfcd5f0b5d14df3e659ef19b8c6820f93d # v6.6.1
if: ${{ !cancelled() }}
with:
report_paths: rapport-junit.xml
annotate_only: true
Déclarez la clé en secret du dépôt (Settings > Secrets and variables > Actions), nommé VALIDE_FACTURE_API_KEY.
Seule l'étape qui lance la commande la reçoit. Sur une pull request ouverte depuis un fork, GitHub ne transmet pas les
secrets : la commande sort en 3, clé absente.
Le workflow tourne sur les push de main et sur les pull requests : une branche qui a une pull request ouverte n'est
pas validée deux fois par push.
GitHub n'affiche pas de rapport JUnit de lui-même. La dernière étape le lit et l'écrit dans le résumé du job, même
quand la commande a échoué : mikepenz/action-junit-report est une action tierce, sous licence Apache-2.0, que vous
pouvez remplacer par tout lecteur de JUnit. Avec annotate_only: true, elle ne crée pas de check run et le droit
contents: read lui suffit. Chaque action est épinglée par l'empreinte de son commit : une étiquette peut être
déplacée, une empreinte non.
Symfony
Dans votre application, installez le client avec le client HTTP de Symfony et une implémentation PSR-7 :
composer config allow-plugins.php-http/discovery true
composer require logalysm/valide-facture symfony/http-client nyholm/psr7
Déclarez le client comme service. Il passe par un client HTTP dédié, borné à 35 secondes sur la durée totale de
l'appel (max_duration : timeout est un délai d'inactivité, qui couperait une validation longue) :
framework:
http_client:
scoped_clients:
valide_facture.client:
base_uri: '%env(VALIDE_FACTURE_BASE_URI)%'
max_duration: 35
services:
Logalysm\ValideFacture\ValideFactureClient:
arguments:
$apiKey: '%env(VALIDE_FACTURE_API_KEY)%'
$baseUri: '%env(VALIDE_FACTURE_BASE_URI)%'
$httpClient: '@psr18.valide_facture.client'
Symfony fournit le service psr18.valide_facture.client, version PSR-18 du client HTTP déclaré au-dessus. Ajoutez
l'adresse du service à la fin de votre fichier .env :
VALIDE_FACTURE_BASE_URI=https://valide-facture.fr
Ne rangez jamais la clé dans un fichier versionné. En développement, collez la clé complète dans votre fichier
.env.local, que Symfony ne versionne pas :
VALIDE_FACTURE_API_KEY=votre-clé
En production, rangez-la avec php bin/console secrets:set VALIDE_FACTURE_API_KEY --env=prod, qui vous demande la
valeur, 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.
Injectez le client là où vous en avez besoin, par exemple dans une commande :
<?php
namespace App\Command;
use Logalysm\ValideFacture\Exception\ProblemException;
use Logalysm\ValideFacture\Exception\QuotaExceededException;
use Logalysm\ValideFacture\Exception\RateLimitExceededException;
use Logalysm\ValideFacture\Exception\TransportException;
use Logalysm\ValideFacture\Exception\UnexpectedResponseException;
use Logalysm\ValideFacture\Report\Verdict;
use Logalysm\ValideFacture\ValideFactureClient;
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:validate-invoice', 'Valide une facture avec valide-facture.fr')]
final class ValidateInvoiceCommand
{
public function __construct(
private readonly ValideFactureClient $client,
) {
}
public function __invoke(SymfonyStyle $console, #[Argument('Chemin du fichier à valider')] string $invoicePath): int
{
try {
$report = $this->client->validateFile($invoicePath);
} catch (RateLimitExceededException $exception) {
$console->error(sprintf('Trop de requêtes : réessayez dans %d seconde(s).', $exception->retryAfterInSeconds ?? 5));
return Command::FAILURE;
} catch (QuotaExceededException $exception) {
$console->error('Quota de validations du mois atteint : ' . $exception->detail);
return Command::FAILURE;
} catch (ProblemException $exception) {
$console->error($exception->getMessage());
$console->text([
'Type : ' . $exception->type,
'Référence à transmettre au support : ' . ($exception->correlationId ?? 'aucune'),
]);
return Command::FAILURE;
} catch (TransportException|UnexpectedResponseException $exception) {
$console->error($exception->getMessage());
return Command::FAILURE;
}
$console->section('Verdict : ' . $report->verdict->value);
$console->text(match ($report->verdict) {
Verdict::Compliant => 'La facture est conforme.',
Verdict::NonCompliant => 'La facture n\'est pas conforme : corrigez les constats ci-dessous.',
Verdict::NotEvaluable => 'La facture n\'a pas pu être entièrement évaluée.',
Verdict::UnsupportedProfile => 'Le profil déclaré par la facture n\'est pas pris en charge.',
default => 'Verdict inconnu de cette version du client.',
});
foreach ($report->findings as $finding) {
$console->writeln(sprintf('- [%s] %s : %s', $finding->severity->value, $finding->code, $finding->messageFr));
}
return $report->verdict === Verdict::Compliant ? Command::SUCCESS : Command::FAILURE;
}
}
Placez une facture à la racine du projet sous le nom facture.pdf, puis lancez :
php bin/console app:validate-invoice facture.pdf
La commande ne rend Command::SUCCESS que pour une facture conforme. Les autres exceptions du client sont listées
dans « Erreurs ».
Sans le client, avec le seul symfony/http-client : voir le guide
Valider vos factures depuis Symfony.
Licence
MIT, voir LICENSE.