src/UI/Frontend/Controller/PaymentController.php line 47

Open in your IDE?
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\UI\Frontend\Controller;
  4. use App\Domain\Payment\Model\Payment;
  5. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  6. use Symfony\Component\HttpFoundation\Request;
  7. use Symfony\Component\HttpFoundation\Response;
  8. use Symfony\Component\Routing\Annotation\Route;
  9. use Doctrine\ORM\EntityManagerInterface;
  10. use Symfony\Component\HttpFoundation\JsonResponse;
  11. use Psr\Log\LoggerAwareInterface;
  12. use Psr\Log\LoggerAwareTrait;
  13. use GuzzleHttp\Client;
  14. use GuzzleHttp\ClientInterface;
  15. use GuzzleHttp\Exception\GuzzleException;
  16. use Psr\Http\Message\ResponseInterface;
  17. use App\Application\Common\CommonServices;
  18. class PaymentController extends AbstractController implements LoggerAwareInterface
  19. {
  20.     private EntityManagerInterface $em;
  21.     private ClientInterface $client;
  22.     private CommonServices $commonServices;
  23.     private string $appEnv;
  24.     private string $ibanxsAuthTokenUrl;
  25.     private string $ibanxsApiBaseUrl;
  26.     public function __construct(EntityManagerInterface $em, CommonServices $commonServices, string $appEnv, string $ibanxsAuthTokenUrl, string $ibanxsApiBaseUrl)
  27.     {
  28.         $this->em = $em;
  29.         $this->client = new Client();
  30.         $this->commonServices = $commonServices;
  31.         $this->appEnv = $appEnv;
  32.         $this->ibanxsAuthTokenUrl = $ibanxsAuthTokenUrl;
  33.         $this->ibanxsApiBaseUrl = $ibanxsApiBaseUrl;
  34.     }
  35.     use LoggerAwareTrait;
  36.     #[Route(path: '/payments/{id}', name: 'frontend_payments_proxy')]
  37.     public function proxy(Request $request, Payment $payment): Response
  38.     {
  39.         if ($payment->getStatus()->isCompleted()) {
  40.             $redirectUrl = $this->getQueryUrl($payment->getSuccessUrl(), $payment);
  41.             return $this->redirect($redirectUrl);
  42.         } elseif ($payment->getStatus()->isFailed()) {
  43.             return $this->redirect($payment->getFailureUrl());
  44.         } else {
  45.             $redirectUrl = $this->getQueryUrl($payment->getPendingUrl(), $payment);
  46.             return $this->redirect($redirectUrl);
  47.         }
  48.     }
  49.     public function getQueryUrl($url, $payment)
  50.     {
  51.         $amount = $payment->getFloatAmount();
  52.         // Parse the URL into components
  53.         $urlComponents = parse_url($url);
  54.         // Start building the URL
  55.         $updatedUrl = $urlComponents['scheme'] . '://' . $urlComponents['host'];
  56.         // Add the port if it exists
  57.         if (isset($urlComponents['port'])) {
  58.             $updatedUrl .= ':' . $urlComponents['port'];
  59.         }
  60.         // Add the path if it exists
  61.         if (isset($urlComponents['path'])) {
  62.             $updatedUrl .= $urlComponents['path'];
  63.         }
  64.         // Handle query parameters
  65.         $queryParams = [];
  66.         if (isset($urlComponents['query'])) {
  67.             parse_str($urlComponents['query'], $queryParams);
  68.         }
  69.         $queryParams['amount'] = number_format($amount, 2, '.', '');
  70.         // Append the query string
  71.         $updatedUrl .= '?' . http_build_query($queryParams);
  72.         return $updatedUrl;
  73.     }
  74.     #[Route(path: '/genome/payment/notify/{id}', name: 'genome_payment_webhook', methods: ['POST'])]
  75.     public function genomeWebhook(string $id, Request $request): Response
  76.     {
  77.         try {
  78.             // You can log, validate, or queue with the ID directly
  79.             $payload = json_decode($request->getContent(), true);
  80.             // Add the request URL into the payload
  81.             $payload['request_url'] = $request->getUri();
  82.             // Example: log and return immediately
  83.             $this->logger?->info('Received webhook for ID: ' . $id, ['payload' => $payload]);
  84.             $this->validateAndUpdatePayment($payload, 'genome');
  85.             return new JsonResponse(['status' => 'Webhook received']);
  86.         } catch (\Throwable $e) {
  87.             $this->logger?->error('Webhook error: ' . $e->getMessage());
  88.             return new JsonResponse(['error' => 'Internal error'], Response::HTTP_INTERNAL_SERVER_ERROR);
  89.         }
  90.     }
  91.     #[Route(path: '/isx/payment/notify', name: 'isx_payment_webhook', methods: ['POST'])]
  92.     public function isxWebhook(Request $request): Response
  93.     {
  94.         try {
  95.             // You can log, validate, or queue with the ID directly
  96.             $payload = json_decode($request->getContent(), true);
  97.             // Add the request URL into the payload
  98.             $payload['request_url'] = $request->getUri();
  99.             // Example: log and return immediately
  100.             $this->logger?->info('Received isxWebhook: ', ['payload' => $payload]);
  101.             $this->validateAndUpdatePayment($payload, 'isx');
  102.             return new JsonResponse(['status' => 'Webhook received']);
  103.         } catch (\Throwable $e) {
  104.             $this->logger?->error('Webhook error: ' . $e->getMessage());
  105.             return new JsonResponse(['error' => 'Internal error'], Response::HTTP_INTERNAL_SERVER_ERROR);
  106.         }
  107.     }
  108.     private function validateAndUpdatePayment(array $payload, string $type): void
  109.     {
  110.         try {
  111.             if ($type === 'genome') {
  112.                 $description = $payload['description'] ?? null;
  113.                 $mappingId = $this->extractMappingId($description);
  114.                 $currency = $payload['amount']['currency'] ?? null;
  115.                 $rawAmount = $payload['amount']['amount'] ?? 0.0;
  116.                 $amount = (int) round((float) $rawAmount * 100); // Convert to cents
  117.                 $senderName = $payload['sender']['name'] ?? null;
  118.             } elseif ($type === 'isx') {
  119.                 $description = $payload['payment_provider_responses'][0]['details'] ?? null;
  120.                 $mappingId = $this->extractMappingId($description);
  121.                 $currency = $payload['payment_amount']['currency'] ?? null;
  122.                 $amount = $payload['payment_amount']['amount'] ?? 0.0;
  123.                 $senderName = $payload['original_sender_name'] ?? null;
  124.             } else {
  125.                 $this->logger?->warning('Unsupported webhook type: ' . $type);
  126.                 return;
  127.             }
  128.             if (!$description || !$amount || !$currency) {
  129.                 $this->logger?->info('Invalid payload: missing fields', [
  130.                     'description' => $description,
  131.                     'mappiingId' => $mappingId,
  132.                     'amount' => $amount,
  133.                     'currency' => $currency,
  134.                     'payload' => $payload,
  135.                 ]);
  136.                 return;
  137.             }
  138.             $conn = $this->em->getConnection();
  139.             // Step 1: Check for matching payment
  140.             $sql = "SELECT * FROM payments WHERE mapping_id = :mapping_id AND amount = :amount AND currency = :currency LIMIT 1";
  141.             $result = $conn->fetchAssociative($sql, [
  142.                 'mapping_id' => $mappingId,
  143.                 'amount' => $amount,
  144.                 'currency' => $currency,
  145.             ]);
  146.             // Step 2: Update if found
  147.             if ($result) {
  148.                 if ($result['status'] === 'completed') {
  149.                     $this->logger?->info('Payment already completed', [
  150.                         'description' => $description,
  151.                         'mappingId' => $mappingId,
  152.                         'amount' => $amount,
  153.                         'currency' => $currency,
  154.                     ]);
  155.                     return; // Payment already completed
  156.                 }
  157.                 $sql = "SELECT secret, billing_information_is_bank_webhook FROM sites WHERE id = :id  LIMIT 1";
  158.                 $site = $conn->fetchAssociative($sql, [
  159.                     'id' => $result['site_id']
  160.                 ]);
  161.                 if (!$site['billing_information_is_bank_webhook']) {
  162.                     $this->logger?->info('Bank Webhook is not enabled');
  163.                     return; // Bank Webhook is not enabled
  164.                 }
  165.                 $logs = [
  166.                     'title' => 'Received new webhook call from Bank',
  167.                     'details' => $payload
  168.                 ];
  169.                 $updateSql = "UPDATE payments SET  status = 'completed', payment_received = True, receiving_response = :response, bank_status = 'completed', updated_at = NOW() WHERE mapping_id = :mapping_id";
  170.                 $conn->executeStatement($updateSql, [
  171.                     'response' => json_encode($payload),
  172.                     'mapping_id' => $mappingId,
  173.                 ]);
  174.                 $this->commonServices->savePaymentLogs($result['id'], 200, $logs);
  175.                 $this->commonServices->saveUserPaymentInfo(payment_id: $result['id'], name: $senderName);
  176.                 $this->sendMerchantNotification($result, $site['secret']);
  177.                 $this->logger?->info("Payment updated successfully for ID: {$description}");
  178.             } else {
  179.                 $this->logger?->warning("No matching payment found", [
  180.                     'description' => $description,
  181.                     'mappiingId' => $mappingId,
  182.                     'amount' => $amount,
  183.                     'currency' => $currency,
  184.                 ]);
  185.             }
  186.         } catch (\Throwable $e) {
  187.             $this->logger?->error('Error in validateAndUpdatePayment: ' . $e->getMessage(), [
  188.                 'exception' => $e
  189.             ]);
  190.         }
  191.     }
  192.     // private function extractMappingId(string $description)
  193.     // {
  194.     //     $conn = $this->em->getConnection();
  195.     //     $sql = "SELECT mapping_id FROM payments WHERE created_at >= NOW() - INTERVAL '80 hours'  AND bank_status = 'initiated' ORDER BY created_at DESC";
  196.     //     $results = $conn->fetchAllAssociative($sql);
  197.     //     foreach ($results as $row) {
  198.     //         if (isset($row['mapping_id']) && stripos($description, (string)$row['mapping_id']) !== false) {
  199.     //             return $row['mapping_id']; // Found mapping_id inside description
  200.     //         }
  201.     //     }
  202.     // }
  203.     private function extractMappingId(string $description)
  204.     {
  205.         $conn = $this->em->getConnection();
  206.         $sql = "
  207.             SELECT mapping_id
  208.             FROM payments
  209.             WHERE bank_status = 'initiated'
  210.             AND pisp_status NOT IN ('rejected', 'initiated')
  211.             AND :description ILIKE '%' || mapping_id || '%'
  212.             ORDER BY LENGTH(mapping_id) DESC, created_at DESC
  213.             LIMIT 1
  214.         ";
  215.         $mappingId = $conn->fetchOne($sql, [
  216.             'description' => $description,
  217.         ]);
  218.         if ($mappingId === false || $mappingId === null) {
  219.             $this->logger?->warning('No mapping_id found in description', [
  220.                 'description' => $description,
  221.             ]);
  222.             return null;
  223.         }
  224.         return $mappingId;
  225.     }
  226.     public function sendMerchantNotification(array $paymentNotification, string $secret): bool
  227.     {
  228.         $payment = $paymentNotification;
  229.         $payload = [
  230.             'id' => $payment['id'],
  231.             'paymentId' => $payment['external_id'],
  232.             'pisp_status' => $payment['pisp_status'],
  233.             'fin_status' => 'completed',
  234.             'currency' => $paymentNotification['currency'],
  235.             'amount' => $payment['amount'] / 100,
  236.             'userId' => $payment['customer_id']
  237.         ];
  238.         try {
  239.             $response = $this->sendRequest('POST', $paymentNotification['notification_url'], $payload, $secret);
  240.         } catch (GuzzleException $exception) {
  241.             $this->logger->error('Merchant notification failed', [
  242.                 'id' => $payment['id'],
  243.                 'paymentId' => $payment['external_id'],
  244.                 'error' => $exception->getMessage()
  245.             ]);
  246.             $saveFailedNotification = $this->commonServices->saveFailedNotifications(
  247.                 $payment['id'],
  248.                 $payload,
  249.                 $paymentNotification['notification_url'],
  250.                 $secret
  251.             );
  252.             $logs = [
  253.                 'title' => 'Failed to send Bank status update to merchant',
  254.                 'details' => $payload
  255.             ];
  256.             $this->commonServices->savePaymentLogs($payment['id'], $response->getStatusCode(), $logs);
  257.             if ($saveFailedNotification) {
  258.                 $this->logger->warning('Merchant failed notification saved to DB', [
  259.                     'id' => $payment['id'],
  260.                     'paymentId' => $payment['external_id'],
  261.                 ]);
  262.             } else {
  263.                 $this->logger->warning('Merchant failed notification failed to save to DB', [
  264.                     'id' => $payment['id'],
  265.                     'paymentId' => $payment['external_id'],
  266.                 ]);
  267.             }
  268.             return false;
  269.         }
  270.         if ($response->getStatusCode() >= 200 && $response->getStatusCode() < 300) {
  271.             $this->logger->info('Merchant notification sent successfully', [
  272.                 'id' => $payment['id'],
  273.                 'paymentId' => $payment['external_id']
  274.             ]);
  275.             $logs = [
  276.                 'title' => 'The Bank status update was successfully sent to the merchant',
  277.                 'details' => $payload
  278.             ];
  279.             $this->commonServices->savePaymentLogs($payment['id'], $response->getStatusCode(), $logs);
  280.             return true;
  281.         }
  282.         $logs = [
  283.             'title' => 'Failed to send Bank status update to merchant',
  284.             'details' => $payload
  285.         ];
  286.         $this->commonServices->savePaymentLogs($payment['id'], $response->getStatusCode(), $logs);
  287.         $this->logger->error('Merchant notification failed', [
  288.             'id' => $payment['id'],
  289.             'paymentId' => $payment['external_id'],
  290.             'statusCode' => $response->getStatusCode(),
  291.             'error' => $response->getBody()->getContents()
  292.         ]);
  293.         $saveFailedNotification = $this->commonServices->saveFailedNotifications(
  294.             $payment['id'],
  295.             $payload,
  296.             $paymentNotification['notification_url'],
  297.             $secret
  298.         );
  299.         if ($saveFailedNotification) {
  300.             $this->logger->warning('Merchant failed notification saved to DB', [
  301.                 'id' => $payment['id'],
  302.                 'paymentId' => $payment['external_id'],
  303.             ]);
  304.         } else {
  305.             $this->logger->warning('Merchant failed notification failed to save to DB', [
  306.                 'id' => $payment['id'],
  307.                 'paymentId' => $payment['external_id'],
  308.             ]);
  309.         }
  310.         return false;
  311.     }
  312.     /**
  313.      * @throws GuzzleException
  314.      */
  315.     private function sendRequest(string $method, string $uri, array $params, string $secretKey): ResponseInterface
  316.     {
  317.         $jsonBase64 = base64_encode(json_encode($params));
  318.         return $this->client->request($method, $uri, [
  319.             'headers' => [
  320.                 'X-Signature' => $this->generateSignature($jsonBase64, $secretKey),
  321.             ],
  322.             'body' => $jsonBase64,
  323.             'http_errors' => false,
  324.             'timeout' => 10,
  325.         ]);
  326.     }
  327.     private function generateSignature(string $jsonBase64, string $secretKey): string
  328.     {
  329.         return hash_hmac('sha256', $jsonBase64, $secretKey);
  330.     }
  331.     #[Route(path: '/countries/list', name: 'countries_list', methods: ['GET'])]
  332.     public function getCountries(Request $request): JsonResponse
  333.     {
  334.         $authorization = $request->headers->get('Authorization');
  335.         $factoryNameConfigDtls = $this->getFactoryNameAndConfig($authorization);
  336.         $factoryName = $factoryNameConfigDtls['factoryName'];
  337.         $configDtls = $factoryNameConfigDtls['config'];
  338.         switch ($factoryName) {
  339.             case 'yapily':
  340.                 return $this->json([
  341.                     'success' => false,
  342.                     'message' => 'Yapily does not support this feature',
  343.                 ], 415);
  344.                 break;
  345.             case 'iban_xs':
  346.                 try {
  347.                     $token = $this->getIbanXsToken($configDtls);
  348.                     $client = new Client();
  349.                     $response = $client->get($this->ibanxsApiBaseUrl . 'v1/countries', [
  350.                         'headers' => [
  351.                             'Accept' => 'application/json',
  352.                             'Authorization' => 'Bearer ' . $token,
  353.                         ],
  354.                     ]);
  355.                     return $this->json([
  356.                         'success' => true,
  357.                         'data' => json_decode($response->getBody()->getContents(), true),
  358.                     ]);
  359.                 } catch (\Throwable $e) {
  360.                     return $this->json([
  361.                         'success' => false,
  362.                         'message' => $e->getMessage(),
  363.                     ], 500);
  364.                 }
  365.                 break;
  366.             default:
  367.                 return $this->json([
  368.                     'success' => false,
  369.                     'message' => 'Invalid Payment Factory',
  370.                 ], 400);
  371.                 break;
  372.         }
  373.     }
  374.     #[Route(path: '/countries/banks/list', name: 'countries_banks_list', methods: ['GET'])]
  375.     public function getBanksForCountry(Request $request): JsonResponse
  376.     {
  377.         $countryCode = $request->query->get('countryCode');
  378.         if (!$countryCode) {
  379.             return $this->json([
  380.                 'success' => false,
  381.                 'message' => 'countryCode query parameter is required.',
  382.             ], 400);
  383.         }
  384.         $authorization = $request->headers->get('Authorization');
  385.         $factoryNameConfigDtls = $this->getFactoryNameAndConfig($authorization);
  386.         $factoryName = $factoryNameConfigDtls['factoryName'];
  387.         $configDtls = $factoryNameConfigDtls['config'];
  388.         switch ($factoryName) {
  389.             case 'yapily':
  390.                 return $this->json([
  391.                     'success' => false,
  392.                     'message' => 'Yapily does not support this feature',
  393.                 ], 415);
  394.                 break;
  395.             case 'iban_xs':
  396.                 try {
  397.                     $token = $this->getIbanXsToken($configDtls);
  398.                     $client = new Client();
  399.                     $response = $client->get($this->ibanxsApiBaseUrl . 'v1/countries/' . $countryCode . '/banks', [
  400.                         'headers' => [
  401.                             'Accept' => 'application/json',
  402.                             'Authorization' => 'Bearer ' . $token,
  403.                         ],
  404.                     ]);
  405.                     $data = json_decode($response->getBody()->getContents(), true);
  406.                     $banks = array_map(static function (array $bank) {
  407.                         return [
  408.                             'bankId' => $bank['bankId'],
  409.                             'name' => $bank['name'],
  410.                         ];
  411.                     }, $data);
  412.                     return $this->json([
  413.                         'success' => true,
  414.                         'data' => $banks,
  415.                     ]);
  416.                 } catch (\Throwable $e) {
  417.                     return $this->json([
  418.                         'success' => false,
  419.                         'message' => $e->getMessage(),
  420.                     ], 500);
  421.                 }
  422.                 break;
  423.             default:
  424.                 return $this->json([
  425.                     'success' => false,
  426.                     'message' => 'Invalid Payment Factory',
  427.                 ], 400);
  428.                 break;
  429.         }
  430.     }
  431.     #[Route(path: '/banks/parameters', name: 'banks_parameters', methods: ['GET'])]
  432.     public function getBanksParameters(Request $request): JsonResponse
  433.     {
  434.         $bankId = $request->query->get('bankId');
  435.         if (!$bankId) {
  436.             return $this->json([
  437.                 'success' => false,
  438.                 'message' => 'bankId query parameter is required.',
  439.             ], 400);
  440.         }
  441.         $authorization = $request->headers->get('Authorization');
  442.         $factoryNameConfigDtls = $this->getFactoryNameAndConfig($authorization);
  443.         $factoryName = $factoryNameConfigDtls['factoryName'];
  444.         $configDtls = $factoryNameConfigDtls['config'];
  445.         switch ($factoryName) {
  446.             case 'yapily':
  447.                 return $this->json([
  448.                     'success' => false,
  449.                     'message' => 'Yapily does not support this feature',
  450.                 ], 415);
  451.                 break;
  452.             case 'iban_xs':
  453.                 try {
  454.                     $token = $this->getIbanXsToken($configDtls);
  455.                     $client = new Client();
  456.                     $response = $client->get($this->ibanxsApiBaseUrl . 'v1/banks/' . $bankId .'/specification/PIS', [
  457.                         'headers' => [
  458.                             'Accept' => 'application/json',
  459.                             'Authorization' => 'Bearer ' . $token,
  460.                         ],
  461.                     ]);
  462.                     $data = json_decode($response->getBody()->getContents(), true);
  463.                     return $this->json([
  464.                         'success' => true,
  465.                         'data' => $data,
  466.                     ]);
  467.                 } catch (\Throwable $e) {
  468.                     return $this->json([
  469.                         'success' => false,
  470.                         'message' => $e->getMessage(),
  471.                     ], 500);
  472.                 }
  473.                 break;
  474.             default:
  475.                 return $this->json([
  476.                     'success' => false,
  477.                     'message' => 'Invalid Payment Factory',
  478.                 ], 400);
  479.                 break;
  480.         }
  481.     }
  482.     // HELPER FUNCTIONS
  483.     private function getFactoryNameAndConfig(string $authorization)
  484.     {
  485.         if (!$authorization || !str_starts_with($authorization, 'Basic ')) {
  486.             return $this->json([
  487.                 'success' => false,
  488.                 'message' => 'Missing or invalid Authorization header',
  489.             ], 401);
  490.         }
  491.         $base64 = substr($authorization, 6);
  492.         $decoded = base64_decode($base64, true);
  493.         if ($decoded === false) {
  494.             return $this->json([
  495.                 'success' => false,
  496.                 'message' => 'Invalid Basic token',
  497.             ], 401);
  498.         }
  499.         // Format: username:password (username is expected to be site UUID, password is site password)
  500.         $parts = explode(':', $decoded, 2);
  501.         $username = $parts[0] ?? null;
  502.         $password = $parts[1] ?? null;
  503.         if (!$username || $password === null) {
  504.             return $this->json([
  505.                 'success' => false,
  506.                 'message' => 'Invalid Basic token format',
  507.             ], 401);
  508.         }
  509.         // Validate against DB (matches SiteAuthenticator/SiteLoginProvider contract)
  510.         try {
  511.             $sites = $this->em
  512.                 ->getRepository(\App\Domain\Site\Model\Site::class)
  513.                 ->findById(\Symfony\Component\Uid\Uuid::fromString((string) $username));
  514.             $site = $sites[0] ?? null;
  515.             if (!$site) {
  516.                 return $this->json([
  517.                     'success' => false,
  518.                     'message' => 'Invalid credentials',
  519.                 ], 401);
  520.             }
  521.         } catch (\Throwable $e) {
  522.             return $this->json([
  523.                 'success' => false,
  524.                 'message' => 'Invalid credentials',
  525.             ], 401);
  526.         }
  527.         $hash = $site->getPassword();
  528.         if (!is_string($hash) || $hash === '') {
  529.             return $this->json([
  530.                 'success' => false,
  531.                 'message' => 'Invalid credentials',
  532.             ], 401);
  533.         }
  534.         // Password hash check
  535.         if ($password !== $hash) {
  536.             return $this->json([
  537.                 'success' => false,
  538.                 'message' => 'Invalid credentials',
  539.             ], 401);
  540.         }
  541.         $factoryName = $site->getPaymentGatewayConfig()->getFactoryName();
  542.         $config = $site->getPaymentGatewayConfig()->getConfig();
  543.         return [
  544.             'factoryName' => $factoryName,
  545.             'config' => $config
  546.         ];
  547.     }
  548.     private function getIbanXsToken($configDtls): string
  549.     {
  550.         $client = new Client();
  551.         $response = $client->post($this->ibanxsAuthTokenUrl, [
  552.             'headers' => [
  553.                 'Content-Type' => 'application/x-www-form-urlencoded',
  554.             ],
  555.             'form_params' => [
  556.                 'client_id' => $configDtls['client_id'],
  557.                 'client_secret' => $configDtls['client_secret'],
  558.                 'grant_type' => 'client_credentials',
  559.             ],
  560.         ]);
  561.         $body = json_decode($response->getBody()->getContents(), true);
  562.         return $body['access_token'];
  563.     }
  564. }