diff --git a/Makefile b/Makefile
index 813d5336..149ac881 100644
--- a/Makefile
+++ b/Makefile
@@ -50,8 +50,6 @@ install-sylius:
${COMPOSER} require --dev sylius/test-application:"^${SYLIUS_VERSION}@alpha" -n -W # TODO: Remove alpha when stable
${COMPOSER} test-application:install
-
-
behat-configure: ## Configure Behat
(cd ${TEST_DIRECTORY} && cp behat.yml.dist behat.yml)
(cd ${TEST_DIRECTORY} && sed -i "s#vendor/sylius/sylius/src/Sylius/Behat/Resources/config/suites.yml#vendor/${PLUGIN_NAME}/tests/Behat/Resources/suites.yml#g" behat.yml)
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/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
new file mode 100644
index 00000000..5ef260d1
--- /dev/null
+++ b/assets/shop/controllers/hosted-fields_controller.js
@@ -0,0 +1,170 @@
+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'];
+
+ connect() {
+ if (typeof payplug_hosted_fields_params === 'undefined') {
+ return;
+ }
+
+ 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({
+ companyId: payplug_hosted_fields_params.companyId,
+ fields: {
+ 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,
+ });
+ this.hfields.load();
+ }
+
+ 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') {
+ 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..394eb7c5
--- /dev/null
+++ b/assets/shop/dist/payment/hosted-fields.css
@@ -0,0 +1,169 @@
+.payplugHostedFields {
+ justify-self: center;
+ display: none
+}
+
+.payplugHostedFields * {
+ font-family: Poppins, Arial, sans-serif !important
+}
+
+.payplugHostedFields--loaded {
+ width: 100%;
+ max-width: 400px;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ margin: 20px auto 0;
+ display: flex;
+ position: relative
+}
+
+.payplugHostedFields__container {
+ width: 100%;
+ margin: 0 0 10px;
+ padding: 0;
+ display: flex;
+ position: relative
+}
+
+.payplugHostedFields__container--brand,
+.payplugHostedFields__container--card,
+.payplugHostedFields__container--expiry,
+.payplugHostedFields__container--cvv {
+ 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;
+ width: 100%;
+ margin: -10px 0 10px;
+ padding-left: 4px;
+ font-size: 12px;
+ line-height: 18px
+}
+
+.payplugHostedFields__error--hide {
+ display: none
+}
diff --git a/config/services.yaml b/config/services.yaml
index 2131eff5..932200b7 100644
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -35,6 +35,11 @@ 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:
+ alias: PayPlug\SyliusPayPlugPlugin\PaymentProcessing\NullHostedFieldsPaymentProcessor
+
payplug_sylius_payplug_plugin.action.capture:
class: PayPlug\SyliusPayPlugPlugin\Action\CaptureAction
diff --git a/config/twig_hooks/admin.yaml b/config/twig_hooks/admin.yaml
index 0683e861..50544202 100644
--- a/config/twig_hooks/admin.yaml
+++ b/config/twig_hooks/admin.yaml
@@ -46,6 +46,9 @@ sylius_twig_hooks:
'sylius_admin.payment_method.create.content.form.sections.gateway_configuration.payplug_uhf': &uhfGateway
live_checkbox: *liveCheckbox
+ one_click:
+ template: '@PayPlugSyliusPayPlugPlugin/admin/payment_method/form/one_click.html.twig'
+ priority: 0
hf_identifier_default: &hfIdentifierDefault
template: '@PayPlugSyliusPayPlugPlugin/admin/payment_method/form/hf_identifier_default.html.twig'
priority: -1
diff --git a/config/twig_hooks/shop.yaml b/config/twig_hooks/shop.yaml
index 36252bfd..ffa22870 100644
--- a/config/twig_hooks/shop.yaml
+++ b/config/twig_hooks/shop.yaml
@@ -50,3 +50,6 @@ sylius_twig_hooks:
'sylius_shop.shared.form.select_payment.payment.choice.details#payplug_wero':
wero:
template: '@PayPlugSyliusPayPlugPlugin/shop/select_payment/_wero.html.twig'
+ 'sylius_shop.shared.form.select_payment.payment.choice.details#payplug_uhf':
+ uhf:
+ template: '@PayPlugSyliusPayPlugPlugin/shop/select_payment/_payplug_uhf.html.twig'
diff --git a/features/shop/hosted_fields_payment_method.feature b/features/shop/hosted_fields_payment_method.feature
new file mode 100644
index 00000000..01a36834
--- /dev/null
+++ b/features/shop/hosted_fields_payment_method.feature
@@ -0,0 +1,26 @@
+@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 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"
+
+ @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
+ And I select "PayPlug Hosted Fields" payment method
+ And I should see the "#card-container" element on the page
diff --git a/src/EventSubscriber/PostPaymentSelectEventSubscriber.php b/src/EventSubscriber/PostPaymentSelectEventSubscriber.php
index 9991e0b3..d013f5b0 100644
--- a/src/EventSubscriber/PostPaymentSelectEventSubscriber.php
+++ b/src/EventSubscriber/PostPaymentSelectEventSubscriber.php
@@ -5,6 +5,8 @@
namespace PayPlug\SyliusPayPlugPlugin\EventSubscriber;
use Doctrine\ORM\EntityManagerInterface;
+use PayPlug\SyliusPayPlugPlugin\Gateway\UhfGatewayFactory;
+use PayPlug\SyliusPayPlugPlugin\PaymentProcessing\HostedFieldsPaymentProcessorInterface;
use Sylius\Abstraction\StateMachine\StateMachineInterface;
use Sylius\Bundle\ResourceBundle\Event\ResourceControllerEvent;
use Sylius\Component\Core\Model\OrderInterface;
@@ -25,26 +27,56 @@ 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
+ /**
+ * 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.
+ *
+ * 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
{
$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')) {
@@ -57,7 +89,7 @@ public function alterRequestConfigurationForIntegratedPayment(RequestEvent $even
}
$syliusRequestConfig['redirect'] = [
- 'route' => 'sylius_shop_order_pay',
+ 'route' => $this->hasHostedFieldsToken($request) ? self::UPDATE_ORDER_PAYMENT_ROUTE : 'sylius_shop_order_pay',
'parameters' => ['tokenValue' => 'resource.tokenValue'],
];
@@ -82,6 +114,12 @@ public function handle(ResourceControllerEvent $resourceControllerEvent): void
return;
}
+ if ($this->hasHostedFieldsToken($request)) {
+ $this->handleHostedFieldsToken($request, $lastPayment);
+
+ return;
+ }
+
if (!$this->hasToken($request)) {
return;
}
@@ -128,6 +166,45 @@ 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
+ {
+ // 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');
+
+ $this->hostedFieldsPaymentProcessor->process($lastPayment, $hfToken, $selectedBrand, $saveCard);
+
+ $this->applyToComplete($lastPayment->getOrder() ?? throw new \LogicException('Order not found for payment'));
+ }
+
+ private function isHostedFieldsEnabled(PaymentInterface $payment): bool
+ {
+ return UhfGatewayFactory::FACTORY_NAME === $payment->getMethod()?->getGatewayConfig()?->getFactoryName();
+ }
+
+ 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/src/Gateway/Form/Extension/UhfGatewayConfigurationTypeExtension.php b/src/Gateway/Form/Extension/UhfGatewayConfigurationTypeExtension.php
index 21bc13ea..e4f95e90 100644
--- a/src/Gateway/Form/Extension/UhfGatewayConfigurationTypeExtension.php
+++ b/src/Gateway/Form/Extension/UhfGatewayConfigurationTypeExtension.php
@@ -8,6 +8,7 @@
use PayPlug\SyliusPayPlugPlugin\Gateway\Form\Type\UhfGatewayConfigurationType;
use PayPlug\SyliusPayPlugPlugin\Gateway\UhfGatewayFactory;
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\Validator\Constraints\NotBlank;
@@ -28,6 +29,14 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
new NotBlank([]),
],
])
+ ->add(UhfGatewayFactory::ONE_CLICK, CheckboxType::class, [
+ 'block_name' => 'payplug_checkbox',
+ 'label' => 'payplug_sylius_payplug_plugin.form.one_click_enable',
+ 'validation_groups' => AbstractGatewayConfigurationType::VALIDATION_GROUPS,
+ 'help' => 'payplug_sylius_payplug_plugin.form.one_click_help',
+ 'help_html' => true,
+ 'required' => false,
+ ])
;
}
diff --git a/src/Gateway/UhfGatewayFactory.php b/src/Gateway/UhfGatewayFactory.php
index e5dd882d..772de381 100644
--- a/src/Gateway/UhfGatewayFactory.php
+++ b/src/Gateway/UhfGatewayFactory.php
@@ -11,4 +11,6 @@ final class UhfGatewayFactory extends AbstractGatewayFactory
public const FACTORY_TITLE = 'Unified Hosted Fields by PayPlug';
public const HF_IDENTIFIER_DEFAULT = 'hfIdentifierDefault';
+
+ public const ONE_CLICK = 'oneClick';
}
diff --git a/src/Gateway/Validator/Constraints/IsCanSavePaymentMethodValidator.php b/src/Gateway/Validator/Constraints/IsCanSavePaymentMethodValidator.php
index 7cc33f5b..a5f2ba38 100644
--- a/src/Gateway/Validator/Constraints/IsCanSavePaymentMethodValidator.php
+++ b/src/Gateway/Validator/Constraints/IsCanSavePaymentMethodValidator.php
@@ -5,11 +5,12 @@
namespace PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints;
use Payplug\Exception\UnauthorizedException;
-use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactory;
+use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface;
use PayPlug\SyliusPayPlugPlugin\Checker\CanSavePayplugPaymentMethodChecker;
use PayPlug\SyliusPayPlugPlugin\Exception\GatewayConfigurationException;
use PayPlug\SyliusPayPlugPlugin\Gateway\OneyGatewayFactory;
use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory;
+use PayPlug\SyliusPayPlugPlugin\Gateway\UhfGatewayFactory;
use Sylius\Component\Core\Model\PaymentMethodInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
@@ -21,9 +22,11 @@
*/
final class IsCanSavePaymentMethodValidator extends ConstraintValidator
{
- private const GATEWAYS_SKIP = [PayPlugGatewayFactory::FACTORY_NAME, OneyGatewayFactory::FACTORY_NAME];
+ // Unified Hosted Fields processes card payments directly (like `payplug`), it is not an
+ // alternative payment method requiring its own per-account enablement flag from PayPlug.
+ private const GATEWAYS_SKIP = [PayPlugGatewayFactory::FACTORY_NAME, OneyGatewayFactory::FACTORY_NAME, UhfGatewayFactory::FACTORY_NAME];
- public function __construct(private PayPlugApiClientFactory $apiClientFactory)
+ public function __construct(private PayPlugApiClientFactoryInterface $apiClientFactory)
{
}
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/src/Validator/PaymentMethodValidator.php b/src/Validator/PaymentMethodValidator.php
index 60485bde..558b4513 100644
--- a/src/Validator/PaymentMethodValidator.php
+++ b/src/Validator/PaymentMethodValidator.php
@@ -51,7 +51,7 @@ public function process(PaymentMethodInterface $paymentMethod): void
ApplePayGatewayFactory::FACTORY_NAME => $this->processDefault($paymentMethod),
ScalapayGatewayFactory::FACTORY_NAME => $this->processDefault($paymentMethod),
WeroGatewayFactory::FACTORY_NAME => $this->processDefault($paymentMethod),
- UhfGatewayFactory::FACTORY_NAME => $this->processDefault($paymentMethod),
+ UhfGatewayFactory::FACTORY_NAME => $this->processUhf($paymentMethod),
default => throw new \InvalidArgumentException('Unsupported payment method'),
};
@@ -83,6 +83,18 @@ private function processPayplug(PaymentMethodInterface $paymentMethod): Constrai
return $this->validator->validate($paymentMethod, $constraintList, self::VALIDATION_GROUPS);
}
+ private function processUhf(PaymentMethodInterface $paymentMethod): ConstraintViolationListInterface
+ {
+ $config = $paymentMethod->getGatewayConfig()?->getConfig() ?? [];
+ $constraintList = [new IsCanSavePaymentMethod()];
+
+ if (true === ($config[UhfGatewayFactory::ONE_CLICK] ?? false)) {
+ $constraintList[] = new PayplugPermission(Permission::CAN_SAVE_CARD);
+ }
+
+ return $this->validator->validate($paymentMethod, $constraintList, self::VALIDATION_GROUPS);
+ }
+
private function processOney(PaymentMethodInterface $paymentMethod): ConstraintViolationListInterface
{
$constraintList = [new IsOneyEnabled()];
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
new file mode 100644
index 00000000..4136effd
--- /dev/null
+++ b/templates/shop/hosted_fields/index.html.twig
@@ -0,0 +1,48 @@
+{% set config = paymentMethod.gatewayConfig.config %}
+
+
+
+
+
+
 }})
