Commit aeae3f044a1 for woocommerce

commit aeae3f044a1ebe52595a40e24c7ac40b671259ce
Author: Seghir Nadir <nadir.seghir@gmail.com>
Date:   Mon Aug 17 14:41:40 2026 +0200

    Fix checkout totals guard not accounting for possible changes from address (#67041)

    * Add failing tests reproducing the checkout total-mismatch guard's address-timing gap

    validate_order_totals() (added in #66420) runs before update_customer_from_request(),
    so it checks expected_total against the total for whatever address is already in
    session, not the address this request is submitting.

    Two currently-failing tests reproduce the two failure modes this causes:
    - test_post_data_rejects_expected_total_when_this_requests_address_changes_tax:
      the shopper's confirmed (pre-edit) total matches the stale session total, so the
      guard lets an order through even though the address in this request raises the
      total - a silent overcharge.
    - test_post_data_accepts_expected_total_correctly_computed_for_this_requests_address:
      a single-POST client that correctly computes expected_total for the address it's
      submitting gets wrongly rejected, because the guard compares against the
      addressless session total instead.

    These are intentionally left failing on CI. The fix is being validated separately
    before it lands.

    See #67021.

    * Reject checkout total mismatch only when the shopper would be charged more

    Two changes to the expected_total guard added in #66420:

    - The error now names both totals ("changed from $30.00 to $33.00") and
      exposes them as expected_total/actual_total in the error data.
    - The guard only rejects when the recalculated total is higher than
      expected_total. A lower total (coupon applied, price drop) never needs
      to block the shopper, so it's no longer treated as a mismatch.

    Known gap: this does not fix
    test_post_data_rejects_expected_total_when_this_requests_address_changes_tax,
    which is intentionally left failing. That case needs validate_order_totals()
    reordered relative to update_customer_from_request() so it sees this
    request's own address before comparing totals - out of scope here.

    See #67021.

    * Add changelog entry

    * Apply the request's addresses before validating the checkout total

    validate_order_totals() ran before update_customer_from_request(), so it compared
    expected_total against the total for whatever address was already in the session
    rather than the address the request was submitting. That let an address-driven tax
    or shipping increase through unchecked (the shopper confirmed one total and was
    charged a higher one), while rejecting single-POST clients that correctly computed
    expected_total for the address they were submitting.

    Rather than recalculating a second time, update_customer_from_request() now runs
    before the existing calculate_totals(), so one recalculation covers both the cart
    validation and the guard. validate_cart_not_empty() moves ahead of it since it
    doesn't depend on totals, so nothing from the request is persisted for an empty
    cart.

    This makes the two reproduction tests added in ea3220952d pass without changes.

    See #67021.

    * Address review feedback on the checkout total guard

    - Constrain expected_total to minor-unit digits in the route schema, so the
      integer comparison cannot be weakened by values like "30.00".
    - Stop RouteException filtering falsy additional data, so a confirmed total
      of zero survives into the 409 payload.
    - Clean up tax rates and the tax option in the test class teardown, assert
      the mismatch error code and totals, and guard the accept-case test against
      passing vacuously.

    * Delete extra changelog

    * Apply suggestion from @senadir

diff --git a/plugins/woocommerce/changelog/fix-checkout-total-mismatch-address-timing b/plugins/woocommerce/changelog/fix-checkout-total-mismatch-address-timing
new file mode 100644
index 00000000000..99af35f4df0
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-checkout-total-mismatch-address-timing
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Update Checkout total guard to run after addresses in the request have been applied.
diff --git a/plugins/woocommerce/src/StoreApi/Exceptions/RouteException.php b/plugins/woocommerce/src/StoreApi/Exceptions/RouteException.php
index 21da74bfc14..6a882e6e553 100644
--- a/plugins/woocommerce/src/StoreApi/Exceptions/RouteException.php
+++ b/plugins/woocommerce/src/StoreApi/Exceptions/RouteException.php
@@ -28,8 +28,16 @@ class RouteException extends \Exception {
 	 * @param array  $additional_data  Extra data (key value pairs) to expose in the error response.
 	 */
 	public function __construct( $error_code, $message, $http_status_code = 400, $additional_data = [] ) {
-		$this->error_code      = $error_code;
-		$this->additional_data = array_filter( (array) $additional_data );
+		$this->error_code = $error_code;
+
+		// Only drop values the client cannot act on. `0` and `false` are meaningful here (e.g. a confirmed total of zero).
+		$this->additional_data = array_filter(
+			(array) $additional_data,
+			static function ( $value ) {
+				return null !== $value && '' !== $value && [] !== $value;
+			}
+		);
+
 		parent::__construct( $message, $http_status_code );
 	}

diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
index 6d9fb42ef95..c33f1a42451 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
@@ -108,8 +108,11 @@ class Checkout extends AbstractCartRoute {
 							'type'        => 'string',
 						],
 						'expected_total'    => [
-							'description' => __( 'The order total the shopper confirmed on the client, as a string in the smallest unit of the store currency (e.g. cents), matching the cart `totals.total_price` format. When provided, the order is rejected if the total calculated on the server no longer matches it, protecting the shopper from being charged an unexpected amount.', 'woocommerce' ),
-							'type'        => 'string',
+							'description'       => __( 'The order total the shopper confirmed on the client, as a string in the smallest unit of the store currency (e.g. cents), matching the cart `totals.total_price` format. When provided, the order is rejected if the total calculated on the server no longer matches it, protecting the shopper from being charged an unexpected amount.', 'woocommerce' ),
+							'type'              => 'string',
+							// Digits only. The value is compared as an integer, so decimals or stray characters would silently weaken the check.
+							'pattern'           => '^[0-9]+$',
+							'validate_callback' => 'rest_validate_request_arg',
 						],
 					],
 					$this->schema->get_endpoint_args_for_item_schema( \WP_REST_Server::CREATABLE )
@@ -548,18 +551,26 @@ class Checkout extends AbstractCartRoute {
 		 */
 		$this->validate_user_can_place_order();

-		/**
-		 * Before triggering validation, ensure totals are current and in turn, things such as shipping costs are present.
-		 * This is so plugins that validate other cart data (e.g. conditional shipping and payments) can access this data.
-		 */
-		$this->cart_controller->calculate_totals();
-
 		/**
 		 * Validate that the cart is not empty.
 		 */
 		$this->cart_controller->validate_cart_not_empty();
 		wc_log_order_step( '[Store API #2] Cart validated' );

+		/**
+		 * Persist customer session data from the request first so that OrderController::update_addresses_from_cart
+		 * uses the up-to-date customer address.
+		 */
+		$this->update_customer_from_request( $request );
+		// Customer save-point: 1 (session-stored).
+		wc_log_order_step( '[Store API #3] Updated customer data from request' );
+
+		/**
+		 * Before triggering validation, ensure totals are current and in turn, things such as shipping costs are present.
+		 * This is so plugins that validate other cart data (e.g. conditional shipping and payments) can access this data.
+		 */
+		$this->cart_controller->calculate_totals();
+
 		/**
 		 * Validate items and fix violations before the order is processed.
 		 */
@@ -573,14 +584,6 @@ class Checkout extends AbstractCartRoute {
 		 */
 		$this->validate_order_totals( $request );

-		/**
-		 * Persist customer session data from the request first so that OrderController::update_addresses_from_cart
-		 * uses the up-to-date customer address.
-		 */
-		$this->update_customer_from_request( $request );
-		// Customer save-point: 1 (session-stored).
-		wc_log_order_step( '[Store API #3] Updated customer data from request' );
-
 		/**
 		 * Create (or update) Draft Order and process request data.
 		 */
@@ -1058,9 +1061,7 @@ class Checkout extends AbstractCartRoute {
 	}

 	/**
-	 * Reject the order if the total the shopper confirmed on the client no longer matches the
-	 * total the server recalculates for this place-order request. Without this guard the order
-	 * could be placed — and the shopper charged — at a total they never saw.
+	 * Reject the order if what the customer is going to be charged at is higher from what they saw.
 	 *
 	 * Runs on POST /checkout only, before the draft order is materialised, so a mismatch leaves
 	 * no order behind. The check only runs when the client sends the total it displayed; flows
@@ -1070,9 +1071,10 @@ class Checkout extends AbstractCartRoute {
 	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
 	 *
 	 * @param \WP_REST_Request $request Request object.
-	 * @throws RouteException When the totals differ. Returns HTTP 409 with the refreshed cart so the client can display the updated total.
+	 * @throws RouteException When the recalculated total is higher. Returns HTTP 409 with the refreshed cart so the client can display the updated total.
 	 */
 	private function validate_order_totals( \WP_REST_Request $request ): void {
+		// The route schema constrains this to minor-unit digits, so the integer comparison below is exact.
 		$expected_total = (string) ( $request['expected_total'] ?? '' );

 		if ( '' === $expected_total ) {
@@ -1086,13 +1088,27 @@ class Checkout extends AbstractCartRoute {
 			[ 'decimals' => $decimals ]
 		);

-		if ( $expected_total !== $actual_total ) {
-			throw new RouteException(
-				'woocommerce_rest_checkout_total_mismatch',
-				esc_html__( 'The order total changed while you were checking out. Please review the updated total and place your order again.', 'woocommerce' ),
-				409
-			);
+		if ( (int) $actual_total <= (int) $expected_total ) {
+			return;
 		}
+
+		$expected_amount = wc_remove_number_precision( absint( $expected_total ) );
+		$actual_amount   = wc_remove_number_precision( absint( $actual_total ) );
+
+		throw new RouteException(
+			'woocommerce_rest_checkout_total_mismatch',
+			sprintf(
+				/* translators: %1$s: total the shopper confirmed, %2$s: updated, higher total */
+				esc_html__( 'The order total changed from %1$s to %2$s while you were checking out. Please review the updated total and place your order again.', 'woocommerce' ),
+				esc_html( wc_price( $expected_amount, [ 'in_span' => false ] ) ),
+				esc_html( wc_price( $actual_amount, [ 'in_span' => false ] ) )
+			),
+			409,
+			[
+				'expected_total' => absint( $expected_total ),
+				'actual_total'   => absint( $actual_total ),
+			]
+		);
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
index 30d2dd97fa8..f63c2b0f8e8 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
@@ -20,6 +20,7 @@ use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields;
 use Automattic\WooCommerce\Enums\ProductStockStatus;
 use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
 use WC_Gateway_BACS;
+use WC_Tax;

 /**
  * Checkout Controller Tests.
@@ -60,6 +61,13 @@ class Checkout extends \WP_Test_REST_TestCase {
 	 */
 	private $paypal_gateway_before_test;

+	/**
+	 * Tax rate IDs inserted by the current test, removed in tearDown().
+	 *
+	 * @var int[]
+	 */
+	private $inserted_tax_rate_ids = array();
+
 	/**
 	 * Create immutable catalog rows shared by all test methods.
 	 */
@@ -171,6 +179,8 @@ class Checkout extends \WP_Test_REST_TestCase {
 	 */
 	protected function tearDown(): void {
 		try {
+			$this->remove_inserted_tax_rates();
+
 			remove_filter( 'woocommerce_set_cookie_enabled', array( $this, 'filter_woocommerce_set_cookie_enabled' ) );

 			remove_all_filters( 'woocommerce_get_country_locale' );
@@ -212,6 +222,49 @@ class Checkout extends \WP_Test_REST_TestCase {
 		}
 	}

+	/**
+	 * Enable taxes and add a 10% US/CA rate, so an address in California changes the cart total.
+	 */
+	private function enable_taxes_with_us_ca_rate(): void {
+		update_option( 'woocommerce_calc_taxes', 'yes' );
+
+		$this->inserted_tax_rate_ids[] = WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => 'US',
+				'tax_rate_state'    => 'CA',
+				'tax_rate'          => '10.0000',
+				'tax_rate_name'     => 'CA Sales Tax',
+				'tax_rate_priority' => '1',
+				'tax_rate_compound' => '0',
+				'tax_rate_shipping' => '0',
+				'tax_rate_order'    => '1',
+			)
+		);
+	}
+
+	/**
+	 * Delete the tax rates inserted by the current test, so a failing test does not leak them into later ones.
+	 */
+	private function remove_inserted_tax_rates(): void {
+		if ( ! $this->inserted_tax_rate_ids ) {
+			return;
+		}
+
+		foreach ( $this->inserted_tax_rate_ids as $tax_rate_id ) {
+			WC_Tax::_delete_tax_rate( $tax_rate_id );
+		}
+
+		$this->inserted_tax_rate_ids = array();
+		update_option( 'woocommerce_calc_taxes', 'no' );
+	}
+
+	/**
+	 * Format the current cart total the way the client sends it in `expected_total`.
+	 */
+	private function get_cart_total_in_minor_units(): string {
+		return (string) (int) round( (float) WC()->cart->get_total( 'edit' ) * pow( 10, wc_get_price_decimals() ), 0, PHP_ROUND_HALF_UP );
+	}
+
 	/**
 	 * Invalidate caches for options modified by checkout tests.
 	 */
@@ -227,6 +280,7 @@ class Checkout extends \WP_Test_REST_TestCase {
 			'woocommerce_bacs_settings',
 			'woocommerce_pickup_location_settings',
 			'pickup_location_pickup_locations',
+			'woocommerce_calc_taxes',
 		);

 		foreach ( $option_names as $option_name ) {
@@ -386,6 +440,231 @@ class Checkout extends \WP_Test_REST_TestCase {
 		$this->assertEquals( 409, $response->get_status(), print_r( $data, true ) );
 		$this->assertEquals( 'woocommerce_rest_checkout_total_mismatch', $data['code'] );
 		$this->assertArrayHasKey( 'cart', $data['data'], 'The refreshed cart should be returned so the client can display the updated total.' );
+		$this->assertEquals( 1, $data['data']['expected_total'] );
+		$this->assertGreaterThan( 1, $data['data']['actual_total'] );
+	}
+
+	/**
+	 * @testdox A confirmed total of zero should still be reported back in the mismatch payload.
+	 *
+	 * RouteException filters its additional data, and a naive filter drops `0`, which is a total a free
+	 * cart can legitimately confirm before the request's address adds tax or shipping.
+	 */
+	public function test_post_data_reports_zero_expected_total_in_mismatch_payload() {
+		$request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' );
+		$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+		$request->set_body_params(
+			array(
+				'billing_address'  => (object) array(
+					'first_name' => 'test',
+					'last_name'  => 'test',
+					'company'    => '',
+					'address_1'  => 'test',
+					'address_2'  => '',
+					'city'       => 'test',
+					'state'      => '',
+					'postcode'   => 'cb241ab',
+					'country'    => 'GB',
+					'phone'      => '1234567890',
+					'email'      => 'testaccount@test.com',
+				),
+				'shipping_address' => (object) array(
+					'first_name' => 'test',
+					'last_name'  => 'test',
+					'company'    => '',
+					'address_1'  => 'test',
+					'address_2'  => '',
+					'city'       => 'test',
+					'state'      => '',
+					'postcode'   => 'cb241ab',
+					'country'    => 'GB',
+					'phone'      => '1234567890',
+				),
+				'payment_method'   => WC_Gateway_BACS::ID,
+				'expected_total'   => '0',
+			)
+		);
+		$response = rest_get_server()->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertEquals( 409, $response->get_status(), print_r( $data, true ) );
+		$this->assertArrayHasKey( 'expected_total', $data['data'], 'A confirmed total of zero should not be filtered out of the error payload.' );
+		$this->assertSame( 0, $data['data']['expected_total'] );
+	}
+
+	/**
+	 * Ensure an expected total that is not in the documented minor-unit format is rejected outright,
+	 * rather than being coerced to an integer and silently weakening the guard.
+	 */
+	public function test_post_data_rejects_malformed_expected_total() {
+		$request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' );
+		$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+		$request->set_body_params(
+			array(
+				'billing_address'  => (object) array(
+					'first_name' => 'test',
+					'last_name'  => 'test',
+					'company'    => '',
+					'address_1'  => 'test',
+					'address_2'  => '',
+					'city'       => 'test',
+					'state'      => '',
+					'postcode'   => 'cb241ab',
+					'country'    => 'GB',
+					'phone'      => '1234567890',
+					'email'      => 'testaccount@test.com',
+				),
+				'shipping_address' => (object) array(
+					'first_name' => 'test',
+					'last_name'  => 'test',
+					'company'    => '',
+					'address_1'  => 'test',
+					'address_2'  => '',
+					'city'       => 'test',
+					'state'      => '',
+					'postcode'   => 'cb241ab',
+					'country'    => 'GB',
+					'phone'      => '1234567890',
+				),
+				'payment_method'   => WC_Gateway_BACS::ID,
+				// A major-unit total: `(int) '30.00'` is 30, which would pass a check meant to compare 3000.
+				'expected_total'   => '30.00',
+			)
+		);
+		$response = rest_get_server()->dispatch( $request );
+
+		$this->assertEquals( 400, $response->get_status(), print_r( $response->get_data(), true ) );
+	}
+
+	/**
+	 * @testdox Should reject the order when this request's address changes the total, even if that total matches the pre-request session total.
+	 *
+	 * validate_order_totals() runs before update_customer_from_request(), so it checks the total for the
+	 * address already in the session, not the address this request is submitting. A client that (correctly)
+	 * echoes back the last total it saw before editing the address ends up matching that stale session
+	 * total, and the guard waves the order through even though the address in this very request raises it.
+	 */
+	public function test_post_data_rejects_expected_total_when_this_requests_address_changes_tax() {
+		$this->enable_taxes_with_us_ca_rate();
+
+		// The address already in the session before this request: untaxed.
+		WC()->customer->set_billing_country( 'GB' );
+		WC()->customer->set_shipping_country( 'GB' );
+		WC()->cart->calculate_totals();
+		$expected_total = $this->get_cart_total_in_minor_units();
+
+		$request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' );
+		$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+		$request->set_body_params(
+			array(
+				'billing_address'  => (object) array(
+					'first_name' => 'test',
+					'last_name'  => 'test',
+					'company'    => '',
+					'address_1'  => '123 Main St',
+					'address_2'  => '',
+					'city'       => 'Beverly Hills',
+					'state'      => 'CA',
+					'postcode'   => '90210',
+					'country'    => 'US',
+					'phone'      => '1234567890',
+					'email'      => 'testaccount@test.com',
+				),
+				'shipping_address' => (object) array(
+					'first_name' => 'test',
+					'last_name'  => 'test',
+					'company'    => '',
+					'address_1'  => '123 Main St',
+					'address_2'  => '',
+					'city'       => 'Beverly Hills',
+					'state'      => 'CA',
+					'postcode'   => '90210',
+					'country'    => 'US',
+					'phone'      => '1234567890',
+				),
+				'payment_method'   => WC_Gateway_BACS::ID,
+				// The total for the GB address already in session, not for the CA address below.
+				'expected_total'   => $expected_total,
+			)
+		);
+		$response = rest_get_server()->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertEquals( 409, $response->get_status(), 'The order should be rejected because the CA address in this request raises the total above what the shopper confirmed: ' . print_r( $data, true ) );
+		$this->assertEquals( 'woocommerce_rest_checkout_total_mismatch', $data['code'], 'The rejection should come from the expected_total guard, not another 409.' );
+		$this->assertEquals( (int) $expected_total, $data['data']['expected_total'] );
+		$this->assertGreaterThan( (int) $expected_total, $data['data']['actual_total'] );
+	}
+
+	/**
+	 * @testdox Should accept the order when expected_total is correctly computed for this request's own address.
+	 *
+	 * A single-POST client (per the documented Store API flow) can submit an address for the first time
+	 * in the place-order request itself, with no prior PUT to sync it to the session. If it correctly
+	 * computes expected_total for that address, the order should place - but validate_order_totals() checks
+	 * the pre-request (addressless) session total instead, so it rejects a correctly-computed request.
+	 */
+	public function test_post_data_accepts_expected_total_correctly_computed_for_this_requests_address() {
+		$this->enable_taxes_with_us_ca_rate();
+
+		// No address in the session yet, matching a fresh single-POST checkout.
+		WC()->customer->set_billing_country( '' );
+		WC()->customer->set_shipping_country( '' );
+
+		// Compute the total the order will actually settle at once the CA address in this request
+		// is applied - this is what a correctly implemented client would send as expected_total.
+		WC()->customer->set_billing_country( 'US' );
+		WC()->customer->set_billing_state( 'CA' );
+		WC()->customer->set_shipping_country( 'US' );
+		WC()->customer->set_shipping_state( 'CA' );
+		WC()->cart->calculate_totals();
+		$expected_total = $this->get_cart_total_in_minor_units();
+
+		// Reset the session back to addressless, since the client hasn't PUT the address yet.
+		WC()->customer->set_billing_country( '' );
+		WC()->customer->set_shipping_country( '' );
+		WC()->cart->calculate_totals();
+
+		// Without this the test would pass vacuously if the tax rate above stopped applying.
+		$this->assertNotSame( $expected_total, $this->get_cart_total_in_minor_units(), 'The CA rate must move the total, otherwise this scenario does not exercise the guard.' );
+
+		$request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' );
+		$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+		$request->set_body_params(
+			array(
+				'billing_address'  => (object) array(
+					'first_name' => 'test',
+					'last_name'  => 'test',
+					'company'    => '',
+					'address_1'  => '123 Main St',
+					'address_2'  => '',
+					'city'       => 'Beverly Hills',
+					'state'      => 'CA',
+					'postcode'   => '90210',
+					'country'    => 'US',
+					'phone'      => '1234567890',
+					'email'      => 'testaccount@test.com',
+				),
+				'shipping_address' => (object) array(
+					'first_name' => 'test',
+					'last_name'  => 'test',
+					'company'    => '',
+					'address_1'  => '123 Main St',
+					'address_2'  => '',
+					'city'       => 'Beverly Hills',
+					'state'      => 'CA',
+					'postcode'   => '90210',
+					'country'    => 'US',
+					'phone'      => '1234567890',
+				),
+				'payment_method'   => WC_Gateway_BACS::ID,
+				'expected_total'   => $expected_total,
+			)
+		);
+		$response = rest_get_server()->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status(), 'The order should place because expected_total is exactly what the server will charge for the CA address in this request: ' . print_r( $data, true ) );
 	}

 	/**