diff --git a/CHANGELOG.md b/CHANGELOG.md index 7be32a5b..661ca66c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - PHP 8.2+ compatibility - Use Payment Request API from Sylius - New Unified Authentication System (OAuth2) +- **Multi-shop support**: several gateway configurations of the same type may now coexist, each + connected to its own PayPlug account and scoped to its own channels +- The connected PayPlug account is displayed on each gateway's update screen +- "Disconnect this account" per gateway, clearing that gateway's credentials without touching others +- Channels already claimed by another enabled gateway of the same type are rendered unselectable > [!IMPORTANT] > Merchants will need to contact support to switch to the new authentication method. @@ -19,12 +24,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Plugin structure has been changed to follow the new Symfony bundle structure - Front assets have been migrated to use Stimulus +- Gateway uniqueness is now enforced **per channel** instead of per installation: two gateways of + the same type may both be enabled as long as their channel sets are disjoint +- Credentials are resolved from the payment method rather than from the gateway factory name, so a + request for one channel can no longer be signed with another channel's account ### Removed - Drop Payum support - Drop Sylius 1.x support - Drop usage of Secret key - Use OAuth2 instead +### Fixed +- Integrated Payment no longer accepts a payment method id that does not belong to the order's + channel, or a disabled one — previously a shopper could have the payment created on another + merchant's PayPlug account +- Oney instalment options, the Oney availability check and Apple Pay now resolve the gateway serving + the current channel instead of an arbitrary one + +### Breaking changes for anyone extending the plugin + +| Removed / changed | Replacement | +| --- | --- | +| `PayPlugApiClientFactoryInterface::create(string $factoryName)` | `createForPaymentMethod(PaymentMethodInterface $pm)` | +| `UnifiedApiPaymentCreatorInterface::createPayment($dto)` | `createPayment($dto, PaymentMethodInterface $method)` | +| `OperationStatusFetcherInterface::getOperation($id)` | `getOperation($id, PaymentMethodInterface $method)` | +| `AbstractGatewayConfigurationType::__construct()` — `$gatewayConfigRepository` and `$requestStack` dropped | translator only | +| `shouldValidateBaseCurrency()` / `baseCurrencyViolationMessage()` — `protected` → `public`, now take the **mapped** config | same hooks, new visibility/shape | +| `$gatewayFactoryName` property on the 8 configuration types | no longer read; the factory name comes off the gateway config | +| Translation key `form.only_one_gateway_allowed` | `form.gateway_channel_conflict` (`%channel%`, `%payment_method%`) | +| Injecting `PayplugUnifiedCore\Contracts\IConfigurationRepository` (its service alias is gone) | `ScopedConfigurationRepositoryInterface`, scoped per payment method | +| `PaymentMethodRepositoryInterface::findOneByGatewayName()` — **deprecated**, returns an arbitrary config when several share a factory name | `findOneEnabledByGatewayNameAndChannel($factoryName, $channel)` | +| `OneyExtension::__construct()` — `$gatewayConfigRepository` dropped, `$paymentMethodRepository` is now the plugin's `PaymentMethodRepositoryInterface` | inject the plugin repository | +| `OneySupportedPaymentChoiceProvider::__construct()` | now also takes a `ChannelContextInterface` | + +Requires `payplug/unified-plugin-core ^1.1.2` (for the nullable `TokenOutput::$idToken`). + +> [!NOTE] +> A gateway connected before this release shows a "re-authenticate" placeholder instead of the +> account email until the merchant reconnects — the address is only available from the interactive +> OAuth `id_token`, which is minted at login. + Please refer to [github releases](https://github.com/payplug/SyliusPayPlugPlugin/releases) for historical release information. --- diff --git a/composer.json b/composer.json index 4edd883c..1d0ec8a0 100755 --- a/composer.json +++ b/composer.json @@ -14,7 +14,7 @@ "ext-json": "*", "giggsey/libphonenumber-for-php": "^8.12", "payplug/payplug-php": "^4.0", - "payplug/unified-plugin-core": "^1.1.0", + "payplug/unified-plugin-core": "^1.1.2", "php-http/message-factory": "^1.1", "sylius/refund-plugin": "^2.0", "sylius/sylius": "^2.0", diff --git a/composer.lock b/composer.lock index 743aa692..1c15664d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "de5c8fb480fe990a5976e263500c12e2", + "content-hash": "7d9d744cdee9e08809f29130004ba4ed", "packages": [ { "name": "alcohol/iso4217", @@ -5747,16 +5747,16 @@ }, { "name": "payplug/unified-plugin-core", - "version": "1.1.1", + "version": "1.1.2", "source": { "type": "git", "url": "https://github.com/payplug/unified-plugin-core.git", - "reference": "fd22a11adef6a3b9833fc986111ecf7973c59928" + "reference": "10ead914e201c06243ddfd61b5ac23f9799a39a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/payplug/unified-plugin-core/zipball/fd22a11adef6a3b9833fc986111ecf7973c59928", - "reference": "fd22a11adef6a3b9833fc986111ecf7973c59928", + "url": "https://api.github.com/repos/payplug/unified-plugin-core/zipball/10ead914e201c06243ddfd61b5ac23f9799a39a1", + "reference": "10ead914e201c06243ddfd61b5ac23f9799a39a1", "shasum": "" }, "require": { @@ -5793,9 +5793,9 @@ "description": "Core foundations shared library for Payplug e-commerce plugins.", "support": { "issues": "https://github.com/payplug/unified-plugin-core/issues", - "source": "https://github.com/payplug/unified-plugin-core/tree/1.1.1" + "source": "https://github.com/payplug/unified-plugin-core/tree/1.1.2" }, - "time": "2026-09-07T12:40:07+00:00" + "time": "2026-09-15T14:24:30+00:00" }, { "name": "payum/core", diff --git a/config/services.yaml b/config/services.yaml index 6fc5f465..5ca21497 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -30,6 +30,13 @@ services: $unifiedApiBaseUrl: '%payplug.unified_api_base_url%' $unifiedApiVerifyTls: '%payplug.unified_api_verify_tls%' + # `security.csrf.token_manager` is only registered while CSRF protection is enabled, so it is + # injected with `@?`: a hard reference would break container compilation for an application + # that turns it off, rather than degrading the way Sylius's own admin actions do. + PayPlug\SyliusPayPlugPlugin\Action\Admin\Auth\UnifiedLogoutController: + arguments: + $csrfTokenManager: '@?security.csrf.token_manager' + PayPlug\SyliusPayPlugPlugin\Repository\PaymentRepositoryInterface: class: PayPlug\SyliusPayPlugPlugin\Repository\PaymentRepository parent: sylius.repository.payment @@ -59,7 +66,13 @@ services: PayplugUnifiedCore\Contracts\IUnifiedApiHttpClient: alias: PayPlug\SyliusPayPlugPlugin\Upc\SyliusUnifiedApiHttpClient - PayplugUnifiedCore\Contracts\IConfigurationRepository: + # Deliberately NOT aliased: PayplugUnifiedCore\Contracts\IConfigurationRepository carries no + # account context on any of its methods, and since PRE-3628 a merchant may hold several CB + # gateway configs — an unscoped SyliusUpcConfigurationRepository throws on every read. Aliasing + # it would let a future UPC consumer autowire a service that blows up at runtime, in whatever + # code path UPC happened to choose; without the alias it fails at container build instead. + # Consumers take ScopedConfigurationRepositoryInterface and scope it to their payment method. + PayPlug\SyliusPayPlugPlugin\Upc\ScopedConfigurationRepositoryInterface: alias: PayPlug\SyliusPayPlugPlugin\Upc\SyliusUpcConfigurationRepository PayplugUnifiedCore\Contracts\IPaymentRepository: diff --git a/config/twig_hooks/admin.yaml b/config/twig_hooks/admin.yaml index f53f5c60..048f4775 100644 --- a/config/twig_hooks/admin.yaml +++ b/config/twig_hooks/admin.yaml @@ -55,21 +55,30 @@ sylius_twig_hooks: renew_oauth: &renewOAuth template: '@PayPlugSyliusPayPlugPlugin/admin/payment_method/form/renew_oauth.html.twig' priority: -5 + connected_account: &connectedAccount + template: '@PayPlugSyliusPayPlugPlugin/admin/payment_method/form/connected_account.html.twig' + priority: -4 'sylius_admin.payment_method.update.content.form.sections.gateway_configuration.payplug_oney': <<: *oneyGateway renew_oauth: *renewOAuth + connected_account: *connectedAccount 'sylius_admin.payment_method.update.content.form.sections.gateway_configuration.payplug_bancontact': <<: *bancontactGateway renew_oauth: *renewOAuth + connected_account: *connectedAccount 'sylius_admin.payment_method.update.content.form.sections.gateway_configuration.payplug_apple_pay': <<: *applePayGateway renew_oauth: *renewOAuth + connected_account: *connectedAccount 'sylius_admin.payment_method.update.content.form.sections.gateway_configuration.payplug_american_express': <<: *amexGateway renew_oauth: *renewOAuth + connected_account: *connectedAccount 'sylius_admin.payment_method.update.content.form.sections.gateway_configuration.payplug_scalapay': <<: *scalapayGateway renew_oauth: *renewOAuth + connected_account: *connectedAccount 'sylius_admin.payment_method.update.content.form.sections.gateway_configuration.payplug_wero': <<: *weroGateway renew_oauth: *renewOAuth + connected_account: *connectedAccount diff --git a/ruleset/phpstan-baseline.neon b/ruleset/phpstan-baseline.neon index 7736db9b..76179e7d 100644 --- a/ruleset/phpstan-baseline.neon +++ b/ruleset/phpstan-baseline.neon @@ -665,14 +665,8 @@ parameters: path: ../src/Gateway/AbstractGatewayFactory.php - - message: '#^Cannot call method add\(\) on mixed\.$#' - identifier: method.nonObject - count: 1 - path: ../src/Gateway/Form/Type/AbstractGatewayConfigurationType.php - - - - message: '#^Cannot call method getId\(\) on mixed\.$#' - identifier: method.nonObject + message: '#^PHPDoc tag @SuppressWarnings has invalid value \(\(PHPMD\.UnusedFormalParameter\)\)\: Unexpected token "\.UnusedFormalParameter\)", expected ''\)'' at offset 554 on line 11$#' + identifier: phpDoc.parseError count: 1 path: ../src/Gateway/Form/Type/AbstractGatewayConfigurationType.php diff --git a/src/Action/Admin/Auth/UnifiedAuthenticationController.php b/src/Action/Admin/Auth/UnifiedAuthenticationController.php index 4c5b7041..43bd0b2a 100644 --- a/src/Action/Admin/Auth/UnifiedAuthenticationController.php +++ b/src/Action/Admin/Auth/UnifiedAuthenticationController.php @@ -7,6 +7,7 @@ use Doctrine\ORM\EntityManagerInterface; use Payplug\Authentication; use Payplug\Payplug; +use PayPlug\SyliusPayPlugPlugin\Auth\IdTokenEmailExtractor; use PayPlug\SyliusPayPlugPlugin\Validator\PaymentMethodValidator; use PayplugUnifiedCore\Auth\OAuth2Client; use PayplugUnifiedCore\Contracts\IOAuthHttpClient; @@ -44,6 +45,7 @@ public function __construct( private PaymentMethodValidator $paymentMethodValidator, private LoggerInterface $logger, private IOAuthHttpClient $oauthHttpClient, + private IdTokenEmailExtractor $idTokenEmailExtractor, private string $payplugOauthBaseUrl, private string $payplugOauthAudience, ) { @@ -125,6 +127,13 @@ public function oauthCallback(Request $request): Response $config = $gatewayConfig->getConfig(); $config['live_client'] = $liveClientDataResult['httpResponse'] ?? null; $config['test_client'] = $testClientDataResult['httpResponse'] ?? null; + // Who just authorized, for the admin screen to show. The id_token is the only carrier + // of that identity — /account has no email field and the client-credentials token used + // for every later API call names no user — and it is discarded with $token when this + // method returns, so it is captured here or not at all. Overwritten unconditionally, + // including with null: after a re-auth against a different PayPlug account, keeping the + // previous address would misreport which account is taking the money. + $config['account_email'] = $this->idTokenEmailExtractor->extract($token->idToken); $gatewayConfig->setConfig($config); $this->entityManager->flush(); diff --git a/src/Action/Admin/Auth/UnifiedLogoutController.php b/src/Action/Admin/Auth/UnifiedLogoutController.php new file mode 100644 index 00000000..8c66f1cb --- /dev/null +++ b/src/Action/Admin/Auth/UnifiedLogoutController.php @@ -0,0 +1,126 @@ + would be invalid HTML. The CSRF token is therefore carried in the query string and + * checked here — it is the only thing between a crafted link and a merchant losing a connection. + */ +#[Route('/payplug/auth')] +final class UnifiedLogoutController extends AbstractController +{ + /** + * Per-payment-method so a token minted for one gateway cannot be replayed against another. + * Public because the template that renders the button mints the token with it. + */ + public const CSRF_TOKEN_ID_PREFIX = 'payplug_logout_'; + + /** + * @param RepositoryInterface<\Sylius\Component\Core\Model\PaymentMethod> $paymentMethodRepository + */ + public function __construct( + private RouterInterface $router, + private RepositoryInterface $paymentMethodRepository, + private GatewayConnectionRevoker $connectionRevoker, + private ?CsrfTokenManagerInterface $csrfTokenManager, + private LoggerInterface $logger, + ) { + } + + #[Route('/logout/{id}', name: 'payplug_sylius_admin_auth_logout', requirements: ['id' => '\d+'], methods: ['GET'])] + public function logout(Request $request, int $id): Response + { + $this->denyUnlessCsrfTokenIsValid($request, $id); + $paymentMethod = $this->findConnectedPayPlugPaymentMethod($id); + + try { + $this->connectionRevoker->revoke($paymentMethod); + $this->addFlashMessage($request, 'success', 'payplug_sylius_payplug_plugin.admin.logout_success'); + } catch (\Throwable $e) { + $this->logger->critical('Error while logging out the Payplug gateway', ['message' => $e->getMessage(), 'exception' => $e]); + $this->addFlashMessage($request, 'error', 'payplug_sylius_payplug_plugin.admin.logout_error'); + } + + return new RedirectResponse($this->router->generate('sylius_admin_payment_method_update', ['id' => $id])); + } + + /** + * SessionInterface makes no promise about flashes — only FlashBagAwareSessionInterface does — + * and the parent's addFlash() would reach for the container instead of the request in hand. + * A non-flash-aware session means a non-web context, where the message has no one to reach + * anyway, so it is dropped rather than raised. + */ + private function addFlashMessage(Request $request, string $type, string $message): void + { + $session = $request->getSession(); + + if ($session instanceof FlashBagAwareSessionInterface) { + $session->getFlashBag()->add($type, $message); + } + } + + /** + * A null token manager means the application runs with CSRF protection off — the service only + * exists while it is on, hence the optional injection in config/services.yaml. The check is + * then skipped rather than failed, matching Sylius's own admin actions that carry a token in + * the query string and guard it with sylius_csrf_protection_enabled(). + */ + private function denyUnlessCsrfTokenIsValid(Request $request, int $id): void + { + if (null === $this->csrfTokenManager) { + return; + } + + $token = new CsrfToken(self::CSRF_TOKEN_ID_PREFIX . $id, $request->query->getString('_csrf_token')); + + if (!$this->csrfTokenManager->isTokenValid($token)) { + throw new BadRequestHttpException('Invalid CSRF token for the PayPlug logout request.'); + } + } + + /** + * 404 rather than a flash for both misses: the only legitimate source of this link is the + * connected-account block on a PayPlug gateway's own update screen, so an id that names + * nothing — or names another provider's payment method, which logout must never disable — + * is a tampered or stale URL, not a merchant mistake worth explaining. + */ + private function findConnectedPayPlugPaymentMethod(int $id): PaymentMethodInterface + { + $paymentMethod = $this->paymentMethodRepository->find($id); + + if ( + !$paymentMethod instanceof PaymentMethodInterface || + !str_contains((string) $paymentMethod->getGatewayConfig()?->getFactoryName(), 'payplug') + ) { + throw new NotFoundHttpException(sprintf('No PayPlug payment method found with id %d.', $id)); + } + + return $paymentMethod; + } +} diff --git a/src/ApiClient/PayPlugApiClientFactory.php b/src/ApiClient/PayPlugApiClientFactory.php index 6b7d8f8a..6e6f641d 100644 --- a/src/ApiClient/PayPlugApiClientFactory.php +++ b/src/ApiClient/PayPlugApiClientFactory.php @@ -21,6 +21,18 @@ public function __construct( ) { } + /** + * Channel-ambiguous: since PRE-3628 several enabled gateway configs may share a factory name — + * one per channel — and findOneBy() then returns an arbitrary one of them, so the client this + * returns may carry another channel's account credentials. + * + * @internal Kept off {@see PayPlugApiClientFactoryInterface} so no application class can reach + * it; the sole remaining callers are the `payplug_sylius_payplug_plugin.api_client.*` + * service-factory definitions in config/services/client.xml, which are #[Autowire]d + * into seven services that have no payment method in scope. Use + * {@see self::createForPaymentMethod()} everywhere else. Removed once those + * singletons are made channel-aware — the open half of PRE-3682. + */ public function create(string $factoryName): PayPlugApiClientInterface { /** @var GatewayConfigInterface|null $gatewayConfig */ diff --git a/src/ApiClient/PayPlugApiClientFactoryInterface.php b/src/ApiClient/PayPlugApiClientFactoryInterface.php index 12b96f2a..70cb1912 100644 --- a/src/ApiClient/PayPlugApiClientFactoryInterface.php +++ b/src/ApiClient/PayPlugApiClientFactoryInterface.php @@ -8,7 +8,12 @@ interface PayPlugApiClientFactoryInterface { - public function create(string $factoryName): PayPlugApiClientInterface; - + /** + * The only way to obtain a client from application code. Resolving one by factory name is + * deliberately absent: since PRE-3628 several enabled gateway configs may share a factory name + * — one per channel — so a name-based lookup returns an arbitrary one of them and can sign a + * request for channel A with channel B's account credentials. Keeping that signature off this + * interface makes the compiler, rather than review, the guard against reintroducing it. + */ public function createForPaymentMethod(PaymentMethodInterface $paymentMethod): PayPlugApiClientInterface; } diff --git a/src/Auth/GatewayConnectionRevoker.php b/src/Auth/GatewayConnectionRevoker.php new file mode 100644 index 00000000..3d1fc8bd --- /dev/null +++ b/src/Auth/GatewayConnectionRevoker.php @@ -0,0 +1,102 @@ +getGatewayConfig() ?? throw new \LogicException( + 'The payment method has no gateway config, so no PayPlug connection can be revoked for it.', + ); + + $config = $gatewayConfig->getConfig(); + + // Read before the keys are dropped: the client id is the only handle on the cache entry. + // The cache is purged after the flush, so a failing flush cannot leave a gateway whose + // credentials still persist but whose tokens are gone. + $revokedCredentials = \array_map( + static fn (string $clientKey): mixed => $config[$clientKey] ?? null, + self::CLIENT_CREDENTIAL_KEYS, + ); + + foreach (self::CONNECTION_KEYS as $key) { + unset($config[$key]); + } + + // The Hosted Fields account id identifies the account that was just disconnected, so it is + // stale the moment the merchant reconnects to a different one. Cleared only where it is + // actually in use — on any other gateway, or on a CB gateway in redirected/integrated mode, + // it is an inert leftover the merchant may still want on their next login. + if (PayPlugGatewayFactory::isHostedFieldsConfig($gatewayConfig)) { + unset($config[PayPlugGatewayFactory::HF_IDENTIFIER]); + } + + $gatewayConfig->setConfig($config); + $paymentMethod->disable(); + + $this->entityManager->flush(); + + foreach ($revokedCredentials as $clientCredentials) { + $this->forgetCachedToken($clientCredentials); + } + } + + private function forgetCachedToken(mixed $clientCredentials): void + { + $clientId = \is_array($clientCredentials) ? ($clientCredentials['client_id'] ?? null) : null; + + if (!\is_string($clientId) || '' === $clientId) { + return; + } + + $this->tokenCache->delete(self::TOKEN_CACHE_KEY_PREFIX . $clientId); + } +} diff --git a/src/Auth/IdTokenEmailExtractor.php b/src/Auth/IdTokenEmailExtractor.php new file mode 100644 index 00000000..d52c6b8a --- /dev/null +++ b/src/Auth/IdTokenEmailExtractor.php @@ -0,0 +1,105 @@ +decodeClaims($idToken); + + $email = $claims['email'] ?? null; + + if (!\is_string($email) || false === filter_var($email, \FILTER_VALIDATE_EMAIL)) { + return null; + } + + return $email; + } + + /** + * @return array The token's claims, or an empty array for anything unreadable + */ + private function decodeClaims(?string $idToken): array + { + $payload = $this->decodePayloadSegment($idToken); + + if (null === $payload) { + return []; + } + + $claims = json_decode($payload, true); + + return \is_array($claims) ? $claims : []; + } + + /** + * @return string|null The decoded payload segment, or null when the token is absent, empty or + * not the three dot-separated segments a JWT is made of + */ + private function decodePayloadSegment(?string $idToken): ?string + { + if (null === $idToken || '' === $idToken) { + return null; + } + + $segments = explode('.', $idToken); + + if (self::SEGMENT_COUNT !== \count($segments)) { + return null; + } + + return $this->base64UrlDecode($segments[self::PAYLOAD_SEGMENT]); + } + + /** + * JWT segments use base64url (RFC 7515 §2): `-`/`_` in place of `+`/`/`, and no `=` padding. + * Feeding one straight to base64_decode() silently corrupts any payload containing those + * characters, which real id tokens routinely do. + */ + private function base64UrlDecode(string $segment): ?string + { + $padded = str_pad(strtr($segment, '-_', '+/'), (int) (ceil(\strlen($segment) / 4) * 4), '='); + + $decoded = base64_decode($padded, true); + + return false === $decoded ? null : $decoded; + } +} diff --git a/src/Checker/GatewayChannelConflictChecker.php b/src/Checker/GatewayChannelConflictChecker.php new file mode 100644 index 00000000..6840dc24 --- /dev/null +++ b/src/Checker/GatewayChannelConflictChecker.php @@ -0,0 +1,126 @@ + + */ + public function findConflicts(PaymentMethodInterface $paymentMethod, string $factoryName): array + { + $channelCodes = $this->channelCodes($paymentMethod); + + if (!$paymentMethod->isEnabled() || [] === $channelCodes) { + return []; + } + + return \array_values(\array_filter( + $this->claims($paymentMethod, $factoryName), + static fn (string $channelCode): bool => \in_array($channelCode, $channelCodes, true), + \ARRAY_FILTER_USE_KEY, + )); + } + + /** + * The channels the gateway channel picker must render as unselectable, keyed by channel code. + * + * Deliberately unconditional where `findConflicts()` returns early on the subject's `enabled` + * flag: the picker is rendered before the admin has decided anything. Building both answers + * from the same `claims()` lookup is what keeps the disabled options and the submit-time rule + * in step. + * + * The subject's own channels are excluded, however. A browser does not submit a disabled + * checkbox, so disabling one that is *checked* would silently drop that channel the next time + * the admin saves — the very removal this picker exists to prevent. An overlap that already + * exists in the data therefore stays selectable and is reported by `findConflicts()` on submit + * instead. `claims()` alone cannot cover this: it excludes the subject by id, which says + * nothing about a *different* enabled payment method holding a channel the subject also holds. + * + * @return array + */ + public function findClaimedChannels(PaymentMethodInterface $paymentMethod, string $factoryName): array + { + return \array_diff_key( + \array_map( + static fn (array $claim): PaymentMethodInterface => $claim['paymentMethod'], + $this->claims($paymentMethod, $factoryName), + ), + \array_flip($this->channelCodes($paymentMethod)), + ); + } + + /** + * Channels held by *other* enabled payment methods of the same factory. + * + * Keying by channel code collapses the (post-PRE-3628 unreachable) case of two enabled rivals + * holding the same channel down to a single claim, so neither caller reports it twice. + * + * @return array + */ + private function claims(PaymentMethodInterface $paymentMethod, string $factoryName): array + { + $claims = []; + foreach ($this->paymentMethodRepository->findEnabledByGatewayName($factoryName) as $rival) { + if ($this->isSamePaymentMethod($paymentMethod, $rival) || !$rival->isEnabled()) { + continue; + } + + foreach ($rival->getChannels() as $rivalChannel) { + if (!$rivalChannel instanceof ChannelInterface) { + continue; + } + + $channelCode = $rivalChannel->getCode(); + + if (null !== $channelCode) { + $claims[$channelCode] = ['channel' => $rivalChannel, 'paymentMethod' => $rival]; + } + } + } + + return $claims; + } + + /** + * @return list + */ + private function channelCodes(PaymentMethodInterface $paymentMethod): array + { + $channelCodes = []; + foreach ($paymentMethod->getChannels() as $channel) { + $channelCode = $channel->getCode(); + if (null !== $channelCode) { + $channelCodes[] = $channelCode; + } + } + + return $channelCodes; + } + + /** + * On creation the subject has no id yet, so it can never match a persisted rival. + */ + private function isSamePaymentMethod(PaymentMethodInterface $subject, PaymentMethodInterface $rival): bool + { + return null !== $subject->getId() && $subject->getId() === $rival->getId(); + } +} diff --git a/src/Command/Handler/CaptureAliasPaymentRequestHandler.php b/src/Command/Handler/CaptureAliasPaymentRequestHandler.php index 9d4c772d..10f27991 100644 --- a/src/Command/Handler/CaptureAliasPaymentRequestHandler.php +++ b/src/Command/Handler/CaptureAliasPaymentRequestHandler.php @@ -61,7 +61,7 @@ public function __invoke(CaptureAliasPaymentRequest $captureAliasPaymentRequest) $common = $this->contextBuilder->buildCommonFields($accountId, $amount, $currencyCode, $paymentRequest, $order); $dto = $this->buildPaymentDto($common, $card, $order); - $output = $this->unifiedApiPaymentCreator->createPayment($dto); + $output = $this->unifiedApiPaymentCreator->createPayment($dto, $method); } catch (ApiException | InvalidPaymentException | \LogicException $e) { $this->outcomeApplier->failPaymentRequest($paymentRequest, $payment, $e, PaymentCaptureFlow::Alias); diff --git a/src/Command/Handler/CaptureHostedPaymentRequestHandler.php b/src/Command/Handler/CaptureHostedPaymentRequestHandler.php index a5efd925..9423f17d 100644 --- a/src/Command/Handler/CaptureHostedPaymentRequestHandler.php +++ b/src/Command/Handler/CaptureHostedPaymentRequestHandler.php @@ -19,6 +19,7 @@ use Sylius\Abstraction\StateMachine\StateMachineInterface; use Sylius\Bundle\PaymentBundle\Provider\PaymentRequestProviderInterface; use Sylius\Component\Core\Model\PaymentInterface; +use Sylius\Component\Payment\Model\PaymentMethodInterface; use Sylius\Component\Payment\Model\PaymentRequestInterface; use Sylius\Component\Payment\PaymentRequestTransitions; use Symfony\Component\Messenger\Attribute\AsMessageHandler; @@ -64,7 +65,7 @@ public function __invoke(CaptureHostedPaymentRequest $captureHostedPaymentReques 'payload' => $dto->createPayloadBody(), ]); - $output = $this->unifiedApiPaymentCreator->createPayment($dto); + $output = $this->unifiedApiPaymentCreator->createPayment($dto, $method); } catch (ApiException | InvalidHostedFieldException | \LogicException $e) { $this->outcomeApplier->failPaymentRequest($paymentRequest, $payment, $e, PaymentCaptureFlow::Hosted); @@ -83,7 +84,7 @@ public function __invoke(CaptureHostedPaymentRequest $captureHostedPaymentReques $saveCard = true === ($details['hosted_fields_save_card'] ?? false); if ($saveCard && null !== $output->aliasId) { $unifiedApiOperationId = $hostedFieldsIds['hosted_fields_operation_id'] ?? null; - $fetchedCardData = null !== $unifiedApiOperationId ? $this->fetchCardDataFromUnifiedApi($unifiedApiOperationId) : []; + $fetchedCardData = null !== $unifiedApiOperationId ? $this->fetchCardDataFromUnifiedApi($unifiedApiOperationId, $method) : []; $this->cardPersister->persist($output->aliasId, $payment, $method, $details, $fetchedCardData); } @@ -171,10 +172,10 @@ private function buildHostedFieldDto( * * @return array{aliasId?: string, brand?: string, last4?: string, expirationMonth?: int, expirationYear?: int} */ - private function fetchCardDataFromUnifiedApi(string $operationId): array + private function fetchCardDataFromUnifiedApi(string $operationId, PaymentMethodInterface $method): array { try { - $response = $this->operationStatusFetcher->getOperation($operationId); + $response = $this->operationStatusFetcher->getOperation($operationId, $method); } catch (ApiException $e) { $this->logger->error('[PayPlug][UPC] Failed to fetch operation for card metadata.', [ 'unified_api_operation_id' => $operationId, diff --git a/src/Command/Handler/NotifyHostedPaymentRequestHandler.php b/src/Command/Handler/NotifyHostedPaymentRequestHandler.php index 00c07813..e41b9f59 100644 --- a/src/Command/Handler/NotifyHostedPaymentRequestHandler.php +++ b/src/Command/Handler/NotifyHostedPaymentRequestHandler.php @@ -5,7 +5,7 @@ namespace PayPlug\SyliusPayPlugPlugin\Command\Handler; use PayPlug\SyliusPayPlugPlugin\Command\NotifyHostedPaymentRequest; -use PayplugUnifiedCore\Contracts\IConfigurationRepository; +use PayPlug\SyliusPayPlugPlugin\Upc\ScopedConfigurationRepositoryInterface; use PayplugUnifiedCore\Contracts\ILock; use PayplugUnifiedCore\Contracts\IOrderStateMutator; use PayplugUnifiedCore\Contracts\IPaymentRepository; @@ -16,6 +16,7 @@ use Psr\Log\LoggerInterface; use Sylius\Abstraction\StateMachine\StateMachineInterface; use Sylius\Bundle\PaymentBundle\Provider\PaymentRequestProviderInterface; +use Sylius\Component\Payment\Model\PaymentMethodInterface; use Sylius\Component\Payment\Model\PaymentRequestInterface; use Sylius\Component\Payment\PaymentRequestTransitions; use Symfony\Component\Messenger\Attribute\AsMessageHandler; @@ -37,7 +38,7 @@ public function __construct( private ILock $lock, private IPaymentRepository $paymentRepository, private IOrderStateMutator $orderStateMutator, - private IConfigurationRepository $configurationRepository, + private ScopedConfigurationRepositoryInterface $configurationRepository, private LoggerInterface $logger, ) { } @@ -63,7 +64,13 @@ public function __invoke(NotifyHostedPaymentRequest $notifyHostedPaymentRequest) } try { - $expectedHeader = $this->configurationRepository->get(self::CONFIG_KEY_WEBHOOK_AUTHORIZATION_HEADER) ?? ''; + // Per-gateway-config shared secret: verify against the account this payment belongs to. + $method = $paymentRequest->getPayment()->getMethod(); + if (!$method instanceof PaymentMethodInterface) { + throw new \LogicException('The payment has no payment method, so no webhook secret can be resolved for it.'); + } + + $expectedHeader = $this->configurationRepository->forPaymentMethod($method)->get(self::CONFIG_KEY_WEBHOOK_AUTHORIZATION_HEADER) ?? ''; $operationData = WebhookNotificationHelper::parse($headers, $rawBody, $expectedHeader); if (!$this->matchesPaymentRequest($paymentRequest, $operationData)) { diff --git a/src/Command/Handler/StatusHostedPaymentRequestHandler.php b/src/Command/Handler/StatusHostedPaymentRequestHandler.php index cd9ba868..4b4e6ea7 100644 --- a/src/Command/Handler/StatusHostedPaymentRequestHandler.php +++ b/src/Command/Handler/StatusHostedPaymentRequestHandler.php @@ -13,6 +13,7 @@ use Sylius\Abstraction\StateMachine\StateMachineInterface; use Sylius\Bundle\PaymentBundle\Provider\PaymentRequestProviderInterface; use Sylius\Component\Core\Model\PaymentInterface; +use Sylius\Component\Payment\Model\PaymentMethodInterface; use Sylius\Component\Payment\PaymentRequestTransitions; use Sylius\Component\Payment\PaymentTransitions; use Symfony\Component\Messenger\Attribute\AsMessageHandler; @@ -77,35 +78,56 @@ public function __invoke(StatusHostedPaymentRequest $statusHostedPaymentRequest) */ private function pollForOutcomeIfStillPending(PaymentInterface $payment): void { - if (\in_array($payment->getState(), self::RESOLVED_STATES, true)) { + $operationId = self::resolveOperationId($payment->getDetails()); + $method = $payment->getMethod(); + + // One guard rather than three: there is nothing to poll unless the payment is still + // pending, carries an operation id and names the method whose account owns it. + if ( + \in_array($payment->getState(), self::RESOLVED_STATES, true) || + null === $operationId || + !$method instanceof PaymentMethodInterface + ) { return; } - $operationId = self::resolveOperationId($payment->getDetails()); - if (null === $operationId) { + $body = $this->fetchOperationBody($payment, $operationId, $method); + + if (null === $body) { return; } try { - $response = $this->operationStatusFetcher->getOperation($operationId); - } catch (ApiException $e) { - $this->logger->error('[PayPlug][UPC] Hosted payment status poll failed.', [ + $this->webhookNotificationHandler->treat($payment, $body, []); + } catch (InvalidNotificationException $e) { + $this->logger->error('[PayPlug][UPC] Hosted payment status poll returned a payload that could not be applied.', [ 'sylius_payment_id' => $payment->getId(), 'hosted_fields_operation_id' => $operationId, 'error' => $e->getMessage(), ]); - - return; } + } + /** + * A failed poll is logged and swallowed rather than raised: it is a fallback for a webhook that + * has not arrived *yet*, so leaving the payment where it is lets the webhook — or the + * UpdatePaymentStateCommand — resolve it later. + */ + private function fetchOperationBody( + PaymentInterface $payment, + string $operationId, + PaymentMethodInterface $method, + ): ?string { try { - $this->webhookNotificationHandler->treat($payment, $response['body'], []); - } catch (InvalidNotificationException $e) { - $this->logger->error('[PayPlug][UPC] Hosted payment status poll returned a payload that could not be applied.', [ + return $this->operationStatusFetcher->getOperation($operationId, $method)['body']; + } catch (ApiException $e) { + $this->logger->error('[PayPlug][UPC] Hosted payment status poll failed.', [ 'sylius_payment_id' => $payment->getId(), 'hosted_fields_operation_id' => $operationId, 'error' => $e->getMessage(), ]); + + return null; } } diff --git a/src/Controller/IntegratedPaymentController.php b/src/Controller/IntegratedPaymentController.php index a5b53925..caaeeb43 100644 --- a/src/Controller/IntegratedPaymentController.php +++ b/src/Controller/IntegratedPaymentController.php @@ -5,7 +5,7 @@ namespace PayPlug\SyliusPayPlugPlugin\Controller; use Doctrine\ORM\EntityManagerInterface; -use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactory; +use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface; use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface; use PayPlug\SyliusPayPlugPlugin\Creator\PayPlugPaymentDataCreator; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; @@ -35,7 +35,7 @@ public function __construct( private RepositoryInterface $paymentMethodRepository, private OrderRepositoryInterface $orderRepository, private PayPlugPaymentDataCreator $paymentDataCreator, - private PayPlugApiClientFactory $apiClientFactory, + private PayPlugApiClientFactoryInterface $apiClientFactory, private EntityManagerInterface $entityManager, private LoggerInterface $logger, ) { @@ -52,11 +52,6 @@ public function __construct( #[Route(path: '/{_locale}/payplug/integrated-payment/init/{paymentMethodId}', name: 'payplug_sylius_integrated_payment_init', methods: ['GET', 'POST'])] public function initPaymentAction(Request $request, int $paymentMethodId): Response { - $paymentMethod = $this->paymentMethodRepository->find($paymentMethodId); - if (!$paymentMethod instanceof PaymentMethodInterface) { - throw $this->createNotFoundException(); - } - $order = null; if (\is_string($orderToken = $request->query->get('orderToken'))) { $order = $this->orderRepository->findOneByTokenValue($orderToken); @@ -73,18 +68,17 @@ public function initPaymentAction(Request $request, int $paymentMethodId): Respo throw $this->createNotFoundException('No payment available'); } + // The id is shopper-supplied, so it is resolved against the order before anything is + // done with it — see resolvePaymentMethod(). + $paymentMethod = $this->resolvePaymentMethod($paymentMethodId, $order); $payment->setMethod($paymentMethod); - $factoryName = $paymentMethod->getGatewayConfig()?->getFactoryName(); - if (PayPlugGatewayFactory::FACTORY_NAME !== $factoryName) { - throw new BadRequestHttpException('Unsupported payment method of Integrated Payment'); - } $paymentData = $this->paymentDataCreator->create($payment); // Mandatory $paymentData['integration'] = PayPlugApiClientInterface::INTEGRATED_PAYMENT_INTEGRATION; $this->logger->debug('Payplug Payment data for creation', $paymentData->getArrayCopy()); - $apiClient = $this->apiClientFactory->create($factoryName); + $apiClient = $this->apiClientFactory->createForPaymentMethod($paymentMethod); $payplugPayment = $apiClient->createPayment($paymentData->getArrayCopy()); $this->logger->debug('PayPlug payment created', (array) $payplugPayment); @@ -98,4 +92,60 @@ public function initPaymentAction(Request $request, int $paymentMethodId): Respo 'payment_id' => $payplugPayment->id, ], Response::HTTP_CREATED); } + + /** + * Resolves the shopper-supplied payment method id, refusing anything the order could not + * legitimately be paid with. + * + * The channel check is the load-bearing one. Since PRE-3628 several CB gateway configs may + * coexist, one per channel, each connected to a different PayPlug account, and + * createForPaymentMethod() faithfully resolves whichever account this id names. Without it a + * shopper checking out on channel A could pass channel B's payment method id and have the + * payment created on merchant B's account, while the IPN — resolving the same method — verifies + * against B's key and marks order A paid. + */ + private function resolvePaymentMethod(int $paymentMethodId, OrderInterface $order): PaymentMethodInterface + { + $paymentMethod = $this->paymentMethodRepository->find($paymentMethodId); + + if (!$paymentMethod instanceof PaymentMethodInterface) { + throw $this->createNotFoundException(); + } + + if (PayPlugGatewayFactory::FACTORY_NAME !== $paymentMethod->getGatewayConfig()?->getFactoryName()) { + throw new BadRequestHttpException('Unsupported payment method of Integrated Payment'); + } + + if (!$paymentMethod->isEnabled()) { + throw new BadRequestHttpException('The payment method is not enabled.'); + } + + if (!$this->isAvailableOnChannelOf($paymentMethod, $order)) { + throw new BadRequestHttpException('The payment method is not available on this channel.'); + } + + return $paymentMethod; + } + + /** + * Matched on channel code rather than object identity, the same way + * GatewayChannelConflictChecker does: the order and the payment method may carry channel + * instances from different unit-of-work states. + */ + private function isAvailableOnChannelOf(PaymentMethodInterface $paymentMethod, OrderInterface $order): bool + { + $channelCode = $order->getChannel()?->getCode(); + + if (null === $channelCode) { + return false; + } + + foreach ($paymentMethod->getChannels() as $channel) { + if ($channelCode === $channel->getCode()) { + return true; + } + } + + return false; + } } diff --git a/src/Controller/IpnAction.php b/src/Controller/IpnAction.php index 6f2332ff..ec4709e0 100644 --- a/src/Controller/IpnAction.php +++ b/src/Controller/IpnAction.php @@ -90,7 +90,7 @@ public function __invoke(Request $request): JsonResponse if ( !$paymentMethod->getGatewayConfig() instanceof GatewayConfigInterface || - !\in_array($factoryName = $paymentMethod->getGatewayConfig()->getFactoryName(), [ + !\in_array($paymentMethod->getGatewayConfig()->getFactoryName(), [ PayPlugGatewayFactory::FACTORY_NAME, OneyGatewayFactory::FACTORY_NAME, BancontactGatewayFactory::FACTORY_NAME, @@ -100,7 +100,7 @@ public function __invoke(Request $request): JsonResponse return new JsonResponse(null, Response::HTTP_UNAUTHORIZED); } - $this->payPlugApiClient = $this->apiClientFactory->create($factoryName); + $this->payPlugApiClient = $this->apiClientFactory->createForPaymentMethod($paymentMethod); try { $resource = $this->payPlugApiClient->treat($input); diff --git a/src/Controller/OneClickAction.php b/src/Controller/OneClickAction.php index a1e27ab2..9128e584 100644 --- a/src/Controller/OneClickAction.php +++ b/src/Controller/OneClickAction.php @@ -5,7 +5,7 @@ namespace PayPlug\SyliusPayPlugPlugin\Controller; use PayPlug\SyliusPayPlugPlugin\Action\Api\ApiAwareTrait; -use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactory; +use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface; use Payum\Core\ApiAwareInterface; use Payum\Core\GatewayAwareInterface; use Payum\Core\GatewayAwareTrait; @@ -33,7 +33,7 @@ class OneClickAction extends AbstractController implements GatewayAwareInterface public function __construct( private PaymentRepositoryInterface $paymentRepository, private Payum $payum, - private PayPlugApiClientFactory $payPlugApiClientFactory, + private PayPlugApiClientFactoryInterface $payPlugApiClientFactory, ) { } diff --git a/src/Gateway/Form/Extension/PaymentMethodTypeExtension.php b/src/Gateway/Form/Extension/PaymentMethodTypeExtension.php new file mode 100644 index 00000000..b6e00330 --- /dev/null +++ b/src/Gateway/Form/Extension/PaymentMethodTypeExtension.php @@ -0,0 +1,277 @@ +addEventListener(FormEvents::POST_SET_DATA, function (FormEvent $event): void { + $form = $event->getForm(); + $paymentMethod = $this->resolvePayPlugPaymentMethod($event->getData()); + + if (null === $paymentMethod || null === $this->resolveConfigurationType($form)) { + return; + } + + /** @var GatewayConfigInterface $gatewayConfig */ + $gatewayConfig = $paymentMethod->getGatewayConfig(); + + $this->disableClaimedChannelChoices($form, $paymentMethod, (string) $gatewayConfig->getFactoryName()); + }); + + $builder->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event): void { + $form = $event->getForm(); + $configurationType = $this->resolveConfigurationType($form); + $paymentMethod = $this->resolvePayPlugPaymentMethod($event->getData()); + + if (null === $configurationType || null === $paymentMethod) { + return; + } + + /** @var GatewayConfigInterface $gatewayConfig */ + $gatewayConfig = $paymentMethod->getGatewayConfig(); + + $this->addChannelConflictErrors($form, $paymentMethod, (string) $gatewayConfig->getFactoryName()); + $this->addBaseCurrencyErrors($form, $paymentMethod, $gatewayConfig, $configurationType); + }); + } + + public static function getExtendedTypes(): iterable + { + return [PaymentMethodType::class]; + } + + /** + * Returns the submitted payment method only when it carries a gateway config with a factory + * name; the listener pairs this with `resolveConfigurationType()` to decide whether the + * payment method is one of ours. + */ + private function resolvePayPlugPaymentMethod(mixed $data): ?PaymentMethodInterface + { + if (!$data instanceof PaymentMethodInterface) { + return null; + } + + $gatewayConfig = $data->getGatewayConfig(); + + $isPayPlugPaymentMethod = $gatewayConfig instanceof GatewayConfigInterface && + null !== $gatewayConfig->getFactoryName(); + + return $isPayPlugPaymentMethod ? $data : null; + } + + /** + * Resolving to non-null is what makes a submitted payment method "one of ours": the decision + * rests on the gateway configuration form's own type rather than on a hardcoded factory-name + * list, since every PayPlug gateway configuration type extends `AbstractGatewayConfigurationType` + * and nothing else does, so the test stays exact when an eighth gateway is added. + * + * `GatewayConfigType` only adds the `config` child when the factory has a registered + * configuration type, hence the `has()` guards. + * + * The per-gateway currency policy is read back off the configuration type instance rather than + * duplicated into a registry here: it already lives one-class-per-gateway, and only the CB type + * narrows it (to Integrated Payment). Form types are stateless services, so calling their + * public hooks is safe. + */ + private function resolveConfigurationType(FormInterface $form): ?AbstractGatewayConfigurationType + { + if (!$form->has('gatewayConfig') || !$form->get('gatewayConfig')->has('config')) { + return null; + } + + $configurationType = $form->get('gatewayConfig')->get('config')->getConfig()->getType()->getInnerType(); + + return $configurationType instanceof AbstractGatewayConfigurationType ? $configurationType : null; + } + + /** + * PRE-3629: render the channels another enabled gateway of the same factory already holds as + * unselectable, instead of letting the admin pick one only to be refused on submit. + * + * The field belongs to Sylius' own `CoreBundle` type extension, so it cannot be configured from + * our `buildForm()` — extension ordering between the two is not guaranteed. POST_SET_DATA on the + * root form is the first safe point: `GatewayConfigType` adds its `config` child during its own + * PRE_SET_DATA, so at root PRE_SET_DATA `resolveConfigurationType()` would still find nothing, + * whereas by root POST_SET_DATA every child is built and populated. Replacing a child there is + * also still data-mapped (`Form::add()` only skips mapping while `lockSetData` is on, i.e. + * during PRE_SET_DATA). + * + * A resolved form config cannot be mutated, hence the replace-with-copied-options approach. + * `data` is `setDefined` rather than defaulted on `FormType`, so it is absent from the copied + * options unless someone deliberately set it — in which case carrying it over is the correct + * thing to do anyway. + */ + private function disableClaimedChannelChoices( + FormInterface $form, + PaymentMethodInterface $paymentMethod, + string $factoryName, + ): void { + if (!$form->has('channels')) { + return; + } + + $claimedChannels = $this->conflictChecker->findClaimedChannels($paymentMethod, $factoryName); + + if ([] === $claimedChannels) { + return; + } + + $form->add('channels', ChannelChoiceType::class, \array_merge( + $form->get('channels')->getConfig()->getOptions(), + [ + 'choice_attr' => fn (ChannelInterface $channel): array => $this->channelChoiceAttributes( + $channel, + $claimedChannels, + ), + ], + )); + } + + /** + * A channel the payment method being edited already holds is never reported as claimed (see + * `GatewayChannelConflictChecker::findClaimedChannels()`), and that is deliberate: browsers do + * not submit disabled checkboxes, so disabling a *checked* one would silently drop the channel + * on save. Those are left selectable and reported by the POST_SUBMIT rule instead. + * + * `disabled => true` is rendered as `disabled="disabled"` by form_div_layout's `attributes` + * block, which every Sylius form theme inherits. That block also pipes `title` through `|trans` + * against the *field's* translation domain — harmless here, since re-translating an already + * translated sentence finds no catalogue entry and Symfony hands the string straight back. The + * message can't be left as a bare key for Twig to translate instead: `attr_translation_parameters` + * is set per field, not per choice, and each channel has to name a different payment method. + * + * @param array $claimedChannels channel code => claiming method + * + * @return array + */ + private function channelChoiceAttributes(ChannelInterface $channel, array $claimedChannels): array + { + $channelCode = (string) $channel->getCode(); + $claimedBy = $claimedChannels[$channelCode] ?? null; + + if (null === $claimedBy) { + return []; + } + + return [ + 'disabled' => true, + 'title' => $this->translator->trans( + 'payplug_sylius_payplug_plugin.form.gateway_channel_conflict', + [ + '%channel%' => $channelCode, + '%payment_method%' => (string) $claimedBy->getName(), + ], + ), + ]; + } + + private function addChannelConflictErrors( + FormInterface $form, + PaymentMethodInterface $paymentMethod, + string $factoryName, + ): void { + if (!$form->has('channels')) { + return; + } + + $seenMessages = []; + foreach ($this->conflictChecker->findConflicts($paymentMethod, $factoryName) as $conflict) { + $message = $this->translator->trans( + 'payplug_sylius_payplug_plugin.form.gateway_channel_conflict', + [ + '%channel%' => (string) $conflict['channel']->getCode(), + '%payment_method%' => (string) $conflict['paymentMethod']->getName(), + ], + ); + + if (\in_array($message, $seenMessages, true)) { + continue; + } + + $seenMessages[] = $message; + $form->get('channels')->addError(new FormError($message)); + } + } + + private function addBaseCurrencyErrors( + FormInterface $form, + PaymentMethodInterface $paymentMethod, + GatewayConfigInterface $gatewayConfig, + AbstractGatewayConfigurationType $configurationType, + ): void { + if ( + !$form->has('channels') || + !$configurationType->shouldValidateBaseCurrency($gatewayConfig->getConfig()) + ) { + return; + } + + $seenMessages = []; + foreach ($paymentMethod->getChannels() as $channel) { + if (!$channel instanceof ChannelInterface) { + continue; + } + + $baseCurrency = $channel->getBaseCurrency(); + + if (null === $baseCurrency || $configurationType->getBaseCurrencyCode() === $baseCurrency->getCode()) { + continue; + } + + $message = $configurationType->baseCurrencyViolationMessage($channel); + + if (\in_array($message, $seenMessages, true)) { + continue; + } + + $seenMessages[] = $message; + $form->get('channels')->addError(new FormError($message)); + } + } +} diff --git a/src/Gateway/Form/Type/AbstractGatewayConfigurationType.php b/src/Gateway/Form/Type/AbstractGatewayConfigurationType.php index 168ebfbb..867dbcab 100644 --- a/src/Gateway/Form/Type/AbstractGatewayConfigurationType.php +++ b/src/Gateway/Form/Type/AbstractGatewayConfigurationType.php @@ -4,19 +4,11 @@ namespace PayPlug\SyliusPayPlugPlugin\Gateway\Form\Type; -use Doctrine\Common\Collections\Collection; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; -use Sylius\Bundle\PayumBundle\Model\GatewayConfigInterface; use Sylius\Component\Core\Model\ChannelInterface; -use Sylius\Component\Resource\Repository\RepositoryInterface; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\Form\FormError; -use Symfony\Component\Form\FormEvent; -use Symfony\Component\Form\FormEvents; -use Symfony\Component\Form\FormInterface; -use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Contracts\Translation\TranslatorInterface; class AbstractGatewayConfigurationType extends AbstractType @@ -25,14 +17,10 @@ class AbstractGatewayConfigurationType extends AbstractType protected string $gatewayFactoryTitle = ''; - protected string $gatewayFactoryName = ''; - protected string $gatewayBaseCurrencyCode = PayPlugGatewayFactory::BASE_CURRENCY_CODE; public function __construct( protected TranslatorInterface $translator, - private RepositoryInterface $gatewayConfigRepository, - protected RequestStack $requestStack, ) { } @@ -56,84 +44,22 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'mapped' => false, 'required' => false, ]) - ->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event): void { - $this->checkCreationRequirements( - $this->gatewayFactoryTitle, - $this->gatewayFactoryName, - $event->getForm(), - ); - - /** @phpstan-ignore-next-line */ - $formChannels = $event->getForm()->getParent()->getParent()->get('channels'); - $dataFormChannels = $formChannels->getData(); - 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(); - if (null === $baseCurrency) { - continue; - } - $baseCurrencyCode = $baseCurrency->getCode(); - if ($this->gatewayBaseCurrencyCode !== $baseCurrencyCode) { - $message = $this->baseCurrencyViolationMessage($dataFormChannel); - $formChannels->get((string) $key)->addError(new FormError($message)); - if (!\in_array($message, $flashedMessages, true)) { - $flashedMessages[] = $message; - $this->requestStack->getSession()->getFlashBag()->add('error', $message); - } - } - } - }) ; } - private function canBeCreated(string $factoryName): bool - { - $alreadyExists = $this->gatewayConfigRepository->findOneBy(['factoryName' => $factoryName]); - - return !$alreadyExists instanceof GatewayConfigInterface; - } - - private function checkCreationRequirements( - string $factoryTitle, - string $factoryName, - FormInterface $form, - ): void { - /** @phpstan-ignore-next-line */ - $paymentMethod = $form->getParent()->getParent()->getData(); - - if (null !== $paymentMethod->getId()) { - return; - } - - if ($this->canBeCreated($factoryName)) { - return; - } - - $message = $this->translator->trans('payplug_sylius_payplug_plugin.form.only_one_gateway_allowed', ['%gateway_title%' => $factoryTitle]); - /* @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. + * Hook for subtypes to scope the base-currency-per-channel restriction enforced by + * PaymentMethodTypeExtension. * 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. + * @param array $gatewayConfig Mapped gateway configuration, as stored on GatewayConfig. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ - protected function shouldValidateBaseCurrency(array $rawFormData): bool + public function shouldValidateBaseCurrency(array $gatewayConfig): bool { return true; } @@ -145,7 +71,7 @@ protected function shouldValidateBaseCurrency(array $rawFormData): bool * * @see shouldValidateBaseCurrency() Companion hook scoping when this message is used. */ - protected function baseCurrencyViolationMessage(ChannelInterface $channel): string + public function baseCurrencyViolationMessage(ChannelInterface $channel): string { return $this->translator->trans( 'payplug_sylius_payplug_plugin.form.base_currency_not_euro', @@ -155,4 +81,9 @@ protected function baseCurrencyViolationMessage(ChannelInterface $channel): stri ], ); } + + public function getBaseCurrencyCode(): string + { + return $this->gatewayBaseCurrencyCode; + } } diff --git a/src/Gateway/Form/Type/AmericanExpressGatewayConfigurationType.php b/src/Gateway/Form/Type/AmericanExpressGatewayConfigurationType.php index 4387942b..7231686e 100644 --- a/src/Gateway/Form/Type/AmericanExpressGatewayConfigurationType.php +++ b/src/Gateway/Form/Type/AmericanExpressGatewayConfigurationType.php @@ -19,7 +19,5 @@ final class AmericanExpressGatewayConfigurationType extends AbstractGatewayConfi { protected string $gatewayFactoryTitle = AmericanExpressGatewayFactory::FACTORY_TITLE; - protected string $gatewayFactoryName = AmericanExpressGatewayFactory::FACTORY_NAME; - protected string $gatewayBaseCurrencyCode = AmericanExpressGatewayFactory::BASE_CURRENCY_CODE; } diff --git a/src/Gateway/Form/Type/ApplePayGatewayConfigurationType.php b/src/Gateway/Form/Type/ApplePayGatewayConfigurationType.php index 1f3da3ec..2c831a0d 100644 --- a/src/Gateway/Form/Type/ApplePayGatewayConfigurationType.php +++ b/src/Gateway/Form/Type/ApplePayGatewayConfigurationType.php @@ -19,7 +19,5 @@ final class ApplePayGatewayConfigurationType extends AbstractGatewayConfiguratio { protected string $gatewayFactoryTitle = ApplePayGatewayFactory::FACTORY_TITLE; - protected string $gatewayFactoryName = ApplePayGatewayFactory::FACTORY_NAME; - protected string $gatewayBaseCurrencyCode = ApplePayGatewayFactory::BASE_CURRENCY_CODE; } diff --git a/src/Gateway/Form/Type/BancontactGatewayConfigurationType.php b/src/Gateway/Form/Type/BancontactGatewayConfigurationType.php index 25eac35b..c7bf6803 100644 --- a/src/Gateway/Form/Type/BancontactGatewayConfigurationType.php +++ b/src/Gateway/Form/Type/BancontactGatewayConfigurationType.php @@ -19,7 +19,5 @@ final class BancontactGatewayConfigurationType extends AbstractGatewayConfigurat { protected string $gatewayFactoryTitle = BancontactGatewayFactory::FACTORY_TITLE; - protected string $gatewayFactoryName = BancontactGatewayFactory::FACTORY_NAME; - protected string $gatewayBaseCurrencyCode = BancontactGatewayFactory::BASE_CURRENCY_CODE; } diff --git a/src/Gateway/Form/Type/OneyGatewayConfigurationType.php b/src/Gateway/Form/Type/OneyGatewayConfigurationType.php index fd548251..b446b709 100644 --- a/src/Gateway/Form/Type/OneyGatewayConfigurationType.php +++ b/src/Gateway/Form/Type/OneyGatewayConfigurationType.php @@ -19,7 +19,5 @@ final class OneyGatewayConfigurationType extends AbstractGatewayConfigurationTyp { protected string $gatewayFactoryTitle = OneyGatewayFactory::FACTORY_TITLE; - protected string $gatewayFactoryName = OneyGatewayFactory::FACTORY_NAME; - protected string $gatewayBaseCurrencyCode = OneyGatewayFactory::BASE_CURRENCY_CODE; } diff --git a/src/Gateway/Form/Type/PayPlugGatewayConfigurationType.php b/src/Gateway/Form/Type/PayPlugGatewayConfigurationType.php index 1344b3d1..309e5478 100644 --- a/src/Gateway/Form/Type/PayPlugGatewayConfigurationType.php +++ b/src/Gateway/Form/Type/PayPlugGatewayConfigurationType.php @@ -20,19 +20,23 @@ final class PayPlugGatewayConfigurationType extends AbstractGatewayConfiguration { protected string $gatewayFactoryTitle = PayPlugGatewayFactory::FACTORY_TITLE; - 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 + * The mode is read back through `resolveDisplayMode()` rather than off a display-mode key: + * `DISPLAY_MODE_FIELD` is an unmapped admin form field and never reaches the persisted config, + * which instead carries the two `INTEGRATED_PAYMENT`/`HOSTED_FIELDS` booleans written by + * `resolveDisplayModeFlags()`. Going through the canonical reader also inherits its + * hosted-fields-wins tie-break when both flags are somehow true. + * + * @param array $gatewayConfig Mapped gateway configuration, as stored on GatewayConfig. */ - protected function shouldValidateBaseCurrency(array $rawFormData): bool + public function shouldValidateBaseCurrency(array $gatewayConfig): bool { - return PayPlugGatewayFactory::DISPLAY_MODE_INTEGRATED_PAYMENT === ($rawFormData[PayPlugGatewayFactory::DISPLAY_MODE_FIELD] ?? null); + return PayPlugGatewayFactory::DISPLAY_MODE_INTEGRATED_PAYMENT === PayPlugGatewayFactory::resolveDisplayMode($gatewayConfig); } /** @@ -40,7 +44,7 @@ protected function shouldValidateBaseCurrency(array $rawFormData): bool * (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 + public 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/ScalapayGatewayConfigurationType.php b/src/Gateway/Form/Type/ScalapayGatewayConfigurationType.php index ec89fb96..d240aa04 100644 --- a/src/Gateway/Form/Type/ScalapayGatewayConfigurationType.php +++ b/src/Gateway/Form/Type/ScalapayGatewayConfigurationType.php @@ -19,7 +19,5 @@ final class ScalapayGatewayConfigurationType extends AbstractGatewayConfiguratio { protected string $gatewayFactoryTitle = ScalapayGatewayFactory::FACTORY_TITLE; - protected string $gatewayFactoryName = ScalapayGatewayFactory::FACTORY_NAME; - protected string $gatewayBaseCurrencyCode = ScalapayGatewayFactory::BASE_CURRENCY_CODE; } diff --git a/src/Gateway/Form/Type/WeroGatewayConfigurationType.php b/src/Gateway/Form/Type/WeroGatewayConfigurationType.php index 39d1a035..79055931 100644 --- a/src/Gateway/Form/Type/WeroGatewayConfigurationType.php +++ b/src/Gateway/Form/Type/WeroGatewayConfigurationType.php @@ -19,7 +19,5 @@ final class WeroGatewayConfigurationType extends AbstractGatewayConfigurationTyp { protected string $gatewayFactoryTitle = WeroGatewayFactory::FACTORY_TITLE; - protected string $gatewayFactoryName = WeroGatewayFactory::FACTORY_NAME; - protected string $gatewayBaseCurrencyCode = WeroGatewayFactory::BASE_CURRENCY_CODE; } diff --git a/src/Gateway/Validator/Constraints/HasNoGatewayChannelConflict.php b/src/Gateway/Validator/Constraints/HasNoGatewayChannelConflict.php new file mode 100644 index 00000000..6f7ddc86 --- /dev/null +++ b/src/Gateway/Validator/Constraints/HasNoGatewayChannelConflict.php @@ -0,0 +1,29 @@ +resolveFactoryName($value); + + if (null === $factoryName) { + return; + } + + foreach ($this->conflictChecker->findConflicts($value, $factoryName) as $conflict) { + $this->context->buildViolation($constraint->message) + ->setParameter('%channel%', (string) $conflict['channel']->getCode()) + ->setParameter('%payment_method%', (string) $conflict['paymentMethod']->getName()) + ->atPath('channels') + ->addViolation(); + } + } + + /** + * Null for anything this constraint has no opinion on — a method with no gateway config, or a + * disabled one. `findConflicts()` bails on a disabled subject anyway; short-circuiting here + * saves the query. + */ + private function resolveFactoryName(PaymentMethodInterface $paymentMethod): ?string + { + if (!$paymentMethod->isEnabled()) { + return null; + } + + $gatewayConfig = $paymentMethod->getGatewayConfig(); + + if (!$gatewayConfig instanceof GatewayConfigInterface) { + return null; + } + + return $gatewayConfig->getFactoryName(); + } +} diff --git a/src/Gateway/Validator/Constraints/IsOneyEnabledValidator.php b/src/Gateway/Validator/Constraints/IsOneyEnabledValidator.php index 0d889031..7237b12e 100644 --- a/src/Gateway/Validator/Constraints/IsOneyEnabledValidator.php +++ b/src/Gateway/Validator/Constraints/IsOneyEnabledValidator.php @@ -5,7 +5,7 @@ namespace PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints; use Payplug\Exception\UnauthorizedException; -use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactory; +use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface; use PayPlug\SyliusPayPlugPlugin\Checker\OneyChecker; use PayPlug\SyliusPayPlugPlugin\Exception\GatewayConfigurationException; use PayPlug\SyliusPayPlugPlugin\Gateway\OneyGatewayFactory; @@ -18,7 +18,7 @@ final class IsOneyEnabledValidator extends ConstraintValidator { - public function __construct(private PayPlugApiClientFactory $apiClientFactory) + public function __construct(private PayPlugApiClientFactoryInterface $apiClientFactory) { } diff --git a/src/Gateway/Validator/Constraints/PayplugPermissionValidator.php b/src/Gateway/Validator/Constraints/PayplugPermissionValidator.php index 328b47e8..eaf8f752 100644 --- a/src/Gateway/Validator/Constraints/PayplugPermissionValidator.php +++ b/src/Gateway/Validator/Constraints/PayplugPermissionValidator.php @@ -5,7 +5,7 @@ namespace PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints; use Payplug\Exception\UnauthorizedException; -use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactory; +use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface; use Sylius\Component\Core\Model\PaymentMethodInterface; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; @@ -13,7 +13,7 @@ final class PayplugPermissionValidator extends ConstraintValidator { - public function __construct(private PayPlugApiClientFactory $apiClientFactory) + public function __construct(private PayPlugApiClientFactoryInterface $apiClientFactory) { } diff --git a/src/Handler/HostedFieldsWebhookNotificationHandler.php b/src/Handler/HostedFieldsWebhookNotificationHandler.php index 659231ff..02375e71 100644 --- a/src/Handler/HostedFieldsWebhookNotificationHandler.php +++ b/src/Handler/HostedFieldsWebhookNotificationHandler.php @@ -9,7 +9,7 @@ use PayPlug\SyliusPayPlugPlugin\Upc\PayplugCardPersister; use PayPlug\SyliusPayPlugPlugin\Upc\RefundDetailsLockKey; use PayPlug\SyliusPayPlugPlugin\Upc\ResourceIdentifier; -use PayplugUnifiedCore\Contracts\IConfigurationRepository; +use PayPlug\SyliusPayPlugPlugin\Upc\ScopedConfigurationRepositoryInterface; use PayplugUnifiedCore\Contracts\ILock; use PayplugUnifiedCore\Contracts\IOrderStateMutator; use PayplugUnifiedCore\Contracts\IPaymentRepository; @@ -19,6 +19,7 @@ use PayplugUnifiedCore\Utilities\Helpers\WebhookNotificationHelper; use Psr\Log\LoggerInterface; use Sylius\Component\Core\Model\PaymentInterface; +use Sylius\Component\Payment\Model\PaymentMethodInterface; /** * Verifies and applies a Unified API (Hosted Fields) webhook notification against a Payment @@ -56,7 +57,7 @@ class HostedFieldsWebhookNotificationHandler public function __construct( private IPaymentRepository $paymentRepository, private IOrderStateMutator $orderStateMutator, - private IConfigurationRepository $configurationRepository, + private ScopedConfigurationRepositoryInterface $configurationRepository, private ILock $lock, private LoggerInterface $logger, private PayplugCardPersister $cardPersister, @@ -72,7 +73,15 @@ public function __construct( */ public function treat(PaymentInterface $payment, string $rawBody, array $headers): void { - $expectedHeader = $this->configurationRepository->get(self::CONFIG_KEY_WEBHOOK_AUTHORIZATION_HEADER) ?? ''; + // The shared secret the notification is verified against is stored per gateway config, so + // it has to be read from the account this payment was created on — another channel's + // header would reject a legitimate webhook (or, worse, accept a foreign one). + $method = $payment->getMethod(); + if (!$method instanceof PaymentMethodInterface) { + throw new \LogicException('The payment has no payment method, so no webhook secret can be resolved for it.'); + } + + $expectedHeader = $this->configurationRepository->forPaymentMethod($method)->get(self::CONFIG_KEY_WEBHOOK_AUTHORIZATION_HEADER) ?? ''; $operationData = WebhookNotificationHelper::parse($headers, $rawBody, $expectedHeader); if (PaymentOutcome::THREE_DS_PENDING === $operationData->outcome) { diff --git a/src/PaymentProcessing/CaptureAuthorizedPaymentProcessor.php b/src/PaymentProcessing/CaptureAuthorizedPaymentProcessor.php index 4b16ee5b..b876a361 100644 --- a/src/PaymentProcessing/CaptureAuthorizedPaymentProcessor.php +++ b/src/PaymentProcessing/CaptureAuthorizedPaymentProcessor.php @@ -4,7 +4,7 @@ namespace PayPlug\SyliusPayPlugPlugin\PaymentProcessing; -use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactory; +use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface; use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Handler\PaymentNotificationHandler; @@ -18,7 +18,7 @@ final class CaptureAuthorizedPaymentProcessor { public function __construct( - private PayPlugApiClientFactory $apiClientFactory, + private PayPlugApiClientFactoryInterface $apiClientFactory, private PaymentNotificationHandler $paymentNotificationHandler, ) { } diff --git a/src/Provider/OneySimulation/CachedSimulationDataProvider.php b/src/Provider/OneySimulation/CachedSimulationDataProvider.php index c0b5f4ca..95935cf6 100644 --- a/src/Provider/OneySimulation/CachedSimulationDataProvider.php +++ b/src/Provider/OneySimulation/CachedSimulationDataProvider.php @@ -25,11 +25,14 @@ public function __construct( public function getForCart(OrderInterface $cart): array { $country = \explode('_', $cart->getLocaleCode() ?? 'fr_FR')[1]; + // The channel is part of the key because `fees_for` is resolved from the Oney gateway + // config serving the *current* channel (PRE-3440), and each channel may run its own. $cacheKey = \sprintf( - 'oney_simulation_%s_%s_%s', + 'oney_simulation_%s_%s_%s_%s', $country, $cart->getTotal(), $this->oneySupportedPaymentChoiceProvider->getFeesFor(), + $cart->getChannel()?->getCode() ?? 'no_channel', ); return $this->cache->get($cacheKey, function (ItemInterface $item) use ($cart): array { diff --git a/src/Provider/OneySupportedPaymentChoiceProvider.php b/src/Provider/OneySupportedPaymentChoiceProvider.php index 9635b7b3..10b62938 100644 --- a/src/Provider/OneySupportedPaymentChoiceProvider.php +++ b/src/Provider/OneySupportedPaymentChoiceProvider.php @@ -6,13 +6,18 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\OneyGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Repository\PaymentMethodRepositoryInterface; +use Sylius\Component\Channel\Context\ChannelContextInterface; +use Sylius\Component\Channel\Context\ChannelNotFoundException; +use Sylius\Component\Core\Model\ChannelInterface; use Sylius\Component\Core\Model\PaymentMethodInterface; use Sylius\Component\Payment\Model\GatewayConfigInterface; class OneySupportedPaymentChoiceProvider { - public function __construct(private PaymentMethodRepositoryInterface $paymentMethodRepository) - { + public function __construct( + private PaymentMethodRepositoryInterface $paymentMethodRepository, + private ChannelContextInterface $channelContext, + ) { } public function getSupportedPaymentChoices(bool $useOneyPrefix = false): array @@ -37,9 +42,14 @@ public function getFeesFor(): string return $this->getPaymentGatewayConfig()['fees_for'] ?? ''; } + /** + * Scoped to the current channel: `fees_for` drives which instalment choices are offered to the + * shopper, and since PRE-3628 each channel may run its own Oney gateway config with its own + * value. A factory-name-only lookup would show one channel's instalment plans on another's. + */ private function getPaymentGatewayConfig(): array { - $paymentMethod = $this->paymentMethodRepository->findOneByGatewayName(OneyGatewayFactory::FACTORY_NAME); + $paymentMethod = $this->resolveOneyPaymentMethod(); if (!$paymentMethod instanceof PaymentMethodInterface) { return []; @@ -50,4 +60,27 @@ private function getPaymentGatewayConfig(): array return $gateway->getConfig(); } + + /** + * `getFeesFor()` is called outside the try/catch in `getSupportedPaymentChoices()`, so this + * absorbs the no-channel case rather than letting it escape — there being no current channel is + * an ordinary state (CLI, an admin request), not an error. + */ + private function resolveOneyPaymentMethod(): ?PaymentMethodInterface + { + try { + $channel = $this->channelContext->getChannel(); + } catch (ChannelNotFoundException) { + return null; + } + + if (!$channel instanceof ChannelInterface) { + return null; + } + + return $this->paymentMethodRepository->findOneEnabledByGatewayNameAndChannel( + OneyGatewayFactory::FACTORY_NAME, + $channel, + ); + } } diff --git a/src/Provider/Payment/ApplePayPaymentProvider.php b/src/Provider/Payment/ApplePayPaymentProvider.php index 96592302..6b886430 100644 --- a/src/Provider/Payment/ApplePayPaymentProvider.php +++ b/src/Provider/Payment/ApplePayPaymentProvider.php @@ -49,9 +49,7 @@ public function __construct( public function provide(Request $request, OrderInterface $order): PaymentInterface { - $paymentMethod = $this->paymentMethodRepository->findOneByGatewayName(ApplePayGatewayFactory::FACTORY_NAME); - - if (!$paymentMethod instanceof PaymentMethodInterface || !$paymentMethod->isEnabled()) { + if (!$this->resolveApplePayPaymentMethod($order) instanceof PaymentMethodInterface) { throw new LogicException('Apple Pay is not enabled'); } @@ -206,13 +204,31 @@ private function initApplePaySyliusPaymentState(OrderInterface $order): PaymentI $payment = $this->getPayment($order); - $paymentMethod = $this->paymentMethodRepository->findOneByGatewayName(ApplePayGatewayFactory::FACTORY_NAME); - $payment->setMethod($paymentMethod); + $payment->setMethod($this->resolveApplePayPaymentMethod($order)); $order->addPayment($payment); return $payment; } + /** + * The enabled Apple Pay method serving the order's own channel. Since PRE-3628 several Apple + * Pay configs may coexist, one per channel and each connected to a different PayPlug account, + * so resolving by factory name alone would attach an arbitrary channel's method to the payment. + */ + private function resolveApplePayPaymentMethod(OrderInterface $order): ?PaymentMethodInterface + { + $channel = $order->getChannel(); + + if (!$channel instanceof ChannelInterface) { + return null; + } + + return $this->paymentMethodRepository->findOneEnabledByGatewayNameAndChannel( + ApplePayGatewayFactory::FACTORY_NAME, + $channel, + ); + } + private function getPayment(OrderInterface $order): PaymentInterface { $lastPayment = $order->getLastPayment(); diff --git a/src/Provider/SupportedMethodsProvider.php b/src/Provider/SupportedMethodsProvider.php index c9c0d4a4..f285683b 100644 --- a/src/Provider/SupportedMethodsProvider.php +++ b/src/Provider/SupportedMethodsProvider.php @@ -46,8 +46,9 @@ public function provide( ?string $billingCountryCode = null, ): array { $activeCurrencyCode = $paymentCurrencyCode ?? $this->currencyContext->getCurrencyCode(); - $authorizedCurrencies = null; - $allowedCountries = null; + + /** @var array> $accounts */ + $accounts = []; foreach ($supportedMethods as $key => $paymentMethod) { Assert::isInstanceOf($paymentMethod, PaymentMethodInterface::class); @@ -59,8 +60,11 @@ public function provide( continue; } - $authorizedCurrencies ??= $this->resolveAuthorizedCurrencies($factoryName); - $allowedCountries ??= $this->resolveAllowedCountries($factoryName); + $memoKey = $this->accountMemoKey($gatewayConfig); + $account = $accounts[$memoKey] ??= $this->clientFactory->createForPaymentMethod($paymentMethod)->getAccount(); + + $authorizedCurrencies = $this->resolveAuthorizedCurrencies($account, $gatewayConfig); + $allowedCountries = $this->resolveAllowedCountries($account, $gatewayConfig); if ($billingCountryCode !== null && $allowedCountries !== [] && !\in_array($billingCountryCode, $allowedCountries, true)) { unset($supportedMethods[$key]); @@ -167,26 +171,48 @@ private function readConfiguredAmounts(array $config): array } /** - * @return array + * Two payment methods of the same factory can be configured on different PayPlug accounts, so + * the `/account` payload is memoized per gateway config rather than once per call — sharing one + * lookup across the loop let the first method's account govern every later one. The persisted + * id is the key; object identity covers a config that has not been flushed yet, whose null id + * would otherwise collide with every other unsaved one. */ - private function resolveAuthorizedCurrencies(string $factoryName): array + private function accountMemoKey(GatewayConfigInterface $gatewayConfig): string { - $account = $this->clientFactory->create($factoryName)->getAccount(); - $underscorePos = strpos($factoryName, '_'); - $paymentMethodKey = false !== $underscorePos ? substr($factoryName, $underscorePos + 1) : null; + $id = $gatewayConfig->getId(); + + if (\is_int($id) || (\is_string($id) && '' !== $id)) { + return 'config:' . $id; + } - return $this->amountRangeResolver->resolve($account, $paymentMethodKey); + return 'object:' . spl_object_id($gatewayConfig); } - private function resolveAllowedCountries(string $factoryName): array + /** + * Both resolvers below read the factory name off the gateway config the $account was fetched + * for, rather than off provide()'s $factoryName argument. The loop guard above makes the two + * equal today, but keeping the account payload and the key used to index it sourced from the + * same config is what stops the pair drifting apart if that guard is ever relaxed. + * + * @param array $account + * + * @return array + */ + private function resolveAuthorizedCurrencies(array $account, GatewayConfigInterface $gatewayConfig): array { - $underscorePos = strpos($factoryName, '_'); - if ($underscorePos === false) { + return $this->amountRangeResolver->resolve($account, $this->paymentMethodKey($gatewayConfig)); + } + + /** + * @param array $account + */ + private function resolveAllowedCountries(array $account, GatewayConfigInterface $gatewayConfig): array + { + $pmKey = $this->paymentMethodKey($gatewayConfig); + if (null === $pmKey) { return []; } - $account = $this->clientFactory->create($factoryName)->getAccount(); - $pmKey = substr($factoryName, $underscorePos + 1); $paymentMethods = $account['payment_methods'] ?? []; Assert::isArray($paymentMethods); $pmData = $paymentMethods[$pmKey] ?? []; @@ -201,4 +227,20 @@ private function resolveAllowedCountries(string $factoryName): array return $allowedCountries; } + + /** + * The `/account` payload keys each PPRO method under the factory name's suffix — `payplug_oney` + * is advertised as `oney`. A suffix-less factory name (`payplug`) is the card gateway, which + * has no such sub-payload. + */ + private function paymentMethodKey(GatewayConfigInterface $gatewayConfig): ?string + { + // provide()'s loop guard has already matched this config against a non-null factory name, + // so the null coalesce is unreachable from there; it keeps the helper total for any later + // caller, and an empty name carries no suffix anyway. + $factoryName = $gatewayConfig->getFactoryName() ?? ''; + $underscorePos = strpos($factoryName, '_'); + + return false !== $underscorePos ? substr($factoryName, $underscorePos + 1) : null; + } } diff --git a/src/Repository/PaymentMethodRepository.php b/src/Repository/PaymentMethodRepository.php index d2e12daa..70d10524 100644 --- a/src/Repository/PaymentMethodRepository.php +++ b/src/Repository/PaymentMethodRepository.php @@ -4,20 +4,79 @@ namespace PayPlug\SyliusPayPlugPlugin\Repository; +use Doctrine\ORM\QueryBuilder; use Sylius\Bundle\CoreBundle\Doctrine\ORM\PaymentMethodRepository as BasePaymentMethodRepository; +use Sylius\Component\Core\Model\ChannelInterface; use Sylius\Component\Core\Model\PaymentMethodInterface; +use Webmozart\Assert\Assert; final class PaymentMethodRepository extends BasePaymentMethodRepository implements PaymentMethodRepositoryInterface { public function findOneByGatewayName(string $gatewayFactoryName): ?PaymentMethodInterface + { + $paymentMethod = $this->createGatewayFactoryQueryBuilder($gatewayFactoryName) + ->getQuery() + ->setMaxResults(1) + ->getOneOrNullResult() + ; + + Assert::nullOrIsInstanceOf($paymentMethod, PaymentMethodInterface::class); + + return $paymentMethod; + } + + public function findOneEnabledByGatewayNameAndChannel( + string $gatewayFactoryName, + ChannelInterface $channel, + ): ?PaymentMethodInterface { + $paymentMethod = $this->createGatewayFactoryQueryBuilder($gatewayFactoryName) + ->innerJoin('o.channels', 'channel') + ->andWhere('channel = :channel') + ->andWhere('o.enabled = true') + ->setParameter('channel', $channel) + // PRE-3628 admits at most one enabled config per channel per factory, so this orders a + // set of one. It only matters if some non-form write path ever breaks that invariant, + // where picking deterministically beats picking arbitrarily. + ->addOrderBy('o.position', 'ASC') + ->addOrderBy('o.id', 'ASC') + ->getQuery() + ->setMaxResults(1) + ->getOneOrNullResult() + ; + + Assert::nullOrIsInstanceOf($paymentMethod, PaymentMethodInterface::class); + + return $paymentMethod; + } + + public function findEnabledByGatewayName(string $gatewayFactoryName): array + { + $paymentMethods = $this->createGatewayFactoryQueryBuilder($gatewayFactoryName) + ->leftJoin('o.channels', 'channel') + ->addSelect('channel') + ->andWhere('o.enabled = true') + ->getQuery() + ->getResult() + ; + + Assert::isList($paymentMethods); + Assert::allIsInstanceOf($paymentMethods, PaymentMethodInterface::class); + + return $paymentMethods; + } + + /** + * The shared head of every lookup here: payment methods whose gateway config names $factoryName. + * + * Each caller narrows it further — by channel, by enabled, or not at all — so the join, the + * predicate and the bound parameter live in one place rather than being repeated three times. + */ + private function createGatewayFactoryQueryBuilder(string $gatewayFactoryName): QueryBuilder { return $this->createQueryBuilder('o') ->innerJoin('o.gatewayConfig', 'gatewayConfig') - ->where('gatewayConfig.factoryName = :gatewayFactoryName') + ->andWhere('gatewayConfig.factoryName = :gatewayFactoryName') ->setParameter('gatewayFactoryName', $gatewayFactoryName) - ->getQuery() - ->setMaxResults(1) - ->getSingleResult() ; } } diff --git a/src/Repository/PaymentMethodRepositoryInterface.php b/src/Repository/PaymentMethodRepositoryInterface.php index 54f8c2e9..259a8908 100644 --- a/src/Repository/PaymentMethodRepositoryInterface.php +++ b/src/Repository/PaymentMethodRepositoryInterface.php @@ -4,10 +4,34 @@ namespace PayPlug\SyliusPayPlugPlugin\Repository; +use Sylius\Component\Core\Model\ChannelInterface; use Sylius\Component\Core\Model\PaymentMethodInterface; use Sylius\Component\Core\Repository\PaymentMethodRepositoryInterface as BasePaymentMethodRepositoryInterface; interface PaymentMethodRepositoryInterface extends BasePaymentMethodRepositoryInterface { + /** + * @deprecated since PRE-3440. Several gateway configs may share a factory name — one per + * channel — so this returns an arbitrary one of them. Use + * {@see self::findOneEnabledByGatewayNameAndChannel()} wherever a channel is in + * scope. + */ public function findOneByGatewayName(string $gatewayFactoryName): ?PaymentMethodInterface; + + /** + * The enabled payment method of that factory serving $channel, or null if the channel has none. + * + * This is the channel-safe replacement for {@see self::findOneByGatewayName()}: since PRE-3628 + * a merchant may run several configs of one factory, each connected to a different PayPlug + * account, and a name-only lookup picks between them arbitrarily. + */ + public function findOneEnabledByGatewayNameAndChannel( + string $gatewayFactoryName, + ChannelInterface $channel, + ): ?PaymentMethodInterface; + + /** + * @return list + */ + public function findEnabledByGatewayName(string $gatewayFactoryName): array; } diff --git a/src/Resolver/PaymentStateResolver.php b/src/Resolver/PaymentStateResolver.php index 1c8de161..9d749928 100644 --- a/src/Resolver/PaymentStateResolver.php +++ b/src/Resolver/PaymentStateResolver.php @@ -7,7 +7,7 @@ use Doctrine\ORM\EntityManagerInterface; use Payplug\Resource\Payment; use Payplug\Resource\PaymentAuthorization; -use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactory; +use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; use Sylius\Abstraction\StateMachine\StateMachineInterface; use Sylius\Component\Core\Model\PaymentInterface; @@ -19,7 +19,7 @@ final class PaymentStateResolver implements PaymentStateResolverInterface { public function __construct( private StateMachineInterface $stateMachine, - private PayPlugApiClientFactory $payPlugApiClientFactory, + private PayPlugApiClientFactoryInterface $payPlugApiClientFactory, private EntityManagerInterface $paymentEntityManager, ) { } diff --git a/src/Twig/OneyExtension.php b/src/Twig/OneyExtension.php index e7952400..53513245 100644 --- a/src/Twig/OneyExtension.php +++ b/src/Twig/OneyExtension.php @@ -6,17 +6,17 @@ use PayPlug\SyliusPayPlugPlugin\Checker\OneyChecker; use PayPlug\SyliusPayPlugPlugin\Gateway\OneyGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\Repository\PaymentMethodRepositoryInterface; use Sylius\Component\Channel\Context\ChannelContextInterface; -use Sylius\Component\Core\Model\PaymentMethod; -use Sylius\Component\Resource\Repository\RepositoryInterface; +use Sylius\Component\Core\Model\ChannelInterface; +use Sylius\Component\Core\Model\PaymentMethodInterface; use Twig\Extension\AbstractExtension; use Twig\TwigFunction; final class OneyExtension extends AbstractExtension { public function __construct( - private RepositoryInterface $gatewayConfigRepository, - private RepositoryInterface $paymentMethodRepository, + private PaymentMethodRepositoryInterface $paymentMethodRepository, private ChannelContextInterface $channelContext, private OneyChecker $oneyChecker, ) { @@ -29,22 +29,26 @@ public function getFunctions(): array ]; } + /** + * Resolved in one channel-scoped query rather than "find any Oney gateway config, then check + * it happens to serve this channel". Since PRE-3628 a merchant may run one Oney config per + * channel; the old shape picked an arbitrary one and then usually rejected it, hiding Oney on + * every channel but whichever the database returned first. + */ public function isOneyEnabled(): bool { - /** @var \Sylius\Bundle\PayumBundle\Model\GatewayConfig|null $gateway */ - $gateway = $this->gatewayConfigRepository->findOneBy(['factoryName' => OneyGatewayFactory::FACTORY_NAME]); - if (null === $gateway) { - return false; - } + $channel = $this->channelContext->getChannel(); - /** @var PaymentMethod|null $paymentMethod */ - $paymentMethod = $this->paymentMethodRepository->findOneBy(['gatewayConfig' => $gateway]); - if (null === $paymentMethod || false === $paymentMethod->isEnabled()) { + if (!$channel instanceof ChannelInterface) { return false; } - $currentChannel = $this->channelContext->getChannel(); - if (!$paymentMethod->getChannels()->contains($currentChannel)) { + $paymentMethod = $this->paymentMethodRepository->findOneEnabledByGatewayNameAndChannel( + OneyGatewayFactory::FACTORY_NAME, + $channel, + ); + + if (!$paymentMethod instanceof PaymentMethodInterface) { return false; } diff --git a/src/Upc/OperationStatusFetcherInterface.php b/src/Upc/OperationStatusFetcherInterface.php index 68f3e5a5..d482d673 100644 --- a/src/Upc/OperationStatusFetcherInterface.php +++ b/src/Upc/OperationStatusFetcherInterface.php @@ -5,13 +5,19 @@ namespace PayPlug\SyliusPayPlugPlugin\Upc; use PayplugUnifiedCore\Exceptions\ApiException; +use Sylius\Component\Payment\Model\PaymentMethodInterface; interface OperationStatusFetcherInterface { /** + * $method is what says which PayPlug account the operation belongs to. An operation id alone + * is not enough: since PRE-3628 two CB payment methods on different channels may be configured + * on different accounts, and fetching with the wrong one's credentials either 404s or reads + * another merchant's operation. Both call sites resolve the payment's own method already. + * * @return array{status: int, body: string} * * @throws ApiException */ - public function getOperation(string $operationId): array; + public function getOperation(string $operationId, PaymentMethodInterface $method): array; } diff --git a/src/Upc/ScopedConfigurationRepositoryInterface.php b/src/Upc/ScopedConfigurationRepositoryInterface.php new file mode 100644 index 00000000..98ae1ef4 --- /dev/null +++ b/src/Upc/ScopedConfigurationRepositoryInterface.php @@ -0,0 +1,37 @@ +gatewayConfig = $gatewayConfig; + + return $scoped; + } + + public function forPaymentMethod(PaymentMethodInterface $paymentMethod): ScopedConfigurationRepositoryInterface + { + return $this->withGatewayConfig( + $paymentMethod->getGatewayConfig() + ?? throw new \LogicException('The payment method has no gateway config, so no PayPlug account can be resolved for it.'), + ); + } + public function get(string $key): ?string { $value = $this->findGatewayConfig()->getConfig()[$key] ?? null; @@ -78,11 +94,18 @@ private function getClientConfig(): array return $rawClientConfig; } + /** + * Deliberately no fallback to findOneBy(['factoryName' => …]). That lookup was correct only + * while exactly one CB gateway config could exist; since PRE-3628 it returns an arbitrary one + * of several, which would sign a channel's payment with another channel's account. An + * unscoped call is a bug at the call site, so it fails loudly here rather than silently + * resolving the wrong merchant. + */ private function findGatewayConfig(): GatewayConfigInterface { - /** @var GatewayConfigInterface|null $gatewayConfig */ - $gatewayConfig = $this->gatewayConfigRepository->findOneBy(['factoryName' => PayPlugGatewayFactory::FACTORY_NAME]); - - return $gatewayConfig ?? throw new \LogicException('No gateway config found for ' . PayPlugGatewayFactory::FACTORY_NAME . '.'); + return $this->gatewayConfig ?? throw new \LogicException( + 'The UPC configuration repository has not been scoped to a gateway config. ' . + 'Call withGatewayConfig() with the gateway config of the payment method being handled.', + ); } } diff --git a/src/Upc/UnifiedApiOperationStatusFetcher.php b/src/Upc/UnifiedApiOperationStatusFetcher.php index 6645c453..38994784 100644 --- a/src/Upc/UnifiedApiOperationStatusFetcher.php +++ b/src/Upc/UnifiedApiOperationStatusFetcher.php @@ -5,28 +5,30 @@ namespace PayPlug\SyliusPayPlugPlugin\Upc; use PayplugUnifiedCore\Auth\TokenManager; -use PayplugUnifiedCore\Contracts\IConfigurationRepository; use PayplugUnifiedCore\Contracts\IUnifiedApiHttpClient; use PayplugUnifiedCore\Services\UnifiedApiPaymentService; +use Sylius\Component\Payment\Model\PaymentMethodInterface; final class UnifiedApiOperationStatusFetcher implements OperationStatusFetcherInterface { public function __construct( private IUnifiedApiHttpClient $httpClient, private TokenManager $tokenManager, - private IConfigurationRepository $configurationRepository, + private ScopedConfigurationRepositoryInterface $configurationRepository, private string $unifiedApiBaseUrl, ) { } - public function getOperation(string $operationId): array + public function getOperation(string $operationId, PaymentMethodInterface $method): array { + $configuration = $this->configurationRepository->forPaymentMethod($method); + $service = new UnifiedApiPaymentService( $this->httpClient, $this->tokenManager, $this->unifiedApiBaseUrl, - $this->configurationRepository->getClientId(), - $this->configurationRepository->getClientSecret(), + $configuration->getClientId(), + $configuration->getClientSecret(), ); return $service->getOperation($operationId); diff --git a/src/Upc/UnifiedApiPaymentCreator.php b/src/Upc/UnifiedApiPaymentCreator.php index eac6108b..779714ad 100644 --- a/src/Upc/UnifiedApiPaymentCreator.php +++ b/src/Upc/UnifiedApiPaymentCreator.php @@ -5,30 +5,32 @@ namespace PayPlug\SyliusPayPlugPlugin\Upc; use PayplugUnifiedCore\Auth\TokenManager; -use PayplugUnifiedCore\Contracts\IConfigurationRepository; use PayplugUnifiedCore\Contracts\IUnifiedApiHttpClient; use PayplugUnifiedCore\Contracts\PaymentRequestPayload; use PayplugUnifiedCore\Output\PaymentOutput; use PayplugUnifiedCore\Services\UnifiedApiPaymentService; +use Sylius\Component\Payment\Model\PaymentMethodInterface; final class UnifiedApiPaymentCreator implements UnifiedApiPaymentCreatorInterface { public function __construct( private IUnifiedApiHttpClient $httpClient, private TokenManager $tokenManager, - private IConfigurationRepository $configurationRepository, + private ScopedConfigurationRepositoryInterface $configurationRepository, private string $unifiedApiBaseUrl, ) { } - public function createPayment(PaymentRequestPayload $dto): PaymentOutput + public function createPayment(PaymentRequestPayload $dto, PaymentMethodInterface $method): PaymentOutput { + $configuration = $this->configurationRepository->forPaymentMethod($method); + $service = new UnifiedApiPaymentService( $this->httpClient, $this->tokenManager, $this->unifiedApiBaseUrl, - $this->configurationRepository->getClientId(), - $this->configurationRepository->getClientSecret(), + $configuration->getClientId(), + $configuration->getClientSecret(), ); return $service->createPayment($dto); diff --git a/src/Upc/UnifiedApiPaymentCreatorInterface.php b/src/Upc/UnifiedApiPaymentCreatorInterface.php index 1dc79629..e33cd35d 100644 --- a/src/Upc/UnifiedApiPaymentCreatorInterface.php +++ b/src/Upc/UnifiedApiPaymentCreatorInterface.php @@ -9,6 +9,7 @@ use PayplugUnifiedCore\Exceptions\InvalidHostedFieldException; use PayplugUnifiedCore\Exceptions\InvalidPaymentException; use PayplugUnifiedCore\Output\PaymentOutput; +use Sylius\Component\Payment\Model\PaymentMethodInterface; interface UnifiedApiPaymentCreatorInterface { @@ -17,5 +18,5 @@ interface UnifiedApiPaymentCreatorInterface * @throws InvalidPaymentException if $dto is a PaymentDto that fails validation * @throws ApiException */ - public function createPayment(PaymentRequestPayload $dto): PaymentOutput; + public function createPayment(PaymentRequestPayload $dto, PaymentMethodInterface $method): PaymentOutput; } diff --git a/src/Upc/UnifiedApiRefundCreator.php b/src/Upc/UnifiedApiRefundCreator.php index efbee8c8..1e44daa9 100644 --- a/src/Upc/UnifiedApiRefundCreator.php +++ b/src/Upc/UnifiedApiRefundCreator.php @@ -5,7 +5,6 @@ namespace PayPlug\SyliusPayPlugPlugin\Upc; use PayplugUnifiedCore\Auth\TokenManager; -use PayplugUnifiedCore\Contracts\IConfigurationRepository; use PayplugUnifiedCore\Contracts\IUnifiedApiHttpClient; use PayplugUnifiedCore\Services\UnifiedApiPaymentService; use Sylius\Component\Payment\Model\PaymentMethodInterface; @@ -15,7 +14,7 @@ final class UnifiedApiRefundCreator implements RefundCreatorInterface public function __construct( private IUnifiedApiHttpClient $httpClient, private TokenManager $tokenManager, - private IConfigurationRepository $configurationRepository, + private ScopedConfigurationRepositoryInterface $configurationRepository, private string $unifiedApiBaseUrl, ) { } @@ -28,13 +27,14 @@ public function createRefund( ?string $currency = null, ): array { $accountId = GatewayCredentialsResolver::resolve($method); + $configuration = $this->configurationRepository->forPaymentMethod($method); $service = new UnifiedApiPaymentService( $this->httpClient, $this->tokenManager, $this->unifiedApiBaseUrl, - $this->configurationRepository->getClientId(), - $this->configurationRepository->getClientSecret(), + $configuration->getClientId(), + $configuration->getClientSecret(), ); return $service->createRefund( diff --git a/src/Validator/PaymentMethodValidator.php b/src/Validator/PaymentMethodValidator.php index ed2cf5ec..26aa539f 100644 --- a/src/Validator/PaymentMethodValidator.php +++ b/src/Validator/PaymentMethodValidator.php @@ -12,6 +12,7 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\OneyGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\ScalapayGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\HasNoGatewayChannelConflict; use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\IsCanSavePaymentMethod; use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\IsOneyEnabled; use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\IsScalapayAmountRangeValid; @@ -79,26 +80,28 @@ private function processPayplug(PaymentMethodInterface $paymentMethod): Constrai $constraintList[] = new PayplugPermission(Permission::CAN_USE_INTEGRATED_PAYMENTS); } + $constraintList[] = new HasNoGatewayChannelConflict(); + return $this->validator->validate($paymentMethod, $constraintList, self::VALIDATION_GROUPS); } private function processOney(PaymentMethodInterface $paymentMethod): ConstraintViolationListInterface { - $constraintList = [new IsOneyEnabled()]; + $constraintList = [new IsOneyEnabled(), new HasNoGatewayChannelConflict()]; return $this->validator->validate($paymentMethod, $constraintList, self::VALIDATION_GROUPS); } private function processDefault(PaymentMethodInterface $paymentMethod): ConstraintViolationListInterface { - $constraintList = [new IsCanSavePaymentMethod()]; + $constraintList = [new IsCanSavePaymentMethod(), new HasNoGatewayChannelConflict()]; return $this->validator->validate($paymentMethod, $constraintList, self::VALIDATION_GROUPS); } private function processScalapay(PaymentMethodInterface $paymentMethod): ConstraintViolationListInterface { - $constraintList = [new IsCanSavePaymentMethod(), new IsScalapayAmountRangeValid()]; + $constraintList = [new IsCanSavePaymentMethod(), new IsScalapayAmountRangeValid(), new HasNoGatewayChannelConflict()]; return $this->validator->validate($paymentMethod, $constraintList, self::VALIDATION_GROUPS); } diff --git a/templates/admin/payment_method/form/connected_account.html.twig b/templates/admin/payment_method/form/connected_account.html.twig new file mode 100644 index 00000000..3f8038ed --- /dev/null +++ b/templates/admin/payment_method/form/connected_account.html.twig @@ -0,0 +1,35 @@ +{# Read-only: the email is written by the OAuth callback, never edited here, so this is plain text + rather than a form field. Sourced from `form.vars.data` — the same path the surrounding Sylius + section template uses to read `gatewayConfig.factoryName` — since `account_email` is stored on + the gateway config without a form child of its own. #} +{% set payment_method = hookable_metadata.context.form.vars.data %} +{% set gateway_config = payment_method.gatewayConfig %} +{% set account_email = gateway_config ? gateway_config.config['account_email']|default(null) : null %} +{# What logout would actually clear. Absent on a gateway saved but never authorized, where the + button would be a no-op that disables a payment method for nothing. #} +{% set is_connected = gateway_config and (gateway_config.config['live_client']|default(null) or gateway_config.config['test_client']|default(null)) %} + +
+
{{ 'payplug_sylius_payplug_plugin.ui.connected_account'|trans }}
+ {% if account_email %} +
{{ account_email }}
+ {% else %} +
{{ 'payplug_sylius_payplug_plugin.ui.connected_account_unknown'|trans }}
+ {% endif %} + + {# A link rather than a submit button: this block is rendered inside the payment-method form, + and a nested
would be invalid HTML. The CSRF token is therefore carried in the query + string and verified by UnifiedLogoutController — whose token-id prefix is read from the + controller itself so the two ends cannot drift apart. #} + {% if is_connected %} + {% set csrf_token_id = constant('PayPlug\\SyliusPayPlugPlugin\\Action\\Admin\\Auth\\UnifiedLogoutController::CSRF_TOKEN_ID_PREFIX') ~ payment_method.id %} + + {{ 'payplug_sylius_payplug_plugin.ui.logout'|trans }} + +
{{ 'payplug_sylius_payplug_plugin.ui.logout_help'|trans }}
+ {% endif %} +
diff --git a/tests/Behat/Mocker/PayPlugApiClientFactory.php b/tests/Behat/Mocker/PayPlugApiClientFactory.php index a143f64d..913e7313 100644 --- a/tests/Behat/Mocker/PayPlugApiClientFactory.php +++ b/tests/Behat/Mocker/PayPlugApiClientFactory.php @@ -6,6 +6,7 @@ use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface; use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface; +use Sylius\Component\Payment\Model\PaymentMethodInterface; use Symfony\Component\DependencyInjection\ContainerInterface; class PayPlugApiClientFactory implements PayPlugApiClientFactoryInterface @@ -22,7 +23,11 @@ public function __construct(ContainerInterface $container, string $serviceName) $this->serviceName = $serviceName; } - public function create(string $factoryName, ?string $key = null): PayPlugApiClientInterface + /** + * The Behat suites stub one PayPlug account for the whole scenario, so the payment method is + * ignored here — the mocked client is the same whichever one is passed. + */ + public function createForPaymentMethod(PaymentMethodInterface $paymentMethod): PayPlugApiClientInterface { return new PayPlugApiClient($this->container, $this->serviceName); } diff --git a/tests/PHPUnit/Action/Admin/Auth/UnifiedAuthenticationControllerTest.php b/tests/PHPUnit/Action/Admin/Auth/UnifiedAuthenticationControllerTest.php index 752af554..4d534bb0 100644 --- a/tests/PHPUnit/Action/Admin/Auth/UnifiedAuthenticationControllerTest.php +++ b/tests/PHPUnit/Action/Admin/Auth/UnifiedAuthenticationControllerTest.php @@ -5,12 +5,17 @@ namespace Tests\PayPlug\SyliusPayPlugPlugin\PHPUnit\Action\Admin\Auth; use Doctrine\ORM\EntityManagerInterface; +use Payplug\Core\HttpClient; use PayPlug\SyliusPayPlugPlugin\Action\Admin\Auth\UnifiedAuthenticationController; +use PayPlug\SyliusPayPlugPlugin\Auth\IdTokenEmailExtractor; +use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Validator\PaymentMethodValidator; use PayplugUnifiedCore\Contracts\IOAuthHttpClient; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; +use Sylius\Component\Core\Model\PaymentMethodInterface; +use Sylius\Component\Payment\Model\GatewayConfigInterface; use Sylius\Resource\Doctrine\Persistence\RepositoryInterface; use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\HttpFoundation\RedirectResponse; @@ -19,16 +24,18 @@ use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; use Symfony\Component\Routing\RouterInterface; +use Symfony\Component\Validator\ConstraintViolationList; use Symfony\Component\Validator\Validator\ValidatorInterface; +use Tests\PayPlug\SyliusPayPlugPlugin\PHPUnit\Support\FakePayplugHttpRequest; /** - * PaymentMethodValidator is `final` and only reached after the legacy - * Authentication::createClientIdAndSecret() static calls in oauthCallback() — calls that cannot - * be intercepted by PHPUnit (static methods on a vendor SDK class, not mockable). Coverage here - * therefore stops at that boundary: everything up to and including the token exchange and the - * "no payment method id in session" guard is covered; the createClientIdAndSecret()-and-beyond - * happy path is not unit-testable without refactoring that legacy call behind an abstraction, - * which is out of scope for this migration. + * PaymentMethodValidator is `final`, and the happy path beyond the token exchange runs through the + * legacy Authentication::createClientIdAndSecret() static calls, which PHPUnit cannot intercept. + * The payplug-php SDK does expose one seam for exactly this — the public static + * HttpClient::$REQUEST_HANDLER, an IHttpRequest the SDK uses in place of cURL when set — so the + * happy path is reached here by installing a FakePayplugHttpRequest rather than by refactoring + * those static calls. tearDown() clears the handler again: it is process-global state, and leaving + * it installed would silently reroute any other test that touches the SDK. */ final class UnifiedAuthenticationControllerTest extends TestCase { @@ -44,6 +51,8 @@ final class UnifiedAuthenticationControllerTest extends TestCase private IOAuthHttpClient&MockObject $oauthHttpClient; + private ValidatorInterface&MockObject $validator; + private UnifiedAuthenticationController $controller; protected function setUp(): void @@ -51,12 +60,13 @@ protected function setUp(): void $this->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->validator = $this->createMock(ValidatorInterface::class); + // final class — cannot be mocked by PHPUnit, so a real instance wired with mocked + // collaborators is built instead. Most scenarios here stop before process() is reached; + // the happy-path ones do reach it, hence the stubbed validator returning no violations. $this->paymentMethodValidator = new PaymentMethodValidator( $this->createMock(RequestStack::class), - $this->createMock(ValidatorInterface::class), + $this->validator, $this->entityManager, ); $this->logger = $this->createMock(LoggerInterface::class); @@ -69,6 +79,7 @@ protected function setUp(): void $this->paymentMethodValidator, $this->logger, $this->oauthHttpClient, + new IdTokenEmailExtractor(), 'https://api-qa.payplug.com', 'https://www.payplug.com', ); @@ -78,6 +89,11 @@ protected function setUp(): void ])); } + protected function tearDown(): void + { + HttpClient::$REQUEST_HANDLER = null; + } + private function buildRequest(array $query = []): Request { $request = new Request($query); @@ -276,4 +292,134 @@ public function testOauthCallback_withValidStateButNoPaymentMethodIdInSession_st self::assertInstanceOf(RedirectResponse::class, $response); self::assertSame('payplug_sylius_payplug_plugin.admin.oauth_setup_error', $request->getSession()->getFlashBag()->peek('error')[0] ?? null); } + + // ------------------------------------------------------------------------- + // oauthCallback() — the connected account's email (PRE-3631) + // ------------------------------------------------------------------------- + + /** + * Builds a JWT-shaped id token carrying the given claims. The signature segment is a + * placeholder — the controller reads the payload for display and never verifies it. + * + * @param array $claims + */ + private function idTokenWithClaims(array $claims): string + { + $encode = static fn (string $raw): string => rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); + + return $encode('{"alg":"RS256"}') . '.' . $encode((string) json_encode($claims)) . '.c2ln'; + } + + /** + * Drives oauthCallback() all the way through a successful login and returns the gateway config + * as it stands afterwards. $tokenResponse is the identity provider's token-endpoint payload. + * + * @param array $tokenResponse + * @param array $initialConfig gateway config as it stands before the login + * + * @return array + */ + private function runSuccessfulCallback(array $tokenResponse, array $initialConfig = []): array + { + $this->stubRouterGenerate([ + 'payplug_sylius_admin_auth_oauth_callback' => 'https://shop.example.com/payplug/auth/oauth-callback', + 'sylius_admin_payment_method_update' => '/admin/payment-methods/1/edit', + ]); + + $this->oauthHttpClient->method('post')->willReturn([ + 'status' => 200, + 'body' => json_encode($tokenResponse), + ]); + + // Answers both createClientIdAndSecret() calls (test then live) without touching the network. + HttpClient::$REQUEST_HANDLER = new FakePayplugHttpRequest([ + json_encode(['client_id' => 'generated_client', 'client_secret' => 'generated_secret']), + ]); + + $storedConfig = $initialConfig; + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getFactoryName')->willReturn(PayPlugGatewayFactory::FACTORY_NAME); + $gatewayConfig->method('getConfig')->willReturnCallback(static fn (): array => $storedConfig); + $gatewayConfig->method('setConfig')->willReturnCallback( + static function (array $config) use (&$storedConfig): void { + $storedConfig = $config; + }, + ); + + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig); + $paymentMethod->method('getName')->willReturn('Carte bancaire'); + $paymentMethod->method('getId')->willReturn(1); + + $this->paymentMethodRepository->method('find')->willReturn($paymentMethod); + $this->validator->method('validate')->willReturn(new ConstraintViolationList()); + + $request = $this->buildRequest(['code' => 'auth_code', 'state' => 'matching-state']); + $session = $request->getSession(); + $session->set('payplug_client_id', 'client_abc'); + $session->set('payplug_company_id', 'company_xyz'); + $session->set('payplug_oauth_state', 'matching-state'); + $session->set('payplug_oauth_code_verifier', 'verifier_123'); + $session->set('payplug_sylius_oauth_payment_method_id', 1); + + $this->logger->expects(self::never())->method('critical'); + + $this->controller->oauthCallback($request); + + return $storedConfig; + } + + /** + * The `/account` endpoint carries no email — the id_token from this exchange is the only place + * the connected merchant's address appears, and it is gone once the callback returns, so it has + * to be persisted here for the admin screen to have anything to show. + */ + public function testOauthCallback_storesTheEmailFromTheIdTokenOnTheGatewayConfig(): void + { + $config = $this->runSuccessfulCallback([ + 'access_token' => 'jwt', + 'expires_in' => 3600, + 'token_type' => 'Bearer', + 'id_token' => $this->idTokenWithClaims(['email' => 'merchant@example.com']), + ]); + + self::assertSame('merchant@example.com', $config['account_email'] ?? null); + } + + /** + * The credentials are what the login exists to produce; the email is a display nicety captured + * alongside them. An identity provider that returns no id_token must therefore still yield a + * fully configured gateway. + */ + public function testOauthCallback_withoutAnIdToken_stillStoresTheClientCredentials(): void + { + $config = $this->runSuccessfulCallback([ + 'access_token' => 'jwt', + 'expires_in' => 3600, + 'token_type' => 'Bearer', + ]); + + self::assertNull($config['account_email'] ?? null); + self::assertSame('generated_client', $config['live_client']['client_id'] ?? null); + self::assertSame('generated_client', $config['test_client']['client_id'] ?? null); + } + + /** + * Re-authenticating against a different PayPlug account must not leave the previous account's + * address on screen — a stale email here would misreport which account takes the money. + */ + public function testOauthCallback_overwritesAPreviouslyStoredEmail(): void + { + $config = $this->runSuccessfulCallback( + [ + 'access_token' => 'jwt', + 'expires_in' => 3600, + 'token_type' => 'Bearer', + 'id_token' => $this->idTokenWithClaims(['email' => 'new-owner@example.com']), + ], + ['account_email' => 'previous-owner@example.com'], + ); + + self::assertSame('new-owner@example.com', $config['account_email'] ?? null); + } } diff --git a/tests/PHPUnit/Action/Admin/Auth/UnifiedLogoutControllerTest.php b/tests/PHPUnit/Action/Admin/Auth/UnifiedLogoutControllerTest.php new file mode 100644 index 00000000..8da4c54d --- /dev/null +++ b/tests/PHPUnit/Action/Admin/Auth/UnifiedLogoutControllerTest.php @@ -0,0 +1,246 @@ + would be invalid HTML — so the CSRF check is the only thing standing + * between a crafted link and a merchant losing a connection. Several tests below exist purely to + * pin that: a request that fails the check must mutate nothing at all. + */ +final class UnifiedLogoutControllerTest extends TestCase +{ + private const VALID_TOKEN = 'a-valid-csrf-token'; + + private RouterInterface&MockObject $router; + + private RepositoryInterface&MockObject $paymentMethodRepository; + + private LoggerInterface&MockObject $logger; + + private SyliusTokenCache $tokenCache; + + private UnifiedLogoutController $controller; + + protected function setUp(): void + { + $this->router = $this->createMock(RouterInterface::class); + $this->paymentMethodRepository = $this->createMock(RepositoryInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->tokenCache = new SyliusTokenCache(new ArrayAdapter()); + + $this->router->method('generate')->willReturnCallback( + static fn (string $route, array $parameters = []): string => '/admin/route/' . $route . '/' . ($parameters['id'] ?? ''), + ); + + $csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class); + $csrfTokenManager->method('isTokenValid')->willReturnCallback( + static fn (CsrfToken $token): bool => self::VALID_TOKEN === $token->getValue(), + ); + + $this->controller = new UnifiedLogoutController( + $this->router, + $this->paymentMethodRepository, + new GatewayConnectionRevoker($this->createMock(EntityManagerInterface::class), $this->tokenCache), + $csrfTokenManager, + $this->logger, + ); + } + + public function testLogout_clearsTheConnectionOfTheTargetedGatewayConfig(): void + { + $paymentMethod = $this->connectedPaymentMethod(); + $this->paymentMethodRepository->method('find')->with(42)->willReturn($paymentMethod); + + $this->controller->logout($this->buildRequest(self::VALID_TOKEN), 42); + + $config = $paymentMethod->getGatewayConfig()?->getConfig() ?? []; + self::assertArrayNotHasKey('live_client', $config); + self::assertArrayNotHasKey('test_client', $config); + self::assertArrayNotHasKey('account_email', $config); + self::assertFalse($paymentMethod->isEnabled()); + } + + public function testLogout_redirectsBackToThePaymentMethodUpdateScreenWithASuccessFlash(): void + { + $this->paymentMethodRepository->method('find')->willReturn($this->connectedPaymentMethod()); + $request = $this->buildRequest(self::VALID_TOKEN); + + $response = $this->controller->logout($request, 42); + + self::assertInstanceOf(RedirectResponse::class, $response); + self::assertSame('/admin/route/sylius_admin_payment_method_update/42', $response->getTargetUrl()); + self::assertSame( + 'payplug_sylius_payplug_plugin.admin.logout_success', + $request->getSession()->getFlashBag()->peek('success')[0] ?? null, + ); + } + + public function testLogout_invalidCsrfToken_isRejectedAndChangesNothing(): void + { + $paymentMethod = $this->connectedPaymentMethod(); + $this->paymentMethodRepository->method('find')->willReturn($paymentMethod); + + try { + $this->controller->logout($this->buildRequest('forged-token'), 42); + self::fail('Expected a BadRequestHttpException for an invalid CSRF token.'); + } catch (BadRequestHttpException) { + // expected + } + + self::assertArrayHasKey('live_client', $paymentMethod->getGatewayConfig()?->getConfig() ?? []); + self::assertTrue($paymentMethod->isEnabled()); + } + + public function testLogout_missingCsrfToken_isRejected(): void + { + $this->paymentMethodRepository->method('find')->willReturn($this->connectedPaymentMethod()); + + $this->expectException(BadRequestHttpException::class); + + $this->controller->logout($this->buildRequest(null), 42); + } + + /** + * `security.csrf.token_manager` only exists while CSRF protection is enabled, so the dependency + * is optional and the check is skipped when it is absent — the same posture as Sylius's own + * token-in-the-query-string admin actions, which guard every check with + * sylius_csrf_protection_enabled(). An app that turns CSRF off has made that call for itself; + * the button must still work there rather than 400 on every click. + */ + public function testLogout_whenCsrfProtectionIsDisabled_proceedsWithoutAToken(): void + { + $paymentMethod = $this->connectedPaymentMethod(); + $this->paymentMethodRepository->method('find')->willReturn($paymentMethod); + + $controller = new UnifiedLogoutController( + $this->router, + $this->paymentMethodRepository, + new GatewayConnectionRevoker($this->createMock(EntityManagerInterface::class), $this->tokenCache), + null, + $this->logger, + ); + + $controller->logout($this->buildRequest(null), 42); + + self::assertArrayNotHasKey('live_client', $paymentMethod->getGatewayConfig()?->getConfig() ?? []); + self::assertFalse($paymentMethod->isEnabled()); + } + + public function testLogout_unknownPaymentMethod_isNotFound(): void + { + $this->paymentMethodRepository->method('find')->willReturn(null); + + $this->expectException(NotFoundHttpException::class); + + $this->controller->logout($this->buildRequest(self::VALID_TOKEN), 42); + } + + public function testLogout_paymentMethodOfAnotherProvider_isNotFound(): void + { + $this->paymentMethodRepository->method('find')->willReturn($this->connectedPaymentMethod('stripe')); + + $this->expectException(NotFoundHttpException::class); + + $this->controller->logout($this->buildRequest(self::VALID_TOKEN), 42); + } + + public function testLogout_whenRevokingFails_logsAndRedirectsWithAnErrorFlash(): void + { + $paymentMethod = new PaymentMethod(); + $paymentMethod->setGatewayConfig($this->gatewayConfig(PayPlugGatewayFactory::FACTORY_NAME)); + $this->paymentMethodRepository->method('find')->willReturn($paymentMethod); + + $this->logger->expects(self::once())->method('critical') + ->with('Error while logging out the Payplug gateway', self::anything()) + ; + + $request = $this->buildRequest(self::VALID_TOKEN); + $response = $this->controllerWithFailingRevoker()->logout($request, 42); + + self::assertInstanceOf(RedirectResponse::class, $response); + self::assertSame( + 'payplug_sylius_payplug_plugin.admin.logout_error', + $request->getSession()->getFlashBag()->peek('error')[0] ?? null, + ); + } + + /** + * GatewayConnectionRevoker is `final` and cannot be mocked, so the failure is injected one + * level down: a real revoker over an entity manager whose flush() throws, which is what a + * database error during logout actually looks like. + */ + private function controllerWithFailingRevoker(): UnifiedLogoutController + { + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->method('flush')->willThrowException(new \RuntimeException('database exploded')); + + $csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class); + $csrfTokenManager->method('isTokenValid')->willReturn(true); + + return new UnifiedLogoutController( + $this->router, + $this->paymentMethodRepository, + new GatewayConnectionRevoker($entityManager, $this->tokenCache), + $csrfTokenManager, + $this->logger, + ); + } + + private function buildRequest(?string $csrfToken): Request + { + $request = new Request(null === $csrfToken ? [] : ['_csrf_token' => $csrfToken]); + $request->setSession(new Session(new MockArraySessionStorage())); + + return $request; + } + + private function connectedPaymentMethod( + string $factoryName = PayPlugGatewayFactory::FACTORY_NAME, + ): PaymentMethodInterface + { + $paymentMethod = new PaymentMethod(); + $paymentMethod->setGatewayConfig($this->gatewayConfig($factoryName)); + $paymentMethod->enable(); + + return $paymentMethod; + } + + private function gatewayConfig(string $factoryName): GatewayConfig + { + $gatewayConfig = new GatewayConfig(); + $gatewayConfig->setFactoryName($factoryName); + $gatewayConfig->setConfig([ + 'live_client' => ['client_id' => 'client_live', 'client_secret' => 'secret_live'], + 'test_client' => ['client_id' => 'client_test', 'client_secret' => 'secret_test'], + 'account_email' => 'merchant@example.com', + ]); + + return $gatewayConfig; + } +} diff --git a/tests/PHPUnit/ApiClient/PayPlugApiClientFactoryTest.php b/tests/PHPUnit/ApiClient/PayPlugApiClientFactoryTest.php index a072416c..2b49a754 100644 --- a/tests/PHPUnit/ApiClient/PayPlugApiClientFactoryTest.php +++ b/tests/PHPUnit/ApiClient/PayPlugApiClientFactoryTest.php @@ -4,6 +4,7 @@ namespace Tests\PayPlug\SyliusPayPlugPlugin\PHPUnit\ApiClient; +use Doctrine\Common\Collections\ArrayCollection; use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactory; use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface; use PayPlug\SyliusPayPlugPlugin\Exception\GatewayConfigurationException; @@ -13,6 +14,8 @@ use PayplugUnifiedCore\Contracts\ITokenCache; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Sylius\Component\Core\Model\ChannelInterface; +use Sylius\Component\Core\Model\PaymentMethodInterface as CorePaymentMethodInterface; use Sylius\Component\Payment\Model\GatewayConfigInterface; use Sylius\Component\Payment\Model\PaymentMethodInterface; use Sylius\Component\Resource\Repository\RepositoryInterface; @@ -160,13 +163,76 @@ public function testCreateForPaymentMethod_withCachedToken_doesNotCallTheTokenEn $this->factory->createForPaymentMethod($paymentMethod); } - private function buildGatewayConfig(bool $isLive): GatewayConfigInterface&MockObject + // ------------------------------------------------------------------------- + // createForPaymentMethod() — credentials are scoped to the payment method, not the factory name + // ------------------------------------------------------------------------- + + /** + * Since PRE-3628 several enabled gateway configs may share a factory name — one per channel. + * `findOneBy(['factoryName' => ...])` then resolves to an arbitrary one of them, so a client + * built that way can sign a request for channel A with channel B's account credentials. + * createForPaymentMethod() must read the credentials off the payment method's own gateway + * config and never consult the repository; routing it back through that lookup is the + * production change that makes this test fail. + */ + public function testCreateForPaymentMethod_withTwoChannelsSharingAFactoryName_usesEachChannelsOwnCredentials(): void { + $frConfig = $this->buildGatewayConfig(isLive: false, clientId: 'client_fr', clientSecret: 'secret_fr'); + $deConfig = $this->buildGatewayConfig(isLive: false, clientId: 'client_de', clientSecret: 'secret_de'); + + // Both rows are enabled and carry factoryName 'payplug', so Doctrine is free to return + // either one; the FR row stands in for "whichever one it picked". + $this->gatewayConfigRepository->method('findOneBy')->willReturn($frConfig); + + $this->tokenCache->method('get')->willReturn(null); // cache miss for both client ids + + /** @var list $sentCredentials */ + $sentCredentials = []; + $this->oauthHttpClient->method('post')->willReturnCallback( + function (string $url, array $formParams, array $headers = []) use (&$sentCredentials): array { + $sentCredentials[] = $headers['Authorization']; + + return [ + 'status' => 200, + 'body' => json_encode(['access_token' => 'jwt', 'expires_in' => 300, 'token_type' => 'Bearer']), + ]; + }, + ); + + $this->factory->createForPaymentMethod($this->buildPaymentMethodOnChannel('FR', $frConfig)); + $this->factory->createForPaymentMethod($this->buildPaymentMethodOnChannel('DE', $deConfig)); + + self::assertSame([ + 'Basic ' . base64_encode('client_fr:secret_fr'), + 'Basic ' . base64_encode('client_de:secret_de'), + ], $sentCredentials); + } + + private function buildPaymentMethodOnChannel( + string $channelCode, + GatewayConfigInterface $gatewayConfig, + ): PaymentMethodInterface&MockObject { + $channel = $this->createMock(ChannelInterface::class); + $channel->method('getCode')->willReturn($channelCode); + + $paymentMethod = $this->createMock(CorePaymentMethodInterface::class); + $paymentMethod->method('isEnabled')->willReturn(true); + $paymentMethod->method('getChannels')->willReturn(new ArrayCollection([$channel])); + $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig); + + return $paymentMethod; + } + + private function buildGatewayConfig( + bool $isLive, + string $clientId = 'client', + string $clientSecret = 'secret', + ): 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'], + 'live_client' => ['client_id' => $clientId, 'client_secret' => $clientSecret], + 'test_client' => ['client_id' => $clientId, 'client_secret' => $clientSecret], ]); $gatewayConfig->method('getFactoryName')->willReturn('payplug'); diff --git a/tests/PHPUnit/Auth/GatewayConnectionRevokerTest.php b/tests/PHPUnit/Auth/GatewayConnectionRevokerTest.php new file mode 100644 index 00000000..6cf0479d --- /dev/null +++ b/tests/PHPUnit/Auth/GatewayConnectionRevokerTest.php @@ -0,0 +1,174 @@ +entityManager = $this->createMock(EntityManagerInterface::class); + $this->tokenCache = new SyliusTokenCache(new ArrayAdapter()); + $this->revoker = new GatewayConnectionRevoker($this->entityManager, $this->tokenCache); + } + + public function testRevoke_removesTheOAuthCredentialsFromTheGatewayConfig(): void + { + $paymentMethod = $this->paymentMethod(); + + $this->revoker->revoke($paymentMethod); + + $config = $paymentMethod->getGatewayConfig()?->getConfig() ?? []; + self::assertArrayNotHasKey('live_client', $config); + self::assertArrayNotHasKey('test_client', $config); + self::assertArrayNotHasKey('account_email', $config); + } + + public function testRevoke_keepsTheMerchantSettingsThatAreNotCredentials(): void + { + $paymentMethod = $this->paymentMethod(config: [ + 'live' => true, + PayPlugGatewayFactory::ONE_CLICK => true, + 'fees_for' => 'merchant', + ]); + + $this->revoker->revoke($paymentMethod); + + $config = $paymentMethod->getGatewayConfig()?->getConfig() ?? []; + self::assertTrue($config['live']); + self::assertTrue($config[PayPlugGatewayFactory::ONE_CLICK]); + self::assertSame('merchant', $config['fees_for']); + } + + public function testRevoke_disablesThePaymentMethod(): void + { + $paymentMethod = $this->paymentMethod(); + $paymentMethod->enable(); + + $this->revoker->revoke($paymentMethod); + + self::assertFalse($paymentMethod->isEnabled()); + } + + public function testRevoke_dropsTheCachedTokenOfBothEnvironments(): void + { + $this->tokenCache->set('upc_oauth_token:client_live', 'live-access-token', 3600); + $this->tokenCache->set('upc_oauth_token:client_test', 'test-access-token', 3600); + + $this->revoker->revoke($this->paymentMethod()); + + self::assertNull($this->tokenCache->get('upc_oauth_token:client_live')); + self::assertNull($this->tokenCache->get('upc_oauth_token:client_test')); + } + + public function testRevoke_leavesAnotherGatewaysCachedTokenAlone(): void + { + $this->tokenCache->set('upc_oauth_token:client_of_another_channel', 'other-access-token', 3600); + + $this->revoker->revoke($this->paymentMethod()); + + self::assertSame('other-access-token', $this->tokenCache->get('upc_oauth_token:client_of_another_channel')); + } + + public function testRevoke_hostedFieldsCbGateway_alsoClearsTheHostedFieldsAccountId(): void + { + $paymentMethod = $this->paymentMethod(config: [ + PayPlugGatewayFactory::HOSTED_FIELDS => true, + PayPlugGatewayFactory::HF_IDENTIFIER => 'acc_123', + ]); + + $this->revoker->revoke($paymentMethod); + + self::assertArrayNotHasKey(PayPlugGatewayFactory::HF_IDENTIFIER, $paymentMethod->getGatewayConfig()?->getConfig() ?? []); + } + + public function testRevoke_cbGatewayWithoutHostedFields_keepsTheHostedFieldsAccountId(): void + { + $paymentMethod = $this->paymentMethod(config: [ + PayPlugGatewayFactory::HOSTED_FIELDS => false, + PayPlugGatewayFactory::HF_IDENTIFIER => 'acc_123', + ]); + + $this->revoker->revoke($paymentMethod); + + self::assertSame('acc_123', ($paymentMethod->getGatewayConfig()?->getConfig() ?? [])[PayPlugGatewayFactory::HF_IDENTIFIER] ?? null); + } + + public function testRevoke_nonCbGateway_keepsTheHostedFieldsAccountId(): void + { + $paymentMethod = $this->paymentMethod( + factoryName: OneyGatewayFactory::FACTORY_NAME, + config: [ + PayPlugGatewayFactory::HOSTED_FIELDS => true, + PayPlugGatewayFactory::HF_IDENTIFIER => 'acc_123', + ], + ); + + $this->revoker->revoke($paymentMethod); + + self::assertSame('acc_123', ($paymentMethod->getGatewayConfig()?->getConfig() ?? [])[PayPlugGatewayFactory::HF_IDENTIFIER] ?? null); + } + + public function testRevoke_persistsTheChangeOnce(): void + { + $this->entityManager->expects(self::once())->method('flush'); + + $this->revoker->revoke($this->paymentMethod()); + } + + public function testRevoke_paymentMethodWithoutGatewayConfig_throws(): void + { + $paymentMethod = new PaymentMethod(); + + $this->expectException(\LogicException::class); + + $this->revoker->revoke($paymentMethod); + } + + /** + * @param array $config merged over the credentials every connected gateway holds + */ + private function paymentMethod( + string $factoryName = PayPlugGatewayFactory::FACTORY_NAME, + array $config = [], + ): PaymentMethodInterface { + $gatewayConfig = new GatewayConfig(); + $gatewayConfig->setFactoryName($factoryName); + $gatewayConfig->setConfig([ + 'live_client' => ['client_id' => 'client_live', 'client_secret' => 'secret_live'], + 'test_client' => ['client_id' => 'client_test', 'client_secret' => 'secret_test'], + 'account_email' => 'merchant@example.com', + ...$config, + ]); + + $paymentMethod = new PaymentMethod(); + $paymentMethod->setGatewayConfig($gatewayConfig); + + return $paymentMethod; + } +} diff --git a/tests/PHPUnit/Auth/IdTokenEmailExtractorTest.php b/tests/PHPUnit/Auth/IdTokenEmailExtractorTest.php new file mode 100644 index 00000000..7ba94f8c --- /dev/null +++ b/tests/PHPUnit/Auth/IdTokenEmailExtractorTest.php @@ -0,0 +1,136 @@ +extractor = new IdTokenEmailExtractor(); + } + + /** + * Builds a JWT-shaped string whose payload segment is the given claims. The signature is + * deliberately a fixed placeholder: the extractor never verifies it (see the class docblock), + * so a real one would prove nothing that this doesn't. + * + * @param array $claims + */ + private function idTokenWithClaims(array $claims): string + { + $encode = static fn (string $raw): string => rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); + + return $encode('{"alg":"RS256","typ":"JWT"}') . '.' . $encode((string) json_encode($claims)) . '.c2lnbmF0dXJl'; + } + + // ------------------------------------------------------------------------- + // Happy path + // ------------------------------------------------------------------------- + + public function testExtract_returnsTheEmailClaim(): void + { + $idToken = $this->idTokenWithClaims(['sub' => 'user_1', 'email' => 'merchant@example.com']); + + self::assertSame('merchant@example.com', $this->extractor->extract($idToken)); + } + + /** + * The payload is base64url-encoded, not plain base64: real PayPlug id tokens routinely contain + * `-` and `_` where standard base64 would emit `+` and `/`, and are stripped of `=` padding. + * Decoding one with plain base64_decode() would corrupt the JSON. + */ + public function testExtract_decodesABase64UrlPayloadContainingUrlUnsafeCharacters(): void + { + $claims = ['email' => 'merchant@example.com', 'nonce' => '>>>???~~~']; + $idToken = $this->idTokenWithClaims($claims); + + self::assertStringNotContainsString('+', explode('.', $idToken)[1]); + self::assertStringNotContainsString('/', explode('.', $idToken)[1]); + self::assertSame('merchant@example.com', $this->extractor->extract($idToken)); + } + + // ------------------------------------------------------------------------- + // Absent / malformed input — every branch degrades to null, never throws + // ------------------------------------------------------------------------- + + public function testExtract_returnsNullWhenTheTokenIsNull(): void + { + self::assertNull($this->extractor->extract(null)); + } + + public function testExtract_returnsNullWhenTheTokenIsEmpty(): void + { + self::assertNull($this->extractor->extract('')); + } + + public function testExtract_returnsNullWhenTheTokenIsNotThreeSegments(): void + { + self::assertNull($this->extractor->extract('header.payload')); + } + + /** + * A payload segment whose length is ≡ 1 (mod 4) cannot be valid base64 — no encoder ever emits + * one — so the padding loop produces a string that base64_decode(..., true) rejects outright. + * The decode therefore short-circuits before json_decode() is ever reached. + */ + public function testExtract_returnsNullWhenThePayloadIsNotDecodableBase64(): void + { + $encode = static fn (string $raw): string => rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); + + self::assertSame(1, \strlen('abcde') % 4); + self::assertNull($this->extractor->extract($encode('{}') . '.abcde.sig')); + } + + public function testExtract_returnsNullWhenThePayloadIsNotValidJson(): void + { + $encode = static fn (string $raw): string => rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); + + self::assertNull($this->extractor->extract($encode('{}') . '.' . $encode('not json') . '.sig')); + } + + public function testExtract_returnsNullWhenThePayloadIsAJsonScalarRatherThanAnObject(): void + { + $encode = static fn (string $raw): string => rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); + + self::assertNull($this->extractor->extract($encode('{}') . '.' . $encode('"just-a-string"') . '.sig')); + } + + public function testExtract_returnsNullWhenTheEmailClaimIsAbsent(): void + { + $idToken = $this->idTokenWithClaims(['sub' => 'user_1', 'name' => 'A Merchant']); + + self::assertNull($this->extractor->extract($idToken)); + } + + public function testExtract_returnsNullWhenTheEmailClaimIsNotAString(): void + { + $idToken = $this->idTokenWithClaims(['email' => ['merchant@example.com']]); + + self::assertNull($this->extractor->extract($idToken)); + } + + /** + * The claim is rendered verbatim on an admin screen, so a value that isn't an address is + * treated as no value at all rather than echoed back. + */ + public function testExtract_returnsNullWhenTheEmailClaimIsNotAnAddress(): void + { + $idToken = $this->idTokenWithClaims(['email' => 'not-an-email']); + + self::assertNull($this->extractor->extract($idToken)); + } + + public function testExtract_returnsNullWhenTheEmailClaimIsEmpty(): void + { + $idToken = $this->idTokenWithClaims(['email' => '']); + + self::assertNull($this->extractor->extract($idToken)); + } +} diff --git a/tests/PHPUnit/Checker/GatewayChannelConflictCheckerTest.php b/tests/PHPUnit/Checker/GatewayChannelConflictCheckerTest.php new file mode 100644 index 00000000..0be806d2 --- /dev/null +++ b/tests/PHPUnit/Checker/GatewayChannelConflictCheckerTest.php @@ -0,0 +1,291 @@ +paymentMethodRepository = $this->createMock(PaymentMethodRepositoryInterface::class); + $this->checker = new GatewayChannelConflictChecker($this->paymentMethodRepository); + } + + public function testFindConflicts_enabledRivalSharesChannel_isReported(): void + { + $subject = $this->paymentMethod(null, true, ['WEB_FR']); + $rival = $this->paymentMethod(7, true, ['WEB_FR'], 'CB 1'); + + $this->paymentMethodRepository + ->expects(self::once()) + ->method('findEnabledByGatewayName') + ->with(PayPlugGatewayFactory::FACTORY_NAME) + ->willReturn([$rival]) + ; + + $conflicts = $this->checker->findConflicts($subject, PayPlugGatewayFactory::FACTORY_NAME); + + self::assertCount(1, $conflicts); + self::assertSame('WEB_FR', $conflicts[0]['channel']->getCode()); + self::assertSame('CB 1', $conflicts[0]['paymentMethod']->getName()); + } + + public function testFindConflicts_channelSetsAreDisjoint_isAllowed(): void + { + $subject = $this->paymentMethod(null, true, ['WEB_FR']); + $rival = $this->paymentMethod(7, true, ['WEB_IT'], 'CB 1'); + + $this->paymentMethodRepository->method('findEnabledByGatewayName')->willReturn([$rival]); + + self::assertSame([], $this->checker->findConflicts($subject, PayPlugGatewayFactory::FACTORY_NAME)); + } + + public function testFindConflicts_twoSharedChannels_bothAreReported(): void + { + $subject = $this->paymentMethod(null, true, ['WEB_FR', 'WEB_IT', 'WEB_BE']); + $rival = $this->paymentMethod(7, true, ['WEB_IT', 'WEB_BE'], 'CB 1'); + + $this->paymentMethodRepository->method('findEnabledByGatewayName')->willReturn([$rival]); + + $conflicts = $this->checker->findConflicts($subject, PayPlugGatewayFactory::FACTORY_NAME); + + self::assertCount(2, $conflicts); + self::assertSame( + ['WEB_IT', 'WEB_BE'], + array_map(static fn (array $conflict): ?string => $conflict['channel']->getCode(), $conflicts), + ); + } + + public function testFindConflicts_subjectIsDisabled_isAllowedWithoutQuerying(): void + { + $subject = $this->paymentMethod(null, false, ['WEB_FR']); + + $this->paymentMethodRepository->expects(self::never())->method('findEnabledByGatewayName'); + + self::assertSame([], $this->checker->findConflicts($subject, PayPlugGatewayFactory::FACTORY_NAME)); + } + + public function testFindConflicts_subjectHasNoChannel_isAllowedWithoutQuerying(): void + { + $subject = $this->paymentMethod(null, true, []); + + $this->paymentMethodRepository->expects(self::never())->method('findEnabledByGatewayName'); + + self::assertSame([], $this->checker->findConflicts($subject, PayPlugGatewayFactory::FACTORY_NAME)); + } + + /** + * Editing an existing gateway must not make it conflict with itself. + */ + public function testFindConflicts_rivalIsTheSubjectItself_isAllowed(): void + { + $subject = $this->paymentMethod(7, true, ['WEB_FR']); + $itself = $this->paymentMethod(7, true, ['WEB_FR'], 'CB 1'); + + $this->paymentMethodRepository->method('findEnabledByGatewayName')->willReturn([$itself]); + + self::assertSame([], $this->checker->findConflicts($subject, PayPlugGatewayFactory::FACTORY_NAME)); + } + + /** + * The repository already filters on `enabled`, but the rule is re-asserted here so the whole + * rule is expressed — and testable — in one place. + */ + public function testFindConflicts_rivalIsDisabled_isAllowed(): void + { + $subject = $this->paymentMethod(null, true, ['WEB_FR']); + $rival = $this->paymentMethod(7, false, ['WEB_FR'], 'CB 1'); + + $this->paymentMethodRepository->method('findEnabledByGatewayName')->willReturn([$rival]); + + self::assertSame([], $this->checker->findConflicts($subject, PayPlugGatewayFactory::FACTORY_NAME)); + } + + public function testFindConflicts_rivalHasNoChannel_isAllowed(): void + { + $subject = $this->paymentMethod(null, true, ['WEB_FR']); + $rival = $this->paymentMethod(7, true, [], 'CB 1'); + + $this->paymentMethodRepository->method('findEnabledByGatewayName')->willReturn([$rival]); + + self::assertSame([], $this->checker->findConflicts($subject, PayPlugGatewayFactory::FACTORY_NAME)); + } + + /** + * Different factory types never conflict: the lookup is scoped to the factory being saved. + */ + public function testFindConflicts_queriesOnlyTheGivenFactory(): void + { + $subject = $this->paymentMethod(null, true, ['WEB_FR']); + + $this->paymentMethodRepository + ->expects(self::once()) + ->method('findEnabledByGatewayName') + ->with(OneyGatewayFactory::FACTORY_NAME) + ->willReturn([]) + ; + + self::assertSame([], $this->checker->findConflicts($subject, OneyGatewayFactory::FACTORY_NAME)); + } + + public function testFindClaimedChannels_enabledRivalChannels_areClaimed(): void + { + $subject = $this->paymentMethod(null, true, []); + $rival = $this->paymentMethod(7, true, ['WEB_FR', 'WEB_BE'], 'CB 1'); + + $this->paymentMethodRepository + ->expects(self::once()) + ->method('findEnabledByGatewayName') + ->with(PayPlugGatewayFactory::FACTORY_NAME) + ->willReturn([$rival]) + ; + + $claimed = $this->checker->findClaimedChannels($subject, PayPlugGatewayFactory::FACTORY_NAME); + + self::assertSame(['WEB_FR', 'WEB_BE'], array_keys($claimed)); + self::assertSame('CB 1', $claimed['WEB_FR']->getName()); + self::assertSame('CB 1', $claimed['WEB_BE']->getName()); + } + + public function testFindClaimedChannels_severalRivals_areAllReported(): void + { + $subject = $this->paymentMethod(null, true, []); + $firstRival = $this->paymentMethod(7, true, ['WEB_FR'], 'CB 1'); + $secondRival = $this->paymentMethod(8, true, ['WEB_IT'], 'CB 2'); + + $this->paymentMethodRepository + ->method('findEnabledByGatewayName') + ->willReturn([$firstRival, $secondRival]) + ; + + $claimed = $this->checker->findClaimedChannels($subject, PayPlugGatewayFactory::FACTORY_NAME); + + self::assertSame('CB 1', $claimed['WEB_FR']->getName()); + self::assertSame('CB 2', $claimed['WEB_IT']->getName()); + } + + /** + * The channels the edited gateway already holds must stay selectable: a disabled checkbox is + * not submitted by the browser, so disabling a *checked* one would silently drop the channel + * on save. Leaving it selectable lets the POST_SUBMIT rule report the conflict instead. + */ + public function testFindClaimedChannels_rivalIsTheSubjectItself_isNotClaimed(): void + { + $subject = $this->paymentMethod(7, true, ['WEB_FR']); + $itself = $this->paymentMethod(7, true, ['WEB_FR'], 'CB 1'); + + $this->paymentMethodRepository->method('findEnabledByGatewayName')->willReturn([$itself]); + + self::assertSame([], $this->checker->findClaimedChannels($subject, PayPlugGatewayFactory::FACTORY_NAME)); + } + + /** + * Same guarantee as above, for the case the id comparison cannot catch: a *different* enabled + * payment method already holds a channel the subject also holds. Reachable through the admin + * alone — create A disabled on a channel (a disabled subject is exempt from `findConflicts()`), + * then create B enabled on it (A, being disabled, is neither a claim nor a conflict). Editing A + * afterwards must not disable its own checked box, or saving A silently drops the channel. + * + * The rival's other channels stay claimed. + */ + public function testFindClaimedChannels_channelTheSubjectAlreadyHolds_isNotClaimed(): void + { + $subject = $this->paymentMethod(5, true, ['WEB_FR']); + $rival = $this->paymentMethod(6, true, ['WEB_FR', 'WEB_BE'], 'CB 1'); + + $this->paymentMethodRepository->method('findEnabledByGatewayName')->willReturn([$rival]); + + $claimed = $this->checker->findClaimedChannels($subject, PayPlugGatewayFactory::FACTORY_NAME); + + self::assertSame(['WEB_BE'], array_keys($claimed)); + self::assertSame('CB 1', $claimed['WEB_BE']->getName()); + } + + public function testFindClaimedChannels_rivalIsDisabled_isNotClaimed(): void + { + $subject = $this->paymentMethod(null, true, []); + $rival = $this->paymentMethod(7, false, ['WEB_FR'], 'CB 1'); + + $this->paymentMethodRepository->method('findEnabledByGatewayName')->willReturn([$rival]); + + self::assertSame([], $this->checker->findClaimedChannels($subject, PayPlugGatewayFactory::FACTORY_NAME)); + } + + /** + * Unlike `findConflicts()`, the picker is rendered before the admin has decided anything: the + * claimed set must not depend on the subject's own `enabled` flag or channel selection. + */ + public function testFindClaimedChannels_subjectIsDisabled_stillReportsClaims(): void + { + $subject = $this->paymentMethod(null, false, []); + $rival = $this->paymentMethod(7, true, ['WEB_FR'], 'CB 1'); + + $this->paymentMethodRepository->method('findEnabledByGatewayName')->willReturn([$rival]); + + self::assertSame( + ['WEB_FR'], + array_keys($this->checker->findClaimedChannels($subject, PayPlugGatewayFactory::FACTORY_NAME)), + ); + } + + public function testFindClaimedChannels_queriesOnlyTheGivenFactory(): void + { + $subject = $this->paymentMethod(null, true, ['WEB_FR']); + + $this->paymentMethodRepository + ->expects(self::once()) + ->method('findEnabledByGatewayName') + ->with(OneyGatewayFactory::FACTORY_NAME) + ->willReturn([]) + ; + + self::assertSame([], $this->checker->findClaimedChannels($subject, OneyGatewayFactory::FACTORY_NAME)); + } + + /** + * @param list $channelCodes + * + * @return PaymentMethodInterface&MockObject + */ + private function paymentMethod( + ?int $id, + bool $enabled, + array $channelCodes, + string $name = 'CB', + ): PaymentMethodInterface { + $channels = []; + foreach ($channelCodes as $channelCode) { + $channel = $this->createMock(ChannelInterface::class); + $channel->method('getCode')->willReturn($channelCode); + $channels[] = $channel; + } + + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $paymentMethod->method('getId')->willReturn($id); + $paymentMethod->method('isEnabled')->willReturn($enabled); + $paymentMethod->method('getName')->willReturn($name); + $paymentMethod->method('getChannels')->willReturn(new ArrayCollection($channels)); + + return $paymentMethod; + } +} diff --git a/tests/PHPUnit/Command/Handler/NotifyHostedPaymentRequestHandlerTest.php b/tests/PHPUnit/Command/Handler/NotifyHostedPaymentRequestHandlerTest.php index a16cafbc..1e15f62a 100644 --- a/tests/PHPUnit/Command/Handler/NotifyHostedPaymentRequestHandlerTest.php +++ b/tests/PHPUnit/Command/Handler/NotifyHostedPaymentRequestHandlerTest.php @@ -6,7 +6,7 @@ use PayPlug\SyliusPayPlugPlugin\Command\Handler\NotifyHostedPaymentRequestHandler; use PayPlug\SyliusPayPlugPlugin\Command\NotifyHostedPaymentRequest; -use PayplugUnifiedCore\Contracts\IConfigurationRepository; +use PayPlug\SyliusPayPlugPlugin\Upc\ScopedConfigurationRepositoryInterface; use PayplugUnifiedCore\Contracts\ILock; use PayplugUnifiedCore\Contracts\IOrderStateMutator; use PayplugUnifiedCore\Contracts\IPaymentRepository; @@ -17,6 +17,7 @@ use Sylius\Abstraction\StateMachine\StateMachineInterface; use Sylius\Bundle\PaymentBundle\Provider\PaymentRequestProviderInterface; use Sylius\Component\Payment\Model\PaymentInterface; +use Sylius\Component\Payment\Model\PaymentMethodInterface; use Sylius\Component\Payment\Model\PaymentRequestInterface; use Sylius\Component\Payment\PaymentRequestTransitions; @@ -32,7 +33,7 @@ final class NotifyHostedPaymentRequestHandlerTest extends TestCase private IOrderStateMutator&MockObject $orderStateMutator; - private IConfigurationRepository&MockObject $configurationRepository; + private ScopedConfigurationRepositoryInterface&MockObject $configurationRepository; private LoggerInterface&MockObject $logger; @@ -45,7 +46,9 @@ protected function setUp(): void $this->lock = $this->createMock(ILock::class); $this->paymentRepository = $this->createMock(IPaymentRepository::class); $this->orderStateMutator = $this->createMock(IOrderStateMutator::class); - $this->configurationRepository = $this->createMock(IConfigurationRepository::class); + $this->configurationRepository = $this->createMock(ScopedConfigurationRepositoryInterface::class); + // The handler scopes before reading; these tests stub one account, so it scopes to itself. + $this->configurationRepository->method('forPaymentMethod')->willReturnSelf(); $this->logger = $this->createMock(LoggerInterface::class); $this->handler = new NotifyHostedPaymentRequestHandler( @@ -68,6 +71,8 @@ private function paymentRequestWithPayload( $payment = $this->createMock(PaymentInterface::class); $payment->method('getId')->willReturn($paymentId); $payment->method('getAmount')->willReturn($paymentAmount); + // The handler resolves the webhook secret from the payment's own method's gateway config. + $payment->method('getMethod')->willReturn($this->createMock(PaymentMethodInterface::class)); $paymentRequest = $this->createMock(PaymentRequestInterface::class); $paymentRequest->method('getPayload')->willReturn(['http_request' => $httpRequest]); diff --git a/tests/PHPUnit/Command/Handler/StatusHostedPaymentRequestHandlerTest.php b/tests/PHPUnit/Command/Handler/StatusHostedPaymentRequestHandlerTest.php index b8fa240f..064d7b12 100644 --- a/tests/PHPUnit/Command/Handler/StatusHostedPaymentRequestHandlerTest.php +++ b/tests/PHPUnit/Command/Handler/StatusHostedPaymentRequestHandlerTest.php @@ -16,6 +16,7 @@ use Sylius\Abstraction\StateMachine\StateMachineInterface; use Sylius\Bundle\PaymentBundle\Provider\PaymentRequestProviderInterface; use Sylius\Component\Core\Model\PaymentInterface; +use Sylius\Component\Payment\Model\PaymentMethodInterface; use Sylius\Component\Payment\Model\PaymentRequestInterface; use Sylius\Component\Payment\PaymentRequestTransitions; use Sylius\Component\Payment\PaymentTransitions; @@ -60,6 +61,8 @@ private function paymentRequest( $payment = $this->createMock(PaymentInterface::class); $payment->method('getState')->willReturn($state); $payment->method('getDetails')->willReturn($details); + // The poll authenticates against the payment's own method's account. + $payment->method('getMethod')->willReturn($this->createMock(PaymentMethodInterface::class)); $paymentRequest = $this->createMock(PaymentRequestInterface::class); $paymentRequest->method('getPayment')->willReturn($payment); diff --git a/tests/PHPUnit/Controller/IntegratedPaymentControllerTest.php b/tests/PHPUnit/Controller/IntegratedPaymentControllerTest.php new file mode 100644 index 00000000..51936b46 --- /dev/null +++ b/tests/PHPUnit/Controller/IntegratedPaymentControllerTest.php @@ -0,0 +1,177 @@ +paymentMethodRepository = $this->createMock(RepositoryInterface::class); + $this->cartContext = $this->createMock(CartContextInterface::class); + $this->paymentDataCreator = $this->createMock(PayPlugPaymentDataCreator::class); + $this->apiClientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class); + + $this->controller = new IntegratedPaymentController( + $this->cartContext, + $this->paymentMethodRepository, + $this->createMock(OrderRepositoryInterface::class), + $this->paymentDataCreator, + $this->apiClientFactory, + $this->createMock(EntityManagerInterface::class), + $this->createMock(LoggerInterface::class), + ); + } + + /** + * The iframe posts a payment method id, so the account the payment is created on must be the + * one configured on *that* payment method. Resolving the client by factory name instead would + * pick an arbitrary one of the several gateway configs that may now share it — one per channel + * since PRE-3628 — and create the payment on another channel's PayPlug account. + */ + public function testInitPayment_createsThePaymentOnTheAccountOfTheSubmittedPaymentMethod(): void + { + $paymentMethod = $this->payPlugPaymentMethod(channelCode: 'WEB_FR'); + $this->paymentMethodRepository->method('find')->with(42)->willReturn($paymentMethod); + + $this->givenCartOnChannel('WEB_FR'); + + $this->paymentDataCreator->method('create')->willReturn(new ArrayObject()); + + $payplugPayment = $this->createMock(PayplugPayment::class); + $payplugPayment->id = 'pay_1'; + $payplugPayment->is_live = false; + + $apiClient = $this->createMock(PayPlugApiClientInterface::class); + $apiClient->method('createPayment')->willReturn($payplugPayment); + + $this->apiClientFactory->expects(self::once()) + ->method('createForPaymentMethod') + ->with(self::identicalTo($paymentMethod)) + ->willReturn($apiClient); + + $response = $this->controller->initPaymentAction(Request::create('/payplug/integrated-payment/init/42'), 42); + + self::assertSame(201, $response->getStatusCode()); + } + + /** + * The id is shopper-supplied. With one CB gateway per channel — each connected to a different + * PayPlug account since PRE-3628 — accepting a payment method that does not belong to the + * order's channel would create the payment on another merchant's account, and the IPN would + * then verify it against that merchant's key and mark this order paid. + */ + public function testInitPayment_refusesAPaymentMethodFromAnotherChannel(): void + { + $paymentMethod = $this->payPlugPaymentMethod(channelCode: 'WEB_IT'); + $this->paymentMethodRepository->method('find')->with(42)->willReturn($paymentMethod); + + $this->givenCartOnChannel('WEB_FR'); + + $this->apiClientFactory->expects(self::never())->method('createForPaymentMethod'); + + $this->expectException(BadRequestHttpException::class); + $this->expectExceptionMessage('not available on this channel'); + + $this->controller->initPaymentAction(Request::create('/payplug/integrated-payment/init/42'), 42); + } + + /** + * A disconnected gateway is left disabled by GatewayConnectionRevoker, and its credentials are + * gone — creating a payment on it would fail at best and resolve stale credentials at worst. + */ + public function testInitPayment_refusesADisabledPaymentMethod(): void + { + $paymentMethod = $this->payPlugPaymentMethod(channelCode: 'WEB_FR', enabled: false); + $this->paymentMethodRepository->method('find')->with(42)->willReturn($paymentMethod); + + $this->givenCartOnChannel('WEB_FR'); + + $this->apiClientFactory->expects(self::never())->method('createForPaymentMethod'); + + $this->expectException(BadRequestHttpException::class); + $this->expectExceptionMessage('not enabled'); + + $this->controller->initPaymentAction(Request::create('/payplug/integrated-payment/init/42'), 42); + } + + public function testInitPayment_refusesAPaymentMethodOfAnotherFactory(): void + { + $paymentMethod = $this->payPlugPaymentMethod(channelCode: 'WEB_FR', factoryName: 'payplug_oney'); + $this->paymentMethodRepository->method('find')->with(42)->willReturn($paymentMethod); + + $this->givenCartOnChannel('WEB_FR'); + + $this->apiClientFactory->expects(self::never())->method('createForPaymentMethod'); + + $this->expectException(BadRequestHttpException::class); + $this->expectExceptionMessage('Unsupported payment method of Integrated Payment'); + + $this->controller->initPaymentAction(Request::create('/payplug/integrated-payment/init/42'), 42); + } + + private function givenCartOnChannel(string $channelCode): void + { + $channel = $this->createMock(ChannelInterface::class); + $channel->method('getCode')->willReturn($channelCode); + + $order = $this->createMock(OrderInterface::class); + $order->method('getLastPayment')->willReturn($this->createMock(PaymentInterface::class)); + $order->method('getChannel')->willReturn($channel); + + $this->cartContext->method('getCart')->willReturn($order); + } + + private function payPlugPaymentMethod( + string $channelCode, + bool $enabled = true, + string $factoryName = PayPlugGatewayFactory::FACTORY_NAME, + ): PaymentMethodInterface&MockObject { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getFactoryName')->willReturn($factoryName); + + $channel = $this->createMock(ChannelInterface::class); + $channel->method('getCode')->willReturn($channelCode); + + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig); + $paymentMethod->method('isEnabled')->willReturn($enabled); + $paymentMethod->method('getChannels')->willReturn(new ArrayCollection([$channel])); + + return $paymentMethod; + } +} diff --git a/tests/PHPUnit/Controller/IpnActionTest.php b/tests/PHPUnit/Controller/IpnActionTest.php index 7b50599e..f8ad7e49 100644 --- a/tests/PHPUnit/Controller/IpnActionTest.php +++ b/tests/PHPUnit/Controller/IpnActionTest.php @@ -69,14 +69,21 @@ private function paymentWithGatewayConfig(): PaymentInterface&MockObject return $payment; } - public function testInvoke_forALegacyPayment_goesThroughTheSdk(): void + /** + * The webhook must sign its `treat()` call with the credentials of the account the payment was + * actually created on. Resolving the client by factory name instead would pick an arbitrary one + * of the several gateway configs that may now share it — one per channel since PRE-3628. + */ + public function testInvoke_forALegacyPayment_buildsTheApiClientFromThePaymentsOwnMethod(): void { $payment = $this->paymentWithGatewayConfig(); $this->paymentRepository->method('findOneByPayPlugPaymentId')->willReturn($payment); $request = Request::create('/payplug/ipn', 'POST', content: \json_encode(['id' => 'pay_1'])); - $this->apiClientFactory->expects(self::once())->method('create')->with(PayPlugGatewayFactory::FACTORY_NAME) + $this->apiClientFactory->expects(self::once()) + ->method('createForPaymentMethod') + ->with(self::identicalTo($payment->getMethod())) ->willReturn($this->createMock(PayPlugApiClientInterface::class)); $response = $this->action->__invoke($request); diff --git a/tests/PHPUnit/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtensionFormSubmissionTest.php b/tests/PHPUnit/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtensionFormSubmissionTest.php index 861aad54..ebc4732a 100644 --- a/tests/PHPUnit/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtensionFormSubmissionTest.php +++ b/tests/PHPUnit/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtensionFormSubmissionTest.php @@ -4,22 +4,13 @@ namespace Tests\PayPlug\SyliusPayPlugPlugin\PHPUnit\Gateway\Form\Extension; -use Doctrine\Common\Collections\ArrayCollection; use PayPlug\SyliusPayPlugPlugin\Gateway\Form\Extension\PayPlugGatewayConfigurationTypeExtension; use PayPlug\SyliusPayPlugPlugin\Gateway\Form\Type\PayPlugGatewayConfigurationType; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; -use PHPUnit\Framework\MockObject\MockObject; -use Sylius\Component\Core\Model\ChannelInterface; -use Sylius\Component\Currency\Model\CurrencyInterface; -use Sylius\Component\Resource\Repository\RepositoryInterface; use Symfony\Component\Form\Extension\Core\Type\FormType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Test\Traits\ValidatorExtensionTrait; use Symfony\Component\Form\Test\TypeTestCase; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\RequestStack; -use Symfony\Component\HttpFoundation\Session\Session; -use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; use Symfony\Contracts\Translation\TranslatorInterface; /** @@ -31,9 +22,13 @@ * * This test exercises the real Symfony form lifecycle (via TypeTestCase, no mocked * FormBuilderInterface) end-to-end, including a realistic 3-level parent chain - * (root -> gatewayConfig -> config), because the extended type's own inherited - * AbstractGatewayConfigurationType::buildForm() PRE_SUBMIT listener walks - * getParent()->getParent() to reach the payment method entity and its "channels" field. + * (root -> gatewayConfig -> config). + * + * The base-currency-per-channel check that used to also be exercised here (via + * AbstractGatewayConfigurationType's own PRE_SUBMIT listener) has moved to + * PaymentMethodTypeExtension's POST_SUBMIT listener on the root PaymentMethodType form; it is + * covered by AbstractGatewayConfigurationTypeTest and PayPlugGatewayConfigurationTypeTest's hook + * tests instead, since this file's minimal 3-level tree doesn't wire up PaymentMethodTypeExtension. */ final class PayPlugGatewayConfigurationTypeExtensionFormSubmissionTest extends TypeTestCase { @@ -46,16 +41,8 @@ protected function getTypes(): array $translator = $this->createMock(TranslatorInterface::class); $translator->method('trans')->willReturnCallback(static fn (string $id) => $id); - $gatewayConfigRepository = $this->createMock(RepositoryInterface::class); - $gatewayConfigRepository->method('findOneBy')->willReturn(null); - - $request = new Request(); - $request->setSession(new Session(new MockArraySessionStorage())); - $requestStack = new RequestStack(); - $requestStack->push($request); - return [ - new PayPlugGatewayConfigurationType($translator, $gatewayConfigRepository, $requestStack), + new PayPlugGatewayConfigurationType($translator), ]; } @@ -228,164 +215,28 @@ public function testSubmit_hostedFieldsModeWithALeftoverSubMerchantIdInStoredCon self::assertTrue($form->isValid()); } - /** - * PRE-3553: selecting a non-EUR channel while `integrated_payment` is selected must be - * rejected with a message specific to this feature ("...not compatible with Integrated - * Payment"), not the generic per-gateway `base_currency_not_euro` wording every other - * PayPlug-family gateway subtype still uses (Bancontact, American Express, Scalapay...). - */ - public function testSubmit_integratedPaymentModeWithNonEurChannel_isInvalidWithCurrencyIncompatibleMessage(): void - { - $form = $this->createRootForm($this->buildChannels(['USD'])); - - // clearMissing=false: "channels" isn't part of this submitted payload (only - // gatewayConfig.config is), and the default clearMissing=true would otherwise call - // submit(null) on it regardless - wiping the Collection set via createRootForm() before - // the currency-check listener ever runs, even though it's not disabled. - $form->submit([ - 'gatewayConfig' => [ - 'config' => [ - PayPlugGatewayFactory::ONE_CLICK => false, - PayPlugGatewayFactory::DEFERRED_CAPTURE => false, - PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_INTEGRATED_PAYMENT, - PayPlugGatewayFactory::HF_IDENTIFIER => '', - ], - ], - ], false); - - self::assertFalse($form->isValid(), 'Form must be invalid when integrated_payment is selected but an associated channel is not EUR.'); - - // The error is added to the specific channel's own child sub-form (mirroring the real - // `channels` field being `multiple => true, expanded => true`, one child per channel), - // not directly to the "channels" form itself. - $channelErrors = $form->get('channels')->get('0')->getErrors(); - self::assertCount(1, $channelErrors); - self::assertSame( - 'payplug_sylius_payplug_plugin.form.integrated_payment_currency_incompatible', - $channelErrors[0]->getMessage(), - ); - } - - /** - * The same non-EUR channel must NOT be rejected for hosted_fields or redirected mode — only - * integrated_payment requires every associated channel to be EUR. - */ - public function testSubmit_hostedFieldsModeWithNonEurChannel_isValid(): void - { - $form = $this->createRootForm($this->buildChannels(['USD'])); - - $form->submit([ - 'gatewayConfig' => [ - 'config' => [ - PayPlugGatewayFactory::ONE_CLICK => false, - PayPlugGatewayFactory::DEFERRED_CAPTURE => false, - PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS, - PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_123', - ], - ], - ], false); - - self::assertTrue($form->isValid()); - } - - /** - * @param list $currencyCodes - * - * @return ArrayCollection - */ - private function buildChannels(array $currencyCodes): ArrayCollection - { - $channels = []; - foreach ($currencyCodes as $index => $currencyCode) { - $currency = $this->createMock(CurrencyInterface::class); - $currency->method('getCode')->willReturn($currencyCode); - - /** @var ChannelInterface&MockObject $channel */ - $channel = $this->createMock(ChannelInterface::class); - $channel->method('getCode')->willReturn('channel_' . $index); - $channel->method('getBaseCurrency')->willReturn($currency); - - $channels[] = $channel; - } - - return new ArrayCollection($channels); - } - /** * Builds a minimal but realistic 3-level tree: root (the PaymentMethod form, exposing * "channels") -> gatewayConfig -> config (PayPlugGatewayConfigurationType, the type under - * test). This mirrors production nesting closely enough to exercise - * AbstractGatewayConfigurationType's inherited PRE_SUBMIT listener (which the extended type - * still carries) without it fatal-erroring on missing parents. - * - * @param ArrayCollection|null $channels Real channel data for the - * "channels" field, needed by - * tests exercising the currency - * check. Left null (an unset, - * non-Collection field) for tests - * that don't care about it. + * test). */ - private function createRootForm(?ArrayCollection $channels = null): \Symfony\Component\Form\FormInterface + private function createRootForm(): \Symfony\Component\Form\FormInterface { $paymentMethod = new class() { public function getId(): ?int { - // Non-null so AbstractGatewayConfigurationType::checkCreationRequirements() - // short-circuits without needing a configured gatewayConfigRepository. return 1; } }; $root = $this->factory->createBuilder(FormType::class, $paymentMethod, ['data_class' => null]); - if (null !== $channels) { - // A bare FormType (no data_class) round-trips setData()/getData() untouched - unlike - // TextType, it has no model-to-view transformer that would choke on a Collection. It - // needs one child per channel, named by its collection key, because the production - // currency-check listener does `$formChannels->get((string) $key)->addError(...)` - - // mirroring the real `channels` field being a `multiple => true, expanded => true` - // ChoiceType, which creates one child sub-form per choice - and, critically, sets - // `error_bubbling => false` on those children (ChoiceType.php), unlike a bare - // FormType's default of bubbling errors up to its parent when compound. Without this, - // addError() on a channel's sub-form bubbles all the way to the root instead of - // staying on that sub-form - purely a test-double mismatch, not a production concern. - // NOTE: this field is NOT `disabled => true` - Form::isValid() unconditionally returns - // true for a disabled form regardless of its errors, and Form::getErrors(true) skips - // any child that isSubmitted() && isValid() when aggregating - together those two - // rules mean a disabled "channels" would make the whole root form always report valid - // no matter what error is added deep inside it. Its pre-set data survives submission - // instead via `$form->submit($data, false)` (clearMissing=false) at the call site, - // which is not disabled but also isn't reset by an absent key. - $channelsBuilder = $root->create('channels', FormType::class, [ - 'mapped' => false, - 'data_class' => null, - ]); - foreach ($channels as $key => $channel) { - $channelsBuilder->add((string) $key, FormType::class, [ - 'mapped' => false, - 'data_class' => null, - 'error_bubbling' => false, - ]); - } - $root->add($channelsBuilder); - } else { - $root->add('channels', TextType::class, ['mapped' => false]); - } + $root->add('channels', TextType::class, ['mapped' => false]); $gatewayConfig = $root->create('gatewayConfig', FormType::class, ['mapped' => false]); $gatewayConfig->add('config', PayPlugGatewayConfigurationType::class); $root->add($gatewayConfig); - $form = $root->getForm(); - if (null !== $channels) { - // Force the root's own lazy defaultDataSet initialization (and its mapDataToForms - // cascade, which would otherwise reset the unmapped "channels" field to null the - // first time anything touches this form) to run now, BEFORE setting "channels"'s - // real data below - so our setData() call is the last word, not overwritten by it. - $form->getData(); - $form->get('channels')->setData($channels); - } - - return $form; + return $root->getForm(); } } diff --git a/tests/PHPUnit/Gateway/Form/Extension/PaymentMethodTypeExtensionFormSubmissionTest.php b/tests/PHPUnit/Gateway/Form/Extension/PaymentMethodTypeExtensionFormSubmissionTest.php new file mode 100644 index 00000000..b470fe11 --- /dev/null +++ b/tests/PHPUnit/Gateway/Form/Extension/PaymentMethodTypeExtensionFormSubmissionTest.php @@ -0,0 +1,280 @@ +add('channels', ChannelChoiceType::class, $alreadyResolvedOptions + [...])` + * survives a *second* trip through ChannelChoiceType's real OptionsResolver and its normalizers; + * 2. that an error added to the `channels` child stays there instead of bubbling to the root + * (ChoiceType sets `error_bubbling => false`, unlike a compound FormType); + * 3. that `Form::add()` from inside POST_SET_DATA really re-maps the model data into the replaced + * child, rather than leaving it empty. + * + * All three are facts about Symfony, not about this plugin, so they are exactly the kind of thing + * that changes under a minor bump without any of our own code moving. + * + * The root form here is assembled by hand rather than through Sylius' own PaymentMethodType, whose + * constructor drags in the translations tree, GatewayConfigType and two generators. What it does + * reproduce faithfully is the shape this extension reaches into: a root carrying `enabled`, a real + * `channels` ChannelChoiceType and a `gatewayConfig.config` subtree, with the extension's listeners + * attached to the real root builder. + */ +final class PaymentMethodTypeExtensionFormSubmissionTest extends TypeTestCase +{ + private const CONFLICT_MESSAGE_ID = 'payplug_sylius_payplug_plugin.form.gateway_channel_conflict'; + + private ChannelInterface $channelFr; + + private ChannelInterface $channelIt; + + /** @var PaymentMethodRepositoryInterface&MockObject */ + private PaymentMethodRepositoryInterface $paymentMethodRepository; + + protected function setUp(): void + { + $this->channelFr = $this->buildChannel('WEB_FR', 'EUR'); + $this->channelIt = $this->buildChannel('WEB_IT', 'EUR'); + $this->paymentMethodRepository = $this->createMock(PaymentMethodRepositoryInterface::class); + + parent::setUp(); + } + + protected function getTypes(): array + { + $channelRepository = $this->createMock(RepositoryInterface::class); + $channelRepository->method('findAll')->willReturn([$this->channelFr, $this->channelIt]); + + return [ + new ChannelChoiceType($channelRepository), + new PayPlugGatewayConfigurationType($this->translator()), + ]; + } + + /** + * A channel another *enabled* CB gateway already holds must come back as a submit-time error on + * the `channels` field itself — not bubbled to the root, where the admin form would render it + * detached from the field that caused it. + */ + public function testSubmit_channelClaimedByAnotherEnabledGateway_isRejectedOnTheChannelsField(): void + { + $this->givenRivalGatewayHolding($this->channelFr, 'CB — France'); + + $form = $this->createRootForm($this->payPlugPaymentMethod()); + + $form->submit([ + 'enabled' => '1', + 'channels' => ['WEB_FR'], + ]); + + self::assertTrue($form->isSubmitted()); + self::assertFalse($form->isValid()); + + $channelsErrors = $form->get('channels')->getErrors(); + self::assertCount(1, $channelsErrors); + self::assertSame( + self::CONFLICT_MESSAGE_ID . '|WEB_FR|CB — France', + $channelsErrors[0]->getMessage(), + ); + + self::assertCount( + 0, + $form->getErrors(), + 'The conflict must stay on the channels field — ChoiceType does not bubble.', + ); + } + + /** + * The mirror case: a free channel submits cleanly. Guards against the rule firing on everything. + */ + public function testSubmit_channelHeldByNobody_isAccepted(): void + { + $this->givenRivalGatewayHolding($this->channelFr, 'CB — France'); + + $form = $this->createRootForm($this->payPlugPaymentMethod()); + + $form->submit([ + 'enabled' => '1', + 'channels' => ['WEB_IT'], + ]); + + self::assertTrue($form->isValid(), 'WEB_IT is held by nobody, so the submission must pass.'); + self::assertCount(0, $form->get('channels')->getErrors(true)); + } + + /** + * PRE-3629, through the real ChannelChoiceType: the replaced `channels` child must still render + * both choices — proving the resolved options survived the second pass through the resolver — + * with only the claimed one carrying `disabled` and a `title` naming the claiming method. + */ + public function testSetData_channelClaimedByAnotherEnabledGateway_isRenderedDisabled(): void + { + $this->givenRivalGatewayHolding($this->channelIt, 'CB — Italy'); + + $form = $this->createRootForm($this->payPlugPaymentMethod()); + + $channelChoices = $form->get('channels')->createView()->children; + + self::assertCount(2, $channelChoices, 'Both channels must still be offered.'); + + $byValue = []; + foreach ($channelChoices as $choiceView) { + $byValue[$choiceView->vars['value']] = $choiceView->vars['attr']; + } + + self::assertArrayNotHasKey('disabled', $byValue['WEB_FR'], 'WEB_FR is free and must stay selectable.'); + self::assertTrue($byValue['WEB_IT']['disabled'] ?? false, 'WEB_IT is claimed and must be disabled.'); + self::assertSame( + self::CONFLICT_MESSAGE_ID . '|WEB_IT|CB — Italy', + $byValue['WEB_IT']['title'] ?? null, + ); + } + + /** + * Channels the edited method already holds stay selectable even when the checker would report + * them: browsers do not submit disabled checkboxes, so disabling a *checked* one would silently + * drop the channel on save. This is the render-side half of that rule, exercised against the + * real ChoiceType rather than a stub. + */ + public function testSetData_channelAlreadyHeldByTheEditedMethod_staysSelectable(): void + { + $subject = $this->payPlugPaymentMethod(); + $subject->addChannel($this->channelFr); + + $this->givenRivalGatewayHolding($this->channelFr, 'CB — France'); + + $form = $this->createRootForm($subject); + + foreach ($form->get('channels')->createView()->children as $choiceView) { + if ('WEB_FR' === $choiceView->vars['value']) { + self::assertArrayNotHasKey( + 'disabled', + $choiceView->vars['attr'], + 'A channel the edited method already holds must stay selectable.', + ); + } + } + } + + /** + * Rivals are what `findEnabledByGatewayName()` returns, so stubbing that one method is enough to + * drive the real GatewayChannelConflictChecker. + */ + private function givenRivalGatewayHolding(ChannelInterface $channel, string $name): void + { + $rival = $this->createMock(PaymentMethodInterface::class); + $rival->method('getId')->willReturn(99); + $rival->method('getName')->willReturn($name); + $rival->method('isEnabled')->willReturn(true); + $rival->method('getChannels')->willReturn(new \Doctrine\Common\Collections\ArrayCollection([$channel])); + + $this->paymentMethodRepository + ->method('findEnabledByGatewayName') + ->with(PayPlugGatewayFactory::FACTORY_NAME) + ->willReturn([$rival]); + } + + private function payPlugPaymentMethod(): PaymentMethod + { + $gatewayConfig = new GatewayConfig(); + $gatewayConfig->setFactoryName(PayPlugGatewayFactory::FACTORY_NAME); + $gatewayConfig->setGatewayName(PayPlugGatewayFactory::FACTORY_NAME); + // Redirected mode: neither display-mode flag set. The CB type only gates on base currency + // for integrated payment, so this keeps these tests about the channel-conflict rule alone. + $gatewayConfig->setConfig([ + PayPlugGatewayFactory::INTEGRATED_PAYMENT => false, + PayPlugGatewayFactory::HOSTED_FIELDS => false, + ]); + + $paymentMethod = new PaymentMethod(); + $paymentMethod->setCode('payplug_cb'); + $paymentMethod->setGatewayConfig($gatewayConfig); + + return $paymentMethod; + } + + /** + * The shape `PaymentMethodTypeExtension` reaches into: `enabled`, a real `channels` + * ChannelChoiceType, and `gatewayConfig.config` — with the extension's own listeners attached + * to the real root builder, so they run through Symfony's real event dispatch. + */ + private function createRootForm(PaymentMethod $paymentMethod): FormInterface + { + $extension = new PaymentMethodTypeExtension( + new GatewayChannelConflictChecker($this->paymentMethodRepository), + $this->translator(), + ); + + $builder = $this->factory->createBuilder(FormType::class, $paymentMethod, [ + 'data_class' => PaymentMethod::class, + ]); + + $builder->add('enabled', CheckboxType::class, ['required' => false]); + $builder->add('channels', ChannelChoiceType::class, [ + 'multiple' => true, + 'expanded' => true, + ]); + + $gatewayConfig = $builder->create('gatewayConfig', FormType::class, ['mapped' => false]); + $gatewayConfig->add('config', PayPlugGatewayConfigurationType::class, ['mapped' => false]); + $builder->add($gatewayConfig); + + $extension->buildForm($builder, []); + + return $builder->getForm(); + } + + private function buildChannel(string $code, string $currencyCode): ChannelInterface + { + $currency = new Currency(); + $currency->setCode($currencyCode); + + $channel = new Channel(); + $channel->setCode($code); + $channel->setName($code); + $channel->setBaseCurrency($currency); + + return $channel; + } + + private function translator(): TranslatorInterface + { + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->willReturnCallback( + static fn (string $id, array $parameters = []): string => \implode( + '|', + [$id, ...\array_values($parameters)], + ), + ); + + return $translator; + } +} diff --git a/tests/PHPUnit/Gateway/Form/Extension/PaymentMethodTypeExtensionTest.php b/tests/PHPUnit/Gateway/Form/Extension/PaymentMethodTypeExtensionTest.php new file mode 100644 index 00000000..482a02ec --- /dev/null +++ b/tests/PHPUnit/Gateway/Form/Extension/PaymentMethodTypeExtensionTest.php @@ -0,0 +1,352 @@ +translator = $this->createMock(TranslatorInterface::class); + $this->translator->method('trans')->willReturnCallback(static fn (string $id) => $id); + } + + /** + * PRE-3553: Integrated Payment is EUR-only, and the error must land on the `channels` field. + */ + public function testPostSubmit_integratedPaymentOnNonEurChannel_addsCurrencyErrorOnChannels(): void + { + self::assertSame( + ['payplug_sylius_payplug_plugin.form.integrated_payment_currency_incompatible'], + $this->submitPayPlugPaymentMethod([PayPlugGatewayFactory::INTEGRATED_PAYMENT => true], 'USD'), + ); + } + + public function testPostSubmit_integratedPaymentOnEuroChannel_addsNoError(): void + { + self::assertSame( + [], + $this->submitPayPlugPaymentMethod([PayPlugGatewayFactory::INTEGRATED_PAYMENT => true], 'EUR'), + ); + } + + /** + * The CB gate narrows the check to Integrated Payment: Hosted Fields works in any currency. + */ + public function testPostSubmit_hostedFieldsOnNonEurChannel_addsNoError(): void + { + self::assertSame( + [], + $this->submitPayPlugPaymentMethod([PayPlugGatewayFactory::HOSTED_FIELDS => true], 'USD'), + ); + } + + /** + * This extension must target the base `PaymentMethodType`, not the AdminBundle subtype, so that + * the AdminBundle form (which extends the base type) inherits the listener too. + */ + public function testGetExtendedTypes_returnsPaymentMethodType(): void + { + self::assertSame([PaymentMethodType::class], PaymentMethodTypeExtension::getExtendedTypes()); + } + + /** + * PRE-3629: a channel held by another *enabled* CB gateway renders as an unselectable choice, + * with the same wording the POST_SUBMIT rule would have produced had it been submitted. + */ + public function testPostSetData_channelClaimedByAnotherEnabledGateway_isRenderedDisabled(): void + { + $choiceAttr = $this->rebuiltChannelsOptions()['choice_attr']; + + self::assertSame( + [ + 'disabled' => true, + 'title' => 'payplug_sylius_payplug_plugin.form.gateway_channel_conflict|WEB_FR|CB 1', + ], + $choiceAttr($this->channel('WEB_FR')), + ); + } + + public function testPostSetData_unclaimedChannel_staysSelectable(): void + { + $choiceAttr = $this->rebuiltChannelsOptions()['choice_attr']; + + self::assertSame([], $choiceAttr($this->channel('WEB_IT'))); + } + + /** + * The field is replaced rather than configured in place, so everything Sylius put on it — + * `multiple`, `expanded`, its label — has to survive the round trip. + */ + public function testPostSetData_rebuiltChannelsField_keepsItsOriginalOptions(): void + { + $options = $this->rebuiltChannelsOptions(); + + self::assertTrue($options['multiple']); + self::assertTrue($options['expanded']); + self::assertSame('sylius.form.payment_method.channels', $options['label']); + } + + /** + * Payment methods belonging to other plugins share this form; their channel picker is none of + * our business. + */ + public function testPostSetData_paymentMethodIsNotOneOfOurs_leavesTheChannelsFieldUntouched(): void + { + self::assertNull($this->runPostSetData(new \stdClass())); + } + + /** + * Runs the extension's POST_SET_DATA listener over a CB payment method, against a rival CB + * gateway that is enabled and holds `WEB_FR`. + * + * @return array the options handed to `add('channels', ...)` + */ + private function rebuiltChannelsOptions(): array + { + $captured = $this->runPostSetData($this->payPlugPaymentMethod()); + + self::assertIsArray($captured); + self::assertSame('channels', $captured['name']); + self::assertSame(ChannelChoiceType::class, $captured['type']); + self::assertIsCallable($captured['options']['choice_attr']); + + return $captured['options']; + } + + /** + * @return array{name: string, type: string, options: array}|null + * the `add()` call the listener made, or null when it left the form alone + */ + private function runPostSetData(mixed $data): ?array + { + $rival = $this->createMock(PaymentMethodInterface::class); + $rival->method('getId')->willReturn(7); + $rival->method('isEnabled')->willReturn(true); + $rival->method('getName')->willReturn('CB 1'); + $rival->method('getChannels')->willReturn(new ArrayCollection([$this->channel('WEB_FR')])); + + $repository = $this->createMock(PaymentMethodRepositoryInterface::class); + $repository->method('findEnabledByGatewayName')->willReturn([$rival]); + + $captured = null; + $form = $this->buildRootForm( + $this->channelsForm(), + function (string $name, string $type, array $options) use (&$captured): void { + $captured = ['name' => $name, 'type' => $type, 'options' => $options]; + }, + ); + + // Renders the parameters into the result so the assertions can pin *which* channel and + // which rival payment method the tooltip names, not just that some message was translated. + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->willReturnCallback( + static fn (string $id, array $parameters = []): string => \implode( + '|', + [$id, ...\array_values($parameters)], + ), + ); + + ($this->captureListener(FormEvents::POST_SET_DATA, $repository, $translator))(new FormEvent($form, $data)); + + return $captured; + } + + /** + * Stand-in for the `channels` field Sylius' own CoreBundle extension added, carrying the + * resolved options the listener has to copy forward. + */ + private function channelsForm(): FormInterface + { + $formConfig = $this->createMock(FormConfigInterface::class); + $formConfig->method('getOptions')->willReturn([ + 'multiple' => true, + 'expanded' => true, + 'label' => 'sylius.form.payment_method.channels', + 'choice_attr' => null, + ]); + + $channelsForm = $this->createMock(FormInterface::class); + $channelsForm->method('getConfig')->willReturn($formConfig); + + return $channelsForm; + } + + private function payPlugPaymentMethod(): PaymentMethodInterface + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getFactoryName')->willReturn(PayPlugGatewayFactory::FACTORY_NAME); + $gatewayConfig->method('getConfig')->willReturn([]); + + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $paymentMethod->method('getId')->willReturn(null); + $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig); + $paymentMethod->method('getChannels')->willReturn(new ArrayCollection()); + + return $paymentMethod; + } + + private function channel(string $code): ChannelInterface + { + $channel = $this->createMock(ChannelInterface::class); + $channel->method('getCode')->willReturn($code); + + return $channel; + } + + /** + * Runs the extension's POST_SUBMIT listener over a CB payment method carrying a single channel. + * + * @param array $mappedGatewayConfig as returned by GatewayConfigInterface::getConfig() + * + * @return list messages of the FormErrors added to the `channels` child + */ + private function submitPayPlugPaymentMethod(array $mappedGatewayConfig, string $baseCurrencyCode): array + { + $currency = $this->createMock(CurrencyInterface::class); + $currency->method('getCode')->willReturn($baseCurrencyCode); + + $channel = $this->createMock(ChannelInterface::class); + $channel->method('getCode')->willReturn('channel_code'); + $channel->method('getBaseCurrency')->willReturn($currency); + + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getFactoryName')->willReturn(PayPlugGatewayFactory::FACTORY_NAME); + $gatewayConfig->method('getConfig')->willReturn($mappedGatewayConfig); + + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig); + $paymentMethod->method('getChannels')->willReturn(new ArrayCollection([$channel])); + + $errors = []; + $channelsForm = $this->createMock(FormInterface::class); + $channelsForm->method('addError')->willReturnCallback( + function (FormError $error) use (&$errors, $channelsForm): FormInterface { + $errors[] = $error->getMessage(); + + return $channelsForm; + }, + ); + + $repository = $this->createMock(PaymentMethodRepositoryInterface::class); + $repository->method('findEnabledByGatewayName')->willReturn([]); + + ($this->captureListener(FormEvents::POST_SUBMIT, $repository))( + new FormEvent($this->buildRootForm($channelsForm), $paymentMethod), + ); + + return $errors; + } + + /** + * Minimal stand-in for the `paymentMethod` → `gatewayConfig` → `config` form tree the extension + * walks to resolve the gateway's configuration type. + */ + private function buildRootForm(FormInterface $channelsForm, ?callable $onAdd = null): FormInterface + { + $resolvedType = $this->createMock(ResolvedFormTypeInterface::class); + $resolvedType->method('getInnerType')->willReturn(new PayPlugGatewayConfigurationType($this->translator)); + + $formConfig = $this->createMock(FormConfigInterface::class); + $formConfig->method('getType')->willReturn($resolvedType); + + $configForm = $this->createMock(FormInterface::class); + $configForm->method('getConfig')->willReturn($formConfig); + + $gatewayConfigForm = $this->createMock(FormInterface::class); + $gatewayConfigForm->method('has')->willReturnCallback(static fn (string $name): bool => 'config' === $name); + $gatewayConfigForm->method('get')->willReturn($configForm); + + $form = $this->createMock(FormInterface::class); + $form->method('has')->willReturnCallback( + static fn (string $name): bool => \in_array($name, ['gatewayConfig', 'channels'], true), + ); + $form->method('get')->willReturnCallback( + static fn (string $name): FormInterface => 'channels' === $name ? $channelsForm : $gatewayConfigForm, + ); + $form->method('add')->willReturnCallback( + function (string $name, string $type, array $options) use ($onAdd, $form): FormInterface { + if (null !== $onAdd) { + $onAdd($name, $type, $options); + } + + return $form; + }, + ); + + return $form; + } + + private function captureListener( + string $wantedEventName, + PaymentMethodRepositoryInterface $repository, + ?TranslatorInterface $translator = null, + ): callable { + $extension = new PaymentMethodTypeExtension( + new GatewayChannelConflictChecker($repository), + $translator ?? $this->translator, + ); + + $listener = null; + $builder = $this->createMock(FormBuilderInterface::class); + $builder->method('addEventListener')->willReturnCallback( + function (string $eventName, callable $callback) use ( + $wantedEventName, + &$listener, + $builder, + ): FormBuilderInterface { + if ($wantedEventName === $eventName) { + $listener = $callback; + } + + return $builder; + }, + ); + + $extension->buildForm($builder, []); + + self::assertIsCallable($listener); + + return $listener; + } +} diff --git a/tests/PHPUnit/Gateway/Form/Type/AbstractGatewayConfigurationTypeTest.php b/tests/PHPUnit/Gateway/Form/Type/AbstractGatewayConfigurationTypeTest.php index 619aa70a..1f8eb048 100644 --- a/tests/PHPUnit/Gateway/Form/Type/AbstractGatewayConfigurationTypeTest.php +++ b/tests/PHPUnit/Gateway/Form/Type/AbstractGatewayConfigurationTypeTest.php @@ -5,99 +5,43 @@ namespace Tests\PayPlug\SyliusPayPlugPlugin\PHPUnit\Gateway\Form\Type; use PayPlug\SyliusPayPlugPlugin\Gateway\Form\Type\AbstractGatewayConfigurationType; -use PayPlug\SyliusPayPlugPlugin\Gateway\OneyGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\Gateway\Form\Type\PayPlugGatewayConfigurationType; +use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Sylius\Bundle\PayumBundle\Model\GatewayConfigInterface; use Sylius\Component\Core\Model\ChannelInterface; -use Sylius\Component\Resource\Repository\RepositoryInterface; -use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Contracts\Translation\TranslatorInterface; /** - * Covers the one-payment-method-per-gateway-factory rule enforced by canBeCreated(). + * Covers the hooks `AbstractGatewayConfigurationType` exposes to its per-gateway subtypes. * - * canBeCreated() is private and only reachable through the form PRE_SUBMIT listener, which would - * require mocking a full three-level form tree; it is therefore invoked here via reflection. + * The per-channel uniqueness rule they used to sit next to now lives in + * `GatewayChannelConflictChecker` and `PaymentMethodTypeExtension`. */ final class AbstractGatewayConfigurationTypeTest extends TestCase { - private RepositoryInterface&MockObject $gatewayConfigRepository; - private TranslatorInterface&MockObject $translator; private AbstractGatewayConfigurationType $type; protected function setUp(): void { - $this->gatewayConfigRepository = $this->createMock(RepositoryInterface::class); $this->translator = $this->createMock(TranslatorInterface::class); $this->translator->method('trans')->willReturnCallback(static fn (string $id) => $id); $this->type = new AbstractGatewayConfigurationType( $this->translator, - $this->gatewayConfigRepository, - $this->createMock(RequestStack::class), ); } - /** - * Every PayPlug-family factory, including `payplug` itself, is limited to one PaymentMethod. - */ - public function testCanBeCreated_otherFactoryAlreadyConfigured_isRefused(): void - { - $this->gatewayConfigRepository - ->expects(self::once()) - ->method('findOneBy') - ->with(['factoryName' => OneyGatewayFactory::FACTORY_NAME]) - ->willReturn($this->createMock(GatewayConfigInterface::class)) - ; - - self::assertFalse($this->canBeCreated(OneyGatewayFactory::FACTORY_NAME)); - } - - public function testCanBeCreated_otherFactoryNotYetConfigured_isAllowed(): void - { - $this->gatewayConfigRepository - ->expects(self::once()) - ->method('findOneBy') - ->with(['factoryName' => OneyGatewayFactory::FACTORY_NAME]) - ->willReturn(null) - ; - - self::assertTrue($this->canBeCreated(OneyGatewayFactory::FACTORY_NAME)); - } - - private function canBeCreated(string $factoryName): bool - { - $method = new \ReflectionMethod(AbstractGatewayConfigurationType::class, 'canBeCreated'); - $method->setAccessible(true); - - /** @var bool $result */ - $result = $method->invoke($this->type, $factoryName); - - return $result; - } - /** * Default hook implementation: every gateway subtype that doesn't override it keeps * today's behavior of always enforcing the base currency. */ public function testShouldValidateBaseCurrency_defaultImplementation_alwaysReturnsTrue(): void { - self::assertTrue($this->shouldValidateBaseCurrency([])); - self::assertTrue($this->shouldValidateBaseCurrency(['anything' => 'irrelevant'])); - } - - private function shouldValidateBaseCurrency(array $data): bool - { - $method = new \ReflectionMethod(AbstractGatewayConfigurationType::class, 'shouldValidateBaseCurrency'); - $method->setAccessible(true); - - /** @var bool $result */ - $result = $method->invoke($this->type, $data); - - return $result; + self::assertTrue($this->type->shouldValidateBaseCurrency([])); + self::assertTrue($this->type->shouldValidateBaseCurrency(['anything' => 'irrelevant'])); } /** @@ -112,18 +56,35 @@ public function testBaseCurrencyViolationMessage_defaultImplementation_returnsGe self::assertSame( 'payplug_sylius_payplug_plugin.form.base_currency_not_euro', - $this->baseCurrencyViolationMessage($channel), + $this->type->baseCurrencyViolationMessage($channel), ); } - private function baseCurrencyViolationMessage(ChannelInterface $channel): string + public function testGetBaseCurrencyCode_defaultImplementation_isEuro(): void { - $method = new \ReflectionMethod(AbstractGatewayConfigurationType::class, 'baseCurrencyViolationMessage'); - $method->setAccessible(true); + self::assertSame('EUR', $this->type->getBaseCurrencyCode()); + } - /** @var string $result */ - $result = $method->invoke($this->type, $channel); + /** + * The only subtype that narrows the hook: Integrated Payment is the only display mode that + * requires every associated channel to be EUR. + * + * Keyed by the persisted `integratedPayment`/`hostedFields` booleans, which is what the caller + * passes (`GatewayConfigInterface::getConfig()`); the `hostedFieldsMode` form field is unmapped + * and never appears in that array. + */ + public function testShouldValidateBaseCurrency_payPlugType_onlyAppliesToIntegratedPayment(): void + { + $type = new PayPlugGatewayConfigurationType( + $this->translator, + ); - return $result; + self::assertTrue($type->shouldValidateBaseCurrency([ + PayPlugGatewayFactory::INTEGRATED_PAYMENT => true, + ])); + self::assertFalse($type->shouldValidateBaseCurrency([ + PayPlugGatewayFactory::HOSTED_FIELDS => true, + ])); + self::assertFalse($type->shouldValidateBaseCurrency([])); } } diff --git a/tests/PHPUnit/Gateway/Form/Type/PayPlugGatewayConfigurationTypeTest.php b/tests/PHPUnit/Gateway/Form/Type/PayPlugGatewayConfigurationTypeTest.php index 882ac793..8edacc1e 100644 --- a/tests/PHPUnit/Gateway/Form/Type/PayPlugGatewayConfigurationTypeTest.php +++ b/tests/PHPUnit/Gateway/Form/Type/PayPlugGatewayConfigurationTypeTest.php @@ -9,8 +9,6 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Sylius\Component\Core\Model\ChannelInterface; -use Sylius\Component\Resource\Repository\RepositoryInterface; -use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Contracts\Translation\TranslatorInterface; final class PayPlugGatewayConfigurationTypeTest extends TestCase @@ -26,22 +24,26 @@ protected function setUp(): void $this->type = new PayPlugGatewayConfigurationType( $this->translator, - $this->createMock(RepositoryInterface::class), - $this->createMock(RequestStack::class), ); } + /** + * The arrays below are keyed by the persisted `integratedPayment`/`hostedFields` booleans + * written by `PayPlugGatewayFactory::resolveDisplayModeFlags()`, i.e. the shape the production + * caller hands the hook (`GatewayConfigInterface::getConfig()`). The `hostedFieldsMode` form + * field is unmapped and never reaches the persisted config. + */ public function testShouldValidateBaseCurrency_integratedPaymentSelected_returnsTrue(): void { self::assertTrue($this->shouldValidateBaseCurrency([ - PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_INTEGRATED_PAYMENT, + PayPlugGatewayFactory::INTEGRATED_PAYMENT => true, ])); } public function testShouldValidateBaseCurrency_hostedFieldsSelected_returnsFalse(): void { self::assertFalse($this->shouldValidateBaseCurrency([ - PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS, + PayPlugGatewayFactory::HOSTED_FIELDS => true, ])); } diff --git a/tests/PHPUnit/Gateway/Validator/Constraints/HasNoGatewayChannelConflictValidatorTest.php b/tests/PHPUnit/Gateway/Validator/Constraints/HasNoGatewayChannelConflictValidatorTest.php new file mode 100644 index 00000000..811add4e --- /dev/null +++ b/tests/PHPUnit/Gateway/Validator/Constraints/HasNoGatewayChannelConflictValidatorTest.php @@ -0,0 +1,144 @@ +paymentMethodRepository = $this->createMock(PaymentMethodRepositoryInterface::class); + + return new HasNoGatewayChannelConflictValidator( + new GatewayChannelConflictChecker($this->paymentMethodRepository), + ); + } + + /** + * The point of the constraint: the same rule the admin form enforces, reached through + * PaymentMethodValidator, so a resource-controller or API write cannot leave two enabled + * gateways of one factory sharing a channel. + */ + public function testValidate_channelClaimedByAnotherEnabledGateway_addsAViolationOnChannels(): void + { + $channel = $this->channel('WEB_FR'); + + $this->givenEnabledGateways([ + $this->paymentMethod(id: 7, channels: [$channel], name: 'CB — France'), + ]); + + $this->validator->validate( + $this->paymentMethod(id: 8, channels: [$channel]), + new HasNoGatewayChannelConflict(), + ); + + $this->buildViolation('payplug_sylius_payplug_plugin.form.gateway_channel_conflict') + ->setParameter('%channel%', 'WEB_FR') + ->setParameter('%payment_method%', 'CB — France') + ->atPath('property.path.channels') + ->assertRaised(); + } + + public function testValidate_disjointChannels_addsNoViolation(): void + { + $this->givenEnabledGateways([ + $this->paymentMethod(id: 7, channels: [$this->channel('WEB_FR')], name: 'CB — France'), + ]); + + $this->validator->validate( + $this->paymentMethod(id: 8, channels: [$this->channel('WEB_IT')]), + new HasNoGatewayChannelConflict(), + ); + + $this->assertNoViolation(); + } + + /** + * A disabled subject claims nothing — that is what makes logging a gateway out release its + * channels for another config (PRE-3632). The repository must not even be consulted. + */ + public function testValidate_disabledSubject_isNotEvenChecked(): void + { + $this->paymentMethodRepository->expects(self::never())->method('findEnabledByGatewayName'); + + $this->validator->validate( + $this->paymentMethod(id: 8, channels: [$this->channel('WEB_FR')], enabled: false), + new HasNoGatewayChannelConflict(), + ); + + $this->assertNoViolation(); + } + + public function testValidate_aValueThatIsNotAPaymentMethod_addsNoViolation(): void + { + $this->validator->validate('not a payment method', new HasNoGatewayChannelConflict()); + + $this->assertNoViolation(); + } + + public function testValidate_theWrongConstraint_throws(): void + { + $this->expectException(UnexpectedTypeException::class); + + $this->validator->validate($this->paymentMethod(id: 8, channels: []), new NotBlank()); + } + + /** + * @param list $paymentMethods + */ + private function givenEnabledGateways(array $paymentMethods): void + { + $this->paymentMethodRepository + ->method('findEnabledByGatewayName') + ->with(PayPlugGatewayFactory::FACTORY_NAME) + ->willReturn($paymentMethods); + } + + /** + * @param list $channels + */ + private function paymentMethod( + int $id, + array $channels, + string $name = 'CB', + bool $enabled = true, + ): PaymentMethodInterface&MockObject { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getFactoryName')->willReturn(PayPlugGatewayFactory::FACTORY_NAME); + + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $paymentMethod->method('getId')->willReturn($id); + $paymentMethod->method('getName')->willReturn($name); + $paymentMethod->method('isEnabled')->willReturn($enabled); + $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig); + $paymentMethod->method('getChannels')->willReturn(new ArrayCollection($channels)); + + return $paymentMethod; + } + + private function channel(string $code): ChannelInterface&MockObject + { + $channel = $this->createMock(ChannelInterface::class); + $channel->method('getCode')->willReturn($code); + + return $channel; + } +} diff --git a/tests/PHPUnit/Handler/HostedFieldsWebhookNotificationHandlerTest.php b/tests/PHPUnit/Handler/HostedFieldsWebhookNotificationHandlerTest.php index 6a222db4..44855b18 100644 --- a/tests/PHPUnit/Handler/HostedFieldsWebhookNotificationHandlerTest.php +++ b/tests/PHPUnit/Handler/HostedFieldsWebhookNotificationHandlerTest.php @@ -8,7 +8,7 @@ use PayPlug\SyliusPayPlugPlugin\Entity\Card; use PayPlug\SyliusPayPlugPlugin\Handler\HostedFieldsWebhookNotificationHandler; use PayPlug\SyliusPayPlugPlugin\Upc\PayplugCardPersister; -use PayplugUnifiedCore\Contracts\IConfigurationRepository; +use PayPlug\SyliusPayPlugPlugin\Upc\ScopedConfigurationRepositoryInterface; use PayplugUnifiedCore\Contracts\ILock; use PayplugUnifiedCore\Contracts\IOrderStateMutator; use PayplugUnifiedCore\Contracts\IPaymentRepository; @@ -30,7 +30,7 @@ final class HostedFieldsWebhookNotificationHandlerTest extends TestCase private IOrderStateMutator&MockObject $orderStateMutator; - private IConfigurationRepository&MockObject $configurationRepository; + private ScopedConfigurationRepositoryInterface&MockObject $configurationRepository; private ILock&MockObject $lock; @@ -48,7 +48,9 @@ protected function setUp(): void { $this->paymentRepository = $this->createMock(IPaymentRepository::class); $this->orderStateMutator = $this->createMock(IOrderStateMutator::class); - $this->configurationRepository = $this->createMock(IConfigurationRepository::class); + $this->configurationRepository = $this->createMock(ScopedConfigurationRepositoryInterface::class); + // The handler scopes before reading; these tests stub one account, so it scopes to itself. + $this->configurationRepository->method('forPaymentMethod')->willReturnSelf(); $this->lock = $this->createMock(ILock::class); $this->lock->method('acquire')->willReturn(true); $this->logger = $this->createMock(LoggerInterface::class); @@ -81,7 +83,9 @@ private function payment( $payment->method('getId')->willReturn($id); $payment->method('getAmount')->willReturn($amount); $payment->method('getDetails')->willReturn($details); - $payment->method('getMethod')->willReturn($method); + // A UHF payment always has a method — treat() resolves the webhook secret from its gateway + // config — so the tests that don't care which one still get one. + $payment->method('getMethod')->willReturn($method ?? $this->createMock(PaymentMethodInterface::class)); if (null !== $orderNumber || null !== $customer) { $order = $this->createMock(OrderInterface::class); diff --git a/tests/PHPUnit/Provider/SupportedMethodsProviderTest.php b/tests/PHPUnit/Provider/SupportedMethodsProviderTest.php index d208c8f3..bec3d0e7 100644 --- a/tests/PHPUnit/Provider/SupportedMethodsProviderTest.php +++ b/tests/PHPUnit/Provider/SupportedMethodsProviderTest.php @@ -28,13 +28,23 @@ final class SupportedMethodsProviderTest extends TestCase private SupportedMethodsProvider $provider; + /** @var \SplObjectStorage */ + private \SplObjectStorage $clientsByPaymentMethod; + protected function setUp(): void { $this->currencyContext = $this->createMock(CurrencyContextInterface::class); $this->clientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class); $this->apiClient = $this->createMock(PayPlugApiClientInterface::class); - $this->clientFactory->method('create')->willReturn($this->apiClient); + // Every payment method resolves to the shared client unless a test gives it one of its own + // through assignAccount(), which is how a second PayPlug account is modelled. + $this->clientsByPaymentMethod = new \SplObjectStorage(); + $this->clientFactory->method('createForPaymentMethod')->willReturnCallback( + fn (object $paymentMethod): PayPlugApiClientInterface => $this->clientsByPaymentMethod->contains($paymentMethod) + ? $this->clientsByPaymentMethod[$paymentMethod] + : $this->apiClient, + ); $this->provider = new SupportedMethodsProvider($this->currencyContext, $this->clientFactory, new AccountAmountRangeResolver(), new NullLogger()); } @@ -572,6 +582,77 @@ public function testProvide_withMalformedMerchantConfiguredAmounts_fallsBackToAp self::assertEmpty($result2); } + // ------------------------------------------------------------------------- + // provide() — one account per gateway config, not one per call + // ------------------------------------------------------------------------- + + /** + * Two enabled methods of the same factory sitting on different PayPlug accounts: the account + * resolved for the first must not decide the fate of the second. Here the first authorizes EUR + * and the second only USD, so an EUR checkout keeps the first and drops the second — sharing + * one lookup across the loop kept both. + */ + public function testProvide_withMethodsOnDifferentAccounts_filtersEachAgainstItsOwnCurrencies(): void + { + $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); + + $eurMethod = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME, configId: 1); + $usdMethod = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME, configId: 2); + + $this->assignAccount($eurMethod, $this->buildAccount(99, 2000000)); + $this->assignAccount($usdMethod, [ + 'configuration' => ['min_amounts' => ['USD' => 99], 'max_amounts' => ['USD' => 2000000]], + 'payment_methods' => [], + ]); + + $result = $this->provider->provide([$eurMethod, $usdMethod], PayPlugGatewayFactory::FACTORY_NAME, 1000); + + self::assertCount(1, $result); + self::assertSame($eurMethod, reset($result)); + } + + /** + * Same split, for the billing-country gate: each method is checked against its own account's + * allowed_countries. + */ + public function testProvide_withMethodsOnDifferentAccounts_filtersEachAgainstItsOwnAllowedCountries(): void + { + $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); + + $frMethod = $this->buildPaymentMethod('payplug_scalapay', configId: 1); + $deMethod = $this->buildPaymentMethod('payplug_scalapay', configId: 2); + + $this->assignAccount($frMethod, $this->buildScalapayAccount(['FR'])); + $this->assignAccount($deMethod, $this->buildScalapayAccount(['DE'])); + + $result = $this->provider->provide([$frMethod, $deMethod], 'payplug_scalapay', 1000, billingCountryCode: 'FR'); + + self::assertCount(1, $result); + self::assertSame($frMethod, reset($result)); + } + + /** + * The per-account lookup must stay memoized: two methods sharing one gateway config hit the + * `/account` endpoint once between them, not once each. + */ + public function testProvide_withMethodsSharingOneGatewayConfig_readsThatAccountOnce(): void + { + $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); + + $gatewayConfig = $this->buildGatewayConfig(PayPlugGatewayFactory::FACTORY_NAME, [], 1); + $first = $this->buildPaymentMethodFor($gatewayConfig); + $second = $this->buildPaymentMethodFor($gatewayConfig); + + $client = $this->createMock(PayPlugApiClientInterface::class); + $client->expects(self::once())->method('getAccount')->willReturn($this->buildAccount(99, 2000000)); + $this->clientsByPaymentMethod[$first] = $client; + $this->clientsByPaymentMethod[$second] = $client; + + $result = $this->provider->provide([$first, $second], PayPlugGatewayFactory::FACTORY_NAME, 1000); + + self::assertCount(2, $result); + } + // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- @@ -587,19 +668,69 @@ private function buildAccount(int $minAmount, int $maxAmount): array ]; } + /** + * @param list $allowedCountries + */ + private function buildScalapayAccount(array $allowedCountries): array + { + return [ + 'configuration' => ['min_amounts' => ['EUR' => 30], 'max_amounts' => ['EUR' => 2000000]], + 'payment_methods' => ['scalapay' => [ + 'min_amounts' => ['EUR' => 500], + 'max_amounts' => ['EUR' => 200000], + 'allowed_countries' => $allowedCountries, + ]], + ]; + } + /** * @param array $config Persisted gateway config; defaults to empty, which is * neither integrated_payment nor hosted_fields. */ - private function buildPaymentMethod(string $factoryName, array $config = []): PaymentMethodInterface + private function buildPaymentMethod( + string $factoryName, + array $config = [], + int|string|null $configId = null, + ): PaymentMethodInterface + { + return $this->buildPaymentMethodFor($this->buildGatewayConfig($factoryName, $config, $configId)); + } + + /** + * @param array $config + */ + private function buildGatewayConfig( + string $factoryName, + array $config = [], + int|string|null $configId = null, + ): GatewayConfigInterface { $gatewayConfig = $this->createMock(GatewayConfigInterface::class); $gatewayConfig->method('getFactoryName')->willReturn($factoryName); $gatewayConfig->method('getConfig')->willReturn($config); + $gatewayConfig->method('getId')->willReturn($configId); + + return $gatewayConfig; + } + private function buildPaymentMethodFor(GatewayConfigInterface $gatewayConfig): PaymentMethodInterface + { $method = $this->createMock(PaymentMethodInterface::class); $method->method('getGatewayConfig')->willReturn($gatewayConfig); return $method; } + + /** + * Puts $paymentMethod on a PayPlug account of its own, as a second configured gateway would be. + * + * @param array $account + */ + private function assignAccount(PaymentMethodInterface $paymentMethod, array $account): void + { + $client = $this->createMock(PayPlugApiClientInterface::class); + $client->method('getAccount')->willReturn($account); + + $this->clientsByPaymentMethod[$paymentMethod] = $client; + } } diff --git a/tests/PHPUnit/Support/FakePayplugHttpRequest.php b/tests/PHPUnit/Support/FakePayplugHttpRequest.php new file mode 100644 index 00000000..57e7d955 --- /dev/null +++ b/tests/PHPUnit/Support/FakePayplugHttpRequest.php @@ -0,0 +1,68 @@ + */ + private array $bodies; + + private int $callCount = 0; + + /** + * @param list $bodies raw response bodies, in the order the SDK will request them + */ + public function __construct(array $bodies, private int $httpStatus = 200) + { + $this->bodies = [] === $bodies ? ['{}'] : $bodies; + } + + public function setopt($option, $value) + { + return true; + } + + public function exec() + { + $body = $this->bodies[$this->callCount] ?? $this->bodies[\count($this->bodies) - 1]; + ++$this->callCount; + + return $body; + } + + public function getinfo($option) + { + return $this->httpStatus; + } + + public function close() + { + } + + public function error() + { + return ''; + } + + public function errno() + { + return 0; + } + + public function callCount(): int + { + return $this->callCount; + } +} diff --git a/tests/PHPUnit/Upc/SyliusUpcConfigurationRepositoryTest.php b/tests/PHPUnit/Upc/SyliusUpcConfigurationRepositoryTest.php index f905d4aa..578738fe 100644 --- a/tests/PHPUnit/Upc/SyliusUpcConfigurationRepositoryTest.php +++ b/tests/PHPUnit/Upc/SyliusUpcConfigurationRepositoryTest.php @@ -6,101 +6,189 @@ use Doctrine\ORM\EntityManagerInterface; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\Upc\ScopedConfigurationRepositoryInterface; use PayPlug\SyliusPayPlugPlugin\Upc\SyliusUpcConfigurationRepository; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Sylius\Component\Payment\Model\GatewayConfigInterface; -use Sylius\Component\Resource\Repository\RepositoryInterface; +use Sylius\Component\Payment\Model\PaymentMethodInterface; final class SyliusUpcConfigurationRepositoryTest extends TestCase { - private RepositoryInterface&MockObject $gatewayConfigRepository; - private EntityManagerInterface&MockObject $entityManager; private SyliusUpcConfigurationRepository $configurationRepository; protected function setUp(): void { - $this->gatewayConfigRepository = $this->createMock(RepositoryInterface::class); $this->entityManager = $this->createMock(EntityManagerInterface::class); - $this->configurationRepository = new SyliusUpcConfigurationRepository($this->gatewayConfigRepository, $this->entityManager); + $this->configurationRepository = new SyliusUpcConfigurationRepository($this->entityManager); } - private function gatewayConfigWith(array $config): GatewayConfigInterface&MockObject + private function gatewayConfig(array $config): GatewayConfigInterface&MockObject { $gatewayConfig = $this->createMock(GatewayConfigInterface::class); $gatewayConfig->method('getConfig')->willReturn($config); - $this->gatewayConfigRepository->method('findOneBy') - ->with(['factoryName' => PayPlugGatewayFactory::FACTORY_NAME]) - ->willReturn($gatewayConfig); return $gatewayConfig; } + /** A repository scoped to a config carrying $config — the only supported way to read anything. */ + private function scopedWith(array $config): ScopedConfigurationRepositoryInterface + { + return $this->configurationRepository->withGatewayConfig($this->gatewayConfig($config)); + } + + // ------------------------------------------------------------------------- + // Explicit scoping — withGatewayConfig() + // ------------------------------------------------------------------------- + + /** + * Since PRE-3628 several enabled CB gateway configs may exist, one per channel. The scoped + * repository reads the config it was handed; the factory-name lookup that used to resolve an + * arbitrary channel's account is gone, along with the gateway config repository it needed. + */ + public function testWithGatewayConfig_readsCredentialsFromTheScopedConfig(): void + { + $scoped = $this->configurationRepository->withGatewayConfig($this->gatewayConfig([ + 'live' => false, + 'test_client' => ['client_id' => 'de_id', 'client_secret' => 'de_secret'], + ])); + + self::assertSame('de_id', $scoped->getClientId()); + self::assertSame('de_secret', $scoped->getClientSecret()); + } + + /** + * The whole point of the ticket: two enabled CB configs on different channels, each resolving + * its own credentials. Also pins the wither as immutable — scoping for DE must not disturb the + * instance already scoped to FR, which is what makes the shared service safe to reuse. + */ + public function testWithGatewayConfig_withTwoChannelsSharingTheCbFactory_keepsEachScopeIndependent(): void + { + $fr = $this->configurationRepository->withGatewayConfig($this->gatewayConfig([ + 'live' => true, + 'live_client' => ['client_id' => 'fr_id', 'client_secret' => 'fr_secret'], + ])); + $de = $this->configurationRepository->withGatewayConfig($this->gatewayConfig([ + 'live' => true, + 'live_client' => ['client_id' => 'de_id', 'client_secret' => 'de_secret'], + ])); + + self::assertSame('fr_id', $fr->getClientId()); + self::assertSame('de_id', $de->getClientId()); + self::assertNotSame($fr, $de); + } + + /** + * Every consumer holds a PaymentMethodInterface rather than a bare gateway config, so this is + * the form they actually use; it exists to keep the null-check in one place instead of five. + */ + public function testForPaymentMethod_scopesToThatMethodsOwnGatewayConfig(): void + { + $method = $this->createMock(PaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn($this->gatewayConfig([ + 'live' => false, + 'test_client' => ['client_id' => 'fr_id', 'client_secret' => 'fr_secret'], + ])); + + self::assertSame('fr_id', $this->configurationRepository->forPaymentMethod($method)->getClientId()); + } + + public function testForPaymentMethod_whenTheMethodHasNoGatewayConfig_throws(): void + { + $method = $this->createMock(PaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn(null); + + $this->expectException(\LogicException::class); + + $this->configurationRepository->forPaymentMethod($method); + } + + /** + * A loud failure at an unscoped call site beats the previous silent wrong-account behaviour: + * any path that forgot to scope reports itself at runtime instead of signing with whichever + * config Doctrine returned first. + */ + public function testGetClientId_whenUnscoped_throwsInsteadOfResolvingAnArbitraryConfig(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('not been scoped'); + + $this->configurationRepository->getClientId(); + } + + public function testGet_whenUnscoped_throwsInsteadOfResolvingAnArbitraryConfig(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('not been scoped'); + + $this->configurationRepository->get('payplug_webhook_authorization_header'); + } + public function testGetClientId_whenLive_readsFromLiveClient(): void { - $this->gatewayConfigWith(['live' => true, 'live_client' => ['client_id' => 'live_id', 'client_secret' => 'live_secret']]); + $scoped = $this->scopedWith(['live' => true, 'live_client' => ['client_id' => 'live_id', 'client_secret' => 'live_secret']]); - self::assertSame('live_id', $this->configurationRepository->getClientId()); + self::assertSame('live_id', $scoped->getClientId()); } public function testGetClientId_whenNotLive_readsFromTestClient(): void { - $this->gatewayConfigWith(['live' => false, 'test_client' => ['client_id' => 'test_id', 'client_secret' => 'test_secret']]); + $scoped = $this->scopedWith(['live' => false, 'test_client' => ['client_id' => 'test_id', 'client_secret' => 'test_secret']]); - self::assertSame('test_id', $this->configurationRepository->getClientId()); + self::assertSame('test_id', $scoped->getClientId()); } public function testGetClientSecret_whenLive_readsFromLiveClient(): void { - $this->gatewayConfigWith(['live' => true, 'live_client' => ['client_id' => 'live_id', 'client_secret' => 'live_secret']]); + $scoped = $this->scopedWith(['live' => true, 'live_client' => ['client_id' => 'live_id', 'client_secret' => 'live_secret']]); - self::assertSame('live_secret', $this->configurationRepository->getClientSecret()); + self::assertSame('live_secret', $scoped->getClientSecret()); } public function testGetClientId_whenNoClientConfigStored_returnsEmptyString(): void { - $this->gatewayConfigWith(['live' => false]); + $scoped = $this->scopedWith(['live' => false]); - self::assertSame('', $this->configurationRepository->getClientId()); + self::assertSame('', $scoped->getClientId()); } public function testGetPublicKeyId_readsHfIdentifier(): void { - $this->gatewayConfigWith([PayPlugGatewayFactory::HF_IDENTIFIER => 'hf_ident_123']); + $scoped = $this->scopedWith([PayPlugGatewayFactory::HF_IDENTIFIER => 'hf_ident_123']); - self::assertSame('hf_ident_123', $this->configurationRepository->getPublicKeyId()); + self::assertSame('hf_ident_123', $scoped->getPublicKeyId()); } public function testGetPublicKeyValue_returnsEmptyString(): void { - $this->gatewayConfigWith([]); + $scoped = $this->scopedWith([]); - self::assertSame('', $this->configurationRepository->getPublicKeyValue()); + self::assertSame('', $scoped->getPublicKeyValue()); } public function testGet_readsArbitraryKeyFromConfig(): void { - $this->gatewayConfigWith(['payplug_webhook_authorization_header' => 'Bearer shared-secret']); + $scoped = $this->scopedWith(['payplug_webhook_authorization_header' => 'Bearer shared-secret']); - self::assertSame('Bearer shared-secret', $this->configurationRepository->get('payplug_webhook_authorization_header')); + self::assertSame('Bearer shared-secret', $scoped->get('payplug_webhook_authorization_header')); } public function testGet_whenKeyMissing_returnsNull(): void { - $this->gatewayConfigWith([]); + $scoped = $this->scopedWith([]); - self::assertNull($this->configurationRepository->get('missing_key')); + self::assertNull($scoped->get('missing_key')); } - public function testSet_mergesTheKeyIntoConfigAndFlushes(): void + public function testSet_mergesTheKeyIntoConfigAndFlushesTheScopedConfig(): void { - $gatewayConfig = $this->gatewayConfigWith(['existing' => 'value']); + $gatewayConfig = $this->gatewayConfig(['existing' => 'value']); $gatewayConfig->expects(self::once())->method('setConfig') ->with(['existing' => 'value', 'new_key' => 'new_value']); $this->entityManager->expects(self::once())->method('flush'); - $this->configurationRepository->set('new_key', 'new_value'); + $this->configurationRepository->withGatewayConfig($gatewayConfig)->set('new_key', 'new_value'); } } diff --git a/tests/PHPUnit/Upc/UnifiedApiOperationStatusFetcherTest.php b/tests/PHPUnit/Upc/UnifiedApiOperationStatusFetcherTest.php index 9db28d0f..a43a66c3 100644 --- a/tests/PHPUnit/Upc/UnifiedApiOperationStatusFetcherTest.php +++ b/tests/PHPUnit/Upc/UnifiedApiOperationStatusFetcherTest.php @@ -4,16 +4,17 @@ namespace Tests\PayPlug\SyliusPayPlugPlugin\PHPUnit\Upc; +use PayPlug\SyliusPayPlugPlugin\Upc\ScopedConfigurationRepositoryInterface; use PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiOperationStatusFetcher; use PayplugUnifiedCore\Auth\OAuth2Client; use PayplugUnifiedCore\Auth\TokenManager; -use PayplugUnifiedCore\Contracts\IConfigurationRepository; use PayplugUnifiedCore\Contracts\IOAuthHttpClient; use PayplugUnifiedCore\Contracts\ITokenCache; use PayplugUnifiedCore\Contracts\IUnifiedApiHttpClient; use PayplugUnifiedCore\Exceptions\ApiException; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Sylius\Component\Payment\Model\PaymentMethodInterface; /** * TokenManager and OAuth2Client are both `final` (cannot be mocked by PHPUnit), so this test @@ -29,7 +30,11 @@ final class UnifiedApiOperationStatusFetcherTest extends TestCase private ITokenCache&MockObject $tokenCache; - private IConfigurationRepository&MockObject $configurationRepository; + private ScopedConfigurationRepositoryInterface&MockObject $configurationRepository; + + private ScopedConfigurationRepositoryInterface&MockObject $scopedConfiguration; + + private ?string $cachedToken = null; private UnifiedApiOperationStatusFetcher $fetcher; @@ -38,9 +43,10 @@ protected function setUp(): void $this->unifiedApiHttpClient = $this->createMock(IUnifiedApiHttpClient::class); $this->oauthHttpClient = $this->createMock(IOAuthHttpClient::class); $this->tokenCache = $this->createMock(ITokenCache::class); - $this->configurationRepository = $this->createMock(IConfigurationRepository::class); - $this->configurationRepository->method('getClientId')->willReturn('client_abc'); - $this->configurationRepository->method('getClientSecret')->willReturn('secret_xyz'); + $this->scopedConfiguration = $this->scopedConfigurationWith('client_abc', 'secret_xyz'); + $this->configurationRepository = $this->createMock(ScopedConfigurationRepositoryInterface::class); + $this->configurationRepository->method('forPaymentMethod') + ->willReturnCallback(fn (): ScopedConfigurationRepositoryInterface => $this->scopedConfiguration); $oauth2Client = new OAuth2Client($this->oauthHttpClient, 'https://api.payplug.com', '', '', 'https://www.payplug.com'); $tokenManager = new TokenManager($this->tokenCache, $oauth2Client); @@ -53,6 +59,53 @@ protected function setUp(): void ); } + private function scopedConfigurationWith( + string $clientId, + string $clientSecret, + ): ScopedConfigurationRepositoryInterface&MockObject + { + $scoped = $this->createMock(ScopedConfigurationRepositoryInterface::class); + $scoped->method('getClientId')->willReturn($clientId); + $scoped->method('getClientSecret')->willReturn($clientSecret); + + return $scoped; + } + + /** + * An operation id alone does not identify an account. Both call sites poll for a specific + * payment, so they pass that payment's method and the fetch is authenticated against the + * account the operation actually lives on. + */ + public function testGetOperation_signsWithTheCredentialsOfThePaymentMethodsOwnAccount(): void + { + $method = $this->createMock(PaymentMethodInterface::class); + $this->scopedConfiguration = $this->scopedConfigurationWith('de_id', 'de_secret'); + + $scopedFor = null; + $this->configurationRepository->expects(self::once()) + ->method('forPaymentMethod') + ->willReturnCallback(function (PaymentMethodInterface $m) use (&$scopedFor): ScopedConfigurationRepositoryInterface { + $scopedFor = $m; + + return $this->scopedConfiguration; + }); + + $sentCredentials = null; + $this->oauthHttpClient->method('post')->willReturnCallback( + function (string $url, array $formParams, array $headers = []) use (&$sentCredentials): array { + $sentCredentials = $headers['Authorization']; + + return ['status' => 200, 'body' => json_encode(['access_token' => 'jwt', 'expires_in' => 300, 'token_type' => 'Bearer'])]; + }, + ); + $this->unifiedApiHttpClient->method('get')->willReturn(['status' => 200, 'body' => '{}']); + + $this->fetcher->getOperation('op_1', $method); + + self::assertSame($method, $scopedFor); + self::assertSame('Basic ' . base64_encode('de_id:de_secret'), $sentCredentials); + } + public function testGetOperation_withValidCredentials_returnsTheRawResponse(): void { $this->tokenCache->method('get')->willReturn('cached-jwt'); @@ -61,7 +114,7 @@ public function testGetOperation_withValidCredentials_returnsTheRawResponse(): v ->with('https://api.payplug.com/processing-operations/operations/public/op_1', ['Authorization' => 'Bearer cached-jwt']) ->willReturn(['status' => 200, 'body' => $body]); - $response = $this->fetcher->getOperation('op_1'); + $response = $this->fetcher->getOperation('op_1', $this->createMock(PaymentMethodInterface::class)); self::assertSame(['status' => 200, 'body' => $body], $response); } @@ -79,7 +132,7 @@ public function testGetOperation_onMissingOperation_throwsApiException(): void $this->expectException(ApiException::class); $this->expectExceptionCode(404); - $this->fetcher->getOperation('op_1'); + $this->fetcher->getOperation('op_1', $this->createMock(PaymentMethodInterface::class)); } public function testGetOperation_onNon2xxResponse_throwsApiException(): void @@ -89,6 +142,6 @@ public function testGetOperation_onNon2xxResponse_throwsApiException(): void $this->expectException(ApiException::class); - $this->fetcher->getOperation('op_1'); + $this->fetcher->getOperation('op_1', $this->createMock(PaymentMethodInterface::class)); } } diff --git a/tests/PHPUnit/Upc/UnifiedApiPaymentCreatorTest.php b/tests/PHPUnit/Upc/UnifiedApiPaymentCreatorTest.php index 4636d692..97570ebd 100644 --- a/tests/PHPUnit/Upc/UnifiedApiPaymentCreatorTest.php +++ b/tests/PHPUnit/Upc/UnifiedApiPaymentCreatorTest.php @@ -4,10 +4,10 @@ namespace Tests\PayPlug\SyliusPayPlugPlugin\PHPUnit\Upc; +use PayPlug\SyliusPayPlugPlugin\Upc\ScopedConfigurationRepositoryInterface; use PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiPaymentCreator; use PayplugUnifiedCore\Auth\OAuth2Client; use PayplugUnifiedCore\Auth\TokenManager; -use PayplugUnifiedCore\Contracts\IConfigurationRepository; use PayplugUnifiedCore\Contracts\IOAuthHttpClient; use PayplugUnifiedCore\Contracts\ITokenCache; use PayplugUnifiedCore\Contracts\IUnifiedApiHttpClient; @@ -16,6 +16,7 @@ use PayplugUnifiedCore\Exceptions\ApiException; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Sylius\Component\Payment\Model\PaymentMethodInterface; /** * TokenManager and OAuth2Client are both `final` (cannot be mocked by PHPUnit), so this test @@ -31,7 +32,10 @@ final class UnifiedApiPaymentCreatorTest extends TestCase private ITokenCache&MockObject $tokenCache; - private IConfigurationRepository&MockObject $configurationRepository; + private ScopedConfigurationRepositoryInterface&MockObject $configurationRepository; + + /** What forPaymentMethod() hands back; a test wanting different credentials reassigns it. */ + private ScopedConfigurationRepositoryInterface&MockObject $scopedConfiguration; private UnifiedApiPaymentCreator $creator; @@ -40,9 +44,11 @@ protected function setUp(): void $this->unifiedApiHttpClient = $this->createMock(IUnifiedApiHttpClient::class); $this->oauthHttpClient = $this->createMock(IOAuthHttpClient::class); $this->tokenCache = $this->createMock(ITokenCache::class); - $this->configurationRepository = $this->createMock(IConfigurationRepository::class); - $this->configurationRepository->method('getClientId')->willReturn('client_abc'); - $this->configurationRepository->method('getClientSecret')->willReturn('secret_xyz'); + + $this->scopedConfiguration = $this->scopedConfigurationWith('client_abc', 'secret_xyz'); + $this->configurationRepository = $this->createMock(ScopedConfigurationRepositoryInterface::class); + $this->configurationRepository->method('forPaymentMethod') + ->willReturnCallback(fn (): ScopedConfigurationRepositoryInterface => $this->scopedConfiguration); $oauth2Client = new OAuth2Client($this->oauthHttpClient, 'https://api.payplug.com', '', '', 'https://www.payplug.com'); $tokenManager = new TokenManager($this->tokenCache, $oauth2Client); @@ -60,6 +66,55 @@ private function dto(): HostedFieldDto return new HostedFieldDto(new CommonFieldsDto('acct_123', 1000, 'eur', '42'), 'hf_token_abc'); } + private function scopedConfigurationWith( + string $clientId, + string $clientSecret, + ): ScopedConfigurationRepositoryInterface&MockObject + { + $scoped = $this->createMock(ScopedConfigurationRepositoryInterface::class); + $scoped->method('getClientId')->willReturn($clientId); + $scoped->method('getClientSecret')->willReturn($clientSecret); + + return $scoped; + } + + /** + * The payload DTO carries no account context, so the payment method is what tells the creator + * which PayPlug account to sign with. Since PRE-3628 two CB payment methods on different + * channels can hold different credentials, so reading them off an unscoped repository would + * create the payment on whichever account Doctrine happened to return. + */ + public function testCreatePayment_signsWithTheCredentialsOfThePaymentMethodsOwnAccount(): void + { + $method = $this->createMock(PaymentMethodInterface::class); + $this->scopedConfiguration = $this->scopedConfigurationWith('de_id', 'de_secret'); + + $scopedFor = null; + $this->configurationRepository->expects(self::once()) + ->method('forPaymentMethod') + ->willReturnCallback(function (PaymentMethodInterface $m) use (&$scopedFor): ScopedConfigurationRepositoryInterface { + $scopedFor = $m; + + return $this->scopedConfiguration; + }); + + $this->tokenCache->method('get')->willReturn(null); // force a token request + $sentCredentials = null; + $this->oauthHttpClient->method('post')->willReturnCallback( + function (string $url, array $formParams, array $headers = []) use (&$sentCredentials): array { + $sentCredentials = $headers['Authorization']; + + return ['status' => 200, 'body' => json_encode(['access_token' => 'jwt', 'expires_in' => 300, 'token_type' => 'Bearer'])]; + }, + ); + $this->unifiedApiHttpClient->method('postJson')->willReturn(['status' => 201, 'body' => '{"id":"pay_1"}']); + + $this->creator->createPayment($this->dto(), $method); + + self::assertSame($method, $scopedFor); + self::assertSame('Basic ' . base64_encode('de_id:de_secret'), $sentCredentials); + } + public function testCreateHostedPayment_withValidCredentials_returnsTheOutput(): void { $this->tokenCache->method('get')->willReturn(null); @@ -69,7 +124,7 @@ public function testCreateHostedPayment_withValidCredentials_returnsTheOutput(): ]); $this->unifiedApiHttpClient->method('postJson')->willReturn(['status' => 201, 'body' => '{"id":"pay_1"}']); - $output = $this->creator->createPayment($this->dto()); + $output = $this->creator->createPayment($this->dto(), $this->createMock(PaymentMethodInterface::class)); self::assertSame(201, $output->status); self::assertNull($output->redirectUrl); @@ -83,7 +138,7 @@ public function testCreateHostedPayment_withPending3ds_extractsTheRedirectUrl(): 'body' => json_encode(['id' => 'pay_1', 'redirect' => ['url' => 'https://3ds.payplug.com/challenge']]), ]); - $output = $this->creator->createPayment($this->dto()); + $output = $this->creator->createPayment($this->dto(), $this->createMock(PaymentMethodInterface::class)); self::assertSame('https://3ds.payplug.com/challenge', $output->redirectUrl); } @@ -95,6 +150,6 @@ public function testCreateHostedPayment_onNon2xxResponse_throwsApiException(): v $this->expectException(ApiException::class); - $this->creator->createPayment($this->dto()); + $this->creator->createPayment($this->dto(), $this->createMock(PaymentMethodInterface::class)); } } diff --git a/tests/PHPUnit/Upc/UnifiedApiRefundCreatorTest.php b/tests/PHPUnit/Upc/UnifiedApiRefundCreatorTest.php index 2c9754d0..5019db3d 100644 --- a/tests/PHPUnit/Upc/UnifiedApiRefundCreatorTest.php +++ b/tests/PHPUnit/Upc/UnifiedApiRefundCreatorTest.php @@ -5,10 +5,10 @@ namespace Tests\PayPlug\SyliusPayPlugPlugin\PHPUnit\Upc; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\Upc\ScopedConfigurationRepositoryInterface; use PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiRefundCreator; use PayplugUnifiedCore\Auth\OAuth2Client; use PayplugUnifiedCore\Auth\TokenManager; -use PayplugUnifiedCore\Contracts\IConfigurationRepository; use PayplugUnifiedCore\Contracts\IOAuthHttpClient; use PayplugUnifiedCore\Contracts\ITokenCache; use PayplugUnifiedCore\Contracts\IUnifiedApiHttpClient; @@ -33,7 +33,12 @@ final class UnifiedApiRefundCreatorTest extends TestCase private ITokenCache&MockObject $tokenCache; - private IConfigurationRepository&MockObject $configurationRepository; + private ScopedConfigurationRepositoryInterface&MockObject $configurationRepository; + + /** What forPaymentMethod() hands back; a test wanting different credentials reassigns it. */ + private ScopedConfigurationRepositoryInterface&MockObject $scopedConfiguration; + + private ?string $cachedToken = 'cached-jwt'; private UnifiedApiRefundCreator $creator; @@ -42,9 +47,11 @@ protected function setUp(): void $this->unifiedApiHttpClient = $this->createMock(IUnifiedApiHttpClient::class); $this->oauthHttpClient = $this->createMock(IOAuthHttpClient::class); $this->tokenCache = $this->createMock(ITokenCache::class); - $this->configurationRepository = $this->createMock(IConfigurationRepository::class); - $this->configurationRepository->method('getClientId')->willReturn('client_abc'); - $this->configurationRepository->method('getClientSecret')->willReturn('secret_xyz'); + + $this->scopedConfiguration = $this->scopedConfigurationWith('client_abc', 'secret_xyz'); + $this->configurationRepository = $this->createMock(ScopedConfigurationRepositoryInterface::class); + $this->configurationRepository->method('forPaymentMethod') + ->willReturnCallback(fn (): ScopedConfigurationRepositoryInterface => $this->scopedConfiguration); $oauth2Client = new OAuth2Client($this->oauthHttpClient, 'https://api.payplug.com', '', '', 'https://www.payplug.com'); $tokenManager = new TokenManager($this->tokenCache, $oauth2Client); @@ -56,7 +63,56 @@ protected function setUp(): void 'https://api.payplug.com', ); - $this->tokenCache->method('get')->willReturn('cached-jwt'); + // Swappable so a test that needs to observe the token request itself can force a cache miss. + $this->tokenCache->method('get')->willReturnCallback(fn (): ?string => $this->cachedToken); + } + + private function scopedConfigurationWith( + string $clientId, + string $clientSecret, + ): ScopedConfigurationRepositoryInterface&MockObject + { + $scoped = $this->createMock(ScopedConfigurationRepositoryInterface::class); + $scoped->method('getClientId')->willReturn($clientId); + $scoped->method('getClientSecret')->willReturn($clientSecret); + + return $scoped; + } + + /** + * createRefund() already routes the *account id* per payment method; the OAuth credentials it + * signs with have to follow the same method, or a refund on channel B is authenticated as + * channel A and rejected — or worse, accepted against the wrong merchant. + */ + public function testCreateRefund_signsWithTheCredentialsOfThePaymentMethodsOwnAccount(): void + { + $method = $this->buildHostedFieldsPaymentMethod('acct_de'); + $this->scopedConfiguration = $this->scopedConfigurationWith('de_id', 'de_secret'); + $this->cachedToken = null; // force a real token request, so its credentials are observable + + $scopedFor = null; + $this->configurationRepository->expects(self::once()) + ->method('forPaymentMethod') + ->willReturnCallback(function (PaymentMethodInterface $m) use (&$scopedFor): ScopedConfigurationRepositoryInterface { + $scopedFor = $m; + + return $this->scopedConfiguration; + }); + + $sentCredentials = null; + $this->oauthHttpClient->method('post')->willReturnCallback( + function (string $url, array $formParams, array $headers = []) use (&$sentCredentials): array { + $sentCredentials = $headers['Authorization']; + + return ['status' => 200, 'body' => json_encode(['access_token' => 'jwt', 'expires_in' => 300, 'token_type' => 'Bearer'])]; + }, + ); + $this->unifiedApiHttpClient->method('postJson')->willReturn(['status' => 200, 'body' => '{}']); + + $this->creator->createRefund($method, 'pay_123', 'order_1'); + + self::assertSame($method, $scopedFor); + self::assertSame('Basic ' . base64_encode('de_id:de_secret'), $sentCredentials); } public function testCreateRefund_withoutAmount_sendsAFullRefundUsingTheMethodsOwnAccountId(): void diff --git a/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php b/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php index 8ce05c0a..5a97dd24 100644 --- a/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php +++ b/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php @@ -9,6 +9,7 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\OneyGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\ScalapayGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\HasNoGatewayChannelConflict; use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\IsCanSavePaymentMethod; use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\IsScalapayAmountRangeValid; use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\PayplugPermission; @@ -162,7 +163,8 @@ public function testProcess_payplugFactory_noFlags_validatesWithBaseConstraintOn ->expects(self::once()) ->method('validate') ->willReturnCallback(function ($subject, array $constraints) { - self::assertCount(1, $constraints); + self::assertCount(2, $constraints); + self::assertInstanceOf(HasNoGatewayChannelConflict::class, $constraints[1]); return new ConstraintViolationList(); }) @@ -199,7 +201,8 @@ public function testProcess_payplugFactory_allFlagsEnabled_validatesWithAllConst ->method('validate') ->willReturnCallback(function ($subject, array $constraints) { // Base + CAN_SAVE_CARD + CAN_CREATE_DEFERRED_PAYMENT + CAN_USE_INTEGRATED_PAYMENTS - self::assertCount(4, $constraints); + self::assertCount(5, $constraints); + self::assertInstanceOf(HasNoGatewayChannelConflict::class, $constraints[4]); return new ConstraintViolationList(); }) @@ -234,7 +237,8 @@ public function testProcess_payplugFactory_hostedFieldsTrueOneClickFalse_validat ->expects(self::once()) ->method('validate') ->willReturnCallback(function ($subject, array $constraints) { - self::assertCount(1, $constraints); + self::assertCount(2, $constraints); + self::assertInstanceOf(HasNoGatewayChannelConflict::class, $constraints[1]); self::assertInstanceOf(IsCanSavePaymentMethod::class, $constraints[0]); return new ConstraintViolationList(); @@ -270,7 +274,8 @@ public function testProcess_payplugFactory_hostedFieldsTrueOneClickTrue_validate ->expects(self::once()) ->method('validate') ->willReturnCallback(function ($subject, array $constraints) { - self::assertCount(2, $constraints); + self::assertCount(3, $constraints); + self::assertInstanceOf(HasNoGatewayChannelConflict::class, $constraints[2]); self::assertInstanceOf(IsCanSavePaymentMethod::class, $constraints[0]); self::assertInstanceOf(PayplugPermission::class, $constraints[1]); @@ -302,7 +307,8 @@ public function testProcess_scalapayFactory_validatesWithBaseAndAmountRangeConst ->expects(self::once()) ->method('validate') ->willReturnCallback(function ($subject, array $constraints) { - self::assertCount(2, $constraints); + self::assertCount(3, $constraints); + self::assertInstanceOf(HasNoGatewayChannelConflict::class, $constraints[2]); self::assertInstanceOf(IsCanSavePaymentMethod::class, $constraints[0]); self::assertInstanceOf(IsScalapayAmountRangeValid::class, $constraints[1]); diff --git a/translations/flashes.en.yml b/translations/flashes.en.yml index 7d4de015..7c66cfac 100644 --- a/translations/flashes.en.yml +++ b/translations/flashes.en.yml @@ -10,3 +10,5 @@ payplug_sylius_payplug_plugin: oauth_callback_success: The OAuth connection was established successfully. payment_method_disabled: ⚠️ The payment method has been disabled because some validation criteria are not met. oauth_setup_error: An error occurred while setting up the OAuth connection. Please try again. + logout_success: The PayPlug account has been disconnected and this payment method has been disabled. + logout_error: An error occurred while disconnecting the PayPlug account. Please try again. diff --git a/translations/flashes.fr.yml b/translations/flashes.fr.yml index cca3eac2..f844ee18 100644 --- a/translations/flashes.fr.yml +++ b/translations/flashes.fr.yml @@ -10,3 +10,5 @@ payplug_sylius_payplug_plugin: oauth_callback_success: La connexion OAuth a été établie avec succès. payment_method_disabled: ⚠️ La méthode de paiement a été désactivée car certains critères de validation ne sont pas remplis. oauth_setup_error: Une erreur s'est produite lors de la configuration de la connexion OAuth. Veuillez réessayer. + logout_success: Le compte PayPlug a été déconnecté et ce moyen de paiement a été désactivé. + logout_error: Une erreur s’est produite lors de la déconnexion du compte PayPlug. Veuillez réessayer. diff --git a/translations/flashes.it.yml b/translations/flashes.it.yml index 49355425..0a03e5b9 100644 --- a/translations/flashes.it.yml +++ b/translations/flashes.it.yml @@ -10,3 +10,5 @@ payplug_sylius_payplug_plugin: oauth_callback_success: La connessione OAuth è stata stabilita con successo. payment_method_disabled: ⚠️ Il metodo di pagamento è stato disattivato perché alcuni criteri di validazione non sono soddisfatti. oauth_setup_error: Si è verificato un errore durante la configurazione della connessione OAuth. Per favore riprova. + logout_success: L’account PayPlug è stato disconnesso e questo metodo di pagamento è stato disattivato. + logout_error: Si è verificato un errore durante la disconnessione dell’account PayPlug. Per favore riprova. diff --git a/translations/messages.en.yml b/translations/messages.en.yml index a486cfb3..fdfdf9e1 100644 --- a/translations/messages.en.yml +++ b/translations/messages.en.yml @@ -128,6 +128,10 @@ payplug_sylius_payplug_plugin: If this option is checked, a new authentication flow will be started when clicking the "Update" button. hf_identifier_label: 'Account ID' hosted_fields_option: 'Advanced (beta) Hosted Fields' + connected_account: Connected PayPlug account + connected_account_unknown: Re-authenticate to display the connected account + logout: Disconnect this account + logout_help: Removes the PayPlug credentials stored for this payment method and disables it. The other payment methods keep their own connection. form: oney_error: Some missing information is required to pay using Oney by Payplug complete_info: @@ -141,8 +145,8 @@ payplug_sylius_payplug_plugin: submit: Confirm and continue base_currency_not_euro: | Channel #channel_code#: #payment_method# is only available on channels with EURO as a currency - only_one_gateway_allowed: | - Please note that the %gateway_title% payment method has already been set. To change it, go to your payment methods. + gateway_channel_conflict: >- + Channel %channel% is already linked to the enabled payment method "%payment_method%". Disable it, or remove that channel from one of the two. one_click_enable: Enable One click one_click_help: | Allow your customers to save their credit card details for later diff --git a/translations/messages.fr.yml b/translations/messages.fr.yml index 4c17f7ad..79c27942 100644 --- a/translations/messages.fr.yml +++ b/translations/messages.fr.yml @@ -148,6 +148,10 @@ payplug_sylius_payplug_plugin: Si cette option est cochée, un nouveau flux d’authentification sera lancé lors du clic sur le bouton "Mise à jour". hf_identifier_label: 'Identifiant de compte' hosted_fields_option: 'Hosted Fields avancé (beta)' + connected_account: Compte PayPlug connecté + connected_account_unknown: Reconnectez-vous pour afficher le compte connecté + logout: Déconnecter ce compte + logout_help: Supprime les identifiants PayPlug enregistrés pour ce moyen de paiement et le désactive. Les autres moyens de paiement conservent leur propre connexion. form: oney_error: Il y a des informations manquantes pour pouvoir payer en utilisant Oney by Payplug complete_info: @@ -161,8 +165,8 @@ payplug_sylius_payplug_plugin: submit: Valider et continuer base_currency_not_euro: | Canal #channel_code# : #payment_method# n’est disponible que sur des canaux dont la devise est l’EURO - only_one_gateway_allowed: | - Attention, le moyen de paiement %gateway_title% existe déjà. Pour le modifier, rendez-vous sur vos moyens de paiement. + gateway_channel_conflict: >- + Le canal %channel% est déjà rattaché au moyen de paiement actif « %payment_method% ». Désactivez-le, ou retirez ce canal de l'un des deux. one_click_enable: Activer le One click one_click_help: | Permettez à vos clients d'enregistrer leurs coordonnées de carte de paiement pour effectuer ultérieurement diff --git a/translations/messages.it.yml b/translations/messages.it.yml index 16e60374..bb461cc1 100644 --- a/translations/messages.it.yml +++ b/translations/messages.it.yml @@ -128,6 +128,10 @@ payplug_sylius_payplug_plugin: Se questa opzione è selezionata, un nuovo flusso di autenticazione verrà avviato quando si fa clic sul pulsante "Aggiorna". hf_identifier_label: 'ID Account' hosted_fields_option: 'Hosted Fields avanzato (beta)' + connected_account: Account PayPlug collegato + connected_account_unknown: Autenticati di nuovo per visualizzare l'account collegato + logout: Disconnetti questo account + logout_help: Rimuove le credenziali PayPlug salvate per questo metodo di pagamento e lo disattiva. Gli altri metodi di pagamento mantengono la propria connessione. form: oney_error: Mancano alcune informazioni per poter pagare con “Oney by Payplug” complete_info: @@ -141,8 +145,8 @@ payplug_sylius_payplug_plugin: submit: Convalida e continua base_currency_not_euro: | Il canale #channel_code# : #payment_method# è disponibile solo per i canali la cui valuta è in EURO - only_one_gateway_allowed: | - Attenzione: il metodo di pagamento %gateway_title% è già definito. Per modificarlo, vai ai tuoi metodi di pagamento. + gateway_channel_conflict: >- + Il canale %channel% è già collegato al metodo di pagamento attivo "%payment_method%". Disattivalo oppure rimuovi quel canale da uno dei due. one_click_enable: Attiva un clic one_click_help: | Consenti ai tuoi clienti di salvare i dettagli della loro carta di credito per dopo