From b2eae1b7d736936256a3630f16ff27786f8e767c Mon Sep 17 00:00:00 2001 From: Jam Balaya Date: Thu, 16 Jul 2026 20:56:33 +0900 Subject: [PATCH] FIX #37826 country_code must be an ISO code, not a label (#39191) * FIX #37826 country_code must be an ISO code, not a label On a proposal/order, the 2nd+ line got VAT 0% with the error "no vat rates defined for country 'France'/'Suisse'". Root cause: the seller country_code held a translated country label instead of the ISO code, so the "c.code IN (...)" lookup in Form::load_cache_vatrates() matched nothing. - Societe::setMysoc(): the canonical MAIN_INFO_SOCIETE_COUNTRY is "id:code:label"; a legacy "id:label" value put the label into country_code. Only treat the full 3-token form as "id:code:label" and otherwise rebuild code/label from the authoritative country id. - Form::load_tva(): defensively recover the seller ISO code from its country id when country_code is not a known c_country code, so a bad value can never silently force the VAT rate to 0%. Signed-off-by: Jam Balaya * FIX #37826 avoid useless SQL per page: recover country ISO code from id only when code is not a 2-char ISO code --------- Signed-off-by: Jam Balaya --- htdocs/core/class/html.form.class.php | 20 ++++++++--- htdocs/societe/class/societe.class.php | 9 +++-- test/phpunit/SocieteTest.php | 46 ++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 8 deletions(-) diff --git a/htdocs/core/class/html.form.class.php b/htdocs/core/class/html.form.class.php index e2a267607c6..14e4e0231f4 100644 --- a/htdocs/core/class/html.form.class.php +++ b/htdocs/core/class/html.form.class.php @@ -8032,12 +8032,22 @@ class Form //exit; // Define list of countries to use to search VAT rates to show - // First we defined code_country to use to find list - if (is_object($societe_vendeuse)) { - $code_country = "'" . $societe_vendeuse->country_code . "'"; - } else { - $code_country = "'" . $mysoc->country_code . "'"; // Pour compatibilite ascendente + // First we defined code_country to use to find list. + // country_code must be a c_country ISO code (e.g. 'FR', 'CH'). In some setups it may hold a + // country label (e.g. 'Suisse') instead, which would make the "c.code IN (...)" lookup done by + // load_cache_vatrates() match nothing and wrongly force the VAT rate to 0%. A valid ISO code is + // always 2 chars, so when the value is not a well formed ISO code (empty or a label) and we have + // a valid country id, we recover the ISO code from the authoritative country id. This way we do + // not run any SQL on each page access when we already have a valid ISO code. + $sellercountrycode = is_object($societe_vendeuse) ? $societe_vendeuse->country_code : $mysoc->country_code; + $sellercountryid = is_object($societe_vendeuse) ? $societe_vendeuse->country_id : $mysoc->country_id; + if ((int) $sellercountryid > 0 && strlen((string) $sellercountrycode) != 2) { + $tmpcountrycode = dol_getIdFromCode($this->db, (string) $sellercountryid, 'c_country', 'rowid', 'code'); + if (!empty($tmpcountrycode) && !is_numeric($tmpcountrycode)) { + $sellercountrycode = $tmpcountrycode; + } } + $code_country = "'" . $sellercountrycode . "'"; // Pour compatibilite ascendente if ($societe_vendeuse == $mysoc && getDolGlobalString('SERVICE_ARE_ECOMMERCE_200238EC')) { // If option to have vat for end customer for services is on require_once DOL_DOCUMENT_ROOT . '/core/lib/company.lib.php'; diff --git a/htdocs/societe/class/societe.class.php b/htdocs/societe/class/societe.class.php index 41d5591d4b8..8ec9caa8fcb 100644 --- a/htdocs/societe/class/societe.class.php +++ b/htdocs/societe/class/societe.class.php @@ -4771,11 +4771,14 @@ class Societe extends CommonObject if (getDolGlobalString('MAIN_INFO_SOCIETE_COUNTRY')) { $tmp = explode(':', getDolGlobalString('MAIN_INFO_SOCIETE_COUNTRY')); $country_id = (is_numeric($tmp[0])) ? (int) $tmp[0] : 0; - if (!empty($tmp[1])) { // If $conf->global->MAIN_INFO_SOCIETE_COUNTRY is "id:code:label" + if (!empty($tmp[1]) && !empty($tmp[2])) { // MAIN_INFO_SOCIETE_COUNTRY is the canonical "id:code:label" $country_code = $tmp[1]; $country_label = $tmp[2]; - } else { - // For backward compatibility + } elseif ($country_id > 0) { + // For backward compatibility. The value is "id" only, or a legacy "id:label" where the + // second token is a country label (e.g. 'Suisse') and not an ISO code. In both cases we + // must not keep a label in country_code (it would break code-based lookups like VAT rates), + // so we rebuild code and label from the authoritative country id. dol_syslog("Your country setup use an old syntax. Reedit it using setup area.", LOG_WARNING); include_once DOL_DOCUMENT_ROOT.'/core/lib/company.lib.php'; $country_code = getCountry($country_id, '2', $this->db); // This need a SQL request, but it's the old feature that should not be used anymore diff --git a/test/phpunit/SocieteTest.php b/test/phpunit/SocieteTest.php index 4f8a4e0fa72..3a1aeaf3d5b 100644 --- a/test/phpunit/SocieteTest.php +++ b/test/phpunit/SocieteTest.php @@ -568,4 +568,50 @@ class SocieteTest extends CommonClassTest print __METHOD__." result=".$result."\n"; return $result; } + + /** + * Test that Societe::setMysoc() always resolves country_code to an ISO country code. + * + * Regression test for issue #37826: when MAIN_INFO_SOCIETE_COUNTRY uses the legacy + * "id:label" (2-token) syntax, the second token is a country label (e.g. 'France'), + * not an ISO code. setMysoc() must not leave that label in country_code (it would break + * code-based lookups such as VAT rates and force a 0% rate), but rebuild it from the id. + * + * @return void + */ + public function testSetMysocCountryCode() + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + require_once dirname(__FILE__).'/../../htdocs/core/lib/company.lib.php'; + + $frid = (int) getCountry('FR', '3', $db); + $this->assertGreaterThan(0, $frid, 'Country FR must exist in dictionary c_country'); + + $savcountryconst = getDolGlobalString('MAIN_INFO_SOCIETE_COUNTRY'); + + // Canonical 3-token syntax "id:code:label". + $conf->global->MAIN_INFO_SOCIETE_COUNTRY = $frid.':FR:France'; + $soc3 = new Societe($db); + $soc3->setMysoc($conf); + + // Legacy 2-token syntax "id:label" (the #37826 case), second token is a label not a code. + $conf->global->MAIN_INFO_SOCIETE_COUNTRY = $frid.':France'; + $soc2 = new Societe($db); + $soc2->setMysoc($conf); + + // Restore the constant before asserting so a failure does not leak state to other tests. + $conf->global->MAIN_INFO_SOCIETE_COUNTRY = $savcountryconst; + + $this->assertEquals('FR', $soc3->country_code, 'country_code must be the ISO code from a 3-token constant'); + $this->assertEquals($frid, $soc3->country_id); + $this->assertEquals('FR', $soc2->country_code, 'country_code must be an ISO code, not a label, from a legacy 2-token constant (#37826)'); + $this->assertEquals($frid, $soc2->country_id); + + print __METHOD__." ok\n"; + } }