diff --git a/src/Provider/SupportedMethodsProvider.php b/src/Provider/SupportedMethodsProvider.php index c9c0d4a4..5506ed90 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, $factoryName); + $allowedCountries = $this->resolveAllowedCountries($account, $factoryName); if ($billingCountryCode !== null && $allowedCountries !== [] && !\in_array($billingCountryCode, $allowedCountries, true)) { unset($supportedMethods[$key]); @@ -167,25 +171,46 @@ private function readConfiguredAmounts(array $config): 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 accountMemoKey(GatewayConfigInterface $gatewayConfig): string + { + $id = $gatewayConfig->getId(); + + if (\is_int($id) || (\is_string($id) && '' !== $id)) { + return 'config:' . $id; + } + + return 'object:' . spl_object_id($gatewayConfig); + } + + /** + * @param array $account + * * @return array */ - private function resolveAuthorizedCurrencies(string $factoryName): array + private function resolveAuthorizedCurrencies(array $account, string $factoryName): array { - $account = $this->clientFactory->create($factoryName)->getAccount(); $underscorePos = strpos($factoryName, '_'); $paymentMethodKey = false !== $underscorePos ? substr($factoryName, $underscorePos + 1) : null; return $this->amountRangeResolver->resolve($account, $paymentMethodKey); } - private function resolveAllowedCountries(string $factoryName): array + /** + * @param array $account + */ + private function resolveAllowedCountries(array $account, string $factoryName): array { $underscorePos = strpos($factoryName, '_'); if ($underscorePos === false) { return []; } - $account = $this->clientFactory->create($factoryName)->getAccount(); $pmKey = substr($factoryName, $underscorePos + 1); $paymentMethods = $account['payment_methods'] ?? []; Assert::isArray($paymentMethods); 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; + } }