Files
hutko-whmcs-module/modules/gateways/hutko/hutko_helper.php

340 lines
13 KiB
PHP

<?php
class Hutko_Helper
{
const ORDER_APPROVED = 'approved';
const ORDER_DECLINED = 'declined';
const ORDER_SEPARATOR = '#';
const SIGNATURE_SEPARATOR = '|';
const REDIRECT_URL = 'https://pay.hutko.org/api/checkout/redirect/';
const REFUND_URL = 'https://pay.hutko.org/api/reverse/order_id';
/**
* Initiates a refund (reverse) request via Hutko API.
*
* @param string $order_id The gateway's order ID to refund.
* @param float $amount The amount to refund (in base units, e.g., 100.50).
* @param string $currency The currency code (e.g., 'UAH').
* @param string $comment Optional comment for the refund.
* @return array Decoded API response array. Returns an error structure on failure.
*/
public static function refund(array $params): array
{
// 1. Prepare the data payload
$data = [
'order_id' => $params['transid'],
// Assuming Configuration::get is available to fetch the merchant ID
'merchant_id' => $params['accountID'],
'version' => '1.0',
// Amount should be in minor units (cents, kopecks) and converted to string as per API example
'amount' => round($params['amount'] * 100),
'currency' => $params['currency'],
];
if (isset($params['description']) && !empty($params['description'])) {
$data['comment'] = 'Return ' . $params['description'];
}
// 2. Calculate the signature based on the data array *before* wrapping in 'request'
$data['signature'] = self::getSignature($data, $params['secretKey']);
return self::sendAPICall(self::REFUND_URL, $data);
}
/**
* Initiates a request via Hutko API.
*
* @param string $url The gateway's url.
* @param array $data The data.
* @return array Decoded API response array. Returns an error structure on failure.
*/
public static function sendAPICall(string $url, array $data, int $timeout = 60): array
{
// Wrap the prepared data inside the 'request' key as required by the API
$requestPayload = ['request' => $data];
// Convert the payload to JSON string
$jsonPayload = json_encode($requestPayload);
if ($jsonPayload === false) {
// Handle JSON encoding error
return [
'response' => [
'response_status' => 'failure',
'error_message' => 'Failed to encode request data to JSON: ' . json_last_error_msg(),
'error_code' => 'JSON_ENCODE_ERROR'
]
];
}
// Initialize CURL
$ch = curl_init();
// 4. Set CURL options
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true); // Use POST method
curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonPayload); // Set the JSON body
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // Return the response as a string
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Content-Length: ' . strlen($jsonPayload), // Good practice
]);
// Recommended for production: Verify SSL certificate
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); // Verify hostname against certificate
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); // Timeout in seconds
// Execute the CURL request
$response = curl_exec($ch);
// Check for CURL errors
$curl_error = curl_error($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($curl_error) {
// Log the error or handle it appropriately
return [
'response' => [
'response_status' => 'failure',
'error_message' => 'CURL Error: ' . $curl_error,
'error_code' => 'CURL_' . curl_errno($ch),
'http_code' => $http_code // Include http code for context
]
];
}
// Process the response
// Decode the JSON response into a PHP array
$responseData = json_decode($response, true);
// Check if JSON decoding failed
if (json_last_error() !== JSON_ERROR_NONE) {
// Log the error or handle it appropriately
return [
'response' => [
'response_status' => 'failure',
'error_message' => 'Invalid JSON response from API: ' . json_last_error_msg(),
'error_code' => 'JSON_DECODE_ERROR',
'http_code' => $http_code,
'raw_response' => $response // Include raw response for debugging
]
];
}
return $responseData;
}
/**
* Validates the signature of a payment gateway response.
*
* This method verifies that the received response originates from the expected merchant
* and that the signature matches the calculated signature based on the response data
* and the merchant's secret key.
*
* @param array $response An associative array containing the payment gateway's response data.
* This array is expected to include keys 'merchant_id' and 'signature'.
* It might also contain temporary signature-related keys that will be unset
* during the validation process.
* @return bool True if the response is valid (merchant ID matches and signature is correct),
* false otherwise.
*/
public static function validateResponse(array $response, array $gatewayParams): bool
{
// 1. Verify the Merchant ID
if ((string)$gatewayParams['accountID'] !== (string)$response['merchant_id']) {
return false;
}
// 2. Prepare Response Data for Signature Verification
$responseSignature = $response['signature'];
// Unset signature-related keys that should not be part of the signature calculation.
// This ensures consistency with how the signature was originally generated.
if (isset($response['response_signature_string'])) unset($response['response_signature_string']);
if (isset($response['signature'])) unset($response['signature']);
// FIX: WHMCS sanitizes $_POST (converts " to &quot;).
// We must revert this to get the raw JSON for valid signature calculation.
foreach ($response as $k => $v) {
if (is_string($v)) {
$response[$k] = html_entity_decode($v, ENT_QUOTES);
}
}
// 3. Calculate and Compare Signatures
$calculatedSignature = self::getSignature($response, $gatewayParams['secretKey']);
return hash_equals($calculatedSignature, $responseSignature);
}
/**
* Generates a URL-friendly slug from a given text.
*
* This method transliterates non-ASCII characters to their closest ASCII equivalents,
* removes any characters that are not alphanumeric or spaces, trims leading/trailing
* spaces, optionally replaces spaces with hyphens, and optionally converts the
* entire string to lowercase.
*
* @param string $text The input string to convert into a slug.
* @param bool $removeSpaces Optional. Whether to replace spaces with hyphens (true) or keep them (false). Defaults to false.
* @param bool $lowerCase Optional. Whether to convert the resulting slug to lowercase (true) or keep the original casing (false). Defaults to false.
* @return string The generated slug.
*/
public static function getSlug(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;
}
/**
* Generates a signature based on the provided data and a secret password.
*
* This method filters out empty and null values from the input data, sorts the remaining
* data alphabetically by key, concatenates the values with a pipe delimiter, prepends
* the secret password, and then generates a SHA1 hash of the resulting string.
*
* @param array $data An associative array of data to be included in the signature generation.
* Empty strings and null values in this array will be excluded.
* @param string $password The secret key used to generate the signature. This should be
* kept confidential.
* @param bool $encoded Optional. Whether to return the SHA1 encoded signature (true by default)
* or the raw string before encoding (false).
* @return string The generated signature (SHA1 hash by default) or the raw string.
*/
public static function getSignature(array $data, string $secretKey, bool $encoded = true): string
{
if (!$secretKey || empty($secretKey)) {
throw new Exception('Merchant secret not set');
}
// 1. Filter out empty and null values from the data array.
$filteredData = array_filter($data, function ($value) {
return $value !== '' && $value !== null;
});
// 2. Sort the filtered data array alphabetically by key.
ksort($filteredData);
// 3. Construct the string to be hashed. Start with the password.
$stringToHash = $secretKey;
// 4. Append the values from the sorted data array, separated by a pipe.
foreach ($filteredData as $value) {
$stringToHash .= self::SIGNATURE_SEPARATOR . $value;
}
// 5. Return the SHA1 hash of the string or the raw string based on the $encoded flag.
if ($encoded) {
return sha1($stringToHash);
} else {
return $stringToHash;
}
}
/**
* Builds a base64 encoded JSON string containing reservation-related data.
*
* This method gathers information about the current cart, customer's delivery
* address, shop details, and products in the cart to create an array. This
* array is then encoded as a JSON string and subsequently base64 encoded
* for transmission or storage.
*
* @return string A base64 encoded JSON string containing the reservation data.
*/
public static function buildReservationData(array $params): string
{
// 3. Construct the data array.
$phone = isset($params['clientdetails']['phonenumber']) ? $params['clientdetails']['phonenumber'] : '';
$addr1 = isset($params['clientdetails']['address1']) ? $params['clientdetails']['address1'] : '';
$country = isset($params['clientdetails']['country']) ? $params['clientdetails']['country'] : '';
$state = isset($params['clientdetails']['state']) ? $params['clientdetails']['state'] : '';
$city = isset($params['clientdetails']['city']) ? $params['clientdetails']['city'] : '';
$zip = isset($params['clientdetails']['postcode']) ? $params['clientdetails']['postcode'] : '';
$last = isset($params['clientdetails']['lastname']) ? $params['clientdetails']['lastname'] : '';
$first = isset($params['clientdetails']['firstname']) ? $params['clientdetails']['firstname'] : '';
$email = isset($params['clientdetails']['email']) ? $params['clientdetails']['email'] : '';
$data = array(
"cms_name" => "WHMCS",
"cms_version" => $params['whmcsVersion'],
"shop_domain" => $params['systemurl'],
"phonemobile" => $phone,
"customer_address" => self::getSlug($addr1),
"customer_country" => self::getSlug($country),
"customer_state" => self::getSlug($state),
"customer_name" => self::getSlug($last . ' ' . $first),
"customer_city" => self::getSlug($city),
"customer_zip" => $zip,
"account" => $email,
"uuid" => hash('sha256', $email . '|' . $params['systemurl']),
);
return base64_encode(json_encode($data));
}
/**
* Helper method to parse the request body from raw input.
*
* @return array The parsed request body.
*/
public static function getCallbackContent($gatewayParams): array
{
if (!empty($_POST)) {
$calbackContent = $_POST;
} else {
$calbackContent = json_decode(file_get_contents("php://input"), true);
}
if (!is_array($calbackContent) || !count($calbackContent)) {
throw new Exception('Empty or malformed request');
}
// Assuming validateResponse returns true on success, or a string error message on failure.
$isSignatureValid = self::validateResponse($calbackContent, $gatewayParams);
if ($isSignatureValid !== true) {
if (function_exists('logTransaction')) {
logTransaction($gatewayParams['name'] . ' [callback]', $calbackContent, 'Invalid hutko signature');
}
throw new Exception('Invalid hutko signature');
}
return $calbackContent;
}
}