- 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>
745 lines
23 KiB
PHP
Executable file
745 lines
23 KiB
PHP
Executable file
<?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;
|
|
}
|
|
}
|