diff --git a/.ddev/config.yaml b/.ddev/config.yaml index b56e10cf2..d65593b7c 100644 --- a/.ddev/config.yaml +++ b/.ddev/config.yaml @@ -27,7 +27,6 @@ web_environment: - ADMIN_PASS=admin - ADMIN_EMAIL=admin@example.com - WC_VERSION=7.7.2 - - PCP_BLOCKS_ENABLED=1 # Key features of ddev's config.yaml: diff --git a/.env.e2e.example b/.env.e2e.example index f080f8faf..98ad27c23 100644 --- a/.env.e2e.example +++ b/.env.e2e.example @@ -15,6 +15,8 @@ PRODUCT_ID=123 SUBSCRIPTION_URL="/product/sub" +APM_ID="sofort" + WP_MERCHANT_USER="admin" WP_MERCHANT_PASSWORD="admin" diff --git a/.psalm/stubs.php b/.psalm/stubs.php index 8b4990022..b57753212 100644 --- a/.psalm/stubs.php +++ b/.psalm/stubs.php @@ -1,4 +1,7 @@ item_factory->from_wc_order( $order ), function ( Item $item ): bool { - return $item->unit_amount()->value() > 0; + return $item->unit_amount()->value() >= 0; } ); $shipping = $this->shipping_factory->from_wc_order( $order ); @@ -166,7 +168,7 @@ class PurchaseUnitFactory { $items = array_filter( $this->item_factory->from_wc_cart( $cart ), function ( Item $item ): bool { - return $item->unit_amount()->value() > 0; + return $item->unit_amount()->value() >= 0; } ); @@ -187,7 +189,14 @@ class PurchaseUnitFactory { $payee = $this->payee_repository->payee(); - $custom_id = ''; + $custom_id = ''; + $session = WC()->session; + if ( $session instanceof WC_Session_Handler ) { + $session_id = $session->get_customer_unique_id(); + if ( $session_id ) { + $custom_id = CustomIds::CUSTOMER_ID_PREFIX . $session_id; + } + } $invoice_id = ''; $soft_descriptor = ''; $purchase_unit = new PurchaseUnit( diff --git a/modules/ppcp-button/resources/js/button.js b/modules/ppcp-button/resources/js/button.js index 718378875..790030920 100644 --- a/modules/ppcp-button/resources/js/button.js +++ b/modules/ppcp-button/resources/js/button.js @@ -28,6 +28,8 @@ const cardsSpinner = new Spinner('#ppcp-hosted-fields'); const bootstrap = () => { const checkoutFormSelector = 'form.woocommerce-checkout'; + const context = PayPalCommerceGateway.context; + const errorHandler = new ErrorHandler( PayPalCommerceGateway.labels.error.generic, document.querySelector(checkoutFormSelector) ?? document.querySelector('.woocommerce-notices-wrapper') @@ -58,7 +60,7 @@ const bootstrap = () => { } }); - const onSmartButtonClick = (data, actions) => { + const onSmartButtonClick = async (data, actions) => { window.ppcpFundingSource = data.fundingSource; const requiredFields = jQuery('form.woocommerce-checkout .validate-required:visible :input'); requiredFields.each((i, input) => { @@ -120,13 +122,21 @@ const bootstrap = () => { freeTrialHandler.handle(); return actions.reject(); } + + if (context === 'checkout' && !PayPalCommerceGateway.funding_sources_without_redirect.includes(data.fundingSource)) { + try { + await formSaver.save(form); + } catch (error) { + console.error(error); + } + } }; + const onSmartButtonsInit = () => { buttonsSpinner.unblock(); }; const renderer = new Renderer(creditCardRenderer, PayPalCommerceGateway, onSmartButtonClick, onSmartButtonsInit); const messageRenderer = new MessageRenderer(PayPalCommerceGateway.messages); - const context = PayPalCommerceGateway.context; if (context === 'mini-cart' || context === 'product') { if (PayPalCommerceGateway.mini_cart_buttons_enabled === '1') { const miniCartBootstrap = new MiniCartBootstap( diff --git a/modules/ppcp-button/resources/js/modules/ActionHandler/CheckoutActionHandler.js b/modules/ppcp-button/resources/js/modules/ActionHandler/CheckoutActionHandler.js index f0654c4f0..f528f289e 100644 --- a/modules/ppcp-button/resources/js/modules/ActionHandler/CheckoutActionHandler.js +++ b/modules/ppcp-button/resources/js/modules/ActionHandler/CheckoutActionHandler.js @@ -102,6 +102,9 @@ class CheckoutActionHandler { } else { errorHandler.message(data.data.message); } + + // fire WC event for other plugins + jQuery( document.body ).trigger( 'checkout_error' , [ errorHandler.currentHtml() ] ); } throw {type: 'create-order-error', data: data.data}; diff --git a/modules/ppcp-button/resources/js/modules/ActionHandler/FreeTrialHandler.js b/modules/ppcp-button/resources/js/modules/ActionHandler/FreeTrialHandler.js index 878e20207..5486576ff 100644 --- a/modules/ppcp-button/resources/js/modules/ActionHandler/FreeTrialHandler.js +++ b/modules/ppcp-button/resources/js/modules/ActionHandler/FreeTrialHandler.js @@ -40,6 +40,10 @@ class FreeTrialHandler { if (errors.length > 0) { this.spinner.unblock(); this.errorHandler.messages(errors); + + // fire WC event for other plugins + jQuery( document.body ).trigger( 'checkout_error' , [ this.errorHandler.currentHtml() ] ); + return; } } catch (error) { diff --git a/modules/ppcp-button/resources/js/modules/ActionHandler/SingleProductActionHandler.js b/modules/ppcp-button/resources/js/modules/ActionHandler/SingleProductActionHandler.js index 3c3371542..146fd7e94 100644 --- a/modules/ppcp-button/resources/js/modules/ActionHandler/SingleProductActionHandler.js +++ b/modules/ppcp-button/resources/js/modules/ActionHandler/SingleProductActionHandler.js @@ -1,4 +1,3 @@ -import ButtonsToggleListener from '../Helper/ButtonsToggleListener'; import Product from '../Entity/Product'; import onApprove from '../OnApproveHandler/onApproveForContinue'; import {payerData} from "../Helper/PayerData"; diff --git a/modules/ppcp-button/resources/js/modules/ContextBootstrap/CartBootstap.js b/modules/ppcp-button/resources/js/modules/ContextBootstrap/CartBootstap.js index 6cc9a13af..5aadb7f61 100644 --- a/modules/ppcp-button/resources/js/modules/ContextBootstrap/CartBootstap.js +++ b/modules/ppcp-button/resources/js/modules/ContextBootstrap/CartBootstap.js @@ -1,4 +1,5 @@ import CartActionHandler from '../ActionHandler/CartActionHandler'; +import BootstrapHelper from "../Helper/BootstrapHelper"; import {setVisible} from "../Helper/Hiding"; class CartBootstrap { @@ -8,6 +9,10 @@ class CartBootstrap { this.messages = messages; this.errorHandler = errorHandler; this.lastAmount = this.gateway.messages.amount; + + this.renderer.onButtonsInit(this.gateway.button.wrapper, () => { + this.handleButtonStatus(); + }, true); } init() { @@ -16,9 +21,11 @@ class CartBootstrap { } this.render(); + this.handleButtonStatus(); jQuery(document.body).on('updated_cart_totals updated_checkout', () => { this.render(); + this.handleButtonStatus(); fetch( this.gateway.ajax.cart_script_params.endpoint, @@ -47,10 +54,18 @@ class CartBootstrap { }); } + handleButtonStatus() { + BootstrapHelper.handleButtonStatus(this); + } + shouldRender() { return document.querySelector(this.gateway.button.wrapper) !== null; } + shouldEnable() { + return BootstrapHelper.shouldEnable(this); + } + render() { const actionHandler = new CartActionHandler( PayPalCommerceGateway, diff --git a/modules/ppcp-button/resources/js/modules/ContextBootstrap/CheckoutBootstap.js b/modules/ppcp-button/resources/js/modules/ContextBootstrap/CheckoutBootstap.js index 59c6197a3..b426ac3ee 100644 --- a/modules/ppcp-button/resources/js/modules/ContextBootstrap/CheckoutBootstap.js +++ b/modules/ppcp-button/resources/js/modules/ContextBootstrap/CheckoutBootstap.js @@ -5,6 +5,8 @@ import { isSavedCardSelected, ORDER_BUTTON_SELECTOR, PaymentMethods } from "../Helper/CheckoutMethodState"; +import BootstrapHelper from "../Helper/BootstrapHelper"; +import {disable, enable} from "../Helper/ButtonDisabler"; class CheckoutBootstap { constructor(gateway, renderer, messages, spinner, errorHandler) { @@ -15,10 +17,15 @@ class CheckoutBootstap { this.errorHandler = errorHandler; this.standardOrderButtonSelector = ORDER_BUTTON_SELECTOR; + + this.renderer.onButtonsInit(this.gateway.button.wrapper, () => { + this.handleButtonStatus(); + }, true); } init() { this.render(); + this.handleButtonStatus(); // Unselect saved card. // WC saves form values, so with our current UI it would be a bit weird @@ -28,6 +35,7 @@ class CheckoutBootstap { jQuery(document.body).on('updated_checkout', () => { this.render() + this.handleButtonStatus(); }); jQuery(document.body).on('updated_checkout payment_method_selected', () => { @@ -43,6 +51,10 @@ class CheckoutBootstap { this.updateUi(); } + handleButtonStatus() { + BootstrapHelper.handleButtonStatus(this); + } + shouldRender() { if (document.querySelector(this.gateway.button.cancel_wrapper)) { return false; @@ -51,6 +63,10 @@ class CheckoutBootstap { return document.querySelector(this.gateway.button.wrapper) !== null || document.querySelector(this.gateway.hosted_fields.wrapper) !== null; } + shouldEnable() { + return BootstrapHelper.shouldEnable(this); + } + render() { if (!this.shouldRender()) { return; diff --git a/modules/ppcp-button/resources/js/modules/ContextBootstrap/MiniCartBootstap.js b/modules/ppcp-button/resources/js/modules/ContextBootstrap/MiniCartBootstap.js index 443c9afe4..4b4e3efd6 100644 --- a/modules/ppcp-button/resources/js/modules/ContextBootstrap/MiniCartBootstap.js +++ b/modules/ppcp-button/resources/js/modules/ContextBootstrap/MiniCartBootstap.js @@ -1,4 +1,5 @@ import CartActionHandler from '../ActionHandler/CartActionHandler'; +import BootstrapHelper from "../Helper/BootstrapHelper"; class MiniCartBootstap { constructor(gateway, renderer, errorHandler) { @@ -15,9 +16,22 @@ class MiniCartBootstap { this.errorHandler, ); this.render(); + this.handleButtonStatus(); jQuery(document.body).on('wc_fragments_loaded wc_fragments_refreshed', () => { this.render(); + this.handleButtonStatus(); + }); + + this.renderer.onButtonsInit(this.gateway.button.mini_cart_wrapper, () => { + this.handleButtonStatus(); + }, true); + } + + handleButtonStatus() { + BootstrapHelper.handleButtonStatus(this, { + wrapper: this.gateway.button.mini_cart_wrapper, + skipMessages: true }); } @@ -26,6 +40,12 @@ class MiniCartBootstap { || document.querySelector(this.gateway.hosted_fields.mini_cart_wrapper) !== null; } + shouldEnable() { + return BootstrapHelper.shouldEnable(this, { + isDisabled: !!this.gateway.button.is_mini_cart_disabled + }); + } + render() { if (!this.shouldRender()) { return; diff --git a/modules/ppcp-button/resources/js/modules/ContextBootstrap/SingleProductBootstap.js b/modules/ppcp-button/resources/js/modules/ContextBootstrap/SingleProductBootstap.js index 376962772..231460642 100644 --- a/modules/ppcp-button/resources/js/modules/ContextBootstrap/SingleProductBootstap.js +++ b/modules/ppcp-button/resources/js/modules/ContextBootstrap/SingleProductBootstap.js @@ -1,7 +1,7 @@ import UpdateCart from "../Helper/UpdateCart"; import SingleProductActionHandler from "../ActionHandler/SingleProductActionHandler"; -import {hide, show, setVisible} from "../Helper/Hiding"; -import ButtonsToggleListener from "../Helper/ButtonsToggleListener"; +import {hide, show} from "../Helper/Hiding"; +import BootstrapHelper from "../Helper/BootstrapHelper"; class SingleProductBootstap { constructor(gateway, renderer, messages, errorHandler) { @@ -10,56 +10,85 @@ class SingleProductBootstap { this.messages = messages; this.errorHandler = errorHandler; this.mutationObserver = new MutationObserver(this.handleChange.bind(this)); + this.formSelector = 'form.cart'; + + this.renderer.onButtonsInit(this.gateway.button.wrapper, () => { + this.handleChange(); + }, true); } + form() { + return document.querySelector(this.formSelector); + } handleChange() { - const shouldRender = this.shouldRender(); - setVisible(this.gateway.button.wrapper, shouldRender); - setVisible(this.gateway.messages.wrapper, shouldRender); - if (!shouldRender) { - return; - } - - this.render(); - } - - init() { - const form = document.querySelector('form.cart'); - if (!form) { - return; - } - - form.addEventListener('change', this.handleChange.bind(this)); - this.mutationObserver.observe(form, {childList: true, subtree: true}); - - const buttonObserver = new ButtonsToggleListener( - form.querySelector('.single_add_to_cart_button'), - () => { - show(this.gateway.button.wrapper); - show(this.gateway.messages.wrapper); - this.messages.renderWithAmount(this.priceAmount()) - }, - () => { - hide(this.gateway.button.wrapper); - hide(this.gateway.messages.wrapper); - }, - ); - buttonObserver.init(); - if (!this.shouldRender()) { - hide(this.gateway.button.wrapper); + this.renderer.disableSmartButtons(this.gateway.button.wrapper); + hide(this.gateway.button.wrapper, this.formSelector); hide(this.gateway.messages.wrapper); return; } this.render(); + + this.renderer.enableSmartButtons(this.gateway.button.wrapper); + show(this.gateway.button.wrapper); + show(this.gateway.messages.wrapper); + + this.handleButtonStatus(); + } + + handleButtonStatus() { + BootstrapHelper.handleButtonStatus(this, { + formSelector: this.formSelector + }); + } + + init() { + const form = this.form(); + + if (!form) { + return; + } + + form.addEventListener('change', () => { + this.handleChange(); + + setTimeout(() => { // Wait for the DOM to be fully updated + // For the moment renderWithAmount should only be done here to prevent undesired side effects due to priceAmount() + // not being correctly formatted in some cases, can be moved to handleButtonStatus() once this issue is fixed + this.messages.renderWithAmount(this.priceAmount()); + }, 100); + }); + this.mutationObserver.observe(form, { childList: true, subtree: true }); + + const addToCartButton = form.querySelector('.single_add_to_cart_button'); + + if (addToCartButton) { + (new MutationObserver(this.handleButtonStatus.bind(this))) + .observe(addToCartButton, { attributes : true }); + } + + if (!this.shouldRender()) { + return; + } + + this.render(); + this.handleChange(); } shouldRender() { - return document.querySelector('form.cart') !== null + return this.form() !== null + && !this.isWcsattSubscriptionMode(); + } + + shouldEnable() { + const form = this.form(); + const addToCartButton = form ? form.querySelector('.single_add_to_cart_button') : null; + + return BootstrapHelper.shouldEnable(this) && !this.priceAmountIsZero() - && !this.isSubscriptionMode(); + && ((null === addToCartButton) || !addToCartButton.classList.contains('disabled')); } priceAmount() { @@ -93,7 +122,7 @@ class SingleProductBootstap { return !price || price === 0; } - isSubscriptionMode() { + isWcsattSubscriptionMode() { // Check "All products for subscriptions" plugin. return document.querySelector('.wcsatt-options-product:not(.wcsatt-options-product--hidden) .subscription-option input[type="radio"]:checked') !== null || document.querySelector('.wcsatt-options-prompt-label-subscription input[type="radio"]:checked') !== null; // grouped @@ -106,7 +135,7 @@ class SingleProductBootstap { this.gateway.ajax.change_cart.endpoint, this.gateway.ajax.change_cart.nonce, ), - document.querySelector('form.cart'), + this.form(), this.errorHandler, ); diff --git a/modules/ppcp-button/resources/js/modules/ErrorHandler.js b/modules/ppcp-button/resources/js/modules/ErrorHandler.js index 726ef2074..6048360b2 100644 --- a/modules/ppcp-button/resources/js/modules/ErrorHandler.js +++ b/modules/ppcp-button/resources/js/modules/ErrorHandler.js @@ -40,6 +40,15 @@ class ErrorHandler { this._scrollToMessages(); } + /** + * @returns {String} + */ + currentHtml() + { + const messageContainer = this._getMessageContainer(); + return messageContainer.outerHTML; + } + /** * @private * @param {String} text diff --git a/modules/ppcp-button/resources/js/modules/Helper/BootstrapHelper.js b/modules/ppcp-button/resources/js/modules/Helper/BootstrapHelper.js new file mode 100644 index 000000000..ba00e2c76 --- /dev/null +++ b/modules/ppcp-button/resources/js/modules/Helper/BootstrapHelper.js @@ -0,0 +1,40 @@ +import {disable, enable} from "./ButtonDisabler"; + +/** + * Common Bootstrap methods to avoid code repetition. + */ +export default class BootstrapHelper { + + static handleButtonStatus(bs, options) { + options = options || {}; + options.wrapper = options.wrapper || bs.gateway.button.wrapper; + options.messagesWrapper = options.messagesWrapper || bs.gateway.messages.wrapper; + options.skipMessages = options.skipMessages || false; + + if (!bs.shouldEnable()) { + bs.renderer.disableSmartButtons(options.wrapper); + disable(options.wrapper, options.formSelector || null); + + if (!options.skipMessages) { + disable(options.messagesWrapper); + } + return; + } + bs.renderer.enableSmartButtons(options.wrapper); + enable(options.wrapper); + + if (!options.skipMessages) { + enable(options.messagesWrapper); + } + } + + static shouldEnable(bs, options) { + options = options || {}; + if (typeof options.isDisabled === 'undefined') { + options.isDisabled = bs.gateway.button.is_disabled; + } + + return bs.shouldRender() + && options.isDisabled !== true; + } +} diff --git a/modules/ppcp-button/resources/js/modules/Helper/ButtonDisabler.js b/modules/ppcp-button/resources/js/modules/Helper/ButtonDisabler.js new file mode 100644 index 000000000..ae7f3665c --- /dev/null +++ b/modules/ppcp-button/resources/js/modules/Helper/ButtonDisabler.js @@ -0,0 +1,56 @@ +/** + * @param selectorOrElement + * @returns {Element} + */ +const getElement = (selectorOrElement) => { + if (typeof selectorOrElement === 'string') { + return document.querySelector(selectorOrElement); + } + return selectorOrElement; +} + +export const setEnabled = (selectorOrElement, enable, form = null) => { + const element = getElement(selectorOrElement); + + if (!element) { + return; + } + + if (enable) { + jQuery(element).css({ + 'cursor': '', + '-webkit-filter': '', + 'filter': '', + } ) + .off('mouseup') + .find('> *') + .css('pointer-events', ''); + } else { + jQuery(element).css({ + 'cursor': 'not-allowed', + '-webkit-filter': 'grayscale(100%)', + 'filter': 'grayscale(100%)', + }) + .on('mouseup', function(event) { + event.stopImmediatePropagation(); + + if (form) { + // Trigger form submit to show the error message + let $form = jQuery(form); + if ($form.find('.single_add_to_cart_button').hasClass('disabled')) { + $form.find(':submit').trigger('click'); + } + } + }) + .find('> *') + .css('pointer-events', 'none'); + } +}; + +export const disable = (selectorOrElement, form = null) => { + setEnabled(selectorOrElement, false, form); +}; + +export const enable = (selectorOrElement) => { + setEnabled(selectorOrElement, true); +}; diff --git a/modules/ppcp-button/resources/js/modules/Helper/ButtonsToggleListener.js b/modules/ppcp-button/resources/js/modules/Helper/ButtonsToggleListener.js deleted file mode 100644 index add1ee28a..000000000 --- a/modules/ppcp-button/resources/js/modules/Helper/ButtonsToggleListener.js +++ /dev/null @@ -1,39 +0,0 @@ -/** - * When you can't add something to the cart, the PayPal buttons should not show. - * Therefore we listen for changes on the add to cart button and show/hide the buttons accordingly. - */ - -class ButtonsToggleListener { - constructor(element, showCallback, hideCallback) - { - this.element = element; - this.showCallback = showCallback; - this.hideCallback = hideCallback; - this.observer = null; - } - - init() - { - if (!this.element) { - return; - } - const config = { attributes : true }; - const callback = () => { - if (this.element.classList.contains('disabled')) { - this.hideCallback(); - return; - } - this.showCallback(); - } - this.observer = new MutationObserver(callback); - this.observer.observe(this.element, config); - callback(); - } - - disconnect() - { - this.observer.disconnect(); - } -} - -export default ButtonsToggleListener; diff --git a/modules/ppcp-button/resources/js/modules/Renderer/CreditCardRenderer.js b/modules/ppcp-button/resources/js/modules/Renderer/CreditCardRenderer.js index 0ddafdd15..fcd20a609 100644 --- a/modules/ppcp-button/resources/js/modules/Renderer/CreditCardRenderer.js +++ b/modules/ppcp-button/resources/js/modules/Renderer/CreditCardRenderer.js @@ -10,6 +10,7 @@ class CreditCardRenderer { this.spinner = spinner; this.cardValid = false; this.formValid = false; + this.emptyFields = new Set(['number', 'cvv', 'expirationDate']); this.currentHostedFieldsInstance = null; } @@ -130,7 +131,7 @@ class CreditCardRenderer { return event.fields[key].isValid; }); - const className = this._cardNumberFiledCLassNameByCardType(event.cards[0].type); + const className = event.cards.length ? this._cardNumberFiledCLassNameByCardType(event.cards[0].type) : ''; event.fields.number.isValid ? cardNumber.classList.add(className) : this._recreateElementClassAttribute(cardNumber, cardNumberField.className); @@ -138,6 +139,12 @@ class CreditCardRenderer { this.formValid = formValid; }); + hostedFields.on('empty', (event) => { + this.emptyFields.add(event.emittedBy); + }); + hostedFields.on('notEmpty', (event) => { + this.emptyFields.delete(event.emittedBy); + }); show(buttonSelector); @@ -249,7 +256,16 @@ class CreditCardRenderer { }); } else { this.spinner.unblock(); - const message = ! this.cardValid ? this.defaultConfig.hosted_fields.labels.card_not_supported : this.defaultConfig.hosted_fields.labels.fields_not_valid; + + let message = this.defaultConfig.labels.error.generic; + if (this.emptyFields.size > 0) { + message = this.defaultConfig.hosted_fields.labels.fields_empty; + } else if (!this.cardValid) { + message = this.defaultConfig.hosted_fields.labels.card_not_supported; + } else if (!this.formValid) { + message = this.defaultConfig.hosted_fields.labels.fields_not_valid; + } + this.errorHandler.message(message); } } diff --git a/modules/ppcp-button/resources/js/modules/Renderer/MessageRenderer.js b/modules/ppcp-button/resources/js/modules/Renderer/MessageRenderer.js index a07e81d21..80c90c0ef 100644 --- a/modules/ppcp-button/resources/js/modules/Renderer/MessageRenderer.js +++ b/modules/ppcp-button/resources/js/modules/Renderer/MessageRenderer.js @@ -2,6 +2,7 @@ class MessageRenderer { constructor(config) { this.config = config; + this.optionsFingerprint = null; } render() { @@ -9,38 +10,58 @@ class MessageRenderer { return; } - paypal.Messages({ + const options = { amount: this.config.amount, placement: this.config.placement, style: this.config.style - }).render(this.config.wrapper); + }; + + if (this.optionsEqual(options)) { + return; + } + + paypal.Messages(options).render(this.config.wrapper); jQuery(document.body).on('updated_cart_totals', () => { - paypal.Messages({ - amount: this.config.amount, - placement: this.config.placement, - style: this.config.style - }).render(this.config.wrapper); + paypal.Messages(options).render(this.config.wrapper); }); } renderWithAmount(amount) { - if (! this.shouldRender()) { return; } + const options = { + amount, + placement: this.config.placement, + style: this.config.style + }; + + if (this.optionsEqual(options)) { + return; + } + const newWrapper = document.createElement('div'); newWrapper.setAttribute('id', this.config.wrapper.replace('#', '')); - const sibling = document.querySelector(this.config.wrapper).nextSibling; - document.querySelector(this.config.wrapper).parentElement.removeChild(document.querySelector(this.config.wrapper)); + const oldWrapper = document.querySelector(this.config.wrapper); + const sibling = oldWrapper.nextSibling; + oldWrapper.parentElement.removeChild(oldWrapper); sibling.parentElement.insertBefore(newWrapper, sibling); - paypal.Messages({ - amount, - placement: this.config.placement, - style: this.config.style - }).render(this.config.wrapper); + + paypal.Messages(options).render(this.config.wrapper); + } + + optionsEqual(options) { + const fingerprint = JSON.stringify(options); + + if (this.optionsFingerprint === fingerprint) { + return true; + } + + this.optionsFingerprint = fingerprint; + return false; } shouldRender() { diff --git a/modules/ppcp-button/resources/js/modules/Renderer/Renderer.js b/modules/ppcp-button/resources/js/modules/Renderer/Renderer.js index 17f58aff3..1f0e3d31a 100644 --- a/modules/ppcp-button/resources/js/modules/Renderer/Renderer.js +++ b/modules/ppcp-button/resources/js/modules/Renderer/Renderer.js @@ -7,6 +7,9 @@ class Renderer { this.onSmartButtonClick = onSmartButtonClick; this.onSmartButtonsInit = onSmartButtonsInit; + this.buttonsOptions = {}; + this.onButtonsInitListeners = {}; + this.renderedSources = new Set(); } @@ -77,7 +80,10 @@ class Renderer { style, ...contextConfig, onClick: this.onSmartButtonClick, - onInit: this.onSmartButtonsInit, + onInit: (data, actions) => { + this.onSmartButtonsInit(data, actions); + this.handleOnButtonsInit(wrapper, data, actions); + }, }); if (!btn.isEligible()) { return; @@ -106,6 +112,44 @@ class Renderer { enableCreditCardFields() { this.creditCardRenderer.enableFields(); } + + onButtonsInit(wrapper, handler, reset) { + this.onButtonsInitListeners[wrapper] = reset ? [] : (this.onButtonsInitListeners[wrapper] || []); + this.onButtonsInitListeners[wrapper].push(handler); + } + + handleOnButtonsInit(wrapper, data, actions) { + + this.buttonsOptions[wrapper] = { + data: data, + actions: actions + } + + if (this.onButtonsInitListeners[wrapper]) { + for (let handler of this.onButtonsInitListeners[wrapper]) { + if (typeof handler === 'function') { + handler({ + wrapper: wrapper, + ...this.buttonsOptions[wrapper] + }); + } + } + } + } + + disableSmartButtons(wrapper) { + if (!this.buttonsOptions[wrapper]) { + return; + } + this.buttonsOptions[wrapper].actions.disable(); + } + + enableSmartButtons(wrapper) { + if (!this.buttonsOptions[wrapper]) { + return; + } + this.buttonsOptions[wrapper].actions.enable(); + } } export default Renderer; diff --git a/modules/ppcp-button/services.php b/modules/ppcp-button/services.php index 7f8c50b14..56381fcc2 100644 --- a/modules/ppcp-button/services.php +++ b/modules/ppcp-button/services.php @@ -67,23 +67,6 @@ return array( return $dummy_ids[ $shop_country ] ?? $container->get( 'button.client_id' ); }, - 'button.is_paypal_continuation' => static function( ContainerInterface $container ): bool { - $session_handler = $container->get( 'session.handler' ); - - $order = $session_handler->order(); - if ( ! $order ) { - return false; - } - $source = $order->payment_source(); - if ( $source && $source->card() ) { - return false; // Ignore for DCC. - } - if ( 'card' === $session_handler->funding_source() ) { - return false; // Ignore for card buttons. - } - - return true; - }, 'button.smart-button' => static function ( ContainerInterface $container ): SmartButtonInterface { $state = $container->get( 'onboarding.state' ); if ( $state->current_state() !== State::STATE_ONBOARDED ) { @@ -125,6 +108,7 @@ return array( $container->get( 'button.basic-checkout-validation-enabled' ), $container->get( 'button.early-wc-checkout-validation-enabled' ), $container->get( 'button.pay-now-contexts' ), + $container->get( 'wcgateway.funding-sources-without-redirect' ), $container->get( 'woocommerce.logger.woocommerce' ) ); }, @@ -176,6 +160,7 @@ return array( $container->get( 'button.early-wc-checkout-validation-enabled' ), $container->get( 'button.pay-now-contexts' ), $container->get( 'button.handle-shipping-in-paypal' ), + $container->get( 'wcgateway.funding-sources-without-redirect' ), $logger ); }, @@ -184,8 +169,7 @@ return array( $state = $container->get( 'onboarding.state' ); $order_processor = $container->get( 'wcgateway.order-processor' ); $session_handler = $container->get( 'session.handler' ); - $prefix = $container->get( 'api.prefix' ); - return new EarlyOrderHandler( $state, $order_processor, $session_handler, $prefix ); + return new EarlyOrderHandler( $state, $order_processor, $session_handler ); }, 'button.endpoint.approve-order' => static function ( ContainerInterface $container ): ApproveOrderEndpoint { $request_data = $container->get( 'button.request-data' ); @@ -215,7 +199,9 @@ return array( ); }, 'button.checkout-form-saver' => static function ( ContainerInterface $container ): CheckoutFormSaver { - return new CheckoutFormSaver(); + return new CheckoutFormSaver( + $container->get( 'session.handler' ) + ); }, 'button.endpoint.save-checkout-form' => static function ( ContainerInterface $container ): SaveCheckoutFormEndpoint { return new SaveCheckoutFormEndpoint( diff --git a/modules/ppcp-button/src/Assets/SmartButton.php b/modules/ppcp-button/src/Assets/SmartButton.php index 7f07c332a..fded20a67 100644 --- a/modules/ppcp-button/src/Assets/SmartButton.php +++ b/modules/ppcp-button/src/Assets/SmartButton.php @@ -11,6 +11,7 @@ namespace WooCommerce\PayPalCommerce\Button\Assets; use Exception; use Psr\Log\LoggerInterface; +use WC_Order; use WC_Product; use WC_Product_Variation; use WooCommerce\PayPalCommerce\ApiClient\Entity\PaymentToken; @@ -173,6 +174,13 @@ class SmartButton implements SmartButtonInterface { */ private $pay_now_contexts; + /** + * The sources that do not cause issues about redirecting (on mobile, ...) and sometimes not returning back. + * + * @var string[] + */ + private $funding_sources_without_redirect; + /** * The logger. * @@ -208,6 +216,7 @@ class SmartButton implements SmartButtonInterface { * @param bool $basic_checkout_validation_enabled Whether the basic JS validation of the form iss enabled. * @param bool $early_validation_enabled Whether to execute WC validation of the checkout form. * @param array $pay_now_contexts The contexts that should have the Pay Now button. + * @param string[] $funding_sources_without_redirect The sources that do not cause issues about redirecting (on mobile, ...) and sometimes not returning back. * @param LoggerInterface $logger The logger. */ public function __construct( @@ -229,6 +238,7 @@ class SmartButton implements SmartButtonInterface { bool $basic_checkout_validation_enabled, bool $early_validation_enabled, array $pay_now_contexts, + array $funding_sources_without_redirect, LoggerInterface $logger ) { @@ -250,6 +260,7 @@ class SmartButton implements SmartButtonInterface { $this->basic_checkout_validation_enabled = $basic_checkout_validation_enabled; $this->early_validation_enabled = $early_validation_enabled; $this->pay_now_contexts = $pay_now_contexts; + $this->funding_sources_without_redirect = $funding_sources_without_redirect; $this->logger = $logger; } @@ -796,8 +807,6 @@ class SmartButton implements SmartButtonInterface { * @return array */ public function script_data(): array { - global $wp; - $is_free_trial_cart = $this->is_free_trial_cart(); $url_params = $this->url_params(); @@ -858,10 +867,12 @@ class SmartButton implements SmartButtonInterface { 'bn_codes' => $this->bn_codes(), 'payer' => $this->payerData(), 'button' => array( - 'wrapper' => '#ppc-button-' . PayPalGateway::ID, - 'mini_cart_wrapper' => '#ppc-button-minicart', - 'cancel_wrapper' => '#ppcp-cancel', - 'mini_cart_style' => array( + 'wrapper' => '#ppc-button-' . PayPalGateway::ID, + 'is_disabled' => $this->is_button_disabled(), + 'mini_cart_wrapper' => '#ppc-button-minicart', + 'is_mini_cart_disabled' => $this->is_button_disabled( 'mini-cart' ), + 'cancel_wrapper' => '#ppcp-cancel', + 'mini_cart_style' => array( 'layout' => $this->style_for_context( 'layout', 'mini-cart' ), 'color' => $this->style_for_context( 'color', 'mini-cart' ), 'shape' => $this->style_for_context( 'shape', 'mini-cart' ), @@ -869,7 +880,7 @@ class SmartButton implements SmartButtonInterface { 'tagline' => $this->style_for_context( 'tagline', 'mini-cart' ), 'height' => $this->settings->has( 'button_mini-cart_height' ) && $this->settings->get( 'button_mini-cart_height' ) ? $this->normalize_height( (int) $this->settings->get( 'button_mini-cart_height' ) ) : 35, ), - 'style' => array( + 'style' => array( 'layout' => $this->style_for_context( 'layout', $this->context() ), 'color' => $this->style_for_context( 'color', $this->context() ), 'shape' => $this->style_for_context( 'shape', $this->context() ), @@ -893,6 +904,10 @@ class SmartButton implements SmartButtonInterface { 'credit_card_number' => '', 'cvv' => '', 'mm_yy' => __( 'MM/YY', 'woocommerce-paypal-payments' ), + 'fields_empty' => __( + 'Card payment details are missing. Please fill in all required fields.', + 'woocommerce-paypal-payments' + ), 'fields_not_valid' => __( 'Unfortunately, your credit card details are not valid.', 'woocommerce-paypal-payments' @@ -934,11 +949,12 @@ class SmartButton implements SmartButtonInterface { // phpcs:ignore WordPress.WP.I18n 'shipping_field' => _x( 'Shipping %s', 'checkout-validation', 'woocommerce' ), ), - 'order_id' => 'pay-now' === $this->context() ? absint( $wp->query_vars['order-pay'] ) : 0, + 'order_id' => 'pay-now' === $this->context() ? $this->get_order_pay_id() : 0, 'single_product_buttons_enabled' => $this->settings_status->is_smart_button_enabled_for_location( 'product' ), 'mini_cart_buttons_enabled' => $this->settings_status->is_smart_button_enabled_for_location( 'mini-cart' ), 'basic_checkout_validation_enabled' => $this->basic_checkout_validation_enabled, 'early_checkout_validation_enabled' => $this->early_validation_enabled, + 'funding_sources_without_redirect' => $this->funding_sources_without_redirect, ); if ( $this->style_for_context( 'layout', 'mini-cart' ) !== 'horizontal' ) { @@ -1000,13 +1016,26 @@ class SmartButton implements SmartButtonInterface { ); if ( $this->environment->current_environment_is( Environment::SANDBOX ) - && defined( 'WP_DEBUG' ) && \WP_DEBUG && is_user_logged_in() + && defined( 'WP_DEBUG' ) && \WP_DEBUG && WC()->customer instanceof \WC_Customer && WC()->customer->get_billing_country() && 2 === strlen( WC()->customer->get_billing_country() ) ) { $params['buyer-country'] = WC()->customer->get_billing_country(); } + if ( 'pay-now' === $this->context() ) { + $wc_order_id = $this->get_order_pay_id(); + if ( $wc_order_id ) { + $wc_order = wc_get_order( $wc_order_id ); + if ( $wc_order instanceof WC_Order ) { + $currency = $wc_order->get_currency(); + if ( $currency ) { + $params['currency'] = $currency; + } + } + } + } + $disable_funding = $this->settings->has( 'disable_funding' ) ? $this->settings->get( 'disable_funding' ) : array(); @@ -1334,6 +1363,51 @@ class SmartButton implements SmartButtonInterface { ); } + /** + * Checks if PayPal buttons/messages should be rendered for the current page. + * + * @param string|null $context The context that should be checked, use default otherwise. + * + * @return bool + */ + protected function is_button_disabled( string $context = null ): bool { + if ( null === $context ) { + $context = $this->context(); + } + + if ( 'product' === $context ) { + $product = wc_get_product(); + + /** + * Allows to decide if the button should be disabled for a given product + */ + $is_disabled = apply_filters( + 'woocommerce_paypal_payments_product_buttons_disabled', + null, + $product + ); + + if ( $is_disabled !== null ) { + return $is_disabled; + } + } + + /** + * Allows to decide if the button should be disabled globally or on a given context + */ + $is_disabled = apply_filters( + 'woocommerce_paypal_payments_buttons_disabled', + null, + $context + ); + + if ( $is_disabled !== null ) { + return $is_disabled; + } + + return false; + } + /** * Retrieves all payment tokens for the user, via API or cached if already queried. * @@ -1433,4 +1507,19 @@ class SmartButton implements SmartButtonInterface { return $this->context() === 'product' ? $product_intent : $other_context_intent; } + + /** + * Returns the ID of WC order on the order-pay page, or 0. + * + * @return int + */ + protected function get_order_pay_id(): int { + global $wp; + + if ( ! isset( $wp->query_vars['order-pay'] ) ) { + return 0; + } + + return absint( $wp->query_vars['order-pay'] ); + } } diff --git a/modules/ppcp-button/src/Endpoint/CreateOrderEndpoint.php b/modules/ppcp-button/src/Endpoint/CreateOrderEndpoint.php index 83d5f89b1..686b87dd8 100644 --- a/modules/ppcp-button/src/Endpoint/CreateOrderEndpoint.php +++ b/modules/ppcp-button/src/Endpoint/CreateOrderEndpoint.php @@ -152,6 +152,13 @@ class CreateOrderEndpoint implements EndpointInterface { */ private $handle_shipping_in_paypal; + /** + * The sources that do not cause issues about redirecting (on mobile, ...) and sometimes not returning back. + * + * @var string[] + */ + private $funding_sources_without_redirect; + /** * The logger. * @@ -175,6 +182,7 @@ class CreateOrderEndpoint implements EndpointInterface { * @param bool $early_validation_enabled Whether to execute WC validation of the checkout form. * @param string[] $pay_now_contexts The contexts that should have the Pay Now button. * @param bool $handle_shipping_in_paypal If true, the shipping methods are sent to PayPal allowing the customer to select it inside the popup. + * @param string[] $funding_sources_without_redirect The sources that do not cause issues about redirecting (on mobile, ...) and sometimes not returning back. * @param LoggerInterface $logger The logger. */ public function __construct( @@ -191,23 +199,25 @@ class CreateOrderEndpoint implements EndpointInterface { bool $early_validation_enabled, array $pay_now_contexts, bool $handle_shipping_in_paypal, + array $funding_sources_without_redirect, LoggerInterface $logger ) { - $this->request_data = $request_data; - $this->purchase_unit_factory = $purchase_unit_factory; - $this->shipping_preference_factory = $shipping_preference_factory; - $this->api_endpoint = $order_endpoint; - $this->payer_factory = $payer_factory; - $this->session_handler = $session_handler; - $this->settings = $settings; - $this->early_order_handler = $early_order_handler; - $this->registration_needed = $registration_needed; - $this->card_billing_data_mode = $card_billing_data_mode; - $this->early_validation_enabled = $early_validation_enabled; - $this->pay_now_contexts = $pay_now_contexts; - $this->handle_shipping_in_paypal = $handle_shipping_in_paypal; - $this->logger = $logger; + $this->request_data = $request_data; + $this->purchase_unit_factory = $purchase_unit_factory; + $this->shipping_preference_factory = $shipping_preference_factory; + $this->api_endpoint = $order_endpoint; + $this->payer_factory = $payer_factory; + $this->session_handler = $session_handler; + $this->settings = $settings; + $this->early_order_handler = $early_order_handler; + $this->registration_needed = $registration_needed; + $this->card_billing_data_mode = $card_billing_data_mode; + $this->early_validation_enabled = $early_validation_enabled; + $this->pay_now_contexts = $pay_now_contexts; + $this->handle_shipping_in_paypal = $handle_shipping_in_paypal; + $this->funding_sources_without_redirect = $funding_sources_without_redirect; + $this->logger = $logger; } /** @@ -288,6 +298,11 @@ class CreateOrderEndpoint implements EndpointInterface { } if ( 'checkout' === $data['context'] ) { + if ( $payment_method === PayPalGateway::ID && ! in_array( $funding_source, $this->funding_sources_without_redirect, true ) ) { + $this->session_handler->replace_order( $order ); + $this->session_handler->replace_funding_source( $funding_source ); + } + if ( ! $this->early_order_handler->should_create_early_order() || $this->registration_needed diff --git a/modules/ppcp-button/src/Helper/CheckoutFormSaver.php b/modules/ppcp-button/src/Helper/CheckoutFormSaver.php index 73f8eeefc..c2f10ca28 100644 --- a/modules/ppcp-button/src/Helper/CheckoutFormSaver.php +++ b/modules/ppcp-button/src/Helper/CheckoutFormSaver.php @@ -10,11 +10,30 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\Button\Helper; use WC_Checkout; +use WooCommerce\PayPalCommerce\Session\SessionHandler; /** * Class CheckoutFormSaver */ class CheckoutFormSaver extends WC_Checkout { + /** + * The Session handler. + * + * @var SessionHandler + */ + private $session_handler; + + /** + * CheckoutFormSaver constructor. + * + * @param SessionHandler $session_handler The session handler. + */ + public function __construct( + SessionHandler $session_handler + ) { + $this->session_handler = $session_handler; + } + /** * Saves the form data to the WC customer and session. * @@ -28,5 +47,7 @@ class CheckoutFormSaver extends WC_Checkout { $data = $this->get_posted_data(); $this->update_session( $data ); + + $this->session_handler->replace_checkout_form( $data ); } } diff --git a/modules/ppcp-button/src/Helper/ContextTrait.php b/modules/ppcp-button/src/Helper/ContextTrait.php index daddbb0a9..77de2a078 100644 --- a/modules/ppcp-button/src/Helper/ContextTrait.php +++ b/modules/ppcp-button/src/Helper/ContextTrait.php @@ -9,6 +9,8 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\Button\Helper; +use WooCommerce\PayPalCommerce\ApiClient\Entity\OrderStatus; + trait ContextTrait { /** @@ -18,6 +20,13 @@ trait ContextTrait { */ protected function context(): string { if ( is_product() || wc_post_content_has_shortcode( 'product_page' ) ) { + + // Do this check here instead of reordering outside conditions. + // In order to have more control over the context. + if ( ( is_checkout() ) && ! $this->is_paypal_continuation() ) { + return 'checkout'; + } + return 'product'; } @@ -56,6 +65,12 @@ trait ContextTrait { return false; } + if ( ! $order->status()->is( OrderStatus::APPROVED ) + && ! $order->status()->is( OrderStatus::COMPLETED ) + ) { + return false; + } + $source = $order->payment_source(); if ( $source && $source->card() ) { return false; // Ignore for DCC. diff --git a/modules/ppcp-button/src/Helper/EarlyOrderHandler.php b/modules/ppcp-button/src/Helper/EarlyOrderHandler.php index 3776a6698..4ac8583d0 100644 --- a/modules/ppcp-button/src/Helper/EarlyOrderHandler.php +++ b/modules/ppcp-button/src/Helper/EarlyOrderHandler.php @@ -15,15 +15,12 @@ use WooCommerce\PayPalCommerce\Onboarding\State; use WooCommerce\PayPalCommerce\Session\SessionHandler; use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway; use WooCommerce\PayPalCommerce\WcGateway\Processor\OrderProcessor; -use WooCommerce\PayPalCommerce\Webhooks\Handler\PrefixTrait; /** * Class EarlyOrderHandler */ class EarlyOrderHandler { - use PrefixTrait; - /** * The State. * @@ -51,19 +48,16 @@ class EarlyOrderHandler { * @param State $state The State. * @param OrderProcessor $order_processor The Order Processor. * @param SessionHandler $session_handler The Session Handler. - * @param string $prefix The Prefix. */ public function __construct( State $state, OrderProcessor $order_processor, - SessionHandler $session_handler, - string $prefix + SessionHandler $session_handler ) { $this->state = $state; $this->order_processor = $order_processor; $this->session_handler = $session_handler; - $this->prefix = $prefix; } /** @@ -101,7 +95,7 @@ class EarlyOrderHandler { $order_id = false; foreach ( $order->purchase_units() as $purchase_unit ) { if ( $purchase_unit->custom_id() === sanitize_text_field( wp_unslash( $_REQUEST['ppcp-resume-order'] ) ) ) { - $order_id = (int) $this->sanitize_custom_id( $purchase_unit->custom_id() ); + $order_id = (int) $purchase_unit->custom_id(); } } if ( $order_id === $resume_order_id ) { diff --git a/modules/ppcp-compat/src/CompatModule.php b/modules/ppcp-compat/src/CompatModule.php index 059425be7..979056835 100644 --- a/modules/ppcp-compat/src/CompatModule.php +++ b/modules/ppcp-compat/src/CompatModule.php @@ -339,14 +339,6 @@ class CompatModule implements ModuleInterface { }, 5 ); - - add_filter( - 'woocommerce_paypal_payments_checkout_button_renderer_hook', - function(): string { - return 'woocommerce_review_order_after_submit'; - }, - 5 - ); } } diff --git a/modules/ppcp-session/src/Cancellation/CancelController.php b/modules/ppcp-session/src/Cancellation/CancelController.php index 79dbd4dcf..a4723cd96 100644 --- a/modules/ppcp-session/src/Cancellation/CancelController.php +++ b/modules/ppcp-session/src/Cancellation/CancelController.php @@ -9,12 +9,14 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\Session\Cancellation; +use WooCommerce\PayPalCommerce\Button\Helper\ContextTrait; use WooCommerce\PayPalCommerce\Session\SessionHandler; /** * Class CancelController */ class CancelController { + use ContextTrait; public const NONCE = 'ppcp-cancel'; @@ -50,7 +52,7 @@ class CancelController { /** * Runs the controller. */ - public function run() { + public function run(): void { $param_name = self::NONCE; if ( isset( $_GET[ $param_name ] ) && // Input var ok. wp_verify_nonce( @@ -61,20 +63,10 @@ class CancelController { $this->session_handler->destroy_session_data(); } - $order = $this->session_handler->order(); - if ( ! $order ) { + if ( ! $this->is_paypal_continuation() ) { return; } - $source = $order->payment_source(); - if ( $source && $source->card() ) { - return; // Ignore for DCC. - } - - if ( 'card' === $this->session_handler->funding_source() ) { - return; // Ignore for card buttons. - } - $url = add_query_arg( array( $param_name => wp_create_nonce( self::NONCE ) ), wc_get_checkout_url() ); add_action( 'woocommerce_review_order_after_submit', diff --git a/modules/ppcp-session/src/MemoryWcSession.php b/modules/ppcp-session/src/MemoryWcSession.php new file mode 100644 index 000000000..583aa5a9d --- /dev/null +++ b/modules/ppcp-session/src/MemoryWcSession.php @@ -0,0 +1,75 @@ +session->get_session). + * + * @var array + */ + private static $data; + + /** + * The customer ID. + * + * @var string|int + */ + private static $customer_id; + + /** + * Enqueues this session handler with the given data to be used by WC. + * + * @param array $session_data The session data (from WC()->session->get_session). + * @param int|string $customer_id The customer ID. + */ + public static function replace_session_handler( array $session_data, $customer_id ): void { + self::$data = $session_data; + self::$customer_id = $customer_id; + + add_filter( + 'woocommerce_session_handler', + function () { + return MemoryWcSession::class; + } + ); + } + + /** + * @inerhitDoc + */ + public function init_session_cookie() { + $this->_customer_id = self::$customer_id; + $this->_data = self::$data; + } + + /** + * @inerhitDoc + */ + public function get_session_data() { + return self::$data; + } + + /** + * @inerhitDoc + */ + public function forget_session() { + self::$data = array(); + + parent::forget_session(); + } +} diff --git a/modules/ppcp-session/src/SessionHandler.php b/modules/ppcp-session/src/SessionHandler.php index 7bceb34a6..d85c40b65 100644 --- a/modules/ppcp-session/src/SessionHandler.php +++ b/modules/ppcp-session/src/SessionHandler.php @@ -47,6 +47,13 @@ class SessionHandler { */ private $funding_source = null; + /** + * The checkout form data. + * + * @var array + */ + private $checkout_form = array(); + /** * Returns the order. * @@ -55,6 +62,8 @@ class SessionHandler { public function order() { $this->load_session(); + do_action( 'ppcp_session_get_order', $this->order, $this ); + return $this->order; } @@ -71,6 +80,30 @@ class SessionHandler { $this->store_session(); } + /** + * Returns the checkout form data. + * + * @return array + */ + public function checkout_form(): array { + $this->load_session(); + + return $this->checkout_form; + } + + /** + * Replaces the checkout form data. + * + * @param array $checkout_form The checkout form data. + */ + public function replace_checkout_form( array $checkout_form ): void { + $this->load_session(); + + $this->checkout_form = $checkout_form; + + $this->store_session(); + } + /** * Returns the BN Code. * @@ -151,6 +184,7 @@ class SessionHandler { $this->bn_code = ''; $this->insufficient_funding_tries = 0; $this->funding_source = null; + $this->checkout_form = array(); $this->store_session(); return $this; } @@ -188,6 +222,7 @@ class SessionHandler { if ( ! is_string( $this->funding_source ) ) { $this->funding_source = null; } + $this->checkout_form = $data['checkout_form'] ?? array(); } /** @@ -202,6 +237,7 @@ class SessionHandler { 'bn_code' => $obj->bn_code, 'insufficient_funding_tries' => $obj->insufficient_funding_tries, 'funding_source' => $obj->funding_source, + 'checkout_form' => $obj->checkout_form, ); } } diff --git a/modules/ppcp-session/src/SessionModule.php b/modules/ppcp-session/src/SessionModule.php index a27effb47..cecc1a98f 100644 --- a/modules/ppcp-session/src/SessionModule.php +++ b/modules/ppcp-session/src/SessionModule.php @@ -9,6 +9,11 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\Session; +use Psr\Log\LoggerInterface; +use Throwable; +use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint; +use WooCommerce\PayPalCommerce\ApiClient\Entity\Order; +use WooCommerce\PayPalCommerce\ApiClient\Entity\OrderStatus; use WooCommerce\PayPalCommerce\Vendor\Dhii\Container\ServiceProvider; use WooCommerce\PayPalCommerce\Vendor\Dhii\Modular\Module\ModuleInterface; use WooCommerce\PayPalCommerce\Session\Cancellation\CancelController; @@ -19,6 +24,12 @@ use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; * Class SessionModule */ class SessionModule implements ModuleInterface { + /** + * A flag to avoid multiple requests to reload order. + * + * @var bool + */ + private $reloaded_order = false; /** * {@inheritDoc} @@ -46,6 +57,45 @@ class SessionModule implements ModuleInterface { $controller->run(); } ); + + add_action( + 'ppcp_session_get_order', + function ( ?Order $order, SessionHandler $session_handler ) use ( $c ): void { + if ( ! isset( WC()->session ) ) { + return; + } + + if ( $this->reloaded_order ) { + return; + } + + if ( ! $order ) { + return; + } + + if ( $order->status()->is( OrderStatus::APPROVED ) + || $order->status()->is( OrderStatus::COMPLETED ) + ) { + return; + } + + $order_endpoint = $c->get( 'api.endpoint.order' ); + assert( $order_endpoint instanceof OrderEndpoint ); + + $this->reloaded_order = true; + + try { + $session_handler->replace_order( $order_endpoint->order( $order->id() ) ); + } catch ( Throwable $exception ) { + $logger = $c->get( 'woocommerce.logger.woocommerce' ); + assert( $logger instanceof LoggerInterface ); + + $logger->warning( 'Failed to reload PayPal order in the session: ' . $exception->getMessage() ); + } + }, + 10, + 2 + ); } /** diff --git a/modules/ppcp-status-report/src/StatusReportModule.php b/modules/ppcp-status-report/src/StatusReportModule.php index abf84b83b..9e2df9599 100644 --- a/modules/ppcp-status-report/src/StatusReportModule.php +++ b/modules/ppcp-status-report/src/StatusReportModule.php @@ -120,11 +120,19 @@ class StatusReportModule implements ModuleInterface { 'value' => $this->bool_to_html( ! $last_webhook_storage->is_empty() ), ), array( - 'label' => esc_html__( 'Vault enabled', 'woocommerce-paypal-payments' ), - 'exported_label' => 'Vault enabled', - 'description' => esc_html__( 'Whether vaulting is enabled on PayPal account or not.', 'woocommerce-paypal-payments' ), + 'label' => esc_html__( 'PayPal Vault enabled', 'woocommerce-paypal-payments' ), + 'exported_label' => 'PayPal Vault enabled', + 'description' => esc_html__( 'Whether vaulting option is enabled on Standard Payments settings or not.', 'woocommerce-paypal-payments' ), 'value' => $this->bool_to_html( - $this->vault_enabled( $bearer ) + $settings->has( 'vault_enabled' ) && $settings->get( 'vault_enabled' ) + ), + ), + array( + 'label' => esc_html__( 'ACDC Vault enabled', 'woocommerce-paypal-payments' ), + 'exported_label' => 'ACDC Vault enabled', + 'description' => esc_html__( 'Whether vaulting option is enabled on Advanced Card Processing settings or not.', 'woocommerce-paypal-payments' ), + 'value' => $this->bool_to_html( + $settings->has( 'vault_enabled_dcc' ) && $settings->get( 'vault_enabled_dcc' ) ), ), array( @@ -192,21 +200,6 @@ class StatusReportModule implements ModuleInterface { return $token->is_valid() && $current_state === $state::STATE_ONBOARDED; } - /** - * It returns whether vaulting is enabled or not. - * - * @param Bearer $bearer The bearer. - * @return bool - */ - private function vault_enabled( Bearer $bearer ): bool { - try { - $token = $bearer->bearer(); - return $token->vaulting_available(); - } catch ( RuntimeException $exception ) { - return false; - } - } - /** * Checks if reference transactions are enabled in account. * diff --git a/modules/ppcp-subscription/.gitignore b/modules/ppcp-subscription/.gitignore new file mode 100644 index 000000000..265c2208c --- /dev/null +++ b/modules/ppcp-subscription/.gitignore @@ -0,0 +1,2 @@ +node_modules +assets diff --git a/modules/ppcp-uninstall/services.php b/modules/ppcp-uninstall/services.php index 054a0d9dc..6f7cda722 100644 --- a/modules/ppcp-uninstall/services.php +++ b/modules/ppcp-uninstall/services.php @@ -33,6 +33,7 @@ return array( 'woocommerce-ppcp-version', WebhookSimulation::OPTION_ID, WebhookRegistrar::KEY, + 'ppcp_payment_tokens_migration_initialized', ); }, @@ -40,6 +41,7 @@ return array( return array( 'woocommerce_paypal_payments_check_pui_payment_captured', 'woocommerce_paypal_payments_check_saved_payment', + 'woocommerce_paypal_payments_payment_tokens_migration', ); }, diff --git a/modules/ppcp-vaulting/src/VaultingModule.php b/modules/ppcp-vaulting/src/VaultingModule.php index 824f206e8..5b7251abb 100644 --- a/modules/ppcp-vaulting/src/VaultingModule.php +++ b/modules/ppcp-vaulting/src/VaultingModule.php @@ -178,6 +178,9 @@ class VaultingModule implements ModuleInterface { } ); + /** + * Allows running migration externally via `do_action('pcp_migrate_payment_tokens')`. + */ add_action( 'pcp_migrate_payment_tokens', function() use ( $container ) { @@ -218,6 +221,10 @@ class VaultingModule implements ModuleInterface { * @return void */ public function migrate_payment_tokens( LoggerInterface $logger ): void { + $initialized = get_option( 'ppcp_payment_tokens_migration_initialized', null ); + if ( $initialized ) { + return; + } // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_query $customers = new WP_User_Query( @@ -235,12 +242,19 @@ class VaultingModule implements ModuleInterface { return; } - $logger->info( 'Starting payment tokens migration for ' . (string) count( $customers ) . ' users' ); + $logger->info( 'Identified ' . (string) count( $customers ) . ' users with payment tokens. Initiating token migration.' ); + update_option( 'ppcp_payment_tokens_migration_initialized', true ); $interval_in_seconds = 5; $timestamp = time(); foreach ( $customers as $id ) { + $tokens = array_filter( get_user_meta( $id, 'ppcp-vault-token' ) ); + $skip_empty_key_migration = apply_filters( 'ppcp_skip_payment_tokens_empty_key_migration', true ); + if ( empty( $tokens ) && $skip_empty_key_migration ) { + continue; + } + /** * Function already exist in WooCommerce * diff --git a/modules/ppcp-wc-gateway/services.php b/modules/ppcp-wc-gateway/services.php index 901c1f94a..478cc35e0 100644 --- a/modules/ppcp-wc-gateway/services.php +++ b/modules/ppcp-wc-gateway/services.php @@ -57,6 +57,7 @@ use WooCommerce\PayPalCommerce\WcGateway\Helper\SettingsStatus; use WooCommerce\PayPalCommerce\WcGateway\Notice\AuthorizeOrderActionNotice; use WooCommerce\PayPalCommerce\WcGateway\Notice\ConnectAdminNotice; use WooCommerce\PayPalCommerce\WcGateway\Notice\GatewayWithoutPayPalAdminNotice; +use WooCommerce\PayPalCommerce\WcGateway\Notice\UnsupportedCurrencyAdminNotice; use WooCommerce\PayPalCommerce\WcGateway\Processor\AuthorizedPaymentsProcessor; use WooCommerce\PayPalCommerce\WcGateway\Processor\OrderProcessor; use WooCommerce\PayPalCommerce\WcGateway\Processor\RefundProcessor; @@ -208,6 +209,12 @@ return array( $settings = $container->get( 'wcgateway.settings' ); return new ConnectAdminNotice( $state, $settings ); }, + 'wcgateway.notice.currency-unsupported' => static function ( ContainerInterface $container ): UnsupportedCurrencyAdminNotice { + $state = $container->get( 'onboarding.state' ); + $shop_currency = $container->get( 'api.shop.currency' ); + $supported_currencies = $container->get( 'api.supported-currencies' ); + return new UnsupportedCurrencyAdminNotice( $state, $shop_currency, $supported_currencies ); + }, 'wcgateway.notice.dcc-without-paypal' => static function ( ContainerInterface $container ): GatewayWithoutPayPalAdminNotice { return new GatewayWithoutPayPalAdminNotice( CreditCardGateway::ID, @@ -223,7 +230,8 @@ return array( $container->get( 'onboarding.state' ), $container->get( 'wcgateway.settings' ), $container->get( 'wcgateway.is-wc-payments-page' ), - $container->get( 'wcgateway.is-ppcp-settings-page' ) + $container->get( 'wcgateway.is-ppcp-settings-page' ), + $container->get( 'wcgateway.settings.status' ) ); }, 'wcgateway.notice.authorize-order-action' => @@ -903,6 +911,12 @@ return array( 'paylater' => _x( 'Pay Later', 'Name of payment method', 'woocommerce-paypal-payments' ), ); }, + /** + * The sources that do not cause issues about redirecting (on mobile, ...) and sometimes not returning back. + */ + 'wcgateway.funding-sources-without-redirect' => static function( ContainerInterface $container ): array { + return array( 'paypal', 'paylater', 'venmo', 'card' ); + }, 'wcgateway.settings.funding-sources' => static function( ContainerInterface $container ): array { return array_diff_key( $container->get( 'wcgateway.all-funding-sources' ), @@ -938,11 +952,11 @@ return array( 'wcgateway.endpoint.return-url' => static function ( ContainerInterface $container ) : ReturnUrlEndpoint { $gateway = $container->get( 'wcgateway.paypal-gateway' ); $endpoint = $container->get( 'api.endpoint.order' ); - $prefix = $container->get( 'api.prefix' ); return new ReturnUrlEndpoint( $gateway, $endpoint, - $prefix + $container->get( 'session.handler' ), + $container->get( 'woocommerce.logger.woocommerce' ) ); }, diff --git a/modules/ppcp-wc-gateway/src/Checkout/DisableGateways.php b/modules/ppcp-wc-gateway/src/Checkout/DisableGateways.php index bbd9b30d3..08e263541 100644 --- a/modules/ppcp-wc-gateway/src/Checkout/DisableGateways.php +++ b/modules/ppcp-wc-gateway/src/Checkout/DisableGateways.php @@ -9,6 +9,7 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\WcGateway\Checkout; +use WooCommerce\PayPalCommerce\Button\Helper\ContextTrait; use WooCommerce\PayPalCommerce\Session\SessionHandler; use WooCommerce\PayPalCommerce\WcGateway\Gateway\CardButtonGateway; use WooCommerce\PayPalCommerce\WcGateway\Gateway\CreditCardGateway; @@ -20,6 +21,7 @@ use WooCommerce\PayPalCommerce\WcGateway\Helper\SettingsStatus; * Class DisableGateways */ class DisableGateways { + use ContextTrait; /** * The Session Handler. @@ -82,8 +84,11 @@ class DisableGateways { unset( $methods[ CreditCardGateway::ID ] ); } - if ( ! $this->settings_status->is_smart_button_enabled_for_location( 'checkout' ) && ! $this->session_handler->order() && is_checkout() ) { - unset( $methods[ PayPalGateway::ID ] ); + if ( ! $this->settings_status->is_smart_button_enabled_for_location( 'checkout' ) ) { + unset( $methods[ CardButtonGateway::ID ] ); + if ( ! $this->session_handler->order() && is_checkout() ) { + unset( $methods[ PayPalGateway::ID ] ); + } } if ( ! $this->needs_to_disable_gateways() ) { @@ -124,20 +129,6 @@ class DisableGateways { * @return bool */ private function needs_to_disable_gateways(): bool { - $order = $this->session_handler->order(); - if ( ! $order ) { - return false; - } - - $source = $order->payment_source(); - if ( $source && $source->card() ) { - return false; // DCC. - } - - if ( 'card' === $this->session_handler->funding_source() ) { - return false; // Card buttons. - } - - return true; + return $this->is_paypal_continuation(); } } diff --git a/modules/ppcp-wc-gateway/src/Endpoint/ReturnUrlEndpoint.php b/modules/ppcp-wc-gateway/src/Endpoint/ReturnUrlEndpoint.php index 57c29c2a4..3efe16542 100644 --- a/modules/ppcp-wc-gateway/src/Endpoint/ReturnUrlEndpoint.php +++ b/modules/ppcp-wc-gateway/src/Endpoint/ReturnUrlEndpoint.php @@ -9,17 +9,18 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\WcGateway\Endpoint; +use Psr\Log\LoggerInterface; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint; +use WooCommerce\PayPalCommerce\ApiClient\Entity\OrderStatus; +use WooCommerce\PayPalCommerce\Session\SessionHandler; use WooCommerce\PayPalCommerce\WcGateway\Gateway\OXXO\OXXOGateway; use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway; -use WooCommerce\PayPalCommerce\Webhooks\Handler\PrefixTrait; /** * Class ReturnUrlEndpoint */ class ReturnUrlEndpoint { - use PrefixTrait; const ENDPOINT = 'ppc-return-url'; /** @@ -36,17 +37,38 @@ class ReturnUrlEndpoint { */ private $order_endpoint; + /** + * The session handler + * + * @var SessionHandler + */ + protected $session_handler; + + /** + * The logger. + * + * @var LoggerInterface + */ + protected $logger; + /** * ReturnUrlEndpoint constructor. * - * @param PayPalGateway $gateway The PayPal Gateway. - * @param OrderEndpoint $order_endpoint The Order Endpoint. - * @param string $prefix The prefix. + * @param PayPalGateway $gateway The PayPal Gateway. + * @param OrderEndpoint $order_endpoint The Order Endpoint. + * @param SessionHandler $session_handler The session handler. + * @param LoggerInterface $logger The logger. */ - public function __construct( PayPalGateway $gateway, OrderEndpoint $order_endpoint, string $prefix ) { - $this->gateway = $gateway; - $this->order_endpoint = $order_endpoint; - $this->prefix = $prefix; + public function __construct( + PayPalGateway $gateway, + OrderEndpoint $order_endpoint, + SessionHandler $session_handler, + LoggerInterface $logger + ) { + $this->gateway = $gateway; + $this->order_endpoint = $order_endpoint; + $this->session_handler = $session_handler; + $this->logger = $logger; } /** @@ -63,13 +85,25 @@ class ReturnUrlEndpoint { // phpcs:enable WordPress.Security.NonceVerification.Recommended $order = $this->order_endpoint->order( $token ); - $wc_order_id = $this->sanitize_custom_id( $order->purchase_units()[0]->custom_id() ); + $wc_order_id = (int) $order->purchase_units()[0]->custom_id(); if ( ! $wc_order_id ) { + // We cannot finish processing here without WC order, but at least go into the continuation mode. + if ( $order->status()->is( OrderStatus::APPROVED ) + || $order->status()->is( OrderStatus::COMPLETED ) + ) { + $this->session_handler->replace_order( $order ); + + wp_safe_redirect( wc_get_checkout_url() ); + exit(); + } + + $this->logger->warning( "Return URL endpoint $token: no WC order ID." ); exit(); } $wc_order = wc_get_order( $wc_order_id ); if ( ! is_a( $wc_order, \WC_Order::class ) ) { + $this->logger->warning( "Return URL endpoint $token: WC order $wc_order_id not found." ); exit(); } diff --git a/modules/ppcp-wc-gateway/src/Gateway/PayPalGateway.php b/modules/ppcp-wc-gateway/src/Gateway/PayPalGateway.php index 0a5835c31..c73d78a42 100644 --- a/modules/ppcp-wc-gateway/src/Gateway/PayPalGateway.php +++ b/modules/ppcp-wc-gateway/src/Gateway/PayPalGateway.php @@ -13,6 +13,7 @@ use Exception; use Psr\Log\LoggerInterface; use WC_Order; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint; +use WooCommerce\PayPalCommerce\ApiClient\Entity\OrderStatus; use WooCommerce\PayPalCommerce\ApiClient\Entity\PaymentToken; use WooCommerce\PayPalCommerce\ApiClient\Exception\PayPalApiException; use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException; @@ -253,8 +254,13 @@ class PayPalGateway extends \WC_Payment_Gateway { $funding_source = $this->session_handler->funding_source(); if ( $funding_source ) { - $this->title = $this->funding_source_renderer->render_name( $funding_source ); - $this->description = $this->funding_source_renderer->render_description( $funding_source ); + $order = $this->session_handler->order(); + if ( $order && + ( $order->status()->is( OrderStatus::APPROVED ) || $order->status()->is( OrderStatus::COMPLETED ) ) + ) { + $this->title = $this->funding_source_renderer->render_name( $funding_source ); + $this->description = $this->funding_source_renderer->render_description( $funding_source ); + } } $this->init_form_fields(); @@ -271,6 +277,27 @@ class PayPalGateway extends \WC_Payment_Gateway { $this->order_endpoint = $order_endpoint; } + /** + * Return the gateway's title. + * + * @return string + */ + public function get_title() { + if ( is_admin() ) { + // $theorder and other things for retrieving the order or post info are not available + // in the constructor, so must do it here. + global $theorder; + if ( $theorder instanceof WC_Order ) { + $payment_method_title = $theorder->get_payment_method_title(); + if ( $payment_method_title ) { + $this->title = $payment_method_title; + } + } + } + + return parent::get_title(); + } + /** * Whether the Gateway needs to be setup. * @@ -533,12 +560,15 @@ class PayPalGateway extends \WC_Payment_Gateway { 'INSTRUMENT_DECLINED' => __( 'Instrument declined.', 'woocommerce-paypal-payments' ), 'PAYER_ACTION_REQUIRED' => __( 'Payer action required, possibly overcharge.', 'woocommerce-paypal-payments' ), ); - $retry_errors = array_filter( - array_keys( $retry_keys_messages ), - function ( string $key ) use ( $error ): bool { - return $error->has_detail( $key ); - } + $retry_errors = array_values( + array_filter( + array_keys( $retry_keys_messages ), + function ( string $key ) use ( $error ): bool { + return $error->has_detail( $key ); + } + ) ); + if ( $retry_errors ) { $retry_error_key = $retry_errors[0]; diff --git a/modules/ppcp-wc-gateway/src/Notice/GatewayWithoutPayPalAdminNotice.php b/modules/ppcp-wc-gateway/src/Notice/GatewayWithoutPayPalAdminNotice.php index 08a20651b..81b057423 100644 --- a/modules/ppcp-wc-gateway/src/Notice/GatewayWithoutPayPalAdminNotice.php +++ b/modules/ppcp-wc-gateway/src/Notice/GatewayWithoutPayPalAdminNotice.php @@ -13,11 +13,16 @@ use WC_Payment_Gateway; use WooCommerce\PayPalCommerce\AdminNotices\Entity\Message; use WooCommerce\PayPalCommerce\Onboarding\State; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; +use WooCommerce\PayPalCommerce\WcGateway\Helper\SettingsStatus; /** * Creates the admin message about the gateway being enabled without the PayPal gateway. */ class GatewayWithoutPayPalAdminNotice { + private const NOTICE_OK = ''; + private const NOTICE_DISABLED_GATEWAY = 'disabled_gateway'; + private const NOTICE_DISABLED_LOCATION = 'disabled_location'; + /** * The gateway ID. * @@ -53,27 +58,37 @@ class GatewayWithoutPayPalAdminNotice { */ private $is_ppcp_settings_page; + /** + * The Settings status helper. + * + * @var SettingsStatus|null + */ + protected $settings_status; + /** * ConnectAdminNotice constructor. * - * @param string $id The gateway ID. - * @param State $state The state. - * @param ContainerInterface $settings The settings. - * @param bool $is_payments_page Whether the current page is the WC payment page. - * @param bool $is_ppcp_settings_page Whether the current page is the PPCP settings page. + * @param string $id The gateway ID. + * @param State $state The state. + * @param ContainerInterface $settings The settings. + * @param bool $is_payments_page Whether the current page is the WC payment page. + * @param bool $is_ppcp_settings_page Whether the current page is the PPCP settings page. + * @param SettingsStatus|null $settings_status The Settings status helper. */ public function __construct( string $id, State $state, ContainerInterface $settings, bool $is_payments_page, - bool $is_ppcp_settings_page + bool $is_ppcp_settings_page, + ?SettingsStatus $settings_status = null ) { $this->id = $id; $this->state = $state; $this->settings = $settings; $this->is_payments_page = $is_payments_page; $this->is_ppcp_settings_page = $is_ppcp_settings_page; + $this->settings_status = $settings_status; } /** @@ -82,8 +97,25 @@ class GatewayWithoutPayPalAdminNotice { * @return Message|null */ public function message(): ?Message { - if ( ! $this->should_display() ) { - return null; + $notice_type = $this->check(); + + switch ( $notice_type ) { + case self::NOTICE_DISABLED_GATEWAY: + /* translators: %1$s the gateway name, %2$s URL. */ + $text = __( + '%1$s cannot be used without the PayPal gateway. Enable the PayPal gateway.', + 'woocommerce-paypal-payments' + ); + break; + case self::NOTICE_DISABLED_LOCATION: + /* translators: %1$s the gateway name, %2$s URL. */ + $text = __( + '%1$s cannot be used without enabling the Checkout location for the PayPal gateway. Enable the Checkout location.', + 'woocommerce-paypal-payments' + ); + break; + default: + return null; } $gateway = $this->get_gateway(); @@ -94,11 +126,7 @@ class GatewayWithoutPayPalAdminNotice { $name = $gateway->get_method_title(); $message = sprintf( - /* translators: %1$s the gateway name, %2$s URL. */ - __( - '%1$s cannot be used without the PayPal gateway. Enable the PayPal gateway.', - 'woocommerce-paypal-payments' - ), + $text, $name, admin_url( 'admin.php?page=wc-settings&tab=checkout§ion=ppcp-gateway' ) ); @@ -106,22 +134,33 @@ class GatewayWithoutPayPalAdminNotice { } /** - * Whether the message should be displayed. + * Checks whether one of the messages should be displayed. * - * @return bool + * @return string One of the NOTICE_* constants. */ - protected function should_display(): bool { + protected function check(): string { if ( State::STATE_ONBOARDED !== $this->state->current_state() || ( ! $this->is_payments_page && ! $this->is_ppcp_settings_page ) ) { - return false; - } - if ( $this->settings->has( 'enabled' ) && $this->settings->get( 'enabled' ) ) { - return false; + return self::NOTICE_OK; } - $gateway = $this->get_gateway(); + $gateway = $this->get_gateway(); + $gateway_enabled = $gateway && wc_string_to_bool( $gateway->get_option( 'enabled' ) ); - return $gateway && wc_string_to_bool( $gateway->get_option( 'enabled' ) ); + if ( ! $gateway_enabled ) { + return self::NOTICE_OK; + } + + $paypal_enabled = $this->settings->has( 'enabled' ) && $this->settings->get( 'enabled' ); + if ( ! $paypal_enabled ) { + return self::NOTICE_DISABLED_GATEWAY; + } + + if ( $this->settings_status && ! $this->settings_status->is_smart_button_enabled_for_location( 'checkout' ) ) { + return self::NOTICE_DISABLED_LOCATION; + } + + return self::NOTICE_OK; } /** diff --git a/modules/ppcp-wc-gateway/src/Notice/UnsupportedCurrencyAdminNotice.php b/modules/ppcp-wc-gateway/src/Notice/UnsupportedCurrencyAdminNotice.php new file mode 100644 index 000000000..27ef14f79 --- /dev/null +++ b/modules/ppcp-wc-gateway/src/Notice/UnsupportedCurrencyAdminNotice.php @@ -0,0 +1,97 @@ +state = $state; + $this->shop_currency = $shop_currency; + $this->supported_currencies = $supported_currencies; + } + + /** + * Returns the message. + * + * @return Message|null + */ + public function unsupported_currency_message() { + if ( ! $this->should_display() ) { + return null; + } + + $message = sprintf( + /* translators: %1$s the shop currency, 2$s the gateway name. */ + __( + 'Attention: Your current WooCommerce store currency (%1$s) is not supported by PayPal. Please update your store currency to one that is supported by PayPal to ensure smooth transactions. Visit the PayPal currency support page for more information on supported currencies.', + 'woocommerce-paypal-payments' + ), + $this->shop_currency, + 'https://developer.paypal.com/api/rest/reference/currency-codes/' + ); + return new Message( $message, 'warning' ); + } + + /** + * Whether the message should display. + * + * @return bool + */ + protected function should_display(): bool { + return $this->state->current_state() === State::STATE_ONBOARDED && ! $this->currency_supported(); + } + + /** + * Whether the currency is supported by PayPal. + * + * @return bool + */ + private function currency_supported(): bool { + $currency = $this->shop_currency; + $supported_currencies = $this->supported_currencies; + return in_array( $currency, $supported_currencies, true ); + } +} diff --git a/modules/ppcp-wc-gateway/src/Processor/OrderProcessor.php b/modules/ppcp-wc-gateway/src/Processor/OrderProcessor.php index 6cc9c7ce4..443fd1cbd 100644 --- a/modules/ppcp-wc-gateway/src/Processor/OrderProcessor.php +++ b/modules/ppcp-wc-gateway/src/Processor/OrderProcessor.php @@ -166,7 +166,7 @@ class OrderProcessor { // phpcs:ignore WordPress.Security.NonceVerification $order_id = $wc_order->get_meta( PayPalGateway::ORDER_ID_META_KEY ) ?: wc_clean( wp_unslash( $_POST['paypal_order_id'] ?? '' ) ); $order = $this->session_handler->order(); - if ( ! $order && is_string( $order_id ) ) { + if ( ! $order && is_string( $order_id ) && $order_id ) { $order = $this->order_endpoint->order( $order_id ); } if ( ! $order ) { @@ -178,7 +178,7 @@ class OrderProcessor { $wc_order->get_id() ) ); - $this->last_error = __( 'Could not retrieve order. This browser may not be supported. Please try again with a different browser.', 'woocommerce-paypal-payments' ); + $this->last_error = __( 'Could not retrieve order. Maybe it was already completed or this browser is not supported. Please check your email or try again with a different browser.', 'woocommerce-paypal-payments' ); return false; } diff --git a/modules/ppcp-wc-gateway/src/Settings/Fields/pay-later-tab-fields.php b/modules/ppcp-wc-gateway/src/Settings/Fields/pay-later-tab-fields.php index fab61ca83..6428b1389 100644 --- a/modules/ppcp-wc-gateway/src/Settings/Fields/pay-later-tab-fields.php +++ b/modules/ppcp-wc-gateway/src/Settings/Fields/pay-later-tab-fields.php @@ -33,11 +33,12 @@ return function ( ContainerInterface $container, array $fields ): array { $selected_country = $container->get( 'api.shop.country' ); $default_messaging_flex_color = $selected_country === 'US' ? 'white-no-border' : 'white'; - - $render_preview_element = function ( string $id, string $type ): string { + $button_message = __( 'Pay Later Button Preview', 'woocommerce-paypal-payments' ); + $messaging_message = __( 'Pay Later Messaging Preview', 'woocommerce-paypal-payments' ); + $render_preview_element = function ( string $id, string $type, string $message ): string { return '