Commit b7a09f97473 for woocommerce

commit b7a09f974730b63903ff1b339bcbaf5829592029
Author: Mike Jolley <mike.jolley@me.com>
Date:   Wed Oct 14 12:29:44 2026 +0100

    Keep VAT exemption when checkout saves a logged-in customer's account (#69615)

    * Fix VAT exemption lost when checkout saves a logged-in customer

    * Add changelog entry for keeping customer VAT exemption

    * Simplify session-only key handling and stale session tests

    * Explain why stale sessions only apply session-only values

diff --git a/plugins/woocommerce/changelog/fix-customer-session-keep-session-only-values b/plugins/woocommerce/changelog/fix-customer-session-keep-session-only-values
new file mode 100644
index 00000000000..83b38846bca
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-customer-session-keep-session-only-values
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep a logged-in customer's VAT exemption after a failed payment, so the retry isn't charged tax.
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-customer-data-store-session.php b/plugins/woocommerce/includes/data-stores/class-wc-customer-data-store-session.php
index f6fd37974d7..fde569c1c62 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-customer-data-store-session.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-customer-data-store-session.php
@@ -54,6 +54,16 @@ class WC_Customer_Data_Store_Session extends WC_Data_Store_WP implements WC_Cust
 		'meta_data',
 	);

+	/**
+	 * Session keys that are not saved to the user, so a newer user record cannot make them stale.
+	 *
+	 * @var string[]
+	 */
+	protected $session_only_keys = array(
+		'is_vat_exempt',
+		'calculated_shipping',
+	);
+
 	/**
 	 * Update the session. Note, this does not persist the data to the DB.
 	 *
@@ -108,15 +118,17 @@ class WC_Customer_Data_Store_Session extends WC_Data_Store_WP implements WC_Cust
 		$data = (array) WC()->session->get( 'customer' );

 		/**
-		 * There is a valid session if $data is not empty, and the ID matches the logged in user ID.
-		 *
-		 * If the user object has been updated since the session was created (based on date_modified) we should not load the session - data should be reloaded.
+		 * The session belongs to this customer if $data is not empty, and the ID matches the logged in user ID.
 		 *
 		 * Empty session values must be applied too (hence isset and not empty below): the session snapshot always contains all the keys,
 		 * so an empty value means the field was explicitly cleared and must override the value loaded from the database.
 		 */
