+ {% if has_saved_cards %}
{{ form_row(form.parent.parent.payplug_card_choice) }}
{% endif %}
- {% if integratedPayment %}
+ {% if is_hosted_fields %}
+ {% include '@PayPlugSyliusPayPlugPlugin/shop/hosted_fields/index.html.twig' with {
+ 'paymentMethod': method,
+ 'hasSavedCards': has_saved_cards,
+ } %}
+ {% elseif is_integrated_payment %}
{% include '@PayPlugSyliusPayPlugPlugin/shop/integrated/index.html.twig' with {
'paymentMethod': method,
'payment': order.getLastPayment('cart'),
- 'hasSavedCards': hasSavedCards,
+ 'hasSavedCards': has_saved_cards,
'paymentInputId': form.vars.id,
} %}
{% endif %}
diff --git a/tests/Behat/Context/Setup/PayPlugContext.php b/tests/Behat/Context/Setup/PayPlugContext.php
index 493e38b6..879059da 100644
--- a/tests/Behat/Context/Setup/PayPlugContext.php
+++ b/tests/Behat/Context/Setup/PayPlugContext.php
@@ -67,6 +67,30 @@ public function theStoreHasAPaymentMethodWithACodeAndPayPlugPaymentGateway(
$this->paymentMethodManager->flush();
}
+ /**
+ * @Given the store has a payment method :paymentMethodName with a code :paymentMethodCode and PayPlug Hosted Fields payment gateway
+ */
+ public function theStoreHasAPaymentMethodWithACodeAndPayPlugHostedFieldsPaymentGateway(
+ string $paymentMethodName,
+ string $paymentMethodCode,
+ ): void {
+ $paymentMethod = $this->createPaymentMethodPayPlug(
+ $paymentMethodName,
+ $paymentMethodCode,
+ PayPlugGatewayFactory::FACTORY_NAME,
+ PayPlugGatewayFactory::FACTORY_TITLE,
+ );
+
+ $paymentMethod->getGatewayConfig()->setConfig([
+ 'secretKey' => 'test',
+ 'payum.http_client' => '@payplug_sylius_payplug_plugin.api_client.payplug',
+ PayPlugGatewayFactory::HOSTED_FIELDS => true,
+ PayPlugGatewayFactory::HF_IDENTIFIER => 'test-company-id',
+ ]);
+
+ $this->paymentMethodManager->flush();
+ }
+
/**
* @Given the store has a payment method :paymentMethodName with a code :paymentMethodCode and Oney payment gateway
*/
diff --git a/tests/Behat/Context/Ui/Shop/CheckoutContext.php b/tests/Behat/Context/Ui/Shop/CheckoutContext.php
index ae35eef1..85b10294 100644
--- a/tests/Behat/Context/Ui/Shop/CheckoutContext.php
+++ b/tests/Behat/Context/Ui/Shop/CheckoutContext.php
@@ -4,7 +4,7 @@
namespace Tests\PayPlug\SyliusPayPlugPlugin\Behat\Context\Ui\Shop;
-use Behat\Behat\Context\Context;
+use Behat\MinkExtension\Context\RawMinkContext;
use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface;
use Sylius\Behat\Page\Shop\Checkout\CompletePageInterface;
use Sylius\Behat\Page\Shop\Order\ShowPageInterface;
@@ -13,7 +13,7 @@
use Tests\PayPlug\SyliusPayPlugPlugin\Behat\Page\Shop\Payum\PaymentPageInterface;
use Webmozart\Assert\Assert;
-final class CheckoutContext implements Context
+final class CheckoutContext extends RawMinkContext
{
/** @var CompletePageInterface */
private $summaryPage;
@@ -157,4 +157,15 @@ public function oneyIsDisabled(): void
{
$this->payPlugApiMocker->disableOney();
}
+
+ /**
+ * @Then I should see the :selector element on the page
+ */
+ public function iShouldSeeTheElementOnThePage(string $selector): void
+ {
+ Assert::notNull(
+ $this->getSession()->getPage()->find('css', $selector),
+ sprintf('Element matching selector "%s" was not found on the page.', $selector),
+ );
+ }
}
diff --git a/tests/Behat/Resources/suites/ui/paying_with_payplug_for_order.yml b/tests/Behat/Resources/suites/ui/paying_with_payplug_for_order.yml
index 659d5003..df97f112 100644
--- a/tests/Behat/Resources/suites/ui/paying_with_payplug_for_order.yml
+++ b/tests/Behat/Resources/suites/ui/paying_with_payplug_for_order.yml
@@ -30,6 +30,9 @@ default:
- sylius.behat.context.setup.user
- payplug_sylius_payplug_plugin.behat.context.setup.payplug
+ # Provides "This secret Key is valid", which installs the static Payplug\Core\HttpClient
+ # mock for the whole test process (needed by templates calling is_payplug_test_mode_enabled).
+ - payplug_sylius_payplug_plugin.behat.context.ui.admin.managing_payment_method_payplug
# - sylius.behat.context.ui.paypal
- sylius.behat.context.ui.shop.cart
diff --git a/tests/PHPUnit/Action/Admin/Auth/UnifiedAuthenticationControllerTest.php b/tests/PHPUnit/Action/Admin/Auth/UnifiedAuthenticationControllerTest.php
new file mode 100644
index 00000000..752af554
--- /dev/null
+++ b/tests/PHPUnit/Action/Admin/Auth/UnifiedAuthenticationControllerTest.php
@@ -0,0 +1,279 @@
+router = $this->createMock(RouterInterface::class);
+ $this->paymentMethodRepository = $this->createMock(RepositoryInterface::class);
+ $this->entityManager = $this->createMock(EntityManagerInterface::class);
+ // final class — cannot be mocked by PHPUnit. Its process() method is never reached by
+ // the scenarios covered here (they all stop before that point), so a real instance
+ // wired with mocked collaborators is built purely to satisfy the constructor type-hint.
+ $this->paymentMethodValidator = new PaymentMethodValidator(
+ $this->createMock(RequestStack::class),
+ $this->createMock(ValidatorInterface::class),
+ $this->entityManager,
+ );
+ $this->logger = $this->createMock(LoggerInterface::class);
+ $this->oauthHttpClient = $this->createMock(IOAuthHttpClient::class);
+
+ $this->controller = new UnifiedAuthenticationController(
+ $this->router,
+ $this->paymentMethodRepository,
+ $this->entityManager,
+ $this->paymentMethodValidator,
+ $this->logger,
+ $this->oauthHttpClient,
+ 'https://api-qa.payplug.com',
+ 'https://www.payplug.com',
+ );
+
+ $this->controller->setContainer(new ServiceLocator([
+ 'router' => fn () => $this->router,
+ ]));
+ }
+
+ private function buildRequest(array $query = []): Request
+ {
+ $request = new Request($query);
+ $request->setSession(new Session(new MockArraySessionStorage()));
+
+ return $request;
+ }
+
+ /**
+ * PHPUnit resolves multiple `method('generate')->with(...)` stubs by registration order, not
+ * by which constraint actually matches a given call — a single callback branching on the
+ * route name is the only way to give different routes different return values reliably.
+ *
+ * @param array
$routeUrls route name => URL to return
+ * @param array $throwForRoutes route names that should throw instead
+ */
+ private function stubRouterGenerate(array $routeUrls, array $throwForRoutes = []): void
+ {
+ $this->router->method('generate')->willReturnCallback(
+ function (string $route) use ($routeUrls, $throwForRoutes): string {
+ if (\in_array($route, $throwForRoutes, true)) {
+ throw new \RuntimeException('router exploded for route ' . $route);
+ }
+
+ return $routeUrls[$route] ?? '/admin/payment-methods';
+ },
+ );
+ }
+
+ // -------------------------------------------------------------------------
+ // setupRedirection() — happy path
+ // -------------------------------------------------------------------------
+
+ public function testSetupRedirection_buildsAuthorizationUrlAndStoresPkceStateInSession(): void
+ {
+ $this->stubRouterGenerate(['payplug_sylius_admin_auth_oauth_callback' => 'https://shop.example.com/payplug/auth/oauth-callback']);
+
+ $request = $this->buildRequest(['client_id' => 'client_abc', 'company_id' => 'company_xyz']);
+
+ $response = $this->controller->setupRedirection($request);
+
+ self::assertInstanceOf(RedirectResponse::class, $response);
+ self::assertStringStartsWith('https://api-qa.payplug.com/oauth2/auth?', $response->getTargetUrl());
+ self::assertStringContainsString('client_id=client_abc', $response->getTargetUrl());
+ self::assertStringContainsString('audience=' . urlencode('https://www.payplug.com'), $response->getTargetUrl());
+
+ $session = $request->getSession();
+ self::assertSame('client_abc', $session->get('payplug_client_id'));
+ self::assertSame('company_xyz', $session->get('payplug_company_id'));
+ self::assertNotNull($session->get('payplug_oauth_state'));
+ self::assertNotNull($session->get('payplug_oauth_code_verifier'));
+ }
+
+ // -------------------------------------------------------------------------
+ // setupRedirection() — failure redirects to payment method index (no id in session yet)
+ // -------------------------------------------------------------------------
+
+ public function testSetupRedirection_onFailure_logsAndRedirectsToPaymentMethodIndex(): void
+ {
+ $this->stubRouterGenerate(
+ ['sylius_admin_payment_method_index' => '/admin/payment-methods'],
+ throwForRoutes: ['payplug_sylius_admin_auth_oauth_callback'],
+ );
+
+ $this->logger->expects(self::once())->method('critical')
+ ->with('Error while perform Payplug OAuth Setup redirection', self::anything())
+ ;
+
+ $request = $this->buildRequest(['client_id' => 'client_abc']);
+
+ $response = $this->controller->setupRedirection($request);
+
+ self::assertInstanceOf(RedirectResponse::class, $response);
+ self::assertSame('payplug_sylius_payplug_plugin.admin.oauth_setup_error', $request->getSession()->getFlashBag()->peek('error')[0] ?? null);
+ }
+
+ // -------------------------------------------------------------------------
+ // oauthCallback() — state mismatch is rejected before any token exchange
+ // -------------------------------------------------------------------------
+
+ public function testOauthCallback_withMismatchedState_rejectsBeforeExchangingToken(): void
+ {
+ $this->stubRouterGenerate([]);
+ $this->oauthHttpClient->expects(self::never())->method('post');
+ $this->logger->expects(self::once())->method('critical')
+ ->with('Error while perform Payplug OAuth callback', self::anything())
+ ;
+
+ $request = $this->buildRequest(['code' => 'auth_code', 'state' => 'attacker-state']);
+ $request->getSession()->set('payplug_oauth_state', 'real-state');
+
+ $response = $this->controller->oauthCallback($request);
+
+ self::assertInstanceOf(RedirectResponse::class, $response);
+ }
+
+ public function testOauthCallback_withNoStateInSession_rejectsBeforeExchangingToken(): void
+ {
+ $this->stubRouterGenerate([]);
+ // Session never went through setupRedirection() (e.g. expired) — no expected state at all.
+ $this->oauthHttpClient->expects(self::never())->method('post');
+
+ $request = $this->buildRequest(['code' => 'auth_code', 'state' => 'some-state']);
+
+ $response = $this->controller->oauthCallback($request);
+
+ self::assertInstanceOf(RedirectResponse::class, $response);
+ }
+
+ public function testOauthCallback_withEmptyState_rejectsBeforeExchangingToken(): void
+ {
+ $this->stubRouterGenerate([]);
+ $this->oauthHttpClient->expects(self::never())->method('post');
+
+ $request = $this->buildRequest(['code' => 'auth_code']); // no "state" query param at all
+ $request->getSession()->set('payplug_oauth_state', '');
+
+ $response = $this->controller->oauthCallback($request);
+
+ self::assertInstanceOf(RedirectResponse::class, $response);
+ }
+
+ // -------------------------------------------------------------------------
+ // oauthCallback() — valid state, but no client id in session
+ // -------------------------------------------------------------------------
+
+ /**
+ * A missing/non-string client_id (e.g. session expired, or setupRedirection() was never hit)
+ * must be rejected before exchangeAuthorizationCode() is called, the same way a state mismatch
+ * already is — otherwise it falls through to a TypeError, logged as a noisy "critical" for
+ * what's really just an expired session.
+ */
+ public function testOauthCallback_withMissingClientId_rejectsBeforeExchangingToken(): void
+ {
+ $this->stubRouterGenerate([]);
+ $this->oauthHttpClient->expects(self::never())->method('post');
+
+ $request = $this->buildRequest(['code' => 'auth_code', 'state' => 'matching-state']);
+ $request->getSession()->set('payplug_oauth_state', 'matching-state');
+ $request->getSession()->set('payplug_oauth_code_verifier', 'verifier_123');
+ // Deliberately no 'payplug_client_id' set.
+
+ $response = $this->controller->oauthCallback($request);
+
+ self::assertInstanceOf(RedirectResponse::class, $response);
+ }
+
+ // -------------------------------------------------------------------------
+ // oauthCallback() — valid state, but no code verifier in session
+ // -------------------------------------------------------------------------
+
+ /**
+ * A missing/non-string code_verifier (e.g. session expired, or setupRedirection() was never
+ * hit) must be rejected before exchangeAuthorizationCode() is called, the same way a state
+ * mismatch already is — otherwise it falls through to a TypeError, logged as a noisy
+ * "critical" for what's really just an expired session.
+ */
+ public function testOauthCallback_withMissingCodeVerifier_rejectsBeforeExchangingToken(): void
+ {
+ $this->stubRouterGenerate([]);
+ $this->oauthHttpClient->expects(self::never())->method('post');
+
+ $request = $this->buildRequest(['code' => 'auth_code', 'state' => 'matching-state']);
+ $request->getSession()->set('payplug_oauth_state', 'matching-state');
+ // Deliberately no 'payplug_oauth_code_verifier' set.
+
+ $response = $this->controller->oauthCallback($request);
+
+ self::assertInstanceOf(RedirectResponse::class, $response);
+ }
+
+ // -------------------------------------------------------------------------
+ // oauthCallback() — valid state, but no payment method id in session
+ // -------------------------------------------------------------------------
+
+ public function testOauthCallback_withValidStateButNoPaymentMethodIdInSession_stopsAfterTokenExchange(): void
+ {
+ $this->stubRouterGenerate(['payplug_sylius_admin_auth_oauth_callback' => 'https://shop.example.com/payplug/auth/oauth-callback']);
+
+ $this->oauthHttpClient->expects(self::once())->method('post')->willReturn([
+ 'status' => 200,
+ 'body' => json_encode(['access_token' => 'jwt', 'expires_in' => 3600, 'token_type' => 'Bearer']),
+ ]);
+
+ // Never reached: the "no payment method id" guard throws first.
+ $this->paymentMethodRepository->expects(self::never())->method('find');
+
+ $request = $this->buildRequest(['code' => 'auth_code', 'state' => 'matching-state']);
+ $request->getSession()->set('payplug_client_id', 'client_abc');
+ $request->getSession()->set('payplug_oauth_state', 'matching-state');
+ $request->getSession()->set('payplug_oauth_code_verifier', 'verifier_123');
+ // Deliberately no 'payplug_sylius_oauth_payment_method_id' set.
+
+ $response = $this->controller->oauthCallback($request);
+
+ self::assertInstanceOf(RedirectResponse::class, $response);
+ self::assertSame('payplug_sylius_payplug_plugin.admin.oauth_setup_error', $request->getSession()->getFlashBag()->peek('error')[0] ?? null);
+ }
+}
diff --git a/tests/PHPUnit/ApiClient/PayPlugApiClientFactoryTest.php b/tests/PHPUnit/ApiClient/PayPlugApiClientFactoryTest.php
new file mode 100644
index 00000000..a072416c
--- /dev/null
+++ b/tests/PHPUnit/ApiClient/PayPlugApiClientFactoryTest.php
@@ -0,0 +1,175 @@
+gatewayConfigRepository = $this->createMock(RepositoryInterface::class);
+ $this->cache = $this->createMock(CacheInterface::class);
+ $this->oauthHttpClient = $this->createMock(IOAuthHttpClient::class);
+ $this->tokenCache = $this->createMock(ITokenCache::class);
+
+ $oauth2Client = new OAuth2Client($this->oauthHttpClient, 'https://api-qa.payplug.com', '', '', 'https://www.payplug.com');
+ $tokenManager = new TokenManager($this->tokenCache, $oauth2Client);
+
+ $this->factory = new PayPlugApiClientFactory($this->gatewayConfigRepository, $this->cache, $tokenManager);
+ }
+
+ // -------------------------------------------------------------------------
+ // create() / createForPaymentMethod() — happy path, token freshly fetched
+ // -------------------------------------------------------------------------
+
+ public function testCreateForPaymentMethod_withValidClientCredentials_returnsApiClient(): void
+ {
+ $paymentMethod = $this->createMock(PaymentMethodInterface::class);
+ $paymentMethod->method('getGatewayConfig')->willReturn($this->buildGatewayConfig(isLive: false));
+
+ $this->tokenCache->method('get')->willReturn(null); // cache miss
+ $this->oauthHttpClient->method('post')->willReturn([
+ 'status' => 200,
+ 'body' => json_encode(['access_token' => 'fresh-jwt', 'expires_in' => 300, 'token_type' => 'Bearer']),
+ ]);
+
+ $client = $this->factory->createForPaymentMethod($paymentMethod);
+
+ self::assertInstanceOf(PayPlugApiClientInterface::class, $client);
+ }
+
+ public function testCreate_withNoGatewayConfigFound_throwsLogicException(): void
+ {
+ $this->gatewayConfigRepository->method('findOneBy')->willReturn(null);
+
+ $this->expectException(\LogicException::class);
+
+ $this->factory->create('payplug');
+ }
+
+ // -------------------------------------------------------------------------
+ // getTokenForGatewayConfig() — missing client config
+ // -------------------------------------------------------------------------
+
+ public function testCreateForPaymentMethod_withNoClientConfigForCurrentMode_throwsGatewayConfigurationException(): void
+ {
+ $paymentMethod = $this->createMock(PaymentMethodInterface::class);
+ $gatewayConfig = $this->createMock(GatewayConfigInterface::class);
+ $gatewayConfig->method('getConfig')->willReturn(['live' => true]); // no 'live_client' key
+ $gatewayConfig->method('getFactoryName')->willReturn('payplug');
+ $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig);
+
+ $this->expectException(GatewayConfigurationException::class);
+ $this->expectExceptionMessage('No client config found for payplug');
+
+ $this->factory->createForPaymentMethod($paymentMethod);
+ }
+
+ // -------------------------------------------------------------------------
+ // getTokenForGatewayConfig() — client config present but missing client_id/client_secret
+ // -------------------------------------------------------------------------
+
+ /**
+ * A present-but-incomplete client config (e.g. `client_secret` missing) must be rejected
+ * before any HTTP call is made — otherwise it reaches the token endpoint with an empty
+ * credential and a genuine misconfiguration gets reported as a connectivity failure instead.
+ */
+ public function testCreateForPaymentMethod_withEmptyClientSecret_throwsGatewayConfigurationExceptionWithoutCallingTokenEndpoint(): void
+ {
+ $paymentMethod = $this->createMock(PaymentMethodInterface::class);
+ $gatewayConfig = $this->createMock(GatewayConfigInterface::class);
+ $gatewayConfig->method('getConfig')->willReturn([
+ 'live' => false,
+ 'test_client' => ['client_id' => 'client_test'], // no 'client_secret' key
+ ]);
+ $gatewayConfig->method('getFactoryName')->willReturn('payplug');
+ $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig);
+
+ $this->oauthHttpClient->expects(self::never())->method('post');
+
+ $this->expectException(GatewayConfigurationException::class);
+ $this->expectExceptionMessage('No client config found for payplug');
+
+ $this->factory->createForPaymentMethod($paymentMethod);
+ }
+
+ // -------------------------------------------------------------------------
+ // getTokenForGatewayConfig() — token endpoint failure wrapped as GatewayConfigurationException
+ // -------------------------------------------------------------------------
+
+ /**
+ * TokenManager -> OAuth2Client throws ApiException on a non-2xx response; the factory must
+ * catch it and rethrow as GatewayConfigurationException (never leak the vendor exception type).
+ */
+ public function testCreateForPaymentMethod_whenTokenEndpointRejectsCredentials_wrapsFailureAsGatewayConfigurationException(): void
+ {
+ $paymentMethod = $this->createMock(PaymentMethodInterface::class);
+ $paymentMethod->method('getGatewayConfig')->willReturn($this->buildGatewayConfig(isLive: true));
+
+ $this->tokenCache->method('get')->willReturn(null);
+ $this->oauthHttpClient->method('post')->willReturn(['status' => 401, 'body' => '{"error":"invalid_client"}']);
+
+ $this->expectException(GatewayConfigurationException::class);
+ $this->expectExceptionMessage('Unable to connect to PayPlug API. Please check your credentials in the PayPlug plugin configuration.');
+
+ $this->factory->createForPaymentMethod($paymentMethod);
+ }
+
+ // -------------------------------------------------------------------------
+ // getTokenForGatewayConfig() — a cached token is reused, no HTTP call made
+ // -------------------------------------------------------------------------
+
+ public function testCreateForPaymentMethod_withCachedToken_doesNotCallTheTokenEndpoint(): void
+ {
+ $paymentMethod = $this->createMock(PaymentMethodInterface::class);
+ $paymentMethod->method('getGatewayConfig')->willReturn($this->buildGatewayConfig(isLive: false));
+
+ $this->tokenCache->method('get')->willReturn('cached-jwt');
+ $this->oauthHttpClient->expects(self::never())->method('post');
+
+ $this->factory->createForPaymentMethod($paymentMethod);
+ }
+
+ private function buildGatewayConfig(bool $isLive): GatewayConfigInterface&MockObject
+ {
+ $gatewayConfig = $this->createMock(GatewayConfigInterface::class);
+ $gatewayConfig->method('getConfig')->willReturn([
+ 'live' => $isLive,
+ 'live_client' => ['client_id' => 'client_live', 'client_secret' => 'secret_live'],
+ 'test_client' => ['client_id' => 'client_test', 'client_secret' => 'secret_test'],
+ ]);
+ $gatewayConfig->method('getFactoryName')->willReturn('payplug');
+
+ return $gatewayConfig;
+ }
+}
diff --git a/tests/PHPUnit/Auth/SyliusOAuthHttpClientTest.php b/tests/PHPUnit/Auth/SyliusOAuthHttpClientTest.php
new file mode 100644
index 00000000..eede4155
--- /dev/null
+++ b/tests/PHPUnit/Auth/SyliusOAuthHttpClientTest.php
@@ -0,0 +1,128 @@
+httpClient = $this->createMock(HttpClientInterface::class);
+ $this->adapter = new SyliusOAuthHttpClient($this->httpClient);
+ }
+
+ // -------------------------------------------------------------------------
+ // post() — delegates to HttpClientInterface with form-encoded body
+ // -------------------------------------------------------------------------
+
+ /**
+ * Verifies the form params are sent as a URL-encoded body (not a raw array), and the given
+ * headers are passed through unchanged.
+ */
+ public function testPost_sendsFormEncodedBodyAndHeaders(): void
+ {
+ $response = $this->createMock(ResponseInterface::class);
+ $response->method('getStatusCode')->willReturn(200);
+ $response->method('getContent')->with(false)->willReturn('{"access_token":"jwt"}');
+
+ $this->httpClient->expects(self::once())
+ ->method('request')
+ ->with(
+ 'POST',
+ 'https://api-qa.payplug.com/oauth2/token',
+ [
+ 'body' => 'grant_type=authorization_code&client_id=client_abc',
+ 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
+ ],
+ )
+ ->willReturn($response)
+ ;
+
+ $result = $this->adapter->post(
+ 'https://api-qa.payplug.com/oauth2/token',
+ ['grant_type' => 'authorization_code', 'client_id' => 'client_abc'],
+ ['Content-Type' => 'application/x-www-form-urlencoded'],
+ );
+
+ self::assertSame(['status' => 200, 'body' => '{"access_token":"jwt"}'], $result);
+ }
+
+ // -------------------------------------------------------------------------
+ // post() — non-2xx status does not throw (caller decides how to react)
+ // -------------------------------------------------------------------------
+
+ /**
+ * getContent(false) is used specifically so a 4xx/5xx response body is still returned
+ * instead of throwing — OAuth2Client itself is responsible for checking the status.
+ */
+ public function testPost_onNon2xxStatus_returnsStatusAndBodyWithoutThrowing(): void
+ {
+ $response = $this->createMock(ResponseInterface::class);
+ $response->method('getStatusCode')->willReturn(401);
+ $response->method('getContent')->with(false)->willReturn('{"error":"invalid_client"}');
+
+ $this->httpClient->method('request')->willReturn($response);
+
+ $result = $this->adapter->post('https://api-qa.payplug.com/oauth2/token', ['grant_type' => 'client_credentials']);
+
+ self::assertSame(['status' => 401, 'body' => '{"error":"invalid_client"}'], $result);
+ }
+
+ // -------------------------------------------------------------------------
+ // post() — default empty headers array is accepted
+ // -------------------------------------------------------------------------
+
+ public function testPost_withNoHeadersArgument_defaultsToEmptyHeaders(): void
+ {
+ $response = $this->createMock(ResponseInterface::class);
+ $response->method('getStatusCode')->willReturn(200);
+ $response->method('getContent')->willReturn('{}');
+
+ $this->httpClient->expects(self::once())
+ ->method('request')
+ ->with(self::anything(), self::anything(), self::callback(
+ static fn (array $options): bool => [] === $options['headers'],
+ ))
+ ->willReturn($response)
+ ;
+
+ $this->adapter->post('https://api-qa.payplug.com/oauth2/token', ['grant_type' => 'client_credentials']);
+ }
+
+ // -------------------------------------------------------------------------
+ // post() — transport-level failure (network error) does not throw
+ // -------------------------------------------------------------------------
+
+ /**
+ * getStatusCode()/getContent() throw TransportExceptionInterface on a genuine network error
+ * (DNS, timeout, connection reset) regardless of the `false` passed to getContent() — that
+ * flag only suppresses HTTP status exceptions, not transport ones. This must be caught here
+ * and turned into a status the caller can react to (0, i.e. never a valid HTTP status),
+ * instead of leaking an uncaught exception into OAuth2Client/TokenManager, which only know
+ * how to translate a malformed HTTP response into ApiException, not a transport failure.
+ */
+ public function testPost_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->post('https://api-qa.payplug.com/oauth2/token', ['grant_type' => 'client_credentials']);
+
+ self::assertSame(0, $result['status']);
+ self::assertSame('Could not resolve host', $result['body']);
+ }
+}
diff --git a/tests/PHPUnit/Auth/SyliusTokenCacheTest.php b/tests/PHPUnit/Auth/SyliusTokenCacheTest.php
new file mode 100644
index 00000000..71331456
--- /dev/null
+++ b/tests/PHPUnit/Auth/SyliusTokenCacheTest.php
@@ -0,0 +1,115 @@
+pool = $this->createMock(CacheItemPoolInterface::class);
+ $this->cache = new SyliusTokenCache($this->pool);
+ }
+
+ // -------------------------------------------------------------------------
+ // get() — cache hit / miss
+ // -------------------------------------------------------------------------
+
+ public function testGet_onCacheHit_returnsTheStoredValue(): void
+ {
+ $item = $this->createMock(CacheItemInterface::class);
+ $item->method('isHit')->willReturn(true);
+ $item->method('get')->willReturn('cached-jwt');
+
+ $this->pool->method('getItem')->with('upc_oauth_token_client_abc')->willReturn($item);
+
+ self::assertSame('cached-jwt', $this->cache->get('upc_oauth_token:client_abc'));
+ }
+
+ public function testGet_onCacheMiss_returnsNull(): void
+ {
+ $item = $this->createMock(CacheItemInterface::class);
+ $item->method('isHit')->willReturn(false);
+
+ $this->pool->method('getItem')->willReturn($item);
+
+ self::assertNull($this->cache->get('upc_oauth_token:client_abc'));
+ }
+
+ // -------------------------------------------------------------------------
+ // set() — stores the value with the given TTL
+ // -------------------------------------------------------------------------
+
+ public function testSet_storesValueAndTtlThenSavesTheItem(): void
+ {
+ $item = $this->createMock(CacheItemInterface::class);
+ $item->expects(self::once())->method('set')->with('fresh-jwt');
+ $item->expects(self::once())->method('expiresAfter')->with(240);
+
+ $this->pool->method('getItem')->with('upc_oauth_token_client_abc')->willReturn($item);
+ $this->pool->expects(self::once())->method('save')->with($item);
+
+ $this->cache->set('upc_oauth_token:client_abc', 'fresh-jwt', 240);
+ }
+
+ // -------------------------------------------------------------------------
+ // delete()
+ // -------------------------------------------------------------------------
+
+ public function testDelete_removesTheSanitizedKeyFromThePool(): void
+ {
+ $this->pool->expects(self::once())->method('deleteItem')->with('upc_oauth_token_client_abc');
+
+ $this->cache->delete('upc_oauth_token:client_abc');
+ }
+
+ // -------------------------------------------------------------------------
+ // Key sanitization — PSR-6 reserved characters must never reach the pool
+ // -------------------------------------------------------------------------
+
+ /**
+ * Symfony's cache component rejects keys containing any of "{}()/\@:" with an
+ * InvalidArgumentException. TokenManager's own key format ("upc_oauth_token:{clientId}")
+ * contains a colon, so this is a real, not hypothetical, input.
+ *
+ * @dataProvider reservedCharacterKeys
+ */
+ public function testSanitizeKey_replacesEveryPsr6ReservedCharacter(
+ string $rawKey,
+ string $expectedSanitizedKey,
+ ): void
+ {
+ $item = $this->createMock(CacheItemInterface::class);
+ $item->method('isHit')->willReturn(false);
+
+ $this->pool->expects(self::once())->method('getItem')->with($expectedSanitizedKey)->willReturn($item);
+
+ $this->cache->get($rawKey);
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function reservedCharacterKeys(): iterable
+ {
+ yield 'colon (TokenManager\'s real format)' => ['upc_oauth_token:client_abc', 'upc_oauth_token_client_abc'];
+ yield 'curly braces' => ['a{b}c', 'a_b_c'];
+ yield 'parentheses' => ['a(b)c', 'a_b_c'];
+ yield 'slash' => ['a/b', 'a_b'];
+ yield 'backslash' => ['a\\b', 'a_b'];
+ yield 'at sign' => ['a@b', 'a_b'];
+ yield 'all reserved characters combined' => ['{}()/\\@:', '________'];
+ yield 'no reserved characters' => ['plain_key_123', 'plain_key_123'];
+ }
+}
diff --git a/tests/PHPUnit/Command/Handler/CaptureAliasPaymentRequestHandlerTest.php b/tests/PHPUnit/Command/Handler/CaptureAliasPaymentRequestHandlerTest.php
new file mode 100644
index 00000000..31281589
--- /dev/null
+++ b/tests/PHPUnit/Command/Handler/CaptureAliasPaymentRequestHandlerTest.php
@@ -0,0 +1,371 @@
+paymentRequestProvider = $this->createMock(PaymentRequestProviderInterface::class);
+ $this->stateMachine = $this->createMock(StateMachineInterface::class);
+ $this->unifiedApiPaymentCreator = $this->createMock(UnifiedApiPaymentCreatorInterface::class);
+ $this->session = $this->createMock(SessionInterface::class);
+ $sessionData = [];
+ $this->session->method('set')->willReturnCallback(static function (string $key, mixed $value) use (&$sessionData): void {
+ $sessionData[$key] = $value;
+ });
+ $this->session->method('get')->willReturnCallback(static function (string $key, mixed $default = null) use (&$sessionData): mixed {
+ return $sessionData[$key] ?? $default;
+ });
+
+ $request = new \Symfony\Component\HttpFoundation\Request();
+ $request->setSession($this->session);
+
+ $this->requestStack = new RequestStack();
+ $this->requestStack->push($request);
+ $this->payplugCardRepository = $this->createMock(RepositoryInterface::class);
+ $this->orderStateMutator = $this->createMock(IOrderStateMutator::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+ $this->urlGenerator = $this->createMock(UrlGeneratorInterface::class);
+ $this->afterPayUrlProvider = $this->createMock(UrlProviderInterface::class);
+ $this->afterPayUrlProvider->method('getUrl')->willReturn('https://shop.test/order/00000042/pay');
+
+ $this->handler = new CaptureAliasPaymentRequestHandler(
+ $this->paymentRequestProvider,
+ $this->stateMachine,
+ $this->unifiedApiPaymentCreator,
+ new SelectedCardResolver($this->requestStack, $this->payplugCardRepository),
+ new PaymentCaptureContextBuilder($this->urlGenerator, $this->afterPayUrlProvider, new OrderAddressDtoCreator(), $this->requestStack),
+ new PaymentCaptureOutcomeApplier($this->logger, $this->stateMachine, $this->orderStateMutator, $this->requestStack),
+ );
+ }
+
+ /**
+ * @param CustomerInterface&MockObject|null $cardCustomer customer the selected Card belongs
+ * to; defaults to the paying order's
+ * own customer (the happy path) —
+ * pass a different mock to exercise
+ * the ownership-mismatch guard
+ * @param PaymentMethodInterface&MockObject|null $cardPaymentMethod payment method the selected
+ * Card was saved under; defaults to
+ * the payment's own method (the happy
+ * path) — pass a different mock to
+ * exercise the account-mismatch guard
+ */
+ private function paymentRequestWithSelectedCard(
+ ?Card $card,
+ ?array $gatewayConfig = ['hfIdentifier' => 'acct_123'],
+ ?CustomerInterface $cardCustomer = null,
+ ?AddressInterface $billingAddress = null,
+ ?PaymentMethodInterface $cardPaymentMethod = null,
+ ?string $customerEmail = 'customer@example.com',
+ ): PaymentRequestInterface&MockObject
+ {
+ $this->requestStack->getSession()->set('payplug_payment_method', self::SELECTED_CARD_ID);
+
+ $method = $this->createMock(PaymentMethodInterface::class);
+ if (null !== $gatewayConfig) {
+ $config = $this->createMock(GatewayConfigInterface::class);
+ $config->method('getConfig')->willReturn($gatewayConfig);
+ $method->method('getGatewayConfig')->willReturn($config);
+ }
+
+ $customer = $this->createMock(CustomerInterface::class);
+ $customer->method('getId')->willReturn(7);
+ $customer->method('getEmail')->willReturn($customerEmail);
+
+ $card?->setCustomer($cardCustomer ?? $customer);
+ $card?->setPaymentMethod($cardPaymentMethod ?? $method);
+ $this->payplugCardRepository->method('find')->with(self::SELECTED_CARD_ID)->willReturn($card);
+
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getCustomer')->willReturn($customer);
+ $order->method('getNumber')->willReturn('00000042');
+ $order->method('getBillingAddress')->willReturn($billingAddress);
+
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getId')->willReturn(42);
+ $payment->method('getDetails')->willReturn([]);
+ $payment->method('getMethod')->willReturn($method);
+ $payment->method('getOrder')->willReturn($order);
+ $payment->method('getAmount')->willReturn(1000);
+ $payment->method('getCurrencyCode')->willReturn('EUR');
+
+ $paymentRequest = $this->createMock(PaymentRequestInterface::class);
+ $paymentRequest->method('getPayment')->willReturn($payment);
+ $paymentRequest->method('getHash')->willReturn(Uuid::v4());
+
+ $this->paymentRequestProvider->method('provide')->willReturn($paymentRequest);
+
+ return $paymentRequest;
+ }
+
+ private function savedCard(): Card
+ {
+ return (new Card())->setExternalId('alias_existing_1')->setBrand('VISA')->setLast4('4242')
+ ->setExpirationMonth(12)->setExpirationYear(2030)->setCountryCode('FR')->setIsLive(false);
+ }
+
+ public function testInvoke_withNoCardSelected_failsThePaymentRequestInsteadOfCrashing(): void
+ {
+ $paymentRequest = $this->paymentRequestWithSelectedCard(null);
+
+ $this->unifiedApiPaymentCreator->expects(self::never())->method('createPayment');
+ $this->stateMachine->expects(self::once())->method('apply')
+ ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL);
+
+ $this->handler->__invoke(new CaptureAliasPaymentRequest(null));
+ }
+
+ public function testInvoke_withGatewayConfigMissingAccountId_failsThePaymentRequestInsteadOfCrashing(): void
+ {
+ $paymentRequest = $this->paymentRequestWithSelectedCard($this->savedCard(), gatewayConfig: null);
+
+ $this->unifiedApiPaymentCreator->expects(self::never())->method('createPayment');
+ $this->stateMachine->expects(self::once())->method('apply')
+ ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL);
+
+ $this->handler->__invoke(new CaptureAliasPaymentRequest(null));
+ }
+
+ public function testInvoke_onDirectSuccess_completesThePaymentRequestWithoutARedirect(): void
+ {
+ $paymentRequest = $this->paymentRequestWithSelectedCard($this->savedCard());
+
+ $this->unifiedApiPaymentCreator->method('createPayment')->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', null, null, 'alias_existing_1'));
+
+ $paymentRequest->expects(self::once())->method('setResponseData')
+ ->with(self::callback(static fn (array $data): bool => !isset($data['redirect_url'])));
+ $this->stateMachine->expects(self::once())->method('apply')
+ ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE);
+
+ $this->handler->__invoke(new CaptureAliasPaymentRequest(null));
+ }
+
+ public function testInvoke_onApiException_failsThePaymentRequest(): void
+ {
+ $paymentRequest = $this->paymentRequestWithSelectedCard($this->savedCard());
+
+ $this->unifiedApiPaymentCreator->method('createPayment')->willThrowException(new ApiException('boom'));
+
+ $this->stateMachine->expects(self::once())->method('apply')
+ ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL);
+
+ $this->handler->__invoke(new CaptureAliasPaymentRequest(null));
+ }
+
+ public function testInvoke_withCardBelongingToAnotherCustomer_failsThePaymentRequest(): void
+ {
+ $anotherCustomer = $this->createMock(CustomerInterface::class);
+ $paymentRequest = $this->paymentRequestWithSelectedCard($this->savedCard(), cardCustomer: $anotherCustomer);
+
+ $this->unifiedApiPaymentCreator->expects(self::never())->method('createPayment');
+ $this->stateMachine->expects(self::once())->method('apply')
+ ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL);
+
+ $this->handler->__invoke(new CaptureAliasPaymentRequest(null));
+ }
+
+ public function testInvoke_withCardBelongingToAnotherPaymentMethod_failsThePaymentRequest(): void
+ {
+ $anotherMethod = $this->createMock(PaymentMethodInterface::class);
+ $paymentRequest = $this->paymentRequestWithSelectedCard($this->savedCard(), cardPaymentMethod: $anotherMethod);
+
+ $this->unifiedApiPaymentCreator->expects(self::never())->method('createPayment');
+ $this->stateMachine->expects(self::once())->method('apply')
+ ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL);
+
+ $this->handler->__invoke(new CaptureAliasPaymentRequest(null));
+ }
+
+ public function testInvoke_withNoCustomerEmail_failsThePaymentRequest(): void
+ {
+ $paymentRequest = $this->paymentRequestWithSelectedCard($this->savedCard(), customerEmail: null);
+
+ $this->unifiedApiPaymentCreator->expects(self::never())->method('createPayment');
+ $this->stateMachine->expects(self::once())->method('apply')
+ ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL);
+
+ $this->handler->__invoke(new CaptureAliasPaymentRequest(null));
+ }
+
+ public function testInvoke_onDirectSuccessWithSuccessExecCode_appliesPaidOutcomeToOrderStateMutator(): void
+ {
+ $this->paymentRequestWithSelectedCard($this->savedCard());
+
+ $this->unifiedApiPaymentCreator->method('createPayment')
+ ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000"}', null, null, 'alias_existing_1'));
+
+ $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::PAID);
+
+ $this->handler->__invoke(new CaptureAliasPaymentRequest(null));
+ }
+
+ public function testInvoke_onRedirectOutcome_neverAppliesOrderStateMutator(): void
+ {
+ $paymentRequest = $this->paymentRequestWithSelectedCard($this->savedCard());
+
+ $this->unifiedApiPaymentCreator->method('createPayment')
+ ->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', 'https://example.com/3ds', null, 'alias_existing_1'));
+
+ $this->orderStateMutator->expects(self::never())->method('apply');
+ $paymentRequest->expects(self::once())->method('setResponseData')
+ ->with(['redirect_url' => 'https://example.com/3ds']);
+
+ $this->handler->__invoke(new CaptureAliasPaymentRequest(null));
+ }
+
+ public function testInvoke_onRedirectHtmlOutcome_storesRedirectHtmlAndNeverAppliesOrderStateMutator(): void
+ {
+ $paymentRequest = $this->paymentRequestWithSelectedCard($this->savedCard());
+
+ $this->unifiedApiPaymentCreator->method('createPayment')
+ ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0001"}', null, '', 'alias_existing_1'));
+
+ $this->orderStateMutator->expects(self::never())->method('apply');
+ $paymentRequest->expects(self::once())->method('setResponseData')
+ ->with(['redirect_html' => '']);
+
+ $this->handler->__invoke(new CaptureAliasPaymentRequest(null));
+ }
+
+ public function testInvoke_setsSuccessAndCancelUrlOnTheUnifiedApiRequest(): void
+ {
+ $this->paymentRequestWithSelectedCard($this->savedCard());
+
+ $this->unifiedApiPaymentCreator->expects(self::once())->method('createPayment')
+ ->with(self::callback(function (PaymentDto $dto): bool {
+ self::assertSame('https://shop.test/order/00000042/pay', $dto->common->successUrl);
+ self::assertSame('https://shop.test/order/00000042/pay?status=canceled', $dto->common->cancelUrl);
+
+ return true;
+ }))
+ ->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', null, null, 'alias_existing_1'));
+
+ $this->handler->__invoke(new CaptureAliasPaymentRequest(null));
+ }
+
+ public function testInvoke_withABillingAddress_sendsItsFullNameAsThePaymentMethodDetails(): void
+ {
+ $billingAddress = $this->createMock(AddressInterface::class);
+ $billingAddress->method('getFullName')->willReturn('John Doe');
+
+ $this->paymentRequestWithSelectedCard($this->savedCard(), billingAddress: $billingAddress);
+
+ $this->unifiedApiPaymentCreator->expects(self::once())->method('createPayment')
+ ->with(self::callback(function (PaymentDto $dto): bool {
+ self::assertIsArray($dto->paymentMethod);
+ self::assertSame('John Doe', $dto->paymentMethod['details']['fullName'] ?? null);
+
+ return true;
+ }))
+ ->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', null, null, 'alias_existing_1'));
+
+ $this->handler->__invoke(new CaptureAliasPaymentRequest(null));
+ }
+
+ public function testInvoke_withNoBillingAddress_leavesPaymentMethodNull(): void
+ {
+ $this->paymentRequestWithSelectedCard($this->savedCard());
+
+ $this->unifiedApiPaymentCreator->expects(self::once())->method('createPayment')
+ ->with(self::callback(function (PaymentDto $dto): bool {
+ self::assertNull($dto->paymentMethod);
+
+ return true;
+ }))
+ ->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', null, null, 'alias_existing_1'));
+
+ $this->handler->__invoke(new CaptureAliasPaymentRequest(null));
+ }
+
+ public function testInvoke_onSuccess_storesTheUnifiedApiPaymentAndOperationIdsOnThePaymentDetails(): void
+ {
+ $paymentRequest = $this->paymentRequestWithSelectedCard($this->savedCard());
+ $payment = $paymentRequest->getPayment();
+
+ $this->unifiedApiPaymentCreator->method('createPayment')
+ ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000","operationIds":["op_1"]}', null, null, 'alias_existing_1'));
+
+ $payment->expects(self::once())->method('setDetails')
+ ->with(self::callback(static fn (array $details): bool => 'pay_1' === ($details['hosted_fields_payment_id'] ?? null) &&
+ 'op_1' === ($details['hosted_fields_operation_id'] ?? null) &&
+ 'alias_existing_1' === ($details['alias_id'] ?? null)));
+
+ $this->handler->__invoke(new CaptureAliasPaymentRequest(null));
+ }
+
+ public function testInvoke_onPending3ds_storesTheUnifiedApiPaymentAndOperationIdsOnThePaymentDetails(): void
+ {
+ $paymentRequest = $this->paymentRequestWithSelectedCard($this->savedCard());
+ $payment = $paymentRequest->getPayment();
+
+ $this->unifiedApiPaymentCreator->method('createPayment')
+ ->willReturn(new PaymentOutput(200, '{"id":"pay_1","execCode":"0001","operationIds":["op_1"]}', 'https://example.com/3ds', null, 'alias_existing_1'));
+
+ $payment->expects(self::once())->method('setDetails')
+ ->with(self::callback(static fn (array $details): bool => 'pay_1' === ($details['hosted_fields_payment_id'] ?? null) &&
+ 'op_1' === ($details['hosted_fields_operation_id'] ?? null)));
+
+ $this->handler->__invoke(new CaptureAliasPaymentRequest(null));
+ }
+}
diff --git a/tests/PHPUnit/Command/Handler/CaptureHostedPaymentRequestHandlerTest.php b/tests/PHPUnit/Command/Handler/CaptureHostedPaymentRequestHandlerTest.php
new file mode 100644
index 00000000..7444c0ad
--- /dev/null
+++ b/tests/PHPUnit/Command/Handler/CaptureHostedPaymentRequestHandlerTest.php
@@ -0,0 +1,619 @@
+paymentRequestProvider = $this->createMock(PaymentRequestProviderInterface::class);
+ $this->stateMachine = $this->createMock(StateMachineInterface::class);
+ $this->unifiedApiPaymentCreator = $this->createMock(UnifiedApiPaymentCreatorInterface::class);
+ $this->operationStatusFetcher = $this->createMock(OperationStatusFetcherInterface::class);
+ $this->afterPayUrlProvider = $this->createMock(UrlProviderInterface::class);
+ $this->afterPayUrlProvider->method('getUrl')->willReturn('https://shop.test/order/00000042/pay');
+ $this->urlGenerator = $this->createMock(UrlGeneratorInterface::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+ $this->requestStack = $this->createMock(RequestStack::class);
+ $this->orderStateMutator = $this->createMock(IOrderStateMutator::class);
+ $this->payplugCardFactory = $this->createMock(FactoryInterface::class);
+ $this->payplugCardRepository = $this->createMock(RepositoryInterface::class);
+ $this->managerRegistry = $this->createMock(ManagerRegistry::class);
+
+ $this->handler = new CaptureHostedPaymentRequestHandler(
+ $this->paymentRequestProvider,
+ $this->stateMachine,
+ $this->unifiedApiPaymentCreator,
+ $this->operationStatusFetcher,
+ new PaymentCaptureContextBuilder($this->urlGenerator, $this->afterPayUrlProvider, new OrderAddressDtoCreator(), $this->requestStack),
+ new PaymentCaptureOutcomeApplier($this->logger, $this->stateMachine, $this->orderStateMutator, $this->requestStack),
+ new PayplugCardPersister($this->payplugCardFactory, $this->payplugCardRepository, $this->managerRegistry),
+ $this->logger,
+ );
+ }
+
+ private function paymentRequestWithPayment(
+ array $details,
+ int $amount = 1000,
+ string $currency = 'EUR',
+ ?array $gatewayConfig = ['hfIdentifier' => 'acct_123'],
+ ?AddressInterface $billingAddress = null,
+ ): PaymentRequestInterface&MockObject
+ {
+ $method = $this->createMock(PaymentMethodInterface::class);
+ if (null !== $gatewayConfig) {
+ $config = $this->createMock(GatewayConfigInterface::class);
+ $config->method('getConfig')->willReturn($gatewayConfig);
+ $method->method('getGatewayConfig')->willReturn($config);
+ }
+
+ $customer = $this->createMock(CustomerInterface::class);
+ $customer->method('getId')->willReturn(7);
+ $customer->method('getEmail')->willReturn('customer@example.com');
+
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getCustomer')->willReturn($customer);
+ $order->method('getBillingAddress')->willReturn($billingAddress);
+
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getId')->willReturn(42);
+ $payment->method('getDetails')->willReturn($details);
+ $payment->method('getMethod')->willReturn($method);
+ $payment->method('getOrder')->willReturn($order);
+ $payment->method('getAmount')->willReturn($amount);
+ $payment->method('getCurrencyCode')->willReturn($currency);
+
+ $paymentRequest = $this->createMock(PaymentRequestInterface::class);
+ $paymentRequest->method('getPayment')->willReturn($payment);
+ $paymentRequest->method('getHash')->willReturn(Uuid::v4());
+
+ $this->paymentRequestProvider->method('provide')->willReturn($paymentRequest);
+
+ return $paymentRequest;
+ }
+
+ public function testInvoke_onDirectSuccess_completesThePaymentRequestWithoutARedirect(): void
+ {
+ $paymentRequest = $this->paymentRequestWithPayment([
+ 'hosted_fields_token' => 'hf_token_abc',
+ 'hosted_fields_selected_brand' => 'VISA',
+ ]);
+
+ $this->unifiedApiPaymentCreator->method('createPayment')->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', null, null, null));
+
+ $paymentRequest->expects(self::once())->method('setResponseData')
+ ->with(self::callback(static fn (array $data): bool => !isset($data['redirect_url'])));
+ $this->stateMachine->expects(self::once())->method('apply')
+ ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE);
+
+ $this->handler->__invoke(new CaptureHostedPaymentRequest($paymentRequest->getId()));
+ }
+
+ public function testInvoke_whenNoHostedFieldsTokenStored_failsThePaymentRequestInsteadOfCrashing(): void
+ {
+ $paymentRequest = $this->paymentRequestWithPayment([]);
+
+ $this->unifiedApiPaymentCreator->expects(self::never())->method('createPayment');
+ $paymentRequest->expects(self::once())->method('setResponseData')
+ ->with(self::callback(static fn (array $data): bool => isset($data['error'])));
+ $this->stateMachine->expects(self::once())->method('apply')
+ ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL);
+
+ $this->handler->__invoke(new CaptureHostedPaymentRequest(null));
+ }
+
+ public function testInvoke_whenGatewayConfigIsMissingAccountOrSubmerchantId_failsThePaymentRequestInsteadOfCrashing(): void
+ {
+ $paymentRequest = $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc'], gatewayConfig: null);
+
+ $this->unifiedApiPaymentCreator->expects(self::never())->method('createPayment');
+ $paymentRequest->expects(self::once())->method('setResponseData')
+ ->with(self::callback(static fn (array $data): bool => isset($data['error'])));
+ $this->stateMachine->expects(self::once())->method('apply')
+ ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL);
+
+ $this->handler->__invoke(new CaptureHostedPaymentRequest(null));
+ }
+
+ public function testInvoke_onApiException_failsThePaymentRequest(): void
+ {
+ $paymentRequest = $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']);
+
+ $this->unifiedApiPaymentCreator->method('createPayment')->willThrowException(new ApiException('boom'));
+
+ $this->stateMachine->expects(self::once())->method('apply')
+ ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL);
+
+ $this->handler->__invoke(new CaptureHostedPaymentRequest(null));
+ }
+
+ public function testInvoke_whenCustomerEmailIsMissing_failsThePaymentRequestInsteadOfCallingUnifiedApiPaymentCreator(): void
+ {
+ $method = $this->createMock(PaymentMethodInterface::class);
+ $gatewayConfig = $this->createMock(GatewayConfigInterface::class);
+ $gatewayConfig->method('getConfig')->willReturn(['hfIdentifier' => 'acct_123']);
+ $method->method('getGatewayConfig')->willReturn($gatewayConfig);
+ $customer = $this->createMock(CustomerInterface::class);
+ $customer->method('getEmail')->willReturn(null);
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getCustomer')->willReturn($customer);
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getId')->willReturn(42);
+ $payment->method('getDetails')->willReturn(['hosted_fields_token' => 'hf_token_abc']);
+ $payment->method('getMethod')->willReturn($method);
+ $payment->method('getOrder')->willReturn($order);
+ $payment->method('getAmount')->willReturn(1000);
+ $payment->method('getCurrencyCode')->willReturn('EUR');
+ $paymentRequest = $this->createMock(PaymentRequestInterface::class);
+ $paymentRequest->method('getPayment')->willReturn($payment);
+ $paymentRequest->method('getHash')->willReturn(Uuid::v4());
+ $this->paymentRequestProvider->method('provide')->willReturn($paymentRequest);
+
+ $this->unifiedApiPaymentCreator->expects(self::never())->method('createPayment');
+ $paymentRequest->expects(self::once())->method('setResponseData')
+ ->with(self::callback(static fn (array $data): bool => isset($data['error'])));
+ $this->stateMachine->expects(self::once())->method('apply')
+ ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL);
+
+ $this->handler->__invoke(new CaptureHostedPaymentRequest(null));
+ }
+
+ public function testInvoke_onDirectSuccessWithoutExecCode_neverAppliesOrderStateMutator(): void
+ {
+ $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']);
+
+ $this->unifiedApiPaymentCreator->method('createPayment')->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', null, null, null));
+
+ $this->orderStateMutator->expects(self::never())->method('apply');
+
+ $this->handler->__invoke(new CaptureHostedPaymentRequest(null));
+ }
+
+ public function testInvoke_onDirectSuccessWithSuccessExecCode_appliesPaidOutcomeToOrderStateMutator(): void
+ {
+ $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']);
+
+ $this->unifiedApiPaymentCreator->method('createPayment')
+ ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000"}', null, null, null));
+
+ $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::PAID);
+
+ $this->handler->__invoke(new CaptureHostedPaymentRequest(null));
+ }
+
+ public function testInvoke_onDirectSuccessWithFailureExecCode_appliesFailedOutcomeToOrderStateMutator(): void
+ {
+ $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']);
+
+ $this->unifiedApiPaymentCreator->method('createPayment')
+ ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"9999"}', null, null, null));
+
+ $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::FAILED);
+
+ $this->handler->__invoke(new CaptureHostedPaymentRequest(null));
+ }
+
+ public function testInvoke_onRedirectOutcome_neverAppliesOrderStateMutator(): void
+ {
+ $paymentRequest = $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']);
+
+ $this->unifiedApiPaymentCreator->method('createPayment')
+ ->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', 'https://example.com/3ds', null, null));
+
+ $this->orderStateMutator->expects(self::never())->method('apply');
+ $paymentRequest->expects(self::once())->method('setResponseData')
+ ->with(['redirect_url' => 'https://example.com/3ds']);
+
+ $this->handler->__invoke(new CaptureHostedPaymentRequest(null));
+ }
+
+ public function testInvoke_onPending3ds_storesTheUnifiedApiPaymentAndOperationIdsOnThePaymentDetails(): void
+ {
+ $paymentRequest = $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']);
+ $payment = $paymentRequest->getPayment();
+
+ $this->unifiedApiPaymentCreator->method('createPayment')
+ ->willReturn(new PaymentOutput(200, '{"id":"pay_1","execCode":"0001","operationIds":["op_1"]}', 'https://example.com/3ds', null, null));
+
+ $payment->expects(self::once())->method('setDetails')
+ ->with(self::callback(static fn (array $details): bool => 'pay_1' === $details['hosted_fields_payment_id'] &&
+ 'op_1' === $details['hosted_fields_operation_id']));
+
+ $this->handler->__invoke(new CaptureHostedPaymentRequest(null));
+ }
+
+ public function testInvoke_whenResponseBodyHasNoId_neverStoresAHostedFieldsPaymentOrOperationId(): void
+ {
+ $paymentRequest = $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']);
+ $payment = $paymentRequest->getPayment();
+
+ $this->unifiedApiPaymentCreator->method('createPayment')->willReturn(new PaymentOutput(201, '{}', null, null, null));
+
+ $payment->expects(self::once())->method('setDetails')
+ ->with(self::callback(static fn (array $details): bool => !isset($details['hosted_fields_payment_id']) &&
+ !isset($details['hosted_fields_operation_id'])));
+
+ $this->handler->__invoke(new CaptureHostedPaymentRequest(null));
+ }
+
+ public function testInvoke_onRedirectHtmlOutcome_storesRedirectHtmlAndNeverAppliesOrderStateMutator(): void
+ {
+ $paymentRequest = $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']);
+
+ $this->unifiedApiPaymentCreator->method('createPayment')
+ ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0001"}', null, '', null));
+
+ $this->orderStateMutator->expects(self::never())->method('apply');
+ $paymentRequest->expects(self::once())->method('setResponseData')
+ ->with(['redirect_html' => '']);
+
+ $this->handler->__invoke(new CaptureHostedPaymentRequest(null));
+ }
+
+ public function testInvoke_setsSuccessAndCancelUrlOnTheUnifiedApiRequest(): void
+ {
+ $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']);
+
+ $this->unifiedApiPaymentCreator->expects(self::once())->method('createPayment')
+ ->with(self::callback(function (HostedFieldDto $dto): bool {
+ self::assertSame('https://shop.test/order/00000042/pay', $dto->common->successUrl);
+ self::assertSame('https://shop.test/order/00000042/pay?status=canceled', $dto->common->cancelUrl);
+
+ return true;
+ }))
+ ->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', null, null, null));
+
+ $this->handler->__invoke(new CaptureHostedPaymentRequest(null));
+ }
+
+ public function testInvoke_withABillingAddress_sendsItsFullNameAlongsideSelectedBrand(): void
+ {
+ $billingAddress = $this->createMock(AddressInterface::class);
+ $billingAddress->method('getFullName')->willReturn('John Doe');
+
+ $this->paymentRequestWithPayment(
+ ['hosted_fields_token' => 'hf_token_abc', 'hosted_fields_selected_brand' => 'VISA'],
+ billingAddress: $billingAddress,
+ );
+
+ $this->unifiedApiPaymentCreator->expects(self::once())->method('createPayment')
+ ->with(self::callback(function (HostedFieldDto $dto): bool {
+ self::assertIsArray($dto->paymentMethod);
+ self::assertSame('John Doe', $dto->paymentMethod['details']['fullName'] ?? null);
+ self::assertSame('VISA', $dto->paymentMethod['details']['selectedBrand'] ?? null);
+
+ return true;
+ }))
+ ->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', null, null, null));
+
+ $this->handler->__invoke(new CaptureHostedPaymentRequest(null));
+ }
+
+ public function testInvoke_withNoBillingAddress_omitsFullNameButStillSendsSelectedBrand(): void
+ {
+ $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc', 'hosted_fields_selected_brand' => 'VISA']);
+
+ $this->unifiedApiPaymentCreator->expects(self::once())->method('createPayment')
+ ->with(self::callback(function (HostedFieldDto $dto): bool {
+ self::assertIsArray($dto->paymentMethod);
+ self::assertArrayNotHasKey('fullName', $dto->paymentMethod['details'] ?? []);
+ self::assertSame('VISA', $dto->paymentMethod['details']['selectedBrand'] ?? null);
+
+ return true;
+ }))
+ ->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', null, null, null));
+
+ $this->handler->__invoke(new CaptureHostedPaymentRequest(null));
+ }
+
+ public function testInvoke_whenSaveCardRequestedWithNoFullNameOrBrandAvailable_omitsSaveFutureUsageInsteadOfFailingThePayment(): void
+ {
+ $paymentRequest = $this->paymentRequestWithPayment([
+ 'hosted_fields_token' => 'hf_token_abc',
+ 'hosted_fields_save_card' => true,
+ ]);
+
+ $this->unifiedApiPaymentCreator->expects(self::once())->method('createPayment')
+ ->with(self::callback(function (HostedFieldDto $dto): bool {
+ self::assertNull($dto->paymentMethod);
+
+ return true;
+ }))
+ ->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', null, null, null));
+
+ $this->stateMachine->expects(self::once())->method('apply')
+ ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE);
+
+ $this->handler->__invoke(new CaptureHostedPaymentRequest(null));
+ }
+
+ public function testInvoke_whenSaveCardRequestedAndAliasReturned_persistsANewCard(): void
+ {
+ $billingAddress = $this->createMock(AddressInterface::class);
+ $billingAddress->method('getFullName')->willReturn('John Doe');
+
+ $paymentRequest = $this->paymentRequestWithPayment([
+ 'hosted_fields_token' => 'hf_token_abc',
+ 'hosted_fields_selected_brand' => 'VISA',
+ 'hosted_fields_save_card' => true,
+ 'hosted_fields_last4' => '4242',
+ 'hosted_fields_expiration_month' => 12,
+ 'hosted_fields_expiration_year' => 2030,
+ 'hosted_fields_country' => 'FR',
+ ], billingAddress: $billingAddress);
+
+ $this->unifiedApiPaymentCreator->expects(self::once())->method('createPayment')
+ ->with(self::callback(function (HostedFieldDto $dto): bool {
+ self::assertSame('ONE_CLICK', $dto->recurringMode);
+ self::assertIsArray($dto->paymentMethod);
+ self::assertTrue($dto->paymentMethod['saveFutureUsage'] ?? false);
+ self::assertSame('VISA', $dto->paymentMethod['details']['selectedBrand'] ?? null);
+
+ return true;
+ }))
+ ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000"}', null, null, 'alias_new_1'));
+
+ $card = new Card();
+ $this->payplugCardFactory->method('createNew')->willReturn($card);
+ $this->payplugCardRepository->expects(self::once())->method('add')->with($card);
+
+ $this->handler->__invoke(new CaptureHostedPaymentRequest(null));
+
+ self::assertSame('alias_new_1', $card->getExternalId());
+ self::assertSame('VISA', $card->getBrand());
+ self::assertSame('4242', $card->getLast4());
+ self::assertSame(12, $card->getExpirationMonth());
+ self::assertSame(2030, $card->getExpirationYear());
+ self::assertSame('FR', $card->getCountryCode());
+ }
+
+ public function testInvoke_whenSaveCardRequestedAndUnifiedApiOperationIdAvailable_enrichesTheCardWithExpirationFetchedFromTheUnifiedApi(): void
+ {
+ $this->paymentRequestWithPayment([
+ 'hosted_fields_token' => 'hf_token_abc',
+ 'hosted_fields_selected_brand' => 'VISA',
+ 'hosted_fields_save_card' => true,
+ 'hosted_fields_country' => 'BE',
+ ]);
+
+ $this->unifiedApiPaymentCreator->method('createPayment')
+ ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000","operationIds":["op_1"]}', null, null, 'alias_new_1'));
+
+ // Real shape confirmed against a staging operation response: card metadata lives under
+ // paymentMethod.card (network, a masked code6x4 PAN standing in for a dedicated last4
+ // field) and paymentMethod.details (selectedBrand, validityDate in "YYYY-MM" form). No
+ // country field exists anywhere on that response.
+ $this->operationStatusFetcher->expects(self::once())->method('getOperation')
+ ->with('op_1')
+ ->willReturn(['status' => 200, 'body' => json_encode([
+ 'paymentMethod' => [
+ 'id' => 'alias_new_1',
+ 'card' => [
+ 'code6x4' => '424242XXXXXX4242',
+ 'network' => 'VISA',
+ ],
+ 'details' => [
+ 'fullName' => 'John Doe',
+ 'validityDate' => '2027-12',
+ 'selectedBrand' => 'VISA',
+ ],
+ ],
+ ])]);
+
+ $card = new Card();
+ $this->payplugCardFactory->method('createNew')->willReturn($card);
+ $this->payplugCardRepository->expects(self::once())->method('add')->with($card);
+
+ $this->handler->__invoke(new CaptureHostedPaymentRequest(null));
+
+ self::assertSame('4242', $card->getLast4());
+ self::assertSame('BE', $card->getCountryCode());
+ self::assertSame(12, $card->getExpirationMonth());
+ self::assertSame(2027, $card->getExpirationYear());
+ }
+
+ public function testInvoke_whenSaveCardRequestedAndFetchingTheOperationFails_stillPersistsTheCardUsingDetailsFallback(): void
+ {
+ $this->paymentRequestWithPayment([
+ 'hosted_fields_token' => 'hf_token_abc',
+ 'hosted_fields_selected_brand' => 'VISA',
+ 'hosted_fields_save_card' => true,
+ 'hosted_fields_last4' => '4242',
+ 'hosted_fields_expiration_month' => 12,
+ 'hosted_fields_expiration_year' => 2030,
+ 'hosted_fields_country' => 'FR',
+ ]);
+
+ $this->unifiedApiPaymentCreator->method('createPayment')
+ ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000","operationIds":["op_1"]}', null, null, 'alias_new_1'));
+
+ $this->operationStatusFetcher->method('getOperation')->willThrowException(new ApiException('boom'));
+
+ $card = new Card();
+ $this->payplugCardFactory->method('createNew')->willReturn($card);
+ $this->payplugCardRepository->expects(self::once())->method('add')->with($card);
+
+ $this->handler->__invoke(new CaptureHostedPaymentRequest(null));
+
+ self::assertSame('4242', $card->getLast4());
+ self::assertSame(12, $card->getExpirationMonth());
+ self::assertSame(2030, $card->getExpirationYear());
+ self::assertSame('FR', $card->getCountryCode());
+ }
+
+ /**
+ * @dataProvider malformedOperationResponseBodyProvider
+ */
+ public function testInvoke_whenSaveCardRequestedAndOperationResponseShapeIsMalformed_stillPersistsTheCardUsingDetailsFallback(
+ string $body,
+ ): void {
+ $this->paymentRequestWithPayment([
+ 'hosted_fields_token' => 'hf_token_abc',
+ 'hosted_fields_selected_brand' => 'VISA',
+ 'hosted_fields_save_card' => true,
+ 'hosted_fields_last4' => '4242',
+ 'hosted_fields_expiration_month' => 12,
+ 'hosted_fields_expiration_year' => 2030,
+ 'hosted_fields_country' => 'FR',
+ ]);
+
+ $this->unifiedApiPaymentCreator->method('createPayment')
+ ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000","operationIds":["op_1"]}', null, null, 'alias_new_1'));
+
+ $this->operationStatusFetcher->method('getOperation')->willReturn(['status' => 200, 'body' => $body]);
+
+ $card = new Card();
+ $this->payplugCardFactory->method('createNew')->willReturn($card);
+ $this->payplugCardRepository->expects(self::once())->method('add')->with($card);
+
+ $this->handler->__invoke(new CaptureHostedPaymentRequest(null));
+
+ self::assertSame('4242', $card->getLast4());
+ self::assertSame(12, $card->getExpirationMonth());
+ self::assertSame(2030, $card->getExpirationYear());
+ self::assertSame('FR', $card->getCountryCode());
+ }
+
+ /** @return array */
+ public static function malformedOperationResponseBodyProvider(): array
+ {
+ return [
+ 'non-array body' => ['"just a string"'],
+ 'paymentMethod key missing' => [json_encode(['id' => 'op_1'])],
+ 'card key missing' => [json_encode(['paymentMethod' => ['details' => ['selectedBrand' => 'VISA']]])],
+ 'details key missing' => [json_encode(['paymentMethod' => ['card' => ['network' => 'VISA']]])],
+ 'validityDate does not match the expected YYYY-MM format' => [json_encode(['paymentMethod' => ['details' => ['validityDate' => '1225']]])],
+ 'validityDate has an out-of-range month' => [json_encode(['paymentMethod' => ['details' => ['validityDate' => '2027-13']]])],
+ 'code6x4 shorter than 4 characters' => [json_encode(['paymentMethod' => ['card' => ['code6x4' => '42']]])],
+ ];
+ }
+
+ public function testInvoke_whenSaveCardNotRequested_neverPersistsACard(): void
+ {
+ $this->paymentRequestWithPayment([
+ 'hosted_fields_token' => 'hf_token_abc',
+ 'hosted_fields_save_card' => false,
+ ]);
+
+ $this->unifiedApiPaymentCreator->method('createPayment')
+ ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000"}', null, null, null));
+
+ $this->payplugCardRepository->expects(self::never())->method('add');
+
+ $this->handler->__invoke(new CaptureHostedPaymentRequest(null));
+ }
+
+ public function testInvoke_whenSaveCardRequestedButNoAliasReturned_neverPersistsACard(): void
+ {
+ $this->paymentRequestWithPayment([
+ 'hosted_fields_token' => 'hf_token_abc',
+ 'hosted_fields_save_card' => true,
+ ]);
+
+ $this->unifiedApiPaymentCreator->method('createPayment')
+ ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000"}', null, null, null));
+
+ $this->payplugCardRepository->expects(self::never())->method('add');
+
+ $this->handler->__invoke(new CaptureHostedPaymentRequest(null));
+ }
+
+ public function testInvoke_whenSaveCardRequestedButMethodIsNotCorePaymentMethod_neverPersistsACard(): void
+ {
+ // Card::$paymentMethod requires Sylius Core's PaymentMethodInterface, which every real
+ // Sylius-wired payment method satisfies — a test double built against only the base
+ // Payment component's PaymentMethodInterface exercises the guard that skips persisting a
+ // card entirely rather than flushing one with that mandatory field left unset.
+ $method = $this->createMock(BasePaymentMethodInterface::class);
+ $gatewayConfig = $this->createMock(GatewayConfigInterface::class);
+ $gatewayConfig->method('getConfig')->willReturn(['hfIdentifier' => 'acct_123']);
+ $method->method('getGatewayConfig')->willReturn($gatewayConfig);
+ $customer = $this->createMock(CustomerInterface::class);
+ $customer->method('getId')->willReturn(7);
+ $customer->method('getEmail')->willReturn('customer@example.com');
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getCustomer')->willReturn($customer);
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getId')->willReturn(42);
+ $payment->method('getDetails')->willReturn(['hosted_fields_token' => 'hf_token_abc', 'hosted_fields_save_card' => true]);
+ $payment->method('getMethod')->willReturn($method);
+ $payment->method('getOrder')->willReturn($order);
+ $payment->method('getAmount')->willReturn(1000);
+ $payment->method('getCurrencyCode')->willReturn('EUR');
+ $paymentRequest = $this->createMock(PaymentRequestInterface::class);
+ $paymentRequest->method('getPayment')->willReturn($payment);
+ $paymentRequest->method('getHash')->willReturn(Uuid::v4());
+ $this->paymentRequestProvider->method('provide')->willReturn($paymentRequest);
+
+ $this->unifiedApiPaymentCreator->method('createPayment')
+ ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000"}', null, null, 'alias_new_1'));
+
+ $this->payplugCardRepository->expects(self::never())->method('add');
+
+ $this->handler->__invoke(new CaptureHostedPaymentRequest(null));
+ }
+}
diff --git a/tests/PHPUnit/Command/Handler/NotifyHostedPaymentRequestHandlerTest.php b/tests/PHPUnit/Command/Handler/NotifyHostedPaymentRequestHandlerTest.php
new file mode 100644
index 00000000..a16cafbc
--- /dev/null
+++ b/tests/PHPUnit/Command/Handler/NotifyHostedPaymentRequestHandlerTest.php
@@ -0,0 +1,218 @@
+paymentRequestProvider = $this->createMock(PaymentRequestProviderInterface::class);
+ $this->stateMachine = $this->createMock(StateMachineInterface::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 NotifyHostedPaymentRequestHandler(
+ $this->paymentRequestProvider,
+ $this->stateMachine,
+ $this->lock,
+ $this->paymentRepository,
+ $this->orderStateMutator,
+ $this->configurationRepository,
+ $this->logger,
+ );
+ }
+
+ private function paymentRequestWithPayload(
+ array $httpRequest,
+ int $paymentId = 42,
+ int $paymentAmount = 1000,
+ ): PaymentRequestInterface&MockObject
+ {
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getId')->willReturn($paymentId);
+ $payment->method('getAmount')->willReturn($paymentAmount);
+
+ $paymentRequest = $this->createMock(PaymentRequestInterface::class);
+ $paymentRequest->method('getPayload')->willReturn(['http_request' => $httpRequest]);
+ $paymentRequest->method('getPayment')->willReturn($payment);
+ $this->paymentRequestProvider->method('provide')->willReturn($paymentRequest);
+
+ return $paymentRequest;
+ }
+
+ public function testInvoke_onValidNotification_savesTreatsAndAppliesTheOutcome(): void
+ {
+ $body = \json_encode(['id' => 'op_123', 'execCode' => '0000', 'orderId' => '42', 'amount' => 1000]);
+ $paymentRequest = $this->paymentRequestWithPayload([
+ 'content' => $body,
+ 'headers' => ['Authorization' => ['Bearer shared-secret']],
+ ]);
+
+ $this->configurationRepository->method('get')->with('payplug_webhook_authorization_header')->willReturn('Bearer shared-secret');
+ $this->lock->method('acquire')->willReturn(true);
+ $this->paymentRepository->method('isTreated')->with('op_123')->willReturn(false);
+
+ $this->paymentRepository->expects(self::once())->method('save');
+ $this->paymentRepository->expects(self::once())->method('markTreated')->with('op_123');
+ $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::PAID);
+ $this->lock->expects(self::once())->method('release');
+ $this->stateMachine->expects(self::once())->method('apply')
+ ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE);
+
+ $this->handler->__invoke(new NotifyHostedPaymentRequest(null));
+ }
+
+ public function testInvoke_onPendingThreeDsOutcome_doesNothingAndReleasesTheLock(): void
+ {
+ $body = \json_encode(['id' => 'op_123', 'execCode' => '0001', 'orderId' => '42', 'amount' => 1000]);
+ $this->paymentRequestWithPayload([
+ 'content' => $body,
+ 'headers' => ['Authorization' => ['Bearer shared-secret']],
+ ]);
+
+ $this->configurationRepository->method('get')->willReturn('Bearer shared-secret');
+ $this->lock->method('acquire')->willReturn(true);
+
+ $this->paymentRepository->expects(self::never())->method('isTreated');
+ $this->paymentRepository->expects(self::never())->method('save');
+ $this->orderStateMutator->expects(self::never())->method('apply');
+ $this->lock->expects(self::once())->method('release');
+ $this->stateMachine->expects(self::never())->method('apply');
+
+ $this->handler->__invoke(new NotifyHostedPaymentRequest(null));
+ }
+
+ public function testInvoke_whenLockIsHeld_releasesNothingAndCompletesWithoutApplying(): void
+ {
+ $paymentRequest = $this->paymentRequestWithPayload(['content' => '{}', 'headers' => []]);
+ $this->lock->method('acquire')->willReturn(false);
+
+ $this->paymentRepository->expects(self::never())->method('save');
+ $this->orderStateMutator->expects(self::never())->method('apply');
+ $this->stateMachine->expects(self::once())->method('apply')
+ ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE);
+
+ $this->handler->__invoke(new NotifyHostedPaymentRequest(null));
+ }
+
+ public function testInvoke_whenAlreadyTreated_isIdempotentAndDoesNotReapply(): void
+ {
+ $body = \json_encode(['id' => 'op_123', 'execCode' => '0000', 'orderId' => '42', 'amount' => 1000]);
+ $this->paymentRequestWithPayload([
+ 'content' => $body,
+ 'headers' => ['Authorization' => ['Bearer shared-secret']],
+ ]);
+
+ $this->configurationRepository->method('get')->willReturn('Bearer shared-secret');
+ $this->lock->method('acquire')->willReturn(true);
+ $this->paymentRepository->method('isTreated')->with('op_123')->willReturn(true);
+
+ $this->paymentRepository->expects(self::never())->method('save');
+ $this->orderStateMutator->expects(self::never())->method('apply');
+ $this->lock->expects(self::once())->method('release');
+
+ $this->handler->__invoke(new NotifyHostedPaymentRequest(null));
+ }
+
+ public function testInvoke_whenOrderIdDoesNotMatchThePaymentRequestsOwnPayment_failsWithoutApplyingTheOutcome(): void
+ {
+ // The webhook body claims to be about order/payment "999", but the notify hash this
+ // request arrived on belongs to a PaymentRequest whose own payment id is 42. Applying the
+ // outcome here would mutate the wrong payment.
+ $body = \json_encode(['id' => 'op_123', 'execCode' => '0000', 'orderId' => '999', 'amount' => 1000]);
+ $paymentRequest = $this->paymentRequestWithPayload([
+ 'content' => $body,
+ 'headers' => ['Authorization' => ['Bearer shared-secret']],
+ ], 42, 1000);
+
+ $this->configurationRepository->method('get')->willReturn('Bearer shared-secret');
+ $this->lock->method('acquire')->willReturn(true);
+
+ $this->paymentRepository->expects(self::never())->method('save');
+ $this->paymentRepository->expects(self::never())->method('markTreated');
+ $this->orderStateMutator->expects(self::never())->method('apply');
+ $this->logger->expects(self::once())->method('error');
+ $this->lock->expects(self::once())->method('release');
+ $paymentRequest->expects(self::once())->method('setResponseData');
+ $this->stateMachine->expects(self::once())->method('apply')
+ ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL);
+
+ $this->handler->__invoke(new NotifyHostedPaymentRequest(null));
+ }
+
+ public function testInvoke_whenAmountDoesNotMatchThePaymentRequestsOwnPayment_failsWithoutApplyingTheOutcome(): void
+ {
+ $body = \json_encode(['id' => 'op_123', 'execCode' => '0000', 'orderId' => '42', 'amount' => 500]);
+ $paymentRequest = $this->paymentRequestWithPayload([
+ 'content' => $body,
+ 'headers' => ['Authorization' => ['Bearer shared-secret']],
+ ], 42, 1000);
+
+ $this->configurationRepository->method('get')->willReturn('Bearer shared-secret');
+ $this->lock->method('acquire')->willReturn(true);
+
+ $this->paymentRepository->expects(self::never())->method('save');
+ $this->orderStateMutator->expects(self::never())->method('apply');
+ $this->logger->expects(self::once())->method('error');
+ $this->lock->expects(self::once())->method('release');
+ $this->stateMachine->expects(self::once())->method('apply')
+ ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL);
+
+ $this->handler->__invoke(new NotifyHostedPaymentRequest(null));
+ }
+
+ public function testInvoke_onInvalidSignature_logsReleasesTheLockAndFailsThePaymentRequest(): void
+ {
+ $paymentRequest = $this->paymentRequestWithPayload([
+ 'content' => '{}',
+ 'headers' => ['Authorization' => ['Bearer wrong-secret']],
+ ]);
+
+ $this->configurationRepository->method('get')->willReturn('Bearer shared-secret');
+ $this->lock->method('acquire')->willReturn(true);
+
+ $this->logger->expects(self::once())->method('error');
+ $this->lock->expects(self::once())->method('release');
+ $this->stateMachine->expects(self::once())->method('apply')
+ ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL);
+
+ $this->handler->__invoke(new NotifyHostedPaymentRequest(null));
+ }
+}
diff --git a/tests/PHPUnit/Command/Handler/StatusHostedPaymentRequestHandlerTest.php b/tests/PHPUnit/Command/Handler/StatusHostedPaymentRequestHandlerTest.php
new file mode 100644
index 00000000..b8fa240f
--- /dev/null
+++ b/tests/PHPUnit/Command/Handler/StatusHostedPaymentRequestHandlerTest.php
@@ -0,0 +1,172 @@
+paymentRequestProvider = $this->createMock(PaymentRequestProviderInterface::class);
+ $this->stateMachine = $this->createMock(StateMachineInterface::class);
+ $this->operationStatusFetcher = $this->createMock(OperationStatusFetcherInterface::class);
+ $this->webhookNotificationHandler = $this->createMock(HostedFieldsWebhookNotificationHandler::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+
+ $this->handler = new StatusHostedPaymentRequestHandler(
+ $this->paymentRequestProvider,
+ $this->stateMachine,
+ $this->operationStatusFetcher,
+ $this->webhookNotificationHandler,
+ $this->logger,
+ );
+ }
+
+ /** @param mixed[] $details */
+ private function paymentRequest(
+ string $state = PaymentInterface::STATE_PROCESSING,
+ array $details = [],
+ ): PaymentRequestInterface&MockObject
+ {
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getState')->willReturn($state);
+ $payment->method('getDetails')->willReturn($details);
+
+ $paymentRequest = $this->createMock(PaymentRequestInterface::class);
+ $paymentRequest->method('getPayment')->willReturn($payment);
+ $this->paymentRequestProvider->method('provide')->willReturn($paymentRequest);
+
+ return $paymentRequest;
+ }
+
+ public function testInvoke_withNoForcedStatus_andPaymentAlreadyResolved_skipsPollingAndCompletesRequest(): void
+ {
+ $paymentRequest = $this->paymentRequest(PaymentInterface::STATE_COMPLETED, ['hosted_fields_operation_id' => 'op_123']);
+
+ $this->operationStatusFetcher->expects(self::never())->method('getOperation');
+ $this->stateMachine->expects(self::once())->method('apply')
+ ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE);
+
+ $this->handler->__invoke(new StatusHostedPaymentRequest(null));
+ }
+
+ public function testInvoke_withNoForcedStatus_andNoOperationIdStored_skipsPollingAndCompletesRequest(): void
+ {
+ $paymentRequest = $this->paymentRequest(PaymentInterface::STATE_PROCESSING, []);
+
+ $this->operationStatusFetcher->expects(self::never())->method('getOperation');
+ $this->stateMachine->expects(self::once())->method('apply')
+ ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE);
+
+ $this->handler->__invoke(new StatusHostedPaymentRequest(null));
+ }
+
+ public function testInvoke_withNoForcedStatus_andFinalExecCode_appliesOutcomeViaWebhookHandler(): void
+ {
+ $paymentRequest = $this->paymentRequest(PaymentInterface::STATE_PROCESSING, ['hosted_fields_operation_id' => 'op_123']);
+ $payment = $paymentRequest->getPayment();
+ $body = json_encode(['id' => 'op_123', 'execCode' => '0000', 'orderId' => '000000072', 'amount' => 7400]);
+
+ $this->operationStatusFetcher->expects(self::once())->method('getOperation')->with('op_123')
+ ->willReturn(['status' => 200, 'body' => $body]);
+ $this->webhookNotificationHandler->expects(self::once())->method('treat')->with($payment, $body, []);
+
+ $this->stateMachine->expects(self::once())->method('apply')
+ ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE);
+
+ $this->handler->__invoke(new StatusHostedPaymentRequest(null));
+ }
+
+ public function testInvoke_withNoForcedStatus_andPendingExecCode_stillDelegatesToWebhookHandler(): void
+ {
+ $paymentRequest = $this->paymentRequest(PaymentInterface::STATE_PROCESSING, ['hosted_fields_operation_id' => 'op_123']);
+ $payment = $paymentRequest->getPayment();
+ $body = json_encode(['id' => 'op_123', 'execCode' => '0001', 'orderId' => '000000072', 'amount' => 7400]);
+
+ $this->operationStatusFetcher->method('getOperation')->willReturn(['status' => 200, 'body' => $body]);
+ $this->webhookNotificationHandler->expects(self::once())->method('treat')->with($payment, $body, []);
+
+ $this->handler->__invoke(new StatusHostedPaymentRequest(null));
+ }
+
+ public function testInvoke_withNoForcedStatus_whenFetcherFails_logsAndStillCompletesRequest(): void
+ {
+ $paymentRequest = $this->paymentRequest(PaymentInterface::STATE_PROCESSING, ['hosted_fields_operation_id' => 'op_123']);
+
+ $this->operationStatusFetcher->method('getOperation')->willThrowException(new ApiException('boom'));
+ $this->webhookNotificationHandler->expects(self::never())->method('treat');
+ $this->logger->expects(self::once())->method('error');
+
+ $this->stateMachine->expects(self::once())->method('apply')
+ ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE);
+
+ $this->handler->__invoke(new StatusHostedPaymentRequest(null));
+ }
+
+ public function testInvoke_withNoForcedStatus_whenWebhookHandlerRejectsThePayload_logsAndStillCompletesRequest(): void
+ {
+ $paymentRequest = $this->paymentRequest(PaymentInterface::STATE_PROCESSING, ['hosted_fields_operation_id' => 'op_123']);
+ $body = json_encode(['id' => 'op_123', 'execCode' => '0000', 'orderId' => '000000072', 'amount' => 7400]);
+
+ $this->operationStatusFetcher->method('getOperation')->willReturn(['status' => 200, 'body' => $body]);
+ $this->webhookNotificationHandler->method('treat')->willThrowException(new InvalidNotificationException('mismatch'));
+ $this->logger->expects(self::once())->method('error');
+
+ $this->stateMachine->expects(self::once())->method('apply')
+ ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE);
+
+ $this->handler->__invoke(new StatusHostedPaymentRequest(null));
+ }
+
+ public function testInvoke_withForcedCanceledStatus_cancelsThePaymentWhenAllowed(): void
+ {
+ $paymentRequest = $this->paymentRequest();
+ $payment = $paymentRequest->getPayment();
+
+ $this->stateMachine->method('can')->with($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_CANCEL)->willReturn(true);
+ $this->operationStatusFetcher->expects(self::never())->method('getOperation');
+ $this->stateMachine->expects(self::exactly(2))->method('apply');
+
+ $this->handler->__invoke(new StatusHostedPaymentRequest(null, 'canceled'));
+ }
+
+ public function testInvoke_withForcedCanceledStatus_whenTransitionNotAllowed_stillCompletesThePaymentRequest(): void
+ {
+ $paymentRequest = $this->paymentRequest();
+
+ $this->stateMachine->method('can')->willReturn(false);
+ $this->stateMachine->expects(self::once())->method('apply')
+ ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE);
+
+ $this->handler->__invoke(new StatusHostedPaymentRequest(null, 'canceled'));
+ }
+}
diff --git a/tests/PHPUnit/Command/Provider/CaptureHostedPaymentRequestCommandProviderTest.php b/tests/PHPUnit/Command/Provider/CaptureHostedPaymentRequestCommandProviderTest.php
new file mode 100644
index 00000000..c05fbe86
--- /dev/null
+++ b/tests/PHPUnit/Command/Provider/CaptureHostedPaymentRequestCommandProviderTest.php
@@ -0,0 +1,136 @@
+setSession(new Session(new MockArraySessionStorage()));
+
+ $this->requestStack = new RequestStack();
+ $this->requestStack->push($request);
+ $this->payplugCardRepository = $this->createMock(RepositoryInterface::class);
+
+ $this->provider = new CaptureHostedPaymentRequestCommandProvider(new SelectedCardResolver($this->requestStack, $this->payplugCardRepository));
+ }
+
+ private function paymentRequestWithDetails(array $details): PaymentRequestInterface&MockObject
+ {
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getDetails')->willReturn($details);
+
+ $paymentRequest = $this->createMock(PaymentRequestInterface::class);
+ $paymentRequest->method('getPayment')->willReturn($payment);
+ $paymentRequest->method('getId')->willReturn('1');
+
+ return $paymentRequest;
+ }
+
+ public function testProvide_withNoCardSelected_returnsCaptureHostedPaymentRequest(): void
+ {
+ $paymentRequest = $this->paymentRequestWithDetails([]);
+
+ self::assertInstanceOf(CaptureHostedPaymentRequest::class, $this->provider->provide($paymentRequest));
+ }
+
+ public function testProvide_withOtherCardSentinelSelected_returnsCaptureHostedPaymentRequest(): void
+ {
+ $this->requestStack->getSession()->set('payplug_payment_method', 'other');
+ $paymentRequest = $this->paymentRequestWithDetails([]);
+
+ self::assertInstanceOf(CaptureHostedPaymentRequest::class, $this->provider->provide($paymentRequest));
+ }
+
+ public function testProvide_withExistingCardSelectedAndNotYetCaptured_returnsCaptureAliasPaymentRequest(): void
+ {
+ $this->requestStack->getSession()->set('payplug_payment_method', self::SELECTED_CARD_ID);
+ $this->payplugCardRepository->method('find')->with(self::SELECTED_CARD_ID)->willReturn(new Card());
+ $paymentRequest = $this->paymentRequestWithDetails([]);
+
+ self::assertInstanceOf(CaptureAliasPaymentRequest::class, $this->provider->provide($paymentRequest));
+ }
+
+ public function testProvide_withExistingCardSelectedButAlreadyCaptured_returnsOfflineCaptureRequest(): void
+ {
+ $card = new Card();
+ $card->setExternalId('alias_existing_1');
+
+ $this->requestStack->getSession()->set('payplug_payment_method', self::SELECTED_CARD_ID);
+ $this->payplugCardRepository->method('find')->with(self::SELECTED_CARD_ID)->willReturn($card);
+ $paymentRequest = $this->paymentRequestWithDetails([
+ 'alias_payment_created_at' => '2026-08-17T10:00:00+00:00',
+ 'alias_id' => 'alias_existing_1',
+ ]);
+
+ self::assertInstanceOf(OfflineCapturePaymentRequest::class, $this->provider->provide($paymentRequest));
+ }
+
+ public function testProvide_withDifferentCardSelectedAfterEarlierAliasAttempt_returnsFreshCaptureAliasPaymentRequest(): void
+ {
+ $card = new Card();
+ $card->setExternalId('alias_new');
+
+ $this->requestStack->getSession()->set('payplug_payment_method', self::SELECTED_CARD_ID);
+ $this->payplugCardRepository->method('find')->with(self::SELECTED_CARD_ID)->willReturn($card);
+ $paymentRequest = $this->paymentRequestWithDetails([
+ 'alias_payment_created_at' => '2026-08-17T10:00:00+00:00',
+ 'alias_id' => 'alias_old',
+ ]);
+
+ self::assertInstanceOf(CaptureAliasPaymentRequest::class, $this->provider->provide($paymentRequest));
+ }
+
+ public function testProvide_withNoCardSelectedAfterEarlierAliasAttempt_returnsOfflineCaptureRequestInsteadOfADuplicateCapture(): void
+ {
+ $paymentRequest = $this->paymentRequestWithDetails([
+ 'alias_payment_created_at' => '2026-08-17T10:00:00+00:00',
+ 'alias_id' => 'alias_old',
+ ]);
+
+ self::assertInstanceOf(OfflineCapturePaymentRequest::class, $this->provider->provide($paymentRequest));
+ }
+
+ public function testProvide_withHostedFieldsTokenAlreadyCaptured_returnsOfflineCaptureRequest(): void
+ {
+ $paymentRequest = $this->paymentRequestWithDetails(['hosted_fields_created_at' => '2026-08-17T10:00:00+00:00']);
+
+ self::assertInstanceOf(OfflineCapturePaymentRequest::class, $this->provider->provide($paymentRequest));
+ }
+
+ public function testProvide_withSelectedCardIdNoLongerFound_fallsBackToCaptureHostedPaymentRequest(): void
+ {
+ $this->requestStack->getSession()->set('payplug_payment_method', self::SELECTED_CARD_ID);
+ $this->payplugCardRepository->method('find')->with(self::SELECTED_CARD_ID)->willReturn(null);
+ $paymentRequest = $this->paymentRequestWithDetails([]);
+
+ self::assertInstanceOf(CaptureHostedPaymentRequest::class, $this->provider->provide($paymentRequest));
+ }
+}
diff --git a/tests/PHPUnit/Command/Provider/CapturePaymentRequestCommandProviderTest.php b/tests/PHPUnit/Command/Provider/CapturePaymentRequestCommandProviderTest.php
new file mode 100644
index 00000000..2801cbfb
--- /dev/null
+++ b/tests/PHPUnit/Command/Provider/CapturePaymentRequestCommandProviderTest.php
@@ -0,0 +1,103 @@
+hostedFieldsCommandProvider = $this->createMock(PaymentRequestCommandProviderInterface::class);
+ $this->provider = new CapturePaymentRequestCommandProvider($this->hostedFieldsCommandProvider);
+ }
+
+ public function testProvide_forPayplugWithHostedFieldsEnabled_delegatesToHostedFieldsProvider(): void
+ {
+ $paymentRequest = $this->paymentRequestWithConfig([
+ 'factoryName' => PayPlugGatewayFactory::FACTORY_NAME,
+ 'config' => [PayPlugGatewayFactory::HOSTED_FIELDS => true],
+ ]);
+
+ $expected = new CaptureHostedPaymentRequest('1');
+ $this->hostedFieldsCommandProvider->expects(self::once())->method('provide')
+ ->with($paymentRequest)->willReturn($expected);
+
+ self::assertSame($expected, $this->provider->provide($paymentRequest));
+ }
+
+ public function testProvide_forPayplugWithoutHostedFields_usesLegacyFlow(): void
+ {
+ $paymentRequest = $this->paymentRequestWithConfig([
+ 'factoryName' => PayPlugGatewayFactory::FACTORY_NAME,
+ 'config' => [PayPlugGatewayFactory::HOSTED_FIELDS => false],
+ ]);
+
+ $this->hostedFieldsCommandProvider->expects(self::never())->method('provide');
+
+ self::assertInstanceOf(CapturePaymentRequest::class, $this->provider->provide($paymentRequest));
+ }
+
+ /**
+ * Other gateways (Oney, Bancontact, ...) never satisfy the Hosted Fields check regardless of
+ * their own config shape, since it's gated on the `payplug` factory name first — behavior for
+ * them must stay exactly as it was before this delegation was introduced.
+ */
+ public function testProvide_forOtherGatewayFactory_neverDelegatesEvenIfConfigHappensToHaveHostedFieldsKey(): void
+ {
+ $paymentRequest = $this->paymentRequestWithConfig([
+ 'factoryName' => 'payplug_oney',
+ 'config' => [PayPlugGatewayFactory::HOSTED_FIELDS => true],
+ ]);
+
+ $this->hostedFieldsCommandProvider->expects(self::never())->method('provide');
+
+ self::assertInstanceOf(CapturePaymentRequest::class, $this->provider->provide($paymentRequest));
+ }
+
+ public function testProvide_whenNoGatewayConfig_usesLegacyFlow(): void
+ {
+ $paymentRequest = $this->paymentRequestWithConfig(null);
+
+ $this->hostedFieldsCommandProvider->expects(self::never())->method('provide');
+
+ self::assertInstanceOf(CapturePaymentRequest::class, $this->provider->provide($paymentRequest));
+ }
+
+ public function testProvide_whenAlreadyCreated_returnsOfflineCaptureRequest(): void
+ {
+ $paymentRequest = $this->paymentRequestWithConfig(
+ ['factoryName' => PayPlugGatewayFactory::FACTORY_NAME, 'config' => [PayPlugGatewayFactory::HOSTED_FIELDS => false]],
+ ['status' => 'captured', 'payment_id' => 'pay_1'],
+ );
+
+ self::assertInstanceOf(OfflineCapturePaymentRequest::class, $this->provider->provide($paymentRequest));
+ }
+
+ public function testSupports_onlyForCaptureAction(): void
+ {
+ $captureRequest = $this->createMock(PaymentRequestInterface::class);
+ $captureRequest->method('getAction')->willReturn(PaymentRequestInterface::ACTION_CAPTURE);
+ self::assertTrue($this->provider->supports($captureRequest));
+
+ $notifyRequest = $this->createMock(PaymentRequestInterface::class);
+ $notifyRequest->method('getAction')->willReturn(PaymentRequestInterface::ACTION_NOTIFY);
+ self::assertFalse($this->provider->supports($notifyRequest));
+ }
+}
diff --git a/tests/PHPUnit/Command/Provider/NotifyPaymentRequestCommandProviderTest.php b/tests/PHPUnit/Command/Provider/NotifyPaymentRequestCommandProviderTest.php
new file mode 100644
index 00000000..ef0d431b
--- /dev/null
+++ b/tests/PHPUnit/Command/Provider/NotifyPaymentRequestCommandProviderTest.php
@@ -0,0 +1,80 @@
+hostedFieldsCommandProvider = $this->createMock(PaymentRequestCommandProviderInterface::class);
+ $this->provider = new NotifyPaymentRequestCommandProvider($this->hostedFieldsCommandProvider);
+ }
+
+ public function testProvide_forPayplugWithHostedFieldsEnabled_delegatesToHostedFieldsProvider(): void
+ {
+ $paymentRequest = $this->paymentRequestWithConfig([
+ 'factoryName' => PayPlugGatewayFactory::FACTORY_NAME,
+ 'config' => [PayPlugGatewayFactory::HOSTED_FIELDS => true],
+ ]);
+
+ $expected = new NotifyHostedPaymentRequest('1');
+ $this->hostedFieldsCommandProvider->expects(self::once())->method('provide')
+ ->with($paymentRequest)->willReturn($expected);
+
+ self::assertSame($expected, $this->provider->provide($paymentRequest));
+ }
+
+ /**
+ * Other gateways (Oney, Bancontact, ...) never satisfy the Hosted Fields check regardless of
+ * their own config shape, since it's gated on the `payplug` factory name first — behavior for
+ * them must stay exactly as it was before this delegation was introduced.
+ */
+ public function testProvide_forOtherGatewayFactory_neverDelegatesEvenIfConfigHappensToHaveHostedFieldsKey(): void
+ {
+ $paymentRequest = $this->paymentRequestWithConfig([
+ 'factoryName' => 'payplug_oney',
+ 'config' => [PayPlugGatewayFactory::HOSTED_FIELDS => true],
+ ]);
+
+ $this->hostedFieldsCommandProvider->expects(self::never())->method('provide');
+
+ self::assertInstanceOf(NotifyPaymentRequest::class, $this->provider->provide($paymentRequest));
+ }
+
+ public function testProvide_whenNoGatewayConfig_usesLegacyFlow(): void
+ {
+ $paymentRequest = $this->paymentRequestWithConfig(null);
+
+ $this->hostedFieldsCommandProvider->expects(self::never())->method('provide');
+
+ self::assertInstanceOf(NotifyPaymentRequest::class, $this->provider->provide($paymentRequest));
+ }
+
+ public function testSupports_onlyForNotifyAction(): void
+ {
+ $notifyRequest = $this->createMock(PaymentRequestInterface::class);
+ $notifyRequest->method('getAction')->willReturn(PaymentRequestInterface::ACTION_NOTIFY);
+ self::assertTrue($this->provider->supports($notifyRequest));
+
+ $captureRequest = $this->createMock(PaymentRequestInterface::class);
+ $captureRequest->method('getAction')->willReturn(PaymentRequestInterface::ACTION_CAPTURE);
+ self::assertFalse($this->provider->supports($captureRequest));
+ }
+}
diff --git a/tests/PHPUnit/Command/Provider/PaymentRequestWithGatewayConfigTrait.php b/tests/PHPUnit/Command/Provider/PaymentRequestWithGatewayConfigTrait.php
new file mode 100644
index 00000000..91859e0f
--- /dev/null
+++ b/tests/PHPUnit/Command/Provider/PaymentRequestWithGatewayConfigTrait.php
@@ -0,0 +1,46 @@
+}|null $gatewayConfig
+ * @param array $details
+ */
+ private function paymentRequestWithConfig(
+ ?array $gatewayConfig,
+ array $details = [],
+ ): PaymentRequestInterface&MockObject
+ {
+ $method = $this->createMock(PaymentMethodInterface::class);
+ if (null !== $gatewayConfig) {
+ $config = $this->createMock(GatewayConfigInterface::class);
+ $config->method('getFactoryName')->willReturn($gatewayConfig['factoryName']);
+ $config->method('getConfig')->willReturn($gatewayConfig['config']);
+ $method->method('getGatewayConfig')->willReturn($config);
+ }
+
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getMethod')->willReturn($method);
+ $payment->method('getDetails')->willReturn($details);
+
+ $paymentRequest = $this->createMock(PaymentRequestInterface::class);
+ $paymentRequest->method('getPayment')->willReturn($payment);
+ $paymentRequest->method('getId')->willReturn('1');
+
+ return $paymentRequest;
+ }
+}
diff --git a/tests/PHPUnit/Command/Provider/StatusPaymentRequestCommandProviderTest.php b/tests/PHPUnit/Command/Provider/StatusPaymentRequestCommandProviderTest.php
new file mode 100644
index 00000000..248753df
--- /dev/null
+++ b/tests/PHPUnit/Command/Provider/StatusPaymentRequestCommandProviderTest.php
@@ -0,0 +1,87 @@
+requestStack = $this->createMock(RequestStack::class);
+ $this->hostedFieldsCommandProvider = $this->createMock(PaymentRequestCommandProviderInterface::class);
+ $this->provider = new StatusPaymentRequestCommandProvider($this->requestStack, $this->hostedFieldsCommandProvider);
+ }
+
+ public function testProvide_forPayplugWithHostedFieldsEnabled_delegatesToHostedFieldsProvider(): void
+ {
+ $paymentRequest = $this->paymentRequestWithConfig([
+ 'factoryName' => PayPlugGatewayFactory::FACTORY_NAME,
+ 'config' => [PayPlugGatewayFactory::HOSTED_FIELDS => true],
+ ]);
+
+ $expected = new StatusHostedPaymentRequest('1');
+ $this->hostedFieldsCommandProvider->expects(self::once())->method('provide')
+ ->with($paymentRequest)->willReturn($expected);
+ $this->requestStack->expects(self::never())->method('getCurrentRequest');
+
+ self::assertSame($expected, $this->provider->provide($paymentRequest));
+ }
+
+ /**
+ * Other gateways (Oney, Bancontact, ...) never satisfy the Hosted Fields check regardless of
+ * their own config shape, since it's gated on the `payplug` factory name first — behavior for
+ * them must stay exactly as it was before this delegation was introduced.
+ */
+ public function testProvide_forOtherGatewayFactory_neverDelegatesEvenIfConfigHappensToHaveHostedFieldsKey(): void
+ {
+ $paymentRequest = $this->paymentRequestWithConfig([
+ 'factoryName' => 'payplug_oney',
+ 'config' => [PayPlugGatewayFactory::HOSTED_FIELDS => true],
+ ]);
+ $this->requestStack->method('getCurrentRequest')->willReturn(null);
+
+ $this->hostedFieldsCommandProvider->expects(self::never())->method('provide');
+
+ self::assertInstanceOf(StatusPaymentRequest::class, $this->provider->provide($paymentRequest));
+ }
+
+ public function testProvide_whenNoGatewayConfig_usesLegacyFlow(): void
+ {
+ $paymentRequest = $this->paymentRequestWithConfig(null);
+ $this->requestStack->method('getCurrentRequest')->willReturn(null);
+
+ $this->hostedFieldsCommandProvider->expects(self::never())->method('provide');
+
+ self::assertInstanceOf(StatusPaymentRequest::class, $this->provider->provide($paymentRequest));
+ }
+
+ public function testSupports_onlyForStatusAction(): void
+ {
+ $statusRequest = $this->createMock(PaymentRequestInterface::class);
+ $statusRequest->method('getAction')->willReturn(PaymentRequestInterface::ACTION_STATUS);
+ self::assertTrue($this->provider->supports($statusRequest));
+
+ $captureRequest = $this->createMock(PaymentRequestInterface::class);
+ $captureRequest->method('getAction')->willReturn(PaymentRequestInterface::ACTION_CAPTURE);
+ self::assertFalse($this->provider->supports($captureRequest));
+ }
+}
diff --git a/tests/PHPUnit/Controller/IpnActionTest.php b/tests/PHPUnit/Controller/IpnActionTest.php
new file mode 100644
index 00000000..7b50599e
--- /dev/null
+++ b/tests/PHPUnit/Controller/IpnActionTest.php
@@ -0,0 +1,97 @@
+logger = $this->createMock(LoggerInterface::class);
+ $this->paymentNotificationHandler = $this->createMock(PaymentNotificationHandler::class);
+ $this->refundNotificationHandler = $this->createMock(RefundNotificationHandler::class);
+ $this->apiClientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class);
+ $this->paymentRepository = $this->createMock(PaymentRepositoryInterface::class);
+ $this->entityManager = $this->createMock(EntityManagerInterface::class);
+
+ $this->action = new IpnAction(
+ $this->logger,
+ $this->paymentNotificationHandler,
+ $this->refundNotificationHandler,
+ $this->apiClientFactory,
+ $this->paymentRepository,
+ $this->entityManager,
+ );
+ }
+
+ private function paymentWithGatewayConfig(): PaymentInterface&MockObject
+ {
+ $gatewayConfig = $this->createMock(GatewayConfigInterface::class);
+ $gatewayConfig->method('getFactoryName')->willReturn(PayPlugGatewayFactory::FACTORY_NAME);
+
+ $method = $this->createMock(PaymentMethodInterface::class);
+ $method->method('getGatewayConfig')->willReturn($gatewayConfig);
+
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getMethod')->willReturn($method);
+
+ return $payment;
+ }
+
+ public function testInvoke_forALegacyPayment_goesThroughTheSdk(): void
+ {
+ $payment = $this->paymentWithGatewayConfig();
+ $this->paymentRepository->method('findOneByPayPlugPaymentId')->willReturn($payment);
+
+ $request = Request::create('/payplug/ipn', 'POST', content: \json_encode(['id' => 'pay_1']));
+
+ $this->apiClientFactory->expects(self::once())->method('create')->with(PayPlugGatewayFactory::FACTORY_NAME)
+ ->willReturn($this->createMock(PayPlugApiClientInterface::class));
+
+ $response = $this->action->__invoke($request);
+
+ self::assertSame(200, $response->getStatusCode());
+ }
+
+ public function testInvoke_whenPaymentIsNotFound_returnsUnauthorized(): void
+ {
+ $this->paymentRepository->method('findOneByPayPlugPaymentId')->willReturn(null);
+
+ $request = Request::create('/payplug/ipn', 'POST', content: \json_encode(['id' => 'pay_1']));
+
+ $response = $this->action->__invoke($request);
+
+ self::assertSame(401, $response->getStatusCode());
+ }
+}
diff --git a/tests/PHPUnit/Controller/UnifiedApiIpnActionTest.php b/tests/PHPUnit/Controller/UnifiedApiIpnActionTest.php
new file mode 100644
index 00000000..d95658dd
--- /dev/null
+++ b/tests/PHPUnit/Controller/UnifiedApiIpnActionTest.php
@@ -0,0 +1,160 @@
+logger = $this->createMock(LoggerInterface::class);
+ $this->hostedFieldsWebhookNotificationHandler = $this->createMock(HostedFieldsWebhookNotificationHandler::class);
+ $this->paymentRepository = $this->createMock(PaymentRepositoryInterface::class);
+
+ $this->action = new UnifiedApiIpnAction(
+ $this->logger,
+ $this->hostedFieldsWebhookNotificationHandler,
+ $this->paymentRepository,
+ );
+ }
+
+ private function paymentWithGatewayConfig(bool $hostedFields): PaymentInterface&MockObject
+ {
+ $gatewayConfig = $this->createMock(GatewayConfigInterface::class);
+ $gatewayConfig->method('getFactoryName')->willReturn(PayPlugGatewayFactory::FACTORY_NAME);
+ $gatewayConfig->method('getConfig')->willReturn([PayPlugGatewayFactory::HOSTED_FIELDS => $hostedFields]);
+
+ $method = $this->createMock(PaymentMethodInterface::class);
+ $method->method('getGatewayConfig')->willReturn($gatewayConfig);
+
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getMethod')->willReturn($method);
+
+ return $payment;
+ }
+
+ public function testInvoke_forAHostedFieldsPayment_delegatesToTheWebhookNotificationHandler(): void
+ {
+ $payment = $this->paymentWithGatewayConfig(hostedFields: true);
+ $this->paymentRepository->method('findOneByPayPlugPaymentId')->with('pay_1')->willReturn($payment);
+
+ $request = Request::create('/payplug/v2/ipn', 'POST', content: \json_encode(['id' => 'pay_1', 'execCode' => '0000']));
+ $request->headers->set('Authorization', 'Bearer shared-secret');
+
+ $this->hostedFieldsWebhookNotificationHandler->expects(self::once())->method('treat')
+ ->with($payment, $request->getContent(), self::callback(static fn (array $headers): bool => 'Bearer shared-secret' === ($headers['authorization'] ?? null)));
+
+ $response = $this->action->__invoke($request);
+
+ self::assertSame(200, $response->getStatusCode());
+ }
+
+ public function testInvoke_forAHostedFieldsPayment_whenNotificationIsInvalid_logsAndStillReturns200(): void
+ {
+ $payment = $this->paymentWithGatewayConfig(hostedFields: true);
+ $this->paymentRepository->method('findOneByPayPlugPaymentId')->willReturn($payment);
+ $this->hostedFieldsWebhookNotificationHandler->method('treat')->willThrowException(new InvalidNotificationException('boom'));
+
+ $request = Request::create('/payplug/v2/ipn', 'POST', content: \json_encode(['id' => 'pay_1']));
+
+ $this->logger->expects(self::once())->method('error');
+
+ $response = $this->action->__invoke($request);
+
+ self::assertSame(200, $response->getStatusCode());
+ }
+
+ public function testInvoke_forANonUnifiedApiPayment_returnsUnauthorizedWithoutCallingTheWebhookNotificationHandler(): void
+ {
+ $payment = $this->paymentWithGatewayConfig(hostedFields: false);
+ $this->paymentRepository->method('findOneByPayPlugPaymentId')->willReturn($payment);
+
+ $request = Request::create('/payplug/v2/ipn', 'POST', content: \json_encode(['id' => 'pay_1']));
+
+ $this->hostedFieldsWebhookNotificationHandler->expects(self::never())->method('treat');
+
+ $response = $this->action->__invoke($request);
+
+ self::assertSame(401, $response->getStatusCode());
+ }
+
+ public function testInvoke_whenPaymentIsNotFound_returnsUnauthorized(): void
+ {
+ $this->paymentRepository->method('findOneByPayPlugPaymentId')->willReturn(null);
+
+ $request = Request::create('/payplug/v2/ipn', 'POST', content: \json_encode(['id' => 'pay_1']));
+
+ $response = $this->action->__invoke($request);
+
+ self::assertSame(401, $response->getStatusCode());
+ }
+
+ public function testInvoke_whenBodyHasNoId_returnsUnauthorized(): void
+ {
+ $this->paymentRepository->expects(self::never())->method('findOneByPayPlugPaymentId');
+
+ $request = Request::create('/payplug/v2/ipn', 'POST', content: '{}');
+
+ $response = $this->action->__invoke($request);
+
+ self::assertSame(401, $response->getStatusCode());
+ }
+
+ /**
+ * PayPlug's webhook can be delivered before CaptureHostedPaymentRequestHandler's own
+ * hosted_fields_payment_id/hosted_fields_operation_id write has committed (Sylius's
+ * doctrine_transaction messenger middleware only commits once that whole handler returns) —
+ * findOneByPayPlugPaymentId() briefly returns null for a payment that does exist.
+ */
+ public function testInvoke_whenPaymentNotYetVisibleOnFirstLookup_retriesAndStillDelegatesToTheWebhookNotificationHandler(): void
+ {
+ $payment = $this->paymentWithGatewayConfig(hostedFields: true);
+ $this->paymentRepository->expects(self::exactly(3))->method('findOneByPayPlugPaymentId')
+ ->with('pay_1')
+ ->willReturnOnConsecutiveCalls(null, null, $payment);
+
+ $request = Request::create('/payplug/v2/ipn', 'POST', content: \json_encode(['id' => 'pay_1', 'execCode' => '0000']));
+
+ $this->hostedFieldsWebhookNotificationHandler->expects(self::once())->method('treat')
+ ->with($payment, $request->getContent(), self::isType('array'));
+
+ $response = $this->action->__invoke($request);
+
+ self::assertSame(200, $response->getStatusCode());
+ }
+
+ public function testInvoke_whenPaymentNeverBecomesVisible_stopsRetryingAndReturnsUnauthorized(): void
+ {
+ $this->paymentRepository->expects(self::exactly(4))->method('findOneByPayPlugPaymentId')->with('pay_1')->willReturn(null);
+
+ $request = Request::create('/payplug/v2/ipn', 'POST', content: \json_encode(['id' => 'pay_1']));
+
+ $this->hostedFieldsWebhookNotificationHandler->expects(self::never())->method('treat');
+
+ $response = $this->action->__invoke($request);
+
+ self::assertSame(401, $response->getStatusCode());
+ }
+}
diff --git a/tests/PHPUnit/Entity/PayPlugOperationTest.php b/tests/PHPUnit/Entity/PayPlugOperationTest.php
new file mode 100644
index 00000000..68cc1173
--- /dev/null
+++ b/tests/PHPUnit/Entity/PayPlugOperationTest.php
@@ -0,0 +1,46 @@
+getOrderId());
+ self::assertSame('op_123', $operation->getOperationId());
+ self::assertSame('0000', $operation->getExecCode());
+ self::assertSame(PaymentOutcome::PAID, $operation->getOutcome());
+ self::assertSame(1000, $operation->getAmount());
+ self::assertFalse($operation->isTreated());
+ }
+
+ public function testMarkTreated_setsTreatedToTrue(): void
+ {
+ $operation = new PayPlugOperation('42', 'op_123', '0000', PaymentOutcome::PAID, 1000);
+
+ $operation->markTreated();
+
+ self::assertTrue($operation->isTreated());
+ }
+
+ public function testToOperationData_returnsEquivalentValueObject(): void
+ {
+ $operation = new PayPlugOperation('42', 'op_123', '0000', PaymentOutcome::PAID, 1000);
+
+ $data = $operation->toOperationData();
+
+ self::assertSame('op_123', $data->operationId);
+ self::assertSame('0000', $data->execCode);
+ self::assertSame(PaymentOutcome::PAID, $data->outcome);
+ self::assertSame(1000, $data->amount);
+ self::assertSame('42', $data->orderId);
+ }
+}
diff --git a/tests/PHPUnit/EventSubscriber/PostPaymentSelectEventSubscriberTest.php b/tests/PHPUnit/EventSubscriber/PostPaymentSelectEventSubscriberTest.php
new file mode 100644
index 00000000..f4212c4c
--- /dev/null
+++ b/tests/PHPUnit/EventSubscriber/PostPaymentSelectEventSubscriberTest.php
@@ -0,0 +1,405 @@
+requestStack = $this->createMock(RequestStack::class);
+ $this->entityManager = $this->createMock(EntityManagerInterface::class);
+ $this->stateMachine = $this->createMock(StateMachineInterface::class);
+ $this->hostedFieldsPaymentProcessor = $this->createMock(HostedFieldsPaymentProcessorInterface::class);
+
+ $this->subscriber = new PostPaymentSelectEventSubscriber(
+ $this->requestStack,
+ $this->entityManager,
+ $this->stateMachine,
+ $this->hostedFieldsPaymentProcessor,
+ );
+ }
+
+ public function testHandle_withHostedFieldsToken_delegatesToProcessorAndCompletesCheckout(): void
+ {
+ $request = Request::create('/checkout/select-payment', 'POST', [
+ 'hostedfields_token' => 'hf_token_abc',
+ 'hostedfields_selected_brand' => 'VISA',
+ 'hostedfields_save_card' => 'true',
+ 'hostedfields_last4' => '4242',
+ 'hostedfields_exp_month' => '12',
+ 'hostedfields_exp_year' => '2030',
+ 'hostedfields_country' => 'FR',
+ ]);
+ $request->attributes->set('_route', 'sylius_shop_checkout_select_payment');
+ $this->requestStack->method('getCurrentRequest')->willReturn($request);
+
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getMethod')->willReturn(
+ $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME, [PayPlugGatewayFactory::HOSTED_FIELDS => true]),
+ );
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getLastPayment')->willReturn($payment);
+ $payment->method('getOrder')->willReturn($order);
+
+ $event = $this->createMock(ResourceControllerEvent::class);
+ $event->method('getSubject')->willReturn($order);
+
+ $this->hostedFieldsPaymentProcessor->expects(self::once())
+ ->method('process')
+ ->with($payment, new HostedFieldsCaptureData('hf_token_abc', 'VISA', true, '4242', 12, 2030, 'FR'))
+ ;
+
+ $this->stateMachine->method('can')
+ ->with($order, OrderCheckoutTransitions::GRAPH, OrderCheckoutTransitions::TRANSITION_COMPLETE)
+ ->willReturn(true)
+ ;
+ $this->stateMachine->expects(self::once())->method('apply');
+ $this->entityManager->expects(self::once())->method('flush');
+
+ $this->subscriber->handle($event);
+ }
+
+ /**
+ * A crafted POST carrying a hosted fields token must not be able to complete checkout
+ * for a payment method that does not have Hosted Fields enabled.
+ */
+ public function testHandle_withHostedFieldsTokenButHostedFieldsNotEnabled_doesNotProcessNorCompleteCheckout(): void
+ {
+ $request = Request::create('/checkout/select-payment', 'POST', [
+ 'hostedfields_token' => 'hf_token_abc',
+ 'hostedfields_selected_brand' => 'VISA',
+ 'hostedfields_save_card' => 'true',
+ 'hostedfields_last4' => '4242',
+ 'hostedfields_exp_month' => '12',
+ 'hostedfields_exp_year' => '2030',
+ 'hostedfields_country' => 'FR',
+ ]);
+ $request->attributes->set('_route', 'sylius_shop_checkout_select_payment');
+ $this->requestStack->method('getCurrentRequest')->willReturn($request);
+
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getMethod')->willReturn(
+ $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME, [PayPlugGatewayFactory::HOSTED_FIELDS => false]),
+ );
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getLastPayment')->willReturn($payment);
+ $payment->method('getOrder')->willReturn($order);
+
+ $event = $this->createMock(ResourceControllerEvent::class);
+ $event->method('getSubject')->willReturn($order);
+
+ $this->hostedFieldsPaymentProcessor->expects(self::never())->method('process');
+ $this->stateMachine->expects(self::never())->method('apply');
+ $this->entityManager->expects(self::never())->method('flush');
+
+ $this->subscriber->handle($event);
+ }
+
+ /**
+ * A crafted POST carrying a hosted fields token must not be able to complete checkout
+ * for a payment method on a different gateway entirely, even if that gateway's config
+ * coincidentally has a truthy value under the same HOSTED_FIELDS key. The factory-name
+ * check must still gate first.
+ */
+ public function testHandle_withHostedFieldsTokenButDifferentFactory_doesNotProcessNorCompleteCheckout(): void
+ {
+ $request = Request::create('/checkout/select-payment', 'POST', [
+ 'hostedfields_token' => 'hf_token_abc',
+ 'hostedfields_selected_brand' => 'VISA',
+ 'hostedfields_save_card' => 'true',
+ 'hostedfields_last4' => '4242',
+ 'hostedfields_exp_month' => '12',
+ 'hostedfields_exp_year' => '2030',
+ 'hostedfields_country' => 'FR',
+ ]);
+ $request->attributes->set('_route', 'sylius_shop_checkout_select_payment');
+ $this->requestStack->method('getCurrentRequest')->willReturn($request);
+
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getMethod')->willReturn(
+ $this->buildPaymentMethod('offline', [PayPlugGatewayFactory::HOSTED_FIELDS => true]),
+ );
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getLastPayment')->willReturn($payment);
+ $payment->method('getOrder')->willReturn($order);
+
+ $event = $this->createMock(ResourceControllerEvent::class);
+ $event->method('getSubject')->willReturn($order);
+
+ $this->hostedFieldsPaymentProcessor->expects(self::never())->method('process');
+ $this->stateMachine->expects(self::never())->method('apply');
+ $this->entityManager->expects(self::never())->method('flush');
+
+ $this->subscriber->handle($event);
+ }
+
+ /**
+ * A payplug payment method whose config predates the Hosted Fields flag (key absent
+ * entirely, e.g. a legacy config) must not be treated as Hosted-Fields-enabled. Pins
+ * the `?? false` default explicitly, distinct from an explicit `false` value.
+ */
+ public function testHandle_withHostedFieldsTokenButConfigKeyAbsent_doesNotProcessNorCompleteCheckout(): void
+ {
+ $request = Request::create('/checkout/select-payment', 'POST', [
+ 'hostedfields_token' => 'hf_token_abc',
+ 'hostedfields_selected_brand' => 'VISA',
+ 'hostedfields_save_card' => 'true',
+ 'hostedfields_last4' => '4242',
+ 'hostedfields_exp_month' => '12',
+ 'hostedfields_exp_year' => '2030',
+ 'hostedfields_country' => 'FR',
+ ]);
+ $request->attributes->set('_route', 'sylius_shop_checkout_select_payment');
+ $this->requestStack->method('getCurrentRequest')->willReturn($request);
+
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getMethod')->willReturn(
+ $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME, []),
+ );
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getLastPayment')->willReturn($payment);
+ $payment->method('getOrder')->willReturn($order);
+
+ $event = $this->createMock(ResourceControllerEvent::class);
+ $event->method('getSubject')->willReturn($order);
+
+ $this->hostedFieldsPaymentProcessor->expects(self::never())->method('process');
+ $this->stateMachine->expects(self::never())->method('apply');
+ $this->entityManager->expects(self::never())->method('flush');
+
+ $this->subscriber->handle($event);
+ }
+
+ /**
+ * Pins the dispatch precedence that alterRequestConfigurationForInlineCardCapture() mirrors:
+ * when both token fields are present, handle() treats the request as Hosted Fields (no
+ * payment_id is ever written). Flipping this order without flipping the redirect ternary would
+ * send a payment_id-less order to sylius_shop_order_pay.
+ */
+ public function testHandle_withBothTokens_isProcessedAsHostedFields(): void
+ {
+ $request = Request::create('/checkout/select-payment', 'POST', [
+ 'payplug_integrated_payment_token' => 'pay_123',
+ 'hostedfields_token' => 'hf_token_abc',
+ 'hostedfields_selected_brand' => 'CB',
+ 'hostedfields_last4' => '4242',
+ 'hostedfields_exp_month' => '12',
+ 'hostedfields_exp_year' => '2030',
+ 'hostedfields_country' => 'FR',
+ ]);
+ $request->attributes->set('_route', 'sylius_shop_checkout_select_payment');
+ $this->requestStack->method('getCurrentRequest')->willReturn($request);
+
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getMethod')->willReturn(
+ $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME, [PayPlugGatewayFactory::HOSTED_FIELDS => true]),
+ );
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getLastPayment')->willReturn($payment);
+ $payment->method('getOrder')->willReturn($order);
+
+ $event = $this->createMock(ResourceControllerEvent::class);
+ $event->method('getSubject')->willReturn($order);
+
+ // Hosted Fields path: the processor is used and no payment_id is written to the details.
+ $this->hostedFieldsPaymentProcessor->expects(self::once())
+ ->method('process')
+ ->with($payment, new HostedFieldsCaptureData('hf_token_abc', 'CB', false, '4242', 12, 2030, 'FR'))
+ ;
+ $payment->expects(self::never())->method('setDetails');
+
+ $this->subscriber->handle($event);
+ }
+
+ public function testHandle_withHostedFieldsTokenButNoPaymentMethod_doesNotProcess(): void
+ {
+ $request = Request::create('/checkout/select-payment', 'POST', [
+ 'hostedfields_token' => 'hf_token_abc',
+ 'hostedfields_last4' => '4242',
+ 'hostedfields_exp_month' => '12',
+ 'hostedfields_exp_year' => '2030',
+ 'hostedfields_country' => 'FR',
+ ]);
+ $request->attributes->set('_route', 'sylius_shop_checkout_select_payment');
+ $this->requestStack->method('getCurrentRequest')->willReturn($request);
+
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getMethod')->willReturn(null);
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getLastPayment')->willReturn($payment);
+
+ $event = $this->createMock(ResourceControllerEvent::class);
+ $event->method('getSubject')->willReturn($order);
+
+ $this->hostedFieldsPaymentProcessor->expects(self::never())->method('process');
+ $this->entityManager->expects(self::never())->method('flush');
+
+ $this->subscriber->handle($event);
+ }
+
+ public function testHandle_withoutAnyToken_doesNothing(): void
+ {
+ $request = Request::create('/checkout/select-payment', 'POST');
+ $request->attributes->set('_route', 'sylius_shop_checkout_select_payment');
+ $this->requestStack->method('getCurrentRequest')->willReturn($request);
+
+ $payment = $this->createMock(PaymentInterface::class);
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getLastPayment')->willReturn($payment);
+
+ $event = $this->createMock(ResourceControllerEvent::class);
+ $event->method('getSubject')->willReturn($order);
+
+ $this->hostedFieldsPaymentProcessor->expects(self::never())->method('process');
+ $this->entityManager->expects(self::never())->method('flush');
+
+ $this->subscriber->handle($event);
+ }
+
+ // -------------------------------------------------------------------------
+ // alterRequestConfigurationForInlineCardCapture()
+ // -------------------------------------------------------------------------
+
+ /**
+ * Integrated Payment relays a real PayPlug payment_id, so the redirect override to
+ * `sylius_shop_order_pay` (Payum capture/status) must stay in place.
+ */
+ public function testAlterRequestConfiguration_withIntegratedPaymentToken_overridesRedirect(): void
+ {
+ $request = Request::create('/checkout/select-payment', 'POST', [
+ 'payplug_integrated_payment_token' => 'pay_123',
+ ]);
+ $request->attributes->set('_route', 'sylius_shop_checkout_select_payment');
+ $request->attributes->set('_sylius', ['redirect' => ['route' => 'sylius_shop_checkout_complete']]);
+
+ $this->subscriber->alterRequestConfigurationForInlineCardCapture($this->buildRequestEvent($request));
+
+ self::assertSame(
+ [
+ 'redirect' => [
+ 'route' => 'sylius_shop_order_pay',
+ 'parameters' => ['tokenValue' => 'resource.tokenValue'],
+ ],
+ ],
+ $request->attributes->get('_sylius'),
+ );
+ }
+
+ /**
+ * Both Integrated Payment and Hosted Fields target `sylius_shop_order_pay` (Payum
+ * capture/status for Integrated Payment; for Hosted Fields, the same `payplug`-tagged
+ * Capture/Notify/StatusPaymentRequestCommandProvider trio delegates to their
+ * Hosted-Fields-specific counterparts — see PayPlugGatewayFactory::isHostedFieldsConfig() —
+ * so the payment is actually created/confirmed through UPC.
+ */
+ public function testAlterRequestConfigurationForInlineCardCapture_forHostedFieldsToken_redirectsToOrderPay(): void
+ {
+ $request = Request::create('/checkout/select-payment', 'POST', [
+ 'hostedfields_token' => 'hf_token_abc',
+ 'hostedfields_selected_brand' => 'VISA',
+ ]);
+ $request->attributes->set('_route', 'sylius_shop_checkout_select_payment');
+ $request->attributes->set('_sylius', ['redirect' => ['route' => 'sylius_shop_checkout_complete']]);
+
+ $this->subscriber->alterRequestConfigurationForInlineCardCapture($this->buildRequestEvent($request));
+
+ self::assertSame(
+ [
+ 'redirect' => [
+ 'route' => 'sylius_shop_order_pay',
+ 'parameters' => ['tokenValue' => 'resource.tokenValue'],
+ ],
+ ],
+ $request->attributes->get('_sylius'),
+ );
+ }
+
+ /**
+ * A crafted request carrying both token fields is dispatched as Hosted Fields by handle()
+ * (it checks hasHostedFieldsToken() first). Since PRE-3551 both paths redirect to the same
+ * route, so this just pins that the redirect override still applies regardless of which
+ * token(s) are present.
+ */
+ public function testAlterRequestConfiguration_withBothTokens_followsHandleAndRedirectsToOrderPay(): void
+ {
+ $request = Request::create('/checkout/select-payment', 'POST', [
+ 'payplug_integrated_payment_token' => 'pay_123',
+ 'hostedfields_token' => 'hf_token_abc',
+ ]);
+ $request->attributes->set('_route', 'sylius_shop_checkout_select_payment');
+ $request->attributes->set('_sylius', []);
+
+ $this->subscriber->alterRequestConfigurationForInlineCardCapture($this->buildRequestEvent($request));
+
+ $syliusRequestConfig = $request->attributes->get('_sylius');
+ self::assertSame('sylius_shop_order_pay', $syliusRequestConfig['redirect']['route']);
+ }
+
+ public function testAlterRequestConfiguration_withoutAnyToken_leavesRedirectUntouched(): void
+ {
+ $request = Request::create('/checkout/select-payment', 'POST');
+ $request->attributes->set('_route', 'sylius_shop_checkout_select_payment');
+ $syliusRequestConfig = ['redirect' => ['route' => 'sylius_shop_checkout_complete']];
+ $request->attributes->set('_sylius', $syliusRequestConfig);
+
+ $this->subscriber->alterRequestConfigurationForInlineCardCapture($this->buildRequestEvent($request));
+
+ self::assertSame($syliusRequestConfig, $request->attributes->get('_sylius'));
+ }
+
+ // -------------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------------
+
+ private function buildRequestEvent(Request $request): RequestEvent
+ {
+ return new RequestEvent(
+ $this->createMock(HttpKernelInterface::class),
+ $request,
+ HttpKernelInterface::MAIN_REQUEST,
+ );
+ }
+
+ private function buildPaymentMethod(string $factoryName, array $config = []): PaymentMethodInterface&MockObject
+ {
+ $gatewayConfig = $this->createMock(GatewayConfigInterface::class);
+ $gatewayConfig->method('getFactoryName')->willReturn($factoryName);
+ $gatewayConfig->method('getConfig')->willReturn($config);
+
+ $paymentMethod = $this->createMock(PaymentMethodInterface::class);
+ $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig);
+
+ return $paymentMethod;
+ }
+}
diff --git a/tests/PHPUnit/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtensionFormSubmissionTest.php b/tests/PHPUnit/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtensionFormSubmissionTest.php
new file mode 100644
index 00000000..861aad54
--- /dev/null
+++ b/tests/PHPUnit/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtensionFormSubmissionTest.php
@@ -0,0 +1,391 @@
+ gatewayConfig -> config), because the extended type's own inherited
+ * AbstractGatewayConfigurationType::buildForm() PRE_SUBMIT listener walks
+ * getParent()->getParent() to reach the payment method entity and its "channels" field.
+ */
+final class PayPlugGatewayConfigurationTypeExtensionFormSubmissionTest extends TypeTestCase
+{
+ use ValidatorExtensionTrait;
+
+ private const ACCOUNT_ID_ERROR = 'payplug_sylius_payplug_plugin.form.account_id_required';
+
+ protected function getTypes(): array
+ {
+ $translator = $this->createMock(TranslatorInterface::class);
+ $translator->method('trans')->willReturnCallback(static fn (string $id) => $id);
+
+ $gatewayConfigRepository = $this->createMock(RepositoryInterface::class);
+ $gatewayConfigRepository->method('findOneBy')->willReturn(null);
+
+ $request = new Request();
+ $request->setSession(new Session(new MockArraySessionStorage()));
+ $requestStack = new RequestStack();
+ $requestStack->push($request);
+
+ return [
+ new PayPlugGatewayConfigurationType($translator, $gatewayConfigRepository, $requestStack),
+ ];
+ }
+
+ protected function getTypeExtensions(): array
+ {
+ $translator = $this->createMock(TranslatorInterface::class);
+ $translator->method('trans')->willReturnCallback(static fn (string $id) => $id);
+
+ return [
+ new PayPlugGatewayConfigurationTypeExtension($translator),
+ ];
+ }
+
+ public function testSubmit_hostedFieldsModeWithBlankIdentifier_isInvalidWithAccountIdError(): void
+ {
+ $form = $this->createRootForm();
+
+ $form->submit([
+ 'gatewayConfig' => [
+ 'config' => [
+ PayPlugGatewayFactory::ONE_CLICK => false,
+ PayPlugGatewayFactory::DEFERRED_CAPTURE => false,
+ PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS,
+ PayPlugGatewayFactory::HF_IDENTIFIER => '',
+ ],
+ ],
+ ]);
+
+ self::assertTrue($form->isSubmitted());
+ self::assertFalse($form->isValid(), 'Form must be invalid when hosted_fields is selected but the account id is blank.');
+
+ $configForm = $form->get('gatewayConfig')->get('config');
+
+ $identifierErrors = $configForm->get(PayPlugGatewayFactory::HF_IDENTIFIER)->getErrors();
+ self::assertCount(1, $identifierErrors);
+ self::assertSame(self::ACCOUNT_ID_ERROR, $identifierErrors[0]->getMessage());
+ }
+
+ /**
+ * The account id is now the only hosted-fields requirement — the SubMerchant ID field it used
+ * to be paired with is gone, so filling this one alone must be enough to save the form.
+ */
+ public function testSubmit_hostedFieldsModeWithIdentifierFilled_isValid(): void
+ {
+ $form = $this->createRootForm();
+
+ $form->submit([
+ 'gatewayConfig' => [
+ 'config' => [
+ PayPlugGatewayFactory::ONE_CLICK => false,
+ PayPlugGatewayFactory::DEFERRED_CAPTURE => false,
+ PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS,
+ PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_123',
+ ],
+ ],
+ ]);
+
+ self::assertTrue($form->isSubmitted());
+ self::assertTrue($form->isValid());
+ }
+
+ /**
+ * Regression coverage for "the radio reverts to redirected on reload": DISPLAY_MODE_FIELD is
+ * `mapped => false`, and the PRE_SET_DATA listener that used to pre-select it there got its
+ * setData() call silently overwritten by Symfony's own DataMapper::mapDataToForms(), which
+ * resets every unmapped child back to its configured (null) default immediately after
+ * PRE_SET_DATA dispatches, before POST_SET_DATA fires. Moving the pre-selection to
+ * POST_SET_DATA fixes it, since nothing runs after that to reset it again.
+ */
+ public function testSetData_existingIntegratedPaymentConfig_preselectsIntegratedPaymentRadio(): void
+ {
+ $form = $this->createRootForm();
+ $configForm = $form->get('gatewayConfig')->get('config');
+
+ $configForm->setData([
+ PayPlugGatewayFactory::ONE_CLICK => false,
+ PayPlugGatewayFactory::DEFERRED_CAPTURE => false,
+ PayPlugGatewayFactory::INTEGRATED_PAYMENT => true,
+ PayPlugGatewayFactory::HOSTED_FIELDS => false,
+ ]);
+
+ self::assertSame(
+ PayPlugGatewayFactory::DISPLAY_MODE_INTEGRATED_PAYMENT,
+ $configForm->get(PayPlugGatewayFactory::DISPLAY_MODE_FIELD)->getData(),
+ );
+ }
+
+ public function testSetData_existingHostedFieldsConfig_preselectsHostedFieldsRadio(): void
+ {
+ $form = $this->createRootForm();
+ $configForm = $form->get('gatewayConfig')->get('config');
+
+ $configForm->setData([
+ PayPlugGatewayFactory::ONE_CLICK => false,
+ PayPlugGatewayFactory::DEFERRED_CAPTURE => false,
+ PayPlugGatewayFactory::INTEGRATED_PAYMENT => false,
+ PayPlugGatewayFactory::HOSTED_FIELDS => true,
+ PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_123',
+ ]);
+
+ self::assertSame(
+ PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS,
+ $configForm->get(PayPlugGatewayFactory::DISPLAY_MODE_FIELD)->getData(),
+ );
+ }
+
+ public function testSetData_neitherFlagSet_leavesRadioUnselected(): void
+ {
+ $form = $this->createRootForm();
+ $configForm = $form->get('gatewayConfig')->get('config');
+
+ $configForm->setData([
+ PayPlugGatewayFactory::ONE_CLICK => false,
+ PayPlugGatewayFactory::DEFERRED_CAPTURE => false,
+ PayPlugGatewayFactory::INTEGRATED_PAYMENT => false,
+ PayPlugGatewayFactory::HOSTED_FIELDS => false,
+ ]);
+
+ self::assertNull($configForm->get(PayPlugGatewayFactory::DISPLAY_MODE_FIELD)->getData());
+ }
+
+ public function testSubmit_integratedPaymentMode_withBlankFields_isValid(): void
+ {
+ // The conditional requirement only applies to hosted_fields; other modes must not be
+ // affected by a blank identifier field.
+ $form = $this->createRootForm();
+
+ $form->submit([
+ 'gatewayConfig' => [
+ 'config' => [
+ PayPlugGatewayFactory::ONE_CLICK => false,
+ PayPlugGatewayFactory::DEFERRED_CAPTURE => false,
+ PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_INTEGRATED_PAYMENT,
+ PayPlugGatewayFactory::HF_IDENTIFIER => '',
+ ],
+ ],
+ ]);
+
+ self::assertTrue($form->isValid());
+ }
+
+ /**
+ * A payment method configured before the SubMerchant ID field was removed still carries
+ * `hfSubMerchantId` in its stored config. Re-saving it must not fail on the now-unknown key,
+ * and the leftover value is simply ignored — GatewayCredentialsResolver no longer reads it.
+ */
+ public function testSubmit_hostedFieldsModeWithALeftoverSubMerchantIdInStoredConfig_isValid(): void
+ {
+ $form = $this->createRootForm();
+ $configForm = $form->get('gatewayConfig')->get('config');
+ $configForm->setData([
+ PayPlugGatewayFactory::ONE_CLICK => false,
+ PayPlugGatewayFactory::DEFERRED_CAPTURE => false,
+ PayPlugGatewayFactory::HOSTED_FIELDS => true,
+ PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_123',
+ 'hfSubMerchantId' => 'sub_456',
+ ]);
+
+ $form->submit([
+ 'gatewayConfig' => [
+ 'config' => [
+ PayPlugGatewayFactory::ONE_CLICK => false,
+ PayPlugGatewayFactory::DEFERRED_CAPTURE => false,
+ PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS,
+ PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_123',
+ ],
+ ],
+ ]);
+
+ self::assertTrue($form->isValid());
+ }
+
+ /**
+ * PRE-3553: selecting a non-EUR channel while `integrated_payment` is selected must be
+ * rejected with a message specific to this feature ("...not compatible with Integrated
+ * Payment"), not the generic per-gateway `base_currency_not_euro` wording every other
+ * PayPlug-family gateway subtype still uses (Bancontact, American Express, Scalapay...).
+ */
+ public function testSubmit_integratedPaymentModeWithNonEurChannel_isInvalidWithCurrencyIncompatibleMessage(): void
+ {
+ $form = $this->createRootForm($this->buildChannels(['USD']));
+
+ // clearMissing=false: "channels" isn't part of this submitted payload (only
+ // gatewayConfig.config is), and the default clearMissing=true would otherwise call
+ // submit(null) on it regardless - wiping the Collection set via createRootForm() before
+ // the currency-check listener ever runs, even though it's not disabled.
+ $form->submit([
+ 'gatewayConfig' => [
+ 'config' => [
+ PayPlugGatewayFactory::ONE_CLICK => false,
+ PayPlugGatewayFactory::DEFERRED_CAPTURE => false,
+ PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_INTEGRATED_PAYMENT,
+ PayPlugGatewayFactory::HF_IDENTIFIER => '',
+ ],
+ ],
+ ], false);
+
+ self::assertFalse($form->isValid(), 'Form must be invalid when integrated_payment is selected but an associated channel is not EUR.');
+
+ // The error is added to the specific channel's own child sub-form (mirroring the real
+ // `channels` field being `multiple => true, expanded => true`, one child per channel),
+ // not directly to the "channels" form itself.
+ $channelErrors = $form->get('channels')->get('0')->getErrors();
+ self::assertCount(1, $channelErrors);
+ self::assertSame(
+ 'payplug_sylius_payplug_plugin.form.integrated_payment_currency_incompatible',
+ $channelErrors[0]->getMessage(),
+ );
+ }
+
+ /**
+ * The same non-EUR channel must NOT be rejected for hosted_fields or redirected mode — only
+ * integrated_payment requires every associated channel to be EUR.
+ */
+ public function testSubmit_hostedFieldsModeWithNonEurChannel_isValid(): void
+ {
+ $form = $this->createRootForm($this->buildChannels(['USD']));
+
+ $form->submit([
+ 'gatewayConfig' => [
+ 'config' => [
+ PayPlugGatewayFactory::ONE_CLICK => false,
+ PayPlugGatewayFactory::DEFERRED_CAPTURE => false,
+ PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS,
+ PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_123',
+ ],
+ ],
+ ], false);
+
+ self::assertTrue($form->isValid());
+ }
+
+ /**
+ * @param list $currencyCodes
+ *
+ * @return ArrayCollection
+ */
+ private function buildChannels(array $currencyCodes): ArrayCollection
+ {
+ $channels = [];
+ foreach ($currencyCodes as $index => $currencyCode) {
+ $currency = $this->createMock(CurrencyInterface::class);
+ $currency->method('getCode')->willReturn($currencyCode);
+
+ /** @var ChannelInterface&MockObject $channel */
+ $channel = $this->createMock(ChannelInterface::class);
+ $channel->method('getCode')->willReturn('channel_' . $index);
+ $channel->method('getBaseCurrency')->willReturn($currency);
+
+ $channels[] = $channel;
+ }
+
+ return new ArrayCollection($channels);
+ }
+
+ /**
+ * Builds a minimal but realistic 3-level tree: root (the PaymentMethod form, exposing
+ * "channels") -> gatewayConfig -> config (PayPlugGatewayConfigurationType, the type under
+ * test). This mirrors production nesting closely enough to exercise
+ * AbstractGatewayConfigurationType's inherited PRE_SUBMIT listener (which the extended type
+ * still carries) without it fatal-erroring on missing parents.
+ *
+ * @param ArrayCollection|null $channels Real channel data for the
+ * "channels" field, needed by
+ * tests exercising the currency
+ * check. Left null (an unset,
+ * non-Collection field) for tests
+ * that don't care about it.
+ */
+ private function createRootForm(?ArrayCollection $channels = null): \Symfony\Component\Form\FormInterface
+ {
+ $paymentMethod = new class() {
+ public function getId(): ?int
+ {
+ // Non-null so AbstractGatewayConfigurationType::checkCreationRequirements()
+ // short-circuits without needing a configured gatewayConfigRepository.
+ return 1;
+ }
+ };
+
+ $root = $this->factory->createBuilder(FormType::class, $paymentMethod, ['data_class' => null]);
+ if (null !== $channels) {
+ // A bare FormType (no data_class) round-trips setData()/getData() untouched - unlike
+ // TextType, it has no model-to-view transformer that would choke on a Collection. It
+ // needs one child per channel, named by its collection key, because the production
+ // currency-check listener does `$formChannels->get((string) $key)->addError(...)` -
+ // mirroring the real `channels` field being a `multiple => true, expanded => true`
+ // ChoiceType, which creates one child sub-form per choice - and, critically, sets
+ // `error_bubbling => false` on those children (ChoiceType.php), unlike a bare
+ // FormType's default of bubbling errors up to its parent when compound. Without this,
+ // addError() on a channel's sub-form bubbles all the way to the root instead of
+ // staying on that sub-form - purely a test-double mismatch, not a production concern.
+ // NOTE: this field is NOT `disabled => true` - Form::isValid() unconditionally returns
+ // true for a disabled form regardless of its errors, and Form::getErrors(true) skips
+ // any child that isSubmitted() && isValid() when aggregating - together those two
+ // rules mean a disabled "channels" would make the whole root form always report valid
+ // no matter what error is added deep inside it. Its pre-set data survives submission
+ // instead via `$form->submit($data, false)` (clearMissing=false) at the call site,
+ // which is not disabled but also isn't reset by an absent key.
+ $channelsBuilder = $root->create('channels', FormType::class, [
+ 'mapped' => false,
+ 'data_class' => null,
+ ]);
+ foreach ($channels as $key => $channel) {
+ $channelsBuilder->add((string) $key, FormType::class, [
+ 'mapped' => false,
+ 'data_class' => null,
+ 'error_bubbling' => false,
+ ]);
+ }
+ $root->add($channelsBuilder);
+ } else {
+ $root->add('channels', TextType::class, ['mapped' => false]);
+ }
+
+ $gatewayConfig = $root->create('gatewayConfig', FormType::class, ['mapped' => false]);
+ $gatewayConfig->add('config', PayPlugGatewayConfigurationType::class);
+
+ $root->add($gatewayConfig);
+
+ $form = $root->getForm();
+ if (null !== $channels) {
+ // Force the root's own lazy defaultDataSet initialization (and its mapDataToForms
+ // cascade, which would otherwise reset the unmapped "channels" field to null the
+ // first time anything touches this form) to run now, BEFORE setting "channels"'s
+ // real data below - so our setData() call is the last word, not overwritten by it.
+ $form->getData();
+ $form->get('channels')->setData($channels);
+ }
+
+ return $form;
+ }
+}
diff --git a/tests/PHPUnit/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtensionTest.php b/tests/PHPUnit/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtensionTest.php
new file mode 100644
index 00000000..cd5561a2
--- /dev/null
+++ b/tests/PHPUnit/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtensionTest.php
@@ -0,0 +1,118 @@
+extension = new PayPlugGatewayConfigurationTypeExtension($this->createMock(TranslatorInterface::class));
+ }
+
+ public function testBuildForm_addsOneClickCheckboxField(): void
+ {
+ [, $addCalls] = $this->buildFormAndCollectAddCalls();
+
+ [$name, $type, $options] = $addCalls[0];
+ self::assertSame(PayPlugGatewayFactory::ONE_CLICK, $name);
+ self::assertSame(CheckboxType::class, $type);
+ self::assertSame('payplug_sylius_payplug_plugin.form.one_click_enable', $options['label']);
+ self::assertFalse($options['required']);
+ }
+
+ public function testBuildForm_addsDeferredCaptureCheckboxField(): void
+ {
+ [, $addCalls] = $this->buildFormAndCollectAddCalls();
+
+ [$name, $type, $options] = $addCalls[1];
+ self::assertSame(PayPlugGatewayFactory::DEFERRED_CAPTURE, $name);
+ self::assertSame(CheckboxType::class, $type);
+ self::assertSame('payplug_sylius_payplug_plugin.form.deferred_capture_enable', $options['label']);
+ self::assertFalse($options['required']);
+ }
+
+ public function testBuildForm_addsDisplayModeChoiceField(): void
+ {
+ [, $addCalls] = $this->buildFormAndCollectAddCalls();
+
+ [$name, $type, $options] = $addCalls[2];
+ self::assertSame(PayPlugGatewayFactory::DISPLAY_MODE_FIELD, $name);
+ self::assertSame(ChoiceType::class, $type);
+ self::assertFalse($options['mapped']);
+ self::assertFalse($options['required']);
+ self::assertTrue($options['expanded']);
+ self::assertSame(
+ [
+ 'payplug_sylius_payplug_plugin.form.integrated_payment_enable' => PayPlugGatewayFactory::DISPLAY_MODE_INTEGRATED_PAYMENT,
+ 'payplug_sylius_payplug_plugin.ui.hosted_fields_option' => PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS,
+ ],
+ $options['choices'],
+ );
+ }
+
+ public function testBuildForm_addsHfIdentifierTextField(): void
+ {
+ [, $addCalls] = $this->buildFormAndCollectAddCalls();
+
+ [$name, $type, $options] = $addCalls[3];
+ self::assertSame(PayPlugGatewayFactory::HF_IDENTIFIER, $name);
+ self::assertSame(TextType::class, $type);
+ self::assertSame('payplug_sylius_payplug_plugin.ui.hf_identifier_label', $options['label']);
+ self::assertFalse($options['required']);
+ }
+
+ /**
+ * The SubMerchant ID field was removed once UPC made `submerchantExternalId` optional: only the
+ * EUR MID configurations carry a submerchant, and Hosted Fields here targets the multi-currency
+ * ones, so the key is omitted from every payload rather than sent empty. HF_IDENTIFIER is the
+ * last field added.
+ */
+ public function testBuildForm_addsNoFieldAfterHfIdentifier(): void
+ {
+ [, $addCalls] = $this->buildFormAndCollectAddCalls();
+
+ self::assertArrayNotHasKey(4, $addCalls);
+ }
+
+ public function testGetExtendedTypes_returnsPayPlugGatewayConfigurationType(): void
+ {
+ self::assertSame([PayPlugGatewayConfigurationType::class], PayPlugGatewayConfigurationTypeExtension::getExtendedTypes());
+ }
+
+ /**
+ * @return array{0: FormBuilderInterface, 1: array}>}
+ */
+ private function buildFormAndCollectAddCalls(): array
+ {
+ $builder = $this->createMock(FormBuilderInterface::class);
+
+ $addCalls = [];
+ $builder
+ ->method('add')
+ ->willReturnCallback(function ($name, $type = null, array $options = []) use (&$addCalls, $builder) {
+ $addCalls[] = [$name, $type, $options];
+
+ return $builder;
+ })
+ ;
+ $builder->method('addEventListener')->willReturn($builder);
+
+ $this->extension->buildForm($builder, []);
+
+ return [$builder, $addCalls];
+ }
+}
diff --git a/tests/PHPUnit/Gateway/Form/Extension/ScalapayGatewayConfigurationTypeExtensionTest.php b/tests/PHPUnit/Gateway/Form/Extension/ScalapayGatewayConfigurationTypeExtensionTest.php
new file mode 100644
index 00000000..5e5f636d
--- /dev/null
+++ b/tests/PHPUnit/Gateway/Form/Extension/ScalapayGatewayConfigurationTypeExtensionTest.php
@@ -0,0 +1,71 @@
+extension = new ScalapayGatewayConfigurationTypeExtension();
+ }
+
+ public function testBuildForm_addsMinAmountMoneyField(): void
+ {
+ [, $addCalls] = $this->buildFormAndCollectAddCalls();
+
+ [$name, $type, $options] = $addCalls[0];
+ self::assertSame(ScalapayGatewayFactory::MIN_AMOUNT, $name);
+ self::assertSame(MoneyType::class, $type);
+ self::assertSame('EUR', $options['currency']);
+ self::assertFalse($options['required']);
+ }
+
+ public function testBuildForm_addsMaxAmountMoneyField(): void
+ {
+ [, $addCalls] = $this->buildFormAndCollectAddCalls();
+
+ [$name, $type, $options] = $addCalls[1];
+ self::assertSame(ScalapayGatewayFactory::MAX_AMOUNT, $name);
+ self::assertSame(MoneyType::class, $type);
+ self::assertSame('EUR', $options['currency']);
+ self::assertFalse($options['required']);
+ }
+
+ public function testGetExtendedTypes_returnsScalapayGatewayConfigurationType(): void
+ {
+ self::assertSame([ScalapayGatewayConfigurationType::class], ScalapayGatewayConfigurationTypeExtension::getExtendedTypes());
+ }
+
+ /**
+ * @return array{0: FormBuilderInterface, 1: array}>}
+ */
+ private function buildFormAndCollectAddCalls(): array
+ {
+ $builder = $this->createMock(FormBuilderInterface::class);
+
+ $addCalls = [];
+ $builder
+ ->method('add')
+ ->willReturnCallback(function ($name, $type = null, array $options = []) use (&$addCalls, $builder) {
+ $addCalls[] = [$name, $type, $options];
+
+ return $builder;
+ })
+ ;
+
+ $this->extension->buildForm($builder, []);
+
+ return [$builder, $addCalls];
+ }
+}
diff --git a/tests/PHPUnit/Gateway/Form/Type/AbstractGatewayConfigurationTypeTest.php b/tests/PHPUnit/Gateway/Form/Type/AbstractGatewayConfigurationTypeTest.php
new file mode 100644
index 00000000..619aa70a
--- /dev/null
+++ b/tests/PHPUnit/Gateway/Form/Type/AbstractGatewayConfigurationTypeTest.php
@@ -0,0 +1,129 @@
+gatewayConfigRepository = $this->createMock(RepositoryInterface::class);
+ $this->translator = $this->createMock(TranslatorInterface::class);
+ $this->translator->method('trans')->willReturnCallback(static fn (string $id) => $id);
+
+ $this->type = new AbstractGatewayConfigurationType(
+ $this->translator,
+ $this->gatewayConfigRepository,
+ $this->createMock(RequestStack::class),
+ );
+ }
+
+ /**
+ * Every PayPlug-family factory, including `payplug` itself, is limited to one PaymentMethod.
+ */
+ public function testCanBeCreated_otherFactoryAlreadyConfigured_isRefused(): void
+ {
+ $this->gatewayConfigRepository
+ ->expects(self::once())
+ ->method('findOneBy')
+ ->with(['factoryName' => OneyGatewayFactory::FACTORY_NAME])
+ ->willReturn($this->createMock(GatewayConfigInterface::class))
+ ;
+
+ self::assertFalse($this->canBeCreated(OneyGatewayFactory::FACTORY_NAME));
+ }
+
+ public function testCanBeCreated_otherFactoryNotYetConfigured_isAllowed(): void
+ {
+ $this->gatewayConfigRepository
+ ->expects(self::once())
+ ->method('findOneBy')
+ ->with(['factoryName' => OneyGatewayFactory::FACTORY_NAME])
+ ->willReturn(null)
+ ;
+
+ self::assertTrue($this->canBeCreated(OneyGatewayFactory::FACTORY_NAME));
+ }
+
+ private function canBeCreated(string $factoryName): bool
+ {
+ $method = new \ReflectionMethod(AbstractGatewayConfigurationType::class, 'canBeCreated');
+ $method->setAccessible(true);
+
+ /** @var bool $result */
+ $result = $method->invoke($this->type, $factoryName);
+
+ return $result;
+ }
+
+ /**
+ * Default hook implementation: every gateway subtype that doesn't override it keeps
+ * today's behavior of always enforcing the base currency.
+ */
+ public function testShouldValidateBaseCurrency_defaultImplementation_alwaysReturnsTrue(): void
+ {
+ self::assertTrue($this->shouldValidateBaseCurrency([]));
+ self::assertTrue($this->shouldValidateBaseCurrency(['anything' => 'irrelevant']));
+ }
+
+ private function shouldValidateBaseCurrency(array $data): bool
+ {
+ $method = new \ReflectionMethod(AbstractGatewayConfigurationType::class, 'shouldValidateBaseCurrency');
+ $method->setAccessible(true);
+
+ /** @var bool $result */
+ $result = $method->invoke($this->type, $data);
+
+ return $result;
+ }
+
+ /**
+ * Default hook implementation: every gateway subtype that doesn't override it keeps today's
+ * generic per-gateway wording (only `PayPlugGatewayConfigurationType` overrides this, for a
+ * message specific to Integrated Payment).
+ */
+ public function testBaseCurrencyViolationMessage_defaultImplementation_returnsGenericKey(): void
+ {
+ $channel = $this->createMock(ChannelInterface::class);
+ $channel->method('getCode')->willReturn('channel_code');
+
+ self::assertSame(
+ 'payplug_sylius_payplug_plugin.form.base_currency_not_euro',
+ $this->baseCurrencyViolationMessage($channel),
+ );
+ }
+
+ private function baseCurrencyViolationMessage(ChannelInterface $channel): string
+ {
+ $method = new \ReflectionMethod(AbstractGatewayConfigurationType::class, 'baseCurrencyViolationMessage');
+ $method->setAccessible(true);
+
+ /** @var string $result */
+ $result = $method->invoke($this->type, $channel);
+
+ return $result;
+ }
+}
diff --git a/tests/PHPUnit/Gateway/Form/Type/PayPlugGatewayConfigurationTypeTest.php b/tests/PHPUnit/Gateway/Form/Type/PayPlugGatewayConfigurationTypeTest.php
new file mode 100644
index 00000000..882ac793
--- /dev/null
+++ b/tests/PHPUnit/Gateway/Form/Type/PayPlugGatewayConfigurationTypeTest.php
@@ -0,0 +1,90 @@
+translator = $this->createMock(TranslatorInterface::class);
+ $this->translator->method('trans')->willReturnCallback(static fn (string $id) => $id);
+
+ $this->type = new PayPlugGatewayConfigurationType(
+ $this->translator,
+ $this->createMock(RepositoryInterface::class),
+ $this->createMock(RequestStack::class),
+ );
+ }
+
+ public function testShouldValidateBaseCurrency_integratedPaymentSelected_returnsTrue(): void
+ {
+ self::assertTrue($this->shouldValidateBaseCurrency([
+ PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_INTEGRATED_PAYMENT,
+ ]));
+ }
+
+ public function testShouldValidateBaseCurrency_hostedFieldsSelected_returnsFalse(): void
+ {
+ self::assertFalse($this->shouldValidateBaseCurrency([
+ PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS,
+ ]));
+ }
+
+ public function testShouldValidateBaseCurrency_noModeSelected_returnsFalse(): void
+ {
+ self::assertFalse($this->shouldValidateBaseCurrency([]));
+ }
+
+ /**
+ * PRE-3553: this must be a message specific to Integrated Payment, not the generic
+ * `base_currency_not_euro` wording used by every other gateway subtype - since
+ * shouldValidateBaseCurrency() above only lets this fire when integrated_payment is
+ * selected, it doesn't need to branch on mode itself.
+ */
+ public function testBaseCurrencyViolationMessage_returnsIntegratedPaymentSpecificKey(): void
+ {
+ $channel = $this->createMock(ChannelInterface::class);
+
+ self::assertSame(
+ 'payplug_sylius_payplug_plugin.form.integrated_payment_currency_incompatible',
+ $this->baseCurrencyViolationMessage($channel),
+ );
+ }
+
+ private function shouldValidateBaseCurrency(array $data): bool
+ {
+ $method = new \ReflectionMethod(PayPlugGatewayConfigurationType::class, 'shouldValidateBaseCurrency');
+ $method->setAccessible(true);
+
+ /** @var bool $result */
+ $result = $method->invoke($this->type, $data);
+
+ return $result;
+ }
+
+ private function baseCurrencyViolationMessage(ChannelInterface $channel): string
+ {
+ $method = new \ReflectionMethod(PayPlugGatewayConfigurationType::class, 'baseCurrencyViolationMessage');
+ $method->setAccessible(true);
+
+ /** @var string $result */
+ $result = $method->invoke($this->type, $channel);
+
+ return $result;
+ }
+}
diff --git a/tests/PHPUnit/Gateway/PayPlugGatewayFactoryTest.php b/tests/PHPUnit/Gateway/PayPlugGatewayFactoryTest.php
new file mode 100644
index 00000000..7a1ae459
--- /dev/null
+++ b/tests/PHPUnit/Gateway/PayPlugGatewayFactoryTest.php
@@ -0,0 +1,131 @@
+ true]),
+ );
+ }
+
+ public function testResolveDisplayMode_hostedFieldsTrueAndIntegratedPaymentTrue_hostedFieldsWins(): void
+ {
+ self::assertSame(
+ PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS,
+ PayPlugGatewayFactory::resolveDisplayMode([
+ PayPlugGatewayFactory::HOSTED_FIELDS => true,
+ PayPlugGatewayFactory::INTEGRATED_PAYMENT => true,
+ ]),
+ );
+ }
+
+ public function testResolveDisplayMode_integratedPaymentTrue_returnsIntegratedPayment(): void
+ {
+ self::assertSame(
+ PayPlugGatewayFactory::DISPLAY_MODE_INTEGRATED_PAYMENT,
+ PayPlugGatewayFactory::resolveDisplayMode([PayPlugGatewayFactory::INTEGRATED_PAYMENT => true]),
+ );
+ }
+
+ public function testResolveDisplayMode_neitherFlagSet_returnsNull(): void
+ {
+ self::assertNull(PayPlugGatewayFactory::resolveDisplayMode([]));
+ }
+
+ public function testResolveDisplayMode_bothFlagsFalse_returnsNull(): void
+ {
+ self::assertNull(PayPlugGatewayFactory::resolveDisplayMode([
+ PayPlugGatewayFactory::HOSTED_FIELDS => false,
+ PayPlugGatewayFactory::INTEGRATED_PAYMENT => false,
+ ]));
+ }
+
+ // -------------------------------------------------------------------------
+ // resolveDisplayModeFlags()
+ // -------------------------------------------------------------------------
+
+ public function testResolveDisplayModeFlags_hostedFields_setsHostedFieldsOnlyTrue(): void
+ {
+ self::assertSame(
+ [PayPlugGatewayFactory::INTEGRATED_PAYMENT => false, PayPlugGatewayFactory::HOSTED_FIELDS => true],
+ PayPlugGatewayFactory::resolveDisplayModeFlags(PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS),
+ );
+ }
+
+ public function testResolveDisplayModeFlags_integratedPayment_setsIntegratedPaymentOnlyTrue(): void
+ {
+ self::assertSame(
+ [PayPlugGatewayFactory::INTEGRATED_PAYMENT => true, PayPlugGatewayFactory::HOSTED_FIELDS => false],
+ PayPlugGatewayFactory::resolveDisplayModeFlags(PayPlugGatewayFactory::DISPLAY_MODE_INTEGRATED_PAYMENT),
+ );
+ }
+
+ public function testResolveDisplayModeFlags_null_setsBothFalse(): void
+ {
+ self::assertSame(
+ [PayPlugGatewayFactory::INTEGRATED_PAYMENT => false, PayPlugGatewayFactory::HOSTED_FIELDS => false],
+ PayPlugGatewayFactory::resolveDisplayModeFlags(null),
+ );
+ }
+
+ public function testResolveDisplayModeFlags_unknownValue_setsBothFalse(): void
+ {
+ self::assertSame(
+ [PayPlugGatewayFactory::INTEGRATED_PAYMENT => false, PayPlugGatewayFactory::HOSTED_FIELDS => false],
+ PayPlugGatewayFactory::resolveDisplayModeFlags('not_a_real_mode'),
+ );
+ }
+
+ // -------------------------------------------------------------------------
+ // missingHostedFieldsRequirements()
+ // -------------------------------------------------------------------------
+
+ public function testMissingHostedFieldsRequirements_hostedFieldsNotSelected_returnsEmpty(): void
+ {
+ self::assertSame([], PayPlugGatewayFactory::missingHostedFieldsRequirements([
+ PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_INTEGRATED_PAYMENT,
+ ]));
+ }
+
+ public function testMissingHostedFieldsRequirements_hostedFieldsSelectedIdentifierMissing_returnsIdentifier(): void
+ {
+ self::assertSame(
+ [PayPlugGatewayFactory::HF_IDENTIFIER],
+ PayPlugGatewayFactory::missingHostedFieldsRequirements([
+ PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS,
+ ]),
+ );
+ }
+
+ public function testMissingHostedFieldsRequirements_hostedFieldsSelectedIdentifierBlank_returnsIdentifier(): void
+ {
+ self::assertSame(
+ [PayPlugGatewayFactory::HF_IDENTIFIER],
+ PayPlugGatewayFactory::missingHostedFieldsRequirements([
+ PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS,
+ PayPlugGatewayFactory::HF_IDENTIFIER => ' ',
+ ]),
+ );
+ }
+
+ public function testMissingHostedFieldsRequirements_hostedFieldsSelectedIdentifierFilled_returnsEmpty(): void
+ {
+ self::assertSame([], PayPlugGatewayFactory::missingHostedFieldsRequirements([
+ PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS,
+ PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_123',
+ ]));
+ }
+}
diff --git a/tests/PHPUnit/Gateway/Validator/Constraints/IsCanSavePaymentMethodValidatorTest.php b/tests/PHPUnit/Gateway/Validator/Constraints/IsCanSavePaymentMethodValidatorTest.php
new file mode 100644
index 00000000..782ad9c6
--- /dev/null
+++ b/tests/PHPUnit/Gateway/Validator/Constraints/IsCanSavePaymentMethodValidatorTest.php
@@ -0,0 +1,136 @@
+apiClientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class);
+
+ return new IsCanSavePaymentMethodValidator($this->apiClientFactory);
+ }
+
+ /**
+ * @dataProvider skipListedFactoryProvider
+ */
+ public function testValidate_skipListedFactory_noViolationAndAccountNeverInspected(string $factoryName): void
+ {
+ $apiClient = $this->createMock(PayPlugApiClientInterface::class);
+ $apiClient->expects(self::never())->method('getAccount');
+
+ $this->apiClientFactory
+ ->expects(self::once())
+ ->method('createForPaymentMethod')
+ ->willReturn($apiClient)
+ ;
+
+ $this->validator->validate($this->buildPaymentMethod($factoryName), new IsCanSavePaymentMethod());
+
+ $this->assertNoViolation();
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function skipListedFactoryProvider(): iterable
+ {
+ yield 'payplug' => [PayPlugGatewayFactory::FACTORY_NAME];
+ yield 'payplug_oney' => [OneyGatewayFactory::FACTORY_NAME];
+ }
+
+ public function testValidate_nonSkipListedFactory_notEnabledOnAccount_raisesNoAccessViolation(): void
+ {
+ $apiClient = $this->mockApiClientWithAccount([
+ 'is_live' => true,
+ 'payment_methods' => [
+ 'scalapay' => ['enabled' => false],
+ ],
+ ]);
+ $this->apiClientFactory->method('createForPaymentMethod')->willReturn($apiClient);
+
+ $constraint = new IsCanSavePaymentMethod();
+ $this->validator->validate($this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME), $constraint);
+
+ $this->buildViolation(sprintf($constraint->noAccessMessage, ScalapayGatewayFactory::FACTORY_NAME))
+ ->assertRaised()
+ ;
+ }
+
+ public function testValidate_nonSkipListedFactory_enabledButNotLive_raisesNoTestKeyViolation(): void
+ {
+ $apiClient = $this->mockApiClientWithAccount([
+ 'is_live' => false,
+ 'payment_methods' => [
+ 'scalapay' => ['enabled' => true],
+ ],
+ ]);
+ $this->apiClientFactory->method('createForPaymentMethod')->willReturn($apiClient);
+
+ $constraint = new IsCanSavePaymentMethod();
+ $this->validator->validate($this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME), $constraint);
+
+ $this->buildViolation(sprintf($constraint->noTestKeyMessage, ScalapayGatewayFactory::FACTORY_NAME))
+ ->assertRaised()
+ ;
+ }
+
+ public function testValidate_nonSkipListedFactory_enabledAndLive_noViolation(): void
+ {
+ $apiClient = $this->mockApiClientWithAccount([
+ 'is_live' => true,
+ 'payment_methods' => [
+ 'scalapay' => ['enabled' => true],
+ ],
+ ]);
+ $this->apiClientFactory->method('createForPaymentMethod')->willReturn($apiClient);
+
+ $this->validator->validate($this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME), new IsCanSavePaymentMethod());
+
+ $this->assertNoViolation();
+ }
+
+ private function mockApiClientWithAccount(array $account): PayPlugApiClientInterface&MockObject
+ {
+ $apiClient = $this->createMock(PayPlugApiClientInterface::class);
+ $apiClient->method('getAccount')->willReturn($account);
+
+ return $apiClient;
+ }
+
+ private function buildPaymentMethod(string $factoryName): PaymentMethodInterface&MockObject
+ {
+ $gatewayConfig = $this->createMock(GatewayConfigInterface::class);
+ $gatewayConfig->method('getFactoryName')->willReturn($factoryName);
+
+ $paymentMethod = $this->createMock(PaymentMethodInterface::class);
+ $paymentMethod->method('isEnabled')->willReturn(true);
+ $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig);
+ $paymentMethod->method('getChannels')->willReturn(new ArrayCollection());
+
+ return $paymentMethod;
+ }
+}
diff --git a/tests/PHPUnit/Gateway/Validator/Constraints/IsScalapayAmountRangeValidValidatorTest.php b/tests/PHPUnit/Gateway/Validator/Constraints/IsScalapayAmountRangeValidValidatorTest.php
new file mode 100644
index 00000000..3574934f
--- /dev/null
+++ b/tests/PHPUnit/Gateway/Validator/Constraints/IsScalapayAmountRangeValidValidatorTest.php
@@ -0,0 +1,255 @@
+apiClientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+
+ return new IsScalapayAmountRangeValidValidator($this->apiClientFactory, new AccountAmountRangeResolver(), $this->logger);
+ }
+
+ public function testValidate_nonScalapayFactory_noViolationAndApiNeverCalled(): void
+ {
+ $this->apiClientFactory->expects(self::never())->method('createForPaymentMethod');
+
+ $paymentMethod = $this->buildPaymentMethod(OneyGatewayFactory::FACTORY_NAME, []);
+ $this->validator->validate($paymentMethod, new IsScalapayAmountRangeValid());
+
+ $this->assertNoViolation();
+ }
+
+ public function testValidate_noAmountsConfigured_noViolationAndApiNeverCalled(): void
+ {
+ $this->apiClientFactory->expects(self::never())->method('createForPaymentMethod');
+
+ $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, []);
+ $this->validator->validate($paymentMethod, new IsScalapayAmountRangeValid());
+
+ $this->assertNoViolation();
+ }
+
+ public function testValidate_minGreaterThanMax_raisesViolationWithoutCallingApi(): void
+ {
+ $this->apiClientFactory->expects(self::never())->method('createForPaymentMethod');
+
+ $config = [ScalapayGatewayFactory::MIN_AMOUNT => 5000, ScalapayGatewayFactory::MAX_AMOUNT => 1000];
+ $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, $config);
+
+ $constraint = new IsScalapayAmountRangeValid();
+ $this->validator->validate($paymentMethod, $constraint);
+
+ $this->buildViolation($constraint->minGreaterThanMaxMessage)->assertRaised();
+ }
+
+ public function testValidate_minBelowApiMin_raisesOutOfRangeViolation(): void
+ {
+ $apiClient = $this->mockApiClientWithAccount(500, 200000);
+ $this->apiClientFactory->method('createForPaymentMethod')->willReturn($apiClient);
+
+ $config = [ScalapayGatewayFactory::MIN_AMOUNT => 100];
+ $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, $config);
+
+ $constraint = new IsScalapayAmountRangeValid();
+ $this->validator->validate($paymentMethod, $constraint);
+
+ $this->buildViolation($constraint->outOfRangeMessage)
+ ->setParameter('%min_amount%', '5.00')
+ ->setParameter('%max_amount%', '2000.00')
+ ->assertRaised()
+ ;
+ }
+
+ public function testValidate_maxAboveApiMax_raisesOutOfRangeViolation(): void
+ {
+ $apiClient = $this->mockApiClientWithAccount(500, 200000);
+ $this->apiClientFactory->method('createForPaymentMethod')->willReturn($apiClient);
+
+ $config = [ScalapayGatewayFactory::MAX_AMOUNT => 300000];
+ $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, $config);
+
+ $constraint = new IsScalapayAmountRangeValid();
+ $this->validator->validate($paymentMethod, $constraint);
+
+ $this->buildViolation($constraint->outOfRangeMessage)
+ ->setParameter('%min_amount%', '5.00')
+ ->setParameter('%max_amount%', '2000.00')
+ ->assertRaised()
+ ;
+ }
+
+ public function testValidate_withinApiBounds_noViolation(): void
+ {
+ $apiClient = $this->mockApiClientWithAccount(500, 200000);
+ $this->apiClientFactory->method('createForPaymentMethod')->willReturn($apiClient);
+
+ $config = [ScalapayGatewayFactory::MIN_AMOUNT => 1000, ScalapayGatewayFactory::MAX_AMOUNT => 100000];
+ $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, $config);
+
+ $this->validator->validate($paymentMethod, new IsScalapayAmountRangeValid());
+
+ $this->assertNoViolation();
+ }
+
+ public function testValidate_apiThrowsUnauthorizedException_noViolation(): void
+ {
+ $apiClient = $this->createMock(PayPlugApiClientInterface::class);
+ $apiClient->method('getAccount')->willThrowException(new UnauthorizedException('unauthorized'));
+ $this->apiClientFactory->method('createForPaymentMethod')->willReturn($apiClient);
+
+ // Failing open leaves the range unvalidated, so the skip must at least be traceable.
+ $this->logger->expects(self::once())->method('warning');
+
+ $config = [ScalapayGatewayFactory::MIN_AMOUNT => 1000];
+ $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, $config);
+
+ $this->validator->validate($paymentMethod, new IsScalapayAmountRangeValid());
+
+ $this->assertNoViolation();
+ }
+
+ public function testValidate_apiThrowsConnectionException_noViolation(): void
+ {
+ $apiClient = $this->createMock(PayPlugApiClientInterface::class);
+ $apiClient->method('getAccount')->willThrowException(new ConnectionException('network blip'));
+ $this->apiClientFactory->method('createForPaymentMethod')->willReturn($apiClient);
+
+ $this->logger->expects(self::once())->method('warning');
+
+ $config = [ScalapayGatewayFactory::MIN_AMOUNT => 1000];
+ $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, $config);
+
+ $this->validator->validate($paymentMethod, new IsScalapayAmountRangeValid());
+
+ $this->assertNoViolation();
+ }
+
+ /**
+ * The account authorizes no EUR range for Scalapay at all, so there is nothing to check the
+ * configured range against. Same fail-open outcome as an API error, and logged for the same
+ * reason.
+ */
+ public function testValidate_accountHasNoEurRange_noViolationButLogged(): void
+ {
+ $apiClient = $this->createMock(PayPlugApiClientInterface::class);
+ $apiClient->method('getAccount')->willReturn([
+ 'configuration' => ['min_amounts' => ['USD' => 500], 'max_amounts' => ['USD' => 200000]],
+ 'payment_methods' => [],
+ ]);
+ $this->apiClientFactory->method('createForPaymentMethod')->willReturn($apiClient);
+
+ $this->logger->expects(self::once())->method('warning');
+
+ $config = [ScalapayGatewayFactory::MIN_AMOUNT => 1000];
+ $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, $config);
+
+ $this->validator->validate($paymentMethod, new IsScalapayAmountRangeValid());
+
+ $this->assertNoViolation();
+ }
+
+ /**
+ * The gateway config is a plain serialized array, so a direct DB edit or an import script can
+ * leave a non-int in it. PaymentMethodValidator::process() has no try/catch: a malformed value
+ * must degrade to "not configured" rather than 500 the admin save with an assertion error.
+ */
+ public function testValidate_malformedConfiguredAmounts_noViolationButLogged(): void
+ {
+ $this->apiClientFactory->expects(self::never())->method('createForPaymentMethod');
+ $this->logger->expects(self::once())->method('warning');
+
+ $config = [ScalapayGatewayFactory::MIN_AMOUNT => '1000', ScalapayGatewayFactory::MAX_AMOUNT => 'nonsense'];
+ $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, $config);
+
+ $this->validator->validate($paymentMethod, new IsScalapayAmountRangeValid());
+
+ $this->assertNoViolation();
+ }
+
+ public function testValidate_disabledMethod_noViolationAndApiNeverCalled(): void
+ {
+ $this->apiClientFactory->expects(self::never())->method('createForPaymentMethod');
+
+ $config = [ScalapayGatewayFactory::MIN_AMOUNT => 1000, ScalapayGatewayFactory::MAX_AMOUNT => 100000];
+ $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, $config, false);
+
+ $this->validator->validate($paymentMethod, new IsScalapayAmountRangeValid());
+
+ $this->assertNoViolation();
+ }
+
+ /**
+ * API range: min=500, max=200000 (cents). Merchant sets only max_amount=300, leaving
+ * min_amount blank. At checkout, the blank side falls back to the API bound (500), making
+ * the *effective* range inverted (500 > 300) even though neither configured value alone
+ * looks invalid against its own matching API bound.
+ */
+ public function testValidate_onlyMaxConfiguredBelowEffectiveMin_raisesMinGreaterThanMaxViolation(): void
+ {
+ $apiClient = $this->mockApiClientWithAccount(500, 200000);
+ $this->apiClientFactory->method('createForPaymentMethod')->willReturn($apiClient);
+
+ $config = [ScalapayGatewayFactory::MAX_AMOUNT => 300];
+ $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, $config);
+
+ $constraint = new IsScalapayAmountRangeValid();
+ $this->validator->validate($paymentMethod, $constraint);
+
+ $this->buildViolation($constraint->minGreaterThanMaxMessage)->assertRaised();
+ }
+
+ private function mockApiClientWithAccount(int $minAmount, int $maxAmount): PayPlugApiClientInterface&MockObject
+ {
+ $apiClient = $this->createMock(PayPlugApiClientInterface::class);
+ $apiClient->method('getAccount')->willReturn([
+ 'configuration' => [
+ 'min_amounts' => ['EUR' => $minAmount],
+ 'max_amounts' => ['EUR' => $maxAmount],
+ ],
+ 'payment_methods' => [],
+ ]);
+
+ return $apiClient;
+ }
+
+ private function buildPaymentMethod(
+ string $factoryName,
+ array $config,
+ bool $enabled = true,
+ ): PaymentMethodInterface&MockObject
+ {
+ $gatewayConfig = $this->createMock(GatewayConfigInterface::class);
+ $gatewayConfig->method('getFactoryName')->willReturn($factoryName);
+ $gatewayConfig->method('getConfig')->willReturn($config);
+
+ $paymentMethod = $this->createMock(PaymentMethodInterface::class);
+ $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig);
+ $paymentMethod->method('isEnabled')->willReturn($enabled);
+
+ return $paymentMethod;
+ }
+}
diff --git a/tests/PHPUnit/Handler/HostedFieldsWebhookNotificationHandlerTest.php b/tests/PHPUnit/Handler/HostedFieldsWebhookNotificationHandlerTest.php
new file mode 100644
index 00000000..6a222db4
--- /dev/null
+++ b/tests/PHPUnit/Handler/HostedFieldsWebhookNotificationHandlerTest.php
@@ -0,0 +1,639 @@
+paymentRepository = $this->createMock(IPaymentRepository::class);
+ $this->orderStateMutator = $this->createMock(IOrderStateMutator::class);
+ $this->configurationRepository = $this->createMock(IConfigurationRepository::class);
+ $this->lock = $this->createMock(ILock::class);
+ $this->lock->method('acquire')->willReturn(true);
+ $this->logger = $this->createMock(LoggerInterface::class);
+ $this->payplugCardFactory = $this->createMock(FactoryInterface::class);
+ $this->payplugCardRepository = $this->createMock(RepositoryInterface::class);
+ $this->managerRegistry = $this->createMock(ManagerRegistry::class);
+
+ $this->handler = new HostedFieldsWebhookNotificationHandler(
+ $this->paymentRepository,
+ $this->orderStateMutator,
+ $this->configurationRepository,
+ $this->lock,
+ $this->logger,
+ new PayplugCardPersister($this->payplugCardFactory, $this->payplugCardRepository, $this->managerRegistry),
+ );
+ }
+
+ /**
+ * @param mixed[] $details
+ */
+ private function payment(
+ int $id = 42,
+ int $amount = 1000,
+ ?string $orderNumber = null,
+ array $details = [],
+ ?PaymentMethodInterface $method = null,
+ ?CustomerInterface $customer = null,
+ ): PaymentInterface&MockObject {
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getId')->willReturn($id);
+ $payment->method('getAmount')->willReturn($amount);
+ $payment->method('getDetails')->willReturn($details);
+ $payment->method('getMethod')->willReturn($method);
+
+ if (null !== $orderNumber || null !== $customer) {
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getNumber')->willReturn($orderNumber);
+ $order->method('getCustomer')->willReturn($customer);
+ $payment->method('getOrder')->willReturn($order);
+ } else {
+ $payment->method('getOrder')->willReturn(null);
+ }
+
+ return $payment;
+ }
+
+ public function testTreat_onValidNotification_savesTreatsAndAppliesTheOutcomeAgainstTheResolvedPayment(): void
+ {
+ // orderId here ("42") matches the payment id fallback used when the payment has no order
+ // yet — the same fallback CaptureHostedPaymentRequestHandler uses when sending orderId to
+ // PayPlug at creation time (order number if present, else the payment id).
+ $body = \json_encode(['id' => 'op_123', 'execCode' => '0000', 'orderId' => '42', 'amount' => 1000]);
+
+ $this->configurationRepository->method('get')->with('payplug_webhook_authorization_header')->willReturn('Bearer shared-secret');
+ $this->paymentRepository->method('isTreated')->with('op_123')->willReturn(false);
+
+ $this->lock->expects(self::once())->method('acquire')->with('payplug_upc_treat_op_123', 30)->willReturn(true);
+ $this->lock->expects(self::once())->method('release')->with('payplug_upc_treat_op_123');
+ $this->paymentRepository->expects(self::once())->method('save');
+ $this->paymentRepository->expects(self::once())->method('markTreated')->with('op_123');
+ $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::PAID);
+
+ $this->handler->treat($this->payment(42, 1000), $body, ['Authorization' => 'Bearer shared-secret']);
+ }
+
+ /**
+ * Guards the race StatusHostedPaymentRequestHandler's GET polling fallback can create: if a
+ * genuine webhook delivery for the same operation is already inside treat() (lock held), a
+ * concurrent caller must back off rather than double-apply.
+ */
+ public function testTreat_whenLockCannotBeAcquired_doesNothingAndReturnsSilently(): void
+ {
+ $body = \json_encode(['id' => 'op_123', 'execCode' => '0000', 'orderId' => '42', 'amount' => 1000]);
+
+ $this->configurationRepository->method('get')->willReturn('Bearer shared-secret');
+ // A fresh mock rather than reconfiguring $this->lock: setUp()'s unconditional
+ // ->method('acquire')->willReturn(true) stub would otherwise still win over this one.
+ $this->lock = $this->createMock(ILock::class);
+ $this->lock->method('acquire')->willReturn(false);
+ $this->handler = new HostedFieldsWebhookNotificationHandler(
+ $this->paymentRepository,
+ $this->orderStateMutator,
+ $this->configurationRepository,
+ $this->lock,
+ $this->logger,
+ new PayplugCardPersister($this->payplugCardFactory, $this->payplugCardRepository, $this->managerRegistry),
+ );
+
+ $this->paymentRepository->expects(self::never())->method('isTreated');
+ $this->paymentRepository->expects(self::never())->method('save');
+ $this->paymentRepository->expects(self::never())->method('markTreated');
+ $this->orderStateMutator->expects(self::never())->method('apply');
+ $this->lock->expects(self::never())->method('release');
+
+ $this->handler->treat($this->payment(42, 1000), $body, ['Authorization' => 'Bearer shared-secret']);
+ }
+
+ public function testTreat_onPendingThreeDsExecCode_doesNothingAndReturnsSilently(): void
+ {
+ $body = \json_encode(['id' => 'op_123', 'execCode' => '0001', 'orderId' => '42', 'amount' => 1000]);
+
+ $this->configurationRepository->method('get')->willReturn('Bearer shared-secret');
+
+ $this->lock->expects(self::never())->method('acquire');
+ $this->paymentRepository->expects(self::never())->method('isTreated');
+ $this->paymentRepository->expects(self::never())->method('save');
+ $this->paymentRepository->expects(self::never())->method('markTreated');
+ $this->orderStateMutator->expects(self::never())->method('apply');
+
+ $this->handler->treat($this->payment(42, 1000), $body, ['Authorization' => 'Bearer shared-secret']);
+ }
+
+ /**
+ * Regression for the live incident on 2026-08-21 (order 000000074): the notifier fired a
+ * webhook mid-3DS-challenge (execCode 0001) before the real, final one (execCode 0000). The
+ * premature call must not consume isTreated()'s dedupe slot, or the later, correct
+ * notification has nothing left to do — the payment ends up permanently stuck instead of paid.
+ */
+ public function testTreat_onPendingExecCodeFollowedByFinalExecCode_appliesOnlyTheFinalOutcome(): void
+ {
+ $pendingBody = \json_encode(['id' => 'op_123', 'execCode' => '0001', 'orderId' => '42', 'amount' => 1000]);
+ $finalBody = \json_encode(['id' => 'op_123', 'execCode' => '0000', 'orderId' => '42', 'amount' => 1000]);
+
+ $this->configurationRepository->method('get')->willReturn('Bearer shared-secret');
+ $this->paymentRepository->method('isTreated')->with('op_123')->willReturn(false);
+
+ $this->handler->treat($this->payment(42, 1000), $pendingBody, ['Authorization' => 'Bearer shared-secret']);
+
+ $this->paymentRepository->expects(self::once())->method('save');
+ $this->paymentRepository->expects(self::once())->method('markTreated')->with('op_123');
+ $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::PAID);
+
+ $this->handler->treat($this->payment(42, 1000), $finalBody, ['Authorization' => 'Bearer shared-secret']);
+ }
+
+ public function testTreat_onRealPlatformShapedNotification_stillParsesTheFourFieldsWebhookNotificationHelperNeeds(): void
+ {
+ // Captured from Datadog (staging notifier webhook attempts): the platform sends a much
+ // richer, nested payload than the {id, execCode, orderId, amount} shape our own tests
+ // otherwise use. WebhookNotificationHelper::parse() only reads those four top-level
+ // fields and ignores everything else, so this locks in that the extra nesting (customer,
+ // authentication, paymentMethod, account...) and sibling fields (paymentId, stan,
+ // descriptor...) never break parsing.
+ $body = \json_encode([
+ 'operationType' => 'PAYMENT',
+ 'customer' => ['id' => '130', 'email' => 'test-client@example.com'],
+ 'authentication' => ['status' => 'Y', 'globalStatus' => 'OK', 'mode' => 'FRICTIONLESS', 'preference' => 'NO_PREF', 'version' => '2', 'enrolledCard' => 'Y'],
+ 'paymentMethod' => ['card' => ['bank' => 'EXAMPLE BANK', 'country' => 'GB', 'usage' => 'debit', 'code6x4' => '446421XXXXXX0000', 'type' => 'VISA', 'network' => 'VISA'], 'details' => ['validityDate' => '2030-12', 'selectedBrand' => 'VISA']],
+ 'account' => ['id' => 'PLUGINS_UHF_QA'],
+ 'additionalData' => 'Playful Paradise Cap',
+ 'currency' => 'EUR',
+ 'amount' => 7400,
+ 'descriptor' => 'PPG',
+ 'authorizationCode' => '452743',
+ 'bankResponse' => '00',
+ 'schemeTransactionId' => 'G8N6XKPB07CO5JT',
+ 'execCode' => '0000',
+ 'message' => 'Successful operation',
+ 'orderId' => '000000065',
+ 'stan' => '333446',
+ 'id' => 'e4d04233-a15d-4815-af91-698c3eb61c36',
+ 'paymentId' => 'b1dde7ce-d069-43dd-b49d-9f2f1cd9d671',
+ ]);
+
+ $this->configurationRepository->method('get')->with('payplug_webhook_authorization_header')->willReturn('Bearer shared-secret');
+ $this->paymentRepository->method('isTreated')->with('e4d04233-a15d-4815-af91-698c3eb61c36')->willReturn(false);
+
+ $this->paymentRepository->expects(self::once())->method('save');
+ $this->paymentRepository->expects(self::once())->method('markTreated')->with('e4d04233-a15d-4815-af91-698c3eb61c36');
+ $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::PAID);
+
+ $this->handler->treat($this->payment(42, 7400, '000000065'), $body, ['Authorization' => 'Bearer shared-secret']);
+ }
+
+ public function testTreat_whenAlreadyTreated_isIdempotentAndDoesNotReapply(): void
+ {
+ $body = \json_encode(['id' => 'op_123', 'execCode' => '0000', 'orderId' => '42', 'amount' => 1000]);
+
+ $this->configurationRepository->method('get')->willReturn('Bearer shared-secret');
+ $this->paymentRepository->method('isTreated')->with('op_123')->willReturn(true);
+
+ $this->paymentRepository->expects(self::never())->method('save');
+ $this->orderStateMutator->expects(self::never())->method('apply');
+ $this->lock->expects(self::once())->method('release')->with('payplug_upc_treat_op_123');
+
+ $this->handler->treat($this->payment(42, 1000), $body, ['Authorization' => 'Bearer shared-secret']);
+ }
+
+ public function testTreat_onInvalidSignature_throwsInvalidNotificationExceptionWithoutApplyingTheOutcome(): void
+ {
+ $this->configurationRepository->method('get')->willReturn('Bearer shared-secret');
+
+ $this->paymentRepository->expects(self::never())->method('save');
+ $this->orderStateMutator->expects(self::never())->method('apply');
+
+ $this->expectException(InvalidNotificationException::class);
+
+ $this->handler->treat($this->payment(), '{}', ['Authorization' => 'Bearer wrong-secret']);
+ }
+
+ public function testTreat_onOrderIdMismatch_logsAndSkipsWithoutApplyingTheOutcome(): void
+ {
+ // Now that no Authorization header is required (see WebhookNotificationHelper), this
+ // orderId/amount cross-check is the only remaining protection against a notification
+ // being applied to the wrong payment on the static, per-account IPN receiver.
+ $body = \json_encode(['id' => 'op_123', 'execCode' => '0000', 'orderId' => 'some-other-order', 'amount' => 1000]);
+
+ $this->configurationRepository->method('get')->willReturn('Bearer shared-secret');
+
+ $this->logger->expects(self::once())->method('error');
+ $this->paymentRepository->expects(self::never())->method('save');
+ $this->paymentRepository->expects(self::never())->method('markTreated');
+ $this->orderStateMutator->expects(self::never())->method('apply');
+
+ $this->handler->treat($this->payment(42, 1000), $body, ['Authorization' => 'Bearer shared-secret']);
+ }
+
+ public function testTreat_onAmountMismatch_logsAndSkipsWithoutApplyingTheOutcome(): void
+ {
+ $body = \json_encode(['id' => 'op_123', 'execCode' => '0000', 'orderId' => '42', 'amount' => 999]);
+
+ $this->configurationRepository->method('get')->willReturn('Bearer shared-secret');
+
+ $this->logger->expects(self::once())->method('error');
+ $this->paymentRepository->expects(self::never())->method('save');
+ $this->orderStateMutator->expects(self::never())->method('apply');
+
+ $this->handler->treat($this->payment(42, 1000), $body, ['Authorization' => 'Bearer shared-secret']);
+ }
+
+ /**
+ * A 3DS-challenge capture never gets an alias back synchronously — this webhook, fired once
+ * the challenge is validated, is the only place a 3DS payment's card ever gets saved. The
+ * alias/card metadata is already in the webhook body itself: same paymentMethod.{id, card,
+ * details} shape as the operation resource CaptureHostedPaymentRequestHandler fetches
+ * separately for a frictionless payment.
+ */
+ public function testTreat_onPaidOutcomeWithSaveCardRequested_persistsANewCard(): void
+ {
+ $body = \json_encode([
+ 'id' => 'op_123',
+ 'execCode' => '0000',
+ 'orderId' => '42',
+ 'amount' => 1000,
+ 'paymentMethod' => [
+ 'id' => 'card_new_1',
+ 'card' => ['network' => 'VISA', 'code6x4' => '424242XXXXXX4242'],
+ 'details' => ['selectedBrand' => 'VISA', 'validityDate' => '2030-12'],
+ ],
+ ]);
+
+ $this->configurationRepository->method('get')->willReturn('Bearer shared-secret');
+ $this->paymentRepository->method('isTreated')->willReturn(false);
+
+ $method = $this->createMock(PaymentMethodInterface::class);
+ $customer = $this->createMock(CustomerInterface::class);
+ $payment = $this->payment(42, 1000, details: ['hosted_fields_save_card' => true], method: $method, customer: $customer);
+
+ $card = new Card();
+ $this->payplugCardFactory->method('createNew')->willReturn($card);
+ $this->payplugCardRepository->expects(self::once())->method('add')->with($card);
+
+ $this->handler->treat($payment, $body, ['Authorization' => 'Bearer shared-secret']);
+
+ self::assertSame('card_new_1', $card->getExternalId());
+ self::assertSame('VISA', $card->getBrand());
+ self::assertSame('4242', $card->getLast4());
+ self::assertSame(12, $card->getExpirationMonth());
+ self::assertSame(2030, $card->getExpirationYear());
+ }
+
+ public function testTreat_onPaidOutcomeWithoutSaveCardRequested_doesNotPersistACard(): void
+ {
+ $body = \json_encode([
+ 'id' => 'op_123',
+ 'execCode' => '0000',
+ 'orderId' => '42',
+ 'amount' => 1000,
+ 'paymentMethod' => ['id' => 'card_new_1', 'card' => ['network' => 'VISA', 'code6x4' => '424242XXXXXX4242']],
+ ]);
+
+ $this->configurationRepository->method('get')->willReturn('Bearer shared-secret');
+ $this->paymentRepository->method('isTreated')->willReturn(false);
+
+ $payment = $this->payment(42, 1000, details: ['hosted_fields_save_card' => false]);
+
+ $this->payplugCardRepository->expects(self::never())->method('add');
+
+ $this->handler->treat($payment, $body, ['Authorization' => 'Bearer shared-secret']);
+ }
+
+ public function testTreat_onPaidOutcomeWithSaveCardRequestedButNoAliasInPayload_logsAndDoesNotPersistACard(): void
+ {
+ $body = \json_encode([
+ 'id' => 'op_123',
+ 'execCode' => '0000',
+ 'orderId' => '42',
+ 'amount' => 1000,
+ // No paymentMethod.id — e.g. this operation never involved an alias at all.
+ 'paymentMethod' => ['card' => ['network' => 'VISA', 'code6x4' => '424242XXXXXX4242']],
+ ]);
+
+ $this->configurationRepository->method('get')->willReturn('Bearer shared-secret');
+ $this->paymentRepository->method('isTreated')->willReturn(false);
+
+ $method = $this->createMock(PaymentMethodInterface::class);
+ $customer = $this->createMock(CustomerInterface::class);
+ $payment = $this->payment(42, 1000, details: ['hosted_fields_save_card' => true], method: $method, customer: $customer);
+
+ $this->logger->expects(self::once())->method('error')
+ ->with(self::stringContains('no alias id'), self::anything());
+ $this->payplugCardRepository->expects(self::never())->method('add');
+
+ $this->handler->treat($payment, $body, ['Authorization' => 'Bearer shared-secret']);
+ }
+
+ public function testTreat_onNonPaidOutcome_doesNotAttemptToPersistACard(): void
+ {
+ // execCode "9999" maps to PaymentOutcome::FAILED (not PAID, not the 0001 pending case
+ // already covered elsewhere) — the card-save branch must not even be attempted.
+ $body = \json_encode([
+ 'id' => 'op_123',
+ 'execCode' => '9999',
+ 'orderId' => '42',
+ 'amount' => 1000,
+ 'paymentMethod' => ['id' => 'card_new_1', 'card' => ['network' => 'VISA', 'code6x4' => '424242XXXXXX4242']],
+ ]);
+
+ $this->configurationRepository->method('get')->willReturn('Bearer shared-secret');
+ $this->paymentRepository->method('isTreated')->willReturn(false);
+
+ $payment = $this->payment(42, 1000, details: ['hosted_fields_save_card' => true]);
+
+ $this->payplugCardRepository->expects(self::never())->method('add');
+
+ $this->handler->treat($payment, $body, ['Authorization' => 'Bearer shared-secret']);
+ }
+
+ public function testTreat_onPaidOutcomeWithSaveCardRequestedAndCardAlreadySaved_doesNotPersistADuplicate(): void
+ {
+ $body = \json_encode([
+ 'id' => 'op_123',
+ 'execCode' => '0000',
+ 'orderId' => '42',
+ 'amount' => 1000,
+ 'paymentMethod' => ['id' => 'card_existing_1', 'card' => ['network' => 'VISA', 'code6x4' => '424242XXXXXX4242']],
+ ]);
+
+ $this->configurationRepository->method('get')->willReturn('Bearer shared-secret');
+ $this->paymentRepository->method('isTreated')->willReturn(false);
+
+ $method = $this->createMock(PaymentMethodInterface::class);
+ $customer = $this->createMock(CustomerInterface::class);
+ $payment = $this->payment(42, 1000, details: ['hosted_fields_save_card' => true], method: $method, customer: $customer);
+
+ $this->payplugCardRepository->method('findOneBy')->with(['externalId' => 'card_existing_1', 'isLive' => false])->willReturn(new Card());
+ $this->payplugCardRepository->expects(self::never())->method('add');
+
+ $this->handler->treat($payment, $body, ['Authorization' => 'Bearer shared-secret']);
+ }
+
+ /**
+ * When the notification's operation id matches one recorded under $details['refunds']
+ * (RefundPaymentProcessor stores it there for both full and partial UHF refunds), this is a
+ * refund confirmation, not the payment's own outcome — ExecCodeMapper's "0000" => PAID mapping
+ * would otherwise misreport a successful refund as the payment being paid. The amount check
+ * must use the refund's own recorded amount (500), not the payment's full amount (1000).
+ */
+ public function testTreat_onNotificationMatchingAKnownRefundId_appliesRefundedInsteadOfThePaymentOutcome(): void
+ {
+ $body = \json_encode(['id' => 'op_refund_1', 'execCode' => '0000', 'orderId' => '42', 'amount' => 500]);
+ $details = ['refunds' => [['internal_id' => 77, 'id' => 'op_refund_1', 'amount' => 500]]];
+
+ $this->configurationRepository->method('get')->willReturn('Bearer shared-secret');
+ $this->paymentRepository->method('isTreated')->with('op_refund_1')->willReturn(false);
+
+ $this->paymentRepository->expects(self::once())->method('save');
+ $this->paymentRepository->expects(self::once())->method('markTreated')->with('op_refund_1');
+ $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::REFUNDED);
+
+ $this->handler->treat($this->payment(42, 1000, null, $details), $body, ['Authorization' => 'Bearer shared-secret']);
+ }
+
+ /**
+ * A refund confirmation is matched against its OWN recorded amount (500), not the payment's
+ * full amount (1000) — the pre-fix behavior (comparing against the payment's full amount)
+ * would reject every partial-refund confirmation, which is exactly the bug this feature fixes.
+ */
+ public function testTreat_onRefundNotificationAmountMismatch_logsAndSkipsWithoutApplyingTheOutcome(): void
+ {
+ $body = \json_encode(['id' => 'op_refund_1', 'execCode' => '0000', 'orderId' => '42', 'amount' => 400]);
+ $details = ['refunds' => [['internal_id' => 77, 'id' => 'op_refund_1', 'amount' => 500]]];
+
+ $this->configurationRepository->method('get')->willReturn('Bearer shared-secret');
+
+ $this->logger->expects(self::once())->method('error');
+ $this->paymentRepository->expects(self::never())->method('save');
+ $this->orderStateMutator->expects(self::never())->method('apply');
+
+ $this->handler->treat($this->payment(42, 1000, null, $details), $body, ['Authorization' => 'Bearer shared-secret']);
+ }
+
+ /**
+ * A full UHF refund records its operation id with internal_id: null (RefundPaymentProcessor::
+ * processHostedFields() has no Sylius $refundId to attach) — still resolvable and REFUNDED.
+ */
+ public function testTreat_onFullRefundNotification_appliesRefundedUsingTheFullRefundAmount(): void
+ {
+ $body = \json_encode(['id' => 'op_refund_full', 'execCode' => '0000', 'orderId' => '42', 'amount' => 1000]);
+ $details = ['refunds' => [['internal_id' => null, 'id' => 'op_refund_full', 'amount' => 1000]]];
+
+ $this->configurationRepository->method('get')->willReturn('Bearer shared-secret');
+ $this->paymentRepository->method('isTreated')->with('op_refund_full')->willReturn(false);
+
+ $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::REFUNDED);
+
+ $this->handler->treat($this->payment(42, 1000, null, $details), $body, ['Authorization' => 'Bearer shared-secret']);
+ }
+
+ /**
+ * RefundPaymentProcessor's createRefund() call can return a 2xx response with no operationIds
+ * (logged as an error there) — the refund entry it records then has id: null, so this
+ * confirmation's own operationId ("op_refund_unresolved") can never match it by id. Falling
+ * back to the unresolved entry's own recorded amount (500) is what still lets this be
+ * classified as REFUNDED instead of being dropped or misapplied as a plain payment
+ * confirmation.
+ */
+ public function testTreat_onNotificationForARefundWithNoCapturedOperationId_fallsBackToTheUnresolvedRefundEntry(): void
+ {
+ $body = \json_encode(['id' => 'op_refund_unresolved', 'execCode' => '0000', 'orderId' => '42', 'amount' => 500]);
+ $details = ['refunds' => [['internal_id' => 77, 'id' => null, 'amount' => 500]]];
+
+ $this->configurationRepository->method('get')->willReturn('Bearer shared-secret');
+ $this->paymentRepository->method('isTreated')->with('op_refund_unresolved')->willReturn(false);
+
+ $this->paymentRepository->expects(self::once())->method('save');
+ $this->paymentRepository->expects(self::once())->method('markTreated')->with('op_refund_unresolved');
+ $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::REFUNDED);
+
+ $this->handler->treat($this->payment(42, 1000, null, $details), $body, ['Authorization' => 'Bearer shared-secret']);
+ }
+
+ /**
+ * The refund's own execCode indicates failure (anything other than "0000") — the outcome
+ * must never be forced to REFUNDED (money never moved), nor passed through as-is to the
+ * Payment's own state machine: PaymentOutcome::FAILED maps to TRANSITION_FAIL (see
+ * SyliusOrderStateMutator), which means "this PAYMENT failed," not "this refund attempt
+ * failed" — the underlying payment already succeeded, only the refund didn't. Only logging +
+ * idempotency tracking happen; orderStateMutator must never be called. The matched refund
+ * entry is also flagged 'failed' => true on the Payment itself — see
+ * RefundPaymentProcessor::sumRecordedRefunds(), which relies on this flag to exclude money
+ * that was accepted synchronously but never actually moved from a later full refund's
+ * remaining-balance calculation.
+ */
+ public function testTreat_onRefundNotificationWithFailureExecCode_neverTouchesThePaymentStateButStillMarksTreated(): void
+ {
+ $body = \json_encode(['id' => 'op_refund_1', 'execCode' => '9999', 'orderId' => '42', 'amount' => 500]);
+ $details = ['refunds' => [['internal_id' => 77, 'id' => 'op_refund_1', 'amount' => 500]]];
+
+ $this->configurationRepository->method('get')->willReturn('Bearer shared-secret');
+ $this->paymentRepository->method('isTreated')->with('op_refund_1')->willReturn(false);
+
+ $payment = $this->payment(42, 1000, null, $details);
+ $payment->expects(self::once())->method('setDetails')->with(self::callback(
+ static function (array $details): bool {
+ return [[
+ 'internal_id' => 77,
+ 'id' => 'op_refund_1',
+ 'amount' => 500,
+ 'failed' => true,
+ ]] === $details['refunds'];
+ },
+ ));
+
+ $this->paymentRepository->expects(self::once())->method('save');
+ $this->paymentRepository->expects(self::once())->method('markTreated')->with('op_refund_1');
+ $this->orderStateMutator->expects(self::never())->method('apply');
+ $this->logger->expects(self::once())->method('error');
+
+ $this->handler->treat($payment, $body, ['Authorization' => 'Bearer shared-secret']);
+ }
+
+ /**
+ * The 'failed' flag write goes through RefundDetailsLockKey — the same lock key
+ * RefundPaymentProcessor::processHostedFields()/processHostedFieldsWithAmount() acquire
+ * around their own (network-call-spanning) read-modify-write of this same
+ * $details['refunds'] array — not the per-operation 'payplug_upc_treat_' lock applyLocked()
+ * uses afterwards. Both locks are acquired/released here: the refund-details one first
+ * (guarding the setDetails() write below), the treat one second (guarding
+ * isTreated()/markTreated()/save()).
+ */
+ public function testTreat_onRefundNotificationWithFailureExecCode_acquiresTheSharedRefundDetailsLockKeyBeforeWritingTheFailedFlag(): void
+ {
+ $body = \json_encode(['id' => 'op_refund_1', 'execCode' => '9999', 'orderId' => '42', 'amount' => 500]);
+ $details = ['refunds' => [['internal_id' => 77, 'id' => 'op_refund_1', 'amount' => 500]]];
+
+ $this->configurationRepository->method('get')->willReturn('Bearer shared-secret');
+ $this->paymentRepository->method('isTreated')->with('op_refund_1')->willReturn(false);
+
+ $acquiredKeys = [];
+ $this->lock = $this->createMock(ILock::class);
+ $this->lock->method('acquire')->willReturnCallback(static function (string $key, int $ttl) use (&$acquiredKeys): bool {
+ $acquiredKeys[] = [$key, $ttl];
+
+ return true;
+ });
+ $this->handler = new HostedFieldsWebhookNotificationHandler(
+ $this->paymentRepository,
+ $this->orderStateMutator,
+ $this->configurationRepository,
+ $this->lock,
+ $this->logger,
+ new PayplugCardPersister($this->payplugCardFactory, $this->payplugCardRepository, $this->managerRegistry),
+ );
+
+ $this->handler->treat($this->payment(42, 1000, null, $details), $body, ['Authorization' => 'Bearer shared-secret']);
+
+ self::assertSame(
+ [['payplug_upc_refund_details_42', 30], ['payplug_upc_treat_op_refund_1', 30]],
+ $acquiredKeys,
+ );
+ }
+
+ /**
+ * A refund creation (RefundPaymentProcessor::processHostedFields()/
+ * processHostedFieldsWithAmount()) is in progress for this payment right now, holding
+ * RefundDetailsLockKey. The notification must NOT be marked treated in that case — returning
+ * without ever calling applyLocked() leaves isTreated()/markTreated() untouched, so a later
+ * redelivery of the same notification gets a fresh chance to record the 'failed' flag once
+ * that refund creation has released the lock — instead of the flag being silently lost forever
+ * because this delivery was marked treated without ever recording it.
+ */
+ public function testTreat_onRefundNotificationWithFailureExecCode_whenRefundDetailsLockCannotBeAcquired_doesNotMarkTreated(): void
+ {
+ $body = \json_encode(['id' => 'op_refund_1', 'execCode' => '9999', 'orderId' => '42', 'amount' => 500]);
+ $details = ['refunds' => [['internal_id' => 77, 'id' => 'op_refund_1', 'amount' => 500]]];
+
+ $this->configurationRepository->method('get')->willReturn('Bearer shared-secret');
+
+ $this->lock = $this->createMock(ILock::class);
+ $this->lock->method('acquire')->willReturnCallback(
+ static fn (string $key): bool => 'payplug_upc_refund_details_42' !== $key,
+ );
+ $this->handler = new HostedFieldsWebhookNotificationHandler(
+ $this->paymentRepository,
+ $this->orderStateMutator,
+ $this->configurationRepository,
+ $this->lock,
+ $this->logger,
+ new PayplugCardPersister($this->payplugCardFactory, $this->payplugCardRepository, $this->managerRegistry),
+ );
+
+ $payment = $this->payment(42, 1000, null, $details);
+ $payment->expects(self::never())->method('setDetails');
+ $this->paymentRepository->expects(self::never())->method('isTreated');
+ $this->paymentRepository->expects(self::never())->method('save');
+ $this->paymentRepository->expects(self::never())->method('markTreated');
+ $this->orderStateMutator->expects(self::never())->method('apply');
+ // Once for the "non-success outcome" log, once for the lock-contention log.
+ $this->logger->expects(self::exactly(2))->method('error');
+
+ $this->handler->treat($payment, $body, ['Authorization' => 'Bearer shared-secret']);
+ }
+
+ /**
+ * A delayed/redelivered copy of the ORIGINAL payment-creation notification must never be
+ * misclassified as a refund confirmation just because this payment also has an unresolved
+ * (id: null) refund entry sitting in $details['refunds'] — the known payment-creation
+ * operation id (hosted_fields_operation_id) excludes it from the unresolved-entry fallback.
+ */
+ public function testTreat_onRedeliveredPaymentNotification_isNotMisclassifiedAsTheUnresolvedRefund(): void
+ {
+ $body = \json_encode(['id' => 'op_payment_1', 'execCode' => '0000', 'orderId' => '42', 'amount' => 1000]);
+ $details = [
+ 'hosted_fields_operation_id' => 'op_payment_1',
+ 'refunds' => [['internal_id' => 77, 'id' => null, 'amount' => 500]],
+ ];
+
+ $this->configurationRepository->method('get')->willReturn('Bearer shared-secret');
+ $this->paymentRepository->method('isTreated')->with('op_payment_1')->willReturn(false);
+
+ $this->paymentRepository->expects(self::once())->method('save');
+ $this->paymentRepository->expects(self::once())->method('markTreated')->with('op_payment_1');
+ $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::PAID);
+
+ $this->handler->treat($this->payment(42, 1000, null, $details), $body, ['Authorization' => 'Bearer shared-secret']);
+ }
+}
diff --git a/tests/PHPUnit/Handler/PaymentNotificationHandlerTest.php b/tests/PHPUnit/Handler/PaymentNotificationHandlerTest.php
index 62d92df0..a1bd28e6 100644
--- a/tests/PHPUnit/Handler/PaymentNotificationHandlerTest.php
+++ b/tests/PHPUnit/Handler/PaymentNotificationHandlerTest.php
@@ -4,7 +4,9 @@
namespace Tests\PayPlug\SyliusPayPlugPlugin\PHPUnit\Handler;
+use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
+use Doctrine\Persistence\ManagerRegistry;
use Payplug\Resource\Payment as PayplugPayment;
use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface;
use PayPlug\SyliusPayPlugPlugin\Entity\Card;
@@ -39,6 +41,8 @@ final class PaymentNotificationHandlerTest extends TestCase
private RequestStack&MockObject $requestStack;
+ private ManagerRegistry&MockObject $managerRegistry;
+
private PaymentNotificationHandler $handler;
protected function setUp(): void
@@ -50,6 +54,7 @@ protected function setUp(): void
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->lockFactory = $this->createMock(LockFactory::class);
$this->requestStack = $this->createMock(RequestStack::class);
+ $this->managerRegistry = $this->createMock(ManagerRegistry::class);
$this->handler = new PaymentNotificationHandler(
$this->logger,
@@ -59,6 +64,7 @@ protected function setUp(): void
$this->entityManager,
$this->lockFactory,
$this->requestStack,
+ $this->managerRegistry,
);
}
@@ -328,6 +334,61 @@ public function testTreat_withIsPaidAndMissingCustomerId_doesNotSaveCard(): void
self::assertSame(PayPlugApiClientInterface::STATUS_CAPTURED, $details['status']);
}
+ // -------------------------------------------------------------------------
+ // treat() — card saving: concurrent save for the same alias does not throw
+ // -------------------------------------------------------------------------
+
+ /**
+ * Two payments notified concurrently for the same card alias can both pass the findOneBy()
+ * guard before either commits; the DB-level unique constraint then rejects the second add().
+ * Verifies that race is swallowed rather than propagated as an uncaught exception.
+ */
+ public function testTreat_whenAddLosesARaceAgainstAConcurrentSaveForTheSameAlias_doesNotThrow(): void
+ {
+ $lock = $this->buildLock();
+ $this->lockFactory->method('createLock')->willReturn($lock);
+
+ $customer = $this->createMock(CustomerInterface::class);
+ $this->customerRepository->method('find')->with(9)->willReturn($customer);
+
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getMethod')->willReturn($this->createMock(PaymentMethodInterface::class));
+ $this->entityManager->method('refresh');
+
+ $paymentResource = $this->buildPayment([
+ 'id' => 'pay_008',
+ 'is_paid' => true,
+ 'is_live' => false,
+ 'created_at' => time(),
+ 'metadata' => ['customer_id' => 9],
+ 'card' => ['id' => 'card_external_race', 'brand' => 'Visa', 'country' => 'FR', 'last4' => '4242', 'exp_month' => 12, 'exp_year' => 2030],
+ ]);
+
+ $this->payplugCardRepository->method('findOneBy')->willReturn(null);
+
+ $card = $this->createMock(Card::class);
+ $card->method('setCustomer')->willReturnSelf();
+ $card->method('setPaymentMethod')->willReturnSelf();
+ $card->method('setExternalId')->willReturnSelf();
+ $card->method('setBrand')->willReturnSelf();
+ $card->method('setCountryCode')->willReturnSelf();
+ $card->method('setLast4')->willReturnSelf();
+ $card->method('setExpirationMonth')->willReturnSelf();
+ $card->method('setExpirationYear')->willReturnSelf();
+ $card->method('setIsLive')->willReturnSelf();
+
+ $this->payplugCardFactory->method('createNew')->willReturn($card);
+ $this->payplugCardRepository->method('add')->with($card)
+ ->willThrowException($this->createMock(UniqueConstraintViolationException::class));
+ $this->managerRegistry->expects(self::once())->method('resetManager');
+
+ $details = new \ArrayObject(['status' => PayPlugApiClientInterface::STATUS_CREATED]);
+
+ $this->handler->treat($payment, $paymentResource, $details);
+
+ self::assertSame(PayPlugApiClientInterface::STATUS_CAPTURED, $details['status']);
+ }
+
// -------------------------------------------------------------------------
// treat() — card NOT saved when card already exists in repo
// -------------------------------------------------------------------------
diff --git a/tests/PHPUnit/OrderPay/Provider/CaptureHttpResponseProviderTest.php b/tests/PHPUnit/OrderPay/Provider/CaptureHttpResponseProviderTest.php
new file mode 100644
index 00000000..d274d115
--- /dev/null
+++ b/tests/PHPUnit/OrderPay/Provider/CaptureHttpResponseProviderTest.php
@@ -0,0 +1,108 @@
+provider = new CaptureHttpResponseProvider();
+ $this->requestConfiguration = $this->createMock(RequestConfiguration::class);
+ }
+
+ private function paymentRequest(string $action, array $responseData): PaymentRequestInterface&MockObject
+ {
+ $paymentRequest = $this->createMock(PaymentRequestInterface::class);
+ $paymentRequest->method('getAction')->willReturn($action);
+ $paymentRequest->method('getResponseData')->willReturn($responseData);
+
+ return $paymentRequest;
+ }
+
+ public function testSupports_whenRedirectUrlIsSetOnCapture_returnsTrue(): void
+ {
+ $paymentRequest = $this->paymentRequest(PaymentRequestInterface::ACTION_CAPTURE, ['redirect_url' => 'https://example.com/3ds']);
+
+ self::assertTrue($this->provider->supports($this->requestConfiguration, $paymentRequest));
+ }
+
+ public function testSupports_whenRedirectHtmlIsSetOnCapture_returnsTrue(): void
+ {
+ $paymentRequest = $this->paymentRequest(PaymentRequestInterface::ACTION_CAPTURE, ['redirect_html' => '']);
+
+ self::assertTrue($this->provider->supports($this->requestConfiguration, $paymentRequest));
+ }
+
+ public function testSupports_whenActionIsNotCapture_returnsFalse(): void
+ {
+ $paymentRequest = $this->paymentRequest(PaymentRequestInterface::ACTION_NOTIFY, ['redirect_url' => 'https://example.com/3ds']);
+
+ self::assertFalse($this->provider->supports($this->requestConfiguration, $paymentRequest));
+ }
+
+ public function testSupports_whenNeitherRedirectFieldIsSet_returnsFalse(): void
+ {
+ $paymentRequest = $this->paymentRequest(PaymentRequestInterface::ACTION_CAPTURE, ['status' => 'processing']);
+
+ self::assertFalse($this->provider->supports($this->requestConfiguration, $paymentRequest));
+ }
+
+ public function testGetResponse_whenRedirectUrlIsSet_returnsARedirectResponse(): void
+ {
+ $paymentRequest = $this->paymentRequest(PaymentRequestInterface::ACTION_CAPTURE, ['redirect_url' => 'https://example.com/3ds']);
+
+ $response = $this->provider->getResponse($this->requestConfiguration, $paymentRequest);
+
+ self::assertInstanceOf(RedirectResponse::class, $response);
+ self::assertSame('https://example.com/3ds', $response->getTargetUrl());
+ }
+
+ public function testGetResponse_whenRedirectHtmlIsSet_returnsThatHtmlAsTheResponseContent(): void
+ {
+ $html = '3DS challenge form';
+ $paymentRequest = $this->paymentRequest(PaymentRequestInterface::ACTION_CAPTURE, ['redirect_html' => $html]);
+
+ $response = $this->provider->getResponse($this->requestConfiguration, $paymentRequest);
+
+ self::assertSame($html, $response->getContent());
+ }
+
+ /**
+ * Not a real-world case (the handler only ever sets one or the other), but proves the
+ * precedence explicitly rather than leaving it implicit: redirect_html wins if both are set.
+ */
+ public function testGetResponse_whenBothRedirectFieldsAreSet_prefersRedirectHtml(): void
+ {
+ $html = '3DS challenge form';
+ $paymentRequest = $this->paymentRequest(PaymentRequestInterface::ACTION_CAPTURE, [
+ 'redirect_url' => 'https://example.com/3ds',
+ 'redirect_html' => $html,
+ ]);
+
+ $response = $this->provider->getResponse($this->requestConfiguration, $paymentRequest);
+
+ self::assertSame($html, $response->getContent());
+ }
+
+ public function testGetResponse_whenNeitherRedirectFieldIsSet_throws(): void
+ {
+ $paymentRequest = $this->paymentRequest(PaymentRequestInterface::ACTION_CAPTURE, []);
+
+ $this->expectException(\LogicException::class);
+
+ $this->provider->getResponse($this->requestConfiguration, $paymentRequest);
+ }
+}
diff --git a/tests/PHPUnit/PaymentProcessing/NullHostedFieldsPaymentProcessorTest.php b/tests/PHPUnit/PaymentProcessing/NullHostedFieldsPaymentProcessorTest.php
new file mode 100644
index 00000000..a4a24faa
--- /dev/null
+++ b/tests/PHPUnit/PaymentProcessing/NullHostedFieldsPaymentProcessorTest.php
@@ -0,0 +1,46 @@
+logger = $this->createMock(LoggerInterface::class);
+ $this->processor = new NullHostedFieldsPaymentProcessor($this->logger);
+ }
+
+ public function testProcess_storesCardMetadataAlongsideTokenAndBrand(): void
+ {
+ $this->logger->expects(self::once())->method('info');
+
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getDetails')->willReturn(['existing_key' => 'kept']);
+ $payment->expects(self::once())->method('setDetails')->with([
+ 'existing_key' => 'kept',
+ 'hosted_fields_token' => 'hf_token_abc',
+ 'hosted_fields_selected_brand' => 'VISA',
+ 'hosted_fields_save_card' => true,
+ 'hosted_fields_last4' => '4242',
+ 'hosted_fields_expiration_month' => 12,
+ 'hosted_fields_expiration_year' => 2030,
+ 'hosted_fields_country' => 'FR',
+ 'status' => PaymentInterface::STATE_PROCESSING,
+ ]);
+
+ $this->processor->process($payment, new HostedFieldsCaptureData('hf_token_abc', 'VISA', true, '4242', 12, 2030, 'FR'));
+ }
+}
diff --git a/tests/PHPUnit/PaymentProcessing/RefundPaymentProcessorTest.php b/tests/PHPUnit/PaymentProcessing/RefundPaymentProcessorTest.php
index 3a2d3763..1b05e29f 100644
--- a/tests/PHPUnit/PaymentProcessing/RefundPaymentProcessorTest.php
+++ b/tests/PHPUnit/PaymentProcessing/RefundPaymentProcessorTest.php
@@ -14,9 +14,13 @@
use PayPlug\SyliusPayPlugPlugin\Gateway\WeroGatewayFactory;
use PayPlug\SyliusPayPlugPlugin\PaymentProcessing\RefundPaymentProcessor;
use PayPlug\SyliusPayPlugPlugin\Repository\RefundHistoryRepositoryInterface;
+use PayPlug\SyliusPayPlugPlugin\Upc\RefundCreatorInterface;
+use PayplugUnifiedCore\Contracts\ILock;
+use PayplugUnifiedCore\Exceptions\ApiException;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
+use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\PaymentInterface;
use Sylius\Component\Core\Model\PaymentMethodInterface;
use Sylius\Component\Payment\Model\GatewayConfigInterface;
@@ -42,6 +46,10 @@ final class RefundPaymentProcessorTest extends TestCase
private PayPlugApiClientInterface&MockObject $apiClient;
+ private RefundCreatorInterface&MockObject $refundCreator;
+
+ private ILock&MockObject $lock;
+
private RefundPaymentProcessor $processor;
protected function setUp(): void
@@ -53,6 +61,9 @@ protected function setUp(): void
$this->payplugRefundHistoryRepository = $this->createMock(RefundHistoryRepositoryInterface::class);
$this->apiClientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class);
$this->apiClient = $this->createMock(PayPlugApiClientInterface::class);
+ $this->refundCreator = $this->createMock(RefundCreatorInterface::class);
+ $this->lock = $this->createMock(ILock::class);
+ $this->lock->method('acquire')->willReturn(true);
$this->apiClientFactory->method('createForPaymentMethod')->willReturn($this->apiClient);
@@ -63,6 +74,8 @@ protected function setUp(): void
$this->refundPaymentRepository,
$this->payplugRefundHistoryRepository,
$this->apiClientFactory,
+ $this->refundCreator,
+ $this->lock,
);
}
@@ -238,6 +251,481 @@ public function testProcessWithAmount_apiThrowsException_throwsUpdateHandlingExc
$this->processor->processWithAmount($payment, 300, 42);
}
+ // -------------------------------------------------------------------------
+ // process() — Hosted Fields (UHF) full refund → calls RefundCreatorInterface
+ // -------------------------------------------------------------------------
+
+ /**
+ * Calls process() with a Hosted-Fields-configured payment. Verifies the UHF refund creator
+ * is called with the payment's hosted_fields_payment_id and no amount (full refund), and the
+ * legacy PayPlugApiClient is never touched.
+ */
+ public function testProcess_hostedFields_callsRefundCreatorWithoutAmount(): void
+ {
+ $payment = $this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_123']);
+
+ $this->refundCreator->expects(self::once())
+ ->method('createRefund')
+ ->with(self::isInstanceOf(PaymentMethodInterface::class), 'pay_hf_123', '000000042', null)
+ ->willReturn(['status' => 200, 'body' => '{}']);
+ $this->apiClient->expects(self::never())->method('refundPayment');
+
+ $this->processor->process($payment);
+ }
+
+ /**
+ * A full refund now records the refund's own operation id under $details['refunds'] (with a
+ * null internal_id, since there's no Sylius RefundPayment/$refundId in this flow) — otherwise
+ * HostedFieldsWebhookNotificationHandler could never resolve the payment for the async webhook
+ * confirming this refund, since PaymentRepository::findOneByPayPlugPaymentId() matches on
+ * ids present somewhere in Payment::details.
+ */
+ public function testProcess_hostedFields_recordsTheRefundOperationIdInDetails(): void
+ {
+ $payment = $this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_123']);
+ $payment->expects(self::once())->method('setDetails')->with(self::callback(
+ static function (array $details): bool {
+ return [[
+ 'internal_id' => null,
+ 'id' => 'op_ref_full',
+ 'amount' => 2400,
+ ]] === $details['refunds'];
+ },
+ ));
+
+ $this->refundCreator->method('createRefund')
+ ->with(self::isInstanceOf(PaymentMethodInterface::class), 'pay_hf_123', '000000042', null)
+ ->willReturn(['status' => 200, 'body' => json_encode(['operationIds' => ['op_ref_full']])]);
+
+ $this->processor->process($payment);
+ }
+
+ /**
+ * A full refund (process()) triggered after an earlier partial refund must record the
+ * REMAINING amount actually refunded by omitting $amount to createRefund() — not
+ * $payment->getAmount() (the original total, 2400 here) — per
+ * UnifiedApiPaymentService::createRefund()'s own documented "omitting $amount refunds the
+ * full remaining amount" behavior. Recording the original total instead would make
+ * matchesPayment() reject this refund's own webhook confirmation (500 already refunded, 1900
+ * really remaining) forever.
+ */
+ public function testProcess_hostedFields_afterAPriorPartialRefund_recordsTheRemainingAmountNotTheOriginalTotal(): void
+ {
+ $existingRefunds = [['internal_id' => 77, 'id' => 'op_ref_1', 'amount' => 500]];
+ $payment = $this->buildHostedFieldsPayment([
+ 'hosted_fields_payment_id' => 'pay_hf_123',
+ 'refunds' => $existingRefunds,
+ ]);
+ $payment->expects(self::once())->method('setDetails')->with(self::callback(
+ static function (array $details) use ($existingRefunds): bool {
+ return [...$existingRefunds, [
+ 'internal_id' => null,
+ 'id' => 'op_ref_full',
+ 'amount' => 1900,
+ ]] === $details['refunds'];
+ },
+ ));
+
+ $this->refundCreator->method('createRefund')
+ ->with(self::isInstanceOf(PaymentMethodInterface::class), 'pay_hf_123', '000000042', null)
+ ->willReturn(['status' => 200, 'body' => json_encode(['operationIds' => ['op_ref_full']])]);
+
+ $this->processor->process($payment);
+ }
+
+ /**
+ * An earlier refund attempt flagged 'failed' => true by
+ * HostedFieldsWebhookNotificationHandler::markMatchedRefundAsFailed() (its createRefund() call
+ * was accepted synchronously, but the async confirmation later reported it never actually
+ * completed) must not count against the remaining balance — a full refund triggered after it
+ * still records the ORIGINAL total (2400), not 2400 minus the failed attempt's amount.
+ */
+ public function testProcess_hostedFields_afterAFailedPriorRefund_ignoresItInTheRemainingAmountCalculation(): void
+ {
+ $existingRefunds = [['internal_id' => 77, 'id' => 'op_ref_1', 'amount' => 500, 'failed' => true]];
+ $payment = $this->buildHostedFieldsPayment([
+ 'hosted_fields_payment_id' => 'pay_hf_123',
+ 'refunds' => $existingRefunds,
+ ]);
+ $payment->expects(self::once())->method('setDetails')->with(self::callback(
+ static function (array $details) use ($existingRefunds): bool {
+ return [...$existingRefunds, [
+ 'internal_id' => null,
+ 'id' => 'op_ref_full',
+ 'amount' => 2400,
+ ]] === $details['refunds'];
+ },
+ ));
+
+ $this->refundCreator->method('createRefund')
+ ->with(self::isInstanceOf(PaymentMethodInterface::class), 'pay_hf_123', '000000042', null)
+ ->willReturn(['status' => 200, 'body' => json_encode(['operationIds' => ['op_ref_full']])]);
+
+ $this->processor->process($payment);
+ }
+
+ /**
+ * processHostedFields() must compute its remaining-balance sum from Payment::details read
+ * AFTER acquiring RefundDetailsLockKey, not from a snapshot taken before it — otherwise a
+ * concurrent HostedFieldsWebhookNotificationHandler::markMatchedRefundAsFailed() call (run
+ * while this refund creation's own network call to createRefund() is in flight, both holding
+ * the same lock key at different times) would have its 'failed' flag silently dropped when
+ * this method's own stale pre-lock $details gets written back. Simulated here via
+ * willReturnOnConsecutiveCalls: the first two getDetails() calls (prepare()'s own read, then
+ * the pre-lock Assert::string(hosted_fields_payment_id) check) see the refund as NOT failed
+ * yet; the third (taken once the lock is held, per processHostedFields()'s own re-read) sees
+ * it flagged failed — exactly as if the webhook's write landed in between. Only that third
+ * snapshot may ever reach setDetails().
+ */
+ public function testProcess_hostedFields_reReadsDetailsAfterAcquiringTheLock_soAConcurrentlyFlaggedFailedRefundIsNotLost(): void
+ {
+ $beforeLock = [
+ 'hosted_fields_payment_id' => 'pay_hf_123',
+ 'refunds' => [['internal_id' => 77, 'id' => 'op_ref_1', 'amount' => 500]],
+ ];
+ $afterLock = [
+ 'hosted_fields_payment_id' => 'pay_hf_123',
+ 'refunds' => [['internal_id' => 77, 'id' => 'op_ref_1', 'amount' => 500, 'failed' => true]],
+ ];
+
+ $gatewayConfig = $this->createMock(GatewayConfigInterface::class);
+ $gatewayConfig->method('getFactoryName')->willReturn(PayPlugGatewayFactory::FACTORY_NAME);
+ $gatewayConfig->method('getConfig')->willReturn([PayPlugGatewayFactory::HOSTED_FIELDS => true]);
+
+ $paymentMethod = $this->createMock(PaymentMethodInterface::class);
+ $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig);
+
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getNumber')->willReturn('000000042');
+
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getMethod')->willReturn($paymentMethod);
+ $payment->method('getDetails')->willReturnOnConsecutiveCalls($beforeLock, $beforeLock, $afterLock);
+ $payment->method('getOrder')->willReturn($order);
+ $payment->method('getAmount')->willReturn(2400);
+ $payment->method('getId')->willReturn(42);
+
+ // The failed entry (500) must NOT be subtracted from the original total (2400): had the
+ // pre-lock snapshot been used instead, this would incorrectly come out to 1900.
+ $payment->expects(self::once())->method('setDetails')->with(self::callback(
+ static function (array $details) use ($afterLock): bool {
+ return [...$afterLock['refunds'], [
+ 'internal_id' => null,
+ 'id' => 'op_ref_full',
+ 'amount' => 2400,
+ ]] === $details['refunds'];
+ },
+ ));
+
+ $this->refundCreator->method('createRefund')
+ ->with(self::isInstanceOf(PaymentMethodInterface::class), 'pay_hf_123', '000000042', null)
+ ->willReturn(['status' => 200, 'body' => json_encode(['operationIds' => ['op_ref_full']])]);
+
+ $this->processor->process($payment);
+ }
+
+ /**
+ * The lock key for a full refund and a partial refund on the SAME payment must be identical —
+ * otherwise the two can run concurrently and both succeed, double-refunding money, exactly
+ * the scenario the lock exists to prevent.
+ */
+ public function testProcess_andProcessWithAmount_useTheSameLockKeyForTheSamePayment(): void
+ {
+ $acquiredKeys = [];
+ $this->lock = $this->createMock(ILock::class);
+ $this->lock->method('acquire')->willReturnCallback(static function (string $key) use (&$acquiredKeys): bool {
+ $acquiredKeys[] = $key;
+
+ return true;
+ });
+ $this->processor = new RefundPaymentProcessor(
+ $this->requestStack,
+ $this->logger,
+ $this->translator,
+ $this->refundPaymentRepository,
+ $this->payplugRefundHistoryRepository,
+ $this->apiClientFactory,
+ $this->refundCreator,
+ $this->lock,
+ );
+
+ $this->refundCreator->method('createRefund')->willReturn(['status' => 200, 'body' => '{}']);
+ $refundPayment = $this->createMock(RefundPayment::class);
+ $this->refundPaymentRepository->method('findOneBy')->willReturn($refundPayment);
+ $this->payplugRefundHistoryRepository->method('findOneBy')->willReturn(null);
+
+ $this->processor->process($this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_123']));
+ $this->processor->processWithAmount($this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_123']), 500, 77);
+
+ self::assertCount(2, $acquiredKeys);
+ self::assertSame($acquiredKeys[0], $acquiredKeys[1]);
+ }
+
+ /**
+ * A full refund has no RefundHistory/refundId to check-then-act on (mirrors the legacy
+ * process()'s own lack of one), so the ILock guard is its only protection against a
+ * concurrent second call for the same payment double-refunding.
+ */
+ public function testProcess_hostedFields_whenLockCannotBeAcquired_throwsUpdateHandlingExceptionWithoutCallingRefundCreator(): void
+ {
+ $this->expectException(UpdateHandlingException::class);
+
+ $payment = $this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_123']);
+ $this->lock = $this->createMock(ILock::class);
+ $this->lock->method('acquire')->willReturn(false);
+ $this->processor = new RefundPaymentProcessor(
+ $this->requestStack,
+ $this->logger,
+ $this->translator,
+ $this->refundPaymentRepository,
+ $this->payplugRefundHistoryRepository,
+ $this->apiClientFactory,
+ $this->refundCreator,
+ $this->lock,
+ );
+
+ $this->refundCreator->expects(self::never())->method('createRefund');
+ $this->logger->expects(self::once())->method('error');
+
+ $this->processor->process($payment);
+ }
+
+ /**
+ * The UHF refund creator throws an ApiException (a UPC exception, always a subtype of the
+ * base \Exception). Verifies the processor catches it the same way as the legacy client's
+ * exceptions, logs an error, and re-throws UpdateHandlingException.
+ */
+ public function testProcess_hostedFields_apiExceptionThrowsUpdateHandlingException(): void
+ {
+ $this->expectException(UpdateHandlingException::class);
+
+ $payment = $this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_fail']);
+
+ $this->refundCreator->method('createRefund')->willThrowException(new ApiException('API error'));
+ $this->logger->expects(self::once())->method('error');
+
+ $this->processor->process($payment);
+ }
+
+ // -------------------------------------------------------------------------
+ // processWithAmount() — Hosted Fields (UHF) partial refund → RefundCreatorInterface +
+ // RefundHistory bookkeeping
+ // -------------------------------------------------------------------------
+
+ /**
+ * Calls processWithAmount() on a Hosted-Fields payment. Verifies the UHF refund creator is
+ * called with the amount, setDetails() records the refund's own operation id (from the
+ * response's operationIds[0]) under $details['refunds'], and a RefundHistory entry is
+ * persisted — externalId stays null, mirroring the legacy flow's own convention that this
+ * field is reserved for the async webhook-confirmed refund, not the synchronous BO one.
+ */
+ public function testProcessWithAmount_hostedFields_createsRefundHistoryEntryFromOperationIds(): void
+ {
+ $payment = $this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_partial']);
+ $payment->expects(self::once())->method('setDetails')->with(self::callback(
+ static function (array $details): bool {
+ return [[
+ 'internal_id' => 77,
+ 'id' => 'op_ref_1',
+ 'amount' => 500,
+ ]] === $details['refunds'];
+ },
+ ));
+
+ $this->refundCreator
+ ->method('createRefund')
+ ->with(self::isInstanceOf(PaymentMethodInterface::class), 'pay_hf_partial', '000000042', 500)
+ ->willReturn(['status' => 200, 'body' => json_encode(['operationIds' => ['op_ref_1']])]);
+
+ $refundPayment = $this->createMock(RefundPayment::class);
+ $this->refundPaymentRepository->method('findOneBy')->with(['id' => 77])->willReturn($refundPayment);
+
+ $this->payplugRefundHistoryRepository->expects(self::once())->method('add')->with(self::callback(
+ static function (RefundHistory $refundHistory): bool {
+ return null === $refundHistory->getExternalId() &&
+ 500 === $refundHistory->getValue() &&
+ $refundHistory->isProcessed();
+ },
+ ));
+
+ $this->processor->processWithAmount($payment, 500, 77);
+ }
+
+ public function testProcessWithAmount_hostedFields_apiExceptionThrowsUpdateHandlingException(): void
+ {
+ $this->expectException(UpdateHandlingException::class);
+
+ $payment = $this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_partial_fail']);
+
+ $this->refundCreator->method('createRefund')->willThrowException(new ApiException('fail'));
+ $this->logger->expects(self::once())->method('error');
+
+ $this->processor->processWithAmount($payment, 300, 42);
+ }
+
+ /**
+ * The whole check-then-act sequence (RefundHistory lookup + createRefund() call) is also
+ * guarded by ILock, keyed by $refundId: without it, two concurrent calls for the same
+ * $refundId could both pass the RefundHistory check below before either persists one, and
+ * both would go on to call createRefund() — a lock is what actually serializes the two
+ * attempts, a plain check-then-act on its own cannot.
+ */
+ public function testProcessWithAmount_hostedFields_whenLockCannotBeAcquired_throwsUpdateHandlingExceptionWithoutCallingRefundCreator(): void
+ {
+ $this->expectException(UpdateHandlingException::class);
+
+ $payment = $this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_partial']);
+ $this->lock = $this->createMock(ILock::class);
+ $this->lock->method('acquire')->willReturn(false);
+ $this->processor = new RefundPaymentProcessor(
+ $this->requestStack,
+ $this->logger,
+ $this->translator,
+ $this->refundPaymentRepository,
+ $this->payplugRefundHistoryRepository,
+ $this->apiClientFactory,
+ $this->refundCreator,
+ $this->lock,
+ );
+
+ $this->refundPaymentRepository->expects(self::never())->method('findOneBy');
+ $this->refundCreator->expects(self::never())->method('createRefund');
+ $this->logger->expects(self::once())->method('error');
+
+ $this->processor->processWithAmount($payment, 500, 77);
+ }
+
+ /**
+ * Unlike the legacy flow (which forwards Sylius's own $refundId to the API as a de-facto
+ * idempotency key), UPC's createRefund() has no idempotency-key parameter at all. A retried
+ * delivery of the same RefundPaymentGenerated message (e.g. after a transient failure once
+ * the RefundHistory for this $refundId was already persisted) must not call createRefund()
+ * again — this is the local guard closing that window.
+ */
+ public function testProcessWithAmount_hostedFields_alreadyProcessed_skipsDuplicateRefundCall(): void
+ {
+ $payment = $this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_partial']);
+ $payment->expects(self::never())->method('setDetails');
+
+ $refundPayment = $this->createMock(RefundPayment::class);
+ $this->refundPaymentRepository->method('findOneBy')->with(['id' => 77])->willReturn($refundPayment);
+
+ $existingRefundHistory = $this->createMock(RefundHistory::class);
+ $this->payplugRefundHistoryRepository
+ ->method('findOneBy')
+ ->with(['refundPayment' => $refundPayment])
+ ->willReturn($existingRefundHistory);
+
+ $this->refundCreator->expects(self::never())->method('createRefund');
+ $this->payplugRefundHistoryRepository->expects(self::never())->method('add');
+
+ $this->processor->processWithAmount($payment, 500, 77);
+ }
+
+ /**
+ * createRefund() returns a 2xx response whose body has no operationIds (malformed/unexpected
+ * shape). The refund still succeeded (money moved) and is still recorded, but with no
+ * tracking id — this must not pass silently, so an error is logged (actionable: without an
+ * operation id, HostedFieldsWebhookNotificationHandler can never match the eventual webhook
+ * confirmation back to this refund).
+ */
+ public function testProcessWithAmount_hostedFields_onMissingOperationIds_logsAnError(): void
+ {
+ $payment = $this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_partial']);
+
+ $this->refundCreator->method('createRefund')->willReturn(['status' => 200, 'body' => '{}']);
+
+ $refundPayment = $this->createMock(RefundPayment::class);
+ $this->refundPaymentRepository->method('findOneBy')->willReturn($refundPayment);
+ $this->payplugRefundHistoryRepository->method('findOneBy')->willReturn(null);
+
+ $this->logger->expects(self::once())->method('error');
+ $this->payplugRefundHistoryRepository->expects(self::once())->method('add');
+
+ $this->processor->processWithAmount($payment, 500, 77);
+ }
+
+ /**
+ * Two sequential partial refunds against the same Hosted-Fields payment must accumulate in
+ * $details['refunds'] rather than the second call overwriting the first — mirrors the
+ * legacy gateway's own Behat coverage for this exact scenario ("Two Partial refund of one
+ * product"), which UHF otherwise has no equivalent for at any test level.
+ */
+ public function testProcessWithAmount_hostedFields_secondPartialRefund_appendsToExistingRefunds(): void
+ {
+ $existingRefunds = [['internal_id' => 77, 'id' => 'op_ref_1', 'amount' => 500]];
+ $payment = $this->buildHostedFieldsPayment([
+ 'hosted_fields_payment_id' => 'pay_hf_partial',
+ 'refunds' => $existingRefunds,
+ ]);
+ $payment->expects(self::once())->method('setDetails')->with(self::callback(
+ static function (array $details) use ($existingRefunds): bool {
+ return [...$existingRefunds, [
+ 'internal_id' => 78,
+ 'id' => 'op_ref_2',
+ 'amount' => 300,
+ ]] === $details['refunds'];
+ },
+ ));
+
+ $this->refundCreator
+ ->method('createRefund')
+ ->with(self::isInstanceOf(PaymentMethodInterface::class), 'pay_hf_partial', '000000042', 300)
+ ->willReturn(['status' => 200, 'body' => json_encode(['operationIds' => ['op_ref_2']])]);
+
+ $refundPayment = $this->createMock(RefundPayment::class);
+ $this->refundPaymentRepository->method('findOneBy')->with(['id' => 78])->willReturn($refundPayment);
+ $this->payplugRefundHistoryRepository->method('findOneBy')->willReturn(null);
+
+ $this->processor->processWithAmount($payment, 300, 78);
+ }
+
+ /**
+ * prepare() must skip the legacy PayPlugApiClientFactory entirely for a Hosted-Fields
+ * payment — building it would mint an OAuth2 token that's never used.
+ */
+ public function testProcessWithAmount_hostedFields_neverCreatesTheLegacyApiClient(): void
+ {
+ $payment = $this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_partial']);
+
+ $this->refundCreator->method('createRefund')->willReturn(['status' => 200, 'body' => '{}']);
+ $refundPayment = $this->createMock(RefundPayment::class);
+ $this->refundPaymentRepository->method('findOneBy')->willReturn($refundPayment);
+ $this->payplugRefundHistoryRepository->method('findOneBy')->willReturn(null);
+
+ $this->apiClientFactory->expects(self::never())->method('createForPaymentMethod');
+
+ $this->processor->processWithAmount($payment, 500, 77);
+ }
+
+ /**
+ * When the payment has no order (edge case), orderId falls back to the payment's own id —
+ * same convention CaptureHostedPaymentRequestHandler already uses at creation time.
+ */
+ public function testProcess_hostedFields_withNoOrder_fallsBackToThePaymentIdAsOrderId(): void
+ {
+ $gatewayConfig = $this->createMock(GatewayConfigInterface::class);
+ $gatewayConfig->method('getFactoryName')->willReturn(PayPlugGatewayFactory::FACTORY_NAME);
+ $gatewayConfig->method('getConfig')->willReturn([PayPlugGatewayFactory::HOSTED_FIELDS => true]);
+
+ $paymentMethod = $this->createMock(PaymentMethodInterface::class);
+ $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig);
+
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getMethod')->willReturn($paymentMethod);
+ $payment->method('getDetails')->willReturn(['hosted_fields_payment_id' => 'pay_hf_no_order']);
+ $payment->method('getOrder')->willReturn(null);
+ $payment->method('getId')->willReturn(99);
+ $payment->method('getAmount')->willReturn(2400);
+
+ $this->refundCreator->expects(self::once())
+ ->method('createRefund')
+ ->with(self::isInstanceOf(PaymentMethodInterface::class), 'pay_hf_no_order', '99', null)
+ ->willReturn(['status' => 200, 'body' => '{}']);
+
+ $this->processor->process($payment);
+ }
+
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
@@ -256,4 +744,26 @@ private function buildPayment(string $factoryName, array $details): PaymentInter
return $payment;
}
+
+ private function buildHostedFieldsPayment(array $details): PaymentInterface&MockObject
+ {
+ $gatewayConfig = $this->createMock(GatewayConfigInterface::class);
+ $gatewayConfig->method('getFactoryName')->willReturn(PayPlugGatewayFactory::FACTORY_NAME);
+ $gatewayConfig->method('getConfig')->willReturn([PayPlugGatewayFactory::HOSTED_FIELDS => true]);
+
+ $paymentMethod = $this->createMock(PaymentMethodInterface::class);
+ $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig);
+
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getNumber')->willReturn('000000042');
+
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getMethod')->willReturn($paymentMethod);
+ $payment->method('getDetails')->willReturn($details);
+ $payment->method('getOrder')->willReturn($order);
+ $payment->method('getAmount')->willReturn(2400);
+ $payment->method('getId')->willReturn(42);
+
+ return $payment;
+ }
}
diff --git a/tests/PHPUnit/Provider/SupportedMethodsProviderTest.php b/tests/PHPUnit/Provider/SupportedMethodsProviderTest.php
index 6e015ea5..d208c8f3 100644
--- a/tests/PHPUnit/Provider/SupportedMethodsProviderTest.php
+++ b/tests/PHPUnit/Provider/SupportedMethodsProviderTest.php
@@ -8,9 +8,12 @@
use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface;
use PayPlug\SyliusPayPlugPlugin\Gateway\BancontactGatewayFactory;
use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory;
+use PayPlug\SyliusPayPlugPlugin\Gateway\ScalapayGatewayFactory;
use PayPlug\SyliusPayPlugPlugin\Provider\SupportedMethodsProvider;
+use PayPlug\SyliusPayPlugPlugin\Resolver\AccountAmountRangeResolver;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
+use Psr\Log\NullLogger;
use Sylius\Component\Core\Model\PaymentMethodInterface;
use Sylius\Component\Currency\Context\CurrencyContextInterface;
use Sylius\Component\Payment\Model\GatewayConfigInterface;
@@ -33,7 +36,7 @@ protected function setUp(): void
$this->clientFactory->method('create')->willReturn($this->apiClient);
- $this->provider = new SupportedMethodsProvider($this->currencyContext, $this->clientFactory);
+ $this->provider = new SupportedMethodsProvider($this->currencyContext, $this->clientFactory, new AccountAmountRangeResolver(), new NullLogger());
}
// -------------------------------------------------------------------------
@@ -67,6 +70,9 @@ public function testProvide_withDifferentFactory_doesNotFilter(): void
/**
* The current currency is USD but the method only authorizes EUR.
* Verifies the method is removed from the result list.
+ *
+ * No $paymentCurrencyCode is passed, so this also covers the documented fallback to
+ * CurrencyContextInterface for a payment carrying no currency of its own.
*/
public function testProvide_withUnauthorizedCurrency_removesMethod(): void
{
@@ -80,6 +86,91 @@ public function testProvide_withUnauthorizedCurrency_removesMethod(): void
self::assertEmpty($result);
}
+ /**
+ * The payment's own currency decides, not the one being displayed. Here the shopper browses in
+ * EUR (authorized) while the order was placed in USD (not authorized) — the amount is USD, so
+ * the method must go. Reading the display currency instead would keep a method whose limits
+ * were never checked against the amount's actual currency.
+ */
+ public function testProvide_withPaymentCurrencyUnauthorized_ignoresDisplayCurrency(): void
+ {
+ $this->currencyContext->method('getCurrencyCode')->willReturn('EUR');
+ $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000));
+
+ $method = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME);
+
+ $result = $this->provider->provide(
+ [$method],
+ PayPlugGatewayFactory::FACTORY_NAME,
+ 1000,
+ paymentCurrencyCode: 'USD',
+ );
+
+ self::assertEmpty($result);
+ }
+
+ /**
+ * The mirror case, and the one that was mis-filtering before: the order is in EUR (authorized,
+ * amount within bounds) while the shopper has switched the display to USD. The method must be
+ * kept — previously the USD display currency was compared against a EUR-only account and hid a
+ * payment method that would have been charged in EUR.
+ */
+ public function testProvide_withPaymentCurrencyAuthorized_keepsMethodDespiteDisplayCurrency(): void
+ {
+ $this->currencyContext->method('getCurrencyCode')->willReturn('USD');
+ $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000));
+
+ $method = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME);
+
+ $result = $this->provider->provide(
+ [$method],
+ PayPlugGatewayFactory::FACTORY_NAME,
+ 1000,
+ paymentCurrencyCode: 'EUR',
+ );
+
+ self::assertCount(1, $result);
+ }
+
+ /**
+ * Same unauthorized-currency setup, but the `payplug` method has Hosted Fields selected.
+ * UHF is exempt from the currency gate because the Retail `/account` payload does not know a
+ * UHF account's currencies (see the comment in SupportedMethodsProvider), so the method must
+ * survive — and, having no advertised limits for USD, must not be amount-filtered either
+ * despite 1000 sitting outside the EUR 99..2000000 range that the payload does advertise.
+ */
+ public function testProvide_withUnauthorizedCurrencyAndHostedFields_keepsMethod(): void
+ {
+ $this->currencyContext->method('getCurrencyCode')->willReturn('USD');
+ $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000));
+
+ $method = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME, [
+ PayPlugGatewayFactory::HOSTED_FIELDS => true,
+ ]);
+
+ $result = $this->provider->provide([$method], PayPlugGatewayFactory::FACTORY_NAME, 1000);
+
+ self::assertCount(1, $result);
+ }
+
+ /**
+ * Hosted Fields is exempt from the currency gate, not from the amount limits: when the active
+ * currency *is* advertised, its min/max still apply.
+ */
+ public function testProvide_withAuthorizedCurrencyAndHostedFields_stillAppliesAmountLimits(): void
+ {
+ $this->currencyContext->method('getCurrencyCode')->willReturn('EUR');
+ $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000));
+
+ $method = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME, [
+ PayPlugGatewayFactory::HOSTED_FIELDS => true,
+ ]);
+
+ $result = $this->provider->provide([$method], PayPlugGatewayFactory::FACTORY_NAME, 50);
+
+ self::assertEmpty($result);
+ }
+
// -------------------------------------------------------------------------
// provide() — amount below min_amount → method removed
// -------------------------------------------------------------------------
@@ -225,7 +316,7 @@ public function testProvide_withAllowedCountry_keepsMethod(): void
$this->apiClient->method('getAccount')->willReturn($account);
$method = $this->buildPaymentMethod('payplug_scalapay');
- $result = $this->provider->provide([$method], 'payplug_scalapay', 1000, 'FR');
+ $result = $this->provider->provide([$method], 'payplug_scalapay', 1000, billingCountryCode: 'FR');
self::assertCount(1, $result);
}
@@ -243,7 +334,7 @@ public function testProvide_withDisallowedCountry_removesMethod(): void
$this->apiClient->method('getAccount')->willReturn($account);
$method = $this->buildPaymentMethod('payplug_scalapay');
- $result = $this->provider->provide([$method], 'payplug_scalapay', 1000, 'US');
+ $result = $this->provider->provide([$method], 'payplug_scalapay', 1000, billingCountryCode: 'US');
self::assertEmpty($result);
}
@@ -261,7 +352,7 @@ public function testProvide_withAllowedCountriesAll_keepsMethod(): void
$this->apiClient->method('getAccount')->willReturn($account);
$method = $this->buildPaymentMethod('payplug_bancontact');
- $result = $this->provider->provide([$method], 'payplug_bancontact', 1000, 'US');
+ $result = $this->provider->provide([$method], 'payplug_bancontact', 1000, billingCountryCode: 'US');
self::assertCount(1, $result);
}
@@ -279,7 +370,7 @@ public function testProvide_withNullBillingCountry_keepsMethod(): void
$this->apiClient->method('getAccount')->willReturn($account);
$method = $this->buildPaymentMethod('payplug_scalapay');
- $result = $this->provider->provide([$method], 'payplug_scalapay', 1000, null);
+ $result = $this->provider->provide([$method], 'payplug_scalapay', 1000, billingCountryCode: null);
self::assertCount(1, $result);
}
@@ -359,6 +450,128 @@ public function testProvide_fallsBackToConfigurationAmounts(): void
self::assertEmpty($result2);
}
+ // -------------------------------------------------------------------------
+ // provide() — merchant-configured min/max override the API bounds
+ // -------------------------------------------------------------------------
+
+ /**
+ * The gateway config sets a min_amount (1000) tighter than the API min (99).
+ * Verifies amounts below the merchant's min are removed, and the merchant's own min boundary is kept.
+ */
+ public function testProvide_withMerchantConfiguredMinAmount_overridesApiMin(): void
+ {
+ $this->currencyContext->method('getCurrencyCode')->willReturn('EUR');
+ $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000));
+
+ $method = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, ['min_amount' => 1000]);
+
+ $result = $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 500);
+ self::assertEmpty($result);
+
+ $result2 = $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 1000);
+ self::assertCount(1, $result2);
+ }
+
+ /**
+ * The gateway config sets a max_amount (100000) tighter than the API max (2000000).
+ * Verifies amounts above the merchant's max are removed, and the merchant's own max boundary is kept.
+ */
+ public function testProvide_withMerchantConfiguredMaxAmount_overridesApiMax(): void
+ {
+ $this->currencyContext->method('getCurrencyCode')->willReturn('EUR');
+ $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000));
+
+ $method = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, ['max_amount' => 100000]);
+
+ $result = $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 150000);
+ self::assertEmpty($result);
+
+ $result2 = $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 100000);
+ self::assertCount(1, $result2);
+ }
+
+ /**
+ * No min_amount/max_amount set in the gateway config (merchant left the fields blank).
+ * Verifies the API bounds alone still apply, unchanged from today's behavior.
+ */
+ public function testProvide_withoutMerchantConfiguredAmounts_fallsBackToApiBoundsOnly(): void
+ {
+ $this->currencyContext->method('getCurrencyCode')->willReturn('EUR');
+ $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000));
+
+ $method = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, []);
+
+ $result = $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 50);
+ self::assertEmpty($result);
+
+ $result2 = $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 99);
+ self::assertCount(1, $result2);
+ }
+
+ /**
+ * The merchant's min_amount/max_amount override is entered as EUR (MoneyType field), but
+ * checkout is happening in USD. The EUR-denominated override must not be applied to a
+ * USD amount — only the API's own per-currency bounds apply.
+ */
+ public function testProvide_merchantConfiguredAmountsIgnoredForNonEurCurrency(): void
+ {
+ $this->currencyContext->method('getCurrencyCode')->willReturn('USD');
+
+ $account = [
+ 'configuration' => [
+ 'min_amounts' => ['USD' => 100],
+ 'max_amounts' => ['USD' => 200000],
+ ],
+ 'payment_methods' => [],
+ ];
+ $this->apiClient->method('getAccount')->willReturn($account);
+
+ // If wrongly applied to USD, this EUR-denominated max_amount would exclude the payment.
+ $method = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, ['max_amount' => 300]);
+
+ $result = $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 150000);
+ self::assertCount(1, $result);
+ }
+
+ /**
+ * The min_amount/max_amount keys are Scalapay's own: only IsScalapayAmountRangeValidValidator
+ * keeps them inside the API-authorized range at save time, and it is wired for Scalapay only.
+ * Another gateway carrying the same keys must therefore be left on the raw API bounds rather
+ * than granted an unvalidated checkout override.
+ */
+ public function testProvide_merchantConfiguredAmountsIgnoredForNonScalapayGateway(): void
+ {
+ $this->currencyContext->method('getCurrencyCode')->willReturn('EUR');
+ $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000));
+
+ // If wrongly honored, this max_amount would exclude the payment below.
+ $method = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME, ['min_amount' => 1000, 'max_amount' => 300]);
+
+ $result = $this->provider->provide([$method], PayPlugGatewayFactory::FACTORY_NAME, 150000);
+ self::assertCount(1, $result);
+ }
+
+ /**
+ * The gateway config is a plain serialized array, so a direct DB edit or an import script can
+ * leave a non-int in it. provide() runs on every checkout page with no surrounding try/catch:
+ * a malformed override must degrade to the API bounds, not throw and break payment-method
+ * resolution for the whole checkout.
+ */
+ public function testProvide_withMalformedMerchantConfiguredAmounts_fallsBackToApiBounds(): void
+ {
+ $this->currencyContext->method('getCurrencyCode')->willReturn('EUR');
+ $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000));
+
+ $method = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, ['min_amount' => '1000', 'max_amount' => 'nonsense']);
+
+ // API bounds are 99–2000000: an in-range amount is kept, an out-of-range one still removed.
+ $result = $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 150000);
+ self::assertCount(1, $result);
+
+ $result2 = $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 50);
+ self::assertEmpty($result2);
+ }
+
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
@@ -374,10 +587,15 @@ private function buildAccount(int $minAmount, int $maxAmount): array
];
}
- private function buildPaymentMethod(string $factoryName): PaymentMethodInterface
+ /**
+ * @param array $config Persisted gateway config; defaults to empty, which is
+ * neither integrated_payment nor hosted_fields.
+ */
+ private function buildPaymentMethod(string $factoryName, array $config = []): PaymentMethodInterface
{
$gatewayConfig = $this->createMock(GatewayConfigInterface::class);
$gatewayConfig->method('getFactoryName')->willReturn($factoryName);
+ $gatewayConfig->method('getConfig')->willReturn($config);
$method = $this->createMock(PaymentMethodInterface::class);
$method->method('getGatewayConfig')->willReturn($gatewayConfig);
diff --git a/tests/PHPUnit/Resolver/AccountAmountRangeResolverTest.php b/tests/PHPUnit/Resolver/AccountAmountRangeResolverTest.php
new file mode 100644
index 00000000..31dc7520
--- /dev/null
+++ b/tests/PHPUnit/Resolver/AccountAmountRangeResolverTest.php
@@ -0,0 +1,120 @@
+resolver = new AccountAmountRangeResolver();
+ }
+
+ public function testResolve_withoutPaymentMethodKey_usesConfigurationDefaults(): void
+ {
+ $account = [
+ 'configuration' => [
+ 'min_amounts' => ['EUR' => 100, 'USD' => 200],
+ 'max_amounts' => ['EUR' => 100000, 'USD' => 200000],
+ ],
+ ];
+
+ $result = $this->resolver->resolve($account, null);
+
+ self::assertSame([
+ 'EUR' => ['min_amount' => 100, 'max_amount' => 100000],
+ 'USD' => ['min_amount' => 200, 'max_amount' => 200000],
+ ], $result);
+ }
+
+ public function testResolve_withPaymentMethodOverride_usesOverrideInsteadOfDefaults(): void
+ {
+ $account = [
+ 'configuration' => [
+ 'min_amounts' => ['EUR' => 30],
+ 'max_amounts' => ['EUR' => 2000000],
+ ],
+ 'payment_methods' => [
+ 'scalapay' => [
+ 'min_amounts' => ['EUR' => 500],
+ 'max_amounts' => ['EUR' => 200000],
+ ],
+ ],
+ ];
+
+ $result = $this->resolver->resolve($account, 'scalapay');
+
+ self::assertSame(['EUR' => ['min_amount' => 500, 'max_amount' => 200000]], $result);
+ }
+
+ public function testResolve_withPaymentMethodKeyButNoOverride_fallsBackToConfigurationDefaults(): void
+ {
+ $account = [
+ 'configuration' => [
+ 'min_amounts' => ['EUR' => 30],
+ 'max_amounts' => ['EUR' => 2000000],
+ ],
+ 'payment_methods' => [
+ 'apple_pay' => [
+ 'enabled' => true,
+ // no min_amounts / max_amounts
+ ],
+ ],
+ ];
+
+ $result = $this->resolver->resolve($account, 'apple_pay');
+
+ self::assertSame(['EUR' => ['min_amount' => 30, 'max_amount' => 2000000]], $result);
+ }
+
+ /**
+ * The per-payment-method override is present but the wrong shape (a string, not an array).
+ * Verifies this degrades gracefully to the configuration defaults instead of blowing up on
+ * a malformed API response — this is the divergence the two original, independent
+ * implementations of this parsing logic used to disagree on.
+ */
+ public function testResolve_withMalformedOverride_fallsBackToConfigurationDefaults(): void
+ {
+ $account = [
+ 'configuration' => [
+ 'min_amounts' => ['EUR' => 30],
+ 'max_amounts' => ['EUR' => 2000000],
+ ],
+ 'payment_methods' => [
+ 'scalapay' => [
+ 'min_amounts' => 'not-an-array',
+ 'max_amounts' => 'not-an-array',
+ ],
+ ],
+ ];
+
+ $result = $this->resolver->resolve($account, 'scalapay');
+
+ self::assertSame(['EUR' => ['min_amount' => 30, 'max_amount' => 2000000]], $result);
+ }
+
+ public function testResolve_currencyMissingFromMaxAmounts_isExcluded(): void
+ {
+ $account = [
+ 'configuration' => [
+ 'min_amounts' => ['EUR' => 100, 'USD' => 200],
+ 'max_amounts' => ['EUR' => 100000],
+ ],
+ ];
+
+ $result = $this->resolver->resolve($account, null);
+
+ self::assertSame(['EUR' => ['min_amount' => 100, 'max_amount' => 100000]], $result);
+ }
+
+ public function testResolve_missingConfiguration_returnsEmptyArray(): void
+ {
+ self::assertSame([], $this->resolver->resolve([], null));
+ }
+}
diff --git a/tests/PHPUnit/Resolver/SelectedCardResolverTest.php b/tests/PHPUnit/Resolver/SelectedCardResolverTest.php
new file mode 100644
index 00000000..b5e49250
--- /dev/null
+++ b/tests/PHPUnit/Resolver/SelectedCardResolverTest.php
@@ -0,0 +1,67 @@
+setSession(new Session(new MockArraySessionStorage()));
+
+ $this->requestStack = new RequestStack();
+ $this->requestStack->push($request);
+ $this->payplugCardRepository = $this->createMock(RepositoryInterface::class);
+
+ $this->resolver = new SelectedCardResolver($this->requestStack, $this->payplugCardRepository);
+ }
+
+ public function testResolve_withNoCardIdInSession_returnsNull(): void
+ {
+ self::assertNull($this->resolver->resolve());
+ }
+
+ public function testResolve_withOtherCardSentinelSelected_returnsNull(): void
+ {
+ $this->requestStack->getSession()->set('payplug_payment_method', 'other');
+
+ self::assertNull($this->resolver->resolve());
+ }
+
+ public function testResolve_withSelectedCardIdFound_returnsTheCard(): void
+ {
+ $card = new Card();
+ $this->requestStack->getSession()->set('payplug_payment_method', self::SELECTED_CARD_ID);
+ $this->payplugCardRepository->method('find')->with(self::SELECTED_CARD_ID)->willReturn($card);
+
+ self::assertSame($card, $this->resolver->resolve());
+ }
+
+ public function testResolve_withSelectedCardIdNoLongerFound_returnsNull(): void
+ {
+ $this->requestStack->getSession()->set('payplug_payment_method', self::SELECTED_CARD_ID);
+ $this->payplugCardRepository->method('find')->with(self::SELECTED_CARD_ID)->willReturn(null);
+
+ self::assertNull($this->resolver->resolve());
+ }
+}
diff --git a/tests/PHPUnit/Twig/PayPlugExtensionTest.php b/tests/PHPUnit/Twig/PayPlugExtensionTest.php
new file mode 100644
index 00000000..ebf560e8
--- /dev/null
+++ b/tests/PHPUnit/Twig/PayPlugExtensionTest.php
@@ -0,0 +1,50 @@
+canSaveCardChecker = $this->createMock(CanSaveCardCheckerInterface::class);
+ $this->apiClientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class);
+
+ $this->extension = new PayPlugExtension($this->canSaveCardChecker, $this->apiClientFactory);
+ }
+
+ public function testHostedFieldsCompanyId_returnsCompanyIdFromAccount(): void
+ {
+ $paymentMethod = $this->createMock(PaymentMethodInterface::class);
+ $apiClient = $this->createMock(PayPlugApiClientInterface::class);
+ $apiClient->method('getAccount')->willReturn(['company_ref' => 'cmp_abc123']);
+ $this->apiClientFactory->method('createForPaymentMethod')->with($paymentMethod)->willReturn($apiClient);
+
+ self::assertSame('cmp_abc123', $this->extension->hostedFieldsCompanyId($paymentMethod));
+ }
+
+ public function testHostedFieldsCompanyId_missingCompanyIdKey_returnsEmptyString(): void
+ {
+ $paymentMethod = $this->createMock(PaymentMethodInterface::class);
+ $apiClient = $this->createMock(PayPlugApiClientInterface::class);
+ $apiClient->method('getAccount')->willReturn([]);
+ $this->apiClientFactory->method('createForPaymentMethod')->with($paymentMethod)->willReturn($apiClient);
+
+ self::assertSame('', $this->extension->hostedFieldsCompanyId($paymentMethod));
+ }
+}
diff --git a/tests/PHPUnit/Upc/CardDataFromPaymentMethodExtractorTest.php b/tests/PHPUnit/Upc/CardDataFromPaymentMethodExtractorTest.php
new file mode 100644
index 00000000..52568e78
--- /dev/null
+++ b/tests/PHPUnit/Upc/CardDataFromPaymentMethodExtractorTest.php
@@ -0,0 +1,124 @@
+ [
+ 'id' => 'card_xxx',
+ 'card' => [
+ 'network' => 'VISA',
+ 'code6x4' => '424242XXXXXX4242',
+ ],
+ 'details' => [
+ 'selectedBrand' => 'VISA',
+ 'validityDate' => '2027-12',
+ ],
+ ],
+ ]);
+
+ self::assertSame([
+ 'aliasId' => 'card_xxx',
+ 'brand' => 'VISA',
+ 'last4' => '4242',
+ 'expirationYear' => 2027,
+ 'expirationMonth' => 12,
+ ], CardDataFromPaymentMethodExtractor::extract($body));
+ }
+
+ public function testExtract_whenCardNetworkAndDetailsSelectedBrandDisagree_cardNetworkWins(): void
+ {
+ $body = json_encode([
+ 'paymentMethod' => [
+ 'card' => ['network' => 'VISA'],
+ 'details' => ['selectedBrand' => 'MASTERCARD'],
+ ],
+ ]);
+
+ self::assertSame(['brand' => 'VISA'], CardDataFromPaymentMethodExtractor::extract($body));
+ }
+
+ public function testExtract_withNoCardNetwork_fallsBackToDetailsSelectedBrand(): void
+ {
+ $body = json_encode([
+ 'paymentMethod' => [
+ 'card' => ['code6x4' => '424242XXXXXX4242'],
+ 'details' => ['selectedBrand' => 'MASTERCARD'],
+ ],
+ ]);
+
+ $result = CardDataFromPaymentMethodExtractor::extract($body);
+
+ self::assertSame('MASTERCARD', $result['brand']);
+ }
+
+ public function testExtract_withNonArrayBody_returnsEmptyArray(): void
+ {
+ self::assertSame([], CardDataFromPaymentMethodExtractor::extract('"just a string"'));
+ }
+
+ public function testExtract_withPaymentMethodKeyMissing_returnsEmptyArray(): void
+ {
+ self::assertSame([], CardDataFromPaymentMethodExtractor::extract(json_encode(['id' => 'op_1'])));
+ }
+
+ public function testExtract_withCardKeyMissing_returnsOnlyAliasId(): void
+ {
+ $body = json_encode(['paymentMethod' => ['id' => 'card_xxx', 'details' => ['selectedBrand' => 'VISA']]]);
+
+ self::assertSame(['aliasId' => 'card_xxx', 'brand' => 'VISA'], CardDataFromPaymentMethodExtractor::extract($body));
+ }
+
+ public function testExtract_withDetailsKeyMissing_returnsOnlyCardFields(): void
+ {
+ $body = json_encode(['paymentMethod' => ['card' => ['network' => 'VISA']]]);
+
+ self::assertSame(['brand' => 'VISA'], CardDataFromPaymentMethodExtractor::extract($body));
+ }
+
+ public function testExtract_withEmptyAliasId_omitsAliasId(): void
+ {
+ $body = json_encode(['paymentMethod' => ['id' => '']]);
+
+ self::assertSame([], CardDataFromPaymentMethodExtractor::extract($body));
+ }
+
+ public function testExtract_withCode6x4ShorterThanFourCharacters_omitsLast4(): void
+ {
+ $body = json_encode(['paymentMethod' => ['card' => ['code6x4' => '42']]]);
+
+ self::assertSame([], CardDataFromPaymentMethodExtractor::extract($body));
+ }
+
+ public function testExtract_withValidityDateNotMatchingTheExpectedFormat_omitsExpiration(): void
+ {
+ $body = json_encode(['paymentMethod' => ['details' => ['validityDate' => '1225']]]);
+
+ self::assertSame([], CardDataFromPaymentMethodExtractor::extract($body));
+ }
+
+ public function testExtract_withValidityDateOutOfRangeMonth_omitsExpiration(): void
+ {
+ $body = json_encode(['paymentMethod' => ['details' => ['validityDate' => '2027-13']]]);
+
+ self::assertSame([], CardDataFromPaymentMethodExtractor::extract($body));
+ }
+
+ public function testExtractFromDecoded_withAnAlreadyDecodedBody_behavesLikeExtract(): void
+ {
+ $decoded = ['paymentMethod' => ['id' => 'card_xxx', 'card' => ['network' => 'VISA']]];
+
+ self::assertSame(
+ ['aliasId' => 'card_xxx', 'brand' => 'VISA'],
+ CardDataFromPaymentMethodExtractor::extractFromDecoded($decoded),
+ );
+ }
+}
diff --git a/tests/PHPUnit/Upc/GatewayCredentialsResolverTest.php b/tests/PHPUnit/Upc/GatewayCredentialsResolverTest.php
new file mode 100644
index 00000000..03488faa
--- /dev/null
+++ b/tests/PHPUnit/Upc/GatewayCredentialsResolverTest.php
@@ -0,0 +1,84 @@
+createMock(GatewayConfigInterface::class);
+ $gatewayConfig->method('getConfig')->willReturn([
+ PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_123',
+ ]);
+
+ $method = $this->createMock(PaymentMethodInterface::class);
+ $method->method('getGatewayConfig')->willReturn($gatewayConfig);
+
+ self::assertSame('acct_123', GatewayCredentialsResolver::resolve($method));
+ }
+
+ /**
+ * The submerchant is no longer configurable here — it belongs to the EUR MID configurations,
+ * not to the multi-currency ones this flow targets — so a config left over from before the
+ * field was removed must resolve exactly like one without it rather than throwing.
+ */
+ public function testResolve_withALeftoverSubmerchantIdInConfig_ignoresIt(): void
+ {
+ $gatewayConfig = $this->createMock(GatewayConfigInterface::class);
+ $gatewayConfig->method('getConfig')->willReturn([
+ PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_123',
+ 'hfSubMerchantId' => 'submerchant_123',
+ ]);
+
+ $method = $this->createMock(PaymentMethodInterface::class);
+ $method->method('getGatewayConfig')->willReturn($gatewayConfig);
+
+ self::assertSame('acct_123', GatewayCredentialsResolver::resolve($method));
+ }
+
+ public function testResolve_withNoGatewayConfig_throws(): void
+ {
+ $method = $this->createMock(PaymentMethodInterface::class);
+ $method->method('getGatewayConfig')->willReturn(null);
+
+ $this->expectException(\LogicException::class);
+
+ GatewayCredentialsResolver::resolve($method);
+ }
+
+ public function testResolve_withMissingAccountId_throws(): void
+ {
+ $gatewayConfig = $this->createMock(GatewayConfigInterface::class);
+ $gatewayConfig->method('getConfig')->willReturn([]);
+
+ $method = $this->createMock(PaymentMethodInterface::class);
+ $method->method('getGatewayConfig')->willReturn($gatewayConfig);
+
+ $this->expectException(\LogicException::class);
+
+ GatewayCredentialsResolver::resolve($method);
+ }
+
+ public function testResolve_withBlankAccountId_throws(): void
+ {
+ $gatewayConfig = $this->createMock(GatewayConfigInterface::class);
+ $gatewayConfig->method('getConfig')->willReturn([
+ PayPlugGatewayFactory::HF_IDENTIFIER => '',
+ ]);
+
+ $method = $this->createMock(PaymentMethodInterface::class);
+ $method->method('getGatewayConfig')->willReturn($gatewayConfig);
+
+ $this->expectException(\LogicException::class);
+
+ GatewayCredentialsResolver::resolve($method);
+ }
+}
diff --git a/tests/PHPUnit/Upc/IntegrationDescriptionProviderTest.php b/tests/PHPUnit/Upc/IntegrationDescriptionProviderTest.php
new file mode 100644
index 00000000..2e3831b0
--- /dev/null
+++ b/tests/PHPUnit/Upc/IntegrationDescriptionProviderTest.php
@@ -0,0 +1,35 @@
+factory = new OrderAddressDtoCreator();
+ }
+
+ private function address(
+ ?string $phoneNumber = null,
+ string $countryCode = 'FR',
+ string $provinceCode = '75',
+ ): AddressInterface&MockObject {
+ $address = $this->createMock(AddressInterface::class);
+ $address->method('getFirstName')->willReturn('Jane');
+ $address->method('getLastName')->willReturn('Doe');
+ $address->method('getStreet')->willReturn('10 Rue de Rivoli');
+ $address->method('getCity')->willReturn('Paris');
+ $address->method('getCountryCode')->willReturn($countryCode);
+ $address->method('getProvinceCode')->willReturn($provinceCode);
+ $address->method('getPostcode')->willReturn('75001');
+ $address->method('getCompany')->willReturn('Acme Corp');
+ $address->method('getPhoneNumber')->willReturn($phoneNumber);
+
+ return $address;
+ }
+
+ private function orderWithAddresses(
+ ?AddressInterface $billing,
+ ?AddressInterface $shipping,
+ ?string $customerEmail = 'jane@example.com',
+ string $customerGender = '',
+ ): OrderInterface&MockObject {
+ $customer = $this->createMock(CustomerInterface::class);
+ $customer->method('getEmail')->willReturn($customerEmail);
+ $customer->method('getGender')->willReturn($customerGender);
+
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getCustomer')->willReturn($customer);
+ $order->method('getBillingAddress')->willReturn($billing);
+ $order->method('getShippingAddress')->willReturn($shipping);
+
+ return $order;
+ }
+
+ public function testCreateBilling_withNoBillingAddress_returnsNull(): void
+ {
+ $order = $this->orderWithAddresses(null, null);
+
+ self::assertNull($this->factory->createBilling($order));
+ }
+
+ public function testCreateShipping_withNoShippingAddress_returnsNull(): void
+ {
+ $order = $this->orderWithAddresses(null, null);
+
+ self::assertNull($this->factory->createShipping($order));
+ }
+
+ public function testCreateBilling_withAFullAddressAndMobilePhone_mapsEveryField(): void
+ {
+ $order = $this->orderWithAddresses($this->address('+33612345678'), null, customerGender: 'f');
+
+ $billing = $this->factory->createBilling($order);
+
+ self::assertNotNull($billing);
+ self::assertNotNull($billing->contact);
+ self::assertSame('Jane', $billing->contact->firstName);
+ self::assertSame('Doe', $billing->contact->lastName);
+ self::assertSame('MRS', $billing->title);
+ self::assertNull($billing->contact->phone);
+ self::assertSame('+33612345678', $billing->contact->mobilePhone);
+ self::assertNotNull($billing->address);
+ self::assertSame('10 Rue de Rivoli', $billing->address->line);
+ self::assertSame('Paris', $billing->address->city);
+ self::assertSame('FR', $billing->address->country);
+ self::assertSame('75', $billing->address->state);
+ self::assertSame('75001', $billing->address->zipCode);
+ }
+
+ public function testCreateBilling_withAProvinceCodeLongerThanThreeChars_omitsState(): void
+ {
+ $address = $this->address(provinceCode: 'US-CA');
+ $order = $this->orderWithAddresses($address, null);
+
+ $billing = $this->factory->createBilling($order);
+
+ self::assertNotNull($billing);
+ self::assertNotNull($billing->address);
+ self::assertNull($billing->address->state);
+ }
+
+ public function testCreateBilling_withALandlinePhone_setsPhoneNotMobilePhone(): void
+ {
+ $order = $this->orderWithAddresses($this->address('+33142345678'), null);
+
+ $billing = $this->factory->createBilling($order);
+
+ self::assertNotNull($billing);
+ self::assertNotNull($billing->contact);
+ self::assertSame('+33142345678', $billing->contact->phone);
+ self::assertNull($billing->contact->mobilePhone);
+ }
+
+ public function testCreateBilling_withNoPhoneNumber_leavesBothPhoneFieldsNull(): void
+ {
+ $order = $this->orderWithAddresses($this->address(null), null);
+
+ $billing = $this->factory->createBilling($order);
+
+ self::assertNotNull($billing);
+ self::assertNotNull($billing->contact);
+ self::assertNull($billing->contact->phone);
+ self::assertNull($billing->contact->mobilePhone);
+ }
+
+ public function testCreateBilling_withAnUnparseablePhoneNumber_leavesBothPhoneFieldsNullInsteadOfThrowing(): void
+ {
+ $order = $this->orderWithAddresses($this->address('not-a-phone-number'), null);
+
+ $billing = $this->factory->createBilling($order);
+
+ self::assertNotNull($billing);
+ self::assertNotNull($billing->contact);
+ self::assertNull($billing->contact->phone);
+ self::assertNull($billing->contact->mobilePhone);
+ }
+
+ public function testCreateBilling_withMaleGender_mapsTitleToMr(): void
+ {
+ $order = $this->orderWithAddresses($this->address(), null, customerGender: 'm');
+
+ $billing = $this->factory->createBilling($order);
+
+ self::assertNotNull($billing);
+ self::assertSame('MR', $billing->title);
+ }
+
+ public function testCreateBilling_withNoGender_leavesTitleNull(): void
+ {
+ $order = $this->orderWithAddresses($this->address(), null, customerGender: '');
+
+ $billing = $this->factory->createBilling($order);
+
+ self::assertNotNull($billing);
+ self::assertNull($billing->title);
+ }
+
+ public function testCreateShipping_withAFullAddress_mapsEveryFieldIncludingCustomerEmailAndCompany(): void
+ {
+ $order = $this->orderWithAddresses(null, $this->address('+33612345678'), customerEmail: 'jane@example.com');
+
+ $shipping = $this->factory->createShipping($order);
+
+ self::assertNotNull($shipping);
+ self::assertNotNull($shipping->contact);
+ self::assertSame('Jane', $shipping->contact->firstName);
+ self::assertSame('Doe', $shipping->contact->lastName);
+ self::assertSame('jane@example.com', $shipping->email);
+ self::assertSame('Acme Corp', $shipping->companyName);
+ self::assertNull($shipping->contact->phone);
+ self::assertSame('+33612345678', $shipping->contact->mobilePhone);
+ self::assertNotNull($shipping->address);
+ self::assertSame('75001', $shipping->address->zipCode);
+ }
+
+ public function testCreateBillingAndCreateShipping_areIndependentOfEachOther(): void
+ {
+ $order = $this->orderWithAddresses($this->address('+33612345678'), null);
+
+ self::assertNotNull($this->factory->createBilling($order));
+ self::assertNull($this->factory->createShipping($order));
+ }
+}
diff --git a/tests/PHPUnit/Upc/PaymentCaptureContextBuilderTest.php b/tests/PHPUnit/Upc/PaymentCaptureContextBuilderTest.php
new file mode 100644
index 00000000..533dc551
--- /dev/null
+++ b/tests/PHPUnit/Upc/PaymentCaptureContextBuilderTest.php
@@ -0,0 +1,296 @@
+urlGenerator = $this->createMock(UrlGeneratorInterface::class);
+ $this->afterPayUrlProvider = $this->createMock(UrlProviderInterface::class);
+ $this->afterPayUrlProvider->method('getUrl')->willReturn('https://shop.test/order/00000042/pay');
+ $this->requestStack = new RequestStack();
+
+ $this->builder = new PaymentCaptureContextBuilder(
+ $this->urlGenerator,
+ $this->afterPayUrlProvider,
+ new OrderAddressDtoCreator(),
+ $this->requestStack,
+ );
+ }
+
+ private function methodWithGatewayConfig(?array $config): PaymentMethodInterface&MockObject
+ {
+ $method = $this->createMock(PaymentMethodInterface::class);
+ if (null !== $config) {
+ $gatewayConfig = $this->createMock(GatewayConfigInterface::class);
+ $gatewayConfig->method('getConfig')->willReturn($config);
+ $method->method('getGatewayConfig')->willReturn($gatewayConfig);
+ }
+
+ return $method;
+ }
+
+ public function testResolveGatewayCredentials_withCompleteConfig_returnsAccountId(): void
+ {
+ $method = $this->methodWithGatewayConfig(['hfIdentifier' => 'acct_123']);
+
+ self::assertSame('acct_123', $this->builder->resolveGatewayCredentials($method));
+ }
+
+ public function testResolveGatewayCredentials_withNoGatewayConfig_throws(): void
+ {
+ $this->expectException(\LogicException::class);
+
+ $this->builder->resolveGatewayCredentials($this->methodWithGatewayConfig(null));
+ }
+
+ public function testResolveGatewayCredentials_withBlankAccountId_throws(): void
+ {
+ $this->expectException(\LogicException::class);
+
+ $this->builder->resolveGatewayCredentials($this->methodWithGatewayConfig(['hfIdentifier' => '']));
+ }
+
+ public function testResolvePaymentMethod_withNoMethodOnThePayment_throws(): void
+ {
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getMethod')->willReturn(null);
+
+ $this->expectException(\LogicException::class);
+
+ $this->builder->resolvePaymentMethod($payment);
+ }
+
+ public function testResolvePaymentMethod_withAMethodOnThePayment_returnsIt(): void
+ {
+ $method = $this->createMock(PaymentMethodInterface::class);
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getMethod')->willReturn($method);
+
+ self::assertSame($method, $this->builder->resolvePaymentMethod($payment));
+ }
+
+ public function testResolveAmountAndCurrency_withAmountOrCurrencyMissing_throws(): void
+ {
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getAmount')->willReturn(null);
+ $payment->method('getCurrencyCode')->willReturn('EUR');
+
+ $this->expectException(\LogicException::class);
+
+ $this->builder->resolveAmountAndCurrency($payment);
+ }
+
+ public function testResolveAmountAndCurrency_withBothSet_returnsThem(): void
+ {
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getAmount')->willReturn(1000);
+ $payment->method('getCurrencyCode')->willReturn('EUR');
+
+ self::assertSame([1000, 'EUR'], $this->builder->resolveAmountAndCurrency($payment));
+ }
+
+ public function testBuildCustomerDto_withNoCustomerOnTheOrder_throws(): void
+ {
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getCustomer')->willReturn(null);
+
+ $this->expectException(\LogicException::class);
+
+ $this->builder->buildCustomerDto($order);
+ }
+
+ public function testBuildCustomerDto_withNoCustomerEmail_throws(): void
+ {
+ $customer = $this->createMock(CustomerInterface::class);
+ $customer->method('getEmail')->willReturn(null);
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getCustomer')->willReturn($customer);
+
+ $this->expectException(\LogicException::class);
+
+ $this->builder->buildCustomerDto($order);
+ }
+
+ public function testBuildCustomerDto_withCustomerAndEmail_returnsCustomerDto(): void
+ {
+ $customer = $this->createMock(CustomerInterface::class);
+ $customer->method('getId')->willReturn(7);
+ $customer->method('getEmail')->willReturn('customer@example.com');
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getCustomer')->willReturn($customer);
+
+ $dto = $this->builder->buildCustomerDto($order);
+
+ self::assertSame('7', $dto->id);
+ self::assertSame('customer@example.com', $dto->email);
+ }
+
+ public function testBuildBrowserDto_withNoCurrentRequest_returnsNull(): void
+ {
+ self::assertNull($this->builder->buildBrowserDto());
+ }
+
+ public function testBuildBrowserDto_withACurrentRequest_returnsItsClientDetails(): void
+ {
+ $request = new Request(server: ['REMOTE_ADDR' => '203.0.113.5']);
+ $request->headers->set('referer', 'https://shop.test/checkout');
+ $request->headers->set('User-Agent', 'TestAgent/1.0');
+ $this->requestStack->push($request);
+
+ $dto = $this->builder->buildBrowserDto();
+
+ self::assertNotNull($dto);
+ self::assertSame('203.0.113.5', $dto->ip);
+ self::assertSame('https://shop.test/checkout', $dto->referrer);
+ self::assertSame('TestAgent/1.0', $dto->userAgent);
+ }
+
+ public function testBuildCommonFields_setsSuccessCancelAndNotificationUrls(): void
+ {
+ $this->urlGenerator->method('generate')->willReturn('https://shop.test/payplug/notify/abc');
+
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getNumber')->willReturn('00000042');
+
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getId')->willReturn(42);
+
+ $paymentRequest = $this->createMock(PaymentRequestInterface::class);
+ $paymentRequest->method('getPayment')->willReturn($payment);
+ $paymentRequest->method('getHash')->willReturn(Uuid::v4());
+
+ $common = $this->builder->buildCommonFields('acct_123', 1000, 'eur', $paymentRequest, $order);
+
+ self::assertSame('acct_123', $common->accountId);
+ self::assertSame(1000, $common->amount);
+ self::assertSame('EUR', $common->currency);
+ self::assertSame('00000042', $common->orderId);
+ self::assertNull($common->submerchantExternalId);
+ self::assertSame('https://shop.test/payplug/notify/abc', $common->notificationUrl);
+ self::assertSame('https://shop.test/order/00000042/pay', $common->successUrl);
+ self::assertSame('https://shop.test/order/00000042/pay?status=canceled', $common->cancelUrl);
+ }
+
+ public function testBuildCommonFields_withNoOrder_fallsBackToPaymentIdAsOrderId(): void
+ {
+ $this->urlGenerator->method('generate')->willReturn('https://shop.test/payplug/notify/abc');
+
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getId')->willReturn(42);
+
+ $paymentRequest = $this->createMock(PaymentRequestInterface::class);
+ $paymentRequest->method('getPayment')->willReturn($payment);
+ $paymentRequest->method('getHash')->willReturn(Uuid::v4());
+
+ $common = $this->builder->buildCommonFields('acct_123', 1000, 'eur', $paymentRequest, null);
+
+ self::assertSame('42', $common->orderId);
+ self::assertNull($common->billing);
+ self::assertNull($common->shipping);
+ }
+
+ public function testBuildCommonFields_withAnOrderItem_usesItsProductNameAsDescription(): void
+ {
+ $this->urlGenerator->method('generate')->willReturn('https://shop.test/payplug/notify/abc');
+
+ $item = $this->createMock(OrderItemInterface::class);
+ $item->method('getProductName')->willReturn('Blue T-Shirt');
+
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getNumber')->willReturn('00000042');
+ $order->method('getItems')->willReturn(new ArrayCollection([$item]));
+
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getId')->willReturn(42);
+
+ $paymentRequest = $this->createMock(PaymentRequestInterface::class);
+ $paymentRequest->method('getPayment')->willReturn($payment);
+ $paymentRequest->method('getHash')->willReturn(Uuid::v4());
+
+ $common = $this->builder->buildCommonFields('acct_123', 1000, 'eur', $paymentRequest, $order);
+
+ self::assertSame('Blue T-Shirt', $common->description);
+ }
+
+ public function testBuildCommonFields_withNoOrderItem_fallsBackToTheIntegrationDescription(): void
+ {
+ $this->urlGenerator->method('generate')->willReturn('https://shop.test/payplug/notify/abc');
+
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getNumber')->willReturn('00000042');
+ $order->method('getItems')->willReturn(new ArrayCollection());
+
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getId')->willReturn(42);
+
+ $paymentRequest = $this->createMock(PaymentRequestInterface::class);
+ $paymentRequest->method('getPayment')->willReturn($payment);
+ $paymentRequest->method('getHash')->willReturn(Uuid::v4());
+
+ $common = $this->builder->buildCommonFields('acct_123', 1000, 'eur', $paymentRequest, $order);
+
+ self::assertNotNull($common->description);
+ self::assertNotSame('Blue T-Shirt', $common->description);
+ }
+
+ public function testResolveFullNameForCardDetails_withABillingAddressFullName_returnsIt(): void
+ {
+ $billingAddress = $this->createMock(AddressInterface::class);
+ $billingAddress->method('getFullName')->willReturn('Jane Doe');
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getBillingAddress')->willReturn($billingAddress);
+
+ self::assertSame('Jane Doe', $this->builder->resolveFullNameForCardDetails($order));
+ }
+
+ public function testResolveFullNameForCardDetails_withNoBillingAddressFullName_fallsBackToTheCustomerFullName(): void
+ {
+ $billingAddress = $this->createMock(AddressInterface::class);
+ $billingAddress->method('getFullName')->willReturn('');
+ $customer = $this->createMock(CustomerInterface::class);
+ $customer->method('getFullName')->willReturn('Jane Customer');
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getBillingAddress')->willReturn($billingAddress);
+ $order->method('getCustomer')->willReturn($customer);
+
+ self::assertSame('Jane Customer', $this->builder->resolveFullNameForCardDetails($order));
+ }
+
+ public function testResolveFullNameForCardDetails_withNoNameAvailableAnywhere_returnsNull(): void
+ {
+ $order = $this->createMock(OrderInterface::class);
+
+ self::assertNull($this->builder->resolveFullNameForCardDetails($order));
+ }
+}
diff --git a/tests/PHPUnit/Upc/PaymentCaptureOutcomeApplierTest.php b/tests/PHPUnit/Upc/PaymentCaptureOutcomeApplierTest.php
new file mode 100644
index 00000000..801d685b
--- /dev/null
+++ b/tests/PHPUnit/Upc/PaymentCaptureOutcomeApplierTest.php
@@ -0,0 +1,194 @@
+logger = $this->createMock(LoggerInterface::class);
+ $this->stateMachine = $this->createMock(StateMachineInterface::class);
+ $this->orderStateMutator = $this->createMock(IOrderStateMutator::class);
+
+ // A real Session/FlashBag rather than a mock: the assertions below are about what the
+ // shopper actually ends up seeing, which is the flash bag's contents.
+ $this->session = new Session(new MockArraySessionStorage());
+ $request = new Request();
+ $request->setSession($this->session);
+ $this->requestStack = new RequestStack();
+ $this->requestStack->push($request);
+
+ $this->applier = $this->createApplier($this->requestStack);
+ }
+
+ private function createApplier(RequestStack $requestStack): PaymentCaptureOutcomeApplier
+ {
+ return new PaymentCaptureOutcomeApplier(
+ $this->logger,
+ $this->stateMachine,
+ $this->orderStateMutator,
+ $requestStack,
+ );
+ }
+
+ public function testFailPaymentRequest_tellsTheShopperTheTransactionDidNotGoThrough(): void
+ {
+ $this->applier->failPaymentRequest(
+ $this->createMock(PaymentRequestInterface::class),
+ $this->createMock(PaymentInterface::class),
+ new \LogicException('boom'),
+ PaymentCaptureFlow::Alias,
+ );
+
+ self::assertSame(
+ [self::SHOPPER_ERROR_FLASH_KEY],
+ $this->session->getFlashBag()->get('error'),
+ );
+ }
+
+ public function testFailPaymentRequest_neverLeaksTheExceptionMessageToTheShopper(): void
+ {
+ // Real example from a UPC 403: the raw message names internal infrastructure and the
+ // account's configuration, so it must stay in the log and out of the flash bag.
+ $leaky = 'The IP address "10.204.92.13" is not allowed to access this account.';
+
+ $this->applier->failPaymentRequest(
+ $this->createMock(PaymentRequestInterface::class),
+ $this->createMock(PaymentInterface::class),
+ new \RuntimeException($leaky),
+ PaymentCaptureFlow::Alias,
+ );
+
+ self::assertSame([self::SHOPPER_ERROR_FLASH_KEY], $this->session->getFlashBag()->get('error'));
+ }
+
+ public function testFailPaymentRequest_withoutASession_stillFailsThePaymentRequest(): void
+ {
+ // Reachable from the CLI (UpdatePaymentStateCommand) and from worker contexts, where
+ // Request::getSession() would throw — a failed payment must not become a 500 because
+ // there was nowhere to put a flash message.
+ $requestStack = new RequestStack();
+ $requestStack->push(new Request());
+ $paymentRequest = $this->createMock(PaymentRequestInterface::class);
+
+ $this->stateMachine->expects(self::once())->method('apply')
+ ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL);
+
+ $this->createApplier($requestStack)->failPaymentRequest(
+ $paymentRequest,
+ $this->createMock(PaymentInterface::class),
+ new \LogicException('boom'),
+ PaymentCaptureFlow::Alias,
+ );
+ }
+
+ public function testFailPaymentRequest_withNoRequestAtAll_stillFailsThePaymentRequest(): void
+ {
+ $paymentRequest = $this->createMock(PaymentRequestInterface::class);
+
+ $this->stateMachine->expects(self::once())->method('apply')
+ ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL);
+
+ $this->createApplier(new RequestStack())->failPaymentRequest(
+ $paymentRequest,
+ $this->createMock(PaymentInterface::class),
+ new \LogicException('boom'),
+ PaymentCaptureFlow::Alias,
+ );
+ }
+
+ public function testFailPaymentRequest_logsSetsResponseDataAndAppliesFailTransition(): void
+ {
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getId')->willReturn(42);
+ $paymentRequest = $this->createMock(PaymentRequestInterface::class);
+
+ $this->logger->expects(self::once())->method('error')
+ ->with(self::stringContains('Hosted payment creation failed.'), self::anything());
+ $paymentRequest->expects(self::once())->method('setResponseData')->with(['error' => 'boom']);
+ $this->stateMachine->expects(self::once())->method('apply')
+ ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL);
+
+ $this->applier->failPaymentRequest($paymentRequest, $payment, new \LogicException('boom'), PaymentCaptureFlow::Hosted);
+ }
+
+ public function testApplyOutcome_withRedirectHtml_storesItAndNeverAppliesOrderStateMutator(): void
+ {
+ $payment = $this->createMock(PaymentInterface::class);
+ $paymentRequest = $this->createMock(PaymentRequestInterface::class);
+ $output = new PaymentOutput(201, '{"id":"pay_1","execCode":"0001"}', null, '', null);
+
+ $paymentRequest->expects(self::once())->method('setResponseData')->with(['redirect_html' => '']);
+ $this->orderStateMutator->expects(self::never())->method('apply');
+
+ $this->applier->applyOutcome($paymentRequest, $payment, $output);
+ }
+
+ public function testApplyOutcome_withRedirectUrl_storesItAndNeverAppliesOrderStateMutator(): void
+ {
+ $payment = $this->createMock(PaymentInterface::class);
+ $paymentRequest = $this->createMock(PaymentRequestInterface::class);
+ $output = new PaymentOutput(201, '{"id":"pay_1"}', 'https://example.com/3ds', null, null);
+
+ $paymentRequest->expects(self::once())->method('setResponseData')->with(['redirect_url' => 'https://example.com/3ds']);
+ $this->orderStateMutator->expects(self::never())->method('apply');
+
+ $this->applier->applyOutcome($paymentRequest, $payment, $output);
+ }
+
+ public function testApplyOutcome_withDirectSuccessExecCode_appliesPaidOutcomeToOrderStateMutator(): void
+ {
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getId')->willReturn(42);
+ $paymentRequest = $this->createMock(PaymentRequestInterface::class);
+ $output = new PaymentOutput(201, '{"id":"pay_1","execCode":"0000"}', null, null, null);
+
+ $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::PAID);
+
+ $this->applier->applyOutcome($paymentRequest, $payment, $output);
+ }
+
+ public function testApplyOutcome_withNoExecCodeInResponseBody_neverAppliesOrderStateMutator(): void
+ {
+ $payment = $this->createMock(PaymentInterface::class);
+ $paymentRequest = $this->createMock(PaymentRequestInterface::class);
+ $output = new PaymentOutput(201, '{"id":"pay_1"}', null, null, null);
+
+ $this->orderStateMutator->expects(self::never())->method('apply');
+
+ $this->applier->applyOutcome($paymentRequest, $payment, $output);
+ }
+}
diff --git a/tests/PHPUnit/Upc/PayplugCardPersisterTest.php b/tests/PHPUnit/Upc/PayplugCardPersisterTest.php
new file mode 100644
index 00000000..29699845
--- /dev/null
+++ b/tests/PHPUnit/Upc/PayplugCardPersisterTest.php
@@ -0,0 +1,235 @@
+payplugCardFactory = $this->createMock(FactoryInterface::class);
+ $this->payplugCardRepository = $this->createMock(RepositoryInterface::class);
+ $this->managerRegistry = $this->createMock(ManagerRegistry::class);
+
+ $this->persister = new PayplugCardPersister($this->payplugCardFactory, $this->payplugCardRepository, $this->managerRegistry);
+ }
+
+ private function paymentWithOrder(?CustomerInterface $customer): PaymentInterface&MockObject
+ {
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getCustomer')->willReturn($customer);
+
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getOrder')->willReturn($order);
+
+ return $payment;
+ }
+
+ private function corePaymentMethod(bool $live = false): CorePaymentMethodInterface&MockObject
+ {
+ $gatewayConfig = $this->createMock(GatewayConfigInterface::class);
+ $gatewayConfig->method('getConfig')->willReturn(['live' => $live]);
+
+ $method = $this->createMock(CorePaymentMethodInterface::class);
+ $method->method('getGatewayConfig')->willReturn($gatewayConfig);
+
+ return $method;
+ }
+
+ public function testPersist_withNoCustomerOnTheOrder_doesNotPersistACard(): void
+ {
+ $payment = $this->paymentWithOrder(null);
+
+ $this->payplugCardRepository->expects(self::never())->method('add');
+
+ $this->persister->persist('alias_1', $payment, $this->corePaymentMethod(), [], []);
+ }
+
+ public function testPersist_withMethodNotACorePaymentMethod_doesNotPersistACard(): void
+ {
+ $payment = $this->paymentWithOrder($this->createMock(CustomerInterface::class));
+ $method = $this->createMock(PaymentMethodInterface::class);
+
+ $this->payplugCardRepository->expects(self::never())->method('add');
+
+ $this->persister->persist('alias_1', $payment, $method, [], []);
+ }
+
+ public function testPersist_withAliasAlreadyStored_doesNotPersistADuplicate(): void
+ {
+ $payment = $this->paymentWithOrder($this->createMock(CustomerInterface::class));
+
+ $this->payplugCardRepository->method('findOneBy')
+ ->with(['externalId' => 'alias_1', 'isLive' => false])
+ ->willReturn(new Card());
+ $this->payplugCardRepository->expects(self::never())->method('add');
+
+ $this->persister->persist('alias_1', $payment, $this->corePaymentMethod(), [], []);
+ }
+
+ public function testPersist_whenAddLosesARaceAgainstAConcurrentPersistCallForTheSameAlias_doesNotThrow(): void
+ {
+ $payment = $this->paymentWithOrder($this->createMock(CustomerInterface::class));
+
+ $card = new Card();
+ $this->payplugCardFactory->method('createNew')->willReturn($card);
+ $this->payplugCardRepository->method('add')->with($card)
+ ->willThrowException($this->createMock(UniqueConstraintViolationException::class));
+ $this->managerRegistry->expects(self::once())->method('resetManager');
+
+ $this->persister->persist('alias_1', $payment, $this->corePaymentMethod(), [], []);
+
+ $this->addToAssertionCount(1);
+ }
+
+ public function testPersist_withFetchedCardDataAvailable_takesPrecedenceOverDetailsFallback(): void
+ {
+ $customer = $this->createMock(CustomerInterface::class);
+ $payment = $this->paymentWithOrder($customer);
+ $method = $this->corePaymentMethod(live: true);
+
+ $card = new Card();
+ $this->payplugCardFactory->method('createNew')->willReturn($card);
+ $this->payplugCardRepository->expects(self::once())->method('add')->with($card);
+
+ $this->persister->persist(
+ 'alias_1',
+ $payment,
+ $method,
+ [
+ 'hosted_fields_selected_brand' => 'CB',
+ 'hosted_fields_last4' => '0000',
+ 'hosted_fields_expiration_month' => 1,
+ 'hosted_fields_expiration_year' => 2020,
+ 'hosted_fields_country' => 'DE',
+ ],
+ [
+ 'brand' => 'VISA',
+ 'last4' => '4242',
+ 'expirationMonth' => 12,
+ 'expirationYear' => 2030,
+ ],
+ );
+
+ self::assertSame($customer, $card->getCustomer());
+ self::assertSame('alias_1', $card->getExternalId());
+ self::assertSame('VISA', $card->getBrand());
+ self::assertSame('4242', $card->getLast4());
+ self::assertSame(12, $card->getExpirationMonth());
+ self::assertSame(2030, $card->getExpirationYear());
+ // No card country field exists on the operation resource, so it always comes from $details.
+ self::assertSame('DE', $card->getCountryCode());
+ self::assertTrue($card->isLive());
+ self::assertSame($method, $card->getPaymentMethod());
+ }
+
+ public function testPersist_withNoFetchedCardDataAndNoDetails_usesEmptyDefaults(): void
+ {
+ $payment = $this->paymentWithOrder($this->createMock(CustomerInterface::class));
+
+ $card = new Card();
+ $this->payplugCardFactory->method('createNew')->willReturn($card);
+
+ $this->persister->persist('alias_1', $payment, $this->corePaymentMethod(), [], []);
+
+ self::assertSame('', $card->getBrand());
+ self::assertSame('', $card->getLast4());
+ self::assertSame(0, $card->getExpirationMonth());
+ self::assertSame(0, $card->getExpirationYear());
+ self::assertSame('', $card->getCountryCode());
+ }
+
+ public function testPersist_withValidDetailsFallbackOnly_usesTheSanitizedValues(): void
+ {
+ $payment = $this->paymentWithOrder($this->createMock(CustomerInterface::class));
+
+ $card = new Card();
+ $this->payplugCardFactory->method('createNew')->willReturn($card);
+
+ // Computed relative to today rather than hardcoded, since sanitizeExpirationYear() rejects
+ // anything before the wall-clock current year — a fixed literal would eventually become a
+ // past year and start failing this test for no code-regression reason.
+ $futureYear = (int) (new \DateTimeImmutable())->format('Y') + 1;
+
+ $this->persister->persist(
+ 'alias_1',
+ $payment,
+ $this->corePaymentMethod(),
+ [
+ 'hosted_fields_last4' => '4242',
+ 'hosted_fields_expiration_month' => 12,
+ 'hosted_fields_expiration_year' => $futureYear,
+ 'hosted_fields_country' => 'fr',
+ ],
+ [],
+ );
+
+ self::assertSame('4242', $card->getLast4());
+ self::assertSame(12, $card->getExpirationMonth());
+ self::assertSame($futureYear, $card->getExpirationYear());
+ // Uppercased regardless of the case the client submitted it in.
+ self::assertSame('FR', $card->getCountryCode());
+ }
+
+ /**
+ * @dataProvider malformedDetailsFallbackProvider
+ *
+ * @param mixed[] $details
+ */
+ public function testPersist_withMalformedDetailsFallbackValues_discardsThemAsIfAbsent(
+ array $details,
+ string $getter,
+ string|int $defaultValue,
+ ): void {
+ $payment = $this->paymentWithOrder($this->createMock(CustomerInterface::class));
+
+ $card = new Card();
+ $this->payplugCardFactory->method('createNew')->willReturn($card);
+
+ $this->persister->persist('alias_1', $payment, $this->corePaymentMethod(), $details, []);
+
+ self::assertSame($defaultValue, $card->$getter());
+ }
+
+ /** @return array */
+ public static function malformedDetailsFallbackProvider(): array
+ {
+ return [
+ 'brand not in the allowed list' => [['hosted_fields_selected_brand' => 'AMEX'], 'getBrand', ''],
+ 'last4 not 4 digits' => [['hosted_fields_last4' => '42'], 'getLast4', ''],
+ 'last4 not numeric' => [['hosted_fields_last4' => 'abcd'], 'getLast4', ''],
+ 'last4 with trailing newline' => [['hosted_fields_last4' => "4242\n"], 'getLast4', ''],
+ 'expiration month out of range' => [['hosted_fields_expiration_month' => 13], 'getExpirationMonth', 0],
+ 'expiration month zero' => [['hosted_fields_expiration_month' => 0], 'getExpirationMonth', 0],
+ 'expiration year in the past' => [['hosted_fields_expiration_year' => 2000], 'getExpirationYear', 0],
+ 'expiration year implausibly far ahead' => [['hosted_fields_expiration_year' => 9999], 'getExpirationYear', 0],
+ 'country not two letters' => [['hosted_fields_country' => 'FRA'], 'getCountryCode', ''],
+ 'country not alphabetic' => [['hosted_fields_country' => '12'], 'getCountryCode', ''],
+ 'country with trailing newline' => [['hosted_fields_country' => "fr\n"], 'getCountryCode', ''],
+ ];
+ }
+}
diff --git a/tests/PHPUnit/Upc/RefundDetailsLockKeyTest.php b/tests/PHPUnit/Upc/RefundDetailsLockKeyTest.php
new file mode 100644
index 00000000..f3c5f4ae
--- /dev/null
+++ b/tests/PHPUnit/Upc/RefundDetailsLockKeyTest.php
@@ -0,0 +1,38 @@
+expectException(\LogicException::class);
+
+ RefundDetailsLockKey::forPaymentId([42]);
+ }
+
+ /**
+ * The same payment id must always resolve to the same key regardless of caller — this is what
+ * lets RefundPaymentProcessor and HostedFieldsWebhookNotificationHandler actually serialize
+ * against each other.
+ */
+ public function testForPaymentId_isStableAcrossCalls(): void
+ {
+ self::assertSame(RefundDetailsLockKey::forPaymentId(42), RefundDetailsLockKey::forPaymentId(42));
+ }
+}
diff --git a/tests/PHPUnit/Upc/SyliusOrderStateMutatorTest.php b/tests/PHPUnit/Upc/SyliusOrderStateMutatorTest.php
new file mode 100644
index 00000000..7fdf0ebc
--- /dev/null
+++ b/tests/PHPUnit/Upc/SyliusOrderStateMutatorTest.php
@@ -0,0 +1,88 @@
+paymentRepository = $this->createMock(PaymentRepositoryInterface::class);
+ $this->stateMachine = $this->createMock(StateMachineInterface::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+ $this->mutator = new SyliusOrderStateMutator($this->paymentRepository, $this->stateMachine, $this->logger);
+ }
+
+ /**
+ * @dataProvider outcomeToTransitionProvider
+ */
+ public function testApply_mapsOutcomeToTheExpectedTransition(string $outcome, string $expectedTransition): void
+ {
+ $payment = $this->createMock(PaymentInterface::class);
+ $this->paymentRepository->method('find')->with(42)->willReturn($payment);
+ $this->stateMachine->method('can')->with($payment, PaymentTransitions::GRAPH, $expectedTransition)->willReturn(true);
+ $this->stateMachine->expects(self::once())->method('apply')->with($payment, PaymentTransitions::GRAPH, $expectedTransition);
+
+ $this->mutator->apply('42', $outcome);
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function outcomeToTransitionProvider(): iterable
+ {
+ yield 'paid' => [PaymentOutcome::PAID, PaymentTransitions::TRANSITION_COMPLETE];
+ yield 'capture_required' => [PaymentOutcome::CAPTURE_REQUIRED, PaymentTransitions::TRANSITION_COMPLETE];
+ yield 'authorized' => [PaymentOutcome::AUTHORIZED, PaymentTransitions::TRANSITION_AUTHORIZE];
+ yield 'refunded' => [PaymentOutcome::REFUNDED, PaymentTransitions::TRANSITION_REFUND];
+ yield 'failed' => [PaymentOutcome::FAILED, PaymentTransitions::TRANSITION_FAIL];
+ }
+
+ public function testApply_forThreeDsPending_doesNothing(): void
+ {
+ $payment = $this->createMock(PaymentInterface::class);
+ $this->paymentRepository->method('find')->willReturn($payment);
+ $this->stateMachine->expects(self::never())->method('apply');
+
+ $this->mutator->apply('42', PaymentOutcome::THREE_DS_PENDING);
+ }
+
+ public function testApply_whenPaymentNotFound_logsAndDoesNothing(): void
+ {
+ $this->paymentRepository->method('find')->with(42)->willReturn(null);
+ $this->logger->expects(self::once())->method('error');
+ $this->stateMachine->expects(self::never())->method('apply');
+
+ $this->mutator->apply('42', PaymentOutcome::PAID);
+ }
+
+ public function testApply_whenTransitionNotAllowed_logsAndDoesNotApply(): void
+ {
+ $payment = $this->createMock(PaymentInterface::class);
+ $this->paymentRepository->method('find')->willReturn($payment);
+ $this->stateMachine->method('can')->willReturn(false);
+ $this->logger->expects(self::once())->method('warning');
+ $this->stateMachine->expects(self::never())->method('apply');
+
+ $this->mutator->apply('42', PaymentOutcome::PAID);
+ }
+}
diff --git a/tests/PHPUnit/Upc/SyliusPaymentOperationRepositoryTest.php b/tests/PHPUnit/Upc/SyliusPaymentOperationRepositoryTest.php
new file mode 100644
index 00000000..af3c17bd
--- /dev/null
+++ b/tests/PHPUnit/Upc/SyliusPaymentOperationRepositoryTest.php
@@ -0,0 +1,110 @@
+entityManager = $this->createMock(EntityManagerInterface::class);
+ $this->doctrineRepository = $this->createMock(EntityRepository::class);
+ $this->entityManager->method('getRepository')->with(PayPlugOperation::class)->willReturn($this->doctrineRepository);
+ $this->repository = new SyliusPaymentOperationRepository($this->entityManager);
+ }
+
+ public function testGetByOrderId_whenFound_returnsOperationData(): void
+ {
+ $entity = new PayPlugOperation('42', 'op_123', '0000', PaymentOutcome::PAID, 1000);
+ $this->doctrineRepository->method('findOneBy')->with(['orderId' => '42'])->willReturn($entity);
+
+ $result = $this->repository->getByOrderId('42');
+
+ self::assertSame('op_123', $result->operationId);
+ }
+
+ public function testGetByOrderId_whenMissing_throwsPaymentNotFoundException(): void
+ {
+ $this->doctrineRepository->method('findOneBy')->with(['orderId' => '42'])->willReturn(null);
+
+ $this->expectException(PaymentNotFoundException::class);
+
+ $this->repository->getByOrderId('42');
+ }
+
+ public function testGetByOperationId_whenFound_returnsOperationData(): void
+ {
+ $entity = new PayPlugOperation('42', 'op_123', '0000', PaymentOutcome::PAID, 1000);
+ $this->doctrineRepository->method('findOneBy')->with(['operationId' => 'op_123'])->willReturn($entity);
+
+ $result = $this->repository->getByOperationId('op_123');
+
+ self::assertSame('42', $result->orderId);
+ }
+
+ public function testGetByOperationId_whenMissing_throwsPaymentNotFoundException(): void
+ {
+ $this->doctrineRepository->method('findOneBy')->with(['operationId' => 'op_123'])->willReturn(null);
+
+ $this->expectException(PaymentNotFoundException::class);
+
+ $this->repository->getByOperationId('op_123');
+ }
+
+ public function testSave_persistsAndFlushesANewEntity(): void
+ {
+ $data = new OperationData('op_123', '0000', PaymentOutcome::PAID, 1000, '42');
+ $this->doctrineRepository->method('findOneBy')->with(['operationId' => 'op_123'])->willReturn(null);
+
+ $this->entityManager->expects(self::once())->method('persist')
+ ->with(self::isInstanceOf(PayPlugOperation::class));
+ $this->entityManager->expects(self::once())->method('flush');
+
+ $this->repository->save($data);
+ }
+
+ public function testIsTreated_delegatesToTheStoredEntity(): void
+ {
+ $entity = new PayPlugOperation('42', 'op_123', '0000', PaymentOutcome::PAID, 1000);
+ $entity->markTreated();
+ $this->doctrineRepository->method('findOneBy')->with(['operationId' => 'op_123'])->willReturn($entity);
+
+ self::assertTrue($this->repository->isTreated('op_123'));
+ }
+
+ public function testIsTreated_whenMissing_returnsFalse(): void
+ {
+ $this->doctrineRepository->method('findOneBy')->with(['operationId' => 'op_123'])->willReturn(null);
+
+ self::assertFalse($this->repository->isTreated('op_123'));
+ }
+
+ public function testMarkTreated_flagsTheStoredEntityAndFlushes(): void
+ {
+ $entity = new PayPlugOperation('42', 'op_123', '0000', PaymentOutcome::PAID, 1000);
+ $this->doctrineRepository->method('findOneBy')->with(['operationId' => 'op_123'])->willReturn($entity);
+
+ $this->entityManager->expects(self::once())->method('flush');
+
+ $this->repository->markTreated('op_123');
+
+ self::assertTrue($entity->isTreated());
+ }
+}
diff --git a/tests/PHPUnit/Upc/SyliusUnifiedApiHttpClientTest.php b/tests/PHPUnit/Upc/SyliusUnifiedApiHttpClientTest.php
new file mode 100644
index 00000000..d26d6393
--- /dev/null
+++ b/tests/PHPUnit/Upc/SyliusUnifiedApiHttpClientTest.php
@@ -0,0 +1,123 @@
+httpClient = $this->createMock(HttpClientInterface::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+ $this->adapter = new SyliusUnifiedApiHttpClient($this->httpClient, $this->logger, true);
+ }
+
+ public function testGet_sendsGetRequestWithHeaders(): 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'],
+ 'timeout' => 10,
+ ])
+ ->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_sendsJsonEncodedBody(): 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],
+ 'headers' => ['Authorization' => 'Bearer jwt'],
+ 'timeout' => 10,
+ ])
+ ->willReturn($response);
+
+ $result = $this->adapter->postJson('https://api.payplug.com/payments', ['amount' => 1000], ['Authorization' => 'Bearer jwt']);
+
+ self::assertSame(['status' => 201, 'body' => '{"id":"pay_123"}'], $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']);
+ }
+
+ public function testPostJson_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->postJson('https://api.payplug.com/payments', []);
+
+ self::assertSame(0, $result['status']);
+ self::assertSame('Could not resolve host', $result['body']);
+ }
+
+ public function testGet_whenVerifyTlsDisabled_passesVerifyPeerAndVerifyHostFalse(): void
+ {
+ $adapter = new SyliusUnifiedApiHttpClient($this->httpClient, $this->logger, false);
+
+ $response = $this->createMock(ResponseInterface::class);
+ $response->method('getStatusCode')->willReturn(200);
+ $response->method('getContent')->with(false)->willReturn('{}');
+
+ $this->httpClient->expects(self::once())->method('request')
+ ->with('GET', 'https://staging-internal-payment.gcp.dlns.io/processing-operations/operations/op_1', [
+ 'headers' => [],
+ 'verify_peer' => false,
+ 'verify_host' => false,
+ 'timeout' => 10,
+ ])
+ ->willReturn($response);
+
+ $adapter->get('https://staging-internal-payment.gcp.dlns.io/processing-operations/operations/op_1');
+ }
+
+ public function testGet_whenVerifyTlsEnabled_neverPassesVerifyPeerOrVerifyHost(): void
+ {
+ $response = $this->createMock(ResponseInterface::class);
+ $response->method('getStatusCode')->willReturn(200);
+ $response->method('getContent')->with(false)->willReturn('{}');
+
+ $this->httpClient->expects(self::once())->method('request')
+ ->with('GET', 'https://api.payplug.com/payments/pay_123', ['headers' => [], 'timeout' => 10])
+ ->willReturn($response);
+
+ $this->adapter->get('https://api.payplug.com/payments/pay_123');
+ }
+}
diff --git a/tests/PHPUnit/Upc/SyliusUpcConfigurationRepositoryTest.php b/tests/PHPUnit/Upc/SyliusUpcConfigurationRepositoryTest.php
new file mode 100644
index 00000000..f905d4aa
--- /dev/null
+++ b/tests/PHPUnit/Upc/SyliusUpcConfigurationRepositoryTest.php
@@ -0,0 +1,106 @@
+gatewayConfigRepository = $this->createMock(RepositoryInterface::class);
+ $this->entityManager = $this->createMock(EntityManagerInterface::class);
+ $this->configurationRepository = new SyliusUpcConfigurationRepository($this->gatewayConfigRepository, $this->entityManager);
+ }
+
+ private function gatewayConfigWith(array $config): GatewayConfigInterface&MockObject
+ {
+ $gatewayConfig = $this->createMock(GatewayConfigInterface::class);
+ $gatewayConfig->method('getConfig')->willReturn($config);
+ $this->gatewayConfigRepository->method('findOneBy')
+ ->with(['factoryName' => PayPlugGatewayFactory::FACTORY_NAME])
+ ->willReturn($gatewayConfig);
+
+ return $gatewayConfig;
+ }
+
+ public function testGetClientId_whenLive_readsFromLiveClient(): void
+ {
+ $this->gatewayConfigWith(['live' => true, 'live_client' => ['client_id' => 'live_id', 'client_secret' => 'live_secret']]);
+
+ self::assertSame('live_id', $this->configurationRepository->getClientId());
+ }
+
+ public function testGetClientId_whenNotLive_readsFromTestClient(): void
+ {
+ $this->gatewayConfigWith(['live' => false, 'test_client' => ['client_id' => 'test_id', 'client_secret' => 'test_secret']]);
+
+ self::assertSame('test_id', $this->configurationRepository->getClientId());
+ }
+
+ public function testGetClientSecret_whenLive_readsFromLiveClient(): void
+ {
+ $this->gatewayConfigWith(['live' => true, 'live_client' => ['client_id' => 'live_id', 'client_secret' => 'live_secret']]);
+
+ self::assertSame('live_secret', $this->configurationRepository->getClientSecret());
+ }
+
+ public function testGetClientId_whenNoClientConfigStored_returnsEmptyString(): void
+ {
+ $this->gatewayConfigWith(['live' => false]);
+
+ self::assertSame('', $this->configurationRepository->getClientId());
+ }
+
+ public function testGetPublicKeyId_readsHfIdentifier(): void
+ {
+ $this->gatewayConfigWith([PayPlugGatewayFactory::HF_IDENTIFIER => 'hf_ident_123']);
+
+ self::assertSame('hf_ident_123', $this->configurationRepository->getPublicKeyId());
+ }
+
+ public function testGetPublicKeyValue_returnsEmptyString(): void
+ {
+ $this->gatewayConfigWith([]);
+
+ self::assertSame('', $this->configurationRepository->getPublicKeyValue());
+ }
+
+ public function testGet_readsArbitraryKeyFromConfig(): void
+ {
+ $this->gatewayConfigWith(['payplug_webhook_authorization_header' => 'Bearer shared-secret']);
+
+ self::assertSame('Bearer shared-secret', $this->configurationRepository->get('payplug_webhook_authorization_header'));
+ }
+
+ public function testGet_whenKeyMissing_returnsNull(): void
+ {
+ $this->gatewayConfigWith([]);
+
+ self::assertNull($this->configurationRepository->get('missing_key'));
+ }
+
+ public function testSet_mergesTheKeyIntoConfigAndFlushes(): void
+ {
+ $gatewayConfig = $this->gatewayConfigWith(['existing' => 'value']);
+ $gatewayConfig->expects(self::once())->method('setConfig')
+ ->with(['existing' => 'value', 'new_key' => 'new_value']);
+ $this->entityManager->expects(self::once())->method('flush');
+
+ $this->configurationRepository->set('new_key', 'new_value');
+ }
+}
diff --git a/tests/PHPUnit/Upc/SyliusUpcLockTest.php b/tests/PHPUnit/Upc/SyliusUpcLockTest.php
new file mode 100644
index 00000000..51e52eec
--- /dev/null
+++ b/tests/PHPUnit/Upc/SyliusUpcLockTest.php
@@ -0,0 +1,60 @@
+lockFactory = $this->createMock(LockFactory::class);
+ $this->lock = new SyliusUpcLock($this->lockFactory);
+ }
+
+ public function testAcquire_whenLockIsFree_returnsTrue(): void
+ {
+ $lockInterface = $this->createMock(SharedLockInterface::class);
+ $lockInterface->method('acquire')->with(false)->willReturn(true);
+ $this->lockFactory->method('createLock')->with('key', 30)->willReturn($lockInterface);
+
+ self::assertTrue($this->lock->acquire('key', 30));
+ }
+
+ public function testAcquire_whenLockIsHeld_returnsFalse(): void
+ {
+ $lockInterface = $this->createMock(SharedLockInterface::class);
+ $lockInterface->method('acquire')->with(false)->willReturn(false);
+ $this->lockFactory->method('createLock')->willReturn($lockInterface);
+
+ self::assertFalse($this->lock->acquire('key', 30));
+ }
+
+ public function testRelease_releasesAPreviouslyAcquiredLock(): void
+ {
+ $lockInterface = $this->createMock(SharedLockInterface::class);
+ $lockInterface->method('acquire')->willReturn(true);
+ $lockInterface->expects(self::once())->method('release');
+ $this->lockFactory->method('createLock')->willReturn($lockInterface);
+
+ $this->lock->acquire('key', 30);
+ $this->lock->release('key');
+ }
+
+ public function testRelease_whenNothingWasAcquired_doesNothing(): void
+ {
+ $this->expectNotToPerformAssertions();
+
+ $this->lock->release('never-acquired');
+ }
+}
diff --git a/tests/PHPUnit/Upc/SyliusUpcLoggerTest.php b/tests/PHPUnit/Upc/SyliusUpcLoggerTest.php
new file mode 100644
index 00000000..ceb653b6
--- /dev/null
+++ b/tests/PHPUnit/Upc/SyliusUpcLoggerTest.php
@@ -0,0 +1,44 @@
+psrLogger = $this->createMock(LoggerInterface::class);
+ $this->logger = new SyliusUpcLogger($this->psrLogger);
+ }
+
+ public function testDebug_delegatesToThePsrLogger(): void
+ {
+ $this->psrLogger->expects(self::once())->method('debug')->with('a message', ['key' => 'value']);
+
+ $this->logger->debug('a message', ['key' => 'value']);
+ }
+
+ public function testInfo_delegatesToThePsrLogger(): void
+ {
+ $this->psrLogger->expects(self::once())->method('info')->with('a message', []);
+
+ $this->logger->info('a message');
+ }
+
+ public function testError_delegatesToThePsrLogger(): void
+ {
+ $this->psrLogger->expects(self::once())->method('error')->with('a message', ['key' => 'value']);
+
+ $this->logger->error('a message', ['key' => 'value']);
+ }
+}
diff --git a/tests/PHPUnit/Upc/UnifiedApiOperationStatusFetcherTest.php b/tests/PHPUnit/Upc/UnifiedApiOperationStatusFetcherTest.php
new file mode 100644
index 00000000..9db28d0f
--- /dev/null
+++ b/tests/PHPUnit/Upc/UnifiedApiOperationStatusFetcherTest.php
@@ -0,0 +1,94 @@
+unifiedApiHttpClient = $this->createMock(IUnifiedApiHttpClient::class);
+ $this->oauthHttpClient = $this->createMock(IOAuthHttpClient::class);
+ $this->tokenCache = $this->createMock(ITokenCache::class);
+ $this->configurationRepository = $this->createMock(IConfigurationRepository::class);
+ $this->configurationRepository->method('getClientId')->willReturn('client_abc');
+ $this->configurationRepository->method('getClientSecret')->willReturn('secret_xyz');
+
+ $oauth2Client = new OAuth2Client($this->oauthHttpClient, 'https://api.payplug.com', '', '', 'https://www.payplug.com');
+ $tokenManager = new TokenManager($this->tokenCache, $oauth2Client);
+
+ $this->fetcher = new UnifiedApiOperationStatusFetcher(
+ $this->unifiedApiHttpClient,
+ $tokenManager,
+ $this->configurationRepository,
+ 'https://api.payplug.com',
+ );
+ }
+
+ public function testGetOperation_withValidCredentials_returnsTheRawResponse(): void
+ {
+ $this->tokenCache->method('get')->willReturn('cached-jwt');
+ $body = '{"id":"op_1","execCode":"0000","orderId":"000000072","amount":7400}';
+ $this->unifiedApiHttpClient->method('get')
+ ->with('https://api.payplug.com/processing-operations/operations/public/op_1', ['Authorization' => 'Bearer cached-jwt'])
+ ->willReturn(['status' => 200, 'body' => $body]);
+
+ $response = $this->fetcher->getOperation('op_1');
+
+ self::assertSame(['status' => 200, 'body' => $body], $response);
+ }
+
+ /**
+ * Unlike the old (deleted) UnifiedApiOperationService, an unknown operation id is not given
+ * its own exception type here — getOperation() folds a 404 into the same generic ApiException
+ * as any other non-2xx status, since no caller currently needs to tell them apart.
+ */
+ public function testGetOperation_onMissingOperation_throwsApiException(): void
+ {
+ $this->tokenCache->method('get')->willReturn('cached-jwt');
+ $this->unifiedApiHttpClient->method('get')->willReturn(['status' => 404, 'body' => '{}']);
+
+ $this->expectException(ApiException::class);
+ $this->expectExceptionCode(404);
+
+ $this->fetcher->getOperation('op_1');
+ }
+
+ public function testGetOperation_onNon2xxResponse_throwsApiException(): void
+ {
+ $this->tokenCache->method('get')->willReturn('cached-jwt');
+ $this->unifiedApiHttpClient->method('get')->willReturn(['status' => 500, 'body' => '{}']);
+
+ $this->expectException(ApiException::class);
+
+ $this->fetcher->getOperation('op_1');
+ }
+}
diff --git a/tests/PHPUnit/Upc/UnifiedApiPaymentCreatorTest.php b/tests/PHPUnit/Upc/UnifiedApiPaymentCreatorTest.php
new file mode 100644
index 00000000..4636d692
--- /dev/null
+++ b/tests/PHPUnit/Upc/UnifiedApiPaymentCreatorTest.php
@@ -0,0 +1,100 @@
+unifiedApiHttpClient = $this->createMock(IUnifiedApiHttpClient::class);
+ $this->oauthHttpClient = $this->createMock(IOAuthHttpClient::class);
+ $this->tokenCache = $this->createMock(ITokenCache::class);
+ $this->configurationRepository = $this->createMock(IConfigurationRepository::class);
+ $this->configurationRepository->method('getClientId')->willReturn('client_abc');
+ $this->configurationRepository->method('getClientSecret')->willReturn('secret_xyz');
+
+ $oauth2Client = new OAuth2Client($this->oauthHttpClient, 'https://api.payplug.com', '', '', 'https://www.payplug.com');
+ $tokenManager = new TokenManager($this->tokenCache, $oauth2Client);
+
+ $this->creator = new UnifiedApiPaymentCreator(
+ $this->unifiedApiHttpClient,
+ $tokenManager,
+ $this->configurationRepository,
+ 'https://api.payplug.com',
+ );
+ }
+
+ private function dto(): HostedFieldDto
+ {
+ return new HostedFieldDto(new CommonFieldsDto('acct_123', 1000, 'eur', '42'), 'hf_token_abc');
+ }
+
+ public function testCreateHostedPayment_withValidCredentials_returnsTheOutput(): void
+ {
+ $this->tokenCache->method('get')->willReturn(null);
+ $this->oauthHttpClient->method('post')->willReturn([
+ 'status' => 200,
+ 'body' => json_encode(['access_token' => 'fresh-jwt', 'expires_in' => 300, 'token_type' => 'Bearer']),
+ ]);
+ $this->unifiedApiHttpClient->method('postJson')->willReturn(['status' => 201, 'body' => '{"id":"pay_1"}']);
+
+ $output = $this->creator->createPayment($this->dto());
+
+ self::assertSame(201, $output->status);
+ self::assertNull($output->redirectUrl);
+ }
+
+ public function testCreateHostedPayment_withPending3ds_extractsTheRedirectUrl(): void
+ {
+ $this->tokenCache->method('get')->willReturn('cached-jwt');
+ $this->unifiedApiHttpClient->method('postJson')->willReturn([
+ 'status' => 200,
+ 'body' => json_encode(['id' => 'pay_1', 'redirect' => ['url' => 'https://3ds.payplug.com/challenge']]),
+ ]);
+
+ $output = $this->creator->createPayment($this->dto());
+
+ self::assertSame('https://3ds.payplug.com/challenge', $output->redirectUrl);
+ }
+
+ public function testCreateHostedPayment_onNon2xxResponse_throwsApiException(): void
+ {
+ $this->tokenCache->method('get')->willReturn('cached-jwt');
+ $this->unifiedApiHttpClient->method('postJson')->willReturn(['status' => 500, 'body' => '{}']);
+
+ $this->expectException(ApiException::class);
+
+ $this->creator->createPayment($this->dto());
+ }
+}
diff --git a/tests/PHPUnit/Upc/UnifiedApiRefundCreatorTest.php b/tests/PHPUnit/Upc/UnifiedApiRefundCreatorTest.php
new file mode 100644
index 00000000..2c9754d0
--- /dev/null
+++ b/tests/PHPUnit/Upc/UnifiedApiRefundCreatorTest.php
@@ -0,0 +1,195 @@
+unifiedApiHttpClient = $this->createMock(IUnifiedApiHttpClient::class);
+ $this->oauthHttpClient = $this->createMock(IOAuthHttpClient::class);
+ $this->tokenCache = $this->createMock(ITokenCache::class);
+ $this->configurationRepository = $this->createMock(IConfigurationRepository::class);
+ $this->configurationRepository->method('getClientId')->willReturn('client_abc');
+ $this->configurationRepository->method('getClientSecret')->willReturn('secret_xyz');
+
+ $oauth2Client = new OAuth2Client($this->oauthHttpClient, 'https://api.payplug.com', '', '', 'https://www.payplug.com');
+ $tokenManager = new TokenManager($this->tokenCache, $oauth2Client);
+
+ $this->creator = new UnifiedApiRefundCreator(
+ $this->unifiedApiHttpClient,
+ $tokenManager,
+ $this->configurationRepository,
+ 'https://api.payplug.com',
+ );
+
+ $this->tokenCache->method('get')->willReturn('cached-jwt');
+ }
+
+ public function testCreateRefund_withoutAmount_sendsAFullRefundUsingTheMethodsOwnAccountId(): void
+ {
+ $method = $this->buildHostedFieldsPaymentMethod('acct_123');
+
+ $this->unifiedApiHttpClient->expects(self::once())
+ ->method('postJson')
+ ->with(
+ 'https://api.payplug.com/api/payment-gateway/payments/pay_123/refund',
+ [
+ 'account' => ['id' => 'acct_123'],
+ 'orderId' => 'order_1',
+ 'description' => 'Refund for order order_1',
+ ],
+ ['Authorization' => 'Bearer cached-jwt', 'Content-Type' => 'application/json'],
+ )
+ ->willReturn(['status' => 200, 'body' => '{"execCode":"0000"}']);
+
+ $result = $this->creator->createRefund($method, 'pay_123', 'order_1');
+
+ self::assertSame(['status' => 200, 'body' => '{"execCode":"0000"}'], $result);
+ }
+
+ public function testCreateRefund_withAmount_sendsAPartialRefund(): void
+ {
+ $method = $this->buildHostedFieldsPaymentMethod('acct_123');
+
+ $this->unifiedApiHttpClient->expects(self::once())
+ ->method('postJson')
+ ->with(
+ 'https://api.payplug.com/api/payment-gateway/payments/pay_123/refund',
+ [
+ 'account' => ['id' => 'acct_123'],
+ 'orderId' => 'order_1',
+ 'description' => 'Refund for order order_1',
+ 'amount' => 500,
+ ],
+ ['Authorization' => 'Bearer cached-jwt', 'Content-Type' => 'application/json'],
+ )
+ ->willReturn(['status' => 200, 'body' => '{}']);
+
+ $this->creator->createRefund($method, 'pay_123', 'order_1', 500);
+ }
+
+ public function testCreateRefund_onA404Response_throwsPaymentNotFoundException(): void
+ {
+ $method = $this->buildHostedFieldsPaymentMethod('acct_123');
+ $this->unifiedApiHttpClient->method('postJson')->willReturn(['status' => 404, 'body' => '{}']);
+
+ $this->expectException(PaymentNotFoundException::class);
+
+ $this->creator->createRefund($method, 'pay_123', 'order_1');
+ }
+
+ public function testCreateRefund_onNon2xxResponse_throwsApiException(): void
+ {
+ $method = $this->buildHostedFieldsPaymentMethod('acct_123');
+ $this->unifiedApiHttpClient->method('postJson')->willReturn(['status' => 500, 'body' => '{}']);
+
+ $this->expectException(ApiException::class);
+
+ $this->creator->createRefund($method, 'pay_123', 'order_1');
+ }
+
+ public function testCreateRefund_withANonPositiveAmount_throwsRefundAmountExceptionBeforeAnyNetworkCall(): void
+ {
+ $method = $this->buildHostedFieldsPaymentMethod('acct_123');
+ $this->unifiedApiHttpClient->expects(self::never())->method('postJson');
+
+ $this->expectException(RefundAmountException::class);
+
+ $this->creator->createRefund($method, 'pay_123', 'order_1', 0);
+ }
+
+ /**
+ * The refund body must state what $amount's minor units are: without it the Unified API infers
+ * the currency from the account, which silently means the wrong thing for a multi-currency
+ * merchant.
+ */
+ public function testCreateRefund_withCurrency_sendsItAlongsideTheAmount(): void
+ {
+ $method = $this->buildHostedFieldsPaymentMethod('acct_123');
+
+ $this->unifiedApiHttpClient->expects(self::once())
+ ->method('postJson')
+ ->with(
+ 'https://api.payplug.com/api/payment-gateway/payments/pay_123/refund',
+ [
+ 'account' => ['id' => 'acct_123'],
+ 'orderId' => 'order_1',
+ 'description' => 'Refund for order order_1',
+ 'amount' => 6800,
+ 'currency' => 'USD',
+ ],
+ ['Authorization' => 'Bearer cached-jwt', 'Content-Type' => 'application/json'],
+ )
+ ->willReturn(['status' => 200, 'body' => '{}']);
+
+ $this->creator->createRefund($method, 'pay_123', 'order_1', 6800, 'USD');
+ }
+
+ /**
+ * Credentials must come from $method's own gateway config, not from whichever
+ * Hosted-Fields-configured payment method IConfigurationRepository's backing store happens to
+ * resolve first — otherwise a merchant with more than one such payment method could have a
+ * refund routed to the wrong account.
+ */
+ public function testCreateRefund_withNoConfiguredAccountId_throwsLogicExceptionBeforeAnyNetworkCall(): void
+ {
+ $method = $this->buildHostedFieldsPaymentMethod('');
+
+ $this->unifiedApiHttpClient->expects(self::never())->method('postJson');
+
+ $this->expectException(\LogicException::class);
+ $this->expectExceptionMessage('Hosted Fields account id is not configured for this payment method.');
+
+ $this->creator->createRefund($method, 'pay_123', 'order_1');
+ }
+
+ private function buildHostedFieldsPaymentMethod(
+ string $accountId,
+ ): PaymentMethodInterface&MockObject
+ {
+ $gatewayConfig = $this->createMock(GatewayConfigInterface::class);
+ $gatewayConfig->method('getConfig')->willReturn([
+ PayPlugGatewayFactory::HOSTED_FIELDS => true,
+ PayPlugGatewayFactory::HF_IDENTIFIER => $accountId,
+ ]);
+
+ $method = $this->createMock(PaymentMethodInterface::class);
+ $method->method('getGatewayConfig')->willReturn($gatewayConfig);
+
+ return $method;
+ }
+}
diff --git a/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php b/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php
index f8fb7b70..8ce05c0a 100644
--- a/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php
+++ b/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php
@@ -8,6 +8,10 @@
use PayPlug\SyliusPayPlugPlugin\Gateway\BancontactGatewayFactory;
use PayPlug\SyliusPayPlugPlugin\Gateway\OneyGatewayFactory;
use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory;
+use PayPlug\SyliusPayPlugPlugin\Gateway\ScalapayGatewayFactory;
+use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\IsCanSavePaymentMethod;
+use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\IsScalapayAmountRangeValid;
+use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\PayplugPermission;
use PayPlug\SyliusPayPlugPlugin\Validator\PaymentMethodValidator;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
@@ -137,12 +141,13 @@ public function testProcess_withViolations_disablesMethodAndFlashesErrors(): voi
}
// -------------------------------------------------------------------------
- // process() — PayPlug factory, no special flags → only IsCanSavePaymentMethod constraint
+ // process() — PayPlug factory, no special flags → base constraints only
// -------------------------------------------------------------------------
/**
* PayPlug gateway with ONE_CLICK, DEFERRED_CAPTURE and INTEGRATED_PAYMENT all false.
- * Verifies only the base IsCanSavePaymentMethod constraint (1 total) is passed to the validator.
+ * Verifies only the always-present constraint (1 total) is passed to the validator:
+ * IsCanSavePaymentMethod.
*/
public function testProcess_payplugFactory_noFlags_validatesWithBaseConstraintOnly(): void
{
@@ -157,7 +162,6 @@ public function testProcess_payplugFactory_noFlags_validatesWithBaseConstraintOn
->expects(self::once())
->method('validate')
->willReturnCallback(function ($subject, array $constraints) {
- // Only the base IsCanSavePaymentMethod constraint (no permission constraints)
self::assertCount(1, $constraints);
return new ConstraintViolationList();
@@ -173,12 +177,13 @@ public function testProcess_payplugFactory_noFlags_validatesWithBaseConstraintOn
}
// -------------------------------------------------------------------------
- // process() — PayPlug factory, all flags enabled → 4 constraints (base + 3 permissions)
+ // process() — PayPlug factory, all permission flags enabled → 4 constraints (1 base + 3 permissions)
// -------------------------------------------------------------------------
/**
* PayPlug gateway with ONE_CLICK, DEFERRED_CAPTURE and INTEGRATED_PAYMENT all true.
- * Verifies 4 constraints are passed to the validator (base + one per enabled feature flag).
+ * Verifies 4 constraints are passed to the validator: the always-present
+ * IsCanSavePaymentMethod plus one per enabled feature flag.
*/
public function testProcess_payplugFactory_allFlagsEnabled_validatesWithAllConstraints(): void
{
@@ -208,6 +213,111 @@ public function testProcess_payplugFactory_allFlagsEnabled_validatesWithAllConst
$this->paymentMethodValidator->process($paymentMethod);
}
+ // -------------------------------------------------------------------------
+ // process() — PayPlug factory, hostedFields true, oneClick false → base constraint only
+ // -------------------------------------------------------------------------
+
+ /**
+ * PayPlug gateway in hosted_fields mode with oneClick absent/false. Verifies
+ * processPayplug() validates with the base IsCanSavePaymentMethod constraint only (1 total) —
+ * hosted_fields adds no permission constraint of its own, matching the redirected mode.
+ */
+ public function testProcess_payplugFactory_hostedFieldsTrueOneClickFalse_validatesWithBaseConstraintOnly(): void
+ {
+ $config = [
+ PayPlugGatewayFactory::HOSTED_FIELDS => true,
+ PayPlugGatewayFactory::ONE_CLICK => false,
+ ];
+ $paymentMethod = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME, $config);
+
+ $this->validator
+ ->expects(self::once())
+ ->method('validate')
+ ->willReturnCallback(function ($subject, array $constraints) {
+ self::assertCount(1, $constraints);
+ self::assertInstanceOf(IsCanSavePaymentMethod::class, $constraints[0]);
+
+ return new ConstraintViolationList();
+ })
+ ;
+
+ $flashBag = $this->createMock(FlashBagInterface::class);
+ $session = $this->createMock(Session::class);
+ $session->method('getFlashBag')->willReturn($flashBag);
+ $this->requestStack->method('getSession')->willReturn($session);
+
+ $this->paymentMethodValidator->process($paymentMethod);
+ }
+
+ // -------------------------------------------------------------------------
+ // process() — PayPlug factory, hostedFields true, oneClick true → base + CAN_SAVE_CARD
+ // -------------------------------------------------------------------------
+
+ /**
+ * PayPlug gateway in hosted_fields mode with oneClick=true. Verifies processPayplug() adds a
+ * PayplugPermission (CAN_SAVE_CARD) constraint alongside the base one (2 total) — the same
+ * behavior as oneClick in redirected/integrated_payment mode.
+ */
+ public function testProcess_payplugFactory_hostedFieldsTrueOneClickTrue_validatesWithPermissionConstraint(): void
+ {
+ $config = [
+ PayPlugGatewayFactory::HOSTED_FIELDS => true,
+ PayPlugGatewayFactory::ONE_CLICK => true,
+ ];
+ $paymentMethod = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME, $config);
+
+ $this->validator
+ ->expects(self::once())
+ ->method('validate')
+ ->willReturnCallback(function ($subject, array $constraints) {
+ self::assertCount(2, $constraints);
+ self::assertInstanceOf(IsCanSavePaymentMethod::class, $constraints[0]);
+ self::assertInstanceOf(PayplugPermission::class, $constraints[1]);
+
+ return new ConstraintViolationList();
+ })
+ ;
+
+ $flashBag = $this->createMock(FlashBagInterface::class);
+ $session = $this->createMock(Session::class);
+ $session->method('getFlashBag')->willReturn($flashBag);
+ $this->requestStack->method('getSession')->willReturn($session);
+
+ $this->paymentMethodValidator->process($paymentMethod);
+ }
+
+ // -------------------------------------------------------------------------
+ // process() — Scalapay factory → base constraint + amount range constraint
+ // -------------------------------------------------------------------------
+
+ /**
+ * Scalapay gateway config. Verifies both IsCanSavePaymentMethod and
+ * IsScalapayAmountRangeValid are passed to the validator (2 total).
+ */
+ public function testProcess_scalapayFactory_validatesWithBaseAndAmountRangeConstraints(): void
+ {
+ $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, []);
+
+ $this->validator
+ ->expects(self::once())
+ ->method('validate')
+ ->willReturnCallback(function ($subject, array $constraints) {
+ self::assertCount(2, $constraints);
+ self::assertInstanceOf(IsCanSavePaymentMethod::class, $constraints[0]);
+ self::assertInstanceOf(IsScalapayAmountRangeValid::class, $constraints[1]);
+
+ return new ConstraintViolationList();
+ })
+ ;
+
+ $flashBag = $this->createMock(FlashBagInterface::class);
+ $session = $this->createMock(Session::class);
+ $session->method('getFlashBag')->willReturn($flashBag);
+ $this->requestStack->method('getSession')->willReturn($session);
+
+ $this->paymentMethodValidator->process($paymentMethod);
+ }
+
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
diff --git a/translations/flashes.en.yml b/translations/flashes.en.yml
index 1faaa11a..7d4de015 100644
--- a/translations/flashes.en.yml
+++ b/translations/flashes.en.yml
@@ -3,6 +3,7 @@ payplug_sylius_payplug_plugin:
oney_not_enabled: Oney is not (or is no longer) activated on your account.
api_unknow_error: An error occurred. Please retry in few seconds.
transaction_failed_1click: The transaction was not completed and your card was not charged.
+ transaction_failed: The transaction was not completed and your card was not charged.
warning:
payment_success_no_card_saved: The payment was successful but we were unable to save your credit card details.
admin:
diff --git a/translations/flashes.fr.yml b/translations/flashes.fr.yml
index b05b5110..cca3eac2 100644
--- a/translations/flashes.fr.yml
+++ b/translations/flashes.fr.yml
@@ -3,6 +3,7 @@ payplug_sylius_payplug_plugin:
oney_not_enabled: Oney n'est pas ou plus activé sur votre compte.
api_unknow_error: Une erreur s'est produite. Veuillez réessayer dans quelques secondes.
transaction_failed_1click: La transaction a échoué, votre carte de paiement ne sera pas débitée.
+ transaction_failed: La transaction a échoué, votre carte de paiement ne sera pas débitée.
warning:
payment_success_no_card_saved: Le paiement a été effectué avec succès, mais nous n'avons pas pu enregistrer votre carte bancaire.
admin:
diff --git a/translations/flashes.it.yml b/translations/flashes.it.yml
index 10cd5113..49355425 100644
--- a/translations/flashes.it.yml
+++ b/translations/flashes.it.yml
@@ -3,6 +3,7 @@ payplug_sylius_payplug_plugin:
oney_not_enabled: Oney non è o non è più attivato per il tuo account.
api_unknow_error: C'è stato un errore. Per favore riprova tra qualche secondo.
transaction_failed_1click: La transazione non è stata conclusa e non è stato effettuato alcun addebito sulla tua carta.
+ transaction_failed: La transazione non è stata conclusa e non è stato effettuato alcun addebito sulla tua carta.
warning:
payment_success_no_card_saved: Il pagamento è andato a buon fine ma non è stato possibile salvare la tua carta di credito.
admin:
diff --git a/translations/messages.en.yml b/translations/messages.en.yml
index 9ffa6acb..a486cfb3 100644
--- a/translations/messages.en.yml
+++ b/translations/messages.en.yml
@@ -93,6 +93,10 @@ payplug_sylius_payplug_plugin:
title: 'The fees are:'
client: Split between you and your customers
merchant: For you
+ scalapay_gateway_config:
+ min_amount: Minimum amount
+ max_amount: Maximum amount
+ amount_help: Leave empty to use the limits authorized by PayPlug.
integrated_payment:
card_holder.title: 'Cardholder name'
card_holder.error: 'Invalid Name and/or Last Name.'
@@ -107,6 +111,11 @@ payplug_sylius_payplug_plugin:
place_order.label: 'Place order'
transaction_secure.label: 'Transaction secured by'
privacy_policy.label: 'Privacy Policy'
+ hosted_fields:
+ error.tokenization_failed: 'Your card details could not be validated. Please check them and try again.'
+ error.unsupported_brand: 'This card brand is not supported for this payment method. Please use a different card.'
+ save_card.label: 'Save my card'
+ place_order.label: 'Place order'
deferred_capture:
process_order_info: |
You will be charged when your order is processed.
@@ -117,6 +126,8 @@ payplug_sylius_payplug_plugin:
renew_oauth: Force OAuth reconnection
renew_oauth_help: |
If this option is checked, a new authentication flow will be started when clicking the "Update" button.
+ hf_identifier_label: 'Account ID'
+ hosted_fields_option: 'Advanced (beta) Hosted Fields'
form:
oney_error: Some missing information is required to pay using Oney by Payplug
complete_info:
@@ -137,6 +148,9 @@ payplug_sylius_payplug_plugin:
Allow your customers to save their credit card details for later
integrated_payment_enable: Enable payment integrated
+ redirected_payment_enable: Enable redirected payment
+ account_id_required: 'Advanced features require an Account ID, please contact your Account Manager.'
+ integrated_payment_currency_incompatible: 'The selected channel is not compatible with Integrated Payment.'
deferred_capture_enable: Enable deferred capture
deferred_capture_help: |
diff --git a/translations/messages.fr.yml b/translations/messages.fr.yml
index 068fba68..4c17f7ad 100644
--- a/translations/messages.fr.yml
+++ b/translations/messages.fr.yml
@@ -111,6 +111,10 @@ payplug_sylius_payplug_plugin:
title: 'Les frais sont :'
client: Répartis entre vous et vos clients
merchant: À votre charge
+ scalapay_gateway_config:
+ min_amount: Montant minimum
+ max_amount: Montant maximum
+ amount_help: Laissez vide pour utiliser les limites autorisées par PayPlug.
integrated_payment:
card_holder.title: 'Nom du titulaire de la carte'
card_holder.error: 'Nom et/ou prénom invalide(s).'
@@ -126,6 +130,11 @@ payplug_sylius_payplug_plugin:
place_order.label: 'Confirmer le paiement'
transaction_secure.label: 'Transaction sécurisée par'
privacy_policy.label: 'Politique de confidentialité'
+ hosted_fields:
+ error.tokenization_failed: 'Les informations de votre carte n’ont pas pu être validées. Veuillez les vérifier et réessayer.'
+ error.unsupported_brand: 'Cette marque de carte n’est pas prise en charge pour ce moyen de paiement. Merci d’utiliser une autre carte.'
+ save_card.label: 'Enregistrer ma carte bancaire'
+ place_order.label: 'Confirmer le paiement'
deferred_capture:
process_order_info: |
Vous serez prélevé(é) lors du traitement de votre commande.
@@ -137,6 +146,8 @@ payplug_sylius_payplug_plugin:
renew_oauth: Forcer la reconnexion OAuth
renew_oauth_help: |
Si cette option est cochée, un nouveau flux d’authentification sera lancé lors du clic sur le bouton "Mise à jour".
+ hf_identifier_label: 'Identifiant de compte'
+ hosted_fields_option: 'Hosted Fields avancé (beta)'
form:
oney_error: Il y a des informations manquantes pour pouvoir payer en utilisant Oney by Payplug
complete_info:
@@ -158,6 +169,9 @@ payplug_sylius_payplug_plugin:
d'autres transactions
integrated_payment_enable: Activer le Paiement Integré
+ redirected_payment_enable: Activer le Paiement redirigé
+ account_id_required: 'Les fonctionnalités avancées nécessitent un Account ID, veuillez contacter votre Account Manager.'
+ integrated_payment_currency_incompatible: 'Le canal sélectionné n''est pas compatible avec le Paiement intégré'
deferred_capture_enable: Activer la capture différée
deferred_capture_help: |
Attention, assurez vous qu'un déclencheur de la capture différée a bien été ajouté dans le code source du projet
diff --git a/translations/messages.it.yml b/translations/messages.it.yml
index 10459f42..16e60374 100644
--- a/translations/messages.it.yml
+++ b/translations/messages.it.yml
@@ -93,6 +93,10 @@ payplug_sylius_payplug_plugin:
title: 'Le spese sono:'
client: Ripartite tra te e i tuoi clienti
merchant: A tuo carico
+ scalapay_gateway_config:
+ min_amount: Importo minimo
+ max_amount: Importo massimo
+ amount_help: Lascia vuoto per usare i limiti autorizzati da PayPlug.
integrated_payment:
card_holder.title: 'Titolare della carta'
card_holder.error: 'Nome e/o Cognome non valido(i).'
@@ -107,6 +111,11 @@ payplug_sylius_payplug_plugin:
place_order.label: 'Ordine'
transaction_secure.label: 'Transazione protetta da'
privacy_policy.label: 'Politica di confidenzialità'
+ hosted_fields:
+ error.tokenization_failed: 'Non è stato possibile verificare i dati della tua carta. Controllali e riprova.'
+ error.unsupported_brand: 'Questo marchio di carta non è supportato per questo metodo di pagamento. Utilizza un’altra carta.'
+ save_card.label: 'Salva la mia carta'
+ place_order.label: 'Ordine'
deferred_capture:
process_order_info: |
L'addebito avverrà al momento dell'elaborazione dell'ordine.
@@ -117,6 +126,8 @@ payplug_sylius_payplug_plugin:
renew_oauth: Forza la riconnessione OAuth
renew_oauth_help: |
Se questa opzione è selezionata, un nuovo flusso di autenticazione verrà avviato quando si fa clic sul pulsante "Aggiorna".
+ hf_identifier_label: 'ID Account'
+ hosted_fields_option: 'Hosted Fields avanzato (beta)'
form:
oney_error: Mancano alcune informazioni per poter pagare con “Oney by Payplug”
complete_info:
@@ -137,6 +148,9 @@ payplug_sylius_payplug_plugin:
Consenti ai tuoi clienti di salvare i dettagli della loro carta di credito per dopo
integrated_payment_enable: Abilita il pagamento integrato
+ redirected_payment_enable: Abilita il pagamento reindirizzato
+ account_id_required: 'Le funzionalità avanzate richiedono un Account ID, si prega di contattare il vostro Account Manager.'
+ integrated_payment_currency_incompatible: 'Il canale selezionato non è compatibile con il Pagamento integrato.'
deferred_capture_enable: Abilita la cattura differita
deferred_capture_help: |
Assicurarsi che nel codice sorgente del progetto sia stato aggiunto un trigger di acquisizione ritardata
diff --git a/translations/validators.en.yml b/translations/validators.en.yml
index 78c1c1fe..cf4141c2 100644
--- a/translations/validators.en.yml
+++ b/translations/validators.en.yml
@@ -33,6 +33,8 @@ payplug_sylius_payplug_plugin:
You don't have access to this feature yet.
To activate Scalapay, please contact us at support@payplug.com
and activate the LIVE mode.
+ min_amount_greater_than_max: The minimum amount must be lower than or equal to the maximum amount.
+ amount_out_of_authorized_range: The amount limits must be between %min_amount% and %max_amount%.
payplug_wero:
can_not_save_method_with_test_key: |
The Wero payment method is not available for the TEST mode.
diff --git a/translations/validators.fr.yml b/translations/validators.fr.yml
index 6031253d..5f4baffc 100644
--- a/translations/validators.fr.yml
+++ b/translations/validators.fr.yml
@@ -32,6 +32,8 @@ payplug_sylius_payplug_plugin:
Vous n'avez pas accès à cette fonctionnalité.
Pour activer Scalapay, contactez-nous à support@payplug.com
et activez le mode LIVE.
+ min_amount_greater_than_max: Le montant minimum doit être inférieur ou égal au montant maximum.
+ amount_out_of_authorized_range: Les limites de montant doivent être comprises entre %min_amount% et %max_amount%.
payplug_wero:
can_not_save_method_with_test_key: |
Le paiement par Wero n'est pas disponible en mode TEST.
diff --git a/translations/validators.it.yml b/translations/validators.it.yml
index 7dcf67b4..7ebaea99 100644
--- a/translations/validators.it.yml
+++ b/translations/validators.it.yml
@@ -32,6 +32,8 @@ payplug_sylius_payplug_plugin:
Non puoi ancora accedere a questa funzionalità.
Per attivare Scalapay, contattaci a support@payplug.com
e attiva la modalità LIVE.
+ min_amount_greater_than_max: L'importo minimo deve essere inferiore o uguale all'importo massimo.
+ amount_out_of_authorized_range: I limiti di importo devono essere compresi tra %min_amount% e %max_amount%.
payplug_wero:
can_not_save_method_with_test_key: |
Il metodo di pagamento Wero non è disponibile in modalità TEST.