Merge branch 'develop' into trunk

This commit is contained in:
Vincent BRIDIER 2026-02-17 15:02:00 +01:00
commit a356d02090
21 changed files with 528 additions and 1604 deletions

View file

@ -75,11 +75,6 @@ GOOGLE_RECAPTCHA_PUBLIC=
GOOGLE_RECAPTCHA_PRIVATE=
GOOGLE_RECAPTCHA_VERSION=
## MERCANET
MERCANET_SECRET=
MERCANET_MERCHAND_ID=
MERCANET_ENV=
## TELESCOPE
TELESCOPE_ENABLED=
@ -101,3 +96,9 @@ REDIS_USERNAME=
REDIS_PASSWORD=
REDIS_PREFIX=
REDIS_SCHEME=
## AXEPTA
AXEPTA_ENDPOINT=
AXEPTA_MERCHANT_ID=
AXEPTA_APIKEY=
AXEPTA_HMAC=

View file

@ -1,44 +0,0 @@
<?php
namespace App\Enums\Mercanet;
use App\Traits\Enums\BaseEnum;
enum PaymentResponse: int
{
use BaseEnum;
case ok = 00;
case invalidIds = 03;
case invalidParameters = 12;
case canceled = 17;
case invalidRequestFormat = 30;
case securityIssue = 34;
case transactionAlreadyExists = 94;
case expired = 97;
case unreachableService = 99;
case internalIssue = 500;
/**
* Optionnal labels definition.
*
* @phpcs:disable Generic.Files.LineLength.TooLong
*
* @phpstan-ignore-next-line
*/
private const LABELS = [
self::ok->name => 'Situation normale suivie du processus normal
d\'affichage des pages de paiement.',
self::invalidIds->name => 'L’identifiant commerçant ou le contrat acquéreur sont invalides.',
self::invalidParameters->name => 'Les paramètres de la transaction sont invalides. Vérifiez les
paramètres de la requête.',
self::canceled->name => 'Transaction annulée',
self::invalidRequestFormat->name => 'Le format de la requête est invalide.',
self::securityIssue->name => 'Problème de sécurité, par ex. le sceau calculé est incorrect.',
self::transactionAlreadyExists->name => 'La transaction existe déjà.',
self::expired->name => 'Délais expiré',
self::unreachableService->name => 'Service temporairement indisponible',
self::internalIssue->name => 'Erreur interne, eg: probleme appel CURL'
];
//phpcs:enable
}

View file

@ -1,35 +0,0 @@
<?php
namespace App\Enums\Mercanet;
use App\Traits\Enums\BaseEnum;
enum RedirectionStatusCode: int
{
use BaseEnum;
case redirect = 0;
case requestError03 = 3;
case requestError12 = 12;
case requestError30 = 30;
case requestError34 = 34;
case referenceAlreadyUsed = 94;
case serviceDisponibiltyIssue = 99;
case internalIssue = 500;
/**
* Optionnal labels definition.
*
* @phpstan-ignore-next-line
*/
private const LABELS = [
self::redirect->name => 'Redirection Acceptée',
self::requestError03->name => 'Problème de request code 03',
self::requestError12->name => 'Problème de request code 12',
self::requestError30->name => 'Problème de request code 30',
self::requestError34->name => 'Problème de request code 34',
self::referenceAlreadyUsed->name => 'La référence de transaction à déja été utilisée',
self::serviceDisponibiltyIssue->name => 'Problème de disponibilité du service de paiement',
self::internalIssue->name => 'Erreur interne, eg: probleme appel CURL'
];
}

View file

@ -1,37 +0,0 @@
<?php
namespace App\Enums\Mercanet;
use App\Traits\Enums\BaseEnum;
enum TransactionResponse: int
{
use BaseEnum;
case accepted = 00;
case refused = 05;
case canceled = 17;
case fraud = 34;
case maxReached = 75;
case technicalIssue = 90;
case duplicate = 94;
case expired = 97;
case internalIssue = 500;
/**
* Optionnal labels definition.
*
* @phpstan-ignore-next-line
*/
private const LABELS = [
self::accepted->name => 'Paiement accepté',
self::refused->name => 'Paiement refusé',
self::canceled->name => 'Paiement annulé',
self::fraud->name => 'Paiement refusé pour fraude',
self::maxReached->name => 'Paiement refusé pour nombre max essais atteint',
self::technicalIssue->name => 'Paiement refusé pour problème technique',
self::duplicate->name => 'Transaction dupliquée',
self::expired->name => 'Paiement expiré',
self::internalIssue->name => 'Erreur interne, eg: probleme appel CURL'
];
}

