diff -ruN --exclude=vendor --exclude=.git _base_lf/ChangeLog.md cm_repo/einvoicing/ChangeLog.md --- _base_lf/ChangeLog.md 2026-09-11 07:09:36 +++ cm_repo/einvoicing/ChangeLog.md 2026-09-11 06:39:46 @@ -1,10 +1,98 @@ # CHANGELOG MODULE EINVOICING FOR [DOLIBARR ERP CRM](https://www.dolibarr.org) -## 1.2.0 -NEW: When using SuperPDP, a PDF is also retreived in addition to the .xml einvoice file. +## 1.2.0 +FIX: #853 [einvoicing] The dates of a received document keep the day they state +FIX: [einvoicing] The CDAR date test errors on Dolibarr 19 since it landed on main +FIX: [einvoicing] Enhance status handling +QUAL: einvoicing: format the CDAR dates with the core date helpers +FIX: #831 Default legal mentions asserted the opposite of the law +FIX: XSS: escape received e-invoice fields shown in the sync results panel +FIX: [einvoicing] Realign the compat copies on the core they backport +FIX: #784 [einvoicing] The consistency check reads the document the invoice was imported from +FIX: [einvoicing] Typos: sotorder in the list title hook, GETPOSt in the OAuth proxy callback +FIX: #832: no lifecycle status offered on a flow the platform filed as B2B international +FIX: [einvoicing] The module no longer shows a raw translation key +NEW: #687 [einvoicing] The e-invoice XML can be read without downloading it +QUAL: [einvoicing] Shorten the comment blocks written by the other contributors +FIX: einvoicing: the prepaid amount follows what the core counts as paid +QUAL: [einvoicing] Shorten the comment blocks of the module to the rule of AGENTS.md +QUAL: einvoicing: regroup the phpunit files by the source file they test +FIX: einvoicing: give imported supplier invoice lines a rank +FIX: einvoicing: run every phpunit file of the module from AllTests +DOC: einvoicing: function map of the outbound and inbound chains, generated from the sources +NEW: [einvoicing] Export the selected flows or API calls for support +QUAL: #794 [einvoicing] An import made again keeps the line of the flow, the number of the draft, and the statuses already received +FIX: einvoicing: offer "Payment transmitted" only on an answered, payable invoice +FIX: einvoicing: name the platform in the CDAR send failure message +FIX: #806 [einvoicing] Write MDT-97 in the CDAR and stop relabelling the recipient address +FIX: einvoicing: build the VAT number the way the core does +FIX: einvoicing: strip the spaces of an identifier the same way everywhere +FIX: einvoicing: look up the payment term in the entity of the invoice +FIX: einvoicing: read the VAT dictionary in the entity of the seller +QUAL: einvoicing: read the previous situation line through FactureLigne +QUAL: einvoicing: handle temporary files with the core file helpers +QUAL: einvoicing: read the invoice status from the object, not from its table +QUAL: einvoicing: use the core getMultidirOutput() when the core has it +QUAL: einvoicing: read the payment mode from the core dictionary helper +FIX: einvoicing: accepting a received invoice validates it in Dolibarr +FIX: einvoicing: the default product of a vendor cannot be removed (#791) +FIX: #761 [einvoicing] A received document is judged on the invoice it carries, not on its envelope +FIX: #783 [einvoicing] A discounted line of a received document is imported at the amount it announces +FIX: [einvoicing] A mandatory extrafield on thirdparties no longer rejects received documents +FIX: also check prodname for ref_fourn +NEW: [einvoicing] Say on the synchronization list how to import a document again +FIX: [einvoicing] Deleting a flow with no supplier invoice is no longer a fatal +FIX: [einvoicing] A price stated per N units is not a price per unit +NEW: [einvoicing] The CI runs the suite, opens the pages and installs the package, on a real Dolibarr +FIX: #781 [einvoicing] A received invoice is recorded at the totals its document announces +FIX: [einvoicing] The message about a missing linked document says which of the two is missing +FIX: [einvoicing] Fatal error getCountry() undefined when auto-creating thirdparty in cron context +NEW: [einvoicing] Run the EN 16931 and CTC-FR rules on the documents of the module in the CI +QUAL: [einvoicing] The two not-for-prod options move to the Dev tools page +NEW: [einvoicing] A lifecycle answer says which build wrote it +FIX: [einvoicing] Say the CDAR temporary directory cannot be written, not that the file is missing +FIX: #772 [einvoicing] A received line that subtracts from the invoice is no longer dropped +FIX: [einvoicing] A control character in a description no longer makes the document unreadable +FIX: #739 [einvoicing] A structured legal identifier attaches a received document to a third party, a name only on request +FIX: #755 [einvoicing] A received e-invoice booked on the wrong third party can be recovered: delete the draft, import the document again +QUAL: [einvoicing] The debug stamp names the commit, not only the version +FIX: [einvoicing] Translation +FIX: [einvoicing] Both providers read the shape of a received flow they can import, not only the Converted one +FIX: [einvoicing] Say how to set the AFNOR conversion format on an existing SuperPDP application +FIX: [einvoicing] A status refused on the electronic address (MDT-73) says which address, and what to fix +FIX: #742 [einvoicing] A received Factur-X in the EXTENDED-CTC-FR profile can be imported again +FIX: #739 [einvoicing] ref_ext is not an identity claim for the seller of a received document +FIX: #739 [einvoicing] A received invoice stops landing on a thirdparty that only shares the email address +FIX: [einvoicing] Declare the hook contexts the module really uses, not 'all' +FIX: #680 [einvoicing] The join on einvoicing_routing stops repeating the thirdparties of the list +FIX: #735 [einvoicing] An invoice line charge (BG-28) stops being lost and corrupting the discount of its line +FIX: #731 [einvoicing] A document level charge (BG-21) of a received document stops leaving the total +FIX: #726 [einvoicing] A received line with an amount but no quantity stops being imported as zero +FIX: [einvoicing] Remove the unreachable shipping/delivery branch of _isLineFromExternalModule() +NEW: Add 'Change entity' button on supplier invoice card (EINVOICING_ALLOW_MULTICOMPANY_INVOICE_MOVE) +NEW: [einvoicing] The VAT category of a line is read from its VAT code (reverse charge, AE) +FIX: [einvoicing] The three check endpoints stop building their provider list without $mysoc +FIX: #720 [einvoicing] The directory check answers about the address the invoice is sent to +FIX: [einvoicing] A header allowance no longer breaks the whole synchronization on Dolibarr 18 and 19 +FIX: #680 [einvoicing] The join on einvoicing_extlinks stops repeating the rows of a list +FIX: #680 [einvoicing] Searching a routing identifier stops turning the list into an SQL error +FIX: #680 [einvoicing] The tables joined into the lists of Dolibarr carry an index +FIX: #704 [einvoicing] The registry check keeps quiet on the fields INSEE does not disclose +FIX: #695 An ampersand in a text value empties the CII element that carries it +FIX: #674 [einvoicing] A situation invoice states the instalment it asks for, not the cumulative amount +FIX: #701 [einvoicing] A received e-invoice is written where the supplier invoice card reads it +FIX: #698 [einvoicing] The directory badge says when its answer comes from the fallback endpoint +FIX: do not forget $db in your object +FIX: #683 [einvoicing] The deliver-to party names the company, and an address keeps its lines +FIX: #685 [einvoicing] A deleted e-invoice stops being announced as ready to send +NEW: #686 The generated XML names the module commit, not only its version +FIX: use EINVOICING_FLOWS_SYNC_CALL_SIZE in cronSyncFlows +FIX: #681 [einvoicing] Generating a Factur-X no longer breaks the next PDF of the request +FIX: [einvoicing] Converting a deposit no longer raises six PHP warnings per VAT rate +FIX: #675 [einvoicing] A received invoice referencing a missing one no longer stops the whole synchronization ## 1.1.0 diff -ruN --exclude=vendor --exclude=.git _base_lf/README.md cm_repo/einvoicing/README.md --- _base_lf/README.md 2026-09-11 07:09:36 +++ cm_repo/einvoicing/README.md 2026-09-11 06:39:46 @@ -39,7 +39,7 @@ EINVOICING_ALLOW_DEVTOOLS: Add a button to display the raw data of the invoice in the invoice card. -EINVOICING_ALLOW_MULTICOMPANY_INVOICE_MOVE: Add a button to move an invoice from an entity to another one (if using multientity with all entity having the same SIREN, you can dispatch invoice in the correct one). +EINVOICING_ALLOW_MULTICOMPANY_INVOICE_MOVE: Set this option to the list of all entities ID. It will add a button to move an invoice from an entity to another one (if using multicompany module with all entities having the same SIREN, you can receive all your invoices in the master entity and dispatch invoices in the correct entity after retreival). ## Licenses diff -ruN --exclude=vendor --exclude=.git _base_lf/admin/setup.php cm_repo/einvoicing/admin/setup.php --- _base_lf/admin/setup.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/admin/setup.php 2026-09-11 06:39:46 @@ -170,6 +170,7 @@ setEventMessages($langs->trans('EINVOICING_SUPERPDP_OAUTH_STATE_MISMATCH'), null, 'errors'); } else { unset($_SESSION['einvoicing_superpdp_oauth_state']); + // @phan-suppress-next-line PhanUndeclaredMethod Guarded by the method_exists() above: only a provider using an authorization code flow declares it. $token = $provider->exchangeAuthorizationCode(GETPOST('code')); if ($token) { setEventMessages("Token generated successfully", null, 'mesgs'); @@ -193,7 +194,7 @@ $item->helpText = $langs->transnoentities('EINVOICING_PDP_HELP'); $item->helpText .= '
'.$langs->transnoentities('EINVOICING_PDP_HELP2'); $item->helpText .= '
'.$langs->transnoentities('EINVOICING_PDP_HELP3'); -$item->cssClass = 'minwidth500'; +$item->cssClass = 'maxwidth500'; //var_dump($item);exit; // Real/test mode has no meaning for the TESTPDP stub, which never talks to any platform. diff -ruN --exclude=vendor --exclude=.git _base_lf/admin/setup_options.php cm_repo/einvoicing/admin/setup_options.php --- _base_lf/admin/setup_options.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/admin/setup_options.php 2026-09-11 06:39:46 @@ -66,6 +66,7 @@ * @var HookManager $hookmanager * @var Translate $langs * @var User $user + * @var Societe $mysoc */ // Libraries require_once DOL_DOCUMENT_ROOT."/core/lib/admin.lib.php"; @@ -282,6 +283,25 @@ $item->defaultFieldValue = 'auto'; $item->cssClass = 'minwidth500'; + // The scheme the party identifier (BT-29, BT-46) is declared under. A list for a French company, + // whose admissible values the specification names, and a free field for any other country, where + // the module has no table of registers and would otherwise declare a national identifier as a DUNS. + if ($mysoc->country_code == 'FR') { + $item = $formSetup->newItem('EINVOICING_PARTY_IDENTIFIER_SCHEME')->setAsSelect(array( + '0225' => $langs->transnoentities('EINVOICING_PARTY_IDENTIFIER_SCHEME_0225'), + '0009' => $langs->transnoentities('EINVOICING_PARTY_IDENTIFIER_SCHEME_0009'), + 'none' => $langs->transnoentities('EINVOICING_PARTY_IDENTIFIER_SCHEME_NONE'), + )); + $item->defaultFieldValue = '0225'; + } else { + // Left empty on purpose outside France: an empty value keeps the code the module has always + // answered for that country, and 0225 is a French scheme that would be wrong anywhere else. + $item = $formSetup->newItem('EINVOICING_PARTY_IDENTIFIER_SCHEME'); + $item->fieldAttr['placeholder'] = $langs->transnoentities('EINVOICING_PARTY_IDENTIFIER_SCHEME_PLACEHOLDER'); + } + $item->helpText = $langs->transnoentities('EINVOICING_PARTY_IDENTIFIER_SCHEME_HELP'); + $item->cssClass = 'minwidth500'; + // Setup conf to automatically transmit the e-invoice to the PA right after it is generated (on validation) if (!getDolGlobalString('EINVOICING_ONLY_GENERATE')) { $item = $formSetup->newItem('EINVOICING_AUTO_SEND_ON_GENERATION')->setAsYesNo(); @@ -363,6 +383,14 @@ $item->fieldParams['warningifon'] = 1; } + // Setup conf to match a vendor product reference written with separators other than the recorded one. + // Off by default: the comparison ignores separators, so it is an approximation. + $item = $formSetup->newItem('EINVOICING_PRODUCTS_MATCH_CANONICAL_REF')->setAsYesNo(); + $item->helpText = $langs->transnoentities('EINVOICING_PRODUCTS_MATCH_CANONICAL_REF_HELP'); + $item->defaultFieldValue = '0'; + $item->cssClass = 'minwidth500'; + $item->fieldParams['warningifon'] = 1; + // Setup conf to choose use of auto generation or not of third parties $item = $formSetup->newItem('EINVOICING_THIRDPARTIES_AUTO_GENERATION')->setAsYesNo(); $item->helpText = $langs->transnoentities('EINVOICING_THIRDPARTIES_AUTO_GENERATION_HELP'); @@ -384,12 +412,14 @@ $item->fieldParams['forcereload'] = 1; */ - if (getDolGlobalString('EINVOICING_SUPPLIER_INVOICE_CHECK_CONSISTENCY_ON_VALIDATION_AVAILABLE')) { - // Setup conf to enable or not the consistency check on supplier invoice validation - $item = $formSetup->newItem('EINVOICING_SUPPLIER_INVOICE_CHECK_CONSISTENCY_ON_VALIDATION'); - $item->helpText = $langs->transnoentities('EINVOICING_SUPPLIER_INVOICE_CHECK_CONSISTENCY_ON_VALIDATION_HELP'); - $item->setAsYesNo(); - } + // Setup conf to enable or not the consistency check on supplier invoice validation. Off by default: + // it re-checks every e-invoice at validation, including the ones edited by hand afterwards, which is + // a wider question than the one the import itself settles. + $item = $formSetup->newItem('EINVOICING_SUPPLIER_INVOICE_CHECK_CONSISTENCY_ON_VALIDATION'); + $item->helpText = $langs->transnoentities('EINVOICING_SUPPLIER_INVOICE_CHECK_CONSISTENCY_ON_VALIDATION_HELP'); + $item->setAsYesNo(); + $item->defaultFieldValue = '0'; + $item->cssClass = 'minwidth500'; // Tell the vendor that its invoice is approved (status 205) when the supplier invoice is validated so approved. // Off by default. diff -ruN --exclude=vendor --exclude=.git _base_lf/call_card.php cm_repo/einvoicing/call_card.php --- _base_lf/call_card.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/call_card.php 2026-09-11 06:39:46 @@ -393,7 +393,7 @@ // Clone if ($permissiontoadd) { - print dolGetButtonAction('', $langs->trans('ToClone'), 'clone', $_SERVER['PHP_SELF'].'?id='.$object->id.(!empty($object->socid) ? '&socid='.$object->socid : '').'&action=clone&token='.newToken(), '', $permissiontoadd); + print dolGetButtonAction('', $langs->trans('ToClone'), 'clone', $_SERVER['PHP_SELF'].'?id='.$object->id.'&action=clone&token='.newToken(), '', $permissiontoadd); } // Delete (with preloaded confirm popup) diff -ruN --exclude=vendor --exclude=.git _base_lf/class/actions_einvoicing.class.php cm_repo/einvoicing/class/actions_einvoicing.class.php --- _base_lf/class/actions_einvoicing.class.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/class/actions_einvoicing.class.php 2026-09-11 06:53:42 @@ -375,11 +375,14 @@ } } - // If the e-invoice is generated but not sent, or if it was sent and a validation error was received, - // display the button to regenerate the e-invoice - // Re-send is offered for not-yet-transmitted states, plus AWAITING_* as a deliberate retry - // affordance. Once REALLY transmitted (persistent flow_id), it is locked by default unless - // EINVOICING_ALLOW_RESEND_TRANSMITTED is set ($locked already accounts for that opt-out). + // If the e-invoice is generated but not sent, or if it was sent and a validation error was + // received, display the button to (re)send the e-invoice. + // Re-send is offered for not-yet-transmitted states, plus AWAITING_*/REJECTED as a deliberate + // retry/correction affordance. Once REALLY transmitted (persistent flow_id) it is locked, and the + // only opt-out is the option EINVOICING_ALLOW_RESEND_TRANSMITTED. That option is read in a single + // place, EInvoicing::isTransmittedLockActive() (assigned to $locked above: it returns false as + // soon as EINVOICING_ALLOW_RESEND_TRANSMITTED is set), which the server-side send_to_pdp gate + // uses too, so the button visibility here and the real enforcement can never drift apart. if (!$locked && !einvoicingIsSendDisabled() && in_array($currentStatusDetails['code'], [ $einvoicing::STATUS_GENERATED, $einvoicing::STATUS_ERROR, @@ -566,6 +569,13 @@ { global $db, $langs, $user, $conf; + // Before anything else, and before the early return below: a list of the core builds its + // $arrayfields before it knows any action, and the versions that offer no 'completeArrayFields' + // hook pass the array by reference here instead. See addFieldsToList(). + if (isset($parameters['arrayfields'])) { + self::addFieldsToList($parameters['arrayfields'], $parameters['context']); + } + if (empty($action)) { return 0; } @@ -599,6 +609,7 @@ if ($isFactureContext) { '@phan-var-force Facture $object'; + /** @var Facture $object */ $permissiontoedit = $user->hasRight('facture', 'write'); $db->begin(); @@ -1336,7 +1347,7 @@ * @param array $parameters Array of parameters * @param CommonObject $object Object * @param string $action Action code - * @param Hookmanager $hookmanager Hook manager + * @param HookManager $hookmanager Hook manager * @return int */ public function formConfirm($parameters, $object, &$action, $hookmanager) @@ -1452,7 +1463,7 @@ * @param array $parameters Array of parameters * @param CommonObject $object Object invoice * @param string $action Code action - * @param Hookmanager $hookmanager Hookmanager + * @param HookManager $hookmanager Hookmanager * @return int Result */ public function formObjectOptions($parameters, $object, &$action, $hookmanager) @@ -1473,18 +1484,21 @@ // generation status/history, not only the send-related parts (those are gated individually inside it). if (in_array($object->element, ['facture']) && (!getDolGlobalString('EINVOICING_DISABLE_SYNC_DOLI_TO_AP') || getDolGlobalString('EINVOICING_ONLY_GENERATE'))) { '@phan-var-force Facture $object'; + /** @var Facture $object */ $this->resprints .= $einvoicing->EInvoiceCardBlock($object, $action, $parameters); // Output fields in card, including js for refreshing state } // Add block in supplier invoice card (reception only) if (in_array($object->element, ['invoice_supplier']) && !einvoicingIsReceiveDisabled()) { '@phan-var-force FactureFournisseur $object'; + /** @var FactureFournisseur $object */ $this->resprints .= $einvoicing->supplierInvoiceCardBlock($object, $action, $parameters); // Output fields in card, including js for refreshing state } // Add block in product/service card (reception only) if (in_array($object->element, ['product']) && !einvoicingIsReceiveDisabled()) { '@phan-var-force Product $object'; + /** @var Product $object */ $this->resprints .= $einvoicing->productServiceCardBlock($object, $action, $parameters); // Output fields in card, including js for refreshing state } @@ -1493,6 +1507,7 @@ // import" part is gated individually inside thirdpartyCardBlock(). if (in_array($object->element, ['societe']) && (!getDolGlobalString('EINVOICING_DISABLE_SYNC_DOLI_TO_AP') || !getDolGlobalString('EINVOICING_DISABLE_SYNC_AP_TO_DOLI') || getDolGlobalString('EINVOICING_ONLY_GENERATE'))) { '@phan-var-force Societe $object'; + /** @var Societe $object */ $this->resprints .= $einvoicing->thirdpartyCardBlock($object, $action, $parameters); // Output fields in card } } @@ -1507,21 +1522,44 @@ * @param array $parameters Array of parameters * @param CommonObject $object Object invoice * @param string $action Code action - * @param Hookmanager $hookmanager Hookmanager + * @param HookManager $hookmanager Hookmanager * @return int Result */ public function completeArrayFields($parameters, $object, &$action, $hookmanager) { - if (in_array('invoicelist', explode(':', $parameters['context'])) && (!getDolGlobalString('EINVOICING_DISABLE_SYNC_DOLI_TO_AP') || getDolGlobalString('EINVOICING_ONLY_GENERATE'))) { + if (isset($parameters['arrayfields'])) { + self::addFieldsToList($parameters['arrayfields'], $parameters['context']); + } + + return 0; + } + + /** + * Declare the columns of the module in the list of fields a list of the core offers to display. + * + * Called from two hooks on purpose: the core runs 'completeArrayFields' only from Dolibarr 22 on + * the customer invoice list and 23 on the two others, while 'doActions' gets the same array by + * reference from 18, 19 and 20. Without both, the checkboxes of the module do not exist at all on + * the older cores. Writing the same keys twice, where both hooks run, changes nothing. + * + * @param array $arrayfields Fields of the list, as the core passes them by reference + * @param string $context Value of $parameters['context'] as the hook manager builds it + * @return void + */ + protected static function addFieldsToList(&$arrayfields, $context) + { + $contexts = explode(':', $context); + + if (in_array('invoicelist', $contexts, true) && (!getDolGlobalString('EINVOICING_DISABLE_SYNC_DOLI_TO_AP') || getDolGlobalString('EINVOICING_ONLY_GENERATE'))) { // Add fields to invoice list - $parameters['arrayfields']['einvoicegenerated'] = array( + $arrayfields['einvoicegenerated'] = array( 'label' => 'EInvoiceFile', 'checked' => -1, 'position' => 900, 'enabled' => 1, 'perms' => '1' ); - $parameters['arrayfields']['pdp_syncstatus'] = array( + $arrayfields['pdp_syncstatus'] = array( 'label' => 'PDPSyncStatus', 'checked' => 1, 'position' => 901, @@ -1530,10 +1568,10 @@ ); } - if (in_array('thirdpartylist', explode(':', $parameters['context']))) { + if (in_array('thirdpartylist', $contexts, true)) { // Routing ID column: kept even under EINVOICING_ONLY_GENERATE, like the field on the thirdparty card. if (!getDolGlobalString('EINVOICING_DISABLE_SYNC_DOLI_TO_AP') || !getDolGlobalString('EINVOICING_DISABLE_SYNC_AP_TO_DOLI') || getDolGlobalString('EINVOICING_ONLY_GENERATE')) { - $parameters['arrayfields']['routing_id'] = array( + $arrayfields['routing_id'] = array( 'label' => 'RoutingIdField', 'help' => 'SpecificRoutingFieldHelp', 'checked' => -1, @@ -1544,7 +1582,7 @@ } // Default product for import: reception only. if (!einvoicingIsReceiveDisabled()) { - $parameters['arrayfields']['routing_product_id'] = array( + $arrayfields['routing_product_id'] = array( 'label' => 'DefaultProductEBilling', 'checked' => -1, 'position' => 901, @@ -1553,8 +1591,6 @@ ); } } - - return 0; } @@ -1581,7 +1617,7 @@ * @param array $parameters Array of parameters * @param CommonObject $object Object invoice * @param string $action Code action - * @param Hookmanager $hookmanager Hookmanager + * @param HookManager $hookmanager Hookmanager * @return int Result */ public function printFieldListSelect($parameters, $object, &$action, $hookmanager) @@ -1654,7 +1690,7 @@ * @param array $parameters Array of parameters * @param CommonObject $object Object invoice * @param string $action Code action - * @param Hookmanager $hookmanager Hookmanager + * @param HookManager $hookmanager Hookmanager * @return int Result */ public function printFieldListFrom($parameters, $object, &$action, $hookmanager) @@ -1696,7 +1732,7 @@ * @param array $parameters Array of parameters * @param CommonObject $object Object invoice * @param string $action Code action - * @param Hookmanager $hookmanager Hookmanager + * @param HookManager $hookmanager Hookmanager * @return int Result */ public function printFieldListWhere($parameters, $object, &$action, $hookmanager) @@ -1757,7 +1793,7 @@ * @param array $parameters Array of parameters * @param CommonObject $object Object invoice * @param string $action Code action - * @param Hookmanager $hookmanager Hookmanager + * @param HookManager $hookmanager Hookmanager * @return int Result */ public function printFieldListGroupBy($parameters, $object, &$action, $hookmanager) @@ -1775,7 +1811,7 @@ * @param array $parameters Array of parameters * @param CommonObject $object Object invoice * @param string $action Code action - * @param Hookmanager $hookmanager Hookmanager + * @param HookManager $hookmanager Hookmanager * @return int Result */ public function printFieldListOption($parameters, $object, &$action, $hookmanager) @@ -1893,7 +1929,9 @@ if (in_array('thirdpartylist', explode(':', $parameters['context'])) && (!getDolGlobalString('EINVOICING_DISABLE_SYNC_DOLI_TO_AP') || !getDolGlobalString('EINVOICING_DISABLE_SYNC_AP_TO_DOLI') || getDolGlobalString('EINVOICING_ONLY_GENERATE'))) { - if (!empty($parameters['arrayfields']['einvoicegenerated']['checked'])) { + // 'routing_id' is the field addFieldsToList() declares for that list; 'einvoicegenerated' is the + // one of the invoice list, so the column of the thirdparty list never followed its own checkbox + if (!empty($parameters['arrayfields']['routing_id']['checked'])) { print ''; print ''; print ''; @@ -1915,7 +1953,7 @@ * @param array $parameters Array of parameters * @param CommonObject $object Object invoice * @param string $action Code action - * @param Hookmanager $hookmanager Hookmanager + * @param HookManager $hookmanager Hookmanager * @return int Result */ public function printFieldListTitle($parameters, $object, &$action, $hookmanager) @@ -1952,7 +1990,7 @@ } if (in_array('thirdpartylist', $contexts) && (!getDolGlobalString('EINVOICING_DISABLE_SYNC_DOLI_TO_AP') || !getDolGlobalString('EINVOICING_DISABLE_SYNC_AP_TO_DOLI') || getDolGlobalString('EINVOICING_ONLY_GENERATE'))) { - if (!empty($parameters['arrayfields']['einvoicegenerated']['checked'])) { + if (!empty($parameters['arrayfields']['routing_id']['checked'])) { print_liste_field_titre($langs->transnoentitiesnoconv('einvoicingThirdPartyRoutingTitle')); } } @@ -1972,7 +2010,7 @@ * @param array $parameters Array of parameters * @param CommonObject $object Object invoice * @param string $action Code action - * @param Hookmanager $hookmanager Hookmanager + * @param HookManager $hookmanager Hookmanager * @return int Result */ public function printFieldListValue($parameters, $object, &$action, $hookmanager) @@ -2061,7 +2099,7 @@ } if (in_array('thirdpartylist', explode(':', $parameters['context']), true)) { - if (!empty($parameters['arrayfields']['einvoicegenerated']['checked'])) { + if (!empty($parameters['arrayfields']['routing_id']['checked'])) { $obj = $parameters['obj']; print ''; @@ -2085,7 +2123,7 @@ * @param array $parameters Array of parameters * @param CommonObject $object Object invoice * @param string $action Code action - * @param Hookmanager $hookmanager Hookmanager + * @param HookManager $hookmanager Hookmanager * @return int Result */ public function isEditable($parameters, $object, &$action, $hookmanager) @@ -2123,7 +2161,7 @@ * @param array{soc_origin:int,soc_dest:int} $parameters Array of parameters (soc_origin = absorbed thirdparty id, soc_dest = surviving thirdparty id) * @param CommonObject $object Destination thirdparty object * @param string $action Code action - * @param Hookmanager $hookmanager Hookmanager + * @param HookManager $hookmanager Hookmanager * @return int 0 on success/nothing to do, -1 on error (sets $this->error/$this->errors) */ public function replaceThirdparty($parameters, $object, &$action, $hookmanager) @@ -2249,7 +2287,7 @@ * @param array{colspan:int,socid:int|string,id:int|string,modulepart:string,relativepath:string} $parameters Array of parameters * @param array $object The file of the line being rendered * @param string $action Code action - * @param Hookmanager $hookmanager Hookmanager + * @param HookManager $hookmanager Hookmanager * @return int 0 in all cases (the line is completed, never replaced) */ public function formBuilddocLineOptions($parameters, $object, &$action, $hookmanager) diff -ruN --exclude=vendor --exclude=.git _base_lf/class/call.class.php cm_repo/einvoicing/class/call.class.php --- _base_lf/class/call.class.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/class/call.class.php 2026-09-11 06:39:46 @@ -357,7 +357,7 @@ if (!$error) { // copy external contacts if same company @phan-suppress-next-line PhanUndeclaredProperty - if (!empty($object->socid) && ((property_exists($this, 'fk_soc') && ($this->fk_soc == $object->socid)) || (property_exists($this, 'socid') && ($this->socid == $object->socid)))) { // @phpstan-ignore-line + if (!empty($object->socid) && ((property_exists($this, 'fk_soc') && ($this->fk_soc == $object->socid)) || (property_exists($this, 'socid') && ($this->socid == $object->socid)))) { // @phpstan-ignore-line @phan-suppress-current-line PhanUndeclaredProperty if ($this->copy_linked_contact($object, 'external') < 0) { $error++; } @@ -1233,11 +1233,23 @@ // Read on the connection this record will be written on ($dbhistory), not on the global $db: a // snapshot read from another transaction returns a stale number and the insert dies on - // uk_einvoicing_call_callid. FOR UPDATE makes it a locking read and holds the range until insert. - $sql = "SELECT MAX(CAST(SUBSTRING(call_id, ".(strlen($prefix) + 1).") AS SIGNED)) AS maxref"; + // uk_einvoicing_call_callid. The read must therefore be a locking one, held until the insert. + $ispgsql = ($this->db->type == 'pgsql'); + + if ($ispgsql) { + // PostgreSQL refuses FOR UPDATE on an aggregate (SQLSTATE 0A000), so serialize the readers + // with an advisory lock instead. It is held until the transaction ends, like FOR UPDATE. + if (!$this->db->query("SELECT pg_advisory_xact_lock(1)")) { + return null; + } + } + + $sql = "SELECT MAX(CAST(SUBSTRING(call_id, ".(strlen($prefix) + 1).") AS INTEGER)) AS maxref"; $sql .= " FROM ".$this->db->prefix().$this->table_element; $sql .= " WHERE call_id LIKE '".$this->db->escape($prefix)."%'"; - $sql .= " FOR UPDATE"; + if (!$ispgsql) { + $sql .= " FOR UPDATE"; + } $resql = $this->db->query($sql); if (!$resql) { diff -ruN --exclude=vendor --exclude=.git _base_lf/class/document.class.php cm_repo/einvoicing/class/document.class.php --- _base_lf/class/document.class.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/class/document.class.php 2026-09-11 06:39:46 @@ -376,7 +376,7 @@ if (!$error) { // copy external contacts if same company - if (!empty($object->socid) && ((property_exists($this, 'fk_soc') && ($this->fk_soc == $object->socid)) || (property_exists($this, 'socid') && ($this->socid == $object->socid)))) { // @phpstan-ignore-line + if (!empty($object->socid) && ((property_exists($this, 'fk_soc') && ($this->fk_soc == $object->socid)) || (property_exists($this, 'socid') && ($this->socid == $object->socid)))) { // @phpstan-ignore-line @phan-suppress-current-line PhanUndeclaredProperty if ($this->copy_linked_contact($object, 'external') < 0) { $error++; } @@ -1603,21 +1603,42 @@ if ($sync_result['res'] <= 0) { $error++; - $errortype = 'errors'; + // The scheduler builds what it shows from $this->error and $this->errors, never from + // $this->output. Leaving both empty is what turns a precise cause into the bare + // "Unknown error" the job card ends up displaying. if (!empty($sync_result['actions'])) { - $errortype = 'warnings'; - $this->output .= '
' . $langs->trans("EINVOICING_JOB_MANUAL_ACTION_REQUIRED") . '
'; + // A business error already carries a message written for a human, and the technical + // line with it, inside the tooltip its picto opens on the card. Handing the transcript + // to the scheduler as well would print that same line a second time and in clear, + // under the very action the operator is being asked to carry out. What is missing here + // is a cause, not a copy of one: give the sentence that closes the list, and stop + // writing it above them. + $this->output .= '
'; foreach ($sync_result['actions'] as $action) { $this->output .= "---
"; $this->output .= $action['businessmessage'] . '
'; } - $this->output = rtrim($this->output, '
'); + // Not rtrim($output, '
'): rtrim strips characters, not a string, so it eats into + // the closing tags of the business message and leaves broken markup behind. + $this->output = preg_replace('/
$/', '', $this->output); + $this->error = $langs->trans("EINVOICING_JOB_MANUAL_ACTION_REQUIRED"); + } else { + // Everything else has no message written for a human, so the transcript is what the + // operator gets. A third-party provider may only fill the older 'details' key, so fall + // back on it rather than on nothing. + $this->errors = $sync_result['errors'] ?? ($sync_result['details'] ?? array()); + + // The early returns of syncFlows() - the access point answering something other than + // 200, the lookup of the already-processed flows failing - put their one message in + // both keys. The scheduler prints output and then the cause, so leaving it in both + // shows it twice. + $this->output = implode('
', array_diff($sync_result['messages'] ?? array(), $this->errors)); } - //$this->output = $langs->trans("FailedToSyncADocument").($errortype ? '
'.$langs->trans("FailedToSyncADocumentMore") : ''); } } else { $error++; - $this->output = $langs->trans("NoPDPProviderConfigured"); + // Set on error only, not on output: the scheduler concatenates the two and would show it twice. + $this->error = $langs->trans("NoPDPProviderConfigured"); } $this->output = trim($this->output); diff -ruN --exclude=vendor --exclude=.git _base_lf/class/einvoicing.class.php cm_repo/einvoicing/class/einvoicing.class.php --- _base_lf/class/einvoicing.class.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/class/einvoicing.class.php 2026-09-11 06:39:46 @@ -522,6 +522,13 @@ ]; /** + * Name, into llx_einvoicing_extrafields, of the mark left on a supplier invoice the import could + * not make total what the received document announces. Holds the announced BT-110 and BT-112, so + * the block it carries can be lifted the moment the invoice totals them (issue #861). + */ + const EXTRAFIELD_TOTALS_MISMATCH = 'import_totals_mismatch'; + + /** * Name, into llx_einvoicing_extrafields, of the order reference the supplier declared on the * invoice it sent (BT-13). Kept whether or not it matched a purchase order of Dolibarr. */ @@ -875,6 +882,9 @@ // Remove Dolibarr internal statuses unset($options[self::STATUS_UNKNOWN]); unset($options[self::STATUS_IGNORE]); + // STATUS_IGNORE_2 is commented out of STATUS_LABEL_KEYS, so the key is never there to + // begin with. The line stays for the day that entry is turned on again. + // @phpstan-ignore unset.offset unset($options[self::STATUS_IGNORE_2]); unset($options[self::STATUS_NOT_GENERATED]); } @@ -1049,6 +1059,15 @@ } } + // An invoice the import could not make total what the document announces is not one to + // approve: approving it commits to paying a figure the vendor did not bill (issue #861). + // Refusing it stays offered, which is the answer such a document deserves. + if (SupplierInvoiceHelper::totalsMismatchBlocks((int) $elementId)) { + foreach (self::STATUSES_ACCEPTING_A_DOCUMENT as $code) { + unset($statuses[$code]); + } + } + // A draft owes nothing yet: it is not in the accounts, cannot be paid, and validating it is // precisely the act of accepting it. Telling the vendor that its payment has been transmitted // at that point describes something that cannot have happened. The state comes from the @@ -1178,7 +1197,7 @@ /** * Validate thirdparty configuration * - * @param Societe $thirdparty Thirdparty object + * @param ?Societe $thirdparty Thirdparty object, null when the invoice carries no loaded thirdparty * @return array{res:int, message:string} Returns array with 'res' (1 on success, -1 on error and 0 on warning) and info 'message' */ public function validatethirdpartyConfiguration($thirdparty) @@ -1233,8 +1252,9 @@ if (empty($thirdparty->country_code)) { $baseErrors[] = $langs->trans("FxCheckErrorCustomerCountry"); } - // Check routing_id - $routing_id = $this->getBuyerCommunicationURI($thirdparty); + // Check routing_id. Without a loaded thirdparty there is no routing to read, and the missing + // name and professional id are already reported above. + $routing_id = is_object($thirdparty) ? $this->getBuyerCommunicationURI($thirdparty) : ''; // If EINVOICING_BLOCK_INVOICE_NO_ROUTING_ID is off, we use the profid as einvoice id and we already have the previous error message of // profid missing. But if on, we also add a message dedicated to einvoice ID. // Same reason as for the professional id above: a B2C third party is not addressed on the network, so @@ -1242,10 +1262,10 @@ if (getDolGlobalString('EINVOICING_BLOCK_INVOICE_NO_ROUTING_ID') && empty($routing_id) && !$isB2C) { $baseErrors[] = $langs->trans("FxCheckErrorCustomerRoutingID"); } - if ($thirdparty->tva_assuj && empty($thirdparty->tva_intra)) { + if (!empty($thirdparty->tva_assuj) && empty($thirdparty->tva_intra)) { // Test VAT code only if thirdparty is subject to VAT $baseWarnings[] = $langs->trans("FxCheckErrorCustomerVAT"); - } elseif ($thirdparty->tva_assuj && !empty($thirdparty->tva_intra) && !empty($thirdparty->country_code) && $thirdparty->country_code === 'FR') { + } elseif (!empty($thirdparty->tva_assuj) && !empty($thirdparty->tva_intra) && !empty($thirdparty->country_code) && $thirdparty->country_code === 'FR') { // Validate French intra-community VAT number format: FR + 2 alphanumeric characters + 9 digits (SIREN) $vatNormalized = strtoupper(removeAllSpaces($thirdparty->tva_intra)); if (!preg_match('/^FR[0-9A-Z]{2}[0-9]{9}$/', $vatNormalized)) { @@ -1352,7 +1372,7 @@ * Optional and non-blocking: an API timeout or unavailability is silently ignored (warning logged). * Only runs when EINVOICING_ENABLE_API_VALIDATION constant is set to 1. * - * @param Societe $thirdparty Thirdparty object to check + * @param ?Societe $thirdparty Thirdparty object to check, null when the invoice carries no loaded thirdparty * @return array{res:int, message:string} res=1 OK, res=0 warning, res=-1 blocking error (never returned by this method) */ private function _checkThirdpartyViaExternalAPIs($thirdparty) @@ -1468,6 +1488,43 @@ } } + // BR-25: every line of the document names what it invoices (BT-153). The name is built from the + // label of the product, or from the first line of the description when there is no product, so a + // line holding neither is issued with an empty name and the document is refused - and refused by + // the platform, after transmission, on a line number the seller then has to go and find. Every + // such line is listed here instead, before anything is sent. + // + // Title and subtotal lines are not concerned: they are pseudo-lines that never reach the + // document. A discount line is not concerned either, its name being built from the piece it + // deducts (see einvoicingDiscountLabel()). + // + // Customer invoices only, afterPDFCreation() gating on instanceof Facture: FactureFournisseurLigne + // fills ->description and not ->desc before 20.0, so extending this guard to supplier invoices + // needs a ?: $line->description or every free line of an 18.0/19.0 purchase invoice reads as + // having no name. + $linesWithNoName = []; + if (!empty($invoice->lines) && is_array($invoice->lines)) { + foreach ($invoice->lines as $line) { + if ((int) $line->product_type == 9 || !empty($line->fk_remise_except)) { + continue; + } + $hasLabel = trim((string) ($line->product_label ?? '')) !== ''; + $hasDesc = trim(dol_string_nohtmltag((string) ($line->desc ?? ''), 0)) !== ''; + if (!$hasLabel && !$hasDesc) { + // The rank places the line on the paper, the rowid is what a correction is addressed to. + // Naming both is what lets whoever reads this go straight to the line and fix it. + // FactureLigne and FactureFournisseurLigne both hold the rank, their common parent + // does not declare it, and this reads whichever of the two the invoice carries. + // @phan-suppress-next-line PhanUndeclaredProperty + $rank = (int) ($line->rang ?? 0); + $linesWithNoName[] = ($rank ? '#'.$rank : '').' (id '.((int) $line->id).')'; + } + } + } + if (!empty($linesWithNoName)) { + $baseErrors[] = $langs->trans("FxCheckErrorLinesWithNoName", implode(', ', $linesWithNoName)); + } + if (!empty($baseErrors)) { $res = -1; $message .= '
Error: ' . implode('
Error: ', $baseErrors); @@ -2765,7 +2822,11 @@ if (!is_object($object->thirdparty ?? null)) { $object->fetch_thirdparty(); } - $siren = is_object($object->thirdparty ?? null) ? preg_replace('/[^0-9]/', '', (string) $object->thirdparty->idprof1) : ''; + $thirdparty = $object->thirdparty; + if (!is_object($thirdparty)) { + return $res; // no recipient loaded: nothing to look up + } + $siren = preg_replace('/[^0-9]/', '', (string) $thirdparty->idprof1); if ($siren === '') { return $res; // no SIREN: the standard required-information checks handle this } @@ -2781,7 +2842,7 @@ // declare several reception addresses, only the one written into the document decides whether // the transmission is accepted. Same call as getBuyerCommunicationURI() makes at generation, so // what is checked and what is emitted can never drift apart. - $routingid = $this->getBuyerCommunicationURI($object->thirdparty, $object); + $routingid = $this->getBuyerCommunicationURI($thirdparty, $object); $dir = $provider->checkRecipientDirectory($siren, $routingid); $res['status'] = isset($dir['status']) ? $dir['status'] : 'error'; @@ -3477,8 +3538,52 @@ return $messages; } + /** + * Fetch the ordered lifecycle event history of a given element (all providers, all flows combined). + * + * Kept agnostic of the element type so the same reader serves customer invoices today and can serve + * supplier invoices later without a rewrite: both write to this table under their own 'element_type'. + * + * @param string $elementType Element type as stored in the table ('facture', 'invoice_supplier', ...) + * @param int $elementId Element id + * @return array{rowid:int,provider:string,flow_id:string,direction:string,lc_status:int,lc_status_message:string,lc_validation_status:string,lc_validation_message:string,lc_reason_code:string,date_creation:int}[] Ordered events (oldest first), empty array if none or on SQL error + */ + public function fetchLifecycleEvents($elementType, $elementId) + { + global $db; + $sql = "SELECT rowid, provider, flow_id, direction, lc_status, lc_status_message, lc_validation_status, lc_validation_message, lc_reason_code, date_creation"; + $sql .= " FROM " . $db->prefix() . "einvoicing_lifecycle_msg"; + $sql .= " WHERE element_type = '" . $db->escape($elementType) . "'"; + $sql .= " AND element_id = " . (int) $elementId; + $sql .= " ORDER BY date_creation ASC, rowid ASC"; + $resql = $db->query($sql); + if (!$resql) { + dol_syslog(__METHOD__ . ' SQL error: ' . $db->lasterror(), LOG_ERR); + return []; + } + + $events = []; + while ($obj = $db->fetch_object($resql)) { + $events[] = [ + 'rowid' => (int) $obj->rowid, + 'provider' => (string) $obj->provider, + 'flow_id' => (string) $obj->flow_id, + 'direction' => (string) $obj->direction, + 'lc_status' => (int) $obj->lc_status, + 'lc_status_message' => (string) $obj->lc_status_message, + 'lc_validation_status' => (string) $obj->lc_validation_status, + 'lc_validation_message' => (string) $obj->lc_validation_message, + 'lc_reason_code' => (string) $obj->lc_reason_code, + 'date_creation' => (int) $db->jdate($obj->date_creation), + ]; + } + $db->free($resql); + + return $events; + } + /** * Update validation information of an existing lifecycle status message. * @@ -3933,10 +4038,12 @@ * @used-by regenerate_einvoicing_fixtures.php For fixture generation * @used-by EInvoicingSamplesTest.php For comparison and regression testing * + * @param array $rawxmls Filled with the same five documents before normalization, + * for a caller that hands them to a validator * @return array Array with keys 'deposit', 'standard', and 'creditnote', * each containing normalized XML of the respective invoice type */ - public static function generateSampleEInvoicesForTests() + public static function generateSampleEInvoicesForTests(&$rawxmls = array()) { global $conf, $db, $langs; require_once DOL_DOCUMENT_ROOT . '/societe/class/societe.class.php'; @@ -4031,6 +4138,18 @@ $conf->global->TAX_MODE_SELL_SERVICE = $savTaxModeSellService; $langs = $savLangs; } + + // The same documents before normalization, for a caller that validates them: normalization + // flattens every date to one value, which makes the date rules of the French socle - + // BR-FR-CO-07, BR-FR-03, G1.07 - true whatever the document says. Handed back apart, so the + // returned array keeps holding five documents and nothing else. + $rawxmls = array( + 'deposit' => $depositXml, + 'standard' => $standardXml, + 'replacement' => $replacementXml, + 'creditnote' => $creditnoteXml, + 'situation' => $situationXml, + ); return array( 'deposit' => self::normalizeSampleInvoiceXml($depositXml), diff -ruN --exclude=vendor --exclude=.git _base_lf/class/einvoicingsyncpending.class.php cm_repo/einvoicing/class/einvoicingsyncpending.class.php --- _base_lf/class/einvoicingsyncpending.class.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/class/einvoicingsyncpending.class.php 2026-09-11 06:53:27 @@ -242,7 +242,7 @@ // which strtotime() does not parse: drop the fraction before converting. $ts = strtotime(preg_replace('/\.\d+/', '', (string) $flow['updatedAt'])); if ($ts !== false && $ts > 0) { - $updatedatSql = $ts; + $updatedatSql = (int) $ts; } } @@ -279,10 +279,12 @@ /** * Mark the queued row of a flow as resolved (the flow finally synchronized). * - * @param string $flowId PDP flow id - * @param string $provider Provider short key - * @param User $user User running the synchronization - * @return int 1 if a row was resolved, 0 if none, <0 on error + * @param string $flowId PDP flow id + * @param string $provider Provider short key + * @param User $user User running the synchronization + * @param string $elementType Element the flow created (e.g. 'invoice_supplier'), stored for traceability + * @param int $elementId Id of that element, stored for traceability + * @return int 1 if a row was resolved, 0 if none, <0 on error */ public function resolveByFlowId($flowId, $provider, User $user, $elementType = '', $elementId = 0) { diff -ruN --exclude=vendor --exclude=.git _base_lf/class/protocols/AbstractProtocol.class.php cm_repo/einvoicing/class/protocols/AbstractProtocol.class.php --- _base_lf/class/protocols/AbstractProtocol.class.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/class/protocols/AbstractProtocol.class.php 2026-09-11 06:39:46 @@ -79,6 +79,19 @@ abstract public function generateXML($invoice, $outputlangs = null); /** + * Generate the e-invoice file of a given invoice, and return where it was written. + * + * The entry point of a protocol: the hooks and the sample generation of CommonProtocol call it on + * whatever protocol the user selected, so every protocol has to answer to it. + * + * @param int|Facture $invoice_id Invoice id, or invoice object, to process + * @param ?Translate $outputlangs Output language + * @param string $sourceFilePath Source document the file is built from, when the format needs one + * @return -1|string -1 if ko, path of the generated file if ok + */ + abstract public function generateInvoice($invoice_id, $outputlangs = null, $sourceFilePath = ''); + + /** * Create a supplier invoice in Dolibarr from Factur-X content. * * This function parses the provided Factur-X XML content diff -ruN --exclude=vendor --exclude=.git _base_lf/class/protocols/CIIProtocol.class.php cm_repo/einvoicing/class/protocols/CIIProtocol.class.php --- _base_lf/class/protocols/CIIProtocol.class.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/class/protocols/CIIProtocol.class.php 2026-09-11 06:39:46 @@ -935,9 +935,17 @@ return ['res' => -1, 'message' => SupplierInvoiceHelper::refLookupErrorMessage($refDocInvoiceId, $refDoc, 'required by received document ' . ($parsedHeader['documentno'] ?? ''))]; } if ($refDocInvoiceId == 0) { - // The document references an invoice this Dolibarr does not hold. Nothing has been created at this - // point, so the flow is postponed and retried on the next synchronization rather than failed, and - // the message spells out what to create with a link to the screen that creates it. + // An unqualified reference (no ram:TypeCode in the XML) is a placeholder that the import + // does not consume — some vendors (e.g. DSV Road) always emit BG-3 with a dummy value + // such as "XXXX" when no preceding invoice applies. Skip it silently so it does not block + // the import and does not reach the post-creation loop. + if (empty($typeDoc)) { + dol_syslog(get_class($this) . '::doCreateSupplierInvoiceFromSource Skipping unqualified InvoiceReferencedDocument ref="' . $refDoc . '" (no TypeCode) for ' . ($parsedHeader['documentno'] ?? ''), LOG_DEBUG); + continue; + } + // The document references a qualified invoice this Dolibarr does not hold yet. Nothing has + // been created at this point, so the flow is postponed and retried on the next + // synchronization rather than failed, and the message spells out what to create. $langs->load("bills"); $action = $langs->trans('CreateTheMissingSupplierInvoiceToImport', $refDoc); $action .= ' '; @@ -1071,6 +1079,12 @@ return ['res' => -1, 'message' => SupplierInvoiceHelper::refLookupErrorMessage($linkedObjectId, $refDoc, 'required by received document ' . ($parsedHeader['documentno'] ?? ''))]; } if ($linkedObjectId == 0) { + // Unqualified references (no TypeCode) were already skipped by the pre-check above and + // should not reach this point. As a safety net, skip them here too rather than failing. + if (empty($typeDoc)) { + dol_syslog(get_class($this) . '::doCreateSupplierInvoiceFromSource Skipping unqualified InvoiceReferencedDocument ref="' . $refDoc . '" (no TypeCode) in post-creation loop for ' . ($parsedHeader['documentno'] ?? ''), LOG_DEBUG); + continue; + } return ['res' => -1, 'message' => 'Document ' . dol_escape_htmltag((string) $refDoc) . ', required by received document ' . dol_escape_htmltag((string) ($parsedHeader['documentno'] ?? '')) . ', was not found in Dolibarr']; } @@ -1616,7 +1630,9 @@ * updateline() recomputes the totals from those three, so BT-131 is never stored as such. A line that is not a * DETAIL item (BT-X-8) carries no amount and becomes a text line. A regular item whose quantity times price * cannot express BT-131 - zero quantity, zero price, opposite sign - carries BT-131 as a single unit instead - * (issues #726 and #772). Anything else is checked against BT-131 and reported when it does not match. + * (issues #726 and #772). Any other line whose quantity times price is not BT-131 is imported at BT-131, at + * the unit price that totals it (issues #844 and #850), and what was rewritten is reported. The charges of + * the line (BG-28) are out of all this: they leave on lines of their own. * * @param array $parsedLine One line as parseInvoiceLines() returns it * @param float $qty Quantity read from the document (BT-129) @@ -1627,8 +1643,13 @@ protected function resolveLineAmounts(array $parsedLine, $qty, $subprice, $remisePercent) { $lineid = (string) ($parsedLine['lineid'] ?? '?'); - $announced = round((float) ($parsedLine['lineTotalAmount'] ?? 0), 2); + // A line charge (BG-28) is part of BT-131 but rejoins the invoice as a line of its own (issue #735), + // so what this line has to total is BT-131 less those charges. + $charges = $this->lineChargeTotal($parsedLine); + $announced = round((float) ($parsedLine['lineTotalAmount'] ?? 0) - $charges, 2); + $announcedText = $announced . ($charges == 0.0 ? '' : ' (BT-131 less the charges that leave on their own line)'); + if (!$this->isDetailLine($parsedLine)) { return array('qty' => 0.0, 'subprice' => 0.0, 'remise_percent' => 0.0, 'warning' => ''); } @@ -1649,27 +1670,57 @@ $reason = 'its quantity (BT-129) and unit price (BT-146) rebuild ' . $rebuilt . ', of the opposite sign'; } - $warning = 'Line ' . $lineid . ' of the received document carries a net amount (BT-131) of ' . $announced + $warning = 'Line ' . $lineid . ' of the received document carries a net amount (BT-131) of ' . $announcedText . ' while ' . $reason . '. It was imported as a single unit at that amount, so the total of the invoice matches the document.'; return array('qty' => 1.0, 'subprice' => $announced, 'remise_percent' => 0.0, 'warning' => $warning); } + // BT-131 is what the line is worth: the totals of the document are summed from it (BR-CO-10, BR-CO-13) + // and no rule ties it to quantity times price. So a line whose couple rebuilds another amount is imported + // at the one announced, with the unit price that totals it: the six decimals of a price cannot state it + // over a large quantity (issue #844), or the issuer simply prices the line elsewhere than it bills it + // (issue #850). Only a line announcing nothing keeps what its price rebuilds. + $difference = abs($rebuilt - $announced); + $divisor = $qty * (1 - ($remisePercent / 100)); + $fromPriceRounding = (abs($divisor) * 0.5 / pow(10, self::MAX_DECIMALS_UNIT_PRICE)) + 0.01; + $warning = ''; - if (abs($rebuilt - $announced) > 0.01) { - $warning = 'Line ' . $lineid . ' of the received document announces a net amount (BT-131) of ' . $announced + $refined = false; + if (!empty($announced) && $difference > 0.01 && $divisor != 0.0) { + $subprice = $announced / $divisor; + $refined = true; + + $warning = 'Line ' . $lineid . ' of the received document announces a net amount (BT-131) of ' . $announcedText + . ', while its quantity (BT-129) and unit price (BT-146) rebuild ' . $rebuilt . '. '; + + if ($difference <= $fromPriceRounding) { + $warning .= 'That difference is within the ' . self::MAX_DECIMALS_UNIT_PRICE + . ' decimals a unit price is written with (BR-FR-DEC-03), so the price was refined to ' + . $this->spellOutUnitPrice($subprice) . ' and the line totals the amount announced.'; + } else { + $warning .= 'The document prices that line elsewhere than it bills it: the line was imported at the amount announced, at a unit price of ' + . $this->spellOutUnitPrice($subprice) . ', so the invoice totals what the document bills.'; + } + } elseif ($difference > 0.01) { + $warning = 'Line ' . $lineid . ' of the received document announces a net amount (BT-131) of ' . $announcedText . ', but its quantity and unit price rebuild ' . $rebuilt . '. The invoice carries the rebuilt amount.'; - } elseif ($subprice != 0.0 && (float) price2num($subprice, 'MU') == 0.0) { - // calcul_price_total() rounds only the pu_ht copy it stores, to MAIN_MAX_DECIMALS_UNIT: a price stated - // per 100 000 (issue #777) totals what the document announces and still reaches the line as 0.00000. - // The import is right, but reopening and saving the line would recompute it to zero, so say so now. - // Spell the price out in full: PHP writes such a float as 1.34195E-6, which reads as a typo. - $spelled = rtrim(rtrim(number_format($subprice, 12, '.', ''), '0'), '.'); + } - $warning = 'Line ' . $lineid . ' of the received document prices a single unit at ' . $spelled + // calcul_price_total() totals the line from the price it is handed, but stores a copy of that price rounded + // to MAIN_MAX_DECIMALS_UNIT: a price stated per 100 000 (issue #777) or refined above reaches the line as + // 0.00000. The amount imported is the one announced, but reopening and saving the line would recompute it + // from the stored price, so say so now rather than let it be found out. + $stored = (float) price2num($subprice, 'MU'); + $reopened = round($qty * $stored * (1 - ($remisePercent / 100)), 2); + if (($warning === '' || $refined) && abs($reopened - $announced) > 0.001) { + $warning = trim($warning . ' Line ' . $lineid . ' of the received document prices a single unit at ' + . $this->spellOutUnitPrice($subprice) . ', below the unit price precision of this Dolibarr (MAIN_MAX_DECIMALS_UNIT = ' - . getDolGlobalInt('MAIN_MAX_DECIMALS_UNIT') . '). Its net amount (BT-131) of ' . $announced - . ' is imported as announced, but the unit price is stored as zero: editing that line would recompute it to 0.00.'; + . getDolGlobalInt('MAIN_MAX_DECIMALS_UNIT') . '). Its net amount (BT-131) of ' . $announcedText + . ' is imported as announced, but the unit price is stored as ' + . number_format($stored, getDolGlobalInt('MAIN_MAX_DECIMALS_UNIT'), '.', '') + . ': editing that line would recompute it to ' . number_format($reopened, 2, '.', '') . '.'); } return array('qty' => $qty, 'subprice' => $subprice, 'remise_percent' => $remisePercent, 'warning' => $warning); @@ -1677,6 +1728,47 @@ /** + * Total of the charges of a line (BG-28), the part of BT-131 that leaves on a line of its own. + * + * buildLineChargeLines() gives every charge of the line a Dolibarr line, so the line itself is worth + * BT-131 less that total: comparing the whole of BT-131 to what quantity times price rebuilds would + * count the charge twice (issue #735). + * + * @param array $parsedLine One line as parseInvoiceLines() returns it + * @return float Sum of the charges of the line, zero when it has none + */ + protected function lineChargeTotal(array $parsedLine) + { + if (empty($parsedLine['lineAllowances']) || !is_array($parsedLine['lineAllowances'])) { + return 0.0; + } + + $total = 0.0; + foreach ($parsedLine['lineAllowances'] as $allowanceCharge) { + if (($allowanceCharge['indicator'] ?? '') === 'true') { + $total += (float) ($allowanceCharge['actualAmount'] ?? 0); + } + } + + return $total; + } + + + /** + * Write a unit price out in full, for a message a human reads. + * + * PHP writes a price of that size as 1.34195E-6, which reads as a typo, and price() would round it away. + * + * @param float $subprice Unit price to spell out + * @return string The same price, in plain digits + */ + private function spellOutUnitPrice($subprice) + { + return rtrim(rtrim(number_format($subprice, 12, '.', ''), '0'), '.'); + } + + + /** * Tell whether a parsed line is a regular invoice item, the only kind that carries an amount. * * The EXTENDED profile adds the BT-X-8 subtype on ram:LineStatusReasonCode. BR-FREXT-BR-22 requires the @@ -1928,6 +2020,7 @@ 'IssuerAssignedID' => $this->getXPathValue($xpath, 'ram:IssuerAssignedID', $n), 'issueDate' => $this->normDate($this->getXPathValue($xpath, 'ram:FormattedIssueDateTime/qdt:DateTimeString', $n) ?? $this->getXPathValue($xpath, 'ram:IssueDateTime/udt:DateTimeString', $n)), + 'TypeCode' => $this->getXPathValue($xpath, 'ram:TypeCode', $n), ]; break; @@ -2052,7 +2145,7 @@ /** * Build CII XML from invoice data. * - * Every value taken from the invoice or the company data is passed through htmlspecialchars() before it is + * Every value taken from the invoice or the company data is passed through einvoicingXmlText() before it is * handed to DOMDocument::createElement(): that method parses its second argument, so a value holding an * ampersand produces an EMPTY element and silently loses the business term - issue #695. * @@ -2112,7 +2205,7 @@ if (!empty($invoiceData['businessProcessId'])) { $bp = $doc->createElement('ram:BusinessProcessSpecifiedDocumentContextParameter'); $ctx->appendChild($bp); - $bp->appendChild($doc->createElement('ram:ID', htmlspecialchars((string) $invoiceData['businessProcessId']))); + $bp->appendChild($doc->createElement('ram:ID', einvoicingXmlText((string) $invoiceData['businessProcessId']))); } $profile = !empty($profile) ? strtoupper($profile) : 'EXTENDED'; @@ -2142,8 +2235,8 @@ $exDoc = $doc->createElement('rsm:ExchangedDocument'); $root->appendChild($exDoc); - $exDoc->appendChild($doc->createElement('ram:ID', htmlspecialchars((string) $invoiceData['documentno']))); - $exDoc->appendChild($doc->createElement('ram:TypeCode', htmlspecialchars((string) $invoiceData['documenttypecode']))); + $exDoc->appendChild($doc->createElement('ram:ID', einvoicingXmlText((string) $invoiceData['documentno']))); + $exDoc->appendChild($doc->createElement('ram:TypeCode', einvoicingXmlText((string) $invoiceData['documenttypecode']))); // Date $issueDT = $doc->createElement('ram:IssueDateTime'); @@ -2163,30 +2256,30 @@ if (!empty($invoiceData['documentNotePublic'])) { $note = $doc->createElement('ram:IncludedNote'); $exDoc->appendChild($note); - $note->appendChild($doc->createElement('ram:Content', htmlspecialchars($invoiceData['documentNotePublic']))); + $note->appendChild($doc->createElement('ram:Content', einvoicingXmlText($invoiceData['documentNotePublic']))); } if (!empty($invoiceData['documentNotePMT'])) { $note = $doc->createElement('ram:IncludedNote'); $exDoc->appendChild($note); - $note->appendChild($doc->createElement('ram:Content', htmlspecialchars($invoiceData['documentNotePMT']))); + $note->appendChild($doc->createElement('ram:Content', einvoicingXmlText($invoiceData['documentNotePMT']))); $note->appendChild($doc->createElement('ram:SubjectCode', 'PMT')); } if (!empty($invoiceData['documentNotePMD'])) { $note = $doc->createElement('ram:IncludedNote'); $exDoc->appendChild($note); - $note->appendChild($doc->createElement('ram:Content', htmlspecialchars($invoiceData['documentNotePMD']))); + $note->appendChild($doc->createElement('ram:Content', einvoicingXmlText($invoiceData['documentNotePMD']))); $note->appendChild($doc->createElement('ram:SubjectCode', 'PMD')); } if (!empty($invoiceData['documentNoteAAB'])) { $note = $doc->createElement('ram:IncludedNote'); $exDoc->appendChild($note); - $note->appendChild($doc->createElement('ram:Content', htmlspecialchars($invoiceData['documentNoteAAB']))); + $note->appendChild($doc->createElement('ram:Content', einvoicingXmlText($invoiceData['documentNoteAAB']))); $note->appendChild($doc->createElement('ram:SubjectCode', 'AAB')); } if (!empty($invoiceData['documentNoteTXD'])) { $note = $doc->createElement('ram:IncludedNote'); $exDoc->appendChild($note); - $note->appendChild($doc->createElement('ram:Content', htmlspecialchars($invoiceData['documentNoteTXD']))); + $note->appendChild($doc->createElement('ram:Content', einvoicingXmlText($invoiceData['documentNoteTXD']))); $note->appendChild($doc->createElement('ram:SubjectCode', 'TXD')); } } @@ -2232,7 +2325,7 @@ $comment = $doc->createComment('Buyer reference (BT-10)'); $agreement->appendChild($comment); - $agreement->appendChild($doc->createElement('ram:BuyerReference', htmlspecialchars($invoiceData['buyerReference']))); + $agreement->appendChild($doc->createElement('ram:BuyerReference', einvoicingXmlText($invoiceData['buyerReference']))); } // Seller @@ -2256,7 +2349,7 @@ $buyerOrderRef = $doc->createElement('ram:BuyerOrderReferencedDocument'); $agreement->appendChild($buyerOrderRef); - $buyerOrderRef->appendChild($doc->createElement('ram:IssuerAssignedID', htmlspecialchars($invoiceData['orderReference']))); + $buyerOrderRef->appendChild($doc->createElement('ram:IssuerAssignedID', einvoicingXmlText($invoiceData['orderReference']))); } // Contract reference (BT-12): the contract the invoice is issued under. ram:ContractReferencedDocument @@ -2268,21 +2361,22 @@ $contractRef = $doc->createElement('ram:ContractReferencedDocument'); $agreement->appendChild($contractRef); - $contractRef->appendChild($doc->createElement('ram:IssuerAssignedID', htmlspecialchars($invoiceData['contractReference']))); + $contractRef->appendChild($doc->createElement('ram:IssuerAssignedID', einvoicingXmlText($invoiceData['contractReference']))); } // Additional order references: when an invoice covers several purchase orders, the first is emitted as BT-13 // (BuyerOrderReferencedDocument above) and the others are listed here as AdditionalReferencedDocument/TypeCode=130. - // Restricted to profiles that carry AdditionalReferencedDocument in the agreement section (not MINIMUM), and - // skipped for Chorus (which does not accept these extra nodes). Sequence position: after + // ram:AdditionalReferencedDocument is only declared in the agreement section from EN16931 up - MINIMUM, BASIC WL + // and BASIC do not have it - and Chorus does not accept these extra nodes. Sequence position: after // ContractReferencedDocument, before SpecifiedProcuringProject (CII schema order). - if (!$invoiceData['_chorus'] && $profile !== 'MINIMUM' && !empty($invoiceData['_customerOrderReferenceList'])) { + if (!$invoiceData['_chorus'] && $this->isEn16931Profile($profile) && !empty($invoiceData['_customerOrderReferenceList'])) { foreach ($invoiceData['_customerOrderReferenceList'] as $additionalOrderRef) { - if ($additionalOrderRef === $invoiceData['orderReference']) { + // A blank reference would become an empty BT-18, which BR-52 rejects as fatal + if ($additionalOrderRef === $invoiceData['orderReference'] || trim((string) $additionalOrderRef) === '') { continue; } $addRef = $doc->createElement('ram:AdditionalReferencedDocument'); - $addRef->appendChild($doc->createElement('ram:IssuerAssignedID', htmlspecialchars($additionalOrderRef))); + $addRef->appendChild($doc->createElement('ram:IssuerAssignedID', einvoicingXmlText($additionalOrderRef))); $addRef->appendChild($doc->createElement('ram:TypeCode', '130')); $agreement->appendChild($addRef); } @@ -2299,9 +2393,9 @@ $procuringProject = $doc->createElement('ram:SpecifiedProcuringProject'); $agreement->appendChild($procuringProject); - $procuringProject->appendChild($doc->createElement('ram:ID', htmlspecialchars((string) $project->ref))); + $procuringProject->appendChild($doc->createElement('ram:ID', einvoicingXmlText((string) $project->ref))); $projectName = trim((string) $project->title); - $procuringProject->appendChild($doc->createElement('ram:Name', htmlspecialchars($projectName !== '' ? $projectName : (string) $project->ref))); + $procuringProject->appendChild($doc->createElement('ram:Name', einvoicingXmlText($projectName !== '' ? $projectName : (string) $project->ref))); } @@ -2380,11 +2474,11 @@ $pm = $doc->createElement('ram:SpecifiedTradeSettlementPaymentMeans'); $settlement->appendChild($pm); - $pm->appendChild($doc->createElement('ram:TypeCode', htmlspecialchars((string) $invoiceData['paymentMeansCode']))); // A code for payment type BT-81 (BG-16) + $pm->appendChild($doc->createElement('ram:TypeCode', einvoicingXmlText((string) $invoiceData['paymentMeansCode']))); // A code for payment type BT-81 (BG-16) // BT-82 and BT-85 below are optional: emit them only when they carry something, // an empty element being refused by PEPPOL-EN16931-R008 (issue #695). if ($this->isEn16931Profile($profile) && !empty($invoiceData['paymentMeansText'])) { - $pm->appendChild($doc->createElement('ram:Information', htmlspecialchars((string) $invoiceData['paymentMeansText']))); // A label for payment type BT-82 + $pm->appendChild($doc->createElement('ram:Information', einvoicingXmlText((string) $invoiceData['paymentMeansText']))); // A label for payment type BT-82 } $acc = $doc->createElement('ram:PayeePartyCreditorFinancialAccount'); @@ -2392,7 +2486,7 @@ // CII XSD order for CreditorFinancialAccountType: IBANID, AccountName, ProprietaryID if (!empty($invoiceData['iban'])) { - $acc->appendChild($doc->createElement('ram:IBANID', htmlspecialchars((string) $invoiceData['iban']))); // BT-84 + $acc->appendChild($doc->createElement('ram:IBANID', einvoicingXmlText((string) $invoiceData['iban']))); // BT-84 } else { // If no IBAN provided if ($invoiceData['paymentMeansCode'] == 30) { // If payment by credit transfer @@ -2404,15 +2498,15 @@ } } if ($this->isEn16931Profile($profile) && !empty($invoiceData['accountName'])) { - $acc->appendChild($doc->createElement('ram:AccountName', htmlspecialchars((string) $invoiceData['accountName']))); // BT-85 + $acc->appendChild($doc->createElement('ram:AccountName', einvoicingXmlText((string) $invoiceData['accountName']))); // BT-85 } if (empty($invoiceData['iban']) && !empty($invoiceData['accountRef'])) { // If IBAN unknown we can fallback on the private ref. - $acc->appendChild($doc->createElement('ram:ProprietaryID', htmlspecialchars((string) $invoiceData['accountRef']))); // BT-84-0 + $acc->appendChild($doc->createElement('ram:ProprietaryID', einvoicingXmlText((string) $invoiceData['accountRef']))); // BT-84-0 } if (!empty($invoiceData['bic']) && $this->isEn16931Profile($profile)) { $inst = $doc->createElement('ram:PayeeSpecifiedCreditorFinancialInstitution'); $pm->appendChild($inst); - $inst->appendChild($doc->createElement('ram:BICID', htmlspecialchars((string) $invoiceData['bic']))); // BT-86 + $inst->appendChild($doc->createElement('ram:BICID', einvoicingXmlText((string) $invoiceData['bic']))); // BT-86 } } @@ -2466,7 +2560,7 @@ $terms = $doc->createElement('ram:SpecifiedTradePaymentTerms'); $settlement->appendChild($terms); - $terms->appendChild($doc->createElement('ram:Description', htmlspecialchars((string) $invoiceData['paymentTermsText']))); + $terms->appendChild($doc->createElement('ram:Description', einvoicingXmlText((string) $invoiceData['paymentTermsText']))); // Due date is optional (e.g. immediate payment); guard against a null date to avoid a fatal on ->format(). if (!empty($invoiceData['paymentDueDate'])) { @@ -2515,13 +2609,13 @@ foreach ($invoiceData['invoiceRefDocs'] as $refDoc) { $refNode = $doc->createElement('ram:InvoiceReferencedDocument'); - $refNode->appendChild($doc->createElement('ram:IssuerAssignedID', htmlspecialchars((string) $refDoc['ref']))); + $refNode->appendChild($doc->createElement('ram:IssuerAssignedID', einvoicingXmlText((string) $refDoc['ref']))); // The document type (BT-X-...) is a fatal error under EN 16931, where CII-DT-018 only // tolerates a TypeCode on an AdditionalReferencedDocument. The EXTENDED and // EXTENDED-CTC-FR profiles reinstate it (CII-EXT-DT-018). if ($this->isExtendedProfile($profile)) { - $refNode->appendChild($doc->createElement('ram:TypeCode', htmlspecialchars((string) $refDoc['type']))); + $refNode->appendChild($doc->createElement('ram:TypeCode', einvoicingXmlText((string) $refDoc['type']))); } // The issue date of the preceding invoice (BT-26) is part of BG-3 in EN 16931, where @@ -2569,19 +2663,19 @@ // ID $docLine = $doc->createElement('ram:AssociatedDocumentLineDocument'); $el->appendChild($docLine); - $docLine->appendChild($doc->createElement('ram:LineID', htmlspecialchars((string) $line['lineid']))); + $docLine->appendChild($doc->createElement('ram:LineID', einvoicingXmlText((string) $line['lineid']))); // Product $prod = $doc->createElement('ram:SpecifiedTradeProduct'); $el->appendChild($prod); if (!empty($line['prodsellerid'])) { $prod->appendChild( - $doc->createElement('ram:SellerAssignedID', htmlspecialchars((string) $line['prodsellerid'])) + $doc->createElement('ram:SellerAssignedID', einvoicingXmlText((string) $line['prodsellerid'])) ); } - $prod->appendChild($doc->createElement('ram:Name', htmlspecialchars($line['prodname']))); + $prod->appendChild($doc->createElement('ram:Name', einvoicingXmlText($line['prodname']))); if (!empty($line['proddesc'])) { - $prod->appendChild($doc->createElement('ram:Description', htmlspecialchars($line['proddesc']))); + $prod->appendChild($doc->createElement('ram:Description', einvoicingXmlText($line['proddesc']))); } // Price @@ -2635,13 +2729,13 @@ $lineExemptionReason = (string) ($line['ExemptionReason'] ?? ''); $lineExemptionReasonCode = (string) ($line['ExemptionReasonCode'] ?? ''); if ($lineExemptionReason !== '') { - $tax->appendChild($doc->createElement('ram:ExemptionReason', htmlspecialchars($lineExemptionReason))); + $tax->appendChild($doc->createElement('ram:ExemptionReason', einvoicingXmlText($lineExemptionReason))); } - $tax->appendChild($doc->createElement('ram:CategoryCode', htmlspecialchars((string) $line['categoryCode']))); + $tax->appendChild($doc->createElement('ram:CategoryCode', einvoicingXmlText((string) $line['categoryCode']))); if ($lineExemptionReasonCode !== '') { - $tax->appendChild($doc->createElement('ram:ExemptionReasonCode', htmlspecialchars((string) $lineExemptionReasonCode))); + $tax->appendChild($doc->createElement('ram:ExemptionReasonCode', einvoicingXmlText((string) $lineExemptionReasonCode))); } - $tax->appendChild($doc->createElement('ram:RateApplicablePercent', htmlspecialchars((string) $line['rateApplicablePercent']))); + $tax->appendChild($doc->createElement('ram:RateApplicablePercent', einvoicingXmlText((string) $line['rateApplicablePercent']))); // Billing period for the line (BG-26 / BT-134 / BT-135). Must be placed after ApplicableTradeTax // and before SpecifiedTradeAllowanceCharge (discount below) per the CII D22B schema sequence. @@ -2671,7 +2765,7 @@ if (!empty($line['isDepositLine'])) { $refNode = $doc->createElement('ram:AdditionalReferencedDocument'); - $refNode->appendChild($doc->createElement('ram:IssuerAssignedID', htmlspecialchars((string) $line['depositInvoiceRef']))); + $refNode->appendChild($doc->createElement('ram:IssuerAssignedID', einvoicingXmlText((string) $line['depositInvoiceRef']))); $refNode->appendChild($doc->createElement('ram:TypeCode', '130')); if (!empty($line['depositInvoiceDate']) && $profile === 'EXTENDED') { @@ -2719,12 +2813,15 @@ if (!$this->isMinimumProfile($profile)) { if (!empty($data[$prefix . 'GlobalIds'])) { foreach ($data[$prefix . 'GlobalIds'] as $globalId) { - $g = $doc->createElement('ram:GlobalID', htmlspecialchars((string) $globalId['value'])); + $g = $doc->createElement('ram:GlobalID', einvoicingXmlText((string) $globalId['value'])); $g->setAttribute('schemeID', $globalId['schemeID']); $node->appendChild($g); } - } else { - $node->appendChild($doc->createElement('ram:ID', htmlspecialchars((string) $data[$prefix . 'ids']))); + } elseif (!empty($data[$prefix . 'ids'])) { + // The ram:ID variant of the same term. Nothing to write when the party identifier is + // deliberately not declared: BT-29 and BT-46 are optional, and an empty element would + // be refused by PEPPOL-EN16931-R008. + $node->appendChild($doc->createElement('ram:ID', einvoicingXmlText((string) $data[$prefix . 'ids']))); } // Routing code of the buyer (BT-46 under scheme 0224), where BR-FR-CPRO-11 and BR-FR-CPRO-13 read @@ -2732,19 +2829,19 @@ // profiles accept (FX-SCH-A-000164 caps that element at one occurrence below them), and it belongs // to the buyer alone: on the deliver-to party the identifier is BT-71, a location (issue #678). if ($type === 'buyer' && !$minimal && $this->isExtendedProfile($profile) && !empty($data['buyerRoutingCode'])) { - $routing = $doc->createElement('ram:GlobalID', htmlspecialchars($data['buyerRoutingCode'])); + $routing = $doc->createElement('ram:GlobalID', einvoicingXmlText($data['buyerRoutingCode'])); $routing->setAttribute('schemeID', EInvoicing::SCHEME_FR_ROUTING_CODE); $node->appendChild($routing); } } - $node->appendChild($doc->createElement('ram:Name', htmlspecialchars($data[$prefix . 'name']))); + $node->appendChild($doc->createElement('ram:Name', einvoicingXmlText($data[$prefix . 'name']))); // Legal org if (!$minimal) { $legal = $doc->createElement('ram:SpecifiedLegalOrganization'); $node->appendChild($legal); - $id = $doc->createElement('ram:ID', htmlspecialchars((string) $data[$prefix . 'LegalOrgId'])); + $id = $doc->createElement('ram:ID', einvoicingXmlText((string) $data[$prefix . 'LegalOrgId'])); $id->setAttribute('schemeID', $data[$prefix . 'LegalOrgScheme']); $legal->appendChild($id); // LegalOrganizationType is reduced to ram:ID by the MINIMUM schema. @@ -2753,7 +2850,7 @@ // nothing at all: an empty element would be refused by PEPPOL-EN16931-R008 (issue #695). if (!$this->isMinimumProfile($profile) && !empty($data[$prefix . 'TradingName'])) { $legal->appendChild( - $doc->createElement('ram:TradingBusinessName', htmlspecialchars((string) $data[$prefix . 'TradingName'])) + $doc->createElement('ram:TradingBusinessName', einvoicingXmlText((string) $data[$prefix . 'TradingName'])) ); } } @@ -2773,17 +2870,17 @@ $node->appendChild($contact); if (!empty($data[$prefix . 'contactpersonname'])) { - $contact->appendChild($doc->createElement('ram:PersonName', htmlspecialchars($data[$prefix . 'contactpersonname']))); + $contact->appendChild($doc->createElement('ram:PersonName', einvoicingXmlText($data[$prefix . 'contactpersonname']))); } if (!empty($data[$prefix . 'contactdepartmentname'])) { - $contact->appendChild($doc->createElement('ram:DepartmentName', htmlspecialchars($data[$prefix . 'contactdepartmentname']))); + $contact->appendChild($doc->createElement('ram:DepartmentName', einvoicingXmlText($data[$prefix . 'contactdepartmentname']))); } if (!empty($data[$prefix . 'contactphoneno'])) { $phone = $doc->createElement('ram:TelephoneUniversalCommunication'); $contact->appendChild($phone); - $phone->appendChild($doc->createElement('ram:CompleteNumber', htmlspecialchars((string) $data[$prefix . 'contactphoneno']))); + $phone->appendChild($doc->createElement('ram:CompleteNumber', einvoicingXmlText((string) $data[$prefix . 'contactphoneno']))); } // No ram:FaxUniversalCommunication here on purpose, see the comment above. The @@ -2793,7 +2890,7 @@ if (!empty($data[$prefix . 'contactemailaddr'])) { $email = $doc->createElement('ram:EmailURIUniversalCommunication'); $contact->appendChild($email); - $email->appendChild($doc->createElement('ram:URIID', htmlspecialchars((string) $data[$prefix . 'contactemailaddr']))); + $email->appendChild($doc->createElement('ram:URIID', einvoicingXmlText((string) $data[$prefix . 'contactemailaddr']))); } } @@ -2804,28 +2901,28 @@ // TradeAddressType is reduced to the country code by the MINIMUM schema if (!$this->isMinimumProfile($profile)) { - $addr->appendChild($doc->createElement('ram:PostcodeCode', htmlspecialchars((string) $data[$prefix . 'postcode']))); + $addr->appendChild($doc->createElement('ram:PostcodeCode', einvoicingXmlText((string) $data[$prefix . 'postcode']))); // The three address lines the norm has: BT-35/36/162 for the seller, BT-50/51/163 for the // buyer. XSD order inside TradeAddressType is PostcodeCode, LineOne, LineTwo, LineThree, // CityName, CountryID - the elements are written in that order and nowhere else. if (!empty($data[$prefix . 'lineone'])) { - $addr->appendChild($doc->createElement('ram:LineOne', htmlspecialchars($data[$prefix . 'lineone']))); + $addr->appendChild($doc->createElement('ram:LineOne', einvoicingXmlText($data[$prefix . 'lineone']))); } if (!empty($data[$prefix . 'linetwo'])) { - $addr->appendChild($doc->createElement('ram:LineTwo', htmlspecialchars($data[$prefix . 'linetwo']))); + $addr->appendChild($doc->createElement('ram:LineTwo', einvoicingXmlText($data[$prefix . 'linetwo']))); } if (!empty($data[$prefix . 'linethree'])) { - $addr->appendChild($doc->createElement('ram:LineThree', htmlspecialchars($data[$prefix . 'linethree']))); + $addr->appendChild($doc->createElement('ram:LineThree', einvoicingXmlText($data[$prefix . 'linethree']))); } - $addr->appendChild($doc->createElement('ram:CityName', htmlspecialchars($data[$prefix . 'city']))); + $addr->appendChild($doc->createElement('ram:CityName', einvoicingXmlText($data[$prefix . 'city']))); } - $addr->appendChild($doc->createElement('ram:CountryID', htmlspecialchars((string) $data[$prefix . 'country']))); + $addr->appendChild($doc->createElement('ram:CountryID', einvoicingXmlText((string) $data[$prefix . 'country']))); // URIUniversalCommunication. Not declared by the MINIMUM schema. if (!$minimal && !$this->isMinimumProfile($profile) && !empty($data[$prefix . 'CommunicationUriScheme']) && !empty($data[$prefix . 'CommunicationUri'])) { $uri = $doc->createElement('ram:URIUniversalCommunication'); $node->appendChild($uri); - $uriid = $doc->createElement('ram:URIID', htmlspecialchars((string) $data[$prefix . 'CommunicationUri'])); // Example 315143296_1939 + $uriid = $doc->createElement('ram:URIID', einvoicingXmlText((string) $data[$prefix . 'CommunicationUri'])); // Example 315143296_1939 $uriid->setAttribute('schemeID', $data[$prefix . 'CommunicationUriScheme']); // Example 0225 $uri->appendChild($uriid); } @@ -2850,7 +2947,7 @@ foreach ($registrations as $registration) { $tax = $doc->createElement('ram:SpecifiedTaxRegistration'); - $id = $doc->createElement('ram:ID', htmlspecialchars((string) $registration['value'])); + $id = $doc->createElement('ram:ID', einvoicingXmlText((string) $registration['value'])); $id->setAttribute('schemeID', $registration['type']); $tax->appendChild($id); $node->appendChild($tax); @@ -2895,7 +2992,7 @@ // BT-70 Deliver-to party name (optional). if (!empty($ship['name'])) { - $node->appendChild($doc->createElement('ram:Name', htmlspecialchars($ship['name']))); + $node->appendChild($doc->createElement('ram:Name', einvoicingXmlText($ship['name']))); } // BG-15 Deliver-to address. @@ -2904,7 +3001,7 @@ // CII XSD order for PostalTradeAddress: PostcodeCode BEFORE LineOne (counter-intuitive). if (!empty($ship['zip'])) { - $addr->appendChild($doc->createElement('ram:PostcodeCode', htmlspecialchars((string) $ship['zip']))); + $addr->appendChild($doc->createElement('ram:PostcodeCode', einvoicingXmlText((string) $ship['zip']))); } // BT-75/76/165: the deliver-to address has three lines too. The caller splits the free text // field Dolibarr stores; a single-line address keeps landing on LineOne alone. @@ -2915,14 +3012,14 @@ ); foreach (array('ram:LineOne', 'ram:LineTwo', 'ram:LineThree') as $rank => $element) { if (!empty($shiplines[$rank])) { - $addr->appendChild($doc->createElement($element, htmlspecialchars($shiplines[$rank]))); + $addr->appendChild($doc->createElement($element, einvoicingXmlText($shiplines[$rank]))); } } if (!empty($ship['town'])) { - $addr->appendChild($doc->createElement('ram:CityName', htmlspecialchars($ship['town']))); + $addr->appendChild($doc->createElement('ram:CityName', einvoicingXmlText($ship['town']))); } // CountryID is mandatory whenever the address block exists (BR-57). Guaranteed non-empty here. - $addr->appendChild($doc->createElement('ram:CountryID', htmlspecialchars((string) $ship['country']))); + $addr->appendChild($doc->createElement('ram:CountryID', einvoicingXmlText((string) $ship['country']))); return $node; } @@ -2980,19 +3077,19 @@ $tax->appendChild($doc->createElement('ram:TypeCode', 'VAT')); if ($vals['ExemptionReason']) { - $tax->appendChild($doc->createElement('ram:ExemptionReason', htmlspecialchars((string) $vals['ExemptionReason']))); + $tax->appendChild($doc->createElement('ram:ExemptionReason', einvoicingXmlText((string) $vals['ExemptionReason']))); } $tax->appendChild($doc->createElement('ram:BasisAmount', number_format($vals['totalHT'], 2, '.', ''))); - $tax->appendChild($doc->createElement('ram:CategoryCode', htmlspecialchars((string) $vals['categoryVAT']))); + $tax->appendChild($doc->createElement('ram:CategoryCode', einvoicingXmlText((string) $vals['categoryVAT']))); if ($vals['ExemptionReasonCode']) { - $tax->appendChild($doc->createElement('ram:ExemptionReasonCode', htmlspecialchars((string) $vals['ExemptionReasonCode']))); + $tax->appendChild($doc->createElement('ram:ExemptionReasonCode', einvoicingXmlText((string) $vals['ExemptionReasonCode']))); } if (!empty($dueDateTypeCode)) { // BT-8, placed before the rate per the CII D22B sequence - $tax->appendChild($doc->createElement('ram:DueDateTypeCode', htmlspecialchars((string) $dueDateTypeCode))); + $tax->appendChild($doc->createElement('ram:DueDateTypeCode', einvoicingXmlText((string) $dueDateTypeCode))); } // BT-119 comes from the group itself, never from the key it is filed under: that key identifies @@ -3057,12 +3154,12 @@ // reasonCode if ($reasonCode !== null) { - $node->appendChild($doc->createElement('ram:ReasonCode', htmlspecialchars((string) $reasonCode))); + $node->appendChild($doc->createElement('ram:ReasonCode', einvoicingXmlText((string) $reasonCode))); } // Reason if ($reason !== null) { - $node->appendChild($doc->createElement('ram:Reason', htmlspecialchars((string) $reason))); + $node->appendChild($doc->createElement('ram:Reason', einvoicingXmlText((string) $reason))); } // Tax (important Factur-X). Document level only: on a line the VAT of the discount is already @@ -3071,7 +3168,7 @@ $taxNode = $doc->createElement('ram:CategoryTradeTax'); $taxNode->appendChild($doc->createElement('ram:TypeCode', 'VAT')); - $taxNode->appendChild($doc->createElement('ram:CategoryCode', htmlspecialchars((string) $taxCategory))); + $taxNode->appendChild($doc->createElement('ram:CategoryCode', einvoicingXmlText((string) $taxCategory))); $taxNode->appendChild($doc->createElement('ram:RateApplicablePercent', number_format($taxRate, 2, '.', ''))); $node->appendChild($taxNode); @@ -3270,12 +3367,14 @@ if (!empty($expedition->origin) && $expedition->origin == "commande" && !empty($expedition->origin_id)) { $commande = new Commande($this->db); $commandeFetchResult = $commande->fetch($expedition->origin_id); - if ($commandeFetchResult > 0 && !empty($commande->ref_client)) { - $customerOrderReferenceList[] = $commande->ref_client; + // empty() would let a reference made of spaces through - it becomes an empty BT-13 or + // BT-18, which BR-52 rejects - and would drop one that reads "0", which is a reference. + if ($commandeFetchResult > 0 && trim((string) $commande->ref_client) !== '') { + $customerOrderReferenceList[] = trim((string) $commande->ref_client); } } if (!empty($expedition->date_delivery)) { - $deliveryDateList[] = date('Y-m-d', $expedition->date_delivery); + $deliveryDateList[] = dol_print_date($expedition->date_delivery, 'dayrfc', 'tzserver'); } } } @@ -3287,8 +3386,8 @@ $commande = new Commande($this->db); $commandeFetchResult = $commande->fetch($commandeId); if ($commandeFetchResult > 0) { - if (!empty($commande->ref_client)) { - $customerOrderReferenceList[] = $commande->ref_client; + if (trim((string) $commande->ref_client) !== '') { + $customerOrderReferenceList[] = trim((string) $commande->ref_client); } $commande->fetchObjectLinked(); $found = 0; @@ -3299,14 +3398,14 @@ if ($expeditionFetchResult > 0) { if (!empty($expedition->date_delivery)) { $found++; - $deliveryDateList[] = date('Y-m-d', $expedition->date_delivery); + $deliveryDateList[] = dol_print_date($expedition->date_delivery, 'dayrfc', 'tzserver'); } } } } if ($found == 0) { if (!empty($commande->delivery_date)) { - $deliveryDateList[] = date('Y-m-d', $commande->delivery_date); + $deliveryDateList[] = dol_print_date($commande->delivery_date, 'dayrfc', 'tzserver'); } } } @@ -3430,9 +3529,9 @@ // price of the Dolibarr line. $priceWithoutDiscount = (float) $lineTotalAmount - $totalChargeAmount + $totalDiscountAmount; - // Base used for percent calculation — BT-137 when the document states it, the amount before - // discount otherwise (issue #783). - $base = $allowances[0]['basisAmount'] ?? $priceWithoutDiscount; + // Base for the percent — BT-137 if given, amount before discount otherwise (issue #783). + // A BasisAmount of 0 (some pivots emit it) counts as not given: ?? would keep the 0 and drop the discount. + $base = !empty($allowances[0]['basisAmount']) ? $allowances[0]['basisAmount'] : $priceWithoutDiscount; if (!$base) { return false; @@ -3476,7 +3575,8 @@ if ($invoice->fetch($supplierInvoiceId) <= 0) { return; } - if (self::totalsAgreeWithDocument($invoice, $announcedTva, $announcedTtc)) { + if (SupplierInvoiceHelper::totalsAgreeWithDocument($invoice, $announcedTva, $announcedTtc)) { + SupplierInvoiceHelper::clearTotalsMismatch($supplierInvoiceId); return; } @@ -3496,7 +3596,7 @@ if ($invoice->fetch($supplierInvoiceId) <= 0) { return; } - if (self::totalsAgreeWithDocument($invoice, $announcedTva, $announcedTtc)) { + if (SupplierInvoiceHelper::totalsAgreeWithDocument($invoice, $announcedTva, $announcedTtc)) { // The import runs from a cron job as well as from a page, so the language file of the // module is not necessarily loaded. $langs->load('einvoicing@einvoicing'); @@ -3510,32 +3610,35 @@ price2num($importedTtc, 'MT') ); dol_syslog(__METHOD__ . ' Invoice ' . $supplierInvoiceId . ' recalculated in VAT mode ' . $modenumber . ' to match the totals of the received document', LOG_DEBUG); + SupplierInvoiceHelper::clearTotalsMismatch($supplierInvoiceId); return; } } - // Neither convention gives the announced totals: leave the invoice as the import built it. + // Neither convention gives the announced totals: the document is one the import cannot + // reproduce. The invoice is left as it was built - the file is attached to it and nothing else + // carries what the vendor sent - but it is marked, and that mark keeps it out of validation and + // out of any approval until the two agree (issue #861). $invoice->update_price(1, 'auto', 0, $invoice->thirdparty); - } + if ($invoice->fetch($supplierInvoiceId) <= 0) { + return; + } - /** - * Tell whether an invoice totals what the received document announces. - * - * Compared on the absolute values: a credit note is stored negative by Dolibarr while BT-110 and - * BT-112 are always announced positive, the document type being what carries the sign (BR-CO-13 - * applies to a credit note as it does to an invoice). The tolerance is there for the float - * representation, not for a difference: the document carries its totals to the cent. - * - * @param FactureFournisseur $invoice The invoice, with its totals as stored - * @param float $announcedTva BT-110 of the received document, absolute value - * @param float $announcedTtc BT-112 of the received document, absolute value - * @return bool True when both totals are the announced ones - */ - private static function totalsAgreeWithDocument(FactureFournisseur $invoice, $announcedTva, $announcedTtc) - { - return abs(abs((float) $invoice->total_tva) - $announcedTva) < 0.005 - && abs(abs((float) $invoice->total_ttc) - $announcedTtc) < 0.005; + SupplierInvoiceHelper::flagTotalsMismatch($supplierInvoiceId, $announcedTva, $announcedTtc); + + $langs->load('einvoicing@einvoicing'); + $return_messages[] = $langs->trans( + 'EInvoiceImportTotalsMismatch', + dol_escape_htmltag((string) ($parsedHeader['documentno'] ?? '')), + price2num($announcedTtc, 'MT'), + price2num($announcedTva, 'MT'), + price2num(abs((float) $invoice->total_ttc), 'MT') + ); + $return_messages[] = $langs->trans('EInvoiceImportTotalsMismatchAction'); + + dol_syslog(__METHOD__ . ' Invoice ' . $supplierInvoiceId . ' does not total the received document (announced ' . $announcedTtc . ' incl. VAT, imported ' . $invoice->total_ttc . '): validation and approval blocked', LOG_WARNING); } + /** diff -ruN --exclude=vendor --exclude=.git _base_lf/class/protocols/CommonProtocol.class.php cm_repo/einvoicing/class/protocols/CommonProtocol.class.php --- _base_lf/class/protocols/CommonProtocol.class.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/class/protocols/CommonProtocol.class.php 2026-09-11 06:39:46 @@ -220,6 +220,17 @@ */ private function getIEC6523Code($country_code, $global = 0) { + // EINVOICING_PARTY_IDENTIFIER_SCHEME decides the scheme of the party identifier (BT-29, BT-46) + // alone. It must not reach $global == 2, the electronic address (BT-34, BT-49), where 0225 is + // the right answer and BR-CL-25 accepts nothing outside the CEF EAS list. + if ($global == 1) { + $configured = trim(getDolGlobalString('EINVOICING_PARTY_IDENTIFIER_SCHEME')); + // 'none' rather than an empty string: an empty option is an option nobody set, which + // keeps the historical scheme of the country. + if ($configured !== '') { + return ($configured === 'none') ? '' : $configured; + } + } $retour = ""; switch ($country_code) { case 'BE': @@ -245,8 +256,102 @@ return $retour; } + /** + * Value of the party identifier (BT-29, BT-46), which follows the scheme the setup asks for. + * + * Every entry of the list but the SIRET is declared with the professional identifier idprof() + * answers for the country of the party, which is what the module has always written. + * + * @param Societe $thirdparty Party the identifier belongs to + * @return string Identifier, empty when that party has nothing under that scheme + */ + private function getPartyIdentifierValue($thirdparty) + { + if (getDolGlobalString('EINVOICING_PARTY_IDENTIFIER_SCHEME') === '0009') { + return removeAllSpaces($thirdparty->idprof2); + } + return idprof($thirdparty); + } + /** + * Canonical form of a product reference, for comparison only. + * + * The same identifier reaches us in as many writings as there are systems it travels through. + * A vendor may write 'A1234-10_42' on its order forms and 'A1234|10|42' on its invoices. + * Dolibarr stores a product reference through dol_sanitizeFileName(), which replaces every + * character forbidden in a file name. A catalogue that went through a spreadsheet comes back + * with non breaking spaces. Comparing the raw strings answers "not found" for what is plainly + * the same item, so the comparison is done on this canonical form instead: letters and digits + * only, upper case. + * + * This form is a comparison key. It is never stored, never displayed, and never written back + * to the reference it was computed from. + * + * @param string $ref Reference as written by its source + * @return string Canonical form, empty when nothing comparable is left + */ + public static function canonicalRef($ref) + { + $ref = dol_string_unaccent((string) $ref); + $ref = preg_replace('/[^A-Za-z0-9]/', '', $ref); + + return strtoupper((string) $ref); + } + + /** + * Vendor references of one supplier, indexed by their canonical form. + * + * The normalization is done in PHP rather than in SQL, for three reasons: removing every + * separator in SQL needs REGEXP_REPLACE, which is not available on every database Dolibarr + * supports; a function applied to the column would prevent the use of any index anyway; and + * one query per supplier for a whole invoice costs less than one scan per invoice line. + * The result is cached for the run, so importing a hundred lines of the same vendor reads + * its references once. + * + * @param DoliDB $db Database handler + * @param int $socid Supplier id + * @return array Canonical reference => product id, 0 when several products share it + */ + protected static function canonicalVendorRefMap($db, $socid) + { + global $conf; + + static $cache = array(); + + // The map depends on the entity, through getEntity() below, so the entity is part of the key. + $cachekey = ((int) $socid) . '_' . ((int) $conf->entity); + if (isset($cache[$cachekey])) { + return $cache[$cachekey]; + } + + $map = array(); + $sql = "SELECT pfp.fk_product, pfp.ref_fourn"; + $sql .= " FROM " . MAIN_DB_PREFIX . "product_fournisseur_price as pfp"; + $sql .= " INNER JOIN " . MAIN_DB_PREFIX . "product as p ON p.rowid = pfp.fk_product"; + $sql .= " WHERE pfp.fk_soc = " . ((int) $socid); + $sql .= " AND p.entity IN (" . getEntity('product') . ")"; + $resql = $db->query($sql); + if ($resql) { + while ($obj = $db->fetch_object($resql)) { + $key = self::canonicalRef($obj->ref_fourn); + if ($key === '') { + continue; + } + if (!isset($map[$key])) { + $map[$key] = (int) $obj->fk_product; + } elseif ($map[$key] !== (int) $obj->fk_product) { + $map[$key] = 0; // two products share that canonical form: undecidable + } + } + } + + $cache[$cachekey] = $map; + + return $map; + } + + /** * Generate a sample E-invoice for demonstration or testing purposes (for Dolibarr version >= 24.0) * * This method creates a dummy invoice with representative data @@ -507,7 +612,7 @@ if (!empty($globalId)) { // Map scheme to idprof field (0002 = SIREN) // TODO Use function idprof() ? - $idprofField = $this->_mapGlobalIdSchemeToIdprof($idScheme, $sellerCountryCode); + $idprofField = $this->_mapGlobalIdSchemeToIdprof($idScheme, $sellerCountryCode, $globalId); if (!empty($idprofField)) { $result = 0; // Fetch thirdparty by corresponding idprof field @@ -690,7 +795,7 @@ if (!empty($sellerInfo['sellerGlobalIds']) && is_array($sellerInfo['sellerGlobalIds'])) { foreach ($sellerInfo['sellerGlobalIds'] as $idScheme => $globalId) { if (!empty($globalId)) { - $idprofField = $this->_mapGlobalIdSchemeToIdprof($idScheme, $sellerCountryCode); + $idprofField = $this->_mapGlobalIdSchemeToIdprof($idScheme, $sellerCountryCode, $globalId); if (!empty($idprofField)) { $thirdparty->$idprofField = removeAllSpaces($globalId); } @@ -738,7 +843,7 @@ if (!empty($sellerInfo['sellerGlobalIds']) && is_array($sellerInfo['sellerGlobalIds'])) { foreach ($sellerInfo['sellerGlobalIds'] as $idScheme => $globalId) { if (!empty($globalId)) { - $idprofField = $this->_mapGlobalIdSchemeToIdprof($idScheme, $sellerCountryCode); + $idprofField = $this->_mapGlobalIdSchemeToIdprof($idScheme, $sellerCountryCode, $globalId); if (!empty($idprofField) && empty($thirdparty->$idprofField)) { $thirdparty->$idprofField = removeAllSpaces($globalId); } @@ -824,7 +929,7 @@ if (!empty($sellerInfo['sellerGlobalIds']) && is_array($sellerInfo['sellerGlobalIds'])) { foreach ($sellerInfo['sellerGlobalIds'] as $idScheme => $globalId) { if (!empty($globalId)) { - $idprofField = $this->_mapGlobalIdSchemeToIdprof($idScheme, $sellerCountryCode); + $idprofField = $this->_mapGlobalIdSchemeToIdprof($idScheme, $sellerCountryCode, $globalId); if (!empty($idprofField)) { $thirdparty->$idprofField = removeAllSpaces($globalId); } @@ -886,7 +991,7 @@ if (!empty($sellerInfo['sellerGlobalIds']) && is_array($sellerInfo['sellerGlobalIds'])) { foreach ($sellerInfo['sellerGlobalIds'] as $idScheme => $globalId) { if (!empty($globalId)) { - $idprofField = $this->_mapGlobalIdSchemeToIdprof($idScheme, $sellerCountryCode); + $idprofField = $this->_mapGlobalIdSchemeToIdprof($idScheme, $sellerCountryCode, $globalId); if (!empty($idprofField)) { $createParams[$idprofField] = $globalId; } @@ -940,7 +1045,7 @@ if (!empty($sellerInfo['sellerGlobalIds']) && is_array($sellerInfo['sellerGlobalIds'])) { foreach ($sellerInfo['sellerGlobalIds'] as $idScheme => $globalId) { if (!empty($globalId)) { - $idprofField = $this->_mapGlobalIdSchemeToIdprof($idScheme, $sellerCountryCode); + $idprofField = $this->_mapGlobalIdSchemeToIdprof($idScheme, $sellerCountryCode, $globalId); if (!empty($idprofField)) { $errorDetails[$idprofField] = $langs->trans($idprofField).': ' . $globalId; $actiondata[$idprofField] = $globalId; @@ -1004,6 +1109,28 @@ // No match found, continue to next step } + // Fall back on the canonical form of the reference, for the vendors that do not write it + // the same way on their orders and on their invoices. The exact lookup above stays first, + // so nothing changes for the vendors that already match, and its index is still used there. + // Off by default: this comparison is an approximation, so it is a setup option the user + // turns on knowingly. + if (getDolGlobalInt('EINVOICING_PRODUCTS_MATCH_CANONICAL_REF')) { + $canonical = self::canonicalRef($lineData['prodsellerid'] ?? ''); + if ($canonical !== '' && !empty($lineData['supplierId'])) { + $map = self::canonicalVendorRefMap($db, (int) $lineData['supplierId']); + if (isset($map[$canonical])) { + if ($map[$canonical] > 0) { + dol_syslog(__METHOD__ . ' Found product by prodsellerid on its canonical form: ' . $map[$canonical]); + return array('res' => $map[$canonical], 'message' => 'Product found by prodsellerid (canonical form)'); + } + // Several products of this supplier share that canonical form. Nothing can be + // decided here, so the line goes to the manual mapping instead of being bound + // to whichever row came first. + dol_syslog(__METHOD__ . ' Ambiguous canonical vendor ref ' . $canonical . ', left to the manual mapping', LOG_WARNING); + } + } + } + // Global ID (prodglobalid + prodglobalidtype) and prodglobalidtype = '0160' search by barcode // TODO @@ -1021,10 +1148,15 @@ } } - // Check with EI- prefix for product inmported using prodsellerid as internal reference with EI- prefix + // Check with EI- prefix for product imported using prodsellerid as internal reference with EI- prefix if (!empty($lineData['prodsellerid']) && $lineData['prodsellerid'] !== "") { + // The reference is sanitized when the product is created (see + // _findOrCreateProductFromEinvoiceLine), so the lookup has to apply the same transform. + // A vendor reference holding a character forbidden in a file name is stored as + // 'EI-A1234_10_42' but was looked up as 'EI-A1234|10|42', so the module could never + // find back a product it had created itself. $sql = "SELECT rowid FROM " . MAIN_DB_PREFIX . "product"; - $sql .= " WHERE ref = 'EI-" . $db->escape($lineData['prodsellerid']) . "'"; + $sql .= " WHERE ref = 'EI-" . $db->escape(dol_sanitizeFileName($lineData['prodsellerid'])) . "'"; $sql .= " AND entity IN (" . getEntity('product') . ")"; $sql .= " LIMIT 1"; $resql = $db->query($sql); @@ -1307,15 +1439,33 @@ /** * Map global ID scheme to Dolibarr idprof field * + * 0002 and 0009 name the register they come from, 0225 does not: it is the French e-invoicing + * ADDRESS scheme, whose value is a SIREN, a SIRET, or either of them suffixed with a routing code + * (rules G1.83, G1.93 and G1.115 of the French specification). Its shape is therefore what decides + * where it is stored, and a suffixed one is stored nowhere: it identifies a mailbox, not a company. + * 0231 (the SIREN of a VAT group) and 0088 (a GLN) are left out on purpose - neither is the + * registration identifier of the party the document names. + * * @param string $scheme Global ID scheme code * @param string $countrycode Country code - * @return string Corresponding idprof field name + * @param string $value Identifier carried under that scheme, read when the scheme alone does not decide + * @return string Corresponding idprof field name, empty when the identifier is not one */ - private function _mapGlobalIdSchemeToIdprof($scheme, $countrycode = '') + private function _mapGlobalIdSchemeToIdprof($scheme, $countrycode = '', $value = '') { + if ($scheme === '0225') { + $digits = preg_replace('/\D/', '', (string) $value); + if ($digits !== (string) $value) { + return ''; + } + if (dol_strlen($digits) == 9) { + return 'idprof1'; // SIREN + } + return (dol_strlen($digits) == 14) ? 'idprof2' : ''; // SIRET + } + $map = [ '0002' => 'idprof1', // SIREN - '0225' => 'idprof1', // SIREN '0009' => 'idprof2', // SIRET ]; diff -ruN --exclude=vendor --exclude=.git _base_lf/class/protocols/FacturXProtocol.class.php cm_repo/einvoicing/class/protocols/FacturXProtocol.class.php --- _base_lf/class/protocols/FacturXProtocol.class.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/class/protocols/FacturXProtocol.class.php 2026-09-11 06:39:46 @@ -713,9 +713,17 @@ return ['res' => -1, 'message' => SupplierInvoiceHelper::refLookupErrorMessage($refDocInvoiceId, $refDoc, 'linked to document ' . ($parsedHeader['documentno'] ?? ''))]; } if ($refDocInvoiceId == 0) { - // The invoice references a document this Dolibarr does not hold (deposit, credited or - // replaced invoice). Nothing has been created yet, so the flow is postponed rather than - // failed, and the message spells out what to create with a link to the creation screen. + // An unqualified reference (no ram:TypeCode in the XML) is a placeholder that the import + // does not consume — some vendors (e.g. DSV Road) always emit BG-3 with a dummy value + // such as "XXXX" when no preceding invoice applies. Skip it silently so it does not block + // the import and does not reach the post-creation loop. + if (empty($typeDoc)) { + dol_syslog(get_class($this) . '::doCreateSupplierInvoiceFromSource Skipping unqualified InvoiceReferencedDocument ref="' . $refDoc . '" (no TypeCode) for ' . ($parsedHeader['documentno'] ?? ''), LOG_DEBUG); + continue; + } + // The invoice references a qualified document this Dolibarr does not hold yet (deposit, + // credited or replaced invoice). Nothing has been created yet, so the flow is postponed + // rather than failed, and the message spells out what to create. $langs->load("bills"); $action = $langs->trans('CreateTheMissingSupplierInvoiceToImport', $refDoc); $action .= ' '; @@ -849,6 +857,12 @@ return ['res' => -1, 'message' => SupplierInvoiceHelper::refLookupErrorMessage($linkedObjectId, $refDoc, 'linked to document ' . ($parsedHeader['documentno'] ?? ''))]; } if ($linkedObjectId == 0) { + // Unqualified references (no TypeCode) were already skipped by the pre-check above and + // should not reach this point. As a safety net, skip them here too rather than failing. + if (empty($typeDoc)) { + dol_syslog(get_class($this) . '::doCreateSupplierInvoiceFromSource Skipping unqualified InvoiceReferencedDocument ref="' . $refDoc . '" (no TypeCode) in post-creation loop for ' . ($parsedHeader['documentno'] ?? ''), LOG_DEBUG); + continue; + } return ['res' => -1, 'message' => 'Document : ' . $refDoc . ' linked to document ' . $parsedHeader['documentno'] . ' not found in Dolibarr']; } diff -ruN --exclude=vendor --exclude=.git _base_lf/class/providers/AbstractPDPProvider.class.php cm_repo/einvoicing/class/providers/AbstractPDPProvider.class.php --- _base_lf/class/providers/AbstractPDPProvider.class.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/class/providers/AbstractPDPProvider.class.php 2026-09-11 06:39:46 @@ -38,6 +38,9 @@ /** @var DoliDB Database handler */ public $db; + /** @var string Error message */ + public $error; + /** @var array Error messages */ public $errors = []; @@ -415,6 +418,26 @@ /** + * Set the setup factory specific to the provider. + * + * Optional: a provider with nothing of its own to configure keeps this empty block, and the setup + * page then simply shows nothing under the common one. + * + * @param FormSetup $formSetup The form setup object to initialize + * @param string $prefix The prefix for configuration keys ('EINVOICING_MYPDP_') + * @param string $prefixenv 'prod' or 'test', depending on EINVOICING_LIVE + * @param array $providersConfig The array containing providers configuration + * @param array $TFieldProtocols The array of available protocols to set in the select field + * @param array $TFieldProfiles The array of available profiles to set in the select field + * @return void + */ + public function initFormSetup(&$formSetup, $prefix, $prefixenv, $providersConfig, $TFieldProtocols, $TFieldProfiles) + { + // Nothing to add to the setup page by default. + } + + + /** * Try to get a flow data from its id and doc type, using API * * @param string $flowId The id of the flow @@ -591,7 +614,7 @@ * * @param string $resource Resource relative URL ('Flows', 'healthcheck' or others) * @param 'POST'|'GET'|'HEAD'|'PUT'|'PUTALREADYFORMATED'|'POSTALREADYFORMATED'|'DELETE' $method HTTP method (dolibarr's types) - * @param string|false $options Options for the request (JSON encoded) + * @param string|false|array $options Body of the request: a JSON encoded string, or an array carrying a CURLFile for a multipart upload. False when there is none. * @param array $extraHeaders Optional additional headers * @param string|null $callType Functional type of the API call for logging purposes (e.g., 'sync_flows', 'send_invoice') * @@ -604,7 +627,7 @@ * @param int $syncFromDate Timestamp from which to start synchronization. If 0, begins from epoch (1970-01-01). * @param int $limit Maximum number of flows to synchronize. 0 means no limit. * - * @return bool|array{res:int, messages:string[], totalFlows?:?int, alreadyExist?:int, syncedFlows?:int, batchlimit?:int, actions?:array, details?:string[]} True on success, false on failure along with messages, details for debugging, and suggested optional actions. + * @return bool|array{res:int, messages:string[], totalFlows?:?int, alreadyExist?:int, syncedFlows?:int, batchlimit?:int, actions?:array, details?:string[], errors?:string[]} True on success, false on failure along with messages, details for debugging, the errors that aborted the run, and suggested optional actions. */ abstract public function syncFlows($syncFromDate = 0, $limit = 0); @@ -820,6 +843,19 @@ /** + * Delete the access token of this provider. + * Called by the setup page only. + * + * @param int $forceentity 0=Use current entity, >0=Use specific entity + * @return bool True if success, false otherwise + */ + public function deleteAccessToken($forceentity = 0) + { + return $this->deleteOAuthTokenDB($forceentity); + } + + + /** * Get the last synchronization date with the PDP provider. * Retrieves the timestamp of the most recent successful flow synchronization * for this provider. If no sync has occurred yet, returns 0. @@ -1011,6 +1047,8 @@ $call->entity = $conf->entity; $call->status = ($statusCode == 200 || $statusCode == 202) ? 1 : 0; + $call->context['statusCode'] = $statusCode; + if ($call->create($user) > 0) { $dbhistory->commit(); return array('id' => $call->id, 'call_id' => $call->call_id); @@ -1065,6 +1103,176 @@ } return $res; + } + + /** + * Record a lifecycle status the vendor issued about one of its invoices, onto the supplier + * invoice it refers to. + * + * Never returns a negative result for a status it cannot attach: a vendor may report on an invoice this + * Dolibarr does not hold (refused, or an access point account shared with another system), and failing + * the flow would stall the whole synchronization on it, run after run. The flow is stored either way. + * + * Shared by every provider (moved out of SuperPDPProvider, issue: EsalinkPDPProvider's + * "SupplierInvoiceLC" case had no equivalent guard and always fell through to the + * fetchStatusMessages() path built for the flows WE send, which an incoming status is not). + * + * @param string $flowId Flow identifier of the lifecycle message + * @param Document $document Flow document being built, completed here with the CDAR data + * @param EInvoicing $einvoicing E-invoicing helper of the running synchronization + * @return array{res:int, message:string} 1 when the status was attached, 0 when it was only stored + */ + protected function processIncomingSupplierInvoiceStatus($flowId, $document, $einvoicing) + { + global $db; + + require_once DOL_DOCUMENT_ROOT . '/fourn/class/fournisseur.facture.class.php'; + dol_include_once('einvoicing/class/utils/CdarHandler.class.php'); + + $flowResource = 'flows/' . $flowId . '?' . http_build_query(array('docType' => 'Original')); + $flowResponse = $this->callApi($flowResource, "GET", false, array('Accept' => 'application/octet-stream')); + if ($flowResponse['status_code'] != 200) { + return array('res' => 0, 'message' => "Failed to retrieve flow details for flowId: " . $flowId); + } + + $cdarHandler = new CdarHandler($db); + $cdarDocument = $cdarHandler->readFromString($flowResponse['response']); + if (empty($cdarDocument) || empty($cdarDocument['AcknowledgementDocument']['ReferenceReferencedDocument'])) { + return array('res' => 0, 'message' => "FlowId: " . $flowId . " - Failed to parse CDAR document"); + } + + $refDoc = $cdarDocument['AcknowledgementDocument']['ReferenceReferencedDocument']; + + $document->cdar_lifecycle_code = $refDoc['ProcessConditionCode']; + $document->cdar_lifecycle_label = isset($refDoc['ProcessCondition']) ? $refDoc['ProcessCondition'] : ''; + $document->cdar_reason_code = isset($refDoc['StatusReasonCode']) ? $refDoc['StatusReasonCode'] : ''; + $document->cdar_reason_desc = isset($refDoc['StatusReason']) ? $refDoc['StatusReason'] : ''; + $document->cdar_reason_detail = isset($refDoc['StatusIncludedNoteContent']) ? $refDoc['StatusIncludedNoteContent'] : ''; + + // The referenced document is the vendor invoice, identified the way its issuer numbered it: + // that is our ref_supplier, and the issuing party is the vendor it belongs to. + $vendorReference = isset($refDoc['IssuerAssignedID']) ? (string) $refDoc['IssuerAssignedID'] : ''; + $vendorLegalId = isset($refDoc['IssuerTradeParty']['GlobalID']) ? (string) $refDoc['IssuerTradeParty']['GlobalID'] : ''; + + $document->tracking_idref = $vendorReference; + + if ($vendorReference === '') { + dol_syslog(__METHOD__ . " FlowId " . $flowId . " carries no IssuerAssignedID, nothing to attach the status to", LOG_WARNING); + return array('res' => 0, 'message' => "FlowId " . $flowId . " - Vendor lifecycle status with no invoice reference"); + } + + $supplierInvoiceId = $this->findSupplierInvoiceByVendorReference($vendorReference, $vendorLegalId); + if ($supplierInvoiceId <= 0) { + dol_syslog(__METHOD__ . " No supplier invoice found for vendor reference " . $vendorReference . " (vendor " . $vendorLegalId . "), flowId " . $flowId, LOG_WARNING); + return array('res' => 0, 'message' => "FlowId " . $flowId . " - No supplier invoice matching the vendor reference " . $vendorReference); + } + + $supplierInvoice = new FactureFournisseur($this->db); + if ($supplierInvoice->fetch($supplierInvoiceId) <= 0) { + return array('res' => 0, 'message' => "FlowId " . $flowId . " - Failed to load supplier invoice id " . $supplierInvoiceId); + } + + $document->fk_element_id = $supplierInvoice->id; + $document->tracking_idref = $supplierInvoice->ref; + + $statusComment = $document->cdar_reason_detail ? $document->cdar_reason_detail : $document->cdar_reason_desc; + + $exceptionmessage = ''; + $db->begin(); + + try { + // The flow_id of the link is left alone on purpose: on a supplier invoice it points at the + // received invoice document, which stays the source of its XML. Only the status moves. + $einvoicing->insertOrUpdateExtLink($supplierInvoice->id, $supplierInvoice->element, '', $document->cdar_lifecycle_code, '', $statusComment); + + $einvoicing->storeStatusMessage( + $supplierInvoice->id, + $supplierInvoice->element, + $document->cdar_lifecycle_code, + $statusComment, + $document->flow_direction, + $flowId, + $document->ack_status, + $document->ack_info, + $document->submittedat, + $document->cdar_reason_code + ); + + $db->commit(); + } catch (Exception $e) { + $exceptionmessage = $e->getMessage(); + + $db->rollback(); + } + + if ($exceptionmessage) { + throw new Exception($exceptionmessage); + } + + $statusLabel = $document->cdar_lifecycle_label ? $document->cdar_lifecycle_label : $document->cdar_lifecycle_code; + $reasonDetail = $document->cdar_reason_detail ? " - " . $document->cdar_reason_detail : ''; + $this->addEvent('STATUS', "EINVOICING - Status: " . $statusLabel, "EINVOICING - Status: " . $statusLabel . $reasonDetail, $supplierInvoice); + + return array('res' => 1, 'message' => "FlowId " . $flowId . " - Vendor status " . $document->cdar_lifecycle_code . " recorded on supplier invoice " . $supplierInvoice->ref); + } + + /** + * Find the supplier invoice a vendor lifecycle status refers to. + * + * A vendor reference is only unique per vendor, never globally, so it is only trusted alone when + * it matches exactly one invoice. When several vendors happen to use the same numbering, the + * legal identifier carried by the CDAR settles it; when it cannot, no invoice is returned rather + * than the wrong one. + * + * @param string $vendorReference Invoice number as assigned by the vendor (BT-1 of the referenced invoice) + * @param string $vendorLegalId Legal identifier of the issuing party, empty when the CDAR carries none + * @return int Supplier invoice id, 0 when there is no single certain match + */ + protected function findSupplierInvoiceByVendorReference($vendorReference, $vendorLegalId) + { + global $db; + + $sql = "SELECT f.rowid, s.siren, s.siret, s.tva_intra"; + $sql .= " FROM " . $db->prefix() . "facture_fourn as f"; + $sql .= " INNER JOIN " . $db->prefix() . "societe as s ON s.rowid = f.fk_soc"; + $sql .= " WHERE f.ref_supplier = '" . $db->escape($vendorReference) . "'"; + + $listofentityids = getEntity('facture_fourn'); + if (getDolGlobalString('EINVOICING_ALLOW_MULTICOMPANY_INVOICE_MOVE')) { + $listofentityids .= ','.getDolGlobalString('EINVOICING_ALLOW_MULTICOMPANY_INVOICE_MOVE'); + } + $sql .= " AND f.entity IN (" . $db->sanitize($listofentityids) . ")"; + + $resql = $db->query($sql); + if (!$resql) { + dol_syslog(__METHOD__ . " " . $db->lasterror(), LOG_ERR); + return 0; + } + + $candidates = array(); + while ($obj = $db->fetch_object($resql)) { + $candidates[] = $obj; + } + $db->free($resql); + + if (count($candidates) == 1) { + return (int) $candidates[0]->rowid; + } + if (empty($candidates) || $vendorLegalId === '') { + return 0; + } + + // Several invoices carry that number: only the one whose vendor is the issuer of the status. + $matches = array(); + foreach ($candidates as $candidate) { + if ($vendorLegalId === (string) $candidate->siren + || $vendorLegalId === (string) $candidate->siret + || $vendorLegalId === (string) $candidate->tva_intra) { + $matches[] = (int) $candidate->rowid; + } + } + + return count($matches) == 1 ? $matches[0] : 0; } /** diff -ruN --exclude=vendor --exclude=.git _base_lf/class/providers/EsalinkPDPProvider.class.php cm_repo/einvoicing/class/providers/EsalinkPDPProvider.class.php --- _base_lf/class/providers/EsalinkPDPProvider.class.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/class/providers/EsalinkPDPProvider.class.php 2026-09-11 06:39:46 @@ -168,7 +168,7 @@ // Client secret $item = $formSetup->newItem($prefix . 'PASSWORD'.(getDolGlobalInt('EINVOICING_LIVE') ? '_PROD' : '')); - if (method_exists('FormSetupItem', 'setAsGenericPassword')) { + if (method_exists($item, 'setAsGenericPassword')) { $item->setAsGenericPassword(); } else { // Dolibarr 18/19 fallback: setAsGenericPassword() does not exist yet. @@ -351,18 +351,6 @@ } /** - * Delete access token. - * Called by the setup page only. - * - * @return bool True if success, false otherwise - */ - public function deleteAccessToken() - { - $result = $this->deleteOAuthTokenDB(); - return $result; - } - - /** * Perform a health check call for PDP provider. * * @return array Contains 'status' (bool) and 'message' (string) @@ -731,7 +719,7 @@ * * @param string $resource Resource relative URL ('token', 'healthcheck', 'Flows', or others) * @param 'POST'|'GET'|'HEAD'|'PUT'|'PUTALREADYFORMATED'|'POSTALREADYFORMATED'|'DELETE' $method HTTP method (dolibarr's types) - * @param string|false $params Options for the request (JSON encoded) + * @param string|false|array $params Body of the request: a JSON encoded string, or an array carrying a CURLFile for a multipart upload. False when there is none. * @param array $extraHeaders Optional additional headers * @param string|null $callType Functional type of the API call for logging purposes (e.g., 'sync_flows', 'send_invoice') * @@ -851,7 +839,7 @@ * * @param int $syncFromDate Timestamp from which to start synchronization. If 0, begins from epoch (1970-01-01). * @param int $limit Maximum number of flows to synchronize. 0 means no limit. - * @return bool|array{res:int, messages:string[], totalFlows?:?int, alreadyExist?:int, syncedFlows?:int, batchlimit?:int, actions?:array, details?:string[]} True on success, false on failure along with messages, details for debugging, and suggested optional actions. + * @return bool|array{res:int, messages:string[], totalFlows?:?int, alreadyExist?:int, syncedFlows?:int, batchlimit?:int, actions?:array, details?:string[], errors?:string[]} True on success, false on failure along with messages, details for debugging, the errors that aborted the run, and suggested optional actions. */ public function syncFlows($syncFromDate = 0, $limit = 0) { @@ -867,6 +855,7 @@ $this->clearIncomingDiagnosticFiles(); $results_messages = array(); // result message (technical error) + $error_messages = array(); // subset of the above holding only what made the run fail $actions = array(); // business message (manual action to do) $resource = 'flows/search'; @@ -900,9 +889,11 @@ $totalFlows = 0; if ($response['status_code'] != 200) { - $this->errors[] = "Failed to retrieve flows for synchronization."; - $results_messages[] = "Failed to retrieve flows for synchronization."; - return array('res' => 0, 'messages' => $results_messages); + $errormessage = "Failed to retrieve flows for synchronization."; + $this->errors[] = $errormessage; + $results_messages[] = $errormessage; + $error_messages[] = $errormessage; + return array('res' => 0, 'messages' => $results_messages, 'errors' => $error_messages); } $totalFlows = $response['response']['total'] ?? 0; @@ -929,11 +920,13 @@ $response = $this->callApi($resource, "POST", $jsonparams, array('Request-Id' => $uuid), "synchronization"); // This will also create the Call entry if ($response['status_code'] != 200) { - $this->errors[] = "Failed to retrieve flows for synchronization." . ' (HTTP ' . $response['status_code'] . ')'; - $results_messages[] = "Failed to retrieve flows for synchronization." . ' (HTTP ' . $response['status_code'] . ')'; + $errormessage = "Failed to retrieve flows for synchronization." . ' (HTTP ' . $response['status_code'] . ')'; + $this->errors[] = $errormessage; + $results_messages[] = $errormessage; + $error_messages[] = $errormessage; dol_syslog(__METHOD__ . " Failed to retrieve the list of flows for synchronization.", LOG_DEBUG, 0, "_einvoicing"); - return array('res' => 0, 'messages' => $results_messages); + return array('res' => 0, 'messages' => $results_messages, 'errors' => $error_messages); } // Some AP returns nb of lines into "total", others returns into "limit" @@ -976,11 +969,13 @@ $alreadyProcessedFlowIds[$obj->flow_id] = $obj->flow_id; } } else { - $this->errors[] = "Failed to retrieve from database the list of flows already processed. ".$this->db->lasterror(); - $results_messages[] = "Failed to retrieve from database the list of flows already processed. ".$this->db->lasterror(); + $errormessage = "Failed to retrieve from database the list of flows already processed. ".$this->db->lasterror(); + $this->errors[] = $errormessage; + $results_messages[] = $errormessage; + $error_messages[] = $errormessage; dol_syslog(__METHOD__ . " Failed to retrieve flows already processed among the list of flows received. ".$this->db->lasterror(), LOG_DEBUG, 0, "_einvoicing"); - return array('res' => 0, 'messages' => $results_messages); + return array('res' => 0, 'messages' => $results_messages, 'errors' => $error_messages); } } @@ -1102,7 +1097,9 @@ } } dol_syslog(__METHOD__ . " Failed to synchronize flow " . $flow['flowId'] . ": " . $res['message'], LOG_DEBUG, 0, "_einvoicing"); - $results_messages[] = "ERROR_SYNCFLOW - Failed to synchronize flow " . dol_escape_htmltag((string) $flow['flowId']) . ": " . $res['message']; + $errormessage = "ERROR_SYNCFLOW - Failed to synchronize flow " . dol_escape_htmltag((string) $flow['flowId']) . ": " . $res['message']; + $results_messages[] = $errormessage; + $error_messages[] = $errormessage; $error++; } @@ -1120,7 +1117,9 @@ //$lastsuccessfullSyncronizedFlow = $flow['flowId']; } } catch (Exception $e) { - $results_messages[] = "Exception occurred while synchronizing flow " . dol_escape_htmltag((string) $flow['flowId']) . ": " . dol_escape_htmltag($e->getMessage()); + $errormessage = "Exception occurred while synchronizing flow " . dol_escape_htmltag((string) $flow['flowId']) . ": " . dol_escape_htmltag($e->getMessage()); + $results_messages[] = $errormessage; + $error_messages[] = $errormessage; $error++; } @@ -1179,6 +1178,7 @@ // Return result // 'actions' contains the action to do (in case of business error) // 'details' will contain all technical error (for Log) + // 'errors' holds only what aborted the run, for a caller that has to report a cause return [ 'res' => $globalres, 'messages' => $messages, @@ -1187,7 +1187,8 @@ 'syncedFlows' => $syncedFlows, 'batchlimit' => $batchlimit, 'actions' => $actions, - 'details' => $results_messages + 'details' => $results_messages, + 'errors' => $error_messages ]; } @@ -1478,7 +1479,18 @@ } if ($flowResponse['status_code'] != 200) { - return array('res' => -1, 'message' => "Failed to retrieve flow details (neither 'Original' nor 'Converted' document) for flowId: " . $flowId); + // Transient, and nothing was stored for this flow: without 'postponeflow' the batch + // aborts here and on every run after it, since an unstored flow never leaves the + // synchronization window. The #718 convention is meant for exactly this. + return array( + 'res' => -1, + 'postponeflow' => 1, + 'message' => "Failed to retrieve flow details (neither 'Original' nor 'Converted' document) for flowId: " . $flowId, + 'actioncode' => 'CANT_RECORD_SENT_INVOICE_LIFECYCLE_STATUS', + 'actionurl' => '', + 'action' => $langs->trans('CheckSyncLogCantRecordSentInvoiceStatus'), + 'businessmessage' => $langs->trans('CantRecordTheStatusOfTheInvoiceYouSent', $flowId) + ); } $cdarXml = $flowResponse['response']; @@ -1488,13 +1500,29 @@ try { // Parse the CDAR document (returns an array) - $cdarDocument = $cdarHandler->readFromString($cdarXml); + try { + $cdarDocument = $cdarHandler->readFromString($cdarXml); + } catch (Exception $e) { + // Malformed XML (a JSON error body, an HTML page): it will not parse any better on + // a later run, so it falls into the guard below instead of the catch at the end. + dol_syslog(__METHOD__ . " FlowId " . $flowId . " - " . $e->getMessage(), LOG_WARNING); + $cdarDocument = array(); + } //var_dump($cdarDocument); exit; - // Check if parsing was successful - if (empty($cdarDocument) || !isset($cdarDocument['AcknowledgementDocument'])) { - return array('res' => -1, 'message' => "FlowId: " . $flowId . " - Failed to parse CDAR document"); + // Check the lifecycle code this case exists to record, not the array: a parsed CDAR + // always carries every key, empty or not (CdarHandler::parseReferencedDocument()), so + // a non-empty array proves nothing. Left untested, IssuerAssignedID below reads as '' + // and Facture::fetch(0, '') returns -1 on its own guard - which aborted the batch on + // a message naming an empty reference, and aborted it again on every later run. + if (empty($cdarDocument['AcknowledgementDocument']['ReferenceReferencedDocument']['ProcessConditionCode'])) { + // Not transient: a document that carries no lifecycle status never will. Stored, so + // the next synchronization skips it instead of reading it again. + dol_syslog(__METHOD__ . " FlowId " . $flowId . " carries no readable CDAR", LOG_WARNING); + $returnRes = 0; + $returnMessage = "FlowId: " . $flowId . " - Failed to parse CDAR document"; + break; } $factureObj = new Facture($this->db); @@ -1505,13 +1533,24 @@ $res = $factureObj->fetch(0, $issuerAssignedID); if ($res < 0) { + // A reference matching no invoice returns 0, and is stored below with no invoice + // attached: a negative result is an SQL failure only, so it is worth retrying. return array( 'res' => -1, - 'message' => "FlowId " . $flowId . " - Failed to fetch customer invoice using CDAR IssuerAssignedID/ref: " . $issuerAssignedID + 'postponeflow' => 1, + 'message' => "FlowId " . $flowId . " - Failed to fetch customer invoice using CDAR IssuerAssignedID/ref: " . $issuerAssignedID, + 'actioncode' => 'CANT_RECORD_SENT_INVOICE_LIFECYCLE_STATUS', + 'actionurl' => '', + 'action' => $langs->trans('CheckSyncLogCantRecordSentInvoiceStatus'), + 'businessmessage' => $langs->trans('CantRecordTheStatusOfTheInvoiceYouSent', $flowId) ); } if ($factureObj->entity && $factureObj->entity != $conf->entity) { - return array('res' => -1, 'message' => "Processing flowId: " . $flowId . " - Failed to fetch customer invoice ref " . $document->tracking_idref . " in entity " . $conf->entity); + // That invoice belongs to another entity, so this flow is not this one's business: + // treated exactly like a reference matching nothing (the flow is stored, with no + // invoice attached), instead of aborting the batch and every flow behind it. + dol_syslog(__METHOD__ . " FlowId " . $flowId . " refers to customer invoice " . $factureObj->ref . " of entity " . $factureObj->entity . ", not entity " . $conf->entity, LOG_WARNING); + $factureObj = new Facture($this->db); } @@ -1617,9 +1656,17 @@ break; } } catch (Exception $e) { + // Nothing is committed when this is reached: the inner block rolls back before it + // rethrows, and what runs after its commit cannot throw. So the flow was not stored + // either, and postponing it retries it whole rather than aborting the batch for good. return array( 'res' => -1, - 'message' => "FlowId " . $flowId . " - Error processing CDAR document - " . $e->getMessage() + 'postponeflow' => 1, + 'message' => "FlowId " . $flowId . " - Error processing CDAR document - " . $e->getMessage(), + 'actioncode' => 'CANT_RECORD_SENT_INVOICE_LIFECYCLE_STATUS', + 'actionurl' => '', + 'action' => $langs->trans('CheckSyncLogCantRecordSentInvoiceStatus'), + 'businessmessage' => $langs->trans('CantRecordTheStatusOfTheInvoiceYouSent', $flowId) ); } @@ -1633,6 +1680,17 @@ require_once DOL_DOCUMENT_ROOT . '/fourn/class/fournisseur.facture.class.php'; $document->fk_element_type = 'invoice_supplier'; + + // An incoming one is a status the VENDOR issues about one of its own invoices - "Cashed in" + // (212) above all, the answer to the payment we reported with a 211. We never sent it, so it + // has no row in einvoicing_lifecycle_msg and the flowId lookup below cannot resolve it. + if ($document->flow_direction == 'In') { + $resIncoming = $this->processIncomingSupplierInvoiceStatus($flowId, $document, $einvoicing); + + $returnRes = $resIncoming['res']; + $returnMessage = $resIncoming['message']; + break; + } // Fetch the linked supplier invoice using flowId stored in einvoicing_lifecycle_msg table when the LC message was sent $resFetchStatusMessages = $einvoicing->fetchStatusMessages($flowId); diff -ruN --exclude=vendor --exclude=.git _base_lf/class/providers/PDPProviderManager.class.php cm_repo/einvoicing/class/providers/PDPProviderManager.class.php --- _base_lf/class/providers/PDPProviderManager.class.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/class/providers/PDPProviderManager.class.php 2026-09-11 06:39:46 @@ -279,10 +279,11 @@ } $provider = new $classnametouse($db); - - if ($provider) { - $provider->providerName = $name; - } + // The is_subclass_of() above is what makes this type true, and a constructor never returns + // anything falsy: the object is usable as an AbstractPDPProvider from here on. + '@phan-var-force AbstractPDPProvider $provider'; + /** @var AbstractPDPProvider $provider */ + $provider->providerName = $name; return $provider; } diff -ruN --exclude=vendor --exclude=.git _base_lf/class/providers/SuperPDPProvider.class.php cm_repo/einvoicing/class/providers/SuperPDPProvider.class.php --- _base_lf/class/providers/SuperPDPProvider.class.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/class/providers/SuperPDPProvider.class.php 2026-09-11 06:53:27 @@ -116,8 +116,12 @@ // The step above describes a creation; an account that already has an application only needs the format changed $this->helpToGetCredentials .= '
' . $langs->trans("EINVOICING_SUPERPDP_HELP_CREDENTIAL3B") . '
'; $this->helpToGetCredentials .= '
' . $langs->trans("EINVOICING_SUPERPDP_HELP_CREDENTIAL4", '{s3}', '{s4}', '{s5}', '{s6}') . '
'; + // Stated apart from the steps: this one setting decides whether received invoices can be read at all - $this->helpToGetCredentials .= '
' . img_picto('', 'warning') . ' ' . $langs->trans("EINVOICING_SUPERPDP_HELP_CREDENTIAL_CONVERSION") . '
'; + // We do not show warning when there may be no need. Warning will be shown at running if we detect a wrong setup + //if (getDolGlobalString('EINVOICING_PROTOCOL') != 'CII') { + // $this->helpToGetCredentials .= '
' . img_picto('', 'warning') . ' ' . $langs->trans("EINVOICING_SUPERPDP_HELP_CREDENTIAL_CONVERSION") . '
'; + //} if (getDolGlobalString('EINVOICING_PDP') == 'SUPERPDPViaPartner') { $this->helpToGetCredentials = '
' . $langs->trans("EINVOICING_SUPERPDP_HELP_CREDENTIAL_VIA_PARTNER", '{s1}') . '
'; @@ -313,7 +317,7 @@ // Password $item = $formSetup->newItem($prefix.'CLIENT_SECRET'.(getDolGlobalInt('EINVOICING_LIVE') ? '_PROD' : '')); - if (method_exists('FormSetupItem', 'setAsGenericPassword')) { + if (method_exists($item, 'setAsGenericPassword')) { $item->setAsGenericPassword(); } else { // Dolibarr 18/19 fallback: setAsGenericPassword() does not exist yet. @@ -752,18 +756,6 @@ } /** - * Delete access token. - * Called by the setup page only. - * - * @return bool True if success, false otherwise - */ - public function deleteAccessToken() - { - $result = $this->deleteOAuthTokenDB(); - return $result; - } - - /** * Perform a health check call for PDP provider. * * @return array Contains 'status' (bool) and 'message' (string) @@ -1402,7 +1394,7 @@ * * @param string $resource Resource relative URL ('token', 'healthcheck', 'Flows', or others) * @param 'POST'|'GET'|'HEAD'|'PUT'|'PUTALREADYFORMATED'|'POSTALREADYFORMATED'|'DELETE' $method HTTP method (dolibarr's types) - * @param string|false $params Options for the request (JSON encoded) + * @param string|false|array $params Body of the request: a JSON encoded string, or an array carrying a CURLFile for a multipart upload. False when there is none. * @param array $extraHeaders Optional additional headers * @param string|null $callType Functional type of the API call for logging purposes (e.g., 'sync_flows', 'send_invoice') * @@ -1777,7 +1769,7 @@ * * @param int $syncFromDate Timestamp from which to start synchronization. If 0, begins from epoch (1970-01-01). * @param int $limit Maximum number of flows to synchronize. 0 means no limit. - * @return bool|array{res:int, messages:string[], totalFlows?:?int, alreadyExist?:int, syncedFlows?:int, batchlimit?:int, actions?:array, details?:string[]} True on success, false on failure along with messages, details for debugging, and suggested optional actions. + * @return bool|array{res:int, messages:string[], totalFlows?:?int, alreadyExist?:int, syncedFlows?:int, batchlimit?:int, actions?:array, details?:string[], errors?:string[]} True on success, false on failure along with messages, details for debugging, the errors that aborted the run, and suggested optional actions. */ public function syncFlows($syncFromDate = 0, $limit = 0) { @@ -1793,6 +1785,7 @@ $this->clearIncomingDiagnosticFiles(); $results_messages = array(); // result message (technical error) + $error_messages = array(); // subset of the above holding only what made the run fail $actions = array(); // business message (manual action to do) $resource = 'flows/search'; @@ -1827,9 +1820,11 @@ $totalFlows = 0; if ($response['status_code'] != 200) { - $this->errors[] = "Failed to retrieve flows for synchronization."; - $results_messages[] = "Failed to retrieve flows for synchronization."; - return array('res' => 0, 'messages' => $results_messages); + $errormessage = "Failed to retrieve flows for synchronization."; + $this->errors[] = $errormessage; + $results_messages[] = $errormessage; + $error_messages[] = $errormessage; + return array('res' => 0, 'messages' => $results_messages, 'errors' => $error_messages); } $totalFlows = $response['response']['total'] ?? 0; @@ -1892,11 +1887,13 @@ $response = $this->callApi($resource, "POST", json_encode($params), array('Request-Id' => $uuid), ($batchNumber == 1 ? "synchronization" : "")); if ($response['status_code'] != 200) { - $this->errors[] = "Failed to retrieve flows for synchronization." . ' (HTTP ' . $response['status_code'] . ')'; - $results_messages[] = "Failed to retrieve flows for synchronization." . ' (HTTP ' . $response['status_code'] . ')'; + $errormessage = "Failed to retrieve flows for synchronization." . ' (HTTP ' . $response['status_code'] . ')'; + $this->errors[] = $errormessage; + $results_messages[] = $errormessage; + $error_messages[] = $errormessage; dol_syslog(__METHOD__ . " Failed to retrieve the list of flows for synchronization.", LOG_DEBUG, 0, "_einvoicing"); - return array('res' => 0, 'messages' => $results_messages); + return array('res' => 0, 'messages' => $results_messages, 'errors' => $error_messages); } if ($batchNumber == 1) { @@ -1934,11 +1931,13 @@ $alreadyProcessedFlowIds[$obj->flow_id] = $obj->flow_id; } } else { - $this->errors[] = "Failed to retrieve from database the list of flows already processed. ".$this->db->lasterror(); - $results_messages[] = "Failed to retrieve from database the list of flows already processed. ".$this->db->lasterror(); + $errormessage = "Failed to retrieve from database the list of flows already processed. ".$this->db->lasterror(); + $this->errors[] = $errormessage; + $results_messages[] = $errormessage; + $error_messages[] = $errormessage; dol_syslog(__METHOD__ . " Failed to retrieve flows already processed among the list of flows received. ".$this->db->lasterror(), LOG_DEBUG, 0, "_einvoicing"); - return array('res' => 0, 'messages' => $results_messages); + return array('res' => 0, 'messages' => $results_messages, 'errors' => $error_messages); } } @@ -2047,14 +2046,17 @@ $actions[$rescode]['businessmessage'] .= $form->textwithpicto('', "ERROR_SYNCFLOW - Failed to synchronize flow " . $flow['flowId'] . ": " . $res['message'], 1, 'help', '', 0, 2, 'help'); } } - // Manual-action business errors used to abort the whole run, and every flow queued behind - // it with it. Record the flow in the pending queue and carry on instead: one incoming - // flow that needs a manual action (a missing product, a missing thirdparty) must not - // freeze the status refresh of every other flow. The queued flow is retried on demand, + + // A manual-action business error (a missing product, a missing thirdparty, a supplier + // invoice found with a different amount) used to abort the whole batch here, and every + // flow behind it with it - and since the cause does not go away on its own, the next run + // stopped at the same place. Record the flow in a persistent manual-action queue and carry + // on instead, the same way the postponed flows above do for the failures the operator + // cannot act on: the queued flow is retried on demand once the product/thirdparty exists, // and it is not lost when it drifts out of the rolling synchronization window. if (in_array($rescode, array('THIRDPARTY_NOT_FOUND', 'PRODUCT_NOT_FOUND', 'SUPPLIER_INVOICE_FOUND_WITH_BAD_AMOUNT'))) { // Normalize the manual actions the protocol computed (create / associate an existing - // product / set default...) into a compact list the pending list renders as icons. + // product / set a default one...) into a compact list the queue renders as icons. $manualactions = array(); if (!empty($res['allactiondata']) && is_array($res['allactiondata'])) { foreach ($res['allactiondata'] as $akey => $adata) { @@ -2067,13 +2069,15 @@ } $syncPending->queueFromFlow($flow, $providershort, $rescode, ($res['message'] ?? ''), $manualactions, $user, ($res['action'] ?? ''), ($res['actiondata'] ?? array())); dol_syslog(__METHOD__ . " Flow " . $flow['flowId'] . " queued for manual action (" . $rescode . "), synchronization continues.", LOG_WARNING, 0, "_einvoicing"); - $results_messages[] = "Flow " . $flow['flowId'] . " queued for manual action (" . $rescode . "): " . $res['message'] . ""; + $results_messages[] = "Flow " . dol_escape_htmltag((string) $flow['flowId']) . " queued for manual action (" . $rescode . "): " . $res['message'] . ""; $pendingQueued++; continue; } dol_syslog(__METHOD__ . " Failed to synchronize flow " . $flow['flowId'] . ": " . $res['message'], LOG_DEBUG, 0, "_einvoicing"); - $results_messages[] = "ERROR_SYNCFLOW - Failed to synchronize flow " . dol_escape_htmltag((string) $flow['flowId']) . ": " . $res['message']; + $errormessage = "ERROR_SYNCFLOW - Failed to synchronize flow " . dol_escape_htmltag((string) $flow['flowId']) . ": " . $res['message']; + $results_messages[] = $errormessage; + $error_messages[] = $errormessage; $error++; } @@ -2091,14 +2095,16 @@ //$lastsuccessfullSyncronizedFlow = $flow['flowId']; } - // A flow that finally synchronized (or now already exists) leaves the pending queue. When - // an incoming flow created a supplier invoice, keep the link to it (traceability flow -> invoice). + // A flow that finally synchronized (or now already exists) leaves the manual-action queue. + // When an incoming flow created a supplier invoice, keep the link to it for traceability. if ($res['res'] >= 0) { $resolvedElementType = ((($flow['flowDirection'] ?? '') === 'In') ? 'invoice_supplier' : ''); $syncPending->resolveByFlowId($flow['flowId'], $providershort, $user, $resolvedElementType, ($res['res'] > 0 ? (int) $res['res'] : 0)); } } catch (Exception $e) { - $results_messages[] = "Exception occurred while synchronizing flow " . dol_escape_htmltag((string) $flow['flowId']) . ": " . dol_escape_htmltag($e->getMessage()); + $errormessage = "Exception occurred while synchronizing flow " . dol_escape_htmltag((string) $flow['flowId']) . ": " . dol_escape_htmltag($e->getMessage()); + $results_messages[] = $errormessage; + $error_messages[] = $errormessage; $error++; } @@ -2177,10 +2183,11 @@ $messages[] = $langs->trans("TotalPostponedSync") . ": " . $postponedFlows . ""; } if ($pendingQueued > 0) { - // Flows put in the manual-action queue during this run (missing product/thirdparty, ...), - // with a link to the queue where the manual action is done and the flow retried. + // Flows put in the manual-action queue during this run (a missing product or thirdparty, a supplier + // invoice with a different amount): the run carried on instead of stalling on them. Point to the + // queue where the manual action is done and the flow retried. $messages[] = $langs->trans("TotalQueuedForManualAction") . ": " . $pendingQueued . " " - . '
' . $langs->trans("GoToPendingQueue") . ' →'; + . '' . $langs->trans("GoToPendingQueue") . ' →'; } // Processing result that will be saved in DB @@ -2207,6 +2214,7 @@ // Return result // 'actions' contains the action to do (in case of business error) // 'details' will contain all technical error (for Log) + // 'errors' holds only what aborted the run, for a caller that has to report a cause return [ 'res' => $globalres, 'messages' => $messages, @@ -2216,7 +2224,8 @@ 'pendingQueued' => $pendingQueued, 'batchlimit' => $batchlimit, 'actions' => $actions, - 'details' => $results_messages + 'details' => $results_messages, + 'errors' => $error_messages ]; } @@ -2508,21 +2517,30 @@ */ // 2. Read CDAR and update status of linked customer invoice - $flowResource = 'flows/' . $flowId; - $flowUrlparams = array( - 'docType' => 'Original', // docType can be 'Metadata', 'Original', 'Converted' or 'ReadableView' - ); - $flowResource .= '?' . http_build_query($flowUrlparams); - $flowResponse = $this->callApi( - $flowResource, - "GET", - false, - ['Accept' => 'application/octet-stream'] - ); + // Some flows have no 'Original' on the platform, only the converted copy, and the sync then + // stops on that flow and on every flow behind it. Both carry the same CDAR, so fall back on + // the converted one. docType can be 'Metadata', 'Original', 'Converted' or 'ReadableView'. + $flowResponse = $this->fetchFlowData($flowId, 'Original'); if ($flowResponse['status_code'] != 200) { - return array('res' => -1, 'message' => "Failed to retrieve flow details for flowId: " . $flowId); + dol_syslog(__METHOD__ . " No 'Original' document for flowId: " . $flowId . " (HTTP " . $flowResponse['status_code'] . "), reading the CDAR from the 'Converted' document instead", LOG_WARNING); + $flowResponse = $this->fetchFlowData($flowId, 'Converted'); } + + if ($flowResponse['status_code'] != 200) { + // Transient, and nothing was stored for this flow: without 'postponeflow' the batch + // aborts here and on every run after it, since an unstored flow never leaves the + // synchronization window. The #718 convention is meant for exactly this. + return array( + 'res' => -1, + 'postponeflow' => 1, + 'message' => "Failed to retrieve flow details for flowId: " . $flowId, + 'actioncode' => 'CANT_RECORD_SENT_INVOICE_LIFECYCLE_STATUS', + 'actionurl' => '', + 'action' => $langs->trans('CheckSyncLogCantRecordSentInvoiceStatus'), + 'businessmessage' => $langs->trans('CantRecordTheStatusOfTheInvoiceYouSent', $flowId) + ); + } $cdarXml = $flowResponse['response']; dol_include_once('einvoicing/class/utils/CdarHandler.class.php'); @@ -2531,13 +2549,29 @@ try { // Parse the CDAR document (returns an array) - $cdarDocument = $cdarHandler->readFromString($cdarXml); + try { + $cdarDocument = $cdarHandler->readFromString($cdarXml); + } catch (Exception $e) { + // Malformed XML (a JSON error body, an HTML page): it will not parse any better on + // a later run, so it falls into the guard below instead of the catch at the end. + dol_syslog(__METHOD__ . " FlowId " . $flowId . " - " . $e->getMessage(), LOG_WARNING); + $cdarDocument = array(); + } //var_dump($cdarDocument); exit; - // Check if parsing was successful - if (empty($cdarDocument) || !isset($cdarDocument['AcknowledgementDocument'])) { - return array('res' => -1, 'message' => "FlowId: " . $flowId . " - Failed to parse CDAR document"); + // Check the lifecycle code this case exists to record, not the array: a parsed CDAR + // always carries every key, empty or not (CdarHandler::parseReferencedDocument()), so + // a non-empty array proves nothing. Left untested, IssuerAssignedID below reads as '' + // and Facture::fetch(0, '') returns -1 on its own guard - which aborted the batch on + // a message naming an empty reference, and aborted it again on every later run. + if (empty($cdarDocument['AcknowledgementDocument']['ReferenceReferencedDocument']['ProcessConditionCode'])) { + // Not transient: a document that carries no lifecycle status never will. Stored, so + // the next synchronization skips it instead of reading it again. + dol_syslog(__METHOD__ . " FlowId " . $flowId . " carries no readable CDAR", LOG_WARNING); + $returnRes = 0; + $returnMessage = "FlowId: " . $flowId . " - Failed to parse CDAR document"; + break; } $factureObj = new Facture($this->db); @@ -2548,13 +2582,24 @@ $res = $factureObj->fetch(0, $issuerAssignedID); if ($res < 0) { + // A reference matching no invoice returns 0, and is stored below with no invoice + // attached: a negative result is an SQL failure only, so it is worth retrying. return array( 'res' => -1, - 'message' => "FlowId " . $flowId . " - Failed to fetch customer invoice using CDAR IssuerAssignedID/ref: " . $issuerAssignedID + 'postponeflow' => 1, + 'message' => "FlowId " . $flowId . " - Failed to fetch customer invoice using CDAR IssuerAssignedID/ref: " . $issuerAssignedID, + 'actioncode' => 'CANT_RECORD_SENT_INVOICE_LIFECYCLE_STATUS', + 'actionurl' => '', + 'action' => $langs->trans('CheckSyncLogCantRecordSentInvoiceStatus'), + 'businessmessage' => $langs->trans('CantRecordTheStatusOfTheInvoiceYouSent', $flowId) ); } if ($factureObj->entity && $factureObj->entity != $conf->entity) { - return array('res' => -1, 'message' => "Processing flowId: " . $flowId . " - Failed to fetch customer invoice ref " . $document->tracking_idref . " in entity " . $conf->entity); + // That invoice belongs to another entity, so this flow is not this one's business: + // treated exactly like a reference matching nothing (the flow is stored, with no + // invoice attached), instead of aborting the batch and every flow behind it. + dol_syslog(__METHOD__ . " FlowId " . $flowId . " refers to customer invoice " . $factureObj->ref . " of entity " . $factureObj->entity . ", not entity " . $conf->entity, LOG_WARNING); + $factureObj = new Facture($this->db); } $document->fk_element_id = !empty($factureObj->id) ? $factureObj->id : 0; @@ -2660,9 +2705,17 @@ break; } } catch (Exception $e) { + // Nothing is committed when this is reached: the inner block rolls back before it + // rethrows, and what runs after its commit cannot throw. So the flow was not stored + // either, and postponing it retries it whole rather than aborting the batch for good. return array( 'res' => -1, - 'message' => "FlowId " . $flowId . " - Error processing CDAR document - " . $e->getMessage() + 'postponeflow' => 1, + 'message' => "FlowId " . $flowId . " - Error processing CDAR document - " . $e->getMessage(), + 'actioncode' => 'CANT_RECORD_SENT_INVOICE_LIFECYCLE_STATUS', + 'actionurl' => '', + 'action' => $langs->trans('CheckSyncLogCantRecordSentInvoiceStatus'), + 'businessmessage' => $langs->trans('CantRecordTheStatusOfTheInvoiceYouSent', $flowId) ); } @@ -2838,172 +2891,6 @@ return array('res' => $returnRes, 'message' => $returnMessage); } - - /** - * Record a lifecycle status the vendor issued about one of its invoices, onto the supplier - * invoice it refers to. - * - * Never returns a negative result for a status it cannot attach: a vendor may report on an invoice this - * Dolibarr does not hold (refused, or an access point account shared with another system), and failing - * the flow would stall the whole synchronization on it, run after run. The flow is stored either way. - * - * @param string $flowId Flow identifier of the lifecycle message - * @param Document $document Flow document being built, completed here with the CDAR data - * @param EInvoicing $einvoicing E-invoicing helper of the running synchronization - * @return array{res:int, message:string} 1 when the status was attached, 0 when it was only stored - */ - private function processIncomingSupplierInvoiceStatus($flowId, $document, $einvoicing) - { - global $db; - - require_once DOL_DOCUMENT_ROOT . '/fourn/class/fournisseur.facture.class.php'; - dol_include_once('einvoicing/class/utils/CdarHandler.class.php'); - - $flowResource = 'flows/' . $flowId . '?' . http_build_query(array('docType' => 'Original')); - $flowResponse = $this->callApi($flowResource, "GET", false, array('Accept' => 'application/octet-stream')); - if ($flowResponse['status_code'] != 200) { - return array('res' => -1, 'message' => "Failed to retrieve flow details for flowId: " . $flowId); - } - - $cdarHandler = new CdarHandler($db); - $cdarDocument = $cdarHandler->readFromString($flowResponse['response']); - if (empty($cdarDocument) || empty($cdarDocument['AcknowledgementDocument']['ReferenceReferencedDocument'])) { - return array('res' => -1, 'message' => "FlowId: " . $flowId . " - Failed to parse CDAR document"); - } - - $refDoc = $cdarDocument['AcknowledgementDocument']['ReferenceReferencedDocument']; - - $document->cdar_lifecycle_code = $refDoc['ProcessConditionCode']; - $document->cdar_lifecycle_label = isset($refDoc['ProcessCondition']) ? $refDoc['ProcessCondition'] : ''; - $document->cdar_reason_code = isset($refDoc['StatusReasonCode']) ? $refDoc['StatusReasonCode'] : ''; - $document->cdar_reason_desc = isset($refDoc['StatusReason']) ? $refDoc['StatusReason'] : ''; - $document->cdar_reason_detail = isset($refDoc['StatusIncludedNoteContent']) ? $refDoc['StatusIncludedNoteContent'] : ''; - - // The referenced document is the vendor invoice, identified the way its issuer numbered it: - // that is our ref_supplier, and the issuing party is the vendor it belongs to. - $vendorReference = isset($refDoc['IssuerAssignedID']) ? (string) $refDoc['IssuerAssignedID'] : ''; - $vendorLegalId = isset($refDoc['IssuerTradeParty']['GlobalID']) ? (string) $refDoc['IssuerTradeParty']['GlobalID'] : ''; - - $document->tracking_idref = $vendorReference; - - if ($vendorReference === '') { - dol_syslog(__METHOD__ . " FlowId " . $flowId . " carries no IssuerAssignedID, nothing to attach the status to", LOG_WARNING); - return array('res' => 0, 'message' => "FlowId " . $flowId . " - Vendor lifecycle status with no invoice reference"); - } - - $supplierInvoiceId = $this->findSupplierInvoiceByVendorReference($vendorReference, $vendorLegalId); - if ($supplierInvoiceId <= 0) { - dol_syslog(__METHOD__ . " No supplier invoice found for vendor reference " . $vendorReference . " (vendor " . $vendorLegalId . "), flowId " . $flowId, LOG_WARNING); - return array('res' => 0, 'message' => "FlowId " . $flowId . " - No supplier invoice matching the vendor reference " . $vendorReference); - } - - $supplierInvoice = new FactureFournisseur($this->db); - if ($supplierInvoice->fetch($supplierInvoiceId) <= 0) { - return array('res' => 0, 'message' => "FlowId " . $flowId . " - Failed to load supplier invoice id " . $supplierInvoiceId); - } - - $document->fk_element_id = $supplierInvoice->id; - $document->tracking_idref = $supplierInvoice->ref; - - $statusComment = $document->cdar_reason_detail ? $document->cdar_reason_detail : $document->cdar_reason_desc; - - $exceptionmessage = ''; - $db->begin(); - - try { - // The flow_id of the link is left alone on purpose: on a supplier invoice it points at the - // received invoice document, which stays the source of its XML. Only the status moves. - $einvoicing->insertOrUpdateExtLink($supplierInvoice->id, $supplierInvoice->element, '', $document->cdar_lifecycle_code, '', $statusComment); - - $einvoicing->storeStatusMessage( - $supplierInvoice->id, - $supplierInvoice->element, - $document->cdar_lifecycle_code, - $statusComment, - $document->flow_direction, - $flowId, - $document->ack_status, - $document->ack_info, - $document->submittedat, - $document->cdar_reason_code - ); - - $db->commit(); - } catch (Exception $e) { - $exceptionmessage = $e->getMessage(); - - $db->rollback(); - } - - if ($exceptionmessage) { - throw new Exception($exceptionmessage); - } - - $statusLabel = $document->cdar_lifecycle_label ? $document->cdar_lifecycle_label : $document->cdar_lifecycle_code; - $reasonDetail = $document->cdar_reason_detail ? " - " . $document->cdar_reason_detail : ''; - $this->addEvent('STATUS', "EINVOICING - Status: " . $statusLabel, "EINVOICING - Status: " . $statusLabel . $reasonDetail, $supplierInvoice); - - return array('res' => 1, 'message' => "FlowId " . $flowId . " - Vendor status " . $document->cdar_lifecycle_code . " recorded on supplier invoice " . $supplierInvoice->ref); - } - - /** - * Find the supplier invoice a vendor lifecycle status refers to. - * - * A vendor reference is only unique per vendor, never globally, so it is only trusted alone when - * it matches exactly one invoice. When several vendors happen to use the same numbering, the - * legal identifier carried by the CDAR settles it; when it cannot, no invoice is returned rather - * than the wrong one. - * - * @param string $vendorReference Invoice number as assigned by the vendor (BT-1 of the referenced invoice) - * @param string $vendorLegalId Legal identifier of the issuing party, empty when the CDAR carries none - * @return int Supplier invoice id, 0 when there is no single certain match - */ - private function findSupplierInvoiceByVendorReference($vendorReference, $vendorLegalId) - { - global $db; - - $sql = "SELECT f.rowid, s.siren, s.siret, s.tva_intra"; - $sql .= " FROM " . $db->prefix() . "facture_fourn as f"; - $sql .= " INNER JOIN " . $db->prefix() . "societe as s ON s.rowid = f.fk_soc"; - $sql .= " WHERE f.ref_supplier = '" . $db->escape($vendorReference) . "'"; - - $listofentityids = getEntity('facture_fourn'); - if (getDolGlobalString('EINVOICING_ALLOW_MULTICOMPANY_INVOICE_MOVE')) { - $listofentityids .= ','.getDolGlobalString('EINVOICING_ALLOW_MULTICOMPANY_INVOICE_MOVE'); - } - $sql .= " AND f.entity IN (" . $db->sanitize($listofentityids) . ")"; - - $resql = $db->query($sql); - if (!$resql) { - dol_syslog(__METHOD__ . " " . $db->lasterror(), LOG_ERR); - return 0; - } - - $candidates = array(); - while ($obj = $db->fetch_object($resql)) { - $candidates[] = $obj; - } - $db->free($resql); - - if (count($candidates) == 1) { - return (int) $candidates[0]->rowid; - } - if (empty($candidates) || $vendorLegalId === '') { - return 0; - } - - // Several invoices carry that number: only the one whose vendor is the issuer of the status. - $matches = array(); - foreach ($candidates as $candidate) { - if ($vendorLegalId === (string) $candidate->siren - || $vendorLegalId === (string) $candidate->siret - || $vendorLegalId === (string) $candidate->tva_intra) { - $matches[] = (int) $candidate->rowid; - } - } - - return count($matches) == 1 ? $matches[0] : 0; - } /** * Send status message of an invoice to PDP/PA diff -ruN --exclude=vendor --exclude=.git _base_lf/class/utils/CdarHandler.class.php cm_repo/einvoicing/class/utils/CdarHandler.class.php --- _base_lf/class/utils/CdarHandler.class.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/class/utils/CdarHandler.class.php 2026-09-11 06:39:46 @@ -195,7 +195,7 @@ /** * generate * - * Values coming from the data are escaped with htmlspecialchars() before they reach + * Values coming from the data are escaped with einvoicingXmlText() before they reach * DOMDocument::createElement(), which parses its second argument: an ampersand in a free text * (a rejection reason, a party name) would otherwise produce an empty element and lose the * information - same defect as issue #695 on the invoice side. @@ -578,7 +578,9 @@ 'TypeCode' => 'MPA', 'ValueAmount' => number_format($paidAmount, 2, '.', ''), 'CurrencyID' => $conf->currency, - 'ValueDateTime' => dol_print_date($paidDate, '%Y%m%d') + // 'tzserver' like the other dates read from the invoice, and not the 'auto' default: + // the day the payment was made must not follow the timezone of whoever sends the status. + 'ValueDateTime' => dol_print_date($paidDate, '%Y%m%d', 'tzserver') ) ); } @@ -909,7 +911,7 @@ $context->appendChild($process); $guideline = $dom->createElement('ram:GuidelineSpecifiedDocumentContextParameter'); - $guideline->appendChild($dom->createElement('ram:ID', htmlspecialchars((string) $guidelineID))); + $guideline->appendChild($dom->createElement('ram:ID', einvoicingXmlText((string) $guidelineID))); $context->appendChild($guideline); $root->appendChild($context); } @@ -927,7 +929,7 @@ private function addDateTimeElement($dom, $parent, $elementName, $value, $format) { $element = $dom->createElement($elementName); - $dateTimeStr = $dom->createElement('udt:DateTimeString', htmlspecialchars((string) $value)); + $dateTimeStr = $dom->createElement('udt:DateTimeString', einvoicingXmlText((string) $value)); $dateTimeStr->setAttribute('format', $format); $element->appendChild($dateTimeStr); $parent->appendChild($element); @@ -947,18 +949,18 @@ $party = $dom->createElement($elementName); if (isset($data['GlobalID'])) { - $globalID = $dom->createElement('ram:GlobalID', htmlspecialchars((string) $data['GlobalID'])); + $globalID = $dom->createElement('ram:GlobalID', einvoicingXmlText((string) $data['GlobalID'])); if (!empty($data['SchemeID'])) { $globalID->setAttribute('schemeID', $data['SchemeID']); } $party->appendChild($globalID); } - $party->appendChild($dom->createElement('ram:RoleCode', htmlspecialchars((string) $data['RoleCode']))); + $party->appendChild($dom->createElement('ram:RoleCode', einvoicingXmlText((string) $data['RoleCode']))); if (isset($data['URIID'])) { $uriComm = $dom->createElement('ram:URIUniversalCommunication'); - $uriID = $dom->createElement('ram:URIID', htmlspecialchars((string) $data['URIID'])); + $uriID = $dom->createElement('ram:URIID', einvoicingXmlText((string) $data['URIID'])); $uriID->setAttribute('schemeID', $data['URISchemeID']); $uriComm->appendChild($uriID); $party->appendChild($uriComm); @@ -1089,8 +1091,8 @@ private function addExchangedDocument($dom, $root, $doc) { $exchanged = $dom->createElement('rsm:ExchangedDocument'); - $exchanged->appendChild($dom->createElement('ram:ID', htmlspecialchars((string) $doc['ID']))); - $exchanged->appendChild($dom->createElement('ram:Name', htmlspecialchars((string) $doc['Name']))); + $exchanged->appendChild($dom->createElement('ram:ID', einvoicingXmlText((string) $doc['ID']))); + $exchanged->appendChild($dom->createElement('ram:Name', einvoicingXmlText((string) $doc['Name']))); $this->addDateTimeElement($dom, $exchanged, 'ram:IssueDateTime', $doc['IssueDateTime'], self::FORMAT_DATETIME); @@ -1118,7 +1120,7 @@ $multipleRef->appendChild($indicator); $ack->appendChild($multipleRef); - $ack->appendChild($dom->createElement('ram:TypeCode', htmlspecialchars((string) $doc['TypeCode']))); + $ack->appendChild($dom->createElement('ram:TypeCode', einvoicingXmlText((string) $doc['TypeCode']))); $this->addDateTimeElement($dom, $ack, 'ram:IssueDateTime', $doc['IssueDateTime'], self::FORMAT_DATETIME); $this->addReferencedDocument($dom, $ack, $doc['ReferenceReferencedDocument']); @@ -1136,24 +1138,24 @@ private function addReferencedDocument($dom, $parent, $doc) { $ref = $dom->createElement('ram:ReferenceReferencedDocument'); - $ref->appendChild($dom->createElement('ram:IssuerAssignedID', htmlspecialchars((string) $doc['IssuerAssignedID']))); - $ref->appendChild($dom->createElement('ram:StatusCode', htmlspecialchars((string) $doc['StatusCode']))); - $ref->appendChild($dom->createElement('ram:TypeCode', htmlspecialchars((string) $doc['TypeCode']))); + $ref->appendChild($dom->createElement('ram:IssuerAssignedID', einvoicingXmlText((string) $doc['IssuerAssignedID']))); + $ref->appendChild($dom->createElement('ram:StatusCode', einvoicingXmlText((string) $doc['StatusCode']))); + $ref->appendChild($dom->createElement('ram:TypeCode', einvoicingXmlText((string) $doc['TypeCode']))); // MDT-97. Its place in the CDAR XSD sequence (ReferencedDocumentType) is after ReceiptDateTime / // AttachmentBinaryObject and before FormattedIssueDateTime - the order the platforms use too. if (!empty($doc['ReferenceTypeCode'])) { - $ref->appendChild($dom->createElement('ram:ReferenceTypeCode', htmlspecialchars((string) $doc['ReferenceTypeCode']))); + $ref->appendChild($dom->createElement('ram:ReferenceTypeCode', einvoicingXmlText((string) $doc['ReferenceTypeCode']))); } $formattedDateTime = $dom->createElement('ram:FormattedIssueDateTime'); - $dateTimeStr = $dom->createElement('qdt:DateTimeString', htmlspecialchars((string) $doc['FormattedIssueDateTime'])); + $dateTimeStr = $dom->createElement('qdt:DateTimeString', einvoicingXmlText((string) $doc['FormattedIssueDateTime'])); $dateTimeStr->setAttribute('format', self::FORMAT_DATE); $formattedDateTime->appendChild($dateTimeStr); $ref->appendChild($formattedDateTime); - $ref->appendChild($dom->createElement('ram:ProcessConditionCode', htmlspecialchars((string) $doc['ProcessConditionCode']))); - $ref->appendChild($dom->createElement('ram:ProcessCondition', htmlspecialchars((string) $doc['ProcessCondition']))); + $ref->appendChild($dom->createElement('ram:ProcessConditionCode', einvoicingXmlText((string) $doc['ProcessConditionCode']))); + $ref->appendChild($dom->createElement('ram:ProcessCondition', einvoicingXmlText((string) $doc['ProcessCondition']))); $this->addTradeParty($dom, $ref, 'ram:IssuerTradeParty', $doc['IssuerTradeParty']); $parent->appendChild($ref); @@ -1163,13 +1165,13 @@ if (!empty($doc['SpecifiedDocumentStatus']['ReasonCode'])) { $status->appendChild( - $dom->createElement('ram:ReasonCode', htmlspecialchars((string) $doc['SpecifiedDocumentStatus']['ReasonCode'])) + $dom->createElement('ram:ReasonCode', einvoicingXmlText((string) $doc['SpecifiedDocumentStatus']['ReasonCode'])) ); } if (!empty($doc['SpecifiedDocumentStatus']['Reason'])) { $status->appendChild( - $dom->createElement('ram:Reason', htmlspecialchars((string) $doc['SpecifiedDocumentStatus']['Reason'])) + $dom->createElement('ram:Reason', einvoicingXmlText((string) $doc['SpecifiedDocumentStatus']['Reason'])) ); } @@ -1187,10 +1189,10 @@ if (!empty($doc['SpecifiedDocumentStatus']['SpecifiedDocumentCharacteristic'])) { foreach ($doc['SpecifiedDocumentStatus']['SpecifiedDocumentCharacteristic'] as $characteristic) { $characteristicElement = $dom->createElement('ram:SpecifiedDocumentCharacteristic'); - $characteristicElement->appendChild($dom->createElement('ram:TypeCode', htmlspecialchars((string) $characteristic['TypeCode']))); + $characteristicElement->appendChild($dom->createElement('ram:TypeCode', einvoicingXmlText((string) $characteristic['TypeCode']))); if (isset($characteristic['ValueAmount'])) { - $amountElement = $dom->createElement('ram:ValueAmount', htmlspecialchars((string) $characteristic['ValueAmount'])); + $amountElement = $dom->createElement('ram:ValueAmount', einvoicingXmlText((string) $characteristic['ValueAmount'])); if (!empty($characteristic['CurrencyID'])) { $amountElement->setAttribute('currencyID', $characteristic['CurrencyID']); } @@ -1202,7 +1204,7 @@ } if (isset($characteristic['ValuePercent'])) { - $characteristicElement->appendChild($dom->createElement('ram:ValuePercent', htmlspecialchars((string) $characteristic['ValuePercent']))); + $characteristicElement->appendChild($dom->createElement('ram:ValuePercent', einvoicingXmlText((string) $characteristic['ValuePercent']))); } $status->appendChild($characteristicElement); diff -ruN --exclude=vendor --exclude=.git _base_lf/class/utils/SupplierInvoiceHelper.class.php cm_repo/einvoicing/class/utils/SupplierInvoiceHelper.class.php --- _base_lf/class/utils/SupplierInvoiceHelper.class.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/class/utils/SupplierInvoiceHelper.class.php 2026-09-11 06:39:46 @@ -20,10 +20,9 @@ * \file einvoicing/class/utils/SupplierInvoiceHelper.class.php * \ingroup einvoicing * \brief Utility class for supplier invoices. - * This file is mainly used when EINVOICING_SUPPLIER_INVOICE_CHECK_CONSISTENCY_ON_VALIDATION is set but - * this option is seriously bugged. Do not use it. */ +dol_include_once('einvoicing/class/einvoicing.class.php'); dol_include_once('einvoicing/class/protocols/ProtocolManager.class.php'); dol_include_once('einvoicing/class/document.class.php'); dol_include_once('einvoicing/class/utils/PriceHelper.class.php'); @@ -477,6 +476,113 @@ } return 1; + } + + /** + * Tell whether an invoice totals what the received document announces. + * + * Compared on the absolute values: a credit note is stored negative by Dolibarr while BT-110 and + * BT-112 are always announced positive, the document type being what carries the sign (BR-CO-13 + * applies to a credit note as it does to an invoice). The tolerance is there for the float + * representation, not for a difference: the document carries its totals to the cent. + * + * @param FactureFournisseur $invoice The invoice, with its totals as stored + * @param float $announcedTva BT-110 of the received document, absolute value + * @param float $announcedTtc BT-112 of the received document, absolute value + * @return bool True when both totals are the announced ones + */ + public static function totalsAgreeWithDocument(FactureFournisseur $invoice, $announcedTva, $announcedTtc) + { + return abs(abs((float) $invoice->total_tva) - (float) $announcedTva) < 0.005 + && abs(abs((float) $invoice->total_ttc) - (float) $announcedTtc) < 0.005; + } + + /** + * Mark a supplier invoice as not totalling what the document it was imported from announces. + * + * The announced totals are kept with the mark, so what it blocks can be re-evaluated without the + * document: an operator who corrects the invoice to the figures the vendor bills lifts the block + * by doing so (issue #861). + * + * @param int $supplierInvoiceId Id of the supplier invoice the import created + * @param float $announcedTva BT-110 of the received document, absolute value + * @param float $announcedTtc BT-112 of the received document, absolute value + * @return int -1 on error, >0 otherwise + */ + public static function flagTotalsMismatch($supplierInvoiceId, $announcedTva, $announcedTtc) + { + global $db; + + $einvoicing = new EInvoicing($db); + $value = json_encode(array('tva' => (float) $announcedTva, 'ttc' => (float) $announcedTtc)); + + return $einvoicing->insertOrUpdateExtraField((int) $supplierInvoiceId, 'invoice_supplier', EInvoicing::EXTRAFIELD_TOTALS_MISMATCH, (string) $value); + } + + /** + * Read the mark left by an import that could not reproduce the totals of the document. + * + * @param int $supplierInvoiceId Id of the supplier invoice + * @return ?array{tva:float,ttc:float} The totals the document announces, or null when the invoice carries no mark + */ + public static function totalsMismatch($supplierInvoiceId) + { + global $db; + + $einvoicing = new EInvoicing($db); + $stored = $einvoicing->getExtraFieldValue((int) $supplierInvoiceId, 'invoice_supplier', EInvoicing::EXTRAFIELD_TOTALS_MISMATCH); + if ($stored === null || $stored === '') { + return null; + } + + $decoded = json_decode($stored, true); + if (!is_array($decoded) || !isset($decoded['tva']) || !isset($decoded['ttc'])) { + return null; + } + + return array('tva' => (float) $decoded['tva'], 'ttc' => (float) $decoded['ttc']); + } + + /** + * Remove the mark, once the invoice totals what the document announces. + * + * @param int $supplierInvoiceId Id of the supplier invoice + * @return int -1 on error, >0 otherwise + */ + public static function clearTotalsMismatch($supplierInvoiceId) + { + global $db; + + $einvoicing = new EInvoicing($db); + + return $einvoicing->insertOrUpdateExtraField((int) $supplierInvoiceId, 'invoice_supplier', EInvoicing::EXTRAFIELD_TOTALS_MISMATCH, ''); + } + + /** + * Tell whether a supplier invoice still disagrees with the document it was imported from. + * + * The mark alone is not the answer: it says the import could not reproduce the document, and the + * invoice may have been corrected since. So the totals are confronted again, and a mark that no + * longer holds blocks nothing. + * + * @param int $supplierInvoiceId Id of the supplier invoice + * @return bool True while the invoice does not total what the document announces + */ + public static function totalsMismatchBlocks($supplierInvoiceId) + { + global $db; + + $announced = self::totalsMismatch((int) $supplierInvoiceId); + if ($announced === null) { + return false; + } + + $invoice = new FactureFournisseur($db); + if ($invoice->fetch((int) $supplierInvoiceId) <= 0) { + return true; + } + + return !self::totalsAgreeWithDocument($invoice, $announced['tva'], $announced['ttc']); } /** diff -ruN --exclude=vendor --exclude=.git _base_lf/core/modules/modEInvoicing.class.php cm_repo/einvoicing/core/modules/modEInvoicing.class.php --- _base_lf/core/modules/modEInvoicing.class.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/core/modules/modEInvoicing.class.php 2026-09-11 06:53:27 @@ -188,7 +188,15 @@ 'fr_FR:ParentCompany'=>'Maison mère ou revendeur' )*/ - if (!isModEnabled("einvoicing")) { + // For retrocompatibility with older Dolibarr versions to avoid crashing when module is present into custom modules repository of an older Dolibarr installation + if (function_exists('isModEnabled')) { + $moduleIsEnabled = isModEnabled("einvoicing"); + } else { + $moduleIsEnabled = !empty($conf->einvoicing->enabled); + } + + // Check if the module is enabled and initialize the configuration if not + if (!$moduleIsEnabled) { $conf->einvoicing = new stdClass(); $conf->einvoicing->enabled = 0; } @@ -197,10 +205,8 @@ /* BEGIN MODULEBUILDER TABS */ // Don't forget to deactivate/reactivate your module to test your changes $this->tabs = array(); - //$this->tabs[] = array('data' => 'invoice:+CustomerLCtab:einvoicecustomerlctab:einvoicing@einvoicing:$user->hasRight("facture", "read"):/einvoicing/einvoice_object_timeline.php?id=__ID__'); + $this->tabs[] = array('data' => 'invoice:+EinvoiceEvents:EinvoiceEventsTab:@einvoicing:$user->hasRight("facture","read"):/einvoicing/einvoice_tracking.php?id=__ID__'); - //$this->tabs[] = array('data' => 'invoice:+EinvoiceEvents:EinvoiceEventsTab:@einvoicing:$user->hasRight("facture","read"):/einvoicing/einvoice_events.php?id=__ID__&elementtype=invoice'); - /* END MODULEBUILDER TABS */ // Example: // To add a new tab identified by code tabname1 @@ -432,7 +438,7 @@ 'object' => '', ); /* END MODULEBUILDER LEFTMENU PDPMAPPEDVENDORREFS */ - /* BEGIN LEFTMENU PDPSYNCPENDING */ + /* BEGIN MODULEBUILDER LEFTMENU PDPSYNCPENDING */ $this->menu[$r++] = array( 'fk_menu' => 'fk_mainmenu=billing,fk_leftmenu=einvoicing_documents', 'type' => 'left', @@ -442,13 +448,13 @@ 'url' => '/einvoicing/sync_pending_list.php', 'langs' => 'einvoicing@einvoicing', 'position' => 1004, - 'enabled' => 'isModEnabled("einvoicing")', + 'enabled' => 'isModEnabled("einvoicing") && !getDolGlobalString("EINVOICING_ONLY_GENERATE")', 'perms' => '$user->hasRight("einvoicing", "read")', 'target' => '', 'user' => 2, 'object' => '', ); - /* END LEFTMENU PDPSYNCPENDING */ + /* END MODULEBUILDER LEFTMENU PDPSYNCPENDING */ /* BEGIN MODULEBUILDER LEFTMENU PDPSOCIETIES */ // $this->menu[$r++] = array( // 'fk_menu' => 'fk_mainmenu=billing,fk_leftmenu=einvoicing_billing', diff -ruN --exclude=vendor --exclude=.git _base_lf/core/triggers/interface_98_modEInvoicing_EInvoicingTriggers.class.php cm_repo/einvoicing/core/triggers/interface_98_modEInvoicing_EInvoicingTriggers.class.php --- _base_lf/core/triggers/interface_98_modEInvoicing_EInvoicingTriggers.class.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/core/triggers/interface_98_modEInvoicing_EInvoicingTriggers.class.php 2026-09-11 06:39:46 @@ -150,6 +150,7 @@ if ($action == 'BILL_CREATE') { /** @var Facture $object */ '@phan-var-force Facture $object'; + /** @var Facture $object */ if (!getDolGlobalString('EINVOICING_DISABLE_SYNC_DOLI_TO_AP')) { // If sync Dolibarr to AP is on $einvoicing = new EInvoicing($this->db); @@ -172,6 +173,7 @@ if ($action == 'BILL_VALIDATE') { /** @var Facture $object */ '@phan-var-force Facture $object'; + /** @var Facture $object */ // Tell the afterPDFCreation() hook that the document rebuild about to happen is the one that // follows a validation. Set unconditionally and before anything else: this only records a fact @@ -210,6 +212,7 @@ if ($action == 'BILL_UNVALIDATE') { /** @var Facture $object */ '@phan-var-force Facture $object'; + /** @var Facture $object */ $einvoicing = new EInvoicing($this->db); // Lock on the REAL PA state (persistent flow_id), not the Dolibarr syncstatus which is reset to @@ -224,6 +227,7 @@ if ($action == 'BILL_DELETE') { /** @var Facture $object */ '@phan-var-force Facture $object'; + /** @var Facture $object */ $einvoicing = new EInvoicing($this->db); // Lock on the REAL PA state (persistent flow_id), see BILL_UNVALIDATE above. @@ -236,6 +240,7 @@ if ($action == 'BILL_MODIFY') { /** @var Facture $object */ '@phan-var-force Facture $object'; + /** @var Facture $object */ $einvoicing = new EInvoicing($this->db); // Lock on the REAL PA state (persistent flow_id), see BILL_UNVALIDATE above. @@ -277,6 +282,7 @@ if ($action == 'PAYMENT_CUSTOMER_CREATE') { /** @var Paiement $object */ '@phan-var-force Paiement $object'; + /** @var Paiement $object */ if (!einvoicingIsSendDisabled()) { // If sync Dolibarr to AP is on require_once DOL_DOCUMENT_ROOT . '/compta/facture/class/facture.class.php'; @@ -302,6 +308,25 @@ if ($action == 'BILL_SUPPLIER_VALIDATE') { /** @var FactureFournisseur $object */ '@phan-var-force FactureFournisseur $object'; + /** @var FactureFournisseur $object */ + // An invoice the import could not make total what its document announces never becomes + // payable by being validated: the totals are confronted again here, so an invoice corrected + // to the figures the vendor bills validates normally and drops the mark (issue #861). + $announced = SupplierInvoiceHelper::totalsMismatch((int) $object->id); + if ($announced !== null) { + if (SupplierInvoiceHelper::totalsAgreeWithDocument($object, $announced['tva'], $announced['ttc'])) { + SupplierInvoiceHelper::clearTotalsMismatch((int) $object->id); + } else { + $this->errors[] = $langs->trans( + 'EInvoiceTotalsMismatchBlocksValidation', + price2num($announced['ttc'], 'MT'), + price2num($announced['tva'], 'MT'), + price2num(abs((float) $object->total_ttc), 'MT') + ); + return -1; + } + } + $duplicate = false; if (getDolGlobalInt('EINVOICING_SUPPLIER_INVOICE_CHECK_CONSISTENCY_ON_VALIDATION') && SupplierInvoiceHelper::isEInvoice($object->id, false, $duplicate)) { if ($duplicate) { @@ -361,6 +386,7 @@ if ($action == 'BILL_SUPPLIER_PAYED') { /** @var FactureFournisseur $object */ '@phan-var-force FactureFournisseur $object'; + /** @var FactureFournisseur $object */ if (getDolGlobalInt('EINVOICING_SEND_PAYMENT_SENT_STATUS') && !einvoicingIsSendDisabled()) { $paidAmount = (float) $object->getSommePaiement(); @@ -394,6 +420,7 @@ if ($action == 'BILL_SUPPLIER_DELETE') { /** @var FactureFournisseur $object */ '@phan-var-force FactureFournisseur $object'; + /** @var FactureFournisseur $object */ $duplicate = false; if (SupplierInvoiceHelper::isEInvoice($object->id, true, $duplicate)) { if ($duplicate) { @@ -434,6 +461,7 @@ * @var Document $object */ '@phan-var-force Document $object'; + /** @var Document $object */ $duplicate = false; // A flow does not always carry a supplier invoice id: a lifecycle message never resolves one, diff -ruN --exclude=vendor --exclude=.git _base_lf/document_card.php cm_repo/einvoicing/document_card.php --- _base_lf/document_card.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/document_card.php 2026-09-11 06:39:46 @@ -589,7 +589,7 @@ // Clone if ($permissiontoadd) { - print dolGetButtonAction('', $langs->trans('ToClone'), 'clone', $_SERVER['PHP_SELF'].'?id='.$object->id.(!empty($object->socid) ? '&socid='.$object->socid : '').'&action=clone&token='.newToken(), '', $permissiontoadd); + print dolGetButtonAction('', $langs->trans('ToClone'), 'clone', $_SERVER['PHP_SELF'].'?id='.$object->id.'&action=clone&token='.newToken(), '', $permissiontoadd); } /* diff -ruN --exclude=vendor --exclude=.git _base_lf/einvoice_tracking.php cm_repo/einvoicing/einvoice_tracking.php --- _base_lf/einvoice_tracking.php 1970-01-01 01:00:00 +++ cm_repo/einvoicing/einvoice_tracking.php 2026-09-11 06:39:46 @@ -0,0 +1,230 @@ +. + */ + +/** + * \file einvoicing/einvoice_tracking.php + * \ingroup einvoicing + * \brief Tab on the customer invoice card to track its e-invoicing lifecycle (current status + full history) + */ + +// Load Dolibarr environment +$res = 0; +// Try main.inc.php into web root known defined into CONTEXT_DOCUMENT_ROOT (not always defined) +if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) { + $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php"; +} +// Try main.inc.php into web root detected using web root calculated from SCRIPT_FILENAME +$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"; +} +// Try main.inc.php using relative path +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 && file_exists("../../../../main.inc.php")) { + $res = @include "../../../../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} +/** + * The main.inc.php has been included so the following variable are now defined: + * @var Conf $conf + * @var DoliDB $db + * @var HookManager $hookmanager + * @var Translate $langs + * @var User $user + */ +require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php'; +require_once DOL_DOCUMENT_ROOT.'/core/lib/invoice.lib.php'; +dol_include_once('/einvoicing/class/einvoicing.class.php'); +dol_include_once('/einvoicing/lib/einvoicing_lifecycle.lib.php'); + +// Load translation files required by the page +$langs->loadLangs(array('bills', 'einvoicing@einvoicing')); + +$id = GETPOSTINT('id'); +$ref = GETPOST('ref', 'alpha'); + +// Security check (enable the most restrictive one) +if (!isModEnabled('einvoicing')) { + accessforbidden('Module einvoicing not enabled'); +} +if (!$user->hasRight('einvoicing', 'read')) { + accessforbidden(); +} + +$object = new Facture($db); +if ($id > 0 || !empty($ref)) { + $object->fetch($id, $ref); +} + +// Standard invoice access rules (the einvoicing read right alone must not expose an invoice +// the user cannot otherwise read). +$result = restrictedArea($user, 'facture', $object->id); + +// This tab only wires customer invoices (element_type 'facture'); supplier invoices write lifecycle +// events to the same table under 'invoice_supplier' and have their own separate UI, out of scope here. +// The reader below (EInvoicing::fetchLifecycleEvents()) is not specific to either type. +$elementType = 'facture'; + + +/* + * View + */ + +$title = ($object->id > 0 ? $object->ref.' - ' : '').$langs->trans('EinvoiceEventsTab'); +llxHeader('', $title); + +$einvoicing = new EInvoicing($db); + +if ($object->id > 0) { + $object->fetch_thirdparty(); + + $head = facture_prepare_head($object); + print dol_get_fiche_head($head, 'einvoicetracking', $langs->trans('InvoiceCustomer'), -1, $object->picto); + + $linkback = ''.$langs->trans("BackToList").''; + + $morehtmlref = '
'; + $morehtmlref .= $object->thirdparty->getNomUrl(1, 'customer'); + $morehtmlref .= '
'; + + dol_banner_tab($object, 'ref', $linkback, 1, 'ref', 'ref', $morehtmlref, '', 0, '', '', 1); + + print dol_get_fiche_end(); + + // --- Full lifecycle history for this invoice --- + $events = $einvoicing->fetchLifecycleEvents($elementType, $object->id); + + // --- Current status: last event per actor (Fournisseur / PDP-PA / Client) --- + // Ported from the LemonSuperPDP module (tab_lifecycle.php), generalized to einvoicing's + // multi-provider status model. + print load_fiche_titre($langs->trans('EInvCurrentStatus'), '', ''); + + $last = array('fournisseur' => null, 'pdp' => null, 'client' => null); + foreach (array_reverse($events) as $evt) { + $flux = einvoicingLifecycleFlux($evt['lc_status'], $evt['direction']); + if ($last[$flux] === null) { + $last[$flux] = $evt; + } + } + + // Before the first PDP exchange, llx_einvoicing_lifecycle_msg has nothing to show yet: fall back to + // the local Dolibarr-side status (not generated / generated / generation error) for the Fournisseur + // card only, since it is the only actor with a meaningful state at that stage. + $localStatus = empty($last['fournisseur']) ? $einvoicing->fetchLastknownInvoiceStatus($object->id, $object->ref) : null; + + $actors = array( + 'fournisseur' => array('label' => $langs->trans('EInvActorSeller'), 'color' => '#185FA5', 'dot' => '#185FA5'), + 'pdp' => array('label' => $langs->trans('EInvActorPlatform'), 'color' => '#5F5E5A', 'dot' => '#888780'), + 'client' => array('label' => $langs->trans('EInvActorBuyer'), 'color' => '#3B6D11', 'dot' => '#3B6D11'), + ); + print '
'; + foreach ($actors as $fluxKey => $actor) { + $evt = $last[$fluxKey]; + print '
'; + print '
'; + print ''; + print ''.dol_escape_htmltag($actor['label']).''; + print '
'; + if ($evt) { + $evtFull = einvoicingLifecycleLabel($einvoicing, (int) $evt['lc_status'], (string) $evt['lc_status_message']); + print '
'.dol_escape_htmltag($evtFull).'
'; + print '
'.dol_print_date($evt['date_creation'], 'dayhour').'
'; + } elseif ($fluxKey === 'fournisseur' && !empty($localStatus)) { + print '
'.dol_escape_htmltag((string) $localStatus['status']).'
'; + print '
'.$langs->trans('EInvLocalStatusNotYetTransmitted').'
'; + } else { + print '
'.$langs->trans('EInvNoLifecycleEvent').'
'; + } + print '
'; + } + print '
'; + + // --- Detailed log (full text: validation status/message, reason code) --- + print load_fiche_titre($langs->trans('EInvLifecycleHistory'), '', ''); + + print '
'; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + if (!empty($events)) { + foreach ($events as $evt) { + $isOut = (strtolower((string) $evt['direction']) == 'out'); + + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + } + } else { + print ''; + } + + print '
'.$langs->trans('Date').''.$langs->trans('provider').''.$langs->trans('EInvDirection').''.$langs->trans('Status').''.$langs->trans('Comments').''.$langs->trans('EInvValidationStatus').'
'.dol_print_date($evt['date_creation'], 'dayhour').''.dol_escape_htmltag((string) $evt['provider']).''.($isOut ? img_picto($langs->trans('EInvDirectionOut'), 'sign-out', 'class="paddingright"') : img_picto($langs->trans('EInvDirectionIn'), 'sign-in-alt', 'class="paddingright"')).dol_escape_htmltag(strtoupper((string) $evt['direction'])).''.dol_escape_htmltag($einvoicing->getStatusLabel((int) $evt['lc_status'])); + if (!empty($evt['lc_reason_code'])) { + print ' ('.dol_escape_htmltag((string) $evt['lc_reason_code']).')'; + } + print ''.dol_escape_htmltag((string) $evt['lc_status_message']); + if (!empty($evt['lc_validation_message'])) { + print '
'.dol_escape_htmltag((string) $evt['lc_validation_message']).''; + } + print '
'; + // lc_validation_status is only filled for outbound ('out') events; an inbound row has it + // empty by construction and that must not be read as a failed validation. + if ($isOut && !empty($evt['lc_validation_status'])) { + print dol_escape_htmltag((string) $evt['lc_validation_status']); + } + print '
'.$langs->trans('EInvNoLifecycleEvent').'
'; + print '
'; +} else { + // Record not found + recordNotFound('', 0); +} + +// End of page +llxFooter(); +$db->close(); diff -ruN --exclude=vendor --exclude=.git _base_lf/einvoicingindex.php cm_repo/einvoicing/einvoicingindex.php --- _base_lf/einvoicingindex.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/einvoicingindex.php 2026-09-11 06:39:46 @@ -72,6 +72,7 @@ * @var User $user */ '@phan-var-force User $user'; +/** @var User $user */ include_once DOL_DOCUMENT_ROOT.'/core/class/html.formfile.class.php'; // Load translation files required by the page diff -ruN --exclude=vendor --exclude=.git _base_lf/langs/en_US/einvoicing.lang cm_repo/einvoicing/langs/en_US/einvoicing.lang --- _base_lf/langs/en_US/einvoicing.lang 2026-09-11 07:09:36 +++ cm_repo/einvoicing/langs/en_US/einvoicing.lang 2026-09-11 06:53:27 @@ -57,6 +57,12 @@ EINVOICING_VAT_POINT_DATE_CODE_5=Invoicing date (VAT on debits) EINVOICING_VAT_POINT_DATE_CODE_29=Date of delivery of goods (VAT on supply of goods) EINVOICING_VAT_POINT_DATE_CODE_72=Payment date (VAT on collection / cash accounting) +EINVOICING_PARTY_IDENTIFIER_SCHEME=Scheme of the party identifier (BT-29, BT-46) +EINVOICING_PARTY_IDENTIFIER_SCHEME_HELP=Identification scheme the seller and buyer identifiers (BT-29 and BT-46 of EN 16931) are declared under. This is not the electronic address the invoice is routed to (BT-34, BT-49), which always stays on the national directory scheme 0225. The legal registration identifier (BT-30, BT-47) is not affected either: it stays on 0002, the SIREN, as the French specification requires. Outside France, leave the field empty to keep the code the module answers for your country, or enter the ISO 6523 ICD code of the register your professional identifier comes from (0007 Sweden, 0106 Netherlands, 0184 Denmark, 0208 Belgium, 0211 Italy...). +EINVOICING_PARTY_IDENTIFIER_SCHEME_0225=0225 - National directory address, carrying the SIREN (current behaviour) +EINVOICING_PARTY_IDENTIFIER_SCHEME_0009=0009 - SIRET, when the third party record holds one +EINVOICING_PARTY_IDENTIFIER_SCHEME_NONE=Do not declare a party identifier +EINVOICING_PARTY_IDENTIFIER_SCHEME_PLACEHOLDER=ISO 6523 ICD code, or empty EINVOICING_VAT_EXIGIBILITY=VAT exigibility EINVOICING_VAT_EXIGIBILITY_HELP=Tells when the VAT of an invoice falls due, hence the VAT point date code the document declares, the "VAT on debits" mention it carries and whether a cash-in is reported with the "Cashed in" (212) status. This module does not define that scheme, it reads the VAT mode of the Tax/VAT module setup. Current value, goods / services: EINVOICING_MAX_FILE_SIZE_MB=Maximum e-invoice file size (MB) @@ -178,6 +184,8 @@ EINVOICING_EINVOICE_CANCEL_IF_EINVOICE_FAILS2=By default, the invoice creation is done even if E-invoice generation fails. In such a case, you can re-generate this E-invoice later. EINVOICING_PRODUCTS_AUTO_GENERATION=Automatic generation of missing products EINVOICING_PRODUCTS_AUTO_GENERATION_HELP=When importing a supplier invoice, if the bought product can't be found in the database, and if there is no default product to use on the supplier card, enabling this option will automatically create missing products during Access Point synchronization, otherwise the synchronization process is stopped until the product has been created manually or a default product for import is set on the vendor card. +EINVOICING_PRODUCTS_MATCH_CANONICAL_REF=Match vendor product references whatever their writing +EINVOICING_PRODUCTS_MATCH_CANONICAL_REF_HELP=When a line of an incoming invoice carries a vendor product reference that no supplier price matches exactly, compare it again ignoring separators, case and accents, so that 'A1234-10_42' and 'A1234|10|42' are read as the same reference. This comparison is an approximation and can merge two references that differ only by their separators, so it is disabled by default. The exact comparison is always tried first. When several products of the supplier share the same simplified form, the line is left to the manual product mapping and a warning is written in the log. EINVOICING_IMPORT_AS_FREE_LINES=Import lines without product as free description lines EINVOICING_IMPORT_AS_FREE_LINES_HELP=When importing a supplier invoice, if the bought product can't be found in the database, and if there is no default product to use on the supplier card, and automatic product creation is disabled, enabling this option will import the line as a free description line (using the XML label and description) without linking any product. If disabled, the synchronisation is stopped until the problem is resolved manually. Enabling this option is a VERY BAD IDEA! EINVOICING_THIRDPARTIES_AUTO_GENERATION=Automatic generation of missing third parties @@ -324,6 +332,17 @@ EInvoiceEventsLabel=E-invoicing events EInvoiceFile=E-invoice File PDPSyncStatus=E-invoice Status +EInvCurrentStatus=Current status +EInvLifecycleHistory=Detailed log +EInvActorSeller=Seller +EInvActorPlatform=PDP / PA +EInvActorBuyer=Buyer +EInvNoLifecycleEvent=No lifecycle event recorded for this invoice yet. +EInvLocalStatusNotYetTransmitted=Not yet transmitted to the platform +EInvDirection=Direction +EInvDirectionIn=Incoming +EInvDirectionOut=Outgoing +EInvValidationStatus=Validation status SuggestedActions=Actions to do CreateSupplier=Create the supplier CreateProduct=Create the product @@ -436,6 +455,9 @@ SupplierInvoiceComparisonVatRateNotFound=VAT rate of %s%% not found in the Dolibarr invoice SupplierInvoiceComparisonSuggestVatCalculationMode=Recalculate the invoice using Mode %s to get VAT amounts corresponding to the e-invoice EInvoiceImportVatModeRealigned=The invoice was recalculated using VAT calculation Mode %s to carry the totals of the received document, which announces a total VAT (BT-110) of %s and a total including VAT (BT-112) of %s, where the VAT calculation mode of this instance rebuilt a total of %s including VAT. +EInvoiceImportTotalsMismatch=The received document %s announces a total of %s including VAT (%s of VAT), while the invoice built from it totals %s including VAT. Neither VAT calculation mode rebuilds what the document announces, so the import could not reproduce it. +EInvoiceImportTotalsMismatchAction=That invoice cannot be validated, and no approval can be sent for it, until its totals are those of the document. The e-invoice file is attached to the invoice and readable from its XML preview: please report the problem with that file, both totals and your Dolibarr version. +EInvoiceTotalsMismatchBlocksValidation=This invoice does not total what the e-invoice it was imported from announces: the document says %s including VAT (%s of VAT), the invoice totals %s. Correct the invoice to the amounts of the document, or refuse the document to the vendor who issued it. EinvoicingCantDeleteADocumentLinkedToAnExistingSupplierInvoice=The flow %s can't be deleted because it is linked to the Dolibarr supplier invoice n° %s EinvoicingCantDeleteAValidatedSupplierInvoice=A supplier invoice created from a received e-invoice can no longer be deleted once it has been validated. Only its draft can, and deleting the draft leaves the received document itself in the flow list. EinvoicingFailedToDetachTheFlowOfADeletedSupplierInvoice=Failed to detach the received e-invoice from the supplier invoice %s, so the invoice was not deleted. @@ -729,7 +751,20 @@ EInvoiceNoFileToPreview=No e-invoice XML has been generated for this invoice. EInvoiceNoReceivedFileToPreview=No e-invoice XML has been received for this invoice. EInvoiceXmlReindented=received on a single line, shown reindented -# Synchronization pending queue (manual action) + +# Wording of a discount line whose source piece cannot be named (BT-153 / BT-97) +EInvDiscountOnInvoice=on invoice %s +DiscountFromCreditNoteNoSource=Discount from a credit note +DiscountFromDepositNoSource=Down payment deducted +DiscountFromExcessReceivedNoSource=Payment in excess deducted +DiscountFromExcessPaidNoSource=Payment in excess deducted +FxCheckErrorLinesWithNoName=The following lines have neither a product label nor a description, so the item name of the e-invoice (BT-153) would be empty, which rule BR-25 refuses: %s. Enter a description on each of them (rank in the document, then line id). + +# A lifecycle status about an invoice we sent, that this run could not record (flow left unstored, retried later) +CantRecordTheStatusOfTheInvoiceYouSent=The status of an invoice you sent, carried by flow %s, could not be recorded. Nothing was stored, the flow will be retried on the next synchronization. +CheckSyncLogCantRecordSentInvoiceStatus=Nothing to do here: this flow is retried automatically on the next synchronization. If it keeps failing, the synchronization log gives the technical reason. + +# Manual-action queue (sync_pending_list.php) EInvoiceSyncPending=Sync pending queue EInvoiceSyncPendingQueue=Flows awaiting a manual action EInvoiceSyncPendingHelp=A flow that cannot be synchronized because it needs a manual action (missing product or thirdparty, supplier invoice with a different amount) used to abort the whole synchronization. It is now queued here and the synchronization carries on with the other flows. Do the manual action (create the product or thirdparty) then retry the flow, without re-running the whole synchronization. @@ -801,7 +836,7 @@ Apply=Apply Identical=Identical Empty=Empty -Overwrite=Overwrite +Overwrite=Replace Back=Back FieldConflictHelp=The thirdparty already has a different value. Only tick it if it really is the same entity (otherwise you may merge two distinct companies). NothingToApplyThirdpartyAlreadyMatches=This thirdparty already carries all the invoice identifiers - nothing to write. diff -ruN --exclude=vendor --exclude=.git _base_lf/langs/fr_FR/einvoicing.lang cm_repo/einvoicing/langs/fr_FR/einvoicing.lang --- _base_lf/langs/fr_FR/einvoicing.lang 2026-09-11 07:09:36 +++ cm_repo/einvoicing/langs/fr_FR/einvoicing.lang 2026-09-11 06:53:27 @@ -659,7 +659,8 @@ ErrorSupplierNotVisibleInEntity=Le fournisseur « %s » n'est pas visible dans l'entité « %s ». Vous devez d'abord le partager ou le créer dans l'entité cible. WarningEntityChangedFileMoveFailed=L'entité a été modifiée, mais les fichiers de factures n'ont pas pu être déplacés vers le répertoire de la nouvelle entité. Vous devrez peut-être les déplacer manuellement. EInvoicingInfoManagedByMasterSetup=La configuration de la facturation électronique est gérée par l'entité principale (ID %s) -# File d'attente de synchronisation (action manuelle) + +# Manual-action queue (sync_pending_list.php) EInvoiceSyncPending=File d'attente synchro EInvoiceSyncPendingQueue=Flux en attente d'action manuelle EInvoiceSyncPendingHelp=Un flux qui ne peut pas être synchronisé car il nécessite une action manuelle (produit ou tiers introuvable, montant de facture fournisseur différent) était auparavant bloquant : il interrompait toute la synchronisation. Il est désormais mis en file ici, et la synchronisation continue avec les autres flux. Faites l'action manuelle (créer le produit ou le tiers) puis relancez le flux avec « Réessayer », sans relancer toute la synchronisation. @@ -731,7 +732,7 @@ Apply=Appliquer Identical=Identique Empty=Vide -Overwrite=Écraser +Overwrite=Remplacer Back=Retour FieldConflictHelp=Le tiers a déjà une valeur différente. Ne cochez que s'il s'agit bien de la même entité (sinon vous risquez de fusionner deux sociétés distinctes). NothingToApplyThirdpartyAlreadyMatches=Ce tiers porte déjà tous les identifiants de la facture — rien à écrire. diff -ruN --exclude=vendor --exclude=.git _base_lf/lib/buildinvoicelines.inc.php cm_repo/einvoicing/lib/buildinvoicelines.inc.php --- _base_lf/lib/buildinvoicelines.inc.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/lib/buildinvoicelines.inc.php 2026-09-11 06:39:46 @@ -130,10 +130,19 @@ $sellerTaxRegistrations = einvoicingSellerTaxRegistrations($mysoc); $myidprof = idprof($mysoc); $mySchemeIdProf = $this->getIEC6523Code($mysoc->country_code); -$myGlobalIdProf = idprof($mysoc); +// BT-29, whose scheme EINVOICING_PARTY_IDENTIFIER_SCHEME decides. An empty scheme or an empty value +// means the term is not declared at all: it is optional, and BR-CO-26 is satisfied by BT-30 alone. +$myGlobalIdProf = $this->getPartyIdentifierValue($mysoc); $mySchemeGlobalIdProf = $this->getIEC6523Code($mysoc->country_code, 1); +$sellerGlobalIds = ($mySchemeGlobalIdProf !== '' && $myGlobalIdProf !== '') + ? array(array('schemeID' => $mySchemeGlobalIdProf, 'value' => $myGlobalIdProf)) + : array(); $myUri = $einvoicing->getSellerCommunicationURI(0); $mySchemeUri = $this->getIEC6523Code($mysoc->country_code, 2); +// BT-28, the trading name of the seller: "a name by which the seller is known, other than the +// seller name". The company setup of the core has no such field, so there is nothing to declare +// here and the term is left out of the document (issue #847). +$sellerTradingName = trim((string) ($mysoc->name_alias ?? '')); // Buyer party resolution. // The external BILLING contact always fills the buyer contact group (BG-9). Whether it also *replaces* @@ -193,10 +202,20 @@ if (!($buyerParty instanceof Societe)) { throw new \RuntimeException('einvoicing: invoice thirdparty is not a valid Societe (invoice id=' . $object->id . ')'); } +// BT-45, the trading name of the buyer: Dolibarr keeps it on the third party as name_alias, +// labelled "Alias name (commercial, trademark, ...)". A term that only repeats the name of the +// party says nothing, and the norm asks for it only when it differs (issue #847). +$buyerTradingName = trim((string) $buyerParty->name_alias); +if ($buyerTradingName === trim((string) $buyerName)) { + $buyerTradingName = ''; +} $idprof = idprof($buyerParty) ?? ''; $schemeIdProf = $this->getIEC6523Code($buyerParty->country_code); -$globalIdProf = idprof($buyerParty) ?? ''; +$globalIdProf = $this->getPartyIdentifierValue($buyerParty) ?? ''; // BT-46, see BT-29 above $schemeGlobalIdProf = $this->getIEC6523Code($buyerParty->country_code, 1); +$buyerGlobalIds = ($schemeGlobalIdProf !== '' && $globalIdProf !== '') + ? array(array('schemeID' => $schemeGlobalIdProf, 'value' => $globalIdProf)) + : array(); $uri = $einvoicing->getBuyerCommunicationURI($buyerParty, $object); $reg = array(); if (preg_match('/(\d+):(.+)/', $uri, $reg)) { @@ -317,7 +336,7 @@ if ($refDocTypeCode !== '' && !empty($object->fk_facture_source)) { $sourceFact = new Facture($this->db); if ($sourceFact->fetch($object->fk_facture_source) > 0) { - $sourceFactDate = new DateTime(dol_print_date($sourceFact->date, 'dayrfc')); + $sourceFactDate = new DateTime(dol_print_date($sourceFact->date, 'dayrfc', 'tzserver')); $invoiceRefDocs[] = [ 'ref' => $sourceFact->ref, 'date' => $sourceFactDate, @@ -327,7 +346,7 @@ } else { if ($object->id == 0) { // Specimen case. $specimenRefDoc = $object->fk_facture_source ?? 'FA0000-SPECIMEN'; - $sourceFactDate = new DateTime(dol_print_date(dol_now() - 100, 'dayrfc')); + $sourceFactDate = new DateTime(dol_print_date(dol_now() - 100, 'dayrfc', 'tzserver')); $invoiceRefDocs[] = [ 'ref' => $specimenRefDoc, 'date' => $sourceFactDate, @@ -351,7 +370,7 @@ $prevSituation = end($object->tab_previous_situation_invoice); reset($object->tab_previous_situation_invoice); if ($prevSituation && !empty($prevSituation->ref)) { - $prevSituationDate = new DateTime(dol_print_date($prevSituation->date, 'dayrfc')); + $prevSituationDate = new DateTime(dol_print_date($prevSituation->date, 'dayrfc', 'tzserver')); $invoiceRefDocs[] = [ 'ref' => $prevSituation->ref, 'date' => $prevSituationDate, @@ -369,6 +388,7 @@ $grand_total_ht = $grand_total_tva = $grand_total_ttc = 0; $prepaidAmount = 0; $depositlines = []; +$lineRowIds = []; // Document line number => llx_facturedet.rowid, for the messages $globalDiscounts = []; $billing_period = []; $numligne = 1; @@ -440,7 +460,8 @@ // The second method need to use the field BT-113. We don't use it as we use the first method. $depositFactRef = null; $depositFactDate = null; - if ($line->desc == '(DEPOSIT)') { + $lineDiscount = null; // Discount the line was built from, when it is a discount line + if ($line->desc == '(DEPOSIT)' && !empty($line->fk_remise_except)) { $isDepositLine = 1; $depositFactRef = ""; $depositFactDate = new DateTime(); @@ -450,12 +471,13 @@ dol_syslog("Fetch discount " . $line->fk_remise_except . ", res=" . $resdiscount, LOG_DEBUG); if ($resdiscount > 0) { + $lineDiscount = $discount; $origFact = new Facture($this->db); $resOrigFact = $origFact->fetch($discount->fk_facture_source); dol_syslog("Fetch origFact " . $discount->fk_facture_source . ", res=" . $resOrigFact, LOG_DEBUG); if ($resOrigFact > 0) { $depositFactRef = $origFact->ref; - $depositFactDate = new DateTime(dol_print_date($origFact->date, 'dayrfc')); + $depositFactDate = new DateTime(dol_print_date($origFact->date, 'dayrfc', 'tzserver')); } } $line->qty = -$line->qty; // For a deposit, ->qty should be -1. @@ -483,9 +505,16 @@ $resdiscount = $discount->fetch($line->fk_remise_except); dol_syslog("Fetch discount " . $line->fk_remise_except . ", res=" . $resdiscount, LOG_DEBUG); + $lineDiscount = ($resdiscount > 0 ? $discount : null); + + // BT-97. The description of a discount built from another piece is a sentinel, not a text to + // show: resolved here, the customer reads which credit note or which excess payment is deducted + // instead of '(CREDIT_NOTE)'. A discount entered by hand keeps the reason that was typed. + $discountReason = einvoicingDiscountLabel($lineDiscount, $discount->description ?? '', $outputlangs, einvoicingDiscountRelatedInvoiceRef($lineDiscount, $this->db)); + $globalDiscounts[] = array( 'value' => (float) $discount->total_ht, - 'reason' => $discount->description ?? 'REMISE', + 'reason' => $discountReason ?: ($discount->description ?? 'REMISE'), 'taxRate' => (float) $discount->tva_tx, 'categoryVAT' => $categoryVAT, ); @@ -548,6 +577,18 @@ } } + // A discount line still standing at this point is a deposit deducted from the invoice, and its + // description is the sentinel the core stores, not a text meant to be read. Left as it is, the + // customer reads '(DEPOSIT)' as the name of the line (BT-153). + // The line has to carry a discount for that to hold, which is why the resolution goes through + // einvoicingDiscountLabelOfLine(): a line of work an operator named '(DEPOSIT)', pointing at no + // discount, is legitimate text and keeps the name it was given. + $discountLabel = einvoicingDiscountLabelOfLine($line, $lineDiscount, $outputlangs, einvoicingDiscountRelatedInvoiceRef($lineDiscount, $this->db)); + if ($discountLabel !== '') { + $libelle = $discountLabel; + $description = ""; + } + // Billing period of the line $linePeriodStart = null; $linePeriodEnd = null; @@ -636,6 +677,11 @@ + // The rowid of the line, kept beside its document line number: the number places the line in the + // document, the rowid is what a correction is addressed to, and a message that names only the first + // leaves its reader to count the lines to find it. + $lineRowIds[$numligne] = (int) $line->id; + // Filling $linesData (based on $lineTemplate) $linesData[$numligne] = [ 'lineid' => $numligne, @@ -789,6 +835,46 @@ } } +// Last look for a sentinel that reached a field the customer reads. Everything above resolves the four +// of them, so anything left here is a way of building a document that this file does not know about - +// which is not a supposition: the resolution was written for the reason of a document level allowance +// and the item name of a deposit line was found carrying the sentinel afterwards, at the second look. +// +// The test is an equality, never an inclusion: a line of work named 'Reprise (DEPOSIT) du chantier' is +// a legitimate text and must go out untouched. And it reports rather than refuses - a marker in an item +// name is ugly, not invalid, and holding back an invoice over it would cost the seller more than it +// saves. +$discountSentinels = array_keys(einvoicingDiscountSentinels()); +$linesWithNoName = array(); +foreach ($linesData as $numligne => $vals) { + if (trim((string) ($vals['prodname'] ?? '')) === '') { + $linesWithNoName[] = $numligne.' (id '.($lineRowIds[$numligne] ?? 0).')'; + } + foreach (array('prodname' => 'BT-153', 'proddesc' => 'BT-154') as $field => $businessTerm) { + if (in_array((string) ($vals[$field] ?? ''), $discountSentinels, true)) { + dol_syslog("EInvoicing: line ".$numligne." of ".$object->ref." carries the unresolved discount marker ".$vals[$field]." in ".$businessTerm.". The line is a discount whose source piece could not be read.", LOG_ERR); + } + } +} + +foreach ($globalDiscounts as $discountIndex => $vals) { + if (in_array((string) ($vals['reason'] ?? ''), $discountSentinels, true)) { + dol_syslog("EInvoicing: allowance ".$discountIndex." of ".$object->ref." carries the unresolved discount marker ".$vals['reason']." in BT-97. The discount source piece could not be read.", LOG_ERR); + } +} + +// BR-25: a line with no name is not a document the platform accepts, so it is refused here rather than +// after transmission, on a line number the seller would then have to go and find. Every such line is +// named at once: sending them back one refusal at a time would be a round trip per line. This is the +// same missing data the pre-check reports before validation (validateInvoiceConfiguration()); a +// document reaching this point with one is one whose lines changed since, or one built by a path that +// does not run the pre-check. Refused after both halves of the last look above, never between them: a +// document carrying a nameless line and an unresolved marker in BT-97 would otherwise leave without the +// marker ever being reported - the very case that last look exists to catch. +if (!empty($linesWithNoName)) { + throw new Exception('MISSINGDATA[BR-25]: The line'.(count($linesWithNoName) > 1 ? 's ' : ' ').implode(', ', $linesWithNoName).' of '.$object->ref.' '.(count($linesWithNoName) > 1 ? 'have' : 'has').' no item name (BT-153). Enter a description on the line, or a label on the product it invoices.'); +} + // Rounding convention of the totals: Dolibarr sums the amounts already rounded on each line ("total of // round", the default), unless MAIN_ROUNDOFTOTAL_NOT_TOTALOFROUND rounds the sum instead. The loop // above always applies the first one, so follow the instance or the document claims a cent less than @@ -833,7 +919,7 @@ if ($sourceDiscountFact->fetch($obj->fk_facture_source) > 0) { $invoiceRefDocs[] = [ 'ref' => $sourceDiscountFact->ref, // BT-25 - 'date' => new DateTime(dol_print_date($sourceDiscountFact->date, 'dayrfc')), // BT-26 + 'date' => new DateTime(dol_print_date($sourceDiscountFact->date, 'dayrfc', 'tzserver')), // BT-26 'type' => $refDocTypeByInvoiceType[(int) $obj->sourcetype] ]; dol_syslog("EInvoicing invoice " . $object->id . " refers to " . $sourceDiscountFact->ref @@ -883,9 +969,12 @@ $invoicingPeriodEnd = $invoicingPeriod['end'] !== null ? $this->_tsToDateTime($invoicingPeriod['end']) : null; // Delivery date +// $deliveryDateList already holds 'Y-m-d' days: handing one to dol_print_date(), which expects a +// timestamp, reached a deprecated branch of the core that reads it back as midnight UTC, so BT-72 +// was emitted one day early on a server west of UTC (issue #853 on the sending side). $deliveryDate = !empty($deliveryDateList) - ? new DateTime(dol_print_date($deliveryDateList[0], 'dayrfc')) - : new DateTime(dol_print_date($object->date, 'dayrfc')); + ? new DateTime($deliveryDateList[0]) + : new DateTime(dol_print_date($object->date, 'dayrfc', 'tzserver')); @@ -906,7 +995,7 @@ // Document part 'documentno' => $object->ref, // BT-25 'documenttypecode' => $this->_getTypeOfInvoice($object), // BT-3 Set the type of invoice (standard, deposit, credit note) - 'documentdate' => new DateTime(dol_print_date($object->date, 'dayrfc')), // BT-26 + 'documentdate' => new DateTime(dol_print_date($object->date, 'dayrfc', 'tzserver')), // BT-26 'invoiceCurrency' => $object->multicurrency_code, 'taxCurrency' => null, 'documentname' => null, @@ -944,7 +1033,7 @@ // Seller part 'sellername' => $mysoc->name, - 'sellerids' => $myidprof, + 'sellerids' => (empty($sellerGlobalIds) ? '' : $myidprof), 'sellerlineone' => $sellerAddressLines[0] !== '' ? $sellerAddressLines[0] : 'ADDRESS EMPTY', 'sellerlinetwo' => $sellerAddressLines[1], @@ -963,7 +1052,7 @@ 'sellerCommunicationUriScheme' => $mySchemeUri, 'sellerCommunicationUri' => $myUri, - 'sellerGlobalIds' => [['schemeID' => $mySchemeGlobalIdProf, 'value' => $myGlobalIdProf]], + 'sellerGlobalIds' => $sellerGlobalIds, // BT-31 or BT-32, whichever the VAT regime of the seller calls for - see // einvoicingSellerTaxRegistrations(). A seller that does not charge VAT has no BT-31 to declare and // must still identify itself, or every exempt line trips BR-E-02 (issue #560). @@ -972,11 +1061,11 @@ 'sellerLegalOrgId' => $myidprof, 'sellerLegalOrgScheme' => $mySchemeIdProf, - 'sellerTradingName' => $mysoc->name ?? 'SPECIMEN', + 'sellerTradingName' => $sellerTradingName, // Buyer part 'buyername' => $buyerName ?: 'CUSTOMER', - 'buyerids' => $idprof ?: 'IDPROF', + 'buyerids' => (empty($buyerGlobalIds) ? '' : $idprof), 'buyerlineone' => $buyerAddressLines[0] !== '' ? $buyerAddressLines[0] : 'ADDRESS', 'buyerlinetwo' => $buyerAddressLines[1], @@ -987,12 +1076,12 @@ 'buyersubdivision' => null, 'buyervatnumber' => $buyerParty->tva_intra ?? '', - 'buyerGlobalIds' => [['schemeID' => $schemeGlobalIdProf, 'value' => $globalIdProf]], + 'buyerGlobalIds' => $buyerGlobalIds, 'buyerRoutingCode' => ($buyerRoutingCode !== '' ? $buyerRoutingCode : null), 'buyerLegalOrgId' => $idprof, 'buyerLegalOrgScheme' => $schemeIdProf, - 'buyerTradingName' => $buyerName, + 'buyerTradingName' => $buyerTradingName, 'buyerReference' => $buyerReference, @@ -1022,7 +1111,7 @@ 'accountRef' => $account->ref, 'accountLabel' => $account->label, - 'paymentDueDate' => new DateTime(dol_print_date($object->date_lim_reglement, 'dayrfc')), + 'paymentDueDate' => new DateTime(dol_print_date($object->date_lim_reglement, 'dayrfc', 'tzserver')), 'paymentTermsText' => $langs->transnoentitiesnoconv("PaymentConditions") . ": " . $langs->transnoentitiesnoconv("PaymentCondition" . $object->cond_reglement_code), // Allowances / charges part diff -ruN --exclude=vendor --exclude=.git _base_lf/lib/einvoicing.lib.php cm_repo/einvoicing/lib/einvoicing.lib.php --- _base_lf/lib/einvoicing.lib.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/lib/einvoicing.lib.php 2026-09-11 06:39:46 @@ -211,10 +211,36 @@ { $object->fetch_thirdparty(); $thirdparty = $object->thirdparty; - return $thirdparty ? idprof($object->thirdparty) : ''; + return $thirdparty ? idprof($thirdparty) : ''; } /** + * Escape a value for a text node or an attribute of a generated XML document. + * + * Two ways a text value breaks the document, neither of which htmlspecialchars() handles alone: + * an invalid UTF-8 sequence, which it answers with an EMPTY STRING below PHP 8.1 where ENT_SUBSTITUTE + * is not a default (one latin-1 byte in a company name, and BR-06 refuses the empty element), and a + * control character forbidden by XML 1.0 (a vertical tab pasted from a PDF), which it copies through + * and which leaves a file no parser reads - the platform answers HTTP 400 on it. + * + * @param mixed $value Value to escape. null is accepted and gives ''. + * @return string Value escaped for DOMDocument::createElement() and setAttribute() + */ +function einvoicingXmlText($value) +{ + $value = (string) $value; + + // Tab, LF and CR are the three control characters XML 1.0 allows. No /u here: the pattern is + // byte based on purpose, so it also holds on the invalid UTF-8 the escape below repairs. + $stripped = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $value); + if ($stripped !== null) { + $value = $stripped; + } + + return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +} + +/** * Remove every space of an identifier, whatever kind of space it is. * * A value copied from a web page or a PDF often carries a non-breaking (U+00A0), thin or zero-width @@ -1055,6 +1081,14 @@ if (!preg_match('#^https?://#i', $url)) { return false; } + // A browser treats a backslash in the authority as a slash, and strips control/space characters, + // while parse_url() does not. That gap lets "https://evil.com\@allowed.com" pass the host check + // below (parse_url sees allowed.com) while the browser navigates to evil.com, redirecting the user + // and the OAuth tokens to an attacker domain. No legitimate https redirect URL carries such a + // character, so reject the URL outright rather than try to normalize it. + if (preg_match('#[\\\\\x00-\x20\x7f]#', $url)) { + return false; + } $host = parse_url($url, PHP_URL_HOST); if (!is_string($host) || $host === '') { @@ -1085,4 +1119,153 @@ } return false; +} + +/** + * The four sentinels Dolibarr stores in the description of a discount, and the text each stands for. + * + * A discount built from another piece - a credit note applied, a deposit deducted, an excess payment + * carried over - carries no text of its own: the core writes one of four sentinels in the description + * of the discount, insert_discount() copies it into the description of the line, and pdf_getlinedesc() + * resolves it against the piece it comes from at print time. Nothing resolves it for an e-invoice, so + * the customer used to read '(CREDIT_NOTE)' in the item name of the line (BT-153) or in the reason of + * a document level allowance (BT-97). + * + * The test is the one the core makes: the description equals a sentinel exactly, and the line is + * actually a discount line. Matching the text alone is wrong in both directions - a description edited + * by hand is missed, and a service line quoting the string is caught - and the four sentinels are not + * even spelled alike: '(CREDIT_NOTE)' holds an underscore where '(EXCESS PAID)' and + * '(EXCESS RECEIVED)' hold a space. + * + * @return array Sentinel of the core => translation key of the text it stands for + */ +function einvoicingDiscountSentinels() +{ + return array( + '(CREDIT_NOTE)' => 'DiscountFromCreditNote', + '(DEPOSIT)' => 'DiscountFromDeposit', + '(EXCESS RECEIVED)' => 'DiscountFromExcessReceived', + '(EXCESS PAID)' => 'DiscountFromExcessPaid', + ); +} + +/** + * Text a discount line stands for, in place of the sentinel Dolibarr stores in its description. + * + * See einvoicingDiscountSentinels() for what the four sentinels are and why they are matched exactly. + * + * @param ?DiscountAbsolute $discount Discount the line was built from, already fetched + * @param string $description Description to resolve, of the line or of the discount + * @param Translate $outputlangs Language of the document being built + * @param string $relatedInvoiceRef Invoice the deducted piece corrects, from einvoicingDiscountRelatedInvoiceRef() + * @return string Resolved text, '' when the description is no sentinel + */ +function einvoicingDiscountLabel($discount, $description, $outputlangs, $relatedInvoiceRef = '') +{ + $transkeyOfSentinel = einvoicingDiscountSentinels(); + + $description = (string) $description; + if (!isset($transkeyOfSentinel[$description])) { + return ''; + } + + $outputlangs->load("bills"); + $outputlangs->load("einvoicing@einvoicing"); + + // Which piece is quoted depends on the side the discount belongs to: a discount held on a supplier + // invoice names that invoice, and reading ref_facture_source there would name nothing at all. + $sourceref = ''; + if (!empty($discount) && !empty($discount->id)) { + $sourceref = !empty($discount->discount_type) ? $discount->ref_invoice_supplier_source : $discount->ref_facture_source; + } + $sourceref = trim((string) $sourceref); + + if ($sourceref === '') { + // No piece to name: a discount entered by hand, or one whose source has been deleted. The text + // of the core quotes a reference and would be issued with a hole in the middle of the sentence, + // so the module has a wording of its own for the case. What must never happen is the marker + // going out as it stands: BT-153 refuses an empty item name (BR-25), and it refuses a technical + // marker in spirit. + return $outputlangs->transnoentitiesnoconv($transkeyOfSentinel[$description].'NoSource'); + } + + $label = $outputlangs->transnoentitiesnoconv($transkeyOfSentinel[$description], $sourceref); + + // The piece deducted usually corrects another invoice, and naming it is what lets the customer + // reconcile the deduction without opening its own ledger. Skipped when it would name the piece + // already named, which happens on a deposit deducted from the invoice it was asked on. + $relatedInvoiceRef = trim((string) $relatedInvoiceRef); + if ($relatedInvoiceRef !== '' && $relatedInvoiceRef !== $sourceref) { + $label .= ' ('.$outputlangs->transnoentitiesnoconv('EInvDiscountOnInvoice', $relatedInvoiceRef).')'; + } + + // The PDF of the core adds the date of the deposit when the option asks for it; the e-invoice reads + // the same way as the paper it accompanies. + if ($description == '(DEPOSIT)' && getDolGlobalString('INVOICE_ADD_DEPOSIT_DATE')) { + $label .= ' ('.dol_print_date($discount->datec, 'day', '', $outputlangs).')'; + } + + return $label; +} + +/** + * Text a discount line of the invoice stands for, '' when the line carries no discount at all. + * + * einvoicingDiscountLabel() decides on the description alone, which is what a document level + * allowance needs: there, the caller has already established that a discount is behind the amount. + * A line of the invoice has not, and the description alone cannot tell - a line of work can be named + * '(DEPOSIT)' and carry nothing, and it was then renamed 'Down payment deducted' on its way out, + * under the wording meant for a discount whose source piece cannot be read, which is a different + * situation entirely. + * + * The test of the core is in two halves, the description AND the discount the line points at + * (pdf_getlinedesc(): $desc == '(DEPOSIT)' && $object->lines[$i]->fk_remise_except). This is where + * the second half is made, so that the two call sites read the line the same way: the one writing + * BT-97 already stands inside a test on fk_remise_except, the one writing BT-153 does not. + * + * @param ?object $line Line of the invoice being written + * @param ?DiscountAbsolute $discount Discount the line was built from, already fetched + * @param Translate $outputlangs Language of the document being built + * @param string $relatedInvoiceRef Invoice the deducted piece corrects, from einvoicingDiscountRelatedInvoiceRef() + * @return string Resolved text, '' when the line is no discount line + */ +function einvoicingDiscountLabelOfLine($line, $discount, $outputlangs, $relatedInvoiceRef = '') +{ + if (empty($line) || empty($line->fk_remise_except)) { + return ''; + } + + return einvoicingDiscountLabel($discount, $line->desc ?? '', $outputlangs, $relatedInvoiceRef); +} + +/** + * Reference of the invoice the piece behind a discount corrects, '' when there is none to name. + * + * A credit note converted into a discount names the invoice it corrects in its own fk_facture_source, + * one level below the discount. Read from the discount alone, a deduction only says which credit note + * it comes from; the customer still has to find which invoice that credit note was about. + * + * @param ?DiscountAbsolute $discount Discount the line was built from, already fetched + * @param DoliDB $db Database handler + * @return string Reference of the corrected invoice, '' when there is none + */ +function einvoicingDiscountRelatedInvoiceRef($discount, $db) +{ + if (empty($discount) || empty($discount->fk_facture_source)) { + return ''; + } + + require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php'; + + $sourcePiece = new Facture($db); + if ($sourcePiece->fetch((int) $discount->fk_facture_source) <= 0 || empty($sourcePiece->fk_facture_source)) { + return ''; + } + + $correctedInvoice = new Facture($db); + if ($correctedInvoice->fetch((int) $sourcePiece->fk_facture_source) <= 0) { + return ''; + } + + return (string) $correctedInvoice->ref; } diff -ruN --exclude=vendor --exclude=.git _base_lf/lib/einvoicing_lifecycle.lib.php cm_repo/einvoicing/lib/einvoicing_lifecycle.lib.php --- _base_lf/lib/einvoicing_lifecycle.lib.php 1970-01-01 01:00:00 +++ cm_repo/einvoicing/lib/einvoicing_lifecycle.lib.php 2026-09-11 06:39:46 @@ -0,0 +1,79 @@ +. + */ + +/** + * \file lib/einvoicing_lifecycle.lib.php + * \ingroup einvoicing + * \brief Actor classification and labelling for the e-invoicing lifecycle of an element (invoice). + * + * Reads the multi-provider llx_einvoicing_lifecycle_msg table, which stores a numeric XP Z12-012 status + * code (EInvoicing::STATUS_*) plus a direction ('in'/'out'), and turns it into the three actors a + * customer invoice tracking view cares about: 'fournisseur' (Dolibarr/seller side, us), 'pdp' (network / + * Access Point layer) and 'client' (buyer side). + */ + +/** + * Swimlane (actor) a lifecycle event belongs to: 'fournisseur' (Dolibarr/seller side, us), 'pdp' + * (network / Access Point layer) or 'client' (buyer side). + * + * Direction gives the primary signal (out = emitted by us, in = received back). For an 'in' event, the + * XP Z12-012 status code refines it between the network acknowledging our submission and the buyer's own + * processing, using EInvoicing::STATUS_* so the classification cannot drift from the module's status map. + * + * @param int $status Lifecycle status code (EInvoicing::STATUS_*) + * @param string $direction 'in' or 'out' + * @return string 'fournisseur'|'pdp'|'client' + */ +function einvoicingLifecycleFlux($status, $direction) +{ + $status = (int) $status; + + if (strtolower((string) $direction) == 'out') { + return 'fournisseur'; + } + + $networkStatuses = array( + EInvoicing::STATUS_DEPOSITED, + EInvoicing::STATUS_ISSUED, + EInvoicing::STATUS_RECEIVED, + EInvoicing::STATUS_AVAILABLE, + EInvoicing::STATUS_REJECTED, + ); + if (in_array($status, $networkStatuses, true)) { + return 'pdp'; + } + + return 'client'; +} + +/** + * Human label for a lifecycle event: the provider's own message when it carries more context than the + * bare status code, otherwise the module's canonical status label. + * + * @param EInvoicing $einvoicing EInvoicing instance (source of canonical status labels) + * @param int $status Lifecycle status code + * @param string $override Provider message (lc_status_message), if any + * @return string + */ +function einvoicingLifecycleLabel($einvoicing, $status, $override = '') +{ + $override = trim((string) $override); + if ($override !== '' && $override !== (string) $status) { + return $override; + } + return $einvoicing->getStatusLabel($status); +} diff -ruN --exclude=vendor --exclude=.git _base_lf/product_mapping.php cm_repo/einvoicing/product_mapping.php --- _base_lf/product_mapping.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/product_mapping.php 2026-09-11 06:39:46 @@ -277,7 +277,14 @@ print ''; print '   '; print '
'.$langs->trans("Supplier").' '; -print $form->select_company($socid, 'socid', '(s.fournisseur:=:1)', 'SelectThirdParty', 0, 0, array(), 0, 'minwidth200'); +// Form::select_company() does not read this filter the same way on every version. Until Dolibarr 18 +// the criteria is appended to the query as it stands, so it has to be plain SQL; from 18 a criteria +// holding parentheses is converted by forgeSQLFromUniversalSearchCriteria(), and on 24 that conversion +// is no longer conditional - every criteria goes through it. Passing the Universal Search syntax to 17 +// therefore ends the page on "DB_ERROR_SYNTAX ... near ':=:1))'", and passing plain SQL to 24 would be +// mangled the other way round. +$vendorfilter = ((float) DOL_VERSION < 18) ? 's.fournisseur = 1' : '(s.fournisseur:=:1)'; +print $form->select_company($socid, 'socid', $vendorfilter, 'SelectThirdParty', 0, 0, array(), 0, 'minwidth200'); print '
'; print ''; print ''; @@ -297,6 +304,7 @@ // Run the matching on each line (read only, nothing is created here) // The matching method comes from the CommonProtocol trait, used by the protocols able to import an invoice. '@phan-var-force ?CIIProtocol $protocol'; + /** @var ?CIIProtocol $protocol */ $nbtomap = 0; $matchresults = array(); foreach ($parsedLines as $idx => $parsedLine) { diff -ruN --exclude=vendor --exclude=.git _base_lf/scripts/regenerate_einvoicing_fixtures.php cm_repo/einvoicing/scripts/regenerate_einvoicing_fixtures.php --- _base_lf/scripts/regenerate_einvoicing_fixtures.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/scripts/regenerate_einvoicing_fixtures.php 2026-09-11 06:39:46 @@ -113,7 +113,8 @@ ); try { - $xmls = EInvoicing::generateSampleEInvoicesForTests(); + $rawxmls = array(); + $xmls = EInvoicing::generateSampleEInvoicesForTests($rawxmls); foreach ($files as $key => $path) { $previous = file_exists($path) ? file_get_contents($path) : null; @@ -122,6 +123,19 @@ file_put_contents($path, $xmls[$key]); print(($changed ? 'UPDATED' : 'unchanged') . ' : ' . $path . PHP_EOL); + } + + // EINVOICING_RAW_FIXTURES_DIR asks for the same documents before normalization, which is what a + // validator has to be given: the normalization flattens every date to one value and makes the date + // rules of the French socle true whatever the document says. Never committed, their dates move. + $rawDir = rtrim((string) getenv('EINVOICING_RAW_FIXTURES_DIR'), '/'); + if ($rawDir !== '') { + dol_mkdir($rawDir); + foreach ($files as $key => $path) { + $rawPath = $rawDir . '/' . basename($path); + file_put_contents($rawPath, $rawxmls[$key]); + print('raw : ' . $rawPath . PHP_EOL); + } } print "Done.\n"; diff -ruN --exclude=vendor --exclude=.git _base_lf/sync_pending_list.php cm_repo/einvoicing/sync_pending_list.php --- _base_lf/sync_pending_list.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/sync_pending_list.php 2026-09-11 06:53:27 @@ -113,7 +113,7 @@ * "create thirdparty" share the same plus icon). * * @param string $url Destination URL of the action - * @return array array('label'=>langkey, 'help'=>langkey, 'icon'=>faicon) + * @return array array('label'=>langkey, 'help'=>langkey, 'icon'=>faicon) */ function einvsp_actionMetaFromUrl($url) { @@ -181,7 +181,7 @@ if ($action == 'confirm_retry' && $rowid > 0 && $permissiontowrite && $confirm == 'yes') { $object->fetch($rowid); if ($object->id > 0) { - require_once DOL_DOCUMENT_ROOT.'/custom/einvoicing/class/providers/PDPProviderManager.class.php'; + dol_include_once('/einvoicing/class/providers/PDPProviderManager.class.php'); $providerManager = new PDPProviderManager($db); $provider = $providerManager->getProvider(getDolGlobalString('EINVOICING_PDP')); if (!is_object($provider)) { @@ -252,6 +252,11 @@ $action = 'view'; } +// Cancel on the field-comparison screen: go back to the candidate list without writing anything. +if ($action == 'confirm_linkthirdparty' && GETPOST('cancel', 'alpha')) { + $action = 'linkthirdparty'; +} + // Link a THIRDPARTY_NOT_FOUND flow to an existing thirdparty: write the issuer identifiers (SIREN/SIRET/VAT) // carried by the invoice onto the chosen thirdparty, so the matching finds it, then retry the flow. if ($action == 'confirm_linkthirdparty' && $rowid > 0 && $permissiontowrite) { @@ -299,7 +304,7 @@ setEventMessages($langs->trans("NoFieldSelectedToApply"), null, 'warnings'); } // Retry the flow now: with the identifiers set, the matching should find the thirdparty. - require_once DOL_DOCUMENT_ROOT.'/custom/einvoicing/class/providers/PDPProviderManager.class.php'; + dol_include_once('/einvoicing/class/providers/PDPProviderManager.class.php'); $providerManager = new PDPProviderManager($db); $provider = $providerManager->getProvider(getDolGlobalString('EINVOICING_PDP')); if (!is_object($provider)) { @@ -478,13 +483,13 @@ print '
'.img_picto('', 'fa-search', 'class="paddingrightonly"').''.$langs->trans("MatchingThirdpartyCandidates").'
'; if (!empty($candidates)) { print '
'; - print ''; + print '
'; print ''; foreach ($candidates as $csocid => $cinfo) { print ''; - print ''; - print ''; - print ''; + print ''; + print ''; + print ''; print ''; } print '
'.$langs->trans("ThirdParty").''.$langs->trans("MatchedOn").'
'.dol_escape_htmltag($cinfo['name']).' (#'.((int) $csocid).')'.dol_escape_htmltag(implode(', ', array_keys($cinfo['crit']))).''.$langs->trans("AssociateWithThisThirdparty").' →'.dol_escape_htmltag($cinfo['name']).' (#'.((int) $csocid).')'.dol_escape_htmltag(implode(', ', array_keys($cinfo['crit']))).''.$langs->trans("AssociateWithThisThirdparty").' →
'; @@ -578,7 +583,7 @@ } print '
'; - print ''; + print '
'; print ''; print ''; print ''; @@ -630,8 +635,7 @@ if (!$anyapplicable) { print '
'.$langs->trans("NothingToApplyThirdpartyAlreadyMatches").'
'; } - print ''; - print '   '.$langs->trans("Back").''; + print $form->buttonsSaveCancel("Associate", "Cancel", array(), 1); print ''; print '
'; } diff -ruN --exclude=vendor --exclude=.git _base_lf/test/phpunit/CIIProfileShapeTest.php cm_repo/einvoicing/test/phpunit/CIIProfileShapeTest.php --- _base_lf/test/phpunit/CIIProfileShapeTest.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/test/phpunit/CIIProfileShapeTest.php 2026-09-11 06:39:46 @@ -398,7 +398,8 @@ } /** - * Invoice data carrying the three header references, on top of the base fixture. + * Invoice data carrying the header references, on top of the base fixture: an invoice covering + * three purchase orders, so the first lands on BT-13 and the other two on BT-18. * * @return array */ @@ -416,6 +417,9 @@ $data['buyerReference'] = 'SERVICE-EXEC-01'; // BT-10 $data['contractReference'] = 'CTR-2026-118'; // BT-12 $data['_project'] = $project; // BT-11 + $data['orderReference'] = 'BC-2026-0007'; // BT-13 + // An invoice covering three orders: BT-13 takes the first, the other two go to BT-18 + $data['_customerOrderReferenceList'] = ['BC-2026-0007', 'BC-2026-0008', 'BC-2026-0009']; return $data; } @@ -587,6 +591,41 @@ } /** + * The order references an invoice carries beyond BT-13 are emitted as invoiced object identifiers + * (BT-18), an element ram:AdditionalReferencedDocument only declares from EN16931 up. + * + * @return void + */ + public function testAdditionalOrderReferencesFollowTheProfileSchema() + { + global $db; + + $protocol = new CIIProtocol($db); + + foreach (CIIProtocol::SUPPORTED_XML_PROFILES as $profile) { + $xml = $protocol->buildXML($this->invoiceDataWithReferences(), $this->baseLinesData(), $profile); + $count = $this->countTag($xml, 'ram:AdditionalReferencedDocument'); + + if (!in_array($profile, ['EN16931', 'EXTENDED', 'EXTENDEDFR'], true)) { + $this->assertSame(0, $count, $profile . ' does not declare ram:AdditionalReferencedDocument in the agreement section'); + continue; + } + + $this->assertSame(2, $count, $profile . ' must carry the two order references BT-13 does not hold'); + + $doc = new DOMDocument(); + $doc->loadXML($xml); + $found = []; + foreach ($doc->getElementsByTagName('AdditionalReferencedDocument') as $node) { + $this->assertSame('130', $node->getElementsByTagName('TypeCode')->item(0)->nodeValue, $profile . ' BT-18 type code'); + $found[] = $node->getElementsByTagName('IssuerAssignedID')->item(0)->nodeValue; + } + // The reference already emitted as BT-13 must not be repeated here + $this->assertSame(['BC-2026-0008', 'BC-2026-0009'], $found, $profile . ' BT-18 values'); + } + } + + /** * The project reference (BT-11) only exists from EN16931 up, and its type makes both ram:ID and * ram:Name mandatory. * @@ -657,6 +696,7 @@ $this->assertSame(0, $this->countTag($xml, 'ram:BuyerReference'), $profile . ' must not carry an empty BT-10'); $this->assertSame(0, $this->countTag($xml, 'ram:ContractReferencedDocument'), $profile . ' must not carry an empty BT-12'); $this->assertSame(0, $this->countTag($xml, 'ram:SpecifiedProcuringProject'), $profile . ' must not carry an empty BT-11'); + $this->assertSame(0, $this->countTag($xml, 'ram:AdditionalReferencedDocument'), $profile . ' must not carry an empty BT-18'); } } diff -ruN --exclude=vendor --exclude=.git _base_lf/test/phpunit/CIITextEscapingTest.php cm_repo/einvoicing/test/phpunit/CIITextEscapingTest.php --- _base_lf/test/phpunit/CIITextEscapingTest.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/test/phpunit/CIITextEscapingTest.php 2026-09-11 06:39:46 @@ -180,7 +180,9 @@ '_chorus' => false, '_depositlines' => [], '_globalDiscounts' => [['value' => 10.0, 'reason' => 'Geste commercial R' . self::AMP, 'taxRate' => 20.0, 'categoryVAT' => 'S']], - '_customerOrderReferenceList' => [], + // An invoice covering three orders, one of which has a blank customer reference: the first + // entry repeats BT-13 and the blank one must not become an empty BT-18 (BR-52). + '_customerOrderReferenceList' => ['CMD-2026' . self::AMP, ' ', 'CMD-2027' . self::AMP], '_project' => null, ]; } @@ -334,6 +336,31 @@ $this->assertSame([], $empty, $profile . ' carries empty elements: ' . implode(', ', $empty)); } + } + + /** + * A customer order whose reference is blank is collected by the same loop as the others, and it + * used to reach the document as an empty BT-18. BR-52 rejects that as fatal, on the Access Point + * validator as well as on the CTC-FR chain. + * + * @return void + */ + public function testABlankOrderReferenceIsNotEmittedAsAnEmptyBt18() + { + global $db; + + $protocol = new CIIProtocol($db); + $doc = $this->load($protocol->buildXML($this->poisonedInvoiceData(), $this->poisonedLinesData(), 'EN16931')); + $xpath = $this->xpathOf($doc); + + $path = '/rsm:CrossIndustryInvoice/rsm:SupplyChainTradeTransaction/ram:ApplicableHeaderTradeAgreement/ram:AdditionalReferencedDocument'; + $emitted = []; + foreach ($xpath->query($path) as $node) { + $emitted[] = $node->getElementsByTagName('IssuerAssignedID')->item(0)->nodeValue; + } + + // Only the third order is left: the first repeats BT-13, the second one is blank + $this->assertSame(['CMD-2027' . self::AMP], $emitted); } /** diff -ruN --exclude=vendor --exclude=.git _base_lf/test/phpunit/CdarDateFormatTest.php cm_repo/einvoicing/test/phpunit/CdarDateFormatTest.php --- _base_lf/test/phpunit/CdarDateFormatTest.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/test/phpunit/CdarDateFormatTest.php 2026-09-11 06:39:46 @@ -300,4 +300,32 @@ $this->assertSame($input, CdarHandler::formatDate($input), 'formatDate() no longer passes "' . $input . '" through'); } } + + /** + * The payment date of the MPA block was the one date of the file left on the 'auto' default of + * dol_print_date(): it followed the timezone of the session as soon as MAIN_TZUSERINPUTKEY was + * 'tzuserrel', where the day the payment was made must not. + * + * @return void + */ + public function testThePaymentDateDoesNotFollowTheSession() + { + global $conf; + + $handler = new CdarHandler($GLOBALS['db']); + $conf->tzuserinputkey = 'tzuserrel'; + + foreach ($this->timezones() as $tz) { + date_default_timezone_set($tz); + + // The epoch is left out: the method reads an empty date as "no date given" and stamps dol_now(). + foreach (array_filter($this->timestamps()) as $ts) { + $_SESSION['dol_tz_string'] = 'Pacific/Kiritimati'; // UTC+14, a day ahead of most of the map + + $mpa = $handler->getPaymentSentCharacteristics(new stdClass(), array('amount' => 12.0, 'date' => $ts)); + + $this->assertSame(date('Ymd', $ts), $mpa[0]['ValueDateTime'], 'Payment date changed for timestamp ' . $ts . ' in ' . $tz); + } + } + } } diff -ruN --exclude=vendor --exclude=.git _base_lf/test/phpunit/DiscountSentinelTest.php cm_repo/einvoicing/test/phpunit/DiscountSentinelTest.php --- _base_lf/test/phpunit/DiscountSentinelTest.php 1970-01-01 01:00:00 +++ cm_repo/einvoicing/test/phpunit/DiscountSentinelTest.php 2026-09-11 06:39:46 @@ -0,0 +1,296 @@ +. + * or see https://www.gnu.org/ + */ + +/** + * \file test/phpunit/DiscountSentinelTest.php + * \ingroup test + * \brief PHPUnit test for the four sentinels a discount line carries. + * A discount built from another piece has no text of its own: the core stores + * '(CREDIT_NOTE)', '(DEPOSIT)', '(EXCESS RECEIVED)' or '(EXCESS PAID)' in its + * description and resolves it at print time. An e-invoice that does not resolve it + * shows the sentinel to the customer, in the item name of a line (BT-153) or in the + * reason of a document level allowance (BT-97). + * \remarks To run this script as CLI: phpunit filename.php + */ + +global $conf, $user, $langs, $db; + +// See VatPointDateCodeTest for why DOLIBARR_HTDOCS is honoured before the relative path. +$dolibarrHtdocs = getenv('DOLIBARR_HTDOCS'); +if (!$dolibarrHtdocs) { + $dolibarrHtdocs = dirname(__FILE__) . '/../../htdocs'; +} +if (!file_exists($dolibarrHtdocs . '/master.inc.php')) { + throw new \RuntimeException('Could not locate master.inc.php under "' . $dolibarrHtdocs . '/". Set the environment variable (export DOLIBARR_HTDOCS=...) to the htdocs directory of the Dolibarr instance to test against.'); +} + +require_once $dolibarrHtdocs . '/master.inc.php'; +dol_include_once('einvoicing/lib/einvoicing.lib.php'); +require_once __DIR__ . '/CommonClassTestCompat.inc.php'; + +/** + * Class for PHPUnit tests + * + * @backupGlobals disabled + * @backupStaticAttributes enabled + * @remarks backupGlobals must be disabled to have db,conf,user and lang not erased. + */ +class DiscountSentinelTest extends CommonClassTest +{ + /** + * A discount, reduced to what einvoicingDiscountLabel() reads on it. Not a DiscountAbsolute: the + * function reads five properties and fetches nothing, which is what makes it testable without + * writing a discount into the database. + * + * @param string $customerRef ->ref_facture_source, the piece a customer side discount names + * @param int $discountType ->discount_type, 1 on the supplier side + * @param string $supplierRef ->ref_invoice_supplier_source + * @return stdClass + */ + private function discount($customerRef = 'FA2026-0180', $discountType = 0, $supplierRef = '') + { + $discount = new stdClass(); + $discount->id = 24; + $discount->discount_type = $discountType; + $discount->ref_facture_source = $customerRef; + $discount->ref_invoice_supplier_source = $supplierRef; + $discount->datec = dol_mktime(0, 0, 0, 6, 30, 2026); + + return $discount; + } + + /** + * A line of the invoice, reduced to the two fields the resolution reads on it: the description it + * carries, and the discount it points at - 0 for a line that points at none. + * + * @param string $desc ->desc, the description the core stores on the line + * @param int $fk_remise_except ->fk_remise_except, the discount behind the line + * @return stdClass + */ + private function line($desc, $fk_remise_except) + { + $line = new stdClass(); + $line->desc = $desc; + $line->fk_remise_except = $fk_remise_except; + + return $line; + } + + /** + * A discount object that was never fetched: nothing to read on it, which is what a deleted or + * unreadable source piece leaves behind. + * + * @return stdClass + */ + private function unfetchedDiscount() + { + $discount = $this->discount(); + $discount->id = 0; + + return $discount; + } + + /** + * The list of the sentinels is the one of the core, spelled exactly as the core spells it. It is + * pinned here because it is shared by the resolution and by the last look that reports a sentinel + * having reached a field of the document: a fifth entry, or one spelling drifting, would silently + * make one of the four unresolvable again. + * + * @return void + */ + public function testTheSentinelsAreTheOnesOfTheCore() + { + $this->assertSame( + array('(CREDIT_NOTE)', '(DEPOSIT)', '(EXCESS RECEIVED)', '(EXCESS PAID)'), + array_keys(einvoicingDiscountSentinels()) + ); + } + + /** + * The four sentinels are resolved, each into the text of the core for the case it stands for, and + * each naming the piece the amount comes from. The spelling of the four is not homogeneous - + * '(CREDIT_NOTE)' holds an underscore where '(EXCESS PAID)' and '(EXCESS RECEIVED)' hold a space - + * which is what a single pattern gets wrong. + * + * @return void + */ + public function testTheFourSentinelsAreResolved() + { + global $langs; + + foreach (array('(CREDIT_NOTE)', '(DEPOSIT)', '(EXCESS RECEIVED)') as $sentinel) { + $label = einvoicingDiscountLabel($this->discount(), $sentinel, $langs); + + $this->assertNotSame('', $label, 'The sentinel '.$sentinel.' must be resolved.'); + $this->assertStringNotContainsString('(', $label, 'No sentinel may survive in '.$sentinel.'.'); + $this->assertStringContainsString('FA2026-0180', $label, 'The piece the amount comes from must be named.'); + } + + // The supplier side names its own piece, and reading ref_facture_source there would name nothing. + $label = einvoicingDiscountLabel($this->discount('', 1, 'SUPPLIER-2026-77'), '(EXCESS PAID)', $langs); + $this->assertStringContainsString('SUPPLIER-2026-77', $label); + } + + /** + * Each sentinel is resolved into the text of its own case: a deposit deducted is not a credit note + * applied, and a document that called one the other would misdescribe what it deducts. + * + * @return void + */ + public function testEachSentinelGetsTheTextOfItsOwnCase() + { + global $langs; + + $creditNote = einvoicingDiscountLabel($this->discount(), '(CREDIT_NOTE)', $langs); + $deposit = einvoicingDiscountLabel($this->discount(), '(DEPOSIT)', $langs); + $excess = einvoicingDiscountLabel($this->discount(), '(EXCESS RECEIVED)', $langs); + + $this->assertNotSame($creditNote, $deposit); + $this->assertNotSame($creditNote, $excess); + $this->assertNotSame($deposit, $excess); + } + + /** + * Nothing else is resolved. The test of the core is an exact equality on the description AND a + * discount behind the line, and matching the text loosely is wrong in both directions: a line of + * work quoting the string would be renamed, and the reason an operator typed by hand would be + * replaced by a text about a piece that does not exist. + * + * @return void + */ + public function testNothingElseIsResolved() + { + global $langs; + + $discount = $this->discount(); + + $this->assertSame('', einvoicingDiscountLabel($discount, '', $langs)); + $this->assertSame('', einvoicingDiscountLabel($discount, 'Remise commerciale', $langs)); + $this->assertSame('', einvoicingDiscountLabel($discount, 'credit_note', $langs)); + $this->assertSame('', einvoicingDiscountLabel($discount, 'CREDIT_NOTE', $langs)); + $this->assertSame('', einvoicingDiscountLabel($discount, '(CREDIT NOTE)', $langs)); + $this->assertSame('', einvoicingDiscountLabel($discount, 'Reprise (DEPOSIT) du chantier', $langs)); + } + + /** + * A sentinel with no piece to name still gets a text of its own, and never goes out as it stands. + * The wording of the core quotes a reference, so it would be issued with a hole in the middle of the + * sentence; the module has its own for the case. The one thing that must not happen is the marker + * reaching the document: an item name that is a technical marker is what BR-25 refuses in spirit, + * and it is what the customer would read. + * + * @return void + */ + public function testASentinelWithNoPieceToNameStillGetsAText() + { + global $langs; + + foreach (array(null, $this->discount(''), $this->unfetchedDiscount()) as $noSource) { + $label = einvoicingDiscountLabel($noSource, '(CREDIT_NOTE)', $langs); + + $this->assertNotSame('', $label); + $this->assertStringNotContainsString('(CREDIT_NOTE)', $label); + } + + // And each case keeps a wording of its own, a deposit deducted not being a credit note applied. + $this->assertNotSame( + einvoicingDiscountLabel(null, '(CREDIT_NOTE)', $langs), + einvoicingDiscountLabel(null, '(DEPOSIT)', $langs) + ); + } + + /** + * The invoice the deducted piece corrects is named beside the piece itself: a deduction that only + * says which credit note it comes from leaves the customer to find which invoice that credit note + * was about. It is skipped when it would name the piece already named, which is what a deposit + * deducted from the very invoice it was asked on would do. + * + * @return void + */ + public function testTheCorrectedInvoiceIsNamedBesideThePiece() + { + global $langs; + + $withCorrected = einvoicingDiscountLabel($this->discount(), '(CREDIT_NOTE)', $langs, 'FA2026-0100'); + $withoutCorrected = einvoicingDiscountLabel($this->discount(), '(CREDIT_NOTE)', $langs); + + $this->assertStringStartsWith($withoutCorrected, $withCorrected); + $this->assertStringContainsString('FA2026-0100', $withCorrected); + + // The same reference on both sides is named once. + $this->assertSame( + einvoicingDiscountLabel($this->discount('FA2026-0180'), '(DEPOSIT)', $langs), + einvoicingDiscountLabel($this->discount('FA2026-0180'), '(DEPOSIT)', $langs, 'FA2026-0180') + ); + } + + /** + * The date of the deposit follows the option of the core, so the e-invoice reads the same way as the + * PDF it accompanies. + * + * @return void + */ + public function testTheDepositDateFollowsTheOptionOfTheCore() + { + global $conf, $langs; + + $savOption = getDolGlobalString('INVOICE_ADD_DEPOSIT_DATE'); + + $conf->global->INVOICE_ADD_DEPOSIT_DATE = ''; + $withoutDate = einvoicingDiscountLabel($this->discount(), '(DEPOSIT)', $langs); + + $conf->global->INVOICE_ADD_DEPOSIT_DATE = '1'; + $withDate = einvoicingDiscountLabel($this->discount(), '(DEPOSIT)', $langs); + + $conf->global->INVOICE_ADD_DEPOSIT_DATE = $savOption; + + $this->assertNotSame($withoutDate, $withDate); + $this->assertStringStartsWith($withoutDate, $withDate); + } + + /** + * A line an operator named after a sentinel, carrying no discount, keeps the name it was given. + * + * This is the half of the test of the core that lives on the line and not on the description: a + * line of work can be named '(DEPOSIT)' and point at nothing, and the resolution has no business + * renaming it. It used to go out as 'Down payment deducted', under the wording meant for a + * discount whose source piece cannot be read - which says something false about a line that + * deducts nothing at all. + * + * @return void + */ + public function testASentinelNameOnALineWithNoDiscountIsLeftAlone() + { + global $langs; + + foreach (array_keys(einvoicingDiscountSentinels()) as $sentinel) { + $this->assertSame('', einvoicingDiscountLabelOfLine($this->line($sentinel, 0), null, $langs)); + } + + // And the line that does carry a discount is still resolved, sentinel by sentinel. + foreach (array_keys(einvoicingDiscountSentinels()) as $sentinel) { + $label = einvoicingDiscountLabelOfLine($this->line($sentinel, 24), $this->discount(), $langs); + + $this->assertNotSame('', $label); + $this->assertStringNotContainsString($sentinel, $label); + } + + // A line of work quoting a sentinel inside a sentence is untouched either way, discount or not. + $this->assertSame('', einvoicingDiscountLabelOfLine($this->line('Reprise (DEPOSIT) du chantier', 24), $this->discount(), $langs)); + } +} diff -ruN --exclude=vendor --exclude=.git _base_lf/test/phpunit/ImportVatCalculationModeTest.php cm_repo/einvoicing/test/phpunit/ImportVatCalculationModeTest.php --- _base_lf/test/phpunit/ImportVatCalculationModeTest.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/test/phpunit/ImportVatCalculationModeTest.php 2026-09-11 06:39:46 @@ -39,6 +39,7 @@ require_once $dolibarrHtdocs . '/master.inc.php'; require_once DOL_DOCUMENT_ROOT . '/fourn/class/fournisseur.facture.class.php'; dol_include_once('einvoicing/class/protocols/CIIProtocol.class.php'); +dol_include_once('einvoicing/class/utils/SupplierInvoiceHelper.class.php'); require_once __DIR__ . '/CommonClassTestCompat.inc.php'; if (empty($user->id)) { @@ -278,12 +279,13 @@ } /** - * A difference neither convention explains is a real one. The invoice is left exactly as the import - * built it, and nothing is said here: the comparison of SupplierInvoiceHelper is what reports it. + * A difference neither convention explains is a document the import cannot reproduce. The invoice + * is left exactly as the import built it - nothing else carries what the vendor sent - but it is + * said, and the invoice is marked so it cannot be validated or approved (issue #861). * * @return void */ - public function testADifferenceThatIsNotARoundingConventionIsLeftAlone() + public function testADifferenceThatIsNotARoundingConventionIsReportedAndBlocks() { $this->setInstanceVatMode(1); @@ -295,7 +297,19 @@ $this->assertEquals(10.47, (float) $untouched->total_ht); $this->assertEquals(2.10, (float) $untouched->total_tva, 'the invoice keeps the mode of the instance'); $this->assertEquals(12.57, (float) $untouched->total_ttc); - $this->assertCount(0, $messages); + + $this->assertCount(2, $messages, 'what the document announces, and what it means for that invoice'); + $this->assertStringContainsString('13.47', $messages[0], 'the total the document announces'); + $this->assertStringContainsString('12.57', $messages[0], 'against the one the invoice carries'); + + $announced = SupplierInvoiceHelper::totalsMismatch((int) $invoice->id); + $this->assertIsArray($announced, 'the invoice is marked'); + $this->assertEquals(13.47, $announced['ttc']); + $this->assertTrue(SupplierInvoiceHelper::totalsMismatchBlocks((int) $invoice->id)); + + // And the mark goes as soon as an import makes the invoice total the document again. + $this->alignWith($invoice, 2.10, 12.57, $messages); + $this->assertNull(SupplierInvoiceHelper::totalsMismatch((int) $invoice->id), 'nothing left to block'); } /** @@ -331,17 +345,14 @@ { global $db; - $method = new ReflectionMethod(CIIProtocol::class, 'totalsAgreeWithDocument'); - $method->setAccessible(true); - $creditNote = new FactureFournisseur($db); $creditNote->total_tva = -2.09; $creditNote->total_ttc = -12.56; - $this->assertTrue($method->invoke(null, $creditNote, 2.09, 12.56)); + $this->assertTrue(SupplierInvoiceHelper::totalsAgreeWithDocument($creditNote, 2.09, 12.56)); $off = new FactureFournisseur($db); $off->total_tva = -2.10; $off->total_ttc = -12.57; - $this->assertFalse($method->invoke(null, $off, 2.09, 12.56)); + $this->assertFalse(SupplierInvoiceHelper::totalsAgreeWithDocument($off, 2.09, 12.56)); } } diff -ruN --exclude=vendor --exclude=.git _base_lf/test/phpunit/ReceivedInvoiceLinesTest.php cm_repo/einvoicing/test/phpunit/ReceivedInvoiceLinesTest.php --- _base_lf/test/phpunit/ReceivedInvoiceLinesTest.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/test/phpunit/ReceivedInvoiceLinesTest.php 2026-09-11 06:39:46 @@ -280,12 +280,12 @@ } /** - * A line whose quantity and price do not rebuild what the document announces keeps the amount the - * core computes - it is the only one Dolibarr can store - but says so. + * A line whose quantity and price do not rebuild what the document announces is imported at the + * amount announced - BT-131 is the figure the totals of the document are summed from - and says so. * * @return void */ - public function testAnAmountThatDoesNotRebuildIsReported() + public function testAnAmountThatDoesNotRebuildIsImportedAtTheAnnouncedAmount() { global $db; @@ -294,8 +294,8 @@ $parsedLine = array('lineid' => '004', 'lineTotalAmount' => 100.0, 'linestatusreasoncode' => 'DETAIL'); $amounts = $this->callResolveLineAmounts($protocol, $parsedLine, 2.0, 40.0); - $this->assertSame(2.0, $amounts['qty'], 'nothing is invented, the document is only reported'); - $this->assertSame(40.0, $amounts['subprice']); + $this->assertSame(2.0, $amounts['qty'], 'the quantity of the document is kept'); + $this->assertEquals(50.0, $amounts['subprice'], 'the price is the one that totals BT-131'); $this->assertStringContainsString('BT-131', $amounts['warning']); $this->assertStringContainsString('80', $amounts['warning'], 'the warning names what was rebuilt'); } @@ -644,6 +644,143 @@ } /** + * The reported case of issue #844, read from the document the way the import reads it: a metered + * consumption of 1 951 593 units at 0.00000548, which the issuer can only write as 0.000005 (BT-146 + * takes six decimals, BR-FR-DEC-03). Quantity times that price rebuilds 9.76 where the document + * announces 10.69, and the invoice used to carry the 9.76. + * + * @return void + */ + public function testTheReportedRoundedUnitPriceStillTotalsTheAnnouncedAmount() + { + global $db; + + $protocol = new CIIProtocol($db); + + $lines = $protocol->parseInvoiceLines($this->documentWithLine(' + 478803531 + Stockage Standard Infrequent Access + + 0.000005 + + 1951593.0000 + + 10.69 + ')); + + $this->assertCount(1, $lines); + $this->assertNull($lines[0]['netpricebasisquantity'], 'BT-149 is absent: the price is stated per unit, rounded'); + + $imported = $this->importedLine($lines[0]); + + $this->assertSame(1951593.0, $imported['qty'], 'the quantity the document bills is kept'); + $this->assertEquals(10.69, $imported['rebuilt'], 'the line totals BT-131, not 9.76'); + $this->assertStringContainsString('BR-FR-DEC-03', $imported['warning'], 'the import says what it refined and why'); + $this->assertStringContainsString('MAIN_MAX_DECIMALS_UNIT', $imported['warning'], 'and that such a price is stored as zero'); + } + + /** + * The reported case of issue #850: a bank fee whose line is priced elsewhere than it is billed. The + * document states a quantity of 0.5999 - the rate the fee is computed at, printed as "0,5999 %" on + * the PDF - against a price of 50 005.00, the credit it applies to, and announces the fee itself, + * 300.00. The couple rebuilds 50 005.00, which is what the invoice used to carry. + * + * @return void + */ + public function testALinePricedElsewhereThanItIsBilledTotalsTheAnnouncedAmount() + { + global $db; + + $protocol = new CIIProtocol($db); + + $lines = $protocol->parseInvoiceLines($this->documentWithLine(' + 2608 + Frais dossier + + + 50005.000000 + 0.5999 + + + 0.5999 + + 300.00 + ')); + + $this->assertCount(1, $lines); + + $imported = $this->importedLine($lines[0]); + + $this->assertSame(0.5999, $imported['qty'], 'the quantity the document bills is kept'); + $this->assertEquals(300.00, $imported['rebuilt'], 'the line totals BT-131, not the 50005.00 its price rebuilds'); + $this->assertStringContainsString('rebuild 50005', $imported['warning'], 'the import says what the document rebuilds'); + $this->assertStringContainsString('imported at the amount announced', $imported['warning'], 'and what it did about it'); + } + + /** + * A charge of the line (BG-28) is part of BT-131 but leaves on a line of its own (issue #735), so the + * line itself is worth BT-131 less that charge: the couple the document states rebuilds exactly that, + * and nothing is rewritten or reported. Five units at 100.05 less 10.001 percent, plus a charge of + * 7.00, announcing 457.22. + * + * @return void + */ + public function testAChargeOfTheLineIsOutOfTheComparison() + { + $parsedLine = array( + 'lineid' => '6', + 'lineTotalAmount' => 457.22, + 'lineAllowances' => array( + array('indicator' => 'false', 'actualAmount' => 50.03, 'reason' => 'Commercial discount'), + array('indicator' => 'true', 'actualAmount' => 7.00, 'reasonCode' => 'FC', 'reason' => 'Handling'), + ), + ); + + $amounts = $this->amounts($parsedLine, 5.0, 100.05, 10.001); + + $this->assertSame(100.05, $amounts['subprice'], 'the price of the document is left alone'); + $this->assertSame('', $amounts['warning'], 'and the charge is not read as a document that does not add up'); + + // Without the charge line the same figures do not add up, and there the price is refined. + unset($parsedLine['lineAllowances'][1]); + $this->assertNotSame('', $this->amounts($parsedLine, 5.0, 100.05, 10.001)['warning']); + } + + /** + * A line announcing nothing has no amount to be imported at: BT-131 is what a line is worth, and an + * absent one is not a figure to rewrite a price against. It keeps what its own price rebuilds. + * + * @return void + */ + public function testALineAnnouncingNothingKeepsWhatItsPriceRebuilds() + { + $amounts = $this->amounts(array('lineid' => '5', 'lineTotalAmount' => 0.0), 2.0, 40.0); + + $this->assertSame(40.0, $amounts['subprice'], 'nothing is rewritten against an absent BT-131'); + $this->assertStringContainsString('carries the rebuilt amount', $amounts['warning']); + } + + /** + * A discounted line is refined the same way, on the price the discount is applied to: the couple the + * core stores is quantity, unit price and percent, and it is their product that has to total BT-131. + * + * @return void + */ + public function testTheRefinedPriceAccountsForTheLineDiscount() + { + $parsedLine = array('lineid' => '5', 'lineTotalAmount' => 8.55); + + $amounts = $this->amounts($parsedLine, 1951593.0, 0.000005, 20.0); + + $this->assertSame(20.0, $amounts['remise_percent'], 'the discount of the document is kept'); + $this->assertEquals( + 8.55, + round($amounts['qty'] * $amounts['subprice'] * (1 - ($amounts['remise_percent'] / 100)), 2), + 'the line still totals what BT-131 announces' + ); + } + + /** * Every other shape of BT-149 leaves the price alone. It is optional and means one when absent; * BR-64 requires it to be positive when it is there, so a zero or a negative one is a broken * document and the price it states is the best the import can do with it. @@ -857,6 +994,30 @@ $this->assertEqualsWithDelta($statedBase['base'], $rebuiltBase['base'], 0.011); $this->assertEqualsWithDelta($statedBase['percent'], $rebuiltBase['percent'], 0.0001); $this->assertEqualsWithDelta(100.05, $rebuiltBase['priceWithoutDiscount'] / 5, 0.001, 'which is BT-146 for a quantity of 5'); + } + + /** + * A base stated as zero is a base the document did not state. Some issuers write BT-137 = 0.00 next + * to a real allowance amount, and the null coalescing that used to pick the base kept that zero: the + * guard below it sent the discount back false and the line was imported at its gross (PR #845). + * + * @return void + */ + public function testABaseStatedAsZeroFallsBackTheSameWay() + { + global $db; + + $protocol = new CIIProtocol($db); + + $zeroBase = $protocol->parseInvoiceLines($this->discountedLine(5.0, 100.05, 50.03, 450.22, 0.00)); + $this->assertSame(0.0, $zeroBase[0]['lineAllowances'][0]['basisAmount'], 'BT-137 is read, and it is zero'); + + $discount = $this->call('resolveLineDiscountPercent', array($zeroBase[0]['lineAllowances'], $zeroBase[0]['lineTotalAmount'])); + + $this->assertNotFalse($discount, 'the discount is resolved and not dropped'); + $this->assertEqualsWithDelta(500.25, $discount['base'], 0.011, 'the amount before the allowance, as when BT-137 is absent'); + $this->assertEqualsWithDelta(10.001, $discount['percent'], 0.0001); + $this->assertEqualsWithDelta(450.22, $this->importedLine($zeroBase[0])['rebuilt'], 0.011, 'and the line totals BT-131'); } /** diff -ruN --exclude=vendor --exclude=.git _base_lf/test/phpunit/SupplierInvoiceHelperTest.php cm_repo/einvoicing/test/phpunit/SupplierInvoiceHelperTest.php --- _base_lf/test/phpunit/SupplierInvoiceHelperTest.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/test/phpunit/SupplierInvoiceHelperTest.php 2026-09-11 06:39:46 @@ -891,6 +891,87 @@ } /** + * The mark of issue #861, end to end: an import that could not reproduce the totals of the document + * blocks the invoice, and the block is lifted the moment the invoice totals what was announced - + * the mark keeps those figures precisely so it can be re-evaluated without the document. + * + * @return void + */ + public function testATotalsMismatchBlocksUntilTheInvoiceAgrees() + { + global $db; + + $invoice = $this->createSpecimenSupplierInvoice(); + $stored = new FactureFournisseur($db); + $this->assertGreaterThan(0, $stored->fetch((int) $invoice->id)); + + $this->assertNull(SupplierInvoiceHelper::totalsMismatch((int) $invoice->id), 'an invoice carries no mark to begin with'); + $this->assertFalse(SupplierInvoiceHelper::totalsMismatchBlocks((int) $invoice->id)); + + // The import could not rebuild what the document announces: 130.00 including VAT, 30.00 of VAT. + SupplierInvoiceHelper::flagTotalsMismatch((int) $invoice->id, 30.00, 130.00); + + $announced = SupplierInvoiceHelper::totalsMismatch((int) $invoice->id); + $this->assertIsArray($announced); + $this->assertEquals(130.00, $announced['ttc'], 'the mark keeps what the document announces'); + $this->assertEquals(30.00, $announced['tva']); + $this->assertTrue(SupplierInvoiceHelper::totalsMismatchBlocks((int) $invoice->id), 'and it blocks while the invoice says otherwise'); + + // Same invoice, marked against the totals it actually carries: there is nothing left to block. + SupplierInvoiceHelper::flagTotalsMismatch((int) $invoice->id, abs((float) $stored->total_tva), abs((float) $stored->total_ttc)); + $this->assertFalse(SupplierInvoiceHelper::totalsMismatchBlocks((int) $invoice->id), 'an invoice that totals the document blocks nothing'); + + SupplierInvoiceHelper::clearTotalsMismatch((int) $invoice->id); + $this->assertNull(SupplierInvoiceHelper::totalsMismatch((int) $invoice->id)); + } + + /** + * The comparison is made on the absolute values: Dolibarr stores a credit note negative while + * BT-110 and BT-112 are always announced positive, the document type carrying the sign. + * + * @return void + */ + public function testTotalsAgreeWithDocumentComparesAbsoluteValues() + { + global $db; + + $creditNote = new FactureFournisseur($db); + $creditNote->total_tva = -20.00; + $creditNote->total_ttc = -120.00; + + $this->assertTrue(SupplierInvoiceHelper::totalsAgreeWithDocument($creditNote, 20.00, 120.00)); + $this->assertFalse(SupplierInvoiceHelper::totalsAgreeWithDocument($creditNote, 20.00, 130.00), 'a cent apart is a difference, not a rounding'); + $this->assertTrue(SupplierInvoiceHelper::totalsAgreeWithDocument($creditNote, 20.001, 119.999), 'the tolerance is there for the float representation only'); + } + + /** + * An invoice the import could not reproduce is not one to approve: approving it commits to paying + * a figure the vendor did not bill. Refusing it stays offered - that is the answer it deserves. + * + * @return void + */ + public function testAnInvoiceThatDoesNotTotalItsDocumentCannotBeApproved() + { + global $db; + + $invoice = $this->createSpecimenSupplierInvoice(); + $this->addEInvoicingDocument($invoice->id); + + $einvoicing = new EInvoicing($db); + $offered = array_map('intval', array_keys($einvoicing->getSendableStatusesForReceivedInvoice($invoice->id, 'invoice_supplier'))); + $this->assertContains(EInvoicing::STATUS_APPROVED, $offered, 'nothing blocks an invoice that totals its document'); + + SupplierInvoiceHelper::flagTotalsMismatch((int) $invoice->id, 30.00, 130.00); + + $offered = array_map('intval', array_keys($einvoicing->getSendableStatusesForReceivedInvoice($invoice->id, 'invoice_supplier'))); + $this->assertNotContains(EInvoicing::STATUS_APPROVED, $offered, 'an invoice that does not total its document cannot be approved'); + $this->assertNotContains(EInvoicing::STATUS_PARTIALLY_APPROVED, $offered, 'nor partially approved, which accepts it too'); + $this->assertContains(EInvoicing::STATUS_REFUSED, $offered, 'refusing the document is what is left to do'); + + SupplierInvoiceHelper::clearTotalsMismatch((int) $invoice->id); + } + + /** * The rule of issue #594: an invoice we refused is cancelled and owes nothing, so the credit note * the vendor issues to close the matter cannot be accepted in its turn. * @@ -1030,6 +1111,18 @@ } /** + * A reference shorter than the default minimum length of the tolerant fallback, and unique per + * call. It carries a letter on purpose: an all digits reference is refused by another rule, and + * the test that uses this one is about the length, not about the digits. + * + * @return string + */ + private function uniqueShortSupplierRef() + { + return 'A' . strtoupper(bin2hex(random_bytes(2))); + } + + /** * Create a draft supplier invoice carrying an explicit ref_supplier. * * @param string $refSupplier Value to store in ref_supplier @@ -1220,7 +1313,9 @@ { global $conf; - $shortRef = 'AB12'; + // A fixed value here survives any run that dies before the class-wide rollback, and the + // lookup below then answers "ambiguous" on that instance for good. + $shortRef = $this->uniqueShortSupplierRef(); $numericRef = (string) mt_rand(100000000, 999999999); $invoice = $this->createSupplierInvoiceWithRef('PAY123 - ' . $shortRef . ' - ' . $numericRef . ' - dinner'); diff -ruN --exclude=vendor --exclude=.git _base_lf/test/phpunit/TradingNameFromAliasTest.php cm_repo/einvoicing/test/phpunit/TradingNameFromAliasTest.php --- _base_lf/test/phpunit/TradingNameFromAliasTest.php 1970-01-01 01:00:00 +++ cm_repo/einvoicing/test/phpunit/TradingNameFromAliasTest.php 2026-09-11 06:39:46 @@ -0,0 +1,256 @@ +. + * or see https://www.gnu.org/ + */ + +/** + * \file test/phpunit/TradingNameFromAliasTest.php + * \ingroup test + * \brief The trading name of a party is its commercial name, or nothing at all. + * \remarks BT-45 carried the legal name of the customer, repeating BT-44, and the commercial + * name recorded on the third party card never reached the document (#847). The + * document is built here from a real invoice, because the value comes from the + * assembly of the invoice data, not from the writer that puts it in the XML. + */ + + +// This script must only be run from the command line. +if (PHP_SAPI !== 'cli') { + echo "Error: this script must be run from the command line (CLI), not through a web server.\n"; + exit(1); +} + +global $conf, $user, $langs, $db; + +// Load Dolibarr environment. Same resolution as the other test files of the module. +$dolibarrHtdocs = getenv('DOLIBARR_HTDOCS'); +if (!$dolibarrHtdocs) { + $dolibarrHtdocs = dirname(__FILE__) . '/../../htdocs'; +} +if (!file_exists($dolibarrHtdocs . '/master.inc.php')) { + throw new \RuntimeException('Could not locate master.inc.php under "' . $dolibarrHtdocs . '/". Set the environment variable (export DOLIBARR_HTDOCS=...) to the htdocs directory of the Dolibarr instance to test against.'); +} +require_once $dolibarrHtdocs . '/master.inc.php'; +require_once DOL_DOCUMENT_ROOT . '/user/class/user.class.php'; +require_once DOL_DOCUMENT_ROOT . '/societe/class/societe.class.php'; +require_once DOL_DOCUMENT_ROOT . '/compta/facture/class/facture.class.php'; + +/** + * @var Conf $conf + * @var DoliDB $db + * @var Translate $langs + * @var User $user + */ + +dol_include_once('einvoicing/class/protocols/CIIProtocol.class.php'); +require_once __DIR__ . '/CommonClassTestCompat.inc.php'; + + +/** + * Class TradingNameFromAliasTest + * + * Invoices three customers - one with a commercial name, one without, one whose commercial name + * merely repeats its legal name - and reads BT-28 and BT-45 back from the generated document. + */ +class TradingNameFromAliasTest extends CommonClassTest +{ + const RAM = 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100'; + + /** @var string Legal name of the customer, the one BT-44 states */ + const BUYER_NAME = 'EINVOICING TEST ALIAS BUYER'; + /** @var string Commercial name of the customer, the one BT-45 is for */ + const BUYER_ALIAS = 'Alias & Commerce'; + + /** + * Build one invoice for a customer of the given shape and generate its document. + * + * @param string $alias Commercial name of the customer, empty for a customer without one + * @return string The generated document + */ + private function documentForAlias($alias) + { + global $conf, $db, $langs, $mysoc; + + $user = new User($db); + $this->assertGreaterThan(0, $user->fetch(1), 'the instance has a user to act as'); + + // The seller is the company of the instance, whose identifiers a demo database does not + // necessarily fill: the generation stops before the first name is written without them. + // $mysoc is a global object, so pinning it changes nothing in the database and is undone below. + $savSeller = array( + 'idprof1' => $mysoc->idprof1, + 'idprof2' => $mysoc->idprof2, + 'tva_intra' => $mysoc->tva_intra, + 'country_id' => $mysoc->country_id, + 'country_code' => $mysoc->country_code, + 'name_alias' => $mysoc->name_alias, + ); + $mysoc->idprof1 = '000000001'; + $mysoc->idprof2 = '00000000100010'; + $mysoc->tva_intra = 'FR12000000001'; + $mysoc->country_id = 1; + $mysoc->country_code = 'FR'; + + $savPdp = getDolGlobalString('EINVOICING_PDP'); + $conf->global->EINVOICING_PDP = 'SPECIMEN'; + + try { + $buyer = new Societe($db); + $buyer->name = self::BUYER_NAME; + $buyer->name_alias = $alias; + $buyer->client = 1; + // Some instances - the demo database among them - number their customers with a module + // that refuses a third party without a code. + $buyer->code_client = 'EINVAL' . strtoupper(substr(md5(uniqid('', true)), 0, 6)); + $buyer->address = '2 rue du Test'; + $buyer->zip = '75000'; + $buyer->town = 'Paris'; + $buyer->country_id = 1; // France + $buyer->country_code = 'FR'; + $buyer->idprof1 = '000000002'; + $buyer->idprof2 = '00000000200010'; + $buyer->tva_intra = 'FR12000000002'; + $this->assertGreaterThan(0, $buyer->create($user), 'the customer is created: ' . $buyer->error); + + $invoice = new Facture($db); + $invoice->socid = $buyer->id; + $invoice->type = Facture::TYPE_STANDARD; + $invoice->date = dol_now(); + $this->assertGreaterThan(0, $invoice->create($user), 'the invoice is created: ' . $invoice->error); + + $lineId = $invoice->addline('Alias line', 100.0, 1, 20.0); + $this->assertGreaterThan(0, $lineId, 'the line of the invoice is added: ' . $invoice->error); + + $reloaded = new Facture($db); + $this->assertGreaterThan(0, $reloaded->fetch($invoice->id), 'the invoice is read back'); + $reloaded->fetch_lines(); + $reloaded->fetch_thirdparty(); + + $protocol = new CIIProtocol($db); + $path = $protocol->generateXML($reloaded, $langs); + $this->assertNotEmpty($path, 'the document is generated: ' . $protocol->error . ' ' . implode(', ', (array) $protocol->errors)); + $this->assertFileExists((string) $path, 'the generated document is written'); + + return (string) file_get_contents((string) $path); + } finally { + $conf->global->EINVOICING_PDP = $savPdp; + foreach ($savSeller as $property => $value) { + $mysoc->$property = $value; + } + } + } + + /** + * Read one term of a party out of the document. + * + * @param string $xml The generated document + * @param string $party ram:SellerTradeParty or ram:BuyerTradeParty + * @param string $term Element to read, relative to the party + * @return ?string Its text, null when the element is absent + */ + private function partyTerm($xml, $party, $term) + { + $doc = new DOMDocument(); + $this->assertTrue($doc->loadXML($xml), 'the generated document is well formed XML'); + $xpath = new DOMXPath($doc); + $xpath->registerNamespace('ram', self::RAM); + + $found = $xpath->query('//ram:' . $party . '/' . $term); + + return ($found !== false && $found->length > 0) ? $found->item(0)->textContent : null; + } + + /** + * The commercial name recorded on the customer is the one the document states as BT-45. + * + * On the code of #847 this reads the legal name of the customer instead. + * + * @return void + */ + public function testTheCommercialNameOfTheCustomerIsItsTradingName() + { + $xml = $this->documentForAlias(self::BUYER_ALIAS); + + $this->assertSame( + self::BUYER_NAME, + $this->partyTerm($xml, 'BuyerTradeParty', 'ram:Name'), + 'BT-44 states the legal name of the customer' + ); + $this->assertSame( + self::BUYER_ALIAS, + $this->partyTerm($xml, 'BuyerTradeParty', 'ram:SpecifiedLegalOrganization/ram:TradingBusinessName'), + 'BT-45 states the commercial name of the customer' + ); + } + + /** + * A customer without a commercial name has no BT-45, and an absent term is an absent element: + * an empty one is refused by PEPPOL-EN16931-R008 (#695). + * + * @return void + */ + public function testACustomerWithoutACommercialNameHasNoTradingName() + { + $xml = $this->documentForAlias(''); + + $this->assertNull( + $this->partyTerm($xml, 'BuyerTradeParty', 'ram:SpecifiedLegalOrganization/ram:TradingBusinessName'), + 'BT-45 is left out when the customer has no commercial name' + ); + $this->assertStringNotContainsString( + '', + $xml, + 'no empty element is written' + ); + } + + /** + * A commercial name that merely repeats the legal name says nothing, and the norm asks for the + * term only when it differs from the name of the party. + * + * @return void + */ + public function testACommercialNameThatRepeatsTheLegalNameIsNotStated() + { + $xml = $this->documentForAlias(self::BUYER_NAME); + + $this->assertNull( + $this->partyTerm($xml, 'BuyerTradeParty', 'ram:SpecifiedLegalOrganization/ram:TradingBusinessName'), + 'BT-45 is left out when it would repeat BT-44' + ); + } + + /** + * The company setup of the core has no commercial name, so the seller has no BT-28 to declare. + * + * @return void + */ + public function testTheSellerStatesNoTradingNameItDoesNotHave() + { + $xml = $this->documentForAlias(self::BUYER_ALIAS); + + // Whatever the instance is called, its name is stated: without that the absence below would + // only prove that the seller party was not built at all. + $this->assertNotEmpty( + (string) $this->partyTerm($xml, 'SellerTradeParty', 'ram:Name'), + 'BT-27 states the name of the company of the instance' + ); + $this->assertNull( + $this->partyTerm($xml, 'SellerTradeParty', 'ram:SpecifiedLegalOrganization/ram:TradingBusinessName'), + 'BT-28 is left out: the core has no commercial name for the company of the instance' + ); + } +} diff -ruN --exclude=vendor --exclude=.git _base_lf/test/phpunit/fixtures/einvoicing_samples/cii_creditnote.xml cm_repo/einvoicing/test/phpunit/fixtures/einvoicing_samples/cii_creditnote.xml --- _base_lf/test/phpunit/fixtures/einvoicing_samples/cii_creditnote.xml 2026-09-11 07:09:36 +++ cm_repo/einvoicing/test/phpunit/fixtures/einvoicing_samples/cii_creditnote.xml 2026-09-11 06:39:46 @@ -77,7 +77,6 @@ EINVOICING TEST SELLER 000000001 - EINVOICING TEST SELLER EINVOICING TEST SELLER @@ -107,7 +106,6 @@ EINVOICING TEST BUYER 000000002 - EINVOICING TEST BUYER 75000 diff -ruN --exclude=vendor --exclude=.git _base_lf/test/phpunit/fixtures/einvoicing_samples/cii_deposit.xml cm_repo/einvoicing/test/phpunit/fixtures/einvoicing_samples/cii_deposit.xml --- _base_lf/test/phpunit/fixtures/einvoicing_samples/cii_deposit.xml 2026-09-11 07:09:36 +++ cm_repo/einvoicing/test/phpunit/fixtures/einvoicing_samples/cii_deposit.xml 2026-09-11 06:39:46 @@ -77,7 +77,6 @@ EINVOICING TEST SELLER 000000001 - EINVOICING TEST SELLER EINVOICING TEST SELLER @@ -107,7 +106,6 @@ EINVOICING TEST BUYER 000000002 - EINVOICING TEST BUYER 75000 diff -ruN --exclude=vendor --exclude=.git _base_lf/test/phpunit/fixtures/einvoicing_samples/cii_replacement.xml cm_repo/einvoicing/test/phpunit/fixtures/einvoicing_samples/cii_replacement.xml --- _base_lf/test/phpunit/fixtures/einvoicing_samples/cii_replacement.xml 2026-09-11 07:09:36 +++ cm_repo/einvoicing/test/phpunit/fixtures/einvoicing_samples/cii_replacement.xml 2026-09-11 06:39:46 @@ -77,7 +77,6 @@ EINVOICING TEST SELLER 000000001 - EINVOICING TEST SELLER EINVOICING TEST SELLER @@ -107,7 +106,6 @@ EINVOICING TEST BUYER 000000002 - EINVOICING TEST BUYER 75000 diff -ruN --exclude=vendor --exclude=.git _base_lf/test/phpunit/fixtures/einvoicing_samples/cii_situation.xml cm_repo/einvoicing/test/phpunit/fixtures/einvoicing_samples/cii_situation.xml --- _base_lf/test/phpunit/fixtures/einvoicing_samples/cii_situation.xml 2026-09-11 07:09:36 +++ cm_repo/einvoicing/test/phpunit/fixtures/einvoicing_samples/cii_situation.xml 2026-09-11 06:39:46 @@ -77,7 +77,6 @@ EINVOICING TEST SELLER 000000001 - EINVOICING TEST SELLER EINVOICING TEST SELLER @@ -107,7 +106,6 @@ EINVOICING TEST BUYER 000000002 - EINVOICING TEST BUYER 75000 diff -ruN --exclude=vendor --exclude=.git _base_lf/test/phpunit/fixtures/einvoicing_samples/cii_standard.xml cm_repo/einvoicing/test/phpunit/fixtures/einvoicing_samples/cii_standard.xml --- _base_lf/test/phpunit/fixtures/einvoicing_samples/cii_standard.xml 2026-09-11 07:09:36 +++ cm_repo/einvoicing/test/phpunit/fixtures/einvoicing_samples/cii_standard.xml 2026-09-11 06:39:46 @@ -77,7 +77,6 @@ EINVOICING TEST SELLER 000000001 - EINVOICING TEST SELLER EINVOICING TEST SELLER @@ -107,7 +106,6 @@ EINVOICING TEST BUYER 000000002 - EINVOICING TEST BUYER 75000 diff -ruN --exclude=vendor --exclude=.git _base_lf/vendorref_list.php cm_repo/einvoicing/vendorref_list.php --- _base_lf/vendorref_list.php 2026-09-11 07:09:36 +++ cm_repo/einvoicing/vendorref_list.php 2026-09-11 06:39:46 @@ -345,7 +345,14 @@ print ''; } print ''; print ''; print '';
'.$langs->trans("Field").''.$langs->trans("ValueFromInvoice").''; -print $form->select_company($search_socid, 'search_socid', '(s.fournisseur:=:1)', '1', 0, 0, array(), 0, 'maxwidth200'); +// Form::select_company() does not read this filter the same way on every version. Until Dolibarr 18 +// the criteria is appended to the query as it stands, so it has to be plain SQL; from 18 a criteria +// holding parentheses is converted by forgeSQLFromUniversalSearchCriteria(), and on 24 that conversion +// is no longer conditional - every criteria goes through it. Passing the Universal Search syntax to 17 +// therefore ends the page on "DB_ERROR_SYNTAX ... near ':=:1))'", and passing plain SQL to 24 would be +// mangled the other way round. +$vendorfilter = ((float) DOL_VERSION < 18) ? 's.fournisseur = 1' : '(s.fournisseur:=:1)'; +print $form->select_company($search_socid, 'search_socid', $vendorfilter, '1', 0, 0, array(), 0, 'maxwidth200'); print '