Commit 2a89b1d57eb for woocommerce

commit 2a89b1d57eb2f26fb4d546ba8e4852897af4c70d
Author: Vasily Belolapotkov <vasily.belolapotkov@automattic.com>
Date:   Mon Sep 14 13:15:52 2026 +0200

    Use store-wide price includes tax mode for created orders (#67926)

    * Record the store's tax entry mode on orders created without one

    Orders created outside the checkout - most visibly from WP Admin - kept
    WC_Abstract_Order's `false` default for prices_include_tax, so on a store
    that enters prices inclusive of tax they were treated as tax exclusive.
    Totals, taxes and coupons then computed against the wrong basis, and the
    value propagated to any order derived from them.

    `false` cannot be distinguished from "never set", so the default becomes
    null and the getter falls back to the store setting, always returning a
    bool. Both data stores record the resolved value when the order is
    created, so an order keeps the tax entry mode it was created with even if
    the store later changes the setting.

    Co-authored-by: Matt Allan <matt.allan@automattic.com>

    * Fix tax mode for admin-created orders

    Persist tax mode at CPT and HPOS admin order creation, preserve historical defaults without read-time fallbacks, and cover explicit programmatic values.

    * Update changelog for admin order tax mode

    * Remove resolved PHPStan baseline entries

    The WC_Order class guard narrows the admin order type, so the five related suppressions are no longer needed.

    * Restore global order state after HPOS test

    * Create admin orders through order data stores

    - Share creation-time defaults between CPT and HPOS
    - Preserve CPT Add Order behavior without read-time fallbacks
    - Expand lifecycle and persistence regression coverage

    * Restore native CPT order creation flow

    Let WordPress create CPT auto-drafts without an early order save or redirect. Load missing tax-mode defaults in the data store and persist them through the normal save path.

    Cover native draft loading, deferred persistence, and preservation of explicit saved values. Leave HPOS order creation unchanged.

    * Update changelog for manual order defaults

    * Initialize order tax mode from store defaults

    * Update changelog for order tax defaults

    * Add Store API checkout tax mode coverage

    ---------

    Co-authored-by: Matt Allan <matt.allan@automattic.com>

diff --git a/plugins/woocommerce/changelog/51426-fix-prices-include-tax-on-order-create b/plugins/woocommerce/changelog/51426-fix-prices-include-tax-on-order-create
new file mode 100644
index 00000000000..d020e691f3a
--- /dev/null
+++ b/plugins/woocommerce/changelog/51426-fix-prices-include-tax-on-order-create
@@ -0,0 +1,4 @@
+Significance: minor
+Type: fix
+
+Initialize new orders with the store's tax entry mode, and preserve saved order values when the store setting changes.
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
index 102fafe9e0f..a9ec774cc37 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
@@ -46,20 +46,19 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 	 * @var array
 	 */
 	protected $data = array(
-		'parent_id'          => 0,
-		'status'             => '',
-		'currency'           => '',
-		'version'            => '',
-		'prices_include_tax' => false,
-		'date_created'       => null,
-		'date_modified'      => null,
-		'discount_total'     => 0,
-		'discount_tax'       => 0,
-		'shipping_total'     => 0,
-		'shipping_tax'       => 0,
-		'cart_tax'           => 0,
-		'total'              => 0,
-		'total_tax'          => 0,
+		'parent_id'      => 0,
+		'status'         => '',
+		'currency'       => '',
+		'version'        => '',
+		'date_created'   => null,
+		'date_modified'  => null,
+		'discount_total' => 0,
+		'discount_tax'   => 0,
+		'shipping_total' => 0,
+		'shipping_tax'   => 0,
+		'cart_tax'       => 0,
+		'total'          => 0,
+		'total_tax'      => 0,
 	);

 	/**
@@ -188,6 +187,8 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 			$this->data['cogs_total_value'] = 0;
 		}

+		$this->data['prices_include_tax'] = 'yes' === get_option( 'woocommerce_prices_include_tax' );
+
 		parent::__construct( $order );

 		if ( is_numeric( $order ) && $order > 0 ) {
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index ad86d1d556c..e015223bf5d 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -57286,36 +57286,6 @@ parameters:
 			count: 1
 			path: src/Internal/Admin/Orders/PageController.php

-		-
-			message: '#^Call to an undefined method object\:\:save\(\)\.$#'
-			identifier: method.notFound
-			count: 1
-			path: src/Internal/Admin/Orders/PageController.php
-
-		-
-			message: '#^Call to an undefined method object\:\:set_created_via\(\)\.$#'
-			identifier: method.notFound
-			count: 1
-			path: src/Internal/Admin/Orders/PageController.php
-
-		-
-			message: '#^Call to an undefined method object\:\:set_object_read\(\)\.$#'
-			identifier: method.notFound
-			count: 1
-			path: src/Internal/Admin/Orders/PageController.php
-
-		-
-			message: '#^Call to an undefined method object\:\:set_status\(\)\.$#'
-			identifier: method.notFound
-			count: 1
-			path: src/Internal/Admin/Orders/PageController.php
-
-		-
-			message: '#^Cannot access offset ''class_name'' on array\|bool\.$#'
-			identifier: offsetAccess.nonOffsetAccessible
-			count: 1
-			path: src/Internal/Admin/Orders/PageController.php
-
 		-
 			message: '#^Cannot access property \$action on WP_Screen\|null\.$#'
 			identifier: property.nonObject
@@ -57382,12 +57352,6 @@ parameters:
 			count: 1
 			path: src/Internal/Admin/Orders/PageController.php

-		-
-			message: '#^Property Automattic\\WooCommerce\\Internal\\Admin\\Orders\\PageController\:\:\$order \(WC_Order\) does not accept object\.$#'
-			identifier: assign.propertyType
-			count: 1
-			path: src/Internal/Admin/Orders/PageController.php
-
 		-
 			message: '#^Property Automattic\\WooCommerce\\Internal\\Admin\\Orders\\PageController\:\:\$order \(WC_Order\) in isset\(\) is not nullable\.$#'
 			identifier: isset.property
diff --git a/plugins/woocommerce/src/Internal/Admin/Orders/PageController.php b/plugins/woocommerce/src/Internal/Admin/Orders/PageController.php
index c91f938b37a..c1f705295fd 100644
--- a/plugins/woocommerce/src/Internal/Admin/Orders/PageController.php
+++ b/plugins/woocommerce/src/Internal/Admin/Orders/PageController.php
@@ -378,7 +378,12 @@ class PageController {

 		$this->verify_create_permission();

-		$order_class_name = wc_get_order_type( $this->order_type )['class_name'];
+		/**
+		 * Registered order class.
+		 *
+		 * @var class-string<\WC_Order>|'' $order_class_name
+		 */
+		$order_class_name = wc_get_order_type( $this->order_type )['class_name'] ?? '';
 		if ( ! $order_class_name || ! class_exists( $order_class_name ) ) {
 			wp_die();
 		}
diff --git a/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-order-test.php b/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-order-test.php
index 2e6fa2e8637..7b728aeac58 100644
--- a/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-order-test.php
+++ b/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-order-test.php
@@ -25,6 +25,140 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
 	 */
 	private $registered_order_statuses = array();

+	/**
+	 * @testdox Order and refund defaults capture the store tax mode without pending changes.
+	 * @dataProvider provide_order_tax_defaults
+	 *
+	 * @param string $order_class Order class.
+	 * @param string $tax_mode Store tax mode.
+	 * @param bool   $expected Expected order tax mode.
+	 * @phpstan-param class-string<WC_Abstract_Order> $order_class
+	 */
+	public function test_constructor_initializes_prices_include_tax( string $order_class, string $tax_mode, bool $expected ): void {
+		$previous_tax_mode = get_option( 'woocommerce_prices_include_tax' );
+
+		try {
+			update_option( 'woocommerce_prices_include_tax', $tax_mode );
+			$sut = new $order_class();
+
+			$this->assertSame( $expected, $sut->get_prices_include_tax( 'edit' ) );
+			$this->assertArrayNotHasKey( 'prices_include_tax', $sut->get_changes(), 'Initialization must not be a pending edit.' );
+
+			update_option( 'woocommerce_prices_include_tax', 'yes' === $tax_mode ? 'no' : 'yes' );
+			$this->assertSame( $expected, $sut->get_prices_include_tax( 'edit' ), 'The default must be captured at construction, not at read time.' );
+		} finally {
+			update_option( 'woocommerce_prices_include_tax', $previous_tax_mode );
+		}
+	}
+
+	/**
+	 * Provides order classes and store tax modes.
+	 *
+	 * @return array<string, array{class-string<WC_Abstract_Order>, string, bool}>
+	 */
+	public static function provide_order_tax_defaults(): array {
+		return array(
+			'inclusive order'  => array( WC_Order::class, 'yes', true ),
+			'exclusive order'  => array( WC_Order::class, 'no', false ),
+			'inclusive refund' => array( WC_Order_Refund::class, 'yes', true ),
+			'exclusive refund' => array( WC_Order_Refund::class, 'no', false ),
+		);
+	}
+
+	/**
+	 * @testdox CRUD persists the initial tax mode or explicit override independently of later store settings.
+	 * @dataProvider provide_order_tax_persistence
+	 *
+	 * @param string    $tax_mode Store tax mode at construction.
+	 * @param bool|null $override Explicit tax mode, or null to leave the default untouched.
+	 * @param bool      $expected Expected persisted tax mode.
+	 */
+	public function test_prices_include_tax_persists_initial_value( string $tax_mode, ?bool $override, bool $expected ): void {
+		$previous_tax_mode = get_option( 'woocommerce_prices_include_tax' );
+		$sut               = null;
+
+		try {
+			update_option( 'woocommerce_prices_include_tax', $tax_mode );
+			$sut = new WC_Order();
+			if ( null !== $override ) {
+				$sut->set_prices_include_tax( $override );
+			}
+			update_option( 'woocommerce_prices_include_tax', $expected ? 'no' : 'yes' );
+			$sut->save();
+
+			$reloaded_order = new WC_Order( $sut->get_id() );
+			$this->assertSame( $expected, $reloaded_order->get_prices_include_tax( 'edit' ), 'Saving and loading must preserve the order value, not use the current store setting.' );
+			$this->assertArrayNotHasKey( 'prices_include_tax', $reloaded_order->get_changes(), 'Loading a persisted value must not mark it as changed.' );
+		} finally {
+			if ( $sut ) {
+				$sut->delete( true );
+			}
+			update_option( 'woocommerce_prices_include_tax', $previous_tax_mode );
+		}
+	}
+
+	/**
+	 * Provides tax defaults and explicit overrides.
+	 *
+	 * @return array<string, array{string, bool|null, bool}>
+	 */
+	public static function provide_order_tax_persistence(): array {
+		return array(
+			'inclusive default' => array( 'yes', null, true ),
+			'exclusive default' => array( 'no', null, false ),
+			'explicit false'    => array( 'yes', false, false ),
+			'explicit true'     => array( 'no', true, true ),
+		);
+	}
+
+	/**
+	 * @testdox Refund creation preserves the parent order tax mode despite a different store default.
+	 * @dataProvider provide_order_tax_persistence
+	 *
+	 * @param string    $tax_mode Store tax mode at parent construction.
+	 * @param bool|null $override Explicit parent tax mode, or null for the default.
+	 * @param bool      $expected Expected refund tax mode.
+	 */
+	public function test_refund_inherits_parent_prices_include_tax( string $tax_mode, ?bool $override, bool $expected ): void {
+		$previous_tax_mode = get_option( 'woocommerce_prices_include_tax' );
+		$order             = null;
+		$sut               = null;
+
+		// This persistence test must not leave email logs for subsequent logging tests.
+		add_filter( 'woocommerce_email_log_enabled', '__return_false' );
+
+		try {
+			update_option( 'woocommerce_prices_include_tax', $tax_mode );
+			$order = new WC_Order();
+			if ( null !== $override ) {
+				$order->set_prices_include_tax( $override );
+			}
+			$order->set_total( '10' );
+			$order->save();
+			update_option( 'woocommerce_prices_include_tax', $expected ? 'no' : 'yes' );
+
+			$sut = wc_create_refund(
+				array(
+					'order_id' => $order->get_id(),
+					'amount'   => 1,
+				)
+			);
+			$this->assertInstanceOf( WC_Order_Refund::class, $sut );
+			$reloaded_refund = new WC_Order_Refund( $sut->get_id() );
+			$this->assertSame( $expected, $reloaded_refund->get_prices_include_tax( 'edit' ) );
+			$this->assertArrayNotHasKey( 'prices_include_tax', $reloaded_refund->get_changes() );
+		} finally {
+			if ( $sut instanceof WC_Order_Refund ) {
+				$sut->delete( true );
+			}
+			if ( $order ) {
+				$order->delete( true );
+			}
+			remove_filter( 'woocommerce_email_log_enabled', '__return_false' );
+			update_option( 'woocommerce_prices_include_tax', $previous_tax_mode );
+		}
+	}
+
 	/**
 	 * Runs after each test.
 	 */
diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-post-types-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-post-types-test.php
index 32f543cd38d..e8d044996cd 100644
--- a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-post-types-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-post-types-test.php
@@ -8,6 +8,8 @@
 declare( strict_types = 1 );

 use Automattic\WooCommerce\Enums\ProductType;
+use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
+use Automattic\WooCommerce\Utilities\OrderUtil;

 /**
  * Class WC_Admin_Post_Types_Test.
@@ -61,6 +63,73 @@ class WC_Admin_Post_Types_Test extends WC_Unit_Test_Case {
 		parent::tearDown();
 	}

+	/**
+	 * @testdox The CPT Add Order screen leaves insertion to WordPress without redirecting or eagerly saving order metadata.
+	 */
+	public function test_new_order_screen_leaves_creation_to_wordpress(): void {
+		global $wp_filter;
+
+		$previous_hpos_state = OrderUtil::custom_orders_table_usage_is_enabled();
+		$previous_get        = $_GET; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+		$redirected_to       = '';
+		$order_id            = 0;
+		$hooked_admin        = new WC_Admin_Post_Types();
+		$intercept_redirect  = static function ( string $location ) use ( &$redirected_to, &$order_id ) {
+			$redirected_to = $location;
+			parse_str( (string) wp_parse_url( $location, PHP_URL_QUERY ), $query_args );
+			$order_id = absint( $query_args['post'] ?? 0 );
+			throw new RuntimeException( 'Order redirect intercepted.' );
+		};
+		$draft_query         = array(
+			'post_type'   => 'shop_order',
+			'post_status' => 'auto-draft',
+			'fields'      => 'ids',
+			'numberposts' => -1,
+		);
+
+		add_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
+		add_filter( 'wp_redirect', $intercept_redirect );
+		OrderHelper::toggle_cot_feature_and_usage( false );
+
+		try {
+			$_GET            = array( 'post_type' => 'shop_order' ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+			$existing_drafts = get_posts( $draft_query );
+
+			try {
+				do_action( 'load-post-new.php' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment, WordPress.NamingConventions.ValidHookName.UseUnderscores -- Simulate the existing WordPress page-load hook.
+			} catch ( RuntimeException $error ) {
+				$this->assertSame( 'Order redirect intercepted.', $error->getMessage() );
+			}
+
+			$this->assertSame( '', $redirected_to, 'WooCommerce must let WordPress continue handling the Add Order request.' );
+			$this->assertSame( $existing_drafts, get_posts( $draft_query ), 'Loading the screen must not pre-create an order.' );
+
+			$post     = get_default_post_to_edit( 'shop_order', true );
+			$order_id = $post->ID;
+
+			$this->assertSame( 'auto-draft', $post->post_status );
+			$this->assertFalse( metadata_exists( 'post', $order_id, '_prices_include_tax' ), 'The native draft should not have tax metadata until a CRUD save.' );
+			$this->assertFalse( metadata_exists( 'post', $order_id, '_order_currency' ), 'The native draft should not have currency metadata until a CRUD save.' );
+		} finally {
+			if ( $order_id ) {
+				wp_delete_post( $order_id, true );
+			}
+			$_GET = $previous_get; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+			OrderHelper::toggle_cot_feature_and_usage( $previous_hpos_state );
+			remove_filter( 'wp_redirect', $intercept_redirect );
+			remove_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
+			foreach ( $wp_filter as $hook_name => $hook ) {
+				foreach ( $hook->callbacks ?? array() as $priority => $callbacks ) {
+					foreach ( $callbacks as $callback ) {
+						if ( is_array( $callback['function'] ) && $hooked_admin === $callback['function'][0] ) {
+							remove_filter( $hook_name, $callback['function'], $priority );
+						}
+					}
+				}
+			}
+		}
+	}
+
 	/**
 	 * @testdox Quick Edit explicitly saves active, future, expired, and empty sale schedules for supported product types.
 	 * @dataProvider explicit_schedule_provider
diff --git a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-order-data-store-cpt-test.php b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-order-data-store-cpt-test.php
index 2ccc0bbff5d..3ac20437fea 100644
--- a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-order-data-store-cpt-test.php
+++ b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-order-data-store-cpt-test.php
@@ -828,6 +828,151 @@ class WC_Order_Data_Store_CPT_Test extends WC_Unit_Test_Case {
 		$this->assertTrue( $read_order->get_prices_include_tax() );
 	}

+	/**
+	 * @testdox An order with missing tax-mode metadata uses the store setting without writing during the read.
+	 */
+	public function test_reading_order_without_prices_include_tax_metadata_uses_store_setting(): void {
+		$previous_tax_mode = get_option( 'woocommerce_prices_include_tax' );
+		$order             = null;
+
+		try {
+			update_option( 'woocommerce_prices_include_tax', 'yes' );
+
+			$order = WC_Helper_Order::create_order();
+			delete_post_meta( $order->get_id(), '_prices_include_tax' );
+			wp_cache_flush();
+
+			$read_order = wc_get_order( $order->get_id() );
+
+			$this->assertTrue(
+				$read_order->get_prices_include_tax(),
+				'Missing metadata should use the existing CPT fallback to the store setting.'
+			);
+			$this->assertFalse( metadata_exists( 'post', $order->get_id(), '_prices_include_tax' ), 'Reading must not persist the fallback.' );
+		} finally {
+			if ( $order ) {
+				$order->delete( true );
+			}
+			update_option( 'woocommerce_prices_include_tax', $previous_tax_mode );
+		}
+	}
+
+	/**
+	 * @testdox A native order draft loads store defaults in memory and persists them on its next CRUD save.
+	 * @dataProvider provide_native_draft_tax_modes
+	 *
+	 * @param string $tax_mode    Store tax-mode setting.
+	 * @param bool   $expected    Expected order tax mode.
+	 * @param string $post_status Post status when WooCommerce first loads the order.
+	 */
+	public function test_native_draft_loads_and_persists_store_defaults( string $tax_mode, bool $expected, string $post_status ): void {
+		$previous_tax_mode = get_option( 'woocommerce_prices_include_tax' );
+		$previous_currency = get_option( 'woocommerce_currency' );
+		$order_id          = 0;
+
+		try {
+			update_option( 'woocommerce_prices_include_tax', $tax_mode );
+			update_option( 'woocommerce_currency', 'EUR' );
+			$post     = get_default_post_to_edit( 'shop_order', true );
+			$order_id = $post->ID;
+
+			if ( 'auto-draft' !== $post_status ) {
+				// WordPress changes auto-draft to draft before the admin metadata save callbacks run.
+				wp_update_post(
+					array(
+						'ID'          => $order_id,
+						'post_status' => $post_status,
+					)
+				);
+			}
+
+			$sut = new WC_Order( $order_id );
+
+			$this->assertSame( $expected, $sut->get_prices_include_tax( 'edit' ), 'Missing tax mode should use the store setting.' );
+			$this->assertSame( 'EUR', $sut->get_currency( 'edit' ), 'Missing currency should use the store setting.' );
+			$this->assertFalse( metadata_exists( 'post', $order_id, '_prices_include_tax' ), 'Loading must not persist tax metadata.' );
+			$this->assertFalse( metadata_exists( 'post', $order_id, '_order_currency' ), 'Loading must not persist currency metadata.' );
+
+			update_option( 'woocommerce_prices_include_tax', 'yes' === $tax_mode ? 'no' : 'yes' );
+			update_option( 'woocommerce_currency', 'USD' );
+			$this->assertSame( $expected, $sut->get_prices_include_tax(), 'The getter should return the loaded property, not reread store settings.' );
+			$this->assertSame( 'EUR', $sut->get_currency(), 'The loaded currency should remain unchanged until the order is reloaded.' );
+
+			$sut->save();
+
+			$this->assertSame( $tax_mode, get_post_meta( $order_id, '_prices_include_tax', true ), 'Saving must write the loaded tax mode even without a setter call.' );
+			$this->assertSame( 'EUR', get_post_meta( $order_id, '_order_currency', true ), 'Saving must write the loaded currency.' );
+
+			$reloaded_order = new WC_Order( $order_id );
+			$this->assertSame( $expected, $reloaded_order->get_prices_include_tax(), 'Persisted tax mode must take precedence over changed store settings.' );
+			$this->assertSame( 'EUR', $reloaded_order->get_currency(), 'Persisted currency must take precedence over changed store settings.' );
+		} finally {
+			if ( $order_id ) {
+				wp_delete_post( $order_id, true );
+			}
+			update_option( 'woocommerce_prices_include_tax', $previous_tax_mode );
+			update_option( 'woocommerce_currency', $previous_currency );
+		}
+	}
+
+	/**
+	 * Provides tax modes for the initial draft load and the first native form submission.
+	 *
+	 * @return array<string, array{string, bool, string}>
+	 */
+	public static function provide_native_draft_tax_modes(): array {
+		return array(
+			'inclusive auto-draft'       => array( 'yes', true, 'auto-draft' ),
+			'exclusive auto-draft'       => array( 'no', false, 'auto-draft' ),
+			'inclusive first submission' => array( 'yes', true, 'draft' ),
+			'exclusive first submission' => array( 'no', false, 'draft' ),
+		);
+	}
+
+	/**
+	 * @testdox A persisted tax mode is preserved when the store setting differs, including explicit false on a draft.
+	 * @dataProvider provide_persisted_order_tax_modes
+	 *
+	 * @param string $tax_mode    Store tax-mode setting.
+	 * @param bool   $saved_value Persisted order tax mode.
+	 * @param string $status      Order status.
+	 */
+	public function test_reading_order_preserves_persisted_tax_mode( string $tax_mode, bool $saved_value, string $status ): void {
+		$previous_tax_mode = get_option( 'woocommerce_prices_include_tax' );
+		$order             = new WC_Order();
+
+		try {
+			update_option( 'woocommerce_prices_include_tax', $tax_mode );
+			$order->set_status( $status );
+			$order->set_prices_include_tax( $saved_value );
+			$order->save();
+
+			$sut = new WC_Order( $order->get_id() );
+			$this->assertSame( $saved_value, $sut->get_prices_include_tax(), 'An existing value must not be replaced with a dynamic default.' );
+			$sut->save();
+			$this->assertSame( $saved_value ? 'yes' : 'no', get_post_meta( $order->get_id(), '_prices_include_tax', true ) );
+		} finally {
+			if ( $order->get_id() ) {
+				$order->delete( true );
+			}
+			update_option( 'woocommerce_prices_include_tax', $previous_tax_mode );
+		}
+	}
+
+	/**
+	 * Provides persisted tax modes that disagree with the store setting.
+	 *
+	 * @return array<string, array{string, bool, string}>
+	 */
+	public static function provide_persisted_order_tax_modes(): array {
+		return array(
+			'explicit false on draft'           => array( 'yes', false, 'auto-draft' ),
+			'explicit true on draft'            => array( 'no', true, 'auto-draft' ),
+			'explicit false on completed order' => array( 'yes', false, 'completed' ),
+			'explicit true on completed order'  => array( 'no', true, 'completed' ),
+		);
+	}
+
 	/**
 	 * Test that order props saved by data stores are read correctly.
 	 */
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 abbb79f9a44..7a3d7053c35 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
@@ -351,6 +351,139 @@ class Checkout extends \WP_Test_REST_TestCase {
 		$this->assertEquals( 200, $response->get_status(), print_r( $response->get_data(), true ) );
 	}

+	/**
+	 * @testdox Should preserve cart tax totals through checkout and order reload.
+	 *
+	 * @dataProvider checkout_tax_totals_provider
+	 * @param string $tax_mode Whether catalog prices include tax.
+	 * @param string $country Customer billing country.
+	 * @param bool   $adjust_non_base_prices Whether to adjust prices outside the base location.
+	 * @param string $expected_subtotal Expected line subtotal excluding tax.
+	 * @param string $expected_tax Expected tax total.
+	 * @param string $expected_total Expected order total.
+	 */
+	public function test_checkout_preserves_cart_tax_totals( string $tax_mode, string $country, bool $adjust_non_base_prices, string $expected_subtotal, string $expected_tax, string $expected_total ): void {
+		$options          = array(
+			'woocommerce_calc_taxes'         => 'yes',
+			'woocommerce_prices_include_tax' => $tax_mode,
+			'woocommerce_tax_based_on'       => 'billing',
+			'woocommerce_default_country'    => 'GB',
+			'woocommerce_price_num_decimals' => '2',
+		);
+		$previous_options = array();
+		foreach ( $options as $name => $value ) {
+			$previous_options[ $name ] = get_option( $name );
+			update_option( $name, $value );
+		}
+		$adjust_prices_filter = $adjust_non_base_prices ? '__return_true' : '__return_false';
+		add_filter( 'woocommerce_adjust_non_base_location_prices', $adjust_prices_filter );
+		$tax_rate_ids = array();
+
+		try {
+			\WC_Tax::create_tax_class( 'Checkout tax totals', 'checkout-tax-totals' );
+			$tax_rates = array(
+				'GB' => '20',
+				'DE' => '10',
+			);
+			foreach ( $tax_rates as $tax_country => $rate ) {
+				$tax_rate_ids[] = \WC_Tax::_insert_tax_rate(
+					array(
+						'tax_rate_country'  => $tax_country,
+						'tax_rate'          => $rate,
+						'tax_rate_name'     => 'Checkout tax',
+						'tax_rate_priority' => 1,
+						'tax_rate_compound' => 0,
+						'tax_rate_shipping' => 0,
+						'tax_rate_class'    => 'checkout-tax-totals',
+					)
+				);
+			}
+			$fixtures = new FixtureData();
+			$product  = $fixtures->get_simple_product(
+				array(
+					'regular_price' => '132',
+					'virtual'       => true,
+					'tax_status'    => 'taxable',
+					'tax_class'     => 'checkout-tax-totals',
+				)
+			);
+			WC()->cart->empty_cart();
+			WC()->cart->add_to_cart( $product->get_id() );
+			WC()->customer->set_is_vat_exempt( false );
+			$billing_address = (object) array(
+				'first_name' => 'Test',
+				'last_name'  => 'Customer',
+				'address_1'  => '1 Test Street',
+				'city'       => 'Test City',
+				'state'      => '',
+				'postcode'   => 'GB' === $country ? 'CB24 1AB' : '10115',
+				'country'    => $country,
+				'email'      => 'testaccount@test.com',
+			);
+
+			$request = new \WP_REST_Request( 'POST', '/wc/store/v1/cart/update-customer' );
+			$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+			$request->set_body_params( array( 'billing_address' => $billing_address ) );
+			$cart_response = rest_get_server()->dispatch( $request );
+			$this->assertSame( 200, $cart_response->get_status(), 'The cart request should succeed.' );
+			$cart_totals = (array) $cart_response->get_data()['totals'];
+			$this->assertSame( $expected_subtotal, $cart_totals['total_items'], 'Cart line subtotal should exclude the applicable tax.' );
+			$this->assertSame( $expected_tax, $cart_totals['total_tax'], 'Cart tax should use the customer location and adjustment setting.' );
+			$this->assertSame( $expected_total, $cart_totals['total_price'], 'Cart total should match the hand-calculated amount.' );
+
+			$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' => $billing_address,
+					'payment_method'  => WC_Gateway_BACS::ID,
+					'expected_total'  => $expected_total,
+				)
+			);
+			$response = rest_get_server()->dispatch( $request );
+			$this->assertSame( 200, $response->get_status(), print_r( $response->get_data(), true ) );
+			$this->assertGreaterThan( 0, $response->get_data()['order_id'], 'Checkout should create an order.' );
+
+			update_option( 'woocommerce_prices_include_tax', 'yes' === $tax_mode ? 'no' : 'yes' );
+			$order = new \WC_Order( $response->get_data()['order_id'] );
+			$this->assertSame( 'yes' === $tax_mode, $order->get_prices_include_tax(), 'Persisted checkout tax mode should survive a store setting change.' );
+			$this->assertSame( (float) $expected_subtotal / 100, (float) $order->get_subtotal(), 'Saved line subtotal should match the cart.' );
+			$this->assertSame( (float) $expected_tax / 100, (float) $order->get_total_tax(), 'Saved order tax should match the cart.' );
+			$this->assertSame( (float) $expected_total / 100, (float) $order->get_total(), 'Saved order total should match the cart.' );
+		} finally {
+			remove_filter( 'woocommerce_adjust_non_base_location_prices', $adjust_prices_filter );
+			foreach ( $tax_rate_ids as $tax_rate_id ) {
+				\WC_Tax::_delete_tax_rate( $tax_rate_id );
+			}
+			\WC_Tax::delete_tax_class_by( 'slug', 'checkout-tax-totals' );
+			foreach ( $previous_options as $name => $value ) {
+				if ( false === $value ) {
+					delete_option( $name );
+				} else {
+					update_option( $name, $value );
+				}
+			}
+		}
+	}
+
+	/**
+	 * Tax totals for a 132 catalog price with 20% base tax and 10% non-base tax, in minor units.
+	 *
+	 * @return array
+	 */
+	public static function checkout_tax_totals_provider(): array {
+		return array(
+			'inclusive base adjusted'       => array( 'yes', 'GB', true, '11000', '2200', '13200' ),
+			'inclusive base unadjusted'     => array( 'yes', 'GB', false, '11000', '2200', '13200' ),
+			'inclusive non-base adjusted'   => array( 'yes', 'DE', true, '11000', '1100', '12100' ),
+			'inclusive non-base unadjusted' => array( 'yes', 'DE', false, '12000', '1200', '13200' ),
+			'exclusive base adjusted'       => array( 'no', 'GB', true, '13200', '2640', '15840' ),
+			'exclusive base unadjusted'     => array( 'no', 'GB', false, '13200', '2640', '15840' ),
+			'exclusive non-base adjusted'   => array( 'no', 'DE', true, '13200', '1320', '14520' ),
+			'exclusive non-base unadjusted' => array( 'no', 'DE', false, '13200', '1320', '14520' ),
+		);
+	}
+
 	/**
 	 * Ensure an order is placed when the expected total sent by the client matches the server total.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Orders/PageControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Orders/PageControllerTest.php
index a2a5daa8ced..daf094dddea 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Orders/PageControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Orders/PageControllerTest.php
@@ -3,6 +3,7 @@
 namespace Automattic\WooCommerce\Tests\Internal\Admin\Orders {

 	use Automattic\WooCommerce\Internal\Admin\Orders\PageController;
+	use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
 	use Automattic\WooCommerce\RestApi\UnitTests\HPOSToggleTrait;
 	use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
 	use Automattic\WooCommerce\Utilities\OrderUtil;
@@ -198,6 +199,107 @@ namespace Automattic\WooCommerce\Tests\Internal\Admin\Orders {
 			$controller->setup();
 			$this->assertFalse( $controller->is_order_screen( 'shop_order', 'new' ) );
 		}
+
+		/**
+		 * @testDox HPOS admin creation persists the store tax-mode setting on the order.
+		 * @dataProvider provide_store_tax_modes
+		 *
+		 * @param string $tax_mode Store tax-mode setting.
+		 * @param bool   $expected Expected order tax mode.
+		 */
+		public function test_new_order_persists_prices_include_tax_setting( string $tax_mode, bool $expected ): void {
+			global $pagenow, $plugin_page, $theorder, $wpdb;
+
+			$had_pagenow       = array_key_exists( 'pagenow', $GLOBALS );
+			$had_plugin_page   = array_key_exists( 'plugin_page', $GLOBALS );
+			$had_theorder      = array_key_exists( 'theorder', $GLOBALS );
+			$previous_pagenow  = $pagenow ?? null;
+			$previous_page     = $plugin_page ?? null;
+			$previous_theorder = $theorder ?? null;
+			$previous_get      = $_GET; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+			$previous_tax_mode = get_option( 'woocommerce_prices_include_tax' );
+			$previous_currency = get_option( 'woocommerce_currency' );
+			$order_id          = 0;
+
+			try {
+				$this->toggle_cot_feature_and_usage( true );
+				update_option( 'woocommerce_prices_include_tax', $tax_mode );
+				update_option( 'woocommerce_currency', 'EUR' );
+				set_current_screen();
+
+				$pagenow        = 'admin.php'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+				$plugin_page    = 'wc-orders'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+				$_GET['action'] = 'new';
+
+				$controller = new PageController();
+				$controller->setup();
+				$controller->handle_load_page_action();
+
+				$order_id = $theorder->get_id();
+
+				$stored_tax_mode = $wpdb->get_var(
+					$wpdb->prepare(
+						'SELECT prices_include_tax FROM ' . OrdersTableDataStore::get_operational_data_table_name() . ' WHERE order_id = %d', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+						$order_id
+					)
+				);
+				$this->assertSame( $expected ? '1' : '0', $stored_tax_mode, 'The HPOS operational table should contain the creation-time tax mode.' );
+
+				update_option( 'woocommerce_prices_include_tax', 'yes' === $tax_mode ? 'no' : 'yes' );
+				update_option( 'woocommerce_currency', 'USD' );
+				wp_cache_flush();
+
+				$read_order = wc_get_order( $order_id );
+
+				$this->assertSame(
+					$expected,
+					$read_order->get_prices_include_tax(),
+					'The saved order should keep its creation-time setting after the store setting changes.'
+				);
+				$this->assertSame( 'EUR', $read_order->get_currency(), 'The saved order should keep its creation-time currency.' );
+				$this->assertSame( 'admin', $read_order->get_created_via() );
+				$this->assertSame( 'auto-draft', $read_order->get_status() );
+			} finally {
+				if ( $order_id ) {
+					$created_order = wc_get_order( $order_id );
+					if ( $created_order ) {
+						$created_order->delete( true );
+					}
+				}
+
+				update_option( 'woocommerce_prices_include_tax', $previous_tax_mode );
+				update_option( 'woocommerce_currency', $previous_currency );
+
+				if ( $had_pagenow ) {
+					$pagenow = $previous_pagenow; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+				} else {
+					unset( $GLOBALS['pagenow'] );
+				}
+				if ( $had_plugin_page ) {
+					$plugin_page = $previous_page; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+				} else {
+					unset( $GLOBALS['plugin_page'] );
+				}
+				if ( $had_theorder ) {
+					$theorder = $previous_theorder;
+				} else {
+					unset( $GLOBALS['theorder'] );
+				}
+				$_GET = $previous_get; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+			}
+		}
+
+		/**
+		 * Provides store tax modes.
+		 *
+		 * @return array<string, array{string, bool}>
+		 */
+		public static function provide_store_tax_modes(): array {
+			return array(
+				'prices entered inclusive of tax' => array( 'yes', true ),
+				'prices entered exclusive of tax' => array( 'no', false ),
+			);
+		}
 	}
 }

