<?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;
use App\Application\Common\CommonServices;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\ServerException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
#[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,
]);
}
private const REFUND_IN_PROGRESS_STATUSES = ['initiated', 'pending'];
#[Route(path: '/{id}/refund-details', name: 'admin_payments_refund_details', methods: ['GET'])]
public function refundDetails(Payment $payment, ManagerRegistry $doctrine): JsonResponse
{
$factoryName = $payment->getSite()?->getPaymentGatewayConfig()?->getFactoryName();
if ('dimoco_pay' !== $factoryName) {
return $this->createErrorJsonResponse('Refunds are only available for Dimoco Pay payments.');
}
$summary = $this->computeRefundSummary($payment, $doctrine->getConnection());
return new JsonResponse([
'success' => true,
'currency' => $payment->getCurrency(),
'pisp_status' => $payment->getPispStatus(),
'total_amount' => $summary['total_amount'],
'refunded_amount' => $summary['refunded_amount'],
'remaining_amount' => $summary['remaining_amount'],
'in_progress' => $summary['in_progress'],
'can_refund' => 'completed' === $payment->getPispStatus() && $summary['can_refund'],
'history' => $summary['history'],
]);
}
/**
* @return array{
* seed_row: ?array,
* total_amount: float,
* refunded_amount: float,
* remaining_amount: float,
* in_progress: bool,
* can_refund: bool,
* history: list<array{amount: float, reason: ?string, status: ?string, created_at: ?string}>,
* }
*/
private function computeRefundSummary(Payment $payment, Connection $conn): array
{
$refundRows = $conn->fetchAllAssociative(
'SELECT * FROM refund_requests WHERE payment_id = :payment_id ORDER BY id ASC',
['payment_id' => (string) $payment->getId()]
);
// The first row is seeded at purchase time and carries session_id/reference_debit_id;
// every subsequent row (status not null) is one actual refund attempt.
$seedRow = $refundRows[0] ?? null;
$inProgress = false;
$refunded = 0.0;
$history = [];
foreach ($refundRows as $row) {
if (null === $row['status']) {
continue;
}
if (in_array($row['status'], self::REFUND_IN_PROGRESS_STATUSES, true)) {
$inProgress = true;
}
if ('completed' === $row['status']) {
$refunded += (float) $row['amount'];
}
$history[] = [
'amount' => (float) $row['amount'],
'reason' => $row['reason'],
'status' => $row['status'],
'created_at' => $row['created_at'],
];
}
$totalAmount = $payment->getAmount() / 100;
$remaining = round($totalAmount - $refunded, 2);
return [
'seed_row' => $seedRow,
'total_amount' => $totalAmount,
'refunded_amount' => round($refunded, 2),
'remaining_amount' => $remaining,
'in_progress' => $inProgress,
'can_refund' => !$inProgress && $remaining > 0.005 && $seedRow && !empty($seedRow['reference_debit_id']),
'history' => array_reverse($history),
];
}
#[Route(path: '/{id}/refund', name: 'admin_payments_refund', methods: ['POST'])]
public function refund(Payment $payment, Request $request, CommonServices $commonServices, ManagerRegistry $doctrine): JsonResponse
{
$gatewayConfig = $payment->getSite()?->getPaymentGatewayConfig();
$factoryName = $gatewayConfig?->getFactoryName();
if ('dimoco_pay' !== $factoryName) {
return $this->createErrorJsonResponse('Refunds are only available for Dimoco Pay payments.');
}
if ('completed' !== $payment->getPispStatus()) {
return $this->createErrorJsonResponse('Refunds are only available for completed payments.');
}
$payload = json_decode((string) $request->getContent(), true);
if (!is_array($payload)) {
$payload = [];
}
$amount = $payload['amount'] ?? null;
$reason = is_string($payload['reason'] ?? null) ? trim($payload['reason']) : '';
if (!is_numeric($amount) || (float) $amount <= 0) {
return $this->createErrorJsonResponse('Amount must be greater than 0.');
}
$amountInSubunits = (int) round(((float) $amount) * 100);
if ($amountInSubunits > $payment->getAmount()) {
return $this->createErrorJsonResponse('Amount cannot exceed the transaction amount.');
}
if ('' === $reason) {
return $this->createErrorJsonResponse('Please provide a reason for the refund.');
}
$paymentId = (string) $payment->getId();
$conn = $doctrine->getConnection();
$summary = $this->computeRefundSummary($payment, $conn);
$seedRow = $summary['seed_row'];
if (!$seedRow) {
return $this->createErrorJsonResponse('This payment cannot be refunded yet: no Dimoco session was recorded for it.');
}
if ($summary['in_progress']) {
return $this->createErrorJsonResponse('A refund is already in progress for this payment.');
}
$remaining = $summary['remaining_amount'];
if ($remaining <= 0) {
return $this->createErrorJsonResponse('This payment has already been fully refunded.');
}
if ((float) $amount > $remaining + 0.005) {
return $this->createErrorJsonResponse("Amount cannot exceed the remaining refundable amount ({$remaining}).");
}
if (empty($seedRow['reference_debit_id'])) {
return $this->createErrorJsonResponse('This payment cannot be refunded yet: the debit reference has not been received from Dimoco yet.');
}
$config = $gatewayConfig->getConfig();
$channelId = $config['client_id'] ?? null;
$xApiKey = $config['client_secret'] ?? null;
$sandbox = $config['sandbox'] ?? true;
if (!$channelId || !$xApiKey) {
return $this->createErrorJsonResponse('Missing Dimoco credentials in the gateway configuration.');
}
$refundRequestId = $conn->fetchOne(
'INSERT INTO refund_requests (payment_id, session_id, reference_debit_id, amount, reason, status, pisp_status)
VALUES (:payment_id, :session_id, :reference_debit_id, :amount, :reason, :status, :pisp_status)
RETURNING id',
[
'payment_id' => $paymentId,
'session_id' => $seedRow['session_id'],
'reference_debit_id' => $seedRow['reference_debit_id'],
'amount' => (string) $amount,
'reason' => $reason,
'status' => 'initiated',
'pisp_status' => 'initiated',
]
);
$refundPayload = [
'channel_id' => $channelId,
'reference_debit_id' => $seedRow['reference_debit_id'],
'amount' => $amountInSubunits,
'currency' => $payment->getCurrency(),
'url_callback' => $this->generateUrl(
'dimoco_payment_webhook',
['id' => $paymentId],
UrlGeneratorInterface::ABSOLUTE_URL
),
'reason' => $reason,
'test_mode' => (bool) $sandbox,
];
try {
$client = new Client(['timeout' => 30]);
$res = $client->post('https://api.upop.dinape.com/v1/refunds', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
'X-Api-Key' => $xApiKey,
],
'json' => $refundPayload,
]);
$body = (string) $res->getBody();
$data = json_decode($body, true);
$refundSessionId = is_array($data) ? ($data['session_id'] ?? null) : null;
$conn->executeStatement(
'UPDATE refund_requests SET refund_session_id = :refund_session_id, updated_at = CURRENT_TIMESTAMP WHERE id = :id',
[
'refund_session_id' => $refundSessionId,
'id' => $refundRequestId,
]
);
$commonServices->savePaymentLogs($paymentId, Response::HTTP_OK, [
'title' => 'Dimoco refund initiated',
'details' => [
'amount' => $amountInSubunits,
'reason' => $reason,
'response' => $data,
],
]);
return new JsonResponse(['success' => true]);
} catch (ClientException|ServerException $e) {
$status = $e->getResponse()?->getStatusCode() ?? 502;
$errBody = $e->getResponse() ? (string) $e->getResponse()->getBody() : null;
$conn->executeStatement(
'UPDATE refund_requests SET status = :status, pisp_status = :pisp_status, updated_at = CURRENT_TIMESTAMP WHERE id = :id',
['status' => 'failed', 'pisp_status' => 'failed', 'id' => $refundRequestId]
);
$commonServices->savePaymentLogs($paymentId, $status, [
'title' => 'Dimoco refund API error',
'details' => ['status' => $status, 'body' => $errBody],
]);
return $this->createErrorJsonResponse('Dimoco refund failed: ' . ($errBody ?: $e->getMessage()));
} catch (GuzzleException $e) {
$conn->executeStatement(
'UPDATE refund_requests SET status = :status, pisp_status = :pisp_status, updated_at = CURRENT_TIMESTAMP WHERE id = :id',
['status' => 'failed', 'pisp_status' => 'failed', 'id' => $refundRequestId]
);
$commonServices->savePaymentLogs($paymentId, 502, [
'title' => 'Network error calling Dimoco refund API',
'details' => ['error' => $e->getMessage()],
]);
return $this->createErrorJsonResponse('Network error calling Dimoco.');
}
}
#[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, id 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',
]);
}
}