Aller au contenu principal

Module 3 - Lecteur Factur-X et tests

Ce module ajoute le lecteur XML, les tests unitaires et la check-list de validation.

On vérifie ici que ce qu’on produit est réellement exploitable par les plateformes et par votre propre domaine métier : relire le XML, tester les invariants, puis valider la sortie finale.

remarque

Objectif : sécuriser la génération Factur-X avec des tests ciblés et un lecteur XML fiable.

Module 3 : 1h

Objectif

Pouvoir recharger un XML Factur-X pour contrôle ou transport PDP, et sécuriser l’implémentation par des tests.

Étape 1 - Créer le lecteur XML

Le lecteur sert à relire un XML généré (ou reçu) et à vérifier que les champs clés sont présents avant l’envoi vers PDP/Chorus Pro.

src/Service/FacturX/FacturXReader.php
namespace App\Service\FacturX;

use Easybill\ZUGFeRD2\Model\CrossIndustryInvoice;
use Easybill\ZUGFeRD2\Reader;

final class FacturXReader
{
public function __construct(private ?Reader $reader = null)
{
$this->reader = $reader ?? Reader::create();
}

public function fromXml(string $xml): CrossIndustryInvoice
{
return $this->reader->transform($xml);
}
}

Étape 2 - Ajouter les tests dans l’ordre

On commence par les tests les plus simples, puis on ajoute la lecture et la vérification PDF.

  1. tests/Service/FacturX/FacturXGeneratorTest.php : profil MINIMUM (XML valide).
  2. tests/Service/FacturX/FacturXGeneratorEn16931Test.php : profil Factur-X (XML valide + lecture).
  3. tests/Service/FacturX/FacturXPdfGeneratorTest.php : PDF/A-3 contient la pièce jointe /AFRelationship /Alternative.
  4. tests/Service/FacturX/FacturXReaderTest.php : lecture XML → CrossIndustryInvoice cohérent.
Conseil

Garde un jeu de données minimal par profil pour éviter des tests fragiles et lents.

Exemple : test de lecture Factur-X

tests/Service/FacturX/FacturXGeneratorEn16931Test.php
public function testTransformReturnsInvoiceWithData(): void
{
$seller = new InvoiceParty('Seller FR', 'FR', 'FR12345678901', 'FR-TAX-42');
$buyer = new InvoiceParty('Buyer FR', 'FR');
$totals = new InvoiceTotals('100.00', '20.00', '120.00');

$invoice = new InvoiceData(
'READ-1',
new \DateTimeImmutable('2025-01-15'),
'EUR',
$seller,
$buyer,
$totals
);

$lines = [
new InvoiceLine(
lineId: '1',
name: 'Prestation',
quantity: '2.0000',
unitCode: 'HUR',
netPrice: '50.0000',
vatRate: '20.00'
),
];

$xml = (new FacturXGenerator())->generateEn16931Invoice(
$invoice,
$lines,
paymentTerms: 'Paiement à 30 jours.'
);

$cii = (new FacturXReader())->fromXml($xml);

self::assertSame('READ-1', $cii->exchangedDocument->id);
self::assertSame('Seller FR', $cii->supplyChainTradeTransaction->applicableHeaderTradeAgreement->sellerTradeParty->name);
self::assertCount(1, $cii->supplyChainTradeTransaction->lineItems);
}

Fichiers de tests à copier

tests/Service/FacturX/FacturXGeneratorTest.php
<?php

declare(strict_types=1);

namespace App\Tests\Service\FacturX;

use App\Service\FacturX\FacturXGenerator;
use App\Service\FacturX\InvoiceData;
use App\Service\FacturX\InvoiceParty;
use App\Service\FacturX\InvoiceTotals;
use Easybill\ZUGFeRD2\Builder;
use Easybill\ZUGFeRD2\Validator;
use PHPUnit\Framework\TestCase;

