diff --git a/modules/gateways/hutko/hutko_helper.php b/modules/gateways/hutko/hutko_helper.php new file mode 100644 index 0000000..d3209fe --- /dev/null +++ b/modules/gateways/hutko/hutko_helper.php @@ -0,0 +1,329 @@ + $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. + $data = [ + "cms_name" => "WHMCS", + "cms_version" => $params['whmcsVersion'], + "shop_domain" => $params['systemurl'], + "phonemobile" => $params['clientdetails']['phonenumber'], + "customer_address" => self::getSlug($params['clientdetails']['address1']), + "customer_country" => self::getSlug($params['clientdetails']['country']), + "customer_state" => self::getSlug($params['clientdetails']['state']), + "customer_name" => self::getSlug($params['clientdetails']['lastname'] . ' ' . $params['clientdetails']['firstname']), + "customer_city" => self::getSlug($params['clientdetails']['city']), + "customer_zip" => $params['clientdetails']['postcode'], + "account" => $params['clientdetails']['email'], + "uuid" => hash('sha256', $params['clientdetails']['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) { + \logTransaction($gatewayParams['name'] . ' [callback]', $calbackContent, 'Invalid hutko signature'); + + throw new Exception('Invalid hutko signature'); + } + + return $calbackContent; + } +}