bundles/CheckoutBundle/Service/PaymentGateway/Paynovate/PaynovateClient.php line 86

Open in your IDE?
  1. <?php
  2. declare(strict_types=1);
  3. namespace Bdm\CheckoutBundle\Service\PaymentGateway\Paynovate;
  4. use Bdm\CheckoutBundle\Entity\PaynovatePayment;
  5. use Bdm\CheckoutBundle\Entity\Transaction;
  6. use Bdm\CheckoutBundle\Repository\OrderRepository;
  7. use GuzzleHttp\Client;
  8. use GuzzleHttp\Exception\ClientException;
  9. use GuzzleHttp\Psr7\Response as GuzzleResponse;
  10. use Symfony\Bundle\FrameworkBundle\Routing\Router;
  11. use Symfony\Component\HttpFoundation\Response;
  12. use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
  13. use Psr\Log\LoggerInterface;
  14. use Monolog\Logger;
  15. /**
  16. * Class PaynovateClient
  17. */
  18. class PaynovateClient
  19. {
  20. /**
  21. * @var Client $oGuzzle
  22. */
  23. protected $oGuzzle;
  24. /**
  25. * @var OrderRepository $oOrderRepository
  26. */
  27. protected $oOrderRepository;
  28. /**
  29. * @var string $sWebhookUrl
  30. */
  31. protected $sWebhookUrl;
  32. /**
  33. * @var string $sMerchantId
  34. */
  35. protected $sMerchantId;
  36. /**
  37. * @var string $sSignatureKey
  38. */
  39. protected $sSignatureKey;
  40. /**
  41. * @var string $sIntegrationUrl
  42. */
  43. protected $sIntegrationUrl;
  44. /**
  45. * @var string $sDirectEndpoint
  46. */
  47. protected $sDirectEndpoint;
  48. /**
  49. * @var array $aCountryCode
  50. */
  51. protected $aCountryCode;
  52. /**
  53. * @var array $aCurrencyCode
  54. */
  55. protected $aCurrencyCode;
  56. /**
  57. * @var LoggerInterface
  58. */
  59. protected $oLogger;
  60. /**
  61. * @param OrderRepository $oOrderRepository oOrderRepository
  62. * @param Router $oRouter oRouter
  63. * @param array $aCountryCode aCountryCode
  64. * @param array $aCurrencyCode aCurrencyCode
  65. * @param string $sPaynovatePathIpn sPaynovatePathIpn
  66. * @param string $sMerchantId sMerchantId
  67. * @param string $sSignatureKey sSignatureKey
  68. * @param string $sIntegrationUrl sIntegrationUrl
  69. * @param boolean $bLocalEnv bLocalEnv
  70. * @param LoggerInterface $oLogger logger
  71. */
  72. public function __construct(
  73. OrderRepository $oOrderRepository,
  74. Router $oRouter,
  75. $aCountryCode,
  76. $aCurrencyCode,
  77. $sPaynovatePathIpn,
  78. $sMerchantId,
  79. $sSignatureKey,
  80. $sIntegrationUrl,
  81. $bLocalEnv = true,
  82. LoggerInterface $oLogger
  83. ) {
  84. $this->oGuzzle = new Client([
  85. 'verify' => true,
  86. 'allow_redirects' => false,
  87. 'http_errors' => false,
  88. ]);
  89. $this->oOrderRepository = $oOrderRepository;
  90. $this->aCountryCode = $aCountryCode;
  91. $this->aCurrencyCode = $aCurrencyCode;
  92. $this->sMerchantId = $sMerchantId;
  93. $this->sSignatureKey = $sSignatureKey;
  94. $this->sIntegrationUrl = rtrim($sIntegrationUrl, '/');
  95. $this->sDirectEndpoint = $this->sIntegrationUrl . '/direct/';
  96. $this->oLogger = $oLogger;
  97. if ($bLocalEnv) {
  98. $this->sWebhookUrl = $sPaynovatePathIpn.'/api/v1/ipn/paynovate';
  99. } else {
  100. $this->sWebhookUrl = $oRouter->generate(
  101. 'bdm_checkout_api_paynovate_ipn',
  102. [],
  103. UrlGeneratorInterface::ABSOLUTE_URL
  104. );
  105. }
  106. }
  107. /**
  108. * Generate SHA512 signature for Paynovate API
  109. *
  110. * @param string|array $sQueryStringOrData URL-encoded query string OR data array for REST API
  111. * @param string|null $sTimestamp Optional timestamp for REST API
  112. * @return string
  113. */
  114. protected function generateSignature($sQueryStringOrData, $sTimestamp = null)
  115. {
  116. if (is_array($sQueryStringOrData)) {
  117. $aData = $sQueryStringOrData;
  118. unset($aData['signature']);
  119. ksort($aData);
  120. $sSignatureString = '';
  121. foreach ($aData as $sKey => $sValue) {
  122. // Gérer les valeurs qui sont des tableaux (sous-tableaux dans callback)
  123. if (is_array($sValue)) {
  124. $sValue = json_encode($sValue);
  125. }
  126. $sSignatureString .= $sKey . $sValue;
  127. }
  128. $sSignatureString .= $this->sSignatureKey;
  129. return hash('sha512', $sSignatureString);
  130. }
  131. return hash('sha512', $sQueryStringOrData . $this->sSignatureKey);
  132. }
  133. /**
  134. * Build URL-encoded query string from sorted data array
  135. *
  136. * @param array $aData request data
  137. * @return string
  138. */
  139. protected function buildQueryString(array $aData)
  140. {
  141. ksort($aData);
  142. $aPairs = [];
  143. foreach ($aData as $sKey => $sValue) {
  144. $aPairs[] = urlencode((string)$sKey) . '=' . urlencode((string)$sValue);
  145. }
  146. return implode('&', $aPairs);
  147. }
  148. /**
  149. * @param Transaction $oTransaction oTransaction
  150. * @param string $sUrl sUrl
  151. * @return mixed
  152. * @throws \Exception
  153. */
  154. public function createPayment(Transaction $oTransaction, $sUrl, $sIpnUrl = null)
  155. {
  156. $oCard = $oTransaction->getPaymentMethod();
  157. $oOrder = $this->oOrderRepository->findById($oTransaction->getPayment()->getToken()->getEntityId());
  158. $oOrderCustomer = $oOrder->getOrderCustomer();
  159. if ($oOrderCustomer === null) {
  160. throw new \Exception('Order customer not found');
  161. }
  162. $sExpYear = (string)$oCard->getExpYear();
  163. $sFormatYear = substr(str_pad($sExpYear, 2, '0', STR_PAD_LEFT), -2);
  164. $sMerchantReference = $oOrder->getReference().$oTransaction->getReference();
  165. $sTransactionUnique = bin2hex(random_bytes(8));
  166. $sBillingAddress = trim($oOrderCustomer->getAddressLine1() . ' ' . $oOrderCustomer->getAddressLine2());
  167. $aData = [
  168. 'merchantID' => $this->sMerchantId,
  169. 'action' => 'SALE',
  170. 'type' => '1',
  171. 'currencyCode' => $this->getCurrencyNumericCode($oTransaction->getCurrencyIsoCode()),
  172. 'countryCode' => $this->getCountryNumericCode($oOrderCustomer->getCountryCode()),
  173. 'amount' => (int)($oTransaction->getAmount() * 100), // Montant en centimes
  174. 'orderRef' => $sMerchantReference,
  175. 'transactionUnique' => $sTransactionUnique,
  176. 'cardNumber' => $oCard->getNumber(),
  177. 'cardExpiryMonth' => str_pad((string) $oCard->getExpMonth(), 2, "0", STR_PAD_LEFT),
  178. 'cardExpiryYear' => $sFormatYear,
  179. 'cardCVV' => $oCard->getCvc(),
  180. 'avscv2CheckRequired' => 'N',
  181. 'duplicateDelay' => '300', //5 minutes (duplaicate order ref)
  182. ];
  183. $oTransaction->set3DS(true);
  184. if($oTransaction->get3DS() === true) {
  185. $aData['threeDSRequired'] = 'Y';
  186. $aData['threeDSRedirectURL'] = $sUrl;
  187. $aData['redirectURL'] = $sUrl; // Redirect URL for user's browser
  188. if ($sIpnUrl) {
  189. $aData['callbackURL'] = $sIpnUrl;
  190. $this->oLogger->log(Logger::INFO, '[createPayment] Using IPN webhook URL: ' . $sIpnUrl);
  191. }
  192. // Device information (required for 3DS 2.0)
  193. $aData['deviceChannel'] = 'browser';
  194. $aData['deviceIdentity'] = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : 'Mozilla/5.0';
  195. $aData['remoteAddress'] = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '127.0.0.1';
  196. $aData['deviceTimeZone'] = '0';
  197. $aData['deviceCapabilities'] = 'javascript';
  198. $aData['deviceScreenResolution'] = '1920x1080x24';
  199. $aData['deviceAcceptContent'] = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8';
  200. $aData['deviceAcceptEncoding'] = 'gzip, deflate, br';
  201. $aData['deviceAcceptLanguage'] = 'en-US,en;q=0.9';
  202. } else {
  203. $aData['threeDSRequired'] = 'N';
  204. $aData['redirectURL'] = $sUrl; // Redirect URL for user's browser
  205. if ($sIpnUrl) {
  206. $aData['callbackURL'] = $sIpnUrl;
  207. $this->oLogger->log(Logger::INFO, '[createPayment] Using IPN webhook URL: ' . $sIpnUrl);
  208. }
  209. }
  210. if ($oOrderCustomer->getEmail()) {
  211. $aData['customerEmail'] = $oOrderCustomer->getEmail();
  212. }
  213. if ($sBillingAddress) {
  214. $aData['customerAddress'] = $sBillingAddress;
  215. }
  216. if ($oOrderCustomer->getCity()) {
  217. $aData['customerTown'] = substr($oOrderCustomer->getCity(), 0, 50);
  218. }
  219. if ($oOrderCustomer->getPostalCode()) {
  220. $aData['customerPostcode'] = $oOrderCustomer->getPostalCode();
  221. }
  222. $sQueryString = $this->buildQueryString($aData);
  223. $sSignature = $this->generateSignature($sQueryString);
  224. $sPostData = $sQueryString . '&signature=' . $sSignature;
  225. $aOptions = [
  226. 'headers' => [
  227. 'Content-Type' => 'application/x-www-form-urlencoded',
  228. ],
  229. 'body' => $sPostData,
  230. ];
  231. $aLogPayload = $aData;
  232. if (isset($aLogPayload['cardNumber'])) {
  233. $sValue = (string) $aLogPayload['cardNumber'];
  234. $iLength = strlen($sValue);
  235. $aLogPayload['cardNumber'] = ($iLength > 4 ? str_repeat('X', $iLength - 4) : '') . substr($sValue, -4);
  236. }
  237. if (isset($aLogPayload['cardCVV'])) {
  238. $aLogPayload['cardCVV'] = '***';
  239. }
  240. $this->oLogger->info('[createPayment] Sending request to Paynovate', [
  241. 'endpoint' => $this->sDirectEndpoint,
  242. 'payload' => $aLogPayload,
  243. ]);
  244. try {
  245. $oRequest = $this->oGuzzle->request('POST', $this->sDirectEndpoint, $aOptions);
  246. $sResponseBody = $oRequest->getBody()->getContents();
  247. $iStatusCode = $oRequest->getStatusCode();
  248. $aHeaders = [];
  249. foreach ($oRequest->getHeaders() as $sName => $aValues) {
  250. $aHeaders[$sName] = implode(', ', $aValues);
  251. }
  252. $this->oLogger->info('[createPayment] Received response from Paynovate', [
  253. 'endpoint' => $this->sDirectEndpoint,
  254. 'status_code' => $iStatusCode,
  255. 'headers' => $aHeaders,
  256. 'body' => $sResponseBody,
  257. ]);
  258. if ($iStatusCode == 302 && $oRequest->hasHeader('Location')) {
  259. $sLocationUrl = $oRequest->getHeader('Location')[0];
  260. $this->oLogger->info('[createPayment] Detected 302 redirect', [
  261. 'location' => $sLocationUrl
  262. ]);
  263. $aParsedUrl = parse_url($sLocationUrl);
  264. if (!empty($aParsedUrl['query'])) {
  265. parse_str($aParsedUrl['query'], $aLocationParams);
  266. $sNewBody = http_build_query($aLocationParams);
  267. $this->oLogger->info('[createPayment] Extracted params from Location header', [
  268. 'params' => $aLocationParams
  269. ]);
  270. return new GuzzleResponse(
  271. 200,
  272. ['Content-Type' => 'application/x-www-form-urlencoded'],
  273. $sNewBody
  274. );
  275. }
  276. }
  277. if (in_array($iStatusCode, [Response::HTTP_OK, Response::HTTP_ACCEPTED, Response::HTTP_CREATED])) {
  278. return $oRequest;
  279. }
  280. throw new \Exception('payment error');
  281. } catch (ClientException $oException) {
  282. $sResponseBody = $oException->getResponse()->getBody()->getContents();
  283. parse_str($sResponseBody, $aResponseData);
  284. $this->oLogger->error('[createPayment] Error response from Paynovate', [
  285. 'endpoint' => $this->sIntegrationUrl,
  286. 'status_code' => $oException->getResponse()->getStatusCode(),
  287. 'body' => $sResponseBody,
  288. 'payload' => $aLogPayload,
  289. ]);
  290. if (isset($aResponseData['responseCode'])) {
  291. $oTransaction->setTransactionErrorCode(PaymentService::PREFIX_ERROR_CODE.$aResponseData['responseCode']);
  292. }
  293. $sErrorMsg = isset($aResponseData['responseMessage']) ? $aResponseData['responseMessage'] : 'payment error';
  294. $iErrorCode = isset($aResponseData['responseCode']) ? (int)$aResponseData['responseCode'] : 0;
  295. throw new \Bdm\CoreBundle\Exception\PaymentGateway\Paynovate\PaynovatePaymentGatewayRejectionException(
  296. $iErrorCode,
  297. $oException->getResponse()->getStatusCode(),
  298. $oException,
  299. 'Paynovate error: ' . $sErrorMsg . ' (Code: ' . ($aResponseData['responseCode'] ?? 'unknown') . ')'
  300. );
  301. }
  302. }
  303. /**
  304. * @param Transaction $oTransaction oTransaction
  305. * @param PaynovatePayment $oPaynovatePayment oPaynovatePayment
  306. * @return \Psr\Http\Message\ResponseInterface|null
  307. * @throws \Exception
  308. */
  309. public function refundTransaction(Transaction $oTransaction, PaynovatePayment $oPaynovatePayment)
  310. {
  311. $oOrder = $this->oOrderRepository->findById($oTransaction->getPayment()->getToken()->getEntityId());
  312. $sTransactionUnique = strtoupper(bin2hex(random_bytes(8)));
  313. $sOrderRef = 'Refund ' . $oOrder->getReference() . '-' . gmdate('Ymd-His');
  314. $aParams = [
  315. 'merchantID' => $this->sMerchantId,
  316. 'action' => 'REFUND_SALE',
  317. 'xref' => $oPaynovatePayment->getXref(),
  318. 'amount' => (int)($oTransaction->getAmount() * 100),
  319. 'orderRef' => $sOrderRef,
  320. 'transactionUnique' => $sTransactionUnique,
  321. 'duplicateDelay' => '0',
  322. 'merchantData' => $sTransactionUnique,
  323. 'callbackURL' => $this->sWebhookUrl,
  324. ];
  325. $sQueryString = $this->buildQueryString($aParams);
  326. $sSignature = $this->generateSignature($sQueryString);
  327. $sPostBody = $sQueryString . '&signature=' . $sSignature;
  328. $aOptions = [
  329. 'body' => $sPostBody,
  330. 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
  331. 'allow_redirects' => false,
  332. ];
  333. try {
  334. $sDirectUrl = rtrim($this->sIntegrationUrl, '/') . '/direct/';
  335. $this->oLogger->info('[refundTransaction] Sending REFUND_SALE request', [
  336. 'endpoint' => $sDirectUrl,
  337. 'payload' => $aParams,
  338. 'signature' => substr($sSignature, 0, 20) . '...',
  339. ]);
  340. $oRequest = $this->oGuzzle->request('POST', $sDirectUrl, $aOptions);
  341. // Handle 302 redirects (response in Location header query params)
  342. if ($oRequest->getStatusCode() === 302) {
  343. $sLocation = $oRequest->getHeader('Location')[0] ?? '';
  344. $aParsedUrl = parse_url($sLocation);
  345. if (isset($aParsedUrl['query'])) {
  346. $oRequest = new \GuzzleHttp\Psr7\Response(200, [], $aParsedUrl['query']);
  347. }
  348. }
  349. $oRequest->getBody()->rewind();
  350. $sResponseBody = $oRequest->getBody()->getContents();
  351. $oRequest->getBody()->rewind();
  352. $this->oLogger->info('[refundTransaction] Received response', [
  353. 'status_code' => $oRequest->getStatusCode(),
  354. 'headers' => $oRequest->getHeaders(),
  355. 'body' => $sResponseBody,
  356. ]);
  357. } catch (\Exception $oException) {
  358. $this->oLogger->error('[refundTransaction] Error calling Paynovate', [
  359. 'message' => $oException->getMessage(),
  360. ]);
  361. $oRequest = null;
  362. }
  363. return $oRequest;
  364. }
  365. /**
  366. * @param Transaction $oTransaction Transaction
  367. * @param PaynovatePayment $oPaynovatePayment oPaynovatePayment
  368. * @return \Psr\Http\Message\ResponseInterface|null
  369. * @throws \Exception
  370. */
  371. public function cancelTransaction(Transaction $oTransaction, PaynovatePayment $oPaynovatePayment)
  372. {
  373. $sXref = $oPaynovatePayment->getXref();
  374. if (empty($sXref)) {
  375. throw new \Exception('Cannot cancel transaction: xref is missing');
  376. }
  377. $aParams = [
  378. 'merchantID' => $this->sMerchantId,
  379. 'action' => 'CANCEL',
  380. 'xref' => $sXref,
  381. ];
  382. $sQueryString = $this->buildQueryString($aParams);
  383. $sSignature = $this->generateSignature($sQueryString);
  384. $sPostData = $sQueryString . '&signature=' . $sSignature;
  385. $aOptions = [
  386. 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
  387. 'body' => $sPostData,
  388. ];
  389. $this->oLogger->info('[cancelTransaction] Sending CANCEL request', [
  390. 'endpoint' => $this->sDirectEndpoint,
  391. 'payload' => $aParams,
  392. 'signature' => substr($sSignature, 0, 20) . '...',
  393. ]);
  394. try {
  395. $oRequest = $this->oGuzzle->request('POST', $this->sDirectEndpoint, $aOptions);
  396. $oRequest->getBody()->rewind();
  397. $sResponseBody = $oRequest->getBody()->getContents();
  398. $oRequest->getBody()->rewind();
  399. $this->oLogger->info('[cancelTransaction] Received response', [
  400. 'status_code' => $oRequest->getStatusCode(),
  401. 'headers' => $oRequest->getHeaders(),
  402. 'body' => $sResponseBody,
  403. ]);
  404. } catch (\Exception $exception) {
  405. $this->oLogger->error('[cancelTransaction] Error calling Paynovate', [
  406. 'message' => $exception->getMessage(),
  407. ]);
  408. throw $exception;
  409. }
  410. return $oRequest;
  411. }
  412. /**
  413. * Continue 3DS authentication flow
  414. * Called after the 3DS "method" callback to continue the authentication
  415. *
  416. * @param string $sThreeDSRef The threeDSRef from initial payment response
  417. * @return \Psr\Http\Message\ResponseInterface
  418. * @throws \Exception
  419. */
  420. public function continue3DS($sThreeDSRef, array $aAdditionalData = [])
  421. {
  422. $this->oLogger->info('[continue3DS] Starting 3DS continuation', [
  423. 'threeDSRef' => substr($sThreeDSRef, 0, 20) . '...',
  424. 'has_additional_data' => !empty($aAdditionalData)
  425. ]);
  426. // Build parameters for continue3DS request
  427. $aData = [
  428. 'merchantID' => $this->sMerchantId,
  429. 'threeDSRef' => $sThreeDSRef,
  430. ];
  431. // Add additional data (like threeDSResponse)
  432. if (!empty($aAdditionalData)) {
  433. $aData = array_merge($aData, $aAdditionalData);
  434. $this->oLogger->info('[continue3DS] Merged additional data', [
  435. 'data_keys' => array_keys($aAdditionalData),
  436. 'full_data' => json_encode($aData)
  437. ]);
  438. }
  439. // Build query string
  440. // Use http_build_query for nested arrays (like threeDSResponse)
  441. // Sort by keys first (required for signature)
  442. ksort($aData);
  443. $sQueryString = http_build_query($aData, '', '&', PHP_QUERY_RFC3986);
  444. $this->oLogger->info('[continue3DS] Built query string', [
  445. 'query_string' => substr($sQueryString, 0, 500) . '...'
  446. ]);
  447. // Generate signature
  448. $sSignature = $this->generateSignature($sQueryString);
  449. // Add signature to query string
  450. $sPostData = $sQueryString . '&signature=' . $sSignature;
  451. $aOptions = [
  452. 'headers' => [
  453. 'Content-Type' => 'application/x-www-form-urlencoded',
  454. ],
  455. 'body' => $sPostData,
  456. ];
  457. $this->oLogger->info('[continue3DS] Sending request to Paynovate', [
  458. 'endpoint' => $this->sDirectEndpoint,
  459. 'payload' => $aData,
  460. 'signature' => substr($sSignature, 0, 20) . '...',
  461. ]);
  462. try {
  463. $oRequest = $this->oGuzzle->request('POST', $this->sDirectEndpoint, $aOptions);
  464. $sResponseBody = $oRequest->getBody()->getContents();
  465. $iStatusCode = $oRequest->getStatusCode();
  466. // Log response headers for debugging
  467. $aHeaders = [];
  468. foreach ($oRequest->getHeaders() as $sName => $aValues) {
  469. $aHeaders[$sName] = implode(', ', $aValues);
  470. }
  471. $this->oLogger->info('[continue3DS] Received response from Paynovate', [
  472. 'endpoint' => $this->sDirectEndpoint,
  473. 'status_code' => $iStatusCode,
  474. 'headers' => $aHeaders,
  475. 'body' => $sResponseBody,
  476. ]);
  477. // IMPORTANT: Paynovate returns 3DS parameters in the Location header of a 302 response
  478. if ($iStatusCode == 302 && $oRequest->hasHeader('Location')) {
  479. $sLocationUrl = $oRequest->getHeader('Location')[0];
  480. $this->oLogger->info('[continue3DS] Detected 302 redirect', [
  481. 'location' => $sLocationUrl
  482. ]);
  483. // Parse the query string from the Location header
  484. $aParsedUrl = parse_url($sLocationUrl);
  485. if (!empty($aParsedUrl['query'])) {
  486. parse_str($aParsedUrl['query'], $aLocationParams);
  487. // Create a new response with the parameters in the body as form-urlencoded
  488. $sNewBody = http_build_query($aLocationParams);
  489. $this->oLogger->info('[continue3DS] Extracted params from Location header', [
  490. 'params' => $aLocationParams
  491. ]);
  492. // Return a new response object with the extracted parameters in the body
  493. return new GuzzleResponse(
  494. 200, // Change status to 200 so PaymentService treats it as success
  495. ['Content-Type' => 'application/x-www-form-urlencoded'],
  496. $sNewBody
  497. );
  498. }
  499. }
  500. if (in_array($iStatusCode, [Response::HTTP_OK, Response::HTTP_ACCEPTED, Response::HTTP_CREATED])) {
  501. return $oRequest;
  502. }
  503. throw new \Exception('continue3DS error: unexpected status code ' . $iStatusCode);
  504. } catch (ClientException $oException) {
  505. $sResponseBody = $oException->getResponse()->getBody()->getContents();
  506. $this->oLogger->error('[continue3DS] Error response from Paynovate', [
  507. 'endpoint' => $this->sDirectEndpoint,
  508. 'status_code' => $oException->getResponse()->getStatusCode(),
  509. 'body' => $sResponseBody,
  510. ]);
  511. throw new \Exception('Paynovate continue3DS error: ' . $sResponseBody);
  512. }
  513. }
  514. /**
  515. * Get Payment informations
  516. * @param PaynovatePayment $oPaynovatePayment $oPaynovatePayment
  517. * @return \Psr\Http\Message\ResponseInterface
  518. * @throws \Exception
  519. */
  520. public function getPaymentInformation(PaynovatePayment $oPaynovatePayment)
  521. {
  522. $aParams = [
  523. 'merchantID' => $this->sMerchantId,
  524. 'action' => 'QUERY',
  525. 'xref' => $oPaynovatePayment->getXref(),
  526. ];
  527. // Build query string for signature (same as other methods)
  528. $sQueryString = $this->buildQueryString($aParams);
  529. $sSignature = $this->generateSignature($sQueryString);
  530. $sPostData = $sQueryString . '&signature=' . $sSignature;
  531. $aOptions = [
  532. 'headers' => [
  533. 'Content-Type' => 'application/x-www-form-urlencoded',
  534. ],
  535. 'body' => $sPostData,
  536. ];
  537. $this->oLogger->info('[getPaymentInformation] Sending request to Paynovate', [
  538. 'endpoint' => $this->sIntegrationUrl . '/direct/',
  539. 'xref' => $oPaynovatePayment->getXref(),
  540. ]);
  541. $oRequest = $this->oGuzzle->request('POST', $this->sIntegrationUrl.'/direct/', $aOptions);
  542. $sResponseBody = $oRequest->getBody()->getContents();
  543. $this->oLogger->info('[getPaymentInformation] Received response from Paynovate', [
  544. 'endpoint' => $this->sIntegrationUrl . '/direct/',
  545. 'status_code' => $oRequest->getStatusCode(),
  546. 'body' => $sResponseBody,
  547. ]);
  548. $oRequest->getBody()->rewind();
  549. if ($oRequest->getStatusCode() == Response::HTTP_OK) {
  550. return $oRequest;
  551. }
  552. throw new \Exception('error_paynovate_get_transaction_information');
  553. }
  554. /**
  555. * Get payment information
  556. * @param Transaction $oTransaction transaction
  557. * @return array
  558. * @throws \Exception
  559. */
  560. protected function getCardType(Transaction $oTransaction)
  561. {
  562. $oCard = $oTransaction->getPaymentMethod();
  563. switch (substr($oCard->getNumber(), 0, 1)) {
  564. case PaynovatePayment::CARD_MASTERCARD_INT:
  565. $aType['label'] = 'MASTERCARD';
  566. $aType['code'] = PaynovatePayment::CARD_MASTERCARD_INT;
  567. break;
  568. case PaynovatePayment::CARD_VISA_INT:
  569. $aType['label'] = 'VISA';
  570. $aType['code'] = PaynovatePayment::CARD_VISA_INT;
  571. break;
  572. default:
  573. throw new \Exception();
  574. }
  575. return $aType;
  576. }
  577. /**
  578. * Get iso3 code
  579. * @param string $sIso2 iso2 code
  580. * @return int|mixed|string
  581. * @throws \Exception
  582. */
  583. private function getIso3Code($sIso2Code)
  584. {
  585. if (strlen($sIso2Code) === 3) {
  586. return $sIso2Code;
  587. }
  588. $aCountryCode = array_flip($this->aCountryCode);
  589. if (isset($aCountryCode[$sIso2Code])) {
  590. return $aCountryCode[$sIso2Code];
  591. }
  592. throw new \Exception('Bad ISO code');
  593. }
  594. /**
  595. * Get numeric country code for Direct API
  596. * @param string $sIso2Code ISO2 country code
  597. * @return string
  598. */
  599. /**
  600. * Get currency numeric code from ISO3 code
  601. *
  602. * @param string $sIso3Code ISO3 currency code (EUR, GBP, USD, etc.)
  603. * @return string Numeric currency code (978, 826, 840, etc.)
  604. */
  605. private function getCurrencyNumericCode($sIso3Code)
  606. {
  607. // Use injected currency codes from parameters.yml
  608. if (isset($this->aCurrencyCode[$sIso3Code])) {
  609. return (string) $this->aCurrencyCode[$sIso3Code];
  610. }
  611. // Fallback par défaut: EUR
  612. $this->oLogger->warning('[getCurrencyNumericCode] Unknown currency: ' . $sIso3Code . ', using EUR (978) as fallback');
  613. return '978';
  614. }
  615. /**
  616. * Get country numeric code from ISO2 code
  617. *
  618. * @param string $sIso2Code ISO2 country code (FR, GB, US, etc.)
  619. * @return string Numeric country code (250, 826, 840, etc.)
  620. */
  621. private function getCountryNumericCode($sIso2Code)
  622. {
  623. // Map ISO2 to numeric codes (ISO 3166-1 numeric)
  624. $aNumericCodes = [
  625. 'FR' => '250', // France
  626. 'GB' => '826', // UK
  627. 'US' => '840', // USA
  628. 'DE' => '276', // Germany
  629. 'ES' => '724', // Spain
  630. 'IT' => '380', // Italy
  631. 'BE' => '056', // Belgium
  632. 'NL' => '528', // Netherlands
  633. // Ajouter d'autres pays si nécessaire
  634. ];
  635. return $aNumericCodes[$sIso2Code] ?? '250'; // Par défaut FR
  636. }
  637. /**
  638. * Verify webhook signature
  639. *
  640. * @param array $aData webhook data
  641. * @param string $sReceivedSignature received signature
  642. * @return bool
  643. */
  644. public function verifyWebhookSignature(array $aData, $sReceivedSignature)
  645. {
  646. $aDataCopy = $aData;
  647. unset($aDataCopy['signature']);
  648. ksort($aDataCopy);
  649. $sQueryString = http_build_query($aDataCopy, '', '&');
  650. $sCalculatedSignature = hash('SHA512', $sQueryString . $this->sSignatureKey);
  651. return hash_equals($sCalculatedSignature, $sReceivedSignature);
  652. }
  653. }