From 6937f234795772604de7b61cdc673452f5895d09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20FRANCE?= Date: Tue, 9 Jun 2026 00:59:42 +0200 Subject: [PATCH 01/18] clean url of payment (#38642) --- htdocs/core/lib/payments.lib.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/core/lib/payments.lib.php b/htdocs/core/lib/payments.lib.php index 8be25a90c9d..19c48107dc8 100644 --- a/htdocs/core/lib/payments.lib.php +++ b/htdocs/core/lib/payments.lib.php @@ -287,7 +287,7 @@ function getOnlinePaymentUrl($mode, $type, $ref = '', $amount = 0, $freetag = 'y } if ($type == 'free') { - $out = $urltouse.'/public/payment/newpayment.php?amount='.($mode ? '' : '').price2num($amount, 'MT').($mode ? '' : '').'&tag='.($mode ? '' : '').$freetag.($mode ? '' : ''); + $out = $urltouse.'/public/payment/newpayment.php?amount='.($mode ? '' : '').price2num($amount, 'MT').($mode ? '' : '').'&tag='.($mode ? '' : '').urlencode($freetag).($mode ? '' : ''); if (getDolGlobalString('PAYMENT_SECURITY_TOKEN')) { if (!getDolGlobalString('PAYMENT_SECURITY_TOKEN_UNIQUE')) { $out .= '&securekey='.urlencode(getDolGlobalString('PAYMENT_SECURITY_TOKEN')); From 34b508d2669acbc689487703e81bcfa1f381ac47 Mon Sep 17 00:00:00 2001 From: atm-adrien <67913809+atm-adrien@users.noreply.github.com> Date: Tue, 9 Jun 2026 01:06:30 +0200 Subject: [PATCH 02/18] FIX : Clean datas on infoGroups api call (#38643) --- htdocs/user/class/api_users.class.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/htdocs/user/class/api_users.class.php b/htdocs/user/class/api_users.class.php index b5622c41b7b..e40b4c363c3 100644 --- a/htdocs/user/class/api_users.class.php +++ b/htdocs/user/class/api_users.class.php @@ -618,6 +618,12 @@ class Users extends DolibarrApi throw new RestException(404, 'Group not found'); } + if ($load_members > 0 && is_array($group_static->members) && count($group_static->members) > 0) { + foreach ($group_static->members as &$member) { + $member = $this->_cleanObjectDatas($member); + } + } + return $this->_cleanObjectDatas($group_static); } From 61afdd2e8ca2420359f76d99434a3b8cdc00eac8 Mon Sep 17 00:00:00 2001 From: atm-corentin <165782689+atm-corentin@users.noreply.github.com> Date: Tue, 9 Jun 2026 01:07:51 +0200 Subject: [PATCH 03/18] FIX : use get_exdir to compute holiday upload path in prepare_head (#38644) holiday module stores files using an ID-based 2-level hierarchy (get_exdir with level=2), not a ref-based flat path. Using dol_sanitizeFileName($object->ref) returns the wrong directory, causing the attachment badge count to always read 0. --- htdocs/core/lib/holiday.lib.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/core/lib/holiday.lib.php b/htdocs/core/lib/holiday.lib.php index 76361b759c4..29c851b585d 100644 --- a/htdocs/core/lib/holiday.lib.php +++ b/htdocs/core/lib/holiday.lib.php @@ -43,7 +43,7 @@ function holiday_prepare_head($object) // Attachments require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php'; require_once DOL_DOCUMENT_ROOT.'/core/class/link.class.php'; - $upload_dir = $conf->holiday->multidir_output[$object->entity].'/'.dol_sanitizeFileName($object->ref); + $upload_dir = $conf->holiday->multidir_output[$object->entity].'/'.get_exdir(0, 0, 0, 1, $object, 'holiday'); $nbFiles = count(dol_dir_list($upload_dir, 'files', 0, '', '(\.meta|_preview.*\.png)$')); $nbLinks = Link::count($db, $object->element, $object->id); $head[$h][0] = DOL_URL_ROOT.'/holiday/document.php?id='.$object->id; From 62b1c0b2ab6b564caf89904b2251c94658a33371 Mon Sep 17 00:00:00 2001 From: atm-corentin <165782689+atm-corentin@users.noreply.github.com> Date: Tue, 9 Jun 2026 01:11:03 +0200 Subject: [PATCH 04/18] FIX : use multidir_output fallback for multi-entity HRM PDF generation (#38640) When generating a PDF for an Evaluation on an entity other than entity 1, $conf->hrm->multidir_output[$entity] was used without a fallback to $conf->hrm->dir_output. If multidir_output for the target entity is empty, the resulting path was '/evaluation/' (missing base), causing a directory creation failure. Apply the same pattern used in other PDF modules (e.g. pdf_crabe for invoices): fall back to dir_output when multidir_output[$entity] is not set. --- htdocs/core/modules/hrm/doc/pdf_standard.modules.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/htdocs/core/modules/hrm/doc/pdf_standard.modules.php b/htdocs/core/modules/hrm/doc/pdf_standard.modules.php index 8c624b85a0d..a7249bf1a57 100644 --- a/htdocs/core/modules/hrm/doc/pdf_standard.modules.php +++ b/htdocs/core/modules/hrm/doc/pdf_standard.modules.php @@ -161,14 +161,14 @@ class pdf_standard extends ModelePDFEvaluation if ($conf->hrm->dir_output) { // Definition of $dir and $file + $entity = isset($object->entity) ? $object->entity : 1; + $basedir = empty($conf->hrm->multidir_output[$entity]) ? $conf->hrm->dir_output : $conf->hrm->multidir_output[$entity]; if ($object->specimen) { - //$dir = $conf->hrm->dir_output; - $dir = $conf->hrm->multidir_output[isset($object->entity) ? $object->entity : 1].'/evaluation'; + $dir = $basedir.'/evaluation'; $file = $dir."/SPECIMEN.pdf"; } else { $objectref = dol_sanitizeFileName($object->ref); - //$dir = $conf->hrm->dir_output."/".$objectref; - $dir = $conf->hrm->multidir_output[isset($object->entity) ? $object->entity : 1].'/evaluation'."/".$objectref; + $dir = $basedir.'/evaluation'."/".$objectref; $file = $dir."/".$objectref.".pdf"; } From 49e3333cdae53af7d938f071e935c4cd9373f754 Mon Sep 17 00:00:00 2001 From: atm-jonathan <146709163+atm-jonathan@users.noreply.github.com> Date: Tue, 9 Jun 2026 01:52:43 +0200 Subject: [PATCH 05/18] FIX: API categories/objects returns empty array for ticket type Add missing require_once for api_tickets.class.php and handle ticket type in getObjects() method of api_categories.class.php (#38584) --- htdocs/categories/class/api_categories.class.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/htdocs/categories/class/api_categories.class.php b/htdocs/categories/class/api_categories.class.php index 0e03c6ef4df..131186d16b9 100644 --- a/htdocs/categories/class/api_categories.class.php +++ b/htdocs/categories/class/api_categories.class.php @@ -30,6 +30,7 @@ require_once DOL_DOCUMENT_ROOT.'/product/class/api_products.class.php'; require_once DOL_DOCUMENT_ROOT.'/societe/class/api_contacts.class.php'; require_once DOL_DOCUMENT_ROOT.'/societe/class/api_thirdparties.class.php'; require_once DOL_DOCUMENT_ROOT.'/projet/class/api_projects.class.php'; +require_once DOL_DOCUMENT_ROOT.'/ticket/class/api_tickets.class.php'; /** * API class for categories @@ -839,6 +840,8 @@ class Categories extends DolibarrApi $objects_api = new Contacts(); } elseif ($type == 'project') { $objects_api = new Projects(); + } elseif ($type == 'ticket') { + $objects_api = new Tickets(); } if (is_object($objects_api)) { From 1e9690b72c669d2fabd6909e027ecfa789b3b37a Mon Sep 17 00:00:00 2001 From: Laurent Destailleur Date: Tue, 9 Jun 2026 03:04:50 +0200 Subject: [PATCH 06/18] Fix CI --- htdocs/user/card.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/user/card.php b/htdocs/user/card.php index 64a8844e58f..3cd66bb721c 100644 --- a/htdocs/user/card.php +++ b/htdocs/user/card.php @@ -469,7 +469,7 @@ if (empty($reshook)) { $object->pass = GETPOST("password", 'password'); } if ($permissiontoeditpasswordandsee || $user->hasRight("api", "apikey", "generate")) { - $object->api_key = (GETPOSTISSET("api_key", 'alphanohtml')) ? GETPOST("api_key", 'alphanohtml') : $object->api_key; + $object->api_key = (GETPOSTISSET("api_key") ? GETPOST("api_key", 'alphanohtml') : $object->api_key); } if (!empty($user->admin) && $user->id != $id) { // admin flag can only be set/unset by an admin user and not four ourself From c852196997fd65193b04599466891da245100bab Mon Sep 17 00:00:00 2001 From: kkhelifa-opendsi Date: Tue, 9 Jun 2026 03:13:20 +0200 Subject: [PATCH 07/18] FIX : Using the default email template when sending bulk emails from a list (#38579) --- htdocs/core/tpl/massactions_pre.tpl.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/core/tpl/massactions_pre.tpl.php b/htdocs/core/tpl/massactions_pre.tpl.php index 59d478e8ba9..d67eff4611c 100644 --- a/htdocs/core/tpl/massactions_pre.tpl.php +++ b/htdocs/core/tpl/massactions_pre.tpl.php @@ -351,7 +351,7 @@ if ($massaction == 'presend') { // Tableau des parameters complementaires du post $formmail->param['action'] = $action; $formmail->param['models'] = $modelmail; // the filter to know which kind of template emails to show. 'none' means no template suggested. - $formmail->param['models_id'] = GETPOSTINT('modelmailselected') ? GETPOSTINT('modelmailselected') : '-1'; + $formmail->param['models_id'] = GETPOSTINT('modelmailselected') ? GETPOSTINT('modelmailselected') : ''; $formmail->param['id'] = implode(',', $arrayofselected); // $formmail->param['returnurl']=$_SERVER["PHP_SELF"].'?id='.$object->id; if (getDolGlobalString('MAILING_LIMIT_SENDBYWEB') && count($listofselectedrecipientobjid) > $conf->global->MAILING_LIMIT_SENDBYWEB) { From 263eb01d48ec8c09f081a175994e2a01f916f152 Mon Sep 17 00:00:00 2001 From: w3houde Date: Tue, 9 Jun 2026 03:27:29 +0200 Subject: [PATCH 08/18] Fix #38212 skip approver balance warning when leave type does not deduct (#38550) htdocs/holiday/card.php fired the 'HolidaysToValidateAlertSolde' note on the approval email whenever the number of requested open days exceeded the current balance, even when the leave type was configured in c_holiday_types with affect=0 (the type does not deduct from the holiday balance). For non-deducted types, by definition, the balance cannot drop, so the warning is bogus and misleading for the approver. Look up c_holiday_types.affect via getDictionaryValue() and only compute the balance comparison when affect=1. The HOLIDAY_HIDE_APPROVER_ABOUT_NEGATIVE_BALANCE global keeps its previous effect of silencing the warning entirely. Same code path on 21.0, 22.0, 23.0 and develop; PR targets 21.0. Signed-off-by: Dolicraft Co-authored-by: Dolicraft --- htdocs/holiday/card.php | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/htdocs/holiday/card.php b/htdocs/holiday/card.php index 0d2b8ed013a..459dcf75da9 100644 --- a/htdocs/holiday/card.php +++ b/htdocs/holiday/card.php @@ -520,12 +520,18 @@ if (empty($reshook)) { } } - // option to notify the validator if the balance is less than the request + // option to notify the validator if the balance is less than the request. + // Skip the warning when the leave type has affect=0 in c_holiday_types, + // i.e. the type is configured not to deduct days from the balance, so a + // shortage is irrelevant (see issue #38212). if (!getDolGlobalString('HOLIDAY_HIDE_APPROVER_ABOUT_NEGATIVE_BALANCE')) { - $nbopenedday = num_open_day($object->date_debut_gmt, $object->date_fin_gmt, 0, 1, $object->halfday); + $typeaffectsbalance = (int) getDictionaryValue('c_holiday_types', 'affect', $object->fk_type, true); + if ($typeaffectsbalance) { + $nbopenedday = num_open_day($object->date_debut_gmt, $object->date_fin_gmt, 0, 1, $object->halfday); - if ($nbopenedday > $object->getCPforUser($object->fk_user, $object->fk_type)) { - $message .= "

".$langs->transnoentities("HolidaysToValidateAlertSolde")."

\n"; + if ($nbopenedday > $object->getCPforUser($object->fk_user, $object->fk_type)) { + $message .= "

".$langs->transnoentities("HolidaysToValidateAlertSolde")."

\n"; + } } } From c987be86a3c2034c1a21ba8bf927ee6642e2c19c Mon Sep 17 00:00:00 2001 From: w3houde Date: Tue, 9 Jun 2026 14:46:40 +0200 Subject: [PATCH 09/18] Fix #34571 tag timespent invoice lines as service in all modes (#38545) Facture::addline()'s sixteenth positional argument is $type, which defaults to 0 (TYPE_PRODUCT). When the addline call passes a non-zero $fk_product, addline overrides $type from the fetched Product object, so the type the caller passed is just a default for the no-product case. htdocs/projet/tasks/time.php had three invoice-line creation paths for the project time-spent flow: - line 567 (one line per user per day) - line 666 (one line per time-spent declaration) - line 764 (one line per task) Only line 764 explicitly passed type=1. The other two relied on the addline default of 0, so when a time entry had no product attached and the chosen "service to use on lines by default" did not match a product row that addline could fetch (for example if the service id got reset between sessions or the product was disabled), the line was stored with product_type=0. The PDF then computed 'Delivery of goods' in the category-of-operations mention even though the invoice was for time spent. The reporter confirmed the 'one line per user' and 'one line per task' modes already worked, which lines up with mode 567 (no product on the time entry) and mode 764 (explicit type=1). Pass Product::TYPE_SERVICE as the type at the two affected call sites, matching the explicit value already used by the per-task mode. The product fetch inside addline still overrides this when a real service product is bound to the line, so the change is a no-op in the happy path and only fixes the leak when no product is resolved. Signed-off-by: Dolicraft Co-authored-by: Dolicraft --- htdocs/projet/tasks/time.php | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/htdocs/projet/tasks/time.php b/htdocs/projet/tasks/time.php index 93ef6997fd4..4f3b1395704 100644 --- a/htdocs/projet/tasks/time.php +++ b/htdocs/projet/tasks/time.php @@ -36,6 +36,7 @@ require '../../main.inc.php'; require_once DOL_DOCUMENT_ROOT . '/projet/class/project.class.php'; require_once DOL_DOCUMENT_ROOT . '/projet/class/task.class.php'; require_once DOL_DOCUMENT_ROOT . '/compta/facture/class/facture.class.php'; +require_once DOL_DOCUMENT_ROOT . '/product/class/product.class.php'; require_once DOL_DOCUMENT_ROOT . '/core/lib/project.lib.php'; require_once DOL_DOCUMENT_ROOT . '/core/lib/date.lib.php'; require_once DOL_DOCUMENT_ROOT . '/core/class/html.formother.class.php'; @@ -563,8 +564,11 @@ if ($action == 'confirm_generateinvoice') { $idprodline = $fk_product; } - // Add lines - $lineid = $tmpinvoice->addline($langs->trans("TimeSpentForInvoice", $username) . ' : ' . $qtyhourtext, $pu_htline, round($qtyhour / $prodDurationHours, 2), $txtvaline, $localtax1line, $localtax2line, ($idprodline > 0 ? $idprodline : 0), $remiseproject); + // Add lines. Pass type=1 (service) explicitly so the invoice line is tagged + // as a service even when no product is bound to the time entry. Otherwise + // the default $type=0 leaks through and the PDF labels the operation as + // "Delivery of goods" instead of "Provision of services" (issue #34571). + $lineid = $tmpinvoice->addline($langs->trans("TimeSpentForInvoice", $username) . ' : ' . $qtyhourtext, $pu_htline, round($qtyhour / $prodDurationHours, 2), $txtvaline, $localtax1line, $localtax2line, ($idprodline > 0 ? $idprodline : 0), $remiseproject, '', '', 0, 0, '', 'HT', 0, Product::TYPE_SERVICE); if ($lineid < 0) { $error++; setEventMessages(null, $tmpinvoice->errors, 'errors'); @@ -663,7 +667,11 @@ if ($action == 'confirm_generateinvoice') { } $idprodline = $value['fk_product']; } - $lineid = $tmpinvoice->addline($value['note'], $pu_htline, round($qtyhour / $prodDurationHours, 2), $txtvaline, $localtax1line, $localtax2line, ($idprodline > 0 ? $idprodline : 0), $remiseproject); + // Pass type=1 (service) explicitly so the invoice line is tagged as a service + // even when no product is bound to the time entry. Otherwise the default + // $type=0 leaks through and the PDF labels the operation as + // "Delivery of goods" instead of "Provision of services" (issue #34571). + $lineid = $tmpinvoice->addline($value['note'], $pu_htline, round($qtyhour / $prodDurationHours, 2), $txtvaline, $localtax1line, $localtax2line, ($idprodline > 0 ? $idprodline : 0), $remiseproject, '', '', 0, 0, '', 'HT', 0, Product::TYPE_SERVICE); if ($lineid < 0) { $error++; setEventMessages(null, $tmpinvoice->errors, 'errors'); From 9b146a0837a6ce405f0c9248cfdea0c54b5bf5ae Mon Sep 17 00:00:00 2001 From: w3houde Date: Tue, 9 Jun 2026 18:33:24 +0200 Subject: [PATCH 10/18] Fix #34352 mass subscription end date from AdherentType duration (#38548) The single subscription form (htdocs/adherents/subscription.php lines 219-220 and 262) computes the subscription end date from the selected AdherentType: it uses adht->duration_value and adht->duration_unit to derive the end date with dol_time_plus_duree, and only falls back to '1 year' when those fields are empty. The mass action on the member list (htdocs/adherents/list.php $action == 'createsubscription_confirm') skipped that step and called Adherent::subscription($now, $amount, ..., $datesubend = 0) with $datesubend left at 0 for any member. Adherent::subscription() treats $datesubend = 0 as 'no end date supplied' and defaults to +1 year - 1 day, so a member type configured for 6 months ended up with a 12-month subscription whenever the mass action was used. Compute the per-member end date before the call, mirroring the single form: fetch the AdherentType of the current member, take duration_value / duration_unit, and add them to $now. The previously shipped MEMBER_SUBSCRIPTION_SUGGEST_END_OF_MONTH / MEMBER_SUBSCRIPTION_SUGGEST_END_OF_YEAR override (commit b1d906d6d38) still wins when set, because $datesubend keeps its computed value; the per-type fallback only kicks in when no global override was used. Same code path on 21.0, 22.0, 23.0 and develop; PR targets 21.0. Signed-off-by: Dolicraft Co-authored-by: Dolicraft --- htdocs/adherents/list.php | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/htdocs/adherents/list.php b/htdocs/adherents/list.php index b672b27f6ee..82c12cf48fb 100644 --- a/htdocs/adherents/list.php +++ b/htdocs/adherents/list.php @@ -378,7 +378,20 @@ if (empty($reshook)) { foreach ($toselect as $id) { $res = $tmpmember->fetch($id); if ($res > 0) { - $result = $tmpmember->subscription($now, $amount, 0, '', '', '', '', '', $datesubend); + // Mirror the single subscription form (adherents/subscription.php): if no + // global EOM/EOY override was set, compute the end date from the member's + // AdherentType duration_value/duration_unit so a 6-month type does not + // silently fall back to the 1-year default in Adherent::subscription() + // (see issue #34352). + $datesubendthismember = $datesubend; + if (!$datesubendthismember && $tmpmember->typeid > 0) { + if ($adht->fetch($tmpmember->typeid) > 0) { + $delay = !empty($adht->duration_value) ? $adht->duration_value : 1; + $delayunit = !empty($adht->duration_unit) ? $adht->duration_unit : 'y'; + $datesubendthismember = dol_time_plus_duree(dol_time_plus_duree($now, $delay, $delayunit), -1, 'd'); + } + } + $result = $tmpmember->subscription($now, $amount, 0, '', '', '', '', '', $datesubendthismember); if ($result < 0) { $error++; } else { From 9ca97adc0b2326d7b37bb29045602263dc00f43c Mon Sep 17 00:00:00 2001 From: w3houde Date: Tue, 9 Jun 2026 18:36:54 +0200 Subject: [PATCH 11/18] Fix #32372 surface variant updateProperties failure instead of price=0 (#38541) ProductCombination::update() called $this->updateProperties($parent, $user) at line 439 and discarded the return value. updateProperties() walks the parent's price levels, computes the variant price from the combination's variation_price / variation_price_percentage and calls Product::updatePrice() on the child product. updatePrice() refuses to set a price below the configured price_min (returns -1 with 'ErrorPriceCantBeLowerThanMinPrice'), but ProductCombination::update() still returned 1 because the failure was thrown away. createProductCombination() initialises the new variant product with price = price_ttc = price_min = price_min_ttc = 0 to keep a clean price history, then commits the combination by calling $newcomb->update($user). When the parent product has a non-zero minimum price and the variation drops the computed price below it, updateProperties() returns -1, update() reports success anyway, the combination is committed and the variant product stays at price 0. The card displays price 0 and minimum 0 with no error message, which is exactly the behaviour the reporter described and that @JonBendtsen confirmed was still present on develop after the v22 release. Capture updateProperties()'s return value and propagate the negative result so the calling createProductCombination() sees the failure, rolls back the transaction and surfaces the underlying 'ErrorPriceCantBeLowerThanMinPrice' message. The variant is no longer silently created with a 0 price; users get either the legitimate adjusted price (when the variation is permissible) or a clear error prompting them to drop the parent's minimum price first. Signed-off-by: Dolicraft Co-authored-by: Dolicraft --- htdocs/variants/class/ProductCombination.class.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/htdocs/variants/class/ProductCombination.class.php b/htdocs/variants/class/ProductCombination.class.php index 2e4be1a5949..584b01418dd 100644 --- a/htdocs/variants/class/ProductCombination.class.php +++ b/htdocs/variants/class/ProductCombination.class.php @@ -436,7 +436,14 @@ class ProductCombination $parent = new Product($this->db); $parent->fetch($this->fk_product_parent); - $this->updateProperties($parent, $user); + // Propagate failure of updateProperties (otherwise an update where the variant + // percentage drives the new price below the parent product's price_min returns + // success but leaves the variant product at the price=0 it had right after + // createProductCombination, see issue #32372). + $result = $this->updateProperties($parent, $user); + if ($result < 0) { + return $result; + } return 1; } From aa06ea65903ca86c4dcb324af92fa351df675473 Mon Sep 17 00:00:00 2001 From: w3houde Date: Tue, 9 Jun 2026 18:40:11 +0200 Subject: [PATCH 12/18] Fix #29052 preserve IRPF / localtax2 when cloning an invoice (#38538) `Facture::createFromClone()` calls `Facture::fetch($fromid)` to populate the in-memory object, then immediately runs `$object->create($user)` to persist the clone. `fetch()` explicitly sets `$this->thirdparty = null;` (see facture.class.php fetch()), and the clone path never calls `fetch_thirdparty()` before `create()`. When `create()` walks the cloned lines it goes through `addLine()` -> `getLocalTaxesFromRate($txtva, 0, $this->thirdparty, $mysoc)` -> `get_localtax($vatrate, 2, $buyer, $seller)`. The buyer argument is the now-null $this->thirdparty, and `get_localtax()` guards on `!$thirdparty_buyer->localtax2_assuj` and returns 0 as soon as that property is unreadable. `calcul_price_total()` then computes total_localtax2 = 0, and addLine() resets the line's localtax2_tx to 0 ("if ($total_localtax2)" branch in addLine()). The cloned line is inserted with the IRPF (localtax2) rate stripped, which is the symptom the reporter and the four other thread participants observed. Call `fetch_thirdparty()` once in createFromClone() so the buyer is populated for the rest of the clone flow. The fix is local to the clone path; the regular "create from form" flow already had a thirdparty set by its caller and is not affected. Signed-off-by: Dolicraft Co-authored-by: Dolicraft --- htdocs/compta/facture/class/facture.class.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/htdocs/compta/facture/class/facture.class.php b/htdocs/compta/facture/class/facture.class.php index f6fac3811c4..e50ddfbc420 100644 --- a/htdocs/compta/facture/class/facture.class.php +++ b/htdocs/compta/facture/class/facture.class.php @@ -1276,6 +1276,12 @@ class Facture extends CommonInvoice $this->db->begin(); $object->fetch($fromid); + // fetch() clears $object->thirdparty, but the subsequent $object->create() path + // reaches addLine() -> getLocalTaxesFromRate() -> get_localtax() and uses the + // buyer thirdparty to decide whether IRPF/localtax2 applies. Without this fetch, + // get_localtax() sees $thirdparty_buyer = null, returns 0 and the cloned lines + // lose their localtax2_tx (see issue #29052). + $object->fetch_thirdparty(); // Load source object $objFrom = clone $object; From b7c5d2c76b19a5be988e0c4b0e49bd5265365d8c Mon Sep 17 00:00:00 2001 From: w3houde Date: Tue, 9 Jun 2026 18:51:03 +0200 Subject: [PATCH 13/18] Fix #29799 widen RUM truncation so customer code fits (#38537) * Fix #29799 widen RUM truncation so customer code fits BonPrelevement::buildRumNumber() builds an auto-generated SEPA UMR when no UMR was supplied on a new bank-mandate row. The format is {RUM/UMR}-{yymmddHHMM}-{drum_id-customer_code} with the tail truncated to 13 characters. That truncation is too aggressive: typical Dolibarr customer codes (CU1908-00002, CU2509-00012, ...) are already 12 characters, so any drum id at all pushes the tail past 13 and the customer code is cut mid-string. The generated UMR then no longer matches the actual customer code, which is the symptom in the report ("customer code CU1908-00002 becomes 1-CU1908-0000 in the UMR"). The SEPA standard allows up to 35 characters for MndtId. The non-truncatable prefix (RUM/UMR + '-' + 10-char date + '-') uses 15 characters, leaving up to 20 characters for the tail before hitting the 35-char limit, and 17 before hitting the more conservative 32-char target the original comment referenced. Bump the truncation to 20: the typical drum-id + 12-char customer code (14 chars) now fits cleanly, and longer codes still get truncated but at a much later point. Only the generation of newly created mandates is affected. Existing $companybankaccount->rum values stored in the database are not touched, so no migration is needed. Signed-off-by: Dolicraft * Update bonprelevement.class.php --------- Signed-off-by: Dolicraft Co-authored-by: Dolicraft Co-authored-by: Laurent Destailleur --- htdocs/compta/prelevement/class/bonprelevement.class.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/htdocs/compta/prelevement/class/bonprelevement.class.php b/htdocs/compta/prelevement/class/bonprelevement.class.php index 376a958ee6a..7a1ff683114 100644 --- a/htdocs/compta/prelevement/class/bonprelevement.class.php +++ b/htdocs/compta/prelevement/class/bonprelevement.class.php @@ -2187,8 +2187,8 @@ class BonPrelevement extends CommonObject $pre = substr(dol_string_nospecial(dol_string_unaccent($langs->transnoentitiesnoconv('RUM'))), 0, 3); // Must always be on 3 char ('RUM' or 'UMR'. This is a protection against bad translation) - // 3 char + '-' + 12 + '-' + id + '-' + code Must be lower than 32. - return $pre . '-' . dol_print_date($row_datec, 'dayhourlogsmall') . '-' . dol_trunc($row_drum . ($row_code_client ? '-' . $row_code_client : ''), 13, 'right', 'UTF-8', 1); + // 3 char + '-' + 10 (yymmddHHMM) + '-' + id + '-' + code. Must be under 32 (SEPA char limit for MndtId is however 35). + return $pre . '-' . dol_print_date($row_datec, 'dayhourlogsmall') . '-' . dol_trunc($row_drum . ($row_code_client ? '-' . $row_code_client : ''), 17, 'right', 'UTF-8', 1); } From b87ed03b2bcd930c62b8d0cb4d6f11c53ed59b42 Mon Sep 17 00:00:00 2001 From: w3houde Date: Tue, 9 Jun 2026 18:52:30 +0200 Subject: [PATCH 14/18] Fix #27987 refuse to save signature when too-many-links sanitizer fires (#38534) * Fix #27987 refuse to save signature when too-many-links sanitizer fires When the user signature contains more inline links/images than MAIN_SECURITY_MAX_IMG_IN_HTML_CONTENT allows, GETPOST('signature', 'restricthtml') returns the literal string 'ErrorTooManyLinksIntoHTMLString' instead of the sanitized HTML (see dol_string_unicode in htdocs/core/lib/functions.lib.php:8755-8757). On the user/card.php save sites, that literal was assigned verbatim to $object->signature and persisted; the next outgoing email then used the literal as the signature block, so the customer received an email whose entire signature was the string 'ErrorTooManyLinksIntoHTMLString'. Detect the literal at both save sites (action == 'add' around line 316 and action == 'update' around line 499), increment $error, surface a clear setEventMessages and route the user back to the form so the signature is not persisted. The MAIN_SECURITY_MAX_IMG_IN_HTML_CONTENT setting is the legitimate guard; this PR only ensures its rejection output never reaches the database or downstream emails. Signed-off-by: Dolicraft * Add ErrorTooManyLinksIntoHTMLString en_US translation pre-commit CI on PR #38534 (Fix #27987) flagged the new lang key ErrorTooManyLinksIntoHTMLString used in user/card.php as missing. Add the en_US entry so the existing helper text from the constant MAIN_SECURITY_MAX_IMG_IN_HTML_CONTENT is reachable through $langs->trans(). Signed-off-by: Dolicraft --------- Signed-off-by: Dolicraft Co-authored-by: Dolicraft --- htdocs/langs/en_US/errors.lang | 1 + htdocs/user/card.php | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/htdocs/langs/en_US/errors.lang b/htdocs/langs/en_US/errors.lang index 4d24adbcce8..5d689374550 100644 --- a/htdocs/langs/en_US/errors.lang +++ b/htdocs/langs/en_US/errors.lang @@ -443,3 +443,4 @@ ThisIdNotDefined=Id not defined OperNotDefined=Payment method not defined EmptyMessageNotAllowedError=Empty message is not allowed SomeShipmentExists=Error, there is some shipment linked to the order. Deletion refused. +ErrorTooManyLinksIntoHTMLString=The HTML content contains too many links or images. Reduce the number or raise MAIN_SECURITY_MAX_IMG_IN_HTML_CONTENT. diff --git a/htdocs/user/card.php b/htdocs/user/card.php index 2bffd4d9c7c..0cd12acb160 100644 --- a/htdocs/user/card.php +++ b/htdocs/user/card.php @@ -314,6 +314,16 @@ if (empty($reshook)) { $object->email = preg_replace('/\s+/', '', GETPOST("email", 'alphanohtml')); $object->job = GETPOST("job", 'alphanohtml'); $object->signature = GETPOST("signature", 'restricthtml'); + // restricthtml may swap the value with the literal 'ErrorTooManyLinksIntoHTMLString' + // when the html exceeds MAIN_SECURITY_MAX_IMG_IN_HTML_CONTENT (see issue #27987). + // Refuse the save so the literal does not end up persisted and later sent as an + // email body to customers. + if ($object->signature === 'ErrorTooManyLinksIntoHTMLString') { + $error++; + $langs->load("errors"); + setEventMessages($langs->trans('ErrorTooManyLinksIntoHTMLString'), null, 'errors'); + $action = 'create'; + } $object->accountancy_code = GETPOST("accountancy_code", 'alphanohtml'); $object->note_public = GETPOST("note_public", 'restricthtml'); $object->note_private = GETPOST("note_private", 'restricthtml'); @@ -497,6 +507,16 @@ if (empty($reshook)) { $object->email = preg_replace('/\s+/', '', GETPOST("email", 'alphanohtml')); $object->job = GETPOST("job", 'alphanohtml'); $object->signature = GETPOST("signature", 'restricthtml'); + // restricthtml may swap the value with the literal 'ErrorTooManyLinksIntoHTMLString' + // when the html exceeds MAIN_SECURITY_MAX_IMG_IN_HTML_CONTENT (see issue #27987). + // Refuse the save so the literal does not end up persisted and later sent as an + // email body to customers. + if ($object->signature === 'ErrorTooManyLinksIntoHTMLString') { + $error++; + $langs->load("errors"); + setEventMessages($langs->trans('ErrorTooManyLinksIntoHTMLString'), null, 'errors'); + $action = 'edit'; + } $object->accountancy_code = GETPOST("accountancy_code", 'alphanohtml'); $object->openid = GETPOST("openid", 'alphanohtml'); $object->fk_user = GETPOSTINT("fk_user") > 0 ? GETPOSTINT("fk_user") : 0; From d17ae69331fef6a372673909217bf27755280cfa Mon Sep 17 00:00:00 2001 From: w3houde Date: Tue, 9 Jun 2026 19:06:04 +0200 Subject: [PATCH 15/18] Fix #38182 Backport oldcopy empty check to 21.0 (#38505) Updating a product selling price via /product/price.php triggered a fatal "Call to undefined method stdClass::isEmpty()" at Product::update line 1457 of 21.0. In the update_price flow, $this->oldcopy is a stdClass (not a Product instance), so isEmpty() does not exist on it. The page returned HTTP 500 and the open transaction was forcibly closed by DoliDBMysqli::close. Replace the isEmpty() call with empty($this->oldcopy->id), which is the form already shipped in 22.0, 23.0 and develop after upstream commit ef94452f77f. The accompanying comment from that commit is also brought in. Same root cause as issue 37792 (closed, fixed in 23.0.3 and forward); the fix was never backported to 21.0 even though the bug is still triggered on that branch. Reported by @YOYOKMC. Signed-off-by: Dolicraft Co-authored-by: Dolicraft --- htdocs/product/class/product.class.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/htdocs/product/class/product.class.php b/htdocs/product/class/product.class.php index ddf541c8279..2af3eca7067 100644 --- a/htdocs/product/class/product.class.php +++ b/htdocs/product/class/product.class.php @@ -1480,7 +1480,8 @@ class Product extends CommonObject if ($result >= 0) { // $this->oldcopy should have been set by the caller of update (here properties were already modified) - if (is_null($this->oldcopy) || (is_object($this->oldcopy) && $this->oldcopy->isEmpty())) { + // Note that this->oldcopy must be object and not stdClass, if not the method hasbatch() will not work. + if (is_null($this->oldcopy) || (is_object($this->oldcopy) && empty($this->oldcopy->id))) { $this->oldcopy = dol_clone($this, 1); } // Test if batch management is activated on existing product From 8d8debd441f0a012856cbfeca76cb26cca60f5d3 Mon Sep 17 00:00:00 2001 From: w3houde Date: Tue, 9 Jun 2026 19:07:07 +0200 Subject: [PATCH 16/18] Fix #38171 Allow underscore in extrafields old-syntax filter regex (#38506) ExtraFields::showInputField normalises the legacy "(field=123)" filter syntax of "List from a table" extrafields into the modern USF "(field:=:123)" form. The compatibility regex "/^\(?([a-z0-9]+)([=<>]+)(\d+)\)?$/i" only accepts alphanumeric field names, so a filter like "fk_typent=2" does not match, the filter is left as is, and forgeSQLFromUniversalSearchCriteria later fails with "Filter error" because the raw expression is not valid universal search syntax. Accept the underscore character in the field name class, the rest of the predicate is untouched. The two identical occurrences in the file (one per branch of the dispatch) are updated. Reported and root cause pinpointed by @stephanebourdon-cmyk. Signed-off-by: Dolicraft Co-authored-by: Dolicraft --- htdocs/core/class/extrafields.class.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/htdocs/core/class/extrafields.class.php b/htdocs/core/class/extrafields.class.php index 77f53bcf85d..8acedbe22b8 100644 --- a/htdocs/core/class/extrafields.class.php +++ b/htdocs/core/class/extrafields.class.php @@ -1408,7 +1408,7 @@ class ExtraFields // Fix better compatibility with some old extrafield syntax filter "(field=123)" $reg = array(); - if (preg_match('/^\(?([a-z0-9]+)([=<>]+)(\d+)\)?$/i', $InfoFieldList[4], $reg)) { + if (preg_match('/^\(?([a-z0-9_]+)([=<>]+)(\d+)\)?$/i', $InfoFieldList[4], $reg)) { $InfoFieldList[4] = '('.$reg[1].':'.$reg[2].':'.$reg[3].')'; } @@ -1638,7 +1638,7 @@ class ExtraFields // Fix better compatibility with some old extrafield syntax filter "(field=123)" $reg = array(); - if (preg_match('/^\(?([a-z0-9]+)([=<>]+)(\d+)\)?$/i', $InfoFieldList[4], $reg)) { + if (preg_match('/^\(?([a-z0-9_]+)([=<>]+)(\d+)\)?$/i', $InfoFieldList[4], $reg)) { $InfoFieldList[4] = '('.$reg[1].':'.$reg[2].':'.$reg[3].')'; } From 505c483484c85d4362e67bb02dd98983e920bae0 Mon Sep 17 00:00:00 2001 From: w3houde Date: Tue, 9 Jun 2026 19:09:12 +0200 Subject: [PATCH 17/18] Fix #38491 Name the field in the ticket too-long error message (#38507) * Fix #38491 Name the field in the ticket too-long error message When a ticket message exceeds 65000 characters, Ticket::verify() and Ticket::update() appended the bare string "ErrorFieldTooLong" to $this->errors. setEventMessages forwards that string and dol_htmloutput_mesg applies $langs->trans() to it, which yields the raw template "Field %s is too long." with the %s placeholder still visible because no argument was passed. Replace the bare key with a pre-translated message that names the field ("Initial message"), the same pattern already used in htdocs/barcode/printsheet.php for the same lang key. Two identical sites in the file are updated (one in verify, called by create, and one in update). The "errors" and "ticket" lang files are loaded defensively before translation because both methods can run before the page-level loadLangs. Reported by @ocemag, expected field name confirmed by @aspangaro. Signed-off-by: Dolicraft * Fix phan: declare $langs as global in ticket too-long branches phan level 10 raised PhanUndeclaredVariable and PhanNonClassMethodCall on the two branches that translate the new error message. The enclosing Ticket::verify() and Ticket::update() do not declare global $langs; add it where needed instead of opening the method scope further. Signed-off-by: Dolicraft --------- Signed-off-by: Dolicraft Co-authored-by: Dolicraft Co-authored-by: Alexandre SPANGARO --- htdocs/ticket/class/ticket.class.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/htdocs/ticket/class/ticket.class.php b/htdocs/ticket/class/ticket.class.php index fe9e0284c0e..fe06300fca0 100644 --- a/htdocs/ticket/class/ticket.class.php +++ b/htdocs/ticket/class/ticket.class.php @@ -421,7 +421,9 @@ class Ticket extends CommonObject if (isset($this->message)) { $this->message = trim($this->message); if (dol_strlen($this->message) > 65000) { - $this->errors[] = 'ErrorFieldTooLong'; + global $langs; + $langs->loadLangs(array('errors', 'ticket')); + $this->errors[] = $langs->trans('ErrorFieldTooLong', $langs->transnoentitiesnoconv('InitialMessage')); dol_syslog(get_class($this).'::create error -1 message too long', LOG_ERR); $result = -1; } @@ -1043,7 +1045,9 @@ class Ticket extends CommonObject if (isset($this->message)) { $this->message = trim($this->message); if (dol_strlen($this->message) > 65000) { - $this->errors[] = 'ErrorFieldTooLong'; + global $langs; + $langs->loadLangs(array('errors', 'ticket')); + $this->errors[] = $langs->trans('ErrorFieldTooLong', $langs->transnoentitiesnoconv('InitialMessage')); dol_syslog(get_class($this).'::update error -1 message too long', LOG_ERR); return -1; } From 4cf54760b2fe6a74e27eb0d9e468db98cf157d28 Mon Sep 17 00:00:00 2001 From: w3houde Date: Tue, 9 Jun 2026 19:51:36 +0200 Subject: [PATCH 18/18] Fix #25107 honour FOR_ALL_STATUS in mass send action (#38532) The per-card Send by email button on Propal, Commande and Facture already honours the hidden constants `PROPOSAL_SENDBYEMAIL_FOR_ALL_STATUS`, `COMMANDE_SENDBYEMAIL_FOR_ALL_STATUS` and `FACTURE_SENDBYEMAIL_FOR_ALL_STATUS` to allow sending drafts. The mass Send by email action in the corresponding lists silently dropped draft objects regardless of those constants, so an administrator who intentionally enabled the override saw inconsistent behaviour between the card and the list. Apply the same constant check on the three draft-skip guards in `htdocs/core/actions_massactions.inc.php` so the mass action behaves the same way as the single-object button. Default behaviour is unchanged: with none of the constants set, drafts are still skipped with the existing 'OnlyXxxNotDraftCanBeSentInMassAction' error message. Signed-off-by: Dolicraft Co-authored-by: Dolicraft --- htdocs/core/actions_massactions.inc.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/htdocs/core/actions_massactions.inc.php b/htdocs/core/actions_massactions.inc.php index 4f422b27c20..9cd155980d7 100644 --- a/htdocs/core/actions_massactions.inc.php +++ b/htdocs/core/actions_massactions.inc.php @@ -316,19 +316,23 @@ if (!$error && $massaction == 'confirm_presend') { foreach ($listofobjectref[$thirdpartyid] as $objectid => $objectobj) { //var_dump($thirdpartyid.' - '.$objectid.' - '.$objectobj->statut); - if ($objectclass == 'Propal' && $objectobj->status == Propal::STATUS_DRAFT) { + // Honour the same per-object hidden constants used by the "Send by email" + // button on the card pages (comm/propal/card.php, commande/card.php, + // compta/facture/card.php) so the behaviour is consistent between the + // single-object action and the mass action. + if ($objectclass == 'Propal' && $objectobj->status == Propal::STATUS_DRAFT && !getDolGlobalString('PROPOSAL_SENDBYEMAIL_FOR_ALL_STATUS')) { $langs->load("errors"); $nbignored++; $resaction .= '
'.$langs->trans('ErrorOnlyProposalNotDraftCanBeSentInMassAction', $objectobj->ref).'

'; continue; // Payment done or started or canceled } - if ($objectclass == 'Commande' && $objectobj->status == Commande::STATUS_DRAFT) { + if ($objectclass == 'Commande' && $objectobj->status == Commande::STATUS_DRAFT && !getDolGlobalString('COMMANDE_SENDBYEMAIL_FOR_ALL_STATUS')) { $langs->load("errors"); $nbignored++; $resaction .= '
'.$langs->trans('ErrorOnlyOrderNotDraftCanBeSentInMassAction', $objectobj->ref).'

'; continue; } - if ($objectclass == 'Facture' && $objectobj->status == Facture::STATUS_DRAFT) { + if ($objectclass == 'Facture' && $objectobj->status == Facture::STATUS_DRAFT && !getDolGlobalString('FACTURE_SENDBYEMAIL_FOR_ALL_STATUS')) { $langs->load("errors"); $nbignored++; $resaction .= '
'.$langs->trans('ErrorOnlyInvoiceValidatedCanBeSentInMassAction', $objectobj->ref).'

';