diff --git a/changelog.md b/changelog.md index ce9f6b82..59f18ea0 100644 --- a/changelog.md +++ b/changelog.md @@ -218,3 +218,5 @@ - 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 +- 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/saferpayofficial.php b/saferpayofficial.php index e5e44717..7004bff6 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 1cdfce80..5242ffc8 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 99c48671..4d88c0c8 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 f52af9e1..2d9789b4 100644 --- a/src/Service/SaferPayRefreshPaymentsService.php +++ b/src/Service/SaferPayRefreshPaymentsService.php @@ -61,48 +61,102 @@ 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']); } + $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) { - $paymentActive = (isset($paymentsInfo[$payment]['active'])) ? (int) $paymentsInfo[$payment]['active'] : 0; - $fieldActive = (isset($paymentsInfo[$payment]['field'])) ? (int) $paymentsInfo[$payment]['field'] : 0; + $paymentName = $this->getPaymentName($payment); + $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, ]); } } + + /** + * @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' => []] + ); + } + } } diff --git a/src/Service/SaferPayStoredPaymentMethods.php b/src/Service/SaferPayStoredPaymentMethods.php new file mode 100644 index 00000000..1eaae263 --- /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 1fbb4f6d..ae51fb5c 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() + ); + } +}