<?php
declare(strict_types=1);
namespace App\Application\Payment\Service;
use App\Application\Common\CommonServices;
use App\Domain\Payment\Model\PaymentStatus;
use App\Domain\Payment\Model\RecurringMandateStatus;
use Doctrine\ORM\EntityManagerInterface;
use GuzzleHttp\Client;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\BadResponseException;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\ServerException;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Component\Uid\Uuid;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
class FinxpPaymentService implements LoggerAwareInterface
{
use LoggerAwareTrait;
/**
* Maps a FinXP SEPA Direct Debit transaction status onto the app's own
* payment status vocabulary (PaymentStatus). Any unrecognised value is
* treated as rejected rather than silently accepted.
*/
public const FINXP_STATUS_MAP = [
'PENDING' => PaymentStatus::PENDING,
'APPROVED' => PaymentStatus::COMPLETED,
'REFUNDED' => PaymentStatus::CANCELLED,
'REVERSED' => PaymentStatus::CANCELLED,
'CANCELLED' => PaymentStatus::CANCELLED,
'DECLINED' => PaymentStatus::REJECTED,
'RETURNED' => PaymentStatus::CANCELLED,
'REJECTED' => PaymentStatus::REJECTED,
'ERROR' => PaymentStatus::REJECTED,
];
/**
* Backoff (in minutes) before each successive retry attempt, indexed by the
* payment's retry_count at the time the retry is being scheduled (0 = the very
* first retry, scheduled after the original attempt failed). Once retry_count
* exceeds the last index, no further retries are scheduled.
*/
public const RETRY_BACKOFF_MINUTES = [10, 20, 40];
private ClientInterface $client;
public function __construct(
private readonly EntityManagerInterface $em,
?ClientInterface $client = null,
private readonly ?CacheInterface $cache = null,
private readonly ?CommonServices $commonServices = null
) {
$this->client = $client ?? new Client();
}
/**
* Maps a FinXP status string to the application's PaymentStatus constant.
*/
public function mapFinxpStatus(?string $finxpStatus): string
{
return self::FINXP_STATUS_MAP[strtoupper((string) $finxpStatus)] ?? PaymentStatus::REJECTED;
}
/**
* Calculates the next payment date based on interval and unit.
*/
public function calculateNextPaymentDate(?string $currentDate, int|string|null $intervalValue, ?string $intervalUnit): ?string
{
if (!$currentDate || !$intervalValue || !$intervalUnit) {
return null;
}
$aliases = [
'DAY' => 'DAY',
'DAYS' => 'DAY',
'DAILY' => 'DAY',
'WEEK' => 'WEEK',
'WEEKS' => 'WEEK',
'WEEKLY' => 'WEEK',
'MONTH' => 'MONTH',
'MONTHS' => 'MONTH',
'MONTHLY' => 'MONTH',
'YEAR' => 'YEAR',
'YEARS' => 'YEAR',
'YEARLY' => 'YEAR',
'ANNUAL' => 'YEAR',
'ANNUALLY' => 'YEAR',
];
$unit = strtoupper(trim((string) $intervalUnit));
$unit = $aliases[$unit] ?? null;
if (!$unit) {
return null;
}
$intervalValue = max(1, (int) $intervalValue);
$date = new \DateTime($currentDate);
switch ($unit) {
case 'DAY':
$date->modify("+{$intervalValue} days");
break;
case 'WEEK':
$date->modify("+{$intervalValue} weeks");
break;
case 'MONTH':
$date->modify("+{$intervalValue} months");
break;
case 'YEAR':
$date->modify("+{$intervalValue} years");
break;
}
return $date->format('Y-m-d');
}
/**
* Fetches and validates payment gateway configuration for a mandate.
*
* @return array<string, mixed>
*/
public function getPaymentGatewayConfigByMandateId(string $mandateId): array
{
$conn = $this->em->getConnection();
$sql = "
SELECT payment_gateway_configs.config
FROM recurring_mandates
JOIN sites ON sites.id::text = recurring_mandates.site_id
JOIN payment_gateway_configs ON payment_gateway_configs.id = sites.payment_gateway_config_id
WHERE recurring_mandates.id::text = :mandate_id
LIMIT 1
";
$rawConfig = $conn->fetchOne($sql, ['mandate_id' => $mandateId]);
if (empty($rawConfig)) {
throw new \RuntimeException(sprintf('Payment gateway configuration not found for mandate "%s".', $mandateId));
}
$config = is_string($rawConfig) ? json_decode($rawConfig, true) : (array) $rawConfig;
if (!is_array($config)) {
throw new \RuntimeException(sprintf('Invalid JSON configuration for mandate "%s".', $mandateId));
}
$tenantId = $config['finxp_tenant_id'] ?? $config['tenant_id'] ?? null;
$clientId = $config['finxp_client_id'] ?? $config['client_id'] ?? null;
$clientSecret = $config['finxp_client_secret'] ?? $config['client_secret'] ?? null;
$channelId = $config['finxp_channel_id'] ?? $config['channel_id'] ?? null;
if (empty($tenantId) || empty($clientId) || empty($clientSecret) || empty($channelId)) {
throw new \RuntimeException(sprintf('Missing required Microsoft OAuth credentials in gateway config for mandate "%s".', $mandateId));
}
$config['tenant_id'] = $tenantId;
$config['client_id'] = $clientId;
$config['client_secret'] = $clientSecret;
$config['channel_id'] = $channelId;
if (isset($config['sandbox'])) {
$config['sandbox'] = (bool) $config['sandbox'];
} elseif (isset($config['finxp_sandbox'])) {
$config['sandbox'] = (bool) $config['finxp_sandbox'];
}
return $config;
}
/**
* Calls Microsoft OAuth2 token endpoint using credentials from payment_gateway_configs.
*
* @return array<string, mixed>
*
* @throws \Throwable
*/
public function requestMicrosoftOAuthToken(string $mandateId): array
{
$config = $this->getPaymentGatewayConfigByMandateId($mandateId);
try {
return $this->requestMicrosoftOAuthTokenForConfig($config);
} catch (\Throwable $e) {
$this->logger?->error(
'Microsoft OAuth token request failed: ' . $e->getMessage(),
[
'mandate_id' => $mandateId,
'exception' => $e,
]
);
throw $e;
}
}
/**
* Same as requestMicrosoftOAuthToken(), but looked up by FinXP channel id
* instead of a recurring mandate id (used to authenticate calls that
* aren't tied to a specific mandate, e.g. fetching webhook signing keys).
*
* @return array<string, mixed>
*
* @throws \Throwable
*/
public function requestMicrosoftOAuthTokenForChannelId(string $channelId): array
{
$config = $this->getPaymentGatewayConfigByChannelId($channelId);
if ($config === null) {
throw new \RuntimeException(sprintf('Payment gateway configuration not found for channel "%s".', $channelId));
}
try {
return $this->requestMicrosoftOAuthTokenForConfig($config);
} catch (\Throwable $e) {
$this->logger?->error(
'Microsoft OAuth token request failed: ' . $e->getMessage(),
[
'channel_id' => $channelId,
'exception' => $e,
]
);
throw $e;
}
}
/**
* @param array<string, mixed> $config
*
* @return array<string, mixed>
*/
private function requestMicrosoftOAuthTokenForConfig(array $config): array
{
$response = $this->client->request(
'POST',
sprintf('https://login.microsoftonline.com/%s/oauth2/v2.0/token', $config['tenant_id']),
[
'headers' => [
'Content-Type' => 'application/x-www-form-urlencoded',
'Cookie' => 'fpc=AtvO9Jv2E0RBi6skJO3qfvZwKpNKAQAAAHKOLOIOAAAA',
],
'form_params' => [
'grant_type' => 'client_credentials',
'client_id' => $config['client_id'],
'client_secret' => $config['client_secret'],
'scope' => 'api://f2a73ca6-778b-4764-98cd-ce491bbffdd0/.default',
],
]
);
$data = json_decode((string) $response->getBody(), true);
return is_array($data) ? $data : [];
}
/**
* Executes a SEPA Direct Debit directly through FinXP with the provided config and payload,
* without requiring an existing recurring mandate in the database.
*
* @param array<string, mixed> $config
* @param array<string, mixed> $payload
*
* @return array<string, mixed>
*
* @throws \Throwable
*/
public function executeDirectDebit(array $config, array $payload, ?string $paymentId = null): array
{
$paymentId = $paymentId
?? (isset($payload['paymentId']) && is_string($payload['paymentId']) && Uuid::isValid($payload['paymentId']) ? $payload['paymentId'] : null)
?? (isset($payload['payment_id']) && is_string($payload['payment_id']) && Uuid::isValid($payload['payment_id']) ? $payload['payment_id'] : null);
if ($paymentId === null && !empty($payload['merchantTxnRef'])) {
try {
$resolved = $this->em->getConnection()->fetchOne(
'SELECT id FROM payments WHERE external_id = :ext ORDER BY created_at DESC LIMIT 1',
['ext' => (string) $payload['merchantTxnRef']]
);
if ($resolved) {
$paymentId = (string) $resolved;
}
} catch (\Throwable) {
}
}
$tenantId = $config['finxp_tenant_id'] ?? $config['tenant_id'] ?? null;
$clientId = $config['finxp_client_id'] ?? $config['client_id'] ?? null;
$clientSecret = $config['finxp_client_secret'] ?? $config['client_secret'] ?? null;
$channelId = $config['finxp_channel_id'] ?? $config['channel_id'] ?? null;
if (empty($tenantId) || empty($clientId) || empty($clientSecret) || empty($channelId)) {
if ($paymentId) {
$this->logPaymentEvent($paymentId, 400, 'Missing required Microsoft OAuth credentials in FinXP gateway configuration.', [
'config' => [
'tenantId' => !empty($tenantId),
'clientId' => !empty($clientId),
'clientSecret' => !empty($clientSecret),
'channelId' => !empty($channelId),
],
]);
}
throw new \RuntimeException('Missing required Microsoft OAuth credentials in FinXP gateway configuration.');
}
$normalizedConfig = [
'tenant_id' => $tenantId,
'client_id' => $clientId,
'client_secret' => $clientSecret,
'channel_id' => $channelId,
'sandbox' => isset($config['sandbox']) ? (bool) $config['sandbox'] : (bool) ($config['finxp_sandbox'] ?? true),
];
$tokenData = $this->requestMicrosoftOAuthTokenForConfig($normalizedConfig);
$accessToken = $tokenData['access_token'] ?? null;
if (empty($accessToken)) {
if ($paymentId) {
$this->logPaymentEvent($paymentId, 401, 'Failed to obtain Microsoft OAuth token for FinXP payment.', [
'tokenData' => $tokenData,
]);
}
throw new \RuntimeException('Failed to obtain Microsoft OAuth token for FinXP payment: ' . json_encode($tokenData));
}
$isSandbox = (bool) $normalizedConfig['sandbox'];
$baseUrl = $isSandbox ? 'https://sepa-api-sandbox.finxp.com' : 'https://sepa-api.finxp.com';
try {
$response = $this->client->request(
'POST',
sprintf('%s/api/v1/channels/%s/sepa-processing/sepa-dd', $baseUrl, $channelId),
[
'headers' => [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $accessToken,
],
'json' => $payload,
]
);
$data = json_decode((string) $response->getBody(), true);
$responseData = is_array($data) ? $data : [];
if ($paymentId) {
$this->logPaymentEvent($paymentId, 200, 'FinXP Direct Debit response received', [
'status' => $responseData['status'] ?? 200,
'response' => $responseData,
]);
}
return $responseData;
} catch (ClientException | ServerException | BadResponseException $e) {
$statusCode = $e->getResponse()?->getStatusCode() ?? 502;
$responseBody = (string) $e->getResponse()?->getBody();
$data = json_decode($responseBody, true);
$this->logger?->error('FinXP Direct Debit API returned an error', [
'statusCode' => $statusCode,
'response' => $data ?: $responseBody,
]);
if ($paymentId) {
$this->logPaymentEvent($paymentId, $statusCode, 'FinXP API error', [
'status' => $statusCode,
'body' => $data ?: $responseBody,
]);
}
if (is_array($data)) {
return $data;
}
throw $e;
} catch (GuzzleException $e) {
$this->logger?->error('Network error calling FinXP: ' . $e->getMessage(), [
'exception' => $e,
]);
if ($paymentId) {
$this->logPaymentEvent($paymentId, 502, 'Network error calling FinXP', [
'error' => $e->getMessage(),
]);
}
throw $e;
} catch (\Throwable $e) {
$this->logger?->error('FinXP Direct Debit request failed: ' . $e->getMessage(), [
'exception' => $e,
]);
if ($paymentId) {
$this->logPaymentEvent($paymentId, 500, 'FinXP Direct Debit request failed', [
'error' => $e->getMessage(),
]);
}
throw $e;
}
}
/**
* Calls FinXP SEPA Direct Debit endpoint using bearer token and mandate details.
*
* @param array<string, mixed> $tokenData
*
* @return array<string, mixed>
*
* @throws \Throwable
*/
public function requestSepaDirectDebit(array $tokenData, string $mandateId): array
{
$accessToken = $tokenData['access_token'] ?? null;
if (empty($accessToken)) {
throw new \RuntimeException(sprintf('Missing access token in OAuth response for mandate "%s".', $mandateId));
}
$config = $this->getPaymentGatewayConfigByMandateId($mandateId);
$channelId = $config['channel_id'] ?? null;
if (empty($channelId)) {
throw new \RuntimeException(sprintf('Channel ID not found in gateway config for mandate "%s".', $mandateId));
}
$conn = $this->em->getConnection();
$sql = "
SELECT
rpp.amount,
rm.debitor_iban,
rm.debitor_name,
rm.mandate_ref,
rm.mandate_date,
rm.external_id,
rm.description
FROM recurring_mandates rm
JOIN recurring_payment_plans rpp ON rpp.mandate_id = rm.id::text
WHERE rm.id::text = :mandate_id
LIMIT 1
";
$mandateDetails = $conn->fetchAssociative($sql, ['mandate_id' => $mandateId]);
if (!$mandateDetails) {
throw new \RuntimeException(sprintf('Recurring mandate or payment plan details not found for mandate "%s".', $mandateId));
}
$mandateDate = $mandateDetails['mandate_date'] ?? null;
if ($mandateDate instanceof \DateTimeInterface) {
$mandateDate = $mandateDate->format('Y-m-d');
} elseif (is_string($mandateDate) && str_contains($mandateDate, ' ')) {
$mandateDate = explode(' ', $mandateDate)[0];
}
$payload = [
'amount' => number_format((float) ($mandateDetails['amount'] ?? 0), 2, '.', ''),
'debitorIBAN' => (string) ($mandateDetails['debitor_iban'] ?? ''),
'debitorName' => (string) ($mandateDetails['debitor_name'] ?? ''),
'mandateRef' => (string) ($mandateDetails['mandate_ref'] ?? ''),
'mandateDate' => (string) $mandateDate,
'merchantTxnRef' => (string) ($mandateDetails['external_id'] ?? ''),
'remittanceInfo' => (string) ($mandateDetails['description'] ?? 'Recurring SEPA Direct Debit'),
'sequenceType' => 'RCUR',
];
$isSandbox = (bool) ($config['sandbox'] ?? true);
$baseUrl = $isSandbox ? 'https://sepa-api-sandbox.finxp.com' : 'https://sepa-api.finxp.com';
try {
$response = $this->client->request(
'POST',
sprintf('%s/api/v1/channels/%s/sepa-processing/sepa-dd', $baseUrl, $channelId),
[
'headers' => [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $accessToken,
],
'json' => $payload,
]
);
$data = json_decode((string) $response->getBody(), true);
return is_array($data) ? $data : [];
} catch (\Throwable $e) {
$this->logger?->error(
'FinXP SEPA Direct Debit request failed: ' . $e->getMessage(),
[
'mandate_id' => $mandateId,
'exception' => $e,
]
);
throw $e;
}
}
/**
* Records that a payment attempt against a mandate is starting, before calling the gateway.
*
* @param array<string, mixed> $data
*/
public function createRecurringPayment(array $data): string
{
$conn = $this->em->getConnection();
$sql = "
INSERT INTO recurring_payments (
site_id, mandate_id, plan_id, amount, currency,
reference_id, type, retry_count, status
) VALUES (
:site_id, :mandate_id, :plan_id, :amount, :currency,
:reference_id, :type, 0, :status
)
RETURNING id
";
return (string) $conn->fetchOne($sql, [
'site_id' => $data['site_id'],
'mandate_id' => $data['mandate_id'],
'plan_id' => $data['plan_id'],
'amount' => $data['amount'] ?? null,
'currency' => $data['currency'] ?? null,
'reference_id' => $data['reference_id'] ?? null,
'type' => $data['type'] ?? 'RECURRING',
'status' => $data['status'],
]);
}
/**
* Records the gateway response payload and status against a recurring payment.
*
* @param array<string, mixed> $responsePayload
*/
public function updateRecurringPayment(string $paymentId, array $responsePayload, string $status): void
{
$conn = $this->em->getConnection();
$conn->executeStatement(
'UPDATE recurring_payments
SET payload = :payload, status = :status, updated_at = EXTRACT(epoch FROM now())::bigint::text
WHERE id = :id',
[
'payload' => json_encode($responsePayload, JSON_UNESCAPED_SLASHES),
'status' => $status,
'id' => $paymentId,
]
);
}
/**
* Increments the current_count by 1 in recurring_payment_plans for the specified mandate.
*/
public function updateCurrentCount(string $mandateId): int
{
$conn = $this->em->getConnection();
return $conn->executeStatement(
'UPDATE recurring_payment_plans
SET current_count = COALESCE(current_count, 0) + 1,
updated_at = EXTRACT(epoch FROM now())::bigint::text
WHERE mandate_id = :mandate_id',
[
'mandate_id' => $mandateId,
]
);
}
/**
* Sets the recurring payment plan status to INACTIVE.
*/
public function deactivateRecurringPlan(string $planId): int
{
$conn = $this->em->getConnection();
return $conn->executeStatement(
'UPDATE recurring_payment_plans
SET status = :status,
updated_at = EXTRACT(epoch FROM now())::bigint::text
WHERE id = :id',
[
'status' => 'INACTIVE',
'id' => $planId,
]
);
}
/**
* Increments the retry_count by 1 in recurring_payments for the specified payment.
*/
public function updateRetryCount(string $paymentId): int
{
return $this->em->getConnection()->executeStatement(
'UPDATE recurring_payments
SET retry_count = COALESCE(retry_count, 0) + 1,
updated_at = EXTRACT(epoch FROM now())::bigint::text
WHERE id::text = :id',
[
'id' => $paymentId,
]
);
}
/**
* Fetches the current retry_count from recurring_payments for the specified payment.
*/
public function getPaymentRetryCount(string $paymentId): int
{
$count = $this->em->getConnection()->fetchOne(
'SELECT retry_count FROM recurring_payments WHERE id::text = :id',
['id' => $paymentId]
);
return (int) ($count ?? 0);
}
/**
* Deletes a record from recurring_retry_crons by ID.
*/
public function deleteRecurringRetryCron(string $retryCronId): int
{
return $this->em->getConnection()->executeStatement(
'DELETE FROM recurring_retry_crons WHERE id::text = :id',
['id' => $retryCronId]
);
}
/**
* Inserts or updates next attempt timestamp in recurring_retry_crons.
*/
public function updateRecurringRetryCrons(string $paymentId, int|string|null $nextAttemptAt): void
{
$nextAttemptTimestamp = null;
if ($nextAttemptAt !== null && $nextAttemptAt !== '') {
$nextAttemptTimestamp = is_numeric($nextAttemptAt) ? (int) $nextAttemptAt : strtotime((string) $nextAttemptAt);
if ($nextAttemptTimestamp === false) {
$nextAttemptTimestamp = null;
}
}
$conn = $this->em->getConnection();
$updated = $conn->executeStatement(
'UPDATE recurring_retry_crons
SET next_attempt_at = :next_attempt_at,
updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT::TEXT
WHERE recurring_payment_id = :recurring_payment_id',
[
'recurring_payment_id' => $paymentId,
'next_attempt_at' => $nextAttemptTimestamp,
]
);
if ($updated === 0) {
$conn->executeStatement(
'INSERT INTO recurring_retry_crons
(recurring_payment_id, next_attempt_at)
VALUES
(:recurring_payment_id, :next_attempt_at)',
[
'recurring_payment_id' => $paymentId,
'next_attempt_at' => $nextAttemptTimestamp,
]
);
}
}
/**
* Records a retry attempt in recurring_retry.
*/
public function saveRecurringRetry(string $paymentId, string $mappedStatus): void
{
$this->em->getConnection()->executeStatement(
'INSERT INTO recurring_retry
(recurring_payment_id, status)
VALUES
(:recurring_payment_id, :status)',
[
'recurring_payment_id' => $paymentId,
'status' => $mappedStatus,
]
);
}
/**
* Sets recurring mandate status to ACTIVE.
*/
public function activateRecurringMandate(string $mandateId): void
{
$this->em->getConnection()->executeStatement(
'UPDATE recurring_mandates
SET status = :status, updated_at = EXTRACT(epoch FROM now())::bigint::text
WHERE id::text = :id',
[
'status' => RecurringMandateStatus::ACTIVE,
'id' => $mandateId,
]
);
}
/**
* Sets recurring mandate status to INACTIVE (its payment plan has run its full course).
*/
public function deactivateRecurringMandate(string $mandateId): void
{
$this->em->getConnection()->executeStatement(
'UPDATE recurring_mandates
SET status = :status, updated_at = EXTRACT(epoch FROM now())::bigint::text
WHERE id::text = :id',
[
'status' => RecurringMandateStatus::INACTIVE,
'id' => $mandateId,
]
);
}
/**
* If the plan has no more scheduled occurrences left (current_count has reached
* max_count), marks both the plan and its mandate as done. Safe to call after
* every payment attempt outcome (success or failure) - a no-op otherwise.
*/
public function deactivateMandateAndPlanIfMaxReached(string $planId, string $mandateId): void
{
$row = $this->em->getConnection()->fetchAssociative(
'SELECT current_count, max_count FROM recurring_payment_plans WHERE id::text = :id',
['id' => $planId]
);
if (!$row) {
return;
}
$maxCount = $row['max_count'] !== null ? (int) $row['max_count'] : null;
$currentCount = (int) ($row['current_count'] ?? 0);
if ($maxCount !== null && $currentCount >= $maxCount) {
$this->deactivateRecurringPlan($planId);
$this->deactivateRecurringMandate($mandateId);
}
}
/**
* Advances a plan's next_payment_at to the given date, e.g. after processing
* its currently due cycle.
*/
public function updatePlanNextPaymentAt(string $planId, string $nextPaymentAt): void
{
$this->em->getConnection()->executeStatement(
'UPDATE recurring_payment_plans
SET next_payment_at = :next_payment_at, updated_at = EXTRACT(epoch FROM now())::bigint::text
WHERE id::text = :id',
[
'next_payment_at' => $nextPaymentAt,
'id' => $planId,
]
);
}
/**
* Backoff (in minutes) before the next retry, given how many retries have
* already happened for the payment. Returns null once retries are exhausted.
*/
public function calculateRetryBackoffMinutes(int $retryCount): ?int
{
return self::RETRY_BACKOFF_MINUTES[$retryCount] ?? null;
}
/**
* Schedules the next retry attempt for a failed recurring payment in
* recurring_retry_crons, backing off per calculateRetryBackoffMinutes(). Used
* uniformly whether the failure was observed synchronously (SEPA DD response)
* or asynchronously (FinXP webhook). Returns false once retries are exhausted,
* in which case no cron entry is scheduled.
*/
public function scheduleRecurringRetryCron(string $paymentId): bool
{
$retryCount = $this->getPaymentRetryCount($paymentId);
$backoffMinutes = $this->calculateRetryBackoffMinutes($retryCount);
if ($backoffMinutes === null) {
return false;
}
$this->updateRecurringRetryCrons($paymentId, time() + ($backoffMinutes * 60));
return true;
}
/**
* Fetches a payment gateway configuration by its FinXP channel ID.
*
* @return array<string, mixed>|null
*/
public function getPaymentGatewayConfigByChannelId(string $channelId): ?array
{
$conn = $this->em->getConnection();
$rawConfig = $conn->fetchOne(
"SELECT config FROM payment_gateway_configs WHERE config->>'finxp_channel_id' = :channel_id OR config->>'channel_id' = :channel_id LIMIT 1",
['channel_id' => $channelId]
);
if (empty($rawConfig)) {
return null;
}
$config = is_string($rawConfig) ? json_decode($rawConfig, true) : (array) $rawConfig;
if (!is_array($config)) {
return null;
}
if (isset($config['finxp_channel_id']) && !isset($config['channel_id'])) {
$config['channel_id'] = $config['finxp_channel_id'];
}
if (isset($config['finxp_tenant_id']) && !isset($config['tenant_id'])) {
$config['tenant_id'] = $config['finxp_tenant_id'];
}
if (isset($config['finxp_client_id']) && !isset($config['client_id'])) {
$config['client_id'] = $config['finxp_client_id'];
}
if (isset($config['finxp_client_secret']) && !isset($config['client_secret'])) {
$config['client_secret'] = $config['finxp_client_secret'];
}
if (isset($config['sandbox'])) {
$config['sandbox'] = (bool) $config['sandbox'];
} elseif (isset($config['finxp_sandbox'])) {
$config['sandbox'] = (bool) $config['finxp_sandbox'];
}
return $config;
}
/**
* Fetches and caches FinXP's webhook signing public keys, keyed by key id.
*
* @return array<string, string> Map of keyId => PEM public key.
*/
public function fetchWebhookPublicKeys(bool $isSandbox, string $bearerToken): array
{
$cacheKey = 'finxp_webhook_keys_' . ($isSandbox ? 'sandbox' : 'production');
$fetch = function () use ($isSandbox, $bearerToken): array {
$baseUrl = $isSandbox ? 'https://sepa-api-sandbox.finxp.com' : 'https://sepa-api.finxp.com';
$response = $this->client->request('GET', sprintf('%s/api/v1/webhook-keys', $baseUrl), [
'headers' => [
'Authorization' => 'Bearer ' . $bearerToken,
],
]);
$data = json_decode((string) $response->getBody(), true);
$keys = [];
foreach ((is_array($data) ? $data : []) as $entry) {
if (is_array($entry) && !empty($entry['keyId']) && !empty($entry['key'])) {
$keys[(string) $entry['keyId']] = (string) $entry['key'];
}
}
return $keys;
};
if ($this->cache === null) {
return $fetch();
}
return $this->cache->get($cacheKey, static function (ItemInterface $item) use ($fetch): array {
$item->expiresAfter(43200);
return $fetch();
});
}
/**
* Finds the recurring_payments row whose stored gateway response matches a FinXP transaction id,
* along with the mandate/plan it belongs to and its type (INITIAL vs RECURRING).
*
* @return array<string, mixed>|null
*/
public function findRecurringPaymentByTxnId(string $txnId): ?array
{
$conn = $this->em->getConnection();
$row = $conn->fetchAssociative(
"SELECT id, status, type, mandate_id, plan_id
FROM recurring_payments
WHERE payload->>'id' = :txn_id
LIMIT 1",
['txn_id' => $txnId]
);
return $row ?: null;
}
/**
* Records an event against a recurring_payments row in the same
* payment_logs table/format used by the other gateways, so it shows up
* in the existing admin/merchant "Recurring Payments" logs UI.
*
* @param array<string, mixed> $details
*/
public function logPaymentEvent(string $paymentId, int $statusCode, string $title, array $details = []): void
{
$this->commonServices?->savePaymentLogs($paymentId, $statusCode, [
'title' => $title,
'details' => $details + ['paymentProvider' => 'finxp'],
]);
}
/**
* Fetches everything needed to notify a merchant about a recurring
* payment's status: the schedule's notification_url and the site's
* signing secret, plus mandate/amount details for the payload.
*
* @return array<string, mixed>|null
*/
public function getNotificationContextByPaymentId(string $paymentId): ?array
{
$conn = $this->em->getConnection();
$row = $conn->fetchAssociative(
"SELECT
rp.id AS payment_id,
rp.amount,
rp.currency,
rpp.notification_url,
rm.mandate_ref,
rm.external_id,
rm.debitor_iban,
rm.debitor_name,
s.secret
FROM recurring_payments rp
JOIN recurring_payment_plans rpp ON rpp.id::text = rp.plan_id
JOIN recurring_mandates rm ON rm.id::text = rpp.mandate_id
JOIN sites s ON s.id::text = rp.site_id
WHERE rp.id::text = :payment_id
LIMIT 1",
['payment_id' => $paymentId]
);
return $row ?: null;
}
/**
* Notifies the merchant of a recurring payment's status via their
* configured notification_url, signed the same way as the other
* gateways (HMAC-SHA256 over base64(json) as X-Signature). Logs the
* outcome to payment_logs, and on failure queues it into the existing
* webhook_attempts retry mechanism (app:retry-merchant-notification).
*/
public function notifyMerchant(string $paymentId, string $mappedStatus): bool
{
$context = $this->getNotificationContextByPaymentId($paymentId);
if ($context === null || empty($context['notification_url'])) {
$this->logger?->info('FinXP: no notification_url configured, skipping merchant notification.', [
'payment_id' => $paymentId,
]);
return false;
}
$notificationUrl = (string) $context['notification_url'];
$secret = (string) $context['secret'];
$payload = [
'paymentId' => $paymentId,
'mandateRef' => $context['mandate_ref'],
'externalId' => $context['external_id'],
'status' => $mappedStatus,
'amount' => $context['amount'] !== null ? (float) $context['amount'] : null,
'currency' => $context['currency'],
'debitorName' => $context['debitor_name'],
'debitorIban' => $context['debitor_iban'],
'paymentProvider' => 'finxp',
];
$jsonBase64 = base64_encode(json_encode($payload, JSON_THROW_ON_ERROR));
try {
$response = $this->client->request('POST', $notificationUrl, [
'headers' => ['X-Signature' => hash_hmac('sha256', $jsonBase64, $secret)],
'body' => $jsonBase64,
'http_errors' => false,
'timeout' => 10,
]);
} catch (\Throwable $e) {
$this->logger?->error('FinXP merchant notification request failed: ' . $e->getMessage(), [
'payment_id' => $paymentId,
]);
$this->commonServices?->saveFailedNotifications($paymentId, $payload, $notificationUrl, $secret);
$this->logPaymentEvent($paymentId, 0, 'Failed to send FinXP payment status update to merchant', $payload);
return false;
}
$statusCode = $response->getStatusCode();
if ($statusCode >= 200 && $statusCode < 300) {
$this->logPaymentEvent($paymentId, $statusCode, 'The FinXP payment status update was successfully sent to the merchant', $payload);
return true;
}
$this->commonServices?->saveFailedNotifications($paymentId, $payload, $notificationUrl, $secret);
$this->logPaymentEvent($paymentId, $statusCode, 'Failed to send FinXP payment status update to merchant', $payload);
return false;
}
}