Version 2.0 - ZUGFeRD PDF-Einbettung

- ZUGFeRD/Factur-X XML-Generierung (EN16931)
- XRechnung 3.0 Unterstützung
- PDF-Einbettung (echtes ZUGFeRD-PDF)
- Option XML nach Einbettung zu löschen
- ODT-Template Unterstützung
- E-Mail Anhang Funktion
- XML-Vorschau

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-02-12 15:36:15 +01:00
commit ee37b3dd69
16 changed files with 3169 additions and 0 deletions

16
.gitignore vendored Normal file
View file

@ -0,0 +1,16 @@
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
# Temp files
*.tmp
*~

34
CHANGELOG.md Normal file
View file

@ -0,0 +1,34 @@
# Changelog
Alle wichtigen Änderungen an diesem Projekt werden in dieser Datei dokumentiert.
## [2.0] - 2026-02-12
### Neu
- PDF-Einbettung: XML wird direkt ins PDF eingebettet (echtes ZUGFeRD-PDF)
- Option "XML nach Einbettung löschen" für saubere Verzeichnisse
- Unterstützung für ODT-Templates mit automatischer PDF-Konvertierung
- Kompakte Mini-Buttons in der Rechnungsansicht
- XML-Vorschau-Funktion
- Automatisches Anhängen der XML an E-Mails
### Verbessert
- Korrekte ZUGFeRD-Einbettung (nur EmbeddedFiles, keine FileAttachment-Annotation)
- TCPDI/TCPDF Kompatibilität verbessert
- Bessere Fehlerbehandlung und Logging
### Behoben
- Doppelte XML-Anzeige bei pdfdetach behoben
- TCPDF/TCPDI Ladereihenfolge korrigiert
- setPDFA() Methodenprüfung für TCPDI
## [1.0] - 2026-01-15
### Neu
- Initiale Version
- ZUGFeRD 2.1 / Factur-X XML-Generierung
- XRechnung 3.0 Unterstützung
- EN16931 Konformität
- Automatische Generierung bei PDF-Erstellung
- Manuelle Generierung über Rechnungsansicht
- Download-Funktion für XML-Dateien

106
README.md Normal file
View file

@ -0,0 +1,106 @@
# ExportZugferd - Dolibarr Modul
Dolibarr-Modul zum Export von Kundenrechnungen im ZUGFeRD/Factur-X und XRechnung Format.
## Beschreibung
Dieses Modul ermöglicht den automatischen Export von Kundenrechnungen im standardisierten elektronischen Rechnungsformat:
- **ZUGFeRD 2.1 / Factur-X** - Hybrides PDF/XML-Format
- **XRechnung 3.0** - Deutscher Standard für Behördenrechnungen
- **EN16931** - Europäische Norm für elektronische Rechnungen
## Funktionen
- Automatische XML-Generierung bei PDF-Erstellung
- XML-Einbettung in PDF (echtes ZUGFeRD-PDF)
- Manuelle XML-Generierung über Rechnungsansicht
- Vorschau der generierten XML
- Automatisches Anhängen an E-Mails
- Unterstützung für ODT-Templates mit PDF-Konvertierung
## Installation
1. Modul-Ordner nach `htdocs/custom/exportzugferd` kopieren
2. In Dolibarr: Einstellungen > Module > ExportZugferd aktivieren
3. Modul-Einstellungen konfigurieren
## Konfiguration
### Einstellungen (Einstellungen > Module > ExportZugferd > Einstellungen)
| Option | Beschreibung |
|--------|--------------|
| **Profil** | ZUGFeRD-Profil (EN16931 empfohlen) |
| **Auto-Generierung** | XML automatisch bei PDF-Erstellung generieren |
| **In PDF einbetten** | XML in das PDF einbetten (ZUGFeRD-konform) |
| **XML nach Einbettung löschen** | Separate XML-Datei nach Einbettung entfernen |
| **An E-Mail anhängen** | XML automatisch an Rechnungs-E-Mails anhängen |
### Unterstützte Profile
- **MINIMUM** - Minimale Rechnungsdaten (nur Archivierung)
- **BASIC** - Grundlegende strukturierte Rechnungsdaten
- **EN16931** - Vollständige EU-konforme Rechnung (empfohlen)
- **XRECHNUNG** - Deutsche Behördenrechnung
## Systemvoraussetzungen
- Dolibarr >= 15.0
- PHP >= 7.4
- TCPDF (in Dolibarr enthalten)
- TCPDI (in Dolibarr enthalten)
- LibreOffice (für ODT zu PDF Konvertierung)
## Verwendung
### Automatische Generierung
Wenn "Auto-Generierung" aktiviert ist, wird bei jeder PDF-Erstellung einer Kundenrechnung automatisch die ZUGFeRD-XML generiert und optional ins PDF eingebettet.
### Manuelle Generierung
In der Rechnungsansicht wird eine ZUGFeRD-Zeile angezeigt mit folgenden Aktionen:
- XML generieren/neu generieren
- XML herunterladen
- XML-Vorschau anzeigen
## Technische Details
### Generierte XML-Struktur
Das Modul generiert CrossIndustryInvoice-XML nach dem UN/CEFACT CII Standard:
```xml
<rsm:CrossIndustryInvoice>
<rsm:ExchangedDocumentContext>...</rsm:ExchangedDocumentContext>
<rsm:ExchangedDocument>...</rsm:ExchangedDocument>
<rsm:SupplyChainTradeTransaction>...</rsm:SupplyChainTradeTransaction>
</rsm:CrossIndustryInvoice>
```
### Dateiablage
- XML-Dateien: `documents/facture/{ref}/factur-x.xml`
- XRechnung: `documents/facture/{ref}/xrechnung-{ref}.xml`
### Hooks
Das Modul nutzt folgende Dolibarr-Hooks:
- `afterPDFCreation` - Nach PDF-Generierung
- `afterODTCreation` - Nach ODT-Generierung (mit PDF-Konvertierung)
- `formObjectOptions` - Anzeige in Rechnungsansicht
## Lizenz
GPL v3 - siehe [LICENSE](LICENSE)
## Autor
**Data IT Solution**
- E-Mail: data@data-it-solution.de
- Web: https://data-it-solution.de
## Changelog
Siehe [CHANGELOG.md](CHANGELOG.md)

105
admin/about.php Executable file
View file

@ -0,0 +1,105 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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 <https://www.gnu.org/licenses/>.
*/
/**
* \file admin/about.php
* \ingroup exportzugferd
* \brief About page for ExportZugferd module
*/
// Load Dolibarr environment
$res = 0;
if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) {
$res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php";
}
$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME'];
$tmp2 = realpath(__FILE__);
$i = strlen($tmp) - 1;
$j = strlen($tmp2) - 1;
while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) {
$i--;
$j--;
}
if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) {
$res = @include substr($tmp, 0, ($i + 1))."/main.inc.php";
}
if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) {
$res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php";
}
if (!$res && file_exists("../main.inc.php")) {
$res = @include "../main.inc.php";
}
if (!$res && file_exists("../../main.inc.php")) {
$res = @include "../../main.inc.php";
}
if (!$res && file_exists("../../../main.inc.php")) {
$res = @include "../../../main.inc.php";
}
if (!$res) {
die("Include of main fails");
}
require_once DOL_DOCUMENT_ROOT . '/core/lib/admin.lib.php';
require_once __DIR__ . '/../lib/exportzugferd.lib.php';
// Security check
if (!$user->admin) {
accessforbidden();
}
$langs->loadLangs(array('admin', 'exportzugferd@exportzugferd'));
/*
* View
*/
$page_name = 'ExportZugferdAbout';
llxHeader('', $langs->trans($page_name), '', '', 0, 0, '', '', '', 'mod-exportzugferd page-admin-about');
// Subheader
$linkback = '<a href="' . DOL_URL_ROOT . '/admin/modules.php?restore_lastsearch_values=1">' . $langs->trans('BackToModuleList') . '</a>';
print load_fiche_titre($langs->trans('ExportZugferdSetup'), $linkback, 'title_setup');
// Configuration header
$head = exportzugferd_admin_prepare_head();
print dol_get_fiche_head($head, 'about', $langs->trans('ExportZugferd'), -1, 'fa-file-export');
print '<div class="fichecenter">';
print '<h2>' . $langs->trans('AboutModule') . '</h2>';
print '<p>' . $langs->trans('ExportZugferdAboutDesc') . '</p>';
print '<h3>' . $langs->trans('Features') . '</h3>';
print '<ul>';
print '<li>' . $langs->trans('FeatureGenerateZugferd') . '</li>';
print '<li>' . $langs->trans('FeatureXRechnung') . '</li>';
print '<li>' . $langs->trans('FeatureEN16931') . '</li>';
print '<li>' . $langs->trans('FeatureAutoGenerate') . '</li>';
print '</ul>';
print '<h3>' . $langs->trans('Version') . '</h3>';
print '<p>1.0</p>';
print '<h3>' . $langs->trans('Author') . '</h3>';
print '<p>Eduard Wisch - Data IT Solution</p>';
print '</div>';
print dol_get_fiche_end();
llxFooter();
$db->close();

239
admin/setup.php Executable file
View file

