src/UI/Public/Controller/FinxpCallbackController.php line 18

Open in your IDE?
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\UI\Public\Controller;
  4. use App\Application\Payment\Service\FinxpPaymentService;
  5. use phpseclib3\Crypt\PublicKeyLoader;
  6. use Psr\Log\LoggerAwareInterface;
  7. use Psr\Log\LoggerAwareTrait;
  8. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  9. use Symfony\Component\HttpFoundation\JsonResponse;
  10. use Symfony\Component\HttpFoundation\Request;
  11. use Symfony\Component\HttpFoundation\Response;
  12. use Symfony\Component\Routing\Annotation\Route;
  13. #[Route('/api')]
  14. class FinxpCallbackController extends AbstractController implements LoggerAwareInterface
  15. {
  16.     use LoggerAwareTrait;
  17.     private const HANDLED_EVENTS = ['txn.StatusChanged''txn.RejectCodeReceived'];
  18.     public function __construct(
  19.         private readonly FinxpPaymentService $finxpPaymentService
  20.     ) {
  21.     }
  22.     /**
  23.      * Webhook receiver for FinXP's SEPA transaction status notifications
  24.      * (txn.StatusChanged, txn.RejectCodeReceived, txn.IncomingAdvice).
  25.      *
  26.      * Verifies the RSA-PSS request signature, then updates the matching
  27.      * recurring_payments row's status for status-change events.
  28.      */
  29.     #[Route('/finxp-callback'name'api_finxp_callback'methods: ['POST'])]
  30.     public function __invoke(Request $request): JsonResponse
  31.     {
  32.         $rawBody $request->getContent();
  33.         $this->logger?->info('POST /api/finxp-callback', [
  34.             'headers' => $request->headers->all(),
  35.             'body'    => $rawBody,
  36.         ]);
  37.         $requestId $request->headers->get('X-FinXP-Request-Id');
  38.         $signatureB64 $request->headers->get('X-Finxp-Signature');
  39.         $keyId $request->headers->get('X-Finxp-Signature-Key-Id');
  40.         $alg $request->headers->get('X-Finxp-Signature-Alg');
  41.         if (!$requestId || !$signatureB64 || !$keyId) {
  42.             $this->logger?->warning('FinXP webhook rejected: missing signature headers.');
  43.             return new JsonResponse(['error' => 'Missing signature headers.'], Response::HTTP_BAD_REQUEST);
  44.         }
  45.         if ($alg !== null && $alg !== 'RSA-PSS') {
  46.             $this->logger?->warning('FinXP webhook rejected: unsupported signature algorithm.', ['alg' => $alg]);
  47.             return new JsonResponse(['error' => 'Unsupported signature algorithm.'], Response::HTTP_BAD_REQUEST);
  48.         }
  49.         $signature base64_decode($signatureB64true);
  50.         if ($signature === false) {
  51.             $this->logger?->warning('FinXP webhook rejected: signature is not valid base64.');
  52.             return new JsonResponse(['error' => 'Invalid signature encoding.'], Response::HTTP_BAD_REQUEST);
  53.         }
  54.         $payload json_decode($rawBodytrue);
  55.         if (!is_array($payload)) {
  56.             $this->logger?->warning('FinXP webhook rejected: body is not valid JSON.');
  57.             return new JsonResponse(['error' => 'Invalid JSON body.'], Response::HTTP_BAD_REQUEST);
  58.         }
  59.         $message $requestId $rawBody;
  60.         if (!$this->verifySignature($message$signature$keyId$payload)) {
  61.             $this->logger?->warning('FinXP webhook rejected: signature verification failed.', [
  62.                 'key_id' => $keyId,
  63.             ]);
  64.             return new JsonResponse(['error' => 'Invalid signature.'], Response::HTTP_UNAUTHORIZED);
  65.         }
  66.         $event $payload['event'] ?? null;
  67.         if (!in_array($eventself::HANDLED_EVENTStrue)) {
  68.             $this->logger?->info('FinXP webhook acknowledged but not processed.', ['event' => $event]);
  69.             return new JsonResponse(['success' => true]);
  70.         }
  71.         $txn $payload['payload']['txn'] ?? null;
  72.         $txnId is_array($txn) ? ($txn['id'] ?? null) : null;
  73.         if (!is_array($txn) || !$txnId) {
  74.             $this->logger?->warning('FinXP webhook rejected: missing transaction data.', ['event' => $event]);
  75.             return new JsonResponse(['error' => 'Missing transaction data.'], Response::HTTP_BAD_REQUEST);
  76.         }
  77.         $recurringPayment $this->finxpPaymentService->findRecurringPaymentByTxnId((string) $txnId);
  78.         if ($recurringPayment === null) {
  79.             $this->logger?->info('FinXP webhook: no matching recurring payment found for transaction.', [
  80.                 'txn_id' => $txnId,
  81.             ]);
  82.             return new JsonResponse(['success' => true]);
  83.         }
  84.         $mappedStatus $this->finxpPaymentService->mapFinxpStatus($txn['status'] ?? null);
  85.         if ($recurringPayment['status'] === $mappedStatus) {
  86.             $this->logger?->info('FinXP webhook: status already up to date, skipping.', [
  87.                 'payment_id' => $recurringPayment['id'],
  88.                 'status'     => $mappedStatus,
  89.             ]);
  90.             return new JsonResponse(['success' => true]);
  91.         }
  92.         $this->finxpPaymentService->updateRecurringPayment((string) $recurringPayment['id'], $txn$mappedStatus);
  93.         $this->logger?->info('FinXP webhook: recurring payment status updated.', [
  94.             'payment_id' => $recurringPayment['id'],
  95.             'txn_id'     => $txnId,
  96.             'status'     => $mappedStatus,
  97.         ]);
  98.         return new JsonResponse(['success' => true]);
  99.     }
  100.     /**
  101.      * @param array<string, mixed> $payload
  102.      */
  103.     private function verifySignature(string $messagestring $signaturestring $keyId, array $payload): bool
  104.     {
  105.         $channelId $payload['channelId'] ?? null;
  106.         if (!is_string($channelId) || $channelId === '') {
  107.             $this->logger?->warning('FinXP webhook: missing channelId, cannot resolve credentials to verify signature.');
  108.             return false;
  109.         }
  110.         try {
  111.             $config $this->finxpPaymentService->getPaymentGatewayConfigByChannelId($channelId);
  112.             if ($config === null) {
  113.                 $this->logger?->warning('FinXP webhook: no gateway config found for channel.', ['channel_id' => $channelId]);
  114.                 return false;
  115.             }
  116.             $isSandbox = (bool) ($config['sandbox'] ?? true);
  117.             $tokenData $this->finxpPaymentService->requestMicrosoftOAuthTokenForChannelId($channelId);
  118.             $accessToken $tokenData['access_token'] ?? null;
  119.             if (empty($accessToken)) {
  120.                 $this->logger?->error('FinXP webhook: could not obtain OAuth token to fetch webhook keys.', ['channel_id' => $channelId]);
  121.                 return false;
  122.             }
  123.             $keys $this->finxpPaymentService->fetchWebhookPublicKeys($isSandbox, (string) $accessToken);
  124.             $pem $keys[$keyId] ?? null;
  125.             if ($pem === null) {
  126.                 $this->logger?->warning('FinXP webhook: no public key found for key id.', ['key_id' => $keyId]);
  127.                 return false;
  128.             }
  129.             $publicKey PublicKeyLoader::loadPublicKey($pem);
  130.             return $publicKey->verify($message$signature);
  131.         } catch (\Throwable $e) {
  132.             $this->logger?->error('FinXP webhook signature verification error: ' $e->getMessage(), [
  133.                 'key_id'     => $keyId,
  134.                 'channel_id' => $channelId,
  135.                 'exception'  => $e,
  136.             ]);
  137.             return false;
  138.         }
  139.     }
  140. }