o * default instead of replacing the rows WooCommerce holds for the order. A prune that could * not run in `sync_order_taxes()` leaves the same shape behind. * * Once the lookup is keyed by tax order item it only holds rows at zero until the rebuild has * been through it, so the check costs nothing on a store that is through it: * `order_item_id > 0` is true and the subquery is never reached. It is paid for while the * rebuild runs, where every row at zero takes an index lookup to find no per-line row beside * it. Measured on a 2M row lookup over a 30 day report, that is 177ms against 595ms, and it * goes away as the rebuild does. * * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed. * @since 11.2.0 * * @return string */ public static function get_legacy_row_condition(): string { $table_name = self::get_db_table_name(); // Nothing can have replaced a row while the lookup is still keyed on // (order_id, tax_rate_id): the sync writes every row at zero and `OrderTaxLookupMigrator` // waits for the re-key. Unlike a rebuild that is still queued, that state does not clear // itself, so the subquery would read the report range on every uncached report and come // back empty for good. if ( ! self::lookup_is_keyed_by_order_item() ) { return "{$table_name}.order_item_id = 0"; } return "( {$table_name}.order_item_id = 0 AND NOT EXISTS ( SELECT 1 FROM {$table_name} per_line WHERE per_line.order_id = {$table_name}.order_id AND per_line.tax_rate_id = {$table_name}.tax_rate_id AND per_line.order_item_id > 0 ) )"; } /** * Create or update an entry in the wc_order_tax_lookup table for an order. * * Writes one row per tax order item, in a single statement so that a write that does not land * rebuilds none of the order's tax lines rather than some of them, then drops the rows the * order held before and no longer carries. * * @param int $order_id Order ID. * @return int|bool Returns -1 if order won't be processed, or a boolean indicating processing success. */ public static function sync_order_taxes( $order_id ) { global $wpdb; $order = wc_get_order( $order_id ); // An order with no creation date has nothing to date its lookup rows by, and // `WC_Data::set_date_prop()` leaves the date null for a zero datetime, not only for a // missing one. `OrdersScheduler::import()` leaves such an order out of the reports for the // same reason. if ( ! $order || ! $order->get_date_created( 'edit' ) ) { return -1; } $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(); // Read the rows the order already holds, so that the prune below names the rows this sync // found, the way the Products and Coupons stores do. Deleting everything outside the // snapshot instead would let two syncs that read different tax lines delete each other's // rows and leave the order with none. $existing_rows = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is not user input. "SELECT tax_rate_id, order_item_id FROM {$table_name} WHERE order_id = %d", $order->get_id() ), ARRAY_A ); // Nothing has been written yet, so the order keeps the rows it came in with. Carrying on // would prune against an empty snapshot, which leaves every row the order no longer // carries in place for the reports to go on counting. if ( $wpdb->last_error ) { wc_get_logger()->error( "Could not read the analytics tax lookup rows of order {$order->get_id()}. The order keeps the rows it had and reports the way it did before.", array( 'source' => 'wc-order-tax-lookup' ) ); return false; } $stale = array(); $rows = array(); $values = array(); foreach ( $existing_rows as $existing_row ) { $stale[ $existing_row['tax_rate_id'] . '-' . $existing_row['order_item_id'] ] = array( (int) $existing_row['tax_rate_id'], (int) $existing_row['order_item_id'], ); } // 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 // one row the report matches to a single line. $order_item_id = $keyed_by_item ? $tax_item->get_id() : 0; $tax_rate_id = (int) $tax_item->get_rate_id(); // A row this sync is about to write is not stale. The key is the rate and the item // together, so a line whose rate id has changed leaves behind the row it held before. unset( $stale[ $tax_rate_id . '-' . $order_item_id ] ); array_push( $values, $order->get_id(), $date_created, $tax_rate_id, $order_item_id, $tax_item->get_shipping_tax_total(), $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 // still on the order item column's zero default beside the rows written next to it, and // 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} ({$columns}) VALUES " . implode( ', ', $rows ), $values ) ); if ( false === $written ) { wc_get_logger()->error( "Could not write the analytics tax lookup rows of order {$order->get_id()}. The order keeps the rows it had and reports the way it did before.", array( 'source' => 'wc-order-tax-lookup' ) ); return false; } } // Drop the rows the order came in with that it no longer carries, which includes the rows // written before the order item column existed, since those sit at zero. Prune only once // the writes have landed, so a write that did not land leaves the order with the rows it // came in with. if ( $stale ) { $keys = array(); $key_values = array( $order->get_id() ); foreach ( $stale as $stale_key ) { $keys[] = '(%d, %d)'; array_push( $key_values, $stale_key[0], $stale_key[1] ); } $deleted = $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 key placeholders are built above, one pair per stale row. "DELETE FROM {$table_name} WHERE order_id = %d AND (tax_rate_id, order_item_id) IN (" . implode( ', ', $keys ) . ')', $key_values ) ); // A row the order no longer carries goes on being counted by the reports, so a prune // that failed is not a sync that succeeded. if ( false === $deleted ) { wc_get_logger()->error( "Could not drop the analytics tax lookup rows order {$order->get_id()} no longer carries. Its old rows stand beside the rows just written, so the reports count those tax lines twice until the order is synced again.", array( 'source' => 'wc-order-tax-lookup' ) ); return false; } } foreach ( $tax_items as $tax_item ) { /** * Fires when tax's reports are updated. * * @param int $tax_rate_id Tax Rate ID. * @param int $order_id Order ID. * * @since 4.0.0 */ do_action( 'woocommerce_analytics_update_tax', $tax_item->get_rate_id(), $order->get_id() ); } 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 ( is_numeric( $tax ) && ! 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 ) { // Admin saves without recalculating store '' for rates that never applied // to the item. Numeric zero still counts (zero-rated sales). if ( ! is_numeric( $tax ) ) { continue; } $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. * * @param int $order_id Order ID. */ public static function sync_on_order_delete( $order_id ) { global $wpdb; $wpdb->delete( self::get_db_table_name(), array( 'order_id' => $order_id ) ); /** * Fires when tax's reports are removed from database. * * @param int $tax_rate_id Tax Rate ID. * @param int $order_id Order ID. */ do_action( 'woocommerce_analytics_delete_tax', 0, $order_id ); ReportsCache::invalidate(); } /** * Initialize query objects. */ protected function initialize_queries() { global $wpdb; $this->clear_all_clauses(); $this->subquery = new SqlQuery( $this->context . '_subquery' ); $this->subquery->add_sql_clause( 'select', self::get_db_table_name() . '.tax_rate_id' ); $this->subquery->add_sql_clause( 'from', self::get_db_table_name() ); $this->subquery->add_sql_clause( 'group_by', self::get_db_table_name() . '.tax_rate_id' ); $this->subquery->add_sql_clause( 'group_by', ", {$wpdb->prefix}woocommerce_order_items.order_item_name, itemmeta_rate_percent.meta_value" ); } }