$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 "). // 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)); 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; } }