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 %} + + + +
+
+ {{ '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_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.