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) { 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; } }