Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions includes/classes/Feature/WooCommerce/Products.php
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,15 @@ public function price_filter( $args, $query_args, $query ) {
$max_price = ! empty( $_GET['max_price'] ) ? sanitize_text_field( wp_unslash( $_GET['max_price'] ) ) : null;
// phpcs:enable WordPress.Security.NonceVerification

// Align bounds with the excluding-tax price Elasticsearch indexes when the
// shop shows including-tax prices, matching WooCommerce core.
if ( null !== $min_price ) {
$min_price = $this->get_price_filter_tax_adjustment( (float) $min_price );
}
if ( null !== $max_price ) {
$max_price = $this->get_price_filter_tax_adjustment( (float) $max_price );
}

if ( $query->is_search() ) {
/**
* This logic is iffy but the WC price filter widget is not intended for use with search anyway
Expand Down Expand Up @@ -154,6 +163,44 @@ public function price_filter( $args, $query_args, $query ) {
return $args;
}

/**
* Subtract inclusive tax from a price filter value so it matches the
* excluding-tax price Elasticsearch stores.
*
* Mirrors WooCommerce core (WC_Query::price_filter_post_clauses). Only kicks
* in when prices are entered without tax but the shop shows including-tax
* prices, the case where the Filter by Price widget sends including-tax bounds.
*
* @param float $price Raw bound from min_price or max_price.
* @return float
*/
protected function get_price_filter_tax_adjustment( $price ) {
if ( ! function_exists( 'wc_tax_enabled' ) || ! wc_tax_enabled() ) {
return $price;
}

if ( 'incl' !== get_option( 'woocommerce_tax_display_shop' ) ) {
return $price;
}

if ( function_exists( 'wc_prices_include_tax' ) && wc_prices_include_tax() ) {
return $price;
}

if ( ! method_exists( 'WC_Tax', 'get_rates' ) ) {
return $price;
}

$tax_class = apply_filters( 'woocommerce_price_filter_widget_tax_class', '' ); // Standard tax class.
$tax_rates = \WC_Tax::get_rates( $tax_class );

if ( empty( $tax_rates ) ) {
return $price;
}

return $price - \WC_Tax::get_tax_total( \WC_Tax::calc_inclusive_tax( $price, $tax_rates ) );
}

/**
* Index WooCommerce products meta fields
*
Expand Down
116 changes: 116 additions & 0 deletions tests/php/features/WooCommerce/TestWooCommerceProduct.php
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,122 @@ public function testPriceFilterWithSearchQuery() {
$this->assertEquals( 2, count( $query ) );
}

/**
* Test the price filter subtracts inclusive tax from bounds so they match
* the excluding-tax price indexed by Elasticsearch.
*
* Reproduces issue #4332: prices entered without tax, shop displays including
* tax. The Filter by Price widget sends including-tax bounds, which must be
* reduced to the excluding-tax value before the Elasticsearch range query.
*
* @group woocommerce
* @group woocommerce-products
*/
public function testPriceFilterWithTax() {
global $wpdb, $wp_the_query, $wp_query;

ElasticPress\Features::factory()->activate_feature( 'woocommerce' );
ElasticPress\Features::factory()->setup_features();

// Capture existing values so we can restore them even if assertions fail.
$option_keys = array(
'woocommerce_calc_taxes',
'woocommerce_tax_display_shop',
'woocommerce_prices_include_tax',
'woocommerce_default_country',
);
$old_options = array();
foreach ( $option_keys as $option_key ) {
$old_options[ $option_key ] = get_option( $option_key );
}

// Prices entered without tax, shop displays including tax.
update_option( 'woocommerce_calc_taxes', 'yes' );
update_option( 'woocommerce_tax_display_shop', 'incl' );
update_option( 'woocommerce_prices_include_tax', 'no' );
update_option( 'woocommerce_default_country', 'GB' );

// Seed a 20% tax rate for the base location so WC_Tax::get_rates finds it.
$wpdb->insert(
$wpdb->prefix . 'woocommerce_tax_rates',
array(
'tax_rate_country' => 'GB',
'tax_rate_state' => '',
'tax_rate' => '20',
'tax_rate_name' => 'VAT',
'tax_rate_priority' => 1,
'tax_rate_compound' => 0,
'tax_rate_shipping' => 1,
'tax_rate_order' => 1,
'tax_rate_class' => '',
)
);
$tax_rate_id = $wpdb->insert_id;
\WC_Cache_Helper::invalidate_cache_group( 'taxes' );

try {
$this->ep_factory->product->create(
[
'name' => 'Cap 1',
'regular_price' => 100,
]
);

ElasticPress\Elasticsearch::factory()->refresh_indices();

// Widget bound is the including-tax price (120), stored price is 100.
parse_str( 'min_price=120&max_price=120', $_GET );

$args = array(
'post_type' => 'product',
);
$query = new \WP_Query( $args );

// mock the query as main query and is_search
$wp_the_query = $query;
$wp_query->is_search = true;

add_filter(
'ep_post_formatted_args',
function ( $formatted_args ) {

$expected_result = array(
'range' => array(
'meta._price.long' => array(
'gte' => 100.0,
'lte' => 100.0,
'boost' => 2,
),
),
);

$this->assertEquals( $expected_result, $formatted_args['query'] );
return $formatted_args;
},
15
);

$query = $query->query( $args );

$this->assertTrue( $wp_the_query->elasticsearch_success, 'Elasticsearch query failed' );
$this->assertEquals( 1, count( $query ) );
} finally {
// Restore options and remove the seeded tax rate regardless of outcome.
$wpdb->delete( $wpdb->prefix . 'woocommerce_tax_rates', array( 'tax_rate_id' => $tax_rate_id ) );
\WC_Cache_Helper::invalidate_cache_group( 'taxes' );

foreach ( $old_options as $option_key => $option_value ) {
if ( false === $option_value ) {
delete_option( $option_key );
} else {
update_option( $option_key, $option_value );
}
}

unset( $_GET['min_price'], $_GET['max_price'] );
}
}

/**
* Tests that attributes filter uses Elasticsearch.
*
Expand Down
Loading