first commit

This commit is contained in:
O K
2025-06-17 14:01:03 +03:00
commit 7f951e6c23
11 changed files with 1754 additions and 0 deletions

View File

@@ -0,0 +1,575 @@
<?php
class ControllerExtensionPaymentHutko extends Controller
{
private $error = array();
private $order_separator = '#';
private $checkout_url = 'https://pay.hutko.org/api/checkout/url/';
private $refund_url = 'https://pay.hutko.org/api/reverse/order_id';
private $status_url = 'https://pay.hutko.org/api/status/order_id';
public function index()
{
$this->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
'<div id="history">', // Common ID for history section
'<div class="tab-pane active" id="tab-history">', // Another common structure
'</fieldset>\s*<fieldset>' // 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 = [
'</form>', // Before closing form tag
'<div id="content"', // Appending inside the main content div as the last element (less ideal, but a last resort)
];
foreach ($fallback_markers as $marker) {
if (strpos($output, $marker) !== false) {
if ($marker == '<div id="content"') { // If appending to content, add it before its closing tag
$output = preg_replace('/(<div id="content"[^>]*>)(.*)(<\/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 '<p>' . $this->language->get('text_logs_disabled') . '</p>';
}
$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 .= '<div style="background-color: #f8f8f8; border: 1px solid #ddd; padding: 10px; max-height: 400px; overflow-y: auto; font-family: monospace; white-space: pre-wrap; word-wrap: break-word;">';
$content .= implode("<br>", array_reverse($hutko_lines)); // Show newest first
$content .= '</div>';
} else {
$content = '<p>' . $this->language->get('text_no_logs_found') . '</p>';
}
} else {
$content = '<p>' . sprintf($this->language->get('text_log_file_not_found'), $log_file) . '</p>';
}
return $content;
}
}

View File

@@ -0,0 +1,114 @@
<?php
// Hutko translation file
// Heading
$_['heading_title'] = 'Hutko Payments';
// Text
$_['text_extension'] = 'Extensions';
$_['text_success'] = 'Success: You have modified Hutko payment module settings!';
$_['text_edit'] = 'Edit Hutko Payments';
$_['text_hutko'] = '<a href="https://hutko.org/" target="_blank"><img src="view/image/payment/hutko.png" alt="Hutko" title="Hutko" style="border: 1px solid #EEEEEE; max-height:25px;" /></a>'; // 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.';

View File

@@ -0,0 +1,104 @@
<?php
// Hutko translation file
// Heading
$_['heading_title'] = 'Платежи Hutko';
$_['text_extension'] = 'Расширения';
$_['text_success'] = 'Успех: Вы изменили настройки модуля оплаты Hutko!';
$_['text_edit'] = 'Изменить настройки Hutko';
$_['text_hutko'] = '<a href="https://hutko.org/" target="_blank"><img src="view/image/payment/hutko.png" alt="Hutko" title="Hutko" style="border: 1px solid #EEEEEE; max-height:25px;" /></a>'; // Вам понадобится 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'] = 'Ошибка: заказ не найден.';

View File

@@ -0,0 +1,29 @@
<?php
class ModelExtensionPaymentHutko extends Model {
public function install() {
$this->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;
}
}

View File

