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
10 changes: 2 additions & 8 deletions ruleset/phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
78 changes: 78 additions & 0 deletions src/Checker/GatewayChannelConflictChecker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

declare(strict_types=1);

namespace PayPlug\SyliusPayPlugPlugin\Checker;

use PayPlug\SyliusPayPlugPlugin\Repository\PaymentMethodRepositoryInterface;
use Sylius\Component\Core\Model\ChannelInterface;
use Sylius\Component\Core\Model\PaymentMethodInterface;

/**
* A channel may be linked to at most one *enabled* gateway config per factory type.
*
* Two gateways of the same factory (e.g. "CB 1" and "CB 2") may coexist and both be enabled as
* long as their channel sets are disjoint; different factory types never conflict with each other.
* Channels are matched on their code rather than on object identity, so the comparison holds
* regardless of which identity map each side was loaded through.
*/
final class GatewayChannelConflictChecker
{
public function __construct(private PaymentMethodRepositoryInterface $paymentMethodRepository)
{
}

/**
* @return list<array{channel: ChannelInterface, paymentMethod: PaymentMethodInterface}>
*/
public function findConflicts(PaymentMethodInterface $paymentMethod, string $factoryName): array
{
$channelCodes = $this->channelCodes($paymentMethod);

if (!$paymentMethod->isEnabled() || [] === $channelCodes) {
return [];
}

$conflicts = [];
foreach ($this->paymentMethodRepository->findEnabledByGatewayName($factoryName) as $rival) {
if ($this->isSamePaymentMethod($paymentMethod, $rival) || !$rival->isEnabled()) {
continue;
}

foreach ($rival->getChannels() as $rivalChannel) {
if (
$rivalChannel instanceof ChannelInterface &&
\in_array($rivalChannel->getCode(), $channelCodes, true)
) {
$conflicts[] = ['channel' => $rivalChannel, 'paymentMethod' => $rival];
}
}
}

return $conflicts;
}

/**
* @return list<string>
*/
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();
}
}
199 changes: 199 additions & 0 deletions src/Gateway/Form/Extension/PaymentMethodTypeExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
<?php

declare(strict_types=1);

namespace PayPlug\SyliusPayPlugPlugin\Gateway\Form\Extension;

use PayPlug\SyliusPayPlugPlugin\Checker\GatewayChannelConflictChecker;
use PayPlug\SyliusPayPlugPlugin\Gateway\Form\Type\AbstractGatewayConfigurationType;
use Sylius\Bundle\PaymentBundle\Form\Type\PaymentMethodType;
use Sylius\Component\Core\Model\ChannelInterface;
use Sylius\Component\Core\Model\PaymentMethodInterface;
use Sylius\Component\Payment\Model\GatewayConfigInterface;
use Symfony\Component\Form\AbstractTypeExtension;
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\Component\HttpFoundation\Session\FlashBagAwareSessionInterface;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Contracts\Translation\TranslatorInterface;

/**
* Validations that need the *whole* submitted payment method, not just its gateway config.
*
* Sylius adds `channels` to the payment-method form from `CoreBundle`'s own type extension, i.e.
* after `gatewayConfig`; since children are submitted in insertion order, a listener inside
* `paymentMethod.gatewayConfig.config` runs before `enabled` and `channels` have been submitted
* and can only see persisted data. POST_SUBMIT on the root form is the first point where the
* submitted channel set, the submitted `enabled` flag and the mapped gateway config all exist.
*/
final class PaymentMethodTypeExtension extends AbstractTypeExtension
{
public function __construct(
private GatewayChannelConflictChecker $conflictChecker,
private TranslatorInterface $translator,
private RequestStack $requestStack,
) {
}

/**
* @inheritdoc
*/
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$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;
}

private function addChannelConflictErrors(
FormInterface $form,
PaymentMethodInterface $paymentMethod,
string $factoryName,
): void {
if (!$form->has('channels')) {
return;
}

$flashedMessages = [];
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(),
],
);

$form->get('channels')->addError(new FormError($message));

if (!\in_array($message, $flashedMessages, true)) {
$flashedMessages[] = $message;
$this->flash($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));
$this->flash($message);
}
}

private function flash(string $message): void
{
$session = $this->resolveSession();

if (!$session instanceof FlashBagAwareSessionInterface) {
return;
}

$session->getFlashBag()->add('error', $message);
}

private function resolveSession(): ?SessionInterface
{
$request = $this->requestStack->getCurrentRequest();

if (null === $request || !$request->hasSession()) {
return null;
}

return $request->getSession();
}
}
Loading