@ -0,0 +1,239 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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 <https://www.gnu.org/licenses/>.
*/
/**
* \file admin/setup.php
* \ingroup exportzugferd
* \brief ExportZugferd setup page
*/
// Load Dolibarr environment
$res = 0;
if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) {
$res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php";
}
$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME'];
$tmp2 = realpath(__FILE__);
$i = strlen($tmp) - 1;
$j = strlen($tmp2) - 1;
while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) {
$i--;
$j--;
}
if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) {
$res = @include substr($tmp, 0, ($i + 1))."/main.inc.php";
}
if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) {
$res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php";
}
if (!$res && file_exists("../main.inc.php")) {
$res = @include "../main.inc.php";
}
if (!$res && file_exists("../../main.inc.php")) {
$res = @include "../../main.inc.php";
}
if (!$res && file_exists("../../../main.inc.php")) {
$res = @include "../../../main.inc.php";
}
if (!$res) {
die("Include of main fails");
}
require_once DOL_DOCUMENT_ROOT . '/core/lib/admin.lib.php';
require_once __DIR__ . '/../lib/exportzugferd.lib.php';
// Security check
if (!$user->admin) {
accessforbidden();
}
$langs->loadLangs(array('admin', 'exportzugferd@exportzugferd'));
$action = GETPOST('action', 'aZ09');
// Available profiles
$profiles = array(
'MINIMUM' => 'ZUGFeRD Minimum',
'BASIC WL' => 'ZUGFeRD Basic WL',
'BASIC' => 'ZUGFeRD Basic',
'EN16931' => 'ZUGFeRD EN16931 (Comfort)',
'EXTENDED' => 'ZUGFeRD Extended',
'XRECHNUNG' => 'XRechnung 3.0',
);
/*
* Actions
*/
if ($action == 'update') {
$error = 0;
$profile = GETPOST('EXPORTZUGFERD_PROFILE', 'alpha');
$autoGenerate = GETPOSTINT('EXPORTZUGFERD_AUTO_GENERATE');
$embedPdf = GETPOSTINT('EXPORTZUGFERD_EMBED_IN_PDF');
$deleteXmlAfterEmbed = GETPOSTINT('EXPORTZUGFERD_DELETE_XML_AFTER_EMBED');
$attachToEmail = GETPOSTINT('EXPORTZUGFERD_ATTACH_TO_EMAIL');
if (!$error) {
$result = dolibarr_set_const($db, 'EXPORTZUGFERD_PROFILE', $profile, 'chaine', 0, '', $conf->entity);
if ($result < 0) {
$error++;
}
}
if (!$error) {
$result = dolibarr_set_const($db, 'EXPORTZUGFERD_AUTO_GENERATE', $autoGenerate, 'chaine', 0, '', $conf->entity);
if ($result < 0) {
$error++;
}
}
if (!$error) {
$result = dolibarr_set_const($db, 'EXPORTZUGFERD_EMBED_IN_PDF', $embedPdf, 'chaine', 0, '', $conf->entity);
if ($result < 0) {
$error++;
}
}
if (!$error) {
$result = dolibarr_set_const($db, 'EXPORTZUGFERD_DELETE_XML_AFTER_EMBED', $deleteXmlAfterEmbed, 'chaine', 0, '', $conf->entity);
if ($result < 0) {
$error++;
}
}
if (!$error) {
$result = dolibarr_set_const($db, 'EXPORTZUGFERD_ATTACH_TO_EMAIL', $attachToEmail, 'chaine', 0, '', $conf->entity);
if ($result < 0) {
$error++;
}
}
if (!$error) {
setEventMessages($langs->trans('SetupSaved'), null, 'mesgs');
} else {
setEventMessages($langs->trans('Error'), null, 'errors');
}
}
/*
* View
*/
$page_name = 'ExportZugferdSetup';
llxHeader('', $langs->trans($page_name), '', '', 0, 0, '', '', '', 'mod-exportzugferd page-admin-setup');
// Subheader
$linkback = '<a href="' . DOL_URL_ROOT . '/admin/modules.php?restore_lastsearch_values=1">' . $langs->trans('BackToModuleList') . '</a>';
print load_fiche_titre($langs->trans($page_name), $linkback, 'title_setup');
// Configuration header
$head = exportzugferd_admin_prepare_head();
print dol_get_fiche_head($head, 'settings', $langs->trans('ExportZugferd'), -1, 'fa-file-export');
print '<form method="POST" action="' . $_SERVER['PHP_SELF'] . '">';
print '<input type="hidden" name="token" value="' . newToken() . '">';
print '<input type="hidden" name="action" value="update">';
print '<table class="noborder centpercent">';
// Profile selection
print '<tr class="liste_titre">';
print '<td colspan="2">' . $langs->trans('GeneralSettings') . '</td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td class="titlefield">' . $langs->trans('ZugferdProfile') . '</td>';
print '<td>';
print '<select name="EXPORTZUGFERD_PROFILE" class="flat minwidth200">';
foreach ($profiles as $code => $label) {
$selected = (getDolGlobalString('EXPORTZUGFERD_PROFILE', 'EN16931') == $code) ? ' selected' : '';
print '<option value="' . $code . '"' . $selected . '>' . $label . '</option>';
}
print '</select>';
print '</td>';
print '</tr>';
// Auto-generate
print '<tr class="oddeven">';
print '<td>' . $langs->trans('AutoGenerateZugferd') . '</td>';
print '<td>';
print $form->selectyesno('EXPORTZUGFERD_AUTO_GENERATE', getDolGlobalInt('EXPORTZUGFERD_AUTO_GENERATE', 0), 1);
print '<span class="opacitymedium"> - ' . $langs->trans('AutoGenerateZugferdDesc') . '</span>';
print '</td>';
print '</tr>';
// Embed in PDF
print '<tr class="oddeven">';
print '<td>' . $langs->trans('EmbedXMLInPDF') . '</td>';
print '<td>';
print $form->selectyesno('EXPORTZUGFERD_EMBED_IN_PDF', getDolGlobalInt('EXPORTZUGFERD_EMBED_IN_PDF', 0), 1);
print '<span class="opacitymedium"> - ' . $langs->trans('EmbedXMLInPDFDesc') . '</span>';
print '</td>';
print '</tr>';
// Delete XML after embedding
print '<tr class="oddeven">';
print '<td>' . $langs->trans('DeleteXMLAfterEmbed') . '</td>';
print '<td>';
print $form->selectyesno('EXPORTZUGFERD_DELETE_XML_AFTER_EMBED', getDolGlobalInt('EXPORTZUGFERD_DELETE_XML_AFTER_EMBED', 0), 1);
print '<span class="opacitymedium"> - ' . $langs->trans('DeleteXMLAfterEmbedDesc') . '</span>';
print '</td>';
print '</tr>';
// Attach to Email
print '<tr class="oddeven">';
print '<td>' . $langs->trans('AttachZugferdToEmail') . '</td>';
print '<td>';
print $form->selectyesno('EXPORTZUGFERD_ATTACH_TO_EMAIL', getDolGlobalInt('EXPORTZUGFERD_ATTACH_TO_EMAIL', 0), 1);
print '<span class="opacitymedium"> - ' . $langs->trans('AttachZugferdToEmailDesc') . '</span>';
print '</td>';
print '</tr>';
print '</table>';
print '<br>';
print '<div class="center">';
print '<input type="submit" class="button" value="' . $langs->trans('Save') . '">';
print '</div>';
print '</form>';
// Info section
print '<br>';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<td colspan="2">' . $langs->trans('Information') . '</td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td class="titlefield">' . $langs->trans('SupportedProfiles') . '</td>';
print '<td>';
print '<ul>';
print '<li><strong>ZUGFeRD Minimum:</strong> ' . $langs->trans('ProfileMinimumDesc') . '</li>';
print '<li><strong>ZUGFeRD Basic:</strong> ' . $langs->trans('ProfileBasicDesc') . '</li>';
print '<li><strong>ZUGFeRD EN16931:</strong> ' . $langs->trans('ProfileEN16931Desc') . '</li>';
print '<li><strong>XRechnung:</strong> ' . $langs->trans('ProfileXRechnungDesc') . '</li>';
print '</ul>';
print '</td>';
print '</tr>';
print '</table>';
print dol_get_fiche_end();
llxFooter();
$db->close();

View file

