Files
hutko/hutko.php
2025-05-31 15:53:21 +03:00

1018 lines
41 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 $checkout_url = 'https://pay.hutko.org/api/checkout/redirect/';
public $refund_url = 'https://pay.hutko.org/api/reverse/order_id';
public $status_url = 'https://pay.hutko.org/api/status/order_id';
private $settingsList = [
'HUTKO_MERCHANT',
'HUTKO_SECRET_KEY',
'HUTKO_BACK_REF',
'HUTKO_SUCCESS_STATUS_ID',
'HUTKO_SHOW_CARDS_LOGO'
];
private $postErrors = [];
public function __construct()
{
$this->name = 'hutko';
$this->tab = 'payments_gateways';
$this->version = '1.1.0';
$this->author = 'Hutko';
$this->bootstrap = true;
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()
{
return parent::install()
&& $this->registerHook('paymentOptions')
&& $this->registerHook('displayAdminOrderContentOrder')
&& $this->registerHook('actionAdminControllerSetMedia');
}
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' => '<i class="icon icon-user"></i>',
'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' => '<i class="icon icon-key"></i>',
'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' => '<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' => '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(true, CART::ONLY_PRODUCTS) * 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);
// 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" => 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" => $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 $fromCallBack = false): bool
{
if (!$idState) {
$idState = (int) Configuration::get('PS_OS_PREPARATION');
}
if (!$fromCallBack) {
$cart = new Cart($id_cart);
$this->context->customer = new Customer($cart->id_customer);
}
// 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, 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 (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);
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): 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 to display content in the admin order page tabs.
* This will add a new tab for "Hutko Payments" or similar.
*
* @param array $params Contains Order 'order'
* @return string
*/
public function hookdisplayAdminOrderContentOrder(array $params): string
{
if (Tools::isSubmit('hutkoRefundsubmit')) {
$this->processRefundForm();
}
if (Tools::getValue('hutkoOrderStatus')) {
$this->processOrderStatus(Tools::getValue('hutkoOrderStatus'));
}
// This hook is used to render the content of the new tab on the order page.
// We will fetch the payments for this order and pass them to the template.
$order = $params['order'];
// Fetch payments made via Hutko for this order
$hutkoPayments = new PrestaShopCollection('OrderPayment');
$hutkoPayments->where('order_reference', '=', $order->reference);
$hutkoPayments->where('payment_method', '=', $this->displayName);
$this->context->smarty->assign([
'hutkoPayments' => $hutkoPayments->getAll(),
'id_order' => $order->id,
]);
return $this->display(__FILE__, 'views/templates/admin/order_payment_refund.tpl');
}
public function processOrderStatus(string $order_id): void
{
$data = [
'order_id' => $order_id,
'merchant_id' => Configuration::get('HUTKO_MERCHANT', null),
'version' => '1.0',
];
$data['signature'] = $this->getSignature($data);
$response = $this->sendAPICall($this->status_url, $data);
$this->context->controller->informations[] = $this->displayArrayInNotification($response['response']);
}
/**
* Hook to set media (JS/CSS) for admin controllers.
* Used to load our custom JavaScript for the refund modal.
*
* @param array $params
* @return void
*/
public function hookActionAdminControllerSetMedia(array $params): void
{
// Only load our JS on the AdminOrders controller page
if ($this->context->controller->controller_name === 'AdminOrders') {
}
}
public function processRefundForm()
{
$orderPaymentId = (int) Tools::getValue('orderPaymentId');
$amount = (float) Tools::getValue('refund_amount');
$comment = mb_substr(Tools::getValue('orderPaymentId', ''), 0, 1024);
$orderId = (int) Tools::getValue('id_order');
$result = $this->processRefund($orderPaymentId, $orderId, $amount, $comment);
if ($result->error) {
$this->context->controller->errors[] = $result->description;
}
if ($result->success) {
$this->context->controller->informations[] = $result->description;
}
}
/**
* 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 $orderId 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 $orderId, float $amount, string $comment = ''): stdClass
{
$result = new stdClass();
$result->error = false;
// 1. Load the OrderPayment object.
$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'));
}
// 2. Validate the transaction_id format and extract cart ID.
// Assuming transaction_id is in the format "cartID|timestamp" or "cartID-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']['response_status'] === 'failure') {
$result->error = true;
$result->description = $response['response']['error_message'];
return $result;
}
if ($response['response']['response_status'] === 'success') {
$result->success = true;
$result->description = $this->trans('Refund success.', [], 'Modules.Hutko.Admin');
}
$order = new Order($orderId);
// Add a note to the order history.
$this->updateOrderStatus($order->id, (int)Configuration::get('PS_OS_REFUND'));
// Add a private message to the order for tracking.
$order->addOrderPayment(
-$amount, // Negative amount for refund
$this->displayName,
$orderPayment->transaction_id
);
PrestaShopLogger::addLog(
'Hutko Refund: Successfully processed refund for Order: ' . $orderId . ', Amount: ' . $amount . ', Comment: ' . $comment,
1, // Info
null,
'OrderPayment',
$orderPaymentId,
true
);
return $result;
}
/**
* 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 $currency, 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', null),
'version' => '1.0',
// Amount should be in minor units (cents, kopecks) and converted to string as per API example
'amount' => (string)round($amount * 100),
'currency' => $currency,
];
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
{
// 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);
// 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;
}
}