diff --git a/changelog.md b/changelog.md index ce9f6b82..ac9f5ad4 100644 --- a/changelog.md +++ b/changelog.md @@ -210,7 +210,9 @@ ## [2.1.0] - BO : Redesigned back-office settings into a React single-page admin - BO : Conditionally show/hide Saferpay Fields settings based on account license -- BO : Renamed the "Custom form" column in Payment methods to "Saferpay Fields" +- BO : Replaced the per-brand "Saferpay Fields" toggles with a single "Use Saferpay Fields" setting, initialized from the old toggles during upgrade +- FO : Card payments automatically fall back to the Saferpay Payment Page when the Saferpay Fields access token is missing +- Added automatic Saferpay Fields access token generation from stored API credentials during upgrade - BO/FO : Accessibility improvements for EAA / WCAG 2.1 AA compliance - Added configurable payment description and order reference on payment page - API update to V1.50: added WERO and GIFTCARD payment methods, removed deprecated GIROPAY/PAYDIREKT/SOFORT @@ -218,3 +220,10 @@ - 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 : Replaced the per-brand card list in checkout with a single "Cards" option that accepts only the enabled brands, with saved cards and Saferpay Fields support +- FO : Added American Express support to the Saferpay Fields form +- FO : Fixed issue when paying with a card brand other than the one selected left the payment authorized at Saferpay without a completed order +- FO : Fixed issue when reloading the payment return page sent a second authorization and failed an already paid order +- FO : Fixed issue when the payment behavior without 3-D Secure setting was ignored for orders paid through the grouped "Cards" option +- FO : Fixed issue when the grouped "Cards" option ignored payment method country and currency restrictions +- FO : Fixed issue when an order paid through the Saferpay hosted payment page stayed awaiting payment because the return page blocked the Saferpay notification diff --git a/controllers/admin/AdminSaferPayOfficialSettingsController.php b/controllers/admin/AdminSaferPayOfficialSettingsController.php index cfb388b9..d6de8695 100755 --- a/controllers/admin/AdminSaferPayOfficialSettingsController.php +++ b/controllers/admin/AdminSaferPayOfficialSettingsController.php @@ -22,13 +22,11 @@ */ use Invertus\SaferPay\Config\SaferPayConfig; -use Invertus\SaferPay\Repository\SaferPayFieldRepository; use Invertus\SaferPay\Repository\SaferPayLogoRepository; use Invertus\SaferPay\Repository\SaferPayPaymentRepository; use Invertus\SaferPay\Repository\SaferPayRestrictionRepository; use Invertus\SaferPay\Repository\SaferPaySavedCreditCardRepository; use Invertus\SaferPay\Adapter\Configuration as SaferPayConfiguration; -use Invertus\SaferPay\Service\SaferPayFieldCreator; use Invertus\SaferPay\Service\SaferPayGenerateFieldAccessToken; use Invertus\SaferPay\Service\SaferPayGetLicense; use Invertus\SaferPay\Service\SaferPayGetTerminals; @@ -97,9 +95,11 @@ public function setMedia($isNewTheme = false) { parent::setMedia($isNewTheme); + // The bundle filename never changes between releases, so CDNs keep serving the + // previous version's build after an upgrade unless the URL carries the version. $distPath = 'modules/' . $this->module->name . '/views/js/admin/dist/'; - $this->addJS($distPath . 'saferpay-settings.js'); - $this->addCSS($distPath . 'saferpay-settings.css'); + $this->addJS($distPath . 'saferpay-settings.js?v=' . $this->module->version); + $this->addCSS($distPath . 'saferpay-settings.css?v=' . $this->module->version); } public function initContent() @@ -318,6 +318,7 @@ public function ajaxProcessSavePaymentProcessing() $configuration->set(SaferPayConfig::SAFERPAY_ORDER_CREATION_AFTER_AUTHORIZATION, $this->getIntValue($data, 'orderCreationAfterAuth')); $configuration->set(SaferPayConfig::SAFERPAY_GROUP_CARDS, !empty($data['groupCards']) ? 1 : 0); $configuration->set(SaferPayConfig::SAFERPAY_GROUP_CARDS_LOGO, !empty($data['groupCardsLogo']) ? 1 : 0); + $configuration->set(SaferPayConfig::SAFERPAY_USE_FIELDS, !empty($data['useFields']) ? 1 : 0); $configuration->set(SaferPayConfig::CREDIT_CARD_SAVE, $this->getIntValue($data, 'creditCardSave')); // If credit card save disabled, clean up saved cards @@ -407,9 +408,6 @@ public function ajaxProcessSavePaymentMethods() /** @var SaferPayLogoCreator $logoCreation */ $logoCreation = $this->module->getService(SaferPayLogoCreator::class); - /** @var SaferPayFieldCreator $fieldCreation */ - $fieldCreation = $this->module->getService(SaferPayFieldCreator::class); - /** @var SaferPayRestrictionCreator $restrictionCreator */ $restrictionCreator = $this->module->getService(SaferPayRestrictionCreator::class); @@ -422,7 +420,6 @@ public function ajaxProcessSavePaymentMethods() $paymentName = $method['name']; $success = $paymentCreation->updatePayment($paymentName, !empty($method['enabled'])) && $success; $success = $logoCreation->updateLogo($paymentName, !empty($method['showLogos'])) && $success; - $success = $fieldCreation->updateField($paymentName, !empty($method['showCustomForm'])) && $success; try { $countries = isset($method['countries']) ? $method['countries'] : []; @@ -574,6 +571,11 @@ private function collectSettingsData() // Resolved before the payload is built because it sets $paymentMethodsFetchFailed. $paymentMethodsData = $this->getPaymentMethodsData(); + $envSuffix = $configuration->get(SaferPayConfig::TEST_MODE) ? SaferPayConfig::TEST_SUFFIX : ''; + $fieldsAccessTokenMissing = (bool) $configuration->get(SaferPayConfig::BUSINESS_LICENSE . $envSuffix) + && (bool) $configuration->get(SaferPayConfig::SAFERPAY_USE_FIELDS) + && !$configuration->get(SaferPayConfig::FIELDS_ACCESS_TOKEN . $envSuffix); + $data = [ // Environment 'testMode' => (bool) $configuration->get(SaferPayConfig::TEST_MODE), @@ -605,6 +607,8 @@ private function collectSettingsData() 'orderCreationAfterAuth' => (int) $configuration->get(SaferPayConfig::SAFERPAY_ORDER_CREATION_AFTER_AUTHORIZATION), 'groupCards' => (bool) $configuration->get(SaferPayConfig::SAFERPAY_GROUP_CARDS), 'groupCardsLogo' => (bool) $configuration->get(SaferPayConfig::SAFERPAY_GROUP_CARDS_LOGO), + 'useFields' => (bool) $configuration->get(SaferPayConfig::SAFERPAY_USE_FIELDS), + 'fieldsAccessTokenMissing' => $fieldsAccessTokenMissing, 'creditCardSave' => (int) $configuration->get(SaferPayConfig::CREDIT_CARD_SAVE), // Email @@ -751,9 +755,6 @@ private function getPaymentMethodsData() /** @var SaferPayLogoRepository $logoRepository */ $logoRepository = $this->module->getService(SaferPayLogoRepository::class); - /** @var SaferPayFieldRepository $fieldRepository */ - $fieldRepository = $this->module->getService(SaferPayFieldRepository::class); - /** @var SaferPayRestrictionRepository $restrictionRepository */ $restrictionRepository = $this->module->getService(SaferPayRestrictionRepository::class); @@ -767,8 +768,6 @@ private function getPaymentMethodsData() 'displayName' => $saferPayPaymentNotation->getForDisplay($paymentMethod), 'enabled' => (bool) $paymentRepository->isActiveByName($paymentMethod), 'showLogos' => (bool) $logoRepository->isActiveByName($paymentMethod), - 'showCustomForm' => (bool) $fieldRepository->isActiveByName($paymentMethod), - 'hasCustomForm' => in_array($paymentMethod, SaferPayConfig::FIELD_SUPPORTED_PAYMENT_METHODS), 'countries' => $restrictionRepository->getSelectedIdsByName( $paymentMethod, SaferPayRestrictionCreator::RESTRICTION_COUNTRY diff --git a/controllers/front/ajax.php b/controllers/front/ajax.php index fb7b9ef6..8c1e2444 100644 --- a/controllers/front/ajax.php +++ b/controllers/front/ajax.php @@ -106,7 +106,7 @@ protected function processGetStatus() 'isFinished' => $saferPayOrder->authorized || $saferPayOrder->captured || $saferPayOrder->pending, 'href' => $this->context->link->getModuleLink( $this->module->name, - $this->getSuccessControllerName($isBusinessLicence, $fieldToken), + $this->getSuccessControllerName($isBusinessLicence, $fieldToken, (int) $selectedCard > 0), [ 'cartId' => $cartId, 'orderId' => $saferPayOrder->id_order, @@ -132,9 +132,9 @@ private function getFailControllerLink($cartId, $secureKey, $moduleId) ); } - private function getSuccessControllerName($isBusinessLicence, $fieldToken) + private function getSuccessControllerName($isBusinessLicence, $fieldToken, $usingSavedCard) { - if ($fieldToken) { + if ($fieldToken || $usingSavedCard) { return ControllerName::SUCCESS_HOSTED; } diff --git a/controllers/front/notify.php b/controllers/front/notify.php index e1fc6bbf..12226634 100644 --- a/controllers/front/notify.php +++ b/controllers/front/notify.php @@ -30,6 +30,7 @@ use Invertus\SaferPay\Repository\SaferPayOrderRepository; use Invertus\SaferPay\Service\SaferPayOrderStatusService; use Invertus\SaferPay\Service\TransactionFlow\SaferPayTransactionAssertion; +use Invertus\SaferPay\Service\TransactionFlow\SaferPayTransactionProcessedGuard; use Invertus\SaferPay\Utility\ExceptionUtility; if (!defined('_PS_VERSION_')) { @@ -110,6 +111,19 @@ public function postProcess() die($this->module->l('Order already complete', self::FILE_NAME)); } + /** @var SaferPayTransactionProcessedGuard $processedGuard */ + $processedGuard = $this->module->getService(SaferPayTransactionProcessedGuard::class); + + if ($processedGuard->isProcessed($cartId)) { + $logger->debug(sprintf('%s - Payment already processed. Dying.', self::FILE_NAME), [ + 'context' => [ + 'cart_id' => $cartId, + ], + ]); + + die($this->module->l('Order already complete', self::FILE_NAME)); + } + /** @var SaferPayOrderRepository $saferPayOrderRepository */ $saferPayOrderRepository = $this->module->getService(SaferPayOrderRepository::class); @@ -142,8 +156,13 @@ public function postProcess() $paymentBehaviorWithout3D = (int) Configuration::get(SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D); + // $order->payment holds the checkout option's name, which is "Cards" for the grouped + // option and never matches a brand, silently skipping the whole without-3DS behaviour. + // The brand Saferpay asserted is what this setting is about. + $assertedPaymentMethod = $assertResponseBody->getPaymentMeans()->getBrand()->getPaymentMethod(); + if (!$assertResponseBody->getLiability()->getLiabilityShift() && - in_array($order->payment, SaferPayConfig::SUPPORTED_3DS_PAYMENT_METHODS) + in_array($assertedPaymentMethod, SaferPayConfig::SUPPORTED_3DS_PAYMENT_METHODS) ) { /** @var SaferPayOrderStatusService $orderStatusService */ $orderStatusService = $this->module->getService(SaferPayOrderStatusService::class); diff --git a/controllers/front/return.php b/controllers/front/return.php index 366fd7ee..2053f98c 100644 --- a/controllers/front/return.php +++ b/controllers/front/return.php @@ -33,9 +33,12 @@ use Invertus\SaferPay\Processor\CheckoutProcessor; use Invertus\SaferPay\Provider\PaymentTypeProvider; use Invertus\SaferPay\Repository\SaferPayFieldRepository; +use Invertus\SaferPay\Response\Response; +use Invertus\SaferPay\Service\CardAliasRegistrationGuard; use Invertus\SaferPay\Service\SaferPayOrderStatusService; use Invertus\SaferPay\Service\TransactionFlow\SaferPayTransactionAssertion; use Invertus\SaferPay\Service\TransactionFlow\SaferPayTransactionAuthorization; +use Invertus\SaferPay\Service\TransactionFlow\SaferPayTransactionProcessedGuard; use Invertus\SaferPay\Utility\ExceptionUtility; use Invertus\SaferPay\Adapter\Cart as CartAdapter; @@ -72,16 +75,46 @@ public function postProcess() $this->redirectWithNotifications($this->getRedirectionToControllerUrl($failController)); } + // Saferpay sends the redirect and the notification in parallel, and with a business licence + // the assert below authorizes the transaction, which may only ever happen once, so both legs + // share a lock key. With isWebhook set the notification is the only leg that completes the + // payment and the assert here is the read-only PaymentPage/Assert, so taking the lock would + // only starve the notification, which dies on a conflict instead of waiting and leaves the + // order awaiting payment forever. + if (!Tools::getValue('isWebhook')) { + $lockResult = $this->applyLock(sprintf('%s-%s', $cartId, $secureKey)); + + // Only a conflict means the notification holds the lock. Any other failure is the locking + // itself being unavailable, and the processed check below still guards the repeated assert. + if ($lockResult->getStatusCode() === Response::HTTP_CONFLICT) { + $logger->debug(sprintf('%s - Notification is already being processed, skipping assert', self::FILE_NAME)); + + return; + } + } + + /** @var SaferPayTransactionProcessedGuard $processedGuard */ + $processedGuard = $this->module->getService(SaferPayTransactionProcessedGuard::class); + + if ($processedGuard->isProcessed($cartId)) { + $logger->debug(sprintf('%s - Payment already processed, skipping assert', self::FILE_NAME)); + + return; + } + /** @var SaferPayTransactionAssertion $transactionAssert */ $transactionAssert = $this->module->getService(SaferPayTransactionAssertion::class); + /** @var CardAliasRegistrationGuard $aliasRegistrationGuard */ + $aliasRegistrationGuard = $this->module->getService(CardAliasRegistrationGuard::class); + $assertResponseBody = null; $transactionStatus = null; try { $assertResponseBody = $transactionAssert->assert( $cartId, - (int) $selectedCard === SaferPayConfig::CREDIT_CARD_OPTION_SAVE, + $aliasRegistrationGuard->shouldRegister($selectedCard), $selectedCard, (int) Tools::getValue(SaferPayConfig::IS_BUSINESS_LICENCE) ); @@ -112,7 +145,13 @@ public function postProcess() /** @var PaymentTypeProvider $paymentTypeProvider */ $paymentTypeProvider = $this->module->getService(PaymentTypeProvider::class); - if ($paymentTypeProvider->get($orderPayment) === PaymentType::HOSTED_IFRAME) { + $paymentType = $paymentTypeProvider->getForReturn( + $orderPayment, + Tools::getValue('fieldToken'), + (int) $selectedCard > 0 + ); + + if ($paymentType === PaymentType::HOSTED_IFRAME) { $order = new Order(Order::getIdByCartId($cartId)); try { @@ -326,8 +365,11 @@ private function createAndValidateOrder($assertResponseBody, $transactionStatus, $order = new Order($orderId); $paymentBehaviorWithout3D = (int) Configuration::get(SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D); + // $order->payment holds the checkout option's name, which is "Cards" for the grouped + // option and never matches a brand, silently skipping the whole without-3DS behaviour. + // The brand Saferpay asserted is what this setting is about. if (!$assertResponseBody->getLiability()->getLiabilityShift() && - in_array($order->payment, SaferPayConfig::SUPPORTED_3DS_PAYMENT_METHODS) + in_array($orderPayment, SaferPayConfig::SUPPORTED_3DS_PAYMENT_METHODS) ) { /** @var SaferPayOrderStatusService $orderStatusService */ $orderStatusService = $this->module->getService(SaferPayOrderStatusService::class); diff --git a/saferpayofficial.php b/saferpayofficial.php index e5e44717..b7cad1eb 100644 --- a/saferpayofficial.php +++ b/saferpayofficial.php @@ -25,6 +25,7 @@ use Invertus\SaferPay\Presentation\Loader\PaymentFormAssetLoader; use Invertus\SaferPay\Presenter\AdminOrderPagePresenter; use Invertus\SaferPay\Presenter\AssertPresenter; +use Invertus\SaferPay\Provider\EnabledCardBrandsProvider; use Invertus\SaferPay\Provider\PaymentRedirectionProvider; use Invertus\SaferPay\Repository\SaferPayCardAliasRepository; use Invertus\SaferPay\Repository\SaferPayOrderRepository; @@ -261,6 +262,8 @@ public function hookPaymentOptions($params) $paymentRedirectionProvider = $this->getService(PaymentRedirectionProvider::class); /** @var LegacyTranslator $translator */ $translator = $this->getService(LegacyTranslator::class); + /** @var EnabledCardBrandsProvider $enabledCardBrandsProvider */ + $enabledCardBrandsProvider = $this->getService(EnabledCardBrandsProvider::class); $isBusinessLicenseEnabled = Configuration::get(SaferPayConfig::BUSINESS_LICENSE . SaferPayConfig::getConfigSuffix()); $isCreditCardSavingEnabled = Configuration::get(SaferPayConfig::CREDIT_CARD_SAVE); @@ -289,7 +292,7 @@ public function hookPaymentOptions($params) $isCreditCard = in_array( $paymentMethod['paymentMethod'], SaferPayConfig::TRANSACTION_METHODS - ); + ) || $paymentMethod['paymentMethod'] === SaferPayConfig::PAYMENT_CARDS; $selectedCard = 0; $isCreditCardSavingEnabledForUser = $isCreditCardSavingEnabled; @@ -319,9 +322,15 @@ public function hookPaymentOptions($params) if ($isCreditCardSavingEnabledForUser && $isCreditCard && $isBusinessLicenseEnabled) { $currentDate = date('Y-m-d h:i:s'); - $savedCards = $cardAliasRepository->getSavedValidCardsByUserIdAndPaymentMethod( + // Aliases are stored under the brand Saferpay reported, so the grouped "Cards" + // option has to look up every brand it stands for, not its own "CARD" name. + $savedCardBrands = $paymentMethod['paymentMethod'] === SaferPayConfig::PAYMENT_CARDS + ? $enabledCardBrandsProvider->get() + : [$paymentMethod['paymentMethod']]; + + $savedCards = $cardAliasRepository->getSavedValidCardsByUserIdAndPaymentMethods( $this->context->customer->id, - $paymentMethod['paymentMethod'], + $savedCardBrands, $currentDate ); @@ -329,6 +338,7 @@ public function hookPaymentOptions($params) [ 'savedCards' => $savedCards, 'paymentMethod' => $paymentMethod['paymentMethod'], + 'showSavedCardBrand' => $paymentMethod['paymentMethod'] === SaferPayConfig::PAYMENT_CARDS, ] ); diff --git a/src/Api/Request/AssertService.php b/src/Api/Request/AssertService.php index c18949b8..e2ff6852 100644 --- a/src/Api/Request/AssertService.php +++ b/src/Api/Request/AssertService.php @@ -25,7 +25,6 @@ use Exception; use Invertus\SaferPay\Api\ApiRequest; -use Invertus\SaferPay\Config\SaferPayConfig; use Invertus\SaferPay\DTO\Request\Assert\AssertRequest; use Invertus\SaferPay\DTO\Response\Assert\AssertBody; use Invertus\SaferPay\EntityBuilder\SaferPayAssertBuilder; @@ -101,18 +100,19 @@ public function assert(AssertRequest $assertRequest, $isBusiness) * @param object|null $responseBody * @param int $saferPayOrderId * @param string $customerId - * @param int $selectedCardOption + * @param bool $saveCard whether an alias was requested from Saferpay for this transaction * * @return AssertBody * @throws Exception */ - public function createObjectsFromAssertResponse($responseBody, $saferPayOrderId, $customerId, $selectedCardOption) + public function createObjectsFromAssertResponse($responseBody, $saferPayOrderId, $customerId, $saveCard) { $assertBody = $this->assertResponseObjectCreator->createAssertObject($responseBody); $this->assertBuilder->createAssert($assertBody, $saferPayOrderId); $isPaymentSafe = $assertBody->getLiability()->getLiabilityShift(); - if ((int) $selectedCardOption === SaferPayConfig::CREDIT_CARD_OPTION_SAVE && $isPaymentSafe) { + // Storing a card alias is only possible when one was asked for, the response carries none otherwise. + if ($saveCard && $isPaymentSafe) { $this->aliasBuilder->createCardAlias($assertBody, $customerId); } diff --git a/src/Config/SaferPayConfig.php b/src/Config/SaferPayConfig.php index 9b36ad36..ec8e8cce 100644 --- a/src/Config/SaferPayConfig.php +++ b/src/Config/SaferPayConfig.php @@ -167,6 +167,7 @@ class SaferPayConfig ]; const FIELD_SUPPORTED_PAYMENT_METHODS = [ + self::PAYMENT_AMEX, self::PAYMENT_VISA, self::PAYMENT_VPAY, self::PAYMENT_MASTERCARD, @@ -299,6 +300,7 @@ class SaferPayConfig const SAFERPAY_GROUP_CARDS = 'SAFERPAY_GROUP_CARDS'; const SAFERPAY_GROUP_CARDS_LOGO = 'SAFERPAY_GROUP_CARDS_LOGO'; + const SAFERPAY_USE_FIELDS = 'SAFERPAY_USE_FIELDS'; /** * Card brands that can be grouped under 'Cards' method */ @@ -314,6 +316,21 @@ class SaferPayConfig self::PAYMENT_BANCONTACT, ]; + /** + * Brand names the Saferpay Fields SDK accepts in its paymentMethods option, which are not the + * module's own constants. VPAY and MYONE have no SDK equivalent, so an option covering either + * cannot be restricted in the browser at all. + */ + public const FIELDS_SDK_BRANDS = [ + self::PAYMENT_AMEX => 'amex', + self::PAYMENT_BANCONTACT => 'bancontact', + self::PAYMENT_DINERS => 'diners', + self::PAYMENT_JCB => 'jcb', + self::PAYMENT_MAESTRO => 'maestro', + self::PAYMENT_MASTERCARD => 'mastercard', + self::PAYMENT_VISA => 'visa', + ]; + public static function supportsOrderCapture($paymentMethod) { //payments that DOES NOT SUPPORT capture @@ -445,7 +462,8 @@ public static function getDefaultConfiguration() self::SAFERPAY_PAYMENT_AWAITING ), self::SAFERPAY_SEND_ORDER_CONF_MAIL => 0, - self::SAFERPAY_GROUP_CARDS => 0, + self::SAFERPAY_GROUP_CARDS => 1, + self::SAFERPAY_USE_FIELDS => 1, ]; } @@ -480,6 +498,7 @@ public static function getUninstallConfiguration() self::SAFERPAY_ORDER_ID_OPTION, self::SAFERPAY_SEND_ORDER_CONF_MAIL, self::SAFERPAY_GROUP_CARDS, + self::SAFERPAY_USE_FIELDS, ]; } diff --git a/src/DTO/Request/Initialize/InitializeRequest.php b/src/DTO/Request/Initialize/InitializeRequest.php index 17cae436..2da46ba7 100644 --- a/src/DTO/Request/Initialize/InitializeRequest.php +++ b/src/DTO/Request/Initialize/InitializeRequest.php @@ -117,6 +117,11 @@ class InitializeRequest implements SaferPayRequestInterface */ private $fieldToken; + /** + * @var array + */ + private $paymentMethods; + public function __construct( RequestHeader $requestHeader, $terminalId, @@ -132,7 +137,8 @@ public function __construct( $alias, Order $order, PayerProfile $payerProfile, - $fieldToken + $fieldToken, + array $paymentMethods = [] ) { $this->requestHeader = $requestHeader; $this->terminalId = $terminalId; @@ -149,6 +155,7 @@ public function __construct( $this->order = $order; $this->payerProfile = $payerProfile; $this->fieldToken = $fieldToken; + $this->paymentMethods = $paymentMethods; } public function getAsArray() @@ -166,9 +173,7 @@ public function getAsArray() 'ClientInfo' => $this->requestHeader->getClientInfo(), ], 'TerminalId' => $this->terminalId, - 'PaymentMethods' => [ - $this->paymentMethod, - ], + 'PaymentMethods' => $this->getPaymentMethods(), 'Payment' => [ 'Amount' => [ 'Value' => $this->payment->getValue(), @@ -268,6 +273,22 @@ public function getAsArray() return $return; } + /** + * The grouped "Cards" option is sent to Saferpay as the brands the merchant actually enabled. + * Sending its own "CARD" value instead would let Saferpay offer every brand the terminal + * supports, including ones switched off in the back office. + * + * @return array + */ + private function getPaymentMethods() + { + if (!empty($this->paymentMethods)) { + return $this->paymentMethods; + } + + return [$this->paymentMethod]; + } + /** * @return array */ diff --git a/src/Presentation/Loader/PaymentFormAssetLoader.php b/src/Presentation/Loader/PaymentFormAssetLoader.php index f4a517ec..73c6d21f 100644 --- a/src/Presentation/Loader/PaymentFormAssetLoader.php +++ b/src/Presentation/Loader/PaymentFormAssetLoader.php @@ -28,6 +28,7 @@ use Invertus\SaferPay\Enum\ControllerName; use Invertus\SaferPay\Enum\PaymentType; use Invertus\SaferPay\Factory\ModuleFactory; +use Invertus\SaferPay\Provider\EnabledCardBrandsProvider; use Invertus\SaferPay\Provider\OpcModulesProvider; use Invertus\SaferPay\Service\SaferPayErrorDisplayService; use Media; @@ -46,12 +47,19 @@ class PaymentFormAssetLoader private $context; /** @var OpcModulesProvider $opcModuleProvider */ private $opcModulesProvider; + /** @var EnabledCardBrandsProvider */ + private $enabledCardBrandsProvider; - public function __construct(ModuleFactory $module, LegacyContext $context, OpcModulesProvider $opcModulesProvider) - { + public function __construct( + ModuleFactory $module, + LegacyContext $context, + OpcModulesProvider $opcModulesProvider, + EnabledCardBrandsProvider $enabledCardBrandsProvider + ) { $this->module = $module->getModule(); $this->context = $context; $this->opcModulesProvider = $opcModulesProvider; + $this->enabledCardBrandsProvider = $enabledCardBrandsProvider; } public function register($controller) @@ -173,6 +181,10 @@ private function registerInlineFieldsAssets($controller) return; } + if (!\Configuration::get(SaferPayConfig::SAFERPAY_USE_FIELDS)) { + return; + } + if (!SaferPayConfig::getFieldAccessToken()) { return; } @@ -186,6 +198,7 @@ private function registerInlineFieldsAssets($controller) 'saferpay_field_label_cardnumber' => $this->module->l('Card number', 'PaymentFormAssetLoader'), 'saferpay_field_label_expiration' => $this->module->l('Expiry date', 'PaymentFormAssetLoader'), 'saferpay_field_label_cvc' => $this->module->l('CVC', 'PaymentFormAssetLoader'), + 'saferpay_field_payment_methods' => $this->getFieldPaymentMethods(), ]); $controller->registerJavascript( @@ -201,6 +214,58 @@ private function registerInlineFieldsAssets($controller) ); } + /** + * Which card brands the Fields form may accept, keyed by the checkout option that renders it. + * A brand typed into an option that does not list it is rejected by the SDK before submit, + * which is what stops a Mastercard being paid under a Visa-only option. + * + * @return array + */ + private function getFieldPaymentMethods() + { + $enabledBrands = $this->enabledCardBrandsProvider->get(); + $methods = []; + + foreach ($enabledBrands as $brand) { + if (!isset(SaferPayConfig::FIELDS_SDK_BRANDS[$brand])) { + continue; + } + + $methods[$brand] = [SaferPayConfig::FIELDS_SDK_BRANDS[$brand]]; + } + + $groupedBrands = $this->getGroupedFieldBrands($enabledBrands); + + if ($groupedBrands) { + $methods[SaferPayConfig::PAYMENT_CARDS] = $groupedBrands; + } + + return $methods; + } + + /** + * Empty as soon as one enabled brand has no SDK equivalent: a partial allowlist would decline + * a card Saferpay itself accepts, so the grouped option is left unrestricted instead. + * + * @param array $enabledBrands + * + * @return array + */ + private function getGroupedFieldBrands(array $enabledBrands) + { + $brands = []; + + foreach ($enabledBrands as $brand) { + if (!isset(SaferPayConfig::FIELDS_SDK_BRANDS[$brand])) { + return []; + } + + $brands[] = SaferPayConfig::FIELDS_SDK_BRANDS[$brand]; + } + + return $brands; + } + public function registerErrorBags() { /** @var SaferPayErrorDisplayService $errorDisplayService */ diff --git a/src/Provider/EnabledCardBrandsProvider.php b/src/Provider/EnabledCardBrandsProvider.php new file mode 100644 index 00000000..2d05571d --- /dev/null +++ b/src/Provider/EnabledCardBrandsProvider.php @@ -0,0 +1,65 @@ + + *@copyright SIX Payment Services + *@license SIX Payment Services + */ + +namespace Invertus\SaferPay\Provider; + +use Invertus\SaferPay\Config\SaferPayConfig; +use Invertus\SaferPay\Service\PaymentRestrictionValidation; + +if (!defined('_PS_VERSION_')) { + exit; +} + +class EnabledCardBrandsProvider +{ + /** + * @var PaymentRestrictionValidation + */ + private $paymentRestrictionValidation; + + public function __construct(PaymentRestrictionValidation $paymentRestrictionValidation) + { + $this->paymentRestrictionValidation = $paymentRestrictionValidation; + } + + /** + * Card brands the shop can actually be paid with right now: enabled in the back office and + * passing the country and currency restrictions for the current context. + * + * @return array + */ + public function get(): array + { + $brands = []; + + foreach (SaferPayConfig::CARD_BRANDS as $brand) { + if (!$this->paymentRestrictionValidation->isPaymentMethodValid($brand)) { + continue; + } + + $brands[] = $brand; + } + + return $brands; + } +} diff --git a/src/Provider/PaymentTypeProvider.php b/src/Provider/PaymentTypeProvider.php index 7cd7df74..cb80dbe3 100644 --- a/src/Provider/PaymentTypeProvider.php +++ b/src/Provider/PaymentTypeProvider.php @@ -23,9 +23,9 @@ namespace Invertus\SaferPay\Provider; +use Invertus\SaferPay\Adapter\Configuration; use Invertus\SaferPay\Config\SaferPayConfig; use Invertus\SaferPay\Enum\PaymentType; -use Invertus\SaferPay\Repository\SaferPayFieldRepository; if (!defined('_PS_VERSION_')) { exit; @@ -33,13 +33,12 @@ class PaymentTypeProvider { - /** @var SaferPayFieldRepository */ - private $saferPayFieldRepository; + /** @var Configuration */ + private $configuration; - public function __construct( - SaferPayFieldRepository $saferPayFieldRepository - ) { - $this->saferPayFieldRepository = $saferPayFieldRepository; + public function __construct(Configuration $configuration) + { + $this->configuration = $configuration; } /** @@ -48,10 +47,10 @@ public function __construct( */ public function get(string $paymentMethod): string { - // Custom Form ON (Saferpay Fields, Business licence) => Saferpay Fields. - // Anything else (Custom Form OFF, non-Business) => Saferpay Payment Page. + // Saferpay Fields prerequisites met => inline card form (Saferpay Fields). + // Anything else => Saferpay Payment Page (redirect). // The legacy Transaction Interface (IFRAME) is no longer selectable (SL-374). - if ($this->isHostedIframeRedirect($paymentMethod)) { + if ($this->isSaferPayFieldsPayment($paymentMethod)) { return PaymentType::HOSTED_IFRAME; } @@ -59,27 +58,65 @@ public function get(string $paymentMethod): string } /** + * Resolves the flow on the return leg from how the payment was initialized, not from the brand + * Saferpay reports back. A field token means the shopper paid through Saferpay Fields, whatever + * card they ended up typing into it. + * + * @param string $paymentMethod + * @param string|null $fieldToken + * @param bool $usingSavedCard + * + * @return string + */ + public function getForReturn(string $paymentMethod, $fieldToken = null, bool $usingSavedCard = false): string + { + if (!empty($fieldToken) || $usingSavedCard) { + return PaymentType::HOSTED_IFRAME; + } + + return $this->get($paymentMethod); + } + + /** + * Card payments use Saferpay Fields only when every prerequisite holds: a Business licence, + * the "Use Saferpay Fields" setting, and a Fields access token. A missing prerequisite falls + * back to the Payment Page, so the checkout never offers a card form it cannot render. + * * @param string $paymentMethod * @return bool */ - private function isHostedIframeRedirect(string $paymentMethod): bool + private function isSaferPayFieldsPayment(string $paymentMethod): bool { - if (!\Configuration::get(SaferPayConfig::BUSINESS_LICENSE . SaferPayConfig::getConfigSuffix())) { + if (!$this->isCardPaymentMethod($paymentMethod)) { return false; } - // Grouped cards render a single inline Fields form under the "Cards" option. - if ($paymentMethod === SaferPayConfig::PAYMENT_CARDS - && \Configuration::get(SaferPayConfig::SAFERPAY_GROUP_CARDS) - ) { - return true; + $suffix = $this->configuration->getAsBoolean(SaferPayConfig::TEST_MODE) + ? SaferPayConfig::TEST_SUFFIX + : ''; + + if (!$this->configuration->getAsBoolean(SaferPayConfig::BUSINESS_LICENSE . $suffix)) { + return false; + } + + if (!$this->configuration->getAsBoolean(SaferPayConfig::SAFERPAY_USE_FIELDS)) { + return false; } - // Individual cards use Fields when their "Saferpay Fields" toggle is on. - if (!$this->saferPayFieldRepository->isActiveByName($paymentMethod)) { + if (empty($this->configuration->get(SaferPayConfig::FIELDS_ACCESS_TOKEN . $suffix))) { return false; } return true; } + + /** + * @param string $paymentMethod + * @return bool + */ + private function isCardPaymentMethod(string $paymentMethod): bool + { + return $paymentMethod === SaferPayConfig::PAYMENT_CARDS + || in_array($paymentMethod, SaferPayConfig::FIELD_SUPPORTED_PAYMENT_METHODS, true); + } } diff --git a/src/Repository/SaferPayCardAliasRepository.php b/src/Repository/SaferPayCardAliasRepository.php index 70fb0da7..1c1a3117 100644 --- a/src/Repository/SaferPayCardAliasRepository.php +++ b/src/Repository/SaferPayCardAliasRepository.php @@ -32,13 +32,32 @@ class SaferPayCardAliasRepository { - public function getSavedValidCardsByUserIdAndPaymentMethod($userId, $paymentMethod, $currentDate) + /** + * The grouped "Cards" option covers several brands at once, and an alias is always stored + * under the brand Saferpay reported, never under "CARD". The brand comes back with the row so + * the checkout can tell two saved cards of different brands apart. + * + * @param int $userId + * @param array $paymentMethods + * @param string $currentDate + * + * @return array + */ + public function getSavedValidCardsByUserIdAndPaymentMethods($userId, array $paymentMethods, $currentDate) { + if (empty($paymentMethods)) { + return []; + } + + $escapedMethods = array_map(function ($paymentMethod) { + return '"' . pSQL($paymentMethod) . '"'; + }, $paymentMethods); + $query = new DbQuery(); - $query->select('`id_saferpay_card_alias`, `card_number`'); + $query->select('`id_saferpay_card_alias`, `card_number`, `payment_method`'); $query->from('saferpay_card_alias'); $query->where('id_customer = ' . (int) $userId); - $query->where('payment_method = "' . pSQL($paymentMethod) . '"'); + $query->where('payment_method IN (' . implode(', ', $escapedMethods) . ')'); $query->where('valid_till > "' . pSQL($currentDate) . '"'); return Db::getInstance()->executeS($query); diff --git a/src/Service/CardAliasRegistrationGuard.php b/src/Service/CardAliasRegistrationGuard.php new file mode 100644 index 00000000..95e5de46 --- /dev/null +++ b/src/Service/CardAliasRegistrationGuard.php @@ -0,0 +1,70 @@ + + *@copyright SIX Payment Services + *@license SIX Payment Services + */ + +namespace Invertus\SaferPay\Service; + +use Invertus\SaferPay\Adapter\Configuration; +use Invertus\SaferPay\Config\SaferPayConfig; + +if (!defined('_PS_VERSION_')) { + exit; +} + +/** + * Asking Saferpay to register an alias only makes sense when the shop can offer that card back to + * the shopper later. The conditions below mirror the ones the checkout uses to display saved cards + * in SaferPayOfficial::hookPaymentOptions, so the shop never tokenises a card it cannot reuse. + */ +class CardAliasRegistrationGuard +{ + /** @var Configuration */ + private $configuration; + + public function __construct(Configuration $configuration) + { + $this->configuration = $configuration; + } + + /** + * @param mixed $selectedCard value posted by the checkout, 0 means the shopper opted in + * + * @return bool + */ + public function shouldRegister($selectedCard): bool + { + if ((int) $selectedCard !== SaferPayConfig::CREDIT_CARD_OPTION_SAVE) { + return false; + } + + if (!$this->configuration->getAsBoolean(SaferPayConfig::CREDIT_CARD_SAVE)) { + return false; + } + + // Saved cards are re-used through an alias transaction, which requires a business licence. + if (!$this->configuration->getAsBoolean(SaferPayConfig::BUSINESS_LICENSE . SaferPayConfig::getConfigSuffix())) { + return false; + } + + return true; + } +} diff --git a/src/Service/CardPaymentGroupingService.php b/src/Service/CardPaymentGroupingService.php index 66b242de..39c92340 100644 --- a/src/Service/CardPaymentGroupingService.php +++ b/src/Service/CardPaymentGroupingService.php @@ -63,10 +63,38 @@ public function group(array $paymentMethods, array $allCurrencies): array $result[] = [ 'paymentMethod' => SaferPayConfig::PAYMENT_CARDS, 'logoUrl' => _PS_BASE_URL_SSL_ . $this->module->getPathUri() . 'views/img/' . SaferPayConfig::PAYMENT_CARDS . '.png', - 'currencies' => $allCurrencies, + 'currencies' => $this->mergeCurrencies($cardMethods, $allCurrencies), ]; } return $result; } + + /** + * The grouped option may only offer the currencies its own card brands support. Handing it every + * shop currency would show the Cards option in a currency no enabled card can be paid in. + * + * @param array $cardMethods + * @param array $allCurrencies + * + * @return array + */ + private function mergeCurrencies(array $cardMethods, array $allCurrencies): array + { + $currencies = []; + + foreach ($cardMethods as $method) { + if (empty($method['currencies'])) { + continue; + } + + $currencies = array_merge($currencies, (array) $method['currencies']); + } + + if (empty($currencies)) { + return $allCurrencies; + } + + return array_values(array_unique($currencies)); + } } diff --git a/src/Service/PaymentRestrictionValidation/BasePaymentRestrictionValidation.php b/src/Service/PaymentRestrictionValidation/BasePaymentRestrictionValidation.php index f664ae2b..599b4761 100644 --- a/src/Service/PaymentRestrictionValidation/BasePaymentRestrictionValidation.php +++ b/src/Service/PaymentRestrictionValidation/BasePaymentRestrictionValidation.php @@ -76,7 +76,7 @@ public function __construct( public function isValid(string $paymentName): bool { if ($paymentName === SaferPayConfig::PAYMENT_CARDS) { - return true; + return $this->hasAnyEnabledCardBrand(); } if (!$this->isPaymentMethodEnabled($paymentName)) { @@ -104,6 +104,33 @@ public function supports(string $paymentName): bool return true; } + /** + * The grouped "Cards" option carries no restrictions of its own. It is payable exactly when at + * least one of the brands behind it is enabled and passes the country and currency checks. + * + * @return bool + */ + private function hasAnyEnabledCardBrand() + { + foreach (SaferPayConfig::CARD_BRANDS as $brand) { + if (!$this->isPaymentMethodEnabled($brand)) { + continue; + } + + if (!$this->isCountrySupportedByPaymentName($brand)) { + continue; + } + + if (!$this->isCurrencySupportedByPaymentName($brand)) { + continue; + } + + return true; + } + + return false; + } + /** * @param string $paymentName * diff --git a/src/Service/Request/InitializeRequestObjectCreator.php b/src/Service/Request/InitializeRequestObjectCreator.php index 4278d9ed..e3beaef4 100644 --- a/src/Service/Request/InitializeRequestObjectCreator.php +++ b/src/Service/Request/InitializeRequestObjectCreator.php @@ -29,6 +29,7 @@ use Invertus\SaferPay\Config\SaferPayConfig; use Invertus\SaferPay\DTO\Request\Initialize\InitializeRequest; use Invertus\SaferPay\DTO\Request\Payer; +use Invertus\SaferPay\Provider\EnabledCardBrandsProvider; if (!defined('_PS_VERSION_')) { exit; @@ -41,9 +42,17 @@ class InitializeRequestObjectCreator */ private $requestObjectCreator; - public function __construct(RequestObjectCreator $requestObjectCreator) - { + /** + * @var EnabledCardBrandsProvider + */ + private $enabledCardBrandsProvider; + + public function __construct( + RequestObjectCreator $requestObjectCreator, + EnabledCardBrandsProvider $enabledCardBrandsProvider + ) { $this->requestObjectCreator = $requestObjectCreator; + $this->enabledCardBrandsProvider = $enabledCardBrandsProvider; } public function create( @@ -104,7 +113,8 @@ public function create( $alias, $order, $payerProfile, - $fieldToken + $fieldToken, + $paymentMethod === SaferPayConfig::PAYMENT_CARDS ? $this->enabledCardBrandsProvider->get() : [] ); } } diff --git a/src/Service/SettingsTranslationService.php b/src/Service/SettingsTranslationService.php index 3a68bbf1..3f47e25b 100644 --- a/src/Service/SettingsTranslationService.php +++ b/src/Service/SettingsTranslationService.php @@ -160,7 +160,6 @@ private function getPaymentMethodsTranslations() 'paymentMethod' => $this->module->l('Payment method', self::FILE_NAME), 'enabled' => $this->module->l('Enabled', self::FILE_NAME), 'logos' => $this->module->l('Logos', self::FILE_NAME), - 'customForm' => $this->module->l('Saferpay Fields', self::FILE_NAME), 'countries' => $this->module->l('Countries', self::FILE_NAME), 'currencies' => $this->module->l('Currencies', self::FILE_NAME), 'selectCountries' => $this->module->l('Select countries', self::FILE_NAME), @@ -205,6 +204,9 @@ private function getPaymentProcessingTranslations() 'showCardsLogoDescription' => $this->module->l('If enabled, a logo for the grouped \'Cards\' payment method will be displayed at checkout.', self::FILE_NAME), 'creditCardSaving' => $this->module->l('Credit card saving for customers', self::FILE_NAME), 'creditCardSavingDescription' => $this->module->l('Allow customers to save credit card for faster purchase.', self::FILE_NAME), + 'useFieldsLabel' => $this->module->l('Use Saferpay Fields', self::FILE_NAME), + 'useFieldsDescription' => $this->module->l('When disabled, card payments use the Saferpay Payment Page (redirect).', self::FILE_NAME), + 'fieldsTokenMissingWarning' => $this->module->l('Saferpay Fields access token is missing. Card payments are using the Payment Page. Generate the token under API credentials.', self::FILE_NAME), ]; } diff --git a/src/Service/TransactionFlow/SaferPayTransactionAssertion.php b/src/Service/TransactionFlow/SaferPayTransactionAssertion.php index 22732b10..f3b4294b 100644 --- a/src/Service/TransactionFlow/SaferPayTransactionAssertion.php +++ b/src/Service/TransactionFlow/SaferPayTransactionAssertion.php @@ -113,7 +113,7 @@ public function assert($cartId, $saveCard = null, $selectedCard = null, $isBusin $assertResponse, $saferPayOrder->id, $cart->id_customer, - $selectedCard + (bool) $saveCard ); // assertion shouldn't update, this is quickfix for what seems to be a general flaw in structure diff --git a/src/Service/TransactionFlow/SaferPayTransactionProcessedGuard.php b/src/Service/TransactionFlow/SaferPayTransactionProcessedGuard.php new file mode 100644 index 00000000..2955c2d2 --- /dev/null +++ b/src/Service/TransactionFlow/SaferPayTransactionProcessedGuard.php @@ -0,0 +1,68 @@ + + *@copyright SIX Payment Services + *@license SIX Payment Services + */ + +namespace Invertus\SaferPay\Service\TransactionFlow; + +use Invertus\SaferPay\Repository\SaferPayOrderRepository; +use SaferPayOrder; + +if (!defined('_PS_VERSION_')) { + exit; +} + +/** + * Saferpay requires a transaction to be marked as processed once a definite authorization response + * has been received, so the shop never authorizes the same transaction twice. The saferpay_order + * row carries that mark already, this only reads it before the assert is attempted again. + */ +class SaferPayTransactionProcessedGuard +{ + /** @var SaferPayOrderRepository */ + private $saferPayOrderRepository; + + public function __construct(SaferPayOrderRepository $saferPayOrderRepository) + { + $this->saferPayOrderRepository = $saferPayOrderRepository; + } + + /** + * A pending transaction is deliberately not treated as processed. Pending is not a definite + * response, the notification is still expected to settle it. + * + * @param int $cartId + * + * @return bool + */ + public function isProcessed(int $cartId): bool + { + $saferPayOrderId = (int) $this->saferPayOrderRepository->getIdByCartId($cartId); + + if (!$saferPayOrderId) { + return false; + } + + $saferPayOrder = new SaferPayOrder($saferPayOrderId); + + return (bool) $saferPayOrder->authorized || (bool) $saferPayOrder->captured; + } +} diff --git a/tests/Unit/Provider/EnabledCardBrandsProviderTest.php b/tests/Unit/Provider/EnabledCardBrandsProviderTest.php new file mode 100644 index 00000000..b0da72a4 --- /dev/null +++ b/tests/Unit/Provider/EnabledCardBrandsProviderTest.php @@ -0,0 +1,72 @@ + + *@copyright SIX Payment Services + *@license SIX Payment Services + */ + +namespace Invertus\SaferPay\Tests\Unit\Provider; + +use Invertus\SaferPay\Config\SaferPayConfig; +use Invertus\SaferPay\Provider\EnabledCardBrandsProvider; +use Invertus\SaferPay\Service\PaymentRestrictionValidation; +use Invertus\SaferPay\Tests\Unit\Tools\UnitTestCase; + +class EnabledCardBrandsProviderTest extends UnitTestCase +{ + public function testItReturnsEveryBrandWhenNoneIsRestricted() + { + $provider = new EnabledCardBrandsProvider($this->mockValidation(SaferPayConfig::CARD_BRANDS)); + + $this->assertEquals(SaferPayConfig::CARD_BRANDS, $provider->get()); + } + + public function testItSkipsRestrictedBrandsAndKeepsTheConfiguredOrder() + { + $validBrands = [SaferPayConfig::PAYMENT_MASTERCARD, SaferPayConfig::PAYMENT_VISA]; + + $provider = new EnabledCardBrandsProvider($this->mockValidation($validBrands)); + + $this->assertEquals($validBrands, $provider->get()); + } + + public function testItReturnsNothingWhenEveryBrandIsRestricted() + { + $provider = new EnabledCardBrandsProvider($this->mockValidation([])); + + $this->assertEquals([], $provider->get()); + } + + private function mockValidation(array $validBrands) + { + $validationMock = $this + ->getMockBuilder(PaymentRestrictionValidation::class) + ->disableOriginalConstructor() + ->getMock(); + + $validationMock + ->method('isPaymentMethodValid') + ->willReturnCallback(function ($paymentMethod) use ($validBrands) { + return in_array($paymentMethod, $validBrands, true); + }) + ; + + return $validationMock; + } +} diff --git a/tests/Unit/Provider/PaymentTypeProviderTest.php b/tests/Unit/Provider/PaymentTypeProviderTest.php new file mode 100644 index 00000000..95e6f989 --- /dev/null +++ b/tests/Unit/Provider/PaymentTypeProviderTest.php @@ -0,0 +1,189 @@ + + *@copyright SIX Payment Services + *@license SIX Payment Services + */ + +namespace Invertus\SaferPay\Tests\Unit\Provider; + +use Invertus\SaferPay\Adapter\Configuration; +use Invertus\SaferPay\Config\SaferPayConfig; +use Invertus\SaferPay\Enum\PaymentType; +use Invertus\SaferPay\Provider\PaymentTypeProvider; +use Invertus\SaferPay\Tests\Unit\Tools\UnitTestCase; + +class PaymentTypeProviderTest extends UnitTestCase +{ + public function testItResolvesFieldsFromTheFieldTokenWhateverBrandCameBack() + { + $provider = $this->makeProvider($this->fieldsDisabledConfiguration()); + + $this->assertEquals( + PaymentType::HOSTED_IFRAME, + $provider->getForReturn(SaferPayConfig::PAYMENT_MASTERCARD, 'field-token', false) + ); + } + + public function testItResolvesFieldsForASavedCard() + { + $provider = $this->makeProvider($this->fieldsDisabledConfiguration()); + + $this->assertEquals( + PaymentType::HOSTED_IFRAME, + $provider->getForReturn(SaferPayConfig::PAYMENT_CARDS, null, true) + ); + } + + public function testItFallsBackToTheConfiguredModeWhenNeitherIsPresent() + { + $provider = $this->makeProvider($this->fieldsDisabledConfiguration()); + + $this->assertEquals( + PaymentType::BASIC, + $provider->getForReturn(SaferPayConfig::PAYMENT_VISA, null, false) + ); + } + + public function testItResolvesFieldsForTheGroupedCardsOption() + { + $provider = $this->makeProvider($this->fieldsEnabledConfiguration()); + + $this->assertEquals( + PaymentType::HOSTED_IFRAME, + $provider->get(SaferPayConfig::PAYMENT_CARDS) + ); + } + + public function testItResolvesFieldsForAnIndividualCardBrand() + { + $provider = $this->makeProvider($this->fieldsEnabledConfiguration()); + + $this->assertEquals( + PaymentType::HOSTED_IFRAME, + $provider->get(SaferPayConfig::PAYMENT_VISA) + ); + } + + public function testANonCardMethodNeverUsesFields() + { + $provider = $this->makeProvider($this->fieldsEnabledConfiguration()); + + $this->assertEquals( + PaymentType::BASIC, + $provider->get(SaferPayConfig::PAYMENT_TWINT) + ); + } + + public function testItFallsBackToThePaymentPageWithoutABusinessLicense() + { + $provider = $this->makeProvider($this->fieldsEnabledConfiguration([ + SaferPayConfig::BUSINESS_LICENSE => null, + ])); + + $this->assertEquals( + PaymentType::BASIC, + $provider->get(SaferPayConfig::PAYMENT_CARDS) + ); + } + + public function testItFallsBackToThePaymentPageWhenFieldsAreTurnedOff() + { + $provider = $this->makeProvider($this->fieldsEnabledConfiguration([ + SaferPayConfig::SAFERPAY_USE_FIELDS => null, + ])); + + $this->assertEquals( + PaymentType::BASIC, + $provider->get(SaferPayConfig::PAYMENT_CARDS) + ); + } + + public function testItFallsBackToThePaymentPageWhenTheAccessTokenIsMissing() + { + $provider = $this->makeProvider($this->fieldsEnabledConfiguration([ + SaferPayConfig::FIELDS_ACCESS_TOKEN => null, + ])); + + $this->assertEquals( + PaymentType::BASIC, + $provider->get(SaferPayConfig::PAYMENT_CARDS) + ); + } + + public function testTestModeReadsTheTestSuffixedConfiguration() + { + $provider = $this->makeProvider($this->makeConfiguration([ + SaferPayConfig::TEST_MODE => '1', + SaferPayConfig::SAFERPAY_USE_FIELDS => '1', + SaferPayConfig::BUSINESS_LICENSE . SaferPayConfig::TEST_SUFFIX => '1', + SaferPayConfig::FIELDS_ACCESS_TOKEN . SaferPayConfig::TEST_SUFFIX => 'test-token', + ])); + + $this->assertEquals( + PaymentType::HOSTED_IFRAME, + $provider->get(SaferPayConfig::PAYMENT_CARDS) + ); + } + + private function makeProvider(Configuration $configuration) + { + return new PaymentTypeProvider($configuration); + } + + private function fieldsEnabledConfiguration(array $overrides = []) + { + return $this->makeConfiguration(array_merge([ + SaferPayConfig::TEST_MODE => null, + SaferPayConfig::BUSINESS_LICENSE => '1', + SaferPayConfig::SAFERPAY_USE_FIELDS => '1', + SaferPayConfig::FIELDS_ACCESS_TOKEN => 'access-token', + ], $overrides)); + } + + private function fieldsDisabledConfiguration() + { + return $this->fieldsEnabledConfiguration([ + SaferPayConfig::SAFERPAY_USE_FIELDS => null, + ]); + } + + private function makeConfiguration(array $values) + { + $configuration = $this + ->getMockBuilder(Configuration::class) + ->disableOriginalConstructor() + ->setMethods(['get', 'getAsBoolean']) + ->getMock(); + + $configuration + ->method('get') + ->willReturnCallback(function ($id) use ($values) { + return isset($values[$id]) ? $values[$id] : null; + }); + + $configuration + ->method('getAsBoolean') + ->willReturnCallback(function ($id) use ($values) { + return !empty($values[$id]); + }); + + return $configuration; + } +} diff --git a/tests/Unit/Provider/index.php b/tests/Unit/Provider/index.php new file mode 100644 index 00000000..ee622726 --- /dev/null +++ b/tests/Unit/Provider/index.php @@ -0,0 +1,31 @@ + + *@copyright SIX Payment Services + *@license SIX Payment Services + */ +header('Expires: Mon, 26 Jul 1997 05:00:00 GMT'); +header('Last-Modified: ' . gmdate('D, d M Y H:i:s') . ' GMT'); + +header('Cache-Control: no-store, no-cache, must-revalidate'); +header('Cache-Control: post-check=0, pre-check=0', false); +header('Pragma: no-cache'); + +header('Location: ../'); +exit; diff --git a/tests/Unit/Service/CardAliasRegistrationGuardTest.php b/tests/Unit/Service/CardAliasRegistrationGuardTest.php new file mode 100644 index 00000000..41f16eaa --- /dev/null +++ b/tests/Unit/Service/CardAliasRegistrationGuardTest.php @@ -0,0 +1,89 @@ + + *@copyright SIX Payment Services + *@license SIX Payment Services + */ + +namespace Invertus\SaferPay\Tests\Unit\Service; + +use Invertus\SaferPay\Adapter\Configuration; +use Invertus\SaferPay\Config\SaferPayConfig; +use Invertus\SaferPay\Service\CardAliasRegistrationGuard; +use Invertus\SaferPay\Tests\Unit\Tools\UnitTestCase; + +class CardAliasRegistrationGuardTest extends UnitTestCase +{ + public function testItRegistersWhenTheShopperOptedIn() + { + $guard = new CardAliasRegistrationGuard($this->mockConfiguration(true, true)); + + $this->assertTrue($guard->shouldRegister(SaferPayConfig::CREDIT_CARD_OPTION_SAVE)); + } + + public function testItDoesNotRegisterWhenTheShopperPaysWithANewCardOnce() + { + $guard = new CardAliasRegistrationGuard($this->mockConfiguration(true, true)); + + $this->assertFalse($guard->shouldRegister(SaferPayConfig::CREDIT_CARD_DONT_OPTION_SAVE)); + } + + public function testItDoesNotRegisterWhenTheShopperPaysWithASavedCard() + { + $guard = new CardAliasRegistrationGuard($this->mockConfiguration(true, true)); + + $this->assertFalse($guard->shouldRegister(7)); + } + + public function testItDoesNotRegisterWhenCardSavingIsDisabled() + { + $guard = new CardAliasRegistrationGuard($this->mockConfiguration(false, true)); + + $this->assertFalse($guard->shouldRegister(SaferPayConfig::CREDIT_CARD_OPTION_SAVE)); + } + + public function testItDoesNotRegisterWithoutABusinessLicence() + { + $guard = new CardAliasRegistrationGuard($this->mockConfiguration(true, false)); + + $this->assertFalse($guard->shouldRegister(SaferPayConfig::CREDIT_CARD_OPTION_SAVE)); + } + + private function mockConfiguration($cardSavingEnabled, $hasBusinessLicence) + { + $configurationMock = $this + ->getMockBuilder(Configuration::class) + ->disableOriginalConstructor() + ->getMock(); + + $configurationMock + ->method('getAsBoolean') + ->willReturnMap([ + [SaferPayConfig::CREDIT_CARD_SAVE, null, $cardSavingEnabled], + [ + SaferPayConfig::BUSINESS_LICENSE . SaferPayConfig::getConfigSuffix(), + null, + $hasBusinessLicence, + ], + ]) + ; + + return $configurationMock; + } +} diff --git a/tests/Unit/Service/PaymentRestrictionValidation/BasePaymentRestrictionValidationTest.php b/tests/Unit/Service/PaymentRestrictionValidation/BasePaymentRestrictionValidationTest.php index 0b87f9fc..63d70ccd 100644 --- a/tests/Unit/Service/PaymentRestrictionValidation/BasePaymentRestrictionValidationTest.php +++ b/tests/Unit/Service/PaymentRestrictionValidation/BasePaymentRestrictionValidationTest.php @@ -24,7 +24,11 @@ namespace Invertus\SaferPay\Tests\Unit\Service\PaymentRestrictionValidation; use Invertus\SaferPay\Config\SaferPayConfig; +use Invertus\SaferPay\Repository\SaferPayPaymentRepository; +use Invertus\SaferPay\Repository\SaferPayRestrictionRepository; use Invertus\SaferPay\Service\PaymentRestrictionValidation\BasePaymentRestrictionValidation; +use Invertus\SaferPay\Service\SaferPayObtainPaymentMethods; +use Invertus\SaferPay\Service\SaferPayRestrictionCreator; use Invertus\SaferPay\Tests\Unit\Tools\UnitTestCase; class BasePaymentRestrictionValidationTest extends UnitTestCase @@ -41,7 +45,8 @@ public function testIsValid( $basePaymentRestrictionValidation = new BasePaymentRestrictionValidation( $this->mockContext('AT', 'AUD'), $this->getPaymentRepositoryMock($paymentName, $paymentResults), - $this->getRestrictionRepositoryMock($paymentName, $restrictionResults) + $this->getRestrictionRepositoryMock($paymentName, $restrictionResults), + $this->getObtainPaymentMethodsMock() ); $this->assertEquals($expectedResult, $basePaymentRestrictionValidation->isValid($paymentName)); } @@ -114,4 +119,95 @@ public function getBasePaymentRestrictionValidationDataProvider() ], ]; } + + /** + * @dataProvider getGroupedCardsDataProvider + */ + public function testItValidatesGroupedCardsThroughTheBrandsBehindThem( + $enabledBrands, + $enabledCountries, + $expectedResult + ) { + $basePaymentRestrictionValidation = new BasePaymentRestrictionValidation( + $this->mockContext('AT', 'AUD'), + $this->getBrandPaymentRepositoryMock($enabledBrands), + $this->getBrandRestrictionRepositoryMock($enabledCountries), + $this->getObtainPaymentMethodsMock() + ); + + $this->assertEquals( + $expectedResult, + $basePaymentRestrictionValidation->isValid(SaferPayConfig::PAYMENT_CARDS) + ); + } + + public function getGroupedCardsDataProvider() + { + return [ + [ + 'enabledBrands' => [SaferPayConfig::PAYMENT_VISA], + 'enabledCountries' => [0], //ALL COUNTRIES + 'expectedResult' => true, + ], + [ + 'enabledBrands' => SaferPayConfig::CARD_BRANDS, + 'enabledCountries' => [0], //ALL COUNTRIES + 'expectedResult' => true, + ], + [ + 'enabledBrands' => [], //EVERY BRAND DISABLED + 'enabledCountries' => [0], //ALL COUNTRIES + 'expectedResult' => false, + ], + [ + 'enabledBrands' => SaferPayConfig::CARD_BRANDS, + 'enabledCountries' => [], //NO COUNTRIES + 'expectedResult' => false, + ], + ]; + } + + private function getBrandPaymentRepositoryMock(array $enabledBrands) + { + $paymentRepositoryMock = $this + ->getMockBuilder(SaferPayPaymentRepository::class) + ->getMock(); + + $paymentRepositoryMock + ->method('isActiveByName') + ->willReturnCallback(function ($paymentName) use ($enabledBrands) { + return in_array($paymentName, $enabledBrands, true); + }) + ; + + return $paymentRepositoryMock; + } + + private function getBrandRestrictionRepositoryMock(array $enabledCountries) + { + $restrictionMock = $this + ->getMockBuilder(SaferPayRestrictionRepository::class) + ->getMock(); + + $restrictionMock + ->method('getSelectedIdsByName') + ->willReturnCallback(function ($paymentName, $restrictionType) use ($enabledCountries) { + if ($restrictionType === SaferPayRestrictionCreator::RESTRICTION_COUNTRY) { + return $enabledCountries; + } + + return [0]; //ALL CURRENCIES + }) + ; + + return $restrictionMock; + } + + private function getObtainPaymentMethodsMock() + { + return $this + ->getMockBuilder(SaferPayObtainPaymentMethods::class) + ->disableOriginalConstructor() + ->getMock(); + } } diff --git a/upgrade/install-2.1.0.php b/upgrade/install-2.1.0.php index 1fbb4f6d..48d28af0 100644 --- a/upgrade/install-2.1.0.php +++ b/upgrade/install-2.1.0.php @@ -25,11 +25,14 @@ exit; } -function upgrade_module_2_1_0() +function upgrade_module_2_1_0(SaferPayOfficial $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_enable_card_grouping(); + saferpayofficial_2_1_0_init_card_form_setting(); + saferpayofficial_2_1_0_generate_fields_access_token($module); Tools::clearSmartyCache(); @@ -154,3 +157,86 @@ function saferpayofficial_2_1_0_delete_removed_configuration() { Configuration::deleteByName('SAFERPAY_HOSTED_FIELDS_TEMPLATE'); } + +/** + * Card brands are no longer offered one by one in the checkout. A shopper who picked a brand and + * then typed a card of another one had the payment approved by Saferpay but no order created, so + * the single "Cards" option becomes the way card payments render. Shops upgrading from an earlier + * version have the setting at 0 and would otherwise keep the per-brand list and the defect with it. + */ +function saferpayofficial_2_1_0_enable_card_grouping() +{ + Configuration::updateValue('SAFERPAY_GROUP_CARDS', 1); +} + +/** + * The per-brand "Saferpay Fields" toggles are replaced by one "Use Saferpay Fields" setting. + * A shop where every toggle was off had deliberately chosen the Payment Page, so the new + * setting starts at 0 there; any active toggle, or a shop that never saw the toggles at all, + * starts on Fields, matching the behaviour of a fresh install. + */ +function saferpayofficial_2_1_0_init_card_form_setting() +{ + $rows = Db::getInstance()->executeS( + 'SELECT `active` FROM `' . _DB_PREFIX_ . 'saferpay_field`' + ); + + $useFields = 1; + + if (!empty($rows) && !in_array('1', array_column($rows, 'active'), false)) { + $useFields = 0; + } + + Configuration::updateValue('SAFERPAY_USE_FIELDS', $useFields); +} + +/** + * Saferpay Fields needs an access token that older versions only created when the merchant + * pressed the Generate button, so most upgraded shops have none and their card options would + * silently fall back to the Payment Page. The token is generated here from the stored + * credentials; a failure is only logged because the runtime fallback and the back office + * warning already cover a shop without a token. + */ +function saferpayofficial_2_1_0_generate_fields_access_token(SaferPayOfficial $module) +{ + foreach (['' => false, '_TEST' => true] as $suffix => $isTestMode) { + if (Configuration::get('SAFERPAY_FIELDS_ACCESS_TOKEN' . $suffix)) { + continue; + } + + $username = Configuration::get('SAFERPAY_USERNAME' . $suffix); + $password = Configuration::get('SAFERPAY_PASSWORD' . $suffix); + $customerId = Configuration::get('SAFERPAY_CUSTOMER_ID' . $suffix); + $terminalId = Configuration::get('SAFERPAY_TERMINAL_ID' . $suffix); + + if (!$username || !$password || !$customerId || !$terminalId) { + continue; + } + + try { + $shopUrl = Context::getContext()->link + ? Context::getContext()->link->getBaseLink() + : Tools::getShopDomainSsl(true, true); + + /** @var Invertus\SaferPay\Service\SaferPayGenerateFieldAccessToken $tokenGenerator */ + $tokenGenerator = $module->getService(Invertus\SaferPay\Service\SaferPayGenerateFieldAccessToken::class); + $token = $tokenGenerator->generateWithCredentials( + $username, + $password, + $customerId, + $terminalId, + $isTestMode, + $shopUrl + ); + + if ($token) { + Configuration::updateValue('SAFERPAY_FIELDS_ACCESS_TOKEN' . $suffix, $token); + } + } catch (Exception $e) { + PrestaShopLogger::addLog( + 'Saferpay upgrade 2.1.0: could not generate Fields access token (' . ($isTestMode ? 'test' : 'live') . '): ' . $e->getMessage(), + 2 + ); + } + } +} diff --git a/views/js/admin/settings-app/src/components/settings/payment-methods.tsx b/views/js/admin/settings-app/src/components/settings/payment-methods.tsx index 5a939ff1..0aa461a1 100644 --- a/views/js/admin/settings-app/src/components/settings/payment-methods.tsx +++ b/views/js/admin/settings-app/src/components/settings/payment-methods.tsx @@ -192,11 +192,10 @@ export function PaymentMethods() { {/* Header row */} -
+
{t('paymentMethod')} {t('enabled')} {t('logos')} - {t('customForm')} {t('countries')} {t('currencies')}
@@ -213,7 +212,7 @@ export function PaymentMethods() { }`} > {/* Desktop layout */} -
+
{method.displayName}
@@ -234,18 +233,6 @@ export function PaymentMethods() { />
-
- {method.hasCustomForm ? ( - updatePaymentMethod(method.name, { showCustomForm: checked })} - aria-label={`${t('customForm')} ${method.displayName}`} - /> - ) : ( - -- - )} -
-
- {method.hasCustomForm && ( -
- - updatePaymentMethod(method.name, { showCustomForm: checked })} - aria-label={`${t('customForm')} ${method.displayName}`} - /> -
- )}
diff --git a/views/js/admin/settings-app/src/components/settings/payment-processing.tsx b/views/js/admin/settings-app/src/components/settings/payment-processing.tsx index 95ab28d4..640e3522 100644 --- a/views/js/admin/settings-app/src/components/settings/payment-processing.tsx +++ b/views/js/admin/settings-app/src/components/settings/payment-processing.tsx @@ -3,13 +3,14 @@ import { Label } from '@/components/ui/label' import { Button } from '@/components/ui/button' import { Switch } from '@/components/ui/switch' import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' -import { CreditCard, ShieldCheck, Loader2 } from 'lucide-react' +import { AlertCircle, CreditCard, ShieldCheck, Loader2 } from 'lucide-react' import { useSettings } from '@/context/settings-context' import { t } from '@/utils/translations' export function PaymentProcessing() { const { settings, updateSettings, savePaymentProcessing, savingSections } = useSettings() const saving = savingSections.has('paymentProcessing') + const hasBusinessLicense = settings.testMode ? settings.testHasBusinessLicense : settings.liveHasBusinessLicense return (
@@ -263,6 +264,31 @@ export function PaymentProcessing() { />
)} + + {hasBusinessLicense && ( +
+
+ +

+ {t('useFieldsDescription')} +

+
+ updateSettings({ useFields: checked })} + /> +
+ )} + + {hasBusinessLicense && settings.useFields && settings.fieldsAccessTokenMissing && ( +
+ + {t('fieldsTokenMissingWarning')} +
+ )}
diff --git a/views/js/admin/settings-app/src/context/settings-context.tsx b/views/js/admin/settings-app/src/context/settings-context.tsx index 7069b1db..f39885bf 100644 --- a/views/js/admin/settings-app/src/context/settings-context.tsx +++ b/views/js/admin/settings-app/src/context/settings-context.tsx @@ -112,6 +112,7 @@ export function SettingsProvider({ children }: { children: React.ReactNode }) { orderCreationAfterAuth: currentSettings.orderCreationAfterAuth, groupCards: currentSettings.groupCards, groupCardsLogo: currentSettings.groupCardsLogo, + useFields: currentSettings.useFields, creditCardSave: currentSettings.creditCardSave, }), 'Payment Processing', 'paymentProcessing') }, [handleSave]) diff --git a/views/js/admin/settings-app/src/types/index.ts b/views/js/admin/settings-app/src/types/index.ts index 83233ba7..0f914c59 100644 --- a/views/js/admin/settings-app/src/types/index.ts +++ b/views/js/admin/settings-app/src/types/index.ts @@ -3,8 +3,6 @@ export interface PaymentMethodData { displayName: string enabled: boolean showLogos: boolean - showCustomForm: boolean - hasCustomForm: boolean countries: number[] currencies: number[] } @@ -44,6 +42,8 @@ export interface SaferpaySettingsData { orderCreationAfterAuth: number groupCards: boolean groupCardsLogo: boolean + useFields: boolean + fieldsAccessTokenMissing: boolean creditCardSave: number // Email diff --git a/views/js/front/inline-fields.js b/views/js/front/inline-fields.js index aa4bdad8..0c2ab995 100644 --- a/views/js/front/inline-fields.js +++ b/views/js/front/inline-fields.js @@ -155,6 +155,20 @@ return parseInt($form.find('[name="selectedCreditCard_' + method + '"]').val(), 10) || 0; } + // Brands this option's Fields form may accept, as the SDK's own lowercase names. Absent + // when the merchant enabled a brand the SDK cannot express (VPAY, myOne): a partial list + // would decline a card Saferpay itself accepts, so the form is left unrestricted. + function fieldPaymentMethods($form) { + if (typeof saferpay_field_payment_methods === 'undefined') { + return null; + } + + var method = $form.find('[name="saved_card_method"]').val(); + var brands = saferpay_field_payment_methods[method]; + + return (brands && brands.length) ? brands : null; + } + function stopLoading() { if (loadingTimeout) { clearTimeout(loadingTimeout); @@ -172,7 +186,7 @@ // Render a fresh Fields form into the selected option's container and initialise the SDK // on it. Rebuilding fresh readonly-input placeholders each time keeps re-initialisation // valid when the customer switches between card options. - function renderInto($container) { + function renderInto($container, $form) { var containerId = $container.attr('id'); if (renderedContainerId === containerId && $('#' + SLOT_ID).length) { return; @@ -185,7 +199,7 @@ renderedContainerId = containerId; loadingTimeout = setTimeout(stopLoading, LOADING_TIMEOUT); - SaferpayFields.init({ + var fieldsConfig = { accessToken: saferpay_field_access_token, url: saferpay_field_url, // Visible labels sit in the field border notch (see fieldMarkup), so the inputs @@ -266,7 +280,40 @@ toggleFieldClass(evt.fieldType, 'is-focused', false); } } - }); + }; + + var allowedBrands = fieldPaymentMethods($form); + if (allowedBrands) { + fieldsConfig.paymentMethods = allowedBrands; + } + + SaferpayFields.init(fieldsConfig); + } + + // The saved-card radios are rendered into the option's additional-information block, which + // is a sibling of the pay-with-