name = 'hutko'; $this->tab = 'payments_gateways'; $this->version = '1.3.1'; $this->author = 'Hutko'; $this->bootstrap = true; $this->need_instance = false; parent::__construct(); $this->ps_versions_compliancy = array('min' => '1.7', 'max' => _PS_VERSION_); //Do not translate displayName as it is used for payment identification $this->displayName = 'Hutko Payments'; $this->description = $this->trans('Hutko is a payment platform whose main function is to provide internet acquiring.', array(), 'Modules.Hutko.Admin'); } public function install(): bool { $success = parent::install() && $this->registerHook('paymentOptions'); // If the initial mandatory hooks failed, stop here. if (!$success) { return false; } // Now, conditionally register the order content/tab hook based on PS version. // We only need to check if the *required* hook for this version was successfully registered. $conditionalHookSuccess = true; // Assume success until we try to register one and it fails // Check if PrestaShop version is 1.7.x (>= 1.7 and < 1.7.7) if (version_compare(_PS_VERSION_, '1.7', '>=') && version_compare(_PS_VERSION_, '1.7.7', '<')) { // Register the 1.7 hook $conditionalHookSuccess = $this->registerHook('displayAdminOrderContentOrder'); } // Check if PrestaShop version is 1.7.7 or 9.x (>= 1.7.7 and < 10.0) elseif (version_compare(_PS_VERSION_, '1.7.7', '>=') && version_compare(_PS_VERSION_, '10.0', '<')) { // Register the 8.x/9.x hook $conditionalHookSuccess = $this->registerHook('displayAdminOrderTabContent'); } // Note: If it's a different version (e.g., 1.6, 10.0+), neither of these specific hooks will be registered, // and $conditionalHookSuccess remains true, which is the desired behavior based on the requirement. // The module installation is successful only if the initial hooks passed AND the conditional hook (if attempted) passed. return $success && $conditionalHookSuccess; } 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(): string { /** * 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() . $this->context->smarty->fetch('module:hutko/views/templates/admin/help.tpl') . $this->displayLastDayLog(); } /** * Create the form that will be displayed in the configuration of your module. */ public 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. */ public function getConfigForm() { $options = []; foreach (OrderState::getOrderStates($this->context->language->id) 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. Use 1700002 for test setup.', 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. Use "test" for test setup', 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' => 'select', 'prefix' => '', 'name' => 'HUTKO_NEW_ORDER_STATUS_ID', 'label' => $this->trans('Status for new orders before payment', array(), 'Modules.Hutko.Admin'), 'options' => array( 'query' => $options, 'id' => 'status_id', 'name' => 'name' ) ), array( 'type' => 'radio', 'name' => 'HUTKO_SHIPPING_INCLUDE', 'label' => $this->trans('Include shipping cost to payment', array(), 'Modules.Hutko.Admin'), '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') ) ), ), array( 'type' => 'text', 'name' => 'HUTKO_SHIPPING_PRODUCT_NAME', 'label' => $this->trans('Shipping Name', array(), 'Modules.Hutko.Admin'), 'desc' => $this->trans('Name of product/service to use in fiscalization for shipping amount', array(), 'Modules.Hutko.Admin'), ), array( 'type' => 'text', 'name' => 'HUTKO_SHIPPING_PRODUCT_CODE', 'label' => $this->trans('Shipping Code', array(), 'Modules.Hutko.Admin'), 'desc' => $this->trans('Code of product/service to use in fiscalization for shipping amount', array(), 'Modules.Hutko.Admin'), ), 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') ) ), ), array( 'type' => 'radio', 'label' => $this->trans('Include fixed amount discounts to Payment amount calculation', array(), 'Modules.Hutko.Admin'), 'name' => 'HUTKO_INCLUDE_DISCOUNT_TO_TOTAL', 'is_bool' => true, 'values' => array( array( 'id' => 'include_discount_to_total', 'value' => 1, 'label' => $this->trans('Yes', array(), 'Modules.Hutko.Admin') ), array( 'id' => 'discard_discount_to_total', 'value' => 0, 'label' => $this->trans('No', array(), 'Modules.Hutko.Admin') ) ), ), array( 'type' => 'radio', 'label' => $this->trans('Save Logs', array(), 'Modules.Hutko.Admin'), 'name' => 'HUTKO_SAVE_LOGS', 'is_bool' => true, 'values' => array( array( 'id' => 'save_logs', 'value' => 1, 'label' => $this->trans('Yes', array(), 'Modules.Hutko.Admin') ), array( 'id' => 'discard_logs', '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. */ public function getConfigFormValues(): array { foreach ($this->settingsList as $settingName) { $list[$settingName] = Configuration::get($settingName); } return $list; } /** * Save form data. */ public function postProcess() { foreach ($this->settingsList as $settingName) { Configuration::updateValue($settingName, Tools::getValue($settingName)); } } /** * 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. */ public 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 buildPaymentRequestData(Order $order, ?float $amount, ?Currency $currency, ?Customer $customer): array { // 1. Generate a unique order ID combining the cart ID and current timestamp. $orderId = $order->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('Order payment #', [], 'Modules.Hutko.Admin') . $order->reference; // 4. Calculate the order amount in the smallest currency unit. if (!$amount) { if (Configuration::get('HUTKO_SHIPPING_INCLUDE') && $order->total_shipping_tax_incl > 0) { $amount = $order->total_products_wt + $order->total_shipping_tax_incl; } else { $amount = $order->total_products_wt; } } if (Configuration::get('HUTKO_INCLUDE_DISCOUNT_TO_TOTAL')) { $amount -= $order->total_discounts_tax_incl; } $amountInt = round($amount * 100); // 5. Get the currency ISO code of the current cart. if (!$currency) { $currency = new Currency($order->id_currency); } $currencyISO = $currency->iso_code; // 6. Generate the server callback URL. $serverCallbackUrl = $this->context->link->getModuleLink($this->name, 'callback', [], true); // 7. Retrieve the customer's email address. if (!$customer) { $customer = new Customer($order->id_customer); } $customerEmail = $customer->email; // 8. Generate the customer redirection URL after payment. $responseUrl = $this->context->link->getModuleLink($this->name, 'return', ['hutkoPV' => $this->urlSafeEncode(json_encode([ 'id_cart' => $order->id_cart, 'id_module' => $this->id, 'id_order' => $order->id, 'key' => $customer->secure_key, ]))], true); // 9. Build the reservation data as a base64 encoded JSON string. $reservationData = $this->buildReservationData($order); // 10. Construct the data array with all the collected parameters. $data = [ 'order_id' => $orderId, 'merchant_id' => $merchantId, 'order_desc' => $orderDescription, 'amount' => $amountInt, 'currency' => $currencyISO, '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); // 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(Order $order): string { // 1. Retrieve the delivery address for the current cart. $address = new Address((int)$order->id_address_delivery, $order->id_lang); // 2. Fetch the customer's state name, if available. $customerState = ''; if ($address->id_state) { $state = new State((int) $address->id_state, $order->id_lang); $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" => empty($address->phone_mobile) ? $address->phone : $address->phone_mobile, "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" => $order->id_customer, "uuid" => hash('sha256', _COOKIE_KEY_ . Tools::getShopDomainSsl()), "products" => $this->getProducts($order), ]; return base64_encode(json_encode($data)); } /** * 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(Order $order): array { $products = []; foreach ($order->getProductsDetail() as $productDetail) { $products[] = [ "id" => $productDetail['product_id'] . '_' . $productDetail['product_attribute_id'] . '_' . $productDetail['id_customization'], "name" => $productDetail['product_name'], "price" => round((float)$productDetail['unit_price_tax_incl'], 2), "total_amount" => round((float) $productDetail['total_price_tax_incl'], 2), "quantity" => (int)$productDetail['product_quantity'], ]; } if (Configuration::get('HUTKO_SHIPPING_INCLUDE') && $order->total_shipping_tax_incl > 0) { $products[] = [ "id" => Configuration::get('HUTKO_SHIPPING_PRODUCT_CODE', null, null, null, '0_0_1'), "name" => Configuration::get('HUTKO_SHIPPING_PRODUCT_NAME', null, null, null, 'Package material'), "price" => round((float)$order->total_shipping_tax_incl, 2), "total_amount" => round((float) $order->total_shipping_tax_incl, 2), "quantity" => 1, ]; } return $products; } /** * 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, bool $encoded = true): string { $password = Configuration::get('HUTKO_SECRET_KEY'); if (!$password || empty($password)) { throw new PrestaShopException('Merchant secret not set'); } // 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 ((string)Configuration::get('HUTKO_MERCHANT') != (string)$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); return hash_equals($calculatedSignature, $responseSignature); } /** * 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): 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(); } } /** * Hook implementation for PrestaShop 1.7.x to display content in the admin order page. * * This hook is typically used to add content *below* the main order details * but before the tabbed section. It's often used for specific sections * rather than entire tabs in 1.7. However, in this case, it's likely * being used as a fallback or alternative for displaying the payment content * depending on the module's design or compatibility needs for 1.7. * * @param array $params Contains context information, including the 'order' object. * @return string The HTML content to be displayed in the admin order page. */ public function hookdisplayAdminOrderContentOrder(array $params) { // Delegate the actual content generation to a shared function // to avoid code duplication. return $this->displayAdminOrderContent($params); } /** * Hook implementation for PrestaShop 8.x and 9.x to display content * within a specific tab on the admin order page. * * This hook is the standard way in newer PS versions to add a custom tab * and populate its content on the order detail page. * * @param array $params Contains context information, including the 'order' object. * @return string The HTML content to be displayed within the module's custom tab. */ public function hookdisplayAdminOrderTabContent(array $params) { $params['order'] = new Order((int) $params['id_order']); // Delegate the actual content generation to a shared function // to avoid code duplication. return $this->displayAdminOrderContent($params); } /** * Common function to display content related to Hutko payments on the admin order page. * * This function handles the logic for processing potential form submissions (like refunds * or status updates) and preparing data to be displayed in a template. * * It is called by different hooks depending on the PrestaShop version * (displayAdminOrderContentOrder for 1.7.x, displayAdminOrderTabContent for 8.x/9.x). * * @param array $params Contains context information from the hook, typically including the 'order' object. * @return string The rendered HTML content from the template. */ public function displayAdminOrderContent(array $params): string { if (!Configuration::get('HUTKO_MERCHANT') || empty(Configuration::get('HUTKO_MERCHANT') || !Configuration::get('HUTKO_SECRET_KEY') || empty(Configuration::get('HUTKO_SECRET_KEY')))) { return ''; } // Ensure the 'order' object is present in the parameters if (!isset($params['order']) || !$params['order'] instanceof Order) { // Log an error or return an empty string if the order object is missing // depending on how critical it is. Returning empty string is safer // to avoid crashing the admin page. PrestaShopLogger::addLog( 'Hutko Module: Order object missing in displayAdminOrderContent hook.', 1, // Error level null, 'Module', (int)$this->id ); return ''; } // Check if a refund form has been submitted if (Tools::isSubmit('hutkoRefundsubmit')) { // Process the refund logic. $this->processRefundForm(); } // Check payment status if (Tools::getValue('hutkoOrderPaymentStatus')) { // Process the requested order status check. $this->renderOrderPaymentStatus(Tools::getValue('hutkoOrderPaymentStatus')); } // Get the Order object from the parameters $order = $params['order']; // Fetch all OrderPayment records associated with this order // that were processed specifically by this payment module (based on display name) // and have a transaction ID matching a specific pattern (order ID prefix). // The transaction_id pattern suggests it's linked to the order ID for easy lookup. $hutkoPayments = new PrestaShopCollection('OrderPayment'); $hutkoPayments->where('order_reference', '=', $order->reference); // Filter by order reference $hutkoPayments->where('payment_method', '=', $this->displayName); // Filter by this module's payment method name // Filter by transaction ID pattern: Starts with the order ID followed by the configured separator. // This assumes transaction IDs generated by this module follow this format. $hutkoPayments->where('transaction_id', 'like', '' . $order->id . $this->order_separator . '%'); // Assign data to Smarty to be used in the template $this->context->smarty->assign([ // Pass the fetched Hutko payment records to the template as an array 'hutkoPayments' => $hutkoPayments, // Pass the order ID to the template 'id_order' => $order->id, 'currency' => new Currency($order->id_currency), 'hutkoOrderPaymentStatus' => $this->context->session->get('hutkoOrderPaymentStatus'), ]); // Render the template located at 'views/templates/admin/order_payment_refund.tpl' // This template will display the fetched payment information and potentially refund/status forms. return $this->display(__FILE__, 'views/templates/admin/order_payment_refund.tpl'); } public function renderOrderPaymentStatus(string $order_id) { $data = $this->getOrderPaymentStatus($order_id); $this->context->session->set('hutkoOrderPaymentStatus', $this->displayArrayInNotification($data['response'])); Tools::redirectAdmin($this->context->controller->currentIndex); } public function getOrderPaymentStatus(string $order_id): array { $data = [ 'order_id' => $order_id, 'merchant_id' => Configuration::get('HUTKO_MERCHANT'), 'version' => '1.0', ]; $data['signature'] = $this->getSignature($data); return $this->sendAPICall($this->status_url, $data); } public function processRefundForm() { $orderPaymentId = (int) Tools::getValue('orderPaymentId'); $amount = (float) Tools::getValue('refund_amount'); $comment = mb_substr(Tools::getValue('comment', ''), 0, 1024); $id_order = (int) Tools::getValue('id_order'); $result = $this->processRefund($orderPaymentId, $id_order, $amount, $comment); $link = $this->context->link->getAdminLink('AdminOrders', true, [], ['id_order' => $id_order, 'vieworder' => true]); if ($result->error) { $this->context->controller->errors[] = $result->description; } if ($result->success) { $this->context->controller->informations[] = $result->description; } Tools::redirectAdmin($link); } /** * Processes a payment refund via the Hutko gateway and updates PrestaShop order. * * This method initiates a refund request to the Hutko payment gateway for a specific * order payment. Upon successful refund from Hutko, it creates an OrderSlip (if partial), * updates the order history, and logs the action. * * @param int $orderPaymentId The ID of the OrderPayment record to refund. * @param int $id_order The ID of the Order to refund. * @param float $amount The amount to refund. * @param string $comment A comment or reason for the refund. * @return stdClass Result description. * @throws Exception If the OrderPayment is not found, invalid, or refund fails. */ public function processRefund(int $orderPaymentId, int $id_order, float $amount, string $comment = ''): stdClass { $result = new stdClass(); $result->error = false; $order = new Order($id_order); $orderPayment = new OrderPayment($orderPaymentId); $currency = new Currency($orderPayment->id_currency); if (!Validate::isLoadedObject($orderPayment)) { PrestaShopLogger::addLog( 'Hutko Refund: OrderPayment object not found for ID: ' . $orderPaymentId, 3, // Error null, 'OrderPayment', $orderPaymentId, true ); throw new Exception($this->trans('Order payment not found.', [], 'Modules.Hutko.Admin')); } // Validate the transaction_id format and extract cart ID. // Assuming transaction_id is in the format "orderID#timestamp" $transactionIdParts = explode($this->order_separator, $orderPayment->transaction_id); $cartId = (int)$transactionIdParts[0]; if (!$cartId) { PrestaShopLogger::addLog( 'Hutko Refund: Invalid transaction ID format for OrderPayment ID: ' . $orderPaymentId . ' Transaction ID: ' . $orderPayment->transaction_id, 3, // Error null, 'OrderPayment', $orderPaymentId, true ); throw new Exception($this->trans('Invalid transaction ID format.', [], 'Modules.Hutko.Admin')); } $response = $this->refundAPICall($orderPayment->transaction_id, $amount, $currency->iso_code, $comment); if ($response['response']['reverse_status'] === 'approved' && $response['response']['response_status'] === 'success') { $result->success = true; $result->description = $this->trans('Refund success.', [], 'Modules.Hutko.Admin'); } else { PrestaShopLogger::addLog( 'Hutko Refund: refund failure response: ' . json_encode($response), 3, // Info null, 'OrderPayment', $orderPaymentId, true ); $this->addOrderMessage($order, $this->trans('Refund Failed. Please check actual amount in Hutko account page.', [], 'Modules.Hutko.Admin')); $result->error = true; $result->description = $response['response']['error_message']; return $result; } $amountFloat = round((int)$response['response']['reversal_amount'] / 100, 2); $this->addOrderMessage($order, $this->trans('Refund success.', [], 'Modules.Hutko.Admin') . ' ' . $currency->iso_code . $amountFloat . '. [' . $comment . ']'); $order->addOrderPayment($amountFloat, $this->displayName, $orderPayment->transaction_id . '_refund', $currency); $order->setCurrentState((int)Configuration::get('PS_OS_REFUND')); return $result; } public function addOrderMessage(Order $order, string $message) { try { $customer = new Customer($order->id_customer); $thread = CustomerThread::getIdCustomerThreadByEmailAndIdOrder($customer->email, $order->id); if (is_int((int) $thread) && $thread != 0) { $thread = (int) $thread; } else { $customer_thread = new CustomerThread(); $customer_thread->id_contact = 0; $customer_thread->id_customer = (int) $order->id_customer; $customer_thread->id_shop = (int) $order->id_shop; $customer_thread->id_order = (int) $order->id; $customer_thread->id_lang = (int) $this->context->language->id; $customer_thread->email = $customer->email; $customer_thread->status = 'open'; $customer_thread->token = Tools::passwdGen(12); $customer_thread->add(); $thread = $customer_thread->id; } $customer_message = new CustomerMessage(); $customer_message->id_customer_thread = $thread; $customer_message->id_employee = 1; $customer_message->message = $message; $customer_message->private = 1; if (false === $customer_message->validateField('message', $customer_message->message)) { throw new Exception('Invalid reply message'); } if (false === $customer_message->add()) { throw new Exception('Failed to add customer message'); } } catch (Throwable $e) { PrestaShopLogger::addLog('failed add message to order. ' . $e->getMessage() . ' | ' . $message, 3); } } public function addRefundPayment(Order $order, OrderPayment $purchaseOrderPayment, float $amount) { $order_payment = new OrderPayment(); $order_payment->order_reference = $order->reference; $order_payment->id_currency = $purchaseOrderPayment->id_currency; $order_payment->payment_method = $purchaseOrderPayment->payment_method; $order_payment->transaction_id = $purchaseOrderPayment->transaction_id . $this->order_separator . 'refund'; $order_payment->amount = $amount; $order_payment->id_employee = 0; $order_payment->add(); $order->total_paid_real = $order->total_paid_real - $amount; $order->update(); } /** * Initiates a refund (reverse) request via Hutko API. * * @param string $order_id The gateway's order ID to refund. * @param float $amount The amount to refund (in base units, e.g., 100.50). * @param string $currency The currency code (e.g., 'UAH'). * @param string $comment Optional comment for the refund. * @return array Decoded API response array. Returns an error structure on failure. */ public function refundAPICall(string $order_id, float $amount, string $currencyISO, string $comment = ''): array { // 1. Prepare the data payload $data = [ 'order_id' => $order_id, // Assuming Configuration::get is available to fetch the merchant ID 'merchant_id' => Configuration::get('HUTKO_MERCHANT'), 'version' => '1.0', // Amount should be in minor units (cents, kopecks) and converted to string as per API example 'amount' => round($amount * 100), 'currency' => $currencyISO, ]; if (!empty($comment)) { $data['comment'] = $comment; } // 2. Calculate the signature based on the data array *before* wrapping in 'request' $data['signature'] = $this->getSignature($data); return $this->sendAPICall($this->refund_url, $data); } /** * Initiates a request via Hutko API. * * @param string $url The gateway's url. * @param array $data The data. * @return array Decoded API response array. Returns an error structure on failure. */ public function sendAPICall(string $url, array $data, int $timeout = 60): array { if (Configuration::get('HUTKO_SAVE_LOGS')) { $this->log($url . ' <= ' . json_encode($data)); } // Wrap the prepared data inside the 'request' key as required by the API $requestPayload = ['request' => $data]; // Convert the payload to JSON string $jsonPayload = json_encode($requestPayload); if ($jsonPayload === false) { // Handle JSON encoding error return [ 'response' => [ 'response_status' => 'failure', 'error_message' => 'Failed to encode request data to JSON: ' . json_last_error_msg(), 'error_code' => 'JSON_ENCODE_ERROR' ] ]; } // Initialize CURL $ch = curl_init(); // 4. Set CURL options curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_POST, true); // Use POST method curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonPayload); // Set the JSON body curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // Return the response as a string curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json', 'Content-Length: ' . strlen($jsonPayload), // Good practice ]); // Recommended for production: Verify SSL certificate curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); // Verify hostname against certificate curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); // Timeout in seconds // Execute the CURL request $response = curl_exec($ch); // Check for CURL errors $curl_error = curl_error($ch); $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); if ($curl_error) { // Log the error or handle it appropriately curl_close($ch); return [ 'response' => [ 'response_status' => 'failure', 'error_message' => 'CURL Error: ' . $curl_error, 'error_code' => 'CURL_' . curl_errno($ch), 'http_code' => $http_code // Include http code for context ] ]; } // Close CURL handle curl_close($ch); if (Configuration::get('HUTKO_SAVE_LOGS')) { $this->log($url . ' => ' . $response); } // Process the response // Decode the JSON response into a PHP array $responseData = json_decode($response, true); // Check if JSON decoding failed if (json_last_error() !== JSON_ERROR_NONE) { // Log the error or handle it appropriately return [ 'response' => [ 'response_status' => 'failure', 'error_message' => 'Invalid JSON response from API: ' . json_last_error_msg(), 'error_code' => 'JSON_DECODE_ERROR', 'http_code' => $http_code, 'raw_response' => $response // Include raw response for debugging ] ]; } return $responseData; } /** * Displays an array's contents in a PrestaShop notification box. * * This function is intended for debugging or displaying API responses/structured data * in the PrestaShop back office notifications. * * @param array $data The array to display. */ protected function displayArrayInNotification(array $data): string { if (isset($data['response_signature_string'])) { unset($data['response_signature_string']); } if (isset($data['additional_info'])) { $data['additional_info_decoded'] = json_decode($data['additional_info'], true); if (isset($data['additional_info_decoded']['reservation_data'])) { $data['additional_info_decoded']['reservation_data_decoded'] = json_decode($data['additional_info_decoded']['reservation_data'], true); unset($data['additional_info_decoded']['reservation_data']); } unset($data['additional_info']); } $retStr = '
' . $this->trans('The log file is empty.', [], 'Modules.Hutko.Admin') . '
'; } else { // Output each line wrapped in atag foreach ($lines as $line) { // Use htmlspecialchars to prevent HTML injection and display characters correctly // Add word-wrap style for long lines $html .= '
' . htmlspecialchars($line, ENT_QUOTES, 'UTF-8') . '
'; } } } else { // Error reading the file $html .= '