<?php
declare(strict_types=1);
namespace App\UI\Frontend\Controller;
use App\Domain\Payment\Model\Payment;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use GuzzleHttp\Client;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
use Psr\Http\Message\ResponseInterface;
use App\Application\Common\CommonServices;
class PaymentController extends AbstractController implements LoggerAwareInterface
{
private EntityManagerInterface $em;
private ClientInterface $client;
private CommonServices $commonServices;
private string $appEnv;
private string $ibanxsAuthTokenUrl;
private string $ibanxsApiBaseUrl;
public function __construct(EntityManagerInterface $em, CommonServices $commonServices, string $appEnv, string $ibanxsAuthTokenUrl, string $ibanxsApiBaseUrl)
{
$this->em = $em;
$this->client = new Client();
$this->commonServices = $commonServices;
$this->appEnv = $appEnv;
$this->ibanxsAuthTokenUrl = $ibanxsAuthTokenUrl;
$this->ibanxsApiBaseUrl = $ibanxsApiBaseUrl;
}
use LoggerAwareTrait;
#[Route(path: '/payments/{id}', name: 'frontend_payments_proxy')]
public function proxy(Request $request, Payment $payment): Response
{
if ($payment->getStatus()->isCompleted()) {
$redirectUrl = $this->getQueryUrl($payment->getSuccessUrl(), $payment);
return $this->redirect($redirectUrl);
} elseif ($payment->getStatus()->isFailed()) {
return $this->redirect($payment->getFailureUrl());
} else {
$redirectUrl = $this->getQueryUrl($payment->getPendingUrl(), $payment);
return $this->redirect($redirectUrl);
}
}
public function getQueryUrl($url, $payment)
{
$amount = $payment->getFloatAmount();
// Parse the URL into components
$urlComponents = parse_url($url);
// Start building the URL
$updatedUrl = $urlComponents['scheme'] . '://' . $urlComponents['host'];
// Add the port if it exists
if (isset($urlComponents['port'])) {
$updatedUrl .= ':' . $urlComponents['port'];
}
// Add the path if it exists
if (isset($urlComponents['path'])) {
$updatedUrl .= $urlComponents['path'];
}
// Handle query parameters
$queryParams = [];
if (isset($urlComponents['query'])) {
parse_str($urlComponents['query'], $queryParams);
}
$queryParams['amount'] = number_format($amount, 2, '.', '');
// Append the query string
$updatedUrl .= '?' . http_build_query($queryParams);
return $updatedUrl;
}
#[Route(path: '/genome/payment/notify/{id}', name: 'genome_payment_webhook', methods: ['POST'])]
public function genomeWebhook(string $id, Request $request): Response
{
try {
// You can log, validate, or queue with the ID directly
$payload = json_decode($request->getContent(), true);
// Add the request URL into the payload
$payload['request_url'] = $request->getUri();
// Example: log and return immediately
$this->logger?->info('Received webhook for ID: ' . $id, ['payload' => $payload]);
$this->validateAndUpdatePayment($payload, 'genome');
return new JsonResponse(['status' => 'Webhook received']);
} catch (\Throwable $e) {
$this->logger?->error('Webhook error: ' . $e->getMessage());
return new JsonResponse(['error' => 'Internal error'], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
#[Route(path: '/isx/payment/notify', name: 'isx_payment_webhook', methods: ['POST'])]
public function isxWebhook(Request $request): Response
{
try {
// You can log, validate, or queue with the ID directly
$payload = json_decode($request->getContent(), true);
// Add the request URL into the payload
$payload['request_url'] = $request->getUri();
// Example: log and return immediately
$this->logger?->info('Received isxWebhook: ', ['payload' => $payload]);
$this->validateAndUpdatePayment($payload, 'isx');
return new JsonResponse(['status' => 'Webhook received']);
} catch (\Throwable $e) {
$this->logger?->error('Webhook error: ' . $e->getMessage());
return new JsonResponse(['error' => 'Internal error'], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
#[Route(path: '/dimoco/payment/notify/{id}', name: 'dimoco_payment_webhook', methods: ['POST'])]
public function dimocoWebhook(string $id, Request $request): Response
{
try {
$this->logger?->info('Received dimoco webhook', [
'id' => $id,
'method' => $request->getMethod(),
'url' => $request->getUri(),
'headers' => $request->headers->all(),
'query' => $request->query->all(),
'request' => $request->request->all(), // Form data
'body' => $request->getContent(), // Raw JSON/XML body
]);
return new JsonResponse([
'status' => 'Webhook received'
]);
} catch (\Throwable $e) {
$this->logger?->error('Webhook error: ' . $e->getMessage());
return new JsonResponse([
'error' => 'Internal error'
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
private function validateAndUpdatePayment(array $payload, string $type): void
{
try {
if ($type === 'genome') {
$description = $payload['description'] ?? null;
$mappingId = $this->extractMappingId($description);
$currency = $payload['amount']['currency'] ?? null;
$rawAmount = $payload['amount']['amount'] ?? 0.0;
$amount = (int) round((float) $rawAmount * 100); // Convert to cents
$senderName = $payload['sender']['name'] ?? null;
} elseif ($type === 'isx') {
$description = $payload['payment_provider_responses'][0]['details'] ?? null;
$mappingId = $this->extractMappingId($description);
$currency = $payload['payment_amount']['currency'] ?? null;
$amount = $payload['payment_amount']['amount'] ?? 0.0;
$senderName = $payload['original_sender_name'] ?? null;
} else {
$this->logger?->warning('Unsupported webhook type: ' . $type);
return;
}
if (!$description || !$amount || !$currency) {
$this->logger?->info('Invalid payload: missing fields', [
'description' => $description,
'mappiingId' => $mappingId,
'amount' => $amount,
'currency' => $currency,
'payload' => $payload,
]);
return;
}
$conn = $this->em->getConnection();
// Step 1: Check for matching payment
$sql = "SELECT * FROM payments WHERE mapping_id = :mapping_id AND amount = :amount AND currency = :currency LIMIT 1";
$result = $conn->fetchAssociative($sql, [
'mapping_id' => $mappingId,
'amount' => $amount,
'currency' => $currency,
]);
// Step 2: Update if found
if ($result) {
if ($result['status'] === 'completed') {
$this->logger?->info('Payment already completed', [
'description' => $description,
'mappingId' => $mappingId,
'amount' => $amount,
'currency' => $currency,
]);
return; // Payment already completed
}
$sql = "SELECT secret, billing_information_is_bank_webhook FROM sites WHERE id = :id LIMIT 1";
$site = $conn->fetchAssociative($sql, [
'id' => $result['site_id']
]);
if (!$site['billing_information_is_bank_webhook']) {
$this->logger?->info('Bank Webhook is not enabled');
return; // Bank Webhook is not enabled
}
$logs = [
'title' => 'Received new webhook call from Bank',
'details' => $payload
];
$updateSql = "UPDATE payments SET status = 'completed', payment_received = True, receiving_response = :response, bank_status = 'completed', updated_at = NOW() WHERE mapping_id = :mapping_id";
$conn->executeStatement($updateSql, [
'response' => json_encode($payload),
'mapping_id' => $mappingId,
]);
$this->commonServices->savePaymentLogs($result['id'], 200, $logs);
$this->commonServices->saveUserPaymentInfo(payment_id: $result['id'], name: $senderName);
$this->sendMerchantNotification($result, $site['secret']);
$this->logger?->info("Payment updated successfully for ID: {$description}");
} else {
$this->logger?->warning("No matching payment found", [
'description' => $description,
'mappiingId' => $mappingId,
'amount' => $amount,
'currency' => $currency,
]);
}
} catch (\Throwable $e) {
$this->logger?->error('Error in validateAndUpdatePayment: ' . $e->getMessage(), [
'exception' => $e
]);
}
}
// private function extractMappingId(string $description)
// {
// $conn = $this->em->getConnection();
// $sql = "SELECT mapping_id FROM payments WHERE created_at >= NOW() - INTERVAL '80 hours' AND bank_status = 'initiated' ORDER BY created_at DESC";
// $results = $conn->fetchAllAssociative($sql);
// foreach ($results as $row) {
// if (isset($row['mapping_id']) && stripos($description, (string)$row['mapping_id']) !== false) {
// return $row['mapping_id']; // Found mapping_id inside description
// }
// }
// }
private function extractMappingId(string $description)
{
$conn = $this->em->getConnection();
$sql = "
SELECT mapping_id
FROM payments
WHERE bank_status = 'initiated'
AND pisp_status NOT IN ('rejected', 'initiated')
AND :description ILIKE '%' || mapping_id || '%'
ORDER BY LENGTH(mapping_id) DESC, created_at DESC
LIMIT 1
";
$mappingId = $conn->fetchOne($sql, [
'description' => $description,
]);
if ($mappingId === false || $mappingId === null) {
$this->logger?->warning('No mapping_id found in description', [
'description' => $description,
]);
return null;
}
return $mappingId;
}
public function sendMerchantNotification(array $paymentNotification, string $secret): bool
{
$payment = $paymentNotification;
$payload = [
'id' => $payment['id'],
'paymentId' => $payment['external_id'],
'pisp_status' => $payment['pisp_status'],
'fin_status' => 'completed',
'currency' => $paymentNotification['currency'],
'amount' => $payment['amount'] / 100,
'userId' => $payment['customer_id']
];
try {
$response = $this->sendRequest('POST', $paymentNotification['notification_url'], $payload, $secret);
} catch (GuzzleException $exception) {
$this->logger->error('Merchant notification failed', [
'id' => $payment['id'],
'paymentId' => $payment['external_id'],
'error' => $exception->getMessage()
]);
$saveFailedNotification = $this->commonServices->saveFailedNotifications(
$payment['id'],
$payload,
$paymentNotification['notification_url'],
$secret
);
$logs = [
'title' => 'Failed to send Bank status update to merchant',
'details' => $payload
];
$this->commonServices->savePaymentLogs($payment['id'], $response->getStatusCode(), $logs);
if ($saveFailedNotification) {
$this->logger->warning('Merchant failed notification saved to DB', [
'id' => $payment['id'],
'paymentId' => $payment['external_id'],
]);
} else {
$this->logger->warning('Merchant failed notification failed to save to DB', [
'id' => $payment['id'],
'paymentId' => $payment['external_id'],
]);
}
return false;
}
if ($response->getStatusCode() >= 200 && $response->getStatusCode() < 300) {
$this->logger->info('Merchant notification sent successfully', [
'id' => $payment['id'],
'paymentId' => $payment['external_id']
]);
$logs = [
'title' => 'The Bank status update was successfully sent to the merchant',
'details' => $payload
];
$this->commonServices->savePaymentLogs($payment['id'], $response->getStatusCode(), $logs);
return true;
}
$logs = [
'title' => 'Failed to send Bank status update to merchant',
'details' => $payload
];
$this->commonServices->savePaymentLogs($payment['id'], $response->getStatusCode(), $logs);
$this->logger->error('Merchant notification failed', [
'id' => $payment['id'],
'paymentId' => $payment['external_id'],
'statusCode' => $response->getStatusCode(),
'error' => $response->getBody()->getContents()
]);
$saveFailedNotification = $this->commonServices->saveFailedNotifications(
$payment['id'],
$payload,
$paymentNotification['notification_url'],
$secret
);
if ($saveFailedNotification) {
$this->logger->warning('Merchant failed notification saved to DB', [
'id' => $payment['id'],
'paymentId' => $payment['external_id'],
]);
} else {
$this->logger->warning('Merchant failed notification failed to save to DB', [
'id' => $payment['id'],
'paymentId' => $payment['external_id'],
]);
}
return false;
}
/**
* @throws GuzzleException
*/
private function sendRequest(string $method, string $uri, array $params, string $secretKey): ResponseInterface
{
$jsonBase64 = base64_encode(json_encode($params));
return $this->client->request($method, $uri, [
'headers' => [
'X-Signature' => $this->generateSignature($jsonBase64, $secretKey),
],
'body' => $jsonBase64,
'http_errors' => false,
'timeout' => 10,
]);
}
private function generateSignature(string $jsonBase64, string $secretKey): string
{
return hash_hmac('sha256', $jsonBase64, $secretKey);
}
#[Route(path: '/countries/list', name: 'countries_list', methods: ['GET'])]
public function getCountries(Request $request): JsonResponse
{
$authorization = $request->headers->get('Authorization');
$factoryNameConfigDtls = $this->getFactoryNameAndConfig($authorization);
$factoryName = $factoryNameConfigDtls['factoryName'];
$configDtls = $factoryNameConfigDtls['config'];
switch ($factoryName) {
case 'yapily':
return $this->json([
'success' => false,
'message' => 'Yapily does not support this feature',
], 415);
break;
case 'iban_xs':
try {
$token = $this->getIbanXsToken($configDtls);
$client = new Client();
$response = $client->get($this->ibanxsApiBaseUrl . 'v1/countries', [
'headers' => [
'Accept' => 'application/json',
'Authorization' => 'Bearer ' . $token,
],
]);
return $this->json([
'success' => true,
'data' => json_decode($response->getBody()->getContents(), true),
]);
} catch (\Throwable $e) {
return $this->json([
'success' => false,
'message' => $e->getMessage(),
], 500);
}
break;
default:
return $this->json([
'success' => false,
'message' => 'Invalid Payment Factory',
], 400);
break;
}
}
#[Route(path: '/countries/banks/list', name: 'countries_banks_list', methods: ['GET'])]
public function getBanksForCountry(Request $request): JsonResponse
{
$countryCode = $request->query->get('countryCode');
if (!$countryCode) {
return $this->json([
'success' => false,
'message' => 'countryCode query parameter is required.',
], 400);
}
$authorization = $request->headers->get('Authorization');
$factoryNameConfigDtls = $this->getFactoryNameAndConfig($authorization);
$factoryName = $factoryNameConfigDtls['factoryName'];
$configDtls = $factoryNameConfigDtls['config'];
switch ($factoryName) {
case 'yapily':
return $this->json([
'success' => false,
'message' => 'Yapily does not support this feature',
], 415);
break;
case 'iban_xs':
try {
$token = $this->getIbanXsToken($configDtls);
$client = new Client();
$response = $client->get($this->ibanxsApiBaseUrl . 'v1/countries/' . $countryCode . '/banks', [
'headers' => [
'Accept' => 'application/json',
'Authorization' => 'Bearer ' . $token,
],
]);
$data = json_decode($response->getBody()->getContents(), true);
$banks = array_map(static function (array $bank) {
return [
'bankId' => $bank['bankId'],
'name' => $bank['name'],
];
}, $data);
return $this->json([
'success' => true,
'data' => $banks,
]);
} catch (\Throwable $e) {
return $this->json([
'success' => false,
'message' => $e->getMessage(),
], 500);
}
break;
default:
return $this->json([
'success' => false,
'message' => 'Invalid Payment Factory',
], 400);
break;
}
}
#[Route(path: '/banks/parameters', name: 'banks_parameters', methods: ['GET'])]
public function getBanksParameters(Request $request): JsonResponse
{
$bankId = $request->query->get('bankId');
if (!$bankId) {
return $this->json([
'success' => false,
'message' => 'bankId query parameter is required.',
], 400);
}
$authorization = $request->headers->get('Authorization');
$factoryNameConfigDtls = $this->getFactoryNameAndConfig($authorization);
$factoryName = $factoryNameConfigDtls['factoryName'];
$configDtls = $factoryNameConfigDtls['config'];
switch ($factoryName) {
case 'yapily':
return $this->json([
'success' => false,
'message' => 'Yapily does not support this feature',
], 415);
break;
case 'iban_xs':
try {
$token = $this->getIbanXsToken($configDtls);
$client = new Client();
$response = $client->get($this->ibanxsApiBaseUrl . 'v1/banks/' . $bankId .'/specification/PIS', [
'headers' => [
'Accept' => 'application/json',
'Authorization' => 'Bearer ' . $token,
],
]);
$data = json_decode($response->getBody()->getContents(), true);
return $this->json([
'success' => true,
'data' => $data,
]);
} catch (\Throwable $e) {
return $this->json([
'success' => false,
'message' => $e->getMessage(),
], 500);
}
break;
default:
return $this->json([
'success' => false,
'message' => 'Invalid Payment Factory',
], 400);
break;
}
}
// HELPER FUNCTIONS
private function getFactoryNameAndConfig(string $authorization)
{
if (!$authorization || !str_starts_with($authorization, 'Basic ')) {
return $this->json([
'success' => false,
'message' => 'Missing or invalid Authorization header',
], 401);
}
$base64 = substr($authorization, 6);
$decoded = base64_decode($base64, true);
if ($decoded === false) {
return $this->json([
'success' => false,
'message' => 'Invalid Basic token',
], 401);
}
// Format: username:password (username is expected to be site UUID, password is site password)
$parts = explode(':', $decoded, 2);
$username = $parts[0] ?? null;
$password = $parts[1] ?? null;
if (!$username || $password === null) {
return $this->json([
'success' => false,
'message' => 'Invalid Basic token format',
], 401);
}
// Validate against DB (matches SiteAuthenticator/SiteLoginProvider contract)
try {
$sites = $this->em
->getRepository(\App\Domain\Site\Model\Site::class)
->findById(\Symfony\Component\Uid\Uuid::fromString((string) $username));
$site = $sites[0] ?? null;
if (!$site) {
return $this->json([
'success' => false,
'message' => 'Invalid credentials',
], 401);
}
} catch (\Throwable $e) {
return $this->json([
'success' => false,
'message' => 'Invalid credentials',
], 401);
}
$hash = $site->getPassword();
if (!is_string($hash) || $hash === '') {
return $this->json([
'success' => false,
'message' => 'Invalid credentials',
], 401);
}
// Password hash check
if ($password !== $hash) {
return $this->json([
'success' => false,
'message' => 'Invalid credentials',
], 401);
}
$factoryName = $site->getPaymentGatewayConfig()->getFactoryName();
$config = $site->getPaymentGatewayConfig()->getConfig();
return [
'factoryName' => $factoryName,
'config' => $config
];
}
private function getIbanXsToken($configDtls): string
{
$client = new Client();
$response = $client->post($this->ibanxsAuthTokenUrl, [
'headers' => [
'Content-Type' => 'application/x-www-form-urlencoded',
],
'form_params' => [
'client_id' => $configDtls['client_id'],
'client_secret' => $configDtls['client_secret'],
'grant_type' => 'client_credentials',
],
]);
$body = json_decode($response->getBody()->getContents(), true);
return $body['access_token'];
}
}