401 lines
13 KiB
PHP
401 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Lib\Axepta;
|
|
|
|
use App\Lib\Axepta\Objects\AxeptaOrder;
|
|
use Carbon\Carbon;
|
|
use Carbon\CarbonInterface;
|
|
use GuzzleHttp\Client;
|
|
use GuzzleHttp\Exception\RequestException;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Str;
|
|
use Psr\Log\LoggerInterface;
|
|
|
|
class AxeptaPayment
|
|
{
|
|
private const int PERMITTED_TIMESTAMP_SKEW_IN_MINUTES = 5;
|
|
|
|
private const string USING_API_VERSION = 'v1';
|
|
|
|
/**
|
|
* Guzzle Client instance
|
|
*
|
|
* @var GuzzleHttp\Client
|
|
*/
|
|
private \GuzzleHttp\Client $client;
|
|
|
|
/**
|
|
* Additionnal or Update Axepta configuration
|
|
*
|
|
* @var array
|
|
*/
|
|
private array $axeptaCheckoutConfig;
|
|
|
|
/**
|
|
* AxeptaBearerToken cache key
|
|
*
|
|
* @var string
|
|
*/
|
|
private string $cacheKey = "AxeptaBearerToken";
|
|
|
|
/**
|
|
* Construct method
|
|
*
|
|
* @param string $endpoint Axepta Endpoint
|
|
* @param string $merchantId Axepta Merchant ID
|
|
* @param string $apiKey Axepta Secret API Key
|
|
* @param string $hmac Axepta HMAC
|
|
* @param array $axeptaConfig Additionnal or Update Axepta configuration
|
|
* @return void
|
|
*/
|
|
public function __construct(
|
|
private readonly LoggerInterface $logger,
|
|
#[\SensitiveParameter]
|
|
private string $endpoint,
|
|
#[\SensitiveParameter]
|
|
private string $merchantId,
|
|
#[\SensitiveParameter]
|
|
private string $apiKey,
|
|
#[\SensitiveParameter]
|
|
private string $hmac,
|
|
#[\SensitiveParameter]
|
|
private array $axeptaConfig = []
|
|
) {
|
|
$this->endpoint = $endpoint;
|
|
$this->merchantId = $merchantId;
|
|
$this->apiKey = $apiKey;
|
|
$this->hmac = $hmac;
|
|
$this->client = new Client([
|
|
'base_uri' => $this->endpoint,
|
|
'timeout' => 5.0,
|
|
]);
|
|
$this->axeptaCheckoutConfig = [
|
|
...[
|
|
"language" => "fr",
|
|
"amount" => [
|
|
"currency" => "EUR",
|
|
],
|
|
"captureMethod" => [
|
|
"type" => "AUTOMATIC"
|
|
],
|
|
"allowedPaymentMethods" => [
|
|
"CARD"
|
|
],
|
|
"paymentMethods" => [
|
|
"card" => [
|
|
"subType" => ["VISA", "MASTERCARD"],
|
|
],
|
|
]
|
|
],
|
|
...$axeptaConfig
|
|
];
|
|
}
|
|
|
|
/**
|
|
* getAccessToken method
|
|
* https://axeptabnpparibas-docs.redocly.app/authentication/authorizationservice
|
|
*
|
|
* @return string
|
|
* @throws \RuntimeException
|
|
*/
|
|
private function getAccessToken(): string
|
|
{
|
|
if (Cache::has($this->cacheKey)) {
|
|
return Cache::get($this->cacheKey);
|
|
}
|
|
|
|
try {
|
|
$response = $this->client->post('/authorization/oauth/token', [
|
|
'form_params' => [
|
|
'grant_type' => 'client_credentials',
|
|
'client_id' => $this->merchantId,
|
|
'client_secret' => $this->apiKey
|
|
]
|
|
]);
|
|
|
|
if ($response->getStatusCode() !== 200) {
|
|
throw new \RuntimeException('Token request failed');
|
|
}
|
|
|
|
$responseBody = json_decode($response->getBody()->getContents());
|
|
|
|
if (json_last_error() !== JSON_ERROR_NONE || !isset($responseBody->access_token) || !isset($responseBody->expires_in)) {
|
|
throw new \RuntimeException('Invalid token response');
|
|
}
|
|
|
|
Cache::put($this->cacheKey, $responseBody->access_token, $responseBody->expires_in);
|
|
|
|
return $responseBody->access_token;
|
|
} catch (RequestException $e) {
|
|
$errorBody = $e->hasResponse() ? $e->getResponse()->getBody()->getContents() : '{}';
|
|
$errorData = json_decode($errorBody, true);
|
|
$message = $errorData['error'] ?? $errorData['responseDescription'] ?? $e->getMessage();
|
|
throw new \RuntimeException("Axepta token error: {$message}", 0, $e);
|
|
} //end try
|
|
}
|
|
|
|
/**
|
|
* getCheckoutUrl method
|
|
*
|
|
* @param \App\Lib\Axepta\Objects\AxeptaOrder $order
|
|
* @param string $returnUrl
|
|
* @param string $cancelUrl
|
|
* @return string
|
|
* @throws \RuntimeException
|
|
*/
|
|
public function getCheckoutUrl(AxeptaOrder $order, string $returnUrl, string $cancelUrl, string $webhookUrl): string
|
|
{
|
|
$payload = [
|
|
"transId" => $this->generateTransId(),
|
|
"urls" => [
|
|
"return" => $returnUrl,
|
|
"cancel" => $cancelUrl,
|
|
"webhook" => $webhookUrl
|
|
],
|
|
"amount" => [
|
|
"value" => $order->amount
|
|
],
|
|
"order" => [
|
|
"merchantReference" => $order->reference,
|
|
"creationDate" => gmdate('c'),
|
|
// ISO 8601 now
|
|
],
|
|
"customerInfo" => [
|
|
"email" => $order->customerEmail,
|
|
]
|
|
];
|
|
|
|
try {
|
|
$response = $this->client->post('/api/v2/payments/sessions', [
|
|
'headers' => [
|
|
'Authorization' => sprintf(
|
|
"Bearer %s",
|
|
$this->getAccessToken()
|
|
),
|
|
'Content-Type' => 'application/json',
|
|
'Idempotency-Key' => (string) Str::uuid()
|
|
],
|
|
'json' => array_merge_recursive($payload, $this->axeptaCheckoutConfig)
|
|
// Automatically JSON encodes and sets Content-Type
|
|
]);
|
|
|
|
if ($response->getStatusCode() !== 202) {
|
|
throw new \RuntimeException('Session creation failed');
|
|
}
|
|
|
|
$responseBody = json_decode($response->getBody()->getContents());
|
|
|
|
if (!isset($responseBody->_links->redirect->href)) {
|
|
throw new \RuntimeException('Missing redirect URL in response');
|
|
}
|
|
|
|
return $responseBody->_links->redirect->href;
|
|
} catch (RequestException $e) {
|
|
throw new \RuntimeException("Payment session error: " . $e->getMessage(), 0, $e);
|
|
} //end try
|
|
}
|
|
|
|
/**
|
|
* generateTransId method
|
|
*
|
|
* @param integer $length
|
|
* @return string
|
|
*/
|
|
private function generateTransId(int $length = 64): string
|
|
{
|
|
$chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()_+-=[]{}|;:,.<>?';
|
|
$bytes = random_bytes($length);
|
|
$result = '';
|
|
for ($i = 0; $i < $length; $i++) {
|
|
$result .= $chars[ord($bytes[$i]) % strlen($chars)];
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
public function getPaymentStatusById(string $paymentId): string|null
|
|
{
|
|
try {
|
|
$response = $this->client->get(
|
|
sprintf(
|
|
'/api/v2/payments/getByPayId/%s',
|
|
$paymentId
|
|
),
|
|
[
|
|
'headers' => [
|
|
'Authorization' => sprintf(
|
|
"Bearer %s",
|
|
$this->getAccessToken()
|
|
),
|
|
'Content-Type' => 'application/json',
|
|
]
|
|
]
|
|
);
|
|
|
|
$responseBody = json_decode($response->getBody()->getContents());
|
|
return $responseBody->responseCode;
|
|
} catch (RequestException $e) {
|
|
if ($e->hasResponse()) {
|
|
$response = $e->getResponse();
|
|
$responseBody = json_decode($response->getBody()->getContents());
|
|
return $responseBody->responseCode;
|
|
} else {
|
|
return null;
|
|
}
|
|
} //end try
|
|
}
|
|
|
|
/**
|
|
* Check if is valid webhook access
|
|
*
|
|
* From Axepta 2.0 Doc
|
|
* https://axepta.computop.com/display/DOCBNP/Gestion+des+callbacks+et+notification+de+paiement
|
|
*
|
|
* X-Paygate-Signature-Version Signature format version (currently v1)
|
|
* X-Paygate-Timestamp Unix epoch timestamp (seconds since 1970-01-01T00:00:00Z, UTC), the timestamp is within ±5 minutes of your local clock.
|
|
* X-Paygate-Signature v1=<hex-hmac>
|
|
*
|
|
* @param \Illuminate\Http\Request $request
|
|
* @return boolean
|
|
*/
|
|
public function isValidWebhookAccess(Request $request): bool
|
|
{
|
|
$signatureVersion = $this->getHeaderValue($request, 'x-paygate-signature-version');
|
|
$signature = $this->getHeaderValue($request, 'x-paygate-signature');
|
|
$timestamp = $this->getHeaderValue($request, 'x-paygate-timestamp');
|
|
$timestamDatetime = Carbon::createFromTimestampUTC($timestamp);
|
|
$rawPayload = $request->getContent(false);
|
|
|
|
$this->logger->debug(
|
|
'Received an IPN call', [
|
|
'request_signature_version' => $signatureVersion,
|
|
'request_signature' => $signature,
|
|
'request_timestamp' => $timestamp,
|
|
'request_payload' => $rawPayload
|
|
]
|
|
);
|
|
|
|
return $this->isValidSignatureVersion($signatureVersion) &&
|
|
$this->requestTimestampIsAcceptable($timestamDatetime) &&
|
|
$this->isValidSignature($signature, $timestamDatetime, $rawPayload);
|
|
}
|
|
|
|
/** Get for sure a string value for a specific header name */
|
|
private function getHeaderValue(Request $request, string $headerName): string
|
|
{
|
|
$headerValue = Arr::wrap($request->header($headerName));
|
|
$headerValue = Arr::map($headerValue, fn($v) => is_scalar($v) ? strval($v) : '');
|
|
|
|
return Str::trim(Arr::join($headerValue, ' '));
|
|
}
|
|
|
|
|
|
/**
|
|
* Check signature version
|
|
*
|
|
* https://axepta.computop.com/display/DOCBNP/Gestion+des+callbacks+et+notification+de+paiement
|
|
*
|
|
* @param string|null $version
|
|
* @return boolean
|
|
*/
|
|
private function isValidSignatureVersion(string $version): bool
|
|
{
|
|
$versionIsValid = $version === self::USING_API_VERSION;
|
|
|
|
if (!$versionIsValid) {
|
|
// NOTE: If this occurs it shall be handled
|
|
$this->logger->error(
|
|
"Version is not accepted",
|
|
[
|
|
'request_version' => $version,
|
|
'accepted_version' => self::USING_API_VERSION
|
|
]
|
|
);
|
|
}
|
|
return $version === self::USING_API_VERSION;
|
|
}
|
|
|
|
private function requestTimestampIsAcceptable(CarbonInterface $timestamp): bool
|
|
{
|
|
$now = Carbon::now('UTC');
|
|
$calculatedDiff = $now->diffInMinutes(date: $timestamp, absolute: true);
|
|
$timestampIsInSkew = $calculatedDiff <= self::PERMITTED_TIMESTAMP_SKEW_IN_MINUTES;
|
|
|
|
if (!$timestampIsInSkew) {
|
|
// NOTE: If this occurs it shall be handled
|
|
$this->logger->error(
|
|
"request timestamp skew is not not matched",
|
|
[
|
|
'request_timestamp' => $timestamp,
|
|
'diff' => $calculatedDiff,
|
|
'max_skew_in_minutes' => self::PERMITTED_TIMESTAMP_SKEW_IN_MINUTES
|
|
]
|
|
);
|
|
}
|
|
|
|
return $timestampIsInSkew;
|
|
}
|
|
|
|
/**
|
|
* Check signature
|
|
*
|
|
* https://axepta.computop.com/display/DOCBNP/Gestion+des+callbacks+et+notification+de+paiement
|
|
*
|
|
* @param string $signature
|
|
* @param \Carbon\CarbonInterface $timestamp
|
|
* @param string $rawPayload
|
|
* @return boolean
|
|
*/
|
|
private function isValidSignature(string $signature, CarbonInterface $timestamp, string $rawPayload): bool
|
|
{
|
|
$providedSignature = $this->extractSignatureFromCurrentVersion($signature);
|
|
|
|
if (!$providedSignature) {
|
|
// NOTE: If this occurs it shall be handled
|
|
$this->logger->error(
|
|
"Could not extract signature from '{$signature}'"
|
|
);
|
|
return false;
|
|
}
|
|
|
|
$signedPayload = "{$timestamp}.{$rawPayload}";
|
|
$expectedSignature = hash_hmac('sha256', $signedPayload, $this->hmac);
|
|
$signatureIsValid = hash_equals($expectedSignature, $providedSignature);
|
|
|
|
if (!$signatureIsValid) {
|
|
// NOTE: If this occurs it could be a fraudulent request
|
|
$this->logger->debug(
|
|
"{Could not validate that expected signature '{$expectedSignature}' matches the request signature '{$signature}'",
|
|
[
|
|
'request_signature' => $signature,
|
|
'request_timestamp' => $timestamp,
|
|
'request_payload' => $rawPayload,
|
|
]
|
|
);
|
|
}
|
|
|
|
return $signatureIsValid;
|
|
}
|
|
|
|
/**
|
|
* Extract the hex value after "v1=" from the signature header.
|
|
*
|
|
* https://axepta.computop.com/display/DOCBNP/Gestion+des+callbacks+et+notification+de+paiement
|
|
*
|
|
* @param string $signature
|
|
* @return string|null
|
|
*/
|
|
private function extractSignatureFromCurrentVersion(string $signature): ?string
|
|
{
|
|
$signatures = explode(',', $signature);
|
|
$filteredSignatures = array_filter($signatures, function (string $key) {
|
|
return $key === self::USING_API_VERSION;
|
|
}, \ARRAY_FILTER_USE_KEY);
|
|
|
|
$hex = $filteredSignatures[0] ?? '';
|
|
|
|
// NOTE: SHA256 hex = 64 chars
|
|
return strlen($hex) === 64 ? $hex : null;
|
|
}
|
|
}
|