@ -0,0 +1,461 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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 <https://www.gnu.org/licenses/>.
*/
/**
* \file class/actions_exportzugferd.class.php
* \ingroup exportzugferd
* \brief Hook class for ZUGFeRD/XRechnung export
*/
/**
* Class ActionsExportzugferd
* Handles hooks for ZUGFeRD export functionality
*/
class ActionsExportzugferd
{
/**
* @var DoliDB Database handler
*/
private $db;
/**
* @var string[] Errors
*/
public $errors = array();
/**
* @var string Error message
*/
public $error = '';
/**
* @var string Result for hooks
*/
public $resprints = '';
/**
* Constructor
*
* @param DoliDB $db Database handler
*/
public function __construct($db)
{
$this->db = $db;
}
/**
* Hook executed after PDF/ODT creation
* Creates ZUGFeRD XML file alongside the document and optionally embeds it in the PDF
*
* @param array $parameters Hook parameters
* @param object $object Object (not the invoice!)
* @param string $action Action
* @return int 0=OK, <0=Error
*/
public function afterPDFCreation($parameters, &$object, &$action)
{
global $conf, $langs;
dol_syslog("ExportZugferd Hook: afterPDFCreation called", LOG_INFO);
// Get the PDF file path from parameters
$pdfFile = isset($parameters['file']) ? $parameters['file'] : '';
if (empty($pdfFile)) {
dol_syslog("ExportZugferd Hook: No file in parameters", LOG_INFO);
return 0;
}
dol_syslog("ExportZugferd Hook: PDF file = " . $pdfFile, LOG_INFO);
// Get the invoice from parameters
$invoice = isset($parameters['object']) ? $parameters['object'] : null;
// Load Facture class if needed
if (!class_exists('Facture')) {
require_once DOL_DOCUMENT_ROOT . '/compta/facture/class/facture.class.php';
}
// Check if it's a customer invoice
if (!is_object($invoice)) {
dol_syslog("ExportZugferd Hook: parameters[object] is not an object", LOG_INFO);
return 0;
}
dol_syslog("ExportZugferd Hook: Object class = " . get_class($invoice), LOG_INFO);
if (!($invoice instanceof Facture)) {
dol_syslog("ExportZugferd Hook: Not a customer invoice (Facture), skipping. Class: " . get_class($invoice), LOG_INFO);
return 0;
}
// Check if auto-generation is enabled
$autoGenerate = getDolGlobalInt('EXPORTZUGFERD_AUTO_GENERATE');
dol_syslog("ExportZugferd Hook: EXPORTZUGFERD_AUTO_GENERATE = " . $autoGenerate, LOG_INFO);
if (!$autoGenerate) {
dol_syslog("ExportZugferd Hook: Auto-generation disabled", LOG_INFO);
return 0;
}
dol_syslog("ExportZugferd Hook: Generating ZUGFeRD XML for invoice " . $invoice->ref, LOG_INFO);
// Generate ZUGFeRD XML and optionally embed into PDF
return $this->generateZugferdXML($invoice, $pdfFile);
}
/**
* Hook executed after ODT creation
*
* @param array $parameters Hook parameters
* @param object $object Object
* @param string $action Action
* @return int 0=OK, <0=Error
*/
public function afterODTCreation($parameters, &$object, &$action)
{
global $conf;
dol_syslog("ExportZugferd Hook: afterODTCreation called", LOG_INFO);
// Get the invoice from parameters
$invoice = isset($parameters['object']) ? $parameters['object'] : null;
// Load Facture class if needed
if (!class_exists('Facture')) {
require_once DOL_DOCUMENT_ROOT . '/compta/facture/class/facture.class.php';
}
// Check if it's a customer invoice
if (!is_object($invoice) || !($invoice instanceof Facture)) {
dol_syslog("ExportZugferd Hook: afterODTCreation - Not a Facture object", LOG_DEBUG);
return 0;
}
// Check if auto-generation is enabled
if (!getDolGlobalInt('EXPORTZUGFERD_AUTO_GENERATE')) {
dol_syslog("ExportZugferd Hook: afterODTCreation - Auto-generation disabled", LOG_DEBUG);
return 0;
}
// Get the file path from parameters (ODT or PDF depending on MAIN_ODT_AS_PDF setting)
$file = isset($parameters['file']) ? $parameters['file'] : '';
dol_syslog("ExportZugferd Hook: afterODTCreation - file parameter = " . $file, LOG_INFO);
// Find PDF file - either the passed file is already PDF, or we need to find it in the directory
$pdfFile = '';
if (!empty($file) && pathinfo($file, PATHINFO_EXTENSION) === 'pdf') {
$pdfFile = $file;
} else {
// Search for PDF in invoice directory
$dir = $conf->facture->dir_output . '/' . dol_sanitizeFileName($invoice->ref);
if (is_dir($dir)) {
$files = glob($dir . '/*.pdf');
if (!empty($files)) {
// Get the most recently modified PDF
usort($files, function($a, $b) {
return filemtime($b) - filemtime($a);
});
$pdfFile = $files[0];
}
}
}
dol_syslog("ExportZugferd Hook: afterODTCreation - PDF file = " . $pdfFile, LOG_INFO);
// Generate ZUGFeRD XML and embed into PDF
return $this->generateZugferdXML($invoice, $pdfFile);
}
/**
* Generate ZUGFeRD XML for an invoice and optionally embed it into PDF
*
* @param Facture $invoice Invoice object
* @param string $pdfFile Optional path to PDF file for embedding
* @return int 0=OK, <0=Error
*/
private function generateZugferdXML($invoice, $pdfFile = '')
{
global $conf;
require_once __DIR__ . '/zugferdgenerator.class.php';
$generator = new ZugferdGenerator($this->db);
// Generate XML
$xml = $generator->generateFromInvoice($invoice);
if ($xml === false) {
$this->error = $generator->error;
$this->errors = $generator->errors;
dol_syslog("ExportZugferd: Error generating XML: " . $this->error, LOG_ERR);
return -1;
}
// Save XML file
$filepath = $generator->saveXML($invoice, $xml);
if ($filepath === false) {
$this->error = $generator->error;
dol_syslog("ExportZugferd: Error saving XML: " . $this->error, LOG_ERR);
return -1;
}
dol_syslog("ExportZugferd: XML generated successfully: " . $filepath, LOG_INFO);
// Embed XML into PDF if enabled and PDF file exists
$embedEnabled = getDolGlobalInt('EXPORTZUGFERD_EMBED_IN_PDF');
dol_syslog("ExportZugferd: EXPORTZUGFERD_EMBED_IN_PDF = " . $embedEnabled . ", pdfFile = " . $pdfFile, LOG_INFO);
if ($embedEnabled && !empty($pdfFile) && file_exists($pdfFile)) {
dol_syslog("ExportZugferd: Starting PDF embedding...", LOG_INFO);
$result = $this->embedXmlInPdf($pdfFile, $filepath);
if ($result < 0) {
dol_syslog("ExportZugferd: Warning - Could not embed XML in PDF: " . $this->error, LOG_WARNING);
// Don't return error, XML file was created successfully
} else {
dol_syslog("ExportZugferd: XML successfully embedded in PDF", LOG_INFO);
// Delete XML file after embedding if option is enabled
if (getDolGlobalInt('EXPORTZUGFERD_DELETE_XML_AFTER_EMBED')) {
@unlink($filepath);
dol_syslog("ExportZugferd: XML file deleted after embedding: " . $filepath, LOG_INFO);
}
}
} elseif ($embedEnabled && empty($pdfFile)) {
dol_syslog("ExportZugferd: Embedding enabled but no PDF file path provided", LOG_WARNING);
} elseif ($embedEnabled && !file_exists($pdfFile)) {
dol_syslog("ExportZugferd: Embedding enabled but PDF file does not exist: " . $pdfFile, LOG_WARNING);
}
return 0;
}
/**
* Embed XML file into PDF
*
* @param string $pdfFile Path to PDF file
* @param string $xmlFile Path to XML file
* @return int 0=OK, <0=Error
*/
private function embedXmlInPdf($pdfFile, $xmlFile)
{
require_once __DIR__ . '/pdfembedder.class.php';
$embedder = new ZugferdPdfEmbedder();
$profile = getDolGlobalString('EXPORTZUGFERD_PROFILE', 'EN16931');
if (!$embedder->embedXmlInPdf($pdfFile, $xmlFile, $profile)) {
$this->error = $embedder->error;
$this->errors = $embedder->errors;
return -1;
}
dol_syslog("ExportZugferd: XML embedded into PDF successfully: " . $pdfFile, LOG_INFO);
return 0;
}
/**
* Hook to add buttons/actions on invoice card
* Now disabled - buttons are shown in document area via formObjectOptions
*
* @param array $parameters Hook parameters
* @param object $object Invoice object
* @param string $action Action
* @return int 0=OK
*/
public function addMoreActionsButtons($parameters, &$object, &$action)
{
// Buttons are now shown in the document area (formObjectOptions) to reduce clutter
return 0;
}
/**
* Hook to add ZUGFeRD info and mini buttons in invoice info area
*
* @param array $parameters Hook parameters
* @param object $object Object
* @param string $action Action
* @return int 0=OK
*/
public function formObjectOptions($parameters, &$object, &$action)
{
global $conf, $langs, $user;
// Only for invoice card context
$contexts = explode(':', $parameters['context']);
if (!in_array('invoicecard', $contexts)) {
return 0;
}
// Check if it's a Facture
if (!($object instanceof Facture) || empty($object->ref)) {
return 0;
}
// Check permissions
$hasRight = $user->hasRight('exportzugferd', 'read') || $user->hasRight('exportzugferd', 'export') || $user->hasRight('facture', 'lire');
if (!$hasRight) {
return 0;
}
$langs->load('exportzugferd@exportzugferd');
$dir = $conf->facture->dir_output . '/' . dol_sanitizeFileName($object->ref);
$zugferdFile = $dir . '/factur-x.xml';
$xrechnungFile = $dir . '/xrechnung-' . $object->ref . '.xml';
$xmlFile = null;
$xmlExists = false;
if (file_exists($zugferdFile)) {
$xmlFile = $zugferdFile;
$xmlExists = true;
} elseif (file_exists($xrechnungFile)) {
$xmlFile = $xrechnungFile;
$xmlExists = true;
}
// Build the ZUGFeRD row with mini action buttons
print '<tr class="oddeven">';
print '<td class="titlefield">ZUGFeRD/XRechnung</td>';
print '<td>';
if ($xmlExists) {
// XML exists - show file info and action buttons
print '<span class="fa fa-file-code paddingright" style="color: #28a745;"></span>';
print '<a href="' . dol_buildpath('/exportzugferd/download.php', 1) . '?id=' . $object->id . '&action=download_xml" title="' . $langs->trans('DownloadZugferdXML') . '">';
print basename($xmlFile);
print '</a>';
print ' <span class="opacitymedium">(' . dol_print_size(filesize($xmlFile)) . ')</span>';
// Mini buttons
print ' &nbsp; ';
print '<a class="paddingleft paddingright" href="' . dol_buildpath('/exportzugferd/preview.php', 1) . '?id=' . $object->id . '" title="' . $langs->trans('PreviewZugferdXML') . '">';
print '<span class="fas fa-eye" style="color: #007bff;"></span>';
print '</a>';
print '<a class="paddingleft paddingright" href="' . dol_buildpath('/exportzugferd/download.php', 1) . '?id=' . $object->id . '&action=download_xml" title="' . $langs->trans('DownloadZugferdXML') . '">';
print '<span class="fas fa-download" style="color: #17a2b8;"></span>';
print '</a>';
print '<a class="paddingleft paddingright" href="' . dol_buildpath('/exportzugferd/download.php', 1) . '?id=' . $object->id . '&action=generate_xml" title="' . $langs->trans('RegenerateZugferdXML') . '">';
print '<span class="fas fa-sync-alt" style="color: #ffc107;"></span>';
print '</a>';
} else {
// XML does not exist - show generate button
print '<span class="fa fa-file-code paddingright opacitymedium"></span>';
print '<span class="opacitymedium">' . $langs->trans('NoZugferdXML') . '</span>';
// Generate button
print ' &nbsp; ';
print '<a class="paddingleft paddingright" href="' . dol_buildpath('/exportzugferd/download.php', 1) . '?id=' . $object->id . '&action=generate_xml" title="' . $langs->trans('GenerateZugferdXML') . '">';
print '<span class="fas fa-plus-circle" style="color: #28a745;"></span> ';
print '<span style="font-size: 0.9em;">' . $langs->trans('GenerateZugferdXML') . '</span>';
print '</a>';
}
print '</td>';
print '</tr>';
return 0;
}
/**
* Hook to add ZUGFeRD XML to email attachments (getFormMail hook)
*
* @param array $parameters Hook parameters
* @param object $object FormMail object
* @param string $action Action
* @return int 0=OK
*/
public function getFormMail($parameters, &$object, &$action)
{
global $conf;
// Check if auto-attach is enabled
if (!getDolGlobalInt('EXPORTZUGFERD_ATTACH_TO_EMAIL')) {
return 0;
}
// Check if this is for an invoice (facture trackid)
$trackid = isset($parameters['trackid']) ? $parameters['trackid'] : '';
if (strpos($trackid, 'inv') !== 0) {
return 0;
}
// Get invoice ID from parameters
$invoiceId = isset($object->param['id']) ? $object->param['id'] : 0;
if (empty($invoiceId)) {
return 0;
}
// Load invoice to get ref
require_once DOL_DOCUMENT_ROOT . '/compta/facture/class/facture.class.php';
$invoice = new Facture($this->db);
if ($invoice->fetch($invoiceId) <= 0) {
return 0;
}
// Find ZUGFeRD XML file
$xmlFile = $this->getZugferdXmlPath($invoice);
if ($xmlFile && file_exists($xmlFile)) {
// Add XML file to fileinit array
if (!isset($object->param['fileinit'])) {
$object->param['fileinit'] = array();
}
if (!is_array($object->param['fileinit'])) {
$object->param['fileinit'] = array($object->param['fileinit']);
}
// Only add if not already in the list
if (!in_array($xmlFile, $object->param['fileinit'])) {
$object->param['fileinit'][] = $xmlFile;
dol_syslog("ExportZugferd: Added ZUGFeRD XML to email attachments: " . $xmlFile, LOG_DEBUG);
}
}
return 0;
}
/**
* Get the path to the ZUGFeRD XML file for an invoice
*
* @param Facture $invoice Invoice object
* @return string|null Path to XML file or null if not found
*/
private function getZugferdXmlPath($invoice)
{
global $conf;
if (empty($invoice->ref)) {
return null;
}
$dir = $conf->facture->dir_output . '/' . dol_sanitizeFileName($invoice->ref);
$zugferdFile = $dir . '/factur-x.xml';
$xrechnungFile = $dir . '/xrechnung-' . $invoice->ref . '.xml';
if (file_exists($zugferdFile)) {
return $zugferdFile;
} elseif (file_exists($xrechnungFile)) {
return $xrechnungFile;
}
return null;
}
}

327
class/pdfembedder.class.php Normal file
View file

