Σ(゚Д゚;≡;゚д゚)Σ(゚Д゚;≡;゚д゚)Σ(゚Д゚;≡;゚д゚)始,故人唐宰相鲁公,🆚开府南服,余以布衣从戎。明年,别公漳水湄。后明年,公以事过张睢阳庙及颜杲卿所尝往来处,悲歌慷慨,卒不负其言而从之游。今其诗具在,可考也。😭 HEX
HEX
Server: Apache
System: Linux webm006.cluster120.gra.hosting.ovh.net 6.18.39-ovh-vps-grsec-zfs+ #1 SMP PREEMPT_DYNAMIC Tue Jul 21 12:03:15 CEST 2026 x86_64
User: studionolh (122383)
PHP: 7.3.33
Disabled: _dyuweyrj4,_dyuweyrj4r,dl
Upload Files
File: /home/s/t/u/studionolh/www/wp-includes/Requests/Cookie/reusable-blocks/API.tar
OnboardingProfile.php000064400000040421152333372110010661 0ustar00<?php
/**
 * REST API Onboarding Profile Controller
 *
 * Handles requests to /onboarding/profile
 */

namespace Automattic\WooCommerce\Admin\API;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile as Profile;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProducts;
use Automattic\Jetpack\Connection\Manager as Jetpack_Connection_Manager;

/**
 * Onboarding Profile controller.
 *
 * @internal
 * @extends WC_REST_Data_Controller
 */
class OnboardingProfile extends \WC_REST_Data_Controller {
	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-admin';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'onboarding/profile';

	/**
	 * Register routes.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_items' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'update_items' ),
					'permission_callback' => array( $this, 'update_items_permissions_check' ),
					'args'                => $this->get_collection_params(),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		// This endpoint is experimental. For internal use only.
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/experimental_get_email_prefill',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_email_prefill' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);
	}

	/**
	 * Check whether a given request has permission to read onboarding profile data.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|boolean
	 */
	public function get_items_permissions_check( $request ) {
		if ( ! wc_rest_check_manager_permissions( 'settings', 'read' ) ) {
			return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
		}

		return true;
	}

	/**
	 * Check whether a given request has permission to edit onboarding profile data.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|boolean
	 */
	public function update_items_permissions_check( $request ) {
		if ( ! wc_rest_check_manager_permissions( 'settings', 'edit' ) ) {
			return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot edit this resource.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
		}

		return true;
	}

	/**
	 * Return all onboarding profile data.
	 *
	 * @param  WP_REST_Request $request Request data.
	 * @return WP_Error|WP_REST_Response
	 */
	public function get_items( $request ) {
		include_once WC_ABSPATH . 'includes/admin/helper/class-wc-helper-options.php';

		$onboarding_data             = get_option( Profile::DATA_OPTION, array() );
		$onboarding_data['industry'] = isset( $onboarding_data['industry'] ) ? $this->filter_industries( $onboarding_data['industry'] ) : null;
		$item_schema                 = $this->get_item_schema();
		$items                       = array();
		foreach ( $item_schema['properties'] as $key => $property_schema ) {
			$items[ $key ] = isset( $onboarding_data[ $key ] ) ? $onboarding_data[ $key ] : null;
		}

		$wccom_auth               = \WC_Helper_Options::get( 'auth' );
		$items['wccom_connected'] = empty( $wccom_auth['access_token'] ) ? false : true;

		$item = $this->prepare_item_for_response( $items, $request );
		$data = $this->prepare_response_for_collection( $item );

		return rest_ensure_response( $data );
	}

	/**
	 * Filter the industries.
	 *
	 * @param  array $industries list of industries.
	 * @return array
	 */
	protected function filter_industries( $industries ) {
		return apply_filters(
			'woocommerce_admin_onboarding_industries',
			$industries
		);
	}

	/**
	 * Update onboarding profile data.
	 *
	 * @param  WP_REST_Request $request Request data.
	 * @return WP_Error|WP_REST_Response
	 */
	public function update_items( $request ) {
		$params          = $request->get_json_params();
		$query_args      = $this->prepare_objects_query( $params );
		$onboarding_data = (array) get_option( Profile::DATA_OPTION, array() );
		$profile_data    = array_merge( $onboarding_data, $query_args );
		update_option( Profile::DATA_OPTION, $profile_data );
		do_action( 'woocommerce_onboarding_profile_data_updated', $onboarding_data, $query_args );

		$result = array(
			'status'  => 'success',
			'message' => __( 'Onboarding profile data has been updated.', 'woocommerce' ),
		);

		$response = $this->prepare_item_for_response( $result, $request );
		$data     = $this->prepare_response_for_collection( $response );

		return rest_ensure_response( $data );
	}

	/**
	 * Returns a default email to be pre-filled in OBW. Prioritizes Jetpack if connected,
	 * otherwise will default to WordPress general settings.
	 *
	 * @param  WP_REST_Request $request Request data.
	 * @return WP_Error|WP_REST_Response
	 */
	public function get_email_prefill( $request ) {
		$result = array(
			'email' => '',
		);

		// Attempt to get email from Jetpack.
		if ( class_exists( Jetpack_Connection_Manager::class ) ) {
			$jetpack_connection_manager = new Jetpack_Connection_Manager();
			if ( $jetpack_connection_manager->is_active() ) {
				$jetpack_user = $jetpack_connection_manager->get_connected_user_data();

				$result['email'] = $jetpack_user['email'];
			}
		}

		// Attempt to get email from WordPress general settings.
		if ( empty( $result['email'] ) ) {
			$result['email'] = get_option( 'admin_email' );
		}

		return rest_ensure_response( $result );
	}

	/**
	 * Prepare objects query.
	 *
	 * @param  array $params The params sent in the request.
	 * @return array
	 */
	protected function prepare_objects_query( $params ) {
		$args       = array();
		$properties = self::get_profile_properties();

		foreach ( $properties as $key => $property ) {
			if ( isset( $params[ $key ] ) ) {
				$args[ $key ] = $params[ $key ];
			}
		}

		/**
		 * Filter the query arguments for a request.
		 *
		 * Enables adding extra arguments or setting defaults for a post
		 * collection request.
		 *
		 * @param array $args    Key value array of query var to query value.
		 * @param array $params The params sent in the request.
		 */
		$args = apply_filters( 'woocommerce_rest_onboarding_profile_object_query', $args, $params );

		return $args;
	}


	/**
	 * Prepare the data object for response.
	 *
	 * @param object          $item Data object.
	 * @param WP_REST_Request $request Request object.
	 * @return WP_REST_Response $response Response data.
	 */
	public function prepare_item_for_response( $item, $request ) {
		$data     = $this->add_additional_fields_to_object( $item, $request );
		$data     = $this->filter_response_by_context( $data, 'view' );
		$response = rest_ensure_response( $data );

		/**
		 * Filter the list returned from the API.
		 *
		 * @param WP_REST_Response $response The response object.
		 * @param array            $item     The original item.
		 * @param WP_REST_Request  $request  Request used to generate the response.
		 */
		return apply_filters( 'woocommerce_rest_onboarding_prepare_profile', $response, $item, $request );
	}

	/**
	 * Get onboarding profile properties.
	 *
	 * @return array
	 */
	public static function get_profile_properties() {
		$properties = array(
			'completed'            => array(
				'type'              => 'boolean',
				'description'       => __( 'Whether or not the profile was completed.', 'woocommerce' ),
				'context'           => array( 'view' ),
				'readonly'          => true,
				'validate_callback' => 'rest_validate_request_arg',
			),
			'skipped'              => array(
				'type'              => 'boolean',
				'description'       => __( 'Whether or not the profile was skipped.', 'woocommerce' ),
				'context'           => array( 'view' ),
				'readonly'          => true,
				'validate_callback' => 'rest_validate_request_arg',
			),
			'industry'             => array(
				'type'              => 'array',
				'description'       => __( 'Industry.', 'woocommerce' ),
				'context'           => array( 'view' ),
				'readonly'          => true,
				'validate_callback' => 'rest_validate_request_arg',
				'items'             => array(
					'type' => 'object',
				),
			),
			'product_types'        => array(
				'type'              => 'array',
				'description'       => __( 'Types of products sold.', 'woocommerce' ),
				'context'           => array( 'view' ),
				'readonly'          => true,
				'sanitize_callback' => 'wp_parse_slug_list',
				'validate_callback' => 'rest_validate_request_arg',
				'items'             => array(
					'enum' => array_keys( OnboardingProducts::get_allowed_product_types() ),
					'type' => 'string',
				),
			),
			'product_count'        => array(
				'type'              => 'string',
				'description'       => __( 'Number of products to be added.', 'woocommerce' ),
				'context'           => array( 'view' ),
				'readonly'          => true,
				'validate_callback' => 'rest_validate_request_arg',
				'enum'              => array(
					'0',
					'1-10',
					'11-100',
					'101-1000',
					'1000+',
				),
			),
			'selling_venues'       => array(
				'type'              => 'string',
				'description'       => __( 'Other places the store is selling products.', 'woocommerce' ),
				'context'           => array( 'view' ),
				'readonly'          => true,
				'validate_callback' => 'rest_validate_request_arg',
				'enum'              => array(
					'no',
					'other',
					'brick-mortar',
					'brick-mortar-other',
					'other-woocommerce',
				),
			),
			'number_employees'     => array(
				'type'              => 'string',
				'description'       => __( 'Number of employees of the store.', 'woocommerce' ),
				'context'           => array( 'view' ),
				'readonly'          => true,
				'validate_callback' => 'rest_validate_request_arg',
				'enum'              => array(
					'1',
					'<10',
					'10-50',
					'50-250',
					'+250',
					'not specified',
				),
			),
			'revenue'              => array(
				'type'              => 'string',
				'description'       => __( 'Current annual revenue of the store.', 'woocommerce' ),
				'context'           => array( 'view' ),
				'readonly'          => true,
				'validate_callback' => 'rest_validate_request_arg',
				'enum'              => array(
					'none',
					'up-to-2500',
					'2500-10000',
					'10000-50000',
					'50000-250000',
					'more-than-250000',
					'rather-not-say',
				),
			),
			'other_platform'       => array(
				'type'              => 'string',
				'description'       => __( 'Name of other platform used to sell.', 'woocommerce' ),
				'context'           => array( 'view' ),
				'readonly'          => true,
				'validate_callback' => 'rest_validate_request_arg',
				'enum'              => array(
					'shopify',
					'bigcommerce',
					'magento',
					'wix',
					'amazon',
					'ebay',
					'etsy',
					'squarespace',
					'other',
				),
			),
			'other_platform_name'  => array(
				'type'              => 'string',
				'description'       => __( 'Name of other platform used to sell (not listed).', 'woocommerce' ),
				'context'           => array( 'view' ),
				'readonly'          => true,
				'validate_callback' => 'rest_validate_request_arg',
			),
			'business_extensions'  => array(
				'type'              => 'array',
				'description'       => __( 'Extra business extensions to install.', 'woocommerce' ),
				'context'           => array( 'view' ),
				'readonly'          => true,
				'sanitize_callback' => 'wp_parse_slug_list',
				'validate_callback' => 'rest_validate_request_arg',
				'items'             => array(
					'enum' => array(
						'jetpack',
						'woocommerce-services',
						'woocommerce-payments',
						'mailchimp-for-woocommerce',
						'creative-mail-by-constant-contact',
						'facebook-for-woocommerce',
						'google-listings-and-ads',
						'mailpoet',
						'tiktok-for-business',
						'tiktok-for-business:alt'
					),
					'type' => 'string',
				),
			),
			'theme'                => array(
				'type'              => 'string',
				'description'       => __( 'Selected store theme.', 'woocommerce' ),
				'context'           => array( 'view' ),
				'readonly'          => true,
				'sanitize_callback' => 'sanitize_title_with_dashes',
				'validate_callback' => 'rest_validate_request_arg',
			),
			'setup_client'         => array(
				'type'              => 'boolean',
				'description'       => __( 'Whether or not this store was setup for a client.', 'woocommerce' ),
				'context'           => array( 'view' ),
				'readonly'          => true,
				'validate_callback' => 'rest_validate_request_arg',
			),
			'wccom_connected'      => array(
				'type'              => 'boolean',
				'description'       => __( 'Whether or not the store was connected to WooCommerce.com during the extension flow.', 'woocommerce' ),
				'context'           => array( 'view' ),
				'readonly'          => true,
				'validate_callback' => 'rest_validate_request_arg',
			),
			'is_agree_marketing'   => array(
				'type'              => 'boolean',
				'description'       => __( 'Whether or not this store agreed to receiving marketing contents from WooCommerce.com.', 'woocommerce' ),
				'context'           => array( 'view' ),
				'readonly'          => true,
				'validate_callback' => 'rest_validate_request_arg',
			),
			'store_email'          => array(
				'type'              => 'string',
				'description'       => __( 'Store email address.', 'woocommerce' ),
				'context'           => array( 'view' ),
				'readonly'          => true,
				'validate_callback' => array( __CLASS__, 'rest_validate_marketing_email' ),
			),
			'is_store_country_set' => array(
				'type'              => 'boolean',
				'description'       => __( 'Whether or not this store country is set via onboarding profiler.', 'woocommerce' ),
				'context'           => array( 'view' ),
				'readonly'          => true,
				'validate_callback' => 'rest_validate_request_arg',
			),
		);

		return apply_filters( 'woocommerce_rest_onboarding_profile_properties', $properties );
	}

	/**
	 * Optionally validates email if user agreed to marketing or if email is not empty.
	 *
	 * @param mixed           $value Email value.
	 * @param WP_REST_Request $request Request object.
	 * @param string          $param Parameter name.
	 * @return true|WP_Error
	 */
	public static function rest_validate_marketing_email( $value, $request, $param ) {
		$is_agree_marketing = $request->get_param( 'is_agree_marketing' );
		if (
			( $is_agree_marketing || ! empty( $value ) ) &&
			! is_email( $value ) ) {
			return new \WP_Error( 'rest_invalid_email', __( 'Invalid email address', 'woocommerce' ) );
		};
		return true;
	}

	/**
	 * Get the schema, conforming to JSON Schema.
	 *
	 * @return array
	 */
	public function get_item_schema() {
		// Unset properties used for collection params.
		$properties = self::get_profile_properties();
		foreach ( $properties as $key => $property ) {
			unset( $properties[ $key ]['default'] );
			unset( $properties[ $key ]['items'] );
			unset( $properties[ $key ]['validate_callback'] );
			unset( $properties[ $key ]['sanitize_callback'] );
		}

		$schema = array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => 'onboarding_profile',
			'type'       => 'object',
			'properties' => $properties,
		);

		return $this->add_additional_fields_schema( $schema );
	}

	/**
	 * Get the query params for collections.
	 *
	 * @return array
	 */
	public function get_collection_params() {
		// Unset properties used for item schema.
		$params = self::get_profile_properties();
		foreach ( $params as $key => $param ) {
			unset( $params[ $key ]['context'] );
			unset( $params[ $key ]['readonly'] );
		}

		$params['context'] = $this->get_context_param( array( 'default' => 'view' ) );

		return apply_filters( 'woocommerce_rest_onboarding_profile_collection_params', $params );
	}
}
ProductsLowInStock.php000064400000023012152333372110011033 0ustar00<?php
/**
 * REST API ProductsLowInStock Controller
 *
 * Handles request to /products/low-in-stock
 */

namespace Automattic\WooCommerce\Admin\API;

defined( 'ABSPATH' ) || exit;

/**
 * ProductsLowInStock controller.
 *
 * @internal
 * @extends WC_REST_Products_Controller
 */
final class ProductsLowInStock extends \WC_REST_Products_Controller {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Register routes.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'products/low-in-stock',
			array(
				'args'   => array(),
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_items' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
					'args'                => $this->get_collection_params(),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);
	}

	/**
	 * Get low in stock products.
	 *
	 * @param WP_REST_Request $request request object.
	 *
	 * @return WP_REST_Response|WP_ERROR
	 */
	public function get_items( $request ) {
		$query_results = $this->get_low_in_stock_products(
			$request->get_param( 'page' ),
			$request->get_param( 'per_page' ),
			$request->get_param( 'status' )
		);

		// set images and attributes.
		$query_results['results'] = array_map(
			function( $query_result ) {
				$product                  = wc_get_product( $query_result );
				$query_result->images     = $this->get_images( $product );
				$query_result->attributes = $this->get_attributes( $product );
				return $query_result;
			},
			$query_results['results']
		);

		// set last_order_date.
		$query_results['results'] = $this->set_last_order_date( $query_results['results'] );

		// convert the post data to the expected API response for the backward compatibility.
		$query_results['results'] = array_map( array( $this, 'transform_post_to_api_response' ), $query_results['results'] );

		$response = rest_ensure_response( array_values( $query_results['results'] ) );
		$response->header( 'X-WP-Total', $query_results['total'] );
		$response->header( 'X-WP-TotalPages', $query_results['pages'] );

		return $response;
	}

	/**
	 * Set the last order date for each data.
	 *
	 * @param array $results query result from get_low_in_stock_products.
	 *
	 * @return mixed
	 */
	protected function set_last_order_date( $results = array() ) {
		global $wpdb;
		if ( 0 === count( $results ) ) {
			return $results;
		}

		$wheres = array();
		foreach ( $results as $result ) {
			'product_variation' === $result->post_type ?
				array_push( $wheres, "(product_id={$result->post_parent} and variation_id={$result->ID})" )
				: array_push( $wheres, "product_id={$result->ID}" );
		}

		count( $wheres ) ? $where_clause = implode( ' or ', $wheres ) : $where_clause = $wheres[0];

		$product_lookup_table = $wpdb->prefix . 'wc_order_product_lookup';
		$query_string         = "
			select
				product_id,
				variation_id,
				MAX( wc_order_product_lookup.date_created ) AS last_order_date
			from {$product_lookup_table} wc_order_product_lookup
			where {$where_clause}
			group by product_id
			order by date_created desc
		";

		// phpcs:ignore -- ignore prepare() warning as we're not using any user input here.
		$last_order_dates = $wpdb->get_results( $query_string );
		$last_order_dates_index = array();
		// Make an index with product_id_variation_id as a key
		// so that it can be referenced back without looping the whole array.
		foreach ( $last_order_dates as $last_order_date ) {
			$last_order_dates_index[ $last_order_date->product_id . '_' . $last_order_date->variation_id ] = $last_order_date;
		}

		foreach ( $results as &$result ) {
			'product_variation' === $result->post_type ?
				$index_key   = $result->post_parent . '_' . $result->ID
				: $index_key = $result->ID . '_' . $result->post_parent;

			if ( isset( $last_order_dates_index[ $index_key ] ) ) {
				$result->last_order_date = $last_order_dates_index[ $index_key ]->last_order_date;
			}
		}

		return $results;
	}

	/**
	 * Get low in stock products data.
	 *
	 * @param int    $page current page.
	 * @param int    $per_page items per page.
	 * @param string $status post status.
	 *
	 * @return array
	 */
	protected function get_low_in_stock_products( $page = 1, $per_page = 1, $status = 'publish' ) {
		global $wpdb;

		$offset              = ( $page - 1 ) * $per_page;
		$low_stock_threshold = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) );

		$query_string = $this->get_query( $this->is_using_sitewide_stock_threshold_only() );

		$query_results = $wpdb->get_results(
			// phpcs:ignore -- not sure why phpcs complains about this line when prepare() is used here.
			$wpdb->prepare( $query_string, $status, $low_stock_threshold, $offset, $per_page ),
			OBJECT_K
		);

		$total_results = $wpdb->get_var( 'SELECT FOUND_ROWS()' );

		return array(
			'results' => $query_results,
			'total'   => (int) $total_results,
			'pages'   => (int) ceil( $total_results / (int) $per_page ),
		);
	}

	/**
	 * Check to see if store is using sitewide threshold only. Meaning that it does not have any custom
	 * stock threshold for a product.
	 *
	 * @return bool
	 */
	protected function is_using_sitewide_stock_threshold_only() {
		global $wpdb;
		$count = $wpdb->get_var( "select count(*) as total from {$wpdb->postmeta} where meta_key='_low_stock_amount'" );
		return 0 === (int) $count;
	}

	/**
	 * Transform post object to expected API response.
	 *
	 * @param object $query_result a row of query result from get_low_in_stock_products().
	 *
	 * @return array
	 */
	protected function transform_post_to_api_response( $query_result ) {
		$low_stock_amount = null;
		if ( isset( $query_result->low_stock_amount ) ) {
			$low_stock_amount = (int) $query_result->low_stock_amount;
		}

		if ( ! isset( $query_result->last_order_date ) ) {
			$query_result->last_order_date = null;
		}

		return array(
			'id'               => (int) $query_result->ID,
			'images'           => $query_result->images,
			'attributes'       => $query_result->attributes,
			'low_stock_amount' => $low_stock_amount,
			'last_order_date'  => wc_rest_prepare_date_response( $query_result->last_order_date ),
			'name'             => $query_result->post_title,
			'parent_id'        => (int) $query_result->post_parent,
			'stock_quantity'   => (int) $query_result->stock_quantity,
			'type'             => 'product_variation' === $query_result->post_type ? 'variation' : 'simple',
		);
	}

	/**
	 * Generate a query.
	 *
	 * @param bool $siteside_only generates a query for sitewide low stock threshold only query.
	 *
	 * @return string
	 */
	protected function get_query( $siteside_only = false ) {
		global $wpdb;
		$query = "
			SELECT
				SQL_CALC_FOUND_ROWS wp_posts.*,
				:postmeta_select
				wc_product_meta_lookup.stock_quantity
			FROM
			  {$wpdb->wc_product_meta_lookup} wc_product_meta_lookup
			  LEFT JOIN {$wpdb->posts} wp_posts ON wp_posts.ID = wc_product_meta_lookup.product_id
			  :postmeta_join
			WHERE
			  wp_posts.post_type IN ('product', 'product_variation')
			  AND wp_posts.post_status = %s
			  AND wc_product_meta_lookup.stock_quantity IS NOT NULL
			  AND wc_product_meta_lookup.stock_status IN('instock', 'outofstock')
			  :postmeta_wheres
			order by wc_product_meta_lookup.product_id DESC
			limit %d, %d
		";

		$postmeta = array(
			'select' => '',
			'join'   => '',
			'wheres' => 'AND wc_product_meta_lookup.stock_quantity <= %d',
		);

		if ( ! $siteside_only ) {
			$postmeta['select'] = 'meta.meta_value AS low_stock_amount,';
			$postmeta['join']   = "LEFT JOIN {$wpdb->postmeta} AS meta ON wp_posts.ID = meta.post_id
			  AND meta.meta_key = '_low_stock_amount'";
			$postmeta['wheres'] = "AND (
			    (
			      meta.meta_value > ''
			      AND wc_product_meta_lookup.stock_quantity <= CAST(
			        meta.meta_value AS SIGNED
			      )
			    )
			    OR (
			      (
			        meta.meta_value IS NULL
			        OR meta.meta_value <= ''
			      )
			      AND wc_product_meta_lookup.stock_quantity <= %d
			    )
		    )";
		}

		return strtr(
			$query,
			array(
				':postmeta_select' => $postmeta['select'],
				':postmeta_join'   => $postmeta['join'],
				':postmeta_wheres' => $postmeta['wheres'],
			)
		);
	}

	/**
	 * Get the query params for collections of attachments.
	 *
	 * @return array
	 */
	public function get_collection_params() {
		$params                       = array();
		$params['context']            = $this->get_context_param();
		$params['context']['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['status'] = array(
			'default'           => 'publish',
			'description'       => __( 'Limit result set to products assigned a specific status.', 'woocommerce' ),
			'type'              => 'string',
			'enum'              => array_merge( array_keys( get_post_statuses() ), array( 'future' ) ),
			'sanitize_callback' => 'sanitize_key',
			'validate_callback' => 'rest_validate_request_arg',
		);

		return $params;
	}
}
NavigationFavorites.php000064400000011515152333372110011242 0ustar00<?php
/**
 * REST API Navigation Favorites controller
 *
 * Handles requests to the navigation favorites endpoint
 */

namespace Automattic\WooCommerce\Admin\API;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Features\Navigation\Favorites;

/**
 * REST API Favorites controller class.
 *
 * @internal
 * @extends WC_REST_CRUD_Controller
 */
class NavigationFavorites extends \WC_REST_Data_Controller {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-admin';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'navigation/favorites';

	/**
	 * Error code to status code mapping.
	 *
	 * @var array
	 */
	protected $error_to_status_map = array(
		'woocommerce_favorites_invalid_request' => 400,
		'woocommerce_favorites_already_exists'  => 409,
		'woocommerce_favorites_does_not_exist'  => 404,
		'woocommerce_favorites_invalid_user'    => 400,
		'woocommerce_favorites_unauthenticated' => 401,
	);

	/**
	 * Register the routes
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/me',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_items' ),
					'permission_callback' => array( $this, 'current_user_permissions_check' ),
				),
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'add_item' ),
					'permission_callback' => array( $this, 'current_user_permissions_check' ),
					'args'                => array(
						'item_id' => array(
							'required' => true,
						),
					),
				),
				array(
					'methods'             => \WP_REST_Server::DELETABLE,
					'callback'            => array( $this, 'delete_item' ),
					'permission_callback' => array( $this, 'current_user_permissions_check' ),
					'args'                => array(
						'item_id' => array(
							'required' => true,
						),
					),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

	}

	/**
	 * Get all favorites.
	 *
	 * @param WP_REST_Request $request Request data.
	 * @return WP_REST_Response
	 */
	public function get_items( $request ) {
		$response = Favorites::get_all( get_current_user_id() );

		if ( is_wp_error( $response ) || ! $response ) {
			return rest_ensure_response( $this->prepare_error( $response ) );
		}

		return rest_ensure_response(
			array_map( 'stripslashes', $response )
		);
	}

	/**
	 * Add a favorite.
	 *
	 * @param WP_REST_Request $request Request data.
	 * @return WP_REST_Response
	 */
	public function add_item( $request ) {
		$user_id = get_current_user_id();
		$fav_id  = $request->get_param( 'item_id' );
		$user    = get_userdata( $user_id );

		if ( false === $user ) {
			return $this->prepare_error(
				new \WP_Error(
					'woocommerce_favorites_invalid_user',
					__( 'Invalid user_id provided', 'woocommerce' )
				)
			);
		}

		$response = Favorites::add_item( $fav_id, $user_id );

		if ( is_wp_error( $response ) || ! $response ) {
			return rest_ensure_response( $this->prepare_error( $response ) );
		}

		return rest_ensure_response( Favorites::get_all( $user_id ) );
	}

	/**
	 * Delete a favorite.
	 *
	 * @param WP_REST_Request $request Request data.
	 * @return WP_REST_Response
	 */
	public function delete_item( $request ) {
		$user_id = get_current_user_id();
		$fav_id  = $request->get_param( 'item_id' );

		$response = Favorites::remove_item( $fav_id, $user_id );

		if ( is_wp_error( $response ) || ! $response ) {
			return rest_ensure_response( $this->prepare_error( $response ) );
		}

		return rest_ensure_response( Favorites::get_all( $user_id ) );
	}

	/**
	 * Check whether a given request has permission to create favorites.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|boolean
	 */
	public function add_item_permissions_check( $request ) {
		return current_user_can( 'edit_users' );
	}

	/**
	 * Check whether a given request has permission to delete notes.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|boolean
	 */
	public function delete_item_permissions_check( $request ) {
		return current_user_can( 'edit_users' );
	}

	/**
	 * Always allow for operations that only impact current user
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|boolean
	 */
	public function current_user_permissions_check( $request ) {
		return true;
	}

	/**
	 * Accept an instance of WP_Error and add the appropriate data for REST transit.
	 *
	 * @param  WP_Error $error Error to prepare.
	 * @return WP_Error
	 */
	protected function prepare_error( $error ) {
		if ( ! is_wp_error( $error ) ) {
			return $error;
		}

		$error->add_data(
			array(
				'status' => $this->error_to_status_map[ $error->get_error_code() ] ?? 500,
			)
		);

		return $error;
	}

}
Orders.php000064400000022301152333372110006511 0ustar00<?php
/**
 * REST API Orders Controller
 *
 * Handles requests to /orders/*
 */

namespace Automattic\WooCommerce\Admin\API;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\API\Reports\Controller as ReportsController;

/**
 * Orders controller.
 *
 * @internal
 * @extends WC_REST_Orders_Controller
 */
class Orders extends \WC_REST_Orders_Controller {
	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Get the query params for collections.
	 *
	 * @return array
	 */
	public function get_collection_params() {
		$params = parent::get_collection_params();
		// This needs to remain a string to support extensions that filter Order Number.
		$params['number'] = array(
			'description'       => __( 'Limit result set to orders matching part of an order number.', 'woocommerce' ),
			'type'              => 'string',
			'validate_callback' => 'rest_validate_request_arg',
		);
		// Fix the default 'status' value until it can be patched in core.
		$params['status']['default'] = array( 'any' );

		// Analytics settings may affect the allowed status list.
		$params['status']['items']['enum'] = ReportsController::get_order_statuses();

		return $params;
	}

	/**
	 * Prepare objects query.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return array
	 */
	protected function prepare_objects_query( $request ) {
		global $wpdb;
		$args = parent::prepare_objects_query( $request );

		// Search by partial order number.
		if ( ! empty( $request['number'] ) ) {
			$partial_number = trim( $request['number'] );
			$limit          = intval( $args['posts_per_page'] );
			$order_ids      = $wpdb->get_col(
				$wpdb->prepare(
					"SELECT ID
					FROM {$wpdb->prefix}posts
					WHERE post_type = 'shop_order'
					AND ID LIKE %s
					LIMIT %d",
					$wpdb->esc_like( absint( $partial_number ) ) . '%',
					$limit
				)
			);

			// Force WP_Query return empty if don't found any order.
			$order_ids        = empty( $order_ids ) ? array( 0 ) : $order_ids;
			$args['post__in'] = $order_ids;
		}

		return $args;
	}

	/**
	 * Get product IDs, names, and quantity from order ID.
	 *
	 * @param array $order_id ID of order.
	 * @return array
	 */
	protected function get_products_by_order_id( $order_id ) {
		global $wpdb;
		$order_items_table    = $wpdb->prefix . 'woocommerce_order_items';
		$order_itemmeta_table = $wpdb->prefix . 'woocommerce_order_itemmeta';
		$products             = $wpdb->get_results(
			$wpdb->prepare(
				// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				"SELECT
				order_id,
				order_itemmeta.meta_value as product_id,
				order_itemmeta_2.meta_value as product_quantity,
				order_itemmeta_3.meta_value as variation_id,
				{$wpdb->posts}.post_title as product_name
			FROM {$order_items_table} order_items
			    LEFT JOIN {$order_itemmeta_table} order_itemmeta on order_items.order_item_id = order_itemmeta.order_item_id
			    LEFT JOIN {$order_itemmeta_table} order_itemmeta_2 on order_items.order_item_id = order_itemmeta_2.order_item_id
			    LEFT JOIN {$order_itemmeta_table} order_itemmeta_3 on order_items.order_item_id = order_itemmeta_3.order_item_id
			    LEFT JOIN {$wpdb->posts} on {$wpdb->posts}.ID = order_itemmeta.meta_value
			WHERE
				order_id = ( %d )
			    AND order_itemmeta.meta_key = '_product_id'
				AND order_itemmeta_2.meta_key = '_qty'
			  	AND order_itemmeta_3.meta_key = '_variation_id'
			GROUP BY product_id
			", // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				$order_id
			),
			ARRAY_A
		);

		return $products;
	}

	/**
	 * Get customer data from customer_id.
	 *
	 * @param array $customer_id ID of customer.
	 * @return array
	 */
	protected function get_customer_by_id( $customer_id ) {
		global $wpdb;

		$customer_lookup_table = $wpdb->prefix . 'wc_customer_lookup';

		$customer = $wpdb->get_row(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				"SELECT * FROM {$customer_lookup_table} WHERE customer_id = ( %d )",
				$customer_id
			),
			ARRAY_A
		);

		return $customer;
	}

	/**
	 * Get formatted item data.
	 *
	 * @param  WC_Data $object WC_Data instance.
	 * @return array
	 */
	protected function get_formatted_item_data( $object ) {
		$extra_fields = array( 'customer', 'products' );
		$fields       = false;
		// Determine if the response fields were specified.
		if ( ! empty( $this->request['_fields'] ) ) {
			$fields = wp_parse_list( $this->request['_fields'] );

			if ( 0 === count( $fields ) ) {
				$fields = false;
			} else {
				$fields = array_map( 'trim', $fields );
			}
		}

		// Initially skip line items if we can.
		$using_order_class_override = is_a( $object, '\Automattic\WooCommerce\Admin\Overrides\Order' );
		if ( $using_order_class_override ) {
			$data = $object->get_data_without_line_items();
		} else {
			$data = $object->get_data();
		}

		$extra_fields      = false === $fields ? array() : array_intersect( $extra_fields, $fields );
		$format_decimal    = array( 'discount_total', 'discount_tax', 'shipping_total', 'shipping_tax', 'shipping_total', 'shipping_tax', 'cart_tax', 'total', 'total_tax' );
		$format_date       = array( 'date_created', 'date_modified', 'date_completed', 'date_paid' );
		$format_line_items = array( 'line_items', 'tax_lines', 'shipping_lines', 'fee_lines', 'coupon_lines' );

		// Add extra data as necessary.
		$extra_data = array();
		foreach ( $extra_fields as $field ) {
			switch ( $field ) {
				case 'customer':
					$extra_data['customer'] = $this->get_customer_by_id( $data['customer_id'] );
					break;
				case 'products':
					$extra_data['products'] = $this->get_products_by_order_id( $object->get_id() );
					break;
			}
		}
		// Format decimal values.
		foreach ( $format_decimal as $key ) {
			$data[ $key ] = wc_format_decimal( $data[ $key ], $this->request['dp'] );
		}

		// format total with order currency.
		if ( $object instanceof \WC_Order ) {
			$data['total_formatted'] = wp_strip_all_tags( html_entity_decode( $object->get_formatted_order_total() ), true );
		}

		// Format date values.
		foreach ( $format_date as $key ) {
			$datetime              = $data[ $key ];
			$data[ $key ]          = wc_rest_prepare_date_response( $datetime, false );
			$data[ $key . '_gmt' ] = wc_rest_prepare_date_response( $datetime );
		}

		// Format the order status.
		$data['status'] = 'wc-' === substr( $data['status'], 0, 3 ) ? substr( $data['status'], 3 ) : $data['status'];

		// Format requested line items.
		$formatted_line_items = array();

		foreach ( $format_line_items as $key ) {
			if ( false === $fields || in_array( $key, $fields, true ) ) {
				if ( $using_order_class_override ) {
					$line_item_data = $object->get_line_item_data( $key );
				} else {
					$line_item_data = $data[ $key ];
				}
				$formatted_line_items[ $key ] = array_values( array_map( array( $this, 'get_order_item_data' ), $line_item_data ) );
			}
		}

		// Refunds.
		$data['refunds'] = array();
		foreach ( $object->get_refunds() as $refund ) {
			$data['refunds'][] = array(
				'id'     => $refund->get_id(),
				'reason' => $refund->get_reason() ? $refund->get_reason() : '',
				'total'  => '-' . wc_format_decimal( $refund->get_amount(), $this->request['dp'] ),
			);
		}

		return array_merge(
			array(
				'id'                   => $object->get_id(),
				'parent_id'            => $data['parent_id'],
				'number'               => $data['number'],
				'order_key'            => $data['order_key'],
				'created_via'          => $data['created_via'],
				'version'              => $data['version'],
				'status'               => $data['status'],
				'currency'             => $data['currency'],
				'date_created'         => $data['date_created'],
				'date_created_gmt'     => $data['date_created_gmt'],
				'date_modified'        => $data['date_modified'],
				'date_modified_gmt'    => $data['date_modified_gmt'],
				'discount_total'       => $data['discount_total'],
				'discount_tax'         => $data['discount_tax'],
				'shipping_total'       => $data['shipping_total'],
				'shipping_tax'         => $data['shipping_tax'],
				'cart_tax'             => $data['cart_tax'],
				'total'                => $data['total'],
				'total_formatted'      => isset( $data['total_formatted'] ) ? $data['total_formatted'] : $data['total'],
				'total_tax'            => $data['total_tax'],
				'prices_include_tax'   => $data['prices_include_tax'],
				'customer_id'          => $data['customer_id'],
				'customer_ip_address'  => $data['customer_ip_address'],
				'customer_user_agent'  => $data['customer_user_agent'],
				'customer_note'        => $data['customer_note'],
				'billing'              => $data['billing'],
				'shipping'             => $data['shipping'],
				'payment_method'       => $data['payment_method'],
				'payment_method_title' => $data['payment_method_title'],
				'transaction_id'       => $data['transaction_id'],
				'date_paid'            => $data['date_paid'],
				'date_paid_gmt'        => $data['date_paid_gmt'],
				'date_completed'       => $data['date_completed'],
				'date_completed_gmt'   => $data['date_completed_gmt'],
				'cart_hash'            => $data['cart_hash'],
				'meta_data'            => $data['meta_data'],
				'refunds'              => $data['refunds'],
			),
			$formatted_line_items,
			$extra_data
		);
	}
}
Coupons.php000064400000004232152333372110006704 0ustar00<?php
/**
 * REST API Coupons Controller
 *
 * Handles requests to /coupons/*
 */

namespace Automattic\WooCommerce\Admin\API;

defined( 'ABSPATH' ) || exit;

/**
 * Coupons controller.
 *
 * @internal
 * @extends WC_REST_Coupons_Controller
 */
class Coupons extends \WC_REST_Coupons_Controller {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Get the query params for collections.
	 *
	 * @return array
	 */
	public function get_collection_params() {
		$params           = parent::get_collection_params();
		$params['search'] = array(
			'description'       => __( 'Limit results to coupons with codes matching a given string.', 'woocommerce' ),
			'type'              => 'string',
			'validate_callback' => 'rest_validate_request_arg',
		);
		return $params;
	}


	/**
	 * Add coupon code searching to the WC API.
	 *
	 * @param WP_REST_Request $request Request data.
	 * @return array
	 */
	protected function prepare_objects_query( $request ) {
		$args = parent::prepare_objects_query( $request );

		if ( ! empty( $request['search'] ) ) {
			$args['search'] = $request['search'];
			$args['s']      = false;
		}

		return $args;
	}

	/**
	 * Get a collection of posts and add the code search option to WP_Query.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_Error|WP_REST_Response
	 */
	public function get_items( $request ) {
		add_filter( 'posts_where', array( __CLASS__, 'add_wp_query_search_code_filter' ), 10, 2 );
		$response = parent::get_items( $request );
		remove_filter( 'posts_where', array( __CLASS__, 'add_wp_query_search_code_filter' ), 10 );
		return $response;
	}

	/**
	 * Add code searching to the WP Query
	 *
	 * @internal
	 * @param string $where Where clause used to search posts.
	 * @param object $wp_query WP_Query object.
	 * @return string
	 */
	public static function add_wp_query_search_code_filter( $where, $wp_query ) {
		global $wpdb;

		$search = $wp_query->get( 'search' );
		if ( $search ) {
			$code_like = '%' . $wpdb->esc_like( $search ) . '%';
			$where    .= $wpdb->prepare( "AND {$wpdb->posts}.post_title LIKE %s", $code_like );
		}

		return $where;
	}
}
OnboardingProductTypes.php000064400000003460152333372110011730 0ustar00<?php
/**
 * REST API Onboarding Product Types Controller
 *
 * Handles requests to /onboarding/product-types
 */

namespace Automattic\WooCommerce\Admin\API;

use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProducts;

defined( 'ABSPATH' ) || exit;

/**
 * Onboarding Product Types Controller.
 *
 * @internal
 * @extends WC_REST_Data_Controller
 */
class OnboardingProductTypes extends \WC_REST_Data_Controller {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-admin';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'onboarding/product-types';

	/**
	 * Register routes.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_product_types' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);
	}

	/**
	 * Check whether a given request has permission to read onboarding profile data.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|boolean
	 */
	public function get_items_permissions_check( $request ) {
		if ( ! wc_rest_check_manager_permissions( 'settings', 'read' ) ) {
			return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
		}

		return true;
	}

	/**
	 * Return available product types.
	 *
	 * @param \WP_REST_Request $request Request data.
	 *
	 * @return \WP_Error|\WP_REST_Response
	 */
	public function get_product_types( $request ) {
		return OnboardingProducts::get_product_types_with_data();
	}

}
Products.php000064400000023315152333372110007064 0ustar00<?php
/**
 * REST API Products Controller
 *
 * Handles requests to /products/*
 */

namespace Automattic\WooCommerce\Admin\API;

defined( 'ABSPATH' ) || exit;

/**
 * Products controller.
 *
 * @internal
 * @extends WC_REST_Products_Controller
 */
class Products extends \WC_REST_Products_Controller {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Local cache of last order dates by ID.
	 *
	 * @var array
	 */
	protected $last_order_dates = array();

	/**
	 * Adds properties that can be embed via ?_embed=1.
	 *
	 * @return array
	 */
	public function get_item_schema() {
		$schema = parent::get_item_schema();

		$properties_to_embed = array(
			'id',
			'name',
			'slug',
			'permalink',
			'images',
			'description',
			'short_description',
		);

		foreach ( $properties_to_embed as $property ) {
			$schema['properties'][ $property ]['context'][] = 'embed';
		}

		$schema['properties']['last_order_date'] = array(
			'description' => __( "The date the last order for this product was placed, in the site's timezone.", 'woocommerce' ),
			'type'        => 'date-time',
			'context'     => array( 'view', 'edit' ),
			'readonly'    => true,
		);

		return $schema;
	}

	/**
	 * Get the query params for collections.
	 *
	 * @return array
	 */
	public function get_collection_params() {
		$params                 = parent::get_collection_params();
		$params['low_in_stock'] = array(
			'description'       => __( 'Limit result set to products that are low or out of stock. (Deprecated)', 'woocommerce' ),
			'type'              => 'boolean',
			'default'           => false,
			'sanitize_callback' => 'wc_string_to_bool',
		);
		$params['search']       = array(
			'description'       => __( 'Search by similar product name or sku.', 'woocommerce' ),
			'type'              => 'string',
			'validate_callback' => 'rest_validate_request_arg',
		);
		return $params;
	}


	/**
	 * Add product name and sku filtering to the WC API.
	 *
	 * @param WP_REST_Request $request Request data.
	 * @return array
	 */
	protected function prepare_objects_query( $request ) {
		$args = parent::prepare_objects_query( $request );

		if ( ! empty( $request['search'] ) ) {
			$args['search'] = trim( $request['search'] );
			unset( $args['s'] );
		}
		if ( ! empty( $request['low_in_stock'] ) ) {
			$args['low_in_stock'] = $request['low_in_stock'];
			$args['post_type']    = array( 'product', 'product_variation' );
		}

		return $args;
	}

	/**
	 * Get a collection of posts and add the post title filter option to WP_Query.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_Error|WP_REST_Response
	 */
	public function get_items( $request ) {
		add_filter( 'posts_fields', array( __CLASS__, 'add_wp_query_fields' ), 10, 2 );
		add_filter( 'posts_where', array( __CLASS__, 'add_wp_query_filter' ), 10, 2 );
		add_filter( 'posts_join', array( __CLASS__, 'add_wp_query_join' ), 10, 2 );
		add_filter( 'posts_groupby', array( __CLASS__, 'add_wp_query_group_by' ), 10, 2 );
		$response = parent::get_items( $request );
		remove_filter( 'posts_fields', array( __CLASS__, 'add_wp_query_fields' ), 10 );
		remove_filter( 'posts_where', array( __CLASS__, 'add_wp_query_filter' ), 10 );
		remove_filter( 'posts_join', array( __CLASS__, 'add_wp_query_join' ), 10 );
		remove_filter( 'posts_groupby', array( __CLASS__, 'add_wp_query_group_by' ), 10 );

		/**
		 * The low stock query caused performance issues in WooCommerce 5.5.1
		 * due to a) being slow, and b) multiple requests being made to this endpoint
		 * from WC Admin.
		 *
		 * This is a temporary measure to trigger the user’s browser to cache the
		 * endpoint response for 1 minute, limiting the amount of requests overall.
		 *
		 * https://github.com/woocommerce/woocommerce-admin/issues/7358
		 */
		if ( $this->is_low_in_stock_request( $request ) ) {
			$response->header( 'Cache-Control', 'max-age=300' );
		}
		return $response;
	}

	/**
	 * Check whether the request is for products low in stock.
	 *
	 * It matches requests with paramaters:
	 *
	 * low_in_stock = true
	 * page = 1
	 * fields[0] = id
	 *
	 * @param string $request WP REST API request.
	 * @return boolean Whether the request matches.
	 */
	private function is_low_in_stock_request( $request ) {
		if (
			$request->get_param( 'low_in_stock' ) === true &&
			$request->get_param( 'page' ) === 1 &&
			is_array( $request->get_param( '_fields' ) ) &&
			count( $request->get_param( '_fields' ) ) === 1 &&
			in_array( 'id', $request->get_param( '_fields' ), true )
		) {
			return true;
		}

		return false;
	}

	/**
	 * Hang onto last order date since it will get removed by wc_get_product().
	 *
	 * @param stdClass $object_data Single row from query results.
	 * @return WC_Data
	 */
	public function get_object( $object_data ) {
		if ( isset( $object_data->last_order_date ) ) {
			$this->last_order_dates[ $object_data->ID ] = $object_data->last_order_date;
		}
		return parent::get_object( $object_data );
	}

	/**
	 * Add `low_stock_amount` property to product data
	 *
	 * @param WC_Data         $object  Object data.
	 * @param WP_REST_Request $request Request object.
	 * @return WP_REST_Response
	 */
	public function prepare_object_for_response( $object, $request ) {
		$data        = parent::prepare_object_for_response( $object, $request );
		$object_data = $object->get_data();
		$product_id  = $object_data['id'];

		if ( $request->get_param( 'low_in_stock' ) ) {
			if ( is_numeric( $object_data['low_stock_amount'] ) ) {
				$data->data['low_stock_amount'] = $object_data['low_stock_amount'];
			}
			if ( isset( $this->last_order_dates[ $product_id ] ) ) {
				$data->data['last_order_date'] = wc_rest_prepare_date_response( $this->last_order_dates[ $product_id ] );
			}
		}
		if ( isset( $data->data['name'] ) ) {
			$data->data['name'] = wp_strip_all_tags( $data->data['name'] );
		}

		return $data;
	}

	/**
	 * Add in conditional select fields to the query.
	 *
	 * @internal
	 * @param string $select Select clause used to select fields from the query.
	 * @param object $wp_query WP_Query object.
	 * @return string
	 */
	public static function add_wp_query_fields( $select, $wp_query ) {
		if ( $wp_query->get( 'low_in_stock' ) ) {
			$fields  = array(
				'low_stock_amount_meta.meta_value AS low_stock_amount',
				'MAX( product_lookup.date_created ) AS last_order_date',
			);
			$select .= ', ' . implode( ', ', $fields );
		}

		return $select;
	}

	/**
	 * Add in conditional search filters for products.
	 *
	 * @internal
	 * @param string $where Where clause used to search posts.
	 * @param object $wp_query WP_Query object.
	 * @return string
	 */
	public static function add_wp_query_filter( $where, $wp_query ) {
		global $wpdb;

		$search = $wp_query->get( 'search' );
		if ( $search ) {
			$title_like = '%' . $wpdb->esc_like( $search ) . '%';
			$where     .= $wpdb->prepare( " AND ({$wpdb->posts}.post_title LIKE %s", $title_like );  // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$where     .= wc_product_sku_enabled() ? $wpdb->prepare( ' OR wc_product_meta_lookup.sku LIKE %s)', $search ) : ')';
		}

		if ( $wp_query->get( 'low_in_stock' ) ) {
			$low_stock_amount = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) );
			$where           .= "
			AND wc_product_meta_lookup.stock_quantity IS NOT NULL
			AND wc_product_meta_lookup.stock_status IN('instock','outofstock')
			AND (
				(
					low_stock_amount_meta.meta_value > ''
					AND wc_product_meta_lookup.stock_quantity <= CAST(low_stock_amount_meta.meta_value AS SIGNED)
				)
				OR (
					(
						low_stock_amount_meta.meta_value IS NULL OR low_stock_amount_meta.meta_value <= ''
					)
					AND wc_product_meta_lookup.stock_quantity <= {$low_stock_amount}
				)
			)";
		}

		return $where;
	}

	/**
	 * Join posts meta tables when product search or low stock query is present.
	 *
	 * @internal
	 * @param string $join Join clause used to search posts.
	 * @param object $wp_query WP_Query object.
	 * @return string
	 */
	public static function add_wp_query_join( $join, $wp_query ) {
		global $wpdb;

		$search = $wp_query->get( 'search' );
		if ( $search && wc_product_sku_enabled() ) {
			$join = self::append_product_sorting_table_join( $join );
		}

		if ( $wp_query->get( 'low_in_stock' ) ) {
			$product_lookup_table = $wpdb->prefix . 'wc_order_product_lookup';

			$join  = self::append_product_sorting_table_join( $join );
			$join .= " LEFT JOIN {$wpdb->postmeta} AS low_stock_amount_meta ON {$wpdb->posts}.ID = low_stock_amount_meta.post_id AND low_stock_amount_meta.meta_key = '_low_stock_amount' ";
			$join .= " LEFT JOIN {$product_lookup_table} product_lookup ON {$wpdb->posts}.ID = CASE
				WHEN {$wpdb->posts}.post_type = 'product' THEN product_lookup.product_id
				WHEN {$wpdb->posts}.post_type = 'product_variation' THEN product_lookup.variation_id
			END";
		}

		return $join;
	}

	/**
	 * Join wc_product_meta_lookup to posts if not already joined.
	 *
	 * @internal
	 * @param string $sql SQL join.
	 * @return string
	 */
	protected static function append_product_sorting_table_join( $sql ) {
		global $wpdb;

		if ( ! strstr( $sql, 'wc_product_meta_lookup' ) ) {
			$sql .= " LEFT JOIN {$wpdb->wc_product_meta_lookup} wc_product_meta_lookup ON $wpdb->posts.ID = wc_product_meta_lookup.product_id ";
		}
		return $sql;
	}

	/**
	 * Group by post ID to prevent duplicates.
	 *
	 * @internal
	 * @param string $groupby Group by clause used to organize posts.
	 * @param object $wp_query WP_Query object.
	 * @return string
	 */
	public static function add_wp_query_group_by( $groupby, $wp_query ) {
		global $wpdb;

		$search       = $wp_query->get( 'search' );
		$low_in_stock = $wp_query->get( 'low_in_stock' );
		if ( empty( $groupby ) && ( $search || $low_in_stock ) ) {
			$groupby = $wpdb->posts . '.ID';
		}
		return $groupby;
	}
}
Taxes.php000064400000011634152333372110006346 0ustar00<?php
/**
 * REST API Taxes Controller
 *
 * Handles requests to /taxes/*
 */

namespace Automattic\WooCommerce\Admin\API;

defined( 'ABSPATH' ) || exit;

/**
 * Taxes controller.
 *
 * @internal
 * @extends WC_REST_Taxes_Controller
 */
class Taxes extends \WC_REST_Taxes_Controller {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Get the query params for collections.
	 *
	 * @return array
	 */
	public function get_collection_params() {
		$params            = parent::get_collection_params();
		$params['search']  = array(
			'description'       => __( 'Search by similar tax code.', 'woocommerce' ),
			'type'              => 'string',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['include'] = array(
			'description'       => __( 'Limit result set to items that have the specified rate ID(s) assigned.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'integer',
			),
			'default'           => array(),
			'validate_callback' => 'rest_validate_request_arg',
		);
		return $params;
	}

	/**
	 * Get all taxes and allow filtering by tax code.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_Error|WP_REST_Response
	 */
	public function get_items( $request ) {
		global $wpdb;

		$prepared_args           = array();
		$prepared_args['order']  = $request['order'];
		$prepared_args['number'] = $request['per_page'];
		if ( ! empty( $request['offset'] ) ) {
			$prepared_args['offset'] = $request['offset'];
		} else {
			$prepared_args['offset'] = ( $request['page'] - 1 ) * $prepared_args['number'];
		}
		$orderby_possibles        = array(
			'id'    => 'tax_rate_id',
			'order' => 'tax_rate_order',
		);
		$prepared_args['orderby'] = $orderby_possibles[ $request['orderby'] ];
		$prepared_args['class']   = $request['class'];
		$prepared_args['search']  = $request['search'];
		$prepared_args['include'] = $request['include'];

		/**
		 * Filter arguments, before passing to $wpdb->get_results(), when querying taxes via the REST API.
		 *
		 * @param array           $prepared_args Array of arguments for $wpdb->get_results().
		 * @param WP_REST_Request $request       The current request.
		 */
		$prepared_args = apply_filters( 'woocommerce_rest_tax_query', $prepared_args, $request );

		$query = "
			SELECT *
			FROM {$wpdb->prefix}woocommerce_tax_rates
			WHERE 1 = 1
		";

		// Filter by tax class.
		if ( ! empty( $prepared_args['class'] ) ) {
			$class  = 'standard' !== $prepared_args['class'] ? sanitize_title( $prepared_args['class'] ) : '';
			$query .= " AND tax_rate_class = '$class'";
		}

		// Filter by tax code.
		$tax_code_search = $prepared_args['search'];
		if ( $tax_code_search ) {
			$code_like = '%' . $wpdb->esc_like( $tax_code_search ) . '%';
			$query    .= $wpdb->prepare( ' AND CONCAT_WS( "-", NULLIF(tax_rate_country, ""), NULLIF(tax_rate_state, ""), NULLIF(tax_rate_name, ""), NULLIF(tax_rate_priority, "") ) LIKE %s', $code_like );
		}

		// Filter by included tax rate IDs.
		$included_taxes = array_map( 'absint', $prepared_args['include'] );
		if ( ! empty( $included_taxes ) ) {
			$included_taxes = implode( ',', $prepared_args['include'] );
			$query         .= " AND tax_rate_id IN ({$included_taxes})";
		}

		// Order tax rates.
		$order_by = sprintf( ' ORDER BY %s', sanitize_key( $prepared_args['orderby'] ) );

		// Pagination.
		$pagination = sprintf( ' LIMIT %d, %d', $prepared_args['offset'], $prepared_args['number'] );

		// Query taxes.
		$results = $wpdb->get_results( $query . $order_by . $pagination ); // @codingStandardsIgnoreLine.

		$taxes = array();
		foreach ( $results as $tax ) {
			$data    = $this->prepare_item_for_response( $tax, $request );
			$taxes[] = $this->prepare_response_for_collection( $data );
		}

		$response = rest_ensure_response( $taxes );

		// Store pagination values for headers then unset for count query.
		$per_page = (int) $prepared_args['number'];
		$page     = ceil( ( ( (int) $prepared_args['offset'] ) / $per_page ) + 1 );

		// Query only for ids.
		$wpdb->get_results( str_replace( 'SELECT *', 'SELECT tax_rate_id', $query ) ); // @codingStandardsIgnoreLine.

		// Calculate totals.
		$total_taxes = (int) $wpdb->num_rows;
		$response->header( 'X-WP-Total', (int) $total_taxes );
		$max_pages = ceil( $total_taxes / $per_page );
		$response->header( 'X-WP-TotalPages', (int) $max_pages );

		$base = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) );
		if ( $page > 1 ) {
			$prev_page = $page - 1;
			if ( $prev_page > $max_pages ) {
				$prev_page = $max_pages;
			}
			$prev_link = add_query_arg( 'page', $prev_page, $base );
			$response->link_header( 'prev', $prev_link );
		}
		if ( $max_pages > $page ) {
			$next_page = $page + 1;
			$next_link = add_query_arg( 'page', $next_page, $base );
			$response->link_header( 'next', $next_link );
		}

		return $response;
	}
}
ProductCategories.php000064400000000712152333372110010703 0ustar00<?php
/**
 * REST API Product Categories Controller
 *
 * Handles requests to /products/categories.
 */

namespace Automattic\WooCommerce\Admin\API;

defined( 'ABSPATH' ) || exit;

/**
 * Product categories controller.
 *
 * @internal
 * @extends WC_REST_Product_Categories_Controller
 */
class ProductCategories extends \WC_REST_Product_Categories_Controller {
	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';
}
ProductVariations.php000064400000013735152333372110010746 0ustar00<?php
/**
 * REST API Product Variations Controller
 *
 * Handles requests to /products/variations.
 */

namespace Automattic\WooCommerce\Admin\API;

defined( 'ABSPATH' ) || exit;

/**
 * Product variations controller.
 *
 * @internal
 * @extends WC_REST_Product_Variations_Controller
 */
class ProductVariations extends \WC_REST_Product_Variations_Controller {
	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Register the routes for products.
	 */
	public function register_routes() {
		parent::register_routes();

		// Add a route for listing variations without specifying the parent product ID.
		register_rest_route(
			$this->namespace,
			'/variations',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_items' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
					'args'                => $this->get_collection_params(),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);
	}

	/**
	 * Get the query params for collections.
	 *
	 * @return array
	 */
	public function get_collection_params() {
		$params           = parent::get_collection_params();
		$params['search'] = array(
			'description'       => __( 'Search by similar product name, sku, or attribute value.', 'woocommerce' ),
			'type'              => 'string',
			'validate_callback' => 'rest_validate_request_arg',
		);
		return $params;
	}

	/**
	 * Add in conditional search filters for variations.
	 *
	 * @internal
	 * @param string $where Where clause used to search posts.
	 * @param object $wp_query WP_Query object.
	 * @return string
	 */
	public static function add_wp_query_filter( $where, $wp_query ) {
		global $wpdb;

		$search = $wp_query->get( 'search' );
		if ( $search ) {
			$like       = '%' . $wpdb->esc_like( $search ) . '%';
			$conditions = array(
				$wpdb->prepare( "{$wpdb->posts}.post_title LIKE %s", $like ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				$wpdb->prepare( 'attr_search_meta.meta_value LIKE %s', $like ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			);

			if ( wc_product_sku_enabled() ) {
				$conditions[] = $wpdb->prepare( 'wc_product_meta_lookup.sku LIKE %s', $like );
			}

			$where .= ' AND (' . implode( ' OR ', $conditions ) . ')';
		}

		return $where;
	}

	/**
	 * Join posts meta tables when variation search query is present.
	 *
	 * @internal
	 * @param string $join Join clause used to search posts.
	 * @param object $wp_query WP_Query object.
	 * @return string
	 */
	public static function add_wp_query_join( $join, $wp_query ) {
		global $wpdb;

		$search = $wp_query->get( 'search' );
		if ( $search ) {
			$join .= " LEFT JOIN {$wpdb->postmeta} AS attr_search_meta
						ON {$wpdb->posts}.ID = attr_search_meta.post_id
						AND attr_search_meta.meta_key LIKE 'attribute_%' ";
		}

		if ( wc_product_sku_enabled() && ! strstr( $join, 'wc_product_meta_lookup' ) ) {
			$join .= " LEFT JOIN {$wpdb->wc_product_meta_lookup} wc_product_meta_lookup
						ON $wpdb->posts.ID = wc_product_meta_lookup.product_id ";
		}

		return $join;
	}

	/**
	 * Add product name and sku filtering to the WC API.
	 *
	 * @param WP_REST_Request $request Request data.
	 * @return array
	 */
	protected function prepare_objects_query( $request ) {
		$args = parent::prepare_objects_query( $request );

		if ( ! empty( $request['search'] ) ) {
			$args['search'] = $request['search'];
			unset( $args['s'] );
		}

		// Retreive variations without specifying a parent product.
		if ( "/{$this->namespace}/variations" === $request->get_route() ) {
			unset( $args['post_parent'] );
		}

		return $args;
	}

	/**
	 * Get a collection of posts and add the post title filter option to WP_Query.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_Error|WP_REST_Response
	 */
	public function get_items( $request ) {
		add_filter( 'posts_where', array( __CLASS__, 'add_wp_query_filter' ), 10, 2 );
		add_filter( 'posts_join', array( __CLASS__, 'add_wp_query_join' ), 10, 2 );
		add_filter( 'posts_groupby', array( 'Automattic\WooCommerce\Admin\API\Products', 'add_wp_query_group_by' ), 10, 2 );
		$response = parent::get_items( $request );
		remove_filter( 'posts_where', array( __CLASS__, 'add_wp_query_filter' ), 10 );
		remove_filter( 'posts_join', array( __CLASS__, 'add_wp_query_join' ), 10 );
		remove_filter( 'posts_groupby', array( 'Automattic\WooCommerce\Admin\API\Products', 'add_wp_query_group_by' ), 10 );
		return $response;
	}

	/**
	 * Get the Product's schema, conforming to JSON Schema.
	 *
	 * @return array
	 */
	public function get_item_schema() {
		$schema = parent::get_item_schema();

		$schema['properties']['name']      = array(
			'description' => __( 'Product parent name.', 'woocommerce' ),
			'type'        => 'string',
			'context'     => array( 'view', 'edit' ),
		);
		$schema['properties']['type']      = array(
			'description' => __( 'Product type.', 'woocommerce' ),
			'type'        => 'string',
			'default'     => 'variation',
			'enum'        => array( 'variation' ),
			'context'     => array( 'view', 'edit' ),
		);
		$schema['properties']['parent_id'] = array(
			'description' => __( 'Product parent ID.', 'woocommerce' ),
			'type'        => 'integer',
			'context'     => array( 'view', 'edit' ),
		);

		return $schema;
	}

	/**
	 * Prepare a single variation output for response.
	 *
	 * @param  WC_Data         $object  Object data.
	 * @param  WP_REST_Request $request Request object.
	 * @return WP_REST_Response
	 */
	public function prepare_object_for_response( $object, $request ) {
		$context  = empty( $request['context'] ) ? 'view' : $request['context'];
		$response = parent::prepare_object_for_response( $object, $request );
		$data     = $response->get_data();

		$data['name']      = $object->get_name( $context );
		$data['type']      = $object->get_type();
		$data['parent_id'] = $object->get_parent_id( $context );

		$response->set_data( $data );

		return $response;
	}
}
OnboardingThemes.php000064400000014007152333372110010507 0ustar00<?php
/**
 * REST API Onboarding Themes Controller
 *
 * Handles requests to install and activate themes.
 */

namespace Automattic\WooCommerce\Admin\API;

use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingThemes as Themes;

defined( 'ABSPATH' ) || exit;

/**
 * Onboarding Themes Controller.
 *
 * @internal
 * @extends WC_REST_Data_Controller
 */
class OnboardingThemes extends \WC_REST_Data_Controller {
	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-admin';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'onboarding/themes';

	/**
	 * Register routes.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/install',
			array(
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'install_theme' ),
					'permission_callback' => array( $this, 'update_item_permissions_check' ),
				),
				'schema' => array( $this, 'get_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/activate',
			array(
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'activate_theme' ),
					'permission_callback' => array( $this, 'update_item_permissions_check' ),
				),
				'schema' => array( $this, 'get_item_schema' ),
			)
		);
	}

	/**
	 * Check if a given request has access to manage themes.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|boolean
	 */
	public function update_item_permissions_check( $request ) {
		if ( ! current_user_can( 'switch_themes' ) ) {
			return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you cannot manage themes.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
		}
		return true;
	}

	/**
	 * Installs the requested theme.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|array Theme installation status.
	 */
	public function install_theme( $request ) {
		$allowed_themes = Themes::get_allowed_themes();
		$theme          = sanitize_text_field( $request['theme'] );

		if ( ! in_array( $theme, $allowed_themes, true ) ) {
			return new \WP_Error( 'woocommerce_rest_invalid_theme', __( 'Invalid theme.', 'woocommerce' ), 404 );
		}

		$installed_themes = wp_get_themes();

		if ( in_array( $theme, array_keys( $installed_themes ), true ) ) {
			return( array(
				'slug'   => $theme,
				'name'   => $installed_themes[ $theme ]->get( 'Name' ),
				'status' => 'success',
			) );
		}

		include_once ABSPATH . '/wp-admin/includes/admin.php';
		include_once ABSPATH . '/wp-admin/includes/theme-install.php';
		include_once ABSPATH . '/wp-admin/includes/theme.php';
		include_once ABSPATH . '/wp-admin/includes/class-wp-upgrader.php';
		include_once ABSPATH . '/wp-admin/includes/class-theme-upgrader.php';

		$api = themes_api(
			'theme_information',
			array(
				'slug'   => $theme,
				'fields' => array(
					'sections' => false,
				),
			)
		);

		if ( is_wp_error( $api ) ) {
			return new \WP_Error(
				'woocommerce_rest_theme_install',
				sprintf(
					/* translators: %s: theme slug (example: woocommerce-services) */
					__( 'The requested theme `%s` could not be installed. Theme API call failed.', 'woocommerce' ),
					$theme
				),
				500
			);
		}

		$upgrader = new \Theme_Upgrader( new \Automatic_Upgrader_Skin() );
		$result   = $upgrader->install( $api->download_link );

		if ( is_wp_error( $result ) || is_null( $result ) ) {
			return new \WP_Error(
				'woocommerce_rest_theme_install',
				sprintf(
					/* translators: %s: theme slug (example: woocommerce-services) */
					__( 'The requested theme `%s` could not be installed.', 'woocommerce' ),
					$theme
				),
				500
			);
		}

		return array(
			'slug'   => $theme,
			'name'   => $api->name,
			'status' => 'success',
		);
	}

	/**
	 * Activate the requested theme.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|array Theme activation status.
	 */
	public function activate_theme( $request ) {
		$allowed_themes = Themes::get_allowed_themes();
		$theme          = sanitize_text_field( $request['theme'] );
		if ( ! in_array( $theme, $allowed_themes, true ) ) {
			return new \WP_Error( 'woocommerce_rest_invalid_theme', __( 'Invalid theme.', 'woocommerce' ), 404 );
		}

		require_once ABSPATH . 'wp-admin/includes/theme.php';

		$installed_themes = wp_get_themes();

		if ( ! in_array( $theme, array_keys( $installed_themes ), true ) ) {
			/* translators: %s: theme slug (example: woocommerce-services) */
			return new \WP_Error( 'woocommerce_rest_invalid_theme', sprintf( __( 'Invalid theme %s.', 'woocommerce' ), $theme ), 404 );
		}

		$result = switch_theme( $theme );
		if ( ! is_null( $result ) ) {
			return new \WP_Error( 'woocommerce_rest_invalid_theme', sprintf( __( 'The requested theme could not be activated.', 'woocommerce' ), $theme ), 500 );
		}

		return( array(
			'slug'   => $theme,
			'name'   => $installed_themes[ $theme ]->get( 'Name' ),
			'status' => 'success',
		) );
	}

	/**
	 * Get the schema, conforming to JSON Schema.
	 *
	 * @return array
	 */
	public function get_item_schema() {
		$schema = array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => 'onboarding_theme',
			'type'       => 'object',
			'properties' => array(
				'slug'   => array(
					'description' => __( 'Theme slug.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'name'   => array(
					'description' => __( 'Theme name.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'status' => array(
					'description' => __( 'Theme status.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
			),
		);

		return $this->add_additional_fields_schema( $schema );
	}
}
MobileAppMagicLink.php000064400000004143152333372110010706 0ustar00<?php
/**
 * REST API Data countries controller.
 *
 * Handles requests to the /mobile-app endpoint.
 */

namespace Automattic\WooCommerce\Admin\API;

defined( 'ABSPATH' ) || exit;

use Automattic\Jetpack\Connection\Manager as Jetpack_Connection_Manager;

/**
 * REST API Data countries controller class.
 *
 * @internal
 * @extends WC_REST_Data_Controller
 */
class MobileAppMagicLink extends \WC_REST_Data_Controller {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-admin';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'mobile-app';

	/**
	 * Register routes.
	 *
	 * @since 7.0.0
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/send-magic-link',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'send_magic_link' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		parent::register_routes();
	}

	/**
	 * Sends request to generate magic link email.
	 *
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function send_magic_link() {
		// Attempt to get email from Jetpack.
		if ( class_exists( Jetpack_Connection_Manager::class ) ) {
			$jetpack_connection_manager = new Jetpack_Connection_Manager();
			if ( $jetpack_connection_manager->is_active() ) {
				if ( class_exists( 'Jetpack_IXR_Client' ) ) {
					$xml = new \Jetpack_IXR_Client(
						array(
							'user_id' => get_current_user_id(),
						)
					);

					$xml->query( 'jetpack.sendMobileMagicLink', array( 'app' => 'woocommerce' ) );
					if ( $xml->isError() ) {
						return new \WP_Error(
							'error_sending_mobile_magic_link',
							sprintf(
								'%s: %s',
								$xml->getErrorCode(),
								$xml->getErrorMessage()
							)
						);
					}

					return rest_ensure_response(
						array(
							'code' => 'success',
						)
					);
				}
			}
		}

		return new \WP_Error( 'jetpack_not_connected', __( 'Jetpack is not connected.', 'woocommerce' ) );
	}
}
Marketing.php000064400000007546152333372110007212 0ustar00<?php
/**
 * REST API Marketing Controller
 *
 * Handles requests to /marketing.
 */

namespace Automattic\WooCommerce\Admin\API;

use Automattic\WooCommerce\Internal\Admin\Marketing as MarketingFeature;
use Automattic\WooCommerce\Admin\PluginsHelper;

defined( 'ABSPATH' ) || exit;

/**
 * Marketing Controller.
 *
 * @internal
 * @extends WC_REST_Data_Controller
 */
class Marketing extends \WC_REST_Data_Controller {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-admin';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'marketing';

	/**
	 * Register routes.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/recommended',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_recommended_plugins' ),
					'permission_callback' => array( $this, 'get_recommended_plugins_permissions_check' ),
					'args'                => array(
						'per_page' => $this->get_collection_params()['per_page'],
						'category' => array(
							'type'              => 'string',
							'validate_callback' => 'rest_validate_request_arg',
							'sanitize_callback' => 'sanitize_title_with_dashes',
						),
					),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/knowledge-base',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_knowledge_base_posts' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
					'args'                => array(
						'category' => array(
							'type'              => 'string',
							'validate_callback' => 'rest_validate_request_arg',
							'sanitize_callback' => 'sanitize_title_with_dashes',
						),
					),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);
	}

	/**
	 * Check whether a given request has permission to install plugins.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|boolean
	 */
	public function get_recommended_plugins_permissions_check( $request ) {
		if ( ! current_user_can( 'install_plugins' ) ) {
			return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you cannot manage plugins.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
		}

		return true;
	}


	/**
	 * Return installed marketing extensions data.
	 *
	 * @param \WP_REST_Request $request Request data.
	 *
	 * @return \WP_Error|\WP_REST_Response
	 */
	public function get_recommended_plugins( $request ) {
		// Default to marketing category (if no category set).
		$category      = ( ! empty( $request->get_param( 'category' ) ) ) ? $request->get_param( 'category' ) : 'marketing';
		$all_plugins   = MarketingFeature::get_instance()->get_recommended_plugins();
		$valid_plugins = [];
		$per_page      = $request->get_param( 'per_page' );

		foreach ( $all_plugins as $plugin ) {

			// default to marketing if 'categories' is empty on the plugin object (support for legacy api while testing).
			$plugin_categories = ( ! empty( $plugin['categories'] ) ) ? $plugin['categories'] : [ 'marketing' ];

			if ( ! PluginsHelper::is_plugin_installed( $plugin['plugin'] ) && in_array( $category, $plugin_categories, true ) ) {
				$valid_plugins[] = $plugin;
			}
		}

		return rest_ensure_response( array_slice( $valid_plugins, 0, $per_page ) );
	}

	/**
	 * Return installed marketing extensions data.
	 *
	 * @param \WP_REST_Request $request Request data.
	 *
	 * @return \WP_Error|\WP_REST_Response
	 */
	public function get_knowledge_base_posts( $request ) {
		$category = $request->get_param( 'category' );
		return rest_ensure_response( MarketingFeature::get_instance()->get_knowledge_base_posts( $category ) );
	}
}
SettingOptions.php000064400000001557152333372110010256 0ustar00<?php
/**
 * REST API Setting Options Controller
 *
 * Handles requests to /settings/{option}
 */

namespace Automattic\WooCommerce\Admin\API;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache;

/**
 * Setting Options controller.
 *
 * @internal
 * @extends WC_REST_Setting_Options_Controller
 */
class SettingOptions extends \WC_REST_Setting_Options_Controller {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Invalidates API cache when updating settings options.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return array Of WP_Error or WP_REST_Response.
	 */
	public function batch_items( $request ) {
		// Invalidate the API cache.
		ReportsCache::invalidate();

		// Process the request.
		return parent::batch_items( $request );
	}
}
Experiments.php000064400000003510152333372110007557 0ustar00<?php
/**
 * REST API Experiment Controller
 *
 * Handles requests to /experiment
 */

namespace Automattic\WooCommerce\Admin\API;

defined( 'ABSPATH' ) || exit;

/**
 * Data controller.
 *
 * @extends WC_REST_Data_Controller
 */
class Experiments extends \WC_REST_Data_Controller {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-admin';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'experiments';

	/**
	 * Register routes.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/assignment',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_assignment' ),
					'permission_callback' => array( $this, 'get_item_permissions_check' ),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);
	}


	/**
	 * Forward the experiment request to WP.com and return the WP.com response.
	 *
	 * @param \WP_REST_Request $request Request data.
	 *
	 * @return \WP_Error|\WP_REST_Response
	 */
	public function get_assignment( $request ) {
		$args = $request->get_query_params();

		if ( ! isset( $args['experiment_name'] ) ) {
			return new \WP_Error(
				'woocommerce_rest_experiment_name_required',
				__( 'Sorry, experiment_name is required.', 'woocommerce' ),
				array( 'status' => 400 )
			);
		}

		unset( $args['rest_route'] );

		$abtest   = new \WooCommerce\Admin\Experimental_Abtest(
			$request->get_param( 'anon_id' ) ?? '',
			'woocommerce',
			true, // set consent to true here since frontend has checked it already.
			true  // set true to send request as auth user.
		);
		$response = $abtest->request_assignment( $args );
		if ( is_wp_error( $response ) ) {
			return $response;
		}

		return json_decode( $response['body'], true );
	}
}
Notes.php000064400000063140152333372110006351 0ustar00<?php
/**
 * REST API Admin Notes controller
 *
 * Handles requests to the admin notes endpoint.
 */

namespace Automattic\WooCommerce\Admin\API;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\Notes as NotesRepository;

/**
 * REST API Admin Notes controller class.
 *
 * @internal
 * @extends WC_REST_CRUD_Controller
 */
class Notes extends \WC_REST_CRUD_Controller {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'admin/notes';

	/**
	 * Allowed promo notes for experimental-activate-promo.
	 *
	 * @var array
	 */
	protected $allowed_promo_notes = array(
		'wcpay-promo-2022-us-incentive-20-off',
	);

	/**
	 * Register the routes for admin notes.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_items' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
					'args'                => $this->get_collection_params(),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>[\d-]+)',
			array(
				'args'   => array(
					'id' => array(
						'description' => __( 'Unique ID for the resource.', 'woocommerce' ),
						'type'        => 'integer',
					),
				),
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_item' ),
					'permission_callback' => array( $this, 'get_item_permissions_check' ),
				),
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'update_item' ),
					'permission_callback' => array( $this, 'update_items_permissions_check' ),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/delete/(?P<id>[\d-]+)',
			array(
				array(
					'methods'             => \WP_REST_Server::DELETABLE,
					'callback'            => array( $this, 'delete_item' ),
					'permission_callback' => array( $this, 'update_items_permissions_check' ),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/delete/all',
			array(
				array(
					'methods'             => \WP_REST_Server::DELETABLE,
					'callback'            => array( $this, 'delete_all_items' ),
					'permission_callback' => array( $this, 'update_items_permissions_check' ),
					'args'                => array(
						'status' => array(
							'description'       => __( 'Status of note.', 'woocommerce' ),
							'type'              => 'array',
							'sanitize_callback' => 'wp_parse_slug_list',
							'validate_callback' => 'rest_validate_request_arg',
							'items'             => array(
								'enum' => Note::get_allowed_statuses(),
								'type' => 'string',
							),
						),
					),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/tracker/(?P<note_id>[\d-]+)/user/(?P<user_id>[\d-]+)',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'track_opened_email' ),
					'permission_callback' => '__return_true',
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/update',
			array(
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'batch_update_items' ),
					'permission_callback' => array( $this, 'update_items_permissions_check' ),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/experimental-activate-promo/(?P<promo_note_name>[\w-]+)',
			array(
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'activate_promo_note' ),
					'permission_callback' => array( $this, 'update_items_permissions_check' ),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);
	}

	/**
	 * Get a single note.
	 *
	 * @param WP_REST_Request $request Request data.
	 * @return WP_REST_Response|WP_Error
	 */
	public function get_item( $request ) {
		$note = NotesRepository::get_note( $request->get_param( 'id' ) );

		if ( ! $note ) {
			return new \WP_Error(
				'woocommerce_note_invalid_id',
				__( 'Sorry, there is no resource with that ID.', 'woocommerce' ),
				array( 'status' => 404 )
			);
		}

		if ( is_wp_error( $note ) ) {
			return $note;
		}

		$data = $this->prepare_note_data_for_response( $note, $request );

		return rest_ensure_response( $data );
	}

	/**
	 * Get all notes.
	 *
	 * @param WP_REST_Request $request Request data.
	 * @return WP_REST_Response
	 */
	public function get_items( $request ) {
		$query_args = $this->prepare_objects_query( $request );

		$notes = NotesRepository::get_notes( 'edit', $query_args );

		$data = array();
		foreach ( (array) $notes as $note_obj ) {
			$note   = $this->prepare_item_for_response( $note_obj, $request );
			$note   = $this->prepare_response_for_collection( $note );
			$data[] = $note;
		}

		$response = rest_ensure_response( $data );
		$response->header( 'X-WP-Total', count( $data ) );

		return $response;
	}

	/**
	 * Checks if user is in tasklist experiment.
	 *
	 * @return bool Whether remote inbox notifications are enabled.
	 */
	private function is_tasklist_experiment_assigned_treatment() {
		$anon_id        = isset( $_COOKIE['tk_ai'] ) ? sanitize_text_field( wp_unslash( $_COOKIE['tk_ai'] ) ) : '';
		$allow_tracking = 'yes' === get_option( 'woocommerce_allow_tracking' );
		$abtest         = new \WooCommerce\Admin\Experimental_Abtest(
			$anon_id,
			'woocommerce',
			$allow_tracking
		);

		$date = new \DateTime();
		$date->setTimeZone( new \DateTimeZone( 'UTC' ) );

		$experiment_name = sprintf(
			'woocommerce_tasklist_progression_headercard_%s_%s',
			$date->format( 'Y' ),
			$date->format( 'm' )
		);

		$experiment_name_2col = sprintf(
			'woocommerce_tasklist_progression_headercard_2col_%s_%s',
			$date->format( 'Y' ),
			$date->format( 'm' )
		);

		return $abtest->get_variation( $experiment_name ) === 'treatment' ||
			$abtest->get_variation( $experiment_name_2col ) === 'treatment';
	}

	/**
	 * Prepare objects query.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return array
	 */
	protected function prepare_objects_query( $request ) {
		$args               = array();
		$args['order']      = $request['order'];
		$args['orderby']    = $request['orderby'];
		$args['per_page']   = $request['per_page'];
		$args['page']       = $request['page'];
		$args['type']       = isset( $request['type'] ) ? $request['type'] : array();
		$args['status']     = isset( $request['status'] ) ? $request['status'] : array();
		$args['source']     = isset( $request['source'] ) ? $request['source'] : array();
		$args['is_deleted'] = 0;

		if ( 'date' === $args['orderby'] ) {
			$args['orderby'] = 'date_created';
		}

		/**
		 * Filter the query arguments for a request.
		 *
		 * Enables adding extra arguments or setting defaults for a post
		 * collection request.
		 *
		 * @param array           $args    Key value array of query var to query value.
		 * @param WP_REST_Request $request The request used.
		 * @since 3.9.0
		 */
		$args = apply_filters( 'woocommerce_rest_notes_object_query', $args, $request );

		return $args;
	}

	/**
	 * Check whether a given request has permission to read a single note.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|boolean
	 */
	public function get_item_permissions_check( $request ) {
		if ( ! wc_rest_check_manager_permissions( 'system_status', 'read' ) ) {
			return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
		}

		return true;
	}

	/**
	 * Check whether a given request has permission to read notes.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|boolean
	 */
	public function get_items_permissions_check( $request ) {
		if ( ! wc_rest_check_manager_permissions( 'system_status', 'read' ) ) {
			return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
		}

		return true;
	}

	/**
	 * Update a single note.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Request|WP_Error
	 */
	public function update_item( $request ) {
		$note = NotesRepository::get_note( $request->get_param( 'id' ) );

		if ( ! $note ) {
			return new \WP_Error(
				'woocommerce_note_invalid_id',
				__( 'Sorry, there is no resource with that ID.', 'woocommerce' ),
				array( 'status' => 404 )
			);
		}

		NotesRepository::update_note( $note, $this->get_requested_updates( $request ) );
		return $this->get_item( $request );
	}

	/**
	 * Delete a single note.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Request|WP_Error
	 */
	public function delete_item( $request ) {
		$note = NotesRepository::get_note( $request->get_param( 'id' ) );

		if ( ! $note ) {
			return new \WP_Error(
				'woocommerce_note_invalid_id',
				__( 'Sorry, there is no note with that ID.', 'woocommerce' ),
				array( 'status' => 404 )
			);
		}

		NotesRepository::delete_note( $note );
		$data = $this->prepare_note_data_for_response( $note, $request );
		return rest_ensure_response( $data );
	}

	/**
	 * Delete all notes.
	 *
	 * @param WP_REST_Request $request Request object.
	 * @return WP_REST_Request|WP_Error
	 */
	public function delete_all_items( $request ) {
		$args = array();
		if ( isset( $request['status'] ) ) {
			$args['status'] = $request['status'];
		}
		$notes = NotesRepository::delete_all_notes( $args );
		$data  = array();
		foreach ( (array) $notes as $note_obj ) {
			$data[] = $this->prepare_note_data_for_response( $note_obj, $request );
		}

		$response = rest_ensure_response( $data );
		$response->header( 'X-WP-Total', NotesRepository::get_notes_count( array( 'info', 'warning' ), array() ) );
		return $response;
	}

	/**
	 * Prepare note data.
	 *
	 * @param Note            $note     Note data.
	 * @param WP_REST_Request $request  Request object.
	 *
	 * @return WP_REST_Response $response Response data.
	 */
	public function prepare_note_data_for_response( $note, $request ) {
		$note = $note->get_data();
		$note = $this->prepare_item_for_response( $note, $request );
		return $this->prepare_response_for_collection( $note );
	}

	/**
	 * Prepare an array with the the requested updates.
	 *
	 * @param WP_REST_Request $request  Request object.
	 * @return array A list of the requested updates values.
	 */
	protected function get_requested_updates( $request ) {
		$requested_updates = array();
		if ( ! is_null( $request->get_param( 'status' ) ) ) {
			$requested_updates['status'] = $request->get_param( 'status' );
		}

		if ( ! is_null( $request->get_param( 'date_reminder' ) ) ) {
			$requested_updates['date_reminder'] = $request->get_param( 'date_reminder' );
		}

		if ( ! is_null( $request->get_param( 'is_deleted' ) ) ) {
			$requested_updates['is_deleted'] = $request->get_param( 'is_deleted' );
		}

		if ( ! is_null( $request->get_param( 'is_read' ) ) ) {
			$requested_updates['is_read'] = $request->get_param( 'is_read' );
		}

		return $requested_updates;
	}

	/**
	 * Batch update a set of notes.
	 *
	 * @param WP_REST_Request $request Request object.
	 * @return WP_REST_Request|WP_Error
	 */
	public function batch_update_items( $request ) {
		$data     = array();
		$note_ids = $request->get_param( 'noteIds' );

		if ( ! isset( $note_ids ) || ! is_array( $note_ids ) ) {
			return new \WP_Error(
				'woocommerce_note_invalid_ids',
				__( 'Please provide an array of IDs through the noteIds param.', 'woocommerce' ),
				array( 'status' => 422 )
			);
		}

		foreach ( (array) $note_ids as $note_id ) {
			$note = NotesRepository::get_note( (int) $note_id );
			if ( $note ) {
				NotesRepository::update_note( $note, $this->get_requested_updates( $request ) );
				$data[] = $this->prepare_note_data_for_response( $note, $request );
			}
		}

		$response = rest_ensure_response( $data );
		$response->header( 'X-WP-Total', NotesRepository::get_notes_count( array( 'info', 'warning' ), array() ) );
		return $response;
	}

	/**
	 * Activate a promo note, create if not exist.
	 *
	 * @param WP_REST_Request $request Request object.
	 * @return WP_REST_Request|WP_Error
	 */
	public function activate_promo_note( $request ) {
		$promo_note_name = $request->get_param( 'promo_note_name' );

		if ( ! in_array( $promo_note_name, $this->allowed_promo_notes, true ) ) {
			return new \WP_Error(
				'woocommerce_note_invalid_promo_note_name',
				__( 'Please provide a valid promo note name.', 'woocommerce' ),
				array( 'status' => 422 )
			);
		}

		$data_store = NotesRepository::load_data_store();
		$note_ids   = $data_store->get_notes_with_name( $promo_note_name );

		if ( empty( $note_ids ) ) {
			// Promo note doesn't exist, this could happen in cases where
			// user might have disabled RemoteInboxNotications via disabling
			// marketing suggestions. Thus we'd have to manually add the note.
			$note = new Note();
			$note->set_name( $promo_note_name );
			$note->set_status( Note::E_WC_ADMIN_NOTE_ACTIONED );
			$data_store->create( $note );
		} else {
			$note = NotesRepository::get_note( $note_ids[0] );
			NotesRepository::update_note(
				$note,
				[
					'status' => Note::E_WC_ADMIN_NOTE_ACTIONED,
				]
			);
		}

		return rest_ensure_response(
			array(
				'success' => true,
			)
		);
	}

	/**
	 * Makes sure the current user has access to WRITE the settings APIs.
	 *
	 * @param WP_REST_Request $request Full data about the request.
	 * @return WP_Error|bool
	 */
	public function update_items_permissions_check( $request ) {
		if ( ! wc_rest_check_manager_permissions( 'settings', 'edit' ) ) {
			return new \WP_Error( 'woocommerce_rest_cannot_edit', __( 'Sorry, you cannot edit this resource.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
		}
		return true;
	}

	/**
	 * Prepare a path or query for serialization to the client.
	 *
	 * @param string $query The query, path, or URL to transform.
	 * @return string A fully formed URL.
	 */
	public function prepare_query_for_response( $query ) {
		if ( empty( $query ) ) {
			return $query;
		}
		if ( 'https://' === substr( $query, 0, 8 ) ) {
			return $query;
		}
		if ( 'http://' === substr( $query, 0, 7 ) ) {
			return $query;
		}
		if ( '?' === substr( $query, 0, 1 ) ) {
			return admin_url( 'admin.php' . $query );
		}

		return admin_url( $query );
	}

	/**
	 * Maybe add a nonce to a URL.
	 *
	 * @link https://codex.wordpress.org/WordPress_Nonces
	 *
	 * @param string $url The URL needing a nonce.
	 * @param string $action The nonce action.
	 * @param string $name The nonce anme.
	 * @return string A fully formed URL.
	 */
	private function maybe_add_nonce_to_url( string $url, string $action = '', string $name = '' ) : string {
		if ( empty( $action ) ) {
			return $url;
		}

		if ( empty( $name ) ) {
			// Default paramater name.
			$name = '_wpnonce';
		}

		return add_query_arg( $name, wp_create_nonce( $action ), $url );
	}

	/**
	 * Prepare a note object for serialization.
	 *
	 * @param array           $data Note data.
	 * @param WP_REST_Request $request Request object.
	 * @return WP_REST_Response $response Response data.
	 */
	public function prepare_item_for_response( $data, $request ) {
		$context                   = ! empty( $request['context'] ) ? $request['context'] : 'view';
		$data                      = $this->add_additional_fields_to_object( $data, $request );
		$data['date_created_gmt']  = wc_rest_prepare_date_response( $data['date_created'] );
		$data['date_created']      = wc_rest_prepare_date_response( $data['date_created'], false );
		$data['date_reminder_gmt'] = wc_rest_prepare_date_response( $data['date_reminder'] );
		$data['date_reminder']     = wc_rest_prepare_date_response( $data['date_reminder'], false );
		$data['title']             = stripslashes( $data['title'] );
		$data['content']           = stripslashes( $data['content'] );
		$data['is_snoozable']      = (bool) $data['is_snoozable'];
		$data['is_deleted']        = (bool) $data['is_deleted'];
		$data['is_read']           = (bool) $data['is_read'];
		foreach ( (array) $data['actions'] as $key => $value ) {
			$data['actions'][ $key ]->label  = stripslashes( $data['actions'][ $key ]->label );
			$data['actions'][ $key ]->url    = $this->maybe_add_nonce_to_url(
				$this->prepare_query_for_response( $data['actions'][ $key ]->query ),
				(string) $data['actions'][ $key ]->nonce_action,
				(string) $data['actions'][ $key ]->nonce_name
			);
			$data['actions'][ $key ]->status = stripslashes( $data['actions'][ $key ]->status );
		}
		$data = $this->filter_response_by_context( $data, $context );

		// Wrap the data in a response object.
		$response = rest_ensure_response( $data );
		$response->add_links(
			array(
				'self'       => array(
					'href' => rest_url( sprintf( '/%s/%s/%d', $this->namespace, $this->rest_base, $data['id'] ) ),
				),
				'collection' => array(
					'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ),
				),
			)
		);
		/**
		 * Filter a note returned from the API.
		 *
		 * Allows modification of the note data right before it is returned.
		 *
		 * @param WP_REST_Response $response The response object.
		 * @param array            $data The original note.
		 * @param WP_REST_Request  $request  Request used to generate the response.
		 * @since 3.9.0
		 */
		return apply_filters( 'woocommerce_rest_prepare_note', $response, $data, $request );
	}


	/**
	 * Track opened emails.
	 *
	 * @param WP_REST_Request $request Request object.
	 */
	public function track_opened_email( $request ) {
		$note = NotesRepository::get_note( $request->get_param( 'note_id' ) );
		if ( ! $note ) {
			return;
		}

		NotesRepository::record_tracks_event_with_user( $request->get_param( 'user_id' ), 'email_note_opened', array( 'note_name' => $note->get_name() ) );
	}

	/**
	 * 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['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(
				'note_id',
				'date',
				'type',
				'title',
				'status',
			),
			'validate_callback' => 'rest_validate_request_arg',
		);
		$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['type']     = array(
			'description'       => __( 'Type of note.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_slug_list',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'enum' => Note::get_allowed_types(),
				'type' => 'string',
			),
		);
		$params['status']   = array(
			'description'       => __( 'Status of note.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_slug_list',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'enum' => Note::get_allowed_statuses(),
				'type' => 'string',
			),
		);
		$params['source']   = array(
			'description'       => __( 'Source of note.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_list',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'type' => 'string',
			),
		);
		return $params;
	}

	/**
	 * Get the note's schema, conforming to JSON Schema.
	 *
	 * @return array
	 */
	public function get_item_schema() {
		$schema = array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => 'note',
			'type'       => 'object',
			'properties' => array(
				'id'                => array(
					'description' => __( 'ID of the note record.', 'woocommerce' ),
					'type'        => 'integer',
					'context'     => array( 'view' ),
					'readonly'    => true,
				),
				'name'              => array(
					'description' => __( 'Name of the note.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'type'              => array(
					'description' => __( 'The type of the note (e.g. error, warning, etc.).', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'locale'            => array(
					'description' => __( 'Locale used for the note title and content.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'title'             => array(
					'description' => __( 'Title of the note.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'content'           => array(
					'description' => __( 'Content of the note.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'content_data'      => array(
					'description' => __( 'Content data for the note. JSON string. Available for re-localization.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'status'            => array(
					'description' => __( 'The status of the note (e.g. unactioned, actioned).', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
				),
				'source'            => array(
					'description' => __( 'Source of the note.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'date_created'      => array(
					'description' => __( 'Date the note was created.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'date_created_gmt'  => array(
					'description' => __( 'Date the note was created (GMT).', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'date_reminder'     => array(
					'description' => __( 'Date after which the user should be reminded of the note, if any.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true, // @todo Allow date_reminder to be updated.
				),
				'date_reminder_gmt' => array(
					'description' => __( 'Date after which the user should be reminded of the note, if any (GMT).', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'is_snoozable'      => array(
					'description' => __( 'Whether or not a user can request to be reminded about the note.', 'woocommerce' ),
					'type'        => 'boolean',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'actions'           => array(
					'description' => __( 'An array of actions, if any, for the note.', 'woocommerce' ),
					'type'        => 'array',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'layout'            => array(
					'description' => __( 'The layout of the note (e.g. banner, thumbnail, plain).', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'image'             => array(
					'description' => __( 'The image of the note, if any.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'is_deleted'        => array(
					'description' => __( 'Registers whether the note is deleted or not', 'woocommerce' ),
					'type'        => 'boolean',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'is_read'           => array(
					'description' => __( 'Registers whether the note is read or not', 'woocommerce' ),
					'type'        => 'boolean',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
			),
		);
		return $this->add_additional_fields_schema( $schema );
	}
}
Features.php000064400000003314152333372110007034 0ustar00<?php
/**
 * REST API Features Controller
 *
 * Handles requests to /features
 */

namespace Automattic\WooCommerce\Admin\API;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Features\Features as FeaturesClass;

/**
 * Features Controller.
 *
 * @internal
 * @extends WC_REST_Data_Controller
 */
class Features extends \WC_REST_Data_Controller {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-admin';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'features';

	/**
	 * Register routes.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_features' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);
	}

	/**
	 * Check whether a given request has permission to read onboarding profile data.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|boolean
	 */
	public function get_items_permissions_check( $request ) {
		if ( ! wc_rest_check_manager_permissions( 'settings', 'read' ) ) {
			return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
		}

		return true;
	}

	/**
	 * Return available payment methods.
	 *
	 * @param \WP_REST_Request $request Request data.
	 *
	 * @return \WP_Error|\WP_REST_Response
	 */
	public function get_features( $request ) {
		return FeaturesClass::get_available_features();
	}

}
NoteActions.php000064400000004622152333372110007507 0ustar00<?php
/**
 * REST API Admin Note Action controller
 *
 * Handles requests to the admin note action endpoint.
 */

namespace Automattic\WooCommerce\Admin\API;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Notes\Note;
use \Automattic\WooCommerce\Admin\Notes\Notes as NotesFactory;

/**
 * REST API Admin Note Action controller class.
 *
 * @internal
 * @extends WC_REST_CRUD_Controller
 */
class NoteActions extends Notes {

	/**
	 * Register the routes for admin notes.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<note_id>[\d-]+)/action/(?P<action_id>[\d-]+)',
			array(
				'args'   => array(
					'note_id'   => array(
						'description' => __( 'Unique ID for the Note.', 'woocommerce' ),
						'type'        => 'integer',
					),
					'action_id' => array(
						'description' => __( 'Unique ID for the Note Action.', 'woocommerce' ),
						'type'        => 'integer',
					),
				),
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'trigger_note_action' ),
					// @todo - double check these permissions for taking note actions.
					'permission_callback' => array( $this, 'get_item_permissions_check' ),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);
	}

	/**
	 * Trigger a note action.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Request|WP_Error
	 */
	public function trigger_note_action( $request ) {
		$note = NotesFactory::get_note( $request->get_param( 'note_id' ) );

		if ( ! $note ) {
			return new \WP_Error(
				'woocommerce_note_invalid_id',
				__( 'Sorry, there is no resource with that ID.', 'woocommerce' ),
				array( 'status' => 404 )
			);
		}

		$note->set_is_read( true );
		$note->save();

		$triggered_action = NotesFactory::get_action_by_id( $note, $request->get_param( 'action_id' ) );

		if ( ! $triggered_action ) {
			return new \WP_Error(
				'woocommerce_note_action_invalid_id',
				__( 'Sorry, there is no resource with that ID.', 'woocommerce' ),
				array( 'status' => 404 )
			);
		}

		$triggered_note = NotesFactory::trigger_note_action( $note, $triggered_action );

		$data = $triggered_note->get_data();
		$data = $this->prepare_item_for_response( $data, $request );
		$data = $this->prepare_response_for_collection( $data );

		return rest_ensure_response( $data );
	}
}
Plugins.php000064400000047603152333372110006710 0ustar00<?php
/**
 * REST API Plugins Controller
 *
 * Handles requests to install and activate depedent plugins.
 */

namespace Automattic\WooCommerce\Admin\API;

use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
use Automattic\WooCommerce\Admin\PaymentMethodSuggestionsDataSourcePoller;
use Automattic\WooCommerce\Admin\PluginsHelper;
use \Automattic\WooCommerce\Internal\Admin\Notes\InstallJPAndWCSPlugins;

defined( 'ABSPATH' ) || exit;

/**
 * Plugins Controller.
 *
 * @internal
 * @extends WC_REST_Data_Controller
 */
class Plugins extends \WC_REST_Data_Controller {
	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-admin';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'plugins';

	/**
	 * Register routes.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/install',
			array(
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'install_plugins' ),
					'permission_callback' => array( $this, 'update_item_permissions_check' ),
				),
				'schema' => array( $this, 'get_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/install/status',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_installation_status' ),
					'permission_callback' => array( $this, 'update_item_permissions_check' ),
				),
				'schema' => array( $this, 'get_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/install/status/(?P<job_id>[a-z0-9_\-]+)',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_job_installation_status' ),
					'permission_callback' => array( $this, 'update_item_permissions_check' ),
				),
				'schema' => array( $this, 'get_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/active',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'active_plugins' ),
					'permission_callback' => array( $this, 'get_item_permissions_check' ),
				),
				'schema' => array( $this, 'get_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/installed',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'installed_plugins' ),
					'permission_callback' => array( $this, 'get_item_permissions_check' ),
				),
				'schema' => array( $this, 'get_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/activate',
			array(
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'activate_plugins' ),
					'permission_callback' => array( $this, 'update_item_permissions_check' ),
				),
				'schema' => array( $this, 'get_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/activate/status',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_activation_status' ),
					'permission_callback' => array( $this, 'update_item_permissions_check' ),
				),
				'schema' => array( $this, 'get_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/activate/status/(?P<job_id>[a-z0-9_\-]+)',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_job_activation_status' ),
					'permission_callback' => array( $this, 'update_item_permissions_check' ),
				),
				'schema' => array( $this, 'get_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/connect-jetpack',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'connect_jetpack' ),
					'permission_callback' => array( $this, 'update_item_permissions_check' ),
				),
				'schema' => array( $this, 'get_connect_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/request-wccom-connect',
			array(
				array(
					'methods'             => 'POST',
					'callback'            => array( $this, 'request_wccom_connect' ),
					'permission_callback' => array( $this, 'update_item_permissions_check' ),
				),
				'schema' => array( $this, 'get_connect_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/finish-wccom-connect',
			array(
				array(
					'methods'             => 'POST',
					'callback'            => array( $this, 'finish_wccom_connect' ),
					'permission_callback' => array( $this, 'update_item_permissions_check' ),
				),
				'schema' => array( $this, 'get_connect_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/connect-wcpay',
			array(
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'connect_wcpay' ),
					'permission_callback' => array( $this, 'update_item_permissions_check' ),
				),
				'schema' => array( $this, 'get_connect_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/connect-square',
			array(
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'connect_square' ),
					'permission_callback' => array( $this, 'update_item_permissions_check' ),
				),
				'schema' => array( $this, 'get_connect_schema' ),
			)
		);
	}

	/**
	 * Check if a given request has access to manage plugins.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|boolean
	 */
	public function update_item_permissions_check( $request ) {
		if ( ! current_user_can( 'install_plugins' ) ) {
			return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you cannot manage plugins.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
		}
		return true;
	}

	/**
	 * Install the requested plugin.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|array Plugin Status
	 */
	public function install_plugin( $request ) {
		wc_deprecated_function( 'install_plugin', '4.3', '\Automattic\WooCommerce\Admin\API\Plugins()->install_plugins' );
		// This method expects a `plugin` argument to be sent, install plugins requires plugins.
		$request['plugins'] = $request['plugin'];
		return self::install_plugins( $request );
	}

	/**
	 * Installs the requested plugins.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|array Plugin Status
	 */
	public function install_plugins( $request ) {
		$plugins = explode( ',', $request['plugins'] );

		if ( empty( $request['plugins'] ) || ! is_array( $plugins ) ) {
			return new \WP_Error( 'woocommerce_rest_invalid_plugins', __( 'Plugins must be a non-empty array.', 'woocommerce' ), 404 );
		}

		if ( isset( $request['async'] ) && $request['async'] ) {
			$job_id = PluginsHelper::schedule_install_plugins( $plugins );

			return array(
				'data'    => array(
					'job_id'  => $job_id,
					'plugins' => $plugins,
				),
				'message' => __( 'Plugin installation has been scheduled.', 'woocommerce' ),
			);
		}

		$data = PluginsHelper::install_plugins( $plugins );

		return array(
			'data'    => array(
				'installed'    => $data['installed'],
				'results'      => $data['results'],
				'install_time' => $data['time'],
			),
			'errors'  => $data['errors'],
			'success' => count( $data['errors']->errors ) === 0,
			'message' => count( $data['errors']->errors ) === 0
				? __( 'Plugins were successfully installed.', 'woocommerce' )
				: __( 'There was a problem installing some of the requested plugins.', 'woocommerce' ),
		);
	}

	/**
	 * Returns a list of recently scheduled installation jobs.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return array Jobs.
	 */
	public function get_installation_status( $request ) {
		return PluginsHelper::get_installation_status();
	}

	/**
	 * Returns a list of recently scheduled installation jobs.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return array Job.
	 */
	public function get_job_installation_status( $request ) {
		$job_id = $request->get_param( 'job_id' );
		$jobs   = PluginsHelper::get_installation_status( $job_id );
		return reset( $jobs );
	}

	/**
	 * Returns a list of active plugins in API format.
	 *
	 * @return array Active plugins
	 */
	public static function active_plugins() {
		return( array(
			'plugins' => array_values( PluginsHelper::get_active_plugin_slugs() ),
		) );
	}
	/**
	 * Returns a list of active plugins.
	 *
	 * @internal
	 * @return array Active plugins
	 */
	public static function get_active_plugins() {
		$data = self::active_plugins();
		return $data['plugins'];
	}

	/**
	 * Returns a list of installed plugins.
	 *
	 * @return array Installed plugins
	 */
	public function installed_plugins() {
		return( array(
			'plugins' => PluginsHelper::get_installed_plugin_slugs(),
		) );
	}

	/**
	 * Activate the requested plugin.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|array Plugin Status
	 */
	public function activate_plugins( $request ) {
		$plugins = explode( ',', $request['plugins'] );

		if ( empty( $request['plugins'] ) || ! is_array( $plugins ) ) {
			return new \WP_Error( 'woocommerce_rest_invalid_plugins', __( 'Plugins must be a non-empty array.', 'woocommerce' ), 404 );
		}

		if ( isset( $request['async'] ) && $request['async'] ) {
			$job_id = PluginsHelper::schedule_activate_plugins( $plugins );

			return array(
				'data'    => array(
					'job_id'  => $job_id,
					'plugins' => $plugins,
				),
				'message' => __( 'Plugin activation has been scheduled.', 'woocommerce' ),
			);
		}

		$data = PluginsHelper::activate_plugins( $plugins );

		return( array(
			'data'    => array(
				'activated' => $data['activated'],
				'active'    => $data['active'],
			),
			'errors'  => $data['errors'],
			'success' => count( $data['errors']->errors ) === 0,
			'message' => count( $data['errors']->errors ) === 0
				? __( 'Plugins were successfully activated.', 'woocommerce' )
				: __( 'There was a problem activating some of the requested plugins.', 'woocommerce' ),
		) );
	}

	/**
	 * Returns a list of recently scheduled activation jobs.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return array Job.
	 */
	public function get_activation_status( $request ) {
		return PluginsHelper::get_activation_status();
	}

	/**
	 * Returns a list of recently scheduled activation jobs.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return array Jobs.
	 */
	public function get_job_activation_status( $request ) {
		$job_id = $request->get_param( 'job_id' );
		$jobs   = PluginsHelper::get_activation_status( $job_id );
		return reset( $jobs );
	}

	/**
	 * Generates a Jetpack Connect URL.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|array Connection URL for Jetpack
	 */
	public function connect_jetpack( $request ) {
		if ( ! class_exists( '\Jetpack' ) ) {
			return new \WP_Error( 'woocommerce_rest_jetpack_not_active', __( 'Jetpack is not installed or active.', 'woocommerce' ), 404 );
		}

		$redirect_url = apply_filters( 'woocommerce_admin_onboarding_jetpack_connect_redirect_url', esc_url_raw( $request['redirect_url'] ) );
		$connect_url  = \Jetpack::init()->build_connect_url( true, $redirect_url, 'woocommerce-onboarding' );

		$calypso_env = defined( 'WOOCOMMERCE_CALYPSO_ENVIRONMENT' ) && in_array( WOOCOMMERCE_CALYPSO_ENVIRONMENT, array( 'development', 'wpcalypso', 'horizon', 'stage' ), true ) ? WOOCOMMERCE_CALYPSO_ENVIRONMENT : 'production';
		$connect_url = add_query_arg( array( 'calypso_env' => $calypso_env ), $connect_url );

		return( array(
			'slug'          => 'jetpack',
			'name'          => __( 'Jetpack', 'woocommerce' ),
			'connectAction' => $connect_url,
		) );
	}

	/**
	 *  Kicks off the WCCOM Connect process.
	 *
	 * @return WP_Error|array Connection URL for WooCommerce.com
	 */
	public function request_wccom_connect() {
		include_once WC_ABSPATH . 'includes/admin/helper/class-wc-helper-api.php';
		if ( ! class_exists( 'WC_Helper_API' ) ) {
			return new \WP_Error( 'woocommerce_rest_helper_not_active', __( 'There was an error loading the WooCommerce.com Helper API.', 'woocommerce' ), 404 );
		}

		$redirect_uri = wc_admin_url( '&task=connect&wccom-connected=1' );

		$request = \WC_Helper_API::post(
			'oauth/request_token',
			array(
				'body' => array(
					'home_url'     => home_url(),
					'redirect_uri' => $redirect_uri,
				),
			)
		);

		$code = wp_remote_retrieve_response_code( $request );
		if ( 200 !== $code ) {
			return new \WP_Error( 'woocommerce_rest_helper_connect', __( 'There was an error connecting to WooCommerce.com. Please try again.', 'woocommerce' ), 500 );
		}

		$secret = json_decode( wp_remote_retrieve_body( $request ) );
		if ( empty( $secret ) ) {
			return new \WP_Error( 'woocommerce_rest_helper_connect', __( 'There was an error connecting to WooCommerce.com. Please try again.', 'woocommerce' ), 500 );
		}

		do_action( 'woocommerce_helper_connect_start' );

		$connect_url = add_query_arg(
			array(
				'home_url'     => rawurlencode( home_url() ),
				'redirect_uri' => rawurlencode( $redirect_uri ),
				'secret'       => rawurlencode( $secret ),
				'wccom-from'   => 'onboarding',
			),
			\WC_Helper_API::url( 'oauth/authorize' )
		);

		if ( defined( 'WOOCOMMERCE_CALYPSO_ENVIRONMENT' ) && in_array( WOOCOMMERCE_CALYPSO_ENVIRONMENT, array( 'development', 'wpcalypso', 'horizon', 'stage' ), true ) ) {
			$connect_url = add_query_arg(
				array(
					'calypso_env' => WOOCOMMERCE_CALYPSO_ENVIRONMENT,
				),
				$connect_url
			);
		}

		return( array(
			'connectAction' => $connect_url,
		) );
	}

	/**
	 * Finishes connecting to WooCommerce.com.
	 *
	 * @param  object $rest_request Request details.
	 * @return WP_Error|array Contains success status.
	 */
	public function finish_wccom_connect( $rest_request ) {
		include_once WC_ABSPATH . 'includes/admin/helper/class-wc-helper.php';
		include_once WC_ABSPATH . 'includes/admin/helper/class-wc-helper-api.php';
		include_once WC_ABSPATH . 'includes/admin/helper/class-wc-helper-updater.php';
		include_once WC_ABSPATH . 'includes/admin/helper/class-wc-helper-options.php';
		if ( ! class_exists( 'WC_Helper_API' ) ) {
			return new \WP_Error( 'woocommerce_rest_helper_not_active', __( 'There was an error loading the WooCommerce.com Helper API.', 'woocommerce' ), 404 );
		}

		// Obtain an access token.
		$request = \WC_Helper_API::post(
			'oauth/access_token',
			array(
				'body' => array(
					'request_token' => wp_unslash( $rest_request['request_token'] ), // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
					'home_url'      => home_url(),
				),
			)
		);

		$code = wp_remote_retrieve_response_code( $request );
		if ( 200 !== $code ) {
			return new \WP_Error( 'woocommerce_rest_helper_connect', __( 'There was an error connecting to WooCommerce.com. Please try again.', 'woocommerce' ), 500 );
		}

		$access_token = json_decode( wp_remote_retrieve_body( $request ), true );
		if ( ! $access_token ) {
			return new \WP_Error( 'woocommerce_rest_helper_connect', __( 'There was an error connecting to WooCommerce.com. Please try again.', 'woocommerce' ), 500 );
		}

		\WC_Helper_Options::update(
			'auth',
			array(
				'access_token'        => $access_token['access_token'],
				'access_token_secret' => $access_token['access_token_secret'],
				'site_id'             => $access_token['site_id'],
				'user_id'             => get_current_user_id(),
				'updated'             => time(),
			)
		);

		if ( ! \WC_Helper::_flush_authentication_cache() ) {
			\WC_Helper_Options::update( 'auth', array() );
			return new \WP_Error( 'woocommerce_rest_helper_connect', __( 'There was an error connecting to WooCommerce.com. Please try again.', 'woocommerce' ), 500 );
		}

		delete_transient( '_woocommerce_helper_subscriptions' );
		\WC_Helper_Updater::flush_updates_cache();

		do_action( 'woocommerce_helper_connected' );

		return array(
			'success' => true,
		);
	}


	/**
	 * Returns a URL that can be used to connect to Square.
	 *
	 * @return WP_Error|array Connect URL.
	 */
	public function connect_square() {
		if ( ! class_exists( '\WooCommerce\Square\Handlers\Connection' ) ) {
			return new \WP_Error( 'woocommerce_rest_helper_connect', __( 'There was an error connecting to Square.', 'woocommerce' ), 500 );
		}

		if ( 'US' === WC()->countries->get_base_country() ) {
			$profile = get_option( OnboardingProfile::DATA_OPTION, array() );
			if ( ! empty( $profile['industry'] ) ) {
				$has_cbd_industry = in_array( 'cbd-other-hemp-derived-products', array_column( $profile['industry'], 'slug' ), true );
			}
		}

		if ( $has_cbd_industry ) {
			$url = 'https://squareup.com/t/f_partnerships/d_referrals/p_woocommerce/c_general/o_none/l_us/dt_alldevice/pr_payments/?route=/solutions/cbd';
		} else {
			$url = \WooCommerce\Square\Handlers\Connection::CONNECT_URL_PRODUCTION;
		}

		$redirect_url = wp_nonce_url( wc_admin_url( '&task=payments&method=square&square-connect-finish=1' ), 'wc_square_connected' );
		$args         = array(
			'redirect' => rawurlencode( rawurlencode( $redirect_url ) ),
			'scopes'   => implode(
				',',
				array(
					'MERCHANT_PROFILE_READ',
					'PAYMENTS_READ',
					'PAYMENTS_WRITE',
					'ORDERS_READ',
					'ORDERS_WRITE',
					'CUSTOMERS_READ',
					'CUSTOMERS_WRITE',
					'SETTLEMENTS_READ',
					'ITEMS_READ',
					'ITEMS_WRITE',
					'INVENTORY_READ',
					'INVENTORY_WRITE',
				)
			),
		);

		$connect_url = add_query_arg( $args, $url );

		return( array(
			'connectUrl' => $connect_url,
		) );
	}

	/**
	 * Returns a URL that can be used to by WCPay to verify business details with Stripe.
	 *
	 * @return WP_Error|array Connect URL.
	 */
	public function connect_wcpay() {
		if ( ! class_exists( 'WC_Payments_Account' ) ) {
			return new \WP_Error( 'woocommerce_rest_helper_connect', __( 'There was an error communicating with the WooCommerce Payments plugin.', 'woocommerce' ), 500 );
		}

		$connect_url = add_query_arg(
			array(
				'wcpay-connect' => 'WCADMIN_PAYMENT_TASK',
				'_wpnonce'      => wp_create_nonce( 'wcpay-connect' ),
			),
			admin_url()
		);

		return( array(
			'connectUrl' => $connect_url,
		) );
	}

	/**
	 * Get the schema, conforming to JSON Schema.
	 *
	 * @return array
	 */
	public function get_item_schema() {
		$schema = array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => 'plugins',
			'type'       => 'object',
			'properties' => array(
				'slug'   => array(
					'description' => __( 'Plugin slug.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'name'   => array(
					'description' => __( 'Plugin name.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'status' => array(
					'description' => __( 'Plugin status.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
			),
		);

		return $this->add_additional_fields_schema( $schema );
	}

	/**
	 * Get the schema, conforming to JSON Schema.
	 *
	 * @return array
	 */
	public function get_connect_schema() {
		$schema = $this->get_item_schema();
		unset( $schema['properties']['status'] );
		$schema['properties']['connectAction'] = array(
			'description' => __( 'Action that should be completed to connect Jetpack.', 'woocommerce' ),
			'type'        => 'string',
			'context'     => array( 'view', 'edit' ),
			'readonly'    => true,
		);
		return $schema;
	}
}
Init.php000064400000017060152333372110006164 0ustar00<?php
/**
 * REST API bootstrap.
 */

namespace Automattic\WooCommerce\Admin\API;

use Automattic\WooCommerce\Admin\Features\Features;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Internal\Admin\Loader;

/**
 * Init class.
 *
 * @internal
 */
class Init {
	/**
	 * The single instance of the class.
	 *
	 * @var object
	 */
	protected static $instance = null;

	/**
	 * Get class instance.
	 *
	 * @return object Instance.
	 */
	final public static function instance() {
		if ( null === static::$instance ) {
			static::$instance = new static();
		}
		return static::$instance;
	}

	/**
	 * Boostrap REST API.
	 */
	public function __construct() {
		// Hook in data stores.
		add_filter( 'woocommerce_data_stores', array( __CLASS__, 'add_data_stores' ) );
		// REST API extensions init.
		add_action( 'rest_api_init', array( $this, 'rest_api_init' ) );

		// Add currency symbol to orders endpoint response.
		add_filter( 'woocommerce_rest_prepare_shop_order_object', array( __CLASS__, 'add_currency_symbol_to_order_response' ) );
	}

	/**
	 * Init REST API.
	 */
	public function rest_api_init() {
		$controllers = array(
			'Automattic\WooCommerce\Admin\API\Features',
			'Automattic\WooCommerce\Admin\API\Notes',
			'Automattic\WooCommerce\Admin\API\NoteActions',
			'Automattic\WooCommerce\Admin\API\Coupons',
			'Automattic\WooCommerce\Admin\API\Data',
			'Automattic\WooCommerce\Admin\API\DataCountries',
			'Automattic\WooCommerce\Admin\API\DataDownloadIPs',
			'Automattic\WooCommerce\Admin\API\Experiments',
			'Automattic\WooCommerce\Admin\API\Marketing',
			'Automattic\WooCommerce\Admin\API\MarketingOverview',
			'Automattic\WooCommerce\Admin\API\Options',
			'Automattic\WooCommerce\Admin\API\Orders',
			'Automattic\WooCommerce\Admin\API\PaymentGatewaySuggestions',
			'Automattic\WooCommerce\Admin\API\Products',
			'Automattic\WooCommerce\Admin\API\ProductAttributes',
			'Automattic\WooCommerce\Admin\API\ProductAttributeTerms',
			'Automattic\WooCommerce\Admin\API\ProductCategories',
			'Automattic\WooCommerce\Admin\API\ProductVariations',
			'Automattic\WooCommerce\Admin\API\ProductReviews',
			'Automattic\WooCommerce\Admin\API\ProductVariations',
			'Automattic\WooCommerce\Admin\API\ProductsLowInStock',
			'Automattic\WooCommerce\Admin\API\SettingOptions',
			'Automattic\WooCommerce\Admin\API\Themes',
			'Automattic\WooCommerce\Admin\API\Plugins',
			'Automattic\WooCommerce\Admin\API\OnboardingFreeExtensions',
			'Automattic\WooCommerce\Admin\API\OnboardingProductTypes',
			'Automattic\WooCommerce\Admin\API\OnboardingProfile',
			'Automattic\WooCommerce\Admin\API\OnboardingTasks',
			'Automattic\WooCommerce\Admin\API\OnboardingThemes',
			'Automattic\WooCommerce\Admin\API\NavigationFavorites',
			'Automattic\WooCommerce\Admin\API\Taxes',
			'Automattic\WooCommerce\Admin\API\MobileAppMagicLink',
		);

		if ( Features::is_enabled( 'analytics' ) ) {
			$analytics_controllers = array(
				'Automattic\WooCommerce\Admin\API\Customers',
				'Automattic\WooCommerce\Admin\API\Leaderboards',
				'Automattic\WooCommerce\Admin\API\Reports\Controller',
				'Automattic\WooCommerce\Admin\API\Reports\Import\Controller',
				'Automattic\WooCommerce\Admin\API\Reports\Export\Controller',
				'Automattic\WooCommerce\Admin\API\Reports\Products\Controller',
				'Automattic\WooCommerce\Admin\API\Reports\Variations\Controller',
				'Automattic\WooCommerce\Admin\API\Reports\Products\Stats\Controller',
				'Automattic\WooCommerce\Admin\API\Reports\Variations\Stats\Controller',
				'Automattic\WooCommerce\Admin\API\Reports\Revenue\Stats\Controller',
				'Automattic\WooCommerce\Admin\API\Reports\Orders\Controller',
				'Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\Controller',
				'Automattic\WooCommerce\Admin\API\Reports\Categories\Controller',
				'Automattic\WooCommerce\Admin\API\Reports\Taxes\Controller',
				'Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats\Controller',
				'Automattic\WooCommerce\Admin\API\Reports\Coupons\Controller',
				'Automattic\WooCommerce\Admin\API\Reports\Coupons\Stats\Controller',
				'Automattic\WooCommerce\Admin\API\Reports\Stock\Controller',
				'Automattic\WooCommerce\Admin\API\Reports\Stock\Stats\Controller',
				'Automattic\WooCommerce\Admin\API\Reports\Downloads\Controller',
				'Automattic\WooCommerce\Admin\API\Reports\Downloads\Stats\Controller',
				'Automattic\WooCommerce\Admin\API\Reports\Customers\Controller',
				'Automattic\WooCommerce\Admin\API\Reports\Customers\Stats\Controller',
			);

			// The performance indicators controller must be registered last, after other /stats endpoints have been registered.
			$analytics_controllers[] = 'Automattic\WooCommerce\Admin\API\Reports\PerformanceIndicators\Controller';

			$controllers = array_merge( $controllers, $analytics_controllers );
		}

		$controllers = apply_filters( 'woocommerce_admin_rest_controllers', $controllers );

		foreach ( $controllers as $controller ) {
			$this->$controller = new $controller();
			$this->$controller->register_routes();
		}
	}

	/**
	 * Adds data stores.
	 *
	 * @internal
	 * @param array $data_stores List of data stores.
	 * @return array
	 */
	public static function add_data_stores( $data_stores ) {
		return array_merge(
			$data_stores,
			array(
				'report-revenue-stats'    => 'Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore',
				'report-orders'           => 'Automattic\WooCommerce\Admin\API\Reports\Orders\DataStore',
				'report-orders-stats'     => 'Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore',
				'report-products'         => 'Automattic\WooCommerce\Admin\API\Reports\Products\DataStore',
				'report-variations'       => 'Automattic\WooCommerce\Admin\API\Reports\Variations\DataStore',
				'report-products-stats'   => 'Automattic\WooCommerce\Admin\API\Reports\Products\Stats\DataStore',
				'report-variations-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Variations\Stats\DataStore',
				'report-categories'       => 'Automattic\WooCommerce\Admin\API\Reports\Categories\DataStore',
				'report-taxes'            => 'Automattic\WooCommerce\Admin\API\Reports\Taxes\DataStore',
				'report-taxes-stats'      => 'Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats\DataStore',
				'report-coupons'          => 'Automattic\WooCommerce\Admin\API\Reports\Coupons\DataStore',
				'report-coupons-stats'    => 'Automattic\WooCommerce\Admin\API\Reports\Coupons\Stats\DataStore',
				'report-downloads'        => 'Automattic\WooCommerce\Admin\API\Reports\Downloads\DataStore',
				'report-downloads-stats'  => 'Automattic\WooCommerce\Admin\API\Reports\Downloads\Stats\DataStore',
				'admin-note'              => 'Automattic\WooCommerce\Admin\Notes\DataStore',
				'report-customers'        => 'Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore',
				'report-customers-stats'  => 'Automattic\WooCommerce\Admin\API\Reports\Customers\Stats\DataStore',
				'report-stock-stats'      => 'Automattic\WooCommerce\Admin\API\Reports\Stock\Stats\DataStore',
			)
		);
	}

	/**
	 * Add the currency symbol (in addition to currency code) to each Order
	 * object in REST API responses. For use in formatAmount().
	 *
	 * @internal
	 * @param {WP_REST_Response} $response REST response object.
	 * @returns {WP_REST_Response}
	 */
	public static function add_currency_symbol_to_order_response( $response ) {
		$response_data                    = $response->get_data();
		$currency_code                    = $response_data['currency'];
		$currency_symbol                  = get_woocommerce_currency_symbol( $currency_code );
		$response_data['currency_symbol'] = html_entity_decode( $currency_symbol );
		$response->set_data( $response_data );

		return $response;
	}
}
Customers.php000064400000004163152333372110007245 0ustar00<?php
/**
 * REST API Customers Controller
 *
 * Handles requests to /customers/*
 */

namespace Automattic\WooCommerce\Admin\API;

defined( 'ABSPATH' ) || exit;

/**
 * Customers controller.
 *
 * @internal
 * @extends \Automattic\WooCommerce\Admin\API\Reports\Customers\Controller
 */
class Customers extends \Automattic\WooCommerce\Admin\API\Reports\Customers\Controller {

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'customers';

	/**
	 * Register the routes for customers.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_items' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
					'args'                => $this->get_collection_params(),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>[\d-]+)',
			array(
				'args'   => array(
					'id' => array(
						'description' => __( 'Unique ID for the resource.', 'woocommerce' ),
						'type'        => 'integer',
					),
				),
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_item' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
					'args'                => $this->get_collection_params(),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);
	}

	/**
	 * Maps query arguments from the REST request.
	 *
	 * @param array $request Request array.
	 * @return array
	 */
	protected function prepare_reports_query( $request ) {
		$args              = parent::prepare_reports_query( $request );
		$args['customers'] = $request['include'];
		return $args;
	}

	/**
	 * Get the query params for collections.
	 *
	 * @return array
	 */
	public function get_collection_params() {
		$params            = parent::get_collection_params();
		$params['include'] = $params['customers'];
		unset( $params['customers'] );
		return $params;
	}
}
ProductAttributes.php000064400000010730152333372110010745 0ustar00<?php
/**
 * REST API Product Attributes Controller
 *
 * Handles requests to /products/attributes.
 */

namespace Automattic\WooCommerce\Admin\API;

defined( 'ABSPATH' ) || exit;

/**
 * Product categories controller.
 *
 * @internal
 * @extends WC_REST_Product_Attributes_Controller
 */
class ProductAttributes extends \WC_REST_Product_Attributes_Controller {
	use CustomAttributeTraits;

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Register the routes for custom product attributes.
	 */
	public function register_routes() {
		parent::register_routes();

		register_rest_route(
			$this->namespace,
			'products/attributes/(?P<slug>[a-z0-9_\-]+)',
			array(
				'args'   => array(
					'slug' => array(
						'description' => __( 'Slug identifier for the resource.', 'woocommerce' ),
						'type'        => 'string',
					),
				),
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_item_by_slug' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);
	}

	/**
	 * Get the query params for collections
	 *
	 * @return array
	 */
	public function get_collection_params() {
		$params           = parent::get_collection_params();
		$params['search'] = array(
			'description'       => __( 'Search by similar attribute name.', 'woocommerce' ),
			'type'              => 'string',
			'validate_callback' => 'rest_validate_request_arg',
		);

		return $params;
	}

	/**
	 * Get the Attribute's schema, conforming to JSON Schema.
	 *
	 * @return array
	 */
	public function get_item_schema() {
		$schema = parent::get_item_schema();
		// Custom attributes substitute slugs for numeric IDs.
		$schema['properties']['id']['type'] = array( 'integer', 'string' );

		return $schema;
	}

	/**
	 * Get a single attribute by it's slug.
	 *
	 * @param WP_REST_Request $request The API request.
	 * @return WP_REST_Response
	 */
	public function get_item_by_slug( $request ) {
		if ( empty( $request['slug'] ) ) {
			return array();
		}

		$attributes = $this->get_custom_attribute_by_slug( $request['slug'] );

		if ( is_wp_error( $attributes ) ) {
			return $attributes;
		}

		$response_items = $this->format_custom_attribute_items_for_response( $attributes );

		return reset( $response_items );
	}

	/**
	 * Format custom attribute items for response (mimic the structure of a taxonomy - backed attribute).
	 *
	 * @param array $custom_attributes - CustomAttributeTraits::get_custom_attributes().
	 * @return array
	 */
	protected function format_custom_attribute_items_for_response( $custom_attributes ) {
		$response = array();

		foreach ( $custom_attributes as $attribute_key => $attribute_value ) {
			$data = array(
				'id'           => $attribute_key,
				'name'         => $attribute_value['name'],
				'slug'         => $attribute_key,
				'type'         => 'select',
				'order_by'     => 'menu_order',
				'has_archives' => false,
			);

			$item_response = rest_ensure_response( $data );
			$item_response->add_links( $this->prepare_links( (object) array( 'attribute_id' => $attribute_key ) ) );
			$item_response = $this->prepare_response_for_collection(
				$item_response
			);

			$response[] = $item_response;
		}

		return $response;
	}

	/**
	 * Get all attributes, with support for searching (which includes custom attributes).
	 *
	 * @param WP_REST_Request $request The API request.
	 * @return WP_REST_Response
	 */
	public function get_items( $request ) {
		if ( empty( $request['search'] ) ) {
			return parent::get_items( $request );
		}

		$search_string       = $request['search'];
		$custom_attributes   = $this->get_custom_attributes( array( 'name' => $search_string ) );
		$matching_attributes = $this->format_custom_attribute_items_for_response( $custom_attributes );
		$taxonomy_attributes = wc_get_attribute_taxonomies();

		foreach ( $taxonomy_attributes as $attribute_obj ) {
			// Skip taxonomy attributes that didn't match the query.
			if ( false === stripos( $attribute_obj->attribute_label, $search_string ) ) {
				continue;
			}

			$attribute             = $this->prepare_item_for_response( $attribute_obj, $request );
			$matching_attributes[] = $this->prepare_response_for_collection( $attribute );
		}

		$response = rest_ensure_response( $matching_attributes );
		$response->header( 'X-WP-Total', count( $matching_attributes ) );
		$response->header( 'X-WP-TotalPages', 1 );

		return $response;
	}
}
Options.php000064400000017331152333372110006715 0ustar00<?php
/**
 * REST API Options Controller
 *
 * Handles requests to get and update options in the wp_options table.
 */

namespace Automattic\WooCommerce\Admin\API;

defined( 'ABSPATH' ) || exit;

/**
 * Options Controller.
 *
 * @deprecated since 6.2.0
 *
 * @extends WC_REST_Data_Controller
 */
class Options extends \WC_REST_Data_Controller {
	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-admin';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'options';

	/**
	 * Register routes.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_options' ),
					'permission_callback' => array( $this, 'get_item_permissions_check' ),
				),
				'schema' => array( $this, 'get_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'update_options' ),
					'permission_callback' => array( $this, 'update_item_permissions_check' ),
				),
				'schema' => array( $this, 'get_item_schema' ),
			)
		);
	}

	/**
	 * Check if a given request has access to get options.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|boolean
	 */
	public function get_item_permissions_check( $request ) {
		$params = explode( ',', $request['options'] );

		if ( ! isset( $request['options'] ) || ! is_array( $params ) ) {
			return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'You must supply an array of options.', 'woocommerce' ), 500 );
		}

		foreach ( $params as $option ) {
			if ( ! $this->user_has_permission( $option, $request ) ) {
				return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot view these options.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
			}
		}

		return true;
	}

	/**
	 * Check if the user has permission given an option name.
	 *
	 * @param  string          $option Option name.
	 * @param  WP_REST_Request $request Full details about the request.
	 * @param  bool            $is_update If the request is to update the option.
	 * @return boolean
	 */
	public function user_has_permission( $option, $request, $is_update = false ) {
		$permissions = $this->get_option_permissions( $request );

		if ( isset( $permissions[ $option ] ) ) {
			return $permissions[ $option ];
		}

		wc_deprecated_function( 'Automattic\WooCommerce\Admin\API\Options::' . ( $is_update ? 'update_options' : 'get_options' ), '3.1' );
		return current_user_can( 'manage_options' );
	}

	/**
	 * Check if a given request has access to update options.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|boolean
	 */
	public function update_item_permissions_check( $request ) {
		$params = $request->get_json_params();

		if ( ! is_array( $params ) ) {
			return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'You must supply an array of options and values.', 'woocommerce' ), 500 );
		}

		foreach ( $params as $option_name => $option_value ) {
			if ( ! $this->user_has_permission( $option_name, $request, true ) ) {
				return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you cannot manage these options.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
			}
		}

		return true;
	}

	/**
	 * Get an array of options and respective permissions for the current user.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return array
	 */
	public function get_option_permissions( $request ) {
		$permissions = self::get_default_option_permissions();
		return apply_filters_deprecated( 'woocommerce_rest_api_option_permissions', array( $permissions, $request ), '3.1.0' );
	}

	/**
	 * Get the default available option permissions.
	 *
	 * @return array
	 */
	public static function get_default_option_permissions() {
		$is_woocommerce_admin    = \Automattic\WooCommerce\Internal\Admin\Homescreen::is_admin_user();
		$woocommerce_permissions = array(
			'woocommerce_setup_jetpack_opted_in',
			'woocommerce_stripe_settings',
			'woocommerce-ppcp-settings',
			'woocommerce_ppcp-gateway_setting',
			'woocommerce_demo_store',
			'woocommerce_demo_store_notice',
			'woocommerce_ces_tracks_queue',
			'woocommerce_navigation_intro_modal_dismissed',
			'woocommerce_shipping_dismissed_timestamp',
			'woocommerce_allow_tracking',
			'woocommerce_task_list_keep_completed',
			'woocommerce_task_list_prompt_shown',
			'woocommerce_default_homepage_layout',
			'woocommerce_setup_jetpack_opted_in',
			'woocommerce_no_sales_tax',
			'woocommerce_calc_taxes',
			'woocommerce_bacs_settings',
			'woocommerce_bacs_accounts',
			'woocommerce_task_list_prompt_shown',
			'woocommerce_settings_shipping_recommendations_hidden',
			'woocommerce_task_list_dismissed_tasks',
			'woocommerce_setting_payments_recommendations_hidden',
			'woocommerce_navigation_favorites_tooltip_hidden',
			'woocommerce_marketing_overview_welcome_hidden',
			'woocommerce_admin_transient_notices_queue',
			'woocommerce_task_list_welcome_modal_dismissed',
			'woocommerce_welcome_from_calypso_modal_dismissed',
			'woocommerce_task_list_hidden',
			'woocommerce_task_list_complete',
			'woocommerce_extended_task_list_hidden',
			'woocommerce_ces_shown_for_actions',
			'woocommerce_clear_ces_tracks_queue_for_page',
			'woocommerce_admin_install_timestamp',
			'woocommerce_task_list_tracked_completed_tasks',
			'woocommerce_show_marketplace_suggestions',
			'woocommerce_task_list_reminder_bar_hidden',
			'wc_connect_options',
			'woocommerce_admin_created_default_shipping_zones',
			'woocommerce_admin_reviewed_default_shipping_zones',
			'woocommerce_admin_reviewed_store_location_settings',
		);

		$theme_permissions = array(
			'theme_mods_' . get_stylesheet() => current_user_can( 'edit_theme_options' ),
			'stylesheet'                     => current_user_can( 'edit_theme_options' ),
		);

		return array_merge(
			array_fill_keys( $theme_permissions, current_user_can( 'edit_theme_options' ) ),
			array_fill_keys( $woocommerce_permissions, $is_woocommerce_admin )
		);
	}

	/**
	 * Gets an array of options and respective values.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return array Options object with option values.
	 */
	public function get_options( $request ) {
		$params  = explode( ',', $request['options'] );
		$options = array();

		if ( ! is_array( $params ) ) {
			return array();
		}

		foreach ( $params as $option ) {
			$options[ $option ] = get_option( $option );
		}

		return $options;
	}

	/**
	 * Updates an array of objects.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return array Options object with a boolean if the option was updated.
	 */
	public function update_options( $request ) {
		$params  = $request->get_json_params();
		$updated = array();

		if ( ! is_array( $params ) ) {
			return array();
		}

		foreach ( $params as $key => $value ) {
			$updated[ $key ] = update_option( $key, $value );
		}

		return $updated;
	}

	/**
	 * Get the schema, conforming to JSON Schema.
	 *
	 * @return array
	 */
	public function get_item_schema() {
		$schema = array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => 'options',
			'type'       => 'object',
			'properties' => array(
				'options' => array(
					'type'        => 'array',
					'description' => __( 'Array of options with associated values.', 'woocommerce' ),
					'context'     => array( 'view' ),
					'readonly'    => true,
				),
			),
		);

		return $this->add_additional_fields_schema( $schema );
	}
}
DataDownloadIPs.php000064400000010230152333372110010226 0ustar00<?php
/**
 * REST API Data Download IP Controller
 *
 * Handles requests to /data/download-ips
 */

namespace Automattic\WooCommerce\Admin\API;

defined( 'ABSPATH' ) || exit;

/**
 * Data Download IP controller.
 *
 * @internal
 * @extends WC_REST_Data_Controller
 */
class DataDownloadIPs extends \WC_REST_Data_Controller {
	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'data/download-ips';

	/**
	 * Register routes.
	 *
	 * @since 3.5.0
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_items' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);
	}

	/**
	 * Return the download IPs matching the passed parameters.
	 *
	 * @since  3.5.0
	 * @param  WP_REST_Request $request Request data.
	 * @return WP_Error|WP_REST_Response
	 */
	public function get_items( $request ) {
		global $wpdb;

		if ( isset( $request['match'] ) ) {
			$downloads = $wpdb->get_results(
				$wpdb->prepare(
					"SELECT DISTINCT( user_ip_address ) FROM {$wpdb->prefix}wc_download_log
					WHERE user_ip_address LIKE %s
					LIMIT 10",
					$request['match'] . '%'
				)
			);
		} else {
			return new \WP_Error( 'woocommerce_rest_data_download_ips_invalid_request', __( 'Invalid request. Please pass the match parameter.', 'woocommerce' ), array( 'status' => 400 ) );
		}

		$data = array();

		if ( ! empty( $downloads ) ) {
			foreach ( $downloads as $download ) {
				$response = $this->prepare_item_for_response( $download, $request );
				$data[]   = $this->prepare_response_for_collection( $response );
			}
		}

		return rest_ensure_response( $data );
	}

	/**
	 * Prepare the data object for response.
	 *
	 * @since  3.5.0
	 * @param object          $item Data object.
	 * @param WP_REST_Request $request Request object.
	 * @return WP_REST_Response $response Response data.
	 */
	public function prepare_item_for_response( $item, $request ) {
		$data     = $this->add_additional_fields_to_object( $item, $request );
		$data     = $this->filter_response_by_context( $data, 'view' );
		$response = rest_ensure_response( $data );

		$response->add_links( $this->prepare_links( $item ) );

		/**
		 * Filter the list returned from the API.
		 *
		 * @param WP_REST_Response $response The response object.
		 * @param array            $item     The original item.
		 * @param WP_REST_Request  $request  Request used to generate the response.
		 */
		return apply_filters( 'woocommerce_rest_prepare_data_download_ip', $response, $item, $request );
	}

	/**
	 * Prepare links for the request.
	 *
	 * @param object $item Data object.
	 * @return array Links for the given object.
	 */
	protected function prepare_links( $item ) {
		$links = array(
			'collection' => array(
				'href' => rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ),
			),
		);
		return $links;
	}

	/**
	 * 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['match']   = array(
			'description'       => __( 'A partial IP address can be passed and matching results will be returned.', 'woocommerce' ),
			'type'              => 'string',
			'validate_callback' => 'rest_validate_request_arg',
		);
		return $params;
	}


	/**
	 * Get the schema, conforming to JSON Schema.
	 *
	 * @return array
	 */
	public function get_item_schema() {
		$schema = array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => 'data_download_ips',
			'type'       => 'object',
			'properties' => array(
				'user_ip_address' => array(
					'type'        => 'string',
					'description' => __( 'IP address.', 'woocommerce' ),
					'context'     => array( 'view' ),
					'readonly'    => true,
				),
			),
		);

		return $this->add_additional_fields_schema( $schema );
	}
}
DataCountries.php000064400000002175152333372110010027 0ustar00<?php
/**
 * REST API Data countries controller.
 *
 * Handles requests to the /data/countries endpoint.
 */

namespace Automattic\WooCommerce\Admin\API;

defined( 'ABSPATH' ) || exit;

/**
 * REST API Data countries controller class.
 *
 * @internal
 * @extends WC_REST_Data_Countries_Controller
 */
class DataCountries extends \WC_REST_Data_Countries_Controller {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Register routes.
	 *
	 * @since 3.5.0
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/locales',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_locales' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		parent::register_routes();
	}

	/**
	 * Get country fields.
	 *
	 * @return array
	 */
	public function get_locales() {
		$locales = WC()->countries->get_country_locale();
		return rest_ensure_response( $locales );
	}
}
ProductAttributeTerms.php000064400000010563152333372110011601 0ustar00<?php
/**
 * REST API Product Attribute Terms Controller
 *
 * Handles requests to /products/attributes/<slug>/terms
 */

namespace Automattic\WooCommerce\Admin\API;

defined( 'ABSPATH' ) || exit;

/**
 * Product attribute terms controller.
 *
 * @internal
 * @extends WC_REST_Product_Attribute_Terms_Controller
 */
class ProductAttributeTerms extends \WC_REST_Product_Attribute_Terms_Controller {
	use CustomAttributeTraits;

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';


	/**
	 * Register the routes for custom product attributes.
	 */
	public function register_routes() {
		parent::register_routes();

		register_rest_route(
			$this->namespace,
			'products/attributes/(?P<slug>[a-z0-9_\-]+)/terms',
			array(
				'args'   => array(
					'slug' => array(
						'description' => __( 'Slug identifier for the resource.', 'woocommerce' ),
						'type'        => 'string',
					),
				),
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_item_by_slug' ),
					'permission_callback' => array( $this, 'get_custom_attribute_permissions_check' ),
					'args'                => $this->get_collection_params(),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);
	}

	/**
	 * Check if a given request has access to read a custom attribute.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|boolean
	 */
	public function get_custom_attribute_permissions_check( $request ) {
		if ( ! wc_rest_check_manager_permissions( 'attributes', 'read' ) ) {
			return new WP_Error(
				'woocommerce_rest_cannot_view',
				__( 'Sorry, you cannot view this resource.', 'woocommerce' ),
				array(
					'status' => rest_authorization_required_code(),
				)
			);
		}

		return true;
	}

	/**
	 * Get the Attribute's schema, conforming to JSON Schema.
	 *
	 * @return array
	 */
	public function get_item_schema() {
		$schema = parent::get_item_schema();
		// Custom attributes substitute slugs for numeric IDs.
		$schema['properties']['id']['type'] = array( 'integer', 'string' );

		return $schema;
	}

	/**
	 * Query custom attribute values by slug.
	 *
	 * @param string $slug Attribute slug.
	 * @return array Attribute values, formatted for response.
	 */
	protected function get_custom_attribute_values( $slug ) {
		global $wpdb;

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

		$attribute_values = array();

		// Get the attribute properties.
		$attribute = $this->get_custom_attribute_by_slug( $slug );

		if ( is_wp_error( $attribute ) ) {
			return $attribute;
		}

		// Find all attribute values assigned to products.
		$query_results = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT meta_value, COUNT(meta_id) AS product_count
				FROM {$wpdb->postmeta}
				WHERE meta_key = %s
				AND meta_value != ''
				GROUP BY meta_value",
				'attribute_' . esc_sql( $slug )
			),
			OBJECT_K
		);

		// Ensure all defined properties are in the response.
		$defined_values = wc_get_text_attributes( $attribute[ $slug ]['value'] );

		foreach ( $defined_values as $defined_value ) {
			if ( array_key_exists( $defined_value, $query_results ) ) {
				continue;
			}

			$query_results[ $defined_value ] = (object) array(
				'meta_value'    => $defined_value, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
				'product_count' => 0,
			);
		}

		foreach ( $query_results as $term_value => $term ) {
			// Mimic the structure of a taxonomy-backed attribute values for response.
			$data = array(
				'id'          => $term_value,
				'name'        => $term_value,
				'slug'        => $term_value,
				'description' => '',
				'menu_order'  => 0,
				'count'       => (int) $term->product_count,
			);

			$response = rest_ensure_response( $data );
			$response->add_links(
				array(
					'collection' => array(
						'href' => rest_url(
							$this->namespace . '/products/attributes/' . $slug . '/terms'
						),
					),
				)
			);
			$response = $this->prepare_response_for_collection( $response );

			$attribute_values[ $term_value ] = $response;
		}

		return array_values( $attribute_values );
	}

	/**
	 * Get a single custom attribute.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Request|WP_Error
	 */
	public function get_item_by_slug( $request ) {
		return $this->get_custom_attribute_values( $request['slug'] );
	}
}
Templates/external_product.csv000064400000002212152333372110012576 0ustar00Type,Name,Published,"Is featured?","Visibility in catalog","Short description",Description,"Date sale price starts","Date sale price ends","Tax status","Tax class","In stock?",Stock,"Backorders allowed?","Sold individually?","Weight (lbs)","Length (in)","Width (in)","Height (in)","Allow customer reviews?","Purchase note","Sale price","Regular price",Categories,Tags,"Shipping class",Images,"Download limit","Download expiry days",Parent,"Grouped products",Upsells,Cross-sells,"External URL","Button text",Position,"Attribute 1 name","Attribute 1 value(s)","Attribute 1 visible","Attribute 1 global","Attribute 2 name","Attribute 2 value(s)","Attribute 2 visible","Attribute 2 global","Meta: _wpcom_is_markdown","Download 1 name","Download 1 URL","Download 2 name","Download 2 URL"
external,WordPress Pennant,0,0,visible,"This is an external product.","High Quality 36 x 12 inches Wordpress Pennant.",,,"taxable",,"1",,"0","0",,,,,"1",,,"111.05",Decor,,,https://woocommercecore.mystagingwebsite.com/wp-content/uploads/2017/12/pennant-1.jpg,,,,,,,"https://mercantile.wordpress.org/product/wordpress-pennant/","Buy on the WordPress swag store!",0,,,,,,,,,"1",,,,
Templates/digital_product.csv000064400000002332152333372110012374 0ustar00Type,Name,Published,"Is featured?","Visibility in catalog","Short description",Description,"Date sale price starts","Date sale price ends","Tax status","Tax class","In stock?",Stock,"Backorders allowed?","Sold individually?","Weight (lbs)","Length (in)","Width (in)","Height (in)","Allow customer reviews?","Purchase note","Sale price","Regular price",Categories,Tags,"Shipping class",Images,"Download limit","Download expiry days",Parent,"Grouped products",Upsells,Cross-sells,"External URL","Button text",Position,"Attribute 1 name","Attribute 1 value(s)","Attribute 1 visible","Attribute 1 global","Attribute 2 name","Attribute 2 value(s)","Attribute 2 visible","Attribute 2 global","Meta: _wpcom_is_markdown","Download 1 name","Download 1 URL","Download 2 name","Download 2 URL"
"simple, downloadable, virtual",Album,0,0,visible,"This is a simple, virtual product.","Album reviewed and chosen by Woo.",,,taxable,,1,,0,0,,,,,1,,,15,Music,,,https://woocommercecore.mystagingwebsite.com/wp-content/uploads/2022/05/album-1.jpg,1,1,,,,,,,0,,,,,,,,,1,"Single 1",https://demo.woothemes.com/woocommerce/wp-content/uploads/sites/56/2017/08/single.jpg,"Single 2",https://demo.woothemes.com/woocommerce/wp-content/uploads/sites/56/2017/08/album.jpg
Templates/variable_product.csv000064400000002467152333372110012555 0ustar00Type,Name,Published,"Is featured?","Visibility in catalog","Short description",Description,"Date sale price starts","Date sale price ends","Tax status","Tax class","In stock?",Stock,"Backorders allowed?","Sold individually?","Weight (lbs)","Length (in)","Width (in)","Height (in)","Allow customer reviews?","Purchase note","Sale price","Regular price",Categories,Tags,"Shipping class",Images,"Download limit","Download expiry days",Parent,"Grouped products",Upsells,Cross-sells,"External URL","Button text",Position,"Attribute 1 name","Attribute 1 value(s)","Attribute 1 visible","Attribute 1 global","Attribute 2 name","Attribute 2 value(s)","Attribute 2 visible","Attribute 2 global","Meta: _wpcom_is_markdown","Download 1 name","Download 1 URL","Download 2 name","Download 2 URL"
variable,"V-Neck T-Shirt",0,1,visible,"This is a variable product.","Relaxed-fit T-shirt in soft cotton (Available in Blue Green and Red).",,,taxable,,1,,0,0,.5,24,1,2,1,,,,"Clothing > Tshirts",,,"https://woocommercecore.mystagingwebsite.com/wp-content/uploads/2017/12/vneck-tee-2.jpg, https://woocommercecore.mystagingwebsite.com/wp-content/uploads/2017/12/vnech-tee-green-1.jpg, https://woocommercecore.mystagingwebsite.com/wp-content/uploads/2017/12/vnech-tee-blue-1.jpg",,,,,,,,,0,Color,"Blue, Green, Red",1,1,Size,"Large, Medium, Small",1,1,1,,,,
Templates/physical_product.csv000064400000002104152333372110012570 0ustar00Type,Name,Published,"Is featured?","Visibility in catalog","Short description",Description,"Date sale price starts","Date sale price ends","Tax status","Tax class","In stock?",Stock,"Backorders allowed?","Sold individually?","Weight (lbs)","Length (in)","Width (in)","Height (in)","Allow customer reviews?","Purchase note","Sale price","Regular price",Categories,Tags,"Shipping class",Images,"Download limit","Download expiry days",Parent,"Grouped products",Upsells,Cross-sells,"External URL","Button text",Position,"Attribute 1 name","Attribute 1 value(s)","Attribute 1 visible","Attribute 1 global","Attribute 2 name","Attribute 2 value(s)","Attribute 2 visible","Attribute 2 global","Meta: _wpcom_is_markdown","Download 1 name","Download 1 URL","Download 2 name","Download 2 URL"
simple,"Hoodie with Logo",0,0,visible,"This is a simple product.","Hoodie in sweatshirt fabric made from a cotton blend.",,,taxable,,1,,0,0,2,10,6,3,1,,,45,"Clothing > Hoodies",,,https://woocommercecore.mystagingwebsite.com/wp-content/uploads/2017/12/hoodie-with-logo-2.jpg,,,,,,,,,0,Color,Blue,1,1,,,,,1,,,,
Templates/grouped_product.csv000064400000002573152333372110012433 0ustar00Type,Name,Published,"Is featured?","Visibility in catalog","Short description",Description,"Date sale price starts","Date sale price ends","Tax status","Tax class","In stock?",Stock,"Backorders allowed?","Sold individually?","Weight (lbs)","Length (in)","Width (in)","Height (in)","Allow customer reviews?","Purchase note","Sale price","Regular price",Categories,Tags,"Shipping class",Images,"Download limit","Download expiry days",Parent,"Grouped products",Upsells,Cross-sells,"External URL","Button text",Position,"Attribute 1 name","Attribute 1 value(s)","Attribute 1 visible","Attribute 1 global","Attribute 2 name","Attribute 2 value(s)","Attribute 2 visible","Attribute 2 global","Meta: _wpcom_is_markdown","Download 1 name","Download 1 URL","Download 2 name","Download 2 URL"
grouped,Logo Collection,0,0,visible,"This is a grouped product.","This collection consists of beanies, t-shirts and hoodies.",,,"taxable",,"1",,"0","0",,,,,"1",,,,Clothing,,,"https://woocommercecore.mystagingwebsite.com/wp-content/uploads/2017/12/logo-1.jpg, https://woocommercecore.mystagingwebsite.com/wp-content/uploads/2017/12/beanie-with-logo-1.jpg, https://woocommercecore.mystagingwebsite.com/wp-content/uploads/2017/12/t-shirt-with-logo-1.jpg, https://woocommercecore.mystagingwebsite.com/wp-content/uploads/2017/12/hoodie-with-logo-2.jpg",,,,"woo-hoodie-with-logo, woo-tshirt, woo-beanie",,,,,0,,,,,,,,,"1",,,,
ProductReviews.php000064400000002462152333372110010246 0ustar00<?php
/**
 * REST API Product Reviews Controller
 *
 * Handles requests to /products/reviews.
 */

namespace Automattic\WooCommerce\Admin\API;

defined( 'ABSPATH' ) || exit;

/**
 * Product reviews controller.
 *
 * @internal
 * @extends WC_REST_Product_Reviews_Controller
 */
class ProductReviews extends \WC_REST_Product_Reviews_Controller {
	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Prepare links for the request.
	 *
	 * @param WP_Comment $review Product review object.
	 * @return array Links for the given product review.
	 */
	protected function prepare_links( $review ) {
		$links = array(
			'self'       => array(
				'href' => rest_url( sprintf( '/%s/%s/%d', $this->namespace, $this->rest_base, $review->comment_ID ) ),
			),
			'collection' => array(
				'href' => rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ),
			),
		);
		if ( 0 !== (int) $review->comment_post_ID ) {
			$links['up'] = array(
				'href'       => rest_url( sprintf( '/%s/products/%d', $this->namespace, $review->comment_post_ID ) ),
				'embeddable' => true,
			);
		}
		if ( 0 !== (int) $review->user_id ) {
			$links['reviewer'] = array(
				'href'       => rest_url( 'wp/v2/users/' . $review->user_id ),
				'embeddable' => true,
			);
		}
		return $links;
	}
}
OnboardingFreeExtensions.php000064400000003514152333372110012224 0ustar00<?php
/**
 * REST API Onboarding Free Extensions Controller
 *
 * Handles requests to /onboarding/free-extensions
 */

namespace Automattic\WooCommerce\Admin\API;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Internal\Admin\RemoteFreeExtensions\Init as RemoteFreeExtensions;

/**
 * Onboarding Payments Controller.
 *
 * @internal
 * @extends WC_REST_Data_Controller
 */
class OnboardingFreeExtensions extends \WC_REST_Data_Controller {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-admin';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'onboarding/free-extensions';

	/**
	 * Register routes.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_available_extensions' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);
	}

	/**
	 * Check whether a given request has permission to read onboarding profile data.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|boolean
	 */
	public function get_items_permissions_check( $request ) {
		if ( ! wc_rest_check_manager_permissions( 'settings', 'read' ) ) {
			return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
		}

		return true;
	}

	/**
	 * Return available payment methods.
	 *
	 * @param \WP_REST_Request $request Request data.
	 *
	 * @return \WP_Error|\WP_REST_Response
	 */
	public function get_available_extensions( $request ) {
		return RemoteFreeExtensions::get_extensions();
	}

}
MarketingOverview.php000064400000006563152333372110010737 0ustar00<?php
/**
 * REST API Marketing Overview Controller
 *
 * Handles requests to /marketing/overview.
 */

namespace Automattic\WooCommerce\Admin\API;

use Automattic\WooCommerce\Admin\Marketing\InstalledExtensions;
use Automattic\WooCommerce\Admin\PluginsHelper;

defined( 'ABSPATH' ) || exit;

/**
 * Marketing Overview Controller.
 *
 * @internal
 * @extends WC_REST_Data_Controller
 */
class MarketingOverview extends \WC_REST_Data_Controller {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-admin';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'marketing/overview';

	/**
	 * Register routes.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/activate-plugin',
			array(
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'activate_plugin' ),
					'permission_callback' => array( $this, 'install_plugins_permissions_check' ),
					'args'                => array(
						'plugin' => array(
							'required'          => true,
							'type'              => 'string',
							'validate_callback' => 'rest_validate_request_arg',
							'sanitize_callback' => 'sanitize_title_with_dashes',
						),
					),
				),
				'schema' => array( $this, 'get_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/installed-plugins',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_installed_plugins' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);
	}

	/**
	 * Return installed marketing extensions data.
	 *
	 * @param \WP_REST_Request $request Request data.
	 *
	 * @return \WP_Error|\WP_REST_Response
	 */
	public function activate_plugin( $request ) {
		$plugin_slug = $request->get_param( 'plugin' );

		if ( ! PluginsHelper::is_plugin_installed( $plugin_slug ) ) {
			return new \WP_Error( 'woocommerce_rest_invalid_plugin', __( 'Invalid plugin.', 'woocommerce' ), 404 );
		}

		$result = activate_plugin( PluginsHelper::get_plugin_path_from_slug( $plugin_slug ) );

		if ( ! is_null( $result ) ) {
			return new \WP_Error( 'woocommerce_rest_invalid_plugin', __( 'The plugin could not be activated.', 'woocommerce' ), 500 );
		}

		// IMPORTANT - Don't return the active plugins data here.
		// Instead we will get that data in a separate request to ensure they are loaded.
		return rest_ensure_response(
			array(
				'status' => 'success',
			)
		);
	}

	/**
	 * Check if a given request has access to manage plugins.
	 *
	 * @param \WP_REST_Request $request Full details about the request.
	 *
	 * @return \WP_Error|boolean
	 */
	public function install_plugins_permissions_check( $request ) {
		if ( ! current_user_can( 'install_plugins' ) ) {
			return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you cannot manage plugins.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
		}

		return true;
	}

	/**
	 * Return installed marketing extensions data.
	 *
	 * @param \WP_REST_Request $request Request data.
	 *
	 * @return \WP_Error|\WP_REST_Response
	 */
	public function get_installed_plugins( $request ) {
		return rest_ensure_response( InstalledExtensions::get_data() );
	}

}
PaymentGatewaySuggestions.php000064400000012112152333372110012444 0ustar00<?php
/**
 * REST API Payment Gateway Suggestions Controller
 *
 * Handles requests to install and activate depedent plugins.
 */

namespace Automattic\WooCommerce\Admin\API;

use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\DefaultPaymentGateways;
use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\Init as Suggestions;

defined( 'ABSPATH' ) || exit;

/**
 * PaymentGatewaySuggetsions Controller.
 *
 * @internal
 * @extends WC_REST_Data_Controller
 */
class PaymentGatewaySuggestions extends \WC_REST_Data_Controller {
	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-admin';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'payment-gateway-suggestions';

	/**
	 * Register routes.
	 */
	public function register_routes() {

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_suggestions' ),
					'permission_callback' => array( $this, 'get_permission_check' ),
					'args'                => array(
						'force_default_suggestions' => array(
							'type'        => 'boolean',
							'description' => __( 'Return the default payment suggestions when woocommerce_show_marketplace_suggestions and woocommerce_setting_payments_recommendations_hidden options are set to no', 'woocommerce' ),
						),
					),
				),
				'schema' => array( $this, 'get_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/dismiss',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'dismiss_payment_gateway_suggestion' ),
					'permission_callback' => array( $this, 'get_permission_check' ),
				),
				'schema' => array( $this, 'get_item_schema' ),
			)
		);

	}

	/**
	 * Check if a given request has access to manage plugins.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|boolean
	 */
	public function get_permission_check( $request ) {
		if ( ! current_user_can( 'install_plugins' ) ) {
			return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you cannot manage plugins.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
		}
		return true;
	}

	/**
	 * Return suggested payment gateways.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return \WP_Error|\WP_HTTP_Response|\WP_REST_Response
	 */
	public function get_suggestions( $request ) {

		$should_display = Suggestions::should_display();
		$force_default  = $request->get_param( 'force_default_suggestions' );

		if ( $should_display ) {
			return Suggestions::get_suggestions();
		} elseif ( false === $should_display && true === $force_default ) {
			return rest_ensure_response( Suggestions::get_suggestions( DefaultPaymentGateways::get_all() ) );
		}

		return rest_ensure_response( array() );
	}

	/**
	 * Dismisses suggested payment gateways.
	 *
	 * @return \WP_Error|\WP_HTTP_Response|\WP_REST_Response
	 */
	public function dismiss_payment_gateway_suggestion() {
		$success = Suggestions::dismiss();
		return rest_ensure_response( $success );
	}

	/**
	 * Get the schema, conforming to JSON Schema.
	 *
	 * @return array
	 */
	public function get_item_schema() {
		$schema = array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => 'payment-gateway-suggestions',
			'type'       => 'array',
			'properties' => array(
				'content'                 => array(
					'description' => __( 'Suggestion description.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'id'                      => array(
					'description' => __( 'Suggestion ID.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'image'                   => array(
					'description' => __( 'Gateway image.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'is_visible'              => array(
					'description' => __( 'Suggestion visibility.', 'woocommerce' ),
					'type'        => 'boolean',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'plugins'                 => array(
					'description' => __( 'Array of plugin slugs.', 'woocommerce' ),
					'type'        => 'array',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'recommendation_priority' => array(
					'description' => __( 'Priority of recommendation.', 'woocommerce' ),
					'type'        => 'integer',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'title'                   => array(
					'description' => __( 'Gateway title.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
			),
		);

		return $this->add_additional_fields_schema( $schema );
	}
}
Reports/SqlQuery.php000064400000011706152333372110010505 0ustar00<?php
/**
 * Admin\API\Reports\SqlQuery class file.
 */

namespace Automattic\WooCommerce\Admin\API\Reports;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Admin\API\Reports\SqlQuery: Common parent for manipulating SQL query clauses.
 */
class SqlQuery {
	/**
	 * List of SQL clauses.
	 *
	 * @var array
	 */
	private $sql_clauses = array(
		'select'     => array(),
		'from'       => array(),
		'left_join'  => array(),
		'join'       => array(),
		'right_join' => array(),
		'where'      => array(),
		'where_time' => array(),
		'group_by'   => array(),
		'having'     => array(),
		'limit'      => array(),
		'order_by'   => array(),
	);
	/**
	 * SQL clause merge filters.
	 *
	 * @var array
	 */
	private $sql_filters = array(
		'where' => array(
			'where',
			'where_time',
		),
		'join'  => array(
			'right_join',
			'join',
			'left_join',
		),
	);
	/**
	 * Data store context used to pass to filters.
	 *
	 * @var string
	 */
	protected $context;

	/**
	 * Constructor.
	 *
	 * @param string $context Optional context passed to filters. Default empty string.
	 */
	public function __construct( $context = '' ) {
		$this->context = $context;
	}

	/**
	 * Add a SQL clause to be included when get_data is called.
	 *
	 * @param string $type   Clause type.
	 * @param string $clause SQL clause.
	 */
	protected function add_sql_clause( $type, $clause ) {
		if ( isset( $this->sql_clauses[ $type ] ) && ! empty( $clause ) ) {
			$this->sql_clauses[ $type ][] = $clause;
		}
	}

	/**
	 * Get SQL clause by type.
	 *
	 * @param string $type     Clause type.
	 * @param string $handling Whether to filter the return value (filtered|unfiltered). Default unfiltered.
	 *
	 * @return string SQL clause.
	 */
	protected function get_sql_clause( $type, $handling = 'unfiltered' ) {
		if ( ! isset( $this->sql_clauses[ $type ] ) ) {
			return '';
		}

		/**
		 * Default to bypassing filters for clause retrieval internal to data stores.
		 * The filters are applied when the full SQL statement is retrieved.
		 */
		if ( 'unfiltered' === $handling ) {
			return implode( ' ', $this->sql_clauses[ $type ] );
		}

		if ( isset( $this->sql_filters[ $type ] ) ) {
			$clauses = array();
			foreach ( $this->sql_filters[ $type ] as $subset ) {
				$clauses = array_merge( $clauses, $this->sql_clauses[ $subset ] );
			}
		} else {
			$clauses = $this->sql_clauses[ $type ];
		}

		/**
		 * Filter SQL clauses by type and context.
		 *
		 * @param array  $clauses The original arguments for the request.
		 * @param string $context The data store context.
		 */
		$clauses = apply_filters( "woocommerce_analytics_clauses_{$type}", $clauses, $this->context );
		/**
		 * Filter SQL clauses by type and context.
		 *
		 * @param array  $clauses The original arguments for the request.
		 */
		$clauses = apply_filters( "woocommerce_analytics_clauses_{$type}_{$this->context}", $clauses );
		return implode( ' ', $clauses );
	}

	/**
	 * Clear SQL clauses by type.
	 *
	 * @param string|array $types Clause type.
	 */
	protected function clear_sql_clause( $types ) {
		foreach ( (array) $types as $type ) {
			if ( isset( $this->sql_clauses[ $type ] ) ) {
				$this->sql_clauses[ $type ] = array();
			}
		}
	}

	/**
	 * Replace strings within SQL clauses by type.
	 *
	 * @param string $type    Clause type.
	 * @param string $search  String to search for.
	 * @param string $replace Replacement string.
	 */
	protected function str_replace_clause( $type, $search, $replace ) {
		if ( isset( $this->sql_clauses[ $type ] ) ) {
			foreach ( $this->sql_clauses[ $type ] as $key => $sql ) {
				$this->sql_clauses[ $type ][ $key ] = str_replace( $search, $replace, $sql );
			}
		}
	}

	/**
	 * Get the full SQL statement.
	 *
	 * @return string
	 */
	public function get_query_statement() {
		$join     = $this->get_sql_clause( 'join', 'filtered' );
		$where    = $this->get_sql_clause( 'where', 'filtered' );
		$group_by = $this->get_sql_clause( 'group_by', 'filtered' );
		$having   = $this->get_sql_clause( 'having', 'filtered' );
		$order_by = $this->get_sql_clause( 'order_by', 'filtered' );

		$statement = "
			SELECT
				{$this->get_sql_clause( 'select', 'filtered' )}
			FROM
				{$this->get_sql_clause( 'from', 'filtered' )}
				{$join}
			WHERE
				1=1
				{$where}
		";

		if ( ! empty( $group_by ) ) {
			$statement .= "
				GROUP BY
					{$group_by}
			";
			if ( ! empty( $having ) ) {
				$statement .= "
					HAVING
						1=1
						{$having}
				";
			}
		}

		if ( ! empty( $order_by ) ) {
			$statement .= "
				ORDER BY
					{$order_by}
			";
		}

		return $statement . $this->get_sql_clause( 'limit', 'filtered' );
	}

	/**
	 * Reinitialize the clause array.
	 */
	public function clear_all_clauses() {
		$this->sql_clauses = array(
			'select'     => array(),
			'from'       => array(),
			'left_join'  => array(),
			'join'       => array(),
			'right_join' => array(),
			'where'      => array(),
			'where_time' => array(),
			'group_by'   => array(),
			'having'     => array(),
			'limit'      => array(),
			'order_by'   => array(),
		);
	}
}
Reports/Customers/Stats/DataStore.php000064400000007232152333372110013667 0ustar00<?php
/**
 * API\Reports\Customers\Stats\DataStore class file.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Customers\Stats;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore as CustomersDataStore;
use \Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface;

/**
 * API\Reports\Customers\Stats\DataStore.
 */
class DataStore extends CustomersDataStore implements DataStoreInterface {
	/**
	 * Mapping columns to data type to return correct response types.
	 *
	 * @var array
	 */
	protected $column_types = array(
		'customers_count'     => 'intval',
		'avg_orders_count'    => 'floatval',
		'avg_total_spend'     => 'floatval',
		'avg_avg_order_value' => 'floatval',
	);

	/**
	 * Cache identifier.
	 *
	 * @var string
	 */
	protected $cache_key = 'customers_stats';

	/**
	 * Data store context used to pass to filters.
	 *
	 * @var string
	 */
	protected $context = 'customers_stats';

	/**
	 * Assign report columns once full table name has been assigned.
	 */
	protected function assign_report_columns() {
		$this->report_columns = array(
			'customers_count'     => 'COUNT( * ) as customers_count',
			'avg_orders_count'    => 'AVG( orders_count ) as avg_orders_count',
			'avg_total_spend'     => 'AVG( total_spend ) as avg_total_spend',
			'avg_avg_order_value' => 'AVG( avg_order_value ) as avg_avg_order_value',
		);
	}

	/**
	 * 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;

		$customers_table_name = self::get_db_table_name();

		// 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'  => 'date_registered',
			'fields'   => '*',
		);
		$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(
				'customers_count'     => 0,
				'avg_orders_count'    => 0,
				'avg_total_spend'     => 0.0,
				'avg_avg_order_value' => 0.0,
			);

			$selections = $this->selected_columns( $query_args );
			$this->add_sql_query_params( $query_args );
			// Clear SQL clauses set for parent class queries that are different here.
			$this->subquery->clear_sql_clause( 'select' );
			$this->subquery->add_sql_clause( 'select', 'SUM( total_sales ) AS total_spend,' );
			$this->subquery->add_sql_clause(
				'select',
				'SUM( CASE WHEN parent_id = 0 THEN 1 END ) as orders_count,'
			);
			$this->subquery->add_sql_clause(
				'select',
				'CASE WHEN SUM( CASE WHEN parent_id = 0 THEN 1 ELSE 0 END ) = 0 THEN NULL ELSE SUM( total_sales ) / SUM( CASE WHEN parent_id = 0 THEN 1 ELSE 0 END ) END AS avg_order_value'
			);

			$this->clear_sql_clause( array( 'order_by', 'limit' ) );
			$this->add_sql_clause( 'select', $selections );
			$this->add_sql_clause( 'from', "({$this->subquery->get_query_statement()}) AS tt" );

			$report_data = $wpdb->get_results(
				$this->get_query_statement(), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
				ARRAY_A
			);

			if ( null === $report_data ) {
				return $data;
			}

			$data = (object) $this->cast_numbers( $report_data[0] );

			$this->set_cached_data( $cache_key, $data );
		}

		return $data;
	}
}
Reports/Customers/Stats/Controller.php000064400000040531152333372110014123 0ustar00<?php
/**
 * REST API Reports customers stats controller
 *
 * Handles requests to the /reports/customers/stats endpoint.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Customers\Stats;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\TimeInterval;

/**
 * REST API Reports customers stats controller class.
 *
 * @internal
 * @extends WC_REST_Reports_Controller
 */
class Controller extends \WC_REST_Reports_Controller {
	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'reports/customers/stats';

	/**
	 * Maps query arguments from the REST request.
	 *
	 * @param array $request Request array.
	 * @return array
	 */
	protected function prepare_reports_query( $request ) {
		$args                        = array();
		$args['registered_before']   = $request['registered_before'];
		$args['registered_after']    = $request['registered_after'];
		$args['match']               = $request['match'];
		$args['search']              = $request['search'];
		$args['name_includes']       = $request['name_includes'];
		$args['name_excludes']       = $request['name_excludes'];
		$args['username_includes']   = $request['username_includes'];
		$args['username_excludes']   = $request['username_excludes'];
		$args['email_includes']      = $request['email_includes'];
		$args['email_excludes']      = $request['email_excludes'];
		$args['country_includes']    = $request['country_includes'];
		$args['country_excludes']    = $request['country_excludes'];
		$args['last_active_before']  = $request['last_active_before'];
		$args['last_active_after']   = $request['last_active_after'];
		$args['orders_count_min']    = $request['orders_count_min'];
		$args['orders_count_max']    = $request['orders_count_max'];
		$args['total_spend_min']     = $request['total_spend_min'];
		$args['total_spend_max']     = $request['total_spend_max'];
		$args['avg_order_value_min'] = $request['avg_order_value_min'];
		$args['avg_order_value_max'] = $request['avg_order_value_max'];
		$args['last_order_before']   = $request['last_order_before'];
		$args['last_order_after']    = $request['last_order_after'];
		$args['customers']           = $request['customers'];
		$args['fields']              = $request['fields'];
		$args['force_cache_refresh'] = $request['force_cache_refresh'];

		$between_params_numeric    = array( 'orders_count', 'total_spend', 'avg_order_value' );
		$normalized_params_numeric = TimeInterval::normalize_between_params( $request, $between_params_numeric, false );
		$between_params_date       = array( 'last_active', 'registered' );
		$normalized_params_date    = TimeInterval::normalize_between_params( $request, $between_params_date, true );
		$args                      = array_merge( $args, $normalized_params_numeric, $normalized_params_date );

		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 );
		$customers_query = new Query( $query_args );
		$report_data     = $customers_query->get_data();
		$out_data        = array(
			'totals' => $report_data,
		);

		return rest_ensure_response( $out_data );
	}

	/**
	 * 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_customers_stats', $response, $report, $request );
	}

	/**
	 * Get the Report's schema, conforming to JSON Schema.
	 *
	 * @return array
	 */
	public function get_item_schema() {
		// @todo Should any of these be 'indicator's?
		$totals = array(
			'customers_count'     => array(
				'description' => __( 'Number of customers.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
			'avg_orders_count'    => array(
				'description' => __( 'Average number of orders.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
			'avg_total_spend'     => array(
				'description' => __( 'Average total spend per customer.', 'woocommerce' ),
				'type'        => 'number',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
				'format'      => 'currency',
			),
			'avg_avg_order_value' => array(
				'description' => __( 'Average AOV per customer.', 'woocommerce' ),
				'type'        => 'number',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
				'format'      => 'currency',
			),
		);

		$schema = array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => 'report_customers_stats',
			'type'       => 'object',
			'properties' => array(
				'totals' => array(
					'description' => __( 'Totals data.', 'woocommerce' ),
					'type'        => 'object',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
					'properties'  => $totals,
				),
			),
		);

		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['registered_before']       = array(
			'description'       => __( 'Limit response to objects registered before (or at) a given ISO8601 compliant datetime.', 'woocommerce' ),
			'type'              => 'string',
			'format'            => 'date-time',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['registered_after']        = array(
			'description'       => __( 'Limit response to objects registered after (or at) a given ISO8601 compliant datetime.', 'woocommerce' ),
			'type'              => 'string',
			'format'            => 'date-time',
			'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['search']                  = array(
			'description'       => __( 'Limit response to objects with a customer field containing the search term. Searches the field provided by `searchby`.', 'woocommerce' ),
			'type'              => 'string',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['searchby']                = array(
			'description' => 'Limit results with `search` and `searchby` to specific fields containing the search term.',
			'type'        => 'string',
			'default'     => 'name',
			'enum'        => array(
				'name',
				'username',
				'email',
			),
		);
		$params['name_includes']           = array(
			'description'       => __( 'Limit response to objects with specfic names.', 'woocommerce' ),
			'type'              => 'string',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['name_excludes']           = array(
			'description'       => __( 'Limit response to objects excluding specfic names.', 'woocommerce' ),
			'type'              => 'string',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['username_includes']       = array(
			'description'       => __( 'Limit response to objects with specfic usernames.', 'woocommerce' ),
			'type'              => 'string',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['username_excludes']       = array(
			'description'       => __( 'Limit response to objects excluding specfic usernames.', 'woocommerce' ),
			'type'              => 'string',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['email_includes']          = array(
			'description'       => __( 'Limit response to objects including emails.', 'woocommerce' ),
			'type'              => 'string',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['email_excludes']          = array(
			'description'       => __( 'Limit response to objects excluding emails.', 'woocommerce' ),
			'type'              => 'string',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['country_includes']        = array(
			'description'       => __( 'Limit response to objects with specfic countries.', 'woocommerce' ),
			'type'              => 'string',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['country_excludes']        = array(
			'description'       => __( 'Limit response to objects excluding specfic countries.', 'woocommerce' ),
			'type'              => 'string',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['last_active_before']      = array(
			'description'       => __( 'Limit response to objects last active before (or at) a given ISO8601 compliant datetime.', 'woocommerce' ),
			'type'              => 'string',
			'format'            => 'date-time',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['last_active_after']       = array(
			'description'       => __( 'Limit response to objects last active after (or at) a given ISO8601 compliant datetime.', 'woocommerce' ),
			'type'              => 'string',
			'format'            => 'date-time',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['last_active_between']     = array(
			'description'       => __( 'Limit response to objects last active between two given ISO8601 compliant datetime.', 'woocommerce' ),
			'type'              => 'array',
			'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_date_arg' ),
			'items'             => array(
				'type' => 'string',
			),
		);
		$params['registered_before']       = array(
			'description'       => __( 'Limit response to objects registered before (or at) a given ISO8601 compliant datetime.', 'woocommerce' ),
			'type'              => 'string',
			'format'            => 'date-time',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['registered_after']        = array(
			'description'       => __( 'Limit response to objects registered after (or at) a given ISO8601 compliant datetime.', 'woocommerce' ),
			'type'              => 'string',
			'format'            => 'date-time',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['registered_between']      = array(
			'description'       => __( 'Limit response to objects last active between two given ISO8601 compliant datetime.', 'woocommerce' ),
			'type'              => 'array',
			'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_date_arg' ),
			'items'             => array(
				'type' => 'string',
			),
		);
		$params['orders_count_min']        = array(
			'description'       => __( 'Limit response to objects with an order count greater than or equal to given integer.', 'woocommerce' ),
			'type'              => 'integer',
			'sanitize_callback' => 'absint',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['orders_count_max']        = array(
			'description'       => __( 'Limit response to objects with an order count less than or equal to given integer.', 'woocommerce' ),
			'type'              => 'integer',
			'sanitize_callback' => 'absint',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['orders_count_between']    = array(
			'description'       => __( 'Limit response to objects with an order count between two given integers.', 'woocommerce' ),
			'type'              => 'array',
			'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_numeric_arg' ),
			'items'             => array(
				'type' => 'integer',
			),
		);
		$params['total_spend_min']         = array(
			'description'       => __( 'Limit response to objects with a total order spend greater than or equal to given number.', 'woocommerce' ),
			'type'              => 'number',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['total_spend_max']         = array(
			'description'       => __( 'Limit response to objects with a total order spend less than or equal to given number.', 'woocommerce' ),
			'type'              => 'number',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['total_spend_between']     = array(
			'description'       => __( 'Limit response to objects with a total order spend between two given numbers.', 'woocommerce' ),
			'type'              => 'array',
			'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_numeric_arg' ),
			'items'             => array(
				'type' => 'integer',
			),
		);
		$params['avg_order_value_min']     = array(
			'description'       => __( 'Limit response to objects with an average order spend greater than or equal to given number.', 'woocommerce' ),
			'type'              => 'number',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['avg_order_value_max']     = array(
			'description'       => __( 'Limit response to objects with an average order spend less than or equal to given number.', 'woocommerce' ),
			'type'              => 'number',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['avg_order_value_between'] = array(
			'description'       => __( 'Limit response to objects with an average order spend between two given numbers.', 'woocommerce' ),
			'type'              => 'array',
			'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_numeric_arg' ),
			'items'             => array(
				'type' => 'integer',
			),
		);
		$params['last_order_before']       = array(
			'description'       => __( 'Limit response to objects with last order before (or at) a given ISO8601 compliant datetime.', 'woocommerce' ),
			'type'              => 'string',
			'format'            => 'date-time',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['last_order_after']        = array(
			'description'       => __( 'Limit response to objects with last order after (or at) a given ISO8601 compliant datetime.', 'woocommerce' ),
			'type'              => 'string',
			'format'            => 'date-time',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['customers']               = array(
			'description'       => __( 'Limit result to items with specified customer ids.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'type' => 'integer',
			),
		);
		$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;
	}
}
Reports/Customers/Stats/Query.php000064400000003006152333372110013101 0ustar00<?php
/**
 * Class for parameter-based Customers Report Stats querying
 *
 * Example usage:
 * $args = array(
 *          'registered_before'   => '2018-07-19 00:00:00',
 *          'registered_after'    => '2018-07-05 00:00:00',
 *          'page'                => 2,
 *          'avg_order_value_min' => 100,
 *          'country'             => 'GB',
 *         );
 * $report = new \Automattic\WooCommerce\Admin\API\Reports\Customers\Stats\Query( $args );
 * $mydata = $report->get_data();
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Customers\Stats;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;

/**
 * API\Reports\Customers\Stats\Query
 */
class Query extends ReportsQuery {

	/**
	 * Valid fields for Customers report.
	 *
	 * @return array
	 */
	protected function get_default_query_vars() {
		return array(
			'per_page' => get_option( 'posts_per_page' ), // not sure if this should be the default.
			'page'     => 1,
			'order'    => 'DESC',
			'orderby'  => 'date_registered',
			'fields'   => '*', // @todo Needed?
		);
	}

	/**
	 * Get product data based on the current query vars.
	 *
	 * @return array
	 */
	public function get_data() {
		$args = apply_filters( 'woocommerce_analytics_customers_stats_query_args', $this->get_query_vars() );

		$data_store = \WC_Data_Store::load( 'report-customers-stats' );
		$results    = $data_store->get_data( $args );
		return apply_filters( 'woocommerce_analytics_customers_stats_select_query', $results, $args );
	}
}
Reports/Customers/DataStore.php000064400000063440152333372110012574 0ustar00<?php
/**
 * Admin\API\Reports\Customers\DataStore class file.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Customers;

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\Utilities\OrderUtil;

/**
 * Admin\API\Reports\Customers\DataStore.
 */
class DataStore extends ReportsDataStore implements DataStoreInterface {

	/**
	 * Table used to get the data.
	 *
	 * @var string
	 */
	protected static $table_name = 'wc_customer_lookup';

	/**
	 * Cache identifier.
	 *
	 * @var string
	 */
	protected $cache_key = 'customers';

	/**
	 * Mapping columns to data type to return correct response types.
	 *
	 * @var array
	 */
	protected $column_types = array(
		'id'              => 'intval',
		'user_id'         => 'intval',
		'orders_count'    => 'intval',
		'total_spend'     => 'floatval',
		'avg_order_value' => 'floatval',
	);

	/**
	 * Data store context used to pass to filters.
	 *
	 * @var string
	 */
	protected $context = 'customers';

	/**
	 * Assign report columns once full table name has been assigned.
	 */
	protected function assign_report_columns() {
		global $wpdb;
		$table_name           = self::get_db_table_name();
		$orders_count         = 'SUM( CASE WHEN parent_id = 0 THEN 1 ELSE 0 END )';
		$total_spend          = 'SUM( total_sales )';
		$this->report_columns = array(
			'id'               => "{$table_name}.customer_id as id",
			'user_id'          => 'user_id',
			'username'         => 'username',
			'name'             => "CONCAT_WS( ' ', first_name, last_name ) as name", // @xxx: What does this mean for RTL?
			'email'            => 'email',
			'country'          => 'country',
			'city'             => 'city',
			'state'            => 'state',
			'postcode'         => 'postcode',
			'date_registered'  => 'date_registered',
			'date_last_active' => 'IF( date_last_active <= "0000-00-00 00:00:00", NULL, date_last_active ) AS date_last_active',
			'date_last_order'  => "MAX( {$wpdb->prefix}wc_order_stats.date_created ) as date_last_order",
			'orders_count'     => "{$orders_count} as orders_count",
			'total_spend'      => "{$total_spend} as total_spend",
			'avg_order_value'  => "CASE WHEN {$orders_count} = 0 THEN NULL ELSE {$total_spend} / {$orders_count} END AS avg_order_value",
		);
	}

	/**
	 * Set up all the hooks for maintaining and populating table data.
	 */
	public static function init() {
		add_action( 'edit_user_profile_update', array( __CLASS__, 'update_registered_customer' ) );
		add_action( 'woocommerce_analytics_delete_order_stats', array( __CLASS__, 'sync_on_order_delete' ), 15, 2 );
	}

	/**
	 * Sync customers data after an order was deleted.
	 *
	 * When an order is deleted, the customer record is deleted from the
	 * table if the customer has no other orders.
	 *
	 * @param int $order_id Order ID.
	 * @param int $customer_id Customer ID.
	 */
	public static function sync_on_order_delete( $order_id, $customer_id ) {
		$customer_id = absint( $customer_id );

		if ( 0 === $customer_id ) {
			return;
		}

		// Calculate the amount of orders remaining for this customer.
		$order_count = self::get_order_count( $customer_id );

		if ( 0 === $order_count ) {
			self::delete_customer( $customer_id );
		}
	}

	/**
	 * Sync customers data after an order was updated.
	 *
	 * Only updates customer if it is the customers last order.
	 *
	 * @param int $post_id of order.
	 * @return true|-1
	 */
	public static function sync_order_customer( $post_id ) {
		global $wpdb;

		if ( ! OrderUtil::is_order( $post_id, array( 'shop_order', 'shop_order_refund' ) ) ) {
			return -1;
		}

		$order       = wc_get_order( $post_id );
		$customer_id = self::get_existing_customer_id_from_order( $order );
		if ( false === $customer_id ) {
			return -1;
		}
		$last_order = self::get_last_order( $customer_id );

		if ( ! $last_order || $order->get_id() !== $last_order->get_id() ) {
			return -1;
		}

		list($data, $format) = self::get_customer_order_data_and_format( $order );

		$result = $wpdb->update( self::get_db_table_name(), $data, array( 'customer_id' => $customer_id ), $format );

		/**
		 * Fires when a customer is updated.
		 *
		 * @param int $customer_id Customer ID.
		 * @since 4.0.0
		 */
		do_action( 'woocommerce_analytics_update_customer', $customer_id );

		return 1 === $result;
	}

	/**
	 * Maps ordering specified by the user to columns in the database/fields in the data.
	 *
	 * @param string $order_by Sorting criterion.
	 * @return string
	 */
	protected function normalize_order_by( $order_by ) {
		if ( 'name' === $order_by ) {
			return "CONCAT_WS( ' ', first_name, last_name )";
		}

		return $order_by;
	}

	/**
	 * Fills WHERE clause of SQL request with date-related constraints.
	 *
	 * @param array  $query_args Parameters supplied by the user.
	 * @param string $table_name Name of the db table relevant for the date constraint.
	 */
	protected function add_time_period_sql_params( $query_args, $table_name ) {
		global $wpdb;

		$this->clear_sql_clause( array( 'where', 'where_time', 'having' ) );
		$date_param_mapping  = array(
			'registered'  => array(
				'clause' => 'where',
				'column' => $table_name . '.date_registered',
			),
			'order'       => array(
				'clause' => 'where',
				'column' => $wpdb->prefix . 'wc_order_stats.date_created',
			),
			'last_active' => array(
				'clause' => 'where',
				'column' => $table_name . '.date_last_active',
			),
			'last_order'  => array(
				'clause' => 'having',
				'column' => "MAX( {$wpdb->prefix}wc_order_stats.date_created )",
			),
		);
		$match_operator      = $this->get_match_operator( $query_args );
		$where_time_clauses  = array();
		$having_time_clauses = array();

		foreach ( $date_param_mapping as $query_param => $param_info ) {
			$subclauses  = array();
			$before_arg  = $query_param . '_before';
			$after_arg   = $query_param . '_after';
			$column_name = $param_info['column'];

			if ( ! empty( $query_args[ $before_arg ] ) ) {
				$datetime     = new \DateTime( $query_args[ $before_arg ] );
				$datetime_str = $datetime->format( TimeInterval::$sql_datetime_format );
				$subclauses[] = "{$column_name} <= '$datetime_str'";
			}

			if ( ! empty( $query_args[ $after_arg ] ) ) {
				$datetime     = new \DateTime( $query_args[ $after_arg ] );
				$datetime_str = $datetime->format( TimeInterval::$sql_datetime_format );
				$subclauses[] = "{$column_name} >= '$datetime_str'";
			}

			if ( $subclauses && ( 'where' === $param_info['clause'] ) ) {
				$where_time_clauses[] = '(' . implode( ' AND ', $subclauses ) . ')';
			}

			if ( $subclauses && ( 'having' === $param_info['clause'] ) ) {
				$having_time_clauses[] = '(' . implode( ' AND ', $subclauses ) . ')';
			}
		}

		if ( $where_time_clauses ) {
			$this->subquery->add_sql_clause( 'where_time', 'AND ' . implode( " {$match_operator} ", $where_time_clauses ) );
		}

		if ( $having_time_clauses ) {
			$this->subquery->add_sql_clause( 'having', 'AND ' . implode( " {$match_operator} ", $having_time_clauses ) );
		}
	}

	/**
	 * Updates the database query with parameters used for Customers report: categories and order status.
	 *
	 * @param array $query_args Query arguments supplied by the user.
	 */
	protected function add_sql_query_params( $query_args ) {
		global $wpdb;
		$customer_lookup_table  = self::get_db_table_name();
		$order_stats_table_name = $wpdb->prefix . 'wc_order_stats';

		$this->add_time_period_sql_params( $query_args, $customer_lookup_table );
		$this->get_limit_sql_params( $query_args );
		$this->add_order_by_sql_params( $query_args );
		$this->subquery->add_sql_clause( 'left_join', "LEFT JOIN {$order_stats_table_name} ON {$customer_lookup_table}.customer_id = {$order_stats_table_name}.customer_id" );

		$match_operator = $this->get_match_operator( $query_args );
		$where_clauses  = array();
		$having_clauses = array();

		$exact_match_params = array(
			'name',
			'username',
			'email',
			'country',
		);

		foreach ( $exact_match_params as $exact_match_param ) {
			if ( ! empty( $query_args[ $exact_match_param . '_includes' ] ) ) {
				$exact_match_arguments         = $query_args[ $exact_match_param . '_includes' ];
				$exact_match_arguments_escaped = array_map( 'esc_sql', explode( ',', $exact_match_arguments ) );
				$included                      = implode( "','", $exact_match_arguments_escaped );
				// 'country_includes' is a list of country codes, the others will be a list of customer ids.
				$table_column    = 'country' === $exact_match_param ? $exact_match_param : 'customer_id';
				$where_clauses[] = "{$customer_lookup_table}.{$table_column} IN ('{$included}')";
			}

			if ( ! empty( $query_args[ $exact_match_param . '_excludes' ] ) ) {
				$exact_match_arguments         = $query_args[ $exact_match_param . '_excludes' ];
				$exact_match_arguments_escaped = array_map( 'esc_sql', explode( ',', $exact_match_arguments ) );
				$excluded                      = implode( "','", $exact_match_arguments_escaped );
				// 'country_includes' is a list of country codes, the others will be a list of customer ids.
				$table_column    = 'country' === $exact_match_param ? $exact_match_param : 'customer_id';
				$where_clauses[] = "{$customer_lookup_table}.{$table_column} NOT IN ('{$excluded}')";
			}
		}

		$search_params = array(
			'name',
			'username',
			'email',
		);

		if ( ! empty( $query_args['search'] ) ) {
			$name_like = '%' . $wpdb->esc_like( $query_args['search'] ) . '%';

			if ( empty( $query_args['searchby'] ) || 'name' === $query_args['searchby'] || ! in_array( $query_args['searchby'], $search_params, true ) ) {
				$searchby = "CONCAT_WS( ' ', first_name, last_name )";
			} else {
				$searchby = $query_args['searchby'];
			}

			$where_clauses[] = $wpdb->prepare( "{$searchby} LIKE %s", $name_like ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		}

		// Allow a list of customer IDs to be specified.
		if ( ! empty( $query_args['customers'] ) ) {
			$included_customers = $this->get_filtered_ids( $query_args, 'customers' );
			$where_clauses[]    = "{$customer_lookup_table}.customer_id IN ({$included_customers})";
		}

		// Allow a list of user IDs to be specified.
		if ( ! empty( $query_args['users'] ) ) {
			$included_users  = $this->get_filtered_ids( $query_args, 'users' );
			$where_clauses[] = "{$customer_lookup_table}.user_id IN ({$included_users})";
		}

		$numeric_params = array(
			'orders_count'    => array(
				'column' => 'COUNT( order_id )',
				'format' => '%d',
			),
			'total_spend'     => array(
				'column' => 'SUM( total_sales )',
				'format' => '%f',
			),
			'avg_order_value' => array(
				'column' => '( SUM( total_sales ) / COUNT( order_id ) )',
				'format' => '%f',
			),
		);

		foreach ( $numeric_params as $numeric_param => $param_info ) {
			$subclauses = array();
			$min_param  = $numeric_param . '_min';
			$max_param  = $numeric_param . '_max';
			$or_equal   = isset( $query_args[ $min_param ] ) && isset( $query_args[ $max_param ] ) ? '=' : '';

			if ( isset( $query_args[ $min_param ] ) ) {
				$subclauses[] = $wpdb->prepare(
					// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
					"{$param_info['column']} >{$or_equal} {$param_info['format']}",
					$query_args[ $min_param ]
				);
			}

			if ( isset( $query_args[ $max_param ] ) ) {
				$subclauses[] = $wpdb->prepare(
					// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
					"{$param_info['column']} <{$or_equal} {$param_info['format']}",
					$query_args[ $max_param ]
				);
			}

			if ( $subclauses ) {
				$having_clauses[] = '(' . implode( ' AND ', $subclauses ) . ')';
			}
		}

		if ( $where_clauses ) {
			$preceding_match = empty( $this->get_sql_clause( 'where_time' ) ) ? ' AND ' : " {$match_operator} ";
			$this->subquery->add_sql_clause( 'where', $preceding_match . implode( " {$match_operator} ", $where_clauses ) );
		}

		$order_status_filter = $this->get_status_subquery( $query_args );
		if ( $order_status_filter ) {
			$this->subquery->add_sql_clause( 'left_join', "AND ( {$order_status_filter} )" );
		}

		if ( $having_clauses ) {
			$preceding_match = empty( $this->get_sql_clause( 'having' ) ) ? ' AND ' : " {$match_operator} ";
			$this->subquery->add_sql_clause( 'having', $preceding_match . implode( " {$match_operator} ", $having_clauses ) );
		}
	}

	/**
	 * 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;

		$customers_table_name   = self::get_db_table_name();
		$order_stats_table_name = $wpdb->prefix . 'wc_order_stats';

		// 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'      => 'date_registered',
			'order_before' => TimeInterval::default_before(),
			'order_after'  => TimeInterval::default_after(),
			'fields'       => '*',
		);
		$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 );
			$sql_query_params = $this->add_sql_query_params( $query_args );
			$count_query      = "SELECT COUNT(*) FROM (
					{$this->subquery->get_query_statement()}
				) as tt
				";
			$db_records_count = (int) $wpdb->get_var(
				$count_query // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
			);

			$params      = $this->get_limit_params( $query_args );
			$total_pages = (int) ceil( $db_records_count / $params['per_page'] );
			if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) {
				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' ) );

			$customer_data = $wpdb->get_results(
				$this->subquery->get_query_statement(), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
				ARRAY_A
			);

			if ( null === $customer_data ) {
				return $data;
			}

			$customer_data = array_map( array( $this, 'cast_numbers' ), $customer_data );
			$data          = (object) array(
				'data'    => $customer_data,
				'total'   => $db_records_count,
				'pages'   => $total_pages,
				'page_no' => (int) $query_args['page'],
			);

			$this->set_cached_data( $cache_key, $data );
		}

		return $data;
	}

	/**
	 * Returns an existing customer ID for an order if one exists.
	 *
	 * @param object $order WC Order.
	 * @return int|bool
	 */
	public static function get_existing_customer_id_from_order( $order ) {
		global $wpdb;

		if ( ! is_a( $order, 'WC_Order' ) ) {
			return false;
		}

		$user_id = $order->get_customer_id();

		if ( 0 === $user_id ) {
			$customer_id = $wpdb->get_var(
				$wpdb->prepare(
					"SELECT customer_id FROM {$wpdb->prefix}wc_order_stats WHERE order_id = %d",
					$order->get_id()
				)
			);

			if ( $customer_id ) {
				return $customer_id;
			}

			$email = $order->get_billing_email( 'edit' );

			if ( $email ) {
				return self::get_guest_id_by_email( $email );
			} else {
				return false;
			}
		} else {
			return self::get_customer_id_by_user_id( $user_id );
		}
	}

	/**
	 * Get or create a customer from a given order.
	 *
	 * @param object $order WC Order.
	 * @return int|bool
	 */
	public static function get_or_create_customer_from_order( $order ) {
		if ( ! $order ) {
			return false;
		}

		global $wpdb;

		if ( ! is_a( $order, 'WC_Order' ) ) {
			return false;
		}

		$returning_customer_id = self::get_existing_customer_id_from_order( $order );

		if ( $returning_customer_id ) {
			return $returning_customer_id;
		}

		list($data, $format) = self::get_customer_order_data_and_format( $order );

		$result      = $wpdb->insert( self::get_db_table_name(), $data, $format );
		$customer_id = $wpdb->insert_id;

		/**
		 * Fires when a new report customer is created.
		 *
		 * @param int $customer_id Customer ID.
		 * @since 4.0.0
		 */
		do_action( 'woocommerce_analytics_new_customer', $customer_id );

		return $result ? $customer_id : false;
	}

	/**
	 * Returns a data object and format object of the customers data coming from the order.
	 *
	 * @param object      $order         WC_Order where we get customer info from.
	 * @param object|null $customer_user WC_Customer registered customer WP user.
	 * @return array ($data, $format)
	 */
	public static function get_customer_order_data_and_format( $order, $customer_user = null ) {
		$data   = array(
			'first_name'       => $order->get_customer_first_name(),
			'last_name'        => $order->get_customer_last_name(),
			'email'            => $order->get_billing_email( 'edit' ),
			'city'             => $order->get_billing_city( 'edit' ),
			'state'            => $order->get_billing_state( 'edit' ),
			'postcode'         => $order->get_billing_postcode( 'edit' ),
			'country'          => $order->get_billing_country( 'edit' ),
			'date_last_active' => gmdate( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getTimestamp() ),
		);
		$format = array(
			'%s',
			'%s',
			'%s',
			'%s',
			'%s',
			'%s',
			'%s',
			'%s',
		);

		// Add registered customer data.
		if ( 0 !== $order->get_user_id() ) {
			$user_id = $order->get_user_id();
			if ( is_null( $customer_user ) ) {
				$customer_user = new \WC_Customer( $user_id );
			}
			$data['user_id']         = $user_id;
			$data['username']        = $customer_user->get_username( 'edit' );
			$data['date_registered'] = $customer_user->get_date_created( 'edit' ) ? $customer_user->get_date_created( 'edit' )->date( TimeInterval::$sql_datetime_format ) : null;
			$format[]                = '%d';
			$format[]                = '%s';
			$format[]                = '%s';
		}
		return array( $data, $format );
	}

	/**
	 * Retrieve a guest ID (when user_id is null) by email.
	 *
	 * @param string $email Email address.
	 * @return false|array Customer array if found, boolean false if not.
	 */
	public static function get_guest_id_by_email( $email ) {
		global $wpdb;

		$table_name  = self::get_db_table_name();
		$customer_id = $wpdb->get_var(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				"SELECT customer_id FROM {$table_name} WHERE email = %s AND user_id IS NULL LIMIT 1",
				$email
			)
		);

		return $customer_id ? (int) $customer_id : false;
	}

	/**
	 * Retrieve a registered customer row id by user_id.
	 *
	 * @param string|int $user_id User ID.
	 * @return false|int Customer ID if found, boolean false if not.
	 */
	public static function get_customer_id_by_user_id( $user_id ) {
		global $wpdb;

		$table_name  = self::get_db_table_name();
		$customer_id = $wpdb->get_var(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				"SELECT customer_id FROM {$table_name} WHERE user_id = %d LIMIT 1",
				$user_id
			)
		);

		return $customer_id ? (int) $customer_id : false;
	}

	/**
	 * Retrieve the last order made by a customer.
	 *
	 * @param int $customer_id Customer ID.
	 * @return object WC_Order|false.
	 */
	public static function get_last_order( $customer_id ) {
		global $wpdb;
		$orders_table = $wpdb->prefix . 'wc_order_stats';

		$last_order = $wpdb->get_var(
			$wpdb->prepare(
				// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				"SELECT order_id, date_created_gmt FROM {$orders_table}
				WHERE customer_id = %d
				ORDER BY date_created_gmt DESC, order_id DESC LIMIT 1",
				// phpcs:enable
				$customer_id
			)
		);
		if ( ! $last_order ) {
			return false;
		}
		return wc_get_order( absint( $last_order ) );
	}

	/**
	 * Retrieve the oldest orders made by a customer.
	 *
	 * @param int $customer_id Customer ID.
	 * @return array Orders.
	 */
	public static function get_oldest_orders( $customer_id ) {
		global $wpdb;
		$orders_table                = $wpdb->prefix . 'wc_order_stats';
		$excluded_statuses           = array_map( array( __CLASS__, 'normalize_order_status' ), self::get_excluded_report_order_statuses() );
		$excluded_statuses_condition = '';
		if ( ! empty( $excluded_statuses ) ) {
			$excluded_statuses_str       = implode( "','", $excluded_statuses );
			$excluded_statuses_condition = "AND status NOT IN ('{$excluded_statuses_str}')";
		}

		return $wpdb->get_results(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				"SELECT order_id, date_created FROM {$orders_table} WHERE customer_id = %d {$excluded_statuses_condition} ORDER BY date_created, order_id ASC LIMIT 2",
				$customer_id
			)
		);
	}

	/**
	 * Retrieve the amount of orders made by a customer.
	 *
	 * @param int $customer_id Customer ID.
	 * @return int|null Amount of orders for customer or null on failure.
	 */
	public static function get_order_count( $customer_id ) {
		global $wpdb;
		$customer_id = absint( $customer_id );

		if ( 0 === $customer_id ) {
			return null;
		}

		$result = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT( order_id ) FROM {$wpdb->prefix}wc_order_stats WHERE customer_id = %d",
				$customer_id
			)
		);

		if ( is_null( $result ) ) {
			return null;
		}

		return (int) $result;
	}

	/**
	 * Update the database with customer data.
	 *
	 * @param int $user_id WP User ID to update customer data for.
	 * @return int|bool|null Number or rows modified or false on failure.
	 */
	public static function update_registered_customer( $user_id ) {
		global $wpdb;

		$customer = new \WC_Customer( $user_id );

		if ( ! self::is_valid_customer( $user_id ) ) {
			return false;
		}

		$first_name = $customer->get_first_name();
		$last_name  = $customer->get_last_name();

		if ( empty( $first_name ) ) {
			$first_name = $customer->get_billing_first_name();
		}
		if ( empty( $last_name ) ) {
			$last_name = $customer->get_billing_last_name();
		}

		$last_active = $customer->get_meta( 'wc_last_active', true, 'edit' );
		$data        = array(
			'user_id'          => $user_id,
			'username'         => $customer->get_username( 'edit' ),
			'first_name'       => $first_name,
			'last_name'        => $last_name,
			'email'            => $customer->get_email( 'edit' ),
			'city'             => $customer->get_billing_city( 'edit' ),
			'state'            => $customer->get_billing_state( 'edit' ),
			'postcode'         => $customer->get_billing_postcode( 'edit' ),
			'country'          => $customer->get_billing_country( 'edit' ),
			'date_registered'  => $customer->get_date_created( 'edit' )->date( TimeInterval::$sql_datetime_format ),
			'date_last_active' => $last_active ? gmdate( 'Y-m-d H:i:s', $last_active ) : null,
		);
		$format      = array(
			'%d',
			'%s',
			'%s',
			'%s',
			'%s',
			'%s',
			'%s',
			'%s',
			'%s',
			'%s',
			'%s',
		);

		$customer_id = self::get_customer_id_by_user_id( $user_id );

		if ( $customer_id ) {
			// Preserve customer_id for existing user_id.
			$data['customer_id'] = $customer_id;
			$format[]            = '%d';
		}

		$results = $wpdb->replace( self::get_db_table_name(), $data, $format );

		/**
		 * Fires when customser's reports are updated.
		 *
		 * @param int $customer_id Customer ID.
		 * @since 4.0.0
		 */
		do_action( 'woocommerce_analytics_update_customer', $customer_id );

		ReportsCache::invalidate();

		return $results;
	}

	/**
	 * Check if a user ID is a valid customer or other user role with past orders.
	 *
	 * @param int $user_id User ID.
	 * @return bool
	 */
	protected static function is_valid_customer( $user_id ) {
		$user = new \WP_User( $user_id );

		if ( (int) $user_id !== $user->ID ) {
			return false;
		}

		/**
		 * Filter the customer roles, used to check if the user is a customer.
		 *
		 * @param array List of customer roles.
		 * @since 4.0.0
		 */
		$customer_roles = (array) apply_filters( 'woocommerce_analytics_customer_roles', array( 'customer' ) );

		if ( empty( $user->roles ) || empty( array_intersect( $user->roles, $customer_roles ) ) ) {
			return false;
		}

		return true;
	}

	/**
	 * Delete a customer lookup row.
	 *
	 * @param int $customer_id Customer ID.
	 */
	public static function delete_customer( $customer_id ) {
		global $wpdb;

		$customer_id = (int) $customer_id;
		$num_deleted = $wpdb->delete( self::get_db_table_name(), array( 'customer_id' => $customer_id ) );

		if ( $num_deleted ) {
			/**
			 * Fires when a customer is deleted.
			 *
			 * @param int $order_id Order ID.
			 * @since 4.0.0
			 */
			do_action( 'woocommerce_analytics_delete_customer', $customer_id );

			ReportsCache::invalidate();
		}
	}

	/**
	 * Delete a customer lookup row by WordPress User ID.
	 *
	 * @param int $user_id WordPress User ID.
	 */
	public static function delete_customer_by_user_id( $user_id ) {
		global $wpdb;

		$user_id     = (int) $user_id;
		$num_deleted = $wpdb->delete( self::get_db_table_name(), array( 'user_id' => $user_id ) );

		if ( $num_deleted ) {
			ReportsCache::invalidate();
		}
	}

	/**
	 * Initialize query objects.
	 */
	protected function initialize_queries() {
		$this->clear_all_clauses();
		$table_name     = self::get_db_table_name();
		$this->subquery = new SqlQuery( $this->context . '_subquery' );
		$this->subquery->add_sql_clause( 'from', $table_name );
		$this->subquery->add_sql_clause( 'select', "{$table_name}.customer_id" );
		$this->subquery->add_sql_clause( 'group_by', "{$table_name}.customer_id" );
	}
}
Reports/Customers/Controller.php000064400000063363152333372110013035 0ustar00<?php
/**
 * REST API Reports customers controller
 *
 * Handles requests to the /reports/customers endpoint.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Customers;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\ExportableTraits;
use \Automattic\WooCommerce\Admin\API\Reports\ExportableInterface;
use \Automattic\WooCommerce\Admin\API\Reports\TimeInterval;

/**
 * REST API Reports customers controller class.
 *
 * @internal
 * @extends WC_REST_Reports_Controller
 */
class Controller extends \WC_REST_Reports_Controller implements ExportableInterface {
	/**
	 * Exportable traits.
	 */
	use ExportableTraits;

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'reports/customers';

	/**
	 * Maps query arguments from the REST request.
	 *
	 * @param array $request Request array.
	 * @return array
	 */
	protected function prepare_reports_query( $request ) {
		$args                        = array();
		$args['registered_before']   = $request['registered_before'];
		$args['registered_after']    = $request['registered_after'];
		$args['order_before']        = $request['before'];
		$args['order_after']         = $request['after'];
		$args['page']                = $request['page'];
		$args['per_page']            = $request['per_page'];
		$args['order']               = $request['order'];
		$args['orderby']             = $request['orderby'];
		$args['match']               = $request['match'];
		$args['search']              = $request['search'];
		$args['searchby']            = $request['searchby'];
		$args['name_includes']       = $request['name_includes'];
		$args['name_excludes']       = $request['name_excludes'];
		$args['username_includes']   = $request['username_includes'];
		$args['username_excludes']   = $request['username_excludes'];
		$args['email_includes']      = $request['email_includes'];
		$args['email_excludes']      = $request['email_excludes'];
		$args['country_includes']    = $request['country_includes'];
		$args['country_excludes']    = $request['country_excludes'];
		$args['last_active_before']  = $request['last_active_before'];
		$args['last_active_after']   = $request['last_active_after'];
		$args['orders_count_min']    = $request['orders_count_min'];
		$args['orders_count_max']    = $request['orders_count_max'];
		$args['total_spend_min']     = $request['total_spend_min'];
		$args['total_spend_max']     = $request['total_spend_max'];
		$args['avg_order_value_min'] = $request['avg_order_value_min'];
		$args['avg_order_value_max'] = $request['avg_order_value_max'];
		$args['last_order_before']   = $request['last_order_before'];
		$args['last_order_after']    = $request['last_order_after'];
		$args['customers']           = $request['customers'];
		$args['users']               = $request['users'];
		$args['force_cache_refresh'] = $request['force_cache_refresh'];

		$between_params_numeric    = array( 'orders_count', 'total_spend', 'avg_order_value' );
		$normalized_params_numeric = TimeInterval::normalize_between_params( $request, $between_params_numeric, false );
		$between_params_date       = array( 'last_active', 'registered' );
		$normalized_params_date    = TimeInterval::normalize_between_params( $request, $between_params_date, true );
		$args                      = array_merge( $args, $normalized_params_numeric, $normalized_params_date );

		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 );
		$customers_query = new Query( $query_args );
		$report_data     = $customers_query->get_data();

		$data = array();

		foreach ( $report_data->data as $customer_data ) {
			$item   = $this->prepare_item_for_response( $customer_data, $request );
			$data[] = $this->prepare_response_for_collection( $item );
		}

		$response = rest_ensure_response( $data );
		$response->header( 'X-WP-Total', (int) $report_data->total );
		$response->header( 'X-WP-TotalPages', (int) $report_data->pages );

		$page      = $report_data->page_no;
		$max_pages = $report_data->pages;
		$base      = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) );
		if ( $page > 1 ) {
			$prev_page = $page - 1;
			if ( $prev_page > $max_pages ) {
				$prev_page = $max_pages;
			}
			$prev_link = add_query_arg( 'page', $prev_page, $base );
			$response->link_header( 'prev', $prev_link );
		}
		if ( $max_pages > $page ) {
			$next_page = $page + 1;
			$next_link = add_query_arg( 'page', $next_page, $base );
			$response->link_header( 'next', $next_link );
		}

		return $response;
	}


	/**
	 * Get one report.
	 *
	 * @param WP_REST_Request $request Request data.
	 * @return array|WP_Error
	 */
	public function get_item( $request ) {
		$query_args              = $this->prepare_reports_query( $request );
		$query_args['customers'] = array( $request->get_param( 'id' ) );
		$customers_query         = new Query( $query_args );
		$report_data             = $customers_query->get_data();

		$data = array();

		foreach ( $report_data->data as $customer_data ) {
			$item   = $this->prepare_item_for_response( $customer_data, $request );
			$data[] = $this->prepare_response_for_collection( $item );
		}

		$response = rest_ensure_response( $data );
		$response->header( 'X-WP-Total', (int) $report_data->total );
		$response->header( 'X-WP-TotalPages', (int) $report_data->pages );

		return $response;
	}

	/**
	 * 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 ) {
		$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
		$data    = $this->add_additional_fields_to_object( $report, $request );
		// Registered date is UTC.
		$data['date_registered_gmt'] = wc_rest_prepare_date_response( $data['date_registered'] );
		$data['date_registered']     = wc_rest_prepare_date_response( $data['date_registered'], false );
		// Last active date is local time.
		$data['date_last_active_gmt'] = wc_rest_prepare_date_response( $data['date_last_active'], false );
		$data['date_last_active']     = wc_rest_prepare_date_response( $data['date_last_active'] );
		$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.
		 * @since 4.0.0
		 */
		return apply_filters( 'woocommerce_rest_prepare_report_customers', $response, $report, $request );
	}

	/**
	 * Prepare links for the request.
	 *
	 * @param array $object Object data.
	 * @return array
	 */
	protected function prepare_links( $object ) {
		if ( empty( $object['user_id'] ) ) {
			return array();
		}

		return array(
			'customer'   => array(
				'href' => rest_url( sprintf( '/%s/customers/%d', $this->namespace, $object['id'] ) ),
			),
			'collection' => array(
				'href' => rest_url( sprintf( '/%s/customers', $this->namespace ) ),
			),
		);
	}

	/**
	 * 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_customers',
			'type'       => 'object',
			'properties' => array(
				'id'                   => array(
					'description' => __( 'Customer ID.', 'woocommerce' ),
					'type'        => 'integer',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'user_id'              => array(
					'description' => __( 'User ID.', 'woocommerce' ),
					'type'        => 'integer',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'name'                 => array(
					'description' => __( 'Name.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'username'             => array(
					'description' => __( 'Username.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'country'              => array(
					'description' => __( 'Country / Region.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'city'                 => array(
					'description' => __( 'City.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'state'                => array(
					'description' => __( 'Region.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'postcode'             => array(
					'description' => __( 'Postal code.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'date_registered'      => array(
					'description' => __( 'Date registered.', 'woocommerce' ),
					'type'        => 'date-time',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'date_registered_gmt'  => array(
					'description' => __( 'Date registered GMT.', 'woocommerce' ),
					'type'        => 'date-time',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'date_last_active'     => array(
					'description' => __( 'Date last active.', 'woocommerce' ),
					'type'        => 'date-time',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'date_last_active_gmt' => array(
					'description' => __( 'Date last active GMT.', 'woocommerce' ),
					'type'        => 'date-time',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'orders_count'         => array(
					'description' => __( 'Order count.', 'woocommerce' ),
					'type'        => 'integer',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'total_spend'          => array(
					'description' => __( 'Total spend.', 'woocommerce' ),
					'type'        => 'number',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'avg_order_value'      => array(
					'description' => __( 'Avg order value.', 'woocommerce' ),
					'type'        => 'number',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
			),
		);
		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['registered_before']       = array(
			'description'       => __( 'Limit response to objects registered before (or at) a given ISO8601 compliant datetime.', 'woocommerce' ),
			'type'              => 'string',
			'format'            => 'date-time',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['registered_after']        = array(
			'description'       => __( 'Limit response to objects registered after (or at) a given ISO8601 compliant datetime.', 'woocommerce' ),
			'type'              => 'string',
			'format'            => 'date-time',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['after']                   = array(
			'description'       => __( 'Limit response to resources with orders 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 with orders published before a given ISO8601 compliant date.', 'woocommerce' ),
			'type'              => 'string',
			'format'            => 'date-time',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$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['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_registered',
			'enum'              => array(
				'username',
				'name',
				'country',
				'city',
				'state',
				'postcode',
				'date_registered',
				'date_last_active',
				'orders_count',
				'total_spend',
				'avg_order_value',
			),
			'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['search']                  = array(
			'description'       => __( 'Limit response to objects with a customer field containing the search term. Searches the field provided by `searchby`.', 'woocommerce' ),
			'type'              => 'string',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['searchby']                = array(
			'description' => 'Limit results with `search` and `searchby` to specific fields containing the search term.',
			'type'        => 'string',
			'default'     => 'name',
			'enum'        => array(
				'name',
				'username',
				'email',
			),
		);
		$params['name_includes']           = array(
			'description'       => __( 'Limit response to objects with specfic names.', 'woocommerce' ),
			'type'              => 'string',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['name_excludes']           = array(
			'description'       => __( 'Limit response to objects excluding specfic names.', 'woocommerce' ),
			'type'              => 'string',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['username_includes']       = array(
			'description'       => __( 'Limit response to objects with specfic usernames.', 'woocommerce' ),
			'type'              => 'string',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['username_excludes']       = array(
			'description'       => __( 'Limit response to objects excluding specfic usernames.', 'woocommerce' ),
			'type'              => 'string',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['email_includes']          = array(
			'description'       => __( 'Limit response to objects including emails.', 'woocommerce' ),
			'type'              => 'string',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['email_excludes']          = array(
			'description'       => __( 'Limit response to objects excluding emails.', 'woocommerce' ),
			'type'              => 'string',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['country_includes']        = array(
			'description'       => __( 'Limit response to objects with specfic countries.', 'woocommerce' ),
			'type'              => 'string',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['country_excludes']        = array(
			'description'       => __( 'Limit response to objects excluding specfic countries.', 'woocommerce' ),
			'type'              => 'string',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['last_active_before']      = array(
			'description'       => __( 'Limit response to objects last active before (or at) a given ISO8601 compliant datetime.', 'woocommerce' ),
			'type'              => 'string',
			'format'            => 'date-time',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['last_active_after']       = array(
			'description'       => __( 'Limit response to objects last active after (or at) a given ISO8601 compliant datetime.', 'woocommerce' ),
			'type'              => 'string',
			'format'            => 'date-time',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['last_active_between']     = array(
			'description'       => __( 'Limit response to objects last active between two given ISO8601 compliant datetime.', 'woocommerce' ),
			'type'              => 'array',
			'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_date_arg' ),
			'items'             => array(
				'type' => 'string',
			),
		);
		$params['registered_before']       = array(
			'description'       => __( 'Limit response to objects registered before (or at) a given ISO8601 compliant datetime.', 'woocommerce' ),
			'type'              => 'string',
			'format'            => 'date-time',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['registered_after']        = array(
			'description'       => __( 'Limit response to objects registered after (or at) a given ISO8601 compliant datetime.', 'woocommerce' ),
			'type'              => 'string',
			'format'            => 'date-time',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['registered_between']      = array(
			'description'       => __( 'Limit response to objects last active between two given ISO8601 compliant datetime.', 'woocommerce' ),
			'type'              => 'array',
			'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_date_arg' ),
			'items'             => array(
				'type' => 'string',
			),
		);
		$params['orders_count_min']        = array(
			'description'       => __( 'Limit response to objects with an order count greater than or equal to given integer.', 'woocommerce' ),
			'type'              => 'integer',
			'sanitize_callback' => 'absint',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['orders_count_max']        = array(
			'description'       => __( 'Limit response to objects with an order count less than or equal to given integer.', 'woocommerce' ),
			'type'              => 'integer',
			'sanitize_callback' => 'absint',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['orders_count_between']    = array(
			'description'       => __( 'Limit response to objects with an order count between two given integers.', 'woocommerce' ),
			'type'              => 'array',
			'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_numeric_arg' ),
			'items'             => array(
				'type' => 'integer',
			),
		);
		$params['total_spend_min']         = array(
			'description'       => __( 'Limit response to objects with a total order spend greater than or equal to given number.', 'woocommerce' ),
			'type'              => 'number',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['total_spend_max']         = array(
			'description'       => __( 'Limit response to objects with a total order spend less than or equal to given number.', 'woocommerce' ),
			'type'              => 'number',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['total_spend_between']     = array(
			'description'       => __( 'Limit response to objects with a total order spend between two given numbers.', 'woocommerce' ),
			'type'              => 'array',
			'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_numeric_arg' ),
			'items'             => array(
				'type' => 'integer',
			),
		);
		$params['avg_order_value_min']     = array(
			'description'       => __( 'Limit response to objects with an average order spend greater than or equal to given number.', 'woocommerce' ),
			'type'              => 'number',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['avg_order_value_max']     = array(
			'description'       => __( 'Limit response to objects with an average order spend less than or equal to given number.', 'woocommerce' ),
			'type'              => 'number',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['avg_order_value_between'] = array(
			'description'       => __( 'Limit response to objects with an average order spend between two given numbers.', 'woocommerce' ),
			'type'              => 'array',
			'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_numeric_arg' ),
			'items'             => array(
				'type' => 'integer',
			),
		);
		$params['last_order_before']       = array(
			'description'       => __( 'Limit response to objects with last order before (or at) a given ISO8601 compliant datetime.', 'woocommerce' ),
			'type'              => 'string',
			'format'            => 'date-time',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['last_order_after']        = array(
			'description'       => __( 'Limit response to objects with last order after (or at) a given ISO8601 compliant datetime.', 'woocommerce' ),
			'type'              => 'string',
			'format'            => 'date-time',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['customers']               = array(
			'description'       => __( 'Limit result to items with specified customer ids.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'type' => 'integer',
			),
		);
		$params['users']                   = array(
			'description'       => __( 'Limit result to items with specified user ids.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'type' => 'integer',
			),
		);
		$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 the column names for export.
	 *
	 * @return array Key value pair of Column ID => Label.
	 */
	public function get_export_columns() {
		$export_columns = array(
			'name'            => __( 'Name', 'woocommerce' ),
			'username'        => __( 'Username', 'woocommerce' ),
			'last_active'     => __( 'Last Active', 'woocommerce' ),
			'registered'      => __( 'Sign Up', 'woocommerce' ),
			'email'           => __( 'Email', 'woocommerce' ),
			'orders_count'    => __( 'Orders', 'woocommerce' ),
			'total_spend'     => __( 'Total Spend', 'woocommerce' ),
			'avg_order_value' => __( 'AOV', 'woocommerce' ),
			'country'         => __( 'Country / Region', 'woocommerce' ),
			'city'            => __( 'City', 'woocommerce' ),
			'region'          => __( 'Region', 'woocommerce' ),
			'postcode'        => __( 'Postal Code', 'woocommerce' ),
		);

		/**
		 * Filter to add or remove column names from the customers report for
		 * export.
		 *
		 * @since 1.6.0
		 */
		return apply_filters(
			'woocommerce_report_customers_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(
			'name'            => $item['name'],
			'username'        => $item['username'],
			'last_active'     => $item['date_last_active'],
			'registered'      => $item['date_registered'],
			'email'           => $item['email'],
			'orders_count'    => $item['orders_count'],
			'total_spend'     => self::csv_number_format( $item['total_spend'] ),
			'avg_order_value' => self::csv_number_format( $item['avg_order_value'] ),
			'country'         => $item['country'],
			'city'            => $item['city'],
			'region'          => $item['state'],
			'postcode'        => $item['postcode'],
		);

		/**
		 * Filter the column values of an item being exported.
		 *
		 * @param object $export_item Key value pair of Column ID => Row Value.
		 * @param object $item        Single report item/row.
		 * @since 4.0.0
		 */
		return apply_filters(
			'woocommerce_report_customers_prepare_export_item',
			$export_item,
			$item
		);
	}
}
Reports/Customers/Query.php000064400000002713152333372110012007 0ustar00<?php
/**
 * Class for parameter-based Customers Report querying
 *
 * Example usage:
 * $args = array(
 *          'registered_before'   => '2018-07-19 00:00:00',
 *          'registered_after'    => '2018-07-05 00:00:00',
 *          'page'                => 2,
 *          'avg_order_value_min' => 100,
 *          'country'             => 'GB',
 *         );
 * $report = new \Automattic\WooCommerce\Admin\API\Reports\Customers\Query( $args );
 * $mydata = $report->get_data();
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Customers;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;

/**
 * API\Reports\Customers\Query
 */
class Query extends ReportsQuery {

	/**
	 * Valid fields for Customers report.
	 *
	 * @return array
	 */
	protected function get_default_query_vars() {
		return array(
			'per_page' => get_option( 'posts_per_page' ), // not sure if this should be the default.
			'page'     => 1,
			'order'    => 'DESC',
			'orderby'  => 'date_registered',
			'fields'   => '*',
		);
	}

	/**
	 * Get product data based on the current query vars.
	 *
	 * @return array
	 */
	public function get_data() {
		$args = apply_filters( 'woocommerce_analytics_customers_query_args', $this->get_query_vars() );

		$data_store = \WC_Data_Store::load( 'report-customers' );
		$results    = $data_store->get_data( $args );
		return apply_filters( 'woocommerce_analytics_customers_select_query', $results, $args );
	}
}
Reports/Taxes/DataStore.php000064400000025753152333372110011701 0ustar00<?php
/**
 * API\Reports\Taxes\DataStore class file.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Taxes;

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;

/**
 * API\Reports\Taxes\DataStore.
 */
class DataStore extends ReportsDataStore implements DataStoreInterface {

	/**
	 * Table used to get the data.
	 *
	 * @var string
	 */
	protected static $table_name = 'wc_order_tax_lookup';

	/**
	 * Cache identifier.
	 *
	 * @var string
	 */
	protected $cache_key = 'taxes';

	/**
	 * Mapping columns to data type to return correct response types.
	 *
	 * @var array
	 */
	protected $column_types = array(
		'tax_rate_id'  => 'intval',
		'name'         => 'strval',
		'tax_rate'     => 'floatval',
		'country'      => 'strval',
		'state'        => 'strval',
		'priority'     => 'intval',
		'total_tax'    => 'floatval',
		'order_tax'    => 'floatval',
		'shipping_tax' => 'floatval',
		'orders_count' => 'intval',
	);

	/**
	 * Data store context used to pass to filters.
	 *
	 * @var string
	 */
	protected $context = 'taxes';

	/**
	 * Assign report columns once full table name has been assigned.
	 */
	protected function assign_report_columns() {
		$table_name           = self::get_db_table_name();
		$this->report_columns = array(
			'tax_rate_id'  => "{$table_name}.tax_rate_id",
			'name'         => 'tax_rate_name as name',
			'tax_rate'     => 'tax_rate',
			'country'      => 'tax_rate_country as country',
			'state'        => 'tax_rate_state as state',
			'priority'     => 'tax_rate_priority as priority',
			'total_tax'    => 'SUM(total_tax) as total_tax',
			'order_tax'    => 'SUM(order_tax) as order_tax',
			'shipping_tax' => 'SUM(shipping_tax) as shipping_tax',
			'orders_count' => "COUNT( DISTINCT ( CASE WHEN total_tax >= 0 THEN {$table_name}.order_id END ) ) as orders_count",
		);
	}

	/**
	 * Set up all the hooks for maintaining and populating table data.
	 */
	public static function init() {
		add_action( 'woocommerce_analytics_delete_order_stats', array( __CLASS__, 'sync_on_order_delete' ), 15 );
	}

	/**
	 * Fills FROM clause of SQL request based on user supplied parameters.
	 *
	 * @param array  $query_args          Query arguments supplied by the user.
	 * @param string $order_status_filter Order status subquery.
	 */
	protected function add_from_sql_params( $query_args, $order_status_filter ) {
		global $wpdb;
		$table_name = self::get_db_table_name();

		if ( $order_status_filter ) {
			$this->subquery->add_sql_clause( 'join', "JOIN {$wpdb->prefix}wc_order_stats ON {$table_name}.order_id = {$wpdb->prefix}wc_order_stats.order_id" );
		}

		if ( isset( $query_args['taxes'] ) && ! empty( $query_args['taxes'] ) ) {
			$this->add_sql_clause( 'join', "JOIN {$wpdb->prefix}woocommerce_tax_rates ON default_results.tax_rate_id = {$wpdb->prefix}woocommerce_tax_rates.tax_rate_id" );
		} else {
			$this->subquery->add_sql_clause( 'join', "JOIN {$wpdb->prefix}woocommerce_tax_rates ON {$table_name}.tax_rate_id = {$wpdb->prefix}woocommerce_tax_rates.tax_rate_id" );
		}
	}

	/**
	 * Updates the database query with parameters used for Taxes report: categories and order status.
	 *
	 * @param array $query_args Query arguments supplied by the user.
	 */
	protected function add_sql_query_params( $query_args ) {
		global $wpdb;

		$order_tax_lookup_table = self::get_db_table_name();

		$this->add_time_period_sql_params( $query_args, $order_tax_lookup_table );
		$this->get_limit_sql_params( $query_args );
		$this->add_order_by_sql_params( $query_args );
		$order_status_filter = $this->get_status_subquery( $query_args );
		$this->add_from_sql_params( $query_args, $order_status_filter );

		if ( isset( $query_args['taxes'] ) && ! empty( $query_args['taxes'] ) ) {
			$allowed_taxes = self::get_filtered_ids( $query_args, 'taxes' );
			$this->subquery->add_sql_clause( 'where', "AND {$order_tax_lookup_table}.tax_rate_id IN ({$allowed_taxes})" );
		}

		if ( $order_status_filter ) {
			$this->subquery->add_sql_clause( 'where', "AND ( {$order_status_filter} )" );
		}
	}

	/**
	 * 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 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'  => 'tax_rate_id',
			'before'   => TimeInterval::default_before(),
			'after'    => TimeInterval::default_after(),
			'fields'   => '*',
			'taxes'    => 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,
			);

			$this->add_sql_query_params( $query_args );
			$params = $this->get_limit_params( $query_args );

			if ( isset( $query_args['taxes'] ) && ! empty( $query_args['taxes'] ) ) {
				$total_results = count( $query_args['taxes'] );
				$total_pages   = (int) ceil( $total_results / $params['per_page'] );

				$inner_selections = array( 'tax_rate_id', 'total_tax', 'order_tax', 'shipping_tax', 'orders_count' );
				$outer_selections = array( 'name', 'tax_rate', 'country', 'state', 'priority' );

				$selections      = $this->selected_columns( array( 'fields' => $inner_selections ) );
				$fields          = $this->get_fields( $query_args );
				$join_selections = $this->format_join_selections( $fields, array( 'tax_rate_id' ), $outer_selections );
				$ids_table       = $this->get_ids_table( $query_args['taxes'], 'tax_rate_id' );

				$this->subquery->clear_sql_clause( 'select' );
				$this->subquery->add_sql_clause( 'select', $this->selected_columns( array( 'fields' => $inner_selections ) ) );
				$this->add_sql_clause( 'select', $join_selections );
				$this->add_sql_clause( 'from', '(' );
				$this->add_sql_clause( 'from', $this->subquery->get_query_statement() );
				$this->add_sql_clause( 'from', ") AS {$table_name}" );
				$this->add_sql_clause(
					'right_join',
					"RIGHT JOIN ( {$ids_table} ) AS default_results
					ON default_results.tax_rate_id = {$table_name}.tax_rate_id"
				);

				$taxes_query = $this->get_query_statement();
			} else {
				$db_records_count = (int) $wpdb->get_var(
					"SELECT COUNT(*) FROM (
						{$this->subquery->get_query_statement()}
					) AS tt"
				); // WPCS: cache ok, DB call ok, unprepared SQL ok.

				$total_results = $db_records_count;
				$total_pages   = (int) ceil( $db_records_count / $params['per_page'] );

				if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) {
					return $data;
				}

				$this->subquery->clear_sql_clause( 'select' );
				$this->subquery->add_sql_clause( 'select', $this->selected_columns( $query_args ) );
				$this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) );
				$taxes_query = $this->subquery->get_query_statement();
			}

			$tax_data = $wpdb->get_results(
				$taxes_query,
				ARRAY_A
			); // WPCS: cache ok, DB call ok, unprepared SQL ok.

			if ( null === $tax_data ) {
				return $data;
			}

			$tax_data = array_map( array( $this, 'cast_numbers' ), $tax_data );
			$data     = (object) array(
				'data'    => $tax_data,
				'total'   => $total_results,
				'pages'   => $total_pages,
				'page_no' => (int) $query_args['page'],
			);

			$this->set_cached_data( $cache_key, $data );
		}

		return $data;
	}

	/**
	 * Maps ordering specified by the user to columns in the database/fields in the data.
	 *
	 * @param string $order_by Sorting criterion.
	 * @return string
	 */
	protected function normalize_order_by( $order_by ) {
		global $wpdb;

		if ( 'tax_code' === $order_by ) {
			return 'CONCAT_WS( "-", NULLIF(tax_rate_country, ""), NULLIF(tax_rate_state, ""), NULLIF(tax_rate_name, ""), NULLIF(tax_rate_priority, "") )';
		} elseif ( 'rate' === $order_by ) {
			return "CAST({$wpdb->prefix}woocommerce_tax_rates.tax_rate as DECIMAL(7,4))";
		}

		return $order_by;
	}

	/**
	 * Create or update an entry in the wc_order_tax_lookup table for an order.
	 *
	 * @param int $order_id Order ID.
	 * @return int|bool Returns -1 if order won't be processed, or a boolean indicating processing success.
	 */
	public static function sync_order_taxes( $order_id ) {
		global $wpdb;

		$order = wc_get_order( $order_id );
		if ( ! $order ) {
			return -1;
		}

		$tax_items   = $order->get_items( 'tax' );
		$num_updated = 0;

		foreach ( $tax_items as $tax_item ) {
			$result = $wpdb->replace(
				self::get_db_table_name(),
				array(
					'order_id'     => $order->get_id(),
					'date_created' => $order->get_date_created( 'edit' )->date( TimeInterval::$sql_datetime_format ),
					'tax_rate_id'  => $tax_item->get_rate_id(),
					'shipping_tax' => $tax_item->get_shipping_tax_total(),
					'order_tax'    => $tax_item->get_tax_total(),
					'total_tax'    => (float) $tax_item->get_tax_total() + (float) $tax_item->get_shipping_tax_total(),
				),
				array(
					'%d',
					'%s',
					'%d',
					'%f',
					'%f',
					'%f',
				)
			);

			/**
			 * Fires when tax's reports are updated.
			 *
			 * @param int $tax_rate_id Tax Rate ID.
			 * @param int $order_id    Order ID.
			 */
			do_action( 'woocommerce_analytics_update_tax', $tax_item->get_rate_id(), $order->get_id() );

			// Sum the rows affected. Using REPLACE can affect 2 rows if the row already exists.
			$num_updated += 2 === intval( $result ) ? 1 : intval( $result );
		}

		return ( count( $tax_items ) === $num_updated );
	}

	/**
	 * Clean taxes data when an order is deleted.
	 *
	 * @param int $order_id Order ID.
	 */
	public static function sync_on_order_delete( $order_id ) {
		global $wpdb;

		$wpdb->delete( self::get_db_table_name(), array( 'order_id' => $order_id ) );

		/**
		 * Fires when tax's reports are removed from database.
		 *
		 * @param int $tax_rate_id Tax Rate ID.
		 * @param int $order_id    Order ID.
		 */
		do_action( 'woocommerce_analytics_delete_tax', 0, $order_id );

		ReportsCache::invalidate();
	}

	/**
	 * Initialize query objects.
	 */
	protected function initialize_queries() {
		$this->clear_all_clauses();
		$this->subquery = new SqlQuery( $this->context . '_subquery' );
		$this->subquery->add_sql_clause( 'select', self::get_db_table_name() . '.tax_rate_id' );
		$this->subquery->add_sql_clause( 'from', self::get_db_table_name() );
		$this->subquery->add_sql_clause( 'group_by', self::get_db_table_name() . '.tax_rate_id' );
	}
}
Reports/Taxes/Query.php000064400000002246152333372110011110 0ustar00<?php
/**
 * Class for parameter-based Taxes Report querying
 *
 * Example usage:
 * $args = array(
 *          'before'       => '2018-07-19 00:00:00',
 *          'after'        => '2018-07-05 00:00:00',
 *          'page'         => 2,
 *          'taxes'        => array(1,2,3)
 *         );
 * $report = new \Automattic\WooCommerce\Admin\API\Reports\Taxes\Query( $args );
 * $mydata = $report->get_data();
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Taxes;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;

/**
 * API\Reports\Taxes\Query
 */
class Query extends ReportsQuery {

	/**
	 * Valid fields for Taxes report.
	 *
	 * @return array
	 */
	protected function get_default_query_vars() {
		return array();
	}

	/**
	 * Get product data based on the current query vars.
	 *
	 * @return array
	 */
	public function get_data() {
		$args = apply_filters( 'woocommerce_analytics_taxes_query_args', $this->get_query_vars() );

		$data_store = \WC_Data_Store::load( 'report-taxes' );
		$results    = $data_store->get_data( $args );
		return apply_filters( 'woocommerce_analytics_taxes_select_query', $results, $args );
	}
}
Reports/Taxes/Stats/Controller.php000064400000031750152333372110013226 0ustar00<?php
/**
 * REST API Reports taxes stats controller
 *
 * Handles requests to the /reports/taxes/stats endpoint.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats;

defined( 'ABSPATH' ) || exit;

/**
 * REST API Reports taxes stats controller class.
 *
 * @internal
 * @extends WC_REST_Reports_Controller
 */
class Controller extends \WC_REST_Reports_Controller {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'reports/taxes/stats';

	/**
	 * Constructor.
	 */
	public function __construct() {
		add_filter( 'woocommerce_analytics_taxes_stats_select_query', array( $this, 'set_default_report_data' ) );
	}

	/**
	 * Set the default results to 0 if API returns an empty array
	 *
	 * @internal
	 * @param Mixed $results Report data.
	 * @return object
	 */
	public function set_default_report_data( $results ) {
		if ( empty( $results ) ) {
			$results                       = new \stdClass();
			$results->total                = 0;
			$results->totals               = new \stdClass();
			$results->totals->tax_codes    = 0;
			$results->totals->total_tax    = 0;
			$results->totals->order_tax    = 0;
			$results->totals->shipping_tax = 0;
			$results->totals->orders       = 0;
			$results->intervals            = array();
			$results->pages                = 1;
			$results->page_no              = 1;
		}
		return $results;
	}

	/**
	 * 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['taxes']               = (array) $request['taxes'];
		$args['segmentby']           = $request['segmentby'];
		$args['fields']              = $request['fields'];
		$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 );
		$taxes_query = new Query( $query_args );
		$report_data = $taxes_query->get_data();

		$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( (object) $interval_data, $request );
			$out_data['intervals'][] = $this->prepare_response_for_collection( $item );
		}

		$response = rest_ensure_response( $out_data );
		$response->header( 'X-WP-Total', (int) $report_data->total );
		$response->header( 'X-WP-TotalPages', (int) $report_data->pages );

		$page      = $report_data->page_no;
		$max_pages = $report_data->pages;
		$base      = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) );
		if ( $page > 1 ) {
			$prev_page = $page - 1;
			if ( $prev_page > $max_pages ) {
				$prev_page = $max_pages;
			}
			$prev_link = add_query_arg( 'page', $prev_page, $base );
			$response->link_header( 'prev', $prev_link );
		}
		if ( $max_pages > $page ) {
			$next_page = $page + 1;
			$next_link = add_query_arg( 'page', $next_page, $base );
			$response->link_header( 'next', $next_link );
		}

		return $response;
	}

	/**
	 * 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 = get_object_vars( $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_taxes_stats', $response, $report, $request );
	}

	/**
	 * Get the Report's schema, conforming to JSON Schema.
	 *
	 * @return array
	 */
	public function get_item_schema() {
		$data_values = array(
			'total_tax'    => array(
				'description' => __( 'Total tax.', 'woocommerce' ),
				'type'        => 'number',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
				'indicator'   => true,
				'format'      => 'currency',
			),
			'order_tax'    => array(
				'description' => __( 'Order tax.', 'woocommerce' ),
				'type'        => 'number',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
				'indicator'   => true,
				'format'      => 'currency',
			),
			'shipping_tax' => array(
				'description' => __( 'Shipping tax.', 'woocommerce' ),
				'type'        => 'number',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
				'indicator'   => true,
				'format'      => 'currency',
			),
			'orders_count' => array(
				'description' => __( 'Number of orders.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
			'tax_codes'    => array(
				'description' => __( 'Amount of tax codes.', 'woocommerce' ),
				'type'        => 'integer',
				'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 );

		$schema = array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => 'report_taxes_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'  => $totals,
							),
						),
					),
				),
			),
		);

		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',
				'items_sold',
				'total_sales',
				'orders_count',
				'products_count',
			),
			'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['taxes']     = array(
			'description'       => __( 'Limit result set to all items that have the specified term assigned in the taxes taxonomy.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'type' => 'integer',
			),
		);
		$params['segmentby'] = array(
			'description'       => __( 'Segment the response by additional constraint.', 'woocommerce' ),
			'type'              => 'string',
			'enum'              => array(
				'tax_rate_id',
			),
			'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;
	}
}
Reports/Taxes/Stats/Query.php000064400000002377152333372110012213 0ustar00<?php
/**
 * Class for parameter-based Taxes Stats Report querying
 *
 * Example usage:
 * $args = array(
 *          'before'       => '2018-07-19 00:00:00',
 *          'after'        => '2018-07-05 00:00:00',
 *          'page'         => 2,
 *          'categories'   => array(15, 18),
 *          'product_ids'  => array(1,2,3)
 *         );
 * $report = new \Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats\Query( $args );
 * $mydata = $report->get_data();
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;

/**
 * API\Reports\Taxes\Stats\Query
 */
class Query extends ReportsQuery {

	/**
	 * Valid fields for Taxes report.
	 *
	 * @return array
	 */
	protected function get_default_query_vars() {
		return array();
	}

	/**
	 * Get tax stats data based on the current query vars.
	 *
	 * @return array
	 */
	public function get_data() {
		$args = apply_filters( 'woocommerce_analytics_taxes_stats_query_args', $this->get_query_vars() );

		$data_store = \WC_Data_Store::load( 'report-taxes-stats' );
		$results    = $data_store->get_data( $args );
		return apply_filters( 'woocommerce_analytics_taxes_stats_select_query', $results, $args );
	}
}
Reports/Taxes/Stats/DataStore.php000064400000023642152333372110012772 0ustar00<?php
/**
 * API\Reports\Taxes\Stats\DataStore class file.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Taxes\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;

/**
 * API\Reports\Taxes\Stats\DataStore.
 */
class DataStore extends ReportsDataStore implements DataStoreInterface {

	/**
	 * Table used to get the data.
	 *
	 * @var string
	 */
	protected static $table_name = 'wc_order_tax_lookup';

	/**
	 * Cache identifier.
	 *
	 * @var string
	 */
	protected $cache_key = 'taxes_stats';

	/**
	 * Mapping columns to data type to return correct response types.
	 *
	 * @var array
	 */
	protected $column_types = array(
		'tax_codes'    => 'intval',
		'total_tax'    => 'floatval',
		'order_tax'    => 'floatval',
		'shipping_tax' => 'floatval',
		'orders_count' => 'intval',
	);

	/**
	 * Data store context used to pass to filters.
	 *
	 * @var string
	 */
	protected $context = 'taxes_stats';

	/**
	 * Assign report columns once full table name has been assigned.
	 */
	protected function assign_report_columns() {
		$table_name           = self::get_db_table_name();
		$this->report_columns = array(
			'tax_codes'    => 'COUNT(DISTINCT tax_rate_id) as tax_codes',
			'total_tax'    => 'SUM(total_tax) AS total_tax',
			'order_tax'    => 'SUM(order_tax) as order_tax',
			'shipping_tax' => 'SUM(shipping_tax) as shipping_tax',
			'orders_count' => "COUNT( DISTINCT ( CASE WHEN parent_id = 0 THEN {$table_name}.order_id END ) ) as orders_count",
		);
	}

	/**
	 * Updates the database query with parameters used for Taxes Stats report
	 *
	 * @param array $query_args       Query arguments supplied by the user.
	 */
	protected function update_sql_query_params( $query_args ) {
		global $wpdb;

		$taxes_where_clause     = '';
		$order_tax_lookup_table = self::get_db_table_name();

		if ( isset( $query_args['taxes'] ) && ! empty( $query_args['taxes'] ) ) {
			$tax_id_placeholders = implode( ',', array_fill( 0, count( $query_args['taxes'] ), '%d' ) );
			/* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
			$taxes_where_clause .= $wpdb->prepare( " AND {$order_tax_lookup_table}.tax_rate_id IN ({$tax_id_placeholders})", $query_args['taxes'] );
			/* phpcs:enable */
		}

		$order_status_filter = $this->get_status_subquery( $query_args );
		if ( $order_status_filter ) {
			$taxes_where_clause .= " AND ( {$order_status_filter} )";
		}

		$this->add_time_period_sql_params( $query_args, $order_tax_lookup_table );
		$this->total_query->add_sql_clause( 'where', $taxes_where_clause );

		$this->add_intervals_sql_params( $query_args, $order_tax_lookup_table );
		$this->interval_query->add_sql_clause( 'where', $taxes_where_clause );
		$this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) . ' AS time_interval' );
		$this->interval_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) );
	}

	/**
	 * Get taxes associated with a store.
	 *
	 * @param array $args Array of args to filter the query by. Supports `include`.
	 * @return array An array of all taxes.
	 */
	public static function get_taxes( $args ) {
		global $wpdb;
		$query = "
			SELECT 
				tax_rate_id, 
				tax_rate_country, 
				tax_rate_state, 
				tax_rate_name, 
				tax_rate_priority 
			FROM {$wpdb->prefix}woocommerce_tax_rates
		";
		if ( ! empty( $args['include'] ) ) {
			/* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
			$tax_placeholders = implode( ',', array_fill( 0, count( $args['include'] ), '%d' ) );
			$query           .= $wpdb->prepare( " WHERE tax_rate_id IN ({$tax_placeholders})", $args['include'] );
			/* phpcs:enable */
		}
		return $wpdb->get_results( $query, ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok.
	}

	/**
	 * 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 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'  => 'tax_rate_id',
			'before'   => TimeInterval::default_before(),
			'after'    => TimeInterval::default_after(),
			'fields'   => '*',
			'taxes'    => 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 );
			$params           = $this->get_limit_params( $query_args );
			$order_stats_join = "JOIN {$wpdb->prefix}wc_order_stats ON {$table_name}.order_id = {$wpdb->prefix}wc_order_stats.order_id";
			$this->update_sql_query_params( $query_args );
			$this->interval_query->add_sql_clause( 'join', $order_stats_join );

			$db_intervals            = $wpdb->get_col(
				$this->interval_query->get_query_statement()
			); // WPCS: 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->total_query->add_sql_clause( 'select', $selections );
			$this->total_query->add_sql_clause( 'join', $order_stats_join );
			$this->total_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) );

			$totals = $wpdb->get_results(
				$this->total_query->get_query_statement(),
				ARRAY_A
			); // WPCS: cache ok, DB call ok, unprepared SQL ok.

			if ( null === $totals ) {
				return new \WP_Error( 'woocommerce_analytics_taxes_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) );
			}

			// @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' => $this->total_query->get_sql_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' => $this->interval_query->get_sql_clause( 'where_time' ),
				'where_clause'      => $this->interval_query->get_sql_clause( 'where' ),
			);
			$segmenter             = new Segmenter( $query_args, $this->report_columns );
			$totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name );

			$this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name );

			if ( '' !== $selections ) {
				$this->interval_query->add_sql_clause( 'select', ', ' . $selections );
			}

			$this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" );
			$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' ) );

			$intervals = $wpdb->get_results(
				$this->interval_query->get_query_statement(),
				ARRAY_A
			); // WPCS: cache ok, DB call ok, unprepared SQL ok.

			if ( null === $intervals ) {
				return new \WP_Error( 'woocommerce_analytics_taxes_stats_result_failed', __( 'Sorry, fetching tax data failed.', 'woocommerce' ) );
			}

			$totals = (object) $this->cast_numbers( $totals[0] );

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

	/**
	 * 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' );
	}
}
Reports/Taxes/Stats/Segmenter.php000064400000013032152333372110013025 0ustar00<?php
/**
 * Class for adding segmenting support without cluttering the data stores.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\Segmenter as ReportsSegmenter;

/**
 * Date & time interval and numeric range handling class for Reporting API.
 */
class Segmenter extends ReportsSegmenter {

	/**
	 * Returns column => query mapping to be used for order-related order-level segmenting query (e.g. tax_rate_id).
	 *
	 * @param string $lookup_table Name of SQL table containing the order-level segmenting info.
	 *
	 * @return array Column => SELECT query mapping.
	 */
	protected function get_segment_selections_order_level( $lookup_table ) {
		$columns_mapping = array(
			'tax_codes'    => "COUNT(DISTINCT $lookup_table.tax_rate_id) as tax_codes",
			'total_tax'    => "SUM($lookup_table.total_tax) AS total_tax",
			'order_tax'    => "SUM($lookup_table.order_tax) as order_tax",
			'shipping_tax' => "SUM($lookup_table.shipping_tax) as shipping_tax",
			'orders_count' => "COUNT(DISTINCT $lookup_table.order_id) as orders_count",
		);

		return $columns_mapping;
	}

	/**
	 * 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
		); // WPCS: 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
		); // WPCS: 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 ) {
		if ( ! isset( $this->query_args['segmentby'] ) || '' === $this->query_args['segmentby'] ) {
			return array();
		}

		$segmenting_where = '';
		$segmenting_from  = '';
		$segments         = array();

		if ( 'tax_rate_id' === $this->query_args['segmentby'] ) {
			$tax_rate_level_columns = $this->get_segment_selections_order_level( $table_name );
			$segmenting_select      = $this->prepare_selections( $tax_rate_level_columns );
			$this->report_columns   = $tax_rate_level_columns;
			$segmenting_groupby     = $table_name . '.tax_rate_id';

			$segments = $this->get_order_related_segments( $type, $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params );
		}

		return $segments;
	}
}
Reports/Taxes/Controller.php000064400000024420152333372110012124 0ustar00<?php
/**
 * REST API Reports taxes controller
 *
 * Handles requests to the /reports/taxes endpoint.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Taxes;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\ExportableInterface;
use \Automattic\WooCommerce\Admin\API\Reports\ExportableTraits;

/**
 * REST API Reports taxes controller class.
 *
 * @internal
 * @extends WC_REST_Reports_Controller
 */
class Controller extends \WC_REST_Reports_Controller implements ExportableInterface {
	/**
	 * Exportable traits.
	 */
	use ExportableTraits;

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'reports/taxes';

	/**
	 * 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['taxes']               = $request['taxes'];
		$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 );
		$taxes_query = new Query( $query_args );
		$report_data = $taxes_query->get_data();

		$data = array();

		foreach ( $report_data->data as $tax_data ) {
			$item   = $this->prepare_item_for_response( (object) $tax_data, $request );
			$data[] = $this->prepare_response_for_collection( $item );
		}

		$response = rest_ensure_response( $data );
		$response->header( 'X-WP-Total', (int) $report_data->total );
		$response->header( 'X-WP-TotalPages', (int) $report_data->pages );

		$page      = $report_data->page_no;
		$max_pages = $report_data->pages;
		$base      = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) );
		if ( $page > 1 ) {
			$prev_page = $page - 1;
			if ( $prev_page > $max_pages ) {
				$prev_page = $max_pages;
			}
			$prev_link = add_query_arg( 'page', $prev_page, $base );
			$response->link_header( 'prev', $prev_link );
		}
		if ( $max_pages > $page ) {
			$next_page = $page + 1;
			$next_link = add_query_arg( 'page', $next_page, $base );
			$response->link_header( 'next', $next_link );
		}

		return $response;
	}

	/**
	 * 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 ) {
		$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
		$report  = $this->add_additional_fields_to_object( $report, $request );
		$report  = $this->filter_response_by_context( $report, $context );

		// Wrap the data in a response object.
		$response = rest_ensure_response( $report );
		$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_taxes', $response, $report, $request );
	}

	/**
	 * Prepare links for the request.
	 *
	 * @param WC_Reports_Query $object Object data.
	 * @return array
	 */
	protected function prepare_links( $object ) {
		$links = array(
			'tax' => array(
				'href' => rest_url( sprintf( '/%s/taxes/%d', $this->namespace, $object->tax_rate_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_taxes',
			'type'       => 'object',
			'properties' => array(
				'tax_rate_id'  => array(
					'description' => __( 'Tax rate ID.', 'woocommerce' ),
					'type'        => 'integer',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'name'         => array(
					'description' => __( 'Tax rate name.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'tax_rate'     => array(
					'description' => __( 'Tax rate.', 'woocommerce' ),
					'type'        => 'number',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'country'      => array(
					'description' => __( 'Country / Region.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'state'        => array(
					'description' => __( 'State.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'priority'     => array(
					'description' => __( 'Priority.', 'woocommerce' ),
					'type'        => 'integer',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'total_tax'    => array(
					'description' => __( 'Total tax.', 'woocommerce' ),
					'type'        => 'number',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'order_tax'    => array(
					'description' => __( 'Order tax.', 'woocommerce' ),
					'type'        => 'number',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'shipping_tax' => array(
					'description' => __( 'Shipping tax.', 'woocommerce' ),
					'type'        => 'number',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'orders_count' => array(
					'description' => __( 'Number of orders.', 'woocommerce' ),
					'type'        => 'integer',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
			),
		);

		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'           => 'tax_rate_id',
			'enum'              => array(
				'name',
				'tax_rate_id',
				'tax_code',
				'rate',
				'order_tax',
				'total_tax',
				'shipping_tax',
				'orders_count',
			),
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['taxes']    = array(
			'description'       => __( 'Limit result set to items assigned one or more tax rates.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_id_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;
	}

	/**
	 * Get the column names for export.
	 *
	 * @return array Key value pair of Column ID => Label.
	 */
	public function get_export_columns() {
		return array(
			'tax_code'     => __( 'Tax code', 'woocommerce' ),
			'rate'         => __( 'Rate', 'woocommerce' ),
			'total_tax'    => __( 'Total tax', 'woocommerce' ),
			'order_tax'    => __( 'Order tax', 'woocommerce' ),
			'shipping_tax' => __( 'Shipping tax', 'woocommerce' ),
			'orders_count' => __( 'Orders', 'woocommerce' ),
		);
	}

	/**
	 * 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 ) {
		return array(
			'tax_code'     => \WC_Tax::get_rate_code( $item['tax_rate_id'] ),
			'rate'         => $item['tax_rate'],
			'total_tax'    => self::csv_number_format( $item['total_tax'] ),
			'order_tax'    => self::csv_number_format( $item['order_tax'] ),
			'shipping_tax' => self::csv_number_format( $item['shipping_tax'] ),
			'orders_count' => $item['orders_count'],
		);
	}
}
Reports/Controller.php000064400000024217152333372110011044 0ustar00<?php
/**
 * REST API Reports controller extended by WC Admin plugin.
 *
 * Handles requests to the reports endpoint.
 */

namespace Automattic\WooCommerce\Admin\API\Reports;

defined( 'ABSPATH' ) || exit;

/**
 * REST API Reports controller class.
 *
 * @internal
 * @extends WC_REST_Reports_Controller
 */
class Controller extends \WC_REST_Reports_Controller {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'reports';

	/**
	 * Register the routes for reports.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_items' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
					'args'                => $this->get_collection_params(),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);
	}

	/**
	 * Check whether a given request has permission to read reports.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|boolean
	 */
	public function get_items_permissions_check( $request ) {
		if ( ! wc_rest_check_manager_permissions( 'reports', 'read' ) ) {
			return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
		}

		return true;
	}


	/**
	 * Get all reports.
	 *
	 * @param WP_REST_Request $request Request data.
	 * @return array|WP_Error
	 */
	public function get_items( $request ) {
		$data    = array();
		$reports = array(
			array(
				'slug'        => 'performance-indicators',
				'description' => __( 'Batch endpoint for getting specific performance indicators from `stats` endpoints.', 'woocommerce' ),
			),
			array(
				'slug'        => 'revenue/stats',
				'description' => __( 'Stats about revenue.', 'woocommerce' ),
			),
			array(
				'slug'        => 'orders/stats',
				'description' => __( 'Stats about orders.', 'woocommerce' ),
			),
			array(
				'slug'        => 'products',
				'description' => __( 'Products detailed reports.', 'woocommerce' ),
			),
			array(
				'slug'        => 'products/stats',
				'description' => __( 'Stats about products.', 'woocommerce' ),
			),
			array(
				'slug'        => 'variations',
				'description' => __( 'Variations detailed reports.', 'woocommerce' ),
			),
			array(
				'slug'        => 'variations/stats',
				'description' => __( 'Stats about variations.', 'woocommerce' ),
			),
			array(
				'slug'        => 'categories',
				'description' => __( 'Product categories detailed reports.', 'woocommerce' ),
			),
			array(
				'slug'        => 'categories/stats',
				'description' => __( 'Stats about product categories.', 'woocommerce' ),
			),
			array(
				'slug'        => 'coupons',
				'description' => __( 'Coupons detailed reports.', 'woocommerce' ),
			),
			array(
				'slug'        => 'coupons/stats',
				'description' => __( 'Stats about coupons.', 'woocommerce' ),
			),
			array(
				'slug'        => 'taxes',
				'description' => __( 'Taxes detailed reports.', 'woocommerce' ),
			),
			array(
				'slug'        => 'taxes/stats',
				'description' => __( 'Stats about taxes.', 'woocommerce' ),
			),
			array(
				'slug'        => 'downloads',
				'description' => __( 'Product downloads detailed reports.', 'woocommerce' ),
			),
			array(
				'slug'        => 'downloads/files',
				'description' => __( 'Product download files detailed reports.', 'woocommerce' ),
			),
			array(
				'slug'        => 'downloads/stats',
				'description' => __( 'Stats about product downloads.', 'woocommerce' ),
			),
			array(
				'slug'        => 'customers',
				'description' => __( 'Customers detailed reports.', 'woocommerce' ),
			),
		);

		/**
		 * Filter the list of allowed reports, so that data can be loaded from third party extensions in addition to WooCommerce core.
		 * Array items should be in format of array( 'slug' => 'downloads/stats', 'description' =>  '',
		 * 'url' => '', and 'path' => '/wc-ext/v1/...'.
		 *
		 * @param array $endpoints The list of allowed reports..
		 */
		$reports = apply_filters( 'woocommerce_admin_reports', $reports );

		foreach ( $reports as $report ) {
			if ( empty( $report['slug'] ) ) {
				continue;
			}

			if ( empty( $report['path'] ) ) {
				$report['path'] = '/' . $this->namespace . '/reports/' . $report['slug'];
			}

			// Allows a different admin page to be loaded here,
			// or allows an empty url if no report exists for a set of performance indicators.
			if ( ! isset( $report['url'] ) ) {
				if ( '/stats' === substr( $report['slug'], -6 ) ) {
					$url_slug = substr( $report['slug'], 0, -6 );
				} else {
					$url_slug = $report['slug'];
				}

				$report['url'] = '/analytics/' . $url_slug;
			}

			$item   = $this->prepare_item_for_response( (object) $report, $request );
			$data[] = $this->prepare_response_for_collection( $item );
		}

		return rest_ensure_response( $data );
	}

	/**
	 * Get the order number for an order. If no filter is present for `woocommerce_order_number`, we can just return the ID.
	 * Returns the parent order number if the order is actually a refund.
	 *
	 * @param  int $order_id Order ID.
	 * @return string
	 */
	protected function get_order_number( $order_id ) {
		$order = wc_get_order( $order_id );

		if ( ! $order instanceof \WC_Order && ! $order instanceof \WC_Order_Refund ) {
			return null;
		}

		if ( 'shop_order_refund' === $order->get_type() ) {
			$order = wc_get_order( $order->get_parent_id() );
		}

		if ( ! has_filter( 'woocommerce_order_number' ) ) {
			return $order->get_id();
		}

		return $order->get_order_number();
	}

	/**
	 * Get the order total with the related currency formatting.
	 * Returns the parent order total if the order is actually a refund.
	 *
	 * @param  int $order_id Order ID.
	 * @return string
	 */
	protected function get_total_formatted( $order_id ) {
		$order = wc_get_order( $order_id );

		if ( ! $order instanceof \WC_Order && ! $order instanceof \WC_Order_Refund ) {
			return null;
		}

		if ( 'shop_order_refund' === $order->get_type() ) {
			$order = wc_get_order( $order->get_parent_id() );
		}

		return wp_strip_all_tags( html_entity_decode( $order->get_formatted_order_total() ), true );
	}

	/**
	 * 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 = array(
			'slug'        => $report->slug,
			'description' => $report->description,
			'path'        => $report->path,
		);

		$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(
			array(
				'self'       => array(
					'href' => rest_url( $report->path ),
				),
				'report'     => array(
					'href' => $report->url,
				),
				'collection' => array(
					'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ),
				),
			)
		);

		/**
		 * 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', $response, $report, $request );
	}

	/**
	 * 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',
			'type'       => 'object',
			'properties' => array(
				'slug'        => array(
					'description' => __( 'An alphanumeric identifier for the resource.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view' ),
					'readonly'    => true,
				),
				'description' => array(
					'description' => __( 'A human-readable description of the resource.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view' ),
					'readonly'    => true,
				),
				'path'        => array(
					'description' => __( 'API path.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view' ),
					'readonly'    => true,
				),
			),
		);

		return $this->add_additional_fields_schema( $schema );
	}

	/**
	 * Get the query params for collections.
	 *
	 * @return array
	 */
	public function get_collection_params() {
		return array(
			'context' => $this->get_context_param( array( 'default' => 'view' ) ),
		);
	}

	/**
	 * Get order statuses without prefixes.
	 * Includes unregistered statuses that have been marked "actionable".
	 *
	 * @internal
	 * @return array
	 */
	public static function get_order_statuses() {
		// Allow all statuses selected as "actionable" - this may include unregistered statuses.
		// See: https://github.com/woocommerce/woocommerce-admin/issues/5592.
		$actionable_statuses = get_option( 'woocommerce_actionable_order_statuses', array() );

		// See WC_REST_Orders_V2_Controller::get_collection_params() re: any/trash statuses.
		$registered_statuses = array_merge( array( 'any', 'trash' ), array_keys( self::get_order_status_labels() ) );

		// Merge the status arrays (using flip to avoid array_unique()).
		$allowed_statuses = array_keys( array_merge( array_flip( $registered_statuses ), array_flip( $actionable_statuses ) ) );

		return $allowed_statuses;
	}

	/**
	 * Get order statuses (and labels) without prefixes.
	 *
	 * @internal
	 * @return array
	 */
	public static function get_order_status_labels() {
		$order_statuses = array();

		foreach ( wc_get_order_statuses() as $key => $label ) {
			$new_key                    = str_replace( 'wc-', '', $key );
			$order_statuses[ $new_key ] = $label;
		}

		return $order_statuses;
	}
}
Reports/TimeInterval.php000064400000056655152333372110011337 0ustar00<?php
/**
 * Class for time interval and numeric range handling for reports.
 */

namespace Automattic\WooCommerce\Admin\API\Reports;

defined( 'ABSPATH' ) || exit;

/**
 * Date & time interval and numeric range handling class for Reporting API.
 */
class TimeInterval {

	/**
	 * Format string for ISO DateTime formatter.
	 *
	 * @var string
	 */
	public static $iso_datetime_format = 'Y-m-d\TH:i:s';

	/**
	 * Format string for use in SQL queries.
	 *
	 * @var string
	 */
	public static $sql_datetime_format = 'Y-m-d H:i:s';

	/**
	 * Converts local datetime to GMT/UTC time.
	 *
	 * @param string $datetime_string String representation of local datetime.
	 * @return DateTime
	 */
	public static function convert_local_datetime_to_gmt( $datetime_string ) {
		$datetime = new \DateTime( $datetime_string, new \DateTimeZone( wc_timezone_string() ) );
		$datetime->setTimezone( new \DateTimeZone( 'GMT' ) );
		return $datetime;
	}

	/**
	 * Returns default 'before' parameter for the reports.
	 *
	 * @return DateTime
	 */
	public static function default_before() {
		$datetime = new \WC_DateTime();
		// Set local timezone or offset.
		if ( get_option( 'timezone_string' ) ) {
			$datetime->setTimezone( new \DateTimeZone( wc_timezone_string() ) );
		} else {
			$datetime->set_utc_offset( wc_timezone_offset() );
		}
		return $datetime;
	}

	/**
	 * Returns default 'after' parameter for the reports.
	 *
	 * @return DateTime
	 */
	public static function default_after() {
		$now       = time();
		$week_back = $now - WEEK_IN_SECONDS;

		$datetime = new \WC_DateTime();
		$datetime->setTimestamp( $week_back );
		// Set local timezone or offset.
		if ( get_option( 'timezone_string' ) ) {
			$datetime->setTimezone( new \DateTimeZone( wc_timezone_string() ) );
		} else {
			$datetime->set_utc_offset( wc_timezone_offset() );
		}
		return $datetime;
	}

	/**
	 * Returns date format to be used as grouping clause in SQL.
	 *
	 * @param string $time_interval Time interval.
	 * @param string $table_name Name of the db table relevant for the date constraint.
	 * @param string $date_column_name Name of the date table column.
	 * @return mixed
	 */
	public static function db_datetime_format( $time_interval, $table_name, $date_column_name = 'date_created' ) {
		$first_day_of_week = absint( get_option( 'start_of_week' ) );

		if ( 1 === $first_day_of_week ) {
			// Week begins on Monday, ISO 8601.
			$week_format = "DATE_FORMAT({$table_name}.`{$date_column_name}`, '%x-%v')";
		} else {
			// Week begins on day other than specified by ISO 8601, needs to be in sync with function simple_week_number.
			$week_format = "CONCAT(YEAR({$table_name}.`{$date_column_name}`), '-', LPAD( FLOOR( ( DAYOFYEAR({$table_name}.`{$date_column_name}`) + ( ( DATE_FORMAT(MAKEDATE(YEAR({$table_name}.`{$date_column_name}`),1), '%w') - $first_day_of_week + 7 ) % 7 ) - 1 ) / 7  ) + 1 , 2, '0'))";

		}

		// Whenever this is changed, double check method time_interval_id to make sure they are in sync.
		$mysql_date_format_mapping = array(
			'hour'    => "DATE_FORMAT({$table_name}.`{$date_column_name}`, '%Y-%m-%d %H')",
			'day'     => "DATE_FORMAT({$table_name}.`{$date_column_name}`, '%Y-%m-%d')",
			'week'    => $week_format,
			'month'   => "DATE_FORMAT({$table_name}.`{$date_column_name}`, '%Y-%m')",
			'quarter' => "CONCAT(YEAR({$table_name}.`{$date_column_name}`), '-', QUARTER({$table_name}.`{$date_column_name}`))",
			'year'    => "YEAR({$table_name}.`{$date_column_name}`)",

		);

		return $mysql_date_format_mapping[ $time_interval ];
	}

	/**
	 * Returns quarter for the DateTime.
	 *
	 * @param DateTime $datetime Local date & time.
	 * @return int|null
	 */
	public static function quarter( $datetime ) {
		switch ( (int) $datetime->format( 'm' ) ) {
			case 1:
			case 2:
			case 3:
				return 1;
			case 4:
			case 5:
			case 6:
				return 2;
			case 7:
			case 8:
			case 9:
				return 3;
			case 10:
			case 11:
			case 12:
				return 4;

		}
		return null;
	}

	/**
	 * Returns simple week number for the DateTime, for week starting on $first_day_of_week.
	 *
	 * The first week of the year is considered to be the week containing January 1.
	 * The second week starts on the next $first_day_of_week.
	 *
	 * @param DateTime $datetime          Local date for which the week number is to be calculated.
	 * @param int      $first_day_of_week 0 for Sunday to 6 for Saturday.
	 * @return int
	 */
	public static function simple_week_number( $datetime, $first_day_of_week ) {
		$beg_of_year_day          = new \DateTime( "{$datetime->format('Y')}-01-01" );
		$adj_day_beg_of_year      = ( (int) $beg_of_year_day->format( 'w' ) - $first_day_of_week + 7 ) % 7;
		$days_since_start_of_year = (int) $datetime->format( 'z' ) + 1;

		return (int) floor( ( ( $days_since_start_of_year + $adj_day_beg_of_year - 1 ) / 7 ) ) + 1;
	}

	/**
	 * Returns ISO 8601 week number for the DateTime, if week starts on Monday,
	 * otherwise returns simple week number.
	 *
	 * @see TimeInterval::simple_week_number()
	 *
	 * @param DateTime $datetime          Local date for which the week number is to be calculated.
	 * @param int      $first_day_of_week 0 for Sunday to 6 for Saturday.
	 * @return int
	 */
	public static function week_number( $datetime, $first_day_of_week ) {
		if ( 1 === $first_day_of_week ) {
			$week_number = (int) $datetime->format( 'W' );
		} else {
			$week_number = self::simple_week_number( $datetime, $first_day_of_week );
		}
		return $week_number;
	}

	/**
	 * Returns time interval id for the DateTime.
	 *
	 * @param string   $time_interval Time interval type (week, day, etc).
	 * @param DateTime $datetime      Date & time.
	 * @return string
	 */
	public static function time_interval_id( $time_interval, $datetime ) {
		// Whenever this is changed, double check method db_datetime_format to make sure they are in sync.
		$php_time_format_for = array(
			'hour'    => 'Y-m-d H',
			'day'     => 'Y-m-d',
			'week'    => 'o-W',
			'month'   => 'Y-m',
			'quarter' => 'Y-' . self::quarter( $datetime ),
			'year'    => 'Y',
		);

		// If the week does not begin on Monday.
		$first_day_of_week = absint( get_option( 'start_of_week' ) );

		if ( 'week' === $time_interval && 1 !== $first_day_of_week ) {
			$week_no = self::simple_week_number( $datetime, $first_day_of_week );
			$week_no = str_pad( $week_no, 2, '0', STR_PAD_LEFT );
			$year_no = $datetime->format( 'Y' );
			return "$year_no-$week_no";
		}

		return $datetime->format( $php_time_format_for[ $time_interval ] );
	}

	/**
	 * Calculates number of time intervals between two dates, closed interval on both sides.
	 *
	 * @param DateTime $start_datetime Start date & time.
	 * @param DateTime $end_datetime End date & time.
	 * @param string   $interval Time interval increment, e.g. hour, day, week.
	 *
	 * @return int
	 */
	public static function intervals_between( $start_datetime, $end_datetime, $interval ) {
		switch ( $interval ) {
			case 'hour':
				$end_timestamp   = (int) $end_datetime->format( 'U' );
				$start_timestamp = (int) $start_datetime->format( 'U' );
				$addendum        = 0;
				// modulo HOUR_IN_SECONDS would normally work, but there are non-full hour timezones, e.g. Nepal.
				$start_min_sec = (int) $start_datetime->format( 'i' ) * MINUTE_IN_SECONDS + (int) $start_datetime->format( 's' );
				$end_min_sec   = (int) $end_datetime->format( 'i' ) * MINUTE_IN_SECONDS + (int) $end_datetime->format( 's' );
				if ( $end_min_sec < $start_min_sec ) {
					$addendum = 1;
				}
				$diff_timestamp = $end_timestamp - $start_timestamp;

				return (int) floor( ( (int) $diff_timestamp ) / HOUR_IN_SECONDS ) + 1 + $addendum;
			case 'day':
				$days               = $start_datetime->diff( $end_datetime )->format( '%r%a' );
				$end_hour_min_sec   = (int) $end_datetime->format( 'H' ) * HOUR_IN_SECONDS + (int) $end_datetime->format( 'i' ) * MINUTE_IN_SECONDS + (int) $end_datetime->format( 's' );
				$start_hour_min_sec = (int) $start_datetime->format( 'H' ) * HOUR_IN_SECONDS + (int) $start_datetime->format( 'i' ) * MINUTE_IN_SECONDS + (int) $start_datetime->format( 's' );
				if ( $end_hour_min_sec < $start_hour_min_sec ) {
					$days++;
				}

				return $days + 1;
			case 'week':
				// @todo Optimize? approximately day count / 7, but year end is tricky, a week can have fewer days.
				$week_count = 0;
				do {
					$start_datetime = self::next_week_start( $start_datetime );
					$week_count++;
				} while ( $start_datetime <= $end_datetime );
				return $week_count;
			case 'month':
				// Year diff in months: (end_year - start_year - 1) * 12.
				$year_diff_in_months = ( (int) $end_datetime->format( 'Y' ) - (int) $start_datetime->format( 'Y' ) - 1 ) * 12;
				// All the months in end_date year plus months from X to 12 in the start_date year.
				$month_diff = (int) $end_datetime->format( 'n' ) + ( 12 - (int) $start_datetime->format( 'n' ) );
				// Add months for number of years between end_date and start_date.
				$month_diff += $year_diff_in_months + 1;
				return $month_diff;
			case 'quarter':
				// Year diff in quarters: (end_year - start_year - 1) * 4.
				$year_diff_in_quarters = ( (int) $end_datetime->format( 'Y' ) - (int) $start_datetime->format( 'Y' ) - 1 ) * 4;
				// All the quarters in end_date year plus quarters from X to 4 in the start_date year.
				$quarter_diff = self::quarter( $end_datetime ) + ( 4 - self::quarter( $start_datetime ) );
				// Add quarters for number of years between end_date and start_date.
				$quarter_diff += $year_diff_in_quarters + 1;
				return $quarter_diff;
			case 'year':
				$year_diff = (int) $end_datetime->format( 'Y' ) - (int) $start_datetime->format( 'Y' );
				return $year_diff + 1;
		}
		return 0;
	}

	/**
	 * Returns a new DateTime object representing the next hour start/previous hour end if reversed.
	 *
	 * @param DateTime $datetime Date and time.
	 * @param bool     $reversed Going backwards in time instead of forward.
	 * @return DateTime
	 */
	public static function next_hour_start( $datetime, $reversed = false ) {
		$hour_increment         = $reversed ? 0 : 1;
		$timestamp              = (int) $datetime->format( 'U' );
		$seconds_into_hour      = (int) $datetime->format( 'i' ) * MINUTE_IN_SECONDS + (int) $datetime->format( 's' );
		$hours_offset_timestamp = $timestamp + ( $hour_increment * HOUR_IN_SECONDS - $seconds_into_hour );

		if ( $reversed ) {
			$hours_offset_timestamp --;
		}

		$hours_offset_time = new \DateTime();
		$hours_offset_time->setTimestamp( $hours_offset_timestamp );
		$hours_offset_time->setTimezone( new \DateTimeZone( wc_timezone_string() ) );
		return $hours_offset_time;
	}

	/**
	 * Returns a new DateTime object representing the next day start, or previous day end if reversed.
	 *
	 * @param DateTime $datetime Date and time.
	 * @param bool     $reversed Going backwards in time instead of forward.
	 * @return DateTime
	 */
	public static function next_day_start( $datetime, $reversed = false ) {
		$oneday       = new \DateInterval( 'P1D' );
		$new_datetime = clone $datetime;

		if ( $reversed ) {
			$new_datetime->sub( $oneday );
			$new_datetime->setTime( 23, 59, 59 );
		} else {
			$new_datetime->add( $oneday );
			$new_datetime->setTime( 0, 0, 0 );
		}

		return $new_datetime;
	}

	/**
	 * Returns DateTime object representing the next week start, or previous week end if reversed.
	 *
	 * The next week start is the first day of the next week at 00:00:00.
	 * The previous week end is the last day of the previous week at 23:59:59.
	 * The start day is determined by the "start_of_week" wp_option.
	 *
	 * @param DateTime $datetime Date and time.
	 * @param bool     $reversed Going backwards in time instead of forward.
	 * @return DateTime
	 */
	public static function next_week_start( $datetime, $reversed = false ) {
		$seven_days = new \DateInterval( 'P7D' );
		// Default timezone set in wp-settings.php.
		$default_timezone = date_default_timezone_get();
		// Timezone that the WP site uses in Settings > General.
		$original_timezone = $datetime->getTimezone();
		// @codingStandardsIgnoreStart
		date_default_timezone_set( 'UTC' );
		$start_end_timestamp  = get_weekstartend( $datetime->format( 'Y-m-d' ) );
		date_default_timezone_set( $default_timezone );
		// @codingStandardsIgnoreEnd
		if ( $reversed ) {
			$result = \DateTime::createFromFormat( 'U', $start_end_timestamp['end'] )->sub( $seven_days );
		} else {
			$result = \DateTime::createFromFormat( 'U', $start_end_timestamp['start'] )->add( $seven_days );
		}
		return \DateTime::createFromFormat( 'Y-m-d H:i:s', $result->format( 'Y-m-d H:i:s' ), $original_timezone );
	}


	/**
	 * Returns a new DateTime object representing the next month start, or previous month end if reversed.
	 *
	 * @param DateTime $datetime Date and time.
	 * @param bool     $reversed Going backwards in time instead of forward.
	 * @return DateTime
	 */
	public static function next_month_start( $datetime, $reversed = false ) {
		$month_increment = 1;
		$year            = $datetime->format( 'Y' );
		$month           = (int) $datetime->format( 'm' );

		if ( $reversed ) {
			$beg_of_month_datetime       = new \DateTime( "$year-$month-01 00:00:00", new \DateTimeZone( wc_timezone_string() ) );
			$timestamp                   = (int) $beg_of_month_datetime->format( 'U' );
			$end_of_prev_month_timestamp = $timestamp - 1;
			$datetime->setTimestamp( $end_of_prev_month_timestamp );
		} else {
			$month += $month_increment;
			if ( $month > 12 ) {
				$month = 1;
				$year ++;
			}
			$day      = '01';
			$datetime = new \DateTime( "$year-$month-$day 00:00:00", new \DateTimeZone( wc_timezone_string() ) );
		}

		return $datetime;
	}

	/**
	 * Returns a new DateTime object representing the next quarter start, or previous quarter end if reversed.
	 *
	 * @param DateTime $datetime Date and time.
	 * @param bool     $reversed Going backwards in time instead of forward.
	 * @return DateTime
	 */
	public static function next_quarter_start( $datetime, $reversed = false ) {
		$year  = $datetime->format( 'Y' );
		$month = (int) $datetime->format( 'n' );

		switch ( $month ) {
			case 1:
			case 2:
			case 3:
				if ( $reversed ) {
					$month = 1;
				} else {
					$month = 4;
				}
				break;
			case 4:
			case 5:
			case 6:
				if ( $reversed ) {
					$month = 4;
				} else {
					$month = 7;
				}
				break;
			case 7:
			case 8:
			case 9:
				if ( $reversed ) {
					$month = 7;
				} else {
					$month = 10;
				}
				break;
			case 10:
			case 11:
			case 12:
				if ( $reversed ) {
					$month = 10;
				} else {
					$month = 1;
					$year ++;
				}
				break;
		}
		$datetime = new \DateTime( "$year-$month-01 00:00:00", new \DateTimeZone( wc_timezone_string() ) );
		if ( $reversed ) {
			$timestamp                   = (int) $datetime->format( 'U' );
			$end_of_prev_month_timestamp = $timestamp - 1;
			$datetime->setTimestamp( $end_of_prev_month_timestamp );
		}

		return $datetime;
	}

	/**
	 * Return a new DateTime object representing the next year start, or previous year end if reversed.
	 *
	 * @param DateTime $datetime Date and time.
	 * @param bool     $reversed Going backwards in time instead of forward.
	 * @return DateTime
	 */
	public static function next_year_start( $datetime, $reversed = false ) {
		$year_increment = 1;
		$year           = (int) $datetime->format( 'Y' );
		$month          = '01';
		$day            = '01';

		if ( $reversed ) {
			$datetime                   = new \DateTime( "$year-$month-$day 00:00:00", new \DateTimeZone( wc_timezone_string() ) );
			$timestamp                  = (int) $datetime->format( 'U' );
			$end_of_prev_year_timestamp = $timestamp - 1;
			$datetime->setTimestamp( $end_of_prev_year_timestamp );
		} else {
			$year    += $year_increment;
			$datetime = new \DateTime( "$year-$month-$day 00:00:00", new \DateTimeZone( wc_timezone_string() ) );
		}

		return $datetime;
	}

	/**
	 * Returns beginning of next time interval for provided DateTime.
	 *
	 * E.g. for current DateTime, beginning of next day, week, quarter, etc.
	 *
	 * @param DateTime $datetime      Date and time.
	 * @param string   $time_interval Time interval, e.g. week, day, hour.
	 * @param bool     $reversed Going backwards in time instead of forward.
	 * @return DateTime
	 */
	public static function iterate( $datetime, $time_interval, $reversed = false ) {
		return call_user_func( array( __CLASS__, "next_{$time_interval}_start" ), $datetime, $reversed );
	}

	/**
	 * Returns expected number of items on the page in case of date ordering.
	 *
	 * @param int $expected_interval_count Expected number of intervals in total.
	 * @param int $items_per_page          Number of items per page.
	 * @param int $page_no                 Page number.
	 *
	 * @return float|int
	 */
	public static function expected_intervals_on_page( $expected_interval_count, $items_per_page, $page_no ) {
		$total_pages = (int) ceil( $expected_interval_count / $items_per_page );
		if ( $page_no < $total_pages ) {
			return $items_per_page;
		} elseif ( $page_no === $total_pages ) {
			return $expected_interval_count - ( $page_no - 1 ) * $items_per_page;
		} else {
			return 0;
		}
	}

	/**
	 * Returns true if there are any intervals that need to be filled in the response.
	 *
	 * @param int    $expected_interval_count Expected number of intervals in total.
	 * @param int    $db_records              Total number of records for given period in the database.
	 * @param int    $items_per_page          Number of items per page.
	 * @param int    $page_no                 Page number.
	 * @param string $order                   asc or desc.
	 * @param string $order_by                Column by which the result will be sorted.
	 * @param int    $intervals_count         Number of records for given (possibly shortened) time interval.
	 *
	 * @return bool
	 */
	public static function intervals_missing( $expected_interval_count, $db_records, $items_per_page, $page_no, $order, $order_by, $intervals_count ) {
		if ( $expected_interval_count <= $db_records ) {
			return false;
		}
		if ( 'date' === $order_by ) {
			$expected_intervals_on_page = self::expected_intervals_on_page( $expected_interval_count, $items_per_page, $page_no );
			return $intervals_count < $expected_intervals_on_page;
		}
		if ( 'desc' === $order ) {
			return $page_no > floor( $db_records / $items_per_page );
		}
		if ( 'asc' === $order ) {
			return $page_no <= ceil( ( $expected_interval_count - $db_records ) / $items_per_page );
		}
		// Invalid ordering.
		return false;
	}

	/**
	 * Normalize "*_between" parameters to "*_min" and "*_max" for numeric values
	 * and "*_after" and "*_before" for date values.
	 *
	 * @param array        $request Query params from REST API request.
	 * @param string|array $param_names One or more param names to handle. Should not include "_between" suffix.
	 * @param bool         $is_date Boolean if the param is date is related.
	 * @return array Normalized query values.
	 */
	public static function normalize_between_params( $request, $param_names, $is_date ) {
		if ( ! is_array( $param_names ) ) {
			$param_names = array( $param_names );
		}

		$normalized = array();

		foreach ( $param_names as $param_name ) {
			if ( ! is_array( $request[ $param_name . '_between' ] ) ) {
				continue;
			}

			$range = $request[ $param_name . '_between' ];

			if ( 2 !== count( $range ) ) {
				continue;
			}

			$min = $is_date ? '_after' : '_min';
			$max = $is_date ? '_before' : '_max';

			if ( $range[0] < $range[1] ) {
				$normalized[ $param_name . $min ] = $range[0];
				$normalized[ $param_name . $max ] = $range[1];
			} else {
				$normalized[ $param_name . $min ] = $range[1];
				$normalized[ $param_name . $max ] = $range[0];
			}
		}

		return $normalized;
	}

	/**
	 * Validate a "*_between" range argument (an array with 2 numeric items).
	 *
	 * @param  mixed           $value Parameter value.
	 * @param  WP_REST_Request $request REST Request.
	 * @param  string          $param Parameter name.
	 * @return WP_Error|boolean
	 */
	public static function rest_validate_between_numeric_arg( $value, $request, $param ) {
		if ( ! wp_is_numeric_array( $value ) ) {
			return new \WP_Error(
				'rest_invalid_param',
				/* translators: 1: parameter name */
				sprintf( __( '%1$s is not a numerically indexed array.', 'woocommerce' ), $param )
			);
		}

		if (
			2 !== count( $value ) ||
			! is_numeric( $value[0] ) ||
			! is_numeric( $value[1] )
		) {
			return new \WP_Error(
				'rest_invalid_param',
				/* translators: %s: parameter name */
				sprintf( __( '%s must contain 2 numbers.', 'woocommerce' ), $param )
			);
		}

		return true;
	}

	/**
	 * Validate a "*_between" range argument (an array with 2 date items).
	 *
	 * @param  mixed           $value Parameter value.
	 * @param  WP_REST_Request $request REST Request.
	 * @param  string          $param Parameter name.
	 * @return WP_Error|boolean
	 */
	public static function rest_validate_between_date_arg( $value, $request, $param ) {
		if ( ! wp_is_numeric_array( $value ) ) {
			return new \WP_Error(
				'rest_invalid_param',
				/* translators: 1: parameter name */
				sprintf( __( '%1$s is not a numerically indexed array.', 'woocommerce' ), $param )
			);
		}

		if (
			2 !== count( $value ) ||
			! rest_parse_date( $value[0] ) ||
			! rest_parse_date( $value[1] )
		) {
			return new \WP_Error(
				'rest_invalid_param',
				/* translators: %s: parameter name */
				sprintf( __( '%s must contain 2 valid dates.', 'woocommerce' ), $param )
			);
		}

		return true;
	}

	/**
	 * Get dates from a timeframe string.
	 *
	 * @param int           $timeframe Timeframe to use.  One of: last_week|last_month|last_quarter|last_6_months|last_year.
	 * @param DateTime|null $current_date DateTime of current date to compare.
	 * @return array
	 */
	public static function get_timeframe_dates( $timeframe, $current_date = null ) {
		if ( ! $current_date ) {
			$current_date = new \DateTime();
		}
		$current_year  = $current_date->format( 'Y' );
		$current_month = $current_date->format( 'm' );

		if ( 'last_week' === $timeframe ) {
			return array(
				'start' => $current_date->modify( 'last week monday' )->format( 'Y-m-d 00:00:00' ),
				'end'   => $current_date->modify( 'this sunday' )->format( 'Y-m-d 23:59:59' ),
			);
		}

		if ( 'last_month' === $timeframe ) {
			return array(
				'start' => $current_date->modify( 'first day of previous month' )->format( 'Y-m-d 00:00:00' ),
				'end'   => $current_date->modify( 'last day of this month' )->format( 'Y-m-d 23:59:59' ),
			);
		}

		if ( 'last_quarter' === $timeframe ) {
			switch ( $current_month ) {
				case $current_month >= 1 && $current_month <= 3:
					return array(
						'start' => ( $current_year - 1 ) . '-10-01 00:00:00',
						'end'   => ( $current_year - 1 ) . '-12-31 23:59:59',
					);
				case $current_month >= 4 && $current_month <= 6:
					return array(
						'start' => $current_year . '-01-01 00:00:00',
						'end'   => $current_year . '-03-31 23:59:59',
					);
				case $current_month >= 7 && $current_month <= 9:
					return array(
						'start' => $current_year . '-04-01 00:00:00',
						'end'   => $current_year . '-06-30 23:59:59',
					);
				case $current_month >= 10 && $current_month <= 12:
					return array(
						'start' => $current_year . '-07-01 00:00:00',
						'end'   => $current_year . '-09-31 23:59:59',
					);
			}
		}

		if ( 'last_6_months' === $timeframe ) {
			if ( $current_month >= 1 && $current_month <= 6 ) {
				return array(
					'start' => ( $current_year - 1 ) . '-07-01 00:00:00',
					'end'   => ( $current_year - 1 ) . '-12-31 23:59:59',
				);
			}
			return array(
				'start' => $current_year . '-01-01 00:00:00',
				'end'   => $current_year . '-06-30 23:59:59',
			);
		}

		if ( 'last_year' === $timeframe ) {
			return array(
				'start' => ( $current_year - 1 ) . '-01-01 00:00:00',
				'end'   => ( $current_year - 1 ) . '-12-31 23:59:59',
			);
		}

		return false;
	}
}
Reports/ExportableInterface.php000064400000001160152333372110012637 0ustar00<?php
/**
 * Reports Exportable Controller Interface
 */

namespace Automattic\WooCommerce\Admin\API\Reports;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * WooCommerce Reports exportable controller interface.
 *
 * @since 3.5.0
 */
interface ExportableInterface {

	/**
	 * Get the column names for export.
	 *
	 * @return array Key value pair of Column ID => Label.
	 */
	public function get_export_columns();

	/**
	 * Get the column values for export.
	 *
	 * @param array $item Single report item/row.
	 * @return array Key value pair of Column ID => Value.
	 */
	public function prepare_item_for_export( $item );
}
Reports/Variations/DataStore.php000064400000042162152333372110012725 0ustar00<?php
/**
 * API\Reports\Variations\DataStore class file.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Variations;

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;

/**
 * API\Reports\Variations\DataStore.
 */
class DataStore extends ReportsDataStore implements DataStoreInterface {

	/**
	 * Table used to get the data.
	 *
	 * @var string
	 */
	protected static $table_name = 'wc_order_product_lookup';

	/**
	 * Cache identifier.
	 *
	 * @var string
	 */
	protected $cache_key = 'variations';

	/**
	 * Mapping columns to data type to return correct response types.
	 *
	 * @var array
	 */
	protected $column_types = array(
		'date_start'   => 'strval',
		'date_end'     => 'strval',
		'product_id'   => 'intval',
		'variation_id' => 'intval',
		'items_sold'   => 'intval',
		'net_revenue'  => 'floatval',
		'orders_count' => 'intval',
		'name'         => 'strval',
		'price'        => 'floatval',
		'image'        => 'strval',
		'permalink'    => 'strval',
		'sku'          => 'strval',
	);

	/**
	 * Extended product attributes to include in the data.
	 *
	 * @var array
	 */
	protected $extended_attributes = array(
		'name',
		'price',
		'image',
		'permalink',
		'stock_status',
		'stock_quantity',
		'low_stock_amount',
		'sku',
	);

	/**
	 * Data store context used to pass to filters.
	 *
	 * @var string
	 */
	protected $context = 'variations';

	/**
	 * Assign report columns once full table name has been assigned.
	 */
	protected function assign_report_columns() {
		$table_name           = self::get_db_table_name();
		$this->report_columns = array(
			'product_id'   => 'product_id',
			'variation_id' => 'variation_id',
			'items_sold'   => 'SUM(product_qty) as items_sold',
			'net_revenue'  => 'SUM(product_net_revenue) AS net_revenue',
			'orders_count' => "COUNT(DISTINCT {$table_name}.order_id) as orders_count",
		);
	}

	/**
	 * Fills FROM clause of SQL request based on user supplied parameters.
	 *
	 * @param array  $query_args Parameters supplied by the user.
	 * @param string $arg_name   Target of the JOIN sql param.
	 */
	protected function add_from_sql_params( $query_args, $arg_name ) {
		global $wpdb;

		if ( 'sku' !== $query_args['orderby'] ) {
			return;
		}

		$table_name = self::get_db_table_name();
		$join       = "LEFT JOIN {$wpdb->postmeta} AS postmeta ON {$table_name}.variation_id = postmeta.post_id AND postmeta.meta_key = '_sku'";

		if ( 'inner' === $arg_name ) {
			$this->subquery->add_sql_clause( 'join', $join );
		} else {
			$this->add_sql_clause( 'join', $join );
		}
	}

	/**
	 * Generate a subquery for order_item_id based on the attribute filters.
	 *
	 * @param array $query_args Query arguments supplied by the user.
	 * @return string
	 */
	protected function get_order_item_by_attribute_subquery( $query_args ) {
		$order_product_lookup_table = self::get_db_table_name();
		$attribute_subqueries       = $this->get_attribute_subqueries( $query_args, $order_product_lookup_table );

		if ( $attribute_subqueries['join'] && $attribute_subqueries['where'] ) {
			// Perform a subquery for DISTINCT order items that match our attribute filters.
			$attr_subquery = new SqlQuery( $this->context . '_attribute_subquery' );
			$attr_subquery->add_sql_clause( 'select', "DISTINCT {$order_product_lookup_table}.order_item_id" );
			$attr_subquery->add_sql_clause( 'from', $order_product_lookup_table );

			if ( $this->should_exclude_simple_products( $query_args ) ) {
				$attr_subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.variation_id != 0" );
			}

			foreach ( $attribute_subqueries['join'] as $attribute_join ) {
				$attr_subquery->add_sql_clause( 'join', $attribute_join );
			}

			$operator = $this->get_match_operator( $query_args );
			$attr_subquery->add_sql_clause( 'where', 'AND (' . implode( " {$operator} ", $attribute_subqueries['where'] ) . ')' );

			return "AND {$order_product_lookup_table}.order_item_id IN ({$attr_subquery->get_query_statement()})";
		}

		return false;
	}

	/**
	 * Updates the database query with parameters used for Products report: categories and order status.
	 *
	 * @param array $query_args Query arguments supplied by the user.
	 */
	protected function add_sql_query_params( $query_args ) {
		global $wpdb;
		$order_product_lookup_table = self::get_db_table_name();
		$order_stats_lookup_table   = $wpdb->prefix . 'wc_order_stats';
		$order_item_meta_table      = $wpdb->prefix . 'woocommerce_order_itemmeta';
		$where_subquery             = array();

		$this->add_time_period_sql_params( $query_args, $order_product_lookup_table );
		$this->get_limit_sql_params( $query_args );
		$this->add_order_by_sql_params( $query_args );

		$included_variations = $this->get_included_variations( $query_args );
		if ( $included_variations > 0 ) {
			$this->add_from_sql_params( $query_args, 'outer' );
		} else {
			$this->add_from_sql_params( $query_args, 'inner' );
		}

		$included_products = $this->get_included_products( $query_args );
		if ( $included_products ) {
			$this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.product_id IN ({$included_products})" );
		}

		$excluded_products = $this->get_excluded_products( $query_args );
		if ( $excluded_products ) {
			$this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.product_id NOT IN ({$excluded_products})" );
		}

		if ( $included_variations ) {
			$this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.variation_id IN ({$included_variations})" );
		} elseif ( ! $included_products ) {
			if ( $this->should_exclude_simple_products( $query_args ) ) {
				$this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.variation_id != 0" );
			}
		}

		$order_status_filter = $this->get_status_subquery( $query_args );
		if ( $order_status_filter ) {
			$this->subquery->add_sql_clause( 'join', "JOIN {$order_stats_lookup_table} ON {$order_product_lookup_table}.order_id = {$order_stats_lookup_table}.order_id" );
			$this->subquery->add_sql_clause( 'where', "AND ( {$order_status_filter} )" );
		}

		$attribute_order_items_subquery = $this->get_order_item_by_attribute_subquery( $query_args );
		if ( $attribute_order_items_subquery ) {
			// JOIN on product lookup if we haven't already.
			if ( ! $order_status_filter ) {
				$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 subquery for matching attributes to WHERE.
			$this->subquery->add_sql_clause( 'where', $attribute_order_items_subquery );
		}

		if ( 0 < count( $where_subquery ) ) {
			$operator = $this->get_match_operator( $query_args );
			$this->subquery->add_sql_clause( 'where', 'AND (' . implode( " {$operator} ", $where_subquery ) . ')' );
		}
	}

	/**
	 * Maps ordering specified by the user to columns in the database/fields in the data.
	 *
	 * @param string $order_by Sorting criterion.
	 *
	 * @return string
	 */
	protected function normalize_order_by( $order_by ) {
		if ( 'date' === $order_by ) {
			return self::get_db_table_name() . '.date_created';
		}
		if ( 'sku' === $order_by ) {
			return 'meta_value';
		}

		return $order_by;
	}

	/**
	 * Enriches the product data with attributes specified by the extended_attributes.
	 *
	 * @param array $products_data Product data.
	 * @param array $query_args Query parameters.
	 */
	protected function include_extended_info( &$products_data, $query_args ) {
		foreach ( $products_data as $key => $product_data ) {
			$extended_info = new \ArrayObject();
			if ( $query_args['extended_info'] ) {
				$extended_attributes = apply_filters( 'woocommerce_rest_reports_variations_extended_attributes', $this->extended_attributes, $product_data );
				$parent_product      = wc_get_product( $product_data['product_id'] );
				$attributes          = array();

				// Base extended info off the parent variable product if the variation ID is 0.
				// This is caused by simple products with prior sales being converted into variable products.
				// See: https://github.com/woocommerce/woocommerce-admin/issues/2719.
				$variation_id      = (int) $product_data['variation_id'];
				$variation_product = ( 0 === $variation_id ) ? $parent_product : wc_get_product( $variation_id );

				// Fall back to the parent product if the variation can't be found.
				$extended_attributes_product = is_a( $variation_product, 'WC_Product' ) ? $variation_product : $parent_product;
				// If both product and variation is not found, set deleted to true.
				if ( ! $extended_attributes_product ) {
					$extended_info['deleted'] = true;
				}
				foreach ( $extended_attributes as $extended_attribute ) {
					$function = 'get_' . $extended_attribute;
					if ( is_callable( array( $extended_attributes_product, $function ) ) ) {
						$value                                = $extended_attributes_product->{$function}();
						$extended_info[ $extended_attribute ] = $value;
					}
				}

				// If this is a variation, add its attributes.
				// NOTE: We don't fall back to the parent product here because it will include all possible attribute options.
				if (
					0 < $variation_id &&
					is_callable( array( $variation_product, 'get_variation_attributes' ) )
				) {
					$variation_attributes = $variation_product->get_variation_attributes();

					foreach ( $variation_attributes as $attribute_name => $attribute ) {
						$name         = str_replace( 'attribute_', '', $attribute_name );
						$option_term  = get_term_by( 'slug', $attribute, $name );
						$attributes[] = array(
							'id'     => wc_attribute_taxonomy_id_by_name( $name ),
							'name'   => str_replace( 'pa_', '', $name ),
							'option' => $option_term && ! is_wp_error( $option_term ) ? $option_term->name : $attribute,
						);
					}
				}

				$extended_info['attributes'] = $attributes;

				// If there is no set low_stock_amount, use the one in user settings.
				if ( '' === $extended_info['low_stock_amount'] ) {
					$extended_info['low_stock_amount'] = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) );
				}
				$extended_info = $this->cast_numbers( $extended_info );
			}
			$products_data[ $key ]['extended_info'] = $extended_info;
		}
	}

	/**
	 * Returns if simple products should be excluded from the report.
	 *
	 * @internal
	 *
	 * @param array $query_args Query parameters.
	 *
	 * @return boolean
	 */
	protected function should_exclude_simple_products( array $query_args ) {
		return apply_filters( 'experimental_woocommerce_analytics_variations_should_exclude_simple_products', true, $query_args );
	}

	/**
	 * Fill missing extended_info.name for the deleted products.
	 *
	 * @param array $products Product data.
	 */
	protected function fill_deleted_product_name( array &$products ) {
		global $wpdb;
		$product_variation_ids = [];
		// Find products with missing extended_info.name.
		foreach ( $products as $key => $product ) {
			if ( ! isset( $product['extended_info']['name'] ) ) {
				$product_variation_ids[ $key ] = [
					'product_id'   => $product['product_id'],
					'variation_id' => $product['variation_id'],
				];
			}
		}

		if ( ! count( $product_variation_ids ) ) {
			return;
		}

		$where_clauses = implode(
			' or ',
			array_map(
				function( $ids ) {
					return "(
						product_lookup.product_id = {$ids['product_id']}
						and
						product_lookup.variation_id = {$ids['variation_id']}
                    )";
				},
				$product_variation_ids
			)
		);

		$query = "
			select
				product_lookup.product_id,
				product_lookup.variation_id,
				order_items.order_item_name
			from
				{$wpdb->prefix}wc_order_product_lookup as product_lookup
				left join {$wpdb->prefix}woocommerce_order_items as order_items
				on product_lookup.order_item_id = order_items.order_item_id
			where
				{$where_clauses}
			group by
				product_lookup.product_id,
				product_lookup.variation_id,
				order_items.order_item_name
		";

		// phpcs:ignore
		$results = $wpdb->get_results( $query );
		$index   = [];
		foreach ( $results as $result ) {
			$index[ $result->product_id . '_' . $result->variation_id ] = $result->order_item_name;
		}

		foreach ( $product_variation_ids as $product_key => $ids ) {
			$product   = $products[ $product_key ];
			$index_key = $product['product_id'] . '_' . $product['variation_id'];
			if ( isset( $index[ $index_key ] ) ) {
				$products[ $product_key ]['extended_info']['name'] = $index[ $index_key ];
			}
		}
	}

	/**
	 * 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 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'            => 'date',
			'before'             => TimeInterval::default_before(),
			'after'              => TimeInterval::default_after(),
			'fields'             => '*',
			'product_includes'   => array(),
			'variation_includes' => array(),
			'extended_info'      => false,
		);
		$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 );
			$included_variations =
				( isset( $query_args['variation_includes'] ) && is_array( $query_args['variation_includes'] ) )
					? $query_args['variation_includes']
					: array();
			$params              = $this->get_limit_params( $query_args );
			$this->add_sql_query_params( $query_args );

			if ( count( $included_variations ) > 0 ) {
				$total_results = count( $included_variations );
				$total_pages   = (int) ceil( $total_results / $params['per_page'] );

				$this->subquery->clear_sql_clause( 'select' );
				$this->subquery->add_sql_clause( 'select', $selections );

				if ( 'date' === $query_args['orderby'] ) {
					$this->subquery->add_sql_clause( 'select', ", {$table_name}.date_created" );
				}

				$fields          = $this->get_fields( $query_args );
				$join_selections = $this->format_join_selections( $fields, array( 'variation_id' ) );
				$ids_table       = $this->get_ids_table( $included_variations, 'variation_id' );

				$this->add_sql_clause( 'select', $join_selections );
				$this->add_sql_clause( 'from', '(' );
				$this->add_sql_clause( 'from', $this->subquery->get_query_statement() );
				$this->add_sql_clause( 'from', ") AS {$table_name}" );
				$this->add_sql_clause(
					'right_join',
					"RIGHT JOIN ( {$ids_table} ) AS default_results
					ON default_results.variation_id = {$table_name}.variation_id"
				);

				$variations_query = $this->get_query_statement();
			} else {
				/* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
				$db_records_count = (int) $wpdb->get_var(
					"SELECT COUNT(*) FROM (
						{$this->subquery->get_query_statement()}
					) AS tt"
				);
				/* phpcs:enable */

				$total_results = $db_records_count;
				$total_pages   = (int) ceil( $db_records_count / $params['per_page'] );

				if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) {
					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' ) );
				$variations_query = $this->subquery->get_query_statement();
			}

			/* phpcs:disable WordPress.DB.PreparedSQL.NotPrepared */
			$product_data = $wpdb->get_results(
				$variations_query,
				ARRAY_A
			);
			/* phpcs:enable */

			if ( null === $product_data ) {
				return $data;
			}

			$this->include_extended_info( $product_data, $query_args );

			if ( $query_args['extended_info'] ) {
				$this->fill_deleted_product_name( $product_data );
			}

			$product_data = array_map( array( $this, 'cast_numbers' ), $product_data );
			$data         = (object) array(
				'data'    => $product_data,
				'total'   => $total_results,
				'pages'   => $total_pages,
				'page_no' => (int) $query_args['page'],
			);

			$this->set_cached_data( $cache_key, $data );
		}

		return $data;
	}

	/**
	 * Initialize query objects.
	 */
	protected function initialize_queries() {
		$this->clear_all_clauses();
		$this->subquery = new SqlQuery( $this->context . '_subquery' );
		$this->subquery->add_sql_clause( 'select', 'product_id' );
		$this->subquery->add_sql_clause( 'from', self::get_db_table_name() );
		$this->subquery->add_sql_clause( 'group_by', 'product_id, variation_id' );
	}
}
Reports/Variations/Controller.php000064400000036475152333372110013174 0ustar00<?php
/**
 * REST API Reports products controller
 *
 * Handles requests to the /reports/products endpoint.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Variations;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\Controller as ReportsController;
use \Automattic\WooCommerce\Admin\API\Reports\ExportableInterface;
use \Automattic\WooCommerce\Admin\API\Reports\ExportableTraits;

/**
 * REST API Reports products controller class.
 *
 * @internal
 * @extends WC_REST_Reports_Controller
 */
class Controller extends ReportsController implements ExportableInterface {
	/**
	 * Exportable traits.
	 */
	use ExportableTraits;

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'reports/variations';

	/**
	 * Mapping between external parameter name and name used in query class.
	 *
	 * @var array
	 */
	protected $param_mapping = array(
		'variations' => 'variation_includes',
	);

	/**
	 * Get items.
	 *
	 * @param WP_REST_Request $request Request data.
	 *
	 * @return array|WP_Error
	 */
	public function get_items( $request ) {
		$args = array();
		/**
		 * Experimental: Filter the list of parameters provided when querying data from the data store.
		 *
		 * @ignore
		 *
		 * @param array $collection_params List of parameters.
		 */
		$collection_params = apply_filters(
			'experimental_woocommerce_analytics_variations_collection_params',
			$this->get_collection_params()
		);
		$registered        = array_keys( $collection_params );
		foreach ( $registered as $param_name ) {
			if ( isset( $request[ $param_name ] ) ) {
				if ( isset( $this->param_mapping[ $param_name ] ) ) {
					$args[ $this->param_mapping[ $param_name ] ] = $request[ $param_name ];
				} else {
					$args[ $param_name ] = $request[ $param_name ];
				}
			}
		}

		$reports       = new Query( $args );
		$products_data = $reports->get_data();

		$data = array();

		foreach ( $products_data->data as $product_data ) {
			$item   = $this->prepare_item_for_response( $product_data, $request );
			$data[] = $this->prepare_response_for_collection( $item );
		}

		$response = rest_ensure_response( $data );
		$response->header( 'X-WP-Total', (int) $products_data->total );
		$response->header( 'X-WP-TotalPages', (int) $products_data->pages );

		$page      = $products_data->page_no;
		$max_pages = $products_data->pages;
		$base      = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) );
		if ( $page > 1 ) {
			$prev_page = $page - 1;
			if ( $prev_page > $max_pages ) {
				$prev_page = $max_pages;
			}
			$prev_link = add_query_arg( 'page', $prev_page, $base );
			$response->link_header( 'prev', $prev_link );
		}
		if ( $max_pages > $page ) {
			$next_page = $page + 1;
			$next_link = add_query_arg( 'page', $next_page, $base );
			$response->link_header( 'next', $next_link );
		}

		return $response;
	}

	/**
	 * 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 );
		$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_variations', $response, $report, $request );
	}

	/**
	 * Prepare links for the request.
	 *
	 * @param array $object Object data.
	 * @return array        Links for the given post.
	 */
	protected function prepare_links( $object ) {
		$links = array(
			'product'   => array(
				'href' => rest_url( sprintf( '/%s/%s/%d', $this->namespace, 'products', $object['product_id'] ) ),
			),
			'variation' => array(
				'href' => rest_url( sprintf( '/%s/%s/%d/%s/%d', $this->namespace, 'products', $object['product_id'], 'variation', $object['variation_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_varitations',
			'type'       => 'object',
			'properties' => array(
				'product_id'    => array(
					'type'        => 'integer',
					'readonly'    => true,
					'context'     => array( 'view', 'edit' ),
					'description' => __( 'Product ID.', 'woocommerce' ),
				),
				'variation_id'  => array(
					'type'        => 'integer',
					'readonly'    => true,
					'context'     => array( 'view', 'edit' ),
					'description' => __( 'Product ID.', 'woocommerce' ),
				),
				'items_sold'    => array(
					'type'        => 'integer',
					'readonly'    => true,
					'context'     => array( 'view', 'edit' ),
					'description' => __( 'Number of items sold.', 'woocommerce' ),
				),
				'net_revenue'   => array(
					'type'        => 'number',
					'readonly'    => true,
					'context'     => array( 'view', 'edit' ),
					'description' => __( 'Total Net sales of all items sold.', 'woocommerce' ),
				),
				'orders_count'  => array(
					'type'        => 'integer',
					'readonly'    => true,
					'context'     => array( 'view', 'edit' ),
					'description' => __( 'Number of orders product appeared in.', 'woocommerce' ),
				),
				'extended_info' => array(
					'name'             => array(
						'type'        => 'string',
						'readonly'    => true,
						'context'     => array( 'view', 'edit' ),
						'description' => __( 'Product name.', 'woocommerce' ),
					),
					'price'            => array(
						'type'        => 'number',
						'readonly'    => true,
						'context'     => array( 'view', 'edit' ),
						'description' => __( 'Product price.', 'woocommerce' ),
					),
					'image'            => array(
						'type'        => 'string',
						'readonly'    => true,
						'context'     => array( 'view', 'edit' ),
						'description' => __( 'Product image.', 'woocommerce' ),
					),
					'permalink'        => array(
						'type'        => 'string',
						'readonly'    => true,
						'context'     => array( 'view', 'edit' ),
						'description' => __( 'Product link.', 'woocommerce' ),
					),
					'attributes'       => array(
						'type'        => 'array',
						'readonly'    => true,
						'context'     => array( 'view', 'edit' ),
						'description' => __( 'Product attributes.', 'woocommerce' ),
					),
					'stock_status'     => array(
						'type'        => 'string',
						'readonly'    => true,
						'context'     => array( 'view', 'edit' ),
						'description' => __( 'Product inventory status.', 'woocommerce' ),
					),
					'stock_quantity'   => array(
						'type'        => 'integer',
						'readonly'    => true,
						'context'     => array( 'view', 'edit' ),
						'description' => __( 'Product inventory quantity.', 'woocommerce' ),
					),
					'low_stock_amount' => array(
						'type'        => 'integer',
						'readonly'    => true,
						'context'     => array( 'view', 'edit' ),
						'description' => __( 'Product inventory threshold for low stock.', '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'           => 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['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['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',
				'items_sold',
				'sku',
			),
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['product_includes']  = array(
			'description'       => __( 'Limit result set to items that have the specified parent product(s).', '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 parent product(s).', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'integer',
			),
			'default'           => array(),
			'validate_callback' => 'rest_validate_request_arg',
			'sanitize_callback' => 'wp_parse_id_list',
		);
		$params['variations']        = array(
			'description'       => __( 'Limit result to items with specified variation ids.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'type' => 'integer',
			),
		);
		$params['extended_info']     = array(
			'description'       => __( 'Add additional piece of info about each variation to the report.', 'woocommerce' ),
			'type'              => 'boolean',
			'default'           => false,
			'sanitize_callback' => 'wc_string_to_bool',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['attribute_is']      = array(
			'description'       => __( 'Limit result set to variations that include 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 variations that don\'t include the specified attributes.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'array',
			),
			'default'           => array(),
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['category_includes'] = array(
			'description'       => __( 'Limit result set to variations in the specified categories.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'type' => 'integer',
			),
		);
		$params['category_excludes'] = array(
			'description'       => __( 'Limit result set to variations not in the specified categories.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'type' => 'integer',
			),
		);
		$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 stock status column export value.
	 *
	 * @param array $status Stock status from report row.
	 * @return string
	 */
	protected function get_stock_status( $status ) {
		$statuses = wc_get_product_stock_status_options();

		return isset( $statuses[ $status ] ) ? $statuses[ $status ] : '';
	}

	/**
	 * Get the column names for export.
	 *
	 * @return array Key value pair of Column ID => Label.
	 */
	public function get_export_columns() {
		$export_columns = array(
			'product_name' => __( 'Product / Variation title', 'woocommerce' ),
			'sku'          => __( 'SKU', 'woocommerce' ),
			'items_sold'   => __( 'Items sold', 'woocommerce' ),
			'net_revenue'  => __( 'N. Revenue', 'woocommerce' ),
			'orders_count' => __( 'Orders', 'woocommerce' ),
		);

		if ( 'yes' === get_option( 'woocommerce_manage_stock' ) ) {
			$export_columns['stock_status'] = __( 'Status', 'woocommerce' );
			$export_columns['stock']        = __( 'Stock', 'woocommerce' );
		}

		return $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(
			'product_name' => $item['extended_info']['name'],
			'sku'          => $item['extended_info']['sku'],
			'items_sold'   => $item['items_sold'],
			'net_revenue'  => self::csv_number_format( $item['net_revenue'] ),
			'orders_count' => $item['orders_count'],
		);

		if ( 'yes' === get_option( 'woocommerce_manage_stock' ) ) {
			$export_item['stock_status'] = $this->get_stock_status( $item['extended_info']['stock_status'] );
			$export_item['stock']        = $item['extended_info']['stock_quantity'];
		}

		return $export_item;
	}
}
Reports/Variations/Query.php000064400000002367152333372110012147 0ustar00<?php
/**
 * Class for parameter-based Products Report querying
 *
 * Example usage:
 * $args = array(
 *          'before'       => '2018-07-19 00:00:00',
 *          'after'        => '2018-07-05 00:00:00',
 *          'page'         => 2,
 *          'categories'   => array(15, 18),
 *          'products'     => array(1,2,3)
 *         );
 * $report = new \Automattic\WooCommerce\Admin\API\Reports\Variations\Query( $args );
 * $mydata = $report->get_data();
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Variations;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;

/**
 * API\Reports\Variations\Query
 */
class Query extends ReportsQuery {

	/**
	 * Valid fields for Products report.
	 *
	 * @return array
	 */
	protected function get_default_query_vars() {
		return array();
	}

	/**
	 * Get product data based on the current query vars.
	 *
	 * @return array
	 */
	public function get_data() {
		$args = apply_filters( 'woocommerce_analytics_variations_query_args', $this->get_query_vars() );

		$data_store = \WC_Data_Store::load( 'report-variations' );
		$results    = $data_store->get_data( $args );
		return apply_filters( 'woocommerce_analytics_variations_select_query', $results, $args );
	}
}
Reports/Variations/Stats/Segmenter.php000064400000017671152333372110014075 0ustar00<?php
/**
 * Class for adding segmenting support without cluttering the data stores.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Variations\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(
			'items_sold'       => "SUM($products_table.product_qty) as items_sold",
			'net_revenue'      => "SUM($products_table.product_net_revenue ) AS net_revenue",
			'orders_count'     => "COUNT( DISTINCT $products_table.order_id ) AS orders_count",
			'variations_count' => "COUNT( DISTINCT $products_table.variation_id ) AS variations_count",
		);

		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.
		/* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
		$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:enable */

		$totals_segments = $this->merge_segment_totals_results( $segmenting_dimension_name, $segments_products, array() );
		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 a multiple of the number of segments.
		preg_match( '/LIMIT (\d+)\s?,\s?(\d+)/', $intervals_query['limit'], $limit_parts );
		$segment_count    = count( $this->get_all_segments() );
		$orig_offset      = intval( $limit_parts[1] );
		$orig_rowcount    = intval( $limit_parts[2] );
		$segmenting_limit = $wpdb->prepare( 'LIMIT %d, %d', $orig_offset * $segment_count, $orig_rowcount * $segment_count );

		// 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.
		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$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",
				// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			ARRAY_A
		);

		$intervals_segments = $this->merge_segment_intervals_results( $segmenting_dimension_name, $segments_products, array() );
		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_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 ( 'variation' === $this->query_args['segmentby'] ) {
			$product_level_columns     = $this->get_segment_selections_product_level( $product_segmenting_table );
			$segmenting_selections     = array(
				'product_level' => $this->prepare_selections( $product_level_columns ),
			);
			$this->report_columns      = $product_level_columns;
			$segmenting_from           = '';
			$segmenting_groupby        = $product_segmenting_table . '.variation_id';
			$segmenting_dimension_name = 'variation_id';

			// Restrict our search space for variation comparisons.
			if ( isset( $this->query_args['variation_includes'] ) ) {
				$variation_ids    = implode( ',', $this->get_all_segments() );
				$segmenting_where = " AND $product_segmenting_table.variation_id IN ( $variation_ids )";
			}

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

		return $segments;
	}
}
Reports/Variations/Stats/Controller.php000064400000037762152333372110014272 0ustar00<?php
/**
 * REST API Reports variations stats controller
 *
 * Handles requests to the /reports/variations/stats endpoint.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Variations\Stats;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\ParameterException;

/**
 * REST API Reports variations stats controller class.
 *
 * @internal
 * @extends WC_REST_Reports_Controller
 */
class Controller extends \WC_REST_Reports_Controller {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'reports/variations/stats';

	/**
	 * Mapping between external parameter name and name used in query class.
	 *
	 * @var array
	 */
	protected $param_mapping = array(
		'variations' => 'variation_includes',
	);

	/**
	 * Constructor.
	 */
	public function __construct() {
		add_filter( 'woocommerce_analytics_variations_stats_select_query', array( $this, 'set_default_report_data' ) );
	}

	/**
	 * Get all reports.
	 *
	 * @param WP_REST_Request $request Request data.
	 * @return array|WP_Error
	 */
	public function get_items( $request ) {
		$query_args = array(
			'fields' => array(
				'items_sold',
				'net_revenue',
				'orders_count',
				'variations_count',
			),
		);
		/**
		 * Experimental: Filter the list of parameters provided when querying data from the data store.
		 *
		 * @ignore
		 *
		 * @param array $collection_params List of parameters.
		 */
		$collection_params = apply_filters( 'experimental_woocommerce_analytics_variations_stats_collection_params', $this->get_collection_params() );
		$registered        = array_keys( $collection_params );
		foreach ( $registered as $param_name ) {
			if ( isset( $request[ $param_name ] ) ) {
				if ( isset( $this->param_mapping[ $param_name ] ) ) {
					$query_args[ $this->param_mapping[ $param_name ] ] = $request[ $param_name ];
				} else {
					$query_args[ $param_name ] = $request[ $param_name ];
				}
			}
		}

		$query = new Query( $query_args );
		try {
			$report_data = $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 );
		}

		$response = rest_ensure_response( $out_data );
		$response->header( 'X-WP-Total', (int) $report_data->total );
		$response->header( 'X-WP-TotalPages', (int) $report_data->pages );

		$page      = $report_data->page_no;
		$max_pages = $report_data->pages;
		$base      = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) );
		if ( $page > 1 ) {
			$prev_page = $page - 1;
			if ( $prev_page > $max_pages ) {
				$prev_page = $max_pages;
			}
			$prev_link = add_query_arg( 'page', $prev_page, $base );
			$response->link_header( 'prev', $prev_link );
		}
		if ( $max_pages > $page ) {
			$next_page = $page + 1;
			$next_link = add_query_arg( 'page', $next_page, $base );
			$response->link_header( 'next', $next_link );
		}

		return $response;
	}

	/**
	 * 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_variations_stats', $response, $report, $request );
	}

	/**
	 * Get the Report's schema, conforming to JSON Schema.
	 *
	 * @return array
	 */
	public function get_item_schema() {
		$data_values = array(
			'items_sold'   => array(
				'title'       => __( 'Variations Sold', 'woocommerce' ),
				'description' => __( 'Number of variation items sold.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
				'indicator'   => true,
			),
			'net_revenue'  => array(
				'description' => __( 'Net sales.', 'woocommerce' ),
				'type'        => 'number',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
				'format'      => 'currency',
			),
			'orders_count' => array(
				'description' => __( 'Number of orders.', 'woocommerce' ),
				'type'        => 'integer',
				'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,
						),
						'segment_label' => array(
							'description' => __( 'Human readable segment label, either product or variation name.', 'woocommerce' ),
							'type'        => 'string',
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
							'enum'        => array( 'day', 'week', 'month', 'year' ),
						),
						'subtotals'     => array(
							'description' => __( 'Interval subtotals.', 'woocommerce' ),
							'type'        => 'object',
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
							'properties'  => $data_values,
						),
					),
				),
			),
		);

		$totals = array_merge( $data_values, $segments );

		$schema = array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => 'report_variations_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'  => $totals,
							),
						),
					),
				),
			),
		);

		return $this->add_additional_fields_schema( $schema );
	}

	/**
	 * Set the default results to 0 if API returns an empty array
	 *
	 * @param Mixed $results Report data.
	 * @return object
	 */
	public function set_default_report_data( $results ) {
		if ( empty( $results ) ) {
			$results                       = new \stdClass();
			$results->total                = 0;
			$results->totals               = new \stdClass();
			$results->totals->items_sold   = 0;
			$results->totals->net_revenue  = 0;
			$results->totals->orders_count = 0;
			$results->intervals            = array();
			$results->pages                = 1;
			$results->page_no              = 1;
		}
		return $results;
	}

	/**
	 * 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['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['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',
				'coupons',
				'refunds',
				'shipping',
				'taxes',
				'net_revenue',
				'orders_count',
				'items_sold',
			),
			'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['category_includes'] = array(
			'description'       => __( 'Limit result to items from the specified categories.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'type' => 'integer',
			),
		);
		$params['category_excludes'] = array(
			'description'       => __( 'Limit result set to variations not in the specified categories.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'type' => 'integer',
			),
		);
		$params['product_includes']  = array(
			'description'       => __( 'Limit result set to items that have the specified parent product(s).', '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 parent product(s).', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'integer',
			),
			'default'           => array(),
			'validate_callback' => 'rest_validate_request_arg',
			'sanitize_callback' => 'wp_parse_id_list',
		);
		$params['variations']        = array(
			'description'       => __( 'Limit result to items with specified variation ids.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'type' => 'integer',
			),
		);
		$params['segmentby']         = array(
			'description'       => __( 'Segment the response by additional constraint.', 'woocommerce' ),
			'type'              => 'string',
			'enum'              => array(
				'product',
				'category',
				'variation',
			),
			'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['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;
	}
}
Reports/Variations/Stats/Query.php000064400000002447152333372110013244 0ustar00<?php
/**
 * Class for parameter-based Variations Stats Report querying
 *
 * Example usage:
 * $args = array(
 *          'before'       => '2018-07-19 00:00:00',
 *          'after'        => '2018-07-05 00:00:00',
 *          'page'         => 2,
 *          'categories'   => array(15, 18),
 *          'product_ids'  => array(1,2,3)
 *         );
 * $report = new \Automattic\WooCommerce\Admin\API\Reports\Variations\Stats\Query( $args );
 * $mydata = $report->get_data();
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Variations\Stats;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;

/**
 * API\Reports\Variations\Stats\Query
 */
class Query extends ReportsQuery {

	/**
	 * Valid fields for Products report.
	 *
	 * @return array
	 */
	protected function get_default_query_vars() {
		return array();
	}

	/**
	 * Get variations data based on the current query vars.
	 *
	 * @return array
	 */
	public function get_data() {
		$args = apply_filters( 'woocommerce_analytics_variations_stats_query_args', $this->get_query_vars() );

		$data_store = \WC_Data_Store::load( 'report-variations-stats' );
		$results    = $data_store->get_data( $args );
		return apply_filters( 'woocommerce_analytics_variations_stats_select_query', $results, $args );
	}

}
Reports/Variations/Stats/DataStore.php000064400000026362152333372110014027 0ustar00<?php
/**
 * API\Reports\Products\Stats\DataStore class file.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Variations\Stats;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\Variations\DataStore as VariationsDataStore;
use \Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface;
use \Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
use \Automattic\WooCommerce\Admin\API\Reports\SqlQuery;

/**
 * API\Reports\Variations\Stats\DataStore.
 */
class DataStore extends VariationsDataStore implements DataStoreInterface {

	/**
	 * Mapping columns to data type to return correct response types.
	 *
	 * @var array
	 */
	protected $column_types = array(
		'items_sold'       => 'intval',
		'net_revenue'      => 'floatval',
		'orders_count'     => 'intval',
		'variations_count' => 'intval',
	);

	/**
	 * Cache identifier.
	 *
	 * @var string
	 */
	protected $cache_key = 'variations_stats';

	/**
	 * Data store context used to pass to filters.
	 *
	 * @var string
	 */
	protected $context = 'variations_stats';

	/**
	 * Assign report columns once full table name has been assigned.
	 */
	protected function assign_report_columns() {
		$table_name           = self::get_db_table_name();
		$this->report_columns = array(
			'items_sold'       => 'SUM(product_qty) as items_sold',
			'net_revenue'      => 'SUM(product_net_revenue) AS net_revenue',
			'orders_count'     => "COUNT( DISTINCT ( CASE WHEN product_gross_revenue >= 0 THEN {$table_name}.order_id END ) ) as orders_count",
			'variations_count' => 'COUNT(DISTINCT variation_id) as variations_count',
		);
	}

	/**
	 * Updates the database query with parameters used for Products Stats report: categories and order status.
	 *
	 * @param array $query_args       Query arguments supplied by the user.
	 */
	protected function update_sql_query_params( $query_args ) {
		global $wpdb;

		$products_where_clause      = '';
		$products_from_clause       = '';
		$where_subquery             = array();
		$order_product_lookup_table = self::get_db_table_name();
		$order_item_meta_table      = $wpdb->prefix . 'woocommerce_order_itemmeta';

		$included_products = $this->get_included_products( $query_args );
		if ( $included_products ) {
			$products_where_clause .= " AND {$order_product_lookup_table}.product_id IN ({$included_products})";
		}

		$excluded_products = $this->get_excluded_products( $query_args );
		if ( $excluded_products ) {
			$products_where_clause .= "AND {$order_product_lookup_table}.product_id NOT IN ({$excluded_products})";
		}

		$included_variations = $this->get_included_variations( $query_args );
		if ( $included_variations ) {
			$products_where_clause .= " AND {$order_product_lookup_table}.variation_id IN ({$included_variations})";
		} elseif ( $this->should_exclude_simple_products( $query_args ) ) {
			$products_where_clause .= " AND {$order_product_lookup_table}.variation_id != 0";
		}

		$order_status_filter = $this->get_status_subquery( $query_args );
		if ( $order_status_filter ) {
			$products_from_clause  .= " JOIN {$wpdb->prefix}wc_order_stats ON {$order_product_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id";
			$products_where_clause .= " AND ( {$order_status_filter} )";
		}

		$attribute_order_items_subquery = $this->get_order_item_by_attribute_subquery( $query_args );
		if ( $attribute_order_items_subquery ) {
			// JOIN on product lookup if we haven't already.
			if ( ! $order_status_filter ) {
				$products_from_clause .= " JOIN {$wpdb->prefix}wc_order_stats ON {$order_product_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id";
			}

			// Add subquery for matching attributes to WHERE.
			$products_where_clause .= $attribute_order_items_subquery;
		}

		if ( 0 < count( $where_subquery ) ) {
			$operator               = $this->get_match_operator( $query_args );
			$products_where_clause .= 'AND (' . implode( " {$operator} ", $where_subquery ) . ')';
		}

		$this->add_time_period_sql_params( $query_args, $order_product_lookup_table );
		$this->total_query->add_sql_clause( 'where', $products_where_clause );
		$this->total_query->add_sql_clause( 'join', $products_from_clause );

		$this->add_intervals_sql_params( $query_args, $order_product_lookup_table );
		$this->interval_query->add_sql_clause( 'where', $products_where_clause );
		$this->interval_query->add_sql_clause( 'join', $products_from_clause );
		$this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) . ' AS time_interval' );
	}

	/**
	 * Returns if simple products should be excluded from the report.
	 *
	 * @internal
	 *
	 * @param array $query_args Query parameters.
	 *
	 * @return boolean
	 */
	protected function should_exclude_simple_products( array $query_args ) {
		return apply_filters( 'experimental_woocommerce_analytics_variations_stats_should_exclude_simple_products', true, $query_args );
	}

	/**
	 * Returns the report data based on parameters supplied by the user.
	 *
	 * @since 3.5.0
	 * @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 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'            => 'date',
			'before'             => TimeInterval::default_before(),
			'after'              => TimeInterval::default_after(),
			'fields'             => '*',
			'category_includes'  => array(),
			'interval'           => 'week',
			'product_includes'   => array(),
			'variation_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();

			$selections = $this->selected_columns( $query_args );
			$params     = $this->get_limit_params( $query_args );

			$this->update_sql_query_params( $query_args );
			$this->get_limit_sql_params( $query_args );
			$this->interval_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) );

			/* phpcs:disable WordPress.DB.PreparedSQL.NotPrepared */
			$db_intervals = $wpdb->get_col(
				$this->interval_query->get_query_statement()
			);
			/* phpcs:enable */

			$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 array();
			}

			$intervals = array();
			$this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name );
			$this->total_query->add_sql_clause( 'select', $selections );
			$this->total_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) );

			/* phpcs:disable WordPress.DB.PreparedSQL.NotPrepared */
			$totals = $wpdb->get_results(
				$this->total_query->get_query_statement(),
				ARRAY_A
			);
			/* phpcs:enable */

			// @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' => $this->total_query->get_sql_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' => $this->interval_query->get_sql_clause( 'where_time' ),
				'where_clause'      => $this->interval_query->get_sql_clause( 'where' ),
				'order_by'          => $this->get_sql_clause( 'order_by' ),
				'limit'             => $this->get_sql_clause( 'limit' ),
			);
			$segmenter             = new Segmenter( $query_args, $this->report_columns );
			$totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name );

			if ( null === $totals ) {
				return new \WP_Error( 'woocommerce_analytics_variations_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) );
			}

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

			/* phpcs:disable WordPress.DB.PreparedSQL.NotPrepared */
			$intervals = $wpdb->get_results(
				$this->interval_query->get_query_statement(),
				ARRAY_A
			);
			/* phpcs:enable */

			if ( null === $intervals ) {
				return new \WP_Error( 'woocommerce_analytics_variations_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) );
			}

			$totals = (object) $this->cast_numbers( $totals[0] );

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

	/**
	 * 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 'time_interval';
		}

		return $order_by;
	}

	/**
	 * 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' );
	}
}
Reports/Revenue/Stats/Controller.php000064400000037041152333372110013552 0ustar00<?php
/**
 * REST API Reports revenue stats controller
 *
 * Handles requests to the /reports/revenue/stats endpoint.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Revenue\Stats;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\Revenue\Query as RevenueQuery;
use \Automattic\WooCommerce\Admin\API\Reports\ExportableInterface;
use \Automattic\WooCommerce\Admin\API\Reports\ExportableTraits;
use \Automattic\WooCommerce\Admin\API\Reports\ParameterException;

/**
 * REST API Reports revenue stats controller class.
 *
 * @internal
 * @extends WC_REST_Reports_Controller
 */
class Controller extends \WC_REST_Reports_Controller implements ExportableInterface {
	/**
	 * Exportable traits.
	 */
	use ExportableTraits;

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'reports/revenue/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['segmentby']           = $request['segmentby'];
		$args['fields']              = $request['fields'];
		$args['force_cache_refresh'] = $request['force_cache_refresh'];

		return $args;
	}

	/**
	 * Get all reports.
	 *
	 * @param WP_REST_Request $request Request data.
	 * @return WP_REST_Response|WP_Error
	 */
	public function get_items( $request ) {
		$query_args      = $this->prepare_reports_query( $request );
		$reports_revenue = new RevenueQuery( $query_args );
		try {
			$report_data = $reports_revenue->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 );
		}

		$response = rest_ensure_response( $out_data );
		$response->header( 'X-WP-Total', (int) $report_data->total );
		$response->header( 'X-WP-TotalPages', (int) $report_data->pages );

		$page      = $report_data->page_no;
		$max_pages = $report_data->pages;
		$base      = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) );
		if ( $page > 1 ) {
			$prev_page = $page - 1;
			if ( $prev_page > $max_pages ) {
				$prev_page = $max_pages;
			}
			$prev_link = add_query_arg( 'page', $prev_page, $base );
			$response->link_header( 'prev', $prev_link );
		}
		if ( $max_pages > $page ) {
			$next_page = $page + 1;
			$next_link = add_query_arg( 'page', $next_page, $base );
			$response->link_header( 'next', $next_link );
		}

		return $response;
	}

	/**
	 * Get report items for export.
	 *
	 * Returns only the interval data.
	 *
	 * @param WP_REST_Request $request Request data.
	 * @return WP_REST_Response
	 */
	public function get_export_items( $request ) {
		$response  = $this->get_items( $request );
		$data      = $response->get_data();
		$intervals = $data['intervals'];

		$response->set_data( $intervals );

		return $response;
	}

	/**
	 * 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_revenue_stats', $response, $report, $request );
	}

	/**
	 * Get the Report's schema, conforming to JSON Schema.
	 *
	 * @return array
	 */
	public function get_item_schema() {
		$data_values = array(
			'total_sales'    => array(
				'description' => __( 'Total sales.', 'woocommerce' ),
				'type'        => 'number',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
				'indicator'   => true,
				'format'      => 'currency',
			),
			'net_revenue'    => array(
				'description' => __( 'Net sales.', 'woocommerce' ),
				'type'        => 'number',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
				'indicator'   => true,
				'format'      => 'currency',
			),
			'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,
				'format'      => 'currency',
			),
			'shipping'       => array(
				'title'       => __( 'Shipping', 'woocommerce' ),
				'description' => __( 'Total of shipping.', 'woocommerce' ),
				'type'        => 'number',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
				'indicator'   => true,
				'format'      => 'currency',
			),
			'taxes'          => array(
				'description' => __( 'Total of taxes.', 'woocommerce' ),
				'type'        => 'number',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
				'format'      => 'currency',
			),
			'refunds'        => array(
				'title'       => __( 'Returns', 'woocommerce' ),
				'description' => __( 'Total of returns.', 'woocommerce' ),
				'type'        => 'number',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
				'indicator'   => true,
				'format'      => 'currency',
			),
			'orders_count'   => array(
				'description' => __( 'Number of orders.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
			'num_items_sold' => array(
				'description' => __( 'Items sold.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
			'products'       => array(
				'description' => __( 'Products sold.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
			'gross_sales'    => array(
				'description' => __( 'Gross sales.', 'woocommerce' ),
				'type'        => 'number',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
				'indicator'   => true,
				'format'      => 'currency',
			),
		);

		$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_revenue_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',
				'total_sales',
				'coupons',
				'refunds',
				'shipping',
				'taxes',
				'net_revenue',
				'orders_count',
				'items_sold',
				'gross_sales',
			),
			'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['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['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 the column names for export.
	 *
	 * @return array Key value pair of Column ID => Label.
	 */
	public function get_export_columns() {
		return array(
			'date'         => __( 'Date', 'woocommerce' ),
			'orders_count' => __( 'Orders', 'woocommerce' ),
			'gross_sales'  => __( 'Gross sales', 'woocommerce' ),
			'refunds'      => __( 'Returns', 'woocommerce' ),
			'coupons'      => __( 'Coupons', 'woocommerce' ),
			'net_revenue'  => __( 'Net sales', 'woocommerce' ),
			'taxes'        => __( 'Taxes', 'woocommerce' ),
			'shipping'     => __( 'Shipping', 'woocommerce' ),
			'total_sales'  => __( 'Total sales', 'woocommerce' ),
		);
	}

	/**
	 * 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 ) {
		$subtotals = (array) $item['subtotals'];

		return array(
			'date'         => $item['date_start'],
			'orders_count' => $subtotals['orders_count'],
			'gross_sales'  => self::csv_number_format( $subtotals['gross_sales'] ),
			'refunds'      => self::csv_number_format( $subtotals['refunds'] ),
			'coupons'      => self::csv_number_format( $subtotals['coupons'] ),
			'net_revenue'  => self::csv_number_format( $subtotals['net_revenue'] ),
			'taxes'        => self::csv_number_format( $subtotals['taxes'] ),
			'shipping'     => self::csv_number_format( $subtotals['shipping'] ),
			'total_sales'  => self::csv_number_format( $subtotals['total_sales'] ),
		);
	}
}
Reports/Revenue/Query.php000064400000003100152333372110011423 0ustar00<?php
/**
 * Class for parameter-based Revenue Reports querying
 *
 * Example usage:
 * $args = array(
 *          'before' => '2018-07-19 00:00:00',
 *          'after'  => '2018-07-05 00:00:00',
 *          'interval' => 'week',
 *         );
 * $report = new \Automattic\WooCommerce\Admin\API\Reports\Revenue\Query( $args );
 * $mydata = $report->get_data();
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Revenue;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;

/**
 * API\Reports\Revenue\Query
 */
class Query extends ReportsQuery {

	/**
	 * Valid fields for Revenue report.
	 *
	 * @return array
	 */
	protected function get_default_query_vars() {
		return array(
			'per_page' => get_option( 'posts_per_page' ), // not sure if this should be the default.
			'page'     => 1,
			'order'    => 'DESC',
			'orderby'  => 'date',
			'before'   => '',
			'after'    => '',
			'interval' => 'week',
			'fields'   => array(
				'orders_count',
				'num_items_sold',
				'total_sales',
				'coupons',
				'coupons_count',
				'refunds',
				'taxes',
				'shipping',
				'net_revenue',
				'gross_sales',
			),
		);
	}

	/**
	 * Get revenue data based on the current query vars.
	 *
	 * @return array
	 */
	public function get_data() {
		$args = apply_filters( 'woocommerce_analytics_revenue_query_args', $this->get_query_vars() );

		$data_store = \WC_Data_Store::load( 'report-revenue-stats' );
		$results    = $data_store->get_data( $args );
		return apply_filters( 'woocommerce_analytics_revenue_select_query', $results, $args );
	}
}
Reports/Export/Controller.php000064400000015570152333372110012327 0ustar00<?php
/**
 * REST API Reports Export Controller
 *
 * Handles requests to:
 * - /reports/[report]/export
 * - /reports/[report]/export/[id]/status
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Export;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\ReportExporter;

/**
 * Reports Export controller.
 *
 * @internal
 * @extends \Automattic\WooCommerce\Admin\API\Reports\Controller
 */
class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller {
	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'reports/(?P<type>[a-z]+)/export';

	/**
	 * Register routes.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'export_items' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
					'args'                => $this->get_export_collection_params(),
				),
				'schema' => array( $this, 'get_export_public_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<export_id>[a-z0-9]+)/status',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'export_status' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
				),
				'schema' => array( $this, 'get_export_status_public_schema' ),
			)
		);
	}

	/**
	 * Get the query params for collections.
	 *
	 * @return array
	 */
	protected function get_export_collection_params() {
		$params                = array();
		$params['report_args'] = array(
			'description'       => __( 'Parameters to pass on to the exported report.', 'woocommerce' ),
			'type'              => 'object',
			'validate_callback' => 'rest_validate_request_arg', // @todo: use each controller's schema?
		);
		$params['email']       = array(
			'description'       => __( 'When true, email a link to download the export to the requesting user.', 'woocommerce' ),
			'type'              => 'boolean',
			'validate_callback' => 'rest_validate_request_arg',
		);
		return $params;
	}

	/**
	 * Get the Report Export's schema, conforming to JSON Schema.
	 *
	 * @return array
	 */
	public function get_export_public_schema() {
		$schema = array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => 'report_export',
			'type'       => 'object',
			'properties' => array(
				'status'    => array(
					'description' => __( 'Export status.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'message'   => array(
					'description' => __( 'Export status message.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'export_id' => array(
					'description' => __( 'Export ID.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
			),
		);

		return $this->add_additional_fields_schema( $schema );
	}

	/**
	 * Get the Export status schema, conforming to JSON Schema.
	 *
	 * @return array
	 */
	public function get_export_status_public_schema() {
		$schema = array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => 'report_export_status',
			'type'       => 'object',
			'properties' => array(
				'percent_complete' => array(
					'description' => __( 'Percentage complete.', 'woocommerce' ),
					'type'        => 'int',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'download_url'     => array(
					'description' => __( 'Export download URL.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
			),
		);

		return $this->add_additional_fields_schema( $schema );
	}

	/**
	 * Export data based on user request params.
	 *
	 * @param  WP_REST_Request $request Request data.
	 * @return WP_Error|WP_REST_Response
	 */
	public function export_items( $request ) {
		$report_type = $request['type'];
		$report_args = empty( $request['report_args'] ) ? array() : $request['report_args'];
		$send_email  = isset( $request['email'] ) ? $request['email'] : false;

		$default_export_id = str_replace( '.', '', microtime( true ) );
		$export_id         = apply_filters( 'woocommerce_admin_export_id', $default_export_id );
		$export_id         = (string) sanitize_file_name( $export_id );

		$total_rows = ReportExporter::queue_report_export( $export_id, $report_type, $report_args, $send_email );

		if ( 0 === $total_rows ) {
			return rest_ensure_response(
				array(
					'message' => __( 'There is no data to export for the given request.', 'woocommerce' ),
				)
			);
		}

		ReportExporter::update_export_percentage_complete( $report_type, $export_id, 0 );

		$response = rest_ensure_response(
			array(
				'message'   => __( 'Your report file is being generated.', 'woocommerce' ),
				'export_id' => $export_id,
			)
		);

		// Include a link to the export status endpoint.
		$response->add_links(
			array(
				'status' => array(
					'href' => rest_url( sprintf( '%s/reports/%s/export/%s/status', $this->namespace, $report_type, $export_id ) ),
				),
			)
		);

		$data = $this->prepare_response_for_collection( $response );

		return rest_ensure_response( $data );
	}

	/**
	 * Export status based on user request params.
	 *
	 * @param  WP_REST_Request $request Request data.
	 * @return WP_Error|WP_REST_Response
	 */
	public function export_status( $request ) {
		$report_type = $request['type'];
		$export_id   = $request['export_id'];
		$percentage  = ReportExporter::get_export_percentage_complete( $report_type, $export_id );

		if ( false === $percentage ) {
			return new \WP_Error(
				'woocommerce_admin_reports_export_invalid_id',
				__( 'Sorry, there is no export with that ID.', 'woocommerce' ),
				array( 'status' => 404 )
			);
		}

		$result = array(
			'percent_complete' => $percentage,
		);

		// @todo - add thing in the links below instead?
		if ( 100 === $percentage ) {
			$query_args = array(
				'action'   => ReportExporter::DOWNLOAD_EXPORT_ACTION,
				'filename' => "wc-{$report_type}-report-export-{$export_id}",
			);

			$result['download_url'] = add_query_arg( $query_args, admin_url() );
		}

		// Wrap the data in a response object.
		$response = rest_ensure_response( $result );
		// Include a link to the export status endpoint.
		$response->add_links(
			array(
				'self' => array(
					'href' => rest_url( sprintf( '%s/reports/%s/export/%s/status', $this->namespace, $report_type, $export_id ) ),
				),
			)
		);

		$data = $this->prepare_response_for_collection( $response );

		return rest_ensure_response( $data );
	}
}
Reports/DataStore.php000064400000142455152333372110010614 0ustar00<?php
/**
 * Admin\API\Reports\DataStore class file.
 */

namespace Automattic\WooCommerce\Admin\API\Reports;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

use \Automattic\WooCommerce\Admin\API\Reports\TimeInterval;

/**
 * Admin\API\Reports\DataStore: Common parent for custom report data stores.
 */
class DataStore extends SqlQuery {

	/**
	 * Cache group for the reports.
	 *
	 * @var string
	 */
	protected $cache_group = 'reports';

	/**
	 * Time out for the cache.
	 *
	 * @var int
	 */
	protected $cache_timeout = 3600;

	/**
	 * Table used as a data store for this report.
	 *
	 * @var string
	 */
	protected static $table_name = '';

	/**
	 * Date field name.
	 *
	 * @var string
	 */
	protected $date_column_name = 'date_created';

	/**
	 * Mapping columns to data type to return correct response types.
	 *
	 * @var array
	 */
	protected $column_types = array();

	// @todo This does not really belong here, maybe factor out the comparison as separate class?
	/**
	 * Order by property, used in the cmp function.
	 *
	 * @var string
	 */
	private $order_by = '';

	/**
	 * Order property, used in the cmp function.
	 *
	 * @var string
	 */
	private $order = '';

	/**
	 * Query limit parameters.
	 *
	 * @var array
	 */
	private $limit_parameters = array();

	/**
	 * Data store context used to pass to filters.
	 *
	 * @var string
	 */
	protected $context = 'reports';

	/**
	 * Subquery object for query nesting.
	 *
	 * @var SqlQuery
	 */
	protected $subquery;

	/**
	 * Totals query object.
	 *
	 * @var SqlQuery
	 */
	protected $total_query;

	/**
	 * Intervals query object.
	 *
	 * @var SqlQuery
	 */
	protected $interval_query;

	/**
	 * Refresh the cache for the current query when true.
	 *
	 * @var bool
	 */
	protected $force_cache_refresh = false;

	/**
	 * Include debugging information in the returned data when true.
	 *
	 * @var bool
	 */
	protected $debug_cache = true;

	/**
	 * Debugging information to include in the returned data.
	 *
	 * @var array
	 */
	protected $debug_cache_data = array();

	/**
	 * Class constructor.
	 */
	public function __construct() {
		self::set_db_table_name();
		$this->assign_report_columns();

		if ( property_exists( $this, 'report_columns' ) ) {
			$this->report_columns = apply_filters(
				'woocommerce_admin_report_columns',
				$this->report_columns,
				$this->context,
				self::get_db_table_name()
			);
		}

		// Utilize enveloped responses to include debugging info.
		// See https://querymonitor.com/blog/2021/05/debugging-wordpress-rest-api-requests/
		if ( isset( $_GET['_envelope'] ) ) {
			$this->debug_cache = true;
			add_filter( 'rest_envelope_response', array( $this, 'add_debug_cache_to_envelope' ), 999, 2 );
		}
	}

	/**
	 * Get table name from database class.
	 */
	public static function get_db_table_name() {
		global $wpdb;
		return isset( $wpdb->{static::$table_name} ) ? $wpdb->{static::$table_name} : $wpdb->prefix . static::$table_name;
	}

	/**
	 * Set table name from database class.
	 */
	protected static function set_db_table_name() {
		global $wpdb;
		if ( static::$table_name && ! isset( $wpdb->{static::$table_name} ) ) {
			$wpdb->{static::$table_name} = $wpdb->prefix . static::$table_name;
		}
	}

	/**
	 * Whether or not the report should use the caching layer.
	 *
	 * Provides an opportunity for plugins to prevent reports from using cache.
	 *
	 * @return boolean Whether or not to utilize caching.
	 */
	protected function should_use_cache() {
		/**
		 * Determines if a report will utilize caching.
		 *
		 * @param bool $use_cache Whether or not to use cache.
		 * @param string $cache_key The report's cache key. Used to identify the report.
		 */
		return (bool) apply_filters( 'woocommerce_analytics_report_should_use_cache', true, $this->cache_key );
	}

	/**
	 * Returns string to be used as cache key for the data.
	 *
	 * @param array $params Query parameters.
	 * @return string
	 */
	protected function get_cache_key( $params ) {
		if ( isset( $params['force_cache_refresh'] ) ) {
			if ( true === $params['force_cache_refresh'] ) {
				$this->force_cache_refresh = true;
			}

			// We don't want this param in the key.
			unset( $params['force_cache_refresh'] );
		}

		if ( true === $this->debug_cache ) {
			$this->debug_cache_data['query_args'] = $params;
		}

		return implode(
			'_',
			array(
				'wc_report',
				$this->cache_key,
				md5( wp_json_encode( $params ) ),
			)
		);
	}

	/**
	 * Wrapper around Cache::get().
	 *
	 * @param string $cache_key Cache key.
	 * @return mixed
	 */
	protected function get_cached_data( $cache_key ) {
		if ( true === $this->debug_cache ) {
			$this->debug_cache_data['should_use_cache']    = $this->should_use_cache();
			$this->debug_cache_data['force_cache_refresh'] = $this->force_cache_refresh;
			$this->debug_cache_data['cache_hit']           = false;
		}

		if ( $this->should_use_cache() && false === $this->force_cache_refresh ) {
			$cached_data = Cache::get( $cache_key );

			$cache_hit = false !== $cached_data;
			if ( true === $this->debug_cache ) {
				$this->debug_cache_data['cache_hit'] = $cache_hit;
			}

			return $cached_data;
		}

		// Cached item has now functionally been refreshed. Reset the option.
		$this->force_cache_refresh = false;

		return false;
	}

	/**
	 * Wrapper around Cache::set().
	 *
	 * @param string $cache_key Cache key.
	 * @param mixed  $value     New value.
	 * @return bool
	 */
	protected function set_cached_data( $cache_key, $value ) {
		if ( $this->should_use_cache() ) {
			return Cache::set( $cache_key, $value );
		}

		return true;
	}

	/**
	 * Add cache debugging information to an enveloped API response.
	 *
	 * @param array             $envelope
	 * @param \WP_REST_Response $response
	 *
	 * @return array
	 */
	public function add_debug_cache_to_envelope( $envelope, $response ) {
		if ( 0 !== strncmp( '/wc-analytics', $response->get_matched_route(), 13 ) ) {
			return $envelope;
		}

		if ( ! empty( $this->debug_cache_data ) ) {
			$envelope['debug_cache'] = $this->debug_cache_data;
		}

		return $envelope;
	}

	/**
	 * Compares two report data objects by pre-defined object property and ASC/DESC ordering.
	 *
	 * @param stdClass $a Object a.
	 * @param stdClass $b Object b.
	 * @return string
	 */
	private function interval_cmp( $a, $b ) {
		if ( '' === $this->order_by || '' === $this->order ) {
			return 0;
			// @todo Should return WP_Error here perhaps?
		}
		if ( $a[ $this->order_by ] === $b[ $this->order_by ] ) {
			// As relative order is undefined in case of equality in usort, second-level sorting by date needs to be enforced
			// so that paging is stable.
			if ( $a['time_interval'] === $b['time_interval'] ) {
				return 0; // This should never happen.
			} elseif ( $a['time_interval'] > $b['time_interval'] ) {
				return 1;
			} elseif ( $a['time_interval'] < $b['time_interval'] ) {
				return -1;
			}
		} elseif ( $a[ $this->order_by ] > $b[ $this->order_by ] ) {
			return strtolower( $this->order ) === 'desc' ? -1 : 1;
		} elseif ( $a[ $this->order_by ] < $b[ $this->order_by ] ) {
			return strtolower( $this->order ) === 'desc' ? 1 : -1;
		}
	}

	/**
	 * Sorts intervals according to user's request.
	 *
	 * They are pre-sorted in SQL, but after adding gaps, they need to be sorted including the added ones.
	 *
	 * @param stdClass $data      Data object, must contain an array under $data->intervals.
	 * @param string   $sort_by   Ordering property.
	 * @param string   $direction DESC/ASC.
	 */
	protected function sort_intervals( &$data, $sort_by, $direction ) {
		$this->sort_array( $data->intervals, $sort_by, $direction );
	}

	/**
	 * Sorts array of arrays based on subarray key $sort_by.
	 *
	 * @param array  $arr       Array to sort.
	 * @param string $sort_by   Ordering property.
	 * @param string $direction DESC/ASC.
	 */
	protected function sort_array( &$arr, $sort_by, $direction ) {
		$this->order_by = $this->normalize_order_by( $sort_by );
		$this->order    = $direction;
		usort( $arr, array( $this, 'interval_cmp' ) );
	}

	/**
	 * Fills in interval gaps from DB with 0-filled objects.
	 *
	 * @param array    $db_intervals   Array of all intervals present in the db.
	 * @param DateTime $start_datetime Start date.
	 * @param DateTime $end_datetime   End date.
	 * @param string   $time_interval  Time interval, e.g. day, week, month.
	 * @param stdClass $data           Data with SQL extracted intervals.
	 * @return stdClass
	 */
	protected function fill_in_missing_intervals( $db_intervals, $start_datetime, $end_datetime, $time_interval, &$data ) {
		// @todo This is ugly and messy.
		$local_tz = new \DateTimeZone( wc_timezone_string() );
		// At this point, we don't know when we can stop iterating, as the ordering can be based on any value.
		$time_ids     = array_flip( wp_list_pluck( $data->intervals, 'time_interval' ) );
		$db_intervals = array_flip( $db_intervals );
		// Totals object used to get all needed properties.
		$totals_arr = get_object_vars( $data->totals );
		foreach ( $totals_arr as $key => $val ) {
			$totals_arr[ $key ] = 0;
		}
		// @todo Should 'products' be in intervals?
		unset( $totals_arr['products'] );
		while ( $start_datetime <= $end_datetime ) {
			$next_start = TimeInterval::iterate( $start_datetime, $time_interval );
			$time_id    = TimeInterval::time_interval_id( $time_interval, $start_datetime );
			// Either create fill-zero interval or use data from db.
			if ( $next_start > $end_datetime ) {
				$interval_end = $end_datetime->format( 'Y-m-d H:i:s' );
			} else {
				$prev_end_timestamp = (int) $next_start->format( 'U' ) - 1;
				$prev_end           = new \DateTime();
				$prev_end->setTimestamp( $prev_end_timestamp );
				$prev_end->setTimezone( $local_tz );
				$interval_end = $prev_end->format( 'Y-m-d H:i:s' );
			}
			if ( array_key_exists( $time_id, $time_ids ) ) {
				// For interval present in the db for this time frame, just fill in dates.
				$record               = &$data->intervals[ $time_ids[ $time_id ] ];
				$record['date_start'] = $start_datetime->format( 'Y-m-d H:i:s' );
				$record['date_end']   = $interval_end;
			} elseif ( ! array_key_exists( $time_id, $db_intervals ) ) {
				// For intervals present in the db outside of this time frame, do nothing.
				// For intervals not present in the db, fabricate it.
				$record_arr                  = array();
				$record_arr['time_interval'] = $time_id;
				$record_arr['date_start']    = $start_datetime->format( 'Y-m-d H:i:s' );
				$record_arr['date_end']      = $interval_end;
				$data->intervals[]           = array_merge( $record_arr, $totals_arr );
			}
			$start_datetime = $next_start;
		}
		return $data;
	}

	/**
	 * Converts input datetime parameters to local timezone. If there are no inputs from the user in query_args,
	 * uses default from $defaults.
	 *
	 * @param array $query_args Array of query arguments.
	 * @param array $defaults Array of default values.
	 */
	protected function normalize_timezones( &$query_args, $defaults ) {
		$local_tz = new \DateTimeZone( wc_timezone_string() );
		foreach ( array( 'before', 'after' ) as $query_arg_key ) {
			if ( isset( $query_args[ $query_arg_key ] ) && is_string( $query_args[ $query_arg_key ] ) ) {
				// Assume that unspecified timezone is a local timezone.
				$datetime = new \DateTime( $query_args[ $query_arg_key ], $local_tz );
				// In case timezone was forced by using +HH:MM, convert to local timezone.
				$datetime->setTimezone( $local_tz );
				$query_args[ $query_arg_key ] = $datetime;
			} elseif ( isset( $query_args[ $query_arg_key ] ) && is_a( $query_args[ $query_arg_key ], 'DateTime' ) ) {
				// In case timezone is in other timezone, convert to local timezone.
				$query_args[ $query_arg_key ]->setTimezone( $local_tz );
			} else {
				$query_args[ $query_arg_key ] = isset( $defaults[ $query_arg_key ] ) ? $defaults[ $query_arg_key ] : null;
			}
		}
	}

	/**
	 * Removes extra records from intervals so that only requested number of records get returned.
	 *
	 * @param stdClass $data           Data from whose intervals the records get removed.
	 * @param int      $page_no        Offset requested by the user.
	 * @param int      $items_per_page Number of records requested by the user.
	 * @param int      $db_interval_count Database interval count.
	 * @param int      $expected_interval_count Expected interval count on the output.
	 * @param string   $order_by Order by field.
	 * @param string   $order ASC or DESC.
	 */
	protected function remove_extra_records( &$data, $page_no, $items_per_page, $db_interval_count, $expected_interval_count, $order_by, $order ) {
		if ( 'date' === strtolower( $order_by ) ) {
			$offset = 0;
		} else {
			if ( 'asc' === strtolower( $order ) ) {
				$offset = ( $page_no - 1 ) * $items_per_page;
			} else {
				$offset = ( $page_no - 1 ) * $items_per_page - $db_interval_count;
			}
			$offset = $offset < 0 ? 0 : $offset;
		}
		$count = $expected_interval_count - ( $page_no - 1 ) * $items_per_page;
		if ( $count < 0 ) {
			$count = 0;
		} elseif ( $count > $items_per_page ) {
			$count = $items_per_page;
		}
		$data->intervals = array_slice( $data->intervals, $offset, $count );
	}

	/**
	 * Returns expected number of items on the page in case of date ordering.
	 *
	 * @param int $expected_interval_count Expected number of intervals in total.
	 * @param int $items_per_page          Number of items per page.
	 * @param int $page_no                 Page number.
	 *
	 * @return float|int
	 */
	protected function expected_intervals_on_page( $expected_interval_count, $items_per_page, $page_no ) {
		$total_pages = (int) ceil( $expected_interval_count / $items_per_page );
		if ( $page_no < $total_pages ) {
			return $items_per_page;
		} elseif ( $page_no === $total_pages ) {
			return $expected_interval_count - ( $page_no - 1 ) * $items_per_page;
		} else {
			return 0;
		}
	}

	/**
	 * Returns true if there are any intervals that need to be filled in the response.
	 *
	 * @param int    $expected_interval_count Expected number of intervals in total.
	 * @param int    $db_records              Total number of records for given period in the database.
	 * @param int    $items_per_page          Number of items per page.
	 * @param int    $page_no                 Page number.
	 * @param string $order                   asc or desc.
	 * @param string $order_by                Column by which the result will be sorted.
	 * @param int    $intervals_count         Number of records for given (possibly shortened) time interval.
	 *
	 * @return bool
	 */
	protected function intervals_missing( $expected_interval_count, $db_records, $items_per_page, $page_no, $order, $order_by, $intervals_count ) {
		if ( $expected_interval_count <= $db_records ) {
			return false;
		}
		if ( 'date' === $order_by ) {
			$expected_intervals_on_page = $this->expected_intervals_on_page( $expected_interval_count, $items_per_page, $page_no );
			return $intervals_count < $expected_intervals_on_page;
		}
		if ( 'desc' === $order ) {
			return $page_no > floor( $db_records / $items_per_page );
		}
		if ( 'asc' === $order ) {
			return $page_no <= ceil( ( $expected_interval_count - $db_records ) / $items_per_page );
		}
		// Invalid ordering.
		return false;
	}

	/**
	 * Updates the LIMIT query part for Intervals query of the report.
	 *
	 * If there are less records in the database than time intervals, then we need to remap offset in SQL query
	 * to fetch correct records.
	 *
	 * @param array  $query_args Query arguments.
	 * @param int    $db_interval_count Database interval count.
	 * @param int    $expected_interval_count Expected interval count on the output.
	 * @param string $table_name Name of the db table relevant for the date constraint.
	 */
	protected function update_intervals_sql_params( &$query_args, $db_interval_count, $expected_interval_count, $table_name ) {
		if ( $db_interval_count === $expected_interval_count ) {
			return;
		}

		$params   = $this->get_limit_params( $query_args );
		$local_tz = new \DateTimeZone( wc_timezone_string() );
		if ( 'date' === strtolower( $query_args['orderby'] ) ) {
			// page X in request translates to slightly different dates in the db, in case some
			// records are missing from the db.
			$start_iteration = 0;
			$end_iteration   = 0;
			if ( 'asc' === strtolower( $query_args['order'] ) ) {
				// ORDER BY date ASC.
				$new_start_date    = $query_args['after'];
				$intervals_to_skip = ( $query_args['page'] - 1 ) * $params['per_page'];
				$latest_end_date   = $query_args['before'];
				for ( $i = 0; $i < $intervals_to_skip; $i++ ) {
					if ( $new_start_date > $latest_end_date ) {
						$new_start_date  = $latest_end_date;
						$start_iteration = 0;
						break;
					}
					$new_start_date = TimeInterval::iterate( $new_start_date, $query_args['interval'] );
					$start_iteration ++;
				}

				$new_end_date = clone $new_start_date;
				for ( $i = 0; $i < $params['per_page']; $i++ ) {
					if ( $new_end_date > $latest_end_date ) {
						break;
					}
					$new_end_date = TimeInterval::iterate( $new_end_date, $query_args['interval'] );
					$end_iteration ++;
				}
				if ( $new_end_date > $latest_end_date ) {
					$new_end_date  = $latest_end_date;
					$end_iteration = 0;
				}
				if ( $end_iteration ) {
					$new_end_date_timestamp = (int) $new_end_date->format( 'U' ) - 1;
					$new_end_date->setTimestamp( $new_end_date_timestamp );
				}
			} else {
				// ORDER BY date DESC.
				$new_end_date        = $query_args['before'];
				$intervals_to_skip   = ( $query_args['page'] - 1 ) * $params['per_page'];
				$earliest_start_date = $query_args['after'];
				for ( $i = 0; $i < $intervals_to_skip; $i++ ) {
					if ( $new_end_date < $earliest_start_date ) {
						$new_end_date  = $earliest_start_date;
						$end_iteration = 0;
						break;
					}
					$new_end_date = TimeInterval::iterate( $new_end_date, $query_args['interval'], true );
					$end_iteration ++;
				}

				$new_start_date = clone $new_end_date;
				for ( $i = 0; $i < $params['per_page']; $i++ ) {
					if ( $new_start_date < $earliest_start_date ) {
						break;
					}
					$new_start_date = TimeInterval::iterate( $new_start_date, $query_args['interval'], true );
					$start_iteration ++;
				}
				if ( $new_start_date < $earliest_start_date ) {
					$new_start_date  = $earliest_start_date;
					$start_iteration = 0;
				}
				if ( $start_iteration ) {
					// @todo Is this correct? should it only be added if iterate runs? other two iterate instances, too?
					$new_start_date_timestamp = (int) $new_start_date->format( 'U' ) + 1;
					$new_start_date->setTimestamp( $new_start_date_timestamp );
				}
			}
			// @todo - Do this without modifying $query_args?
			$query_args['adj_after']  = $new_start_date;
			$query_args['adj_before'] = $new_end_date;
			$adj_after                = $new_start_date->format( TimeInterval::$sql_datetime_format );
			$adj_before               = $new_end_date->format( TimeInterval::$sql_datetime_format );
			$this->interval_query->clear_sql_clause( array( 'where_time', 'limit' ) );
			$this->interval_query->add_sql_clause( 'where_time', "AND {$table_name}.`{$this->date_column_name}` <= '$adj_before'" );
			$this->interval_query->add_sql_clause( 'where_time', "AND {$table_name}.`{$this->date_column_name}` >= '$adj_after'" );
			$this->clear_sql_clause( 'limit' );
			$this->add_sql_clause( 'limit', 'LIMIT 0,' . $params['per_page'] );
		} else {
			if ( 'asc' === $query_args['order'] ) {
				$offset = ( ( $query_args['page'] - 1 ) * $params['per_page'] ) - ( $expected_interval_count - $db_interval_count );
				$offset = $offset < 0 ? 0 : $offset;
				$count  = $query_args['page'] * $params['per_page'] - ( $expected_interval_count - $db_interval_count );
				if ( $count < 0 ) {
					$count = 0;
				} elseif ( $count > $params['per_page'] ) {
					$count = $params['per_page'];
				}

				$this->clear_sql_clause( 'limit' );
				$this->add_sql_clause( 'limit', 'LIMIT ' . $offset . ',' . $count );
			}
			// Otherwise no change in limit clause.
			// @todo - Do this without modifying $query_args?
			$query_args['adj_after']  = $query_args['after'];
			$query_args['adj_before'] = $query_args['before'];
		}
	}

	/**
	 * Casts strings returned from the database to appropriate data types for output.
	 *
	 * @param array $array Associative array of values extracted from the database.
	 * @return array|WP_Error
	 */
	protected function cast_numbers( $array ) {
		$retyped_array = array();
		$column_types  = apply_filters( 'woocommerce_rest_reports_column_types', $this->column_types, $array );
		foreach ( $array as $column_name => $value ) {
			if ( is_array( $value ) ) {
				$value = $this->cast_numbers( $value );
			}

			if ( isset( $column_types[ $column_name ] ) ) {
				$retyped_array[ $column_name ] = $column_types[ $column_name ]( $value );
			} else {
				$retyped_array[ $column_name ] = $value;
			}
		}
		return $retyped_array;
	}

	/**
	 * Returns a list of columns selected by the query_args formatted as a comma separated string.
	 *
	 * @param array $query_args User-supplied options.
	 * @return string
	 */
	protected function selected_columns( $query_args ) {
		$selections = $this->report_columns;

		if ( isset( $query_args['fields'] ) && is_array( $query_args['fields'] ) ) {
			$keep = array();
			foreach ( $query_args['fields'] as $field ) {
				if ( isset( $selections[ $field ] ) ) {
					$keep[ $field ] = $selections[ $field ];
				}
			}
			$selections = implode( ', ', $keep );
		} else {
			$selections = implode( ', ', $selections );
		}
		return $selections;
	}

	/**
	 * Get the excluded order statuses used when calculating reports.
	 *
	 * @return array
	 */
	protected static function get_excluded_report_order_statuses() {
		$excluded_statuses = \WC_Admin_Settings::get_option( 'woocommerce_excluded_report_order_statuses', array( 'pending', 'failed', 'cancelled' ) );
		$excluded_statuses = array_merge( array( 'auto-draft', 'trash' ), array_map( 'esc_sql', $excluded_statuses ) );
		return apply_filters( 'woocommerce_analytics_excluded_order_statuses', $excluded_statuses );
	}

	/**
	 * Maps order status provided by the user to the one used in the database.
	 *
	 * @param string $status Order status.
	 * @return string
	 */
	protected static function normalize_order_status( $status ) {
		$status = trim( $status );
		return 'wc-' . $status;
	}

	/**
	 * Normalizes order_by clause to match to SQL query.
	 *
	 * @param string $order_by Order by option requested by user.
	 * @return string
	 */
	protected function normalize_order_by( $order_by ) {
		if ( 'date' === $order_by ) {
			return 'time_interval';
		}

		return $order_by;
	}

	/**
	 * Updates start and end dates for intervals so that they represent intervals' borders, not times when data in db were recorded.
	 *
	 * E.g. if there are db records for only Tuesday and Thursday this week, the actual week interval is [Mon, Sun], not [Tue, Thu].
	 *
	 * @param DateTime $start_datetime Start date.
	 * @param DateTime $end_datetime End date.
	 * @param string   $time_interval Time interval, e.g. day, week, month.
	 * @param array    $intervals Array of intervals extracted from SQL db.
	 */
	protected function update_interval_boundary_dates( $start_datetime, $end_datetime, $time_interval, &$intervals ) {
		$local_tz = new \DateTimeZone( wc_timezone_string() );
		foreach ( $intervals as $key => $interval ) {
			$datetime = new \DateTime( $interval['datetime_anchor'], $local_tz );

			$prev_start = TimeInterval::iterate( $datetime, $time_interval, true );
			// @todo Not sure if the +1/-1 here are correct, especially as they are applied before the ?: below.
			$prev_start_timestamp = (int) $prev_start->format( 'U' ) + 1;
			$prev_start->setTimestamp( $prev_start_timestamp );
			if ( $start_datetime ) {
				$date_start                      = $prev_start < $start_datetime ? $start_datetime : $prev_start;
				$intervals[ $key ]['date_start'] = $date_start->format( 'Y-m-d H:i:s' );
			} else {
				$intervals[ $key ]['date_start'] = $prev_start->format( 'Y-m-d H:i:s' );
			}

			$next_end           = TimeInterval::iterate( $datetime, $time_interval );
			$next_end_timestamp = (int) $next_end->format( 'U' ) - 1;
			$next_end->setTimestamp( $next_end_timestamp );
			if ( $end_datetime ) {
				$date_end                      = $next_end > $end_datetime ? $end_datetime : $next_end;
				$intervals[ $key ]['date_end'] = $date_end->format( 'Y-m-d H:i:s' );
			} else {
				$intervals[ $key ]['date_end'] = $next_end->format( 'Y-m-d H:i:s' );
			}

			$intervals[ $key ]['interval'] = $time_interval;
		}
	}

	/**
	 * Change structure of intervals to form a correct response.
	 *
	 * Also converts local datetimes to GMT and adds them to the intervals.
	 *
	 * @param array $intervals Time interval, e.g. day, week, month.
	 */
	protected function create_interval_subtotals( &$intervals ) {
		foreach ( $intervals as $key => $interval ) {
			$start_gmt = TimeInterval::convert_local_datetime_to_gmt( $interval['date_start'] );
			$end_gmt   = TimeInterval::convert_local_datetime_to_gmt( $interval['date_end'] );
			// Move intervals result to subtotals object.
			$intervals[ $key ] = array(
				'interval'       => $interval['time_interval'],
				'date_start'     => $interval['date_start'],
				'date_start_gmt' => $start_gmt->format( TimeInterval::$sql_datetime_format ),
				'date_end'       => $interval['date_end'],
				'date_end_gmt'   => $end_gmt->format( TimeInterval::$sql_datetime_format ),
			);

			unset( $interval['interval'] );
			unset( $interval['date_start'] );
			unset( $interval['date_end'] );
			unset( $interval['datetime_anchor'] );
			unset( $interval['time_interval'] );
			$intervals[ $key ]['subtotals'] = (object) $this->cast_numbers( $interval );
		}
	}

	/**
	 * Fills WHERE clause of SQL request with date-related constraints.
	 *
	 * @param array  $query_args Parameters supplied by the user.
	 * @param string $table_name Name of the db table relevant for the date constraint.
	 */
	protected function add_time_period_sql_params( $query_args, $table_name ) {
		$this->clear_sql_clause( array( 'from', 'where_time', 'where' ) );
		if ( isset( $this->subquery ) ) {
			$this->subquery->clear_sql_clause( 'where_time' );
		}

		if ( isset( $query_args['before'] ) && '' !== $query_args['before'] ) {
			if ( is_a( $query_args['before'], 'WC_DateTime' ) ) {
				$datetime_str = $query_args['before']->date( TimeInterval::$sql_datetime_format );
			} else {
				$datetime_str = $query_args['before']->format( TimeInterval::$sql_datetime_format );
			}
			if ( isset( $this->subquery ) ) {
				$this->subquery->add_sql_clause( 'where_time', "AND {$table_name}.`{$this->date_column_name}` <= '$datetime_str'" );
			} else {
				$this->add_sql_clause( 'where_time', "AND {$table_name}.`{$this->date_column_name}` <= '$datetime_str'" );
			}
		}

		if ( isset( $query_args['after'] ) && '' !== $query_args['after'] ) {
			if ( is_a( $query_args['after'], 'WC_DateTime' ) ) {
				$datetime_str = $query_args['after']->date( TimeInterval::$sql_datetime_format );
			} else {
				$datetime_str = $query_args['after']->format( TimeInterval::$sql_datetime_format );
			}
			if ( isset( $this->subquery ) ) {
				$this->subquery->add_sql_clause( 'where_time', "AND {$table_name}.`{$this->date_column_name}` >= '$datetime_str'" );
			} else {
				$this->add_sql_clause( 'where_time', "AND {$table_name}.`{$this->date_column_name}` >= '$datetime_str'" );
			}
		}
	}

	/**
	 * Fills LIMIT clause of SQL request based on user supplied parameters.
	 *
	 * @param array $query_args Parameters supplied by the user.
	 * @return array
	 */
	protected function get_limit_sql_params( $query_args ) {
		global $wpdb;
		$params = $this->get_limit_params( $query_args );

		$this->clear_sql_clause( 'limit' );
		$this->add_sql_clause( 'limit', $wpdb->prepare( 'LIMIT %d, %d', $params['offset'], $params['per_page'] ) );
		return $params;
	}

	/**
	 * Fills LIMIT parameters of SQL request based on user supplied parameters.
	 *
	 * @param array $query_args Parameters supplied by the user.
	 * @return array
	 */
	protected function get_limit_params( $query_args = array() ) {
		if ( isset( $query_args['per_page'] ) && is_numeric( $query_args['per_page'] ) ) {
			$this->limit_parameters['per_page'] = (int) $query_args['per_page'];
		} else {
			$this->limit_parameters['per_page'] = get_option( 'posts_per_page' );
		}

		$this->limit_parameters['offset'] = 0;
		if ( isset( $query_args['page'] ) ) {
			$this->limit_parameters['offset'] = ( (int) $query_args['page'] - 1 ) * $this->limit_parameters['per_page'];
		}

		return $this->limit_parameters;
	}

	/**
	 * Generates a virtual table given a list of IDs.
	 *
	 * @param array $ids          Array of IDs.
	 * @param array $id_field     Name of the ID field.
	 * @param array $other_values Other values that must be contained in the virtual table.
	 * @return array
	 */
	protected function get_ids_table( $ids, $id_field, $other_values = array() ) {
		global $wpdb;
		$selects = array();
		foreach ( $ids as $id ) {
			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$new_select = $wpdb->prepare( "SELECT %s AS {$id_field}", $id );
			foreach ( $other_values as $key => $value ) {
				$new_select .= $wpdb->prepare( ", %s AS {$key}", $value );
			}
			// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			array_push( $selects, $new_select );
		}
		return join( ' UNION ', $selects );
	}

	/**
	 * Returns a comma separated list of the fields in the `query_args`, if there aren't, returns `report_columns` keys.
	 *
	 * @param array $query_args Parameters supplied by the user.
	 * @return array
	 */
	protected function get_fields( $query_args ) {
		if ( isset( $query_args['fields'] ) && is_array( $query_args['fields'] ) ) {
			return $query_args['fields'];
		}
		return array_keys( $this->report_columns );
	}

	/**
	 * Returns a comma separated list of the field names prepared to be used for a selection after a join with `default_results`.
	 *
	 * @param array $fields                 Array of fields name.
	 * @param array $default_results_fields Fields to load from `default_results` table.
	 * @param array $outer_selections       Array of fields that are not selected in the inner query.
	 * @return string
	 */
	protected function format_join_selections( $fields, $default_results_fields, $outer_selections = array() ) {
		foreach ( $fields as $i => $field ) {
			foreach ( $default_results_fields as $default_results_field ) {
				if ( $field === $default_results_field ) {
					$field        = esc_sql( $field );
					$fields[ $i ] = "default_results.{$field} AS {$field}";
				}
			}
			if ( in_array( $field, $outer_selections, true ) && array_key_exists( $field, $this->report_columns ) ) {
				$fields[ $i ] = $this->report_columns[ $field ];
			}
		}
		return implode( ', ', $fields );
	}

	/**
	 * Fills ORDER BY clause of SQL request based on user supplied parameters.
	 *
	 * @param array $query_args Parameters supplied by the user.
	 */
	protected function add_order_by_sql_params( $query_args ) {
		if ( isset( $query_args['orderby'] ) ) {
			$order_by_clause = $this->normalize_order_by( esc_sql( $query_args['orderby'] ) );
		} else {
			$order_by_clause = '';
		}

		$this->clear_sql_clause( 'order_by' );
		$this->add_sql_clause( 'order_by', $order_by_clause );
		$this->add_orderby_order_clause( $query_args, $this );
	}

	/**
	 * Fills FROM and WHERE clauses of SQL request for 'Intervals' section of data response based on user supplied parameters.
	 *
	 * @param array  $query_args Parameters supplied by the user.
	 * @param string $table_name Name of the db table relevant for the date constraint.
	 */
	protected function add_intervals_sql_params( $query_args, $table_name ) {
		$this->clear_sql_clause( array( 'from', 'where_time', 'where' ) );

		$this->add_time_period_sql_params( $query_args, $table_name );

		if ( isset( $query_args['interval'] ) && '' !== $query_args['interval'] ) {
			$interval = $query_args['interval'];
			$this->clear_sql_clause( 'select' );
			$this->add_sql_clause( 'select', TimeInterval::db_datetime_format( $interval, $table_name, $this->date_column_name ) );
		}
	}

	/**
	 * Get join and where clauses for refunds based on user supplied parameters.
	 *
	 * @param array $query_args Parameters supplied by the user.
	 * @return array
	 */
	protected function get_refund_subquery( $query_args ) {
		global $wpdb;
		$table_name = $wpdb->prefix . 'wc_order_stats';
		$sql_query  = array(
			'where_clause' => '',
			'from_clause'  => '',
		);

		if ( ! isset( $query_args['refunds'] ) ) {
			return $sql_query;
		}

		if ( 'all' === $query_args['refunds'] ) {
			$sql_query['where_clause'] .= 'parent_id != 0';
		}

		if ( 'none' === $query_args['refunds'] ) {
			$sql_query['where_clause'] .= 'parent_id = 0';
		}

		if ( 'full' === $query_args['refunds'] || 'partial' === $query_args['refunds'] ) {
			$operator                   = 'full' === $query_args['refunds'] ? '=' : '!=';
			$sql_query['from_clause']  .= " JOIN {$table_name} parent_order_stats ON {$table_name}.parent_id = parent_order_stats.order_id";
			$sql_query['where_clause'] .= "parent_order_stats.status {$operator} '{$this->normalize_order_status( 'refunded' )}'";
		}

		return $sql_query;
	}

	/**
	 * Returns an array of products belonging to given categories.
	 *
	 * @param array $categories List of categories IDs.
	 * @return array|stdClass
	 */
	protected function get_products_by_cat_ids( $categories ) {
		$terms = get_terms(
			array(
				'taxonomy' => 'product_cat',
				'include'  => $categories,
			)
		);

		if ( is_wp_error( $terms ) || empty( $terms ) ) {
			return array();
		}

		$args = array(
			'category' => wc_list_pluck( $terms, 'slug' ),
			'limit'    => -1,
			'return'   => 'ids',
		);
		return wc_get_products( $args );
	}

	/**
	 * Get WHERE filter by object ids subquery.
	 *
	 * @param string $select_table Select table name.
	 * @param string $select_field Select table object ID field name.
	 * @param string $filter_table Lookup table name.
	 * @param string $filter_field Lookup table object ID field name.
	 * @param string $compare      Comparison string (IN|NOT IN).
	 * @param string $id_list      Comma separated ID list.
	 *
	 * @return string
	 */
	protected function get_object_where_filter( $select_table, $select_field, $filter_table, $filter_field, $compare, $id_list ) {
		global $wpdb;
		if ( empty( $id_list ) ) {
			return '';
		}

		$lookup_name = isset( $wpdb->$filter_table ) ? $wpdb->$filter_table : $wpdb->prefix . $filter_table;
		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		return " {$select_table}.{$select_field} {$compare} (
			SELECT
				DISTINCT {$filter_table}.{$select_field}
			FROM
				{$filter_table}
			WHERE
				{$filter_table}.{$filter_field} IN ({$id_list})
		)";
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
	}

	/**
	 * Returns an array of ids of allowed products, based on query arguments from the user.
	 *
	 * @param array $query_args Parameters supplied by the user.
	 * @return array
	 */
	protected function get_included_products_array( $query_args ) {
		$included_products = array();
		$operator          = $this->get_match_operator( $query_args );

		if ( isset( $query_args['category_includes'] ) && is_array( $query_args['category_includes'] ) && count( $query_args['category_includes'] ) > 0 ) {
			$included_products = $this->get_products_by_cat_ids( $query_args['category_includes'] );

			// If no products were found in the specified categories, we will force an empty set
			// by matching a product ID of -1, unless the filters are OR/any and products are specified.
			if ( empty( $included_products ) ) {
				$included_products = array( '-1' );
			}
		}

		if ( isset( $query_args['product_includes'] ) && is_array( $query_args['product_includes'] ) && count( $query_args['product_includes'] ) > 0 ) {
			if ( count( $included_products ) > 0 ) {
				if ( 'AND' === $operator ) {
					// AND results in an intersection between products from selected categories and manually included products.
					$included_products = array_intersect( $included_products, $query_args['product_includes'] );
				} elseif ( 'OR' === $operator ) {
					// OR results in a union of products from selected categories and manually included products.
					$included_products = array_merge( $included_products, $query_args['product_includes'] );
				}
			} else {
				$included_products = $query_args['product_includes'];
			}
		}

		return $included_products;
	}

	/**
	 * Returns comma separated ids of allowed products, based on query arguments from the user.
	 *
	 * @param array $query_args Parameters supplied by the user.
	 * @return string
	 */
	protected function get_included_products( $query_args ) {
		$included_products = $this->get_included_products_array( $query_args );
		return implode( ',', $included_products );
	}

	/**
	 * Returns comma separated ids of allowed variations, based on query arguments from the user.
	 *
	 * @param array $query_args Parameters supplied by the user.
	 * @return string
	 */
	protected function get_included_variations( $query_args ) {
		return $this->get_filtered_ids( $query_args, 'variation_includes' );
	}

	/**
	 * Returns comma separated ids of excluded variations, based on query arguments from the user.
	 *
	 * @param array $query_args Parameters supplied by the user.
	 * @return string
	 */
	protected function get_excluded_variations( $query_args ) {
		return $this->get_filtered_ids( $query_args, 'variation_excludes' );
	}

	/**
	 * Returns an array of ids of disallowed products, based on query arguments from the user.
	 *
	 * @param array $query_args Parameters supplied by the user.
	 * @return array
	 */
	protected function get_excluded_products_array( $query_args ) {
		$excluded_products = array();
		$operator          = $this->get_match_operator( $query_args );

		if ( isset( $query_args['category_excludes'] ) && is_array( $query_args['category_excludes'] ) && count( $query_args['category_excludes'] ) > 0 ) {
			$excluded_products = $this->get_products_by_cat_ids( $query_args['category_excludes'] );
		}

		if ( isset( $query_args['product_excludes'] ) && is_array( $query_args['product_excludes'] ) && count( $query_args['product_excludes'] ) > 0 ) {
			$excluded_products = array_merge( $excluded_products, $query_args['product_excludes'] );
		}

		return $excluded_products;
	}

	/**
	 * Returns comma separated ids of excluded products, based on query arguments from the user.
	 *
	 * @param array $query_args Parameters supplied by the user.
	 * @return string
	 */
	protected function get_excluded_products( $query_args ) {
		$excluded_products = $this->get_excluded_products_array( $query_args );
		return implode( ',', $excluded_products );
	}

	/**
	 * Returns comma separated ids of included categories, based on query arguments from the user.
	 *
	 * @param array $query_args Parameters supplied by the user.
	 * @return string
	 */
	protected function get_included_categories( $query_args ) {
		return $this->get_filtered_ids( $query_args, 'category_includes' );
	}

	/**
	 * Returns comma separated ids of included coupons, based on query arguments from the user.
	 *
	 * @param array  $query_args Parameters supplied by the user.
	 * @param string $field      Field name in the parameter list.
	 * @return string
	 */
	protected function get_included_coupons( $query_args, $field = 'coupon_includes' ) {
		return $this->get_filtered_ids( $query_args, $field );
	}

	/**
	 * Returns comma separated ids of excluded coupons, based on query arguments from the user.
	 *
	 * @param array $query_args Parameters supplied by the user.
	 * @return string
	 */
	protected function get_excluded_coupons( $query_args ) {
		return $this->get_filtered_ids( $query_args, 'coupon_excludes' );
	}

	/**
	 * Returns comma separated ids of included orders, based on query arguments from the user.
	 *
	 * @param array $query_args Parameters supplied by the user.
	 * @return string
	 */
	protected function get_included_orders( $query_args ) {
		return $this->get_filtered_ids( $query_args, 'order_includes' );
	}

	/**
	 * Returns comma separated ids of excluded orders, based on query arguments from the user.
	 *
	 * @param array $query_args Parameters supplied by the user.
	 * @return string
	 */
	protected function get_excluded_orders( $query_args ) {
		return $this->get_filtered_ids( $query_args, 'order_excludes' );
	}

	/**
	 * Returns comma separated ids of included users, based on query arguments from the user.
	 *
	 * @param array $query_args Parameters supplied by the user.
	 * @return string
	 */
	protected function get_included_users( $query_args ) {
		return $this->get_filtered_ids( $query_args, 'user_includes' );
	}

	/**
	 * Returns comma separated ids of excluded users, based on query arguments from the user.
	 *
	 * @param array $query_args Parameters supplied by the user.
	 * @return string
	 */
	protected function get_excluded_users( $query_args ) {
		return $this->get_filtered_ids( $query_args, 'user_excludes' );
	}

	/**
	 * Returns order status subquery to be used in WHERE SQL query, based on query arguments from the user.
	 *
	 * @param array  $query_args Parameters supplied by the user.
	 * @param string $operator   AND or OR, based on match query argument.
	 * @return string
	 */
	protected function get_status_subquery( $query_args, $operator = 'AND' ) {
		global $wpdb;

		$subqueries        = array();
		$excluded_statuses = array();
		if ( isset( $query_args['status_is'] ) && is_array( $query_args['status_is'] ) && count( $query_args['status_is'] ) > 0 ) {
			$allowed_statuses = array_map( array( $this, 'normalize_order_status' ), esc_sql( $query_args['status_is'] ) );
			if ( $allowed_statuses ) {
				$subqueries[] = "{$wpdb->prefix}wc_order_stats.status IN ( '" . implode( "','", $allowed_statuses ) . "' )";
			}
		}

		if ( isset( $query_args['status_is_not'] ) && is_array( $query_args['status_is_not'] ) && count( $query_args['status_is_not'] ) > 0 ) {
			$excluded_statuses = array_map( array( $this, 'normalize_order_status' ), $query_args['status_is_not'] );
		}

		if ( ( ! isset( $query_args['status_is'] ) || empty( $query_args['status_is'] ) )
			&& ( ! isset( $query_args['status_is_not'] ) || empty( $query_args['status_is_not'] ) )
		) {
			$excluded_statuses = array_map( array( $this, 'normalize_order_status' ), $this->get_excluded_report_order_statuses() );
		}

		if ( $excluded_statuses ) {
			$subqueries[] = "{$wpdb->prefix}wc_order_stats.status NOT IN ( '" . implode( "','", $excluded_statuses ) . "' )";
		}

		return implode( " $operator ", $subqueries );
	}

	/**
	 * Add order status SQL clauses if included in query.
	 *
	 * @param array    $query_args Parameters supplied by the user.
	 * @param string   $table_name Database table name.
	 * @param SqlQuery $sql_query  Query object.
	 */
	protected function add_order_status_clause( $query_args, $table_name, &$sql_query ) {
		global $wpdb;
		$order_status_filter = $this->get_status_subquery( $query_args );
		if ( $order_status_filter ) {
			$sql_query->add_sql_clause( 'join', "JOIN {$wpdb->prefix}wc_order_stats ON {$table_name}.order_id = {$wpdb->prefix}wc_order_stats.order_id" );
			$sql_query->add_sql_clause( 'where', "AND ( {$order_status_filter} )" );
		}
	}

	/**
	 * Add order by SQL clause if included in query.
	 *
	 * @param array    $query_args Parameters supplied by the user.
	 * @param SqlQuery $sql_query  Query object.
	 * @return string Order by clause.
	 */
	protected function add_order_by_clause( $query_args, &$sql_query ) {
		$order_by_clause = '';

		$sql_query->clear_sql_clause( array( 'order_by' ) );
		if ( isset( $query_args['orderby'] ) ) {
			$order_by_clause = $this->normalize_order_by( esc_sql( $query_args['orderby'] ) );
			$sql_query->add_sql_clause( 'order_by', $order_by_clause );
		}

		// Return ORDER BY clause to allow adding the sort field(s) to query via a JOIN.
		return $order_by_clause;
	}

	/**
	 * Add order by order SQL clause.
	 *
	 * @param array    $query_args Parameters supplied by the user.
	 * @param SqlQuery $sql_query  Query object.
	 */
	protected function add_orderby_order_clause( $query_args, &$sql_query ) {
		if ( isset( $query_args['order'] ) ) {
			$sql_query->add_sql_clause( 'order_by', esc_sql( $query_args['order'] ) );
		} else {
			$sql_query->add_sql_clause( 'order_by', 'DESC' );
		}
	}

	/**
	 * Returns customer subquery to be used in WHERE SQL query, based on query arguments from the user.
	 *
	 * @param array $query_args Parameters supplied by the user.
	 * @return string
	 */
	protected function get_customer_subquery( $query_args ) {
		global $wpdb;

		$customer_filter = '';
		if ( isset( $query_args['customer_type'] ) ) {
			if ( 'new' === strtolower( $query_args['customer_type'] ) ) {
				$customer_filter = " {$wpdb->prefix}wc_order_stats.returning_customer = 0";
			} elseif ( 'returning' === strtolower( $query_args['customer_type'] ) ) {
				$customer_filter = " {$wpdb->prefix}wc_order_stats.returning_customer = 1";
			}
		}

		return $customer_filter;
	}

	/**
	 * Returns product attribute subquery elements used in JOIN and WHERE clauses,
	 * based on query arguments from the user.
	 *
	 * @param array  $query_args Parameters supplied by the user.
	 * @param string $table_name Database table name.
	 * @return array
	 */
	protected function get_attribute_subqueries( $query_args, $table_name ) {
		global $wpdb;

		$sql_clauses           = array(
			'join'  => array(),
			'where' => array(),
		);
		$match_operator        = $this->get_match_operator( $query_args );
		$post_meta_comparators = array(
			'='  => 'attribute_is',
			'!=' => 'attribute_is_not',
		);

		foreach ( $post_meta_comparators as $comparator => $arg ) {
			if ( ! isset( $query_args[ $arg ] ) || ! is_array( $query_args[ $arg ] ) ) {
				continue;
			}
			foreach ( $query_args[ $arg ] as $attribute_term ) {
				// We expect tuples.
				if ( ! is_array( $attribute_term ) || 2 !== count( $attribute_term ) ) {
					continue;
				}

				// If the tuple is numeric, assume these are IDs.
				if ( is_numeric( $attribute_term[0] ) && is_numeric( $attribute_term[1] ) ) {
					$attribute_id = intval( $attribute_term[0] );
					$term_id      = intval( $attribute_term[1] );

					// Invalid IDs.
					if ( 0 === $attribute_id || 0 === $term_id ) {
						continue;
					}

					// @todo: Use wc_get_attribute () instead ?
					$attr_taxonomy = wc_attribute_taxonomy_name_by_id( $attribute_id );
					// Invalid attribute ID.
					if ( empty( $attr_taxonomy ) ) {
						continue;
					}

					$attr_term = get_term_by( 'id', $term_id, $attr_taxonomy );
					// Invalid term ID.
					if ( false === $attr_term ) {
						continue;
					}

					$meta_key   = sanitize_title( $attr_taxonomy );
					$meta_value = $attr_term->slug;
				} else {
					// Assume these are a custom attribute slug/value pair.
					$meta_key   = esc_sql( $attribute_term[0] );
					$meta_value = esc_sql( $attribute_term[1] );
				}

				$join_alias = 'orderitemmeta1';

				if ( empty( $sql_clauses['join'] ) ) {
					$table_name            = esc_sql( $table_name );
					$sql_clauses['join'][] = "JOIN {$wpdb->prefix}woocommerce_order_items orderitems ON orderitems.order_id = {$table_name}.order_id";
				}

				// If we're matching all filters (AND), we'll need multiple JOINs on postmeta.
				// If not, just one.
				if ( 'AND' === $match_operator || 1 === count( $sql_clauses['join'] ) ) {
					$join_idx              = count( $sql_clauses['join'] );
					$join_alias            = 'orderitemmeta' . $join_idx;
					$sql_clauses['join'][] = "JOIN {$wpdb->prefix}woocommerce_order_itemmeta as {$join_alias} ON {$join_alias}.order_item_id = orderitems.order_item_id";
				}

				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				$sql_clauses['where'][] = $wpdb->prepare( "( {$join_alias}.meta_key = %s AND {$join_alias}.meta_value {$comparator} %s )", $meta_key, $meta_value );
			}
		}

		// If we're matching multiple attributes and all filters (AND), make sure
		// we're matching attributes on the same product.
		$num_attribute_filters = count( $sql_clauses['join'] );

		for ( $i = 2; $i < $num_attribute_filters; $i++ ) {
			$join_alias            = 'orderitemmeta' . $i;
			$sql_clauses['join'][] = "AND orderitemmeta1.order_item_id = {$join_alias}.order_item_id";
		}

		return $sql_clauses;
	}

	/**
	 * Returns logic operator for WHERE subclause based on 'match' query argument.
	 *
	 * @param array $query_args Parameters supplied by the user.
	 * @return string
	 */
	protected function get_match_operator( $query_args ) {
		$operator = 'AND';

		if ( ! isset( $query_args['match'] ) ) {
			return $operator;
		}

		if ( 'all' === strtolower( $query_args['match'] ) ) {
			$operator = 'AND';
		} elseif ( 'any' === strtolower( $query_args['match'] ) ) {
			$operator = 'OR';
		}
		return $operator;
	}

	/**
	 * Returns filtered comma separated ids, based on query arguments from the user.
	 *
	 * @param array  $query_args Parameters supplied by the user.
	 * @param string $field      Query field to filter.
	 * @param string $separator  Field separator.
	 * @return string
	 */
	protected function get_filtered_ids( $query_args, $field, $separator = ',' ) {
		global $wpdb;

		$ids_str = '';
		$ids     = isset( $query_args[ $field ] ) && is_array( $query_args[ $field ] ) ? $query_args[ $field ] : array();

		/**
		 * Filter the IDs before retrieving report data.
		 *
		 * Allows filtering of the objects included or excluded from reports.
		 *
		 * @param array  $ids        List of object Ids.
		 * @param array  $query_args The original arguments for the request.
		 * @param string $field      The object type.
		 * @param string $context    The data store context.
		 */
		$ids = apply_filters( 'woocommerce_analytics_' . $field, $ids, $query_args, $field, $this->context );

		if ( ! empty( $ids ) ) {
			$placeholders = implode( $separator, array_fill( 0, count( $ids ), '%d' ) );
			/* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
			$ids_str = $wpdb->prepare( "{$placeholders}", $ids );
			/* phpcs:enable */
		}
		return $ids_str;
	}

	/**
	 * Assign report columns once full table name has been assigned.
	 */
	protected function assign_report_columns() {}
}
Reports/Orders/Query.php000064400000002350152333372110011256 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 );
	}
}
Reports/Orders/Stats/DataStore.php000064400000061766152333372110013155 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';

	/**
	 * 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( '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, $orders_stats_table );
		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_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',
			'%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() );
			}
		}

		// 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(
				"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' );
	}
}
Reports/Orders/Stats/Query.php000064400000003004152333372110012351 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 );
	}
}
Reports/Orders/Stats/Segmenter.php000064400000051635152333372110013212 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;
	}
}
Reports/Orders/Stats/Controller.php000064400000050522152333372110013376 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 {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * 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 );
		}

		$response = rest_ensure_response( $out_data );
		$response->header( 'X-WP-Total', (int) $report_data->total );
		$response->header( 'X-WP-TotalPages', (int) $report_data->pages );

		$page      = $report_data->page_no;
		$max_pages = $report_data->pages;
		$base      = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) );
		if ( $page > 1 ) {
			$prev_page = $page - 1;
			if ( $prev_page > $max_pages ) {
				$prev_page = $max_pages;
			}
			$prev_link = add_query_arg( 'page', $prev_page, $base );
			$response->link_header( 'prev', $prev_link );
		}
		if ( $max_pages > $page ) {
			$next_page = $page + 1;
			$next_link = add_query_arg( 'page', $next_page, $base );
			$response->link_header( 'next', $next_link );
		}

		return $response;
	}

	/**
	 * 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;
	}
}
Reports/Orders/Controller.php000064400000051016152333372110012277 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 {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * 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 );
		}

		$response = rest_ensure_response( $data );
		$response->header( 'X-WP-Total', (int) $report_data->total );
		$response->header( 'X-WP-TotalPages', (int) $report_data->pages );

		$page      = $report_data->page_no;
		$max_pages = $report_data->pages;
		$base      = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) );
		if ( $page > 1 ) {
			$prev_page = $page - 1;
			if ( $prev_page > $max_pages ) {
				$prev_page = $max_pages;
			}
			$prev_link = add_query_arg( 'page', $prev_page, $base );
			$response->link_header( 'prev', $prev_link );
		}
		if ( $max_pages > $page ) {
			$next_page = $page + 1;
			$next_link = add_query_arg( 'page', $next_page, $base );
			$response->link_header( 'next', $next_link );
		}

		return $response;
	}

	/**
	 * 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
		);
	}
}
Reports/Orders/DataStore.php000064400000045052152333372110012045 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 {

	/**
	 * 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",
			'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, $order_stats_lookup_table );
		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;

		$table_name = self::get_db_table_name();

		// 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'           => 'date_created',
			'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(*) 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 'date_created';
		}

		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() );
	}
}
Reports/DataStoreInterface.php000064400000000621152333372110012421 0ustar00<?php
/**
 * Reports Data Store Interface
 */

namespace Automattic\WooCommerce\Admin\API\Reports;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * WooCommerce Reports data store interface.
 *
 * @since 3.5.0
 */
interface DataStoreInterface {

	/**
	 * Get the data based on args.
	 *
	 * @param array $args Query parameters.
	 * @return stdClass|WP_Error
	 */
	public function get_data( $args );
}
Reports/ExportableTraits.php000064400000001160152333372110012205 0ustar00<?php
/**
 * REST API Reports exportable traits
 *
 * Collection of utility methods for exportable reports.
 */

namespace Automattic\WooCommerce\Admin\API\Reports;

defined( 'ABSPATH' ) || exit;

/**
 * ExportableTraits class.
 */
trait ExportableTraits {
	/**
	 * Format numbers for CSV using store precision setting.
	 *
	 * @param string|float $value Numeric value.
	 * @return string Formatted value.
	 */
	public static function csv_number_format( $value ) {
		$decimals = wc_get_price_decimals();
		// See: @woocommerce/currency: getCurrencyFormatDecimal().
		return number_format( $value, $decimals, '.', '' );
	}
}
Reports/Import/Controller.php000064400000021246152333372110012315 0ustar00<?php
/**
 * REST API Reports Import Controller
 *
 * Handles requests to /reports/import
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Import;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\ReportsSync;

/**
 * Reports Imports controller.
 *
 * @internal
 * @extends \Automattic\WooCommerce\Admin\API\Reports\Controller
 */
class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller {
	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'reports/import';

	/**
	 * Register routes.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'import_items' ),
					'permission_callback' => array( $this, 'import_permissions_check' ),
					'args'                => $this->get_import_collection_params(),
				),
				'schema' => array( $this, 'get_import_public_schema' ),
			)
		);
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/cancel',
			array(
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'cancel_import' ),
					'permission_callback' => array( $this, 'import_permissions_check' ),
				),
				'schema' => array( $this, 'get_import_public_schema' ),
			)
		);
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/delete',
			array(
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'delete_imported_items' ),
					'permission_callback' => array( $this, 'import_permissions_check' ),
				),
				'schema' => array( $this, 'get_import_public_schema' ),
			)
		);
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/status',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_import_status' ),
					'permission_callback' => array( $this, 'import_permissions_check' ),
				),
				'schema' => array( $this, 'get_import_public_schema' ),
			)
		);
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/totals',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_import_totals' ),
					'permission_callback' => array( $this, 'import_permissions_check' ),
					'args'                => $this->get_import_collection_params(),
				),
				'schema' => array( $this, 'get_import_public_schema' ),
			)
		);
	}

	/**
	 * Makes sure the current user has access to WRITE the settings APIs.
	 *
	 * @param WP_REST_Request $request Full data about the request.
	 * @return WP_Error|bool
	 */
	public function import_permissions_check( $request ) {
		if ( ! wc_rest_check_manager_permissions( 'settings', 'edit' ) ) {
			return new \WP_Error( 'woocommerce_rest_cannot_edit', __( 'Sorry, you cannot edit this resource.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
		}
		return true;
	}

	/**
	 * Import data based on user request params.
	 *
	 * @param  WP_REST_Request $request Request data.
	 * @return WP_Error|WP_REST_Response
	 */
	public function import_items( $request ) {
		$query_args = $this->prepare_objects_query( $request );
		$import     = ReportsSync::regenerate_report_data( $query_args['days'], $query_args['skip_existing'] );

		if ( is_wp_error( $import ) ) {
			$result = array(
				'status'  => 'error',
				'message' => $import->get_error_message(),
			);
		} else {
			$result = array(
				'status'  => 'success',
				'message' => $import,
			);
		}

		$response = $this->prepare_item_for_response( $result, $request );
		$data     = $this->prepare_response_for_collection( $response );

		return rest_ensure_response( $data );
	}

	/**
	 * Prepare request object as query args.
	 *
	 * @param WP_REST_Request $request Request data.
	 * @return array
	 */
	protected function prepare_objects_query( $request ) {
		$args                  = array();
		$args['skip_existing'] = $request['skip_existing'];
		$args['days']          = $request['days'];

		return $args;
	}

	/**
	 * Prepare the data object for response.
	 *
	 * @param object          $item Data object.
	 * @param WP_REST_Request $request Request object.
	 * @return WP_REST_Response $response Response data.
	 */
	public function prepare_item_for_response( $item, $request ) {
		$data     = $this->add_additional_fields_to_object( $item, $request );
		$data     = $this->filter_response_by_context( $data, 'view' );
		$response = rest_ensure_response( $data );

		/**
		 * Filter the list returned from the API.
		 *
		 * @param WP_REST_Response $response The response object.
		 * @param array            $item     The original item.
		 * @param WP_REST_Request  $request  Request used to generate the response.
		 */
		return apply_filters( 'woocommerce_rest_prepare_reports_import', $response, $item, $request );
	}

	/**
	 * Get the query params for collections.
	 *
	 * @return array
	 */
	public function get_import_collection_params() {
		$params                  = array();
		$params['days']          = array(
			'description'       => __( 'Number of days to import.', 'woocommerce' ),
			'type'              => 'integer',
			'sanitize_callback' => 'absint',
			'validate_callback' => 'rest_validate_request_arg',
			'minimum'           => 0,
		);
		$params['skip_existing'] = array(
			'description'       => __( 'Skip importing existing order data.', 'woocommerce' ),
			'type'              => 'boolean',
			'default'           => false,
			'sanitize_callback' => 'wc_string_to_bool',
			'validate_callback' => 'rest_validate_request_arg',
		);
		return $params;
	}

	/**
	 * Get the Report's schema, conforming to JSON Schema.
	 *
	 * @return array
	 */
	public function get_import_public_schema() {
		$schema = array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => 'report_import',
			'type'       => 'object',
			'properties' => array(
				'status'  => array(
					'description' => __( 'Regeneration status.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'message' => array(
					'description' => __( 'Regenerate data message.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
			),
		);

		return $this->add_additional_fields_schema( $schema );
	}

	/**
	 * Cancel all queued import actions.
	 *
	 * @param  WP_REST_Request $request Request data.
	 * @return WP_Error|WP_REST_Response
	 */
	public function cancel_import( $request ) {
		ReportsSync::clear_queued_actions();

		$result = array(
			'status'  => 'success',
			'message' => __( 'All pending and in-progress import actions have been cancelled.', 'woocommerce' ),
		);

		$response = $this->prepare_item_for_response( $result, $request );
		$data     = $this->prepare_response_for_collection( $response );

		return rest_ensure_response( $data );
	}

	/**
	 * Delete all imported items.
	 *
	 * @param  WP_REST_Request $request Request data.
	 * @return WP_Error|WP_REST_Response
	 */
	public function delete_imported_items( $request ) {
		$delete = ReportsSync::delete_report_data();

		if ( is_wp_error( $delete ) ) {
			$result = array(
				'status'  => 'error',
				'message' => $delete->get_error_message(),
			);
		} else {
			$result = array(
				'status'  => 'success',
				'message' => $delete,
			);
		}

		$response = $this->prepare_item_for_response( $result, $request );
		$data     = $this->prepare_response_for_collection( $response );

		return rest_ensure_response( $data );
	}

	/**
	 * Get the status of the current import.
	 *
	 * @param  WP_REST_Request $request Request data.
	 * @return WP_Error|WP_REST_Response
	 */
	public function get_import_status( $request ) {
		$result   = ReportsSync::get_import_stats();
		$response = $this->prepare_item_for_response( $result, $request );
		$data     = $this->prepare_response_for_collection( $response );

		return rest_ensure_response( $data );
	}

	/**
	 * Get the total orders and customers based on user supplied params.
	 *
	 * @param  WP_REST_Request $request Request data.
	 * @return WP_Error|WP_REST_Response
	 */
	public function get_import_totals( $request ) {
		$query_args = $this->prepare_objects_query( $request );
		$totals     = ReportsSync::get_import_totals( $query_args['days'], $query_args['skip_existing'] );

		$response = $this->prepare_item_for_response( $totals, $request );
		$data     = $this->prepare_response_for_collection( $response );

		return rest_ensure_response( $data );
	}
}
Reports/Stock/Stats/DataStore.php000064400000010567152333372110012773 0ustar00<?php
/**
 * API\Reports\Stock\Stats\DataStore class file.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Stock\Stats;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore;
use \Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface;

/**
 * API\Reports\Stock\Stats\DataStore.
 */
class DataStore extends ReportsDataStore implements DataStoreInterface {

	/**
	 * Get stock counts for the whole store.
	 *
	 * @param array $query Not used for the stock stats data store, but needed for the interface.
	 * @return array Array of counts.
	 */
	public function get_data( $query ) {
		$report_data              = array();
		$cache_expire             = DAY_IN_SECONDS * 30;
		$low_stock_transient_name = 'wc_admin_stock_count_lowstock';
		$low_stock_count          = get_transient( $low_stock_transient_name );
		if ( false === $low_stock_count ) {
			$low_stock_count = $this->get_low_stock_count();
			set_transient( $low_stock_transient_name, $low_stock_count, $cache_expire );
		} else {
			$low_stock_count = intval( $low_stock_count );
		}
		$report_data['lowstock'] = $low_stock_count;

		$status_options = wc_get_product_stock_status_options();
		foreach ( $status_options as $status => $label ) {
			$transient_name = 'wc_admin_stock_count_' . $status;
			$count          = get_transient( $transient_name );
			if ( false === $count ) {
				$count = $this->get_count( $status );
				set_transient( $transient_name, $count, $cache_expire );
			} else {
				$count = intval( $count );
			}
			$report_data[ $status ] = $count;
		}

		$product_count_transient_name = 'wc_admin_product_count';
		$product_count                = get_transient( $product_count_transient_name );
		if ( false === $product_count ) {
			$product_count = $this->get_product_count();
			set_transient( $product_count_transient_name, $product_count, $cache_expire );
		} else {
			$product_count = intval( $product_count );
		}
		$report_data['products'] = $product_count;
		return $report_data;
	}

	/**
	 * Get low stock count (products with stock < low stock amount, but greater than no stock amount).
	 *
	 * @return int Low stock count.
	 */
	private function get_low_stock_count() {
		global $wpdb;

		$no_stock_amount  = absint( max( get_option( 'woocommerce_notify_no_stock_amount' ), 0 ) );
		$low_stock_amount = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) );

		return (int) $wpdb->get_var(
			$wpdb->prepare(
				"
				SELECT count( DISTINCT posts.ID ) FROM {$wpdb->posts} posts
				LEFT JOIN {$wpdb->wc_product_meta_lookup} wc_product_meta_lookup ON posts.ID = wc_product_meta_lookup.product_id
				LEFT JOIN {$wpdb->postmeta} low_stock_amount_meta ON posts.ID = low_stock_amount_meta.post_id AND low_stock_amount_meta.meta_key = '_low_stock_amount'
				WHERE posts.post_type IN ( 'product', 'product_variation' )
				AND wc_product_meta_lookup.stock_quantity IS NOT NULL
				AND wc_product_meta_lookup.stock_status = 'instock'
				AND (
					(
						low_stock_amount_meta.meta_value > ''
						AND wc_product_meta_lookup.stock_quantity <= CAST(low_stock_amount_meta.meta_value AS SIGNED)
						AND wc_product_meta_lookup.stock_quantity > %d
					)
					OR (
						(
							low_stock_amount_meta.meta_value IS NULL OR low_stock_amount_meta.meta_value <= ''
						)
						AND wc_product_meta_lookup.stock_quantity <= %d
						AND wc_product_meta_lookup.stock_quantity > %d
					)
				)
				",
				$no_stock_amount,
				$low_stock_amount,
				$no_stock_amount
			)
		);
	}

	/**
	 * Get count for the passed in stock status.
	 *
	 * @param  string $status Status slug.
	 * @return int Count.
	 */
	private function get_count( $status ) {
		global $wpdb;

		return (int) $wpdb->get_var(
			$wpdb->prepare(
				"
				SELECT count( DISTINCT posts.ID ) FROM {$wpdb->posts} posts
				LEFT JOIN {$wpdb->wc_product_meta_lookup} wc_product_meta_lookup ON posts.ID = wc_product_meta_lookup.product_id
				WHERE posts.post_type IN ( 'product', 'product_variation' )
				AND wc_product_meta_lookup.stock_status = %s
				",
				$status
			)
		);
	}

	/**
	 * Get product count for the store.
	 *
	 * @return int Product count.
	 */
	private function get_product_count() {
		$query_args              = array();
		$query_args['post_type'] = array( 'product', 'product_variation' );
		$query                   = new \WP_Query();
		$query->query( $query_args );
		return intval( $query->found_posts );
	}
}
Reports/Stock/Stats/Query.php000064400000001317152333372110012203 0ustar00<?php
/**
 * Class for stock stats report querying
 *
 * $report = new \Automattic\WooCommerce\Admin\API\Reports\Stock\Stats\Query();
 * $mydata = $report->get_data();
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Stock\Stats;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;

/**
 * API\Reports\Stock\Stats\Query
 */
class Query extends ReportsQuery {

	/**
	 * Get product data based on the current query vars.
	 *
	 * @return array
	 */
	public function get_data() {
		$data_store = \WC_Data_Store::load( 'report-stock-stats' );
		$results    = $data_store->get_data();
		return apply_filters( 'woocommerce_analytics_stock_stats_query', $results );
	}
}
Reports/Stock/Stats/Controller.php000064400000007111152333372110013217 0ustar00<?php
/**
 * REST API Reports stock stats controller
 *
 * Handles requests to the /reports/stock/stats endpoint.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Stock\Stats;

defined( 'ABSPATH' ) || exit;

/**
 * REST API Reports stock stats controller class.
 *
 * @internal
 * @extends WC_REST_Reports_Controller
 */
class Controller extends \WC_REST_Reports_Controller {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'reports/stock/stats';

	/**
	 * Get Stock Status Totals.
	 *
	 * @param  WP_REST_Request $request Request data.
	 * @return array|WP_Error
	 */
	public function get_items( $request ) {
		$stock_query = new Query();
		$report_data = $stock_query->get_data();
		$out_data    = array(
			'totals' => $report_data,
		);
		return rest_ensure_response( $out_data );
	}

	/**
	 * Prepare a report object for serialization.
	 *
	 * @param  WC_Product      $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 WC_Product       $product   The original bject.
		 * @param WP_REST_Request  $request  Request used to generate the response.
		 */
		return apply_filters( 'woocommerce_rest_prepare_report_stock_stats', $response, $product, $request );
	}

	/**
	 * Get the Report's schema, conforming to JSON Schema.
	 *
	 * @return array
	 */
	public function get_item_schema() {
		$totals = array(
			'products' => array(
				'description' => __( 'Number of products.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
			'lowstock' => array(
				'description' => __( 'Number of low stock products.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
		);

		$status_options = wc_get_product_stock_status_options();
		foreach ( $status_options as $status => $label ) {
			$totals[ $status ] = array(
				/* translators: Stock status. Example: "Number of low stock products */
				'description' => sprintf( __( 'Number of %s products.', 'woocommerce' ), $label ),
				'type'        => 'integer',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			);
		}

		$schema = array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => 'report_customers_stats',
			'type'       => 'object',
			'properties' => array(
				'totals' => array(
					'description' => __( 'Totals data.', 'woocommerce' ),
					'type'        => 'object',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
					'properties'  => $totals,
				),
			),
		);

		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' ) );
		return $params;
	}
}
Reports/Stock/Controller.php000064400000044313152333372110012126 0ustar00<?php
/**
 * REST API Reports stock controller
 *
 * Handles requests to the /reports/stock endpoint.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Stock;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\ExportableInterface;

/**
 * REST API Reports stock controller class.
 *
 * @internal
 * @extends WC_REST_Reports_Controller
 */
class Controller extends \WC_REST_Reports_Controller implements ExportableInterface {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'reports/stock';

	/**
	 * Registered stock status options.
	 *
	 * @var array
	 */
	protected $status_options;

	/**
	 * Constructor.
	 */
	public function __construct() {
		$this->status_options = wc_get_product_stock_status_options();
	}

	/**
	 * Maps query arguments from the REST request.
	 *
	 * @param  WP_REST_Request $request Request array.
	 * @return array
	 */
	protected function prepare_reports_query( $request ) {
		$args                        = array();
		$args['offset']              = $request['offset'];
		$args['order']               = $request['order'];
		$args['orderby']             = $request['orderby'];
		$args['paged']               = $request['page'];
		$args['post__in']            = $request['include'];
		$args['post__not_in']        = $request['exclude'];
		$args['posts_per_page']      = $request['per_page'];
		$args['post_parent__in']     = $request['parent'];
		$args['post_parent__not_in'] = $request['parent_exclude'];

		if ( 'date' === $args['orderby'] ) {
			$args['orderby'] = 'date ID';
		} elseif ( 'include' === $args['orderby'] ) {
			$args['orderby'] = 'post__in';
		} elseif ( 'id' === $args['orderby'] ) {
			$args['orderby'] = 'ID'; // ID must be capitalized.
		}

		$args['post_type'] = array( 'product', 'product_variation' );

		if ( 'lowstock' === $request['type'] ) {
			$args['low_in_stock'] = true;
		} elseif ( in_array( $request['type'], array_keys( $this->status_options ), true ) ) {
			$args['stock_status'] = $request['type'];
		}

		$args['ignore_sticky_posts'] = true;

		return $args;
	}

	/**
	 * Query products.
	 *
	 * @param  array $query_args Query args.
	 * @return array
	 */
	protected function get_products( $query_args ) {
		$query  = new \WP_Query();
		$result = $query->query( $query_args );

		$total_posts = $query->found_posts;
		if ( $total_posts < 1 ) {
			// Out-of-bounds, run the query again without LIMIT for total count.
			unset( $query_args['paged'] );
			$count_query = new \WP_Query();
			$count_query->query( $query_args );
			$total_posts = $count_query->found_posts;
		}

		return array(
			'objects' => array_map( 'wc_get_product', $result ),
			'total'   => (int) $total_posts,
			'pages'   => (int) ceil( $total_posts / (int) $query->query_vars['posts_per_page'] ),
		);
	}

	/**
	 * Get all reports.
	 *
	 * @param  WP_REST_Request $request Request data.
	 * @return array|WP_Error
	 */
	public function get_items( $request ) {
		add_filter( 'posts_where', array( __CLASS__, 'add_wp_query_filter' ), 10, 2 );
		add_filter( 'posts_join', array( __CLASS__, 'add_wp_query_join' ), 10, 2 );
		add_filter( 'posts_groupby', array( __CLASS__, 'add_wp_query_group_by' ), 10, 2 );
		add_filter( 'posts_clauses', array( __CLASS__, 'add_wp_query_orderby' ), 10, 2 );
		$query_args    = $this->prepare_reports_query( $request );
		$query_results = $this->get_products( $query_args );
		remove_filter( 'posts_where', array( __CLASS__, 'add_wp_query_filter' ), 10 );
		remove_filter( 'posts_join', array( __CLASS__, 'add_wp_query_join' ), 10 );
		remove_filter( 'posts_groupby', array( __CLASS__, 'add_wp_query_group_by' ), 10 );
		remove_filter( 'posts_clauses', array( __CLASS__, 'add_wp_query_orderby' ), 10 );

		$objects = array();
		foreach ( $query_results['objects'] as $object ) {
			$data      = $this->prepare_item_for_response( $object, $request );
			$objects[] = $this->prepare_response_for_collection( $data );
		}

		$page      = (int) $query_args['paged'];
		$max_pages = $query_results['pages'];

		$response = rest_ensure_response( $objects );
		$response->header( 'X-WP-Total', $query_results['total'] );
		$response->header( 'X-WP-TotalPages', (int) $max_pages );

		$base = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) );

		if ( $page > 1 ) {
			$prev_page = $page - 1;
			if ( $prev_page > $max_pages ) {
				$prev_page = $max_pages;
			}
			$prev_link = add_query_arg( 'page', $prev_page, $base );
			$response->link_header( 'prev', $prev_link );
		}
		if ( $max_pages > $page ) {
			$next_page = $page + 1;
			$next_link = add_query_arg( 'page', $next_page, $base );
			$response->link_header( 'next', $next_link );
		}

		return $response;
	}

	/**
	 * Add in conditional search filters for products.
	 *
	 * @internal
	 * @param string $where Where clause used to search posts.
	 * @param object $wp_query WP_Query object.
	 * @return string
	 */
	public static function add_wp_query_filter( $where, $wp_query ) {
		global $wpdb;

		$stock_status = $wp_query->get( 'stock_status' );
		if ( $stock_status ) {
			$where .= $wpdb->prepare(
				' AND wc_product_meta_lookup.stock_status = %s ',
				$stock_status
			);
		}

		if ( $wp_query->get( 'low_in_stock' ) ) {
			// We want products with stock < low stock amount, but greater than no stock amount.
			$no_stock_amount  = absint( max( get_option( 'woocommerce_notify_no_stock_amount' ), 0 ) );
			$low_stock_amount = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) );
			$where           .= "
			AND wc_product_meta_lookup.stock_quantity IS NOT NULL
			AND wc_product_meta_lookup.stock_status = 'instock'
			AND (
				(
					low_stock_amount_meta.meta_value > ''
					AND wc_product_meta_lookup.stock_quantity <= CAST(low_stock_amount_meta.meta_value AS SIGNED)
					AND wc_product_meta_lookup.stock_quantity > {$no_stock_amount}
				)
				OR (
					(
						low_stock_amount_meta.meta_value IS NULL OR low_stock_amount_meta.meta_value <= ''
					)
					AND wc_product_meta_lookup.stock_quantity <= {$low_stock_amount}
					AND wc_product_meta_lookup.stock_quantity > {$no_stock_amount}
				)
			)";
		}

		return $where;
	}

	/**
	 * Join posts meta tables when product search or low stock query is present.
	 *
	 * @internal
	 * @param string $join Join clause used to search posts.
	 * @param object $wp_query WP_Query object.
	 * @return string
	 */
	public static function add_wp_query_join( $join, $wp_query ) {
		global $wpdb;

		$stock_status = $wp_query->get( 'stock_status' );
		if ( $stock_status ) {
			$join = self::append_product_sorting_table_join( $join );
		}

		if ( $wp_query->get( 'low_in_stock' ) ) {
			$join  = self::append_product_sorting_table_join( $join );
			$join .= " LEFT JOIN {$wpdb->postmeta} AS low_stock_amount_meta ON {$wpdb->posts}.ID = low_stock_amount_meta.post_id AND low_stock_amount_meta.meta_key = '_low_stock_amount' ";
		}

		return $join;
	}

	/**
	 * Join wc_product_meta_lookup to posts if not already joined.
	 *
	 * @internal
	 * @param string $sql SQL join.
	 * @return string
	 */
	protected static function append_product_sorting_table_join( $sql ) {
		global $wpdb;

		if ( ! strstr( $sql, 'wc_product_meta_lookup' ) ) {
			$sql .= " LEFT JOIN {$wpdb->wc_product_meta_lookup} wc_product_meta_lookup ON $wpdb->posts.ID = wc_product_meta_lookup.product_id ";
		}
		return $sql;
	}

	/**
	 * Group by post ID to prevent duplicates.
	 *
	 * @internal
	 * @param string $groupby Group by clause used to organize posts.
	 * @param object $wp_query WP_Query object.
	 * @return string
	 */
	public static function add_wp_query_group_by( $groupby, $wp_query ) {
		global $wpdb;

		if ( empty( $groupby ) ) {
			$groupby = $wpdb->posts . '.ID';
		}
		return $groupby;
	}

	/**
	 * Custom orderby clauses using the lookup tables.
	 *
	 * @internal
	 * @param array  $args Query args.
	 * @param object $wp_query WP_Query object.
	 * @return array
	 */
	public static function add_wp_query_orderby( $args, $wp_query ) {
		global $wpdb;

		$orderby = $wp_query->get( 'orderby' );
		$order   = esc_sql( $wp_query->get( 'order' ) ? $wp_query->get( 'order' ) : 'desc' );

		switch ( $orderby ) {
			case 'stock_quantity':
				$args['join']    = self::append_product_sorting_table_join( $args['join'] );
				$args['orderby'] = " wc_product_meta_lookup.stock_quantity {$order}, wc_product_meta_lookup.product_id {$order} ";
				break;
			case 'stock_status':
				$args['join']    = self::append_product_sorting_table_join( $args['join'] );
				$args['orderby'] = " wc_product_meta_lookup.stock_status {$order}, wc_product_meta_lookup.stock_quantity {$order} ";
				break;
			case 'sku':
				$args['join']    = self::append_product_sorting_table_join( $args['join'] );
				$args['orderby'] = " wc_product_meta_lookup.sku {$order}, wc_product_meta_lookup.product_id {$order} ";
				break;
		}

		return $args;
	}

	/**
	 * Prepare a report object for serialization.
	 *
	 * @param  WC_Product      $product  Report data.
	 * @param  WP_REST_Request $request Request object.
	 * @return WP_REST_Response
	 */
	public function prepare_item_for_response( $product, $request ) {
		$data = array(
			'id'               => $product->get_id(),
			'parent_id'        => $product->get_parent_id(),
			'name'             => wp_strip_all_tags( $product->get_name() ),
			'sku'              => $product->get_sku(),
			'stock_status'     => $product->get_stock_status(),
			'stock_quantity'   => (float) $product->get_stock_quantity(),
			'manage_stock'     => $product->get_manage_stock(),
			'low_stock_amount' => $product->get_low_stock_amount(),
		);

		if ( '' === $data['low_stock_amount'] ) {
			$data['low_stock_amount'] = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) );
		}

		$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( $product ) );

		/**
		 * 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 WC_Product       $product   The original product object.
		 * @param WP_REST_Request  $request  Request used to generate the response.
		 */
		return apply_filters( 'woocommerce_rest_prepare_report_stock', $response, $product, $request );
	}

	/**
	 * Prepare links for the request.
	 *
	 * @param  WC_Product $product Object data.
	 * @return array
	 */
	protected function prepare_links( $product ) {
		if ( $product->is_type( 'variation' ) ) {
			$links = array(
				'product' => array(
					'href' => rest_url( sprintf( '/%s/products/%d/variations/%d', $this->namespace, $product->get_parent_id(), $product->get_id() ) ),
				),
				'parent'  => array(
					'href' => rest_url( sprintf( '/%s/products/%d', $this->namespace, $product->get_parent_id() ) ),
				),
			);
		} elseif ( $product->get_parent_id() ) {
			$links = array(
				'product' => array(
					'href' => rest_url( sprintf( '/%s/products/%d', $this->namespace, $product->get_id() ) ),
				),
				'parent'  => array(
					'href' => rest_url( sprintf( '/%s/products/%d', $this->namespace, $product->get_parent_id() ) ),
				),
			);
		} else {
			$links = array(
				'product' => array(
					'href' => rest_url( sprintf( '/%s/products/%d', $this->namespace, $product->get_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_stock',
			'type'       => 'object',
			'properties' => array(
				'id'             => array(
					'description' => __( 'Unique identifier for the resource.', 'woocommerce' ),
					'type'        => 'integer',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'parent_id'      => array(
					'description' => __( 'Product parent ID.', 'woocommerce' ),
					'type'        => 'integer',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'name'           => array(
					'description' => __( 'Product name.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'sku'            => array(
					'description' => __( 'Unique identifier.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'stock_status'   => array(
					'description' => __( 'Stock status.', 'woocommerce' ),
					'type'        => 'string',
					'enum'        => array_keys( wc_get_product_stock_status_options() ),
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'stock_quantity' => array(
					'description' => __( 'Stock quantity.', 'woocommerce' ),
					'type'        => 'integer',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'manage_stock'   => array(
					'description' => __( 'Manage stock.', 'woocommerce' ),
					'type'        => 'boolean',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
			),
		);

		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['exclude']        = array(
			'description'       => __( 'Ensure result set excludes specific IDs.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'integer',
			),
			'default'           => array(),
			'sanitize_callback' => 'wp_parse_id_list',
		);
		$params['include']        = array(
			'description'       => __( 'Limit result set to specific ids.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'integer',
			),
			'default'           => array(),
			'sanitize_callback' => 'wp_parse_id_list',
		);
		$params['offset']         = array(
			'description'       => __( 'Offset the result set by a specific number of items.', 'woocommerce' ),
			'type'              => 'integer',
			'sanitize_callback' => 'absint',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['order']          = array(
			'description'       => __( 'Order sort attribute ascending or descending.', 'woocommerce' ),
			'type'              => 'string',
			'default'           => 'asc',
			'enum'              => array( 'asc', 'desc' ),
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['orderby']        = array(
			'description'       => __( 'Sort collection by object attribute.', 'woocommerce' ),
			'type'              => 'string',
			'default'           => 'stock_status',
			'enum'              => array(
				'stock_status',
				'stock_quantity',
				'date',
				'id',
				'include',
				'title',
				'sku',
			),
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['parent']         = array(
			'description'       => __( 'Limit result set to those of particular parent IDs.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'integer',
			),
			'sanitize_callback' => 'wp_parse_id_list',
			'default'           => array(),
		);
		$params['parent_exclude'] = array(
			'description'       => __( 'Limit result set to all items except those of a particular parent ID.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'integer',
			),
			'sanitize_callback' => 'wp_parse_id_list',
			'default'           => array(),
		);
		$params['type']           = array(
			'description' => __( 'Limit result set to items assigned a stock report type.', 'woocommerce' ),
			'type'        => 'string',
			'default'     => 'all',
			'enum'        => array_merge( array( 'all', 'lowstock' ), array_keys( wc_get_product_stock_status_options() ) ),
		);

		return $params;
	}

	/**
	 * Get the column names for export.
	 *
	 * @return array Key value pair of Column ID => Label.
	 */
	public function get_export_columns() {
		$export_columns = array(
			'title'          => __( 'Product / Variation', 'woocommerce' ),
			'sku'            => __( 'SKU', 'woocommerce' ),
			'stock_status'   => __( 'Status', 'woocommerce' ),
			'stock_quantity' => __( 'Stock', 'woocommerce' ),
		);

		/**
		 * Filter to add or remove column names from the stock report for
		 * export.
		 *
		 * @since 1.6.0
		 */
		return apply_filters(
			'woocommerce_report_stock_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 ) {
		$status = $item['stock_status'];
		if ( array_key_exists( $item['stock_status'], $this->status_options ) ) {
			$status = $this->status_options[ $item['stock_status'] ];
		}

		$export_item = array(
			'title'          => $item['name'],
			'sku'            => $item['sku'],
			'stock_status'   => $status,
			'stock_quantity' => $item['stock_quantity'],
		);

		/**
		 * Filter to prepare extra columns in the export item for the stock
		 * report.
		 *
		 * @since 1.6.0
		 */
		return apply_filters(
			'woocommerce_report_stock_prepare_export_item',
			$export_item,
			$item
		);
	}
}
Reports/Cache.php000064400000002753152333372110007725 0ustar00<?php
/**
 * REST API Reports Cache.
 *
 * Handles report data object caching.
 */

namespace Automattic\WooCommerce\Admin\API\Reports;

defined( 'ABSPATH' ) || exit;

/**
 * REST API Reports Cache class.
 */
class Cache {
	/**
	 * Cache version. Used to invalidate all cached values.
	 */
	const VERSION_OPTION = 'woocommerce_reports';

	/**
	 * Invalidate cache.
	 */
	public static function invalidate() {
		\WC_Cache_Helper::get_transient_version( self::VERSION_OPTION, true );
	}

	/**
	 * Get cache version number.
	 *
	 * @return string
	 */
	public static function get_version() {
		$version = \WC_Cache_Helper::get_transient_version( self::VERSION_OPTION );

		return $version;
	}

	/**
	 * Get cached value.
	 *
	 * @param string $key Cache key.
	 * @return mixed
	 */
	public static function get( $key ) {
		$transient_version = self::get_version();
		$transient_value   = get_transient( $key );

		if (
			isset( $transient_value['value'], $transient_value['version'] ) &&
			$transient_value['version'] === $transient_version
		) {
			return $transient_value['value'];
		}

		return false;
	}

	/**
	 * Update cached value.
	 *
	 * @param string $key   Cache key.
	 * @param mixed  $value New value.
	 * @return bool
	 */
	public static function set( $key, $value ) {
		$transient_version = self::get_version();
		$transient_value   = array(
			'version' => $transient_version,
			'value'   => $value,
		);

		$result = set_transient( $key, $transient_value, WEEK_IN_SECONDS );

		return $result;
	}
}
Reports/ParameterException.php000064400000000506152333372110012513 0ustar00<?php
/**
 * WooCommerce Admin Input Parameter Exception Class
 *
 * Exception class thrown when user provides incorrect parameters.
 */

namespace Automattic\WooCommerce\Admin\API\Reports;

defined( 'ABSPATH' ) || exit;

/**
 * API\Reports\ParameterException class.
 */
class ParameterException extends \WC_Data_Exception {}
Reports/Categories/DataStore.php000064400000025221152333372110012670 0ustar00<?php
/**
 * API\Reports\Categories\DataStore class file.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Categories;

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;

/**
 * API\Reports\Categories\DataStore.
 */
class DataStore extends ReportsDataStore implements DataStoreInterface {

	/**
	 * Table used to get the data.
	 *
	 * @var string
	 */
	protected static $table_name = 'wc_order_product_lookup';

	/**
	 * Cache identifier.
	 *
	 * @var string
	 */
	protected $cache_key = 'categories';

	/**
	 * Order by setting used for sorting categories data.
	 *
	 * @var string
	 */
	private $order_by = '';

	/**
	 * Order setting used for sorting categories data.
	 *
	 * @var string
	 */
	private $order = '';

	/**
	 * Mapping columns to data type to return correct response types.
	 *
	 * @var array
	 */
	protected $column_types = array(
		'category_id'    => 'intval',
		'items_sold'     => 'intval',
		'net_revenue'    => 'floatval',
		'orders_count'   => 'intval',
		'products_count' => 'intval',
	);

	/**
	 * Data store context used to pass to filters.
	 *
	 * @var string
	 */
	protected $context = 'categories';

	/**
	 * Assign report columns once full table name has been assigned.
	 */
	protected function assign_report_columns() {
		$table_name           = self::get_db_table_name();
		$this->report_columns = array(
			'items_sold'     => 'SUM(product_qty) as items_sold',
			'net_revenue'    => 'SUM(product_net_revenue) AS net_revenue',
			'orders_count'   => "COUNT(DISTINCT {$table_name}.order_id) as orders_count",
			'products_count' => "COUNT(DISTINCT {$table_name}.product_id) as products_count",
		);
	}

	/**
	 * Return the database query with parameters used for Categories report: time span and order status.
	 *
	 * @param array $query_args Query arguments supplied by the user.
	 */
	protected function add_sql_query_params( $query_args ) {
		global $wpdb;
		$order_product_lookup_table = self::get_db_table_name();

		$this->add_time_period_sql_params( $query_args, $order_product_lookup_table );

		// join wp_order_product_lookup_table with relationships and taxonomies
		// @todo How to handle custom product tables?
		$this->subquery->add_sql_clause( 'left_join', "LEFT JOIN {$wpdb->term_relationships} ON {$order_product_lookup_table}.product_id = {$wpdb->term_relationships}.object_id" );
		// Adding this (inner) JOIN as a LEFT JOIN for ordering purposes. See comment in add_order_by_params().
		$this->subquery->add_sql_clause( 'left_join', "JOIN {$wpdb->term_taxonomy} ON {$wpdb->term_taxonomy}.term_taxonomy_id = {$wpdb->term_relationships}.term_taxonomy_id" );

		$included_categories = $this->get_included_categories( $query_args );
		if ( $included_categories ) {
			$this->subquery->add_sql_clause( 'where', "AND {$wpdb->term_relationships}.term_taxonomy_id IN ({$included_categories})" );

			// Limit is left out here so that the grouping in code by PHP can be applied correctly.
			// This also needs to be put after the term_taxonomy JOIN so that we can match the correct term name.
			$this->add_order_by_params( $query_args, 'outer', 'default_results.category_id' );
		} else {
			$this->add_order_by_params( $query_args, 'inner', "{$wpdb->term_relationships}.term_taxonomy_id" );
		}

		$this->add_order_status_clause( $query_args, $order_product_lookup_table, $this->subquery );
		$this->subquery->add_sql_clause( 'where', "AND {$wpdb->term_taxonomy}.taxonomy = 'product_cat'" );
	}

	/**
	 * Fills ORDER BY clause of SQL request based on user supplied parameters.
	 *
	 * @param array  $query_args Parameters supplied by the user.
	 * @param string $from_arg   Target of the JOIN sql param.
	 * @param string $id_cell    ID cell identifier, like `table_name.id_column_name`.
	 */
	protected function add_order_by_params( $query_args, $from_arg, $id_cell ) {
		global $wpdb;

		// Sanitize input: guarantee that the id cell in the join is quoted with backticks.
		$id_cell_segments   = explode( '.', str_replace( '`', '', $id_cell ) );
		$id_cell_identifier = '`' . implode( '`.`', $id_cell_segments ) . '`';

		$lookup_table    = self::get_db_table_name();
		$order_by_clause = $this->add_order_by_clause( $query_args, $this );
		$this->add_orderby_order_clause( $query_args, $this );

		if ( false !== strpos( $order_by_clause, '_terms' ) ) {
			$join = "JOIN {$wpdb->terms} AS _terms ON {$id_cell_identifier} = _terms.term_id";
			if ( 'inner' === $from_arg ) {
				// Even though this is an (inner) JOIN, we're adding it as a `left_join` to
				// affect its order in the query statement. The SqlQuery::$sql_filters variable
				// determines the order in which joins are concatenated.
				// See: https://github.com/woocommerce/woocommerce-admin/blob/1f261998e7287b77bc13c3d4ee2e84b717da7957/src/API/Reports/SqlQuery.php#L46-L50.
				$this->subquery->add_sql_clause( 'left_join', $join );
			} else {
				$this->add_sql_clause( 'join', $join );
			}
		}
	}

	/**
	 * Maps ordering specified by the user to columns in the database/fields in the data.
	 *
	 * @param string $order_by Sorting criterion.
	 * @return string
	 */
	protected function normalize_order_by( $order_by ) {
		if ( 'date' === $order_by ) {
			return 'time_interval';
		}
		if ( 'category' === $order_by ) {
			return '_terms.name';
		}
		return $order_by;
	}

	/**
	 * Returns an array of ids of included categories, based on query arguments from the user.
	 *
	 * @param array $query_args Parameters supplied by the user.
	 * @return string
	 */
	protected function get_included_categories_array( $query_args ) {
		if ( isset( $query_args['category_includes'] ) && is_array( $query_args['category_includes'] ) && count( $query_args['category_includes'] ) > 0 ) {
			return $query_args['category_includes'];
		}
		return array();
	}

	/**
	 * Returns the page of data according to page number and items per page.
	 *
	 * @param array   $data           Data to paginate.
	 * @param integer $page_no        Page number.
	 * @param integer $items_per_page Number of items per page.
	 * @return array
	 */
	protected function page_records( $data, $page_no, $items_per_page ) {
		$offset = ( $page_no - 1 ) * $items_per_page;
		return array_slice( $data, $offset, $items_per_page );
	}

	/**
	 * Enriches the category data.
	 *
	 * @param array $categories_data Categories data.
	 * @param array $query_args  Query parameters.
	 */
	protected function include_extended_info( &$categories_data, $query_args ) {
		foreach ( $categories_data as $key => $category_data ) {
			$extended_info = new \ArrayObject();
			if ( $query_args['extended_info'] ) {
				$extended_info['name'] = get_the_category_by_ID( $category_data['category_id'] );
			}
			$categories_data[ $key ]['extended_info'] = $extended_info;
		}
	}

	/**
	 * 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 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'           => 'date',
			'before'            => TimeInterval::default_before(),
			'after'             => TimeInterval::default_after(),
			'fields'            => '*',
			'category_includes' => array(),
			'extended_info'     => false,
		);
		$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,
			);

			$this->subquery->add_sql_clause( 'select', $this->selected_columns( $query_args ) );
			$included_categories = $this->get_included_categories_array( $query_args );
			$this->add_sql_query_params( $query_args );

			if ( count( $included_categories ) > 0 ) {
				$fields    = $this->get_fields( $query_args );
				$ids_table = $this->get_ids_table( $included_categories, 'category_id' );

				$this->add_sql_clause( 'select', $this->format_join_selections( array_merge( array( 'category_id' ), $fields ), array( 'category_id' ) ) );
				$this->add_sql_clause( 'from', '(' );
				$this->add_sql_clause( 'from', $this->subquery->get_query_statement() );
				$this->add_sql_clause( 'from', ") AS {$table_name}" );
				$this->add_sql_clause(
					'right_join',
					"RIGHT JOIN ( {$ids_table} ) AS default_results
					ON default_results.category_id = {$table_name}.category_id"
				);

				$categories_query = $this->get_query_statement();
			} else {
				$this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) );
				$categories_query = $this->subquery->get_query_statement();
			}
			$categories_data = $wpdb->get_results(
				$categories_query, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
				ARRAY_A
			);

			if ( null === $categories_data ) {
				return new \WP_Error( 'woocommerce_analytics_categories_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ), array( 'status' => 500 ) );
			}

			$record_count = count( $categories_data );
			$total_pages  = (int) ceil( $record_count / $query_args['per_page'] );
			if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) {
				return $data;
			}

			$categories_data = $this->page_records( $categories_data, $query_args['page'], $query_args['per_page'] );
			$this->include_extended_info( $categories_data, $query_args );
			$categories_data = array_map( array( $this, 'cast_numbers' ), $categories_data );
			$data            = (object) array(
				'data'    => $categories_data,
				'total'   => $record_count,
				'pages'   => $total_pages,
				'page_no' => (int) $query_args['page'],
			);

			$this->set_cached_data( $cache_key, $data );
		}

		return $data;
	}

	/**
	 * Initialize query objects.
	 */
	protected function initialize_queries() {
		global $wpdb;
		$this->subquery = new SqlQuery( $this->context . '_subquery' );
		$this->subquery->add_sql_clause( 'select', "{$wpdb->term_taxonomy}.term_id as category_id," );
		$this->subquery->add_sql_clause( 'from', self::get_db_table_name() );
		$this->subquery->add_sql_clause( 'group_by', "{$wpdb->term_taxonomy}.term_id" );
	}
}
Reports/Categories/Query.php000064400000002370152333372110012107 0ustar00<?php
/**
 * Class for parameter-based Categories Report querying
 *
 * Example usage:
 * $args = array(
 *          'before'       => '2018-07-19 00:00:00',
 *          'after'        => '2018-07-05 00:00:00',
 *          'page'         => 2,
 *          'order'        => 'desc',
 *          'orderby'      => 'items_sold',
 *         );
 * $report = new \Automattic\WooCommerce\Admin\API\Reports\Categories\Query( $args );
 * $mydata = $report->get_data();
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Categories;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;

/**
 * API\Reports\Query
 */
class Query extends ReportsQuery {

	const REPORT_NAME = 'report-categories';

	/**
	 * Valid fields for Categories report.
	 *
	 * @return array
	 */
	protected function get_default_query_vars() {
		return array();
	}

	/**
	 * Get categories data based on the current query vars.
	 *
	 * @return array
	 */
	public function get_data() {
		$args    = apply_filters( 'woocommerce_analytics_categories_query_args', $this->get_query_vars() );
		$results = \WC_Data_Store::load( self::REPORT_NAME )->get_data( $args );
		return apply_filters( 'woocommerce_analytics_categories_select_query', $results, $args );
	}
}
Reports/Categories/Controller.php000064400000030131152333372110013121 0ustar00<?php
/**
 * REST API Reports categories controller
 *
 * Handles requests to the /reports/categories endpoint.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Categories;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\Controller as ReportsController;
use \Automattic\WooCommerce\Admin\API\Reports\ExportableInterface;

/**
 * REST API Reports categories controller class.
 *
 * @internal
 * @extends \Automattic\WooCommerce\Admin\API\Reports\Controller
 */
class Controller extends ReportsController implements ExportableInterface {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'reports/categories';

	/**
	 * 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['extended_info']       = $request['extended_info'];
		$args['category_includes']   = (array) $request['categories'];
		$args['status_is']           = (array) $request['status_is'];
		$args['status_is_not']       = (array) $request['status_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 );
		$categories_query = new Query( $query_args );
		$report_data      = $categories_query->get_data();

		if ( is_wp_error( $report_data ) ) {
			return $report_data;
		}

		if ( ! isset( $report_data->data ) || ! isset( $report_data->page_no ) || ! isset( $report_data->pages ) ) {
			return new \WP_Error( 'woocommerce_rest_reports_categories_invalid_response', __( 'Invalid response from data store.', 'woocommerce' ), array( 'status' => 500 ) );
		}

		$out_data = array();

		foreach ( $report_data->data as $datum ) {
			$item       = $this->prepare_item_for_response( $datum, $request );
			$out_data[] = $this->prepare_response_for_collection( $item );
		}

		$response = rest_ensure_response( $out_data );
		$response->header( 'X-WP-Total', (int) $report_data->total );
		$response->header( 'X-WP-TotalPages', (int) $report_data->pages );

		$page      = $report_data->page_no;
		$max_pages = $report_data->pages;
		$base      = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) );
		if ( $page > 1 ) {
			$prev_page = $page - 1;
			if ( $prev_page > $max_pages ) {
				$prev_page = $max_pages;
			}
			$prev_link = add_query_arg( 'page', $prev_page, $base );
			$response->link_header( 'prev', $prev_link );
		}
		if ( $max_pages > $page ) {
			$next_page = $page + 1;
			$next_link = add_query_arg( 'page', $next_page, $base );
			$response->link_header( 'next', $next_link );
		}

		return $response;
	}

	/**
	 * 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_categories', $response, $report, $request );
	}

	/**
	 * Prepare links for the request.
	 *
	 * @param \Automattic\WooCommerce\Admin\API\Reports\Query $object Object data.
	 * @return array
	 */
	protected function prepare_links( $object ) {
		$links = array(
			'category' => array(
				'href' => rest_url( sprintf( '/%s/products/categories/%d', $this->namespace, $object['category_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_categories',
			'type'       => 'object',
			'properties' => array(
				'category_id'    => array(
					'description' => __( 'Category ID.', 'woocommerce' ),
					'type'        => 'integer',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'items_sold'     => array(
					'description' => __( 'Amount of items sold.', 'woocommerce' ),
					'type'        => 'integer',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'net_revenue'    => array(
					'description' => __( 'Total sales.', 'woocommerce' ),
					'type'        => 'number',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'orders_count'   => array(
					'description' => __( 'Number of orders.', 'woocommerce' ),
					'type'        => 'integer',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'products_count' => array(
					'description' => __( 'Amount of products.', 'woocommerce' ),
					'type'        => 'integer',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'extended_info'  => array(
					'name' => array(
						'type'        => 'string',
						'readonly'    => true,
						'context'     => array( 'view', 'edit' ),
						'description' => __( 'Category name.', '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'           => 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'           => 'category_id',
			'enum'              => array(
				'category_id',
				'items_sold',
				'net_revenue',
				'orders_count',
				'products_count',
				'category',
			),
			'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['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['categories']    = array(
			'description'       => __( 'Limit result set to all items that have the specified term assigned in the categories taxonomy.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'type' => 'integer',
			),
		);
		$params['extended_info'] = array(
			'description'       => __( 'Add additional piece of info about each category to the report.', 'woocommerce' ),
			'type'              => 'boolean',
			'default'           => false,
			'sanitize_callback' => 'wc_string_to_bool',
			'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 the column names for export.
	 *
	 * @return array Key value pair of Column ID => Label.
	 */
	public function get_export_columns() {
		$export_columns = array(
			'category'       => __( 'Category', 'woocommerce' ),
			'items_sold'     => __( 'Items sold', 'woocommerce' ),
			'net_revenue'    => __( 'Net Revenue', 'woocommerce' ),
			'products_count' => __( 'Products', 'woocommerce' ),
			'orders_count'   => __( 'Orders', 'woocommerce' ),
		);

		/**
		 * Filter to add or remove column names from the categories report for
		 * export.
		 *
		 * @since 1.6.0
		 */
		return apply_filters(
			'woocommerce_report_categories_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(
			'category'       => $item['extended_info']['name'],
			'items_sold'     => $item['items_sold'],
			'net_revenue'    => $item['net_revenue'],
			'products_count' => $item['products_count'],
			'orders_count'   => $item['orders_count'],
		);

		/**
		 * Filter to prepare extra columns in the export item for the
		 * categories export.
		 *
		 * @since 1.6.0
		 */
		return apply_filters(
			'woocommerce_report_categories_prepare_export_item',
			$export_item,
			$item
		);
	}
}
Reports/Segmenter.php000064400000052326152333372110010654 0ustar00<?php
/**
 * Class for adding segmenting support without cluttering the data stores.
 */

namespace Automattic\WooCommerce\Admin\API\Reports;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\Coupons\DataStore as CouponsDataStore;
use \Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats\DataStore as TaxesStatsDataStore;

/**
 * Date & time interval and numeric range handling class for Reporting API.
 */
class Segmenter {

	/**
	 * Array of all segment ids.
	 *
	 * @var array|bool
	 */
	protected $all_segment_ids = false;

	/**
	 * Array of all segment labels.
	 *
	 * @var array
	 */
	protected $segment_labels = array();

	/**
	 * Query arguments supplied by the user for data store.
	 *
	 * @var array
	 */
	protected $query_args = '';

	/**
	 * SQL definition for each column.
	 *
	 * @var array
	 */
	protected $report_columns = array();

	/**
	 * Constructor.
	 *
	 * @param array $query_args Query arguments supplied by the user for data store.
	 * @param array $report_columns Report columns lookup from data store.
	 */
	public function __construct( $query_args, $report_columns ) {
		$this->query_args     = $query_args;
		$this->report_columns = $report_columns;
	}

	/**
	 * Filters definitions for SELECT clauses based on query_args and joins them into one string usable in SELECT clause.
	 *
	 * @param array $columns_mapping Column name -> SQL statememt mapping.
	 *
	 * @return string to be used in SELECT clause statements.
	 */
	protected function prepare_selections( $columns_mapping ) {
		if ( isset( $this->query_args['fields'] ) && is_array( $this->query_args['fields'] ) ) {
			$keep = array();
			foreach ( $this->query_args['fields'] as $field ) {
				if ( isset( $columns_mapping[ $field ] ) ) {
					$keep[ $field ] = $columns_mapping[ $field ];
				}
			}
			$selections = implode( ', ', $keep );
		} else {
			$selections = implode( ', ', $columns_mapping );
		}

		if ( $selections ) {
			$selections = ',' . $selections;
		}

		return $selections;
	}

	/**
	 * Update row-level db result for segments in 'totals' section to the format used for output.
	 *
	 * @param array  $segments_db_result Results from the SQL db query for segmenting.
	 * @param string $segment_dimension Name of column used for grouping the result.
	 *
	 * @return array Reformatted array.
	 */
	protected function reformat_totals_segments( $segments_db_result, $segment_dimension ) {
		$segment_result = array();

		if ( strpos( $segment_dimension, '.' ) ) {
			$segment_dimension = substr( strstr( $segment_dimension, '.' ), 1 );
		}

		$segment_labels = $this->get_segment_labels();
		foreach ( $segments_db_result as $segment_data ) {
			$segment_id = $segment_data[ $segment_dimension ];
			if ( ! isset( $segment_labels[ $segment_id ] ) ) {
				continue;
			}

			unset( $segment_data[ $segment_dimension ] );
			$segment_datum                 = array(
				'segment_id'    => $segment_id,
				'segment_label' => $segment_labels[ $segment_id ],
				'subtotals'     => $segment_data,
			);
			$segment_result[ $segment_id ] = $segment_datum;
		}

		return $segment_result;
	}

	/**
	 * Merges segmented results for totals response part.
	 *
	 * E.g. $r1 = array(
	 *     0 => array(
	 *          'product_id' => 3,
	 *          'net_amount' => 15,
	 *     ),
	 * );
	 * $r2 = array(
	 *     0 => array(
	 *          'product_id'      => 3,
	 *          'avg_order_value' => 25,
	 *     ),
	 * );
	 *
	 * $merged = array(
	 *     3 => array(
	 *          'segment_id' => 3,
	 *          'subtotals'  => array(
	 *              'net_amount'      => 15,
	 *              'avg_order_value' => 25,
	 *          )
	 *     ),
	 * );
	 *
	 * @param string $segment_dimension Name of the segment dimension=key in the result arrays used to match records from result sets.
	 * @param array  $result1 Array 1 of segmented figures.
	 * @param array  $result2 Array 2 of segmented figures.
	 *
	 * @return array
	 */
	protected function merge_segment_totals_results( $segment_dimension, $result1, $result2 ) {
		$result_segments = array();
		$segment_labels  = $this->get_segment_labels();

		foreach ( $result1 as $segment_data ) {
			$segment_id = $segment_data[ $segment_dimension ];
			if ( ! isset( $segment_labels[ $segment_id ] ) ) {
				continue;
			}

			unset( $segment_data[ $segment_dimension ] );
			$result_segments[ $segment_id ] = array(
				'segment_label' => $segment_labels[ $segment_id ],
				'segment_id'    => $segment_id,
				'subtotals'     => $segment_data,
			);
		}

		foreach ( $result2 as $segment_data ) {
			$segment_id = $segment_data[ $segment_dimension ];
			if ( ! isset( $segment_labels[ $segment_id ] ) ) {
				continue;
			}

			unset( $segment_data[ $segment_dimension ] );
			if ( ! isset( $result_segments[ $segment_id ] ) ) {
				$result_segments[ $segment_id ] = array(
					'segment_label' => $segment_labels[ $segment_id ],
					'segment_id'    => $segment_id,
					'subtotals'     => array(),
				);
			}
			$result_segments[ $segment_id ]['subtotals'] = array_merge( $result_segments[ $segment_id ]['subtotals'], $segment_data );
		}
		return $result_segments;
	}
	/**
	 * Merges segmented results for intervals response part.
	 *
	 * E.g. $r1 = array(
	 *     0 => array(
	 *          'product_id'    => 3,
	 *          'time_interval' => '2018-12'
	 *          'net_amount'    => 15,
	 *     ),
	 * );
	 * $r2 = array(
	 *     0 => array(
	 *          'product_id'      => 3,
	 *          'time_interval' => '2018-12'
	 *          'avg_order_value' => 25,
	 *     ),
	 * );
	 *
	 * $merged = array(
	 *     '2018-12' => array(
	 *          'segments' => array(
	 *              3 => array(
	 *                  'segment_id' => 3,
	 *                  'subtotals'  => array(
	 *                      'net_amount'      => 15,
	 *                      'avg_order_value' => 25,
	 *                  ),
	 *              ),
	 *          ),
	 *     ),
	 * );
	 *
	 * @param string $segment_dimension Name of the segment dimension=key in the result arrays used to match records from result sets.
	 * @param array  $result1 Array 1 of segmented figures.
	 * @param array  $result2 Array 2 of segmented figures.
	 *
	 * @return array
	 */
	protected function merge_segment_intervals_results( $segment_dimension, $result1, $result2 ) {
		$result_segments = array();
		$segment_labels  = $this->get_segment_labels();

		foreach ( $result1 as $segment_data ) {
			$segment_id = $segment_data[ $segment_dimension ];
			if ( ! isset( $segment_labels[ $segment_id ] ) ) {
				continue;
			}

			$time_interval = $segment_data['time_interval'];
			if ( ! isset( $result_segments[ $time_interval ] ) ) {
				$result_segments[ $time_interval ]             = array();
				$result_segments[ $time_interval ]['segments'] = array();
			}

			unset( $segment_data['time_interval'] );
			unset( $segment_data['datetime_anchor'] );
			unset( $segment_data[ $segment_dimension ] );
			$segment_datum = array(
				'segment_label' => $segment_labels[ $segment_id ],
				'segment_id'    => $segment_id,
				'subtotals'     => $segment_data,
			);
			$result_segments[ $time_interval ]['segments'][ $segment_id ] = $segment_datum;
		}

		foreach ( $result2 as $segment_data ) {
			$segment_id = $segment_data[ $segment_dimension ];
			if ( ! isset( $segment_labels[ $segment_id ] ) ) {
				continue;
			}

			$time_interval = $segment_data['time_interval'];
			if ( ! isset( $result_segments[ $time_interval ] ) ) {
				$result_segments[ $time_interval ]             = array();
				$result_segments[ $time_interval ]['segments'] = array();
			}

			unset( $segment_data['time_interval'] );
			unset( $segment_data['datetime_anchor'] );
			unset( $segment_data[ $segment_dimension ] );

			if ( ! isset( $result_segments[ $time_interval ]['segments'][ $segment_id ] ) ) {
				$result_segments[ $time_interval ]['segments'][ $segment_id ] = array(
					'segment_label' => $segment_labels[ $segment_id ],
					'segment_id'    => $segment_id,
					'subtotals'     => array(),
				);
			}
			$result_segments[ $time_interval ]['segments'][ $segment_id ]['subtotals'] = array_merge( $result_segments[ $time_interval ]['segments'][ $segment_id ]['subtotals'], $segment_data );
		}
		return $result_segments;
	}

	/**
	 * Update row-level db result for segments in 'intervals' section to the format used for output.
	 *
	 * @param array  $segments_db_result Results from the SQL db query for segmenting.
	 * @param string $segment_dimension Name of column used for grouping the result.
	 *
	 * @return array Reformatted array.
	 */
	protected function reformat_intervals_segments( $segments_db_result, $segment_dimension ) {
		$aggregated_segment_result = array();

		if ( strpos( $segment_dimension, '.' ) ) {
			$segment_dimension = substr( strstr( $segment_dimension, '.' ), 1 );
		}

		$segment_labels = $this->get_segment_labels();

		foreach ( $segments_db_result as $segment_data ) {
			$segment_id = $segment_data[ $segment_dimension ];
			if ( ! isset( $segment_labels[ $segment_id ] ) ) {
				continue;
			}

			$time_interval = $segment_data['time_interval'];
			if ( ! isset( $aggregated_segment_result[ $time_interval ] ) ) {
				$aggregated_segment_result[ $time_interval ]             = array();
				$aggregated_segment_result[ $time_interval ]['segments'] = array();
			}
			unset( $segment_data['time_interval'] );
			unset( $segment_data['datetime_anchor'] );
			unset( $segment_data[ $segment_dimension ] );
			$segment_datum = array(
				'segment_label' => $segment_labels[ $segment_id ],
				'segment_id'    => $segment_id,
				'subtotals'     => $segment_data,
			);
			$aggregated_segment_result[ $time_interval ]['segments'][ $segment_id ] = $segment_datum;
		}

		return $aggregated_segment_result;
	}

	/**
	 * Fetches all segment ids from db and stores it for later use.
	 *
	 * @return void
	 */
	protected function set_all_segments() {
		global $wpdb;

		if ( ! isset( $this->query_args['segmentby'] ) || '' === $this->query_args['segmentby'] ) {
			$this->all_segment_ids = array();
			return;
		}

		$segments       = array();
		$segment_labels = array();

		if ( 'product' === $this->query_args['segmentby'] ) {
			$args = array(
				'return' => 'objects',
				'limit'  => -1,
			);

			if ( isset( $this->query_args['product_includes'] ) ) {
				$args['include'] = $this->query_args['product_includes'];
			}

			if ( isset( $this->query_args['category_includes'] ) ) {
				$categories       = $this->query_args['category_includes'];
				$args['category'] = array();
				foreach ( $categories as $category_id ) {
					$terms              = get_term_by( 'id', $category_id, 'product_cat' );
					$args['category'][] = $terms->slug;
				}
			}

			$segment_objects = wc_get_products( $args );
			foreach ( $segment_objects as $segment ) {
				$id                    = $segment->get_id();
				$segments[]            = $id;
				$segment_labels[ $id ] = $segment->get_name();
			}
		} elseif ( 'variation' === $this->query_args['segmentby'] ) {
			$args = array(
				'return' => 'objects',
				'limit'  => -1,
				'type'   => 'variation',
			);

			if (
				isset( $this->query_args['product_includes'] ) &&
				count( $this->query_args['product_includes'] ) === 1
			) {
				$args['parent'] = $this->query_args['product_includes'][0];
			}

			if ( isset( $this->query_args['variation_includes'] ) ) {
				$args['include'] = $this->query_args['variation_includes'];
			}

			$segment_objects = wc_get_products( $args );

			foreach ( $segment_objects as $segment ) {
				$id           = $segment->get_id();
				$segments[]   = $id;
				$product_name = $segment->get_name();
				$separator    = apply_filters( 'woocommerce_product_variation_title_attributes_separator', ' - ', $segment );
				$attributes   = wc_get_formatted_variation( $segment, true, false );

				$segment_labels[ $id ] = $product_name . $separator . $attributes;
			}

			// If no variations were specified, add a segment for the parent product (variation = 0).
			// This is to catch simple products with prior sales converted into variable products.
			// See: https://github.com/woocommerce/woocommerce-admin/issues/2719.
			if ( isset( $args['parent'] ) && empty( $args['include'] ) ) {
				$parent_object     = wc_get_product( $args['parent'] );
				$segments[]        = 0;
				$segment_labels[0] = $parent_object->get_name();
			}
		} elseif ( 'category' === $this->query_args['segmentby'] ) {
			$args = array(
				'taxonomy' => 'product_cat',
			);

			if ( isset( $this->query_args['category_includes'] ) ) {
				$args['include'] = $this->query_args['category_includes'];
			}

			// @todo: Look into `wc_get_products` or data store methods and not directly touching the database or post types.
			$categories = get_categories( $args );

			$segments       = wp_list_pluck( $categories, 'cat_ID' );
			$segment_labels = wp_list_pluck( $categories, 'name', 'cat_ID' );

		} elseif ( 'coupon' === $this->query_args['segmentby'] ) {
			$args = array();
			if ( isset( $this->query_args['coupons'] ) ) {
				$args['include'] = $this->query_args['coupons'];
			}
			$coupons_store  = new CouponsDataStore();
			$coupons        = $coupons_store->get_coupons( $args );
			$segments       = wp_list_pluck( $coupons, 'ID' );
			$segment_labels = wp_list_pluck( $coupons, 'post_title', 'ID' );
			$segment_labels = array_map( 'wc_format_coupon_code', $segment_labels );
		} elseif ( 'customer_type' === $this->query_args['segmentby'] ) {
			// 0 -- new customer
			// 1 -- returning customer
			$segments = array( 0, 1 );
		} elseif ( 'tax_rate_id' === $this->query_args['segmentby'] ) {
			$args = array();
			if ( isset( $this->query_args['taxes'] ) ) {
				$args['include'] = $this->query_args['taxes'];
			}
			$taxes = TaxesStatsDataStore::get_taxes( $args );

			foreach ( $taxes as $tax ) {
				$id                    = $tax['tax_rate_id'];
				$segments[]            = $id;
				$segment_labels[ $id ] = \WC_Tax::get_rate_code( (object) $tax );
			}
		} else {
			// Catch all default.
			$segments = array();
		}

		$this->all_segment_ids = $segments;
		$this->segment_labels  = $segment_labels;
	}

	/**
	 * Return all segment ids for given segmentby query parameter.
	 *
	 * @return array
	 */
	protected function get_all_segments() {
		if ( ! is_array( $this->all_segment_ids ) ) {
			$this->set_all_segments();
		}

		return $this->all_segment_ids;
	}

	/**
	 * Return all segment labels for given segmentby query parameter.
	 *
	 * @return array
	 */
	protected function get_segment_labels() {
		if ( ! is_array( $this->all_segment_ids ) ) {
			$this->set_all_segments();
		}

		return $this->segment_labels;
	}

	/**
	 * Compares two report data objects by pre-defined object property and ASC/DESC ordering.
	 *
	 * @param stdClass $a Object a.
	 * @param stdClass $b Object b.
	 * @return string
	 */
	private function segment_cmp( $a, $b ) {
		if ( $a['segment_id'] === $b['segment_id'] ) {
			return 0;
		} elseif ( $a['segment_id'] > $b['segment_id'] ) {
			return 1;
		} elseif ( $a['segment_id'] < $b['segment_id'] ) {
			return - 1;
		}
	}

	/**
	 * Adds zeroes for segments not present in the data selection.
	 *
	 * @param array $segments Array of segments from the database for given data points.
	 *
	 * @return array
	 */
	protected function fill_in_missing_segments( $segments ) {
		$segment_subtotals = array();
		if ( isset( $this->query_args['fields'] ) && is_array( $this->query_args['fields'] ) ) {
			foreach ( $this->query_args['fields'] as $field ) {
				if ( isset( $this->report_columns[ $field ] ) ) {
					$segment_subtotals[ $field ] = 0;
				}
			}
		} else {
			foreach ( $this->report_columns as $field => $sql_clause ) {
				$segment_subtotals[ $field ] = 0;
			}
		}
		if ( ! is_array( $segments ) ) {
			$segments = array();
		}
		$all_segment_ids = $this->get_all_segments();
		$segment_labels  = $this->get_segment_labels();
		foreach ( $all_segment_ids as $segment_id ) {
			if ( ! isset( $segments[ $segment_id ] ) ) {
				$segments[ $segment_id ] = array(
					'segment_id'    => $segment_id,
					'segment_label' => $segment_labels[ $segment_id ],
					'subtotals'     => $segment_subtotals,
				);
			}
		}

		// Using array_values to remove custom keys, so that it gets later converted to JSON as an array.
		$segments_no_keys = array_values( $segments );
		usort( $segments_no_keys, array( $this, 'segment_cmp' ) );
		return $segments_no_keys;
	}

	/**
	 * Adds missing segments to intervals, modifies $data.
	 *
	 * @param stdClass $data Response data.
	 */
	protected function fill_in_missing_interval_segments( &$data ) {
		foreach ( $data->intervals as $order_id => $interval_data ) {
			$data->intervals[ $order_id ]['segments'] = $this->fill_in_missing_segments( $data->intervals[ $order_id ]['segments'] );
		}
	}

	/**
	 * Calculate segments for segmenting property bound to product (e.g. category, product_id, variation_id).
	 *
	 * @param string $type Type of segments to return--'totals' or 'intervals'.
	 * @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  $query_params Array of SQL clauses for intervals/totals query.
	 * @param string $unique_orders_table Name of temporary SQL table that holds unique orders.
	 *
	 * @return array
	 */
	protected function get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table ) {
		if ( 'totals' === $type ) {
			return $this->get_product_related_totals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table );
		} elseif ( 'intervals' === $type ) {
			return $this->get_product_related_intervals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table );
		}
	}

	/**
	 * Calculate segments for segmenting property bound to order (e.g. coupon or customer type).
	 *
	 * @param string $type Type of segments to return--'totals' or 'intervals'.
	 * @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  $query_params Array of SQL clauses for intervals/totals query.
	 *
	 * @return array
	 */
	protected function get_order_related_segments( $type, $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params ) {
		if ( 'totals' === $type ) {
			return $this->get_order_related_totals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params );
		} elseif ( 'intervals' === $type ) {
			return $this->get_order_related_intervals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params );
		}
	}

	/**
	 * Assign segments to time intervals by updating original $intervals array.
	 *
	 * @param array $intervals Result array from intervals SQL query.
	 * @param array $intervals_segments Result array from interval segments SQL query.
	 */
	protected function assign_segments_to_intervals( &$intervals, $intervals_segments ) {
		$old_keys = array_keys( $intervals );
		foreach ( $intervals as $interval ) {
			$intervals[ $interval['time_interval'] ]             = $interval;
			$intervals[ $interval['time_interval'] ]['segments'] = array();
		}
		foreach ( $old_keys as $key ) {
			unset( $intervals[ $key ] );
		}

		foreach ( $intervals_segments as $time_interval => $segment ) {
			if ( isset( $intervals[ $time_interval ] ) ) {
				$intervals[ $time_interval ]['segments'] = $segment['segments'];
			}
		}
		// To remove time interval keys (so that REST response is formatted correctly).
		$intervals = array_values( $intervals );
	}

	/**
	 * Returns an array of segments for totals part of REST response.
	 *
	 * @param array  $query_params Totals SQL query parameters.
	 * @param string $table_name Name of the SQL table that is the main order stats table.
	 *
	 * @return array
	 */
	public function get_totals_segments( $query_params, $table_name ) {
		$segments = $this->get_segments( 'totals', $query_params, $table_name );
		$segments = $this->fill_in_missing_segments( $segments );

		return $segments;
	}

	/**
	 * Adds an array of segments to data->intervals object.
	 *
	 * @param stdClass $data Data object representing the REST response.
	 * @param array    $intervals_query Intervals SQL query parameters.
	 * @param string   $table_name Name of the SQL table that is the main order stats table.
	 */
	public function add_intervals_segments( &$data, $intervals_query, $table_name ) {
		$intervals_segments = $this->get_segments( 'intervals', $intervals_query, $table_name );

		$this->assign_segments_to_intervals( $data->intervals, $intervals_segments );
		$this->fill_in_missing_interval_segments( $data );
	}
}
Reports/Downloads/Query.php000064400000002261152333372110011753 0ustar00<?php
/**
 * Class for parameter-based downloads report querying.
 *
 * Example usage:
 * $args = array(
 *      'before'       => '2018-07-19 00:00:00',
 *      'after'        => '2018-07-05 00:00:00',
 *      'page'         => 2,
 *      'products'     => array(1,2,3)
 * );
 * $report = new \Automattic\WooCommerce\Admin\API\Reports\Downloads\Query( $args );
 * $mydata = $report->get_data();
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Downloads;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;

/**
 * API\Reports\Downloads\Query
 */
class Query extends ReportsQuery {

	/**
	 * Valid fields for downloads report.
	 *
	 * @return array
	 */
	protected function get_default_query_vars() {
		return array();
	}

	/**
	 * Get downloads data based on the current query vars.
	 *
	 * @return array
	 */
	public function get_data() {
		$args = apply_filters( 'woocommerce_analytics_downloads_query_args', $this->get_query_vars() );

		$data_store = \WC_Data_Store::load( 'report-downloads' );
		$results    = $data_store->get_data( $args );
		return apply_filters( 'woocommerce_analytics_downloads_select_query', $results, $args );
	}
}
Reports/Downloads/Controller.php000064400000035250152333372110012775 0ustar00<?php
/**
 * REST API Reports downloads controller
 *
 * Handles requests to the /reports/downloads endpoint.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Downloads;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\Controller as ReportsController;
use \Automattic\WooCommerce\Admin\API\Reports\ExportableInterface;

/**
 * REST API Reports downloads controller class.
 *
 * @internal
 * @extends Automattic\WooCommerce\Admin\API\Reports\Controller
 */
class Controller extends ReportsController implements ExportableInterface {
	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'reports/downloads';

	/**
	 * Get items.
	 *
	 * @param WP_REST_Request $request Request data.
	 * @return array|WP_Error
	 */
	public function get_items( $request ) {
		$args       = array();
		$registered = array_keys( $this->get_collection_params() );
		foreach ( $registered as $param_name ) {
			if ( isset( $request[ $param_name ] ) ) {
				$args[ $param_name ] = $request[ $param_name ];
			}
		}

		$reports        = new Query( $args );
		$downloads_data = $reports->get_data();

		$data = array();

		foreach ( $downloads_data->data as $download_data ) {
			$item   = $this->prepare_item_for_response( $download_data, $request );
			$data[] = $this->prepare_response_for_collection( $item );
		}

		$response = rest_ensure_response( $data );

		$response->header( 'X-WP-Total', (int) $downloads_data->total );
		$response->header( 'X-WP-TotalPages', (int) $downloads_data->pages );

		$page      = $downloads_data->page_no;
		$max_pages = $downloads_data->pages;
		$base      = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) );
		if ( $page > 1 ) {
			$prev_page = $page - 1;
			if ( $prev_page > $max_pages ) {
				$prev_page = $max_pages;
			}
			$prev_link = add_query_arg( 'page', $prev_page, $base );
			$response->link_header( 'prev', $prev_link );
		}
		if ( $max_pages > $page ) {
			$next_page = $page + 1;
			$next_link = add_query_arg( 'page', $next_page, $base );
			$response->link_header( 'next', $next_link );
		}

		return $response;
	}

	/**
	 * 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 );
		$response->add_links( $this->prepare_links( $report ) );

		$response->data['date'] = get_date_from_gmt( $data['date_gmt'], 'Y-m-d H:i:s' );

		// Figure out file name.
		// Matches https://github.com/woocommerce/woocommerce/blob/4be0018c092e617c5d2b8c46b800eb71ece9ddef/includes/class-wc-download-handler.php#L197.
		$product_id = intval( $data['product_id'] );
		$_product   = wc_get_product( $product_id );

		// Make sure the product hasn't been deleted.
		if ( $_product ) {
			$file_path                   = $_product->get_file_download_path( $data['download_id'] );
			$filename                    = basename( $file_path );
			$response->data['file_name'] = apply_filters( 'woocommerce_file_download_filename', $filename, $product_id );
			$response->data['file_path'] = $file_path;
		} else {
			$response->data['file_name'] = '';
			$response->data['file_path'] = '';
		}

		$customer                       = new \WC_Customer( $data['user_id'] );
		$response->data['username']     = $customer->get_username();
		$response->data['order_number'] = $this->get_order_number( $data['order_id'] );

		/**
		 * 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_downloads', $response, $report, $request );
	}

	/**
	 * Prepare links for the request.
	 *
	 * @param Array $object Object data.
	 * @return array        Links for the given post.
	 */
	protected function prepare_links( $object ) {
		$links = array(
			'product' => array(
				'href'       => rest_url( sprintf( '/%s/%s/%d', $this->namespace, 'products', $object['product_id'] ) ),
				'embeddable' => true,
			),
		);

		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_downloads',
			'type'       => 'object',
			'properties' => array(
				'id'           => array(
					'type'        => 'integer',
					'readonly'    => true,
					'context'     => array( 'view', 'edit' ),
					'description' => __( 'ID.', 'woocommerce' ),
				),
				'product_id'   => array(
					'type'        => 'integer',
					'readonly'    => true,
					'context'     => array( 'view', 'edit' ),
					'description' => __( 'Product ID.', 'woocommerce' ),
				),
				'date'         => array(
					'description' => __( "The date of the download, in the site's timezone.", 'woocommerce' ),
					'type'        => 'date-time',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'date_gmt'     => array(
					'description' => __( 'The date of the download, as GMT.', 'woocommerce' ),
					'type'        => 'date-time',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'download_id'  => array(
					'type'        => 'string',
					'readonly'    => true,
					'context'     => array( 'view', 'edit' ),
					'description' => __( 'Download ID.', 'woocommerce' ),
				),
				'file_name'    => array(
					'type'        => 'string',
					'readonly'    => true,
					'context'     => array( 'view', 'edit' ),
					'description' => __( 'File name.', 'woocommerce' ),
				),
				'file_path'    => array(
					'type'        => 'string',
					'readonly'    => true,
					'context'     => array( 'view', 'edit' ),
					'description' => __( 'File URL.', 'woocommerce' ),
				),
				'order_id'     => array(
					'type'        => 'integer',
					'readonly'    => true,
					'context'     => array( 'view', 'edit' ),
					'description' => __( 'Order ID.', 'woocommerce' ),
				),
				'order_number' => array(
					'type'        => 'string',
					'readonly'    => true,
					'context'     => array( 'view', 'edit' ),
					'description' => __( 'Order Number.', 'woocommerce' ),
				),
				'user_id'      => array(
					'type'        => 'integer',
					'readonly'    => true,
					'context'     => array( 'view', 'edit' ),
					'description' => __( 'User ID for the downloader.', 'woocommerce' ),
				),
				'username'     => array(
					'type'        => 'string',
					'readonly'    => true,
					'context'     => array( 'view', 'edit' ),
					'description' => __( 'User name of the downloader.', 'woocommerce' ),
				),
				'ip_address'   => array(
					'type'        => 'string',
					'readonly'    => true,
					'context'     => array( 'view', 'edit' ),
					'description' => __( 'IP address for the downloader.', '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'           => 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',
				'product',
			),
			'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: products, orders, username, ip_address.', 'woocommerce' ),
			'type'              => 'string',
			'default'           => 'all',
			'enum'              => array(
				'all',
				'any',
			),
			'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['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['customer_includes']   = array(
			'description'       => __( 'Limit response to objects that have the specified user ids.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'type' => 'integer',
			),
		);
		$params['customer_excludes']   = array(
			'description'       => __( 'Limit response to objects that don\'t have the specified user ids.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'type' => 'integer',
			),
		);
		$params['ip_address_includes'] = array(
			'description'       => __( 'Limit response to objects that have a specified ip address.', 'woocommerce' ),
			'type'              => 'array',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'type' => 'string',
			),
		);
		$params['ip_address_excludes'] = array(
			'description'       => __( 'Limit response to objects that don\'t have a specified ip address.', 'woocommerce' ),
			'type'              => 'array',
			'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;
	}

	/**
	 * Get the column names for export.
	 *
	 * @return array Key value pair of Column ID => Label.
	 */
	public function get_export_columns() {
		$export_columns = array(
			'date'         => __( 'Date', 'woocommerce' ),
			'product'      => __( 'Product title', 'woocommerce' ),
			'file_name'    => __( 'File name', 'woocommerce' ),
			'order_number' => __( 'Order #', 'woocommerce' ),
			'user_id'      => __( 'User Name', 'woocommerce' ),
			'ip_address'   => __( 'IP', 'woocommerce' ),
		);

		/**
		 * Filter to add or remove column names from the downloads report for
		 * export.
		 *
		 * @since 1.6.0
		 */
		return apply_filters(
			'woocommerce_filter_downloads_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'         => $item['date'],
			'product'      => $item['_embedded']['product'][0]['name'],
			'file_name'    => $item['file_name'],
			'order_number' => $item['order_number'],
			'user_id'      => $item['username'],
			'ip_address'   => $item['ip_address'],
		);

		/**
		 * Filter to prepare extra columns in the export item for the downloads
		 * report.
		 *
		 * @since 1.6.0
		 */
		return apply_filters(
			'woocommerce_report_downloads_prepare_export_item',
			$export_item,
			$item
		);
	}
}
Reports/Downloads/DataStore.php000064400000030627152333372110012543 0ustar00<?php
/**
 * API\Reports\Downloads\DataStore class file.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Downloads;

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;

/**
 * API\Reports\Downloads\DataStore.
 */
class DataStore extends ReportsDataStore implements DataStoreInterface {

	/**
	 * Table used to get the data.
	 *
	 * @var string
	 */
	protected static $table_name = 'wc_download_log';

	/**
	 * Cache identifier.
	 *
	 * @var string
	 */
	protected $cache_key = 'downloads';

	/**
	 * Mapping columns to data type to return correct response types.
	 *
	 * @var array
	 */
	protected $column_types = array(
		'id'          => 'intval',
		'date'        => 'strval',
		'date_gmt'    => 'strval',
		'download_id' => 'strval', // String because this can sometimes be a hash.
		'file_name'   => 'strval',
		'product_id'  => 'intval',
		'order_id'    => 'intval',
		'user_id'     => 'intval',
		'ip_address'  => 'strval',
	);

	/**
	 * Data store context used to pass to filters.
	 *
	 * @var string
	 */
	protected $context = 'downloads';

	/**
	 * Assign report columns once full table name has been assigned.
	 */
	protected function assign_report_columns() {
		$this->report_columns = array(
			'id'          => 'download_log_id as id',
			'date'        => 'timestamp as date_gmt',
			'download_id' => 'product_permissions.download_id',
			'product_id'  => 'product_permissions.product_id',
			'order_id'    => 'product_permissions.order_id',
			'user_id'     => 'product_permissions.user_id',
			'ip_address'  => 'user_ip_address as ip_address',
		);
	}

	/**
	 * Updates the database query with parameters used for downloads report.
	 *
	 * @param array $query_args Query arguments supplied by the user.
	 */
	protected function add_sql_query_params( $query_args ) {
		global $wpdb;

		$lookup_table     = self::get_db_table_name();
		$permission_table = $wpdb->prefix . 'woocommerce_downloadable_product_permissions';
		$operator         = $this->get_match_operator( $query_args );
		$where_filters    = array();
		$join             = "JOIN {$permission_table} as product_permissions ON {$lookup_table}.permission_id = product_permissions.permission_id";

		$where_time = $this->add_time_period_sql_params( $query_args, $lookup_table );
		if ( $where_time ) {
			if ( isset( $this->subquery ) ) {
				$this->subquery->add_sql_clause( 'where_time', $where_time );
			} else {
				$this->interval_query->add_sql_clause( 'where_time', $where_time );
			}
		}
		$this->get_limit_sql_params( $query_args );

		$where_filters[] = $this->get_object_where_filter(
			$lookup_table,
			'permission_id',
			$permission_table,
			'product_id',
			'IN',
			$this->get_included_products( $query_args )
		);
		$where_filters[] = $this->get_object_where_filter(
			$lookup_table,
			'permission_id',
			$permission_table,
			'product_id',
			'NOT IN',
			$this->get_excluded_products( $query_args )
		);
		$where_filters[] = $this->get_object_where_filter(
			$lookup_table,
			'permission_id',
			$permission_table,
			'order_id',
			'IN',
			$this->get_included_orders( $query_args )
		);
		$where_filters[] = $this->get_object_where_filter(
			$lookup_table,
			'permission_id',
			$permission_table,
			'order_id',
			'NOT IN',
			$this->get_excluded_orders( $query_args )
		);

		$customer_lookup_table = $wpdb->prefix . 'wc_customer_lookup';
		$customer_lookup       = "SELECT {$customer_lookup_table}.user_id FROM {$customer_lookup_table} WHERE {$customer_lookup_table}.customer_id IN (%s)";
		$included_customers    = $this->get_included_customers( $query_args );
		$excluded_customers    = $this->get_excluded_customers( $query_args );
		if ( $included_customers ) {
			$where_filters[] = $this->get_object_where_filter(
				$lookup_table,
				'permission_id',
				$permission_table,
				'user_id',
				'IN',
				sprintf( $customer_lookup, $included_customers )
			);
		}

		if ( $excluded_customers ) {
			$where_filters[] = $this->get_object_where_filter(
				$lookup_table,
				'permission_id',
				$permission_table,
				'user_id',
				'NOT IN',
				sprintf( $customer_lookup, $excluded_customers )
			);
		}

		$included_ip_addresses = $this->get_included_ip_addresses( $query_args );
		$excluded_ip_addresses = $this->get_excluded_ip_addresses( $query_args );
		if ( $included_ip_addresses ) {
			$where_filters[] = "{$lookup_table}.user_ip_address IN ('{$included_ip_addresses}')";
		}

		if ( $excluded_ip_addresses ) {
			$where_filters[] = "{$lookup_table}.user_ip_address NOT IN ('{$excluded_ip_addresses}')";
		}

		$where_filters   = array_filter( $where_filters );
		$where_subclause = implode( " $operator ", $where_filters );
		if ( $where_subclause ) {
			if ( isset( $this->subquery ) ) {
				$this->subquery->add_sql_clause( 'where', "AND ( $where_subclause )" );
			} else {
				$this->interval_query->add_sql_clause( 'where', "AND ( $where_subclause )" );
			}
		}

		if ( isset( $this->subquery ) ) {
			$this->subquery->add_sql_clause( 'join', $join );
		} else {
			$this->interval_query->add_sql_clause( 'join', $join );
		}
		$this->add_order_by( $query_args );
	}

	/**
	 * Returns comma separated ids of included ip address, based on query arguments from the user.
	 *
	 * @param array $query_args Parameters supplied by the user.
	 * @return string
	 */
	protected function get_included_ip_addresses( $query_args ) {
		return $this->get_filtered_ip_addresses( $query_args, 'ip_address_includes' );
	}

	/**
	 * Returns comma separated ids of excluded ip address, based on query arguments from the user.
	 *
	 * @param array $query_args Parameters supplied by the user.
	 * @return string
	 */
	protected function get_excluded_ip_addresses( $query_args ) {
		return $this->get_filtered_ip_addresses( $query_args, 'ip_address_excludes' );
	}

	/**
	 * Returns filtered comma separated ids, based on query arguments from the user.
	 *
	 * @param array  $query_args  Parameters supplied by the user.
	 * @param string $field       Query field to filter.
	 * @return string
	 */
	protected function get_filtered_ip_addresses( $query_args, $field ) {
		if ( isset( $query_args[ $field ] ) && is_array( $query_args[ $field ] ) && count( $query_args[ $field ] ) > 0 ) {
			$ip_addresses = array_map( 'esc_sql', $query_args[ $field ] );

			/**
			 * Filter the IDs before retrieving report data.
			 *
			 * Allows filtering of the objects included or excluded from reports.
			 *
			 * @param array  $ids        List of object Ids.
			 * @param array  $query_args The original arguments for the request.
			 * @param string $field      The object type.
			 * @param string $context    The data store context.
			 */
			$ip_addresses = apply_filters( 'woocommerce_analytics_' . $field, $ip_addresses, $query_args, $field, $this->context );

			return implode( "','", $ip_addresses );
		}
		return '';
	}

	/**
	 * Returns comma separated ids of included customers, based on query arguments from the user.
	 *
	 * @param array $query_args Parameters supplied by the user.
	 * @return string
	 */
	protected function get_included_customers( $query_args ) {
		return self::get_filtered_ids( $query_args, 'customer_includes' );
	}

	/**
	 * Returns comma separated ids of excluded customers, based on query arguments from the user.
	 *
	 * @param array $query_args Parameters supplied by the user.
	 * @return string
	 */
	protected function get_excluded_customers( $query_args ) {
		return self::get_filtered_ids( $query_args, 'customer_excludes' );
	}

	/**
	 * Gets WHERE time clause of SQL request with date-related constraints.
	 *
	 * @param array  $query_args Parameters supplied by the user.
	 * @param string $table_name Name of the db table relevant for the date constraint.
	 * @return string
	 */
	protected function add_time_period_sql_params( $query_args, $table_name ) {
		$where_time = '';
		if ( $query_args['before'] ) {
			$datetime_str = $query_args['before']->format( TimeInterval::$sql_datetime_format );
			$where_time  .= " AND {$table_name}.timestamp <= '$datetime_str'";

		}

		if ( $query_args['after'] ) {
			$datetime_str = $query_args['after']->format( TimeInterval::$sql_datetime_format );
			$where_time  .= " AND {$table_name}.timestamp >= '$datetime_str'";
		}

		return $where_time;
	}

	/**
	 * Fills ORDER BY clause of SQL request based on user supplied parameters.
	 *
	 * @param array $query_args Parameters supplied by the user.
	 */
	protected function add_order_by( $query_args ) {
		global $wpdb;
		$this->clear_sql_clause( 'order_by' );
		$order_by = '';
		if ( isset( $query_args['orderby'] ) ) {
			$order_by = $this->normalize_order_by( esc_sql( $query_args['orderby'] ) );
			$this->add_sql_clause( 'order_by', $order_by );
		}

		if ( false !== strpos( $order_by, '_products' ) ) {
			$this->subquery->add_sql_clause( 'join', "JOIN {$wpdb->posts} AS _products ON product_permissions.product_id = _products.ID" );
		}

		$this->add_orderby_order_clause( $query_args, $this );
	}

	/**
	 * 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 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'  => 'timestamp',
			'before'   => TimeInterval::default_before(),
			'after'    => TimeInterval::default_after(),
			'fields'   => '*',
		);
		$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 );
			$this->add_sql_query_params( $query_args );

			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$db_records_count = (int) $wpdb->get_var(
				"SELECT COUNT(*) FROM (
					{$this->subquery->get_query_statement()}
				) AS tt"
			);
			// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

			$params      = $this->get_limit_params( $query_args );
			$total_pages = (int) ceil( $db_records_count / $params['per_page'] );
			if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) {
				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' ) );

			$download_data = $wpdb->get_results(
				$this->subquery->get_query_statement(), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
				ARRAY_A
			);

			if ( null === $download_data ) {
				return $data;
			}

			$download_data = array_map( array( $this, 'cast_numbers' ), $download_data );
			$data          = (object) array(
				'data'    => $download_data,
				'total'   => $db_records_count,
				'pages'   => $total_pages,
				'page_no' => (int) $query_args['page'],
			);

			$this->set_cached_data( $cache_key, $data );
		}

		return $data;
	}

	/**
	 * Maps ordering specified by the user to columns in the database/fields in the data.
	 *
	 * @param string $order_by Sorting criterion.
	 * @return string
	 */
	protected function normalize_order_by( $order_by ) {
		global $wpdb;

		if ( 'date' === $order_by ) {
			return $wpdb->prefix . 'wc_download_log.timestamp';
		}

		if ( 'product' === $order_by ) {
			return '_products.post_title';
		}

		return $order_by;
	}

	/**
	 * Initialize query objects.
	 */
	protected function initialize_queries() {
		$this->clear_all_clauses();
		$table_name     = self::get_db_table_name();
		$this->subquery = new SqlQuery( $this->context . '_subquery' );
		$this->subquery->add_sql_clause( 'from', $table_name );
		$this->subquery->add_sql_clause( 'select', "{$table_name}.download_log_id" );
		$this->subquery->add_sql_clause( 'group_by', "{$table_name}.download_log_id" );
	}
}
Reports/Downloads/Stats/Controller.php000064400000033001152333372110014063 0ustar00<?php
/**
 * REST API Reports downloads stats controller
 *
 * Handles requests to the /reports/downloads/stats endpoint.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Downloads\Stats;

defined( 'ABSPATH' ) || exit;

/**
 * REST API Reports downloads stats controller class.
 *
 * @internal
 * @extends WC_REST_Reports_Controller
 */
class Controller extends \WC_REST_Reports_Controller {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'reports/downloads/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['match']               = $request['match'];
		$args['product_includes']    = (array) $request['product_includes'];
		$args['product_excludes']    = (array) $request['product_excludes'];
		$args['customer_includes']   = (array) $request['customer_includes'];
		$args['customer_excludes']   = (array) $request['customer_excludes'];
		$args['order_includes']      = (array) $request['order_includes'];
		$args['order_excludes']      = (array) $request['order_excludes'];
		$args['ip_address_includes'] = (array) $request['ip_address_includes'];
		$args['ip_address_excludes'] = (array) $request['ip_address_excludes'];
		$args['fields']              = $request['fields'];
		$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 );
		$downloads_query = new Query( $query_args );
		$report_data     = $downloads_query->get_data();

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

		$response = rest_ensure_response( $out_data );
		$response->header( 'X-WP-Total', (int) $report_data->total );
		$response->header( 'X-WP-TotalPages', (int) $report_data->pages );

		$page      = $report_data->page_no;
		$max_pages = $report_data->pages;
		$base      = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) );
		if ( $page > 1 ) {
			$prev_page = $page - 1;
			if ( $prev_page > $max_pages ) {
				$prev_page = $max_pages;
			}
			$prev_link = add_query_arg( 'page', $prev_page, $base );
			$response->link_header( 'prev', $prev_link );
		}
		if ( $max_pages > $page ) {
			$next_page = $page + 1;
			$next_link = add_query_arg( 'page', $next_page, $base );
			$response->link_header( 'next', $next_link );
		}

		return $response;
	}

	/**
	 * 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_downloads_stats', $response, $report, $request );
	}

	/**
	 * Get the Report's schema, conforming to JSON Schema.
	 *
	 * @return array
	 */
	public function get_item_schema() {
		$totals = array(
			'download_count' => array(
				'title'       => __( 'Downloads', 'woocommerce' ),
				'description' => __( 'Number of downloads.', 'woocommerce' ),
				'type'        => 'number',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
				'indicator'   => true,
			),
		);

		$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'  => $totals,
							),
						),
					),
				),
			),
		);

		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',
				'download_count',
			),
			'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['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['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['customer_includes']   = array(
			'description'       => __( 'Limit response to objects that have the specified customer ids.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'type' => 'integer',
			),
		);
		$params['customer_excludes']   = array(
			'description'       => __( 'Limit response to objects that don\'t have the specified customer ids.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'type' => 'integer',
			),
		);
		$params['ip_address_includes'] = array(
			'description'       => __( 'Limit response to objects that have a specified ip address.', 'woocommerce' ),
			'type'              => 'array',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'type' => 'string',
			),
		);

		$params['ip_address_excludes'] = array(
			'description'       => __( 'Limit response to objects that don\'t have a specified ip address.', 'woocommerce' ),
			'type'              => 'array',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'type' => 'string',
			),
		);
		$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;
	}
}
Reports/Downloads/Stats/DataStore.php000064400000015323152333372110013635 0ustar00<?php
/**
 * API\Reports\Downloads\Stats\DataStore class file.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Downloads\Stats;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\Downloads\DataStore as DownloadsDataStore;
use \Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface;
use \Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
use \Automattic\WooCommerce\Admin\API\Reports\SqlQuery;

/**
 * API\Reports\Downloads\Stats\DataStore.
 */
class DataStore extends DownloadsDataStore implements DataStoreInterface {

	/**
	 * Mapping columns to data type to return correct response types.
	 *
	 * @var array
	 */
	protected $column_types = array(
		'download_count' => 'intval',
	);

	/**
	 * Cache identifier.
	 *
	 * @var string
	 */
	protected $cache_key = 'downloads_stats';

	/**
	 * Data store context used to pass to filters.
	 *
	 * @var string
	 */
	protected $context = 'downloads_stats';

	/**
	 * Assign report columns once full table name has been assigned.
	 */
	protected function assign_report_columns() {
		$this->report_columns = array(
			'download_count' => 'COUNT(DISTINCT download_log_id) as download_count',
		);
	}

	/**
	 * 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 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'  => 'date',
			'fields'   => '*',
			'interval' => 'week',
			'before'   => TimeInterval::default_before(),
			'after'    => TimeInterval::default_after(),
		);
		$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();
			$selections = $this->selected_columns( $query_args );
			$this->add_sql_query_params( $query_args );
			$where_time = $this->add_time_period_sql_params( $query_args, $table_name );
			$this->add_intervals_sql_params( $query_args, $table_name );

			$this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) . ' AS time_interval' );
			$this->interval_query->str_replace_clause( 'select', 'date_created', 'timestamp' );
			$this->interval_query->str_replace_clause( 'where_time', 'date_created', 'timestamp' );

			$db_intervals = $wpdb->get_col(
				$this->interval_query->get_query_statement()
			); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok.

			$db_records_count = count( $db_intervals );

			$params                  = $this->get_limit_params( $query_args );
			$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 array();
			}

			$this->update_intervals_sql_params( $query_args, $db_records_count, $expected_interval_count, $table_name );
			$this->interval_query->str_replace_clause( 'where_time', 'date_created', 'timestamp' );
			$this->total_query->add_sql_clause( 'select', $selections );
			$this->total_query->add_sql_clause( 'where', $this->interval_query->get_sql_clause( 'where' ) );
			if ( $where_time ) {
				$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_downloads_stats_result_failed', __( 'Sorry, fetching downloads data failed.', 'woocommerce' ) );
			}

			$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(timestamp) 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_downloads_stats_result_failed', __( 'Sorry, fetching downloads data failed.', 'woocommerce' ) );
			}

			$totals = (object) $this->cast_numbers( $totals[0] );
			$data   = (object) array(
				'totals'    => $totals,
				'intervals' => $intervals,
				'total'     => $expected_interval_count,
				'pages'     => $total_pages,
				'page_no'   => (int) $query_args['page'],
			);

			if ( $this->intervals_missing( $expected_interval_count, $db_records_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_records_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 );
			}
			$this->create_interval_subtotals( $data->intervals );

			$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 'time_interval';
		}

		return $order_by;
	}

	/**
	 * 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' );
	}
}
Reports/Downloads/Stats/Query.php000064400000001600152333372110013045 0ustar00<?php
/**
 * Class for parameter-based downloads Reports querying
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Downloads\Stats;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;

/**
 * API\Reports\Downloads\Stats\Query
 */
class Query extends ReportsQuery {

	/**
	 * Valid fields for Orders report.
	 *
	 * @return array
	 */
	protected function get_default_query_vars() {
		return array();
	}

	/**
	 * Get revenue data based on the current query vars.
	 *
	 * @return array
	 */
	public function get_data() {
		$args = apply_filters( 'woocommerce_analytics_downloads_stats_query_args', $this->get_query_vars() );

		$data_store = \WC_Data_Store::load( 'report-downloads-stats' );
		$results    = $data_store->get_data( $args );
		return apply_filters( 'woocommerce_analytics_downloads_stats_select_query', $results, $args );
	}
}
Reports/Downloads/Files/Controller.php000064400000001122152333372110014026 0ustar00<?php
/**
 * REST API Reports downloads files controller
 *
 * Handles requests to the /reports/downloads/files endpoint.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Downloads\Files;

defined( 'ABSPATH' ) || exit;

/**
 * REST API Reports downloads files controller class.
 *
 * @internal
 * @extends WC_REST_Reports_Controller
 */
class Controller extends \WC_REST_Reports_Controller {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'reports/downloads/files';
}
Reports/Coupons/DataStore.php000064400000036741152333372110012242 0ustar00<?php
/**
 * API\Reports\Coupons\DataStore class file.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Coupons;

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;

/**
 * API\Reports\Coupons\DataStore.
 */
class DataStore extends ReportsDataStore implements DataStoreInterface {

	/**
	 * Table used to get the data.
	 *
	 * @var string
	 */
	protected static $table_name = 'wc_order_coupon_lookup';

	/**
	 * Cache identifier.
	 *
	 * @var string
	 */
	protected $cache_key = 'coupons';

	/**
	 * Mapping columns to data type to return correct response types.
	 *
	 * @var array
	 */
	protected $column_types = array(
		'coupon_id'    => 'intval',
		'amount'       => 'floatval',
		'orders_count' => 'intval',
	);

	/**
	 * Data store context used to pass to filters.
	 *
	 * @var string
	 */
	protected $context = 'coupons';

	/**
	 * Assign report columns once full table name has been assigned.
	 */
	protected function assign_report_columns() {
		$table_name           = self::get_db_table_name();
		$this->report_columns = array(
			'coupon_id'    => 'coupon_id',
			'amount'       => 'SUM(discount_amount) as amount',
			'orders_count' => "COUNT(DISTINCT {$table_name}.order_id) as orders_count",
		);
	}

	/**
	 * Set up all the hooks for maintaining and populating table data.
	 */
	public static function init() {
		add_action( 'woocommerce_analytics_delete_order_stats', array( __CLASS__, 'sync_on_order_delete' ), 5 );
	}

	/**
	 * Returns an array of ids of included coupons, based on query arguments from the user.
	 *
	 * @param array $query_args Parameters supplied by the user.
	 * @return array
	 */
	protected function get_included_coupons_array( $query_args ) {
		if ( isset( $query_args['coupons'] ) && is_array( $query_args['coupons'] ) && count( $query_args['coupons'] ) > 0 ) {
			return $query_args['coupons'];
		}
		return array();
	}

	/**
	 * Updates the database query with parameters used for Products report: categories and order status.
	 *
	 * @param array $query_args Query arguments supplied by the user.
	 */
	protected function add_sql_query_params( $query_args ) {
		global $wpdb;
		$order_coupon_lookup_table = self::get_db_table_name();

		$this->add_time_period_sql_params( $query_args, $order_coupon_lookup_table );
		$this->get_limit_sql_params( $query_args );

		$included_coupons = $this->get_included_coupons( $query_args, 'coupons' );
		if ( $included_coupons ) {
			$this->subquery->add_sql_clause( 'where', "AND {$order_coupon_lookup_table}.coupon_id IN ({$included_coupons})" );

			$this->add_order_by_params( $query_args, 'outer', 'default_results.coupon_id' );
		} else {
			$this->add_order_by_params( $query_args, 'inner', "{$order_coupon_lookup_table}.coupon_id" );
		}

		$this->add_order_status_clause( $query_args, $order_coupon_lookup_table, $this->subquery );
	}

	/**
	 * Fills ORDER BY clause of SQL request based on user supplied parameters.
	 *
	 * @param array  $query_args Parameters supplied by the user.
	 * @param string $from_arg   Target of the JOIN sql param.
	 * @param string $id_cell    ID cell identifier, like `table_name.id_column_name`.
	 */
	protected function add_order_by_params( $query_args, $from_arg, $id_cell ) {
		global $wpdb;

		// Sanitize input: guarantee that the id cell in the join is quoted with backticks.
		$id_cell_segments   = explode( '.', str_replace( '`', '', $id_cell ) );
		$id_cell_identifier = '`' . implode( '`.`', $id_cell_segments ) . '`';

		$lookup_table    = self::get_db_table_name();
		$order_by_clause = $this->add_order_by_clause( $query_args, $this );
		$join            = "JOIN {$wpdb->posts} AS _coupons ON {$id_cell_identifier} = _coupons.ID";
		$this->add_orderby_order_clause( $query_args, $this );

		if ( 'inner' === $from_arg ) {
			$this->subquery->clear_sql_clause( 'join' );
			if ( false !== strpos( $order_by_clause, '_coupons' ) ) {
				$this->subquery->add_sql_clause( 'join', $join );
			}
		} else {
			$this->clear_sql_clause( 'join' );
			if ( false !== strpos( $order_by_clause, '_coupons' ) ) {
				$this->add_sql_clause( 'join', $join );
			}
		}
	}

	/**
	 * Maps ordering specified by the user to columns in the database/fields in the data.
	 *
	 * @param string $order_by Sorting criterion.
	 * @return string
	 */
	protected function normalize_order_by( $order_by ) {
		if ( 'date' === $order_by ) {
			return 'time_interval';
		}
		if ( 'code' === $order_by ) {
			return '_coupons.post_title';
		}
		return $order_by;
	}

	/**
	 * Enriches the coupon data with extra attributes.
	 *
	 * @param array $coupon_data Coupon data.
	 * @param array $query_args Query parameters.
	 */
	protected function include_extended_info( &$coupon_data, $query_args ) {
		foreach ( $coupon_data as $idx => $coupon_datum ) {
			$extended_info = new \ArrayObject();
			if ( $query_args['extended_info'] ) {
				$coupon_id = $coupon_datum['coupon_id'];
				$coupon    = new \WC_Coupon( $coupon_id );

				if ( 0 === $coupon->get_id() ) {
					// Deleted or otherwise invalid coupon.
					$extended_info = array(
						'code'             => __( '(Deleted)', 'woocommerce' ),
						'date_created'     => '',
						'date_created_gmt' => '',
						'date_expires'     => '',
						'date_expires_gmt' => '',
						'discount_type'    => __( 'N/A', 'woocommerce' ),
					);
				} else {
					$gmt_timzone = new \DateTimeZone( 'UTC' );

					$date_expires = $coupon->get_date_expires();
					if ( is_a( $date_expires, 'DateTime' ) ) {
						$date_expires     = $date_expires->format( TimeInterval::$iso_datetime_format );
						$date_expires_gmt = new \DateTime( $date_expires );
						$date_expires_gmt->setTimezone( $gmt_timzone );
						$date_expires_gmt = $date_expires_gmt->format( TimeInterval::$iso_datetime_format );
					} else {
						$date_expires     = '';
						$date_expires_gmt = '';
					}

					$date_created = $coupon->get_date_created();
					if ( is_a( $date_created, 'DateTime' ) ) {
						$date_created     = $date_created->format( TimeInterval::$iso_datetime_format );
						$date_created_gmt = new \DateTime( $date_created );
						$date_created_gmt->setTimezone( $gmt_timzone );
						$date_created_gmt = $date_created_gmt->format( TimeInterval::$iso_datetime_format );
					} else {
						$date_created     = '';
						$date_created_gmt = '';
					}

					$extended_info = array(
						'code'             => $coupon->get_code(),
						'date_created'     => $date_created,
						'date_created_gmt' => $date_created_gmt,
						'date_expires'     => $date_expires,
						'date_expires_gmt' => $date_expires_gmt,
						'discount_type'    => $coupon->get_discount_type(),
					);
				}
			}
			$coupon_data[ $idx ]['extended_info'] = $extended_info;
		}
	}

	/**
	 * 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 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'       => 'coupon_id',
			'before'        => TimeInterval::default_before(),
			'after'         => TimeInterval::default_after(),
			'fields'        => '*',
			'coupons'       => array(),
			'extended_info' => false,
		);
		$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 );
			$included_coupons = $this->get_included_coupons_array( $query_args );
			$limit_params     = $this->get_limit_params( $query_args );
			$this->subquery->add_sql_clause( 'select', $selections );
			$this->add_sql_query_params( $query_args );

			if ( count( $included_coupons ) > 0 ) {
				$total_results = count( $included_coupons );
				$total_pages   = (int) ceil( $total_results / $limit_params['per_page'] );

				$fields    = $this->get_fields( $query_args );
				$ids_table = $this->get_ids_table( $included_coupons, 'coupon_id' );

				$this->add_sql_clause( 'select', $this->format_join_selections( $fields, array( 'coupon_id' ) ) );
				$this->add_sql_clause( 'from', '(' );
				$this->add_sql_clause( 'from', $this->subquery->get_query_statement() );
				$this->add_sql_clause( 'from', ") AS {$table_name}" );
				$this->add_sql_clause(
					'right_join',
					"RIGHT JOIN ( {$ids_table} ) AS default_results
					ON default_results.coupon_id = {$table_name}.coupon_id"
				);

				$coupons_query = $this->get_query_statement();
			} else {
				$this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) );
				$this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) );
				$coupons_query = $this->subquery->get_query_statement();

				$this->subquery->clear_sql_clause( array( 'select', 'order_by', 'limit' ) );
				$this->subquery->add_sql_clause( 'select', 'coupon_id' );
				$coupon_subquery = "SELECT COUNT(*) FROM (
					{$this->subquery->get_query_statement()}
				) AS tt";

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

				$total_results = $db_records_count;
				$total_pages   = (int) ceil( $db_records_count / $limit_params['per_page'] );
				if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) {
					return $data;
				}
			}

			$coupon_data = $wpdb->get_results(
				$coupons_query, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
				ARRAY_A
			);
			if ( null === $coupon_data ) {
				return $data;
			}

			$this->include_extended_info( $coupon_data, $query_args );

			$coupon_data = array_map( array( $this, 'cast_numbers' ), $coupon_data );
			$data        = (object) array(
				'data'    => $coupon_data,
				'total'   => $total_results,
				'pages'   => $total_pages,
				'page_no' => (int) $query_args['page'],
			);

			$this->set_cached_data( $cache_key, $data );
		}

		return $data;
	}

	/**
	 * Get coupon ID for an order.
	 *
	 * Tries to get the ID from order item meta, then falls back to a query of published coupons.
	 *
	 * @param \WC_Order_Item_Coupon $coupon_item The coupon order item object.
	 * @return int Coupon ID on success, 0 on failure.
	 */
	public static function get_coupon_id( \WC_Order_Item_Coupon $coupon_item ) {
		// First attempt to get coupon ID from order item data.
		$coupon_data = $coupon_item->get_meta( 'coupon_data', true );

		// Normal checkout orders should have this data.
		// See: https://github.com/woocommerce/woocommerce/blob/3dc7df7af9f7ca0c0aa34ede74493e856f276abe/includes/abstracts/abstract-wc-order.php#L1206.
		if ( isset( $coupon_data['id'] ) ) {
			return $coupon_data['id'];
		}

		// Try to get the coupon ID using the code.
		return wc_get_coupon_id_by_code( $coupon_item->get_code() );
	}

	/**
	 * Create or update an an entry in the wc_order_coupon_lookup table for an order.
	 *
	 * @since 3.5.0
	 * @param int $order_id Order ID.
	 * @return int|bool Returns -1 if order won't be processed, or a boolean indicating processing success.
	 */
	public static function sync_order_coupons( $order_id ) {
		global $wpdb;

		$order = wc_get_order( $order_id );

		if ( ! $order ) {
			return -1;
		}

		// Refunds don't affect coupon stats so return successfully if one is called here.
		if ( 'shop_order_refund' === $order->get_type() ) {
			return true;
		}

		$table_name     = self::get_db_table_name();
		$existing_items = $wpdb->get_col(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				"SELECT coupon_id FROM {$table_name} WHERE order_id = %d",
				$order_id
			)
		);
		$existing_items     = array_flip( $existing_items );
		$coupon_items       = $order->get_items( 'coupon' );
		$coupon_items_count = count( $coupon_items );
		$num_updated        = 0;
		$num_deleted        = 0;

		foreach ( $coupon_items as $coupon_item ) {
			$coupon_id = self::get_coupon_id( $coupon_item );
			unset( $existing_items[ $coupon_id ] );

			if ( ! $coupon_id ) {
				// Insert a unique, but obviously invalid ID for this deleted coupon.
				$num_deleted++;
				$coupon_id = -1 * $num_deleted;
			}

			$result = $wpdb->replace(
				self::get_db_table_name(),
				array(
					'order_id'        => $order_id,
					'coupon_id'       => $coupon_id,
					'discount_amount' => $coupon_item->get_discount(),
					'date_created'    => $order->get_date_created( 'edit' )->date( TimeInterval::$sql_datetime_format ),
				),
				array(
					'%d',
					'%d',
					'%f',
					'%s',
				)
			);

			/**
			 * Fires when coupon's reports are updated.
			 *
			 * @param int $coupon_id Coupon ID.
			 * @param int $order_id  Order ID.
			 */
			do_action( 'woocommerce_analytics_update_coupon', $coupon_id, $order_id );

			// Sum the rows affected. Using REPLACE can affect 2 rows if the row already exists.
			$num_updated += 2 === intval( $result ) ? 1 : intval( $result );
		}

		if ( ! empty( $existing_items ) ) {
			$existing_items = array_flip( $existing_items );
			$format         = array_fill( 0, count( $existing_items ), '%d' );
			$format         = implode( ',', $format );
			array_unshift( $existing_items, $order_id );
			$wpdb->query(
				$wpdb->prepare(
					// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
					"DELETE FROM {$table_name} WHERE order_id = %d AND coupon_id in ({$format})",
					$existing_items
				)
			);
		}

		return ( $coupon_items_count === $num_updated );
	}

	/**
	 * Clean coupons data when an order is deleted.
	 *
	 * @param int $order_id Order ID.
	 */
	public static function sync_on_order_delete( $order_id ) {
		global $wpdb;

		$wpdb->delete( self::get_db_table_name(), array( 'order_id' => $order_id ) );
		/**
		 * Fires when coupon's reports are removed from database.
		 *
		 * @param int $coupon_id Coupon ID.
		 * @param int $order_id  Order ID.
		 */
		do_action( 'woocommerce_analytics_delete_coupon', 0, $order_id );

		ReportsCache::invalidate();
	}

	/**
	 * Gets coupons based on the provided arguments.
	 *
	 * @todo Upon core merge, including this in core's `class-wc-coupon-data-store-cpt.php` might make more sense.
	 * @param array $args Array of args to filter the query by. Supports `include`.
	 * @return array Array of results.
	 */
	public function get_coupons( $args ) {
		global $wpdb;
		$query = "SELECT ID, post_title FROM {$wpdb->posts} WHERE post_type='shop_coupon'";

		$included_coupons = $this->get_included_coupons( $args, 'include' );
		if ( ! empty( $included_coupons ) ) {
			$query .= " AND ID IN ({$included_coupons})";
		}

		return $wpdb->get_results( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
	}

	/**
	 * Initialize query objects.
	 */
	protected function initialize_queries() {
		$this->clear_all_clauses();
		$this->subquery = new SqlQuery( $this->context . '_subquery' );
		$this->subquery->add_sql_clause( 'from', self::get_db_table_name() );
		$this->subquery->add_sql_clause( 'group_by', 'coupon_id' );
	}
}
Reports/Coupons/Controller.php000064400000026177152333372110012501 0ustar00<?php
/**
 * REST API Reports coupons controller
 *
 * Handles requests to the /reports/coupons endpoint.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Coupons;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\ExportableInterface;

/**
 * REST API Reports coupons controller class.
 *
 * @internal
 * @extends WC_REST_Reports_Controller
 */
class Controller extends \WC_REST_Reports_Controller implements ExportableInterface {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'reports/coupons';

	/**
	 * 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['coupons']             = (array) $request['coupons'];
		$args['extended_info']       = $request['extended_info'];
		$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 );
		$coupons_query = new Query( $query_args );
		$report_data   = $coupons_query->get_data();

		$data = array();

		foreach ( $report_data->data as $coupons_data ) {
			$item   = $this->prepare_item_for_response( $coupons_data, $request );
			$data[] = $this->prepare_response_for_collection( $item );
		}

		$response = rest_ensure_response( $data );
		$response->header( 'X-WP-Total', (int) $report_data->total );
		$response->header( 'X-WP-TotalPages', (int) $report_data->pages );

		$page      = $report_data->page_no;
		$max_pages = $report_data->pages;
		$base      = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) );
		if ( $page > 1 ) {
			$prev_page = $page - 1;
			if ( $prev_page > $max_pages ) {
				$prev_page = $max_pages;
			}
			$prev_link = add_query_arg( 'page', $prev_page, $base );
			$response->link_header( 'prev', $prev_link );
		}
		if ( $max_pages > $page ) {
			$next_page = $page + 1;
			$next_link = add_query_arg( 'page', $next_page, $base );
			$response->link_header( 'next', $next_link );
		}

		return $response;
	}

	/**
	 * 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_coupons', $response, $report, $request );
	}

	/**
	 * Prepare links for the request.
	 *
	 * @param WC_Reports_Query $object Object data.
	 * @return array
	 */
	protected function prepare_links( $object ) {
		$links = array(
			'coupon' => array(
				'href' => rest_url( sprintf( '/%s/coupons/%d', $this->namespace, $object['coupon_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_coupons',
			'type'       => 'object',
			'properties' => array(
				'coupon_id'     => array(
					'description' => __( 'Coupon ID.', 'woocommerce' ),
					'type'        => 'integer',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'amount'        => array(
					'description' => __( 'Net discount amount.', 'woocommerce' ),
					'type'        => 'number',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'orders_count'  => array(
					'description' => __( 'Number of orders.', 'woocommerce' ),
					'type'        => 'integer',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'extended_info' => array(
					'code'             => array(
						'type'        => 'string',
						'readonly'    => true,
						'context'     => array( 'view', 'edit' ),
						'description' => __( 'Coupon code.', 'woocommerce' ),
					),
					'date_created'     => array(
						'type'        => 'date-time',
						'readonly'    => true,
						'context'     => array( 'view', 'edit' ),
						'description' => __( 'Coupon creation date.', 'woocommerce' ),
					),
					'date_created_gmt' => array(
						'type'        => 'date-time',
						'readonly'    => true,
						'context'     => array( 'view', 'edit' ),
						'description' => __( 'Coupon creation date in GMT.', 'woocommerce' ),
					),
					'date_expires'     => array(
						'type'        => 'date-time',
						'readonly'    => true,
						'context'     => array( 'view', 'edit' ),
						'description' => __( 'Coupon expiration date.', 'woocommerce' ),
					),
					'date_expires_gmt' => array(
						'type'        => 'date-time',
						'readonly'    => true,
						'context'     => array( 'view', 'edit' ),
						'description' => __( 'Coupon expiration date in GMT.', 'woocommerce' ),
					),
					'discount_type'    => array(
						'type'        => 'string',
						'readonly'    => true,
						'context'     => array( 'view', 'edit' ),
						'enum'        => array_keys( wc_get_coupon_types() ),
						'description' => __( 'Coupon discount type.', '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'           => 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'           => 'coupon_id',
			'enum'              => array(
				'coupon_id',
				'code',
				'amount',
				'orders_count',
			),
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['coupons']       = array(
			'description'       => __( 'Limit result set to coupons assigned specific coupon IDs.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'type' => 'integer',
			),
		);
		$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['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 the column names for export.
	 *
	 * @return array Key value pair of Column ID => Label.
	 */
	public function get_export_columns() {
		$export_columns = array(
			'code'         => __( 'Coupon code', 'woocommerce' ),
			'orders_count' => __( 'Orders', 'woocommerce' ),
			'amount'       => __( 'Amount discounted', 'woocommerce' ),
			'created'      => __( 'Created', 'woocommerce' ),
			'expires'      => __( 'Expires', 'woocommerce' ),
			'type'         => __( 'Type', 'woocommerce' ),
		);

		/**
		 * Filter to add or remove column names from the coupons report for
		 * export.
		 *
		 * @since 1.6.0
		 */
		return apply_filters(
			'woocommerce_report_coupons_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 ) {
		$date_expires = empty( $item['extended_info']['date_expires'] )
			? __( 'N/A', 'woocommerce' )
			: $item['extended_info']['date_expires'];

		$export_item = array(
			'code'         => $item['extended_info']['code'],
			'orders_count' => $item['orders_count'],
			'amount'       => $item['amount'],
			'created'      => $item['extended_info']['date_created'],
			'expires'      => $date_expires,
			'type'         => $item['extended_info']['discount_type'],
		);

		/**
		 * Filter to prepare extra columns in the export item for the coupons
		 * report.
		 *
		 * @since 1.6.0
		 */
		return apply_filters(
			'woocommerce_report_coupons_prepare_export_item',
			$export_item,
			$item
		);
	}
}
Reports/Coupons/Query.php000064400000002246152333372110011452 0ustar00<?php
/**
 * Class for parameter-based Coupons Report querying
 *
 * Example usage:
 * $args = array(
 *          'before'  => '2018-07-19 00:00:00',
 *          'after'   => '2018-07-05 00:00:00',
 *          'page'    => 2,
 *          'coupons' => array(5, 120),
 *         );
 * $report = new \Automattic\WooCommerce\Admin\API\Reports\Coupons\Query( $args );
 * $mydata = $report->get_data();
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Coupons;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;

/**
 * API\Reports\Coupons\Query
 */
class Query extends ReportsQuery {

	/**
	 * Valid fields for Products report.
	 *
	 * @return array
	 */
	protected function get_default_query_vars() {
		return array();
	}

	/**
	 * Get product data based on the current query vars.
	 *
	 * @return array
	 */
	public function get_data() {
		$args = apply_filters( 'woocommerce_analytics_coupons_query_args', $this->get_query_vars() );

		$data_store = \WC_Data_Store::load( 'report-coupons' );
		$results    = $data_store->get_data( $args );
		return apply_filters( 'woocommerce_analytics_coupons_select_query', $results, $args );
	}

}
Reports/Coupons/Stats/Controller.php000064400000027675152333372110013603 0ustar00<?php
/**
 * REST API Reports coupons stats controller
 *
 * Handles requests to the /reports/coupons/stats endpoint.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Coupons\Stats;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\ParameterException;

/**
 * REST API Reports coupons stats controller class.
 *
 * @internal
 * @extends WC_REST_Reports_Controller
 */
class Controller extends \WC_REST_Reports_Controller {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'reports/coupons/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['coupons']             = (array) $request['coupons'];
		$args['segmentby']           = $request['segmentby'];
		$args['fields']              = $request['fields'];
		$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 );
		$coupons_query = new Query( $query_args );
		try {
			$report_data = $coupons_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( (object) $interval_data, $request );
			$out_data['intervals'][] = $this->prepare_response_for_collection( $item );
		}

		$response = rest_ensure_response( $out_data );
		$response->header( 'X-WP-Total', (int) $report_data->total );
		$response->header( 'X-WP-TotalPages', (int) $report_data->pages );

		$page      = $report_data->page_no;
		$max_pages = $report_data->pages;
		$base      = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) );
		if ( $page > 1 ) {
			$prev_page = $page - 1;
			if ( $prev_page > $max_pages ) {
				$prev_page = $max_pages;
			}
			$prev_link = add_query_arg( 'page', $prev_page, $base );
			$response->link_header( 'prev', $prev_link );
		}
		if ( $max_pages > $page ) {
			$next_page = $page + 1;
			$next_link = add_query_arg( 'page', $next_page, $base );
			$response->link_header( 'next', $next_link );
		}

		return $response;
	}

	/**
	 * 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 = get_object_vars( $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_coupons_stats', $response, $report, $request );
	}

	/**
	 * Get the Report's schema, conforming to JSON Schema.
	 *
	 * @return array
	 */
	public function get_item_schema() {
		$data_values = array(
			'amount'        => array(
				'description' => __( 'Net discount amount.', 'woocommerce' ),
				'type'        => 'number',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
				'indicator'   => true,
				'format'      => 'currency',
			),
			'coupons_count' => array(
				'description' => __( 'Number of coupons.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
			'orders_count'  => array(
				'title'       => __( 'Discounted orders', 'woocommerce' ),
				'description' => __( 'Number of discounted orders.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
				'indicator'   => 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 );

		$schema = array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => 'report_coupons_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'  => $totals,
							),
						),
					),
				),
			),
		);

		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',
				'amount',
				'coupons_count',
				'orders_count',
			),
			'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['coupons']   = array(
			'description'       => __( 'Limit result set to coupons assigned specific coupon IDs.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'type' => 'integer',
			),
		);
		$params['segmentby'] = array(
			'description'       => __( 'Segment the response by additional constraint.', 'woocommerce' ),
			'type'              => 'string',
			'enum'              => array(
				'product',
				'variation',
				'category',
				'coupon',
			),
			'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;
	}
}
Reports/Coupons/Stats/Segmenter.php000064400000036143152333372110013377 0ustar00<?php
/**
 * Class for adding segmenting support to coupons/stats without cluttering the data store.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Coupons\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. coupon discount amount for product X when segmenting by product id or 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(
			'amount' => "SUM($products_table.coupon_amount) as amount",
		);

		return $columns_mapping;
	}

	/**
	 * Returns column => query mapping to be used for order-related product-level segmenting query
	 * (e.g. orders_count when segmented by category).
	 *
	 * @param string $coupons_lookup_table Name of SQL table containing the order-level segmenting info.
	 *
	 * @return array Column => SELECT query mapping.
	 */
	protected function get_segment_selections_order_level( $coupons_lookup_table ) {
		$columns_mapping = array(
			'coupons_count' => "COUNT(DISTINCT $coupons_lookup_table.coupon_id) as coupons_count",
			'orders_count'  => "COUNT(DISTINCT $coupons_lookup_table.order_id) as orders_count",
		);

		return $columns_mapping;
	}

	/**
	 * Returns column => query mapping to be used for order-level segmenting query
	 * (e.g. discount amount when segmented by coupons).
	 *
	 * @param string $coupons_lookup_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( $coupons_lookup_table, $overrides = array() ) {
		$columns_mapping = array(
			'amount'        => "SUM($coupons_lookup_table.discount_amount) as amount",
			'coupons_count' => "COUNT(DISTINCT $coupons_lookup_table.coupon_id) as coupons_count",
			'orders_count'  => "COUNT(DISTINCT $coupons_lookup_table.order_id) as orders_count",
		);

		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-level numbers and order-level numbers can be fetched by the same query.
		$segments_products = $wpdb->get_results(
			"SELECT
						$segmenting_groupby AS $segmenting_dimension_name
						{$segmenting_selections['product_level']}
						{$segmenting_selections['order_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
		); // WPCS: cache ok, DB call ok, unprepared SQL ok.

		$totals_segments = $this->merge_segment_totals_results( $segmenting_dimension_name, $segments_products, array() );
		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;

		// 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() );

		// Product-level numbers and order-level numbers can be fetched by the same query.
		$segments_products = $wpdb->get_results(
			"SELECT
						{$intervals_query['select_clause']} AS time_interval,
						$segmenting_groupby AS $segmenting_dimension_name
						{$segmenting_selections['product_level']}
						{$segmenting_selections['order_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
		); // WPCS: cache ok, DB call ok, unprepared SQL ok.

		$intervals_segments = $this->merge_segment_intervals_results( $segmenting_dimension_name, $segments_products, array() );
		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
		); // WPCS: 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;
		$limit_parts      = explode( ',', $intervals_query['limit'] );
		$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
		); // WPCS: 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      = '';
		$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'] ) {
			$product_level_columns     = $this->get_segment_selections_product_level( $product_segmenting_table );
			$order_level_columns       = $this->get_segment_selections_order_level( $table_name );
			$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( $table_name );
			$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( $table_name );
			$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'] ) {
			$coupon_level_columns  = $this->segment_selections_orders( $table_name );
			$segmenting_selections = $this->prepare_selections( $coupon_level_columns );
			$this->report_columns  = $coupon_level_columns;
			$segmenting_from       = '';
			$segmenting_groupby    = "$table_name.coupon_id";

			$segments = $this->get_order_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params );
		}

		return $segments;
	}
}
Reports/Coupons/Stats/Query.php000064400000002305152333372110012544 0ustar00<?php
/**
 * Class for parameter-based Products Report querying
 *
 * Example usage:
 * $args = array(
 *          'before'  => '2018-07-19 00:00:00',
 *          'after'   => '2018-07-05 00:00:00',
 *          'page'    => 2,
 *          'coupons' => array(5, 120),
 *         );
 * $report = new \Automattic\WooCommerce\Admin\API\Reports\Coupons\Stats\Query( $args );
 * $mydata = $report->get_data();
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Coupons\Stats;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;

/**
 * API\Reports\Coupons\Stats\Query
 */
class Query extends ReportsQuery {

	/**
	 * Valid fields for Products report.
	 *
	 * @return array
	 */
	protected function get_default_query_vars() {
		return array();
	}

	/**
	 * Get product data based on the current query vars.
	 *
	 * @return array
	 */
	public function get_data() {
		$args = apply_filters( 'woocommerce_analytics_coupons_stats_query_args', $this->get_query_vars() );

		$data_store = \WC_Data_Store::load( 'report-coupons-stats' );
		$results    = $data_store->get_data( $args );
		return apply_filters( 'woocommerce_analytics_coupons_select_query', $results, $args );
	}

}
Reports/Coupons/Stats/DataStore.php000064400000021311152333372110013323 0ustar00<?php
/**
 * API\Reports\Coupons\Stats\DataStore class file.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Coupons\Stats;

defined( 'ABSPATH' ) || exit;
use \Automattic\WooCommerce\Admin\API\Reports\Coupons\DataStore as CouponsDataStore;
use \Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface;
use \Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
use \Automattic\WooCommerce\Admin\API\Reports\SqlQuery;

/**
 * API\Reports\Coupons\Stats\DataStore.
 */
class DataStore extends CouponsDataStore implements DataStoreInterface {
	/**
	 * Mapping columns to data type to return correct response types.
	 *
	 * @var array
	 */
	protected $column_types = array(
		'date_start'     => 'strval',
		'date_end'       => 'strval',
		'date_start_gmt' => 'strval',
		'date_end_gmt'   => 'strval',
		'amount'         => 'floatval',
		'coupons_count'  => 'intval',
		'orders_count'   => 'intval',
	);

	/**
	 * SQL columns to select in the db query.
	 *
	 * @var array
	 */
	protected $report_columns;

	/**
	 * Data store context used to pass to filters.
	 *
	 * @var string
	 */
	protected $context = 'coupons_stats';

	/**
	 * Cache identifier.
	 *
	 * @var string
	 */
	protected $cache_key = 'coupons_stats';

	/**
	 * Assign report columns once full table name has been assigned.
	 */
	protected function assign_report_columns() {
		$table_name           = self::get_db_table_name();
		$this->report_columns = array(
			'amount'        => 'SUM(discount_amount) as amount',
			'coupons_count' => 'COUNT(DISTINCT coupon_id) as coupons_count',
			'orders_count'  => "COUNT(DISTINCT {$table_name}.order_id) as orders_count",
		);
	}

	/**
	 * Updates the database query with parameters used for Products Stats report: categories and order status.
	 *
	 * @param array $query_args       Query arguments supplied by the user.
	 */
	protected function update_sql_query_params( $query_args ) {
		global $wpdb;

		$clauses = array(
			'where' => '',
			'join'  => '',
		);

		$order_coupon_lookup_table = self::get_db_table_name();

		$included_coupons = $this->get_included_coupons( $query_args, 'coupons' );
		if ( $included_coupons ) {
			$clauses['where'] .= " AND {$order_coupon_lookup_table}.coupon_id IN ({$included_coupons})";
		}

		$order_status_filter = $this->get_status_subquery( $query_args );
		if ( $order_status_filter ) {
			$clauses['join']  .= " JOIN {$wpdb->prefix}wc_order_stats ON {$order_coupon_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id";
			$clauses['where'] .= " AND ( {$order_status_filter} )";
		}

		$this->add_time_period_sql_params( $query_args, $order_coupon_lookup_table );
		$this->add_intervals_sql_params( $query_args, $order_coupon_lookup_table );
		$clauses['where_time'] = $this->get_sql_clause( 'where_time' );

		$this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) );
		$this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) );
		$this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) );
		$this->interval_query->add_sql_clause( 'select', 'AS time_interval' );

		foreach ( array( 'join', 'where_time', 'where' ) as $clause ) {
			$this->interval_query->add_sql_clause( $clause, $clauses[ $clause ] );
			$this->total_query->add_sql_clause( $clause, $clauses[ $clause ] );
		}
	}

	/**
	 * Returns the report data based on parameters supplied by the user.
	 *
	 * @since 3.5.0
	 * @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 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'  => 'date',
			'before'   => TimeInterval::default_before(),
			'after'    => TimeInterval::default_after(),
			'fields'   => '*',
			'interval' => 'week',
			'coupons'  => 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 );
			$totals_query    = array();
			$intervals_query = array();
			$limit_params    = $this->get_limit_sql_params( $query_args );
			$this->update_sql_query_params( $query_args, $totals_query, $intervals_query );

			$db_intervals = $wpdb->get_col(
				$this->interval_query->get_query_statement()
			); // WPCS: 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 / $limit_params['per_page'] );
			if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) {
				return $data;
			}

			$this->total_query->add_sql_clause( 'select', $selections );
			$totals = $wpdb->get_results(
				$this->total_query->get_query_statement(),
				ARRAY_A
			); // WPCS: cache ok, DB call ok, unprepared SQL ok.

			if ( null === $totals ) {
				return $data;
			}

			// @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' => $this->total_query->get_sql_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' => $this->interval_query->get_sql_clause( 'where_time' ),
				'where_clause'      => $this->interval_query->get_sql_clause( 'where' ),
				'limit'             => $this->get_sql_clause( 'limit' ),
			);
			$segmenter             = new Segmenter( $query_args, $this->report_columns );
			$totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name );
			$totals                = (object) $this->cast_numbers( $totals[0] );

			// Intervals.
			$this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name );
			$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
			); // WPCS: cache ok, DB call ok, unprepared SQL ok.

			if ( null === $intervals ) {
				return $data;
			}

			$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, $limit_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'], $limit_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;
	}

	/**
	 * 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' );
	}
}
Reports/Products/Query.php000064400000002353152333372110011626 0ustar00<?php
/**
 * Class for parameter-based Products Report querying
 *
 * Example usage:
 * $args = array(
 *          'before'       => '2018-07-19 00:00:00',
 *          'after'        => '2018-07-05 00:00:00',
 *          'page'         => 2,
 *          'categories'   => array(15, 18),
 *          'products'     => array(1,2,3)
 *         );
 * $report = new \Automattic\WooCommerce\Admin\API\Reports\Products\Query( $args );
 * $mydata = $report->get_data();
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Products;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;

/**
 * API\Reports\Products\Query
 */
class Query extends ReportsQuery {

	/**
	 * Valid fields for Products report.
	 *
	 * @return array
	 */
	protected function get_default_query_vars() {
		return array();
	}

	/**
	 * Get product data based on the current query vars.
	 *
	 * @return array
	 */
	public function get_data() {
		$args = apply_filters( 'woocommerce_analytics_products_query_args', $this->get_query_vars() );

		$data_store = \WC_Data_Store::load( 'report-products' );
		$results    = $data_store->get_data( $args );
		return apply_filters( 'woocommerce_analytics_products_select_query', $results, $args );
	}
}
Reports/Products/DataStore.php000064400000042533152333372110012413 0ustar00<?php
/**
 * API\Reports\Products\DataStore class file.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Products;

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;

/**
 * API\Reports\Products\DataStore.
 */
class DataStore extends ReportsDataStore implements DataStoreInterface {

	/**
	 * Table used to get the data.
	 *
	 * @var string
	 */
	protected static $table_name = 'wc_order_product_lookup';

	/**
	 * Cache identifier.
	 *
	 * @var string
	 */
	protected $cache_key = 'products';

	/**
	 * Mapping columns to data type to return correct response types.
	 *
	 * @var array
	 */
	protected $column_types = array(
		'date_start'       => 'strval',
		'date_end'         => 'strval',
		'product_id'       => 'intval',
		'items_sold'       => 'intval',
		'net_revenue'      => 'floatval',
		'orders_count'     => 'intval',
		// Extended attributes.
		'name'             => 'strval',
		'price'            => 'floatval',
		'image'            => 'strval',
		'permalink'        => 'strval',
		'stock_status'     => 'strval',
		'stock_quantity'   => 'intval',
		'low_stock_amount' => 'intval',
		'category_ids'     => 'array_values',
		'variations'       => 'array_values',
		'sku'              => 'strval',
	);

	/**
	 * Extended product attributes to include in the data.
	 *
	 * @var array
	 */
	protected $extended_attributes = array(
		'name',
		'price',
		'image',
		'permalink',
		'stock_status',
		'stock_quantity',
		'manage_stock',
		'low_stock_amount',
		'category_ids',
		'variations',
		'sku',
	);

	/**
	 * Data store context used to pass to filters.
	 *
	 * @var string
	 */
	protected $context = 'products';

	/**
	 * Assign report columns once full table name has been assigned.
	 */
	protected function assign_report_columns() {
		$table_name           = self::get_db_table_name();
		$this->report_columns = array(
			'product_id'   => 'product_id',
			'items_sold'   => 'SUM(product_qty) as items_sold',
			'net_revenue'  => 'SUM(product_net_revenue) AS net_revenue',
			'orders_count' => "COUNT( DISTINCT ( CASE WHEN product_gross_revenue >= 0 THEN {$table_name}.order_id END ) ) as orders_count",
		);
	}

	/**
	 * Set up all the hooks for maintaining and populating table data.
	 */
	public static function init() {
		add_action( 'woocommerce_analytics_delete_order_stats', array( __CLASS__, 'sync_on_order_delete' ), 10 );
	}

	/**
	 * Fills FROM clause of SQL request based on user supplied parameters.
	 *
	 * @param array  $query_args Parameters supplied by the user.
	 * @param string $arg_name   Target of the JOIN sql param.
	 * @param string $id_cell    ID cell identifier, like `table_name.id_column_name`.
	 */
	protected function add_from_sql_params( $query_args, $arg_name, $id_cell ) {
		global $wpdb;

		$type = 'join';
		// Order by product name requires extra JOIN.
		switch ( $query_args['orderby'] ) {
			case 'product_name':
				$join = " JOIN {$wpdb->posts} AS _products ON {$id_cell} = _products.ID";
				break;
			case 'sku':
				$join = " LEFT JOIN {$wpdb->postmeta} AS postmeta ON {$id_cell} = postmeta.post_id AND postmeta.meta_key = '_sku'";
				break;
			case 'variations':
				$type = 'left_join';
				$join = "LEFT JOIN ( SELECT post_parent, COUNT(*) AS variations FROM {$wpdb->posts} WHERE post_type = 'product_variation' GROUP BY post_parent ) AS _variations ON {$id_cell} = _variations.post_parent";
				break;
			default:
				$join = '';
				break;
		}
		if ( $join ) {
			if ( 'inner' === $arg_name ) {
				$this->subquery->add_sql_clause( $type, $join );
			} else {
				$this->add_sql_clause( $type, $join );
			}
		}
	}

	/**
	 * Updates the database query with parameters used for Products report: categories and order status.
	 *
	 * @param array $query_args Query arguments supplied by the user.
	 */
	protected function add_sql_query_params( $query_args ) {
		global $wpdb;
		$order_product_lookup_table = self::get_db_table_name();

		$this->add_time_period_sql_params( $query_args, $order_product_lookup_table );
		$this->get_limit_sql_params( $query_args );
		$this->add_order_by_sql_params( $query_args );

		$included_products = $this->get_included_products( $query_args );
		if ( $included_products ) {
			$this->add_from_sql_params( $query_args, 'outer', 'default_results.product_id' );
			$this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.product_id IN ({$included_products})" );
		} else {
			$this->add_from_sql_params( $query_args, 'inner', "{$order_product_lookup_table}.product_id" );
		}

		$included_variations = $this->get_included_variations( $query_args );
		if ( $included_variations ) {
			$this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.variation_id IN ({$included_variations})" );
		}

		$order_status_filter = $this->get_status_subquery( $query_args );
		if ( $order_status_filter ) {
			$this->subquery->add_sql_clause( 'join', "JOIN {$wpdb->prefix}wc_order_stats ON {$order_product_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id" );
			$this->subquery->add_sql_clause( 'where', "AND ( {$order_status_filter} )" );
		}
	}

	/**
	 * Maps ordering specified by the user to columns in the database/fields in the data.
	 *
	 * @param string $order_by Sorting criterion.
	 * @return string
	 */
	protected function normalize_order_by( $order_by ) {
		if ( 'date' === $order_by ) {
			return self::get_db_table_name() . '.date_created';
		}
		if ( 'product_name' === $order_by ) {
			return 'post_title';
		}
		if ( 'sku' === $order_by ) {
			return 'meta_value';
		}
		return $order_by;
	}

	/**
	 * Enriches the product data with attributes specified by the extended_attributes.
	 *
	 * @param array $products_data Product data.
	 * @param array $query_args  Query parameters.
	 */
	protected function include_extended_info( &$products_data, $query_args ) {
		global $wpdb;
		$product_names = array();

		foreach ( $products_data as $key => $product_data ) {
			$extended_info = new \ArrayObject();
			if ( $query_args['extended_info'] ) {
				$product_id = $product_data['product_id'];
				$product    = wc_get_product( $product_id );
				// Product was deleted.
				if ( ! $product ) {
					if ( ! isset( $product_names[ $product_id ] ) ) {
						$product_names[ $product_id ] = $wpdb->get_var(
							$wpdb->prepare(
								"SELECT i.order_item_name
								FROM {$wpdb->prefix}woocommerce_order_items i, {$wpdb->prefix}woocommerce_order_itemmeta m
								WHERE i.order_item_id = m.order_item_id
								AND m.meta_key = '_product_id'
								AND m.meta_value = %s
								ORDER BY i.order_item_id DESC
								LIMIT 1",
								$product_id
							)
						);
					}

					/* translators: %s is product name */
					$products_data[ $key ]['extended_info']['name'] = $product_names[ $product_id ] ? sprintf( __( '%s (Deleted)', 'woocommerce' ), $product_names[ $product_id ] ) : __( '(Deleted)', 'woocommerce' );
					continue;
				}

				$extended_attributes = apply_filters( 'woocommerce_rest_reports_products_extended_attributes', $this->extended_attributes, $product_data );
				foreach ( $extended_attributes as $extended_attribute ) {
					if ( 'variations' === $extended_attribute ) {
						if ( ! $product->is_type( 'variable' ) ) {
							continue;
						}
						$function = 'get_children';
					} else {
						$function = 'get_' . $extended_attribute;
					}
					if ( is_callable( array( $product, $function ) ) ) {
						$value                                = $product->{$function}();
						$extended_info[ $extended_attribute ] = $value;
					}
				}
				// If there is no set low_stock_amount, use the one in user settings.
				if ( '' === $extended_info['low_stock_amount'] ) {
					$extended_info['low_stock_amount'] = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) );
				}
				$extended_info = $this->cast_numbers( $extended_info );
			}
			$products_data[ $key ]['extended_info'] = $extended_info;
		}
	}

	/**
	 * 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 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'           => 'date',
			'before'            => TimeInterval::default_before(),
			'after'             => TimeInterval::default_after(),
			'fields'            => '*',
			'category_includes' => array(),
			'product_includes'  => array(),
			'extended_info'     => false,
		);
		$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 );
			$included_products = $this->get_included_products_array( $query_args );
			$params            = $this->get_limit_params( $query_args );
			$this->add_sql_query_params( $query_args );

			if ( count( $included_products ) > 0 ) {
				$filtered_products = array_diff( $included_products, array( '-1' ) );
				$total_results     = count( $filtered_products );
				$total_pages       = (int) ceil( $total_results / $params['per_page'] );

				if ( 'date' === $query_args['orderby'] ) {
					$selections .= ", {$table_name}.date_created";
				}

				$fields          = $this->get_fields( $query_args );
				$join_selections = $this->format_join_selections( $fields, array( 'product_id' ) );
				$ids_table       = $this->get_ids_table( $included_products, 'product_id' );

				$this->subquery->clear_sql_clause( 'select' );
				$this->subquery->add_sql_clause( 'select', $selections );
				$this->add_sql_clause( 'select', $join_selections );
				$this->add_sql_clause( 'from', '(' );
				$this->add_sql_clause( 'from', $this->subquery->get_query_statement() );
				$this->add_sql_clause( 'from', ") AS {$table_name}" );
				$this->add_sql_clause(
					'right_join',
					"RIGHT JOIN ( {$ids_table} ) AS default_results
					ON default_results.product_id = {$table_name}.product_id"
				);
				$this->add_sql_clause( 'where', 'AND default_results.product_id != -1' );

				$products_query = $this->get_query_statement();
			} else {
				$count_query      = "SELECT COUNT(*) FROM (
						{$this->subquery->get_query_statement()}
					) AS tt";
				$db_records_count = (int) $wpdb->get_var(
					$count_query // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
				);

				$total_results = $db_records_count;
				$total_pages   = (int) ceil( $db_records_count / $params['per_page'] );

				if ( ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) ) {
					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' ) );
				$products_query = $this->subquery->get_query_statement();
			}

			$product_data = $wpdb->get_results(
				$products_query, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
				ARRAY_A
			);

			if ( null === $product_data ) {
				return $data;
			}

			$product_data = array_map( array( $this, 'cast_numbers' ), $product_data );
			$data         = (object) array(
				'data'    => $product_data,
				'total'   => $total_results,
				'pages'   => $total_pages,
				'page_no' => (int) $query_args['page'],
			);

			$this->set_cached_data( $cache_key, $data );
		}

		$this->include_extended_info( $data->data, $query_args );

		return $data;
	}

	/**
	 * Create or update an entry in the wc_admin_order_product_lookup table for an order.
	 *
	 * @since 3.5.0
	 * @param int $order_id Order ID.
	 * @return int|bool Returns -1 if order won't be processed, or a boolean indicating processing success.
	 */
	public static function sync_order_products( $order_id ) {
		global $wpdb;

		$order = wc_get_order( $order_id );
		if ( ! $order ) {
			return -1;
		}

		$table_name     = self::get_db_table_name();
		$existing_items = $wpdb->get_col(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				"SELECT order_item_id FROM {$table_name} WHERE order_id = %d",
				$order_id
			)
		);
		$existing_items = array_flip( $existing_items );
		$order_items    = $order->get_items();
		$num_updated    = 0;
		$decimals       = wc_get_price_decimals();
		$round_tax      = 'no' === get_option( 'woocommerce_tax_round_at_subtotal' );

		foreach ( $order_items as $order_item ) {
			$order_item_id = $order_item->get_id();
			unset( $existing_items[ $order_item_id ] );
			$product_qty         = $order_item->get_quantity( 'edit' );
			$shipping_amount     = $order->get_item_shipping_amount( $order_item );
			$shipping_tax_amount = $order->get_item_shipping_tax_amount( $order_item );
			$coupon_amount       = $order->get_item_coupon_amount( $order_item );

			// Skip line items without changes to product quantity.
			if ( ! $product_qty ) {
				$num_updated++;
				continue;
			}

			// Tax amount.
			$tax_amount  = 0;
			$order_taxes = $order->get_taxes();
			$tax_data    = $order_item->get_taxes();
			foreach ( $order_taxes as $tax_item ) {
				$tax_item_id = $tax_item->get_rate_id();
				$tax_amount += isset( $tax_data['total'][ $tax_item_id ] ) ? (float) $tax_data['total'][ $tax_item_id ] : 0;
			}

			$net_revenue = round( $order_item->get_total( 'edit' ), $decimals );
			if ( $round_tax ) {
				$tax_amount = round( $tax_amount, $decimals );
			}

			$result = $wpdb->replace(
				self::get_db_table_name(),
				array(
					'order_item_id'         => $order_item_id,
					'order_id'              => $order->get_id(),
					'product_id'            => wc_get_order_item_meta( $order_item_id, '_product_id' ),
					'variation_id'          => wc_get_order_item_meta( $order_item_id, '_variation_id' ),
					'customer_id'           => $order->get_report_customer_id(),
					'product_qty'           => $product_qty,
					'product_net_revenue'   => $net_revenue,
					'date_created'          => $order->get_date_created( 'edit' )->date( TimeInterval::$sql_datetime_format ),
					'coupon_amount'         => $coupon_amount,
					'tax_amount'            => $tax_amount,
					'shipping_amount'       => $shipping_amount,
					'shipping_tax_amount'   => $shipping_tax_amount,
					// @todo Can this be incorrect if modified by filters?
					'product_gross_revenue' => $net_revenue + $tax_amount + $shipping_amount + $shipping_tax_amount,
				),
				array(
					'%d', // order_item_id.
					'%d', // order_id.
					'%d', // product_id.
					'%d', // variation_id.
					'%d', // customer_id.
					'%d', // product_qty.
					'%f', // product_net_revenue.
					'%s', // date_created.
					'%f', // coupon_amount.
					'%f', // tax_amount.
					'%f', // shipping_amount.
					'%f', // shipping_tax_amount.
					'%f', // product_gross_revenue.
				)
			); // WPCS: cache ok, DB call ok, unprepared SQL ok.

			/**
			 * Fires when product's reports are updated.
			 *
			 * @param int $order_item_id Order Item ID.
			 * @param int $order_id      Order ID.
			 */
			do_action( 'woocommerce_analytics_update_product', $order_item_id, $order->get_id() );

			// Sum the rows affected. Using REPLACE can affect 2 rows if the row already exists.
			$num_updated += 2 === intval( $result ) ? 1 : intval( $result );
		}

		if ( ! empty( $existing_items ) ) {
			$existing_items = array_flip( $existing_items );
			$format         = array_fill( 0, count( $existing_items ), '%d' );
			$format         = implode( ',', $format );
			array_unshift( $existing_items, $order_id );
			$wpdb->query(
				$wpdb->prepare(
					// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
					"DELETE FROM {$table_name} WHERE order_id = %d AND order_item_id in ({$format})",
					$existing_items
				)
			);
		}

		return ( count( $order_items ) === $num_updated );
	}

	/**
	 * Clean products data when an order is deleted.
	 *
	 * @param int $order_id Order ID.
	 */
	public static function sync_on_order_delete( $order_id ) {
		global $wpdb;

		$wpdb->delete( self::get_db_table_name(), array( 'order_id' => $order_id ) );

		/**
		 * Fires when product's reports are removed from database.
		 *
		 * @param int $product_id Product ID.
		 * @param int $order_id   Order ID.
		 */
		do_action( 'woocommerce_analytics_delete_product', 0, $order_id );

		ReportsCache::invalidate();
	}

	/**
	 * Initialize query objects.
	 */
	protected function initialize_queries() {
		$this->clear_all_clauses();
		$this->subquery = new SqlQuery( $this->context . '_subquery' );
		$this->subquery->add_sql_clause( 'select', 'product_id' );
		$this->subquery->add_sql_clause( 'from', self::get_db_table_name() );
		$this->subquery->add_sql_clause( 'group_by', 'product_id' );
	}
}
Reports/Products/Controller.php000064400000034660152333372110012652 0ustar00<?php
/**
 * REST API Reports products controller
 *
 * Handles requests to the /reports/products endpoint.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Products;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\ExportableInterface;

/**
 * REST API Reports products controller class.
 *
 * @internal
 * @extends WC_REST_Reports_Controller
 */
class Controller extends \WC_REST_Reports_Controller implements ExportableInterface {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'reports/products';

	/**
	 * Mapping between external parameter name and name used in query class.
	 *
	 * @var array
	 */
	protected $param_mapping = array(
		'categories' => 'category_includes',
		'products'   => 'product_includes',
		'variations' => 'variation_includes',
	);

	/**
	 * Get items.
	 *
	 * @param WP_REST_Request $request Request data.
	 *
	 * @return array|WP_Error
	 */
	public function get_items( $request ) {
		$args       = array();
		$registered = array_keys( $this->get_collection_params() );
		foreach ( $registered as $param_name ) {
			if ( isset( $request[ $param_name ] ) ) {
				if ( isset( $this->param_mapping[ $param_name ] ) ) {
					$args[ $this->param_mapping[ $param_name ] ] = $request[ $param_name ];
				} else {
					$args[ $param_name ] = $request[ $param_name ];
				}
			}
		}

		$reports       = new Query( $args );
		$products_data = $reports->get_data();

		$data = array();

		foreach ( $products_data->data as $product_data ) {
			$item = $this->prepare_item_for_response( $product_data, $request );
			if ( isset( $item->data['extended_info']['name'] ) ) {
				$item->data['extended_info']['name'] = wp_strip_all_tags( $item->data['extended_info']['name'] );
			}
			$data[] = $this->prepare_response_for_collection( $item );
		}

		$response = rest_ensure_response( $data );
		$response->header( 'X-WP-Total', (int) $products_data->total );
		$response->header( 'X-WP-TotalPages', (int) $products_data->pages );

		$page      = $products_data->page_no;
		$max_pages = $products_data->pages;
		$base      = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) );
		if ( $page > 1 ) {
			$prev_page = $page - 1;
			if ( $prev_page > $max_pages ) {
				$prev_page = $max_pages;
			}
			$prev_link = add_query_arg( 'page', $prev_page, $base );
			$response->link_header( 'prev', $prev_link );
		}
		if ( $max_pages > $page ) {
			$next_page = $page + 1;
			$next_link = add_query_arg( 'page', $next_page, $base );
			$response->link_header( 'next', $next_link );
		}

		return $response;
	}

	/**
	 * 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 );
		$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_products', $response, $report, $request );
	}

	/**
	 * Prepare links for the request.
	 *
	 * @param Array $object Object data.
	 * @return array        Links for the given post.
	 */
	protected function prepare_links( $object ) {
		$links = array(
			'product' => array(
				'href' => rest_url( sprintf( '/%s/%s/%d', $this->namespace, 'products', $object['product_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_products',
			'type'       => 'object',
			'properties' => array(
				'product_id'    => array(
					'type'        => 'integer',
					'readonly'    => true,
					'context'     => array( 'view', 'edit' ),
					'description' => __( 'Product ID.', 'woocommerce' ),
				),
				'items_sold'    => array(
					'type'        => 'integer',
					'readonly'    => true,
					'context'     => array( 'view', 'edit' ),
					'description' => __( 'Number of items sold.', 'woocommerce' ),
				),
				'net_revenue'   => array(
					'type'        => 'number',
					'readonly'    => true,
					'context'     => array( 'view', 'edit' ),
					'description' => __( 'Total Net sales of all items sold.', 'woocommerce' ),
				),
				'orders_count'  => array(
					'type'        => 'integer',
					'readonly'    => true,
					'context'     => array( 'view', 'edit' ),
					'description' => __( 'Number of orders product appeared in.', 'woocommerce' ),
				),
				'extended_info' => array(
					'name'             => array(
						'type'        => 'string',
						'readonly'    => true,
						'context'     => array( 'view', 'edit' ),
						'description' => __( 'Product name.', 'woocommerce' ),
					),
					'price'            => array(
						'type'        => 'number',
						'readonly'    => true,
						'context'     => array( 'view', 'edit' ),
						'description' => __( 'Product price.', 'woocommerce' ),
					),
					'image'            => array(
						'type'        => 'string',
						'readonly'    => true,
						'context'     => array( 'view', 'edit' ),
						'description' => __( 'Product image.', 'woocommerce' ),
					),
					'permalink'        => array(
						'type'        => 'string',
						'readonly'    => true,
						'context'     => array( 'view', 'edit' ),
						'description' => __( 'Product link.', 'woocommerce' ),
					),
					'category_ids'     => array(
						'type'        => 'array',
						'readonly'    => true,
						'context'     => array( 'view', 'edit' ),
						'description' => __( 'Product category IDs.', 'woocommerce' ),
					),
					'stock_status'     => array(
						'type'        => 'string',
						'readonly'    => true,
						'context'     => array( 'view', 'edit' ),
						'description' => __( 'Product inventory status.', 'woocommerce' ),
					),
					'stock_quantity'   => array(
						'type'        => 'integer',
						'readonly'    => true,
						'context'     => array( 'view', 'edit' ),
						'description' => __( 'Product inventory quantity.', 'woocommerce' ),
					),
					'low_stock_amount' => array(
						'type'        => 'integer',
						'readonly'    => true,
						'context'     => array( 'view', 'edit' ),
						'description' => __( 'Product inventory threshold for low stock.', 'woocommerce' ),
					),
					'variations'       => array(
						'type'        => 'array',
						'readonly'    => true,
						'context'     => array( 'view', 'edit' ),
						'description' => __( 'Product variations IDs.', 'woocommerce' ),
					),
					'sku'              => array(
						'type'        => 'string',
						'readonly'    => true,
						'context'     => array( 'view', 'edit' ),
						'description' => __( 'Product SKU.', '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'           => 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',
				'items_sold',
				'product_name',
				'variations',
				'sku',
			),
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['categories'] = array(
			'description'       => __( 'Limit result to items from the specified categories.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'type' => 'integer',
			),
		);
		$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['products']   = array(
			'description'       => __( 'Limit result to items with specified product ids.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'type' => 'integer',
			),

		);
		$params['extended_info'] = array(
			'description'       => __( 'Add additional piece of info about each product to the report.', 'woocommerce' ),
			'type'              => 'boolean',
			'default'           => false,
			'sanitize_callback' => 'wc_string_to_bool',
			'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 stock status column export value.
	 *
	 * @param array $status Stock status from report row.
	 * @return string
	 */
	protected function get_stock_status( $status ) {
		$statuses = wc_get_product_stock_status_options();

		return isset( $statuses[ $status ] ) ? $statuses[ $status ] : '';
	}

	/**
	 * Get categories column export value.
	 *
	 * @param array $category_ids Category IDs from report row.
	 * @return string
	 */
	protected function get_categories( $category_ids ) {
		$category_names = get_terms(
			array(
				'taxonomy' => 'product_cat',
				'include'  => $category_ids,
				'fields'   => 'names',
			)
		);

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

	/**
	 * Get the column names for export.
	 *
	 * @return array Key value pair of Column ID => Label.
	 */
	public function get_export_columns() {
		$export_columns = array(
			'product_name' => __( 'Product title', 'woocommerce' ),
			'sku'          => __( 'SKU', 'woocommerce' ),
			'items_sold'   => __( 'Items sold', 'woocommerce' ),
			'net_revenue'  => __( 'N. Revenue', 'woocommerce' ),
			'orders_count' => __( 'Orders', 'woocommerce' ),
			'product_cat'  => __( 'Category', 'woocommerce' ),
			'variations'   => __( 'Variations', 'woocommerce' ),
		);

		if ( 'yes' === get_option( 'woocommerce_manage_stock' ) ) {
			$export_columns['stock_status'] = __( 'Status', 'woocommerce' );
			$export_columns['stock']        = __( 'Stock', 'woocommerce' );
		}

		/**
		 * Filter to add or remove column names from the products report for
		 * export.
		 *
		 * @since 1.6.0
		 */
		return apply_filters(
			'woocommerce_report_products_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(
			'product_name' => $item['extended_info']['name'],
			'sku'          => $item['extended_info']['sku'],
			'items_sold'   => $item['items_sold'],
			'net_revenue'  => $item['net_revenue'],
			'orders_count' => $item['orders_count'],
			'product_cat'  => $this->get_categories( $item['extended_info']['category_ids'] ),
			'variations'   => isset( $item['extended_info']['variations'] ) ? count( $item['extended_info']['variations'] ) : 0,
		);

		if ( 'yes' === get_option( 'woocommerce_manage_stock' ) ) {
			if ( $item['extended_info']['manage_stock'] ) {
				$export_item['stock_status'] = $this->get_stock_status( $item['extended_info']['stock_status'] );
				$export_item['stock']        = $item['extended_info']['stock_quantity'];
			} else {
				$export_item['stock_status'] = __( 'N/A', 'woocommerce' );
				$export_item['stock']        = __( 'N/A', 'woocommerce' );
			}
		}

		/**
		 * Filter to prepare extra columns in the export item for the products
		 * report.
		 *
		 * @since 1.6.0
		 */
		return apply_filters(
			'woocommerce_report_products_prepare_export_item',
			$export_item,
			$item
		);
	}
}
Reports/Products/Stats/Controller.php000064400000033161152333372110013743 0ustar00<?php
/**
 * REST API Reports products stats controller
 *
 * Handles requests to the /reports/products/stats endpoint.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Products\Stats;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\ParameterException;

/**
 * REST API Reports products stats controller class.
 *
 * @internal
 * @extends WC_REST_Reports_Controller
 */
class Controller extends \WC_REST_Reports_Controller {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'reports/products/stats';

	/**
	 * Mapping between external parameter name and name used in query class.
	 *
	 * @var array
	 */
	protected $param_mapping = array(
		'categories' => 'category_includes',
		'products'   => 'product_includes',
		'variations' => 'variation_includes',
	);

	/**
	 * Constructor.
	 */
	public function __construct() {
		add_filter( 'woocommerce_analytics_products_stats_select_query', array( $this, 'set_default_report_data' ) );
	}

	/**
	 * Get all reports.
	 *
	 * @param WP_REST_Request $request Request data.
	 * @return array|WP_Error
	 */
	public function get_items( $request ) {
		$query_args = array(
			'fields' => array(
				'items_sold',
				'net_revenue',
				'orders_count',
				'products_count',
				'variations_count',
			),
		);

		$registered = array_keys( $this->get_collection_params() );
		foreach ( $registered as $param_name ) {
			if ( isset( $request[ $param_name ] ) ) {
				if ( isset( $this->param_mapping[ $param_name ] ) ) {
					$query_args[ $this->param_mapping[ $param_name ] ] = $request[ $param_name ];
				} else {
					$query_args[ $param_name ] = $request[ $param_name ];
				}
			}
		}

		$query = new Query( $query_args );
		try {
			$report_data = $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 );
		}

		$response = rest_ensure_response( $out_data );
		$response->header( 'X-WP-Total', (int) $report_data->total );
		$response->header( 'X-WP-TotalPages', (int) $report_data->pages );

		$page      = $report_data->page_no;
		$max_pages = $report_data->pages;
		$base      = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) );
		if ( $page > 1 ) {
			$prev_page = $page - 1;
			if ( $prev_page > $max_pages ) {
				$prev_page = $max_pages;
			}
			$prev_link = add_query_arg( 'page', $prev_page, $base );
			$response->link_header( 'prev', $prev_link );
		}
		if ( $max_pages > $page ) {
			$next_page = $page + 1;
			$next_link = add_query_arg( 'page', $next_page, $base );
			$response->link_header( 'next', $next_link );
		}

		return $response;
	}

	/**
	 * 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_products_stats', $response, $report, $request );
	}

	/**
	 * Get the Report's schema, conforming to JSON Schema.
	 *
	 * @return array
	 */
	public function get_item_schema() {
		$data_values = array(
			'items_sold'   => array(
				'title'       => __( 'Products sold', 'woocommerce' ),
				'description' => __( 'Number of product items sold.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
				'indicator'   => true,
			),
			'net_revenue'  => array(
				'description' => __( 'Net sales.', 'woocommerce' ),
				'type'        => 'number',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
				'format'      => 'currency',
			),
			'orders_count' => array(
				'description' => __( 'Number of orders.', 'woocommerce' ),
				'type'        => 'integer',
				'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,
						),
						'segment_label' => array(
							'description' => __( 'Human readable segment label, either product or variation name.', 'woocommerce' ),
							'type'        => 'string',
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
							'enum'        => array( 'day', 'week', 'month', 'year' ),
						),
						'subtotals'     => array(
							'description' => __( 'Interval subtotals.', 'woocommerce' ),
							'type'        => 'object',
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
							'properties'  => $data_values,
						),
					),
				),
			),
		);

		$totals = array_merge( $data_values, $segments );

		$schema = array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => 'report_products_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'  => $totals,
							),
						),
					),
				),
			),
		);

		return $this->add_additional_fields_schema( $schema );
	}

	/**
	 * Set the default results to 0 if API returns an empty array
	 *
	 * @internal
	 * @param Mixed $results Report data.
	 * @return object
	 */
	public function set_default_report_data( $results ) {
		if ( empty( $results ) ) {
			$results                       = new \stdClass();
			$results->total                = 0;
			$results->totals               = new \stdClass();
			$results->totals->items_sold   = 0;
			$results->totals->net_revenue  = 0;
			$results->totals->orders_count = 0;
			$results->intervals            = array();
			$results->pages                = 1;
			$results->page_no              = 1;
		}
		return $results;
	}

	/**
	 * 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',
				'coupons',
				'refunds',
				'shipping',
				'taxes',
				'net_revenue',
				'orders_count',
				'items_sold',
			),
			'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['categories'] = array(
			'description'       => __( 'Limit result to items from the specified categories.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'type' => 'integer',
			),
		);
		$params['products']   = array(
			'description'       => __( 'Limit result to items with specified product ids.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'type' => 'integer',
			),
		);
		$params['variations'] = array(
			'description'       => __( 'Limit result to items with specified variation ids.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'type' => 'integer',
			),
		);
		$params['segmentby']  = array(
			'description'       => __( 'Segment the response by additional constraint.', 'woocommerce' ),
			'type'              => 'string',
			'enum'              => array(
				'product',
				'category',
				'variation',
			),
			'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;
	}
}
Reports/Products/Stats/DataStore.php000064400000023377152333372110013516 0ustar00<?php
/**
 * API\Reports\Products\Stats\DataStore class file.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Products\Stats;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\Products\DataStore as ProductsDataStore;
use \Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface;
use \Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
use \Automattic\WooCommerce\Admin\API\Reports\SqlQuery;

/**
 * API\Reports\Products\Stats\DataStore.
 */
class DataStore extends ProductsDataStore implements DataStoreInterface {

	/**
	 * Mapping columns to data type to return correct response types.
	 *
	 * @var array
	 */
	protected $column_types = array(
		'date_start'       => 'strval',
		'date_end'         => 'strval',
		'product_id'       => 'intval',
		'items_sold'       => 'intval',
		'net_revenue'      => 'floatval',
		'orders_count'     => 'intval',
		'products_count'   => 'intval',
		'variations_count' => 'intval',
	);

	/**
	 * Cache identifier.
	 *
	 * @var string
	 */
	protected $cache_key = 'products_stats';

	/**
	 * Data store context used to pass to filters.
	 *
	 * @var string
	 */
	protected $context = 'products_stats';

	/**
	 * Assign report columns once full table name has been assigned.
	 */
	protected function assign_report_columns() {
		$table_name           = self::get_db_table_name();
		$this->report_columns = array(
			'items_sold'       => 'SUM(product_qty) as items_sold',
			'net_revenue'      => 'SUM(product_net_revenue) AS net_revenue',
			'orders_count'     => "COUNT( DISTINCT ( CASE WHEN product_gross_revenue >= 0 THEN {$table_name}.order_id END ) ) as orders_count",
			'products_count'   => 'COUNT(DISTINCT product_id) as products_count',
			'variations_count' => 'COUNT(DISTINCT variation_id) as variations_count',
		);
	}

	/**
	 * Updates the database query with parameters used for Products Stats report: categories and order status.
	 *
	 * @param array $query_args       Query arguments supplied by the user.
	 */
	protected function update_sql_query_params( $query_args ) {
		global $wpdb;

		$products_where_clause      = '';
		$products_from_clause       = '';
		$order_product_lookup_table = self::get_db_table_name();

		$included_products = $this->get_included_products( $query_args );
		if ( $included_products ) {
			$products_where_clause .= " AND {$order_product_lookup_table}.product_id IN ({$included_products})";
		}

		$included_variations = $this->get_included_variations( $query_args );
		if ( $included_variations ) {
			$products_where_clause .= " AND {$order_product_lookup_table}.variation_id IN ({$included_variations})";
		}

		$order_status_filter = $this->get_status_subquery( $query_args );
		if ( $order_status_filter ) {
			$products_from_clause  .= " JOIN {$wpdb->prefix}wc_order_stats ON {$order_product_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id";
			$products_where_clause .= " AND ( {$order_status_filter} )";
		}

		$this->add_time_period_sql_params( $query_args, $order_product_lookup_table );
		$this->total_query->add_sql_clause( 'where', $products_where_clause );
		$this->total_query->add_sql_clause( 'join', $products_from_clause );

		$this->add_intervals_sql_params( $query_args, $order_product_lookup_table );
		$this->interval_query->add_sql_clause( 'where', $products_where_clause );
		$this->interval_query->add_sql_clause( 'join', $products_from_clause );
		$this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) . ' AS time_interval' );
	}

	/**
	 * Returns the report data based on parameters supplied by the user.
	 *
	 * @since 3.5.0
	 * @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 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'           => 'date',
			'before'            => TimeInterval::default_before(),
			'after'             => TimeInterval::default_after(),
			'fields'            => '*',
			'category_includes' => array(),
			'interval'          => 'week',
			'product_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();

			$selections = $this->selected_columns( $query_args );
			$params     = $this->get_limit_params( $query_args );

			$this->update_sql_query_params( $query_args );
			$this->get_limit_sql_params( $query_args );
			$this->interval_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) );

			$db_intervals = $wpdb->get_col(
				$this->interval_query->get_query_statement()
			); // WPCS: 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 array();
			}

			$intervals = array();
			$this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name );
			$this->total_query->add_sql_clause( 'select', $selections );
			$this->total_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) );

			$totals = $wpdb->get_results(
				$this->total_query->get_query_statement(),
				ARRAY_A
			); // WPCS: cache ok, DB call ok, unprepared SQL ok.

			// @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' => $this->total_query->get_sql_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' => $this->interval_query->get_sql_clause( 'where_time' ),
				'where_clause'      => $this->interval_query->get_sql_clause( 'where' ),
				'order_by'          => $this->get_sql_clause( 'order_by' ),
				'limit'             => $this->get_sql_clause( 'limit' ),
			);
			$segmenter             = new Segmenter( $query_args, $this->report_columns );
			$totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name );

			if ( null === $totals ) {
				return new \WP_Error( 'woocommerce_analytics_products_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) );
			}

			$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
			); // WPCS: cache ok, DB call ok, unprepared SQL ok.

			if ( null === $intervals ) {
				return new \WP_Error( 'woocommerce_analytics_products_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) );
			}

			$totals = (object) $this->cast_numbers( $totals[0] );

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

	/**
	 * 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 'time_interval';
		}

		return $order_by;
	}

	/**
	 * 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' );
	}
}
Reports/Products/Stats/Segmenter.php000064400000024314152333372110013551 0ustar00<?php
/**
 * Class for adding segmenting support without cluttering the data stores.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Products\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(
			'items_sold'       => "SUM($products_table.product_qty) as items_sold",
			'net_revenue'      => "SUM($products_table.product_net_revenue ) AS net_revenue",
			'orders_count'     => "COUNT( DISTINCT $products_table.order_id ) AS orders_count",
			'products_count'   => "COUNT( DISTINCT $products_table.product_id ) AS products_count",
			'variations_count' => "COUNT( DISTINCT $products_table.variation_id ) AS variations_count",
		);

		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
		); // WPCS: cache ok, DB call ok, unprepared SQL ok.

		$totals_segments = $this->merge_segment_totals_results( $segmenting_dimension_name, $segments_products, array() );
		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 a multiple of the number of segments.
		preg_match( '/LIMIT (\d+)\s?,\s?(\d+)/', $intervals_query['limit'], $limit_parts );
		$segment_count    = count( $this->get_all_segments() );
		$orig_offset      = intval( $limit_parts[1] );
		$orig_rowcount    = intval( $limit_parts[2] );
		$segmenting_limit = $wpdb->prepare( 'LIMIT %d, %d', $orig_offset * $segment_count, $orig_rowcount * $segment_count );

		// 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
		); // WPCS: cache ok, DB call ok, unprepared SQL ok.

		$intervals_segments = $this->merge_segment_intervals_results( $segmenting_dimension_name, $segments_products, array() );
		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_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'] ) {
			$product_level_columns     = $this->get_segment_selections_product_level( $product_segmenting_table );
			$segmenting_selections     = array(
				'product_level' => $this->prepare_selections( $product_level_columns ),
			);
			$this->report_columns      = $product_level_columns;
			$segmenting_from           = '';
			$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 );
			$segmenting_selections     = array(
				'product_level' => $this->prepare_selections( $product_level_columns ),
			);
			$this->report_columns      = $product_level_columns;
			$segmenting_from           = '';
			$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 );
			$segmenting_selections     = array(
				'product_level' => $this->prepare_selections( $product_level_columns ),
			);
			$this->report_columns      = $product_level_columns;
			$segmenting_from           = "
			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';

			// Restrict our search space for category comparisons.
			if ( isset( $this->query_args['category_includes'] ) ) {
				$category_ids      = implode( ',', $this->get_all_segments() );
				$segmenting_where .= " AND {$wpdb->wc_category_lookup}.category_id IN ( $category_ids )";
			}

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

		return $segments;
	}
}
Reports/Products/Stats/Query.php000064400000002426152333372110012725 0ustar00<?php
/**
 * Class for parameter-based Products Stats Report querying
 *
 * Example usage:
 * $args = array(
 *          'before'       => '2018-07-19 00:00:00',
 *          'after'        => '2018-07-05 00:00:00',
 *          'page'         => 2,
 *          'categories'   => array(15, 18),
 *          'product_ids'  => array(1,2,3)
 *         );
 * $report = new \Automattic\WooCommerce\Admin\API\Reports\Products\Stats\Query( $args );
 * $mydata = $report->get_data();
 */

namespace Automattic\WooCommerce\Admin\API\Reports\Products\Stats;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery;

/**
 * API\Reports\Products\Stats\Query
 */
class Query extends ReportsQuery {

	/**
	 * Valid fields for Products report.
	 *
	 * @return array
	 */
	protected function get_default_query_vars() {
		return array();
	}

	/**
	 * Get product data based on the current query vars.
	 *
	 * @return array
	 */
	public function get_data() {
		$args = apply_filters( 'woocommerce_analytics_products_stats_query_args', $this->get_query_vars() );

		$data_store = \WC_Data_Store::load( 'report-products-stats' );
		$results    = $data_store->get_data( $args );
		return apply_filters( 'woocommerce_analytics_products_stats_select_query', $results, $args );
	}

}
Reports/Query.php000064400000001121152333372110010013 0ustar00<?php
/**
 * Class for parameter-based Reports querying
 */

namespace Automattic\WooCommerce\Admin\API\Reports;

defined( 'ABSPATH' ) || exit;

/**
 * Admin\API\Reports\Query
 */
abstract class Query extends \WC_Object_Query {

	/**
	 * Get report data matching the current query vars.
	 *
	 * @return array|object of WC_Product objects
	 */
	public function get_data() {
		/* translators: %s: Method name */
		return new \WP_Error( 'invalid-method', sprintf( __( "Method '%s' not implemented. Must be overridden in subclass.", 'woocommerce' ), __METHOD__ ), array( 'status' => 405 ) );
	}
}
Reports/PerformanceIndicators/Controller.php000064400000046445152333372110015334 0ustar00<?php
/**
 * REST API Performance indicators controller
 *
 * Handles requests to the /reports/store-performance endpoint.
 */

namespace Automattic\WooCommerce\Admin\API\Reports\PerformanceIndicators;

use \Automattic\WooCommerce\Admin\API\Reports\TimeInterval;

defined( 'ABSPATH' ) || exit;

/**
 * REST API Reports Performance indicators controller class.
 *
 * @internal
 * @extends WC_REST_Reports_Controller
 */
class Controller extends \WC_REST_Reports_Controller {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'reports/performance-indicators';

	/**
	 * Contains a list of endpoints by report slug.
	 *
	 * @var array
	 */
	protected $endpoints = array();

	/**
	 * Contains a list of active Jetpack module slugs.
	 *
	 * @var array
	 */
	protected $active_jetpack_modules = null;

	/**
	 * Contains a list of allowed stats.
	 *
	 * @var array
	 */
	protected $allowed_stats = array();

	/**
	 * Contains a list of stat labels.
	 *
	 * @var array
	 */
	protected $labels = array();

	/**
	 * Contains a list of endpoints by url.
	 *
	 * @var array
	 */
	protected $urls = array();

	/**
	 * Contains a cache of retrieved stats data, grouped by report slug.
	 *
	 * @var array
	 */
	protected $stats_data = array();

	/**
	 * Constructor.
	 */
	public function __construct() {
		add_filter( 'woocommerce_rest_performance_indicators_data_value', array( $this, 'format_data_value' ), 10, 5 );
	}

	/**
	 * Register the routes for reports.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_items' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
					'args'                => $this->get_collection_params(),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/allowed',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_allowed_items' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
					'args'                => $this->get_collection_params(),
				),
				'schema' => array( $this, 'get_public_allowed_item_schema' ),
			)
		);
	}

	/**
	 * 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['stats']  = $request['stats'];
		return $args;
	}

	/**
	 * Get analytics report data and endpoints.
	 */
	private function get_analytics_report_data() {
		$request  = new \WP_REST_Request( 'GET', '/wc-analytics/reports' );
		$response = rest_do_request( $request );

		if ( is_wp_error( $response ) ) {
			return $response;
		}

		if ( 200 !== $response->get_status() ) {
			return new \WP_Error( 'woocommerce_analytics_performance_indicators_result_failed', __( 'Sorry, fetching performance indicators failed.', 'woocommerce' ) );
		}

		$endpoints = $response->get_data();

		foreach ( $endpoints as $endpoint ) {
			if ( '/stats' === substr( $endpoint['slug'], -6 ) ) {
				$request  = new \WP_REST_Request( 'OPTIONS', $endpoint['path'] );
				$response = rest_do_request( $request );

				if ( is_wp_error( $response ) ) {
					return $response;
				}

				$data = $response->get_data();

				$prefix = substr( $endpoint['slug'], 0, -6 );

				if ( empty( $data['schema']['properties']['totals']['properties'] ) ) {
					continue;
				}

				foreach ( $data['schema']['properties']['totals']['properties'] as $property_key => $schema_info ) {
					if ( empty( $schema_info['indicator'] ) || ! $schema_info['indicator'] ) {
						continue;
					}

					$stat                   = $prefix . '/' . $property_key;
					$this->allowed_stats[]  = $stat;
					$stat_label             = empty( $schema_info['title'] ) ? $schema_info['description'] : $schema_info['title'];
					$this->labels[ $stat ]  = trim( $stat_label, '.' );
					$this->formats[ $stat ] = isset( $schema_info['format'] ) ? $schema_info['format'] : 'number';
				}

				$this->endpoints[ $prefix ] = $endpoint['path'];
				$this->urls[ $prefix ]      = $endpoint['_links']['report'][0]['href'];
			}
		}
	}

	/**
	 * Get active Jetpack modules.
	 *
	 * @return array List of active Jetpack module slugs.
	 */
	private function get_active_jetpack_modules() {
		if ( is_null( $this->active_jetpack_modules ) ) {
			if ( class_exists( '\Jetpack' ) && method_exists( '\Jetpack', 'get_active_modules' ) ) {
				$active_modules               = \Jetpack::get_active_modules();
				$this->active_jetpack_modules = is_array( $active_modules ) ? $active_modules : array();
			} else {
				$this->active_jetpack_modules = array();
			}
		}

		return $this->active_jetpack_modules;
	}

	/**
	 * Set active Jetpack modules.
	 *
	 * @internal
	 * @param array $modules List of active Jetpack module slugs.
	 */
	public function set_active_jetpack_modules( $modules ) {
		$this->active_jetpack_modules = $modules;
	}

	/**
	 * Get active Jetpack modules and endpoints.
	 */
	private function get_jetpack_modules_data() {
		$active_modules = $this->get_active_jetpack_modules();

		if ( empty( $active_modules ) ) {
			return;
		}

		$items = apply_filters(
			'woocommerce_rest_performance_indicators_jetpack_items',
			array(
				'stats/visitors' => array(
					'label'      => __( 'Visitors', 'woocommerce' ),
					'permission' => 'view_stats',
					'format'     => 'number',
					'module'     => 'stats',
				),
				'stats/views'    => array(
					'label'      => __( 'Views', 'woocommerce' ),
					'permission' => 'view_stats',
					'format'     => 'number',
					'module'     => 'stats',
				),
			)
		);

		foreach ( $items as $item_key => $item ) {
			if ( ! in_array( $item['module'], $active_modules, true ) ) {
				return;
			}

			if ( $item['permission'] && ! current_user_can( $item['permission'] ) ) {
				return;
			}

			$stat                         = 'jetpack/' . $item_key;
			$endpoint                     = 'jetpack/' . $item['module'];
			$this->allowed_stats[]        = $stat;
			$this->labels[ $stat ]        = $item['label'];
			$this->endpoints[ $endpoint ] = '/jetpack/v4/module/' . $item['module'] . '/data';
			$this->formats[ $stat ]       = $item['format'];
		}

		$this->urls['jetpack/stats'] = '/jetpack';
	}

	/**
	 * Get information such as allowed stats, stat labels, and endpoint data from stats reports.
	 *
	 * @return WP_Error|True
	 */
	private function get_indicator_data() {
		// Data already retrieved.
		if ( ! empty( $this->endpoints ) && ! empty( $this->labels ) && ! empty( $this->allowed_stats ) ) {
			return true;
		}

		$this->get_analytics_report_data();
		$this->get_jetpack_modules_data();

		return true;
	}

	/**
	 * Returns a list of allowed performance indicators.
	 *
	 * @param  WP_REST_Request $request Request data.
	 * @return array|WP_Error
	 */
	public function get_allowed_items( $request ) {
		$indicator_data = $this->get_indicator_data();
		if ( is_wp_error( $indicator_data ) ) {
			return $indicator_data;
		}

		$data = array();
		foreach ( $this->allowed_stats as $stat ) {
			$pieces = $this->get_stats_parts( $stat );
			$report = $pieces[0];
			$chart  = $pieces[1];
			$data[] = (object) array(
				'stat'  => $stat,
				'chart' => $chart,
				'label' => $this->labels[ $stat ],
			);
		}

		usort( $data, array( $this, 'sort' ) );

		$objects = array();
		foreach ( $data as $item ) {
			$prepared  = $this->prepare_item_for_response( $item, $request );
			$objects[] = $this->prepare_response_for_collection( $prepared );
		}

		$response = rest_ensure_response( $objects );
		$response->header( 'X-WP-Total', count( $data ) );
		$response->header( 'X-WP-TotalPages', 1 );

		$base = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) );

		return $response;
	}

	/**
	 * Sorts the list of stats. Sorted by custom arrangement.
	 *
	 * @internal
	 * @see https://github.com/woocommerce/woocommerce-admin/issues/1282
	 * @param object $a First item.
	 * @param object $b Second item.
	 * @return order
	 */
	public function sort( $a, $b ) {
		/**
		 * Custom ordering for store performance indicators.
		 *
		 * @see https://github.com/woocommerce/woocommerce-admin/issues/1282
		 * @param array $indicators A list of ordered indicators.
		 */
		$stat_order = apply_filters(
			'woocommerce_rest_report_sort_performance_indicators',
			array(
				'revenue/total_sales',
				'revenue/net_revenue',
				'orders/orders_count',
				'orders/avg_order_value',
				'products/items_sold',
				'revenue/refunds',
				'coupons/orders_count',
				'coupons/amount',
				'taxes/total_tax',
				'taxes/order_tax',
				'taxes/shipping_tax',
				'revenue/shipping',
				'downloads/download_count',
			)
		);

		$a = array_search( $a->stat, $stat_order, true );
		$b = array_search( $b->stat, $stat_order, true );

		if ( false === $a && false === $b ) {
			return 0;
		} elseif ( false === $a ) {
			return 1;
		} elseif ( false === $b ) {
			return -1;
		} else {
			return $a - $b;
		}
	}

	/**
	 * Get report stats data, avoiding duplicate requests for stats that use the same endpoint.
	 *
	 * @param string $report Report slug to request data for.
	 * @param array  $query_args Report query args.
	 * @return WP_REST_Response|WP_Error Report stats data.
	 */
	private function get_stats_data( $report, $query_args ) {
		// Return from cache if we've already requested these report stats.
		if ( isset( $this->stats_data[ $report ] ) ) {
			return $this->stats_data[ $report ];
		}

		// Request the report stats.
		$request_url = $this->endpoints[ $report ];
		$request     = new \WP_REST_Request( 'GET', $request_url );
		$request->set_param( 'before', $query_args['before'] );
		$request->set_param( 'after', $query_args['after'] );

		$response = rest_do_request( $request );

		// Cache the response.
		$this->stats_data[ $report ] = $response;

		return $response;
	}

	/**
	 * Get all reports.
	 *
	 * @param  WP_REST_Request $request Request data.
	 * @return array|WP_Error
	 */
	public function get_items( $request ) {
		$indicator_data = $this->get_indicator_data();
		if ( is_wp_error( $indicator_data ) ) {
			return $indicator_data;
		}

		$query_args = $this->prepare_reports_query( $request );
		if ( empty( $query_args['stats'] ) ) {
			return new \WP_Error( 'woocommerce_analytics_performance_indicators_empty_query', __( 'A list of stats to query must be provided.', 'woocommerce' ), 400 );
		}

		$stats = array();
		foreach ( $query_args['stats'] as $stat ) {
			$is_error = false;

			$pieces = $this->get_stats_parts( $stat );
			$report = $pieces[0];
			$chart  = $pieces[1];

			if ( ! in_array( $stat, $this->allowed_stats, true ) ) {
				continue;
			}

			$response = $this->get_stats_data( $report, $query_args );

			if ( is_wp_error( $response ) ) {
				return $response;
			}

			$data   = $response->get_data();
			$format = $this->formats[ $stat ];
			$label  = $this->labels[ $stat ];

			if ( 200 !== $response->get_status() ) {
				$stats[] = (object) array(
					'stat'   => $stat,
					'chart'  => $chart,
					'label'  => $label,
					'format' => $format,
					'value'  => null,
				);
				continue;
			}

			$stats[] = (object) array(
				'stat'   => $stat,
				'chart'  => $chart,
				'label'  => $label,
				'format' => $format,
				'value'  => apply_filters( 'woocommerce_rest_performance_indicators_data_value', $data, $stat, $report, $chart, $query_args ),
			);
		}

		usort( $stats, array( $this, 'sort' ) );

		$objects = array();
		foreach ( $stats as $stat ) {
			$data      = $this->prepare_item_for_response( $stat, $request );
			$objects[] = $this->prepare_response_for_collection( $data );
		}

		$response = rest_ensure_response( $objects );
		$response->header( 'X-WP-Total', count( $stats ) );
		$response->header( 'X-WP-TotalPages', 1 );

		$base = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) );

		return $response;
	}

	/**
	 * Prepare a report object for serialization.
	 *
	 * @param stdClass        $stat_data    Report data.
	 * @param WP_REST_Request $request Request object.
	 * @return WP_REST_Response
	 */
	public function prepare_item_for_response( $stat_data, $request ) {
		$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
		$data    = $this->add_additional_fields_to_object( $stat_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( $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_performance_indicators', $response, $stat_data, $request );
	}

	/**
	 * Prepare links for the request.
	 *
	 * @param \Automattic\WooCommerce\Admin\API\Reports\Query $object Object data.
	 * @return array
	 */
	protected function prepare_links( $object ) {
		$pieces   = $this->get_stats_parts( $object->stat );
		$endpoint = $pieces[0];
		$stat     = $pieces[1];
		$url      = isset( $this->urls[ $endpoint ] ) ? $this->urls[ $endpoint ] : '';

		$links = array(
			'api'    => array(
				'href' => rest_url( $this->endpoints[ $endpoint ] ),
			),
			'report' => array(
				'href' => $url,
			),
		);

		return $links;
	}

	/**
	 * Returns the endpoint part of a stat request (prefix) and the actual stat total we want.
	 * To allow extensions to namespace (example: fue/emails/sent), we break on the last forward slash.
	 *
	 * @param string $full_stat A stat request string like orders/avg_order_value or fue/emails/sent.
	 * @return array Containing the prefix (endpoint) and suffix (stat).
	 */
	private function get_stats_parts( $full_stat ) {
		$endpoint = substr( $full_stat, 0, strrpos( $full_stat, '/' ) );
		$stat     = substr( $full_stat, ( strrpos( $full_stat, '/' ) + 1 ) );
		return array(
			$endpoint,
			$stat,
		);
	}

	/**
	 * Format the data returned from the API for given stats.
	 *
	 * @param array  $data Data from external endpoint.
	 * @param string $stat Name of the stat.
	 * @param string $report Name of the report.
	 * @param string $chart Name of the chart.
	 * @param array  $query_args Query args.
	 * @return mixed
	 */
	public function format_data_value( $data, $stat, $report, $chart, $query_args ) {
		if ( 'jetpack/stats' === $report ) {
			// Get the index of the field to tally.
			$index = array_search( $chart, $data['general']->visits->fields, true );
			if ( ! $index ) {
				return null;
			}

			// Loop over provided data and filter by the queried date.
			// Note that this is currently limited to 30 days via the Jetpack API
			// but the WordPress.com endpoint allows up to 90 days.
			$total  = 0;
			$before = gmdate( 'Y-m-d', strtotime( isset( $query_args['before'] ) ? $query_args['before'] : TimeInterval::default_before() ) );
			$after  = gmdate( 'Y-m-d', strtotime( isset( $query_args['after'] ) ? $query_args['after'] : TimeInterval::default_after() ) );
			foreach ( $data['general']->visits->data as $datum ) {
				if ( $datum[0] >= $after && $datum[0] <= $before ) {
					$total += $datum[ $index ];
				}
			}
			return $total;
		}

		if ( isset( $data['totals'] ) && isset( $data['totals'][ $chart ] ) ) {
			return $data['totals'][ $chart ];
		}

		return null;
	}

	/**
	 * Get the Report's schema, conforming to JSON Schema.
	 *
	 * @return array
	 */
	public function get_item_schema() {
		$indicator_data = $this->get_indicator_data();
		if ( is_wp_error( $indicator_data ) ) {
			$allowed_stats = array();
		} else {
			$allowed_stats = $this->allowed_stats;
		}

		$schema = array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => 'report_performance_indicator',
			'type'       => 'object',
			'properties' => array(
				'stat'   => array(
					'description' => __( 'Unique identifier for the resource.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
					'enum'        => $allowed_stats,
				),
				'chart'  => array(
					'description' => __( 'The specific chart this stat referrers to.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'label'  => array(
					'description' => __( 'Human readable label for the stat.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'format' => array(
					'description' => __( 'Format of the stat.', 'woocommerce' ),
					'type'        => 'number',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
					'enum'        => array( 'number', 'currency' ),
				),
				'value'  => array(
					'description' => __( 'Value of the stat. Returns null if the stat does not exist or cannot be loaded.', 'woocommerce' ),
					'type'        => 'number',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
			),
		);

		return $this->add_additional_fields_schema( $schema );
	}

	/**
	 * Get schema for the list of allowed performance indicators.
	 *
	 * @return array $schema
	 */
	public function get_public_allowed_item_schema() {
		$schema = $this->get_public_item_schema();
		unset( $schema['properties']['value'] );
		unset( $schema['properties']['format'] );
		return $schema;
	}

	/**
	 * Get the query params for collections.
	 *
	 * @return array
	 */
	public function get_collection_params() {
		$indicator_data = $this->get_indicator_data();
		if ( is_wp_error( $indicator_data ) ) {
			$allowed_stats = __( 'There was an issue loading the report endpoints', 'woocommerce' );
		} else {
			$allowed_stats = implode( ', ', $this->allowed_stats );
		}

		$params            = array();
		$params['context'] = $this->get_context_param( array( 'default' => 'view' ) );
		$params['stats']   = array(
			'description'       => sprintf(
				/* translators: Allowed values is a list of stat endpoints. */
				__( 'Limit response to specific report stats. Allowed values: %s.', 'woocommerce' ),
				$allowed_stats
			),
			'type'              => 'array',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'type' => 'string',
				'enum' => $this->allowed_stats,
			),
			'default'           => $this->allowed_stats,
		);
		$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',
		);
		return $params;
	}
}
Themes.php000064400000014141152333372110006503 0ustar00<?php
/**
 * REST API Themes Controller
 *
 * Handles requests to /themes
 */

namespace Automattic\WooCommerce\Admin\API;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Overrides\ThemeUpgrader;
use Automattic\WooCommerce\Admin\Overrides\ThemeUpgraderSkin;

/**
 * Themes controller.
 *
 * @internal
 * @extends WC_REST_Data_Controller
 */
class Themes extends \WC_REST_Data_Controller {
	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-admin';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'themes';

	/**
	 * Register routes.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'upload_theme' ),
					'permission_callback' => array( $this, 'upload_theme_permissions_check' ),
					'args'                => $this->get_collection_params(),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);
	}

	/**
	 * Check whether a given request has permission to edit upload plugins/themes.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|boolean
	 */
	public function upload_theme_permissions_check( $request ) {
		if ( ! current_user_can( 'upload_themes' ) ) {
			return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you are not allowed to install themes on this site.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
		}

		return true;
	}

	/**
	 * Upload and install a theme.
	 *
	 * @param  WP_REST_Request $request Request data.
	 * @return WP_Error|WP_REST_Response
	 */
	public function upload_theme( $request ) {
		if (
			! isset( $_FILES['pluginzip'] ) || ! isset( $_FILES['pluginzip']['tmp_name'] ) || ! is_uploaded_file( $_FILES['pluginzip']['tmp_name'] ) || ! is_file( $_FILES['pluginzip']['tmp_name'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,  WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
			return new \WP_Error( 'woocommerce_rest_invalid_file', __( 'Specified file failed upload test.', 'woocommerce' ) );
		}

		include_once ABSPATH . 'wp-admin/includes/file.php';
		include_once ABSPATH . '/wp-admin/includes/admin.php';
		include_once ABSPATH . '/wp-admin/includes/theme-install.php';
		include_once ABSPATH . '/wp-admin/includes/theme.php';
		include_once ABSPATH . '/wp-admin/includes/class-wp-upgrader.php';
		include_once ABSPATH . '/wp-admin/includes/class-theme-upgrader.php';

		$_GET['package'] = true;
		$file_upload     = new \File_Upload_Upgrader( 'pluginzip', 'package' );
		$upgrader        = new ThemeUpgrader( new ThemeUpgraderSkin() );
		$install         = $upgrader->install( $file_upload->package );

		if ( $install || is_wp_error( $install ) ) {
			$file_upload->cleanup();
		}

		if ( ! is_wp_error( $install ) && isset( $install['destination_name'] ) ) {
			$theme  = $install['destination_name'];
			$result = array(
				'status'  => 'success',
				'message' => $upgrader->strings['process_success'],
				'theme'   => $theme,
			);

			/**
			 * Fires when a theme is successfully installed.
			 *
			 * @param string $theme The theme name.
			 */
			do_action( 'woocommerce_theme_installed', $theme );
		} else {
			if ( is_wp_error( $install ) && $install->get_error_code() ) {
				$error_message = isset( $upgrader->strings[ $install->get_error_code() ] ) ? $upgrader->strings[ $install->get_error_code() ] : $install->get_error_data();
			} else {
				$error_message = $upgrader->strings['process_failed'];
			}

			$result = array(
				'status'  => 'error',
				'message' => $error_message,
			);
		}

		$response = $this->prepare_item_for_response( $result, $request );
		$data     = $this->prepare_response_for_collection( $response );

		return rest_ensure_response( $data );
	}


	/**
	 * Prepare the data object for response.
	 *
	 * @param object          $item Data object.
	 * @param WP_REST_Request $request Request object.
	 * @return WP_REST_Response $response Response data.
	 */
	public function prepare_item_for_response( $item, $request ) {
		$data     = $this->add_additional_fields_to_object( $item, $request );
		$data     = $this->filter_response_by_context( $data, 'view' );
		$response = rest_ensure_response( $data );

		/**
		 * Filter the list returned from the API.
		 *
		 * @param WP_REST_Response $response The response object.
		 * @param array            $item     The original item.
		 * @param WP_REST_Request  $request  Request used to generate the response.
		 */
		return apply_filters( 'woocommerce_rest_prepare_themes', $response, $item, $request );
	}


	/**
	 * Get the schema, conforming to JSON Schema.
	 *
	 * @return array
	 */
	public function get_item_schema() {
		$schema = array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => 'upload_theme',
			'type'       => 'object',
			'properties' => array(
				'status'  => array(
					'description' => __( 'Theme installation status.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'message' => array(
					'description' => __( 'Theme installation message.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'theme'   => array(
					'description' => __( 'Uploaded theme.', 'woocommerce' ),
					'type'        => 'object',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
			),
		);

		return $this->add_additional_fields_schema( $schema );
	}

	/**
	 * Get the query params for collections.
	 *
	 * @return array
	 */
	public function get_collection_params() {
		$params['context']   = $this->get_context_param( array( 'default' => 'view' ) );
		$params['pluginzip'] = array(
			'description'       => __( 'A zip file of the theme to be uploaded.', 'woocommerce' ),
			'type'              => 'file',
			'validate_callback' => 'rest_validate_request_arg',
		);

		return apply_filters( 'woocommerce_rest_themes_collection_params', $params );
	}
}
Leaderboards.php000064400000043323152333372110007651 0ustar00<?php
/**
 * REST API Leaderboards Controller
 *
 * Handles requests to /leaderboards
 */

namespace Automattic\WooCommerce\Admin\API;

defined( 'ABSPATH' ) || exit;

use \Automattic\WooCommerce\Admin\API\Reports\Categories\DataStore as CategoriesDataStore;
use \Automattic\WooCommerce\Admin\API\Reports\Coupons\DataStore as CouponsDataStore;
use \Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore as CustomersDataStore;
use \Automattic\WooCommerce\Admin\API\Reports\Products\DataStore as ProductsDataStore;

/**
 * Leaderboards controller.
 *
 * @internal
 * @extends WC_REST_Data_Controller
 */
class Leaderboards extends \WC_REST_Data_Controller {
	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'leaderboards';

	/**
	 * Register routes.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_items' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
					'args'                => $this->get_collection_params(),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/allowed',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_allowed_items' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
				),
				'schema' => array( $this, 'get_public_allowed_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<leaderboard>\w+)',
			array(
				'args' => array(
					'leaderboard' => array(
						'type' => 'string',
						'enum' => array( 'customers', 'coupons', 'categories', 'products' ),
					),
				),
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_items' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
					'args'                => $this->get_collection_params(),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);
	}

	/**
	 * Get the data for the coupons leaderboard.
	 *
	 * @param int    $per_page Number of rows.
	 * @param string $after Items after date.
	 * @param string $before Items before date.
	 * @param string $persisted_query URL query string.
	 */
	protected function get_coupons_leaderboard( $per_page, $after, $before, $persisted_query ) {
		$coupons_data_store = new CouponsDataStore();
		$coupons_data       = $per_page > 0 ? $coupons_data_store->get_data(
			apply_filters(
				'woocommerce_analytics_coupons_query_args',
				array(
					'orderby'       => 'orders_count',
					'order'         => 'desc',
					'after'         => $after,
					'before'        => $before,
					'per_page'      => $per_page,
					'extended_info' => true,
				)
			)
		)->data : array();

		$rows = array();
		foreach ( $coupons_data as $coupon ) {
			$url_query   = wp_parse_args(
				array(
					'filter'  => 'single_coupon',
					'coupons' => $coupon['coupon_id'],
				),
				$persisted_query
			);
			$coupon_url  = wc_admin_url( '/analytics/coupons', $url_query );
			$coupon_code = isset( $coupon['extended_info'] ) && isset( $coupon['extended_info']['code'] ) ? $coupon['extended_info']['code'] : '';
			$rows[]      = array(
				array(
					'display' => "<a href='{$coupon_url}'>{$coupon_code}</a>",
					'value'   => $coupon_code,
				),
				array(
					'display' => wc_admin_number_format( $coupon['orders_count'] ),
					'value'   => $coupon['orders_count'],
				),
				array(
					'display' => wc_price( $coupon['amount'] ),
					'value'   => $coupon['amount'],
				),
			);
		}

		return array(
			'id'      => 'coupons',
			'label'   => __( 'Top Coupons - Number of Orders', 'woocommerce' ),
			'headers' => array(
				array(
					'label' => __( 'Coupon code', 'woocommerce' ),
				),
				array(
					'label' => __( 'Orders', 'woocommerce' ),
				),
				array(
					'label' => __( 'Amount discounted', 'woocommerce' ),
				),
			),
			'rows'    => $rows,
		);
	}

	/**
	 * Get the data for the categories leaderboard.
	 *
	 * @param int    $per_page Number of rows.
	 * @param string $after Items after date.
	 * @param string $before Items before date.
	 * @param string $persisted_query URL query string.
	 */
	protected function get_categories_leaderboard( $per_page, $after, $before, $persisted_query ) {
		$categories_data_store = new CategoriesDataStore();
		$categories_data       = $per_page > 0 ? $categories_data_store->get_data(
			apply_filters(
				'woocommerce_analytics_categories_query_args',
				array(
					'orderby'       => 'items_sold',
					'order'         => 'desc',
					'after'         => $after,
					'before'        => $before,
					'per_page'      => $per_page,
					'extended_info' => true,
				)
			)
		)->data : array();

		$rows = array();
		foreach ( $categories_data as $category ) {
			$url_query     = wp_parse_args(
				array(
					'filter'     => 'single_category',
					'categories' => $category['category_id'],
				),
				$persisted_query
			);
			$category_url  = wc_admin_url( '/analytics/categories', $url_query );
			$category_name = isset( $category['extended_info'] ) && isset( $category['extended_info']['name'] ) ? $category['extended_info']['name'] : '';
			$rows[]        = array(
				array(
					'display' => "<a href='{$category_url}'>{$category_name}</a>",
					'value'   => $category_name,
				),
				array(
					'display' => wc_admin_number_format( $category['items_sold'] ),
					'value'   => $category['items_sold'],
				),
				array(
					'display' => wc_price( $category['net_revenue'] ),
					'value'   => $category['net_revenue'],
				),
			);
		}

		return array(
			'id'      => 'categories',
			'label'   => __( 'Top categories - Items sold', 'woocommerce' ),
			'headers' => array(
				array(
					'label' => __( 'Category', 'woocommerce' ),
				),
				array(
					'label' => __( 'Items sold', 'woocommerce' ),
				),
				array(
					'label' => __( 'Net sales', 'woocommerce' ),
				),
			),
			'rows'    => $rows,
		);
	}

	/**
	 * Get the data for the customers leaderboard.
	 *
	 * @param int    $per_page Number of rows.
	 * @param string $after Items after date.
	 * @param string $before Items before date.
	 * @param string $persisted_query URL query string.
	 */
	protected function get_customers_leaderboard( $per_page, $after, $before, $persisted_query ) {
		$customers_data_store = new CustomersDataStore();
		$customers_data       = $per_page > 0 ? $customers_data_store->get_data(
			apply_filters(
				'woocommerce_analytics_customers_query_args',
				array(
					'orderby'      => 'total_spend',
					'order'        => 'desc',
					'order_after'  => $after,
					'order_before' => $before,
					'per_page'     => $per_page,
				)
			)
		)->data : array();

		$rows = array();
		foreach ( $customers_data as $customer ) {
			$url_query    = wp_parse_args(
				array(
					'filter'    => 'single_customer',
					'customers' => $customer['id'],
				),
				$persisted_query
			);
			$customer_url = wc_admin_url( '/analytics/customers', $url_query );
			$rows[]       = array(
				array(
					'display' => "<a href='{$customer_url}'>{$customer['name']}</a>",
					'value'   => $customer['name'],
				),
				array(
					'display' => wc_admin_number_format( $customer['orders_count'] ),
					'value'   => $customer['orders_count'],
				),
				array(
					'display' => wc_price( $customer['total_spend'] ),
					'value'   => $customer['total_spend'],
				),
			);
		}

		return array(
			'id'      => 'customers',
			'label'   => __( 'Top Customers - Total Spend', 'woocommerce' ),
			'headers' => array(
				array(
					'label' => __( 'Customer Name', 'woocommerce' ),
				),
				array(
					'label' => __( 'Orders', 'woocommerce' ),
				),
				array(
					'label' => __( 'Total Spend', 'woocommerce' ),
				),
			),
			'rows'    => $rows,
		);
	}

	/**
	 * Get the data for the products leaderboard.
	 *
	 * @param int    $per_page Number of rows.
	 * @param string $after Items after date.
	 * @param string $before Items before date.
	 * @param string $persisted_query URL query string.
	 */
	protected function get_products_leaderboard( $per_page, $after, $before, $persisted_query ) {
		$products_data_store = new ProductsDataStore();
		$products_data       = $per_page > 0 ? $products_data_store->get_data(
			apply_filters(
				'woocommerce_analytics_products_query_args',
				array(
					'orderby'       => 'items_sold',
					'order'         => 'desc',
					'after'         => $after,
					'before'        => $before,
					'per_page'      => $per_page,
					'extended_info' => true,
				)
			)
		)->data : array();

		$rows = array();
		foreach ( $products_data as $product ) {
			$url_query    = wp_parse_args(
				array(
					'filter'   => 'single_product',
					'products' => $product['product_id'],
				),
				$persisted_query
			);
			$product_url  = wc_admin_url( '/analytics/products', $url_query );
			$product_name = isset( $product['extended_info'] ) && isset( $product['extended_info']['name'] ) ? $product['extended_info']['name'] : '';
			$rows[]       = array(
				array(
					'display' => "<a href='{$product_url}'>{$product_name}</a>",
					'value'   => $product_name,
				),
				array(
					'display' => wc_admin_number_format( $product['items_sold'] ),
					'value'   => $product['items_sold'],
				),
				array(
					'display' => wc_price( $product['net_revenue'] ),
					'value'   => $product['net_revenue'],
				),
			);
		}

		return array(
			'id'      => 'products',
			'label'   => __( 'Top products - Items sold', 'woocommerce' ),
			'headers' => array(
				array(
					'label' => __( 'Product', 'woocommerce' ),
				),
				array(
					'label' => __( 'Items sold', 'woocommerce' ),
				),
				array(
					'label' => __( 'Net sales', 'woocommerce' ),
				),
			),
			'rows'    => $rows,
		);
	}

	/**
	 * Get an array of all leaderboards.
	 *
	 * @param int    $per_page Number of rows.
	 * @param string $after Items after date.
	 * @param string $before Items before date.
	 * @param string $persisted_query URL query string.
	 * @return array
	 */
	public function get_leaderboards( $per_page, $after, $before, $persisted_query ) {
		$leaderboards = array(
			$this->get_customers_leaderboard( $per_page, $after, $before, $persisted_query ),
			$this->get_coupons_leaderboard( $per_page, $after, $before, $persisted_query ),
			$this->get_categories_leaderboard( $per_page, $after, $before, $persisted_query ),
			$this->get_products_leaderboard( $per_page, $after, $before, $persisted_query ),
		);

		return apply_filters( 'woocommerce_leaderboards', $leaderboards, $per_page, $after, $before, $persisted_query );
	}

	/**
	 * Return all leaderboards.
	 *
	 * @param  WP_REST_Request $request Request data.
	 * @return WP_Error|WP_REST_Response
	 */
	public function get_items( $request ) {
		$persisted_query = json_decode( $request['persisted_query'], true );

		switch ( $request['leaderboard'] ) {
			case 'customers':
				$leaderboards = array( $this->get_customers_leaderboard( $request['per_page'], $request['after'], $request['before'], $persisted_query ) );
				break;
			case 'coupons':
				$leaderboards = array( $this->get_coupons_leaderboard( $request['per_page'], $request['after'], $request['before'], $persisted_query ) );
				break;
			case 'categories':
				$leaderboards = array( $this->get_categories_leaderboard( $request['per_page'], $request['after'], $request['before'], $persisted_query ) );
				break;
			case 'products':
				$leaderboards = array( $this->get_products_leaderboard( $request['per_page'], $request['after'], $request['before'], $persisted_query ) );
				break;
			default:
				$leaderboards = $this->get_leaderboards( $request['per_page'], $request['after'], $request['before'], $persisted_query );
				break;
		}

		$data = array();
		if ( ! empty( $leaderboards ) ) {
			foreach ( $leaderboards as $leaderboard ) {
				$response = $this->prepare_item_for_response( $leaderboard, $request );
				$data[]   = $this->prepare_response_for_collection( $response );
			}
		}

		return rest_ensure_response( $data );
	}

	/**
	 * Returns a list of allowed leaderboards.
	 *
	 * @param  WP_REST_Request $request Request data.
	 * @return array|WP_Error
	 */
	public function get_allowed_items( $request ) {
		$leaderboards = $this->get_leaderboards( 0, null, null, null );

		$data = array();
		foreach ( $leaderboards as $leaderboard ) {
			$data[] = (object) array(
				'id'      => $leaderboard['id'],
				'label'   => $leaderboard['label'],
				'headers' => $leaderboard['headers'],
			);
		}

		$objects = array();
		foreach ( $data as $item ) {
			$prepared  = $this->prepare_item_for_response( $item, $request );
			$objects[] = $this->prepare_response_for_collection( $prepared );
		}

		$response = rest_ensure_response( $objects );
		$response->header( 'X-WP-Total', count( $data ) );
		$response->header( 'X-WP-TotalPages', 1 );

		$base = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) );

		return $response;
	}

	/**
	 * Prepare the data object for response.
	 *
	 * @param object          $item Data object.
	 * @param WP_REST_Request $request Request object.
	 * @return WP_REST_Response $response Response data.
	 */
	public function prepare_item_for_response( $item, $request ) {
		$data     = $this->add_additional_fields_to_object( $item, $request );
		$data     = $this->filter_response_by_context( $data, 'view' );
		$response = rest_ensure_response( $data );

		/**
		 * Filter the list returned from the API.
		 *
		 * @param WP_REST_Response $response The response object.
		 * @param array            $item     The original item.
		 * @param WP_REST_Request  $request  Request used to generate the response.
		 */
		return apply_filters( 'woocommerce_rest_prepare_leaderboard', $response, $item, $request );
	}

	/**
	 * Get the query params for collections.
	 *
	 * @return array
	 */
	public function get_collection_params() {
		$params                    = array();
		$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'           => 5,
			'minimum'           => 1,
			'maximum'           => 20,
			'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['persisted_query'] = array(
			'description'       => __( 'URL query to persist across links.', 'woocommerce' ),
			'type'              => 'string',
			'validate_callback' => 'rest_validate_request_arg',
		);
		return $params;
	}

	/**
	 * Get the schema, conforming to JSON Schema.
	 *
	 * @return array
	 */
	public function get_item_schema() {
		$schema = array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => 'leaderboard',
			'type'       => 'object',
			'properties' => array(
				'id'      => array(
					'type'        => 'string',
					'description' => __( 'Leaderboard ID.', 'woocommerce' ),
					'context'     => array( 'view' ),
					'readonly'    => true,
				),
				'label'   => array(
					'type'        => 'string',
					'description' => __( 'Displayed title for the leaderboard.', 'woocommerce' ),
					'context'     => array( 'view' ),
					'readonly'    => true,
				),
				'headers' => array(
					'type'        => 'array',
					'description' => __( 'Table headers.', 'woocommerce' ),
					'context'     => array( 'view' ),
					'readonly'    => true,
					'items'       => array(
						'type'       => 'array',
						'properties' => array(
							'label' => array(
								'description' => __( 'Table column header.', 'woocommerce' ),
								'type'        => 'string',
								'context'     => array( 'view', 'edit' ),
								'readonly'    => true,
							),
						),
					),
				),
				'rows'    => array(
					'type'        => 'array',
					'description' => __( 'Table rows.', 'woocommerce' ),
					'context'     => array( 'view' ),
					'readonly'    => true,
					'items'       => array(
						'type'       => 'array',
						'properties' => array(
							'display' => array(
								'description' => __( 'Table cell display.', 'woocommerce' ),
								'type'        => 'string',
								'context'     => array( 'view', 'edit' ),
								'readonly'    => true,
							),
							'value'   => array(
								'description' => __( 'Table cell value.', 'woocommerce' ),
								'type'        => 'string',
								'context'     => array( 'view', 'edit' ),
								'readonly'    => true,
							),
						),
					),
				),
			),
		);

		return $this->add_additional_fields_schema( $schema );
	}

	/**
	 * Get schema for the list of allowed leaderboards.
	 *
	 * @return array $schema
	 */
	public function get_public_allowed_item_schema() {
		$schema = $this->get_public_item_schema();
		unset( $schema['properties']['rows'] );
		return $schema;
	}
}
Data.php000064400000001653152333372110006133 0ustar00<?php
/**
 * REST API Data Controller
 *
 * Handles requests to /data
 */

namespace Automattic\WooCommerce\Admin\API;

defined( 'ABSPATH' ) || exit;

/**
 * Data controller.
 *
 * @internal
 * @extends WC_REST_Data_Controller
 */
class Data extends \WC_REST_Data_Controller {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-analytics';

	/**
	 * Return the list of data resources.
	 *
	 * @param  WP_REST_Request $request Request data.
	 * @return WP_Error|WP_REST_Response
	 */
	public function get_items( $request ) {
		$response         = parent::get_items( $request );
		$response->data[] = $this->prepare_response_for_collection(
			$this->prepare_item_for_response(
				(object) array(
					'slug'        => 'download-ips',
					'description' => __( 'An endpoint used for searching download logs for a specific IP address.', 'woocommerce' ),
				),
				$request
			)
		);
		return $response;
	}
}
OnboardingTasks.php000064400000100613152333372110010346 0ustar00<?php
/**
 * REST API Onboarding Tasks Controller
 *
 * Handles requests to complete various onboarding tasks.
 */

namespace Automattic\WooCommerce\Admin\API;

use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingIndustries;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskLists;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\DeprecatedExtendedTask;

defined( 'ABSPATH' ) || exit;

/**
 * Onboarding Tasks Controller.
 *
 * @internal
 * @extends WC_REST_Data_Controller
 */
class OnboardingTasks extends \WC_REST_Data_Controller {
	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-admin';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'onboarding/tasks';

	/**
	 * Duration to milisecond mapping.
	 *
	 * @var string
	 */
	protected $duration_to_ms = array(
		'day'  => DAY_IN_SECONDS * 1000,
		'hour' => HOUR_IN_SECONDS * 1000,
		'week' => WEEK_IN_SECONDS * 1000,
	);

	/**
	 * Register routes.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/import_sample_products',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'import_sample_products' ),
					'permission_callback' => array( $this, 'create_products_permission_check' ),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/create_homepage',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'create_homepage' ),
					'permission_callback' => array( $this, 'create_pages_permission_check' ),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/create_product_from_template',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'create_product_from_template' ),
					'permission_callback' => array( $this, 'create_products_permission_check' ),
					'args'                => array_merge(
						$this->get_endpoint_args_for_item_schema( \WP_REST_Server::CREATABLE ),
						array(
							'template_name' => array(
								'required'    => true,
								'type'        => 'string',
								'description' => __( 'Product template name.', 'woocommerce' ),
							),
						)
					),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_tasks' ),
					'permission_callback' => array( $this, 'get_tasks_permission_check' ),
					'args'                => array(
						'ids' => array(
							'description'       => __( 'Optional parameter to get only specific task lists by id.', 'woocommerce' ),
							'type'              => 'array',
							'sanitize_callback' => 'wp_parse_slug_list',
							'validate_callback' => 'rest_validate_request_arg',
							'items'             => array(
								'enum' => TaskLists::get_list_ids(),
								'type' => 'string',
							),
						),
					),
				),
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'get_tasks' ),
					'permission_callback' => array( $this, 'get_tasks_permission_check' ),
					'args'                => $this->get_task_list_params(),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>[a-z0-9_\-]+)/hide',
			array(
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'hide_task_list' ),
					'permission_callback' => array( $this, 'hide_task_list_permission_check' ),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>[a-z0-9_\-]+)/unhide',
			array(
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'unhide_task_list' ),
					'permission_callback' => array( $this, 'hide_task_list_permission_check' ),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>[a-z0-9_\-]+)/dismiss',
			array(
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'dismiss_task' ),
					'permission_callback' => array( $this, 'get_tasks_permission_check' ),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>[a-z0-9_\-]+)/undo_dismiss',
			array(
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'undo_dismiss_task' ),
					'permission_callback' => array( $this, 'get_tasks_permission_check' ),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>[a-z0-9_-]+)/snooze',
			array(
				'args'   => array(
					'duration'     => array(
						'description'       => __( 'Time period to snooze the task.', 'woocommerce' ),
						'type'              => 'string',
						'validate_callback' => function( $param, $request, $key ) {
							return in_array( $param, array_keys( $this->duration_to_ms ), true );
						},
					),
					'task_list_id' => array(
						'description' => __( 'Optional parameter to query specific task list.', 'woocommerce' ),
						'type'        => 'string',
					),
				),
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'snooze_task' ),
					'permission_callback' => array( $this, 'snooze_task_permissions_check' ),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>[a-z0-9_\-]+)/action',
			array(
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'action_task' ),
					'permission_callback' => array( $this, 'get_tasks_permission_check' ),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>[a-z0-9_\-]+)/undo_snooze',
			array(
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'undo_snooze_task' ),
					'permission_callback' => array( $this, 'snooze_task_permissions_check' ),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);
	}

	/**
	 * Check if a given request has access to create a product.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|boolean
	 */
	public function create_products_permission_check( $request ) {
		if ( ! wc_rest_check_post_permissions( 'product', 'create' ) ) {
			return new \WP_Error( 'woocommerce_rest_cannot_create', __( 'Sorry, you are not allowed to create resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
		}

		return true;
	}

	/**
	 * Check if a given request has access to create a product.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|boolean
	 */
	public function create_pages_permission_check( $request ) {
		if ( ! wc_rest_check_post_permissions( 'page', 'create' ) || ! current_user_can( 'manage_options' ) ) {
			return new \WP_Error( 'woocommerce_rest_cannot_create', __( 'Sorry, you are not allowed to create new pages.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
		}

		return true;
	}

	/**
	 * Check if a given request has access to manage woocommerce.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|boolean
	 */
	public function get_tasks_permission_check( $request ) {
		if ( ! current_user_can( 'manage_woocommerce' ) ) {
			return new \WP_Error( 'woocommerce_rest_cannot_create', __( 'Sorry, you are not allowed to retrieve onboarding tasks.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
		}

		return true;
	}

	/**
	 * Check if a given request has permission to hide task lists.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|boolean
	 */
	public function hide_task_list_permission_check( $request ) {
		if ( ! current_user_can( 'manage_woocommerce' ) ) {
			return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you are not allowed to hide task lists.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
		}

		return true;
	}

	/**
	 * Check if a given request has access to manage woocommerce.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|boolean
	 */
	public function snooze_task_permissions_check( $request ) {
		if ( ! current_user_can( 'manage_woocommerce' ) ) {
			return new \WP_Error( 'woocommerce_rest_cannot_create', __( 'Sorry, you are not allowed to snooze onboarding tasks.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
		}

		return true;
	}

	/**
	 * Import sample products from given CSV path.
	 *
	 * @param  string $csv_file CSV file path.
	 * @return WP_Error|WP_REST_Response
	 */
	public static function import_sample_products_from_csv( $csv_file ) {
		include_once WC_ABSPATH . 'includes/import/class-wc-product-csv-importer.php';

		if ( file_exists( $csv_file ) && class_exists( 'WC_Product_CSV_Importer' ) ) {
			// Override locale so we can return mappings from WooCommerce in English language stores.
			add_filter( 'locale', '__return_false', 9999 );
			$importer_class = apply_filters( 'woocommerce_product_csv_importer_class', 'WC_Product_CSV_Importer' );
			$args           = array(
				'parse'   => true,
				'mapping' => self::get_header_mappings( $csv_file ),
			);
			$args           = apply_filters( 'woocommerce_product_csv_importer_args', $args, $importer_class );

			$importer = new $importer_class( $csv_file, $args );
			$import   = $importer->import();
			return $import;
		} else {
			return new \WP_Error( 'woocommerce_rest_import_error', __( 'Sorry, the sample products data file was not found.', 'woocommerce' ) );
		}
	}

	/**
	 * Import sample products from WooCommerce sample CSV.
	 *
	 * @internal
	 * @return WP_Error|WP_REST_Response
	 */
	public static function import_sample_products() {
		if (
			( Features::is_enabled( 'experimental-import-products-task' ) || Features::is_enabled( 'experimental-products-task' ) )
			&& static::is_experiment_product_task()
		) {

			$sample_csv_file = Features::is_enabled( 'experimental-fashion-sample-products' ) ? WC_ABSPATH . 'sample-data/experimental_fashion_sample_9_products.csv' :
			WC_ABSPATH . 'sample-data/experimental_sample_9_products.csv';
		} else {
			$sample_csv_file = WC_ABSPATH . 'sample-data/sample_products.csv';
		}

		$import = self::import_sample_products_from_csv( $sample_csv_file );
		return rest_ensure_response( $import );
	}

	/**
	 * Check if product task experiment is treatment.
	 *
	 * @return bool
	 */
	public static function is_experiment_product_task() {
		$anon_id        = isset( $_COOKIE['tk_ai'] ) ? sanitize_text_field( wp_unslash( $_COOKIE['tk_ai'] ) ) : '';
		$allow_tracking = get_option( 'woocommerce_allow_tracking' ) === 'yes';
		$abtest         = new \WooCommerce\Admin\Experimental_Abtest(
			$anon_id,
			'woocommerce',
			$allow_tracking
		);
		return $abtest->get_variation( 'woocommerce_products_task_layout_stacked_v3' ) === 'treatment' ||
			$abtest->get_variation( 'woocommerce_products_task_layout_card_v3' ) === 'treatment';
	}

	/**
	 * Creates a product from a template name passed in through the template_name param.
	 *
	 * @internal
	 * @param WP_REST_Request $request Request data.
	 * @return WP_REST_Response|WP_Error
	 */
	public static function create_product_from_template( $request ) {
		$template_name = $request->get_param( 'template_name' );
		$template_path = __DIR__ . '/Templates/' . $template_name . '_product.csv';
		$template_path = apply_filters( 'woocommerce_product_template_csv_file_path', $template_path, $template_name );

		$import = self::import_sample_products_from_csv( $template_path );

		if ( is_wp_error( $import ) || 0 === count( $import['imported'] ) ) {
			return new \WP_Error(
				'woocommerce_rest_product_creation_error',
				/* translators: %s is template name */
				__( 'Sorry, creating the product with template failed.', 'woocommerce' ),
				array( 'status' => 500 )
			);
		}
		$product = wc_get_product( $import['imported'][0] );
		$product->set_status( 'auto-draft' );
		$product->save();

		return rest_ensure_response(
			array(
				'id' => $product->get_id(),
			)
		);
	}


	/**
	 * Get header mappings from CSV columns.
	 *
	 * @internal
	 * @param string $file File path.
	 * @return array Mapped headers.
	 */
	public static function get_header_mappings( $file ) {
		include_once WC_ABSPATH . 'includes/admin/importers/mappings/mappings.php';

		$importer_class  = apply_filters( 'woocommerce_product_csv_importer_class', 'WC_Product_CSV_Importer' );
		$importer        = new $importer_class( $file, array() );
		$raw_headers     = $importer->get_raw_keys();
		$default_columns = wc_importer_default_english_mappings( array() );
		$special_columns = wc_importer_default_special_english_mappings( array() );

		$headers = array();
		foreach ( $raw_headers as $key => $field ) {
			$index             = $field;
			$headers[ $index ] = $field;

			if ( isset( $default_columns[ $field ] ) ) {
				$headers[ $index ] = $default_columns[ $field ];
			} else {
				foreach ( $special_columns as $regex => $special_key ) {
					if ( preg_match( self::sanitize_special_column_name_regex( $regex ), $field, $matches ) ) {
						$headers[ $index ] = $special_key . $matches[1];
						break;
					}
				}
			}
		}

		return $headers;
	}

	/**
	 * Sanitize special column name regex.
	 *
	 * @internal
	 * @param  string $value Raw special column name.
	 * @return string
	 */
	public static function sanitize_special_column_name_regex( $value ) {
		return '/' . str_replace( array( '%d', '%s' ), '(.*)', trim( quotemeta( $value ) ) ) . '/';
	}

	/**
	 * Returns a valid cover block with an image, if one exists, or background as a fallback.
	 *
	 * @internal
	 * @param  array $image Image to use for the cover block. Should contain a media ID and image URL.
	 * @return string Block content.
	 */
	private static function get_homepage_cover_block( $image ) {
		$shop_url = get_permalink( wc_get_page_id( 'shop' ) );
		if ( ! empty( $image['url'] ) && ! empty( $image['id'] ) ) {
			return '<!-- wp:cover {"url":"' . esc_url( $image['url'] ) . '","id":' . intval( $image['id'] ) . ',"dimRatio":0} -->
			<div class="wp-block-cover" style="background-image:url(' . esc_url( $image['url'] ) . ')"><div class="wp-block-cover__inner-container"><!-- wp:paragraph {"align":"center","placeholder":"' . __( 'Write title…', 'woocommerce' ) . '","textColor":"white","fontSize":"large"} -->
			<p class="has-text-align-center has-large-font-size">' . __( 'Welcome to the store', 'woocommerce' ) . '</p>
			<!-- /wp:paragraph -->

			<!-- wp:paragraph {"align":"center","textColor":"white"} -->
			<p class="has-text-color has-text-align-center">' . __( 'Write a short welcome message here', 'woocommerce' ) . '</p>
			<!-- /wp:paragraph -->

			<!-- wp:buttons {"layout":{"type":"flex","justifyContent":"center"}} -->
			<div class="wp-block-buttons"><!-- wp:button -->
			<div class="wp-block-button"><a class="wp-block-button__link" href="' . esc_url( $shop_url ) . '">' . __( 'Go shopping', 'woocommerce' ) . '</a></div>
			<!-- /wp:button --></div>
			<!-- /wp:buttons --></div></div>
			<!-- /wp:cover -->';
		}

		return '<!-- wp:cover {"dimRatio":0} -->
		<div class="wp-block-cover"><div class="wp-block-cover__inner-container"><!-- wp:paragraph {"align":"center","placeholder":"' . __( 'Write title…', 'woocommerce' ) . '","textColor":"white","fontSize":"large"} -->
		<p class="has-text-color has-text-align-center has-large-font-size">' . __( 'Welcome to the store', 'woocommerce' ) . '</p>
		<!-- /wp:paragraph -->

		<!-- wp:paragraph {"align":"center","textColor":"white"} -->
		<p class="has-text-color has-text-align-center">' . __( 'Write a short welcome message here', 'woocommerce' ) . '</p>
		<!-- /wp:paragraph -->

		<!-- wp:buttons {"layout":{"type":"flex","justifyContent":"center"}} -->
		<div class="wp-block-buttons"><!-- wp:button -->
		<div class="wp-block-button"><a class="wp-block-button__link" href="' . esc_url( $shop_url ) . '">' . __( 'Go shopping', 'woocommerce' ) . '</a></div>
		<!-- /wp:button --></div>
		<!-- /wp:buttons --></div></div>
		<!-- /wp:cover -->';
	}

	/**
	 * Returns a valid media block with an image, if one exists, or a uninitialized media block the user can set.
	 *
	 * @internal
	 * @param  array  $image Image to use for the cover block. Should contain a media ID and image URL.
	 * @param  string $align If the image should be aligned to the left or right.
	 * @return string Block content.
	 */
	private static function get_homepage_media_block( $image, $align = 'left' ) {
		$media_position = 'right' === $align ? '"mediaPosition":"right",' : '';
		$css_class      = 'right' === $align ? ' has-media-on-the-right' : '';

		if ( ! empty( $image['url'] ) && ! empty( $image['id'] ) ) {
			return '<!-- wp:media-text {' . $media_position . '"mediaId":' . intval( $image['id'] ) . ',"mediaType":"image"} -->
			<div class="wp-block-media-text alignwide' . $css_class . '""><figure class="wp-block-media-text__media"><img src="' . esc_url( $image['url'] ) . '" alt="" class="wp-image-' . intval( $image['id'] ) . '"/></figure><div class="wp-block-media-text__content"><!-- wp:paragraph {"placeholder":"' . __( 'Content…', 'woocommerce' ) . '","fontSize":"large"} -->
			<p class="has-large-font-size"></p>
			<!-- /wp:paragraph --></div></div>
			<!-- /wp:media-text -->';
		}

		return '<!-- wp:media-text {' . $media_position . '} -->
		<div class="wp-block-media-text alignwide' . $css_class . '"><figure class="wp-block-media-text__media"></figure><div class="wp-block-media-text__content"><!-- wp:paragraph {"placeholder":"' . __( 'Content…', 'woocommerce' ) . '","fontSize":"large"} -->
		<p class="has-large-font-size"></p>
		<!-- /wp:paragraph --></div></div>
		<!-- /wp:media-text -->';
	}

	/**
	 * Returns a homepage template to be inserted into a post. A different template will be used depending on the number of products.
	 *
	 * @internal
	 * @param int $post_id ID of the homepage template.
	 * @return string Template contents.
	 */
	private static function get_homepage_template( $post_id ) {
		$products = wp_count_posts( 'product' );
		if ( $products->publish >= 4 ) {
			$images   = self::sideload_homepage_images( $post_id, 1 );
			$image_1  = ! empty( $images[0] ) ? $images[0] : '';
			$template = self::get_homepage_cover_block( $image_1 ) . '
				<!-- wp:heading {"align":"center"} -->
				<h2 style="text-align:center">' . __( 'Shop by Category', 'woocommerce' ) . '</h2>
				<!-- /wp:heading -->
				<!-- wp:shortcode -->
				[product_categories number="0" parent="0"]
				<!-- /wp:shortcode -->
				<!-- wp:heading {"align":"center"} -->
				<h2 style="text-align:center">' . __( 'New In', 'woocommerce' ) . '</h2>
				<!-- /wp:heading -->
				<!-- wp:woocommerce/product-new {"columns":4} /-->
				<!-- wp:heading {"align":"center"} -->
				<h2 style="text-align:center">' . __( 'Fan Favorites', 'woocommerce' ) . '</h2>
				<!-- /wp:heading -->
				<!-- wp:woocommerce/product-top-rated {"columns":4} /-->
				<!-- wp:heading {"align":"center"} -->
				<h2 style="text-align:center">' . __( 'On Sale', 'woocommerce' ) . '</h2>
				<!-- /wp:heading -->
				<!-- wp:woocommerce/product-on-sale {"columns":4} /-->
				<!-- wp:heading {"align":"center"} -->
				<h2 style="text-align:center">' . __( 'Best Sellers', 'woocommerce' ) . '</h2>
				<!-- /wp:heading -->
				<!-- wp:woocommerce/product-best-sellers {"columns":4} /-->
			';

			/**
			 * Modify the template/content of the default homepage.
			 *
			 * @param string $template The default homepage template.
			 */
			return apply_filters( 'woocommerce_admin_onboarding_homepage_template', $template );
		}

		$images   = self::sideload_homepage_images( $post_id, 3 );
		$image_1  = ! empty( $images[0] ) ? $images[0] : '';
		$image_2  = ! empty( $images[1] ) ? $images[1] : '';
		$image_3  = ! empty( $images[2] ) ? $images[2] : '';
		$template = self::get_homepage_cover_block( $image_1 ) . '
		<!-- wp:heading {"align":"center"} -->
		<h2 style="text-align:center">' . __( 'New Products', 'woocommerce' ) . '</h2>
		<!-- /wp:heading -->

		<!-- wp:woocommerce/product-new /--> ' .

		self::get_homepage_media_block( $image_1, 'right' ) .
		self::get_homepage_media_block( $image_2, 'left' ) .
		self::get_homepage_media_block( $image_3, 'right' ) . '

		<!-- wp:woocommerce/featured-product /-->';

		/** This filter is documented in src/API/OnboardingTasks.php. */
		return apply_filters( 'woocommerce_admin_onboarding_homepage_template', $template );
	}

	/**
	 * Gets the possible industry images from the plugin folder for sideloading. If an image doesn't exist, other.jpg is used a fallback.
	 *
	 * @internal
	 * @return array An array of images by industry.
	 */
	private static function get_available_homepage_images() {
		$industry_images = array();
		$industries      = OnboardingIndustries::get_allowed_industries();
		foreach ( $industries as $industry_slug => $label ) {
			$industry_images[ $industry_slug ] = apply_filters( 'woocommerce_admin_onboarding_industry_image', WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/other-small.jpg', $industry_slug );
		}
		return $industry_images;
	}

	/**
	 * Uploads a number of images to a homepage template, depending on the selected industry from the profile wizard.
	 *
	 * @internal
	 * @param  int $post_id ID of the homepage template.
	 * @param  int $number_of_images The number of images that should be sideloaded (depending on how many media slots are in the template).
	 * @return array An array of images that have been attached to the post.
	 */
	private static function sideload_homepage_images( $post_id, $number_of_images ) {
		$profile            = get_option( OnboardingProfile::DATA_OPTION, array() );
		$images_to_sideload = array();
		$available_images   = self::get_available_homepage_images();

		require_once ABSPATH . 'wp-admin/includes/image.php';
		require_once ABSPATH . 'wp-admin/includes/file.php';
		require_once ABSPATH . 'wp-admin/includes/media.php';

		if ( ! empty( $profile['industry'] ) ) {
			foreach ( $profile['industry'] as $selected_industry ) {
				if ( is_string( $selected_industry ) ) {
					$industry_slug = $selected_industry;
				} elseif ( is_array( $selected_industry ) && ! empty( $selected_industry['slug'] ) ) {
					$industry_slug = $selected_industry['slug'];
				} else {
					continue;
				}
				// Capture the first industry for use in our minimum images logic.
				$first_industry       = isset( $first_industry ) ? $first_industry : $industry_slug;
				$images_to_sideload[] = ! empty( $available_images[ $industry_slug ] ) ? $available_images[ $industry_slug ] : $available_images['other'];
			}
		}

		// Make sure we have at least {$number_of_images} images.
		if ( count( $images_to_sideload ) < $number_of_images ) {
			for ( $i = count( $images_to_sideload ); $i < $number_of_images; $i++ ) {
				// Fill up missing image slots with the first selected industry, or other.
				$industry             = isset( $first_industry ) ? $first_industry : 'other';
				$images_to_sideload[] = empty( $available_images[ $industry ] ) ? $available_images['other'] : $available_images[ $industry ];
			}
		}

		$already_sideloaded = array();
		$images_for_post    = array();
		foreach ( $images_to_sideload as $image ) {
			// Avoid uploading two of the same image, if an image is repeated.
			if ( ! empty( $already_sideloaded[ $image ] ) ) {
				$images_for_post[] = $already_sideloaded[ $image ];
				continue;
			}

			$sideload_id = \media_sideload_image( $image, $post_id, null, 'id' );
			if ( ! is_wp_error( $sideload_id ) ) {
				$sideload_url                 = wp_get_attachment_url( $sideload_id );
				$already_sideloaded[ $image ] = array(
					'id'  => $sideload_id,
					'url' => $sideload_url,
				);
				$images_for_post[]            = $already_sideloaded[ $image ];
			}
		}

		return $images_for_post;
	}

	/**
	 * Create a homepage from a template.
	 *
	 * @return WP_Error|array
	 */
	public static function create_homepage() {
		$post_id = wp_insert_post(
			array(
				'post_title'   => __( 'Homepage', 'woocommerce' ),
				'post_type'    => 'page',
				'post_status'  => 'publish',
				'post_content' => '', // Template content is updated below, so images can be attached to the post.
			)
		);

		if ( ! is_wp_error( $post_id ) && 0 < $post_id ) {

			$template = self::get_homepage_template( $post_id );
			wp_update_post(
				array(
					'ID'           => $post_id,
					'post_content' => $template,
				)
			);

			update_option( 'show_on_front', 'page' );
			update_option( 'page_on_front', $post_id );
			update_option( 'woocommerce_onboarding_homepage_post_id', $post_id );

			// Use the full width template on stores using Storefront.
			if ( 'storefront' === get_stylesheet() ) {
				update_post_meta( $post_id, '_wp_page_template', 'template-fullwidth.php' );
			}

			return array(
				'status'         => 'success',
				'message'        => __( 'Homepage created', 'woocommerce' ),
				'post_id'        => $post_id,
				'edit_post_link' => htmlspecialchars_decode( get_edit_post_link( $post_id ) ),
			);
		} else {
			return $post_id;
		}
	}

	/**
	 * Get the query params for task lists.
	 *
	 * @return array
	 */
	public function get_task_list_params() {
		$params                   = array();
		$params['ids']            = array(
			'description'       => __( 'Optional parameter to get only specific task lists by id.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_slug_list',
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'enum' => TaskLists::get_list_ids(),
				'type' => 'string',
			),
		);
		$params['extended_tasks'] = array(
			'description'       => __( 'List of extended deprecated tasks from the client side filter.', 'woocommerce' ),
			'type'              => 'array',
			'validate_callback' => function( $param, $request, $key ) {
				$has_valid_keys = true;
				foreach ( $param as $task ) {
					if ( $has_valid_keys ) {
						$has_valid_keys = array_key_exists( 'list_id', $task ) && array_key_exists( 'id', $task );
					}
				}
				return $has_valid_keys;
			},
		);
		return $params;
	}

	/**
	 * Get the onboarding tasks.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function get_tasks( $request ) {
		$extended_tasks = $request->get_param( 'extended_tasks' );
		$task_list_ids  = $request->get_param( 'ids' );

		TaskLists::maybe_add_extended_tasks( $extended_tasks );

		$lists = is_array( $task_list_ids ) && count( $task_list_ids ) > 0 ? TaskLists::get_lists_by_ids( $task_list_ids ) : TaskLists::get_lists();

		$json = array_map(
			function( $list ) {
				return $list->sort_tasks()->get_json();
			},
			$lists
		);

		return rest_ensure_response( array_values( apply_filters( 'woocommerce_admin_onboarding_tasks', $json ) ) );
	}

	/**
	 * Dismiss a single task.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Request|WP_Error
	 */
	public function dismiss_task( $request ) {
		$id   = $request->get_param( 'id' );
		$task = TaskLists::get_task( $id );

		if ( ! $task && $id ) {
			$task = new DeprecatedExtendedTask(
				array(
					'id'             => $id,
					'is_dismissable' => true,
				)
			);
		}

		if ( ! $task || ! $task->is_dismissable() ) {
			return new \WP_Error(
				'woocommerce_rest_invalid_task',
				__( 'Sorry, no dismissable task with that ID was found.', 'woocommerce' ),
				array(
					'status' => 404,
				)
			);
		}

		$task->dismiss();
		return rest_ensure_response( $task->get_json() );
	}

	/**
	 * Undo dismissal of a single task.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Request|WP_Error
	 */
	public function undo_dismiss_task( $request ) {
		$id   = $request->get_param( 'id' );
		$task = TaskLists::get_task( $id );

		if ( ! $task && $id ) {
			$task = new DeprecatedExtendedTask(
				array(
					'id'             => $id,
					'is_dismissable' => true,
				)
			);
		}

		if ( ! $task || ! $task->is_dismissable() ) {
			return new \WP_Error(
				'woocommerce_rest_invalid_task',
				__( 'Sorry, no dismissable task with that ID was found.', 'woocommerce' ),
				array(
					'status' => 404,
				)
			);
		}

		$task->undo_dismiss();

		return rest_ensure_response( $task->get_json() );
	}

	/**
	 * Snooze an onboarding task.
	 *
	 * @param WP_REST_Request $request Request data.
	 *
	 * @return WP_REST_Response|WP_Error
	 */
	public function snooze_task( $request ) {
		$task_id      = $request->get_param( 'id' );
		$task_list_id = $request->get_param( 'task_list_id' );
		$duration     = $request->get_param( 'duration' );

		$task = TaskLists::get_task( $task_id, $task_list_id );

		if ( ! $task && $task_id ) {
			$task = new DeprecatedExtendedTask(
				array(
					'id'            => $task_id,
					'is_snoozeable' => true,
				)
			);
		}

		if ( ! $task || ! $task->is_snoozeable() ) {
			return new \WP_Error(
				'woocommerce_rest_invalid_task',
				__( 'Sorry, no snoozeable task with that ID was found.', 'woocommerce' ),
				array(
					'status' => 404,
				)
			);
		}

		$task->snooze( isset( $duration ) ? $duration : 'day' );
		return rest_ensure_response( $task->get_json() );
	}

	/**
	 * Undo snooze of a single task.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Request|WP_Error
	 */
	public function undo_snooze_task( $request ) {
		$id   = $request->get_param( 'id' );
		$task = TaskLists::get_task( $id );

		if ( ! $task && $id ) {
			$task = new DeprecatedExtendedTask(
				array(
					'id'            => $id,
					'is_snoozeable' => true,
				)
			);
		}

		if ( ! $task || ! $task->is_snoozeable() ) {
			return new \WP_Error(
				'woocommerce_rest_invalid_task',
				__( 'Sorry, no snoozeable task with that ID was found.', 'woocommerce' ),
				array(
					'status' => 404,
				)
			);
		}

		$task->undo_snooze();
		return rest_ensure_response( $task->get_json() );
	}

	/**
	 * Hide a task list.
	 *
	 * @param WP_REST_Request $request Request data.
	 *
	 * @return WP_REST_Response|WP_Error
	 */
	public function hide_task_list( $request ) {
		$id        = $request->get_param( 'id' );
		$task_list = TaskLists::get_list( $id );

		if ( ! $task_list ) {
			return new \WP_Error(
				'woocommerce_rest_invalid_task_list',
				__( 'Sorry, that task list was not found', 'woocommerce' ),
				array(
					'status' => 404,
				)
			);
		}

		$update = $task_list->hide();
		$json   = $task_list->get_json();

		return rest_ensure_response( $json );
	}

	/**
	 * Unhide a task list.
	 *
	 * @param WP_REST_Request $request Request data.
	 *
	 * @return WP_REST_Response|WP_Error
	 */
	public function unhide_task_list( $request ) {
		$id        = $request->get_param( 'id' );
		$task_list = TaskLists::get_list( $id );

		if ( ! $task_list ) {
			return new \WP_Error(
				'woocommerce_tasks_invalid_task_list',
				__( 'Sorry, that task list was not found', 'woocommerce' ),
				array(
					'status' => 404,
				)
			);
		}

		$update = $task_list->unhide();
		$json   = $task_list->get_json();

		return rest_ensure_response( $json );
	}

	/**
	 * Action a single task.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Request|WP_Error
	 */
	public function action_task( $request ) {
		$id   = $request->get_param( 'id' );
		$task = TaskLists::get_task( $id );

		if ( ! $task && $id ) {
			$task = new DeprecatedExtendedTask(
				array(
					'id' => $id,
				)
			);
		}

		if ( ! $task ) {
			return new \WP_Error(
				'woocommerce_rest_invalid_task',
				__( 'Sorry, no task with that ID was found.', 'woocommerce' ),
				array(
					'status' => 404,
				)
			);
		}

		$task->mark_actioned();
		return rest_ensure_response( $task->get_json() );
	}

}
CustomAttributeTraits.php000064400000006634152333372110011613 0ustar00<?php
/**
 * Traits for handling custom product attributes and their terms.
 */

namespace Automattic\WooCommerce\Admin\API;

defined( 'ABSPATH' ) || exit;

/**
 * CustomAttributeTraits class.
 *
 * @internal
 */
trait CustomAttributeTraits {
	/**
	 * Get a single attribute by its slug.
	 *
	 * @internal
	 * @param string $slug The attribute slug.
	 * @return WP_Error|object The matching attribute object or WP_Error if not found.
	 */
	public function get_custom_attribute_by_slug( $slug ) {
		$matching_attributes = $this->get_custom_attributes( array( 'slug' => $slug ) );

		if ( empty( $matching_attributes ) ) {
			return new \WP_Error(
				'woocommerce_rest_product_attribute_not_found',
				__( 'No product attribute with that slug was found.', 'woocommerce' ),
				array( 'status' => 404 )
			);
		}

		foreach ( $matching_attributes as $attribute_key => $attribute_value ) {
			return array( $attribute_key => $attribute_value );
		}
	}

	/**
	 * Query custom attributes by name or slug.
	 *
	 * @param string $args Search arguments, either name or slug.
	 * @return array Matching attributes, formatted for response.
	 */
	protected function get_custom_attributes( $args ) {
		global $wpdb;

		$args = wp_parse_args(
			$args,
			array(
				'name' => '',
				'slug' => '',
			)
		);

		if ( empty( $args['name'] ) && empty( $args['slug'] ) ) {
			return array();
		}

		$mode = $args['name'] ? 'name' : 'slug';

		if ( 'name' === $mode ) {
			$name = $args['name'];
			// Get as close as we can to matching the name property of custom attributes using SQL.
			$like = '%"name";s:%:"%' . $wpdb->esc_like( $name ) . '%"%';
		} else {
			$slug = sanitize_title_for_query( $args['slug'] );
			// Get as close as we can to matching the slug property of custom attributes using SQL.
			$like = '%s:' . strlen( $slug ) . ':"' . $slug . '";a:6:{%';
		}

		// Find all serialized product attributes with names like the search string.
		$query_results = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT meta_value
				FROM {$wpdb->postmeta}
				WHERE meta_key = '_product_attributes'
				AND meta_value LIKE %s
				LIMIT 100",
				$like
			),
			ARRAY_A
		);

		$custom_attributes = array();

		foreach ( $query_results as $raw_product_attributes ) {

			$meta_attributes = maybe_unserialize( $raw_product_attributes['meta_value'] );

			if ( empty( $meta_attributes ) || ! is_array( $meta_attributes ) ) {
				continue;
			}

			foreach ( $meta_attributes as $meta_attribute_key => $meta_attribute_value ) {
				$meta_value = array_merge(
					array(
						'name'        => '',
						'is_taxonomy' => 0,
					),
					(array) $meta_attribute_value
				);

				// Skip non-custom attributes.
				if ( ! empty( $meta_value['is_taxonomy'] ) ) {
					continue;
				}

				// Skip custom attributes that didn't match the query.
				// (There can be any number of attributes in the meta value).
				if ( ( 'name' === $mode ) && ( false === stripos( $meta_value['name'], $name ) ) ) {
					continue;
				}

				if ( ( 'slug' === $mode ) && ( $meta_attribute_key !== $slug ) ) {
					continue;
				}

				// Combine all values when there are multiple matching custom attributes.
				if ( isset( $custom_attributes[ $meta_attribute_key ] ) ) {
					$custom_attributes[ $meta_attribute_key ]['value'] .= ' ' . WC_DELIMITER . ' ' . $meta_value['value'];
				} else {
					$custom_attributes[ $meta_attribute_key ] = $meta_attribute_value;
				}
			}
		}

		return $custom_attributes;
	}
}