View file

@ -8,7 +8,7 @@ enum OrderType: int
{
use BaseEnum;
// case cb = 1;
case cb = 1;
case check = 2;
/**
@ -17,7 +17,7 @@ enum OrderType: int
* @phpstan-ignore-next-line
*/
private const LABELS = [
// self::cb->name => 'Carte Bancaire',
self::cb->name => 'Carte Bancaire',
self::check->name => 'Chèque',
];
}

View file

@ -2,9 +2,6 @@
namespace App\Http\Controllers\Club;
use App\Enums\Mercanet\PaymentResponse;
use App\Enums\Mercanet\RedirectionStatusCode;
use App\Enums\Mercanet\TransactionResponse;
use App\Enums\Shop\ContactFormCivility;
use App\Enums\Shop\OrderStatus;
use App\Enums\Shop\OrderType;
@ -13,7 +10,9 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\Club\Shop\ShopBuyRequest;
use App\Jobs\Arcadia\SendOrderToArcadia;
use App\Jobs\Shop\SendShopMail;
use App\Lib\Mercanet\Mercanet;
use App\Lib\Axepta\AxeptaPayment;
use App\Lib\Axepta\Enums\PaymentResponse;
use App\Lib\Axepta\Objects\AxeptaOrder;
use App\Models\DeliveryAddress;
use App\Models\Notification;
use App\Models\Order;
@ -82,13 +81,12 @@ class ShopController extends Controller
compact('order')
)]);
}
// FIXME: uncomment when mercanet cb is ready.
// if ($order->payementType->equals(OrderType::cb)) {
// return response()->json(['redirect' => route(
// 'club.shop.payementCB',
// compact('order')
// )]);
// }
if ($order->payementType->equals(OrderType::cb)) {
return response()->json(['redirect' => route(
'club.shop.payementCB',
compact('order')
)]);
}
abort(500, sprintf('Order already exists but type is unkown %d', $order->getKey()));
} //end try
@ -107,11 +105,10 @@ class ShopController extends Controller
}
switch ($request->payementMethod) {
// FIXME: uncomment when mercanet cb is ready.
// case OrderType::cb->value():
// \session()->put(sprintf('shop.order.%d.time', $order->getKey()), \time());
// \session()->save();
// return response()->json(['redirect' => route('club.shop.payementCB', compact('order'))]);
case OrderType::cb->value():
\session()->put(sprintf('shop.order.%d.time', $order->getKey()), \time());
\session()->save();
return response()->json(['redirect' => route('club.shop.payementCB', compact('order'))]);
case OrderType::check->value():
try {
\session()->put(sprintf('shop.order.%d.time', $order->getKey()), \time());
@ -161,7 +158,7 @@ class ShopController extends Controller
*
* @param \App\Models\Order $order
* @return \Illuminate\Contracts\View\View|\Illuminate\Http\RedirectResponse
* @throws \App\Exceptions\CBPayementError Error response from Mercanet Lib.
* @throws \App\Exceptions\CBPayementError Error response from Axepta Lib.
* @phpcs:disable Generic.Metrics.CyclomaticComplexity.TooHigh
*/
public function payWithCb(Order $order): \Illuminate\Contracts\View\View|\Illuminate\Http\RedirectResponse
@ -171,14 +168,17 @@ class ShopController extends Controller
try {
$time = session()->get(sprintf('shop.order.%d.time', $order->getKey()), 0);
if ($order->status->equals(OrderStatus::failed)) {
return view('club.pages.shop.error')
->with('error', trans('Commande annulée, veuillez réessayer'));
return view('club.pages.shop.info')
->with('title', trans('Commande annulée'))
->with('message', trans("Le paiement n'a pu aboutir. Veuillez réessayer")
);
}
// 4 minutes
if (($time + (60 * 4)) < \time()) {
return view('club.pages.shop.error')
->with('error', trans('Délai d\'attente expiré, veuillez réessayer'));
return view('club.pages.shop.info')
->with('title', trans('Erreur'))
->with('message', trans('Délai d\'attente expiré, veuillez réessayer'));
}
$totalToPay = $order->sellItems->map(function ($sellItem) {
@ -193,39 +193,36 @@ class ShopController extends Controller
// * Convert total in cents
$totalToPay = (int) (\number_format((float) $totalToPay, 2)) * 100;
$mercanet = $this->getMercanet($order);
$aPayment = new AxeptaPayment(
config('axepta.endpoint'),
config('axepta.merchantId'),
config('axepta.apiKey'),
config('axepta.hmac')
);
$aOrder = new AxeptaOrder(
$order->ref,
$totalToPay,
$order->customer->email
);
$payment = $mercanet->QuickPayment($totalToPay, $order->ref);
try {
$payment = $aPayment->getCheckoutUrl(
$aOrder,
route('club.shop.reglement.payementConfirmCB', $order),
route('club.shop.reglement.payementConfirmCB', ['order' => $order, 'cancel' => true]),
route('club.shop.reglement.payementConfirmCBServ', $order)
);
return response()->redirectTo($payment);
} catch (\RuntimeException $e) {
$order->status = OrderStatus::failed;
$order->save();
throw new CBPayementError(sprintf(
"Error response from Axepta Lib : %s",
$e->getMessage()
));
}
switch ($payment->code) {
case RedirectionStatusCode::redirect->value():
return view('club.pages.shop.redirectMercanet', [
'url' => $payment->url,
'data' => $payment->data,
'version' => $payment->version
]);
case RedirectionStatusCode::referenceAlreadyUsed:
return response()->redirectToRoute('club.shop.index');
case RedirectionStatusCode::requestError03->value():
case RedirectionStatusCode::requestError12->value():
case RedirectionStatusCode::requestError30->value():
case RedirectionStatusCode::requestError34->value():
case RedirectionStatusCode::serviceDisponibiltyIssue->value():
$order->status = OrderStatus::failed;
$order->save();
throw new CBPayementError(sprintf(
"Error response from Mercanet Lib : %s\n %s",
RedirectionStatusCode::make(intval($payment->code))->label(),
json_encode($payment)
));
default:
$order->status = OrderStatus::failed;
$order->save();
throw new CBPayementError(sprintf(
'Un handled response from Mercanet Lib %s',
json_encode($payment)
));
} //end switch
} catch (ModelNotFoundException | CBPayementError $e) {
report($e);
Log::critical(sprintf(
@ -233,14 +230,16 @@ class ShopController extends Controller
$order->getKey(),
$e->getMessage()
));
return view('club.pages.shop.error')
->with('error', trans('La commande que vous venez de passer n\'a pu aboutir, veuillez réessayer'));
return view('club.pages.shop.info')
->with('title', trans('Commande annulée'))
->with('message', trans("Le paiement n'a pu aboutir. Veuillez réessayer")
);
} //end try
}
/**
* Pas de validate ou custom request sur la réponse de mercanet,
* c'est la Lib mercanet qui fait.
* Pas de validate ou custom request sur la réponse de Axepta,
* C'est la Lib Axepta qui fait.
*
* @param \Illuminate\Http\Request $request
* @param \App\Models\Order $order
@ -248,40 +247,56 @@ class ShopController extends Controller
* @throws \App\Exceptions\CBPayementError Data were altered from response.
* @phpcs:disable Generic.Metrics.CyclomaticComplexity.TooHigh
*/
public function confirmCbFromMercanet(
public function confirmCbFromAxepta(
Request $request,
Order $order
): \Illuminate\Http\Response|\Illuminate\Contracts\Routing\ResponseFactory {
// phpcs:enable
try {
$data = $this->getMercanetData($request);
$aPayment = new AxeptaPayment(
config('axepta.endpoint'),
config('axepta.merchantId'),
config('axepta.apiKey'),
config('axepta.hmac')
);
/** @var \App\Models\Order */
$order = Order::query()->where('ref', $data['ref'])->firstOrFail();
$mercanet = $this->getMercanet($order);
$mercanet->setResponse($request->post());
if (!$mercanet->isValid()) {
if (!$aPayment->isValidWebhookAccess($request)) {
$order->status = OrderStatus::failed;
$order->save();
// @phpstan-ignore finally.exitPoint
throw new CBPayementError('Data were altered from response');
}
$response = $aPayment->getPaymentStatusById($request->payId);
switch ($data['responseCode']) {
case TransactionResponse::accepted:
$order->status = OrderStatus::payed;
if (is_null($response)) {
if (
!$order->confirmMailSent and
!$order->status->equals(OrderStatus::payed)
) {
$order->status = OrderStatus::failed;
$order->save();
SendOrderToArcadia::dispatch($order);
SendShopMail::dispatch($order);
}
return response('', 200);
}
switch (intval($response[0])) {
case PaymentResponse::ok->value:
if (
!$order->confirmMailSent and
!$order->status->equals(OrderStatus::payed)
) {
$order->status = OrderStatus::payed;
$order->save();
SendOrderToArcadia::dispatch($order);
SendShopMail::dispatch($order);
}
break;
case TransactionResponse::canceled:
case TransactionResponse::expired:
case TransactionResponse::refused:
case TransactionResponse::fraud:
case TransactionResponse::duplicate:
case TransactionResponse::maxReached:
case PaymentResponse::transient->value:
case PaymentResponse::emvinfo->value:
break;
case PaymentResponse::error->value:
case PaymentResponse::fatalerror->value:
if (
!$order->confirmMailSent and
!$order->status->equals(OrderStatus::payed)
@ -290,17 +305,9 @@ class ShopController extends Controller
$order->save();
}
break;
case TransactionResponse::technicalIssue:
// @phpstan-ignore finally.exitPoint
throw new CBPayementError(sprintf(
'Error from Mercanet Server %s on ref %s, %s',
$data['responseCode'],
$data['ref'],
json_encode($data['returnDatas']),
));
default:
// * ERROR
if (
// * ERROR
if (
!$order->confirmMailSent and
!$order->status->equals(OrderStatus::payed)
) {
@ -309,12 +316,12 @@ class ShopController extends Controller
}
// @phpstan-ignore finally.exitPoint
throw new CBPayementError(sprintf(
'Un handled response from Mercanet Server %s on ref %s, %s',
$data['responseCode'],
$data['ref'],
json_encode($data['returnDatas']),
'Unhandled response from Axepta Server %s on ref %s',
$response,
$order->ref
));
} //end switch
} //end switch
} catch (ModelNotFoundException | CBPayementError | \Exception $e) {
report($e);
Log::critical(sprintf(
@ -329,135 +336,63 @@ class ShopController extends Controller
}
/**
*
* Pas de validate ou custom request sur la réponse de mercanet,
* c'est la Lib mercanet qui fait.
* Confirm CB method
*
* @param Request $request
* @param Order $order
* @return \Illuminate\Contracts\View\View
* @throws \App\Exceptions\CBPayementError Data were altered from response.
* @phpcs:disable Generic.Metrics.CyclomaticComplexity.TooHigh
*/
public function confirmCb(Request $request, Order $order): \Illuminate\Contracts\View\View
{
// phpcs:enable
Notification::getNotifications();
try {
if ($order->status->equals(OrderStatus::failed)) {
return view('club.pages.shop.error');
}
if ($order->status->equals(OrderStatus::payed)) {
return view('club.pages.shop.validated');
}
$mercanet = $this->getMercanet($order);
$mercanet->setResponse($request->post());
if (!$mercanet->isValid()) {
try {
$order->status = OrderStatus::failed;
$order->saveOrFail();
} finally {
throw new CBPayementError("Data were altered from response");
}
}
$data = $this->getMercanetData($request);
switch ($data['responseCode']) {
case PaymentResponse::ok:
try {
$status = $order->status;
$order->status = OrderStatus::payed;
$order->saveOrFail();
if ($status->equals(OrderStatus::waiting)) {
SendOrderToArcadia::dispatch($order);
SendShopMail::dispatch($order);
}
} catch (Exception $e) {
report($e);
Log::critical(sprintf(
'Could not end customer transaction (save and send mail) in boutique for order %d, %s',
$order->getKey(),
$e->getMessage()
));
} finally {
return view('club.pages.shop.validated');
}
case PaymentResponse::canceled:
$order->status = OrderStatus::failed;
$order->saveOrFail();
return view('club.pages.shop.error');
case PaymentResponse::invalidIds:
case PaymentResponse::invalidRequestFormat:
case PaymentResponse::invalidParameters:
case PaymentResponse::unreachableService:
case PaymentResponse::securityIssue:
case PaymentResponse::transactionAlreadyExists:
case PaymentResponse::expired:
case PaymentResponse::internalIssue:
$order->status = OrderStatus::failed;
$order->saveOrFail();
throw new CBPayementError(sprintf(
"Error response from Mercanet Lib : %s\n %s",
PaymentResponse::make($data['responseCode'])->label(),
json_encode($data['returnDatas'])
));
default:
$order->status = OrderStatus::failed;
$order->save();
throw new CBPayementError(sprintf(
'Un handled response from Mercanet Lib %s',
json_encode($data['returnDatas'])
));
} //end switch
} catch (ModelNotFoundException | CBPayementError $e) {
report($e);
Log::critical(sprintf(
'Could not end customer transaction in boutique for order %d, %s',
$order->getKey(),
$e->getMessage()
));
return view('club.pages.shop.error');
} //end try
}
/**
* Get initialized Mercanet object.
*
* @param \App\Models\Order $order
* @return \App\Lib\Mercanet\Mercanet
*/
private function getMercanet(Order $order): \App\Lib\Mercanet\Mercanet
{
return new Mercanet(
config('mercanet.secret'),
config('mercanet.merchantId'),
config('mercanet.env'),
route('club.shop.reglement.payementConfirmCB', $order),
route('club.shop.reglement.payementConfirmCBServ', $order)
);
}
/**
* Get Mercanet ref and responseCode for request.
*
* @param \Illuminate\Http\Request $request
* @return array<string,string> array['ref' => 'value', 'responseCode' => 'value', 'returnDatas' => 'value']
*/
private function getMercanetData(Request $request): array
{
$formDatas = [];
$returnDatas = explode("|", $request->post('Data'));
foreach ($returnDatas as $item) {
$tmp = explode("=", $item);
$formDatas[$tmp[0]] = $tmp[1];
if (
!$request->has('PayID') or
$request->has('cancel') or
$order->status->equals(OrderStatus::failed)
) {
return view('club.pages.shop.info')
->with('title', trans('Commande annulée'))
->with('message', trans("Le paiement n'a pu aboutir. Veuillez réessayer")
);
}
$ref = $formDatas['transactionReference'];
$responseCode = $formDatas['responseCode'];
return compact('ref', 'responseCode', 'returnDatas');
if ($order->status->equals(OrderStatus::payed)) {
return view('club.pages.shop.validated');
}
$aPayment = new AxeptaPayment(
config('axepta.endpoint'),
config('axepta.merchantId'),
config('axepta.apiKey'),
config('axepta.hmac')
);
$response = $aPayment->getPaymentStatusById($request->PayID);
if (is_null($response)) {
return view('club.pages.shop.info')
->with('title', trans('Commande annulée'))
->with('message', trans("Le paiement n'a pu aboutir. Veuillez réessayer")
);
}
switch (intval($response[0])) {
case PaymentResponse::ok->value:
return view('club.pages.shop.validated');
case PaymentResponse::transient->value:
case PaymentResponse::emvinfo->value:
return view('club.pages.shop.info')
->with('title', 'Paiement en cours')
->with('message', 'Paiement en attente de traitement par la banque');
case PaymentResponse::error->value:
case PaymentResponse::fatalerror->value:
default:
return view('club.pages.shop.info')
->with('title', trans('Commande annulée'))
->with('message', trans("Le paiement n'a pu aboutir. Veuillez réessayer")
);
}
}
}

View file

@ -87,7 +87,7 @@ class SecureHeaders
'https://use.fontawesome.com',
'https://fonts.googleapis.com',
// * VITE DEV
config('app.debug') ? "https://{$host}:4443" : '',
config('app.debug') ? "https://{$host}:4443" : ''
]) . ';',
// ! If you dont use ckeditor use this
// "style-src 'self' 'nonce-$nonce';",

View file

@ -3,6 +3,7 @@
namespace App\Lib\Arcadia;
use App\Enums\Arcadia\ArcadiaOrderType;
use App\Enums\Shop\OrderType;
use App\Models\Order;
use App\Models\SellItem;
use Illuminate\Contracts\Support\Arrayable;
@ -103,10 +104,7 @@ class HasOrderedAnItemData implements Jsonable, Arrayable
'label_type_support' => $sellItem->name,
'order_date' => $order->created_at,
'order_num' => $order->getKey(),
'state_order' => ArcadiaOrderType::check->value(),
// FIXME: uncomment when mercanet cb is ready.
// 'state_order' => $order->payementType->equals(OrderType::cb) ?
// ArcadiaOrderType::cb->value() : ArcadiaOrderType::check->value(),
'state_order' => $order->payementType->equals(OrderType::cb) ? ArcadiaOrderType::cb->value() : ArcadiaOrderType::check->value(),
'package_unit_weight' => $sellItem->arcadia_weight,
'email' => $order->email,
'firstname' => $firstname,

View file

@ -0,0 +1,301 @@
<?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());
}
}

View file

@ -0,0 +1,32 @@
<?php
namespace App\Lib\Axepta\Enums;
use App\Traits\Enums\BaseEnum;
enum PaymentResponse: int
{
use BaseEnum;
case ok = 0;
case error = 2;
case fatalerror = 4;
case transient = 6;
case emvinfo = 7;
/**
* Optionnal labels definition.
*
* @phpcs:disable Generic.Files.LineLength.TooLong
*
* @phpstan-ignore-next-line
*/
private const LABELS = [
self::ok->name => 'Transaction successful, other digits may indicate further information',
self::error->name => 'Transaction failed, other digits may indicate further information',
self::fatalerror->name => 'Transaction failed, other digits may indicate further information, e.g. mandatory parameter is missing',
self::transient->name => 'Transaction pending. Final status will be sent asynchronously when available',
self::emvinfo->name => 'Intermediate states in the EMV 3-D Secure sequence'
];
//phpcs:enable
}

View file

@ -0,0 +1,20 @@
<?php
namespace App\Lib\Axepta\Objects;
class AxeptaOrder
{
/**
* Construct method
*
* @param string $reference Internal system reference order
* @param int $amount Order amount ex: 10000 = 100$
* @param string $customerEmail Customer email
* @return void
*/
public function __construct(
public string $reference,
public int $amount,
public string $customerEmail
) {}
}

File diff suppressed because it is too large Load diff

8
config/axepta.php Normal file
View file

@ -0,0 +1,8 @@
<?php
return [
'endpoint' => env('AXEPTA_ENDPOINT'),
'merchantId' => env('AXEPTA_MERCHANT_ID'),
'apiKey' => env('AXEPTA_APIKEY'),
'hmac' => env('AXEPTA_HMAC')
];

View file

@ -1,8 +0,0 @@
<?php
return [
'secret' => env('MERCANET_SECRET'),
'merchantId' => env('MERCANET_MERCHAND_ID'),
// Ex: DEVELOPMENT, PRODUCTION.
'env' => env('MERCANET_ENV', 'DEVELOPMENT'),
];

View file

@ -58,6 +58,10 @@ data:
REDIS_PREFIX: "${APP_REDIS_PREFIX}"
REDIS_SCHEME: "${APP_REDIS_SCHEME}"
UWIL_ADMIN_SECURE_CIDR_LIST: "${APP_GLOBAL_UWIL_ADMIN_SECURE_CIDR_LIST}"
AXEPTA_ENDPOINT: "${APP_AXEPTA_ENDPOINT}"
AXEPTA_MERCHANT_ID: "${APP_AXEPTA_MERCHANT_ID}"
AXEPTA_APIKEY: "${APP_AXEPTA_APIKEY}"
AXEPTA_HMAC: "${APP_AXEPTA_HMAC}"
---
apiVersion: v1
kind: PersistentVolumeClaim

View file

@ -276,9 +276,6 @@
>{{ payementMethod.label }}</label>
</li>
</ul>
<p class="fst-italic m-0">
{{ trans.methods.__('(CB prochainement)') }}
</p>
</div>
</div>
<!-- FORM RGPD -->

View file

@ -17,11 +17,12 @@
<span class="header-icon position-relative mb-3 mb-sm-0 me-sm-3">
@include('club.icons.bell')
</span>
<h1 class="text-uppercase m-0">{{ __('Commande annulée') }}</h1>
<h1 class="text-uppercase m-0">{{ $title }}</h1>
</div>
<div class="col-lg-6">
<p class="text-center">
{{ __('La commande que vous venez de passer n\'a pu aboutir !') }}
{{ $message }}
</p>
</div>
</section>

View file

@ -1,29 +0,0 @@
@extends('club.layout')
@section('title', __('Redirection vers Mercanet'))
@section('description', __('Page de redirection vers le service de BNP Paribas nommé Mercanet.'))
@section('breadcrumb', request()->route()->getName())
@section('container-fluid', true)
@section('no-bottom-space', true)
@section('page-class', 'shop')
@section('metaIndex', 'noindex,nofollow')
@section('content')
<section class="container mb-5 pb-5">
<form name="redirectForm" method="POST" action="{{ $url }}">
<input type="hidden" name="redirectionVersion" value="{{ $version }}">
<input type="hidden" name="redirectionData" value="{{ $data }}">
<h2 class="text-center">{{ __('Redirection en cours') }}</h2>
<noscript>
<input type="submit" name="Go" value="{{ __('Cliquez ici pour accéder à la page de paiement') }}" />
</noscript>
</form>
</section>
@endsection
@push('scripts')
<script nonce="{{ $nonce }}">
document.redirectForm.submit();
</script>
@endpush

View file

@ -7,9 +7,9 @@
@if ($orderType->equals(\App\Enums\Arcadia\ArcadiaOrderType::check))
<Etat_Commande_CB>Null</Etat_Commande_CB>
<Etat_Commande_Cheque>1</Etat_Commande_Cheque>
{{-- @elseif($orderType->equals(\App\Enums\Arcadia\ArcadiaOrderType::cb))
@elseif($orderType->equals(\App\Enums\Arcadia\ArcadiaOrderType::cb))
<Etat_Commande_CB>1</Etat_Commande_CB>
<Etat_Commande_Cheque>Null</Etat_Commande_Cheque> --}}
<Etat_Commande_Cheque>Null</Etat_Commande_Cheque>
@endif
<Email_conso>
<![CDATA[{{ $email }}]]>

View file

@ -102,7 +102,7 @@ Route::name('club.')->prefix('club')->group(function () {
Route::get('{order}/reglement', [ShopController::class, 'payWithCb'])->name('payementCB');
});
Route::prefix('/reglement')->name('reglement.')->group(function () {
Route::post('/{order}/confirmServ', [ShopController::class, 'confirmCbFromMercanet'])
Route::post('/{order}/confirmServ', [ShopController::class, 'confirmCbFromAxepta'])
->name('payementConfirmCBServ')
->withoutMiddleware([
'auth:club',
@ -115,19 +115,8 @@ Route::name('club.')->prefix('club')->group(function () {
XssSanitizer::class,
EnsureEmailIsVerified::class,
]);
Route::post('/{order}/confirm', [ShopController::class, 'confirmCb'])
->name('payementConfirmCB')
->withoutMiddleware([
'auth:club',
Authenticate::class,
AddQueuedCookiesToResponse::class,
EncryptCookies::class,
VerifyCsrfToken::class,
StartSession::class,
ShareErrorsFromSession::class,
XssSanitizer::class,
EnsureEmailIsVerified::class,
]);
Route::get('/{order}/confirm', [ShopController::class, 'confirmCb'])
->name('payementConfirmCB');
});
});

View file

@ -13,8 +13,5 @@ spec:
services:
- name: ${CI_PROJECT_PATH_SLUG}-service
port: 80
middlewares:
- name: admin-ldc-whitelist
namespace: common-services
tls:
secretName: ${CRT_TLS_NAME}