From 08527fcc96814fdbe5ec0c8e8761dd2ff3b91224 Mon Sep 17 00:00:00 2001 From: Tadas Labutis Date: Thu, 27 Aug 2026 12:34:22 +0300 Subject: [PATCH 1/2] Read checkout payment methods from storage instead of the API PrestaShop re-renders the payment step over AJAX on every address and carrier change, and hookPaymentOptions called SaferPayObtainPaymentMethods on each render, so one order produced several Management API round trips. The only things the checkout needed from that response beyond the method name were the logo and the supported currencies. Both are now stored on saferpay_payment as logo_url and currencies, written by SaferPayRefreshPaymentsService, and read by the new SaferPayStoredPaymentMethods, which falls back to one account call only when nothing is stored yet. SaferPayRefreshPaymentsService now also reads every stored row rather than the active ones alone, so a method the merchant switched off keeps its flags across a refresh instead of reappearing enabled. The column migration and its backfill go into upgrade/install-2.1.0.php rather than a new 2.1.1 script, because 2.1.0 is still unreleased and PrestaShop skips an upgrade file whose version is above the module version. A backfill failure is logged rather than fatal, since an unreachable account must not break the upgrade and the checkout repopulates the columns by itself. The card brand grouping changes sitting on the same working tree are deliberately left out and ship on their own branch. --- changelog.md | 1 + saferpayofficial.php | 16 +-- src/Install/Installer.php | 2 + src/Repository/SaferPayPaymentRepository.php | 15 +++ .../SaferPayRefreshPaymentsService.php | 37 +++--- src/Service/SaferPayStoredPaymentMethods.php | 117 ++++++++++++++++++ upgrade/install-2.1.0.php | 42 ++++++- 7 files changed, 204 insertions(+), 26 deletions(-) create mode 100644 src/Service/SaferPayStoredPaymentMethods.php diff --git a/changelog.md b/changelog.md index ce9f6b82e..3111bf88b 100644 --- a/changelog.md +++ b/changelog.md @@ -218,3 +218,4 @@ - BO : Fixed issue when the "Could not reach your Saferpay account" warning kept showing after payment methods had loaded successfully - Fixed issue when files removed in this version stayed on disk after an upgrade, leaving obsolete iframe checkout controllers reachable and re-creating obsolete menu tabs on module reset - BO : Fixed issue when a saved API password offered no visible way to enter a new one, and browser password manager icons covered the show/hide password control +- FO : Removed the Saferpay Management API call from the checkout, payment methods are now read from local storage diff --git a/saferpayofficial.php b/saferpayofficial.php index e5e447171..7004bff6b 100644 --- a/saferpayofficial.php +++ b/saferpayofficial.php @@ -38,9 +38,8 @@ use Invertus\SaferPay\Install\Uninstaller; use Invertus\SaferPay\Service\SaferPayCartService; use Invertus\SaferPay\Provider\PaymentTypeProvider; -use Invertus\SaferPay\Service\SaferPayObtainPaymentMethods; +use Invertus\SaferPay\Service\SaferPayStoredPaymentMethods; use Invertus\SaferPay\Repository\SaferPayPaymentRepository; -use Invertus\SaferPay\Exception\Api\SaferPayApiException; use Invertus\SaferPay\Service\PaymentRestrictionValidation; use Invertus\SaferPay\Provider\CurrencyProvider; use Invertus\SaferPay\Service\SaferPayEmailTemplateControlServiceInterface; @@ -217,14 +216,17 @@ public function hookPaymentOptions($params) /** @var PaymentTypeProvider $paymentTypeProvider */ $paymentTypeProvider = $this->getService(PaymentTypeProvider::class); - /** @var SaferPayObtainPaymentMethods $obtainPaymentMethods */ - $obtainPaymentMethods = $this->getService(SaferPayObtainPaymentMethods::class); + /** @var SaferPayStoredPaymentMethods $storedPaymentMethods */ + $storedPaymentMethods = $this->getService(SaferPayStoredPaymentMethods::class); /** @var SaferPayPaymentRepository $paymentRepository */ $paymentRepository = $this->getService(SaferPayPaymentRepository::class); - try { - $paymentMethods = $obtainPaymentMethods->obtainPaymentMethods(); - } catch (SaferPayApiException $exception) { + // Read the account's payment methods from storage. PrestaShop re-renders the payment + // step over AJAX on every address and carrier change, so calling the Management API + // here meant several GetTerminal calls per order. + $paymentMethods = $storedPaymentMethods->getPaymentMethods(); + + if (empty($paymentMethods)) { return []; } diff --git a/src/Install/Installer.php b/src/Install/Installer.php index 1cdfce80d..5242ffc89 100644 --- a/src/Install/Installer.php +++ b/src/Install/Installer.php @@ -153,6 +153,8 @@ private function installSaferPayPaymentTable() `id_saferpay_payment` INTEGER(10) UNSIGNED AUTO_INCREMENT PRIMARY KEY, `name` VARCHAR(64) NOT NULL, `active` tinyint(1) DEFAULT 0, + `logo_url` VARCHAR(255) DEFAULT NULL, + `currencies` VARCHAR(1024) DEFAULT NULL, UNIQUE (`name`) ) ENGINE=' . _MYSQL_ENGINE_ . ' DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci' ); diff --git a/src/Repository/SaferPayPaymentRepository.php b/src/Repository/SaferPayPaymentRepository.php index 99c48671b..4d88c0c8a 100644 --- a/src/Repository/SaferPayPaymentRepository.php +++ b/src/Repository/SaferPayPaymentRepository.php @@ -110,6 +110,21 @@ public function getActivePaymentMethodsNames() return $result; } + public function getAllPaymentMethods() + { + $query = new DbQuery(); + $query->select('*'); + $query->from('saferpay_payment'); + + $result = Db::getInstance()->executeS($query); + + if (!$result) { + return []; + } + + return $result; + } + public function getAllPaymentMethodsNames() { $query = new DbQuery(); diff --git a/src/Service/SaferPayRefreshPaymentsService.php b/src/Service/SaferPayRefreshPaymentsService.php index f52af9e1b..af8844433 100644 --- a/src/Service/SaferPayRefreshPaymentsService.php +++ b/src/Service/SaferPayRefreshPaymentsService.php @@ -61,27 +61,18 @@ public function __construct( public function refreshPayments() { - // Get enabled payments. - $activePayments = $this->paymentRepository->getActivePaymentMethods(); - - if (empty($activePayments)) { - $this->logger->info('No active payment options found', [ - 'context' => [], - ]); - - return; - } - // Get payments from API. try { - $paymentsFromAPI = $this->obtainPayments->obtainPaymentMethodsNamesAsArray(); + $paymentsFromAPI = $this->obtainPayments->obtainPaymentMethods(); } catch (Exception $exception) { throw new SaferPayApiException('Initialize API failed', SaferPayApiException::INITIALIZE); } + // Read every stored row, not only the enabled ones, so that a method the merchant + // deliberately switched off keeps its flags across a refresh instead of silently + // reappearing as enabled-by-default. $paymentsInfo = []; - foreach ($activePayments as $payment) { - $paymentsInfo[$payment['name']]['name'] = $payment['name']; + foreach ($this->paymentRepository->getAllPaymentMethods() as $payment) { $paymentsInfo[$payment['name']]['active'] = $payment['active']; $paymentsInfo[$payment['name']]['field'] = $this->fieldRepository->isActiveByName($payment['name']); } @@ -91,16 +82,26 @@ public function refreshPayments() $this->fieldRepository->truncateTable(); foreach ($paymentsFromAPI as $payment) { - $paymentActive = (isset($paymentsInfo[$payment]['active'])) ? (int) $paymentsInfo[$payment]['active'] : 0; - $fieldActive = (isset($paymentsInfo[$payment]['field'])) ? (int) $paymentsInfo[$payment]['field'] : 0; + $paymentName = str_replace(' ', '', $payment['paymentMethod']); + $paymentActive = (isset($paymentsInfo[$paymentName]['active'])) ? (int) $paymentsInfo[$paymentName]['active'] : 0; + $fieldActive = (isset($paymentsInfo[$paymentName]['field'])) ? (int) $paymentsInfo[$paymentName]['field'] : 0; + + // The logo and the supported currencies are the only two things the checkout + // needed the account for. Persisting them here is what lets hookPaymentOptions + // build the payment list without calling the Management API on every render. + $currencies = isset($payment['currencies']) && is_array($payment['currencies']) + ? $payment['currencies'] + : []; $this->paymentRepository->insertPayment([ - 'name' => $payment, + 'name' => pSQL($paymentName), 'active' => $paymentActive, + 'logo_url' => pSQL((string) $payment['logoUrl']), + 'currencies' => pSQL(implode(',', $currencies)), ]); $this->fieldRepository->insertField([ - 'name' => $payment, + 'name' => pSQL($paymentName), 'active' => $fieldActive, ]); } diff --git a/src/Service/SaferPayStoredPaymentMethods.php b/src/Service/SaferPayStoredPaymentMethods.php new file mode 100644 index 000000000..1eaae263f --- /dev/null +++ b/src/Service/SaferPayStoredPaymentMethods.php @@ -0,0 +1,117 @@ + + *@copyright SIX Payment Services + *@license SIX Payment Services + */ + +namespace Invertus\SaferPay\Service; + +use Exception; +use Invertus\SaferPay\Logger\LoggerInterface; +use Invertus\SaferPay\Repository\SaferPayPaymentRepository; + +if (!defined('_PS_VERSION_')) { + exit; +} + +/** + * Serves the checkout the payment method list that used to come from the Management API. + * + * The account is only asked when nothing is stored yet, so a shop that has never opened the + * settings page still recovers on its own instead of showing an empty payment step. + */ +class SaferPayStoredPaymentMethods +{ + const FILE_NAME = 'SaferPayStoredPaymentMethods'; + + /** @var SaferPayPaymentRepository */ + private $paymentRepository; + + /** @var SaferPayRefreshPaymentsService */ + private $refreshPaymentsService; + + /** @var LoggerInterface */ + private $logger; + + public function __construct( + SaferPayPaymentRepository $paymentRepository, + SaferPayRefreshPaymentsService $refreshPaymentsService, + LoggerInterface $logger + ) { + $this->paymentRepository = $paymentRepository; + $this->refreshPaymentsService = $refreshPaymentsService; + $this->logger = $logger; + } + + public function getPaymentMethods(): array + { + $paymentMethods = $this->readStoredPaymentMethods(); + + if ($this->isPopulated($paymentMethods)) { + return $paymentMethods; + } + + try { + $this->refreshPaymentsService->refreshPayments(); + } catch (Exception $exception) { + $this->logger->error(sprintf('%s - failed to populate the stored payment methods', self::FILE_NAME), [ + 'context' => [], + 'exception' => $exception, + ]); + + return []; + } + + return $this->readStoredPaymentMethods(); + } + + /** + * Rows written before the logo and currencies were stored carry neither, and so cannot + * drive the checkout. Every method the account returns has a logo, so its absence across + * the board means the upgrade backfill never ran or could not reach the account. + */ + private function isPopulated(array $paymentMethods): bool + { + foreach ($paymentMethods as $paymentMethod) { + if ($paymentMethod['logoUrl'] !== '') { + return true; + } + } + + return false; + } + + private function readStoredPaymentMethods(): array + { + $paymentMethods = []; + + foreach ($this->paymentRepository->getAllPaymentMethods() as $payment) { + $currencies = empty($payment['currencies']) ? [] : explode(',', $payment['currencies']); + + $paymentMethods[$payment['name']] = [ + 'paymentMethod' => $payment['name'], + 'logoUrl' => (string) $payment['logo_url'], + 'currencies' => $currencies, + ]; + } + + return $paymentMethods; + } +} diff --git a/upgrade/install-2.1.0.php b/upgrade/install-2.1.0.php index 1fbb4f6d6..ae51fb5c6 100644 --- a/upgrade/install-2.1.0.php +++ b/upgrade/install-2.1.0.php @@ -21,15 +21,19 @@ *@license SIX Payment Services */ +use Invertus\SaferPay\Service\SaferPayRefreshPaymentsService; + if (!defined('_PS_VERSION_')) { exit; } -function upgrade_module_2_1_0() +function upgrade_module_2_1_0($module) { saferpayofficial_2_1_0_delete_removed_tabs(); saferpayofficial_2_1_0_delete_removed_files(); saferpayofficial_2_1_0_delete_removed_configuration(); + saferpayofficial_2_1_0_add_payment_method_details(); + saferpayofficial_2_1_0_backfill_payment_method_details($module); Tools::clearSmartyCache(); @@ -154,3 +158,39 @@ function saferpayofficial_2_1_0_delete_removed_configuration() { Configuration::deleteByName('SAFERPAY_HOSTED_FIELDS_TEMPLATE'); } + +/** + * The checkout used to read the logo and the supported currencies straight off the Management + * API on every payment step render. Storing them alongside the method removes that call. + */ +function saferpayofficial_2_1_0_add_payment_method_details() +{ + $table = _DB_PREFIX_ . 'saferpay_payment'; + $columns = array_column(Db::getInstance()->executeS('SHOW COLUMNS FROM `' . $table . '`'), 'Field'); + + if (!in_array('logo_url', $columns, true)) { + Db::getInstance()->execute('ALTER TABLE `' . $table . '` ADD `logo_url` VARCHAR(255) DEFAULT NULL'); + } + + if (!in_array('currencies', $columns, true)) { + Db::getInstance()->execute('ALTER TABLE `' . $table . '` ADD `currencies` VARCHAR(1024) DEFAULT NULL'); + } +} + +/** + * Populate the two new columns for shops that already have payment methods stored. Best effort + * on purpose: an unreachable account must not fail the upgrade, and the checkout repopulates + * on its own when it finds the columns still empty. + */ +function saferpayofficial_2_1_0_backfill_payment_method_details($module) +{ + try { + /** @var SaferPayRefreshPaymentsService $refreshPaymentsService */ + $refreshPaymentsService = $module->getService(SaferPayRefreshPaymentsService::class); + $refreshPaymentsService->refreshPayments(); + } catch (Exception $exception) { + PrestaShopLogger::addLog( + 'SaferPay 2.1.0 upgrade: could not backfill payment method details - ' . $exception->getMessage() + ); + } +} From f4aabccd9ba315bd9bf613126d1434b9848c9da9 Mon Sep 17 00:00:00 2001 From: Tadas Labutis Date: Thu, 27 Aug 2026 16:13:29 +0300 Subject: [PATCH 2/2] Log payment methods that Saferpay stopped offering A method that disappears from the Saferpay account is dropped from storage by refreshPayments() without leaving any trace, so the merchant finds it gone from both the settings page and the checkout with nothing to explain why, and support cannot tell whether Saferpay stopped offering it or the module lost it. refreshPayments() now diffs the stored names against the account list and logs each removal through logRemovedPayments() before the tables are rebuilt, so a failure part way through the inserts still leaves a record. A removal that was enabled is a warning because it cost the shop a live checkout option, one that was already disabled is a notice. Logger::warning() and Logger::notice() are not gated by SaferPayConfig::isDebugMode(), so the rows reach Advanced Parameters > Logs on a default install. getPaymentName() is extracted so the payment name normalisation is shared by the comparison and the insert. Left out: the reactive refresh on a rejected Initialize, which needs the Saferpay ErrorName for an unavailable method confirmed against the sandbox first. --- changelog.md | 1 + .../SaferPayRefreshPaymentsService.php | 55 ++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/changelog.md b/changelog.md index 3111bf88b..59f18ea09 100644 --- a/changelog.md +++ b/changelog.md @@ -219,3 +219,4 @@ - Fixed issue when files removed in this version stayed on disk after an upgrade, leaving obsolete iframe checkout controllers reachable and re-creating obsolete menu tabs on module reset - BO : Fixed issue when a saved API password offered no visible way to enter a new one, and browser password manager icons covered the show/hide password control - FO : Removed the Saferpay Management API call from the checkout, payment methods are now read from local storage +- BO : Added a log entry when Saferpay stops offering a payment method, so a method disappearing from the settings and the checkout can be traced diff --git a/src/Service/SaferPayRefreshPaymentsService.php b/src/Service/SaferPayRefreshPaymentsService.php index af8844433..2d9789b41 100644 --- a/src/Service/SaferPayRefreshPaymentsService.php +++ b/src/Service/SaferPayRefreshPaymentsService.php @@ -77,12 +77,21 @@ public function refreshPayments() $paymentsInfo[$payment['name']]['field'] = $this->fieldRepository->isActiveByName($payment['name']); } + $paymentNamesFromAPI = []; + foreach ($paymentsFromAPI as $payment) { + $paymentNamesFromAPI[] = $this->getPaymentName($payment); + } + + // Logged before the rebuild so that a failure part way through the inserts still + // leaves a record of what the account stopped offering. + $this->logRemovedPayments($paymentsInfo, $paymentNamesFromAPI); + // Truncate tables. $this->paymentRepository->truncateTable(); $this->fieldRepository->truncateTable(); foreach ($paymentsFromAPI as $payment) { - $paymentName = str_replace(' ', '', $payment['paymentMethod']); + $paymentName = $this->getPaymentName($payment); $paymentActive = (isset($paymentsInfo[$paymentName]['active'])) ? (int) $paymentsInfo[$paymentName]['active'] : 0; $fieldActive = (isset($paymentsInfo[$paymentName]['field'])) ? (int) $paymentsInfo[$paymentName]['field'] : 0; @@ -106,4 +115,48 @@ public function refreshPayments() ]); } } + + /** + * @param array $payment + * + * @return string + */ + private function getPaymentName(array $payment) + { + return str_replace(' ', '', $payment['paymentMethod']); + } + + /** + * A method that disappears from the Saferpay account is dropped from storage without + * leaving any trace, so the merchant finds it gone from both the settings page and the + * checkout with nothing to explain why. Logging it is what lets support tell them that + * Saferpay stopped offering it, instead of the module having lost it. + * + * @param array $storedPayments + * @param array $paymentNamesFromAPI + * + * @return void + */ + private function logRemovedPayments(array $storedPayments, array $paymentNamesFromAPI) + { + $removedPayments = array_diff(array_keys($storedPayments), $paymentNamesFromAPI); + + foreach ($removedPayments as $paymentName) { + $message = sprintf( + 'Payment method "%s" is no longer available on the Saferpay account and was removed from this shop', + $paymentName + ); + + if (empty($storedPayments[$paymentName]['active'])) { + $this->logger->notice($message, ['context' => []]); + + continue; + } + + $this->logger->warning( + sprintf('%s. It was enabled, so it is no longer offered in the checkout', $message), + ['context' => []] + ); + } + } }