commit cae15fb881bde427fb44863457a5c60d30e11280
Author: O K
Date: Thu May 29 10:54:49 2025 +0300
first commit
diff --git a/controllers/front/callback.php b/controllers/front/callback.php
new file mode 100644
index 0000000..7450410
--- /dev/null
+++ b/controllers/front/callback.php
@@ -0,0 +1,214 @@
+getRequestBody();
+
+ // If request body is empty, log and exit.
+ if (empty($requestBody)) {
+ PrestaShopLogger::addLog('Hutko Callback: Empty request body received.', 2, null, 'Cart', null, true);
+ exit('Empty request');
+ }
+
+ // 2. Validate the request signature and required fields.
+ // Ensure all expected fields are present before proceeding with validation.
+ $requiredFields = ['order_id', 'amount', 'order_status', 'signature', 'merchant_id'];
+ foreach ($requiredFields as $field) {
+ if (!isset($requestBody[$field])) {
+ PrestaShopLogger::addLog('Hutko Callback: Missing required field in request: ' . $field, 2, null, 'Cart', null, true);
+ exit('Missing parameter: ' . $field);
+ }
+ }
+
+ // Assuming validateResponse returns true on success, or a string error message on failure.
+ $isSignatureValid = $this->module->validateResponse($requestBody);
+ if ($isSignatureValid !== true) {
+ PrestaShopLogger::addLog('Hutko Callback: Invalid signature. Error: ' . $isSignatureValid, 2, null, 'Cart', null, true);
+ exit('Invalid signature');
+ }
+
+ // 3. Extract cart ID and load the cart.
+ // The order_id is expected to be in the format "cartID|timestamp".
+ $transaction_id = $requestBody['order_id'];
+ $orderIdParamParts = explode($this->module->order_separator, $transaction_id);
+ $cartId = (int)$orderIdParamParts[0]; // Ensure it's an integer
+
+ $cart = new Cart($cartId);
+
+ // Validate cart object.
+ if (!Validate::isLoadedObject($cart)) {
+ PrestaShopLogger::addLog('Hutko Callback: Cart not found for ID: ' . $cartId, 3, null, 'Cart', $cartId, true);
+ exit('Cart not found');
+ }
+
+ // 4. Determine the amount received from the callback.
+ $amountReceived = round((float)$requestBody['amount'] / 100, 2);
+
+ // 5. Check if the order already exists for this cart.
+ $orderId = Order::getIdByCartId($cart->id);
+ $orderExists = (bool)$orderId;
+
+ // 6. If the order doesn't exist, attempt to validate it using postponeCallback.
+ // This handles the scenario where the callback arrives before the customer returns to the site.
+ if (!$orderExists) {
+ // The callback function will check for order existence again right before validation
+ // to handle potential race conditions.
+ $validationCallback = function () use ($cart, $amountReceived, $transaction_id) {
+ // Re-check if the order exists right before validation in case the result controller
+ // created it in the interim while we were waiting for the second digit.
+ if (Order::getIdByCartId($cart->id)) {
+ return true; // Order already exists, no need to validate again.
+ }
+ // If order still doesn't exist, proceed with validation.
+ $idState = (int)Configuration::get('HUTKO_SUCCESS_STATUS_ID');
+ return $this->module->validateOrderFromCart((int)$cart->id, $amountReceived, $transaction_id, $idState);
+ };
+
+ // Postpone validation to seconds ending in 8 to avoid collision with result controller (ending in 3).
+ $validationResult = $this->module->postponeCallback($validationCallback, 8);
+
+ // Re-fetch order ID after potential validation.
+ $orderId = Order::getIdByCartId($cart->id);
+
+ if (!$orderId || !$validationResult) {
+ PrestaShopLogger::addLog('Hutko Callback: Order validation failed for cart ID: ' . $cart->id, 2, null, 'Cart', $cart->id, true);
+ exit('Order validation failed');
+ }
+ }
+
+ // If we reached here, an order should exist. Load it.
+ $order = new Order($orderId);
+ if (!Validate::isLoadedObject($order)) {
+ PrestaShopLogger::addLog('Hutko Callback: Order could not be loaded for ID: ' . $orderId, 3, null, 'Order', $orderId, true);
+ exit('Order not found after validation');
+ }
+
+ // 7. Handle payment status from the callback.
+ $orderStatusCallback = $requestBody['order_status'];
+ $currentOrderState = (int)$order->getCurrentState();
+
+ switch ($orderStatusCallback) {
+ case 'approved':
+ $expectedState = (int)Configuration::get('HUTKO_SUCCESS_STATUS_ID');
+ // Only change state if it's not already the success state or "Payment accepted".
+ // "Payment accepted" (PS_OS_PAYMENT) might be set by validateOrderFromCart.
+ if ($currentOrderState !== $expectedState && $currentOrderState !== (int)Configuration::get('PS_OS_PAYMENT')) {
+ $this->module->updateOrderStatus($orderId, $expectedState, 'Payment approved by Hutko.');
+ }
+ exit('OK');
+ break;
+
+ case 'declined':
+ $expectedState = (int)Configuration::get('PS_OS_ERROR');
+ // Only change state if it's not already the error state.
+ if ($currentOrderState !== $expectedState) {
+ $this->module->updateOrderStatus($orderId, $expectedState, 'Payment ' . $orderStatusCallback . ' by Hutko.');
+ }
+ exit('Order ' . $orderStatusCallback);
+ break;
+ case 'expired':
+ $expectedState = (int)Configuration::get('PS_OS_ERROR');
+ // Only change state if it's not already the error state.
+ if ($currentOrderState !== $expectedState) {
+ $this->module->updateOrderStatus($orderId, $expectedState, 'Payment ' . $orderStatusCallback . ' by Hutko.');
+ }
+ exit('Order ' . $orderStatusCallback);
+ break;
+
+ case 'processing':
+ // If the order is still processing, we might want to update its status
+ // to a specific 'processing' state if available, or just acknowledge.
+ // For now, if it's not already in a success/error state, set it to 'processing'.
+ $processingState = (int)Configuration::get('PS_OS_PAYMENT'); // Or a custom 'processing' state
+ if ($currentOrderState !== $processingState && $currentOrderState !== (int)Configuration::get('HUTKO_SUCCESS_STATUS_ID') && $currentOrderState !== (int)Configuration::get('PS_OS_ERROR')) {
+ $this->module->updateOrderStatus($orderId, $processingState, 'Payment processing by Hutko.');
+ }
+ exit('Processing');
+ break;
+
+ default:
+ // Log unexpected status and exit with an error.
+ PrestaShopLogger::addLog('Hutko Callback: Unexpected order status received: ' . $orderStatusCallback . ' for order ID: ' . $orderId, 3, null, 'Order', $orderId, true);
+ exit('Unexpected status');
+ break;
+ }
+ } catch (Exception $e) {
+ // Log any uncaught exceptions and exit with the error message.
+ PrestaShopLogger::addLog('Hutko Callback Error: ' . $e->getMessage(), 3, null, 'HutkoCallbackModuleFrontController', null, true);
+ exit($e->getMessage());
+ }
+ }
+
+ /**
+ * Helper method to parse the request body from POST or raw input.
+ *
+ * @return array The parsed request body.
+ */
+ private function getRequestBody(): array
+ {
+ // Prioritize $_POST for form data.
+ if (!empty($_POST)) {
+ return $_POST;
+ }
+
+ // Fallback to raw input for JSON payloads, common for callbacks.
+ $jsonBody = json_decode(Tools::file_get_contents("php://input"), true);
+ if (is_array($jsonBody)) {
+ return $jsonBody;
+ }
+
+ return [];
+ }
+}
diff --git a/controllers/front/index.php b/controllers/front/index.php
new file mode 100644
index 0000000..55c9b2c
--- /dev/null
+++ b/controllers/front/index.php
@@ -0,0 +1,20 @@
+context->smarty->assign([
+ 'hutko_url' => $this->module->checkout_url, // The URL of the Hutko payment gateway.
+ 'hutko_inputs' => $this->module->buildInputs(), // An array of input parameters required by Hutko.
+ ]);
+
+ // Set the template to be used for displaying the redirection form.
+ $this->setTemplate('module:' . $this->module->name . '/views/templates/front/redirect.tpl');
+ }
+}
diff --git a/controllers/front/result.php b/controllers/front/result.php
new file mode 100644
index 0000000..93bd42a
--- /dev/null
+++ b/controllers/front/result.php
@@ -0,0 +1,185 @@
+module->order_separator, $transaction_id);
+ $cartId = (int)$cartIdParts[0];
+
+ // Validate extracted cart ID. It must be a numeric value.
+ if (!is_numeric($cartId)) {
+ $this->errors[] = Tools::displayError($this->trans('Invalid cart ID received.', [], 'Modules.Hutko.Shop'));
+ $this->redirectWithNotifications($this->context->link->getPageLink('order', true, $this->context->language->id));
+ return; // Stop execution after redirection
+ }
+
+ // Load the cart object.
+ $cart = new Cart($cartId);
+
+ // Verify that the cart belongs to the current customer to prevent unauthorized access.
+ if (!Validate::isLoadedObject($cart) || $cart->id_customer != $this->context->customer->id) {
+ $this->errors[] = Tools::displayError($this->trans('Access denied to this order.', [], 'Modules.Hutko.Shop'));
+ Tools::redirect('/'); // Redirect to home or a more appropriate error page
+ }
+
+ // Handle different payment statuses.
+ switch ($orderStatus) {
+ case 'declined':
+ $this->errors[] = Tools::displayError($this->trans('Your payment was declined. Please try again or use a different payment method.', [], 'Modules.Hutko.Shop'));
+ $this->redirectWithNotifications($this->context->link->getPageLink('order', true, $this->context->language->id));
+ break;
+
+ case 'expired':
+ $this->errors[] = Tools::displayError($this->trans('Your payment has expired. Please try again.', [], 'Modules.Hutko.Shop'));
+ $this->redirectWithNotifications($this->context->link->getPageLink('order', true, $this->context->language->id));
+ break;
+
+ case 'processing':
+ // For 'processing' status, we need to poll for order creation.
+ // This loop will try to find the order for a limited time to avoid
+ // exceeding PHP execution limits.
+ $maxAttempts = 10; // Max 10 attempts
+ $sleepTime = 5; // Sleep 5 seconds between attempts (total max 50 seconds)
+ $orderFound = false;
+ $orderId = 0;
+
+ for ($i = 0; $i < $maxAttempts; $i++) {
+ $orderId = Order::getIdByCartId($cart->id);
+ if ($orderId) {
+ $orderFound = true;
+ break; // Order found, exit loop
+ }
+ // If not found, wait for a few seconds before retrying.
+ sleep($sleepTime);
+ }
+
+ if ($orderFound) {
+ // Order found, redirect to confirmation page.
+ Tools::redirect($this->context->link->getPageLink('order-confirmation', true, $this->context->language->id, [
+ 'id_cart' => $cart->id,
+ 'id_module' => $this->module->id,
+ 'id_order' => $orderId,
+ 'key' => $this->context->customer->secure_key,
+ ]));
+ } else {
+ // Order not found after multiple attempts, assume it's still processing or failed silently.
+ $this->errors[] = Tools::displayError($this->trans('Your payment is still processing. Please check your order history later.', [], 'Modules.Hutko.Shop'));
+ $this->redirectWithNotifications($this->context->link->getPageLink('order-history', true, $this->context->language->id));
+ }
+ break;
+
+ case 'approved':
+ $orderId = Order::getIdByCartId($cart->id);
+
+ // If the order doesn't exist yet, validate it.
+ // The postponeCallback is used here to avoid race conditions with the callback controller
+ // (which might be trying to validate the order on seconds ending in 8, while this
+ // controller tries on seconds ending in 3).
+ if (!$orderId) {
+ // Define the validation logic to be executed by postponeCallback.
+ // This callback will first check if the order exists, and only
+ // validate if it doesn't, to avoid race conditions.
+ $validationCallback = function () use ($cart, $amountReceived, $transaction_id) {
+ // Re-check if the order exists right before validation in case the callback
+ // controller created it in the interim while we were waiting for the second digit.
+ if (Order::getIdByCartId($cart->id)) {
+ return true; // Order already exists, no need to validate again.
+ }
+ $idState = (int)Configuration::get('HUTKO_SUCCESS_STATUS_ID');
+ // If order still doesn't exist, proceed with validation.
+ return $this->module->validateOrderFromCart((int)$cart->id, $amountReceived, $transaction_id, $idState);
+ };
+
+ // Postpone the execution of the validation callback until the second ends in 3.
+ $validationResult = $this->module->postponeCallback($validationCallback, 3);
+
+ // After the postponed callback has run, try to get the order ID again.
+ $orderId = Order::getIdByCartId($cart->id);
+
+ // If validation failed or order still not found, add an error.
+ if (!$orderId || !$validationResult) {
+ $this->errors[] = Tools::displayError($this->trans('Payment approved but order could not be created. Please contact support.', [], 'Modules.Hutko.Shop'));
+ $this->redirectWithNotifications($this->context->link->getPageLink('order', true, $this->context->language->id));
+ break;
+ }
+ }
+
+ // If order exists (either found initially or created by validation), redirect to confirmation.
+ Tools::redirect($this->context->link->getPageLink('order-confirmation', true, $this->context->language->id, [
+ 'id_cart' => $cart->id,
+ 'id_module' => $this->module->id,
+ 'id_order' => $orderId,
+ 'key' => $this->context->customer->secure_key,
+ ]));
+ break;
+
+ default:
+ // For any unexpected status, redirect to order history with a generic error.
+ $this->errors[] = Tools::displayError($this->trans('An unexpected payment status was received. Please check your order history.', [], 'Modules.Hutko.Shop'));
+ $this->redirectWithNotifications($this->context->link->getPageLink('order-history', true, $this->context->language->id));
+ break;
+ }
+
+ // This part should ideally not be reached if all cases are handled with redirects.
+ // However, as a fallback, if any errors were accumulated without a specific redirect,
+ // redirect to the order page.
+ if (count($this->errors)) {
+ $this->redirectWithNotifications($this->context->link->getPageLink('order', true, $this->context->language->id));
+ }
+ }
+}
diff --git a/controllers/index.php b/controllers/index.php
new file mode 100644
index 0000000..55c9b2c
--- /dev/null
+++ b/controllers/index.php
@@ -0,0 +1,20 @@
+name = 'hutko';
+ $this->tab = 'payments_gateways';
+ $this->version = '1.1.0';
+ $this->author = 'Hutko';
+ $this->bootstrap = true;
+ $this->ps_versions_compliancy = array('min' => '1.7', 'max' => _PS_VERSION_);
+ $this->is_eu_compatible = 1;
+
+ parent::__construct();
+ $this->displayName = $this->trans('Hutko Payments', array(), 'Modules.Hutko.Admin');
+ $this->description = $this->trans('Hutko is a payment platform whose main function is to provide internet acquiring.
+ Payment gateway supports EUR, USD, PLN, GBP, UAH, RUB and +100 other currencies.', array(), 'Modules.Hutko.Admin');
+ }
+
+ public function install()
+ {
+ return parent::install()
+ && $this->registerHook('paymentOptions');
+ }
+
+ public function uninstall()
+ {
+ foreach ($this->settingsList as $val) {
+ if (!Configuration::deleteByName($val)) {
+ return false;
+ }
+ }
+ if (!parent::uninstall()) {
+ return false;
+ }
+ return true;
+ }
+
+
+ /**
+ * Load the configuration form
+ */
+ public function getContent()
+ {
+ /**
+ * If values have been submitted in the form, process.
+ */
+ $err = '';
+ if (((bool)Tools::isSubmit('submitHutkoModule')) == true) {
+ $this->postValidation();
+ if (!sizeof($this->postErrors)) {
+ $this->postProcess();
+ } else {
+ foreach ($this->postErrors as $error) {
+ $err .= $this->displayError($error);
+ }
+ }
+ }
+
+ return $err . $this->renderForm();
+ }
+
+ /**
+ * Create the form that will be displayed in the configuration of your module.
+ */
+ protected function renderForm()
+ {
+ $helper = new HelperForm();
+
+ $helper->show_toolbar = false;
+ $helper->table = $this->table;
+ $helper->module = $this;
+ $helper->default_form_language = $this->context->language->id;
+ $helper->allow_employee_form_lang = Configuration::get('PS_BO_ALLOW_EMPLOYEE_FORM_LANG', 0);
+
+ $helper->identifier = $this->identifier;
+ $helper->submit_action = 'submitHutkoModule';
+ $helper->currentIndex = $this->context->link->getAdminLink('AdminModules', false)
+ . '&configure=' . $this->name . '&tab_module=' . $this->tab . '&module_name=' . $this->name;
+ $helper->token = Tools::getAdminTokenLite('AdminModules');
+
+ $helper->tpl_vars = array(
+ 'fields_value' => $this->getConfigFormValues(), /* Add values for your inputs */
+ 'languages' => $this->context->controller->getLanguages(),
+ 'id_language' => $this->context->language->id,
+ );
+
+ return $helper->generateForm(array($this->getConfigForm()));
+ }
+
+ /**
+ * Create the structure of your form.
+ */
+ protected function getConfigForm()
+ {
+ global $cookie;
+
+ $options = [];
+
+ foreach (OrderState::getOrderStates($cookie->id_lang) as $state) { // getting all Prestashop statuses
+ if (empty($state['module_name'])) {
+ $options[] = ['status_id' => $state['id_order_state'], 'name' => $state['name'] . " [ID: $state[id_order_state]]"];
+ }
+ }
+
+ return array(
+ 'form' => array(
+ 'legend' => array(
+ 'title' => $this->trans('Please specify the Hutko account details for customers', array(), 'Modules.Hutko.Admin'),
+ 'icon' => 'icon-cogs',
+ ),
+ 'input' => array(
+ array(
+ 'col' => 4,
+ 'type' => 'text',
+ 'prefix' => '',
+ 'desc' => $this->trans('Enter a merchant id', array(), 'Modules.Hutko.Admin'),
+ 'name' => 'HUTKO_MERCHANT',
+ 'label' => $this->trans('Merchant ID', array(), 'Modules.Hutko.Admin'),
+ ),
+ array(
+ 'col' => 4,
+ 'type' => 'text',
+ 'prefix' => '',
+ 'name' => 'HUTKO_SECRET_KEY',
+ 'desc' => $this->trans('Enter a secret key', array(), 'Modules.Hutko.Admin'),
+ 'label' => $this->trans('Secret key', array(), 'Modules.Hutko.Admin'),
+ ),
+ array(
+ 'type' => 'select',
+ 'prefix' => '',
+ 'name' => 'HUTKO_SUCCESS_STATUS_ID',
+ 'label' => $this->trans('Status after success payment', array(), 'Modules.Hutko.Admin'),
+ 'options' => array(
+ 'query' => $options,
+ 'id' => 'status_id',
+ 'name' => 'name'
+ )
+ ),
+ array(
+ 'type' => 'radio',
+ 'label' => $this->trans('Show Visa/MasterCard logo', array(), 'Modules.Hutko.Admin'),
+ 'name' => 'HUTKO_SHOW_CARDS_LOGO',
+ 'is_bool' => true,
+ 'values' => array(
+ array(
+ 'id' => 'show_cards',
+ 'value' => 1,
+ 'label' => $this->trans('Yes', array(), 'Modules.Hutko.Admin')
+ ),
+ array(
+ 'id' => 'hide_cards',
+ 'value' => 0,
+ 'label' => $this->trans('No', array(), 'Modules.Hutko.Admin')
+ )
+ ),
+ ),
+ ),
+ 'submit' => array(
+ 'title' => $this->trans('Save', array(), 'Modules.Hutko.Admin'),
+ 'class' => 'btn btn-default pull-right'
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Set values for the inputs.
+ */
+ protected function getConfigFormValues()
+ {
+ return array(
+ 'HUTKO_MERCHANT' => Configuration::get('HUTKO_MERCHANT', null),
+ 'HUTKO_SECRET_KEY' => Configuration::get('HUTKO_SECRET_KEY', null),
+ 'HUTKO_SUCCESS_STATUS_ID' => Configuration::get('HUTKO_SUCCESS_STATUS_ID', null),
+ 'HUTKO_SHOW_CARDS_LOGO' => Configuration::get('HUTKO_SHOW_CARDS_LOGO', null),
+ );
+ }
+
+ /**
+ * Save form data.
+ */
+ protected function postProcess()
+ {
+ $form_values = $this->getConfigFormValues();
+ foreach (array_keys($form_values) as $key) {
+ Configuration::updateValue($key, Tools::getValue($key));
+ }
+ }
+
+ /**
+ * Validates the configuration submitted through the module's settings form.
+ *
+ * This method checks if the form has been submitted and then validates the
+ * Merchant ID and Secret Key provided by the user. It adds error messages
+ * to the `$this->postErrors` array if any of the validation rules fail.
+ */
+ private function postValidation(): void
+ {
+ // Check if the module's configuration form has been submitted.
+ if (Tools::isSubmit('submitHutkoModule')) {
+ // Retrieve the submitted Merchant ID and Secret Key.
+ $merchantId = Tools::getValue('HUTKO_MERCHANT');
+ $secretKey = Tools::getValue('HUTKO_SECRET_KEY');
+
+ // Validate Merchant ID:
+ if (empty($merchantId)) {
+ $this->postErrors[] = $this->trans('Merchant ID is required.', [], 'Modules.Hutko.Admin');
+ }
+ if (!is_numeric($merchantId)) {
+ $this->postErrors[] = $this->trans('Merchant ID must be numeric.', [], 'Modules.Hutko.Admin');
+ }
+
+ // Validate Secret Key:
+ if (empty($secretKey)) {
+ $this->postErrors[] = $this->trans('Secret key is required.', [], 'Modules.Hutko.Admin');
+ }
+ if ($secretKey != 'test' && (Tools::strlen($secretKey) < 10 || is_numeric($secretKey))) {
+ $this->postErrors[] = $this->trans('Secret key must be at least 10 characters long and cannot be entirely numeric.', [], 'Modules.Hutko.Admin');
+ }
+ }
+ }
+
+
+ /**
+ * Hook for displaying payment options on the checkout page.
+ *
+ * This hook is responsible for adding the Hutko payment option to the list
+ * of available payment methods during the checkout process. It checks if the
+ * module is active, if the necessary configuration is set, and if the cart's
+ * currency is supported before preparing the payment option.
+ *
+ * @param array $params An array of parameters passed by the hook, containing
+ * information about the current cart.
+ * @return array|false An array containing the Hutko PaymentOption object if
+ * the module is active, configured, and the currency is supported, otherwise false.
+ */
+ public function hookPaymentOptions($params)
+ {
+ // 1. Check if the module is active. If not, do not display the payment option.
+ if (!$this->active) {
+ return false;
+ }
+
+ // 2. Check if the merchant ID and secret key are configured. If not, do not display the option.
+ if (!Configuration::get("HUTKO_MERCHANT") || !Configuration::get("HUTKO_SECRET_KEY")) {
+ return false;
+ }
+
+ // 3. Check if the cart's currency is supported by the module. If not, do not display the payment option.
+ if (!$this->checkCurrency($params['cart'])) {
+ return false;
+ }
+
+ // 4. Assign template variables to be used in the payment option's additional information.
+ $this->context->smarty->assign([
+ 'hutko_logo_path' => $this->context->link->getMediaLink(__PS_BASE_URI__ . 'modules/' . $this->name . '/views/img/logo.png'),
+ 'hutko_description' => $this->trans('Pay via payment system Hutko', [], 'Modules.Hutko.Admin'),
+ ]);
+
+ // 5. Create a new PaymentOption object for the Hutko payment method.
+ $newOption = new PaymentOption();
+
+ // 6. Configure the PaymentOption object.
+ $newOption->setModuleName($this->name)
+ ->setCallToActionText($this->trans('Pay via Hutko', [], 'Modules.Hutko.Admin'))
+ ->setAction($this->context->link->getModuleLink($this->name, 'redirect', [], true))
+ ->setAdditionalInformation($this->context->smarty->fetch('module:hutko/views/templates/front/hutko.tpl'));
+
+ // 7. Optionally set a logo for the payment option if the corresponding configuration is enabled.
+ if (Configuration::get("HUTKO_SHOW_CARDS_LOGO")) {
+ $newOption->setLogo(Tools::getHttpHost(true) . $this->_path . 'views/img/hutko_logo_cards.svg');
+ }
+
+ // 8. Return an array containing the configured PaymentOption object.
+ return [$newOption];
+ }
+
+
+ /**
+ * Builds an array of input parameters required for the payment gateway.
+ *
+ * This method gathers necessary information such as order ID, merchant ID,
+ * order description, amount, currency, callback URLs, customer email,
+ * reservation data, and generates a signature for the request.
+ *
+ * @return array An associative array containing the input parameters for the
+ * payment gateway. This array includes the generated signature.
+ */
+ public function buildInputs(): array
+ {
+ // 1. Generate a unique order ID combining the cart ID and current timestamp.
+ $orderId = $this->context->cart->id . $this->order_separator . time();
+
+ // 2. Retrieve the merchant ID from the module's configuration.
+ $merchantId = Configuration::get('HUTKO_MERCHANT');
+
+ // 3. Create a description for the order.
+ $orderDescription = $this->trans('Cart pay №', [], 'Modules.Hutko.Admin') . $this->context->cart->id;
+ // 4. Calculate the order amount in the smallest currency unit.
+ $amount = round($this->context->cart->getOrderTotal() * 100);
+
+ // 5. Get the currency ISO code of the current cart.
+ $currency = $this->context->currency->iso_code;
+
+ // 6. Generate the server callback URL.
+ $serverCallbackUrl = $this->context->link->getModuleLink($this->name, 'callback', [], true);
+
+ // 7. Generate the customer redirection URL after payment.
+ $responseUrl = $this->context->link->getModuleLink($this->name, 'result', [], true);
+
+ // 8. Retrieve the customer's email address.
+ $customerEmail = $this->context->customer->email;
+
+ // 9. Build the reservation data as a base64 encoded JSON string.
+ $reservationData = $this->buildReservationData();
+
+ // 10. Construct the data array with all the collected parameters.
+ $data = [
+ 'order_id' => $orderId,
+ 'merchant_id' => $merchantId,
+ 'order_desc' => $orderDescription,
+ 'amount' => $amount,
+ 'currency' => $currency,
+ 'server_callback_url' => $serverCallbackUrl,
+ 'response_url' => $responseUrl,
+ 'sender_email' => $customerEmail,
+ 'reservation_data' => $reservationData,
+ ];
+
+ // 11. Generate the signature for the data array using the merchant's secret key.
+ $data['signature'] = $this->getSignature($data, Configuration::get('HUTKO_SECRET_KEY'));
+
+ // 12. Return the complete data array including the signature.
+ return $data;
+ }
+
+
+
+
+ /**
+ * Builds a base64 encoded JSON string containing reservation-related data.
+ *
+ * This method gathers information about the current cart, customer's delivery
+ * address, shop details, and products in the cart to create an array. This
+ * array is then encoded as a JSON string and subsequently base64 encoded
+ * for transmission or storage.
+ *
+ * @return string A base64 encoded JSON string containing the reservation data.
+ */
+ public function buildReservationData(): string
+ {
+ // 1. Retrieve the delivery address for the current cart.
+ $address = new Address((int)$this->context->cart->id_address_delivery, $this->context->language->id);
+
+ // 2. Fetch the customer's state name, if available.
+ $customerState = '';
+ if ($address->id_state) {
+ $state = new State((int) $address->id_state, $this->context->language->id);
+ $customerState = $state->name;
+ }
+
+ // 3. Construct the data array.
+ $data = [
+ "cms_name" => "Prestashop",
+ "cms_version" => _PS_VERSION_,
+ "shop_domain" => Tools::getShopDomainSsl(),
+ "path" => 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'],
+ "phonemobile" => $address->phone_mobile ?? $address->phone,
+ "customer_address" => $this->getSlug($address->address1),
+ "customer_country" => $this->getSlug($address->country),
+ "customer_state" => $this->getSlug($customerState),
+ "customer_name" => $this->getSlug($address->lastname . ' ' . $address->firstname),
+ "customer_city" => $this->getSlug($address->city),
+ "customer_zip" => $address->postcode,
+ "account" => $this->context->customer->id,
+ "uuid" => hash('sha256', _COOKIE_KEY_ . Tools::getShopDomainSsl()),
+ "products" => $this->getProducts(),
+ ];
+
+ // 4. Encode the data array as a JSON string.
+ $jsonData = json_encode($data);
+
+ // 5. Base64 encode the JSON string.
+ return base64_encode($jsonData);
+ }
+
+
+ /**
+ * Retrieves an array of product details from the current cart.
+ *
+ * This method iterates through the products in the current customer's cart
+ * using the context and extracts relevant information such as ID, name,
+ * unit price, total amount for each product (price multiplied by quantity),
+ * and the quantity itself.
+ *
+ * @return array An array where each element is an associative array containing
+ * the details of a product in the cart. The keys for each product are:
+ * - 'id': The product ID.
+ * - 'name': The name of the product.
+ * - 'price': The unit price of the product.
+ * - 'total_amount': The total price of the product in the cart (price * quantity), rounded to two decimal places.
+ * - 'quantity': The quantity of the product in the cart.
+ */
+ public function getProducts(): array
+ {
+ $products = [];
+ foreach ($this->context->cart->getProducts() as $cartProduct) {
+ $products[] = [
+ "id" => (int)$cartProduct['id_product'],
+ "name" => $cartProduct['name'],
+ "price" => (float)$cartProduct['price'],
+ "total_amount" => round((float) $cartProduct['price'] * (int)$cartProduct['quantity'], 2),
+ "quantity" => (int)$cartProduct['quantity'],
+ ];
+ }
+ return $products;
+ }
+
+
+
+ /**
+ * Validates an order based on the provided cart ID and expected amount,
+ * setting the order status to "preparation".
+ *
+ * This method serves as a convenience wrapper around the `validateOrder` method,
+ * pre-filling the order status with the configured "preparation" status.
+ *
+ * @param int $id_cart The ID of the cart associated with the order to be validated.
+ * @param float $amount The expected total amount of the order. This value will be
+ * compared against the cart's total.
+ * @return bool True if the order validation was successful, false otherwise.
+ * @see PaymentModule::validateOrder()
+ */
+ public function validateOrderFromCart(int $id_cart, float $amount, string $transaction_id = '', int $idState = 0): bool
+ {
+ if (!$idState) {
+ $idState = (int) Configuration::get('PS_OS_PREPARATION');
+ }
+ // Call the parent validateOrder method with the "preparation" status.
+ return $this->validateOrder($id_cart, $idState, $amount, $this->displayName, null, ['transaction_id' => $transaction_id], null, false, $this->context->customer->secure_key);
+ }
+
+ /**
+ * Generates a URL-friendly slug from a given text.
+ *
+ * This method transliterates non-ASCII characters to their closest ASCII equivalents,
+ * removes any characters that are not alphanumeric or spaces, trims leading/trailing
+ * spaces, optionally replaces spaces with hyphens, and optionally converts the
+ * entire string to lowercase.
+ *
+ * @param string $text The input string to convert into a slug.
+ * @param bool $removeSpaces Optional. Whether to replace spaces with hyphens (true) or keep them (false). Defaults to false.
+ * @param bool $lowerCase Optional. Whether to convert the resulting slug to lowercase (true) or keep the original casing (false). Defaults to false.
+ * @return string The generated slug.
+ */
+ public function getSlug(string $text, bool $removeSpaces = false, bool $lowerCase = false): string
+ {
+ // 1. Transliterate non-ASCII characters to ASCII.
+ $text = iconv('utf-8', 'us-ascii//TRANSLIT', $text);
+
+ // 2. Remove any characters that are not alphanumeric or spaces.
+ $text = preg_replace("/[^a-zA-Z0-9 ]/", "", $text);
+
+ // 3. Trim leading and trailing spaces.
+ $text = trim($text, ' ');
+
+ // 4. Optionally replace spaces with hyphens.
+ if ($removeSpaces) {
+ $text = str_replace(' ', '-', $text);
+ }
+
+ // 5. Optionally convert the slug to lowercase.
+ if ($lowerCase) {
+ $text = strtolower($text);
+ }
+
+ // 6. Return the generated slug.
+ return $text;
+ }
+
+
+ /**
+ * Checks if the cart's currency is supported by the module.
+ *
+ * This method retrieves the currency of the provided cart and then checks if this
+ * currency is present within the list of currencies supported by the module.
+ *
+ * @param Cart $cart The cart object whose currency needs to be checked.
+ * @return bool True if the cart's currency is supported by the module, false otherwise.
+ */
+ private function checkCurrency(Cart $cart): bool
+ {
+ // 1. Get the currency object of the order from the cart.
+ $orderCurrency = new Currency((int)$cart->id_currency);
+
+ // 2. Get the list of currencies supported by this module.
+ $moduleCurrencies = $this->getCurrency((int)$cart->id_currency);
+
+ // 3. Check if the module supports any currencies.
+ if (is_array($moduleCurrencies)) {
+ // 4. Iterate through the module's supported currencies.
+ foreach ($moduleCurrencies as $moduleCurrency) {
+ // 5. If the order currency ID matches a supported currency ID, return true.
+ if ($orderCurrency->id === (int)$moduleCurrency['id_currency']) {
+ return true;
+ }
+ }
+ }
+
+ // 6. If no matching currency is found, return false.
+ return false;
+ }
+
+
+
+
+ /**
+ * Generates a signature based on the provided data and a secret password.
+ *
+ * This method filters out empty and null values from the input data, sorts the remaining
+ * data alphabetically by key, concatenates the values with a pipe delimiter, prepends
+ * the secret password, and then generates a SHA1 hash of the resulting string.
+ *
+ * @param array $data An associative array of data to be included in the signature generation.
+ * Empty strings and null values in this array will be excluded.
+ * @param string $password The secret key used to generate the signature. This should be
+ * kept confidential.
+ * @param bool $encoded Optional. Whether to return the SHA1 encoded signature (true by default)
+ * or the raw string before encoding (false).
+ * @return string The generated signature (SHA1 hash by default) or the raw string.
+ */
+ public function getSignature(array $data, string $password, bool $encoded = true): string
+ {
+ // 1. Filter out empty and null values from the data array.
+ $filteredData = array_filter($data, function ($value) {
+ return $value !== '' && $value !== null;
+ });
+
+ // 2. Sort the filtered data array alphabetically by key.
+ ksort($filteredData);
+
+ // 3. Construct the string to be hashed. Start with the password.
+ $stringToHash = $password;
+
+ // 4. Append the values from the sorted data array, separated by a pipe.
+ foreach ($filteredData as $value) {
+ $stringToHash .= '|' . $value;
+ }
+
+ // 5. Return the SHA1 hash of the string or the raw string based on the $encoded flag.
+ if ($encoded) {
+ return sha1($stringToHash);
+ } else {
+ return $stringToHash;
+ }
+ }
+
+
+ /**
+ * Validates the signature of a payment gateway response.
+ *
+ * This method verifies that the received response originates from the expected merchant
+ * and that the signature matches the calculated signature based on the response data
+ * and the merchant's secret key.
+ *
+ * @param array $response An associative array containing the payment gateway's response data.
+ * This array is expected to include keys 'merchant_id' and 'signature'.
+ * It might also contain temporary signature-related keys that will be unset
+ * during the validation process.
+ * @return bool True if the response is valid (merchant ID matches and signature is correct),
+ * false otherwise.
+ */
+ public function validateResponse(array $response): bool
+ {
+ // 1. Verify the Merchant ID
+ if (Configuration::get('HUTKO_MERCHANT') !== $response['merchant_id']) {
+ return false;
+ }
+
+ // 2. Prepare Response Data for Signature Verification
+ $responseSignature = $response['signature'];
+
+ // Unset signature-related keys that should not be part of the signature calculation.
+ // This ensures consistency with how the signature was originally generated.
+ unset($response['response_signature_string'], $response['signature']);
+
+ // 3. Calculate and Compare Signatures
+ $calculatedSignature = $this->getSignature($response, Configuration::get('HUTKO_SECRET_KEY'));
+
+ return hash_equals($calculatedSignature, $responseSignature);
+ }
+
+
+ /**
+ * Postpones the execution of a callback function until the last digit of the current second
+ * matches a specified target digit, and returns the result of the callback.
+ *
+ * @param callable $callback The callback function to execute.
+ * @param int $targetDigit An integer from 0 to 9, representing the desired last digit of the second.
+ * return the result of the callback function execution.
+ * @throws InvalidArgumentException If $targetDigit is not an integer between 0 and 9.
+ */
+ function postponeCallback(callable $callback, int $targetDigit)
+ {
+ // Validate the target digit to ensure it's within the valid range (0-9)
+ if ($targetDigit < 0 || $targetDigit > 9) {
+ throw new InvalidArgumentException("The target digit must be an integer between 0 and 9.");
+ }
+
+ // Loop indefinitely until the condition is met
+ while (true) {
+ // Get the current second as a two-digit string (e.g., '05', '12', '59')
+ $currentSecond = (int)date('s');
+
+ // Extract the last digit of the current second
+ $lastDigitOfSecond = $currentSecond % 10;
+
+ // Check if the last digit matches the target digit
+ if ($lastDigitOfSecond === $targetDigit) {
+ echo "Condition met! Current second is {$currentSecond}, last digit is {$lastDigitOfSecond}.\n";
+ // If the condition is met, execute the callback and return its result
+ return $callback(); // Capture and return the callback's result
+ } else {
+ // If the condition is not met, print the current status and wait for a short period
+ echo "Current second: {$currentSecond}, last digit: {$lastDigitOfSecond}. Still waiting...\n";
+ // Wait for 100 milliseconds (0.1 seconds) to avoid busy-waiting and reduce CPU usage
+ usleep(100000); // 100000 microseconds = 100 milliseconds
+ }
+ }
+ }
+
+
+ /**
+ * Helper method to update order status and add to history.
+ *
+ * @param int $orderId The ID of the order to update.
+ * @param int $newStateId The ID of the new order state.
+ * @param string $message A message to log with the status change.
+ * @return void
+ */
+ public function updateOrderStatus(int $orderId, int $newStateId, string $message = ''): void
+ {
+ $order = new Order($orderId);
+ // Only update if the order is loaded and the current state is different from the new state.
+ if (Validate::isLoadedObject($order) && (int)$order->getCurrentState() !== $newStateId) {
+ $history = new OrderHistory();
+ $history->id_order = $orderId;
+ $history->changeIdOrderState($newStateId, $orderId);
+ $history->addWithemail(true, ['order_name' => $orderId]);
+ // PrestaShopLogger::addLog('Hutko Callback: Order ' . $orderId . ' status changed to ' . $newStateId . '. Message: ' . $message, 1, null, 'Order', $orderId, true);
+ } else {
+ // Log if the order was not loaded or already in the target state.
+ // PrestaShopLogger::addLog('Hutko Callback: Attempted to update order ' . $orderId . ' to state ' . $newStateId . ' but order not loaded or already in target state. Message: ' . $message, 2, null, 'Order', $orderId, true);
+ }
+ }
+}
diff --git a/index.php b/index.php
new file mode 100644
index 0000000..c4d62ed
--- /dev/null
+++ b/index.php
@@ -0,0 +1,21 @@
+ Менеджер модулів**.
+
+* Натисніть кнопку «Завантажити модуль» (зазвичай розташована у верхньому правому куті).
+
+* Перетягніть файл модуля `.zip` в область завантаження або клацніть, щоб вибрати файл.
+
+3. **Встановіть модуль:**
+
+* Після завантаження PrestaShop автоматично виявить модуль.
+
+* Натисніть кнопку «Встановити» поруч із модулем «Hutko».
+
+* Дотримуйтесь будь-яких підказок на екрані.
+
+## Конфігурація
+
+Після успішної інсталяції необхідно налаштувати модуль, використовуючи дані вашого облікового запису Hutko:
+
+1. **Конфігурація модуля доступу:**
+
+* У панелі адміністратора PrestaShop перейдіть до **Модулі > Менеджер модулів**.
+
+* Знайдіть модуль "Hutko" та натисніть кнопку "Налаштувати".
+
+2. **Введіть необхідні облікові дані:**
+
+* **Ідентифікатор продавця:** Введіть свій унікальний ідентифікатор продавця, наданий Hutko. Це обов'язкове поле.
+
+* **Секретний ключ:** Введіть свій секретний ключ, наданий Hutko. Це обов'язкове поле, яке є критично важливим для безпечної перевірки підпису.
+
+* **Статус успішного замовлення:** (Необов'язково, якщо застосовується) Виберіть статус замовлення, який слід застосовувати до замовлень, успішно оплачених через Hutko.
+
+* **Показати логотип картки:** (Необов'язково) Увімкніть або вимкніть відображення логотипів картки на сторінці вибору способу оплати.
+
+3. **Зберегти зміни:** Натисніть кнопку "Зберегти", щоб застосувати налаштування конфігурації.
+
+**Важливо:** Без правильного налаштування **Ідентифікатора продавця** та **Секретного ключа** модуль не працюватиме належним чином і не відображатиметься як варіант оплати під час оформлення замовлення.
+
+## Використання
+
+Після налаштування варіант оплати Hutko автоматично з’явиться на сторінці оформлення замовлення для клієнтів.
+
+1. Клієнти вибирають «Оплатити через Hutko» на кроці оплати.
+
+2. Їх буде перенаправлено на сторінку оплати Hutko для завершення транзакції.
+
+3. Після успішної оплати клієнта буде перенаправлено назад на сторінку підтвердження замовлення вашого магазину PrestaShop, і статус замовлення буде оновлено відповідно.
+
+4. У разі невдалої оплати клієнта буде перенаправлено назад на сторінку замовлення з відповідним повідомленням про помилку.
+
+## Підтримка
+
+Якщо у вас виникнуть проблеми або виникнуть запитання щодо модуля Hutko PrestaShop, будь ласка, зверніться до наступного:
+
+* **Документація Hutko:** Зверніться до офіційного API та документації інтеграції Hutko для отримання детальної інформації.
+
+* **Форуми PrestaShop:** Шукайте або залишайте своє запитання на офіційних форумах PrestaShop.
+
+* **Зв’язатися з розробником:** Для отримання безпосередньої підтримки ви можете звернутися до автора модуля `panariga`.
+
+# Hutko PrestaShop Payment Module
+
+Hutko is a payment service that drives businesses forward. Launch, gain momentum, scale – we've got you covered everywhere.
+
+This module integrates the Hutko payment gateway into your PrestaShop store, allowing your customers to pay for their orders securely through Hutko.
+
+## Table of Contents
+
+1. [Features](#features)
+
+2. [Installation](#installation)
+
+3. [Configuration](#configuration)
+
+4. [Usage](#usage)
+
+5. [Support](#support)
+
+## Features
+
+* Seamless integration with the Hutko payment gateway.
+
+* Secure payment processing.
+
+* Support for various payment statuses (Approved, Declined, Expired, Processing).
+
+* Automatic order status updates in PrestaShop.
+
+* Robust handling of payment callbacks to prevent race conditions.
+
+## Installation
+
+Follow these steps to install the Hutko module on your PrestaShop store:
+
+1. **Download the Module:** Obtain the latest version of the Hutko module from the official source or your provided package.
+
+2. **Upload to PrestaShop:**
+
+ * Log in to your PrestaShop admin panel.
+
+ * Navigate to **Modules > Module Manager**.
+
+ * Click on the "Upload a module" button (usually located in the top right corner).
+
+ * Drag and drop the module's `.zip` file into the upload area, or click to select the file.
+
+3. **Install the Module:**
+
+ * Once uploaded, PrestaShop will automatically detect the module.
+
+ * Click on the "Install" button next to the "Hutko" module.
+
+ * Follow any on-screen prompts.
+
+## Configuration
+
+After successful installation, you must configure the module with your Hutko account details:
+
+1. **Access Module Configuration:**
+
+ * In your PrestaShop admin panel, go to **Modules > Module Manager**.
+
+ * Find the "Hutko" module and click on the "Configure" button.
+
+2. **Enter Required Credentials:**
+
+ * **Merchant ID:** Enter your unique Merchant ID provided by Hutko. This is a mandatory field.
+
+ * **Secret Key:** Enter your Secret Key provided by Hutko. This is a mandatory field and is crucial for secure signature validation.
+
+ * **Success Order Status:** (Optional, if applicable) Select the order status that should be applied to orders successfully paid via Hutko.
+
+ * **Show Cards Logo:** (Optional) Enable or disable the display of card logos on the payment selection page.
+
+3. **Save Changes:** Click the "Save" button to apply your configuration settings.
+
+**Important:** Without setting the correct **Merchant ID** and **Secret Key**, the module will not function correctly and will not appear as a payment option during checkout.
+
+## Usage
+
+Once configured, the Hutko payment option will automatically appear on your checkout page for customers.
+
+1. Customers select "Pay via Hutko" on the payment step of the checkout.
+
+2. They are redirected to the Hutko payment page to complete their transaction.
+
+3. Upon successful payment, the customer is redirected back to your PrestaShop store's order confirmation page, and the order status is updated accordingly.
+
+4. In case of payment failure, the customer will be redirected back to the order page with an appropriate error message.
+
+## Support
+
+If you encounter any issues or have questions regarding the Hutko PrestaShop module, please refer to the following:
+
+* **Hutko Documentation:** Consult the official Hutko API and integration documentation for detailed information.
+
+* **PrestaShop Forums:** Search or post your question on the official PrestaShop forums.
+
+* **Contact Developer:** For direct support, you can contact the module author `panariga`.
\ No newline at end of file
diff --git a/views/img/hutko_logo_cards.svg b/views/img/hutko_logo_cards.svg
new file mode 100644
index 0000000..8e428f9
--- /dev/null
+++ b/views/img/hutko_logo_cards.svg
@@ -0,0 +1,87 @@
+
+
+
+
diff --git a/views/img/index.php b/views/img/index.php
new file mode 100644
index 0000000..55c9b2c
--- /dev/null
+++ b/views/img/index.php
@@ -0,0 +1,20 @@
+
+
+ {$hutko_description|escape:'htmlall'}
+
+
+{/block}
\ No newline at end of file
diff --git a/views/templates/index.php b/views/templates/index.php
new file mode 100644
index 0000000..55c9b2c
--- /dev/null
+++ b/views/templates/index.php
@@ -0,0 +1,20 @@
+