* * 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; } }