@@ -0,0 +1,258 @@
{{ header }}{{ column_left }}
<div id="content">
<div class="page-header">
<div class="container-fluid">
<div class="pull-right"> {# Bootstrap 3 uses pull-right #}
<button type="submit" form="form-payment-hutko" data-toggle="tooltip" title="{{ button_save }}" class="btn btn-primary"><i class="fa fa-save"></i></button> {# Font Awesome icons common in OC3 #}
<a href="{{ cancel }}" data-toggle="tooltip" title="{{ button_cancel }}" class="btn btn-default"><i class="fa fa-reply"></i></a></div>
<h1>{{ heading_title }}</h1>
<ul class="breadcrumb"> {# Bootstrap 3 breadcrumb structure #}
{% for breadcrumb in breadcrumbs %}
<li><a href="{{ breadcrumb.href }}">{{ breadcrumb.text }}</a></li>
{% endfor %}
</ul>
</div>
</div>
<div class="container-fluid">
{% if error_warning %}
<div class="alert alert-danger alert-dismissible"><i class="fa fa-exclamation-circle"></i> {{ error_warning }}
<button type="button" class="close" data-dismiss="alert">×</button> {# Bootstrap 3 dismiss #}
</div>
{% endif %}
<div class="panel panel-default"> {# Bootstrap 3 panel #}
<div class="panel-heading">
<h3 class="panel-title"><i class="fa fa-pencil"></i> {{ text_edit }}</h3>
</div>
<div class="panel-body">
<form action="{{ action }}" method="post" enctype="multipart/form-data" id="form-payment-hutko" class="form-horizontal"> {# Bootstrap 3 form-horizontal common #}
{# Tab Navigation for Bootstrap 3 #}
<ul class="nav nav-tabs">
<li class="active"><a href="#tab-general" data-toggle="tab">{{ tab_general }}</a></li>
<li><a href="#tab-order-statuses" data-toggle="tab">{{ tab_order_statuses }}</a></li>
<li><a href="#tab-fiscalization" data-toggle="tab">{{ tab_fiscalization }}</a></li>
<li><a href="#tab-advanced" data-toggle="tab">{{ tab_advanced }}</a></li>
<li><a href="#tab-logs" data-toggle="tab">{{ tab_logs }}</a></li>
</ul>
{# Tab Content for Bootstrap 3 #}
<div class="tab-content">
<div class="tab-pane active" id="tab-general">
{# General Settings Fields - Bootstrap 3 form-group structure #}
<div class="form-group required">
<label class="col-sm-2 control-label" for="input-merchant-id">{{ entry_merchant_id }}</label>
<div class="col-sm-10">
<input type="text" name="payment_hutko_merchant_id" value="{{ payment_hutko_merchant_id }}" placeholder="{{ entry_merchant_id }}" id="input-merchant-id" class="form-control"/>
<span class="help-block">{{ text_info_merchant }}</span> {# Bootstrap 3 help-block #}
{% if error_merchant_id %}
<div class="text-danger">{{ error_merchant_id }}</div>
{% endif %}
</div>
</div>
<div class="form-group required">
<label class="col-sm-2 control-label" for="input-secret-key">{{ entry_secret_key }}</label>
<div class="col-sm-10">
<input type="text" name="payment_hutko_secret_key" value="{{ payment_hutko_secret_key }}" placeholder="{{ entry_secret_key }}" id="input-secret-key" class="form-control"/>
<span class="help-block">{{ text_info_secret }}</span>
{% if error_secret_key %}
<div class="text-danger">{{ error_secret_key }}</div>
{% endif %}
</div>
</div>
<div class="form-group">
<label class="col-sm-2 control-label" for="input-total">{{ entry_total }}</label>
<div class="col-sm-10">
<input type="text" name="payment_hutko_total" value="{{ payment_hutko_total }}" placeholder="{{ entry_total }}" id="input-total" class="form-control"/>
<span class="help-block">{{ help_total }}</span>
</div>
</div>
<div class="form-group">
<label class="col-sm-2 control-label" for="input-geo-zone">{{ entry_geo_zone }}</label>
<div class="col-sm-10">
<select name="payment_hutko_geo_zone_id" id="input-geo-zone" class="form-control">
<option value="0">{{ text_all_zones }}</option>
{% for geo_zone in geo_zones %}
{% if geo_zone.geo_zone_id == payment_hutko_geo_zone_id %}
<option value="{{ geo_zone.geo_zone_id }}" selected="selected">{{ geo_zone.name }}</option>
{% else %}
<option value="{{ geo_zone.geo_zone_id }}">{{ geo_zone.name }}</option>
{% endif %}
{% endfor %}
</select>
</div>
</div>
<div class="form-group">
<label class="col-sm-2 control-label" for="input-status">{{ entry_status }}</label>
<div class="col-sm-10">
<select name="payment_hutko_status" id="input-status" class="form-control">
{% if payment_hutko_status %}
<option value="1" selected="selected">{{ text_enabled }}</option>
<option value="0">{{ text_disabled }}</option>
{% else %}
<option value="1">{{ text_enabled }}</option>
<option value="0" selected="selected">{{ text_disabled }}</option>
{% endif %}
</select>
</div>
</div>
<div class="form-group">
<label class="col-sm-2 control-label" for="input-sort-order">{{ entry_sort_order }}</label>
<div class="col-sm-10">
<input type="text" name="payment_hutko_sort_order" value="{{ payment_hutko_sort_order }}" placeholder="{{ entry_sort_order }}" id="input-sort-order" class="form-control"/>
</div>
</div>
</div>
<div class="tab-pane" id="tab-order-statuses">
{# Order Statuses Fields #}
<div class="form-group">
<label class="col-sm-2 control-label" for="input-new-order-status">{{ entry_new_order_status }}</label>
<div class="col-sm-10">
<select name="payment_hutko_new_order_status_id" id="input-new-order-status" class="form-control">
{% for order_status in order_statuses %}
{% if order_status.order_status_id == payment_hutko_new_order_status_id %}
<option value="{{ order_status.order_status_id }}" selected="selected">{{ order_status.name }}</option>
{% else %}
<option value="{{ order_status.order_status_id }}">{{ order_status.name }}</option>
{% endif %}
{% endfor %}
</select>
<span class="help-block">{{ help_new_order_status }}</span>
</div>
</div>
<div class="form-group">
<label class="col-sm-2 control-label" for="input-success-status">{{ entry_success_status }}</label>
<div class="col-sm-10">
<select name="payment_hutko_success_status_id" id="input-success-status" class="form-control">
{% for order_status in order_statuses %}
{% if order_status.order_status_id == payment_hutko_success_status_id %}
<option value="{{ order_status.order_status_id }}" selected="selected">{{ order_status.name }}</option>
{% else %}
<option value="{{ order_status.order_status_id }}">{{ order_status.name }}</option>
{% endif %}
{% endfor %}
</select>
<span class="help-block">{{ help_success_status }}</span>
</div>
</div>
<div class="form-group">
<label class="col-sm-2 control-label" for="input-declined-status">{{ entry_declined_status }}</label>
<div class="col-sm-10">
<select name="payment_hutko_declined_status_id" id="input-declined-status" class="form-control">
{% for order_status in order_statuses %}
{% if order_status.order_status_id == payment_hutko_declined_status_id %}
<option value="{{ order_status.order_status_id }}" selected="selected">{{ order_status.name }}</option>
{% else %}
<option value="{{ order_status.order_status_id }}">{{ order_status.name }}</option>
{% endif %}
{% endfor %}
</select>
</div>
</div>
<div class="form-group">
<label class="col-sm-2 control-label" for="input-expired-status">{{ entry_expired_status }}</label>
<div class="col-sm-10">
<select name="payment_hutko_expired_status_id" id="input-expired-status" class="form-control">
{% for order_status in order_statuses %}
{% if order_status.order_status_id == payment_hutko_expired_status_id %}
<option value="{{ order_status.order_status_id }}" selected="selected">{{ order_status.name }}</option>
{% else %}
<option value="{{ order_status.order_status_id }}">{{ order_status.name }}</option>
{% endif %}
{% endfor %}
</select>
</div>
</div>
<div class="form-group">
<label class="col-sm-2 control-label" for="input-refunded-status">{{ entry_refunded_status }}</label>
<div class="col-sm-10">
<select name="payment_hutko_refunded_status_id" id="input-refunded-status" class="form-control">
{% for order_status in order_statuses %}
{% if order_status.order_status_id == payment_hutko_refunded_status_id %}
<option value="{{ order_status.order_status_id }}" selected="selected">{{ order_status.name }}</option>
{% else %}
<option value="{{ order_status.order_status_id }}">{{ order_status.name }}</option>
{% endif %}
{% endfor %}
</select>
</div>
</div>
</div>
<div class="tab-pane" id="tab-fiscalization">
{# Fiscalization Fields #}
<div class="form-group">
<label class="col-sm-2 control-label">{{ entry_shipping_include }}</label>
<div class="col-sm-10">
<label class="radio-inline"> {# Bootstrap 3 radio-inline #}
<input type="radio" name="payment_hutko_shipping_include" value="1" {% if payment_hutko_shipping_include == '1' %}checked="checked"{% endif %} />
{{ text_yes }}
</label>
<label class="radio-inline">
<input type="radio" name="payment_hutko_shipping_include" value="0" {% if payment_hutko_shipping_include == '0' or payment_hutko_shipping_include == '' %}checked="checked"{% endif %} />
{{ text_no }}
</label>
<span class="help-block">{{ help_shipping_include }}</span>
</div>
</div>
<div class="form-group">
<label class="col-sm-2 control-label" for="input-shipping-product-name">{{ entry_shipping_product_name }}</label>
<div class="col-sm-10">
<input type="text" name="payment_hutko_shipping_product_name" value="{{ payment_hutko_shipping_product_name }}" placeholder="{{ entry_shipping_product_name }}" id="input-shipping-product-name" class="form-control"/>
<span class="help-block">{{ help_shipping_product_name }}</span>
</div>
</div>
<div class="form-group">
<label class="col-sm-2 control-label" for="input-shipping-product-code">{{ entry_shipping_product_code }}</label>
<div class="col-sm-10">
<input type="text" name="payment_hutko_shipping_product_code" value="{{ payment_hutko_shipping_product_code }}" placeholder="{{ entry_shipping_product_code }}" id="input-shipping-product-code" class="form-control"/>
<span class="help-block">{{ help_shipping_product_code }}</span>
</div>
</div>
</div>
<div class="tab-pane" id="tab-advanced">
{# Advanced Settings Fields #}
<div class="form-group">
<label class="col-sm-2 control-label">{{ entry_include_discount_to_total }}</label>
<div class="col-sm-10">
<label class="radio-inline">
<input type="radio" name="payment_hutko_include_discount_to_total" value="1" {% if payment_hutko_include_discount_to_total == '1' %}checked="checked"{% endif %} />
{{ text_yes }}
</label>
<label class="radio-inline">
<input type="radio" name="payment_hutko_include_discount_to_total" value="0" {% if payment_hutko_include_discount_to_total == '0' or payment_hutko_include_discount_to_total == '' %}checked="checked"{% endif %} />
{{ text_no }}
</label>
<span class="help-block">{{ help_include_discount_to_total }}</span>
</div>
</div>
<div class="form-group">
<label class="col-sm-2 control-label">{{ entry_save_logs }}</label>
<div class="col-sm-10">
<label class="radio-inline">
<input type="radio" name="payment_hutko_save_logs" value="1" {% if payment_hutko_save_logs == '1' %}checked="checked"{% endif %} />
{{ text_yes }}
</label>
<label class="radio-inline">
<input type="radio" name="payment_hutko_save_logs" value="0" {% if payment_hutko_save_logs == '0' or payment_hutko_save_logs == '' %}checked="checked"{% endif %} />
{{ text_no }}
</label>
<span class="help-block">{{ help_save_logs }}</span>
</div>
</div>
</div>
<div class="tab-pane" id="tab-logs">
{# Logs Content #}
<h4>{{ heading_title }} - {{ tab_logs }}</h4>
<div id="log-viewer">
{{ log_content|raw }}
</div>
</div>
</div> {# End tab-content #}
</form>
</div> {# End panel-body #}
</div> {# End panel #}
</div> {# End container-fluid #}
</div> {# End content #}
{{ footer }}

View File

@@ -0,0 +1,129 @@
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">
<i class="fa fa-credit-card"></i>
{{ text_payment_information }}
(Hutko)</h3>
</div>
<div class="panel-body">
<table class="table table-bordered">
<tr>
<td>{{ text_hutko_transaction_ref_label }}</td>
<td id="hutko-transaction-id-cell">{{ hutko_transaction_ref_display }}</td>
</tr>
</table>
{# Refund Section #}
{% if hutko_transaction_ref_display != text_not_available %}
<div id="hutko-refund-section" style="margin-top:15px; padding-top:15px; border-top: 1px solid #eee;">
<h4>{{ text_hutko_refund_title }}</h4>
<div class="form-horizontal">
<div class="form-group">
<label class="col-sm-3 control-label" for="input-refund-amount">{{ entry_refund_amount }}</label>
<div class="col-sm-9">
<input type="text" name="hutko_refund_amount" value="" placeholder="{{ entry_refund_amount }}" id="input-refund-amount" class="form-control"/>
</div>
</div>
<div class="form-group">
<label class="col-sm-3 control-label" for="input-refund-comment">{{ entry_refund_comment }}</label>
<div class="col-sm-9">
<textarea name="hutko_refund_comment" placeholder="{{ entry_refund_comment }}" id="input-refund-comment" class="form-control" rows="3"></textarea>
</div>
</div>
<div class="form-group">
<div class="col-sm-9 col-sm-offset-3">
<button type="button" id="button-hutko-refund" data-loading-text="{{ text_loading }}" class="btn btn-primary">{{ button_hutko_refund }}</button>
</div>
</div>
</div>
<div id="hutko-refund-response" style="margin-top:10px;"></div>
</div>
{% endif %}
{# Status Check Section #}
{% if hutko_transaction_ref_display != text_not_available %}
<div id="hutko-status-check-section" style="margin-top:15px; padding-top:15px; border-top: 1px solid #eee;">
<h4>{{ text_hutko_status_title }}</h4>
<button type="button" id="button-hutko-status" data-loading-text="{{ text_loading }}" class="btn btn-info">{{ button_hutko_status_check }}</button>
<div id="hutko-status-response" style="margin-top:10px; white-space: pre-wrap; max-height: 300px; overflow-y: auto; background: #f5f5f5; padding: 10px; border: 1px solid #ddd;"></div>
</div>
{% endif %}
<script
type="text/javascript">
<!--
$(document).ready(function () { // Ensure DOM is ready for these bindings
$('#button-hutko-refund').on('click', function () {
if (confirm('{{ text_confirm_refund }}')) {
$.ajax({
url: '{{ hutko_refund_action_url|raw }}&user_token={{ user_token_value|raw }}',
type: 'post',
dataType: 'json',
data: {
'refund_amount': $('#input-refund-amount').val(),
'refund_comment': $('#input-refund-comment').val(),
'hutko_transaction_ref': '{{ hutko_transaction_ref_display }}',
'order_id': {{ order_id }}
},
beforeSend: function () {
$('#button-hutko-refund').button('loading');
$('#hutko-refund-response').html('');
},
complete: function () {
$('#button-hutko-refund').button('reset');
},
success: function (json) {
$('#hutko-refund-response').parent().find('.alert-dismissible').remove(); // Clear previous alerts in this section
if (json['error']) {
$('#hutko-refund-response').html('<div class="alert alert-danger alert-dismissible"><i class="fa fa-exclamation-circle"></i> ' + json['error'] + '<button type="button" class="close" data-dismiss="alert">×</button></div>');
}
if (json['success']) {
$('#hutko-refund-response').html('<div class="alert alert-success alert-dismissible"><i class="fa fa-check-circle"></i> ' + json['success'] + '<button type="button" class="close" data-dismiss="alert">×</button></div>');
setTimeout(function () { // More targeted reload if history is in a specific container
if ($('#history').length) {
$('#history').load('index.php?route=sale/order/history&user_token={{ user_token }}&order_id={{ order_id }}');
} else {
location.reload();
}
}, 2000);
}
},
error: function (xhr, ajaxOptions, thrownError) {
alert(thrownError + "\r\n" + xhr.statusText + "\r\n" + xhr.responseText);
}
});
}
});
$('#button-hutko-status').on('click', function () {
$.ajax({
url: '{{ hutko_status_action_url|raw }}&user_token={{ user_token_value|raw }}&order_id={{ order_id|raw }}',
type: 'post', // Your status function in controller expects POST if order_id is not enough
dataType: 'json',
data: {'hutko_transaction_ref': '{{ hutko_transaction_ref_display }}'}, // If order_id in URL is sufficient, otherwise pass {'hutko_transaction_ref': '{{ hutko_transaction_ref_display }}' }
beforeSend: function () {
$('#button-hutko-status').button('loading');
$('#hutko-status-response').html('');
},
complete: function () {
$('#button-hutko-status').button('reset');
},
success: function (json) {
$('#hutko-status-response').parent().find('.alert-dismissible').remove(); // Clear previous alerts
if (json['error']) {
$('#hutko-status-response').html('<div class="alert alert-danger alert-dismissible"><i class="fa fa-exclamation-circle"></i> ' + json['error'] + '<button type="button" class="close" data-dismiss="alert">×</button></div>');
}
if (json['success']) {
let responseData = json['data'] ? JSON.stringify(json['data'], null, 2) : 'No additional data.';
$('#hutko-status-response').html('<div class="alert alert-success alert-dismissible"><i class="fa fa-check-circle"></i> ' + json['success'] + '<button type="button" class="close" data-dismiss="alert">×</button><br><pre style="max-height: 200px; overflow-y:auto; word-wrap: break-word;">' + responseData.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">") + '</pre></div>');
}
},
error: function (xhr, ajaxOptions, thrownError) {
alert(thrownError + "\r\n" + xhr.statusText + "\r\n" + xhr.responseText);
}
});
});
});
//--></script>
</div>
</div>

View File

@@ -0,0 +1,450 @@
<?php
class ControllerExtensionPaymentHutko extends Controller
{
private $order_separator = '#';
private $checkout_url = 'https://pay.hutko.org/api/checkout/url/';
public function index()
{
$this->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;
}
}

View File

@@ -0,0 +1,14 @@
<?php
// Text
$_['text_title'] = 'Credit Card / Debit Card (Hutko)';
$_['text_initiated_payment'] = 'Customer initiated payment via Hutko.';
$_['text_order'] = 'Order';
$_['text_loading'] = 'Loading';
$_['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.';

View File

@@ -0,0 +1,16 @@
<?php
// Hutko translation file
$_['text_title'] = 'Оплата картой через Hutko';
$_['text_initiated_payment'] = 'Клиент инициировал платеж через Hutko.';
$_['text_order'] = 'Заказ';
$_['text_loading'] = 'Загрузка';
$_['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.';

View File

@@ -0,0 +1,53 @@
<?php
class ModelExtensionPaymentHutko extends Model
{
public function getMethod($address, $total)
{
$this->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;
}
}

View File

@@ -0,0 +1,12 @@
<form action="{{ action_redirect_to_gateway }}" method="post" id="hutko_payment_form">
<div class="buttons">
<div class="float-end">
<button type="submit" id="button-confirm-hutko" data-loading-text="{{ text_loading }}" class="btn btn-primary">{{ button_confirm }}</button>
</div>
</div>
</form>
<script type="text/javascript"><!--
$('#hutko_payment_form').on('submit', function() {
$('#button-confirm-hutko').button('loading');
});</script>