src/UI/Merchant/Controller/PaymentController.php line 27

Open in your IDE?
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\UI\Merchant\Controller;
  4. use App\UI\Merchant\Datatable\PaymentDatatable;
  5. use App\UI\Shared\Controller\AbstractController;
  6. use Sg\DatatablesBundle\Response\DatatableResponse;
  7. use Symfony\Component\HttpFoundation\Request;
  8. use Symfony\Component\HttpFoundation\Response;
  9. use Symfony\Component\Routing\Annotation\Route;
  10. use Symfony\Component\HttpFoundation\StreamedResponse;
  11. use Doctrine\Persistence\ManagerRegistry;
  12. use App\Domain\Payment\Model\Payment;
  13. use Symfony\Component\Security\Core\Security;
  14. use Symfony\Component\HttpFoundation\JsonResponse;
  15. use Doctrine\DBAL\Connection;
  16. use Symfony\Component\HttpFoundation\ResponseHeaderBag;
  17. class PaymentController extends AbstractController
  18. {
  19.     #[Route(path: '/', name: 'merchant_payments')]
  20.     public function index(
  21.         Request $request,
  22.         PaymentDatatable $datatable,
  23.         DatatableResponse $datatableResponse,
  24.         \Doctrine\Persistence\ManagerRegistry $doctrine,
  25.         Security $security
  26.     ): Response {
  27.         $isAjax = $request->isXmlHttpRequest();
  28.         $datatable->buildDatatable();
  29.         if ($isAjax) {
  30.             try {
  31.                 $data = $request->query->all();
  32.                 $tzName = $request->query->get('tz') ?? \date_default_timezone_get() ?? 'UTC';
  33.                 // --- Validate/normalize user tz
  34.                 try {
  35.                     $userTz = new \DateTimeZone($tzName);
  36.                 } catch (\Throwable) {
  37.                     $tzName = 'UTC';
  38.                     $userTz = new \DateTimeZone('UTC');
  39.                 }
  40.                 // --- Detect DB session timezone  ---
  41.                 $conn = $doctrine->getConnection();
  42.                 $dbTzName = 'UTC';
  43.                 try {
  44.                     $platform = $conn->getDatabasePlatform()->getName();
  45.                     if ($platform === 'mysql') {
  46.                         $sessionTz = $conn->fetchOne('SELECT @@time_zone');
  47.                         if ($sessionTz === 'SYSTEM') {
  48.                             $systemTz = $conn->fetchOne('SELECT @@system_time_zone');
  49.                             $dbTzName = $systemTz ?: 'UTC';
  50.                         } else {
  51.                             $dbTzName = $sessionTz ?: 'UTC';
  52.                         }
  53.                     } elseif ($platform === 'postgresql') {
  54.                         $dbTzName = $conn->fetchOne('SHOW TIME ZONE') ?: 'UTC';
  55.                     }
  56.                 } catch (\Throwable) {
  57.                     // keep default UTC
  58.                 }
  59.                 try {
  60.                     $dbTz = new \DateTimeZone($dbTzName);
  61.                 } catch (\Throwable) {
  62.                     $dbTzName = 'UTC';
  63.                     $dbTz = new \DateTimeZone('UTC');
  64.                 }
  65.                 // --- Strip createdAt LIKE filter from DataTables payload
  66.                 $createdAtRange = null;
  67.                 $paymentTypeFilter = null;
  68.                 if (isset($data['columns'])) {
  69.                     foreach ($data['columns'] as &$column) {
  70.                         if (($column['data'] ?? null) === 'createdAt' && !empty($column['search']['value'])) {
  71.                             $createdAtRange = explode('|', $column['search']['value']);
  72.                             $column['search']['value'] = '';
  73.                             $column['searchable'] = 'false';
  74.                         }
  75.                         if (($column['data'] ?? null) === 'paymentType' && !empty($column['search']['value'])) {
  76.                             $paymentTypeFilter = $column['search']['value'];
  77.                             if ($paymentTypeFilter === 'sepa') {
  78.                                 $column['search']['value'] = '';
  79.                                 $column['searchable'] = 'false';
  80.                             }
  81.                         }
  82.                     }
  83.                     unset($column);
  84.                 }
  85.                 // Overwrite cleaned query
  86.                 $request->query->replace($data);
  87.                 // --- Build query
  88.                 $datatableResponse->setDatatable($datatable);
  89.                 $qb = $datatableResponse->getDatatableQueryBuilder()->getQb();
  90.                 $alias = $qb->getRootAliases()[0];
  91.                 if ($paymentTypeFilter === 'sepa') {
  92.                     $qb->andWhere("($alias.paymentType = 'sepa' OR $alias.paymentType IS NULL OR $alias.paymentType = '')");
  93.                 }
  94.                 // Always show latest records at the top
  95.                 $qb->addOrderBy("$alias.createdAt", "DESC");
  96.                 // Merchant constraint (site already joined by your datatable)
  97.                 $qb->andWhere('site.merchant = :merchant')
  98.                     ->setParameter('merchant', $security->getUser());
  99.                 // Timezone-aware createdAt filter
  100.                 if ($createdAtRange && count($createdAtRange) === 2) {
  101.                     $startStr = \trim($createdAtRange[0]) . ' 00:00:00';
  102.                     $endStr = \trim($createdAtRange[1]) . ' 23:59:59';
  103.                     // Interpret as *user-local calendar days*
  104.                     $startLocal = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $startStr, $userTz);
  105.                     $endLocal = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $endStr, $userTz);
  106.                     if (!$startLocal || !$endLocal) {
  107.                         throw new \RuntimeException('Invalid createdAt date range.');
  108.                     }
  109.                     // Convert to *DB session timezone*
  110.                     $startDb = $startLocal->setTimezone($dbTz);
  111.                     $endDb = $endLocal->setTimezone($dbTz);
  112.                     $qb->andWhere("$alias.createdAt BETWEEN :start AND :end")
  113.                         ->setParameter('start', $startDb, 'datetime_immutable')
  114.                         ->setParameter('end', $endDb, 'datetime_immutable');
  115.                 }
  116.                 return $datatableResponse->getResponse();
  117.             } catch (\Throwable $e) {
  118.                 return new JsonResponse([
  119.                     'error' => true,
  120.                     'message' => $e->getMessage(),
  121.                     'trace' => $e->getTraceAsString(),
  122.                 ]);
  123.             }
  124.         }
  125.         return $this->render('merchant/payment/index.html.twig', [
  126.             'datatable' => $datatable,
  127.         ]);
  128.     }
  129.     #[Route(path: '/export-all', name: 'merchant_payments_export_all')]
  130.     public function exportAll(
  131.         Request $request,
  132.         ManagerRegistry $doctrine,
  133.         Security $security
  134.     ): StreamedResponse {
  135.         $merchant = $security->getUser();
  136.         if (!$merchant) {
  137.             throw $this->createAccessDeniedException('No merchant associated with this user.');
  138.         }
  139.         $startYmd = $request->query->get('start'); // e.g. "2025-08-01"
  140.         $endYmd = $request->query->get('end');   // e.g. "2025-08-09"
  141.         $tzName = $request->query->get('tz') ?? \date_default_timezone_get() ?? 'UTC';
  142.         // --- Validate user tz
  143.         try {
  144.             $userTz = new \DateTimeZone($tzName);
  145.         } catch (\Throwable) {
  146.             $tzName = 'UTC';
  147.             $userTz = new \DateTimeZone('UTC');
  148.         }
  149.         // --- Detect DB session timezone
  150.         $conn = $doctrine->getConnection();
  151.         $dbTzName = 'UTC';
  152.         try {
  153.             $platform = $conn->getDatabasePlatform()->getName();
  154.             if ($platform === 'mysql') {
  155.                 $sessionTz = $conn->fetchOne('SELECT @@time_zone');
  156.                 if ($sessionTz === 'SYSTEM') {
  157.                     $systemTz = $conn->fetchOne('SELECT @@system_time_zone');
  158.                     $dbTzName = $systemTz ?: 'UTC';
  159.                 } else {
  160.                     $dbTzName = $sessionTz ?: 'UTC';
  161.                 }
  162.             } elseif ($platform === 'postgresql') {
  163.                 $dbTzName = $conn->fetchOne('SHOW TIME ZONE') ?: 'UTC';
  164.             }
  165.         } catch (\Throwable) {
  166.             // keep default UTC
  167.         }
  168.         try {
  169.             $dbTz = new \DateTimeZone($dbTzName);
  170.         } catch (\Throwable) {
  171.             $dbTzName = 'UTC';
  172.             $dbTz = new \DateTimeZone('UTC');
  173.         }
  174.         // Build query
  175.         $repo = $doctrine->getRepository(Payment::class);
  176.         $qb = $repo->createQueryBuilder('p')
  177.             ->leftJoin('p.site', 'site')
  178.             ->leftJoin('p.userPaymentInfo', 'upi')
  179.             ->addSelect('site', 'upi')
  180.             ->andWhere('site.merchant = :merchant')
  181.             ->setParameter('merchant', $merchant)
  182.             ->orderBy('p.createdAt', 'DESC');
  183.         // --- Apply timezone-aware date filter if provided
  184.         if ($startYmd && $endYmd) {
  185.             $startLocal = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', \trim($startYmd) . ' 00:00:00', $userTz);
  186.             $endLocal = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', \trim($endYmd) . ' 23:59:59', $userTz);
  187.             if (!$startLocal || !$endLocal) {
  188.                 throw new \RuntimeException('Invalid start/end date.');
  189.             }
  190.             // Convert bounds to DB session timezone
  191.             $startDb = $startLocal->setTimezone($dbTz);
  192.             $endDb = $endLocal->setTimezone($dbTz);
  193.             $qb->andWhere('p.createdAt BETWEEN :start AND :end')
  194.                 ->setParameter('start', $startDb, 'datetime_immutable')
  195.                 ->setParameter('end', $endDb, 'datetime_immutable');
  196.         }
  197.         $payments = $qb->getQuery()->getResult();
  198.         // --- Stream CSV; render "Created At" in the USER'S timezone
  199.         $response = new StreamedResponse(function () use ($payments, $userTz) {
  200.             $handle = fopen('php://output', 'w+');
  201.             // Header
  202.             fputcsv($handle, ['ID', 'External ID', 'Site', 'Payment Gateway', 'Payment Type', 'IP Address', 'Name', 'PISP Status', 'Bank Status', 'Amount', 'Currency', 'Created At']);
  203.             /** @var \App\Domain\Payment\Model\Payment $payment */
  204.             foreach ($payments as $payment) {
  205.                 $createdAt = $payment->getCreatedAt();
  206.                 $createdAtStr = $createdAt
  207.                     ? (new \DateTimeImmutable('@' . $createdAt->getTimestamp()))
  208.                         ->setTimezone($userTz)
  209.                         ->format('Y-m-d H:i:s')
  210.                     : '';
  211.                 //ADDING BELOW CODE, BECAUSE PAYMENT PROVIDER, PISP STATUS AND BANK STATUS ARE NOT SET IN FOR OLD PAYMENT ENTITY
  212.                 $paymentProvider = '';
  213.                 try {
  214.                     $tmp = $payment->getSite()?->getPaymentGatewayConfig()?->getFactoryName();
  215.                     if (is_string($tmp) && $tmp !== '') {
  216.                         $paymentProvider = ($tmp === 'fin_xp') ? 'FinXP' : $tmp;
  217.                     }
  218.                 } catch (\Throwable $e) {
  219.                     // ignore errors for old data
  220.                 }
  221.                 $paymentType = ($payment->getPaymentType() === 'direct_debit') ? 'Direct Debit' : 'SEPA';
  222.                 $pispStatus = '';
  223.                 try {
  224.                     $tmp = $payment->getPispStatus();
  225.                     if (is_string($tmp) && $tmp !== '') {
  226.                         $pispStatus = $tmp;
  227.                     }
  228.                 } catch (\Throwable $e) {
  229.                 }
  230.                 $bankStatus = '';
  231.                 try {
  232.                     $tmp = $payment->getBankStatus();
  233.                     if (is_string($tmp) && $tmp !== '') {
  234.                         $bankStatus = $tmp;
  235.                     }
  236.                 } catch (\Throwable $e) {
  237.                 }
  238.                 $upi = $payment->getUserPaymentInfo();
  239.                 $ip = $upi ? $upi->getIpAddress() : null;
  240.                 $name = $upi ? $upi->getName() : null;
  241.                 fputcsv($handle, [
  242.                     $payment->getId(),
  243.                     $payment->getExternalId(),
  244.                     $payment->getSite()?->getName(),
  245.                     // $payment->getStatus()?->getValue(),
  246.                     $paymentProvider,
  247.                     $paymentType,
  248.                     $ip,
  249.                     $name,
  250.                     $pispStatus,
  251.                     $bankStatus,
  252.                     $payment->getAmount() / 100,
  253.                     $payment->getCurrency(),
  254.                     $createdAtStr,
  255.                 ]);
  256.             }
  257.             fclose($handle);
  258.         });
  259.         $filename = 'merchant_payments_' . \date('Ymd_His') . '.csv';
  260.         $response->headers->set('Content-Type', 'text/csv');
  261.         $response->headers->set('Content-Disposition', 'attachment; filename="' . $filename . '"');
  262.         return $response;
  263.     }
  264.     #[Route(path: '/payment-logs', name: 'merchant_payment_logs')]
  265.     public function paymentLogs(Request $request, ManagerRegistry $doctrine): StreamedResponse
  266.     {
  267.         $paymentId = $request->query->get('payment_id');
  268.         if (!$paymentId) {
  269.             throw new \InvalidArgumentException("payment_id is required");
  270.         }
  271.         $conn = $doctrine->getConnection();
  272.         $query = <<<SQL
  273.         SELECT id, payment_id, status_code, timestamp, logs, created_at, updated_at
  274.         FROM payment_logs
  275.         WHERE payment_id = :paymentId
  276.         ORDER BY timestamp DESC
  277.     SQL;
  278.         $stmt = $conn->prepare($query);
  279.         $result = $stmt->executeQuery(['paymentId' => $paymentId]);
  280.         $rows = $result->fetchAllAssociative();
  281.         // Helper to decode possibly-double-encoded JSON into an array.
  282.         $decodeMaybe = static function ($value, int $maxPasses = 3): array {
  283.             if (is_array($value)) {
  284.                 return $value;
  285.             }
  286.             if (!is_string($value) || $value === '') {
  287.                 return [];
  288.             }
  289.             $s = $value;
  290.             for ($i = 0; $i < $maxPasses; $i++) {
  291.                 $decoded = json_decode($s, true);
  292.                 if (json_last_error() === JSON_ERROR_NONE) {
  293.                     if (is_array($decoded)) {
  294.                         return $decoded;               // got the object/array
  295.                     }
  296.                     if (is_string($decoded)) {
  297.                         $s = $decoded;                 // it was a JSON string containing JSON — try again
  298.                         continue;
  299.                     }
  300.                     // scalar/null → fall through to cleanup
  301.                 }
  302.                 // Cleanup pass: trim, strip wrapping quotes, unescape backslashes
  303.                 $s = trim($s);
  304.                 $len = strlen($s);
  305.                 if ($len >= 2) {
  306.                     $first = $s[0];
  307.                     $last = $s[$len - 1];
  308.                     if (($first === '"' && $last === '"') || ($first === "'" && $last === "'")) {
  309.                         $s = substr($s, 1, -1);
  310.                     }
  311.                 }
  312.                 $s = stripslashes($s);
  313.             }
  314.             return [];
  315.         };
  316.         $responseData = [];
  317.         foreach ($rows as $log) {
  318.             $logsArray = $decodeMaybe($log['logs'] ?? '');
  319.             // Normalize details: if it's a JSON string, decode it once.
  320.             $details = $logsArray['details'] ?? '';
  321.             if (is_string($details) && $details !== '') {
  322.                 $maybe = json_decode($details, true);
  323.                 if (json_last_error() === JSON_ERROR_NONE && is_array($maybe)) {
  324.                     $details = $maybe;
  325.                 }
  326.             }
  327.             $responseData[] = [
  328.                 'title' => $logsArray['title'] ?? '',
  329.                 'details' => $details,
  330.                 'timestamp' => $log['timestamp'],
  331.                 'status_code' => $log['status_code'],
  332.             ];
  333.         }
  334.         return new StreamedResponse(function () use ($responseData) {
  335.             echo json_encode($responseData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
  336.         }, 200, [
  337.             'Content-Type' => 'application/json',
  338.         ]);
  339.     }
  340. }