diff --git a/changelog/fix-472-mccy-wc-bookings b/changelog/fix-472-mccy-wc-bookings new file mode 100644 index 00000000000..aaffc2d5921 --- /dev/null +++ b/changelog/fix-472-mccy-wc-bookings @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Update currency conversion method for booking products. diff --git a/includes/multi-currency/Compatibility/WooCommerceBookings.php b/includes/multi-currency/Compatibility/WooCommerceBookings.php index 756e4eef355..bb6bc4bb667 100644 --- a/includes/multi-currency/Compatibility/WooCommerceBookings.php +++ b/includes/multi-currency/Compatibility/WooCommerceBookings.php @@ -10,6 +10,7 @@ use WCPay\MultiCurrency\FrontendCurrencies; use WCPay\MultiCurrency\MultiCurrency; use WCPay\MultiCurrency\Utils; +use WC_Product; /** * Class that controls Multi Currency Compatibility with WooCommerce Bookings Plugin. @@ -43,6 +44,7 @@ public function init() { // Add needed actions and filters if Bookings is active. if ( class_exists( 'WC_Bookings' ) ) { if ( ! is_admin() || wp_doing_ajax() ) { + add_filter( 'woocommerce_bookings_calculated_booking_cost', [ $this, 'adjust_amount_for_calculated_booking_cost' ], 50, 1 ); add_filter( 'woocommerce_product_get_block_cost', [ $this, 'get_price' ], 50, 1 ); add_filter( 'woocommerce_product_get_cost', [ $this, 'get_price' ], 50, 1 ); add_filter( 'woocommerce_product_get_display_cost', [ $this, 'get_price' ], 50, 1 ); @@ -50,7 +52,7 @@ public function init() { add_filter( 'woocommerce_product_booking_person_type_get_cost', [ $this, 'get_price' ], 50, 1 ); add_filter( 'woocommerce_product_get_resource_base_costs', [ $this, 'get_resource_prices' ], 50, 1 ); add_filter( 'woocommerce_product_get_resource_block_costs', [ $this, 'get_resource_prices' ], 50, 1 ); - add_filter( MultiCurrency::FILTER_PREFIX . 'should_convert_product_price', [ $this, 'should_convert_product_price' ] ); + add_filter( MultiCurrency::FILTER_PREFIX . 'should_convert_product_price', [ $this, 'should_convert_product_price' ], 50, 2 ); add_action( 'wp_ajax_wc_bookings_calculate_costs', [ $this, 'add_wc_price_args_filter_for_ajax' ], 9 ); add_action( 'wp_ajax_nopriv_wc_bookings_calculate_costs', [ $this, 'add_wc_price_args_filter_for_ajax' ], 9 ); } @@ -58,7 +60,28 @@ public function init() { } /** - * Returns the price for an item. + * Adjusts the calculated booking cost for the selected currency, applying rounding and charm pricing as necessary. + * + * @param mixed $costs The original calculated booking costs. + * @return mixed The booking cost adjusted for the selected currency. + */ + public function adjust_amount_for_calculated_booking_cost( $costs ) { + /** + * Prevents adjustment of the calculated booking cost during cart addition. + * + * When a booking is added to the cart, the Booking plugin calculates the booking cost and + * overrides the cart item price with this calculated amount. To avoid interfering with this process, + * this function skips any additional adjustments at this stage. + */ + if ( $this->utils->is_call_in_backtrace( [ 'WC_Cart->add_to_cart' ] ) ) { + return $costs; + } + + return $this->multi_currency->adjust_amount_for_selected_currency( $costs ); + } + + /** + * Retrieves the price for an item, converting it based on the selected currency and context. * * @param mixed $price The item's price. * @@ -68,7 +91,19 @@ public function get_price( $price ) { if ( ! $price ) { return $price; } - return $this->multi_currency->get_price( $price, 'product' ); + + // Skip conversion during specific booking cost calculations to avoid double conversion. + if ( $this->utils->is_call_in_backtrace( [ 'WC_Cart->add_to_cart' ] ) && $this->utils->is_call_in_backtrace( [ 'WC_Bookings_Cost_Calculation::calculate_booking_cost' ] ) ) { + return $price; + } + + /** + * When showing the price in HTML, the function applies currency conversion, charm pricing, + * and rounding. For internal calculations, it uses the raw exchange rate, with charm pricing + * and rounding adjustments applied only to the final calculated amount (handled in + * adjust_amount_for_calculated_booking_cost). + */ + return $this->multi_currency->get_price( $price, $this->utils->is_call_in_backtrace( [ 'WC_Product_Booking->get_price_html' ] ) ? 'product' : 'exchange_rate' ); } /** @@ -90,28 +125,17 @@ public function get_resource_prices( $prices ) { /** * Checks to see if the product's price should be converted. * - * @param bool $return Whether to convert the product's price or not. Default is true. + * @param bool $return Whether to convert the product's price or not. Default is true. + * @param WC_Product $product The product instance being checked. * * @return bool True if it should be converted. */ - public function should_convert_product_price( bool $return ): bool { - // If it's already false, return it. - if ( ! $return ) { + public function should_convert_product_price( bool $return, WC_Product $product ): bool { + // If it's already false, or the product is not a booking, ignore it. + if ( ! $return || $product->get_type() !== 'booking' ) { return $return; } - // This prevents a double conversion of the price in the cart. - if ( $this->utils->is_call_in_backtrace( [ 'WC_Product_Booking->get_price' ] ) ) { - $calls = [ - 'WC_Cart_Totals->calculate_item_totals', - 'WC_Cart->get_product_price', - 'WC_Cart->get_product_subtotal', - ]; - if ( $this->utils->is_call_in_backtrace( $calls ) ) { - return false; - } - } - // Fixes price display on product page and in shop. if ( $this->utils->is_call_in_backtrace( [ 'WC_Product_Booking->get_price_html' ] ) ) { return false; diff --git a/includes/multi-currency/MultiCurrency.php b/includes/multi-currency/MultiCurrency.php index c177d253978..2070b8b8c56 100644 --- a/includes/multi-currency/MultiCurrency.php +++ b/includes/multi-currency/MultiCurrency.php @@ -1108,7 +1108,7 @@ public function set_client_format_and_rounding_precision() { woocommerce_admin_meta_boxes.rounding_precision = ; base + is_admin() + && $current_tab && $current_screen + && 'wcpay_multi_currency' === $current_tab + && 'woocommerce_page_wc-settings' === $current_screen->base ); } @@ -1277,7 +1277,7 @@ public function get_all_customer_currencies(): array { $query_union = []; if ( class_exists( 'Automattic\WooCommerce\Utilities\OrderUtil' ) && - \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled() ) { + \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled() ) { foreach ( $currencies as $currency ) { $query_union[] = $wpdb->prepare( "SELECT %s AS currency_code, EXISTS(SELECT currency FROM {$wpdb->prefix}wc_orders WHERE currency=%s LIMIT 1) AS exists_in_orders", @@ -1327,6 +1327,20 @@ public function is_initialized(): bool { return static::$is_initialized; } + /** + * Adjusts the given amount for the currently selected currency. + * + * Applies charm pricing if specified, and adjusts the amount according to + * the selected currency's conversion rate. + * + * @param float $amount The original amount to adjust. + * @param bool $apply_charm_pricing Optional. Whether to apply charm pricing to the adjusted amount. Default true. + * @return float The amount adjusted for the selected currency. + */ + public function adjust_amount_for_selected_currency( $amount, $apply_charm_pricing = true ) { + return $this->get_adjusted_price( $amount, $apply_charm_pricing, $this->get_selected_currency() ); + } + /** * Returns the amount with the backend format. * diff --git a/tests/unit/multi-currency/compatibility/test-class-woocommerce-bookings.php b/tests/unit/multi-currency/compatibility/test-class-woocommerce-bookings.php index 82409afeb38..cda1202ac80 100644 --- a/tests/unit/multi-currency/compatibility/test-class-woocommerce-bookings.php +++ b/tests/unit/multi-currency/compatibility/test-class-woocommerce-bookings.php @@ -51,6 +51,13 @@ class WCPay_Multi_Currency_WooCommerceBookings_Tests extends WCPAY_UnitTestCase */ private $localization_service; + /** + * Mock product. + * + * @var \WC_Product|PHPUnit_Framework_MockObject_MockObject + */ + private $mock_product; + /** * Pre-test setup */ @@ -62,6 +69,14 @@ public function set_up() { $this->mock_frontend_currencies = $this->createMock( FrontendCurrencies::class ); $this->woocommerce_bookings = new WooCommerceBookings( $this->mock_multi_currency, $this->mock_utils, $this->mock_frontend_currencies ); $this->localization_service = new WC_Payments_Localization_Service(); + + $this->mock_product = $this->createMock( \WC_Product::class ); + $this->mock_product + ->method( 'get_id' ) + ->willReturn( 42 ); + $this->mock_product + ->method( 'get_type' ) + ->willReturn( 'booking' ); } public function test_get_price_returns_empty_string() { @@ -95,53 +110,29 @@ public function test_get_resource_prices_returns_converted_prices() { // If false is passed, it should automatically return false. public function test_should_convert_product_price_returns_false_if_false_passed() { $this->mock_utils->expects( $this->exactly( 0 ) )->method( 'is_call_in_backtrace' ); - $this->assertFalse( $this->woocommerce_bookings->should_convert_product_price( false ) ); + $this->assertFalse( $this->woocommerce_bookings->should_convert_product_price( false, $this->mock_product ) ); } - // If the first two sets of calls are found, it should return false. - public function test_should_convert_product_price_returns_false_if_cart_calls_found() { - $first_calls = [ 'WC_Product_Booking->get_price' ]; - $second_calls = [ - 'WC_Cart_Totals->calculate_item_totals', - 'WC_Cart->get_product_price', - 'WC_Cart->get_product_subtotal', - ]; - $this->mock_utils - ->expects( $this->exactly( 2 ) ) - ->method( 'is_call_in_backtrace' ) - ->withConsecutive( [ $first_calls ], [ $second_calls ] ) - ->willReturn( true, true ); - $this->assertFalse( $this->woocommerce_bookings->should_convert_product_price( true ) ); - } - - // If the last set of calls are found, it should return false. - // This also tests to make sure if the first set of calls is found, but not the second, it continues. + // If the get_price_html call is found, it should return false. public function test_should_convert_product_price_returns_false_if_display_calls_found() { - $first_calls = [ 'WC_Product_Booking->get_price' ]; - $second_calls = [ - 'WC_Cart_Totals->calculate_item_totals', - 'WC_Cart->get_product_price', - 'WC_Cart->get_product_subtotal', - ]; - $third_calls = [ 'WC_Product_Booking->get_price_html' ]; + $expected_calls = [ 'WC_Product_Booking->get_price_html' ]; $this->mock_utils - ->expects( $this->exactly( 3 ) ) + ->expects( $this->exactly( 1 ) ) ->method( 'is_call_in_backtrace' ) - ->withConsecutive( [ $first_calls ], [ $second_calls ], [ $third_calls ] ) - ->willReturn( true, false, true ); - $this->assertFalse( $this->woocommerce_bookings->should_convert_product_price( true ) ); + ->with( $expected_calls ) + ->willReturn( true ); + $this->assertFalse( $this->woocommerce_bookings->should_convert_product_price( true, $this->mock_product ) ); } // If no calls are found, it should return true. public function test_should_convert_product_price_returns_true_if_no_calls_found() { - $first_calls = [ 'WC_Product_Booking->get_price' ]; - $third_calls = [ 'WC_Product_Booking->get_price_html' ]; + $expected_calls = [ 'WC_Product_Booking->get_price_html' ]; $this->mock_utils - ->expects( $this->exactly( 2 ) ) + ->expects( $this->exactly( 1 ) ) ->method( 'is_call_in_backtrace' ) - ->withConsecutive( [ $first_calls ], [ $third_calls ] ) - ->willReturn( false, false ); - $this->assertTrue( $this->woocommerce_bookings->should_convert_product_price( true ) ); + ->with( $expected_calls ) + ->willReturn( false ); + $this->assertTrue( $this->woocommerce_bookings->should_convert_product_price( true, $this->mock_product ) ); } public function test_filter_wc_price_args_returns_expected_results() {