diff --git a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php
index 772ebdfb690..8e2097d4a28 100644
--- a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php
@@ -2521,6 +2521,112 @@ class OrdersTableDataStoreTests extends \HposTestCase {
 		$this->assertEquals( $modified_date->format( 'Y-m-d H:i:s' ), $post->post_modified_gmt );
 	}

+	/**
+	 * @testdox Generic order persistence saves the store tax-mode default captured at construction.
+	 */
+	public function test_create_without_explicit_prices_include_tax_keeps_default(): void {
+		$previous_tax_mode = get_option( 'woocommerce_prices_include_tax' );
+		$order             = null;
+
+		try {
+			update_option( 'woocommerce_prices_include_tax', 'yes' );
+			$order = new WC_Order();
+			update_option( 'woocommerce_prices_include_tax', 'no' );
+			$this->switch_data_store( $order, $this->sut );
+			$order->save();
+
+			wp_cache_flush();
+
+			$r_order = new WC_Order();
+			$r_order->set_id( $order->get_id() );
+			$this->switch_data_store( $r_order, $this->sut );
+			$this->sut->read( $r_order );
+
+			$this->assertTrue(
+				$r_order->get_prices_include_tax(),
+				'Persistence should use the default captured at construction, not the current store setting.'
+			);
+			$this->assertArrayNotHasKey( 'prices_include_tax', $r_order->get_changes() );
+		} finally {
+			if ( $order ) {
+				$order->delete( true );
+			}
+			update_option( 'woocommerce_prices_include_tax', $previous_tax_mode );
+		}
+	}
+
+	/**
+	 * @testDox An explicitly set prices_include_tax value is persisted as given, not replaced by the store setting.
+	 */
+	public function test_create_keeps_explicit_prices_include_tax_value(): void {
+		$previous_tax_mode = get_option( 'woocommerce_prices_include_tax' );
+		$order             = null;
+
+		try {
+			update_option( 'woocommerce_prices_include_tax', 'yes' );
+			$order = new WC_Order();
+			$this->switch_data_store( $order, $this->sut );
+			$order->set_prices_include_tax( false );
+			$order->save();
+
+			wp_cache_flush();
+
+			$r_order = new WC_Order();
+			$r_order->set_id( $order->get_id() );
+			$this->switch_data_store( $r_order, $this->sut );
+			$this->sut->read( $r_order );
+
+			$this->assertFalse(
+				$r_order->get_prices_include_tax(),
+				'An order explicitly created as tax exclusive should stay tax exclusive on a tax inclusive store.'
+			);
+		} finally {
+			if ( $order ) {
+				$order->delete( true );
+			}
+			update_option( 'woocommerce_prices_include_tax', $previous_tax_mode );
+		}
+	}
+
+	/**
+	 * @testdox An HPOS order with a null tax-mode column uses the store default without a pending edit.
+	 */
+	public function test_read_with_null_prices_include_tax_uses_store_default(): void {
+		global $wpdb;
+
+		$previous_tax_mode = get_option( 'woocommerce_prices_include_tax' );
+		$order             = new WC_Order();
+
+		try {
+			update_option( 'woocommerce_prices_include_tax', 'yes' );
+			$this->switch_data_store( $order, $this->sut );
+			$order->save();
+
+			$operational_data_table = $this->sut::get_operational_data_table_name();
+			$wpdb->query(
+				$wpdb->prepare(
+					"UPDATE {$operational_data_table} SET prices_include_tax = NULL WHERE order_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+					$order->get_id()
+				)
+			);
+			wp_cache_flush();
+
+			$read_order = new WC_Order();
+			$read_order->set_id( $order->get_id() );
+			$this->switch_data_store( $read_order, $this->sut );
+			$this->sut->read( $read_order );
+
+			$this->assertTrue(
+				$read_order->get_prices_include_tax(),
+				'A missing stored value should use the constructor default.'
+			);
+			$this->assertArrayNotHasKey( 'prices_include_tax', $read_order->get_changes() );
+		} finally {
+			$order->delete( true );
+			update_option( 'woocommerce_prices_include_tax', $previous_tax_mode );
+		}
+	}
+
 	/**
 	 * @testDox Test that inserting with strict SQL mode is also supported.
 	 */