src/UI/Admin/Controller/PaymentController.php line 36

Open in your IDE?
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\UI\Admin\Controller;
  4. use App\Domain\Payment\Model\Payment;
  5. use App\UI\Admin\Datatable\PaymentDatatable;
  6. use App\UI\Shared\Controller\AbstractController;
  7. use Doctrine\Persistence\ManagerRegistry;
  8. use Sg\DatatablesBundle\Response\DatatableResponse;
  9. use Symfony\Component\HttpFoundation\Request;
  10. use Symfony\Component\HttpFoundation\Response;
  11. use Symfony\Component\HttpFoundation\StreamedResponse;
  12. use Symfony\Component\Routing\Annotation\Route;
  13. use Symfony\Component\HttpFoundation\JsonResponse;
  14. use Doctrine\DBAL\Connection;
  15. use Symfony\Component\HttpFoundation\ResponseHeaderBag;
  16. use Psr\Log\LoggerAwareInterface;
  17. use Psr\Log\LoggerAwareTrait;
  18. use App\Application\Common\CommonServices;
  19. use GuzzleHttp\Client;
  20. use GuzzleHttp\Exception\ClientException;
  21. use GuzzleHttp\Exception\GuzzleException;
  22. use GuzzleHttp\Exception\ServerException;
  23. use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
  24. #[Route('/payments')]
  25. class PaymentController extends AbstractController implements LoggerAwareInterface
  26. {
  27.     use LoggerAwareTrait;
  28.     #[Route(path: '/', name: 'admin_payments')]
  29.     public function index(
  30.         Request $request,
  31.         PaymentDatatable $datatable,
  32.         DatatableResponse $datatableResponse,
  33.         \Doctrine\Persistence\ManagerRegistry $doctrine,
  34.     ): Response {
  35.         $isAjax = $request->isXmlHttpRequest();
  36.         $datatable->buildDatatable();
  37.         if ($isAjax) {
  38.             try {
  39.                 // Log raw incoming params
  40.                 // $this->logger->info('Request query parameters', $request->query->all());
  41.                 $data = $request->query->all();
  42.                 $tzName = $request->query->get('tz') ?? \date_default_timezone_get() ?? 'UTC';
  43.                 // Validate/normalize user tz
  44.                 try {
  45.                     $userTz = new \DateTimeZone($tzName);
  46.                 } catch (\Throwable) {
  47.                     $tzName = 'UTC';
  48.                     $userTz = new \DateTimeZone('UTC');
  49.                 }
  50.                 // $this->logger->info('TIMEZONE', ['tz' => $tzName]);
  51.                 // -------- Detect DB session timezone (MySQL / Postgres) --------
  52.                 $conn = $doctrine->getConnection();
  53.                 $dbTzName = 'UTC';
  54.                 try {
  55.                     $platform = $conn->getDatabasePlatform()->getName();
  56.                     if ($platform === 'mysql') {
  57.                         $sessionTz = $conn->fetchOne('SELECT @@time_zone');
  58.                         if ($sessionTz === 'SYSTEM') {
  59.                             $systemTz = $conn->fetchOne('SELECT @@system_time_zone');
  60.                             $dbTzName = $systemTz ?: 'UTC';
  61.                         } else {
  62.                             $dbTzName = $sessionTz ?: 'UTC';
  63.                         }
  64.                     } elseif ($platform === 'postgresql') {
  65.                         // SHOW TIME ZONE returns a single row, single column
  66.                         $dbTzName = $conn->fetchOne('SHOW TIME ZONE') ?: 'UTC';
  67.                     }
  68.                 } catch (\Throwable $e) {
  69.                     // keep default 'UTC' on any failure
  70.                 }
  71.                 // Ensure it's a valid IANA/offset for PHP
  72.                 try {
  73.                     $dbTz = new \DateTimeZone($dbTzName);
  74.                 } catch (\Throwable) {
  75.                     $dbTzName = 'UTC';
  76.                     $dbTz = new \DateTimeZone('UTC');
  77.                 }
  78.                 // $this->logger->info('DB TIMEZONE', ['db_tz' => $dbTzName, 'platform' => $conn->getDatabasePlatform()->getName()]);
  79.                 // -------- Strip createdAt LIKE filter --------
  80.                 $createdAtRange = null;
  81.                 if (isset($data['columns'])) {
  82.                     foreach ($data['columns'] as &$column) {
  83.                         if (($column['data'] ?? null) === 'createdAt' && !empty($column['search']['value'])) {
  84.                             $createdAtRange = explode('|', $column['search']['value']);
  85.                             $column['search']['value'] = '';
  86.                             $column['searchable'] = 'false';
  87.                         }
  88.                     }
  89.                     unset($column);
  90.                 }
  91.                 // Overwrite cleaned query
  92.                 $request->query->replace($data);
  93.                 // -------- Build query --------
  94.                 $datatableResponse->setDatatable($datatable);
  95.                 $queryBuilder = $datatableResponse->getDatatableQueryBuilder()->getQb();
  96.                 $alias = $queryBuilder->getRootAliases()[0];
  97.                 // Always show latest records at the top
  98.                 $queryBuilder->addOrderBy("$alias.createdAt", "DESC");
  99.                 if ($createdAtRange && count($createdAtRange) === 2) {
  100.                     $startStr = \trim($createdAtRange[0]) . ' 00:00:00';
  101.                     $endStr = \trim($createdAtRange[1]) . ' 23:59:59';
  102.                     // Interpret the picked dates as *user-local calendar days*
  103.                     $startLocal = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $startStr, $userTz);
  104.                     $endLocal = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $endStr, $userTz);
  105.                     if (!$startLocal || !$endLocal) {
  106.                         throw new \RuntimeException('Invalid createdAt date range.');
  107.                     }
  108.                     // Convert to *DB session timezone* we detected above
  109.                     $startDb = $startLocal->setTimezone($dbTz);
  110.                     $endDb = $endLocal->setTimezone($dbTz);
  111.                     $queryBuilder
  112.                         ->andWhere("$alias.createdAt BETWEEN :start AND :end")
  113.                         ->setParameter('start', $startDb, 'datetime_immutable')
  114.                         ->setParameter('end', $endDb, 'datetime_immutable');
  115.                     $this->logger->info('createdAt filter applied', [
  116.                         'user_tz' => $tzName,
  117.                         'db_tz' => $dbTzName,
  118.                         'start_loc' => $startLocal->format('Y-m-d H:i:sP'),
  119.                         'end_loc' => $endLocal->format('Y-m-d H:i:sP'),
  120.                         'start_db' => $startDb->format('Y-m-d H:i:sP'),
  121.                         'end_db' => $endDb->format('Y-m-d H:i:sP'),
  122.                     ]);
  123.                 }
  124.                 // -------- Return DataTables JSON --------
  125.                 $response = $datatableResponse->getResponse();
  126.                 // $this->logger->info('Payment datatable request completed.', [
  127.                 //     'response' => json_decode($response->getContent(), true)
  128.                 // ]);
  129.                 return $response;
  130.             } catch (\Throwable $e) {
  131.                 return new JsonResponse([
  132.                     'error' => true,
  133.                     'message' => $e->getMessage(),
  134.                     'trace' => $e->getTraceAsString(),
  135.                 ]);
  136.             }
  137.         }
  138.         return $this->render('admin/payment/index.html.twig', [
  139.             'datatable' => $datatable,
  140.         ]);
  141.     }
  142.     private const REFUND_IN_PROGRESS_STATUSES = ['initiated', 'pending'];
  143.     #[Route(path: '/{id}/refund-details', name: 'admin_payments_refund_details', methods: ['GET'])]
  144.     public function refundDetails(Payment $payment, ManagerRegistry $doctrine): JsonResponse
  145.     {
  146.         $factoryName = $payment->getSite()?->getPaymentGatewayConfig()?->getFactoryName();
  147.         if ('dimoco_pay' !== $factoryName) {
  148.             return $this->createErrorJsonResponse('Refunds are only available for Dimoco Pay payments.');
  149.         }
  150.         $summary = $this->computeRefundSummary($payment, $doctrine->getConnection());
  151.         return new JsonResponse([
  152.             'success' => true,
  153.             'currency' => $payment->getCurrency(),
  154.             'pisp_status' => $payment->getPispStatus(),
  155.             'total_amount' => $summary['total_amount'],
  156.             'refunded_amount' => $summary['refunded_amount'],
  157.             'remaining_amount' => $summary['remaining_amount'],
  158.             'in_progress' => $summary['in_progress'],
  159.             'can_refund' => 'completed' === $payment->getPispStatus() && $summary['can_refund'],
  160.             'history' => $summary['history'],
  161.         ]);
  162.     }
  163.     /**
  164.      * @return array{
  165.      *     seed_row: ?array,
  166.      *     total_amount: float,
  167.      *     refunded_amount: float,
  168.      *     remaining_amount: float,
  169.      *     in_progress: bool,
  170.      *     can_refund: bool,
  171.      *     history: list<array{amount: float, reason: ?string, status: ?string, created_at: ?string}>,
  172.      * }
  173.      */
  174.     private function computeRefundSummary(Payment $payment, Connection $conn): array
  175.     {
  176.         $refundRows = $conn->fetchAllAssociative(
  177.             'SELECT * FROM refund_requests WHERE payment_id = :payment_id ORDER BY id ASC',
  178.             ['payment_id' => (string) $payment->getId()]
  179.         );
  180.         // The first row is seeded at purchase time and carries session_id/reference_debit_id;
  181.         // every subsequent row (status not null) is one actual refund attempt.
  182.         $seedRow = $refundRows[0] ?? null;
  183.         $inProgress = false;
  184.         $refunded = 0.0;
  185.         $history = [];
  186.         foreach ($refundRows as $row) {
  187.             if (null === $row['status']) {
  188.                 continue;
  189.             }
  190.             if (in_array($row['status'], self::REFUND_IN_PROGRESS_STATUSES, true)) {
  191.                 $inProgress = true;
  192.             }
  193.             if ('completed' === $row['status']) {
  194.                 $refunded += (float) $row['amount'];
  195.             }
  196.             $history[] = [
  197.                 'amount' => (float) $row['amount'],
  198.                 'reason' => $row['reason'],
  199.                 'status' => $row['status'],
  200.                 'created_at' => $row['created_at'],
  201.             ];
  202.         }
  203.         $totalAmount = $payment->getAmount() / 100;
  204.         $remaining = round($totalAmount - $refunded, 2);
  205.         return [
  206.             'seed_row' => $seedRow,
  207.             'total_amount' => $totalAmount,
  208.             'refunded_amount' => round($refunded, 2),
  209.             'remaining_amount' => $remaining,
  210.             'in_progress' => $inProgress,
  211.             'can_refund' => !$inProgress && $remaining > 0.005 && $seedRow && !empty($seedRow['reference_debit_id']),
  212.             'history' => array_reverse($history),
  213.         ];
  214.     }
  215.     #[Route(path: '/{id}/refund', name: 'admin_payments_refund', methods: ['POST'])]
  216.     public function refund(Payment $payment, Request $request, CommonServices $commonServices, ManagerRegistry $doctrine): JsonResponse
  217.     {
  218.         $gatewayConfig = $payment->getSite()?->getPaymentGatewayConfig();
  219.         $factoryName = $gatewayConfig?->getFactoryName();
  220.         if ('dimoco_pay' !== $factoryName) {
  221.             return $this->createErrorJsonResponse('Refunds are only available for Dimoco Pay payments.');
  222.         }
  223.         if ('completed' !== $payment->getPispStatus()) {
  224.             return $this->createErrorJsonResponse('Refunds are only available for completed payments.');
  225.         }
  226.         $payload = json_decode((string) $request->getContent(), true);
  227.         if (!is_array($payload)) {
  228.             $payload = [];
  229.         }
  230.         $amount = $payload['amount'] ?? null;
  231.         $reason = is_string($payload['reason'] ?? null) ? trim($payload['reason']) : '';
  232.         if (!is_numeric($amount) || (float) $amount <= 0) {
  233.             return $this->createErrorJsonResponse('Amount must be greater than 0.');
  234.         }
  235.         $amountInSubunits = (int) round(((float) $amount) * 100);
  236.         if ($amountInSubunits > $payment->getAmount()) {
  237.             return $this->createErrorJsonResponse('Amount cannot exceed the transaction amount.');
  238.         }
  239.         if ('' === $reason) {
  240.             return $this->createErrorJsonResponse('Please provide a reason for the refund.');
  241.         }
  242.         $paymentId = (string) $payment->getId();
  243.         $conn = $doctrine->getConnection();
  244.         $summary = $this->computeRefundSummary($payment, $conn);
  245.         $seedRow = $summary['seed_row'];
  246.         if (!$seedRow) {
  247.             return $this->createErrorJsonResponse('This payment cannot be refunded yet: no Dimoco session was recorded for it.');
  248.         }
  249.         if ($summary['in_progress']) {
  250.             return $this->createErrorJsonResponse('A refund is already in progress for this payment.');
  251.         }
  252.         $remaining = $summary['remaining_amount'];
  253.         if ($remaining <= 0) {
  254.             return $this->createErrorJsonResponse('This payment has already been fully refunded.');
  255.         }
  256.         if ((float) $amount > $remaining + 0.005) {
  257.             return $this->createErrorJsonResponse("Amount cannot exceed the remaining refundable amount ({$remaining}).");
  258.         }
  259.         if (empty($seedRow['reference_debit_id'])) {
  260.             return $this->createErrorJsonResponse('This payment cannot be refunded yet: the debit reference has not been received from Dimoco yet.');
  261.         }
  262.         $config = $gatewayConfig->getConfig();
  263.         $channelId = $config['client_id'] ?? null;
  264.         $xApiKey = $config['client_secret'] ?? null;
  265.         $sandbox = $config['sandbox'] ?? true;
  266.         if (!$channelId || !$xApiKey) {
  267.             return $this->createErrorJsonResponse('Missing Dimoco credentials in the gateway configuration.');
  268.         }
  269.         $refundRequestId = $conn->fetchOne(
  270.             'INSERT INTO refund_requests (payment_id, session_id, reference_debit_id, amount, reason, status, pisp_status)
  271.                 VALUES (:payment_id, :session_id, :reference_debit_id, :amount, :reason, :status, :pisp_status)
  272.                 RETURNING id',
  273.             [
  274.                 'payment_id' => $paymentId,
  275.                 'session_id' => $seedRow['session_id'],
  276.                 'reference_debit_id' => $seedRow['reference_debit_id'],
  277.                 'amount' => (string) $amount,
  278.                 'reason' => $reason,
  279.                 'status' => 'initiated',
  280.                 'pisp_status' => 'initiated',
  281.             ]
  282.         );
  283.         $refundPayload = [
  284.             'channel_id' => $channelId,
  285.             'reference_debit_id' => $seedRow['reference_debit_id'],
  286.             'amount' => $amountInSubunits,
  287.             'currency' => $payment->getCurrency(),
  288.             'url_callback' => $this->generateUrl(
  289.                 'dimoco_payment_webhook',
  290.                 ['id' => $paymentId],
  291.                 UrlGeneratorInterface::ABSOLUTE_URL
  292.             ),
  293.             'reason' => $reason,
  294.             'test_mode' => (bool) $sandbox,
  295.         ];
  296.         try {
  297.             $client = new Client(['timeout' => 30]);
  298.             $res = $client->post('https://api.upop.dinape.com/v1/refunds', [
  299.                 'headers' => [
  300.                     'Accept' => 'application/json',
  301.                     'Content-Type' => 'application/json',
  302.                     'X-Api-Key' => $xApiKey,
  303.                 ],
  304.                 'json' => $refundPayload,
  305.             ]);
  306.             $body = (string) $res->getBody();
  307.             $data = json_decode($body, true);
  308.             $refundSessionId = is_array($data) ? ($data['session_id'] ?? null) : null;
  309.             $conn->executeStatement(
  310.                 'UPDATE refund_requests SET refund_session_id = :refund_session_id, updated_at = CURRENT_TIMESTAMP WHERE id = :id',
  311.                 [
  312.                     'refund_session_id' => $refundSessionId,
  313.                     'id' => $refundRequestId,
  314.                 ]
  315.             );
  316.             $commonServices->savePaymentLogs($paymentId, Response::HTTP_OK, [
  317.                 'title' => 'Dimoco refund initiated',
  318.                 'details' => [
  319.                     'amount' => $amountInSubunits,
  320.                     'reason' => $reason,
  321.                     'response' => $data,
  322.                 ],
  323.             ]);
  324.             return new JsonResponse(['success' => true]);
  325.         } catch (ClientException|ServerException $e) {
  326.             $status = $e->getResponse()?->getStatusCode() ?? 502;
  327.             $errBody = $e->getResponse() ? (string) $e->getResponse()->getBody() : null;
  328.             $conn->executeStatement(
  329.                 'UPDATE refund_requests SET status = :status, pisp_status = :pisp_status, updated_at = CURRENT_TIMESTAMP WHERE id = :id',
  330.                 ['status' => 'failed', 'pisp_status' => 'failed', 'id' => $refundRequestId]
  331.             );
  332.             $commonServices->savePaymentLogs($paymentId, $status, [
  333.                 'title' => 'Dimoco refund API error',
  334.                 'details' => ['status' => $status, 'body' => $errBody],
  335.             ]);
  336.             return $this->createErrorJsonResponse('Dimoco refund failed: ' . ($errBody ?: $e->getMessage()));
  337.         } catch (GuzzleException $e) {
  338.             $conn->executeStatement(
  339.                 'UPDATE refund_requests SET status = :status, pisp_status = :pisp_status, updated_at = CURRENT_TIMESTAMP WHERE id = :id',
  340.                 ['status' => 'failed', 'pisp_status' => 'failed', 'id' => $refundRequestId]
  341.             );
  342.             $commonServices->savePaymentLogs($paymentId, 502, [
  343.                 'title' => 'Network error calling Dimoco refund API',
  344.                 'details' => ['error' => $e->getMessage()],
  345.             ]);
  346.             return $this->createErrorJsonResponse('Network error calling Dimoco.');
  347.         }
  348.     }
  349.     #[Route(path: '/export-all', name: 'admin_payments_export_all')]
  350.     public function exportAll(Request $request, ManagerRegistry $doctrine): StreamedResponse
  351.     {
  352.         $startYmd = $request->query->get('start');
  353.         $endYmd = $request->query->get('end');   // e.g. "2025-03-15"
  354.         $tzName = $request->query->get('tz') ?? \date_default_timezone_get() ?? 'UTC';
  355.         // --- Validate user tz (fallback to UTC) ---
  356.         try {
  357.             $userTz = new \DateTimeZone($tzName);
  358.         } catch (\Throwable) {
  359.             $tzName = 'UTC';
  360.             $userTz = new \DateTimeZone('UTC');
  361.         }
  362.         // --- Detect DB session timezone ---
  363.         $conn = $doctrine->getConnection();
  364.         $dbTzName = 'UTC';
  365.         try {
  366.             $platform = $conn->getDatabasePlatform()->getName();
  367.             if ($platform === 'mysql') {
  368.                 $sessionTz = $conn->fetchOne('SELECT @@time_zone');
  369.                 if ($sessionTz === 'SYSTEM') {
  370.                     $systemTz = $conn->fetchOne('SELECT @@system_time_zone');
  371.                     $dbTzName = $systemTz ?: 'UTC';
  372.                 } else {
  373.                     $dbTzName = $sessionTz ?: 'UTC';
  374.                 }
  375.             } elseif ($platform === 'postgresql') {
  376.                 $dbTzName = $conn->fetchOne('SHOW TIME ZONE') ?: 'UTC';
  377.             }
  378.         } catch (\Throwable) {
  379.             // keep default UTC
  380.         }
  381.         try {
  382.             $dbTz = new \DateTimeZone($dbTzName);
  383.         } catch (\Throwable) {
  384.             $dbTzName = 'UTC';
  385.             $dbTz = new \DateTimeZone('UTC');
  386.         }
  387.         $repo = $doctrine->getRepository(Payment::class);
  388.         $qb = $repo->createQueryBuilder('p');
  389.         // join user_payment_info
  390.         $qb->leftJoin('p.userPaymentInfo', 'upi')
  391.             ->addSelect('upi');
  392.         // --- Apply timezone-aware date filter if provided ---
  393.         if ($startYmd && $endYmd) {
  394.             $startLocal = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', trim($startYmd) . ' 00:00:00', $userTz);
  395.             $endLocal = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', trim($endYmd) . ' 23:59:59', $userTz);
  396.             if (!$startLocal || !$endLocal) {
  397.                 throw new \RuntimeException('Invalid start/end date.');
  398.             }
  399.             $startDb = $startLocal->setTimezone($dbTz);
  400.             $endDb = $endLocal->setTimezone($dbTz);
  401.             $qb->andWhere('p.createdAt BETWEEN :start AND :end')
  402.                 ->setParameter('start', $startDb, 'datetime_immutable')
  403.                 ->setParameter('end', $endDb, 'datetime_immutable');
  404.         }
  405.         $qb->orderBy('p.createdAt', 'DESC');
  406.         $payments = $qb->getQuery()->getResult();
  407.         // --- Stream CSV; render "Created At" in the USER'S timezone ---
  408.         $response = new StreamedResponse(function () use ($payments, $userTz) {
  409.             $handle = fopen('php://output', 'w+');
  410.             // Header
  411.             fputcsv($handle, ['ID', 'External ID', 'Site', 'Payment Provider', 'IP Address', 'Name', 'PISP Status', 'Bank Status', 'Amount', 'Currency', 'Created At']);
  412.             // Rows
  413.             /** @var \App\Domain\Payment\Model\Payment $payment */
  414.             foreach ($payments as $payment) {
  415.                 $createdAt = $payment->getCreatedAt();
  416.                 $createdAtStr = $createdAt
  417.                     ? (new \DateTimeImmutable('@' . $createdAt->getTimestamp()))
  418.                         ->setTimezone($userTz)
  419.                         ->format('Y-m-d H:i:s')
  420.                     : '';
  421.                 //ADDING BELOW CODE, BECAUSE PAYMENT PROVIDER, PISP STATUS AND BANK STATUS ARE NOT SET IN FOR OLD PAYMENT ENTITY
  422.                 $paymentProvider = '';
  423.                 try {
  424.                     $tmp = $payment->getSite()?->getPaymentGatewayConfig()?->getFactoryName();
  425.                     if (is_string($tmp) && $tmp !== '') {
  426.                         $paymentProvider = $tmp;
  427.                     }
  428.                 } catch (\Throwable $e) {
  429.                     // ignore errors for old data
  430.                 }
  431.                 $pispStatus = '';
  432.                 try {
  433.                     $tmp = $payment->getPispStatus();
  434.                     if (is_string($tmp) && $tmp !== '') {
  435.                         $pispStatus = $tmp;
  436.                     }
  437.                 } catch (\Throwable $e) {
  438.                 }
  439.                 $bankStatus = '';
  440.                 try {
  441.                     $tmp = $payment->getBankStatus();
  442.                     if (is_string($tmp) && $tmp !== '') {
  443.                         $bankStatus = $tmp;
  444.                     }
  445.                 } catch (\Throwable $e) {
  446.                 }
  447.                 $upi = $payment->getUserPaymentInfo();
  448.                 $ip = $upi ? $upi->getIpAddress() : null;
  449.                 $name = $upi ? $upi->getName() : null;
  450.                 fputcsv($handle, [
  451.                     $payment->getId(),
  452.                     $payment->getExternalId(),
  453.                     $payment->getSite()?->getName(),
  454.                     // $payment->getStatus()?->getValue(),
  455.                     $paymentProvider,
  456.                     $ip,
  457.                     $name,
  458.                     $pispStatus,
  459.                     $bankStatus,
  460.                     $payment->getAmount() / 100,
  461.                     $payment->getCurrency(),
  462.                     $createdAtStr,
  463.                 ]);
  464.             }
  465.             fclose($handle);
  466.         });
  467.         $filename = 'payments_' . date('Ymd_His') . '.csv';
  468.         $response->headers->set('Content-Type', 'text/csv');
  469.         $response->headers->set('Content-Disposition', 'attachment; filename="' . $filename . '"');
  470.         return $response;
  471.     }
  472.     #[Route(path: '/payment-logs', name: 'admin_payment_logs')]
  473.     public function paymentLogs(Request $request, ManagerRegistry $doctrine): StreamedResponse
  474.     {
  475.         $paymentId = $request->query->get('payment_id');
  476.         if (!$paymentId) {
  477.             throw new \InvalidArgumentException("payment_id is required");
  478.         }
  479.         $conn = $doctrine->getConnection();
  480.         $query = <<<SQL
  481.         SELECT id, payment_id, status_code, timestamp, logs, created_at, updated_at
  482.         FROM payment_logs
  483.         WHERE payment_id = :paymentId
  484.         ORDER BY timestamp DESC, id DESC
  485.     SQL;
  486.         $stmt = $conn->prepare($query);
  487.         $result = $stmt->executeQuery(['paymentId' => $paymentId]);
  488.         $rows = $result->fetchAllAssociative();
  489.         // Helper to decode possibly-double-encoded JSON into an array.
  490.         $decodeMaybe = static function ($value, int $maxPasses = 3): array {
  491.             if (is_array($value)) {
  492.                 return $value;
  493.             }
  494.             if (!is_string($value) || $value === '') {
  495.                 return [];
  496.             }
  497.             $s = $value;
  498.             for ($i = 0; $i < $maxPasses; $i++) {
  499.                 $decoded = json_decode($s, true);
  500.                 if (json_last_error() === JSON_ERROR_NONE) {
  501.                     if (is_array($decoded)) {
  502.                         return $decoded;               // got the object/array
  503.                     }
  504.                     if (is_string($decoded)) {
  505.                         $s = $decoded;                 // it was a JSON string containing JSON — try again
  506.                         continue;
  507.                     }
  508.                     // scalar/null → fall through to cleanup
  509.                 }
  510.                 // Cleanup pass: trim, strip wrapping quotes, unescape backslashes
  511.                 $s = trim($s);
  512.                 $len = strlen($s);
  513.                 if ($len >= 2) {
  514.                     $first = $s[0];
  515.                     $last = $s[$len - 1];
  516.                     if (($first === '"' && $last === '"') || ($first === "'" && $last === "'")) {
  517.                         $s = substr($s, 1, -1);
  518.                     }
  519.                 }
  520.                 $s = stripslashes($s);
  521.             }
  522.             return [];
  523.         };
  524.         $responseData = [];
  525.         foreach ($rows as $log) {
  526.             $logsArray = $decodeMaybe($log['logs'] ?? '');
  527.             // Normalize details: if it's a JSON string, decode it once.
  528.             $details = $logsArray['details'] ?? '';
  529.             if (is_string($details) && $details !== '') {
  530.                 $maybe = json_decode($details, true);
  531.                 if (json_last_error() === JSON_ERROR_NONE && is_array($maybe)) {
  532.                     $details = $maybe;
  533.                 }
  534.             }
  535.             $responseData[] = [
  536.                 'title' => $logsArray['title'] ?? '',
  537.                 'details' => $details,
  538.                 'timestamp' => $log['timestamp'],
  539.                 'status_code' => $log['status_code'],
  540.             ];
  541.         }
  542.         return new StreamedResponse(function () use ($responseData) {
  543.             echo json_encode($responseData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
  544.         }, 200, [
  545.             'Content-Type' => 'application/json',
  546.         ]);
  547.     }
  548. }