Files
hutko/hutko.php
2025-06-08 15:40:24 +03:00

1370 lines
57 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* Hutko - Платіжний сервіс, який рухає бізнеси вперед.
*
* Запускайтесь, набирайте темп, масштабуйтесь ми підстрахуємо всюди.
*
* @author panariga
* @copyright 2025 Hutko
* @license http://opensource.org/licenses/afl-3.0.php Academic Free License (AFL 3.0)
*/
use PrestaShop\PrestaShop\Core\Payment\PaymentOption;
if (!defined('_PS_VERSION_')) {
exit;
}
class Hutko extends PaymentModule
{
public $order_separator = '#';
public $redirect_url = 'https://pay.hutko.org/api/checkout/redirect/';
public $checkout_url = 'https://pay.hutko.org/api/checkout/url/';
public $refund_url = 'https://pay.hutko.org/api/reverse/order_id';
public $status_url = 'https://pay.hutko.org/api/status/order_id';
public $settingsList = [
'HUTKO_MERCHANT',
'HUTKO_SECRET_KEY',
'HUTKO_SHIPPING_INCLUDE',
'HUTKO_SHIPPING_PRODUCT_NAME',
'HUTKO_SHIPPING_PRODUCT_CODE',
'HUTKO_NEW_ORDER_STATUS_ID',
'HUTKO_SUCCESS_STATUS_ID',
'HUTKO_SHOW_CARDS_LOGO',
'HUTKO_SAVE_LOGS',
'HUTKO_INCLUDE_DISCOUNT_TO_TOTAL'
];
public $postErrors = [];
public function __construct()
{
$this->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' => '<i class="icon icon-user"></i>',
'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' => '<i class="icon icon-key"></i>',
'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' => '<i class="icon icon-key"></i>',
'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' => '<i class="icon icon-key"></i>',
'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 = '<ul>';
if (is_array($data)) {
foreach ($data as $key => $val) {
if (is_array($val)) {
$retStr .= '<li>' . $key . ' => ' . $this->displayArrayInNotification($val) . '</li>';
} else {
$retStr .= '<li>' . $key . ' => ' . $val . '</li>';
}
}
}
$retStr .= '</ul>';
return $retStr;
}
/**
* Check if the module uses the new translation system.
*
* @return bool
*/
public function isUsingNewTranslationSystem()
{
return true;
}
public function log($data)
{
if (!is_string($data)) {
$data = json_encode($data, JSON_UNESCAPED_UNICODE);
}
$logdirectory = _PS_ROOT_DIR_ . '/var/modules/' . $this->name . '/logs/' . date("Y") . '/' . date("m") . '/' . date("d") . '/';
if (!is_dir($logdirectory)) {
mkdir($logdirectory, 0750, true);
}
$logger = new \FileLogger(0); //0 == debug level, logDebug() wont work without this.
$logger->setFilename($logdirectory . 'dayly.log');
$logger->logInfo($data);
}
public function displayLastDayLog()
{
$baseLogDir = _PS_ROOT_DIR_ . '/var/modules/' . $this->name . '/logs/';
$daysToCheck = 300; // How many recent days to check for logs
$latestLogFile = false;
$logDate = null;
// Find the latest log file by iterating backward from today
for ($i = 0; $i < $daysToCheck; $i++) {
// Use \DateTime for clarity as we might be in a different namespace
$date = new \DateTime("-$i days");
$year = $date->format("Y");
$month = $date->format("m");
$day = $date->format("d");
$potentialDir = $baseLogDir . $year . '/' . $month . '/' . $day . '/';
$potentialFile = $potentialDir . 'dayly.log';
if (is_dir($potentialDir) && file_exists($potentialFile)) {
$latestLogFile = $potentialFile;
$logDate = $date; // Store the date of the found log
break; // Found the latest one, stop searching
}
}
// Prepare HTML output
$html = '<div class="panel">';
// Use translation function for the heading
$html .= '<div class="panel-heading"><i class="icon-align-left"></i> ' . $this->trans('Hutko Module Logs', [], 'Modules.Hutko.Admin') . '</div>';
$html .= '<div class="panel-body">';
if ($latestLogFile) {
// Add a heading indicating the date of the log file
$html .= '<h4>' . sprintf($this->trans('Log file for %s', [], 'Modules.Hutko.Admin'), $logDate->format("Y-m-d")) . '</h4>';
// Read the file line by line into an array
// FILE_IGNORE_NEW_LINES: removes the trailing newline character from each element
// FILE_SKIP_EMPTY_LINES: skips empty lines
$lines = file($latestLogFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines !== false) { // Check if file() succeeded
if (empty($lines)) {
$html .= '<p>' . $this->trans('The log file is empty.', [], 'Modules.Hutko.Admin') . '</p>';
} else {
// Output each line wrapped in a <p> tag
foreach ($lines as $line) {
// Use htmlspecialchars to prevent HTML injection and display characters correctly
// Add word-wrap style for long lines
$html .= '<p style="word-wrap: break-word;">' . htmlspecialchars($line, ENT_QUOTES, 'UTF-8') . '</p>';
}
}
} else {
// Error reading the file
$html .= '<div class="alert alert-warning">' . $this->trans('Could not read the log file:', [], 'Modules.Hutko.Admin') . ' ' . $latestLogFile . '</div>';
}
} else {
// No log file found in the specified range
$html .= '<div class="alert alert-info">' . sprintf($this->trans('No log file found in the last %d days.', [], 'Modules.Hutko.Admin'), $daysToCheck) . '</div>';
}
$html .= '</div>'; // End panel-body
$html .= '</div>'; // End panel
return $html;
}
/**
* URL-safe encodes a string using a Base64-like approach.
* Replaces '+' with '-', '/' with '_', and removes trailing '=' padding.
* Useful for encoding arbitrary binary data into a URL-friendly format
* that can be safely passed in URLs (e.g., within query parameters
* or path segments without needing standard percent encoding for + and /).
*
* @param string $string The string or binary data to encode.
* @return string The URL-safe encoded string.
*/
public function urlSafeEncode($string)
{
// Standard Base64 encode
$encoded = base64_encode($string);
// Replace '+' with '-' and '/' with '_'
// This avoids characters that have special meaning in URLs
$encoded = str_replace(['+', '/'], ['-', '_'], $encoded);
// Remove trailing '=' padding
// Padding is not necessary for decoding if the length is known or can be inferred.
// Removing it makes the string shorter and avoids another problematic character (=).
$encoded = rtrim($encoded, '=');
return $encoded;
}
/**
* Decodes a URL-safe encoded string back to its original form.
* Replaces '-' with '+', '_' with '/', and adds back trailing '=' padding.
*
* @param string $string The URL-safe encoded string.
* @return string|false The decoded string, or false on failure (if the input is not valid base64 after adjustments).
*/
public function urlSafeDecode($string)
{
// Replace '-' with '+' and '_' with '/'
$decoded = str_replace(['-', '_'], ['+', '/'], $string);
// Add back trailing '=' padding.
// Base64 strings (before decoding) must have a length that is a multiple of 4.
// We calculate how many characters short we are from a multiple of 4
// and add the corresponding number of '=' characters back.
$padding = strlen($decoded) % 4;
if ($padding > 0) {
$decoded .= str_repeat('=', 4 - $padding);
}
// Standard Base64 decode
// base64_decode returns the original data or FALSE on failure.
return base64_decode($decoded);
}
}