class FacturXGeneratorTest extends TestCase
{
public function testGenerateMinimumInvoiceProducesValidXml(): void
{
$generator = new FacturXGenerator();
$invoiceData = new InvoiceData(
'INV-2024-001',
new \DateTimeImmutable('2024-10-01'),
'EUR',
new InvoiceParty(
name: 'ACME Corp',
countryCode: 'FR',
vatNumber: 'FR12345678901',
taxNumber: 'FR-TAX-42'
),
new InvoiceParty(
name: 'Client Démo',
countryCode: 'FR'
),
new InvoiceTotals(
taxBasisAmount: '100.00',
taxAmount: '20.00',
grandTotalAmount: '120.00'
)
);

$xml = $generator->generateMinimumInvoice($invoiceData);

self::assertStringContainsString('INV-2024-001', $xml);
self::assertStringContainsString('20241001', $xml);
self::assertStringContainsString(Builder::GUIDELINE_SPECIFIED_DOCUMENT_CONTEXT_ID_MINIMUM, $xml);
self::assertStringContainsString('<ram:DuePayableAmount', $xml);

$validator = new Validator();
$errors = $validator->validateAgainstXsd($xml, Validator::SCHEMA_MINIMUM);
self::assertNull($errors);
}
}
tests/Service/FacturX/FacturXGeneratorEn16931Test.php
<?php

declare(strict_types=1);

namespace App\Tests\Service\FacturX;

use App\Service\FacturX\FacturXGenerator;
use App\Service\FacturX\FacturXReader;
use App\Service\FacturX\InvoiceData;
use App\Service\FacturX\InvoiceLine;
use App\Service\FacturX\InvoiceParty;
use App\Service\FacturX\InvoiceTotals;
use Easybill\ZUGFeRD2\Builder;
use Easybill\ZUGFeRD2\Validator;
use PHPUnit\Framework\TestCase;

class FacturXGeneratorEn16931Test extends TestCase
{
public function testGenerateEn16931ProducesValidXml(): void
{
$generator = new FacturXGenerator();
$invoiceData = new InvoiceData(
'471102',
new \DateTimeImmutable('2018-03-05'),
'EUR',
new InvoiceParty(
name: 'Lieferant GmbH',
countryCode: 'DE',
vatNumber: 'DE123456789',
taxNumber: '201/113/40209',
streetLine1: 'Lieferantenstraße 20',
city: 'München',
postcode: '80333',
),
new InvoiceParty(
name: 'Kunden AG Mitte',
countryCode: 'DE',
streetLine1: 'Kundenstraße 15',
city: 'Frankfurt',
postcode: '69876',
),
new InvoiceTotals(
taxBasisAmount: '473.00',
taxAmount: '56.87',
grandTotalAmount: '529.87',
)
);

$lines = [
new InvoiceLine(
lineId: '1',
name: 'Trennblätter A4',
quantity: '20.0000',
unitCode: 'H87',
netPrice: '9.9000',
vatRate: '19.00',
vatCategory: 'S',
sellerAssignedId: 'TB100A4'
),
new InvoiceLine(
lineId: '2',
name: 'Joghurt Banane',
quantity: '50.0000',
unitCode: 'H87',
netPrice: '5.5000',
vatRate: '7.00',
vatCategory: 'S',
sellerAssignedId: 'ARNR2'
),
];

$xml = $generator->generateEn16931Invoice($invoiceData, $lines, paymentTerms: 'Zahlbar innerhalb 30 Tagen netto.');

self::assertStringContainsString(Builder::GUIDELINE_SPECIFIED_DOCUMENT_CONTEXT_ID_EN16931, $xml);
self::assertStringContainsString('<ram:GrandTotalAmount', $xml);
self::assertStringContainsString('Trennblätter A4', $xml);
self::assertStringContainsString('Joghurt Banane', $xml);

$validator = new Validator();
$errors = $validator->validateAgainstXsd($xml, Validator::SCHEMA_EN16931);
self::assertNull($errors);
}

public function testReaderCanLoadGeneratedEn16931Xml(): void
{
$generator = new FacturXGenerator();
$reader = new FacturXReader();

$xml = $generator->generateEn16931Invoice(
new InvoiceData(
'INV-READ-1',
new \DateTimeImmutable('2024-05-10'),
'EUR',
new InvoiceParty('Seller', 'FR'),
new InvoiceParty('Buyer', 'FR'),
new InvoiceTotals('100.00', '20.00', '120.00')
),
[
new InvoiceLine(
lineId: '1',
name: 'Produit test',
quantity: '2.0000',
unitCode: 'C62',
netPrice: '50.0000',
vatRate: '20.00'
),
],
paymentTerms: 'Paiement à 30 jours.'
);

$cii = $reader->fromXml($xml);
self::assertSame('INV-READ-1', $cii->exchangedDocument->id);
self::assertSame('Seller', $cii->supplyChainTradeTransaction->applicableHeaderTradeAgreement->sellerTradeParty->name);
}
}
tests/Service/FacturX/FacturXPdfGeneratorTest.php
<?php