@ -0,0 +1,327 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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 <https://www.gnu.org/licenses/>.
*/
/**
* \file class/pdfembedder.class.php
* \ingroup exportzugferd
* \brief Embeds ZUGFeRD/Factur-X XML into PDF
*/
/**
* Class ZugferdPdfEmbedder
* Embeds ZUGFeRD XML into existing PDF files to create PDF/A-3 compliant ZUGFeRD PDFs
*/
class ZugferdPdfEmbedder
{
/**
* @var string Last error message
*/
public $error = '';
/**
* @var string[] Error messages
*/
public $errors = array();
/**
* Embed XML file into existing PDF
*
* @param string $pdfFile Path to the PDF file
* @param string $xmlFile Path to the XML file to embed
* @param string $profile ZUGFeRD profile (MINIMUM, BASIC, EN16931, XRECHNUNG, EXTENDED)
* @return bool True on success, false on error
*/
public function embedXmlInPdf($pdfFile, $xmlFile, $profile = 'EN16931')
{
global $conf;
dol_syslog("ZugferdPdfEmbedder::embedXmlInPdf called - PDF: $pdfFile, XML: $xmlFile", LOG_INFO);
if (!file_exists($pdfFile)) {
$this->error = 'PDF file not found: ' . $pdfFile;
dol_syslog("ZugferdPdfEmbedder: " . $this->error, LOG_ERR);
return false;
}
if (!file_exists($xmlFile)) {
$this->error = 'XML file not found: ' . $xmlFile;
dol_syslog("ZugferdPdfEmbedder: " . $this->error, LOG_ERR);
return false;
}
// Load TCPDF first (required by TCPDI)
if (!class_exists('TCPDF')) {
$tcpdfPath = DOL_DOCUMENT_ROOT . '/includes/tecnickcom/tcpdf/tcpdf.php';
if (file_exists($tcpdfPath)) {
require_once $tcpdfPath;
} else {
$this->error = 'TCPDF library not found';
dol_syslog("ZugferdPdfEmbedder: " . $this->error, LOG_ERR);
return false;
}
}
// Check if TCPDI is available
if (!class_exists('TCPDI')) {
$tcpdiPath = DOL_DOCUMENT_ROOT . '/includes/tcpdi/tcpdi.php';
if (file_exists($tcpdiPath)) {
require_once $tcpdiPath;
} else {
$this->error = 'TCPDI library not found';
dol_syslog("ZugferdPdfEmbedder: " . $this->error, LOG_ERR);
return false;
}
}
try {
// Read XML content
$xmlContent = file_get_contents($xmlFile);
if ($xmlContent === false) {
$this->error = 'Could not read XML file';
dol_syslog("ZugferdPdfEmbedder: " . $this->error, LOG_ERR);
return false;
}
dol_syslog("ZugferdPdfEmbedder: Creating TCPDI object", LOG_DEBUG);
// Create new PDF with embedded XML
$pdf = new TCPDI('P', 'mm', 'A4', true, 'UTF-8');
dol_syslog("ZugferdPdfEmbedder: TCPDI object created", LOG_DEBUG);
// Disable header and footer
$pdf->setPrintHeader(false);
$pdf->setPrintFooter(false);
// Set PDF/A-3 mode for ZUGFeRD compliance (only if method exists - TCPDI may not have it)
if (method_exists($pdf, 'setPDFA')) {
$pdf->setPDFA(true);
}
// Set document information
$pdf->SetCreator('Dolibarr ExportZugferd');
$pdf->SetAuthor('Dolibarr ERP/CRM');
$pdf->SetTitle('ZUGFeRD Invoice');
$pdf->SetSubject('Electronic Invoice with embedded ZUGFeRD XML');
dol_syslog("ZugferdPdfEmbedder: Setting source file: $pdfFile", LOG_DEBUG);
// Import existing PDF pages
$pageCount = $pdf->setSourceFile($pdfFile);
dol_syslog("ZugferdPdfEmbedder: Importing $pageCount pages", LOG_DEBUG);
for ($pageNo = 1; $pageNo <= $pageCount; $pageNo++) {
dol_syslog("ZugferdPdfEmbedder: Importing page $pageNo", LOG_DEBUG);
$templateId = $pdf->importPage($pageNo);
$size = $pdf->getTemplateSize($templateId);
// TCPDI returns 'w' and 'h' keys, not 'width' and 'height'
$width = isset($size['w']) ? $size['w'] : (isset($size['width']) ? $size['width'] : 210);
$height = isset($size['h']) ? $size['h'] : (isset($size['height']) ? $size['height'] : 297);
$orientation = isset($size['orientation']) ? $size['orientation'] : ($width > $height ? 'L' : 'P');
dol_syslog("ZugferdPdfEmbedder: Page $pageNo size: {$width}x{$height}, orientation: $orientation", LOG_DEBUG);
// Add page with same size as original
$pdf->AddPage($orientation, array($width, $height));
$pdf->useTemplate($templateId);
}
dol_syslog("ZugferdPdfEmbedder: All pages imported", LOG_DEBUG);
// Determine the attachment filename based on profile
$attachmentName = $this->getAttachmentFilename($profile);
dol_syslog("ZugferdPdfEmbedder: Adding embedded file for $attachmentName", LOG_DEBUG);
// Embed XML using the correct ZUGFeRD method: only in EmbeddedFiles name tree
// NOT as a FileAttachment annotation (which causes double listing)
$this->addEmbeddedFile($pdf, $xmlFile, $attachmentName);
dol_syslog("ZugferdPdfEmbedder: Embedded file added", LOG_DEBUG);
// Create temporary file for output
$tempFile = $pdfFile . '.tmp';
// Output the modified PDF
$pdf->Output($tempFile, 'F');
dol_syslog("ZugferdPdfEmbedder: Temp file created: $tempFile", LOG_DEBUG);
// Replace original with modified version
if (file_exists($tempFile)) {
if (filesize($tempFile) > 0) {
// Replace original
if (!rename($tempFile, $pdfFile)) {
$this->error = 'Could not replace original PDF file';
@unlink($tempFile);
dol_syslog("ZugferdPdfEmbedder: " . $this->error, LOG_ERR);
return false;
}
dol_syslog("ZugferdPdfEmbedder: Successfully embedded XML into PDF: " . $pdfFile, LOG_INFO);
return true;
} else {
$this->error = 'Generated PDF is empty';
@unlink($tempFile);
dol_syslog("ZugferdPdfEmbedder: " . $this->error, LOG_ERR);
return false;
}
} else {
$this->error = 'Failed to create temporary PDF file';
dol_syslog("ZugferdPdfEmbedder: " . $this->error, LOG_ERR);
return false;
}
} catch (Exception $e) {
$this->error = 'PDF embedding failed: ' . $e->getMessage();
dol_syslog("ZugferdPdfEmbedder error: " . $e->getMessage(), LOG_ERR);
return false;
}
}
/**
* Get the standard attachment filename for the profile
*
* @param string $profile ZUGFeRD profile
* @return string Filename
*/
private function getAttachmentFilename($profile)
{
switch (strtoupper($profile)) {
case 'XRECHNUNG':
return 'xrechnung.xml';
case 'MINIMUM':
case 'BASIC':
case 'BASIC WL':
case 'EN16931':
case 'EXTENDED':
default:
return 'factur-x.xml';
}
}
/**
* Embed XML content directly into PDF (alternative method)
*
* @param string $pdfFile Path to the PDF file
* @param string $xmlContent XML content to embed
* @param string $xmlFilename Filename for the embedded XML (e.g., factur-x.xml)
* @param string $profile ZUGFeRD profile
* @return bool True on success, false on error
*/
public function embedXmlContentInPdf($pdfFile, $xmlContent, $xmlFilename = 'factur-x.xml', $profile = 'EN16931')
{
// Create temporary XML file
$tempXmlFile = sys_get_temp_dir() . '/' . $xmlFilename;
if (file_put_contents($tempXmlFile, $xmlContent) === false) {
$this->error = 'Could not create temporary XML file';
return false;
}
$result = $this->embedXmlInPdf($pdfFile, $tempXmlFile, $profile);
// Clean up temporary file
@unlink($tempXmlFile);
return $result;
}
/**
* Check if embedding is possible
*
* @return bool True if libraries are available
*/
public static function isEmbeddingAvailable()
{
// Check for TCPDF
$tcpdfAvailable = class_exists('TCPDF') || file_exists(DOL_DOCUMENT_ROOT . '/includes/tecnickcom/tcpdf/tcpdf.php');
// Check for TCPDI
$tcpdiAvailable = class_exists('TCPDI') || file_exists(DOL_DOCUMENT_ROOT . '/includes/tcpdi/tcpdi.php');
return $tcpdfAvailable && $tcpdiAvailable;
}
/**
* Get ZUGFeRD conformance level string for XMP metadata
*
* @param string $profile Profile name
* @return string Conformance level
*/
private function getConformanceLevel($profile)
{
switch (strtoupper($profile)) {
case 'MINIMUM':
return 'MINIMUM';
case 'BASIC WL':
case 'BASICWL':
return 'BASIC WL';
case 'BASIC':
return 'BASIC';
case 'EN16931':
return 'EN 16931';
case 'EXTENDED':
return 'EXTENDED';
case 'XRECHNUNG':
return 'EN 16931';
default:
return 'EN 16931';
}
}
/**
* Add embedded file to PDF without creating a FileAttachment annotation
* This ensures the file is only listed once in the EmbeddedFiles name tree
*
* @param TCPDI $pdf PDF object
* @param string $filepath Path to the file to embed
* @param string $filename Name for the embedded file
* @return void
*/
private function addEmbeddedFile($pdf, $filepath, $filename)
{
// Access TCPDF's internal embeddedfiles array directly
// This avoids creating a FileAttachment annotation which causes double listing
$reflection = new ReflectionClass($pdf);
// Get the 'n' property (object counter)
$nProp = $reflection->getProperty('n');
$nProp->setAccessible(true);
$n = $nProp->getValue($pdf);
// Get the embeddedfiles array
$efProp = $reflection->getProperty('embeddedfiles');
$efProp->setAccessible(true);
$embeddedfiles = $efProp->getValue($pdf);
// Add the file to the embeddedfiles array
$embeddedfiles[$filename] = array(
'f' => ++$n,
'n' => ++$n,
'file' => $filepath
);
// Update the object counter
$nProp->setValue($pdf, $n);
// Update the embeddedfiles array
$efProp->setValue($pdf, $embeddedfiles);
dol_syslog("ZugferdPdfEmbedder: Added $filename to embeddedfiles array", LOG_DEBUG);
}
}

745
class/zugferdgenerator.class.php Executable file
View file

