<?php
declare(strict_types=1);
namespace App\UI\Admin\Controller;
use App\Domain\Payment\Model\Payment;
use App\UI\Admin\Datatable\PaymentDatatable;
use App\UI\Shared\Controller\AbstractController;
use Doctrine\Persistence\ManagerRegistry;
use Sg\DatatablesBundle\Response\DatatableResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\JsonResponse;
use Doctrine\DBAL\Connection;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
#[Route('/payments')]
class PaymentController extends AbstractController implements LoggerAwareInterface
{
use LoggerAwareTrait;
#[Route(path: '/', name: 'admin_payments')]
public function index(
Request $request,
PaymentDatatable $datatable,
DatatableResponse $datatableResponse,
\Doctrine\Persistence\ManagerRegistry $doctrine,
): Response {
$isAjax = $request->isXmlHttpRequest();
$datatable->buildDatatable();
if ($isAjax) {
try {
// Log raw incoming params
// $this->logger->info('Request query parameters', $request->query->all());
$data = $request->query->all();
$tzName = $request->query->get('tz') ?? \date_default_timezone_get() ?? 'UTC';
// Validate/normalize user tz
try {
$userTz = new \DateTimeZone($tzName);
} catch (\Throwable) {
$tzName = 'UTC';
$userTz = new \DateTimeZone('UTC');
}
// $this->logger->info('TIMEZONE', ['tz' => $tzName]);
// -------- Detect DB session timezone (MySQL / Postgres) --------
$conn = $doctrine->getConnection();
$dbTzName = 'UTC';
try {
$platform = $conn->getDatabasePlatform()->getName();
if ($platform === 'mysql') {
$sessionTz = $conn->fetchOne('SELECT @@time_zone');
if ($sessionTz === 'SYSTEM') {
$systemTz = $conn->fetchOne('SELECT @@system_time_zone');
$dbTzName = $systemTz ?: 'UTC';
} else {
$dbTzName = $sessionTz ?: 'UTC';
}
} elseif ($platform === 'postgresql') {
// SHOW TIME ZONE returns a single row, single column
$dbTzName = $conn->fetchOne('SHOW TIME ZONE') ?: 'UTC';
}
} catch (\Throwable $e) {
// keep default 'UTC' on any failure
}
// Ensure it's a valid IANA/offset for PHP
try {
$dbTz = new \DateTimeZone($dbTzName);
} catch (\Throwable) {
$dbTzName = 'UTC';
$dbTz = new \DateTimeZone('UTC');
}
// $this->logger->info('DB TIMEZONE', ['db_tz' => $dbTzName, 'platform' => $conn->getDatabasePlatform()->getName()]);
// -------- Strip createdAt LIKE filter --------
$createdAtRange = null;
if (isset($data['columns'])) {
foreach ($data['columns'] as &$column) {
if (($column['data'] ?? null) === 'createdAt' && !empty($column['search']['value'])) {
$createdAtRange = explode('|', $column['search']['value']);
$column['search']['value'] = '';
$column['searchable'] = 'false';
}
}
unset($column);
}
// Overwrite cleaned query
$request->query->replace($data);
// -------- Build query --------
$datatableResponse->setDatatable($datatable);
$queryBuilder = $datatableResponse->getDatatableQueryBuilder()->getQb();
$alias = $queryBuilder->getRootAliases()[0];
// Always show latest records at the top
$queryBuilder->addOrderBy("$alias.createdAt", "DESC");
if ($createdAtRange && count($createdAtRange) === 2) {
$startStr = \trim($createdAtRange[0]) . ' 00:00:00';
$endStr = \trim($createdAtRange[1]) . ' 23:59:59';
// Interpret the picked dates as *user-local calendar days*
$startLocal = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $startStr, $userTz);
$endLocal = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $endStr, $userTz);
if (!$startLocal || !$endLocal) {
throw new \RuntimeException('Invalid createdAt date range.');
}
// Convert to *DB session timezone* we detected above
$startDb = $startLocal->setTimezone($dbTz);
$endDb = $endLocal->setTimezone($dbTz);
$queryBuilder
->andWhere("$alias.createdAt BETWEEN :start AND :end")
->setParameter('start', $startDb, 'datetime_immutable')
->setParameter('end', $endDb, 'datetime_immutable');
$this->logger->info('createdAt filter applied', [
'user_tz' => $tzName,
'db_tz' => $dbTzName,
'start_loc' => $startLocal->format('Y-m-d H:i:sP'),
'end_loc' => $endLocal->format('Y-m-d H:i:sP'),
'start_db' => $startDb->format('Y-m-d H:i:sP'),
'end_db' => $endDb->format('Y-m-d H:i:sP'),
]);
}
// -------- Return DataTables JSON --------
$response = $datatableResponse->getResponse();
// $this->logger->info('Payment datatable request completed.', [
// 'response' => json_decode($response->getContent(), true)
// ]);
return $response;
} catch (\Throwable $e) {
return new JsonResponse([
'error' => true,
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
}
return $this->render('admin/payment/index.html.twig', [
'datatable' => $datatable,
]);
}
#[Route(path: '/export-all', name: 'admin_payments_export_all')]
public function exportAll(Request $request, ManagerRegistry $doctrine): StreamedResponse
{
$startYmd = $request->query->get('start');
$endYmd = $request->query->get('end'); // e.g. "2025-03-15"
$tzName = $request->query->get('tz') ?? \date_default_timezone_get() ?? 'UTC';
// --- Validate user tz (fallback to UTC) ---
try {
$userTz = new \DateTimeZone($tzName);
} catch (\Throwable) {
$tzName = 'UTC';
$userTz = new \DateTimeZone('UTC');
}
// --- Detect DB session timezone ---
$conn = $doctrine->getConnection();
$dbTzName = 'UTC';
try {
$platform = $conn->getDatabasePlatform()->getName();
if ($platform === 'mysql') {
$sessionTz = $conn->fetchOne('SELECT @@time_zone');
if ($sessionTz === 'SYSTEM') {
$systemTz = $conn->fetchOne('SELECT @@system_time_zone');
$dbTzName = $systemTz ?: 'UTC';
} else {
$dbTzName = $sessionTz ?: 'UTC';
}
} elseif ($platform === 'postgresql') {
$dbTzName = $conn->fetchOne('SHOW TIME ZONE') ?: 'UTC';
}
} catch (\Throwable) {
// keep default UTC
}
try {
$dbTz = new \DateTimeZone($dbTzName);
} catch (\Throwable) {
$dbTzName = 'UTC';
$dbTz = new \DateTimeZone('UTC');
}
$repo = $doctrine->getRepository(Payment::class);
$qb = $repo->createQueryBuilder('p');
// join user_payment_info
$qb->leftJoin('p.userPaymentInfo', 'upi')
->addSelect('upi');
// --- Apply timezone-aware date filter if provided ---
if ($startYmd && $endYmd) {
$startLocal = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', trim($startYmd) . ' 00:00:00', $userTz);
$endLocal = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', trim($endYmd) . ' 23:59:59', $userTz);
if (!$startLocal || !$endLocal) {
throw new \RuntimeException('Invalid start/end date.');
}
$startDb = $startLocal->setTimezone($dbTz);
$endDb = $endLocal->setTimezone($dbTz);
$qb->andWhere('p.createdAt BETWEEN :start AND :end')
->setParameter('start', $startDb, 'datetime_immutable')
->setParameter('end', $endDb, 'datetime_immutable');
}
$qb->orderBy('p.createdAt', 'DESC');
$payments = $qb->getQuery()->getResult();
// --- Stream CSV; render "Created At" in the USER'S timezone ---
$response = new StreamedResponse(function () use ($payments, $userTz) {
$handle = fopen('php://output', 'w+');
// Header
fputcsv($handle, ['ID', 'External ID', 'Site', 'Payment Provider', 'IP Address', 'Name', 'PISP Status', 'Bank Status', 'Amount', 'Currency', 'Created At']);
// Rows
/** @var \App\Domain\Payment\Model\Payment $payment */
foreach ($payments as $payment) {
$createdAt = $payment->getCreatedAt();
$createdAtStr = $createdAt
? (new \DateTimeImmutable('@' . $createdAt->getTimestamp()))
->setTimezone($userTz)
->format('Y-m-d H:i:s')
: '';
//ADDING BELOW CODE, BECAUSE PAYMENT PROVIDER, PISP STATUS AND BANK STATUS ARE NOT SET IN FOR OLD PAYMENT ENTITY
$paymentProvider = '';
try {
$tmp = $payment->getSite()?->getPaymentGatewayConfig()?->getFactoryName();
if (is_string($tmp) && $tmp !== '') {
$paymentProvider = $tmp;
}
} catch (\Throwable $e) {
// ignore errors for old data
}
$pispStatus = '';
try {
$tmp = $payment->getPispStatus();
if (is_string($tmp) && $tmp !== '') {
$pispStatus = $tmp;
}
} catch (\Throwable $e) {
}
$bankStatus = '';
try {
$tmp = $payment->getBankStatus();
if (is_string($tmp) && $tmp !== '') {
$bankStatus = $tmp;
}
} catch (\Throwable $e) {
}
$upi = $payment->getUserPaymentInfo();
$ip = $upi ? $upi->getIpAddress() : null;
$name = $upi ? $upi->getName() : null;
fputcsv($handle, [
$payment->getId(),
$payment->getExternalId(),
$payment->getSite()?->getName(),
// $payment->getStatus()?->getValue(),
$paymentProvider,
$ip,
$name,
$pispStatus,
$bankStatus,
$payment->getAmount() / 100,
$payment->getCurrency(),
$createdAtStr,
]);
}
fclose($handle);
});
$filename = 'payments_' . date('Ymd_His') . '.csv';
$response->headers->set('Content-Type', 'text/csv');
$response->headers->set('Content-Disposition', 'attachment; filename="' . $filename . '"');
return $response;
}
#[Route(path: '/payment-logs', name: 'admin_payment_logs')]
public function paymentLogs(Request $request, ManagerRegistry $doctrine): StreamedResponse
{
$paymentId = $request->query->get('payment_id');
if (!$paymentId) {
throw new \InvalidArgumentException("payment_id is required");
}
$conn = $doctrine->getConnection();
$query = <<<SQL
SELECT id, payment_id, status_code, timestamp, logs, created_at, updated_at
FROM payment_logs
WHERE payment_id = :paymentId
ORDER BY timestamp DESC
SQL;
$stmt = $conn->prepare($query);
$result = $stmt->executeQuery(['paymentId' => $paymentId]);
$rows = $result->fetchAllAssociative();
// Helper to decode possibly-double-encoded JSON into an array.
$decodeMaybe = static function ($value, int $maxPasses = 3): array {
if (is_array($value)) {
return $value;
}
if (!is_string($value) || $value === '') {
return [];
}
$s = $value;
for ($i = 0; $i < $maxPasses; $i++) {
$decoded = json_decode($s, true);
if (json_last_error() === JSON_ERROR_NONE) {
if (is_array($decoded)) {
return $decoded; // got the object/array
}
if (is_string($decoded)) {
$s = $decoded; // it was a JSON string containing JSON — try again
continue;
}
// scalar/null → fall through to cleanup
}
// Cleanup pass: trim, strip wrapping quotes, unescape backslashes
$s = trim($s);
$len = strlen($s);
if ($len >= 2) {
$first = $s[0];
$last = $s[$len - 1];
if (($first === '"' && $last === '"') || ($first === "'" && $last === "'")) {
$s = substr($s, 1, -1);
}
}
$s = stripslashes($s);
}
return [];
};
$responseData = [];
foreach ($rows as $log) {
$logsArray = $decodeMaybe($log['logs'] ?? '');
// Normalize details: if it's a JSON string, decode it once.
$details = $logsArray['details'] ?? '';
if (is_string($details) && $details !== '') {
$maybe = json_decode($details, true);
if (json_last_error() === JSON_ERROR_NONE && is_array($maybe)) {
$details = $maybe;
}
}
$responseData[] = [
'title' => $logsArray['title'] ?? '',
'details' => $details,
'timestamp' => $log['timestamp'],
'status_code' => $log['status_code'],
];
}
return new StreamedResponse(function () use ($responseData) {
echo json_encode($responseData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}, 200, [
'Content-Type' => 'application/json',
]);
}
}