From a38225818a1cf0262c7608ad950ffd1f33586762 Mon Sep 17 00:00:00 2001 From: adumont-payplug Date: Fri, 17 Jul 2026 12:02:33 +0200 Subject: [PATCH 01/15] PRE-3563: Add SyliusOAuthHttpClient adapter --- src/Auth/SyliusOAuthHttpClient.php | 36 ++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/Auth/SyliusOAuthHttpClient.php diff --git a/src/Auth/SyliusOAuthHttpClient.php b/src/Auth/SyliusOAuthHttpClient.php new file mode 100644 index 00000000..12331be3 --- /dev/null +++ b/src/Auth/SyliusOAuthHttpClient.php @@ -0,0 +1,36 @@ + $formParams + * @param array $headers + * + * @return array{status: int, body: string} + */ + public function post(string $url, array $formParams, array $headers = []): array + { + $response = $this->httpClient->request('POST', $url, [ + 'body' => http_build_query($formParams), + 'headers' => $headers, + ]); + + return [ + 'status' => $response->getStatusCode(), + // false = don't throw on non-2xx; OAuth2Client itself checks the status. + 'body' => $response->getContent(false), + ]; + } +} From 81a7fee9b4231b873901598dd049cfde8f6faff4 Mon Sep 17 00:00:00 2001 From: adumont-payplug Date: Fri, 17 Jul 2026 12:06:48 +0200 Subject: [PATCH 02/15] Add SyliusTokenCache adapter for UPC's ITokenCache --- src/Auth/SyliusTokenCache.php | 49 +++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/Auth/SyliusTokenCache.php diff --git a/src/Auth/SyliusTokenCache.php b/src/Auth/SyliusTokenCache.php new file mode 100644 index 00000000..e8ca6fe0 --- /dev/null +++ b/src/Auth/SyliusTokenCache.php @@ -0,0 +1,49 @@ +cache->getItem($this->sanitizeKey($key)); + + if (!$item->isHit()) { + return null; + } + + /** @var string $value */ + $value = $item->get(); + + return $value; + } + + public function set(string $key, string $value, int $ttlSeconds): void + { + $item = $this->cache->getItem($this->sanitizeKey($key)); + $item->set($value); + $item->expiresAfter($ttlSeconds); + $this->cache->save($item); + } + + public function delete(string $key): void + { + $this->cache->deleteItem($this->sanitizeKey($key)); + } + + // PSR-6 rejects "{}()/\@:" in cache keys; TokenManager's keys contain ":". + private function sanitizeKey(string $key): string + { + return (string) preg_replace('/[{}()\/\\\\@:]/', '_', $key); + } +} From 71818e49a72657e0f0d37c2831c8ab7c377b86d9 Mon Sep 17 00:00:00 2001 From: adumont-payplug Date: Mon, 20 Jul 2026 15:24:02 +0200 Subject: [PATCH 03/15] PRE-3563: add OAuth2/PKCE throw UPC --- .gitignore | 1 + composer.json | 1 + config/services.yaml | 10 + config/services/client.xml | 19 ++ .../Auth/UnifiedAuthenticationController.php | 90 ++++--- src/ApiClient/PayPlugApiClientFactory.php | 60 +++-- .../UnifiedAuthenticationControllerTest.php | 254 ++++++++++++++++++ .../ApiClient/PayPlugApiClientFactoryTest.php | 175 ++++++++++++ .../Auth/SyliusOAuthHttpClientTest.php | 102 +++++++ tests/PHPUnit/Auth/SyliusTokenCacheTest.php | 115 ++++++++ 10 files changed, 773 insertions(+), 54 deletions(-) create mode 100644 tests/PHPUnit/Action/Admin/Auth/UnifiedAuthenticationControllerTest.php create mode 100644 tests/PHPUnit/ApiClient/PayPlugApiClientFactoryTest.php create mode 100644 tests/PHPUnit/Auth/SyliusOAuthHttpClientTest.php create mode 100644 tests/PHPUnit/Auth/SyliusTokenCacheTest.php diff --git a/.gitignore b/.gitignore index f47650bd..534a23cd 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ CLAUDE.md .claude .review .phpunit.result.cache +docs/ \ No newline at end of file diff --git a/composer.json b/composer.json index 2d93bb4d..d2565406 100755 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "ext-json": "*", "giggsey/libphonenumber-for-php": "^8.12", "payplug/payplug-php": "^4.0", + "payplug/unified-plugin-core": "0.0.7", "php-http/message-factory": "^1.1", "sylius/refund-plugin": "^2.0", "sylius/sylius": "^2.0", diff --git a/config/services.yaml b/config/services.yaml index 7db6a5fe..2131eff5 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -1,3 +1,11 @@ +parameters: + # Overridable via env vars for QA/staging testing; merchants installing the plugin normally + # never need to set these — the defaults below are used automatically. + payplug.oauth_base_url.default: 'https://api.payplug.com' + payplug.oauth_base_url: '%env(default:payplug.oauth_base_url.default:PAYPLUG_OAUTH_BASE_URL)%' + payplug.oauth_audience.default: 'https://www.payplug.com' + payplug.oauth_audience: '%env(default:payplug.oauth_audience.default:PAYPLUG_OAUTH_AUDIENCE)%' + services: _defaults: autowire: true @@ -9,6 +17,8 @@ services: exclude: '../src/{ApiClient,DependencyInjection,Entity,Exception,Model,Repository,PayPlugSyliusPayPlugPlugin.php}' bind: Psr\Log\LoggerInterface: '@monolog.logger.payplug' + $payplugOauthBaseUrl: '%payplug.oauth_base_url%' + $payplugOauthAudience: '%payplug.oauth_audience%' PayPlug\SyliusPayPlugPlugin\Repository\PaymentRepositoryInterface: class: PayPlug\SyliusPayPlugPlugin\Repository\PaymentRepository diff --git a/config/services/client.xml b/config/services/client.xml index 3f2d3391..73148c93 100644 --- a/config/services/client.xml +++ b/config/services/client.xml @@ -10,6 +10,25 @@ + + + + + + + + %payplug.oauth_base_url% + + + %payplug.oauth_audience% + + + + $paymentMethodRepository */ @@ -39,36 +43,46 @@ public function __construct( private EntityManagerInterface $entityManager, private PaymentMethodValidator $paymentMethodValidator, private LoggerInterface $logger, - private CacheInterface $cache, + private IOAuthHttpClient $oauthHttpClient, + private string $payplugOauthBaseUrl, + private string $payplugOauthAudience, ) { } + private function buildOAuth2Client(string $redirectUri): OAuth2Client + { + return new OAuth2Client($this->oauthHttpClient, $this->payplugOauthBaseUrl, $redirectUri, self::PKCE_SCOPE, $this->payplugOauthAudience); + } + #[Route('/setup-redirection', name: 'payplug_sylius_admin_auth_setup_redirection')] public function setupRedirection(Request $request): Response { try { - $clientId = $request->query->get('client_id'); - $companyId = $request->query->get('company_id'); + $clientId = $request->query->getString('client_id'); + $companyId = $request->query->getString('company_id'); $request->getSession()->set('payplug_client_id', $clientId); $request->getSession()->set('payplug_company_id', $companyId); - $challenge = bin2hex(openssl_random_pseudo_bytes(50)); - $request->getSession()->set('payplug_oauth_challenge', $challenge); - $callBackUrl = $this->router->generate('payplug_sylius_admin_auth_oauth_callback', [], RouterInterface::ABSOLUTE_URL); - // This method will redirect the user to PayPlug's oauth page via header('Location')' - Authentication::initiateOAuth($clientId, $callBackUrl, $challenge); - // Fetch the header Location the Sdk put and redirect the user to it - $headers = \headers_list(); - foreach ($headers as $header) { - if (str_starts_with($header, 'Location:')) { - return new RedirectResponse(substr($header, 9)); - } - } - - throw new \LogicException('No location header found'); + // Legacy flow, superseded by PayplugUnifiedCore\Auth\OAuth2Client below. + // $challenge = bin2hex(openssl_random_pseudo_bytes(50)); + // $request->getSession()->set('payplug_oauth_challenge', $challenge); + // Authentication::initiateOAuth($clientId, $callBackUrl, $challenge); + // $headers = \headers_list(); + // foreach ($headers as $header) { + // if (str_starts_with($header, 'Location:')) { + // return new RedirectResponse(substr($header, 9)); + // } + // } + // throw new \LogicException('No location header found'); + + $authorizationRequest = $this->buildOAuth2Client($callBackUrl)->buildAuthorizationUrl($clientId); + $request->getSession()->set('payplug_oauth_state', $authorizationRequest->state); + $request->getSession()->set('payplug_oauth_code_verifier', $authorizationRequest->codeVerifier); + + return new RedirectResponse($authorizationRequest->url); } catch (\Throwable $e) { $this->logger->critical('Error while perform Payplug OAuth Setup redirection', ['message' => $e->getMessage(), 'exception' => $e]); @@ -81,16 +95,31 @@ public function oauthCallback(Request $request): Response { try { $code = $request->query->getString('code'); + $state = $request->query->getString('state'); /** @var string $clientId */ $clientId = $request->getSession()->get('payplug_client_id'); - /** @var string $challenge */ - $challenge = $request->getSession()->get('payplug_oauth_challenge'); - $callback = $this->generateUrl('payplug_sylius_admin_auth_oauth_callback', [], UrlGeneratorInterface::ABSOLUTE_URL); + /** @var string $expectedState */ + $expectedState = $request->getSession()->get('payplug_oauth_state'); + $codeVerifier = $request->getSession()->get('payplug_oauth_code_verifier'); - $jwt = Authentication::generateJWTOneShot($code, $callback, $clientId, $challenge); - if ([] === $jwt || $jwt['httpStatus'] !== 200 || !\is_array($jwt['httpResponse'])) { - throw new BadRequestHttpException('Error while generating JWT'); + if ('' === $state || $state !== $expectedState) { + throw new BadRequestHttpException('OAuth state mismatch'); } + + if (!\is_string($codeVerifier) || '' === $codeVerifier) { + throw new BadRequestHttpException('OAuth code verifier missing from session'); + } + + $callback = $this->generateUrl('payplug_sylius_admin_auth_oauth_callback', [], UrlGeneratorInterface::ABSOLUTE_URL); + + // Legacy flow, superseded by PayplugUnifiedCore\Auth\OAuth2Client below. + // $jwt = Authentication::generateJWTOneShot($code, $callback, $clientId, $challenge); + // if ([] === $jwt || $jwt['httpStatus'] !== 200 || !\is_array($jwt['httpResponse'])) { + // throw new BadRequestHttpException('Error while generating JWT'); + // } + + $token = $this->buildOAuth2Client($callback)->exchangeAuthorizationCode($clientId, $code, $codeVerifier); + $paymentMethodId = $request->getSession()->get('payplug_sylius_oauth_payment_method_id'); if (null === $paymentMethodId) { throw new BadRequestHttpException('No payment method id found in session'); @@ -105,7 +134,7 @@ public function oauthCallback(Request $request): Response } $companyId = $request->getSession()->get('payplug_company_id'); - Payplug::init(['secretKey' => $jwt['httpResponse']['access_token']]); + Payplug::init(['secretKey' => $token->accessToken]); $clientName = 'Sylius - ' . $paymentMethod->getName(); $testClientDataResult = Authentication::createClientIdAndSecret($companyId, $clientName, 'test'); $liveClientDataResult = Authentication::createClientIdAndSecret($companyId, $clientName, 'live'); @@ -119,11 +148,9 @@ public function oauthCallback(Request $request): Response $this->cleanSession($request); $request->getSession()->getFlashBag()->add('success', 'payplug_sylius_payplug_plugin.admin.oauth_callback_success'); - // Clean previous cached client config - $cacheKeyLive = sprintf('payplug_%s_api_key_live', $gatewayConfig->getFactoryName()); - $cacheKeyTest = sprintf('payplug_%s_api_key_test', $gatewayConfig->getFactoryName()); - $this->cache->delete($cacheKeyLive); - $this->cache->delete($cacheKeyTest); + // Token cache invalidation is now handled internally by TokenManager, keyed by + // client_id — createClientIdAndSecret() above always mints a fresh client_id per + // OAuth run, so there is nothing stale to clean up here. // Ensure that the payment method is well configured $this->paymentMethodValidator->process($paymentMethod); @@ -152,7 +179,8 @@ private function cleanSession(Request $request): void $session = $request->getSession(); $session->remove('payplug_client_id'); $session->remove('payplug_company_id'); - $session->remove('payplug_oauth_challenge'); + $session->remove('payplug_oauth_state'); + $session->remove('payplug_oauth_code_verifier'); $session->remove('payplug_sylius_oauth_payment_method_id'); } } diff --git a/src/ApiClient/PayPlugApiClientFactory.php b/src/ApiClient/PayPlugApiClientFactory.php index 7d57cfe9..78da307e 100644 --- a/src/ApiClient/PayPlugApiClientFactory.php +++ b/src/ApiClient/PayPlugApiClientFactory.php @@ -4,19 +4,23 @@ namespace PayPlug\SyliusPayPlugPlugin\ApiClient; -use Payplug\Authentication; +// use Payplug\Authentication; // superseded by PayplugUnifiedCore\Auth\TokenManager below use PayPlug\SyliusPayPlugPlugin\Exception\GatewayConfigurationException; +use PayplugUnifiedCore\Auth\TokenManager; +use PayplugUnifiedCore\Exceptions\ApiException; use Sylius\Component\Payment\Model\GatewayConfigInterface; use Sylius\Component\Payment\Model\PaymentMethodInterface; use Sylius\Component\Resource\Repository\RepositoryInterface; use Symfony\Contracts\Cache\CacheInterface; -use Symfony\Contracts\Cache\ItemInterface; + +// use Symfony\Contracts\Cache\ItemInterface; // superseded, see getTokenForGatewayConfig() final class PayPlugApiClientFactory implements PayPlugApiClientFactoryInterface { public function __construct( private RepositoryInterface $gatewayConfigRepository, private CacheInterface $cache, + private TokenManager $tokenManager, ) { } @@ -54,26 +58,36 @@ private function getTokenForGatewayConfig(GatewayConfigInterface $gatewayConfig) } /** @var array $clientConfig */ $clientConfig = $rawClientConfig; - $cacheKey = sprintf('payplug_%s_api_key_%s', $gatewayConfig->getFactoryName(), $isLive ? 'live' : 'test'); - - return $this->cache->get($cacheKey, function (ItemInterface $item) use ($clientConfig) { - $response = Authentication::generateJWT($clientConfig['client_id'] ?? '', $clientConfig['client_secret'] ?? ''); - if ([] === $response || !is_array($response['httpResponse'])) { - throw new GatewayConfigurationException('Unable to connect to PayPlug API. Please check your credentials in the PayPlug plugin configuration.'); - } - - $accessToken = $response['httpResponse']['access_token']; - if (!is_string($accessToken)) { - throw new GatewayConfigurationException('Unable to connect to PayPlug API. Please check your credentials in the PayPlug plugin configuration.'); - } - $expiresIn = $response['httpResponse']['expires_in']; - if (!is_int($expiresIn)) { - $expiresIn = 200; - } - - $item->expiresAfter($expiresIn); - - return $accessToken; - }); + + // Legacy flow, superseded by PayplugUnifiedCore\Auth\TokenManager below. + // $cacheKey = sprintf('payplug_%s_api_key_%s', $gatewayConfig->getFactoryName(), $isLive ? 'live' : 'test'); + // return $this->cache->get($cacheKey, function (ItemInterface $item) use ($clientConfig) { + // $response = Authentication::generateJWT($clientConfig['client_id'] ?? '', $clientConfig['client_secret'] ?? ''); + // if ([] === $response || !is_array($response['httpResponse'])) { + // throw new GatewayConfigurationException('Unable to connect to PayPlug API. Please check your credentials in the PayPlug plugin configuration.'); + // } + // $accessToken = $response['httpResponse']['access_token']; + // if (!is_string($accessToken)) { + // throw new GatewayConfigurationException('Unable to connect to PayPlug API. Please check your credentials in the PayPlug plugin configuration.'); + // } + // $expiresIn = $response['httpResponse']['expires_in']; + // if (!is_int($expiresIn)) { + // $expiresIn = 200; + // } + // $item->expiresAfter($expiresIn); + // return $accessToken; + // }); + + $clientId = $clientConfig['client_id'] ?? ''; + $clientSecret = $clientConfig['client_secret'] ?? ''; + if ('' === $clientId || '' === $clientSecret) { + throw new GatewayConfigurationException('No client config found for ' . $gatewayConfig->getFactoryName() . '. Please renew your credentials in the PayPlug plugin configuration.'); + } + + try { + return $this->tokenManager->getValidToken($clientId, $clientSecret); + } catch (ApiException $e) { + throw new GatewayConfigurationException('Unable to connect to PayPlug API. Please check your credentials in the PayPlug plugin configuration.', 0, $e); + } } } diff --git a/tests/PHPUnit/Action/Admin/Auth/UnifiedAuthenticationControllerTest.php b/tests/PHPUnit/Action/Admin/Auth/UnifiedAuthenticationControllerTest.php new file mode 100644 index 00000000..98d323e5 --- /dev/null +++ b/tests/PHPUnit/Action/Admin/Auth/UnifiedAuthenticationControllerTest.php @@ -0,0 +1,254 @@ +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 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..b7fae2fe --- /dev/null +++ b/tests/PHPUnit/Auth/SyliusOAuthHttpClientTest.php @@ -0,0 +1,102 @@ +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']); + } +} 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']; + } +} From fb944d5bf020a8b780c05798dbb1123a227231bd Mon Sep 17 00:00:00 2001 From: adumont-payplug Date: Wed, 22 Jul 2026 12:06:02 +0200 Subject: [PATCH 04/15] PRE-3563: adding coverage check on CI and review fixes --- .github/PULL_REQUEST_TEMPLATE.md | 1 + .github/workflows/ci.yml | 73 ++++++++++++++++++- .gitignore | 3 +- Dockerfile | 38 ++++++++++ Makefile | 14 ++++ README.md | 1 + composer.json | 1 + phpunit.xml.dist | 6 ++ .../Auth/UnifiedAuthenticationController.php | 24 ------ src/ApiClient/PayPlugApiClientFactory.php | 22 ------ 10 files changed, 134 insertions(+), 49 deletions(-) create mode 100644 Dockerfile diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 6fed9975..e0e42858 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -28,6 +28,7 @@ ### Testing - [ ] Unit tests added / updated +- [ ] New/changed code is covered by tests — SonarCloud Quality Gate (coverage on new code) passes on the `sonarcloud` CI job ### Security & Ops - [ ] No sensitive data or secrets introduced diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1616306..d3874d07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,13 +19,82 @@ jobs: with: symfony-versions: '["7.3"]' + # ----------------------------------------------------------------------- + # COVERAGE — generates a Clover coverage report (PCOV) for SonarCloud to + # ingest. Kept separate from the reusable sylius_phpunit matrix (which + # runs with coverage: none) so coverage generation stays this repo's + # own concern, same as payplug/unified-plugin-core's ci.yml. + # ----------------------------------------------------------------------- + coverage: + name: Coverage + if: github.base_ref == 'develop' + runs-on: ubuntu-latest + env: + APP_ENV: test + services: + mariadb: + image: 'mariadb:10.4.11' + ports: + - '3306:3306' + env: + MYSQL_ALLOW_EMPTY_PASSWORD: true + options: '--health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3' + steps: + - + uses: actions/checkout@v4 + - + name: 'Setup PHP 8.2' + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + ini-values: date.timezone=UTC + extensions: intl + tools: symfony + coverage: pcov + - + name: 'Setup Node 20.x' + uses: actions/setup-node@v4 + with: + node-version: '20.x' + - + name: 'Composer - Get Cache Directory' + id: composer-cache + run: 'echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT' + - + name: 'Composer - Set cache' + uses: actions/cache@v4 + with: + path: '${{ steps.composer-cache.outputs.dir }}' + key: 'php-8.2-sylius-2.1.0-symfony-7.3-coverage-composer-${{ hashFiles(''**/composer.json'') }}' + restore-keys: 'php-8.2-sylius-2.1.0-symfony-7.3-coverage-composer-' + - + name: 'Composer - Github Auth' + run: 'composer config -g github-oauth.github.com ${{ github.token }}' + - + name: 'Install Sylius-Standard and Plugin' + run: 'make install -e SYLIUS_VERSION=2.1.0 SYMFONY_VERSION=7.3' + id: end-of-setup-sylius + - + name: 'Run tests with coverage' + run: 'composer test-coverage' + if: 'always() && steps.end-of-setup-sylius.outcome == ''success''' + - + name: 'Upload coverage report' + uses: actions/upload-artifact@v4 + with: + name: clover-coverage + path: build/logs/clover.xml + retention-days: 1 + sonarcloud: if: always() && !failure() && !cancelled() && github.base_ref == 'develop' - needs: [sylius-matrix] - uses: payplug/template-ci/.github/workflows/sonarcloud.yml@main + needs: [sylius-matrix, coverage] + uses: payplug/template-ci/.github/workflows/sonarcloud-coverage.yml@main with: project-name: 'github-payplug-payplug-syliuspayplugplugin' src-folder: 'src/' + coverage-report-artifact: 'clover-coverage' + enforce-quality-gate: true secrets: sonar-orga: ${{ secrets.SONAR_ORGA }} sonar-token: ${{ secrets.SONAR_TOKEN }} diff --git a/.gitignore b/.gitignore index 534a23cd..2900961e 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,5 @@ CLAUDE.md .claude .review .phpunit.result.cache -docs/ \ No newline at end of file +docs/ +build/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..00f23d65 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +FROM composer:2 AS composer + +FROM php:8.2-cli + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + unzip \ + libicu-dev \ + libzip-dev \ + libonig-dev \ + libxml2-dev \ + libpng-dev \ + libjpeg-dev \ + libfreetype6-dev \ + libsodium-dev \ + && docker-php-ext-configure gd --with-freetype --with-jpeg \ + && docker-php-ext-install -j$(nproc) \ + intl \ + gd \ + sodium \ + pdo_mysql \ + mbstring \ + xml \ + dom \ + simplexml \ + xmlwriter \ + zip \ + && pecl install pcov \ + && docker-php-ext-enable pcov \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=composer /usr/bin/composer /usr/local/bin/composer + +RUN useradd --create-home --uid 1000 appuser + +WORKDIR /app + +USER appuser diff --git a/Makefile b/Makefile index b3007cc4..813d5336 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,12 @@ SYLIUS_VERSION=2.1.0 SYMFONY_VERSION=6.4 PLUGIN_NAME=payplug/sylius-payplug-plugin +# Coverage runs inside Docker (PHP 8.2 + PCOV) instead of the host PHP, since the host's default +# `php`/`composer` may resolve to an unrelated version with no coverage driver installed. Assumes +# `vendor/` (and the Sylius test-application) is already installed on the host via `make install`. +IMAGE_DEV := sylius-payplug-plugin-dev +DOCKER_RUN := docker run --rm -v $(CURDIR):/app -w /app -u "$$(id -u):$$(id -g)" -e COMPOSER_HOME=/tmp/composer $(IMAGE_DEV) + ### ### DEVELOPMENT ### ¯¯¯¯¯¯¯¯¯¯¯ @@ -23,6 +29,14 @@ phpunit: ## Run PHPUnit tests ./vendor/bin/phpunit .PHONY: phpunit +build-dev: ## Build the Docker image used to run coverage + docker build -t $(IMAGE_DEV) . +.PHONY: build-dev + +coverage: build-dev ## Run PHPUnit tests with a Clover coverage report (build/logs/clover.xml), via Docker + $(DOCKER_RUN) composer test-coverage +.PHONY: coverage + ### ### OTHER ### ¯¯¯¯¯¯ diff --git a/README.md b/README.md index 40b8025e..d71a5990 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=github-payplug-payplug-syliuspayplugplugin&metric=alert_status&token=af29f9f3fbb3a74caff4e4a4d168bddab858f4dc)](https://sonarcloud.io/summary/new_code?id=github-payplug-payplug-syliuspayplugplugin) [![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=github-payplug-payplug-syliuspayplugplugin&metric=duplicated_lines_density&token=af29f9f3fbb3a74caff4e4a4d168bddab858f4dc)](https://sonarcloud.io/summary/new_code?id=github-payplug-payplug-syliuspayplugplugin) [![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=github-payplug-payplug-syliuspayplugplugin&metric=code_smells&token=af29f9f3fbb3a74caff4e4a4d168bddab858f4dc)](https://sonarcloud.io/summary/new_code?id=github-payplug-payplug-syliuspayplugplugin) +[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=github-payplug-payplug-syliuspayplugplugin&metric=coverage&token=af29f9f3fbb3a74caff4e4a4d168bddab858f4dc)](https://sonarcloud.io/summary/new_code?id=github-payplug-payplug-syliuspayplugplugin) [![Version](https://img.shields.io/packagist/v/payplug/sylius-payplug-plugin.svg)](https://packagist.org/packages/payplug/sylius-payplug-plugin) [![Total Downloads](https://poser.pugx.org/payplug/sylius-payplug-plugin/downloads)](https://packagist.org/packages/payplug/sylius-payplug-plugin) diff --git a/composer.json b/composer.json index d2565406..a7e6c659 100755 --- a/composer.json +++ b/composer.json @@ -86,6 +86,7 @@ "phpmd": "phpmd src ansi ruleset/.php_md.xml", "phpstan": "phpstan analyse src -c ruleset/phpstan.neon", "phpunit": "phpunit tests/PHPUnit --colors=always", + "test-coverage": "phpunit tests/PHPUnit --colors=always --coverage-clover=build/logs/clover.xml", "tests": [ "@ecs", "@phpmd", diff --git a/phpunit.xml.dist b/phpunit.xml.dist index b01264ac..89387765 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -18,4 +18,10 @@ + + + + src + + diff --git a/src/Action/Admin/Auth/UnifiedAuthenticationController.php b/src/Action/Admin/Auth/UnifiedAuthenticationController.php index e1a5f7ab..38d45871 100644 --- a/src/Action/Admin/Auth/UnifiedAuthenticationController.php +++ b/src/Action/Admin/Auth/UnifiedAuthenticationController.php @@ -65,19 +65,6 @@ public function setupRedirection(Request $request): Response $request->getSession()->set('payplug_company_id', $companyId); $callBackUrl = $this->router->generate('payplug_sylius_admin_auth_oauth_callback', [], RouterInterface::ABSOLUTE_URL); - - // Legacy flow, superseded by PayplugUnifiedCore\Auth\OAuth2Client below. - // $challenge = bin2hex(openssl_random_pseudo_bytes(50)); - // $request->getSession()->set('payplug_oauth_challenge', $challenge); - // Authentication::initiateOAuth($clientId, $callBackUrl, $challenge); - // $headers = \headers_list(); - // foreach ($headers as $header) { - // if (str_starts_with($header, 'Location:')) { - // return new RedirectResponse(substr($header, 9)); - // } - // } - // throw new \LogicException('No location header found'); - $authorizationRequest = $this->buildOAuth2Client($callBackUrl)->buildAuthorizationUrl($clientId); $request->getSession()->set('payplug_oauth_state', $authorizationRequest->state); $request->getSession()->set('payplug_oauth_code_verifier', $authorizationRequest->codeVerifier); @@ -111,13 +98,6 @@ public function oauthCallback(Request $request): Response } $callback = $this->generateUrl('payplug_sylius_admin_auth_oauth_callback', [], UrlGeneratorInterface::ABSOLUTE_URL); - - // Legacy flow, superseded by PayplugUnifiedCore\Auth\OAuth2Client below. - // $jwt = Authentication::generateJWTOneShot($code, $callback, $clientId, $challenge); - // if ([] === $jwt || $jwt['httpStatus'] !== 200 || !\is_array($jwt['httpResponse'])) { - // throw new BadRequestHttpException('Error while generating JWT'); - // } - $token = $this->buildOAuth2Client($callback)->exchangeAuthorizationCode($clientId, $code, $codeVerifier); $paymentMethodId = $request->getSession()->get('payplug_sylius_oauth_payment_method_id'); @@ -148,10 +128,6 @@ public function oauthCallback(Request $request): Response $this->cleanSession($request); $request->getSession()->getFlashBag()->add('success', 'payplug_sylius_payplug_plugin.admin.oauth_callback_success'); - // Token cache invalidation is now handled internally by TokenManager, keyed by - // client_id — createClientIdAndSecret() above always mints a fresh client_id per - // OAuth run, so there is nothing stale to clean up here. - // Ensure that the payment method is well configured $this->paymentMethodValidator->process($paymentMethod); diff --git a/src/ApiClient/PayPlugApiClientFactory.php b/src/ApiClient/PayPlugApiClientFactory.php index 78da307e..6b7d8f8a 100644 --- a/src/ApiClient/PayPlugApiClientFactory.php +++ b/src/ApiClient/PayPlugApiClientFactory.php @@ -4,7 +4,6 @@ namespace PayPlug\SyliusPayPlugPlugin\ApiClient; -// use Payplug\Authentication; // superseded by PayplugUnifiedCore\Auth\TokenManager below use PayPlug\SyliusPayPlugPlugin\Exception\GatewayConfigurationException; use PayplugUnifiedCore\Auth\TokenManager; use PayplugUnifiedCore\Exceptions\ApiException; @@ -13,8 +12,6 @@ use Sylius\Component\Resource\Repository\RepositoryInterface; use Symfony\Contracts\Cache\CacheInterface; -// use Symfony\Contracts\Cache\ItemInterface; // superseded, see getTokenForGatewayConfig() - final class PayPlugApiClientFactory implements PayPlugApiClientFactoryInterface { public function __construct( @@ -59,25 +56,6 @@ private function getTokenForGatewayConfig(GatewayConfigInterface $gatewayConfig) /** @var array $clientConfig */ $clientConfig = $rawClientConfig; - // Legacy flow, superseded by PayplugUnifiedCore\Auth\TokenManager below. - // $cacheKey = sprintf('payplug_%s_api_key_%s', $gatewayConfig->getFactoryName(), $isLive ? 'live' : 'test'); - // return $this->cache->get($cacheKey, function (ItemInterface $item) use ($clientConfig) { - // $response = Authentication::generateJWT($clientConfig['client_id'] ?? '', $clientConfig['client_secret'] ?? ''); - // if ([] === $response || !is_array($response['httpResponse'])) { - // throw new GatewayConfigurationException('Unable to connect to PayPlug API. Please check your credentials in the PayPlug plugin configuration.'); - // } - // $accessToken = $response['httpResponse']['access_token']; - // if (!is_string($accessToken)) { - // throw new GatewayConfigurationException('Unable to connect to PayPlug API. Please check your credentials in the PayPlug plugin configuration.'); - // } - // $expiresIn = $response['httpResponse']['expires_in']; - // if (!is_int($expiresIn)) { - // $expiresIn = 200; - // } - // $item->expiresAfter($expiresIn); - // return $accessToken; - // }); - $clientId = $clientConfig['client_id'] ?? ''; $clientSecret = $clientConfig['client_secret'] ?? ''; if ('' === $clientId || '' === $clientSecret) { From 23de379ba46e0ad440a6bd63d64cb0f4ed6bae1f Mon Sep 17 00:00:00 2001 From: adumont-payplug Date: Wed, 22 Jul 2026 15:29:39 +0200 Subject: [PATCH 05/15] PRE-3563: review fixes --- .../Auth/UnifiedAuthenticationController.php | 5 +++- src/Auth/SyliusOAuthHttpClient.php | 28 +++++++++++++------ src/Auth/SyliusTokenCache.php | 3 +- .../UnifiedAuthenticationControllerTest.php | 25 +++++++++++++++++ .../Auth/SyliusOAuthHttpClientTest.php | 26 +++++++++++++++++ 5 files changed, 75 insertions(+), 12 deletions(-) diff --git a/src/Action/Admin/Auth/UnifiedAuthenticationController.php b/src/Action/Admin/Auth/UnifiedAuthenticationController.php index 38d45871..4c5b7041 100644 --- a/src/Action/Admin/Auth/UnifiedAuthenticationController.php +++ b/src/Action/Admin/Auth/UnifiedAuthenticationController.php @@ -83,7 +83,6 @@ public function oauthCallback(Request $request): Response try { $code = $request->query->getString('code'); $state = $request->query->getString('state'); - /** @var string $clientId */ $clientId = $request->getSession()->get('payplug_client_id'); /** @var string $expectedState */ $expectedState = $request->getSession()->get('payplug_oauth_state'); @@ -93,6 +92,10 @@ public function oauthCallback(Request $request): Response throw new BadRequestHttpException('OAuth state mismatch'); } + if (!\is_string($clientId) || '' === $clientId) { + throw new BadRequestHttpException('OAuth client id missing from session'); + } + if (!\is_string($codeVerifier) || '' === $codeVerifier) { throw new BadRequestHttpException('OAuth code verifier missing from session'); } diff --git a/src/Auth/SyliusOAuthHttpClient.php b/src/Auth/SyliusOAuthHttpClient.php index 12331be3..36611f3d 100644 --- a/src/Auth/SyliusOAuthHttpClient.php +++ b/src/Auth/SyliusOAuthHttpClient.php @@ -5,6 +5,7 @@ namespace PayPlug\SyliusPayPlugPlugin\Auth; use PayplugUnifiedCore\Contracts\IOAuthHttpClient; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; final class SyliusOAuthHttpClient implements IOAuthHttpClient @@ -22,15 +23,24 @@ public function __construct( */ public function post(string $url, array $formParams, array $headers = []): array { - $response = $this->httpClient->request('POST', $url, [ - 'body' => http_build_query($formParams), - 'headers' => $headers, - ]); + try { + $response = $this->httpClient->request('POST', $url, [ + 'body' => http_build_query($formParams), + 'headers' => $headers, + ]); - return [ - 'status' => $response->getStatusCode(), - // false = don't throw on non-2xx; OAuth2Client itself checks the status. - 'body' => $response->getContent(false), - ]; + return [ + 'status' => $response->getStatusCode(), + // false = don't throw on non-2xx; OAuth2Client itself checks the status. + 'body' => $response->getContent(false), + ]; + } catch (TransportExceptionInterface $e) { + // Network-level failure (DNS, timeout, connection reset) — getStatusCode()/getContent() + // throw this regardless of the `false` above, since it only suppresses HTTP status + // exceptions, not transport ones. Status 0 makes OAuth2Client::requestToken() throw its + // own ApiException, which callers (e.g. PayPlugApiClientFactory) already catch and + // translate, the same way a non-2xx response from PayPlug itself would be handled. + return ['status' => 0, 'body' => $e->getMessage()]; + } } } diff --git a/src/Auth/SyliusTokenCache.php b/src/Auth/SyliusTokenCache.php index e8ca6fe0..1be872a8 100644 --- a/src/Auth/SyliusTokenCache.php +++ b/src/Auth/SyliusTokenCache.php @@ -22,10 +22,9 @@ public function get(string $key): ?string return null; } - /** @var string $value */ $value = $item->get(); - return $value; + return \is_string($value) ? $value : null; } public function set(string $key, string $value, int $ttlSeconds): void diff --git a/tests/PHPUnit/Action/Admin/Auth/UnifiedAuthenticationControllerTest.php b/tests/PHPUnit/Action/Admin/Auth/UnifiedAuthenticationControllerTest.php index 98d323e5..752af554 100644 --- a/tests/PHPUnit/Action/Admin/Auth/UnifiedAuthenticationControllerTest.php +++ b/tests/PHPUnit/Action/Admin/Auth/UnifiedAuthenticationControllerTest.php @@ -200,6 +200,31 @@ public function testOauthCallback_withEmptyState_rejectsBeforeExchangingToken(): 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 // ------------------------------------------------------------------------- diff --git a/tests/PHPUnit/Auth/SyliusOAuthHttpClientTest.php b/tests/PHPUnit/Auth/SyliusOAuthHttpClientTest.php index b7fae2fe..eede4155 100644 --- a/tests/PHPUnit/Auth/SyliusOAuthHttpClientTest.php +++ b/tests/PHPUnit/Auth/SyliusOAuthHttpClientTest.php @@ -7,6 +7,7 @@ use PayPlug\SyliusPayPlugPlugin\Auth\SyliusOAuthHttpClient; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpClient\Exception\TransportException; use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\ResponseInterface; @@ -99,4 +100,29 @@ public function testPost_withNoHeadersArgument_defaultsToEmptyHeaders(): void $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']); + } } From e81f28041e43550a961af674dd0eb6412011ce37 Mon Sep 17 00:00:00 2001 From: hdelaforce-payplug <51410640+hdelaforce-payplug@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:48:35 +0200 Subject: [PATCH 06/15] PRE-3553 feat: Create admin payment method Unify Hosted Fields (#307) --- config/services/client.xml | 9 +++ config/twig_hooks/admin.yaml | 9 +++ .../UhfGatewayConfigurationTypeExtension.php | 38 ++++++++++++ .../Form/Type/UhfGatewayConfigurationType.php | 25 ++++++++ src/Gateway/UhfGatewayFactory.php | 14 +++++ src/Validator/PaymentMethodValidator.php | 42 +++---------- .../form/hf_identifier_default.html.twig | 5 ++ ...fGatewayConfigurationTypeExtensionTest.php | 59 +++++++++++++++++++ .../Validator/PaymentMethodValidatorTest.php | 34 +++++++++++ translations/messages.en.yml | 2 + translations/messages.fr.yml | 2 + translations/messages.it.yml | 2 + translations/validators.en.yml | 8 +++ translations/validators.fr.yml | 8 +++ translations/validators.it.yml | 8 +++ 15 files changed, 231 insertions(+), 34 deletions(-) create mode 100644 src/Gateway/Form/Extension/UhfGatewayConfigurationTypeExtension.php create mode 100644 src/Gateway/Form/Type/UhfGatewayConfigurationType.php create mode 100644 src/Gateway/UhfGatewayFactory.php create mode 100644 templates/admin/payment_method/form/hf_identifier_default.html.twig create mode 100644 tests/PHPUnit/Gateway/Form/Extension/UhfGatewayConfigurationTypeExtensionTest.php diff --git a/config/services/client.xml b/config/services/client.xml index 73148c93..4e2053e8 100644 --- a/config/services/client.xml +++ b/config/services/client.xml @@ -90,5 +90,14 @@ method="create"/> payplug_wero + + + + payplug_uhf + diff --git a/config/twig_hooks/admin.yaml b/config/twig_hooks/admin.yaml index ebd3dc54..0683e861 100644 --- a/config/twig_hooks/admin.yaml +++ b/config/twig_hooks/admin.yaml @@ -44,6 +44,12 @@ sylius_twig_hooks: 'sylius_admin.payment_method.create.content.form.sections.gateway_configuration.payplug_wero': &weroGateway live_checkbox: *liveCheckbox + 'sylius_admin.payment_method.create.content.form.sections.gateway_configuration.payplug_uhf': &uhfGateway + live_checkbox: *liveCheckbox + hf_identifier_default: &hfIdentifierDefault + template: '@PayPlugSyliusPayPlugPlugin/admin/payment_method/form/hf_identifier_default.html.twig' + priority: -1 + 'sylius_admin.payment_method.update.content.form.sections.gateway_configuration.payplug': <<: *payplugGateway renew_oauth: &renewOAuth @@ -67,3 +73,6 @@ sylius_twig_hooks: 'sylius_admin.payment_method.update.content.form.sections.gateway_configuration.payplug_wero': <<: *weroGateway renew_oauth: *renewOAuth + 'sylius_admin.payment_method.update.content.form.sections.gateway_configuration.payplug_uhf': + <<: *uhfGateway + renew_oauth: *renewOAuth diff --git a/src/Gateway/Form/Extension/UhfGatewayConfigurationTypeExtension.php b/src/Gateway/Form/Extension/UhfGatewayConfigurationTypeExtension.php new file mode 100644 index 00000000..21bc13ea --- /dev/null +++ b/src/Gateway/Form/Extension/UhfGatewayConfigurationTypeExtension.php @@ -0,0 +1,38 @@ +add(UhfGatewayFactory::HF_IDENTIFIER_DEFAULT, TextType::class, [ + 'label' => 'payplug_sylius_payplug_plugin.ui.hf_identifier_default_label', + 'required' => true, + 'validation_groups' => AbstractGatewayConfigurationType::VALIDATION_GROUPS, + 'constraints' => [ + new NotBlank([]), + ], + ]) + ; + } + + public static function getExtendedTypes(): iterable + { + return [UhfGatewayConfigurationType::class]; + } +} diff --git a/src/Gateway/Form/Type/UhfGatewayConfigurationType.php b/src/Gateway/Form/Type/UhfGatewayConfigurationType.php new file mode 100644 index 00000000..c7d0b9ef --- /dev/null +++ b/src/Gateway/Form/Type/UhfGatewayConfigurationType.php @@ -0,0 +1,25 @@ + 'payplug_uhf', + 'label' => 'payplug_sylius_payplug_plugin.ui.uhf_gateway_label', + 'priority' => 80, + ], +)] +final class UhfGatewayConfigurationType extends AbstractGatewayConfigurationType +{ + protected string $gatewayFactoryTitle = UhfGatewayFactory::FACTORY_TITLE; + + protected string $gatewayFactoryName = UhfGatewayFactory::FACTORY_NAME; + + protected string $gatewayBaseCurrencyCode = UhfGatewayFactory::BASE_CURRENCY_CODE; +} diff --git a/src/Gateway/UhfGatewayFactory.php b/src/Gateway/UhfGatewayFactory.php new file mode 100644 index 00000000..e5dd882d --- /dev/null +++ b/src/Gateway/UhfGatewayFactory.php @@ -0,0 +1,14 @@ +getGatewayConfig()->getFactoryName()) { PayPlugGatewayFactory::FACTORY_NAME => $this->processPayplug($paymentMethod), OneyGatewayFactory::FACTORY_NAME => $this->processOney($paymentMethod), - BancontactGatewayFactory::FACTORY_NAME => $this->processBancontact($paymentMethod), - AmericanExpressGatewayFactory::FACTORY_NAME => $this->processAmex($paymentMethod), - ApplePayGatewayFactory::FACTORY_NAME => $this->processApplePay($paymentMethod), - ScalapayGatewayFactory::FACTORY_NAME => $this->processScalapay($paymentMethod), - WeroGatewayFactory::FACTORY_NAME => $this->processWero($paymentMethod), + BancontactGatewayFactory::FACTORY_NAME => $this->processDefault($paymentMethod), + AmericanExpressGatewayFactory::FACTORY_NAME => $this->processDefault($paymentMethod), + ApplePayGatewayFactory::FACTORY_NAME => $this->processDefault($paymentMethod), + ScalapayGatewayFactory::FACTORY_NAME => $this->processDefault($paymentMethod), + WeroGatewayFactory::FACTORY_NAME => $this->processDefault($paymentMethod), + UhfGatewayFactory::FACTORY_NAME => $this->processDefault($paymentMethod), default => throw new \InvalidArgumentException('Unsupported payment method'), }; @@ -88,35 +90,7 @@ private function processOney(PaymentMethodInterface $paymentMethod): ConstraintV return $this->validator->validate($paymentMethod, $constraintList, self::VALIDATION_GROUPS); } - private function processBancontact(PaymentMethodInterface $paymentMethod): ConstraintViolationListInterface - { - $constraintList = [new IsCanSavePaymentMethod()]; - - return $this->validator->validate($paymentMethod, $constraintList, self::VALIDATION_GROUPS); - } - - private function processAmex(PaymentMethodInterface $paymentMethod): ConstraintViolationListInterface - { - $constraintList = [new IsCanSavePaymentMethod()]; - - return $this->validator->validate($paymentMethod, $constraintList, self::VALIDATION_GROUPS); - } - - private function processApplePay(PaymentMethodInterface $paymentMethod): ConstraintViolationListInterface - { - $constraintList = [new IsCanSavePaymentMethod()]; - - return $this->validator->validate($paymentMethod, $constraintList, self::VALIDATION_GROUPS); - } - - private function processScalapay(PaymentMethodInterface $paymentMethod): ConstraintViolationListInterface - { - $constraintList = [new IsCanSavePaymentMethod()]; - - return $this->validator->validate($paymentMethod, $constraintList, self::VALIDATION_GROUPS); - } - - private function processWero(PaymentMethodInterface $paymentMethod): ConstraintViolationListInterface + private function processDefault(PaymentMethodInterface $paymentMethod): ConstraintViolationListInterface { $constraintList = [new IsCanSavePaymentMethod()]; diff --git a/templates/admin/payment_method/form/hf_identifier_default.html.twig b/templates/admin/payment_method/form/hf_identifier_default.html.twig new file mode 100644 index 00000000..08bcf7ff --- /dev/null +++ b/templates/admin/payment_method/form/hf_identifier_default.html.twig @@ -0,0 +1,5 @@ +{% set form = hookable_metadata.context.form.gatewayConfig.config.hfIdentifierDefault %} + +
+ {{ form_row(form) }} +
diff --git a/tests/PHPUnit/Gateway/Form/Extension/UhfGatewayConfigurationTypeExtensionTest.php b/tests/PHPUnit/Gateway/Form/Extension/UhfGatewayConfigurationTypeExtensionTest.php new file mode 100644 index 00000000..0d1e7d7d --- /dev/null +++ b/tests/PHPUnit/Gateway/Form/Extension/UhfGatewayConfigurationTypeExtensionTest.php @@ -0,0 +1,59 @@ +extension = new UhfGatewayConfigurationTypeExtension(); + } + + public function testBuildForm_addsHfIdentifierDefaultTextField(): void + { + $builder = $this->createMock(FormBuilderInterface::class); + + $addCalls = []; + $builder + ->expects(self::once()) + ->method('add') + ->willReturnCallback(function ($name, $type = null, array $options = []) use (&$addCalls, $builder) { + $addCalls[] = [$name, $type, $options]; + + return $builder; + }) + ; + + $this->extension->buildForm($builder, []); + + [$name, $type, $options] = $addCalls[0]; + self::assertSame(UhfGatewayFactory::HF_IDENTIFIER_DEFAULT, $name); + self::assertSame(TextType::class, $type); + self::assertSame( + 'payplug_sylius_payplug_plugin.ui.hf_identifier_default_label', + $options['label'], + ); + self::assertTrue($options['required']); + self::assertSame(AbstractGatewayConfigurationType::VALIDATION_GROUPS, $options['validation_groups']); + self::assertCount(1, $options['constraints']); + self::assertInstanceOf(NotBlank::class, $options['constraints'][0]); + } + + public function testGetExtendedTypes_returnsUhfGatewayConfigurationType(): void + { + self::assertSame([UhfGatewayConfigurationType::class], UhfGatewayConfigurationTypeExtension::getExtendedTypes()); + } +} diff --git a/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php b/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php index f8fb7b70..0da3f243 100644 --- a/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php +++ b/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php @@ -8,6 +8,8 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\BancontactGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\OneyGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\Gateway\UhfGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\IsCanSavePaymentMethod; use PayPlug\SyliusPayPlugPlugin\Validator\PaymentMethodValidator; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -208,6 +210,38 @@ public function testProcess_payplugFactory_allFlagsEnabled_validatesWithAllConst $this->paymentMethodValidator->process($paymentMethod); } + // ------------------------------------------------------------------------- + // process() — UHF factory → routed to processDefault(), base constraint only + // ------------------------------------------------------------------------- + + /** + * UHF gateway. Verifies the match statement routes UhfGatewayFactory::FACTORY_NAME to + * processDefault(), which validates with the base IsCanSavePaymentMethod constraint only (1 + * total), the same as Bancontact/Amex/ApplePay/Scalapay/Wero. + */ + public function testProcess_uhfFactory_validatesWithBaseConstraintOnly(): void + { + $paymentMethod = $this->buildPaymentMethod(UhfGatewayFactory::FACTORY_NAME, []); + + $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); + } + // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- diff --git a/translations/messages.en.yml b/translations/messages.en.yml index 9ffa6acb..a6c4e5bc 100644 --- a/translations/messages.en.yml +++ b/translations/messages.en.yml @@ -26,6 +26,7 @@ payplug_sylius_payplug_plugin: bancontact_gateway_label: Bancontact by Payplug scalapay_gateway_label: Scalapay by PayPlug wero_gateway_label: Wero by PayPlug + uhf_gateway_label: Unified Hosted Fields by PayPlug apple_pay_gateway_label: Apple Pay by Payplug american_express_gateway_label: American Express by Payplug apple_pay_not_available: Apple Pay is not available on this browser or device. @@ -117,6 +118,7 @@ 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_default_label: 'HF Identifier' form: oney_error: Some missing information is required to pay using Oney by Payplug complete_info: diff --git a/translations/messages.fr.yml b/translations/messages.fr.yml index 068fba68..8fff41d7 100644 --- a/translations/messages.fr.yml +++ b/translations/messages.fr.yml @@ -26,6 +26,7 @@ payplug_sylius_payplug_plugin: bancontact_gateway_label: Bancontact by Payplug scalapay_gateway_label: Scalapay by PayPlug wero_gateway_label: Wero by PayPlug + uhf_gateway_label: Unified Hosted Fields by PayPlug apple_pay_gateway_label: Apple Pay by Payplug american_express_gateway_label: American Express by Payplug apple_pay_not_available: Apple Pay n'est pas disponible sur ce navigateur ou appareil. @@ -137,6 +138,7 @@ 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_default_label: 'Identifiant HF' form: oney_error: Il y a des informations manquantes pour pouvoir payer en utilisant Oney by Payplug complete_info: diff --git a/translations/messages.it.yml b/translations/messages.it.yml index 10459f42..390ab06c 100644 --- a/translations/messages.it.yml +++ b/translations/messages.it.yml @@ -26,6 +26,7 @@ payplug_sylius_payplug_plugin: bancontact_gateway_label: Bancontact by Payplug scalapay_gateway_label: Scalapay by PayPlug wero_gateway_label: Wero by PayPlug + uhf_gateway_label: Unified Hosted Fields by PayPlug apple_pay_gateway_label: Apple Pay by Payplug american_express_gateway_label: American Express by Payplug apple_pay_not_available: Apple Pay non è disponibile su questo browser o dispositivo. @@ -117,6 +118,7 @@ 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_default_label: 'Identificatore HF' form: oney_error: Mancano alcune informazioni per poter pagare con “Oney by Payplug” complete_info: diff --git a/translations/validators.en.yml b/translations/validators.en.yml index 78c1c1fe..9e68408c 100644 --- a/translations/validators.en.yml +++ b/translations/validators.en.yml @@ -41,6 +41,14 @@ payplug_sylius_payplug_plugin: You don't have access to this feature yet. To activate Wero, please contact us at support@payplug.com and activate the LIVE mode. + payplug_uhf: + can_not_save_method_with_test_key: | + The Unified Hosted Fields payment method is not available for the TEST mode. + Please activate the LIVE mode. + can_not_save_method_no_access: | + You don't have access to this feature yet. + To activate Unified Hosted Fields, please contact us at support@payplug.com + and activate the LIVE mode. payplug_apple_pay: can_not_save_method_with_test_key: | The Apple Pay payment method is not available for the TEST mode. diff --git a/translations/validators.fr.yml b/translations/validators.fr.yml index 6031253d..f04a6995 100644 --- a/translations/validators.fr.yml +++ b/translations/validators.fr.yml @@ -40,6 +40,14 @@ payplug_sylius_payplug_plugin: Vous n'avez pas accès à cette fonctionnalité. Pour activer Wero, contactez-nous à support@payplug.com et activez le mode LIVE. + payplug_uhf: + can_not_save_method_with_test_key: | + Le paiement par Unified Hosted Fields n'est pas disponible en mode TEST. + Veuillez activer le mode LIVE. + can_not_save_method_no_access: | + Vous n'avez pas accès à cette fonctionnalité. + Pour activer Unified Hosted Fields, contactez-nous à support@payplug.com + et activez le mode LIVE. payplug_apple_pay: can_not_save_method_with_test_key: | Le paiement par Apple Pay n’est pas disponible en mode TEST. diff --git a/translations/validators.it.yml b/translations/validators.it.yml index 7dcf67b4..b5460fc2 100644 --- a/translations/validators.it.yml +++ b/translations/validators.it.yml @@ -40,6 +40,14 @@ payplug_sylius_payplug_plugin: Non puoi ancora accedere a questa funzionalità. Per attivare Wero, contattaci a support@payplug.com e attiva la modalità LIVE. + payplug_uhf: + can_not_save_method_with_test_key: | + Il metodo di pagamento Unified Hosted Fields non è disponibile in modalità TEST. + Attiva la modalità LIVE. + can_not_save_method_no_access: | + Non puoi ancora accedere a questa funzionalità. + Per attivare Unified Hosted Fields, contattaci a support@payplug.com + e attiva la modalità LIVE. payplug_american_express: can_not_save_method_with_test_key: | Il pagamento Apple Pay non è disponibile in modalità TEST. From fa301c1b249926ed8a61eb07661d6a54f80d9e11 Mon Sep 17 00:00:00 2001 From: jhoaraupp <93672369+jhoaraupp@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:17:20 +0200 Subject: [PATCH 07/15] Feature/pre 3550 Adds Unified Hosted Fields (UHF) card tokenization at Sylius checkout (#308) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * PRE-3550: add hostedFields gateway config flag and credential fields Co-Authored-By: Claude Sonnet 5 * PRE-3550: reject combining integratedPayment and hostedFields on the same payment method Co-Authored-By: Claude Sonnet 5 * PRE-3550: register admin form hook for Hosted Fields configuration Co-Authored-By: Claude Sonnet 5 * PRE-3550: add HostedFieldsPaymentProcessorInterface with a no-op stub pending PRE-3551 Co-Authored-By: Claude Sonnet 5 * PRE-3550: relay Hosted Fields token to HostedFieldsPaymentProcessorInterface * PRE-3550: add Hosted Fields shop checkout template * PRE-3550: add Hosted Fields Stimulus controller * PRE-3550: add Behat coverage for Hosted Fields checkout visibility * PRE-3550: allow several payment methods on the payplug gateway factory A merchant must be able to offer Integrated Payment and Hosted Fields side by side, which requires two PaymentMethod entities sharing factoryName=payplug. canBeCreated() now bypasses the duplicate check for that factory only; every other PayPlug-family factory (Oney, Bancontact, Amex, Apple Pay, Scalapay, Wero) keeps the one-payment-method-per-factory rule. Co-Authored-By: Claude Sonnet 5 * PRE-3550: only accept a Hosted Fields token when the flag is enabled handleHostedFieldsToken() used to process any request carrying a non-empty hostedfields_token, so a crafted POST could complete checkout through that path for any payment method. It now verifies the payment method's gateway config actually has hostedFields=true before delegating to the processor. Also adds coverage for alterRequestConfigurationForInlineCardCapture(). Co-Authored-By: Claude Sonnet 5 * PRE-3550: mount the Hosted Fields iframes only once the method is selected Three related frontend fixes: - the wrapping div now carries data-payment-inline-submit="true", so the generic checkout "next step" button is disabled while Hosted Fields is selected (clicking it submitted an empty hostedfields_token, bypassing tokenization); - connect() no longer calls dalenys.hostedFields(...).load() unconditionally. The cross-origin iframes were mounted into a container that is still hidden at connect time (see shop/select_payment/choice.html.twig). The controller now mirrors integrated-payment: container target, idempotent openFields()/ closeFields() and handleShow()/handleHide(), loading on selection only; - the saved-card radios now pipe handleHide/handleShow to the hosted-fields controller alongside integrated-payment, so picking a saved card hides the Hosted Fields form when both oneClick and hostedFields are enabled. Co-Authored-By: Claude Sonnet 5 * PRE-3550: mock the PayPlug account lookup in the Hosted Fields Behat scenario The Hosted Fields shop template calls is_payplug_test_mode_enabled(), which performs a real PayPlug account lookup; the fixture's secretKey 'test' is not a valid credential. The scenario now uses the existing "This secret Key is valid" step, whose context had to be registered in the shop suite that runs it. Also bundles three small fixes: - HostedFieldsPaymentProcessorInterface is now an alias instead of a second definition, so it resolves to the auto-registered service and keeps its @monolog.logger.payplug binding; - fixes the "Paiement Integré" -> "Paiement Intégré" typo in validators.fr.yml; - refreshes the two stale constraint-count docblocks in PaymentMethodValidatorTest. Co-Authored-By: Claude Sonnet 5 * PRE-3550: keep Hosted Fields away from Payum after checkout completion A Hosted Fields payment carries a Dalenys hfToken and no PayPlug payment_id until PRE-3551 lands, so routing it to sylius_shop_order_pay made StatusAction markNew(), Payum rebuild the details through Convert and CaptureAction issue a real createPayment() API call - which the temporary stub must never cause, even indirectly. The redirect override cannot simply be dropped: Sylius's CheckoutRedirectListener listens to the same sylius.order.post_payment event and bails out only when _sylius['redirect'] is set. Without it, it resolves a route for the `completed` checkout state, which has no entry in sylius_shop.checkout_resolver.route_map, and the request dies with a RouteNotFoundException. Hosted Fields is therefore redirected to sylius_shop_order_show instead (same token-based, guest-accessible route, no Payum involved). Integrated Payment keeps sylius_shop_order_pay. Co-Authored-By: Claude Sonnet 5 * PRE-3550: align the redirect precedence with handle()'s dispatch order The redirect ternary picked sylius_shop_order_pay whenever hasToken() was true, but handle() checks hasHostedFieldsToken() first. A request carrying both token fields was therefore processed as Hosted Fields - never writing a payment_id - while still being redirected to sylius_shop_order_pay, reopening the StatusAction -> Convert -> CaptureAction::createPayment() chain this redirect exists to prevent. The ternary now checks hasHostedFieldsToken() first, mirroring handle(). Tests pin the invariant on both sides so the two cannot drift apart again. Also uses self::UPDATE_ORDER_PAYMENT_ROUTE instead of repeating its literal value. Co-Authored-By: Claude Sonnet 5 * PRE-3550: add oneClick to the payplug_uhf gateway configuration * PRE-3550: add PaymentMethodValidator::processUhf() with a oneClick permission check * PRE-3550: repoint the Hosted Fields shop flow onto payplug_uhf * PRE-3550: remove the flag-based Hosted Fields implementation on payplug Co-Authored-By: Claude Sonnet 5 * PRE-3550: register the payplug_uhf twig hook so Hosted Fields checkout renders The old hostedFields flag branch in _payplug.html.twig included the card-iframe markup, but that flag was removed when Hosted Fields moved to its own payplug_uhf gateway factory, leaving the shop checkout with no include site for templates/shop/hosted_fields/index.html.twig at all. Add the missing #payplug_uhf twig-hook entry (following the same pattern as the other factory-keyed hooks in shop.yaml) with a dedicated _payplug_uhf.html.twig partial, and drop the now-dead hostedFields branch from _payplug.html.twig, which is exclusively for the payplug (Integrated Payment) factory. * PRE-3550: JS-escape values interpolated into the Hosted Fields inline script Twig's HTML autoescaping does not escape a bare apostrophe, so any of the translated/dynamic values interpolated into the single-quoted JS string literals in the hosted_fields inline + +
+
+ {{ 'sylius.ui.loading'|trans }} +
+
+
+
+
+
+ {% if is_save_card_enabled(paymentMethod) %} +
+ {# No name to not trigger LiveComponent #} + +
+ {% endif %} +
+ +
+
+ + + + diff --git a/templates/shop/select_payment/_payplug_uhf.html.twig b/templates/shop/select_payment/_payplug_uhf.html.twig new file mode 100644 index 00000000..b2e08b92 --- /dev/null +++ b/templates/shop/select_payment/_payplug_uhf.html.twig @@ -0,0 +1,7 @@ +{% set method = hookable_metadata.context.method %} + +
+ {% include '@PayPlugSyliusPayPlugPlugin/shop/hosted_fields/index.html.twig' with { + 'paymentMethod': method, + } %} +
diff --git a/tests/Behat/Context/Setup/PayPlugContext.php b/tests/Behat/Context/Setup/PayPlugContext.php index 493e38b6..4b91cc1c 100644 --- a/tests/Behat/Context/Setup/PayPlugContext.php +++ b/tests/Behat/Context/Setup/PayPlugContext.php @@ -8,6 +8,7 @@ use Doctrine\Persistence\ObjectManager; use PayPlug\SyliusPayPlugPlugin\Gateway\OneyGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\Gateway\UhfGatewayFactory; use Sylius\Behat\Service\SharedStorageInterface; use Sylius\Bundle\CoreBundle\Fixture\Factory\ExampleFactoryInterface; use Sylius\Component\Core\Model\PaymentMethodInterface; @@ -67,6 +68,29 @@ 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, + UhfGatewayFactory::FACTORY_NAME, + UhfGatewayFactory::FACTORY_TITLE, + ); + + $paymentMethod->getGatewayConfig()->setConfig([ + 'secretKey' => 'test', + 'payum.http_client' => '@payplug_sylius_payplug_plugin.api_client.uhf', + UhfGatewayFactory::HF_IDENTIFIER_DEFAULT => '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/EventSubscriber/PostPaymentSelectEventSubscriberTest.php b/tests/PHPUnit/EventSubscriber/PostPaymentSelectEventSubscriberTest.php new file mode 100644 index 00000000..a13e10d5 --- /dev/null +++ b/tests/PHPUnit/EventSubscriber/PostPaymentSelectEventSubscriberTest.php @@ -0,0 +1,312 @@ +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', + ]); + $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(UhfGatewayFactory::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::once()) + ->method('process') + ->with($payment, 'hf_token_abc', 'VISA', true) + ; + + $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 is not on the payplug_uhf factory. + */ + public function testHandle_withHostedFieldsTokenButNotUhfFactory_doesNotProcessNorCompleteCheckout(): void + { + $request = Request::create('/checkout/select-payment', 'POST', [ + 'hostedfields_token' => 'hf_token_abc', + 'hostedfields_selected_brand' => 'VISA', + 'hostedfields_save_card' => 'true', + ]); + $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', + ]); + $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(UhfGatewayFactory::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); + + // 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, 'hf_token_abc', 'CB', false) + ; + $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', + ]); + $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'), + ); + } + + /** + * Hosted Fields has no PayPlug payment_id yet (PRE-3551): reaching `sylius_shop_order_pay` would + * make StatusAction markNew() and end up issuing a real createPayment() API call. A `redirect` + * entry is still required (Sylius's CheckoutRedirectListener would otherwise fail to resolve a + * route for the `completed` checkout state), so it points at `sylius_shop_order_show` instead. + */ + public function testAlterRequestConfiguration_withOnlyHostedFieldsToken_redirectsToOrderShowNotOrderPay(): 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_show', + '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), so it must be routed as Hosted Fields too — + * otherwise no payment_id is ever set and the order still lands on the Payum capture/status + * chain this redirect exists to avoid. + */ + public function testAlterRequestConfiguration_withBothTokens_followsHandleAndRedirectsToOrderShow(): 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_show', $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): PaymentMethodInterface&MockObject + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getFactoryName')->willReturn($factoryName); + + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig); + + return $paymentMethod; + } +} diff --git a/tests/PHPUnit/Gateway/Form/Extension/UhfGatewayConfigurationTypeExtensionTest.php b/tests/PHPUnit/Gateway/Form/Extension/UhfGatewayConfigurationTypeExtensionTest.php index 0d1e7d7d..57a464d5 100644 --- a/tests/PHPUnit/Gateway/Form/Extension/UhfGatewayConfigurationTypeExtensionTest.php +++ b/tests/PHPUnit/Gateway/Form/Extension/UhfGatewayConfigurationTypeExtensionTest.php @@ -9,6 +9,7 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\Form\Type\UhfGatewayConfigurationType; use PayPlug\SyliusPayPlugPlugin\Gateway\UhfGatewayFactory; use PHPUnit\Framework\TestCase; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Validator\Constraints\NotBlank; @@ -24,20 +25,7 @@ protected function setUp(): void public function testBuildForm_addsHfIdentifierDefaultTextField(): void { - $builder = $this->createMock(FormBuilderInterface::class); - - $addCalls = []; - $builder - ->expects(self::once()) - ->method('add') - ->willReturnCallback(function ($name, $type = null, array $options = []) use (&$addCalls, $builder) { - $addCalls[] = [$name, $type, $options]; - - return $builder; - }) - ; - - $this->extension->buildForm($builder, []); + [, $addCalls] = $this->buildFormAndCollectAddCalls(); [$name, $type, $options] = $addCalls[0]; self::assertSame(UhfGatewayFactory::HF_IDENTIFIER_DEFAULT, $name); @@ -52,8 +40,46 @@ public function testBuildForm_addsHfIdentifierDefaultTextField(): void self::assertInstanceOf(NotBlank::class, $options['constraints'][0]); } + public function testBuildForm_addsOneClickCheckboxField(): void + { + [, $addCalls] = $this->buildFormAndCollectAddCalls(); + + [$name, $type, $options] = $addCalls[1]; + self::assertSame(UhfGatewayFactory::ONE_CLICK, $name); + self::assertSame(CheckboxType::class, $type); + self::assertSame('payplug_checkbox', $options['block_name']); + self::assertSame('payplug_sylius_payplug_plugin.form.one_click_enable', $options['label']); + self::assertSame('payplug_sylius_payplug_plugin.form.one_click_help', $options['help']); + self::assertTrue($options['help_html']); + self::assertFalse($options['required']); + self::assertSame(AbstractGatewayConfigurationType::VALIDATION_GROUPS, $options['validation_groups']); + } + public function testGetExtendedTypes_returnsUhfGatewayConfigurationType(): void { self::assertSame([UhfGatewayConfigurationType::class], UhfGatewayConfigurationTypeExtension::getExtendedTypes()); } + + /** + * @return array{0: FormBuilderInterface, 1: array}>} + */ + private function buildFormAndCollectAddCalls(): array + { + $builder = $this->createMock(FormBuilderInterface::class); + + $addCalls = []; + $builder + ->expects(self::exactly(2)) + ->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..f7337a9a --- /dev/null +++ b/tests/PHPUnit/Gateway/Form/Type/AbstractGatewayConfigurationTypeTest.php @@ -0,0 +1,76 @@ +gatewayConfigRepository = $this->createMock(RepositoryInterface::class); + + $this->type = new AbstractGatewayConfigurationType( + $this->createMock(TranslatorInterface::class), + $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; + } +} diff --git a/tests/PHPUnit/Gateway/Validator/Constraints/IsCanSavePaymentMethodValidatorTest.php b/tests/PHPUnit/Gateway/Validator/Constraints/IsCanSavePaymentMethodValidatorTest.php new file mode 100644 index 00000000..0b12d1fd --- /dev/null +++ b/tests/PHPUnit/Gateway/Validator/Constraints/IsCanSavePaymentMethodValidatorTest.php @@ -0,0 +1,138 @@ +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]; + yield 'payplug_uhf' => [UhfGatewayFactory::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/PaymentProcessing/NullHostedFieldsPaymentProcessorTest.php b/tests/PHPUnit/PaymentProcessing/NullHostedFieldsPaymentProcessorTest.php new file mode 100644 index 00000000..c6ac578a --- /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_logsAndStoresDetailsWithoutCallingAnyApi(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getId')->willReturn(42); + $payment->method('getDetails')->willReturn(['existing' => 'value']); + + $payment->expects(self::once()) + ->method('setDetails') + ->with([ + 'existing' => 'value', + 'hosted_fields_token' => 'hf_token_123', + 'hosted_fields_selected_brand' => 'CB', + 'hosted_fields_save_card' => true, + 'status' => PaymentInterface::STATE_PROCESSING, + ]) + ; + + $this->logger->expects(self::once())->method('info'); + + $this->processor->process($payment, 'hf_token_123', 'CB', true); + } +} diff --git a/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php b/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php index 0da3f243..ebdfa911 100644 --- a/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php +++ b/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php @@ -10,6 +10,7 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\UhfGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\IsCanSavePaymentMethod; +use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\PayplugPermission; use PayPlug\SyliusPayPlugPlugin\Validator\PaymentMethodValidator; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -139,12 +140,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 { @@ -159,7 +161,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(); @@ -175,12 +176,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 { @@ -211,17 +213,16 @@ public function testProcess_payplugFactory_allFlagsEnabled_validatesWithAllConst } // ------------------------------------------------------------------------- - // process() — UHF factory → routed to processDefault(), base constraint only + // process() — UHF factory → routed to processUhf() // ------------------------------------------------------------------------- /** - * UHF gateway. Verifies the match statement routes UhfGatewayFactory::FACTORY_NAME to - * processDefault(), which validates with the base IsCanSavePaymentMethod constraint only (1 - * total), the same as Bancontact/Amex/ApplePay/Scalapay/Wero. + * UHF gateway with oneClick absent/false. Verifies processUhf() validates with the base + * IsCanSavePaymentMethod constraint only (1 total) — no permission constraint added. */ - public function testProcess_uhfFactory_validatesWithBaseConstraintOnly(): void + public function testProcess_uhfFactory_oneClickFalse_validatesWithBaseConstraintOnly(): void { - $paymentMethod = $this->buildPaymentMethod(UhfGatewayFactory::FACTORY_NAME, []); + $paymentMethod = $this->buildPaymentMethod(UhfGatewayFactory::FACTORY_NAME, [UhfGatewayFactory::ONE_CLICK => false]); $this->validator ->expects(self::once()) @@ -242,6 +243,34 @@ public function testProcess_uhfFactory_validatesWithBaseConstraintOnly(): void $this->paymentMethodValidator->process($paymentMethod); } + /** + * UHF gateway with oneClick=true. Verifies processUhf() adds a PayplugPermission + * (CAN_SAVE_CARD) constraint alongside the base one (2 total). + */ + public function testProcess_uhfFactory_oneClickTrue_validatesWithPermissionConstraint(): void + { + $paymentMethod = $this->buildPaymentMethod(UhfGatewayFactory::FACTORY_NAME, [UhfGatewayFactory::ONE_CLICK => true]); + + $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); + } + // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- diff --git a/translations/messages.en.yml b/translations/messages.en.yml index a6c4e5bc..cfcedb05 100644 --- a/translations/messages.en.yml +++ b/translations/messages.en.yml @@ -108,6 +108,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. diff --git a/translations/messages.fr.yml b/translations/messages.fr.yml index 8fff41d7..818b51e7 100644 --- a/translations/messages.fr.yml +++ b/translations/messages.fr.yml @@ -127,6 +127,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. diff --git a/translations/messages.it.yml b/translations/messages.it.yml index 390ab06c..e89ff5f8 100644 --- a/translations/messages.it.yml +++ b/translations/messages.it.yml @@ -108,6 +108,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. From 4ecf1664df366efec3b771a5e4abe1987ed0f350 Mon Sep 17 00:00:00 2001 From: hdelaforce-payplug <51410640+hdelaforce-payplug@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:20:42 +0200 Subject: [PATCH 08/15] PRE-3553 refactor: Set UHF as standard payment option (#310) --- config/services/client.xml | 9 - config/twig_hooks/admin.yaml | 24 +- config/twig_hooks/shop.yaml | 3 - .../shop/hosted_fields_payment_method.feature | 10 +- .../PostPaymentSelectEventSubscriber.php | 7 +- ...yPlugGatewayConfigurationTypeExtension.php | 100 +++- .../UhfGatewayConfigurationTypeExtension.php | 47 -- .../Type/AbstractGatewayConfigurationType.php | 52 ++- .../Type/PayPlugGatewayConfigurationType.php | 22 + .../Form/Type/UhfGatewayConfigurationType.php | 25 - src/Gateway/PayPlugGatewayFactory.php | 81 ++++ src/Gateway/UhfGatewayFactory.php | 16 - .../IsCanSavePaymentMethodValidator.php | 8 +- src/Twig/PayPlugExtension.php | 23 +- src/Validator/PaymentMethodValidator.php | 20 +- ...ault.html.twig => hf_identifier.html.twig} | 2 +- .../form/hf_sub_merchant_id.html.twig | 5 + ...html.twig => hosted_fields_mode.html.twig} | 4 +- templates/shop/hosted_fields/index.html.twig | 4 +- .../shop/select_payment/_payplug.html.twig | 15 +- .../select_payment/_payplug_uhf.html.twig | 7 - tests/Behat/Context/Setup/PayPlugContext.php | 11 +- .../PostPaymentSelectEventSubscriberTest.php | 81 +++- ...urationTypeExtensionFormSubmissionTest.php | 442 ++++++++++++++++++ ...gGatewayConfigurationTypeExtensionTest.php | 117 +++++ ...fGatewayConfigurationTypeExtensionTest.php | 85 ---- .../AbstractGatewayConfigurationTypeTest.php | 55 ++- .../PayPlugGatewayConfigurationTypeTest.php | 90 ++++ .../Gateway/PayPlugGatewayFactoryTest.php | 133 ++++++ .../IsCanSavePaymentMethodValidatorTest.php | 4 +- tests/PHPUnit/Twig/PayPlugExtensionTest.php | 50 ++ .../Validator/PaymentMethodValidatorTest.php | 33 +- translations/messages.en.yml | 9 +- translations/messages.fr.yml | 9 +- translations/messages.it.yml | 9 +- translations/validators.en.yml | 8 - translations/validators.fr.yml | 8 - translations/validators.it.yml | 8 - 38 files changed, 1317 insertions(+), 319 deletions(-) delete mode 100644 src/Gateway/Form/Extension/UhfGatewayConfigurationTypeExtension.php delete mode 100644 src/Gateway/Form/Type/UhfGatewayConfigurationType.php delete mode 100644 src/Gateway/UhfGatewayFactory.php rename templates/admin/payment_method/form/{hf_identifier_default.html.twig => hf_identifier.html.twig} (84%) create mode 100644 templates/admin/payment_method/form/hf_sub_merchant_id.html.twig rename templates/admin/payment_method/form/{integrated_payment.html.twig => hosted_fields_mode.html.twig} (80%) delete mode 100644 templates/shop/select_payment/_payplug_uhf.html.twig create mode 100644 tests/PHPUnit/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtensionFormSubmissionTest.php create mode 100644 tests/PHPUnit/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtensionTest.php delete mode 100644 tests/PHPUnit/Gateway/Form/Extension/UhfGatewayConfigurationTypeExtensionTest.php create mode 100644 tests/PHPUnit/Gateway/Form/Type/PayPlugGatewayConfigurationTypeTest.php create mode 100644 tests/PHPUnit/Gateway/PayPlugGatewayFactoryTest.php create mode 100644 tests/PHPUnit/Twig/PayPlugExtensionTest.php diff --git a/config/services/client.xml b/config/services/client.xml index 4e2053e8..73148c93 100644 --- a/config/services/client.xml +++ b/config/services/client.xml @@ -90,14 +90,5 @@ method="create"/> payplug_wero
- - - - payplug_uhf - diff --git a/config/twig_hooks/admin.yaml b/config/twig_hooks/admin.yaml index 50544202..faff9e66 100644 --- a/config/twig_hooks/admin.yaml +++ b/config/twig_hooks/admin.yaml @@ -19,12 +19,18 @@ sylius_twig_hooks: one_click: template: '@PayPlugSyliusPayPlugPlugin/admin/payment_method/form/one_click.html.twig' priority: 0 - integrated_payment: - template: '@PayPlugSyliusPayPlugPlugin/admin/payment_method/form/integrated_payment.html.twig' + hosted_fields_mode: + template: '@PayPlugSyliusPayPlugPlugin/admin/payment_method/form/hosted_fields_mode.html.twig' priority: 0 + hf_identifier: + template: '@PayPlugSyliusPayPlugPlugin/admin/payment_method/form/hf_identifier.html.twig' + priority: -1 + hf_sub_merchant_id: + template: '@PayPlugSyliusPayPlugPlugin/admin/payment_method/form/hf_sub_merchant_id.html.twig' + priority: -2 deferred_capture: template: '@PayPlugSyliusPayPlugPlugin/admin/payment_method/form/deferred_capture.html.twig' - priority: 0 + priority: -3 'sylius_admin.payment_method.create.content.form.sections.gateway_configuration.payplug_oney': &oneyGateway live_checkbox: *liveCheckbox fees_for: @@ -44,15 +50,6 @@ sylius_twig_hooks: 'sylius_admin.payment_method.create.content.form.sections.gateway_configuration.payplug_wero': &weroGateway live_checkbox: *liveCheckbox - 'sylius_admin.payment_method.create.content.form.sections.gateway_configuration.payplug_uhf': &uhfGateway - live_checkbox: *liveCheckbox - one_click: - template: '@PayPlugSyliusPayPlugPlugin/admin/payment_method/form/one_click.html.twig' - priority: 0 - hf_identifier_default: &hfIdentifierDefault - template: '@PayPlugSyliusPayPlugPlugin/admin/payment_method/form/hf_identifier_default.html.twig' - priority: -1 - 'sylius_admin.payment_method.update.content.form.sections.gateway_configuration.payplug': <<: *payplugGateway renew_oauth: &renewOAuth @@ -76,6 +73,3 @@ sylius_twig_hooks: 'sylius_admin.payment_method.update.content.form.sections.gateway_configuration.payplug_wero': <<: *weroGateway renew_oauth: *renewOAuth - 'sylius_admin.payment_method.update.content.form.sections.gateway_configuration.payplug_uhf': - <<: *uhfGateway - renew_oauth: *renewOAuth diff --git a/config/twig_hooks/shop.yaml b/config/twig_hooks/shop.yaml index ffa22870..36252bfd 100644 --- a/config/twig_hooks/shop.yaml +++ b/config/twig_hooks/shop.yaml @@ -50,6 +50,3 @@ sylius_twig_hooks: 'sylius_shop.shared.form.select_payment.payment.choice.details#payplug_wero': wero: template: '@PayPlugSyliusPayPlugPlugin/shop/select_payment/_wero.html.twig' - 'sylius_shop.shared.form.select_payment.payment.choice.details#payplug_uhf': - uhf: - template: '@PayPlugSyliusPayPlugPlugin/shop/select_payment/_payplug_uhf.html.twig' diff --git a/features/shop/hosted_fields_payment_method.feature b/features/shop/hosted_fields_payment_method.feature index 01a36834..7f22142b 100644 --- a/features/shop/hosted_fields_payment_method.feature +++ b/features/shop/hosted_fields_payment_method.feature @@ -2,25 +2,25 @@ Feature: Paying with Hosted Fields during checkout In order to buy products As a Customer - I want to see Hosted Fields as a distinct payment method at checkout + I want to see Hosted Fields when the merchant enabled it on the PayPlug payment method Background: Given the store operates on a single channel in "United States" And that channel also allows to shop using the "EUR" currency And there is a user "john@bitbag.pl" identified by "password123" And I changed my currency to "EUR" - And the store has a payment method "PayPlug Hosted Fields" with a code "payplug_hosted_fields" and PayPlug Hosted Fields payment gateway + And the store has a payment method "PayPlug" with a code "payplug" and PayPlug Hosted Fields payment gateway And This secret Key is valid And the store ships everywhere for free And the store has "DHL" shipping method with "$0.00" fee And I am logged in as "john@bitbag.pl" @ui - Scenario: I can see and select the Hosted Fields payment method + Scenario: I can see and select the PayPlug payment method with Hosted Fields enabled Given the store has a product "PHP T-Shirt" priced at "€50.00" And I added product "PHP T-Shirt" to the cart And I chose "DHL" shipping method Then I should be on the checkout payment step - And I should be able to select "PayPlug Hosted Fields" payment method - And I select "PayPlug Hosted Fields" payment method + And I should be able to select "PayPlug" payment method + And I select "PayPlug" payment method And I should see the "#card-container" element on the page diff --git a/src/EventSubscriber/PostPaymentSelectEventSubscriber.php b/src/EventSubscriber/PostPaymentSelectEventSubscriber.php index d013f5b0..47701bbf 100644 --- a/src/EventSubscriber/PostPaymentSelectEventSubscriber.php +++ b/src/EventSubscriber/PostPaymentSelectEventSubscriber.php @@ -5,7 +5,7 @@ namespace PayPlug\SyliusPayPlugPlugin\EventSubscriber; use Doctrine\ORM\EntityManagerInterface; -use PayPlug\SyliusPayPlugPlugin\Gateway\UhfGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; use PayPlug\SyliusPayPlugPlugin\PaymentProcessing\HostedFieldsPaymentProcessorInterface; use Sylius\Abstraction\StateMachine\StateMachineInterface; use Sylius\Bundle\ResourceBundle\Event\ResourceControllerEvent; @@ -194,7 +194,10 @@ private function handleHostedFieldsToken(Request $request, PaymentInterface $las private function isHostedFieldsEnabled(PaymentInterface $payment): bool { - return UhfGatewayFactory::FACTORY_NAME === $payment->getMethod()?->getGatewayConfig()?->getFactoryName(); + $gatewayConfig = $payment->getMethod()?->getGatewayConfig(); + + return PayPlugGatewayFactory::FACTORY_NAME === $gatewayConfig?->getFactoryName() && + true === ($gatewayConfig->getConfig()[PayPlugGatewayFactory::HOSTED_FIELDS] ?? false); } private function getRequestField(Request $request, string $field): string diff --git a/src/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtension.php b/src/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtension.php index a6c47a2a..273617ad 100644 --- a/src/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtension.php +++ b/src/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtension.php @@ -9,12 +9,21 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; use Symfony\Component\Form\AbstractTypeExtension; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\PasswordType; +use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormError; use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvents; +use Symfony\Contracts\Translation\TranslatorInterface; final class PayPlugGatewayConfigurationTypeExtension extends AbstractTypeExtension { + public function __construct(private TranslatorInterface $translator) + { + } + /** * @inheritdoc */ @@ -29,12 +38,6 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'help_html' => true, 'required' => false, ]) - ->add(PayPlugGatewayFactory::INTEGRATED_PAYMENT, CheckboxType::class, [ - 'block_name' => 'payplug_checkbox', - 'label' => 'payplug_sylius_payplug_plugin.form.integrated_payment_enable', - 'validation_groups' => AbstractGatewayConfigurationType::VALIDATION_GROUPS, - 'required' => false, - ]) ->add(PayPlugGatewayFactory::DEFERRED_CAPTURE, CheckboxType::class, [ 'block_name' => 'payplug_checkbox', 'label' => 'payplug_sylius_payplug_plugin.form.deferred_capture_enable', @@ -43,6 +46,49 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'help_html' => true, 'required' => false, ]) + ->add(PayPlugGatewayFactory::DISPLAY_MODE_FIELD, ChoiceType::class, [ + 'mapped' => false, + 'required' => false, + 'expanded' => true, + 'placeholder' => 'payplug_sylius_payplug_plugin.form.redirected_payment_enable', + 'validation_groups' => AbstractGatewayConfigurationType::VALIDATION_GROUPS, + 'choices' => [ + '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, + ], + ]) + ->add(PayPlugGatewayFactory::HF_IDENTIFIER, TextType::class, [ + 'label' => 'payplug_sylius_payplug_plugin.ui.hf_identifier_label', + 'required' => false, + 'validation_groups' => AbstractGatewayConfigurationType::VALIDATION_GROUPS, + ]) + ->add(PayPlugGatewayFactory::HF_SUB_MERCHANT_ID, PasswordType::class, [ + 'label' => 'payplug_sylius_payplug_plugin.ui.hf_sub_merchant_id_label', + 'required' => false, + 'validation_groups' => AbstractGatewayConfigurationType::VALIDATION_GROUPS, + ]) + ->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event): void { + $rawData = $event->getData(); + if (!is_array($rawData)) { + return; + } + + $submitted = $rawData[PayPlugGatewayFactory::HF_SUB_MERCHANT_ID] ?? ''; + if (!is_scalar($submitted) || '' !== trim((string) $submitted)) { + return; + } + + // PasswordType keeps its default `always_empty` (never echoes the stored secret + // back into the rendered `value` attribute), so a blank submission means "left + // untouched", not "clear it" - same convention as a change-password form. Restore + // the previously persisted value instead of letting a blank field wipe it out. + $previousData = $event->getForm()->getData(); + $previousValue = is_array($previousData) ? ($previousData[PayPlugGatewayFactory::HF_SUB_MERCHANT_ID] ?? null) : null; + if (is_string($previousValue) && '' !== $previousValue) { + $rawData[PayPlugGatewayFactory::HF_SUB_MERCHANT_ID] = $previousValue; + $event->setData($rawData); + } + }) ->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void { $data = $event->getData(); // phpstan check @@ -52,6 +98,48 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $data['payum.http_client'] = '@payplug_sylius_payplug_plugin.api_client.payplug'; $event->setData($data); }) + // DISPLAY_MODE_FIELD's pre-selection must happen on POST_SET_DATA, not PRE_SET_DATA: + // it's `mapped => false`, and Symfony's DataMapper::mapDataToForms() runs right after + // PRE_SET_DATA dispatches (as part of the same parent setData() call), resetting every + // unmapped child back to its configured (null) default — silently wiping out a + // setData() call made from PRE_SET_DATA. POST_SET_DATA fires after that reset, so + // nothing overwrites it afterward. + ->addEventListener(FormEvents::POST_SET_DATA, function (FormEvent $event): void { + $data = $event->getData(); + if (!is_array($data)) { + return; + } + + $event->getForm()->get(PayPlugGatewayFactory::DISPLAY_MODE_FIELD)->setData( + PayPlugGatewayFactory::resolveDisplayMode($data), + ); + }) + ->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event): void { + $form = $event->getForm(); + $submittedData = [ + PayPlugGatewayFactory::DISPLAY_MODE_FIELD => $form->get(PayPlugGatewayFactory::DISPLAY_MODE_FIELD)->getData(), + PayPlugGatewayFactory::HF_IDENTIFIER => $form->get(PayPlugGatewayFactory::HF_IDENTIFIER)->getData(), + PayPlugGatewayFactory::HF_SUB_MERCHANT_ID => $form->get(PayPlugGatewayFactory::HF_SUB_MERCHANT_ID)->getData(), + ]; + + foreach (PayPlugGatewayFactory::missingHostedFieldsRequirements($submittedData) as $field) { + $messageKey = PayPlugGatewayFactory::HF_IDENTIFIER === $field + ? 'payplug_sylius_payplug_plugin.form.account_id_required' + : 'payplug_sylius_payplug_plugin.form.submerchant_id_required'; + + $form->get($field)->addError(new FormError($this->translator->trans($messageKey))); + } + }) + ->addEventListener(FormEvents::SUBMIT, function (FormEvent $event): void { + $data = $event->getData(); + if (!is_array($data)) { + return; + } + + $displayMode = $event->getForm()->get(PayPlugGatewayFactory::DISPLAY_MODE_FIELD)->getData(); + $displayMode = is_string($displayMode) ? $displayMode : null; + $event->setData(array_merge($data, PayPlugGatewayFactory::resolveDisplayModeFlags($displayMode))); + }) ; } diff --git a/src/Gateway/Form/Extension/UhfGatewayConfigurationTypeExtension.php b/src/Gateway/Form/Extension/UhfGatewayConfigurationTypeExtension.php deleted file mode 100644 index e4f95e90..00000000 --- a/src/Gateway/Form/Extension/UhfGatewayConfigurationTypeExtension.php +++ /dev/null @@ -1,47 +0,0 @@ -add(UhfGatewayFactory::HF_IDENTIFIER_DEFAULT, TextType::class, [ - 'label' => 'payplug_sylius_payplug_plugin.ui.hf_identifier_default_label', - 'required' => true, - 'validation_groups' => AbstractGatewayConfigurationType::VALIDATION_GROUPS, - 'constraints' => [ - new NotBlank([]), - ], - ]) - ->add(UhfGatewayFactory::ONE_CLICK, CheckboxType::class, [ - 'block_name' => 'payplug_checkbox', - 'label' => 'payplug_sylius_payplug_plugin.form.one_click_enable', - 'validation_groups' => AbstractGatewayConfigurationType::VALIDATION_GROUPS, - 'help' => 'payplug_sylius_payplug_plugin.form.one_click_help', - 'help_html' => true, - 'required' => false, - ]) - ; - } - - public static function getExtendedTypes(): iterable - { - return [UhfGatewayConfigurationType::class]; - } -} diff --git a/src/Gateway/Form/Type/AbstractGatewayConfigurationType.php b/src/Gateway/Form/Type/AbstractGatewayConfigurationType.php index 80fce24a..168ebfbb 100644 --- a/src/Gateway/Form/Type/AbstractGatewayConfigurationType.php +++ b/src/Gateway/Form/Type/AbstractGatewayConfigurationType.php @@ -69,6 +69,13 @@ public function buildForm(FormBuilderInterface $builder, array $options): void if (!$dataFormChannels instanceof Collection) { return; } + + $rawData = $event->getData(); + if (!\is_array($rawData) || !$this->shouldValidateBaseCurrency($rawData)) { + return; + } + + $flashedMessages = []; /** @var ChannelInterface $dataFormChannel */ foreach ($dataFormChannels as $key => $dataFormChannel) { $baseCurrency = $dataFormChannel->getBaseCurrency(); @@ -77,15 +84,12 @@ public function buildForm(FormBuilderInterface $builder, array $options): void } $baseCurrencyCode = $baseCurrency->getCode(); if ($this->gatewayBaseCurrencyCode !== $baseCurrencyCode) { - $message = $this->translator->trans( - 'payplug_sylius_payplug_plugin.form.base_currency_not_euro', - [ - '#channel_code#' => $dataFormChannel->getCode(), - '#payment_method#' => $this->gatewayFactoryTitle, - ], - ); + $message = $this->baseCurrencyViolationMessage($dataFormChannel); $formChannels->get((string) $key)->addError(new FormError($message)); - $this->requestStack->getSession()->getFlashBag()->add('error', $message); + if (!\in_array($message, $flashedMessages, true)) { + $flashedMessages[] = $message; + $this->requestStack->getSession()->getFlashBag()->add('error', $message); + } } } }) @@ -119,4 +123,36 @@ private function checkCreationRequirements( /* @phpstan-ignore-next-line */ $form->getParent()->getParent()->get('enabled')->addError(new FormError($message)); } + + /** + * Hook for subtypes to scope the base-currency-per-channel restriction below. + * Default: always enforced, preserving today's behavior for every gateway that doesn't + * override this (Bancontact, American Express, Scalapay, Wero, Oney...). + * + * @see baseCurrencyViolationMessage() Companion hook customizing the message this guards. + * + * @param array $rawFormData Raw PRE_SUBMIT data of the gateway config form. + */ + protected function shouldValidateBaseCurrency(array $rawFormData): bool + { + return true; + } + + /** + * Hook for subtypes to customize the currency-violation message. Default matches today's + * generic wording, used by every gateway subtype that doesn't override it (Bancontact, + * American Express, Scalapay, Wero, Oney...). + * + * @see shouldValidateBaseCurrency() Companion hook scoping when this message is used. + */ + protected function baseCurrencyViolationMessage(ChannelInterface $channel): string + { + return $this->translator->trans( + 'payplug_sylius_payplug_plugin.form.base_currency_not_euro', + [ + '#channel_code#' => $channel->getCode(), + '#payment_method#' => $this->gatewayFactoryTitle, + ], + ); + } } diff --git a/src/Gateway/Form/Type/PayPlugGatewayConfigurationType.php b/src/Gateway/Form/Type/PayPlugGatewayConfigurationType.php index f9056fdf..1344b3d1 100644 --- a/src/Gateway/Form/Type/PayPlugGatewayConfigurationType.php +++ b/src/Gateway/Form/Type/PayPlugGatewayConfigurationType.php @@ -5,6 +5,7 @@ namespace PayPlug\SyliusPayPlugPlugin\Gateway\Form\Type; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; +use Sylius\Component\Core\Model\ChannelInterface; use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag; #[AutoconfigureTag( @@ -22,4 +23,25 @@ final class PayPlugGatewayConfigurationType extends AbstractGatewayConfiguration protected string $gatewayFactoryName = PayPlugGatewayFactory::FACTORY_NAME; protected string $gatewayBaseCurrencyCode = PayPlugGatewayFactory::BASE_CURRENCY_CODE; + + /** + * Only `integrated_payment` requires every associated channel to be EUR; the redirected + * and `hosted_fields` display modes both work in any currency. + * + * @param array $rawFormData + */ + protected function shouldValidateBaseCurrency(array $rawFormData): bool + { + return PayPlugGatewayFactory::DISPLAY_MODE_INTEGRATED_PAYMENT === ($rawFormData[PayPlugGatewayFactory::DISPLAY_MODE_FIELD] ?? null); + } + + /** + * shouldValidateBaseCurrency() above only ever lets this fire for `integrated_payment` mode + * (redirected/hosted_fields both return false there), so this message can be specific to + * that mode rather than the generic per-gateway wording. + */ + protected function baseCurrencyViolationMessage(ChannelInterface $channel): string + { + return $this->translator->trans('payplug_sylius_payplug_plugin.form.integrated_payment_currency_incompatible'); + } } diff --git a/src/Gateway/Form/Type/UhfGatewayConfigurationType.php b/src/Gateway/Form/Type/UhfGatewayConfigurationType.php deleted file mode 100644 index c7d0b9ef..00000000 --- a/src/Gateway/Form/Type/UhfGatewayConfigurationType.php +++ /dev/null @@ -1,25 +0,0 @@ - 'payplug_uhf', - 'label' => 'payplug_sylius_payplug_plugin.ui.uhf_gateway_label', - 'priority' => 80, - ], -)] -final class UhfGatewayConfigurationType extends AbstractGatewayConfigurationType -{ - protected string $gatewayFactoryTitle = UhfGatewayFactory::FACTORY_TITLE; - - protected string $gatewayFactoryName = UhfGatewayFactory::FACTORY_NAME; - - protected string $gatewayBaseCurrencyCode = UhfGatewayFactory::BASE_CURRENCY_CODE; -} diff --git a/src/Gateway/PayPlugGatewayFactory.php b/src/Gateway/PayPlugGatewayFactory.php index 99cb0e41..21826092 100644 --- a/src/Gateway/PayPlugGatewayFactory.php +++ b/src/Gateway/PayPlugGatewayFactory.php @@ -16,4 +16,85 @@ final class PayPlugGatewayFactory extends AbstractGatewayFactory public const INTEGRATED_PAYMENT = 'integratedPayment'; public const DEFERRED_CAPTURE = 'deferredCapture'; + + public const HOSTED_FIELDS = 'hostedFields'; + + public const HF_IDENTIFIER = 'hfIdentifier'; + + public const HF_SUB_MERCHANT_ID = 'hfSubMerchantId'; + + // Unmapped admin form field driving INTEGRATED_PAYMENT/HOSTED_FIELDS below + public const DISPLAY_MODE_FIELD = 'hostedFieldsMode'; + + public const DISPLAY_MODE_INTEGRATED_PAYMENT = 'integrated_payment'; + + public const DISPLAY_MODE_HOSTED_FIELDS = 'hosted_fields'; + + /** + * Derives the admin form radio's initial selection from persisted config. + * Hosted Fields wins if both flags are somehow true, since only it carries the + * mandatory Account ID / SubMerchant ID fields the merchant would otherwise lose sight of. + */ + public static function resolveDisplayMode(array $config): ?string + { + if (true === ($config[self::HOSTED_FIELDS] ?? false)) { + return self::DISPLAY_MODE_HOSTED_FIELDS; + } + if (true === ($config[self::INTEGRATED_PAYMENT] ?? false)) { + return self::DISPLAY_MODE_INTEGRATED_PAYMENT; + } + + return null; + } + + /** + * Derives the two persisted booleans from the submitted radio value. + * Always returns both keys explicitly (rather than only the "true" one) so that switching + * away from a previously-selected mode clears the stale flag instead of leaving it behind. + * + * @return array{integratedPayment: bool, hostedFields: bool} + */ + public static function resolveDisplayModeFlags(?string $displayMode): array + { + return [ + self::INTEGRATED_PAYMENT => self::DISPLAY_MODE_INTEGRATED_PAYMENT === $displayMode, + self::HOSTED_FIELDS => self::DISPLAY_MODE_HOSTED_FIELDS === $displayMode, + ]; + } + + /** + * @param array $rawFormData Display-mode/HF-identifier/HF-sub-merchant-id + * values as submitted (assembled from the config + * form's already-submitted child forms at + * POST_SUBMIT, not PRE_SUBMIT's raw payload). + * + * @return list Config keys (HF_IDENTIFIER / HF_SUB_MERCHANT_ID) that are blank + * while hosted_fields is selected; empty if hosted_fields isn't selected + * or both fields are filled. + */ + public static function missingHostedFieldsRequirements(array $rawFormData): array + { + if (self::DISPLAY_MODE_HOSTED_FIELDS !== ($rawFormData[self::DISPLAY_MODE_FIELD] ?? null)) { + return []; + } + + $missing = []; + if (self::isBlank($rawFormData[self::HF_IDENTIFIER] ?? '')) { + $missing[] = self::HF_IDENTIFIER; + } + if (self::isBlank($rawFormData[self::HF_SUB_MERCHANT_ID] ?? '')) { + $missing[] = self::HF_SUB_MERCHANT_ID; + } + + return $missing; + } + + private static function isBlank(mixed $value): bool + { + if (!is_scalar($value)) { + return true; + } + + return '' === trim((string) $value); + } } diff --git a/src/Gateway/UhfGatewayFactory.php b/src/Gateway/UhfGatewayFactory.php deleted file mode 100644 index 772de381..00000000 --- a/src/Gateway/UhfGatewayFactory.php +++ /dev/null @@ -1,16 +0,0 @@ -isSaveCardAllowed(...)), new TwigFunction('is_payplug_test_mode_enabled', $this->isTest(...)), + new TwigFunction('payplug_hosted_fields_company_id', $this->hostedFieldsCompanyId(...)), + new TwigFunction('payplug_display_mode', $this->displayMode(...)), ]; } + /** + * @param array $config + */ + public function displayMode(array $config): ?string + { + return PayPlugGatewayFactory::resolveDisplayMode($config); + } + public function isSaveCardAllowed(PaymentMethodInterface $paymentMethod): bool { return $this->canSaveCardChecker->isAllowed($paymentMethod); @@ -37,4 +48,12 @@ public function isTest(PaymentMethodInterface $paymentMethod): bool return !(bool) $client->getAccount()['is_live']; } + + public function hostedFieldsCompanyId(PaymentMethodInterface $paymentMethod): string + { + $client = $this->apiClientFactory->createForPaymentMethod($paymentMethod); + $companyId = $client->getAccount()['company_ref'] ?? ''; + + return \is_string($companyId) ? $companyId : ''; + } } diff --git a/src/Validator/PaymentMethodValidator.php b/src/Validator/PaymentMethodValidator.php index 558b4513..2a9b0d49 100644 --- a/src/Validator/PaymentMethodValidator.php +++ b/src/Validator/PaymentMethodValidator.php @@ -12,7 +12,6 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\OneyGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\ScalapayGatewayFactory; -use PayPlug\SyliusPayPlugPlugin\Gateway\UhfGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\IsCanSavePaymentMethod; use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\IsOneyEnabled; use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\PayplugPermission; @@ -51,7 +50,6 @@ public function process(PaymentMethodInterface $paymentMethod): void ApplePayGatewayFactory::FACTORY_NAME => $this->processDefault($paymentMethod), ScalapayGatewayFactory::FACTORY_NAME => $this->processDefault($paymentMethod), WeroGatewayFactory::FACTORY_NAME => $this->processDefault($paymentMethod), - UhfGatewayFactory::FACTORY_NAME => $this->processUhf($paymentMethod), default => throw new \InvalidArgumentException('Unsupported payment method'), }; @@ -70,31 +68,19 @@ private function processPayplug(PaymentMethodInterface $paymentMethod): Constrai $config = $paymentMethod->getGatewayConfig()?->getConfig() ?? []; $constraintList = [new IsCanSavePaymentMethod()]; - if (true === $config[PayPlugGatewayFactory::ONE_CLICK]) { + if (true === ($config[PayPlugGatewayFactory::ONE_CLICK] ?? false)) { $constraintList[] = new PayplugPermission(Permission::CAN_SAVE_CARD); } - if (true === $config[PayPlugGatewayFactory::DEFERRED_CAPTURE]) { + if (true === ($config[PayPlugGatewayFactory::DEFERRED_CAPTURE] ?? false)) { $constraintList[] = new PayplugPermission(Permission::CAN_CREATE_DEFERRED_PAYMENT); } - if (true === $config[PayPlugGatewayFactory::INTEGRATED_PAYMENT]) { + if (true === ($config[PayPlugGatewayFactory::INTEGRATED_PAYMENT] ?? false)) { $constraintList[] = new PayplugPermission(Permission::CAN_USE_INTEGRATED_PAYMENTS); } return $this->validator->validate($paymentMethod, $constraintList, self::VALIDATION_GROUPS); } - private function processUhf(PaymentMethodInterface $paymentMethod): ConstraintViolationListInterface - { - $config = $paymentMethod->getGatewayConfig()?->getConfig() ?? []; - $constraintList = [new IsCanSavePaymentMethod()]; - - if (true === ($config[UhfGatewayFactory::ONE_CLICK] ?? false)) { - $constraintList[] = new PayplugPermission(Permission::CAN_SAVE_CARD); - } - - return $this->validator->validate($paymentMethod, $constraintList, self::VALIDATION_GROUPS); - } - private function processOney(PaymentMethodInterface $paymentMethod): ConstraintViolationListInterface { $constraintList = [new IsOneyEnabled()]; diff --git a/templates/admin/payment_method/form/hf_identifier_default.html.twig b/templates/admin/payment_method/form/hf_identifier.html.twig similarity index 84% rename from templates/admin/payment_method/form/hf_identifier_default.html.twig rename to templates/admin/payment_method/form/hf_identifier.html.twig index 08bcf7ff..0942b7be 100644 --- a/templates/admin/payment_method/form/hf_identifier_default.html.twig +++ b/templates/admin/payment_method/form/hf_identifier.html.twig @@ -1,4 +1,4 @@ -{% set form = hookable_metadata.context.form.gatewayConfig.config.hfIdentifierDefault %} +{% set form = hookable_metadata.context.form.gatewayConfig.config.hfIdentifier %}
{{ form_row(form) }} diff --git a/templates/admin/payment_method/form/hf_sub_merchant_id.html.twig b/templates/admin/payment_method/form/hf_sub_merchant_id.html.twig new file mode 100644 index 00000000..1f2d5696 --- /dev/null +++ b/templates/admin/payment_method/form/hf_sub_merchant_id.html.twig @@ -0,0 +1,5 @@ +{% set form = hookable_metadata.context.form.gatewayConfig.config.hfSubMerchantId %} + +
+ {{ form_row(form) }} +
diff --git a/templates/admin/payment_method/form/integrated_payment.html.twig b/templates/admin/payment_method/form/hosted_fields_mode.html.twig similarity index 80% rename from templates/admin/payment_method/form/integrated_payment.html.twig rename to templates/admin/payment_method/form/hosted_fields_mode.html.twig index 26c8c373..f63517fd 100644 --- a/templates/admin/payment_method/form/integrated_payment.html.twig +++ b/templates/admin/payment_method/form/hosted_fields_mode.html.twig @@ -1,5 +1,5 @@ -{% set form = hookable_metadata.context.form.gatewayConfig.config.integratedPayment %} +{% set form = hookable_metadata.context.form.gatewayConfig.config.hostedFieldsMode %}
{{ form_row(form) }} -
\ No newline at end of file +
diff --git a/templates/shop/hosted_fields/index.html.twig b/templates/shop/hosted_fields/index.html.twig index 4136effd..1a8c2970 100644 --- a/templates/shop/hosted_fields/index.html.twig +++ b/templates/shop/hosted_fields/index.html.twig @@ -1,10 +1,8 @@ -{% set config = paymentMethod.gatewayConfig.config %} -