src/Application/Payment/Service/FinxpPaymentService.php line 887

Open in your IDE?
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Application\Payment\Service;
  4. use App\Application\Common\CommonServices;
  5. use App\Domain\Payment\Model\PaymentStatus;
  6. use App\Domain\Payment\Model\RecurringMandateStatus;
  7. use Doctrine\ORM\EntityManagerInterface;
  8. use GuzzleHttp\Client;
  9. use GuzzleHttp\ClientInterface;
  10. use GuzzleHttp\Exception\BadResponseException;
  11. use GuzzleHttp\Exception\ClientException;
  12. use GuzzleHttp\Exception\GuzzleException;
  13. use GuzzleHttp\Exception\ServerException;
  14. use Psr\Log\LoggerAwareInterface;
  15. use Psr\Log\LoggerAwareTrait;
  16. use Symfony\Component\Uid\Uuid;
  17. use Symfony\Contracts\Cache\CacheInterface;
  18. use Symfony\Contracts\Cache\ItemInterface;
  19. class FinxpPaymentService implements LoggerAwareInterface
  20. {
  21.     use LoggerAwareTrait;
  22.     /**
  23.      * Maps a FinXP SEPA Direct Debit transaction status onto the app's own
  24.      * payment status vocabulary (PaymentStatus). Any unrecognised value is
  25.      * treated as rejected rather than silently accepted.
  26.      */
  27.     public const FINXP_STATUS_MAP = [
  28.         'PENDING'   => PaymentStatus::PENDING,
  29.         'APPROVED'  => PaymentStatus::COMPLETED,
  30.         'REFUNDED'  => PaymentStatus::CANCELLED,
  31.         'REVERSED'  => PaymentStatus::CANCELLED,
  32.         'CANCELLED' => PaymentStatus::CANCELLED,
  33.         'DECLINED'  => PaymentStatus::REJECTED,
  34.         'RETURNED'  => PaymentStatus::CANCELLED,
  35.         'REJECTED'  => PaymentStatus::REJECTED,
  36.         'ERROR'     => PaymentStatus::REJECTED,
  37.     ];
  38.     /**
  39.      * Backoff (in minutes) before each successive retry attempt, indexed by the
  40.      * payment's retry_count at the time the retry is being scheduled (0 = the very
  41.      * first retry, scheduled after the original attempt failed). Once retry_count
  42.      * exceeds the last index, no further retries are scheduled.
  43.      */
  44.     public const RETRY_BACKOFF_MINUTES = [10, 20, 40];
  45.     private ClientInterface $client;
  46.     public function __construct(
  47.         private readonly EntityManagerInterface $em,
  48.         ?ClientInterface $client = null,
  49.         private readonly ?CacheInterface $cache = null,
  50.         private readonly ?CommonServices $commonServices = null
  51.     ) {
  52.         $this->client = $client ?? new Client();
  53.     }
  54.     /**
  55.      * Maps a FinXP status string to the application's PaymentStatus constant.
  56.      */
  57.     public function mapFinxpStatus(?string $finxpStatus): string
  58.     {
  59.         return self::FINXP_STATUS_MAP[strtoupper((string) $finxpStatus)] ?? PaymentStatus::REJECTED;
  60.     }
  61.     /**
  62.      * Calculates the next payment date based on interval and unit.
  63.      */
  64.     public function calculateNextPaymentDate(?string $currentDate, int|string|null $intervalValue, ?string $intervalUnit): ?string
  65.     {
  66.         if (!$currentDate || !$intervalValue || !$intervalUnit) {
  67.             return null;
  68.         }
  69.         $aliases = [
  70.             'DAY'      => 'DAY',
  71.             'DAYS'     => 'DAY',
  72.             'DAILY'    => 'DAY',
  73.             'WEEK'     => 'WEEK',
  74.             'WEEKS'    => 'WEEK',
  75.             'WEEKLY'   => 'WEEK',
  76.             'MONTH'    => 'MONTH',
  77.             'MONTHS'   => 'MONTH',
  78.             'MONTHLY'  => 'MONTH',
  79.             'YEAR'     => 'YEAR',
  80.             'YEARS'    => 'YEAR',
  81.             'YEARLY'   => 'YEAR',
  82.             'ANNUAL'   => 'YEAR',
  83.             'ANNUALLY' => 'YEAR',
  84.         ];
  85.         $unit = strtoupper(trim((string) $intervalUnit));
  86.         $unit = $aliases[$unit] ?? null;
  87.         if (!$unit) {
  88.             return null;
  89.         }
  90.         $intervalValue = max(1, (int) $intervalValue);
  91.         $date = new \DateTime($currentDate);
  92.         switch ($unit) {
  93.             case 'DAY':
  94.                 $date->modify("+{$intervalValue} days");
  95.                 break;
  96.             case 'WEEK':
  97.                 $date->modify("+{$intervalValue} weeks");
  98.                 break;
  99.             case 'MONTH':
  100.                 $date->modify("+{$intervalValue} months");
  101.                 break;
  102.             case 'YEAR':
  103.                 $date->modify("+{$intervalValue} years");
  104.                 break;
  105.         }
  106.         return $date->format('Y-m-d');
  107.     }
  108.     /**
  109.      * Fetches and validates payment gateway configuration for a mandate.
  110.      *
  111.      * @return array<string, mixed>
  112.      */
  113.     public function getPaymentGatewayConfigByMandateId(string $mandateId): array
  114.     {
  115.         $conn = $this->em->getConnection();
  116.         $sql = "
  117.             SELECT payment_gateway_configs.config
  118.             FROM recurring_mandates
  119.             JOIN sites ON sites.id::text = recurring_mandates.site_id
  120.             JOIN payment_gateway_configs ON payment_gateway_configs.id = sites.payment_gateway_config_id
  121.             WHERE recurring_mandates.id::text = :mandate_id
  122.             LIMIT 1
  123.         ";
  124.         $rawConfig = $conn->fetchOne($sql, ['mandate_id' => $mandateId]);
  125.         if (empty($rawConfig)) {
  126.             throw new \RuntimeException(sprintf('Payment gateway configuration not found for mandate "%s".', $mandateId));
  127.         }
  128.         $config = is_string($rawConfig) ? json_decode($rawConfig, true) : (array) $rawConfig;
  129.         if (!is_array($config)) {
  130.             throw new \RuntimeException(sprintf('Invalid JSON configuration for mandate "%s".', $mandateId));
  131.         }
  132.         $tenantId = $config['finxp_tenant_id'] ?? $config['tenant_id'] ?? null;
  133.         $clientId = $config['finxp_client_id'] ?? $config['client_id'] ?? null;
  134.         $clientSecret = $config['finxp_client_secret'] ?? $config['client_secret'] ?? null;
  135.         $channelId = $config['finxp_channel_id'] ?? $config['channel_id'] ?? null;
  136.         if (empty($tenantId) || empty($clientId) || empty($clientSecret) || empty($channelId)) {
  137.             throw new \RuntimeException(sprintf('Missing required Microsoft OAuth credentials in gateway config for mandate "%s".', $mandateId));
  138.         }
  139.         $config['tenant_id'] = $tenantId;
  140.         $config['client_id'] = $clientId;
  141.         $config['client_secret'] = $clientSecret;
  142.         $config['channel_id'] = $channelId;
  143.         if (isset($config['sandbox'])) {
  144.             $config['sandbox'] = (bool) $config['sandbox'];
  145.         } elseif (isset($config['finxp_sandbox'])) {
  146.             $config['sandbox'] = (bool) $config['finxp_sandbox'];
  147.         }
  148.         return $config;
  149.     }
  150.     /**
  151.      * Calls Microsoft OAuth2 token endpoint using credentials from payment_gateway_configs.
  152.      *
  153.      * @return array<string, mixed>
  154.      *
  155.      * @throws \Throwable
  156.      */
  157.     public function requestMicrosoftOAuthToken(string $mandateId): array
  158.     {
  159.         $config = $this->getPaymentGatewayConfigByMandateId($mandateId);
  160.         try {
  161.             return $this->requestMicrosoftOAuthTokenForConfig($config);
  162.         } catch (\Throwable $e) {
  163.             $this->logger?->error(
  164.                 'Microsoft OAuth token request failed: ' . $e->getMessage(),
  165.                 [
  166.                     'mandate_id' => $mandateId,
  167.                     'exception'  => $e,
  168.                 ]
  169.             );
  170.             throw $e;
  171.         }
  172.     }
  173.     /**
  174.      * Same as requestMicrosoftOAuthToken(), but looked up by FinXP channel id
  175.      * instead of a recurring mandate id (used to authenticate calls that
  176.      * aren't tied to a specific mandate, e.g. fetching webhook signing keys).
  177.      *
  178.      * @return array<string, mixed>
  179.      *
  180.      * @throws \Throwable
  181.      */
  182.     public function requestMicrosoftOAuthTokenForChannelId(string $channelId): array
  183.     {
  184.         $config = $this->getPaymentGatewayConfigByChannelId($channelId);
  185.         if ($config === null) {
  186.             throw new \RuntimeException(sprintf('Payment gateway configuration not found for channel "%s".', $channelId));
  187.         }
  188.         try {
  189.             return $this->requestMicrosoftOAuthTokenForConfig($config);
  190.         } catch (\Throwable $e) {
  191.             $this->logger?->error(
  192.                 'Microsoft OAuth token request failed: ' . $e->getMessage(),
  193.                 [
  194.                     'channel_id' => $channelId,
  195.                     'exception'  => $e,
  196.                 ]
  197.             );
  198.             throw $e;
  199.         }
  200.     }
  201.     /**
  202.      * @param array<string, mixed> $config
  203.      *
  204.      * @return array<string, mixed>
  205.      */
  206.     private function requestMicrosoftOAuthTokenForConfig(array $config): array
  207.     {
  208.         $response = $this->client->request(
  209.             'POST',
  210.             sprintf('https://login.microsoftonline.com/%s/oauth2/v2.0/token', $config['tenant_id']),
  211.             [
  212.                 'headers' => [
  213.                     'Content-Type' => 'application/x-www-form-urlencoded',
  214.                     'Cookie'       => 'fpc=AtvO9Jv2E0RBi6skJO3qfvZwKpNKAQAAAHKOLOIOAAAA',
  215.                 ],
  216.                 'form_params' => [
  217.                     'grant_type'    => 'client_credentials',
  218.                     'client_id'     => $config['client_id'],
  219.                     'client_secret' => $config['client_secret'],
  220.                     'scope'         => 'api://f2a73ca6-778b-4764-98cd-ce491bbffdd0/.default',
  221.                 ],
  222.             ]
  223.         );
  224.         $data = json_decode((string) $response->getBody(), true);
  225.         return is_array($data) ? $data : [];
  226.     }
  227.     /**
  228.      * Executes a SEPA Direct Debit directly through FinXP with the provided config and payload,
  229.      * without requiring an existing recurring mandate in the database.
  230.      *
  231.      * @param array<string, mixed> $config
  232.      * @param array<string, mixed> $payload
  233.      *
  234.      * @return array<string, mixed>
  235.      *
  236.      * @throws \Throwable
  237.      */
  238.     public function executeDirectDebit(array $config, array $payload, ?string $paymentId = null): array
  239.     {
  240.         $paymentId = $paymentId
  241.             ?? (isset($payload['paymentId']) && is_string($payload['paymentId']) && Uuid::isValid($payload['paymentId']) ? $payload['paymentId'] : null)
  242.             ?? (isset($payload['payment_id']) && is_string($payload['payment_id']) && Uuid::isValid($payload['payment_id']) ? $payload['payment_id'] : null);
  243.         if ($paymentId === null && !empty($payload['merchantTxnRef'])) {
  244.             try {
  245.                 $resolved = $this->em->getConnection()->fetchOne(
  246.                     'SELECT id FROM payments WHERE external_id = :ext ORDER BY created_at DESC LIMIT 1',
  247.                     ['ext' => (string) $payload['merchantTxnRef']]
  248.                 );
  249.                 if ($resolved) {
  250.                     $paymentId = (string) $resolved;
  251.                 }
  252.             } catch (\Throwable) {
  253.             }
  254.         }
  255.         $tenantId = $config['finxp_tenant_id'] ?? $config['tenant_id'] ?? null;
  256.         $clientId = $config['finxp_client_id'] ?? $config['client_id'] ?? null;
  257.         $clientSecret = $config['finxp_client_secret'] ?? $config['client_secret'] ?? null;
  258.         $channelId = $config['finxp_channel_id'] ?? $config['channel_id'] ?? null;
  259.         if (empty($tenantId) || empty($clientId) || empty($clientSecret) || empty($channelId)) {
  260.             if ($paymentId) {
  261.                 $this->logPaymentEvent($paymentId, 400, 'Missing required Microsoft OAuth credentials in FinXP gateway configuration.', [
  262.                     'config' => [
  263.                         'tenantId' => !empty($tenantId),
  264.                         'clientId' => !empty($clientId),
  265.                         'clientSecret' => !empty($clientSecret),
  266.                         'channelId' => !empty($channelId),
  267.                     ],
  268.                 ]);
  269.             }
  270.             throw new \RuntimeException('Missing required Microsoft OAuth credentials in FinXP gateway configuration.');
  271.         }
  272.         $normalizedConfig = [
  273.             'tenant_id' => $tenantId,
  274.             'client_id' => $clientId,
  275.             'client_secret' => $clientSecret,
  276.             'channel_id' => $channelId,
  277.             'sandbox' => isset($config['sandbox']) ? (bool) $config['sandbox'] : (bool) ($config['finxp_sandbox'] ?? true),
  278.         ];
  279.         $tokenData = $this->requestMicrosoftOAuthTokenForConfig($normalizedConfig);
  280.         $accessToken = $tokenData['access_token'] ?? null;
  281.         if (empty($accessToken)) {
  282.             if ($paymentId) {
  283.                 $this->logPaymentEvent($paymentId, 401, 'Failed to obtain Microsoft OAuth token for FinXP payment.', [
  284.                     'tokenData' => $tokenData,
  285.                 ]);
  286.             }
  287.             throw new \RuntimeException('Failed to obtain Microsoft OAuth token for FinXP payment: ' . json_encode($tokenData));
  288.         }
  289.         $isSandbox = (bool) $normalizedConfig['sandbox'];
  290.         $baseUrl = $isSandbox ? 'https://sepa-api-sandbox.finxp.com' : 'https://sepa-api.finxp.com';
  291.         try {
  292.             $response = $this->client->request(
  293.                 'POST',
  294.                 sprintf('%s/api/v1/channels/%s/sepa-processing/sepa-dd', $baseUrl, $channelId),
  295.                 [
  296.                     'headers' => [
  297.                         'Content-Type' => 'application/json',
  298.                         'Authorization' => 'Bearer ' . $accessToken,
  299.                     ],
  300.                     'json' => $payload,
  301.                 ]
  302.             );
  303.             $data = json_decode((string) $response->getBody(), true);
  304.             $responseData = is_array($data) ? $data : [];
  305.             if ($paymentId) {
  306.                 $this->logPaymentEvent($paymentId, 200, 'FinXP Direct Debit response received', [
  307.                     'status' => $responseData['status'] ?? 200,
  308.                     'response' => $responseData,
  309.                 ]);
  310.             }
  311.             return $responseData;
  312.         } catch (ClientException | ServerException | BadResponseException $e) {
  313.             $statusCode = $e->getResponse()?->getStatusCode() ?? 502;
  314.             $responseBody = (string) $e->getResponse()?->getBody();
  315.             $data = json_decode($responseBody, true);
  316.             $this->logger?->error('FinXP Direct Debit API returned an error', [
  317.                 'statusCode' => $statusCode,
  318.                 'response' => $data ?: $responseBody,
  319.             ]);
  320.             if ($paymentId) {
  321.                 $this->logPaymentEvent($paymentId, $statusCode, 'FinXP API error', [
  322.                     'status' => $statusCode,
  323.                     'body' => $data ?: $responseBody,
  324.                 ]);
  325.             }
  326.             if (is_array($data)) {
  327.                 return $data;
  328.             }
  329.             throw $e;
  330.         } catch (GuzzleException $e) {
  331.             $this->logger?->error('Network error calling FinXP: ' . $e->getMessage(), [
  332.                 'exception' => $e,
  333.             ]);
  334.             if ($paymentId) {
  335.                 $this->logPaymentEvent($paymentId, 502, 'Network error calling FinXP', [
  336.                     'error' => $e->getMessage(),
  337.                 ]);
  338.             }
  339.             throw $e;
  340.         } catch (\Throwable $e) {
  341.             $this->logger?->error('FinXP Direct Debit request failed: ' . $e->getMessage(), [
  342.                 'exception' => $e,
  343.             ]);
  344.             if ($paymentId) {
  345.                 $this->logPaymentEvent($paymentId, 500, 'FinXP Direct Debit request failed', [
  346.                     'error' => $e->getMessage(),
  347.                 ]);
  348.             }
  349.             throw $e;
  350.         }
  351.     }
  352.     /**
  353.      * Calls FinXP SEPA Direct Debit endpoint using bearer token and mandate details.
  354.      *
  355.      * @param array<string, mixed> $tokenData
  356.      *
  357.      * @return array<string, mixed>
  358.      *
  359.      * @throws \Throwable
  360.      */
  361.     public function requestSepaDirectDebit(array $tokenData, string $mandateId): array
  362.     {
  363.         $accessToken = $tokenData['access_token'] ?? null;
  364.         if (empty($accessToken)) {
  365.             throw new \RuntimeException(sprintf('Missing access token in OAuth response for mandate "%s".', $mandateId));
  366.         }
  367.         $config = $this->getPaymentGatewayConfigByMandateId($mandateId);
  368.         $channelId = $config['channel_id'] ?? null;
  369.         if (empty($channelId)) {
  370.             throw new \RuntimeException(sprintf('Channel ID not found in gateway config for mandate "%s".', $mandateId));
  371.         }
  372.         $conn = $this->em->getConnection();
  373.         $sql = "
  374.             SELECT
  375.                 rpp.amount,
  376.                 rm.debitor_iban,
  377.                 rm.debitor_name,
  378.                 rm.mandate_ref,
  379.                 rm.mandate_date,
  380.                 rm.external_id,
  381.                 rm.description
  382.             FROM recurring_mandates rm
  383.             JOIN recurring_payment_plans rpp ON rpp.mandate_id = rm.id::text
  384.             WHERE rm.id::text = :mandate_id
  385.             LIMIT 1
  386.         ";
  387.         $mandateDetails = $conn->fetchAssociative($sql, ['mandate_id' => $mandateId]);
  388.         if (!$mandateDetails) {
  389.             throw new \RuntimeException(sprintf('Recurring mandate or payment plan details not found for mandate "%s".', $mandateId));
  390.         }
  391.         $mandateDate = $mandateDetails['mandate_date'] ?? null;
  392.         if ($mandateDate instanceof \DateTimeInterface) {
  393.             $mandateDate = $mandateDate->format('Y-m-d');
  394.         } elseif (is_string($mandateDate) && str_contains($mandateDate, ' ')) {
  395.             $mandateDate = explode(' ', $mandateDate)[0];
  396.         }
  397.         $payload = [
  398.             'amount'         => number_format((float) ($mandateDetails['amount'] ?? 0), 2, '.', ''),
  399.             'debitorIBAN'    => (string) ($mandateDetails['debitor_iban'] ?? ''),
  400.             'debitorName'    => (string) ($mandateDetails['debitor_name'] ?? ''),
  401.             'mandateRef'     => (string) ($mandateDetails['mandate_ref'] ?? ''),
  402.             'mandateDate'    => (string) $mandateDate,
  403.             'merchantTxnRef' => (string) ($mandateDetails['external_id'] ?? ''),
  404.             'remittanceInfo' => (string) ($mandateDetails['description'] ?? 'Recurring SEPA Direct Debit'),
  405.             'sequenceType'   => 'RCUR',
  406.         ];
  407.         $isSandbox = (bool) ($config['sandbox'] ?? true);
  408.         $baseUrl = $isSandbox ? 'https://sepa-api-sandbox.finxp.com' : 'https://sepa-api.finxp.com';
  409.         try {
  410.             $response = $this->client->request(
  411.                 'POST',
  412.                 sprintf('%s/api/v1/channels/%s/sepa-processing/sepa-dd', $baseUrl, $channelId),
  413.                 [
  414.                     'headers' => [
  415.                         'Content-Type'  => 'application/json',
  416.                         'Authorization' => 'Bearer ' . $accessToken,
  417.                     ],
  418.                     'json' => $payload,
  419.                 ]
  420.             );
  421.             $data = json_decode((string) $response->getBody(), true);
  422.             return is_array($data) ? $data : [];
  423.         } catch (\Throwable $e) {
  424.             $this->logger?->error(
  425.                 'FinXP SEPA Direct Debit request failed: ' . $e->getMessage(),
  426.                 [
  427.                     'mandate_id' => $mandateId,
  428.                     'exception'  => $e,
  429.                 ]
  430.             );
  431.             throw $e;
  432.         }
  433.     }
  434.     /**
  435.      * Records that a payment attempt against a mandate is starting, before calling the gateway.
  436.      *
  437.      * @param array<string, mixed> $data
  438.      */
  439.     public function createRecurringPayment(array $data): string
  440.     {
  441.         $conn = $this->em->getConnection();
  442.         $sql = "
  443.             INSERT INTO recurring_payments (
  444.                 site_id, mandate_id, plan_id, amount, currency,
  445.                 reference_id, type, retry_count, status
  446.             ) VALUES (
  447.                 :site_id, :mandate_id, :plan_id, :amount, :currency,
  448.                 :reference_id, :type, 0, :status
  449.             )
  450.             RETURNING id
  451.         ";
  452.         return (string) $conn->fetchOne($sql, [
  453.             'site_id'      => $data['site_id'],
  454.             'mandate_id'   => $data['mandate_id'],
  455.             'plan_id'      => $data['plan_id'],
  456.             'amount'       => $data['amount'] ?? null,
  457.             'currency'     => $data['currency'] ?? null,
  458.             'reference_id' => $data['reference_id'] ?? null,
  459.             'type'         => $data['type'] ?? 'RECURRING',
  460.             'status'       => $data['status'],
  461.         ]);
  462.     }
  463.     /**
  464.      * Records the gateway response payload and status against a recurring payment.
  465.      *
  466.      * @param array<string, mixed> $responsePayload
  467.      */
  468.     public function updateRecurringPayment(string $paymentId, array $responsePayload, string $status): void
  469.     {
  470.         $conn = $this->em->getConnection();
  471.         $conn->executeStatement(
  472.             'UPDATE recurring_payments
  473.              SET payload = :payload, status = :status, updated_at = EXTRACT(epoch FROM now())::bigint::text
  474.              WHERE id = :id',
  475.             [
  476.                 'payload' => json_encode($responsePayload, JSON_UNESCAPED_SLASHES),
  477.                 'status'  => $status,
  478.                 'id'      => $paymentId,
  479.             ]
  480.         );
  481.     }
  482.     /**
  483.      * Increments the current_count by 1 in recurring_payment_plans for the specified mandate.
  484.      */
  485.     public function updateCurrentCount(string $mandateId): int
  486.     {
  487.         $conn = $this->em->getConnection();
  488.         return $conn->executeStatement(
  489.             'UPDATE recurring_payment_plans
  490.              SET current_count = COALESCE(current_count, 0) + 1,
  491.                  updated_at = EXTRACT(epoch FROM now())::bigint::text
  492.              WHERE mandate_id = :mandate_id',
  493.             [
  494.                 'mandate_id' => $mandateId,
  495.             ]
  496.         );
  497.     }
  498.     /**
  499.      * Sets the recurring payment plan status to INACTIVE.
  500.      */
  501.     public function deactivateRecurringPlan(string $planId): int
  502.     {
  503.         $conn = $this->em->getConnection();
  504.         return $conn->executeStatement(
  505.             'UPDATE recurring_payment_plans
  506.              SET status = :status,
  507.                  updated_at = EXTRACT(epoch FROM now())::bigint::text
  508.              WHERE id = :id',
  509.             [
  510.                 'status' => 'INACTIVE',
  511.                 'id'     => $planId,
  512.             ]
  513.         );
  514.     }
  515.     /**
  516.      * Increments the retry_count by 1 in recurring_payments for the specified payment.
  517.      */
  518.     public function updateRetryCount(string $paymentId): int
  519.     {
  520.         return $this->em->getConnection()->executeStatement(
  521.             'UPDATE recurring_payments
  522.              SET retry_count = COALESCE(retry_count, 0) + 1,
  523.                  updated_at = EXTRACT(epoch FROM now())::bigint::text
  524.              WHERE id::text = :id',
  525.             [
  526.                 'id' => $paymentId,
  527.             ]
  528.         );
  529.     }
  530.     /**
  531.      * Fetches the current retry_count from recurring_payments for the specified payment.
  532.      */
  533.     public function getPaymentRetryCount(string $paymentId): int
  534.     {
  535.         $count = $this->em->getConnection()->fetchOne(
  536.             'SELECT retry_count FROM recurring_payments WHERE id::text = :id',
  537.             ['id' => $paymentId]
  538.         );
  539.         return (int) ($count ?? 0);
  540.     }
  541.     /**
  542.      * Deletes a record from recurring_retry_crons by ID.
  543.      */
  544.     public function deleteRecurringRetryCron(string $retryCronId): int
  545.     {
  546.         return $this->em->getConnection()->executeStatement(
  547.             'DELETE FROM recurring_retry_crons WHERE id::text = :id',
  548.             ['id' => $retryCronId]
  549.         );
  550.     }
  551.     /**
  552.      * Inserts or updates next attempt timestamp in recurring_retry_crons.
  553.      */
  554.     public function updateRecurringRetryCrons(string $paymentId, int|string|null $nextAttemptAt): void
  555.     {
  556.         $nextAttemptTimestamp = null;
  557.         if ($nextAttemptAt !== null && $nextAttemptAt !== '') {
  558.             $nextAttemptTimestamp = is_numeric($nextAttemptAt) ? (int) $nextAttemptAt : strtotime((string) $nextAttemptAt);
  559.             if ($nextAttemptTimestamp === false) {
  560.                 $nextAttemptTimestamp = null;
  561.             }
  562.         }
  563.         $conn = $this->em->getConnection();
  564.         $updated = $conn->executeStatement(
  565.             'UPDATE recurring_retry_crons
  566.             SET next_attempt_at = :next_attempt_at,
  567.                 updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT::TEXT
  568.             WHERE recurring_payment_id = :recurring_payment_id',
  569.             [
  570.                 'recurring_payment_id' => $paymentId,
  571.                 'next_attempt_at'      => $nextAttemptTimestamp,
  572.             ]
  573.         );
  574.         if ($updated === 0) {
  575.             $conn->executeStatement(
  576.                 'INSERT INTO recurring_retry_crons
  577.                     (recurring_payment_id, next_attempt_at)
  578.                 VALUES
  579.                     (:recurring_payment_id, :next_attempt_at)',
  580.                 [
  581.                     'recurring_payment_id' => $paymentId,
  582.                     'next_attempt_at'      => $nextAttemptTimestamp,
  583.                 ]
  584.             );
  585.         }
  586.     }
  587.     /**
  588.      * Records a retry attempt in recurring_retry.
  589.      */
  590.     public function saveRecurringRetry(string $paymentId, string $mappedStatus): void
  591.     {
  592.         $this->em->getConnection()->executeStatement(
  593.             'INSERT INTO recurring_retry
  594.                 (recurring_payment_id, status)
  595.             VALUES
  596.                 (:recurring_payment_id, :status)',
  597.             [
  598.                 'recurring_payment_id' => $paymentId,
  599.                 'status'               => $mappedStatus,
  600.             ]
  601.         );
  602.     }
  603.     /**
  604.      * Sets recurring mandate status to ACTIVE.
  605.      */
  606.     public function activateRecurringMandate(string $mandateId): void
  607.     {
  608.         $this->em->getConnection()->executeStatement(
  609.             'UPDATE recurring_mandates
  610.              SET status = :status, updated_at = EXTRACT(epoch FROM now())::bigint::text
  611.              WHERE id::text = :id',
  612.             [
  613.                 'status' => RecurringMandateStatus::ACTIVE,
  614.                 'id'     => $mandateId,
  615.             ]
  616.         );
  617.     }
  618.     /**
  619.      * Sets recurring mandate status to INACTIVE (its payment plan has run its full course).
  620.      */
  621.     public function deactivateRecurringMandate(string $mandateId): void
  622.     {
  623.         $this->em->getConnection()->executeStatement(
  624.             'UPDATE recurring_mandates
  625.              SET status = :status, updated_at = EXTRACT(epoch FROM now())::bigint::text
  626.              WHERE id::text = :id',
  627.             [
  628.                 'status' => RecurringMandateStatus::INACTIVE,
  629.                 'id'     => $mandateId,
  630.             ]
  631.         );
  632.     }
  633.     /**
  634.      * If the plan has no more scheduled occurrences left (current_count has reached
  635.      * max_count), marks both the plan and its mandate as done. Safe to call after
  636.      * every payment attempt outcome (success or failure) - a no-op otherwise.
  637.      */
  638.     public function deactivateMandateAndPlanIfMaxReached(string $planId, string $mandateId): void
  639.     {
  640.         $row = $this->em->getConnection()->fetchAssociative(
  641.             'SELECT current_count, max_count FROM recurring_payment_plans WHERE id::text = :id',
  642.             ['id' => $planId]
  643.         );
  644.         if (!$row) {
  645.             return;
  646.         }
  647.         $maxCount = $row['max_count'] !== null ? (int) $row['max_count'] : null;
  648.         $currentCount = (int) ($row['current_count'] ?? 0);
  649.         if ($maxCount !== null && $currentCount >= $maxCount) {
  650.             $this->deactivateRecurringPlan($planId);
  651.             $this->deactivateRecurringMandate($mandateId);
  652.         }
  653.     }
  654.     /**
  655.      * Advances a plan's next_payment_at to the given date, e.g. after processing
  656.      * its currently due cycle.
  657.      */
  658.     public function updatePlanNextPaymentAt(string $planId, string $nextPaymentAt): void
  659.     {
  660.         $this->em->getConnection()->executeStatement(
  661.             'UPDATE recurring_payment_plans
  662.              SET next_payment_at = :next_payment_at, updated_at = EXTRACT(epoch FROM now())::bigint::text
  663.              WHERE id::text = :id',
  664.             [
  665.                 'next_payment_at' => $nextPaymentAt,
  666.                 'id'              => $planId,
  667.             ]
  668.         );
  669.     }
  670.     /**
  671.      * Backoff (in minutes) before the next retry, given how many retries have
  672.      * already happened for the payment. Returns null once retries are exhausted.
  673.      */
  674.     public function calculateRetryBackoffMinutes(int $retryCount): ?int
  675.     {
  676.         return self::RETRY_BACKOFF_MINUTES[$retryCount] ?? null;
  677.     }
  678.     /**
  679.      * Schedules the next retry attempt for a failed recurring payment in
  680.      * recurring_retry_crons, backing off per calculateRetryBackoffMinutes(). Used
  681.      * uniformly whether the failure was observed synchronously (SEPA DD response)
  682.      * or asynchronously (FinXP webhook). Returns false once retries are exhausted,
  683.      * in which case no cron entry is scheduled.
  684.      */
  685.     public function scheduleRecurringRetryCron(string $paymentId): bool
  686.     {
  687.         $retryCount = $this->getPaymentRetryCount($paymentId);
  688.         $backoffMinutes = $this->calculateRetryBackoffMinutes($retryCount);
  689.         if ($backoffMinutes === null) {
  690.             return false;
  691.         }
  692.         $this->updateRecurringRetryCrons($paymentId, time() + ($backoffMinutes * 60));
  693.         return true;
  694.     }
  695.     /**
  696.      * Fetches a payment gateway configuration by its FinXP channel ID.
  697.      *
  698.      * @return array<string, mixed>|null
  699.      */
  700.     public function getPaymentGatewayConfigByChannelId(string $channelId): ?array
  701.     {
  702.         $conn = $this->em->getConnection();
  703.         $rawConfig = $conn->fetchOne(
  704.             "SELECT config FROM payment_gateway_configs WHERE config->>'finxp_channel_id' = :channel_id OR config->>'channel_id' = :channel_id LIMIT 1",
  705.             ['channel_id' => $channelId]
  706.         );
  707.         if (empty($rawConfig)) {
  708.             return null;
  709.         }
  710.         $config = is_string($rawConfig) ? json_decode($rawConfig, true) : (array) $rawConfig;
  711.         if (!is_array($config)) {
  712.             return null;
  713.         }
  714.         if (isset($config['finxp_channel_id']) && !isset($config['channel_id'])) {
  715.             $config['channel_id'] = $config['finxp_channel_id'];
  716.         }
  717.         if (isset($config['finxp_tenant_id']) && !isset($config['tenant_id'])) {
  718.             $config['tenant_id'] = $config['finxp_tenant_id'];
  719.         }
  720.         if (isset($config['finxp_client_id']) && !isset($config['client_id'])) {
  721.             $config['client_id'] = $config['finxp_client_id'];
  722.         }
  723.         if (isset($config['finxp_client_secret']) && !isset($config['client_secret'])) {
  724.             $config['client_secret'] = $config['finxp_client_secret'];
  725.         }
  726.         if (isset($config['sandbox'])) {
  727.             $config['sandbox'] = (bool) $config['sandbox'];
  728.         } elseif (isset($config['finxp_sandbox'])) {
  729.             $config['sandbox'] = (bool) $config['finxp_sandbox'];
  730.         }
  731.         return $config;
  732.     }
  733.     /**
  734.      * Fetches and caches FinXP's webhook signing public keys, keyed by key id.
  735.      *
  736.      * @return array<string, string> Map of keyId => PEM public key.
  737.      */
  738.     public function fetchWebhookPublicKeys(bool $isSandbox, string $bearerToken): array
  739.     {
  740.         $cacheKey = 'finxp_webhook_keys_' . ($isSandbox ? 'sandbox' : 'production');
  741.         $fetch = function () use ($isSandbox, $bearerToken): array {
  742.             $baseUrl = $isSandbox ? 'https://sepa-api-sandbox.finxp.com' : 'https://sepa-api.finxp.com';
  743.             $response = $this->client->request('GET', sprintf('%s/api/v1/webhook-keys', $baseUrl), [
  744.                 'headers' => [
  745.                     'Authorization' => 'Bearer ' . $bearerToken,
  746.                 ],
  747.             ]);
  748.             $data = json_decode((string) $response->getBody(), true);
  749.             $keys = [];
  750.             foreach ((is_array($data) ? $data : []) as $entry) {
  751.                 if (is_array($entry) && !empty($entry['keyId']) && !empty($entry['key'])) {
  752.                     $keys[(string) $entry['keyId']] = (string) $entry['key'];
  753.                 }
  754.             }
  755.             return $keys;
  756.         };
  757.         if ($this->cache === null) {
  758.             return $fetch();
  759.         }
  760.         return $this->cache->get($cacheKey, static function (ItemInterface $item) use ($fetch): array {
  761.             $item->expiresAfter(43200);
  762.             return $fetch();
  763.         });
  764.     }
  765.     /**
  766.      * Finds the recurring_payments row whose stored gateway response matches a FinXP transaction id,
  767.      * along with the mandate/plan it belongs to and its type (INITIAL vs RECURRING).
  768.      *
  769.      * @return array<string, mixed>|null
  770.      */
  771.     public function findRecurringPaymentByTxnId(string $txnId): ?array
  772.     {
  773.         $conn = $this->em->getConnection();
  774.         $row = $conn->fetchAssociative(
  775.             "SELECT id, status, type, mandate_id, plan_id
  776.              FROM recurring_payments
  777.              WHERE payload->>'id' = :txn_id
  778.              LIMIT 1",
  779.             ['txn_id' => $txnId]
  780.         );
  781.         return $row ?: null;
  782.     }
  783.     /**
  784.      * Records an event against a recurring_payments row in the same
  785.      * payment_logs table/format used by the other gateways, so it shows up
  786.      * in the existing admin/merchant "Recurring Payments" logs UI.
  787.      *
  788.      * @param array<string, mixed> $details
  789.      */
  790.     public function logPaymentEvent(string $paymentId, int $statusCode, string $title, array $details = []): void
  791.     {
  792.         $this->commonServices?->savePaymentLogs($paymentId, $statusCode, [
  793.             'title'   => $title,
  794.             'details' => $details + ['paymentProvider' => 'finxp'],
  795.         ]);
  796.     }
  797.     /**
  798.      * Fetches everything needed to notify a merchant about a recurring
  799.      * payment's status: the schedule's notification_url and the site's
  800.      * signing secret, plus mandate/amount details for the payload.
  801.      *
  802.      * @return array<string, mixed>|null
  803.      */
  804.     public function getNotificationContextByPaymentId(string $paymentId): ?array
  805.     {
  806.         $conn = $this->em->getConnection();
  807.         $row = $conn->fetchAssociative(
  808.             "SELECT
  809.                 rp.id AS payment_id,
  810.                 rp.amount,
  811.                 rp.currency,
  812.                 rpp.notification_url,
  813.                 rm.mandate_ref,
  814.                 rm.external_id,
  815.                 rm.debitor_iban,
  816.                 rm.debitor_name,
  817.                 s.secret
  818.             FROM recurring_payments rp
  819.             JOIN recurring_payment_plans rpp ON rpp.id::text = rp.plan_id
  820.             JOIN recurring_mandates rm ON rm.id::text = rpp.mandate_id
  821.             JOIN sites s ON s.id::text = rp.site_id
  822.             WHERE rp.id::text = :payment_id
  823.             LIMIT 1",
  824.             ['payment_id' => $paymentId]
  825.         );
  826.         return $row ?: null;
  827.     }
  828.     /**
  829.      * Notifies the merchant of a recurring payment's status via their
  830.      * configured notification_url, signed the same way as the other
  831.      * gateways (HMAC-SHA256 over base64(json) as X-Signature). Logs the
  832.      * outcome to payment_logs, and on failure queues it into the existing
  833.      * webhook_attempts retry mechanism (app:retry-merchant-notification).
  834.      */
  835.     public function notifyMerchant(string $paymentId, string $mappedStatus): bool
  836.     {
  837.         $context = $this->getNotificationContextByPaymentId($paymentId);
  838.         if ($context === null || empty($context['notification_url'])) {
  839.             $this->logger?->info('FinXP: no notification_url configured, skipping merchant notification.', [
  840.                 'payment_id' => $paymentId,
  841.             ]);
  842.             return false;
  843.         }
  844.         $notificationUrl = (string) $context['notification_url'];
  845.         $secret = (string) $context['secret'];
  846.         $payload = [
  847.             'paymentId'       => $paymentId,
  848.             'mandateRef'      => $context['mandate_ref'],
  849.             'externalId'      => $context['external_id'],
  850.             'status'          => $mappedStatus,
  851.             'amount'          => $context['amount'] !== null ? (float) $context['amount'] : null,
  852.             'currency'        => $context['currency'],
  853.             'debitorName'     => $context['debitor_name'],
  854.             'debitorIban'     => $context['debitor_iban'],
  855.             'paymentProvider' => 'finxp',
  856.         ];
  857.         $jsonBase64 = base64_encode(json_encode($payload, JSON_THROW_ON_ERROR));
  858.         try {
  859.             $response = $this->client->request('POST', $notificationUrl, [
  860.                 'headers'     => ['X-Signature' => hash_hmac('sha256', $jsonBase64, $secret)],
  861.                 'body'        => $jsonBase64,
  862.                 'http_errors' => false,
  863.                 'timeout'     => 10,
  864.             ]);
  865.         } catch (\Throwable $e) {
  866.             $this->logger?->error('FinXP merchant notification request failed: ' . $e->getMessage(), [
  867.                 'payment_id' => $paymentId,
  868.             ]);
  869.             $this->commonServices?->saveFailedNotifications($paymentId, $payload, $notificationUrl, $secret);
  870.             $this->logPaymentEvent($paymentId, 0, 'Failed to send FinXP payment status update to merchant', $payload);
  871.             return false;
  872.         }
  873.         $statusCode = $response->getStatusCode();
  874.         if ($statusCode >= 200 && $statusCode < 300) {
  875.             $this->logPaymentEvent($paymentId, $statusCode, 'The FinXP payment status update was successfully sent to the merchant', $payload);
  876.             return true;
  877.         }
  878.         $this->commonServices?->saveFailedNotifications($paymentId, $payload, $notificationUrl, $secret);
  879.         $this->logPaymentEvent($paymentId, $statusCode, 'Failed to send FinXP payment status update to merchant', $payload);
  880.         return false;
  881.     }
  882. }