From 5f2905d54a2e5c536f68b4245459391fafc16acb Mon Sep 17 00:00:00 2001 From: ThomasNgr-OpenDSI Date: Sat, 4 Jul 2026 13:27:58 +0200 Subject: [PATCH] FIX bon prelevement SEPA generation (#38998) * TEST : add test for bon_prelevement generation * TEST : add tests for bank transfer SEPA file generation * FIX WARNING : dol_syslog(array) outputs a warning. * FIX bon prelevement : non-default IBAN were not used in the generated XML. * BonPrelevement test : add validation against XSD * BonVirement test : add validation against XSD * FIX codesniffer * Enhance test error messages * Avoid PHP warning during tests * TEST FIX : one bank account was not created * TEST FIX : runnning all tests makes this one fail because is corrupted by a previous test. --------- Co-authored-by: Laurent Destailleur --- .../class/bonprelevement.class.php | 35 +- test/assets/xsd/pain.001.001.03.xsd | 921 ++++++++++++++++++ test/assets/xsd/pain.008.001.02.xsd | 879 +++++++++++++++++ test/phpunit/BonPrelevementTest.php | 654 ++++++++++++- test/phpunit/BonVirementTest.php | 695 +++++++++++++ 5 files changed, 3180 insertions(+), 4 deletions(-) create mode 100644 test/assets/xsd/pain.001.001.03.xsd create mode 100644 test/assets/xsd/pain.008.001.02.xsd create mode 100644 test/phpunit/BonVirementTest.php diff --git a/htdocs/compta/prelevement/class/bonprelevement.class.php b/htdocs/compta/prelevement/class/bonprelevement.class.php index 342aca24358..ae0f57bb388 100644 --- a/htdocs/compta/prelevement/class/bonprelevement.class.php +++ b/htdocs/compta/prelevement/class/bonprelevement.class.php @@ -1099,7 +1099,7 @@ class BonPrelevement extends CommonObject // phpcs:enable global $conf, $langs, $user; - dol_syslog(__METHOD__ . " mode=".$mode." format=".$format." type=".$type." dids=".$dids." fk_bank_account=".$fk_bank_account." sourcetype=".$sourcetype, LOG_DEBUG); + dol_syslog(__METHOD__ . " mode=".$mode." format=".$format." type=".$type." dids=".(is_array($dids) ? implode(',', $dids) : $dids)." fk_bank_account=".$fk_bank_account." sourcetype=".$sourcetype, LOG_DEBUG); require_once DOL_DOCUMENT_ROOT . "/compta/facture/class/facture.class.php"; require_once DOL_DOCUMENT_ROOT . "/societe/class/societe.class.php"; @@ -1421,7 +1421,7 @@ class BonPrelevement extends CommonObject $row = $this->db->fetch_row($resql); // Build the new ref - $ref = $prefixt . $ref . sprintf("%03d", (intval($row[0]) + 1)); + $ref = $prefixt . $ref . sprintf("%03d", (intval($row[0] ?? 0) + 1)); // $conf->abc->dir_output may be: // /home/ldestailleur/git/dolibarr_15.0/documents/abc/ @@ -1986,10 +1986,39 @@ class BonPrelevement extends CommonObject } $cachearraytotestduplicate[$obj->idfac] = $obj->rowid; + // Get the default value $daterum = (!empty($obj->date_rum)) ? $this->db->jdate($obj->date_rum) : $this->db->jdate($obj->datec); $iban = dolDecrypt($obj->iban); + $bic = $obj->bic; + $drum = $obj->drum; + $rum = $obj->rum; - $fileDebiteurSection .= $this->EnregDestinataireSEPA($obj->code, $obj->nom, $obj->address, $obj->zip, $obj->town, $obj->country_code, '', '', '', $obj->somme, $obj->reffac, $obj->idfac, $iban, $obj->bic, $daterum, (string) $obj->drum, $obj->rum, $type); + // But if a force bank account is defined, we use it instead + if (!empty($obj->fk_prelevement_demande)) { + $companybankaccountid = 0; + + $sqltmp = "SELECT fk_societe_rib FROM ".MAIN_DB_PREFIX."prelevement_demande"; + $sqltmp .= " WHERE rowid = ".((int) $obj->fk_prelevement_demande); + + $resqltmp = $this->db->query($sqltmp); + + $objtmp = $this->db->fetch_object($resqltmp); + if ($objtmp) { + $companybankaccountid = (int) $objtmp->fk_societe_rib; + } + + $bankaccount = new CompanyBankAccount($this->db); + $bankaccount->fetch($companybankaccountid); + if ($bankaccount->id > 0) { + $daterum = $bankaccount->date_rum; + $iban = $bankaccount->iban; + $bic = $bankaccount->bic; + $drum = $bankaccount->id; + $rum = $bankaccount->rum; + } + } + + $fileDebiteurSection .= $this->EnregDestinataireSEPA($obj->code, $obj->nom, $obj->address, $obj->zip, $obj->town, $obj->country_code, '', '', '', $obj->somme, $obj->reffac, $obj->idfac, $iban, $bic, $daterum, (string) $drum, $rum, $type); $this->total += $obj->somme; $i++; diff --git a/test/assets/xsd/pain.001.001.03.xsd b/test/assets/xsd/pain.001.001.03.xsd new file mode 100644 index 00000000000..8649779919c --- /dev/null +++ b/test/assets/xsd/pain.001.001.03.xsd @@ -0,0 +1,921 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/assets/xsd/pain.008.001.02.xsd b/test/assets/xsd/pain.008.001.02.xsd new file mode 100644 index 00000000000..63359725617 --- /dev/null +++ b/test/assets/xsd/pain.008.001.02.xsd @@ -0,0 +1,879 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/phpunit/BonPrelevementTest.php b/test/phpunit/BonPrelevementTest.php index 42cf9daabab..2e77da29adc 100644 --- a/test/phpunit/BonPrelevementTest.php +++ b/test/phpunit/BonPrelevementTest.php @@ -2,6 +2,7 @@ /* Copyright (C) 2010 Laurent Destailleur * Copyright (C) 2023 Alexandre Janniaux * Copyright (C) 2024 Frédéric France + * Copyright (C) 2025 Thomas Negre * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -29,7 +30,12 @@ global $conf,$user,$langs,$db; //define('TEST_DB_FORCE_TYPE','mysql'); // This is to force using mysql driver //require_once 'PHPUnit/Autoload.php'; require_once dirname(__FILE__).'/../../htdocs/master.inc.php'; +require_once dirname(__FILE__).'/../../htdocs/core/lib/admin.lib.php'; require_once dirname(__FILE__).'/../../htdocs/compta/prelevement/class/bonprelevement.class.php'; +require_once dirname(__FILE__).'/../../htdocs/compta/facture/class/facture.class.php'; +require_once dirname(__FILE__).'/../../htdocs/societe/class/societe.class.php'; +require_once dirname(__FILE__).'/../../htdocs/societe/class/companybankaccount.class.php'; +require_once dirname(__FILE__).'/../../htdocs/compta/bank/class/account.class.php'; require_once dirname(__FILE__).'/CommonClassTest.class.php'; if (empty($user->id)) { @@ -51,8 +57,188 @@ $langs->load("main"); */ class BonPrelevementTest extends CommonClassTest { + // --------------------------------------------------------------------------- + // Test IBANs valid (mod97 check compliant). + // Used to identify which bank account (RIB) was selected in the SEPA file. + // --------------------------------------------------------------------------- + const IBAN_A_DEFAULT = 'FR7630001007941234567890185'; // default bank account of COMPANY_A + const IBAN_A_SPECIFIC = 'FR7630001007941234567890282'; // specific bank account of COMPANY_A + const IBAN_B_DEFAULT = 'FR7630001007941234567890379'; // default bank account of COMPANY_B + const IBAN_B_SPECIFIC = 'FR7630001007941234567890476'; // specific bank account of COMPANY_B + const BIC = 'BNPAFRPPXXX'; + const XSD_PAIN_008 = __DIR__.'/../assets/xsd/pain.008.001.02.xsd'; + + // --------------------------------------------------------------------------- + // Shared fixtures created once in setUpBeforeClass(), + // inside the parent transaction (rolled back by tearDownAfterClass()). + // --------------------------------------------------------------------------- + /** @var int Row ID of COMPANY_A */ + protected static $socidA = 0; + /** @var int Row ID of COMPANY_B */ + protected static $socidB = 0; + /** @var int Row ID of the default bank account of COMPANY_A */ + protected static $ribADefaultId = 0; + /** @var int Row ID of the specific bank account of COMPANY_A */ + protected static $ribASpecificId = 0; + /** @var int Row ID of the default bank account of COMPANY_B */ + protected static $ribBDefaultId = 0; + /** @var int Row ID of the specific bank account of COMPANY_B */ + protected static $ribBSpecificId = 0; + /** @var int Row ID of the issuer bank account (llx_bank_account) */ + protected static $fkBankAccount = 0; + /** @var string Error message collected in setUpBeforeClass() if a fixture failed to be created */ + protected static $setUpError = ''; + /** @var ?Societe Global $mysoc as it was before this test class forced it into a SEPA country */ + protected static $savmysoc; + /** - * testBonPrevelementCreate + * setUpBeforeClass + * + * Creates shared fixtures for all tests in this class: + * - COMPANY_A and COMPANY_B (French customer third parties) + * - Two bank accounts per company (one default, one specific) + * - One issuer bank account used for BonPrelevement generation + * + * Everything is created inside the parent transaction; + * the final rollback in tearDownAfterClass() removes all this data. + * + * @return void + */ + public static function setUpBeforeClass(): void + { + global $db, $user, $mysoc; + parent::setUpBeforeClass(); // Opens the parent transaction ($db->begin()) + + // BonPrelevement::generate() requires $mysoc to be in a SEPA country (global $mysoc, + // not restored between test classes by CommonClassTest). Some other test class run + // earlier in the same PHPUnit process (e.g. PricesTest) may have left it on a + // non-SEPA country, so force it here and restore it in tearDownAfterClass(). + self::$savmysoc = clone $mysoc; + $mysoc->country_code = 'FR'; + $mysoc->country_id = 1; + + // Enable the prelevement module if not already active + if (!isModEnabled('prelevement')) { + activateModule('modPrelevement', 1, 1); + } + + // ------------------------------------------------------------------ + // COMPANY_A: French customer third party + // ------------------------------------------------------------------ + $socA = new Societe($db); + $socA->name = 'BonPrelevTest CompanyA'; + $socA->client = 1; + $socA->country_id = 1; // France (rowid=1 in c_country) + $socA->code_client = -1; // -1 = auto-generate customer code + self::$socidA = (int) $socA->create($user); + if (self::$socidA <= 0) { + self::$setUpError .= 'Societe::create() (COMPANY_A) failed: '.$socA->errorsToString().' '; + } + + // RIB_A_DEFAULT: first bank account for COMPANY_A, will be the default (default_rib=1) + $ribADef = new CompanyBankAccount($db); + $ribADef->socid = self::$socidA; + $ribADef->type = 'ban'; + $ribADef->iban = self::IBAN_A_DEFAULT; + $ribADef->bic = self::BIC; + $ribADef->rum = 'RUM-A-DEF-01'; + $ribADef->date_rum = dol_now(); + $ribADef->frstrecur = 'RCUR'; + $ribADef->default_rib = 1; + self::$ribADefaultId = (int) $ribADef->create($user); // minimal INSERT + $ribADef->update($user); // persists iban, bic, rum, date_rum, default_rib + + // RIB_A_SPECIFIC: second bank account for COMPANY_A, non-default + $ribASpec = new CompanyBankAccount($db); + $ribASpec->socid = self::$socidA; + $ribASpec->type = 'ban'; + $ribASpec->iban = self::IBAN_A_SPECIFIC; + $ribASpec->bic = self::BIC; + $ribASpec->rum = 'RUM-A-SPEC-01'; + $ribASpec->date_rum = dol_now(); + $ribASpec->frstrecur = 'RCUR'; + $ribASpec->default_rib = 0; + self::$ribASpecificId = (int) $ribASpec->create($user); + $ribASpec->update($user); + + // ------------------------------------------------------------------ + // COMPANY_B: same structure, distinct IBANs + // ------------------------------------------------------------------ + $socB = new Societe($db); + $socB->name = 'BonPrelevTest CompanyB'; + $socB->client = 1; + $socB->country_id = 1; + $socB->code_client = -1; // -1 = auto-generate customer code + self::$socidB = (int) $socB->create($user); + if (self::$socidB <= 0) { + self::$setUpError .= 'Societe::create() (COMPANY_B) failed: '.$socB->errorsToString().' '; + } + + $ribBDef = new CompanyBankAccount($db); + $ribBDef->socid = self::$socidB; + $ribBDef->type = 'ban'; + $ribBDef->iban = self::IBAN_B_DEFAULT; + $ribBDef->bic = self::BIC; + $ribBDef->rum = 'RUM-B-DEF-01'; + $ribBDef->date_rum = dol_now(); + $ribBDef->frstrecur = 'RCUR'; + $ribBDef->default_rib = 1; + self::$ribBDefaultId = (int) $ribBDef->create($user); + $ribBDef->update($user); + + $ribBSpec = new CompanyBankAccount($db); + $ribBSpec->socid = self::$socidB; + $ribBSpec->type = 'ban'; + $ribBSpec->iban = self::IBAN_B_SPECIFIC; + $ribBSpec->bic = self::BIC; + $ribBSpec->rum = 'RUM-B-SPEC-01'; + $ribBSpec->date_rum = dol_now(); + $ribBSpec->frstrecur = 'RCUR'; + $ribBSpec->default_rib = 0; + self::$ribBSpecificId = (int) $ribBSpec->create($user); + $ribBSpec->update($user); + + // ------------------------------------------------------------------ + // Issuer bank account (llx_bank_account) passed as fk_bank_account + // ------------------------------------------------------------------ + $account = new Account($db); + $account->ref = 'BONPRELTEST'; // max 12 chars (llx_bank_account.ref is varchar(12)) + $account->label = 'BonPrelevTest Issuer'; + $account->country_id = 1; // France + $account->date_solde = dol_now(); + $account->iban = 'FR7630001007941234567890573'; // valid IBAN, not used in assertions + $account->bic = self::BIC; + $account->ics = 'FR77ZZZ123456789'; // SEPA Creditor Identifier (ICS) + $account->owner_name = 'TestCorp'; + $account->currency_code = 'EUR'; + self::$fkBankAccount = (int) $account->create($user); + if (self::$fkBankAccount <= 0) { + self::$setUpError .= 'Account::create() (issuer) failed: '.$account->errorsToString().' '; + } + } + + /** + * tearDownAfterClass + * + * Restores the global $mysoc forced in setUpBeforeClass() before rolling back + * the parent transaction, so this class does not leak state to test classes + * that run after it in the same PHPUnit process. + * + * @return void + */ + public static function tearDownAfterClass(): void + { + global $mysoc; + $mysoc = self::$savmysoc; + + parent::tearDownAfterClass(); // Rolls back the parent transaction ($db->rollback()) + } + + /** + * testBonPrelevementCreate + * + * Non-regression test: verifies that create() in simulation mode + * with no pending payment requests returns 0 (no requests processed, no error). * * @return int */ @@ -85,6 +271,472 @@ class BonPrelevementTest extends CommonClassTest // Test SEPA file + return $result; + } + + /** + * testTwoCompaniesSimpleRib + * + * Verifies that when two different companies each have one invoice with a + * specific bank account forced in the direct debit request, the generated + * SEPA file contains the forced IBAN for each transaction (not the + * company's default bank account.) + * + * Scenario: + * INV_A (100) -> request with RIB_A_SPECIFIC -> expects IBAN_A_SPECIFIC + * INV_B (300) -> request with RIB_B_SPECIFIC -> expects IBAN_B_SPECIFIC + * + * @return void + */ + public function testTwoCompaniesSimpleRib() + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + $this->assertGreaterThan(0, self::$socidA, 'setUpBeforeClass() did not create fixtures (socidA): '.self::$setUpError); + $this->assertGreaterThan(0, self::$fkBankAccount, 'setUpBeforeClass() did not create issuer account: '.self::$setUpError); + + // Create one invoice for COMPANY_A (100) and one for COMPANY_B (300) + $facA = $this->createValidatedInvoice(self::$socidA, 100.0); + $facB = $this->createValidatedInvoice(self::$socidB, 300.0); + + // Link each invoice to its specific bank account (not the default) + $demAId = $this->createPaymentRequest($facA, 100.0, self::$ribASpecificId); + $demBId = $this->createPaymentRequest($facB, 300.0, self::$ribBSpecificId); + + // Generate the direct debit order with both requests + $bon = new BonPrelevement($db); + $result = $bon->create('', '', 'real', 'RCUR', 0, 0, 'direct-debit', + array($demAId, $demBId), self::$fkBankAccount); + $this->assertGreaterThanOrEqual(0, $result, 'BonPrelevement::create() failed: '.$bon->errorsToString()); + + // Parse the SEPA file and extract IBAN -> amount pairs + $ibanAmounts = $this->parseSepaIbanAmounts($bon->filename); + + // Invoice A must use COMPANY_A's specific IBAN + $this->assertEquals(100.0, $ibanAmounts[self::IBAN_A_SPECIFIC], + 'COMPANY_A invoice must use IBAN_A_SPECIFIC (forced bank account)'); + // Invoice B must use COMPANY_B's specific IBAN + $this->assertEquals(300.0, $ibanAmounts[self::IBAN_B_SPECIFIC], + 'COMPANY_B invoice must use IBAN_B_SPECIFIC (forced bank account)'); + // Total must be 100 + 300 + $this->assertEquals(400.0, $bon->total, + 'Order total must equal the sum of both invoices'); + + $this->assertSepaXmlValid($bon->filename); + } + + /** + * testTwoCompaniesDefaultRib + * + * Verifies that when two different companies each have one invoice with no + * forced bank account in the direct debit request (fk_societe_rib IS NULL), + * the generated SEPA file contains each company's default IBAN (default_rib=1). + * + * Scenario: + * INV_A (100) -> request with no forced RIB -> expects IBAN_A_DEFAULT + * INV_B (300) -> request with no forced RIB -> expects IBAN_B_DEFAULT + * + * @return void + */ + public function testTwoCompaniesDefaultRib() + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + $this->assertGreaterThan(0, self::$socidA, 'setUpBeforeClass() did not create fixtures (socidA): '.self::$setUpError); + $this->assertGreaterThan(0, self::$fkBankAccount, 'setUpBeforeClass() did not create issuer account: '.self::$setUpError); + + // Create one invoice for COMPANY_A (100) and one for COMPANY_B (300) + $facA = $this->createValidatedInvoice(self::$socidA, 100.0); + $facB = $this->createValidatedInvoice(self::$socidB, 300.0); + + // No forced bank account: fk_societe_rib will be NULL -> default RIB used + $demAId = $this->createPaymentRequest($facA, 100.0); + $demBId = $this->createPaymentRequest($facB, 300.0); + + // Generate the direct debit order with both requests + $bon = new BonPrelevement($db); + $result = $bon->create('', '', 'real', 'RCUR', 0, 0, 'direct-debit', + array($demAId, $demBId), self::$fkBankAccount); + $this->assertGreaterThanOrEqual(0, $result, 'BonPrelevement::create() failed: '.$bon->errorsToString()); + + // Parse the SEPA file and extract IBAN -> amount pairs + $ibanAmounts = $this->parseSepaIbanAmounts($bon->filename); + + // Invoice A must use COMPANY_A's default IBAN + $this->assertEquals(100.0, $ibanAmounts[self::IBAN_A_DEFAULT], + 'COMPANY_A invoice must use IBAN_A_DEFAULT (default_rib=1, no forced account)'); + // Invoice B must use COMPANY_B's default IBAN + $this->assertEquals(300.0, $ibanAmounts[self::IBAN_B_DEFAULT], + 'COMPANY_B invoice must use IBAN_B_DEFAULT (default_rib=1, no forced account)'); + // No specific IBAN must appear in this order + $this->assertArrayNotHasKey(self::IBAN_A_SPECIFIC, $ibanAmounts, + 'IBAN_A_SPECIFIC must not appear when no RIB is forced for COMPANY_A'); + $this->assertArrayNotHasKey(self::IBAN_B_SPECIFIC, $ibanAmounts, + 'IBAN_B_SPECIFIC must not appear when no RIB is forced for COMPANY_B'); + // Total must be 100 + 300 + $this->assertEquals(400.0, $bon->total, + 'Order total must equal the sum of both invoices'); + + $this->assertSepaXmlValid($bon->filename); + } + + /** + * testTwoCompaniesDefaultRibFilteredToFirst + * + * Verifies that when $dids is limited to only the first payment request, + * only COMPANY_A's invoice appears in the SEPA file (even though a second + * request exists in the database for COMPANY_B.) + * + * Scenario: + * INV_A (100) -> request with no forced RIB -> expects IBAN_A_DEFAULT (in $dids) + * INV_B (300) -> request with no forced RIB -> must NOT appear in file (not in $dids) + * + * @return void + */ + public function testTwoCompaniesDefaultRibFilteredToFirst() + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + $this->assertGreaterThan(0, self::$socidA, 'setUpBeforeClass() did not create fixtures (socidA): '.self::$setUpError); + $this->assertGreaterThan(0, self::$fkBankAccount, 'setUpBeforeClass() did not create issuer account: '.self::$setUpError); + + // Create one invoice for each company + $facA = $this->createValidatedInvoice(self::$socidA, 100.0); + $facB = $this->createValidatedInvoice(self::$socidB, 300.0); + + // Create both payment requests (no forced bank account for either) + $demAId = $this->createPaymentRequest($facA, 100.0); + $demBId = $this->createPaymentRequest($facB, 300.0); // exists in DB but excluded from $dids + + // Generate the order with ONLY the first request ($demAId) + $bon = new BonPrelevement($db); + $result = $bon->create('', '', 'real', 'RCUR', 0, 0, 'direct-debit', + array($demAId), self::$fkBankAccount); + $this->assertGreaterThanOrEqual(0, $result, 'BonPrelevement::create() failed: '.$bon->errorsToString()); + + $ibanAmounts = $this->parseSepaIbanAmounts($bon->filename); + + // COMPANY_A must appear with its default IBAN + $this->assertEquals(100.0, $ibanAmounts[self::IBAN_A_DEFAULT], + 'COMPANY_A invoice must use IBAN_A_DEFAULT when no RIB is forced'); + // COMPANY_B must NOT appear since its request was not in $dids + $this->assertArrayNotHasKey(self::IBAN_B_DEFAULT, $ibanAmounts, + 'COMPANY_B must not appear: its request was excluded from $dids'); + // Total must be 100 only + $this->assertEquals(100.0, $bon->total, + 'Order total must equal only the included request'); + + $this->assertSepaXmlValid($bon->filename); + } + + /** + * testOneCompanyTwoRibs + * + * Verifies that for the same company with two invoices, the bank account + * selection is correct based on whether a specific account is forced: + * - A request with a forced bank account uses that specific IBAN. + * - A request without a forced account (ribId=0) falls back to the + * company's default bank account (default_rib=1). + * No IBAN from COMPANY_B should appear in the file. + * + * Scenario: + * INV_A1 (100) -> request with RIB_A_SPECIFIC -> expects IBAN_A_SPECIFIC + * INV_A2 (200) -> request without forced RIB -> expects IBAN_A_DEFAULT + * + * @return void + */ + public function testOneCompanyTwoRibs() + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + $this->assertGreaterThan(0, self::$socidA, 'setUpBeforeClass() did not create fixtures (socidA): '.self::$setUpError); + $this->assertGreaterThan(0, self::$fkBankAccount, 'setUpBeforeClass() did not create issuer account: '.self::$setUpError); + + // Two invoices for COMPANY_A with different amounts + $facA1 = $this->createValidatedInvoice(self::$socidA, 100.0); + $facA2 = $this->createValidatedInvoice(self::$socidA, 200.0); + + // First request: specific bank account forced (not the default) + $demA1Id = $this->createPaymentRequest($facA1, 100.0, self::$ribASpecificId); + // Second request: no forced bank account (ribId=0 -> default will be used) + $demA2Id = $this->createPaymentRequest($facA2, 200.0); + + // Generate the order with both COMPANY_A requests only + $bon = new BonPrelevement($db); + $result = $bon->create('', '', 'real', 'RCUR', 0, 0, 'direct-debit', + array($demA1Id, $demA2Id), self::$fkBankAccount); + $this->assertGreaterThanOrEqual(0, $result, 'BonPrelevement::create() failed: '.$bon->errorsToString()); + + $ibanAmounts = $this->parseSepaIbanAmounts($bon->filename); + + // INV_A1: forced bank account -> must use IBAN_A_SPECIFIC + $this->assertEquals(100.0, $ibanAmounts[self::IBAN_A_SPECIFIC], + 'INV_A1 with forced bank account must use IBAN_A_SPECIFIC'); + // INV_A2: no forced account -> must fall back to default IBAN_A_DEFAULT + $this->assertEquals(200.0, $ibanAmounts[self::IBAN_A_DEFAULT], + 'INV_A2 without forced account must use IBAN_A_DEFAULT (default_rib=1)'); + // No IBAN from COMPANY_B must appear in this order + $this->assertArrayNotHasKey(self::IBAN_B_DEFAULT, $ibanAmounts, + 'COMPANY_B bank account must not appear in a COMPANY_A-only order'); + $this->assertEquals(300.0, $bon->total); + + $this->assertSepaXmlValid($bon->filename); + } + + /** + * testOneCompanyTwoRibsSpecificOnly + * + * Verifies that when $dids contains only the request with the specific bank + * account, only that invoice appears in the SEPA file (even though a second + * request using the default RIB exists in the database.) + * + * Scenario: + * INV_A1 (100) -> request with RIB_A_SPECIFIC -> expects IBAN_A_SPECIFIC (in $dids) + * INV_A2 (200) -> request without forced RIB -> must NOT appear in file (not in $dids) + * + * @return void + */ + public function testOneCompanyTwoRibsSpecificOnly() + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + $this->assertGreaterThan(0, self::$socidA, 'setUpBeforeClass() did not create fixtures (socidA): '.self::$setUpError); + $this->assertGreaterThan(0, self::$fkBankAccount, 'setUpBeforeClass() did not create issuer account: '.self::$setUpError); + + // Two invoices for COMPANY_A with different amounts + $facA1 = $this->createValidatedInvoice(self::$socidA, 100.0); + $facA2 = $this->createValidatedInvoice(self::$socidA, 200.0); + + // First request: specific bank account forced + $demA1Id = $this->createPaymentRequest($facA1, 100.0, self::$ribASpecificId); + // Second request: no forced bank account (exists in DB but excluded from $dids) + $demA2Id = $this->createPaymentRequest($facA2, 200.0); + + // Generate the order with ONLY the specific-RIB request ($demA1Id) + $bon = new BonPrelevement($db); + $result = $bon->create('', '', 'real', 'RCUR', 0, 0, 'direct-debit', + array($demA1Id), self::$fkBankAccount); + $this->assertGreaterThanOrEqual(0, $result, 'BonPrelevement::create() failed: '.$bon->errorsToString()); + + $ibanAmounts = $this->parseSepaIbanAmounts($bon->filename); + + // INV_A1 must appear with the forced specific IBAN + $this->assertEquals(100.0, $ibanAmounts[self::IBAN_A_SPECIFIC], + 'INV_A1 with forced bank account must use IBAN_A_SPECIFIC'); + // INV_A2 must NOT appear since its request was not in $dids + $this->assertArrayNotHasKey(self::IBAN_A_DEFAULT, $ibanAmounts, + 'INV_A2 must not appear: its request was excluded from $dids'); + // Total must be 100 only + $this->assertEquals(100.0, $bon->total, + 'Order total must equal only the included request'); + + $this->assertSepaXmlValid($bon->filename); + } + + /** + * testMixedOrderFourInvoices + * + * Verifies that bank account assignment remains correct when direct debit + * requests from two companies are interleaved (A1, B1, A2, B2). + * The test guards against cross-contamination between companies or between + * consecutive requests. + * + * Scenario (requests created in interleaved order): + * INV_A1 (100) -> forced: RIB_A_SPECIFIC -> expects IBAN_A_SPECIFIC + * INV_B1 (300) -> no forced RIB -> default -> expects IBAN_B_DEFAULT + * INV_A2 (200) -> no forced RIB -> default -> expects IBAN_A_DEFAULT + * INV_B2 (400) -> forced: RIB_B_SPECIFIC -> expects IBAN_B_SPECIFIC + * + * @return void + */ + public function testMixedOrderFourInvoices() + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + $this->assertGreaterThan(0, self::$socidA, 'setUpBeforeClass() did not create fixtures (socidA): '.self::$setUpError); + $this->assertGreaterThan(0, self::$fkBankAccount, 'setUpBeforeClass() did not create issuer account: '.self::$setUpError); + + // Interleaved invoices: A1, B1, A2, B2 + $facA1 = $this->createValidatedInvoice(self::$socidA, 100.0); + $facB1 = $this->createValidatedInvoice(self::$socidB, 300.0); + $facA2 = $this->createValidatedInvoice(self::$socidA, 200.0); + $facB2 = $this->createValidatedInvoice(self::$socidB, 400.0); + + // Interleaved requests: forced, no RIB, no RIB, forced + $demA1Id = $this->createPaymentRequest($facA1, 100.0, self::$ribASpecificId); // forced + $demB1Id = $this->createPaymentRequest($facB1, 300.0); // -> RIB_B_DEFAULT + $demA2Id = $this->createPaymentRequest($facA2, 200.0); // -> RIB_A_DEFAULT + $demB2Id = $this->createPaymentRequest($facB2, 400.0, self::$ribBSpecificId); // forced + + // Generate the order with all four requests in interleaved order + $bon = new BonPrelevement($db); + $result = $bon->create('', '', 'real', 'RCUR', 0, 0, 'direct-debit', + array($demA1Id, $demB1Id, $demA2Id, $demB2Id), self::$fkBankAccount); + $this->assertGreaterThanOrEqual(0, $result, 'BonPrelevement::create() failed: '.$bon->errorsToString()); + + $ibanAmounts = $this->parseSepaIbanAmounts($bon->filename); + + // Verify each invoice is matched to the correct IBAN with no cross-contamination + $this->assertEquals(100.0, $ibanAmounts[self::IBAN_A_SPECIFIC], + 'INV_A1 with forced bank account must use IBAN_A_SPECIFIC'); + $this->assertEquals(300.0, $ibanAmounts[self::IBAN_B_DEFAULT], + 'INV_B1 without forced account must use IBAN_B_DEFAULT (default_rib=1 of COMPANY_B)'); + $this->assertEquals(200.0, $ibanAmounts[self::IBAN_A_DEFAULT], + 'INV_A2 without forced account must use IBAN_A_DEFAULT (default_rib=1 of COMPANY_A)'); + $this->assertEquals(400.0, $ibanAmounts[self::IBAN_B_SPECIFIC], + 'INV_B2 with forced bank account must use IBAN_B_SPECIFIC'); + $this->assertEquals(1000.0, $bon->total, + 'Order total must equal the sum of all four invoices'); + + $this->assertSepaXmlValid($bon->filename); + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** + * Creates and validates a customer invoice for a given company and amount. + * The invoice contains a single service line at 0% VAT. + * + * @param int $socid Row ID of the third party + * @param float $amount Pre-tax amount of the line (= total incl. tax with 0% VAT) + * @return Facture + */ + private function createValidatedInvoice(int $socid, float $amount): Facture + { + global $user, $db; + + $fac = new Facture($db); + $fac->socid = $socid; + $fac->date = dol_now(); + $fac->cond_reglement_code = 'RECEP'; + $fac->mode_reglement_code = 'PRE'; // direct debit payment mode + + $result = $fac->create($user); + $this->assertGreaterThan(0, $result, 'Facture::create() failed: '.$fac->errorsToString()); + + // Service line at 0% VAT so total_ttc = $amount + $fac->addline('Service test', $amount, 1, 0); + + $result = $fac->validate($user); + $this->assertGreaterThanOrEqual(0, $result, 'Facture::validate() failed: '.$fac->errorsToString()); + + return $fac; + } + + /** + * Creates a direct debit payment request (demande_prelevement) for a validated invoice. + * If $ribId is provided, the request stores that specific bank account in fk_societe_rib. + * Otherwise (ribId=0), the company's default bank account will be used. + * + * @param Facture $fac Validated invoice + * @param float $amount Requested amount + * @param int $ribId Row ID of the bank account to force (0 = use default) + * @return int Row ID of the created entry in llx_prelevement_demande + */ + private function createPaymentRequest(Facture $fac, float $amount, int $ribId = 0): int + { + global $user, $db; + + // Insert a row in llx_prelevement_demande with fk_societe_rib = $ribId + $result = $fac->demande_prelevement($user, $amount, 'direct-debit', 'facture', 0, $ribId); + $this->assertEquals(1, $result, 'demande_prelevement() failed for invoice #'.$fac->id.': '.$fac->errorsToString()); + + // Retrieve the row ID of the freshly inserted request + $sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."prelevement_demande WHERE fk_facture = ".((int) $fac->id)." AND traite = 0 ORDER BY rowid DESC LIMIT 1"; + $resql = $db->query($sql); + $obj = $db->fetch_object($resql); + $this->assertNotNull($obj, 'Payment request not found in DB for invoice #'.$fac->id); + + return (int) $obj->rowid; + } + + /** + * Asserts that a generated SEPA XML file validates against the pain.008.001.02 XSD schema. + * + * @param string $filename Path to the SEPA XML file + * @return void + */ + private function assertSepaXmlValid(string $filename): void + { + $this->assertFileExists($filename, 'SEPA XML file does not exist: '.$filename); + $this->assertFileExists(self::XSD_PAIN_008, 'XSD schema file not found: '.self::XSD_PAIN_008); + + $dom = new DOMDocument(); + $loaded = $dom->load($filename); + $this->assertTrue($loaded, 'DOMDocument failed to load SEPA XML: '.$filename); + + libxml_use_internal_errors(true); + $valid = $dom->schemaValidate(self::XSD_PAIN_008); + $errors = libxml_get_errors(); + libxml_clear_errors(); + libxml_use_internal_errors(false); + + $messages = array(); + foreach ($errors as $error) { + $messages[] = trim($error->message).' (line '.$error->line.')'; + } + $this->assertTrue($valid, 'SEPA XML does not validate against pain.008.001.02 XSD: '.implode('; ', $messages)); + } + + /** + * Parses a generated SEPA pain.008 XML file and returns an associative + * array of IBAN => amount for each DrctDbtTxInf transaction element. + * + * @param string $filename Path to the SEPA XML file + * @return array Array keyed by IBAN with transaction amount as value + */ + private function parseSepaIbanAmounts(string $filename): array + { + $this->assertFileExists($filename, 'SEPA XML file was not created: '.$filename); + + $xml = simplexml_load_file($filename); + $this->assertNotFalse($xml, 'Failed to parse SEPA XML file: '.$filename); + + // Register the SEPA direct-debit namespace (pain.008.001.02) + $ns = 'urn:iso:std:iso:20022:tech:xsd:pain.008.001.02'; + $xml->registerXPathNamespace('ns', $ns); + + $result = array(); + foreach ($xml->xpath('//ns:DrctDbtTxInf') as $txInf) { + $txInf->registerXPathNamespace('ns', $ns); + // Debtor IBAN (the third party being debited) + $ibanNodes = $txInf->xpath('ns:DbtrAcct/ns:Id/ns:IBAN'); + // Transaction amount + $amountNodes = $txInf->xpath('ns:InstdAmt'); + + if (!empty($ibanNodes) && !empty($amountNodes)) { + $iban = (string) $ibanNodes[0]; + $amount = (float) $amountNodes[0]; + $result[$iban] = $amount; + } + } + return $result; } } diff --git a/test/phpunit/BonVirementTest.php b/test/phpunit/BonVirementTest.php new file mode 100644 index 00000000000..e0cc051da06 --- /dev/null +++ b/test/phpunit/BonVirementTest.php @@ -0,0 +1,695 @@ + + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * or see https://www.gnu.org/ + */ + +/** + * \file test/phpunit/BonVirementTest.php + * \ingroup test + * \brief PHPUnit test + * \remarks To run this script as CLI: phpunit filename.php + */ + +global $conf,$user,$langs,$db; +require_once dirname(__FILE__).'/../../htdocs/master.inc.php'; +require_once dirname(__FILE__).'/../../htdocs/core/lib/admin.lib.php'; +require_once dirname(__FILE__).'/../../htdocs/compta/prelevement/class/bonprelevement.class.php'; +require_once dirname(__FILE__).'/../../htdocs/fourn/class/fournisseur.facture.class.php'; +require_once dirname(__FILE__).'/../../htdocs/societe/class/societe.class.php'; +require_once dirname(__FILE__).'/../../htdocs/societe/class/companybankaccount.class.php'; +require_once dirname(__FILE__).'/../../htdocs/compta/bank/class/account.class.php'; +require_once dirname(__FILE__).'/CommonClassTest.class.php'; + +if (empty($user->id)) { + print "Load permissions for admin user nb 1\n"; + $user->fetch(1); + $user->loadRights(); +} +$conf->global->MAIN_DISABLE_ALL_MAILS = 1; + +$langs->load("main"); + + +/** + * Class for PHPUnit tests + * + * @backupGlobals disabled + * @backupStaticAttributes enabled + * @remarks backupGlobals must be disabled to have db,conf,user and lang not erased. + */ +class BonVirementTest extends CommonClassTest +{ + // --------------------------------------------------------------------------- + // Test IBANs valid (mod97 check compliant). + // Distinct from BonPrelevementTest IBANs to avoid collisions when both + // suites run against the same database instance. + // --------------------------------------------------------------------------- + const IBAN_A_DEFAULT = 'FR7630001007941234567890670'; // default bank account of COMPANY_A + const IBAN_A_SPECIFIC = 'FR7630001007941234567890767'; // specific bank account of COMPANY_A + const IBAN_B_DEFAULT = 'FR7630001007941234567890864'; // default bank account of COMPANY_B + const IBAN_B_SPECIFIC = 'FR7630001007941234567890961'; // specific bank account of COMPANY_B + const BIC = 'BNPAFRPPXXX'; + const XSD_PAIN_001 = __DIR__.'/../assets/xsd/pain.001.001.03.xsd'; + + // --------------------------------------------------------------------------- + // Shared fixtures created once in setUpBeforeClass(), + // inside the parent transaction (rolled back by tearDownAfterClass()). + // --------------------------------------------------------------------------- + /** @var int Row ID of COMPANY_A */ + protected static $socidA = 0; + /** @var int Row ID of COMPANY_B */ + protected static $socidB = 0; + /** @var int Row ID of the default bank account of COMPANY_A */ + protected static $ribADefaultId = 0; + /** @var int Row ID of the specific bank account of COMPANY_A */ + protected static $ribASpecificId = 0; + /** @var int Row ID of the default bank account of COMPANY_B */ + protected static $ribBDefaultId = 0; + /** @var int Row ID of the specific bank account of COMPANY_B */ + protected static $ribBSpecificId = 0; + /** @var int Row ID of the issuer bank account (llx_bank_account) */ + protected static $fkBankAccount = 0; + + /** + * setUpBeforeClass + * + * Creates shared fixtures for all tests in this class: + * - COMPANY_A and COMPANY_B (French supplier third parties) + * - Two bank accounts per company (one default, one specific) + * - One issuer bank account used for BonPrelevement generation + * + * Everything is created inside the parent transaction; + * the final rollback in tearDownAfterClass() removes all this data. + * + * @return void + */ + public static function setUpBeforeClass(): void + { + global $db, $user; + parent::setUpBeforeClass(); // Opens the parent transaction ($db->begin()) + + // Enable required modules if not already active + if (!isModEnabled('paymentbybanktransfer')) { + activateModule('modPaymentByBankTransfer', 1, 1); + } + if (!isModEnabled('fournisseur')) { + activateModule('modFournisseur', 1, 1); + } + + // ------------------------------------------------------------------ + // COMPANY_A: French supplier third party + // ------------------------------------------------------------------ + $socA = new Societe($db); + $socA->name = 'BonVirementTest CompanyA'; + $socA->fournisseur = 1; + $socA->country_id = 1; // France (rowid=1 in c_country) + $socA->code_fournisseur = -1; // -1 = auto-generate supplier code + self::$socidA = (int) $socA->create($user); + + // RIB_A_DEFAULT: first bank account for COMPANY_A, will be the default (default_rib=1) + $ribADef = new CompanyBankAccount($db); + $ribADef->socid = self::$socidA; + $ribADef->type = 'ban'; + $ribADef->iban = self::IBAN_A_DEFAULT; + $ribADef->bic = self::BIC; + $ribADef->default_rib = 1; + self::$ribADefaultId = (int) $ribADef->create($user); + $ribADef->update($user); + + // RIB_A_SPECIFIC: second bank account for COMPANY_A, non-default + $ribASpec = new CompanyBankAccount($db); + $ribASpec->socid = self::$socidA; + $ribASpec->type = 'ban'; + $ribASpec->iban = self::IBAN_A_SPECIFIC; + $ribASpec->bic = self::BIC; + $ribASpec->default_rib = 0; + self::$ribASpecificId = (int) $ribASpec->create($user); + $ribASpec->update($user); + + // ------------------------------------------------------------------ + // COMPANY_B: same structure, distinct IBANs + // ------------------------------------------------------------------ + $socB = new Societe($db); + $socB->name = 'BonVirementTest CompanyB'; + $socB->fournisseur = 1; + $socB->country_id = 1; + $socB->code_fournisseur = -1; + self::$socidB = (int) $socB->create($user); + + $ribBDef = new CompanyBankAccount($db); + $ribBDef->socid = self::$socidB; + $ribBDef->type = 'ban'; + $ribBDef->iban = self::IBAN_B_DEFAULT; + $ribBDef->bic = self::BIC; + $ribBDef->default_rib = 1; + self::$ribBDefaultId = (int) $ribBDef->create($user); + $ribBDef->update($user); + + $ribBSpec = new CompanyBankAccount($db); + $ribBSpec->socid = self::$socidB; + $ribBSpec->type = 'ban'; + $ribBSpec->iban = self::IBAN_B_SPECIFIC; + $ribBSpec->bic = self::BIC; + $ribBSpec->default_rib = 0; + self::$ribBSpecificId = (int) $ribBSpec->create($user); + $ribBSpec->update($user); + + // ------------------------------------------------------------------ + // Issuer bank account (llx_bank_account) passed as fk_bank_account + // ------------------------------------------------------------------ + $account = new Account($db); + $account->ref = 'BONVIR-TEST'; + $account->label = 'BonVirementTest Issuer'; + $account->country_id = 1; // France + $account->date_solde = dol_now(); + $account->iban = 'FR7630001007941234567891058'; // valid IBAN, not used in assertions + $account->bic = self::BIC; + $account->ics = 'FR77ZZZ123456789'; // SEPA identifier (also used for bank-transfer) + $account->ics_transfer = 'FR77ZZZ123456789'; // used when SEPA_USE_IDS is enabled + $account->owner_name = 'TestCorp'; + $account->currency_code = 'EUR'; + self::$fkBankAccount = (int) $account->create($user); + } + + /** + * Constructor + * We save global variables into local variables + * + * @param string $name Name + * @param array $data Test data + * @param string $dataName Test data name. + */ + public function __construct($name = null, array $data = array(), $dataName = '') + { + parent::__construct($name, $data, $dataName); + + // This const is tested in prelevement_check_config(), it SHOULD be set. + $this->savconf->global->PAYMENTBYBANKTRANSFER_ID_BANKACCOUNT = self::$fkBankAccount; + } + + /** + * testBonVirementCreate + * + * Non-regression test: verifies that create() in simulation mode + * with no pending payment requests returns 0 (no requests processed, no error). + * + * @return int + */ + public function testBonVirementCreate() + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + $localobject = new BonPrelevement($db); + $result = $localobject->Create(0, 0, 'simu'); + + print __METHOD__." result=".$result."\n"; + $this->assertEquals($result, 0); + + return $result; + } + + /** + * testTwoSuppliersSpecificRib + * + * Verifies that when two different suppliers each have one invoice with a + * specific bank account forced in the bank transfer request, the generated + * SEPA file contains the forced IBAN for each transaction (not the + * supplier's default bank account.) + * + * Scenario: + * INV_A (100) -> request with RIB_A_SPECIFIC -> expects IBAN_A_SPECIFIC + * INV_B (300) -> request with RIB_B_SPECIFIC -> expects IBAN_B_SPECIFIC + * + * @return void + */ + public function testTwoSuppliersSpecificRib() + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + $this->assertGreaterThan(0, self::$socidA, 'setUpBeforeClass() did not create fixtures (socidA)'); + $this->assertGreaterThan(0, self::$fkBankAccount, 'setUpBeforeClass() did not create issuer account'); + + // Create one invoice for COMPANY_A (100) and one for COMPANY_B (300) + $facA = $this->createValidatedSupplierInvoice(self::$socidA, 100.0); + $facB = $this->createValidatedSupplierInvoice(self::$socidB, 300.0); + + // Link each invoice to its specific bank account (not the default) + $demAId = $this->createPaymentRequest($facA, 100.0, self::$ribASpecificId); + $demBId = $this->createPaymentRequest($facB, 300.0, self::$ribBSpecificId); + + // Generate the bank transfer order with both requests + $bon = new BonPrelevement($db); + $result = $bon->create('', '', 'real', 'ALL', 0, 0, 'bank-transfer', + array($demAId, $demBId), self::$fkBankAccount); + $this->assertGreaterThanOrEqual(0, $result, 'BonPrelevement::create() failed: '.$bon->error); + + // Parse the SEPA file and extract IBAN -> amount pairs + $ibanAmounts = $this->parseSepaIbanAmounts($bon->filename); + + // Invoice A must use COMPANY_A's specific IBAN + $this->assertEquals(100.0, $ibanAmounts[self::IBAN_A_SPECIFIC], + 'COMPANY_A invoice must use IBAN_A_SPECIFIC (forced bank account)'); + // Invoice B must use COMPANY_B's specific IBAN + $this->assertEquals(300.0, $ibanAmounts[self::IBAN_B_SPECIFIC], + 'COMPANY_B invoice must use IBAN_B_SPECIFIC (forced bank account)'); + // Total must be 100 + 300 + $this->assertEquals(400.0, $bon->total, + 'Order total must equal the sum of both invoices'); + + $this->assertSepaXmlValid($bon->filename); + } + + /** + * testTwoSuppliersDefaultRib + * + * Verifies that when two different suppliers each have one invoice with no + * forced bank account in the bank transfer request (fk_societe_rib IS NULL), + * the generated SEPA file contains each supplier's default IBAN (default_rib=1). + * + * Scenario: + * INV_A (100) -> request with no forced RIB -> expects IBAN_A_DEFAULT + * INV_B (300) -> request with no forced RIB -> expects IBAN_B_DEFAULT + * + * @return void + */ + public function testTwoSuppliersDefaultRib() + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + $this->assertGreaterThan(0, self::$socidA, 'setUpBeforeClass() did not create fixtures (socidA)'); + $this->assertGreaterThan(0, self::$fkBankAccount, 'setUpBeforeClass() did not create issuer account'); + + // Create one invoice for COMPANY_A (100) and one for COMPANY_B (300) + $facA = $this->createValidatedSupplierInvoice(self::$socidA, 100.0); + $facB = $this->createValidatedSupplierInvoice(self::$socidB, 300.0); + + // No forced bank account: fk_societe_rib will be NULL -> default RIB used + $demAId = $this->createPaymentRequest($facA, 100.0); + $demBId = $this->createPaymentRequest($facB, 300.0); + + // Generate the bank transfer order with both requests + $bon = new BonPrelevement($db); + $result = $bon->create('', '', 'real', 'ALL', 0, 0, 'bank-transfer', + array($demAId, $demBId), self::$fkBankAccount); + $this->assertGreaterThanOrEqual(0, $result, 'BonPrelevement::create() failed: '.$bon->error); + + // Parse the SEPA file and extract IBAN -> amount pairs + $ibanAmounts = $this->parseSepaIbanAmounts($bon->filename); + + // Invoice A must use COMPANY_A's default IBAN + $this->assertEquals(100.0, $ibanAmounts[self::IBAN_A_DEFAULT], + 'COMPANY_A invoice must use IBAN_A_DEFAULT (default_rib=1, no forced account)'); + // Invoice B must use COMPANY_B's default IBAN + $this->assertEquals(300.0, $ibanAmounts[self::IBAN_B_DEFAULT], + 'COMPANY_B invoice must use IBAN_B_DEFAULT (default_rib=1, no forced account)'); + // No specific IBAN must appear in this order + $this->assertArrayNotHasKey(self::IBAN_A_SPECIFIC, $ibanAmounts, + 'IBAN_A_SPECIFIC must not appear when no RIB is forced for COMPANY_A'); + $this->assertArrayNotHasKey(self::IBAN_B_SPECIFIC, $ibanAmounts, + 'IBAN_B_SPECIFIC must not appear when no RIB is forced for COMPANY_B'); + // Total must be 100 + 300 + $this->assertEquals(400.0, $bon->total, + 'Order total must equal the sum of both invoices'); + + $this->assertSepaXmlValid($bon->filename); + } + + /** + * testTwoSuppliersDefaultRibFilteredToFirst + * + * Verifies that when $dids is limited to only the first payment request, + * only COMPANY_A's invoice appears in the SEPA file (even though a second + * request exists in the database for COMPANY_B.) + * + * Scenario: + * INV_A (100) -> request with no forced RIB -> expects IBAN_A_DEFAULT (in $dids) + * INV_B (300) -> request with no forced RIB -> must NOT appear in file (not in $dids) + * + * @return void + */ + public function testTwoSuppliersDefaultRibFilteredToFirst() + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + $this->assertGreaterThan(0, self::$socidA, 'setUpBeforeClass() did not create fixtures (socidA)'); + $this->assertGreaterThan(0, self::$fkBankAccount, 'setUpBeforeClass() did not create issuer account'); + + // Create one invoice for each supplier + $facA = $this->createValidatedSupplierInvoice(self::$socidA, 100.0); + $facB = $this->createValidatedSupplierInvoice(self::$socidB, 300.0); + + // Create both payment requests (no forced bank account for either) + $demAId = $this->createPaymentRequest($facA, 100.0); + $demBId = $this->createPaymentRequest($facB, 300.0); // exists in DB but excluded from $dids + + // Generate the order with ONLY the first request ($demAId) + $bon = new BonPrelevement($db); + $result = $bon->create('', '', 'real', 'ALL', 0, 0, 'bank-transfer', + array($demAId), self::$fkBankAccount); + $this->assertGreaterThanOrEqual(0, $result, 'BonPrelevement::create() failed: '.$bon->error); + + $ibanAmounts = $this->parseSepaIbanAmounts($bon->filename); + + // COMPANY_A must appear with its default IBAN + $this->assertEquals(100.0, $ibanAmounts[self::IBAN_A_DEFAULT], + 'COMPANY_A invoice must use IBAN_A_DEFAULT when no RIB is forced'); + // COMPANY_B must NOT appear since its request was not in $dids + $this->assertArrayNotHasKey(self::IBAN_B_DEFAULT, $ibanAmounts, + 'COMPANY_B must not appear: its request was excluded from $dids'); + // Total must be 100 only + $this->assertEquals(100.0, $bon->total, + 'Order total must equal only the included request'); + + $this->assertSepaXmlValid($bon->filename); + } + + /** + * testOneSupplierTwoRibs + * + * Verifies that for the same supplier with two invoices, the bank account + * selection is correct based on whether a specific account is forced: + * - A request with a forced bank account uses that specific IBAN. + * - A request without a forced account (ribId=0) falls back to the + * supplier's default bank account (default_rib=1). + * No IBAN from COMPANY_B should appear in the file. + * + * Scenario: + * INV_A1 (100) -> request with RIB_A_SPECIFIC -> expects IBAN_A_SPECIFIC + * INV_A2 (200) -> request without forced RIB -> expects IBAN_A_DEFAULT + * + * @return void + */ + public function testOneSupplierTwoRibs() + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + $this->assertGreaterThan(0, self::$socidA, 'setUpBeforeClass() did not create fixtures (socidA)'); + $this->assertGreaterThan(0, self::$fkBankAccount, 'setUpBeforeClass() did not create issuer account'); + + // Two invoices for COMPANY_A with different amounts + $facA1 = $this->createValidatedSupplierInvoice(self::$socidA, 100.0); + $facA2 = $this->createValidatedSupplierInvoice(self::$socidA, 200.0); + + // First request: specific bank account forced (not the default) + $demA1Id = $this->createPaymentRequest($facA1, 100.0, self::$ribASpecificId); + // Second request: no forced bank account (ribId=0 -> default will be used) + $demA2Id = $this->createPaymentRequest($facA2, 200.0); + + // Generate the order with both COMPANY_A requests only + $bon = new BonPrelevement($db); + $result = $bon->create('', '', 'real', 'ALL', 0, 0, 'bank-transfer', + array($demA1Id, $demA2Id), self::$fkBankAccount); + $this->assertGreaterThanOrEqual(0, $result, 'BonPrelevement::create() failed: '.$bon->error); + + $ibanAmounts = $this->parseSepaIbanAmounts($bon->filename); + + // INV_A1: forced bank account -> must use IBAN_A_SPECIFIC + $this->assertEquals(100.0, $ibanAmounts[self::IBAN_A_SPECIFIC], + 'INV_A1 with forced bank account must use IBAN_A_SPECIFIC'); + // INV_A2: no forced account -> must fall back to default IBAN_A_DEFAULT + $this->assertEquals(200.0, $ibanAmounts[self::IBAN_A_DEFAULT], + 'INV_A2 without forced account must use IBAN_A_DEFAULT (default_rib=1)'); + // No IBAN from COMPANY_B must appear in this order + $this->assertArrayNotHasKey(self::IBAN_B_DEFAULT, $ibanAmounts, + 'COMPANY_B bank account must not appear in a COMPANY_A-only order'); + $this->assertEquals(300.0, $bon->total); + + $this->assertSepaXmlValid($bon->filename); + } + + /** + * testOneSupplierTwoRibsSpecificOnly + * + * Verifies that when $dids contains only the request with the specific bank + * account, only that invoice appears in the SEPA file (even though a second + * request using the default RIB exists in the database.) + * + * Scenario: + * INV_A1 (100) -> request with RIB_A_SPECIFIC -> expects IBAN_A_SPECIFIC (in $dids) + * INV_A2 (200) -> request without forced RIB -> must NOT appear in file (not in $dids) + * + * @return void + */ + public function testOneSupplierTwoRibsSpecificOnly() + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + $this->assertGreaterThan(0, self::$socidA, 'setUpBeforeClass() did not create fixtures (socidA)'); + $this->assertGreaterThan(0, self::$fkBankAccount, 'setUpBeforeClass() did not create issuer account'); + + // Two invoices for COMPANY_A with different amounts + $facA1 = $this->createValidatedSupplierInvoice(self::$socidA, 100.0); + $facA2 = $this->createValidatedSupplierInvoice(self::$socidA, 200.0); + + // First request: specific bank account forced + $demA1Id = $this->createPaymentRequest($facA1, 100.0, self::$ribASpecificId); + // Second request: no forced bank account (exists in DB but excluded from $dids) + $demA2Id = $this->createPaymentRequest($facA2, 200.0); + + // Generate the order with ONLY the specific-RIB request ($demA1Id) + $bon = new BonPrelevement($db); + $result = $bon->create('', '', 'real', 'ALL', 0, 0, 'bank-transfer', + array($demA1Id), self::$fkBankAccount); + $this->assertGreaterThanOrEqual(0, $result, 'BonPrelevement::create() failed: '.$bon->error); + + $ibanAmounts = $this->parseSepaIbanAmounts($bon->filename); + + // INV_A1 must appear with the forced specific IBAN + $this->assertEquals(100.0, $ibanAmounts[self::IBAN_A_SPECIFIC], + 'INV_A1 with forced bank account must use IBAN_A_SPECIFIC'); + // INV_A2 must NOT appear since its request was not in $dids + $this->assertArrayNotHasKey(self::IBAN_A_DEFAULT, $ibanAmounts, + 'INV_A2 must not appear: its request was excluded from $dids'); + // Total must be 100 only + $this->assertEquals(100.0, $bon->total, + 'Order total must equal only the included request'); + + $this->assertSepaXmlValid($bon->filename); + } + + /** + * testMixedOrderFourInvoices + * + * Verifies that bank account assignment remains correct when bank transfer + * requests from two suppliers are interleaved (A1, B1, A2, B2). + * The test guards against cross-contamination between suppliers or between + * consecutive requests. + * + * Scenario (requests created in interleaved order): + * INV_A1 (100) -> forced: RIB_A_SPECIFIC -> expects IBAN_A_SPECIFIC + * INV_B1 (300) -> no forced RIB -> default -> expects IBAN_B_DEFAULT + * INV_A2 (200) -> no forced RIB -> default -> expects IBAN_A_DEFAULT + * INV_B2 (400) -> forced: RIB_B_SPECIFIC -> expects IBAN_B_SPECIFIC + * + * @return void + */ + public function testMixedOrderFourInvoices() + { + global $conf,$user,$langs,$db; + $conf = $this->savconf; + $user = $this->savuser; + $langs = $this->savlangs; + $db = $this->savdb; + + $this->assertGreaterThan(0, self::$socidA, 'setUpBeforeClass() did not create fixtures (socidA)'); + $this->assertGreaterThan(0, self::$fkBankAccount, 'setUpBeforeClass() did not create issuer account'); + + // Interleaved invoices: A1, B1, A2, B2 + $facA1 = $this->createValidatedSupplierInvoice(self::$socidA, 100.0); + $facB1 = $this->createValidatedSupplierInvoice(self::$socidB, 300.0); + $facA2 = $this->createValidatedSupplierInvoice(self::$socidA, 200.0); + $facB2 = $this->createValidatedSupplierInvoice(self::$socidB, 400.0); + + // Interleaved requests: forced, no RIB, no RIB, forced + $demA1Id = $this->createPaymentRequest($facA1, 100.0, self::$ribASpecificId); // forced + $demB1Id = $this->createPaymentRequest($facB1, 300.0); // -> RIB_B_DEFAULT + $demA2Id = $this->createPaymentRequest($facA2, 200.0); // -> RIB_A_DEFAULT + $demB2Id = $this->createPaymentRequest($facB2, 400.0, self::$ribBSpecificId); // forced + + // Generate the order with all four requests in interleaved order + $bon = new BonPrelevement($db); + $result = $bon->create('', '', 'real', 'ALL', 0, 0, 'bank-transfer', + array($demA1Id, $demB1Id, $demA2Id, $demB2Id), self::$fkBankAccount); + $this->assertGreaterThanOrEqual(0, $result, 'BonPrelevement::create() failed: '.$bon->error); + + $ibanAmounts = $this->parseSepaIbanAmounts($bon->filename); + + // Verify each invoice is matched to the correct IBAN with no cross-contamination + $this->assertEquals(100.0, $ibanAmounts[self::IBAN_A_SPECIFIC], + 'INV_A1 with forced bank account must use IBAN_A_SPECIFIC'); + $this->assertEquals(300.0, $ibanAmounts[self::IBAN_B_DEFAULT], + 'INV_B1 without forced account must use IBAN_B_DEFAULT (default_rib=1 of COMPANY_B)'); + $this->assertEquals(200.0, $ibanAmounts[self::IBAN_A_DEFAULT], + 'INV_A2 without forced account must use IBAN_A_DEFAULT (default_rib=1 of COMPANY_A)'); + $this->assertEquals(400.0, $ibanAmounts[self::IBAN_B_SPECIFIC], + 'INV_B2 with forced bank account must use IBAN_B_SPECIFIC'); + $this->assertEquals(1000.0, $bon->total, + 'Order total must equal the sum of all four invoices'); + + $this->assertSepaXmlValid($bon->filename); + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** + * Creates and validates a supplier invoice for a given company and amount. + * The invoice contains a single service line at 0% VAT. + * + * @param int $socid Row ID of the third party + * @param float $amount Pre-tax amount of the line (= total incl. tax with 0% VAT) + * @return FactureFournisseur + */ + private function createValidatedSupplierInvoice(int $socid, float $amount): FactureFournisseur + { + global $user, $db; + + $fac = new FactureFournisseur($db); + $fac->socid = $socid; + $fac->date = dol_now(); + $fac->cond_reglement_code = 'RECEP'; + $fac->mode_reglement_code = 'VIR'; // bank transfer payment mode + $fac->ref_supplier = 'SUPP-TEST-'.$socid.'-'.$amount.'-'.uniqid(); + + $result = $fac->create($user); + $this->assertGreaterThan(0, $result, 'FactureFournisseur::create() failed: '.$fac->error); + + // Service line at 0% VAT so total_ttc = $amount + // Signature: addline(desc, pu, txtva, txlocaltax1, txlocaltax2, qty, ...) + $fac->addline('Service test', $amount, 0, 0, 0, 1); + + $result = $fac->validate($user); + $this->assertGreaterThanOrEqual(0, $result, 'FactureFournisseur::validate() failed: '.$fac->error); + + return $fac; + } + + /** + * Creates a bank transfer payment request (demande_prelevement) for a validated supplier invoice. + * If $ribId is provided, the request stores that specific bank account in fk_societe_rib. + * Otherwise (ribId=0), the supplier's default bank account will be used. + * + * @param FactureFournisseur $fac Validated supplier invoice + * @param float $amount Requested amount + * @param int $ribId Row ID of the bank account to force (0 = use default) + * @return int Row ID of the created entry in llx_prelevement_demande + */ + private function createPaymentRequest(FactureFournisseur $fac, float $amount, int $ribId = 0): int + { + global $user, $db; + + // Insert a row in llx_prelevement_demande with fk_facture_fourn and fk_societe_rib = $ribId + $result = $fac->demande_prelevement($user, $amount, 'bank-transfer', 'supplier_invoice', 0, $ribId); + $this->assertEquals(1, $result, 'demande_prelevement() failed for invoice #'.$fac->id.': '.$fac->error); + + // Retrieve the row ID of the freshly inserted request + $sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."prelevement_demande WHERE fk_facture_fourn = ".((int) $fac->id)." AND traite = 0 ORDER BY rowid DESC LIMIT 1"; + $resql = $db->query($sql); + $obj = $db->fetch_object($resql); + $this->assertNotNull($obj, 'Payment request not found in DB for invoice #'.$fac->id); + + return (int) $obj->rowid; + } + + /** + * Asserts that a generated SEPA XML file validates against the pain.001.001.03 XSD schema. + * + * @param string $filename Path to the SEPA XML file + * @return void + */ + private function assertSepaXmlValid(string $filename): void + { + $this->assertFileExists($filename, 'SEPA XML file does not exist: '.$filename); + $this->assertFileExists(self::XSD_PAIN_001, 'XSD schema file not found: '.self::XSD_PAIN_001); + + $dom = new DOMDocument(); + $loaded = $dom->load($filename); + $this->assertTrue($loaded, 'DOMDocument failed to load SEPA XML: '.$filename); + + libxml_use_internal_errors(true); + $valid = $dom->schemaValidate(self::XSD_PAIN_001); + $errors = libxml_get_errors(); + libxml_clear_errors(); + libxml_use_internal_errors(false); + + $messages = array(); + foreach ($errors as $error) { + $messages[] = trim($error->message).' (line '.$error->line.')'; + } + $this->assertTrue($valid, 'SEPA XML does not validate against pain.001.001.03 XSD: '.implode('; ', $messages)); + } + + /** + * Parses a generated SEPA pain.001 XML file and returns an associative + * array of IBAN => amount for each CdtTrfTxInf transaction element. + * + * @param string $filename Path to the SEPA XML file + * @return array Array keyed by IBAN with transaction amount as value + */ + private function parseSepaIbanAmounts(string $filename): array + { + $this->assertFileExists($filename, 'SEPA XML file was not created: '.$filename); + + $xml = simplexml_load_file($filename); + $this->assertNotFalse($xml, 'Failed to parse SEPA XML file: '.$filename); + + // Register the SEPA credit-transfer namespace (pain.001.001.03) + $ns = 'urn:iso:std:iso:20022:tech:xsd:pain.001.001.03'; + $xml->registerXPathNamespace('ns', $ns); + + $result = array(); + foreach ($xml->xpath('//ns:CdtTrfTxInf') as $txInf) { + $txInf->registerXPathNamespace('ns', $ns); + // Creditor IBAN (the supplier being paid) + $ibanNodes = $txInf->xpath('ns:CdtrAcct/ns:Id/ns:IBAN'); + // Transaction amount (nested under Amt element in pain.001) + $amountNodes = $txInf->xpath('ns:Amt/ns:InstdAmt'); + + if (!empty($ibanNodes) && !empty($amountNodes)) { + $iban = (string) $ibanNodes[0]; + $amount = (float) $amountNodes[0]; + $result[$iban] = $amount; + } + } + + return $result; + } +}