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.