diff --git a/composer.json b/composer.json
index a7e6c659..d869d56e 100755
--- a/composer.json
+++ b/composer.json
@@ -14,7 +14,7 @@
"ext-json": "*",
"giggsey/libphonenumber-for-php": "^8.12",
"payplug/payplug-php": "^4.0",
- "payplug/unified-plugin-core": "0.0.7",
+ "payplug/unified-plugin-core": "dev-develop",
"php-http/message-factory": "^1.1",
"sylius/refund-plugin": "^2.0",
"sylius/sylius": "^2.0",
diff --git a/config/resources.yaml b/config/resources.yaml
index 16265d4d..8cdbd63f 100644
--- a/config/resources.yaml
+++ b/config/resources.yaml
@@ -9,3 +9,8 @@ sylius_resource:
driver: doctrine/orm
classes:
model: PayPlug\SyliusPayPlugPlugin\Entity\Card
+ payplug.payplug_operation:
+ driver: doctrine/orm
+ classes:
+ model: PayPlug\SyliusPayPlugPlugin\Entity\PayplugOperation
+ repository: PayPlug\SyliusPayPlugPlugin\Repository\PayplugOperationRepository
diff --git a/config/services.yaml b/config/services.yaml
index 2131eff5..634b00cd 100644
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -169,6 +169,26 @@ services:
- name: sylius.payment_request.provider.http_response
gateway_factory: !php/const PayPlug\SyliusPayPlugPlugin\Gateway\WeroGatewayFactory::FACTORY_NAME
+ ## Uhf Payplug Gateway ##
+ payplug_sylius_payplug_plugin.command_provider.payplug_uhf:
+ class: Sylius\Bundle\PaymentBundle\CommandProvider\ActionsCommandProvider
+ arguments:
+ - !tagged_locator
+ tag: payplug_sylius_payplug_plugin.command_provider.payplug_uhf
+ index_by: 'action'
+ tags:
+ - name: sylius.payment_request.command_provider
+ gateway_factory: !php/const PayPlug\SyliusPayPlugPlugin\Gateway\UhfGatewayFactory::FACTORY_NAME
+ payplug_sylius_payplug_plugin.provider.order_pay.http_response.payplug_uhf:
+ class: Sylius\Bundle\PaymentBundle\Provider\ActionsHttpResponseProvider
+ arguments:
+ - !tagged_locator
+ tag: payplug_sylius_payplug_plugin.http_response_provider.payplug_uhf
+ index_by: action
+ tags:
+ - name: sylius.payment_request.provider.http_response
+ gateway_factory: !php/const PayPlug\SyliusPayPlugPlugin\Gateway\UhfGatewayFactory::FACTORY_NAME
+
## Apple Pay Payplug Gateway ##
payplug_sylius_payplug_plugin.command_provider.payplug_apple_pay:
diff --git a/config/services/client.xml b/config/services/client.xml
index 4e2053e8..de9d70c5 100644
--- a/config/services/client.xml
+++ b/config/services/client.xml
@@ -16,6 +16,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -29,6 +54,13 @@
+
+
+ %payplug.oauth_base_url%
+
+
+
+
+
+ PayPlug\SyliusPayPlugPlugin\Gateway\UhfGatewayFactory
+
+
diff --git a/migrations/Version20260806120000.php b/migrations/Version20260806120000.php
new file mode 100644
index 00000000..b8f24966
--- /dev/null
+++ b/migrations/Version20260806120000.php
@@ -0,0 +1,38 @@
+addSql('CREATE TABLE payplug_operations (
+ id INT AUTO_INCREMENT NOT NULL,
+ operation_id VARCHAR(255) NOT NULL,
+ order_id VARCHAR(255) NOT NULL,
+ exec_code VARCHAR(255) NOT NULL,
+ outcome VARCHAR(255) NOT NULL,
+ amount INT NOT NULL,
+ treated TINYINT(1) NOT NULL,
+ created_at DATETIME NOT NULL,
+ UNIQUE INDEX UNIQ_PAYPLUG_OPERATIONS_OPERATION_ID (operation_id),
+ INDEX IDX_PAYPLUG_OPERATIONS_ORDER_ID (order_id),
+ PRIMARY KEY(id)
+ ) DEFAULT CHARACTER SET UTF8 COLLATE `UTF8_unicode_ci` ENGINE = InnoDB');
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('DROP TABLE payplug_operations');
+ }
+}
diff --git a/ruleset/phpstan-baseline.neon b/ruleset/phpstan-baseline.neon
index abca980f..f8f1121d 100644
--- a/ruleset/phpstan-baseline.neon
+++ b/ruleset/phpstan-baseline.neon
@@ -1312,6 +1312,18 @@ parameters:
count: 1
path: ../src/Repository/PaymentRepository.php
+ -
+ message: '#^Method PayPlug\\SyliusPayPlugPlugin\\Repository\\PayplugOperationRepository\:\:findOneByOperationId\(\) should return PayPlug\\SyliusPayPlugPlugin\\Entity\\PayplugOperation\|null but returns mixed\.$#'
+ identifier: return.type
+ count: 1
+ path: ../src/Repository/PayplugOperationRepository.php
+
+ -
+ message: '#^Method PayPlug\\SyliusPayPlugPlugin\\Repository\\PayplugOperationRepository\:\:findOneByOrderId\(\) should return PayPlug\\SyliusPayPlugPlugin\\Entity\\PayplugOperation\|null but returns mixed\.$#'
+ identifier: return.type
+ count: 1
+ path: ../src/Repository/PayplugOperationRepository.php
+
-
message: '#^Method PayPlug\\SyliusPayPlugPlugin\\Repository\\RefundHistoryRepository\:\:findLastProcessedRefundForPayment\(\) should return PayPlug\\SyliusPayPlugPlugin\\Entity\\RefundHistory\|null but returns mixed\.$#'
identifier: return.type
diff --git a/src/ApiClient/UnifiedApiHostedPaymentServiceFactory.php b/src/ApiClient/UnifiedApiHostedPaymentServiceFactory.php
new file mode 100644
index 00000000..3be45d4e
--- /dev/null
+++ b/src/ApiClient/UnifiedApiHostedPaymentServiceFactory.php
@@ -0,0 +1,70 @@
+getGatewayConfig() ?? throw new \LogicException('Gateway config not found');
+ $clientConfig = $this->getClientConfig($gatewayConfig);
+
+ $clientId = $clientConfig['client_id'] ?? '';
+ $clientSecret = $clientConfig['client_secret'] ?? '';
+ $accountId = $clientConfig['account_id'] ?? '';
+
+ if ('' === $clientId || '' === $clientSecret || '' === $accountId) {
+ throw new GatewayConfigurationException('No client config found for ' . UhfGatewayFactory::FACTORY_NAME . '. Please renew your credentials in the PayPlug plugin configuration.');
+ }
+
+ return new UnifiedApiHostedPaymentService(
+ $this->httpClient,
+ $this->tokenManager,
+ $this->payplugOauthBaseUrl,
+ $clientId,
+ $clientSecret,
+ $accountId,
+ );
+ }
+
+ /** @return array */
+ private function getClientConfig(GatewayConfigInterface $gatewayConfig): array
+ {
+ $config = $gatewayConfig->getConfig();
+ $isLive = true === ($config['live'] ?? false);
+ $rawClientConfig = $isLive ? ($config['live_client'] ?? null) : ($config['test_client'] ?? null);
+ if (!\is_array($rawClientConfig)) {
+ return [];
+ }
+
+ /** @var array $clientConfig */
+ $clientConfig = $rawClientConfig;
+
+ return $clientConfig;
+ }
+}
diff --git a/src/Auth/SyliusConfigurationRepository.php b/src/Auth/SyliusConfigurationRepository.php
new file mode 100644
index 00000000..242728dc
--- /dev/null
+++ b/src/Auth/SyliusConfigurationRepository.php
@@ -0,0 +1,89 @@
+getConfig()[$key] ?? null;
+
+ return \is_string($value) ? $value : null;
+ }
+
+ public function set(string $key, string $value): void
+ {
+ $gatewayConfig = $this->getGatewayConfig();
+ $config = $gatewayConfig->getConfig();
+ $config[$key] = $value;
+ $gatewayConfig->setConfig($config);
+ $this->gatewayConfigRepository->add($gatewayConfig);
+ }
+
+ public function getClientId(): string
+ {
+ return $this->getClientConfig()['client_id'] ?? '';
+ }
+
+ public function getClientSecret(): string
+ {
+ return $this->getClientConfig()['client_secret'] ?? '';
+ }
+
+ public function getPublicKeyId(): string
+ {
+ return $this->getClientConfig()['public_key_id'] ?? '';
+ }
+
+ public function getPublicKeyValue(): string
+ {
+ return $this->getClientConfig()['public_key_value'] ?? '';
+ }
+
+ /** @return array */
+ private function getConfig(): array
+ {
+ return $this->getGatewayConfig()->getConfig();
+ }
+
+ private function getGatewayConfig(): GatewayConfigInterface
+ {
+ /** @var GatewayConfigInterface|null $gatewayConfig */
+ $gatewayConfig = $this->gatewayConfigRepository->findOneBy(['factoryName' => UhfGatewayFactory::FACTORY_NAME]);
+
+ if (null === $gatewayConfig) {
+ throw new \LogicException('No gateway config found for ' . UhfGatewayFactory::FACTORY_NAME);
+ }
+
+ return $gatewayConfig;
+ }
+
+ /** @return array */
+ private function getClientConfig(): array
+ {
+ $config = $this->getConfig();
+ $isLive = true === ($config['live'] ?? false);
+ $rawClientConfig = $isLive ? ($config['live_client'] ?? null) : ($config['test_client'] ?? null);
+
+ if (!\is_array($rawClientConfig)) {
+ return [];
+ }
+
+ /** @var array $clientConfig */
+ $clientConfig = $rawClientConfig;
+
+ return $clientConfig;
+ }
+}
diff --git a/src/Auth/SyliusUnifiedApiHttpClient.php b/src/Auth/SyliusUnifiedApiHttpClient.php
new file mode 100644
index 00000000..2ab1b278
--- /dev/null
+++ b/src/Auth/SyliusUnifiedApiHttpClient.php
@@ -0,0 +1,62 @@
+ $headers
+ *
+ * @return array{status: int, body: string}
+ */
+ public function get(string $url, array $headers = []): array
+ {
+ return $this->request('GET', $url, ['headers' => $headers]);
+ }
+
+ /**
+ * @param array $body
+ * @param array $headers
+ *
+ * @return array{status: int, body: string}
+ */
+ public function postJson(string $url, array $body, array $headers = []): array
+ {
+ return $this->request('POST', $url, ['json' => $body, 'headers' => $headers]);
+ }
+
+ /**
+ * @param array $options
+ *
+ * @return array{status: int, body: string}
+ */
+ private function request(string $method, string $url, array $options): array
+ {
+ try {
+ $response = $this->httpClient->request($method, $url, $options);
+
+ return [
+ 'status' => $response->getStatusCode(),
+ // false = don't throw on non-2xx; the Unified API caller itself checks the status.
+ 'body' => $response->getContent(false),
+ ];
+ } catch (TransportExceptionInterface $e) {
+ // Network-level failure (DNS, timeout, connection reset) — getStatusCode()/getContent()
+ // throw this regardless of the `false` above, since it only suppresses HTTP status
+ // exceptions, not transport ones. Status 0 mirrors SyliusOAuthHttpClient's translation
+ // so callers can treat any UPC HTTP adapter failure uniformly.
+ return ['status' => 0, 'body' => $e->getMessage()];
+ }
+ }
+}
diff --git a/src/Command/Handler/CapturePaymentRequestHandler.php b/src/Command/Handler/CapturePaymentRequestHandler.php
index cda95705..ef6b30d1 100644
--- a/src/Command/Handler/CapturePaymentRequestHandler.php
+++ b/src/Command/Handler/CapturePaymentRequestHandler.php
@@ -7,12 +7,19 @@
use Payplug\Exception\HttpException;
use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface;
use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface;
+use PayPlug\SyliusPayPlugPlugin\ApiClient\UnifiedApiHostedPaymentServiceFactory;
use PayPlug\SyliusPayPlugPlugin\Command\CapturePaymentRequest;
use PayPlug\SyliusPayPlugPlugin\Creator\PayPlugPaymentDataCreator;
+use PayPlug\SyliusPayPlugPlugin\Gateway\UhfGatewayFactory;
+use PayPlug\SyliusPayPlugPlugin\Provider\Payment\HfTokenProvider;
+use PayplugUnifiedCore\Exceptions\ApiException;
use Psr\Log\LoggerInterface;
use Sylius\Abstraction\StateMachine\StateMachineInterface;
use Sylius\Bundle\CoreBundle\OrderPay\Provider\UrlProviderInterface;
use Sylius\Bundle\PaymentBundle\Provider\PaymentRequestProviderInterface;
+use Sylius\Component\Core\Model\PaymentInterface;
+use Sylius\Component\Payment\Model\PaymentMethodInterface;
+use Sylius\Component\Payment\Model\PaymentRequestInterface;
use Sylius\Component\Payment\PaymentRequestTransitions;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
@@ -29,6 +36,9 @@ public function __construct(
#[Autowire(service: 'sylius_shop.provider.order_pay.after_pay_url')]
private UrlProviderInterface $afterPayUrlProvider,
private LoggerInterface $logger,
+ private UnifiedApiHostedPaymentServiceFactory $hostedPaymentServiceFactory,
+ private HfTokenProvider $hfTokenProvider,
+ private UrlGeneratorInterface $urlGenerator,
) {}
public function __invoke(CapturePaymentRequest $capturePaymentRequest): void
@@ -42,6 +52,12 @@ public function __invoke(CapturePaymentRequest $capturePaymentRequest): void
throw new \LogicException('Payment method is not set for the payment.');
}
+ if (UhfGatewayFactory::FACTORY_NAME === $method->getGatewayConfig()?->getFactoryName()) {
+ $this->captureViaUnifiedApi($paymentRequest, $payment, $method);
+
+ return;
+ }
+
if (
PayPlugApiClientInterface::STATUS_CREATED === ($payment->getDetails()['status'] ?? null) &&
($payment->getDetails()['factory_name'] ?? null) === $method->getGatewayConfig()?->getFactoryName()
@@ -107,4 +123,108 @@ public function __invoke(CapturePaymentRequest $capturePaymentRequest): void
PaymentRequestTransitions::TRANSITION_COMPLETE,
);
}
+
+ /**
+ * Creates/confirms the payment through UPC's Unified API from the shopper's hfToken, instead of
+ * the legacy PayPlug SDK path above. Never applies an order/payment state transition beyond the
+ * PaymentRequest's own workflow (COMPLETE/FAIL) — the Unified API's asynchronous webhook is the
+ * single source of truth for the final PaymentOutcome, handled by a later task's
+ * NotifyPaymentRequestHandler branch, whether or not a 3DS redirect happened here.
+ */
+ private function captureViaUnifiedApi(
+ PaymentRequestInterface $paymentRequest,
+ PaymentInterface $payment,
+ PaymentMethodInterface $method,
+ ): void {
+ if (
+ PayPlugApiClientInterface::STATUS_CREATED === ($payment->getDetails()['status'] ?? null) &&
+ UhfGatewayFactory::FACTORY_NAME === ($payment->getDetails()['factory_name'] ?? null)
+ ) {
+ $paymentRequest->setResponseData([
+ 'retry' => true,
+ 'message' => 'Payment already created',
+ 'payment_id' => $payment->getDetails()['payment_id'] ?? 'unknown',
+ 'redirect_url' => $payment->getDetails()['redirect_url'] ?? null,
+ ]);
+
+ $this->stateMachine->apply(
+ $paymentRequest,
+ PaymentRequestTransitions::GRAPH,
+ PaymentRequestTransitions::TRANSITION_COMPLETE,
+ );
+
+ return;
+ }
+
+ $hfToken = $this->hfTokenProvider->getHfToken($payment);
+
+ if (null === $hfToken) {
+ $this->logger->error('[PayPlug] UHF capture failed: no hfToken available for this payment.', [
+ 'payment_id' => $payment->getId(),
+ ]);
+ $paymentRequest->setResponseData(['error' => 'missing_hf_token']);
+ $this->stateMachine->apply(
+ $paymentRequest,
+ PaymentRequestTransitions::GRAPH,
+ PaymentRequestTransitions::TRANSITION_FAIL,
+ );
+
+ return;
+ }
+
+ $order = $payment->getOrder();
+ $notificationUrl = $this->urlGenerator->generate(
+ 'sylius_payment_method_notify',
+ ['code' => $method->getCode()],
+ UrlGeneratorInterface::ABSOLUTE_URL,
+ );
+
+ try {
+ $result = $this->hostedPaymentServiceFactory->createForPaymentMethod($method)->createHostedPayment(
+ $hfToken,
+ $payment->getAmount() ?? 0,
+ $payment->getCurrencyCode() ?? '',
+ (string) $order?->getId(), // @phpstan-ignore-line
+ null,
+ null,
+ null,
+ null,
+ null,
+ $notificationUrl,
+ );
+ } catch (ApiException $exception) {
+ $this->logger->error('[PayPlug] UHF capture failed', ['error' => $exception->getMessage()]);
+ $paymentRequest->setResponseData(['error' => $exception->getMessage()]);
+ $this->stateMachine->apply(
+ $paymentRequest,
+ PaymentRequestTransitions::GRAPH,
+ PaymentRequestTransitions::TRANSITION_FAIL,
+ );
+
+ return;
+ }
+
+ $responseBody = \json_decode($result->body, true);
+ $operationId = \is_array($responseBody) && \is_string($responseBody['id'] ?? null) ? $responseBody['id'] : null;
+
+ $payment->setDetails([
+ ...$payment->getDetails(),
+ 'status' => PayPlugApiClientInterface::STATUS_CREATED,
+ 'factory_name' => UhfGatewayFactory::FACTORY_NAME,
+ 'payment_id' => $operationId,
+ 'redirect_url' => $result->redirectUrl,
+ ]);
+
+ $paymentRequest->setResponseData([
+ 'payment_id' => $operationId,
+ 'redirect_url' => $result->redirectUrl,
+ 'status' => $result->status,
+ ]);
+
+ $this->stateMachine->apply(
+ $paymentRequest,
+ PaymentRequestTransitions::GRAPH,
+ PaymentRequestTransitions::TRANSITION_COMPLETE,
+ );
+ }
}
diff --git a/src/Command/Handler/NotifyPaymentRequestHandler.php b/src/Command/Handler/NotifyPaymentRequestHandler.php
index 9ea5147f..de583e17 100644
--- a/src/Command/Handler/NotifyPaymentRequestHandler.php
+++ b/src/Command/Handler/NotifyPaymentRequestHandler.php
@@ -7,18 +7,29 @@
use Payplug\Resource\Payment;
use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface;
use PayPlug\SyliusPayPlugPlugin\Command\NotifyPaymentRequest;
+use PayPlug\SyliusPayPlugPlugin\Gateway\UhfGatewayFactory;
use PayPlug\SyliusPayPlugPlugin\Handler\PaymentNotificationHandler;
use PayPlug\SyliusPayPlugPlugin\Handler\RefundNotificationHandler;
use PayPlug\SyliusPayPlugPlugin\PaymentProcessing\PaymentTransitionApplier;
+use PayplugUnifiedCore\Contracts\IConfigurationRepository;
+use PayplugUnifiedCore\Contracts\ILock;
+use PayplugUnifiedCore\Contracts\IOrderStateMutator;
+use PayplugUnifiedCore\Contracts\IPaymentRepository;
+use PayplugUnifiedCore\Exceptions\InvalidNotificationException;
+use PayplugUnifiedCore\Utilities\Helpers\WebhookNotificationHelper;
+use Psr\Log\LoggerInterface;
use Sylius\Abstraction\StateMachine\StateMachineInterface;
use Sylius\Bundle\PaymentBundle\Provider\PaymentRequestProviderInterface;
use Sylius\Component\Core\Model\PaymentInterface;
+use Sylius\Component\Payment\Model\PaymentRequestInterface;
use Sylius\Component\Payment\PaymentRequestTransitions;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
class NotifyPaymentRequestHandler
{
+ private const LOCK_TTL_SECONDS = 30;
+
public function __construct(
private PaymentRequestProviderInterface $paymentRequestProvider,
private StateMachineInterface $stateMachine,
@@ -26,6 +37,11 @@ public function __construct(
private PaymentNotificationHandler $paymentNotificationHandler,
private RefundNotificationHandler $refundNotificationHandler,
private PaymentTransitionApplier $paymentTransitionApplier,
+ private ILock $lock,
+ private IPaymentRepository $paymentRepository,
+ private IOrderStateMutator $orderStateMutator,
+ private IConfigurationRepository $configurationRepository,
+ private LoggerInterface $logger,
) {}
public function __invoke(NotifyPaymentRequest $notifyPaymentRequest): void
@@ -34,6 +50,13 @@ public function __invoke(NotifyPaymentRequest $notifyPaymentRequest): void
/** @var PaymentInterface $payment */
$payment = $paymentRequest->getPayment();
+ $method = $payment->getMethod();
+ if (null !== $method && UhfGatewayFactory::FACTORY_NAME === $method->getGatewayConfig()?->getFactoryName()) {
+ $this->notifyViaUnifiedApi($paymentRequest);
+
+ return;
+ }
+
try {
$payload = $paymentRequest->getPayload();
$content = $payload['http_request']['content'] ?? null; // @phpstan-ignore-line
@@ -85,4 +108,77 @@ public function __invoke(NotifyPaymentRequest $notifyPaymentRequest): void
);
}
}
+
+ /**
+ * Verifies, persists, and applies a UPC Unified API webhook notification. ILock guards against
+ * PayPlug retrying the same notification while a first attempt is still in flight; IPaymentRepository's
+ * isTreated()/markTreated() guard against re-applying an outcome that already succeeded (the lock alone
+ * only prevents concurrent processing, not a later, sequential retry after the first attempt released it).
+ */
+ private function notifyViaUnifiedApi(PaymentRequestInterface $paymentRequest): void
+ {
+ $payload = $paymentRequest->getPayload();
+ $rawBody = $payload['http_request']['content'] ?? null; // @phpstan-ignore-line
+ $rawHeaders = $payload['http_request']['headers'] ?? []; // @phpstan-ignore-line
+
+ if (!\is_string($rawBody) || '' === $rawBody) {
+ $this->failNotifyRequest($paymentRequest, 'Invalid UHF notification payload: empty body.');
+
+ return;
+ }
+
+ $headers = [];
+ foreach ((array) $rawHeaders as $name => $values) {
+ $headers[(string) $name] = \is_array($values) ? (string) ($values[0] ?? '') : (string) $values; // @phpstan-ignore-line
+ }
+
+ $expectedAuthorizationHeader = $this->configurationRepository->get('payplug_webhook_authorization_header') ?? '';
+
+ try {
+ $operationData = WebhookNotificationHelper::parse($headers, $rawBody, $expectedAuthorizationHeader);
+ } catch (InvalidNotificationException $exception) {
+ $this->logger->error('[PayPlug] UHF webhook notification rejected.', ['error' => $exception->getMessage()]);
+ $this->failNotifyRequest($paymentRequest, $exception->getMessage());
+
+ return;
+ }
+
+ $lockKey = 'payplug_uhf_operation_' . $operationData->operationId;
+
+ if (!$this->lock->acquire($lockKey, self::LOCK_TTL_SECONDS)) {
+ $this->stateMachine->apply(
+ $paymentRequest,
+ PaymentRequestTransitions::GRAPH,
+ PaymentRequestTransitions::TRANSITION_COMPLETE,
+ );
+
+ return;
+ }
+
+ try {
+ if (!$this->paymentRepository->isTreated($operationData->operationId)) {
+ $this->paymentRepository->save($operationData);
+ $this->orderStateMutator->apply($operationData->orderId, $operationData->outcome);
+ $this->paymentRepository->markTreated($operationData->operationId);
+ }
+ } finally {
+ $this->lock->release($lockKey);
+ }
+
+ $this->stateMachine->apply(
+ $paymentRequest,
+ PaymentRequestTransitions::GRAPH,
+ PaymentRequestTransitions::TRANSITION_COMPLETE,
+ );
+ }
+
+ private function failNotifyRequest(PaymentRequestInterface $paymentRequest, string $message): void
+ {
+ $paymentRequest->setResponseData(['error' => $message]);
+ $this->stateMachine->apply(
+ $paymentRequest,
+ PaymentRequestTransitions::GRAPH,
+ PaymentRequestTransitions::TRANSITION_FAIL,
+ );
+ }
}
diff --git a/src/Command/Handler/StatusPaymentRequestHandler.php b/src/Command/Handler/StatusPaymentRequestHandler.php
index 9cee5d15..b4a26505 100644
--- a/src/Command/Handler/StatusPaymentRequestHandler.php
+++ b/src/Command/Handler/StatusPaymentRequestHandler.php
@@ -7,6 +7,7 @@
use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface;
use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface;
use PayPlug\SyliusPayPlugPlugin\Command\StatusPaymentRequest;
+use PayPlug\SyliusPayPlugPlugin\Gateway\UhfGatewayFactory;
use PayPlug\SyliusPayPlugPlugin\Handler\PaymentNotificationHandler;
use PayPlug\SyliusPayPlugPlugin\PaymentProcessing\PaymentTransitionApplier;
use Psr\Log\LoggerInterface;
@@ -44,6 +45,20 @@ public function __invoke(StatusPaymentRequest $statusPaymentRequest): void
throw new \LogicException('Payment method is not set for the payment.');
}
+ if (UhfGatewayFactory::FACTORY_NAME === $method->getGatewayConfig()?->getFactoryName()) {
+ // The Unified API's webhook (see NotifyPaymentRequestHandler) is the single source of
+ // truth for the final outcome, whether or not a 3DS challenge happened — nothing to
+ // synchronously resolve here. Complete the PaymentRequest so the shopper reaches the
+ // normal "thank you / order in progress" page; the order updates moments later.
+ $this->stateMachine->apply(
+ $paymentRequest,
+ PaymentRequestTransitions::GRAPH,
+ PaymentRequestTransitions::TRANSITION_COMPLETE,
+ );
+
+ return;
+ }
+
// We don't have a forced status, so we retrieve the payment status from PayPlug
$client = $this->apiClientFactory->createForPaymentMethod($method);
/** @var string|null $payplugPaymentId */
diff --git a/src/Command/Provider/CapturePaymentRequestCommandProvider.php b/src/Command/Provider/CapturePaymentRequestCommandProvider.php
index bb507e7e..0aaa9f31 100644
--- a/src/Command/Provider/CapturePaymentRequestCommandProvider.php
+++ b/src/Command/Provider/CapturePaymentRequestCommandProvider.php
@@ -39,6 +39,10 @@
'payplug_sylius_payplug_plugin.command_provider.payplug_wero',
['action' => PaymentRequestInterface::ACTION_CAPTURE],
)]
+#[AutoconfigureTag(
+ 'payplug_sylius_payplug_plugin.command_provider.payplug_uhf',
+ ['action' => PaymentRequestInterface::ACTION_CAPTURE],
+)]
final class CapturePaymentRequestCommandProvider implements PaymentRequestCommandProviderInterface
{
public function supports(PaymentRequestInterface $paymentRequest): bool
diff --git a/src/Command/Provider/NotifyPaymentRequestCommandProvider.php b/src/Command/Provider/NotifyPaymentRequestCommandProvider.php
index 4ef91b6a..bafb7102 100644
--- a/src/Command/Provider/NotifyPaymentRequestCommandProvider.php
+++ b/src/Command/Provider/NotifyPaymentRequestCommandProvider.php
@@ -37,6 +37,10 @@
'payplug_sylius_payplug_plugin.command_provider.payplug_wero',
['action' => PaymentRequestInterface::ACTION_NOTIFY],
)]
+#[AutoconfigureTag(
+ 'payplug_sylius_payplug_plugin.command_provider.payplug_uhf',
+ ['action' => PaymentRequestInterface::ACTION_NOTIFY],
+)]
final class NotifyPaymentRequestCommandProvider implements PaymentRequestCommandProviderInterface
{
public function supports(PaymentRequestInterface $paymentRequest): bool
diff --git a/src/Command/Provider/StatusPaymentRequestCommandProvider.php b/src/Command/Provider/StatusPaymentRequestCommandProvider.php
index 37676e9b..1649bd9e 100644
--- a/src/Command/Provider/StatusPaymentRequestCommandProvider.php
+++ b/src/Command/Provider/StatusPaymentRequestCommandProvider.php
@@ -38,6 +38,10 @@
'payplug_sylius_payplug_plugin.command_provider.payplug_wero',
['action' => PaymentRequestInterface::ACTION_STATUS],
)]
+#[AutoconfigureTag(
+ 'payplug_sylius_payplug_plugin.command_provider.payplug_uhf',
+ ['action' => PaymentRequestInterface::ACTION_STATUS],
+)]
final class StatusPaymentRequestCommandProvider implements PaymentRequestCommandProviderInterface
{
public function __construct(private RequestStack $requestStack)
diff --git a/src/Controller/SetUhfTestHfTokenAction.php b/src/Controller/SetUhfTestHfTokenAction.php
new file mode 100644
index 00000000..dc2909e8
--- /dev/null
+++ b/src/Controller/SetUhfTestHfTokenAction.php
@@ -0,0 +1,61 @@
+query->getString('hfToken');
+
+ if ('' === $hfToken) {
+ throw new BadRequestHttpException('Missing "hfToken" query parameter.');
+ }
+
+ $this->requestStack->getSession()->set(self::TEST_SESSION_KEY, $hfToken);
+
+ return new Response('Test hfToken stored for this session. You can now pay with Unified Hosted Fields.');
+ }
+}
diff --git a/src/Entity/PayplugOperation.php b/src/Entity/PayplugOperation.php
new file mode 100644
index 00000000..db2d506e
--- /dev/null
+++ b/src/Entity/PayplugOperation.php
@@ -0,0 +1,120 @@
+operationId = $operationId;
+ $this->orderId = $orderId;
+ $this->execCode = $execCode;
+ $this->outcome = $outcome;
+ $this->amount = $amount;
+ $this->createdAt = new DateTime();
+ }
+
+ public function getId(): int
+ {
+ return $this->id;
+ }
+
+ public function getOperationId(): string
+ {
+ return $this->operationId;
+ }
+
+ public function getOrderId(): string
+ {
+ return $this->orderId;
+ }
+
+ public function getExecCode(): string
+ {
+ return $this->execCode;
+ }
+
+ public function setExecCode(string $execCode): void
+ {
+ $this->execCode = $execCode;
+ }
+
+ public function getOutcome(): string
+ {
+ return $this->outcome;
+ }
+
+ public function setOutcome(string $outcome): void
+ {
+ $this->outcome = $outcome;
+ }
+
+ public function getAmount(): int
+ {
+ return $this->amount;
+ }
+
+ public function setAmount(int $amount): void
+ {
+ $this->amount = $amount;
+ }
+
+ public function isTreated(): bool
+ {
+ return $this->treated;
+ }
+
+ public function markTreated(): void
+ {
+ $this->treated = true;
+ }
+
+ public function getCreatedAt(): DateTimeInterface
+ {
+ Assert::isInstanceOf($this->createdAt, DateTime::class);
+
+ return DateTimeImmutable::createFromMutable($this->createdAt);
+ }
+}
diff --git a/src/Lock/SyliusLock.php b/src/Lock/SyliusLock.php
new file mode 100644
index 00000000..82c3e65d
--- /dev/null
+++ b/src/Lock/SyliusLock.php
@@ -0,0 +1,43 @@
+ */
+ private array $locks = [];
+
+ public function __construct(
+ private LockFactory $lockFactory,
+ ) {
+ }
+
+ public function acquire(string $key, int $ttlSeconds): bool
+ {
+ $lock = $this->lockFactory->createLock($key, $ttlSeconds);
+
+ if (!$lock->acquire()) {
+ return false;
+ }
+
+ $this->locks[$key] = $lock;
+
+ return true;
+ }
+
+ public function release(string $key): void
+ {
+ if (!isset($this->locks[$key])) {
+ return;
+ }
+
+ $this->locks[$key]->release();
+ unset($this->locks[$key]);
+ }
+}
diff --git a/src/OrderPay/Provider/CaptureHttpResponseProvider.php b/src/OrderPay/Provider/CaptureHttpResponseProvider.php
index c5e1b972..bdb2a242 100644
--- a/src/OrderPay/Provider/CaptureHttpResponseProvider.php
+++ b/src/OrderPay/Provider/CaptureHttpResponseProvider.php
@@ -39,6 +39,10 @@
'payplug_sylius_payplug_plugin.http_response_provider.payplug_wero',
['action' => PaymentRequestInterface::ACTION_CAPTURE],
)]
+#[AutoconfigureTag(
+ 'payplug_sylius_payplug_plugin.http_response_provider.payplug_uhf',
+ ['action' => PaymentRequestInterface::ACTION_CAPTURE],
+)]
class CaptureHttpResponseProvider implements HttpResponseProviderInterface
{
public function supports(RequestConfiguration $requestConfiguration, PaymentRequestInterface $paymentRequest): bool
diff --git a/src/OrderPay/Provider/UhfNotifyPaymentProvider.php b/src/OrderPay/Provider/UhfNotifyPaymentProvider.php
new file mode 100644
index 00000000..54a83d93
--- /dev/null
+++ b/src/OrderPay/Provider/UhfNotifyPaymentProvider.php
@@ -0,0 +1,62 @@
+getGatewayConfig()?->getFactoryName() &&
+ $request->getPayload()->has('id') &&
+ $request->getPayload()->has('execCode') &&
+ $request->getPayload()->has('orderId')
+ ;
+ }
+
+ public function getPayment(Request $request, PaymentMethodInterface $paymentMethod): PaymentInterface
+ {
+ $orderId = $request->getPayload()->getString('orderId');
+ $order = $this->orderRepository->findOneBy(['id' => $orderId]);
+
+ if (!$order instanceof OrderInterface) {
+ throw new \InvalidArgumentException(sprintf('Order with id "%s" not found', $orderId));
+ }
+
+ $operationId = $request->getPayload()->getString('id');
+ $payment = $order->getPayments()->filter(
+ static fn (PaymentInterface $payment): bool => $operationId === ($payment->getDetails()['payment_id'] ?? null),
+ )->first();
+
+ if (false === $payment) {
+ throw new \InvalidArgumentException(sprintf('Payment with operation id "%s" not found in order "%s"', $operationId, $orderId));
+ }
+
+ return $payment;
+ }
+}
diff --git a/src/PaymentProcessing/SyliusOrderStateMutator.php b/src/PaymentProcessing/SyliusOrderStateMutator.php
new file mode 100644
index 00000000..ca7b40ba
--- /dev/null
+++ b/src/PaymentProcessing/SyliusOrderStateMutator.php
@@ -0,0 +1,74 @@
+orderRepository->find((int) $orderId);
+
+ if (!$order instanceof OrderInterface) {
+ throw new \LogicException(sprintf('No Sylius order found for id "%s".', $orderId));
+ }
+
+ $payment = $order->getLastPayment();
+
+ if (null === $payment) {
+ throw new \LogicException(sprintf('Order "%s" has no payment to apply outcome "%s" to.', $orderId, $outcome));
+ }
+
+ $transition = $this->mapOutcomeToTransition($outcome);
+
+ if (null === $transition) {
+ $this->logger->debug('[PayPlug] Outcome does not map to a Sylius payment transition yet.', [
+ 'order_id' => $orderId,
+ 'outcome' => $outcome,
+ ]);
+
+ return;
+ }
+
+ if (!$this->stateMachine->can($payment, PaymentTransitions::GRAPH, $transition)) {
+ $this->logger->warning('[PayPlug] Cannot apply payment transition (already applied or incompatible with current state).', [
+ 'order_id' => $orderId,
+ 'payment_id' => $payment->getId(),
+ 'current_state' => $payment->getState(),
+ 'transition' => $transition,
+ 'outcome' => $outcome,
+ ]);
+
+ return;
+ }
+
+ $this->stateMachine->apply($payment, PaymentTransitions::GRAPH, $transition);
+ }
+
+ private function mapOutcomeToTransition(string $outcome): ?string
+ {
+ return match ($outcome) {
+ PaymentOutcome::PAID => PaymentTransitions::TRANSITION_COMPLETE,
+ PaymentOutcome::AUTHORIZED => PaymentTransitions::TRANSITION_AUTHORIZE,
+ PaymentOutcome::REFUNDED => PaymentTransitions::TRANSITION_REFUND,
+ PaymentOutcome::FAILED => PaymentTransitions::TRANSITION_FAIL,
+ default => null,
+ };
+ }
+}
diff --git a/src/Provider/Payment/HfTokenProvider.php b/src/Provider/Payment/HfTokenProvider.php
new file mode 100644
index 00000000..e40481d3
--- /dev/null
+++ b/src/Provider/Payment/HfTokenProvider.php
@@ -0,0 +1,37 @@
+getDetails();
+ $hfToken = $details['hf_token'] ?? null;
+
+ if (\is_string($hfToken) && '' !== $hfToken) {
+ return $hfToken;
+ }
+
+ $testToken = $this->requestStack->getSession()->get(self::TEST_SESSION_KEY);
+
+ return \is_string($testToken) && '' !== $testToken ? $testToken : null;
+ }
+}
diff --git a/src/Repository/PayplugOperationRepository.php b/src/Repository/PayplugOperationRepository.php
new file mode 100644
index 00000000..6656aef4
--- /dev/null
+++ b/src/Repository/PayplugOperationRepository.php
@@ -0,0 +1,33 @@
+createQueryBuilder('operation')
+ ->where('operation.operationId = :operationId')
+ ->setParameter('operationId', $operationId)
+ ->getQuery()
+ ->getOneOrNullResult()
+ ;
+ }
+
+ public function findOneByOrderId(string $orderId): ?PayplugOperation
+ {
+ return $this->createQueryBuilder('operation')
+ ->where('operation.orderId = :orderId')
+ ->setParameter('orderId', $orderId)
+ ->orderBy('operation.id', 'DESC')
+ ->setMaxResults(1)
+ ->getQuery()
+ ->getOneOrNullResult()
+ ;
+ }
+}
diff --git a/src/Repository/PayplugOperationRepositoryInterface.php b/src/Repository/PayplugOperationRepositoryInterface.php
new file mode 100644
index 00000000..0565d2b1
--- /dev/null
+++ b/src/Repository/PayplugOperationRepositoryInterface.php
@@ -0,0 +1,15 @@
+operationRepository->findOneByOrderId($orderId);
+ if (null === $operation) {
+ throw new PaymentNotFoundException(sprintf('No operation for order "%s".', $orderId));
+ }
+
+ return $this->toOperationData($operation);
+ }
+
+ public function getByOperationId(string $operationId): OperationData
+ {
+ $operation = $this->operationRepository->findOneByOperationId($operationId);
+ if (null === $operation) {
+ throw new PaymentNotFoundException(sprintf('No operation for operation id "%s".', $operationId));
+ }
+
+ return $this->toOperationData($operation);
+ }
+
+ public function save(OperationData $operationData): void
+ {
+ $operation = $this->operationRepository->findOneByOperationId($operationData->operationId);
+
+ if (null === $operation) {
+ $operation = new PayplugOperation(
+ $operationData->operationId,
+ $operationData->orderId,
+ $operationData->execCode,
+ $operationData->outcome,
+ $operationData->amount,
+ );
+ $this->operationRepository->add($operation);
+
+ return;
+ }
+
+ $operation->setExecCode($operationData->execCode);
+ $operation->setOutcome($operationData->outcome);
+ $operation->setAmount($operationData->amount);
+ $this->operationRepository->add($operation);
+ }
+
+ public function markTreated(string $operationId): void
+ {
+ $operation = $this->operationRepository->findOneByOperationId($operationId);
+ if (null === $operation) {
+ throw new PaymentNotFoundException(sprintf('No operation for operation id "%s".', $operationId));
+ }
+ $operation->markTreated();
+ $this->operationRepository->add($operation);
+ }
+
+ public function isTreated(string $operationId): bool
+ {
+ $operation = $this->operationRepository->findOneByOperationId($operationId);
+ if (null === $operation) {
+ throw new PaymentNotFoundException(sprintf('No operation for operation id "%s".', $operationId));
+ }
+
+ return $operation->isTreated();
+ }
+
+ private function toOperationData(PayplugOperation $operation): OperationData
+ {
+ return new OperationData(
+ $operation->getOperationId(),
+ $operation->getExecCode(),
+ $operation->getOutcome(),
+ $operation->getAmount(),
+ $operation->getOrderId(),
+ );
+ }
+}
diff --git a/tests/PHPUnit/ApiClient/UnifiedApiHostedPaymentServiceFactoryTest.php b/tests/PHPUnit/ApiClient/UnifiedApiHostedPaymentServiceFactoryTest.php
new file mode 100644
index 00000000..cec350c8
--- /dev/null
+++ b/tests/PHPUnit/ApiClient/UnifiedApiHostedPaymentServiceFactoryTest.php
@@ -0,0 +1,114 @@
+httpClient = $this->createMock(IUnifiedApiHttpClient::class);
+ $oauthHttpClient = $this->createMock(IOAuthHttpClient::class);
+ $tokenCache = $this->createMock(ITokenCache::class);
+ $oauth2Client = new OAuth2Client($oauthHttpClient, 'https://api-qa.payplug.com', '', '', 'https://www.payplug.com');
+ $tokenManager = new TokenManager($tokenCache, $oauth2Client);
+
+ $this->factory = new UnifiedApiHostedPaymentServiceFactory(
+ $this->httpClient,
+ $tokenManager,
+ 'https://api-qa.payplug.com',
+ );
+ }
+
+ public function testCreateForPaymentMethod_withLiveCredentials_buildsAConfiguredService(): void
+ {
+ $gatewayConfig = $this->createMock(GatewayConfigInterface::class);
+ $gatewayConfig->method('getConfig')->willReturn([
+ 'live' => true,
+ 'live_client' => ['client_id' => 'live_id', 'client_secret' => 'live_secret', 'account_id' => 'acc_live'],
+ 'test_client' => ['client_id' => 'test_id', 'client_secret' => 'test_secret', 'account_id' => 'acc_test'],
+ ]);
+ $method = $this->createMock(PaymentMethodInterface::class);
+ $method->method('getGatewayConfig')->willReturn($gatewayConfig);
+
+ $service = $this->factory->createForPaymentMethod($method);
+
+ self::assertInstanceOf(UnifiedApiHostedPaymentService::class, $service);
+ }
+
+ public function testCreateForPaymentMethod_whenNotLive_usesTestCredentials(): void
+ {
+ $gatewayConfig = $this->createMock(GatewayConfigInterface::class);
+ $gatewayConfig->method('getConfig')->willReturn([
+ 'live' => false,
+ 'test_client' => ['client_id' => 'test_id', 'client_secret' => 'test_secret', 'account_id' => 'acc_test'],
+ ]);
+ $method = $this->createMock(PaymentMethodInterface::class);
+ $method->method('getGatewayConfig')->willReturn($gatewayConfig);
+
+ $service = $this->factory->createForPaymentMethod($method);
+
+ self::assertInstanceOf(UnifiedApiHostedPaymentService::class, $service);
+ }
+
+ public function testCreateForPaymentMethod_whenClientConfigMissing_throwsGatewayConfigurationException(): void
+ {
+ $gatewayConfig = $this->createMock(GatewayConfigInterface::class);
+ $gatewayConfig->method('getConfig')->willReturn(['live' => true]);
+ $method = $this->createMock(PaymentMethodInterface::class);
+ $method->method('getGatewayConfig')->willReturn($gatewayConfig);
+
+ $this->expectException(GatewayConfigurationException::class);
+
+ $this->factory->createForPaymentMethod($method);
+ }
+
+ public function testCreateForPaymentMethod_whenAccountIdMissing_throwsGatewayConfigurationException(): void
+ {
+ $gatewayConfig = $this->createMock(GatewayConfigInterface::class);
+ $gatewayConfig->method('getConfig')->willReturn([
+ 'live' => true,
+ 'live_client' => ['client_id' => 'live_id', 'client_secret' => 'live_secret'],
+ ]);
+ $method = $this->createMock(PaymentMethodInterface::class);
+ $method->method('getGatewayConfig')->willReturn($gatewayConfig);
+
+ $this->expectException(GatewayConfigurationException::class);
+
+ $this->factory->createForPaymentMethod($method);
+ }
+
+ public function testCreateForPaymentMethod_whenNoGatewayConfig_throwsLogicException(): void
+ {
+ $method = $this->createMock(PaymentMethodInterface::class);
+ $method->method('getGatewayConfig')->willReturn(null);
+
+ $this->expectException(\LogicException::class);
+
+ $this->factory->createForPaymentMethod($method);
+ }
+}
diff --git a/tests/PHPUnit/Auth/SyliusConfigurationRepositoryTest.php b/tests/PHPUnit/Auth/SyliusConfigurationRepositoryTest.php
new file mode 100644
index 00000000..e3984de9
--- /dev/null
+++ b/tests/PHPUnit/Auth/SyliusConfigurationRepositoryTest.php
@@ -0,0 +1,102 @@
+gatewayConfigRepository = $this->createMock(RepositoryInterface::class);
+ $this->gatewayConfig = $this->createMock(GatewayConfigInterface::class);
+
+ $this->gatewayConfigRepository->method('findOneBy')
+ ->with(['factoryName' => UhfGatewayFactory::FACTORY_NAME])
+ ->willReturn($this->gatewayConfig)
+ ;
+
+ $this->repository = new SyliusConfigurationRepository($this->gatewayConfigRepository);
+ }
+
+ public function testGetClientId_whenLive_readsFromLiveClientConfig(): void
+ {
+ $this->gatewayConfig->method('getConfig')->willReturn([
+ 'live' => true,
+ 'live_client' => ['client_id' => 'live_id', 'client_secret' => 'live_secret'],
+ 'test_client' => ['client_id' => 'test_id', 'client_secret' => 'test_secret'],
+ ]);
+
+ self::assertSame('live_id', $this->repository->getClientId());
+ self::assertSame('live_secret', $this->repository->getClientSecret());
+ }
+
+ public function testGetClientId_whenNotLive_readsFromTestClientConfig(): void
+ {
+ $this->gatewayConfig->method('getConfig')->willReturn([
+ 'live' => false,
+ 'live_client' => ['client_id' => 'live_id', 'client_secret' => 'live_secret'],
+ 'test_client' => ['client_id' => 'test_id', 'client_secret' => 'test_secret'],
+ ]);
+
+ self::assertSame('test_id', $this->repository->getClientId());
+ }
+
+ public function testGetClientId_whenNoClientConfig_returnsEmptyString(): void
+ {
+ $this->gatewayConfig->method('getConfig')->willReturn(['live' => true]);
+ self::assertSame('', $this->repository->getClientId());
+ }
+
+ public function testGetPublicKeyIdAndValue_readFromTheSameClientConfig(): void
+ {
+ $this->gatewayConfig->method('getConfig')->willReturn([
+ 'live' => true,
+ 'live_client' => ['public_key_id' => 'pk_123', 'public_key_value' => 'pk_value'],
+ ]);
+
+ self::assertSame('pk_123', $this->repository->getPublicKeyId());
+ self::assertSame('pk_value', $this->repository->getPublicKeyValue());
+ }
+
+ public function testGet_readsATopLevelConfigKey(): void
+ {
+ $this->gatewayConfig->method('getConfig')->willReturn([
+ 'payplug_webhook_authorization_header' => 'Bearer shared-secret',
+ ]);
+
+ self::assertSame('Bearer shared-secret', $this->repository->get('payplug_webhook_authorization_header'));
+ }
+
+ public function testGet_whenKeyIsMissing_returnsNull(): void
+ {
+ $this->gatewayConfig->method('getConfig')->willReturn([]);
+ self::assertNull($this->repository->get('payplug_webhook_authorization_header'));
+ }
+
+ public function testSet_writesTheKeyBackToTheGatewayConfigAndPersists(): void
+ {
+ $this->gatewayConfig->method('getConfig')->willReturn(['live' => true]);
+
+ $this->gatewayConfig->expects(self::once())
+ ->method('setConfig')
+ ->with(['live' => true, 'payplug_webhook_authorization_header' => 'Bearer shared-secret'])
+ ;
+ $this->gatewayConfigRepository->expects(self::once())->method('add')->with($this->gatewayConfig);
+
+ $this->repository->set('payplug_webhook_authorization_header', 'Bearer shared-secret');
+ }
+}
diff --git a/tests/PHPUnit/Auth/SyliusUnifiedApiHttpClientTest.php b/tests/PHPUnit/Auth/SyliusUnifiedApiHttpClientTest.php
new file mode 100644
index 00000000..8f7f4258
--- /dev/null
+++ b/tests/PHPUnit/Auth/SyliusUnifiedApiHttpClientTest.php
@@ -0,0 +1,103 @@
+httpClient = $this->createMock(HttpClientInterface::class);
+ $this->adapter = new SyliusUnifiedApiHttpClient($this->httpClient);
+ }
+
+ public function testGet_sendsRequestWithHeadersAndReturnsStatusAndBody(): void
+ {
+ $response = $this->createMock(ResponseInterface::class);
+ $response->method('getStatusCode')->willReturn(200);
+ $response->method('getContent')->with(false)->willReturn('{"id":"pay_123"}');
+
+ $this->httpClient->expects(self::once())
+ ->method('request')
+ ->with(
+ 'GET',
+ 'https://api.payplug.com/payments/pay_123',
+ ['headers' => ['Authorization' => 'Bearer jwt']],
+ )
+ ->willReturn($response)
+ ;
+
+ $result = $this->adapter->get(
+ 'https://api.payplug.com/payments/pay_123',
+ ['Authorization' => 'Bearer jwt'],
+ );
+
+ self::assertSame(['status' => 200, 'body' => '{"id":"pay_123"}'], $result);
+ }
+
+ public function testPostJson_sendsJsonBodyAndHeaders(): void
+ {
+ $response = $this->createMock(ResponseInterface::class);
+ $response->method('getStatusCode')->willReturn(201);
+ $response->method('getContent')->with(false)->willReturn('{"id":"pay_123"}');
+
+ $this->httpClient->expects(self::once())
+ ->method('request')
+ ->with(
+ 'POST',
+ 'https://api.payplug.com/payments',
+ [
+ 'json' => ['amount' => 1000, 'currency' => 'EUR'],
+ 'headers' => ['Authorization' => 'Bearer jwt', 'Content-Type' => 'application/json'],
+ ],
+ )
+ ->willReturn($response)
+ ;
+
+ $result = $this->adapter->postJson(
+ 'https://api.payplug.com/payments',
+ ['amount' => 1000, 'currency' => 'EUR'],
+ ['Authorization' => 'Bearer jwt', 'Content-Type' => 'application/json'],
+ );
+
+ self::assertSame(['status' => 201, 'body' => '{"id":"pay_123"}'], $result);
+ }
+
+ public function testPostJson_onNon2xxStatus_returnsStatusAndBodyWithoutThrowing(): void
+ {
+ $response = $this->createMock(ResponseInterface::class);
+ $response->method('getStatusCode')->willReturn(401);
+ $response->method('getContent')->with(false)->willReturn('{"error":"unauthorized"}');
+
+ $this->httpClient->method('request')->willReturn($response);
+
+ $result = $this->adapter->postJson('https://api.payplug.com/payments', ['amount' => 1000]);
+
+ self::assertSame(['status' => 401, 'body' => '{"error":"unauthorized"}'], $result);
+ }
+
+ public function testGet_onTransportFailure_returnsZeroStatusInsteadOfThrowing(): void
+ {
+ $response = $this->createMock(ResponseInterface::class);
+ $response->method('getStatusCode')->willThrowException(new TransportException('Could not resolve host'));
+
+ $this->httpClient->method('request')->willReturn($response);
+
+ $result = $this->adapter->get('https://api.payplug.com/payments/pay_123');
+
+ self::assertSame(0, $result['status']);
+ self::assertSame('Could not resolve host', $result['body']);
+ }
+}
diff --git a/tests/PHPUnit/Command/Handler/CapturePaymentRequestHandlerTest.php b/tests/PHPUnit/Command/Handler/CapturePaymentRequestHandlerTest.php
new file mode 100644
index 00000000..0052d31e
--- /dev/null
+++ b/tests/PHPUnit/Command/Handler/CapturePaymentRequestHandlerTest.php
@@ -0,0 +1,354 @@
+paymentRequestProvider = $this->createMock(PaymentRequestProviderInterface::class);
+ $this->stateMachine = $this->createMock(StateMachineInterface::class);
+ $this->apiClientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class);
+ $this->paymentDataCreator = $this->createMock(PayPlugPaymentDataCreator::class);
+ $this->afterPayUrlProvider = $this->createMock(UrlProviderInterface::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+
+ $this->unifiedApiHttpClient = $this->createMock(IUnifiedApiHttpClient::class);
+ $tokenCache = $this->createMock(ITokenCache::class);
+ $tokenCache->method('get')->willReturn('cached-token');
+ $oauthHttpClient = $this->createMock(IOAuthHttpClient::class);
+ $oauthHttpClient->expects(self::never())->method('post');
+ $oauth2Client = new OAuth2Client($oauthHttpClient, 'https://api-qa.payplug.com', '', '', 'https://www.payplug.com');
+ $tokenManager = new TokenManager($tokenCache, $oauth2Client);
+ $hostedPaymentServiceFactory = new UnifiedApiHostedPaymentServiceFactory(
+ $this->unifiedApiHttpClient,
+ $tokenManager,
+ 'https://api-qa.payplug.com',
+ );
+
+ $this->requestStack = $this->createMock(RequestStack::class);
+ $this->session = $this->createMock(SessionInterface::class);
+ $this->requestStack->method('getSession')->willReturn($this->session);
+ $hfTokenProvider = new HfTokenProvider($this->requestStack);
+
+ $this->urlGenerator = $this->createMock(UrlGeneratorInterface::class);
+ $this->urlGenerator->method('generate')->willReturn('https://shop.example/notify');
+
+ $this->handler = new CapturePaymentRequestHandler(
+ $this->paymentRequestProvider,
+ $this->stateMachine,
+ $this->apiClientFactory,
+ $this->paymentDataCreator,
+ $this->afterPayUrlProvider,
+ $this->logger,
+ $hostedPaymentServiceFactory,
+ $hfTokenProvider,
+ $this->urlGenerator,
+ );
+ }
+
+ // -------------------------------------------------------------------------
+ // Legacy gateway path (pre-existing behavior, unaffected by the new UHF branch)
+ // -------------------------------------------------------------------------
+
+ public function testInvoke_forLegacyGatewayOnSuccess_storesDetailsAndCompletesTheRequest(): void
+ {
+ $method = $this->buildMethod(PayPlugGatewayFactory::FACTORY_NAME);
+ $payment = $this->buildPayment($method, []);
+ $paymentRequest = $this->buildPaymentRequest($payment);
+
+ $this->afterPayUrlProvider->method('getUrl')->willReturn('https://shop.example/after-pay');
+ $this->paymentDataCreator->method('create')->willReturn(new \ArrayObject(['amount' => 1000]));
+
+ $client = $this->createMock(PayPlugApiClientInterface::class);
+ $payplugPayment = Payment::fromAttributes([
+ 'id' => 'pay_legacy_1',
+ 'hosted_payment' => ['payment_url' => 'https://payplug.example/pay'],
+ ]);
+ $client->method('createPayment')->willReturn($payplugPayment);
+ $this->apiClientFactory->method('createForPaymentMethod')->willReturn($client);
+
+ $payment->expects(self::once())->method('setDetails')->with(self::callback(
+ static fn (array $details) => 'pay_legacy_1' === $details['payment_id'] &&
+ PayPlugApiClientInterface::STATUS_CREATED === $details['status'] &&
+ 'https://payplug.example/pay' === $details['redirect_url'],
+ ));
+
+ $this->stateMachine->expects(self::once())->method('apply')->with(
+ $paymentRequest,
+ PaymentRequestTransitions::GRAPH,
+ PaymentRequestTransitions::TRANSITION_COMPLETE,
+ );
+
+ ($this->handler)(new CapturePaymentRequest('hash-legacy-1'));
+ }
+
+ public function testInvoke_forLegacyGatewayAlreadyCreated_doesNotCallTheClientAgain(): void
+ {
+ $method = $this->buildMethod(PayPlugGatewayFactory::FACTORY_NAME);
+ $payment = $this->buildPayment($method, [
+ 'status' => PayPlugApiClientInterface::STATUS_CREATED,
+ 'factory_name' => PayPlugGatewayFactory::FACTORY_NAME,
+ 'payment_id' => 'pay_legacy_2',
+ 'redirect_url' => 'https://payplug.example/pay-2',
+ ]);
+ $paymentRequest = $this->buildPaymentRequest($payment);
+
+ $this->apiClientFactory->expects(self::never())->method('createForPaymentMethod');
+
+ $this->stateMachine->expects(self::once())->method('apply')->with(
+ $paymentRequest,
+ PaymentRequestTransitions::GRAPH,
+ PaymentRequestTransitions::TRANSITION_COMPLETE,
+ );
+
+ ($this->handler)(new CapturePaymentRequest('hash-legacy-2'));
+ }
+
+ // -------------------------------------------------------------------------
+ // UHF branch — captureViaUnifiedApi()
+ // -------------------------------------------------------------------------
+
+ public function testInvoke_forUhfWithRedirectRequired_storesRedirectUrlAndCompletesTheRequest(): void
+ {
+ $method = $this->buildMethod(UhfGatewayFactory::FACTORY_NAME);
+ $payment = $this->buildPayment($method, ['hf_token' => 'hf_token_1']);
+ $paymentRequest = $this->buildPaymentRequest($payment);
+
+ $this->unifiedApiHttpClient->method('postJson')->willReturn([
+ 'status' => 201,
+ 'body' => \json_encode([
+ 'id' => 'pay_uhf_1',
+ 'redirect' => ['url' => 'https://3ds.example/redirect'],
+ ]),
+ ]);
+
+ $payment->expects(self::once())->method('setDetails')->with(self::callback(
+ static fn (array $details) => 'pay_uhf_1' === $details['payment_id'] &&
+ 'https://3ds.example/redirect' === $details['redirect_url'] &&
+ PayPlugApiClientInterface::STATUS_CREATED === $details['status'] &&
+ UhfGatewayFactory::FACTORY_NAME === $details['factory_name'],
+ ));
+
+ $paymentRequest->expects(self::once())->method('setResponseData')->with(self::callback(
+ static fn (array $data) => 'pay_uhf_1' === $data['payment_id'] &&
+ 'https://3ds.example/redirect' === $data['redirect_url'],
+ ));
+
+ $this->stateMachine->expects(self::once())->method('apply')->with(
+ $paymentRequest,
+ PaymentRequestTransitions::GRAPH,
+ PaymentRequestTransitions::TRANSITION_COMPLETE,
+ );
+
+ ($this->handler)(new CapturePaymentRequest('hash-uhf-1'));
+ }
+
+ public function testInvoke_forUhfWithNoRedirect_completesWithoutRedirectUrl(): void
+ {
+ $method = $this->buildMethod(UhfGatewayFactory::FACTORY_NAME);
+ $payment = $this->buildPayment($method, ['hf_token' => 'hf_token_2']);
+ $paymentRequest = $this->buildPaymentRequest($payment);
+
+ $this->unifiedApiHttpClient->method('postJson')->willReturn([
+ 'status' => 201,
+ 'body' => \json_encode(['id' => 'pay_uhf_2']),
+ ]);
+
+ $paymentRequest->expects(self::once())->method('setResponseData')->with(self::callback(
+ static fn (array $data) => 'pay_uhf_2' === $data['payment_id'] &&
+ null === $data['redirect_url'],
+ ));
+
+ $this->stateMachine->expects(self::once())->method('apply')->with(
+ $paymentRequest,
+ PaymentRequestTransitions::GRAPH,
+ PaymentRequestTransitions::TRANSITION_COMPLETE,
+ );
+
+ ($this->handler)(new CapturePaymentRequest('hash-uhf-2'));
+ }
+
+ public function testInvoke_forUhfWithNoHfToken_failsTheRequestWithoutCallingUnifiedApi(): void
+ {
+ $method = $this->buildMethod(UhfGatewayFactory::FACTORY_NAME);
+ $payment = $this->buildPayment($method, []);
+ $paymentRequest = $this->buildPaymentRequest($payment);
+
+ $this->session->method('get')->willReturn(null);
+
+ $this->unifiedApiHttpClient->expects(self::never())->method('postJson');
+
+ $this->stateMachine->expects(self::once())->method('apply')->with(
+ $paymentRequest,
+ PaymentRequestTransitions::GRAPH,
+ PaymentRequestTransitions::TRANSITION_FAIL,
+ );
+
+ ($this->handler)(new CapturePaymentRequest('hash-uhf-3'));
+ }
+
+ public function testInvoke_whenUnifiedApiThrows_failsTheRequest(): void
+ {
+ $method = $this->buildMethod(UhfGatewayFactory::FACTORY_NAME);
+ $payment = $this->buildPayment($method, ['hf_token' => 'hf_token_4']);
+ $paymentRequest = $this->buildPaymentRequest($payment);
+
+ $this->unifiedApiHttpClient->method('postJson')->willReturn([
+ 'status' => 500,
+ 'body' => '{"error":"internal"}',
+ ]);
+
+ $payment->expects(self::never())->method('setDetails');
+
+ $this->stateMachine->expects(self::once())->method('apply')->with(
+ $paymentRequest,
+ PaymentRequestTransitions::GRAPH,
+ PaymentRequestTransitions::TRANSITION_FAIL,
+ );
+
+ ($this->handler)(new CapturePaymentRequest('hash-uhf-4'));
+ }
+
+ public function testInvoke_whenAlreadyCreatedForUhf_doesNotCallUnifiedApiAgain(): void
+ {
+ $method = $this->buildMethod(UhfGatewayFactory::FACTORY_NAME);
+ $payment = $this->buildPayment($method, [
+ 'status' => PayPlugApiClientInterface::STATUS_CREATED,
+ 'factory_name' => UhfGatewayFactory::FACTORY_NAME,
+ 'payment_id' => 'pay_uhf_5',
+ 'redirect_url' => 'https://3ds.example/redirect-5',
+ ]);
+ $paymentRequest = $this->buildPaymentRequest($payment);
+
+ $this->unifiedApiHttpClient->expects(self::never())->method('postJson');
+
+ $paymentRequest->expects(self::once())->method('setResponseData')->with(self::callback(
+ static fn (array $data) => true === $data['retry'] &&
+ 'pay_uhf_5' === $data['payment_id'] &&
+ 'https://3ds.example/redirect-5' === $data['redirect_url'],
+ ));
+
+ $this->stateMachine->expects(self::once())->method('apply')->with(
+ $paymentRequest,
+ PaymentRequestTransitions::GRAPH,
+ PaymentRequestTransitions::TRANSITION_COMPLETE,
+ );
+
+ ($this->handler)(new CapturePaymentRequest('hash-uhf-5'));
+ }
+
+ // -------------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------------
+
+ private function buildMethod(string $factoryName): PaymentMethodInterface&MockObject
+ {
+ $gatewayConfig = $this->createMock(GatewayConfigInterface::class);
+ $gatewayConfig->method('getFactoryName')->willReturn($factoryName);
+ $gatewayConfig->method('getConfig')->willReturn([
+ 'live' => false,
+ 'test_client' => [
+ 'client_id' => 'test_id',
+ 'client_secret' => 'test_secret',
+ 'account_id' => 'acc_test',
+ ],
+ ]);
+
+ $method = $this->createMock(PaymentMethodInterface::class);
+ $method->method('getGatewayConfig')->willReturn($gatewayConfig);
+ $method->method('getCode')->willReturn('payplug_method_code');
+
+ return $method;
+ }
+
+ /** @param array $details */
+ private function buildPayment(PaymentMethodInterface $method, array $details): PaymentInterface&MockObject
+ {
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getId')->willReturn(42);
+
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getMethod')->willReturn($method);
+ $payment->method('getDetails')->willReturn($details);
+ $payment->method('getAmount')->willReturn(1000);
+ $payment->method('getCurrencyCode')->willReturn('EUR');
+ $payment->method('getOrder')->willReturn($order);
+ $payment->method('getId')->willReturn(7);
+
+ return $payment;
+ }
+
+ private function buildPaymentRequest(PaymentInterface $payment): PaymentRequestInterface&MockObject
+ {
+ $paymentRequest = $this->createMock(PaymentRequestInterface::class);
+ $paymentRequest->method('getPayment')->willReturn($payment);
+
+ $this->paymentRequestProvider->method('provide')->willReturn($paymentRequest);
+
+ return $paymentRequest;
+ }
+}
diff --git a/tests/PHPUnit/Command/Handler/NotifyPaymentRequestHandlerTest.php b/tests/PHPUnit/Command/Handler/NotifyPaymentRequestHandlerTest.php
new file mode 100644
index 00000000..a428ea31
--- /dev/null
+++ b/tests/PHPUnit/Command/Handler/NotifyPaymentRequestHandlerTest.php
@@ -0,0 +1,395 @@
+paymentRequestProvider = $this->createMock(PaymentRequestProviderInterface::class);
+ $this->stateMachine = $this->createMock(StateMachineInterface::class);
+ $this->apiClientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class);
+ $this->paymentNotificationHandler = $this->createMock(PaymentNotificationHandler::class);
+ $this->refundNotificationHandler = $this->createMock(RefundNotificationHandler::class);
+ $this->paymentTransitionApplier = $this->createMock(PaymentTransitionApplier::class);
+ $this->lock = $this->createMock(ILock::class);
+ $this->paymentRepository = $this->createMock(IPaymentRepository::class);
+ $this->orderStateMutator = $this->createMock(IOrderStateMutator::class);
+ $this->configurationRepository = $this->createMock(IConfigurationRepository::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+
+ $this->handler = new NotifyPaymentRequestHandler(
+ $this->paymentRequestProvider,
+ $this->stateMachine,
+ $this->apiClientFactory,
+ $this->paymentNotificationHandler,
+ $this->refundNotificationHandler,
+ $this->paymentTransitionApplier,
+ $this->lock,
+ $this->paymentRepository,
+ $this->orderStateMutator,
+ $this->configurationRepository,
+ $this->logger,
+ );
+ }
+
+ // -------------------------------------------------------------------------
+ // Legacy gateway path (pre-existing behavior, unaffected by the new UHF branch)
+ // -------------------------------------------------------------------------
+
+ public function testInvoke_forLegacyGatewayOnSuccess_treatsNotificationAndCompletesTheRequest(): void
+ {
+ $method = $this->buildMethod(PayPlugGatewayFactory::FACTORY_NAME);
+ $payment = $this->buildPayment($method, [], PaymentInterface::STATE_NEW);
+ $paymentRequest = $this->buildPaymentRequest($payment, ['http_request' => ['content' => '{"id":"pay_1"}']]);
+
+ $payplugPayment = Payment::fromAttributes(['id' => 'pay_1']);
+ $client = $this->createMock(PayPlugApiClientInterface::class);
+ $client->method('treat')->willReturn($payplugPayment);
+ $this->apiClientFactory->method('createForPaymentMethod')->willReturn($client);
+
+ $this->paymentNotificationHandler->expects(self::once())->method('treat');
+ $this->refundNotificationHandler->expects(self::once())->method('treat');
+ $this->paymentTransitionApplier->expects(self::once())->method('apply')->with($payment);
+
+ $this->stateMachine->expects(self::once())->method('apply')->with(
+ $paymentRequest,
+ PaymentRequestTransitions::GRAPH,
+ PaymentRequestTransitions::TRANSITION_COMPLETE,
+ );
+
+ ($this->handler)(new NotifyPaymentRequest('hash-legacy-1'));
+ }
+
+ public function testInvoke_forLegacyGatewayWithInvalidPayload_failsTheRequest(): void
+ {
+ $method = $this->buildMethod(PayPlugGatewayFactory::FACTORY_NAME);
+ $payment = $this->buildPayment($method, [], PaymentInterface::STATE_NEW);
+ $paymentRequest = $this->buildPaymentRequest($payment, ['http_request' => ['content' => '']]);
+
+ $this->apiClientFactory->expects(self::never())->method('createForPaymentMethod');
+
+ $paymentRequest->expects(self::once())->method('setResponseData');
+
+ $this->stateMachine->expects(self::once())->method('apply')->with(
+ $paymentRequest,
+ PaymentRequestTransitions::GRAPH,
+ PaymentRequestTransitions::TRANSITION_FAIL,
+ );
+
+ ($this->handler)(new NotifyPaymentRequest('hash-legacy-2'));
+ }
+
+ // -------------------------------------------------------------------------
+ // UHF branch — notifyViaUnifiedApi()
+ // -------------------------------------------------------------------------
+
+ public function testInvoke_forUhfWithAValidNotification_locksSavesMutatesAndMarksTreated(): void
+ {
+ $method = $this->buildMethod(UhfGatewayFactory::FACTORY_NAME);
+ $payment = $this->buildPayment($method, [], PaymentInterface::STATE_NEW);
+ $paymentRequest = $this->buildPaymentRequest($payment, $this->buildUhfPayload(
+ 'op_1',
+ '0000',
+ 'order_55',
+ 1000,
+ 'expected-secret',
+ ));
+
+ $this->configurationRepository->method('get')
+ ->with('payplug_webhook_authorization_header')
+ ->willReturn('expected-secret');
+
+ $this->lock->expects(self::once())->method('acquire')
+ ->with('payplug_uhf_operation_op_1', 30)
+ ->willReturn(true);
+
+ $this->paymentRepository->expects(self::once())->method('isTreated')->with('op_1')->willReturn(false);
+
+ $callOrder = [];
+
+ $this->paymentRepository->expects(self::once())->method('save')
+ ->with(self::callback(static function (OperationData $data) use (&$callOrder): bool {
+ $callOrder[] = 'save';
+
+ return 'op_1' === $data->operationId &&
+ PaymentOutcome::PAID === $data->outcome &&
+ 'order_55' === $data->orderId &&
+ 1000 === $data->amount;
+ }));
+
+ $this->orderStateMutator->expects(self::once())->method('apply')
+ ->with('order_55', PaymentOutcome::PAID)
+ ->willReturnCallback(static function () use (&$callOrder): void {
+ $callOrder[] = 'apply';
+ });
+
+ $this->paymentRepository->expects(self::once())->method('markTreated')
+ ->with('op_1')
+ ->willReturnCallback(static function () use (&$callOrder): void {
+ $callOrder[] = 'markTreated';
+ });
+
+ $this->lock->expects(self::once())->method('release')->with('payplug_uhf_operation_op_1');
+
+ $this->stateMachine->expects(self::once())->method('apply')->with(
+ $paymentRequest,
+ PaymentRequestTransitions::GRAPH,
+ PaymentRequestTransitions::TRANSITION_COMPLETE,
+ );
+
+ ($this->handler)(new NotifyPaymentRequest('hash-uhf-1'));
+
+ self::assertSame(['save', 'apply', 'markTreated'], $callOrder);
+ }
+
+ public function testInvoke_whenAlreadyTreated_doesNotSaveOrMutateAgain(): void
+ {
+ $method = $this->buildMethod(UhfGatewayFactory::FACTORY_NAME);
+ $payment = $this->buildPayment($method, [], PaymentInterface::STATE_NEW);
+ $paymentRequest = $this->buildPaymentRequest($payment, $this->buildUhfPayload(
+ 'op_2',
+ '0000',
+ 'order_56',
+ 2000,
+ 'expected-secret',
+ ));
+
+ $this->configurationRepository->method('get')->willReturn('expected-secret');
+
+ $this->lock->expects(self::once())->method('acquire')
+ ->with('payplug_uhf_operation_op_2', 30)
+ ->willReturn(true);
+
+ $this->paymentRepository->expects(self::once())->method('isTreated')->with('op_2')->willReturn(true);
+
+ $this->paymentRepository->expects(self::never())->method('save');
+ $this->orderStateMutator->expects(self::never())->method('apply');
+ $this->paymentRepository->expects(self::never())->method('markTreated');
+
+ $this->lock->expects(self::once())->method('release')->with('payplug_uhf_operation_op_2');
+
+ $this->stateMachine->expects(self::once())->method('apply')->with(
+ $paymentRequest,
+ PaymentRequestTransitions::GRAPH,
+ PaymentRequestTransitions::TRANSITION_COMPLETE,
+ );
+
+ ($this->handler)(new NotifyPaymentRequest('hash-uhf-2'));
+ }
+
+ public function testInvoke_whenLockAlreadyHeld_skipsWithoutFailingTheRequest(): void
+ {
+ $method = $this->buildMethod(UhfGatewayFactory::FACTORY_NAME);
+ $payment = $this->buildPayment($method, [], PaymentInterface::STATE_NEW);
+ $paymentRequest = $this->buildPaymentRequest($payment, $this->buildUhfPayload(
+ 'op_3',
+ '0000',
+ 'order_57',
+ 3000,
+ 'expected-secret',
+ ));
+
+ $this->configurationRepository->method('get')->willReturn('expected-secret');
+
+ $this->lock->expects(self::once())->method('acquire')
+ ->with('payplug_uhf_operation_op_3', 30)
+ ->willReturn(false);
+
+ $this->paymentRepository->expects(self::never())->method('isTreated');
+ $this->paymentRepository->expects(self::never())->method('save');
+ $this->orderStateMutator->expects(self::never())->method('apply');
+ $this->paymentRepository->expects(self::never())->method('markTreated');
+ $this->lock->expects(self::never())->method('release');
+
+ $this->stateMachine->expects(self::once())->method('apply')->with(
+ $paymentRequest,
+ PaymentRequestTransitions::GRAPH,
+ PaymentRequestTransitions::TRANSITION_COMPLETE,
+ );
+
+ ($this->handler)(new NotifyPaymentRequest('hash-uhf-3'));
+ }
+
+ public function testInvoke_withWrongAuthorizationHeader_failsTheRequestWithoutLocking(): void
+ {
+ $method = $this->buildMethod(UhfGatewayFactory::FACTORY_NAME);
+ $payment = $this->buildPayment($method, [], PaymentInterface::STATE_NEW);
+ $paymentRequest = $this->buildPaymentRequest($payment, $this->buildUhfPayload(
+ 'op_4',
+ '0000',
+ 'order_58',
+ 4000,
+ 'wrong-secret',
+ ));
+
+ $this->configurationRepository->method('get')
+ ->with('payplug_webhook_authorization_header')
+ ->willReturn('expected-secret');
+
+ $this->lock->expects(self::never())->method('acquire');
+ $this->paymentRepository->expects(self::never())->method('isTreated');
+ $this->paymentRepository->expects(self::never())->method('save');
+ $this->orderStateMutator->expects(self::never())->method('apply');
+
+ $paymentRequest->expects(self::once())->method('setResponseData')->with(self::callback(
+ static fn (array $data) => isset($data['error']),
+ ));
+
+ $this->stateMachine->expects(self::once())->method('apply')->with(
+ $paymentRequest,
+ PaymentRequestTransitions::GRAPH,
+ PaymentRequestTransitions::TRANSITION_FAIL,
+ );
+
+ ($this->handler)(new NotifyPaymentRequest('hash-uhf-4'));
+ }
+
+ public function testInvoke_withMalformedBody_failsTheRequest(): void
+ {
+ $method = $this->buildMethod(UhfGatewayFactory::FACTORY_NAME);
+ $payment = $this->buildPayment($method, [], PaymentInterface::STATE_NEW);
+ $paymentRequest = $this->buildPaymentRequest($payment, [
+ 'http_request' => [
+ 'content' => '',
+ 'headers' => ['Authorization' => ['expected-secret']],
+ ],
+ ]);
+
+ $this->lock->expects(self::never())->method('acquire');
+ $this->paymentRepository->expects(self::never())->method('isTreated');
+
+ $paymentRequest->expects(self::once())->method('setResponseData')->with(self::callback(
+ static fn (array $data) => isset($data['error']),
+ ));
+
+ $this->stateMachine->expects(self::once())->method('apply')->with(
+ $paymentRequest,
+ PaymentRequestTransitions::GRAPH,
+ PaymentRequestTransitions::TRANSITION_FAIL,
+ );
+
+ ($this->handler)(new NotifyPaymentRequest('hash-uhf-5'));
+ }
+
+ // -------------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------------
+
+ private function buildMethod(string $factoryName): PaymentMethodInterface&MockObject
+ {
+ $gatewayConfig = $this->createMock(GatewayConfigInterface::class);
+ $gatewayConfig->method('getFactoryName')->willReturn($factoryName);
+
+ $method = $this->createMock(PaymentMethodInterface::class);
+ $method->method('getGatewayConfig')->willReturn($gatewayConfig);
+ $method->method('getCode')->willReturn('payplug_method_code');
+
+ return $method;
+ }
+
+ /** @param array $details */
+ private function buildPayment(
+ PaymentMethodInterface $method,
+ array $details,
+ string $state,
+ ): PaymentInterface&MockObject
+ {
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getMethod')->willReturn($method);
+ $payment->method('getDetails')->willReturn($details);
+ $payment->method('getState')->willReturn($state);
+ $payment->method('getId')->willReturn(7);
+
+ return $payment;
+ }
+
+ /** @param mixed $payload */
+ private function buildPaymentRequest(PaymentInterface $payment, $payload): PaymentRequestInterface&MockObject
+ {
+ $paymentRequest = $this->createMock(PaymentRequestInterface::class);
+ $paymentRequest->method('getPayment')->willReturn($payment);
+ $paymentRequest->method('getPayload')->willReturn($payload);
+
+ $this->paymentRequestProvider->method('provide')->willReturn($paymentRequest);
+
+ return $paymentRequest;
+ }
+
+ /** @return array */
+ private function buildUhfPayload(
+ string $operationId,
+ string $execCode,
+ string $orderId,
+ int $amount,
+ string $authorizationHeaderValue,
+ ): array {
+ return [
+ 'http_request' => [
+ 'content' => \json_encode([
+ 'id' => $operationId,
+ 'execCode' => $execCode,
+ 'orderId' => $orderId,
+ 'amount' => $amount,
+ ]),
+ 'headers' => [
+ 'Authorization' => [$authorizationHeaderValue],
+ 'Content-Type' => ['application/json'],
+ ],
+ ],
+ ];
+ }
+}
diff --git a/tests/PHPUnit/Command/Handler/StatusPaymentRequestHandlerTest.php b/tests/PHPUnit/Command/Handler/StatusPaymentRequestHandlerTest.php
new file mode 100644
index 00000000..e4dc57e9
--- /dev/null
+++ b/tests/PHPUnit/Command/Handler/StatusPaymentRequestHandlerTest.php
@@ -0,0 +1,73 @@
+paymentRequestProvider = $this->createMock(PaymentRequestProviderInterface::class);
+ $this->stateMachine = $this->createMock(StateMachineInterface::class);
+ $this->apiClientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class);
+
+ $this->handler = new StatusPaymentRequestHandler(
+ $this->paymentRequestProvider,
+ $this->stateMachine,
+ $this->apiClientFactory,
+ $this->createMock(PaymentNotificationHandler::class),
+ $this->createMock(PaymentTransitionApplier::class),
+ $this->createMock(LoggerInterface::class),
+ );
+ }
+
+ public function testInvoke_forUhf_completesTheRequestWithoutCallingTheLegacyApiClient(): void
+ {
+ $gatewayConfig = $this->createMock(GatewayConfigInterface::class);
+ $gatewayConfig->method('getFactoryName')->willReturn(UhfGatewayFactory::FACTORY_NAME);
+ $method = $this->createMock(PaymentMethodInterface::class);
+ $method->method('getGatewayConfig')->willReturn($gatewayConfig);
+
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getMethod')->willReturn($method);
+
+ $paymentRequest = $this->createMock(PaymentRequestInterface::class);
+ $paymentRequest->method('getPayment')->willReturn($payment);
+
+ $this->paymentRequestProvider->method('provide')->willReturn($paymentRequest);
+
+ $this->apiClientFactory->expects(self::never())->method('createForPaymentMethod');
+ $this->stateMachine->expects(self::once())
+ ->method('apply')
+ ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE)
+ ;
+
+ ($this->handler)(new StatusPaymentRequest($paymentRequest->getId()));
+ }
+}
diff --git a/tests/PHPUnit/Controller/SetUhfTestHfTokenActionTest.php b/tests/PHPUnit/Controller/SetUhfTestHfTokenActionTest.php
new file mode 100644
index 00000000..095eaee4
--- /dev/null
+++ b/tests/PHPUnit/Controller/SetUhfTestHfTokenActionTest.php
@@ -0,0 +1,49 @@
+requestStack = $this->createMock(RequestStack::class);
+ $this->session = $this->createMock(SessionInterface::class);
+ $this->requestStack->method('getSession')->willReturn($this->session);
+
+ $this->action = new SetUhfTestHfTokenAction($this->requestStack);
+ }
+
+ public function testInvoke_withHfTokenQueryParameter_storesItInTheSession(): void
+ {
+ $this->session->expects(self::once())->method('set')->with('payplug_uhf_test_hf_token', 'hf_test_123');
+
+ $response = ($this->action)(new Request(['hfToken' => 'hf_test_123']));
+
+ self::assertSame(200, $response->getStatusCode());
+ }
+
+ public function testInvoke_withoutHfTokenQueryParameter_throwsBadRequest(): void
+ {
+ $this->session->expects(self::never())->method('set');
+
+ $this->expectException(BadRequestHttpException::class);
+
+ ($this->action)(new Request());
+ }
+}
diff --git a/tests/PHPUnit/Lock/SyliusLockTest.php b/tests/PHPUnit/Lock/SyliusLockTest.php
new file mode 100644
index 00000000..f8fa8e60
--- /dev/null
+++ b/tests/PHPUnit/Lock/SyliusLockTest.php
@@ -0,0 +1,87 @@
+lockFactory = $this->createMock(LockFactory::class);
+ $this->lock = new SyliusLock($this->lockFactory);
+ }
+
+ // -------------------------------------------------------------------------
+ // acquire() — lock is free vs already held
+ // -------------------------------------------------------------------------
+
+ public function testAcquire_whenLockIsFree_returnsTrue(): void
+ {
+ $symfonyLock = $this->createMock(LockInterface::class);
+ $symfonyLock->expects(self::once())->method('acquire')->willReturn(true);
+
+ $this->lockFactory->expects(self::once())
+ ->method('createLock')
+ ->with('operation_123', 30)
+ ->willReturn($symfonyLock)
+ ;
+
+ self::assertTrue($this->lock->acquire('operation_123', 30));
+ }
+
+ public function testAcquire_whenLockIsAlreadyHeld_returnsFalse(): void
+ {
+ $symfonyLock = $this->createMock(LockInterface::class);
+ $symfonyLock->method('acquire')->willReturn(false);
+
+ $this->lockFactory->method('createLock')->willReturn($symfonyLock);
+
+ self::assertFalse($this->lock->acquire('operation_123', 30));
+ }
+
+ // -------------------------------------------------------------------------
+ // release() — only releases a lock this instance actually acquired
+ // -------------------------------------------------------------------------
+
+ public function testRelease_afterSuccessfulAcquire_releasesTheUnderlyingLock(): void
+ {
+ $symfonyLock = $this->createMock(LockInterface::class);
+ $symfonyLock->method('acquire')->willReturn(true);
+ $symfonyLock->expects(self::once())->method('release');
+
+ $this->lockFactory->method('createLock')->willReturn($symfonyLock);
+
+ $this->lock->acquire('operation_123', 30);
+ $this->lock->release('operation_123');
+ }
+
+ public function testRelease_whenKeyWasNeverAcquired_doesNothing(): void
+ {
+ $this->lockFactory->expects(self::never())->method('createLock');
+
+ $this->lock->release('never_acquired');
+ }
+
+ public function testRelease_whenAcquireFailed_doesNotTryToReleaseIt(): void
+ {
+ $symfonyLock = $this->createMock(LockInterface::class);
+ $symfonyLock->method('acquire')->willReturn(false);
+ $symfonyLock->expects(self::never())->method('release');
+
+ $this->lockFactory->method('createLock')->willReturn($symfonyLock);
+
+ $this->lock->acquire('operation_123', 30);
+ $this->lock->release('operation_123');
+ }
+}
diff --git a/tests/PHPUnit/OrderPay/Provider/UhfNotifyPaymentProviderTest.php b/tests/PHPUnit/OrderPay/Provider/UhfNotifyPaymentProviderTest.php
new file mode 100644
index 00000000..ad21c4f7
--- /dev/null
+++ b/tests/PHPUnit/OrderPay/Provider/UhfNotifyPaymentProviderTest.php
@@ -0,0 +1,102 @@
+orderRepository = $this->createMock(OrderRepositoryInterface::class);
+ $this->provider = new UhfNotifyPaymentProvider($this->orderRepository);
+
+ $gatewayConfig = $this->createMock(GatewayConfigInterface::class);
+ $gatewayConfig->method('getFactoryName')->willReturn(UhfGatewayFactory::FACTORY_NAME);
+ $this->uhfMethod = $this->createMock(PaymentMethodInterface::class);
+ $this->uhfMethod->method('getGatewayConfig')->willReturn($gatewayConfig);
+ }
+
+ private function requestWithPayload(array $payload): Request
+ {
+ return Request::create('/payment-methods/uhf_code', 'POST', [], [], [], [], \json_encode($payload));
+ }
+
+ public function testSupports_withUnifiedApiWebhookShapeAndUhfMethod_returnsTrue(): void
+ {
+ $request = $this->requestWithPayload(['id' => 'pay_1', 'execCode' => '0000', 'orderId' => '42', 'amount' => 1000]);
+ self::assertTrue($this->provider->supports($request, $this->uhfMethod));
+ }
+
+ public function testSupports_withLegacyPayplugWebhookShape_returnsFalse(): void
+ {
+ $request = $this->requestWithPayload(['id' => 'pay_1', 'object' => 'payment', 'metadata' => ['order_number' => '42']]);
+ self::assertFalse($this->provider->supports($request, $this->uhfMethod));
+ }
+
+ public function testSupports_forANonUhfPaymentMethod_returnsFalse(): void
+ {
+ $gatewayConfig = $this->createMock(GatewayConfigInterface::class);
+ $gatewayConfig->method('getFactoryName')->willReturn('payplug_scalapay');
+ $method = $this->createMock(PaymentMethodInterface::class);
+ $method->method('getGatewayConfig')->willReturn($gatewayConfig);
+
+ $request = $this->requestWithPayload(['id' => 'pay_1', 'execCode' => '0000', 'orderId' => '42', 'amount' => 1000]);
+ self::assertFalse($this->provider->supports($request, $method));
+ }
+
+ public function testGetPayment_findsTheOrderAndThePaymentMatchingTheOperationId(): void
+ {
+ $matchingPayment = $this->createMock(PaymentInterface::class);
+ $matchingPayment->method('getDetails')->willReturn(['payment_id' => 'pay_1']);
+ $otherPayment = $this->createMock(PaymentInterface::class);
+ $otherPayment->method('getDetails')->willReturn(['payment_id' => 'pay_0']);
+
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getPayments')->willReturn(new ArrayCollection([$otherPayment, $matchingPayment]));
+
+ $this->orderRepository->method('findOneBy')->with(['id' => '42'])->willReturn($order);
+
+ $request = $this->requestWithPayload(['id' => 'pay_1', 'execCode' => '0000', 'orderId' => '42', 'amount' => 1000]);
+ self::assertSame($matchingPayment, $this->provider->getPayment($request, $this->uhfMethod));
+ }
+
+ public function testGetPayment_whenOrderNotFound_throwsInvalidArgumentException(): void
+ {
+ $this->orderRepository->method('findOneBy')->willReturn(null);
+ $request = $this->requestWithPayload(['id' => 'pay_1', 'execCode' => '0000', 'orderId' => '999', 'amount' => 1000]);
+ $this->expectException(\InvalidArgumentException::class);
+ $this->provider->getPayment($request, $this->uhfMethod);
+ }
+
+ public function testGetPayment_whenNoPaymentMatchesTheOperationId_throwsInvalidArgumentException(): void
+ {
+ $otherPayment = $this->createMock(PaymentInterface::class);
+ $otherPayment->method('getDetails')->willReturn(['payment_id' => 'pay_0']);
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getPayments')->willReturn(new ArrayCollection([$otherPayment]));
+ $this->orderRepository->method('findOneBy')->willReturn($order);
+
+ $request = $this->requestWithPayload(['id' => 'pay_1', 'execCode' => '0000', 'orderId' => '42', 'amount' => 1000]);
+ $this->expectException(\InvalidArgumentException::class);
+ $this->provider->getPayment($request, $this->uhfMethod);
+ }
+}
diff --git a/tests/PHPUnit/PaymentProcessing/SyliusOrderStateMutatorTest.php b/tests/PHPUnit/PaymentProcessing/SyliusOrderStateMutatorTest.php
new file mode 100644
index 00000000..308db06b
--- /dev/null
+++ b/tests/PHPUnit/PaymentProcessing/SyliusOrderStateMutatorTest.php
@@ -0,0 +1,115 @@
+orderRepository = $this->createMock(OrderRepositoryInterface::class);
+ $this->stateMachine = $this->createMock(StateMachineInterface::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+
+ $this->mutator = new SyliusOrderStateMutator($this->orderRepository, $this->stateMachine, $this->logger);
+ }
+
+ public function testApply_withPaidOutcome_appliesCompleteTransitionToTheLastPayment(): void
+ {
+ $payment = $this->createMock(PaymentInterface::class);
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getLastPayment')->willReturn($payment);
+ $this->orderRepository->method('find')->with(42)->willReturn($order);
+
+ $this->stateMachine->method('can')
+ ->with($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_COMPLETE)
+ ->willReturn(true)
+ ;
+ $this->stateMachine->expects(self::once())
+ ->method('apply')
+ ->with($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_COMPLETE)
+ ;
+
+ $this->mutator->apply('42', PaymentOutcome::PAID);
+ }
+
+ public function testApply_withFailedOutcome_appliesFailTransition(): void
+ {
+ $payment = $this->createMock(PaymentInterface::class);
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getLastPayment')->willReturn($payment);
+ $this->orderRepository->method('find')->willReturn($order);
+
+ $this->stateMachine->method('can')->willReturn(true);
+ $this->stateMachine->expects(self::once())
+ ->method('apply')
+ ->with($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_FAIL)
+ ;
+
+ $this->mutator->apply('42', PaymentOutcome::FAILED);
+ }
+
+ public function testApply_withThreeDsPendingOutcome_doesNotTransitionAndLogs(): void
+ {
+ $payment = $this->createMock(PaymentInterface::class);
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getLastPayment')->willReturn($payment);
+ $this->orderRepository->method('find')->willReturn($order);
+
+ $this->stateMachine->expects(self::never())->method('apply');
+ $this->logger->expects(self::once())->method('debug');
+
+ $this->mutator->apply('42', PaymentOutcome::THREE_DS_PENDING);
+ }
+
+ public function testApply_whenTransitionNotAllowed_logsAWarningAndDoesNotThrow(): void
+ {
+ $payment = $this->createMock(PaymentInterface::class);
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getLastPayment')->willReturn($payment);
+ $this->orderRepository->method('find')->willReturn($order);
+
+ $this->stateMachine->method('can')->willReturn(false);
+ $this->stateMachine->expects(self::never())->method('apply');
+ $this->logger->expects(self::once())->method('warning');
+
+ $this->mutator->apply('42', PaymentOutcome::PAID);
+ }
+
+ public function testApply_whenOrderNotFound_throwsLogicException(): void
+ {
+ $this->orderRepository->method('find')->willReturn(null);
+ $this->expectException(\LogicException::class);
+ $this->mutator->apply('999', PaymentOutcome::PAID);
+ }
+
+ public function testApply_whenOrderHasNoPayment_throwsLogicException(): void
+ {
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getLastPayment')->willReturn(null);
+ $this->orderRepository->method('find')->willReturn($order);
+
+ $this->expectException(\LogicException::class);
+ $this->mutator->apply('42', PaymentOutcome::PAID);
+ }
+}
diff --git a/tests/PHPUnit/Provider/Payment/HfTokenProviderTest.php b/tests/PHPUnit/Provider/Payment/HfTokenProviderTest.php
new file mode 100644
index 00000000..4ad03910
--- /dev/null
+++ b/tests/PHPUnit/Provider/Payment/HfTokenProviderTest.php
@@ -0,0 +1,70 @@
+requestStack = $this->createMock(RequestStack::class);
+ $this->session = $this->createMock(SessionInterface::class);
+ $this->requestStack->method('getSession')->willReturn($this->session);
+
+ $this->provider = new HfTokenProvider($this->requestStack);
+ }
+
+ public function testGetHfToken_whenStoredOnThePayment_returnsItWithoutTouchingTheSession(): void
+ {
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getDetails')->willReturn(['hf_token' => 'hf_real_token']);
+
+ $this->session->expects(self::never())->method('get');
+
+ self::assertSame('hf_real_token', $this->provider->getHfToken($payment));
+ }
+
+ public function testGetHfToken_whenNotOnThePayment_fallsBackToTheTestSessionToken(): void
+ {
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getDetails')->willReturn([]);
+
+ $this->session->method('get')->with('payplug_uhf_test_hf_token')->willReturn('hf_test_token');
+
+ self::assertSame('hf_test_token', $this->provider->getHfToken($payment));
+ }
+
+ public function testGetHfToken_whenNeitherIsSet_returnsNull(): void
+ {
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getDetails')->willReturn([]);
+
+ $this->session->method('get')->willReturn(null);
+
+ self::assertNull($this->provider->getHfToken($payment));
+ }
+
+ public function testGetHfToken_whenPaymentTokenIsAnEmptyString_fallsBackToTheTestSessionToken(): void
+ {
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getDetails')->willReturn(['hf_token' => '']);
+
+ $this->session->method('get')->willReturn('hf_test_token');
+
+ self::assertSame('hf_test_token', $this->provider->getHfToken($payment));
+ }
+}
diff --git a/tests/PHPUnit/Repository/SyliusPaymentRepositoryTest.php b/tests/PHPUnit/Repository/SyliusPaymentRepositoryTest.php
new file mode 100644
index 00000000..6b7214c3
--- /dev/null
+++ b/tests/PHPUnit/Repository/SyliusPaymentRepositoryTest.php
@@ -0,0 +1,110 @@
+operationRepository = $this->createMock(PayplugOperationRepositoryInterface::class);
+ $this->repository = new SyliusPaymentRepository($this->operationRepository);
+ }
+
+ public function testGetByOrderId_whenFound_returnsOperationData(): void
+ {
+ $operation = new PayplugOperation('op_1', 'order_42', '0000', PaymentOutcome::PAID, 1000);
+ $this->operationRepository->method('findOneByOrderId')->with('order_42')->willReturn($operation);
+
+ $result = $this->repository->getByOrderId('order_42');
+
+ self::assertSame('op_1', $result->operationId);
+ self::assertSame('order_42', $result->orderId);
+ self::assertSame(PaymentOutcome::PAID, $result->outcome);
+ self::assertSame(1000, $result->amount);
+ }
+
+ public function testGetByOrderId_whenNotFound_throwsPaymentNotFoundException(): void
+ {
+ $this->operationRepository->method('findOneByOrderId')->willReturn(null);
+ $this->expectException(PaymentNotFoundException::class);
+ $this->repository->getByOrderId('unknown_order');
+ }
+
+ public function testGetByOperationId_whenNotFound_throwsPaymentNotFoundException(): void
+ {
+ $this->operationRepository->method('findOneByOperationId')->willReturn(null);
+ $this->expectException(PaymentNotFoundException::class);
+ $this->repository->getByOperationId('unknown_op');
+ }
+
+ public function testSave_whenOperationDoesNotExistYet_addsANewEntity(): void
+ {
+ $this->operationRepository->method('findOneByOperationId')->with('op_1')->willReturn(null);
+
+ $this->operationRepository->expects(self::once())
+ ->method('add')
+ ->with(self::callback(
+ static fn (PayplugOperation $operation): bool => 'op_1' === $operation->getOperationId() &&
+ 'order_42' === $operation->getOrderId() &&
+ PaymentOutcome::PAID === $operation->getOutcome(),
+ ))
+ ;
+
+ $this->repository->save(new OperationData('op_1', '0000', PaymentOutcome::PAID, 1000, 'order_42'));
+ }
+
+ public function testSave_whenOperationAlreadyExists_updatesItInPlace(): void
+ {
+ $existing = new PayplugOperation('op_1', 'order_42', '0001', PaymentOutcome::FAILED, 1000);
+ $this->operationRepository->method('findOneByOperationId')->with('op_1')->willReturn($existing);
+
+ $this->operationRepository->expects(self::once())->method('add')->with($existing);
+
+ $this->repository->save(new OperationData('op_1', '0000', PaymentOutcome::PAID, 1000, 'order_42'));
+
+ self::assertSame(PaymentOutcome::PAID, $existing->getOutcome());
+ self::assertSame('0000', $existing->getExecCode());
+ }
+
+ public function testMarkTreated_setsTheFlagAndPersists(): void
+ {
+ $operation = new PayplugOperation('op_1', 'order_42', '0000', PaymentOutcome::PAID, 1000);
+ $this->operationRepository->method('findOneByOperationId')->with('op_1')->willReturn($operation);
+ $this->operationRepository->expects(self::once())->method('add')->with($operation);
+
+ $this->repository->markTreated('op_1');
+
+ self::assertTrue($operation->isTreated());
+ }
+
+ public function testMarkTreated_whenNotFound_throwsPaymentNotFoundException(): void
+ {
+ $this->operationRepository->method('findOneByOperationId')->willReturn(null);
+ $this->expectException(PaymentNotFoundException::class);
+ $this->repository->markTreated('unknown_op');
+ }
+
+ public function testIsTreated_reflectsTheStoredFlag(): void
+ {
+ $operation = new PayplugOperation('op_1', 'order_42', '0000', PaymentOutcome::PAID, 1000);
+ $operation->markTreated();
+ $this->operationRepository->method('findOneByOperationId')->with('op_1')->willReturn($operation);
+
+ self::assertTrue($this->repository->isTreated('op_1'));
+ }
+}