@ -0,0 +1,745 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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 <https://www.gnu.org/licenses/>.
*/
/**
* \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 = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
$xml .= '<rsm:CrossIndustryInvoice';
foreach ($this->namespaces as $prefix => $uri) {
$xml .= ' xmlns:' . $prefix . '="' . $uri . '"';
}
$xml .= '>' . "\n";
return $xml;
}
/**
* Create XML footer
*
* @return string XML footer
*/
private function createXMLFooter()
{
return '</rsm:CrossIndustryInvoice>' . "\n";
}
/**
* Create ExchangedDocumentContext element
*
* @return string XML content
*/
private function createExchangedDocumentContext()
{
$profileId = $this->getProfileURN();
$xml = ' <rsm:ExchangedDocumentContext>' . "\n";
$xml .= ' <ram:GuidelineSpecifiedDocumentContextParameter>' . "\n";
$xml .= ' <ram:ID>' . $this->xmlEncode($profileId) . '</ram:ID>' . "\n";
$xml .= ' </ram:GuidelineSpecifiedDocumentContextParameter>' . "\n";
$xml .= ' </rsm:ExchangedDocumentContext>' . "\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 = ' <rsm:ExchangedDocument>' . "\n";
$xml .= ' <ram:ID>' . $this->xmlEncode($invoice->ref) . '</ram:ID>' . "\n";
$xml .= ' <ram:TypeCode>' . $this->getInvoiceTypeCode($invoice) . '</ram:TypeCode>' . "\n";
$xml .= ' <ram:IssueDateTime>' . "\n";
$xml .= ' <udt:DateTimeString format="102">' . date('Ymd', $invoice->date) . '</udt:DateTimeString>' . "\n";
$xml .= ' </ram:IssueDateTime>' . "\n";
// Add notes if present
if (!empty($invoice->note_public)) {
$xml .= ' <ram:IncludedNote>' . "\n";
$xml .= ' <ram:Content>' . $this->xmlEncode($invoice->note_public) . '</ram:Content>' . "\n";
$xml .= ' </ram:IncludedNote>' . "\n";
}
$xml .= ' </rsm:ExchangedDocument>' . "\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 = ' <rsm:SupplyChainTradeTransaction>' . "\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 .= ' </rsm:SupplyChainTradeTransaction>' . "\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 = ' <ram:IncludedSupplyChainTradeLineItem>' . "\n";
// Line document
$xml .= ' <ram:AssociatedDocumentLineDocument>' . "\n";
$xml .= ' <ram:LineID>' . $lineNumber . '</ram:LineID>' . "\n";
$xml .= ' </ram:AssociatedDocumentLineDocument>' . "\n";
// Product info
$xml .= ' <ram:SpecifiedTradeProduct>' . "\n";
if (!empty($line->product_ref)) {
$xml .= ' <ram:SellerAssignedID>' . $this->xmlEncode($line->product_ref) . '</ram:SellerAssignedID>' . "\n";
}
$xml .= ' <ram:Name>' . $this->xmlEncode($line->product_label ?: $line->desc) . '</ram:Name>' . "\n";
if (!empty($line->desc) && $line->desc != $line->product_label) {
$xml .= ' <ram:Description>' . $this->xmlEncode(strip_tags($line->desc)) . '</ram:Description>' . "\n";
}
$xml .= ' </ram:SpecifiedTradeProduct>' . "\n";
// Line agreement (price)
$xml .= ' <ram:SpecifiedLineTradeAgreement>' . "\n";
$xml .= ' <ram:NetPriceProductTradePrice>' . "\n";
$xml .= ' <ram:ChargeAmount>' . $this->formatAmount($line->subprice) . '</ram:ChargeAmount>' . "\n";
$xml .= ' </ram:NetPriceProductTradePrice>' . "\n";
$xml .= ' </ram:SpecifiedLineTradeAgreement>' . "\n";
// Line delivery (quantity)
$xml .= ' <ram:SpecifiedLineTradeDelivery>' . "\n";
$xml .= ' <ram:BilledQuantity unitCode="' . $this->getUnitCode($line) . '">' . $this->formatQuantity($line->qty) . '</ram:BilledQuantity>' . "\n";
$xml .= ' </ram:SpecifiedLineTradeDelivery>' . "\n";
// Line settlement (tax, total)
$xml .= ' <ram:SpecifiedLineTradeSettlement>' . "\n";
$xml .= ' <ram:ApplicableTradeTax>' . "\n";
$xml .= ' <ram:TypeCode>VAT</ram:TypeCode>' . "\n";
$xml .= ' <ram:CategoryCode>' . $this->getVATCategoryCode($line->tva_tx) . '</ram:CategoryCode>' . "\n";
$xml .= ' <ram:RateApplicablePercent>' . $this->formatAmount($line->tva_tx) . '</ram:RateApplicablePercent>' . "\n";
$xml .= ' </ram:ApplicableTradeTax>' . "\n";
$xml .= ' <ram:SpecifiedTradeSettlementLineMonetarySummation>' . "\n";
$xml .= ' <ram:LineTotalAmount>' . $this->formatAmount($line->total_ht) . '</ram:LineTotalAmount>' . "\n";
$xml .= ' </ram:SpecifiedTradeSettlementLineMonetarySummation>' . "\n";
$xml .= ' </ram:SpecifiedLineTradeSettlement>' . "\n";
$xml .= ' </ram:IncludedSupplyChainTradeLineItem>' . "\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 = ' <ram:ApplicableHeaderTradeAgreement>' . "\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 .= ' <ram:BuyerReference>' . $this->xmlEncode($leitwegId) . '</ram:BuyerReference>' . "\n";
}
// Seller
$xml .= ' <ram:SellerTradeParty>' . "\n";
$xml .= ' <ram:Name>' . $this->xmlEncode($mysoc->name) . '</ram:Name>' . "\n";
// Seller address
$xml .= ' <ram:PostalTradeAddress>' . "\n";
$xml .= ' <ram:PostcodeCode>' . $this->xmlEncode($mysoc->zip) . '</ram:PostcodeCode>' . "\n";
$xml .= ' <ram:LineOne>' . $this->xmlEncode($mysoc->address) . '</ram:LineOne>' . "\n";
$xml .= ' <ram:CityName>' . $this->xmlEncode($mysoc->town) . '</ram:CityName>' . "\n";
$xml .= ' <ram:CountryID>' . $this->xmlEncode($mysoc->country_code) . '</ram:CountryID>' . "\n";
$xml .= ' </ram:PostalTradeAddress>' . "\n";
// Seller tax registration
if (!empty($mysoc->tva_intra)) {
$xml .= ' <ram:SpecifiedTaxRegistration>' . "\n";
$xml .= ' <ram:ID schemeID="VA">' . $this->xmlEncode($mysoc->tva_intra) . '</ram:ID>' . "\n";
$xml .= ' </ram:SpecifiedTaxRegistration>' . "\n";
}
$xml .= ' </ram:SellerTradeParty>' . "\n";
// Buyer
$xml .= ' <ram:BuyerTradeParty>' . "\n";
$xml .= ' <ram:Name>' . $this->xmlEncode($buyer->name) . '</ram:Name>' . "\n";
// Buyer address
$xml .= ' <ram:PostalTradeAddress>' . "\n";
$xml .= ' <ram:PostcodeCode>' . $this->xmlEncode($buyer->zip) . '</ram:PostcodeCode>' . "\n";
$xml .= ' <ram:LineOne>' . $this->xmlEncode($buyer->address) . '</ram:LineOne>' . "\n";
$xml .= ' <ram:CityName>' . $this->xmlEncode($buyer->town) . '</ram:CityName>' . "\n";
$xml .= ' <ram:CountryID>' . $this->xmlEncode($buyer->country_code) . '</ram:CountryID>' . "\n";
$xml .= ' </ram:PostalTradeAddress>' . "\n";
// Buyer tax registration
if (!empty($buyer->tva_intra)) {
$xml .= ' <ram:SpecifiedTaxRegistration>' . "\n";
$xml .= ' <ram:ID schemeID="VA">' . $this->xmlEncode($buyer->tva_intra) . '</ram:ID>' . "\n";
$xml .= ' </ram:SpecifiedTaxRegistration>' . "\n";
}
$xml .= ' </ram:BuyerTradeParty>' . "\n";
// Buyer order reference
if (!empty($invoice->ref_client)) {
$xml .= ' <ram:BuyerOrderReferencedDocument>' . "\n";
$xml .= ' <ram:IssuerAssignedID>' . $this->xmlEncode($invoice->ref_client) . '</ram:IssuerAssignedID>' . "\n";
$xml .= ' </ram:BuyerOrderReferencedDocument>' . "\n";
}
$xml .= ' </ram:ApplicableHeaderTradeAgreement>' . "\n";
return $xml;
}
/**
* Create ApplicableHeaderTradeDelivery element
*
* @param Facture $invoice Invoice object
* @return string XML content
*/
private function createApplicableHeaderTradeDelivery($invoice)
{
$xml = ' <ram:ApplicableHeaderTradeDelivery>' . "\n";
// Ship to party (same as buyer for now)
$buyer = $invoice->thirdparty;
$xml .= ' <ram:ShipToTradeParty>' . "\n";
$xml .= ' <ram:Name>' . $this->xmlEncode($buyer->name) . '</ram:Name>' . "\n";
$xml .= ' <ram:PostalTradeAddress>' . "\n";
$xml .= ' <ram:PostcodeCode>' . $this->xmlEncode($buyer->zip) . '</ram:PostcodeCode>' . "\n";
$xml .= ' <ram:LineOne>' . $this->xmlEncode($buyer->address) . '</ram:LineOne>' . "\n";
$xml .= ' <ram:CityName>' . $this->xmlEncode($buyer->town) . '</ram:CityName>' . "\n";
$xml .= ' <ram:CountryID>' . $this->xmlEncode($buyer->country_code) . '</ram:CountryID>' . "\n";
$xml .= ' </ram:PostalTradeAddress>' . "\n";
$xml .= ' </ram:ShipToTradeParty>' . "\n";
$xml .= ' </ram:ApplicableHeaderTradeDelivery>' . "\n";
return $xml;
}
/**
* Create ApplicableHeaderTradeSettlement element (payment, totals)
*
* @param Facture $invoice Invoice object
* @return string XML content
*/
private function createApplicableHeaderTradeSettlement($invoice)
{
global $conf;
$xml = ' <ram:ApplicableHeaderTradeSettlement>' . "\n";
// Currency
$xml .= ' <ram:InvoiceCurrencyCode>' . ($invoice->multicurrency_code ?: $conf->currency) . '</ram:InvoiceCurrencyCode>' . "\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 .= ' <ram:SpecifiedTradeSettlementPaymentMeans>' . "\n";
$xml .= ' <ram:TypeCode>' . $this->getPaymentMeansCode($invoice) . '</ram:TypeCode>' . "\n";
// Bank account for SEPA transfer
if (!empty($iban)) {
$xml .= ' <ram:PayeePartyCreditorFinancialAccount>' . "\n";
$xml .= ' <ram:IBANID>' . $this->xmlEncode($iban) . '</ram:IBANID>' . "\n";
$xml .= ' </ram:PayeePartyCreditorFinancialAccount>' . "\n";
// BIC if available
if (!empty($bic)) {
$xml .= ' <ram:PayeeSpecifiedCreditorFinancialInstitution>' . "\n";
$xml .= ' <ram:BICID>' . $this->xmlEncode($bic) . '</ram:BICID>' . "\n";
if (!empty($bankName)) {
$xml .= ' <ram:Name>' . $this->xmlEncode($bankName) . '</ram:Name>' . "\n";
}
$xml .= ' </ram:PayeeSpecifiedCreditorFinancialInstitution>' . "\n";
}
}
$xml .= ' </ram:SpecifiedTradeSettlementPaymentMeans>' . "\n";
// Tax summary
$xml .= $this->createTaxSummary($invoice);
// Payment terms
if (!empty($invoice->cond_reglement_id)) {
$xml .= ' <ram:SpecifiedTradePaymentTerms>' . "\n";
if ($invoice->date_lim_reglement) {
$xml .= ' <ram:DueDateDateTime>' . "\n";
$xml .= ' <udt:DateTimeString format="102">' . date('Ymd', $invoice->date_lim_reglement) . '</udt:DateTimeString>' . "\n";
$xml .= ' </ram:DueDateDateTime>' . "\n";
}
$xml .= ' </ram:SpecifiedTradePaymentTerms>' . "\n";
}
// Monetary summation
$xml .= ' <ram:SpecifiedTradeSettlementHeaderMonetarySummation>' . "\n";
$xml .= ' <ram:LineTotalAmount>' . $this->formatAmount($invoice->total_ht) . '</ram:LineTotalAmount>' . "\n";
$xml .= ' <ram:TaxBasisTotalAmount>' . $this->formatAmount($invoice->total_ht) . '</ram:TaxBasisTotalAmount>' . "\n";
$xml .= ' <ram:TaxTotalAmount currencyID="' . ($invoice->multicurrency_code ?: $conf->currency) . '">' . $this->formatAmount($invoice->total_tva) . '</ram:TaxTotalAmount>' . "\n";
$xml .= ' <ram:GrandTotalAmount>' . $this->formatAmount($invoice->total_ttc) . '</ram:GrandTotalAmount>' . "\n";
$xml .= ' <ram:DuePayableAmount>' . $this->formatAmount($invoice->total_ttc - $invoice->getSommePaiement()) . '</ram:DuePayableAmount>' . "\n";
$xml .= ' </ram:SpecifiedTradeSettlementHeaderMonetarySummation>' . "\n";
$xml .= ' </ram:ApplicableHeaderTradeSettlement>' . "\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 .= ' <ram:ApplicableTradeTax>' . "\n";
$xml .= ' <ram:CalculatedAmount>' . $this->formatAmount($data['amount']) . '</ram:CalculatedAmount>' . "\n";
$xml .= ' <ram:TypeCode>VAT</ram:TypeCode>' . "\n";
$xml .= ' <ram:BasisAmount>' . $this->formatAmount($data['base']) . '</ram:BasisAmount>' . "\n";
$xml .= ' <ram:CategoryCode>' . $this->getVATCategoryCode($rate) . '</ram:CategoryCode>' . "\n";
$xml .= ' <ram:RateApplicablePercent>' . $this->formatAmount($rate) . '</ram:RateApplicablePercent>' . "\n";
$xml .= ' </ram:ApplicableTradeTax>' . "\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;
}
}

View file

