Add core/components/minishop2/custom/payment/hutko.class.php

This commit is contained in:
2026-03-09 14:22:39 +02:00
parent 6a0a3076ba
commit 7c7cb718ac

View File

@@ -0,0 +1,283 @@
<?php
/* Sample config
{
"merchant_id": "1700002", //use '1700002' for test
"secret_key": "test", //use 'test' for test
"currency": "UAH",
"delivery_code": "Shipping_001",
"delivery_name": "Shipping",
"order_description": "Оплата замовлення #",
"payment_with_delivery": true,
"paid_status_id": 2,
}
*/
if (!class_exists('msPaymentInterface')) {
require_once dirname(dirname(dirname(__FILE__))) . '/model/minishop2/mspaymenthandler.class.php';
}
class Hutko extends msPaymentHandler implements msPaymentInterface
{
public $modx;
const ORDER_SEPARATOR = '#';
function __construct(xPDOObject $object)
{
$this->modx = &$object->xpdo;
}
public function send(msOrder $order)
{
$email = '';
if ($this->modx->user->isAuthenticated()) {
$profile = $this->modx->user->Profile;
$email = $profile->get('email');
}
if (empty($email)) {
$email = $order->get('email');
}
if (empty($email)) {
$email = filter_var($_POST['email'], FILTER_SANITIZE_EMAIL);
}
$config = $this->getPaymentConfig($order);
$hutko_ref = $order->get('id') . self::ORDER_SEPARATOR . time();
$requestData = $this->buildRequest($order, $hutko_ref, $email, $config);
$response = $this->hutkoApiCall('https://pay.hutko.org/api/checkout/url/', $requestData);
if (!isset($response['response']['checkout_url'])) {
return $this->error('checkout_url not set in server response');
}
return $this->success('', ['redirect' => $response['response']['checkout_url']]);
}
public function paymentError($text, $request = array())
{
$this->modx->log(modX::LOG_LEVEL_ERROR, '[miniShop2:hutko] ' . $text . ', request: ' . print_r($request, 1));
header("HTTP/1.0 400 Bad Request");
die('ERR: ' . $text);
}
private function buildRequest(msOrder $msOrder, $hutko_ref, $email, $config)
{
$products_data = $this->getProducts($msOrder, $config);
if ($config['payment_with_delivery']) {
$amount = $msOrder->get('cost');
} else {
$amount = $msOrder->get('cart_cost');
}
$address = $msOrder->getOne('Address');
if ($address) {
$addressData = [
'city' => $address->get('city'),
'street' => $address->get('street'),
'building' => $address->get('building'),
'region' => $address->get('region'),
];
} else {
$addressData = [
'city' => '',
'street' => '',
'building' => '',
'region' => '',
];
}
$reservation_data = [
"cms_name" => "modx",
"cms_version" => $this->modx->getOption('settings_version', null, '0.0.0'),
"shop_domain" => preg_replace("(^https?://)", "", $this->modx->getOption('site_url')),
"phonemobile" => $address->get('phone'),
"customer_address" => $addressData['city'] . ' ' . $addressData['street'] . ' ' . $addressData['building'],
"customer_country" => $addressData['region'],
"customer_name" => $address->get('name'),
"customer_email" => $email,
"products" => $products_data
];
$data = [
'order_id' => $hutko_ref,
'merchant_id' => $config['merchant_id'],
'amount' => (int)round($amount * 100),
'currency' => $config['currency'],
'order_desc' => $config['order_description'] . $msOrder->get('id'),
'response_url' => $config['success_url'],
'server_callback_url' => $config['callback_url'],
'sender_email' => $addressData['email'],
'reservation_data' => base64_encode(json_encode($reservation_data))
];
$data['signature'] = $this->sign($data, $config['secret_key']);
return $data;
}
private function getProducts(msOrder $msOrder, $config): array
{
$products_data = [];
$products = $msOrder->getMany('Products');
/** @var msOrderProduct $product */
foreach ($products as $product) {
$count = $product->get('count');
$price = $product->get('price');
$total_amount = $price * $count;
$products_data[] = [
"id" => $product->get('id'),
"name" => $product->get('name'),
// for reservation data prices we use float
"price" => round((float)$price, 2),
// for reservation data prices we use float
"total_amount" => round((float)$total_amount, 2),
"quantity" => (int)$count,
];
}
$shipping_val = $msOrder->get('delivery_cost');
if ($config['payment_with_delivery'] && $shipping_val > 0) {
$products_data[] = [
"id" => $config['delivery_code'],
"name" => $config['delivery_name'],
"price" => round((float)$shipping_val, 2),
"total_amount" => round((float)$shipping_val, 2),
"quantity" => 1,
];
}
return $products_data;
}
private function sign($data, $secretKey)
{
$arr = array_filter($data, function ($v) {
return $v !== '' && $v !== null;
});
ksort($arr);
$str = $secretKey;
foreach ($arr as $v) $str .= '|' . $v;
return sha1($str);
}
private function hutkoApiCall($url, $data)
{
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(['request' => $data]));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
$res = curl_exec($ch);
$error = curl_error($ch);
return json_decode($res, true) ?: [];
}
private function validate($data, $secretKey)
{
if (!isset($data['signature']) || empty($data['signature']) || !is_string($data['signature'])) {
return false;
}
$sig = $data['signature'] ?? '';
unset($data['signature'], $data['response_signature_string']);
return hash_equals($this->sign($data, $secretKey), $sig);
}
private function getPaymentConfig(msOrder $order)
{
$payment = $order->getOne('Payment');
if (!$payment) {
$this->modx->log(modX::LOG_LEVEL_ERROR, '[Hutko] No payment method found for order ID: ' . $order->get('id'));
return [];
}
$siteUrl = $this->modx->getOption('site_url');
$assetsUrl = $this->modx->getOption('minishop2.assets_url', [], $this->modx->getOption('assets_url') . 'components/minishop2/');
$paymentUrl = $siteUrl . substr($assetsUrl, 1) . 'payment/hutko.php';
$paymentId = $payment->get('id');
$settingKey = 'ms2_hutko_payment_' . $paymentId . '_config';
$jsonConfig = $this->modx->getOption($settingKey, null, '{}');
$userConfig = json_decode($jsonConfig, true);
if (!is_array($userConfig)) {
$this->modx->log(modX::LOG_LEVEL_ERROR, '[Hutko] Invalid JSON in System Setting: ' . $settingKey);
$userConfig = [];
}
return array_merge([
'merchant_id' => '', //use '1700002' for test
'secret_key' => '', // use 'test' for test
'currency' => 'UAH',
'delivery_code' => 'Shipping_001',
'delivery_name' => 'Shipping',
'order_description' => "Оплата замовлення #",
'payment_with_delivery' => true,
'callback_url' => $paymentUrl,
'success_url' => $this->modx->getOption('site_url'),
'paid_status_id' => 2,
], $userConfig);
}
public function processCallback(msOrder $order, array $callbackContent)
{
$config = $this->getPaymentConfig($order);
$isPaymentValid = $this->validate($callbackContent, $config['secret_key']);
if ($isPaymentValid !== true) {
$this->paymentError('callback validation failed', $callbackContent);
}
$order_status_callback = $callbackContent['order_status'] ?? 'unknown';
switch ($order_status_callback) {
case 'approved':
// Ensure not already refunded or in a final error state
if ($config['payment_with_delivery']) {
$expectedAmount = (int)round($order->get('cost') * 100);
} else {
$expectedAmount = (int)round($order->get('cart_cost') * 100);
}
if (isset($callbackContent['response_status']) && $callbackContent['response_status'] == 'success' && (!isset($callbackContent['reversal_amount']) || (int)$callbackContent['reversal_amount'] === 0) && $expectedAmount == (int)$callbackContent['amount']) {
if ($order->get('status') != $config['paid_status_id']) {
$miniShop2 = $this->modx->getService('miniShop2');
$miniShop2->changeOrderStatus($order->get('id'), $config['paid_status_id']);
}
echo "OK"; // Hutko expects "OK" on success
} else {
header("HTTP/1.0 400 Bad Request");
echo "Error: Approved but invalid details"; // Or a more generic OK to stop retries
}
break;
case 'declined':
echo "Order declined";
break;
case 'expired':
break;
case 'processing':
// Potentially a specific "processing" status, or leave as is.
echo "Order processing";
break;
default:
echo "Unexpected status";
break;
}
exit;
}
}