diff --git a/hutko.php b/hutko.php index ec6d66f..ba8776b 100644 --- a/hutko.php +++ b/hutko.php @@ -10,6 +10,7 @@ * @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_')) { @@ -22,6 +23,9 @@ 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', @@ -39,19 +43,19 @@ class Hutko extends PaymentModule $this->version = '1.1.0'; $this->author = 'Hutko'; $this->bootstrap = true; - $this->ps_versions_compliancy = array('min' => '1.7', 'max' => _PS_VERSION_); - $this->is_eu_compatible = 1; - parent::__construct(); - $this->displayName = $this->trans('Hutko Payments', array(), 'Modules.Hutko.Admin'); - $this->description = $this->trans('Hutko is a payment platform whose main function is to provide internet acquiring. - Payment gateway supports EUR, USD, PLN, GBP, UAH, RUB and +100 other currencies.', array(), 'Modules.Hutko.Admin'); + $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('paymentOptions') + && $this->registerHook('displayAdminOrderContentOrder') + && $this->registerHook('actionAdminControllerSetMedia'); } public function uninstall() @@ -213,6 +217,7 @@ class Hutko extends PaymentModule */ protected function postProcess() { + $form_values = $this->getConfigFormValues(); foreach (array_keys($form_values) as $key) { Configuration::updateValue($key, Tools::getValue($key)); @@ -329,7 +334,7 @@ class Hutko extends PaymentModule // 3. Create a description for the order. $orderDescription = $this->trans('Cart pay №', [], 'Modules.Hutko.Admin') . $this->context->cart->id; // 4. Calculate the order amount in the smallest currency unit. - $amount = round($this->context->cart->getOrderTotal() * 100); + $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; @@ -360,7 +365,7 @@ class Hutko extends PaymentModule ]; // 11. Generate the signature for the data array using the merchant's secret key. - $data['signature'] = $this->getSignature($data, Configuration::get('HUTKO_SECRET_KEY')); + $data['signature'] = $this->getSignature($data); // 12. Return the complete data array including the signature. return $data; @@ -397,7 +402,7 @@ class Hutko extends PaymentModule "cms_version" => _PS_VERSION_, "shop_domain" => Tools::getShopDomainSsl(), "path" => 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'], - "phonemobile" => $address->phone_mobile ?? $address->phone, + "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), @@ -561,8 +566,12 @@ class Hutko extends PaymentModule * or the raw string before encoding (false). * @return string The generated signature (SHA1 hash by default) or the raw string. */ - public function getSignature(array $data, string $password, bool $encoded = true): string + 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; @@ -617,7 +626,7 @@ class Hutko extends PaymentModule unset($response['response_signature_string'], $response['signature']); // 3. Calculate and Compare Signatures - $calculatedSignature = $this->getSignature($response, Configuration::get('HUTKO_SECRET_KEY')); + $calculatedSignature = $this->getSignature($response); return hash_equals($calculatedSignature, $responseSignature); } @@ -670,7 +679,7 @@ class Hutko extends PaymentModule * @param string $message A message to log with the status change. * @return void */ - public function updateOrderStatus(int $orderId, int $newStateId, string $message = ''): 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. @@ -678,11 +687,328 @@ class Hutko extends PaymentModule $history = new OrderHistory(); $history->id_order = $orderId; $history->changeIdOrderState($newStateId, $orderId); - $history->addWithemail(true, ['order_name' => $orderId]); - // PrestaShopLogger::addLog('Hutko Callback: Order ' . $orderId . ' status changed to ' . $newStateId . '. Message: ' . $message, 1, null, 'Order', $orderId, true); - } else { - // Log if the order was not loaded or already in the target state. - // PrestaShopLogger::addLog('Hutko Callback: Attempted to update order ' . $orderId . ' to state ' . $newStateId . ' but order not loaded or already in target state. Message: ' . $message, 2, null, 'Order', $orderId, true); + $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 = '
| {l s='Transaction ID' mod='hutko'} | +{l s='Amount' mod='hutko'} | +{l s='Payment Date' mod='hutko'} | +{l s='Actions' mod='hutko'} | +
|---|---|---|---|
| {$payment->transaction_id|escape:'htmlall':'UTF-8'} | +{displayPrice price=Tools::ps_round($payment->amount, 2) currency=$currency->id|floatval} + | +{$payment->date_add|date_format:'%Y-%m-%d %H:%M:%S'} | ++ {if $payment->amount > 0} + + {/if} + + + + | +