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

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 App\Domain\Payment\Model\PaymentStatus;
  6. use phpseclib3\Crypt\PublicKeyLoader;
  7. use Psr\Log\LoggerAwareInterface;
  8. use Psr\Log\LoggerAwareTrait;
  9. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  10. use Symfony\Component\HttpFoundation\JsonResponse;
  11. use Symfony\Component\HttpFoundation\Request;
  12. use Symfony\Component\HttpFoundation\Response;
  13. use Symfony\Component\Routing\Annotation\Route;
  14. #[Route('/api')]
  15. class FinxpCallbackController extends AbstractController implements LoggerAwareInterface
  16. {
  17.     use LoggerAwareTrait;
  18.     private const HANDLED_EVENTS = ['txn.StatusChanged', 'txn.RejectCodeReceived'];
  19.     public function __construct(
  20.         private readonly FinxpPaymentService $finxpPaymentService
  21.     ) {
  22.     }
  23.     /**
  24.      * Webhook receiver for FinXP's SEPA transaction status notifications
  25.      * (txn.StatusChanged, txn.RejectCodeReceived, txn.IncomingAdvice).
  26.      *
  27.      * Verifies the RSA-PSS request signature, then updates the matching
  28.      * recurring_payments row's status for status-change events.
  29.      */
  30.     #[Route('/finxp-callback', name: 'api_finxp_callback', methods: ['POST'])]
  31.     public function __invoke(Request $request): JsonResponse
  32.     {
  33.         $rawBody = $request->getContent();
  34.         $this->logger?->info('POST /api/finxp-callback', [
  35.             'headers' => $request->headers->all(),
  36.             'body'    => $rawBody,
  37.         ]);
  38.         $requestId = $request->headers->get('X-FinXP-Request-Id');
  39.         $signatureB64 = $request->headers->get('X-Finxp-Signature');
  40.         $keyId = $request->headers->get('X-Finxp-Signature-Key-Id');
  41.         $alg = $request->headers->get('X-Finxp-Signature-Alg');
  42.         if (!$requestId || !$signatureB64 || !$keyId) {
  43.             $this->logger?->warning('FinXP webhook rejected: missing signature headers.');
  44.             return new JsonResponse(['error' => 'Missing signature headers.'], Response::HTTP_BAD_REQUEST);
  45.         }
  46.         if ($alg !== null && $alg !== 'RSA-PSS') {
  47.             $this->logger?->warning('FinXP webhook rejected: unsupported signature algorithm.', ['alg' => $alg]);
  48.             return new JsonResponse(['error' => 'Unsupported signature algorithm.'], Response::HTTP_BAD_REQUEST);
  49.         }
  50.         $signature = base64_decode($signatureB64, true);
  51.         if ($signature === false) {
  52.             $this->logger?->warning('FinXP webhook rejected: signature is not valid base64.');
  53.             return new JsonResponse(['error' => 'Invalid signature encoding.'], Response::HTTP_BAD_REQUEST);
  54.         }
  55.         $payload = json_decode($rawBody, true);
  56.         if (!is_array($payload)) {
  57.             $this->logger?->warning('FinXP webhook rejected: body is not valid JSON.');
  58.             return new JsonResponse(['error' => 'Invalid JSON body.'], Response::HTTP_BAD_REQUEST);
  59.         }
  60.         $message = $requestId . $rawBody;
  61.         if (!$this->verifySignature($message, $signature, $keyId, $payload)) {
  62.             $this->logger?->warning('FinXP webhook rejected: signature verification failed.', [
  63.                 'key_id' => $keyId,
  64.             ]);
  65.             return new JsonResponse(['error' => 'Invalid signature.'], Response::HTTP_UNAUTHORIZED);
  66.         }
  67.         $event = $payload['event'] ?? null;
  68.         if (!in_array($event, self::HANDLED_EVENTS, true)) {
  69.             $this->logger?->info('FinXP webhook acknowledged but not processed.', ['event' => $event]);
  70.             return new JsonResponse(['success' => true]);
  71.         }
  72.         $txn = $payload['payload']['txn'] ?? null;
  73.         $txnId = is_array($txn) ? ($txn['id'] ?? null) : null;
  74.         if (!is_array($txn) || !$txnId) {
  75.             $this->logger?->warning('FinXP webhook rejected: missing transaction data.', ['event' => $event]);
  76.             return new JsonResponse(['error' => 'Missing transaction data.'], Response::HTTP_BAD_REQUEST);
  77.         }
  78.         $recurringPayment = $this->finxpPaymentService->findRecurringPaymentByTxnId((string) $txnId);
  79.         if ($recurringPayment === null) {
  80.             $this->logger?->info('FinXP webhook: no matching recurring payment found for transaction.', [
  81.                 'txn_id' => $txnId,
  82.             ]);
  83.             return new JsonResponse(['success' => true]);
  84.         }
  85.         $mappedStatus = $this->finxpPaymentService->mapFinxpStatus($txn['status'] ?? null);
  86.         if ($recurringPayment['status'] === $mappedStatus) {
  87.             $this->logger?->info('FinXP webhook: status already up to date, skipping.', [
  88.                 'payment_id' => $recurringPayment['id'],
  89.                 'status'     => $mappedStatus,
  90.             ]);
  91.             return new JsonResponse(['success' => true]);
  92.         }
  93.         $paymentId = (string) $recurringPayment['id'];
  94.         $type = (string) ($recurringPayment['type'] ?? '');
  95.         $mandateId = (string) ($recurringPayment['mandate_id'] ?? '');
  96.         $planId = (string) ($recurringPayment['plan_id'] ?? '');
  97.         $this->finxpPaymentService->updateRecurringPayment($paymentId, $txn, $mappedStatus);
  98.         $this->finxpPaymentService->logPaymentEvent($paymentId, 200, 'New FinXP status update', [
  99.             'txnId'  => $txnId,
  100.             'status' => $mappedStatus,
  101.         ]);
  102.         $this->logger?->info('FinXP webhook: recurring payment status updated.', [
  103.             'payment_id' => $paymentId,
  104.             'txn_id'     => $txnId,
  105.             'status'     => $mappedStatus,
  106.         ]);
  107.         // A mandate only becomes active once its first (INITIAL) payment actually
  108.         // completes - mirrors FinxpPaymentsController::saveRecurringMandates() for
  109.         // the case where that first payment resolves asynchronously via webhook
  110.         // instead of in the initial synchronous SEPA DD response.
  111.         if ($mappedStatus === PaymentStatus::COMPLETED && $type === 'INITIAL' && $mandateId !== '') {
  112.             $this->finxpPaymentService->activateRecurringMandate($mandateId);
  113.         }
  114.         // For recurring (non-initial) payments: a completed cycle may have been the
  115.         // plan's last scheduled occurrence, in which case the mandate is done too;
  116.         // a failed cycle needs its retry scheduled, same as the sync SEPA DD path.
  117.         if ($type === 'RECURRING' && $mandateId !== '' && $planId !== '') {
  118.             if ($mappedStatus === PaymentStatus::COMPLETED) {
  119.                 $this->finxpPaymentService->deactivateMandateAndPlanIfMaxReached($planId, $mandateId);
  120.             } elseif (in_array($mappedStatus, [PaymentStatus::REJECTED, PaymentStatus::CANCELLED], true)) {
  121.                 $this->finxpPaymentService->scheduleRecurringRetryCron($paymentId);
  122.                 $this->finxpPaymentService->saveRecurringRetry($paymentId, $mappedStatus);
  123.             }
  124.         }
  125.         $this->finxpPaymentService->notifyMerchant($paymentId, $mappedStatus);
  126.         return new JsonResponse(['success' => true]);
  127.     }
  128.     /**
  129.      * @param array<string, mixed> $payload
  130.      */
  131.     private function verifySignature(string $message, string $signature, string $keyId, array $payload): bool
  132.     {
  133.         $channelId = $payload['channelId'] ?? null;
  134.         if (!is_string($channelId) || $channelId === '') {
  135.             $this->logger?->warning('FinXP webhook: missing channelId, cannot resolve credentials to verify signature.');
  136.             return false;
  137.         }
  138.         try {
  139.             $config = $this->finxpPaymentService->getPaymentGatewayConfigByChannelId($channelId);
  140.             if ($config === null) {
  141.                 $this->logger?->warning('FinXP webhook: no gateway config found for channel.', ['channel_id' => $channelId]);
  142.                 return false;
  143.             }
  144.             $isSandbox = (bool) ($config['sandbox'] ?? true);
  145.             $tokenData = $this->finxpPaymentService->requestMicrosoftOAuthTokenForChannelId($channelId);
  146.             $accessToken = $tokenData['access_token'] ?? null;
  147.             if (empty($accessToken)) {
  148.                 $this->logger?->error('FinXP webhook: could not obtain OAuth token to fetch webhook keys.', ['channel_id' => $channelId]);
  149.                 return false;
  150.             }
  151.             $keys = $this->finxpPaymentService->fetchWebhookPublicKeys($isSandbox, (string) $accessToken);
  152.             $pem = $keys[$keyId] ?? null;
  153.             if ($pem === null) {
  154.                 $this->logger?->warning('FinXP webhook: no public key found for key id.', ['key_id' => $keyId]);
  155.                 return false;
  156.             }
  157.             $publicKey = PublicKeyLoader::loadPublicKey($pem);
  158.             return $publicKey->verify($message, $signature);
  159.         } catch (\Throwable $e) {
  160.             $this->logger?->error('FinXP webhook signature verification error: ' . $e->getMessage(), [
  161.                 'key_id'     => $keyId,
  162.                 'channel_id' => $channelId,
  163.                 'exception'  => $e,
  164.             ]);
  165.             return false;
  166.         }
  167.     }
  168. }