<?php
declare(strict_types=1);
namespace App\UI\Merchant\Controller;
use App\UI\Merchant\Datatable\PaymentDatatable;
use App\UI\Shared\Controller\AbstractController;
use Sg\DatatablesBundle\Response\DatatableResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Doctrine\Persistence\ManagerRegistry;
use App\Domain\Payment\Model\Payment;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Doctrine\DBAL\Connection;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
class PaymentController extends AbstractController
{
#[Route(path: '/', name: 'merchant_payments')]
public function index(
Request $request,
PaymentDatatable $datatable,
DatatableResponse $datatableResponse,
\Doctrine\Persistence\ManagerRegistry $doctrine,
Security $security
): Response {
$isAjax = $request->isXmlHttpRequest();
$datatable->buildDatatable();
if ($isAjax) {
try {
$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');
}
// --- 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');
}
// --- Strip createdAt LIKE filter from DataTables payload
$createdAtRange = null;
$paymentTypeFilter = 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';
}
if (($column['data'] ?? null) === 'paymentType' && !empty($column['search']['value'])) {
$paymentTypeFilter = $column['search']['value'];
if ($paymentTypeFilter === 'sepa') {
$column['search']['value'] = '';
$column['searchable'] = 'false';
}
}
}
unset($column);
}
// Overwrite cleaned query
$request->query->replace($data);
// --- Build query
$datatableResponse->setDatatable($datatable);
$qb = $datatableResponse->getDatatableQueryBuilder()->getQb();
$alias = $qb->getRootAliases()[0];
if ($paymentTypeFilter === 'sepa') {
$qb->andWhere("($alias.paymentType = 'sepa' OR $alias.paymentType IS NULL OR $alias.paymentType = '')");
}
// Always show latest records at the top
$qb->addOrderBy("$alias.createdAt", "DESC");
// Merchant constraint (site already joined by your datatable)
$qb->andWhere('site.merchant = :merchant')
->setParameter('merchant', $security->getUser());
// Timezone-aware createdAt filter
if ($createdAtRange && count($createdAtRange) === 2) {
$startStr = \trim($createdAtRange[0]) . ' 00:00:00';
$endStr = \trim($createdAtRange[1]) . ' 23:59:59';
// Interpret 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*
$startDb = $startLocal->setTimezone($dbTz);
$endDb = $endLocal->setTimezone($dbTz);
$qb->andWhere("$alias.createdAt BETWEEN :start AND :end")
->setParameter('start', $startDb, 'datetime_immutable')
->setParameter('end', $endDb, 'datetime_immutable');
}
return $datatableResponse->getResponse();
} catch (\Throwable $e) {
return new JsonResponse([
'error' => true,
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
}
return $this->render('merchant/payment/index.html.twig', [
'datatable' => $datatable,
]);
}
#[Route(path: '/export-all', name: 'merchant_payments_export_all')]
public function exportAll(
Request $request,
ManagerRegistry $doctrine,
Security $security
): StreamedResponse {
$merchant = $security->getUser();
if (!$merchant) {
throw $this->createAccessDeniedException('No merchant associated with this user.');
}
$startYmd = $request->query->get('start'); // e.g. "2025-08-01"
$endYmd = $request->query->get('end'); // e.g. "2025-08-09"
$tzName = $request->query->get('tz') ?? \date_default_timezone_get() ?? 'UTC';
// --- Validate user tz
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');
}
// Build query
$repo = $doctrine->getRepository(Payment::class);
$qb = $repo->createQueryBuilder('p')
->leftJoin('p.site', 'site')
->leftJoin('p.userPaymentInfo', 'upi')
->addSelect('site', 'upi')
->andWhere('site.merchant = :merchant')
->setParameter('merchant', $merchant)
->orderBy('p.createdAt', 'DESC');
// --- 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.');
}
// Convert bounds to DB session timezone
$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');
}
$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 Gateway', 'Payment Type', 'IP Address', 'Name', 'PISP Status', 'Bank Status', 'Amount', 'Currency', 'Created At']);
/** @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 === 'fin_xp') ? 'FinXP' : $tmp;
}
} catch (\Throwable $e) {
// ignore errors for old data
}
$paymentType = ($payment->getPaymentType() === 'direct_debit') ? 'Direct Debit' : 'SEPA';
$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,
$paymentType,
$ip,
$name,
$pispStatus,
$bankStatus,
$payment->getAmount() / 100,
$payment->getCurrency(),
$createdAtStr,
]);
}
fclose($handle);
});
$filename = 'merchant_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: 'merchant_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',
]);
}
}