Files
hutko/hutko.php
T
2025-05-31 16:31:04 +03:00

1018 lines
41 KiB
PHP

<?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.1';
$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" => round((float)$cartProduct['price_with_reduction'], 2),
"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) {
$this->context->cart = new Cart($id_cart);
$this->context->customer = new Customer($this->context->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;
}
}