Commit 07ea58d1969 for woocommerce

commit 07ea58d1969e01bc3683f1f9dbd1905094f381fc
Author: Peter Petrov <peter.petrov89@gmail.com>
Date:   Tue Sep 8 14:07:29 2026 +0300

    Add taxable amount column to the Analytics Taxes report (#68233)

    * Add taxable amount column to Analytics Taxes report

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * Drop PHPStan baseline entries resolved by typing the tax items

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * Pin the tax location basis in the Taxes report test fixtures

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * Drop the since tag from the private export formatter

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * Pin the shipping tax class in the Taxes report test fixtures

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * Take refund compound flags from the parent order's tax items

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * Cover refund netting of the taxable amount

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * Clear the rate objects cache in the deleted-rate refund test

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * Drop baseline entries resolved by the typed tax items

    * Mark the taxable amount column check as internal

    * Reword the export column order assertion message

    * Read the taxable amount column from the schema instead of an option

    * Trim the column check comments

    ---------

    Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
    Co-authored-by: Miroslav Mitev <m1r0@users.noreply.github.com>

diff --git a/packages/js/data/changelog/63697-analytics-taxes-taxable-amount b/packages/js/data/changelog/63697-analytics-taxes-taxable-amount
new file mode 100644
index 00000000000..2460be60c92
--- /dev/null
+++ b/packages/js/data/changelog/63697-analytics-taxes-taxable-amount
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add the taxable_amount field to the TaxesReport type.
diff --git a/packages/js/data/src/reports/types.ts b/packages/js/data/src/reports/types.ts
index ee63458b1e3..7a387efa5d0 100644
--- a/packages/js/data/src/reports/types.ts
+++ b/packages/js/data/src/reports/types.ts
@@ -220,6 +220,8 @@ export type TaxesReport = {
 	order_tax: number;
 	/** Shipping tax. */
 	shipping_tax: number;
+	/** Taxable amount. */
+	taxable_amount?: number;
 	/** Number of orders. */
 	orders_count: number;
 };
diff --git a/plugins/woocommerce/changelog/63697-analytics-taxes-taxable-amount b/plugins/woocommerce/changelog/63697-analytics-taxes-taxable-amount
new file mode 100644
index 00000000000..87096d0b46f
--- /dev/null
+++ b/plugins/woocommerce/changelog/63697-analytics-taxes-taxable-amount
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a taxable amount column to the Analytics Taxes report, recording the net amount each tax rate applied to.
diff --git a/plugins/woocommerce/client/admin/client/analytics/report/taxes/table.js b/plugins/woocommerce/client/admin/client/analytics/report/taxes/table.js
index f26139c6867..6d43abf8ea5 100644
--- a/plugins/woocommerce/client/admin/client/analytics/report/taxes/table.js
+++ b/plugins/woocommerce/client/admin/client/analytics/report/taxes/table.js
@@ -53,6 +53,11 @@ class TaxesReportTable extends Component {
 				key: 'shipping_tax',
 				isSortable: true,
 			},
+			{
+				label: __( 'Taxable amount', 'woocommerce' ),
+				key: 'taxable_amount',
+				isSortable: true,
+			},
 			{
 				label: __( 'Orders', 'woocommerce' ),
 				key: 'orders_count',
@@ -80,7 +85,13 @@ class TaxesReportTable extends Component {
 				tax_rate_id: taxRateId,
 				total_tax: totalTax,
 				shipping_tax: shippingTax,
+				taxable_amount: taxableAmount,
 			} = tax;
+			// A zero base under a non-zero tax marks a lookup row recorded before the
+			// taxable amount existed (or a manual tax line) - unknown, not zero.
+			const hasTaxableAmount =
+				taxableAmount !== undefined &&
+				! ( taxableAmount === 0 && totalTax !== 0 );
 			const taxCode = getTaxCode( tax );

 			const persistedQuery = getPersistedQuery( query );
@@ -119,6 +130,14 @@ class TaxesReportTable extends Component {
 					display: renderCurrency( shippingTax ),
 					value: getCurrencyFormatDecimal( shippingTax ),
 				},
+				{
+					display: hasTaxableAmount
+						? renderCurrency( taxableAmount )
+						: __( 'N/A', 'woocommerce' ),
+					value: hasTaxableAmount
+						? getCurrencyFormatDecimal( taxableAmount )
+						: '',
+				},
 				{
 					display: formatValue(
 						getCurrencyConfig(),
diff --git a/plugins/woocommerce/includes/class-wc-install.php b/plugins/woocommerce/includes/class-wc-install.php
index 68f66bb20da..9ef661b20d1 100644
--- a/plugins/woocommerce/includes/class-wc-install.php
+++ b/plugins/woocommerce/includes/class-wc-install.php
@@ -2114,6 +2114,7 @@ CREATE TABLE {$wpdb->prefix}wc_order_tax_lookup (
 	shipping_tax double DEFAULT 0 NOT NULL,
 	order_tax double DEFAULT 0 NOT NULL,
 	total_tax double DEFAULT 0 NOT NULL,
+	taxable_amount double DEFAULT 0 NOT NULL,
 	PRIMARY KEY (order_id, tax_rate_id, order_item_id),
 	KEY tax_rate_id (tax_rate_id),
 	KEY date_created (date_created)
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index c2d819931d3..2203de29141 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -43611,24 +43611,6 @@ parameters:
 			count: 1
 			path: src/Admin/API/Reports/Taxes/Controller.php

-		-
-			message: '#^Call to an undefined method WC_Order_Item\:\:get_rate_id\(\)\.$#'
-			identifier: method.notFound
-			count: 2
-			path: src/Admin/API/Reports/Taxes/DataStore.php
-
-		-
-			message: '#^Call to an undefined method WC_Order_Item\:\:get_shipping_tax_total\(\)\.$#'
-			identifier: method.notFound
-			count: 2
-			path: src/Admin/API/Reports/Taxes/DataStore.php
-
-		-
-			message: '#^Call to an undefined method WC_Order_Item\:\:get_tax_total\(\)\.$#'
-			identifier: method.notFound
-			count: 2
-			path: src/Admin/API/Reports/Taxes/DataStore.php
-
 		-
 			message: '#^Method Automattic\\WooCommerce\\Admin\\API\\Reports\\Taxes\\DataStore\:\:add_from_sql_params\(\) has no return type specified\.$#'
 			identifier: missingType.return
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Taxes/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Taxes/Controller.php
index 26dacfef02a..be0bf1e4e3f 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Taxes/Controller.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Taxes/Controller.php
@@ -121,61 +121,67 @@ class Controller extends GenericController implements ExportableInterface {
 			'title'      => 'report_taxes',
 			'type'       => 'object',
 			'properties' => array(
-				'tax_rate_id'  => array(
+				'tax_rate_id'    => array(
 					'description' => __( 'Tax rate ID.', 'woocommerce' ),
 					'type'        => 'integer',
 					'context'     => array( 'view', 'edit' ),
 					'readonly'    => true,
 				),
-				'name'         => array(
+				'name'           => array(
 					'description' => __( 'Tax rate name.', 'woocommerce' ),
 					'type'        => 'string',
 					'context'     => array( 'view', 'edit' ),
 					'readonly'    => true,
 				),
-				'tax_rate'     => array(
+				'tax_rate'       => array(
 					'description' => __( 'Tax rate.', 'woocommerce' ),
 					'type'        => 'number',
 					'context'     => array( 'view', 'edit' ),
 					'readonly'    => true,
 				),
-				'country'      => array(
+				'country'        => array(
 					'description' => __( 'Country / Region.', 'woocommerce' ),
 					'type'        => 'string',
 					'context'     => array( 'view', 'edit' ),
 					'readonly'    => true,
 				),
-				'state'        => array(
+				'state'          => array(
 					'description' => __( 'State.', 'woocommerce' ),
 					'type'        => 'string',
 					'context'     => array( 'view', 'edit' ),
 					'readonly'    => true,
 				),
-				'priority'     => array(
+				'priority'       => array(
 					'description' => __( 'Priority.', 'woocommerce' ),
 					'type'        => 'integer',
 					'context'     => array( 'view', 'edit' ),
 					'readonly'    => true,
 				),
-				'total_tax'    => array(
+				'total_tax'      => array(
 					'description' => __( 'Total tax.', 'woocommerce' ),
 					'type'        => 'number',
 					'context'     => array( 'view', 'edit' ),
 					'readonly'    => true,
 				),
-				'order_tax'    => array(
+				'order_tax'      => array(
 					'description' => __( 'Order tax.', 'woocommerce' ),
 					'type'        => 'number',
 					'context'     => array( 'view', 'edit' ),
 					'readonly'    => true,
 				),
-				'shipping_tax' => array(
+				'shipping_tax'   => array(
 					'description' => __( 'Shipping tax.', 'woocommerce' ),
 					'type'        => 'number',
 					'context'     => array( 'view', 'edit' ),
 					'readonly'    => true,
 				),
-				'orders_count' => array(
+				'taxable_amount' => array(
+					'description' => __( 'Taxable amount.', 'woocommerce' ),
+					'type'        => 'number',
+					'context'     => array( 'view', 'edit' ),
+					'readonly'    => true,
+				),
+				'orders_count'   => array(
 					'description' => __( 'Number of orders.', 'woocommerce' ),
 					'type'        => 'integer',
 					'context'     => array( 'view', 'edit' ),
@@ -204,6 +210,7 @@ class Controller extends GenericController implements ExportableInterface {
 				'order_tax',
 				'total_tax',
 				'shipping_tax',
+				'taxable_amount',
 				'orders_count',
 			)
 		);
@@ -227,12 +234,13 @@ class Controller extends GenericController implements ExportableInterface {
 	 */
 	public function get_export_columns() {
 		$export_columns = array(
-			'tax_code'     => __( 'Tax code', 'woocommerce' ),
-			'rate'         => __( 'Rate', 'woocommerce' ),
-			'total_tax'    => __( 'Total tax', 'woocommerce' ),
-			'order_tax'    => __( 'Order tax', 'woocommerce' ),
-			'shipping_tax' => __( 'Shipping tax', 'woocommerce' ),
-			'orders_count' => __( 'Orders', 'woocommerce' ),
+			'tax_code'       => __( 'Tax code', 'woocommerce' ),
+			'rate'           => __( 'Rate', 'woocommerce' ),
+			'total_tax'      => __( 'Total tax', 'woocommerce' ),
+			'order_tax'      => __( 'Order tax', 'woocommerce' ),
+			'shipping_tax'   => __( 'Shipping tax', 'woocommerce' ),
+			'taxable_amount' => __( 'Taxable amount', 'woocommerce' ),
+			'orders_count'   => __( 'Orders', 'woocommerce' ),
 		);

 		/**
@@ -252,7 +260,7 @@ class Controller extends GenericController implements ExportableInterface {
 	 */
 	public function prepare_item_for_export( $item ) {
 		$export_item = array(
-			'tax_code'     => \WC_Tax::get_rate_code(
+			'tax_code'       => \WC_Tax::get_rate_code(
 				(object) array(
 					'tax_rate_id'       => $item['tax_rate_id'],
 					'tax_rate_country'  => $item['country'],
@@ -261,11 +269,12 @@ class Controller extends GenericController implements ExportableInterface {
 					'tax_rate_priority' => $item['priority'],
 				)
 			),
-			'rate'         => $item['tax_rate'],
-			'total_tax'    => self::csv_number_format( $item['total_tax'] ),
-			'order_tax'    => self::csv_number_format( $item['order_tax'] ),
-			'shipping_tax' => self::csv_number_format( $item['shipping_tax'] ),
-			'orders_count' => $item['orders_count'],
+			'rate'           => $item['tax_rate'],
+			'total_tax'      => self::csv_number_format( $item['total_tax'] ),
+			'order_tax'      => self::csv_number_format( $item['order_tax'] ),
+			'shipping_tax'   => self::csv_number_format( $item['shipping_tax'] ),
+			'taxable_amount' => $this->prepare_taxable_amount_for_export( $item ),
+			'orders_count'   => $item['orders_count'],
 		);

 		/**
@@ -278,4 +287,24 @@ class Controller extends GenericController implements ExportableInterface {
 		 */
 		return apply_filters( 'woocommerce_report_taxes_prepare_export_item', $export_item, $item );
 	}
+
+	/**
+	 * Format the taxable amount of a report row for export.
+	 *
+	 * A zero base under a non-zero tax marks a lookup row recorded before the taxable
+	 * amount existed (or a manual tax line) - unknown, so exported as an empty cell
+	 * rather than a zero a merchant could mistake for a filing figure.
+	 *
+	 * @param array $item Single report item/row.
+	 * @return string
+	 */
+	private function prepare_taxable_amount_for_export( $item ) {
+		if ( ! isset( $item['taxable_amount'] ) ) {
+			return '';
+		}
+
+		$unknown = 0.0 === (float) $item['taxable_amount'] && 0.0 !== (float) ( $item['total_tax'] ?? 0 );
+
+		return $unknown ? '' : self::csv_number_format( $item['taxable_amount'] );
+	}
 }
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Taxes/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Taxes/DataStore.php
index 862d403a123..a320f607e68 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Taxes/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Taxes/DataStore.php
@@ -45,16 +45,17 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 	 * @var array
 	 */
 	protected $column_types = array(
-		'tax_rate_id'  => 'intval',
-		'name'         => 'strval',
-		'tax_rate'     => 'floatval',
-		'country'      => 'strval',
-		'state'        => 'strval',
-		'priority'     => 'intval',
-		'total_tax'    => 'floatval',
-		'order_tax'    => 'floatval',
-		'shipping_tax' => 'floatval',
-		'orders_count' => 'intval',
+		'tax_rate_id'    => 'intval',
+		'name'           => 'strval',
+		'tax_rate'       => 'floatval',
+		'country'        => 'strval',
+		'state'          => 'strval',
+		'priority'       => 'intval',
+		'total_tax'      => 'floatval',
+		'order_tax'      => 'floatval',
+		'shipping_tax'   => 'floatval',
+		'taxable_amount' => 'floatval',
+		'orders_count'   => 'intval',
 	);

 	/**
@@ -98,20 +99,80 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 		// but given this query is paginated and cached, then it is not a big deal. There is always room for
 		// improvements here.
 		$this->report_columns = array(
-			'tax_rate_id'  => "{$table_name}.tax_rate_id",
-			'name'         => "SUBSTRING_INDEX(SUBSTRING_INDEX({$wpdb->prefix}woocommerce_order_items.order_item_name,'-',-2), '-', 1) as name",
-			'tax_rate'     => 'CAST(itemmeta_rate_percent.meta_value AS DECIMAL(7,4)) as tax_rate',
-			'country'      => "SUBSTRING_INDEX({$wpdb->prefix}woocommerce_order_items.order_item_name,'-',1) as country",
-			'state'        => "SUBSTRING_INDEX(SUBSTRING_INDEX({$wpdb->prefix}woocommerce_order_items.order_item_name,'-',-3), '-', 1) as state",
-			'priority'     => "SUBSTRING_INDEX({$wpdb->prefix}woocommerce_order_items.order_item_name,'-',-1) as priority",
-			'total_tax'    => 'SUM(total_tax) as total_tax',
-			'order_tax'    => 'SUM(order_tax) as order_tax',
-			'shipping_tax' => 'SUM(shipping_tax) as shipping_tax',
+			'tax_rate_id'    => "{$table_name}.tax_rate_id",
+			'name'           => "SUBSTRING_INDEX(SUBSTRING_INDEX({$wpdb->prefix}woocommerce_order_items.order_item_name,'-',-2), '-', 1) as name",
+			'tax_rate'       => 'CAST(itemmeta_rate_percent.meta_value AS DECIMAL(7,4)) as tax_rate',
+			'country'        => "SUBSTRING_INDEX({$wpdb->prefix}woocommerce_order_items.order_item_name,'-',1) as country",
+			'state'          => "SUBSTRING_INDEX(SUBSTRING_INDEX({$wpdb->prefix}woocommerce_order_items.order_item_name,'-',-3), '-', 1) as state",
+			'priority'       => "SUBSTRING_INDEX({$wpdb->prefix}woocommerce_order_items.order_item_name,'-',-1) as priority",
+			'total_tax'      => 'SUM(total_tax) as total_tax',
+			'order_tax'      => 'SUM(order_tax) as order_tax',
+			'shipping_tax'   => 'SUM(shipping_tax) as shipping_tax',
+			'taxable_amount' => 'SUM(taxable_amount) as taxable_amount',
 			// parent_id stays unqualified: wc_order_stats is the only joined table carrying it, and
 			// this string is carried by the public woocommerce_admin_report_columns filter, so it must
 			// match the released form for extension callbacks that inspect or rewrite it.
-			'orders_count' => "COUNT( DISTINCT ( CASE WHEN parent_id = 0 THEN {$table_name}.order_id END ) ) as orders_count",
+			'orders_count'   => "COUNT( DISTINCT ( CASE WHEN parent_id = 0 THEN {$table_name}.order_id END ) ) as orders_count",
 		);
+
+		// Guard against the column not existing yet: the report otherwise breaks entirely on a
+		// site where the upgrade routine has not run (or could not run) the schema update.
+		if ( ! static::has_taxable_amount_column() ) {
+			unset( $this->report_columns['taxable_amount'] );
+		}
+	}
+
+	/**
+	 * Check if the wc_order_tax_lookup table has the taxable_amount column.
+	 *
+	 * Asks the schema on every request rather than caching the answer in an option, so a
+	 * column that appears or disappears behind WooCommerce's back (a partial restore, a
+	 * manual drop) corrects the report on the next request.
+	 *
+	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
+	 * @since 11.2.0
+	 *
+	 * @return bool
+	 */
+	public static function has_taxable_amount_column() {
+		global $wpdb;
+
+		// One schema check per request: imports call this once per synced order. Keyed by
+		// blog id since the schema is per site.
+		static $has_column = array();
+
+		$blog_id = get_current_blog_id();
+
+		if ( isset( $has_column[ $blog_id ] ) ) {
+			return $has_column[ $blog_id ];
+		}
+
+		$table_name = self::get_db_table_name();
+
+		// If the table itself does not exist yet, checking its columns would be a DB error.
+		$table_exists = $wpdb->get_var(
+			$wpdb->prepare(
+				'SHOW TABLES LIKE %s',
+				$table_name
+			)
+		);
+
+		if ( ! $table_exists ) {
+			$has_column[ $blog_id ] = false;
+			return false;
+		}
+
+		$column_exists = $wpdb->get_var(
+			$wpdb->prepare(
+				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot be prepared.
+				"SHOW COLUMNS FROM `{$table_name}` LIKE %s",
+				'taxable_amount'
+			)
+		);
+
+		$has_column[ $blog_id ] = ! empty( $column_exists );
+
+		return $has_column[ $blog_id ];
 	}

 	/**
@@ -224,6 +285,12 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 			'page_no' => 0,
 		);

+		// While the taxable_amount column is missing its report column is unset, so ordering
+		// by it would be a SQL error. Fall back to the default order.
+		if ( 'taxable_amount' === ( $query_args['orderby'] ?? '' ) && ! isset( $this->report_columns['taxable_amount'] ) ) {
+			$query_args['orderby'] = 'tax_rate_id';
+		}
+
 		$this->add_sql_query_params( $query_args );
 		$params = $this->get_limit_params( $query_args );

@@ -246,7 +313,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {

 		$this->subquery->clear_sql_clause( 'select' );
 		$this->subquery->add_sql_clause( 'select', $this->selected_columns( $query_args ) );
-		if ( in_array( $query_args['orderby'], array( 'total_tax', 'order_tax', 'shipping_tax', 'orders_count' ), true ) ) {
+		if ( in_array( $query_args['orderby'], array( 'total_tax', 'order_tax', 'shipping_tax', 'taxable_amount', 'orders_count' ), true ) ) {
 			$this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) . ', tax_rate_id' );
 		} else {
 			$this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) );
@@ -398,8 +465,13 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 			return -1;
 		}

-		$table_name    = self::get_db_table_name();
-		$date_created  = $order->get_date_created( 'edit' )->date( TimeInterval::$sql_datetime_format );
+		$table_name   = self::get_db_table_name();
+		$date_created = $order->get_date_created( 'edit' )->date( TimeInterval::$sql_datetime_format );
+		/**
+		 * Tax line items of the order.
+		 *
+		 * @var \WC_Order_Item_Tax[] $tax_items
+		 */
 		$tax_items     = $order->get_items( OrderItemType::TAX );
 		$keyed_by_item = self::lookup_is_keyed_by_order_item();

@@ -439,6 +511,42 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 			);
 		}

+		// Guard against the column not existing yet: wpdb::replace() with an unknown column
+		// fails whole, which would silently drop the order from the Taxes report.
+		$has_taxable_amount_column = static::has_taxable_amount_column();
+		$taxable_amounts           = array();
+
+		// Also skip orders without tax lines: computing bases would hydrate every
+		// line item, fee and shipping row for a write that never happens.
+		if ( $has_taxable_amount_column && ! empty( $tax_items ) ) {
+			// A refund's tax items re-derive the compound flag from the live rate, which can
+			// be gone by refund time; the parent's tax items carry the flags as charged, and
+			// the refund base must mirror them or a refunded order stops netting to zero.
+			$flag_items = $tax_items;
+			if ( $order instanceof \WC_Order_Refund ) {
+				$parent = wc_get_order( $order->get_parent_id() );
+				if ( $parent ) {
+					/**
+					 * Tax line items of the parent order.
+					 *
+					 * @var \WC_Order_Item_Tax[] $parent_tax_items
+					 */
+					$parent_tax_items = $parent->get_items( OrderItemType::TAX );
+					if ( ! empty( $parent_tax_items ) ) {
+						$flag_items = $parent_tax_items;
+					}
+				}
+			}
+
+			$compound_rate_ids = array();
+			foreach ( $flag_items as $tax_item ) {
+				if ( $tax_item->get_compound() ) {
+					$compound_rate_ids[] = $tax_item->get_rate_id();
+				}
+			}
+			$taxable_amounts = static::get_taxable_amounts_by_rate( $order, $compound_rate_ids );
+		}
+
 		foreach ( $tax_items as $tax_item ) {
 			// Leaving the column at zero on a table the re-key never reached keeps the row in the
 			// shape the released report reads, rather than collapsing the order's tax lines into
@@ -450,8 +558,6 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 			// together, so a line whose rate id has changed leaves behind the row it held before.
 			unset( $stale[ $tax_rate_id . '-' . $order_item_id ] );

-			$rows[] = '(%d, %s, %d, %d, %f, %f, %f)';
-
 			array_push(
 				$values,
 				$order->get_id(),
@@ -462,6 +568,20 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 				$tax_item->get_tax_total(),
 				(float) $tax_item->get_tax_total() + (float) $tax_item->get_shipping_tax_total()
 			);
+
+			if ( $has_taxable_amount_column ) {
+				// The bases are computed per rate and the report sums the column per rate, so
+				// when tax lines share a rate only the first row carries the rate's base. On an
+				// unkeyed table the lines collapse into one row and the last write wins, so
+				// there every write carries the base.
+				$values[] = $taxable_amounts[ $tax_rate_id ] ?? 0;
+				if ( $keyed_by_item ) {
+					unset( $taxable_amounts[ $tax_rate_id ] );
+				}
+				$rows[] = '(%d, %s, %d, %d, %f, %f, %f, %f)';
+			} else {
+				$rows[] = '(%d, %s, %d, %d, %f, %f, %f)';
+			}
 		}

 		// One statement for the whole order. Rebuilding only some of its lines would leave a row
@@ -469,10 +589,15 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 		// that row stands in for every line of the order sharing its rate, so the reports would
 		// count those lines twice.
 		if ( $rows ) {
+			$columns = 'order_id, date_created, tax_rate_id, order_item_id, shipping_tax, order_tax, total_tax';
+			if ( $has_taxable_amount_column ) {
+				$columns .= ', taxable_amount';
+			}
+
 			$written = $wpdb->query(
 				$wpdb->prepare(
 					// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Table name is not user input, and the value placeholders are built above, one set per tax line.
-					"REPLACE INTO {$table_name} (order_id, date_created, tax_rate_id, order_item_id, shipping_tax, order_tax, total_tax) VALUES " . implode( ', ', $rows ),
+					"REPLACE INTO {$table_name} ({$columns}) VALUES " . implode( ', ', $rows ),
 					$values
 				)
 			);
@@ -535,6 +660,60 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 		return true;
 	}

+	/**
+	 * Sum the net totals of the order parts (line items, fees, shipping) each tax rate applied to.
+	 *
+	 * Computed from the items rather than derived from the tax amounts, so zero-rated
+	 * sales still record the base amount they were taxed on. A compound rate is applied
+	 * on top of the other taxes of the same item, so its base includes them.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param \WC_Order|\WC_Order_Refund $order             Order object.
+	 * @param int[]                      $compound_rate_ids Ids of the order's compound tax rates.
+	 * @return array Map of tax rate id => taxable amount.
+	 */
+	protected static function get_taxable_amounts_by_rate( $order, $compound_rate_ids = array() ) {
+		$amounts = array();
+		/**
+		 * Taxable line items of the order.
+		 *
+		 * @var \WC_Order_Item_Product[]|\WC_Order_Item_Fee[]|\WC_Order_Item_Shipping[] $items
+		 */
+		$items = $order->get_items( array( OrderItemType::LINE_ITEM, OrderItemType::FEE, OrderItemType::SHIPPING ) );
+
+		foreach ( $items as $item ) {
+			$taxes = $item->get_taxes();
+			if ( empty( $taxes['total'] ) || ! is_array( $taxes['total'] ) ) {
+				continue;
+			}
+
+			$non_compound_tax = 0.0;
+			foreach ( $taxes['total'] as $rate_id => $tax ) {
+				if ( ! in_array( (int) $rate_id, $compound_rate_ids, true ) ) {
+					$non_compound_tax += (float) $tax;
+				}
+			}
+
+			// Mirror WC_Tax::calc_exclusive_tax(): a compound rate is applied over the item
+			// total plus all non-compound taxes plus the compound taxes applied before it.
+			// Assumes the item tax data keeps core's rate order (compound rates after the
+			// rates they compound over); a third-party engine writing another order would
+			// only mis-split the base between multiple compound rates, not the total.
+			$compound_running_tax = 0.0;
+			foreach ( $taxes['total'] as $rate_id => $tax ) {
+				$base = (float) $item->get_total();
+				if ( in_array( (int) $rate_id, $compound_rate_ids, true ) ) {
+					$base                 += $non_compound_tax + $compound_running_tax;
+					$compound_running_tax += (float) $tax;
+				}
+				$amounts[ $rate_id ] = ( $amounts[ $rate_id ] ?? 0 ) + $base;
+			}
+		}
+
+		return $amounts;
+	}
+
 	/**
 	 * Clean taxes data when an order is deleted.
 	 *
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-taxes.php b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-taxes.php
index f2623dfa731..69cc2b79198 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-taxes.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-taxes.php
@@ -369,6 +369,30 @@ class WC_Admin_Tests_API_Reports_Taxes extends WC_REST_Unit_Test_Case {
 		$this->assertEquals( 401, $response->get_status() );
 	}

+	/**
+	 * Test that the CSV export column order stays in sync with the report table.
+	 *
+	 * @testdox Should keep the CSV export column order in sync with the report table.
+	 */
+	public function test_export_column_order_matches_report_table() {
+		// Mirrors getHeadersContent() in
+		// client/admin/client/analytics/report/taxes/table.js. Keys differ
+		// between the two, the order must not.
+		$expected_order = array(
+			'tax_code',
+			'rate',
+			'total_tax',
+			'order_tax',
+			'shipping_tax',
+			'taxable_amount',
+			'orders_count',
+		);
+
+		$controller = new \Automattic\WooCommerce\Admin\API\Reports\Taxes\Controller();
+
+		$this->assertSame( $expected_order, array_keys( $controller->get_export_columns() ), 'The CSV column order must match the column order in table.js' );
+	}
+
 	/**
 	 * Test reports schema.
 	 *
@@ -382,7 +406,7 @@ class WC_Admin_Tests_API_Reports_Taxes extends WC_REST_Unit_Test_Case {
 		$data       = $response->get_data();
 		$properties = $data['schema']['properties'];

-		$this->assertEquals( 10, count( $properties ) );
+		$this->assertEquals( 11, count( $properties ) );
 		$this->assertArrayHasKey( 'tax_rate_id', $properties );
 		$this->assertArrayHasKey( 'name', $properties );
 		$this->assertArrayHasKey( 'tax_rate', $properties );
@@ -392,6 +416,7 @@ class WC_Admin_Tests_API_Reports_Taxes extends WC_REST_Unit_Test_Case {
 		$this->assertArrayHasKey( 'total_tax', $properties );
 		$this->assertArrayHasKey( 'order_tax', $properties );
 		$this->assertArrayHasKey( 'shipping_tax', $properties );
+		$this->assertArrayHasKey( 'taxable_amount', $properties );
 		$this->assertArrayHasKey( 'orders_count', $properties );
 	}

diff --git a/plugins/woocommerce/tests/php/src/Admin/API/Reports/Taxes/ControllerTest.php b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Taxes/ControllerTest.php
index fb6d5aad8d5..dbbb8be4cd2 100644
--- a/plugins/woocommerce/tests/php/src/Admin/API/Reports/Taxes/ControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Taxes/ControllerTest.php
@@ -115,6 +115,38 @@ class ControllerTest extends WC_Unit_Test_Case {
 		$this->assertSame( 'USD', $export_item['currency'] );
 	}

+	/**
+	 * @testdox prepare_item_for_export formats the taxable amount, exporting unknown values as empty cells.
+	 */
+	public function test_prepare_item_for_export_formats_taxable_amount(): void {
+		$item = array(
+			'tax_rate_id'  => 1,
+			'country'      => 'US',
+			'state'        => 'CA',
+			'name'         => 'State Tax',
+			'priority'     => 1,
+			'tax_rate'     => '8.25',
+			'total_tax'    => 82.50,
+			'order_tax'    => 75.00,
+			'shipping_tax' => 7.50,
+			'orders_count' => 10,
+		);
+
+		// Key absent (column missing during the upgrade window): empty cell.
+		$export_item = $this->sut->prepare_item_for_export( $item );
+		$this->assertSame( '', $export_item['taxable_amount'], 'A missing taxable amount should export as an empty cell.' );
+
+		// Zero base under non-zero tax (row recorded before the column existed): empty cell.
+		$item['taxable_amount'] = 0;
+		$export_item            = $this->sut->prepare_item_for_export( $item );
+		$this->assertSame( '', $export_item['taxable_amount'], 'An unknown taxable amount should export as an empty cell, not a zero.' );
+
+		// Known value: formatted number.
+		$item['taxable_amount'] = 1000.5;
+		$export_item            = $this->sut->prepare_item_for_export( $item );
+		$this->assertSame( Controller::csv_number_format( 1000.5 ), $export_item['taxable_amount'], 'A recorded taxable amount should export as a formatted number.' );
+	}
+
 	/**
 	 * @testdox prepare_item_for_export passes the original item to the filter.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Admin/API/Reports/Taxes/DataStoreTest.php b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Taxes/DataStoreTest.php
index 8833ce72de8..1e8b912917c 100644
--- a/plugins/woocommerce/tests/php/src/Admin/API/Reports/Taxes/DataStoreTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Taxes/DataStoreTest.php
@@ -5,6 +5,7 @@ namespace Automattic\WooCommerce\Tests\Admin\API\Reports\Taxes;

 use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache;
 use Automattic\WooCommerce\Admin\API\Reports\Orders\DataStore as OrdersDataStore;
+use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore as OrdersStatsDataStore;
 use Automattic\WooCommerce\Admin\ReportsSync;
 use Automattic\WooCommerce\Admin\API\Reports\Taxes\DataStore;
 use Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats\DataStore as StatsDataStore;
@@ -16,8 +17,11 @@ use WC_Helper_Order;
 use WC_Helper_Queue;
 use WC_Helper_Reports;
 use WC_Order;
+use WC_Order_Item_Fee;
+use WC_Order_Item_Shipping;
 use WC_Order_Item_Tax;
 use WC_Product_Simple;
+use WC_Tax;
 use WC_Unit_Test_Case;

 /**
@@ -42,14 +46,35 @@ class DataStoreTest extends WC_Unit_Test_Case {
 	 */
 	private $original_date_type;

+	/**
+	 * Original woocommerce_tax_based_on option value.
+	 *
+	 * @var string|false
+	 */
+	private $original_tax_based_on;
+
+	/**
+	 * Original woocommerce_shipping_tax_class option value.
+	 *
+	 * @var string|false
+	 */
+	private $original_shipping_tax_class;
+
 	/**
 	 * Set up test fixtures.
 	 */
 	public function setUp(): void {
 		parent::setUp();
-		$this->original_calc_taxes = get_option( 'woocommerce_calc_taxes' );
-		$this->original_date_type  = get_option( 'woocommerce_date_type' );
+		$this->original_calc_taxes         = get_option( 'woocommerce_calc_taxes' );
+		$this->original_date_type          = get_option( 'woocommerce_date_type' );
+		$this->original_tax_based_on       = get_option( 'woocommerce_tax_based_on' );
+		$this->original_shipping_tax_class = get_option( 'woocommerce_shipping_tax_class' );
 		update_option( 'woocommerce_calc_taxes', 'yes' );
+		// Pin the tax location basis and the shipping tax class so the DE order fixtures
+		// do not depend on the environment's store base address or on the class-inheritance
+		// resolution, which varies with state left behind by the wider suite.
+		update_option( 'woocommerce_tax_based_on', 'billing' );
+		update_option( 'woocommerce_shipping_tax_class', '' );
 	}

 	/**
@@ -57,6 +82,16 @@ class DataStoreTest extends WC_Unit_Test_Case {
 	 */
 	public function tearDown(): void {
 		update_option( 'woocommerce_calc_taxes', $this->original_calc_taxes );
+		if ( false === $this->original_tax_based_on ) {
+			delete_option( 'woocommerce_tax_based_on' );
+		} else {
+			update_option( 'woocommerce_tax_based_on', $this->original_tax_based_on );
+		}
+		if ( false === $this->original_shipping_tax_class ) {
+			delete_option( 'woocommerce_shipping_tax_class' );
+		} else {
+			update_option( 'woocommerce_shipping_tax_class', $this->original_shipping_tax_class );
+		}
 		if ( false === $this->original_date_type ) {
 			delete_option( 'woocommerce_date_type' );
 		} else {
@@ -68,23 +103,25 @@ class DataStoreTest extends WC_Unit_Test_Case {
 	/**
 	 * Insert a DE VAT tax rate and return its id.
 	 *
+	 * @param string $rate     Tax rate percentage.
+	 * @param int    $priority Tax rate priority.
+	 * @param int    $compound Whether the rate is compound.
 	 * @return int
 	 */
-	private function insert_tax_rate(): int {
-		global $wpdb;
-		$wpdb->insert(
-			$wpdb->prefix . 'woocommerce_tax_rates',
+	private function insert_tax_rate( string $rate = '19', int $priority = 1, int $compound = 0 ): int {
+		return (int) WC_Tax::_insert_tax_rate(
 			array(
-				'tax_rate_id'       => 1,
-				'tax_rate'          => '19',
 				'tax_rate_country'  => 'DE',
 				'tax_rate_state'    => '',
+				'tax_rate'          => $rate,
 				'tax_rate_name'     => 'VAT',
-				'tax_rate_priority' => 1,
-				'tax_rate_order'    => 1,
+				'tax_rate_priority' => $priority,
+				'tax_rate_compound' => $compound,
+				'tax_rate_shipping' => 1,
+				'tax_rate_order'    => 0,
+				'tax_rate_class'    => '',
 			)
 		);
-		return 1;
 	}

 	/**
@@ -327,6 +364,42 @@ class DataStoreTest extends WC_Unit_Test_Case {
 		$this->assertSame( $taxes_count, (int) $orders_data->total, 'Taxes and Orders reports should agree on the order count for the same tax rate and period.' );
 	}

+	/**
+	 * Create a completed DE order with two product units, a fee and shipping, all taxed.
+	 *
+	 * @return \WC_Order
+	 */
+	private function create_taxed_de_order(): \WC_Order {
+		$product = new WC_Product_Simple();
+		$product->set_name( 'Taxable Product' );
+		$product->set_regular_price( '100' );
+		$product->save();
+
+		$order = wc_create_order();
+		$order->set_billing_country( 'DE' );
+		$order->set_shipping_country( 'DE' );
+		$order->add_product( $product, 2 );
+
+		$fee = new WC_Order_Item_Fee();
+		$fee->set_name( 'Handling' );
+		$fee->set_total( '10' );
+		$fee->set_tax_status( 'taxable' );
+		$order->add_item( $fee );
+
+		$shipping = new WC_Order_Item_Shipping();
+		$shipping->set_method_title( 'Flat rate' );
+		$shipping->set_method_id( 'flat_rate' );
+		$shipping->set_total( '5' );
+		$order->add_item( $shipping );
+
+		$order->calculate_totals();
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->set_date_paid( time() );
+		$order->save();
+
+		return $order;
+	}
+
 	/**
 	 * Create a completed, paid order whose tax lines carry an arbitrary `rate_id`, including one
 	 * that has no `woocommerce_tax_rates` row and one shared by several lines.
@@ -398,6 +471,297 @@ class DataStoreTest extends WC_Unit_Test_Case {
 		return $order;
 	}

+	/**
+	 * @testdox Syncing an order records the net amount its tax rate applied to, and the reports expose it.
+	 */
+	public function test_sync_order_taxes_records_taxable_amount(): void {
+		global $wpdb;
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$rate_id = $this->insert_tax_rate();
+		$order   = $this->create_taxed_de_order();
+
+		OrdersStatsDataStore::sync_order( $order->get_id() );
+		DataStore::sync_order_taxes( $order->get_id() );
+		ReportsCache::invalidate();
+
+		// 2 x 100 product + 10 fee + 5 shipping, all net of tax.
+		$lookup_amount = $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT taxable_amount FROM {$wpdb->prefix}wc_order_tax_lookup WHERE order_id = %d AND tax_rate_id = %d",
+				$order->get_id(),
+				$rate_id
+			)
+		);
+		$this->assertSame( 215.0, (float) $lookup_amount, 'The lookup row should record the net total the rate applied to.' );
+
+		$after  = gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS );
+		$before = gmdate( 'Y-m-d H:i:s', time() + DAY_IN_SECONDS );
+
+		$taxes_data = ( new DataStore() )->get_data( $this->taxes_query( $after, $before, $rate_id ) );
+		$this->assertArrayHasKey( 'taxable_amount', $taxes_data->data[0] );
+		$this->assertSame( 215.0, $taxes_data->data[0]['taxable_amount'], 'The Taxes report row should expose the taxable amount.' );
+	}
+
+	/**
+	 * @testdox Syncing an order records the taxable amount for a zero-rated tax rate.
+	 */
+	public function test_sync_order_taxes_records_taxable_amount_for_zero_rate(): void {
+		global $wpdb;
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$rate_id = $this->insert_tax_rate( '0' );
+		$order   = $this->create_taxed_de_order();
+
+		DataStore::sync_order_taxes( $order->get_id() );
+
+		$lookup_row = $wpdb->get_row(
+			$wpdb->prepare(
+				"SELECT total_tax, taxable_amount FROM {$wpdb->prefix}wc_order_tax_lookup WHERE order_id = %d AND tax_rate_id = %d",
+				$order->get_id(),
+				$rate_id
+			)
+		);
+		$this->assertNotNull( $lookup_row, 'A zero-rated tax should still produce a lookup row.' );
+		$this->assertSame( 0.0, (float) $lookup_row->total_tax );
+		$this->assertSame( 215.0, (float) $lookup_row->taxable_amount, 'A zero-rated sale should record the base amount it was taxed on.' );
+	}
+
+	/**
+	 * @testdox Syncing an order with a manual tax line no item carries records a zero taxable amount.
+	 */
+	public function test_sync_order_taxes_records_zero_taxable_amount_for_unapplied_rate(): void {
+		global $wpdb;
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$rate_id = $this->insert_tax_rate();
+
+		$tax_item = new WC_Order_Item_Tax();
+		$tax_item->set_rate( $rate_id );
+		$tax_item->set_tax_total( 19 );
+
+		$order = wc_create_order();
+		$order->add_item( $tax_item );
+		$order->save();
+
+		DataStore::sync_order_taxes( $order->get_id() );
+
+		$lookup_row = $wpdb->get_row(
+			$wpdb->prepare(
+				"SELECT taxable_amount FROM {$wpdb->prefix}wc_order_tax_lookup WHERE order_id = %d AND tax_rate_id = %d",
+				$order->get_id(),
+				$rate_id
+			)
+		);
+		$this->assertNotNull( $lookup_row, 'A manual tax line should still produce a lookup row.' );
+		$this->assertSame( 0.0, (float) $lookup_row->taxable_amount, 'A tax line applied to no order item should record a zero taxable amount.' );
+	}
+
+	/**
+	 * @testdox Syncing an order records the base of a compound tax rate including the taxes it compounds over.
+	 */
+	public function test_sync_order_taxes_records_taxable_amount_for_compound_rate(): void {
+		global $wpdb;
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$base_rate_id       = $this->insert_tax_rate( '5', 1 );
+		$compound_rate_id   = $this->insert_tax_rate( '7', 2, 1 );
+		$compound_rate_2_id = $this->insert_tax_rate( '10', 3, 1 );
+		$order              = $this->create_taxed_de_order();
+
+		DataStore::sync_order_taxes( $order->get_id() );
+
+		$amounts = $wpdb->get_results(
+			$wpdb->prepare(
+				"SELECT tax_rate_id, taxable_amount FROM {$wpdb->prefix}wc_order_tax_lookup WHERE order_id = %d",
+				$order->get_id()
+			),
+			OBJECT_K
+		);
+
+		// 2 x 100 product + 10 fee + 5 shipping = 215 net; the first compound rate is
+		// applied on top of the 5% tax (10.75), so its base is 225.75, and the second
+		// compound rate is additionally applied on top of the first one's tax.
+		$this->assertSame( 215.0, (float) $amounts[ $base_rate_id ]->taxable_amount, 'The non-compound rate base should be the net total.' );
+		$this->assertSame( 225.75, (float) $amounts[ $compound_rate_id ]->taxable_amount, 'The compound rate base should include the taxes it compounds over.' );
+		$this->assertEqualsWithDelta( 241.55, (float) $amounts[ $compound_rate_2_id ]->taxable_amount, 0.02, 'A second compound rate should also compound over the first one.' );
+	}
+
+	/**
+	 * @testdox An order whose tax lines share one tax rate records the taxable amount once per rate.
+	 */
+	public function test_taxable_amount_is_recorded_once_for_tax_lines_sharing_a_rate(): void {
+		global $wpdb;
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$rate_id = $this->insert_tax_rate();
+		$order   = $this->create_taxed_de_order();
+
+		// Automated tax plugins can add several tax lines carrying the same rate id.
+		$duplicate_tax_item = new WC_Order_Item_Tax();
+		$duplicate_tax_item->set_rate( $rate_id );
+		$duplicate_tax_item->set_tax_total( 0 );
+		$order->add_item( $duplicate_tax_item );
+		$order->save();
+
+		DataStore::sync_order_taxes( $order->get_id() );
+
+		// Pins the storage-layer invariant: however the lookup table is keyed, the base
+		// written for one rate must not multiply across its rows - it is the amount the
+		// rate applied to, counted once. The report query's join can still inflate
+		// duplicated tax lines, but that is pre-existing and shared with total_tax.
+		$summed = $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT SUM(taxable_amount) FROM {$wpdb->prefix}wc_order_tax_lookup WHERE order_id = %d AND tax_rate_id = %d",
+				$order->get_id(),
+				$rate_id
+			)
+		);
+		$this->assertSame( 215.0, (float) $summed, 'Tax lines sharing a rate id must not multiply the taxable amount.' );
+	}
+
+	/**
+	 * Refund the given order in full, mirroring its items and their taxes.
+	 *
+	 * @param \WC_Order $order Order to refund.
+	 * @return \WC_Order_Refund
+	 */
+	private function refund_order_in_full( \WC_Order $order ): \WC_Order_Refund {
+		$line_items = array();
+		foreach ( $order->get_items( array( OrderItemType::LINE_ITEM, OrderItemType::FEE, OrderItemType::SHIPPING ) ) as $item_id => $item ) {
+			$line_items[ $item_id ] = array(
+				'qty'          => is_callable( array( $item, 'get_quantity' ) ) ? $item->get_quantity() : 0,
+				'refund_total' => $item->get_total(),
+				'refund_tax'   => $item->get_taxes()['total'],
+			);
+		}
+
+		$refund = wc_create_refund(
+			array(
+				'order_id'   => $order->get_id(),
+				'amount'     => $order->get_total(),
+				'line_items' => $line_items,
+			)
+		);
+		$this->assertNotWPError( $refund, 'The full refund should be created.' );
+
+		return $refund;
+	}
+
+	/**
+	 * @testdox A fully refunded order nets its taxable amount back to zero.
+	 */
+	public function test_refunded_order_nets_taxable_amount_to_zero(): void {
+		global $wpdb;
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$rate_id = $this->insert_tax_rate();
+		$order   = $this->create_taxed_de_order();
+		$refund  = $this->refund_order_in_full( $order );
+
+		DataStore::sync_order_taxes( $order->get_id() );
+		DataStore::sync_order_taxes( $refund->get_id() );
+
+		$sums = $wpdb->get_row(
+			$wpdb->prepare(
+				"SELECT SUM(total_tax) AS total_tax, SUM(taxable_amount) AS taxable_amount FROM {$wpdb->prefix}wc_order_tax_lookup WHERE order_id IN (%d, %d) AND tax_rate_id = %d",
+				$order->get_id(),
+				$refund->get_id(),
+				$rate_id
+			)
+		);
+		$this->assertSame( 0.0, (float) $sums->total_tax, 'A full refund should net the tax back to zero.' );
+		$this->assertSame( 0.0, (float) $sums->taxable_amount, 'A full refund should net the taxable amount back to zero.' );
+	}
+
+	/**
+	 * @testdox A refund created after a compound rate was deleted still nets the taxable amount to zero.
+	 */
+	public function test_refund_nets_compound_taxable_amount_after_rate_deletion(): void {
+		global $wpdb;
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$base_rate_id     = $this->insert_tax_rate( '5', 1 );
+		$compound_rate_id = $this->insert_tax_rate( '7', 2, 1 );
+		$order            = $this->create_taxed_de_order();
+
+		DataStore::sync_order_taxes( $order->get_id() );
+
+		// The refund's own tax items re-derive the compound flag from the live rate,
+		// so deleting the rate first exercises the parent-flags fallback.
+		WC_Tax::_delete_tax_rate( $compound_rate_id );
+
+		// A real refund happens in a later request, where the rate objects cache is cold;
+		// clear it so the refund's tax items re-derive their flags from the database.
+		$rate_store = wc_get_container()->get( \Automattic\WooCommerce\Internal\Tax\TaxRateDataStore::class );
+		$cache_prop = ( new \ReflectionClass( $rate_store ) )->getProperty( 'rate_objects_cache' );
+		$cache_prop->setAccessible( true );
+		$cache_prop->setValue( $rate_store, array() );
+
+		$refund = $this->refund_order_in_full( $order );
+
+		DataStore::sync_order_taxes( $refund->get_id() );
+
+		foreach ( array( $base_rate_id, $compound_rate_id ) as $rate_id ) {
+			$summed = $wpdb->get_var(
+				$wpdb->prepare(
+					"SELECT SUM(taxable_amount) FROM {$wpdb->prefix}wc_order_tax_lookup WHERE order_id IN (%d, %d) AND tax_rate_id = %d",
+					$order->get_id(),
+					$refund->get_id(),
+					$rate_id
+				)
+			);
+			$this->assertSame( 0.0, (float) $summed, "Rate {$rate_id} should net its taxable amount back to zero after a full refund." );
+		}
+	}
+
+	/**
+	 * @testdox While the taxable_amount column is missing, syncing still writes rows and the report still returns data.
+	 */
+	public function test_guards_apply_while_taxable_amount_column_is_missing(): void {
+		global $wpdb;
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$rate_id = $this->insert_tax_rate();
+		$order   = $this->create_taxed_de_order();
+
+		// Force the column-missing code paths through the static:: seam instead of
+		// dropping the real column, which would break test transaction isolation.
+		$sut       = new class() extends DataStore {
+			/**
+			 * Report the taxable_amount column as missing.
+			 *
+			 * @return bool
+			 */
+			public static function has_taxable_amount_column() {
+				return false;
+			}
+		};
+		$sut_class = get_class( $sut );
+
+		$sut_class::sync_order_taxes( $order->get_id() );
+		OrdersStatsDataStore::sync_order( $order->get_id() );
+		ReportsCache::invalidate();
+
+		$lookup_row = $wpdb->get_row(
+			$wpdb->prepare(
+				"SELECT total_tax, taxable_amount FROM {$wpdb->prefix}wc_order_tax_lookup WHERE order_id = %d AND tax_rate_id = %d",
+				$order->get_id(),
+				$rate_id
+			)
+		);
+		$this->assertNotNull( $lookup_row, 'The sync must still write lookup rows while the column is missing.' );
+		$this->assertSame( 40.85, round( (float) $lookup_row->total_tax, 2 ), 'The tax columns must still be recorded.' );
+
+		$after  = gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS );
+		$before = gmdate( 'Y-m-d H:i:s', time() + DAY_IN_SECONDS );
+		$query  = $this->taxes_query( $after, $before, $rate_id );
+
+		$data = $sut->get_data( array_merge( $query, array( 'orderby' => 'taxable_amount' ) ) );
+		$this->assertCount( 1, $data->data, 'Ordering by the missing column must fall back instead of erroring into an empty report.' );
+		$this->assertArrayNotHasKey( 'taxable_amount', $data->data[0], 'The report must omit the column it cannot select.' );
+	}
+
 	/**
 	 * Four jurisdiction tax lines that all carry `rate_id = 0`, the shape produced by an
 	 * integration that calculates tax without registering its rates with WooCommerce.