*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
/**
* \file class/zugferdgenerator.class.php
* \ingroup exportzugferd
* \brief ZUGFeRD/Factur-X/XRechnung XML Generator
*/
/**
* Class ZugferdGenerator
* Generates EN16931-compliant XML for ZUGFeRD/Factur-X/XRechnung
*/
class ZugferdGenerator
{
/**
* @var DoliDB Database handler
*/
private $db;
/**
* @var string[] Error messages
*/
public $errors = array();
/**
* @var string Last error message
*/
public $error = '';
/**
* Profile constants
*/
const PROFILE_MINIMUM = 'MINIMUM';
const PROFILE_BASICWL = 'BASIC WL';
const PROFILE_BASIC = 'BASIC';
const PROFILE_EN16931 = 'EN16931';
const PROFILE_EXTENDED = 'EXTENDED';
const PROFILE_XRECHNUNG = 'XRECHNUNG';
/**
* @var string Current profile
*/
private $profile;
/**
* XML Namespaces
*/
private $namespaces = array(
'rsm' => 'urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100',
'ram' => 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100',
'qdt' => 'urn:un:unece:uncefact:data:standard:QualifiedDataType:100',
'udt' => 'urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100',
);
/**
* Constructor
*
* @param DoliDB $db Database handler
*/
public function __construct($db)
{
$this->db = $db;
$this->profile = getDolGlobalString('EXPORTZUGFERD_PROFILE', self::PROFILE_EN16931);
}
/**
* Generate ZUGFeRD XML from a Dolibarr invoice
*
* @param Facture $invoice Invoice object
* @return string|false XML content or false on error
*/
public function generateFromInvoice($invoice)
{
global $conf, $mysoc;
if (empty($invoice->id)) {
$this->error = 'Invoice ID is empty';
return false;
}
// Fetch invoice data if not already loaded
if (empty($invoice->lines)) {
$invoice->fetch_lines();
}
// Fetch thirdparty
if (empty($invoice->thirdparty) || empty($invoice->thirdparty->id)) {
$invoice->fetch_thirdparty();
}
// Start building XML
$xml = $this->createXMLHeader();
$xml .= $this->createExchangedDocumentContext();
$xml .= $this->createExchangedDocument($invoice);
$xml .= $this->createSupplyChainTradeTransaction($invoice, $mysoc);
$xml .= $this->createXMLFooter();
return $xml;
}
/**
* Create XML header with namespaces
*
* @return string XML header
*/
private function createXMLHeader()
{
$xml = '' . "\n";
$xml .= 'namespaces as $prefix => $uri) {
$xml .= ' xmlns:' . $prefix . '="' . $uri . '"';
}
$xml .= '>' . "\n";
return $xml;
}
/**
* Create XML footer
*
* @return string XML footer
*/
private function createXMLFooter()
{
return '' . "\n";
}
/**
* Create ExchangedDocumentContext element
*
* @return string XML content
*/
private function createExchangedDocumentContext()
{
$profileId = $this->getProfileURN();
$xml = ' ' . "\n";
$xml .= ' ' . "\n";
$xml .= ' ' . $this->xmlEncode($profileId) . '' . "\n";
$xml .= ' ' . "\n";
$xml .= ' ' . "\n";
return $xml;
}
/**
* Get profile URN based on selected profile
*
* @return string Profile URN
*/
private function getProfileURN()
{
switch ($this->profile) {
case self::PROFILE_MINIMUM:
return 'urn:factur-x.eu:1p0:minimum';
case self::PROFILE_BASICWL:
return 'urn:factur-x.eu:1p0:basicwl';
case self::PROFILE_BASIC:
return 'urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:basic';
case self::PROFILE_EXTENDED:
return 'urn:cen.eu:en16931:2017#conformant#urn:factur-x.eu:1p0:extended';
case self::PROFILE_XRECHNUNG:
return 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0';
case self::PROFILE_EN16931:
default:
return 'urn:cen.eu:en16931:2017';
}
}
/**
* Create ExchangedDocument element
*
* @param Facture $invoice Invoice object
* @return string XML content
*/
private function createExchangedDocument($invoice)
{
$xml = ' ' . "\n";
$xml .= ' ' . $this->xmlEncode($invoice->ref) . '' . "\n";
$xml .= ' ' . $this->getInvoiceTypeCode($invoice) . '' . "\n";
$xml .= ' ' . "\n";
$xml .= ' ' . date('Ymd', $invoice->date) . '' . "\n";
$xml .= ' ' . "\n";
// Add notes if present
if (!empty($invoice->note_public)) {
$xml .= ' ' . "\n";
$xml .= ' ' . $this->xmlEncode($invoice->note_public) . '' . "\n";
$xml .= ' ' . "\n";
}
$xml .= ' ' . "\n";
return $xml;
}
/**
* Get invoice type code (UNCL 1001)
*
* @param Facture $invoice Invoice object
* @return string Type code
*/
private function getInvoiceTypeCode($invoice)
{
// 380 = Commercial invoice
// 381 = Credit note
// 384 = Corrected invoice
// 389 = Self-billed invoice
if ($invoice->type == Facture::TYPE_CREDIT_NOTE) {
return '381';
}
return '380';
}
/**
* Create SupplyChainTradeTransaction element
*
* @param Facture $invoice Invoice object
* @param Societe $mysoc Seller company
* @return string XML content
*/
private function createSupplyChainTradeTransaction($invoice, $mysoc)
{
$xml = ' ' . "\n";
// Invoice lines - skip title/subtotal lines with no amount
$lineNumber = 0;
foreach ($invoice->lines as $line) {
// Skip lines that are titles, subtotals, or have zero total and zero price
// These are typically from subtotaltitle module
if ($this->isSkippableLine($line)) {
continue;
}
$lineNumber++;
$xml .= $this->createLineItem($line, $lineNumber);
}
// Trade agreement (seller/buyer)
$xml .= $this->createApplicableHeaderTradeAgreement($invoice, $mysoc);
// Delivery information
$xml .= $this->createApplicableHeaderTradeDelivery($invoice);
// Payment information and totals
$xml .= $this->createApplicableHeaderTradeSettlement($invoice);
$xml .= ' ' . "\n";
return $xml;
}
/**
* Create line item element
*
* @param FactureLigne $line Invoice line
* @param int $lineNumber Line number
* @return string XML content
*/
private function createLineItem($line, $lineNumber)
{
$xml = ' ' . "\n";
// Line document
$xml .= ' ' . "\n";
$xml .= ' ' . $lineNumber . '' . "\n";
$xml .= ' ' . "\n";
// Product info
$xml .= ' ' . "\n";
if (!empty($line->product_ref)) {
$xml .= ' ' . $this->xmlEncode($line->product_ref) . '' . "\n";
}
$xml .= ' ' . $this->xmlEncode($line->product_label ?: $line->desc) . '' . "\n";
if (!empty($line->desc) && $line->desc != $line->product_label) {
$xml .= ' ' . $this->xmlEncode(strip_tags($line->desc)) . '' . "\n";
}
$xml .= ' ' . "\n";
// Line agreement (price)
$xml .= ' ' . "\n";
$xml .= ' ' . "\n";
$xml .= ' ' . $this->formatAmount($line->subprice) . '' . "\n";
$xml .= ' ' . "\n";
$xml .= ' ' . "\n";
// Line delivery (quantity)
$xml .= ' ' . "\n";
$xml .= ' ' . $this->formatQuantity($line->qty) . '' . "\n";
$xml .= ' ' . "\n";
// Line settlement (tax, total)
$xml .= ' ' . "\n";
$xml .= ' ' . "\n";
$xml .= ' VAT' . "\n";
$xml .= ' ' . $this->getVATCategoryCode($line->tva_tx) . '' . "\n";
$xml .= ' ' . $this->formatAmount($line->tva_tx) . '' . "\n";
$xml .= ' ' . "\n";
$xml .= ' ' . "\n";
$xml .= ' ' . $this->formatAmount($line->total_ht) . '' . "\n";
$xml .= ' ' . "\n";
$xml .= ' ' . "\n";
$xml .= ' ' . "\n";
return $xml;
}
/**
* Create ApplicableHeaderTradeAgreement element (seller/buyer)
*
* @param Facture $invoice Invoice object
* @param Societe $mysoc Seller company
* @return string XML content
*/
private function createApplicableHeaderTradeAgreement($invoice, $mysoc)
{
$buyer = $invoice->thirdparty;
// Load extrafields for buyer if not loaded
if (empty($buyer->array_options)) {
$buyer->fetch_optionals();
}
$xml = ' ' . "\n";
// Buyer Reference (Leitweg-ID for XRechnung) - must come before SellerTradeParty
$leitwegId = '';
if (!empty($buyer->array_options['options_leitweg_id'])) {
$leitwegId = $buyer->array_options['options_leitweg_id'];
}
if (!empty($leitwegId)) {
$xml .= ' ' . $this->xmlEncode($leitwegId) . '' . "\n";
}
// Seller
$xml .= ' ' . "\n";
$xml .= ' ' . $this->xmlEncode($mysoc->name) . '' . "\n";
// Seller address
$xml .= ' ' . "\n";
$xml .= ' ' . $this->xmlEncode($mysoc->zip) . '' . "\n";
$xml .= ' ' . $this->xmlEncode($mysoc->address) . '' . "\n";
$xml .= ' ' . $this->xmlEncode($mysoc->town) . '' . "\n";
$xml .= ' ' . $this->xmlEncode($mysoc->country_code) . '' . "\n";
$xml .= ' ' . "\n";
// Seller tax registration
if (!empty($mysoc->tva_intra)) {
$xml .= ' ' . "\n";
$xml .= ' ' . $this->xmlEncode($mysoc->tva_intra) . '' . "\n";
$xml .= ' ' . "\n";
}
$xml .= ' ' . "\n";
// Buyer
$xml .= ' ' . "\n";
$xml .= ' ' . $this->xmlEncode($buyer->name) . '' . "\n";
// Buyer address
$xml .= ' ' . "\n";
$xml .= ' ' . $this->xmlEncode($buyer->zip) . '' . "\n";
$xml .= ' ' . $this->xmlEncode($buyer->address) . '' . "\n";
$xml .= ' ' . $this->xmlEncode($buyer->town) . '' . "\n";
$xml .= ' ' . $this->xmlEncode($buyer->country_code) . '' . "\n";
$xml .= ' ' . "\n";
// Buyer tax registration
if (!empty($buyer->tva_intra)) {
$xml .= ' ' . "\n";
$xml .= ' ' . $this->xmlEncode($buyer->tva_intra) . '' . "\n";
$xml .= ' ' . "\n";
}
$xml .= ' ' . "\n";
// Buyer order reference
if (!empty($invoice->ref_client)) {
$xml .= ' ' . "\n";
$xml .= ' ' . $this->xmlEncode($invoice->ref_client) . '' . "\n";
$xml .= ' ' . "\n";
}
$xml .= ' ' . "\n";
return $xml;
}
/**
* Create ApplicableHeaderTradeDelivery element
*
* @param Facture $invoice Invoice object
* @return string XML content
*/
private function createApplicableHeaderTradeDelivery($invoice)
{
$xml = ' ' . "\n";
// Ship to party (same as buyer for now)
$buyer = $invoice->thirdparty;
$xml .= ' ' . "\n";
$xml .= ' ' . $this->xmlEncode($buyer->name) . '' . "\n";
$xml .= ' ' . "\n";
$xml .= ' ' . $this->xmlEncode($buyer->zip) . '' . "\n";
$xml .= ' ' . $this->xmlEncode($buyer->address) . '' . "\n";
$xml .= ' ' . $this->xmlEncode($buyer->town) . '' . "\n";
$xml .= ' ' . $this->xmlEncode($buyer->country_code) . '' . "\n";
$xml .= ' ' . "\n";
$xml .= ' ' . "\n";
$xml .= ' ' . "\n";
return $xml;
}
/**
* Create ApplicableHeaderTradeSettlement element (payment, totals)
*
* @param Facture $invoice Invoice object
* @return string XML content
*/
private function createApplicableHeaderTradeSettlement($invoice)
{
global $conf;
$xml = ' ' . "\n";
// Currency
$xml .= ' ' . ($invoice->multicurrency_code ?: $conf->currency) . '' . "\n";
// Get bank account IBAN from invoice
$iban = '';
$bic = '';
$bankName = '';
if (!empty($invoice->fk_account)) {
require_once DOL_DOCUMENT_ROOT . '/compta/bank/class/account.class.php';
$bankAccount = new Account($this->db);
if ($bankAccount->fetch($invoice->fk_account) > 0) {
$iban = $bankAccount->iban;
$bic = $bankAccount->bic;
$bankName = $bankAccount->bank;
}
}
// Payment means
$xml .= ' ' . "\n";
$xml .= ' ' . $this->getPaymentMeansCode($invoice) . '' . "\n";
// Bank account for SEPA transfer
if (!empty($iban)) {
$xml .= ' ' . "\n";
$xml .= ' ' . $this->xmlEncode($iban) . '' . "\n";
$xml .= ' ' . "\n";
// BIC if available
if (!empty($bic)) {
$xml .= ' ' . "\n";
$xml .= ' ' . $this->xmlEncode($bic) . '' . "\n";
if (!empty($bankName)) {
$xml .= ' ' . $this->xmlEncode($bankName) . '' . "\n";
}
$xml .= ' ' . "\n";
}
}
$xml .= ' ' . "\n";
// Tax summary
$xml .= $this->createTaxSummary($invoice);
// Payment terms
if (!empty($invoice->cond_reglement_id)) {
$xml .= ' ' . "\n";
if ($invoice->date_lim_reglement) {
$xml .= ' ' . "\n";
$xml .= ' ' . date('Ymd', $invoice->date_lim_reglement) . '' . "\n";
$xml .= ' ' . "\n";
}
$xml .= ' ' . "\n";
}
// Monetary summation
$xml .= ' ' . "\n";
$xml .= ' ' . $this->formatAmount($invoice->total_ht) . '' . "\n";
$xml .= ' ' . $this->formatAmount($invoice->total_ht) . '' . "\n";
$xml .= ' ' . $this->formatAmount($invoice->total_tva) . '' . "\n";
$xml .= ' ' . $this->formatAmount($invoice->total_ttc) . '' . "\n";
$xml .= ' ' . $this->formatAmount($invoice->total_ttc - $invoice->getSommePaiement()) . '' . "\n";
$xml .= ' ' . "\n";
$xml .= ' ' . "\n";
return $xml;
}
/**
* Check if a line should be skipped (title, subtotal, etc.)
*
* @param FactureLigne $line Invoice line
* @return bool True if line should be skipped
*/
private function isSkippableLine($line)
{
// Skip free text lines (product_type == 9 in some modules)
if (isset($line->product_type) && $line->product_type == 9) {
return true;
}
// Skip lines with special_code for titles/subtotals (subtotaltitle module uses special_code)
if (!empty($line->special_code) && in_array($line->special_code, array(104777, 104778, 104779))) {
return true;
}
// Skip lines where qty is 0 and total is 0 and it looks like a title/subtotal
if ((float) $line->qty == 0 && (float) $line->total_ht == 0 && (float) $line->subprice == 0) {
return true;
}
// Skip lines where description contains "Zwischensumme" or starts with title markers
$desc = strtolower($line->desc ?? '');
if (strpos($desc, 'zwischensumme') !== false) {
return true;
}
return false;
}
/**
* Create tax summary from invoice lines
*
* @param Facture $invoice Invoice object
* @return string XML content
*/
private function createTaxSummary($invoice)
{
// Group by VAT rate - only include real lines
$vatGroups = array();
foreach ($invoice->lines as $line) {
// Skip title/subtotal lines
if ($this->isSkippableLine($line)) {
continue;
}
$rate = (float) $line->tva_tx;
if (!isset($vatGroups[$rate])) {
$vatGroups[$rate] = array(
'base' => 0,
'amount' => 0,
);
}
$vatGroups[$rate]['base'] += $line->total_ht;
$vatGroups[$rate]['amount'] += $line->total_tva;
}
$xml = '';
foreach ($vatGroups as $rate => $data) {
$xml .= ' ' . "\n";
$xml .= ' ' . $this->formatAmount($data['amount']) . '' . "\n";
$xml .= ' VAT' . "\n";
$xml .= ' ' . $this->formatAmount($data['base']) . '' . "\n";
$xml .= ' ' . $this->getVATCategoryCode($rate) . '' . "\n";
$xml .= ' ' . $this->formatAmount($rate) . '' . "\n";
$xml .= ' ' . "\n";
}
return $xml;
}
/**
* Get payment means code (UNCL 4461)
*
* @param Facture $invoice Invoice object
* @return string Payment means code
*/
private function getPaymentMeansCode($invoice)
{
// 10 = Cash
// 30 = Credit transfer
// 42 = Payment to bank account
// 48 = Bank card
// 49 = Direct debit
// 58 = SEPA credit transfer
// 59 = SEPA direct debit
return '58'; // Default to SEPA credit transfer
}
/**
* Get VAT category code (UNCL 5305)
*
* @param float $rate VAT rate
* @return string Category code
*/
private function getVATCategoryCode($rate)
{
// S = Standard rate
// Z = Zero rated
// E = Exempt
// AE = Reverse charge
// K = Intra-community supply
// G = Export outside EU
// O = Outside scope of VAT
// L = Canary Islands
// M = Ceuta and Melilla
if ($rate == 0) {
return 'Z';
}
return 'S';
}
/**
* Get unit code (UN/ECE Rec. 20)
*
* @param FactureLigne $line Invoice line
* @return string Unit code
*/
private function getUnitCode($line)
{
// Map Dolibarr units to UN/ECE codes
// Common codes: C62=piece, HUR=hour, DAY=day, MTR=meter, KGM=kilogram, LTR=liter
$unit = strtolower($line->product_type == 1 ? 'service' : ($line->fk_unit ?: 'piece'));
$unitMap = array(
'piece' => 'C62',
'pce' => 'C62',
'stk' => 'C62',
'stück' => 'C62',
'hour' => 'HUR',
'h' => 'HUR',
'std' => 'HUR',
'stunde' => 'HUR',
'day' => 'DAY',
'tag' => 'DAY',
'meter' => 'MTR',
'm' => 'MTR',
'kg' => 'KGM',
'kilogram' => 'KGM',
'liter' => 'LTR',
'l' => 'LTR',
'service' => 'C62',
);
return $unitMap[$unit] ?? 'C62';
}
/**
* Format amount for XML
*
* @param float $amount Amount
* @return string Formatted amount
*/
private function formatAmount($amount)
{
return number_format((float) $amount, 2, '.', '');
}
/**
* Format quantity for XML
*
* @param float $qty Quantity
* @return string Formatted quantity
*/
private function formatQuantity($qty)
{
return number_format((float) $qty, 4, '.', '');
}
/**
* Encode string for XML
*
* @param string $string String to encode
* @return string Encoded string
*/
private function xmlEncode($string)
{
return htmlspecialchars((string) $string, ENT_XML1 | ENT_QUOTES, 'UTF-8');
}
/**
* Set profile
*
* @param string $profile Profile constant
* @return void
*/
public function setProfile($profile)
{
$this->profile = $profile;
}
/**
* Get generated XML filename
*
* @param Facture $invoice Invoice object
* @return string Filename
*/
public function getXMLFilename($invoice)
{
if ($this->profile === self::PROFILE_XRECHNUNG) {
return 'xrechnung-' . $invoice->ref . '.xml';
}
return 'factur-x.xml';
}
/**
* Save XML to file
*
* @param Facture $invoice Invoice object
* @param string $xml XML content
* @return string|false File path or false on error
*/
public function saveXML($invoice, $xml)
{
global $conf;
$dir = $conf->facture->dir_output . '/' . dol_sanitizeFileName($invoice->ref);
if (!is_dir($dir)) {
dol_mkdir($dir);
}
$filename = $this->getXMLFilename($invoice);
$filepath = $dir . '/' . $filename;
$result = file_put_contents($filepath, $xml);
if ($result === false) {
$this->error = 'Failed to write XML file: ' . $filepath;
return false;
}
return $filepath;
}
}