<?php
declare(strict_types=1);
namespace App\UI\Public\Controller;
use App\Application\Payment\Service\FinxpPaymentService;
use phpseclib3\Crypt\PublicKeyLoader;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
#[Route('/api')]
class FinxpCallbackController extends AbstractController implements LoggerAwareInterface
{
use LoggerAwareTrait;
private const HANDLED_EVENTS = ['txn.StatusChanged', 'txn.RejectCodeReceived'];
public function __construct(
private readonly FinxpPaymentService $finxpPaymentService
) {
}
/**
* Webhook receiver for FinXP's SEPA transaction status notifications
* (txn.StatusChanged, txn.RejectCodeReceived, txn.IncomingAdvice).
*
* Verifies the RSA-PSS request signature, then updates the matching
* recurring_payments row's status for status-change events.
*/
#[Route('/finxp-callback', name: 'api_finxp_callback', methods: ['POST'])]
public function __invoke(Request $request): JsonResponse
{
$rawBody = $request->getContent();
$this->logger?->info('POST /api/finxp-callback', [
'headers' => $request->headers->all(),
'body' => $rawBody,
]);
$requestId = $request->headers->get('X-FinXP-Request-Id');
$signatureB64 = $request->headers->get('X-Finxp-Signature');
$keyId = $request->headers->get('X-Finxp-Signature-Key-Id');
$alg = $request->headers->get('X-Finxp-Signature-Alg');
if (!$requestId || !$signatureB64 || !$keyId) {
$this->logger?->warning('FinXP webhook rejected: missing signature headers.');
return new JsonResponse(['error' => 'Missing signature headers.'], Response::HTTP_BAD_REQUEST);
}
if ($alg !== null && $alg !== 'RSA-PSS') {
$this->logger?->warning('FinXP webhook rejected: unsupported signature algorithm.', ['alg' => $alg]);
return new JsonResponse(['error' => 'Unsupported signature algorithm.'], Response::HTTP_BAD_REQUEST);
}
$signature = base64_decode($signatureB64, true);
if ($signature === false) {
$this->logger?->warning('FinXP webhook rejected: signature is not valid base64.');
return new JsonResponse(['error' => 'Invalid signature encoding.'], Response::HTTP_BAD_REQUEST);
}
$payload = json_decode($rawBody, true);
if (!is_array($payload)) {
$this->logger?->warning('FinXP webhook rejected: body is not valid JSON.');
return new JsonResponse(['error' => 'Invalid JSON body.'], Response::HTTP_BAD_REQUEST);
}
$message = $requestId . $rawBody;
if (!$this->verifySignature($message, $signature, $keyId, $payload)) {
$this->logger?->warning('FinXP webhook rejected: signature verification failed.', [
'key_id' => $keyId,
]);
return new JsonResponse(['error' => 'Invalid signature.'], Response::HTTP_UNAUTHORIZED);
}
$event = $payload['event'] ?? null;
if (!in_array($event, self::HANDLED_EVENTS, true)) {
$this->logger?->info('FinXP webhook acknowledged but not processed.', ['event' => $event]);
return new JsonResponse(['success' => true]);
}
$txn = $payload['payload']['txn'] ?? null;
$txnId = is_array($txn) ? ($txn['id'] ?? null) : null;
if (!is_array($txn) || !$txnId) {
$this->logger?->warning('FinXP webhook rejected: missing transaction data.', ['event' => $event]);
return new JsonResponse(['error' => 'Missing transaction data.'], Response::HTTP_BAD_REQUEST);
}
$recurringPayment = $this->finxpPaymentService->findRecurringPaymentByTxnId((string) $txnId);
if ($recurringPayment === null) {
$this->logger?->info('FinXP webhook: no matching recurring payment found for transaction.', [
'txn_id' => $txnId,
]);
return new JsonResponse(['success' => true]);
}
$mappedStatus = $this->finxpPaymentService->mapFinxpStatus($txn['status'] ?? null);
if ($recurringPayment['status'] === $mappedStatus) {
$this->logger?->info('FinXP webhook: status already up to date, skipping.', [
'payment_id' => $recurringPayment['id'],
'status' => $mappedStatus,
]);
return new JsonResponse(['success' => true]);
}
$this->finxpPaymentService->updateRecurringPayment((string) $recurringPayment['id'], $txn, $mappedStatus);
$this->logger?->info('FinXP webhook: recurring payment status updated.', [
'payment_id' => $recurringPayment['id'],
'txn_id' => $txnId,
'status' => $mappedStatus,
]);
return new JsonResponse(['success' => true]);
}
/**
* @param array<string, mixed> $payload
*/
private function verifySignature(string $message, string $signature, string $keyId, array $payload): bool
{
$channelId = $payload['channelId'] ?? null;
if (!is_string($channelId) || $channelId === '') {
$this->logger?->warning('FinXP webhook: missing channelId, cannot resolve credentials to verify signature.');
return false;
}
try {
$config = $this->finxpPaymentService->getPaymentGatewayConfigByChannelId($channelId);
if ($config === null) {
$this->logger?->warning('FinXP webhook: no gateway config found for channel.', ['channel_id' => $channelId]);
return false;
}
$isSandbox = (bool) ($config['sandbox'] ?? true);
$tokenData = $this->finxpPaymentService->requestMicrosoftOAuthTokenForChannelId($channelId);
$accessToken = $tokenData['access_token'] ?? null;
if (empty($accessToken)) {
$this->logger?->error('FinXP webhook: could not obtain OAuth token to fetch webhook keys.', ['channel_id' => $channelId]);
return false;
}
$keys = $this->finxpPaymentService->fetchWebhookPublicKeys($isSandbox, (string) $accessToken);
$pem = $keys[$keyId] ?? null;
if ($pem === null) {
$this->logger?->warning('FinXP webhook: no public key found for key id.', ['key_id' => $keyId]);
return false;
}
$publicKey = PublicKeyLoader::loadPublicKey($pem);
return $publicKey->verify($message, $signature);
} catch (\Throwable $e) {
$this->logger?->error('FinXP webhook signature verification error: ' . $e->getMessage(), [
'key_id' => $keyId,
'channel_id' => $channelId,
'exception' => $e,
]);
return false;
}
}
}