Add core/components/minishop2/custom/payment/hutko.class.php
This commit is contained in:
283
core/components/minishop2/custom/payment/hutko.class.php
Normal file
283
core/components/minishop2/custom/payment/hutko.class.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user