@ -0,0 +1,226 @@
<?php
/* Copyright (C) 2004-2018 Laurent Destailleur <eldy@users.sourceforge.net>
* Copyright (C) 2018-2019 Nicolas ZABOURI <info@inovea-conseil.com>
* Copyright (C) 2019-2024 Frédéric France <frederic.france@free.fr>
* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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 <https://www.gnu.org/licenses/>.
*/
/**
* \defgroup exportzugferd Module ExportZugferd
* \brief ExportZugferd module descriptor.
*
* \file htdocs/exportzugferd/core/modules/modExportZugferd.class.php
* \ingroup exportzugferd
* \brief Description and activation file for module ExportZugferd
*/
include_once DOL_DOCUMENT_ROOT.'/core/modules/DolibarrModules.class.php';
/**
* Description and activation class for module ExportZugferd
*/
class modExportZugferd extends DolibarrModules
{
/**
* Constructor. Define names, constants, directories, boxes, permissions
*
* @param DoliDB $db Database handler
*/
public function __construct($db)
{
global $conf, $langs;
$this->db = $db;
// Id for module (must be unique).
$this->numero = 500017;
// Key text used to identify module (for permissions, menus, etc...)
$this->rights_class = 'exportzugferd';
// Family can be 'base' (core modules),'crm','financial','hr','projects','products','ecm','technic' (transverse modules),'interface' (link with external tools),'other','...'
$this->family = "financial";
// Module position in the family on 2 digits ('01', '10', '20', ...)
$this->module_position = '91';
// Module label (no space allowed)
$this->name = preg_replace('/^mod/i', '', get_class($this));
// Module description
$this->description = "ExportZugferdDescription";
$this->descriptionlong = "ExportZugferdDescriptionLong";
// Author
$this->editor_name = 'Data IT Solution';
$this->editor_url = '';
// Version
$this->version = '2.0';
// Key used in llx_const table to save module status
$this->const_name = 'MAIN_MODULE_'.strtoupper($this->name);
// Module icon
$this->picto = 'object_exportzugferd@exportzugferd';
// Define some features supported by module
$this->module_parts = array(
'triggers' => 0,
'login' => 0,
'substitutions' => 0,
'menus' => 0,
'tpl' => 0,
'barcode' => 0,
'models' => 0,
'printing' => 0,
'theme' => 0,
'css' => array(),
'js' => array(),
'hooks' => array(
'data' => array(
'pdfgeneration',
'odtgeneration',
'invoicecard',
'formmail',
),
'entity' => '0',
),
'moduleforexternal' => 0,
'websitetemplates' => 0,
'captcha' => 0
);
// Data directories to create when module is enabled
$this->dirs = array("/exportzugferd/temp");
// Config pages
$this->config_page_url = array("setup.php@exportzugferd");
// Dependencies
$this->hidden = getDolGlobalInt('MODULE_EXPORTZUGFERD_DISABLED');
$this->depends = array('modFacture');
$this->requiredby = array();
$this->conflictwith = array();
// Constants
$this->const = array(
1 => array('EXPORTZUGFERD_PROFILE', 'chaine', 'EN16931', 'Default ZUGFeRD profile', 0, 'current', 1),
2 => array('EXPORTZUGFERD_EMBED_PDF', 'chaine', '0', 'Embed XML in PDF (requires FPDI)', 0, 'current', 1),
);
// Boxes/Widgets
$this->boxes = array();
// Cronjobs
$this->cronjobs = array();
// Permissions provided by this module
$r = 0;
$this->rights[$r][0] = $this->numero + $r + 1;
$this->rights[$r][1] = 'Read ZUGFeRD exports';
$this->rights[$r][3] = 0;
$this->rights[$r][4] = 'read';
$this->rights[$r][5] = '';
$r++;
$this->rights[$r][0] = $this->numero + $r + 1;
$this->rights[$r][1] = 'Create/Download ZUGFeRD exports';
$this->rights[$r][3] = 0;
$this->rights[$r][4] = 'export';
$this->rights[$r][5] = '';
$r++;
// Main menu entries to add
$this->menu = array();
$r = 0;
// Add top menu entry
$this->menu[$r++] = array(
'fk_menu' => 'fk_mainmenu=billing',
'type' => 'left',
'titre' => 'ExportZugferd',
'prefix' => img_picto('', $this->picto, 'class="paddingright pictofixedwidth valignmiddle"'),
'mainmenu' => 'billing',
'leftmenu' => 'exportzugferd',
'url' => '/exportzugferd/index.php',
'langs' => 'exportzugferd@exportzugferd',
'position' => 1000 + $r,
'enabled' => 'isModEnabled("exportzugferd")',
'perms' => '$user->hasRight("exportzugferd", "read")',
'target' => '',
'user' => 0,
);
}
/**
* Function called when module is enabled.
*
* @param string $options Options when enabling module ('', 'newboxdefonly', 'noboxes')
* @return int 1 if OK, 0 if KO
*/
public function init($options = '')
{
$result = $this->_load_tables('/install/mysql/', 'exportzugferd');
if ($result < 0) {
return -1;
}
// Create extrafield for Leitweg-ID on thirdparty (customer)
include_once DOL_DOCUMENT_ROOT.'/core/class/extrafields.class.php';
$extrafields = new ExtraFields($this->db);
// Add Leitweg-ID field for XRechnung (German public sector invoices)
// Only add if it doesn't exist
$result = $extrafields->addExtraField(
'leitweg_id', // attribute code
'Leitweg-ID (XRechnung)', // label
'varchar', // type
100, // position
'50', // size
'thirdparty', // element type
0, // unique
0, // required
'', // default value
'', // params
1, // always editable
'', // permission
1, // list (show in list)
'', // computed value
'', // entity
'', // lang file
'exportzugferd@exportzugferd', // module
'isModEnabled("exportzugferd")' // enabled condition
);
$sql = array();
return $this->_init($sql, $options);
}
/**
* Function called when module is disabled.
*
* @param string $options Options when disabling module
* @return int 1 if OK, 0 if KO
*/
public function remove($options = '')
{
$sql = array();
return $this->_remove($sql, $options);
}
}

152
download.php Executable file
View file

@ -0,0 +1,152 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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 <https://www.gnu.org/licenses/>.
*/
/**
* \file download.php
* \ingroup exportzugferd
* \brief Download ZUGFeRD XML file
*/
// Load Dolibarr environment
$res = 0;
if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) {
$res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php";
}
$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME'];
$tmp2 = realpath(__FILE__);
$i = strlen($tmp) - 1;
$j = strlen($tmp2) - 1;
while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) {
$i--;
$j--;
}
if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) {
$res = @include substr($tmp, 0, ($i + 1))."/main.inc.php";
}
if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) {
$res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php";
}
if (!$res && file_exists("../main.inc.php")) {
$res = @include "../main.inc.php";
}
if (!$res && file_exists("../../main.inc.php")) {
$res = @include "../../main.inc.php";
}
if (!$res) {
die("Include of main fails");
}
require_once DOL_DOCUMENT_ROOT . '/compta/facture/class/facture.class.php';
require_once __DIR__ . '/class/zugferdgenerator.class.php';
// Security check
$id = GETPOSTINT('id');
$action = GETPOST('action', 'aZ09');
if (!$user->hasRight('exportzugferd', 'export') && !$user->hasRight('facture', 'lire')) {
accessforbidden();
}
$langs->loadLangs(array('exportzugferd@exportzugferd', 'bills'));
// Load invoice
$invoice = new Facture($db);
$result = $invoice->fetch($id);
if ($result <= 0) {
setEventMessages($langs->trans('ErrorRecordNotFound'), null, 'errors');
header('Location: ' . DOL_URL_ROOT . '/compta/facture/list.php');
exit;
}
// Security check on third party
$socid = $invoice->socid;
if ($user->socid > 0 && $socid != $user->socid) {
accessforbidden();
}
// Handle actions
if ($action == 'download_xml') {
$generator = new ZugferdGenerator($db);
// Check if XML already exists
$dir = $conf->facture->dir_output . '/' . dol_sanitizeFileName($invoice->ref);
$zugferdFile = $dir . '/factur-x.xml';
$xrechnungFile = $dir . '/xrechnung-' . $invoice->ref . '.xml';
$xmlFile = null;
if (file_exists($xrechnungFile)) {
$xmlFile = $xrechnungFile;
} elseif (file_exists($zugferdFile)) {
$xmlFile = $zugferdFile;
}
// Generate if not exists
if (!$xmlFile) {
$xml = $generator->generateFromInvoice($invoice);
if ($xml === false) {
setEventMessages($generator->error, $generator->errors, 'errors');
header('Location: ' . DOL_URL_ROOT . '/compta/facture/card.php?id=' . $id);
exit;
}
$xmlFile = $generator->saveXML($invoice, $xml);
if ($xmlFile === false) {
setEventMessages($generator->error, null, 'errors');
header('Location: ' . DOL_URL_ROOT . '/compta/facture/card.php?id=' . $id);
exit;
}
}
// Download file
$filename = basename($xmlFile);
header('Content-Type: application/xml');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . filesize($xmlFile));
header('Cache-Control: private, max-age=0, must-revalidate');
header('Pragma: public');
readfile($xmlFile);
exit;
}
if ($action == 'generate_xml') {
$generator = new ZugferdGenerator($db);
$xml = $generator->generateFromInvoice($invoice);
if ($xml === false) {
setEventMessages($generator->error, $generator->errors, 'errors');
} else {
$xmlFile = $generator->saveXML($invoice, $xml);
if ($xmlFile === false) {
setEventMessages($generator->error, null, 'errors');
} else {
setEventMessages($langs->trans('ZugferdXMLGenerated'), null, 'mesgs');
}
}
header('Location: ' . DOL_URL_ROOT . '/compta/facture/card.php?id=' . $id);
exit;
}
// Default: redirect to invoice card
header('Location: ' . DOL_URL_ROOT . '/compta/facture/card.php?id=' . $id);
exit;

View file

@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4CAF50;stop-opacity:1" />
<stop offset="100%" style="stop-color:#2E7D32;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Document base -->
<path d="M6 2C4.9 2 4 2.9 4 4v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6H6z" fill="url(#grad1)"/>
<!-- Document fold -->
<path d="M14 2v6h6" fill="none" stroke="#1B5E20" stroke-width="1"/>
<path d="M14 2l6 6h-6V2z" fill="#81C784"/>
<!-- XML brackets -->
<text x="7" y="15" font-family="monospace" font-size="6" font-weight="bold" fill="white">&lt;/&gt;</text>
<!-- Export arrow -->
<path d="M16 14l3 3m0 0l-3 3m3-3H12" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 894 B

155
index.php Executable file
View file

@ -0,0 +1,155 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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 <https://www.gnu.org/licenses/>.
*/
/**
* \file index.php
* \ingroup exportzugferd
* \brief Home page for ExportZugferd module
*/
// Load Dolibarr environment
$res = 0;
if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) {
$res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php";
}
$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME'];
$tmp2 = realpath(__FILE__);
$i = strlen($tmp) - 1;
$j = strlen($tmp2) - 1;
while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) {
$i--;
$j--;
}
if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) {
$res = @include substr($tmp, 0, ($i + 1))."/main.inc.php";
}
if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) {
$res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php";
}
if (!$res && file_exists("../main.inc.php")) {
$res = @include "../main.inc.php";
}
if (!$res && file_exists("../../main.inc.php")) {
$res = @include "../../main.inc.php";
}
if (!$res) {
die("Include of main fails");
}
require_once DOL_DOCUMENT_ROOT . '/core/class/html.formfile.class.php';
require_once DOL_DOCUMENT_ROOT . '/compta/facture/class/facture.class.php';
// Security check
if (!$user->hasRight('exportzugferd', 'read')) {
accessforbidden();
}
$langs->loadLangs(array('exportzugferd@exportzugferd', 'bills'));
/*
* View
*/
$form = new Form($db);
llxHeader('', $langs->trans('ExportZugferd'), '', '', 0, 0, '', '', '', 'mod-exportzugferd page-index');
print load_fiche_titre($langs->trans('ExportZugferd'), '', 'fa-file-export');
print '<div class="fichecenter">';
print '<div class="fichethirdleft">';
// Info box
print '<div class="div-table-responsive-no-min">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<td colspan="2">' . $langs->trans('Information') . '</td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td class="titlefield">' . $langs->trans('ZugferdProfile') . '</td>';
print '<td>' . getDolGlobalString('EXPORTZUGFERD_PROFILE', 'EN16931') . '</td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td>' . $langs->trans('AutoGenerateZugferd') . '</td>';
print '<td>' . yn(getDolGlobalInt('EXPORTZUGFERD_AUTO_GENERATE')) . '</td>';
print '</tr>';
print '</table>';
print '</div>';
print '</div>';
print '<div class="fichetwothirdright">';
// Recent invoices with ZUGFeRD
print '<div class="div-table-responsive-no-min">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<td colspan="4">' . $langs->trans('RecentInvoicesWithZugferd') . '</td>';
print '</tr>';
// Get recent invoices
$sql = "SELECT f.rowid, f.ref, f.datef, f.total_ttc, s.nom as socname";
$sql .= " FROM " . MAIN_DB_PREFIX . "facture as f";
$sql .= " LEFT JOIN " . MAIN_DB_PREFIX . "societe as s ON s.rowid = f.fk_soc";
$sql .= " WHERE f.entity IN (" . getEntity('invoice') . ")";
$sql .= " ORDER BY f.datef DESC";
$sql .= " LIMIT 10";
$resql = $db->query($sql);
if ($resql) {
$num = $db->num_rows($resql);
if ($num > 0) {
while ($obj = $db->fetch_object($resql)) {
$invoice = new Facture($db);
$invoice->fetch($obj->rowid);
// Check if XML exists
$dir = $conf->facture->dir_output . '/' . dol_sanitizeFileName($obj->ref);
$hasXML = file_exists($dir . '/factur-x.xml') || file_exists($dir . '/xrechnung-' . $obj->ref . '.xml');
print '<tr class="oddeven">';
print '<td>' . $invoice->getNomUrl(1) . '</td>';
print '<td>' . htmlspecialchars($obj->socname) . '</td>';
print '<td class="right">' . price($obj->total_ttc) . ' ' . $conf->currency . '</td>';
print '<td class="right">';
if ($hasXML) {
print '<span class="badge badge-status4">' . $langs->trans('ZugferdXMLFile') . '</span>';
} else {
print '<a class="button buttongen" href="' . dol_buildpath('/exportzugferd/download.php', 1) . '?id=' . $obj->rowid . '&action=generate_xml">';
print $langs->trans('GenerateZugferdXML');
print '</a>';
}
print '</td>';
print '</tr>';
}
} else {
print '<tr class="oddeven"><td colspan="4" class="opacitymedium">' . $langs->trans('NoRecordFound') . '</td></tr>';
}
$db->free($resql);
} else {
dol_print_error($db);
}
print '</table>';
print '</div>';
print '</div>';
print '</div>';
llxFooter();
$db->close();

