Automattic\WooCommerce\Admin\API\Reports\Taxes

DataStore::sync_order_taxes │ public static │ WC 1.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.

Method of the class: DataStore{}

Hooks from the method

Returns

int|bool. Returns -1 if order won't be processed, or a boolean indicating processing success.

Usage

$result = DataStore::sync_order_taxes( $order_id );
$order_id(int) (required)
Order ID.

DataStore::sync_order_taxes() code WC 11.2.2

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;
}