HEX
Server: LiteSpeed
System: Linux eko108.isimtescil.net 4.18.0-477.21.1.lve.1.el8.x86_64 #1 SMP Tue Sep 5 23:08:35 UTC 2023 x86_64
User: uyarreklamcomtr (11202)
PHP: 7.4.33
Disabled: opcache_get_status
Upload Files
File: /var/www/vhosts/uyarreklam.com.tr/httpdocs/Orders.tar
CouponsController.php000064400000007022151542565670010770 0ustar00<?php

namespace Automattic\WooCommerce\Internal\Orders;

use Automattic\WooCommerce\Utilities\ArrayUtil;
use Automattic\WooCommerce\Utilities\StringUtil;
use Exception;

/**
 * Class with methods for handling order coupons.
 */
class CouponsController {

	/**
	 * Add order discount via Ajax.
	 *
	 * @throws Exception If order or coupon is invalid.
	 */
	public function add_coupon_discount_via_ajax(): void {
		check_ajax_referer( 'order-item', 'security' );

		if ( ! current_user_can( 'edit_shop_orders' ) ) {
			wp_die( -1 );
		}

		$response = array();

		try {
			$order = $this->add_coupon_discount( $_POST );

			ob_start();
			include __DIR__ . '/../../../includes/admin/meta-boxes/views/html-order-items.php';
			$response['html'] = ob_get_clean();

			ob_start();
			$notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
			include __DIR__ . '/../../../includes/admin/meta-boxes/views/html-order-notes.php';
			$response['notes_html'] = ob_get_clean();
		} catch ( Exception $e ) {
			wp_send_json_error( array( 'error' => $e->getMessage() ) );
		}

		// wp_send_json_success must be outside the try block not to break phpunit tests.
		wp_send_json_success( $response );
	}

	/**
	 * Add order discount programmatically.
	 *
	 * @param array $post_variables Contents of the $_POST array that would be passed in an Ajax call.
	 * @return object The retrieved order object.
	 * @throws \Exception Invalid order or coupon.
	 */
	public function add_coupon_discount( array $post_variables ): object {
		$order_id           = isset( $post_variables['order_id'] ) ? absint( $post_variables['order_id'] ) : 0;
		$order              = wc_get_order( $order_id );
		$calculate_tax_args = array(
			'country'  => isset( $post_variables['country'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['country'] ) ) ) : '',
			'state'    => isset( $post_variables['state'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['state'] ) ) ) : '',
			'postcode' => isset( $post_variables['postcode'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['postcode'] ) ) ) : '',
			'city'     => isset( $post_variables['city'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['city'] ) ) ) : '',
		);

		if ( ! $order ) {
			throw new Exception( __( 'Invalid order', 'woocommerce' ) );
		}

		$coupon = ArrayUtil::get_value_or_default( $post_variables, 'coupon' );
		if ( StringUtil::is_null_or_whitespace( $coupon ) ) {
			throw new Exception( __( 'Invalid coupon', 'woocommerce' ) );
		}

		// Add user ID and/or email so validation for coupon limits works.
		$user_id_arg    = isset( $post_variables['user_id'] ) ? absint( $post_variables['user_id'] ) : 0;
		$user_email_arg = isset( $post_variables['user_email'] ) ? sanitize_email( wp_unslash( $post_variables['user_email'] ) ) : '';

		if ( $user_id_arg ) {
			$order->set_customer_id( $user_id_arg );
		}
		if ( $user_email_arg ) {
			$order->set_billing_email( $user_email_arg );
		}

		$order->calculate_taxes( $calculate_tax_args );
		$order->calculate_totals( false );

		$code   = wc_format_coupon_code( wp_unslash( $coupon ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		$result = $order->apply_coupon( $code );

		if ( is_wp_error( $result ) ) {
			throw new Exception( html_entity_decode( wp_strip_all_tags( $result->get_error_message() ) ) );
		}

		// translators: %s coupon code.
		$order->add_order_note( esc_html( sprintf( __( 'Coupon applied: "%s".', 'woocommerce' ), $code ) ), 0, true );

		return $order;
	}
}
IppFunctions.php000064400000004243151542565670007721 0ustar00<?php


namespace Automattic\WooCommerce\Internal\Orders;

use WC_Order;

/**
 * Class with methods for handling order In-Person Payments.
 */
class IppFunctions {

	/**
	 * Returns if order is eligible to accept In-Person Payments.
	 *
	 * @param WC_Order $order order that the conditions are checked for.
	 *
	 * @return bool true if order is eligible, false otherwise
	 */
	public static function is_order_in_person_payment_eligible( WC_Order $order ): bool {
		$has_status            = in_array( $order->get_status(), array( 'pending', 'on-hold', 'processing' ), true );
		$has_payment_method    = in_array( $order->get_payment_method(), array( 'cod', 'woocommerce_payments', 'none' ), true );
		$order_is_not_paid     = null === $order->get_date_paid();
		$order_is_not_refunded = empty( $order->get_refunds() );

		$order_has_no_subscription_products = true;
		foreach ( $order->get_items() as $item ) {
			$product = $item->get_product();

			if ( is_object( $product ) && $product->is_type( 'subscription' ) ) {
				$order_has_no_subscription_products = false;
				break;
			}
		}

		return $has_status && $has_payment_method && $order_is_not_paid && $order_is_not_refunded && $order_has_no_subscription_products;
	}

	/**
	 * Returns if store is eligible to accept In-Person Payments.
	 *
	 * @return bool true if store is eligible, false otherwise
	 */
	public static function is_store_in_person_payment_eligible(): bool {
		$is_store_usa_based    = self::has_store_specified_country_currency( 'US', 'USD' );
		$is_store_canada_based = self::has_store_specified_country_currency( 'CA', 'CAD' );

		return $is_store_usa_based || $is_store_canada_based;
	}

	/**
	 * Checks if the store has specified country location and currency used.
	 *
	 * @param string $country country to compare store's country with.
	 * @param string $currency currency to compare store's currency with.
	 *
	 * @return bool true if specified country and currency match the store's ones. false otherwise
	 */
	public static function has_store_specified_country_currency( string $country, string $currency ): bool {
		return ( WC()->countries->get_base_country() === $country && get_woocommerce_currency() === $currency );
	}
}
MobileMessagingHandler.php000064400000013002151542565670011634 0ustar00<?php

namespace Automattic\WooCommerce\Internal\Orders;

use DateTime;
use Exception;
use WC_Order;
use WC_Tracker;

/**
 * Prepares formatted mobile deep link navigation link for order mails.
 */
class MobileMessagingHandler {

	private const OPEN_ORDER_INTERVAL_DAYS = 30;

	/**
	 * Prepares mobile messaging with a deep link.
	 *
	 * @param WC_Order $order order that mobile message is created for.
	 * @param ?int     $blog_id  of blog to make a deep link for (will be null if Jetpack is not enabled).
	 * @param DateTime $now      current DateTime.
	 * @param string   $domain URL of the current site.
	 *
	 * @return ?string
	 */
	public static function prepare_mobile_message(
		WC_Order $order,
		?int $blog_id,
		DateTime $now,
		string $domain
	): ?string {
		try {
			$last_mobile_used = self::get_closer_mobile_usage_date();

			$used_app_in_last_month = null !== $last_mobile_used && $last_mobile_used->diff( $now )->days <= self::OPEN_ORDER_INTERVAL_DAYS;
			$has_jetpack            = null !== $blog_id;

			if ( IppFunctions::is_store_in_person_payment_eligible() && IppFunctions::is_order_in_person_payment_eligible( $order ) ) {
				return self::accept_payment_message( $blog_id, $domain );
			} else {
				if ( $used_app_in_last_month && $has_jetpack ) {
					return self::manage_order_message( $blog_id, $order->get_id(), $domain );
				} else {
					return self::no_app_message( $blog_id, $domain );
				}
			}
		} catch ( Exception $e ) {
			return null;
		}
	}

	/**
	 * Returns the closest date of last usage of any mobile app platform.
	 *
	 * @return ?DateTime
	 */
	private static function get_closer_mobile_usage_date(): ?DateTime {
		$mobile_usage = WC_Tracker::get_woocommerce_mobile_usage();

		if ( ! $mobile_usage ) {
			return null;
		}

		$last_ios_used     = self::get_last_used_or_null( 'ios', $mobile_usage );
		$last_android_used = self::get_last_used_or_null( 'android', $mobile_usage );

		return max( $last_android_used, $last_ios_used );
	}

	/**
	 * Returns last used date of specified mobile app platform.
	 *
	 * @param string $platform     mobile platform to check.
	 * @param array  $mobile_usage mobile apps usage data.
	 *
	 * @return ?DateTime last used date of specified mobile app
	 */
	private static function get_last_used_or_null(
		string $platform, array $mobile_usage
	): ?DateTime {
		try {
			if ( array_key_exists( $platform, $mobile_usage ) ) {
				return new DateTime( $mobile_usage[ $platform ]['last_used'] );
			} else {
				return null;
			}
		} catch ( Exception $e ) {
			return null;
		}
	}

	/**
	 * Prepares message with a deep link to mobile payment.
	 *
	 * @param ?int   $blog_id blog id to deep link to.
	 * @param string $domain URL of the current site.
	 *
	 * @return string formatted message
	 */
	private static function accept_payment_message( ?int $blog_id, $domain ): string {
		$deep_link_url = add_query_arg(
			array_merge(
				array(
					'blog_id' => absint( $blog_id ),
				),
				self::prepare_utm_parameters( 'deeplinks_payments', $blog_id, $domain )
			),
			'https://woocommerce.com/mobile/payments'
		);

		return sprintf(
			/* translators: 1: opening link tag 2: closing link tag. */
			esc_html__(
				'%1$sCollect payments easily%2$s from your customers anywhere with our mobile app.',
				'woocommerce'
			),
			'<a href="' . esc_url( $deep_link_url ) . '">',
			'</a>'
		);
	}

	/**
	 * Prepares message with a deep link to manage order details.
	 *
	 * @param int    $blog_id blog id to deep link to.
	 * @param int    $order_id order id to deep link to.
	 * @param string $domain URL of the current site.
	 *
	 * @return string formatted message
	 */
	private static function manage_order_message( int $blog_id, int $order_id, string $domain ): string {
		$deep_link_url = add_query_arg(
			array_merge(
				array(
					'blog_id'  => absint( $blog_id ),
					'order_id' => absint( $order_id ),
				),
				self::prepare_utm_parameters( 'deeplinks_orders_details', $blog_id, $domain )
			),
			'https://woocommerce.com/mobile/orders/details'
		);

		return sprintf(
			/* translators: 1: opening link tag 2: closing link tag. */
			esc_html__(
				'%1$sManage the order%2$s with the app.',
				'woocommerce'
			),
			'<a href="' . esc_url( $deep_link_url ) . '">',
			'</a>'
		);
	}

	/**
	 * Prepares message with a deep link to learn more about mobile app.
	 *
	 * @param ?int   $blog_id blog id used for tracking.
	 * @param string $domain URL of the current site.
	 *
	 * @return string formatted message
	 */
	private static function no_app_message( ?int $blog_id, string $domain ): string {
		$deep_link_url = add_query_arg(
			array_merge(
				array(
					'blog_id' => absint( $blog_id ),
				),
				self::prepare_utm_parameters( 'deeplinks_promote_app', $blog_id, $domain )
			),
			'https://woocommerce.com/mobile'
		);
		return sprintf(
			/* translators: 1: opening link tag 2: closing link tag. */
			esc_html__(
				'Process your orders on the go. %1$sGet the app%2$s.',
				'woocommerce'
			),
			'<a href="' . esc_url( $deep_link_url ) . '">',
			'</a>'
		);
	}

	/**
	 * Prepares array of parameters used by WooCommerce.com for tracking.
	 *
	 * @param string   $campaign name of the deep link campaign.
	 * @param int|null $blog_id blog id of the current site.
	 * @param string   $domain URL of the current site.
	 *
	 * @return array
	 */
	private static function prepare_utm_parameters(
		string $campaign,
		?int $blog_id,
		string $domain
	): array {
		return array(
			'utm_campaign' => $campaign,
			'utm_medium'   => 'email',
			'utm_source'   => $domain,
			'utm_term'     => absint( $blog_id ),
		);
	}
}
TaxesController.php000064400000003462151542565670010432 0ustar00<?php

namespace Automattic\WooCommerce\Internal\Orders;

/**
 * Class with methods for handling order taxes.
 */
class TaxesController {

	/**
	 * Calculate line taxes via Ajax call.
	 */
	public function calc_line_taxes_via_ajax(): void {
		check_ajax_referer( 'calc-totals', 'security' );

		if ( ! current_user_can( 'edit_shop_orders' ) || ! isset( $_POST['order_id'], $_POST['items'] ) ) {
			wp_die( -1 );
		}

		$order = $this->calc_line_taxes( $_POST );

		include __DIR__ . '/../../../includes/admin/meta-boxes/views/html-order-items.php';
		wp_die();
	}

	/**
	 * Calculate line taxes programmatically.
	 *
	 * @param array $post_variables Contents of the $_POST array that would be passed in an Ajax call.
	 * @return object The retrieved order object.
	 */
	public function calc_line_taxes( array $post_variables ): object {
		$order_id           = absint( $post_variables['order_id'] );
		$calculate_tax_args = array(
			'country'  => isset( $post_variables['country'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['country'] ) ) ) : '',
			'state'    => isset( $post_variables['state'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['state'] ) ) ) : '',
			'postcode' => isset( $post_variables['postcode'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['postcode'] ) ) ) : '',
			'city'     => isset( $post_variables['city'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['city'] ) ) ) : '',
		);

		// Parse the jQuery serialized items.
		$items = array();
		parse_str( wp_unslash( $post_variables['items'] ), $items );

		// Save order items first.
		wc_save_order_items( $order_id, $items );

		// Grab the order and recalculate taxes.
		$order = wc_get_order( $order_id );
		$order->calculate_taxes( $calculate_tax_args );
		$order->calculate_totals( false );

		return $order;
	}
}
COTRedirectionController.php000064400000005405151547221760012154 0ustar00<?php

namespace Automattic\WooCommerce\Internal\Admin\Orders;

use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;

/**
 * When Custom Order Tables are not the default order store (ie, posts are authoritative), we should take care of
 * redirecting requests for the order editor and order admin list table to the equivalent posts-table screens.
 *
 * If the redirect logic is problematic, it can be unhooked using code like the following example:
 *
 *     remove_action(
 *         'admin_page_access_denied',
 *         array( wc_get_container()->get( COTRedirectionController::class ), 'handle_hpos_admin_requests' )
 *     );
 */
class COTRedirectionController {
	use AccessiblePrivateMethods;

	/**
	 * Add hooks needed to perform our magic.
	 */
	public function setup(): void {
		// Only take action in cases where access to the admin screen would otherwise be denied.
		self::add_action( 'admin_page_access_denied', array( $this, 'handle_hpos_admin_requests' ) );
	}

	/**
	 * Listen for denied admin requests and, if they appear to relate to HPOS admin screens, potentially
	 * redirect the user to the equivalent CPT-driven screens.
	 *
	 * @param array|null $query_params The query parameters to use when determining the redirect. If not provided, the $_GET superglobal will be used.
	 */
	private function handle_hpos_admin_requests( $query_params = null ) {
		$query_params = is_array( $query_params ) ? $query_params : $_GET;

		if ( ! isset( $query_params['page'] ) || 'wc-orders' !== $query_params['page'] ) {
			return;
		}

		$params = wp_unslash( $query_params );
		$action = $params['action'] ?? '';
		unset( $params['page'] );

		if ( 'edit' === $action && isset( $params['id'] ) ) {
			$params['post'] = $params['id'];
			unset( $params['id'] );
			$new_url = add_query_arg( $params, get_admin_url( null, 'post.php' ) );
		} elseif ( 'new' === $action ) {
			unset( $params['action'] );
			$params['post_type'] = 'shop_order';
			$new_url             = add_query_arg( $params, get_admin_url( null, 'post-new.php' ) );
		} else {
			// If nonce parameters are present and valid, rebuild them for the CPT admin list table.
			if ( isset( $params['_wpnonce'] ) && check_admin_referer( 'bulk-orders' ) ) {
				$params['_wp_http_referer'] = get_admin_url( null, 'edit.php?post_type=shop_order' );
				$params['_wpnonce']         = wp_create_nonce( 'bulk-posts' );
			}

			// If an `id` array parameter is present, rename as `post`.
			if ( isset( $params['id'] ) && is_array( $params['id'] ) ) {
				$params['post'] = $params['id'];
				unset( $params['id'] );
			}

			$params['post_type'] = 'shop_order';
			$new_url             = add_query_arg( $params, get_admin_url( null, 'edit.php' ) );
		}

		if ( ! empty( $new_url ) && wp_safe_redirect( $new_url, 301 ) ) {
			exit;
		}
	}
}
Edit.php000064400000031146151547222000006145 0ustar00<?php
/**
 * Renders order edit page, works with both post and order object.
 */

namespace Automattic\WooCommerce\Internal\Admin\Orders;

use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\CustomMetaBox;
use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\TaxonomiesMetaBox;

/**
 * Class Edit.
 */
class Edit {

	/**
	 * Screen ID for the edit order screen.
	 *
	 * @var string
	 */
	private $screen_id;

	/**
	 * Instance of the CustomMetaBox class. Used to render meta box for custom meta.
	 *
	 * @var CustomMetaBox
	 */
	private $custom_meta_box;

	/**
	 * Instance of the TaxonomiesMetaBox class. Used to render meta box for taxonomies.
	 *
	 * @var TaxonomiesMetaBox
	 */
	private $taxonomies_meta_box;

	/**
	 * Instance of WC_Order to be used in metaboxes.
	 *
	 * @var \WC_Order
	 */
	private $order;

	/**
	 * Action name that the form is currently handling. Could be new_order or edit_order.
	 *
	 * @var string
	 */
	private $current_action;

	/**
	 * Message to be displayed to the user. Index of message from the messages array registered when declaring shop_order post type.
	 *
	 * @var int
	 */
	private $message;

	/**
	 * Controller for orders page. Used to determine redirection URLs.
	 *
	 * @var PageController
	 */
	private $orders_page_controller;

	/**
	 * Hooks all meta-boxes for order edit page. This is static since this may be called by post edit form rendering.
	 *
	 * @param string $screen_id Screen ID.
	 * @param string $title Title of the page.
	 */
	public static function add_order_meta_boxes( string $screen_id, string $title ) {
		/* Translators: %s order type name. */
		add_meta_box( 'woocommerce-order-data', sprintf( __( '%s data', 'woocommerce' ), $title ), 'WC_Meta_Box_Order_Data::output', $screen_id, 'normal', 'high' );
		add_meta_box( 'woocommerce-order-items', __( 'Items', 'woocommerce' ), 'WC_Meta_Box_Order_Items::output', $screen_id, 'normal', 'high' );
		/* Translators: %s order type name. */
		add_meta_box( 'woocommerce-order-notes', sprintf( __( '%s notes', 'woocommerce' ), $title ), 'WC_Meta_Box_Order_Notes::output', $screen_id, 'side', 'default' );
		add_meta_box( 'woocommerce-order-downloads', __( 'Downloadable product permissions', 'woocommerce' ) . wc_help_tip( __( 'Note: Permissions for order items will automatically be granted when the order status changes to processing/completed.', 'woocommerce' ) ), 'WC_Meta_Box_Order_Downloads::output', $screen_id, 'normal', 'default' );
		/* Translators: %s order type name. */
		add_meta_box( 'woocommerce-order-actions', sprintf( __( '%s actions', 'woocommerce' ), $title ), 'WC_Meta_Box_Order_Actions::output', $screen_id, 'side', 'high' );
	}

	/**
	 * Hooks metabox save functions for order edit page.
	 *
	 * @return void
	 */
	public static function add_save_meta_boxes() {
		/**
		 * Save Order Meta Boxes.
		 *
		 * In order:
		 *      Save the order items.
		 *      Save the order totals.
		 *      Save the order downloads.
		 *      Save order data - also updates status and sends out admin emails if needed. Last to show latest data.
		 *      Save actions - sends out other emails. Last to show latest data.
		 */
		add_action( 'woocommerce_process_shop_order_meta', 'WC_Meta_Box_Order_Items::save', 10 );
		add_action( 'woocommerce_process_shop_order_meta', 'WC_Meta_Box_Order_Downloads::save', 30, 2 );
		add_action( 'woocommerce_process_shop_order_meta', 'WC_Meta_Box_Order_Data::save', 40 );
		add_action( 'woocommerce_process_shop_order_meta', 'WC_Meta_Box_Order_Actions::save', 50, 2 );
	}

	/**
	 * Enqueue necessary scripts for order edit page.
	 */
	private function enqueue_scripts() {
		if ( wp_is_mobile() ) {
			wp_enqueue_script( 'jquery-touch-punch' );
		}
		wp_enqueue_script( 'post' ); // Ensure existing JS libraries are still available for backward compat.
	}

	/**
	 * Returns the PageController for this edit form. This method is protected to allow child classes to overwrite the PageController object and return custom links.
	 *
	 * @since 8.0.0
	 *
	 * @return PageController PageController object.
	 */
	protected function get_page_controller() {
		if ( ! isset( $this->orders_page_controller ) ) {
			$this->orders_page_controller = wc_get_container()->get( PageController::class );
		}
		return $this->orders_page_controller;
	}

	/**
	 * Setup hooks, actions and variables needed to render order edit page.
	 *
	 * @param \WC_Order $order Order object.
	 */
	public function setup( \WC_Order $order ) {
		$this->order    = $order;
		$current_screen = get_current_screen();
		$current_screen->is_block_editor( false );
		$this->screen_id = $current_screen->id;
		if ( ! isset( $this->custom_meta_box ) ) {
			$this->custom_meta_box = wc_get_container()->get( CustomMetaBox::class );
		}

		if ( ! isset( $this->taxonomies_meta_box ) ) {
			$this->taxonomies_meta_box = wc_get_container()->get( TaxonomiesMetaBox::class );
		}

		$this->add_save_meta_boxes();
		$this->handle_order_update();
		$this->add_order_meta_boxes( $this->screen_id, __( 'Order', 'woocommerce' ) );
		$this->add_order_specific_meta_box();
		$this->add_order_taxonomies_meta_box();

		/**
		 * From wp-admin/includes/meta-boxes.php.
		 *
		 * Fires after all built-in meta boxes have been added. Custom metaboxes may be enqueued here.
		 *
		 * @since 3.8.0.
		 */
		do_action( 'add_meta_boxes', $this->screen_id, $this->order );

		/**
		 * Provides an opportunity to inject custom meta boxes into the order editor screen. This
		 * hook is an analog of `add_meta_boxes_<POST_TYPE>` as provided by WordPress core.
		 *
		 * @since 7.4.0
		 *
		 * @oaram WC_Order $order The order being edited.
		 */
		do_action( 'add_meta_boxes_' . $this->screen_id, $this->order );

		$this->enqueue_scripts();
	}

	/**
	 * Set the current action for the form.
	 *
	 * @param string $action Action name.
	 */
	public function set_current_action( string $action ) {
		$this->current_action = $action;
	}

	/**
	 * Hooks meta box for order specific meta.
	 */
	private function add_order_specific_meta_box() {
		add_meta_box(
			'order_custom',
			__( 'Custom Fields', 'woocommerce' ),
			array( $this, 'render_custom_meta_box' ),
			$this->screen_id,
			'normal'
		);
	}

	/**
	 * Render custom meta box.
	 *
	 * @return void
	 */
	private function add_order_taxonomies_meta_box() {
		$this->taxonomies_meta_box->add_taxonomies_meta_boxes( $this->screen_id, $this->order->get_type() );
	}

	/**
	 * Takes care of updating order data. Fires action that metaboxes can hook to for order data updating.
	 *
	 * @return void
	 */
	public function handle_order_update() {
		if ( ! isset( $this->order ) ) {
			return;
		}

		if ( 'edit_order' !== sanitize_text_field( wp_unslash( $_POST['action'] ?? '' ) ) ) {
			return;
		}

		check_admin_referer( $this->get_order_edit_nonce_action() );

		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized later on by taxonomies_meta_box object.
		$taxonomy_input = isset( $_POST['tax_input'] ) ? wp_unslash( $_POST['tax_input'] ) : null;
		$this->taxonomies_meta_box->save_taxonomies( $this->order, $taxonomy_input );

		/**
		 * Save meta for shop order.
		 *
		 * @param int Order ID.
		 * @param \WC_Order Post object.
		 *
		 * @since 2.1.0
		 */
		do_action( 'woocommerce_process_shop_order_meta', $this->order->get_id(), $this->order );

		$this->custom_meta_box->handle_metadata_changes($this->order);

		// Order updated message.
		$this->message = 1;

		$this->redirect_order( $this->order );
	}

	/**
	 * Helper method to redirect to order edit page.
	 *
	 * @since 8.0.0
	 *
	 * @param \WC_Order $order Order object.
	 */
	private function redirect_order( \WC_Order $order ) {
		$redirect_to = $this->get_page_controller()->get_edit_url( $order->get_id() );
		if ( isset( $this->message ) ) {
			$redirect_to = add_query_arg( 'message', $this->message, $redirect_to );
		}
		wp_safe_redirect(
			/**
			 * Filter the URL used to redirect after an order is updated. Similar to the WP post's `redirect_post_location` filter.
			 *
			 * @param string    $redirect_to The redirect destination URL.
			 * @param int       $order_id The order ID.
			 * @param \WC_Order $order The order object.
			 *
			 * @since 8.0.0
			 */
			apply_filters(
				'woocommerce_redirect_order_location',
				$redirect_to,
				$order->get_id(),
				$order
			)
		);
		exit;
	}

	/**
	 * Helper method to get the name of order edit nonce.
	 *
	 * @return string Nonce action name.
	 */
	private function get_order_edit_nonce_action() {
		return 'update-order_' . $this->order->get_id();
	}

	/**
	 * Render meta box for order specific meta.
	 */
	public function render_custom_meta_box() {
		$this->custom_meta_box->output( $this->order );
	}

	/**
	 * Render order edit page.
	 */
	public function display() {
		/**
		 * This is used by the order edit page to show messages in the notice fields.
		 * It should be similar to post_updated_messages filter, i.e.:
		 * array(
		 *   {order_type} => array(
		 *      1 => 'Order updated.',
		 *      2 => 'Custom field updated.',
		 * ...
		 * ).
		 *
		 * The index to be displayed is computed from the $_GET['message'] variable.
		 *
		 * @since 7.4.0.
		 */
		$messages = apply_filters( 'woocommerce_order_updated_messages', array() );

		$message = $this->message;
		if ( isset( $_GET['message'] ) ) {
			$message = absint( $_GET['message'] );
		}

		if ( isset( $message ) ) {
			$message = $messages[ $this->order->get_type() ][ $message ] ?? false;
		}

		$this->render_wrapper_start( '', $message );
		$this->render_meta_boxes();
		$this->render_wrapper_end();
	}

	/**
	 * Helper function to render wrapper start.
	 *
	 * @param string $notice Notice to display, if any.
	 * @param string $message Message to display, if any.
	 */
	private function render_wrapper_start( $notice = '', $message = '' ) {
		$post_type = get_post_type_object( $this->order->get_type() );

		$edit_page_url = $this->get_page_controller()->get_edit_url( $this->order->get_id() );
		$form_action   = 'edit_order';
		$referer       = wp_get_referer();
		$new_page_url  = $this->get_page_controller()->get_new_page_url( $this->order->get_type() );

		?>
		<div class="wrap">
		<h1 class="wp-heading-inline">
			<?php
			echo 'new_order' === $this->current_action ? esc_html( $post_type->labels->add_new_item ) : esc_html( $post_type->labels->edit_item );
			?>
		</h1>
		<?php
		if ( 'edit_order' === $this->current_action ) {
			echo ' <a href="' . esc_url( $new_page_url ) . '" class="page-title-action">' . esc_html( $post_type->labels->add_new ) . '</a>';
		}
		?>
		<hr class="wp-header-end">

		<?php
		if ( $notice ) :
			?>
			<div id="notice" class="notice notice-warning"><p
					id="has-newer-autosave"><?php echo wp_kses_post( $notice ); ?></p></div>
		<?php endif; ?>
		<?php if ( $message ) : ?>
			<div id="message" class="updated notice notice-success is-dismissible">
				<p><?php echo wp_kses_post( $message ); ?></p></div>
			<?php
			endif;
		?>

		<form name="order" action="<?php echo esc_url( $edit_page_url ); ?>" method="post" id="order"
		<?php
		/**
		 * Fires inside the order edit form tag.
		 *
		 * @param \WC_Order $order Order object.
		 *
		 * @since 6.9.0
		 */
		do_action( 'order_edit_form_tag', $this->order );
		?>
		>
		<?php wp_nonce_field( $this->get_order_edit_nonce_action() ); ?>
		<?php
		/**
		 * Fires at the top of the order edit form. Can be used as a replacement for edit_form_top hook for HPOS.
		 *
		 * @param \WC_Order $order Order object.
		 *
		 * @since 8.0.0
		 */
		do_action( 'order_edit_form_top', $this->order );
		?>
		<input type="hidden" id="hiddenaction" name="action" value="<?php echo esc_attr( $form_action ); ?>"/>
		<input type="hidden" id="original_order_status" name="original_order_status" value="<?php echo esc_attr( $this->order->get_status() ); ?>"/>
		<input type="hidden" id="referredby" name="referredby" value="<?php echo $referer ? esc_url( $referer ) : ''; ?>"/>
		<input type="hidden" id="post_ID" name="post_ID" value="<?php echo esc_attr( $this->order->get_id() ); ?>"/>
		<div id="poststuff">
		<div id="post-body"
		class="metabox-holder columns-<?php echo ( 1 === get_current_screen()->get_columns() ) ? '1' : '2'; ?>">
		<?php
	}

	/**
	 * Helper function to render meta boxes.
	 */
	private function render_meta_boxes() {
		?>
		<div id="postbox-container-1" class="postbox-container">
			<?php do_meta_boxes( $this->screen_id, 'side', $this->order ); ?>
		</div>
		<div id="postbox-container-2" class="postbox-container">
			<?php
			do_meta_boxes( $this->screen_id, 'normal', $this->order );
			do_meta_boxes( $this->screen_id, 'advanced', $this->order );
			?>
		</div>
		<?php
	}

	/**
	 * Helper function to render wrapper end.
	 */
	private function render_wrapper_end() {
		?>
		</div> <!-- /post-body -->
		</div> <!-- /poststuff  -->
		</form>
		</div> <!-- /wrap -->
		<?php
	}
}
EditLock.php000064400000016770151547222000006764 0ustar00<?php
namespace Automattic\WooCommerce\Internal\Admin\Orders;

/**
 * This class takes care of the edit lock logic when HPOS is enabled.
 * For better interoperability with WordPress, edit locks are stored in the same format as posts. That is, as a metadata
 * in the order object (key: '_edit_lock') in the format "timestamp:user_id".
 *
 * @since 7.8.0
 */
class EditLock {

	const META_KEY_NAME = '_edit_lock';

	/**
	 * Obtains lock information for a given order. If the lock has expired or it's assigned to an invalid user,
	 * the order is no longer considered locked.
	 *
	 * @param \WC_Order $order Order to check.
	 * @return bool|array
	 */
	public function get_lock( \WC_Order $order ) {
		$lock = $order->get_meta( self::META_KEY_NAME, true, 'edit' );
		if ( ! $lock ) {
			return false;
		}

		$lock = explode( ':', $lock );
		if ( 2 !== count( $lock ) ) {
			return false;
		}

		$time    = absint( $lock[0] );
		$user_id = isset( $lock[1] ) ? absint( $lock[1] ) : 0;

		if ( ! $time || ! get_user_by( 'id', $user_id ) ) {
			return false;
		}

		/** This filter is documented in WP's wp-admin/includes/ajax-actions.php */
		$time_window = apply_filters( 'wp_check_post_lock_window', 150 ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingSinceComment
		if ( time() >= ( $time + $time_window ) ) {
			return false;
		}

		return compact( 'time', 'user_id' );
	}

	/**
	 * Checks whether the order is being edited (i.e. locked) by another user.
	 *
	 * @param \WC_Order $order Order to check.
	 * @return bool TRUE if order is locked and currently being edited by another user. FALSE otherwise.
	 */
	public function is_locked_by_another_user( \WC_Order $order ) : bool {
		$lock = $this->get_lock( $order );
		return $lock && ( get_current_user_id() !== $lock['user_id'] );
	}

	/**
	 * Checks whether the order is being edited by any user.
	 *
	 * @param \WC_Order $order Order to check.
	 * @return boolean TRUE if order is locked and currently being edited by a user. FALSE otherwise.
	 */
	public function is_locked( \WC_Order $order ) : bool {
		return (bool) $this->get_lock( $order );
	}

	/**
	 * Assigns an order's edit lock to the current user.
	 *
	 * @param \WC_Order $order The order to apply the lock to.
	 * @return array|bool FALSE if no user is logged-in, an array in the same format as {@see get_lock()} otherwise.
	 */
	public function lock( \WC_Order $order ) {
		$user_id = get_current_user_id();

		if ( ! $user_id ) {
			return false;
		}

		$order->update_meta_data( self::META_KEY_NAME, time() . ':' . $user_id );
		$order->save_meta_data();

		return $order->get_meta( self::META_KEY_NAME, true, 'edit' );
	}

	/**
	 * Hooked to 'heartbeat_received' on the edit order page to refresh the lock on an order being edited by the current user.
	 *
	 * @param array $response The heartbeat response to be sent.
	 * @param array $data     Data sent through the heartbeat.
	 * @return array Response to be sent.
	 */
	public function refresh_lock_ajax( $response, $data ) {
		$order_id = absint( $data['wc-refresh-order-lock'] ?? 0 );
		if ( ! $order_id ) {
			return $response;
		}

		unset( $response['wp-refresh-post-lock'] );

		$order = wc_get_order( $order_id );
		if ( ! $order || ( ! current_user_can( get_post_type_object( $order->get_type() )->cap->edit_post, $order->get_id() ) && ! current_user_can( 'manage_woocommerce' ) ) ) {
			return $response;
		}

		$response['wc-refresh-order-lock'] = array();

		if ( ! $this->is_locked_by_another_user( $order ) ) {
			$response['wc-refresh-order-lock']['lock'] = $this->lock( $order );
		} else {
			$current_lock = $this->get_lock( $order );
			$user         = get_user_by( 'id', $current_lock['user_id'] );

			$response['wc-refresh-order-lock']['error'] = array(
				// translators: %s is a user's name.
				'message'            => sprintf( __( '%s has taken over and is currently editing.', 'woocommerce' ), $user->display_name ),
				'user_name'          => $user->display_name,
				'user_avatar_src'    => get_option( 'show_avatars' ) ? get_avatar_url( $user->ID, array( 'size' => 64 ) ) : '',
				'user_avatar_src_2x' => get_option( 'show_avatars' ) ? get_avatar_url( $user->ID, array( 'size' => 128 ) ) : '',
			);
		}

		return $response;
	}

	/**
	 * Hooked to 'heartbeat_received' on the orders screen to refresh the locked status of orders in the list table.
	 *
	 * @param array $response The heartbeat response to be sent.
	 * @param array $data     Data sent through the heartbeat.
	 * @return array Response to be sent.
	 */
	public function check_locked_orders_ajax( $response, $data ) {
		if ( empty( $data['wc-check-locked-orders'] ) || ! is_array( $data['wc-check-locked-orders'] ) ) {
			return $response;
		}

		$response['wc-check-locked-orders'] = array();

		$order_ids = array_unique( array_map( 'absint', $data['wc-check-locked-orders'] ) );
		foreach ( $order_ids as $order_id ) {
			$order = wc_get_order( $order_id );
			if ( ! $order ) {
				continue;
			}

			if ( ! $this->is_locked_by_another_user( $order ) || ( ! current_user_can( get_post_type_object( $order->get_type() )->cap->edit_post, $order->get_id() ) && ! current_user_can( 'manage_woocommerce' ) ) ) {
				continue;
			}

			$response['wc-check-locked-orders'][ $order_id ] = true;
		}

		return $response;
	}

	/**
	 * Outputs HTML for the lock dialog based on the status of the lock on the order (if any).
	 * Depending on who owns the lock, this could be a message with the chance to take over or a message indicating that
	 * someone else has taken over the order.
	 *
	 * @param \WC_Order $order Order object.
	 * @return void
	 */
	public function render_dialog( $order ) {
		$locked = $this->is_locked_by_another_user( $order );
		$lock   = $this->get_lock( $order );
		$user   = get_user_by( 'id', $lock['user_id'] );

		$edit_url = wc_get_container()->get( \Automattic\WooCommerce\Internal\Admin\Orders\PageController::class )->get_edit_url( $order->get_id() );

		$sendback_url = wp_get_referer();
		if ( ! $sendback_url ) {
			$sendback_url = wc_get_container()->get( \Automattic\WooCommerce\Internal\Admin\Orders\PageController::class )->get_base_page_url( $order->get_type() );
		}

		$sendback_text = __( 'Go back', 'woocommerce' );
		?>
		<div id="post-lock-dialog" class="notification-dialog-wrap <?php echo $locked ? '' : 'hidden'; ?> order-lock-dialog">
			<div class="notification-dialog-background"></div>
			<div class="notification-dialog">
			<?php if ( $locked ) : ?>
			<div class="post-locked-message">
				<div class="post-locked-avatar"><?php echo get_avatar( $user->ID, 64 ); ?></div>
				<p class="currently-editing wp-tab-first" tabindex="0">
				<?php
				// translators: %s is a user's name.
				echo esc_html( sprintf( __( '%s is currently editing this order. Do you want to take over?', 'woocommerce' ), esc_html( $user->display_name ) ) );
				?>
				</p>
				<p>
					<a class="button" href="<?php echo esc_url( $sendback_url ); ?>"><?php echo esc_html( $sendback_text ); ?></a>
					<a class="button button-primary wp-tab-last" href="<?php echo esc_url( add_query_arg( 'claim-lock', '1', wp_nonce_url( $edit_url, 'claim-lock-' . $order->get_id() ) ) ); ?>"><?php esc_html_e( 'Take over', 'woocommerce' ); ?></a>
				</p>
			</div>
			<?php else : ?>
			<div class="post-taken-over">
				<div class="post-locked-avatar"></div>
				<p class="wp-tab-first" tabindex="0">
				<span class="currently-editing"></span><br />
				</p>
				<p><a class="button button-primary wp-tab-last" href="<?php echo esc_url( $sendback_url ); ?>"><?php echo esc_html( $sendback_text ); ?></a></p>
			</div>
			<?php endif; ?>
			</div>
		</div>
		<?php
	}

}
ListTable.php000064400000136351151547222000007147 0ustar00<?php

namespace Automattic\WooCommerce\Internal\Admin\Orders;

use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
use Automattic\WooCommerce\Utilities\OrderUtil;
use WC_Order;
use WP_List_Table;
use WP_Screen;

/**
 * Admin list table for orders as managed by the OrdersTableDataStore.
 */
class ListTable extends WP_List_Table {

	/**
	 * Order type.
	 *
	 * @var string
	 */
	private $order_type;

	/**
	 * Request vars.
	 *
	 * @var array
	 */
	private $request = array();

	/**
	 * Contains the arguments to be used in the order query.
	 *
	 * @var array
	 */
	private $order_query_args = array();

	/**
	 * Tracks if a filter (ie, date or customer filter) has been applied.
	 *
	 * @var bool
	 */
	private $has_filter = false;

	/**
	 * Page controller instance for this request.
	 *
	 * @var PageController
	 */
	private $page_controller;

	/**
	 * Tracks whether we're currently inside the trash.
	 *
	 * @var boolean
	 */
	private $is_trash = false;

	/**
	 * Caches order counts by status.
	 *
	 * @var array
	 */
	private $status_count_cache = null;

	/**
	 * Sets up the admin list table for orders (specifically, for orders managed by the OrdersTableDataStore).
	 *
	 * @see WC_Admin_List_Table_Orders for the corresponding class used in relation to the traditional WP Post store.
	 */
	public function __construct() {
		parent::__construct(
			array(
				'singular' => 'order',
				'plural'   => 'orders',
				'ajax'     => false,
			)
		);
	}

	/**
	 * Init method, invoked by DI container.
	 *
	 * @internal This method is not intended to be used directly (except for testing).
	 * @param PageController $page_controller Page controller instance for this request.
	 */
	final public function init( PageController $page_controller ) {
		$this->page_controller = $page_controller;
	}

	/**
	 * Performs setup work required before rendering the table.
	 *
	 * @param array $args Args to initialize this list table.
	 *
	 * @return void
	 */
	public function setup( $args = array() ): void {
		$this->order_type = $args['order_type'] ?? 'shop_order';

		add_action( 'admin_notices', array( $this, 'bulk_action_notices' ) );
		add_filter( "manage_{$this->screen->id}_columns", array( $this, 'get_columns' ), 0 );
		add_filter( 'set_screen_option_edit_' . $this->order_type . '_per_page', array( $this, 'set_items_per_page' ), 10, 3 );
		add_filter( 'default_hidden_columns', array( $this, 'default_hidden_columns' ), 10, 2 );
		add_action( 'admin_footer', array( $this, 'enqueue_scripts' ) );

		$this->items_per_page();
		set_screen_options();

		add_action( 'manage_' . wc_get_page_screen_id( $this->order_type ) . '_custom_column', array( $this, 'render_column' ), 10, 2 );
	}

	/**
	 * Generates content for a single row of the table.
	 *
	 * @since 7.8.0
	 *
	 * @param \WC_Order $order The current order.
	 */
	public function single_row( $order ) {
		/**
		 * Filters the list of CSS class names for a given order row in the orders list table.
		 *
		 * @since 7.8.0
		 *
		 * @param string[]  $classes An array of CSS class names.
		 * @param \WC_Order $order   The order object.
		 */
		$css_classes = apply_filters(
			'woocommerce_' . $this->order_type . '_list_table_order_css_classes',
			array(
				'order-' . $order->get_id(),
				'type-' . $order->get_type(),
				'status-' . $order->get_status(),
			),
			$order
		);
		$css_classes = array_unique( array_map( 'trim', $css_classes ) );

		// Is locked?
		$edit_lock = wc_get_container()->get( EditLock::class );
		if ( $edit_lock->is_locked_by_another_user( $order ) ) {
			$css_classes[] = 'wp-locked';
		}

		echo '<tr id="order-' . esc_attr( $order->get_id() ) . '" class="' . esc_attr( implode( ' ', $css_classes ) ) . '">';
		$this->single_row_columns( $order );
		echo '</tr>';
	}

	/**
	 * Render individual column.
	 *
	 * @param string   $column_id Column ID to render.
	 * @param WC_Order $order Order object.
	 */
	public function render_column( $column_id, $order ) {
		if ( ! $order ) {
			return;
		}

		if ( is_callable( array( $this, 'render_' . $column_id . '_column' ) ) ) {
			call_user_func( array( $this, 'render_' . $column_id . '_column' ), $order );
		}
	}

	/**
	 * Handles output for the default column.
	 *
	 * @param \WC_Order $order       Current WooCommerce order object.
	 * @param string    $column_name Identifier for the custom column.
	 */
	public function column_default( $order, $column_name ) {
		/**
		 * Fires for each custom column for a specific order type. This hook takes precedence over the generic
		 * action `manage_{$this->screen->id}_custom_column`.
		 *
		 * @param string    $column_name Identifier for the custom column.
		 * @param \WC_Order $order       Current WooCommerce order object.
		 *
		 * @since 7.3.0
		 */
		do_action( 'woocommerce_' . $this->order_type . '_list_table_custom_column', $column_name, $order );

		/**
		 * Fires for each custom column in the Custom Order Table in the administrative screen.
		 *
		 * @param string    $column_name Identifier for the custom column.
		 * @param \WC_Order $order       Current WooCommerce order object.
		 *
		 * @since 7.0.0
		 */
		do_action( "manage_{$this->screen->id}_custom_column", $column_name, $order );
	}

	/**
	 * Sets up an items-per-page control.
	 */
	private function items_per_page(): void {
		add_screen_option(
			'per_page',
			array(
				'default' => 20,
				'option'  => 'edit_' . $this->order_type . '_per_page',
			)
		);
	}

	/**
	 * Saves the items-per-page setting.
	 *
	 * @param mixed  $default The default value.
	 * @param string $option  The option being configured.
	 * @param int    $value   The submitted option value.
	 *
	 * @return mixed
	 */
	public function set_items_per_page( $default, string $option, int $value ) {
		return 'edit_' . $this->order_type . '_per_page' === $option ? absint( $value ) : $default;
	}

	/**
	 * Render the table.
	 *
	 * @return void
	 */
	public function display() {
		$post_type = get_post_type_object( $this->order_type );

		$title         = esc_html( $post_type->labels->name );
		$add_new       = esc_html( $post_type->labels->add_new );
		$new_page_link = $this->page_controller->get_new_page_url( $this->order_type );
		$search_label  = '';

		if ( ! empty( $this->order_query_args['s'] ) ) {
			$search_label  = '<span class="subtitle">';
			$search_label .= sprintf(
				/* translators: %s: Search query. */
				__( 'Search results for: %s', 'woocommerce' ),
				'<strong>' . esc_html( $this->order_query_args['s'] ) . '</strong>'
			);
			$search_label .= '</span>';
		}

		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		echo wp_kses_post(
			"
			<div class='wrap'>
				<h1 class='wp-heading-inline'>{$title}</h1>
				<a href='" . esc_url( $new_page_link ) . "' class='page-title-action'>{$add_new}</a>
				{$search_label}
				<hr class='wp-header-end'>"
		);

		if ( $this->should_render_blank_state() ) {
			$this->render_blank_state();
			return;
		}

		$this->views();

		echo '<form id="wc-orders-filter" method="get" action="' . esc_url( get_admin_url( null, 'admin.php' ) ) . '">';
		$this->print_hidden_form_fields();
		$this->search_box( esc_html__( 'Search orders', 'woocommerce' ), 'orders-search-input' );

		parent::display();
		echo '</form> </div>';
	}

	/**
	 * Renders advice in the event that no orders exist yet.
	 *
	 * @return void
	 */
	public function render_blank_state(): void {
		?>
			<div class="woocommerce-BlankState">

				<h2 class="woocommerce-BlankState-message">
					<?php esc_html_e( 'When you receive a new order, it will appear here.', 'woocommerce' ); ?>
				</h2>

				<div class="woocommerce-BlankState-buttons">
					<a class="woocommerce-BlankState-cta button-primary button" target="_blank" href="https://docs.woocommerce.com/document/managing-orders/?utm_source=blankslate&utm_medium=product&utm_content=ordersdoc&utm_campaign=woocommerceplugin"><?php esc_html_e( 'Learn more about orders', 'woocommerce' ); ?></a>
				</div>

			<?php
			/**
			 * Renders after the 'blank state' message for the order list table has rendered.
			 *
			 * @since 6.6.1
			 */
			do_action( 'wc_marketplace_suggestions_orders_empty_state' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingSinceComment
			?>

			</div>
		<?php
	}

	/**
	 * Retrieves the list of bulk actions available for this table.
	 *
	 * @return array
	 */
	protected function get_bulk_actions() {
		$selected_status = $this->order_query_args['status'] ?? false;

		if ( array( 'trash' ) === $selected_status ) {
			$actions = array(
				'untrash' => __( 'Restore', 'woocommerce' ),
				'delete'  => __( 'Delete permanently', 'woocommerce' ),
			);
		} else {
			$actions = array(
				'mark_processing' => __( 'Change status to processing', 'woocommerce' ),
				'mark_on-hold'    => __( 'Change status to on-hold', 'woocommerce' ),
				'mark_completed'  => __( 'Change status to completed', 'woocommerce' ),
				'mark_cancelled'  => __( 'Change status to cancelled', 'woocommerce' ),
				'trash'           => __( 'Move to Trash', 'woocommerce' ),
			);
		}

		if ( wc_string_to_bool( get_option( 'woocommerce_allow_bulk_remove_personal_data', 'no' ) ) ) {
			$actions['remove_personal_data'] = __( 'Remove personal data', 'woocommerce' );
		}

		return $actions;
	}

	/**
	 * Gets a list of CSS classes for the WP_List_Table table tag.
	 *
	 * @since 7.8.0
	 *
	 * @return string[] Array of CSS classes for the table tag.
	 */
	protected function get_table_classes() {
		/**
		 * Filters the list of CSS class names for the orders list table.
		 *
		 * @since 7.8.0
		 *
		 * @param string[] $classes    An array of CSS class names.
		 * @param string   $order_type The order type.
		 */
		$css_classes = apply_filters(
			'woocommerce_' . $this->order_type . '_list_table_css_classes',
			array_merge(
				parent::get_table_classes(),
				array(
					'wc-orders-list-table',
					'wc-orders-list-table-' . $this->order_type,
				)
			),
			$this->order_type
		);

		return array_unique( array_map( 'trim', $css_classes ) );
	}

	/**
	 * Prepares the list of items for displaying.
	 */
	public function prepare_items() {
		$limit = $this->get_items_per_page( 'edit_' . $this->order_type . '_per_page' );

		$this->order_query_args = array(
			'limit'    => $limit,
			'page'     => $this->get_pagenum(),
			'paginate' => true,
			'type'     => $this->order_type,
		);

		foreach ( array( 'status', 's', 'm', '_customer_user' ) as $query_var ) {
			$this->request[ $query_var ] = sanitize_text_field( wp_unslash( $_REQUEST[ $query_var ] ?? '' ) );
		}

		/**
		 * Allows 3rd parties to filter the initial request vars before defaults and other logic is applied.
		 *
		 * @param array $request Request to be passed to `wc_get_orders()`.
		 *
		 * @since 7.3.0
		 */
		$this->request = apply_filters( 'woocommerce_' . $this->order_type . '_list_table_request', $this->request );

		$this->set_status_args();
		$this->set_order_args();
		$this->set_date_args();
		$this->set_customer_args();
		$this->set_search_args();

		/**
		 * Provides an opportunity to modify the query arguments used in the (Custom Order Table-powered) order list
		 * table.
		 *
		 * @since 6.9.0
		 *
		 * @param array $query_args Arguments to be passed to `wc_get_orders()`.
		 */
		$order_query_args = (array) apply_filters( 'woocommerce_order_list_table_prepare_items_query_args', $this->order_query_args );

		/**
		 * Same as `woocommerce_order_list_table_prepare_items_query_args` but for a specific order type.
		 *
		 * @param array $query_args Arguments to be passed to `wc_get_orders()`.
		 *
		 * @since 7.3.0
		 */
		$order_query_args = apply_filters( 'woocommerce_' . $this->order_type . '_list_table_prepare_items_query_args', $order_query_args );

		// We must ensure the 'paginate' argument is set.
		$order_query_args['paginate'] = true;

		$orders      = wc_get_orders( $order_query_args );
		$this->items = $orders->orders;

		$max_num_pages = $orders->max_num_pages;

		// Check in case the user has attempted to page beyond the available range of orders.
		if ( 0 === $max_num_pages && $this->order_query_args['page'] > 1 ) {
			$count_query_args          = $order_query_args;
			$count_query_args['page']  = 1;
			$count_query_args['limit'] = 1;
			$order_count               = wc_get_orders( $count_query_args );
			$max_num_pages             = (int) ceil( $order_count->total / $order_query_args['limit'] );
		}

		$this->set_pagination_args(
			array(
				'total_items' => $orders->total ?? 0,
				'per_page'    => $limit,
				'total_pages' => $max_num_pages,
			)
		);

		// Are we inside the trash?
		$this->is_trash = 'trash' === $this->request['status'];
	}

	/**
	 * Updates the WC Order Query arguments as needed to support orderable columns.
	 */
	private function set_order_args() {
		$sortable  = $this->get_sortable_columns();
		$field     = sanitize_text_field( wp_unslash( $_GET['orderby'] ?? '' ) );
		$direction = strtoupper( sanitize_text_field( wp_unslash( $_GET['order'] ?? '' ) ) );

		if ( ! in_array( $field, $sortable, true ) ) {
			$this->order_query_args['orderby'] = 'date';
			$this->order_query_args['order']   = 'DESC';
			return;
		}

		$this->order_query_args['orderby'] = $field;
		$this->order_query_args['order']   = in_array( $direction, array( 'ASC', 'DESC' ), true ) ? $direction : 'ASC';
	}

	/**
	 * Implements date (month-based) filtering.
	 */
	private function set_date_args() {
		$year_month = sanitize_text_field( wp_unslash( $_GET['m'] ?? '' ) );

		if ( empty( $year_month ) || ! preg_match( '/^[0-9]{6}$/', $year_month ) ) {
			return;
		}

		$year  = (int) substr( $year_month, 0, 4 );
		$month = (int) substr( $year_month, 4, 2 );

		if ( $month < 0 || $month > 12 ) {
			return;
		}

		$last_day_of_month                      = date_create( "$year-$month" )->format( 'Y-m-t' );
		$this->order_query_args['date_created'] = "$year-$month-01..." . $last_day_of_month;
		$this->has_filter                       = true;
	}

	/**
	 * Implements filtering of orders by customer.
	 */
	private function set_customer_args() {
		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		$customer = (int) wp_unslash( $_GET['_customer_user'] ?? '' );

		if ( $customer < 1 ) {
			return;
		}

		$this->order_query_args['customer'] = $customer;
		$this->has_filter                   = true;
	}

	/**
	 * Implements filtering of orders by status.
	 */
	private function set_status_args() {
		$status = array_filter( array_map( 'trim', (array) $this->request['status'] ) );

		if ( empty( $status ) || in_array( 'all', $status, true ) ) {
			/**
			 * Allows 3rd parties to set the default list of statuses for a given order type.
			 *
			 * @param string[] $statuses Statuses.
			 *
			 * @since 7.3.0
			 */
			$status = apply_filters(
				'woocommerce_' . $this->order_type . '_list_table_default_statuses',
				array_intersect(
					array_keys( wc_get_order_statuses() ),
					get_post_stati( array( 'show_in_admin_all_list' => true ), 'names' )
				)
			);
		} else {
			$this->has_filter = true;
		}

		$this->order_query_args['status'] = $status;
	}

	/**
	 * Implements order search.
	 */
	private function set_search_args(): void {
		$search_term = trim( sanitize_text_field( $this->request['s'] ) );

		if ( ! empty( $search_term ) ) {
			$this->order_query_args['s'] = $search_term;
			$this->has_filter            = true;
		}
	}

	/**
	 * Get the list of views for this table (all orders, completed orders, etc, each with a count of the number of
	 * corresponding orders).
	 *
	 * @return array
	 */
	public function get_views() {
		$view_counts = array();
		$view_links  = array();
		$statuses    = $this->get_visible_statuses();
		$current     = ! empty( $this->request['status'] ) ? sanitize_text_field( $this->request['status'] ) : 'all';
		$all_count   = 0;

		foreach ( array_keys( $statuses ) as $slug ) {
			$total_in_status = $this->count_orders_by_status( $slug );

			if ( $total_in_status > 0 ) {
				$view_counts[ $slug ] = $total_in_status;
			}

			if ( ( get_post_status_object( $slug ) )->show_in_admin_all_list && 'auto-draft' !== $slug ) {
				$all_count += $total_in_status;
			}
		}

		$view_links['all'] = $this->get_view_link( 'all', __( 'All', 'woocommerce' ), $all_count, '' === $current || 'all' === $current );

		foreach ( $view_counts as $slug => $count ) {
			$view_links[ $slug ] = $this->get_view_link( $slug, $statuses[ $slug ], $count, $slug === $current );
		}

		return $view_links;
	}

	/**
	 * Count orders by status.
	 *
	 * @param string|string[] $status The order status we are interested in.
	 *
	 * @return int
	 */
	private function count_orders_by_status( $status ): int {
		global $wpdb;

		// Compute all counts and cache if necessary.
		if ( is_null( $this->status_count_cache ) ) {
			$orders_table = OrdersTableDataStore::get_orders_table_name();

			$res = $wpdb->get_results(
				$wpdb->prepare(
					"SELECT status, COUNT(*) AS cnt FROM {$orders_table} WHERE type = %s GROUP BY status", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
					$this->order_type
				),
				ARRAY_A
			);

			$this->status_count_cache =
				$res
				? array_combine( array_column( $res, 'status' ), array_map( 'absint', array_column( $res, 'cnt' ) ) )
				: array();
		}

		$status = (array) $status;
		$count  = array_sum( array_intersect_key( $this->status_count_cache, array_flip( $status ) ) );

		/**
		 * Allows 3rd parties to modify the count of orders by status.
		 *
		 * @param int      $count  Number of orders for the given status.
		 * @param string[] $status List of order statuses in the count.
		 * @since 7.3.0
		 */
		return apply_filters(
			'woocommerce_' . $this->order_type . '_list_table_order_count',
			$count,
			$status
		);
	}

	/**
	 * Checks whether the blank state should be rendered or not. This depends on whether there are others with a visible
	 * status.
	 *
	 * @return boolean TRUE when the blank state should be rendered, FALSE otherwise.
	 */
	private function should_render_blank_state(): bool {
		return ( ! $this->has_filter ) && 0 === $this->count_orders_by_status( array_keys( $this->get_visible_statuses() ) );
	}

	/**
	 * Returns a list of slug and labels for order statuses that should be visible in the status list.
	 *
	 * @return array slug => label array of order statuses.
	 */
	private function get_visible_statuses(): array {
		return array_intersect_key(
			array_merge(
				wc_get_order_statuses(),
				array(
					'trash'      => ( get_post_status_object( 'trash' ) )->label,
					'draft'      => ( get_post_status_object( 'draft' ) )->label,
					'auto-draft' => ( get_post_status_object( 'auto-draft' ) )->label,
				)
			),
			array_flip( get_post_stati( array( 'show_in_admin_status_list' => true ) ) )
		);
	}

	/**
	 * Form a link to use in the list of table views.
	 *
	 * @param string $slug    Slug used to identify the view (usually the order status slug).
	 * @param string $name    Human-readable name of the view (usually the order status label).
	 * @param int    $count   Number of items in this view.
	 * @param bool   $current If this is the current view.
	 *
	 * @return string
	 */
	private function get_view_link( string $slug, string $name, int $count, bool $current ): string {
		$base_url = get_admin_url( null, 'admin.php?page=wc-orders' . ( 'shop_order' === $this->order_type ? '' : '--' . $this->order_type ) );
		$url      = esc_url( add_query_arg( 'status', $slug, $base_url ) );
		$name     = esc_html( $name );
		$count    = absint( $count );
		$class    = $current ? 'class="current"' : '';

		return "<a href='$url' $class>$name <span class='count'>($count)</span></a>";
	}

	/**
	 * Extra controls to be displayed between bulk actions and pagination.
	 *
	 * @param string $which Either 'top' or 'bottom'.
	 */
	protected function extra_tablenav( $which ) {
		echo '<div class="alignleft actions">';

		if ( 'top' === $which ) {
			ob_start();

			$this->months_filter();
			$this->customers_filter();

			/**
			 * Fires before the "Filter" button on the list table for orders and other order types.
			 *
			 * @since 7.3.0
			 *
			 * @param string $order_type  The order type.
			 * @param string $which       The location of the extra table nav: 'top' or 'bottom'.
			 */
			do_action( 'woocommerce_order_list_table_restrict_manage_orders', $this->order_type, $which );

			$output = ob_get_clean();

			if ( ! empty( $output ) ) {
				echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
				submit_button( __( 'Filter', 'woocommerce' ), '', 'filter_action', false, array( 'id' => 'order-query-submit' ) );
			}
		}

		if ( $this->is_trash && $this->has_items() && current_user_can( 'edit_others_shop_orders' ) ) {
			submit_button( __( 'Empty Trash', 'woocommerce' ), 'apply', 'delete_all', false );
		}

		/**
		 * Fires immediately following the closing "actions" div in the tablenav for the order
		 * list table.
		 *
		 * @since 7.3.0
		 *
		 * @param string $order_type  The order type.
		 * @param string $which       The location of the extra table nav: 'top' or 'bottom'.
		 */
		do_action( 'woocommerce_order_list_table_extra_tablenav', $this->order_type, $which );

		echo '</div>';
	}

	/**
	 * Render the months filter dropdown.
	 *
	 * @return void
	 */
	private function months_filter() {
		// XXX: [review] we may prefer to move this logic outside of the ListTable class.

		global $wp_locale;
		global $wpdb;

		$orders_table = esc_sql( OrdersTableDataStore::get_orders_table_name() );
		$utc_offset = wc_timezone_offset();

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$order_dates = $wpdb->get_results(
			"
				SELECT DISTINCT YEAR( t.date_created_local ) AS year,
								MONTH( t.date_created_local ) AS month
				FROM ( SELECT DATE_ADD( date_created_gmt, INTERVAL $utc_offset SECOND ) AS date_created_local FROM $orders_table WHERE status != 'trash' ) t
				ORDER BY year DESC, month DESC
			"
		);

		$m = isset( $_GET['m'] ) ? (int) $_GET['m'] : 0;
		echo '<select name="m" id="filter-by-date">';
		echo '<option ' . selected( $m, 0, false ) . ' value="0">' . esc_html__( 'All dates', 'woocommerce' ) . '</option>';

		foreach ( $order_dates as $date ) {
			$month           = zeroise( $date->month, 2 );
			$month_year_text = sprintf(
				/* translators: 1: Month name, 2: 4-digit year. */
				esc_html_x( '%1$s %2$d', 'order dates dropdown', 'woocommerce' ),
				$wp_locale->get_month( $month ),
				$date->year
			);

			printf(
				'<option %1$s value="%2$s">%3$s</option>\n',
				selected( $m, $date->year . $month, false ),
				esc_attr( $date->year . $month ),
				esc_html( $month_year_text )
			);
		}

		echo '</select>';
	}

	/**
	 * Render the customer filter dropdown.
	 *
	 * @return void
	 */
	public function customers_filter() {
		$user_string = '';
		$user_id     = '';

		// phpcs:disable WordPress.Security.NonceVerification.Recommended
		if ( ! empty( $_GET['_customer_user'] ) ) {
			$user_id = absint( $_GET['_customer_user'] );
			$user    = get_user_by( 'id', $user_id );

			$user_string = sprintf(
				/* translators: 1: user display name 2: user ID 3: user email */
				esc_html__( '%1$s (#%2$s &ndash; %3$s)', 'woocommerce' ),
				$user->display_name,
				absint( $user->ID ),
				$user->user_email
			);
		}

		// Note: use of htmlspecialchars (below) is to prevent XSS when rendered by selectWoo.
		?>
		<select class="wc-customer-search" name="_customer_user" data-placeholder="<?php esc_attr_e( 'Filter by registered customer', 'woocommerce' ); ?>" data-allow_clear="true">
			<option value="<?php echo esc_attr( $user_id ); ?>" selected="selected"><?php echo htmlspecialchars( wp_kses_post( $user_string ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></option>
		</select>
		<?php
	}

	/**
	 * Get list columns.
	 *
	 * @return array
	 */
	public function get_columns() {
		/**
		 * Filters the list of columns.
		 *
		 * @param array $columns List of sortable columns.
		 *
		 * @since 7.3.0
		 */
		return apply_filters(
			'woocommerce_' . $this->order_type . '_list_table_columns',
			array(
				'cb'               => '<input type="checkbox" />',
				'order_number'     => esc_html__( 'Order', 'woocommerce' ),
				'order_date'       => esc_html__( 'Date', 'woocommerce' ),
				'order_status'     => esc_html__( 'Status', 'woocommerce' ),
				'billing_address'  => esc_html__( 'Billing', 'woocommerce' ),
				'shipping_address' => esc_html__( 'Ship to', 'woocommerce' ),
				'order_total'      => esc_html__( 'Total', 'woocommerce' ),
				'wc_actions'       => esc_html__( 'Actions', 'woocommerce' ),
			)
		);
	}

	/**
	 * Defines the default sortable columns.
	 *
	 * @return string[]
	 */
	public function get_sortable_columns() {
		/**
		 * Filters the list of sortable columns.
		 *
		 * @param array $sortable_columns List of sortable columns.
		 *
		 * @since 7.3.0
		 */
		return apply_filters(
			'woocommerce_' . $this->order_type . '_list_table_sortable_columns',
			array(
				'order_number' => 'ID',
				'order_date'   => 'date',
				'order_total'  => 'order_total',
			)
		);
	}

	/**
	 * Specify the columns we wish to hide by default.
	 *
	 * @param array     $hidden Columns set to be hidden.
	 * @param WP_Screen $screen Screen object.
	 *
	 * @return array
	 */
	public function default_hidden_columns( array $hidden, WP_Screen $screen ) {
		if ( isset( $screen->id ) && wc_get_page_screen_id( 'shop-order' ) === $screen->id ) {
			$hidden = array_merge(
				$hidden,
				array(
					'billing_address',
					'shipping_address',
					'wc_actions',
				)
			);
		}

		return $hidden;
	}

	/**
	 * Checklist column, used for selecting items for processing by a bulk action.
	 *
	 * @param WC_Order $item The order object for the current row.
	 *
	 * @return string
	 */
	public function column_cb( $item ) {
		ob_start();
		?>
		<input id="cb-select-<?php echo esc_attr( $item->get_id() ); ?>" type="checkbox" name="id[]" value="<?php echo esc_attr( $item->get_id() ); ?>" />

		<div class="locked-indicator">
			<span class="locked-indicator-icon" aria-hidden="true"></span>
			<span class="screen-reader-text">
				<?php
				// translators: %s is an order ID.
				echo esc_html( sprintf( __( 'Order %s is locked.', 'woocommerce' ), $item->get_id() ) );
				?>
			</span>
		</div>
		<?php
		return ob_get_clean();
	}

	/**
	 * Renders the order number, customer name and provides a preview link.
	 *
	 * @param WC_Order $order The order object for the current row.
	 *
	 * @return void
	 */
	public function render_order_number_column( WC_Order $order ): void {
		$buyer = '';

		if ( $order->get_billing_first_name() || $order->get_billing_last_name() ) {
			/* translators: 1: first name 2: last name */
			$buyer = trim( sprintf( _x( '%1$s %2$s', 'full name', 'woocommerce' ), $order->get_billing_first_name(), $order->get_billing_last_name() ) );
		} elseif ( $order->get_billing_company() ) {
			$buyer = trim( $order->get_billing_company() );
		} elseif ( $order->get_customer_id() ) {
			$user  = get_user_by( 'id', $order->get_customer_id() );
			$buyer = ucwords( $user->display_name );
		}

		/**
		 * Filter buyer name in list table orders.
		 *
		 * @since 3.7.0
		 *
		 * @param string   $buyer Buyer name.
		 * @param WC_Order $order Order data.
		 */
		$buyer = apply_filters( 'woocommerce_admin_order_buyer_name', $buyer, $order );

		if ( $order->get_status() === 'trash' ) {
			echo '<strong>#' . esc_attr( $order->get_order_number() ) . ' ' . esc_html( $buyer ) . '</strong>';
		} else {
			echo '<a href="#" class="order-preview" data-order-id="' . absint( $order->get_id() ) . '" title="' . esc_attr( __( 'Preview', 'woocommerce' ) ) . '">' . esc_html( __( 'Preview', 'woocommerce' ) ) . '</a>';
			echo '<a href="' . esc_url( $this->get_order_edit_link( $order ) ) . '" class="order-view"><strong>#' . esc_attr( $order->get_order_number() ) . ' ' . esc_html( $buyer ) . '</strong></a>';
		}
	}

	/**
	 * Get the edit link for an order.
	 *
	 * @param WC_Order $order Order object.
	 *
	 * @return string Edit link for the order.
	 */
	private function get_order_edit_link( WC_Order $order ) : string {
		return $this->page_controller->get_edit_url( $order->get_id() );
	}

	/**
	 * Renders the order date.
	 *
	 * @param WC_Order $order The order object for the current row.
	 *
	 * @return void
	 */
	public function render_order_date_column( WC_Order $order ): void {
		$order_timestamp = $order->get_date_created() ? $order->get_date_created()->getTimestamp() : '';

		if ( ! $order_timestamp ) {
			echo '&ndash;';
			return;
		}

		// Check if the order was created within the last 24 hours, and not in the future.
		if ( $order_timestamp > strtotime( '-1 day', time() ) && $order_timestamp <= time() ) {
			$show_date = sprintf(
			/* translators: %s: human-readable time difference */
				_x( '%s ago', '%s = human-readable time difference', 'woocommerce' ),
				human_time_diff( $order->get_date_created()->getTimestamp(), time() )
			);
		} else {
			$show_date = $order->get_date_created()->date_i18n( apply_filters( 'woocommerce_admin_order_date_format', __( 'M j, Y', 'woocommerce' ) ) ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
		}
		printf(
			'<time datetime="%1$s" title="%2$s">%3$s</time>',
			esc_attr( $order->get_date_created()->date( 'c' ) ),
			esc_html( $order->get_date_created()->date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ) ),
			esc_html( $show_date )
		);
	}

	/**
	 * Renders the order status.
	 *
	 * @param WC_Order $order The order object for the current row.
	 *
	 * @return void
	 */
	public function render_order_status_column( WC_Order $order ): void {
		$tooltip = '';
		remove_filter( 'comments_clauses', array( 'WC_Comments', 'exclude_order_comments' ), 10, 1 );
		$comment_count = get_comment_count( $order->get_id() );
		add_filter( 'comments_clauses', array( 'WC_Comments', 'exclude_order_comments' ), 10, 1 );
		$approved_comments_count = absint( $comment_count['approved'] );

		if ( $approved_comments_count ) {
			$latest_notes = wc_get_order_notes(
				array(
					'order_id' => $order->get_id(),
					'limit'    => 1,
					'orderby'  => 'date_created_gmt',
				)
			);

			$latest_note = current( $latest_notes );

			if ( isset( $latest_note->content ) && 1 === $approved_comments_count ) {
				$tooltip = wc_sanitize_tooltip( $latest_note->content );
			} elseif ( isset( $latest_note->content ) ) {
				/* translators: %d: notes count */
				$tooltip = wc_sanitize_tooltip( $latest_note->content . '<br/><small style="display:block">' . sprintf( _n( 'Plus %d other note', 'Plus %d other notes', ( $approved_comments_count - 1 ), 'woocommerce' ), $approved_comments_count - 1 ) . '</small>' );
			} else {
				/* translators: %d: notes count */
				$tooltip = wc_sanitize_tooltip( sprintf( _n( '%d note', '%d notes', $approved_comments_count, 'woocommerce' ), $approved_comments_count ) );
			}
		}

		// Gracefully handle legacy statuses.
		if ( in_array( $order->get_status(), array( 'trash', 'draft', 'auto-draft' ), true ) ) {
			$status_name = ( get_post_status_object( $order->get_status() ) )->label;
		} else {
			$status_name = wc_get_order_status_name( $order->get_status() );
		}

		if ( $tooltip ) {
			printf( '<mark class="order-status %s tips" data-tip="%s"><span>%s</span></mark>', esc_attr( sanitize_html_class( 'status-' . $order->get_status() ) ), wp_kses_post( $tooltip ), esc_html( $status_name ) );
		} else {
			printf( '<mark class="order-status %s"><span>%s</span></mark>', esc_attr( sanitize_html_class( 'status-' . $order->get_status() ) ), esc_html( $status_name ) );
		}
	}

	/**
	 * Renders order billing information.
	 *
	 * @param WC_Order $order The order object for the current row.
	 *
	 * @return void
	 */
	public function render_billing_address_column( WC_Order $order ): void {
		$address = $order->get_formatted_billing_address();

		if ( $address ) {
			echo esc_html( preg_replace( '#<br\s*/?>#i', ', ', $address ) );

			if ( $order->get_payment_method() ) {
				/* translators: %s: payment method */
				echo '<span class="description">' . sprintf( esc_html__( 'via %s', 'woocommerce' ), esc_html( $order->get_payment_method_title() ) ) . '</span>';
			}
		} else {
			echo '&ndash;';
		}
	}

	/**
	 * Renders order shipping information.
	 *
	 * @param WC_Order $order The order object for the current row.
	 *
	 * @return void
	 */
	public function render_shipping_address_column( WC_Order $order ): void {
		$address = $order->get_formatted_shipping_address();

		if ( $address ) {
			echo '<a target="_blank" href="' . esc_url( $order->get_shipping_address_map_url() ) . '">' . esc_html( preg_replace( '#<br\s*/?>#i', ', ', $address ) ) . '</a>';
			if ( $order->get_shipping_method() ) {
				/* translators: %s: shipping method */
				echo '<span class="description">' . sprintf( esc_html__( 'via %s', 'woocommerce' ), esc_html( $order->get_shipping_method() ) ) . '</span>';
			}
		} else {
			echo '&ndash;';
		}
	}

	/**
	 * Renders the order total.
	 *
	 * @param WC_Order $order The order object for the current row.
	 *
	 * @return void
	 */
	public function render_order_total_column( WC_Order $order ): void {
		if ( $order->get_payment_method_title() ) {
			/* translators: %s: method */
			echo '<span class="tips" data-tip="' . esc_attr( sprintf( __( 'via %s', 'woocommerce' ), $order->get_payment_method_title() ) ) . '">' . wp_kses_post( $order->get_formatted_order_total() ) . '</span>';
		} else {
			echo wp_kses_post( $order->get_formatted_order_total() );
		}
	}

	/**
	 * Renders order actions.
	 *
	 * @param WC_Order $order The order object for the current row.
	 *
	 * @return void
	 */
	public function render_wc_actions_column( WC_Order $order ): void {
		echo '<p>';

		/**
		 * Fires before the order action buttons (within the actions column for the order list table)
		 * are registered.
		 *
		 * @param WC_Order $order Current order object.
		 * @since 6.7.0
		 */
		do_action( 'woocommerce_admin_order_actions_start', $order );

		$actions = array();

		if ( $order->has_status( array( 'pending', 'on-hold' ) ) ) {
			$actions['processing'] = array(
				'url'    => wp_nonce_url( admin_url( 'admin-ajax.php?action=woocommerce_mark_order_status&status=processing&order_id=' . $order->get_id() ), 'woocommerce-mark-order-status' ),
				'name'   => __( 'Processing', 'woocommerce' ),
				'action' => 'processing',
			);
		}

		if ( $order->has_status( array( 'pending', 'on-hold', 'processing' ) ) ) {
			$actions['complete'] = array(
				'url'    => wp_nonce_url( admin_url( 'admin-ajax.php?action=woocommerce_mark_order_status&status=completed&order_id=' . $order->get_id() ), 'woocommerce-mark-order-status' ),
				'name'   => __( 'Complete', 'woocommerce' ),
				'action' => 'complete',
			);
		}

		/**
		 * Provides an opportunity to modify the action buttons within the order list table.
		 *
		 * @param array    $action Order actions.
		 * @param WC_Order $order  Current order object.
		 * @since 6.7.0
		 */
		$actions = apply_filters( 'woocommerce_admin_order_actions', $actions, $order );

		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		echo wc_render_action_buttons( $actions );

		/**
		 * Fires after the order action buttons (within the actions column for the order list table)
		 * are rendered.
		 *
		 * @param WC_Order $order Current order object.
		 * @since 6.7.0
		 */
		do_action( 'woocommerce_admin_order_actions_end', $order );

		echo '</p>';
	}

	/**
	 * Outputs hidden fields used to retain state when filtering.
	 *
	 * @return void
	 */
	private function print_hidden_form_fields(): void {
		echo '<input type="hidden" name="page" value="wc-orders' . ( 'shop_order' === $this->order_type ? '' : '--' . $this->order_type ) . '" >'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped

		$state_params = array(
			'paged',
			'status',
		);

		foreach ( $state_params as $param ) {
			if ( ! isset( $_GET[ $param ] ) ) {
				continue;
			}

			echo '<input type="hidden" name="' . esc_attr( $param ) . '" value="' . esc_attr( sanitize_text_field( wp_unslash( $_GET[ $param ] ) ) ) . '" >';
		}
	}

	/**
	 * Gets the current action selected from the bulk actions dropdown.
	 *
	 * @return string|false The action name. False if no action was selected.
	 */
	public function current_action() {
		if ( ! empty( $_REQUEST['delete_all'] ) ) {
			return 'delete_all';
		}

		return parent::current_action();
	}

	/**
	 * Handle bulk actions.
	 */
	public function handle_bulk_actions() {
		$action = $this->current_action();

		if ( ! $action ) {
			return;
		}

		check_admin_referer( 'bulk-orders' );

		$redirect_to = remove_query_arg( array( 'deleted', 'ids' ), wp_get_referer() );
		$redirect_to = add_query_arg( 'paged', $this->get_pagenum(), $redirect_to );

		if ( 'delete_all' === $action ) {
			// Get all trashed orders.
			$ids = wc_get_orders(
				array(
					'type'   => $this->order_type,
					'status' => 'trash',
					'limit'  => -1,
					'return' => 'ids',
				)
			);

			$action = 'delete';
		} else {
			$ids = isset( $_REQUEST['id'] ) ? array_reverse( array_map( 'absint', (array) $_REQUEST['id'] ) ) : array();
		}

		/**
		 * Allows 3rd parties to modify order IDs about to be affected by a bulk action.
		 *
		 * @param array Array of order IDs.
		 */
		$ids = apply_filters( // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingSinceComment
			'woocommerce_bulk_action_ids',
			$ids,
			$action,
			'order'
		);

		if ( ! $ids ) {
			wp_safe_redirect( $redirect_to );
			exit;
		}

		$report_action  = '';
		$changed        = 0;
		$action_handled = true;

		if ( 'remove_personal_data' === $action ) {
			$report_action = 'removed_personal_data';
			$changed       = $this->do_bulk_action_remove_personal_data( $ids );
		} elseif ( 'trash' === $action ) {
			$changed       = $this->do_delete( $ids );
			$report_action = 'trashed';
		} elseif ( 'delete' === $action ) {
			$changed       = $this->do_delete( $ids, true );
			$report_action = 'deleted';
		} elseif ( 'untrash' === $action ) {
			$changed       = $this->do_untrash( $ids );
			$report_action = 'untrashed';
		} elseif ( false !== strpos( $action, 'mark_' ) ) {
			$order_statuses = wc_get_order_statuses();
			$new_status     = substr( $action, 5 );
			$report_action  = 'marked_' . $new_status;

			if ( isset( $order_statuses[ 'wc-' . $new_status ] ) ) {
				$changed = $this->do_bulk_action_mark_orders( $ids, $new_status );
			} else {
				$action_handled = false;
			}
		} else {
			$action_handled = false;
		}

		// Custom action.
		if ( ! $action_handled ) {
			$screen = get_current_screen()->id;

			/**
			 * This action is documented in /wp-admin/edit.php (it is a core WordPress hook).
			 *
			 * @since 7.2.0
			 *
			 * @param string $redirect_to The URL to redirect to after processing the bulk actions.
			 * @param string $action      The current bulk action.
			 * @param int[]  $ids         IDs for the orders to be processed.
			 */
			$custom_sendback = apply_filters( "handle_bulk_actions-{$screen}", $redirect_to, $action, $ids ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
		}

		if ( ! empty( $custom_sendback ) ) {
			$redirect_to = $custom_sendback;
		} elseif ( $changed ) {
			$redirect_to = add_query_arg(
				array(
					'bulk_action' => $report_action,
					'changed'     => $changed,
					'ids'         => implode( ',', $ids ),
				),
				$redirect_to
			);
		}

		wp_safe_redirect( $redirect_to );
		exit;
	}

	/**
	 * Implements the "remove personal data" bulk action.
	 *
	 * @param array $order_ids The Order IDs.
	 * @return int Number of orders modified.
	 */
	private function do_bulk_action_remove_personal_data( $order_ids ): int {
		$changed = 0;

		foreach ( $order_ids as $id ) {
			$order = wc_get_order( $id );

			if ( ! $order ) {
				continue;
			}

			do_action( 'woocommerce_remove_order_personal_data', $order ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
			$changed++;
		}

		return $changed;
	}

	/**
	 * Implements the "mark <status>" bulk action.
	 *
	 * @param array  $order_ids  The order IDs to change.
	 * @param string $new_status The new order status.
	 * @return int Number of orders modified.
	 */
	private function do_bulk_action_mark_orders( $order_ids, $new_status ): int {
		$changed = 0;

		// Initialize payment gateways in case order has hooked status transition actions.
		WC()->payment_gateways();

		foreach ( $order_ids as $id ) {
			$order = wc_get_order( $id );

			if ( ! $order ) {
				continue;
			}

			$order->update_status( $new_status, __( 'Order status changed by bulk edit.', 'woocommerce' ), true );
			do_action( 'woocommerce_order_edit_status', $id, $new_status ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
			$changed++;
		}

		return $changed;
	}

	/**
	 * Handles bulk trashing of orders.
	 *
	 * @param int[] $ids Order IDs to be trashed.
	 * @param bool  $force_delete When set, the order will be completed deleted. Otherwise, it will be trashed.
	 *
	 * @return int Number of orders that were trashed.
	 */
	private function do_delete( array $ids, bool $force_delete = false ): int {
		$changed      = 0;

		foreach ( $ids as $id ) {
			$order = wc_get_order( $id );
			$order->delete( $force_delete );
			$updated_order = wc_get_order( $id );

			if ( ( $force_delete && false === $updated_order ) || ( ! $force_delete && $updated_order->get_status() === 'trash' ) ) {
				$changed++;
			}
		}

		return $changed;
	}

	/**
	 * Handles bulk restoration of trashed orders.
	 *
	 * @param array $ids Order IDs to be restored to their previous status.
	 *
	 * @return int Number of orders that were restored from the trash.
	 */
	private function do_untrash( array $ids ): int {
		$orders_store = wc_get_container()->get( OrdersTableDataStore::class );
		$changed      = 0;

		foreach ( $ids as $id ) {
			if ( $orders_store->untrash_order( wc_get_order( $id ) ) ) {
				$changed++;
			}
		}

		return $changed;
	}

	/**
	 * Show confirmation message that order status changed for number of orders.
	 */
	public function bulk_action_notices() {
		if ( empty( $_REQUEST['bulk_action'] ) ) {
			return;
		}

		$order_statuses = wc_get_order_statuses();
		$number         = absint( $_REQUEST['changed'] ?? 0 );
		$bulk_action    = wc_clean( wp_unslash( $_REQUEST['bulk_action'] ) );
		$message        = '';

		// Check if any status changes happened.
		foreach ( $order_statuses as $slug => $name ) {
			if ( 'marked_' . str_replace( 'wc-', '', $slug ) === $bulk_action ) { // WPCS: input var ok, CSRF ok.
				/* translators: %s: orders count */
				$message = sprintf( _n( '%s order status changed.', '%s order statuses changed.', $number, 'woocommerce' ), number_format_i18n( $number ) );
				break;
			}
		}

		switch ( $bulk_action ) {
			case 'removed_personal_data':
				/* translators: %s: orders count */
				$message = sprintf( _n( 'Removed personal data from %s order.', 'Removed personal data from %s orders.', $number, 'woocommerce' ), number_format_i18n( $number ) );
				echo '<div class="updated"><p>' . esc_html( $message ) . '</p></div>';
				break;

			case 'trashed':
				/* translators: %s: orders count */
				$message = sprintf( _n( '%s order moved to the Trash.', '%s orders moved to the Trash.', $number, 'woocommerce' ), number_format_i18n( $number ) );
				break;

			case 'untrashed':
				/* translators: %s: orders count */
				$message = sprintf( _n( '%s order restored from the Trash.', '%s orders restored from the Trash.', $number, 'woocommerce' ), number_format_i18n( $number ) );
				break;

			case 'deleted':
				/* translators: %s: orders count */
				$message = sprintf( _n( '%s order permanently deleted.', '%s orders permanently deleted.', $number, 'woocommerce' ), number_format_i18n( $number ) );
				break;
		}

		if ( ! empty( $message ) ) {
			echo '<div class="updated"><p>' . esc_html( $message ) . '</p></div>';
		}
	}

	/**
	 * Enqueue list table scripts.
	 *
	 * @return void
	 */
	public function enqueue_scripts(): void {
		echo $this->get_order_preview_template(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		wp_enqueue_script( 'wc-orders' );
	}

	/**
	 * Returns the HTML for the order preview template.
	 *
	 * @return string HTML template.
	 */
	public function get_order_preview_template(): string {
		$order_edit_url_placeholder =
			wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled()
			? esc_url( admin_url( 'admin.php?page=wc-orders&action=edit' ) ) . '&id={{ data.data.id }}'
			: esc_url( admin_url( 'post.php?action=edit' ) ) . '&post={{ data.data.id }}';

		ob_start();
		?>
		<script type="text/template" id="tmpl-wc-modal-view-order">
			<div class="wc-backbone-modal wc-order-preview">
				<div class="wc-backbone-modal-content">
					<section class="wc-backbone-modal-main" role="main">
						<header class="wc-backbone-modal-header">
							<mark class="order-status status-{{ data.status }}"><span>{{ data.status_name }}</span></mark>
							<?php /* translators: %s: order ID */ ?>
							<h1><?php echo esc_html( sprintf( __( 'Order #%s', 'woocommerce' ), '{{ data.order_number }}' ) ); ?></h1>
							<button class="modal-close modal-close-link dashicons dashicons-no-alt">
								<span class="screen-reader-text"><?php esc_html_e( 'Close modal panel', 'woocommerce' ); ?></span>
							</button>
						</header>
						<article>
							<?php do_action( 'woocommerce_admin_order_preview_start' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment ?>

							<div class="wc-order-preview-addresses">
								<div class="wc-order-preview-address">
									<h2><?php esc_html_e( 'Billing details', 'woocommerce' ); ?></h2>
									{{{ data.formatted_billing_address }}}

									<# if ( data.data.billing.email ) { #>
										<strong><?php esc_html_e( 'Email', 'woocommerce' ); ?></strong>
										<a href="mailto:{{ data.data.billing.email }}">{{ data.data.billing.email }}</a>
									<# } #>

									<# if ( data.data.billing.phone ) { #>
										<strong><?php esc_html_e( 'Phone', 'woocommerce' ); ?></strong>
										<a href="tel:{{ data.data.billing.phone }}">{{ data.data.billing.phone }}</a>
									<# } #>

									<# if ( data.payment_via ) { #>
										<strong><?php esc_html_e( 'Payment via', 'woocommerce' ); ?></strong>
										{{{ data.payment_via }}}
									<# } #>
								</div>
								<# if ( data.needs_shipping ) { #>
									<div class="wc-order-preview-address">
										<h2><?php esc_html_e( 'Shipping details', 'woocommerce' ); ?></h2>
										<# if ( data.ship_to_billing ) { #>
											{{{ data.formatted_billing_address }}}
										<# } else { #>
											<a href="{{ data.shipping_address_map_url }}" target="_blank">{{{ data.formatted_shipping_address }}}</a>
										<# } #>

										<# if ( data.shipping_via ) { #>
											<strong><?php esc_html_e( 'Shipping method', 'woocommerce' ); ?></strong>
											{{ data.shipping_via }}
										<# } #>
									</div>
								<# } #>

								<# if ( data.data.customer_note ) { #>
									<div class="wc-order-preview-note">
										<strong><?php esc_html_e( 'Note', 'woocommerce' ); ?></strong>
										{{ data.data.customer_note }}
									</div>
								<# } #>
							</div>

							{{{ data.item_html }}}

							<?php do_action( 'woocommerce_admin_order_preview_end' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment ?>
						</article>
						<footer>
							<div class="inner">
								{{{ data.actions_html }}}

								<a class="button button-primary button-large" aria-label="<?php esc_attr_e( 'Edit this order', 'woocommerce' ); ?>" href="<?php echo $order_edit_url_placeholder; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>"><?php esc_html_e( 'Edit', 'woocommerce' ); ?></a>
							</div>
						</footer>
					</section>
				</div>
			</div>
			<div class="wc-backbone-modal-backdrop modal-close"></div>
		</script>
		<?php

		$html = ob_get_clean();

		return $html;
	}

}
MetaBoxes/CustomMetaBox.php000064400000036731151547222000011706 0ustar00<?php
/**
 * Meta box to edit and add custom meta values for an order.
 */

namespace Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes;

use WC_Data_Store;
use WC_Meta_Data;
use WC_Order;
use WP_Ajax_Response;

/**
 * Class CustomMetaBox.
 */
class CustomMetaBox {

	/**
	 * Update nonce shared among different meta rows.
	 *
	 * @var string
	 */
	private $update_nonce;

	/**
	 * Helper method to get formatted meta data array with proper keys. This can be directly fed to `list_meta()` method.
	 *
	 * @param \WC_Order $order Order object.
	 *
	 * @return array Meta data.
	 */
	private function get_formatted_order_meta_data( \WC_Order $order ) {
		$metadata         = $order->get_meta_data();
		$metadata_to_list = array();
		foreach ( $metadata as $meta ) {
			$data = $meta->get_data();
			if ( is_protected_meta( $data['key'], 'order' ) ) {
				continue;
			}
			$metadata_to_list[] = array(
				'meta_id'    => $data['id'],
				'meta_key'   => $data['key'], // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- False positive, not a meta query.
				'meta_value' => maybe_serialize( $data['value'] ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- False positive, not a meta query.
			);
		}
		return $metadata_to_list;
	}

	/**
	 * Renders the meta box to manage custom meta.
	 *
	 * @param \WP_Post|\WC_Order $order_or_post Post or order object that we are rendering for.
	 */
	public function output( $order_or_post ) {
		if ( is_a( $order_or_post, \WP_Post::class ) ) {
			$order = wc_get_order( $order_or_post );
		} else {
			$order = $order_or_post;
		}
		$this->render_custom_meta_form( $this->get_formatted_order_meta_data( $order ), $order );
	}

	/**
	 * Helper method to render layout and actual HTML
	 *
	 * @param array     $metadata_to_list List of metadata to render.
	 * @param \WC_Order $order Order object.
	 */
	private function render_custom_meta_form( array $metadata_to_list, \WC_Order $order ) {
		?>
		<div id="postcustomstuff">
			<div id="ajax-response"></div>
			<?php
			list_meta( $metadata_to_list );
			$this->render_meta_form( $order );
			?>
		</div>
		<p>
			<?php
			printf(
				/* translators: 1: opening documentation tag 2: closing documentation tag. */
				esc_html( __( 'Custom fields can be used to add extra metadata to an order that you can %1$suse in your theme%2$s.', 'woocommerce' ) ),
				'<a href="' . esc_attr__( 'https://wordpress.org/support/article/custom-fields/', 'woocommerce' ) . '">',
				'</a>'
			);
			?>
		</p>
		<?php
	}

	/**
	 * Compute keys to display in autofill when adding new meta key entry in custom meta box.
	 * Currently, returns empty keys, will be implemented after caching is merged.
	 *
	 * @param array|null         $keys Keys to display in autofill.
	 * @param \WP_Post|\WC_Order $order Order object.
	 *
	 * @return array|mixed Array of keys to display in autofill.
	 */
	public function order_meta_keys_autofill( $keys, $order ) {
		if ( is_a( $order, \WC_Order::class ) ) {
			return array();
		}

		return $keys;
	}

	/**
	 * Reimplementation of WP core's `meta_form` function. Renders meta form box.
	 *
	 * @param \WC_Order $order WC_Order object.
	 *
	 * @return void
	 */
	public function render_meta_form( \WC_Order $order ) : void {
		$meta_key_input_id = 'metakeyselect';

		$keys = $this->order_meta_keys_autofill( null, $order );
		/**
		 * Filters values for the meta key dropdown in the Custom Fields meta box.
		 *
		 * Compatibility filter for `postmeta_form_keys` filter.
		 *
		 * @since 6.9.0
		 *
		 * @param array|null $keys Pre-defined meta keys to be used in place of a postmeta query. Default null.
		 * @param \WC_Order  $order The current post object.
		 */
		$keys = apply_filters( 'postmeta_form_keys', $keys, $order );
		?>
		<p><strong><?php esc_html_e( 'Add New Custom Field:', 'woocommerce' ); ?></strong></p>
		<table id="newmeta">
			<thead>
			<tr>
				<th class="left"><label for="<?php echo esc_attr( $meta_key_input_id ); ?>"><?php esc_html_e( 'Name', 'woocommerce' ); ?></label></th>
				<th><label for="metavalue"><?php esc_html_e( 'Value', 'woocommerce' ); ?></label></th>
			</tr>
			</thead>

			<tbody>
			<tr>
				<td id="newmetaleft" class="left">
					<?php if ( $keys ) { ?>
						<select id="metakeyselect" name="metakeyselect">
							<option value="#NONE#"><?php esc_html_e( '&mdash; Select &mdash;', 'woocommerce' ); ?></option>
							<?php
							foreach ( $keys as $key ) {
								if ( is_protected_meta( $key, 'post' ) || ! current_user_can( 'edit_others_shop_order', $order->get_id() ) ) {
									continue;
								}
								echo "\n<option value='" . esc_attr( $key ) . "'>" . esc_html( $key ) . '</option>';
							}
							?>
						</select>
						<input class="hide-if-js" type="text" id="metakeyinput" name="metakeyinput" value="" />
						<a href="#postcustomstuff" class="hide-if-no-js" onclick="jQuery('#metakeyinput, #metakeyselect, #enternew, #cancelnew').toggle();return false;">
							<span id="enternew"><?php esc_html_e( 'Enter new', 'woocommerce' ); ?></span>
							<span id="cancelnew" class="hidden"><?php esc_html_e( 'Cancel', 'woocommerce' ); ?></span></a>
					<?php } else { ?>
						<input type="text" id="metakeyinput" name="metakeyinput" value="" />
					<?php } ?>
				</td>
				<td><textarea id="metavalue" name="metavalue" rows="2" cols="25"></textarea></td>
			</tr>

			<tr><td colspan="2">
					<div class="submit">
						<?php
						submit_button(
							__( 'Add Custom Field', 'woocommerce' ),
							'',
							'addmeta',
							false,
							array(
								'id'            => 'newmeta-submit',
								'data-wp-lists' => 'add:the-list:newmeta',
							)
						);
						?>
					</div>
					<?php wp_nonce_field( 'add-meta', '_ajax_nonce-add-meta', false ); ?>
				</td></tr>
			</tbody>
		</table>
		<?php
	}

	/**
	 * Helper method to verify order edit permissions.
	 *
	 * @param int $order_id Order ID.
	 *
	 * @return ?WC_Order WC_Order object if the user can edit the order, die otherwise.
	 */
	private function verify_order_edit_permission_for_ajax( int $order_id ): ?WC_Order {
		if ( ! current_user_can( 'manage_woocommerce' ) || ! current_user_can( 'edit_others_shop_orders' ) ) {
			wp_send_json_error( 'missing_capabilities' );
			wp_die();
		}

		$order = wc_get_order( $order_id );
		if ( ! $order ) {
			wp_send_json_error( 'invalid_order_id' );
			wp_die();
		}
		return $order;
	}

	/**
	 * Reimplementation of WP core's `wp_ajax_add_meta` method to support order custom meta updates with custom tables.
	 */
	public function add_meta_ajax() {
		if ( ! check_ajax_referer( 'add-meta', '_ajax_nonce-add-meta' ) ) {
			wp_send_json_error( 'invalid_nonce' );
			wp_die();
		}

		$order_id = (int) $_POST['order_id'] ?? 0;
		$order    = $this->verify_order_edit_permission_for_ajax( $order_id );

		if ( isset( $_POST['metakeyselect'] ) && '#NONE#' === $_POST['metakeyselect'] && empty( $_POST['metakeyinput'] ) ) {
			wp_die( 1 );
		}

		if ( isset( $_POST['metakeyinput'] ) ) { // add meta.
			$meta_key   = sanitize_text_field( wp_unslash( $_POST['metakeyinput'] ) );
			$meta_value = sanitize_text_field( wp_unslash( $_POST['metavalue'] ?? '' ) );
			$this->handle_add_meta( $order, $meta_key, $meta_value );
		} else { // update.
			$meta = wp_unslash( $_POST['meta'] ?? array() ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitization done below in array_walk.
			$this->handle_update_meta( $order, $meta );
		}
	}

	/**
	 * Part of WP Core's `wp_ajax_add_meta`. This is re-implemented to support updating meta for custom tables.
	 *
	 * @param WC_Order $order Order object.
	 * @param string   $meta_key Meta key.
	 * @param string   $meta_value Meta value.
	 *
	 * @return void
	 */
	private function handle_add_meta( WC_Order $order, string $meta_key, string $meta_value ) {
		$count = 0;
		if ( is_protected_meta( $meta_key ) ) {
			wp_send_json_error( 'protected_meta' );
			wp_die();
		}
		$metas_for_current_key = wp_list_filter( $order->get_meta_data(), array( 'key' => $meta_key ) );
		$meta_ids              = wp_list_pluck( $metas_for_current_key, 'id' );
		$order->add_meta_data( $meta_key, $meta_value );
		$order->save_meta_data();
		$metas_for_current_key_with_new = wp_list_filter( $order->get_meta_data(), array( 'key' => $meta_key ) );
		$meta_id                        = 0;
		$new_meta_ids                   = wp_list_pluck( $metas_for_current_key_with_new, 'id' );
		$new_meta_ids                   = array_values( array_diff( $new_meta_ids, $meta_ids ) );
		if ( count( $new_meta_ids ) > 0 ) {
			$meta_id = $new_meta_ids[0];
		}
		$response = new WP_Ajax_Response(
			array(
				'what'     => 'meta',
				'id'       => $meta_id,
				'data'     => $this->list_meta_row(
					array(
						'meta_id'    => $meta_id,
						'meta_key'   => $meta_key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- false positive, not a meta query.
						'meta_value' => $meta_value, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- false positive, not a meta query.
					),
					$count
				),
				'position' => 1,
			)
		);
		$response->send();
	}

	/**
	 * Handles updating metadata.
	 *
	 * @param WC_Order $order Order object.
	 * @param array    $meta Meta object to update.
	 *
	 * @return void
	 */
	private function handle_update_meta( WC_Order $order, array $meta ) {
		if ( ! is_array( $meta ) ) {
			wp_send_json_error( 'invalid_meta' );
			wp_die();
		}
		array_walk( $meta, 'sanitize_text_field' );
		$mid = (int) key( $meta );
		if ( ! $mid ) {
			wp_send_json_error( 'invalid_meta_id' );
			wp_die();
		}
		$key   = $meta[ $mid ]['key'];
		$value = $meta[ $mid ]['value'];
		if ( is_protected_meta( $key ) ) {
			wp_send_json_error( 'protected_meta' );
			wp_die();
		}
		if ( '' === trim( $key ) ) {
			wp_send_json_error( 'invalid_meta_key' );
			wp_die();
		}

		$count = 0;
		$order->update_meta_data( $key, $value, $mid );
		$order->save_meta_data();
		$response = new WP_Ajax_Response(
			array(
				'what'     => 'meta',
				'id'       => $mid,
				'old_id'   => $mid,
				'data'     => $this->list_meta_row(
					array(
						'meta_key'   => $key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- false positive, not a meta query.
						'meta_value' => $value, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- false positive, not a meta query.
						'meta_id'    => $mid,
					),
					$count
				),
				'position' => 0,
			)
		);
		$response->send();
	}

	/**
	 * Outputs a single row of public meta data in the Custom Fields meta box.
	 *
	 * @since 2.5.0
	 *
	 * @param array $entry Meta entry.
	 * @param int   $count Sequence number of meta entries.
	 * @return string
	 */
	private function list_meta_row( array $entry, int &$count ) : string {
		if ( is_protected_meta( $entry['meta_key'], 'post' ) ) {
			return '';
		}

		if ( ! $this->update_nonce ) {
			$this->update_nonce = wp_create_nonce( 'add-meta' );
		}

		$r = '';
		++ $count;

		if ( is_serialized( $entry['meta_value'] ) ) {
			if ( is_serialized_string( $entry['meta_value'] ) ) {
				// This is a serialized string, so we should display it.
				$entry['meta_value'] = maybe_unserialize( $entry['meta_value'] ); // // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- false positive, not a meta query.
			} else {
				// This is a serialized array/object so we should NOT display it.
				--$count;
				return '';
			}
		}

		$entry['meta_key']   = esc_attr( $entry['meta_key'] ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- false positive, not a meta query.
		$entry['meta_value'] = esc_textarea( $entry['meta_value'] ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- false positive, not a meta query.
		$entry['meta_id']    = (int) $entry['meta_id'];

		$delete_nonce = wp_create_nonce( 'delete-meta_' . $entry['meta_id'] );

		$r .= "\n\t<tr id='meta-{$entry['meta_id']}'>";
		$r .= "\n\t\t<td class='left'><label class='screen-reader-text' for='meta-{$entry['meta_id']}-key'>" . __( 'Key', 'woocommerce' ) . "</label><input name='meta[{$entry['meta_id']}][key]' id='meta-{$entry['meta_id']}-key' type='text' size='20' value='{$entry['meta_key']}' />";

		$r .= "\n\t\t<div class='submit'>";
		$r .= get_submit_button( __( 'Delete', 'woocommerce' ), 'deletemeta small', "deletemeta[{$entry['meta_id']}]", false, array( 'data-wp-lists' => "delete:the-list:meta-{$entry['meta_id']}::_ajax_nonce:$delete_nonce" ) );
		$r .= "\n\t\t";
		$r .= get_submit_button( __( 'Update', 'woocommerce' ), 'updatemeta small', "meta-{$entry['meta_id']}-submit", false, array( 'data-wp-lists' => "add:the-list:meta-{$entry['meta_id']}::_ajax_nonce-add-meta={$this->update_nonce}" ) );
		$r .= '</div>';
		$r .= wp_nonce_field( 'change-meta', '_ajax_nonce', false, false );
		$r .= '</td>';

		$r .= "\n\t\t<td><label class='screen-reader-text' for='meta-{$entry['meta_id']}-value'>" . __( 'Value', 'woocommerce' ) . "</label><textarea name='meta[{$entry['meta_id']}][value]' id='meta-{$entry['meta_id']}-value' rows='2' cols='30'>{$entry['meta_value']}</textarea></td>\n\t</tr>";
		return $r;
	}

	/**
	 * Reimplementation of WP core's `wp_ajax_delete_meta` method to support order custom meta updates with custom tables.
	 *
	 * @return void
	 */
	public function delete_meta_ajax() {
		$meta_id  = (int) $_POST['id'] ?? 0;
		$order_id = (int) $_POST['order_id'] ?? 0;
		if ( ! $meta_id || ! $order_id ) {
			wp_send_json_error( 'invalid_meta_id' );
			wp_die();
		}
		check_ajax_referer( "delete-meta_$meta_id" );

		$order          = $this->verify_order_edit_permission_for_ajax( $order_id );
		$meta_to_delete = wp_list_filter( $order->get_meta_data(), array( 'id' => $meta_id ) );

		if ( empty( $meta_to_delete ) ) {
			wp_send_json_error( 'invalid_meta_id' );
			wp_die();
		}

		$order->delete_meta_data_by_mid( $meta_id );
		if ( $order->save() ) {
			wp_die( 1 );
		}
		wp_die( 0 );
	}

	/**
	 * Handle the possible changes in order metadata coming from an order edit page in admin
	 * (labeled "custom fields" in the UI).
	 *
	 * This method expects the $_POST array to contain a 'meta' key that is an associative
	 * array of [meta item id => [ 'key' => meta item name, 'value' => meta item value ];
	 * and also to contain (possibly empty) 'metakeyinput' and 'metavalue' keys.
	 *
	 * @param WC_Order $order The order to handle.
	 */
	public function handle_metadata_changes( $order ) {
		$has_meta_changes = false;

		$order_meta = $order->get_meta_data();

		$order_meta =
			array_combine(
				array_map( fn( $meta ) => $meta->id, $order_meta ),
				$order_meta
			);

		// phpcs:disable WordPress.Security.ValidatedSanitizedInput, WordPress.Security.NonceVerification.Missing

		foreach ( ( $_POST['meta'] ?? array() ) as $request_meta_id => $request_meta_data ) {
			$request_meta_id    = wp_unslash( $request_meta_id );
			$request_meta_key   = wp_unslash( $request_meta_data['key'] );
			$request_meta_value = wp_unslash( $request_meta_data['value'] );
			if ( array_key_exists( $request_meta_id, $order_meta ) &&
				( $order_meta[ $request_meta_id ]->key !== $request_meta_key || $order_meta[ $request_meta_id ]->value !== $request_meta_value ) ) {
				$order->update_meta_data( $request_meta_key, $request_meta_value, $request_meta_id );
				$has_meta_changes = true;
			}
		}

		$request_new_key   = wp_unslash( $_POST['metakeyinput'] ?? '' );
		$request_new_value = wp_unslash( $_POST['metavalue'] ?? '' );
		if ( '' !== $request_new_key ) {
			$order->add_meta_data( $request_new_key, $request_new_value );
			$has_meta_changes = true;
		}

		// phpcs:enable WordPress.Security.ValidatedSanitizedInput, WordPress.Security.NonceVerification.Missing

		if ( $has_meta_changes ) {
			$order->save();
		}
	}
}
MetaBoxes/TaxonomiesMetaBox.php000064400000010430151547222010012547 0ustar00<?php

namespace Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes;

use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;

/**
 * TaxonomiesMetaBox class, renders taxonomy sidebar widget on order edit screen.
 */
class TaxonomiesMetaBox {

	/**
	 * Order Table data store class.
	 *
	 * @var OrdersTableDataStore
	 */
	private $orders_table_data_store;

	/**
	 * Dependency injection init method.
	 *
	 * @param OrdersTableDataStore $orders_table_data_store Order Table data store class.
	 *
	 * @return void
	 */
	public function init( OrdersTableDataStore $orders_table_data_store ) {
		$this->orders_table_data_store = $orders_table_data_store;
	}

	/**
	 * Registers meta boxes to be rendered in order edit screen for taxonomies.
	 *
	 * Note: This is re-implementation of part of WP core's `register_and_do_post_meta_boxes` function. Since the code block that add meta box for taxonomies is not filterable, we have to re-implement it.
	 *
	 * @param string $screen_id Screen ID.
	 * @param string $order_type Order type to register meta boxes for.
	 *
	 * @return void
	 */
	public function add_taxonomies_meta_boxes( string $screen_id, string $order_type ) {
		include_once ABSPATH . 'wp-admin/includes/meta-boxes.php';
		$taxonomies = get_object_taxonomies( $order_type );
		// All taxonomies.
		foreach ( $taxonomies as $tax_name ) {
			$taxonomy = get_taxonomy( $tax_name );
			if ( ! $taxonomy->show_ui || false === $taxonomy->meta_box_cb ) {
				continue;
			}

			if ( 'post_categories_meta_box' === $taxonomy->meta_box_cb ) {
				$taxonomy->meta_box_cb = array( $this, 'order_categories_meta_box' );
			}

			if ( 'post_tags_meta_box' === $taxonomy->meta_box_cb ) {
				$taxonomy->meta_box_cb = array( $this, 'order_tags_meta_box' );
			}

			$label = $taxonomy->labels->name;

			if ( ! is_taxonomy_hierarchical( $tax_name ) ) {
				$tax_meta_box_id = 'tagsdiv-' . $tax_name;
			} else {
				$tax_meta_box_id = $tax_name . 'div';
			}

			add_meta_box(
				$tax_meta_box_id,
				$label,
				$taxonomy->meta_box_cb,
				$screen_id,
				'side',
				'core',
				array(
					'taxonomy'               => $tax_name,
					'__back_compat_meta_box' => true,
				)
			);
		}
	}

	/**
	 * Save handler for taxonomy data.
	 *
	 * @param \WC_Abstract_Order $order Order object.
	 * @param array|null         $taxonomy_input Taxonomy input passed from input.
	 */
	public function save_taxonomies( \WC_Abstract_Order $order, $taxonomy_input ) {
		if ( ! isset( $taxonomy_input ) ) {
			return;
		}

		$sanitized_tax_input = $this->sanitize_tax_input( $taxonomy_input );

		$sanitized_tax_input = $this->orders_table_data_store->init_default_taxonomies( $order, $sanitized_tax_input );
		$this->orders_table_data_store->set_custom_taxonomies( $order, $sanitized_tax_input );
	}

	/**
	 * Sanitize taxonomy input by calling sanitize callbacks for each registered taxonomy.
	 *
	 * @param array|null $taxonomy_data Nonce verified taxonomy input.
	 *
	 * @return array Sanitized taxonomy input.
	 */
	private function sanitize_tax_input( $taxonomy_data ) : array {
		$sanitized_tax_input = array();
		if ( ! is_array( $taxonomy_data ) ) {
			return $sanitized_tax_input;
		}

		// Convert taxonomy input to term IDs, to avoid ambiguity.
		foreach ( $taxonomy_data as $taxonomy => $terms ) {
			$tax_object = get_taxonomy( $taxonomy );
			if ( $tax_object && isset( $tax_object->meta_box_sanitize_cb ) ) {
				$sanitized_tax_input[ $taxonomy ] = call_user_func_array( $tax_object->meta_box_sanitize_cb, array( $taxonomy, $terms ) );
			}
		}

		return $sanitized_tax_input;
	}

	/**
	 * Add the categories meta box to the order screen. This is just a wrapper around the post_categories_meta_box.
	 *
	 * @param \WC_Abstract_Order $order Order object.
	 * @param array              $box   Meta box args.
	 *
	 * @return void
	 */
	public function order_categories_meta_box( $order, $box ) {
		$post = get_post( $order->get_id() );
		post_categories_meta_box( $post, $box );
	}

	/**
	 * Add the tags meta box to the order screen. This is just a wrapper around the post_tags_meta_box.
	 *
	 * @param \WC_Abstract_Order $order Order object.
	 * @param array              $box   Meta box args.
	 *
	 * @return void
	 */
	public function order_tags_meta_box( $order, $box ) {
		$post = get_post( $order->get_id() );
		post_tags_meta_box( $post, $box );
	}
}
PageController.php000064400000037746151547222010010215 0ustar00<?php
namespace Automattic\WooCommerce\Internal\Admin\Orders;

use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;

/**
 * Controls the different pages/screens associated to the "Orders" menu page.
 */
class PageController {

	use AccessiblePrivateMethods;

	/**
	 * The order type.
	 *
	 * @var string
	 */
	private $order_type = '';

	/**
	 * Instance of the posts redirection controller.
	 *
	 * @var PostsRedirectionController
	 */
	private $redirection_controller;

	/**
	 * Instance of the orders list table.
	 *
	 * @var ListTable
	 */
	private $orders_table;

	/**
	 * Instance of orders edit form.
	 *
	 * @var Edit
	 */
	private $order_edit_form;

	/**
	 * Current action.
	 *
	 * @var string
	 */
	private $current_action = '';

	/**
	 * Order object to be used in edit/new form.
	 *
	 * @var \WC_Order
	 */
	private $order;

	/**
	 * Verify that user has permission to edit orders.
	 *
	 * @return void
	 */
	private function verify_edit_permission() {
		if ( 'edit_order' === $this->current_action && ( ! isset( $this->order ) || ! $this->order ) ) {
			wp_die( esc_html__( 'You attempted to edit an order that does not exist. Perhaps it was deleted?', 'woocommerce' ) );
		}

		if ( $this->order->get_type() !== $this->order_type ) {
			wp_die( esc_html__( 'Order type mismatch.', 'woocommerce' ) );
		}

		if ( ! current_user_can( get_post_type_object( $this->order_type )->cap->edit_post, $this->order->get_id() ) && ! current_user_can( 'manage_woocommerce' ) ) {
			wp_die( esc_html__( 'You do not have permission to edit this order', 'woocommerce' ) );
		}

		if ( 'trash' === $this->order->get_status() ) {
			wp_die( esc_html__( 'You cannot edit this item because it is in the Trash. Please restore it and try again.', 'woocommerce' ) );
		}
	}

	/**
	 * Verify that user has permission to create order.
	 *
	 * @return void
	 */
	private function verify_create_permission() {
		if ( ! current_user_can( get_post_type_object( $this->order_type )->cap->publish_posts ) && ! current_user_can( 'manage_woocommerce' ) ) {
			wp_die( esc_html__( 'You don\'t have permission to create a new order', 'woocommerce' ) );
		}

		if ( isset( $this->order ) ) {
			$this->verify_edit_permission();
		}
	}

	/**
	 * Claims the lock for the order being edited/created (unless it belongs to someone else).
	 * Also handles the 'claim-lock' action which allows taking over the order forcefully.
	 *
	 * @return void
	 */
	private function handle_edit_lock() {
		if ( ! $this->order ) {
			return;
		}

		$edit_lock = wc_get_container()->get( EditLock::class );

		$locked = $edit_lock->is_locked_by_another_user( $this->order );

		// Take over order?
		if ( ! empty( $_GET['claim-lock'] ) && wp_verify_nonce( $_GET['_wpnonce'] ?? '', 'claim-lock-' . $this->order->get_id() ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash
			$edit_lock->lock( $this->order );
			wp_safe_redirect( $this->get_edit_url( $this->order->get_id() ) );
			exit;
		}

		if ( ! $locked ) {
			$edit_lock->lock( $this->order );
		}

		add_action(
			'admin_footer',
			function() use ( $edit_lock ) {
				$edit_lock->render_dialog( $this->order );
			}
		);
	}

	/**
	 * Sets up the page controller, including registering the menu item.
	 *
	 * @return void
	 */
	public function setup(): void {
		global $plugin_page, $pagenow;

		$this->redirection_controller = new PostsRedirectionController( $this );

		// Register menu.
		if ( 'admin_menu' === current_action() ) {
			$this->register_menu();
		} else {
			add_action( 'admin_menu', 'register_menu', 9 );
		}

		// Not on an Orders page.
		if ( 'admin.php' !== $pagenow || 0 !== strpos( $plugin_page, 'wc-orders' ) ) {
			return;
		}

		$this->set_order_type();
		$this->set_action();

		$page_suffix = ( 'shop_order' === $this->order_type ? '' : '--' . $this->order_type );

		self::add_action( 'load-woocommerce_page_wc-orders' . $page_suffix, array( $this, 'handle_load_page_action' ) );
		self::add_action( 'admin_title', array( $this, 'set_page_title' ) );
	}

	/**
	 * Perform initialization for the current action.
	 */
	private function handle_load_page_action() {
		$screen            = get_current_screen();
		$screen->post_type = $this->order_type;

		if ( method_exists( $this, 'setup_action_' . $this->current_action ) ) {
			$this->{"setup_action_{$this->current_action}"}();
		}
	}

	/**
	 * Set the document title for Orders screens to match what it would be with the shop_order CPT.
	 *
	 * @param string $admin_title The admin screen title before it's filtered.
	 *
	 * @return string The filtered admin title.
	 */
	private function set_page_title( $admin_title ) {
		if ( ! $this->is_order_screen( $this->order_type ) ) {
			return $admin_title;
		}

		$wp_order_type = get_post_type_object( $this->order_type );
		$labels        = get_post_type_labels( $wp_order_type );

		if ( $this->is_order_screen( $this->order_type, 'list' ) ) {
			$admin_title = sprintf(
				// translators: 1: The label for an order type 2: The name of the website.
				esc_html__( '%1$s &lsaquo; %2$s &#8212; WordPress', 'woocommerce' ),
				esc_html( $labels->name ),
				esc_html( get_bloginfo( 'name' ) )
			);
		} elseif ( $this->is_order_screen( $this->order_type, 'edit' ) ) {
			$admin_title = sprintf(
				// translators: 1: The label for an order type 2: The title of the order 3: The name of the website.
				esc_html__( '%1$s #%2$s &lsaquo; %3$s &#8212; WordPress', 'woocommerce' ),
				esc_html( $labels->edit_item ),
				absint( $this->order->get_id() ),
				esc_html( get_bloginfo( 'name' ) )
			);
		} elseif ( $this->is_order_screen( $this->order_type, 'new' ) ) {
			$admin_title = sprintf(
				// translators: 1: The label for an order type 2: The name of the website.
				esc_html__( '%1$s &lsaquo; %2$s &#8212; WordPress', 'woocommerce' ),
				esc_html( $labels->add_new_item ),
				esc_html( get_bloginfo( 'name' ) )
			);
		}

		return $admin_title;
	}

	/**
	 * Determines the order type for the current screen.
	 *
	 * @return void
	 */
	private function set_order_type() {
		global $plugin_page;

		$this->order_type = str_replace( array( 'wc-orders--', 'wc-orders' ), '', $plugin_page );
		$this->order_type = empty( $this->order_type ) ? 'shop_order' : $this->order_type;

		$wc_order_type = wc_get_order_type( $this->order_type );
		$wp_order_type = get_post_type_object( $this->order_type );

		if ( ! $wc_order_type || ! $wp_order_type || ! $wp_order_type->show_ui || ! current_user_can( $wp_order_type->cap->edit_posts ) ) {
			wp_die();
		}
	}

	/**
	 * Sets the current action based on querystring arguments. Defaults to 'list_orders'.
	 *
	 * @return void
	 */
	private function set_action(): void {
		switch ( isset( $_GET['action'] ) ? sanitize_text_field( wp_unslash( $_GET['action'] ) ) : '' ) {
			case 'edit':
				$this->current_action = 'edit_order';
				break;
			case 'new':
				$this->current_action = 'new_order';
				break;
			default:
				$this->current_action = 'list_orders';
				break;
		}
	}

	/**
	 * Registers the "Orders" menu.
	 *
	 * @return void
	 */
	public function register_menu(): void {
		$order_types = wc_get_order_types( 'admin-menu' );

		foreach ( $order_types as $order_type ) {
			$post_type = get_post_type_object( $order_type );

			add_submenu_page(
				'woocommerce',
				$post_type->labels->name,
				$post_type->labels->menu_name,
				$post_type->cap->edit_posts,
				'wc-orders' . ( 'shop_order' === $order_type ? '' : '--' . $order_type ),
				array( $this, 'output' )
			);
		}

		// In some cases (such as if the authoritative order store was changed earlier in the current request) we
		// need an extra step to remove the menu entry for the menu post type.
		add_action(
			'admin_init',
			function() use ( $order_types ) {
				foreach ( $order_types as $order_type ) {
					remove_submenu_page( 'woocommerce', 'edit.php?post_type=' . $order_type );
				}
			}
		);
	}

	/**
	 * Outputs content for the current orders screen.
	 *
	 * @return void
	 */
	public function output(): void {
		switch ( $this->current_action ) {
			case 'edit_order':
			case 'new_order':
				$this->order_edit_form->display();
				break;
			case 'list_orders':
			default:
				$this->orders_table->prepare_items();
				$this->orders_table->display();
				break;
		}
	}

	/**
	 * Handles initialization of the orders list table.
	 *
	 * @return void
	 */
	private function setup_action_list_orders(): void {
		$this->orders_table = wc_get_container()->get( ListTable::class );
		$this->orders_table->setup(
			array(
				'order_type' => $this->order_type,
			)
		);

		if ( $this->orders_table->current_action() ) {
			$this->orders_table->handle_bulk_actions();
		}

		$this->strip_http_referer();
	}

	/**
	 * Perform a redirect to remove the `_wp_http_referer` and `_wpnonce` strings if present in the URL (see also
	 * wp-admin/edit.php where a similar process takes place), otherwise the size of this field builds to an
	 * unmanageable length over time.
	 */
	private function strip_http_referer(): void {
		$current_url  = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) );
		$stripped_url = remove_query_arg( array( '_wp_http_referer', '_wpnonce' ), $current_url );

		if ( $stripped_url !== $current_url ) {
			wp_safe_redirect( $stripped_url );
			exit;
		}
	}

	/**
	 * Prepares the order edit form for creating or editing an order.
	 *
	 * @see \Automattic\WooCommerce\Internal\Admin\Orders\Edit.
	 * @since 8.1.0
	 */
	private function prepare_order_edit_form(): void {
		if ( ! $this->order || ! in_array( $this->current_action, array( 'new_order', 'edit_order' ), true ) ) {
			return;
		}

		$this->order_edit_form = $this->order_edit_form ?? new Edit();
		$this->order_edit_form->setup( $this->order );
		$this->order_edit_form->set_current_action( $this->current_action );
	}

	/**
	 * Handles initialization of the orders edit form.
	 *
	 * @return void
	 */
	private function setup_action_edit_order(): void {
		global $theorder;
		$this->order = wc_get_order( absint( isset( $_GET['id'] ) ? $_GET['id'] : 0 ) );
		$this->verify_edit_permission();
		$this->handle_edit_lock();
		$theorder = $this->order;

		$this->prepare_order_edit_form();
	}

	/**
	 * Handles initialization of the orders edit form with a new order.
	 *
	 * @return void
	 */
	private function setup_action_new_order(): void {
		global $theorder;

		$this->verify_create_permission();

		$order_class_name = wc_get_order_type( $this->order_type )['class_name'];
		if ( ! $order_class_name || ! class_exists( $order_class_name ) ) {
			wp_die();
		}

		$this->order = new $order_class_name();
		$this->order->set_object_read( false );
		$this->order->set_status( 'auto-draft' );
		$this->order->set_created_via( 'admin' );
		$this->order->save();
		$this->handle_edit_lock();

		// Schedule auto-draft cleanup. We re-use the WP event here on purpose.
		if ( ! wp_next_scheduled( 'wp_scheduled_auto_draft_delete' ) ) {
			wp_schedule_event( time(), 'daily', 'wp_scheduled_auto_draft_delete' );
		}

		$theorder = $this->order;

		$this->prepare_order_edit_form();
	}

	/**
	 * Returns the current order type.
	 *
	 * @return string
	 */
	public function get_order_type() {
		return $this->order_type;
	}

	/**
	 * Helper method to generate a link to the main orders screen.
	 *
	 * @return string Orders screen URL.
	 */
	public function get_orders_url(): string {
		return wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled() ?
			admin_url( 'admin.php?page=wc-orders' ) :
			admin_url( 'edit.php?post_type=shop_order' );
	}

	/**
	 * Helper method to generate edit link for an order.
	 *
	 * @param int $order_id Order ID.
	 *
	 * @return string Edit link.
	 */
	public function get_edit_url( int $order_id ) : string {
		if ( ! wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled() ) {
			return admin_url( 'post.php?post=' . absint( $order_id ) ) . '&action=edit';
		}

		$order = wc_get_order( $order_id );

		// Confirm we could obtain the order object (since it's possible it will not exist, due to a sync issue, or may
		// have been deleted in a separate concurrent request).
		if ( false === $order ) {
			wc_get_logger()->debug(
				sprintf(
					/* translators: %d order ID. */
					__( 'Attempted to determine the edit URL for order %d, however the order does not exist.', 'woocommerce' ),
					$order_id
				)
			);
			$order_type = 'shop_order';
		} else {
			$order_type = $order->get_type();
		}

		return add_query_arg(
			array(
				'action' => 'edit',
				'id'     => absint( $order_id ),
			),
			$this->get_base_page_url( $order_type )
		);
	}

	/**
	 * Helper method to generate a link for creating order.
	 *
	 * @param string $order_type The order type. Defaults to 'shop_order'.
	 * @return string
	 */
	public function get_new_page_url( $order_type = 'shop_order' ) : string {
		$url = wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled() ?
			add_query_arg( 'action', 'new', $this->get_base_page_url( $order_type ) ) :
			admin_url( 'post-new.php?post_type=' . $order_type );

		return $url;
	}

	/**
	 * Helper method to generate a link to the main screen for a custom order type.
	 *
	 * @param string $order_type The order type.
	 *
	 * @return string
	 *
	 * @throws \Exception When an invalid order type is passed.
	 */
	public function get_base_page_url( $order_type ): string {
		$order_types_with_ui = wc_get_order_types( 'admin-menu' );

		if ( ! in_array( $order_type, $order_types_with_ui, true ) ) {
			// translators: %s is a custom order type.
			throw new \Exception( sprintf( __( 'Invalid order type: %s.', 'woocommerce' ), esc_html( $order_type ) ) );
		}

		return admin_url( 'admin.php?page=wc-orders' . ( 'shop_order' === $order_type ? '' : '--' . $order_type ) );
	}

	/**
	 * Helper method to check if the current admin screen is related to orders.
	 *
	 * @param string $type   Optional. The order type to check for. Default shop_order.
	 * @param string $action Optional. The purpose of the screen to check for. 'list', 'edit', or 'new'.
	 *                       Leave empty to check for any order screen.
	 *
	 * @return bool
	 */
	public function is_order_screen( $type = 'shop_order', $action = '' ) : bool {
		if ( ! did_action( 'current_screen' ) ) {
			wc_doing_it_wrong(
				__METHOD__,
				sprintf(
					// translators: %s is the name of a function.
					esc_html__( '%s must be called after the current_screen action.', 'woocommerce' ),
					esc_html( __METHOD__ )
				),
				'7.9.0'
			);

			return false;
		}

		$valid_types = wc_get_order_types( 'view-order' );
		if ( ! in_array( $type, $valid_types, true ) ) {
			wc_doing_it_wrong(
				__METHOD__,
				sprintf(
					// translators: %s is the name of an order type.
					esc_html__( '%s is not a valid order type.', 'woocommerce' ),
					esc_html( $type )
				),
				'7.9.0'
			);

			return false;
		}

		if ( wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled() ) {
			if ( $action ) {
				switch ( $action ) {
					case 'edit':
						$is_action = 'edit_order' === $this->current_action;
						break;
					case 'list':
						$is_action = 'list_orders' === $this->current_action;
						break;
					case 'new':
						$is_action = 'new_order' === $this->current_action;
						break;
					default:
						$is_action = false;
						break;
				}
			}

			$type_match   = $type === $this->order_type;
			$action_match = ! $action || $is_action;
		} else {
			$screen = get_current_screen();

			if ( $action ) {
				switch ( $action ) {
					case 'edit':
						$screen_match = 'post' === $screen->base && filter_input( INPUT_GET, 'post', FILTER_VALIDATE_INT );
						break;
					case 'list':
						$screen_match = 'edit' === $screen->base;
						break;
					case 'new':
						$screen_match = 'post' === $screen->base && 'add' === $screen->action;
						break;
					default:
						$screen_match = false;
						break;
				}
			}

			$type_match   = $type === $screen->post_type;
			$action_match = ! $action || $screen_match;
		}

		return $type_match && $action_match;
	}
}
PostsRedirectionController.php000064400000011537151547222010012627 0ustar00<?php
namespace Automattic\WooCommerce\Internal\Admin\Orders;

use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;

/**
 * When {@see OrdersTableDataStore} is in use, this class takes care of redirecting admins from CPT-based URLs
 * to the new ones.
 */
class PostsRedirectionController {

	/**
	 * Instance of the PageController class.
	 *
	 * @var PageController
	 */
	private $page_controller;

	/**
	 * Constructor.
	 *
	 * @param PageController $page_controller Page controller instance. Used to generate links/URLs.
	 */
	public function __construct( PageController $page_controller ) {
		$this->page_controller = $page_controller;

		if ( ! wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled() ) {
			return;
		}

		add_action(
			'load-edit.php',
			function() {
				$this->maybe_redirect_to_orders_page();
			}
		);

		add_action(
			'load-post-new.php',
			function() {
				$this->maybe_redirect_to_new_order_page();
			}
		);

		add_action(
			'load-post.php',
			function() {
				$this->maybe_redirect_to_edit_order_page();
			}
		);
	}

	/**
	 * If needed, performs a redirection to the main orders page.
	 *
	 * @return void
	 */
	private function maybe_redirect_to_orders_page(): void {
		$post_type = $_GET['post_type'] ?? ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized

		if ( ! $post_type || ! in_array( $post_type, wc_get_order_types( 'admin-menu' ), true ) ) {
			return;
		}

		// Respect query args, except for 'post_type'.
		$query_args = wp_unslash( $_GET );
		$action     = $query_args['action'] ?? '';
		$posts      = $query_args['post'] ?? array();
		unset( $query_args['post_type'], $query_args['post'], $query_args['_wpnonce'], $query_args['_wp_http_referer'], $query_args['action'] );

		// Remap 'post_status' arg.
		if ( isset( $query_args['post_status'] ) ) {
			$query_args['status'] = $query_args['post_status'];
			unset( $query_args['post_status'] );
		}

		$new_url = $this->page_controller->get_base_page_url( $post_type );
		$new_url = add_query_arg( $query_args, $new_url );

		// Handle bulk actions.
		if ( $action && in_array( $action, array( 'trash', 'untrash', 'delete', 'mark_processing', 'mark_on-hold', 'mark_completed', 'mark_cancelled' ), true ) ) {
			check_admin_referer( 'bulk-posts' );

			$new_url = add_query_arg(
				array(
					'action'           => $action,
					'id'               => $posts,
					'_wp_http_referer' => $this->page_controller->get_orders_url(),
					'_wpnonce'         => wp_create_nonce( 'bulk-orders' ),
				),
				$new_url
			);
		}

		wp_safe_redirect( $new_url, 301 );
		exit;
	}

	/**
	 * If needed, performs a redirection to the new order page.
	 *
	 * @return void
	 */
	private function maybe_redirect_to_new_order_page(): void {
		$post_type = $_GET['post_type'] ?? ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized

		if ( ! $post_type || ! in_array( $post_type, wc_get_order_types( 'admin-menu' ), true ) ) {
			return;
		}

		// Respect query args, except for 'post_type'.
		$query_args = wp_unslash( $_GET );
		unset( $query_args['post_type'] );

		$new_url = $this->page_controller->get_new_page_url( $post_type );
		$new_url = add_query_arg( $query_args, $new_url );

		wp_safe_redirect( $new_url, 301 );
		exit;
	}

	/**
	 * If needed, performs a redirection to the edit order page.
	 *
	 * @return void
	 */
	private function maybe_redirect_to_edit_order_page(): void {
		$post_id = absint( $_GET['post'] ?? 0 );

		$redirect_from_types   = wc_get_order_types( 'admin-menu' );
		$redirect_from_types[] = 'shop_order_placehold';

		if ( ! $post_id || ! in_array( get_post_type( $post_id ), $redirect_from_types, true ) || ! isset( $_GET['action'] ) ) {
			return;
		}

		// Respect query args, except for 'post'.
		$query_args = wp_unslash( $_GET );
		$action     = $query_args['action'];
		unset( $query_args['post'], $query_args['_wpnonce'], $query_args['_wp_http_referer'], $query_args['action'] );

		$new_url = '';

		switch ( $action ) {
			case 'edit':
				$new_url = $this->page_controller->get_edit_url( $post_id );
				break;

			case 'trash':
			case 'untrash':
			case 'delete':
				// Re-generate nonce if validation passes.
				check_admin_referer( $action . '-post_' . $post_id );

				$new_url = add_query_arg(
					array(
						'action'           => $action,
						'order'            => array( $post_id ),
						'_wp_http_referer' => $this->page_controller->get_orders_url(),
						'_wpnonce'         => wp_create_nonce( 'bulk-orders' ),
					),
					$this->page_controller->get_orders_url()
				);

				break;

			default:
				break;
		}

		if ( ! $new_url ) {
			return;
		}

		$new_url = add_query_arg( $query_args, $new_url );

		wp_safe_redirect( $new_url, 301 );
		exit;
	}

}

CustomOrdersTableController.php000064400000045117151547734010012741 0ustar00<?php
/**
 * CustomOrdersTableController class file.
 */

namespace Automattic\WooCommerce\Internal\DataStores\Orders;

use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Caches\OrderCache;
use Automattic\WooCommerce\Caches\OrderCacheController;
use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessingController;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use Automattic\WooCommerce\Utilities\PluginUtil;

defined( 'ABSPATH' ) || exit;

/**
 * This is the main class that controls the custom orders tables feature. Its responsibilities are:
 *
 * - Displaying UI components (entries in the tools page and in settings)
 * - Providing the proper data store for orders via 'woocommerce_order_data_store' hook
 *
 * ...and in general, any functionality that doesn't imply database access.
 */
class CustomOrdersTableController {

	use AccessiblePrivateMethods;

	/**
	 * The name of the option for enabling the usage of the custom orders tables
	 */
	public const CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION = 'woocommerce_custom_orders_table_enabled';

	/**
	 * The name of the option that tells whether database transactions are to be used or not for data synchronization.
	 */
	public const USE_DB_TRANSACTIONS_OPTION = 'woocommerce_use_db_transactions_for_custom_orders_table_data_sync';

	/**
	 * The name of the option to store the transaction isolation level to use when database transactions are enabled.
	 */
	public const DB_TRANSACTIONS_ISOLATION_LEVEL_OPTION = 'woocommerce_db_transactions_isolation_level_for_custom_orders_table_data_sync';

	public const DEFAULT_DB_TRANSACTIONS_ISOLATION_LEVEL = 'READ UNCOMMITTED';

	/**
	 * The data store object to use.
	 *
	 * @var OrdersTableDataStore
	 */
	private $data_store;

	/**
	 * Refunds data store object to use.
	 *
	 * @var OrdersTableRefundDataStore
	 */
	private $refund_data_store;

	/**
	 * The data synchronizer object to use.
	 *
	 * @var DataSynchronizer
	 */
	private $data_synchronizer;

	/**
	 * The batch processing controller to use.
	 *
	 * @var BatchProcessingController
	 */
	private $batch_processing_controller;

	/**
	 * The features controller to use.
	 *
	 * @var FeaturesController
	 */
	private $features_controller;

	/**
	 * The orders cache object to use.
	 *
	 * @var OrderCache
	 */
	private $order_cache;

	/**
	 * The orders cache controller object to use.
	 *
	 * @var OrderCacheController
	 */
	private $order_cache_controller;

	/**
	 * The plugin util object to use.
	 *
	 * @var PluginUtil
	 */
	private $plugin_util;

	/**
	 * Class constructor.
	 */
	public function __construct() {
		$this->init_hooks();
	}

	/**
	 * Initialize the hooks used by the class.
	 */
	private function init_hooks() {
		self::add_filter( 'woocommerce_order_data_store', array( $this, 'get_orders_data_store' ), 999, 1 );
		self::add_filter( 'woocommerce_order-refund_data_store', array( $this, 'get_refunds_data_store' ), 999, 1 );
		self::add_filter( 'woocommerce_debug_tools', array( $this, 'add_initiate_regeneration_entry_to_tools_array' ), 999, 1 );
		self::add_filter( 'updated_option', array( $this, 'process_updated_option' ), 999, 3 );
		self::add_filter( 'pre_update_option', array( $this, 'process_pre_update_option' ), 999, 3 );
		self::add_action( FeaturesController::FEATURE_ENABLED_CHANGED_ACTION, array( $this, 'handle_data_sync_option_changed' ), 10, 1 );
		self::add_action( 'woocommerce_after_register_post_type', array( $this, 'register_post_type_for_order_placeholders' ), 10, 0 );
		self::add_action( FeaturesController::FEATURE_ENABLED_CHANGED_ACTION, array( $this, 'handle_feature_enabled_changed' ), 10, 2 );
		self::add_action( 'woocommerce_feature_setting', array( $this, 'get_hpos_feature_setting' ), 10, 2 );
	}

	/**
	 * Class initialization, invoked by the DI container.
	 *
	 * @internal
	 * @param OrdersTableDataStore       $data_store The data store to use.
	 * @param DataSynchronizer           $data_synchronizer The data synchronizer to use.
	 * @param OrdersTableRefundDataStore $refund_data_store The refund data store to use.
	 * @param BatchProcessingController  $batch_processing_controller The batch processing controller to use.
	 * @param FeaturesController         $features_controller The features controller instance to use.
	 * @param OrderCache                 $order_cache The order cache engine to use.
	 * @param OrderCacheController       $order_cache_controller The order cache controller to use.
	 * @param PluginUtil                 $plugin_util The plugin util to use.
	 */
	final public function init(
		OrdersTableDataStore $data_store,
		DataSynchronizer $data_synchronizer,
		OrdersTableRefundDataStore $refund_data_store,
		BatchProcessingController $batch_processing_controller,
		FeaturesController $features_controller,
		OrderCache $order_cache,
		OrderCacheController $order_cache_controller,
		PluginUtil $plugin_util
	) {
		$this->data_store                  = $data_store;
		$this->data_synchronizer           = $data_synchronizer;
		$this->batch_processing_controller = $batch_processing_controller;
		$this->refund_data_store           = $refund_data_store;
		$this->features_controller         = $features_controller;
		$this->order_cache                 = $order_cache;
		$this->order_cache_controller      = $order_cache_controller;
		$this->plugin_util                 = $plugin_util;
	}

	/**
	 * Is the custom orders table usage enabled via settings?
	 * This can be true only if the feature is enabled and a table regeneration has been completed.
	 *
	 * @return bool True if the custom orders table usage is enabled
	 */
	public function custom_orders_table_usage_is_enabled(): bool {
		return get_option( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION ) === 'yes';
	}

	/**
	 * Gets the instance of the orders data store to use.
	 *
	 * @param \WC_Object_Data_Store_Interface|string $default_data_store The default data store (as received via the woocommerce_order_data_store hook).
	 *
	 * @return \WC_Object_Data_Store_Interface|string The actual data store to use.
	 */
	private function get_orders_data_store( $default_data_store ) {
		return $this->get_data_store_instance( $default_data_store, 'order' );
	}

	/**
	 * Gets the instance of the refunds data store to use.
	 *
	 * @param \WC_Object_Data_Store_Interface|string $default_data_store The default data store (as received via the woocommerce_order-refund_data_store hook).
	 *
	 * @return \WC_Object_Data_Store_Interface|string The actual data store to use.
	 */
	private function get_refunds_data_store( $default_data_store ) {
		return $this->get_data_store_instance( $default_data_store, 'order_refund' );
	}

	/**
	 * Gets the instance of a given data store.
	 *
	 * @param \WC_Object_Data_Store_Interface|string $default_data_store The default data store (as received via the appropriate hooks).
	 * @param string                                 $type               The type of the data store to get.
	 *
	 * @return \WC_Object_Data_Store_Interface|string The actual data store to use.
	 */
	private function get_data_store_instance( $default_data_store, string $type ) {
		if ( $this->custom_orders_table_usage_is_enabled() ) {
			switch ( $type ) {
				case 'order_refund':
					return $this->refund_data_store;
				default:
					return $this->data_store;
			}
		} else {
			return $default_data_store;
		}
	}

	/**
	 * Add an entry to Status - Tools to create or regenerate the custom orders table,
	 * and also an entry to delete the table as appropriate.
	 *
	 * @param array $tools_array The array of tools to add the tool to.
	 * @return array The updated array of tools-
	 */
	private function add_initiate_regeneration_entry_to_tools_array( array $tools_array ): array {
		if ( ! $this->data_synchronizer->check_orders_table_exists() ) {
			return $tools_array;
		}

		if ( $this->custom_orders_table_usage_is_enabled() || $this->data_synchronizer->data_sync_is_enabled() ) {
			$disabled = true;
			$message  = __( 'This will delete the custom orders tables. The tables can be deleted only if the "High-Performance order storage" is not authoritative and sync is disabled (via Settings > Advanced > Features).', 'woocommerce' );
		} else {
			$disabled = false;
			$message  = __( 'This will delete the custom orders tables. To create them again enable the "High-Performance order storage" feature (via Settings > Advanced > Features).', 'woocommerce' );
		}

		$tools_array['delete_custom_orders_table'] = array(
			'name'             => __( 'Delete the custom orders tables', 'woocommerce' ),
			'desc'             => sprintf(
				'<strong class="red">%1$s</strong> %2$s',
				__( 'Note:', 'woocommerce' ),
				$message
			),
			'requires_refresh' => true,
			'callback'         => function () {
				$this->features_controller->change_feature_enable( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, false );
				$this->delete_custom_orders_tables();
				return __( 'Custom orders tables have been deleted.', 'woocommerce' );
			},
			'button'           => __( 'Delete', 'woocommerce' ),
			'disabled'         => $disabled,
		);

		return $tools_array;
	}

	/**
	 * Delete the custom orders tables and any related options and data in response to the user pressing the tool button.
	 *
	 * @throws \Exception Can't delete the tables.
	 */
	private function delete_custom_orders_tables() {
		if ( $this->custom_orders_table_usage_is_enabled() ) {
			throw new \Exception( "Can't delete the custom orders tables: they are currently in use (via Settings > Advanced > Features)." );
		}

		delete_option( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION );
		$this->data_synchronizer->delete_database_tables();
	}

	/**
	 * Handler for the individual setting updated hook.
	 *
	 * @param string $option Setting name.
	 * @param mixed  $old_value Old value of the setting.
	 * @param mixed  $value New value of the setting.
	 */
	private function process_updated_option( $option, $old_value, $value ) {
		if ( DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION === $option && 'no' === $value ) {
			$this->data_synchronizer->cleanup_synchronization_state();
		}
	}

	/**
	 * Handler for the setting pre-update hook.
	 * We use it to verify that authoritative orders table switch doesn't happen while sync is pending.
	 *
	 * @param mixed  $value New value of the setting.
	 * @param string $option Setting name.
	 * @param mixed  $old_value Old value of the setting.
	 *
	 * @throws \Exception Attempt to change the authoritative orders table while orders sync is pending.
	 */
	private function process_pre_update_option( $value, $option, $old_value ) {
		if ( DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION === $option && $value !== $old_value ) {
			$this->order_cache->flush();
			return $value;
		}

		if ( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION !== $option || $value === $old_value || false === $old_value ) {
			return $value;
		}

		$this->order_cache->flush();

		/**
		 * Re-enable the following code once the COT to posts table sync is implemented (it's currently commented out to ease testing).
		$sync_is_pending = 0 !== $this->data_synchronizer->get_current_orders_pending_sync_count();
		if ( $sync_is_pending ) {
			throw new \Exception( "The authoritative table for orders storage can't be changed while there are orders out of sync" );
		}
		 */

		return $value;
	}

	/**
	 * Handler for the all settings updated hook.
	 *
	 * @param string $feature_id Feature ID.
	 */
	private function handle_data_sync_option_changed( string $feature_id ) {
		if ( DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION !== $feature_id ) {
			return;
		}
		$data_sync_is_enabled = $this->data_synchronizer->data_sync_is_enabled();

		if ( ! $this->data_synchronizer->check_orders_table_exists() ) {
			$this->data_synchronizer->create_database_tables();
		}

		// Enabling/disabling the sync implies starting/stopping it too, if needed.
		// We do this check here, and not in process_pre_update_option, so that if for some reason
		// the setting is enabled but no sync is in process, sync will start by just saving the
		// settings even without modifying them (and the opposite: sync will be stopped if for
		// some reason it was ongoing while it was disabled).
		if ( $data_sync_is_enabled ) {
			$this->batch_processing_controller->enqueue_processor( DataSynchronizer::class );
		} else {
			$this->batch_processing_controller->remove_processor( DataSynchronizer::class );
		}
	}

	/**
	 * Handle the 'woocommerce_feature_enabled_changed' action,
	 * if the custom orders table feature is enabled create the database tables if they don't exist.
	 *
	 * @param string $feature_id The id of the feature that is being enabled or disabled.
	 * @param bool   $is_enabled True if the feature is being enabled, false if it's being disabled.
	 */
	private function handle_feature_enabled_changed( $feature_id, $is_enabled ): void {
		if ( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION !== $feature_id || ! $is_enabled ) {
			return;
		}

		if ( ! $this->data_synchronizer->check_orders_table_exists() ) {
			$success = $this->data_synchronizer->create_database_tables();
			if ( ! $success ) {
				update_option( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, 'no' );
			}
		}
	}

	/**
	 * Handler for the woocommerce_after_register_post_type post,
	 * registers the post type for placeholder orders.
	 *
	 * @return void
	 */
	private function register_post_type_for_order_placeholders(): void {
		wc_register_order_type(
			DataSynchronizer::PLACEHOLDER_ORDER_POST_TYPE,
			array(
				'public'                           => false,
				'exclude_from_search'              => true,
				'publicly_queryable'               => false,
				'show_ui'                          => false,
				'show_in_menu'                     => false,
				'show_in_nav_menus'                => false,
				'show_in_admin_bar'                => false,
				'show_in_rest'                     => false,
				'rewrite'                          => false,
				'query_var'                        => false,
				'can_export'                       => false,
				'supports'                         => array(),
				'capabilities'                     => array(),
				'exclude_from_order_count'         => true,
				'exclude_from_order_views'         => true,
				'exclude_from_order_reports'       => true,
				'exclude_from_order_sales_reports' => true,
			)
		);
	}

	/**
	 * Returns the HPOS setting for rendering in Features section of the settings page.
	 *
	 * @param array  $feature_setting HPOS feature value as defined in the feature controller.
	 * @param string $feature_id ID of the feature.
	 *
	 * @return array Feature setting object.
	 */
	private function get_hpos_feature_setting( array $feature_setting, string $feature_id ) {
		if ( ! in_array( $feature_id, array( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION, 'custom_order_tables' ), true ) ) {
			return $feature_setting;
		}

		if ( 'yes' === get_transient( 'wc_installing' ) ) {
			return $feature_setting;
		}

		$sync_status = $this->data_synchronizer->get_sync_status();
		switch ( $feature_id ) {
			case self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION:
				return $this->get_hpos_setting_for_feature( $sync_status );
			case DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION:
				return $this->get_hpos_setting_for_sync( $sync_status );
			case 'custom_order_tables':
				return array();
		}
	}

	/**
	 * Returns the HPOS setting for rendering HPOS vs Post setting block in Features section of the settings page.
	 *
	 * @param array $sync_status Details of sync status, includes pending count, and count when sync started.
	 *
	 * @return array Feature setting object.
	 */
	private function get_hpos_setting_for_feature( $sync_status ) {
		$hpos_enabled            = $this->custom_orders_table_usage_is_enabled();
		$plugin_info             = $this->features_controller->get_compatible_plugins_for_feature( 'custom_order_tables', true );
		$plugin_incompat_warning = $this->plugin_util->generate_incompatible_plugin_feature_warning( 'custom_order_tables', $plugin_info );
		$sync_complete           = 0 === $sync_status['current_pending_count'];
		$disabled_option         = array();
		// Changing something here? might also want to look at `enable|disable` functions in CLIRunner.
		if ( count( array_merge( $plugin_info['uncertain'], $plugin_info['incompatible'] ) ) > 0 ) {
			$disabled_option = array( 'yes' );
		}
		if ( ! $sync_complete ) {
			$disabled_option = array( 'yes', 'no' );
		}

		return array(
			'id'          => self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION,
			'title'       => __( 'Order data storage', 'woocommerce' ),
			'type'        => 'radio',
			'options'     => array(
				'no'  => __( 'WordPress posts storage (legacy)', 'woocommerce' ),
				'yes' => __( 'High-performance order storage (recommended)', 'woocommerce' ),
			),
			'value'       => $hpos_enabled ? 'yes' : 'no',
			'disabled'    => $disabled_option,
			'desc'        => $plugin_incompat_warning,
			'desc_at_end' => true,
			'row_class'   => self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION,
		);
	}

	/**
	 * Returns the setting for rendering sync enabling setting block in Features section of the settings page.
	 *
	 * @param array $sync_status Details of sync status, includes pending count, and count when sync started.
	 *
	 * @return array Feature setting object.
	 */
	private function get_hpos_setting_for_sync( $sync_status ) {
		$sync_in_progress = $this->batch_processing_controller->is_enqueued( get_class( $this->data_synchronizer ) );
		$sync_enabled     = get_option( DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION );
		$sync_message     = '';
		if ( $sync_in_progress && $sync_status['current_pending_count'] > 0 ) {
			$sync_message = sprintf(
				// translators: %d: number of pending orders.
				__( 'Currently syncing orders... %d pending', 'woocommerce' ),
				$sync_status['current_pending_count']
			);
		} elseif ( $sync_status['current_pending_count'] > 0 ) {
			$sync_message = sprintf(
				// translators: %d: number of pending orders.
				_n(
					'%d order pending to be synchronized. You can switch order data storage <strong>only when the posts and orders tables are in sync</strong>.',
					'%d orders pending to be synchronized. You can switch order data storage <strong>only when the posts and orders tables are in sync</strong>.',
					$sync_status['current_pending_count'],
					'woocommerce'
				),
				$sync_status['current_pending_count'],
			);
		}

		return array(
			'id'        => DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION,
			'title'     => '',
			'type'      => 'checkbox',
			'desc'      => __( 'Enable compatibility mode (synchronizes orders to the posts table).', 'woocommerce' ),
			'value'     => $sync_enabled,
			'desc_tip'  => $sync_message,
			'row_class' => DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION,
		);
	}
}
DataSynchronizer.php000064400000067331151547734010010565 0ustar00<?php
/**
 * DataSynchronizer class file.
 */

namespace Automattic\WooCommerce\Internal\DataStores\Orders;

use Automattic\WooCommerce\Caches\OrderCache;
use Automattic\WooCommerce\Caches\OrderCacheController;
use Automattic\WooCommerce\Database\Migrations\CustomOrderTable\PostsToOrdersMigrationController;
use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessorInterface;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
use Automattic\WooCommerce\Proxies\LegacyProxy;

defined( 'ABSPATH' ) || exit;

/**
 * This class handles the database structure creation and the data synchronization for the custom orders tables. Its responsibilites are:
 *
 * - Providing entry points for creating and deleting the required database tables.
 * - Synchronizing changes between the custom orders tables and the posts table whenever changes in orders happen.
 */
class DataSynchronizer implements BatchProcessorInterface {

	use AccessiblePrivateMethods;

	public const ORDERS_DATA_SYNC_ENABLED_OPTION           = 'woocommerce_custom_orders_table_data_sync_enabled';
	private const INITIAL_ORDERS_PENDING_SYNC_COUNT_OPTION = 'woocommerce_initial_orders_pending_sync_count';
	public const PLACEHOLDER_ORDER_POST_TYPE               = 'shop_order_placehold';

	public const DELETED_RECORD_META_KEY        = '_deleted_from';
	public const DELETED_FROM_POSTS_META_VALUE  = 'posts_table';
	public const DELETED_FROM_ORDERS_META_VALUE = 'orders_table';

	public const ORDERS_TABLE_CREATED = 'woocommerce_custom_orders_table_created';

	private const ORDERS_SYNC_BATCH_SIZE = 250;

	// Allowed values for $type in get_ids_of_orders_pending_sync method.
	public const ID_TYPE_MISSING_IN_ORDERS_TABLE   = 0;
	public const ID_TYPE_MISSING_IN_POSTS_TABLE    = 1;
	public const ID_TYPE_DIFFERENT_UPDATE_DATE     = 2;
	public const ID_TYPE_DELETED_FROM_ORDERS_TABLE = 3;
	public const ID_TYPE_DELETED_FROM_POSTS_TABLE  = 4;

	/**
	 * The data store object to use.
	 *
	 * @var OrdersTableDataStore
	 */
	private $data_store;

	/**
	 * The database util object to use.
	 *
	 * @var DatabaseUtil
	 */
	private $database_util;

	/**
	 * The posts to COT migrator to use.
	 *
	 * @var PostsToOrdersMigrationController
	 */
	private $posts_to_cot_migrator;

	/**
	 * Logger object to be used to log events.
	 *
	 * @var \WC_Logger
	 */
	private $error_logger;

	/**
	 * The order cache controller.
	 *
	 * @var OrderCacheController
	 */
	private $order_cache_controller;

	/**
	 * Class constructor.
	 */
	public function __construct() {
		self::add_action( 'deleted_post', array( $this, 'handle_deleted_post' ), 10, 2 );
		self::add_action( 'woocommerce_new_order', array( $this, 'handle_updated_order' ), 100 );
		self::add_action( 'woocommerce_refund_created', array( $this, 'handle_updated_order' ), 100 );
		self::add_action( 'woocommerce_update_order', array( $this, 'handle_updated_order' ), 100 );
		self::add_action( 'wp_scheduled_auto_draft_delete', array( $this, 'delete_auto_draft_orders' ), 9 );

		self::add_filter( 'woocommerce_feature_description_tip', array( $this, 'handle_feature_description_tip' ), 10, 3 );
	}

	/**
	 * Class initialization, invoked by the DI container.
	 *
	 * @param OrdersTableDataStore             $data_store The data store to use.
	 * @param DatabaseUtil                     $database_util The database util class to use.
	 * @param PostsToOrdersMigrationController $posts_to_cot_migrator The posts to COT migration class to use.
	 * @param LegacyProxy                      $legacy_proxy The legacy proxy instance to use.
	 * @param OrderCacheController             $order_cache_controller The order cache controller instance to use.
	 * @internal
	 */
	final public function init(
		OrdersTableDataStore $data_store,
		DatabaseUtil $database_util,
		PostsToOrdersMigrationController $posts_to_cot_migrator,
		LegacyProxy $legacy_proxy,
		OrderCacheController $order_cache_controller
	) {
		$this->data_store             = $data_store;
		$this->database_util          = $database_util;
		$this->posts_to_cot_migrator  = $posts_to_cot_migrator;
		$this->error_logger           = $legacy_proxy->call_function( 'wc_get_logger' );
		$this->order_cache_controller = $order_cache_controller;
	}

	/**
	 * Does the custom orders tables exist in the database?
	 *
	 * @return bool True if the custom orders tables exist in the database.
	 */
	public function check_orders_table_exists(): bool {
		$missing_tables = $this->database_util->get_missing_tables( $this->data_store->get_database_schema() );

		if ( count( $missing_tables ) === 0 ) {
			update_option( self::ORDERS_TABLE_CREATED, 'yes' );
			return true;
		} else {
			update_option( self::ORDERS_TABLE_CREATED, 'no' );
			return false;
		}
	}

	/**
	 * Returns the value of the orders table created option. If it's not set, then it checks the orders table and set it accordingly.
	 *
	 * @return bool Whether orders table exists.
	 */
	public function get_table_exists(): bool {
		$table_exists = get_option( self::ORDERS_TABLE_CREATED );
		switch ( $table_exists ) {
			case 'no':
			case 'yes':
				return 'yes' === $table_exists;
			default:
				return $this->check_orders_table_exists();
		}
	}

	/**
	 * Create the custom orders database tables.
	 */
	public function create_database_tables() {
		$this->database_util->dbdelta( $this->data_store->get_database_schema() );
		$success = $this->check_orders_table_exists();
		if ( ! $success ) {
			$missing_tables = $this->database_util->get_missing_tables( $this->data_store->get_database_schema() );
			$missing_tables = implode( ', ', $missing_tables );
			$this->error_logger->error( "HPOS tables are missing in the database and couldn't be created. The missing tables are: $missing_tables" );
		}
		return $success;
	}

	/**
	 * Delete the custom orders database tables.
	 */
	public function delete_database_tables() {
		$table_names = $this->data_store->get_all_table_names();

		foreach ( $table_names as $table_name ) {
			$this->database_util->drop_database_table( $table_name );
		}
		delete_option( self::ORDERS_TABLE_CREATED );
	}

	/**
	 * Is the data sync between old and new tables currently enabled?
	 *
	 * @return bool
	 */
	public function data_sync_is_enabled(): bool {
		return 'yes' === get_option( self::ORDERS_DATA_SYNC_ENABLED_OPTION );
	}

	/**
	 * Get the current sync process status.
	 * The information is meaningful only if pending_data_sync_is_in_progress return true.
	 *
	 * @return array
	 */
	public function get_sync_status() {
		return array(
			'initial_pending_count' => (int) get_option( self::INITIAL_ORDERS_PENDING_SYNC_COUNT_OPTION, 0 ),
			'current_pending_count' => $this->get_total_pending_count(),
		);
	}

	/**
	 * Get the total number of orders pending synchronization.
	 *
	 * @return int
	 */
	public function get_current_orders_pending_sync_count_cached() : int {
		return $this->get_current_orders_pending_sync_count( true );
	}

	/**
	 * Calculate how many orders need to be synchronized currently.
	 * A database query is performed to get how many orders match one of the following:
	 *
	 * - Existing in the authoritative table but not in the backup table.
	 * - Existing in both tables, but they have a different update date.
	 *
	 * @param bool $use_cache Whether to use the cached value instead of fetching from database.
	 */
	public function get_current_orders_pending_sync_count( $use_cache = false ): int {
		global $wpdb;

		if ( $use_cache ) {
			$pending_count = wp_cache_get( 'woocommerce_hpos_pending_sync_count' );
			if ( false !== $pending_count ) {
				return (int) $pending_count;
			}
		}

		$order_post_types = wc_get_order_types( 'cot-migration' );

		$order_post_type_placeholder = implode( ', ', array_fill( 0, count( $order_post_types ), '%s' ) );

		$orders_table = $this->data_store::get_orders_table_name();

		if ( empty( $order_post_types ) ) {
			$this->error_logger->debug(
				sprintf(
					/* translators: 1: method name. */
					esc_html__( '%1$s was called but no order types were registered: it may have been called too early.', 'woocommerce' ),
					__METHOD__
				)
			);

			return 0;
		}

		if ( ! $this->get_table_exists() ) {
			$count = $wpdb->get_var(
				// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $order_post_type_placeholder is prepared.
				$wpdb->prepare(
					"SELECT COUNT(*) FROM $wpdb->posts where post_type in ( $order_post_type_placeholder )",
					$order_post_types
				)
				// phpcs:enable
			);
			return $count;
		}

		if ( $this->custom_orders_table_is_authoritative() ) {
			$missing_orders_count_sql = $wpdb->prepare(
				"
SELECT COUNT(1) FROM $wpdb->posts posts
INNER JOIN $orders_table orders ON posts.id=orders.id
WHERE posts.post_type = '" . self::PLACEHOLDER_ORDER_POST_TYPE . "'
 AND orders.status not in ( 'auto-draft' )
 AND orders.type IN ($order_post_type_placeholder)",
				$order_post_types
			);
			$operator                 = '>';
		} else {
			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $order_post_type_placeholder is prepared.
			$missing_orders_count_sql = $wpdb->prepare(
				"
SELECT COUNT(1) FROM $wpdb->posts posts
LEFT JOIN $orders_table orders ON posts.id=orders.id
WHERE
  posts.post_type in ($order_post_type_placeholder)
  AND posts.post_status != 'auto-draft'
  AND orders.id IS NULL",
				$order_post_types
			);
			// phpcs:enable
			$operator = '<';
		}

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $missing_orders_count_sql is prepared.
		$sql = $wpdb->prepare(
			"
SELECT(
	($missing_orders_count_sql)
	+
	(SELECT COUNT(1) FROM (
		SELECT orders.id FROM $orders_table orders
		JOIN $wpdb->posts posts on posts.ID = orders.id
		WHERE
		  posts.post_type IN ($order_post_type_placeholder)
		  AND orders.date_updated_gmt $operator posts.post_modified_gmt
	) x)
) count",
			$order_post_types
		);
		// phpcs:enable

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
		$pending_count = (int) $wpdb->get_var( $sql );

		$deleted_from_table = $this->get_current_deletion_record_meta_value();

		$deleted_count  = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT count(1) FROM {$wpdb->prefix}wc_orders_meta WHERE meta_key=%s AND meta_value=%s",
				array( self::DELETED_RECORD_META_KEY, $deleted_from_table )
			)
		);
		$pending_count += $deleted_count;

		wp_cache_set( 'woocommerce_hpos_pending_sync_count', $pending_count );
		return $pending_count;
	}

	/**
	 * Get the meta value for order deletion records based on which table is currently authoritative.
	 *
	 * @return string self::DELETED_FROM_ORDERS_META_VALUE if the orders table is authoritative, self::DELETED_FROM_POSTS_META_VALUE otherwise.
	 */
	private function get_current_deletion_record_meta_value() {
		return $this->custom_orders_table_is_authoritative() ?
				self::DELETED_FROM_ORDERS_META_VALUE :
				self::DELETED_FROM_POSTS_META_VALUE;
	}

	/**
	 * Is the custom orders table the authoritative data source for orders currently?
	 *
	 * @return bool Whether the custom orders table the authoritative data source for orders currently.
	 */
	public function custom_orders_table_is_authoritative(): bool {
		return wc_string_to_bool( get_option( CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION ) );
	}

	/**
	 * Get a list of ids of orders than are out of sync.
	 *
	 * Valid values for $type are:
	 *
	 * ID_TYPE_MISSING_IN_ORDERS_TABLE: orders that exist in posts table but not in orders table.
	 * ID_TYPE_MISSING_IN_POSTS_TABLE: orders that exist in orders table but not in posts table (the corresponding post entries are placeholders).
	 * ID_TYPE_DIFFERENT_UPDATE_DATE: orders that exist in both tables but have different last update dates.
	 * ID_TYPE_DELETED_FROM_ORDERS_TABLE: orders deleted from the orders table but not yet from the posts table.
	 * ID_TYPE_DELETED_FROM_POSTS_TABLE: orders deleted from the posts table but not yet from the orders table.
	 *
	 * @param int $type One of ID_TYPE_MISSING_IN_ORDERS_TABLE, ID_TYPE_MISSING_IN_POSTS_TABLE, ID_TYPE_DIFFERENT_UPDATE_DATE.
	 * @param int $limit Maximum number of ids to return.
	 * @return array An array of order ids.
	 * @throws \Exception Invalid parameter.
	 */
	public function get_ids_of_orders_pending_sync( int $type, int $limit ) {
		global $wpdb;

		if ( $limit < 1 ) {
			throw new \Exception( '$limit must be at least 1' );
		}

		$orders_table                 = $this->data_store::get_orders_table_name();
		$order_post_types             = wc_get_order_types( 'cot-migration' );
		$order_post_type_placeholders = implode( ', ', array_fill( 0, count( $order_post_types ), '%s' ) );

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		switch ( $type ) {
			case self::ID_TYPE_MISSING_IN_ORDERS_TABLE:
				// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $order_post_type_placeholders is prepared.
				$sql = $wpdb->prepare(
					"
SELECT posts.ID FROM $wpdb->posts posts
LEFT JOIN $orders_table orders ON posts.ID = orders.id
WHERE
  posts.post_type IN ($order_post_type_placeholders)
  AND posts.post_status != 'auto-draft'
  AND orders.id IS NULL
ORDER BY posts.ID ASC",
					$order_post_types
				);
				// phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
				break;
			case self::ID_TYPE_MISSING_IN_POSTS_TABLE:
				$sql = $wpdb->prepare(
					"
SELECT posts.ID FROM $wpdb->posts posts
INNER JOIN $orders_table orders ON posts.id=orders.id
WHERE posts.post_type = '" . self::PLACEHOLDER_ORDER_POST_TYPE . "'
AND orders.status not in ( 'auto-draft' )
AND orders.type IN ($order_post_type_placeholders)
ORDER BY posts.id ASC",
					$order_post_types
				);
				break;
			case self::ID_TYPE_DIFFERENT_UPDATE_DATE:
				$operator = $this->custom_orders_table_is_authoritative() ? '>' : '<';
				// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $order_post_type_placeholders is prepared.
				$sql = $wpdb->prepare(
					"
SELECT orders.id FROM $orders_table orders
JOIN $wpdb->posts posts on posts.ID = orders.id
WHERE
  posts.post_type IN ($order_post_type_placeholders)
  AND orders.date_updated_gmt $operator posts.post_modified_gmt
ORDER BY orders.id ASC
",
					$order_post_types
				);
				// phpcs:enable
				break;
			case self::ID_TYPE_DELETED_FROM_ORDERS_TABLE:
				return $this->get_deleted_order_ids( true, $limit );
			case self::ID_TYPE_DELETED_FROM_POSTS_TABLE:
				return $this->get_deleted_order_ids( false, $limit );
			default:
				throw new \Exception( 'Invalid $type, must be one of the ID_TYPE_... constants.' );
		}
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		// phpcs:ignore WordPress.DB
		return array_map( 'intval', $wpdb->get_col( $sql . " LIMIT $limit" ) );
	}

	/**
	 * Get the ids of the orders that are marked as deleted in the orders meta table.
	 *
	 * @param bool $deleted_from_orders_table True to get the ids of the orders deleted from the orders table, false o get the ids of the orders deleted from the posts table.
	 * @param int  $limit The maximum count of orders to return.
	 * @return array An array of order ids.
	 */
	private function get_deleted_order_ids( bool $deleted_from_orders_table, int $limit ) {
		global $wpdb;

		$deleted_from_table = $this->get_current_deletion_record_meta_value();

		$order_ids = $wpdb->get_col(
			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$wpdb->prepare(
				"SELECT DISTINCT(order_id) FROM {$wpdb->prefix}wc_orders_meta WHERE meta_key=%s AND meta_value=%s LIMIT {$limit}",
				self::DELETED_RECORD_META_KEY,
				$deleted_from_table
			)
			// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		);

		return array_map( 'absint', $order_ids );
	}

	/**
	 * Cleanup all the synchronization status information,
	 * because the process has been disabled by the user via settings,
	 * or because there's nothing left to synchronize.
	 */
	public function cleanup_synchronization_state() {
		delete_option( self::INITIAL_ORDERS_PENDING_SYNC_COUNT_OPTION );
	}

	/**
	 * Process data for current batch.
	 *
	 * @param array $batch Batch details.
	 */
	public function process_batch( array $batch ) : void {
		if ( empty( $batch ) ) {
			return;
		}

		$batch = array_map( 'absint', $batch );

		$this->order_cache_controller->temporarily_disable_orders_cache_usage();

		$custom_orders_table_is_authoritative = $this->custom_orders_table_is_authoritative();
		$deleted_order_ids                    = $this->process_deleted_orders( $batch, $custom_orders_table_is_authoritative );
		$batch                                = array_diff( $batch, $deleted_order_ids );

		if ( ! empty( $batch ) ) {
			if ( $custom_orders_table_is_authoritative ) {
				foreach ( $batch as $id ) {
					$order = wc_get_order( $id );
					if ( ! $order ) {
						$this->error_logger->error( "Order $id not found during batch process, skipping." );
						continue;
					}
					$data_store = $order->get_data_store();
					$data_store->backfill_post_record( $order );
				}
			} else {
				$this->posts_to_cot_migrator->migrate_orders( $batch );
			}
		}

		if ( 0 === $this->get_total_pending_count() ) {
			$this->cleanup_synchronization_state();
			$this->order_cache_controller->maybe_restore_orders_cache_usage();
		}
	}

	/**
	 * Take a batch of order ids pending synchronization and process those that were deleted, ignoring the others
	 * (which will be orders that were created or modified) and returning the ids of the orders actually processed.
	 *
	 * @param array $batch Array of ids of order pending synchronization.
	 * @param bool  $custom_orders_table_is_authoritative True if the custom orders table is currently authoritative.
	 * @return array Order ids that have been actually processed.
	 */
	private function process_deleted_orders( array $batch, bool $custom_orders_table_is_authoritative ): array {
		global $wpdb;

		$deleted_from_table_name = $this->get_current_deletion_record_meta_value();

		$data_store_for_deletion =
			$custom_orders_table_is_authoritative ?
			new \WC_Order_Data_Store_CPT() :
			wc_get_container()->get( OrdersTableDataStore::class );

		$order_ids_as_sql_list = '(' . implode( ',', $batch ) . ')';

		$deleted_order_ids  = array();
		$meta_ids_to_delete = array();

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$deletion_data = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT id, order_id FROM {$wpdb->prefix}wc_orders_meta WHERE meta_key=%s AND meta_value=%s AND order_id IN $order_ids_as_sql_list ORDER BY order_id DESC",
				self::DELETED_RECORD_META_KEY,
				$deleted_from_table_name
			),
			ARRAY_A
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		if ( empty( $deletion_data ) ) {
			return array();
		}

		foreach ( $deletion_data as $item ) {
			$meta_id  = $item['id'];
			$order_id = $item['order_id'];

			if ( isset( $deleted_order_ids[ $order_id ] ) ) {
				$meta_ids_to_delete[] = $meta_id;
				continue;
			}

			if ( ! $data_store_for_deletion->order_exists( $order_id ) ) {
				$this->error_logger->warning( "Order {$order_id} doesn't exist in the backup table, thus it can't be deleted" );
				$deleted_order_ids[]  = $order_id;
				$meta_ids_to_delete[] = $meta_id;
				continue;
			}

			try {
				$order = new \WC_Order();
				$order->set_id( $order_id );
				$data_store_for_deletion->read( $order );

				$data_store_for_deletion->delete(
					$order,
					array(
						'force_delete'     => true,
						'suppress_filters' => true,
					)
				);
			} catch ( \Exception $ex ) {
				$this->error_logger->error( "Couldn't delete order {$order_id} from the backup table: {$ex->getMessage()}" );
				continue;
			}

			$deleted_order_ids[]  = $order_id;
			$meta_ids_to_delete[] = $meta_id;
		}

		if ( ! empty( $meta_ids_to_delete ) ) {
			$order_id_rows_as_sql_list = '(' . implode( ',', $meta_ids_to_delete ) . ')';
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$wpdb->query( "DELETE FROM {$wpdb->prefix}wc_orders_meta WHERE id IN {$order_id_rows_as_sql_list}" );
		}

		return $deleted_order_ids;
	}

	/**
	 * Get total number of pending records that require update.
	 *
	 * @return int Number of pending records.
	 */
	public function get_total_pending_count(): int {
		return $this->get_current_orders_pending_sync_count();
	}

	/**
	 * Returns the batch with records that needs to be processed for a given size.
	 *
	 * @param int $size Size of the batch.
	 *
	 * @return array Batch of records.
	 */
	public function get_next_batch_to_process( int $size ): array {
		$orders_table_is_authoritative = $this->custom_orders_table_is_authoritative();

		$order_ids = $this->get_ids_of_orders_pending_sync(
			$orders_table_is_authoritative ? self::ID_TYPE_MISSING_IN_POSTS_TABLE : self::ID_TYPE_MISSING_IN_ORDERS_TABLE,
			$size
		);
		if ( count( $order_ids ) >= $size ) {
			return $order_ids;
		}

		$updated_order_ids = $this->get_ids_of_orders_pending_sync( self::ID_TYPE_DIFFERENT_UPDATE_DATE, $size - count( $order_ids ) );
		$order_ids         = array_merge( $order_ids, $updated_order_ids );
		if ( count( $order_ids ) >= $size ) {
			return $order_ids;
		}

		$deleted_order_ids = $this->get_ids_of_orders_pending_sync(
			$orders_table_is_authoritative ? self::ID_TYPE_DELETED_FROM_ORDERS_TABLE : self::ID_TYPE_DELETED_FROM_POSTS_TABLE,
			$size - count( $order_ids )
		);
		$order_ids         = array_merge( $order_ids, $deleted_order_ids );

		return array_map( 'absint', $order_ids );
	}

	/**
	 * Default batch size to use.
	 *
	 * @return int Default batch size.
	 */
	public function get_default_batch_size(): int {
		$batch_size = self::ORDERS_SYNC_BATCH_SIZE;

		if ( $this->custom_orders_table_is_authoritative() ) {
			// Back-filling is slower than migration.
			$batch_size = absint( self::ORDERS_SYNC_BATCH_SIZE / 10 ) + 1;
		}
		/**
		 * Filter to customize the count of orders that will be synchronized in each step of the custom orders table to/from posts table synchronization process.
		 *
		 * @since 6.6.0
		 *
		 * @param int Default value for the count.
		 */
		return apply_filters( 'woocommerce_orders_cot_and_posts_sync_step_size', $batch_size );
	}

	/**
	 * A user friendly name for this process.
	 *
	 * @return string Name of the process.
	 */
	public function get_name(): string {
		return 'Order synchronizer';
	}

	/**
	 * A user friendly description for this process.
	 *
	 * @return string Description.
	 */
	public function get_description(): string {
		return 'Synchronizes orders between posts and custom order tables.';
	}

	/**
	 * Handle the 'deleted_post' action.
	 *
	 * When posts is authoritative and sync is enabled, deleting a post also deletes COT data.
	 *
	 * @param int     $postid The post id.
	 * @param WP_Post $post The deleted post.
	 */
	private function handle_deleted_post( $postid, $post ): void {
		global $wpdb;

		$order_post_types = wc_get_order_types( 'cot-migration' );
		if ( ! in_array( $post->post_type, $order_post_types, true ) ) {
			return;
		}

		if ( ! $this->get_table_exists() ) {
			return;
		}

		if ( $this->data_sync_is_enabled() ) {
			$this->data_store->delete_order_data_from_custom_order_tables( $postid );
		} elseif ( $this->custom_orders_table_is_authoritative() ) {
			return;
		}

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.SlowDBQuery
		if ( $wpdb->get_var(
			$wpdb->prepare(
				"SELECT EXISTS (SELECT id FROM {$this->data_store::get_orders_table_name()} WHERE ID=%d)
						AND NOT EXISTS (SELECT order_id FROM {$this->data_store::get_meta_table_name()} WHERE order_id=%d AND meta_key=%s AND meta_value=%s)",
				$postid,
				$postid,
				self::DELETED_RECORD_META_KEY,
				self::DELETED_FROM_POSTS_META_VALUE
			)
		)
		) {
			$wpdb->insert(
				$this->data_store::get_meta_table_name(),
				array(
					'order_id'   => $postid,
					'meta_key'   => self::DELETED_RECORD_META_KEY,
					'meta_value' => self::DELETED_FROM_POSTS_META_VALUE,
				)
			);
		}
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.SlowDBQuery
	}

	/**
	 * Handle the 'woocommerce_update_order' action.
	 *
	 * When posts is authoritative and sync is enabled, updating a post triggers a corresponding change in the COT table.
	 *
	 * @param int $order_id The order id.
	 */
	private function handle_updated_order( $order_id ): void {
		if ( ! $this->custom_orders_table_is_authoritative() && $this->data_sync_is_enabled() ) {
			$this->posts_to_cot_migrator->migrate_orders( array( $order_id ) );
		}
	}

	/**
	 * Handles deletion of auto-draft orders in sync with WP's own auto-draft deletion.
	 *
	 * @since 7.7.0
	 *
	 * @return void
	 */
	private function delete_auto_draft_orders() {
		if ( ! $this->custom_orders_table_is_authoritative() ) {
			return;
		}

		// Fetch auto-draft orders older than 1 week.
		$to_delete = wc_get_orders(
			array(
				'date_query' => array(
					array(
						'column' => 'date_created',
						'before' => '-1 week',
					),
				),
				'orderby'    => 'date',
				'order'      => 'ASC',
				'status'     => 'auto-draft',
			)
		);

		foreach ( $to_delete as $order ) {
			$order->delete( true );
		}

		/**
		 * Fires after schedueld deletion of auto-draft orders has been completed.
		 *
		 * @since 7.7.0
		 */
		do_action( 'woocommerce_scheduled_auto_draft_delete' );
	}

	/**
	 * Handle the 'woocommerce_feature_description_tip' filter.
	 *
	 * When the COT feature is enabled and there are orders pending sync (in either direction),
	 * show a "you should ync before disabling" warning under the feature in the features page.
	 * Skip this if the UI prevents changing the feature enable status.
	 *
	 * @param string $desc_tip The original description tip for the feature.
	 * @param string $feature_id The feature id.
	 * @param bool   $ui_disabled True if the UI doesn't allow to enable or disable the feature.
	 * @return string The new description tip for the feature.
	 */
	private function handle_feature_description_tip( $desc_tip, $feature_id, $ui_disabled ): string {
		if ( 'custom_order_tables' !== $feature_id || $ui_disabled ) {
			return $desc_tip;
		}

		$features_controller = wc_get_container()->get( FeaturesController::class );
		$feature_is_enabled  = $features_controller->feature_is_enabled( 'custom_order_tables' );
		if ( ! $feature_is_enabled ) {
			return $desc_tip;
		}

		$pending_sync_count = $this->get_current_orders_pending_sync_count();
		if ( ! $pending_sync_count ) {
			return $desc_tip;
		}

		if ( $this->custom_orders_table_is_authoritative() ) {
			$extra_tip = sprintf(
				_n(
					"âš  There's one order pending sync from the orders table to the posts table. The feature shouldn't be disabled until this order is synchronized.",
					"âš  There are %1\$d orders pending sync from the orders table to the posts table. The feature shouldn't be disabled until these orders are synchronized.",
					$pending_sync_count,
					'woocommerce'
				),
				$pending_sync_count
			);
		} else {
			$extra_tip = sprintf(
				_n(
					"âš  There's one order pending sync from the posts table to the orders table. The feature shouldn't be disabled until this order is synchronized.",
					"âš  There are %1\$d orders pending sync from the posts table to the orders table. The feature shouldn't be disabled until these orders are synchronized.",
					$pending_sync_count,
					'woocommerce'
				),
				$pending_sync_count
			);
		}

		$cot_settings_url = add_query_arg(
			array(
				'page'    => 'wc-settings',
				'tab'     => 'advanced',
				'section' => 'custom_data_stores',
			),
			admin_url( 'admin.php' )
		);

		/* translators: %s = URL of the custom data stores settings page */
		$manage_cot_settings_link = sprintf( __( "<a href='%s'>Manage orders synchronization</a>", 'woocommerce' ), $cot_settings_url );

		return $desc_tip ? "{$desc_tip}<br/>{$extra_tip} {$manage_cot_settings_link}" : "{$extra_tip} {$manage_cot_settings_link}";
	}
}
OrdersTableDataStore.php000064400000273414151547734010011314 0ustar00<?php
/**
 * OrdersTableDataStore class file.
 */

namespace Automattic\WooCommerce\Internal\DataStores\Orders;

use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Caches\OrderCache;
use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\ArrayUtil;
use Exception;
use WC_Abstract_Order;
use WC_Data;
use WC_Order;

defined( 'ABSPATH' ) || exit;

/**
 * This class is the standard data store to be used when the custom orders table is in use.
 */
class OrdersTableDataStore extends \Abstract_WC_Order_Data_Store_CPT implements \WC_Object_Data_Store_Interface, \WC_Order_Data_Store_Interface {

	/**
	 * Order IDs for which we are checking sync on read in the current request. In WooCommerce, using wc_get_order is a very common pattern, to avoid performance issues, we only sync on read once per request per order. This works because we consider out of sync orders to be an anomaly, so we don't recommend running HPOS with incompatible plugins.
	 *
	 * @var array.
	 */
	private static $reading_order_ids = array();

	/**
	 * Keep track of order IDs that are actively being backfilled. We use this to prevent further read on sync from add_|update_|delete_postmeta etc hooks. If we allow this, then we would end up syncing the same order multiple times as it is being backfilled.
	 *
	 * @var array
	 */
	private static $backfilling_order_ids = array();

	/**
	 * Data stored in meta keys, but not considered "meta" for an order.
	 *
	 * @since 7.0.0
	 * @var array
	 */
	protected $internal_meta_keys = array(
		'_customer_user',
		'_order_key',
		'_order_currency',
		'_billing_first_name',
		'_billing_last_name',
		'_billing_company',
		'_billing_address_1',
		'_billing_address_2',
		'_billing_city',
		'_billing_state',
		'_billing_postcode',
		'_billing_country',
		'_billing_email',
		'_billing_phone',
		'_shipping_first_name',
		'_shipping_last_name',
		'_shipping_company',
		'_shipping_address_1',
		'_shipping_address_2',
		'_shipping_city',
		'_shipping_state',
		'_shipping_postcode',
		'_shipping_country',
		'_shipping_phone',
		'_completed_date',
		'_paid_date',
		'_edit_last',
		'_cart_discount',
		'_cart_discount_tax',
		'_order_shipping',
		'_order_shipping_tax',
		'_order_tax',
		'_order_total',
		'_payment_method',
		'_payment_method_title',
		'_transaction_id',
		'_customer_ip_address',
		'_customer_user_agent',
		'_created_via',
		'_order_version',
		'_prices_include_tax',
		'_date_completed',
		'_date_paid',
		'_payment_tokens',
		'_billing_address_index',
		'_shipping_address_index',
		'_recorded_sales',
		'_recorded_coupon_usage_counts',
		'_download_permissions_granted',
		'_order_stock_reduced',
		'_new_order_email_sent',
	);

	/**
	 * Handles custom metadata in the wc_orders_meta table.
	 *
	 * @var OrdersTableDataStoreMeta
	 */
	protected $data_store_meta;

	/**
	 * The database util object to use.
	 *
	 * @var DatabaseUtil
	 */
	protected $database_util;

	/**
	 * The posts data store object to use.
	 *
	 * @var \WC_Order_Data_Store_CPT
	 */
	private $cpt_data_store;

	/**
	 * Logger object to be used to log events.
	 *
	 * @var \WC_Logger
	 */
	private $error_logger;

	/**
	 * The name of the main orders table.
	 *
	 * @var string
	 */
	private $orders_table_name;

	/**
	 * The instance of the LegacyProxy object to use.
	 *
	 * @var LegacyProxy
	 */
	private $legacy_proxy;

	/**
	 * Initialize the object.
	 *
	 * @internal
	 * @param OrdersTableDataStoreMeta $data_store_meta Metadata instance.
	 * @param DatabaseUtil             $database_util   The database util instance to use.
	 * @param LegacyProxy              $legacy_proxy    The legacy proxy instance to use.
	 *
	 * @return void
	 */
	final public function init( OrdersTableDataStoreMeta $data_store_meta, DatabaseUtil $database_util, LegacyProxy $legacy_proxy ) {
		$this->data_store_meta    = $data_store_meta;
		$this->database_util      = $database_util;
		$this->legacy_proxy       = $legacy_proxy;
		$this->error_logger       = $legacy_proxy->call_function( 'wc_get_logger' );
		$this->internal_meta_keys = $this->get_internal_meta_keys();

		$this->orders_table_name = self::get_orders_table_name();
	}

	/**
	 * Get the custom orders table name.
	 *
	 * @return string The custom orders table name.
	 */
	public static function get_orders_table_name() {
		global $wpdb;

		return $wpdb->prefix . 'wc_orders';
	}

	/**
	 * Get the order addresses table name.
	 *
	 * @return string The order addresses table name.
	 */
	public static function get_addresses_table_name() {
		global $wpdb;

		return $wpdb->prefix . 'wc_order_addresses';
	}

	/**
	 * Get the orders operational data table name.
	 *
	 * @return string The orders operational data table name.
	 */
	public static function get_operational_data_table_name() {
		global $wpdb;

		return $wpdb->prefix . 'wc_order_operational_data';
	}

	/**
	 * Get the orders meta data table name.
	 *
	 * @return string Name of order meta data table.
	 */
	public static function get_meta_table_name() {
		global $wpdb;

		return $wpdb->prefix . 'wc_orders_meta';
	}

	/**
	 * Get the names of all the tables involved in the custom orders table feature.
	 *
	 * See also : get_all_table_names_with_id.
	 *
	 * @return string[]
	 */
	public function get_all_table_names() {
		return array(
			$this->get_orders_table_name(),
			$this->get_addresses_table_name(),
			$this->get_operational_data_table_name(),
			$this->get_meta_table_name(),
		);
	}

	/**
	 * Similar to get_all_table_names, but also returns the table name along with the items table.
	 *
	 * @return array Names of the tables.
	 */
	public static function get_all_table_names_with_id() {
		global $wpdb;
		return array(
			'orders'           => self::get_orders_table_name(),
			'addresses'        => self::get_addresses_table_name(),
			'operational_data' => self::get_operational_data_table_name(),
			'meta'             => self::get_meta_table_name(),
			'items'            => $wpdb->prefix . 'woocommerce_order_items',
		);
	}

	/**
	 * Table column to WC_Order mapping for wc_orders table.
	 *
	 * @var \string[][]
	 */
	protected $order_column_mapping = array(
		'id'                   => array(
			'type' => 'int',
			'name' => 'id',
		),
		'status'               => array(
			'type' => 'string',
			'name' => 'status',
		),
		'type'                 => array(
			'type' => 'string',
			'name' => 'type',
		),
		'currency'             => array(
			'type' => 'string',
			'name' => 'currency',
		),
		'tax_amount'           => array(
			'type' => 'decimal',
			'name' => 'cart_tax',
		),
		'total_amount'         => array(
			'type' => 'decimal',
			'name' => 'total',
		),
		'customer_id'          => array(
			'type' => 'int',
			'name' => 'customer_id',
		),
		'billing_email'        => array(
			'type' => 'string',
			'name' => 'billing_email',
		),
		'date_created_gmt'     => array(
			'type' => 'date',
			'name' => 'date_created',
		),
		'date_updated_gmt'     => array(
			'type' => 'date',
			'name' => 'date_modified',
		),
		'parent_order_id'      => array(
			'type' => 'int',
			'name' => 'parent_id',
		),
		'payment_method'       => array(
			'type' => 'string',
			'name' => 'payment_method',
		),
		'payment_method_title' => array(
			'type' => 'string',
			'name' => 'payment_method_title',
		),
		'ip_address'           => array(
			'type' => 'string',
			'name' => 'customer_ip_address',
		),
		'transaction_id'       => array(
			'type' => 'string',
			'name' => 'transaction_id',
		),
		'user_agent'           => array(
			'type' => 'string',
			'name' => 'customer_user_agent',
		),
		'customer_note'        => array(
			'type' => 'string',
			'name' => 'customer_note',
		),
	);

	/**
	 * Table column to WC_Order mapping for billing addresses in wc_address table.
	 *
	 * @var \string[][]
	 */
	protected $billing_address_column_mapping = array(
		'id'           => array( 'type' => 'int' ),
		'order_id'     => array( 'type' => 'int' ),
		'address_type' => array( 'type' => 'string' ),
		'first_name'   => array(
			'type' => 'string',
			'name' => 'billing_first_name',
		),
		'last_name'    => array(
			'type' => 'string',
			'name' => 'billing_last_name',
		),
		'company'      => array(
			'type' => 'string',
			'name' => 'billing_company',
		),
		'address_1'    => array(
			'type' => 'string',
			'name' => 'billing_address_1',
		),
		'address_2'    => array(
			'type' => 'string',
			'name' => 'billing_address_2',
		),
		'city'         => array(
			'type' => 'string',
			'name' => 'billing_city',
		),
		'state'        => array(
			'type' => 'string',
			'name' => 'billing_state',
		),
		'postcode'     => array(
			'type' => 'string',
			'name' => 'billing_postcode',
		),
		'country'      => array(
			'type' => 'string',
			'name' => 'billing_country',
		),
		'email'        => array(
			'type' => 'string',
			'name' => 'billing_email',
		),
		'phone'        => array(
			'type' => 'string',
			'name' => 'billing_phone',
		),
	);

	/**
	 * Table column to WC_Order mapping for shipping addresses in wc_address table.
	 *
	 * @var \string[][]
	 */
	protected $shipping_address_column_mapping = array(
		'id'           => array( 'type' => 'int' ),
		'order_id'     => array( 'type' => 'int' ),
		'address_type' => array( 'type' => 'string' ),
		'first_name'   => array(
			'type' => 'string',
			'name' => 'shipping_first_name',
		),
		'last_name'    => array(
			'type' => 'string',
			'name' => 'shipping_last_name',
		),
		'company'      => array(
			'type' => 'string',
			'name' => 'shipping_company',
		),
		'address_1'    => array(
			'type' => 'string',
			'name' => 'shipping_address_1',
		),
		'address_2'    => array(
			'type' => 'string',
			'name' => 'shipping_address_2',
		),
		'city'         => array(
			'type' => 'string',
			'name' => 'shipping_city',
		),
		'state'        => array(
			'type' => 'string',
			'name' => 'shipping_state',
		),
		'postcode'     => array(
			'type' => 'string',
			'name' => 'shipping_postcode',
		),
		'country'      => array(
			'type' => 'string',
			'name' => 'shipping_country',
		),
		'email'        => array( 'type' => 'string' ),
		'phone'        => array(
			'type' => 'string',
			'name' => 'shipping_phone',
		),
	);

	/**
	 * Table column to WC_Order mapping for wc_operational_data table.
	 *
	 * @var \string[][]
	 */
	protected $operational_data_column_mapping = array(
		'id'                          => array( 'type' => 'int' ),
		'order_id'                    => array( 'type' => 'int' ),
		'created_via'                 => array(
			'type' => 'string',
			'name' => 'created_via',
		),
		'woocommerce_version'         => array(
			'type' => 'string',
			'name' => 'version',
		),
		'prices_include_tax'          => array(
			'type' => 'bool',
			'name' => 'prices_include_tax',
		),
		'coupon_usages_are_counted'   => array(
			'type' => 'bool',
			'name' => 'recorded_coupon_usage_counts',
		),
		'download_permission_granted' => array(
			'type' => 'bool',
			'name' => 'download_permissions_granted',
		),
		'cart_hash'                   => array(
			'type' => 'string',
			'name' => 'cart_hash',
		),
		'new_order_email_sent'        => array(
			'type' => 'bool',
			'name' => 'new_order_email_sent',
		),
		'order_key'                   => array(
			'type' => 'string',
			'name' => 'order_key',
		),
		'order_stock_reduced'         => array(
			'type' => 'bool',
			'name' => 'order_stock_reduced',
		),
		'date_paid_gmt'               => array(
			'type' => 'date',
			'name' => 'date_paid',
		),
		'date_completed_gmt'          => array(
			'type' => 'date',
			'name' => 'date_completed',
		),
		'shipping_tax_amount'         => array(
			'type' => 'decimal',
			'name' => 'shipping_tax',
		),
		'shipping_total_amount'       => array(
			'type' => 'decimal',
			'name' => 'shipping_total',
		),
		'discount_tax_amount'         => array(
			'type' => 'decimal',
			'name' => 'discount_tax',
		),
		'discount_total_amount'       => array(
			'type' => 'decimal',
			'name' => 'discount_total',
		),
		'recorded_sales'              => array(
			'type' => 'bool',
			'name' => 'recorded_sales',
		),
	);

	/**
	 * Cache variable to store combined mapping.
	 *
	 * @var array[][][]
	 */
	private $all_order_column_mapping;

	/**
	 * Return combined mappings for all order tables.
	 *
	 * @return array|\array[][][] Return combined mapping.
	 */
	public function get_all_order_column_mappings() {
		if ( ! isset( $this->all_order_column_mapping ) ) {
			$this->all_order_column_mapping = array(
				'orders'           => $this->order_column_mapping,
				'billing_address'  => $this->billing_address_column_mapping,
				'shipping_address' => $this->shipping_address_column_mapping,
				'operational_data' => $this->operational_data_column_mapping,
			);
		}

		return $this->all_order_column_mapping;
	}

	/**
	 * Helper function to get alias for order table, this is used in select query.
	 *
	 * @return string Alias.
	 */
	private function get_order_table_alias() : string {
		return 'o';
	}

	/**
	 * Helper function to get alias for op table, this is used in select query.
	 *
	 * @return string Alias.
	 */
	private function get_op_table_alias() : string {
		return 'p';
	}

	/**
	 * Helper function to get alias for address table, this is used in select query.
	 *
	 * @param string $type Type of address; 'billing' or 'shipping'.
	 *
	 * @return string Alias.
	 */
	private function get_address_table_alias( string $type ) : string {
		return 'billing' === $type ? 'b' : 's';
	}

	/**
	 * Helper method to get a CPT data store instance to use.
	 *
	 * @return \WC_Order_Data_Store_CPT Data store instance.
	 */
	public function get_cpt_data_store_instance() {
		if ( ! isset( $this->cpt_data_store ) ) {
			$this->cpt_data_store = $this->get_post_data_store_for_backfill();
		}
		return $this->cpt_data_store;
	}


	/**
	 * Returns data store object to use backfilling.
	 *
	 * @return \Abstract_WC_Order_Data_Store_CPT
	 */
	protected function get_post_data_store_for_backfill() {
		return new \WC_Order_Data_Store_CPT();
	}

	/**
	 * Backfills order details in to WP_Post DB. Uses WC_Order_Data_store_CPT.
	 *
	 * @param \WC_Abstract_Order $order Order object to backfill.
	 */
	public function backfill_post_record( $order ) {
		$cpt_data_store = $this->get_post_data_store_for_backfill();
		if ( is_null( $cpt_data_store ) || ! method_exists( $cpt_data_store, 'update_order_from_object' ) ) {
			return;
		}

		self::$backfilling_order_ids[] = $order->get_id();
		$this->update_order_meta_from_object( $order );
		$order_class = get_class( $order );
		$post_order  = new $order_class();
		$post_order->set_id( $order->get_id() );
		$cpt_data_store->read( $post_order );

		// This compares the order data to the post data and set changes array for props that are changed.
		$post_order->set_props( $order->get_data() );

		$cpt_data_store->update_order_from_object( $post_order );

		foreach ( $cpt_data_store->get_internal_data_store_key_getters() as $key => $getter_name ) {
			if (
				is_callable( array( $cpt_data_store, "set_$getter_name" ) ) &&
				is_callable( array( $this, "get_$getter_name" ) )
			) {
				call_user_func_array(
					array(
						$cpt_data_store,
						"set_$getter_name",
					),
					array(
						$order,
						$this->{"get_$getter_name"}( $order ),
					)
				);
			}
		}
		self::$backfilling_order_ids = array_diff( self::$backfilling_order_ids, array( $order->get_id() ) );
	}

	/**
	 * Get information about whether permissions are granted yet.
	 *
	 * @param \WC_Order $order Order object.
	 *
	 * @return bool Whether permissions are granted.
	 */
	public function get_download_permissions_granted( $order ) {
		$order_id = is_int( $order ) ? $order : $order->get_id();
		$order    = wc_get_order( $order_id );
		return $order->get_download_permissions_granted();
	}

	/**
	 * Stores information about whether permissions were generated yet.
	 *
	 * @param \WC_Order $order Order ID or order object.
	 * @param bool      $set True or false.
	 */
	public function set_download_permissions_granted( $order, $set ) {
		if ( is_int( $order ) ) {
			$order = wc_get_order( $order );
		}
		$order->set_download_permissions_granted( $set );
		$order->save();
	}

	/**
	 * Gets information about whether sales were recorded.
	 *
	 * @param \WC_Order $order Order object.
	 *
	 * @return bool Whether sales are recorded.
	 */
	public function get_recorded_sales( $order ) {
		$order_id = is_int( $order ) ? $order : $order->get_id();
		$order    = wc_get_order( $order_id );
		return $order->get_recorded_sales();
	}

	/**
	 * Stores information about whether sales were recorded.
	 *
	 * @param \WC_Order $order Order object.
	 * @param bool      $set True or false.
	 */
	public function set_recorded_sales( $order, $set ) {
		if ( is_int( $order ) ) {
			$order = wc_get_order( $order );
		}
		$order->set_recorded_sales( $set );
		$order->save();
	}

	/**
	 * Gets information about whether coupon counts were updated.
	 *
	 * @param \WC_Order $order Order object.
	 *
	 * @return bool Whether coupon counts were updated.
	 */
	public function get_recorded_coupon_usage_counts( $order ) {
		$order_id = is_int( $order ) ? $order : $order->get_id();
		$order    = wc_get_order( $order_id );
		return $order->get_recorded_coupon_usage_counts();
	}

	/**
	 * Stores information about whether coupon counts were updated.
	 *
	 * @param \WC_Order $order Order object.
	 * @param bool      $set True or false.
	 */
	public function set_recorded_coupon_usage_counts( $order, $set ) {
		if ( is_int( $order ) ) {
			$order = wc_get_order( $order );
		}
		$order->set_recorded_coupon_usage_counts( $set );
		$order->save();
	}

	/**
	 * Whether email have been sent for this order.
	 *
	 * @param \WC_Order|int $order Order object.
	 *
	 * @return bool Whether email is sent.
	 */
	public function get_email_sent( $order ) {
		$order_id = is_int( $order ) ? $order : $order->get_id();
		$order    = wc_get_order( $order_id );
		return $order->get_new_order_email_sent();
	}

	/**
	 * Stores information about whether email was sent.
	 *
	 * @param \WC_Order $order Order object.
	 * @param bool      $set True or false.
	 */
	public function set_email_sent( $order, $set ) {
		if ( is_int( $order ) ) {
			$order = wc_get_order( $order );
		}
		$order->set_new_order_email_sent( $set );
		$order->save();
	}

	/**
	 * Helper setter for email_sent.
	 *
	 * @param \WC_Order $order Order object.
	 *
	 * @return bool Whether email was sent.
	 */
	public function get_new_order_email_sent( $order ) {
		return $this->get_email_sent( $order );
	}

	/**
	 * Helper setter for new order email sent.
	 *
	 * @param \WC_Order $order Order object.
	 * @param bool      $set True or false.
	 */
	public function set_new_order_email_sent( $order, $set ) {
		if ( is_int( $order ) ) {
			$order = wc_get_order( $order );
		}
		$order->set_new_order_email_sent( $set );
		$order->save();
	}

	/**
	 * Gets information about whether stock was reduced.
	 *
	 * @param \WC_Order $order Order object.
	 *
	 * @return bool Whether stock was reduced.
	 */
	public function get_stock_reduced( $order ) {
		$order_id = is_int( $order ) ? $order : $order->get_id();
		$order    = wc_get_order( $order_id );
		return $order->get_order_stock_reduced();
	}

	/**
	 * Stores information about whether stock was reduced.
	 *
	 * @param \WC_Order $order Order ID or order object.
	 * @param bool      $set True or false.
	 */
	public function set_stock_reduced( $order, $set ) {
		if ( is_int( $order ) ) {
			$order = wc_get_order( $order );
		}
		$order->set_order_stock_reduced( $set );
		$order->save();
	}

	/**
	 * Helper getter for `order_stock_reduced`.
	 *
	 * @param \WC_Order $order Order object.
	 * @return bool Whether stock was reduced.
	 */
	public function get_order_stock_reduced( $order ) {
		return $this->get_stock_reduced( $order );
	}

	/**
	 * Helper setter for `order_stock_reduced`.
	 *
	 * @param \WC_Order $order Order ID or order object.
	 * @param bool      $set Whether stock was reduced.
	 */
	public function set_order_stock_reduced( $order, $set ) {
		$this->set_stock_reduced( $order, $set );
	}

	/**
	 * Get token ids for an order.
	 *
	 * @param WC_Order $order Order object.
	 * @return array
	 */
	public function get_payment_token_ids( $order ) {
		/**
		 * We don't store _payment_tokens in props to preserve backward compatibility. In CPT data store, `_payment_tokens` is always fetched directly from DB instead of from prop.
		 */
		$payment_tokens = $this->data_store_meta->get_metadata_by_key( $order, '_payment_tokens' );
		if ( $payment_tokens ) {
			$payment_tokens = $payment_tokens[0]->meta_value;
		}
		if ( ! $payment_tokens && version_compare( $order->get_version(), '8.0.0', '<' ) ) {
			// Before 8.0 we were incorrectly storing payment_tokens in the order meta. So we need to check there too.
			$payment_tokens = get_post_meta( $order->get_id(), '_payment_tokens', true );
		}
		return array_filter( (array) $payment_tokens );
	}

	/**
	 * Update token ids for an order.
	 *
	 * @param WC_Order $order Order object.
	 * @param array    $token_ids Payment token ids.
	 */
	public function update_payment_token_ids( $order, $token_ids ) {
		$meta          = new \WC_Meta_Data();
		$meta->key     = '_payment_tokens';
		$meta->value   = $token_ids;
		$existing_meta = $this->data_store_meta->get_metadata_by_key( $order, '_payment_tokens' );
		if ( $existing_meta ) {
			$existing_meta = $existing_meta[0];
			$meta->id      = $existing_meta->id;
			$this->data_store_meta->update_meta( $order, $meta );
		} else {
			$this->data_store_meta->add_meta( $order, $meta );
		}
	}

	/**
	 * Get amount already refunded.
	 *
	 * @param \WC_Order $order Order object.
	 *
	 * @return float Refunded amount.
	 */
	public function get_total_refunded( $order ) {
		global $wpdb;
		$order_table = self::get_orders_table_name();
		$total       = $wpdb->get_var(
			$wpdb->prepare(
			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $order_table is hardcoded.
				"
SELECT SUM( total_amount ) FROM $order_table
WHERE
    type = %s AND
    parent_order_id = %d
;
",
				// phpcs:enable
				'shop_order_refund',
				$order->get_id()
			)
		);
		return -1 * ( isset( $total ) ? $total : 0 );
	}

	/**
	 * Get the total tax refunded.
	 *
	 * @param  WC_Order $order Order object.
	 * @return float
	 */
	public function get_total_tax_refunded( $order ) {
		global $wpdb;

		$order_table = self::get_orders_table_name();

		$total = $wpdb->get_var(
			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $order_table is hardcoded.
			$wpdb->prepare(
				"SELECT SUM( order_itemmeta.meta_value )
				FROM {$wpdb->prefix}woocommerce_order_itemmeta AS order_itemmeta
				INNER JOIN $order_table AS orders ON ( orders.type = 'shop_order_refund' AND orders.parent_order_id = %d )
				INNER JOIN {$wpdb->prefix}woocommerce_order_items AS order_items ON ( order_items.order_id = orders.id AND order_items.order_item_type = 'tax' )
				WHERE order_itemmeta.order_item_id = order_items.order_item_id
				AND order_itemmeta.meta_key IN ('tax_amount', 'shipping_tax_amount')",
				$order->get_id()
			)
		) ?? 0;
		// phpcs:enable

		return abs( $total );
	}

	/**
	 * Get the total shipping refunded.
	 *
	 * @param  WC_Order $order Order object.
	 * @return float
	 */
	public function get_total_shipping_refunded( $order ) {
		global $wpdb;

		$order_table = self::get_orders_table_name();

		$total = $wpdb->get_var(
			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $order_table is hardcoded.
			$wpdb->prepare(
				"SELECT SUM( order_itemmeta.meta_value )
				FROM {$wpdb->prefix}woocommerce_order_itemmeta AS order_itemmeta
				INNER JOIN $order_table AS orders ON ( orders.type = 'shop_order_refund' AND orders.parent_order_id = %d )
				INNER JOIN {$wpdb->prefix}woocommerce_order_items AS order_items ON ( order_items.order_id = orders.id AND order_items.order_item_type = 'shipping' )
				WHERE order_itemmeta.order_item_id = order_items.order_item_id
				AND order_itemmeta.meta_key IN ('cost')",
				$order->get_id()
			)
		) ?? 0;
		// phpcs:enable

		return abs( $total );
	}

	/**
	 * Finds an Order ID based on an order key.
	 *
	 * @param string $order_key An order key has generated by.
	 * @return int The ID of an order, or 0 if the order could not be found
	 */
	public function get_order_id_by_order_key( $order_key ) {
		global $wpdb;

		$orders_table = self::get_orders_table_name();
		$op_table     = self::get_operational_data_table_name();

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		return (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT {$orders_table}.id FROM {$orders_table}
				INNER JOIN {$op_table} ON {$op_table}.order_id = {$orders_table}.id
				WHERE {$op_table}.order_key = %s AND {$op_table}.order_key != ''",
				$order_key
			)
		);
		// phpcs:enable
	}

	/**
	 * Return count of orders with a specific status.
	 *
	 * @param  string $status Order status. Function wc_get_order_statuses() returns a list of valid statuses.
	 * @return int
	 */
	public function get_order_count( $status ) {
		global $wpdb;

		$orders_table = self::get_orders_table_name();

		return absint( $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$orders_table} WHERE type = %s AND status = %s", 'shop_order', $status ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
	}

	/**
	 * Get all orders matching the passed in args.
	 *
	 * @deprecated 3.1.0 - Use {@see wc_get_orders} instead.
	 * @param  array $args List of args passed to wc_get_orders().
	 * @return array|object
	 */
	public function get_orders( $args = array() ) {
		wc_deprecated_function( __METHOD__, '3.1.0', 'Use wc_get_orders instead.' );
		return wc_get_orders( $args );
	}

	/**
	 * Get unpaid orders last updated before the specified date.
	 *
	 * @param  int $date Timestamp.
	 * @return array
	 */
	public function get_unpaid_orders( $date ) {
		global $wpdb;

		$orders_table    = self::get_orders_table_name();
		$order_types_sql = "('" . implode( "','", wc_get_order_types() ) . "')";

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		return $wpdb->get_col(
			$wpdb->prepare(
				"SELECT id FROM {$orders_table} WHERE
				{$orders_table}.type IN {$order_types_sql}
				AND {$orders_table}.status = %s
				AND {$orders_table}.date_updated_gmt < %s",
				'wc-pending',
				gmdate( 'Y-m-d H:i:s', absint( $date ) )
			)
		);
		// phpcs:enable
	}

	/**
	 * Search order data for a term and return matching order IDs.
	 *
	 * @param string $term Search term.
	 *
	 * @return int[] Array of order IDs.
	 */
	public function search_orders( $term ) {
		$order_ids = wc_get_orders(
			array(
				's'      => $term,
				'return' => 'ids',
			)
		);

		/**
		 * Provides an opportunity to modify the list of order IDs obtained during an order search.
		 *
		 * This hook is used for Custom Order Table queries. For Custom Post Type order searches, the corresponding hook
		 * is `woocommerce_shop_order_search_results`.
		 *
		 * @since 7.0.0
		 *
		 * @param int[]  $order_ids Search results as an array of order IDs.
		 * @param string $term      The search term.
		 */
		return array_map( 'intval', (array) apply_filters( 'woocommerce_cot_shop_order_search_results', $order_ids, $term ) );
	}

	/**
	 * Fetch order type for orders in bulk.
	 *
	 * @param array $order_ids Order IDs.
	 *
	 * @return array array( $order_id1 => $type1, ... ) Array for all orders.
	 */
	public function get_orders_type( $order_ids ) {
		global $wpdb;

		if ( empty( $order_ids ) ) {
			return array();
		}

		$orders_table          = self::get_orders_table_name();
		$order_ids_placeholder = implode( ', ', array_fill( 0, count( $order_ids ), '%d' ) );

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
		$results = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT id, type FROM {$orders_table} WHERE id IN ( $order_ids_placeholder )",
				$order_ids
			)
		);
		// phpcs:enable
		$order_types = array();
		foreach ( $results as $row ) {
			$order_types[ $row->id ] = $row->type;
		}
		return $order_types;
	}

	/**
	 * Get order type from DB.
	 *
	 * @param int $order_id Order ID.
	 *
	 * @return string Order type.
	 */
	public function get_order_type( $order_id ) {
		$type = $this->get_orders_type( array( $order_id ) );
		return $type[ $order_id ] ?? '';
	}

	/**
	 * Check if an order exists by id.
	 *
	 * @since 8.0.0
	 *
	 * @param int $order_id The order id to check.
	 * @return bool True if an order exists with the given name.
	 */
	public function order_exists( $order_id ) : bool {
		global $wpdb;

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$exists = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT EXISTS (SELECT id FROM {$this->orders_table_name} WHERE id=%d)",
				$order_id
			)
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		return (bool) $exists;
	}

	/**
	 * Method to read an order from custom tables.
	 *
	 * @param \WC_Order $order Order object.
	 *
	 * @throws \Exception If passed order is invalid.
	 */
	public function read( &$order ) {
		$orders_array = array( $order->get_id() => $order );
		$this->read_multiple( $orders_array );
	}

	/**
	 * Reads multiple orders from custom tables in one pass.
	 *
	 * @since 6.9.0
	 * @param array[\WC_Order] $orders Order objects.
	 * @throws \Exception If passed an invalid order.
	 */
	public function read_multiple( &$orders ) {
		$order_ids = array_keys( $orders );
		$data      = $this->get_order_data_for_ids( $order_ids );

		if ( count( $data ) !== count( $order_ids ) ) {
			throw new \Exception( __( 'Invalid order IDs in call to read_multiple()', 'woocommerce' ) );
		}

		$data_synchronizer = wc_get_container()->get( DataSynchronizer::class );
		if ( ! $data_synchronizer instanceof DataSynchronizer ) {
			return;
		}

		$data_sync_enabled = $data_synchronizer->data_sync_is_enabled();
		if ( $data_sync_enabled ) {
			/**
			 * Allow opportunity to disable sync on read, while keeping sync on write enabled. This adds another step as a large shop progresses from full sync to no sync with HPOS authoritative.
			 * This filter is only executed if data sync is enabled from settings in the first place as it's meant to be a step between full sync -> no sync, rather than be a control for enabling just the sync on read. Sync on read without sync on write is problematic as any update will reset on the next read, but sync on write without sync on read is fine.
			 *
			 * @param bool $read_on_sync_enabled Whether to sync on read.
			 *
			 * @since 8.1.0
			 */
			$data_sync_enabled = apply_filters( 'woocommerce_hpos_enable_sync_on_read', $data_sync_enabled );
		}

		$load_posts_for = array_diff( $order_ids, array_merge( self::$reading_order_ids, self::$backfilling_order_ids ) );
		$post_orders    = $data_sync_enabled ? $this->get_post_orders_for_ids( array_intersect_key( $orders, array_flip( $load_posts_for ) ) ) : array();

		foreach ( $data as $order_data ) {
			$order_id = absint( $order_data->id );
			$order    = $orders[ $order_id ];

			$this->init_order_record( $order, $order_id, $order_data );

			if ( $data_sync_enabled && $this->should_sync_order( $order ) && isset( $post_orders[ $order_id ] ) ) {
				self::$reading_order_ids[] = $order_id;
				$this->maybe_sync_order( $order, $post_orders[ $order->get_id() ] );
			}
		}
	}

	/**
	 * Helper method to check whether to sync the order.
	 *
	 * @param \WC_Abstract_Order $order Order object.
	 *
	 * @return bool Whether the order should be synced.
	 */
	private function should_sync_order( \WC_Abstract_Order $order ) : bool {
		$draft_order    = in_array( $order->get_status(), array( 'draft', 'auto-draft' ), true );
		$already_synced = in_array( $order->get_id(), self::$reading_order_ids, true );
		return ! $draft_order && ! $already_synced;
	}

	/**
	 * Helper method to initialize order object from DB data.
	 *
	 * @param \WC_Abstract_Order $order Order object.
	 * @param int                $order_id Order ID.
	 * @param \stdClass          $order_data Order data fetched from DB.
	 *
	 * @return void
	 */
	protected function init_order_record( \WC_Abstract_Order &$order, int $order_id, \stdClass $order_data ) {
		$order->set_defaults();
		$order->set_id( $order_id );
		$filtered_meta_data = $this->filter_raw_meta_data( $order, $order_data->meta_data );
		$order->init_meta_data( $filtered_meta_data );
		$this->set_order_props_from_data( $order, $order_data );
		$order->set_object_read( true );
	}

	/**
	 * For post based data stores, this was used to filter internal meta data. For custom tables, technically there is no internal meta data,
	 * (i.e. we store all core data as properties for the order, and not in meta data). So this method is a no-op.
	 *
	 * Except that some meta such as billing_address_index and shipping_address_index are infact stored in meta data, so we need to filter those out.
	 *
	 * However, declaring $internal_meta_keys is still required so that our backfill and other comparison checks works as expected.
	 *
	 * @param \WC_Data $object Object to filter meta data for.
	 * @param array    $raw_meta_data Raw meta data.
	 *
	 * @return array Filtered meta data.
	 */
	public function filter_raw_meta_data( &$object, $raw_meta_data ) {
		$filtered_meta_data = parent::filter_raw_meta_data( $object, $raw_meta_data );
		$allowed_keys       = array(
			'_billing_address_index',
			'_shipping_address_index',
		);
		$allowed_meta       = array_filter(
			$raw_meta_data,
			function( $meta ) use ( $allowed_keys ) {
				return in_array( $meta->meta_key, $allowed_keys, true );
			}
		);

		return array_merge( $allowed_meta, $filtered_meta_data );
	}

	/**
	 * Sync order to/from posts tables if we are able to detect difference between order and posts but the sync is enabled.
	 *
	 * @param \WC_Abstract_Order $order Order object.
	 * @param \WC_Abstract_Order $post_order Order object initialized from post.
	 *
	 * @return void
	 * @throws \Exception If passed an invalid order.
	 */
	private function maybe_sync_order( \WC_Abstract_Order &$order, \WC_Abstract_Order $post_order ) {
		if ( ! $this->is_post_different_from_order( $order, $post_order ) ) {
			return;
		}

		// Modified dates can be empty when the order is created but never updated again. Fallback to created date in those cases.
		$order_modified_date      = $order->get_date_modified() ?? $order->get_date_created();
		$order_modified_date      = is_null( $order_modified_date ) ? 0 : $order_modified_date->getTimestamp();
		$post_order_modified_date = $post_order->get_date_modified() ?? $post_order->get_date_created();
		$post_order_modified_date = is_null( $post_order_modified_date ) ? 0 : $post_order_modified_date->getTimestamp();

		/**
		 * We are here because there was difference in posts and order data, although the sync is enabled.
		 * When order modified date is more recent than post modified date, it can only mean that COT definitely has more updated version of the order.
		 *
		 * In a case where post meta was updated (without updating post_modified date), post_modified would be equal to order_modified date.
		 *
		 * So we write back to the order table when order modified date is more recent than post modified date. Otherwise, we write to the post table.
		 */
		if ( $post_order_modified_date >= $order_modified_date ) {
			$this->migrate_post_record( $order, $post_order );
		}
	}

	/**
	 * Get the post type order representation.
	 *
	 * @param \WP_Post $post Post object.
	 *
	 * @return \WC_Order Order object.
	 */
	private function get_cpt_order( $post ) {
		$cpt_order = new \WC_Order();
		$cpt_order->set_id( $post->ID );
		$cpt_data_store = $this->get_cpt_data_store_instance();
		$cpt_data_store->read( $cpt_order );
		return $cpt_order;
	}

	/**
	 * Helper function to get posts data for an order in bullk. We use to this to compute posts object in bulk so that we can compare it with COT data.
	 *
	 * @param array $orders    List of orders mapped by $order_id.
	 *
	 * @return array List of posts.
	 */
	private function get_post_orders_for_ids( array $orders ): array {
		$order_ids = array_keys( $orders );
		// We have to bust meta cache, otherwise we will just get the meta cached by OrderTableDataStore.
		foreach ( $order_ids as $order_id ) {
			wp_cache_delete( WC_Order::generate_meta_cache_key( $order_id, 'orders' ), 'orders' );
		}

		$cpt_stores       = array();
		$cpt_store_orders = array();
		foreach ( $orders as $order_id => $order ) {
			$table_data_store     = $order->get_data_store();
			$cpt_data_store       = $table_data_store->get_cpt_data_store_instance();
			$cpt_store_class_name = get_class( $cpt_data_store );
			if ( ! isset( $cpt_stores[ $cpt_store_class_name ] ) ) {
				$cpt_stores[ $cpt_store_class_name ]       = $cpt_data_store;
				$cpt_store_orders[ $cpt_store_class_name ] = array();
			}
			$cpt_store_orders[ $cpt_store_class_name ][ $order_id ] = $order;
		}

		$cpt_orders = array();
		foreach ( $cpt_stores as $cpt_store_name => $cpt_store ) {
			// Prime caches if we can.
			if ( method_exists( $cpt_store, 'prime_caches_for_orders' ) ) {
				$cpt_store->prime_caches_for_orders( array_keys( $cpt_store_orders[ $cpt_store_name ] ), array() );
			}

			foreach ( $cpt_store_orders[ $cpt_store_name ] as $order_id => $order ) {
				$cpt_order_class_name = wc_get_order_type( $order->get_type() )['class_name'];
				$cpt_order            = new $cpt_order_class_name();

				try {
					$cpt_order->set_id( $order_id );
					$cpt_store->read( $cpt_order );
					$cpt_orders[ $order_id ] = $cpt_order;
				} catch ( Exception $e ) {
					// If the post record has been deleted (for instance, by direct query) then an exception may be thrown.
					$this->error_logger->warning(
						sprintf(
							/* translators: %1$d order ID. */
							__( 'Unable to load the post record for order %1$d', 'woocommerce' ),
							$order_id
						),
						array(
							'exception_code' => $e->getCode(),
							'exception_msg'  => $e->getMessage(),
							'origin'         => __METHOD__,
						)
					);
				}
			}
		}
		return $cpt_orders;
	}

	/**
	 * Computes whether post has been updated after last order. Tries to do it as efficiently as possible.
	 *
	 * @param \WC_Abstract_Order $order Order object.
	 * @param \WC_Abstract_Order $post_order Order object read from posts table.
	 *
	 * @return bool True if post is different than order.
	 */
	private function is_post_different_from_order( $order, $post_order ): bool {
		if ( ArrayUtil::deep_compare_array_diff( $order->get_base_data(), $post_order->get_base_data(), false ) ) {
			return true;
		}

		$meta_diff = $this->get_diff_meta_data_between_orders( $order, $post_order );
		if ( ! empty( $meta_diff ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Migrate meta data from post to order.
	 *
	 * @param \WC_Abstract_Order $order Order object.
	 * @param \WC_Abstract_Order $post_order Order object read from posts table.
	 *
	 * @return array List of meta data that was migrated.
	 */
	private function migrate_meta_data_from_post_order( \WC_Abstract_Order &$order, \WC_Abstract_Order $post_order ) {
		$diff = $this->get_diff_meta_data_between_orders( $order, $post_order, true );
		$order->save_meta_data();
		return $diff;
	}

	/**
	 * Helper function to compute diff between metadata of post and cot data for an order.
	 *
	 * Also provides an option to sync the metadata as well, since we are already computing the diff.
	 *
	 * @param \WC_Abstract_Order $order1 Order object read from posts.
	 * @param \WC_Abstract_Order $order2 Order object read from COT.
	 * @param bool               $sync   Whether to also sync the meta data.
	 *
	 * @return array Difference between post and COT meta data.
	 */
	private function get_diff_meta_data_between_orders( \WC_Abstract_Order &$order1, \WC_Abstract_Order $order2, $sync = false ): array {
		$order1_meta        = ArrayUtil::select( $order1->get_meta_data(), 'get_data', ArrayUtil::SELECT_BY_OBJECT_METHOD );
		$order2_meta        = ArrayUtil::select( $order2->get_meta_data(), 'get_data', ArrayUtil::SELECT_BY_OBJECT_METHOD );
		$order1_meta_by_key = ArrayUtil::select_as_assoc( $order1_meta, 'key', ArrayUtil::SELECT_BY_ARRAY_KEY );
		$order2_meta_by_key = ArrayUtil::select_as_assoc( $order2_meta, 'key', ArrayUtil::SELECT_BY_ARRAY_KEY );

		$diff = array();
		foreach ( $order1_meta_by_key as $key => $value ) {
			if ( in_array( $key, $this->internal_meta_keys, true ) ) {
				// These should have already been verified in the base data comparison.
				continue;
			}
			$order1_values = ArrayUtil::select( $value, 'value', ArrayUtil::SELECT_BY_ARRAY_KEY );
			if ( ! array_key_exists( $key, $order2_meta_by_key ) ) {
				$sync && $order1->delete_meta_data( $key );
				$diff[ $key ] = $order1_values;
				unset( $order2_meta_by_key[ $key ] );
				continue;
			}

			$order2_values = ArrayUtil::select( $order2_meta_by_key[ $key ], 'value', ArrayUtil::SELECT_BY_ARRAY_KEY );
			$new_diff      = ArrayUtil::deep_assoc_array_diff( $order1_values, $order2_values );
			if ( ! empty( $new_diff ) && $sync ) {
				if ( count( $order2_values ) > 1 ) {
					$sync && $order1->delete_meta_data( $key );
					foreach ( $order2_values as $post_order_value ) {
						$sync && $order1->add_meta_data( $key, $post_order_value, false );
					}
				} else {
					$sync && $order1->update_meta_data( $key, $order2_values[0] );
				}
				$diff[ $key ] = $new_diff;
				unset( $order2_meta_by_key[ $key ] );
			}
		}

		foreach ( $order2_meta_by_key as $key => $value ) {
			if ( array_key_exists( $key, $order1_meta_by_key ) || in_array( $key, $this->internal_meta_keys, true ) ) {
				continue;
			}
			$order2_values = ArrayUtil::select( $value, 'value', ArrayUtil::SELECT_BY_ARRAY_KEY );
			foreach ( $order2_values as $meta_value ) {
				$sync && $order1->add_meta_data( $key, $meta_value );
			}
			$diff[ $key ] = $order2_values;
		}
		return $diff;
	}

	/**
	 * Log difference between post and COT data for an order.
	 *
	 * @param array $diff Difference between post and COT data.
	 *
	 * @return void
	 */
	private function log_diff( array $diff ): void {
		$this->error_logger->notice( 'Diff found: ' . wp_json_encode( $diff, JSON_PRETTY_PRINT ) );
	}

	/**
	 * Migrate post record from a given order object.
	 *
	 * @param \WC_Abstract_Order $order Order object.
	 * @param \WC_Abstract_Order $post_order Order object read from posts.
	 *
	 * @return void
	 */
	private function migrate_post_record( \WC_Abstract_Order &$order, \WC_Abstract_Order $post_order ): void {
		$this->migrate_meta_data_from_post_order( $order, $post_order );
		$post_order_base_data = $post_order->get_base_data();
		foreach ( $post_order_base_data as $key => $value ) {
			$this->set_order_prop( $order, $key, $value );
		}
		$this->persist_updates( $order, false );
	}

	/**
	 * Sets order properties based on a row from the database.
	 *
	 * @param \WC_Abstract_Order $order      The order object.
	 * @param object             $order_data A row of order data from the database.
	 */
	protected function set_order_props_from_data( &$order, $order_data ) {
		foreach ( $this->get_all_order_column_mappings() as $table_name => $column_mapping ) {
			foreach ( $column_mapping as $column_name => $prop_details ) {
				if ( ! isset( $prop_details['name'] ) ) {
					continue;
				}
				$prop_value = $order_data->{$prop_details['name']};
				if ( is_null( $prop_value ) ) {
					continue;
				}

				try {
					if ( 'date' === $prop_details['type'] ) {
						$prop_value = $this->string_to_timestamp( $prop_value );
					}

					$this->set_order_prop( $order, $prop_details['name'], $prop_value );
				} catch ( \Exception $e ) {
					$order_id = $order->get_id();
					$this->error_logger->warning(
						sprintf(
						/* translators: %1$d = peoperty name, %2$d = order ID, %3$s = error message. */
							__( 'Error when setting property \'%1$s\' for order %2$d: %3$s', 'woocommerce' ),
							$prop_details['name'],
							$order_id,
							$e->getMessage()
						),
						array(
							'exception_code' => $e->getCode(),
							'exception_msg'  => $e->getMessage(),
							'origin'         => __METHOD__,
							'order_id'       => $order_id,
							'property_name'  => $prop_details['name'],
						)
					);
				}
			}
		}
	}

	/**
	 * Set order prop if a setter exists in either the order object or in the data store.
	 *
	 * @param \WC_Abstract_Order $order Order object.
	 * @param string             $prop_name Property name.
	 * @param mixed              $prop_value Property value.
	 *
	 * @return bool True if the property was set, false otherwise.
	 */
	private function set_order_prop( \WC_Abstract_Order $order, string $prop_name, $prop_value ) {
		$prop_setter_function_name = "set_{$prop_name}";
		if ( is_callable( array( $order, $prop_setter_function_name ) ) ) {
			return $order->{$prop_setter_function_name}( $prop_value );
		} elseif ( is_callable( array( $this, $prop_setter_function_name ) ) ) {
			return $this->{$prop_setter_function_name}( $order, $prop_value, false );
		}
		return false;
	}

	/**
	 * Return order data for a single order ID.
	 *
	 * @param int $id Order ID.
	 *
	 * @return object|\WP_Error DB order object or WP_Error.
	 */
	private function get_order_data_for_id( $id ) {
		$results = $this->get_order_data_for_ids( array( $id ) );

		return is_array( $results ) && count( $results ) > 0 ? $results[ $id ] : $results;
	}

	/**
	 * Return order data for multiple IDs.
	 *
	 * @param array $ids List of order IDs.
	 *
	 * @return \stdClass[]|object|null DB Order objects or error.
	 */
	protected function get_order_data_for_ids( $ids ) {
		global $wpdb;

		if ( ! $ids || empty( $ids ) ) {
			return array();
		}

		$table_aliases     = array(
			'orders'           => $this->get_order_table_alias(),
			'billing_address'  => $this->get_address_table_alias( 'billing' ),
			'shipping_address' => $this->get_address_table_alias( 'shipping' ),
			'operational_data' => $this->get_op_table_alias(),
		);
		$order_table_alias = $table_aliases['orders'];
		$order_table_query = $this->get_order_table_select_statement();
		$id_placeholder    = implode( ', ', array_fill( 0, count( $ids ), '%d' ) );
		$order_meta_table  = self::get_meta_table_name();

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $order_table_query is autogenerated and should already be prepared.
		$table_data = $wpdb->get_results(
			$wpdb->prepare(
				"$order_table_query WHERE $order_table_alias.id in ( $id_placeholder )",
				$ids
			)
		);
		// phpcs:enable

		$meta_data_query = $this->get_order_meta_select_statement();
		$order_data      = array();
		$meta_data       = $wpdb->get_results(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $meta_data_query and $order_meta_table is autogenerated and should already be prepared. $id_placeholder is already prepared.
				"$meta_data_query WHERE $order_meta_table.order_id in ( $id_placeholder )",
				$ids
			)
		);

		foreach ( $table_data as $table_datum ) {
			$id                = $table_datum->{"{$order_table_alias}_id"};
			$order_data[ $id ] = new \stdClass();
			foreach ( $this->get_all_order_column_mappings() as $table_name => $column_mappings ) {
				$table_alias = $table_aliases[ $table_name ];
				// This remapping is required to keep the query length small enough to be supported by implementations such as HyperDB (i.e. fetching some tables in join via alias.*, while others via full name). We can revert this commit if HyperDB starts supporting SRTM for query length more than 3076 characters.
				foreach ( $column_mappings as $field => $map ) {
					$field_name = $map['name'] ?? "{$table_name}_$field";
					if ( property_exists( $table_datum, $field_name ) ) {
						$field_value = $table_datum->{ $field_name }; // Unique column, field name is different prop name.
					} elseif ( property_exists( $table_datum, "{$table_alias}_$field" ) ) {
						$field_value = $table_datum->{"{$table_alias}_$field"}; // Non-unique column (billing, shipping etc).
					} else {
						$field_value = $table_datum->{ $field }; // Unique column, field name is same as prop name.
					}
					$order_data[ $id ]->{$field_name} = $field_value;
				}
			}
			$order_data[ $id ]->id        = $id;
			$order_data[ $id ]->meta_data = array();
		}

		foreach ( $meta_data as $meta_datum ) {
			// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Not a meta query.
			$order_data[ $meta_datum->order_id ]->meta_data[] = (object) array(
				'meta_id'    => $meta_datum->id,
				'meta_key'   => $meta_datum->meta_key,
				'meta_value' => $meta_datum->meta_value,
			);
			// phpcs:enable
		}
		return $order_data;
	}

	/**
	 * Helper method to generate combined select statement.
	 *
	 * @return string Select SQL statement to fetch order.
	 */
	private function get_order_table_select_statement() {
		$order_table                  = $this::get_orders_table_name();
		$order_table_alias            = $this->get_order_table_alias();
		$billing_address_table_alias  = $this->get_address_table_alias( 'billing' );
		$shipping_address_table_alias = $this->get_address_table_alias( 'shipping' );
		$op_data_table_alias          = $this->get_op_table_alias();
		$billing_address_clauses      = $this->join_billing_address_table_to_order_query( $order_table_alias, $billing_address_table_alias );
		$shipping_address_clauses     = $this->join_shipping_address_table_to_order_query( $order_table_alias, $shipping_address_table_alias );
		$operational_data_clauses     = $this->join_operational_data_table_to_order_query( $order_table_alias, $op_data_table_alias );

		/**
		 * We fully spell out address table columns because they have duplicate columns for billing and shipping and would be overwritten if we don't spell them out. There is not such duplication in the operational data table and orders table, so select with `alias`.* is fine.
		 * We do spell ID columns manually, as they are duplicate.
		 */
		return "
SELECT $order_table_alias.id as o_id, $op_data_table_alias.id as p_id, $order_table_alias.*, {$billing_address_clauses['select']}, {$shipping_address_clauses['select']}, $op_data_table_alias.*
FROM $order_table $order_table_alias
LEFT JOIN {$billing_address_clauses['join']}
LEFT JOIN {$shipping_address_clauses['join']}
LEFT JOIN {$operational_data_clauses['join']}
";
	}

	/**
	 * Helper function to generate select statement for fetching metadata in bulk.
	 *
	 * @return string Select SQL statement to fetch order metadata.
	 */
	private function get_order_meta_select_statement() {
		$order_meta_table = self::get_meta_table_name();
		return "
SELECT $order_meta_table.id, $order_meta_table.order_id, $order_meta_table.meta_key, $order_meta_table.meta_value
FROM $order_meta_table
		";
	}

	/**
	 * Helper method to generate join query for billing addresses in wc_address table.
	 *
	 * @param string $order_table_alias Alias for order table to use in join.
	 * @param string $address_table_alias Alias for address table to use in join.
	 *
	 * @return array Select and join statements for billing address table.
	 */
	private function join_billing_address_table_to_order_query( $order_table_alias, $address_table_alias ) {
		return $this->join_address_table_order_query( 'billing', $order_table_alias, $address_table_alias );
	}

	/**
	 * Helper method to generate join query for shipping addresses in wc_address table.
	 *
	 * @param string $order_table_alias Alias for order table to use in join.
	 * @param string $address_table_alias Alias for address table to use in join.
	 *
	 * @return array Select and join statements for shipping address table.
	 */
	private function join_shipping_address_table_to_order_query( $order_table_alias, $address_table_alias ) {
		return $this->join_address_table_order_query( 'shipping', $order_table_alias, $address_table_alias );
	}

	/**
	 * Helper method to generate join and select query for address table.
	 *
	 * @param string $address_type Type of address; 'billing' or 'shipping'.
	 * @param string $order_table_alias Alias of order table to use.
	 * @param string $address_table_alias Alias for address table to use.
	 *
	 * @return array Select and join statements for address table.
	 */
	private function join_address_table_order_query( $address_type, $order_table_alias, $address_table_alias ) {
		global $wpdb;
		$address_table    = $this::get_addresses_table_name();
		$column_props_map = 'billing' === $address_type ? $this->billing_address_column_mapping : $this->shipping_address_column_mapping;
		$clauses          = $this->generate_select_and_join_clauses( $order_table_alias, $address_table, $address_table_alias, $column_props_map );
		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $clauses['join'] and $address_table_alias are hardcoded.
		$clauses['join'] = $wpdb->prepare(
			"{$clauses['join']} AND $address_table_alias.address_type = %s",
			$address_type
		);

		// phpcs:enable
		return array(
			'select' => $clauses['select'],
			'join'   => $clauses['join'],
		);
	}

	/**
	 * Helper method to join order operational data table.
	 *
	 * @param string $order_table_alias Alias to use for order table.
	 * @param string $operational_table_alias Alias to use for operational data table.
	 *
	 * @return array Select and join queries for operational data table.
	 */
	private function join_operational_data_table_to_order_query( $order_table_alias, $operational_table_alias ) {
		$operational_data_table = $this::get_operational_data_table_name();

		return $this->generate_select_and_join_clauses(
			$order_table_alias,
			$operational_data_table,
			$operational_table_alias,
			$this->operational_data_column_mapping
		);
	}

	/**
	 * Helper method to generate join and select clauses.
	 *
	 * @param string  $order_table_alias Alias for order table.
	 * @param string  $table Table to join.
	 * @param string  $table_alias Alias for table to join.
	 * @param array[] $column_props_map Column to prop map for table to join.
	 *
	 * @return array Select and join queries.
	 */
	private function generate_select_and_join_clauses( $order_table_alias, $table, $table_alias, $column_props_map ) {
		// Add aliases to column names so they will be unique when fetching.
		$select_clause = $this->generate_select_clause_for_props( $table_alias, $column_props_map );
		$join_clause   = "$table $table_alias ON $table_alias.order_id = $order_table_alias.id";

		return array(
			'select' => $select_clause,
			'join'   => $join_clause,
		);
	}

	/**
	 * Helper method to generate select clause for props.
	 *
	 * @param string  $table_alias Alias for table.
	 * @param array[] $props Props to column mapping for table.
	 *
	 * @return string Select clause.
	 */
	private function generate_select_clause_for_props( $table_alias, $props ) {
		$select_clauses = array();
		foreach ( $props as $column_name => $prop_details ) {
			$select_clauses[] = isset( $prop_details['name'] ) ? "$table_alias.$column_name as {$prop_details['name']}" : "$table_alias.$column_name as {$table_alias}_$column_name";
		}

		return implode( ', ', $select_clauses );
	}

	/**
	 * Persists order changes to the database.
	 *
	 * @param \WC_Abstract_Order $order            The order.
	 * @param bool               $force_all_fields Force saving all fields to DB and just changed.
	 *
	 * @throws \Exception If order data is not valid.
	 *
	 * @since 6.8.0
	 */
	protected function persist_order_to_db( &$order, bool $force_all_fields = false ) {
		$context   = ( 0 === absint( $order->get_id() ) ) ? 'create' : 'update';
		$data_sync = wc_get_container()->get( DataSynchronizer::class );

		if ( 'create' === $context ) {
			$post_id = wp_insert_post(
				array(
					'post_type'     => $data_sync->data_sync_is_enabled() ? $order->get_type() : $data_sync::PLACEHOLDER_ORDER_POST_TYPE,
					'post_status'   => 'draft',
					'post_parent'   => $order->get_changes()['parent_id'] ?? $order->get_data()['parent_id'] ?? 0,
					'post_date'     => gmdate( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getOffsetTimestamp() ),
					'post_date_gmt' => gmdate( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getTimestamp() ),
				)
			);

			if ( ! $post_id ) {
				throw new \Exception( __( 'Could not create order in posts table.', 'woocommerce' ) );
			}

			$order->set_id( $post_id );
		}

		$only_changes = ! $force_all_fields && 'update' === $context;
		// Figure out what needs to be updated in the database.
		$db_updates = $this->get_db_rows_for_order( $order, $context, $only_changes );

		// Persist changes.
		foreach ( $db_updates as $update ) {
			// Make sure 'data' and 'format' entries match before passing to $wpdb.
			ksort( $update['data'] );
			ksort( $update['format'] );

			$result = $this->database_util->insert_on_duplicate_key_update(
				$update['table'],
				$update['data'],
				array_values( $update['format'] )
			);

			if ( false === $result ) {
				// translators: %s is a table name.
				throw new \Exception( sprintf( __( 'Could not persist order to database table "%s".', 'woocommerce' ), $update['table'] ) );
			}
		}

		$changes = $order->get_changes();
		$this->update_address_index_meta( $order, $changes );
		$default_taxonomies = $this->init_default_taxonomies( $order, array() );
		$this->set_custom_taxonomies( $order, $default_taxonomies );
	}

	/**
	 * Set default taxonomies for the order.
	 *
	 * Note: This is re-implementation of part of WP core's `wp_insert_post` function. Since the code block that set default taxonomies is not filterable, we have to re-implement it.
	 *
	 * @param \WC_Abstract_Order $order               Order object.
	 * @param array              $sanitized_tax_input Sanitized taxonomy input.
	 *
	 * @return array Sanitized tax input with default taxonomies.
	 */
	public function init_default_taxonomies( \WC_Abstract_Order $order, array $sanitized_tax_input ) {
		if ( 'auto-draft' === $order->get_status() ) {
			return $sanitized_tax_input;
		}

		foreach ( get_object_taxonomies( $order->get_type(), 'object' ) as $taxonomy => $tax_object ) {
			if ( empty( $tax_object->default_term ) ) {
				return $sanitized_tax_input;
			}

			// Filter out empty terms.
			if ( isset( $sanitized_tax_input[ $taxonomy ] ) && is_array( $sanitized_tax_input[ $taxonomy ] ) ) {
				$sanitized_tax_input[ $taxonomy ] = array_filter( $sanitized_tax_input[ $taxonomy ] );
			}

			// Passed custom taxonomy list overwrites the existing list if not empty.
			$terms = wp_get_object_terms( $order->get_id(), $taxonomy, array( 'fields' => 'ids' ) );
			if ( ! empty( $terms ) && empty( $sanitized_tax_input[ $taxonomy ] ) ) {
				$sanitized_tax_input[ $taxonomy ] = $terms;
			}

			if ( empty( $sanitized_tax_input[ $taxonomy ] ) ) {
				$default_term_id = get_option( 'default_term_' . $taxonomy );
				if ( ! empty( $default_term_id ) ) {
					$sanitized_tax_input[ $taxonomy ] = array( (int) $default_term_id );
				}
			}
		}
		return $sanitized_tax_input;
	}

	/**
	 * Set custom taxonomies for the order.
	 *
	 * Note: This is re-implementation of part of WP core's `wp_insert_post` function. Since the code block that set custom taxonomies is not filterable, we have to re-implement it.
	 *
	 * @param \WC_Abstract_Order $order               Order object.
	 * @param array              $sanitized_tax_input Sanitized taxonomy input.
	 *
	 * @return void
	 */
	public function set_custom_taxonomies( \WC_Abstract_Order $order, array $sanitized_tax_input ) {
		if ( empty( $sanitized_tax_input ) ) {
			return;
		}

		foreach ( $sanitized_tax_input as $taxonomy => $tags ) {
			$taxonomy_obj = get_taxonomy( $taxonomy );

			if ( ! $taxonomy_obj ) {
				/* translators: %s: Taxonomy name. */
				_doing_it_wrong( __FUNCTION__, esc_html( sprintf( __( 'Invalid taxonomy: %s.', 'woocommerce' ), $taxonomy ) ), '7.9.0' );
				continue;
			}

			// array = hierarchical, string = non-hierarchical.
			if ( is_array( $tags ) ) {
				$tags = array_filter( $tags );
			}

			if ( current_user_can( $taxonomy_obj->cap->assign_terms ) ) {
				wp_set_post_terms( $order->get_id(), $tags, $taxonomy );
			}
		}
	}

	/**
	 * Generates an array of rows with all the details required to insert or update an order in the database.
	 *
	 * @param \WC_Abstract_Order $order The order.
	 * @param string             $context The context: 'create' or 'update'.
	 * @param boolean            $only_changes Whether to consider only changes in the order for generating the rows.
	 *
	 * @return array
	 * @throws \Exception When invalid data is found for the given context.
	 *
	 * @since 6.8.0
	 */
	protected function get_db_rows_for_order( \WC_Abstract_Order $order, string $context = 'create', bool $only_changes = false ): array {
		$result = array();

		$row = $this->get_db_row_from_order( $order, $this->order_column_mapping, $only_changes );
		if ( 'create' === $context && ! $row ) {
			throw new \Exception( 'No data for new record.' ); // This shouldn't occur.
		}

		if ( $row ) {
			$result[] = array(
				'table'  => self::get_orders_table_name(),
				'data'   => array_merge(
					$row['data'],
					array(
						'id'   => $order->get_id(),
						'type' => $order->get_type(),
					)
				),
				'format' => array_merge(
					$row['format'],
					array(
						'id'   => '%d',
						'type' => '%s',
					)
				),
			);
		}

		// wc_order_operational_data.
		$row = $this->get_db_row_from_order( $order, $this->operational_data_column_mapping, $only_changes );
		if ( $row ) {
			$result[] = array(
				'table'  => self::get_operational_data_table_name(),
				'data'   => array_merge( $row['data'], array( 'order_id' => $order->get_id() ) ),
				'format' => array_merge( $row['format'], array( 'order_id' => '%d' ) ),
			);
		}

		// wc_order_addresses.
		foreach ( array( 'billing', 'shipping' ) as $address_type ) {
			$row = $this->get_db_row_from_order( $order, $this->{$address_type . '_address_column_mapping'}, $only_changes );

			if ( $row ) {
				$result[] = array(
					'table'  => self::get_addresses_table_name(),
					'data'   => array_merge(
						$row['data'],
						array(
							'order_id'     => $order->get_id(),
							'address_type' => $address_type,
						)
					),
					'format' => array_merge(
						$row['format'],
						array(
							'order_id'     => '%d',
							'address_type' => '%s',
						)
					),
				);
			}
		}

		/**
		 * Allow third parties to include rows that need to be inserted/updated in custom tables when persisting an order.
		 *
		 * @since 6.8.0
		 *
		 * @param array      Array of rows to be inserted/updated when persisting an order. Each entry should be an array with
		 *                   keys 'table', 'data' (the row), 'format' (row format), 'where' and 'where_format'.
		 * @param \WC_Order  The order object.
		 * @param string     The context of the operation: 'create' or 'update'.
		 */
		$ext_rows = apply_filters( 'woocommerce_orders_table_datastore_extra_db_rows_for_order', array(), $order, $context );

		return array_merge( $result, $ext_rows );
	}

	/**
	 * Produces an array with keys 'row' and 'format' that can be passed to `$wpdb->update()` as the `$data` and
	 * `$format` parameters. Values are taken from the order changes array and properly formatted for inclusion in the
	 * database.
	 *
	 * @param \WC_Abstract_Order $order          Order.
	 * @param array              $column_mapping Table column mapping.
	 * @param bool               $only_changes   Whether to consider only changes in the order object or all fields.
	 * @return array
	 *
	 * @since 6.8.0
	 */
	protected function get_db_row_from_order( $order, $column_mapping, $only_changes = false ) {
		$changes = $only_changes ? $order->get_changes() : array_merge( $order->get_data(), $order->get_changes() );

		// Make sure 'status' is correctly prefixed.
		if ( array_key_exists( 'status', $column_mapping ) && array_key_exists( 'status', $changes ) ) {
			$changes['status'] = $this->get_post_status( $order );
		}

		$row        = array();
		$row_format = array();

		foreach ( $column_mapping as $column => $details ) {
			if ( ! isset( $details['name'] ) || ! array_key_exists( $details['name'], $changes ) ) {
				continue;
			}

			$row[ $column ]        = $this->database_util->format_object_value_for_db( $changes[ $details['name'] ], $details['type'] );
			$row_format[ $column ] = $this->database_util->get_wpdb_format_for_type( $details['type'] );
		}

		if ( ! $row ) {
			return false;
		}

		return array(
			'data'   => $row,
			'format' => $row_format,
		);
	}

	/**
	 * Method to delete an order from the database.
	 *
	 * @param \WC_Abstract_Order $order Order object.
	 * @param array              $args Array of args to pass to the delete method.
	 *
	 * @return void
	 */
	public function delete( &$order, $args = array() ) {
		$order_id = $order->get_id();

		if ( ! $order_id ) {
			return;
		}

		$args = wp_parse_args(
			$args,
			array(
				'force_delete'     => false,
				'suppress_filters' => false,
			)
		);

		$do_filters = ! $args['suppress_filters'];

		if ( $args['force_delete'] ) {

			if ( $do_filters ) {
				/**
				 * Fires immediately before an order is deleted from the database.
				 *
				 * @since 7.1.0
				 *
				 * @param int      $order_id ID of the order about to be deleted.
				 * @param WC_Order $order    Instance of the order that is about to be deleted.
				 */
				do_action( 'woocommerce_before_delete_order', $order_id, $order );
			}

			$this->upshift_or_delete_child_orders( $order );
			$this->delete_order_data_from_custom_order_tables( $order_id );
			$this->delete_items( $order );

			$order->set_id( 0 );

			/** We can delete the post data if:
			 * 1. The HPOS table is authoritative and synchronization is enabled.
			 * 2. The post record is of type `shop_order_placehold`, since this is created by the HPOS in the first place.
			 *
			 * In other words, we do not delete the post record when HPOS table is authoritative and synchronization is disabled but post record is a full record and not just a placeholder, because it implies that the order was created before HPOS was enabled.
			 */
			$orders_table_is_authoritative = $order->get_data_store()->get_current_class_name() === self::class;

			if ( $orders_table_is_authoritative ) {
				$data_synchronizer = wc_get_container()->get( DataSynchronizer::class );
				if ( $data_synchronizer->data_sync_is_enabled() ) {
					// Delete the associated post, which in turn deletes order items, etc. through {@see WC_Post_Data}.
					// Once we stop creating posts for orders, we should do the cleanup here instead.
					wp_delete_post( $order_id );
				} else {
					$this->handle_order_deletion_with_sync_disabled( $order_id );
				}
			}

			if ( $do_filters ) {
				/**
				 * Fires immediately after an order is deleted.
				 *
				 * @since
				 *
				 * @param int $order_id ID of the order that has been deleted.
				 */
				do_action( 'woocommerce_delete_order', $order_id ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
			}
		} else {
			if ( $do_filters ) {
				/**
				 * Fires immediately before an order is trashed.
				 *
				 * @since 7.1.0
				 *
				 * @param int      $order_id ID of the order about to be trashed.
				 * @param WC_Order $order    Instance of the order that is about to be trashed.
				 */
				do_action( 'woocommerce_before_trash_order', $order_id, $order );
			}

			$this->trash_order( $order );

			if ( $do_filters ) {
				/**
				 * Fires immediately after an order is trashed.
				 *
				 * @since
				 *
				 * @param int $order_id ID of the order that has been trashed.
				 */
				do_action( 'woocommerce_trash_order', $order_id ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
			}
		}
	}

	/**
	 * Handles the deletion of an order from the orders table when sync is disabled:
	 *
	 * If the corresponding row in the posts table is of placeholder type,
	 * it's just deleted; otherwise a "deleted_from" record is created in the meta table
	 * and the sync process will detect these and take care of deleting the appropriate post records.
	 *
	 * @param int $order_id Th id of the order that has been deleted from the orders table.
	 * @return void
	 */
	protected function handle_order_deletion_with_sync_disabled( $order_id ): void {
		global $wpdb;

		$post_type = $wpdb->get_var(
			$wpdb->prepare( "SELECT post_type FROM {$wpdb->posts} WHERE ID=%d", $order_id )
		);

		if ( DataSynchronizer::PLACEHOLDER_ORDER_POST_TYPE === $post_type ) {
			$wpdb->query(
				$wpdb->prepare(
					"DELETE FROM {$wpdb->posts} WHERE ID=%d OR post_parent=%d",
					$order_id,
					$order_id
				)
			);
		} else {
			// phpcs:disable WordPress.DB.SlowDBQuery
			$wpdb->insert(
				self::get_meta_table_name(),
				array(
					'order_id'   => $order_id,
					'meta_key'   => DataSynchronizer::DELETED_RECORD_META_KEY,
					'meta_value' => DataSynchronizer::DELETED_FROM_ORDERS_META_VALUE,
				)
			);
			// phpcs:enable WordPress.DB.SlowDBQuery

			// Note that at this point upshift_or_delete_child_orders will already have been invoked,
			// thus all the child orders either still exist but have a different parent id,
			// or have been deleted and got their own deletion record already.
			// So there's no need to do anything about them.
		}
	}

	/**
	 * Set the parent id of child orders to the parent order's parent if the post type
	 * for the order is hierarchical, just delete the child orders otherwise.
	 *
	 * @param \WC_Abstract_Order $order Order object.
	 *
	 * @return void
	 */
	private function upshift_or_delete_child_orders( $order ) : void {
		global $wpdb;

		$order_table     = self::get_orders_table_name();
		$order_parent_id = $order->get_parent_id();

		if ( $this->legacy_proxy->call_function( 'is_post_type_hierarchical', $order->get_type() ) ) {
			$wpdb->update(
				$order_table,
				array( 'parent_order_id' => $order_parent_id ),
				array( 'parent_order_id' => $order->get_id() ),
				array( '%d' ),
				array( '%d' )
			);
		} else {
			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$child_order_ids = $wpdb->get_col(
				$wpdb->prepare(
					"SELECT id FROM $order_table WHERE parent_order_id=%d",
					$order->get_id()
				)
			);
			// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

			foreach ( $child_order_ids as $child_order_id ) {
				$child_order = wc_get_order( $child_order_id );
				if ( $child_order ) {
					$child_order->delete( true );
				}
			}
		}
	}

	/**
	 * Trashes an order.
	 *
	 * @param  WC_Order $order The order object.
	 *
	 * @return void
	 */
	public function trash_order( $order ) {
		global $wpdb;

		if ( 'trash' === $order->get_status( 'edit' ) ) {
			return;
		}

		$trash_metadata = array(
			'_wp_trash_meta_status' => 'wc-' . $order->get_status( 'edit' ),
			'_wp_trash_meta_time'   => time(),
		);

		$wpdb->update(
			self::get_orders_table_name(),
			array(
				'status'           => 'trash',
				'date_updated_gmt' => current_time( 'Y-m-d H:i:s', true ),
			),
			array( 'id' => $order->get_id() ),
			array( '%s', '%s' ),
			array( '%d' )
		);

		$order->set_status( 'trash' );

		foreach ( $trash_metadata as $meta_key => $meta_value ) {
			$this->add_meta(
				$order,
				(object) array(
					'key'   => $meta_key,
					'value' => $meta_value,
				)
			);
		}

		$data_synchronizer = wc_get_container()->get( DataSynchronizer::class );
		if ( $data_synchronizer->data_sync_is_enabled() ) {
			wp_trash_post( $order->get_id() );
		}
	}

	/**
	 * Attempts to restore the specified order back to its original status (after having been trashed).
	 *
	 * @param WC_Order $order The order to be untrashed.
	 *
	 * @return bool If the operation was successful.
	 */
	public function untrash_order( WC_Order $order ): bool {
		$id     = $order->get_id();
		$status = $order->get_status();

		if ( 'trash' !== $status ) {
			wc_get_logger()->warning(
				sprintf(
					/* translators: 1: order ID, 2: order status */
					__( 'Order %1$d cannot be restored from the trash: it has already been restored to status "%2$s".', 'woocommerce' ),
					$id,
					$status
				)
			);
			return false;
		}

		$previous_status           = $order->get_meta( '_wp_trash_meta_status' );
		$valid_statuses            = wc_get_order_statuses();
		$previous_state_is_invalid = ! array_key_exists( $previous_status, $valid_statuses );
		$pending_is_valid_status   = array_key_exists( 'wc-pending', $valid_statuses );

		if ( $previous_state_is_invalid && $pending_is_valid_status ) {
			// If the previous status is no longer valid, let's try to restore it to "pending" instead.
			wc_get_logger()->warning(
				sprintf(
					/* translators: 1: order ID, 2: order status */
					__( 'The previous status of order %1$d ("%2$s") is invalid. It has been restored to "pending" status instead.', 'woocommerce' ),
					$id,
					$previous_status
				)
			);

			$previous_status = 'pending';
		} elseif ( $previous_state_is_invalid ) {
			// If we cannot restore to pending, we should probably stand back and let the merchant intervene some other way.
			wc_get_logger()->warning(
				sprintf(
					/* translators: 1: order ID, 2: order status */
					__( 'The previous status of order %1$d ("%2$s") is invalid. It could not be restored.', 'woocommerce' ),
					$id,
					$previous_status
				)
			);

			return false;
		}

		/**
		 * Fires before an order is restored from the trash.
		 *
		 * @since 7.2.0
		 *
		 * @param int    $order_id        Order ID.
		 * @param string $previous_status The status of the order before it was trashed.
		 */
		do_action( 'woocommerce_untrash_order', $order->get_id(), $previous_status );

		$order->set_status( $previous_status );
		$order->save();

		// Was the status successfully restored? Let's clean up the meta and indicate success...
		if ( 'wc-' . $order->get_status() === $previous_status ) {
			$order->delete_meta_data( '_wp_trash_meta_status' );
			$order->delete_meta_data( '_wp_trash_meta_time' );
			$order->delete_meta_data( '_wp_trash_meta_comments_status' );
			$order->save_meta_data();

			return true;
		}

		// ...Or log a warning and bail.
		wc_get_logger()->warning(
			sprintf(
				/* translators: 1: order ID, 2: order status */
				__( 'Something went wrong when trying to restore order %d from the trash. It could not be restored.', 'woocommerce' ),
				$id
			)
		);

		return false;
	}


	/**
	 * Deletes order data from custom order tables.
	 *
	 * @param int $order_id The order ID.
	 * @return void
	 */
	public function delete_order_data_from_custom_order_tables( $order_id ) {
		global $wpdb;
		$order_cache = wc_get_container()->get( OrderCache::class );

		// Delete COT-specific data.
		foreach ( $this->get_all_table_names() as $table ) {
			$wpdb->delete(
				$table,
				( self::get_orders_table_name() === $table )
					? array( 'id' => $order_id )
					: array( 'order_id' => $order_id ),
				array( '%d' )
			);
			$order_cache->remove( $order_id );
		}
	}

	/**
	 * Method to create an order in the database.
	 *
	 * @param \WC_Order $order Order object.
	 */
	public function create( &$order ) {
		if ( '' === $order->get_order_key() ) {
			$order->set_order_key( wc_generate_order_key() );
		}

		$this->persist_save( $order );

		// Do not fire 'woocommerce_new_order' for draft statuses for backwards compatibility.
		if ( 'auto-draft' === $order->get_status( 'edit' ) ) {
			return;
		}

		/**
		 * Fires when a new order is created.
		 *
		 * @since 2.7.0
		 *
		 * @param int       Order ID.
		 * @param \WC_Order Order object.
		 */
		do_action( 'woocommerce_new_order', $order->get_id(), $order );
	}

	/**
	 * Helper method responsible for persisting new data to order table.
	 *
	 * This should not contain and specific meta or actions, so that it can be used other order types safely.
	 *
	 * @param \WC_Order $order Order object.
	 * @param bool      $force_all_fields Force update all fields, instead of calculating and updating only changed fields.
	 * @param bool      $backfill Whether to backfill data to post datastore.
	 *
	 * @return void
	 *
	 * @throws \Exception When unable to save data.
	 */
	protected function persist_save( &$order, bool $force_all_fields = false, $backfill = true ) {
		$order->set_version( Constants::get_constant( 'WC_VERSION' ) );
		$order->set_currency( $order->get_currency() ? $order->get_currency() : get_woocommerce_currency() );

		if ( ! $order->get_date_created( 'edit' ) ) {
			$order->set_date_created( time() );
		}

		if ( ! $order->get_date_modified( 'edit' ) ) {
			$order->set_date_modified( current_time( 'mysql' ) );
		}

		$this->persist_order_to_db( $order, $force_all_fields );

		$this->update_order_meta( $order );

		$order->save_meta_data();
		$order->apply_changes();

		if ( $backfill ) {
			self::$backfilling_order_ids[] = $order->get_id();
			$r_order                       = wc_get_order( $order->get_id() ); // Refresh order to account for DB changes from post hooks.
			$this->maybe_backfill_post_record( $r_order );
			self::$backfilling_order_ids = array_diff( self::$backfilling_order_ids, array( $order->get_id() ) );
		}
		$this->clear_caches( $order );
	}

	/**
	 * Method to update an order in the database.
	 *
	 * @param \WC_Order $order Order object.
	 */
	public function update( &$order ) {
		$previous_status = ArrayUtil::get_value_or_default( $order->get_data(), 'status' );
		$changes         = $order->get_changes();

		// Before updating, ensure date paid is set if missing.
		if (
			! $order->get_date_paid( 'edit' )
			&& version_compare( $order->get_version( 'edit' ), '3.0', '<' )
			&& $order->has_status( apply_filters( 'woocommerce_payment_complete_order_status', $order->needs_processing() ? 'processing' : 'completed', $order->get_id(), $order ) ) // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
		) {
			$order->set_date_paid( $order->get_date_created( 'edit' ) );
		}

		if ( null === $order->get_date_created( 'edit' ) ) {
			$order->set_date_created( time() );
		}

		$order->set_version( Constants::get_constant( 'WC_VERSION' ) );

		// Fetch changes.
		$changes = $order->get_changes();
		$this->persist_updates( $order );

		// Update download permissions if necessary.
		if ( array_key_exists( 'billing_email', $changes ) || array_key_exists( 'customer_id', $changes ) ) {
			$data_store = \WC_Data_Store::load( 'customer-download' );
			$data_store->update_user_by_order_id( $order->get_id(), $order->get_customer_id(), $order->get_billing_email() );
		}

		// Mark user account as active.
		if ( array_key_exists( 'customer_id', $changes ) ) {
			wc_update_user_last_active( $order->get_customer_id() );
		}

		$order->apply_changes();
		$this->clear_caches( $order );

		// For backwards compatibility, moving an auto-draft order to a valid status triggers the 'woocommerce_new_order' hook.
		if ( ! empty( $changes['status'] ) && 'auto-draft' === $previous_status ) {
			do_action( 'woocommerce_new_order', $order->get_id(), $order ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
			return;
		}

		// For backwards compat with CPT, trashing/untrashing and changing previously datastore-level props does not trigger the update hook.
		if ( ( ! empty( $changes['status'] ) && in_array( 'trash', array( $changes['status'], $previous_status ), true ) )
			|| ! array_diff_key( $changes, array_flip( $this->get_post_data_store_for_backfill()->get_internal_data_store_key_getters() ) ) ) {
			return;
		}

		do_action( 'woocommerce_update_order', $order->get_id(), $order ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
	}

	/**
	 * Proxy to udpating order meta. Here for backward compatibility reasons.
	 *
	 * @param \WC_Order $order Order object.
	 *
	 * @return void
	 */
	protected function update_post_meta( &$order ) {
		$this->update_order_meta( $order );
	}

	/**
	 * Helper method that is responsible for persisting order updates to the database.
	 *
	 * This is expected to be reused by other order types, and should not contain any specific metadata updates or actions.
	 *
	 * @param \WC_Order $order Order object.
	 * @param bool      $backfill Whether to backfill data to post tables.
	 *
	 * @return array $changes Array of changes.
	 *
	 * @throws \Exception When unable to persist order.
	 */
	protected function persist_updates( &$order, $backfill = true ) {
		// Fetch changes.
		$changes = $order->get_changes();

		if ( ! isset( $changes['date_modified'] ) ) {
			$order->set_date_modified( current_time( 'mysql' ) );
		}

		$this->persist_order_to_db( $order );
		$order->save_meta_data();

		if ( $backfill ) {
			self::$backfilling_order_ids[] = $order->get_id();
			$this->clear_caches( $order );
			$r_order = wc_get_order( $order->get_id() ); // Refresh order to account for DB changes from post hooks.
			$this->maybe_backfill_post_record( $r_order );
			self::$backfilling_order_ids = array_diff( self::$backfilling_order_ids, array( $order->get_id() ) );
		}

		return $changes;
	}

	/**
	 * Helper method to check whether to backfill post record.
	 *
	 * @return bool
	 */
	private function should_backfill_post_record() {
		$data_sync = wc_get_container()->get( DataSynchronizer::class );
		return $data_sync->data_sync_is_enabled();
	}

	/**
	 * Helper function to decide whether to backfill post record.
	 *
	 * @param \WC_Abstract_Order $order Order object.
	 *
	 * @return void
	 */
	private function maybe_backfill_post_record( $order ) {
		if ( $this->should_backfill_post_record() ) {
			$this->backfill_post_record( $order );
		}
	}

	/**
	 * Helper method that updates post meta based on an order object.
	 * Mostly used for backwards compatibility purposes in this datastore.
	 *
	 * @param \WC_Order $order Order object.
	 *
	 * @since 7.0.0
	 */
	public function update_order_meta( &$order ) {
		$changes = $order->get_changes();
		$this->update_address_index_meta( $order, $changes );
	}

	/**
	 * Helper function to update billing and shipping address metadata.
	 *
	 * @param \WC_Abstract_Order $order Order Object.
	 * @param array              $changes Array of changes.
	 *
	 * @return void
	 */
	private function update_address_index_meta( $order, $changes ) {
		// If address changed, store concatenated version to make searches faster.
		foreach ( array( 'billing', 'shipping' ) as $address_type ) {
			if ( isset( $changes[ $address_type ] ) ) {
				$order->update_meta_data( "_{$address_type}_address_index", implode( ' ', $order->get_address( $address_type ) ) );
			}
		}
	}

	/**
	 * Return array of coupon_code => meta_key for coupon which have usage limit and have tentative keys.
	 * Pass $coupon_id if key for only one of the coupon is needed.
	 *
	 * @param WC_Order $order     Order object.
	 * @param int      $coupon_id If passed, will return held key for that coupon.
	 *
	 * @return array|string Key value pair for coupon code and meta key name. If $coupon_id is passed, returns meta_key for only that coupon.
	 */
	public function get_coupon_held_keys( $order, $coupon_id = null ) {
		$held_keys = $order->get_meta( '_coupon_held_keys' );
		if ( $coupon_id ) {
			return isset( $held_keys[ $coupon_id ] ) ? $held_keys[ $coupon_id ] : null;
		}
		return $held_keys;
	}

	/**
	 * Return array of coupon_code => meta_key for coupon which have usage limit per customer and have tentative keys.
	 *
	 * @param WC_Order $order Order object.
	 * @param int      $coupon_id If passed, will return held key for that coupon.
	 *
	 * @return mixed
	 */
	public function get_coupon_held_keys_for_users( $order, $coupon_id = null ) {
		$held_keys_for_user = $order->get_meta( '_coupon_held_keys_for_users' );
		if ( $coupon_id ) {
			return isset( $held_keys_for_user[ $coupon_id ] ) ? $held_keys_for_user[ $coupon_id ] : null;
		}
		return $held_keys_for_user;
	}

	/**
	 * Add/Update list of meta keys that are currently being used by this order to hold a coupon.
	 * This is used to figure out what all meta entries we should delete when order is cancelled/completed.
	 *
	 * @param WC_Order $order              Order object.
	 * @param array    $held_keys          Array of coupon_code => meta_key.
	 * @param array    $held_keys_for_user Array of coupon_code => meta_key for held coupon for user.
	 *
	 * @return mixed
	 */
	public function set_coupon_held_keys( $order, $held_keys, $held_keys_for_user ) {
		if ( is_array( $held_keys ) && 0 < count( $held_keys ) ) {
			$order->update_meta_data( '_coupon_held_keys', $held_keys );
		}
		if ( is_array( $held_keys_for_user ) && 0 < count( $held_keys_for_user ) ) {
			$order->update_meta_data( '_coupon_held_keys_for_users', $held_keys_for_user );
		}
	}

	/**
	 * Release all coupons held by this order.
	 *
	 * @param WC_Order $order Current order object.
	 * @param bool     $save  Whether to delete keys from DB right away. Could be useful to pass `false` if you are building a bulk request.
	 */
	public function release_held_coupons( $order, $save = true ) {
		$coupon_held_keys = $this->get_coupon_held_keys( $order );
		if ( is_array( $coupon_held_keys ) ) {
			foreach ( $coupon_held_keys as $coupon_id => $meta_key ) {
				$coupon = new \WC_Coupon( $coupon_id );
				$coupon->delete_meta_data( $meta_key );
				$coupon->save_meta_data();
			}
		}
		$order->delete_meta_data( '_coupon_held_keys' );

		$coupon_held_keys_for_users = $this->get_coupon_held_keys_for_users( $order );
		if ( is_array( $coupon_held_keys_for_users ) ) {
			foreach ( $coupon_held_keys_for_users as $coupon_id => $meta_key ) {
				$coupon = new \WC_Coupon( $coupon_id );
				$coupon->delete_meta_data( $meta_key );
				$coupon->save_meta_data();
			}
		}
		$order->delete_meta_data( '_coupon_held_keys_for_users' );

		if ( $save ) {
			$order->save_meta_data();
		}

	}

	/**
	 * Performs actual query to get orders. Uses `OrdersTableQuery` to build and generate the query.
	 *
	 * @param array $query_vars Query variables.
	 *
	 * @return array|object List of orders and count of orders.
	 */
	public function query( $query_vars ) {
		if ( ! isset( $query_vars['paginate'] ) || ! $query_vars['paginate'] ) {
			$query_vars['no_found_rows'] = true;
		}

		if ( isset( $query_vars['anonymized'] ) ) {
			$query_vars['meta_query'] = $query_vars['meta_query'] ?? array(); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query

			if ( $query_vars['anonymized'] ) {
				$query_vars['meta_query'][] = array(
					'key'   => '_anonymized',
					'value' => 'yes',
				);
			} else {
				$query_vars['meta_query'][] = array(
					'key'     => '_anonymized',
					'compare' => 'NOT EXISTS',
				);
			}
		}

		try {
			$query = new OrdersTableQuery( $query_vars );
		} catch ( \Exception $e ) {
			$query = (object) array(
				'orders'        => array(),
				'found_orders'  => 0,
				'max_num_pages' => 0,
			);
		}

		if ( isset( $query_vars['return'] ) && 'ids' === $query_vars['return'] ) {
			$orders = $query->orders;
		} else {
			$orders = WC()->order_factory->get_orders( $query->orders );
		}

		if ( isset( $query_vars['paginate'] ) && $query_vars['paginate'] ) {
			return (object) array(
				'orders'        => $orders,
				'total'         => $query->found_orders,
				'max_num_pages' => $query->max_num_pages,
			);
		}

		return $orders;
	}

	//phpcs:enable Squiz.Commenting, Generic.Commenting

	/**
	 * Get the SQL needed to create all the tables needed for the custom orders table feature.
	 *
	 * @return string
	 */
	public function get_database_schema() {
		global $wpdb;

		$collate = $wpdb->has_cap( 'collation' ) ? $wpdb->get_charset_collate() : '';

		$orders_table_name           = $this->get_orders_table_name();
		$addresses_table_name        = $this->get_addresses_table_name();
		$operational_data_table_name = $this->get_operational_data_table_name();
		$meta_table                  = $this->get_meta_table_name();

		$max_index_length                   = $this->database_util->get_max_index_length();
		$composite_meta_value_index_length  = max( $max_index_length - 8 - 100 - 1, 20 ); // 8 for order_id, 100 for meta_key, 10 minimum for meta_value.
		$composite_customer_id_email_length = max( $max_index_length - 20, 20 ); // 8 for customer_id, 20 minimum for email.

		$sql = "
CREATE TABLE $orders_table_name (
	id bigint(20) unsigned,
	status varchar(20) null,
	currency varchar(10) null,
	type varchar(20) null,
	tax_amount decimal(26,8) null,
	total_amount decimal(26,8) null,
	customer_id bigint(20) unsigned null,
	billing_email varchar(320) null,
	date_created_gmt datetime null,
	date_updated_gmt datetime null,
	parent_order_id bigint(20) unsigned null,
	payment_method varchar(100) null,
	payment_method_title text null,
	transaction_id varchar(100) null,
	ip_address varchar(100) null,
	user_agent text null,
	customer_note text null,
	PRIMARY KEY (id),
	KEY status (status),
	KEY date_created (date_created_gmt),
	KEY customer_id_billing_email (customer_id, billing_email({$composite_customer_id_email_length})),
	KEY billing_email (billing_email($max_index_length)),
	KEY type_status_date (type, status, date_created_gmt),
	KEY parent_order_id (parent_order_id),
	KEY date_updated (date_updated_gmt)
) $collate;
CREATE TABLE $addresses_table_name (
	id bigint(20) unsigned auto_increment primary key,
	order_id bigint(20) unsigned NOT NULL,
	address_type varchar(20) null,
	first_name text null,
	last_name text null,
	company text null,
	address_1 text null,
	address_2 text null,
	city text null,
	state text null,
	postcode text null,
	country text null,
	email varchar(320) null,
	phone varchar(100) null,
	KEY order_id (order_id),
	UNIQUE KEY address_type_order_id (address_type, order_id),
	KEY email (email($max_index_length)),
	KEY phone (phone)
) $collate;
CREATE TABLE $operational_data_table_name (
	id bigint(20) unsigned auto_increment primary key,
	order_id bigint(20) unsigned NULL,
	created_via varchar(100) NULL,
	woocommerce_version varchar(20) NULL,
	prices_include_tax tinyint(1) NULL,
	coupon_usages_are_counted tinyint(1) NULL,
	download_permission_granted tinyint(1) NULL,
	cart_hash varchar(100) NULL,
	new_order_email_sent tinyint(1) NULL,
	order_key varchar(100) NULL,
	order_stock_reduced tinyint(1) NULL,
	date_paid_gmt datetime NULL,
	date_completed_gmt datetime NULL,
	shipping_tax_amount decimal(26,8) NULL,
	shipping_total_amount decimal(26,8) NULL,
	discount_tax_amount decimal(26,8) NULL,
	discount_total_amount decimal(26,8) NULL,
	recorded_sales tinyint(1) NULL,
	UNIQUE KEY order_id (order_id),
	KEY order_key (order_key)
) $collate;
CREATE TABLE $meta_table (
	id bigint(20) unsigned auto_increment primary key,
	order_id bigint(20) unsigned null,
	meta_key varchar(255),
	meta_value text null,
	KEY meta_key_value (meta_key(100), meta_value($composite_meta_value_index_length)),
	KEY order_id_meta_key_meta_value (order_id, meta_key(100), meta_value($composite_meta_value_index_length))
) $collate;
";

		return $sql;
	}

	/**
	 * Returns an array of meta for an object.
	 *
	 * @param  WC_Data $object WC_Data object.
	 * @return array
	 */
	public function read_meta( &$object ) {
		$raw_meta_data = $this->data_store_meta->read_meta( $object );
		return $this->filter_raw_meta_data( $object, $raw_meta_data );
	}

	/**
	 * Deletes meta based on meta ID.
	 *
	 * @param WC_Data   $object WC_Data object.
	 * @param \stdClass $meta (containing at least ->id).
	 *
	 * @return bool
	 */
	public function delete_meta( &$object, $meta ) {
		if ( $this->should_backfill_post_record() && isset( $meta->id ) ) {
			// Let's get the actual meta key before its deleted for backfilling. We cannot delete just by ID because meta IDs are different in HPOS and posts tables.
			$db_meta = $this->data_store_meta->get_metadata_by_id( $meta->id );
			if ( $db_meta ) {
				$meta->key   = $db_meta->meta_key;
				$meta->value = $db_meta->meta_value;
			}
		}

		$delete_meta     = $this->data_store_meta->delete_meta( $object, $meta );
		$changes_applied = $this->after_meta_change( $object, $meta );

		if ( ! $changes_applied && $object instanceof WC_Abstract_Order && $this->should_backfill_post_record() && isset( $meta->key ) ) {
			self::$backfilling_order_ids[] = $object->get_id();
			delete_post_meta( $object->get_id(), $meta->key, $meta->value );
			self::$backfilling_order_ids = array_diff( self::$backfilling_order_ids, array( $object->get_id() ) );
		}

		return $delete_meta;
	}

	/**
	 * Add new piece of meta.
	 *
	 * @param WC_Data   $object WC_Data object.
	 * @param \stdClass $meta (containing ->key and ->value).
	 *
	 * @return int|bool  meta ID or false on failure
	 */
	public function add_meta( &$object, $meta ) {
		$add_meta        = $this->data_store_meta->add_meta( $object, $meta );
		$meta->id        = $add_meta;
		$changes_applied = $this->after_meta_change( $object, $meta );

		if ( ! $changes_applied && $object instanceof WC_Abstract_Order && $this->should_backfill_post_record() ) {
			self::$backfilling_order_ids[] = $object->get_id();
			add_post_meta( $object->get_id(), $meta->key, $meta->value );
			self::$backfilling_order_ids = array_diff( self::$backfilling_order_ids, array( $object->get_id() ) );
		}

		return $add_meta;
	}

	/**
	 * Update meta.
	 *
	 * @param WC_Data   $object WC_Data object.
	 * @param \stdClass $meta (containing ->id, ->key and ->value).
	 *
	 * @return bool The number of rows updated, or false on error.
	 */
	public function update_meta( &$object, $meta ) {
		$update_meta     = $this->data_store_meta->update_meta( $object, $meta );
		$changes_applied = $this->after_meta_change( $object, $meta );

		if ( ! $changes_applied && $object instanceof WC_Abstract_Order && $this->should_backfill_post_record() ) {
			self::$backfilling_order_ids[] = $object->get_id();
			update_post_meta( $object->get_id(), $meta->key, $meta->value );
			self::$backfilling_order_ids = array_diff( self::$backfilling_order_ids, array( $object->get_id() ) );
		}

		return $update_meta;
	}

	/**
	 * Perform after meta change operations, including updating the date_modified field, clearing caches and applying changes.
	 *
	 * @param WC_Abstract_Order $order Order object.
	 * @param \WC_Meta_Data     $meta  Metadata object.
	 *
	 * @return bool True if changes were applied, false otherwise.
	 */
	protected function after_meta_change( &$order, $meta ) {
		method_exists( $meta, 'apply_changes' ) && $meta->apply_changes();

		// Prevent this happening multiple time in same request.
		if ( $this->should_save_after_meta_change( $order ) ) {
			$order->set_date_modified( current_time( 'mysql' ) );
			$order->save();
			return true;
		} else {
			$order_cache = wc_get_container()->get( OrderCache::class );
			$order_cache->remove( $order->get_id() );
		}

		return false;
	}

	/**
	 * Helper function to check whether the modified date needs to be updated after a meta save.
	 *
	 * This method prevents order->save() call multiple times in the same request after any meta update by checking if:
	 * 1. Order modified date is already the current date, no updates needed in this case.
	 * 2. If there are changes already queued for order object, then we don't need to update the modified date as it will be updated ina subsequent save() call.
	 *
	 * @param WC_Order $order Order object.
	 *
	 * @return bool Whether the modified date needs to be updated.
	 */
	private function should_save_after_meta_change( $order ) {
		$current_date_time = new \WC_DateTime( current_time( 'mysql', 1 ), new \DateTimeZone( 'GMT' ) );
		return $order->get_date_modified() < $current_date_time && empty( $order->get_changes() );
	}
}
OrdersTableDataStoreMeta.php000064400000001336151547734010012113 0ustar00<?php
/**
 * OrdersTableDataStoreMeta class file.
 */

namespace Automattic\WooCommerce\Internal\DataStores\Orders;

use Automattic\WooCommerce\Internal\DataStores\CustomMetaDataStore;

/**
 * Mimics a WP metadata (i.e. add_metadata(), get_metadata() and friends) implementation using a custom table.
 */
class OrdersTableDataStoreMeta extends CustomMetaDataStore {

	/**
	 * Returns the name of the table used for storage.
	 *
	 * @return string
	 */
	protected function get_table_name() {
		return OrdersTableDataStore::get_meta_table_name();
	}

	/**
	 * Returns the name of the field/column used for associating meta with objects.
	 *
	 * @return string
	 */
	protected function get_object_id_field() {
		return 'order_id';
	}

}
OrdersTableFieldQuery.php000064400000020740151547734010011467 0ustar00<?php
namespace Automattic\WooCommerce\Internal\DataStores\Orders;

defined( 'ABSPATH' ) || exit;

/**
 * Provides the implementation for `field_query` in {@see OrdersTableQuery} used to build
 * complex queries against order fields in the database.
 *
 * @internal
 */
class OrdersTableFieldQuery {

	/**
	 * List of valid SQL operators to use as field_query 'compare' values.
	 *
	 * @var array
	 */
	private const VALID_COMPARISON_OPERATORS = array(
		'=',
		'!=',
		'LIKE',
		'NOT LIKE',
		'IN',
		'NOT IN',
		'EXISTS',
		'NOT EXISTS',
		'RLIKE',
		'REGEXP',
		'NOT REGEXP',
		'>',
		'>=',
		'<',
		'<=',
		'BETWEEN',
		'NOT BETWEEN',
	);

	/**
	 * The original query object.
	 *
	 * @var OrdersTableQuery
	 */
	private $query = null;

	/**
	 * Determines whether the field query should produce no results due to an invalid argument.
	 *
	 * @var boolean
	 */
	private $force_no_results = false;

	/**
	 * Holds a sanitized version of the `field_query`.
	 *
	 * @var array
	 */
	private $queries = array();

	/**
	 * JOIN clauses to add to the main SQL query.
	 *
	 * @var array
	 */
	private $join = array();

	/**
	 * WHERE clauses to add to the main SQL query.
	 *
	 * @var array
	 */
	private $where = array();

	/**
	 * Table aliases in use by the field query. Used to keep track of JOINs and optimize when possible.
	 *
	 * @var array
	 */
	private $table_aliases = array();


	/**
	 * Constructor.
	 *
	 * @param OrdersTableQuery $q The main query being performed.
	 */
	public function __construct( OrdersTableQuery $q ) {
		$field_query = $q->get( 'field_query' );

		if ( ! $field_query || ! is_array( $field_query ) ) {
			return;
		}

		$this->query   = $q;
		$this->queries = $this->sanitize_query( $field_query );
		$this->where   = ( ! $this->force_no_results ) ? $this->process( $this->queries ) : '1=0';
	}

	/**
	 * Sanitizes the field_query argument.
	 *
	 * @param array $q A field_query array.
	 * @return array A sanitized field query array.
	 * @throws \Exception When field table info is missing.
	 */
	private function sanitize_query( array $q ) {
		$sanitized = array();

		foreach ( $q as $key => $arg ) {
			if ( 'relation' === $key ) {
				$relation = $arg;
			} elseif ( ! is_array( $arg ) ) {
				continue;
			} elseif ( $this->is_atomic( $arg ) ) {
				if ( isset( $arg['value'] ) && array() === $arg['value'] ) {
					continue;
				}

				// Sanitize 'compare'.
				$arg['compare'] = strtoupper( $arg['compare'] ?? '=' );
				$arg['compare'] = in_array( $arg['compare'], self::VALID_COMPARISON_OPERATORS, true ) ? $arg['compare'] : '=';

				if ( '=' === $arg['compare'] && isset( $arg['value'] ) && is_array( $arg['value'] ) ) {
					$arg['compare'] = 'IN';
				}

				// Sanitize 'cast'.
				$arg['cast'] = $this->sanitize_cast_type( $arg['type'] ?? '' );

				$field_info = $this->query->get_field_mapping_info( $arg['field'] );
				if ( ! $field_info ) {
					$this->force_no_results = true;
					continue;
				}

				$arg = array_merge( $arg, $field_info );

				$sanitized[ $key ] = $arg;
			} else {
				$sanitized_arg = $this->sanitize_query( $arg );

				if ( $sanitized_arg ) {
					$sanitized[ $key ] = $sanitized_arg;
				}
			}
		}

		if ( $sanitized ) {
			$sanitized['relation'] = 1 === count( $sanitized ) ? 'OR' : $this->sanitize_relation( $relation ?? 'AND' );
		}

		return $sanitized;
	}

	/**
	 * Makes sure we use an AND or OR relation. Defaults to AND.
	 *
	 * @param string $relation An unsanitized relation prop.
	 * @return string
	 */
	private function sanitize_relation( string $relation ): string {
		if ( ! empty( $relation ) && 'OR' === strtoupper( $relation ) ) {
			return 'OR';
		}

		return 'AND';
	}

	/**
	 * Processes field_query entries and generates the necessary table aliases, JOIN statements and WHERE conditions.
	 *
	 * @param array $q A field query.
	 * @return string An SQL WHERE statement.
	 */
	private function process( array $q ) {
		$where = '';

		if ( empty( $q ) ) {
			return $where;
		}

		if ( $this->is_atomic( $q ) ) {
			$q['alias'] = $this->find_or_create_table_alias_for_clause( $q );
			$where      = $this->generate_where_for_clause( $q );
		} else {
			$relation = $q['relation'];
			unset( $q['relation'] );

			foreach ( $q as $query ) {
				$chunks[] = $this->process( $query );
			}

			if ( 1 === count( $chunks ) ) {
				$where = $chunks[0];
			} else {
				$where = '(' . implode( " {$relation} ", $chunks ) . ')';
			}
		}

		return $where;
	}

	/**
	 * Checks whether a given field_query clause is atomic or not (i.e. not nested).
	 *
	 * @param array $q The field_query clause.
	 * @return boolean TRUE if atomic, FALSE otherwise.
	 */
	private function is_atomic( $q ) {
		return isset( $q['field'] );
	}

	/**
	 * Finds a common table alias that the field_query clause can use, or creates one.
	 *
	 * @param array $q       An atomic field_query clause.
	 * @return string A table alias for use in an SQL JOIN clause.
	 * @throws \Exception When table info for clause is missing.
	 */
	private function find_or_create_table_alias_for_clause( $q ) {
		global $wpdb;

		if ( ! empty( $q['alias'] ) ) {
			return $q['alias'];
		}

		if ( empty( $q['table'] ) || empty( $q['column'] ) ) {
			throw new \Exception( __( 'Missing table info for query arg.', 'woocommerce' ) );
		}

		$join = '';

		if ( isset( $q['mapping_id'] ) ) {
			// Re-use JOINs and aliases from OrdersTableQuery for core tables.
			$alias = $this->query->get_core_mapping_alias( $q['mapping_id'] );
			$join  = $this->query->get_core_mapping_join( $q['mapping_id'] );
		} else {
			$alias = $q['table'];
			$join  = '';
		}

		if ( in_array( $alias, $this->table_aliases, true ) ) {
			return $alias;
		}

		$this->table_aliases[] = $alias;

		if ( $join ) {
			$this->join[ $alias ] = $join;
		}

		return $alias;
	}

	/**
	 * Returns the correct type for a given clause 'type'.
	 *
	 * @param string $type MySQL type.
	 * @return string MySQL type.
	 */
	private function sanitize_cast_type( $type ) {
		$clause_type = strtoupper( $type );

		if ( ! $clause_type || ! preg_match( '/^(?:BINARY|CHAR|DATE|DATETIME|SIGNED|UNSIGNED|TIME|NUMERIC(?:\(\d+(?:,\s?\d+)?\))?|DECIMAL(?:\(\d+(?:,\s?\d+)?\))?)$/', $clause_type ) ) {
			return 'CHAR';
		}

		if ( 'NUMERIC' === $clause_type ) {
			$clause_type = 'SIGNED';
		}

		return $clause_type;
	}

	/**
	 * Generates an SQL WHERE clause for a given field_query atomic clause.
	 *
	 * @param array $clause An atomic field_query clause.
	 * @return string An SQL WHERE clause or an empty string if $clause is invalid.
	 */
	private function generate_where_for_clause( $clause ): string {
		global $wpdb;

		$clause_value = $clause['value'] ?? '';

		if ( in_array( $clause['compare'], array( 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN' ), true ) ) {
			if ( ! is_array( $clause_value ) ) {
				$clause_value = preg_split( '/[,\s]+/', $clause_value );
			}
		} elseif ( is_string( $clause_value ) ) {
			$clause_value = trim( $clause_value );
		}

		$clause_compare = $clause['compare'];

		switch ( $clause_compare ) {
			case 'IN':
			case 'NOT IN':
				$where = $wpdb->prepare( '(' . substr( str_repeat( ',%s', count( $clause_value ) ), 1 ) . ')', $clause_value ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
				break;
			case 'BETWEEN':
			case 'NOT BETWEEN':
				$where = $wpdb->prepare( '%s AND %s', $clause_value[0], $clause_value[1] ?? $clause_value[0] );
				break;
			case 'LIKE':
			case 'NOT LIKE':
				$where = $wpdb->prepare( '%s', '%' . $wpdb->esc_like( $clause_value ) . '%' );
				break;
			case 'EXISTS':
				// EXISTS with a value is interpreted as '='.
				if ( $clause_value ) {
					$clause_compare = '=';
					$where          = $wpdb->prepare( '%s', $clause_value );
				} else {
					$clause_compare = 'IS NOT';
					$where          = 'NULL';
				}

				break;
			case 'NOT EXISTS':
				// 'value' is ignored for NOT EXISTS.
				$clause_compare = 'IS';
				$where          = 'NULL';
				break;
			default:
				$where = $wpdb->prepare( '%s', $clause_value );
				break;
		}

		if ( $where ) {
			if ( 'CHAR' === $clause['cast'] ) {
				return "`{$clause['alias']}`.`{$clause['column']}` {$clause_compare} {$where}";
			} else {
				return "CAST(`{$clause['alias']}`.`{$clause['column']}` AS {$clause['cast']}) {$clause_compare} {$where}";
			}
		}

		return '';
	}

	/**
	 * Returns JOIN and WHERE clauses to be appended to the main SQL query.
	 *
	 * @return array {
	 *     @type string $join  JOIN clause.
	 *     @type string $where WHERE clause.
	 * }
	 */
	public function get_sql_clauses() {
		return array(
			'join'  => $this->join,
			'where' => $this->where ? array( $this->where ) : array(),
		);
	}

}
OrdersTableMetaQuery.php000064400000045036151547734010011337 0ustar00<?php
namespace Automattic\WooCommerce\Internal\DataStores\Orders;

defined( 'ABSPATH' ) || exit;

/**
 * Class used to implement meta queries for the orders table datastore via {@see OrdersTableQuery}.
 * Heavily inspired by WordPress' own `WP_Meta_Query` for backwards compatibility reasons.
 *
 * Parts of the implementation have been adapted from {@link https://core.trac.wordpress.org/browser/tags/6.0.1/src/wp-includes/class-wp-meta-query.php}.
 */
class OrdersTableMetaQuery {

	/**
	 * List of non-numeric SQL operators used for comparisons in meta queries.
	 *
	 * @var array
	 */
	private const NON_NUMERIC_OPERATORS = array(
		'=',
		'!=',
		'LIKE',
		'NOT LIKE',
		'IN',
		'NOT IN',
		'EXISTS',
		'NOT EXISTS',
		'RLIKE',
		'REGEXP',
		'NOT REGEXP',
	);

	/**
	 * List of numeric SQL operators used for comparisons in meta queries.
	 *
	 * @var array
	 */
	private const NUMERIC_OPERATORS = array(
		'>',
		'>=',
		'<',
		'<=',
		'BETWEEN',
		'NOT BETWEEN',

	);

	/**
	 * Prefix used when generating aliases for the metadata table.
	 *
	 * @var string
	 */
	private const ALIAS_PREFIX = 'meta';

	/**
	 * Name of the main orders table.
	 *
	 * @var string
	 */
	private $meta_table = '';

	/**
	 * Name of the metadata table.
	 *
	 * @var string
	 */
	private $orders_table = '';

	/**
	 * Sanitized `meta_query`.
	 *
	 * @var array
	 */
	private $queries = array();

	/**
	 * Flat list of clauses by name.
	 *
	 * @var array
	 */
	private $flattened_clauses = array();

	/**
	 * JOIN clauses to add to the main SQL query.
	 *
	 * @var array
	 */
	private $join = array();

	/**
	 * WHERE clauses to add to the main SQL query.
	 *
	 * @var array
	 */
	private $where = array();

	/**
	 * Table aliases in use by the meta query. Used to optimize JOINs when possible.
	 *
	 * @var array
	 */
	private $table_aliases = array();

	/**
	 * Constructor.
	 *
	 * @param OrdersTableQuery $q The main query being performed.
	 */
	public function __construct( OrdersTableQuery $q ) {
		$meta_query = $q->get( 'meta_query' );

		if ( ! $meta_query ) {
			return;
		}

		$this->queries = $this->sanitize_meta_query( $meta_query );

		$this->meta_table   = $q->get_table_name( 'meta' );
		$this->orders_table = $q->get_table_name( 'orders' );

		$this->build_query();
	}

	/**
	 * Returns JOIN and WHERE clauses to be appended to the main SQL query.
	 *
	 * @return array {
	 *     @type string $join  JOIN clause.
	 *     @type string $where WHERE clause.
	 * }
	 */
	public function get_sql_clauses(): array {
		return array(
			'join'  => $this->sanitize_join( $this->join ),
			'where' => $this->flatten_where_clauses( $this->where ),
		);
	}

	/**
	 * Returns a list of names (corresponding to meta_query clauses) that can be used as an 'orderby' arg.
	 *
	 * @since 7.4
	 *
	 * @return array
	 */
	public function get_orderby_keys(): array {
		if ( ! $this->flattened_clauses ) {
			return array();
		}

		$keys   = array();
		$keys[] = 'meta_value';
		$keys[] = 'meta_value_num';

		$first_clause = reset( $this->flattened_clauses );
		if ( $first_clause && ! empty( $first_clause['key'] ) ) {
			$keys[] = $first_clause['key'];
		}

		$keys = array_merge(
			$keys,
			array_keys( $this->flattened_clauses )
		);

		return $keys;
	}

	/**
	 * Returns an SQL fragment for the given meta_query key that can be used in an ORDER BY clause.
	 * Call {@see 'get_orderby_keys'} to obtain a list of valid keys.
	 *
	 * @since 7.4
	 *
	 * @param string $key The key name.
	 * @return string
	 *
	 * @throws \Exception When an invalid key is passed.
	 */
	public function get_orderby_clause_for_key( string $key ): string {
		$clause = false;

		if ( isset( $this->flattened_clauses[ $key ] ) ) {
			$clause = $this->flattened_clauses[ $key ];
		} else {
			$first_clause = reset( $this->flattened_clauses );

			if ( $first_clause && ! empty( $first_clause['key'] ) ) {
				if ( 'meta_value_num' === $key ) {
					return "{$first_clause['alias']}.meta_value+0";
				}

				if ( 'meta_value' === $key || $first_clause['key'] === $key ) {
					$clause = $first_clause;
				}
			}
		}

		if ( ! $clause ) {
			// translators: %s is a meta_query key.
			throw new \Exception( sprintf( __( 'Invalid meta_query clause key: %s.', 'woocommerce' ), $key ) );
		}

		return "CAST({$clause['alias']}.meta_value AS {$clause['cast']})";
	}

	/**
	 * Checks whether a given meta_query clause is atomic or not (i.e. not nested).
	 *
	 * @param array $arg The meta_query clause.
	 * @return boolean TRUE if atomic, FALSE otherwise.
	 */
	private function is_atomic( array $arg ): bool {
		return isset( $arg['key'] ) || isset( $arg['value'] );
	}

	/**
	 * Sanitizes the meta_query argument.
	 *
	 * @param array $q A meta_query array.
	 * @return array A sanitized meta query array.
	 */
	private function sanitize_meta_query( array $q ): array {
		$sanitized = array();

		foreach ( $q as $key => $arg ) {
			if ( 'relation' === $key ) {
				$relation = $arg;
			} elseif ( ! is_array( $arg ) ) {
				continue;
			} elseif ( $this->is_atomic( $arg ) ) {
				if ( isset( $arg['value'] ) && array() === $arg['value'] ) {
					unset( $arg['value'] );
				}

				$arg['compare']     = isset( $arg['compare'] ) ? strtoupper( $arg['compare'] ) : ( isset( $arg['value'] ) && is_array( $arg['value'] ) ? 'IN' : '=' );
				$arg['compare_key'] = isset( $arg['compare_key'] ) ? strtoupper( $arg['compare_key'] ) : ( isset( $arg['key'] ) && is_array( $arg['key'] ) ? 'IN' : '=' );

				if ( ! in_array( $arg['compare'], self::NON_NUMERIC_OPERATORS, true ) && ! in_array( $arg['compare'], self::NUMERIC_OPERATORS, true ) ) {
					$arg['compare'] = '=';
				}

				if ( ! in_array( $arg['compare_key'], self::NON_NUMERIC_OPERATORS, true ) ) {
					$arg['compare_key'] = '=';
				}

				$sanitized[ $key ]          = $arg;
				$sanitized[ $key ]['index'] = $key;
			} else {
				$sanitized_arg = $this->sanitize_meta_query( $arg );

				if ( $sanitized_arg ) {
					$sanitized[ $key ] = $sanitized_arg;
				}
			}
		}

		if ( $sanitized ) {
			$sanitized['relation'] = 1 === count( $sanitized ) ? 'OR' : $this->sanitize_relation( $relation ?? 'AND' );
		}

		return $sanitized;
	}

	/**
	 * Makes sure we use an AND or OR relation. Defaults to AND.
	 *
	 * @param string $relation An unsanitized relation prop.
	 * @return string
	 */
	private function sanitize_relation( string $relation ): string {
		if ( ! empty( $relation ) && 'OR' === strtoupper( $relation ) ) {
			return 'OR';
		}

		return 'AND';
	}

	/**
	 * Returns the correct type for a given meta type.
	 *
	 * @param string $type MySQL type.
	 * @return string MySQL type.
	 */
	private function sanitize_cast_type( string $type = '' ): string {
		$meta_type = strtoupper( $type );

		if ( ! $meta_type || ! preg_match( '/^(?:BINARY|CHAR|DATE|DATETIME|SIGNED|UNSIGNED|TIME|NUMERIC(?:\(\d+(?:,\s?\d+)?\))?|DECIMAL(?:\(\d+(?:,\s?\d+)?\))?)$/', $meta_type ) ) {
			return 'CHAR';
		}

		if ( 'NUMERIC' === $meta_type ) {
			$meta_type = 'SIGNED';
		}

		return $meta_type;
	}

	/**
	 * Makes sure a JOIN array does not have duplicates.
	 *
	 * @param array $join A JOIN array.
	 * @return array A sanitized JOIN array.
	 */
	private function sanitize_join( array $join ): array {
		return array_filter( array_unique( array_map( 'trim', $join ) ) );
	}

	/**
	 * Flattens a nested WHERE array.
	 *
	 * @param array $where A possibly nested WHERE array with AND/OR operators.
	 * @return string An SQL WHERE clause.
	 */
	private function flatten_where_clauses( $where ): string {
		if ( is_string( $where ) ) {
			return trim( $where );
		}

		$chunks   = array();
		$operator = $this->sanitize_relation( $where['operator'] ?? '' );

		foreach ( $where as $key => $w ) {
			if ( 'operator' === $key ) {
				continue;
			}

			$flattened = $this->flatten_where_clauses( $w );
			if ( $flattened ) {
				$chunks[] = $flattened;
			}
		}

		if ( $chunks ) {
			return '(' . implode( " {$operator} ", $chunks ) . ')';
		} else {
			return '';
		}
	}

	/**
	 * Builds all the required internal bits for this meta query.
	 *
	 * @return void
	 */
	private function build_query(): void {
		if ( ! $this->queries ) {
			return;
		}

		$queries     = $this->queries;
		$sql_where   = $this->process( $queries );
		$this->where = $sql_where;

	}

	/**
	 * Processes meta_query entries and generates the necessary table aliases, JOIN statements and WHERE conditions.
	 *
	 * @param array      $arg    A meta query.
	 * @param null|array $parent The parent of the element being processed.
	 * @return array A nested array of WHERE conditions.
	 */
	private function process( array &$arg, &$parent = null ): array {
		$where = array();

		if ( $this->is_atomic( $arg ) ) {
			$arg['alias'] = $this->find_or_create_table_alias_for_clause( $arg, $parent );
			$arg['cast']  = $this->sanitize_cast_type( $arg['type'] ?? '' );

			$where = array_filter(
				array(
					$this->generate_where_for_clause_key( $arg ),
					$this->generate_where_for_clause_value( $arg ),
				)
			);

			// Store clauses by their key for ORDER BY purposes.
			$flat_clause_key = is_int( $arg['index'] ) ? $arg['alias'] : $arg['index'];

			$unique_flat_key = $flat_clause_key;
			$i               = 1;
			while ( isset( $this->flattened_clauses[ $unique_flat_key ] ) ) {
				$unique_flat_key = $flat_clause_key . '-' . $i;
				$i++;
			}

			$this->flattened_clauses[ $unique_flat_key ] =& $arg;
		} else {
			// Nested.
			$relation = $arg['relation'];
			unset( $arg['relation'] );

			foreach ( $arg as $index => &$clause ) {
				$chunks[] = $this->process( $clause, $arg );
			}

			// Merge chunks of the form OR(m) with the surrounding clause.
			if ( 1 === count( $chunks ) ) {
				$where = $chunks[0];
			} else {
				$where = array_merge(
					array(
						'operator' => $relation,
					),
					$chunks
				);
			}
		}

		return $where;
	}

	/**
	 * Generates a JOIN clause to handle an atomic meta_query clause.
	 *
	 * @param array  $clause An atomic meta_query clause.
	 * @param string $alias  Metadata table alias to use.
	 * @return string An SQL JOIN clause.
	 */
	private function generate_join_for_clause( array $clause, string $alias ): string {
		global $wpdb;

		if ( 'NOT EXISTS' === $clause['compare'] ) {
			if ( 'LIKE' === $clause['compare_key'] ) {
				return $wpdb->prepare(
					"LEFT JOIN {$this->meta_table} AS {$alias} ON ( {$this->orders_table}.id = {$alias}.order_id AND {$alias}.meta_key LIKE %s )", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
					'%' . $wpdb->esc_like( $clause['key'] ) . '%'
				);
			} else {
				return $wpdb->prepare(
					"LEFT JOIN {$this->meta_table} AS {$alias} ON ( {$this->orders_table}.id = {$alias}.order_id AND {$alias}.meta_key = %s )", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
					$clause['key']
				);
			}
		}

		return "INNER JOIN {$this->meta_table} AS {$alias} ON ( {$this->orders_table}.id = {$alias}.order_id )";
	}

	/**
	 * Finds a common table alias that the meta_query clause can use, or creates one.
	 *
	 * @param array $clause       An atomic meta_query clause.
	 * @param array $parent_query The parent query this clause is in.
	 * @return string A table alias for use in an SQL JOIN clause.
	 */
	private function find_or_create_table_alias_for_clause( array $clause, array $parent_query ): string {
		if ( ! empty( $clause['alias'] ) ) {
			return $clause['alias'];
		}

		$alias    = false;
		$siblings = array_filter(
			$parent_query,
			array( __CLASS__, 'is_atomic' )
		);

		foreach ( $siblings as $sibling ) {
			if ( empty( $sibling['alias'] ) ) {
				continue;
			}

			if ( $this->is_operator_compatible_with_shared_join( $clause, $sibling, $parent_query['relation'] ?? 'AND' ) ) {
				$alias = $sibling['alias'];
				break;
			}
		}

		if ( ! $alias ) {
			$alias                 = self::ALIAS_PREFIX . count( $this->table_aliases );
			$this->join[]          = $this->generate_join_for_clause( $clause, $alias );
			$this->table_aliases[] = $alias;
		}

		return $alias;
	}

	/**
	 * Checks whether two meta_query clauses can share a JOIN.
	 *
	 * @param array  $clause    An atomic meta_query clause.
	 * @param array  $sibling   An atomic meta_query clause.
	 * @param string $relation The relation involving both clauses.
	 * @return boolean TRUE if the clauses can share a table alias, FALSE otherwise.
	 */
	private function is_operator_compatible_with_shared_join( array $clause, array $sibling, string $relation = 'AND' ): bool {
		if ( ! $this->is_atomic( $clause ) || ! $this->is_atomic( $sibling ) ) {
			return false;
		}

		$valid_operators = array();

		if ( 'OR' === $relation ) {
			$valid_operators = array( '=', 'IN', 'BETWEEN', 'LIKE', 'REGEXP', 'RLIKE', '>', '>=', '<', '<=' );
		} elseif ( isset( $sibling['key'] ) && isset( $clause['key'] ) && $sibling['key'] === $clause['key'] ) {
			$valid_operators = array( '!=', 'NOT IN', 'NOT LIKE' );
		}

		return in_array( strtoupper( $clause['compare'] ), $valid_operators, true ) && in_array( strtoupper( $sibling['compare'] ), $valid_operators, true );
	}

	/**
	 * Generates an SQL WHERE clause for a given meta_query atomic clause based on its meta key.
	 * Adapted from WordPress' `WP_Meta_Query::get_sql_for_clause()` method.
	 *
	 * @param array $clause An atomic meta_query clause.
	 * @return string An SQL WHERE clause or an empty string if $clause is invalid.
	 */
	private function generate_where_for_clause_key( array $clause ): string {
		global $wpdb;

		if ( ! array_key_exists( 'key', $clause ) ) {
			return '';
		}

		if ( 'NOT EXISTS' === $clause['compare'] ) {
			return "{$clause['alias']}.order_id IS NULL";
		}

		$alias = $clause['alias'];

		if ( in_array( $clause['compare_key'], array( '!=', 'NOT IN', 'NOT LIKE', 'NOT EXISTS', 'NOT REGEXP' ), true ) ) {
			$i                     = count( $this->table_aliases );
			$subquery_alias        = self::ALIAS_PREFIX . $i;
			$this->table_aliases[] = $subquery_alias;

			$meta_compare_string_start  = 'NOT EXISTS (';
			$meta_compare_string_start .= "SELECT 1 FROM {$this->meta_table} {$subquery_alias} ";
			$meta_compare_string_start .= "WHERE {$subquery_alias}.order_id = {$alias}.order_id ";
			$meta_compare_string_end    = 'LIMIT 1';
			$meta_compare_string_end   .= ')';
		}

		switch ( $clause['compare_key'] ) {
			case '=':
			case 'EXISTS':
				$where = $wpdb->prepare( "$alias.meta_key = %s", trim( $clause['key'] ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				break;
			case 'LIKE':
				$meta_compare_value = '%' . $wpdb->esc_like( trim( $clause['key'] ) ) . '%';
				$where              = $wpdb->prepare( "$alias.meta_key LIKE %s", $meta_compare_value ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				break;
			case 'IN':
				$meta_compare_string = "$alias.meta_key IN (" . substr( str_repeat( ',%s', count( $clause['key'] ) ), 1 ) . ')';
				$where               = $wpdb->prepare( $meta_compare_string, $clause['key'] ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
				break;
			case 'RLIKE':
			case 'REGEXP':
				$operator = $clause['compare_key'];
				if ( isset( $clause['type_key'] ) && 'BINARY' === strtoupper( $clause['type_key'] ) ) {
					$cast = 'BINARY';
				} else {
					$cast = '';
				}
				$where = $wpdb->prepare( "$alias.meta_key $operator $cast %s", trim( $clause['key'] ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				break;
			case '!=':
			case 'NOT EXISTS':
				$meta_compare_string = $meta_compare_string_start . "AND $subquery_alias.meta_key = %s " . $meta_compare_string_end;
				$where               = $wpdb->prepare( $meta_compare_string, $clause['key'] ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
				break;
			case 'NOT LIKE':
				$meta_compare_string = $meta_compare_string_start . "AND $subquery_alias.meta_key LIKE %s " . $meta_compare_string_end;

				$meta_compare_value = '%' . $wpdb->esc_like( trim( $clause['key'] ) ) . '%';
				$where              = $wpdb->prepare( $meta_compare_string, $meta_compare_value ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
				break;
			case 'NOT IN':
				$array_subclause     = '(' . substr( str_repeat( ',%s', count( $clause['key'] ) ), 1 ) . ') ';
				$meta_compare_string = $meta_compare_string_start . "AND $subquery_alias.meta_key IN " . $array_subclause . $meta_compare_string_end;
				$where               = $wpdb->prepare( $meta_compare_string, $clause['key'] ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
				break;
			case 'NOT REGEXP':
				$operator = $clause['compare_key'];
				if ( isset( $clause['type_key'] ) && 'BINARY' === strtoupper( $clause['type_key'] ) ) {
					$cast = 'BINARY';
				} else {
					$cast = '';
				}

				$meta_compare_string = $meta_compare_string_start . "AND $subquery_alias.meta_key REGEXP $cast %s " . $meta_compare_string_end;
				$where               = $wpdb->prepare( $meta_compare_string, $clause['key'] ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
				break;
			default:
				$where = '';
				break;
		}

		return $where;
	}

	/**
	 * Generates an SQL WHERE clause for a given meta_query atomic clause based on its meta value.
	 * Adapted from WordPress' `WP_Meta_Query::get_sql_for_clause()` method.
	 *
	 * @param array $clause An atomic meta_query clause.
	 * @return string An SQL WHERE clause or an empty string if $clause is invalid.
	 */
	private function generate_where_for_clause_value( $clause ): string {
		global $wpdb;

		if ( ! array_key_exists( 'value', $clause ) ) {
			return '';
		}

		$meta_value = $clause['value'];

		if ( in_array( $clause['compare'], array( 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN' ), true ) ) {
			if ( ! is_array( $meta_value ) ) {
				$meta_value = preg_split( '/[,\s]+/', $meta_value );
			}
		} elseif ( is_string( $meta_value ) ) {
			$meta_value = trim( $meta_value );
		}

		$meta_compare = $clause['compare'];

		switch ( $meta_compare ) {
			case 'IN':
			case 'NOT IN':
				$where = $wpdb->prepare( '(' . substr( str_repeat( ',%s', count( $meta_value ) ), 1 ) . ')', $meta_value ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
				break;

			case 'BETWEEN':
			case 'NOT BETWEEN':
				$where = $wpdb->prepare( '%s AND %s', $meta_value[0], $meta_value[1] );
				break;

			case 'LIKE':
			case 'NOT LIKE':
				$where = $wpdb->prepare( '%s', '%' . $wpdb->esc_like( $meta_value ) . '%' );
				break;

			// EXISTS with a value is interpreted as '='.
			case 'EXISTS':
				$meta_compare = '=';
				$where        = $wpdb->prepare( '%s', $meta_value );
				break;

			// 'value' is ignored for NOT EXISTS.
			case 'NOT EXISTS':
				$where = '';
				break;

			default:
				$where = $wpdb->prepare( '%s', $meta_value );
				break;
		}

		if ( $where ) {
			if ( 'CHAR' === $clause['cast'] ) {
				return "{$clause['alias']}.meta_value {$meta_compare} {$where}";
			} else {
				return "CAST({$clause['alias']}.meta_value AS {$clause['cast']}) {$meta_compare} {$where}";
			}
		}
	}

}
OrdersTableQuery.php000064400000127575151547734010010541 0ustar00<?php
// phpcs:disable Generic.Commenting.Todo.TaskFound
/**
 * OrdersTableQuery class file.
 */

namespace Automattic\WooCommerce\Internal\DataStores\Orders;

use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;

defined( 'ABSPATH' ) || exit;

/**
 * This class provides a `WP_Query`-like interface to custom order tables.
 *
 * @property-read int   $found_orders  Number of found orders.
 * @property-read int   $found_posts   Alias of the `$found_orders` property.
 * @property-read int   $max_num_pages Max number of pages matching the current query.
 * @property-read array $orders        Order objects, or order IDs.
 * @property-read array $posts         Alias of the $orders property.
 */
class OrdersTableQuery {

	/**
	 * Values to ignore when parsing query arguments.
	 */
	public const SKIPPED_VALUES = array( '', array(), null );

	/**
	 * Regex used to catch "shorthand" comparisons in date-related query args.
	 */
	public const REGEX_SHORTHAND_DATES = '/([^.<>]*)(>=|<=|>|<|\.\.\.)([^.<>]+)/';

	/**
	 * Highest possible unsigned bigint value (unsigned bigints being the type of the `id` column).
	 *
	 * This is deliberately held as a string, rather than a numeric type, for inclusion within queries.
	 */
	private const MYSQL_MAX_UNSIGNED_BIGINT = '18446744073709551615';

	/**
	 * Names of all COT tables (orders, addresses, operational_data, meta) in the form 'table_id' => 'table name'.
	 *
	 * @var array
	 */
	private $tables = array();

	/**
	 * Column mappings for all COT tables.
	 *
	 * @var array
	 */
	private $mappings = array();

	/**
	 * Query vars after processing and sanitization.
	 *
	 * @var array
	 */
	private $args = array();

	/**
	 * Columns to be selected in the SELECT clause.
	 *
	 * @var array
	 */
	private $fields = array();

	/**
	 * Array of table aliases and conditions used to compute the JOIN clause of the query.
	 *
	 * @var array
	 */
	private $join = array();

	/**
	 * Array of fields and conditions used to compute the WHERE clause of the query.
	 *
	 * @var array
	 */
	private $where = array();

	/**
	 * Field to be used in the GROUP BY clause of the query.
	 *
	 * @var array
	 */
	private $groupby = array();

	/**
	 * Array of fields used to compute the ORDER BY clause of the query.
	 *
	 * @var array
	 */
	private $orderby = array();

	/**
	 * Limits used to compute the LIMIT clause of the query.
	 *
	 * @var array
	 */
	private $limits = array();

	/**
	 * Results (order IDs) for the current query.
	 *
	 * @var array
	 */
	private $orders = array();

	/**
	 * Final SQL query to run after processing of args.
	 *
	 * @var string
	 */
	private $sql = '';

	/**
	 * Final SQL query to count results after processing of args.
	 *
	 * @var string
	 */
	private $count_sql = '';

	/**
	 * The number of pages (when pagination is enabled).
	 *
	 * @var int
	 */
	private $max_num_pages = 0;

	/**
	 * The number of orders found.
	 *
	 * @var int
	 */
	private $found_orders = 0;

	/**
	 * Field query parser.
	 *
	 * @var OrdersTableFieldQuery
	 */
	private $field_query = null;

	/**
	 * Meta query parser.
	 *
	 * @var OrdersTableMetaQuery
	 */
	private $meta_query = null;

	/**
	 * Search query parser.
	 *
	 * @var OrdersTableSearchQuery?
	 */
	private $search_query = null;

	/**
	 * Date query parser.
	 *
	 * @var WP_Date_Query
	 */
	private $date_query = null;

	/**
	 * Instance of the OrdersTableDataStore class.
	 *
	 * @var OrdersTableDataStore
	 */
	private $order_datastore = null;

	/**
	 * Whether to run filters to modify the query or not.
	 *
	 * @var boolean
	 */
	private $suppress_filters = false;

	/**
	 * Sets up and runs the query after processing arguments.
	 *
	 * @param array $args Array of query vars.
	 */
	public function __construct( $args = array() ) {
		// Note that ideally we would inject this dependency via constructor, but that's not possible since this class needs to be backward compatible with WC_Order_Query class.
		$this->order_datastore = wc_get_container()->get( OrdersTableDataStore::class );

		$this->tables   = $this->order_datastore::get_all_table_names_with_id();
		$this->mappings = $this->order_datastore->get_all_order_column_mappings();

		$this->suppress_filters = array_key_exists( 'suppress_filters', $args ) ? (bool) $args['suppress_filters'] : false;
		unset( $args['suppress_filters'] );

		$this->args = $args;

		// TODO: args to be implemented.
		unset( $this->args['customer_note'], $this->args['name'] );

		$this->build_query();
		if ( ! $this->maybe_override_query() ) {
			$this->run_query();
		}
	}

	/**
	 * Lets the `woocommerce_hpos_pre_query` filter override the query.
	 *
	 * @return boolean Whether the query was overridden or not.
	 */
	private function maybe_override_query(): bool {
		/**
		 * Filters the orders array before the query takes place.
		 *
		 * Return a non-null value to bypass the HPOS default order queries.
		 *
		 * If the query includes limits via the `limit`, `page`, or `offset` arguments, we
		 * encourage the `found_orders` and `max_num_pages` properties to also be set.
		 *
		 * @since 8.2.0
		 *
		 * @param array|null $order_data {
		 *     An array of order data.
		 *     @type int[] $orders        Return an array of order IDs data to short-circuit the HPOS query,
		 *                                or null to allow HPOS to run its normal query.
		 *     @type int   $found_orders  The number of orders found.
		 *     @type int   $max_num_pages The number of pages.
		 * }
		 * @param OrdersTableQuery   $query The OrdersTableQuery instance.
		 * @param string             $sql The OrdersTableQuery instance.
		 */
		$pre_query = apply_filters( 'woocommerce_hpos_pre_query', null, $this, $this->sql );
		if ( ! $pre_query || ! isset( $pre_query[0] ) || ! is_array( $pre_query[0] ) ) {
			return false;
		}

		// If the filter set the orders, make sure the others values are set as well and skip running the query.
		list( $this->orders, $this->found_orders, $this->max_num_pages ) = $pre_query;

		if ( ! is_int( $this->found_orders ) || $this->found_orders < 1 ) {
			$this->found_orders = count( $this->orders );
		}

		if ( ! is_int( $this->max_num_pages ) || $this->max_num_pages < 1 ) {
			if ( ! $this->arg_isset( 'limit' ) || ! is_int( $this->args['limit'] ) || $this->args['limit'] < 1 ) {
				$this->args['limit'] = 10;
			}
			$this->max_num_pages = (int) ceil( $this->found_orders / $this->args['limit'] );
		}

		return true;
	}

	/**
	 * Remaps some legacy and `WP_Query` specific query vars to vars available in the customer order table scheme.
	 *
	 * @return void
	 */
	private function maybe_remap_args(): void {
		$mapping = array(
			// WP_Query legacy.
			'post_date'           => 'date_created',
			'post_date_gmt'       => 'date_created_gmt',
			'post_modified'       => 'date_updated',
			'post_modified_gmt'   => 'date_updated_gmt',
			'post_status'         => 'status',
			'_date_completed'     => 'date_completed',
			'_date_paid'          => 'date_paid',
			'paged'               => 'page',
			'post_parent'         => 'parent_order_id',
			'post_parent__in'     => 'parent_order_id',
			'post_parent__not_in' => 'parent_exclude',
			'post__not_in'        => 'exclude',
			'posts_per_page'      => 'limit',
			'p'                   => 'id',
			'post__in'            => 'id',
			'post_type'           => 'type',
			'fields'              => 'return',

			'customer_user'       => 'customer_id',
			'order_currency'      => 'currency',
			'order_version'       => 'woocommerce_version',
			'cart_discount'       => 'discount_total_amount',
			'cart_discount_tax'   => 'discount_tax_amount',
			'order_shipping'      => 'shipping_total_amount',
			'order_shipping_tax'  => 'shipping_tax_amount',
			'order_tax'           => 'tax_amount',

			// Translate from WC_Order_Query to table structure.
			'version'             => 'woocommerce_version',
			'date_modified'       => 'date_updated',
			'date_modified_gmt'   => 'date_updated_gmt',
			'discount_total'      => 'discount_total_amount',
			'discount_tax'        => 'discount_tax_amount',
			'shipping_total'      => 'shipping_total_amount',
			'shipping_tax'        => 'shipping_tax_amount',
			'cart_tax'            => 'tax_amount',
			'total'               => 'total_amount',
			'customer_ip_address' => 'ip_address',
			'customer_user_agent' => 'user_agent',
			'parent'              => 'parent_order_id',
		);

		foreach ( $mapping as $query_key => $table_field ) {
			if ( isset( $this->args[ $query_key ] ) && '' !== $this->args[ $query_key ] ) {
				$this->args[ $table_field ] = $this->args[ $query_key ];
				unset( $this->args[ $query_key ] );
			}
		}

		// meta_query.
		$this->args['meta_query'] = ( $this->arg_isset( 'meta_query' ) && is_array( $this->args['meta_query'] ) ) ? $this->args['meta_query'] : array(); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query

		$shortcut_meta_query = array();
		foreach ( array( 'key', 'value', 'compare', 'type', 'compare_key', 'type_key' ) as $key ) {
			if ( $this->arg_isset( "meta_{$key}" ) ) {
				$shortcut_meta_query[ $key ] = $this->args[ "meta_{$key}" ];
			}
		}

		if ( ! empty( $shortcut_meta_query ) ) {
			if ( ! empty( $this->args['meta_query'] ) ) {
				$this->args['meta_query'] = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
					'relation' => 'AND',
					$shortcut_meta_query,
					$this->args['meta_query'],
				);
			} else {
				$this->args['meta_query'] = array( $shortcut_meta_query ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
			}
		}
	}

	/**
	 * Generates a `WP_Date_Query` compatible query from a given date.
	 * YYYY-MM-DD queries have 'day' precision for backwards compatibility.
	 *
	 * @param mixed  $date The date. Can be a {@see \WC_DateTime}, a timestamp or a string.
	 * @return array An array with keys 'year', 'month', 'day' and possibly 'hour', 'minute' and 'second'.
	 */
	private function date_to_date_query_arg( $date ): array {
		$result    = array(
			'year'  => '',
			'month' => '',
			'day'   => '',
		);

		if ( is_numeric( $date ) ) {
			$date      = new \WC_DateTime( "@{$date}", new \DateTimeZone( 'UTC' ) );
			$precision = 'second';
		} elseif ( ! is_a( $date, 'WC_DateTime' ) ) {
			// For backwards compat (see https://github.com/woocommerce/woocommerce/wiki/wc_get_orders-and-WC_Order_Query#date)
			// only YYYY-MM-DD is considered for date values. Timestamps do support second precision.
			$date      = wc_string_to_datetime( date( 'Y-m-d', strtotime( $date ) ) );
			$precision = 'day';
		}

		$result['year']  = $date->date( 'Y' );
		$result['month'] = $date->date( 'm' );
		$result['day']   = $date->date( 'd' );

		if ( 'second' === $precision ) {
			$result['hour']   = $date->date( 'H' );
			$result['minute'] = $date->date( 'i' );
			$result['second'] = $date->date( 's' );
		}

		return $result;
	}

	/**
	 * Returns UTC-based date query arguments for a combination of local time dates and a date shorthand operator.
	 *
	 * @param  array $dates_raw Array of dates (in local time) to use in combination with the operator.
	 * @param  string $operator One of the operators supported by date queries (<, <=, =, ..., >, >=).
	 * @return array Partial date query arg with relevant dates now UTC-based.
	 *
	 * @since 8.2.0
	 */
	private function local_time_to_gmt_date_query( $dates_raw, $operator ) {
		$result = array();

		// Convert YYYY-MM-DD to UTC timestamp. Per https://github.com/woocommerce/woocommerce/wiki/wc_get_orders-and-WC_Order_Query#date only date is relevant (time is ignored).
		foreach ( $dates_raw as &$raw_date ) {
			$raw_date = is_numeric( $raw_date ) ? $raw_date : strtotime( get_gmt_from_date( date( 'Y-m-d', strtotime( $raw_date ) ) ) );
		}

		$date1  = end( $dates_raw );

		switch ( $operator ) {
			case '>':
				$result = array(
					'after'     => $this->date_to_date_query_arg( $date1 + DAY_IN_SECONDS ),
					'inclusive' => true,
				);
				break;
			case '>=':
				$result = array(
					'after'     => $this->date_to_date_query_arg( $date1 ),
					'inclusive' => true,
				);
				break;
			case '=':
				$result = array(
					'relation' => 'AND',
					array(
						'after'     => $this->date_to_date_query_arg( $date1 ),
						'inclusive' => true,
					),
					array(
						'before'     => $this->date_to_date_query_arg( $date1 + DAY_IN_SECONDS ),
						'inclusive'  => false,
					)
				);
				break;
			case '<=':
				$result = array(
					'before'    => $this->date_to_date_query_arg( $date1 + DAY_IN_SECONDS ),
					'inclusive' => false,
				);
				break;
			case '<':
				$result = array(
					'before'    => $this->date_to_date_query_arg( $date1 ),
					'inclusive' => false,
				);
				break;
			case '...':
				$result = array(
					'relation' => 'AND',
					$this->local_time_to_gmt_date_query( array( $dates_raw[1] ), '<=' ),
					$this->local_time_to_gmt_date_query( array( $dates_raw[0] ), '>=' ),
				);

				break;
		}

		if ( ! $result ) {
			throw new \Exception( 'Please specify a valid date shorthand operator.' );
		}

		return $result;
	}

	/**
	 * Processes date-related query args and merges the result into 'date_query'.
	 *
	 * @return void
	 * @throws \Exception When date args are invalid.
	 */
	private function process_date_args(): void {
		if ( $this->arg_isset( 'date_query' ) ) {
			// Process already passed date queries args.
			$this->args['date_query'] = $this->map_gmt_and_post_keys_to_hpos_keys( $this->args['date_query'] );
		}

		$valid_operators        = array( '>', '>=', '=', '<=', '<', '...' );
		$date_queries           = array();
		$local_to_gmt_date_keys = array(
			'date_created'   => 'date_created_gmt',
			'date_updated'   => 'date_updated_gmt',
			'date_paid'      => 'date_paid_gmt',
			'date_completed' => 'date_completed_gmt',
		);

		$gmt_date_keys   = array_values( $local_to_gmt_date_keys );
		$local_date_keys = array_keys( $local_to_gmt_date_keys );

		$valid_date_keys = array_merge( $gmt_date_keys, $local_date_keys );
		$date_keys       = array_filter( $valid_date_keys, array( $this, 'arg_isset' ) );

		foreach ( $date_keys as $date_key ) {
			$is_local   = in_array( $date_key, $local_date_keys, true );
			$date_value = $this->args[ $date_key ];

			$operator   = '=';
			$dates_raw  = array();
			$dates      = array();

			if ( is_string( $date_value ) && preg_match( self::REGEX_SHORTHAND_DATES, $date_value, $matches ) ) {
				$operator = in_array( $matches[2], $valid_operators, true ) ? $matches[2] : '';

				if ( ! empty( $matches[1] ) ) {
					$dates_raw[] = $matches[1];
				}

				$dates_raw[] = $matches[3];
			} else {
				$dates_raw[] = $date_value;
			}

			if ( empty( $dates_raw ) || ! $operator || ( '...' === $operator && count( $dates_raw ) < 2 ) ) {
				throw new \Exception( 'Invalid date_query' );
			}

			if ( $is_local ) {
				$date_key = $local_to_gmt_date_keys[ $date_key ];

				if ( ! is_numeric( $dates_raw[0] ) && ( ! isset( $dates_raw[1] ) || ! is_numeric( $dates_raw[1] ) ) ) {
					// Only non-numeric args can be considered local time. Timestamps are assumed to be UTC per https://github.com/woocommerce/woocommerce/wiki/wc_get_orders-and-WC_Order_Query#date.
					$date_queries[] = array_merge(
						array(
							'column' => $date_key,
						),
						$this->local_time_to_gmt_date_query( $dates_raw, $operator )
					);

					continue;
				}
			}

			$operator_to_keys = array();

			if ( in_array( $operator, array( '>', '>=', '...' ), true ) ) {
				$operator_to_keys[] = 'after';
			}

			if ( in_array( $operator, array( '<', '<=', '...' ), true ) ) {
				$operator_to_keys[] = 'before';
			}

			$dates          = array_map( array( $this, 'date_to_date_query_arg' ), $dates_raw );
			$date_queries[] = array_merge(
				array(
					'column'    => $date_key,
					'inclusive' => ! in_array( $operator, array( '<', '>' ), true ),
				),
				'=' === $operator
					? end( $dates )
					: array_combine( $operator_to_keys, $dates )
			);
		}

		// Add top-level date parameters to the date_query.
		$tl_query = array();
		foreach ( array( 'hour', 'minute', 'second', 'year', 'monthnum', 'week', 'day', 'year' ) as $tl_key ) {
			if ( $this->arg_isset( $tl_key ) ) {
				$tl_query[ $tl_key ] = $this->args[ $tl_key ];
				unset( $this->args[ $tl_key ] );
			}
		}

		if ( $tl_query ) {
			$tl_query['column'] = 'date_created_gmt';
			$date_queries[]     = $tl_query;
		}

		if ( $date_queries ) {
			if ( ! $this->arg_isset( 'date_query' ) ) {
				$this->args['date_query'] = array();
			}

			$this->args['date_query'] = array_merge(
				array( 'relation' => 'AND' ),
				$date_queries,
				$this->args['date_query']
			);
		}

		$this->process_date_query_columns();
	}

	/**
	 * Helper function to map posts and gmt based keys to HPOS keys.
	 *
	 * @param array $query Date query argument.
	 *
	 * @return array|mixed Date query argument with modified keys.
	 */
	private function map_gmt_and_post_keys_to_hpos_keys( $query ) {
		if ( ! is_array( $query ) ) {
			return $query;
		}

		$post_to_hpos_mappings = array(
			'post_date'         => 'date_created',
			'post_date_gmt'     => 'date_created_gmt',
			'post_modified'     => 'date_updated',
			'post_modified_gmt' => 'date_updated_gmt',
			'_date_completed'   => 'date_completed',
			'_date_paid'        => 'date_paid',
			'date_modified'     => 'date_updated',
			'date_modified_gmt' => 'date_updated_gmt',
		);

		$local_to_gmt_date_keys = array(
			'date_created'   => 'date_created_gmt',
			'date_updated'   => 'date_updated_gmt',
			'date_paid'      => 'date_paid_gmt',
			'date_completed' => 'date_completed_gmt',
		);

		array_walk(
			$query,
			function ( &$sub_query ) {
				$sub_query = $this->map_gmt_and_post_keys_to_hpos_keys( $sub_query );
			}
		);

		if ( ! isset( $query['column'] ) ) {
			return $query;
		}

		if ( isset( $post_to_hpos_mappings[ $query['column'] ] ) ) {
			$query['column'] = $post_to_hpos_mappings[ $query['column'] ];
		}

		// Convert any local dates to GMT.
		if ( isset( $local_to_gmt_date_keys[ $query['column'] ] ) ) {
			$query['column']  = $local_to_gmt_date_keys[ $query['column'] ];
			$op               = isset( $query['after'] ) ? 'after' : 'before';
			$date_value_local = $query[ $op ];
			$date_value_gmt   = wc_string_to_timestamp( get_gmt_from_date( wc_string_to_datetime( $date_value_local ) ) );
			$query[ $op ]     = $this->date_to_date_query_arg( $date_value_gmt );
		}

		return $query;
	}

	/**
	 * Makes sure all 'date_query' columns are correctly prefixed and their respective tables are being JOIN'ed.
	 *
	 * @return void
	 */
	private function process_date_query_columns() {
		global $wpdb;

		$legacy_columns = array(
			'post_date'         => 'date_created_gmt',
			'post_date_gmt'     => 'date_created_gmt',
			'post_modified'     => 'date_modified_gmt',
			'post_modified_gmt' => 'date_updated_gmt',
		);
		$table_mapping  = array(
			'date_created_gmt'   => $this->tables['orders'],
			'date_updated_gmt'   => $this->tables['orders'],
			'date_paid_gmt'      => $this->tables['operational_data'],
			'date_completed_gmt' => $this->tables['operational_data'],
		);

		if ( empty( $this->args['date_query'] ) ) {
			return;
		}

		array_walk_recursive(
			$this->args['date_query'],
			function( &$value, $key ) use ( $legacy_columns, $table_mapping, $wpdb ) {
				if ( 'column' !== $key ) {
					return;
				}

				// Translate legacy columns from wp_posts if necessary.
				$value =
					( isset( $legacy_columns[ $value ] ) || isset( $legacy_columns[ "{$wpdb->posts}.{$value}" ] ) )
					? $legacy_columns[ $value ]
					: $value;

				$table = $table_mapping[ $value ] ?? null;

				if ( ! $table ) {
					return;
				}

				$value = "{$table}.{$value}";

				if ( $table !== $this->tables['orders'] ) {
					$this->join( $table, '', '', 'inner', true );
				}
			}
		);
	}

	/**
	 * Sanitizes the 'status' query var.
	 *
	 * @return void
	 */
	private function sanitize_status(): void {
		// Sanitize status.
		$valid_statuses = array_keys( wc_get_order_statuses() );

		if ( empty( $this->args['status'] ) || 'any' === $this->args['status'] ) {
			$this->args['status'] = $valid_statuses;
		} elseif ( 'all' === $this->args['status'] ) {
			$this->args['status'] = array();
		} else {
			$this->args['status'] = is_array( $this->args['status'] ) ? $this->args['status'] : array( $this->args['status'] );

			foreach ( $this->args['status'] as &$status ) {
				$status = in_array( 'wc-' . $status, $valid_statuses, true ) ? 'wc-' . $status : $status;
			}

			$this->args['status'] = array_unique( array_filter( $this->args['status'] ) );
		}
	}

	/**
	 * Parses and sanitizes the 'orderby' query var.
	 *
	 * @return void
	 */
	private function sanitize_order_orderby(): void {
		// Allowed keys.
		// TODO: rand, meta keys, etc.
		$allowed_keys = array( 'ID', 'id', 'type', 'date', 'modified', 'parent' );

		// Translate $orderby to a valid field.
		$mapping = array(
			'ID'            => "{$this->tables['orders']}.id",
			'id'            => "{$this->tables['orders']}.id",
			'type'          => "{$this->tables['orders']}.type",
			'date'          => "{$this->tables['orders']}.date_created_gmt",
			'date_created'  => "{$this->tables['orders']}.date_created_gmt",
			'modified'      => "{$this->tables['orders']}.date_updated_gmt",
			'date_modified' => "{$this->tables['orders']}.date_updated_gmt",
			'parent'        => "{$this->tables['orders']}.parent_order_id",
			'total'         => "{$this->tables['orders']}.total_amount",
			'order_total'   => "{$this->tables['orders']}.total_amount",
		);

		$order   = $this->args['order'] ?? '';
		$orderby = $this->args['orderby'] ?? '';

		if ( 'none' === $orderby ) {
			return;
		}

		// No need to sanitize, will be processed in calling function.
		if ( 'include' === $orderby || 'post__in' === $orderby ) {
			return;
		}

		if ( is_string( $orderby ) ) {
			$orderby_fields = array_map( 'trim', explode( ' ', $orderby ) );
			$orderby        = array();
			foreach ( $orderby_fields as $field ) {
				$orderby[ $field ] = $order;
			}
		}

		$allowed_orderby = array_merge(
			array_keys( $mapping ),
			array_values( $mapping ),
			$this->meta_query ? $this->meta_query->get_orderby_keys() : array()
		);

		$this->args['orderby'] = array();
		foreach ( $orderby as $order_key => $order ) {
			if ( ! in_array( $order_key, $allowed_orderby, true ) ) {
				continue;
			}

			if ( isset( $mapping[ $order_key ] ) ) {
				$order_key = $mapping[ $order_key ];
			}

			$this->args['orderby'][ $order_key ] = $this->sanitize_order( $order );
		}
	}

	/**
	 * Makes sure the order in an ORDER BY statement is either 'ASC' o 'DESC'.
	 *
	 * @param string $order The unsanitized order.
	 * @return string The sanitized order.
	 */
	private function sanitize_order( string $order ): string {
		$order = strtoupper( $order );

		return in_array( $order, array( 'ASC', 'DESC' ), true ) ? $order : 'DESC';
	}

	/**
	 * Builds the final SQL query to be run.
	 *
	 * @return void
	 */
	private function build_query(): void {
		$this->maybe_remap_args();

		// Field queries.
		if ( ! empty( $this->args['field_query'] ) ) {
			$this->field_query = new OrdersTableFieldQuery( $this );
			$sql               = $this->field_query->get_sql_clauses();
			$this->join        = $sql['join'] ? array_merge( $this->join, $sql['join'] ) : $this->join;
			$this->where       = $sql['where'] ? array_merge( $this->where, $sql['where'] ) : $this->where;
		}

		// Build query.
		$this->process_date_args();
		$this->process_orders_table_query_args();
		$this->process_operational_data_table_query_args();
		$this->process_addresses_table_query_args();

		// Search queries.
		if ( ! empty( $this->args['s'] ) ) {
			$this->search_query = new OrdersTableSearchQuery( $this );
			$sql                = $this->search_query->get_sql_clauses();
			$this->join         = $sql['join'] ? array_merge( $this->join, $sql['join'] ) : $this->join;
			$this->where        = $sql['where'] ? array_merge( $this->where, $sql['where'] ) : $this->where;
		}

		// Meta queries.
		if ( ! empty( $this->args['meta_query'] ) ) {
			$this->meta_query = new OrdersTableMetaQuery( $this );

			$sql = $this->meta_query->get_sql_clauses();

			$this->join  = $sql['join'] ? array_merge( $this->join, $sql['join'] ) : $this->join;
			$this->where = $sql['where'] ? array_merge( $this->where, array( $sql['where'] ) ) : $this->where;

		}

		// Date queries.
		if ( ! empty( $this->args['date_query'] ) ) {
			$this->date_query = new \WP_Date_Query( $this->args['date_query'], "{$this->tables['orders']}.date_created_gmt" );
			$this->where[]    = substr( trim( $this->date_query->get_sql() ), 3 ); // WP_Date_Query includes "AND".
		}

		$this->process_orderby();
		$this->process_limit();

		$orders_table = $this->tables['orders'];

		// Group by is a faster substitute for DISTINCT, as long as we are only selecting IDs. MySQL don't like it when we join tables and use DISTINCT.
		$this->groupby[] = "{$this->tables['orders']}.id";
		$this->fields    = "{$orders_table}.id";
		$fields          = $this->fields;

		// JOIN.
		$join = implode( ' ', array_unique( array_filter( array_map( 'trim', $this->join ) ) ) );

		// WHERE.
		$where = '1=1';
		foreach ( $this->where as $_where ) {
			$where .= " AND ({$_where})";
		}

		// ORDER BY.
		$orderby = $this->orderby ? implode( ', ', $this->orderby ) : '';

		// LIMITS.
		$limits = '';

		if ( ! empty( $this->limits ) && count( $this->limits ) === 2 ) {
			list( $offset, $row_count ) = $this->limits;
			$row_count                  = -1 === $row_count ? self::MYSQL_MAX_UNSIGNED_BIGINT : (int) $row_count;
			$limits                     = 'LIMIT ' . (int) $offset . ', ' . $row_count;
		}

		// GROUP BY.
		$groupby = $this->groupby ? implode( ', ', (array) $this->groupby ) : '';

		$pieces = compact( 'fields', 'join', 'where', 'groupby', 'orderby', 'limits' );

		if ( ! $this->suppress_filters ) {
			/**
			 * Filters all query clauses at once.
			 * Covers the fields (SELECT), JOIN, WHERE, GROUP BY, ORDER BY, and LIMIT clauses.
			 *
			 * @since 7.9.0
			 *
			 * @param string[]         $clauses {
			 *     Associative array of the clauses for the query.
			 *
			 *     @type string $fields  The SELECT clause of the query.
			 *     @type string $join    The JOIN clause of the query.
			 *     @type string $where   The WHERE clause of the query.
			 *     @type string $groupby The GROUP BY clause of the query.
			 *     @type string $orderby The ORDER BY clause of the query.
			 *     @type string $limits  The LIMIT clause of the query.
			 * }
			 * @param OrdersTableQuery $query   The OrdersTableQuery instance (passed by reference).
			 * @param array            $args    Query args.
			 */
			$clauses = (array) apply_filters_ref_array( 'woocommerce_orders_table_query_clauses', array( $pieces, &$this, $this->args ) );

			$fields  = $clauses['fields'] ?? '';
			$join    = $clauses['join'] ?? '';
			$where   = $clauses['where'] ?? '';
			$groupby = $clauses['groupby'] ?? '';
			$orderby = $clauses['orderby'] ?? '';
			$limits  = $clauses['limits'] ?? '';
		}

		$groupby = $groupby ? ( 'GROUP BY ' . $groupby ) : '';
		$orderby = $orderby ? ( 'ORDER BY ' . $orderby ) : '';

		$this->sql = "SELECT $fields FROM $orders_table $join WHERE $where $groupby $orderby $limits";

		if ( ! $this->suppress_filters ) {
			/**
			 * Filters the completed SQL query.
			 *
			 * @since 7.9.0
			 *
			 * @param string           $sql   The complete SQL query.
			 * @param OrdersTableQuery $query The OrdersTableQuery instance (passed by reference).
			 * @param array            $args  Query args.
			 */
			$this->sql = apply_filters_ref_array( 'woocommerce_orders_table_query_sql', array( $this->sql, &$this, $this->args ) );
		}

		$this->build_count_query( $fields, $join, $where, $groupby );
	}

	/**
	 * Build SQL query for counting total number of results.
	 *
	 * @param string $fields Prepared fields for SELECT clause.
	 * @param string $join Prepared JOIN clause.
	 * @param string $where Prepared WHERE clause.
	 * @param string $groupby Prepared GROUP BY clause.
	 */
	private function build_count_query( $fields, $join, $where, $groupby ) {
		if ( ! isset( $this->sql ) || '' === $this->sql ) {
			wc_doing_it_wrong( __FUNCTION__, 'Count query can only be build after main query is built.', '7.3.0' );
		}
		$orders_table    = $this->tables['orders'];
		$this->count_sql = "SELECT COUNT(DISTINCT $fields) FROM  $orders_table $join WHERE $where";
	}

	/**
	 * Returns the table alias for a given table mapping.
	 *
	 * @param string $mapping_id The mapping name (e.g. 'orders' or 'operational_data').
	 * @return string Table alias.
	 *
	 * @since 7.0.0
	 */
	public function get_core_mapping_alias( string $mapping_id ): string {
		return in_array( $mapping_id, array( 'billing_address', 'shipping_address' ), true )
			? $mapping_id
			: $this->tables[ $mapping_id ];
	}

	/**
	 * Returns an SQL JOIN clause that can be used to join the main orders table with another order table.
	 *
	 * @param string $mapping_id The mapping name (e.g. 'orders' or 'operational_data').
	 * @return string The JOIN clause.
	 *
	 * @since 7.0.0
	 */
	public function get_core_mapping_join( string $mapping_id ): string {
		global $wpdb;

		if ( 'orders' === $mapping_id ) {
			return '';
		}

		$is_address_mapping = in_array( $mapping_id, array( 'billing_address', 'shipping_address' ), true );

		$alias   = $this->get_core_mapping_alias( $mapping_id );
		$table   = $is_address_mapping ? $this->tables['addresses'] : $this->tables[ $mapping_id ];
		$join    = '';
		$join_on = '';

		$join .= "INNER JOIN `{$table}`" . ( $alias !== $table ? " AS `{$alias}`" : '' );

		if ( isset( $this->mappings[ $mapping_id ]['order_id'] ) ) {
			$join_on .= "`{$this->tables['orders']}`.id = `{$alias}`.order_id";
		}

		if ( $is_address_mapping ) {
			$join_on .= $wpdb->prepare( " AND `{$alias}`.address_type = %s", substr( $mapping_id, 0, -8 ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		}

		return $join . ( $join_on ? " ON ( {$join_on} )" : '' );
	}

	/**
	 * JOINs the main orders table with another table.
	 *
	 * @param string  $table      Table name (including prefix).
	 * @param string  $alias      Table alias to use. Defaults to $table.
	 * @param string  $on         ON clause. Defaults to "wc_orders.id = {$alias}.order_id".
	 * @param string  $join_type  JOIN type: LEFT, RIGHT or INNER.
	 * @param boolean $alias_once If TRUE, table won't be JOIN'ed again if already JOIN'ed.
	 * @return void
	 * @throws \Exception When an error occurs, such as trying to re-use an alias with $alias_once = FALSE.
	 */
	private function join( string $table, string $alias = '', string $on = '', string $join_type = 'inner', bool $alias_once = false ) {
		$alias     = empty( $alias ) ? $table : $alias;
		$join_type = strtoupper( trim( $join_type ) );

		if ( $this->tables['orders'] === $alias ) {
			// translators: %s is a table name.
			throw new \Exception( sprintf( __( '%s can not be used as a table alias in OrdersTableQuery', 'woocommerce' ), $alias ) );
		}

		if ( empty( $on ) ) {
			if ( $this->tables['orders'] === $table ) {
				$on = "`{$this->tables['orders']}`.id = `{$alias}`.id";
			} else {
				$on = "`{$this->tables['orders']}`.id = `{$alias}`.order_id";
			}
		}

		if ( isset( $this->join[ $alias ] ) ) {
			if ( ! $alias_once ) {
				// translators: %s is a table name.
				throw new \Exception( sprintf( __( 'Can not re-use table alias "%s" in OrdersTableQuery.', 'woocommerce' ), $alias ) );
			}

			return;
		}

		if ( '' === $join_type || ! in_array( $join_type, array( 'LEFT', 'RIGHT', 'INNER' ), true ) ) {
			$join_type = 'INNER';
		}

		$sql_join  = '';
		$sql_join .= "{$join_type} JOIN `{$table}` ";
		$sql_join .= ( $alias !== $table ) ? "AS `{$alias}` " : '';
		$sql_join .= "ON ( {$on} )";

		$this->join[ $alias ] = $sql_join;
	}

	/**
	 * Generates a properly escaped and sanitized WHERE condition for a given field.
	 *
	 * @param string $table    The table the field belongs to.
	 * @param string $field    The field or column name.
	 * @param string $operator The operator to use in the condition. Defaults to '=' or 'IN' depending on $value.
	 * @param mixed  $value    The value.
	 * @param string $type     The column type as specified in {@see OrdersTableDataStore} column mappings.
	 * @return string The resulting WHERE condition.
	 */
	public function where( string $table, string $field, string $operator, $value, string $type ): string {
		global $wpdb;

		$db_util  = wc_get_container()->get( DatabaseUtil::class );
		$operator = strtoupper( '' !== $operator ? $operator : '=' );

		try {
			$format = $db_util->get_wpdb_format_for_type( $type );
		} catch ( \Exception $e ) {
			$format = '%s';
		}

		// = and != can be shorthands for IN and NOT in for array values.
		if ( is_array( $value ) && '=' === $operator ) {
			$operator = 'IN';
		} elseif ( is_array( $value ) && '!=' === $operator ) {
			$operator = 'NOT IN';
		}

		if ( ! in_array( $operator, array( '=', '!=', 'IN', 'NOT IN' ), true ) ) {
			return false;
		}

		if ( is_array( $value ) ) {
			$value = array_map( array( $db_util, 'format_object_value_for_db' ), $value, array_fill( 0, count( $value ), $type ) );
		} else {
			$value = $db_util->format_object_value_for_db( $value, $type );
		}

		if ( is_array( $value ) ) {
			$placeholder = array_fill( 0, count( $value ), $format );
			$placeholder = '(' . implode( ',', $placeholder ) . ')';
		} else {
			$placeholder = $format;
		}

		$sql = $wpdb->prepare( "{$table}.{$field} {$operator} {$placeholder}", $value ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare

		return $sql;
	}

	/**
	 * Processes fields related to the orders table.
	 *
	 * @return void
	 */
	private function process_orders_table_query_args(): void {
		$this->sanitize_status();

		$fields = array_filter(
			array(
				'id',
				'status',
				'type',
				'currency',
				'tax_amount',
				'customer_id',
				'billing_email',
				'total_amount',
				'parent_order_id',
				'payment_method',
				'payment_method_title',
				'transaction_id',
				'ip_address',
				'user_agent',
			),
			array( $this, 'arg_isset' )
		);

		foreach ( $fields as $arg_key ) {
			$this->where[] = $this->where( $this->tables['orders'], $arg_key, '=', $this->args[ $arg_key ], $this->mappings['orders'][ $arg_key ]['type'] );
		}

		if ( $this->arg_isset( 'parent_exclude' ) ) {
			$this->where[] = $this->where( $this->tables['orders'], 'parent_order_id', '!=', $this->args['parent_exclude'], 'int' );
		}

		if ( $this->arg_isset( 'exclude' ) ) {
			$this->where[] = $this->where( $this->tables['orders'], 'id', '!=', $this->args['exclude'], 'int' );
		}

		// 'customer' is a very special field.
		if ( $this->arg_isset( 'customer' ) ) {
			$customer_query = $this->generate_customer_query( $this->args['customer'] );

			if ( $customer_query ) {
				$this->where[] = $customer_query;
			}
		}
	}

	/**
	 * Generate SQL conditions for the 'customer' query.
	 *
	 * @param array  $values   List of customer ids or emails.
	 * @param string $relation 'OR' or 'AND' relation used to build the customer query.
	 * @return string SQL to be used in a WHERE clause.
	 */
	private function generate_customer_query( $values, string $relation = 'OR' ): string {
		$values = is_array( $values ) ? $values : array( $values );
		$ids    = array();
		$emails = array();

		foreach ( $values as $value ) {
			if ( is_array( $value ) ) {
				$sql      = $this->generate_customer_query( $value, 'AND' );
				$pieces[] = $sql ? '(' . $sql . ')' : '';
			} elseif ( is_numeric( $value ) ) {
				$ids[] = absint( $value );
			} elseif ( is_string( $value ) && is_email( $value ) ) {
				$emails[] = sanitize_email( $value );
			} else {
				// Invalid query.
				$pieces[] = '1=0';
			}
		}

		if ( $ids ) {
			$pieces[] = $this->where( $this->tables['orders'], 'customer_id', '=', $ids, 'int' );
		}

		if ( $emails ) {
			$pieces[] = $this->where( $this->tables['orders'], 'billing_email', '=', $emails, 'string' );
		}

		return $pieces ? implode( " $relation ", $pieces ) : '';
	}

	/**
	 * Processes fields related to the operational data table.
	 *
	 * @return void
	 */
	private function process_operational_data_table_query_args(): void {
		$fields = array_filter(
			array(
				'created_via',
				'woocommerce_version',
				'prices_include_tax',
				'order_key',
				'discount_total_amount',
				'discount_tax_amount',
				'shipping_total_amount',
				'shipping_tax_amount',
			),
			array( $this, 'arg_isset' )
		);

		if ( ! $fields ) {
			return;
		}

		$this->join(
			$this->tables['operational_data'],
			'',
			'',
			'inner',
			true
		);

		foreach ( $fields as $arg_key ) {
			$this->where[] = $this->where( $this->tables['operational_data'], $arg_key, '=', $this->args[ $arg_key ], $this->mappings['operational_data'][ $arg_key ]['type'] );
		}
	}

	/**
	 * Processes fields related to the addresses table.
	 *
	 * @return void
	 */
	private function process_addresses_table_query_args(): void {
		global $wpdb;

		foreach ( array( 'billing', 'shipping' ) as $address_type ) {
			$fields = array_filter(
				array(
					$address_type . '_first_name',
					$address_type . '_last_name',
					$address_type . '_company',
					$address_type . '_address_1',
					$address_type . '_address_2',
					$address_type . '_city',
					$address_type . '_state',
					$address_type . '_postcode',
					$address_type . '_country',
					$address_type . '_phone',
				),
				array( $this, 'arg_isset' )
			);

			if ( ! $fields ) {
				continue;
			}

			$this->join(
				$this->tables['addresses'],
				$address_type,
				$wpdb->prepare( "{$this->tables['orders']}.id = {$address_type}.order_id AND {$address_type}.address_type = %s", $address_type ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				'inner',
				false
			);

			foreach ( $fields as $arg_key ) {
				$column_name = str_replace( "{$address_type}_", '', $arg_key );

				$this->where[] = $this->where(
					$address_type,
					$column_name,
					'=',
					$this->args[ $arg_key ],
					$this->mappings[ "{$address_type}_address" ][ $column_name ]['type']
				);
			}
		}
	}

	/**
	 * Generates the ORDER BY clause.
	 *
	 * @return void
	 */
	private function process_orderby(): void {
		// 'order' and 'orderby' vars.
		$this->args['order'] = $this->sanitize_order( $this->args['order'] ?? '' );
		$this->sanitize_order_orderby();

		$orderby = $this->args['orderby'];

		if ( 'none' === $orderby ) {
			$this->orderby = '';
			return;
		}

		if ( 'include' === $orderby || 'post__in' === $orderby ) {
			$ids = $this->args['id'] ?? $this->args['includes'];
			if ( empty( $ids ) ) {
				return;
			}
			$ids           = array_map( 'absint', $ids );
			$this->orderby = array( "FIELD( {$this->tables['orders']}.id, " . implode( ',', $ids ) . ' )' );
			return;
		}

		$meta_orderby_keys = $this->meta_query ? $this->meta_query->get_orderby_keys() : array();

		$orderby_array = array();
		foreach ( $this->args['orderby'] as $_orderby => $order ) {
			if ( in_array( $_orderby, $meta_orderby_keys, true ) ) {
				$_orderby = $this->meta_query->get_orderby_clause_for_key( $_orderby );
			}

			$orderby_array[] = "{$_orderby} {$order}";
		}

		$this->orderby = $orderby_array;
	}

	/**
	 * Generates the limits to be used in the LIMIT clause.
	 *
	 * @return void
	 */
	private function process_limit(): void {
		$row_count = ( $this->arg_isset( 'limit' ) ? (int) $this->args['limit'] : false );
		$page      = ( $this->arg_isset( 'page' ) ? absint( $this->args['page'] ) : 1 );
		$offset    = ( $this->arg_isset( 'offset' ) ? absint( $this->args['offset'] ) : false );

		// Bool false indicates no limit was specified; less than -1 means an invalid value was passed (such as -3).
		if ( false === $row_count || $row_count < -1 ) {
			return;
		}

		if ( false === $offset && $row_count > -1 ) {
			$offset = (int) ( ( $page - 1 ) * $row_count );
		}

		$this->limits = array( $offset, $row_count );
	}

	/**
	 * Checks if a query var is set (i.e. not one of the "skipped values").
	 *
	 * @param string $arg_key Query var.
	 * @return bool TRUE if query var is set.
	 */
	public function arg_isset( string $arg_key ): bool {
		return ( isset( $this->args[ $arg_key ] ) && ! in_array( $this->args[ $arg_key ], self::SKIPPED_VALUES, true ) );
	}

	/**
	 * Runs the SQL query.
	 *
	 * @return void
	 */
	private function run_query(): void {
		global $wpdb;

		// Run query.
		$this->orders = array_map( 'absint', $wpdb->get_col( $this->sql ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared

		// Set max_num_pages and found_orders if necessary.
		if ( ( $this->arg_isset( 'no_found_rows' ) && ! $this->args['no_found_rows'] ) || empty( $this->orders ) ) {
			return;
		}

		if ( $this->limits ) {
			$this->found_orders  = absint( $wpdb->get_var( $this->count_sql ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
			$this->max_num_pages = (int) ceil( $this->found_orders / $this->args['limit'] );
		} else {
			$this->found_orders = count( $this->orders );
		}
	}

	/**
	 * Make some private available for backwards compatibility.
	 *
	 * @param string $name Property to get.
	 * @return mixed
	 */
	public function __get( string $name ) {
		switch ( $name ) {
			case 'found_orders':
			case 'found_posts':
				return $this->found_orders;
			case 'max_num_pages':
				return $this->max_num_pages;
			case 'posts':
			case 'orders':
				return $this->orders;
			case 'request':
				return $this->sql;
			default:
				break;
		}
	}

	/**
	 * Returns the value of one of the query arguments.
	 *
	 * @param string $arg_name Query var.
	 * @return mixed
	 */
	public function get( string $arg_name ) {
		return $this->args[ $arg_name ] ?? null;
	}

	/**
	 * Returns the name of one of the OrdersTableDatastore tables.
	 *
	 * @param string $table_id Table identifier. One of 'orders', 'operational_data', 'addresses', 'meta'.
	 * @return string The prefixed table name.
	 * @throws \Exception When table ID is not found.
	 */
	public function get_table_name( string $table_id = '' ): string {
		if ( ! isset( $this->tables[ $table_id ] ) ) {
			// Translators: %s is a table identifier.
			throw new \Exception( sprintf( __( 'Invalid table id: %s.', 'woocommerce' ), $table_id ) );
		}

		return $this->tables[ $table_id ];
	}

	/**
	 * Finds table and mapping information about a field or column.
	 *
	 * @param string $field Field to look for in `<mapping|field_name>.<column|field_name>` format or just `<field_name>`.
	 * @return false|array {
	 *     @type string $table      Full table name where the field is located.
	 *     @type string $mapping_id Unprefixed table or mapping name.
	 *     @type string $field_name Name of the corresponding order field.
	 *     @type string $column     Column in $table that corresponds to the field.
	 *     @type string $type       Field type.
	 * }
	 */
	public function get_field_mapping_info( $field ) {
		global $wpdb;

		$result = array(
			'table'       => '',
			'mapping_id'  => '',
			'field_name'  => '',
			'column'      => '',
			'column_type' => '',
		);

		$mappings_to_search = array();

		if ( false !== strstr( $field, '.' ) ) {
			list( $mapping_or_table, $field_name_or_col ) = explode( '.', $field );

			$mapping_or_table = substr( $mapping_or_table, 0, strlen( $wpdb->prefix ) ) === $wpdb->prefix ? substr( $mapping_or_table, strlen( $wpdb->prefix ) ) : $mapping_or_table;
			$mapping_or_table = 'wc_' === substr( $mapping_or_table, 0, 3 ) ? substr( $mapping_or_table, 3 ) : $mapping_or_table;

			if ( isset( $this->mappings[ $mapping_or_table ] ) ) {
				if ( isset( $this->mappings[ $mapping_or_table ][ $field_name_or_col ] ) ) {
					$result['mapping_id'] = $mapping_or_table;
					$result['column']     = $field_name_or_col;
				} else {
					$mappings_to_search = array( $mapping_or_table );
				}
			}
		} else {
			$field_name_or_col  = $field;
			$mappings_to_search = array_keys( $this->mappings );
		}

		foreach ( $mappings_to_search as $mapping_id ) {
			foreach ( $this->mappings[ $mapping_id ] as $column_name => $column_data ) {
				if ( isset( $column_data['name'] ) && $column_data['name'] === $field_name_or_col ) {
					$result['mapping_id'] = $mapping_id;
					$result['column']     = $column_name;
					break 2;
				}
			}
		}

		if ( ! $result['mapping_id'] || ! $result['column'] ) {
			return false;
		}

		$field_info = $this->mappings[ $result['mapping_id'] ][ $result['column'] ];

		$result['field_name']  = $field_info['name'];
		$result['column_type'] = $field_info['type'];
		$result['table']       = ( in_array( $result['mapping_id'], array( 'billing_address', 'shipping_address' ), true ) )
								? $this->tables['addresses']
								: $this->tables[ $result['mapping_id'] ];

		return $result;
	}

}
OrdersTableRefundDataStore.php000064400000013767151547734010012463 0ustar00<?php
/**
 * Order refund data store. Refunds are based on orders (essentially negative orders) but there is slight difference in how we save them.
 * For example, order save hooks etc can't be fired when saving refund, so we need to do it a separate datastore.
 */

namespace Automattic\WooCommerce\Internal\DataStores\Orders;

use \WC_Cache_Helper;
use \WC_Meta_Data;

/**
 * Class OrdersTableRefundDataStore.
 */
class OrdersTableRefundDataStore extends OrdersTableDataStore {

	/**
	 * Data stored in meta keys, but not considered "meta" for refund.
	 *
	 * @var string[]
	 */
	protected $internal_meta_keys = array(
		'_refund_amount',
		'_refund_reason',
		'_refunded_by',
		'_refunded_payment',
	);

	/**
	 * We do not have and use all the getters and setters from OrderTableDataStore, so we only select the props we actually need.
	 *
	 * @var \string[][]
	 */
	protected $operational_data_column_mapping = array(
		'id'                        => array( 'type' => 'int' ),
		'order_id'                  => array( 'type' => 'int' ),
		'woocommerce_version'       => array(
			'type' => 'string',
			'name' => 'version',
		),
		'prices_include_tax'        => array(
			'type' => 'bool',
			'name' => 'prices_include_tax',
		),
		'coupon_usages_are_counted' => array(
			'type' => 'bool',
			'name' => 'recorded_coupon_usage_counts',
		),
		'shipping_tax_amount'       => array(
			'type' => 'decimal',
			'name' => 'shipping_tax',
		),
		'shipping_total_amount'     => array(
			'type' => 'decimal',
			'name' => 'shipping_total',
		),
		'discount_tax_amount'       => array(
			'type' => 'decimal',
			'name' => 'discount_tax',
		),
		'discount_total_amount'     => array(
			'type' => 'decimal',
			'name' => 'discount_total',
		),
	);

	/**
	 * Delete a refund order from database.
	 *
	 * @param \WC_Order $refund Refund object to delete.
	 * @param array     $args Array of args to pass to the delete method.
	 *
	 * @return void
	 */
	public function delete( &$refund, $args = array() ) {
		$refund_id = $refund->get_id();
		if ( ! $refund_id ) {
			return;
		}

		$refund_cache_key = WC_Cache_Helper::get_cache_prefix( 'orders' ) . 'refunds' . $refund->get_parent_id();
		wp_cache_delete( $refund_cache_key, 'orders' );

		$this->delete_order_data_from_custom_order_tables( $refund_id );
		$refund->set_id( 0 );

		$orders_table_is_authoritative = $refund->get_data_store()->get_current_class_name() === self::class;

		if ( $orders_table_is_authoritative ) {
			$data_synchronizer = wc_get_container()->get( DataSynchronizer::class );
			if ( $data_synchronizer->data_sync_is_enabled() ) {
				// Delete the associated post, which in turn deletes order items, etc. through {@see WC_Post_Data}.
				// Once we stop creating posts for orders, we should do the cleanup here instead.
				wp_delete_post( $refund_id );
			} else {
				$this->handle_order_deletion_with_sync_disabled( $refund_id );
			}
		}
	}

	/**
	 * Helper method to set refund props.
	 *
	 * @param \WC_Order_Refund $refund Refund object.
	 * @param object           $data   DB data object.
	 *
	 * @since 8.0.0
	 */
	protected function set_order_props_from_data( &$refund, $data ) {
		parent::set_order_props_from_data( $refund, $data );
		foreach ( $data->meta_data as $meta ) {
			switch ( $meta->meta_key ) {
				case '_refund_amount':
					$refund->set_amount( $meta->meta_value );
					break;
				case '_refunded_by':
					$refund->set_refunded_by( $meta->meta_value );
					break;
				case '_refunded_payment':
					$refund->set_refunded_payment( wc_string_to_bool( $meta->meta_value ) );
					break;
				case '_refund_reason':
					$refund->set_reason( $meta->meta_value );
					break;
			}
		}
	}

	/**
	 * Method to create a refund in the database.
	 *
	 * @param \WC_Abstract_Order $refund Refund object.
	 */
	public function create( &$refund ) {
		$refund->set_status( 'completed' ); // Refund are always marked completed.
		$this->persist_save( $refund );
	}

	/**
	 * Update refund in database.
	 *
	 * @param \WC_Order $refund Refund object.
	 */
	public function update( &$refund ) {
		$this->persist_updates( $refund );
	}

	/**
	 * Helper method that updates post meta based on an refund object.
	 * Mostly used for backwards compatibility purposes in this datastore.
	 *
	 * @param \WC_Order $refund Refund object.
	 */
	public function update_order_meta( &$refund ) {
		parent::update_order_meta( $refund );

		// Update additional props.
		$updated_props     = array();
		$meta_key_to_props = array(
			'_refund_amount'    => 'amount',
			'_refunded_by'      => 'refunded_by',
			'_refunded_payment' => 'refunded_payment',
			'_refund_reason'    => 'reason',
		);

		$props_to_update = $this->get_props_to_update( $refund, $meta_key_to_props );
		foreach ( $props_to_update as $meta_key => $prop ) {
			$meta_object        = new WC_Meta_Data();
			$meta_object->key   = $meta_key;
			$meta_object->value = $refund->{"get_$prop"}( 'edit' );
			$existing_meta      = $this->data_store_meta->get_metadata_by_key( $refund, $meta_key );
			if ( $existing_meta ) {
				$existing_meta   = $existing_meta[0];
				$meta_object->id = $existing_meta->id;
				$this->update_meta( $refund, $meta_object );
			} else {
				$this->add_meta( $refund, $meta_object );
			}
			$updated_props[] = $prop;
		}

		/**
		 * Fires after updating meta for a order refund.
		 *
		 * @since 2.7.0
		 */
		do_action( 'woocommerce_order_refund_object_updated_props', $refund, $updated_props );
	}

	/**
	 * Get a title for the new post type.
	 *
	 * @return string
	 */
	protected function get_post_title() {
		return sprintf(
		/* translators: %s: Order date */
			__( 'Refund &ndash; %s', 'woocommerce' ),
			( new \DateTime( 'now' ) )->format( _x( 'M d, Y @ h:i A', 'Order date parsed by DateTime::format', 'woocommerce' ) ) // phpcs:ignore WordPress.WP.I18n.MissingTranslatorsComment, WordPress.WP.I18n.UnorderedPlaceholdersText
		);
	}


	/**
	 * Returns data store object to use backfilling.
	 *
	 * @return \WC_Order_Refund_Data_Store_CPT
	 */
	protected function get_post_data_store_for_backfill() {
		return new \WC_Order_Refund_Data_Store_CPT();
	}

}
OrdersTableSearchQuery.php000064400000011022151547734010011642 0ustar00<?php

namespace Automattic\WooCommerce\Internal\DataStores\Orders;

use Exception;

/**
 * Creates the join and where clauses needed to perform an order search using Custom Order Tables.
 *
 * @internal
 */
class OrdersTableSearchQuery {
	/**
	 * Holds the Orders Table Query object.
	 *
	 * @var OrdersTableQuery
	 */
	private $query;

	/**
	 * Holds the search term to be used in the WHERE clauses.
	 *
	 * @var string
	 */
	private $search_term;

	/**
	 * Creates the JOIN and WHERE clauses needed to execute a search of orders.
	 *
	 * @internal
	 *
	 * @param OrdersTableQuery $query The order query object.
	 */
	public function __construct( OrdersTableQuery $query ) {
		global $wpdb;
		$this->query         = $query;
		$this->search_term   = esc_sql( '%' . $wpdb->esc_like( urldecode( $query->get( 's' ) ) ) . '%' );
	}

	/**
	 * Supplies an array of clauses to be used in an order query.
	 *
	 * @internal
	 * @throws Exception If unable to generate either the JOIN or WHERE SQL fragments.
	 *
	 * @return array {
	 *     @type string $join  JOIN clause.
	 *     @type string $where WHERE clause.
	 * }
	 */
	public function get_sql_clauses(): array {
		return array(
			'join'  => array( $this->generate_join() ),
			'where' => array( $this->generate_where() ),
		);
	}

	/**
	 * Generates the necessary JOIN clauses for the order search to be performed.
	 *
	 * @throws Exception May be triggered if a table name cannot be determined.
	 *
	 * @return string
	 */
	private function generate_join(): string {
		$orders_table = $this->query->get_table_name( 'orders' );
		$items_table  = $this->query->get_table_name( 'items' );

		return "
			LEFT JOIN $items_table AS search_query_items ON search_query_items.order_id = $orders_table.id
		";
	}

	/**
	 * Generates the necessary WHERE clauses for the order search to be performed.
	 *
	 * @throws Exception May be triggered if a table name cannot be determined.
	 *
	 * @return string
	 */
	private function generate_where(): string {
		global $wpdb;
		$where             = '';
		$possible_order_id = (string) absint( $this->query->get( 's' ) );
		$order_table       = $this->query->get_table_name( 'orders' );

		// Support the passing of an order ID as the search term.
		if ( (string) $this->query->get( 's' ) === $possible_order_id ) {
			$where = "`$order_table`.id = $possible_order_id OR ";
		}

		$meta_sub_query = $this->generate_where_for_meta_table();

		$where .= $wpdb->prepare(
			"
			search_query_items.order_item_name LIKE %s
			OR `$order_table`.id IN ( $meta_sub_query )
			",
			$this->search_term
		);

		return " ( $where ) ";
	}

	/**
	 * Generates where clause for meta table.
	 *
	 * Note we generate the where clause as a subquery to be used by calling function inside the IN clause. This is against the general wisdom for performance, but in this particular case, a subquery is able to use the order_id-meta_key-meta_value index, which is not possible with a join.
	 *
	 * Since it can use the index, which otherwise would not be possible, it is much faster than both LEFT JOIN or SQL_CALC approach that could have been used.
	 *
	 * @return string The where clause for meta table.
	 */
	private function generate_where_for_meta_table(): string {
		global $wpdb;
		$meta_table  = $this->query->get_table_name( 'meta' );
		$meta_fields = $this->get_meta_fields_to_be_searched();
		return $wpdb->prepare(
			"
SELECT search_query_meta.order_id
FROM $meta_table as search_query_meta
WHERE search_query_meta.meta_key IN ( $meta_fields )
AND search_query_meta.meta_value LIKE %s
GROUP BY search_query_meta.order_id
",
			$this->search_term
		);
	}

	/**
	 * Returns the order meta field keys to be searched.
	 *
	 * These will be returned as a single string, where the meta keys have been escaped, quoted and are
	 * comma-separated (ie, "'abc', 'foo'" - ready for inclusion in a SQL IN() clause).
	 *
	 * @return string
	 */
	private function get_meta_fields_to_be_searched(): string {
		/**
		 * Controls the order meta keys to be included in search queries.
		 *
		 * This hook is used when Custom Order Tables are in use: the corresponding hook when CPT-orders are in use
		 * is 'woocommerce_shop_order_search_fields'.
		 *
		 * @since 7.0.0
		 *
		 * @param array
		 */
		$meta_keys = apply_filters(
			'woocommerce_order_table_search_query_meta_keys',
			array(
				'_billing_address_index',
				'_shipping_address_index',
			)
		);

		$meta_keys = (array) array_map(
			function ( string $meta_key ): string {
				return "'" . esc_sql( wc_clean( $meta_key ) ) . "'";
			},
			$meta_keys
		);

		return implode( ',', $meta_keys );
	}
}
Controller.php000064400000047477151554100370007424 0ustar00<?php
/**
 * REST API Reports orders controller
 *
 * Handles requests to the /reports/orders endpoint.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Orders;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\API\Reports\Controller as ReportsController;
use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface;

/**
 * REST API Reports orders controller class.
 *
 * @internal
 * @extends \Automattic\WooCommerce\Admin\API\Reports\Controller
 */
class Controller extends ReportsController implements ExportableInterface {

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'reports/orders';

	/**
	 * Maps query arguments from the REST request.
	 *
	 * @param array $request Request array.
	 * @return array
	 */
	protected function prepare_reports_query( $request ) {
		$args                        = array();
		$args['before']              = $request['before'];
		$args['after']               = $request['after'];
		$args['page']                = $request['page'];
		$args['per_page']            = $request['per_page'];
		$args['orderby']             = $request['orderby'];
		$args['order']               = $request['order'];
		$args['product_includes']    = (array) $request['product_includes'];
		$args['product_excludes']    = (array) $request['product_excludes'];
		$args['variation_includes']  = (array) $request['variation_includes'];
		$args['variation_excludes']  = (array) $request['variation_excludes'];
		$args['coupon_includes']     = (array) $request['coupon_includes'];
		$args['coupon_excludes']     = (array) $request['coupon_excludes'];
		$args['tax_rate_includes']   = (array) $request['tax_rate_includes'];
		$args['tax_rate_excludes']   = (array) $request['tax_rate_excludes'];
		$args['status_is']           = (array) $request['status_is'];
		$args['status_is_not']       = (array) $request['status_is_not'];
		$args['customer_type']       = $request['customer_type'];
		$args['extended_info']       = $request['extended_info'];
		$args['refunds']             = $request['refunds'];
		$args['match']               = $request['match'];
		$args['order_includes']      = $request['order_includes'];
		$args['order_excludes']      = $request['order_excludes'];
		$args['attribute_is']        = (array) $request['attribute_is'];
		$args['attribute_is_not']    = (array) $request['attribute_is_not'];
		$args['force_cache_refresh'] = $request['force_cache_refresh'];

		return $args;
	}

	/**
	 * Get all reports.
	 *
	 * @param WP_REST_Request $request Request data.
	 * @return array|WP_Error
	 */
	public function get_items( $request ) {
		$query_args   = $this->prepare_reports_query( $request );
		$orders_query = new Query( $query_args );
		$report_data  = $orders_query->get_data();

		$data = array();

		foreach ( $report_data->data as $orders_data ) {
			$orders_data['order_number']    = $this->get_order_number( $orders_data['order_id'] );
			$orders_data['total_formatted'] = $this->get_total_formatted( $orders_data['order_id'] );
			$item                           = $this->prepare_item_for_response( $orders_data, $request );
			$data[]                         = $this->prepare_response_for_collection( $item );
		}

		return $this->add_pagination_headers(
			$request,
			$data,
			(int) $report_data->total,
			(int) $report_data->page_no,
			(int) $report_data->pages
		);
	}

	/**
	 * Prepare a report object for serialization.
	 *
	 * @param stdClass        $report  Report data.
	 * @param WP_REST_Request $request Request object.
	 * @return WP_REST_Response
	 */
	public function prepare_item_for_response( $report, $request ) {
		$data = $report;

		$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
		$data    = $this->add_additional_fields_to_object( $data, $request );
		$data    = $this->filter_response_by_context( $data, $context );

		// Wrap the data in a response object.
		$response = rest_ensure_response( $data );
		$response->add_links( $this->prepare_links( $report ) );

		/**
		 * Filter a report returned from the API.
		 *
		 * Allows modification of the report data right before it is returned.
		 *
		 * @param WP_REST_Response $response The response object.
		 * @param object           $report   The original report object.
		 * @param WP_REST_Request  $request  Request used to generate the response.
		 */
		return apply_filters( 'woocommerce_rest_prepare_report_orders', $response, $report, $request );
	}

	/**
	 * Prepare links for the request.
	 *
	 * @param WC_Reports_Query $object Object data.
	 * @return array
	 */
	protected function prepare_links( $object ) {
		$links = array(
			'order' => array(
				'href' => rest_url( sprintf( '/%s/orders/%d', $this->namespace, $object['order_id'] ) ),
			),
		);

		return $links;
	}

	/**
	 * Get the Report's schema, conforming to JSON Schema.
	 *
	 * @return array
	 */
	public function get_item_schema() {
		$schema = array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => 'report_orders',
			'type'       => 'object',
			'properties' => array(
				'order_id'         => array(
					'description' => __( 'Order ID.', 'woocommerce' ),
					'type'        => 'integer',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'order_number'     => array(
					'description' => __( 'Order Number.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'date_created'     => array(
					'description' => __( "Date the order was created, in the site's timezone.", 'woocommerce' ),
					'type'        => 'date-time',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'date_created_gmt' => array(
					'description' => __( 'Date the order was created, as GMT.', 'woocommerce' ),
					'type'        => 'date-time',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'status'           => array(
					'description' => __( 'Order status.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'customer_id'      => array(
					'description' => __( 'Customer ID.', 'woocommerce' ),
					'type'        => 'integer',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'num_items_sold'   => array(
					'description' => __( 'Number of items sold.', 'woocommerce' ),
					'type'        => 'integer',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'net_total'        => array(
					'description' => __( 'Net total revenue.', 'woocommerce' ),
					'type'        => 'float',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'total_formatted'  => array(
					'description' => __( 'Net total revenue (formatted).', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'customer_type'    => array(
					'description' => __( 'Returning or new customer.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'extended_info'    => array(
					'products' => array(
						'type'        => 'array',
						'readonly'    => true,
						'context'     => array( 'view', 'edit' ),
						'description' => __( 'List of order product IDs, names, quantities.', 'woocommerce' ),
					),
					'coupons'  => array(
						'type'        => 'array',
						'readonly'    => true,
						'context'     => array( 'view', 'edit' ),
						'description' => __( 'List of order coupons.', 'woocommerce' ),
					),
					'customer' => array(
						'type'        => 'object',
						'readonly'    => true,
						'context'     => array( 'view', 'edit' ),
						'description' => __( 'Order customer information.', 'woocommerce' ),
					),
				),
			),
		);

		return $this->add_additional_fields_schema( $schema );
	}

	/**
	 * Get the query params for collections.
	 *
	 * @return array
	 */
	public function get_collection_params() {
		$params                        = array();
		$params['context']             = $this->get_context_param( array( 'default' => 'view' ) );
		$params['page']                = array(
			'description'       => __( 'Current page of the collection.', 'woocommerce' ),
			'type'              => 'integer',
			'default'           => 1,
			'sanitize_callback' => 'absint',
			'validate_callback' => 'rest_validate_request_arg',
			'minimum'           => 1,
		);
		$params['per_page']            = array(
			'description'       => __( 'Maximum number of items to be returned in result set.', 'woocommerce' ),
			'type'              => 'integer',
			'default'           => 10,
			'minimum'           => 0,
			'maximum'           => 100,
			'sanitize_callback' => 'absint',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['after']               = array(
			'description'       => __( 'Limit response to resources published after a given ISO8601 compliant date.', 'woocommerce' ),
			'type'              => 'string',
			'format'            => 'date-time',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['before']              = array(
			'description'       => __( 'Limit response to resources published before a given ISO8601 compliant date.', 'woocommerce' ),
			'type'              => 'string',
			'format'            => 'date-time',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['order']               = array(
			'description'       => __( 'Order sort attribute ascending or descending.', 'woocommerce' ),
			'type'              => 'string',
			'default'           => 'desc',
			'enum'              => array( 'asc', 'desc' ),
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['orderby']             = array(
			'description'       => __( 'Sort collection by object attribute.', 'woocommerce' ),
			'type'              => 'string',
			'default'           => 'date',
			'enum'              => array(
				'date',
				'num_items_sold',
				'net_total',
			),
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['product_includes']    = array(
			'description'       => __( 'Limit result set to items that have the specified product(s) assigned.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'integer',
			),
			'default'           => array(),
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['product_excludes']    = array(
			'description'       => __( 'Limit result set to items that don\'t have the specified product(s) assigned.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'integer',
			),
			'default'           => array(),
			'validate_callback' => 'rest_validate_request_arg',
			'sanitize_callback' => 'wp_parse_id_list',
		);
		$params['variation_includes']  = array(
			'description'       => __( 'Limit result set to items that have the specified variation(s) assigned.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'integer',
			),
			'default'           => array(),
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['variation_excludes']  = array(
			'description'       => __( 'Limit result set to items that don\'t have the specified variation(s) assigned.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'integer',
			),
			'default'           => array(),
			'validate_callback' => 'rest_validate_request_arg',
			'sanitize_callback' => 'wp_parse_id_list',
		);
		$params['coupon_includes']     = array(
			'description'       => __( 'Limit result set to items that have the specified coupon(s) assigned.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'integer',
			),
			'default'           => array(),
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['coupon_excludes']     = array(
			'description'       => __( 'Limit result set to items that don\'t have the specified coupon(s) assigned.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'integer',
			),
			'default'           => array(),
			'validate_callback' => 'rest_validate_request_arg',
			'sanitize_callback' => 'wp_parse_id_list',
		);
		$params['tax_rate_includes']   = array(
			'description'       => __( 'Limit result set to items that have the specified tax rate(s) assigned.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'integer',
			),
			'default'           => array(),
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['tax_rate_excludes']   = array(
			'description'       => __( 'Limit result set to items that don\'t have the specified tax rate(s) assigned.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'integer',
			),
			'default'           => array(),
			'validate_callback' => 'rest_validate_request_arg',
			'sanitize_callback' => 'wp_parse_id_list',
		);
		$params['status_is']           = array(
			'description'       => __( 'Limit result set to items that have the specified order status.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_slug_list',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'enum' => self::get_order_statuses(),
				'type' => 'string',
			),
		);
		$params['status_is_not']       = array(
			'description'       => __( 'Limit result set to items that don\'t have the specified order status.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_slug_list',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'enum' => self::get_order_statuses(),
				'type' => 'string',
			),
		);
		$params['customer_type']       = array(
			'description'       => __( 'Limit result set to returning or new customers.', 'woocommerce' ),
			'type'              => 'string',
			'default'           => '',
			'enum'              => array(
				'',
				'returning',
				'new',
			),
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['refunds']             = array(
			'description'       => __( 'Limit result set to specific types of refunds.', 'woocommerce' ),
			'type'              => 'string',
			'default'           => '',
			'enum'              => array(
				'',
				'all',
				'partial',
				'full',
				'none',
			),
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['extended_info']       = array(
			'description'       => __( 'Add additional piece of info about each coupon to the report.', 'woocommerce' ),
			'type'              => 'boolean',
			'default'           => false,
			'sanitize_callback' => 'wc_string_to_bool',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['order_includes']      = array(
			'description'       => __( 'Limit result set to items that have the specified order ids.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'type' => 'integer',
			),
		);
		$params['order_excludes']      = array(
			'description'       => __( 'Limit result set to items that don\'t have the specified order ids.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'type' => 'integer',
			),
		);
		$params['attribute_is']        = array(
			'description'       => __( 'Limit result set to orders that include products with the specified attributes.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'array',
			),
			'default'           => array(),
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['attribute_is_not']    = array(
			'description'       => __( 'Limit result set to orders that don\'t include products with the specified attributes.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'array',
			),
			'default'           => array(),
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['force_cache_refresh'] = array(
			'description'       => __( 'Force retrieval of fresh data instead of from the cache.', 'woocommerce' ),
			'type'              => 'boolean',
			'sanitize_callback' => 'wp_validate_boolean',
			'validate_callback' => 'rest_validate_request_arg',
		);

		return $params;
	}

	/**
	 * Get customer name column export value.
	 *
	 * @param array $customer Customer from report row.
	 * @return string
	 */
	protected function get_customer_name( $customer ) {
		return $customer['first_name'] . ' ' . $customer['last_name'];
	}

	/**
	 * Get products column export value.
	 *
	 * @param array $products Products from report row.
	 * @return string
	 */
	protected function get_products( $products ) {
		$products_list = array();

		foreach ( $products as $product ) {
			$products_list[] = sprintf(
				/* translators: 1: numeric product quantity, 2: name of product */
				__( '%1$s× %2$s', 'woocommerce' ),
				$product['quantity'],
				$product['name']
			);
		}

		return implode( ', ', $products_list );
	}

	/**
	 * Get coupons column export value.
	 *
	 * @param array $coupons Coupons from report row.
	 * @return string
	 */
	protected function get_coupons( $coupons ) {
		return implode( ', ', wp_list_pluck( $coupons, 'code' ) );
	}

	/**
	 * Get the column names for export.
	 *
	 * @return array Key value pair of Column ID => Label.
	 */
	public function get_export_columns() {
		$export_columns = array(
			'date_created'    => __( 'Date', 'woocommerce' ),
			'order_number'    => __( 'Order #', 'woocommerce' ),
			'total_formatted' => __( 'N. Revenue (formatted)', 'woocommerce' ),
			'status'          => __( 'Status', 'woocommerce' ),
			'customer_name'   => __( 'Customer', 'woocommerce' ),
			'customer_type'   => __( 'Customer type', 'woocommerce' ),
			'products'        => __( 'Product(s)', 'woocommerce' ),
			'num_items_sold'  => __( 'Items sold', 'woocommerce' ),
			'coupons'         => __( 'Coupon(s)', 'woocommerce' ),
			'net_total'       => __( 'N. Revenue', 'woocommerce' ),
		);

		/**
		 * Filter to add or remove column names from the orders report for
		 * export.
		 *
		 * @since 1.6.0
		 */
		return apply_filters(
			'woocommerce_report_orders_export_columns',
			$export_columns
		);
	}

	/**
	 * Get the column values for export.
	 *
	 * @param array $item Single report item/row.
	 * @return array Key value pair of Column ID => Row Value.
	 */
	public function prepare_item_for_export( $item ) {
		$export_item = array(
			'date_created'    => $item['date_created'],
			'order_number'    => $item['order_number'],
			'total_formatted' => $item['total_formatted'],
			'status'          => $item['status'],
			'customer_name'   => isset( $item['extended_info']['customer'] ) ? $this->get_customer_name( $item['extended_info']['customer'] ) : null,
			'customer_type'   => $item['customer_type'],
			'products'        => isset( $item['extended_info']['products'] ) ? $this->get_products( $item['extended_info']['products'] ) : null,
			'num_items_sold'  => $item['num_items_sold'],
			'coupons'         => isset( $item['extended_info']['coupons'] ) ? $this->get_coupons( $item['extended_info']['coupons'] ) : null,
			'net_total'       => $item['net_total'],
		);

		/**
		 * Filter to prepare extra columns in the export item for the orders
		 * report.
		 *
		 * @since 1.6.0
		 */
		return apply_filters(
			'woocommerce_report_orders_prepare_export_item',
			$export_item,
			$item
		);
	}
}
DataStore.php000064400000045535151554100370007160 0ustar00<?php
/**
 * API\Reports\Orders\DataStore class file.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Orders;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore;
use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface;
use Automattic\WooCommerce\Admin\API\Reports\SqlQuery;
use Automattic\WooCommerce\Admin\API\Reports\Cache;
use Automattic\WooCommerce\Admin\API\Reports\TimeInterval;


/**
 * API\Reports\Orders\DataStore.
 */
class DataStore extends ReportsDataStore implements DataStoreInterface {

	/**
	 * Dynamically sets the date column name based on configuration
	 */
	public function __construct() {
		$this->date_column_name = get_option( 'woocommerce_date_type', 'date_paid' );
		parent::__construct();
	}

	/**
	 * Table used to get the data.
	 *
	 * @var string
	 */
	protected static $table_name = 'wc_order_stats';

	/**
	 * Cache identifier.
	 *
	 * @var string
	 */
	protected $cache_key = 'orders';

	/**
	 * Mapping columns to data type to return correct response types.
	 *
	 * @var array
	 */
	protected $column_types = array(
		'order_id'         => 'intval',
		'parent_id'        => 'intval',
		'date_created'     => 'strval',
		'date_created_gmt' => 'strval',
		'status'           => 'strval',
		'customer_id'      => 'intval',
		'net_total'        => 'floatval',
		'total_sales'      => 'floatval',
		'num_items_sold'   => 'intval',
		'customer_type'    => 'strval',
	);

	/**
	 * Data store context used to pass to filters.
	 *
	 * @var string
	 */
	protected $context = 'orders';

	/**
	 * Assign report columns once full table name has been assigned.
	 */
	protected function assign_report_columns() {
		$table_name = self::get_db_table_name();
		// Avoid ambigious columns in SQL query.
		$this->report_columns = array(
			'order_id'         => "DISTINCT {$table_name}.order_id",
			'parent_id'        => "{$table_name}.parent_id",
			// Add 'date' field based on date type setting.
			'date'             => "{$table_name}.{$this->date_column_name} AS date",
			'date_created'     => "{$table_name}.date_created",
			'date_created_gmt' => "{$table_name}.date_created_gmt",
			'status'           => "REPLACE({$table_name}.status, 'wc-', '') as status",
			'customer_id'      => "{$table_name}.customer_id",
			'net_total'        => "{$table_name}.net_total",
			'total_sales'      => "{$table_name}.total_sales",
			'num_items_sold'   => "{$table_name}.num_items_sold",
			'customer_type'    => "(CASE WHEN {$table_name}.returning_customer = 0 THEN 'new' ELSE 'returning' END) as customer_type",
		);
	}

	/**
	 * Updates the database query with parameters used for orders report: coupons and products filters.
	 *
	 * @param array $query_args Query arguments supplied by the user.
	 */
	protected function add_sql_query_params( $query_args ) {
		global $wpdb;
		$order_stats_lookup_table   = self::get_db_table_name();
		$order_coupon_lookup_table  = $wpdb->prefix . 'wc_order_coupon_lookup';
		$order_product_lookup_table = $wpdb->prefix . 'wc_order_product_lookup';
		$order_tax_lookup_table     = $wpdb->prefix . 'wc_order_tax_lookup';
		$operator                   = $this->get_match_operator( $query_args );
		$where_subquery             = array();
		$have_joined_products_table = false;

		$this->add_time_period_sql_params( $query_args, $order_stats_lookup_table );
		$this->get_limit_sql_params( $query_args );
		$this->add_order_by_sql_params( $query_args );

		$status_subquery = $this->get_status_subquery( $query_args );
		if ( $status_subquery ) {
			if ( empty( $query_args['status_is'] ) && empty( $query_args['status_is_not'] ) ) {
				$this->subquery->add_sql_clause( 'where', "AND {$status_subquery}" );
			} else {
				$where_subquery[] = $status_subquery;
			}
		}

		$included_orders = $this->get_included_orders( $query_args );
		if ( $included_orders ) {
			$where_subquery[] = "{$order_stats_lookup_table}.order_id IN ({$included_orders})";
		}

		$excluded_orders = $this->get_excluded_orders( $query_args );
		if ( $excluded_orders ) {
			$where_subquery[] = "{$order_stats_lookup_table}.order_id NOT IN ({$excluded_orders})";
		}

		if ( $query_args['customer_type'] ) {
			$returning_customer = 'returning' === $query_args['customer_type'] ? 1 : 0;
			$where_subquery[]   = "{$order_stats_lookup_table}.returning_customer = {$returning_customer}";
		}

		$refund_subquery = $this->get_refund_subquery( $query_args );
		$this->subquery->add_sql_clause( 'from', $refund_subquery['from_clause'] );
		if ( $refund_subquery['where_clause'] ) {
			$where_subquery[] = $refund_subquery['where_clause'];
		}

		$included_coupons = $this->get_included_coupons( $query_args );
		$excluded_coupons = $this->get_excluded_coupons( $query_args );
		if ( $included_coupons || $excluded_coupons ) {
			$this->subquery->add_sql_clause( 'join', "LEFT JOIN {$order_coupon_lookup_table} ON {$order_stats_lookup_table}.order_id = {$order_coupon_lookup_table}.order_id" );
		}
		if ( $included_coupons ) {
			$where_subquery[] = "{$order_coupon_lookup_table}.coupon_id IN ({$included_coupons})";
		}
		if ( $excluded_coupons ) {
			$where_subquery[] = "({$order_coupon_lookup_table}.coupon_id IS NULL OR {$order_coupon_lookup_table}.coupon_id NOT IN ({$excluded_coupons}))";
		}

		$included_products = $this->get_included_products( $query_args );
		$excluded_products = $this->get_excluded_products( $query_args );
		if ( $included_products || $excluded_products ) {
			$this->subquery->add_sql_clause( 'join', "LEFT JOIN {$order_product_lookup_table} product_lookup" );
			$this->subquery->add_sql_clause( 'join', "ON {$order_stats_lookup_table}.order_id = product_lookup.order_id" );
		}
		if ( $included_products ) {
			$this->subquery->add_sql_clause( 'join', "AND product_lookup.product_id IN ({$included_products})" );
			$where_subquery[] = 'product_lookup.order_id IS NOT NULL';
		}
		if ( $excluded_products ) {
			$this->subquery->add_sql_clause( 'join', "AND product_lookup.product_id IN ({$excluded_products})" );
			$where_subquery[] = 'product_lookup.order_id IS NULL';
		}

		$included_variations = $this->get_included_variations( $query_args );
		$excluded_variations = $this->get_excluded_variations( $query_args );
		if ( $included_variations || $excluded_variations ) {
			$this->subquery->add_sql_clause( 'join', "LEFT JOIN {$order_product_lookup_table} variation_lookup" );
			$this->subquery->add_sql_clause( 'join', "ON {$order_stats_lookup_table}.order_id = variation_lookup.order_id" );
		}
		if ( $included_variations ) {
			$this->subquery->add_sql_clause( 'join', "AND variation_lookup.variation_id IN ({$included_variations})" );
			$where_subquery[] = 'variation_lookup.order_id IS NOT NULL';
		}
		if ( $excluded_variations ) {
			$this->subquery->add_sql_clause( 'join', "AND variation_lookup.variation_id IN ({$excluded_variations})" );
			$where_subquery[] = 'variation_lookup.order_id IS NULL';
		}

		$included_tax_rates = ! empty( $query_args['tax_rate_includes'] ) ? implode( ',', array_map( 'esc_sql', $query_args['tax_rate_includes'] ) ) : false;
		$excluded_tax_rates = ! empty( $query_args['tax_rate_excludes'] ) ? implode( ',', array_map( 'esc_sql', $query_args['tax_rate_excludes'] ) ) : false;
		if ( $included_tax_rates || $excluded_tax_rates ) {
			$this->subquery->add_sql_clause( 'join', "LEFT JOIN {$order_tax_lookup_table} ON {$order_stats_lookup_table}.order_id = {$order_tax_lookup_table}.order_id" );
		}
		if ( $included_tax_rates ) {
			$where_subquery[] = "{$order_tax_lookup_table}.tax_rate_id IN ({$included_tax_rates})";
		}
		if ( $excluded_tax_rates ) {
			$where_subquery[] = "{$order_tax_lookup_table}.tax_rate_id NOT IN ({$excluded_tax_rates}) OR {$order_tax_lookup_table}.tax_rate_id IS NULL";
		}

		$attribute_subqueries = $this->get_attribute_subqueries( $query_args );
		if ( $attribute_subqueries['join'] && $attribute_subqueries['where'] ) {
			$this->subquery->add_sql_clause( 'join', "JOIN {$order_product_lookup_table} ON {$order_stats_lookup_table}.order_id = {$order_product_lookup_table}.order_id" );

			// Add JOINs for matching attributes.
			foreach ( $attribute_subqueries['join'] as $attribute_join ) {
				$this->subquery->add_sql_clause( 'join', $attribute_join );
			}
			// Add WHEREs for matching attributes.
			$where_subquery = array_merge( $where_subquery, $attribute_subqueries['where'] );
		}

		if ( 0 < count( $where_subquery ) ) {
			$this->subquery->add_sql_clause( 'where', 'AND (' . implode( " {$operator} ", $where_subquery ) . ')' );
		}
	}

	/**
	 * Returns the report data based on parameters supplied by the user.
	 *
	 * @param array $query_args  Query parameters.
	 * @return stdClass|WP_Error Data.
	 */
	public function get_data( $query_args ) {
		global $wpdb;

		// These defaults are only partially applied when used via REST API, as that has its own defaults.
		$defaults   = array(
			'per_page'          => get_option( 'posts_per_page' ),
			'page'              => 1,
			'order'             => 'DESC',
			'orderby'           => $this->date_column_name,
			'before'            => TimeInterval::default_before(),
			'after'             => TimeInterval::default_after(),
			'fields'            => '*',
			'product_includes'  => array(),
			'product_excludes'  => array(),
			'coupon_includes'   => array(),
			'coupon_excludes'   => array(),
			'tax_rate_includes' => array(),
			'tax_rate_excludes' => array(),
			'customer_type'     => null,
			'status_is'         => array(),
			'extended_info'     => false,
			'refunds'           => null,
			'order_includes'    => array(),
			'order_excludes'    => array(),
		);
		$query_args = wp_parse_args( $query_args, $defaults );
		$this->normalize_timezones( $query_args, $defaults );

		/*
		 * We need to get the cache key here because
		 * parent::update_intervals_sql_params() modifies $query_args.
		 */
		$cache_key = $this->get_cache_key( $query_args );
		$data      = $this->get_cached_data( $cache_key );

		if ( false === $data ) {
			$this->initialize_queries();

			$data = (object) array(
				'data'    => array(),
				'total'   => 0,
				'pages'   => 0,
				'page_no' => 0,
			);

			$selections = $this->selected_columns( $query_args );
			$params     = $this->get_limit_params( $query_args );
			$this->add_sql_query_params( $query_args );
			/* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
			$db_records_count = (int) $wpdb->get_var(
				"SELECT COUNT( DISTINCT tt.order_id ) FROM (
					{$this->subquery->get_query_statement()}
				) AS tt"
			);
			/* phpcs:enable */

			if ( 0 === $params['per_page'] ) {
				$total_pages = 0;
			} else {
				$total_pages = (int) ceil( $db_records_count / $params['per_page'] );
			}
			if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) {
				$data = (object) array(
					'data'    => array(),
					'total'   => $db_records_count,
					'pages'   => 0,
					'page_no' => 0,
				);
				return $data;
			}

			$this->subquery->clear_sql_clause( 'select' );
			$this->subquery->add_sql_clause( 'select', $selections );
			$this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) );
			$this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) );
			/* phpcs:disable WordPress.DB.PreparedSQL.NotPrepared */
			$orders_data = $wpdb->get_results(
				$this->subquery->get_query_statement(),
				ARRAY_A
			);
			/* phpcs:enable */

			if ( null === $orders_data ) {
				return $data;
			}

			if ( $query_args['extended_info'] ) {
				$this->include_extended_info( $orders_data, $query_args );
			}

			$orders_data = array_map( array( $this, 'cast_numbers' ), $orders_data );
			$data        = (object) array(
				'data'    => $orders_data,
				'total'   => $db_records_count,
				'pages'   => $total_pages,
				'page_no' => (int) $query_args['page'],
			);

			$this->set_cached_data( $cache_key, $data );
		}
		return $data;
	}

	/**
	 * Normalizes order_by clause to match to SQL query.
	 *
	 * @param string $order_by Order by option requeste by user.
	 * @return string
	 */
	protected function normalize_order_by( $order_by ) {
		if ( 'date' === $order_by ) {
			return $this->date_column_name;
		}

		return $order_by;
	}

	/**
	 * Enriches the order data.
	 *
	 * @param array $orders_data Orders data.
	 * @param array $query_args  Query parameters.
	 */
	protected function include_extended_info( &$orders_data, $query_args ) {
		$mapped_orders    = $this->map_array_by_key( $orders_data, 'order_id' );
		$related_orders   = $this->get_orders_with_parent_id( $mapped_orders );
		$order_ids        = array_merge( array_keys( $mapped_orders ), array_keys( $related_orders ) );
		$products         = $this->get_products_by_order_ids( $order_ids );
		$coupons          = $this->get_coupons_by_order_ids( array_keys( $mapped_orders ) );
		$customers        = $this->get_customers_by_orders( $orders_data );
		$mapped_customers = $this->map_array_by_key( $customers, 'customer_id' );

		$mapped_data = array();
		foreach ( $products as $product ) {
			if ( ! isset( $mapped_data[ $product['order_id'] ] ) ) {
				$mapped_data[ $product['order_id'] ]['products'] = array();
			}

			$is_variation = '0' !== $product['variation_id'];
			$product_data = array(
				'id'       => $is_variation ? $product['variation_id'] : $product['product_id'],
				'name'     => $product['product_name'],
				'quantity' => $product['product_quantity'],
			);

			if ( $is_variation ) {
				$variation = wc_get_product( $product_data['id'] );
				/**
				 * Used to determine the separator for products and their variations titles.
				 *
				 * @since 4.0.0
				 */
				$separator = apply_filters( 'woocommerce_product_variation_title_attributes_separator', ' - ', $variation );

				if ( false === strpos( $product_data['name'], $separator ) ) {
					$attributes            = wc_get_formatted_variation( $variation, true, false );
					$product_data['name'] .= $separator . $attributes;
				}
			}

			$mapped_data[ $product['order_id'] ]['products'][] = $product_data;

			// If this product's order has another related order, it will be added to our mapped_data.
			if ( isset( $related_orders [ $product['order_id'] ] ) ) {
				$mapped_data[ $related_orders[ $product['order_id'] ]['order_id'] ] ['products'] [] = $product_data;
			}
		}

		foreach ( $coupons as $coupon ) {
			if ( ! isset( $mapped_data[ $coupon['order_id'] ] ) ) {
				$mapped_data[ $product['order_id'] ]['coupons'] = array();
			}

			$mapped_data[ $coupon['order_id'] ]['coupons'][] = array(
				'id'   => $coupon['coupon_id'],
				'code' => wc_format_coupon_code( $coupon['coupon_code'] ),
			);
		}

		foreach ( $orders_data as $key => $order_data ) {
			$defaults                             = array(
				'products' => array(),
				'coupons'  => array(),
				'customer' => array(),
			);
			$orders_data[ $key ]['extended_info'] = isset( $mapped_data[ $order_data['order_id'] ] ) ? array_merge( $defaults, $mapped_data[ $order_data['order_id'] ] ) : $defaults;
			if ( $order_data['customer_id'] && isset( $mapped_customers[ $order_data['customer_id'] ] ) ) {
				$orders_data[ $key ]['extended_info']['customer'] = $mapped_customers[ $order_data['customer_id'] ];
			}
		}
	}

	/**
	 * Returns oreders that have a parent id
	 *
	 * @param array $orders Orders array.
	 * @return array
	 */
	protected function get_orders_with_parent_id( $orders ) {
		$related_orders = array();
		foreach ( $orders as $order ) {
			if ( '0' !== $order['parent_id'] ) {
				$related_orders[ $order['parent_id'] ] = $order;
			}
		}
		return $related_orders;
	}

	/**
	 * Returns the same array index by a given key
	 *
	 * @param array  $array Array to be looped over.
	 * @param string $key Key of values used for new array.
	 * @return array
	 */
	protected function map_array_by_key( $array, $key ) {
		$mapped = array();
		foreach ( $array as $item ) {
			$mapped[ $item[ $key ] ] = $item;
		}
		return $mapped;
	}

	/**
	 * Get product IDs, names, and quantity from order IDs.
	 *
	 * @param array $order_ids Array of order IDs.
	 * @return array
	 */
	protected function get_products_by_order_ids( $order_ids ) {
		global $wpdb;
		$order_product_lookup_table = $wpdb->prefix . 'wc_order_product_lookup';
		$included_order_ids         = implode( ',', $order_ids );

		/* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
		$products = $wpdb->get_results(
			"SELECT
				order_id,
				product_id,
				variation_id,
				post_title as product_name,
				product_qty as product_quantity
			FROM {$wpdb->posts}
			JOIN
				{$order_product_lookup_table}
				ON {$wpdb->posts}.ID = (
					CASE WHEN variation_id > 0
						THEN variation_id
						ELSE product_id
					END
				)
			WHERE
				order_id IN ({$included_order_ids})
			",
			ARRAY_A
		);
		/* phpcs:enable */

		return $products;
	}

	/**
	 * Get customer data from Order data.
	 *
	 * @param array $orders Array of orders data.
	 * @return array
	 */
	protected function get_customers_by_orders( $orders ) {
		global $wpdb;

		$customer_lookup_table = $wpdb->prefix . 'wc_customer_lookup';
		$customer_ids          = array();

		foreach ( $orders as $order ) {
			if ( $order['customer_id'] ) {
				$customer_ids[] = intval( $order['customer_id'] );
			}
		}

		if ( empty( $customer_ids ) ) {
			return array();
		}

		/* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
		$customer_ids = implode( ',', $customer_ids );
		$customers    = $wpdb->get_results(
			"SELECT * FROM {$customer_lookup_table} WHERE customer_id IN ({$customer_ids})",
			ARRAY_A
		);
		/* phpcs:enable */

		return $customers;
	}

	/**
	 * Get coupon information from order IDs.
	 *
	 * @param array $order_ids Array of order IDs.
	 * @return array
	 */
	protected function get_coupons_by_order_ids( $order_ids ) {
		global $wpdb;
		$order_coupon_lookup_table = $wpdb->prefix . 'wc_order_coupon_lookup';
		$included_order_ids        = implode( ',', $order_ids );

		/* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
		$coupons = $wpdb->get_results(
			"SELECT order_id, coupon_id, post_title as coupon_code
				FROM {$wpdb->posts}
				JOIN {$order_coupon_lookup_table} ON {$order_coupon_lookup_table}.coupon_id = {$wpdb->posts}.ID
				WHERE
					order_id IN ({$included_order_ids})
				",
			ARRAY_A
		);
		/* phpcs:enable */

		return $coupons;
	}

	/**
	 * Get all statuses that have been synced.
	 *
	 * @return array Unique order statuses.
	 */
	public static function get_all_statuses() {
		global $wpdb;

		$cache_key = 'orders-all-statuses';
		$statuses  = Cache::get( $cache_key );

		if ( false === $statuses ) {
			/* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
			$table_name = self::get_db_table_name();
			$statuses   = $wpdb->get_col(
				"SELECT DISTINCT status FROM {$table_name}"
			);
			/* phpcs:enable */

			Cache::set( $cache_key, $statuses );
		}

		return $statuses;
	}

	/**
	 * Initialize query objects.
	 */
	protected function initialize_queries() {
		$this->clear_all_clauses();
		$this->subquery = new SqlQuery( $this->context . '_subquery' );
		$this->subquery->add_sql_clause( 'select', self::get_db_table_name() . '.order_id' );
		$this->subquery->add_sql_clause( 'from', self::get_db_table_name() );
	}
}
Query.php000064400000002347151554100370006371 0ustar00<?php
/**
 * Class for parameter-based Orders Reports querying
 *
 * Example usage:
 * $args = array(
 *          'before'        => '2018-07-19 00:00:00',
 *          'after'         => '2018-07-05 00:00:00',
 *          'interval'      => 'week',
 *          'products'      => array(15, 18),
 *          'coupons'       => array(138),
 *          'status_is'     => array('completed'),
 *          'status_is_not' => array('failed'),
 *          'new_customers' => false,
 *         );
 * $report = new \Automattic\WooCommerce\Admin\API\Reports\Orders\Query( $args );
 * $mydata = $report->get_data();
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Orders;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;

/**
 * API\Reports\Orders\Query
 */
class Query extends ReportsQuery {

	/**
	 * Get order data based on the current query vars.
	 *
	 * @return array
	 */
	public function get_data() {
		$args       = apply_filters( 'woocommerce_analytics_orders_query_args', $this->get_query_vars() );
		$data_store = \WC_Data_Store::load( 'report-orders' );
		$results    = $data_store->get_data( $args );
		return apply_filters( 'woocommerce_analytics_orders_select_query', $results, $args );
	}
}
Stats/Controller.php000064400000047171151554100370010511 0ustar00<?php
/**
 * REST API Reports orders stats controller
 *
 * Handles requests to the /reports/orders/stats endpoint.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Orders\Stats;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\API\Reports\ParameterException;

/**
 * REST API Reports orders stats controller class.
 *
 * @internal
 * @extends \Automattic\WooCommerce\Admin\API\Reports\Controller
 */
class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller {

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'reports/orders/stats';

	/**
	 * Maps query arguments from the REST request.
	 *
	 * @param array $request Request array.
	 * @return array
	 */
	protected function prepare_reports_query( $request ) {
		$args                        = array();
		$args['before']              = $request['before'];
		$args['after']               = $request['after'];
		$args['interval']            = $request['interval'];
		$args['page']                = $request['page'];
		$args['per_page']            = $request['per_page'];
		$args['orderby']             = $request['orderby'];
		$args['order']               = $request['order'];
		$args['fields']              = $request['fields'];
		$args['match']               = $request['match'];
		$args['status_is']           = (array) $request['status_is'];
		$args['status_is_not']       = (array) $request['status_is_not'];
		$args['product_includes']    = (array) $request['product_includes'];
		$args['product_excludes']    = (array) $request['product_excludes'];
		$args['variation_includes']  = (array) $request['variation_includes'];
		$args['variation_excludes']  = (array) $request['variation_excludes'];
		$args['coupon_includes']     = (array) $request['coupon_includes'];
		$args['coupon_excludes']     = (array) $request['coupon_excludes'];
		$args['tax_rate_includes']   = (array) $request['tax_rate_includes'];
		$args['tax_rate_excludes']   = (array) $request['tax_rate_excludes'];
		$args['customer_type']       = $request['customer_type'];
		$args['refunds']             = $request['refunds'];
		$args['attribute_is']        = (array) $request['attribute_is'];
		$args['attribute_is_not']    = (array) $request['attribute_is_not'];
		$args['category_includes']   = (array) $request['categories'];
		$args['segmentby']           = $request['segmentby'];
		$args['force_cache_refresh'] = $request['force_cache_refresh'];

		// For backwards compatibility, `customer` is aliased to `customer_type`.
		if ( empty( $request['customer_type'] ) && ! empty( $request['customer'] ) ) {
			$args['customer_type'] = $request['customer'];
		}

		return $args;
	}

	/**
	 * Get all reports.
	 *
	 * @param WP_REST_Request $request Request data.
	 * @return array|WP_Error
	 */
	public function get_items( $request ) {
		$query_args   = $this->prepare_reports_query( $request );
		$orders_query = new Query( $query_args );
		try {
			$report_data = $orders_query->get_data();
		} catch ( ParameterException $e ) {
			return new \WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) );
		}

		$out_data = array(
			'totals'    => get_object_vars( $report_data->totals ),
			'intervals' => array(),
		);

		foreach ( $report_data->intervals as $interval_data ) {
			$item                    = $this->prepare_item_for_response( $interval_data, $request );
			$out_data['intervals'][] = $this->prepare_response_for_collection( $item );
		}

		return $this->add_pagination_headers(
			$request,
			$out_data,
			(int) $report_data->total,
			(int) $report_data->page_no,
			(int) $report_data->pages
		);
	}

	/**
	 * Prepare a report object for serialization.
	 *
	 * @param Array           $report  Report data.
	 * @param WP_REST_Request $request Request object.
	 * @return WP_REST_Response
	 */
	public function prepare_item_for_response( $report, $request ) {
		$data = $report;

		$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
		$data    = $this->add_additional_fields_to_object( $data, $request );
		$data    = $this->filter_response_by_context( $data, $context );

		// Wrap the data in a response object.
		$response = rest_ensure_response( $data );

		/**
		 * Filter a report returned from the API.
		 *
		 * Allows modification of the report data right before it is returned.
		 *
		 * @param WP_REST_Response $response The response object.
		 * @param object           $report   The original report object.
		 * @param WP_REST_Request  $request  Request used to generate the response.
		 */
		return apply_filters( 'woocommerce_rest_prepare_report_orders_stats', $response, $report, $request );
	}

	/**
	 * Get the Report's schema, conforming to JSON Schema.
	 *
	 * @return array
	 */
	public function get_item_schema() {
		$data_values = array(
			'net_revenue'         => array(
				'description' => __( 'Net sales.', 'woocommerce' ),
				'type'        => 'number',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
				'format'      => 'currency',
			),
			'orders_count'        => array(
				'title'       => __( 'Orders', 'woocommerce' ),
				'description' => __( 'Number of orders', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
				'indicator'   => true,
			),
			'avg_order_value'     => array(
				'description' => __( 'Average order value.', 'woocommerce' ),
				'type'        => 'number',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
				'indicator'   => true,
				'format'      => 'currency',
			),
			'avg_items_per_order' => array(
				'description' => __( 'Average items per order', 'woocommerce' ),
				'type'        => 'number',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
			'num_items_sold'      => array(
				'description' => __( 'Number of items sold', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
			'coupons'             => array(
				'description' => __( 'Amount discounted by coupons.', 'woocommerce' ),
				'type'        => 'number',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
			'coupons_count'       => array(
				'description' => __( 'Unique coupons count.', 'woocommerce' ),
				'type'        => 'number',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
			'total_customers'     => array(
				'description' => __( 'Total distinct customers.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
			'products'            => array(
				'description' => __( 'Number of distinct products sold.', 'woocommerce' ),
				'type'        => 'number',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
		);

		$segments = array(
			'segments' => array(
				'description' => __( 'Reports data grouped by segment condition.', 'woocommerce' ),
				'type'        => 'array',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
				'items'       => array(
					'type'       => 'object',
					'properties' => array(
						'segment_id' => array(
							'description' => __( 'Segment identificator.', 'woocommerce' ),
							'type'        => 'integer',
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
						),
						'subtotals'  => array(
							'description' => __( 'Interval subtotals.', 'woocommerce' ),
							'type'        => 'object',
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
							'properties'  => $data_values,
						),
					),
				),
			),
		);

		$totals = array_merge( $data_values, $segments );

		// Products is not shown in intervals.
		unset( $data_values['products'] );

		$intervals = array_merge( $data_values, $segments );

		$schema = array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => 'report_orders_stats',
			'type'       => 'object',
			'properties' => array(
				'totals'    => array(
					'description' => __( 'Totals data.', 'woocommerce' ),
					'type'        => 'object',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
					'properties'  => $totals,
				),
				'intervals' => array(
					'description' => __( 'Reports data grouped by intervals.', 'woocommerce' ),
					'type'        => 'array',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
					'items'       => array(
						'type'       => 'object',
						'properties' => array(
							'interval'       => array(
								'description' => __( 'Type of interval.', 'woocommerce' ),
								'type'        => 'string',
								'context'     => array( 'view', 'edit' ),
								'readonly'    => true,
								'enum'        => array( 'day', 'week', 'month', 'year' ),
							),
							'date_start'     => array(
								'description' => __( "The date the report start, in the site's timezone.", 'woocommerce' ),
								'type'        => 'date-time',
								'context'     => array( 'view', 'edit' ),
								'readonly'    => true,
							),
							'date_start_gmt' => array(
								'description' => __( 'The date the report start, as GMT.', 'woocommerce' ),
								'type'        => 'date-time',
								'context'     => array( 'view', 'edit' ),
								'readonly'    => true,
							),
							'date_end'       => array(
								'description' => __( "The date the report end, in the site's timezone.", 'woocommerce' ),
								'type'        => 'date-time',
								'context'     => array( 'view', 'edit' ),
								'readonly'    => true,
							),
							'date_end_gmt'   => array(
								'description' => __( 'The date the report end, as GMT.', 'woocommerce' ),
								'type'        => 'date-time',
								'context'     => array( 'view', 'edit' ),
								'readonly'    => true,
							),
							'subtotals'      => array(
								'description' => __( 'Interval subtotals.', 'woocommerce' ),
								'type'        => 'object',
								'context'     => array( 'view', 'edit' ),
								'readonly'    => true,
								'properties'  => $intervals,
							),
						),
					),
				),
			),
		);

		return $this->add_additional_fields_schema( $schema );
	}

	/**
	 * Get the query params for collections.
	 *
	 * @return array
	 */
	public function get_collection_params() {
		$params                     = array();
		$params['context']          = $this->get_context_param( array( 'default' => 'view' ) );
		$params['page']             = array(
			'description'       => __( 'Current page of the collection.', 'woocommerce' ),
			'type'              => 'integer',
			'default'           => 1,
			'sanitize_callback' => 'absint',
			'validate_callback' => 'rest_validate_request_arg',
			'minimum'           => 1,
		);
		$params['per_page']         = array(
			'description'       => __( 'Maximum number of items to be returned in result set.', 'woocommerce' ),
			'type'              => 'integer',
			'default'           => 10,
			'minimum'           => 1,
			'maximum'           => 100,
			'sanitize_callback' => 'absint',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['after']            = array(
			'description'       => __( 'Limit response to resources published after a given ISO8601 compliant date.', 'woocommerce' ),
			'type'              => 'string',
			'format'            => 'date-time',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['before']           = array(
			'description'       => __( 'Limit response to resources published before a given ISO8601 compliant date.', 'woocommerce' ),
			'type'              => 'string',
			'format'            => 'date-time',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['order']            = array(
			'description'       => __( 'Order sort attribute ascending or descending.', 'woocommerce' ),
			'type'              => 'string',
			'default'           => 'desc',
			'enum'              => array( 'asc', 'desc' ),
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['orderby']          = array(
			'description'       => __( 'Sort collection by object attribute.', 'woocommerce' ),
			'type'              => 'string',
			'default'           => 'date',
			'enum'              => array(
				'date',
				'net_revenue',
				'orders_count',
				'avg_order_value',
			),
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['interval']         = array(
			'description'       => __( 'Time interval to use for buckets in the returned data.', 'woocommerce' ),
			'type'              => 'string',
			'default'           => 'week',
			'enum'              => array(
				'hour',
				'day',
				'week',
				'month',
				'quarter',
				'year',
			),
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['match']            = array(
			'description'       => __( 'Indicates whether all the conditions should be true for the resulting set, or if any one of them is sufficient. Match affects the following parameters: status_is, status_is_not, product_includes, product_excludes, coupon_includes, coupon_excludes, customer, categories', 'woocommerce' ),
			'type'              => 'string',
			'default'           => 'all',
			'enum'              => array(
				'all',
				'any',
			),
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['status_is']        = array(
			'description'       => __( 'Limit result set to items that have the specified order status.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_slug_list',
			'validate_callback' => 'rest_validate_request_arg',
			'default'           => null,
			'items'             => array(
				'enum' => self::get_order_statuses(),
				'type' => 'string',
			),
		);
		$params['status_is_not']    = array(
			'description'       => __( 'Limit result set to items that don\'t have the specified order status.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_slug_list',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'enum' => self::get_order_statuses(),
				'type' => 'string',
			),
		);
		$params['product_includes'] = array(
			'description'       => __( 'Limit result set to items that have the specified product(s) assigned.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'integer',
			),
			'default'           => array(),
			'sanitize_callback' => 'wp_parse_id_list',

		);
		$params['product_excludes']    = array(
			'description'       => __( 'Limit result set to items that don\'t have the specified product(s) assigned.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'integer',
			),
			'default'           => array(),
			'sanitize_callback' => 'wp_parse_id_list',
		);
		$params['variation_includes']  = array(
			'description'       => __( 'Limit result set to items that have the specified variation(s) assigned.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'integer',
			),
			'default'           => array(),
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['variation_excludes']  = array(
			'description'       => __( 'Limit result set to items that don\'t have the specified variation(s) assigned.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'integer',
			),
			'default'           => array(),
			'validate_callback' => 'rest_validate_request_arg',
			'sanitize_callback' => 'wp_parse_id_list',
		);
		$params['coupon_includes']     = array(
			'description'       => __( 'Limit result set to items that have the specified coupon(s) assigned.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'integer',
			),
			'default'           => array(),
			'sanitize_callback' => 'wp_parse_id_list',
		);
		$params['coupon_excludes']     = array(
			'description'       => __( 'Limit result set to items that don\'t have the specified coupon(s) assigned.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'integer',
			),
			'default'           => array(),
			'sanitize_callback' => 'wp_parse_id_list',
		);
		$params['tax_rate_includes']   = array(
			'description'       => __( 'Limit result set to items that have the specified tax rate(s) assigned.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'integer',
			),
			'default'           => array(),
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['tax_rate_excludes']   = array(
			'description'       => __( 'Limit result set to items that don\'t have the specified tax rate(s) assigned.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'integer',
			),
			'default'           => array(),
			'validate_callback' => 'rest_validate_request_arg',
			'sanitize_callback' => 'wp_parse_id_list',
		);
		$params['customer']            = array(
			'description'       => __( 'Alias for customer_type (deprecated).', 'woocommerce' ),
			'type'              => 'string',
			'enum'              => array(
				'new',
				'returning',
			),
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['customer_type']       = array(
			'description'       => __( 'Limit result set to orders that have the specified customer_type', 'woocommerce' ),
			'type'              => 'string',
			'enum'              => array(
				'new',
				'returning',
			),
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['refunds']             = array(
			'description'       => __( 'Limit result set to specific types of refunds.', 'woocommerce' ),
			'type'              => 'string',
			'default'           => '',
			'enum'              => array(
				'',
				'all',
				'partial',
				'full',
				'none',
			),
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['attribute_is']        = array(
			'description'       => __( 'Limit result set to orders that include products with the specified attributes.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'array',
			),
			'default'           => array(),
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['attribute_is_not']    = array(
			'description'       => __( 'Limit result set to orders that don\'t include products with the specified attributes.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'array',
			),
			'default'           => array(),
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['segmentby']           = array(
			'description'       => __( 'Segment the response by additional constraint.', 'woocommerce' ),
			'type'              => 'string',
			'enum'              => array(
				'product',
				'category',
				'variation',
				'coupon',
				'customer_type', // new vs returning.
			),
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['fields']              = array(
			'description'       => __( 'Limit stats fields to the specified items.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_slug_list',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'type' => 'string',
			),
		);
		$params['force_cache_refresh'] = array(
			'description'       => __( 'Force retrieval of fresh data instead of from the cache.', 'woocommerce' ),
			'type'              => 'boolean',
			'sanitize_callback' => 'wp_validate_boolean',
			'validate_callback' => 'rest_validate_request_arg',
		);

		return $params;
	}
}
Stats/DataStore.php000064400000064022151554100370010246 0ustar00<?php
/**
 * API\Reports\Orders\Stats\DataStore class file.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Orders\Stats;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore;
use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface;
use Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
use Automattic\WooCommerce\Admin\API\Reports\SqlQuery;
use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache;
use Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore as CustomersDataStore;
use Automattic\WooCommerce\Utilities\OrderUtil;

/**
 * API\Reports\Orders\Stats\DataStore.
 */
class DataStore extends ReportsDataStore implements DataStoreInterface {

	/**
	 * Table used to get the data.
	 *
	 * @var string
	 */
	protected static $table_name = 'wc_order_stats';

	/**
	 * Cron event name.
	 */
	const CRON_EVENT = 'wc_order_stats_update';

	/**
	 * Cache identifier.
	 *
	 * @var string
	 */
	protected $cache_key = 'orders_stats';

	/**
	 * Type for each column to cast values correctly later.
	 *
	 * @var array
	 */
	protected $column_types = array(
		'orders_count'        => 'intval',
		'num_items_sold'      => 'intval',
		'gross_sales'         => 'floatval',
		'total_sales'         => 'floatval',
		'coupons'             => 'floatval',
		'coupons_count'       => 'intval',
		'refunds'             => 'floatval',
		'taxes'               => 'floatval',
		'shipping'            => 'floatval',
		'net_revenue'         => 'floatval',
		'avg_items_per_order' => 'floatval',
		'avg_order_value'     => 'floatval',
		'total_customers'     => 'intval',
		'products'            => 'intval',
		'segment_id'          => 'intval',
	);

	/**
	 * Data store context used to pass to filters.
	 *
	 * @var string
	 */
	protected $context = 'orders_stats';

	/**
	 * Dynamically sets the date column name based on configuration
	 */
	public function __construct() {
		$this->date_column_name = get_option( 'woocommerce_date_type', 'date_paid' );
		parent::__construct();
	}

	/**
	 * Assign report columns once full table name has been assigned.
	 */
	protected function assign_report_columns() {
		$table_name = self::get_db_table_name();
		// Avoid ambigious columns in SQL query.
		$refunds     = "ABS( SUM( CASE WHEN {$table_name}.net_total < 0 THEN {$table_name}.net_total ELSE 0 END ) )";
		$gross_sales =
			"( SUM({$table_name}.total_sales)" .
			' + COALESCE( SUM(discount_amount), 0 )' . // SUM() all nulls gives null.
			" - SUM({$table_name}.tax_total)" .
			" - SUM({$table_name}.shipping_total)" .
			" + {$refunds}" .
			' ) as gross_sales';

		$this->report_columns = array(
			'orders_count'        => "SUM( CASE WHEN {$table_name}.parent_id = 0 THEN 1 ELSE 0 END ) as orders_count",
			'num_items_sold'      => "SUM({$table_name}.num_items_sold) as num_items_sold",
			'gross_sales'         => $gross_sales,
			'total_sales'         => "SUM({$table_name}.total_sales) AS total_sales",
			'coupons'             => 'COALESCE( SUM(discount_amount), 0 ) AS coupons', // SUM() all nulls gives null.
			'coupons_count'       => 'COALESCE( coupons_count, 0 ) as coupons_count',
			'refunds'             => "{$refunds} AS refunds",
			'taxes'               => "SUM({$table_name}.tax_total) AS taxes",
			'shipping'            => "SUM({$table_name}.shipping_total) AS shipping",
			'net_revenue'         => "SUM({$table_name}.net_total) AS net_revenue",
			'avg_items_per_order' => "SUM( {$table_name}.num_items_sold ) / SUM( CASE WHEN {$table_name}.parent_id = 0 THEN 1 ELSE 0 END ) AS avg_items_per_order",
			'avg_order_value'     => "SUM( {$table_name}.net_total ) / SUM( CASE WHEN {$table_name}.parent_id = 0 THEN 1 ELSE 0 END ) AS avg_order_value",
			'total_customers'     => "COUNT( DISTINCT( {$table_name}.customer_id ) ) as total_customers",
		);
	}

	/**
	 * Set up all the hooks for maintaining and populating table data.
	 */
	public static function init() {
		add_action( 'woocommerce_before_delete_order', array( __CLASS__, 'delete_order' ) );
		add_action( 'delete_post', array( __CLASS__, 'delete_order' ) );
	}

	/**
	 * Updates the totals and intervals database queries with parameters used for Orders report: categories, coupons and order status.
	 *
	 * @param array $query_args      Query arguments supplied by the user.
	 */
	protected function orders_stats_sql_filter( $query_args ) {
		// phpcs:ignore Generic.Commenting.Todo.TaskFound
		// @todo Performance of all of this?
		global $wpdb;

		$from_clause        = '';
		$orders_stats_table = self::get_db_table_name();
		$product_lookup     = $wpdb->prefix . 'wc_order_product_lookup';
		$coupon_lookup      = $wpdb->prefix . 'wc_order_coupon_lookup';
		$tax_rate_lookup    = $wpdb->prefix . 'wc_order_tax_lookup';
		$operator           = $this->get_match_operator( $query_args );

		$where_filters = array();

		// Products filters.
		$where_filters[] = $this->get_object_where_filter(
			$orders_stats_table,
			'order_id',
			$product_lookup,
			'product_id',
			'IN',
			$this->get_included_products( $query_args )
		);
		$where_filters[] = $this->get_object_where_filter(
			$orders_stats_table,
			'order_id',
			$product_lookup,
			'product_id',
			'NOT IN',
			$this->get_excluded_products( $query_args )
		);

		// Variations filters.
		$where_filters[] = $this->get_object_where_filter(
			$orders_stats_table,
			'order_id',
			$product_lookup,
			'variation_id',
			'IN',
			$this->get_included_variations( $query_args )
		);
		$where_filters[] = $this->get_object_where_filter(
			$orders_stats_table,
			'order_id',
			$product_lookup,
			'variation_id',
			'NOT IN',
			$this->get_excluded_variations( $query_args )
		);

		// Coupons filters.
		$where_filters[] = $this->get_object_where_filter(
			$orders_stats_table,
			'order_id',
			$coupon_lookup,
			'coupon_id',
			'IN',
			$this->get_included_coupons( $query_args )
		);
		$where_filters[] = $this->get_object_where_filter(
			$orders_stats_table,
			'order_id',
			$coupon_lookup,
			'coupon_id',
			'NOT IN',
			$this->get_excluded_coupons( $query_args )
		);

		// Tax rate filters.
		$where_filters[] = $this->get_object_where_filter(
			$orders_stats_table,
			'order_id',
			$tax_rate_lookup,
			'tax_rate_id',
			'IN',
			implode( ',', $query_args['tax_rate_includes'] )
		);
		$where_filters[] = $this->get_object_where_filter(
			$orders_stats_table,
			'order_id',
			$tax_rate_lookup,
			'tax_rate_id',
			'NOT IN',
			implode( ',', $query_args['tax_rate_excludes'] )
		);

		// Product attribute filters.
		$attribute_subqueries = $this->get_attribute_subqueries( $query_args );
		if ( $attribute_subqueries['join'] && $attribute_subqueries['where'] ) {
			// Build a subquery for getting order IDs by product attribute(s).
			// Done here since our use case is a little more complicated than get_object_where_filter() can handle.
			$attribute_subquery = new SqlQuery();
			$attribute_subquery->add_sql_clause( 'select', "{$orders_stats_table}.order_id" );
			$attribute_subquery->add_sql_clause( 'from', $orders_stats_table );

			// JOIN on product lookup.
			$attribute_subquery->add_sql_clause( 'join', "JOIN {$product_lookup} ON {$orders_stats_table}.order_id = {$product_lookup}.order_id" );

			// Add JOINs for matching attributes.
			foreach ( $attribute_subqueries['join'] as $attribute_join ) {
				$attribute_subquery->add_sql_clause( 'join', $attribute_join );
			}
			// Add WHEREs for matching attributes.
			$attribute_subquery->add_sql_clause( 'where', 'AND (' . implode( " {$operator} ", $attribute_subqueries['where'] ) . ')' );

			// Generate subquery statement and add to our where filters.
			$where_filters[] = "{$orders_stats_table}.order_id IN (" . $attribute_subquery->get_query_statement() . ')';
		}

		$where_filters[] = $this->get_customer_subquery( $query_args );
		$refund_subquery = $this->get_refund_subquery( $query_args );
		$from_clause    .= $refund_subquery['from_clause'];
		if ( $refund_subquery['where_clause'] ) {
			$where_filters[] = $refund_subquery['where_clause'];
		}

		$where_filters   = array_filter( $where_filters );
		$where_subclause = implode( " $operator ", $where_filters );

		// Append status filter after to avoid matching ANY on default statuses.
		$order_status_filter = $this->get_status_subquery( $query_args, $operator );
		if ( $order_status_filter ) {
			if ( empty( $query_args['status_is'] ) && empty( $query_args['status_is_not'] ) ) {
				$operator = 'AND';
			}
			$where_subclause = implode( " $operator ", array_filter( array( $where_subclause, $order_status_filter ) ) );
		}

		// To avoid requesting the subqueries twice, the result is applied to all queries passed to the method.
		if ( $where_subclause ) {
			$this->total_query->add_sql_clause( 'where', "AND ( $where_subclause )" );
			$this->total_query->add_sql_clause( 'join', $from_clause );
			$this->interval_query->add_sql_clause( 'where', "AND ( $where_subclause )" );
			$this->interval_query->add_sql_clause( 'join', $from_clause );
		}
	}

	/**
	 * Returns the report data based on parameters supplied by the user.
	 *
	 * @param array $query_args  Query parameters.
	 * @return stdClass|WP_Error Data.
	 */
	public function get_data( $query_args ) {
		global $wpdb;

		$table_name = self::get_db_table_name();

		// These defaults are only applied when not using REST API, as the API has its own defaults that overwrite these for most values (except before, after, etc).
		$defaults   = array(
			'per_page'          => get_option( 'posts_per_page' ),
			'page'              => 1,
			'order'             => 'DESC',
			'orderby'           => 'date',
			'before'            => TimeInterval::default_before(),
			'after'             => TimeInterval::default_after(),
			'interval'          => 'week',
			'fields'            => '*',
			'segmentby'         => '',

			'match'             => 'all',
			'status_is'         => array(),
			'status_is_not'     => array(),
			'product_includes'  => array(),
			'product_excludes'  => array(),
			'coupon_includes'   => array(),
			'coupon_excludes'   => array(),
			'tax_rate_includes' => array(),
			'tax_rate_excludes' => array(),
			'customer_type'     => '',
			'category_includes' => array(),
		);
		$query_args = wp_parse_args( $query_args, $defaults );
		$this->normalize_timezones( $query_args, $defaults );

		/*
		 * We need to get the cache key here because
		 * parent::update_intervals_sql_params() modifies $query_args.
		 */
		$cache_key = $this->get_cache_key( $query_args );
		$data      = $this->get_cached_data( $cache_key );

		if ( false === $data ) {
			$this->initialize_queries();

			$data = (object) array(
				'totals'    => (object) array(),
				'intervals' => (object) array(),
				'total'     => 0,
				'pages'     => 0,
				'page_no'   => 0,
			);

			$selections = $this->selected_columns( $query_args );
			$this->add_time_period_sql_params( $query_args, $table_name );
			$this->add_intervals_sql_params( $query_args, $table_name );
			$this->add_order_by_sql_params( $query_args );
			$where_time  = $this->get_sql_clause( 'where_time' );
			$params      = $this->get_limit_sql_params( $query_args );
			$coupon_join = "LEFT JOIN (
						SELECT
							order_id,
							SUM(discount_amount) AS discount_amount,
							COUNT(DISTINCT coupon_id) AS coupons_count
						FROM
							{$wpdb->prefix}wc_order_coupon_lookup
						GROUP BY
							order_id
						) order_coupon_lookup
						ON order_coupon_lookup.order_id = {$wpdb->prefix}wc_order_stats.order_id";

			// Additional filtering for Orders report.
			$this->orders_stats_sql_filter( $query_args );
			$this->total_query->add_sql_clause( 'select', $selections );
			$this->total_query->add_sql_clause( 'left_join', $coupon_join );
			$this->total_query->add_sql_clause( 'where_time', $where_time );
			$totals = $wpdb->get_results(
				$this->total_query->get_query_statement(),
				ARRAY_A
			); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok.
			if ( null === $totals ) {
				return new \WP_Error( 'woocommerce_analytics_revenue_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) );
			}

			// phpcs:ignore Generic.Commenting.Todo.TaskFound
			// @todo Remove these assignements when refactoring segmenter classes to use query objects.
			$totals_query    = array(
				'from_clause'       => $this->total_query->get_sql_clause( 'join' ),
				'where_time_clause' => $where_time,
				'where_clause'      => $this->total_query->get_sql_clause( 'where' ),
			);
			$intervals_query = array(
				'select_clause'     => $this->get_sql_clause( 'select' ),
				'from_clause'       => $this->interval_query->get_sql_clause( 'join' ),
				'where_time_clause' => $where_time,
				'where_clause'      => $this->interval_query->get_sql_clause( 'where' ),
				'limit'             => $this->get_sql_clause( 'limit' ),
			);

			$unique_products            = $this->get_unique_product_count( $totals_query['from_clause'], $totals_query['where_time_clause'], $totals_query['where_clause'] );
			$totals[0]['products']      = $unique_products;
			$segmenter                  = new Segmenter( $query_args, $this->report_columns );
			$unique_coupons             = $this->get_unique_coupon_count( $totals_query['from_clause'], $totals_query['where_time_clause'], $totals_query['where_clause'] );
			$totals[0]['coupons_count'] = $unique_coupons;
			$totals[0]['segments']      = $segmenter->get_totals_segments( $totals_query, $table_name );
			$totals                     = (object) $this->cast_numbers( $totals[0] );

			$this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) . ' AS time_interval' );
			$this->interval_query->add_sql_clause( 'left_join', $coupon_join );
			$this->interval_query->add_sql_clause( 'where_time', $where_time );
			$db_intervals = $wpdb->get_col(
				$this->interval_query->get_query_statement()
			); // phpcs:ignore cache ok, DB call ok, , unprepared SQL ok.

			$db_interval_count       = count( $db_intervals );
			$expected_interval_count = TimeInterval::intervals_between( $query_args['after'], $query_args['before'], $query_args['interval'] );
			$total_pages             = (int) ceil( $expected_interval_count / $params['per_page'] );

			if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) {
				return $data;
			}

			$this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name );
			$this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) );
			$this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) );
			$this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" );
			if ( '' !== $selections ) {
				$this->interval_query->add_sql_clause( 'select', ', ' . $selections );
			}
			$intervals = $wpdb->get_results(
				$this->interval_query->get_query_statement(),
				ARRAY_A
			); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok.

			if ( null === $intervals ) {
				return new \WP_Error( 'woocommerce_analytics_revenue_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) );
			}

			if ( isset( $intervals[0] ) ) {
				$unique_coupons                = $this->get_unique_coupon_count( $intervals_query['from_clause'], $intervals_query['where_time_clause'], $intervals_query['where_clause'], true );
				$intervals[0]['coupons_count'] = $unique_coupons;
			}

			$data = (object) array(
				'totals'    => $totals,
				'intervals' => $intervals,
				'total'     => $expected_interval_count,
				'pages'     => $total_pages,
				'page_no'   => (int) $query_args['page'],
			);

			if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) {
				$this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data );
				$this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] );
				$this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] );
			} else {
				$this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals );
			}
			$segmenter->add_intervals_segments( $data, $intervals_query, $table_name );
			$this->create_interval_subtotals( $data->intervals );

			$this->set_cached_data( $cache_key, $data );
		}

		return $data;
	}

	/**
	 * Get unique products based on user time query
	 *
	 * @param string $from_clause       From clause with date query.
	 * @param string $where_time_clause Where clause with date query.
	 * @param string $where_clause      Where clause with date query.
	 * @return integer Unique product count.
	 */
	public function get_unique_product_count( $from_clause, $where_time_clause, $where_clause ) {
		global $wpdb;

		$table_name = self::get_db_table_name();
		return $wpdb->get_var(
			"SELECT
					COUNT( DISTINCT {$wpdb->prefix}wc_order_product_lookup.product_id )
				FROM
					{$wpdb->prefix}wc_order_product_lookup JOIN {$table_name} ON {$wpdb->prefix}wc_order_product_lookup.order_id = {$table_name}.order_id
					{$from_clause}
				WHERE
					1=1
					{$where_time_clause}
					{$where_clause}"
		); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok.
	}

	/**
	 * Get unique coupons based on user time query
	 *
	 * @param string $from_clause       From clause with date query.
	 * @param string $where_time_clause Where clause with date query.
	 * @param string $where_clause      Where clause with date query.
	 * @return integer Unique product count.
	 */
	public function get_unique_coupon_count( $from_clause, $where_time_clause, $where_clause ) {
		global $wpdb;

		$table_name = self::get_db_table_name();
		return $wpdb->get_var(
			"SELECT
					COUNT(DISTINCT coupon_id)
				FROM
					{$wpdb->prefix}wc_order_coupon_lookup JOIN {$table_name} ON {$wpdb->prefix}wc_order_coupon_lookup.order_id = {$table_name}.order_id
					{$from_clause}
				WHERE
					1=1
					{$where_time_clause}
					{$where_clause}"
		); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok.
	}

	/**
	 * Add order information to the lookup table when orders are created or modified.
	 *
	 * @param int $post_id Post ID.
	 * @return int|bool Returns -1 if order won't be processed, or a boolean indicating processing success.
	 */
	public static function sync_order( $post_id ) {
		if ( ! OrderUtil::is_order( $post_id, array( 'shop_order', 'shop_order_refund' ) ) ) {
			return -1;
		}

		$order = wc_get_order( $post_id );
		if ( ! $order ) {
			return -1;
		}

		return self::update( $order );
	}

	/**
	 * Update the database with stats data.
	 *
	 * @param WC_Order|WC_Order_Refund $order Order or refund to update row for.
	 * @return int|bool Returns -1 if order won't be processed, or a boolean indicating processing success.
	 */
	public static function update( $order ) {
		global $wpdb;
		$table_name = self::get_db_table_name();

		if ( ! $order->get_id() || ! $order->get_date_created() ) {
			return -1;
		}

		/**
		 * Filters order stats data.
		 *
		 * @param array $data Data written to order stats lookup table.
		 * @param WC_Order $order  Order object.
		 *
		 * @since 4.0.0
		 */
		$data = apply_filters(
			'woocommerce_analytics_update_order_stats_data',
			array(
				'order_id'           => $order->get_id(),
				'parent_id'          => $order->get_parent_id(),
				'date_created'       => $order->get_date_created()->date( 'Y-m-d H:i:s' ),
				'date_paid'          => $order->get_date_paid() ? $order->get_date_paid()->date( 'Y-m-d H:i:s' ) : null,
				'date_completed'     => $order->get_date_completed() ? $order->get_date_completed()->date( 'Y-m-d H:i:s' ) : null,
				'date_created_gmt'   => gmdate( 'Y-m-d H:i:s', $order->get_date_created()->getTimestamp() ),
				'num_items_sold'     => self::get_num_items_sold( $order ),
				'total_sales'        => $order->get_total(),
				'tax_total'          => $order->get_total_tax(),
				'shipping_total'     => $order->get_shipping_total(),
				'net_total'          => self::get_net_total( $order ),
				'status'             => self::normalize_order_status( $order->get_status() ),
				'customer_id'        => $order->get_report_customer_id(),
				'returning_customer' => $order->is_returning_customer(),
			),
			$order
		);

		$format = array(
			'%d',
			'%d',
			'%s',
			'%s',
			'%s',
			'%s',
			'%d',
			'%f',
			'%f',
			'%f',
			'%f',
			'%s',
			'%d',
			'%d',
		);

		if ( 'shop_order_refund' === $order->get_type() ) {
			$parent_order = wc_get_order( $order->get_parent_id() );
			if ( $parent_order ) {
				$data['parent_id'] = $parent_order->get_id();
				$data['status']    = self::normalize_order_status( $parent_order->get_status() );
			}
			/**
			 * Set date_completed and date_paid the same as date_created to avoid problems
			 * when they are being used to sort the data, as refunds don't have them filled
			*/
			$data['date_completed'] = $data['date_created'];
			$data['date_paid']      = $data['date_created'];
		}

		// Update or add the information to the DB.
		$result = $wpdb->replace( $table_name, $data, $format );

		/**
		 * Fires when order's stats reports are updated.
		 *
		 * @param int $order_id Order ID.
		 *
		 * @since 4.0.0.
		 */
		do_action( 'woocommerce_analytics_update_order_stats', $order->get_id() );

		// Check the rows affected for success. Using REPLACE can affect 2 rows if the row already exists.
		return ( 1 === $result || 2 === $result );
	}

	/**
	 * Deletes the order stats when an order is deleted.
	 *
	 * @param int $post_id Post ID.
	 */
	public static function delete_order( $post_id ) {
		global $wpdb;
		$order_id = (int) $post_id;

		if ( ! OrderUtil::is_order( $post_id, array( 'shop_order', 'shop_order_refund' ) ) ) {
			return;
		}

		// Retrieve customer details before the order is deleted.
		$order       = wc_get_order( $order_id );
		$customer_id = absint( CustomersDataStore::get_existing_customer_id_from_order( $order ) );

		// Delete the order.
		$wpdb->delete( self::get_db_table_name(), array( 'order_id' => $order_id ) );
		/**
		 * Fires when orders stats are deleted.
		 *
		 * @param int $order_id Order ID.
		 * @param int $customer_id Customer ID.
		 *
		 * @since 4.0.0
		 */
		do_action( 'woocommerce_analytics_delete_order_stats', $order_id, $customer_id );

		ReportsCache::invalidate();
	}


	/**
	 * Calculation methods.
	 */

	/**
	 * Get number of items sold among all orders.
	 *
	 * @param array $order WC_Order object.
	 * @return int
	 */
	protected static function get_num_items_sold( $order ) {
		$num_items = 0;

		$line_items = $order->get_items( 'line_item' );
		foreach ( $line_items as $line_item ) {
			$num_items += $line_item->get_quantity();
		}

		return $num_items;
	}

	/**
	 * Get the net amount from an order without shipping, tax, or refunds.
	 *
	 * @param array $order WC_Order object.
	 * @return float
	 */
	protected static function get_net_total( $order ) {
		$net_total = floatval( $order->get_total() ) - floatval( $order->get_total_tax() ) - floatval( $order->get_shipping_total() );
		return (float) $net_total;
	}

	/**
	 * Check to see if an order's customer has made previous orders or not
	 *
	 * @param array     $order WC_Order object.
	 * @param int|false $customer_id Customer ID. Optional.
	 * @return bool
	 */
	public static function is_returning_customer( $order, $customer_id = null ) {
		if ( is_null( $customer_id ) ) {
			$customer_id = \Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore::get_existing_customer_id_from_order( $order );
		}

		if ( ! $customer_id ) {
			return false;
		}

		$oldest_orders = \Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore::get_oldest_orders( $customer_id );

		if ( empty( $oldest_orders ) ) {
			return false;
		}

		$first_order       = $oldest_orders[0];
		$second_order      = isset( $oldest_orders[1] ) ? $oldest_orders[1] : false;
		$excluded_statuses = self::get_excluded_report_order_statuses();

		// Order is older than previous first order.
		if ( $order->get_date_created() < wc_string_to_datetime( $first_order->date_created ) &&
			! in_array( $order->get_status(), $excluded_statuses, true )
		) {
			self::set_customer_first_order( $customer_id, $order->get_id() );
			return false;
		}

		// The current order is the oldest known order.
		$is_first_order = (int) $order->get_id() === (int) $first_order->order_id;
		// Order date has changed and next oldest is now the first order.
		$date_change = $second_order &&
			$order->get_date_created() > wc_string_to_datetime( $first_order->date_created ) &&
			wc_string_to_datetime( $second_order->date_created ) < $order->get_date_created();
		// Status has changed to an excluded status and next oldest order is now the first order.
		$status_change = $second_order &&
			in_array( $order->get_status(), $excluded_statuses, true );
		if ( $is_first_order && ( $date_change || $status_change ) ) {
			self::set_customer_first_order( $customer_id, $second_order->order_id );
			return true;
		}

		return (int) $order->get_id() !== (int) $first_order->order_id;
	}

	/**
	 * Set a customer's first order and all others to returning.
	 *
	 * @param int $customer_id Customer ID.
	 * @param int $order_id Order ID.
	 */
	protected static function set_customer_first_order( $customer_id, $order_id ) {
		global $wpdb;
		$orders_stats_table = self::get_db_table_name();

		$wpdb->query(
			$wpdb->prepare(
				// phpcs:ignore Generic.Commenting.Todo.TaskFound
				// TODO: use the %i placeholder to prepare the table name when available in the the minimum required WordPress version.
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				"UPDATE {$orders_stats_table} SET returning_customer = CASE WHEN order_id = %d THEN false ELSE true END WHERE customer_id = %d",
				$order_id,
				$customer_id
			)
		);
	}

	/**
	 * Initialize query objects.
	 */
	protected function initialize_queries() {
		$this->clear_all_clauses();
		unset( $this->subquery );
		$this->total_query = new SqlQuery( $this->context . '_total' );
		$this->total_query->add_sql_clause( 'from', self::get_db_table_name() );

		$this->interval_query = new SqlQuery( $this->context . '_interval' );
		$this->interval_query->add_sql_clause( 'from', self::get_db_table_name() );
		$this->interval_query->add_sql_clause( 'group_by', 'time_interval' );
	}
}
Stats/Query.php000064400000003003151554100370007455 0ustar00<?php
/**
 * Class for parameter-based Order Stats Reports querying
 *
 * Example usage:
 * $args = array(
 *          'before'       => '2018-07-19 00:00:00',
 *          'after'        => '2018-07-05 00:00:00',
 *          'interval'     => 'week',
 *          'categories'   => array(15, 18),
 *          'coupons'      => array(138),
 *          'status_in'    => array('completed'),
 *         );
 * $report = new \Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\Query( $args );
 * $mydata = $report->get_data();
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Orders\Stats;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;

/**
 * API\Reports\Orders\Stats\Query
 */
class Query extends ReportsQuery {

	/**
	 * Valid fields for Orders report.
	 *
	 * @return array
	 */
	protected function get_default_query_vars() {
		return array(
			'fields' => array(
				'net_revenue',
				'avg_order_value',
				'orders_count',
				'avg_items_per_order',
				'num_items_sold',
				'coupons',
				'coupons_count',
				'total_customers',
			),
		);
	}

	/**
	 * Get revenue data based on the current query vars.
	 *
	 * @return array
	 */
	public function get_data() {
		$args = apply_filters( 'woocommerce_analytics_orders_stats_query_args', $this->get_query_vars() );

		$data_store = \WC_Data_Store::load( 'report-orders-stats' );
		$results    = $data_store->get_data( $args );
		return apply_filters( 'woocommerce_analytics_orders_stats_select_query', $results, $args );
	}
}
Stats/Segmenter.php000064400000051633151554100370010315 0ustar00<?php
/**
 * Class for adding segmenting support without cluttering the data stores.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Orders\Stats;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\API\Reports\Segmenter as ReportsSegmenter;
use Automattic\WooCommerce\Admin\API\Reports\ParameterException;

/**
 * Date & time interval and numeric range handling class for Reporting API.
 */
class Segmenter extends ReportsSegmenter {

	/**
	 * Returns column => query mapping to be used for product-related product-level segmenting query
	 * (e.g. products sold, revenue from product X when segmenting by category).
	 *
	 * @param string $products_table Name of SQL table containing the product-level segmenting info.
	 *
	 * @return array Column => SELECT query mapping.
	 */
	protected function get_segment_selections_product_level( $products_table ) {
		$columns_mapping = array(
			'num_items_sold' => "SUM($products_table.product_qty) as num_items_sold",
			'total_sales'    => "SUM($products_table.product_gross_revenue) AS total_sales",
			'coupons'        => 'SUM( coupon_lookup_left_join.discount_amount ) AS coupons',
			'coupons_count'  => 'COUNT( DISTINCT( coupon_lookup_left_join.coupon_id ) ) AS coupons_count',
			'refunds'        => "SUM( CASE WHEN $products_table.product_gross_revenue < 0 THEN $products_table.product_gross_revenue ELSE 0 END ) AS refunds",
			'taxes'          => "SUM($products_table.tax_amount) AS taxes",
			'shipping'       => "SUM($products_table.shipping_amount) AS shipping",
			'net_revenue'    => "SUM($products_table.product_net_revenue) AS net_revenue",
		);

		return $columns_mapping;
	}

	/**
	 * Returns column => query mapping to be used for order-related product-level segmenting query
	 * (e.g. avg items per order when segmented by category).
	 *
	 * @param string $unique_orders_table Name of SQL table containing the order-level segmenting info.
	 *
	 * @return array Column => SELECT query mapping.
	 */
	protected function get_segment_selections_order_level( $unique_orders_table ) {
		$columns_mapping = array(
			'orders_count'        => "COUNT($unique_orders_table.order_id) AS orders_count",
			'avg_items_per_order' => "AVG($unique_orders_table.num_items_sold) AS avg_items_per_order",
			'avg_order_value'     => "SUM($unique_orders_table.net_total) / COUNT($unique_orders_table.order_id) AS avg_order_value",
			'total_customers'     => "COUNT( DISTINCT( $unique_orders_table.customer_id ) ) AS total_customers",
		);

		return $columns_mapping;
	}

	/**
	 * Returns column => query mapping to be used for order-level segmenting query
	 * (e.g. avg items per order or Net sales when segmented by coupons).
	 *
	 * @param string $order_stats_table Name of SQL table containing the order-level info.
	 * @param array  $overrides Array of overrides for default column calculations.
	 *
	 * @return array Column => SELECT query mapping.
	 */
	protected function segment_selections_orders( $order_stats_table, $overrides = array() ) {
		$columns_mapping = array(
			'num_items_sold'      => "SUM($order_stats_table.num_items_sold) as num_items_sold",
			'total_sales'         => "SUM($order_stats_table.total_sales) AS total_sales",
			'coupons'             => "SUM($order_stats_table.discount_amount) AS coupons",
			'coupons_count'       => 'COUNT( DISTINCT(coupon_lookup_left_join.coupon_id) ) AS coupons_count',
			'refunds'             => "SUM( CASE WHEN $order_stats_table.parent_id != 0 THEN $order_stats_table.total_sales END ) AS refunds",
			'taxes'               => "SUM($order_stats_table.tax_total) AS taxes",
			'shipping'            => "SUM($order_stats_table.shipping_total) AS shipping",
			'net_revenue'         => "SUM($order_stats_table.net_total) AS net_revenue",
			'orders_count'        => "COUNT($order_stats_table.order_id) AS orders_count",
			'avg_items_per_order' => "AVG($order_stats_table.num_items_sold) AS avg_items_per_order",
			'avg_order_value'     => "SUM($order_stats_table.net_total) / COUNT($order_stats_table.order_id) AS avg_order_value",
			'total_customers'     => "COUNT( DISTINCT( $order_stats_table.customer_id ) ) AS total_customers",
		);

		if ( $overrides ) {
			$columns_mapping = array_merge( $columns_mapping, $overrides );
		}

		return $columns_mapping;
	}

	/**
	 * Calculate segments for totals where the segmenting property is bound to product (e.g. category, product_id, variation_id).
	 *
	 * @param array  $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'.
	 * @param string $segmenting_from FROM part of segmenting SQL query.
	 * @param string $segmenting_where WHERE part of segmenting SQL query.
	 * @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
	 * @param string $segmenting_dimension_name Name of the segmenting dimension.
	 * @param string $table_name Name of SQL table which is the stats table for orders.
	 * @param array  $totals_query Array of SQL clauses for totals query.
	 * @param string $unique_orders_table Name of temporary SQL table that holds unique orders.
	 *
	 * @return array
	 */
	protected function get_product_related_totals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $totals_query, $unique_orders_table ) {
		global $wpdb;

		$product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup';

		// Can't get all the numbers from one query, so split it into one query for product-level numbers and one for order-level numbers (which first need to have orders uniqued).
		// Product-level numbers.
		$segments_products = $wpdb->get_results(
			"SELECT
						$segmenting_groupby AS $segmenting_dimension_name
						{$segmenting_selections['product_level']}
					FROM
						$table_name
						$segmenting_from
						{$totals_query['from_clause']}
					WHERE
						1=1
						{$totals_query['where_time_clause']}
						{$totals_query['where_clause']}
						$segmenting_where
					GROUP BY
						$segmenting_groupby",
			ARRAY_A
		); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok.

		// Order level numbers.
		// As there can be 2 same product ids (or variation ids) per one order, the orders first need to be uniqued before calculating averages, customer counts, etc.
		$segments_orders = $wpdb->get_results(
			"SELECT
				    $unique_orders_table.$segmenting_dimension_name AS $segmenting_dimension_name
				    {$segmenting_selections['order_level']}
				FROM
				(
					SELECT
				        $table_name.order_id,
				        $segmenting_groupby AS $segmenting_dimension_name,
				        MAX( num_items_sold ) AS num_items_sold,
				        MAX( net_total ) as net_total,
				        MAX( returning_customer ) AS returning_customer,
						MAX( $table_name.customer_id ) as customer_id
				    FROM
				        $table_name
				        $segmenting_from
				        {$totals_query['from_clause']}
				    WHERE
				        1=1
						{$totals_query['where_time_clause']}
						{$totals_query['where_clause']}
						$segmenting_where
				    GROUP BY
				        $product_segmenting_table.order_id, $segmenting_groupby
				) AS $unique_orders_table
				GROUP BY
				    $unique_orders_table.$segmenting_dimension_name",
			ARRAY_A
		); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok.

		$totals_segments = $this->merge_segment_totals_results( $segmenting_dimension_name, $segments_products, $segments_orders );
		return $totals_segments;
	}

	/**
	 * Calculate segments for intervals where the segmenting property is bound to product (e.g. category, product_id, variation_id).
	 *
	 * @param array  $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'.
	 * @param string $segmenting_from FROM part of segmenting SQL query.
	 * @param string $segmenting_where WHERE part of segmenting SQL query.
	 * @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
	 * @param string $segmenting_dimension_name Name of the segmenting dimension.
	 * @param string $table_name Name of SQL table which is the stats table for orders.
	 * @param array  $intervals_query Array of SQL clauses for intervals query.
	 * @param string $unique_orders_table Name of temporary SQL table that holds unique orders.
	 *
	 * @return array
	 */
	protected function get_product_related_intervals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $intervals_query, $unique_orders_table ) {
		global $wpdb;

		$product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup';

		// LIMIT offset, rowcount needs to be updated to LIMIT offset, rowcount * max number of segments.
		$limit_parts      = explode( ',', $intervals_query['limit'] );
		$orig_rowcount    = intval( $limit_parts[1] );
		$segmenting_limit = $limit_parts[0] . ',' . $orig_rowcount * count( $this->get_all_segments() );

		// Can't get all the numbers from one query, so split it into one query for product-level numbers and one for order-level numbers (which first need to have orders uniqued).
		// Product-level numbers.
		$segments_products = $wpdb->get_results(
			"SELECT
						{$intervals_query['select_clause']} AS time_interval,
						$segmenting_groupby AS $segmenting_dimension_name
						{$segmenting_selections['product_level']}
					FROM
						$table_name
						$segmenting_from
						{$intervals_query['from_clause']}
					WHERE
						1=1
						{$intervals_query['where_time_clause']}
						{$intervals_query['where_clause']}
						$segmenting_where
					GROUP BY
						time_interval, $segmenting_groupby
					$segmenting_limit",
			ARRAY_A
		); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok.

		// Order level numbers.
		// As there can be 2 same product ids (or variation ids) per one order, the orders first need to be uniqued before calculating averages, customer counts, etc.
		$segments_orders = $wpdb->get_results(
			"SELECT
					$unique_orders_table.time_interval AS time_interval,
				    $unique_orders_table.$segmenting_dimension_name AS $segmenting_dimension_name
				    {$segmenting_selections['order_level']}
				FROM
				(
					SELECT
						MAX( $table_name.date_created ) AS datetime_anchor,
						{$intervals_query['select_clause']} AS time_interval,
				        $table_name.order_id,
				        $segmenting_groupby AS $segmenting_dimension_name,
				        MAX( num_items_sold ) AS num_items_sold,
				        MAX( net_total ) as net_total,
				        MAX( returning_customer ) AS returning_customer,
						MAX( $table_name.customer_id ) as customer_id
				    FROM
				        $table_name
				        $segmenting_from
				        {$intervals_query['from_clause']}
				    WHERE
				        1=1
						{$intervals_query['where_time_clause']}
						{$intervals_query['where_clause']}
						$segmenting_where
				    GROUP BY
				        time_interval, $product_segmenting_table.order_id, $segmenting_groupby
				) AS $unique_orders_table
				GROUP BY
				    time_interval, $unique_orders_table.$segmenting_dimension_name
				$segmenting_limit",
			ARRAY_A
		); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok.

		$intervals_segments = $this->merge_segment_intervals_results( $segmenting_dimension_name, $segments_products, $segments_orders );
		return $intervals_segments;
	}

	/**
	 * Calculate segments for totals query where the segmenting property is bound to order (e.g. coupon or customer type).
	 *
	 * @param string $segmenting_select SELECT part of segmenting SQL query.
	 * @param string $segmenting_from FROM part of segmenting SQL query.
	 * @param string $segmenting_where WHERE part of segmenting SQL query.
	 * @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
	 * @param string $table_name Name of SQL table which is the stats table for orders.
	 * @param array  $totals_query Array of SQL clauses for intervals query.
	 *
	 * @return array
	 */
	protected function get_order_related_totals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $totals_query ) {
		global $wpdb;

		$totals_segments = $wpdb->get_results(
			"SELECT
						$segmenting_groupby
						$segmenting_select
					FROM
						$table_name
						$segmenting_from
						{$totals_query['from_clause']}
					WHERE
						1=1
						{$totals_query['where_time_clause']}
						{$totals_query['where_clause']}
						$segmenting_where
					GROUP BY
						$segmenting_groupby",
			ARRAY_A
		); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok.

		// Reformat result.
		$totals_segments = $this->reformat_totals_segments( $totals_segments, $segmenting_groupby );
		return $totals_segments;
	}

	/**
	 * Calculate segments for intervals query where the segmenting property is bound to order (e.g. coupon or customer type).
	 *
	 * @param string $segmenting_select SELECT part of segmenting SQL query.
	 * @param string $segmenting_from FROM part of segmenting SQL query.
	 * @param string $segmenting_where WHERE part of segmenting SQL query.
	 * @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
	 * @param string $table_name Name of SQL table which is the stats table for orders.
	 * @param array  $intervals_query Array of SQL clauses for intervals query.
	 *
	 * @return array
	 */
	protected function get_order_related_intervals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $intervals_query ) {
		global $wpdb;
		$segmenting_limit = '';
		$limit_parts      = explode( ',', $intervals_query['limit'] );
		if ( 2 === count( $limit_parts ) ) {
			$orig_rowcount    = intval( $limit_parts[1] );
			$segmenting_limit = $limit_parts[0] . ',' . $orig_rowcount * count( $this->get_all_segments() );
		}

		$intervals_segments = $wpdb->get_results(
			"SELECT
						MAX($table_name.date_created) AS datetime_anchor,
						{$intervals_query['select_clause']} AS time_interval,
						$segmenting_groupby
						$segmenting_select
					FROM
						$table_name
						$segmenting_from
						{$intervals_query['from_clause']}
					WHERE
						1=1
						{$intervals_query['where_time_clause']}
						{$intervals_query['where_clause']}
						$segmenting_where
					GROUP BY
						time_interval, $segmenting_groupby
					$segmenting_limit",
			ARRAY_A
		); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok.

		// Reformat result.
		$intervals_segments = $this->reformat_intervals_segments( $intervals_segments, $segmenting_groupby );
		return $intervals_segments;
	}

	/**
	 * Return array of segments formatted for REST response.
	 *
	 * @param string $type Type of segments to return--'totals' or 'intervals'.
	 * @param array  $query_params SQL query parameter array.
	 * @param string $table_name Name of main SQL table for the data store (used as basis for JOINS).
	 *
	 * @return array
	 * @throws \Automattic\WooCommerce\Admin\API\Reports\ParameterException In case of segmenting by variations, when no parent product is specified.
	 */
	protected function get_segments( $type, $query_params, $table_name ) {
		global $wpdb;
		if ( ! isset( $this->query_args['segmentby'] ) || '' === $this->query_args['segmentby'] ) {
			return array();
		}

		$product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup';
		$unique_orders_table      = 'uniq_orders';
		$segmenting_from          = "LEFT JOIN {$wpdb->prefix}wc_order_coupon_lookup AS coupon_lookup_left_join ON ($table_name.order_id = coupon_lookup_left_join.order_id) ";
		$segmenting_where         = '';

		// Product, variation, and category are bound to product, so here product segmenting table is required,
		// while coupon and customer are bound to order, so we don't need the extra JOIN for those.
		// This also means that segment selections need to be calculated differently.
		if ( 'product' === $this->query_args['segmentby'] ) {
			// @todo How to handle shipping taxes when grouped by product?
			$product_level_columns     = $this->get_segment_selections_product_level( $product_segmenting_table );
			$order_level_columns       = $this->get_segment_selections_order_level( $unique_orders_table );
			$segmenting_selections     = array(
				'product_level' => $this->prepare_selections( $product_level_columns ),
				'order_level'   => $this->prepare_selections( $order_level_columns ),
			);
			$this->report_columns      = array_merge( $product_level_columns, $order_level_columns );
			$segmenting_from          .= "INNER JOIN $product_segmenting_table ON ($table_name.order_id = $product_segmenting_table.order_id)";
			$segmenting_groupby        = $product_segmenting_table . '.product_id';
			$segmenting_dimension_name = 'product_id';

			$segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table );
		} elseif ( 'variation' === $this->query_args['segmentby'] ) {
			if ( ! isset( $this->query_args['product_includes'] ) || count( $this->query_args['product_includes'] ) !== 1 ) {
				throw new ParameterException( 'wc_admin_reports_invalid_segmenting_variation', __( 'product_includes parameter need to specify exactly one product when segmenting by variation.', 'woocommerce' ) );
			}

			$product_level_columns     = $this->get_segment_selections_product_level( $product_segmenting_table );
			$order_level_columns       = $this->get_segment_selections_order_level( $unique_orders_table );
			$segmenting_selections     = array(
				'product_level' => $this->prepare_selections( $product_level_columns ),
				'order_level'   => $this->prepare_selections( $order_level_columns ),
			);
			$this->report_columns      = array_merge( $product_level_columns, $order_level_columns );
			$segmenting_from          .= "INNER JOIN $product_segmenting_table ON ($table_name.order_id = $product_segmenting_table.order_id)";
			$segmenting_where          = "AND $product_segmenting_table.product_id = {$this->query_args['product_includes'][0]}";
			$segmenting_groupby        = $product_segmenting_table . '.variation_id';
			$segmenting_dimension_name = 'variation_id';

			$segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table );
		} elseif ( 'category' === $this->query_args['segmentby'] ) {
			$product_level_columns     = $this->get_segment_selections_product_level( $product_segmenting_table );
			$order_level_columns       = $this->get_segment_selections_order_level( $unique_orders_table );
			$segmenting_selections     = array(
				'product_level' => $this->prepare_selections( $product_level_columns ),
				'order_level'   => $this->prepare_selections( $order_level_columns ),
			);
			$this->report_columns      = array_merge( $product_level_columns, $order_level_columns );
			$segmenting_from          .= "
			INNER JOIN $product_segmenting_table ON ($table_name.order_id = $product_segmenting_table.order_id)
			LEFT JOIN {$wpdb->term_relationships} ON {$product_segmenting_table}.product_id = {$wpdb->term_relationships}.object_id
			JOIN {$wpdb->term_taxonomy} ON {$wpdb->term_taxonomy}.term_taxonomy_id = {$wpdb->term_relationships}.term_taxonomy_id
			LEFT JOIN {$wpdb->wc_category_lookup} ON {$wpdb->term_taxonomy}.term_id = {$wpdb->wc_category_lookup}.category_id
			";
			$segmenting_where          = " AND {$wpdb->wc_category_lookup}.category_tree_id IS NOT NULL";
			$segmenting_groupby        = "{$wpdb->wc_category_lookup}.category_tree_id";
			$segmenting_dimension_name = 'category_id';

			$segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table );
		} elseif ( 'coupon' === $this->query_args['segmentby'] ) {
			// As there can be 2 or more coupons applied per one order, coupon amount needs to be split.
			$coupon_override       = array(
				'coupons' => 'SUM(coupon_lookup.discount_amount) AS coupons',
			);
			$coupon_level_columns  = $this->segment_selections_orders( $table_name, $coupon_override );
			$segmenting_selections = $this->prepare_selections( $coupon_level_columns );
			$this->report_columns  = $coupon_level_columns;
			$segmenting_from      .= "
			INNER JOIN {$wpdb->prefix}wc_order_coupon_lookup AS coupon_lookup ON ($table_name.order_id = coupon_lookup.order_id)
            ";
			$segmenting_groupby    = 'coupon_lookup.coupon_id';

			$segments = $this->get_order_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params );
		} elseif ( 'customer_type' === $this->query_args['segmentby'] ) {
			$customer_level_columns = $this->segment_selections_orders( $table_name );
			$segmenting_selections  = $this->prepare_selections( $customer_level_columns );
			$this->report_columns   = $customer_level_columns;
			$segmenting_groupby     = "$table_name.returning_customer";

			$segments = $this->get_order_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params );
		}

		return $segments;
	}
}