78
langs/de_DE/exportzugferd.lang Executable file
View file

@ -0,0 +1,78 @@
# Copyright (C) 2026 Eduard Wisch
#
# 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.
# Module
Module500017Name = Export ZUGFeRD
Module500017Desc = Exportiert Rechnungen im ZUGFeRD/Factur-X/XRechnung Format
# Admin
ExportZugferdSetup = Export ZUGFeRD Einrichtung
ExportZugferdAbout = Über Export ZUGFeRD
ExportZugferd = Export ZUGFeRD
ExportZugferdDescription = Erstellt ZUGFeRD/Factur-X/XRechnung XML-Dateien für Kundenrechnungen
ExportZugferdDescriptionLong = Dieses Modul generiert standardkonforme elektronische Rechnungen im ZUGFeRD/Factur-X und XRechnung Format (EN16931). Die XML-Dateien können als Anhang versandt oder in PDF-Dokumente eingebettet werden.
# Settings
GeneralSettings = Allgemeine Einstellungen
ZugferdProfile = ZUGFeRD Profil
AutoGenerateZugferd = Automatisch generieren
AutoGenerateZugferdDesc = Erstellt automatisch eine XML-Datei bei jeder Rechnungsgenerierung
EmbedXMLInPDF = XML in PDF einbetten
EmbedXMLInPDFDesc = Bettet die XML-Datei direkt in das PDF ein (echtes ZUGFeRD-PDF)
# Profiles
SupportedProfiles = Unterstützte Profile
ProfileMinimumDesc = Minimale Rechnungsdaten (nur für Archivierung)
ProfileBasicDesc = Grundlegende strukturierte Rechnungsdaten
ProfileEN16931Desc = Vollständige EU-konforme Rechnung (empfohlen)
ProfileXRechnungDesc = Deutsche Behördenrechnung nach XRechnung-Standard
# Actions
DownloadZugferdXML = XML herunterladen
GenerateZugferdXML = XML generieren
ZugferdXMLGenerated = ZUGFeRD XML erfolgreich erstellt
ZugferdXMLFile = ZUGFeRD/XRechnung Datei
NoZugferdXML = Keine XML vorhanden
# About
AboutModule = Über dieses Modul
ExportZugferdAboutDesc = Dieses Modul ermöglicht den Export von Kundenrechnungen im standardisierten elektronischen Rechnungsformat ZUGFeRD/Factur-X und XRechnung. Diese Formate entsprechen der europäischen Norm EN16931 für elektronische Rechnungen.
Features = Funktionen
FeatureGenerateZugferd = Generierung von ZUGFeRD/Factur-X XML-Dateien
FeatureXRechnung = Unterstützung für XRechnung 3.0 (deutsche Behördenrechnung)
FeatureEN16931 = Vollständige EN16931-Konformität
FeatureAutoGenerate = Automatische Generierung bei Rechnungserstellung
# Preview
PreviewZugferdXML = ZUGFeRD Vorschau
ZugferdPreview = ZUGFeRD Vorschau
ZugferdXMLValues = ZUGFeRD XML Werte
DocumentInfo = Dokumentinformationen
InvoiceNumber = Rechnungsnummer
TypeCode = Dokumenttyp-Code
IssueDate = Ausstellungsdatum
DueDate = Fälligkeitsdatum
Seller = Verkäufer
Lines = Positionen
Line = Position
Totals = Summen
GeneratedXML = Generierte XML
RegenerateZugferdXML = XML neu generieren
BackToInvoice = Zurück zur Rechnung
LeitwegID = Leitweg-ID
# XML Options
DeleteXMLAfterEmbed = XML nach Einbettung löschen
DeleteXMLAfterEmbedDesc = Löscht die separate XML-Datei nachdem sie ins PDF eingebettet wurde
# Email
AttachZugferdToEmail = ZUGFeRD XML an E-Mail anhängen
AttachZugferdToEmailDesc = Hängt die ZUGFeRD/XRechnung XML automatisch an ausgehende Rechnungs-E-Mails an
# Errors
ErrorGeneratingXML = Fehler beim Erstellen der XML-Datei
ErrorSavingXML = Fehler beim Speichern der XML-Datei

51
langs/en_US/exportzugferd.lang Executable file
View file

@ -0,0 +1,51 @@
# Copyright (C) 2026 Eduard Wisch
#
# 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.
# Module
Module500017Name = Export ZUGFeRD
Module500017Desc = Export invoices in ZUGFeRD/Factur-X/XRechnung format
# Admin
ExportZugferdSetup = Export ZUGFeRD Setup
ExportZugferdAbout = About Export ZUGFeRD
ExportZugferd = Export ZUGFeRD
ExportZugferdDescription = Generate ZUGFeRD/Factur-X/XRechnung XML files for customer invoices
ExportZugferdDescriptionLong = This module generates standard-compliant electronic invoices in ZUGFeRD/Factur-X and XRechnung format (EN16931). The XML files can be sent as attachments or embedded in PDF documents.
# Settings
GeneralSettings = General Settings
ZugferdProfile = ZUGFeRD Profile
AutoGenerateZugferd = Auto-generate
AutoGenerateZugferdDesc = Automatically create an XML file with each invoice generation
EmbedXMLInPDF = Embed XML in PDF
EmbedXMLInPDFDesc = Embed the XML file in the PDF (requires additional libraries)
# Profiles
SupportedProfiles = Supported Profiles
ProfileMinimumDesc = Minimal invoice data (archiving only)
ProfileBasicDesc = Basic structured invoice data
ProfileEN16931Desc = Complete EU-compliant invoice (recommended)
ProfileXRechnungDesc = German public sector invoice according to XRechnung standard
# Actions
DownloadZugferdXML = Download ZUGFeRD XML
GenerateZugferdXML = Generate ZUGFeRD XML
ZugferdXMLGenerated = ZUGFeRD XML successfully created
ZugferdXMLFile = ZUGFeRD/XRechnung File
# About
AboutModule = About this module
ExportZugferdAboutDesc = This module enables the export of customer invoices in the standardized electronic invoice format ZUGFeRD/Factur-X and XRechnung. These formats comply with the European standard EN16931 for electronic invoices.
Features = Features
FeatureGenerateZugferd = Generation of ZUGFeRD/Factur-X XML files
FeatureXRechnung = Support for XRechnung 3.0 (German public sector invoices)
FeatureEN16931 = Full EN16931 compliance
FeatureAutoGenerate = Automatic generation when creating invoices
# Errors
ErrorGeneratingXML = Error generating XML file
ErrorSavingXML = Error saving XML file

53
lib/exportzugferd.lib.php Executable file
View file

@ -0,0 +1,53 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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 <https://www.gnu.org/licenses/>.
*/
/**
* \file lib/exportzugferd.lib.php
* \ingroup exportzugferd
* \brief Library functions for ExportZugferd module
*/
/**
* Prepare admin pages header
*
* @return array Array of tabs
*/
function exportzugferd_admin_prepare_head()
{
global $langs, $conf;
$langs->load('exportzugferd@exportzugferd');
$h = 0;
$head = array();
$head[$h][0] = dol_buildpath('/exportzugferd/admin/setup.php', 1);
$head[$h][1] = $langs->trans('Settings');
$head[$h][2] = 'settings';
$h++;
$head[$h][0] = dol_buildpath('/exportzugferd/admin/about.php', 1);
$head[$h][1] = $langs->trans('About');
$head[$h][2] = 'about';
$h++;
complete_head_from_modules($conf, $langs, null, $head, $h, 'exportzugferd@exportzugferd');
complete_head_from_modules($conf, $langs, null, $head, $h, 'exportzugferd@exportzugferd', 'remove');
return $head;
}

404
preview.php Normal file
View file

