From 0659dc6cc0a64ca233713f2902896e0fb2bc8ef4 Mon Sep 17 00:00:00 2001 From: Julien Hoarau Date: Mon, 3 Aug 2026 16:06:46 +0200 Subject: [PATCH 01/22] PRE-3550: add hostedFields gateway config flag and credential fields Co-Authored-By: Claude Sonnet 5 --- ...ayPlugGatewayConfigurationTypeExtension.php | 17 +++++++++++++++++ src/Gateway/PayPlugGatewayFactory.php | 6 ++++++ .../Gateway/PayPlugGatewayFactoryTest.php | 18 ++++++++++++++++++ translations/messages.en.yml | 3 +++ translations/messages.fr.yml | 3 +++ translations/messages.it.yml | 3 +++ 6 files changed, 50 insertions(+) create mode 100644 tests/PHPUnit/Gateway/PayPlugGatewayFactoryTest.php diff --git a/src/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtension.php b/src/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtension.php index a6c47a2a..05052c2b 100644 --- a/src/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtension.php +++ b/src/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtension.php @@ -9,6 +9,7 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; use Symfony\Component\Form\AbstractTypeExtension; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvents; @@ -43,6 +44,22 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'help_html' => true, 'required' => false, ]) + ->add(PayPlugGatewayFactory::HOSTED_FIELDS, CheckboxType::class, [ + 'block_name' => 'payplug_checkbox', + 'label' => 'payplug_sylius_payplug_plugin.form.hosted_fields_enable', + 'validation_groups' => AbstractGatewayConfigurationType::VALIDATION_GROUPS, + 'required' => false, + ]) + ->add(PayPlugGatewayFactory::HOSTED_FIELDS_KEY_ID, TextType::class, [ + 'label' => 'payplug_sylius_payplug_plugin.form.hosted_fields_key_id', + 'validation_groups' => AbstractGatewayConfigurationType::VALIDATION_GROUPS, + 'required' => false, + ]) + ->add(PayPlugGatewayFactory::HOSTED_FIELDS_KEY_VALUE, TextType::class, [ + 'label' => 'payplug_sylius_payplug_plugin.form.hosted_fields_key_value', + 'validation_groups' => AbstractGatewayConfigurationType::VALIDATION_GROUPS, + 'required' => false, + ]) ->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void { $data = $event->getData(); // phpstan check diff --git a/src/Gateway/PayPlugGatewayFactory.php b/src/Gateway/PayPlugGatewayFactory.php index 99cb0e41..acda9808 100644 --- a/src/Gateway/PayPlugGatewayFactory.php +++ b/src/Gateway/PayPlugGatewayFactory.php @@ -16,4 +16,10 @@ final class PayPlugGatewayFactory extends AbstractGatewayFactory public const INTEGRATED_PAYMENT = 'integratedPayment'; public const DEFERRED_CAPTURE = 'deferredCapture'; + + public const HOSTED_FIELDS = 'hostedFields'; + + public const HOSTED_FIELDS_KEY_ID = 'hostedFieldsKeyId'; + + public const HOSTED_FIELDS_KEY_VALUE = 'hostedFieldsKeyValue'; } diff --git a/tests/PHPUnit/Gateway/PayPlugGatewayFactoryTest.php b/tests/PHPUnit/Gateway/PayPlugGatewayFactoryTest.php new file mode 100644 index 00000000..a927e592 --- /dev/null +++ b/tests/PHPUnit/Gateway/PayPlugGatewayFactoryTest.php @@ -0,0 +1,18 @@ + Date: Mon, 3 Aug 2026 16:23:23 +0200 Subject: [PATCH 02/22] PRE-3550: reject combining integratedPayment and hostedFields on the same payment method Co-Authored-By: Claude Sonnet 5 --- ruleset/phpstan-baseline.neon | 6 ++ ...biningIntegratedPaymentAndHostedFields.php | 21 +++++ ...egratedPaymentAndHostedFieldsValidator.php | 39 ++++++++++ src/Validator/PaymentMethodValidator.php | 3 +- ...tedPaymentAndHostedFieldsValidatorTest.php | 77 +++++++++++++++++++ .../Validator/PaymentMethodValidatorTest.php | 10 ++- translations/validators.en.yml | 3 +- translations/validators.fr.yml | 3 +- translations/validators.it.yml | 3 +- 9 files changed, 157 insertions(+), 8 deletions(-) create mode 100644 src/Gateway/Validator/Constraints/IsNotCombiningIntegratedPaymentAndHostedFields.php create mode 100644 src/Gateway/Validator/Constraints/IsNotCombiningIntegratedPaymentAndHostedFieldsValidator.php create mode 100644 tests/PHPUnit/Gateway/Validator/Constraints/IsNotCombiningIntegratedPaymentAndHostedFieldsValidatorTest.php diff --git a/ruleset/phpstan-baseline.neon b/ruleset/phpstan-baseline.neon index abca980f..e7eb45fb 100644 --- a/ruleset/phpstan-baseline.neon +++ b/ruleset/phpstan-baseline.neon @@ -748,6 +748,12 @@ parameters: count: 1 path: ../src/Gateway/Validator/Constraints/IsCanSavePaymentMethodValidator.php + - + message: '#^Method PayPlug\\SyliusPayPlugPlugin\\Gateway\\Validator\\Constraints\\IsNotCombiningIntegratedPaymentAndHostedFieldsValidator\:\:validate\(\) has parameter \$value with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: ../src/Gateway/Validator/Constraints/IsNotCombiningIntegratedPaymentAndHostedFieldsValidator.php + - message: '#^Method PayPlug\\SyliusPayPlugPlugin\\Gateway\\Validator\\Constraints\\IsOneyEnabledValidator\:\:validate\(\) has parameter \$value with no type specified\.$#' identifier: missingType.parameter diff --git a/src/Gateway/Validator/Constraints/IsNotCombiningIntegratedPaymentAndHostedFields.php b/src/Gateway/Validator/Constraints/IsNotCombiningIntegratedPaymentAndHostedFields.php new file mode 100644 index 00000000..4c3d3ee6 --- /dev/null +++ b/src/Gateway/Validator/Constraints/IsNotCombiningIntegratedPaymentAndHostedFields.php @@ -0,0 +1,21 @@ +getGatewayConfig(); + if (!$gatewayConfig instanceof GatewayConfigInterface) { + return; + } + + $config = $gatewayConfig->getConfig(); + $integratedPaymentEnabled = true === ($config[PayPlugGatewayFactory::INTEGRATED_PAYMENT] ?? false); + $hostedFieldsEnabled = true === ($config[PayPlugGatewayFactory::HOSTED_FIELDS] ?? false); + + if ($integratedPaymentEnabled && $hostedFieldsEnabled) { + $this->context->buildViolation($constraint->message)->addViolation(); + } + } +} diff --git a/src/Validator/PaymentMethodValidator.php b/src/Validator/PaymentMethodValidator.php index 60485bde..deef6c60 100644 --- a/src/Validator/PaymentMethodValidator.php +++ b/src/Validator/PaymentMethodValidator.php @@ -14,6 +14,7 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\ScalapayGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\UhfGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\IsCanSavePaymentMethod; +use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\IsNotCombiningIntegratedPaymentAndHostedFields; use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\IsOneyEnabled; use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\PayplugPermission; use PayPlug\SyliusPayPlugPlugin\Gateway\WeroGatewayFactory; @@ -68,7 +69,7 @@ public function process(PaymentMethodInterface $paymentMethod): void private function processPayplug(PaymentMethodInterface $paymentMethod): ConstraintViolationListInterface { $config = $paymentMethod->getGatewayConfig()?->getConfig() ?? []; - $constraintList = [new IsCanSavePaymentMethod()]; + $constraintList = [new IsCanSavePaymentMethod(), new IsNotCombiningIntegratedPaymentAndHostedFields()]; if (true === $config[PayPlugGatewayFactory::ONE_CLICK]) { $constraintList[] = new PayplugPermission(Permission::CAN_SAVE_CARD); diff --git a/tests/PHPUnit/Gateway/Validator/Constraints/IsNotCombiningIntegratedPaymentAndHostedFieldsValidatorTest.php b/tests/PHPUnit/Gateway/Validator/Constraints/IsNotCombiningIntegratedPaymentAndHostedFieldsValidatorTest.php new file mode 100644 index 00000000..16127cd8 --- /dev/null +++ b/tests/PHPUnit/Gateway/Validator/Constraints/IsNotCombiningIntegratedPaymentAndHostedFieldsValidatorTest.php @@ -0,0 +1,77 @@ +context = $this->createMock(ExecutionContextInterface::class); + $this->validator = new IsNotCombiningIntegratedPaymentAndHostedFieldsValidator(); + $this->validator->initialize($this->context); + } + + public function testBothFlagsEnabled_buildsViolation(): void + { + $paymentMethod = $this->buildPaymentMethod([ + PayPlugGatewayFactory::INTEGRATED_PAYMENT => true, + PayPlugGatewayFactory::HOSTED_FIELDS => true, + ]); + + $violationBuilder = $this->createMock(\Symfony\Component\Validator\Violation\ConstraintViolationBuilderInterface::class); + $violationBuilder->method('addViolation'); + $this->context->expects(self::once())->method('buildViolation')->willReturn($violationBuilder); + + $this->validator->validate($paymentMethod, new IsNotCombiningIntegratedPaymentAndHostedFields()); + } + + public function testOnlyHostedFieldsEnabled_noViolation(): void + { + $paymentMethod = $this->buildPaymentMethod([ + PayPlugGatewayFactory::INTEGRATED_PAYMENT => false, + PayPlugGatewayFactory::HOSTED_FIELDS => true, + ]); + + $this->context->expects(self::never())->method('buildViolation'); + + $this->validator->validate($paymentMethod, new IsNotCombiningIntegratedPaymentAndHostedFields()); + } + + public function testNeitherFlagEnabled_noViolation(): void + { + $paymentMethod = $this->buildPaymentMethod([ + PayPlugGatewayFactory::INTEGRATED_PAYMENT => false, + PayPlugGatewayFactory::HOSTED_FIELDS => false, + ]); + + $this->context->expects(self::never())->method('buildViolation'); + + $this->validator->validate($paymentMethod, new IsNotCombiningIntegratedPaymentAndHostedFields()); + } + + private function buildPaymentMethod(array $config): PaymentMethodInterface&MockObject + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getConfig')->willReturn($config); + + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig); + + return $paymentMethod; + } +} diff --git a/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php b/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php index 0da3f243..6ea28bc9 100644 --- a/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php +++ b/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php @@ -152,6 +152,7 @@ public function testProcess_payplugFactory_noFlags_validatesWithBaseConstraintOn PayPlugGatewayFactory::ONE_CLICK => false, PayPlugGatewayFactory::DEFERRED_CAPTURE => false, PayPlugGatewayFactory::INTEGRATED_PAYMENT => false, + PayPlugGatewayFactory::HOSTED_FIELDS => false, ]; $paymentMethod = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME, $config); @@ -159,8 +160,8 @@ public function testProcess_payplugFactory_noFlags_validatesWithBaseConstraintOn ->expects(self::once()) ->method('validate') ->willReturnCallback(function ($subject, array $constraints) { - // Only the base IsCanSavePaymentMethod constraint (no permission constraints) - self::assertCount(1, $constraints); + // Base IsCanSavePaymentMethod + IsNotCombiningIntegratedPaymentAndHostedFields (no permission constraints) + self::assertCount(2, $constraints); return new ConstraintViolationList(); }) @@ -188,6 +189,7 @@ public function testProcess_payplugFactory_allFlagsEnabled_validatesWithAllConst PayPlugGatewayFactory::ONE_CLICK => true, PayPlugGatewayFactory::DEFERRED_CAPTURE => true, PayPlugGatewayFactory::INTEGRATED_PAYMENT => true, + PayPlugGatewayFactory::HOSTED_FIELDS => false, ]; $paymentMethod = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME, $config); @@ -195,8 +197,8 @@ public function testProcess_payplugFactory_allFlagsEnabled_validatesWithAllConst ->expects(self::once()) ->method('validate') ->willReturnCallback(function ($subject, array $constraints) { - // Base + CAN_SAVE_CARD + CAN_CREATE_DEFERRED_PAYMENT + CAN_USE_INTEGRATED_PAYMENTS - self::assertCount(4, $constraints); + // Base + IsNotCombiningIntegratedPaymentAndHostedFields + CAN_SAVE_CARD + CAN_CREATE_DEFERRED_PAYMENT + CAN_USE_INTEGRATED_PAYMENTS + self::assertCount(5, $constraints); return new ConstraintViolationList(); }) diff --git a/translations/validators.en.yml b/translations/validators.en.yml index 9e68408c..e35756cb 100644 --- a/translations/validators.en.yml +++ b/translations/validators.en.yml @@ -70,4 +70,5 @@ payplug_sylius_payplug_plugin: payplug: can_not_save_method_no_access: | You do not have access to LIVE mode. - To enable it, please complete your onboarding and reconnect. \ No newline at end of file + To enable it, please complete your onboarding and reconnect. + integrated_payment_and_hosted_fields_conflict: 'Integrated Payment and Hosted Fields cannot both be enabled on the same payment method.' \ No newline at end of file diff --git a/translations/validators.fr.yml b/translations/validators.fr.yml index f04a6995..c135d8d7 100644 --- a/translations/validators.fr.yml +++ b/translations/validators.fr.yml @@ -69,4 +69,5 @@ payplug_sylius_payplug_plugin: payplug: can_not_save_method_no_access: | Vous n'avez pas accès au mode LIVE. - Pour l'activer, veuillez terminer votre onboarding et vous reconnecter. \ No newline at end of file + Pour l'activer, veuillez terminer votre onboarding et vous reconnecter. + integrated_payment_and_hosted_fields_conflict: 'Le Paiement Integré et les champs hébergés (Hosted Fields) ne peuvent pas être activés en même temps sur un même moyen de paiement.' \ No newline at end of file diff --git a/translations/validators.it.yml b/translations/validators.it.yml index b5460fc2..cd3ed72d 100644 --- a/translations/validators.it.yml +++ b/translations/validators.it.yml @@ -69,4 +69,5 @@ payplug_sylius_payplug_plugin: payplug: can_not_save_method_no_access: | Non hai accesso alla modalità LIVE. - Per attivarla, completa il tuo onboarding e riconnettiti. \ No newline at end of file + Per attivarla, completa il tuo onboarding e riconnettiti. + integrated_payment_and_hosted_fields_conflict: 'Il pagamento integrato e i campi hosted (Hosted Fields) non possono essere entrambi attivi sullo stesso metodo di pagamento.' \ No newline at end of file From f06d36d45990f6a840c778f285762a4ccc89c441 Mon Sep 17 00:00:00 2001 From: Julien Hoarau Date: Mon, 3 Aug 2026 16:28:11 +0200 Subject: [PATCH 03/22] PRE-3550: register admin form hook for Hosted Fields configuration Co-Authored-By: Claude Sonnet 5 --- config/twig_hooks/admin.yaml | 3 +++ .../payment_method/form/hosted_fields.html.twig | 13 +++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 templates/admin/payment_method/form/hosted_fields.html.twig diff --git a/config/twig_hooks/admin.yaml b/config/twig_hooks/admin.yaml index 0683e861..c3596536 100644 --- a/config/twig_hooks/admin.yaml +++ b/config/twig_hooks/admin.yaml @@ -25,6 +25,9 @@ sylius_twig_hooks: deferred_capture: template: '@PayPlugSyliusPayPlugPlugin/admin/payment_method/form/deferred_capture.html.twig' priority: 0 + hosted_fields: + template: '@PayPlugSyliusPayPlugPlugin/admin/payment_method/form/hosted_fields.html.twig' + priority: 0 'sylius_admin.payment_method.create.content.form.sections.gateway_configuration.payplug_oney': &oneyGateway live_checkbox: *liveCheckbox fees_for: diff --git a/templates/admin/payment_method/form/hosted_fields.html.twig b/templates/admin/payment_method/form/hosted_fields.html.twig new file mode 100644 index 00000000..a27cdce9 --- /dev/null +++ b/templates/admin/payment_method/form/hosted_fields.html.twig @@ -0,0 +1,13 @@ +{% set hostedFieldsCheckbox = hookable_metadata.context.form.gatewayConfig.config.hostedFields %} +{% set hostedFieldsKeyId = hookable_metadata.context.form.gatewayConfig.config.hostedFieldsKeyId %} +{% set hostedFieldsKeyValue = hookable_metadata.context.form.gatewayConfig.config.hostedFieldsKeyValue %} + +
+ {{ form_row(hostedFieldsCheckbox) }} +
+
+ {{ form_row(hostedFieldsKeyId) }} +
+
+ {{ form_row(hostedFieldsKeyValue) }} +
From d2ba4f0174b824f4fb038ba7d06db51c3b1326e5 Mon Sep 17 00:00:00 2001 From: Julien Hoarau Date: Mon, 3 Aug 2026 16:30:52 +0200 Subject: [PATCH 04/22] PRE-3550: add HostedFieldsPaymentProcessorInterface with a no-op stub pending PRE-3551 Co-Authored-By: Claude Sonnet 5 --- config/services.yaml | 3 ++ .../HostedFieldsPaymentProcessorInterface.php | 12 +++++ .../NullHostedFieldsPaymentProcessor.php | 41 +++++++++++++++++ .../NullHostedFieldsPaymentProcessorTest.php | 46 +++++++++++++++++++ 4 files changed, 102 insertions(+) create mode 100644 src/PaymentProcessing/HostedFieldsPaymentProcessorInterface.php create mode 100644 src/PaymentProcessing/NullHostedFieldsPaymentProcessor.php create mode 100644 tests/PHPUnit/PaymentProcessing/NullHostedFieldsPaymentProcessorTest.php diff --git a/config/services.yaml b/config/services.yaml index 2131eff5..49f1b4d0 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -35,6 +35,9 @@ services: PayPlug\SyliusPayPlugPlugin\Provider\OneySimulation\OneySimulationDataProviderInterface: class: PayPlug\SyliusPayPlugPlugin\Provider\OneySimulation\OneySimulationDataProvider + PayPlug\SyliusPayPlugPlugin\PaymentProcessing\HostedFieldsPaymentProcessorInterface: + class: PayPlug\SyliusPayPlugPlugin\PaymentProcessing\NullHostedFieldsPaymentProcessor + payplug_sylius_payplug_plugin.action.capture: class: PayPlug\SyliusPayPlugPlugin\Action\CaptureAction diff --git a/src/PaymentProcessing/HostedFieldsPaymentProcessorInterface.php b/src/PaymentProcessing/HostedFieldsPaymentProcessorInterface.php new file mode 100644 index 00000000..9560a84b --- /dev/null +++ b/src/PaymentProcessing/HostedFieldsPaymentProcessorInterface.php @@ -0,0 +1,12 @@ +logger->info('Hosted Fields token received, awaiting UPC payment processing (PRE-3551).', [ + 'payment_id' => $payment->getId(), + 'selected_brand' => $selectedBrand, + 'save_card' => $saveCard, + ]); + + $payment->setDetails(\array_merge( + $payment->getDetails(), + [ + 'hosted_fields_token' => $hfToken, + 'hosted_fields_selected_brand' => $selectedBrand, + 'hosted_fields_save_card' => $saveCard, + 'status' => PaymentInterface::STATE_PROCESSING, + ], + )); + } +} diff --git a/tests/PHPUnit/PaymentProcessing/NullHostedFieldsPaymentProcessorTest.php b/tests/PHPUnit/PaymentProcessing/NullHostedFieldsPaymentProcessorTest.php new file mode 100644 index 00000000..c6ac578a --- /dev/null +++ b/tests/PHPUnit/PaymentProcessing/NullHostedFieldsPaymentProcessorTest.php @@ -0,0 +1,46 @@ +logger = $this->createMock(LoggerInterface::class); + $this->processor = new NullHostedFieldsPaymentProcessor($this->logger); + } + + public function testProcess_logsAndStoresDetailsWithoutCallingAnyApi(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getId')->willReturn(42); + $payment->method('getDetails')->willReturn(['existing' => 'value']); + + $payment->expects(self::once()) + ->method('setDetails') + ->with([ + 'existing' => 'value', + 'hosted_fields_token' => 'hf_token_123', + 'hosted_fields_selected_brand' => 'CB', + 'hosted_fields_save_card' => true, + 'status' => PaymentInterface::STATE_PROCESSING, + ]) + ; + + $this->logger->expects(self::once())->method('info'); + + $this->processor->process($payment, 'hf_token_123', 'CB', true); + } +} From 8cd627d63b22689f1d966d677c0ee382a3c82d28 Mon Sep 17 00:00:00 2001 From: Julien Hoarau Date: Mon, 3 Aug 2026 16:34:56 +0200 Subject: [PATCH 05/22] PRE-3550: relay Hosted Fields token to HostedFieldsPaymentProcessorInterface --- .../PostPaymentSelectEventSubscriber.php | 51 +++++++++- .../PostPaymentSelectEventSubscriberTest.php | 98 +++++++++++++++++++ 2 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 tests/PHPUnit/EventSubscriber/PostPaymentSelectEventSubscriberTest.php diff --git a/src/EventSubscriber/PostPaymentSelectEventSubscriber.php b/src/EventSubscriber/PostPaymentSelectEventSubscriber.php index 9991e0b3..878ade52 100644 --- a/src/EventSubscriber/PostPaymentSelectEventSubscriber.php +++ b/src/EventSubscriber/PostPaymentSelectEventSubscriber.php @@ -5,6 +5,7 @@ namespace PayPlug\SyliusPayPlugPlugin\EventSubscriber; use Doctrine\ORM\EntityManagerInterface; +use PayPlug\SyliusPayPlugPlugin\PaymentProcessing\HostedFieldsPaymentProcessorInterface; use Sylius\Abstraction\StateMachine\StateMachineInterface; use Sylius\Bundle\ResourceBundle\Event\ResourceControllerEvent; use Sylius\Component\Core\Model\OrderInterface; @@ -25,26 +26,36 @@ final class PostPaymentSelectEventSubscriber implements EventSubscriberInterface private const TOKEN_FIELD = 'payplug_integrated_payment_token'; + private const HOSTED_FIELDS_TOKEN_FIELD = 'hostedfields_token'; + + private const HOSTED_FIELDS_SELECTED_BRAND_FIELD = 'hostedfields_selected_brand'; + + private const HOSTED_FIELDS_SAVE_CARD_FIELD = 'hostedfields_save_card'; + public function __construct( private RequestStack $requestStack, private EntityManagerInterface $entityManager, private StateMachineInterface $stateMachine, + private HostedFieldsPaymentProcessorInterface $hostedFieldsPaymentProcessor, ) { } public static function getSubscribedEvents(): array { return [ - RequestEvent::class => 'alterRequestConfigurationForIntegratedPayment', + RequestEvent::class => 'alterRequestConfigurationForInlineCardCapture', 'sylius.order.post_payment' => 'handle', 'sylius.order.post_update' => 'handle', ]; } - public function alterRequestConfigurationForIntegratedPayment(RequestEvent $event): void + public function alterRequestConfigurationForInlineCardCapture(RequestEvent $event): void { $request = $event->getRequest(); - if (!$this->hasToken($request) || self::CHECKOUT_ROUTE !== $request->attributes->get('_route')) { + if ( + (!$this->hasToken($request) && !$this->hasHostedFieldsToken($request)) + || self::CHECKOUT_ROUTE !== $request->attributes->get('_route') + ) { return; } if (!$request->attributes->has('_sylius')) { @@ -82,6 +93,12 @@ public function handle(ResourceControllerEvent $resourceControllerEvent): void return; } + if ($this->hasHostedFieldsToken($request)) { + $this->handleHostedFieldsToken($request, $lastPayment); + + return; + } + if (!$this->hasToken($request)) { return; } @@ -128,6 +145,34 @@ private function getToken(Request $request): string return $token; } + private function hasHostedFieldsToken(Request $request): bool + { + if (!$request->request->has(self::HOSTED_FIELDS_TOKEN_FIELD)) { + return false; + } + + return '' !== $this->getRequestField($request, self::HOSTED_FIELDS_TOKEN_FIELD); + } + + private function handleHostedFieldsToken(Request $request, PaymentInterface $lastPayment): void + { + $hfToken = $this->getRequestField($request, self::HOSTED_FIELDS_TOKEN_FIELD); + $selectedBrand = $this->getRequestField($request, self::HOSTED_FIELDS_SELECTED_BRAND_FIELD); + $saveCard = 'true' === $request->request->get(self::HOSTED_FIELDS_SAVE_CARD_FIELD, 'false'); + + $this->hostedFieldsPaymentProcessor->process($lastPayment, $hfToken, $selectedBrand, $saveCard); + + $this->applyToComplete($lastPayment->getOrder() ?? throw new \LogicException('Order not found for payment')); + } + + private function getRequestField(Request $request, string $field): string + { + $value = $request->request->get($field, ''); + Assert::string($value); + + return $value; + } + private function applyToComplete(OrderInterface $order): void { if ($this->stateMachine->can($order, OrderCheckoutTransitions::GRAPH, OrderCheckoutTransitions::TRANSITION_COMPLETE)) { diff --git a/tests/PHPUnit/EventSubscriber/PostPaymentSelectEventSubscriberTest.php b/tests/PHPUnit/EventSubscriber/PostPaymentSelectEventSubscriberTest.php new file mode 100644 index 00000000..d9582911 --- /dev/null +++ b/tests/PHPUnit/EventSubscriber/PostPaymentSelectEventSubscriberTest.php @@ -0,0 +1,98 @@ +requestStack = $this->createMock(RequestStack::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->stateMachine = $this->createMock(StateMachineInterface::class); + $this->hostedFieldsPaymentProcessor = $this->createMock(HostedFieldsPaymentProcessorInterface::class); + + $this->subscriber = new PostPaymentSelectEventSubscriber( + $this->requestStack, + $this->entityManager, + $this->stateMachine, + $this->hostedFieldsPaymentProcessor, + ); + } + + public function testHandle_withHostedFieldsToken_delegatesToProcessorAndCompletesCheckout(): void + { + $request = Request::create('/checkout/select-payment', 'POST', [ + 'hostedfields_token' => 'hf_token_abc', + 'hostedfields_selected_brand' => 'VISA', + 'hostedfields_save_card' => 'true', + ]); + $request->attributes->set('_route', 'sylius_shop_checkout_select_payment'); + $this->requestStack->method('getCurrentRequest')->willReturn($request); + + $payment = $this->createMock(PaymentInterface::class); + $order = $this->createMock(OrderInterface::class); + $order->method('getLastPayment')->willReturn($payment); + $payment->method('getOrder')->willReturn($order); + + $event = $this->createMock(ResourceControllerEvent::class); + $event->method('getSubject')->willReturn($order); + + $this->hostedFieldsPaymentProcessor->expects(self::once()) + ->method('process') + ->with($payment, 'hf_token_abc', 'VISA', true) + ; + + $this->stateMachine->method('can') + ->with($order, OrderCheckoutTransitions::GRAPH, OrderCheckoutTransitions::TRANSITION_COMPLETE) + ->willReturn(true) + ; + $this->stateMachine->expects(self::once())->method('apply'); + $this->entityManager->expects(self::once())->method('flush'); + + $this->subscriber->handle($event); + } + + public function testHandle_withoutAnyToken_doesNothing(): void + { + $request = Request::create('/checkout/select-payment', 'POST'); + $request->attributes->set('_route', 'sylius_shop_checkout_select_payment'); + $this->requestStack->method('getCurrentRequest')->willReturn($request); + + $payment = $this->createMock(PaymentInterface::class); + $order = $this->createMock(OrderInterface::class); + $order->method('getLastPayment')->willReturn($payment); + + $event = $this->createMock(ResourceControllerEvent::class); + $event->method('getSubject')->willReturn($order); + + $this->hostedFieldsPaymentProcessor->expects(self::never())->method('process'); + $this->entityManager->expects(self::never())->method('flush'); + + $this->subscriber->handle($event); + } +} From a354361b0e90b63512c48adb6f99a84c2d5d8f8c Mon Sep 17 00:00:00 2001 From: Julien Hoarau Date: Mon, 3 Aug 2026 16:44:36 +0200 Subject: [PATCH 06/22] PRE-3550: add Hosted Fields shop checkout template --- templates/shop/hosted_fields/index.html.twig | 44 +++++++++++++++++++ .../shop/select_payment/_payplug.html.twig | 13 +++++- translations/messages.en.yml | 5 +++ translations/messages.fr.yml | 5 +++ translations/messages.it.yml | 5 +++ 5 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 templates/shop/hosted_fields/index.html.twig diff --git a/templates/shop/hosted_fields/index.html.twig b/templates/shop/hosted_fields/index.html.twig new file mode 100644 index 00000000..318502eb --- /dev/null +++ b/templates/shop/hosted_fields/index.html.twig @@ -0,0 +1,44 @@ +{% set config = paymentMethod.gatewayConfig.config %} + + + +
+
+ {{ 'sylius.ui.loading'|trans }} +
+
+
+
+
+
+ {% if is_save_card_enabled(paymentMethod) %} +
+ {# No name to not trigger LiveComponent #} + +
+ {% endif %} +
+ +
+
+ + + + diff --git a/templates/shop/select_payment/_payplug.html.twig b/templates/shop/select_payment/_payplug.html.twig index cb5fa9e5..bf684aed 100644 --- a/templates/shop/select_payment/_payplug.html.twig +++ b/templates/shop/select_payment/_payplug.html.twig @@ -21,7 +21,12 @@ {% set integratedPayment = true %} {% endif %} -
+{% set hostedFields = false %} +{% if method.gatewayConfig.config.hostedFields is defined and method.gatewayConfig.config.hostedFields is same as true %} + {% set hostedFields = true %} +{% endif %} + +
{% if hasSavedCards %}
{{ form_row(form.parent.parent.payplug_card_choice) }} @@ -36,4 +41,10 @@ 'paymentInputId': form.vars.id, } %} {% endif %} + + {% if hostedFields %} + {% include '@PayPlugSyliusPayPlugPlugin/shop/hosted_fields/index.html.twig' with { + 'paymentMethod': method, + } %} + {% endif %}
diff --git a/translations/messages.en.yml b/translations/messages.en.yml index 4fa496a3..3645bc8d 100644 --- a/translations/messages.en.yml +++ b/translations/messages.en.yml @@ -108,6 +108,11 @@ payplug_sylius_payplug_plugin: place_order.label: 'Place order' transaction_secure.label: 'Transaction secured by' privacy_policy.label: 'Privacy Policy' + hosted_fields: + error.tokenization_failed: 'Your card details could not be validated. Please check them and try again.' + error.unsupported_brand: 'This card brand is not supported for this payment method. Please use a different card.' + save_card.label: 'Save my card' + place_order.label: 'Place order' deferred_capture: process_order_info: | You will be charged when your order is processed. diff --git a/translations/messages.fr.yml b/translations/messages.fr.yml index 342cb63f..a23c3ee4 100644 --- a/translations/messages.fr.yml +++ b/translations/messages.fr.yml @@ -127,6 +127,11 @@ payplug_sylius_payplug_plugin: place_order.label: 'Confirmer le paiement' transaction_secure.label: 'Transaction sécurisée par' privacy_policy.label: 'Politique de confidentialité' + hosted_fields: + error.tokenization_failed: 'Les informations de votre carte n’ont pas pu être validées. Veuillez les vérifier et réessayer.' + error.unsupported_brand: 'Cette marque de carte n’est pas prise en charge pour ce moyen de paiement. Merci d’utiliser une autre carte.' + save_card.label: 'Enregistrer ma carte bancaire' + place_order.label: 'Confirmer le paiement' deferred_capture: process_order_info: | Vous serez prélevé(é) lors du traitement de votre commande. diff --git a/translations/messages.it.yml b/translations/messages.it.yml index 15d5cba6..b6eb5c37 100644 --- a/translations/messages.it.yml +++ b/translations/messages.it.yml @@ -108,6 +108,11 @@ payplug_sylius_payplug_plugin: place_order.label: 'Ordine' transaction_secure.label: 'Transazione protetta da' privacy_policy.label: 'Politica di confidenzialità' + hosted_fields: + error.tokenization_failed: 'Non è stato possibile verificare i dati della tua carta. Controllali e riprova.' + error.unsupported_brand: 'Questo marchio di carta non è supportato per questo metodo di pagamento. Utilizza un’altra carta.' + save_card.label: 'Salva la mia carta' + place_order.label: 'Ordine' deferred_capture: process_order_info: | L'addebito avverrà al momento dell'elaborazione dell'ordine. From 98d4edaac097490daea1e8f3aa04701cb9bfd6c5 Mon Sep 17 00:00:00 2001 From: Julien Hoarau Date: Mon, 3 Aug 2026 16:48:12 +0200 Subject: [PATCH 07/22] PRE-3550: add Hosted Fields Stimulus controller --- assets/controllers.json | 7 ++ .../controllers/hosted-fields_controller.js | 77 +++++++++++++++++++ assets/shop/dist/payment/hosted-fields.css | 19 +++++ 3 files changed, 103 insertions(+) create mode 100644 assets/shop/controllers/hosted-fields_controller.js create mode 100644 assets/shop/dist/payment/hosted-fields.css diff --git a/assets/controllers.json b/assets/controllers.json index 5582d953..9d9dfddd 100644 --- a/assets/controllers.json +++ b/assets/controllers.json @@ -16,6 +16,13 @@ "@payplug/sylius-payplug-plugin/shop/dist/payment/integrated.css": true } }, + "hosted-fields": { + "enabled": true, + "fetch": "lazy", + "autoimport": { + "@payplug/sylius-payplug-plugin/shop/dist/payment/hosted-fields.css": true + } + }, "oney-payment": { "enabled": true, "fetch": "lazy" diff --git a/assets/shop/controllers/hosted-fields_controller.js b/assets/shop/controllers/hosted-fields_controller.js new file mode 100644 index 00000000..c2361501 --- /dev/null +++ b/assets/shop/controllers/hosted-fields_controller.js @@ -0,0 +1,77 @@ +import { Controller } from '@hotwired/stimulus'; + +const ALLOWED_BRANDS = ['CB', 'VISA', 'MASTERCARD']; + +/* stimulusFetch: 'lazy' */ +export default class extends Controller { + static targets = ['error', 'submitButton']; + + connect() { + if (typeof payplug_hosted_fields_params === 'undefined') { + return; + } + + this.form = this.element.closest('form'); + this.hfields = window.dalenys.hostedFields({ + key: { + id: payplug_hosted_fields_params.key_id, + value: payplug_hosted_fields_params.key_value, + }, + fields: { + brand: { id: 'brand-container' }, + card: { id: 'card-container' }, + expiry: { id: 'expiry-container' }, + cryptogram: { id: 'cvv-container' }, + }, + location: payplug_hosted_fields_params.locale, + }); + this.hfields.load(); + + if (this.hasSubmitButtonTarget) { + this.submitButtonTarget.addEventListener('click', (event) => { + event.preventDefault(); + this.tokenizeAndSubmit(); + }); + } + } + + tokenizeAndSubmit() { + this.hideError(); + this.hfields.createToken((result) => { + if (result.execCode !== '0000') { + this.showError(payplug_hosted_fields_params.error.tokenization_failed); + return; + } + + const selectedBrand = (result.selectedBrand || '').toUpperCase(); + if (!ALLOWED_BRANDS.includes(selectedBrand)) { + this.showError(payplug_hosted_fields_params.error.unsupported_brand); + return; + } + + const saveCardElement = this.element.querySelector('#hostedfields_savecard'); + const saveCard = null !== saveCardElement && saveCardElement.checked; + + this.form.querySelector('#hostedfields_token').value = result.hfToken; + this.form.querySelector('#hostedfields_selected_brand').value = selectedBrand; + this.form.querySelector('#hostedfields_save_card').value = saveCard ? 'true' : 'false'; + this.form.submit(); + }); + } + + showError(message) { + if (!this.hasErrorTarget) { + return; + } + this.errorTarget.textContent = message; + this.errorTarget.classList.remove('payplugHostedFields__error--hide'); + } + + hideError() { + if (!this.hasErrorTarget) { + return; + } + this.errorTarget.textContent = ''; + this.errorTarget.classList.add('payplugHostedFields__error--hide'); + } +} diff --git a/assets/shop/dist/payment/hosted-fields.css b/assets/shop/dist/payment/hosted-fields.css new file mode 100644 index 00000000..55f7704b --- /dev/null +++ b/assets/shop/dist/payment/hosted-fields.css @@ -0,0 +1,19 @@ +.payplugHostedFields__container { + margin-bottom: 12px; +} + +.payplugHostedFields__container--brand, +.payplugHostedFields__container--card, +.payplugHostedFields__container--expiry, +.payplugHostedFields__container--cvv { + min-height: 40px; +} + +.payplugHostedFields__error { + color: #E91932; + margin-bottom: 12px; +} + +.payplugHostedFields__error--hide { + display: none; +} From f3b04e40bb847d74f9acd1b411a3149d96cfe209 Mon Sep 17 00:00:00 2001 From: Julien Hoarau Date: Mon, 3 Aug 2026 16:56:12 +0200 Subject: [PATCH 08/22] PRE-3550: add Behat coverage for Hosted Fields checkout visibility --- .../shop/hosted_fields_payment_method.feature | 23 +++++++++++++++++ tests/Behat/Context/Setup/PayPlugContext.php | 25 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 features/shop/hosted_fields_payment_method.feature diff --git a/features/shop/hosted_fields_payment_method.feature b/features/shop/hosted_fields_payment_method.feature new file mode 100644 index 00000000..5b47b37e --- /dev/null +++ b/features/shop/hosted_fields_payment_method.feature @@ -0,0 +1,23 @@ +@paying_with_payplug_for_order +Feature: Paying with Hosted Fields during checkout + In order to buy products + As a Customer + I want to see Hosted Fields as a distinct payment method at checkout + + Background: + Given the store operates on a single channel in "United States" + And that channel also allows to shop using the "EUR" currency + And there is a user "john@bitbag.pl" identified by "password123" + And I changed my currency to "EUR" + And the store has a payment method "PayPlug Hosted Fields" with a code "payplug_hosted_fields" and PayPlug Hosted Fields payment gateway + And the store ships everywhere for free + And the store has "DHL" shipping method with "$0.00" fee + And I am logged in as "john@bitbag.pl" + + @ui + Scenario: I can see and select the Hosted Fields payment method + Given the store has a product "PHP T-Shirt" priced at "€50.00" + And I added product "PHP T-Shirt" to the cart + And I chose "DHL" shipping method + Then I should be on the checkout payment step + And I should be able to select "PayPlug Hosted Fields" payment method diff --git a/tests/Behat/Context/Setup/PayPlugContext.php b/tests/Behat/Context/Setup/PayPlugContext.php index 493e38b6..5b82a8e1 100644 --- a/tests/Behat/Context/Setup/PayPlugContext.php +++ b/tests/Behat/Context/Setup/PayPlugContext.php @@ -67,6 +67,31 @@ public function theStoreHasAPaymentMethodWithACodeAndPayPlugPaymentGateway( $this->paymentMethodManager->flush(); } + /** + * @Given the store has a payment method :paymentMethodName with a code :paymentMethodCode and PayPlug Hosted Fields payment gateway + */ + public function theStoreHasAPaymentMethodWithACodeAndPayPlugHostedFieldsPaymentGateway( + string $paymentMethodName, + string $paymentMethodCode, + ): void { + $paymentMethod = $this->createPaymentMethodPayPlug( + $paymentMethodName, + $paymentMethodCode, + PayPlugGatewayFactory::FACTORY_NAME, + 'PayPlug', + ); + + $paymentMethod->getGatewayConfig()->setConfig([ + 'secretKey' => 'test', + 'payum.http_client' => '@payplug_sylius_payplug_plugin.api_client.payplug', + PayPlugGatewayFactory::HOSTED_FIELDS => true, + PayPlugGatewayFactory::HOSTED_FIELDS_KEY_ID => 'test-key-id', + PayPlugGatewayFactory::HOSTED_FIELDS_KEY_VALUE => 'test-key-value', + ]); + + $this->paymentMethodManager->flush(); + } + /** * @Given the store has a payment method :paymentMethodName with a code :paymentMethodCode and Oney payment gateway */ From 1a2d7918b06e8e8f566e834fd8f9d7b52dde6ba7 Mon Sep 17 00:00:00 2001 From: Julien Hoarau Date: Mon, 3 Aug 2026 17:32:27 +0200 Subject: [PATCH 09/22] PRE-3550: allow several payment methods on the payplug gateway factory A merchant must be able to offer Integrated Payment and Hosted Fields side by side, which requires two PaymentMethod entities sharing factoryName=payplug. canBeCreated() now bypasses the duplicate check for that factory only; every other PayPlug-family factory (Oney, Bancontact, Amex, Apple Pay, Scalapay, Wero) keeps the one-payment-method-per-factory rule. Co-Authored-By: Claude Sonnet 5 --- .../Type/AbstractGatewayConfigurationType.php | 7 ++ .../AbstractGatewayConfigurationTypeTest.php | 88 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 tests/PHPUnit/Gateway/Form/Type/AbstractGatewayConfigurationTypeTest.php diff --git a/src/Gateway/Form/Type/AbstractGatewayConfigurationType.php b/src/Gateway/Form/Type/AbstractGatewayConfigurationType.php index 80fce24a..954f0795 100644 --- a/src/Gateway/Form/Type/AbstractGatewayConfigurationType.php +++ b/src/Gateway/Form/Type/AbstractGatewayConfigurationType.php @@ -94,6 +94,13 @@ public function buildForm(FormBuilderInterface $builder, array $options): void private function canBeCreated(string $factoryName): bool { + // The main PayPlug gateway is intentionally exempt from the one-payment-method-per-factory + // rule: a merchant must be able to offer Integrated Payment and Hosted Fields side by side, + // which requires two PaymentMethod entities on the same `payplug` factory (PRE-3550). + if (PayPlugGatewayFactory::FACTORY_NAME === $factoryName) { + return true; + } + $alreadyExists = $this->gatewayConfigRepository->findOneBy(['factoryName' => $factoryName]); return !$alreadyExists instanceof GatewayConfigInterface; diff --git a/tests/PHPUnit/Gateway/Form/Type/AbstractGatewayConfigurationTypeTest.php b/tests/PHPUnit/Gateway/Form/Type/AbstractGatewayConfigurationTypeTest.php new file mode 100644 index 00000000..bbbe6883 --- /dev/null +++ b/tests/PHPUnit/Gateway/Form/Type/AbstractGatewayConfigurationTypeTest.php @@ -0,0 +1,88 @@ +gatewayConfigRepository = $this->createMock(RepositoryInterface::class); + + $this->type = new AbstractGatewayConfigurationType( + $this->createMock(TranslatorInterface::class), + $this->gatewayConfigRepository, + $this->createMock(RequestStack::class), + ); + } + + /** + * The `payplug` factory is exempt: Integrated Payment and Hosted Fields need two distinct + * PaymentMethod entities sharing that factory name (PRE-3550). + */ + public function testCanBeCreated_payplugFactory_alwaysAllowedAndSkipsLookup(): void + { + $this->gatewayConfigRepository->expects(self::never())->method('findOneBy'); + + self::assertTrue($this->canBeCreated(PayPlugGatewayFactory::FACTORY_NAME)); + } + + /** + * Every other PayPlug-family factory keeps the duplicate check. + */ + 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; + } +} From 6008de42712583877b457e3c34d9487980c0af40 Mon Sep 17 00:00:00 2001 From: Julien Hoarau Date: Mon, 3 Aug 2026 17:32:38 +0200 Subject: [PATCH 10/22] PRE-3550: only accept a Hosted Fields token when the flag is enabled handleHostedFieldsToken() used to process any request carrying a non-empty hostedfields_token, so a crafted POST could complete checkout through that path for any payment method. It now verifies the payment method's gateway config actually has hostedFields=true before delegating to the processor. Also adds coverage for alterRequestConfigurationForInlineCardCapture(). Co-Authored-By: Claude Sonnet 5 --- .../PostPaymentSelectEventSubscriber.php | 14 ++ .../PostPaymentSelectEventSubscriberTest.php | 127 ++++++++++++++++++ 2 files changed, 141 insertions(+) diff --git a/src/EventSubscriber/PostPaymentSelectEventSubscriber.php b/src/EventSubscriber/PostPaymentSelectEventSubscriber.php index 878ade52..b4219f10 100644 --- a/src/EventSubscriber/PostPaymentSelectEventSubscriber.php +++ b/src/EventSubscriber/PostPaymentSelectEventSubscriber.php @@ -5,6 +5,7 @@ namespace PayPlug\SyliusPayPlugPlugin\EventSubscriber; use Doctrine\ORM\EntityManagerInterface; +use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; use PayPlug\SyliusPayPlugPlugin\PaymentProcessing\HostedFieldsPaymentProcessorInterface; use Sylius\Abstraction\StateMachine\StateMachineInterface; use Sylius\Bundle\ResourceBundle\Event\ResourceControllerEvent; @@ -156,6 +157,12 @@ private function hasHostedFieldsToken(Request $request): bool private function handleHostedFieldsToken(Request $request, PaymentInterface $lastPayment): void { + // Guard against a crafted POST completing checkout through this path for a payment + // method that does not actually have Hosted Fields enabled. + if (!$this->isHostedFieldsEnabled($lastPayment)) { + return; + } + $hfToken = $this->getRequestField($request, self::HOSTED_FIELDS_TOKEN_FIELD); $selectedBrand = $this->getRequestField($request, self::HOSTED_FIELDS_SELECTED_BRAND_FIELD); $saveCard = 'true' === $request->request->get(self::HOSTED_FIELDS_SAVE_CARD_FIELD, 'false'); @@ -165,6 +172,13 @@ private function handleHostedFieldsToken(Request $request, PaymentInterface $las $this->applyToComplete($lastPayment->getOrder() ?? throw new \LogicException('Order not found for payment')); } + private function isHostedFieldsEnabled(PaymentInterface $payment): bool + { + $config = $payment->getMethod()?->getGatewayConfig()?->getConfig() ?? []; + + return true === ($config[PayPlugGatewayFactory::HOSTED_FIELDS] ?? false); + } + private function getRequestField(Request $request, string $field): string { $value = $request->request->get($field, ''); diff --git a/tests/PHPUnit/EventSubscriber/PostPaymentSelectEventSubscriberTest.php b/tests/PHPUnit/EventSubscriber/PostPaymentSelectEventSubscriberTest.php index d9582911..bd709742 100644 --- a/tests/PHPUnit/EventSubscriber/PostPaymentSelectEventSubscriberTest.php +++ b/tests/PHPUnit/EventSubscriber/PostPaymentSelectEventSubscriberTest.php @@ -6,6 +6,7 @@ use Doctrine\ORM\EntityManagerInterface; use PayPlug\SyliusPayPlugPlugin\EventSubscriber\PostPaymentSelectEventSubscriber; +use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; use PayPlug\SyliusPayPlugPlugin\PaymentProcessing\HostedFieldsPaymentProcessorInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -13,9 +14,13 @@ use Sylius\Bundle\ResourceBundle\Event\ResourceControllerEvent; use Sylius\Component\Core\Model\OrderInterface; use Sylius\Component\Core\Model\PaymentInterface; +use Sylius\Component\Core\Model\PaymentMethodInterface; use Sylius\Component\Core\OrderCheckoutTransitions; +use Sylius\Component\Payment\Model\GatewayConfigInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; final class PostPaymentSelectEventSubscriberTest extends TestCase { @@ -55,6 +60,9 @@ public function testHandle_withHostedFieldsToken_delegatesToProcessorAndComplete $this->requestStack->method('getCurrentRequest')->willReturn($request); $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn( + $this->buildPaymentMethod([PayPlugGatewayFactory::HOSTED_FIELDS => true]), + ); $order = $this->createMock(OrderInterface::class); $order->method('getLastPayment')->willReturn($payment); $payment->method('getOrder')->willReturn($order); @@ -77,6 +85,60 @@ public function testHandle_withHostedFieldsToken_delegatesToProcessorAndComplete $this->subscriber->handle($event); } + /** + * A crafted POST carrying a hosted fields token must not be able to complete checkout + * for a payment method that does not have the Hosted Fields flag enabled. + */ + public function testHandle_withHostedFieldsTokenButFlagDisabled_doesNotProcessNorCompleteCheckout(): void + { + $request = Request::create('/checkout/select-payment', 'POST', [ + 'hostedfields_token' => 'hf_token_abc', + 'hostedfields_selected_brand' => 'VISA', + 'hostedfields_save_card' => 'true', + ]); + $request->attributes->set('_route', 'sylius_shop_checkout_select_payment'); + $this->requestStack->method('getCurrentRequest')->willReturn($request); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn( + $this->buildPaymentMethod([PayPlugGatewayFactory::HOSTED_FIELDS => false]), + ); + $order = $this->createMock(OrderInterface::class); + $order->method('getLastPayment')->willReturn($payment); + $payment->method('getOrder')->willReturn($order); + + $event = $this->createMock(ResourceControllerEvent::class); + $event->method('getSubject')->willReturn($order); + + $this->hostedFieldsPaymentProcessor->expects(self::never())->method('process'); + $this->stateMachine->expects(self::never())->method('apply'); + $this->entityManager->expects(self::never())->method('flush'); + + $this->subscriber->handle($event); + } + + public function testHandle_withHostedFieldsTokenButNoPaymentMethod_doesNotProcess(): void + { + $request = Request::create('/checkout/select-payment', 'POST', [ + 'hostedfields_token' => 'hf_token_abc', + ]); + $request->attributes->set('_route', 'sylius_shop_checkout_select_payment'); + $this->requestStack->method('getCurrentRequest')->willReturn($request); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn(null); + $order = $this->createMock(OrderInterface::class); + $order->method('getLastPayment')->willReturn($payment); + + $event = $this->createMock(ResourceControllerEvent::class); + $event->method('getSubject')->willReturn($order); + + $this->hostedFieldsPaymentProcessor->expects(self::never())->method('process'); + $this->entityManager->expects(self::never())->method('flush'); + + $this->subscriber->handle($event); + } + public function testHandle_withoutAnyToken_doesNothing(): void { $request = Request::create('/checkout/select-payment', 'POST'); @@ -95,4 +157,69 @@ public function testHandle_withoutAnyToken_doesNothing(): void $this->subscriber->handle($event); } + + // ------------------------------------------------------------------------- + // alterRequestConfigurationForInlineCardCapture() + // ------------------------------------------------------------------------- + + /** + * Integrated Payment relays a real PayPlug payment_id, so the redirect override to + * `sylius_shop_order_pay` (Payum capture/status) must stay in place. + */ + public function testAlterRequestConfiguration_withIntegratedPaymentToken_overridesRedirect(): void + { + $request = Request::create('/checkout/select-payment', 'POST', [ + 'payplug_integrated_payment_token' => 'pay_123', + ]); + $request->attributes->set('_route', 'sylius_shop_checkout_select_payment'); + $request->attributes->set('_sylius', ['redirect' => ['route' => 'sylius_shop_checkout_complete']]); + + $this->subscriber->alterRequestConfigurationForInlineCardCapture($this->buildRequestEvent($request)); + + self::assertSame( + [ + 'redirect' => [ + 'route' => 'sylius_shop_order_pay', + 'parameters' => ['tokenValue' => 'resource.tokenValue'], + ], + ], + $request->attributes->get('_sylius'), + ); + } + + public function testAlterRequestConfiguration_withoutAnyToken_leavesRedirectUntouched(): void + { + $request = Request::create('/checkout/select-payment', 'POST'); + $request->attributes->set('_route', 'sylius_shop_checkout_select_payment'); + $syliusRequestConfig = ['redirect' => ['route' => 'sylius_shop_checkout_complete']]; + $request->attributes->set('_sylius', $syliusRequestConfig); + + $this->subscriber->alterRequestConfigurationForInlineCardCapture($this->buildRequestEvent($request)); + + self::assertSame($syliusRequestConfig, $request->attributes->get('_sylius')); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function buildRequestEvent(Request $request): RequestEvent + { + return new RequestEvent( + $this->createMock(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + ); + } + + private function buildPaymentMethod(array $config): PaymentMethodInterface&MockObject + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getConfig')->willReturn($config); + + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig); + + return $paymentMethod; + } } From 0d6d1b4563b03ee3bdc64967096165f3e40534f1 Mon Sep 17 00:00:00 2001 From: Julien Hoarau Date: Mon, 3 Aug 2026 17:32:46 +0200 Subject: [PATCH 11/22] PRE-3550: mount the Hosted Fields iframes only once the method is selected Three related frontend fixes: - the wrapping div now carries data-payment-inline-submit="true", so the generic checkout "next step" button is disabled while Hosted Fields is selected (clicking it submitted an empty hostedfields_token, bypassing tokenization); - connect() no longer calls dalenys.hostedFields(...).load() unconditionally. The cross-origin iframes were mounted into a container that is still hidden at connect time (see shop/select_payment/choice.html.twig). The controller now mirrors integrated-payment: container target, idempotent openFields()/ closeFields() and handleShow()/handleHide(), loading on selection only; - the saved-card radios now pipe handleHide/handleShow to the hosted-fields controller alongside integrated-payment, so picking a saved card hides the Hosted Fields form when both oneClick and hostedFields are enabled. Co-Authored-By: Claude Sonnet 5 --- .../controllers/hosted-fields_controller.js | 94 +++++++++++++++++-- assets/shop/dist/payment/hosted-fields.css | 12 +++ ...lius_checkout_select_payment_row.html.twig | 8 +- templates/shop/hosted_fields/index.html.twig | 7 +- 4 files changed, 110 insertions(+), 11 deletions(-) diff --git a/assets/shop/controllers/hosted-fields_controller.js b/assets/shop/controllers/hosted-fields_controller.js index c2361501..fb9240c0 100644 --- a/assets/shop/controllers/hosted-fields_controller.js +++ b/assets/shop/controllers/hosted-fields_controller.js @@ -4,7 +4,7 @@ const ALLOWED_BRANDS = ['CB', 'VISA', 'MASTERCARD']; /* stimulusFetch: 'lazy' */ export default class extends Controller { - static targets = ['error', 'submitButton']; + static targets = ['container', 'error', 'submitButton']; connect() { if (typeof payplug_hosted_fields_params === 'undefined') { @@ -12,6 +12,86 @@ export default class extends Controller { } this.form = this.element.closest('form'); + this.hfields = null; + + if (this.hasSubmitButtonTarget) { + this.submitButtonTarget.addEventListener('click', (event) => { + event.preventDefault(); + this.tokenizeAndSubmit(); + }); + } + + // Stimulus connects as soon as the markup is in the DOM, even though the payment method + // container starts hidden (see shop/select_payment/choice.html.twig). Mounting the + // cross-origin Dalenys iframes into a display:none container breaks their rendering, so + // load them only once this payment method is actually selected. + const isChecked = this.getPaymentMethodSelectors({ + methodCode: payplug_hosted_fields_params.payment_method_code, + checked: true, + }); + if (isChecked.length) { + this.openFields(); + } + + this.getPaymentMethodSelectors().forEach((element) => { + element.addEventListener('change', (e) => { + if (payplug_hosted_fields_params.payment_method_code === e.currentTarget.value && e.currentTarget.checked) { + this.openFields(); + } + }); + }); + } + + handleShow(event) { + if (this.hasContainerTarget) { + import('jquery').then(({ default: $ }) => { + $(this.containerTarget).slideDown(); + }); + this.openFields(); + this.containerTarget.dataset.paymentInlineSubmit = "true"; + this.element.dispatchEvent(new CustomEvent('payment-method-state-change', { bubbles: true })); + } + } + + handleHide(event) { + if (this.hasContainerTarget) { + import('jquery').then(({ default: $ }) => { + $(this.containerTarget).slideUp(); + }); + this.closeFields(); + this.containerTarget.dataset.paymentInlineSubmit = "false"; + this.element.dispatchEvent(new CustomEvent('payment-method-state-change', { bubbles: true })); + } + } + + getPaymentMethodSelectors({ methodCode, checked } = {}) { + const baseSelector = '[id*=checkout_select_payment_payments]'; + + if (methodCode) { + if (checked) { + return document.querySelectorAll(`${baseSelector}[value=${methodCode}]:checked`); + } + return document.querySelectorAll(`${baseSelector}[value=${methodCode}]`); + } + return document.querySelectorAll(baseSelector); + } + + openFields() { + if (this.hasContainerTarget) { + this.containerTarget.classList.add('payplugHostedFields--loaded'); + } + if (null === this.hfields) { + this.load(); + } + } + + closeFields() { + if (this.hasContainerTarget) { + this.containerTarget.classList.remove('payplugHostedFields--loaded'); + } + } + + load() { this.hfields = window.dalenys.hostedFields({ key: { id: payplug_hosted_fields_params.key_id, @@ -26,16 +106,14 @@ export default class extends Controller { location: payplug_hosted_fields_params.locale, }); this.hfields.load(); - - if (this.hasSubmitButtonTarget) { - this.submitButtonTarget.addEventListener('click', (event) => { - event.preventDefault(); - this.tokenizeAndSubmit(); - }); - } } tokenizeAndSubmit() { + if (null === this.hfields) { + // Fields were never mounted (payment method not selected yet): nothing to tokenize. + return; + } + this.hideError(); this.hfields.createToken((result) => { if (result.execCode !== '0000') { diff --git a/assets/shop/dist/payment/hosted-fields.css b/assets/shop/dist/payment/hosted-fields.css index 55f7704b..6fc3505f 100644 --- a/assets/shop/dist/payment/hosted-fields.css +++ b/assets/shop/dist/payment/hosted-fields.css @@ -1,3 +1,15 @@ +/* + * Mirrors .payplugIntegratedPayment / --loaded: the hosted iframes are only mounted once this + * payment method is actually selected, so the markup stays hidden until the controller reveals it. + */ +.payplugHostedFields { + display: none; +} + +.payplugHostedFields--loaded { + display: block; +} + .payplugHostedFields__container { margin-bottom: 12px; } diff --git a/templates/form/sylius_checkout_select_payment_row.html.twig b/templates/form/sylius_checkout_select_payment_row.html.twig index 6c27adff..681c1183 100644 --- a/templates/form/sylius_checkout_select_payment_row.html.twig +++ b/templates/form/sylius_checkout_select_payment_row.html.twig @@ -80,7 +80,8 @@ class="payplug-payment-choice__input payment-choice__input" {{ stimulus_action('@payplug/sylius-payplug-plugin/checkout-select-payment', 'enableNextStepButton', 'change') | - stimulus_action('@payplug/sylius-payplug-plugin/integrated-payment', 'handleHide', 'change') + stimulus_action('@payplug/sylius-payplug-plugin/integrated-payment', 'handleHide', 'change') | + stimulus_action('@payplug/sylius-payplug-plugin/hosted-fields', 'handleHide', 'change') }} {% if form.vars.value is not empty %} {{ form.vars.value == card.id ? 'checked="checked"' : '' }} @@ -103,7 +104,10 @@ id="payplug_choice_card_other" name="{{ form.vars.full_name }}" class="payplug-payment-choice__input payment-choice__input" - {{ stimulus_action('@payplug/sylius-payplug-plugin/integrated-payment', 'handleShow', 'change') }} + {{ + stimulus_action('@payplug/sylius-payplug-plugin/integrated-payment', 'handleShow', 'change') | + stimulus_action('@payplug/sylius-payplug-plugin/hosted-fields', 'handleShow', 'change') + }} {% if form.vars.value is not empty %} {{ form.vars.value == 'other' ? 'checked="checked"' : '' }} {% elseif sylius.customer.cards is empty %} diff --git a/templates/shop/hosted_fields/index.html.twig b/templates/shop/hosted_fields/index.html.twig index 318502eb..3f7f2709 100644 --- a/templates/shop/hosted_fields/index.html.twig +++ b/templates/shop/hosted_fields/index.html.twig @@ -6,6 +6,7 @@ var payplug_hosted_fields_params = { key_id: '{{ config.hostedFieldsKeyId }}', key_value: '{{ config.hostedFieldsKeyValue }}', + payment_method_code: '{{ paymentMethod.code }}', locale: '{{ app.request.locale|split('_')|first }}', error: { tokenization_failed: '{{ 'payplug_sylius_payplug_plugin.ui.hosted_fields.error.tokenization_failed'|trans }}', @@ -14,7 +15,11 @@ } /* ]] */ -
+
{{ 'sylius.ui.loading'|trans }}
From 009bdc9a7e205aeeaa313361cb6fdbb9a6d45289 Mon Sep 17 00:00:00 2001 From: Julien Hoarau Date: Mon, 3 Aug 2026 17:32:53 +0200 Subject: [PATCH 12/22] PRE-3550: mock the PayPlug account lookup in the Hosted Fields Behat scenario MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Hosted Fields shop template calls is_payplug_test_mode_enabled(), which performs a real PayPlug account lookup; the fixture's secretKey 'test' is not a valid credential. The scenario now uses the existing "This secret Key is valid" step, whose context had to be registered in the shop suite that runs it. Also bundles three small fixes: - HostedFieldsPaymentProcessorInterface is now an alias instead of a second definition, so it resolves to the auto-registered service and keeps its @monolog.logger.payplug binding; - fixes the "Paiement Integré" -> "Paiement Intégré" typo in validators.fr.yml; - refreshes the two stale constraint-count docblocks in PaymentMethodValidatorTest. Co-Authored-By: Claude Sonnet 5 --- config/services.yaml | 4 +++- .../shop/hosted_fields_payment_method.feature | 1 + .../suites/ui/paying_with_payplug_for_order.yml | 3 +++ .../Validator/PaymentMethodValidatorTest.php | 17 +++++++++++------ translations/validators.fr.yml | 2 +- 5 files changed, 19 insertions(+), 8 deletions(-) diff --git a/config/services.yaml b/config/services.yaml index 49f1b4d0..932200b7 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -35,8 +35,10 @@ services: PayPlug\SyliusPayPlugPlugin\Provider\OneySimulation\OneySimulationDataProviderInterface: class: PayPlug\SyliusPayPlugPlugin\Provider\OneySimulation\OneySimulationDataProvider + # Alias (not a separate definition) so the interface resolves to the service auto-registered by the + # `PayPlug\SyliusPayPlugPlugin\:` prototype above, keeping its `@monolog.logger.payplug` binding. PayPlug\SyliusPayPlugPlugin\PaymentProcessing\HostedFieldsPaymentProcessorInterface: - class: PayPlug\SyliusPayPlugPlugin\PaymentProcessing\NullHostedFieldsPaymentProcessor + alias: PayPlug\SyliusPayPlugPlugin\PaymentProcessing\NullHostedFieldsPaymentProcessor payplug_sylius_payplug_plugin.action.capture: class: PayPlug\SyliusPayPlugPlugin\Action\CaptureAction diff --git a/features/shop/hosted_fields_payment_method.feature b/features/shop/hosted_fields_payment_method.feature index 5b47b37e..9087d9f6 100644 --- a/features/shop/hosted_fields_payment_method.feature +++ b/features/shop/hosted_fields_payment_method.feature @@ -10,6 +10,7 @@ Feature: Paying with Hosted Fields during checkout And there is a user "john@bitbag.pl" identified by "password123" And I changed my currency to "EUR" And the store has a payment method "PayPlug Hosted Fields" with a code "payplug_hosted_fields" and PayPlug Hosted Fields payment gateway + And This secret Key is valid And the store ships everywhere for free And the store has "DHL" shipping method with "$0.00" fee And I am logged in as "john@bitbag.pl" diff --git a/tests/Behat/Resources/suites/ui/paying_with_payplug_for_order.yml b/tests/Behat/Resources/suites/ui/paying_with_payplug_for_order.yml index 659d5003..df97f112 100644 --- a/tests/Behat/Resources/suites/ui/paying_with_payplug_for_order.yml +++ b/tests/Behat/Resources/suites/ui/paying_with_payplug_for_order.yml @@ -30,6 +30,9 @@ default: - sylius.behat.context.setup.user - payplug_sylius_payplug_plugin.behat.context.setup.payplug + # Provides "This secret Key is valid", which installs the static Payplug\Core\HttpClient + # mock for the whole test process (needed by templates calling is_payplug_test_mode_enabled). + - payplug_sylius_payplug_plugin.behat.context.ui.admin.managing_payment_method_payplug # - sylius.behat.context.ui.paypal - sylius.behat.context.ui.shop.cart diff --git a/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php b/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php index 6ea28bc9..074e8c4a 100644 --- a/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php +++ b/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php @@ -139,12 +139,13 @@ public function testProcess_withViolations_disablesMethodAndFlashesErrors(): voi } // ------------------------------------------------------------------------- - // process() — PayPlug factory, no special flags → only IsCanSavePaymentMethod constraint + // process() — PayPlug factory, no special flags → base constraints only // ------------------------------------------------------------------------- /** - * PayPlug gateway with ONE_CLICK, DEFERRED_CAPTURE and INTEGRATED_PAYMENT all false. - * Verifies only the base IsCanSavePaymentMethod constraint (1 total) is passed to the validator. + * PayPlug gateway with ONE_CLICK, DEFERRED_CAPTURE, INTEGRATED_PAYMENT and HOSTED_FIELDS all false. + * Verifies only the two always-present constraints (2 total) are passed to the validator: + * IsCanSavePaymentMethod and IsNotCombiningIntegratedPaymentAndHostedFields. */ public function testProcess_payplugFactory_noFlags_validatesWithBaseConstraintOnly(): void { @@ -176,12 +177,16 @@ public function testProcess_payplugFactory_noFlags_validatesWithBaseConstraintOn } // ------------------------------------------------------------------------- - // process() — PayPlug factory, all flags enabled → 4 constraints (base + 3 permissions) + // process() — PayPlug factory, all permission flags enabled → 5 constraints (2 base + 3 permissions) // ------------------------------------------------------------------------- /** - * PayPlug gateway with ONE_CLICK, DEFERRED_CAPTURE and INTEGRATED_PAYMENT all true. - * Verifies 4 constraints are passed to the validator (base + one per enabled feature flag). + * PayPlug gateway with ONE_CLICK, DEFERRED_CAPTURE and INTEGRATED_PAYMENT all true + * (HOSTED_FIELDS stays false: it is mutually exclusive with INTEGRATED_PAYMENT and adds no + * permission constraint of its own). + * Verifies 5 constraints are passed to the validator: the two always-present ones + * (IsCanSavePaymentMethod + IsNotCombiningIntegratedPaymentAndHostedFields) plus one per + * enabled feature flag. */ public function testProcess_payplugFactory_allFlagsEnabled_validatesWithAllConstraints(): void { diff --git a/translations/validators.fr.yml b/translations/validators.fr.yml index c135d8d7..cb338fef 100644 --- a/translations/validators.fr.yml +++ b/translations/validators.fr.yml @@ -70,4 +70,4 @@ payplug_sylius_payplug_plugin: can_not_save_method_no_access: | Vous n'avez pas accès au mode LIVE. Pour l'activer, veuillez terminer votre onboarding et vous reconnecter. - integrated_payment_and_hosted_fields_conflict: 'Le Paiement Integré et les champs hébergés (Hosted Fields) ne peuvent pas être activés en même temps sur un même moyen de paiement.' \ No newline at end of file + integrated_payment_and_hosted_fields_conflict: 'Le Paiement Intégré et les champs hébergés (Hosted Fields) ne peuvent pas être activés en même temps sur un même moyen de paiement.' \ No newline at end of file From 55af9d6844811ee0cab65063f6dfc01ea60ddbf8 Mon Sep 17 00:00:00 2001 From: Julien Hoarau Date: Mon, 3 Aug 2026 17:36:53 +0200 Subject: [PATCH 13/22] PRE-3550: keep Hosted Fields away from Payum after checkout completion A Hosted Fields payment carries a Dalenys hfToken and no PayPlug payment_id until PRE-3551 lands, so routing it to sylius_shop_order_pay made StatusAction markNew(), Payum rebuild the details through Convert and CaptureAction issue a real createPayment() API call - which the temporary stub must never cause, even indirectly. The redirect override cannot simply be dropped: Sylius's CheckoutRedirectListener listens to the same sylius.order.post_payment event and bails out only when _sylius['redirect'] is set. Without it, it resolves a route for the `completed` checkout state, which has no entry in sylius_shop.checkout_resolver.route_map, and the request dies with a RouteNotFoundException. Hosted Fields is therefore redirected to sylius_shop_order_show instead (same token-based, guest-accessible route, no Payum involved). Integrated Payment keeps sylius_shop_order_pay. Co-Authored-By: Claude Sonnet 5 --- .../PostPaymentSelectEventSubscriber.php | 19 +++++++- .../PostPaymentSelectEventSubscriberTest.php | 46 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/EventSubscriber/PostPaymentSelectEventSubscriber.php b/src/EventSubscriber/PostPaymentSelectEventSubscriber.php index b4219f10..544d0d85 100644 --- a/src/EventSubscriber/PostPaymentSelectEventSubscriber.php +++ b/src/EventSubscriber/PostPaymentSelectEventSubscriber.php @@ -50,6 +50,23 @@ public static function getSubscribedEvents(): array ]; } + /** + * Both inline card-capture modes force the checkout to TRANSITION_COMPLETE inside + * `sylius.order.post_payment` (see handle()), so a `redirect` entry MUST be injected here: + * Sylius's CheckoutRedirectListener listens to that same event and bails out only when + * `_sylius['redirect']` is set. Without it, it would resolve a route for the `completed` + * checkout state, which has no entry in `sylius_shop.checkout_resolver.route_map` + * (RouteNotFoundException). + * + * The target route differs per mode: + * - Integrated Payment relays a real PayPlug `payment_id`, so the order goes to + * `sylius_shop_order_pay` (Payum capture/status) to be reconciled; + * - Hosted Fields relays a Dalenys `hfToken` and has no `payment_id` yet (see + * NullHostedFieldsPaymentProcessor, pending PRE-3551). Reaching `sylius_shop_order_pay` + * would make StatusAction `markNew()`, Payum rebuild the details through Convert and + * CaptureAction issue a real createPayment() API call. It is sent to `sylius_shop_order_show` + * instead: same token-based, guest-friendly access, but Payum is never invoked. + */ public function alterRequestConfigurationForInlineCardCapture(RequestEvent $event): void { $request = $event->getRequest(); @@ -69,7 +86,7 @@ public function alterRequestConfigurationForInlineCardCapture(RequestEvent $even } $syliusRequestConfig['redirect'] = [ - 'route' => 'sylius_shop_order_pay', + 'route' => $this->hasToken($request) ? 'sylius_shop_order_pay' : 'sylius_shop_order_show', 'parameters' => ['tokenValue' => 'resource.tokenValue'], ]; diff --git a/tests/PHPUnit/EventSubscriber/PostPaymentSelectEventSubscriberTest.php b/tests/PHPUnit/EventSubscriber/PostPaymentSelectEventSubscriberTest.php index bd709742..3ef44ce5 100644 --- a/tests/PHPUnit/EventSubscriber/PostPaymentSelectEventSubscriberTest.php +++ b/tests/PHPUnit/EventSubscriber/PostPaymentSelectEventSubscriberTest.php @@ -187,6 +187,52 @@ public function testAlterRequestConfiguration_withIntegratedPaymentToken_overrid ); } + /** + * Hosted Fields has no PayPlug payment_id yet (PRE-3551): reaching `sylius_shop_order_pay` would + * make StatusAction markNew() and end up issuing a real createPayment() API call. A `redirect` + * entry is still required (Sylius's CheckoutRedirectListener would otherwise fail to resolve a + * route for the `completed` checkout state), so it points at `sylius_shop_order_show` instead. + */ + public function testAlterRequestConfiguration_withOnlyHostedFieldsToken_redirectsToOrderShowNotOrderPay(): void + { + $request = Request::create('/checkout/select-payment', 'POST', [ + 'hostedfields_token' => 'hf_token_abc', + 'hostedfields_selected_brand' => 'VISA', + ]); + $request->attributes->set('_route', 'sylius_shop_checkout_select_payment'); + $request->attributes->set('_sylius', ['redirect' => ['route' => 'sylius_shop_checkout_complete']]); + + $this->subscriber->alterRequestConfigurationForInlineCardCapture($this->buildRequestEvent($request)); + + self::assertSame( + [ + 'redirect' => [ + 'route' => 'sylius_shop_order_show', + 'parameters' => ['tokenValue' => 'resource.tokenValue'], + ], + ], + $request->attributes->get('_sylius'), + ); + } + + /** + * Belt and braces: the Hosted Fields branch must never resolve to the Payum-invoking route. + */ + public function testAlterRequestConfiguration_withBothTokens_prefersIntegratedPaymentRoute(): void + { + $request = Request::create('/checkout/select-payment', 'POST', [ + 'payplug_integrated_payment_token' => 'pay_123', + 'hostedfields_token' => 'hf_token_abc', + ]); + $request->attributes->set('_route', 'sylius_shop_checkout_select_payment'); + $request->attributes->set('_sylius', []); + + $this->subscriber->alterRequestConfigurationForInlineCardCapture($this->buildRequestEvent($request)); + + $syliusRequestConfig = $request->attributes->get('_sylius'); + self::assertSame('sylius_shop_order_pay', $syliusRequestConfig['redirect']['route']); + } + public function testAlterRequestConfiguration_withoutAnyToken_leavesRedirectUntouched(): void { $request = Request::create('/checkout/select-payment', 'POST'); From d759ca5400156a53987424d5de16c5f42d1317e7 Mon Sep 17 00:00:00 2001 From: Julien Hoarau Date: Mon, 3 Aug 2026 17:45:35 +0200 Subject: [PATCH 14/22] PRE-3550: align the redirect precedence with handle()'s dispatch order The redirect ternary picked sylius_shop_order_pay whenever hasToken() was true, but handle() checks hasHostedFieldsToken() first. A request carrying both token fields was therefore processed as Hosted Fields - never writing a payment_id - while still being redirected to sylius_shop_order_pay, reopening the StatusAction -> Convert -> CaptureAction::createPayment() chain this redirect exists to prevent. The ternary now checks hasHostedFieldsToken() first, mirroring handle(). Tests pin the invariant on both sides so the two cannot drift apart again. Also uses self::UPDATE_ORDER_PAYMENT_ROUTE instead of repeating its literal value. Co-Authored-By: Claude Sonnet 5 --- assets/package.json | 9 + .../controllers/hosted-fields_controller.js | 31 +++- assets/shop/dist/payment/hosted-fields.css | 160 ++++++++++++++++-- .../PostPaymentSelectEventSubscriber.php | 5 +- ...yPlugGatewayConfigurationTypeExtension.php | 9 +- src/Gateway/PayPlugGatewayFactory.php | 4 +- .../form/hosted_fields.html.twig | 8 +- templates/shop/hosted_fields/index.html.twig | 5 +- tests/Behat/Context/Setup/PayPlugContext.php | 3 +- .../PostPaymentSelectEventSubscriberTest.php | 46 ++++- .../Gateway/PayPlugGatewayFactoryTest.php | 3 +- translations/messages.en.yml | 3 +- translations/messages.fr.yml | 3 +- translations/messages.it.yml | 3 +- 14 files changed, 240 insertions(+), 52 deletions(-) diff --git a/assets/package.json b/assets/package.json index 52494447..c17e1363 100644 --- a/assets/package.json +++ b/assets/package.json @@ -34,6 +34,15 @@ "@payplug/sylius-payplug-plugin/shop/dist/payment/integrated.css": true } }, + "hosted-fields": { + "main": "shop/controllers/hosted-fields_controller.js", + "webpackMode": "lazy", + "fetch": "lazy", + "enabled": true, + "autoimport": { + "@payplug/sylius-payplug-plugin/shop/dist/payment/hosted-fields.css": true + } + }, "oney-payment": { "main": "shop/controllers/oney-payment_controller.js", "webpackMode": "lazy", diff --git a/assets/shop/controllers/hosted-fields_controller.js b/assets/shop/controllers/hosted-fields_controller.js index fb9240c0..5ef260d1 100644 --- a/assets/shop/controllers/hosted-fields_controller.js +++ b/assets/shop/controllers/hosted-fields_controller.js @@ -2,6 +2,24 @@ import { Controller } from '@hotwired/stimulus'; const ALLOWED_BRANDS = ['CB', 'VISA', 'MASTERCARD']; +// Applied inside each hosted iframe (Dalenys renders these rules into the field's own +// document). The SDK only accepts a small whitelist of CSS properties here — anything +// outside it (we tried "height": SDK logged "Css property ... is not supported" and threw, +// which aborted ALL fields, not just the one it complained about) — so stick to exactly the +// properties confirmed by PayPlug's own documented example (font-size/color/font-style). +// Background and sizing/centering of the field's content are NOT controllable this way. +const FIELD_STYLE = { + input: { + 'font-size': '14px', + color: '#2B343D', + 'background-color': 'transparent', + }, + '::placeholder': { + 'font-size': '14px', + color: '#969a9f', + }, +}; + /* stimulusFetch: 'lazy' */ export default class extends Controller { static targets = ['container', 'error', 'submitButton']; @@ -93,15 +111,12 @@ export default class extends Controller { load() { this.hfields = window.dalenys.hostedFields({ - key: { - id: payplug_hosted_fields_params.key_id, - value: payplug_hosted_fields_params.key_value, - }, + companyId: payplug_hosted_fields_params.companyId, fields: { - brand: { id: 'brand-container' }, - card: { id: 'card-container' }, - expiry: { id: 'expiry-container' }, - cryptogram: { id: 'cvv-container' }, + brand: { id: 'brand-container', style: FIELD_STYLE }, + card: { id: 'card-container', style: FIELD_STYLE }, + expiry: { id: 'expiry-container', style: FIELD_STYLE }, + cryptogram: { id: 'cvv-container', style: FIELD_STYLE }, }, location: payplug_hosted_fields_params.locale, }); diff --git a/assets/shop/dist/payment/hosted-fields.css b/assets/shop/dist/payment/hosted-fields.css index 6fc3505f..394eb7c5 100644 --- a/assets/shop/dist/payment/hosted-fields.css +++ b/assets/shop/dist/payment/hosted-fields.css @@ -1,31 +1,169 @@ -/* - * Mirrors .payplugIntegratedPayment / --loaded: the hosted iframes are only mounted once this - * payment method is actually selected, so the markup stays hidden until the controller reveals it. - */ .payplugHostedFields { - display: none; + justify-self: center; + display: none +} + +.payplugHostedFields * { + font-family: Poppins, Arial, sans-serif !important } .payplugHostedFields--loaded { - display: block; + width: 100%; + max-width: 400px; + flex-wrap: wrap; + justify-content: space-between; + margin: 20px auto 0; + display: flex; + position: relative } .payplugHostedFields__container { - margin-bottom: 12px; + width: 100%; + margin: 0 0 10px; + padding: 0; + display: flex; + position: relative } .payplugHostedFields__container--brand, .payplugHostedFields__container--card, .payplugHostedFields__container--expiry, .payplugHostedFields__container--cvv { - min-height: 40px; + height: 40px; + cursor: text; + border: 1px solid #d5d6d8; + border-radius: 2px; + line-height: 40px +} + +.payplugHostedFields__container--card, +.payplugHostedFields__container--expiry, +.payplugHostedFields__container--cvv { + padding: 0 16px 0 50px +} + +.payplugHostedFields__container--card:before, +.payplugHostedFields__container--expiry:before, +.payplugHostedFields__container--cvv:before { + content: ""; + width: 24px; + height: 24px; + background: #95999e 50%/100% no-repeat; + position: absolute; + top: 20%; + left: 16px +} + +.payplugHostedFields__container--card:before { + -webkit-mask-image: url(card.0d2bd9bc.svg); + mask-image: url(card.0d2bd9bc.svg) +} + +.payplugHostedFields__container--expiry:before { + -webkit-mask-image: url(calendar.3c23bb16.svg); + mask-image: url(calendar.3c23bb16.svg) +} + +.payplugHostedFields__container--cvv:before { + -webkit-mask-image: url(lock.fe8a73cd.svg); + mask-image: url(lock.fe8a73cd.svg) +} + +.payplugHostedFields__container--expiry, +.payplugHostedFields__container--cvv { + max-width: calc(50% - 2px); + display: inline-block +} + +.payplugHostedFields__container--brand, +.payplugHostedFields__container--card, +.payplugHostedFields__container--expiry, +.payplugHostedFields__container--cvv { + overflow: hidden +} + +.payplugHostedFields__container--brand iframe, +.payplugHostedFields__container--card iframe, +.payplugHostedFields__container--expiry iframe, +.payplugHostedFields__container--cvv iframe { + width: 100%; + height: 100%; + border: none; + display: block +} + +.payplugHostedFields__container--saveCard { + height: auto; + align-items: center; + padding: 10px 0 0; + display: flex +} + +.payplugHostedFields__container--saveCard input { + display: none +} + +.payplugHostedFields__container--saveCard input:checked+label span:before { + opacity: 1 +} + +.payplugHostedFields__container--saveCard label { + cursor: pointer; + color: #918f8f; + margin: 0 !important; + font-size: 12px !important +} + +.payplugHostedFields__container--saveCard label span { + cursor: pointer; + height: 16px; + -o-transition: border .4s; + width: 16px; + border: 1px solid #d5d6d8; + border-radius: 2px; + margin: 0 10px -3px 0; + transition: border .4s; + display: inline-block; + position: relative +} + +.payplugHostedFields__container--saveCard label span:before { + content: ""; + height: 5px; + opacity: 0; + width: 10px; + border-top: none; + border-bottom: 2.5px solid #2b343d; + border-left: 2.5px solid #2b343d; + border-right: none; + border-radius: 1px; + transition: opacity .4s; + display: block; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -55%)rotate(-48deg) +} + +.payplugHostedFields__container--saveCard label:hover { + color: #2b343d; + transition: all .1s +} + +.payplugHostedFields__container--saveCard label:hover span { + border-color: #2b343d; + transition: all .1s } .payplugHostedFields__error { - color: #E91932; - margin-bottom: 12px; + color: #e91932; + width: 100%; + margin: -10px 0 10px; + padding-left: 4px; + font-size: 12px; + line-height: 18px } .payplugHostedFields__error--hide { - display: none; + display: none } diff --git a/src/EventSubscriber/PostPaymentSelectEventSubscriber.php b/src/EventSubscriber/PostPaymentSelectEventSubscriber.php index 544d0d85..40467b5a 100644 --- a/src/EventSubscriber/PostPaymentSelectEventSubscriber.php +++ b/src/EventSubscriber/PostPaymentSelectEventSubscriber.php @@ -66,6 +66,9 @@ public static function getSubscribedEvents(): array * would make StatusAction `markNew()`, Payum rebuild the details through Convert and * CaptureAction issue a real createPayment() API call. It is sent to `sylius_shop_order_show` * instead: same token-based, guest-friendly access, but Payum is never invoked. + * + * The Hosted Fields check comes first, mirroring handle()'s dispatch order: a request carrying + * both token fields is processed as Hosted Fields, so it must be routed as Hosted Fields too. */ public function alterRequestConfigurationForInlineCardCapture(RequestEvent $event): void { @@ -86,7 +89,7 @@ public function alterRequestConfigurationForInlineCardCapture(RequestEvent $even } $syliusRequestConfig['redirect'] = [ - 'route' => $this->hasToken($request) ? 'sylius_shop_order_pay' : 'sylius_shop_order_show', + 'route' => $this->hasHostedFieldsToken($request) ? self::UPDATE_ORDER_PAYMENT_ROUTE : 'sylius_shop_order_pay', 'parameters' => ['tokenValue' => 'resource.tokenValue'], ]; diff --git a/src/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtension.php b/src/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtension.php index 05052c2b..a0133b2a 100644 --- a/src/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtension.php +++ b/src/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtension.php @@ -50,13 +50,8 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'validation_groups' => AbstractGatewayConfigurationType::VALIDATION_GROUPS, 'required' => false, ]) - ->add(PayPlugGatewayFactory::HOSTED_FIELDS_KEY_ID, TextType::class, [ - 'label' => 'payplug_sylius_payplug_plugin.form.hosted_fields_key_id', - 'validation_groups' => AbstractGatewayConfigurationType::VALIDATION_GROUPS, - 'required' => false, - ]) - ->add(PayPlugGatewayFactory::HOSTED_FIELDS_KEY_VALUE, TextType::class, [ - 'label' => 'payplug_sylius_payplug_plugin.form.hosted_fields_key_value', + ->add(PayPlugGatewayFactory::HOSTED_FIELDS_COMPANY_ID, TextType::class, [ + 'label' => 'payplug_sylius_payplug_plugin.form.hosted_fields_company_id', 'validation_groups' => AbstractGatewayConfigurationType::VALIDATION_GROUPS, 'required' => false, ]) diff --git a/src/Gateway/PayPlugGatewayFactory.php b/src/Gateway/PayPlugGatewayFactory.php index acda9808..08b29be3 100644 --- a/src/Gateway/PayPlugGatewayFactory.php +++ b/src/Gateway/PayPlugGatewayFactory.php @@ -19,7 +19,5 @@ final class PayPlugGatewayFactory extends AbstractGatewayFactory public const HOSTED_FIELDS = 'hostedFields'; - public const HOSTED_FIELDS_KEY_ID = 'hostedFieldsKeyId'; - - public const HOSTED_FIELDS_KEY_VALUE = 'hostedFieldsKeyValue'; + public const HOSTED_FIELDS_COMPANY_ID = 'hostedFieldsCompanyId'; } diff --git a/templates/admin/payment_method/form/hosted_fields.html.twig b/templates/admin/payment_method/form/hosted_fields.html.twig index a27cdce9..15c0ead4 100644 --- a/templates/admin/payment_method/form/hosted_fields.html.twig +++ b/templates/admin/payment_method/form/hosted_fields.html.twig @@ -1,13 +1,9 @@ {% set hostedFieldsCheckbox = hookable_metadata.context.form.gatewayConfig.config.hostedFields %} -{% set hostedFieldsKeyId = hookable_metadata.context.form.gatewayConfig.config.hostedFieldsKeyId %} -{% set hostedFieldsKeyValue = hookable_metadata.context.form.gatewayConfig.config.hostedFieldsKeyValue %} +{% set hostedFieldsCompanyId = hookable_metadata.context.form.gatewayConfig.config.hostedFieldsCompanyId %}
{{ form_row(hostedFieldsCheckbox) }}
- {{ form_row(hostedFieldsKeyId) }} -
-
- {{ form_row(hostedFieldsKeyValue) }} + {{ form_row(hostedFieldsCompanyId) }}
diff --git a/templates/shop/hosted_fields/index.html.twig b/templates/shop/hosted_fields/index.html.twig index 3f7f2709..7d0596cb 100644 --- a/templates/shop/hosted_fields/index.html.twig +++ b/templates/shop/hosted_fields/index.html.twig @@ -1,11 +1,10 @@ {% set config = paymentMethod.gatewayConfig.config %} - +