From 7c7cb718acb1d0f542f98c6006eafd9d400fb936 Mon Sep 17 00:00:00 2001 From: panariga Date: Mon, 9 Mar 2026 14:22:39 +0200 Subject: [PATCH] Add core/components/minishop2/custom/payment/hutko.class.php --- .../minishop2/custom/payment/hutko.class.php | 283 ++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 core/components/minishop2/custom/payment/hutko.class.php diff --git a/core/components/minishop2/custom/payment/hutko.class.php b/core/components/minishop2/custom/payment/hutko.class.php new file mode 100644 index 0000000..3eb3468 --- /dev/null +++ b/core/components/minishop2/custom/payment/hutko.class.php @@ -0,0 +1,283 @@ +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; + } +}