Module 1 - DTO et générateur XML
Ce module pose les bases : DTO d’entrée et générateur XML Factur-X avec validation XSD.
On commence par modéliser les données d’entrée, puis on construit l’XML en respectant les contraintes Factur-X. L’idée est d’obtenir un flux simple : DTO → builder → XML validé.
Objectif : obtenir un XML Factur-X strictement valide, prêt à être embarqué dans un PDF/A-3.
Objectif
Assembler les DTO Factur-X et produire un CrossIndustryInvoice valide (profil Factur-X).
Étape 1 - Créer les DTO d’entrée
Quatre fichiers simples, sans logique, pour structurer les données.
<?php
declare(strict_types=1);
namespace App\Service\FacturX;
class InvoiceData
{
public function __construct(
public string $number,
public \DateTimeImmutable $issueDate,
public string $currency,
public InvoiceParty $seller,
public InvoiceParty $buyer,
public InvoiceTotals $totals,
public string $typeCode = '380', // 380 facture, 381 avoir
) {
}
}
<?php
declare(strict_types=1);
namespace App\Service\FacturX;
class InvoiceLine
{
public function __construct(
public string $lineId,
public string $name,
public string $quantity,
public string $unitCode,
public string $netPrice,
public string $vatRate,
public string $vatCategory = 'S',
public ?string $sellerAssignedId = null,
) {
}
}
<?php
declare(strict_types=1);
namespace App\Service\FacturX;
class InvoiceParty
{
public function __construct(
public string $name,
public string $countryCode,
public ?string $vatNumber = null,
public ?string $taxNumber = null,
public ?string $streetLine1 = null,
public ?string $city = null,
public ?string $postcode = null,
) {
}
}
<?php
declare(strict_types=1);
namespace App\Service\FacturX;
class InvoiceTotals
{
public function __construct(
public string $taxBasisAmount,
public string $taxAmount,
public string $grandTotalAmount,
public ?string $duePayableAmount = null,
) {
}
}
Étape 2 - Créer le générateur XML
On met en place une classe qui transforme les DTO en CrossIndustryInvoice et qui fixe le contexte Factur-X.
Squelette de classe (avant d’assembler le reste)
final class FacturXGenerator
{
public function __construct(
private ?Builder $builder = null,
private ?Validator $validator = null,
) {
$this->builder = $this->builder ?? Builder::create();
$this->validator = $this->validator ?? new Validator();
}
public function generateEn16931Invoice(
InvoiceData $invoice,
array $lines,
?string $paymentTerms = null,
bool $validateWithXsd = true,
): string {
// construit le CrossIndustryInvoice, puis valide
}
}
Étape 3 - Mapper les données et agréger les totaux
On calcule les totaux à partir des lignes pour éviter tout écart (exigence classique Factur-X).
private function aggregateTotals(array $lines): array
{
$netTotal = '0.00';
$taxTotal = '0.00';
$taxes = []; // clé = taux TVA
foreach ($lines as $line) {
$lineNet = $this->formatAmount($this->multiply($line->netPrice, $line->quantity));
$lineTax = $this->formatAmount($this->percentOf($lineNet, $line->vatRate));
$netTotal = $this->formatAmount($this->add($netTotal, $lineNet));
$taxTotal = $this->formatAmount($this->add($taxTotal, $lineTax));
$key = $line->vatRate;
$taxes[$key] ??= ['basis' => '0.00', 'amount' => '0.00', 'category' => $line->vatCategory];
$taxes[$key]['basis'] = $this->formatAmount($this->add($taxes[$key]['basis'], $lineNet));
$taxes[$key]['amount'] = $this->formatAmount($this->add($taxes[$key]['amount'], $lineTax));
}
$grandTotal = $this->formatAmount($this->add($netTotal, $taxTotal));
return ['netTotal' => $netTotal, 'taxTotal' => $taxTotal, 'grandTotal' => $grandTotal, 'taxes' => $taxes];
}
Étape 4 - Valider l’XML via XSD
$errors = $validator->validateAgainstXsd($xml, Validator::SCHEMA_EN16931);
if ($errors !== null) {
throw new \RuntimeException($errors);
}
Exemple complet d’appel
L’appel est plus lisible si on prépare les objets avant la génération.
$seller = new InvoiceParty('Société Démo SAS', 'FR', 'FR12345678901', 'FR-TAX-42', '10 Rue de la Paix', 'Paris', '75002');
$buyer = new InvoiceParty('Client Test SARL', 'FR', null, null, '20 Avenue de la République', 'Lyon', '69001');
$totals = new InvoiceTotals('120.00', '24.00', '144.00');
$invoice = new InvoiceData(
number: 'FAC-2025-001',
issueDate: new \DateTimeImmutable('2025-01-15'),
currency: 'EUR',
seller: $seller,
buyer: $buyer,
totals: $totals,
);
$lines = [
new InvoiceLine(
lineId: '1',
name: 'Prestation de service',
quantity: '2.0000',
unitCode: 'HUR',
netPrice: '60.0000',
vatRate: '20.00',
vatCategory: 'S',
sellerAssignedId: 'SERV-001'
),
];
$xml = (new FacturXGenerator())->generateEn16931Invoice(
$invoice,
$lines,
paymentTerms: 'Paiement à 30 jours fin de mois.'
);
Fichier complet (à copier tel quel)
<?php
declare(strict_types=1);
namespace App\Service\FacturX;
use Easybill\ZUGFeRD2\Builder;
use Easybill\ZUGFeRD2\Model\Amount;
use Easybill\ZUGFeRD2\Model\CrossIndustryInvoice;
use Easybill\ZUGFeRD2\Model\DateTime;
use Easybill\ZUGFeRD2\Model\DocumentContextParameter;
use Easybill\ZUGFeRD2\Model\DocumentLineDocument;
use Easybill\ZUGFeRD2\Model\ExchangedDocument;
use Easybill\ZUGFeRD2\Model\ExchangedDocumentContext;
use Easybill\ZUGFeRD2\Model\HeaderTradeAgreement;
use Easybill\ZUGFeRD2\Model\HeaderTradeDelivery;
use Easybill\ZUGFeRD2\Model\HeaderTradeSettlement;
use Easybill\ZUGFeRD2\Model\LineTradeAgreement;
use Easybill\ZUGFeRD2\Model\LineTradeDelivery;
use Easybill\ZUGFeRD2\Model\LineTradeSettlement;
use Easybill\ZUGFeRD2\Model\Quantity;
use Easybill\ZUGFeRD2\Model\SupplyChainEvent;
use Easybill\ZUGFeRD2\Model\SupplyChainTradeLineItem;
use Easybill\ZUGFeRD2\Model\SupplyChainTradeTransaction;
use Easybill\ZUGFeRD2\Model\TaxRegistration;
use Easybill\ZUGFeRD2\Model\TradeAddress;
use Easybill\ZUGFeRD2\Model\TradeParty;
use Easybill\ZUGFeRD2\Model\TradePaymentTerms;
use Easybill\ZUGFeRD2\Model\TradePrice;
use Easybill\ZUGFeRD2\Model\TradeProduct;
use Easybill\ZUGFeRD2\Model\TradeSettlementHeaderMonetarySummation;
use Easybill\ZUGFeRD2\Model\TradeSettlementLineMonetarySummation;
use Easybill\ZUGFeRD2\Model\TradeTax;
use Easybill\ZUGFeRD2\Validator;
class FacturXGenerator
{
private Builder $builder;
private Validator $validator;
public function __construct(?Builder $builder = null, ?Validator $validator = null)
{
$this->builder = $builder ?? Builder::create();
$this->validator = $validator ?? new Validator();
}
public function generateMinimumInvoice(InvoiceData $invoice, bool $validateWithXsd = true): string
{
$cii = new CrossIndustryInvoice();
$cii->exchangedDocumentContext = new ExchangedDocumentContext();
$cii->exchangedDocumentContext->documentContextParameter = new DocumentContextParameter();
$cii->exchangedDocumentContext->documentContextParameter->id = Builder::GUIDELINE_SPECIFIED_DOCUMENT_CONTEXT_ID_MINIMUM;
$cii->exchangedDocument = new ExchangedDocument();
$cii->exchangedDocument->id = $invoice->number;
$cii->exchangedDocument->typeCode = $invoice->typeCode;
$cii->exchangedDocument->issueDateTime = DateTime::create(102, $invoice->issueDate->format('Ymd'));
$cii->supplyChainTradeTransaction = new SupplyChainTradeTransaction();
$cii->supplyChainTradeTransaction->applicableHeaderTradeAgreement = $agreement = new HeaderTradeAgreement();
$agreement->sellerTradeParty = $this->mapParty($invoice->seller);
$agreement->buyerTradeParty = $this->mapParty($invoice->buyer);
$cii->supplyChainTradeTransaction->applicableHeaderTradeDelivery = new HeaderTradeDelivery();
$cii->supplyChainTradeTransaction->applicableHeaderTradeSettlement = $settlement = new HeaderTradeSettlement();
$settlement->invoiceCurrencyCode = $invoice->currency;
$settlement->specifiedTradeSettlementHeaderMonetarySummation = $summation = new TradeSettlementHeaderMonetarySummation();
$summation->taxBasisTotalAmount[] = Amount::create($invoice->totals->taxBasisAmount);
$summation->taxTotalAmount[] = Amount::create($invoice->totals->taxAmount, $invoice->currency);
$summation->grandTotalAmount[] = Amount::create($invoice->totals->grandTotalAmount);
$summation->duePayableAmount = Amount::create($invoice->totals->duePayableAmount ?? $invoice->totals->grandTotalAmount);
$xml = $this->builder->transform($cii);
if ($validateWithXsd) {
$errors = $this->validator->validateAgainstXsd($xml, Validator::SCHEMA_MINIMUM);
if ($errors !== null) {
throw new \RuntimeException($errors);
}
}
return $xml;
}
/**
* Génération Factur-X avec lignes de facture et TVA agrégée.
*
* @param InvoiceLine[] $lines
*/
public function generateEn16931Invoice(InvoiceData $invoice, array $lines, ?string $paymentTerms = null, bool $validateWithXsd = true): string
{
$cii = new CrossIndustryInvoice();
$cii->exchangedDocumentContext = new ExchangedDocumentContext();
$cii->exchangedDocumentContext->documentContextParameter = new DocumentContextParameter();
$cii->exchangedDocumentContext->documentContextParameter->id = Builder::GUIDELINE_SPECIFIED_DOCUMENT_CONTEXT_ID_EN16931;
$cii->exchangedDocument = new ExchangedDocument();
$cii->exchangedDocument->id = $invoice->number;
$cii->exchangedDocument->typeCode = $invoice->typeCode;
$cii->exchangedDocument->issueDateTime = DateTime::create(102, $invoice->issueDate->format('Ymd'));
$cii->supplyChainTradeTransaction = new SupplyChainTradeTransaction();
foreach ($lines as $line) {
$cii->supplyChainTradeTransaction->lineItems[] = $this->mapLine($line);
}
$cii->supplyChainTradeTransaction->applicableHeaderTradeAgreement = $agreement = new HeaderTradeAgreement();
$agreement->sellerTradeParty = $this->mapParty($invoice->seller);
$agreement->buyerTradeParty = $this->mapParty($invoice->buyer);
$deliveryEvent = new SupplyChainEvent();
$deliveryEvent->occurrenceDateTime = DateTime::create(102, $invoice->issueDate->format('Ymd'));
$cii->supplyChainTradeTransaction->applicableHeaderTradeDelivery = new HeaderTradeDelivery();
$cii->supplyChainTradeTransaction->applicableHeaderTradeDelivery->actualDeliverySupplyChainEvent = $deliveryEvent;
$aggregates = $this->aggregateTotals($lines);
$cii->supplyChainTradeTransaction->applicableHeaderTradeSettlement = $settlement = new HeaderTradeSettlement();
$settlement->invoiceCurrencyCode = $invoice->currency;
foreach ($aggregates['taxes'] as $rate => $taxData) {
$tradeTax = new TradeTax();
$tradeTax->typeCode = 'VAT';
$tradeTax->categoryCode = $taxData['category'];
$tradeTax->basisAmount = Amount::create($taxData['basis']);
$tradeTax->calculatedAmount = Amount::create($taxData['amount']);
$tradeTax->rateApplicablePercent = $rate;
$settlement->tradeTaxes[] = $tradeTax;
}
if ($paymentTerms !== null) {
$terms = new TradePaymentTerms();
$terms->description = $paymentTerms;
$settlement->specifiedTradePaymentTerms[] = $terms;
}
$settlement->specifiedTradeSettlementHeaderMonetarySummation = $summation = new TradeSettlementHeaderMonetarySummation();
$summation->lineTotalAmount = Amount::create($aggregates['netTotal']);
$summation->chargeTotalAmount = Amount::create('0.00');
$summation->allowanceTotalAmount = Amount::create('0.00');
$summation->taxBasisTotalAmount[] = Amount::create($aggregates['netTotal']);
$summation->taxTotalAmount[] = Amount::create($aggregates['taxTotal'], $invoice->currency);
$summation->grandTotalAmount[] = Amount::create($aggregates['grandTotal']);
$summation->totalPrepaidAmount = Amount::create('0.00');
$summation->duePayableAmount = Amount::create($aggregates['grandTotal']);
$xml = $this->builder->transform($cii);
if ($validateWithXsd) {
$errors = $this->validator->validateAgainstXsd($xml, Validator::SCHEMA_EN16931);
if ($errors !== null) {
throw new \RuntimeException($errors);
}
}
return $xml;
}
private function mapParty(InvoiceParty $party): TradeParty
{
$tradeParty = new TradeParty();
$tradeParty->name = $party->name;
$tradeParty->postalTradeAddress = new TradeAddress();
$tradeParty->postalTradeAddress->countryID = $party->countryCode;
$tradeParty->postalTradeAddress->lineOne = $party->streetLine1;
$tradeParty->postalTradeAddress->cityName = $party->city;
$tradeParty->postalTradeAddress->postcodeCode = $party->postcode;
if ($party->taxNumber !== null) {
$tradeParty->taxRegistrations[] = TaxRegistration::create($party->taxNumber, 'FC');
}
if ($party->vatNumber !== null) {
$tradeParty->taxRegistrations[] = TaxRegistration::create($party->vatNumber, 'VA');
}
return $tradeParty;
}
private function mapLine(InvoiceLine $line): SupplyChainTradeLineItem
{
$item = new SupplyChainTradeLineItem();
$item->associatedDocumentLineDocument = DocumentLineDocument::create($line->lineId);
$item->specifiedTradeProduct = new TradeProduct();
$item->specifiedTradeProduct->name = $line->name;
$item->specifiedTradeProduct->sellerAssignedID = $line->sellerAssignedId;
$item->tradeAgreement = new LineTradeAgreement();
$item->tradeAgreement->grossPrice = TradePrice::create($line->netPrice);
$item->tradeAgreement->netPrice = TradePrice::create($line->netPrice);
$item->delivery = new LineTradeDelivery();
$item->delivery->billedQuantity = Quantity::create($line->quantity, $line->unitCode);
$item->specifiedLineTradeSettlement = new LineTradeSettlement();
$lineTax = new TradeTax();
$lineTax->typeCode = 'VAT';
$lineTax->categoryCode = $line->vatCategory;
$lineTax->rateApplicablePercent = $line->vatRate;
$item->specifiedLineTradeSettlement->tradeTax[] = $lineTax;
$item->specifiedLineTradeSettlement->monetarySummation = TradeSettlementLineMonetarySummation::create(
$this->formatAmount($this->multiply($line->netPrice, $line->quantity))
);
return $item;
}
/**
* @param InvoiceLine[] $lines
*
* @return array{netTotal:string,taxTotal:string,grandTotal:string,taxes:array<string,array{basis:string,amount:string,category:string}>}
*/
private function aggregateTotals(array $lines): array
{
$netTotal = '0.00';
$taxTotal = '0.00';
/** @var array<string,array{basis:string,amount:string,category:string}> $taxes */
$taxes = [];
foreach ($lines as $line) {
$lineNet = $this->formatAmount($this->multiply($line->netPrice, $line->quantity));
$lineTax = $this->formatAmount($this->percentOf($lineNet, $line->vatRate));
$netTotal = $this->formatAmount($this->add($netTotal, $lineNet));
$taxTotal = $this->formatAmount($this->add($taxTotal, $lineTax));
$key = $line->vatRate;
if (!isset($taxes[$key])) {
$taxes[$key] = [
'basis' => '0.00',
'amount' => '0.00',
'category' => $line->vatCategory,
];
}
$taxes[$key]['basis'] = $this->formatAmount($this->add($taxes[$key]['basis'], $lineNet));
$taxes[$key]['amount'] = $this->formatAmount($this->add($taxes[$key]['amount'], $lineTax));
}
$grandTotal = $this->formatAmount($this->add($netTotal, $taxTotal));
return [
'netTotal' => $netTotal,
'taxTotal' => $taxTotal,
'grandTotal' => $grandTotal,
'taxes' => $taxes,
];
}
private function add(string $a, string $b): float
{
return (float) $a + (float) $b;
}
private function multiply(string $amount, string $factor): float
{
return (float) $amount * (float) $factor;
}
private function percentOf(string $amount, string $percent): float
{
return ((float) $amount * (float) $percent) / 100;
}
private function formatAmount(float $amount): string
{
return number_format($amount, 2, '.', '');
}
}
À retenir pour la suite
ConformanceLevelcible : selon le profil choisi.- Les totaux passés à
InvoiceDataservent surtout au profil MINIMUM ; pour le profil principal ils sont recalculés à partir des lignes. - Le paramètre
paymentTermssatisfait la règle BR-CO-25 (si montant dû > 0, date/termes de paiement requis).
Passe au Module 2 pour attacher le XML dans un PDF/A-3 avec métadonnées XMP Factur-X.