From ef195c03b6c8e64b923e1ffc509024e0c881decf Mon Sep 17 00:00:00 2001 From: Rafael Zaleski Date: Mon, 3 Jun 2024 10:45:09 -0300 Subject: [PATCH] Add payment processing using ECE on the Blocks checkout and cart pages (#8884) --- .../add-8773-ece-support-blocks-checkout-page | 4 + client/checkout/api/index.js | 33 + client/checkout/blocks/index.js | 7 +- .../components/express-checkout-component.js | 52 ++ .../components/express-checkout-container.js | 28 + .../blocks/express-checkout.js | 36 - .../blocks/hooks/use-express-checkout.js | 93 ++ client/express-checkout/blocks/index.js | 9 +- client/express-checkout/event-handlers.js | 61 ++ client/express-checkout/utils/index.js | 1 + client/express-checkout/utils/normalize.js | 101 ++ client/utils/express-checkout/index.js | 39 +- includes/class-wc-payment-gateway-wcpay.php | 6 +- includes/class-wc-payments.php | 11 +- ...payments-express-checkout-ajax-handler.php | 195 ++++ ...xpress-checkout-button-display-handler.php | 26 +- ...yments-express-checkout-button-handler.php | 400 +------- ...ayments-express-checkout-button-helper.php | 859 ++++++++++++++++-- .../services/class-checkout-service.php | 2 +- src/Internal/Payment/Factor.php | 7 + .../unit/src/Internal/Payment/FactorTest.php | 1 + ...xpress-checkout-button-display-handler.php | 33 +- 22 files changed, 1456 insertions(+), 548 deletions(-) create mode 100644 changelog/add-8773-ece-support-blocks-checkout-page create mode 100644 client/express-checkout/blocks/components/express-checkout-component.js create mode 100644 client/express-checkout/blocks/components/express-checkout-container.js delete mode 100644 client/express-checkout/blocks/express-checkout.js create mode 100644 client/express-checkout/blocks/hooks/use-express-checkout.js create mode 100644 client/express-checkout/event-handlers.js create mode 100644 client/express-checkout/utils/index.js create mode 100644 client/express-checkout/utils/normalize.js create mode 100644 includes/express-checkout/class-wc-payments-express-checkout-ajax-handler.php diff --git a/changelog/add-8773-ece-support-blocks-checkout-page b/changelog/add-8773-ece-support-blocks-checkout-page new file mode 100644 index 00000000000..1e8174e125c --- /dev/null +++ b/changelog/add-8773-ece-support-blocks-checkout-page @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add payment processing using ECE in the Blocks checkout and cart pages. diff --git a/client/checkout/api/index.js b/client/checkout/api/index.js index 6a088a9c5c1..3775aa7f394 100644 --- a/client/checkout/api/index.js +++ b/client/checkout/api/index.js @@ -8,6 +8,8 @@ import { getPaymentRequestData, getPaymentRequestAjaxURL, buildAjaxURL, + getExpressCheckoutAjaxURL, + getExpressCheckoutConfig, } from 'utils/express-checkout'; /** @@ -406,6 +408,37 @@ export default class WCPayAPI { } ); } + /** + * Submits shipping address to get available shipping options + * from Express Checkout ECE payment method. + * + * @param {Object} shippingAddress Shipping details. + * @return {Promise} Promise for the request to the server. + */ + expressCheckoutECECalculateShippingOptions( shippingAddress ) { + return this.request( + getExpressCheckoutAjaxURL( 'get_shipping_options' ), + { + security: getExpressCheckoutConfig( 'nonce' )?.shipping, + is_product_page: getExpressCheckoutConfig( 'is_product_page' ), + ...shippingAddress, + } + ); + } + + /** + * Creates order based on Express Checkout ECE payment method. + * + * @param {Object} paymentData Order data. + * @return {Promise} Promise for the request to the server. + */ + expressCheckoutECECreateOrder( paymentData ) { + return this.request( getExpressCheckoutAjaxURL( 'create_order' ), { + _wpnonce: getExpressCheckoutConfig( 'nonce' )?.checkout, + ...paymentData, + } ); + } + initWooPay( userEmail, woopayUserSession ) { if ( ! this.isWooPayRequesting ) { this.isWooPayRequesting = true; diff --git a/client/checkout/blocks/index.js b/client/checkout/blocks/index.js index 0e26e9ed4b1..9f858acd87d 100644 --- a/client/checkout/blocks/index.js +++ b/client/checkout/blocks/index.js @@ -154,8 +154,11 @@ if ( getUPEConfig( 'isWooPayEnabled' ) ) { } } -registerExpressPaymentMethod( paymentRequestPaymentMethod( api ) ); -registerExpressPaymentMethod( expressCheckoutElementPaymentMethod( api ) ); +if ( getUPEConfig( 'isExpressCheckoutElementEnabled' ) ) { + registerExpressPaymentMethod( expressCheckoutElementPaymentMethod( api ) ); +} else { + registerExpressPaymentMethod( paymentRequestPaymentMethod( api ) ); +} window.addEventListener( 'load', () => { enqueueFraudScripts( getUPEConfig( 'fraudServices' ) ); addCheckoutTracking(); diff --git a/client/express-checkout/blocks/components/express-checkout-component.js b/client/express-checkout/blocks/components/express-checkout-component.js new file mode 100644 index 00000000000..a8bbc56ba4b --- /dev/null +++ b/client/express-checkout/blocks/components/express-checkout-component.js @@ -0,0 +1,52 @@ +/** + * External dependencies + */ +import { ExpressCheckoutElement } from '@stripe/react-stripe-js'; +import { shippingAddressChangeHandler } from '../../event-handlers'; +import { useExpressCheckout } from '../hooks/use-express-checkout'; + +/** + * ExpressCheckout express payment method component. + * + * @param {Object} props PaymentMethodProps. + * + * @return {ReactNode} Stripe Elements component. + */ +const ExpressCheckoutComponent = ( { + api, + billing, + shippingData, + setExpressPaymentError, + onClick, + onClose, +} ) => { + const { + buttonOptions, + onButtonClick, + onConfirm, + onCancel, + } = useExpressCheckout( { + api, + billing, + shippingData, + onClick, + onClose, + setExpressPaymentError, + } ); + + const onShippingAddressChange = ( event ) => { + shippingAddressChangeHandler( api, event ); + }; + + return ( + + ); +}; + +export default ExpressCheckoutComponent; diff --git a/client/express-checkout/blocks/components/express-checkout-container.js b/client/express-checkout/blocks/components/express-checkout-container.js new file mode 100644 index 00000000000..e25960c03cd --- /dev/null +++ b/client/express-checkout/blocks/components/express-checkout-container.js @@ -0,0 +1,28 @@ +/** + * External dependencies + */ +import { Elements } from '@stripe/react-stripe-js'; + +/** + * Internal dependencies + */ +import ExpressCheckoutComponent from './express-checkout-component'; + +const ExpressCheckoutContainer = ( props ) => { + const { stripe, billing } = props; + + const options = { + mode: 'payment', + paymentMethodCreation: 'manual', + amount: billing.cartTotal.value, + currency: billing.currency.code.toLowerCase(), + }; + + return ( + + + + ); +}; + +export default ExpressCheckoutContainer; diff --git a/client/express-checkout/blocks/express-checkout.js b/client/express-checkout/blocks/express-checkout.js deleted file mode 100644 index 5b4c47658bd..00000000000 --- a/client/express-checkout/blocks/express-checkout.js +++ /dev/null @@ -1,36 +0,0 @@ -/* global wcpayExpressCheckoutParams */ - -/** - * External dependencies - */ -import { Elements, ExpressCheckoutElement } from '@stripe/react-stripe-js'; - -/** - * ExpressCheckout express payment method component. - * - * @param {Object} props PaymentMethodProps. - * - * @return {ReactNode} Stripe Elements component. - */ -export const ExpressCheckout = ( props ) => { - const { stripe } = props; - - const options = { - mode: 'payment', - amount: 1099, - currency: 'usd', - }; - - const buttonOptions = { - buttonType: { - googlePay: wcpayExpressCheckoutParams.button.type, - applePay: wcpayExpressCheckoutParams.button.type, - }, - }; - - return ( - - - - ); -}; diff --git a/client/express-checkout/blocks/hooks/use-express-checkout.js b/client/express-checkout/blocks/hooks/use-express-checkout.js new file mode 100644 index 00000000000..67dc33cc489 --- /dev/null +++ b/client/express-checkout/blocks/hooks/use-express-checkout.js @@ -0,0 +1,93 @@ +/* global wcpayExpressCheckoutParams */ + +/** + * External dependencies + */ +import { useCallback } from '@wordpress/element'; +import { useStripe, useElements } from '@stripe/react-stripe-js'; +import { normalizeLineItems } from 'wcpay/express-checkout/utils'; +import { onConfirmHandler } from 'wcpay/express-checkout/event-handlers'; + +export const useExpressCheckout = ( { + api, + billing, + shippingData, + onClick, + onClose, + setExpressPaymentError, +} ) => { + const stripe = useStripe(); + const elements = useElements(); + + const buttonOptions = { + paymentMethods: { + applePay: 'always', + googlePay: 'always', + link: 'auto', + }, + buttonType: { + googlePay: wcpayExpressCheckoutParams.button.type, + applePay: wcpayExpressCheckoutParams.button.type, + }, + }; + + const onCancel = () => { + onClose(); + }; + + const completePayment = ( redirectUrl ) => { + window.location = redirectUrl; + }; + + const abortPayment = ( onConfirmEvent, message ) => { + onConfirmEvent.paymentFailed( 'fail' ); + setExpressPaymentError( message ); + }; + + const onButtonClick = useCallback( + ( event ) => { + const options = { + lineItems: normalizeLineItems( billing?.cartTotalItems ), + emailRequired: true, + shippingAddressRequired: shippingData?.needsShipping, + phoneNumberRequired: + wcpayExpressCheckoutParams?.checkout?.needs_payer_phone, + shippingRates: shippingData?.shippingRates[ 0 ]?.shipping_rates?.map( + ( r ) => { + return { + id: r.rate_id, + amount: parseInt( r.price, 10 ), + displayName: r.name, + }; + } + ), + }; + event.resolve( options ); + onClick(); + }, + [ + onClick, + billing.cartTotalItems, + shippingData.needsShipping, + shippingData.shippingRates, + ] + ); + + const onConfirm = async ( event ) => { + onConfirmHandler( + api, + stripe, + elements, + completePayment, + abortPayment, + event + ); + }; + + return { + buttonOptions, + onButtonClick, + onConfirm, + onCancel, + }; +}; diff --git a/client/express-checkout/blocks/index.js b/client/express-checkout/blocks/index.js index 17b2c1221dd..c7aac8d9b5c 100644 --- a/client/express-checkout/blocks/index.js +++ b/client/express-checkout/blocks/index.js @@ -4,13 +4,18 @@ * Internal dependencies */ import { PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT } from '../../checkout/constants'; -import { ExpressCheckout } from './express-checkout'; +import ExpressCheckoutContainer from './components/express-checkout-container'; import { getConfig } from '../../utils/checkout'; import ApplePayPreview from './apple-pay-preview'; const expressCheckoutElementPaymentMethod = ( api ) => ( { name: PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT, - content: , + content: ( + + ), edit: , paymentMethodId: PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT, supports: { diff --git a/client/express-checkout/event-handlers.js b/client/express-checkout/event-handlers.js new file mode 100644 index 00000000000..e46931838a2 --- /dev/null +++ b/client/express-checkout/event-handlers.js @@ -0,0 +1,61 @@ +/** + * Internal dependencies + */ +import { normalizeOrderData, normalizeShippingAddress } from './utils'; +import { getErrorMessageFromNotice } from 'utils/express-checkout'; + +export const shippingAddressChangeHandler = async ( api, event ) => { + const response = await api.expressCheckoutECECalculateShippingOptions( + normalizeShippingAddress( event.shippingAddress ) + ); + event.resolve( { + shippingRates: response.shipping_options, + } ); +}; + +export const onConfirmHandler = async ( + api, + stripe, + elements, + completePayment, + abortPayment, + event +) => { + const { paymentMethod, error } = await stripe.createPaymentMethod( { + elements, + } ); + + if ( error ) { + abortPayment( event, error.message ); + return; + } + + // Kick off checkout processing step. + const createOrderResponse = await api.expressCheckoutECECreateOrder( + normalizeOrderData( event, paymentMethod.id ) + ); + + if ( createOrderResponse.result !== 'success' ) { + return abortPayment( + event, + getErrorMessageFromNotice( createOrderResponse.messages ) + ); + } + + try { + const confirmationRequest = api.confirmIntent( + createOrderResponse.redirect + ); + + // `true` means there is no intent to confirm. + if ( confirmationRequest === true ) { + completePayment( createOrderResponse.redirect ); + } else { + const redirectUrl = await confirmationRequest; + + completePayment( redirectUrl ); + } + } catch ( e ) { + abortPayment( event, error.message ); + } +}; diff --git a/client/express-checkout/utils/index.js b/client/express-checkout/utils/index.js new file mode 100644 index 00000000000..d29d7cccc32 --- /dev/null +++ b/client/express-checkout/utils/index.js @@ -0,0 +1 @@ +export * from './normalize'; diff --git a/client/express-checkout/utils/normalize.js b/client/express-checkout/utils/normalize.js new file mode 100644 index 00000000000..fd44ca77b5c --- /dev/null +++ b/client/express-checkout/utils/normalize.js @@ -0,0 +1,101 @@ +/** + * Normalizes incoming cart total items for use as a displayItems with the Stripe api. + * + * @param {Array} displayItems Items to normalize. + * @param {boolean} pending Whether to mark items as pending or not. + * + * @return {Array} An array of PaymentItems + */ +export const normalizeLineItems = ( displayItems ) => { + return displayItems + .filter( ( displayItem ) => { + return !! displayItem.value; + } ) + .map( ( displayItem ) => { + return { + amount: displayItem.value, + name: displayItem.label, + }; + } ); +}; + +/** + * Normalize order data from Stripe's object to the expected format for WC. + * + * @param {Object} event Stripe's event object. + * @param {Object} paymentMethodId Stripe's payment method id. + * + * @return {Object} Order object in the format WooCommerce expects. + */ +export const normalizeOrderData = ( event, paymentMethodId ) => { + const name = event?.billingDetails?.name; + const email = event?.billingDetails?.email ?? ''; + const phone = event?.billingDetails?.phone ?? ''; + const billing = event?.billingDetails?.address ?? {}; + const shipping = event?.shippingAddress ?? {}; + const fraudPreventionTokenValue = window.wcpayFraudPreventionToken ?? ''; + + return { + billing_first_name: + name?.split( ' ' )?.slice( 0, 1 )?.join( ' ' ) ?? '', + billing_last_name: name?.split( ' ' )?.slice( 1 )?.join( ' ' ) || '-', + billing_company: billing?.organization ?? '', + billing_email: email ?? event?.payerEmail ?? '', + billing_phone: + phone ?? event?.payerPhone?.replace( '/[() -]/g', '' ) ?? '', + billing_country: billing?.country ?? '', + billing_address_1: billing?.line1 ?? '', + billing_address_2: billing?.line2 ?? '', + billing_city: billing?.city ?? '', + billing_state: billing?.state ?? '', + billing_postcode: billing?.postal_code ?? '', + shipping_first_name: + shipping?.name?.split( ' ' )?.slice( 0, 1 )?.join( ' ' ) ?? '', + shipping_last_name: + shipping?.name?.split( ' ' )?.slice( 1 )?.join( ' ' ) ?? '', + shipping_company: shipping?.organization ?? '', + shipping_country: shipping?.address?.country ?? '', + shipping_address_1: shipping?.address?.line1 ?? '', + shipping_address_2: shipping?.address?.line2 ?? '', + shipping_city: shipping?.address?.city ?? '', + shipping_state: shipping?.address?.state ?? '', + shipping_postcode: shipping?.address?.postal_code ?? '', + shipping_method: [ event?.shippingRate?.id ?? null ], + order_comments: '', + payment_method: 'woocommerce_payments', + ship_to_different_address: 1, + terms: 1, + 'wcpay-payment-method': paymentMethodId, + payment_request_type: event?.expressPaymentType, + express_payment_type: event?.expressPaymentType, + 'wcpay-fraud-prevention-token': fraudPreventionTokenValue, + }; +}; + +/** + * Normalize shipping address information from Stripe's address object to + * the cart shipping address object shape. + * + * @param {Object} shippingAddress Stripe's shipping address item + * + * @return {Object} The shipping address in the shape expected by the cart. + */ +export const normalizeShippingAddress = ( shippingAddress ) => { + return { + first_name: + shippingAddress?.recipient + ?.split( ' ' ) + ?.slice( 0, 1 ) + ?.join( ' ' ) ?? '', + last_name: + shippingAddress?.recipient?.split( ' ' )?.slice( 1 )?.join( ' ' ) ?? + '', + company: '', + address_1: shippingAddress?.addressLine?.[ 0 ] ?? '', + address_2: shippingAddress?.addressLine?.[ 1 ] ?? '', + city: shippingAddress?.city ?? '', + state: shippingAddress?.region ?? '', + country: shippingAddress?.country ?? '', + postcode: shippingAddress?.postalCode?.replace( ' ', '' ) ?? '', + }; +}; diff --git a/client/utils/express-checkout/index.js b/client/utils/express-checkout/index.js index fb8fc89bc39..e0e93470d33 100644 --- a/client/utils/express-checkout/index.js +++ b/client/utils/express-checkout/index.js @@ -1,12 +1,12 @@ /* global wcpayPaymentRequestParams, wcpayExpressCheckoutParams */ /** - * Retrieves payment request data from global variable. + * Retrieves express checkout config from global variable. * * @param {string} key The object property key. * @return {mixed} Value of the object prop or null. */ -export const getPaymentRequestData = ( key ) => { +export const getExpressCheckoutConfig = ( key ) => { if ( typeof wcpayExpressCheckoutParams === 'object' && wcpayExpressCheckoutParams.hasOwnProperty( key ) @@ -23,16 +23,33 @@ export const getPaymentRequestData = ( key ) => { }; /** - * Get WC AJAX endpoint URL. + * Get WC AJAX endpoint URL for express checkout endpoints. * * @param {string} endpoint Endpoint. * @return {string} URL with interpolated endpoint. */ -export const getPaymentRequestAjaxURL = ( endpoint ) => - getPaymentRequestData( 'wc_ajax_url' ) +export const getExpressCheckoutAjaxURL = ( endpoint ) => + getExpressCheckoutConfig( 'wc_ajax_url' ) .toString() .replace( '%%endpoint%%', 'wcpay_' + endpoint ); +/** + * Retrieves payment request data from global variable. + * + * @param {string} key The object property key. + * @return {mixed} Value of the object prop or null. + */ +export const getPaymentRequestData = ( key ) => getExpressCheckoutConfig( key ); + +/** + * Get WC AJAX endpoint URL. + * + * @param {string} endpoint Endpoint. + * @return {string} URL with interpolated endpoint. + */ +export const getPaymentRequestAjaxURL = ( endpoint ) => + getExpressCheckoutAjaxURL( endpoint ); + /** * Construct WC AJAX endpoint URL. * @@ -59,3 +76,15 @@ export const shouldUseGooglePayBrand = () => { const isBrave = isChrome && window.navigator.brave; return isChrome && ! isBrave; }; + +/** + * Get error messages from WooCommerce notice from server response. + * + * @param {string} notice Error notice. + * @return {string} Error messages. + */ +export const getErrorMessageFromNotice = ( notice ) => { + const div = document.createElement( 'div' ); + div.innerHTML = notice.trim(); + return div.firstChild ? div.firstChild.textContent : ''; +}; diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php index 0ca27dd3788..fb5fa1633c3 100644 --- a/includes/class-wc-payment-gateway-wcpay.php +++ b/includes/class-wc-payment-gateway-wcpay.php @@ -1063,6 +1063,10 @@ function_exists( 'wcs_order_contains_subscription' ) $factors[] = Factor::PAYMENT_REQUEST(); } + if ( defined( 'WCPAY_EXPRESS_CHECKOUT_CHECKOUT' ) && WCPAY_EXPRESS_CHECKOUT_CHECKOUT ) { + $factors[] = Factor::EXPRESS_CHECKOUT_ELEMENT(); + } + $router = wcpay_get_container()->get( Router::class ); return $router->should_use_new_payment_process( $factors ); } @@ -1794,7 +1798,7 @@ public function process_payment_for_order( $cart, $payment_information, $schedul $payment_method_type = $this->get_payment_method_type_for_setup_intent( $intent, $token ); } - if ( empty( $_POST['payment_request_type'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification + if ( empty( $_POST['payment_request_type'] ) || empty( $_POST['express_payment_type'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification $this->set_payment_method_title_for_order( $order, $payment_method_type, $payment_method_details ); } diff --git a/includes/class-wc-payments.php b/includes/class-wc-payments.php index c459d0cefec..d4716873ccf 100644 --- a/includes/class-wc-payments.php +++ b/includes/class-wc-payments.php @@ -410,6 +410,7 @@ public static function init() { include_once __DIR__ . '/class-wc-payment-token-wcpay-sepa.php'; include_once __DIR__ . '/class-wc-payments-status.php'; include_once __DIR__ . '/class-wc-payments-token-service.php'; + include_once __DIR__ . '/express-checkout/class-wc-payments-express-checkout-ajax-handler.php'; include_once __DIR__ . '/express-checkout/class-wc-payments-express-checkout-button-display-handler.php'; include_once __DIR__ . '/express-checkout/class-wc-payments-express-checkout-button-handler.php'; include_once __DIR__ . '/class-wc-payments-payment-request-button-handler.php'; @@ -1628,10 +1629,12 @@ public static function maybe_enqueue_woopay_common_config_script( $should_enqueu */ public static function maybe_display_express_checkout_buttons() { if ( WC_Payments_Features::are_payments_enabled() ) { - $payment_request_button_handler = new WC_Payments_Payment_Request_Button_Handler( self::$account, self::get_gateway(), self::get_express_checkout_helper() ); - $woopay_button_handler = new WC_Payments_WooPay_Button_Handler( self::$account, self::get_gateway(), self::$woopay_util, self::get_express_checkout_helper() ); - $express_checkout_element_button_handler = new WC_Payments_Express_Checkout_Button_Handler( self::$account, self::get_gateway(), self::get_express_checkout_helper() ); - $express_checkout_button_display_handler = new WC_Payments_Express_Checkout_Button_Display_Handler( self::get_gateway(), $payment_request_button_handler, $woopay_button_handler, $express_checkout_element_button_handler, self::get_express_checkout_helper() ); + $payment_request_button_handler = new WC_Payments_Payment_Request_Button_Handler( self::$account, self::get_gateway(), self::get_express_checkout_helper() ); + $woopay_button_handler = new WC_Payments_WooPay_Button_Handler( self::$account, self::get_gateway(), self::$woopay_util, self::get_express_checkout_helper() ); + + $express_checkout_ajax_handler = new WC_Payments_Express_Checkout_Ajax_Handler( self::get_express_checkout_helper() ); + $express_checkout_element_button_handler = new WC_Payments_Express_Checkout_Button_Handler( self::$account, self::get_gateway(), self::get_express_checkout_helper(), $express_checkout_ajax_handler ); + $express_checkout_button_display_handler = new WC_Payments_Express_Checkout_Button_Display_Handler( self::get_gateway(), $payment_request_button_handler, $woopay_button_handler, $express_checkout_element_button_handler, $express_checkout_ajax_handler, self::get_express_checkout_helper() ); $express_checkout_button_display_handler->init(); } } 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 new file mode 100644 index 00000000000..773f3c5d61b --- /dev/null +++ b/includes/express-checkout/class-wc-payments-express-checkout-ajax-handler.php @@ -0,0 +1,195 @@ +express_checkout_button_helper = $express_checkout_button_helper; + } + + /** + * Initialize hooks. + * + * @return void + */ + public function init() { + add_action( 'wc_ajax_wcpay_create_order', [ $this, 'ajax_create_order' ] ); + add_action( 'wc_ajax_wcpay_get_shipping_options', [ $this, 'ajax_get_shipping_options' ] ); + } + + /** + * Create order. Security is handled by WC. + */ + public function ajax_create_order() { + if ( WC()->cart->is_empty() ) { + wp_send_json_error( __( 'Empty cart', 'woocommerce-payments' ), 400 ); + } + + if ( ! defined( 'WOOCOMMERCE_CHECKOUT' ) ) { + define( 'WOOCOMMERCE_CHECKOUT', true ); + } + + if ( ! defined( 'WCPAY_ECE_CHECKOUT' ) ) { + define( 'WCPAY_ECE_CHECKOUT', true ); + } + + // In case the state is required, but is missing, add a more descriptive error notice. + $this->express_checkout_button_helper->validate_state(); + + $this->express_checkout_button_helper->normalize_state(); + + WC()->checkout()->process_checkout(); + + die( 0 ); + } + + /** + * Get shipping options. + * + * @see WC_Cart::get_shipping_packages(). + * @see WC_Shipping::calculate_shipping(). + * @see WC_Shipping::get_packages(). + */ + public function ajax_get_shipping_options() { + check_ajax_referer( 'wcpay-payment-request-shipping', 'security' ); + + $shipping_address = filter_input_array( + INPUT_POST, + [ + 'country' => FILTER_SANITIZE_SPECIAL_CHARS, + 'state' => FILTER_SANITIZE_SPECIAL_CHARS, + 'postcode' => FILTER_SANITIZE_SPECIAL_CHARS, + 'city' => FILTER_SANITIZE_SPECIAL_CHARS, + 'address_1' => FILTER_SANITIZE_SPECIAL_CHARS, + 'address_2' => FILTER_SANITIZE_SPECIAL_CHARS, + ] + ); + $product_view_options = filter_input_array( INPUT_POST, [ 'is_product_page' => FILTER_SANITIZE_SPECIAL_CHARS ] ); + $should_show_itemized_view = ! isset( $product_view_options['is_product_page'] ) ? true : filter_var( $product_view_options['is_product_page'], FILTER_VALIDATE_BOOLEAN ); + + $data = $this->express_checkout_button_helper->get_shipping_options( $shipping_address, $should_show_itemized_view ); + wp_send_json( $data ); + } + + /** + * Adds the current product to the cart. Used on product detail page. + */ + public function ajax_add_to_cart() { + check_ajax_referer( 'wcpay-add-to-cart', 'security' ); + + if ( ! defined( 'WOOCOMMERCE_CART' ) ) { + define( 'WOOCOMMERCE_CART', true ); + } + + WC()->shipping->reset_shipping(); + + $product_id = isset( $_POST['product_id'] ) ? absint( $_POST['product_id'] ) : false; + $product = wc_get_product( $product_id ); + + if ( ! $product ) { + wp_send_json( + [ + 'error' => [ + 'code' => 'invalid_product_id', + 'message' => __( 'Invalid product id', 'woocommerce-payments' ), + ], + ], + 404 + ); + return; + } + + $quantity = $this->express_checkout_button_helper->get_quantity(); + + $product_type = $product->get_type(); + + $is_add_to_cart_valid = apply_filters( 'woocommerce_add_to_cart_validation', true, $product_id, $quantity ); + + if ( ! $is_add_to_cart_valid ) { + // Some extensions error messages needs to be + // submitted to show error messages. + wp_send_json( + [ + 'error' => true, + 'submit' => true, + ], + 400 + ); + return; + } + + // First empty the cart to prevent wrong calculation. + WC()->cart->empty_cart(); + + if ( ( 'variable' === $product_type || 'variable-subscription' === $product_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 ); + + WC()->cart->add_to_cart( $product->get_id(), $quantity, $variation_id, $attributes ); + } + + if ( in_array( $product_type, [ 'simple', 'variation', 'subscription', 'subscription_variation', 'booking', 'bundle', 'mix-and-match' ], true ) ) { + WC()->cart->add_to_cart( $product->get_id(), $quantity ); + } + + WC()->cart->calculate_totals(); + + if ( 'booking' === $product_type ) { + $booking_id = $this->express_checkout_button_helper->get_booking_id_from_cart(); + } + + $data = []; + $data += $this->express_checkout_button_helper->build_display_items(); + $data['result'] = 'success'; + + if ( ! empty( $booking_id ) ) { + $data['bookingId'] = $booking_id; + } + + wp_send_json( $data ); + } + + /** + * Empties the cart via AJAX. Used on the product page. + */ + public function ajax_empty_cart() { + check_ajax_referer( 'wcpay-empty-cart', 'security' ); + + $booking_id = isset( $_POST['booking_id'] ) ? absint( $_POST['booking_id'] ) : null; + + WC()->cart->empty_cart(); + + if ( $booking_id ) { + // When a bookable product is added to the cart, a 'booking' is create with status 'in-cart'. + // This status is used to prevent the booking from being booked by another customer + // and should be removed when the cart is emptied for PRB purposes. + do_action( 'wc-booking-remove-inactive-cart', $booking_id ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + } + + wp_send_json( [ 'result' => 'success' ] ); + } +} 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 3fee5b1d9bd..ab132195f81 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 @@ -42,6 +42,13 @@ class WC_Payments_Express_Checkout_Button_Display_Handler { */ private $express_checkout_button_handler; + /** + * Express Checkout Helper instance. + * + * @var WC_Payments_Express_Checkout_Ajax_Handler + */ + private $express_checkout_ajax_handler; + /** * Express Checkout Helper instance. * @@ -56,19 +63,22 @@ class WC_Payments_Express_Checkout_Button_Display_Handler { * @param WC_Payments_Payment_Request_Button_Handler $payment_request_button_handler Payment request button handler. * @param WC_Payments_WooPay_Button_Handler $platform_checkout_button_handler Platform checkout button handler. * @param WC_Payments_Express_Checkout_Button_Handler $express_checkout_button_handler Express Checkout Element button handler. - * @param WC_Payments_Express_Checkout_Button_Helper $express_checkout_helper Express checkout helper. + * @param WC_Payments_Express_Checkout_Ajax_Handler $express_checkout_ajax_handler Express checkout ajax handlers. + * @param WC_Payments_Express_Checkout_Button_Helper $express_checkout_helper Express checkout button helper. */ public function __construct( WC_Payment_Gateway_WCPay $gateway, WC_Payments_Payment_Request_Button_Handler $payment_request_button_handler, WC_Payments_WooPay_Button_Handler $platform_checkout_button_handler, WC_Payments_Express_Checkout_Button_Handler $express_checkout_button_handler, + WC_Payments_Express_Checkout_Ajax_Handler $express_checkout_ajax_handler, WC_Payments_Express_Checkout_Button_Helper $express_checkout_helper ) { $this->gateway = $gateway; $this->payment_request_button_handler = $payment_request_button_handler; $this->platform_checkout_button_handler = $platform_checkout_button_handler; $this->express_checkout_button_handler = $express_checkout_button_handler; + $this->express_checkout_ajax_handler = $express_checkout_ajax_handler; $this->express_checkout_helper = $express_checkout_helper; } @@ -86,8 +96,8 @@ public function init() { $is_payment_request_enabled = 'yes' === $this->gateway->get_option( 'payment_request' ); if ( $is_woopay_enabled || $is_payment_request_enabled ) { - add_action( 'wc_ajax_wcpay_add_to_cart', [ $this->express_checkout_helper, 'ajax_add_to_cart' ] ); - add_action( 'wc_ajax_wcpay_empty_cart', [ $this->express_checkout_helper, 'ajax_empty_cart' ] ); + add_action( 'wc_ajax_wcpay_add_to_cart', [ $this->express_checkout_ajax_handler, 'ajax_add_to_cart' ] ); + add_action( 'wc_ajax_wcpay_empty_cart', [ $this->express_checkout_ajax_handler, 'ajax_empty_cart' ] ); add_action( 'woocommerce_after_add_to_cart_form', [ $this, 'display_express_checkout_buttons' ], 1 ); add_action( 'woocommerce_proceed_to_checkout', [ $this, 'display_express_checkout_buttons' ], 21 ); @@ -120,12 +130,14 @@ public function display_express_checkout_separator_if_necessary( $separator_star * @return void */ public function display_express_checkout_buttons() { - $should_show_woopay = $this->platform_checkout_button_handler->should_show_woopay_button(); - $should_show_payment_request = $this->payment_request_button_handler->should_show_payment_request_button(); + $should_show_woopay = $this->platform_checkout_button_handler->should_show_woopay_button(); + $should_show_payment_request = $this->payment_request_button_handler->should_show_payment_request_button(); + $should_show_express_checkout_button = $this->express_checkout_helper->should_show_express_checkout_button(); + // When Payment Request button is enabled, we need the separator markup on the page, but hidden in case the browser doesn't have any payment request methods to display. // More details: https://github.com/Automattic/woocommerce-payments/pull/5399#discussion_r1073633776. - $separator_starts_hidden = $should_show_payment_request && ! $should_show_woopay; - if ( $should_show_woopay || $should_show_payment_request ) { + $separator_starts_hidden = ( $should_show_payment_request || $should_show_express_checkout_button ) && ! $should_show_woopay; + if ( $should_show_woopay || $should_show_payment_request || $should_show_express_checkout_button ) { ?>
account = $account; - $this->gateway = $gateway; - $this->express_checkout_helper = $express_checkout_helper; + public function __construct( WC_Payments_Account $account, WC_Payment_Gateway_WCPay $gateway, WC_Payments_Express_Checkout_Button_Helper $express_checkout_helper, WC_Payments_Express_Checkout_Ajax_Handler $express_checkout_ajax_handler ) { + $this->account = $account; + $this->gateway = $gateway; + $this->express_checkout_helper = $express_checkout_helper; + $this->express_checkout_ajax_handler = $express_checkout_ajax_handler; } /** @@ -81,6 +88,8 @@ public function init() { } add_action( 'wp_enqueue_scripts', [ $this, 'scripts' ] ); + + $this->express_checkout_ajax_handler->init(); } /** @@ -100,159 +109,12 @@ public function get_button_settings() { return array_merge( $common_settings, $payment_request_button_settings ); } - /** - * Checks whether Payment Request Button should be available on this page. - * - * @return bool - */ - public function should_show_express_checkout_button() { - // If account is not connected, then bail. - if ( ! $this->account->is_stripe_connected( false ) ) { - return false; - } - - // If no SSL, bail. - if ( ! WC_Payments::mode()->is_test() && ! is_ssl() ) { - Logger::log( 'Stripe Payment Request live mode requires SSL.' ); - - return false; - } - - // Page not supported. - if ( ! $this->express_checkout_helper->is_product() && ! $this->express_checkout_helper->is_cart() && ! $this->express_checkout_helper->is_checkout() ) { - return false; - } - - // Product page, but not available in settings. - if ( $this->express_checkout_helper->is_product() && ! $this->express_checkout_helper->is_available_at( 'product', self::BUTTON_LOCATIONS ) ) { - return false; - } - - // Checkout page, but not available in settings. - if ( $this->express_checkout_helper->is_checkout() && ! $this->express_checkout_helper->is_available_at( 'checkout', self::BUTTON_LOCATIONS ) ) { - return false; - } - - // Cart page, but not available in settings. - if ( $this->express_checkout_helper->is_cart() && ! $this->express_checkout_helper->is_available_at( 'cart', self::BUTTON_LOCATIONS ) ) { - return false; - } - - // Product page, but has unsupported product type. - if ( $this->express_checkout_helper->is_product() && ! $this->is_product_supported() ) { - Logger::log( 'Product page has unsupported product type ( Payment Request button disabled )' ); - return false; - } - - // Cart has unsupported product type. - if ( ( $this->express_checkout_helper->is_checkout() || $this->express_checkout_helper->is_cart() ) && ! $this->has_allowed_items_in_cart() ) { - Logger::log( 'Items in the cart have unsupported product type ( Payment Request button disabled )' ); - return false; - } - - // Order total doesn't matter for Pay for Order page. Thus, this page should always display payment buttons. - if ( $this->express_checkout_helper->is_pay_for_order_page() ) { - return true; - } - - // Cart total is 0 or is on product page and product price is 0. - // Exclude pay-for-order pages from this check. - if ( - ( ! $this->express_checkout_helper->is_product() && ! $this->express_checkout_helper->is_pay_for_order_page() && 0.0 === (float) WC()->cart->get_total( 'edit' ) ) || - ( $this->express_checkout_helper->is_product() && 0.0 === (float) $this->express_checkout_helper->get_product()->get_price() ) - - ) { - Logger::log( 'Order price is 0 ( Payment Request button disabled )' ); - return false; - } - - return true; - } - - /** - * Checks to make sure product type is supported. - * - * @return array - */ - public function supported_product_types() { - return apply_filters( - 'wcpay_payment_request_supported_types', - [ - 'simple', - 'variable', - 'variation', - 'subscription', - 'variable-subscription', - 'subscription_variation', - 'booking', - 'bundle', - 'composite', - 'mix-and-match', - ] - ); - } - - /** - * Checks the cart to see if all items are allowed to be used. - * - * @return boolean - * - * @psalm-suppress UndefinedClass - */ - public function has_allowed_items_in_cart() { - /** - * Pre Orders compatbility where we don't support charge upon release. - * - * @psalm-suppress UndefinedClass - */ - if ( class_exists( 'WC_Pre_Orders_Cart' ) && WC_Pre_Orders_Cart::cart_contains_pre_order() && class_exists( 'WC_Pre_Orders_Product' ) && WC_Pre_Orders_Product::product_is_charged_upon_release( WC_Pre_Orders_Cart::get_pre_order_product() ) ) { - return false; - } - - foreach ( WC()->cart->get_cart() as $cart_item_key => $cart_item ) { - $_product = apply_filters( 'woocommerce_cart_item_product', $cart_item['data'], $cart_item, $cart_item_key ); - - if ( ! in_array( $_product->get_type(), $this->supported_product_types(), true ) ) { - return false; - } - - /** - * Filter whether product supports Payment Request Button on cart page. - * - * @since 6.9.0 - * - * @param boolean $is_supported Whether product supports Payment Request Button on cart page. - * @param object $_product Product object. - */ - if ( ! apply_filters( 'wcpay_payment_request_is_cart_supported', true, $_product ) ) { - return false; - } - - /** - * Trial subscriptions with shipping are not supported. - * - * @psalm-suppress UndefinedClass - */ - if ( class_exists( 'WC_Subscriptions_Product' ) && WC_Subscriptions_Product::is_subscription( $_product ) && $_product->needs_shipping() && WC_Subscriptions_Product::get_trial_length( $_product ) > 0 ) { - return false; - } - } - - // We don't support multiple packages with Payment Request Buttons because we can't offer a good UX. - $packages = WC()->cart->get_shipping_packages(); - if ( 1 < ( is_countable( $packages ) ? count( $packages ) : 0 ) ) { - return false; - } - - return true; - } - /** * Load public scripts and styles. */ public function scripts() { // Don't load scripts if page is not supported. - if ( ! $this->should_show_express_checkout_button() ) { + if ( ! $this->express_checkout_helper->should_show_express_checkout_button() ) { return; } @@ -288,7 +150,7 @@ public function scripts() { 'button_context' => $this->express_checkout_helper->get_button_context(), 'is_pay_for_order' => $this->express_checkout_helper->is_pay_for_order_page(), 'has_block' => has_block( 'woocommerce/cart' ) || has_block( 'woocommerce/checkout' ), - 'product' => $this->get_product_data(), + 'product' => $this->express_checkout_helper->get_product_data(), 'total_label' => $this->express_checkout_helper->get_total_label(), 'is_checkout_page' => $this->express_checkout_helper->is_checkout(), ]; @@ -320,235 +182,11 @@ public function scripts() { * Display the payment request button. */ public function display_express_checkout_button_html() { - if ( ! $this->should_show_express_checkout_button() ) { + if ( ! $this->express_checkout_helper->should_show_express_checkout_button() ) { return; } ?>
express_checkout_helper->get_product(); - $is_supported = true; - - /** - * Ignore undefined classes from 3rd party plugins. - * - * @psalm-suppress UndefinedClass - */ - if ( is_null( $product ) - || ! is_object( $product ) - || ! in_array( $product->get_type(), $this->supported_product_types(), true ) - || ( class_exists( 'WC_Subscriptions_Product' ) && $product->needs_shipping() && WC_Subscriptions_Product::get_trial_length( $product ) > 0 ) // Trial subscriptions with shipping are not supported. - || ( class_exists( 'WC_Pre_Orders_Product' ) && WC_Pre_Orders_Product::product_is_charged_upon_release( $product ) ) // Pre Orders charge upon release not supported. - || ( class_exists( 'WC_Composite_Products' ) && $product->is_type( 'composite' ) ) // Composite products are not supported on the product page. - || ( class_exists( 'WC_Mix_and_Match' ) && $product->is_type( 'mix-and-match' ) ) // Mix and match products are not supported on the product page. - ) { - $is_supported = false; - } elseif ( class_exists( 'WC_Product_Addons_Helper' ) ) { - // File upload addon not supported. - $product_addons = WC_Product_Addons_Helper::get_product_addons( $product->get_id() ); - foreach ( $product_addons as $addon ) { - if ( 'file_upload' === $addon['type'] ) { - $is_supported = false; - break; - } - } - } - - return apply_filters( 'wcpay_payment_request_is_product_supported', $is_supported, $product ); - } - - /** - * Gets the product data for the currently viewed page. - * - * @return mixed Returns false if not on a product page, the product information otherwise. - */ - public function get_product_data() { - if ( ! $this->express_checkout_helper->is_product() ) { - return false; - } - - /** @var WC_Product_Variable $product */ // phpcs:ignore - $product = $this->express_checkout_helper->get_product(); - $currency = get_woocommerce_currency(); - - if ( 'variable' === $product->get_type() || 'variable-subscription' === $product->get_type() ) { - $variation_attributes = $product->get_variation_attributes(); - $attributes = []; - - foreach ( $variation_attributes as $attribute_name => $attribute_values ) { - $attribute_key = 'attribute_' . sanitize_title( $attribute_name ); - - // Passed value via GET takes precedence. Otherwise get the default value for given attribute. - $attributes[ $attribute_key ] = isset( $_GET[ $attribute_key ] ) // phpcs:ignore WordPress.Security.NonceVerification - ? wc_clean( wp_unslash( $_GET[ $attribute_key ] ) ) // phpcs:ignore WordPress.Security.NonceVerification - : $product->get_variation_default_attribute( $attribute_name ); - } - - $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 ); - } - } - - try { - $price = $this->get_product_price( $product ); - } catch ( Invalid_Price_Exception $e ) { - Logger::log( $e->getMessage() ); - return false; - } - - $data = []; - $items = []; - - $items[] = [ - 'label' => $product->get_name(), - 'amount' => WC_Payments_Utils::prepare_amount( $price, $currency ), - ]; - - $total_tax = 0; - foreach ( $this->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() && 0 !== wc_get_shipping_method_count( true ) && $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' => apply_filters( 'wcpay_payment_request_total_label', $this->express_checkout_helper->get_total_label() ), - 'amount' => WC_Payments_Utils::prepare_amount( $price + $total_tax, $currency ), - 'pending' => true, - ]; - - $data['needs_shipping'] = ( wc_shipping_enabled() && 0 !== wc_get_shipping_method_count( true ) && $product->needs_shipping() ); - $data['currency'] = strtolower( $currency ); - $data['country_code'] = substr( get_option( 'woocommerce_default_country' ), 0, 2 ); - - return apply_filters( 'wcpay_payment_request_product_data', $data, $product ); - } - - /** - * Gets the product total price. - * - * @param object $product WC_Product_* object. - * @param bool $is_deposit Whether customer is paying a deposit. - * @param int $deposit_plan_id The ID of the deposit plan. - * @return mixed Total price. - * - * @throws Invalid_Price_Exception Whenever a product has no price. - * - * @psalm-suppress UndefinedClass - */ - public function get_product_price( $product, ?bool $is_deposit = null, int $deposit_plan_id = 0 ) { - // If prices should include tax, using tax inclusive price. - if ( $this->express_checkout_helper->cart_prices_include_tax() ) { - $base_price = wc_get_price_including_tax( $product ); - } else { - $base_price = wc_get_price_excluding_tax( $product ); - } - - // If WooCommerce Deposits is active, we need to get the correct price for the product. - if ( class_exists( 'WC_Deposits_Product_Manager' ) && WC_Deposits_Product_Manager::deposits_enabled( $product->get_id() ) ) { - if ( is_null( $is_deposit ) ) { - /** - * If is_deposit is null, we use the default deposit type for the product. - * - * @psalm-suppress UndefinedClass - */ - $is_deposit = 'deposit' === WC_Deposits_Product_Manager::get_deposit_selected_type( $product->get_id() ); - } - if ( $is_deposit ) { - /** - * Ignore undefined classes from 3rd party plugins. - * - * @psalm-suppress UndefinedClass - */ - $deposit_type = WC_Deposits_Product_Manager::get_deposit_type( $product->get_id() ); - $available_plan_ids = WC_Deposits_Plans_Manager::get_plan_ids_for_product( $product->get_id() ); - // Default to first (default) plan if no plan is specified. - if ( 'plan' === $deposit_type && 0 === $deposit_plan_id && ! empty( $available_plan_ids ) ) { - $deposit_plan_id = $available_plan_ids[0]; - } - - // Ensure the selected plan is available for the product. - if ( 0 === $deposit_plan_id || in_array( $deposit_plan_id, $available_plan_ids, true ) ) { - $base_price = WC_Deposits_Product_Manager::get_deposit_amount( $product, $deposit_plan_id, 'display', $base_price ); - } - } - } - - // Add subscription sign-up fees to product price. - $sign_up_fee = 0; - $subscription_types = [ - 'subscription', - 'subscription_variation', - ]; - if ( in_array( $product->get_type(), $subscription_types, true ) && class_exists( 'WC_Subscriptions_Product' ) ) { - // When there is no sign-up fee, `get_sign_up_fee` falls back to an int 0. - $sign_up_fee = WC_Subscriptions_Product::get_sign_up_fee( $product ); - } - - if ( ! is_numeric( $base_price ) || ! is_numeric( $sign_up_fee ) ) { - $error_message = sprintf( - // Translators: %d is the numeric ID of the product without a price. - __( 'Express checkout does not support products without prices! Please add a price to product #%d', 'woocommerce-payments' ), - (int) $product->get_id() - ); - throw new Invalid_Price_Exception( - esc_html( $error_message ) - ); - } - - return $base_price + $sign_up_fee; - } - - /** - * Calculates taxes as displayed on cart, based on a product and a particular price. - * - * @param WC_Product $product The product, for retrieval of tax classes. - * @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 ) { - if ( ! wc_tax_enabled() || $this->express_checkout_helper->cart_prices_include_tax() ) { - // Only proceed when taxes are enabled, but not included. - return []; - } - - // Follows the way `WC_Cart_Totals::get_item_tax_rates()` works. - $tax_class = $product->get_tax_class(); - $rates = WC_Tax::get_rates( $tax_class ); - // No cart item, `woocommerce_cart_totals_get_item_tax_rates` can't be applied here. - - // Normally there should be a single tax, but `calc_tax` returns an array, let's use it. - return WC_Tax::calc_tax( $price, $rates, false ); - } } \ No newline at end of file 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 75495a3b990..266e69e6f6d 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 @@ -7,6 +7,10 @@ defined( 'ABSPATH' ) || exit; +use WCPay\Constants\Country_Code; +use WCPay\Exceptions\Invalid_Price_Exception; +use WCPay\Logger; + /** * Express Checkout Button Helper class. */ @@ -36,86 +40,6 @@ public function __construct( WC_Payment_Gateway_WCPay $gateway, WC_Payments_Acco $this->account = $account; } - /** - * Adds the current product to the cart. Used on product detail page. - */ - public function ajax_add_to_cart() { - check_ajax_referer( 'wcpay-add-to-cart', 'security' ); - - if ( ! defined( 'WOOCOMMERCE_CART' ) ) { - define( 'WOOCOMMERCE_CART', true ); - } - - WC()->shipping->reset_shipping(); - - $product_id = isset( $_POST['product_id'] ) ? absint( $_POST['product_id'] ) : false; - $product = wc_get_product( $product_id ); - - if ( ! $product ) { - wp_send_json( - [ - 'error' => [ - 'code' => 'invalid_product_id', - 'message' => __( 'Invalid product id', 'woocommerce-payments' ), - ], - ], - 404 - ); - return; - } - - $quantity = $this->get_quantity(); - - $product_type = $product->get_type(); - - $is_add_to_cart_valid = apply_filters( 'woocommerce_add_to_cart_validation', true, $product_id, $quantity ); - - if ( ! $is_add_to_cart_valid ) { - // Some extensions error messages needs to be - // submitted to show error messages. - wp_send_json( - [ - 'error' => true, - 'submit' => true, - ], - 400 - ); - return; - } - - // First empty the cart to prevent wrong calculation. - WC()->cart->empty_cart(); - - if ( ( 'variable' === $product_type || 'variable-subscription' === $product_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 ); - - WC()->cart->add_to_cart( $product->get_id(), $quantity, $variation_id, $attributes ); - } - - if ( in_array( $product_type, [ 'simple', 'variation', 'subscription', 'subscription_variation', 'booking', 'bundle', 'mix-and-match' ], true ) ) { - WC()->cart->add_to_cart( $product->get_id(), $quantity ); - } - - WC()->cart->calculate_totals(); - - if ( 'booking' === $product_type ) { - $booking_id = $this->get_booking_id_from_cart(); - } - - $data = []; - $data += $this->build_display_items(); - $data['result'] = 'success'; - - if ( ! empty( $booking_id ) ) { - $data['bookingId'] = $booking_id; - } - - wp_send_json( $data ); - } - /** * Gets the booking id from the cart. * It's expected that the cart only contains one item which was added via ajax_add_to_cart. @@ -134,26 +58,6 @@ public function get_booking_id_from_cart() { return false; } - /** - * Empties the cart via AJAX. Used on the product page. - */ - public function ajax_empty_cart() { - check_ajax_referer( 'wcpay-empty-cart', 'security' ); - - $booking_id = isset( $_POST['booking_id'] ) ? absint( $_POST['booking_id'] ) : null; - - WC()->cart->empty_cart(); - - if ( $booking_id ) { - // When a bookable product is added to the cart, a 'booking' is create with status 'in-cart'. - // This status is used to prevent the booking from being booked by another customer - // and should be removed when the cart is emptied for PRB purposes. - do_action( 'wc-booking-remove-inactive-cart', $booking_id ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores - } - - wp_send_json( [ 'result' => 'success' ] ); - } - /** * Builds the line items to pass to Payment Request * @@ -277,7 +181,7 @@ public function get_total_label() { * * @return int */ - private function get_quantity() { + public function get_quantity() { // Payment Request Button sends the quantity as qty. WooPay sends it as quantity. if ( isset( $_POST['quantity'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing return absint( $_POST['quantity'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing @@ -433,4 +337,757 @@ public function is_product_subscription( WC_Product $product ): bool { || 'subscription_variation' === $product->get_type() || 'variable-subscription' === $product->get_type(); } + + /** + * Checks whether Payment Request Button should be available on this page. + * + * @return bool + */ + public function should_show_express_checkout_button() { + // If account is not connected, then bail. + if ( ! $this->account->is_stripe_connected( false ) ) { + return false; + } + + // If no SSL, bail. + if ( ! WC_Payments::mode()->is_test() && ! is_ssl() ) { + Logger::log( 'Stripe Payment Request live mode requires SSL.' ); + + return false; + } + + // Page not supported. + if ( ! $this->is_product() && ! $this->is_cart() && ! $this->is_checkout() ) { + return false; + } + + // Product page, but not available in settings. + if ( $this->is_product() && ! $this->is_available_at( 'product', WC_Payments_Express_Checkout_Button_Handler::BUTTON_LOCATIONS ) ) { + return false; + } + + // Checkout page, but not available in settings. + if ( $this->is_checkout() && ! $this->is_available_at( 'checkout', WC_Payments_Express_Checkout_Button_Handler::BUTTON_LOCATIONS ) ) { + return false; + } + + // Cart page, but not available in settings. + if ( $this->is_cart() && ! $this->is_available_at( 'cart', WC_Payments_Express_Checkout_Button_Handler::BUTTON_LOCATIONS ) ) { + return false; + } + + // Product page, but has unsupported product type. + if ( $this->is_product() && ! $this->is_product_supported() ) { + Logger::log( 'Product page has unsupported product type ( Payment Request button disabled )' ); + return false; + } + + // Cart has unsupported product type. + if ( ( $this->is_checkout() || $this->is_cart() ) && ! $this->has_allowed_items_in_cart() ) { + Logger::log( 'Items in the cart have unsupported product type ( Payment Request button disabled )' ); + return false; + } + + // Order total doesn't matter for Pay for Order page. Thus, this page should always display payment buttons. + if ( $this->is_pay_for_order_page() ) { + return true; + } + + // Cart total is 0 or is on product page and product price is 0. + // Exclude pay-for-order pages from this check. + if ( + ( ! $this->is_product() && ! $this->is_pay_for_order_page() && 0.0 === (float) WC()->cart->get_total( 'edit' ) ) || + ( $this->is_product() && 0.0 === (float) $this->get_product()->get_price() ) + + ) { + Logger::log( 'Order price is 0 ( Payment Request button disabled )' ); + return false; + } + + return true; + } + + /** + * Checks to make sure product type is supported. + * + * @return array + */ + public function supported_product_types() { + return apply_filters( + 'wcpay_payment_request_supported_types', + [ + 'simple', + 'variable', + 'variation', + 'subscription', + 'variable-subscription', + 'subscription_variation', + 'booking', + 'bundle', + 'composite', + 'mix-and-match', + ] + ); + } + + /** + * Checks the cart to see if all items are allowed to be used. + * + * @return boolean + * + * @psalm-suppress UndefinedClass + */ + public function has_allowed_items_in_cart() { + /** + * Pre Orders compatbility where we don't support charge upon release. + * + * @psalm-suppress UndefinedClass + */ + if ( class_exists( 'WC_Pre_Orders_Cart' ) && WC_Pre_Orders_Cart::cart_contains_pre_order() && class_exists( 'WC_Pre_Orders_Product' ) && WC_Pre_Orders_Product::product_is_charged_upon_release( WC_Pre_Orders_Cart::get_pre_order_product() ) ) { + return false; + } + + foreach ( WC()->cart->get_cart() as $cart_item_key => $cart_item ) { + $_product = apply_filters( 'woocommerce_cart_item_product', $cart_item['data'], $cart_item, $cart_item_key ); + + if ( ! in_array( $_product->get_type(), $this->supported_product_types(), true ) ) { + return false; + } + + /** + * Filter whether product supports Payment Request Button on cart page. + * + * @since 6.9.0 + * + * @param boolean $is_supported Whether product supports Payment Request Button on cart page. + * @param object $_product Product object. + */ + if ( ! apply_filters( 'wcpay_payment_request_is_cart_supported', true, $_product ) ) { + return false; + } + + /** + * Trial subscriptions with shipping are not supported. + * + * @psalm-suppress UndefinedClass + */ + if ( class_exists( 'WC_Subscriptions_Product' ) && WC_Subscriptions_Product::is_subscription( $_product ) && $_product->needs_shipping() && WC_Subscriptions_Product::get_trial_length( $_product ) > 0 ) { + return false; + } + } + + // We don't support multiple packages with Payment Request Buttons because we can't offer a good UX. + $packages = WC()->cart->get_shipping_packages(); + if ( 1 < ( is_countable( $packages ) ? count( $packages ) : 0 ) ) { + return false; + } + + return true; + } + + /** + * Gets shipping options available for specified shipping address + * + * @param array $shipping_address Shipping address. + * @param boolean $itemized_display_items Indicates whether to show subtotals or itemized views. + * + * @return array Shipping options data. + * + * phpcs:ignore Squiz.Commenting.FunctionCommentThrowTag + */ + public function get_shipping_options( $shipping_address, $itemized_display_items = false ) { + try { + // Set the shipping options. + $data = []; + + // Remember current shipping method before resetting. + $chosen_shipping_methods = WC()->session->get( 'chosen_shipping_methods', [] ); + $this->calculate_shipping( apply_filters( 'wcpay_payment_request_shipping_posted_values', $shipping_address ) ); + + $packages = WC()->shipping->get_packages(); + + if ( ! empty( $packages ) && WC()->customer->has_calculated_shipping() ) { + foreach ( $packages as $package_key => $package ) { + if ( empty( $package['rates'] ) ) { + throw new Exception( __( 'Unable to find shipping method for address.', 'woocommerce-payments' ) ); + } + + foreach ( $package['rates'] as $key => $rate ) { + $data['shipping_options'][] = [ + 'id' => $rate->id, + 'displayName' => $rate->label, + 'amount' => WC_Payments_Utils::prepare_amount( $rate->cost, get_woocommerce_currency() ), + ]; + } + } + } else { + throw new Exception( __( 'Unable to find shipping method for address.', 'woocommerce-payments' ) ); + } + + // The first shipping option is automatically applied on the client. + // Keep chosen shipping method by sorting shipping options if the method still available for new address. + // Fallback to the first available shipping method. + if ( isset( $data['shipping_options'][0] ) ) { + if ( isset( $chosen_shipping_methods[0] ) ) { + $chosen_method_id = $chosen_shipping_methods[0]; + $compare_shipping_options = function ( $a, $b ) use ( $chosen_method_id ) { + if ( $a['id'] === $chosen_method_id ) { + return -1; + } + + if ( $b['id'] === $chosen_method_id ) { + return 1; + } + + return 0; + }; + usort( $data['shipping_options'], $compare_shipping_options ); + } + + $first_shipping_method_id = $data['shipping_options'][0]['id']; + $this->update_shipping_method( [ $first_shipping_method_id ] ); + } + + WC()->cart->calculate_totals(); + + $this->maybe_restore_recurring_chosen_shipping_methods( $chosen_shipping_methods ); + + $data += $this->build_display_items( $itemized_display_items ); + $data['result'] = 'success'; + } catch ( Exception $e ) { + $data += $this->build_display_items( $itemized_display_items ); + $data['result'] = 'invalid_shipping_address'; + } + + return $data; + } + + /** + * Restores the shipping methods previously chosen for each recurring cart after shipping was reset and recalculated + * during the Payment Request get_shipping_options flow. + * + * When the cart contains multiple subscriptions with different billing periods, customers are able to select different shipping + * methods for each subscription, however, this is not supported when purchasing with Apple Pay and Google Pay as it's + * only concerned about handling the initial purchase. + * + * In order to avoid Woo Subscriptions's `WC_Subscriptions_Cart::validate_recurring_shipping_methods` throwing an error, we need to restore + * the previously chosen shipping methods for each recurring cart. + * + * This function needs to be called after `WC()->cart->calculate_totals()` is run, otherwise `WC()->cart->recurring_carts` won't exist yet. + * + * @param array $previous_chosen_methods The previously chosen shipping methods. + */ + private function maybe_restore_recurring_chosen_shipping_methods( $previous_chosen_methods = [] ) { + if ( empty( WC()->cart->recurring_carts ) || ! method_exists( 'WC_Subscriptions_Cart', 'get_recurring_shipping_package_key' ) ) { + return; + } + + $chosen_shipping_methods = WC()->session->get( 'chosen_shipping_methods', [] ); + + foreach ( WC()->cart->recurring_carts as $recurring_cart_key => $recurring_cart ) { + foreach ( $recurring_cart->get_shipping_packages() as $recurring_cart_package_index => $recurring_cart_package ) { + // phpcs:ignore + /** + * @psalm-suppress UndefinedClass + */ + $package_key = WC_Subscriptions_Cart::get_recurring_shipping_package_key( $recurring_cart_key, $recurring_cart_package_index ); + + // If the recurring cart package key is found in the previous chosen methods, but not in the current chosen methods, restore it. + if ( isset( $previous_chosen_methods[ $package_key ] ) && ! isset( $chosen_shipping_methods[ $package_key ] ) ) { + $chosen_shipping_methods[ $package_key ] = $previous_chosen_methods[ $package_key ]; + } + } + } + + WC()->session->set( 'chosen_shipping_methods', $chosen_shipping_methods ); + } + + /** + * Gets the product data for the currently viewed page. + * + * @return mixed Returns false if not on a product page, the product information otherwise. + */ + public function get_product_data() { + if ( ! $this->is_product() ) { + return false; + } + + /** @var WC_Product_Variable $product */ // phpcs:ignore + $product = $this->get_product(); + $currency = get_woocommerce_currency(); + + if ( 'variable' === $product->get_type() || 'variable-subscription' === $product->get_type() ) { + $variation_attributes = $product->get_variation_attributes(); + $attributes = []; + + foreach ( $variation_attributes as $attribute_name => $attribute_values ) { + $attribute_key = 'attribute_' . sanitize_title( $attribute_name ); + + // Passed value via GET takes precedence. Otherwise get the default value for given attribute. + $attributes[ $attribute_key ] = isset( $_GET[ $attribute_key ] ) // phpcs:ignore WordPress.Security.NonceVerification + ? wc_clean( wp_unslash( $_GET[ $attribute_key ] ) ) // phpcs:ignore WordPress.Security.NonceVerification + : $product->get_variation_default_attribute( $attribute_name ); + } + + $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 ); + } + } + + try { + $price = $this->get_product_price( $product ); + } catch ( Invalid_Price_Exception $e ) { + Logger::log( $e->getMessage() ); + return false; + } + + $data = []; + $items = []; + + $items[] = [ + 'label' => $product->get_name(), + 'amount' => WC_Payments_Utils::prepare_amount( $price, $currency ), + ]; + + $total_tax = 0; + foreach ( $this->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() && 0 !== wc_get_shipping_method_count( true ) && $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' => apply_filters( 'wcpay_payment_request_total_label', $this->get_total_label() ), + 'amount' => WC_Payments_Utils::prepare_amount( $price + $total_tax, $currency ), + 'pending' => true, + ]; + + $data['needs_shipping'] = ( wc_shipping_enabled() && 0 !== wc_get_shipping_method_count( true ) && $product->needs_shipping() ); + $data['currency'] = strtolower( $currency ); + $data['country_code'] = substr( get_option( 'woocommerce_default_country' ), 0, 2 ); + + return apply_filters( 'wcpay_payment_request_product_data', $data, $product ); + } + + /** + * Whether product page has a supported product. + * + * @return boolean + */ + private function is_product_supported() { + $product = $this->get_product(); + $is_supported = true; + + /** + * Ignore undefined classes from 3rd party plugins. + * + * @psalm-suppress UndefinedClass + */ + if ( is_null( $product ) + || ! is_object( $product ) + || ! in_array( $product->get_type(), $this->supported_product_types(), true ) + || ( class_exists( 'WC_Subscriptions_Product' ) && $product->needs_shipping() && WC_Subscriptions_Product::get_trial_length( $product ) > 0 ) // Trial subscriptions with shipping are not supported. + || ( class_exists( 'WC_Pre_Orders_Product' ) && WC_Pre_Orders_Product::product_is_charged_upon_release( $product ) ) // Pre Orders charge upon release not supported. + || ( class_exists( 'WC_Composite_Products' ) && $product->is_type( 'composite' ) ) // Composite products are not supported on the product page. + || ( class_exists( 'WC_Mix_and_Match' ) && $product->is_type( 'mix-and-match' ) ) // Mix and match products are not supported on the product page. + ) { + $is_supported = false; + } elseif ( class_exists( 'WC_Product_Addons_Helper' ) ) { + // File upload addon not supported. + $product_addons = WC_Product_Addons_Helper::get_product_addons( $product->get_id() ); + foreach ( $product_addons as $addon ) { + if ( 'file_upload' === $addon['type'] ) { + $is_supported = false; + break; + } + } + } + + return apply_filters( 'wcpay_payment_request_is_product_supported', $is_supported, $product ); + } + + /** + * Gets the product total price. + * + * @param object $product WC_Product_* object. + * @param bool $is_deposit Whether customer is paying a deposit. + * @param int $deposit_plan_id The ID of the deposit plan. + * @return mixed Total price. + * + * @throws Invalid_Price_Exception Whenever a product has no price. + * + * @psalm-suppress UndefinedClass + */ + public function get_product_price( $product, ?bool $is_deposit = null, int $deposit_plan_id = 0 ) { + // If prices should include tax, using tax inclusive price. + if ( $this->cart_prices_include_tax() ) { + $base_price = wc_get_price_including_tax( $product ); + } else { + $base_price = wc_get_price_excluding_tax( $product ); + } + + // If WooCommerce Deposits is active, we need to get the correct price for the product. + if ( class_exists( 'WC_Deposits_Product_Manager' ) && WC_Deposits_Product_Manager::deposits_enabled( $product->get_id() ) ) { + if ( is_null( $is_deposit ) ) { + /** + * If is_deposit is null, we use the default deposit type for the product. + * + * @psalm-suppress UndefinedClass + */ + $is_deposit = 'deposit' === WC_Deposits_Product_Manager::get_deposit_selected_type( $product->get_id() ); + } + if ( $is_deposit ) { + /** + * Ignore undefined classes from 3rd party plugins. + * + * @psalm-suppress UndefinedClass + */ + $deposit_type = WC_Deposits_Product_Manager::get_deposit_type( $product->get_id() ); + $available_plan_ids = WC_Deposits_Plans_Manager::get_plan_ids_for_product( $product->get_id() ); + // Default to first (default) plan if no plan is specified. + if ( 'plan' === $deposit_type && 0 === $deposit_plan_id && ! empty( $available_plan_ids ) ) { + $deposit_plan_id = $available_plan_ids[0]; + } + + // Ensure the selected plan is available for the product. + if ( 0 === $deposit_plan_id || in_array( $deposit_plan_id, $available_plan_ids, true ) ) { + $base_price = WC_Deposits_Product_Manager::get_deposit_amount( $product, $deposit_plan_id, 'display', $base_price ); + } + } + } + + // Add subscription sign-up fees to product price. + $sign_up_fee = 0; + $subscription_types = [ + 'subscription', + 'subscription_variation', + ]; + if ( in_array( $product->get_type(), $subscription_types, true ) && class_exists( 'WC_Subscriptions_Product' ) ) { + // When there is no sign-up fee, `get_sign_up_fee` falls back to an int 0. + $sign_up_fee = WC_Subscriptions_Product::get_sign_up_fee( $product ); + } + + if ( ! is_numeric( $base_price ) || ! is_numeric( $sign_up_fee ) ) { + $error_message = sprintf( + // Translators: %d is the numeric ID of the product without a price. + __( 'Express checkout does not support products without prices! Please add a price to product #%d', 'woocommerce-payments' ), + (int) $product->get_id() + ); + throw new Invalid_Price_Exception( + esc_html( $error_message ) + ); + } + + return $base_price + $sign_up_fee; + } + + /** + * Calculates taxes as displayed on cart, based on a product and a particular price. + * + * @param WC_Product $product The product, for retrieval of tax classes. + * @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 ) { + if ( ! wc_tax_enabled() || $this->cart_prices_include_tax() ) { + // Only proceed when taxes are enabled, but not included. + return []; + } + + // Follows the way `WC_Cart_Totals::get_item_tax_rates()` works. + $tax_class = $product->get_tax_class(); + $rates = WC_Tax::get_rates( $tax_class ); + // No cart item, `woocommerce_cart_totals_get_item_tax_rates` can't be applied here. + + // Normally there should be a single tax, but `calc_tax` returns an array, let's use it. + return WC_Tax::calc_tax( $price, $rates, false ); + } + + /** + * Gets the normalized state/county field because in some + * cases, the state/county field is formatted differently from + * what WC is expecting and throws an error. An example + * for Ireland, the county dropdown in Chrome shows "Co. Clare" format. + * + * @param string $state Full state name or an already normalized abbreviation. + * @param string $country Two-letter country code. + * + * @return string Normalized state abbreviation. + */ + public function get_normalized_state( $state, $country ) { + // If it's empty or already normalized, skip. + if ( ! $state || $this->is_normalized_state( $state, $country ) ) { + return $state; + } + + // Try to match state from the Payment Request API list of states. + $state = $this->get_normalized_state_from_pr_states( $state, $country ); + + // If it's normalized, return. + if ( $this->is_normalized_state( $state, $country ) ) { + return $state; + } + + // If the above doesn't work, fallback to matching against the list of translated + // states from WooCommerce. + return $this->get_normalized_state_from_wc_states( $state, $country ); + } + + /** + * The Payment Request API provides its own validation for the address form. + * For some countries, it might not provide a state field, so we need to return a more descriptive + * error message, indicating that the Payment Request button is not supported for that country. + */ + public static function validate_state() { + $wc_checkout = WC_Checkout::instance(); + $posted_data = $wc_checkout->get_posted_data(); + $checkout_fields = $wc_checkout->get_checkout_fields(); + $countries = WC()->countries->get_countries(); + + $is_supported = true; + // Checks if billing state is missing and is required. + if ( ! empty( $checkout_fields['billing']['billing_state']['required'] ) && '' === $posted_data['billing_state'] ) { + $is_supported = false; + } + + // Checks if shipping state is missing and is required. + if ( WC()->cart->needs_shipping_address() && ! empty( $checkout_fields['shipping']['shipping_state']['required'] ) && '' === $posted_data['shipping_state'] ) { + $is_supported = false; + } + + if ( ! $is_supported ) { + wc_add_notice( + sprintf( + /* translators: %s: country. */ + __( 'The payment request button is not supported in %s because some required fields couldn\'t be verified. Please proceed to the checkout page and try again.', 'woocommerce-payments' ), + $countries[ $posted_data['billing_country'] ] ?? $posted_data['billing_country'] + ), + 'error' + ); + } + } + + /** + * Normalizes billing and shipping state fields. + */ + public function normalize_state() { + check_ajax_referer( 'woocommerce-process_checkout', '_wpnonce' ); + + $billing_country = ! empty( $_POST['billing_country'] ) ? wc_clean( wp_unslash( $_POST['billing_country'] ) ) : ''; + $shipping_country = ! empty( $_POST['shipping_country'] ) ? wc_clean( wp_unslash( $_POST['shipping_country'] ) ) : ''; + $billing_state = ! empty( $_POST['billing_state'] ) ? wc_clean( wp_unslash( $_POST['billing_state'] ) ) : ''; + $shipping_state = ! empty( $_POST['shipping_state'] ) ? wc_clean( wp_unslash( $_POST['shipping_state'] ) ) : ''; + + if ( $billing_state && $billing_country ) { + $_POST['billing_state'] = $this->get_normalized_state( $billing_state, $billing_country ); + } + + if ( $shipping_state && $shipping_country ) { + $_POST['shipping_state'] = $this->get_normalized_state( $shipping_state, $shipping_country ); + } + } + + /** + * Checks if given state is normalized. + * + * @param string $state State. + * @param string $country Two-letter country code. + * + * @return bool Whether state is normalized or not. + */ + public function is_normalized_state( $state, $country ) { + $wc_states = WC()->countries->get_states( $country ); + return is_array( $wc_states ) && array_key_exists( $state, $wc_states ); + } + + /** + * Get normalized state from Payment Request API dropdown list of states. + * + * @param string $state Full state name or state code. + * @param string $country Two-letter country code. + * + * @return string Normalized state or original state input value. + */ + public function get_normalized_state_from_pr_states( $state, $country ) { + // Include Payment Request API State list for compatibility with WC countries/states. + include_once WCPAY_ABSPATH . 'includes/constants/class-payment-request-button-states.php'; + $pr_states = \WCPay\Constants\Payment_Request_Button_States::STATES; + + if ( ! isset( $pr_states[ $country ] ) ) { + return $state; + } + + foreach ( $pr_states[ $country ] as $wc_state_abbr => $pr_state ) { + $sanitized_state_string = $this->sanitize_string( $state ); + // Checks if input state matches with Payment Request state code (0), name (1) or localName (2). + if ( + ( ! empty( $pr_state[0] ) && $sanitized_state_string === $this->sanitize_string( $pr_state[0] ) ) || + ( ! empty( $pr_state[1] ) && $sanitized_state_string === $this->sanitize_string( $pr_state[1] ) ) || + ( ! empty( $pr_state[2] ) && $sanitized_state_string === $this->sanitize_string( $pr_state[2] ) ) + ) { + return $wc_state_abbr; + } + } + + return $state; + } + + /** + * Get normalized state from WooCommerce list of translated states. + * + * @param string $state Full state name or state code. + * @param string $country Two-letter country code. + * + * @return string Normalized state or original state input value. + */ + public function get_normalized_state_from_wc_states( $state, $country ) { + $wc_states = WC()->countries->get_states( $country ); + + if ( is_array( $wc_states ) ) { + foreach ( $wc_states as $wc_state_abbr => $wc_state_value ) { + if ( preg_match( '/' . preg_quote( $wc_state_value, '/' ) . '/i', $state ) ) { + return $wc_state_abbr; + } + } + } + + return $state; + } + + /** + * Normalizes postal code in case of redacted data from Apple Pay. + * + * @param string $postcode Postal code. + * @param string $country Country. + */ + public function get_normalized_postal_code( $postcode, $country ) { + /** + * Currently, Apple Pay truncates the UK and Canadian postal codes to the first 4 and 3 characters respectively + * when passing it back from the shippingcontactselected object. This causes WC to invalidate + * the postal code and not calculate shipping zones correctly. + */ + if ( Country_Code::UNITED_KINGDOM === $country ) { + // Replaces a redacted string with something like N1C0000. + return str_pad( preg_replace( '/\s+/', '', $postcode ), 7, '0' ); + } + if ( Country_Code::CANADA === $country ) { + // Replaces a redacted string with something like H3B000. + return str_pad( preg_replace( '/\s+/', '', $postcode ), 6, '0' ); + } + + return $postcode; + } + + /** + * Sanitize string for comparison. + * + * @param string $string String to be sanitized. + * + * @return string The sanitized string. + */ + public function sanitize_string( $string ) { + return trim( wc_strtolower( remove_accents( $string ) ) ); + } + + /** + * Updates shipping method in WC session + * + * @param array $shipping_methods Array of selected shipping methods ids. + */ + public function update_shipping_method( $shipping_methods ) { + $chosen_shipping_methods = (array) WC()->session->get( 'chosen_shipping_methods' ); + + if ( is_array( $shipping_methods ) ) { + foreach ( $shipping_methods as $i => $value ) { + $chosen_shipping_methods[ $i ] = wc_clean( $value ); + } + } + + WC()->session->set( 'chosen_shipping_methods', $chosen_shipping_methods ); + } + + /** + * Calculate and set shipping method. + * + * @param array $address Shipping address. + */ + protected function calculate_shipping( $address = [] ) { + $country = $address['country']; + $state = $address['state']; + $postcode = $address['postcode']; + $city = $address['city']; + $address_1 = $address['address_1']; + $address_2 = $address['address_2']; + + // Normalizes state to calculate shipping zones. + $state = $this->get_normalized_state( $state, $country ); + + // Normalizes postal code in case of redacted data from Apple Pay. + $postcode = $this->get_normalized_postal_code( $postcode, $country ); + + WC()->shipping->reset_shipping(); + + if ( $postcode && WC_Validation::is_postcode( $postcode, $country ) ) { + $postcode = wc_format_postcode( $postcode, $country ); + } + + if ( $country ) { + WC()->customer->set_location( $country, $state, $postcode, $city ); + WC()->customer->set_shipping_location( $country, $state, $postcode, $city ); + } else { + WC()->customer->set_billing_address_to_base(); + WC()->customer->set_shipping_address_to_base(); + } + + WC()->customer->set_calculated_shipping( true ); + WC()->customer->save(); + + $packages = []; + + $packages[0]['contents'] = WC()->cart->get_cart(); + $packages[0]['contents_cost'] = 0; + $packages[0]['applied_coupons'] = WC()->cart->applied_coupons; + $packages[0]['user']['ID'] = get_current_user_id(); + $packages[0]['destination']['country'] = $country; + $packages[0]['destination']['state'] = $state; + $packages[0]['destination']['postcode'] = $postcode; + $packages[0]['destination']['city'] = $city; + $packages[0]['destination']['address'] = $address_1; + $packages[0]['destination']['address_2'] = $address_2; + + foreach ( WC()->cart->get_cart() as $item ) { + if ( $item['data']->needs_shipping() ) { + if ( isset( $item['line_total'] ) ) { + $packages[0]['contents_cost'] += $item['line_total']; + } + } + } + + $packages = apply_filters( 'woocommerce_cart_shipping_packages', $packages ); + + WC()->shipping->calculate_shipping( $packages ); + } } diff --git a/includes/woopay/services/class-checkout-service.php b/includes/woopay/services/class-checkout-service.php index f10ca7bad7b..b776d505f27 100644 --- a/includes/woopay/services/class-checkout-service.php +++ b/includes/woopay/services/class-checkout-service.php @@ -64,7 +64,7 @@ public function create_and_confirm_setup_intention_request( Request $base_reques */ public function is_platform_payment_method( Payment_Information $payment_information ) { // Return false for express checkout method. - if ( isset( $_POST['payment_request_type'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification + if ( isset( $_POST['payment_request_type'] ) || isset( $_POST['express_payment_type'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification return false; } diff --git a/src/Internal/Payment/Factor.php b/src/Internal/Payment/Factor.php index 3a63e895738..957c131fd7a 100644 --- a/src/Internal/Payment/Factor.php +++ b/src/Internal/Payment/Factor.php @@ -100,6 +100,12 @@ class Factor extends Base_Constant { */ const PAYMENT_REQUEST = 'PAYMENT_REQUEST'; + /** + * ECE buttons (Google Pay and Apple Pay) + * Type: Entry point + */ + const EXPRESS_CHECKOUT_ELEMENT = 'EXPRESS_CHECKOUT_ELEMENT'; + /** * Returns all possible factors. * @@ -121,6 +127,7 @@ public static function get_all_factors() { static::IPP_CAPTURE(), static::STRIPE_LINK(), static::PAYMENT_REQUEST(), + static::EXPRESS_CHECKOUT_ELEMENT(), ]; } } diff --git a/tests/unit/src/Internal/Payment/FactorTest.php b/tests/unit/src/Internal/Payment/FactorTest.php index e1e1e14ba71..c570d6dcfe0 100644 --- a/tests/unit/src/Internal/Payment/FactorTest.php +++ b/tests/unit/src/Internal/Payment/FactorTest.php @@ -36,6 +36,7 @@ public function test_get_all_factors() { 'IPP_CAPTURE', 'STRIPE_LINK', 'PAYMENT_REQUEST', + 'EXPRESS_CHECKOUT_ELEMENT', ]; $result = Factor::get_all_factors(); diff --git a/tests/unit/test-class-wc-payments-express-checkout-button-display-handler.php b/tests/unit/test-class-wc-payments-express-checkout-button-display-handler.php index 40329be06fd..2fc3948b716 100644 --- a/tests/unit/test-class-wc-payments-express-checkout-button-display-handler.php +++ b/tests/unit/test-class-wc-payments-express-checkout-button-display-handler.php @@ -79,6 +79,13 @@ class WC_Payments_Express_Checkout_Button_Display_Handler_Test extends WCPAY_Uni */ private $mock_express_checkout_helper; + /** + * Express Checkout Ajax Handler instance. + * + * @var WC_Payments_Express_Checkout_Ajax_Handler + */ + private $mock_express_checkout_ajax_handler; + /** * Sets up things all tests need. */ @@ -117,6 +124,14 @@ public function set_up() { ) ->getMock(); + $this->mock_express_checkout_ajax_handler = $this->getMockBuilder( WC_Payments_Express_Checkout_Ajax_Handler::class ) + ->setConstructorArgs( + [ + $this->mock_express_checkout_helper, + ] + ) + ->getMock(); + $this->mock_woopay_button_handler = $this->getMockBuilder( WC_Payments_WooPay_Button_Handler::class ) ->setConstructorArgs( [ @@ -156,6 +171,7 @@ public function set_up() { $this->mock_wcpay_account, $this->mock_wcpay_gateway, $this->mock_express_checkout_helper, + $this->mock_express_checkout_ajax_handler, ] ) ->setMethods( @@ -165,14 +181,15 @@ public function set_up() { ) ->getMock(); - $this->express_checkout_button_display_handler = new WC_Payments_Express_Checkout_Button_Display_Handler( - $this->mock_wcpay_gateway, - $this->mock_payment_request_button_handler, - $this->mock_woopay_button_handler, - $this->mock_express_checkout_ece_button_handler, - $this->mock_express_checkout_helper - ); - $this->express_checkout_button_display_handler->init(); + $this->express_checkout_button_display_handler = new WC_Payments_Express_Checkout_Button_Display_Handler( + $this->mock_wcpay_gateway, + $this->mock_payment_request_button_handler, + $this->mock_woopay_button_handler, + $this->mock_express_checkout_ece_button_handler, + $this->mock_express_checkout_ajax_handler, + $this->mock_express_checkout_helper + ); + $this->express_checkout_button_display_handler->init(); add_filter( 'woocommerce_available_payment_gateways',