ldc-legaulois/app/Lib/Axepta/AxeptaPayment.php
2026-02-17 14:01:32 +00:00

301 lines
9.8 KiB
PHP

<?php
namespace App\Lib\Axepta;
use App\Lib\Axepta\Objects\AxeptaOrder;
use Carbon\Carbon;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
class AxeptaPayment
{
/**
* 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 string $endpoint, private string $merchantId, private string $apiKey, private string $hmac, 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);
}
}
/**
* 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);
}
}
/**
* generateTransId method
*
* @param int $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;
}
}
}
/**
* Check signature version
*
* https://axepta.computop.com/display/DOCBNP/Gestion+des+callbacks+et+notification+de+paiement
*
* @param string|null $version
* @return bool
*/
private function isValidSignatureVersion(?string $version): bool
{
return $version === 'v1';
}
/**
* Check signature
*
* https://axepta.computop.com/display/DOCBNP/Gestion+des+callbacks+et+notification+de+paiement
*
* @param string $signature
* @param string $timestamp
* @param string $rawPayload
* @return bool
*/
private function isValidSignature(string $signature, string $timestamp, string $rawPayload): bool
{
$providedSignature = $this->extractSignature($signature);
if (!$providedSignature) {
return false;
}
$signedPayload = "{$timestamp}.{$rawPayload}";
$expectedSignature = hash_hmac('sha256', $signedPayload, $this->hmac);
return hash_equals($expectedSignature, $providedSignature);
}
/**
* 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 extractSignature(string $signature): ?string
{
if (!str_starts_with($signature, 'v1=')) {
return null;
}
$hex = substr($signature, 3);
return strlen($hex) === 64 ? $hex : null; // SHA256 hex = 64 chars
}
/**
* 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 bool
*/
public function isValidWebhookAccess(\Illuminate\Http\Request $request): bool
{
$signatureVersion = $request->header('x-paygate-signature-version');
$timestamp = $request->header('x-paygate-timestamp', '0');
$signature = $request->header('x-paygate-signature');
return $this->isValidSignatureVersion($signatureVersion) and
Carbon::createFromTimestampUTC($timestamp)->diffInMinutes(Carbon::now('UTC')) <= 5 and
$this->isValidSignature($signature, $timestamp, $request->getContent());
}
}