diff --git a/changelog/9001-tokenized-prb-order-origin b/changelog/9001-tokenized-prb-order-origin new file mode 100644 index 00000000000..82d4c4aac15 --- /dev/null +++ b/changelog/9001-tokenized-prb-order-origin @@ -0,0 +1,5 @@ +Significance: patch +Type: fix +Comment: Bug fix for PRB feature behind a flag. + + diff --git a/changelog/as-8871-ece-on-supported-product-types b/changelog/as-8871-ece-on-supported-product-types new file mode 100644 index 00000000000..5aa499e2693 --- /dev/null +++ b/changelog/as-8871-ece-on-supported-product-types @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add ECE support for multiple product types. diff --git a/client/express-checkout/event-handlers.js b/client/express-checkout/event-handlers.js index 21c1199bce3..f0ae3359376 100644 --- a/client/express-checkout/event-handlers.js +++ b/client/express-checkout/event-handlers.js @@ -13,6 +13,7 @@ import { trackExpressCheckoutButtonClick, trackExpressCheckoutButtonLoad, } from './tracking'; +import { __ } from '@wordpress/i18n'; export const shippingAddressChangeHandler = async ( api, event, elements ) => { try { @@ -77,27 +78,27 @@ export const onConfirmHandler = async ( return abortPayment( event, error.message ); } - // Kick off checkout processing step. - let orderResponse; - if ( ! order ) { - orderResponse = await api.expressCheckoutECECreateOrder( - normalizeOrderData( event, paymentMethod.id ) - ); - } else { - orderResponse = await api.expressCheckoutECEPayForOrder( - order, - normalizePayForOrderData( event, paymentMethod.id ) - ); - } + try { + // Kick off checkout processing step. + let orderResponse; + if ( ! order ) { + orderResponse = await api.expressCheckoutECECreateOrder( + normalizeOrderData( event, paymentMethod.id ) + ); + } else { + orderResponse = await api.expressCheckoutECEPayForOrder( + order, + normalizePayForOrderData( event, paymentMethod.id ) + ); + } - if ( orderResponse.result !== 'success' ) { - return abortPayment( - event, - getErrorMessageFromNotice( orderResponse.messages ) - ); - } + if ( orderResponse.result !== 'success' ) { + return abortPayment( + event, + getErrorMessageFromNotice( orderResponse.messages ) + ); + } - try { const confirmationRequest = api.confirmIntent( orderResponse.redirect ); // `true` means there is no intent to confirm. @@ -109,7 +110,14 @@ export const onConfirmHandler = async ( completePayment( redirectUrl ); } } catch ( e ) { - return abortPayment( event, e.message ); + return abortPayment( + event, + e.message ?? + __( + 'There was a problem processing the order.', + 'woocommerce-payments' + ) + ); } }; diff --git a/client/express-checkout/index.js b/client/express-checkout/index.js index 94be0031230..71d2e75135e 100644 --- a/client/express-checkout/index.js +++ b/client/express-checkout/index.js @@ -1,5 +1,6 @@ /* global jQuery, wcpayExpressCheckoutParams, wcpayECEPayForOrderParams */ import { __ } from '@wordpress/i18n'; +import { debounce } from 'lodash'; /** * Internal dependencies @@ -19,6 +20,7 @@ import { shippingAddressChangeHandler, shippingRateChangeHandler, } from './event-handlers'; +import { displayLoginConfirmation } from './utils'; jQuery( ( $ ) => { // Don't load if blocks checkout is being loaded. @@ -30,6 +32,7 @@ jQuery( ( $ ) => { } const publishableKey = wcpayExpressCheckoutParams.stripe.publishableKey; + const quantityInputSelector = '.quantity .qty[type=number]'; if ( ! publishableKey ) { // If no configuration is present, probably this is not the checkout page. @@ -50,6 +53,12 @@ jQuery( ( $ ) => { } ); + let wcPayECEError = ''; + const defaultErrorMessage = __( + 'There was an error getting the product information.', + 'woocommerce-payments' + ); + /** * Object to handle Stripe payment forms. */ @@ -156,7 +165,7 @@ jQuery( ( $ ) => { const data = { product_id: productId, - qty: $( '.quantity .qty' ).val(), + qty: $( quantityInputSelector ).val(), attributes: $( '.variations_form' ).length ? wcpayECE.getAttributes().data : [], @@ -250,9 +259,45 @@ jQuery( ( $ ) => { wcpayECE.showButton( eceButton ); eceButton.on( 'click', function ( event ) { - // TODO: handle cases where we need login confirmation. + // If login is required for checkout, display redirect confirmation dialog. + if ( getExpressCheckoutData( 'login_confirmation' ) ) { + displayLoginConfirmation( event.expressPaymentType ); + return; + } if ( getExpressCheckoutData( 'is_product_page' ) ) { + const addToCartButton = $( '.single_add_to_cart_button' ); + + // First check if product can be added to cart. + if ( addToCartButton.is( '.disabled' ) ) { + if ( + addToCartButton.is( '.wc-variation-is-unavailable' ) + ) { + window.alert( + window?.wc_add_to_cart_variation_params + ?.i18n_unavailable_text || + __( + 'Sorry, this product is unavailable. Please choose a different combination.', + 'woocommerce-payments' + ) + ); + } else { + window.alert( + __( + 'Please select your product options before proceeding.', + 'woocommerce-payments' + ) + ); + } + return; + } + + if ( wcPayECEError ) { + window.alert( wcPayECEError ); + return; + } + + // Add products to the cart if everything is right. wcpayECE.addToCart(); } @@ -291,10 +336,15 @@ jQuery( ( $ ) => { } ); eceButton.on( 'cancel', async () => { + wcpayECE.paymentAborted = true; wcpayECE.unblock(); } ); eceButton.on( 'ready', onReadyHandler ); + + if ( getExpressCheckoutData( 'is_product_page' ) ) { + wcpayECE.attachProductPageEventListeners( elements ); + } }, getSelectedProductData: () => { @@ -333,7 +383,7 @@ jQuery( ( $ ) => { const data = { product_id: productId, - qty: $( '.quantity .qty' ).val(), + qty: $( quantityInputSelector ).val(), attributes: $( '.variations_form' ).length ? wcpayECE.getAttributes().data : [], @@ -356,44 +406,132 @@ jQuery( ( $ ) => { return elements.create( 'expressCheckout', options ); }, - getElements: () => { - return $( - '.wcpay-payment-request-wrapper,#wcpay-express-checkout-button-separator' - ); - }, - - hide: () => { - wcpayECE.getElements().hide(); - }, + attachProductPageEventListeners: ( elements ) => { + $( document.body ) + .off( 'woocommerce_variation_has_changed' ) + .on( 'woocommerce_variation_has_changed', () => { + wcpayECE.blockExpressCheckoutButton(); + + $.when( wcpayECE.getSelectedProductData() ) + .then( ( response ) => { + /** + * If the customer aborted the express checkout, + * we need to re init the express checkout button to ensure the shipping + * options are refetched. If the customer didn't abort the express checkout, + * and the product's shipping status is consistent, + * we can simply update the express checkout button with the new total and display items. + */ + if ( + ! wcpayECE.paymentAborted && + getExpressCheckoutData( 'product' ) + .needs_shipping === response.needs_shipping + ) { + elements.update( { + amount: response.total.amount, + displayItems: response.displayItems, + } ); + } else { + wcpayECE.reInitExpressCheckoutElement( + response + ); + } + } ) + .catch( () => { + wcpayECE.hide(); + } ) + .always( () => { + wcpayECE.unblockExpressCheckoutButton(); + } ); + } ); - show: () => { - wcpayECE.getElements().show(); + $( '.quantity' ) + .off( 'input', '.qty' ) + .on( + 'input', + '.qty', + debounce( () => { + wcpayECE.blockExpressCheckoutButton(); + wcPayECEError = ''; + + $.when( wcpayECE.getSelectedProductData() ) + .then( + ( response ) => { + // In case the server returns an unexpected response + if ( typeof response !== 'object' ) { + wcPayECEError = defaultErrorMessage; + } + + if ( + ! wcpayECE.paymentAborted && + getExpressCheckoutData( 'product' ) + .needs_shipping === + response.needs_shipping + ) { + elements.update( { + amount: response.total.amount, + } ); + } else { + wcpayECE.reInitExpressCheckoutElement( + response + ); + } + }, + ( response ) => { + wcPayECEError = + response.responseJSON?.error ?? + defaultErrorMessage; + } + ) + .always( function () { + wcpayECE.unblockExpressCheckoutButton(); + } ); + }, 250 ) + ); }, - showButton: ( eceButton ) => { - if ( $( '#wcpay-express-checkout-element' ).length ) { - wcpayECE.show(); - eceButton.mount( '#wcpay-express-checkout-element' ); - } + reInitExpressCheckoutElement: ( response ) => { + wcpayExpressCheckoutParams.product.needs_shipping = + response.needs_shipping; + wcpayExpressCheckoutParams.product.total = response.total; + wcpayExpressCheckoutParams.product.displayItems = + response.displayItems; + wcpayECE.init(); }, - blockButton: () => { + blockExpressCheckoutButton: () => { // check if element isn't already blocked before calling block() to avoid blinking overlay issues // blockUI.isBlocked is either undefined or 0 when element is not blocked if ( - $( '#wcpay-express-checkout-button' ).data( + $( '#wcpay-express-checkout-element' ).data( 'blockUI.isBlocked' ) ) { return; } - $( '#wcpay-express-checkout-button' ).block( { message: null } ); + $( '#wcpay-express-checkout-element' ).block( { message: null } ); }, - unblockButton: () => { + unblockExpressCheckoutButton: () => { wcpayECE.show(); - $( '#wcpay-express-checkout-button' ).unblock(); + $( '#wcpay-express-checkout-element' ).unblock(); + }, + + getElements: () => { + return $( + '.wcpay-payment-request-wrapper,#wcpay-express-checkout-button-separator' + ); + }, + + show: () => { + wcpayECE.getElements().show(); + }, + + showButton: ( eceButton ) => { + if ( $( '#wcpay-express-checkout-element' ).length ) { + wcpayECE.show(); + eceButton.mount( '#wcpay-express-checkout-element' ); + } }, /** @@ -454,6 +592,9 @@ jQuery( ( $ ) => { } ); } ); } + + // After initializing a new express checkout button, we need to reset the paymentAborted flag. + wcpayECE.paymentAborted = false; }, }; diff --git a/client/express-checkout/utils/index.ts b/client/express-checkout/utils/index.ts index b945ba92974..2d8317bd0c7 100644 --- a/client/express-checkout/utils/index.ts +++ b/client/express-checkout/utils/index.ts @@ -4,6 +4,12 @@ export * from './normalize'; import { getDefaultBorderRadius } from 'wcpay/utils/express-checkout'; +interface MyWindow extends Window { + wcpayExpressCheckoutParams: WCPayExpressCheckoutParams; +} + +declare let window: MyWindow; + /** * An /incomplete/ representation of the data that is loaded into the frontend for the Express Checkout. */ @@ -77,6 +83,12 @@ export interface WCPayExpressCheckoutParams { amount: number; }; }; + + /** + * Settings for the user authentication dialog and redirection. + */ + login_confirmation: { message: string; redirect_url: string } | false; + stripe: { accountId: string; locale: string; @@ -97,11 +109,7 @@ export const getExpressCheckoutData = < >( key: K ) => { - if ( window.wcpayExpressCheckoutParams ) { - return window.wcpayExpressCheckoutParams?.[ key ]; - } - - return null; + return window.wcpayExpressCheckoutParams?.[ key ] ?? null; }; /** @@ -116,6 +124,51 @@ export const getErrorMessageFromNotice = ( notice: string ) => { return div.firstChild ? div.firstChild.textContent : ''; }; +type ExpressPaymentType = + | 'apple_pay' + | 'google_pay' + | 'amazon_pay' + | 'paypal' + | 'link'; + +/** + * Displays a `confirm` dialog which leads to a redirect. + * + * @param expressPaymentType Can be either 'apple_pay', 'google_pay', 'amazon_pay', 'paypal' or 'link'. + */ +export const displayLoginConfirmation = ( + expressPaymentType: ExpressPaymentType +) => { + const loginConfirmation = getExpressCheckoutData( 'login_confirmation' ); + + if ( ! loginConfirmation ) { + return; + } + + const paymentTypesMap = { + apple_pay: 'Apple Pay', + google_pay: 'Google Pay', + amazon_pay: 'Amazon Pay', + paypal: 'PayPal', + link: 'Link', + }; + let message = loginConfirmation.message; + + // Replace dialog text with specific express checkout type. + message = message.replace( + /\*\*.*?\*\*/, + paymentTypesMap[ expressPaymentType ] + ); + + // Remove asterisks from string. + message = message.replace( /\*\*/g, '' ); + + if ( confirm( message ) ) { + // Redirect to my account page. + window.location.href = loginConfirmation.redirect_url; + } +}; + /** * Returns the appearance settings for the Express Checkout buttons. * Currently only configures border radius for the buttons. diff --git a/includes/express-checkout/class-wc-payments-express-checkout-ajax-handler.php b/includes/express-checkout/class-wc-payments-express-checkout-ajax-handler.php index 94482930a62..c4ba8cff02b 100644 --- a/includes/express-checkout/class-wc-payments-express-checkout-ajax-handler.php +++ b/includes/express-checkout/class-wc-payments-express-checkout-ajax-handler.php @@ -9,6 +9,9 @@ exit; } +use WCPay\Exceptions\Invalid_Price_Exception; +use WCPay\Logger; + /** * WC_Payments_Express_Checkout_Ajax_Handler class. */ @@ -40,6 +43,7 @@ public function init() { add_action( 'wc_ajax_wcpay_get_shipping_options', [ $this, 'ajax_get_shipping_options' ] ); add_action( 'wc_ajax_wcpay_get_cart_details', [ $this, 'ajax_get_cart_details' ] ); add_action( 'wc_ajax_wcpay_update_shipping_method', [ $this, 'ajax_update_shipping_method' ] ); + add_action( 'wc_ajax_wcpay_get_selected_product_data', [ $this, 'ajax_get_selected_product_data' ] ); } /** @@ -212,6 +216,109 @@ public function ajax_update_shipping_method() { wp_send_json( $data ); } + /** + * Gets the selected product data. + * + * @throws Exception If product or stock is unavailable - caught inside function. + */ + public function ajax_get_selected_product_data() { + check_ajax_referer( 'wcpay-get-selected-product-data', 'security' ); + + try { + $product_id = isset( $_POST['product_id'] ) ? absint( $_POST['product_id'] ) : false; + $qty = ! isset( $_POST['qty'] ) ? 1 : apply_filters( 'woocommerce_add_to_cart_quantity', absint( $_POST['qty'] ), $product_id ); + $addon_value = isset( $_POST['addon_value'] ) ? max( (float) $_POST['addon_value'], 0 ) : 0; + $product = wc_get_product( $product_id ); + $variation_id = null; + $currency = get_woocommerce_currency(); + $is_deposit = isset( $_POST['wc_deposit_option'] ) ? 'yes' === sanitize_text_field( wp_unslash( $_POST['wc_deposit_option'] ) ) : null; + $deposit_plan_id = isset( $_POST['wc_deposit_payment_plan'] ) ? absint( $_POST['wc_deposit_payment_plan'] ) : 0; + + if ( ! is_a( $product, 'WC_Product' ) ) { + /* translators: product ID */ + throw new Exception( sprintf( __( 'Product with the ID (%d) cannot be found.', 'woocommerce-payments' ), $product_id ) ); + } + + if ( ( 'variable' === $product->get_type() || 'variable-subscription' === $product->get_type() ) && isset( $_POST['attributes'] ) ) { + $attributes = wc_clean( wp_unslash( $_POST['attributes'] ) ); + + $data_store = WC_Data_Store::load( 'product' ); + $variation_id = $data_store->find_matching_product_variation( $product, $attributes ); + + if ( ! empty( $variation_id ) ) { + $product = wc_get_product( $variation_id ); + } + } + + // Force quantity to 1 if sold individually and check for existing item in cart. + if ( $product->is_sold_individually() ) { + $qty = apply_filters( 'wcpay_payment_request_add_to_cart_sold_individually_quantity', 1, $qty, $product_id, $variation_id ); + } + + if ( ! $product->has_enough_stock( $qty ) ) { + /* translators: 1: product name 2: quantity in stock */ + throw new Exception( sprintf( __( 'You cannot add that amount of "%1$s"; to the cart because there is not enough stock (%2$s remaining).', 'woocommerce-payments' ), $product->get_name(), wc_format_stock_quantity_for_display( $product->get_stock_quantity(), $product ) ) ); + } + + $price = $this->express_checkout_button_helper->get_product_price( $product, $is_deposit, $deposit_plan_id ); + $total = $qty * $price + $addon_value; + + $quantity_label = 1 < $qty ? ' (x' . $qty . ')' : ''; + + $data = []; + $items = []; + + $items[] = [ + 'label' => $product->get_name() . $quantity_label, + 'amount' => WC_Payments_Utils::prepare_amount( $total, $currency ), + ]; + + $total_tax = 0; + foreach ( $this->express_checkout_button_helper->get_taxes_like_cart( $product, $price ) as $tax ) { + $total_tax += $tax; + + $items[] = [ + 'label' => __( 'Tax', 'woocommerce-payments' ), + 'amount' => WC_Payments_Utils::prepare_amount( $tax, $currency ), + 'pending' => 0 === $tax, + ]; + } + + if ( wc_shipping_enabled() && $product->needs_shipping() ) { + $items[] = [ + 'label' => __( 'Shipping', 'woocommerce-payments' ), + 'amount' => 0, + 'pending' => true, + ]; + + $data['shippingOptions'] = [ + 'id' => 'pending', + 'label' => __( 'Pending', 'woocommerce-payments' ), + 'detail' => '', + 'amount' => 0, + ]; + } + + $data['displayItems'] = $items; + $data['total'] = [ + 'label' => $this->express_checkout_button_helper->get_total_label(), + 'amount' => WC_Payments_Utils::prepare_amount( $total + $total_tax, $currency ), + 'pending' => true, + ]; + + $data['needs_shipping'] = ( wc_shipping_enabled() && $product->needs_shipping() ); + $data['currency'] = strtolower( get_woocommerce_currency() ); + $data['country_code'] = substr( get_option( 'woocommerce_default_country' ), 0, 2 ); + + wp_send_json( $data ); + } catch ( Exception $e ) { + if ( is_a( $e, Invalid_Price_Exception::class ) ) { + Logger::log( $e->getMessage() ); + } + wp_send_json( [ 'error' => wp_strip_all_tags( $e->getMessage() ) ], 500 ); + } + } + /** * Adds the current product to the cart. Used on product detail page. */ diff --git a/includes/express-checkout/class-wc-payments-express-checkout-button-display-handler.php b/includes/express-checkout/class-wc-payments-express-checkout-button-display-handler.php index 66c203b818f..ba1e0c5bff9 100644 --- a/includes/express-checkout/class-wc-payments-express-checkout-button-display-handler.php +++ b/includes/express-checkout/class-wc-payments-express-checkout-button-display-handler.php @@ -150,14 +150,29 @@ public function display_express_checkout_buttons() { } else { $this->payment_request_button_handler->display_payment_request_button_html(); } + + if ( is_cart() ) { + add_action( 'woocommerce_after_cart', [ $this, 'add_order_attribution_inputs' ], 1 ); + } else { + $this->add_order_attribution_inputs(); + } + ?> - display_express_checkout_separator_if_necessary( $separator_starts_hidden ); } } + /** + * Add order attribution inputs to the page. + * + * @return void + */ + public function add_order_attribution_inputs() { + echo ''; + } + /** * Check if the pay-for-order flow is supported. * diff --git a/includes/express-checkout/class-wc-payments-express-checkout-button-handler.php b/includes/express-checkout/class-wc-payments-express-checkout-button-handler.php index 444977bfb90..6a8d66d1bd8 100644 --- a/includes/express-checkout/class-wc-payments-express-checkout-button-handler.php +++ b/includes/express-checkout/class-wc-payments-express-checkout-button-handler.php @@ -88,6 +88,10 @@ public function init() { return; } + add_action( 'template_redirect', [ $this, 'set_session' ] ); + add_action( 'template_redirect', [ $this, 'handle_express_checkout_redirect' ] ); + add_filter( 'woocommerce_login_redirect', [ $this, 'get_login_redirect_url' ], 10, 3 ); + add_filter( 'woocommerce_registration_redirect', [ $this, 'get_login_redirect_url' ], 10, 3 ); add_action( 'wp_enqueue_scripts', [ $this, 'scripts' ] ); add_action( 'before_woocommerce_pay_form', [ $this, 'display_pay_for_order_page_html' ], 1 ); @@ -111,6 +115,99 @@ public function get_button_settings() { return array_merge( $common_settings, $payment_request_button_settings ); } + /** + * Settings array for the user authentication dialog and redirection. + * + * @return array|false + */ + public function get_login_confirmation_settings() { + if ( is_user_logged_in() || ! $this->is_authentication_required() ) { + return false; + } + + /* translators: The text encapsulated in `**` can be replaced with "Apple Pay" or "Google Pay". Please translate this text, but don't remove the `**`. */ + $message = __( 'To complete your transaction with **the selected payment method**, you must log in or create an account with our site.', 'woocommerce-payments' ); + $redirect_url = add_query_arg( + [ + '_wpnonce' => wp_create_nonce( 'wcpay-set-redirect-url' ), + 'wcpay_express_checkout_redirect_url' => rawurlencode( home_url( add_query_arg( [] ) ) ), + // Current URL to redirect to after login. + ], + home_url() + ); + + return [ // nosemgrep: audit.php.wp.security.xss.query-arg -- home_url passed in to add_query_arg. + 'message' => $message, + 'redirect_url' => $redirect_url, + ]; + } + + /** + * Checks whether authentication is required for checkout. + * + * @return bool + */ + public function is_authentication_required() { + // If guest checkout is disabled and account creation is not possible, authentication is required. + if ( 'no' === get_option( 'woocommerce_enable_guest_checkout', 'yes' ) && ! $this->is_account_creation_possible() ) { + return true; + } + // If cart contains subscription and account creation is not posible, authentication is required. + if ( $this->has_subscription_product() && ! $this->is_account_creation_possible() ) { + return true; + } + + return false; + } + + /** + * Checks whether cart contains a subscription product or this is a subscription product page. + * + * @return boolean + */ + public function has_subscription_product() { + if ( ! class_exists( 'WC_Subscriptions_Product' ) || ! class_exists( 'WC_Subscriptions_Cart' ) ) { + return false; + } + + if ( $this->express_checkout_helper->is_product() ) { + $product = $this->express_checkout_helper->get_product(); + if ( WC_Subscriptions_Product::is_subscription( $product ) ) { + return true; + } + } + + if ( $this->express_checkout_helper->is_checkout() || $this->express_checkout_helper->is_cart() ) { + if ( WC_Subscriptions_Cart::cart_contains_subscription() ) { + return true; + } + } + + return false; + } + + /** + * Checks whether account creation is possible during checkout. + * + * @return bool + */ + public function is_account_creation_possible() { + $is_signup_from_checkout_allowed = 'yes' === get_option( 'woocommerce_enable_signup_and_login_from_checkout', 'no' ); + + // If a subscription is being purchased, check if account creation is allowed for subscriptions. + if ( ! $is_signup_from_checkout_allowed && $this->has_subscription_product() ) { + $is_signup_from_checkout_allowed = 'yes' === get_option( 'woocommerce_enable_signup_from_checkout_for_subscriptions', 'no' ); + } + + // If automatically generate username/password are disabled, the Payment Request API + // can't include any of those fields, so account creation is not possible. + return ( + $is_signup_from_checkout_allowed && + 'yes' === get_option( 'woocommerce_registration_generate_username', 'yes' ) && + 'yes' === get_option( 'woocommerce_registration_generate_password', 'yes' ) + ); + } + /** * Load public scripts and styles. */ @@ -147,7 +244,7 @@ public function scripts() { 'needs_payer_phone' => 'required' === get_option( 'woocommerce_checkout_phone_field', 'required' ), ], 'button' => $this->get_button_settings(), - 'login_confirmation' => '', + 'login_confirmation' => $this->get_login_confirmation_settings(), 'is_product_page' => $this->express_checkout_helper->is_product(), 'button_context' => $this->express_checkout_helper->get_button_context(), 'is_pay_for_order' => $this->express_checkout_helper->is_pay_for_order_page(), @@ -250,4 +347,59 @@ public function display_pay_for_order_page_html( $order ) { wp_localize_script( 'WCPAY_EXPRESS_CHECKOUT_ECE', 'wcpayECEPayForOrderParams', $data ); } + + /** + * Sets the WC customer session if one is not set. + * This is needed so nonces can be verified by AJAX Request. + * + * @return void + */ + public function set_session() { + // Don't set session cookies on product pages to allow for caching when express checkout + // buttons are disabled. But keep cookies if there is already an active WC session in place. + if ( + ! ( $this->express_checkout_helper->is_product() && $this->express_checkout_helper->should_show_express_checkout_button() ) + || ( isset( WC()->session ) && WC()->session->has_session() ) + ) { + return; + } + + WC()->session->set_customer_session_cookie( true ); + } + + /** + * Handles express checkout redirect when the redirect dialog "Continue" button is clicked. + */ + public function handle_express_checkout_redirect() { + if ( + ! empty( $_GET['wcpay_express_checkout_redirect_url'] ) + && ! empty( $_GET['_wpnonce'] ) + && wp_verify_nonce( $_GET['_wpnonce'], 'wcpay-set-redirect-url' ) // @codingStandardsIgnoreLine + ) { + $url = rawurldecode( esc_url_raw( wp_unslash( $_GET['wcpay_express_checkout_redirect_url'] ) ) ); + // Sets a redirect URL cookie for 10 minutes, which we will redirect to after authentication. + // Users will have a 10 minute timeout to login/create account, otherwise redirect URL expires. + wc_setcookie( 'wcpay_express_checkout_redirect_url', $url, time() + MINUTE_IN_SECONDS * 10 ); + // Redirects to "my-account" page. + wp_safe_redirect( get_permalink( get_option( 'woocommerce_myaccount_page_id' ) ) ); + } + } + + /** + * Returns the login redirect URL. + * + * @param string $redirect Default redirect URL. + * + * @return string Redirect URL. + */ + public function get_login_redirect_url( $redirect ) { + $url = esc_url_raw( wp_unslash( $_COOKIE['wcpay_express_checkout_redirect_url'] ?? '' ) ); + + if ( empty( $url ) ) { + return $redirect; + } + wc_setcookie( 'wcpay_express_checkout_redirect_url', '' ); + + return $url; + } } diff --git a/includes/express-checkout/class-wc-payments-express-checkout-button-helper.php b/includes/express-checkout/class-wc-payments-express-checkout-button-helper.php index 378a2c4d7c5..8f9d0d7ac9c 100644 --- a/includes/express-checkout/class-wc-payments-express-checkout-button-helper.php +++ b/includes/express-checkout/class-wc-payments-express-checkout-button-helper.php @@ -815,7 +815,7 @@ public function get_product_price( $product, ?bool $is_deposit = null, int $depo * @param float $price The price, which to calculate taxes for. * @return array An array of final taxes. */ - private function get_taxes_like_cart( $product, $price ) { + public function get_taxes_like_cart( $product, $price ) { if ( ! wc_tax_enabled() || $this->cart_prices_include_tax() ) { // Only proceed when taxes are enabled, but not included. return []; diff --git a/includes/fraud-prevention/class-fraud-prevention-service.php b/includes/fraud-prevention/class-fraud-prevention-service.php index abab5b9f454..9bef49875c5 100644 --- a/includes/fraud-prevention/class-fraud-prevention-service.php +++ b/includes/fraud-prevention/class-fraud-prevention-service.php @@ -66,6 +66,7 @@ public static function get_instance( $session = null, $gateway = null ): self { /** * Appends the fraud prevention token to the JS context if the protection is enabled, and a session exists. + * This token will also be used by express checkouts. * * @return void */ @@ -86,9 +87,9 @@ public static function maybe_append_fraud_prevention_token() { return; } - // Don't add the token if the user isn't on the cart or checkout page. - // Checking the cart page too because the user can pay quickly via the payment buttons on that page. - if ( ! is_checkout() && ! is_cart() ) { + // Don't add the token if the user isn't on the cart, checkout or product page. + // Checking the product and cart page too because the user can pay quickly via the payment buttons on that page. + if ( ! is_checkout() && ! is_cart() && ! is_product() ) { return; } diff --git a/psalm-baseline.xml b/psalm-baseline.xml index b358e46babb..751f04cb47f 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -31,6 +31,11 @@ WC_Subscriptions_Cart + + + WC_Subscriptions_Cart + + WC_Subscriptions_Product