+
+
+
+
+
+
+ {% if is_save_card_enabled(paymentMethod) %}
+
+ {# No name to not trigger LiveComponent #}
+
+
+ {% endif %}
+
+
+
+
+
+
+
+
diff --git a/templates/shop/select_payment/_payplug_uhf.html.twig b/templates/shop/select_payment/_payplug_uhf.html.twig
new file mode 100644
index 00000000..b2e08b92
--- /dev/null
+++ b/templates/shop/select_payment/_payplug_uhf.html.twig
@@ -0,0 +1,7 @@
+{% set method = hookable_metadata.context.method %}
+
+
+ {% include '@PayPlugSyliusPayPlugPlugin/shop/hosted_fields/index.html.twig' with {
+ 'paymentMethod': method,
+ } %}
+
diff --git a/tests/Behat/Context/Setup/PayPlugContext.php b/tests/Behat/Context/Setup/PayPlugContext.php
index 493e38b6..4b91cc1c 100644
--- a/tests/Behat/Context/Setup/PayPlugContext.php
+++ b/tests/Behat/Context/Setup/PayPlugContext.php
@@ -8,6 +8,7 @@
use Doctrine\Persistence\ObjectManager;
use PayPlug\SyliusPayPlugPlugin\Gateway\OneyGatewayFactory;
use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory;
+use PayPlug\SyliusPayPlugPlugin\Gateway\UhfGatewayFactory;
use Sylius\Behat\Service\SharedStorageInterface;
use Sylius\Bundle\CoreBundle\Fixture\Factory\ExampleFactoryInterface;
use Sylius\Component\Core\Model\PaymentMethodInterface;
@@ -67,6 +68,29 @@ 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,
+ UhfGatewayFactory::FACTORY_NAME,
+ UhfGatewayFactory::FACTORY_TITLE,
+ );
+
+ $paymentMethod->getGatewayConfig()->setConfig([
+ 'secretKey' => 'test',
+ 'payum.http_client' => '@payplug_sylius_payplug_plugin.api_client.uhf',
+ UhfGatewayFactory::HF_IDENTIFIER_DEFAULT => 'test-company-id',
+ ]);
+
+ $this->paymentMethodManager->flush();
+ }
+
/**
* @Given the store has a payment method :paymentMethodName with a code :paymentMethodCode and Oney payment gateway
*/
diff --git a/tests/Behat/Context/Ui/Shop/CheckoutContext.php b/tests/Behat/Context/Ui/Shop/CheckoutContext.php
index ae35eef1..85b10294 100644
--- a/tests/Behat/Context/Ui/Shop/CheckoutContext.php
+++ b/tests/Behat/Context/Ui/Shop/CheckoutContext.php
@@ -4,7 +4,7 @@
namespace Tests\PayPlug\SyliusPayPlugPlugin\Behat\Context\Ui\Shop;
-use Behat\Behat\Context\Context;
+use Behat\MinkExtension\Context\RawMinkContext;
use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface;
use Sylius\Behat\Page\Shop\Checkout\CompletePageInterface;
use Sylius\Behat\Page\Shop\Order\ShowPageInterface;
@@ -13,7 +13,7 @@
use Tests\PayPlug\SyliusPayPlugPlugin\Behat\Page\Shop\Payum\PaymentPageInterface;
use Webmozart\Assert\Assert;
-final class CheckoutContext implements Context
+final class CheckoutContext extends RawMinkContext
{
/** @var CompletePageInterface */
private $summaryPage;
@@ -157,4 +157,15 @@ public function oneyIsDisabled(): void
{
$this->payPlugApiMocker->disableOney();
}
+
+ /**
+ * @Then I should see the :selector element on the page
+ */
+ public function iShouldSeeTheElementOnThePage(string $selector): void
+ {
+ Assert::notNull(
+ $this->getSession()->getPage()->find('css', $selector),
+ sprintf('Element matching selector "%s" was not found on the page.', $selector),
+ );
+ }
}
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/EventSubscriber/PostPaymentSelectEventSubscriberTest.php b/tests/PHPUnit/EventSubscriber/PostPaymentSelectEventSubscriberTest.php
new file mode 100644
index 00000000..a13e10d5
--- /dev/null
+++ b/tests/PHPUnit/EventSubscriber/PostPaymentSelectEventSubscriberTest.php
@@ -0,0 +1,312 @@
+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);
+ $payment->method('getMethod')->willReturn(
+ $this->buildPaymentMethod(UhfGatewayFactory::FACTORY_NAME),
+ );
+ $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);
+ }
+
+ /**
+ * A crafted POST carrying a hosted fields token must not be able to complete checkout
+ * for a payment method that is not on the payplug_uhf factory.
+ */
+ public function testHandle_withHostedFieldsTokenButNotUhfFactory_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::FACTORY_NAME),
+ );
+ $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);
+ }
+
+ /**
+ * Pins the dispatch precedence that alterRequestConfigurationForInlineCardCapture() mirrors:
+ * when both token fields are present, handle() treats the request as Hosted Fields (no
+ * payment_id is ever written). Flipping this order without flipping the redirect ternary would
+ * send a payment_id-less order to sylius_shop_order_pay.
+ */
+ public function testHandle_withBothTokens_isProcessedAsHostedFields(): void
+ {
+ $request = Request::create('/checkout/select-payment', 'POST', [
+ 'payplug_integrated_payment_token' => 'pay_123',
+ 'hostedfields_token' => 'hf_token_abc',
+ 'hostedfields_selected_brand' => 'CB',
+ ]);
+ $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(UhfGatewayFactory::FACTORY_NAME),
+ );
+ $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);
+
+ // Hosted Fields path: the processor is used and no payment_id is written to the details.
+ $this->hostedFieldsPaymentProcessor->expects(self::once())
+ ->method('process')
+ ->with($payment, 'hf_token_abc', 'CB', false)
+ ;
+ $payment->expects(self::never())->method('setDetails');
+
+ $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');
+ $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);
+ }
+
+ // -------------------------------------------------------------------------
+ // 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'),
+ );
+ }
+
+ /**
+ * 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'),
+ );
+ }
+
+ /**
+ * A crafted request carrying both token fields is dispatched as Hosted Fields by handle()
+ * (it checks hasHostedFieldsToken() first), so it must be routed as Hosted Fields too —
+ * otherwise no payment_id is ever set and the order still lands on the Payum capture/status
+ * chain this redirect exists to avoid.
+ */
+ public function testAlterRequestConfiguration_withBothTokens_followsHandleAndRedirectsToOrderShow(): 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_show', $syliusRequestConfig['redirect']['route']);
+ }
+
+ 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(string $factoryName): PaymentMethodInterface&MockObject
+ {
+ $gatewayConfig = $this->createMock(GatewayConfigInterface::class);
+ $gatewayConfig->method('getFactoryName')->willReturn($factoryName);
+
+ $paymentMethod = $this->createMock(PaymentMethodInterface::class);
+ $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig);
+
+ return $paymentMethod;
+ }
+}
diff --git a/tests/PHPUnit/Gateway/Form/Extension/UhfGatewayConfigurationTypeExtensionTest.php b/tests/PHPUnit/Gateway/Form/Extension/UhfGatewayConfigurationTypeExtensionTest.php
index 0d1e7d7d..57a464d5 100644
--- a/tests/PHPUnit/Gateway/Form/Extension/UhfGatewayConfigurationTypeExtensionTest.php
+++ b/tests/PHPUnit/Gateway/Form/Extension/UhfGatewayConfigurationTypeExtensionTest.php
@@ -9,6 +9,7 @@
use PayPlug\SyliusPayPlugPlugin\Gateway\Form\Type\UhfGatewayConfigurationType;
use PayPlug\SyliusPayPlugPlugin\Gateway\UhfGatewayFactory;
use PHPUnit\Framework\TestCase;
+use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints\NotBlank;
@@ -24,20 +25,7 @@ protected function setUp(): void
public function testBuildForm_addsHfIdentifierDefaultTextField(): void
{
- $builder = $this->createMock(FormBuilderInterface::class);
-
- $addCalls = [];
- $builder
- ->expects(self::once())
- ->method('add')
- ->willReturnCallback(function ($name, $type = null, array $options = []) use (&$addCalls, $builder) {
- $addCalls[] = [$name, $type, $options];
-
- return $builder;
- })
- ;
-
- $this->extension->buildForm($builder, []);
+ [, $addCalls] = $this->buildFormAndCollectAddCalls();
[$name, $type, $options] = $addCalls[0];
self::assertSame(UhfGatewayFactory::HF_IDENTIFIER_DEFAULT, $name);
@@ -52,8 +40,46 @@ public function testBuildForm_addsHfIdentifierDefaultTextField(): void
self::assertInstanceOf(NotBlank::class, $options['constraints'][0]);
}
+ public function testBuildForm_addsOneClickCheckboxField(): void
+ {
+ [, $addCalls] = $this->buildFormAndCollectAddCalls();
+
+ [$name, $type, $options] = $addCalls[1];
+ self::assertSame(UhfGatewayFactory::ONE_CLICK, $name);
+ self::assertSame(CheckboxType::class, $type);
+ self::assertSame('payplug_checkbox', $options['block_name']);
+ self::assertSame('payplug_sylius_payplug_plugin.form.one_click_enable', $options['label']);
+ self::assertSame('payplug_sylius_payplug_plugin.form.one_click_help', $options['help']);
+ self::assertTrue($options['help_html']);
+ self::assertFalse($options['required']);
+ self::assertSame(AbstractGatewayConfigurationType::VALIDATION_GROUPS, $options['validation_groups']);
+ }
+
public function testGetExtendedTypes_returnsUhfGatewayConfigurationType(): void
{
self::assertSame([UhfGatewayConfigurationType::class], UhfGatewayConfigurationTypeExtension::getExtendedTypes());
}
+
+ /**
+ * @return array{0: FormBuilderInterface, 1: array}>}
+ */
+ private function buildFormAndCollectAddCalls(): array
+ {
+ $builder = $this->createMock(FormBuilderInterface::class);
+
+ $addCalls = [];
+ $builder
+ ->expects(self::exactly(2))
+ ->method('add')
+ ->willReturnCallback(function ($name, $type = null, array $options = []) use (&$addCalls, $builder) {
+ $addCalls[] = [$name, $type, $options];
+
+ return $builder;
+ })
+ ;
+
+ $this->extension->buildForm($builder, []);
+
+ return [$builder, $addCalls];
+ }
}
diff --git a/tests/PHPUnit/Gateway/Form/Type/AbstractGatewayConfigurationTypeTest.php b/tests/PHPUnit/Gateway/Form/Type/AbstractGatewayConfigurationTypeTest.php
new file mode 100644
index 00000000..f7337a9a
--- /dev/null
+++ b/tests/PHPUnit/Gateway/Form/Type/AbstractGatewayConfigurationTypeTest.php
@@ -0,0 +1,76 @@
+gatewayConfigRepository = $this->createMock(RepositoryInterface::class);
+
+ $this->type = new AbstractGatewayConfigurationType(
+ $this->createMock(TranslatorInterface::class),
+ $this->gatewayConfigRepository,
+ $this->createMock(RequestStack::class),
+ );
+ }
+
+ /**
+ * Every PayPlug-family factory, including `payplug` itself, is limited to one PaymentMethod.
+ */
+ public function testCanBeCreated_otherFactoryAlreadyConfigured_isRefused(): void
+ {
+ $this->gatewayConfigRepository
+ ->expects(self::once())
+ ->method('findOneBy')
+ ->with(['factoryName' => OneyGatewayFactory::FACTORY_NAME])
+ ->willReturn($this->createMock(GatewayConfigInterface::class))
+ ;
+
+ self::assertFalse($this->canBeCreated(OneyGatewayFactory::FACTORY_NAME));
+ }
+
+ public function testCanBeCreated_otherFactoryNotYetConfigured_isAllowed(): void
+ {
+ $this->gatewayConfigRepository
+ ->expects(self::once())
+ ->method('findOneBy')
+ ->with(['factoryName' => OneyGatewayFactory::FACTORY_NAME])
+ ->willReturn(null)
+ ;
+
+ self::assertTrue($this->canBeCreated(OneyGatewayFactory::FACTORY_NAME));
+ }
+
+ private function canBeCreated(string $factoryName): bool
+ {
+ $method = new \ReflectionMethod(AbstractGatewayConfigurationType::class, 'canBeCreated');
+ $method->setAccessible(true);
+
+ /** @var bool $result */
+ $result = $method->invoke($this->type, $factoryName);
+
+ return $result;
+ }
+}
diff --git a/tests/PHPUnit/Gateway/Validator/Constraints/IsCanSavePaymentMethodValidatorTest.php b/tests/PHPUnit/Gateway/Validator/Constraints/IsCanSavePaymentMethodValidatorTest.php
new file mode 100644
index 00000000..0b12d1fd
--- /dev/null
+++ b/tests/PHPUnit/Gateway/Validator/Constraints/IsCanSavePaymentMethodValidatorTest.php
@@ -0,0 +1,138 @@
+apiClientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class);
+
+ return new IsCanSavePaymentMethodValidator($this->apiClientFactory);
+ }
+
+ /**
+ * @dataProvider skipListedFactoryProvider
+ */
+ public function testValidate_skipListedFactory_noViolationAndAccountNeverInspected(string $factoryName): void
+ {
+ $apiClient = $this->createMock(PayPlugApiClientInterface::class);
+ $apiClient->expects(self::never())->method('getAccount');
+
+ $this->apiClientFactory
+ ->expects(self::once())
+ ->method('createForPaymentMethod')
+ ->willReturn($apiClient)
+ ;
+
+ $this->validator->validate($this->buildPaymentMethod($factoryName), new IsCanSavePaymentMethod());
+
+ $this->assertNoViolation();
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function skipListedFactoryProvider(): iterable
+ {
+ yield 'payplug' => [PayPlugGatewayFactory::FACTORY_NAME];
+ yield 'payplug_oney' => [OneyGatewayFactory::FACTORY_NAME];
+ yield 'payplug_uhf' => [UhfGatewayFactory::FACTORY_NAME];
+ }
+
+ public function testValidate_nonSkipListedFactory_notEnabledOnAccount_raisesNoAccessViolation(): void
+ {
+ $apiClient = $this->mockApiClientWithAccount([
+ 'is_live' => true,
+ 'payment_methods' => [
+ 'scalapay' => ['enabled' => false],
+ ],
+ ]);
+ $this->apiClientFactory->method('createForPaymentMethod')->willReturn($apiClient);
+
+ $constraint = new IsCanSavePaymentMethod();
+ $this->validator->validate($this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME), $constraint);
+
+ $this->buildViolation(sprintf($constraint->noAccessMessage, ScalapayGatewayFactory::FACTORY_NAME))
+ ->assertRaised()
+ ;
+ }
+
+ public function testValidate_nonSkipListedFactory_enabledButNotLive_raisesNoTestKeyViolation(): void
+ {
+ $apiClient = $this->mockApiClientWithAccount([
+ 'is_live' => false,
+ 'payment_methods' => [
+ 'scalapay' => ['enabled' => true],
+ ],
+ ]);
+ $this->apiClientFactory->method('createForPaymentMethod')->willReturn($apiClient);
+
+ $constraint = new IsCanSavePaymentMethod();
+ $this->validator->validate($this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME), $constraint);
+
+ $this->buildViolation(sprintf($constraint->noTestKeyMessage, ScalapayGatewayFactory::FACTORY_NAME))
+ ->assertRaised()
+ ;
+ }
+
+ public function testValidate_nonSkipListedFactory_enabledAndLive_noViolation(): void
+ {
+ $apiClient = $this->mockApiClientWithAccount([
+ 'is_live' => true,
+ 'payment_methods' => [
+ 'scalapay' => ['enabled' => true],
+ ],
+ ]);
+ $this->apiClientFactory->method('createForPaymentMethod')->willReturn($apiClient);
+
+ $this->validator->validate($this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME), new IsCanSavePaymentMethod());
+
+ $this->assertNoViolation();
+ }
+
+ private function mockApiClientWithAccount(array $account): PayPlugApiClientInterface&MockObject
+ {
+ $apiClient = $this->createMock(PayPlugApiClientInterface::class);
+ $apiClient->method('getAccount')->willReturn($account);
+
+ return $apiClient;
+ }
+
+ private function buildPaymentMethod(string $factoryName): PaymentMethodInterface&MockObject
+ {
+ $gatewayConfig = $this->createMock(GatewayConfigInterface::class);
+ $gatewayConfig->method('getFactoryName')->willReturn($factoryName);
+
+ $paymentMethod = $this->createMock(PaymentMethodInterface::class);
+ $paymentMethod->method('isEnabled')->willReturn(true);
+ $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig);
+ $paymentMethod->method('getChannels')->willReturn(new ArrayCollection());
+
+ return $paymentMethod;
+ }
+}
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);
+ }
+}
diff --git a/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php b/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php
index 0da3f243..ebdfa911 100644
--- a/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php
+++ b/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php
@@ -10,6 +10,7 @@
use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory;
use PayPlug\SyliusPayPlugPlugin\Gateway\UhfGatewayFactory;
use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\IsCanSavePaymentMethod;
+use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\PayplugPermission;
use PayPlug\SyliusPayPlugPlugin\Validator\PaymentMethodValidator;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
@@ -139,12 +140,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.
+ * Verifies only the always-present constraint (1 total) is passed to the validator:
+ * IsCanSavePaymentMethod.
*/
public function testProcess_payplugFactory_noFlags_validatesWithBaseConstraintOnly(): void
{
@@ -159,7 +161,6 @@ 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);
return new ConstraintViolationList();
@@ -175,12 +176,13 @@ public function testProcess_payplugFactory_noFlags_validatesWithBaseConstraintOn
}
// -------------------------------------------------------------------------
- // process() — PayPlug factory, all flags enabled → 4 constraints (base + 3 permissions)
+ // process() — PayPlug factory, all permission flags enabled → 4 constraints (1 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).
+ * Verifies 4 constraints are passed to the validator: the always-present
+ * IsCanSavePaymentMethod plus one per enabled feature flag.
*/
public function testProcess_payplugFactory_allFlagsEnabled_validatesWithAllConstraints(): void
{
@@ -211,17 +213,16 @@ public function testProcess_payplugFactory_allFlagsEnabled_validatesWithAllConst
}
// -------------------------------------------------------------------------
- // process() — UHF factory → routed to processDefault(), base constraint only
+ // process() — UHF factory → routed to processUhf()
// -------------------------------------------------------------------------
/**
- * UHF gateway. Verifies the match statement routes UhfGatewayFactory::FACTORY_NAME to
- * processDefault(), which validates with the base IsCanSavePaymentMethod constraint only (1
- * total), the same as Bancontact/Amex/ApplePay/Scalapay/Wero.
+ * UHF gateway with oneClick absent/false. Verifies processUhf() validates with the base
+ * IsCanSavePaymentMethod constraint only (1 total) — no permission constraint added.
*/
- public function testProcess_uhfFactory_validatesWithBaseConstraintOnly(): void
+ public function testProcess_uhfFactory_oneClickFalse_validatesWithBaseConstraintOnly(): void
{
- $paymentMethod = $this->buildPaymentMethod(UhfGatewayFactory::FACTORY_NAME, []);
+ $paymentMethod = $this->buildPaymentMethod(UhfGatewayFactory::FACTORY_NAME, [UhfGatewayFactory::ONE_CLICK => false]);
$this->validator
->expects(self::once())
@@ -242,6 +243,34 @@ public function testProcess_uhfFactory_validatesWithBaseConstraintOnly(): void
$this->paymentMethodValidator->process($paymentMethod);
}
+ /**
+ * UHF gateway with oneClick=true. Verifies processUhf() adds a PayplugPermission
+ * (CAN_SAVE_CARD) constraint alongside the base one (2 total).
+ */
+ public function testProcess_uhfFactory_oneClickTrue_validatesWithPermissionConstraint(): void
+ {
+ $paymentMethod = $this->buildPaymentMethod(UhfGatewayFactory::FACTORY_NAME, [UhfGatewayFactory::ONE_CLICK => true]);
+
+ $this->validator
+ ->expects(self::once())
+ ->method('validate')
+ ->willReturnCallback(function ($subject, array $constraints) {
+ self::assertCount(2, $constraints);
+ self::assertInstanceOf(IsCanSavePaymentMethod::class, $constraints[0]);
+ self::assertInstanceOf(PayplugPermission::class, $constraints[1]);
+
+ return new ConstraintViolationList();
+ })
+ ;
+
+ $flashBag = $this->createMock(FlashBagInterface::class);
+ $session = $this->createMock(Session::class);
+ $session->method('getFlashBag')->willReturn($flashBag);
+ $this->requestStack->method('getSession')->willReturn($session);
+
+ $this->paymentMethodValidator->process($paymentMethod);
+ }
+
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
diff --git a/translations/messages.en.yml b/translations/messages.en.yml
index a6c4e5bc..cfcedb05 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 8fff41d7..818b51e7 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 390ab06c..e89ff5f8 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.