-		if ( isset( $data['id'], $data['date_modified'] ) && $data['id'] === (string) $customer->get_id() && $data['date_modified'] === (string) $customer->get_date_modified( 'edit' ) ) {
-			foreach ( $this->session_keys as $session_key ) {
+		if ( isset( $data['id'] ) && $data['id'] === (string) $customer->get_id() ) {
+			// If the user was saved after this session was (e.g. by checkout), the session's copies of saved fields such as
+			// addresses are out of date, so only apply the session-only values. The user has no copy of those to fall back on.
+			$is_current = isset( $data['date_modified'] ) && $data['date_modified'] === (string) $customer->get_date_modified( 'edit' );
+
+			foreach ( $is_current ? $this->session_keys : $this->session_only_keys as $session_key ) {
 				if ( in_array( $session_key, array( 'id', 'date_modified' ), true ) ) {
 					continue;
 				}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php b/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
index abc2a0d6387..a52c89d3445 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
@@ -42,6 +42,10 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {
 			public function process_order_without_payment( $order_id ) {
 				return parent::process_order_without_payment( $order_id );
 			}
+
+			public function process_customer( $data ) {
+				parent::process_customer( $data );
+			}
 		};
 		// phpcs:enable Generic.CodeAnalysis, Squiz.Commenting

@@ -198,6 +202,42 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {
 		$this->assertNotNull( $reloaded_order->get_date_paid(), 'The intentionally payment-free order should have a paid date.' );
 	}

+	/**
+	 * @testdox A logged in customer's VAT exemption should survive checkout saving their account.
+	 */
+	public function test_process_customer_keeps_logged_in_customer_vat_exemption(): void {
+		$customer_id = WC_Helper_Customer::create_customer( 'classic_vat_exempt', 'password', 'classic-vat-exempt@example.com' )->get_id();
+		// Backdate the account so checkout's save gives it a newer timestamp than the session.
+		update_user_meta( $customer_id, 'last_update', time() - MINUTE_IN_SECONDS );
+		wp_set_current_user( $customer_id );
+		WC()->session->init();
+
+		$original_customer = WC()->customer;
+		WC()->customer     = new WC_Customer( $customer_id, true );
+
+		try {
+			WC()->customer->set_is_vat_exempt( true );
+			WC()->customer->save();
+
+			$this->sut->process_customer(
+				array(
+					'billing_first_name' => 'Classic',
+					'billing_address_1'  => '456 Classic Road',
+					'billing_email'      => 'classic-vat-exempt@example.com',
+				)
+			);
+
+			// The session customer is saved on shutdown in a real request.
+			WC()->customer->save();
+
+			$reloaded_customer = new WC_Customer( $customer_id, true );
+			$this->assertTrue( $reloaded_customer->get_is_vat_exempt(), 'VAT exemption should survive the account save so a retry is not charged tax.' );
+			$this->assertSame( '456 Classic Road', $reloaded_customer->get_billing_address_1(), 'The address should come from the saved account.' );
+		} finally {
+			WC()->customer = $original_customer;
+		}
+	}
+
 	/**
 	 * @testdox the customer notes can have linebreaks.
 	 */
diff --git a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-customer-data-store-session-test.php b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-customer-data-store-session-test.php
index f1448ee99fc..64316b3a16a 100644
--- a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-customer-data-store-session-test.php
+++ b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-customer-data-store-session-test.php
@@ -121,18 +121,20 @@ class WC_Customer_Data_Store_Session_Test extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Should ignore the session data entirely when the date modified does not match the persisted customer.
+	 * @testdox Should ignore session addresses but keep session-only values when the date modified does not match the persisted customer.
 	 */
-	public function test_stale_session_data_is_ignored(): void {
+	public function test_stale_session_data_keeps_only_session_only_values(): void {
 		$customer_id = WC_Helper_Customer::create_customer( 'session_stale_data', 'password', 'session-stale-data@example.com' )->get_id();

 		WC()->session->set(
 			'customer',
 			array(
-				'id'                 => (string) $customer_id,
-				'date_modified'      => 'stale-date',
-				'address_2'          => '',
-				'shipping_address_2' => '',
+				'id'                  => (string) $customer_id,
+				'date_modified'       => 'stale-date',
+				'address_2'           => '',
+				'shipping_address_2'  => '',
+				'is_vat_exempt'       => true,
+				'calculated_shipping' => true,
 			)
 		);

@@ -140,6 +142,30 @@ class WC_Customer_Data_Store_Session_Test extends WC_Unit_Test_Case {

 		$this->assertSame( 'Apt 1', $customer->get_billing_address_2(), 'Stale session data should not override the persisted billing values' );
 		$this->assertSame( 'Apt 1', $customer->get_shipping_address_2(), 'Stale session data should not override the persisted shipping values' );
+		$this->assertTrue( $customer->get_is_vat_exempt(), 'VAT exemption is not saved to the account, so a newer account should not discard it' );
+		$this->assertTrue( $customer->get_calculated_shipping(), 'Calculated shipping is not saved to the account, so a newer account should not discard it' );
+	}
+
+	/**
+	 * @testdox Should not apply session-only values from a session that belongs to a different customer.
+	 */
+	public function test_session_only_values_are_ignored_for_a_different_customer(): void {
+		$customer_id = WC_Helper_Customer::create_customer( 'session_other_customer', 'password', 'session-other-customer@example.com' )->get_id();
+
+		WC()->session->set(
+			'customer',
+			array(
+				'id'                  => '0',
+				'date_modified'       => '',
+				'is_vat_exempt'       => true,
+				'calculated_shipping' => true,
+			)
+		);
+
+		$customer = new WC_Customer( $customer_id, true );
+
+		$this->assertFalse( $customer->get_is_vat_exempt(), 'A guest session should not make a logged in customer VAT exempt' );
+		$this->assertFalse( $customer->get_calculated_shipping(), 'A guest session should not carry calculated shipping to a logged in customer' );
 	}

 	/**
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 575693ac2a4..7a290f5079b 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
@@ -3404,6 +3404,41 @@ class Checkout extends \WP_Test_REST_TestCase {
 		$this->assertSame( $order_id, (int) $persisted_session_data['order_awaiting_payment'], 'Redirect payments should leave the persisted order linked to the shopper session.' );
 	}

+	/**
+	 * @testdox A logged in customer's VAT exemption should survive a failed checkout that saves their account.
+	 */
+	public function test_failed_checkout_keeps_logged_in_customer_vat_exemption() {
+		$customer_id = \WC_Helper_Customer::create_customer( 'vat_exempt_retry', 'password', 'vat-exempt-retry@example.com' )->get_id();
+		// Backdate the account so checkout's save gives it a newer timestamp than the session.
+		update_user_meta( $customer_id, 'last_update', time() - MINUTE_IN_SECONDS );
+		wp_set_current_user( $customer_id );
+		WC()->session->init();
+		WC()->customer = new \WC_Customer( $customer_id, true );
+		WC()->customer->set_is_vat_exempt( true );
+		WC()->customer->save();
+
+		$fail_hook = function () {
+			throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException( 'woocommerce_rest_checkout_payment_failed', 'Payment declined.', 400 );
+		};
+		add_action( 'woocommerce_store_api_checkout_order_processed', $fail_hook, 999 );
+
+		$response = rest_get_server()->dispatch( $this->build_valid_post_request() );
+		$this->assertEquals( 400, $response->get_status(), 'Checkout should fail per the forced-failure hook.' );
+
+		// The session customer is saved on shutdown in a real request.
+		WC()->customer->save();
+
+		$this->assertNotSame(
+			(string) WC()->session->get( 'customer' )['date_modified'],
+			(string) ( new \WC_Customer( $customer_id ) )->get_date_modified( 'edit' ),
+			'Checkout should have saved the account, leaving the session snapshot behind it.'
+		);
+
+		$reloaded_customer = new \WC_Customer( $customer_id, true );
+		$this->assertTrue( $reloaded_customer->get_is_vat_exempt(), 'VAT exemption should survive the account save so the retry is not charged tax.' );
+		$this->assertSame( '123 Test St', $reloaded_customer->get_billing_address_1(), 'The address should come from the saved account.' );
+	}
+
 	/**
 	 * Build a valid checkout POST request body for use by the sample-extension tests.
 	 *