From 1dbd05e7894fe1e9a923cdb1d157d38430ba964a Mon Sep 17 00:00:00 2001 From: Guilherme Pressutto Date: Fri, 15 Dec 2023 11:44:38 -0300 Subject: [PATCH 1/2] Tax is calculated twice for virtual products if Apple/Google Pay is used on product pages (#7818) Co-authored-by: Brett Shumaker --- changelog/fix-apple-pay-including-tax | 4 + ...ayments-payment-request-button-handler.php | 30 +-- ...lass-wc-payments-woopay-button-handler.php | 14 +- ...ayments-payment-request-button-handler.php | 189 ++++++++++++++++++ 4 files changed, 215 insertions(+), 22 deletions(-) create mode 100644 changelog/fix-apple-pay-including-tax diff --git a/changelog/fix-apple-pay-including-tax b/changelog/fix-apple-pay-including-tax new file mode 100644 index 00000000000..59afc2bef4f --- /dev/null +++ b/changelog/fix-apple-pay-including-tax @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fixed Apple Pay Double Tax Calculation Issue diff --git a/includes/class-wc-payments-payment-request-button-handler.php b/includes/class-wc-payments-payment-request-button-handler.php index 82da5a8d449..ff030924969 100644 --- a/includes/class-wc-payments-payment-request-button-handler.php +++ b/includes/class-wc-payments-payment-request-button-handler.php @@ -211,10 +211,10 @@ public function get_button_height() { */ public function get_product_price( $product ) { // If prices should include tax, using tax inclusive price. - if ( ! $this->prices_exclude_tax() ) { + if ( $this->cart_prices_include_tax() ) { $base_price = wc_get_price_including_tax( $product ); } else { - $base_price = $product->get_price(); + $base_price = wc_get_price_excluding_tax( $product ); } // Add subscription sign-up fees to product price. @@ -292,7 +292,7 @@ public function get_product_data() { ]; $total_tax = 0; - foreach ( $this->get_taxes( $product, $price ) as $tax ) { + foreach ( $this->get_taxes_like_cart( $product, $price ) as $tax ) { $total_tax += $tax; $items[] = [ @@ -1094,7 +1094,7 @@ public function ajax_get_selected_product_data() { ]; $total_tax = 0; - foreach ( $this->get_taxes( $product, $price ) as $tax ) { + foreach ( $this->get_taxes_like_cart( $product, $price ) as $tax ) { $total_tax += $tax; $items[] = [ @@ -1511,13 +1511,13 @@ protected function calculate_shipping( $address = [] ) { } /** - * Whether tax should be displayed on seperate line. - * returns true if tax is enabled & display of tax in checkout is set to exclusive. + * 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 */ - private function prices_exclude_tax() { - return wc_tax_enabled() && 'incl' !== get_option( 'woocommerce_tax_display_cart' ); + private function cart_prices_include_tax() { + return ! wc_tax_enabled() || 'incl' === get_option( 'woocommerce_tax_display_cart' ); } /** @@ -1549,7 +1549,7 @@ protected function build_shipping_methods( $shipping_methods ) { * * @param boolean $itemized_display_items Indicates whether to show subtotals or itemized views. */ - protected function build_display_items( $itemized_display_items = false ) { + public function build_display_items( $itemized_display_items = false ) { if ( ! defined( 'WOOCOMMERCE_CART' ) ) { define( 'WOOCOMMERCE_CART', true ); } @@ -1568,7 +1568,7 @@ protected function build_display_items( $itemized_display_items = false ) { $product_name = $cart_item['data']->get_name(); - $item_tax = $this->prices_exclude_tax() ? 0 : ( $cart_item['line_subtotal_tax'] ?? 0 ); + $item_tax = $this->cart_prices_include_tax() ? ( $cart_item['line_subtotal_tax'] ?? 0 ) : 0; $item = [ 'label' => $product_name . $quantity_label, @@ -1595,7 +1595,7 @@ protected function build_display_items( $itemized_display_items = false ) { $items_total = wc_format_decimal( WC()->cart->cart_contents_total, WC()->cart->dp ) + $discounts; $order_total = version_compare( WC_VERSION, '3.2', '<' ) ? wc_format_decimal( $items_total + $tax + $shipping - $discounts, WC()->cart->dp ) : WC()->cart->get_total( '' ); - if ( $this->prices_exclude_tax() ) { + if ( ! $this->cart_prices_include_tax() ) { $items[] = [ 'label' => esc_html( __( 'Tax', 'woocommerce-payments' ) ), 'amount' => WC_Payments_Utils::prepare_amount( $tax, $currency ), @@ -1603,7 +1603,7 @@ protected function build_display_items( $itemized_display_items = false ) { } if ( WC()->cart->needs_shipping() ) { - $shipping_tax = $this->prices_exclude_tax() ? 0 : WC()->cart->shipping_tax_total; + $shipping_tax = $this->cart_prices_include_tax() ? WC()->cart->shipping_tax_total : 0; $items[] = [ 'label' => esc_html( __( 'Shipping', 'woocommerce-payments' ) ), 'amount' => WC_Payments_Utils::prepare_amount( $shipping + $shipping_tax, $currency ), @@ -1708,14 +1708,14 @@ public function get_login_confirmation_settings() { } /** - * Calculates taxes, based on a product and a particular price. + * 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( $product, $price ) { - if ( ! wc_tax_enabled() || ! $this->prices_exclude_tax() ) { + 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 []; } diff --git a/includes/class-wc-payments-woopay-button-handler.php b/includes/class-wc-payments-woopay-button-handler.php index f3467f9cc8d..d69cae52083 100644 --- a/includes/class-wc-payments-woopay-button-handler.php +++ b/includes/class-wc-payments-woopay-button-handler.php @@ -309,7 +309,7 @@ protected function build_display_items( $itemized_display_items = false ) { $product_name = $cart_item['data']->get_name(); - $item_tax = $this->prices_exclude_tax() ? 0 : ( $cart_item['line_subtotal_tax'] ?? 0 ); + $item_tax = $this->cart_prices_include_tax() ? ( $cart_item['line_subtotal_tax'] ?? 0 ) : 0; $item = [ 'label' => $product_name . $quantity_label, @@ -336,7 +336,7 @@ protected function build_display_items( $itemized_display_items = false ) { $items_total = wc_format_decimal( WC()->cart->cart_contents_total, WC()->cart->dp ) + $discounts; $order_total = version_compare( WC_VERSION, '3.2', '<' ) ? wc_format_decimal( $items_total + $tax + $shipping - $discounts, WC()->cart->dp ) : WC()->cart->get_total( '' ); - if ( $this->prices_exclude_tax() ) { + if ( ! $this->cart_prices_include_tax() ) { $items[] = [ 'label' => esc_html( __( 'Tax', 'woocommerce-payments' ) ), 'amount' => WC_Payments_Utils::prepare_amount( $tax, $currency ), @@ -344,7 +344,7 @@ protected function build_display_items( $itemized_display_items = false ) { } if ( WC()->cart->needs_shipping() ) { - $shipping_tax = $this->prices_exclude_tax() ? 0 : WC()->cart->shipping_tax_total; + $shipping_tax = $this->cart_prices_include_tax() ? WC()->cart->shipping_tax_total : 0; $items[] = [ 'label' => esc_html( __( 'Shipping', 'woocommerce-payments' ) ), 'amount' => WC_Payments_Utils::prepare_amount( $shipping + $shipping_tax, $currency ), @@ -383,13 +383,13 @@ protected function build_display_items( $itemized_display_items = false ) { } /** - * Whether tax should be displayed on seperate line. - * returns true if tax is enabled & display of tax in checkout is set to exclusive. + * 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 */ - private function prices_exclude_tax() { - return wc_tax_enabled() && 'incl' !== get_option( 'woocommerce_tax_display_cart' ); + private function cart_prices_include_tax() { + return ! wc_tax_enabled() || 'incl' === get_option( 'woocommerce_tax_display_cart' ); } /** diff --git a/tests/unit/test-class-wc-payments-payment-request-button-handler.php b/tests/unit/test-class-wc-payments-payment-request-button-handler.php index b0e5dd3fd90..7940d7b20f0 100644 --- a/tests/unit/test-class-wc-payments-payment-request-button-handler.php +++ b/tests/unit/test-class-wc-payments-payment-request-button-handler.php @@ -81,6 +81,7 @@ class WC_Payments_Payment_Request_Button_Handler_Test extends WCPAY_UnitTestCase */ public function set_up() { parent::set_up(); + add_filter( 'pre_option_woocommerce_tax_based_on', [ $this, '__return_base' ] ); $this->mock_api_client = $this->getMockBuilder( 'WC_Payments_API_Client' ) ->disableOriginalConstructor() @@ -109,6 +110,23 @@ public function set_up() { $zone->set_zone_order( 1 ); $zone->save(); + add_filter( + 'woocommerce_find_rates', + function() { + return [ + 1 => + [ + 'rate' => 10.0, + 'label' => 'Tax', + 'shipping' => 'yes', + 'compound' => 'no', + ], + ]; + }, + 50, + 2 + ); + $this->flat_rate_id = $zone->add_shipping_method( 'flat_rate' ); self::set_shipping_method_cost( $this->flat_rate_id, '5' ); @@ -129,6 +147,36 @@ public function tear_down() { WC()->session->cleanup_sessions(); $this->zone->delete(); delete_option( 'woocommerce_woocommerce_payments_settings' ); + remove_filter( 'pre_option_woocommerce_tax_based_on', [ $this, '__return_base' ] ); + remove_filter( 'pre_option_woocommerce_tax_display_cart', [ $this, '__return_excl' ] ); + remove_filter( 'pre_option_woocommerce_tax_display_cart', [ $this, '__return_incl' ] ); + remove_filter( 'pre_option_woocommerce_tax_display_shop', [ $this, '__return_excl' ] ); + remove_filter( 'pre_option_woocommerce_tax_display_shop', [ $this, '__return_incl' ] ); + remove_filter( 'pre_option_woocommerce_prices_include_tax', [ $this, '__return_yes' ] ); + remove_filter( 'pre_option_woocommerce_prices_include_tax', [ $this, '__return_no' ] ); + remove_filter( 'wc_tax_enabled', '__return_true' ); + remove_filter( 'wc_tax_enabled', '__return_false' ); + remove_filter( 'wc_shipping_enabled', '__return_false' ); + } + + public function __return_yes() { + return 'yes'; + } + + public function __return_no() { + return 'no'; + } + + public function __return_excl() { + return 'excl'; + } + + public function __return_incl() { + return 'incl'; + } + + public function __return_base() { + return 'base'; } /** @@ -284,6 +332,105 @@ public function test_get_product_price_returns_simple_price() { ); } + /** + * @dataProvider provide_get_product_tax_tests + */ + public function test_get_product_price_returns_price_with_tax( $tax_enabled, $prices_include_tax, $tax_display_shop, $tax_display_cart, $product_price, $expected_price ) { + $this->simple_product->set_price( $product_price ); + add_filter( 'wc_tax_enabled', $tax_enabled ? '__return_true' : '__return_false' ); // reset in tear_down. + add_filter( 'pre_option_woocommerce_prices_include_tax', [ $this, "__return_$prices_include_tax" ] ); // reset in tear_down. + add_filter( 'pre_option_woocommerce_tax_display_shop', [ $this, "__return_$tax_display_shop" ] ); // reset in tear_down. + add_filter( 'pre_option_woocommerce_tax_display_cart', [ $this, "__return_$tax_display_cart" ] ); // reset in tear_down. + WC()->cart->calculate_totals(); + $this->assertEquals( + $expected_price, + $this->pr->get_product_price( $this->simple_product ) + ); + } + + public function provide_get_product_tax_tests() { + yield 'Tax Disabled, Price Display Unaffected' => [ + 'tax_enabled' => false, + 'prices_include_tax' => 'no', + 'tax_display_shop' => 'excl', + 'tax_display_cart' => 'incl', + 'product_price' => 10, + 'expected_price' => 10, + ]; + + // base prices DO NOT include tax. + + yield 'Shop: Excl / Cart: Incl, Base Prices Don\'t Include Tax' => [ + 'tax_enabled' => true, + 'prices_include_tax' => 'no', + 'tax_display_shop' => 'excl', + 'tax_display_cart' => 'incl', + 'product_price' => 10, + 'expected_price' => 11, + ]; + yield 'Shop: Excl / Cart: Excl, Base Prices Don\'t Include Tax' => [ + 'tax_enabled' => true, + 'prices_include_tax' => 'no', + 'tax_display_shop' => 'excl', + 'tax_display_cart' => 'excl', + 'product_price' => 10, + 'expected_price' => 10, + ]; + + yield 'Shop: Incl / Cart: Incl, Base Prices Don\'t Include Tax' => [ + 'tax_enabled' => true, + 'prices_include_tax' => 'no', + 'tax_display_shop' => 'incl', + 'tax_display_cart' => 'incl', + 'product_price' => 10, + 'expected_price' => 11, + ]; + yield 'Shop: Incl / Cart: Excl, Base Prices Don\'t Include Tax' => [ + 'tax_enabled' => true, + 'prices_include_tax' => 'no', + 'tax_display_shop' => 'incl', + 'tax_display_cart' => 'excl', + 'product_price' => 10, + 'expected_price' => 10, + ]; + + // base prices include tax. + + yield 'Shop: Excl / Cart: Incl, Base Prices Include Tax' => [ + 'tax_enabled' => true, + 'prices_include_tax' => 'yes', + 'tax_display_shop' => 'excl', + 'tax_display_cart' => 'incl', + 'product_price' => 10, + 'expected_price' => 10, + ]; + yield 'Shop: Excl / Cart: Excl, Base Prices Include Tax' => [ + 'tax_enabled' => true, + 'prices_include_tax' => 'yes', + 'tax_display_shop' => 'excl', + 'tax_display_cart' => 'excl', + 'product_price' => 10, + 'expected_price' => 9.090909, + ]; + + yield 'Shop: Incl / Cart: Incl, Base Prices Include Tax' => [ + 'tax_enabled' => true, + 'prices_include_tax' => 'yes', + 'tax_display_shop' => 'incl', + 'tax_display_cart' => 'incl', + 'product_price' => 10, + 'expected_price' => 10, + ]; + yield 'Shop: Incl / Cart: Excl, Base Prices Include Tax' => [ + 'tax_enabled' => true, + 'prices_include_tax' => 'yes', + 'tax_display_shop' => 'incl', + 'tax_display_cart' => 'excl', + 'product_price' => 10, + 'expected_price' => 9.090909, + ]; + } + public function test_get_product_price_includes_subscription_sign_up_fee() { $mock_product = $this->create_mock_subscription( 'subscription' ); @@ -347,4 +494,46 @@ private function create_mock_subscription( $type ) { return $mock_product; } + + /** + * @dataProvider provide_get_product_tax_tests + */ + public function test_get_product_data_returns_the_same_as_build_display_items_without_shipping( $tax_enabled, $prices_include_tax, $tax_display_shop, $tax_display_cart, $_product_price, $_expected_price ) { + add_filter( 'wc_tax_enabled', $tax_enabled ? '__return_true' : '__return_false' ); // reset in tear_down. + add_filter( 'pre_option_woocommerce_prices_include_tax', [ $this, "__return_$prices_include_tax" ] ); // reset in tear_down. + add_filter( 'pre_option_woocommerce_tax_display_shop', [ $this, "__return_$tax_display_shop" ] ); // reset in tear_down. + add_filter( 'pre_option_woocommerce_tax_display_cart', [ $this, "__return_$tax_display_cart" ] ); // reset in tear_down. + add_filter( 'wc_shipping_enabled', '__return_false' ); // reset in tear_down. + WC()->cart->calculate_totals(); + $build_display_items_result = $this->pr->build_display_items( true ); + + $mock_pr = $this->getMockBuilder( WC_Payments_Payment_Request_Button_Handler::class ) + ->setConstructorArgs( [ $this->mock_wcpay_account, $this->mock_wcpay_gateway ] ) + ->setMethods( [ 'is_product', 'get_product' ] ) + ->getMock(); + + $mock_pr->method( 'is_product' ) + ->willReturn( true ); + $mock_pr->method( 'get_product' ) + ->willReturn( $this->simple_product ); + + $get_product_data_result = $mock_pr->get_product_data(); + + foreach ( $get_product_data_result['displayItems'] as $key => $display_item ) { + if ( isset( $display_item['pending'] ) ) { + unset( $get_product_data_result['displayItems'][ $key ]['pending'] ); + } + } + + $this->assertEquals( + $get_product_data_result['displayItems'], + $build_display_items_result['displayItems'], + 'Failed asserting displayItems are the same for get_product_data and build_display_items' + ); + $this->assertEquals( + $get_product_data_result['total']['amount'], + $build_display_items_result['total']['amount'], + 'Failed asserting total amount are the same for get_product_data and build_display_items' + ); + } } From cc429402e17b3d20347915d181ebaf42a9bb2945 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Costa?= <10233985+cesarcosta99@users.noreply.github.com> Date: Fri, 15 Dec 2023 12:50:12 -0500 Subject: [PATCH 2/2] Fix e2e tests on WC 7.7 (#7893) --- changelog/dev-fix-e2e-tests-on-wc-7-7 | 5 +++++ .../wcpay/shopper/shopper-multi-currency-widget.spec.js | 3 +++ tests/e2e/utils/flows.js | 8 ++++++-- 3 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 changelog/dev-fix-e2e-tests-on-wc-7-7 diff --git a/changelog/dev-fix-e2e-tests-on-wc-7-7 b/changelog/dev-fix-e2e-tests-on-wc-7-7 new file mode 100644 index 00000000000..15b4d3b72e9 --- /dev/null +++ b/changelog/dev-fix-e2e-tests-on-wc-7-7 @@ -0,0 +1,5 @@ +Significance: patch +Type: dev +Comment: Fix e2e tests on WC 7.7. + + diff --git a/tests/e2e/specs/wcpay/shopper/shopper-multi-currency-widget.spec.js b/tests/e2e/specs/wcpay/shopper/shopper-multi-currency-widget.spec.js index 23aec018d59..3d3045c12a8 100644 --- a/tests/e2e/specs/wcpay/shopper/shopper-multi-currency-widget.spec.js +++ b/tests/e2e/specs/wcpay/shopper/shopper-multi-currency-widget.spec.js @@ -95,6 +95,7 @@ describe( 'Shopper Multi-Currency widget', () => { await setupTest(); await page.waitForSelector( '.widget select[name=currency]', { visible: true, + timeout: 5000, } ); await page.select( '.widget select[name=currency]', 'EUR' ); await expect( page.url() ).toContain( @@ -105,6 +106,7 @@ describe( 'Shopper Multi-Currency widget', () => { ); // Change it back to USD for the other tests. await page.select( '.widget select[name=currency]', 'USD' ); + await page.reload( { waitUntil: 'networkidle0' } ); } ); } ); @@ -178,5 +180,6 @@ describe( 'Shopper Multi-Currency widget', () => { '.widget select[name=currency]' ); expect( currencySwitcher ).toBeNull(); + await merchant.logout(); } ); } ); diff --git a/tests/e2e/utils/flows.js b/tests/e2e/utils/flows.js index aa18cd328a7..72461c8aea9 100644 --- a/tests/e2e/utils/flows.js +++ b/tests/e2e/utils/flows.js @@ -679,7 +679,11 @@ export const merchantWCP = { searchInput.type( 'switcher', { delay: 20 } ); await page.waitForSelector( - 'button.components-button[role="option"]' + 'button.components-button[role="option"]', + { + visible: true, + timeout: 5000, + } ); await page.click( 'button.components-button[role="option"]' ); await page.waitFor( 2000 ); @@ -699,7 +703,7 @@ export const merchantWCP = { await merchant.openNewOrder(); await page.click( 'button.add-line-item' ); await page.click( 'button.add-order-item' ); - await page.click( 'select.wc-product-search' ); + await page.click( 'select[name="item_id"]' ); await page.type( '.select2-search--dropdown > input', config.get( 'products.simple.name' ),