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= * * @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()); } }