commit 7f951e6c23b6def633ee5dc8825d8b730eea9f0a Author: O K Date: Tue Jun 17 14:01:03 2025 +0300 first commit diff --git a/upload/admin/controller/extension/payment/hutko.php b/upload/admin/controller/extension/payment/hutko.php new file mode 100644 index 0000000..1fcd4d4 --- /dev/null +++ b/upload/admin/controller/extension/payment/hutko.php @@ -0,0 +1,575 @@ +load->language('extension/payment/hutko'); + $this->document->setTitle($this->language->get('heading_title')); + $this->load->model('setting/setting'); + + if (($this->request->server['REQUEST_METHOD'] == 'POST') && $this->validate()) { + $this->model_setting_setting->editSetting('payment_hutko', $this->request->post); + $this->session->data['success'] = $this->language->get('text_success'); + $this->response->redirect($this->url->link('extension/payment/hutko', 'user_token=' . $this->session->data['user_token'], true)); + } + + $data['heading_title'] = $this->language->get('heading_title'); + // Populate $data with language strings and current settings + $fields = [ + 'payment_hutko_merchant_id', + 'payment_hutko_secret_key', + 'payment_hutko_shipping_include', + 'payment_hutko_shipping_product_name', + 'payment_hutko_shipping_product_code', + 'payment_hutko_new_order_status_id', + 'payment_hutko_success_status_id', + 'payment_hutko_declined_status_id', + 'payment_hutko_expired_status_id', + 'payment_hutko_refunded_status_id', + 'payment_hutko_include_discount_to_total', + 'payment_hutko_status', + 'payment_hutko_sort_order', + 'payment_hutko_geo_zone_id', + 'payment_hutko_total', + 'payment_hutko_save_logs' + ]; + + foreach ($fields as $field) { + if (isset($this->request->post[$field])) { + $data[$field] = $this->request->post[$field]; + } else { + $data[$field] = $this->config->get($field); + } + } + + // Default values for new installs + if (is_null($data['payment_hutko_shipping_product_name'])) { + $data['payment_hutko_shipping_product_name'] = 'Package material'; + } + if (is_null($data['payment_hutko_shipping_product_code'])) { + $data['payment_hutko_shipping_product_code'] = '0_0_1'; + } + if (is_null($data['payment_hutko_total'])) { + $data['payment_hutko_total'] = '0.01'; + } + + + // Error messages + $errors = ['warning', 'merchant_id', 'secret_key']; + foreach ($errors as $err_key) { + if (isset($this->error[$err_key])) { + $data['error_' . $err_key] = $this->error[$err_key]; + } else { + $data['error_' . $err_key] = ''; + } + } + + $data['breadcrumbs'] = array(); + $data['breadcrumbs'][] = array('text' => $this->language->get('text_home'), 'href' => $this->url->link('common/dashboard', 'user_token=' . $this->session->data['user_token'], true)); + $data['breadcrumbs'][] = array('text' => $this->language->get('text_extension'), 'href' => $this->url->link('marketplace/extension', 'user_token=' . $this->session->data['user_token'] . '&type=payment', true)); + $data['breadcrumbs'][] = array('text' => $this->language->get('heading_title'), 'href' => $this->url->link('extension/payment/hutko', 'user_token=' . $this->session->data['user_token'], true)); + + $data['action'] = $this->url->link('extension/payment/hutko', 'user_token=' . $this->session->data['user_token'], true); + $data['cancel'] = $this->url->link('marketplace/extension', 'user_token=' . $this->session->data['user_token'] . '&type=payment', true); + + $this->load->model('localisation/order_status'); + $data['order_statuses'] = $this->model_localisation_order_status->getOrderStatuses(); + + $this->load->model('localisation/geo_zone'); + $data['geo_zones'] = $this->model_localisation_geo_zone->getGeoZones(); + + // Logs (simplified) + $data['log_content'] = $this->displayLastDayLog(); + $data['user_token'] = $this->session->data['user_token']; // Ensure it's passed to the view + + + + $data['header'] = $this->load->controller('common/header'); + $data['column_left'] = $this->load->controller('common/column_left'); + $data['footer'] = $this->load->controller('common/footer'); + + $this->response->setOutput($this->load->view('extension/payment/hutko', $data)); + } + + protected function validate() + { + if (!$this->user->hasPermission('modify', 'extension/payment/hutko')) { + $this->error['warning'] = $this->language->get('error_permission'); + } + + $merchantId = $this->request->post['payment_hutko_merchant_id']; + $secretKey = $this->request->post['payment_hutko_secret_key']; + + if (empty($merchantId)) { + $this->error['merchant_id'] = $this->language->get('error_merchant_id_required'); + } elseif (!is_numeric($merchantId)) { + $this->error['merchant_id'] = $this->language->get('error_merchant_id_numeric'); + } + + if (empty($secretKey)) { + $this->error['secret_key'] = $this->language->get('error_secret_key_required'); + } elseif ($secretKey != 'test' && (strlen($secretKey) < 10 || is_numeric($secretKey))) { + $this->error['secret_key'] = $this->language->get('error_secret_key_invalid'); + } + + return !$this->error; + } + + public function install() + { + $this->load->model('extension/payment/hutko'); // Load our custom model + $this->model_extension_payment_hutko->install(); // Call install method from our model + $this->load->model('setting/setting'); + $defaults = array( + 'payment_hutko_status' => 0, + 'payment_hutko_sort_order' => 1, + 'payment_hutko_total' => '0.01', + 'payment_hutko_new_order_status_id' => $this->config->get('config_order_status_id'), // Default pending + 'payment_hutko_success_status_id' => 2, // Processing + 'payment_hutko_declined_status_id' => 10, // Failed + 'payment_hutko_expired_status_id' => 14, // Expired + 'payment_hutko_refunded_status_id' => 11, // Refunded + 'payment_hutko_shipping_include' => 1, + 'payment_hutko_shipping_product_name' => 'Shipping', + 'payment_hutko_shipping_product_code' => 'SHIPPING_001', + 'payment_hutko_save_logs' => 1, + 'payment_hutko_include_discount_to_total' => 1, + ); + $this->model_setting_setting->editSetting('payment_hutko', $defaults); + + + // Register event for displaying info on admin order page (OC 3.x+) + if (defined('VERSION') && version_compare(VERSION, '3.0.0.0', '>=')) { + $this->load->model('setting/event'); + $this->model_setting_event->addEvent( + 'hutko_admin_order_info_panel', // event_code (unique) + 'admin/view/sale/order_info/after', // trigger (after main view is rendered) + 'extension/payment/hutko/inject_admin_order_panel', // action (controller route) + 1, // status (1 = enabled) + 0 // sort_order + ); + } + } + + public function uninstall() + { + $this->load->model('extension/payment/hutko'); // Load our custom model + $this->model_extension_payment_hutko->uninstall(); // Call uninstall method from our model + + $this->load->model('setting/setting'); + $this->model_setting_setting->deleteSetting('payment_hutko'); + + // Unregister event (OC 3.x+) + if (defined('VERSION') && version_compare(VERSION, '3.0.0.0', '>=')) { + $this->load->model('setting/event'); + $this->model_setting_event->deleteEventByCode('hutko_admin_order_info_panel'); + } + } + /** + * Event handler to inject Hutko panel into the admin order view output. + * Triggered by: admin/view/sale/order_info/after + */ + public function inject_admin_order_panel(&$route, &$data, &$output) + { + // Ensure order_id is available + if (!isset($data['order_id'])) { + // If order_id is not in $data, we cannot proceed. + // This would be unusual for the sale/order/info route. + $this->logOC("Hutko inject_admin_order_panel: order_id not found in \$data array."); + return; + } + + $order_id = (int)$data['order_id']; + $current_payment_code = ''; + + // Check if payment_code is already in $data + if (isset($data['payment_code'])) { + $current_payment_code = $data['payment_code']; + } else { + // If not in $data, load the order info to get the payment_code + $this->load->model('sale/order'); // Standard OpenCart order model + $order_info = $this->model_sale_order->getOrder($order_id); + if ($order_info && isset($order_info['payment_code'])) { + $current_payment_code = $order_info['payment_code']; + // Optionally, add it back to $data if other parts of your logic expect it, + // though for this specific function, having $current_payment_code is enough. + // $data['payment_code'] = $order_info['payment_code']; + } else { + $this->logOC("Hutko inject_admin_order_panel: Could not retrieve payment_code for order_id: " . $order_id); + return; // Can't determine payment method + } + } + + // Now, check if this is a Hutko payment order + if ($current_payment_code == 'hutko') { + $this->load->language('extension/payment/hutko'); + $this->load->model('extension/payment/hutko'); + + $hutko_order_data = $this->model_extension_payment_hutko->getHutkoOrder($order_id); + + $panel_data = []; + + if ($hutko_order_data && !empty($hutko_order_data['hutko_transaction_ref'])) { + $panel_data['hutko_transaction_ref_display'] = $hutko_order_data['hutko_transaction_ref']; + } else { + $panel_data['hutko_transaction_ref_display'] = $this->language->get('text_not_available'); + } + + $panel_data['hutko_refund_action_url'] = $this->url->link('extension/payment/hutko/refund', '', true); + $panel_data['hutko_status_action_url'] = $this->url->link('extension/payment/hutko/status', '', true); + $panel_data['order_id'] = $order_id; + $panel_data['user_token_value'] = $this->session->data['user_token']; + + // Language strings for the panel template + $panel_data['text_payment_information'] = $this->language->get('text_payment_information'); + $panel_data['text_hutko_refund_title'] = $this->language->get('text_hutko_refund_title'); + $panel_data['text_hutko_status_title'] = $this->language->get('text_hutko_status_title'); + $panel_data['button_hutko_refund'] = $this->language->get('button_hutko_refund'); + $panel_data['button_hutko_status_check'] = $this->language->get('button_hutko_status_check'); + $panel_data['text_hutko_transaction_ref_label'] = $this->language->get('text_hutko_transaction_ref_label'); + $panel_data['entry_refund_amount'] = $this->language->get('entry_refund_amount'); + $panel_data['entry_refund_comment'] = $this->language->get('entry_refund_comment'); + $panel_data['text_not_available'] = $this->language->get('text_not_available'); + $panel_data['text_loading'] = $this->language->get('text_loading'); + $panel_data['text_confirm_refund'] = $this->language->get('text_confirm_refund'); + $panel_data['user_token'] = $this->session->data['user_token']; + $panel_data['order_id'] = $order_id; + + + // Render the Hutko panel HTML + $hutko_panel_html = $this->load->view('extension/payment/hutko_order_info_panel', $panel_data); + + + + // Try common injection points for better theme compatibility + $possible_markers = [ + '{{ history }}', // Default Twig variable for history + '
', // Common ID for history section + '
', // Another common structure + '\s*
' // Before the next fieldset after payment details + ]; + + $injected = false; + foreach ($possible_markers as $marker) { + if (strpos($output, $marker) !== false) { + $output = str_replace($marker, $hutko_panel_html . $marker, $output); + $injected = true; + break; + } else if (preg_match('/' . preg_quote($marker, '/') . '/i', $output)) { // Case-insensitive for HTML tags + $output = preg_replace('/(' . preg_quote($marker, '/') . ')/i', $hutko_panel_html . '$1', $output, 1); + $injected = true; + break; + } + } + + if (!$injected) { + // Fallback: if no specific marker found, try appending before the last major closing div of the form or content area. + // This is less precise and might need adjustment based on common admin theme structures. + $fallback_markers = [ + '', // Before closing form tag + '
]*>)(.*)(<\/div>)/is', '$1$2' . $hutko_panel_html . '$3', $output, 1); + } else { + $output = str_replace($marker, $hutko_panel_html . $marker, $output); + } + $injected = true; + $this->logOC("Hutko inject_admin_order_panel: Used fallback marker '$marker'."); + break; + } + } + } + + if (!$injected) { + $this->logOC("Hutko inject_admin_order_panel: Could not find any suitable injection marker in order_info output for order_id: " . $order_id); + // As a very last resort, you could append to the end of $output, but this is usually not desired. + // $output .= $hutko_panel_html; + } + } + } + + public function refund() + { + $this->load->language('extension/payment/hutko'); + $this->load->model('extension/payment/hutko'); // Your custom model for hutko_transaction_ref + $this->load->model('sale/order'); // Correct admin order model + + $json = array(); + + // Check if order_id is coming from post (from JS AJAX call definition) + if (!isset($this->request->post['order_id'])) { + $json['error'] = $this->language->get('error_missing_order_id'); + $this->response->addHeader('Content-Type: application/json'); + $this->response->setOutput(json_encode($json)); + return; + } + $order_id = (int)$this->request->post['order_id']; + + // Get Hutko transaction reference from custom table + $hutko_order_info = $this->model_extension_payment_hutko->getHutkoOrder($order_id); + if (!$hutko_order_info || empty($hutko_order_info['hutko_transaction_ref'])) { + $json['error'] = $this->language->get('error_hutko_transaction_ref_not_found_db'); + $this->response->addHeader('Content-Type: application/json'); + $this->response->setOutput(json_encode($json)); + return; + } + $hutko_transaction_ref = $hutko_order_info['hutko_transaction_ref']; + + // Check for refund amount and comment from POST data + if (!isset($this->request->post['refund_amount'])) { + $json['error'] = $this->language->get('error_missing_refund_amount'); + $this->response->addHeader('Content-Type: application/json'); + $this->response->setOutput(json_encode($json)); + return; + } + $amount = (float)$this->request->post['refund_amount']; + $comment = isset($this->request->post['refund_comment']) ? substr(trim($this->request->post['refund_comment']), 0, 1024) : ''; + + + $order_info = $this->model_sale_order->getOrder($order_id); + + if ($order_info && $hutko_transaction_ref && $amount > 0) { + $response = $this->refundAPICallOC($hutko_transaction_ref, $amount, $order_info['currency_code'], $comment); + + if (isset($response['response']['reverse_status']) && $response['response']['reverse_status'] === 'approved' && isset($response['response']['response_status']) && $response['response']['response_status'] === 'success') { + $refund_amount_returned = round((int)$response['response']['reversal_amount'] / 100, 2); + $history_comment_text = sprintf($this->language->get('text_refund_success_comment'), $hutko_transaction_ref, $this->currency->format($refund_amount_returned, $order_info['currency_code'], $order_info['currency_value'], true), $comment); + $this->addOrderHistory($order_id, $this->config->get('payment_hutko_refunded_status_id'), $history_comment_text, true); + $json['success'] = $this->language->get('text_refund_success'); + } else { + $error_message = isset($response['response']['error_message']) ? $response['response']['error_message'] : $this->language->get('text_unknown_error'); + $history_comment_text = sprintf($this->language->get('text_refund_failed_comment'), $hutko_transaction_ref, $error_message); + $this->addOrderHistory($order_id, $order_info['order_status_id'], $history_comment_text, false); // Keep current status on failure + $json['error'] = sprintf($this->language->get('text_refund_api_error'), $error_message); + $this->logOC("Hutko Refund API Error for OC Order ID $order_id / Hutko ID $hutko_transaction_ref: " . json_encode($response)); + } + } else { + if (!$order_info) { + $json['error'] = $this->language->get('error_order_not_found'); // Add this lang string + } elseif ($amount <= 0) { + $json['error'] = $this->language->get('error_invalid_refund_amount'); + } else { + $json['error'] = $this->language->get('error_invalid_request'); + } + } + + $this->response->addHeader('Content-Type: application/json'); + $this->response->setOutput(json_encode($json)); + } + + /** + * Helper function to add order history from admin. + * This replicates the core logic found in admin/controller/sale/order.php history() method. + */ + private function addOrderHistory($order_id, $order_status_id, $comment = '', $notify = false, $override = false) + { + $this->load->model('sale/order'); + // Get order info to prevent status update if not necessary or if order is complete/cancelled + $order_info = $this->model_sale_order->getOrder($order_id); + if (!$order_info) { + $this->logOC("addOrderHistory: Order ID {$order_id} not found."); + return; // Order not found + } + // Add history + $this->db->query("INSERT INTO " . DB_PREFIX . "order_history SET order_id = '" . (int)$order_id . "', order_status_id = '" . (int)$order_status_id . "', notify = '" . (int)$notify . "', comment = '" . $this->db->escape($comment) . "', date_added = NOW()"); + // Update the order status + $this->db->query("UPDATE `" . DB_PREFIX . "order` SET order_status_id = '" . (int)$order_status_id . "', date_modified = NOW() WHERE order_id = '" . (int)$order_id . "'"); + } + + public function status() + { + $this->load->language('extension/payment/hutko'); + $json = array(); + + if (isset($this->request->post['hutko_transaction_ref'])) { + $hutko_transaction_ref = $this->request->post['hutko_transaction_ref']; + $response = $this->getOrderPaymentStatusOC($hutko_transaction_ref); + + if (isset($response['response']['response_status']) && $response['response']['response_status'] === 'success') { + $json['success'] = $this->language->get('text_status_success'); + // Remove sensitive or overly verbose data before sending to frontend + unset($response['response']['response_signature_string'], $response['response']['signature']); + if (isset($response['response']['additional_info'])) { + $additional_info_decoded = json_decode($response['response']['additional_info'], true); + if (isset($additional_info_decoded['reservation_data'])) { + $additional_info_decoded['reservation_data_decoded'] = json_decode(base64_decode($additional_info_decoded['reservation_data']), true); + unset($additional_info_decoded['reservation_data']); + } + $response['response']['additional_info_decoded'] = $additional_info_decoded; + unset($response['response']['additional_info']); + } + $json['data'] = $response['response']; + } else { + $error_message = isset($response['response']['error_message']) ? $response['response']['error_message'] : $this->language->get('text_unknown_error'); + $json['error'] = sprintf($this->language->get('text_status_api_error'), $error_message); + $this->logOC("Hutko Status API Error for Hutko ID $hutko_transaction_ref: " . json_encode($response)); + } + } else { + $json['error'] = $this->language->get('error_missing_params'); + } + $this->response->addHeader('Content-Type: application/json'); + $this->response->setOutput(json_encode($json)); + } + + protected function getSignatureOC(array $data, bool $encoded = true): string + { + $password = $this->config->get('payment_hutko_secret_key'); + if (!$password || empty($password)) { + $this->logOC('Hutko Error: Merchant secret not set for signature generation.'); + return ''; + } + $filteredData = array_filter($data, function ($value) { + return $value !== '' && $value !== null; + }); + ksort($filteredData); + $stringToHash = $password; + foreach ($filteredData as $value) { + $stringToHash .= '|' . $value; + } + if ($encoded) { + return sha1($stringToHash); + } else { + return $stringToHash; + } + } + + protected function sendAPICallOC(string $url, array $data, int $timeout = 60): array + { + if ($this->config->get('payment_hutko_save_logs')) { + $this->logOC('Hutko API Request to ' . $url . ': ' . json_encode(['request' => $data])); + } + + $requestPayload = ['request' => $data]; + $jsonPayload = json_encode($requestPayload); + + if ($jsonPayload === false) { + $error_msg = 'Failed to encode request data to JSON: ' . json_last_error_msg(); + $this->logOC('Hutko API Error: ' . $error_msg); + return ['response' => ['response_status' => 'failure', 'error_message' => $error_msg]]; + } + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonPayload); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json', 'Content-Length: ' . strlen($jsonPayload)]); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); + curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); + $response_body = curl_exec($ch); + $curl_error = curl_error($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($curl_error) { + $error_msg = 'CURL Error: ' . $curl_error; + $this->logOC('Hutko API CURL Error: ' . $error_msg . ' (HTTP Code: ' . $http_code . ')'); + return ['response' => ['response_status' => 'failure', 'error_message' => $error_msg, 'http_code' => $http_code]]; + } + + if ($this->config->get('payment_hutko_save_logs')) { + $this->logOC('Hutko API Response from ' . $url . ': ' . $response_body); + } + + $responseData = json_decode($response_body, true); + if (json_last_error() !== JSON_ERROR_NONE) { + $error_msg = 'Invalid JSON response from API: ' . json_last_error_msg(); + $this->logOC('Hutko API JSON Decode Error: ' . $error_msg . ' (Raw: ' . $response_body . ')'); + return ['response' => ['response_status' => 'failure', 'error_message' => $error_msg, 'raw_response' => $response_body]]; + } + return $responseData; + } + + protected function refundAPICallOC(string $hutko_order_id, float $amount, string $currencyISO, string $comment = ''): array + { + $data = [ + 'order_id' => $hutko_order_id, + 'merchant_id' => $this->config->get('payment_hutko_merchant_id'), + 'version' => '1.0', + 'amount' => round($amount * 100), + 'currency' => $currencyISO, + ]; + if (!empty($comment)) { + $data['comment'] = $comment; + } + $data['signature'] = $this->getSignatureOC($data); + return $this->sendAPICallOC($this->refund_url, $data); + } + + protected function getOrderPaymentStatusOC(string $hutko_order_id): array + { + $data = [ + 'order_id' => $hutko_order_id, + 'merchant_id' => $this->config->get('payment_hutko_merchant_id'), + 'version' => '1.0', + ]; + $data['signature'] = $this->getSignatureOC($data); + return $this->sendAPICallOC($this->status_url, $data); + } + + protected function logOC(string $message): void + { + if ($this->config->get('payment_hutko_save_logs')) { + $this->log->write('Hutko Payment: ' . $message); + } + } + + protected function displayLastDayLog() + { + if (!$this->config->get('payment_hutko_save_logs')) { + return '

' . $this->language->get('text_logs_disabled') . '

'; + } + + $log_file = DIR_LOGS . 'error.log'; + // More sophisticated would be to filter for "Hutko Payment:" lines + // For simplicity, just show tail of general log file + $content = ''; + if (file_exists($log_file)) { + $size = filesize($log_file); + // Read last N KB or N lines + $lines_to_show = 100; // Show last 100 lines containing "Hutko Payment" + $buffer_size = 4096; + $hutko_lines = []; + + if ($size > 0) { + $fp = fopen($log_file, 'r'); + if ($size > $buffer_size * 5) { // If file is large, seek towards the end + fseek($fp, $size - ($buffer_size * 5)); + } + + while (!feof($fp) && count($hutko_lines) < $lines_to_show * 2) { // Read a bit more to filter + $line = fgets($fp); + if ($line && strpos($line, 'Hutko Payment:') !== false) { + $hutko_lines[] = htmlspecialchars($line, ENT_QUOTES, 'UTF-8'); + } + } + fclose($fp); + $hutko_lines = array_slice($hutko_lines, -$lines_to_show); // Get the actual last N lines + } + + + if (!empty($hutko_lines)) { + $content .= '
'; + $content .= implode("
", array_reverse($hutko_lines)); // Show newest first + $content .= '
'; + } else { + $content = '

' . $this->language->get('text_no_logs_found') . '

'; + } + } else { + $content = '

' . sprintf($this->language->get('text_log_file_not_found'), $log_file) . '

'; + } + return $content; + } +} diff --git a/upload/admin/language/en-gb/extension/payment/hutko.php b/upload/admin/language/en-gb/extension/payment/hutko.php new file mode 100644 index 0000000..9a6aae1 --- /dev/null +++ b/upload/admin/language/en-gb/extension/payment/hutko.php @@ -0,0 +1,114 @@ +Hutko'; // You'll need a hutko.png in admin/view/image/payment/ +$_['text_enabled'] = 'Enabled'; +$_['text_disabled'] = 'Disabled'; +$_['text_yes'] = 'Yes'; +$_['text_no'] = 'No'; +$_['text_info_merchant'] = 'Use 1700002 for test setup.'; +$_['text_info_secret'] = 'Use "test" for test setup.'; +$_['text_logs_disabled'] = 'Logging is currently disabled. Enable "Save Logs" to see logs.'; +$_['text_no_logs_found'] = 'No Hutko specific log entries found in the main log file for today, or logging is disabled.'; +$_['text_log_file_not_found'] = 'Log file (%s) not found.'; +$_['text_refund_success_comment'] = 'Refund Successful for ID: %s. Amount: %s. Comment: %s'; +$_['text_refund_failed_comment'] = 'Refund Attempt Failed for ID: %s. Reason: %s'; +$_['text_refund_success'] = 'Refund processed successfully via Hutko.'; +$_['text_refund_api_error'] = 'Hutko API Error: %s'; +$_['text_status_success'] = 'Status retrieved successfully from Hutko.'; +$_['text_status_api_error'] = 'Hutko API Error fetching status: %s'; +$_['text_unknown_error'] = 'An unknown error occurred.'; + + +// Entry +$_['entry_merchant_id'] = 'Merchant ID'; +$_['entry_secret_key'] = 'Secret Key'; +$_['entry_new_order_status'] = 'New Order Status'; +$_['entry_success_status'] = 'Successful Payment Status'; +$_['entry_declined_status'] = 'Declined Payment Status'; +$_['entry_expired_status'] = 'Expired Payment Status'; +$_['entry_refunded_status'] = 'Refunded Payment Status'; +$_['entry_shipping_include'] = 'Include Shipping Cost'; +$_['entry_shipping_product_name'] = 'Shipping Fiscal Name'; +$_['entry_shipping_product_code'] = 'Shipping Fiscal Code'; +$_['entry_show_cards_logo'] = 'Show Visa/MasterCard Logo'; +$_['entry_save_logs'] = 'Save Logs'; +$_['entry_include_discount_to_total'] = 'Include Discounts in Total (for API)'; +$_['entry_total'] = 'Minimum Order Total'; +$_['entry_geo_zone'] = 'Geo Zone'; +$_['entry_status'] = 'Status'; +$_['entry_sort_order'] = 'Sort Order'; + +// Help +$_['help_total'] = 'The checkout total the order must reach before this payment method becomes active.'; +$_['help_new_order_status'] = 'Status for new orders before payment redirection.'; +$_['help_success_status'] = 'Status for successfully paid orders.'; +$_['help_shipping_include'] = 'Include shipping cost as a separate item in the payment request details.'; +$_['help_shipping_product_name'] = 'Name of product/service to use in fiscalization for shipping amount.'; +$_['help_shipping_product_code'] = 'Code of product/service to use in fiscalization for shipping amount.'; +$_['help_show_cards_logo'] = 'Display Visa/MasterCard logos next to the payment method name on checkout.'; +$_['help_save_logs'] = 'Log API communication and callbacks to the system log file.'; +$_['help_include_discount_to_total'] = 'If Yes, order discounts will be subtracted from the payment total, this may prevent fiscalization.'; + + +// Error +$_['error_permission'] = 'Warning: You do not have permission to modify Hutko payment module!'; +$_['error_merchant_id_required'] = 'Merchant ID is required!'; +$_['error_merchant_id_numeric'] = 'Merchant ID must be numeric!'; +$_['error_secret_key_required'] = 'Secret Key is required!'; +$_['error_secret_key_invalid'] = 'Secret key must be "test" or at least 10 characters long and not entirely numeric.'; +$_['error_invalid_request'] = 'Invalid request data for refund/status.'; +$_['error_missing_params'] = 'Missing required parameters for refund/status.'; + + +// Tab +$_['tab_general'] = 'General'; +$_['tab_order_statuses'] = 'Order Statuses'; +$_['tab_fiscalization'] = 'Fiscalization'; +$_['tab_advanced'] = 'Advanced'; +$_['tab_logs'] = 'Logs'; +$_['text_payment_information'] = 'Payments History'; + + +$_['text_not_available'] = 'N/A'; +$_['text_hutko_transaction_ref_label'] = 'Hutko Transaction ID'; +$_['text_hutko_refund_title'] = 'Hutko Refund'; +$_['text_hutko_status_title'] = 'Hutko Status Check'; +$_['button_hutko_refund'] = 'Process Hutko Refund'; +$_['button_hutko_status_check'] = 'Check Hutko Payment Status'; +$_['entry_refund_amount'] = 'Refund Amount'; +$_['entry_refund_comment'] = 'Refund Comment (optional)'; + +$_['text_refund_success_comment'] = 'Refund for Hutko ID %s successful. Amount: %s. Comment: %s'; +$_['text_refund_failed_comment'] = 'Refund attempt for Hutko ID %s failed. Gateway error: %s'; +$_['text_refund_api_error'] = 'Hutko Refund API Error: %s'; +$_['text_status_api_error'] = 'Hutko Status API Error: %s'; +$_['text_unknown_error'] = 'An unknown error occurred with the API.'; + + +$_['error_missing_order_id'] = 'Error: Order ID is missing from the request.'; +$_['error_hutko_transaction_ref_not_found_db'] = 'Error: Hutko Transaction ID not found in database for this order.'; +$_['error_hutko_transaction_ref_missing'] = 'Error: Hutko Transaction ID is required for this operation.'; +$_['error_invalid_refund_amount'] = 'Error: Invalid refund amount. Must be greater than 0.'; +$_['error_missing_refund_amount'] = 'Error: Refund amount is required.'; + +// For catalog side (checkout process) +$_['error_payment_data_build'] = 'Error: Could not prepare payment data. Please try again or contact support.'; +$_['error_api_communication'] = 'Error: Could not communicate with the payment gateway. Please try again.'; +$_['text_redirecting_comment'] = 'Redirecting to Hutko. Hutko Order ID: %s. URL: %s'; + +// For callback +$_['text_payment_approved'] = 'Payment Approved by Hutko.'; +$_['text_payment_declined'] = 'Payment Declined by Hutko.'; +$_['text_payment_expired'] = 'Payment Expired at Hutko.'; +$_['text_payment_processing'] = 'Payment is Processing at Hutko.'; +$_['text_confirm_refund'] = 'Are you sure you want to refund this transaction via Hutko? This action cannot be undone.'; + +$_['text_loading'] = 'Loading...'; +$_['error_order_not_found'] = 'Error: Order not found.'; diff --git a/upload/admin/language/ru-ru/extension/payment/hutko.php b/upload/admin/language/ru-ru/extension/payment/hutko.php new file mode 100644 index 0000000..3cf9e6d --- /dev/null +++ b/upload/admin/language/ru-ru/extension/payment/hutko.php @@ -0,0 +1,104 @@ +Hutko'; // Вам понадобится hutko.png в admin/view/image/payment/ +$_['text_enabled'] = 'Включено'; +$_['text_disabled'] = 'Отключено'; +$_['text_yes'] = 'Да'; +$_['text_no'] = 'Нет'; +$_['text_info_merchant'] = 'Используйте 1700002 для теста.'; +$_['text_info_secret'] = 'Используйте "test" для теста.'; +$_['text_logs_disabled'] = 'Ведение журнала в настоящее время отключено. Включите "Сохранить журналы", чтобы увидеть журналы.'; +$_['text_no_logs_found'] = 'В главном файле журнала на сегодня не найдено никаких записей журнала Hutko, или ведение журнала отключено.'; +$_['text_log_file_not_found'] = 'Файл журнала (%s) не найден.'; +$_['text_refund_success_comment'] = 'Возмещение выполнен успешно для ID: %s. Сумма: %s. Комментарий: %s'; +$_['text_refund_failed_comment'] = 'Попытка возмещения не удалась для ID: %s. Причина: %s'; +$_['text_refund_success'] = 'Возмещение успешно обработано через Hutko.'; +$_['text_refund_api_error'] = 'Ошибка API Hutko: %s'; +$_['text_status_success'] = 'Статус успешно получен от Hutko.'; +$_['text_status_api_error'] = 'Ошибка API Hutko при получении статуса: %s'; +$_['text_unknown_error'] = 'Произошла неизвестная ошибка.'; + +$_['entry_merchant_id'] = 'ID продавца'; +$_['entry_secret_key'] = 'Секретный ключ'; +$_['entry_new_order_status'] = 'Статус нового заказа'; +$_['entry_success_status'] = 'Статус при успешном платеже'; +$_['entry_declined_status'] = 'Статус при отклоненном платеже'; +$_['entry_expired_status'] = 'Статус при просроченном платеже'; +$_['entry_refunded_status'] = 'Статус при возмещении платеже'; +$_['entry_shipping_include'] = 'Включить стоимость доставки'; +$_['entry_shipping_product_name'] = 'Наименование доставки в фискальном чеке'; +$_['entry_shipping_product_code'] = 'Код доставки в фискальном чеке'; +$_['entry_show_cards_logo'] = 'Показать логотип Visa/MasterCard'; +$_['entry_save_logs'] = 'Сохранять журналы'; +$_['entry_include_discount_to_total'] = 'Включить скидки в общую сумму (для API)'; +$_['entry_total'] = 'Минимальная сумма заказа'; +$_['entry_geo_zone'] = 'Геозона'; +$_['entry_status'] = 'Статус'; +$_['entry_sort_order'] = 'Порядок сортировки'; + + +$_['help_total'] = 'Сумма, которую должен достичь заказ, прежде чем этот способ оплаты станет активным.'; +$_['help_new_order_status'] = 'Статус для новых заказов до получения платежа.'; +$_['help_success_status'] = 'Статус для успешно оплаченных заказов.'; +$_['help_shipping_include'] = 'Включить стоимость доставки в суму платежа.'; +$_['help_shipping_product_name'] = 'Название продукта/услуги для использования при фискализации для суммы доставки.'; +$_['help_shipping_product_code'] = 'Код продукта/услуги для использования при фискализации для суммы доставки.'; +$_['help_show_cards_logo'] = 'Отображать логотипы Visa/MasterCard рядом с названием способа оплаты при оформлении заказа.'; +$_['help_save_logs'] = 'Записывать коммуникацию API и обратные вызовы в системный файл журнала.'; +$_['help_include_discount_to_total'] = 'Если да, скидки по заказу будут вычтены из общей суммы платежа, это может помешать фискализации.'; + + +$_['error_permission'] = 'Внимание: у вас нет разрешения на изменение платежного модуля Hutko!'; +$_['error_merchant_id_required'] = 'Требуется идентификатор продавца!'; +$_['error_merchant_id_numeric'] = 'Идентификатор продавца должен быть числовым!'; +$_['error_secret_key_required'] = 'Требуется секретный ключ!'; +$_['error_secret_key_invalid'] = 'Секретный ключ должен быть "test" или содержать не менее 10 символов и не состоять полностью из цифр.'; +$_['error_invalid_request'] = 'Недопустимые данные запроса на возмещения/статус.'; +$_['error_missing_params'] = 'Отсутствуют обязательные параметры для возмещения/статуса.'; + + +$_['tab_general'] = 'Общие'; +$_['tab_order_statuses'] = 'Статусы заказов'; +$_['tab_fiscalization'] = 'Фискализация'; +$_['tab_advanced'] = 'Дополнительно'; +$_['tab_logs'] = 'Журналы'; +$_['text_payment_information'] = 'История платежей'; +$_['text_not_available'] = 'Н/Д'; +$_['text_hutko_transaction_ref_label'] = 'Идентификатор заказа в Hutko'; +$_['text_hutko_refund_title'] = 'Возмещение Hutko'; +$_['text_hutko_status_title'] = 'Проверка статуса Hutko'; +$_['button_hutko_refund'] = 'Обработать возмещение через Hutko'; +$_['button_hutko_status_check'] = 'Проверить статус платежа Hutko'; +$_['entry_refund_amount'] = 'Сумма возмещения'; +$_['entry_refund_comment'] = 'Комментарий к возмещению (необязательно)'; +$_['text_refund_success_comment'] = 'Возмещение средств по ID %s успешно. Сумма: %s. Комментарий: %s'; +$_['text_refund_failed_comment'] = 'Попытка возмещения средств по ID %s не удалась. Ошибка шлюза: %s'; +$_['text_refund_api_error'] = 'Ошибка API возмещения Hutko: %s'; +$_['text_status_api_error'] = 'Ошибка API статуса Hutko: %s'; +$_['text_unknown_error'] = 'Произошла неизвестная ошибка API.'; +$_['error_missing_order_id'] = 'Ошибка: в запросе отсутствует идентификатор заказа.'; +$_['error_hutko_transaction_ref_not_found_db'] = 'Ошибка: идентификатор заказа Hutko не найден в базе данных для этого заказа.'; +$_['error_hutko_transaction_ref_missing'] = 'Ошибка: идентификатор заказа Hutko требуется для этой операции.'; +$_['error_invalid_refund_amount'] = 'Ошибка: недопустимая сумма возврата. Должна быть больше 0.'; +$_['error_missing_refund_amount'] = 'Ошибка: требуется сумма возврата.'; + +$_['error_payment_data_build'] = 'Ошибка: не удалось подготовить данные платежа. Повторите попытку или обратитесь в службу поддержки.'; +$_['error_api_communication'] = 'Ошибка: не удалось связаться с платежным шлюзом. Повторите попытку.'; +$_['text_redirecting_comment'] = 'Перенаправление на Hutko. Идентификатор заказа Hutko: %s. URL: %s'; + +// Для обратного вызова +$_['text_payment_approved'] = 'Платеж одобрен Hutko.'; +$_['text_payment_declined'] = 'Платеж отклонен Hutko.'; +$_['text_payment_expired'] = 'Срок платежа истек в Hutko.'; +$_['text_payment_processing'] = 'Платеж обрабатывается в Hutko.'; +$_['text_confirm_refund'] = 'Вы уверены, что хотите возместить оплату через Hutko? Это действие нельзя отменить.'; +$_['text_loading'] = 'Загрузка...'; +$_['error_order_not_found'] = 'Ошибка: заказ не найден.'; \ No newline at end of file diff --git a/upload/admin/model/extension/payment/hutko.php b/upload/admin/model/extension/payment/hutko.php new file mode 100644 index 0000000..b242193 --- /dev/null +++ b/upload/admin/model/extension/payment/hutko.php @@ -0,0 +1,29 @@ +db->query(" + CREATE TABLE IF NOT EXISTS `" . DB_PREFIX . "hutko_order` ( + `hutko_order_pk_id` INT(11) NOT NULL AUTO_INCREMENT, + `order_id` INT(11) NOT NULL, + `hutko_transaction_ref` VARCHAR(255) NOT NULL, + `date_added` DATETIME NOT NULL, + PRIMARY KEY (`hutko_order_pk_id`), + UNIQUE KEY `idx_order_id` (`order_id`), + KEY `idx_hutko_transaction_ref` (`hutko_transaction_ref`) + ) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci; + "); + } + + public function uninstall() { + // $this->db->query("DROP TABLE IF EXISTS `" . DB_PREFIX . "hutko_order`;"); + } + + public function addHutkoOrder($order_id, $hutko_transaction_ref) { + $this->db->query("INSERT INTO `" . DB_PREFIX . "hutko_order` SET `order_id` = '" . (int)$order_id . "', `hutko_transaction_ref` = '" . $this->db->escape($hutko_transaction_ref) . "', `date_added` = NOW() ON DUPLICATE KEY UPDATE `hutko_transaction_ref` = '" . $this->db->escape($hutko_transaction_ref) . "', `date_added` = NOW()"); + } + + public function getHutkoOrder($order_id) { + $query = $this->db->query("SELECT * FROM `" . DB_PREFIX . "hutko_order` WHERE `order_id` = '" . (int)$order_id . "'"); + return $query->row; + } +} \ No newline at end of file diff --git a/upload/admin/view/template/extension/payment/hutko.twig b/upload/admin/view/template/extension/payment/hutko.twig new file mode 100644 index 0000000..4191812 --- /dev/null +++ b/upload/admin/view/template/extension/payment/hutko.twig @@ -0,0 +1,258 @@ +{{ header }}{{ column_left }} +
+ +
+ {% if error_warning %} +
{{ error_warning }} + {# Bootstrap 3 dismiss #} +
+ {% endif %} +
{# Bootstrap 3 panel #} +
+

{{ text_edit }}

+
+
+
{# Bootstrap 3 form-horizontal common #} + {# Tab Navigation for Bootstrap 3 #} + + + {# Tab Content for Bootstrap 3 #} +
+
+ {# General Settings Fields - Bootstrap 3 form-group structure #} +
+ +
+ + {{ text_info_merchant }} {# Bootstrap 3 help-block #} + {% if error_merchant_id %} +
{{ error_merchant_id }}
+ {% endif %} +
+
+
+ +
+ + {{ text_info_secret }} + {% if error_secret_key %} +
{{ error_secret_key }}
+ {% endif %} +
+
+
+ +
+ + {{ help_total }} +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ {# Order Statuses Fields #} +
+ +
+ + {{ help_new_order_status }} +
+
+
+ +
+ + {{ help_success_status }} +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ {# Fiscalization Fields #} +
+ +
+ + + {{ help_shipping_include }} +
+
+
+ +
+ + {{ help_shipping_product_name }} +
+
+
+ +
+ + {{ help_shipping_product_code }} +
+
+
+ +
+ {# Advanced Settings Fields #} +
+ +
+ + + {{ help_include_discount_to_total }} +
+
+
+ +
+ + + {{ help_save_logs }} +
+
+
+ +
+ {# Logs Content #} +

{{ heading_title }} - {{ tab_logs }}

+
+ {{ log_content|raw }} +
+
+
{# End tab-content #} +
+
{# End panel-body #} +
{# End panel #} +
{# End container-fluid #} +
{# End content #} +{{ footer }} \ No newline at end of file diff --git a/upload/admin/view/template/extension/payment/hutko_order_info_panel.twig b/upload/admin/view/template/extension/payment/hutko_order_info_panel.twig new file mode 100644 index 0000000..8a990e7 --- /dev/null +++ b/upload/admin/view/template/extension/payment/hutko_order_info_panel.twig @@ -0,0 +1,129 @@ +
+
+

+ + {{ text_payment_information }} + (Hutko)

+
+
+ + + + + +
{{ text_hutko_transaction_ref_label }}{{ hutko_transaction_ref_display }}
+ + {# Refund Section #} + {% if hutko_transaction_ref_display != text_not_available %} +
+

{{ text_hutko_refund_title }}

+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+
+
+
+ {% endif %} + + {# Status Check Section #} + {% if hutko_transaction_ref_display != text_not_available %} +
+

{{ text_hutko_status_title }}

+ +
+
+ {% endif %} + + +
+
diff --git a/upload/catalog/controller/extension/payment/hutko.php b/upload/catalog/controller/extension/payment/hutko.php new file mode 100644 index 0000000..a9fbde8 --- /dev/null +++ b/upload/catalog/controller/extension/payment/hutko.php @@ -0,0 +1,450 @@ +load->language('extension/payment/hutko'); + $data['button_confirm'] = $this->language->get('button_confirm'); + $data['text_loading'] = $this->language->get('text_loading'); // For JS button state + + // URL for the form to POST to + $data['action_redirect_to_gateway'] = $this->url->link('extension/payment/hutko/redirectToGateway', '', true); + + return $this->load->view('extension/payment/hutko', $data); + } + + public function redirectToGateway() + { + $this->load->language('extension/payment/hutko'); + $this->load->model('checkout/order'); + + if (!isset($this->session->data['order_id'])) { + $this->logOC("Hutko redirectToGateway: No order_id in session."); + $this->session->data['error'] = "Session expired or order ID missing."; // Generic error + $this->response->redirect($this->url->link('checkout/failure', '', true)); + return; + } + + $order_info = $this->model_checkout_order->getOrder($this->session->data['order_id']); + + if (!$order_info) { + $this->logOC("Hutko Error: Order info not found for order_id: " . $this->session->data['order_id']); + $this->session->data['error'] = "Critical error: Order details not found."; + $this->response->redirect($this->url->link('checkout/failure', '', true)); + return; + } + + $this->model_checkout_order->addOrderHistory($order_info['order_id'], $this->config->get('payment_hutko_new_order_status_id'), $this->language->get('text_initiated_payment'), false); + + $requestData = $this->buildPaymentRequestDataOC($order_info); + + if (empty($requestData)) { + $this->logOC("Hutko Error: Failed to build payment request data for order_id: " . $order_info['order_id']); + $this->session->data['error'] = $this->language->get('error_payment_data_build'); + $this->response->redirect($this->url->link('checkout/failure', '', true)); + return; + } + // *** SAVE HUTKO TRANSACTION REFERENCE *** + $this->load->model('extension/payment/hutko'); // Load our catalog model + $this->model_extension_payment_hutko->addHutkoOrder($order_info['order_id'], $requestData['order_id']); + $this->logOC("Hutko: Saved Hutko transaction ref '{$requestData['order_id']}' for OC order ID {$order_info['order_id']}."); + // *** END SAVE *** + $apiResponse = $this->sendAPICallOC($this->checkout_url, $requestData); + + if (isset($apiResponse['response']['response_status']) && $apiResponse['response']['response_status'] === 'success' && !empty($apiResponse['response']['checkout_url'])) { + $comment = sprintf($this->language->get('text_redirecting_comment'), $requestData['order_id'], $apiResponse['response']['checkout_url']); + $this->model_checkout_order->addOrderHistory($order_info['order_id'], $this->config->get('payment_hutko_new_order_status_id'), $comment, false); + + $this->response->redirect($apiResponse['response']['checkout_url']); // This redirect is fine now + } else { + $error_message = isset($apiResponse['response']['error_message']) ? $apiResponse['response']['error_message'] : $this->language->get('error_api_communication'); + $this->logOC("Hutko API Error (checkout_url) for order_id " . $order_info['order_id'] . ": " . $error_message . " | Response: " . json_encode($apiResponse)); + + // Use a more generic failed status if available, or config_fraud_status_id + $failed_status_id = $this->config->get('payment_hutko_declined_status_id') ? $this->config->get('payment_hutko_declined_status_id') : $this->config->get('config_fraud_status_id'); + $this->model_checkout_order->addOrderHistory($order_info['order_id'], $failed_status_id, 'Hutko API Error before redirect: ' . $error_message, false); + + $this->session->data['error'] = $this->language->get('error_api_communication') . (isset($apiResponse['response']['error_message']) ? (': ' . $apiResponse['response']['error_message']) : ''); + $this->response->redirect($this->url->link('checkout/failure', '', true)); + } + } + + + public function callback() + { + $this->load->language('extension/payment/hutko'); + $this->load->model('checkout/order'); + + $callbackContent = json_decode(file_get_contents("php://input"), true); + + if ($this->config->get('payment_hutko_save_logs')) { + $this->logOC("Hutko Callback received: " . json_encode($callbackContent)); + } + + if (!is_array($callbackContent) || empty($callbackContent)) { + $this->logOC("Hutko Callback Error: Empty or invalid JSON payload."); + http_response_code(400); + echo "Empty request"; + exit; + } + + if (!$this->validateResponseOC($callbackContent)) { + $this->logOC("Hutko Callback Error: Invalid signature or merchant ID mismatch."); + http_response_code(400); + echo "Invalid signature"; + exit; + } + + // Hutko's order_id is store_order_id#timestamp + $hutko_order_id_parts = explode($this->order_separator, $callbackContent['order_id']); + $order_id = (int)$hutko_order_id_parts[0]; + + $order_info = $this->model_checkout_order->getOrder($order_id); + + if (!$order_info) { + $this->logOC("Hutko Callback Error: Order not found for OC Order ID: " . $order_id . " (from Hutko ID: " . $callbackContent['order_id'] . ")"); + http_response_code(404); + echo "Order not found"; + exit; + } + // *** VERIFY/SAVE HUTKO TRANSACTION REFERENCE FROM CALLBACK *** + $this->load->model('extension/payment/hutko'); + $stored_hutko_order = $this->model_extension_payment_hutko->getHutkoOrder($order_id); + + if ($stored_hutko_order) { + if ($stored_hutko_order['hutko_transaction_ref'] != $callbackContent['order_id']) { + $this->logOC("Hutko Callback Warning: Mismatch for OC Order ID {$order_id}. Callback Hutko ID: {$callbackContent['order_id']}, Stored Hutko ID: {$stored_hutko_order['hutko_transaction_ref']}. Consider if update needed."); + // Optionally update if callback is authoritative: + // $this->model_extension_payment_hutko->addHutkoOrder($order_id, $hutko_order_id_from_callback); + } + } else { + // If it was not saved during redirectToGateway (e.g., edge case, interruption), save it now from a valid callback. + $this->logOC("Hutko Callback: Stored Hutko transaction ref not found for OC Order ID {$order_id}. Saving from callback: {$callbackContent['order_id']}."); + $this->model_extension_payment_hutko->addHutkoOrder($order_id, $callbackContent['order_id']); + } + // *** END VERIFY/SAVE *** + $order_status_callback = $callbackContent['order_status'] ?? 'unknown'; + $current_order_status_id = $order_info['order_status_id']; + $comment_details = "Hutko Order ID: " . $callbackContent['order_id'] . ". Status: " . $order_status_callback . ". "; + if (isset($callbackContent['rrn'])) $comment_details .= "RRN: " . $callbackContent['rrn'] . ". "; + if (isset($callbackContent['approval_code'])) $comment_details .= "Approval Code: " . $callbackContent['approval_code'] . ". "; + + + $notify_customer = true; // Usually true for final statuses + + switch ($order_status_callback) { + case 'approved': + // Ensure not already refunded or in a final error state + if (isset($callbackContent['response_status']) && $callbackContent['response_status'] == 'success' && (!isset($callbackContent['reversal_amount']) || (int)$callbackContent['reversal_amount'] === 0)) { + $target_status_id = (int)$this->config->get('payment_hutko_success_status_id'); + if ($current_order_status_id != $target_status_id) { + $callbackAmount = $callbackContent['actual_amount'] ?? $callbackContent['amount']; + $amountFloat = round($callbackAmount / 100, 2); + $comment = $this->language->get('text_payment_approved') . " " . $this->currency->format($amountFloat, $callbackContent['currency'], '', false) . ". " . $comment_details; + $this->model_checkout_order->addOrderHistory($order_id, $target_status_id, $comment, $notify_customer); + } + echo "OK"; // Hutko expects "OK" on success + } else { + $this->logOC("Hutko Callback: Approved status but response_status not success or reversal_amount present for order_id: " . $order_id); + echo "Error: Approved but invalid details"; // Or a more generic OK to stop retries + } + break; + case 'declined': + $target_status_id = (int)$this->config->get('payment_hutko_declined_status_id'); + if ($current_order_status_id != $target_status_id) { + $this->model_checkout_order->addOrderHistory($order_id, $target_status_id, $this->language->get('text_payment_declined') . $comment_details, $notify_customer); + } + echo "Order declined"; + break; + case 'expired': + $target_status_id = (int)$this->config->get('payment_hutko_expired_status_id'); + if ($current_order_status_id != $target_status_id) { + $this->model_checkout_order->addOrderHistory($order_id, $target_status_id, $this->language->get('text_payment_expired') . $comment_details, $notify_customer); + } + echo "Order expired"; + break; + case 'processing': + // Potentially a specific "processing" status, or leave as is. + echo "Order processing"; + break; + default: + $this->logOC("Hutko Callback: Unexpected order status '{$order_status_callback}' for OC Order ID: " . $order_id); + echo "Unexpected status"; + break; + } + exit; + } + + protected function buildPaymentRequestDataOC(array $order_info): array + { + $hutko_order_id = $order_info['order_id'] . $this->order_separator . time(); + $merchant_id = $this->config->get('payment_hutko_merchant_id'); + + $description_parts = [ + $this->config->get('config_name'), + $this->language->get('text_order'), + $order_info['order_id'], + ]; + $order_description = implode(' ', array_filter($description_parts)); + $order_description = substr($order_description, 0, 254); + + $server_callback_url = $this->url->link('extension/payment/hutko/callback', '', true); + $response_url = $this->url->link('checkout/success', '', true); + $customer_email = $order_info['email']; + $reservation_data_array = $this->buildReservationDataOC($order_info); + $reservation_data_json = json_encode($reservation_data_array); + $reservation_data_base64 = base64_encode($reservation_data_json); + + $products = $reservation_data_array['products']; + $total_products = 0; + foreach ($products as $product) { + $total_products += $product['total_amount']; + } + $total = (float)$order_info['total']; + $order_totals = $this->model_checkout_order->getOrderTotals($order_info['order_id']); + $total_shipping_cost = 0; + // we need update shipping cost only if shipping was not included in products array already + if (!$this->config->get('payment_hutko_shipping_include')) { + foreach ($order_totals as $total_line) { + if ($total_line['code'] == 'shipping') { + $total_shipping_cost = $this->currency->format($total_line['value'], $order_info['currency_code'], $order_info['currency_value'], false); + break; + } + } + } + $total_discounts = $total - $total_shipping_cost - $total_products; + if ($this->config->get('payment_hutko_include_discount_to_total')) { + $amount = $total_products + $total_discounts; + } else { + $amount = $total_products; + } + + + $amount_int = (int)round($this->currency->format($amount, $order_info['currency_code'], $order_info['currency_value'], false) * 100); + + $data = [ + 'order_id' => $hutko_order_id, + 'merchant_id' => $merchant_id, + 'order_desc' => $order_description, + 'amount' => $amount_int, + 'currency' => $order_info['currency_code'], + 'server_callback_url' => $server_callback_url, + 'response_url' => $response_url, + 'sender_email' => $customer_email, + 'reservation_data' => $reservation_data_base64, + ]; + $data['signature'] = $this->getSignatureOC($data); + + return $data; + } + + protected function buildReservationDataOC(array $order_info): array + { + $customer_state = $order_info['payment_zone']; // OpenCart provides zone name + + // Ensure phone is available + $phone = !empty($order_info['telephone']) ? $order_info['telephone'] : ''; + $account = $order_info['customer_id'] ? (string)$order_info['customer_id'] : 'guest'; + + $data = [ + "cms_name" => "OpenCart", + "cms_version" => VERSION, + "shop_domain" => preg_replace("(^https?://)", "", HTTPS_SERVER), // remove scheme + "path" => HTTPS_SERVER . $this->request->server['REQUEST_URI'], // current path + "phonemobile" => $phone, + "customer_address" => $order_info['payment_address_1'] . (!empty($order_info['payment_address_2']) ? ' ' . $order_info['payment_address_2'] : ''), + "customer_country" => $order_info['shipping_iso_code_2'], + "customer_state" => $customer_state, + "customer_name" => $order_info['payment_firstname'] . ' ' . $order_info['payment_lastname'], + "customer_city" => $order_info['payment_city'], + "customer_zip" => $order_info['payment_postcode'], + "account" => $account, + "uuid" => hash('sha256', HTTPS_SERVER . $this->config->get('config_encryption') . $account), + "products" => $this->getProductsOC($order_info['order_id'], $order_info), + ]; + return $data; + } + + protected function getProductsOC(int $order_id, array $order_info): array + { + $this->load->model('checkout/order'); + $order_products = $this->model_checkout_order->getOrderProducts($order_id); + $products_data = []; + + foreach ($order_products as $product) { + // Price per unit with tax + $unit_price_incl_tax = $this->currency->format($product['price'] + ($product['tax'] / $product['quantity']), $order_info['currency_code'], $order_info['currency_value'], false); + // Total for this line item with tax + $total_price_incl_tax = $this->currency->format($product['total'] + $product['tax'], $order_info['currency_code'], $order_info['currency_value'], false); + + $products_data[] = [ + "id" => $product['product_id'] . '_' . ($product['order_option'] ?? '0'), + "name" => $product['name'] . ' ' . $product['model'], + "price" => round((float)$unit_price_incl_tax, 2), + "total_amount" => round((float)$total_price_incl_tax, 2), + "quantity" => (int)$product['quantity'], + ]; + } + + // Handle shipping if enabled + if ($this->config->get('payment_hutko_shipping_include')) { + $order_totals = $this->model_checkout_order->getOrderTotals($order_id); + $shipping_cost = 0; + foreach ($order_totals as $total_line) { + if ($total_line['code'] == 'shipping') { + $shipping_cost = $this->currency->format($total_line['value'], $order_info['currency_code'], $order_info['currency_value'], false); + break; + } + } + if ($shipping_cost > 0) { + $products_data[] = [ + "id" => $this->config->get('payment_hutko_shipping_product_code') ?: 'SHIPPING_001', + "name" => $this->config->get('payment_hutko_shipping_product_name') ?: 'Shipping', + "price" => round((float)$shipping_cost, 2), + "total_amount" => round((float)$shipping_cost, 2), + "quantity" => 1, + ]; + } + } + return $products_data; + } + + protected function getSlugOC(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; + } + + protected function getSignatureOC(array $data, bool $encoded = true): string + { + $password = $this->config->get('payment_hutko_secret_key'); + if (!$password || empty($password)) { + $this->logOC('Hutko Error: Merchant secret not set for signature generation.'); + return ''; + } + $filteredData = array_filter($data, function ($value) { + return $value !== '' && $value !== null; + }); + ksort($filteredData); + $stringToHash = $password; + foreach ($filteredData as $value) { + $stringToHash .= '|' . $value; + } + if ($encoded) { + return sha1($stringToHash); + } else { + return $stringToHash; + } + } + + protected function validateResponseOC(array $response): bool + { + if ((string)$this->config->get('payment_hutko_merchant_id') != (string)($response['merchant_id'] ?? '')) { + $this->logOC("Hutko validateResponseOC: Merchant ID mismatch. Expected: " . $this->config->get('payment_hutko_merchant_id') . ", Received: " . ($response['merchant_id'] ?? 'NULL')); + return false; + } + $responseSignature = $response['signature'] ?? ''; + unset($response['response_signature_string'], $response['signature']); + + $calculatedSignature = $this->getSignatureOC($response); + + if (!hash_equals($calculatedSignature, $responseSignature)) { + $this->logOC("Hutko validateResponseOC: Signature mismatch. Calculated: " . $calculatedSignature . ", Received: " . $responseSignature . ". Data for calc: " . json_encode($response)); + if ($this->config->get('payment_hutko_save_logs')) { // Log string used for calc if sig fails + $this->logOC("Hutko validateResponseOC: String for calc (before sha1): " . $this->getSignatureOC($response, false)); + } + return false; + } + return true; + } + + protected function sendAPICallOC(string $url, array $data, int $timeout = 60): array + { + // This is duplicated from admin controller, ideally in a shared library/trait + if ($this->config->get('payment_hutko_save_logs')) { + $this->logOC('Hutko API Request to ' . $url . ': ' . json_encode(['request' => $data])); + } + + $requestPayload = ['request' => $this->sanitizeForApi($data)]; + $jsonPayload = json_encode($requestPayload, JSON_UNESCAPED_UNICODE); + + if ($jsonPayload === false) { + $error_msg = 'Failed to encode request data to JSON: ' . json_last_error_msg(); + $this->logOC('Hutko API Error: ' . $error_msg); + return ['response' => ['response_status' => 'failure', 'error_message' => $error_msg]]; + } + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonPayload); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json', 'Content-Length: ' . strlen($jsonPayload)]); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); + curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); + $response_body = curl_exec($ch); + $curl_error = curl_error($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($curl_error) { + $error_msg = 'CURL Error: ' . $curl_error; + $this->logOC('Hutko API CURL Error: ' . $error_msg . ' (HTTP Code: ' . $http_code . ')'); + return ['response' => ['response_status' => 'failure', 'error_message' => $error_msg, 'http_code' => $http_code]]; + } + + if ($this->config->get('payment_hutko_save_logs')) { + $this->logOC('Hutko API Response from ' . $url . ': ' . $response_body); + } + + $responseData = json_decode($response_body, true); + if (json_last_error() !== JSON_ERROR_NONE) { + $error_msg = 'Invalid JSON response from API: ' . json_last_error_msg(); + $this->logOC('Hutko API JSON Decode Error: ' . $error_msg . ' (Raw: ' . $response_body . ')'); + return ['response' => ['response_status' => 'failure', 'error_message' => $error_msg, 'raw_response' => $response_body]]; + } + return $responseData; + } + + protected function logOC(string $message): void + { + if ($this->config->get('payment_hutko_save_logs')) { + $this->log->write('Hutko Payment: ' . $message); + } + } + + + + protected function sanitizeForApi(array $array): array + { + $result = []; + foreach ($array as $key => $value) { + $result[$key] = str_replace('|', '_', $value); + } + // Remove pipe symbols and potentially other problematic characters for this specific API + return $result; + } +} diff --git a/upload/catalog/language/en-gb/extension/payment/hutko.php b/upload/catalog/language/en-gb/extension/payment/hutko.php new file mode 100644 index 0000000..92e546b --- /dev/null +++ b/upload/catalog/language/en-gb/extension/payment/hutko.php @@ -0,0 +1,14 @@ +load->language('extension/payment/hutko'); + // Define allowed currencies for Hutko + $allowed_currencies = array('UAH', 'USD', 'EUR', 'GBP', 'CZK'); + $current_currency_code = $this->session->data['currency']; + + $query = $this->db->query("SELECT * FROM " . DB_PREFIX . "zone_to_geo_zone WHERE geo_zone_id = '" . (int)$this->config->get('payment_hutko_geo_zone_id') . "' AND country_id = '" . (int)$address['country_id'] . "' AND (zone_id = '" . (int)$address['zone_id'] . "' OR zone_id = '0')"); + + if ($this->config->get('payment_hutko_total') > 0 && $this->config->get('payment_hutko_total') > $total) { + $status = false; + } elseif (!$this->config->get('payment_hutko_geo_zone_id')) { + $status = true; + } elseif ($query->num_rows) { + $status = true; + } else { + $status = false; + } + if ($status) { // Only proceed if still active + if (!in_array(strtoupper($current_currency_code), $allowed_currencies)) { + $this->log->write('Hutko Payment: Disabled because current currency (' . $current_currency_code . ') is not in allowed list: ' . implode(', ', $allowed_currencies)); + $status = false; + } + } + $method_data = array(); + + if ($status && $this->config->get('payment_hutko_status')) { + $title = $this->language->get('text_title'); + + $method_data = array( + 'code' => 'hutko', + 'title' => $title, + 'terms' => '', + 'sort_order' => $this->config->get('payment_hutko_sort_order') + ); + } + return $method_data; + } + + public function addHutkoOrder($order_id, $hutko_transaction_ref) + { + $this->db->query("INSERT INTO `" . DB_PREFIX . "hutko_order` SET `order_id` = '" . (int)$order_id . "', `hutko_transaction_ref` = '" . $this->db->escape($hutko_transaction_ref) . "', `date_added` = NOW() ON DUPLICATE KEY UPDATE `hutko_transaction_ref` = '" . $this->db->escape($hutko_transaction_ref) . "', `date_added` = NOW()"); + } + + public function getHutkoOrder($order_id) + { + $query = $this->db->query("SELECT * FROM `" . DB_PREFIX . "hutko_order` WHERE `order_id` = '" . (int)$order_id . "'"); + return $query->row; + } +} diff --git a/upload/catalog/view/theme/default/template/extension/payment/hutko.twig b/upload/catalog/view/theme/default/template/extension/payment/hutko.twig new file mode 100644 index 0000000..8700721 --- /dev/null +++ b/upload/catalog/view/theme/default/template/extension/payment/hutko.twig @@ -0,0 +1,12 @@ +
+
+
+ +
+
+
+