Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 33 additions & 8 deletions src/Provider/SupportedMethodsProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ public function provide(
?string $billingCountryCode = null,
): array {
$activeCurrencyCode = $paymentCurrencyCode ?? $this->currencyContext->getCurrencyCode();
$authorizedCurrencies = null;
$allowedCountries = null;

/** @var array<string, array<array-key, mixed>> $accounts */
$accounts = [];

foreach ($supportedMethods as $key => $paymentMethod) {
Assert::isInstanceOf($paymentMethod, PaymentMethodInterface::class);
Expand All @@ -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();
Comment thread
adumont-payplug marked this conversation as resolved.

$authorizedCurrencies = $this->resolveAuthorizedCurrencies($account, $factoryName);
Comment thread
adumont-payplug marked this conversation as resolved.
$allowedCountries = $this->resolveAllowedCountries($account, $factoryName);

if ($billingCountryCode !== null && $allowedCountries !== [] && !\in_array($billingCountryCode, $allowedCountries, true)) {
unset($supportedMethods[$key]);
Expand Down Expand Up @@ -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<array-key, mixed> $account
*
* @return array<string, array{min_amount: int, max_amount: int}>
*/
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<array-key, mixed> $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);
Expand Down
135 changes: 133 additions & 2 deletions tests/PHPUnit/Provider/SupportedMethodsProviderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,23 @@ final class SupportedMethodsProviderTest extends TestCase

private SupportedMethodsProvider $provider;

/** @var \SplObjectStorage<object, PayPlugApiClientInterface> */
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());
}
Expand Down Expand Up @@ -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
// -------------------------------------------------------------------------
Expand All @@ -587,19 +668,69 @@ private function buildAccount(int $minAmount, int $maxAmount): array
];
}

/**
* @param list<string> $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<string, mixed> $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<string, mixed> $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<string, mixed> $account
*/
private function assignAccount(PaymentMethodInterface $paymentMethod, array $account): void
{
$client = $this->createMock(PayPlugApiClientInterface::class);
$client->method('getAccount')->willReturn($account);

$this->clientsByPaymentMethod[$paymentMethod] = $client;
}
}
Loading