@ -0,0 +1,404 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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 <https://www.gnu.org/licenses/>.
*/
/**
* \file preview.php
* \ingroup exportzugferd
* \brief Preview ZUGFeRD XML values for an invoice
*/
// Load Dolibarr environment
$res = 0;
if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) {
$res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php";
}
$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME'];
$tmp2 = realpath(__FILE__);
$i = strlen($tmp) - 1;
$j = strlen($tmp2) - 1;
while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) {
$i--;
$j--;
}
if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) {
$res = @include substr($tmp, 0, ($i + 1))."/main.inc.php";
}
if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) {
$res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php";
}
if (!$res && file_exists("../main.inc.php")) {
$res = @include "../main.inc.php";
}
if (!$res && file_exists("../../main.inc.php")) {
$res = @include "../../main.inc.php";
}
if (!$res) {
die("Include of main fails");
}
require_once DOL_DOCUMENT_ROOT . '/compta/facture/class/facture.class.php';
require_once __DIR__ . '/class/zugferdgenerator.class.php';
// Security check
$id = GETPOSTINT('id');
if (!$user->hasRight('exportzugferd', 'export') && !$user->hasRight('facture', 'lire')) {
accessforbidden();
}
$langs->loadLangs(array('exportzugferd@exportzugferd', 'bills'));
// Load invoice
$invoice = new Facture($db);
$result = $invoice->fetch($id);
if ($result <= 0) {
setEventMessages($langs->trans('ErrorRecordNotFound'), null, 'errors');
header('Location: ' . DOL_URL_ROOT . '/compta/facture/list.php');
exit;
}
// Security check on third party
$socid = $invoice->socid;
if ($user->socid > 0 && $socid != $user->socid) {
accessforbidden();
}
// Fetch related data
$invoice->fetch_thirdparty();
if (empty($invoice->lines)) {
$invoice->fetch_lines();
}
/*
* View
*/
$title = $langs->trans('PreviewZugferdXML') . ' - ' . $invoice->ref;
llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-exportzugferd page-preview');
$head = array();
$head[0][0] = DOL_URL_ROOT . '/compta/facture/card.php?id=' . $invoice->id;
$head[0][1] = $langs->trans('Invoice');
$head[0][2] = 'card';
$head[1][0] = dol_buildpath('/exportzugferd/preview.php', 1) . '?id=' . $invoice->id;
$head[1][1] = $langs->trans('ZugferdPreview');
$head[1][2] = 'zugferd';
print dol_get_fiche_head($head, 'zugferd', $langs->trans('Invoice'), -1, 'bill');
// Invoice info
print '<div class="fichecenter">';
print '<div class="underbanner clearboth"></div>';
print '<table class="border centpercent tableforfield">';
print '<tr><td class="titlefield">' . $langs->trans('Ref') . '</td>';
print '<td>' . $invoice->getNomUrl(1) . '</td></tr>';
print '<tr><td>' . $langs->trans('Customer') . '</td>';
print '<td>' . $invoice->thirdparty->getNomUrl(1) . '</td></tr>';
print '<tr><td>' . $langs->trans('DateInvoice') . '</td>';
print '<td>' . dol_print_date($invoice->date, 'day') . '</td></tr>';
print '<tr><td>' . $langs->trans('AmountTTC') . '</td>';
print '<td>' . price($invoice->total_ttc) . ' ' . $conf->currency . '</td></tr>';
print '</table>';
print '</div>';
print dol_get_fiche_end();
// ZUGFeRD XML Preview
print '<div class="div-table-responsive-no-min">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<td colspan="2">' . $langs->trans('ZugferdXMLValues') . '</td>';
print '</tr>';
// Document Info
print '<tr class="liste_titre">';
print '<td colspan="2"><strong>' . $langs->trans('DocumentInfo') . '</strong></td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td class="titlefield">' . $langs->trans('InvoiceNumber') . '</td>';
print '<td><code>' . htmlspecialchars($invoice->ref) . '</code></td>';
print '</tr>';
$typeCode = '380'; // Standard invoice
if ($invoice->type == Facture::TYPE_CREDIT_NOTE) {
$typeCode = '381';
} elseif ($invoice->type == Facture::TYPE_DEPOSIT) {
$typeCode = '386';
}
print '<tr class="oddeven">';
print '<td>' . $langs->trans('TypeCode') . '</td>';
print '<td><code>' . $typeCode . '</code> (' . ($typeCode == '380' ? 'Commercial Invoice' : ($typeCode == '381' ? 'Credit Note' : 'Deposit')) . ')</td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td>' . $langs->trans('IssueDate') . '</td>';
print '<td><code>' . dol_print_date($invoice->date, '%Y%m%d') . '</code></td>';
print '</tr>';
if (!empty($invoice->date_lim_reglement)) {
print '<tr class="oddeven">';
print '<td>' . $langs->trans('DueDate') . '</td>';
print '<td><code>' . dol_print_date($invoice->date_lim_reglement, '%Y%m%d') . '</code></td>';
print '</tr>';
}
// Seller Info
print '<tr class="liste_titre">';
print '<td colspan="2"><strong>' . $langs->trans('Seller') . ' (SellerTradeParty)</strong></td>';
print '</tr>';
global $mysoc;
print '<tr class="oddeven">';
print '<td class="titlefield">' . $langs->trans('Name') . '</td>';
print '<td><code>' . htmlspecialchars($mysoc->name) . '</code></td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td>' . $langs->trans('Address') . '</td>';
print '<td><code>' . htmlspecialchars($mysoc->address) . '</code></td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td>' . $langs->trans('Zip') . ' / ' . $langs->trans('Town') . '</td>';
print '<td><code>' . htmlspecialchars($mysoc->zip) . '</code> / <code>' . htmlspecialchars($mysoc->town) . '</code></td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td>' . $langs->trans('Country') . '</td>';
print '<td><code>' . htmlspecialchars($mysoc->country_code) . '</code></td>';
print '</tr>';
if (!empty($mysoc->tva_intra)) {
print '<tr class="oddeven">';
print '<td>' . $langs->trans('VATIntra') . '</td>';
print '<td><code>' . htmlspecialchars($mysoc->tva_intra) . '</code></td>';
print '</tr>';
}
// Buyer Info
print '<tr class="liste_titre">';
print '<td colspan="2"><strong>' . $langs->trans('Customer') . ' (BuyerTradeParty)</strong></td>';
print '</tr>';
$buyer = $invoice->thirdparty;
print '<tr class="oddeven">';
print '<td class="titlefield">' . $langs->trans('Name') . '</td>';
print '<td><code>' . htmlspecialchars($buyer->name) . '</code></td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td>' . $langs->trans('Address') . '</td>';
print '<td><code>' . htmlspecialchars($buyer->address) . '</code></td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td>' . $langs->trans('Zip') . ' / ' . $langs->trans('Town') . '</td>';
print '<td><code>' . htmlspecialchars($buyer->zip) . '</code> / <code>' . htmlspecialchars($buyer->town) . '</code></td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td>' . $langs->trans('Country') . '</td>';
print '<td><code>' . htmlspecialchars($buyer->country_code) . '</code></td>';
print '</tr>';
if (!empty($buyer->tva_intra)) {
print '<tr class="oddeven">';
print '<td>' . $langs->trans('VATIntra') . '</td>';
print '<td><code>' . htmlspecialchars($buyer->tva_intra) . '</code></td>';
print '</tr>';
}
// Leitweg-ID (for XRechnung)
if (!empty($buyer->array_options['options_leitweg_id'])) {
print '<tr class="oddeven">';
print '<td>' . $langs->trans('LeitwegID') . ' (BuyerReference)</td>';
print '<td><code>' . htmlspecialchars($buyer->array_options['options_leitweg_id']) . '</code></td>';
print '</tr>';
}
// Payment Info
print '<tr class="liste_titre">';
print '<td colspan="2"><strong>' . $langs->trans('Payment') . '</strong></td>';
print '</tr>';
// Bank Account Info
if (!empty($invoice->fk_account)) {
require_once DOL_DOCUMENT_ROOT . '/compta/bank/class/account.class.php';
$bankAccount = new Account($db);
if ($bankAccount->fetch($invoice->fk_account) > 0) {
if (!empty($bankAccount->iban)) {
print '<tr class="oddeven">';
print '<td class="titlefield">IBAN</td>';
print '<td><code>' . htmlspecialchars($bankAccount->iban) . '</code></td>';
print '</tr>';
}
if (!empty($bankAccount->bic)) {
print '<tr class="oddeven">';
print '<td>BIC</td>';
print '<td><code>' . htmlspecialchars($bankAccount->bic) . '</code></td>';
print '</tr>';
}
}
}
// Line Items
print '<tr class="liste_titre">';
print '<td colspan="2"><strong>' . $langs->trans('Lines') . ' (IncludedSupplyChainTradeLineItem)</strong></td>';
print '</tr>';
$lineNo = 0;
foreach ($invoice->lines as $line) {
// Skip title/subtotal lines
if (!empty($line->special_code) && in_array($line->special_code, array(104777, 104778, 104779))) {
continue;
}
if ($line->qty == 0 && $line->total_ht == 0 && empty($line->fk_product)) {
continue;
}
$lineNo++;
print '<tr class="oddeven">';
print '<td colspan="2" style="background-color: #f5f5f5;"><strong>' . $langs->trans('Line') . ' ' . $lineNo . '</strong></td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td class="titlefield paddingleft">&nbsp;&nbsp;LineID</td>';
print '<td><code>' . $lineNo . '</code></td>';
print '</tr>';
if (!empty($line->fk_product)) {
$product = new Product($db);
$product->fetch($line->fk_product);
print '<tr class="oddeven">';
print '<td class="paddingleft">&nbsp;&nbsp;SellerAssignedID</td>';
print '<td><code>' . htmlspecialchars($product->ref) . '</code></td>';
print '</tr>';
}
print '<tr class="oddeven">';
print '<td class="paddingleft">&nbsp;&nbsp;Name</td>';
print '<td><code>' . htmlspecialchars($line->product_label ?: $line->desc) . '</code></td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td class="paddingleft">&nbsp;&nbsp;BilledQuantity</td>';
print '<td><code>' . number_format($line->qty, 4, '.', '') . '</code></td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td class="paddingleft">&nbsp;&nbsp;NetPriceAmount (ChargeAmount)</td>';
print '<td><code>' . number_format($line->subprice, 2, '.', '') . '</code></td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td class="paddingleft">&nbsp;&nbsp;LineTotalAmount</td>';
print '<td><code>' . number_format($line->total_ht, 2, '.', '') . '</code></td>';
print '</tr>';
$vatRate = $line->tva_tx;
$categoryCode = ($vatRate > 0) ? 'S' : 'Z';
print '<tr class="oddeven">';
print '<td class="paddingleft">&nbsp;&nbsp;VAT (CategoryCode / Rate)</td>';
print '<td><code>' . $categoryCode . '</code> / <code>' . number_format($vatRate, 2, '.', '') . '%</code></td>';
print '</tr>';
}
// Totals
print '<tr class="liste_titre">';
print '<td colspan="2"><strong>' . $langs->trans('Totals') . ' (SpecifiedTradeSettlementHeaderMonetarySummation)</strong></td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td class="titlefield">LineTotalAmount</td>';
print '<td><code>' . number_format($invoice->total_ht, 2, '.', '') . '</code></td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td>TaxBasisTotalAmount</td>';
print '<td><code>' . number_format($invoice->total_ht, 2, '.', '') . '</code></td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td>TaxTotalAmount</td>';
print '<td><code>' . number_format($invoice->total_tva, 2, '.', '') . '</code> (currencyID="' . $conf->currency . '")</td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td>GrandTotalAmount</td>';
print '<td><code>' . number_format($invoice->total_ttc, 2, '.', '') . '</code></td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td>DuePayableAmount</td>';
print '<td><code>' . number_format($invoice->total_ttc - $invoice->getSommePaiement(), 2, '.', '') . '</code></td>';
print '</tr>';
print '</table>';
print '</div>';
// Check if XML exists and show it
$dir = $conf->facture->dir_output . '/' . dol_sanitizeFileName($invoice->ref);
$zugferdFile = $dir . '/factur-x.xml';
$xrechnungFile = $dir . '/xrechnung-' . $invoice->ref . '.xml';
$xmlFile = null;
if (file_exists($zugferdFile)) {
$xmlFile = $zugferdFile;
} elseif (file_exists($xrechnungFile)) {
$xmlFile = $xrechnungFile;
}
if ($xmlFile) {
print '<br>';
print '<div class="div-table-responsive-no-min">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<td>' . $langs->trans('GeneratedXML') . ' (' . basename($xmlFile) . ')</td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td>';
print '<pre style="white-space: pre-wrap; word-wrap: break-word; font-size: 11px; max-height: 500px; overflow-y: auto;">';
print htmlspecialchars(file_get_contents($xmlFile));
print '</pre>';
print '</td>';
print '</tr>';
print '</table>';
print '</div>';
}
// Actions
print '<div class="tabsAction">';
print '<a class="butAction" href="' . dol_buildpath('/exportzugferd/download.php', 1) . '?id=' . $invoice->id . '&action=generate_xml">';
print '<span class="fa fa-sync paddingright"></span>';
print $langs->trans('RegenerateZugferdXML');
print '</a>';
print '<a class="butAction" href="' . dol_buildpath('/exportzugferd/download.php', 1) . '?id=' . $invoice->id . '&action=download_xml">';
print '<span class="fa fa-download paddingright"></span>';
print $langs->trans('DownloadZugferdXML');
print '</a>';
print '<a class="butAction" href="' . DOL_URL_ROOT . '/compta/facture/card.php?id=' . $invoice->id . '">';
print '<span class="fa fa-arrow-left paddingright"></span>';
print $langs->trans('BackToInvoice');
print '</a>';
print '</div>';
llxFooter();
$db->close();