declare(strict_types=1);

namespace App\Tests\Service\FacturX;

use App\Service\FacturX\FacturXGenerator;
use App\Service\FacturX\FacturXPdfGenerator;
use App\Service\FacturX\InvoiceData;
use App\Service\FacturX\InvoiceLine;
use App\Service\FacturX\InvoiceParty;
use App\Service\FacturX\InvoiceTotals;
use PHPUnit\Framework\TestCase;

class FacturXPdfGeneratorTest extends TestCase
{
public function testPdfA3ContainsFacturxAttachment(): void
{
$xml = (new FacturXGenerator())->generateEn16931Invoice(
new InvoiceData(
'PDF-TEST-1',
new \DateTimeImmutable('2024-05-10'),
'EUR',
new InvoiceParty('Seller', 'FR'),
new InvoiceParty('Buyer', 'FR'),
new InvoiceTotals('100.00', '20.00', '120.00')
),
[
new InvoiceLine(
lineId: '1',
name: 'Produit test',
quantity: '2.0000',
unitCode: 'C62',
netPrice: '50.0000',
vatRate: '20.00'
),
]
);

$pdfGenerator = new FacturXPdfGenerator();
$pdf = $pdfGenerator->generatePdfA3WithAttachment($xml);

self::assertNotEmpty($pdf);
self::assertStringContainsString('/AFRelationship /Alternative', $pdf);
self::assertStringContainsString('factur-x.xml', $pdf);
self::assertGreaterThan(1000, \strlen($pdf), 'PDF too small, attachment likely missing.');
}
}
tests/Service/FacturX/FacturXReaderTest.php
<?php

declare(strict_types=1);

namespace App\Tests\Service\FacturX;

use App\Service\FacturX\FacturXGenerator;
use App\Service\FacturX\FacturXReader;
use App\Service\FacturX\InvoiceData;
use App\Service\FacturX\InvoiceLine;
use App\Service\FacturX\InvoiceParty;
use App\Service\FacturX\InvoiceTotals;
use PHPUnit\Framework\TestCase;

class FacturXReaderTest extends TestCase
{
public function testTransformReturnsInvoiceWithData(): void
{
$xml = (new FacturXGenerator())->generateEn16931Invoice(
new InvoiceData(
'READ-1',
new \DateTimeImmutable('2025-01-15'),
'EUR',
new InvoiceParty('Seller FR', 'FR', 'FR12345678901', 'FR-TAX-42'),
new InvoiceParty('Buyer FR', 'FR'),
new InvoiceTotals('100.00', '20.00', '120.00')
),
[
new InvoiceLine(
lineId: '1',
name: 'Prestation',
quantity: '2.0000',
unitCode: 'HUR',
netPrice: '50.0000',
vatRate: '20.00'
),
],
paymentTerms: 'Paiement à 30 jours.'
);

$cii = (new FacturXReader())->fromXml($xml);

self::assertSame('READ-1', $cii->exchangedDocument->id);
self::assertSame('Seller FR', $cii->supplyChainTradeTransaction->applicableHeaderTradeAgreement->sellerTradeParty->name);
self::assertCount(1, $cii->supplyChainTradeTransaction->lineItems);
}
}

Commandes utiles

  • Tests ciblés Factur-X :
    php bin/phpunit --filter FacturX
  • Génération PDF rapide : voir la page overview pour la commande complète.

Check-list finale

  • XML valide XSD/Schematron (profil Factur-X).
  • PDF/A-3 valide, attachement avec /AFRelationship /Alternative.
  • XMP Factur-X cohérent : fx:ConformanceLevel = Factur-X, fx:Version = 1.0.
  • Payment terms présents si montant dû > 0 (BR-CO-25).
  • Tests unitaires Factur-X verts.