diff --git a/Makefile b/Makefile index 6d7ccb15a..c70260db1 100755 --- a/Makefile +++ b/Makefile @@ -147,6 +147,18 @@ test-e2e-headless-1786: build-react: cd views/js/admin/settings-app && pnpm install && pnpm run build +# target: dev-react - Start React dev server with HMR +dev-react: + cd views/js/admin/settings-app && pnpm dev + +# target: watch-react - Build React app and watch for changes +watch-react: + cd views/js/admin/settings-app && pnpm run build --watch + +# target: lint-react - Run TypeScript type check +lint-react: + cd views/js/admin/settings-app && pnpm run tsc --noEmit + prepare-zip: rm -rf vendor && \ composer install --no-dev --optimize-autoloader && \ diff --git a/changelog.md b/changelog.md index 4dc83f73a..3fe31cd34 100755 --- a/changelog.md +++ b/changelog.md @@ -196,4 +196,7 @@ ## [2.0.2] - Remove WL Crypto payment method - Added setting to toggle order confirmation email sending -- Added feature to group card payment methods into unified "Card" payment method \ No newline at end of file +- Added feature to group card payment methods into unified "Card" payment method +- Fixed issue when newly enabled payment methods did not appear in checkout because default "all countries/currencies" restriction was not created on save +- Fixed issue when payment method country/currency dropdowns showed "0" instead of indicating that all countries/currencies are allowed +- BO : Added validation for Merchant Emails field (frontend + backend) to prevent saving invalid addresses diff --git a/controllers/admin/AdminSaferPayOfficialFieldsController.php b/controllers/admin/AdminSaferPayOfficialFieldsController.php deleted file mode 100755 index 7508d5986..000000000 --- a/controllers/admin/AdminSaferPayOfficialFieldsController.php +++ /dev/null @@ -1,90 +0,0 @@ - - *@copyright SIX Payment Services - *@license SIX Payment Services - */ - -use Invertus\SaferPay\Config\SaferPayConfig; - -require_once dirname(__FILE__) . '/../../vendor/autoload.php'; - -if (!defined('_PS_VERSION_')) { - exit; -} - -class AdminSaferPayOfficialFieldsController extends ModuleAdminController -{ - public function __construct() - { - parent::__construct(); - $this->bootstrap = true; - - $this->tpl_folder = 'field-option-settings/'; - $this->initOptions(); - } - - public function initContent() - { - parent::initContent(); - } - - public function initOptions() - { - $this->fields_options = [ - 'hosted_fields_settings' => [ - 'title' => $this->module->l('Hosted fields settings'), - 'icon' => 'icon-settings', - 'fields' => [ - SaferPayConfig::HOSTED_FIELDS_TEMPLATE . '_description' => [ - 'type' => 'desc', - 'class' => 'col-lg-12', - 'template' => 'field-hosted-field-template-desc.tpl', - ], - - SaferPayConfig::HOSTED_FIELDS_TEMPLATE => [ - 'type' => 'select-template', - 'name' => SaferPayConfig::HOSTED_FIELDS_TEMPLATE, - 'templateOptions' => [ - "{$this->module->getPathUri()}views/img/hosted-templates/template1.jpg", - "{$this->module->getPathUri()}views/img/hosted-templates/template2.jpg", - "{$this->module->getPathUri()}views/img/hosted-templates/template3.jpg", - ], - ], - ], - 'buttons' => [ - 'save_and_connect' => [ - 'title' => $this->module->l('Save'), - 'icon' => 'process-icon-save', - 'class' => 'btn btn-default pull-right', - 'type' => 'submit', - ], - ], - ], - ]; - } - - public function setMedia($isNewTheme = false) - { - parent::setMedia($isNewTheme); - - $this->addJS('modules/' . $this->module->name . '/views/js/admin/saferpay_fields.js'); - $this->addCSS('modules/' . $this->module->name . '/views/css/admin/saferpay_fields.css'); - } -} diff --git a/controllers/admin/AdminSaferPayOfficialSettingsController.php b/controllers/admin/AdminSaferPayOfficialSettingsController.php index 2e952cc9e..0d4f5ffcf 100755 --- a/controllers/admin/AdminSaferPayOfficialSettingsController.php +++ b/controllers/admin/AdminSaferPayOfficialSettingsController.php @@ -29,6 +29,8 @@ 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; use Invertus\SaferPay\Service\SaferPayLogoCreator; use Invertus\SaferPay\Service\SaferPayObtainPaymentMethods; @@ -38,6 +40,7 @@ use Invertus\SaferPay\Service\SaferPayRestrictionCreator; use Invertus\SaferPay\Exception\Api\SaferPayApiException; use Invertus\SaferPay\Exception\Restriction\RestrictionException; +use Invertus\SaferPay\Logger\LoggerInterface; require_once dirname(__FILE__) . '/../../vendor/autoload.php'; @@ -50,6 +53,17 @@ class AdminSaferPayOfficialSettingsController extends ModuleAdminController const FILE_NAME = 'AdminSaferPayOfficialSettingsController'; const PASSWORD_PLACEHOLDER = '********'; + const ALLOWED_AJAX_ACTIONS = [ + 'saveCredentials', + 'savePaymentProcessing', + 'saveEmailSettings', + 'saveGeneralSettings', + 'savePaymentMethods', + 'getTerminals', + 'generateFieldAccessToken', + 'refreshData', + ]; + /** @var \SaferPayOfficial */ public $module; @@ -90,13 +104,19 @@ public function postProcess() return parent::postProcess(); } + if (!$this->validateAjaxToken()) { + $this->ajaxResponse(false, $this->module->l('Invalid security token', self::FILE_NAME)); + return; + } + $action = Tools::getValue('action'); - if ($action) { - $methodName = 'ajaxProcess' . ucfirst($action); - if (method_exists($this, $methodName)) { - $this->{$methodName}(); - } + if (!$action || !in_array($action, self::ALLOWED_AJAX_ACTIONS)) { + $this->ajaxResponse(false, $this->module->l('Invalid action', self::FILE_NAME)); + return; } + + $methodName = 'ajaxProcess' . ucfirst($action); + $this->{$methodName}(); } /** @@ -104,7 +124,17 @@ public function postProcess() */ private function isAjax() { - return Tools::getValue('ajax') == 1; + return (int) Tools::getValue('ajax') === 1; + } + + /** + * Validate AJAX requests come from authenticated admin + */ + private function validateAjaxToken() + { + // In PS9, the routing layer already validates the admin token in the URL + // before the controller is reached. We just verify the employee is logged in. + return $this->context->employee && $this->context->employee->id; } /** @@ -114,54 +144,126 @@ public function ajaxProcessSaveCredentials() { $data = $this->getJsonInput(); if (!$data) { - $this->ajaxResponse(false, 'Invalid request data'); + $this->ajaxResponse(false, $this->module->l('Invalid request data', self::FILE_NAME)); return; } /** @var SaferPayConfiguration $configuration */ $configuration = $this->module->getService(SaferPayConfiguration::class); - // Test mode - $configuration->set(SaferPayConfig::TEST_MODE, !empty($data['testMode']) ? 1 : 0); + // Resolve active credentials for validation before saving + $isTestMode = !empty($data['testMode']); + $activeUsername = $isTestMode ? $this->getStringValue($data, 'testUsername') : $this->getStringValue($data, 'liveUsername'); + $activePassword = $isTestMode ? $this->getStringValue($data, 'testPassword') : $this->getStringValue($data, 'livePassword'); + $activeCustomerId = $this->parseCustomerIdFromUsername($activeUsername); + + if ($activePassword === self::PASSWORD_PLACEHOLDER) { + $passwordSuffix = $isTestMode ? SaferPayConfig::TEST_SUFFIX : ''; + $activePassword = (string) $configuration->get(SaferPayConfig::PASSWORD . $passwordSuffix); + } + + // Validate credentials against Saferpay API before saving + if (!empty($activeUsername) && !empty($activePassword)) { + if (empty($activeCustomerId)) { + $this->ajaxResponse(false, $this->module->l('Invalid API username. Please check your credentials and try again.', self::FILE_NAME)); + return; + } + + try { + /** @var SaferPayGetTerminals $getTerminals */ + $getTerminals = $this->module->getService(SaferPayGetTerminals::class); + $getTerminals->fetchTerminalsWithCredentials($activeUsername, $activePassword, $activeCustomerId, $isTestMode); + } catch (\Exception $e) { + $this->ajaxResponse(false, $this->parseApiErrorMessage($e->getMessage())); + return; + } + } + + $testMerchantEmails = $this->getStringValue($data, 'testMerchantEmails'); + $liveMerchantEmails = $this->getStringValue($data, 'liveMerchantEmails'); + $invalidEmail = $this->findInvalidEmail($testMerchantEmails) ?: $this->findInvalidEmail($liveMerchantEmails); + if ($invalidEmail !== null) { + $this->ajaxResponse(false, sprintf( + $this->module->l('Invalid merchant email address: %s', self::FILE_NAME), + $invalidEmail + )); + return; + } + + // Credentials validated — now save + $configuration->set(SaferPayConfig::TEST_MODE, $isTestMode ? 1 : 0); // Test credentials - $configuration->set(SaferPayConfig::USERNAME . SaferPayConfig::TEST_SUFFIX, $this->getStringValue($data, 'testUsername')); + $testUsername = $this->getStringValue($data, 'testUsername'); + $configuration->set(SaferPayConfig::USERNAME . SaferPayConfig::TEST_SUFFIX, $testUsername); $testPassword = $this->getStringValue($data, 'testPassword'); if ($testPassword && $testPassword !== self::PASSWORD_PLACEHOLDER) { $configuration->set(SaferPayConfig::PASSWORD . SaferPayConfig::TEST_SUFFIX, $testPassword); } - $configuration->set(SaferPayConfig::CUSTOMER_ID . SaferPayConfig::TEST_SUFFIX, $this->getStringValue($data, 'testCustomerId')); + $configuration->set(SaferPayConfig::CUSTOMER_ID . SaferPayConfig::TEST_SUFFIX, $this->parseCustomerIdFromUsername($testUsername)); $configuration->set(SaferPayConfig::TERMINAL_ID . SaferPayConfig::TEST_SUFFIX, $this->getStringValue($data, 'testTerminalId')); $configuration->set(SaferPayConfig::MERCHANT_EMAILS . SaferPayConfig::TEST_SUFFIX, $this->getStringValue($data, 'testMerchantEmails')); $configuration->set(SaferPayConfig::FIELDS_ACCESS_TOKEN . SaferPayConfig::TEST_SUFFIX, $this->getStringValue($data, 'testFieldAccessToken')); $configuration->set(SaferPayConfig::FIELDS_LIBRARY . SaferPayConfig::TEST_SUFFIX, $this->getStringValue($data, 'testFieldJsUrl')); - $configuration->set(SaferPayConfig::BUSINESS_LICENSE . SaferPayConfig::TEST_SUFFIX, !empty($data['testBusinessLicense']) ? 1 : 0); // Live credentials - $configuration->set(SaferPayConfig::USERNAME, $this->getStringValue($data, 'liveUsername')); + $liveUsername = $this->getStringValue($data, 'liveUsername'); + $configuration->set(SaferPayConfig::USERNAME, $liveUsername); $livePassword = $this->getStringValue($data, 'livePassword'); if ($livePassword && $livePassword !== self::PASSWORD_PLACEHOLDER) { $configuration->set(SaferPayConfig::PASSWORD, $livePassword); } - $configuration->set(SaferPayConfig::CUSTOMER_ID, $this->getStringValue($data, 'liveCustomerId')); + $configuration->set(SaferPayConfig::CUSTOMER_ID, $this->parseCustomerIdFromUsername($liveUsername)); $configuration->set(SaferPayConfig::TERMINAL_ID, $this->getStringValue($data, 'liveTerminalId')); $configuration->set(SaferPayConfig::MERCHANT_EMAILS, $this->getStringValue($data, 'liveMerchantEmails')); $configuration->set(SaferPayConfig::FIELDS_ACCESS_TOKEN, $this->getStringValue($data, 'liveFieldAccessToken')); $configuration->set(SaferPayConfig::FIELDS_LIBRARY, $this->getStringValue($data, 'liveFieldJsUrl')); - $configuration->set(SaferPayConfig::BUSINESS_LICENSE, !empty($data['liveBusinessLicense']) ? 1 : 0); - // Validate: business license requires field access token - $suffix = SaferPayConfig::getConfigSuffix(); - $haveFieldToken = $configuration->get(SaferPayConfig::FIELDS_ACCESS_TOKEN . $suffix); - $haveBusinessLicense = $configuration->get(SaferPayConfig::BUSINESS_LICENSE . $suffix); + // Auto-detect license features from Saferpay Management API + $suffix = $isTestMode ? SaferPayConfig::TEST_SUFFIX : ''; + $hasBusinessLicense = false; + $licenseFetchFailed = false; - if (!$haveFieldToken && $haveBusinessLicense) { + if (!empty($activeUsername) && !empty($activePassword) && !empty($activeCustomerId)) { + try { + /** @var SaferPayGetLicense $getLicense */ + $getLicense = $this->module->getService(SaferPayGetLicense::class); + $licenseInfo = $getLicense->fetchLicenseWithCredentials( + $activeUsername, + $activePassword, + $activeCustomerId, + $isTestMode + ); + + $hasBusinessLicense = $licenseInfo['hasBusinessLicense']; + $configuration->set(SaferPayConfig::BUSINESS_LICENSE . $suffix, $hasBusinessLicense ? 1 : 0); + } catch (\Exception $e) { + $configuration->set(SaferPayConfig::BUSINESS_LICENSE . $suffix, 0); + $licenseFetchFailed = true; + + /** @var LoggerInterface $logger */ + $logger = $this->module->getService(LoggerInterface::class); + $logger->error('License fetch failed on credentials save: ' . $e->getMessage(), [ + 'context' => ['exception_class' => get_class($e)], + ]); + } + } else { $configuration->set(SaferPayConfig::BUSINESS_LICENSE . $suffix, 0); - $this->ajaxResponse(true, 'Saved, but Field Access Token is required to use business license. Business license was disabled.'); - return; } - $this->ajaxResponse(true, 'API Credentials saved successfully'); + $message = $licenseFetchFailed + ? $this->module->l('Settings saved, but Saferpay Fields availability could not be confirmed. Please try again later or check the module Logs for details.', self::FILE_NAME) + : $this->module->l('Settings saved successfully.', self::FILE_NAME); + + $this->ajaxResponse( + true, + $message, + [ + 'testHasBusinessLicense' => (bool) $configuration->get(SaferPayConfig::BUSINESS_LICENSE . SaferPayConfig::TEST_SUFFIX), + 'liveHasBusinessLicense' => (bool) $configuration->get(SaferPayConfig::BUSINESS_LICENSE), + 'warning' => $licenseFetchFailed, + ] + ); } /** @@ -171,20 +273,20 @@ public function ajaxProcessSavePaymentProcessing() { $data = $this->getJsonInput(); if (!$data) { - $this->ajaxResponse(false, 'Invalid request data'); + $this->ajaxResponse(false, $this->module->l('Invalid request data', self::FILE_NAME)); return; } /** @var SaferPayConfiguration $configuration */ $configuration = $this->module->getService(SaferPayConfiguration::class); - $configuration->set(SaferPayConfig::PAYMENT_BEHAVIOR, (int) $this->getIntValue($data, 'paymentBehavior')); - $configuration->set(SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D, (int) $this->getIntValue($data, 'paymentBehaviorWithout3D')); - $configuration->set(SaferPayConfig::RESTRICT_REFUND_AMOUNT_TO_CAPTURED_AMOUNT, (int) $this->getIntValue($data, 'restrictRefund')); - $configuration->set(SaferPayConfig::SAFERPAY_ORDER_CREATION_AFTER_AUTHORIZATION, (int) $this->getIntValue($data, 'orderCreationAfterAuth')); + $configuration->set(SaferPayConfig::PAYMENT_BEHAVIOR, $this->getIntValue($data, 'paymentBehavior')); + $configuration->set(SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D, $this->getIntValue($data, 'paymentBehaviorWithout3D')); + $configuration->set(SaferPayConfig::RESTRICT_REFUND_AMOUNT_TO_CAPTURED_AMOUNT, $this->getIntValue($data, 'restrictRefund')); + $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::CREDIT_CARD_SAVE, (int) $this->getIntValue($data, 'creditCardSave')); + $configuration->set(SaferPayConfig::CREDIT_CARD_SAVE, $this->getIntValue($data, 'creditCardSave')); // If credit card save disabled, clean up saved cards if (empty($data['creditCardSave']) || (int) $data['creditCardSave'] === 0) { @@ -193,7 +295,7 @@ public function ajaxProcessSavePaymentProcessing() $cardRepo->deleteAllSavedCreditCards(); } - $this->ajaxResponse(true, 'Payment Processing saved successfully'); + $this->ajaxResponse(true, $this->module->l('Payment Processing saved successfully', self::FILE_NAME)); } /** @@ -203,7 +305,7 @@ public function ajaxProcessSaveEmailSettings() { $data = $this->getJsonInput(); if (!$data) { - $this->ajaxResponse(false, 'Invalid request data'); + $this->ajaxResponse(false, $this->module->l('Invalid request data', self::FILE_NAME)); return; } @@ -214,7 +316,7 @@ public function ajaxProcessSaveEmailSettings() $configuration->set(SaferPayConfig::SAFERPAY_SEND_NEW_ORDER_MAIL, !empty($data['sendNewOrderMail']) ? 1 : 0); $configuration->set(SaferPayConfig::SAFERPAY_SEND_ORDER_CONF_MAIL, !empty($data['sendOrderConfMail']) ? 1 : 0); - $this->ajaxResponse(true, 'Email settings saved successfully'); + $this->ajaxResponse(true, $this->module->l('Email settings saved successfully', self::FILE_NAME)); } /** @@ -224,19 +326,31 @@ public function ajaxProcessSaveGeneralSettings() { $data = $this->getJsonInput(); if (!$data) { - $this->ajaxResponse(false, 'Invalid request data'); + $this->ajaxResponse(false, $this->module->l('Invalid request data', self::FILE_NAME)); return; } /** @var SaferPayConfiguration $configuration */ $configuration = $this->module->getService(SaferPayConfiguration::class); - $configuration->set(SaferPayConfig::SAFERPAY_ORDER_STATE_CHOICE_AWAITING_PAYMENT, (int) $this->getIntValue($data, 'orderStateAwaitingPayment')); + $configuration->set(SaferPayConfig::SAFERPAY_ORDER_STATE_CHOICE_AWAITING_PAYMENT, $this->getIntValue($data, 'orderStateAwaitingPayment')); $configuration->set(SaferPayConfig::SAFERPAY_PAYMENT_DESCRIPTION, $this->getStringValue($data, 'paymentDescription')); - $configuration->set(SaferPayConfig::CONFIGURATION_NAME, $this->getStringValue($data, 'configurationName')); + + $configurationName = $this->getStringValue($data, 'configurationName'); + if ($configurationName !== '' && (strlen($configurationName) > 20 || !preg_match('/^[A-Za-z0-9.:\-_]+$/', $configurationName))) { + $this->ajaxResponse(false, $this->module->l('Only letters, numbers, dots, colons, hyphens, and underscores are allowed. Max 20 characters.', self::FILE_NAME)); + return; + } + $configuration->set(SaferPayConfig::CONFIGURATION_NAME, $configurationName); + $hostedFieldsTemplate = $this->getIntValue($data, 'hostedFieldsTemplate'); + if ($hostedFieldsTemplate < 1 || $hostedFieldsTemplate > 3) { + $hostedFieldsTemplate = SaferPayConfig::HOSTED_FIELDS_TEMPLATE_DEFAULT; + } + $configuration->set(SaferPayConfig::HOSTED_FIELDS_TEMPLATE, $hostedFieldsTemplate); + $configuration->set(SaferPayConfig::SAFERPAY_ORDER_ID_OPTION, $this->getIntValue($data, 'orderIdOption')); $configuration->set(SaferPayConfig::SAFERPAY_DEBUG_MODE, !empty($data['debugMode']) ? 1 : 0); - $this->ajaxResponse(true, 'General settings saved successfully'); + $this->ajaxResponse(true, $this->module->l('General settings saved successfully', self::FILE_NAME)); } /** @@ -246,7 +360,7 @@ public function ajaxProcessSavePaymentMethods() { $data = $this->getJsonInput(); if (!$data || !isset($data['paymentMethods'])) { - $this->ajaxResponse(false, 'Invalid request data'); + $this->ajaxResponse(false, $this->module->l('Invalid request data', self::FILE_NAME)); return; } @@ -274,37 +388,48 @@ public function ajaxProcessSavePaymentMethods() $success = true; foreach ($data['paymentMethods'] as $method) { + if (!isset($method['name']) || !is_string($method['name'])) { + continue; + } + $paymentName = $method['name']; - $success &= $paymentCreation->updatePayment($paymentName, !empty($method['enabled'])); - $success &= $logoCreation->updateLogo($paymentName, !empty($method['showLogos'])); - $success &= $fieldCreation->updateField($paymentName, !empty($method['showCustomForm'])); + $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'] : []; $currencies = isset($method['currencies']) ? $method['currencies'] : []; - $success &= $restrictionCreator->updateRestriction( + if (empty($countries)) { + $countries = [SaferPayRestrictionCreator::RESTRICTION_ALL]; + } + if (empty($currencies)) { + $currencies = [SaferPayRestrictionCreator::RESTRICTION_ALL]; + } + + $success = $restrictionCreator->updateRestriction( $paymentName, SaferPayRestrictionCreator::RESTRICTION_COUNTRY, $countries - ); - $success &= $restrictionCreator->updateRestriction( + ) && $success; + $success = $restrictionCreator->updateRestriction( $paymentName, SaferPayRestrictionCreator::RESTRICTION_CURRENCY, $currencies - ); + ) && $success; } catch (RestrictionException $e) { - $this->ajaxResponse(false, 'Wrong restriction type'); + $this->ajaxResponse(false, $this->module->l('Wrong restriction type', self::FILE_NAME)); return; } } if (!$success) { - $this->ajaxResponse(false, 'Failed to update payment methods'); + $this->ajaxResponse(false, $this->module->l('Failed to update payment methods', self::FILE_NAME)); return; } - $this->ajaxResponse(true, 'Payment methods saved successfully'); + $this->ajaxResponse(true, $this->module->l('Payment methods saved successfully', self::FILE_NAME)); } /** @@ -313,25 +438,21 @@ public function ajaxProcessSavePaymentMethods() public function ajaxProcessGetTerminals() { $data = $this->getJsonInput(); - if (!$data) { - $this->ajaxResponse(false, 'Invalid request data'); - return; - } + $isTestMode = isset($data['env']) && $data['env'] === 'test'; + $suffix = $isTestMode ? SaferPayConfig::TEST_SUFFIX : ''; - $username = isset($data['username']) ? $data['username'] : ''; + $username = isset($data['username']) ? trim($data['username']) : ''; $password = isset($data['password']) ? $data['password'] : ''; - $customerId = isset($data['customerId']) ? $data['customerId'] : ''; - $isTestMode = isset($data['env']) && $data['env'] === 'test'; + $customerId = $this->parseCustomerIdFromUsername($username); if ($password === self::PASSWORD_PLACEHOLDER) { - $suffix = $isTestMode ? SaferPayConfig::TEST_SUFFIX : ''; /** @var SaferPayConfiguration $configuration */ $configuration = $this->module->getService(SaferPayConfiguration::class); $password = (string) $configuration->get(SaferPayConfig::PASSWORD . $suffix); } if (empty($username) || empty($password) || empty($customerId)) { - $this->ajaxResponse(false, 'Username, password and customer ID are required'); + $this->ajaxResponse(false, $this->module->l('Invalid credentials. Please check your username and password.', self::FILE_NAME)); return; } @@ -340,12 +461,66 @@ public function ajaxProcessGetTerminals() $getTerminals = $this->module->getService(SaferPayGetTerminals::class); $terminals = $getTerminals->fetchTerminalsWithCredentials($username, $password, $customerId, $isTestMode); - $this->ajaxDie(json_encode([ + $this->sendJsonResponse([ 'success' => true, 'terminals' => $terminals, - ])); + ]); + } catch (\Exception $e) { + $this->ajaxResponse(false, $this->module->l('Invalid credentials. Please check your username and password.', self::FILE_NAME)); + } + } + + /** + * AJAX: Generate Saferpay Fields access token + */ + public function ajaxProcessGenerateFieldAccessToken() + { + $data = $this->getJsonInput(); + $isTestMode = isset($data['env']) && $data['env'] === 'test'; + $suffix = $isTestMode ? SaferPayConfig::TEST_SUFFIX : ''; + + $username = isset($data['username']) ? trim($data['username']) : ''; + $password = isset($data['password']) ? $data['password'] : ''; + $terminalId = isset($data['terminalId']) ? trim($data['terminalId']) : ''; + $customerId = $this->parseCustomerIdFromUsername($username); + + if ($password === self::PASSWORD_PLACEHOLDER) { + /** @var SaferPayConfiguration $configuration */ + $configuration = $this->module->getService(SaferPayConfiguration::class); + $password = (string) $configuration->get(SaferPayConfig::PASSWORD . $suffix); + } + + if (empty($username) || empty($password) || empty($customerId) || empty($terminalId)) { + $this->ajaxResponse(false, $this->module->l('Please enter valid credentials and select a terminal first.', self::FILE_NAME)); + return; + } + + try { + /** @var SaferPayGenerateFieldAccessToken $tokenGenerator */ + $tokenGenerator = $this->module->getService(SaferPayGenerateFieldAccessToken::class); + $shopUrl = $this->context->link->getBaseLink(); + $token = $tokenGenerator->generateWithCredentials($username, $password, $customerId, $terminalId, $isTestMode, $shopUrl); + + /** @var SaferPayConfiguration $configuration */ + $configuration = $this->module->getService(SaferPayConfiguration::class); + $configuration->set(SaferPayConfig::FIELDS_ACCESS_TOKEN . $suffix, $token); + + $this->sendJsonResponse([ + 'success' => true, + 'message' => $this->module->l('Access token generated successfully.', self::FILE_NAME), + 'token' => $token, + ]); } catch (\Exception $e) { - $this->ajaxResponse(false, 'Failed to fetch terminals: ' . $e->getMessage()); + \PrestaShopLogger::addLog( + 'SaferPay: Failed to generate field access token - ' . $e->getMessage(), + 3, + null, + null, + null, + true + ); + + $this->ajaxResponse(false, $this->module->l('Failed to generate access token.', self::FILE_NAME)); } } @@ -355,10 +530,10 @@ public function ajaxProcessGetTerminals() public function ajaxProcessRefreshData() { $settingsData = $this->collectSettingsData(); - $this->ajaxDie(json_encode([ + $this->sendJsonResponse([ 'success' => true, 'data' => $settingsData, - ])); + ]); } /** @@ -376,22 +551,22 @@ private function collectSettingsData() // Test credentials 'testUsername' => (string) $configuration->get(SaferPayConfig::USERNAME . SaferPayConfig::TEST_SUFFIX), 'testPassword' => $configuration->get(SaferPayConfig::PASSWORD . SaferPayConfig::TEST_SUFFIX) ? self::PASSWORD_PLACEHOLDER : '', - 'testCustomerId' => (string) $configuration->get(SaferPayConfig::CUSTOMER_ID . SaferPayConfig::TEST_SUFFIX), 'testTerminalId' => (string) $configuration->get(SaferPayConfig::TERMINAL_ID . SaferPayConfig::TEST_SUFFIX), - 'testMerchantEmails' => (string) $configuration->get(SaferPayConfig::MERCHANT_EMAILS . SaferPayConfig::TEST_SUFFIX), + 'testMerchantEmails' => (string) $configuration->get(SaferPayConfig::MERCHANT_EMAILS . SaferPayConfig::TEST_SUFFIX) ?: (string) \Configuration::get('PS_SHOP_EMAIL'), 'testFieldAccessToken' => (string) $configuration->get(SaferPayConfig::FIELDS_ACCESS_TOKEN . SaferPayConfig::TEST_SUFFIX), 'testFieldJsUrl' => (string) $configuration->get(SaferPayConfig::FIELDS_LIBRARY . SaferPayConfig::TEST_SUFFIX), - 'testBusinessLicense' => (bool) $configuration->get(SaferPayConfig::BUSINESS_LICENSE . SaferPayConfig::TEST_SUFFIX), // Live credentials 'liveUsername' => (string) $configuration->get(SaferPayConfig::USERNAME), 'livePassword' => $configuration->get(SaferPayConfig::PASSWORD) ? self::PASSWORD_PLACEHOLDER : '', - 'liveCustomerId' => (string) $configuration->get(SaferPayConfig::CUSTOMER_ID), 'liveTerminalId' => (string) $configuration->get(SaferPayConfig::TERMINAL_ID), - 'liveMerchantEmails' => (string) $configuration->get(SaferPayConfig::MERCHANT_EMAILS), + 'liveMerchantEmails' => (string) $configuration->get(SaferPayConfig::MERCHANT_EMAILS) ?: (string) \Configuration::get('PS_SHOP_EMAIL'), 'liveFieldAccessToken' => (string) $configuration->get(SaferPayConfig::FIELDS_ACCESS_TOKEN), 'liveFieldJsUrl' => (string) $configuration->get(SaferPayConfig::FIELDS_LIBRARY), - 'liveBusinessLicense' => (bool) $configuration->get(SaferPayConfig::BUSINESS_LICENSE), + + // License (auto-detected, per environment) + 'testHasBusinessLicense' => (bool) $configuration->get(SaferPayConfig::BUSINESS_LICENSE . SaferPayConfig::TEST_SUFFIX), + 'liveHasBusinessLicense' => (bool) $configuration->get(SaferPayConfig::BUSINESS_LICENSE), // Payment Processing 'paymentBehavior' => (int) $configuration->get(SaferPayConfig::PAYMENT_BEHAVIOR), @@ -411,6 +586,9 @@ private function collectSettingsData() 'orderStateAwaitingPayment' => (int) $configuration->get(SaferPayConfig::SAFERPAY_ORDER_STATE_CHOICE_AWAITING_PAYMENT), 'paymentDescription' => (string) $configuration->get(SaferPayConfig::SAFERPAY_PAYMENT_DESCRIPTION), 'configurationName' => (string) $configuration->get(SaferPayConfig::CONFIGURATION_NAME), + 'hostedFieldsTemplate' => (int) $configuration->get(SaferPayConfig::HOSTED_FIELDS_TEMPLATE), + 'modulePath' => $this->module->getPathUri(), + 'orderIdOption' => (int) $configuration->get(SaferPayConfig::SAFERPAY_ORDER_ID_OPTION), 'debugMode' => (bool) $configuration->get(SaferPayConfig::SAFERPAY_DEBUG_MODE), // Reference data @@ -422,11 +600,25 @@ private function collectSettingsData() // Endpoints 'ajaxUrl' => $this->context->link->getAdminLink('AdminSaferPayOfficialSettings'), 'adminToken' => Tools::getAdminTokenLite('AdminSaferPayOfficialSettings'), + + // Translations + 'translations' => $this->getSettingsTranslations(), ]; return $data; } + /** + * Get all translatable strings for the React frontend + */ + private function getSettingsTranslations() + { + /** @var \Invertus\SaferPay\Service\SettingsTranslationService $translationService */ + $translationService = $this->module->getService(\Invertus\SaferPay\Service\SettingsTranslationService::class); + + return $translationService->getAll(); + } + /** * Get order states for dropdown */ @@ -450,7 +642,7 @@ private function getCountries() { $countries = Country::getCountries($this->context->language->id, true); $result = []; - $result[] = ['id' => 0, 'name' => 'All']; + $result[] = ['id' => 0, 'name' => $this->module->l('All', self::FILE_NAME)]; foreach ($countries as $key => $country) { $result[] = [ 'id' => (int) $key, @@ -467,7 +659,7 @@ private function getCurrencies() { $currencies = Currency::getCurrencies(); $result = []; - $result[] = ['id' => 0, 'iso_code' => 'All']; + $result[] = ['id' => 0, 'iso_code' => $this->module->l('All', self::FILE_NAME)]; foreach ($currencies as $currency) { $result[] = [ 'id' => (int) $currency['id_currency'], @@ -487,7 +679,7 @@ private function getPaymentMethodsData() $obtainMethods = $this->module->getService(SaferPayObtainPaymentMethods::class); $paymentMethods = $obtainMethods->obtainPaymentMethodsNamesAsArray(); } catch (SaferPayApiException $exception) { - return []; + return ['error' => $this->module->l('Failed to load payment methods. Please verify your API credentials.', self::FILE_NAME)]; } /** @var SaferPayPaymentRepository $paymentRepository */ @@ -541,12 +733,59 @@ private function getJsonInput() /** * Send AJAX JSON response */ - private function ajaxResponse($success, $message = '') + private function ajaxResponse($success, $message = '', $extraData = []) { - $this->ajaxDie(json_encode([ + $this->sendJsonResponse(array_merge([ 'success' => $success, 'message' => $message, - ])); + ], $extraData)); + } + + /** + * Send JSON response and terminate (PS9 compatible) + */ + private function sendJsonResponse(array $data) + { + header('Content-Type: application/json'); + ob_end_clean(); + die(json_encode($data)); + } + + /** + * Parse customer ID from API username (format: PREFIX_CUSTOMERID_SUFFIX) + */ + private function parseCustomerIdFromUsername($username) + { + $parts = explode('_', (string) $username); + + return isset($parts[1]) ? $parts[1] : ''; + } + + /** + * Parse Saferpay API error message into user-friendly text + */ + private function parseApiErrorMessage($rawMessage) + { + $jsonStart = strpos($rawMessage, '{'); + if ($jsonStart !== false) { + $jsonString = substr($rawMessage, $jsonStart); + $decoded = json_decode($jsonString, true); + if (is_array($decoded) && !empty($decoded['ErrorName'])) { + $errorName = $decoded['ErrorName']; + if ($errorName === 'AUTHENTICATION_FAILED') { + return $this->module->l('Invalid API credentials. Please verify your username and password.', self::FILE_NAME); + } + + $message = $this->module->l('API validation failed:', self::FILE_NAME) . ' ' . $errorName; + if (!empty($decoded['ErrorMessage'])) { + $message .= ' — ' . $decoded['ErrorMessage']; + } + + return $message; + } + } + + return $this->module->l('API validation failed. Please check your credentials and try again.', self::FILE_NAME); } /** @@ -554,7 +793,7 @@ private function ajaxResponse($success, $message = '') */ private function getStringValue($data, $key) { - return isset($data[$key]) ? pSQL((string) $data[$key]) : ''; + return isset($data[$key]) ? (string) $data[$key] : ''; } /** @@ -564,4 +803,24 @@ private function getIntValue($data, $key) { return isset($data[$key]) ? (int) $data[$key] : 0; } + + /** + * Returns the first invalid email in a comma-separated list, or null if all are valid. + */ + private function findInvalidEmail($emails) + { + if ($emails === '') { + return null; + } + foreach (explode(',', $emails) as $email) { + $email = trim($email); + if ($email === '') { + continue; + } + if (!\Validate::isEmail($email)) { + return $email; + } + } + return null; + } } diff --git a/controllers/front/notify.php b/controllers/front/notify.php index 870464e7c..667a1fd4d 100755 --- a/controllers/front/notify.php +++ b/controllers/front/notify.php @@ -140,27 +140,50 @@ public function postProcess() //NOTE must be left below assert action to get newest information. $order = new Order($orderId); + $paymentBehaviorWithout3D = (int) Configuration::get(SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D); + if (!$assertResponseBody->getLiability()->getLiabilityShift() && - in_array($order->payment, SaferPayConfig::SUPPORTED_3DS_PAYMENT_METHODS) && - (int) Configuration::get(SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D) === SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D_CANCEL + in_array($order->payment, SaferPayConfig::SUPPORTED_3DS_PAYMENT_METHODS) ) { /** @var SaferPayOrderStatusService $orderStatusService */ $orderStatusService = $this->module->getService(SaferPayOrderStatusService::class); - $orderStatusService->cancel($order); - $logger->debug(sprintf('%s - Liability shift is false', self::FILE_NAME), [ - 'context' => [ - 'id_order' => $order->id, - ], - ]); + if ($paymentBehaviorWithout3D === SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D_CANCEL) { + $orderStatusService->cancel($order); - $logger->debug(sprintf('%s - liability shift is false', self::FILE_NAME), [ - 'context' => [ - 'id_order' => $order->id, - ], - ]); + $logger->debug(sprintf('%s - Liability shift is false, canceling order', self::FILE_NAME), [ + 'context' => [ + 'id_order' => $order->id, + ], + ]); - die($this->module->l('Liability shift is false', self::FILE_NAME)); + die($this->module->l('Liability shift is false', self::FILE_NAME)); + } + + if ($paymentBehaviorWithout3D === SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D_AUTHORIZE) { + $logger->debug(sprintf('%s - Liability shift is false, order left authorized', self::FILE_NAME), [ + 'context' => [ + 'id_order' => $order->id, + ], + ]); + + die($this->module->l('Liability shift is false, order left authorized', self::FILE_NAME)); + } + + if ($paymentBehaviorWithout3D === SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D_CAPTURE + && SaferPayConfig::supportsOrderCapture($order->payment) + && $transactionStatus !== TransactionStatus::CAPTURED + ) { + $orderStatusService->capture($order); + + $logger->debug(sprintf('%s - Liability shift is false, capturing order', self::FILE_NAME), [ + 'context' => [ + 'id_order' => $order->id, + ], + ]); + + die($this->module->l('Liability shift is false, capturing order', self::FILE_NAME)); + } } //NOTE to get latest information possible and not override new information. diff --git a/controllers/front/return.php b/controllers/front/return.php index b5fc37c69..a65984b32 100755 --- a/controllers/front/return.php +++ b/controllers/front/return.php @@ -274,7 +274,9 @@ private function executeTransaction($orderId, $selectedCard) */ private function getRedirectionToControllerUrl($controllerName) { - $cartId = $this->context->cart->id ? $this->context->cart->id : Tools::getValue('cartId'); + $cartId = (int) Tools::getValue('cartId') ?: (int) $this->context->cart->id; + $cart = new Cart($cartId); + $secureKey = Validate::isLoadedObject($cart) ? $cart->secure_key : $this->context->cart->secure_key; return $this->context->link->getModuleLink( $this->module->name, @@ -282,7 +284,7 @@ private function getRedirectionToControllerUrl($controllerName) [ 'cartId' => $cartId, 'orderId' => Order::getIdByCartId($cartId), - 'secureKey' => $this->context->cart->secure_key, + 'secureKey' => $secureKey, 'moduleId' => $this->module->id, ] ); @@ -329,19 +331,39 @@ private function createAndValidateOrder($assertResponseBody, $transactionStatus, $orderId = Order::getIdByCartId($cartId); $order = new Order($orderId); + $paymentBehaviorWithout3D = (int) Configuration::get(SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D); + if (!$assertResponseBody->getLiability()->getLiabilityShift() && - in_array($order->payment, SaferPayConfig::SUPPORTED_3DS_PAYMENT_METHODS) && - (int) Configuration::get(SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D) === SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D_CANCEL + in_array($order->payment, SaferPayConfig::SUPPORTED_3DS_PAYMENT_METHODS) ) { /** @var SaferPayOrderStatusService $orderStatusService */ $orderStatusService = $this->module->getService(SaferPayOrderStatusService::class); - $orderStatusService->cancel($order); + + if ($paymentBehaviorWithout3D === SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D_CANCEL) { + $orderStatusService->cancel($order); + + return; + } + + if ($paymentBehaviorWithout3D === SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D_AUTHORIZE) { + return; + } + + if ($paymentBehaviorWithout3D === SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D_CAPTURE + && SaferPayConfig::supportsOrderCapture($order->payment) + && $transactionStatus !== TransactionStatus::CAPTURED + ) { + $orderStatusService->capture($order); + + return; + } } //NOTE to get latest information possible and not override new information. - $paymentMethod = $assertResponseBody->getPaymentMeans()->getBrand()->getPaymentMethod();// if payment does not support order capture, it means it always auto-captures it (at least with accountToAccount payment), + $paymentMethod = $assertResponseBody->getPaymentMeans()->getBrand()->getPaymentMethod(); + // if payment does not support order capture, it means it always auto-captures it (at least with accountToAccount payment), // so in this case if status comes back "captured" we just update the order state accordingly if (!SaferPayConfig::supportsOrderCapture($paymentMethod) && $transactionStatus === TransactionStatus::CAPTURED diff --git a/cypress/integration/01_ps1764.Module.Configure.cy.js b/cypress/integration/01_ps1764.Module.Configure.cy.js index b7313cb4c..f8988d2b8 100755 --- a/cypress/integration/01_ps1764.Module.Configure.cy.js +++ b/cypress/integration/01_ps1764.Module.Configure.cy.js @@ -117,14 +117,6 @@ it('04 Fields and Logs tabs are shown OK', () => { cy.get('.pstaggerAddTagInput').type('saferpay') cy.get('#module-search-button').click() cy.get('.btn-group > .btn-primary-reverse').click() //clicking the Congifure - cy.get('#subtab-AdminSaferPayOfficialFields').click() - cy.get('[id="configuration_form"]').should('be.visible') - cy.get('.field-container > :nth-child(1) > img').click() - cy.get(':nth-child(2) > img').click() - cy.get(':nth-child(3) > img').click() - cy.get('[class="alert alert-info"]').should('be.visible') - cy.get('[name="submitOptionsconfiguration"]').click() - cy.get('[class="alert alert-success"]').should('be.visible') cy.get('#subtab-AdminSaferPayOfficialLogs').click() cy.get('[id="form-saferpay_log"]').should('be.visible') }) diff --git a/cypress/integration/01_ps1770.Module.Configure.cy.js b/cypress/integration/01_ps1770.Module.Configure.cy.js index 9696c496c..1de0a18c6 100755 --- a/cypress/integration/01_ps1770.Module.Configure.cy.js +++ b/cypress/integration/01_ps1770.Module.Configure.cy.js @@ -117,14 +117,6 @@ it('04 Fields and Logs tabs are shown OK', () => { cy.get('.pstaggerAddTagInput').type('saferpay') cy.get('#module-search-button').click() cy.get('.btn-group > .btn-primary-reverse').click() //clicking the Congifure - cy.get('#subtab-AdminSaferPayOfficialFields').click() - cy.get('[id="configuration_form"]').should('be.visible') - cy.get('.field-container > :nth-child(1) > img').click() - cy.get(':nth-child(2) > img').click() - cy.get(':nth-child(3) > img').click() - cy.get('[class="alert alert-info"]').should('be.visible') - cy.get('[name="submitOptionsconfiguration"]').click() - cy.get('[class="alert alert-success"]').should('be.visible') cy.get('#subtab-AdminSaferPayOfficialLogs').click() cy.get('[id="form-saferpay_log"]').should('be.visible') }) diff --git a/cypress/integration/01_ps1784.Module.Configure.cy.js b/cypress/integration/01_ps1784.Module.Configure.cy.js index 8da2aae5b..716aa4837 100755 --- a/cypress/integration/01_ps1784.Module.Configure.cy.js +++ b/cypress/integration/01_ps1784.Module.Configure.cy.js @@ -117,14 +117,6 @@ it('04 Fields and Logs tabs are shown OK', () => { cy.get('.pstaggerAddTagInput').type('saferpay') cy.get('#module-search-button').click() cy.get('.btn-group > .btn-primary-reverse').click() //clicking the Congifure - cy.get('#subtab-AdminSaferPayOfficialFields').click() - cy.get('[id="configuration_form"]').should('be.visible') - cy.get('.field-container > :nth-child(1) > img').click() - cy.get(':nth-child(2) > img').click() - cy.get(':nth-child(3) > img').click() - cy.get('[class="alert alert-info"]').should('be.visible') - cy.get('[name="submitOptionsconfiguration"]').click() - cy.get('[class="alert alert-success"]').should('be.visible') cy.get('#subtab-AdminSaferPayOfficialLogs').click() cy.get('[id="form-saferpay_log"]').should('be.visible') }) diff --git a/cypress/integration/01_ps1786.Module.Configure.cy.js b/cypress/integration/01_ps1786.Module.Configure.cy.js index 0c8259a9d..863cb240b 100755 --- a/cypress/integration/01_ps1786.Module.Configure.cy.js +++ b/cypress/integration/01_ps1786.Module.Configure.cy.js @@ -117,14 +117,6 @@ it('04 Fields and Logs tabs are shown OK', () => { cy.get('.pstaggerAddTagInput').type('saferpay') cy.get('#module-search-button').click() cy.get('.btn-group > .btn-primary-reverse').click() //clicking the Congifure - cy.get('#subtab-AdminSaferPayOfficialFields').click() - cy.get('[id="configuration_form"]').should('be.visible') - cy.get('.field-container > :nth-child(1) > img').click() - cy.get(':nth-child(2) > img').click() - cy.get(':nth-child(3) > img').click() - cy.get('[class="alert alert-info"]').should('be.visible') - cy.get('[name="submitOptionsconfiguration"]').click() - cy.get('[class="alert alert-success"]').should('be.visible') cy.get('#subtab-AdminSaferPayOfficialLogs').click() cy.get('[id="form-saferpay_log"]').should('be.visible') }) diff --git a/saferpayofficial.php b/saferpayofficial.php index b0acd4630..dd997aa5e 100755 --- a/saferpayofficial.php +++ b/saferpayofficial.php @@ -55,7 +55,6 @@ class SaferPayOfficial extends PaymentModule const ADMIN_SAFERPAY_MODULE_CONTROLLER = 'AdminSaferPayOfficialModule'; const ADMIN_SETTINGS_CONTROLLER = 'AdminSaferPayOfficialSettings'; const ADMIN_PAYMENTS_CONTROLLER = 'AdminSaferPayOfficialPayment'; - const ADMIN_FIELDS_CONTROLLER = 'AdminSaferPayOfficialFields'; const ADMIN_ORDER_CONTROLLER = 'AdminSaferPayOfficialOrder'; const ADMIN_LOGS_CONTROLLER = 'AdminSaferPayOfficialLogs'; @@ -65,7 +64,7 @@ public function __construct($name = null) { $this->name = 'saferpayofficial'; $this->author = 'Invertus'; - $this->version = '2.0.2'; + $this->version = '2.1.1'; $this->module_key = '3d3506c3e184a1fe63b936b82bda1bdf'; $this->displayName = 'SaferpayOfficial'; $this->description = 'Saferpay Payment module'; diff --git a/src/Api/ApiRequest.php b/src/Api/ApiRequest.php index 70c9323a1..5df54cd59 100755 --- a/src/Api/ApiRequest.php +++ b/src/Api/ApiRequest.php @@ -114,14 +114,16 @@ public function get($url, $params = []) return json_decode($response->raw_body); } catch (Exception $exception) { - $this->logger->error($exception->getMessage(), [ - 'context' => [ - 'headers' => $this->getHeaders(), - ], - 'request' => $params, - 'response' => json_decode($response->raw_body), - 'exceptions' => ExceptionUtility::getExceptions($exception), - ]); + if ($response === null) { + $this->logger->error($exception->getMessage(), [ + 'context' => [ + 'headers' => $this->getHeaders(), + ], + 'request' => $params, + 'response' => null, + 'exceptions' => ExceptionUtility::getExceptions($exception), + ]); + } throw $exception; } @@ -170,13 +172,73 @@ public function getWithCredentials($url, $username, $password, $baseUrl, $params return json_decode($response->raw_body); } catch (Exception $exception) { - $this->logger->error($exception->getMessage(), [ - 'context' => [], + if ($response === null) { + $this->logger->error($exception->getMessage(), [ + 'context' => [], + 'request' => $params, + 'response' => null, + 'exceptions' => ExceptionUtility::getExceptions($exception), + ]); + } + + throw $exception; + } + } + + /** + * API Request Post Method with explicit credentials. + * + * @param string $url + * @param string $username + * @param string $password + * @param string $baseUrl + * @param array|null $params + * @return mixed + * @throws Exception + */ + public function postWithCredentials($url, $username, $password, $baseUrl, $params = null) + { + $response = null; + + try { + $credentials = base64_encode("$username:$password"); + $headers = [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + 'Saferpay-ApiVersion' => SaferPayConfig::API_VERSION, + 'Saferpay-RequestId' => 'false', + 'Authorization' => "Basic $credentials", + ]; + + $body = $params !== null ? json_encode($params) : '{}'; + + $response = Request::post( + $baseUrl . $url, + $headers, + $body + ); + + $this->logger->debug(sprintf('%s - POST (credentials) response: %d', self::FILE_NAME, $response->code), [ + 'context' => [ + 'uri' => $baseUrl . $url, + ], 'request' => $params, - 'response' => $response ? json_decode($response->raw_body) : null, - 'exceptions' => ExceptionUtility::getExceptions($exception), + 'response' => $response->body, ]); + $this->isValidResponse($response); + + return json_decode($response->raw_body); + } catch (Exception $exception) { + if ($response === null) { + $this->logger->error($exception->getMessage(), [ + 'context' => [], + 'request' => $params, + 'response' => null, + 'exceptions' => ExceptionUtility::getExceptions($exception), + ]); + } + throw $exception; } } diff --git a/src/Api/Request/GenerateFieldAccessTokenService.php b/src/Api/Request/GenerateFieldAccessTokenService.php new file mode 100644 index 000000000..3ef46f9a9 --- /dev/null +++ b/src/Api/Request/GenerateFieldAccessTokenService.php @@ -0,0 +1,61 @@ + + *@copyright SIX Payment Services + *@license SIX Payment Services + */ + +namespace Invertus\SaferPay\Api\Request; + +use Invertus\SaferPay\Api\ApiRequest; +use Invertus\SaferPay\DTO\Request\GenerateFieldAccessToken\GenerateFieldAccessTokenRequest; + +if (!defined('_PS_VERSION_')) { + exit; +} + +class GenerateFieldAccessTokenService +{ + /** @var ApiRequest */ + private $apiRequest; + + public function __construct(ApiRequest $apiRequest) + { + $this->apiRequest = $apiRequest; + } + + /** + * @param GenerateFieldAccessTokenRequest $request + * @param string $username + * @param string $password + * @param string $baseUrl + * @param array|null $params + * @return mixed + */ + public function generateToken(GenerateFieldAccessTokenRequest $request, $username, $password, $baseUrl, $params = null) + { + return $this->apiRequest->postWithCredentials( + $request->generateRequestUrl(), + $username, + $password, + $baseUrl, + $params + ); + } +} diff --git a/src/Api/Request/GetLicenseService.php b/src/Api/Request/GetLicenseService.php new file mode 100644 index 000000000..0299875fb --- /dev/null +++ b/src/Api/Request/GetLicenseService.php @@ -0,0 +1,76 @@ + + *@copyright SIX Payment Services + *@license SIX Payment Services + */ + +namespace Invertus\SaferPay\Api\Request; + +use Invertus\SaferPay\Api\ApiRequest; +use Invertus\SaferPay\DTO\Request\GetLicense\GetLicenseRequest; + +if (!defined('_PS_VERSION_')) { + exit; +} + +class GetLicenseService +{ + /** @var ApiRequest */ + private $apiRequest; + + public function __construct(ApiRequest $apiRequest) + { + $this->apiRequest = $apiRequest; + } + + /** + * @param GetLicenseRequest $request + * @param string $username + * @param string $password + * @param string $baseUrl + * @return mixed + */ + public function getLicense(GetLicenseRequest $request, $username, $password, $baseUrl) + { + return $this->apiRequest->getWithCredentials( + $request->generateRequestUrl(), + $username, + $password, + $baseUrl + ); + } + + /** + * @param GetLicenseRequest $request + * @param string $username + * @param string $password + * @param string $baseUrl + * @return mixed + */ + public function getLicenseFallback(GetLicenseRequest $request, $username, $password, $baseUrl) + { + return $this->apiRequest->getWithCredentials( + $request->generateFallbackRequestUrl(), + $username, + $password, + $baseUrl + ); + } +} diff --git a/src/Config/SaferPayConfig.php b/src/Config/SaferPayConfig.php index ae7879997..793d0b8e1 100755 --- a/src/Config/SaferPayConfig.php +++ b/src/Config/SaferPayConfig.php @@ -276,6 +276,9 @@ class SaferPayConfig const PAYMENT_BEHAVIOR_WITHOUT_3D_CANCEL = 0; const PAYMENT_BEHAVIOR_WITHOUT_3D_AUTHORIZE = 1; + const PAYMENT_BEHAVIOR_WITHOUT_3D_CAPTURE = 2; + + const SAFERPAY_ORDER_ID_OPTION = 'SAFERPAY_ORDER_ID_OPTION'; const SAFERPAY_CARDFORM_HOLDERNAME_REQUIRENCE = 'MANDATORY'; const SAFERPAY_DEBUG_MODE = 'SAFERPAY_DEBUG_MODE'; @@ -433,12 +436,13 @@ public static function getDefaultConfiguration() RequestHeader::SPEC_REFUND_VERSION => SaferPayConfig::API_VERSION, RequestHeader::RETRY_INDICATOR => 0, SaferPayConfig::PAYMENT_BEHAVIOR => 1, - SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D => 1, + SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D => 0, SaferPayConfig::SAFERPAY_ALLOW_SAFERPAY_SEND_CUSTOMER_MAIL => 1, SaferPayConfig::SAFERPAY_PAYMENT_DESCRIPTION => self::SAFERPAY_PAYMENT_DESCRIPTION_DEFAULT_VALUE, SaferPayConfig::FIELDS_LIBRARY => self::FIELDS_LIBRARY_DEFAULT_VALUE, SaferPayConfig::FIELDS_LIBRARY . SaferPayConfig::TEST_SUFFIX => self::FIELDS_LIBRARY_TEST_DEFAULT_VALUE, self::SAFERPAY_ORDER_CREATION_AFTER_AUTHORIZATION => 0, + self::SAFERPAY_ORDER_ID_OPTION => 0, self::TEST_MODE => 1, self::HOSTED_FIELDS_TEMPLATE => self::HOSTED_FIELDS_TEMPLATE_DEFAULT, self::SAFERPAY_ORDER_STATE_CHOICE_AWAITING_PAYMENT => (int) Configuration::get( @@ -479,6 +483,7 @@ public static function getUninstallConfiguration() self::FIELDS_LIBRARY, self::FIELDS_LIBRARY . self::TEST_SUFFIX, self::SAFERPAY_ORDER_CREATION_AFTER_AUTHORIZATION, + self::SAFERPAY_ORDER_ID_OPTION, self::SAFERPAY_SEND_ORDER_CONF_MAIL, self::SAFERPAY_GROUP_CARDS, ]; diff --git a/src/DTO/Request/GenerateFieldAccessToken/GenerateFieldAccessTokenRequest.php b/src/DTO/Request/GenerateFieldAccessToken/GenerateFieldAccessTokenRequest.php new file mode 100644 index 000000000..5eeb4a490 --- /dev/null +++ b/src/DTO/Request/GenerateFieldAccessToken/GenerateFieldAccessTokenRequest.php @@ -0,0 +1,67 @@ + + *@copyright SIX Payment Services + *@license SIX Payment Services + */ + +namespace Invertus\SaferPay\DTO\Request\GenerateFieldAccessToken; + +if (!defined('_PS_VERSION_')) { + exit; +} + +class GenerateFieldAccessTokenRequest +{ + /** @var string */ + private $customerId; + + /** @var string */ + private $terminalId; + + /** + * @param string $customerId + * @param string $terminalId + */ + public function __construct($customerId, $terminalId) + { + if (!preg_match('/^[a-zA-Z0-9\-_]+$/', $customerId)) { + throw new \InvalidArgumentException('Invalid customer ID format'); + } + + if (!preg_match('/^[a-zA-Z0-9\-_]+$/', $terminalId)) { + throw new \InvalidArgumentException('Invalid terminal ID format'); + } + + $this->customerId = $customerId; + $this->terminalId = $terminalId; + } + + /** + * @return string + */ + public function generateRequestUrl() + { + return sprintf( + 'rest/customers/%s/terminals/%s/fields-access-tokens', + $this->customerId, + $this->terminalId + ); + } +} diff --git a/views/css/admin/saferpay_fields.css b/src/DTO/Request/GetLicense/GetLicenseRequest.php old mode 100755 new mode 100644 similarity index 50% rename from views/css/admin/saferpay_fields.css rename to src/DTO/Request/GetLicense/GetLicenseRequest.php index 064c2c333..0cf139810 --- a/views/css/admin/saferpay_fields.css +++ b/src/DTO/Request/GetLicense/GetLicenseRequest.php @@ -1,3 +1,4 @@ +customerId = $customerId; + } + + /** + * @return string + */ + public function generateRequestUrl() + { + return sprintf('rest/customers/%s/license', $this->customerId); + } -.field-container { - display: flex; - flex-wrap: wrap; + /** + * @return string + */ + public function generateFallbackRequestUrl() + { + return sprintf('rest/customers/%s/license-configuration', $this->customerId); + } } diff --git a/src/DTO/Request/GetTerminals/GetTerminalsRequest.php b/src/DTO/Request/GetTerminals/GetTerminalsRequest.php index 2d3b6b3f3..6e32d45af 100644 --- a/src/DTO/Request/GetTerminals/GetTerminalsRequest.php +++ b/src/DTO/Request/GetTerminals/GetTerminalsRequest.php @@ -37,6 +37,10 @@ class GetTerminalsRequest */ public function __construct($customerId) { + if (!preg_match('/^[a-zA-Z0-9\-_]+$/', $customerId)) { + throw new \InvalidArgumentException('Invalid customer ID format'); + } + $this->customerId = $customerId; } diff --git a/src/Entity/index.php b/src/Entity/index.php deleted file mode 100755 index ee6227264..000000000 --- a/src/Entity/index.php +++ /dev/null @@ -1,31 +0,0 @@ - - *@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/src/Install/AbstractInstaller.php b/src/Install/AbstractInstaller.php index cbe8d897d..2188841f0 100755 --- a/src/Install/AbstractInstaller.php +++ b/src/Install/AbstractInstaller.php @@ -63,12 +63,6 @@ public function tabs() 'module_tab' => true, 'visible' => false, ], - [ - 'name' => $this->module->l('Fields'), - 'class_name' => SaferPayOfficial::ADMIN_FIELDS_CONTROLLER, - 'parent_class_name' => SaferPayOfficial::ADMIN_SAFERPAY_MODULE_CONTROLLER, - 'module_tab' => true, - ], [ 'name' => $this->module->l('Order'), 'class_name' => SaferPayOfficial::ADMIN_ORDER_CONTROLLER, diff --git a/src/Service/Request/RequestObjectCreator.php b/src/Service/Request/RequestObjectCreator.php index 6e961489f..21fc39835 100755 --- a/src/Service/Request/RequestObjectCreator.php +++ b/src/Service/Request/RequestObjectCreator.php @@ -118,14 +118,19 @@ public function createPayment(Cart $cart, $totalPrice) $payment = new Payment(); $payment->setValue($totalPrice); $payment->setCurrencyCode($currency['iso_code']); - $payment->setDescription((string) Configuration::get(SaferPayConfig::SAFERPAY_PAYMENT_DESCRIPTION)); - if ((int) \Configuration::get(SaferPayConfig::SAFERPAY_ORDER_CREATION_AFTER_AUTHORIZATION) && empty($order)) { - return $payment; + $description = (string) Configuration::get(SaferPayConfig::SAFERPAY_PAYMENT_DESCRIPTION); + $orderIdOption = (int) Configuration::get(SaferPayConfig::SAFERPAY_ORDER_ID_OPTION); + + if ($orderIdOption === 0 && !empty($order)) { + $payment->setDescription($order->reference); + } else { + $payment->setDescription($description); } - /** This param is not mandatory, but recommended **/ - $payment->setOrderReference($order->reference); + if (!empty($order)) { + $payment->setOrderReference($order->reference); + } return $payment; } diff --git a/src/Service/SaferPayGenerateFieldAccessToken.php b/src/Service/SaferPayGenerateFieldAccessToken.php new file mode 100644 index 000000000..1609cdd37 --- /dev/null +++ b/src/Service/SaferPayGenerateFieldAccessToken.php @@ -0,0 +1,81 @@ + + *@copyright SIX Payment Services + *@license SIX Payment Services + */ + +namespace Invertus\SaferPay\Service; + +use Exception; +use Invertus\SaferPay\Api\Request\GenerateFieldAccessTokenService; +use Invertus\SaferPay\Config\SaferPayConfig; +use Invertus\SaferPay\DTO\Request\GenerateFieldAccessToken\GenerateFieldAccessTokenRequest; + +if (!defined('_PS_VERSION_')) { + exit; +} + +class SaferPayGenerateFieldAccessToken +{ + /** @var GenerateFieldAccessTokenService */ + private $generateFieldAccessTokenService; + + public function __construct(GenerateFieldAccessTokenService $generateFieldAccessTokenService) + { + $this->generateFieldAccessTokenService = $generateFieldAccessTokenService; + } + + /** + * @param string $username + * @param string $password + * @param string $customerId + * @param string $terminalId + * @param bool $isTestMode + * @param string $shopUrl + * + * @return string + * + * @throws Exception + */ + public function generateWithCredentials($username, $password, $customerId, $terminalId, $isTestMode, $shopUrl) + { + $baseUrl = $isTestMode ? SaferPayConfig::TEST_API : SaferPayConfig::API; + $request = new GenerateFieldAccessTokenRequest($customerId, $terminalId); + + $params = [ + 'Description' => 'PrestaShop Module', + 'SourceUrls' => [$shopUrl], + ]; + + $response = $this->generateFieldAccessTokenService->generateToken( + $request, + $username, + $password, + $baseUrl, + $params + ); + + if (!isset($response->AccessToken)) { + throw new Exception('Unexpected API response: no access token returned'); + } + + return $response->AccessToken; + } +} diff --git a/src/Service/SaferPayGetLicense.php b/src/Service/SaferPayGetLicense.php new file mode 100644 index 000000000..a88e410a6 --- /dev/null +++ b/src/Service/SaferPayGetLicense.php @@ -0,0 +1,106 @@ + + *@copyright SIX Payment Services + *@license SIX Payment Services + */ + +namespace Invertus\SaferPay\Service; + +use Exception; +use Invertus\SaferPay\Api\Request\GetLicenseService; +use Invertus\SaferPay\Config\SaferPayConfig; +use Invertus\SaferPay\DTO\Request\GetLicense\GetLicenseRequest; + +if (!defined('_PS_VERSION_')) { + exit; +} + +class SaferPayGetLicense +{ + const FEATURE_HOSTED_ENTRY_FORM = 'HOSTED_ENTRY_FORM'; + + /** @var GetLicenseService */ + private $getLicenseService; + + public function __construct(GetLicenseService $getLicenseService) + { + $this->getLicenseService = $getLicenseService; + } + + /** + * @param string $username + * @param string $password + * @param string $customerId + * @param bool $isTestMode + * + * @return array{hasBusinessLicense: bool, packageName: string, features: array} + * + * @throws Exception + */ + public function fetchLicenseWithCredentials($username, $password, $customerId, $isTestMode) + { + $baseUrl = $isTestMode ? SaferPayConfig::TEST_API : SaferPayConfig::API; + $request = new GetLicenseRequest($customerId); + + $response = $this->fetchWithFallback($request, $username, $password, $baseUrl); + + $packageName = ''; + if (isset($response->Package->DisplayName)) { + $packageName = $response->Package->DisplayName; + } + + $features = []; + $featureList = isset($response->Features) ? $response->Features : []; + if (is_array($featureList)) { + foreach ($featureList as $feature) { + if (isset($feature->Id)) { + $features[] = $feature->Id; + } + } + } + + $hasBusinessLicense = in_array(self::FEATURE_HOSTED_ENTRY_FORM, $features, true); + + return [ + 'hasBusinessLicense' => $hasBusinessLicense, + 'packageName' => $packageName, + 'features' => $features, + ]; + } + + /** + * @param GetLicenseRequest $request + * @param string $username + * @param string $password + * @param string $baseUrl + * + * @return mixed + * + * @throws Exception + */ + private function fetchWithFallback(GetLicenseRequest $request, $username, $password, $baseUrl) + { + try { + return $this->getLicenseService->getLicense($request, $username, $password, $baseUrl); + } catch (Exception $e) { + return $this->getLicenseService->getLicenseFallback($request, $username, $password, $baseUrl); + } + } +} diff --git a/src/Service/SettingsTranslationService.php b/src/Service/SettingsTranslationService.php new file mode 100644 index 000000000..ae2de9990 --- /dev/null +++ b/src/Service/SettingsTranslationService.php @@ -0,0 +1,264 @@ + + *@copyright SIX Payment Services + *@license SIX Payment Services + */ + +namespace Invertus\SaferPay\Service; + +use Invertus\SaferPay\Factory\ModuleFactory; +use SaferPayOfficial; + +if (!defined('_PS_VERSION_')) { + exit; +} + +class SettingsTranslationService +{ + const FILE_NAME = 'SettingsTranslationService'; + + /** @var SaferPayOfficial */ + private $module; + + public function __construct(ModuleFactory $moduleFactory) + { + $this->module = $moduleFactory->getModule(); + } + + /** + * @return array + */ + public function getAll() + { + return array_merge( + $this->getAppTranslations(), + $this->getTabTranslations(), + $this->getCommonTranslations(), + $this->getApiCredentialsTranslations(), + $this->getPaymentMethodsTranslations(), + $this->getPaymentProcessingTranslations(), + $this->getEmailTranslations(), + $this->getGeneralSettingsTranslations(), + $this->getToastTranslations() + ); + } + + private function getAppTranslations() + { + return [ + 'saferpaySettings' => $this->module->l('Saferpay Settings', self::FILE_NAME), + 'configureIntegration' => $this->module->l('Configure your Saferpay payment integration for your Prestashop store.', self::FILE_NAME), + 'errorLoadingSettings' => $this->module->l('Something went wrong loading Saferpay settings. Please refresh the page.', self::FILE_NAME), + 'failedToLoadSettings' => $this->module->l('Failed to load settings data.', self::FILE_NAME), + ]; + } + + private function getTabTranslations() + { + return [ + 'tabApiCredentials' => $this->module->l('API Credentials', self::FILE_NAME), + 'tabPaymentMethods' => $this->module->l('Payment Methods', self::FILE_NAME), + 'tabPaymentProcessing' => $this->module->l('Payment Processing', self::FILE_NAME), + 'tabEmailNotifications' => $this->module->l('Email Notifications', self::FILE_NAME), + 'tabGeneralSettings' => $this->module->l('General Settings', self::FILE_NAME), + ]; + } + + private function getCommonTranslations() + { + return [ + 'saveChanges' => $this->module->l('Save Changes', self::FILE_NAME), + 'saving' => $this->module->l('Saving...', self::FILE_NAME), + 'enable' => $this->module->l('Enable', self::FILE_NAME), + 'disable' => $this->module->l('Disable', self::FILE_NAME), + 'search' => $this->module->l('Search...', self::FILE_NAME), + 'noResultsFound' => $this->module->l('No results found.', self::FILE_NAME), + 'clearAll' => $this->module->l('Clear all', self::FILE_NAME), + 'selected' => $this->module->l('selected', self::FILE_NAME), + ]; + } + + private function getApiCredentialsTranslations() + { + return [ + 'environment' => $this->module->l('Environment', self::FILE_NAME), + 'envDescription' => $this->module->l('Select your active environment. Credentials are stored separately for each.', self::FILE_NAME), + 'selectEnvironment' => $this->module->l('Select environment', self::FILE_NAME), + 'testEnvironment' => $this->module->l('Test Environment', self::FILE_NAME), + 'liveEnvironment' => $this->module->l('Live Environment', self::FILE_NAME), + 'testModeWarning' => $this->module->l('You are currently in test mode. No real transactions will be processed.', self::FILE_NAME), + 'liveModeWarning' => $this->module->l('You are in live mode. Real transactions will be processed.', self::FILE_NAME), + 'test' => $this->module->l('Test', self::FILE_NAME), + 'live' => $this->module->l('Live', self::FILE_NAME), + 'apiCredentials' => $this->module->l('API Credentials', self::FILE_NAME), + 'enterSaferpayCredentials' => $this->module->l('Enter your Saferpay %s environment API credentials.', self::FILE_NAME), + 'jsonApiUsername' => $this->module->l('JSON API Username', self::FILE_NAME), + 'enterApiUsername' => $this->module->l('Enter %s API username', self::FILE_NAME), + 'jsonApiPassword' => $this->module->l('JSON API Password', self::FILE_NAME), + 'enterApiPassword' => $this->module->l('Enter %s API password', self::FILE_NAME), + 'hidePassword' => $this->module->l('Hide password', self::FILE_NAME), + 'showPassword' => $this->module->l('Show password', self::FILE_NAME), + 'terminalId' => $this->module->l('Terminal ID', self::FILE_NAME), + 'selectTerminal' => $this->module->l('Select a terminal', self::FILE_NAME), + 'refreshTerminals' => $this->module->l('Refresh terminals', self::FILE_NAME), + 'fetchTerminalsFromApi' => $this->module->l('Fetch terminals from API', self::FILE_NAME), + 'merchantEmails' => $this->module->l('Merchant Emails', self::FILE_NAME), + 'enterMerchantEmails' => $this->module->l('Enter merchant email addresses (comma-separated)', self::FILE_NAME), + 'separateEmails' => $this->module->l('These email addresses receive payment notification emails directly from SaferPay. Separate multiple email addresses with commas.', self::FILE_NAME), + 'invalidMerchantEmails' => $this->module->l('Invalid email address', self::FILE_NAME), + 'saferpayFields' => $this->module->l('Saferpay Fields', self::FILE_NAME), + 'saferpayFieldsDescription' => $this->module->l('Configure Saferpay Fields for inline payment form integration.', self::FILE_NAME), + 'fieldAccessTokenInfo' => $this->module->l('Saferpay Field Access Token can be found in Saferpay Backoffice, navigate to', self::FILE_NAME), + 'fieldAccessTokenPath' => $this->module->l('Settings', self::FILE_NAME) . ' > ' . $this->module->l('Saferpay Fields Access Tokens', self::FILE_NAME), + 'fieldAccessToken' => $this->module->l('Field Access Token', self::FILE_NAME), + 'enterFieldAccessToken' => $this->module->l('Enter or generate token', self::FILE_NAME), + 'generate' => $this->module->l('Generate', self::FILE_NAME), + 'enterCredentialsToGenerateToken' => $this->module->l('Enter your API credentials first to generate a token.', self::FILE_NAME), + 'moreInformation' => $this->module->l('More information', self::FILE_NAME), + 'fieldJsUrl' => $this->module->l('Field Javascript Library URL', self::FILE_NAME), + 'findLibraryUrlHere' => $this->module->l('Find the library URL here', self::FILE_NAME), + 'enterCredentialsFirst' => $this->module->l('Enter credentials first', self::FILE_NAME), + 'enterCredentialsToLoadTerminals' => $this->module->l('Enter your API username and password first to load available terminals.', self::FILE_NAME), + 'validatingCredentials' => $this->module->l('Validating credentials...', self::FILE_NAME), + 'credentialsValid' => $this->module->l('Credentials verified successfully.', self::FILE_NAME), + 'invalidCredentials' => $this->module->l('Invalid credentials. Please check your username and password.', self::FILE_NAME), + 'saferpayFieldsIncluded' => $this->module->l('Saferpay Fields is included in your license', self::FILE_NAME), + 'saferpayFieldsIncludedDescription' => $this->module->l('You can use hosted payment fields for a seamless checkout experience.', self::FILE_NAME), + 'tokenGeneratedSuccessfully' => $this->module->l('Access token generated successfully.', self::FILE_NAME), + 'failedToGenerateToken' => $this->module->l('Failed to generate access token.', self::FILE_NAME), + ]; + } + + private function getPaymentMethodsTranslations() + { + return [ + 'paymentMethods' => $this->module->l('Payment Methods', self::FILE_NAME), + 'paymentMethodsDescription' => $this->module->l('Enable and configure available payment methods for your checkout.', self::FILE_NAME), + 'active' => $this->module->l('active', self::FILE_NAME), + '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('Custom form', 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), + 'selectCurrencies' => $this->module->l('Select currencies', self::FILE_NAME), + 'select' => $this->module->l('Select', self::FILE_NAME), + 'noPaymentMethods' => $this->module->l('No payment methods available. Please configure your API credentials first.', self::FILE_NAME), + ]; + } + + private function getPaymentProcessingTranslations() + { + return [ + 'transactionHandling' => $this->module->l('Transaction Handling', self::FILE_NAME), + 'transactionHandlingDescription' => $this->module->l('Configure how payments are processed, authorized, and captured.', self::FILE_NAME), + 'defaultPaymentBehavior' => $this->module->l('Default payment behavior', self::FILE_NAME), + 'paymentBehaviorDescription' => $this->module->l('How payment provider should behave when order is created.', self::FILE_NAME), + 'capture' => $this->module->l('Capture', self::FILE_NAME), + 'chargeImmediately' => $this->module->l('Charge immediately', self::FILE_NAME), + 'authorize' => $this->module->l('Authorize', self::FILE_NAME), + 'reserveAndCaptureLater' => $this->module->l('Reserve and capture later', self::FILE_NAME), + 'behaviourWhen3dsFails' => $this->module->l('Behavior when 3D Secure Payer Authentication was not successful and liability shift was not granted', self::FILE_NAME), + 'behaviourWhen3dsDescription' => $this->module->l('Default payment behavior for payment without 3-D Secure.', self::FILE_NAME), + 'cancel' => $this->module->l('Cancel', self::FILE_NAME), + 'rejectPayment' => $this->module->l('Reject the payment', self::FILE_NAME), + 'continueWithout3ds' => $this->module->l('Continue without 3DS', self::FILE_NAME), + 'captureWithout3ds' => $this->module->l('Charge immediately', self::FILE_NAME), + 'restrictRefundAmount' => $this->module->l('Restrict RefundAmount to Captured Amount', self::FILE_NAME), + 'restrictRefundDescription' => $this->module->l('If set to true, the refund will be rejected if the sum of authorized refunds exceeds the capture value.', self::FILE_NAME), + 'orderCreationRule' => $this->module->l('Order creation rule', self::FILE_NAME), + 'orderCreationDescription' => $this->module->l('Select the option to determine whether the order should be created.', self::FILE_NAME), + 'afterAuthorization' => $this->module->l('After authorization', self::FILE_NAME), + 'createWhenAuthorized' => $this->module->l('Create when authorized', self::FILE_NAME), + 'beforeAuthorization' => $this->module->l('Before authorization', self::FILE_NAME), + 'createBeforePayment' => $this->module->l('Create before payment', self::FILE_NAME), + 'cardDisplaySaving' => html_entity_decode($this->module->l('Card Display & Saving', self::FILE_NAME), ENT_QUOTES, 'UTF-8'), + 'cardDisplayDescription' => $this->module->l('Configure how cards appear at checkout and whether customers can save them.', self::FILE_NAME), + 'groupCardsLabel' => $this->module->l('Group debit/credit cards as \'Cards\' in checkout', self::FILE_NAME), + 'groupCardsDescription' => $this->module->l('If enabled, all supported card brands will be grouped and shown as a single \'Cards\' payment method at checkout.', self::FILE_NAME), + 'showCardsLogo' => $this->module->l('Show \'Cards\' payment method logo', self::FILE_NAME), + '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), + ]; + } + + private function getEmailTranslations() + { + return [ + 'emailSending' => $this->module->l('Email Sending', self::FILE_NAME), + 'emailSendingDescription' => $this->module->l('Configure which emails are sent during the payment process.', self::FILE_NAME), + 'saferpayCustomerMail' => $this->module->l('Send an email from Saferpay on payment completion', self::FILE_NAME), + 'saferpayCustomerMailDescription' => $this->module->l('With this setting enabled an email from the Saferpay system will be sent to the customer.', self::FILE_NAME), + 'newOrderMail' => $this->module->l('Send new order mail on authorization', self::FILE_NAME), + 'newOrderMailDescription' => $this->module->l('Receive a notification when an order is authorized by Saferpay (Using the Mail alert module).', self::FILE_NAME), + 'orderConfMail' => $this->module->l('Send order confirmation mail on payment completion', self::FILE_NAME), + 'orderConfMailDescription' => $this->module->l('Send an email from Saferpay on payment completion.', self::FILE_NAME), + 'emailConfInfo' => $this->module->l('When this feature is enabled, a confirmation email will be only sent once the payment is authorized by Saferpay.', self::FILE_NAME), + 'emailConfMailAlert' => $this->module->l('For this feature to be functioning you need to have the Mail Alert module configured.', self::FILE_NAME), + ]; + } + + private function getGeneralSettingsTranslations() + { + return [ + 'orderState' => $this->module->l('Order State', self::FILE_NAME), + 'orderStateDescription' => $this->module->l('Define the default order status for Saferpay payments.', self::FILE_NAME), + 'statusAwaitingPayment' => $this->module->l('Status for Saferpay payment awaiting', self::FILE_NAME), + 'selectOrderStatus' => $this->module->l('Select order status', self::FILE_NAME), + 'defaultStatusDescription' => $this->module->l('Default status on SaferPay order creation.', self::FILE_NAME), + 'styling' => $this->module->l('Styling', self::FILE_NAME), + 'stylingDescription' => $this->module->l('Customize the appearance of the payment page.', self::FILE_NAME), + 'configName' => $this->module->l('Payment Page configurations name', self::FILE_NAME), + 'enterConfigName' => $this->module->l('Enter configuration name', self::FILE_NAME), + 'configNameDescription' => html_entity_decode($this->module->l('Name of the Payment Page Configuration created in Saferpay Backoffice (Settings > Payment Page Configuration). Max 20 characters. Allowed: letters, numbers, dots, colons, hyphens, underscores.', self::FILE_NAME), ENT_QUOTES, 'UTF-8'), + 'hostedFieldInfo' => html_entity_decode($this->module->l('This style applies only to payment methods with "Custom form" enabled in the Payment Methods list. Methods without Custom form or paid with saved cards use the Saferpay-hosted payment page, whose appearance is controlled by "Payment Page configurations name".', self::FILE_NAME), ENT_QUOTES, 'UTF-8'), + 'hostedFieldStyle' => $this->module->l('Hosted field style', self::FILE_NAME), + 'hostedFieldStyleDescription' => $this->module->l('Select the card input form layout for the payment page.', self::FILE_NAME), + 'classicLayout' => $this->module->l('Classic Layout', self::FILE_NAME), + 'labeledLayout' => $this->module->l('Labeled Layout', self::FILE_NAME), + 'inlineLayoutWithCard' => $this->module->l('Inline Layout with Card', self::FILE_NAME), + 'configuration' => $this->module->l('Configuration', self::FILE_NAME), + 'configurationDescription' => $this->module->l('General module configuration settings.', self::FILE_NAME), + 'description' => $this->module->l('Description', self::FILE_NAME), + 'enterDescription' => $this->module->l('Enter description', self::FILE_NAME), + 'descriptionHelp' => $this->module->l('This description is visible in payment page also in payment confirmation email.', self::FILE_NAME), + 'orderReferenceOnPaymentPage' => $this->module->l('Order reference on payment page', self::FILE_NAME), + 'usePrestaShopOrderReference' => $this->module->l('Use PrestaShop Order reference (default)', self::FILE_NAME), + 'useDescriptionFieldValue' => $this->module->l('Use Description field value', self::FILE_NAME), + 'orderReferenceFallbackInfo' => html_entity_decode($this->module->l('When "Use PrestaShop Order reference" is selected and the order is not yet created (e.g. order creation after authorization), the Description field value is used as fallback.', self::FILE_NAME), ENT_QUOTES, 'UTF-8'), + 'debugMode' => $this->module->l('Debug mode', self::FILE_NAME), + 'debugModeDescription' => $this->module->l('Enable debug mode to see more information in logs.', self::FILE_NAME), + ]; + } + + private function getToastTranslations() + { + return [ + 'failedToFetchTerminals' => $this->module->l('Failed to fetch terminals', self::FILE_NAME), + 'errorFetchingTerminals' => $this->module->l('Error fetching terminals', self::FILE_NAME), + 'savedSuccessfully' => $this->module->l('%s saved successfully', self::FILE_NAME), + 'failedToSave' => $this->module->l('Failed to save %s', self::FILE_NAME), + 'errorSaving' => $this->module->l('Error saving %s: %s', self::FILE_NAME), + 'errorRefreshingPaymentMethods' => $this->module->l('Error refreshing payment methods: %s', self::FILE_NAME), + ]; + } +} diff --git a/translations/en.php b/translations/en.php new file mode 100644 index 000000000..e69de29bb diff --git a/upgrade/install-1.0.3.php b/upgrade/install-1.0.3.php index 2574c76e5..b4938c7f3 100755 --- a/upgrade/install-1.0.3.php +++ b/upgrade/install-1.0.3.php @@ -57,12 +57,5 @@ function upgrade_module_1_0_3($module) ADD COLUMN `authorized` TINYINT(1) DEFAULT 0' ); - $installer = new \Invertus\SaferPay\Install\Installer($module); - $installer->installTab( - SaferPayOfficial::ADMIN_FIELDS_CONTROLLER, - SaferPayOfficial::ADMIN_SAFERPAY_MODULE_CONTROLLER, - $module->l('Fields') - ); - return $result; } diff --git a/views/templates/admin/partials/field-hosted-field-template-desc.tpl b/upgrade/install-2.1.1.php old mode 100755 new mode 100644 similarity index 75% rename from views/templates/admin/partials/field-hosted-field-template-desc.tpl rename to upgrade/install-2.1.1.php index 8314405ef..b7b39ae00 --- a/views/templates/admin/partials/field-hosted-field-template-desc.tpl +++ b/upgrade/install-2.1.1.php @@ -1,4 +1,5 @@ -{** + *@copyright SIX Payment Services *@license SIX Payment Services - *} -
- {l s='Choose which hosted field will be displayed on payment option selection with supported payment methods' mod='saferpayofficial'} -
+ */ + +if (!defined('_PS_VERSION_')) { + exit; +} + +function upgrade_module_2_1_1() +{ + $tabId = Tab::getIdFromClassName('AdminSaferPayOfficialFields'); + if ($tabId) { + $tab = new Tab($tabId); + $tab->delete(); + } + + return true; +} diff --git a/views/css/admin/logs_tab.css b/views/css/admin/logs_tab.css index 5bb36a92f..702cff1ec 100755 --- a/views/css/admin/logs_tab.css +++ b/views/css/admin/logs_tab.css @@ -73,10 +73,31 @@ border-bottom: solid 1px grey; pointer-events: all; display: flex; - justify-content: center; + justify-content: space-between; + align-items: center; max-height: 10vh; } +.log-modal-close { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + padding: 0.25rem 0.5rem; + line-height: 1; + color: #6b7280; + margin-right: 0.5rem; +} + +.log-modal-close:hover { + color: #111827; +} + +.log-modal-close:focus { + outline: 2px solid #2196F3; + outline-offset: 2px; +} + .log-modal-content { padding: 15px; height: 50vh; diff --git a/views/css/admin/payment_method.css b/views/css/admin/payment_method.css index 336eda6f0..9b0b08c91 100755 --- a/views/css/admin/payment_method.css +++ b/views/css/admin/payment_method.css @@ -43,6 +43,12 @@ width: 0; } +/* Visible focus indicator for keyboard navigation */ +.container-checkbox input:focus ~ .checkmark { + outline: 2px solid #2196F3; + outline-offset: 2px; +} + /* Create a custom checkbox */ .checkmark { position: absolute; diff --git a/views/js/admin/log.js b/views/js/admin/log.js index 4a8eb769a..a16cecc4b 100644 --- a/views/js/admin/log.js +++ b/views/js/admin/log.js @@ -21,17 +21,65 @@ */ $(document).ready(function () { + function closeModal($modal) { + $modal.removeClass('open'); + var triggerButton = $modal.data('triggerButton'); + if (triggerButton) { + triggerButton.focus(); + } + } + $('.log-modal-overlay').on('click', function (event) { - $('.modal.open').removeClass('open'); + closeModal($(this).closest('.modal')); + event.preventDefault(); + }); + + $('.js-log-modal-close').on('click', function (event) { + closeModal($(this).closest('.modal')); event.preventDefault(); }); + $(document).on('keydown', function (event) { + var $openModal = $('.modal.open'); + if (!$openModal.length) { + return; + } + if (event.key === 'Escape') { + closeModal($openModal); + event.preventDefault(); + return; + } + if (event.key === 'Tab') { + var focusables = $openModal.find('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])').filter(':visible'); + if (!focusables.length) { + event.preventDefault(); + return; + } + var first = focusables.first()[0]; + var last = focusables.last()[0]; + if (event.shiftKey && document.activeElement === first) { + last.focus(); + event.preventDefault(); + } else if (!event.shiftKey && document.activeElement === last) { + first.focus(); + event.preventDefault(); + } else if (!$openModal[0].contains(document.activeElement)) { + first.focus(); + event.preventDefault(); + } + } + }); + $('.js-log-button').on('click', function (event) { var logId = $(this).data('log-id'); var informationType = $(this).data('information-type'); + var $modal = $('#' + $(this).data('target')); + + $modal.data('triggerButton', $(this)); // NOTE: opening modal - $('#' + $(this).data('target')).addClass('open'); + $modal.addClass('open'); + $modal.find('.js-log-modal-close').focus(); // NOTE: if information has been set already we don't need to call ajax again. if (!$('#log-modal-' + logId + '-' + informationType + ' .log-modal-content-data').hasClass('hidden')) { diff --git a/views/js/admin/saferpay_settings.js b/views/js/admin/saferpay_settings.js index 6407bc177..72f482b10 100755 --- a/views/js/admin/saferpay_settings.js +++ b/views/js/admin/saferpay_settings.js @@ -20,16 +20,23 @@ *@license SIX Payment Services */ -$(document).ready(function (e) { - $("input[name='SAFERPAY_CONFIGURATION_NAME']").keypress(function (e) { - //disable symbols +$(document).ready(function () { + var $configInput = $("input[name='SAFERPAY_CONFIGURATION_NAME']"); + + $configInput.attr('maxlength', 20); + + $configInput.keypress(function (e) { var txt = String.fromCharCode(e.which); - if (!txt.match(/[A-Za-z0-9&. ]/)) { - return false; - } - // disable space - if (e.keyCode === 32) { + if (!txt.match(/[A-Za-z0-9.:\-_]/)) { return false; } }); + + $configInput.on('paste', function (e) { + var $input = $(this); + setTimeout(function () { + var cleaned = $input.val().replace(/[^A-Za-z0-9.:\-_]/g, '').substring(0, 20); + $input.val(cleaned); + }, 0); + }); }); \ No newline at end of file diff --git a/views/js/admin/settings-app/src/App.tsx b/views/js/admin/settings-app/src/App.tsx index b2edbdd38..849fe5f53 100644 --- a/views/js/admin/settings-app/src/App.tsx +++ b/views/js/admin/settings-app/src/App.tsx @@ -1,10 +1,39 @@ +import React from 'react' import { SettingsProvider } from './context/settings-context' import { SaferpaySettings } from './components/settings/saferpay-settings' +import { t } from '@/utils/translations' + +class ErrorBoundary extends React.Component< + { children: React.ReactNode }, + { hasError: boolean } +> { + constructor(props: { children: React.ReactNode }) { + super(props) + this.state = { hasError: false } + } + + static getDerivedStateFromError() { + return { hasError: true } + } + + render() { + if (this.state.hasError) { + return ( +
+ {t('errorLoadingSettings')} +
+ ) + } + return this.props.children + } +} export default function App() { return ( - - - + + + + + ) } diff --git a/views/js/admin/settings-app/src/api/client.ts b/views/js/admin/settings-app/src/api/client.ts index d75d68602..9fa4a8149 100644 --- a/views/js/admin/settings-app/src/api/client.ts +++ b/views/js/admin/settings-app/src/api/client.ts @@ -1,13 +1,18 @@ import type { PaymentMethodData, TerminalOption } from '@/types' +interface AjaxResponse { + success: boolean + message?: string +} + function getConfig() { return window.saferpaySettingsData } -async function postAjax(action: string, data: Record = {}) { +async function postAjax(action: string, data: Record = {}): Promise { const config = getConfig() const separator = config.ajaxUrl.includes('?') ? '&' : '?' - const url = `${config.ajaxUrl}${separator}ajax=1&action=${action}` + const url = `${config.ajaxUrl}${separator}ajax=1&action=${action}&token=${encodeURIComponent(config.adminToken)}` const response = await fetch(url, { method: 'POST', @@ -22,26 +27,31 @@ async function postAjax(action: string, data: Record = {}) { throw new Error(`HTTP error ${response.status}`) } - return response.json() + const result = await response.json() + if (typeof result !== 'object' || result === null || typeof result.success !== 'boolean') { + throw new Error('Invalid response format') + } + + return result } -export async function saveCredentials(data: Record): Promise<{ success: boolean; message?: string }> { +export async function saveCredentials(data: Record): Promise { return postAjax('saveCredentials', data) } -export async function savePaymentProcessing(data: Record): Promise<{ success: boolean; message?: string }> { +export async function savePaymentProcessing(data: Record): Promise { return postAjax('savePaymentProcessing', data) } -export async function saveEmailSettings(data: Record): Promise<{ success: boolean; message?: string }> { +export async function saveEmailSettings(data: Record): Promise { return postAjax('saveEmailSettings', data) } -export async function saveGeneralSettings(data: Record): Promise<{ success: boolean; message?: string }> { +export async function saveGeneralSettings(data: Record): Promise { return postAjax('saveGeneralSettings', data) } -export async function savePaymentMethods(methods: PaymentMethodData[]): Promise<{ success: boolean; message?: string }> { +export async function savePaymentMethods(methods: PaymentMethodData[]): Promise { return postAjax('savePaymentMethods', { paymentMethods: methods }) } @@ -49,11 +59,27 @@ export async function getTerminals( env: string, username: string, password: string, - customerId: string, -): Promise<{ success: boolean; terminals: TerminalOption[] }> { - return postAjax('getTerminals', { env, username, password, customerId }) +): Promise<{ success: boolean; message?: string; terminals: TerminalOption[] }> { + return postAjax('getTerminals', { env, username, password }) as Promise<{ + success: boolean + message?: string + terminals: TerminalOption[] + }> +} + +export async function generateFieldAccessToken( + env: string, + username: string, + password: string, + terminalId: string, +): Promise<{ success: boolean; message?: string; token?: string }> { + return postAjax('generateFieldAccessToken', { env, username, password, terminalId }) as Promise<{ + success: boolean + message?: string + token?: string + }> } export async function refreshData(): Promise<{ success: boolean; data: Record }> { - return postAjax('refreshData') + return postAjax('refreshData') as Promise<{ success: boolean; data: Record }> } diff --git a/views/js/admin/settings-app/src/components/settings/api-credentials.tsx b/views/js/admin/settings-app/src/components/settings/api-credentials.tsx index 09d0bbfab..d806211bc 100644 --- a/views/js/admin/settings-app/src/components/settings/api-credentials.tsx +++ b/views/js/admin/settings-app/src/components/settings/api-credentials.tsx @@ -1,56 +1,82 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Label } from '@/components/ui/label' import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' -import { Switch } from '@/components/ui/switch' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { AlertCircle, Eye, EyeOff, Key, Shield, Loader2, Info } from 'lucide-react' +import { AlertCircle, Eye, EyeOff, Key, Shield, Loader2, Info, CheckCircle2, Wand2, XCircle } from 'lucide-react' import { useSettings } from '@/context/settings-context' +import { toast } from '@/hooks/use-toast' +import { t } from '@/utils/translations' import type { TerminalOption } from '@/types' +type CredentialStatus = 'idle' | 'checking' | 'valid' | 'invalid' + export function ApiCredentials() { - const { settings, updateSettings, saveCredentials, fetchTerminals, saving } = useSettings() + const { settings, updateSettings, saveCredentials, fetchTerminals, generateFieldAccessToken, savingSections } = useSettings() + const saving = savingSections.has('credentials') const [showApiPassword, setShowApiPassword] = useState(false) - const [isLoadingTerminals, setIsLoadingTerminals] = useState(false) + const [generatingToken, setGeneratingToken] = useState(false) const [terminals, setTerminals] = useState([]) + const [credentialStatus, setCredentialStatus] = useState('idle') + const [credentialError, setCredentialError] = useState('') + const debounceTimer = useRef | null>(null) - const environment = settings.testMode ? 'test' : 'live' const isTest = settings.testMode + const prefix = isTest ? 'test' : 'live' + const envLabel = isTest ? t('test') : t('live') + const environment = isTest ? 'test' : 'live' const username = isTest ? settings.testUsername : settings.liveUsername const password = isTest ? settings.testPassword : settings.livePassword - const customerId = isTest ? settings.testCustomerId : settings.liveCustomerId const terminalId = isTest ? settings.testTerminalId : settings.liveTerminalId const merchantEmails = isTest ? settings.testMerchantEmails : settings.liveMerchantEmails const fieldAccessToken = isTest ? settings.testFieldAccessToken : settings.liveFieldAccessToken const fieldJsUrl = isTest ? settings.testFieldJsUrl : settings.liveFieldJsUrl - const hasBusinessLicense = isTest ? settings.testBusinessLicense : settings.liveBusinessLicense - const prefix = isTest ? 'test' : 'live' + const hasCredentials = username.length > 0 && password.length > 0 + const hasBusinessLicense = isTest ? settings.testHasBusinessLicense : settings.liveHasBusinessLicense + + const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + const invalidEmails = merchantEmails + .split(',') + .map(e => e.trim()) + .filter(e => e.length > 0 && !EMAIL_RE.test(e)) + const merchantEmailsInvalid = invalidEmails.length > 0 + const setField = (field: string, value: string | boolean) => { updateSettings({ [`${prefix}${field.charAt(0).toUpperCase() + field.slice(1)}`]: value } as Record) } - const envLabel = isTest ? 'Test' : 'Live' - const hasCredentials = username.length > 0 && password.length > 0 + // Debounced credential check: when username+password are filled, validate and fetch terminals + useEffect(() => { + if (debounceTimer.current) clearTimeout(debounceTimer.current) - const handleFetchTerminals = async () => { - if (!hasCredentials) return - setIsLoadingTerminals(true) - try { - const result = await fetchTerminals(environment, username, password, customerId) - setTerminals(result) - } finally { - setIsLoadingTerminals(false) + if (!hasCredentials) { + setCredentialStatus('idle') + setCredentialError('') + setTerminals([]) + return } - } - useEffect(() => { - if (hasCredentials && terminals.length === 0) { - handleFetchTerminals() + debounceTimer.current = setTimeout(async () => { + setCredentialStatus('checking') + setCredentialError('') + try { + const result = await fetchTerminals(environment, username, password) + setTerminals(result) + setCredentialStatus('valid') + } catch { + setTerminals([]) + setCredentialStatus('invalid') + setCredentialError(t('invalidCredentials')) + } + }, 1000) + + return () => { + if (debounceTimer.current) clearTimeout(debounceTimer.current) } - }, [environment]) + }, [username, password, environment]) return (
@@ -59,29 +85,32 @@ export function ApiCredentials() {
- Environment - - Select your active environment. Credentials are stored separately for each. - + {t('environment')} + {t('envDescription')}
setField('username', e.target.value)} + required + aria-required="true" />
- +
setField('password', e.target.value)} className="sp-pr-10" + required + aria-required="true" /> @@ -159,108 +193,149 @@ export function ApiCredentials() {
-
-
- - setField('customerId', e.target.value)} - /> + {/* Credential status feedback */} + {credentialStatus === 'checking' && ( +
+ + {t('validatingCredentials')}
-
- -
- - -
- {!hasCredentials && ( -

- Enter your API username and password first to load available terminals. -

- )} + )} + {credentialStatus === 'valid' && ( +
+ + {t('credentialsValid')}
+ )} + {credentialStatus === 'invalid' && ( +
+ + {credentialError} +
+ )} + + {/* Terminal ID */} +
+ + + {!hasCredentials && ( +

+ {t('enterCredentialsToLoadTerminals')} +

+ )}
+ {/* Merchant Emails */}
- + setField('merchantEmails', e.target.value)} + aria-invalid={merchantEmailsInvalid} + className={merchantEmailsInvalid ? 'sp-border-destructive focus-visible:sp-ring-destructive' : ''} /> + {merchantEmailsInvalid && ( +

+ {t('invalidMerchantEmails')}: {invalidEmails.join(', ')} +

+ )}

- Separate multiple email addresses with commas. + {t('separateEmails')}

- {/* Saferpay Fields Configuration */} - + {hasBusinessLicense && - Saferpay Fields - - Configure Saferpay Fields for inline payment form integration. - + {t('saferpayFields')} + {t('saferpayFieldsDescription')}
+
+ +
+

+ {t('saferpayFieldsIncluded')} +

+

+ {t('saferpayFieldsIncludedDescription')} +

+
+
+

- Saferpay Field Access Token can be found in Saferpay Backoffice, navigate to{' '} - {'Settings > Saferpay Fields Access Tokens'}. + {t('fieldAccessTokenInfo')}{' '} + {t('fieldAccessTokenPath')}.{' '} + {t('moreInformation')}

- - setField('fieldAccessToken', e.target.value)} - /> + +
+ setField('fieldAccessToken', e.target.value)} + className="sp-flex-1" + /> + +
+

+ {t('enterCredentialsToGenerateToken')} +

- + setField('fieldJsUrl', e.target.value)} /> + + {t('findLibraryUrlHere')} +
- -
-
- -

- Enable if you have a Saferpay Business license for advanced features. -

-
- setField('businessLicense', checked)} - /> -
-
+
}
-
diff --git a/views/js/admin/settings-app/src/components/settings/email-notifications.tsx b/views/js/admin/settings-app/src/components/settings/email-notifications.tsx index 7c5b702d1..d79d331ac 100644 --- a/views/js/admin/settings-app/src/components/settings/email-notifications.tsx +++ b/views/js/admin/settings-app/src/components/settings/email-notifications.tsx @@ -4,9 +4,11 @@ import { Button } from '@/components/ui/button' import { Switch } from '@/components/ui/switch' import { Mail, Info, Loader2 } from 'lucide-react' import { useSettings } from '@/context/settings-context' +import { t } from '@/utils/translations' export function EmailNotifications() { - const { settings, updateSettings, saveEmailSettings, saving } = useSettings() + const { settings, updateSettings, saveEmailSettings, savingSections } = useSettings() + const saving = savingSections.has('emailSettings') return (
@@ -15,9 +17,9 @@ export function EmailNotifications() {
- Email Sending + {t('emailSending')} - Configure which emails are sent during the payment process. + {t('emailSendingDescription')}
@@ -27,10 +29,10 @@ export function EmailNotifications() {

- With this setting enabled an email from the Saferpay system will be sent to the customer. + {t('saferpayCustomerMailDescription')}

- Receive a notification when an order is authorized by Saferpay (Using the Mail alert module). + {t('newOrderMailDescription')}

- Send an email from Saferpay on payment completion. + {t('orderConfMailDescription')}

- When this feature is enabled, a confirmation email will be only sent once the payment is authorized by Saferpay. + {t('emailConfInfo')}

- For this feature to be functioning you need to have the Mail Alert module configured. + {t('emailConfMailAlert')}

@@ -88,8 +90,8 @@ export function EmailNotifications() {
-
diff --git a/views/js/admin/settings-app/src/components/settings/general-settings.tsx b/views/js/admin/settings-app/src/components/settings/general-settings.tsx index 6f6ff92e6..52b795687 100644 --- a/views/js/admin/settings-app/src/components/settings/general-settings.tsx +++ b/views/js/admin/settings-app/src/components/settings/general-settings.tsx @@ -3,12 +3,15 @@ import { Label } from '@/components/ui/label' import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' import { Switch } from '@/components/ui/switch' +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { Settings2, Paintbrush, ClipboardList, Loader2 } from 'lucide-react' +import { Settings2, Paintbrush, ClipboardList, Loader2, Info } from 'lucide-react' import { useSettings } from '@/context/settings-context' +import { t } from '@/utils/translations' export function GeneralSettings() { - const { settings, updateSettings, saveGeneralSettings, saving } = useSettings() + const { settings, updateSettings, saveGeneralSettings, savingSections } = useSettings() + const saving = savingSections.has('generalSettings') return (
@@ -18,22 +21,22 @@ export function GeneralSettings() {
- Order State + {t('orderState')} - Define the default order status for Saferpay payments. + {t('orderStateDescription')}
- +

- Default status on SaferPay order creation. + {t('defaultStatusDescription')}

@@ -56,27 +59,73 @@ export function GeneralSettings() {
- Styling + {t('styling')} - Customize the appearance of the payment page. + {t('stylingDescription')}
- + updateSettings({ configurationName: e.target.value })} + onChange={(e) => { + const cleaned = e.target.value.replace(/[^A-Za-z0-9.:\-_]/g, '') + updateSettings({ configurationName: cleaned }) + }} />

- This name is visible in payment page and also in payment confirmation email. + {t('configNameDescription')}

+ + {/* Hosted field info banner */} +
+ +

+ {t('hostedFieldInfo')} +

+
+ + {/* Hosted field style selector */} +
+
+ + +

+ {t('hostedFieldStyleDescription')} +

+
+
+ { +
+
@@ -86,36 +135,79 @@ export function GeneralSettings() {
- Configuration + {t('configuration')} - General module configuration settings. + {t('configurationDescription')}
-
- - updateSettings({ paymentDescription: e.target.value })} - /> -

- This description is visible in payment page also in payment confirmation email. + {/* Order reference on payment page */} +

+ + updateSettings({ orderIdOption: Number(val) })} + className="sp-flex sp-flex-col sp-gap-3" + > + + + +
+ + {settings.orderIdOption === 1 && ( +
+ + updateSettings({ paymentDescription: e.target.value })} + /> +

+ {t('descriptionHelp')} +

+
+ )} + + {/* Info banner */} +
+ +

+ {t('orderReferenceFallbackInfo')}

- Enable debug mode to see more information in logs. + {t('debugModeDescription')}

-
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 48f31d8db..99dfd735a 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 @@ -9,6 +9,7 @@ import { Checkbox } from '@/components/ui/checkbox' import { Wallet, ChevronDown, Search, Loader2 } from 'lucide-react' import { Input } from '@/components/ui/input' import { useSettings } from '@/context/settings-context' +import { t } from '@/utils/translations' function MultiSelect({ options, @@ -30,15 +31,28 @@ function MultiSelect({ [options, search], ) + const ALL_VALUE = 0 + + const validSelected = useMemo( + () => selected.filter((s) => s !== ALL_VALUE && options.some((o) => o.id === s)), + [selected, options], + ) + + const isAll = selected.includes(ALL_VALUE) || validSelected.length === 0 + const toggle = useCallback( (value: number) => { - onChange( - selected.includes(value) - ? selected.filter((s) => s !== value) - : [...selected, value], - ) + if (value === ALL_VALUE) { + onChange([ALL_VALUE]) + return + } + const base = isAll ? [] : validSelected + const next = base.includes(value) + ? base.filter((s) => s !== value) + : [...base, value] + onChange(next.length === 0 ? [ALL_VALUE] : next) }, - [selected, onChange], + [validSelected, isAll, onChange], ) return ( @@ -47,14 +61,18 @@ function MultiSelect({
@@ -91,7 +109,7 @@ function MultiSelect({ className="sp-flex sp-cursor-pointer sp-items-center sp-gap-2 sp-rounded-sm sp-px-2 sp-py-1.5 sp-text-sm hover:sp-bg-accent" > toggle(option.id)} /> {option.name} @@ -99,18 +117,18 @@ function MultiSelect({ ))} {filteredOptions.length === 0 && (

- No results found. + {t('noResultsFound')}

)}
- {selected.length > 0 && ( + {validSelected.length > 0 && (
)} @@ -120,13 +138,14 @@ function MultiSelect({ } export function PaymentMethods() { - const { paymentMethods, updatePaymentMethod, savePaymentMethods, saving, settings, refreshPaymentMethods } = useSettings() + const { paymentMethods, updatePaymentMethod, savePaymentMethods, savingSections, settings, refreshPaymentMethods } = useSettings() + const saving = savingSections.has('paymentMethods') useEffect(() => { if (paymentMethods.length === 0) { refreshPaymentMethods() } - }, []) + }, [paymentMethods.length, refreshPaymentMethods]) const enabledCount = paymentMethods.filter((m) => m.enabled).length @@ -138,15 +157,15 @@ export function PaymentMethods() {
- Payment Methods + {t('paymentMethods')} - Enable and configure available payment methods for your checkout. + {t('paymentMethodsDescription')}
{enabledCount > 0 && ( - {enabledCount} active + {enabledCount} {t('active')} )}
@@ -154,12 +173,12 @@ export function PaymentMethods() { {/* Header row */}
- Payment method - Enabled - Logos - Custom form - Countries - Currencies + {t('paymentMethod')} + {t('enabled')} + {t('logos')} + {t('customForm')} + {t('countries')} + {t('currencies')}
{/* Payment method rows */} @@ -183,7 +202,7 @@ export function PaymentMethods() { updatePaymentMethod(method.name, { enabled: checked })} - aria-label={`Enable ${method.displayName}`} + aria-label={`${t('enable')} ${method.displayName}`} />
@@ -191,7 +210,7 @@ export function PaymentMethods() { updatePaymentMethod(method.name, { showLogos: checked })} - aria-label={`Show logos for ${method.displayName}`} + aria-label={`${t('logos')} ${method.displayName}`} />
@@ -200,7 +219,7 @@ export function PaymentMethods() { updatePaymentMethod(method.name, { showCustomForm: checked })} - aria-label={`Show custom form for ${method.displayName}`} + aria-label={`${t('customForm')} ${method.displayName}`} /> ) : ( -- @@ -211,16 +230,16 @@ export function PaymentMethods() { options={settings.countries} selected={method.countries} onChange={(countries) => updatePaymentMethod(method.name, { countries })} - placeholder="Select countries" - label={`Countries for ${method.displayName}`} + placeholder={t('selectCountries')} + label={`${t('countries')} ${method.displayName}`} /> ({ id: c.id, name: c.iso_code }))} selected={method.currencies} onChange={(currencies) => updatePaymentMethod(method.name, { currencies })} - placeholder="Select currencies" - label={`Currencies for ${method.displayName}`} + placeholder={t('selectCurrencies')} + label={`${t('currencies')} ${method.displayName}`} /> @@ -231,24 +250,26 @@ export function PaymentMethods() { updatePaymentMethod(method.name, { enabled: checked })} - aria-label={`Enable ${method.displayName}`} + aria-label={`${t('enable')} ${method.displayName}`} />
- + updatePaymentMethod(method.name, { showLogos: checked })} + aria-label={`${t('logos')} ${method.displayName}`} />
{method.hasCustomForm && (
- + updatePaymentMethod(method.name, { showCustomForm: checked })} + aria-label={`${t('customForm')} ${method.displayName}`} />
)} @@ -256,23 +277,23 @@ export function PaymentMethods() {
- + updatePaymentMethod(method.name, { countries })} - placeholder="Select" - label={`Countries for ${method.displayName}`} + placeholder={t('select')} + label={`${t('countries')} ${method.displayName}`} />
- + ({ id: c.id, name: c.iso_code }))} selected={method.currencies} onChange={(currencies) => updatePaymentMethod(method.name, { currencies })} - placeholder="Select" - label={`Currencies for ${method.displayName}`} + placeholder={t('select')} + label={`${t('currencies')} ${method.displayName}`} />
@@ -282,18 +303,20 @@ export function PaymentMethods() { {paymentMethods.length === 0 && (
- No payment methods available. Please configure your API credentials first. + {t('noPaymentMethods')}
)}
-
- -
+ {paymentMethods.length > 0 && ( +
+ +
+ )} ) } 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 c57c9034b..3d26f72da 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 @@ -5,9 +5,11 @@ import { Switch } from '@/components/ui/switch' import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' import { CreditCard, ShieldCheck, Loader2 } from 'lucide-react' import { useSettings } from '@/context/settings-context' +import { t } from '@/utils/translations' export function PaymentProcessing() { - const { settings, updateSettings, savePaymentProcessing, saving } = useSettings() + const { settings, updateSettings, savePaymentProcessing, savingSections } = useSettings() + const saving = savingSections.has('paymentProcessing') return (
@@ -17,9 +19,9 @@ export function PaymentProcessing() {
- Transaction Handling + {t('transactionHandling')} - Configure how payments are processed, authorized, and captured. + {t('transactionHandlingDescription')}
@@ -29,9 +31,9 @@ export function PaymentProcessing() { {/* Default Payment Behavior */}
- +

- How payment provider should behave when order is created. + {t('paymentBehaviorDescription')}

- Capture - Charge immediately + {t('capture')} + {t('chargeImmediately')}
@@ -73,9 +75,9 @@ export function PaymentProcessing() { {/* 3D Secure Behavior */}
- +

- Default payment behavior for payment without 3-D Secure. + {t('behaviourWhen3dsDescription')}

- Cancel - Reject the payment + {t('cancel')} + {t('rejectPayment')}
+
@@ -117,9 +133,9 @@ export function PaymentProcessing() { {/* Restrict Refund */}
- +

- If set to true, the refund will be rejected if the sum of authorized refunds exceeds the capture value. + {t('restrictRefundDescription')}

- Enable + {t('enable')}
@@ -155,9 +171,9 @@ export function PaymentProcessing() { {/* Order Creation Rule */}
- +

- Select the option to determine whether the order should be created. + {t('orderCreationDescription')}

- After authorization - Create when authorized + {t('afterAuthorization')} + {t('createWhenAuthorized')}
@@ -205,9 +221,9 @@ export function PaymentProcessing() {
- Card Display & Saving + {t('cardDisplaySaving')} - Configure how cards appear at checkout and whether customers can save them. + {t('cardDisplayDescription')}
@@ -217,10 +233,10 @@ export function PaymentProcessing() {

- {"If enabled, all supported card brands will be grouped and shown as a single 'Cards' payment method at checkout."} + {t('groupCardsDescription')}

-
-
- -

- {"If enabled, a logo for the grouped 'Cards' payment method will be displayed at checkout."} -

+ {settings.groupCards && ( +
+
+ +

+ {t('showCardsLogoDescription')} +

+
+
-
+ )}
- +

- Allow customers to save credit card for faster purchase. + {t('creditCardSavingDescription')}

- Enable + {t('enable')}
@@ -287,8 +305,8 @@ export function PaymentProcessing() {
-
diff --git a/views/js/admin/settings-app/src/components/settings/saferpay-settings.tsx b/views/js/admin/settings-app/src/components/settings/saferpay-settings.tsx index 6b232221c..93b02197c 100644 --- a/views/js/admin/settings-app/src/components/settings/saferpay-settings.tsx +++ b/views/js/admin/settings-app/src/components/settings/saferpay-settings.tsx @@ -1,3 +1,4 @@ +import { t } from '@/utils/translations' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { ApiCredentials } from './api-credentials' import { PaymentProcessing } from './payment-processing' @@ -12,10 +13,10 @@ export function SaferpaySettings() {

- Saferpay Settings + {t('saferpaySettings')}

- Configure your Saferpay payment integration for your Prestashop store. + {t('configureIntegration')}

@@ -23,38 +24,43 @@ export function SaferpaySettings() { - API Credentials + {t('tabApiCredentials')} - Payment Methods + {t('tabPaymentMethods')} - Payment Processing + {t('tabPaymentProcessing')} - Email Notifications + {t('tabEmailNotifications')} - General Settings + {t('tabGeneralSettings')} diff --git a/views/js/admin/settings-app/src/components/settings/toast-container.tsx b/views/js/admin/settings-app/src/components/settings/toast-container.tsx index b1162ab36..0b5f905db 100644 --- a/views/js/admin/settings-app/src/components/settings/toast-container.tsx +++ b/views/js/admin/settings-app/src/components/settings/toast-container.tsx @@ -3,17 +3,21 @@ import { useToast } from '@/hooks/use-toast' export function ToastContainer() { const { toasts } = useToast() - if (toasts.length === 0) return null - return ( -
+
{toasts.map((t) => (
{t.title &&
{t.title}
} 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 b20e78fd6..784cf7fb1 100644 --- a/views/js/admin/settings-app/src/context/settings-context.tsx +++ b/views/js/admin/settings-app/src/context/settings-context.tsx @@ -1,7 +1,10 @@ -import React, { createContext, useContext, useState, useCallback } from 'react' +import React, { createContext, useContext, useState, useCallback, useMemo, useRef } from 'react' import type { SaferpaySettingsData, PaymentMethodData, TerminalOption } from '@/types' import * as api from '@/api/client' import { toast } from '@/hooks/use-toast' +import { t } from '@/utils/translations' + +type SavingSection = 'credentials' | 'paymentProcessing' | 'emailSettings' | 'generalSettings' | 'paymentMethods' interface SettingsContextValue { settings: SaferpaySettingsData @@ -11,19 +14,28 @@ interface SettingsContextValue { saveEmailSettings: () => Promise saveGeneralSettings: () => Promise savePaymentMethods: () => Promise - fetchTerminals: (env: string, username: string, password: string, customerId: string) => Promise + fetchTerminals: (env: string, username: string, password: string) => Promise + generateFieldAccessToken: () => Promise<{ success: boolean; message?: string; token?: string }> refreshPaymentMethods: () => Promise paymentMethods: PaymentMethodData[] updatePaymentMethod: (name: string, updates: Partial) => void - saving: boolean + savingSections: Set } const SettingsContext = createContext(null) export function SettingsProvider({ children }: { children: React.ReactNode }) { const [settings, setSettings] = useState(() => window.saferpaySettingsData) - const [paymentMethods, setPaymentMethods] = useState(() => window.saferpaySettingsData.paymentMethods || []) - const [saving, setSaving] = useState(false) + const [paymentMethods, setPaymentMethods] = useState( + () => Array.isArray(window.saferpaySettingsData.paymentMethods) ? window.saferpaySettingsData.paymentMethods : [], + ) + const [savingSections, setSavingSections] = useState>(new Set()) + + const settingsRef = useRef(settings) + settingsRef.current = settings + + const paymentMethodsRef = useRef(paymentMethods) + paymentMethodsRef.current = paymentMethods const updateSettings = useCallback((updates: Partial) => { setSettings((prev) => ({ ...prev, ...updates })) @@ -35,119 +47,175 @@ export function SettingsProvider({ children }: { children: React.ReactNode }) { ) }, []) - const handleSave = useCallback(async (saveFn: () => Promise<{ success: boolean; message?: string }>, label: string) => { - setSaving(true) + const handleSave = useCallback(async ( + saveFn: () => Promise<{ success: boolean; message?: string; warning?: boolean }>, + label: string, + section: SavingSection, + ) => { + setSavingSections((prev) => new Set(prev).add(section)) try { const result = await saveFn() if (result.success) { - toast({ title: `${label} saved successfully`, variant: 'default' }) + const variant = result.warning ? 'warning' : 'default' + toast({ title: result.message || t('savedSuccessfully', label), variant }) } else { - toast({ title: result.message || `Failed to save ${label}`, variant: 'destructive' }) + toast({ title: result.message || t('failedToSave', label), variant: 'destructive' }) } } catch (e) { - toast({ title: `Error saving ${label}`, variant: 'destructive' }) + const message = e instanceof Error ? e.message : 'Unknown error' + toast({ title: t('errorSaving', label, message), variant: 'destructive' }) } finally { - setSaving(false) + setSavingSections((prev) => { + const next = new Set(prev) + next.delete(section) + return next + }) } }, []) const saveCredentials = useCallback(async () => { - await handleSave(() => api.saveCredentials({ - testMode: settings.testMode, - testUsername: settings.testUsername, - testPassword: settings.testPassword, - testCustomerId: settings.testCustomerId, - testTerminalId: settings.testTerminalId, - testMerchantEmails: settings.testMerchantEmails, - testFieldAccessToken: settings.testFieldAccessToken, - testFieldJsUrl: settings.testFieldJsUrl, - testBusinessLicense: settings.testBusinessLicense, - liveUsername: settings.liveUsername, - livePassword: settings.livePassword, - liveCustomerId: settings.liveCustomerId, - liveTerminalId: settings.liveTerminalId, - liveMerchantEmails: settings.liveMerchantEmails, - liveFieldAccessToken: settings.liveFieldAccessToken, - liveFieldJsUrl: settings.liveFieldJsUrl, - liveBusinessLicense: settings.liveBusinessLicense, - }), 'API Credentials') - }, [settings, handleSave]) - - const savePaymentProcessingFn = useCallback(async () => { + const currentSettings = settingsRef.current + await handleSave(async () => { + const result = await api.saveCredentials({ + testMode: currentSettings.testMode, + testUsername: currentSettings.testUsername, + testPassword: currentSettings.testPassword, + testTerminalId: currentSettings.testTerminalId, + testMerchantEmails: currentSettings.testMerchantEmails, + testFieldAccessToken: currentSettings.testFieldAccessToken, + testFieldJsUrl: currentSettings.testFieldJsUrl, + liveUsername: currentSettings.liveUsername, + livePassword: currentSettings.livePassword, + liveTerminalId: currentSettings.liveTerminalId, + liveMerchantEmails: currentSettings.liveMerchantEmails, + liveFieldAccessToken: currentSettings.liveFieldAccessToken, + liveFieldJsUrl: currentSettings.liveFieldJsUrl, + }) + const data = result as unknown as Record + if (result.success) { + setSettings((prev) => ({ + ...prev, + ...(typeof data.testHasBusinessLicense === 'boolean' ? { testHasBusinessLicense: data.testHasBusinessLicense as boolean } : {}), + ...(typeof data.liveHasBusinessLicense === 'boolean' ? { liveHasBusinessLicense: data.liveHasBusinessLicense as boolean } : {}), + })) + } + return { ...result, warning: data.warning === true } + }, 'API Credentials', 'credentials') + }, [handleSave]) + + const savePaymentProcessing = useCallback(async () => { + const currentSettings = settingsRef.current await handleSave(() => api.savePaymentProcessing({ - paymentBehavior: settings.paymentBehavior, - paymentBehaviorWithout3D: settings.paymentBehaviorWithout3D, - restrictRefund: settings.restrictRefund, - orderCreationAfterAuth: settings.orderCreationAfterAuth, - groupCards: settings.groupCards, - groupCardsLogo: settings.groupCardsLogo, - creditCardSave: settings.creditCardSave, - }), 'Payment Processing') - }, [settings, handleSave]) - - const saveEmailSettingsFn = useCallback(async () => { + paymentBehavior: currentSettings.paymentBehavior, + paymentBehaviorWithout3D: currentSettings.paymentBehaviorWithout3D, + restrictRefund: currentSettings.restrictRefund, + orderCreationAfterAuth: currentSettings.orderCreationAfterAuth, + groupCards: currentSettings.groupCards, + groupCardsLogo: currentSettings.groupCardsLogo, + creditCardSave: currentSettings.creditCardSave, + }), 'Payment Processing', 'paymentProcessing') + }, [handleSave]) + + const saveEmailSettings = useCallback(async () => { + const currentSettings = settingsRef.current await handleSave(() => api.saveEmailSettings({ - allowSaferpayMail: settings.allowSaferpayMail, - sendNewOrderMail: settings.sendNewOrderMail, - sendOrderConfMail: settings.sendOrderConfMail, - }), 'Email Settings') - }, [settings, handleSave]) + allowSaferpayMail: currentSettings.allowSaferpayMail, + sendNewOrderMail: currentSettings.sendNewOrderMail, + sendOrderConfMail: currentSettings.sendOrderConfMail, + }), 'Email Settings', 'emailSettings') + }, [handleSave]) - const saveGeneralSettingsFn = useCallback(async () => { + const saveGeneralSettings = useCallback(async () => { + const currentSettings = settingsRef.current await handleSave(() => api.saveGeneralSettings({ - orderStateAwaitingPayment: settings.orderStateAwaitingPayment, - paymentDescription: settings.paymentDescription, - configurationName: settings.configurationName, - debugMode: settings.debugMode, - }), 'General Settings') - }, [settings, handleSave]) + orderStateAwaitingPayment: currentSettings.orderStateAwaitingPayment, + paymentDescription: currentSettings.paymentDescription, + configurationName: currentSettings.configurationName, + hostedFieldsTemplate: currentSettings.hostedFieldsTemplate, + orderIdOption: currentSettings.orderIdOption, + debugMode: currentSettings.debugMode, + }), 'General Settings', 'generalSettings') + }, [handleSave]) - const savePaymentMethodsFn = useCallback(async () => { - await handleSave(() => api.savePaymentMethods(paymentMethods), 'Payment Methods') - }, [paymentMethods, handleSave]) + const savePaymentMethods = useCallback(async () => { + await handleSave( + () => api.savePaymentMethods(paymentMethodsRef.current), + 'Payment Methods', + 'paymentMethods', + ) + }, [handleSave]) const refreshPaymentMethods = useCallback(async () => { try { const result = await api.refreshData() - if (result.success && result.data?.paymentMethods) { + if (result.success && Array.isArray(result.data?.paymentMethods)) { setPaymentMethods(result.data.paymentMethods as PaymentMethodData[]) } } catch { - // silently fail, user still has initial data + toast({ title: t('errorRefreshingPaymentMethods'), variant: 'destructive' }) } }, []) - const fetchTerminals = useCallback(async (env: string, username: string, password: string, customerId: string) => { - try { - const result = await api.getTerminals(env, username, password, customerId) - if (result.success) { - return result.terminals - } - toast({ title: 'Failed to fetch terminals', variant: 'destructive' }) - return [] - } catch { - toast({ title: 'Error fetching terminals', variant: 'destructive' }) - return [] + const generateFieldAccessToken = useCallback(async () => { + const s = settingsRef.current + const env = s.testMode ? 'test' : 'live' + const username = s.testMode ? s.testUsername : s.liveUsername + const password = s.testMode ? s.testPassword : s.livePassword + const terminalId = s.testMode ? s.testTerminalId : s.liveTerminalId + + const result = await api.generateFieldAccessToken(env, username, password, terminalId) + if (!result.success) { + throw new Error(result.message || t('failedToGenerateToken')) + } + + if (result.token) { + const fieldKey = s.testMode ? 'testFieldAccessToken' : 'liveFieldAccessToken' + setSettings((prev) => ({ ...prev, [fieldKey]: result.token })) } + + return result }, []) + const fetchTerminals = useCallback(async (env: string, username: string, password: string) => { + const result = await api.getTerminals(env, username, password) + if (!result.success) { + throw new Error(result.message || t('failedToFetchTerminals')) + } + return result.terminals + }, []) + + const value = useMemo(() => ({ + settings, + updateSettings, + saveCredentials, + savePaymentProcessing, + saveEmailSettings, + saveGeneralSettings, + savePaymentMethods, + fetchTerminals, + generateFieldAccessToken, + refreshPaymentMethods, + paymentMethods, + updatePaymentMethod, + savingSections, + }), [ + settings, + updateSettings, + saveCredentials, + savePaymentProcessing, + saveEmailSettings, + saveGeneralSettings, + savePaymentMethods, + fetchTerminals, + generateFieldAccessToken, + refreshPaymentMethods, + paymentMethods, + updatePaymentMethod, + savingSections, + ]) + return ( - + {children} ) diff --git a/views/js/admin/settings-app/src/globals.css b/views/js/admin/settings-app/src/globals.css index 084c1fe3b..b222286c7 100644 --- a/views/js/admin/settings-app/src/globals.css +++ b/views/js/admin/settings-app/src/globals.css @@ -14,7 +14,7 @@ --sp-secondary: 180 15% 95%; --sp-secondary-foreground: 220 20% 10%; --sp-muted: 180 12% 95%; - --sp-muted-foreground: 220 10% 46%; + --sp-muted-foreground: 220 12% 38%; --sp-accent: 180 25% 92%; --sp-accent-foreground: 180 51% 22%; --sp-destructive: 0 72% 51%; diff --git a/views/js/admin/settings-app/src/hooks/use-mobile.tsx b/views/js/admin/settings-app/src/hooks/use-mobile.tsx deleted file mode 100644 index 4331d5c56..000000000 --- a/views/js/admin/settings-app/src/hooks/use-mobile.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import * as React from 'react' - -const MOBILE_BREAKPOINT = 768 - -export function useIsMobile() { - const [isMobile, setIsMobile] = React.useState(undefined) - - React.useEffect(() => { - const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) - const onChange = () => { - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - } - mql.addEventListener('change', onChange) - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - return () => mql.removeEventListener('change', onChange) - }, []) - - return !!isMobile -} diff --git a/views/js/admin/settings-app/src/hooks/use-toast.ts b/views/js/admin/settings-app/src/hooks/use-toast.ts index a0dadc74c..f0af5fe04 100644 --- a/views/js/admin/settings-app/src/hooks/use-toast.ts +++ b/views/js/admin/settings-app/src/hooks/use-toast.ts @@ -7,7 +7,7 @@ export interface ToasterToast { id: string title?: string description?: string - variant?: 'default' | 'destructive' + variant?: 'default' | 'destructive' | 'warning' } type Action = @@ -91,7 +91,7 @@ function useToast() { const index = listeners.indexOf(setState) if (index > -1) listeners.splice(index, 1) } - }, [state]) + }, []) return { ...state, diff --git a/views/js/admin/settings-app/src/main.tsx b/views/js/admin/settings-app/src/main.tsx index be0a207eb..273674a88 100644 --- a/views/js/admin/settings-app/src/main.tsx +++ b/views/js/admin/settings-app/src/main.tsx @@ -2,14 +2,36 @@ import React from 'react' import ReactDOM from 'react-dom/client' import App from './App' import './globals.css' +import type { SaferpaySettingsData } from '@/types' +import { initTranslations } from '@/utils/translations' + +function parseSettingsData(): SaferpaySettingsData | null { + const el = document.getElementById('saferpay-settings-data') + if (!el?.textContent) return null + + try { + return JSON.parse(el.textContent) as SaferpaySettingsData + } catch { + return null + } +} document.addEventListener('DOMContentLoaded', () => { const rootEl = document.getElementById('saferpay-settings-root') - if (rootEl) { - ReactDOM.createRoot(rootEl).render( - - - , - ) + if (!rootEl) return + + const data = parseSettingsData() + if (!data) { + rootEl.innerHTML = '
Failed to load settings data.
' + return } + + window.saferpaySettingsData = data + initTranslations(data.translations || {}) + + ReactDOM.createRoot(rootEl).render( + + + , + ) }) diff --git a/views/js/admin/settings-app/src/types/index.ts b/views/js/admin/settings-app/src/types/index.ts index a6a598df1..79bf8e2ae 100644 --- a/views/js/admin/settings-app/src/types/index.ts +++ b/views/js/admin/settings-app/src/types/index.ts @@ -21,22 +21,21 @@ export interface SaferpaySettingsData { // Test credentials testUsername: string testPassword: string - testCustomerId: string testTerminalId: string testMerchantEmails: string testFieldAccessToken: string testFieldJsUrl: string - testBusinessLicense: boolean - // Live credentials liveUsername: string livePassword: string - liveCustomerId: string liveTerminalId: string liveMerchantEmails: string liveFieldAccessToken: string liveFieldJsUrl: string - liveBusinessLicense: boolean + + // License (read-only, auto-detected from API, per environment) + testHasBusinessLicense: boolean + liveHasBusinessLicense: boolean // Payment Processing paymentBehavior: number @@ -56,6 +55,9 @@ export interface SaferpaySettingsData { orderStateAwaitingPayment: number paymentDescription: string configurationName: string + hostedFieldsTemplate: number + modulePath: string + orderIdOption: number debugMode: boolean // Reference data @@ -67,6 +69,9 @@ export interface SaferpaySettingsData { // Endpoints ajaxUrl: string adminToken: string + + // Translations + translations: Record } declare global { diff --git a/views/js/admin/settings-app/src/utils/translations.ts b/views/js/admin/settings-app/src/utils/translations.ts new file mode 100644 index 000000000..131018d00 --- /dev/null +++ b/views/js/admin/settings-app/src/utils/translations.ts @@ -0,0 +1,13 @@ +let translations: Record = {} + +export function initTranslations(t: Record) { + translations = t +} + +export function t(key: string, ...args: (string | number)[]): string { + let str = translations[key] || key + args.forEach((arg) => { + str = str.replace('%s', String(arg)) + }) + return str +} diff --git a/views/templates/admin/field-option-settings/helpers/index.php b/views/templates/admin/field-option-settings/helpers/index.php deleted file mode 100755 index ee6227264..000000000 --- a/views/templates/admin/field-option-settings/helpers/index.php +++ /dev/null @@ -1,31 +0,0 @@ - - *@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/views/templates/admin/field-option-settings/helpers/options/index.php b/views/templates/admin/field-option-settings/helpers/options/index.php deleted file mode 100755 index ee6227264..000000000 --- a/views/templates/admin/field-option-settings/helpers/options/index.php +++ /dev/null @@ -1,31 +0,0 @@ - - *@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/views/templates/admin/field-option-settings/helpers/options/options.tpl b/views/templates/admin/field-option-settings/helpers/options/options.tpl deleted file mode 100755 index 4c100589d..000000000 --- a/views/templates/admin/field-option-settings/helpers/options/options.tpl +++ /dev/null @@ -1,70 +0,0 @@ -{** - *NOTICE OF LICENSE - * - *This source file is subject to the Open Software License (OSL 3.0) - *that is bundled with this package in the file LICENSE.txt. - *It is also available through the world-wide-web at this URL: - *http://opensource.org/licenses/osl-3.0.php - *If you did not receive a copy of the license and are unable to - *obtain it through the world-wide-web, please send an email - *to license@prestashop.com so we can send you a copy immediately. - * - *DISCLAIMER - * - * Do not edit or add to this file if you wish to upgrade PrestaShop to newer - *versions in the future. If you wish to customize PrestaShop for your - *needs please refer to http://www.prestashop.com for more information. - * - *@author INVERTUS UAB www.invertus.eu - *@copyright SIX Payment Services - *@license SIX Payment Services - *} - -{extends file="helpers/options/options.tpl"} - -{block name="input" append} - {if $field['type'] == 'password_input'} -
- -
- {/if} - {if $field['type'] == 'desc'} -
- {if $field['template'] == 'field-javascript-library-desc.tpl'} - {include file="../../../partials/field-javascript-library-desc.tpl"} - {/if} - - {if $field['template'] == 'field-access-token-desc.tpl'} - {include file="../../../partials/field-access-token-desc.tpl"} - {/if} - - {if $field['template'] == 'field-hosted-field-template-desc.tpl'} - {include file="../../../partials/field-hosted-field-template-desc.tpl"} - {/if} - {if $field['template'] == 'field-new-order-mail-desc.tpl'} - {include file="../../../partials/field-new-order-mail-desc.tpl"} - {/if} -
- {/if} - - {if $field['type'] == 'select-template'} - -
- {foreach from=$field['templateOptions'] key=key item=templateUrl} - {assign var='key' value=$key + 1} {* To have normal keys without 0 *} - - {/foreach} -
- - {/if} -{/block} diff --git a/views/templates/admin/field-option-settings/index.php b/views/templates/admin/field-option-settings/index.php deleted file mode 100755 index ee6227264..000000000 --- a/views/templates/admin/field-option-settings/index.php +++ /dev/null @@ -1,31 +0,0 @@ - - *@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/views/templates/admin/logs/log_modal.tpl b/views/templates/admin/logs/log_modal.tpl index 62cb43dd4..eadfc7fcd 100644 --- a/views/templates/admin/logs/log_modal.tpl +++ b/views/templates/admin/logs/log_modal.tpl @@ -19,7 +19,8 @@ *@copyright SIX Payment Services *@license SIX Payment Services *} -
{l s='View' mod='saferpayofficial'} -
+ -