<?php
declare(strict_types=1);
namespace Bdm\CheckoutBundle\Service\PaymentGateway\Paynovate;
use Bdm\CheckoutBundle\Entity\PaynovatePayment;
use Bdm\CheckoutBundle\Entity\Transaction;
use Bdm\CheckoutBundle\Repository\OrderRepository;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Psr7\Response as GuzzleResponse;
use Symfony\Bundle\FrameworkBundle\Routing\Router;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Psr\Log\LoggerInterface;
use Monolog\Logger;
/**
* Class PaynovateClient
*/
class PaynovateClient
{
/**
* @var Client $oGuzzle
*/
protected $oGuzzle;
/**
* @var OrderRepository $oOrderRepository
*/
protected $oOrderRepository;
/**
* @var string $sWebhookUrl
*/
protected $sWebhookUrl;
/**
* @var string $sMerchantId
*/
protected $sMerchantId;
/**
* @var string $sSignatureKey
*/
protected $sSignatureKey;
/**
* @var string $sIntegrationUrl
*/
protected $sIntegrationUrl;
/**
* @var string $sDirectEndpoint
*/
protected $sDirectEndpoint;
/**
* @var array $aCountryCode
*/
protected $aCountryCode;
/**
* @var array $aCurrencyCode
*/
protected $aCurrencyCode;
/**
* @var LoggerInterface
*/
protected $oLogger;
/**
* @param OrderRepository $oOrderRepository oOrderRepository
* @param Router $oRouter oRouter
* @param array $aCountryCode aCountryCode
* @param array $aCurrencyCode aCurrencyCode
* @param string $sPaynovatePathIpn sPaynovatePathIpn
* @param string $sMerchantId sMerchantId
* @param string $sSignatureKey sSignatureKey
* @param string $sIntegrationUrl sIntegrationUrl
* @param boolean $bLocalEnv bLocalEnv
* @param LoggerInterface $oLogger logger
*/
public function __construct(
OrderRepository $oOrderRepository,
Router $oRouter,
$aCountryCode,
$aCurrencyCode,
$sPaynovatePathIpn,
$sMerchantId,
$sSignatureKey,
$sIntegrationUrl,
$bLocalEnv = true,
LoggerInterface $oLogger
) {
$this->oGuzzle = new Client([
'verify' => true,
'allow_redirects' => false,
'http_errors' => false,
]);
$this->oOrderRepository = $oOrderRepository;
$this->aCountryCode = $aCountryCode;
$this->aCurrencyCode = $aCurrencyCode;
$this->sMerchantId = $sMerchantId;
$this->sSignatureKey = $sSignatureKey;
$this->sIntegrationUrl = rtrim($sIntegrationUrl, '/');
$this->sDirectEndpoint = $this->sIntegrationUrl . '/direct/';
$this->oLogger = $oLogger;
if ($bLocalEnv) {
$this->sWebhookUrl = $sPaynovatePathIpn.'/api/v1/ipn/paynovate';
} else {
$this->sWebhookUrl = $oRouter->generate(
'bdm_checkout_api_paynovate_ipn',
[],
UrlGeneratorInterface::ABSOLUTE_URL
);
}
}
/**
* Generate SHA512 signature for Paynovate API
*
* @param string|array $sQueryStringOrData URL-encoded query string OR data array for REST API
* @param string|null $sTimestamp Optional timestamp for REST API
* @return string
*/
protected function generateSignature($sQueryStringOrData, $sTimestamp = null)
{
if (is_array($sQueryStringOrData)) {
$aData = $sQueryStringOrData;
unset($aData['signature']);
ksort($aData);
$sSignatureString = '';
foreach ($aData as $sKey => $sValue) {
// Gérer les valeurs qui sont des tableaux (sous-tableaux dans callback)
if (is_array($sValue)) {
$sValue = json_encode($sValue);
}
$sSignatureString .= $sKey . $sValue;
}
$sSignatureString .= $this->sSignatureKey;
return hash('sha512', $sSignatureString);
}
return hash('sha512', $sQueryStringOrData . $this->sSignatureKey);
}
/**
* Build URL-encoded query string from sorted data array
*
* @param array $aData request data
* @return string
*/
protected function buildQueryString(array $aData)
{
ksort($aData);
$aPairs = [];
foreach ($aData as $sKey => $sValue) {
$aPairs[] = urlencode((string)$sKey) . '=' . urlencode((string)$sValue);
}
return implode('&', $aPairs);
}
/**
* @param Transaction $oTransaction oTransaction
* @param string $sUrl sUrl
* @return mixed
* @throws \Exception
*/
public function createPayment(Transaction $oTransaction, $sUrl, $sIpnUrl = null)
{
$oCard = $oTransaction->getPaymentMethod();
$oOrder = $this->oOrderRepository->findById($oTransaction->getPayment()->getToken()->getEntityId());
$oOrderCustomer = $oOrder->getOrderCustomer();
if ($oOrderCustomer === null) {
throw new \Exception('Order customer not found');
}
$sExpYear = (string)$oCard->getExpYear();
$sFormatYear = substr(str_pad($sExpYear, 2, '0', STR_PAD_LEFT), -2);
$sMerchantReference = $oOrder->getReference().$oTransaction->getReference();
$sTransactionUnique = bin2hex(random_bytes(8));
$sBillingAddress = trim($oOrderCustomer->getAddressLine1() . ' ' . $oOrderCustomer->getAddressLine2());
$aData = [
'merchantID' => $this->sMerchantId,
'action' => 'SALE',
'type' => '1',
'currencyCode' => $this->getCurrencyNumericCode($oTransaction->getCurrencyIsoCode()),
'countryCode' => $this->getCountryNumericCode($oOrderCustomer->getCountryCode()),
'amount' => (int)($oTransaction->getAmount() * 100), // Montant en centimes
'orderRef' => $sMerchantReference,
'transactionUnique' => $sTransactionUnique,
'cardNumber' => $oCard->getNumber(),
'cardExpiryMonth' => str_pad((string) $oCard->getExpMonth(), 2, "0", STR_PAD_LEFT),
'cardExpiryYear' => $sFormatYear,
'cardCVV' => $oCard->getCvc(),
'avscv2CheckRequired' => 'N',
'duplicateDelay' => '300', //5 minutes (duplaicate order ref)
];
$oTransaction->set3DS(true);
if($oTransaction->get3DS() === true) {
$aData['threeDSRequired'] = 'Y';
$aData['threeDSRedirectURL'] = $sUrl;
$aData['redirectURL'] = $sUrl; // Redirect URL for user's browser
if ($sIpnUrl) {
$aData['callbackURL'] = $sIpnUrl;
$this->oLogger->log(Logger::INFO, '[createPayment] Using IPN webhook URL: ' . $sIpnUrl);
}
// Device information (required for 3DS 2.0)
$aData['deviceChannel'] = 'browser';
$aData['deviceIdentity'] = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : 'Mozilla/5.0';
$aData['remoteAddress'] = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '127.0.0.1';
$aData['deviceTimeZone'] = '0';
$aData['deviceCapabilities'] = 'javascript';
$aData['deviceScreenResolution'] = '1920x1080x24';
$aData['deviceAcceptContent'] = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8';
$aData['deviceAcceptEncoding'] = 'gzip, deflate, br';
$aData['deviceAcceptLanguage'] = 'en-US,en;q=0.9';
} else {
$aData['threeDSRequired'] = 'N';
$aData['redirectURL'] = $sUrl; // Redirect URL for user's browser
if ($sIpnUrl) {
$aData['callbackURL'] = $sIpnUrl;
$this->oLogger->log(Logger::INFO, '[createPayment] Using IPN webhook URL: ' . $sIpnUrl);
}
}
if ($oOrderCustomer->getEmail()) {
$aData['customerEmail'] = $oOrderCustomer->getEmail();
}
if ($sBillingAddress) {
$aData['customerAddress'] = $sBillingAddress;
}
if ($oOrderCustomer->getCity()) {
$aData['customerTown'] = substr($oOrderCustomer->getCity(), 0, 50);
}
if ($oOrderCustomer->getPostalCode()) {
$aData['customerPostcode'] = $oOrderCustomer->getPostalCode();
}
$sQueryString = $this->buildQueryString($aData);
$sSignature = $this->generateSignature($sQueryString);
$sPostData = $sQueryString . '&signature=' . $sSignature;
$aOptions = [
'headers' => [
'Content-Type' => 'application/x-www-form-urlencoded',
],
'body' => $sPostData,
];
$aLogPayload = $aData;
if (isset($aLogPayload['cardNumber'])) {
$sValue = (string) $aLogPayload['cardNumber'];
$iLength = strlen($sValue);
$aLogPayload['cardNumber'] = ($iLength > 4 ? str_repeat('X', $iLength - 4) : '') . substr($sValue, -4);
}
if (isset($aLogPayload['cardCVV'])) {
$aLogPayload['cardCVV'] = '***';
}
$this->oLogger->info('[createPayment] Sending request to Paynovate', [
'endpoint' => $this->sDirectEndpoint,
'payload' => $aLogPayload,
]);
try {
$oRequest = $this->oGuzzle->request('POST', $this->sDirectEndpoint, $aOptions);
$sResponseBody = $oRequest->getBody()->getContents();
$iStatusCode = $oRequest->getStatusCode();
$aHeaders = [];
foreach ($oRequest->getHeaders() as $sName => $aValues) {
$aHeaders[$sName] = implode(', ', $aValues);
}
$this->oLogger->info('[createPayment] Received response from Paynovate', [
'endpoint' => $this->sDirectEndpoint,
'status_code' => $iStatusCode,
'headers' => $aHeaders,
'body' => $sResponseBody,
]);
if ($iStatusCode == 302 && $oRequest->hasHeader('Location')) {
$sLocationUrl = $oRequest->getHeader('Location')[0];
$this->oLogger->info('[createPayment] Detected 302 redirect', [
'location' => $sLocationUrl
]);
$aParsedUrl = parse_url($sLocationUrl);
if (!empty($aParsedUrl['query'])) {
parse_str($aParsedUrl['query'], $aLocationParams);
$sNewBody = http_build_query($aLocationParams);
$this->oLogger->info('[createPayment] Extracted params from Location header', [
'params' => $aLocationParams
]);
return new GuzzleResponse(
200,
['Content-Type' => 'application/x-www-form-urlencoded'],
$sNewBody
);
}
}
if (in_array($iStatusCode, [Response::HTTP_OK, Response::HTTP_ACCEPTED, Response::HTTP_CREATED])) {
return $oRequest;
}
throw new \Exception('payment error');
} catch (ClientException $oException) {
$sResponseBody = $oException->getResponse()->getBody()->getContents();
parse_str($sResponseBody, $aResponseData);
$this->oLogger->error('[createPayment] Error response from Paynovate', [
'endpoint' => $this->sIntegrationUrl,
'status_code' => $oException->getResponse()->getStatusCode(),
'body' => $sResponseBody,
'payload' => $aLogPayload,
]);
if (isset($aResponseData['responseCode'])) {
$oTransaction->setTransactionErrorCode(PaymentService::PREFIX_ERROR_CODE.$aResponseData['responseCode']);
}
$sErrorMsg = isset($aResponseData['responseMessage']) ? $aResponseData['responseMessage'] : 'payment error';
$iErrorCode = isset($aResponseData['responseCode']) ? (int)$aResponseData['responseCode'] : 0;
throw new \Bdm\CoreBundle\Exception\PaymentGateway\Paynovate\PaynovatePaymentGatewayRejectionException(
$iErrorCode,
$oException->getResponse()->getStatusCode(),
$oException,
'Paynovate error: ' . $sErrorMsg . ' (Code: ' . ($aResponseData['responseCode'] ?? 'unknown') . ')'
);
}
}
/**
* @param Transaction $oTransaction oTransaction
* @param PaynovatePayment $oPaynovatePayment oPaynovatePayment
* @return \Psr\Http\Message\ResponseInterface|null
* @throws \Exception
*/
public function refundTransaction(Transaction $oTransaction, PaynovatePayment $oPaynovatePayment)
{
$oOrder = $this->oOrderRepository->findById($oTransaction->getPayment()->getToken()->getEntityId());
$sTransactionUnique = strtoupper(bin2hex(random_bytes(8)));
$sOrderRef = 'Refund ' . $oOrder->getReference() . '-' . gmdate('Ymd-His');
$aParams = [
'merchantID' => $this->sMerchantId,
'action' => 'REFUND_SALE',
'xref' => $oPaynovatePayment->getXref(),
'amount' => (int)($oTransaction->getAmount() * 100),
'orderRef' => $sOrderRef,
'transactionUnique' => $sTransactionUnique,
'duplicateDelay' => '0',
'merchantData' => $sTransactionUnique,
'callbackURL' => $this->sWebhookUrl,
];
$sQueryString = $this->buildQueryString($aParams);
$sSignature = $this->generateSignature($sQueryString);
$sPostBody = $sQueryString . '&signature=' . $sSignature;
$aOptions = [
'body' => $sPostBody,
'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
'allow_redirects' => false,
];
try {
$sDirectUrl = rtrim($this->sIntegrationUrl, '/') . '/direct/';
$this->oLogger->info('[refundTransaction] Sending REFUND_SALE request', [
'endpoint' => $sDirectUrl,
'payload' => $aParams,
'signature' => substr($sSignature, 0, 20) . '...',
]);
$oRequest = $this->oGuzzle->request('POST', $sDirectUrl, $aOptions);
// Handle 302 redirects (response in Location header query params)
if ($oRequest->getStatusCode() === 302) {
$sLocation = $oRequest->getHeader('Location')[0] ?? '';
$aParsedUrl = parse_url($sLocation);
if (isset($aParsedUrl['query'])) {
$oRequest = new \GuzzleHttp\Psr7\Response(200, [], $aParsedUrl['query']);
}
}
$oRequest->getBody()->rewind();
$sResponseBody = $oRequest->getBody()->getContents();
$oRequest->getBody()->rewind();
$this->oLogger->info('[refundTransaction] Received response', [
'status_code' => $oRequest->getStatusCode(),
'headers' => $oRequest->getHeaders(),
'body' => $sResponseBody,
]);
} catch (\Exception $oException) {
$this->oLogger->error('[refundTransaction] Error calling Paynovate', [
'message' => $oException->getMessage(),
]);
$oRequest = null;
}
return $oRequest;
}
/**
* @param Transaction $oTransaction Transaction
* @param PaynovatePayment $oPaynovatePayment oPaynovatePayment
* @return \Psr\Http\Message\ResponseInterface|null
* @throws \Exception
*/
public function cancelTransaction(Transaction $oTransaction, PaynovatePayment $oPaynovatePayment)
{
$sXref = $oPaynovatePayment->getXref();
if (empty($sXref)) {
throw new \Exception('Cannot cancel transaction: xref is missing');
}
$aParams = [
'merchantID' => $this->sMerchantId,
'action' => 'CANCEL',
'xref' => $sXref,
];
$sQueryString = $this->buildQueryString($aParams);
$sSignature = $this->generateSignature($sQueryString);
$sPostData = $sQueryString . '&signature=' . $sSignature;
$aOptions = [
'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
'body' => $sPostData,
];
$this->oLogger->info('[cancelTransaction] Sending CANCEL request', [
'endpoint' => $this->sDirectEndpoint,
'payload' => $aParams,
'signature' => substr($sSignature, 0, 20) . '...',
]);
try {
$oRequest = $this->oGuzzle->request('POST', $this->sDirectEndpoint, $aOptions);
$oRequest->getBody()->rewind();
$sResponseBody = $oRequest->getBody()->getContents();
$oRequest->getBody()->rewind();
$this->oLogger->info('[cancelTransaction] Received response', [
'status_code' => $oRequest->getStatusCode(),
'headers' => $oRequest->getHeaders(),
'body' => $sResponseBody,
]);
} catch (\Exception $exception) {
$this->oLogger->error('[cancelTransaction] Error calling Paynovate', [
'message' => $exception->getMessage(),
]);
throw $exception;
}
return $oRequest;
}
/**
* Continue 3DS authentication flow
* Called after the 3DS "method" callback to continue the authentication
*
* @param string $sThreeDSRef The threeDSRef from initial payment response
* @return \Psr\Http\Message\ResponseInterface
* @throws \Exception
*/
public function continue3DS($sThreeDSRef, array $aAdditionalData = [])
{
$this->oLogger->info('[continue3DS] Starting 3DS continuation', [
'threeDSRef' => substr($sThreeDSRef, 0, 20) . '...',
'has_additional_data' => !empty($aAdditionalData)
]);
// Build parameters for continue3DS request
$aData = [
'merchantID' => $this->sMerchantId,
'threeDSRef' => $sThreeDSRef,
];
// Add additional data (like threeDSResponse)
if (!empty($aAdditionalData)) {
$aData = array_merge($aData, $aAdditionalData);
$this->oLogger->info('[continue3DS] Merged additional data', [
'data_keys' => array_keys($aAdditionalData),
'full_data' => json_encode($aData)
]);
}
// Build query string
// Use http_build_query for nested arrays (like threeDSResponse)
// Sort by keys first (required for signature)
ksort($aData);
$sQueryString = http_build_query($aData, '', '&', PHP_QUERY_RFC3986);
$this->oLogger->info('[continue3DS] Built query string', [
'query_string' => substr($sQueryString, 0, 500) . '...'
]);
// Generate signature
$sSignature = $this->generateSignature($sQueryString);
// Add signature to query string
$sPostData = $sQueryString . '&signature=' . $sSignature;
$aOptions = [
'headers' => [
'Content-Type' => 'application/x-www-form-urlencoded',
],
'body' => $sPostData,
];
$this->oLogger->info('[continue3DS] Sending request to Paynovate', [
'endpoint' => $this->sDirectEndpoint,
'payload' => $aData,
'signature' => substr($sSignature, 0, 20) . '...',
]);
try {
$oRequest = $this->oGuzzle->request('POST', $this->sDirectEndpoint, $aOptions);
$sResponseBody = $oRequest->getBody()->getContents();
$iStatusCode = $oRequest->getStatusCode();
// Log response headers for debugging
$aHeaders = [];
foreach ($oRequest->getHeaders() as $sName => $aValues) {
$aHeaders[$sName] = implode(', ', $aValues);
}
$this->oLogger->info('[continue3DS] Received response from Paynovate', [
'endpoint' => $this->sDirectEndpoint,
'status_code' => $iStatusCode,
'headers' => $aHeaders,
'body' => $sResponseBody,
]);
// IMPORTANT: Paynovate returns 3DS parameters in the Location header of a 302 response
if ($iStatusCode == 302 && $oRequest->hasHeader('Location')) {
$sLocationUrl = $oRequest->getHeader('Location')[0];
$this->oLogger->info('[continue3DS] Detected 302 redirect', [
'location' => $sLocationUrl
]);
// Parse the query string from the Location header
$aParsedUrl = parse_url($sLocationUrl);
if (!empty($aParsedUrl['query'])) {
parse_str($aParsedUrl['query'], $aLocationParams);
// Create a new response with the parameters in the body as form-urlencoded
$sNewBody = http_build_query($aLocationParams);
$this->oLogger->info('[continue3DS] Extracted params from Location header', [
'params' => $aLocationParams
]);
// Return a new response object with the extracted parameters in the body
return new GuzzleResponse(
200, // Change status to 200 so PaymentService treats it as success
['Content-Type' => 'application/x-www-form-urlencoded'],
$sNewBody
);
}
}
if (in_array($iStatusCode, [Response::HTTP_OK, Response::HTTP_ACCEPTED, Response::HTTP_CREATED])) {
return $oRequest;
}
throw new \Exception('continue3DS error: unexpected status code ' . $iStatusCode);
} catch (ClientException $oException) {
$sResponseBody = $oException->getResponse()->getBody()->getContents();
$this->oLogger->error('[continue3DS] Error response from Paynovate', [
'endpoint' => $this->sDirectEndpoint,
'status_code' => $oException->getResponse()->getStatusCode(),
'body' => $sResponseBody,
]);
throw new \Exception('Paynovate continue3DS error: ' . $sResponseBody);
}
}
/**
* Get Payment informations
* @param PaynovatePayment $oPaynovatePayment $oPaynovatePayment
* @return \Psr\Http\Message\ResponseInterface
* @throws \Exception
*/
public function getPaymentInformation(PaynovatePayment $oPaynovatePayment)
{
$aParams = [
'merchantID' => $this->sMerchantId,
'action' => 'QUERY',
'xref' => $oPaynovatePayment->getXref(),
];
// Build query string for signature (same as other methods)
$sQueryString = $this->buildQueryString($aParams);
$sSignature = $this->generateSignature($sQueryString);
$sPostData = $sQueryString . '&signature=' . $sSignature;
$aOptions = [
'headers' => [
'Content-Type' => 'application/x-www-form-urlencoded',
],
'body' => $sPostData,
];
$this->oLogger->info('[getPaymentInformation] Sending request to Paynovate', [
'endpoint' => $this->sIntegrationUrl . '/direct/',
'xref' => $oPaynovatePayment->getXref(),
]);
$oRequest = $this->oGuzzle->request('POST', $this->sIntegrationUrl.'/direct/', $aOptions);
$sResponseBody = $oRequest->getBody()->getContents();
$this->oLogger->info('[getPaymentInformation] Received response from Paynovate', [
'endpoint' => $this->sIntegrationUrl . '/direct/',
'status_code' => $oRequest->getStatusCode(),
'body' => $sResponseBody,
]);
$oRequest->getBody()->rewind();
if ($oRequest->getStatusCode() == Response::HTTP_OK) {
return $oRequest;
}
throw new \Exception('error_paynovate_get_transaction_information');
}
/**
* Get payment information
* @param Transaction $oTransaction transaction
* @return array
* @throws \Exception
*/
protected function getCardType(Transaction $oTransaction)
{
$oCard = $oTransaction->getPaymentMethod();
switch (substr($oCard->getNumber(), 0, 1)) {
case PaynovatePayment::CARD_MASTERCARD_INT:
$aType['label'] = 'MASTERCARD';
$aType['code'] = PaynovatePayment::CARD_MASTERCARD_INT;
break;
case PaynovatePayment::CARD_VISA_INT:
$aType['label'] = 'VISA';
$aType['code'] = PaynovatePayment::CARD_VISA_INT;
break;
default:
throw new \Exception();
}
return $aType;
}
/**
* Get iso3 code
* @param string $sIso2 iso2 code
* @return int|mixed|string
* @throws \Exception
*/
private function getIso3Code($sIso2Code)
{
if (strlen($sIso2Code) === 3) {
return $sIso2Code;
}
$aCountryCode = array_flip($this->aCountryCode);
if (isset($aCountryCode[$sIso2Code])) {
return $aCountryCode[$sIso2Code];
}
throw new \Exception('Bad ISO code');
}
/**
* Get numeric country code for Direct API
* @param string $sIso2Code ISO2 country code
* @return string
*/
/**
* Get currency numeric code from ISO3 code
*
* @param string $sIso3Code ISO3 currency code (EUR, GBP, USD, etc.)
* @return string Numeric currency code (978, 826, 840, etc.)
*/
private function getCurrencyNumericCode($sIso3Code)
{
// Use injected currency codes from parameters.yml
if (isset($this->aCurrencyCode[$sIso3Code])) {
return (string) $this->aCurrencyCode[$sIso3Code];
}
// Fallback par défaut: EUR
$this->oLogger->warning('[getCurrencyNumericCode] Unknown currency: ' . $sIso3Code . ', using EUR (978) as fallback');
return '978';
}
/**
* Get country numeric code from ISO2 code
*
* @param string $sIso2Code ISO2 country code (FR, GB, US, etc.)
* @return string Numeric country code (250, 826, 840, etc.)
*/
private function getCountryNumericCode($sIso2Code)
{
// Map ISO2 to numeric codes (ISO 3166-1 numeric)
$aNumericCodes = [
'FR' => '250', // France
'GB' => '826', // UK
'US' => '840', // USA
'DE' => '276', // Germany
'ES' => '724', // Spain
'IT' => '380', // Italy
'BE' => '056', // Belgium
'NL' => '528', // Netherlands
// Ajouter d'autres pays si nécessaire
];
return $aNumericCodes[$sIso2Code] ?? '250'; // Par défaut FR
}
/**
* Verify webhook signature
*
* @param array $aData webhook data
* @param string $sReceivedSignature received signature
* @return bool
*/
public function verifyWebhookSignature(array $aData, $sReceivedSignature)
{
$aDataCopy = $aData;
unset($aDataCopy['signature']);
ksort($aDataCopy);
$sQueryString = http_build_query($aDataCopy, '', '&');
$sCalculatedSignature = hash('SHA512', $sQueryString . $this->sSignatureKey);
return hash_equals($sCalculatedSignature, $sReceivedSignature);
}
}