diff --git a/.eslintrc.js b/.eslintrc.js index b86ff0ce9..64a543847 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -18,7 +18,6 @@ module.exports = { node: true, }, rules: { - 'require-await': 'error', 'react-hooks/exhaustive-deps': 'error', 'react-hooks/rules-of-hooks': 'error', 'react/jsx-curly-brace-presence': [ diff --git a/changelog.txt b/changelog.txt index 0d018860b..5af01297e 100644 --- a/changelog.txt +++ b/changelog.txt @@ -8,6 +8,7 @@ * Add - Implemented the "Update all subscriptions payment methods" checkbox on My Account → Payment methods for UPE payment methods. * Add - Add support for the new Stripe Checkout Element on the shortcode checkout page. * Add - Add support for the new Stripe Checkout Element on the pay for order page. +* Add - Add support for the new Stripe Checkout Element on the product page. * Dev - Introduces a new class with payment methods constants. * Dev - Introduces a new class with currency codes constants. * Dev - Improves the readability of the redirect URL generation code (UPE). diff --git a/client/api/index.js b/client/api/index.js index a0a255f8b..a5b59f867 100644 --- a/client/api/index.js +++ b/client/api/index.js @@ -548,4 +548,47 @@ export default class WCStripeAPI { ...paymentData, } ); } + + /** + * Add product to cart from product page. + * + * @param {Object} productData Product data. + * @return {Promise} Promise for the request to the server. + */ + expressCheckoutAddToCart( productData ) { + return this.request( getExpressCheckoutAjaxURL( 'add_to_cart' ), { + security: getExpressCheckoutData( 'nonce' )?.add_to_cart, + ...productData, + } ); + } + + /** + * Get selected product data from variable product page. + * + * @param {Object} productData Product data. + * @return {Promise} Promise for the request to the server. + */ + expressCheckoutGetSelectedProductData( productData ) { + return this.request( + getExpressCheckoutAjaxURL( 'get_selected_product_data' ), + { + security: getExpressCheckoutData( 'nonce' ) + ?.get_selected_product_data, + ...productData, + } + ); + } + + /** + * Empty the cart. + * + * @param {number} bookingId Booking ID. + * @return {Promise} Promise for the request to the server. + */ + expressCheckoutEmptyCart( bookingId ) { + return this.request( getExpressCheckoutAjaxURL( 'clear_cart' ), { + security: getExpressCheckoutData( 'nonce' )?.clear_cart, + booking_id: bookingId, + } ); + } } diff --git a/client/entrypoints/express-checkout/index.js b/client/entrypoints/express-checkout/index.js index 7f12cff3d..5071363d9 100644 --- a/client/entrypoints/express-checkout/index.js +++ b/client/entrypoints/express-checkout/index.js @@ -34,6 +34,8 @@ jQuery( function ( $ ) { } const publishableKey = getExpressCheckoutData( 'stripe' ).publishable_key; + const quantityInputSelector = '.quantity .qty[type=number]'; + if ( ! publishableKey ) { // If no configuration is present, probably this is not the checkout page. return; @@ -285,7 +287,19 @@ jQuery( function ( $ ) { order, } ); } else if ( getExpressCheckoutData( 'is_product_page' ) ) { - // Product page specific initialization. + wcStripeECE.startExpressCheckoutElement( { + mode: 'payment', + total: getExpressCheckoutData( 'product' )?.total.amount, + currency: getExpressCheckoutData( 'product' )?.currency, + requestShipping: + getExpressCheckoutData( 'product' )?.requestShipping ?? + false, + requestPhone: + getExpressCheckoutData( 'checkout' ) + ?.needs_payer_phone ?? false, + displayItems: getExpressCheckoutData( 'product' ) + .displayItems, + } ); } else { // Cart and Checkout page specific initialization. api.expressCheckoutGetCartDetails().then( ( cart ) => { @@ -306,6 +320,131 @@ jQuery( function ( $ ) { wcStripeECE.paymentAborted = false; }, + getAttributes: () => { + const select = $( '.variations_form' ).find( '.variations select' ); + const data = {}; + let count = 0; + let chosen = 0; + + select.each( function () { + const attributeName = + $( this ).data( 'attribute_name' ) || + $( this ).attr( 'name' ); + const value = $( this ).val() || ''; + + if ( value.length > 0 ) { + chosen++; + } + + count++; + data[ attributeName ] = value; + } ); + + return { + count, + chosenCount: chosen, + data, + }; + }, + + getSelectedProductData: () => { + let productId = $( '.single_add_to_cart_button' ).val(); + + // Check if product is a variable product. + if ( $( '.single_variation_wrap' ).length ) { + productId = $( '.single_variation_wrap' ) + .find( 'input[name="product_id"]' ) + .val(); + } + + // WC Bookings Support. + if ( $( '.wc-bookings-booking-form' ).length ) { + productId = $( '.wc-booking-product-id' ).val(); + } + + const addons = + $( '#product-addons-total' ).data( 'price_data' ) || []; + const addonValue = addons.reduce( + ( sum, addon ) => sum + addon.cost, + 0 + ); + + // WC Deposits Support. + const depositObject = {}; + if ( $( 'input[name=wc_deposit_option]' ).length ) { + depositObject.wc_deposit_option = $( + 'input[name=wc_deposit_option]:checked' + ).val(); + } + if ( $( 'input[name=wc_deposit_payment_plan]' ).length ) { + depositObject.wc_deposit_payment_plan = $( + 'input[name=wc_deposit_payment_plan]:checked' + ).val(); + } + + const data = { + product_id: productId, + qty: $( quantityInputSelector ).val(), + attributes: $( '.variations_form' ).length + ? wcStripeECE.getAttributes().data + : [], + addon_value: addonValue, + ...depositObject, + }; + + return api.expressCheckoutGetSelectedProductData( data ); + }, + + /** + * Adds the item to the cart and return cart details. + * + * @return {Promise} Promise for the request to the server. + */ + addToCart: () => { + let productId = $( '.single_add_to_cart_button' ).val(); + + // Check if product is a variable product. + if ( $( '.single_variation_wrap' ).length ) { + productId = $( '.single_variation_wrap' ) + .find( 'input[name="product_id"]' ) + .val(); + } + + if ( $( '.wc-bookings-booking-form' ).length ) { + productId = $( '.wc-booking-product-id' ).val(); + } + + const data = { + product_id: productId, + qty: $( quantityInputSelector ).val(), + attributes: $( '.variations_form' ).length + ? wcStripeECE.getAttributes().data + : [], + }; + + // Add extension data to the POST body + const formData = $( 'form.cart' ).serializeArray(); + $.each( formData, ( i, field ) => { + if ( /^(addon-|wc_)/.test( field.name ) ) { + if ( /\[\]$/.test( field.name ) ) { + const fieldName = field.name.substring( + 0, + field.name.length - 2 + ); + if ( data[ fieldName ] ) { + data[ fieldName ].push( field.value ); + } else { + data[ fieldName ] = [ field.value ]; + } + } else { + data[ field.name ] = field.value; + } + } + } ); + + return api.expressCheckoutAddToCart( data ); + }, + /** * Complete payment. * @@ -480,4 +619,42 @@ jQuery( function ( $ ) { }; wcStripeECE.init(); + + // Handle bookable products on the product page. + let wcBookingFormChanged = false; + + $( document.body ) + .off( 'wc_booking_form_changed' ) + .on( 'wc_booking_form_changed', () => { + wcBookingFormChanged = true; + } ); + + // Listen for the WC Bookings wc_bookings_calculate_costs event to complete + // and add the bookable product to the cart, using the response to update the + // payment request request params with correct totals. + $( document ).ajaxComplete( function ( event, xhr, settings ) { + if ( wcBookingFormChanged ) { + if ( + settings.url === window.booking_form_params.ajax_url && + settings.data.includes( 'wc_bookings_calculate_costs' ) && + xhr.responseText.includes( 'SUCCESS' ) + ) { + wcStripeECE.blockExpressCheckoutButton(); + wcBookingFormChanged = false; + + return wcStripeECE.addToCart().then( ( response ) => { + getExpressCheckoutData( 'product' ).total = response.total; + getExpressCheckoutData( 'product' ).displayItems = + response.displayItems; + + // Empty the cart to avoid having 2 products in the cart when payment request is not used. + api.expressCheckoutEmptyCart( response.bookingId ); + + wcStripeECE.init(); + + wcStripeECE.unblockExpressCheckoutButton(); + } ); + } + } + } ); } ); diff --git a/includes/payment-methods/class-wc-stripe-express-checkout-ajax-handler.php b/includes/payment-methods/class-wc-stripe-express-checkout-ajax-handler.php index c2dd0598c..962e7dd80 100644 --- a/includes/payment-methods/class-wc-stripe-express-checkout-ajax-handler.php +++ b/includes/payment-methods/class-wc-stripe-express-checkout-ajax-handler.php @@ -100,7 +100,7 @@ public function ajax_add_to_cart() { WC()->cart->add_to_cart( $product->get_id(), $qty, $variation_id, $attributes ); } - if ( in_array( $product_type, [ 'simple', 'variation', 'subscription', 'subscription_variation' ], true ) ) { + if ( in_array( $product_type, [ 'simple', 'variation', 'subscription', 'subscription_variation', 'booking' ], true ) ) { WC()->cart->add_to_cart( $product->get_id(), $qty ); } @@ -110,6 +110,14 @@ public function ajax_add_to_cart() { $data += $this->express_checkout_helper->build_display_items(); $data['result'] = 'success'; + if ( 'booking' === $product_type ) { + $booking_id = $this->express_checkout_helper->get_booking_id_from_cart(); + + if ( ! empty( $booking_id ) ) { + $data['bookingId'] = $booking_id; + } + } + // @phpstan-ignore-next-line (return statement is added) wp_send_json( $data ); } @@ -120,7 +128,17 @@ public function ajax_add_to_cart() { public function ajax_clear_cart() { check_ajax_referer( 'wc-stripe-clear-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 + } + exit; } @@ -186,11 +204,14 @@ public function ajax_get_selected_product_data() { check_ajax_referer( 'wc-stripe-get-selected-product-data', 'security' ); try { // @phpstan-ignore-line (return statement is added) - $product_id = isset( $_POST['product_id'] ) ? absint( $_POST['product_id'] ) : 0; - $qty = ! isset( $_POST['qty'] ) ? 1 : apply_filters( 'woocommerce_add_to_cart_quantity', absint( $_POST['qty'] ), $product_id ); - $addon_value = isset( $_POST['addon_value'] ) ? max( floatval( $_POST['addon_value'] ), 0 ) : 0; - $product = wc_get_product( $product_id ); - $variation_id = null; + $product_id = isset( $_POST['product_id'] ) ? absint( $_POST['product_id'] ) : 0; + $qty = ! isset( $_POST['qty'] ) ? 1 : apply_filters( 'woocommerce_add_to_cart_quantity', absint( $_POST['qty'] ), $product_id ); + $addon_value = isset( $_POST['addon_value'] ) ? max( floatval( $_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: 1) The product Id */ @@ -222,27 +243,35 @@ public function ajax_get_selected_product_data() { 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-gateway-stripe' ), $product->get_name(), wc_format_stock_quantity_for_display( $product->get_stock_quantity(), $product ) ) ); } - $total = $qty * $this->express_checkout_helper->get_product_price( $product ) + $addon_value; + $price = $this->express_checkout_helper->get_product_price( $product, $is_deposit, $deposit_plan_id ); + $total = $qty * $price + $addon_value; $quantity_label = 1 < $qty ? ' (x' . $qty . ')' : ''; - $data = []; $items = []; + $data = [ + 'currency' => strtolower( $currency ), + 'country_code' => substr( get_option( 'woocommerce_default_country' ), 0, 2 ), + 'requestShipping' => wc_shipping_enabled() && 0 !== wc_get_shipping_method_count( true ) && $product->needs_shipping(), + ]; $items[] = [ 'label' => $product->get_name() . $quantity_label, 'amount' => WC_Stripe_Helper::get_stripe_amount( $total ), ]; - if ( wc_tax_enabled() ) { + $total_tax = 0; + foreach ( $this->express_checkout_helper->get_taxes_like_cart( $product, $price ) as $tax ) { + $total_tax += $tax; + $items[] = [ 'label' => __( 'Tax', 'woocommerce-gateway-stripe' ), - 'amount' => 0, - 'pending' => true, + 'amount' => WC_Stripe_Helper::get_stripe_amount( $tax, $currency ), + 'pending' => 0 === $tax, ]; } - if ( wc_shipping_enabled() && $product->needs_shipping() ) { + if ( true === $data['requestShipping'] ) { $items[] = [ 'label' => __( 'Shipping', 'woocommerce-gateway-stripe' ), 'amount' => 0, @@ -260,13 +289,9 @@ public function ajax_get_selected_product_data() { $data['displayItems'] = $items; $data['total'] = [ 'label' => $this->express_checkout_helper->get_total_label(), - 'amount' => WC_Stripe_Helper::get_stripe_amount( $total ), + 'amount' => WC_Stripe_Helper::get_stripe_amount( $total + $total_tax, $currency ), ]; - $data['requestShipping'] = ( 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 ) { wp_send_json( [ 'error' => wp_strip_all_tags( $e->getMessage() ) ] ); diff --git a/includes/payment-methods/class-wc-stripe-express-checkout-helper.php b/includes/payment-methods/class-wc-stripe-express-checkout-helper.php index 18a7d8219..8a04364ea 100644 --- a/includes/payment-methods/class-wc-stripe-express-checkout-helper.php +++ b/includes/payment-methods/class-wc-stripe-express-checkout-helper.php @@ -143,14 +143,44 @@ public function get_total_label() { /** * Gets the product total price. * - * @param object $product WC_Product_* object. + * @param object $product WC_Product_* object. + * @param bool|null $is_deposit Whether this is a deposit. + * @param int $deposit_plan_id Deposit plan ID. + * * @return integer Total price. */ - public function get_product_price( $product ) { - $product_price = $product->get_price(); + public function get_product_price( $product, $is_deposit = null, $deposit_plan_id = 0 ) { + // If prices should include tax, using tax inclusive price. + if ( $this->cart_prices_include_tax() ) { + $product_price = wc_get_price_including_tax( $product ); + } else { + $product_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' ) && class_exists( 'WC_Deposits_Plans_Manager' ) && WC_Deposits_Product_Manager::deposits_enabled( $product->get_id() ) ) { + // If is_deposit is null, we use the default deposit type for the product. + if ( is_null( $is_deposit ) ) { + $is_deposit = 'deposit' === WC_Deposits_Product_Manager::get_deposit_selected_type( $product->get_id() ); + } + if ( $is_deposit ) { + $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 ) ) { + $product_price = WC_Deposits_Product_Manager::get_deposit_amount( $product, $deposit_plan_id, 'display', $product_price ); + } + } + } + // Add subscription sign-up fees to product price. if ( in_array( $product->get_type(), [ 'subscription', 'subscription_variation' ] ) && class_exists( 'WC_Subscriptions_Product' ) ) { - $product_price = $product->get_price() + WC_Subscriptions_Product::get_sign_up_fee( $product ); + $product_price = $product_price + WC_Subscriptions_Product::get_sign_up_fee( $product ); } return $product_price; @@ -194,23 +224,28 @@ public function get_product_data() { } } - $data = []; - $items = []; + $data = []; + $items = []; + $price = $this->get_product_price( $product ); + $currency = get_woocommerce_currency(); + $total_tax = 0; $items[] = [ 'label' => $product->get_name(), - 'amount' => WC_Stripe_Helper::get_stripe_amount( $this->get_product_price( $product ) ), + 'amount' => WC_Stripe_Helper::get_stripe_amount( $price ), ]; - if ( wc_tax_enabled() ) { + foreach ( $this->get_taxes_like_cart( $product, $price ) as $tax ) { + $total_tax += $tax; + $items[] = [ 'label' => __( 'Tax', 'woocommerce-gateway-stripe' ), - 'amount' => 0, - 'pending' => true, + 'amount' => WC_Stripe_Helper::get_stripe_amount( $tax, $currency ), + 'pending' => 0 === $tax, ]; } - if ( wc_shipping_enabled() && $product->needs_shipping() ) { + if ( wc_shipping_enabled() && 0 !== wc_get_shipping_method_count( true ) && $product->needs_shipping() ) { $items[] = [ 'label' => __( 'Shipping', 'woocommerce-gateway-stripe' ), 'amount' => 0, @@ -228,11 +263,12 @@ public function get_product_data() { $data['displayItems'] = $items; $data['total'] = [ 'label' => apply_filters( 'wc_stripe_payment_request_total_label', $this->total_label ), - 'amount' => WC_Stripe_Helper::get_stripe_amount( $this->get_product_price( $product ) ), + 'amount' => WC_Stripe_Helper::get_stripe_amount( $price + $total_tax, $currency ), + 'pending' => true, ]; $data['requestShipping'] = ( wc_shipping_enabled() && $product->needs_shipping() && 0 !== wc_get_shipping_method_count( true ) ); - $data['currency'] = strtolower( get_woocommerce_currency() ); + $data['currency'] = strtolower( $currency ); $data['country_code'] = substr( get_option( 'woocommerce_default_country' ), 0, 2 ); // On product page load, if there's a variation already selected, check if it's supported. @@ -348,7 +384,7 @@ public function is_invalid_subscription_product( $product, $is_product_page_requ return false; } - $is_invalid = true; + $is_invalid = true; if ( $product->get_type() === 'variable-subscription' ) { $products = $product->get_available_variations( 'object' ); @@ -1078,8 +1114,15 @@ public function build_display_items( $itemized_display_items = false ) { $subtotal = 0; $discounts = 0; $display_items = ! apply_filters( 'wc_stripe_payment_request_hide_itemization', true ) || $itemized_display_items; + $has_deposits = false; foreach ( WC()->cart->get_cart() as $cart_item_key => $cart_item ) { + // Hide itemization/subtotals for Apple Pay and Google Pay when deposits are present. + if ( ! empty( $cart_item['is_deposit'] ) ) { + $has_deposits = true; + continue; + } + $subtotal += $cart_item['line_subtotal']; $amount = $cart_item['line_subtotal']; $quantity_label = 1 < $cart_item['quantity'] ? ' (x' . $cart_item['quantity'] . ')' : ''; @@ -1091,11 +1134,9 @@ public function build_display_items( $itemized_display_items = false ) { ]; } - if ( $display_items ) { + if ( $display_items && ! $has_deposits ) { $items = array_merge( $items, $lines ); - } else { - // Default show only subtotal instead of itemization. - + } elseif ( ! $has_deposits ) { // If the cart contains a deposit, the subtotal will be different to the cart total and will throw an error. $items[] = [ 'label' => 'Subtotal', 'amount' => WC_Stripe_Helper::get_stripe_amount( $subtotal ), @@ -1280,4 +1321,55 @@ public function add_order_payment_method_title( $order ) { $order->set_payment_method_title( $payment_method_title . $suffix ); $order->save(); } + + /** + * 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. + */ + 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 []; + } + + // 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 ); + } + + /** + * Whether tax should be displayed on separate line in cart. + * returns true if tax is disabled or display of tax in checkout is set to inclusive. + * + * @return boolean + */ + public function cart_prices_include_tax() { + return ! wc_tax_enabled() || 'incl' === get_option( 'woocommerce_tax_display_cart' ); + } + + /** + * 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. + * Used to remove the booking from WC Bookings in-cart status. + * + * @return int|false + */ + public function get_booking_id_from_cart() { + $cart = WC()->cart->get_cart(); + $cart_item = reset( $cart ); + + if ( $cart_item && isset( $cart_item['booking']['_booking_id'] ) ) { + return $cart_item['booking']['_booking_id']; + } + + return false; + } } diff --git a/readme.txt b/readme.txt index b320488c4..7b1c3de4c 100644 --- a/readme.txt +++ b/readme.txt @@ -136,6 +136,7 @@ If you get stuck, you can ask for help in the Plugin Forum. * Add - Implemented the "Update all subscriptions payment methods" checkbox on My Account → Payment methods for UPE payment methods. * Add - Add support for the new Stripe Checkout Element on the shortcode checkout page. * Add - Add support for the new Stripe Checkout Element on the pay for order page. +* Add - Add support for the new Stripe Checkout Element on the product page. * Dev - Introduces a new class with payment methods constants. * Dev - Introduces a new class with currency codes constants. * Dev - Improves the readability of the redirect URL generation code (UPE). @@ -143,7 +144,6 @@ If you get stuck, you can ask for help in the Plugin Forum. * Fix - Fix Google Pay address fields mapping for UAE addresses. * Tweak - Render the Klarna payment page in the store locale. * Tweak - Update the Apple Pay domain registration flow to use the new Stripe API endpoint. -* Fix - Resolve an error for checkout block where 'wc_stripe_upe_params' is undefined due to the script registering the variable not being loaded yet. * Fix - Fix empty error message for Express Payments when order creation fails. * Fix - Fix multiple issues related to the reuse of Cash App Pay tokens (as a saved payment method) when subscribing. * Fix - Move charge related code to separate try-catch to prevent renewal failure.