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.
*