HEX
Server: LiteSpeed
System: Linux eko108.isimtescil.net 4.18.0-477.21.1.lve.1.el8.x86_64 #1 SMP Tue Sep 5 23:08:35 UTC 2023 x86_64
User: uyarreklamcomtr (11202)
PHP: 7.4.33
Disabled: opcache_get_status
Upload Files
File: /var/www/vhosts/uyarreklam.com.tr/httpdocs/Utilities.tar
ArrayUtil.php000064400000026424151536754370007221 0ustar00<?php
/**
 * A class of utilities for dealing with arrays.
 */

namespace Automattic\WooCommerce\Utilities;

/**
 * A class of utilities for dealing with arrays.
 */
class ArrayUtil {

	/**
	 * Automatic selector type for the 'select' method.
	 */
	public const SELECT_BY_AUTO = 0;

	/**
	 * Object method selector type for the 'select' method.
	 */
	public const SELECT_BY_OBJECT_METHOD = 1;

	/**
	 * Object property selector type for the 'select' method.
	 */
	public const SELECT_BY_OBJECT_PROPERTY = 2;

	/**
	 * Array key selector type for the 'select' method.
	 */
	public const SELECT_BY_ARRAY_KEY = 3;

	/**
	 * Get a value from an nested array by specifying the entire key hierarchy with '::' as separator.
	 *
	 * E.g. for [ 'foo' => [ 'bar' => [ 'fizz' => 'buzz' ] ] ] the value for key 'foo::bar::fizz' would be 'buzz'.
	 *
	 * @param array  $array The array to get the value from.
	 * @param string $key The complete key hierarchy, using '::' as separator.
	 * @param mixed  $default The value to return if the key doesn't exist in the array.
	 *
	 * @return mixed The retrieved value, or the supplied default value.
	 * @throws \Exception $array is not an array.
	 */
	public static function get_nested_value( array $array, string $key, $default = null ) {
		$key_stack = explode( '::', $key );
		$subkey    = array_shift( $key_stack );

		if ( isset( $array[ $subkey ] ) ) {
			$value = $array[ $subkey ];

			if ( count( $key_stack ) ) {
				foreach ( $key_stack as $subkey ) {
					if ( is_array( $value ) && isset( $value[ $subkey ] ) ) {
						$value = $value[ $subkey ];
					} else {
						$value = $default;
						break;
					}
				}
			}
		} else {
			$value = $default;
		}

		return $value;
	}

	/**
	 * Checks if a given key exists in an array and its value can be evaluated as 'true'.
	 *
	 * @param array  $array The array to check.
	 * @param string $key The key for the value to check.
	 * @return bool True if the key exists in the array and the value can be evaluated as 'true'.
	 */
	public static function is_truthy( array $array, string $key ) {
		return isset( $array[ $key ] ) && $array[ $key ];
	}

	/**
	 * Gets the value for a given key from an array, or a default value if the key doesn't exist in the array.
	 *
	 * This is equivalent to "$array[$key] ?? $default" except in one case:
	 * when they key exists, has a null value, and a non-null default is supplied:
	 *
	 * $array = ['key' => null]
	 * $array['key'] ?? 'default' => 'default'
	 * ArrayUtil::get_value_or_default($array, 'key', 'default') => null
	 *
	 * @param array  $array The array to get the value from.
	 * @param string $key The key to use to retrieve the value.
	 * @param null   $default The default value to return if the key doesn't exist in the array.
	 * @return mixed|null The value for the key, or the default value passed.
	 */
	public static function get_value_or_default( array $array, string $key, $default = null ) {
		return array_key_exists( $key, $array ) ? $array[ $key ] : $default;
	}

	/**
	 * Converts an array of numbers to a human-readable range, such as "1,2,3,5" to "1-3, 5". It also supports
	 * floating point numbers, however with some perhaps unexpected / undefined behaviour if used within a range.
	 * Source: https://stackoverflow.com/a/34254663/4574
	 *
	 * @param array     $items    An array (in any order, see $sort) of individual numbers.
	 * @param string    $item_separator  The string that separates sequential range groups.  Defaults to ', '.
	 * @param string    $range_separator The string that separates ranges.  Defaults to '-'.  A plausible example otherwise would be ' to '.
	 * @param bool|true $sort     Sort the array prior to iterating?  You'll likely always want to sort, but if not, you can set this to false.
	 *
	 * @return string
	 */
	public static function to_ranges_string( array $items, string $item_separator = ', ', string $range_separator = '-', bool $sort = true ): string {
		if ( $sort ) {
			sort( $items );
		}

		$point = null;
		$range = false;
		$str   = '';

		foreach ( $items as $i ) {
			if ( null === $point ) {
				$str .= $i;
			} elseif ( ( $point + 1 ) === $i ) {
				$range = true;
			} else {
				if ( $range ) {
					$str  .= $range_separator . $point;
					$range = false;
				}
				$str .= $item_separator . $i;
			}
			$point = $i;
		}

		if ( $range ) {
			$str .= $range_separator . $point;
		}

		return $str;
	}

	/**
	 * Helper function to generate a callback which can be executed on an array to select a value from each item.
	 *
	 * @param string $selector_name Field/property/method name to select.
	 * @param int    $selector_type Selector type.
	 *
	 * @return \Closure Callback to select the value.
	 */
	private static function get_selector_callback( string $selector_name, int $selector_type = self::SELECT_BY_AUTO ): \Closure {
		if ( self::SELECT_BY_OBJECT_METHOD === $selector_type ) {
			$callback = function( $item ) use ( $selector_name ) {
				return $item->$selector_name();
			};
		} elseif ( self::SELECT_BY_OBJECT_PROPERTY === $selector_type ) {
			$callback = function( $item ) use ( $selector_name ) {
				return $item->$selector_name;
			};
		} elseif ( self::SELECT_BY_ARRAY_KEY === $selector_type ) {
			$callback = function( $item ) use ( $selector_name ) {
				return $item[ $selector_name ];
			};
		} else {
			$callback = function( $item ) use ( $selector_name ) {
				if ( is_array( $item ) ) {
					return $item[ $selector_name ];
				} elseif ( method_exists( $item, $selector_name ) ) {
					return $item->$selector_name();
				} else {
					return $item->$selector_name;
				}
			};
		}
		return $callback;
	}

	/**
	 * Select one single value from all the items in an array of either arrays or objects based on a selector.
	 * For arrays, the selector is a key name; for objects, the selector can be either a method name or a property name.
	 *
	 * @param array  $items Items to apply the selection to.
	 * @param string $selector_name Key, method or property name to use as a selector.
	 * @param int    $selector_type Selector type, one of the SELECT_BY_* constants.
	 * @return array The selected values.
	 */
	public static function select( array $items, string $selector_name, int $selector_type = self::SELECT_BY_AUTO ): array {
		$callback = self::get_selector_callback( $selector_name, $selector_type );
		return array_map( $callback, $items );
	}

	/**
	 * Returns a new assoc array with format [ $key1 => $item1, $key2 => $item2, ... ] where $key is the value of the selector and items are original items passed.
	 *
	 * @param array  $items Items to use for conversion.
	 * @param string $selector_name Key, method or property name to use as a selector.
	 * @param int    $selector_type Selector type, one of the SELECT_BY_* constants.
	 *
	 * @return array The converted assoc array.
	 */
	public static function select_as_assoc( array $items, string $selector_name, int $selector_type = self::SELECT_BY_AUTO ): array {
		$selector_callback = self::get_selector_callback( $selector_name, $selector_type );
		$result            = array();
		foreach ( $items as $item ) {
			$key = $selector_callback( $item );
			self::ensure_key_is_array( $result, $key );
			$result[ $key ][] = $item;
		}
		return $result;
	}

	/**
	 * Returns whether two assoc array are same. The comparison is done recursively by keys, and the functions returns on first difference found.
	 *
	 * @param array $array1 First array to compare.
	 * @param array $array2 Second array to compare.
	 * @param bool  $strict Whether to use strict comparison.
	 *
	 * @return bool Whether the arrays are different.
	 */
	public static function deep_compare_array_diff( array $array1, array $array2, bool $strict = true ) {
		return self::deep_compute_or_compare_array_diff( $array1, $array2, true, $strict );
	}

	/**
	 * Computes difference between two assoc arrays recursively. Similar to PHP's native assoc_array_diff, but also supports nested arrays.
	 *
	 * @param array $array1 First array.
	 * @param array $array2 Second array.
	 * @param bool  $strict Whether to also match type of values.
	 *
	 * @return array The difference between the two arrays.
	 */
	public static function deep_assoc_array_diff( array $array1, array $array2, bool $strict = true ): array {
		return self::deep_compute_or_compare_array_diff( $array1, $array2, false, $strict );
	}

	/**
	 * Helper method to compare to compute difference between two arrays. Comparison is done recursively.
	 *
	 * @param array $array1 First array.
	 * @param array $array2 Second array.
	 * @param bool  $compare Whether to compare the arrays. If true, then function will return false on first difference, in order to be slightly more efficient.
	 * @param bool  $strict Whether to do string comparison.
	 *
	 * @return array|bool The difference between the two arrays, or if array are same, depending upon $compare param.
	 */
	private static function deep_compute_or_compare_array_diff( array $array1, array $array2, bool $compare, bool $strict = true ) {
		$diff = array();
		foreach ( $array1 as $key => $value ) {
			if ( is_array( $value ) ) {
				if ( ! array_key_exists( $key, $array2 ) || ! is_array( $array2[ $key ] ) ) {
					if ( $compare ) {
						return true;
					}
					$diff[ $key ] = $value;
					continue;
				}
				$new_diff = self::deep_assoc_array_diff( $value, $array2[ $key ], $strict );
				if ( ! empty( $new_diff ) ) {
					if ( $compare ) {
						return true;
					}
					$diff[ $key ] = $new_diff;
				}
			} elseif ( $strict ) {
				if ( ! array_key_exists( $key, $array2 ) || $value !== $array2[ $key ] ) {
					if ( $compare ) {
						return true;
					}
					$diff[ $key ] = $value;
				}
			} else {
				// phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison -- Intentional when $strict is false.
				if ( ! array_key_exists( $key, $array2 ) || $value != $array2[ $key ] ) {
					if ( $compare ) {
						return true;
					}
					$diff[ $key ] = $value;
				}
			}
		}

		return $compare ? false : $diff;
	}

	/**
	 * Push a value to an array, but only if the value isn't in the array already.
	 *
	 * @param array $array The array.
	 * @param mixed $value The value to maybe push.
	 * @return bool True if the value has been added to the array, false if the value was already in the array.
	 */
	public static function push_once( array &$array, $value ) : bool {
		if ( in_array( $value, $array, true ) ) {
			return false;
		}

		$array[] = $value;
		return true;
	}

	/**
	 * Ensure that an associative array has a given key, and if not, set the key to an empty array.
	 *
	 * @param array  $array The array to check.
	 * @param string $key The key to check.
	 * @param bool   $throw_if_existing_is_not_array If true, an exception will be thrown if the key already exists in the array but the value is not an array.
	 * @return bool True if the key has been added to the array, false if not (the key already existed).
	 * @throws \Exception The key already exists in the array but the value is not an array.
	 */
	public static function ensure_key_is_array( array &$array, string $key, bool $throw_if_existing_is_not_array = false ): bool {
		if ( ! isset( $array[ $key ] ) ) {
			$array[ $key ] = array();
			return true;
		}

		if ( $throw_if_existing_is_not_array && ! is_array( $array[ $key ] ) ) {
			$type = is_object( $array[ $key ] ) ? get_class( $array[ $key ] ) : gettype( $array[ $key ] );
			throw new \Exception( "Array key exists but it's not an array, it's a {$type}" );
		}

		return false;
	}
}

FeaturesUtil.php000064400000010766151536754370007723 0ustar00<?php
/**
 * FeaturesUtil class file.
 */

namespace Automattic\WooCommerce\Utilities;

use Automattic\WooCommerce\Internal\Features\FeaturesController;

/**
 * Class with methods that allow to retrieve information about the existing WooCommerce features,
 * also has methods for WooCommerce plugins to declare (in)compatibility with the features.
 */
class FeaturesUtil {

	/**
	 * Get all the existing WooCommerce features.
	 *
	 * Returns an associative array where keys are unique feature ids
	 * and values are arrays with these keys:
	 *
	 * - name
	 * - description
	 * - is_experimental
	 * - is_enabled (if $include_enabled_info is passed as true)
	 *
	 * @param bool $include_experimental Include also experimental/work in progress features in the list.
	 * @param bool $include_enabled_info True to include the 'is_enabled' field in the returned features info.
	 * @returns array An array of information about existing features.
	 */
	public static function get_features( bool $include_experimental = false, bool $include_enabled_info = false ): array {
		return wc_get_container()->get( FeaturesController::class )->get_features( $include_experimental, $include_enabled_info );
	}

	/**
	 * Check if a given feature is currently enabled.
	 *
	 * @param  string $feature_id Unique feature id.
	 * @return bool True if the feature is enabled, false if not or if the feature doesn't exist.
	 */
	public static function feature_is_enabled( string $feature_id ): bool {
		return wc_get_container()->get( FeaturesController::class )->feature_is_enabled( $feature_id );
	}

	/**
	 * Declare (in)compatibility with a given feature for a given plugin.
	 *
	 * This method MUST be executed from inside a handler for the 'before_woocommerce_init' hook and
	 * SHOULD be executed from the main plugin file passing __FILE__ or 'my-plugin/my-plugin.php' for the
	 * $plugin_file argument.
	 *
	 * @param string $feature_id Unique feature id.
	 * @param string $plugin_file The full plugin file path.
	 * @param bool   $positive_compatibility True if the plugin declares being compatible with the feature, false if it declares being incompatible.
	 * @return bool True on success, false on error (feature doesn't exist or not inside the required hook).
	 */
	public static function declare_compatibility( string $feature_id, string $plugin_file, bool $positive_compatibility = true ): bool {
		$plugin_id = wc_get_container()->get( PluginUtil::class )->get_wp_plugin_id( $plugin_file );

		if ( ! $plugin_id ) {
			$logger = wc_get_logger();
			$logger->error( "FeaturesUtil::declare_compatibility: {$plugin_file} is not a known WordPress plugin." );
			return false;
		}

		return wc_get_container()->get( FeaturesController::class )->declare_compatibility( $feature_id, $plugin_id, $positive_compatibility );
	}

	/**
	 * Get the ids of the features that a certain plugin has declared compatibility for.
	 *
	 * This method can't be called before the 'woocommerce_init' hook is fired.
	 *
	 * @param string $plugin_name Plugin name, in the form 'directory/file.php'.
	 * @return array An array having a 'compatible' and an 'incompatible' key, each holding an array of plugin ids.
	 */
	public static function get_compatible_features_for_plugin( string $plugin_name ): array {
		return wc_get_container()->get( FeaturesController::class )->get_compatible_features_for_plugin( $plugin_name );
	}

	/**
	 * Get the names of the plugins that have been declared compatible or incompatible with a given feature.
	 *
	 * @param string $feature_id Feature id.
	 * @return array An array having a 'compatible' and an 'incompatible' key, each holding an array of plugin names.
	 */
	public static function get_compatible_plugins_for_feature( string $feature_id ): array {
		return wc_get_container()->get( FeaturesController::class )->get_compatible_plugins_for_feature( $feature_id );
	}

	/**
	 * Sets a flag indicating that it's allowed to enable features for which incompatible plugins are active
	 * from the WooCommerce feature settings page.
	 */
	public static function allow_enabling_features_with_incompatible_plugins(): void {
		wc_get_container()->get( FeaturesController::class )->allow_enabling_features_with_incompatible_plugins();
	}

	/**
	 * Sets a flag indicating that it's allowed to activate plugins for which incompatible features are enabled
	 * from the WordPress plugins page.
	 */
	public static function allow_activating_plugins_with_incompatible_features(): void {
		wc_get_container()->get( FeaturesController::class )->allow_activating_plugins_with_incompatible_features();
	}
}
I18nUtil.php000064400000003277151536754370006663 0ustar00<?php
/**
 * A class of utilities for dealing with internationalization.
 */

namespace Automattic\WooCommerce\Utilities;

/**
 * A class of utilities for dealing with internationalization.
 */
final class I18nUtil {
	/**
	 * A cache for the i18n units data.
	 *
	 * @var array $units
	 */
	private static $units;

	/**
	 * Get the translated label for a weight unit of measure.
	 *
	 * This will return the original input string if it isn't found in the units array. This way a custom unit of
	 * measure can be used even if it's not getting translated.
	 *
	 * @param string $weight_unit The abbreviated weight unit in English, e.g. kg.
	 *
	 * @return string
	 */
	public static function get_weight_unit_label( $weight_unit ) {
		if ( empty( self::$units ) ) {
			self::$units = include WC()->plugin_path() . '/i18n/units.php';
		}

		$label = $weight_unit;

		if ( ! empty( self::$units['weight'][ $weight_unit ] ) ) {
			$label = self::$units['weight'][ $weight_unit ];
		}

		return $label;
	}

	/**
	 * Get the translated label for a dimensions unit of measure.
	 *
	 * This will return the original input string if it isn't found in the units array. This way a custom unit of
	 * measure can be used even if it's not getting translated.
	 *
	 * @param string $dimensions_unit The abbreviated dimension unit in English, e.g. cm.
	 *
	 * @return string
	 */
	public static function get_dimensions_unit_label( $dimensions_unit ) {
		if ( empty( self::$units ) ) {
			self::$units = include WC()->plugin_path() . '/i18n/units.php';
		}

		$label = $dimensions_unit;

		if ( ! empty( self::$units['dimensions'][ $dimensions_unit ] ) ) {
			$label = self::$units['dimensions'][ $dimensions_unit ];
		}

		return $label;
	}
}
NumberUtil.php000064400000002230151536754370007360 0ustar00<?php
/**
 * A class of utilities for dealing with numbers.
 */

namespace Automattic\WooCommerce\Utilities;

/**
 * A class of utilities for dealing with numbers.
 */
final class NumberUtil {

	/**
	 * Round a number using the built-in `round` function, but unless the value to round is numeric
	 * (a number or a string that can be parsed as a number), apply 'floatval' first to it
	 * (so it will convert it to 0 in most cases).
	 *
	 * This is needed because in PHP 7 applying `round` to a non-numeric value returns 0,
	 * but in PHP 8 it throws an error. Specifically, in WooCommerce we have a few places where
	 * round('') is often executed.
	 *
	 * @param mixed $val The value to round.
	 * @param int   $precision The optional number of decimal digits to round to.
	 * @param int   $mode A constant to specify the mode in which rounding occurs.
	 *
	 * @return float The value rounded to the given precision as a float, or the supplied default value.
	 */
	public static function round( $val, int $precision = 0, int $mode = PHP_ROUND_HALF_UP ) : float {
		if ( ! is_numeric( $val ) ) {
			$val = floatval( $val );
		}
		return round( $val, $precision, $mode );
	}
}
OrderUtil.php000064400000014376151536754370007221 0ustar00<?php
/**
 * A class of utilities for dealing with orders.
 */

namespace Automattic\WooCommerce\Utilities;

use Automattic\WooCommerce\Caches\OrderCacheController;
use Automattic\WooCommerce\Internal\Admin\Orders\PageController;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\Utilities\COTMigrationUtil;
use WC_Order;
use WP_Post;

/**
 * A class of utilities for dealing with orders.
 */
final class OrderUtil {

	/**
	 * Helper function to get screen name of orders page in wp-admin.
	 *
	 * @return string
	 */
	public static function get_order_admin_screen() : string {
		return wc_get_container()->get( COTMigrationUtil::class )->get_order_admin_screen();
	}


	/**
	 * Helper function to get whether custom order tables are enabled or not.
	 *
	 * @return bool
	 */
	public static function custom_orders_table_usage_is_enabled() : bool {
		return wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled();
	}

	/**
	 * Helper function to get whether the orders cache should be used or not.
	 *
	 * @return bool True if the orders cache should be used, false otherwise.
	 */
	public static function orders_cache_usage_is_enabled() : bool {
		return wc_get_container()->get( OrderCacheController::class )->orders_cache_usage_is_enabled();
	}

	/**
	 * Checks if posts and order custom table sync is enabled and there are no pending orders.
	 *
	 * @return bool
	 */
	public static function is_custom_order_tables_in_sync() : bool {
		return wc_get_container()->get( COTMigrationUtil::class )->is_custom_order_tables_in_sync();
	}

	/**
	 * Gets value of a meta key from WC_Data object if passed, otherwise from the post object.
	 * This helper function support backward compatibility for meta box functions, when moving from posts based store to custom tables.
	 *
	 * @param WP_Post|null  $post Post object, meta will be fetched from this only when `$data` is not passed.
	 * @param \WC_Data|null $data WC_Data object, will be preferred over post object when passed.
	 * @param string        $key Key to fetch metadata for.
	 * @param bool          $single Whether metadata is single.
	 *
	 * @return array|mixed|string Value of the meta key.
	 */
	public static function get_post_or_object_meta( ?WP_Post $post, ?\WC_Data $data, string $key, bool $single ) {
		return wc_get_container()->get( COTMigrationUtil::class )->get_post_or_object_meta( $post, $data, $key, $single );
	}

	/**
	 * Helper function to initialize the global $theorder object, mostly used during order meta boxes rendering.
	 *
	 * @param WC_Order|WP_Post $post_or_order_object Post or order object.
	 *
	 * @return bool|WC_Order|WC_Order_Refund WC_Order object.
	 */
	public static function init_theorder_object( $post_or_order_object ) {
		return wc_get_container()->get( COTMigrationUtil::class )->init_theorder_object( $post_or_order_object );
	}

	/**
	 * Helper function to id from an post or order object.
	 *
	 * @param WP_Post/WC_Order $post_or_order_object WP_Post/WC_Order object to get ID for.
	 *
	 * @return int Order or post ID.
	 */
	public static function get_post_or_order_id( $post_or_order_object ) : int {
		return wc_get_container()->get( COTMigrationUtil::class )->get_post_or_order_id( $post_or_order_object );
	}

	/**
	 * Checks if passed id, post or order object is a WC_Order object.
	 *
	 * @param int|WP_Post|WC_Order $order_id Order ID, post object or order object.
	 * @param string[]             $types    Types to match against.
	 *
	 * @return bool Whether the passed param is an order.
	 */
	public static function is_order( $order_id, $types = array( 'shop_order' ) ) {
		return wc_get_container()->get( COTMigrationUtil::class )->is_order( $order_id, $types );
	}

	/**
	 * Returns type pf passed id, post or order object.
	 *
	 * @param int|WP_Post|WC_Order $order_id Order ID, post object or order object.
	 *
	 * @return string|null Type of the order.
	 */
	public static function get_order_type( $order_id ) {
		return wc_get_container()->get( COTMigrationUtil::class )->get_order_type( $order_id );
	}

	/**
	 * Helper method to generate admin url for an order.
	 *
	 * @param int $order_id Order ID.
	 *
	 * @return string Admin url for an order.
	 */
	public static function get_order_admin_edit_url( int $order_id ) : string {
		return wc_get_container()->get( PageController::class )->get_edit_url( $order_id );
	}

	/**
	 * Helper method to generate admin URL for new order.
	 *
	 * @return string Link for new order.
	 */
	public static function get_order_admin_new_url() : string {
		return wc_get_container()->get( PageController::class )->get_new_page_url();
	}

	/**
	 * Check if the current admin screen is an order list table.
	 *
	 * @param string $order_type Optional. The order type to check for. Default shop_order.
	 *
	 * @return bool
	 */
	public static function is_order_list_table_screen( $order_type = 'shop_order' ) : bool {
		return wc_get_container()->get( PageController::class )->is_order_screen( $order_type, 'list' );
	}

	/**
	 * Check if the current admin screen is for editing an order.
	 *
	 * @param string $order_type Optional. The order type to check for. Default shop_order.
	 *
	 * @return bool
	 */
	public static function is_order_edit_screen( $order_type = 'shop_order' ) : bool {
		return wc_get_container()->get( PageController::class )->is_order_screen( $order_type, 'edit' );
	}

	/**
	 * Check if the current admin screen is adding a new order.
	 *
	 * @param string $order_type Optional. The order type to check for. Default shop_order.
	 *
	 * @return bool
	 */
	public static function is_new_order_screen( $order_type = 'shop_order' ) : bool {
		return wc_get_container()->get( PageController::class )->is_order_screen( $order_type, 'new' );
	}

	/**
	 * Get the name of the database table that's currently in use for orders.
	 *
	 * @return string
	 */
	public static function get_table_for_orders() {
		return wc_get_container()->get( COTMigrationUtil::class )->get_table_for_orders();
	}

	/**
	 * Get the name of the database table that's currently in use for orders.
	 *
	 * @return string
	 */
	public static function get_table_for_order_meta() {
		return wc_get_container()->get( COTMigrationUtil::class )->get_table_for_order_meta();
	}
}
PluginUtil.php000064400000017227151536754370007402 0ustar00<?php
/**
 * A class of utilities for dealing with plugins.
 */

namespace Automattic\WooCommerce\Utilities;

use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use Automattic\WooCommerce\Proxies\LegacyProxy;

/**
 * A class of utilities for dealing with plugins.
 */
class PluginUtil {

	use AccessiblePrivateMethods;

	/**
	 * The LegacyProxy instance to use.
	 *
	 * @var LegacyProxy
	 */
	private $proxy;

	/**
	 * The cached list of WooCommerce aware plugin ids.
	 *
	 * @var null|array
	 */
	private $woocommerce_aware_plugins = null;

	/**
	 * The cached list of enabled WooCommerce aware plugin ids.
	 *
	 * @var null|array
	 */
	private $woocommerce_aware_active_plugins = null;

	/**
	 * Creates a new instance of the class.
	 */
	public function __construct() {
		self::add_action( 'activated_plugin', array( $this, 'handle_plugin_de_activation' ), 10, 0 );
		self::add_action( 'deactivated_plugin', array( $this, 'handle_plugin_de_activation' ), 10, 0 );
	}

	/**
	 * Initialize the class instance.
	 *
	 * @internal
	 *
	 * @param LegacyProxy $proxy The instance of LegacyProxy to use.
	 */
	final public function init( LegacyProxy $proxy ) {
		$this->proxy = $proxy;
		require_once ABSPATH . WPINC . '/plugin.php';
	}

	/**
	 * Get a list with the names of the WordPress plugins that are WooCommerce aware
	 * (they have a "WC tested up to" header).
	 *
	 * @param bool $active_only True to return only active plugins, false to return all the active plugins.
	 * @return string[] A list of plugin ids (path/file.php).
	 */
	public function get_woocommerce_aware_plugins( bool $active_only = false ): array {
		if ( is_null( $this->woocommerce_aware_plugins ) ) {
			// In case `get_plugins` was called much earlier in the request (before our headers could be injected), we
			// invalidate the plugin cache list.
			wp_cache_delete( 'plugins', 'plugins' );
			$all_plugins = $this->proxy->call_function( 'get_plugins' );

			$this->woocommerce_aware_plugins =
				array_keys(
					array_filter(
						$all_plugins,
						array( $this, 'is_woocommerce_aware_plugin' )
					)
				);

			$this->woocommerce_aware_active_plugins =
				array_values(
					array_filter(
						$this->woocommerce_aware_plugins,
						function ( $plugin_name ) {
							return $this->proxy->call_function( 'is_plugin_active', $plugin_name );
						}
					)
				);
		}

		return $active_only ? $this->woocommerce_aware_active_plugins : $this->woocommerce_aware_plugins;
	}

	/**
	 * Get the printable name of a plugin.
	 *
	 * @param string $plugin_id Plugin id (path/file.php).
	 * @return string Printable plugin name, or the plugin id itself if printable name is not available.
	 */
	public function get_plugin_name( string $plugin_id ): string {
		$plugin_data = $this->proxy->call_function( 'get_plugin_data', WP_PLUGIN_DIR . DIRECTORY_SEPARATOR . $plugin_id );
		return $plugin_data['Name'] ?? $plugin_id;
	}

	/**
	 * Check if a plugin is WooCommerce aware.
	 *
	 * @param string|array $plugin_file_or_data Plugin id (path/file.php) or plugin data (as returned by get_plugins).
	 * @return bool True if the plugin exists and is WooCommerce aware.
	 * @throws \Exception The input is neither a string nor an array.
	 */
	public function is_woocommerce_aware_plugin( $plugin_file_or_data ): bool {
		if ( is_string( $plugin_file_or_data ) ) {
			return in_array( $plugin_file_or_data, $this->get_woocommerce_aware_plugins(), true );
		} elseif ( is_array( $plugin_file_or_data ) ) {
			return '' !== ( $plugin_file_or_data['WC tested up to'] ?? '' );
		} else {
			throw new \Exception( 'is_woocommerce_aware_plugin requires a plugin name or an array of plugin data as input' );
		}
	}

	/**
	 * Match plugin identifier passed as a parameter with the output from `get_plugins()`.
	 *
	 * @param string $plugin_file Plugin identifier, either 'my-plugin/my-plugin.php', or output from __FILE__.
	 *
	 * @return string|false Key from the array returned by `get_plugins` if matched. False if no match.
	 */
	public function get_wp_plugin_id( $plugin_file ) {
		$wp_plugins = array_keys( $this->proxy->call_function( 'get_plugins' ) );

		// Try to match plugin_basename().
		$plugin_basename = $this->proxy->call_function( 'plugin_basename', $plugin_file );
		if ( in_array( $plugin_basename, $wp_plugins, true ) ) {
			return $plugin_basename;
		}

		// Try to match by the my-file/my-file.php (dir + file name), then by my-file.php (file name only).
		$plugin_file     = str_replace( array( '\\', '/' ), DIRECTORY_SEPARATOR, $plugin_file );
		$file_name_parts = explode( DIRECTORY_SEPARATOR, $plugin_file );
		$file_name       = array_pop( $file_name_parts );
		$directory_name  = array_pop( $file_name_parts );
		$full_matches    = array();
		$partial_matches = array();
		foreach ( $wp_plugins as $wp_plugin ) {
			if ( false !== strpos( $wp_plugin, $directory_name . DIRECTORY_SEPARATOR . $file_name ) ) {
				$full_matches[] = $wp_plugin;
			}

			if ( false !== strpos( $wp_plugin, $file_name ) ) {
				$partial_matches[] = $wp_plugin;
			}
		}

		if ( 1 === count( $full_matches ) ) {
			return $full_matches[0];
		}

		if ( 1 === count( $partial_matches ) ) {
			return $partial_matches[0];
		}

		return false;
	}

	/**
	 * Handle plugin activation and deactivation by clearing the WooCommerce aware plugin ids cache.
	 */
	private function handle_plugin_de_activation(): void {
		$this->woocommerce_aware_plugins        = null;
		$this->woocommerce_aware_active_plugins = null;
	}

	/**
	 * Util function to generate warning string for incompatible features based on active plugins.
	 *
	 * @param string $feature_id Feature id.
	 * @param array  $plugin_feature_info Array of plugin feature info. See FeaturesControllers->get_compatible_plugins_for_feature() for details.
	 *
	 * @return string Warning string.
	 */
	public function generate_incompatible_plugin_feature_warning( string $feature_id, array $plugin_feature_info ) : string {
		$feature_warning    = '';
		$incompatibles      = array_merge( $plugin_feature_info['incompatible'], $plugin_feature_info['uncertain'] );
		$incompatibles      = array_filter( $incompatibles, 'is_plugin_active' );
		$incompatible_count = count( $incompatibles );
		if ( $incompatible_count > 0 ) {
			if ( 1 === $incompatible_count ) {
				/* translators: %s = printable plugin name */
				$feature_warning = sprintf( __( '⚠ 1 Incompatible plugin detected (%s).', 'woocommerce' ), $this->get_plugin_name( $incompatibles[0] ) );
			} elseif ( 2 === $incompatible_count ) {
				$feature_warning = sprintf(
					/* translators: %1\$s, %2\$s = printable plugin names */
					__( '⚠ 2 Incompatible plugins detected (%1$s and %2$s).', 'woocommerce' ),
					$this->get_plugin_name( $incompatibles[0] ),
					$this->get_plugin_name( $incompatibles[1] )
				);
			} else {

				$feature_warning = sprintf(
					/* translators: %1\$s, %2\$s = printable plugin names, %3\$d = plugins count */
					_n(
						'⚠ Incompatible plugins detected (%1$s, %2$s and %3$d other).',
						'⚠ Incompatible plugins detected (%1$s and %2$s plugins and %3$d others).',
						$incompatible_count - 2,
						'woocommerce'
					),
					$this->get_plugin_name( $incompatibles[0] ),
					$this->get_plugin_name( $incompatibles[1] ),
					$incompatible_count - 2
				);
			}

			$incompatible_plugins_url = add_query_arg(
				array(
					'plugin_status' => 'incompatible_with_feature',
					'feature_id'    => $feature_id,
				),
				admin_url( 'plugins.php' )
			);
			$extra_desc_tip           = '<br>' . sprintf(
				/* translators: %1$s opening link tag %2$s closing link tag. */
				__( '%1$sView and manage%2$s', 'woocommerce' ),
				'<a href="' . esc_url( $incompatible_plugins_url ) . '">',
				'</a>'
			);

			$feature_warning .= $extra_desc_tip;

		}

		return $feature_warning;
	}
}
StringUtil.php000064400000011156151536754370007405 0ustar00<?php
/**
 * A class of utilities for dealing with strings.
 */

namespace Automattic\WooCommerce\Utilities;

/**
 * A class of utilities for dealing with strings.
 */
final class StringUtil {

	/**
	 * Checks to see whether or not a string starts with another.
	 *
	 * @param string $string The string we want to check.
	 * @param string $starts_with The string we're looking for at the start of $string.
	 * @param bool   $case_sensitive Indicates whether the comparison should be case-sensitive.
	 *
	 * @return bool True if the $string starts with $starts_with, false otherwise.
	 */
	public static function starts_with( string $string, string $starts_with, bool $case_sensitive = true ): bool {
		$len = strlen( $starts_with );
		if ( $len > strlen( $string ) ) {
			return false;
		}

		$string = substr( $string, 0, $len );

		if ( $case_sensitive ) {
			return strcmp( $string, $starts_with ) === 0;
		}

		return strcasecmp( $string, $starts_with ) === 0;
	}

	/**
	 * Checks to see whether or not a string ends with another.
	 *
	 * @param string $string The string we want to check.
	 * @param string $ends_with The string we're looking for at the end of $string.
	 * @param bool   $case_sensitive Indicates whether the comparison should be case-sensitive.
	 *
	 * @return bool True if the $string ends with $ends_with, false otherwise.
	 */
	public static function ends_with( string $string, string $ends_with, bool $case_sensitive = true ): bool {
		$len = strlen( $ends_with );
		if ( $len > strlen( $string ) ) {
			return false;
		}

		$string = substr( $string, -$len );

		if ( $case_sensitive ) {
			return strcmp( $string, $ends_with ) === 0;
		}

		return strcasecmp( $string, $ends_with ) === 0;
	}

	/**
	 * Checks if one string is contained into another at any position.
	 *
	 * @param string $string The string we want to check.
	 * @param string $contained The string we're looking for inside $string.
	 * @param bool   $case_sensitive Indicates whether the comparison should be case-sensitive.
	 * @return bool True if $contained is contained inside $string, false otherwise.
	 */
	public static function contains( string $string, string $contained, bool $case_sensitive = true ): bool {
		if ( $case_sensitive ) {
			return false !== strpos( $string, $contained );
		} else {
			return false !== stripos( $string, $contained );
		}
	}

	/**
	 * Get the name of a plugin in the form 'directory/file.php', as in the keys of the array returned by 'get_plugins'.
	 *
	 * @param string $plugin_file_path The path of the main plugin file (can be passed as __FILE__ from the plugin itself).
	 * @return string The name of the plugin in the form 'directory/file.php'.
	 */
	public static function plugin_name_from_plugin_file( string $plugin_file_path ): string {
		return basename( dirname( $plugin_file_path ) ) . DIRECTORY_SEPARATOR . basename( $plugin_file_path );
	}

	/**
	 * Check if a string is null or is empty.
	 *
	 * @param string|null $value The string to check.
	 * @return bool True if the string is null or is empty.
	 */
	public static function is_null_or_empty( ?string $value ) {
		return is_null( $value ) || '' === $value;
	}

	/**
	 * Check if a string is null, is empty, or has only whitespace characters
	 * (space, tab, vertical tab, form feed, carriage return, new line)
	 *
	 * @param string|null $value The string to check.
	 * @return bool True if the string is null, is empty, or contains only whitespace characters.
	 */
	public static function is_null_or_whitespace( ?string $value ) {
		return is_null( $value ) || '' === $value || ctype_space( $value );
	}

	/**
	 * Convert an array of values to a list suitable for a SQL "IN" statement
	 * (so comma separated and delimited by parenthesis).
	 * e.g.: [1,2,3] --> (1,2,3)
	 *
	 * @param array $values The values to convert.
	 * @return string A parenthesized and comma-separated string generated from the values.
	 * @throws \InvalidArgumentException Empty values array passed.
	 */
	public static function to_sql_list( array $values ) {
		if ( empty( $values ) ) {
			throw new \InvalidArgumentException( self::class_name_without_namespace( __CLASS__ ) . '::' . __FUNCTION__ . ': the values array is empty' );
		}

		return '(' . implode( ',', $values ) . ')';
	}

	/**
	 * Get the name of a class without the namespace.
	 *
	 * @param string $class_name The full class name.
	 * @return string The class name without the namespace.
	 */
	public static function class_name_without_namespace( string $class_name ) {
		// A '?:' would convert this to a one-liner, but WP coding standards disallow these :shrug:.
		$result = substr( strrchr( $class_name, '\\' ), 1 );
		return $result ? $result : $class_name;
	}
}
BlocksUtil.php000064400000004275151542630320007341 0ustar00<?php

namespace Automattic\WooCommerce\Internal\Utilities;

/**
 * Helper functions for working with blocks.
 */
class BlocksUtil {

	/**
	 * Return blocks with their inner blocks flattened.
	 *
	 * @param array $blocks Array of blocks as returned by parse_blocks().
	 * @return array All blocks.
	 */
	public static function flatten_blocks( $blocks ) {
		return array_reduce(
			$blocks,
			function( $carry, $block ) {
				array_push( $carry, array_diff_key( $block, array_flip( array( 'innerBlocks' ) ) ) );
				if ( isset( $block['innerBlocks'] ) ) {
					$inner_blocks = self::flatten_blocks( $block['innerBlocks'] );
					return array_merge( $carry, $inner_blocks );
				}

				return $carry;
			},
			array()
		);
	}

	/**
	 * Get all instances of the specified block from the widget area.
	 *
	 * @param array $block_name The name (id) of a block, e.g. `woocommerce/mini-cart`.
	 * @return array Array of blocks as returned by parse_blocks().
	 */
	public static function get_blocks_from_widget_area( $block_name ) {
		return array_reduce(
			get_option( 'widget_block' ),
			function ( $acc, $block ) use ( $block_name ) {
				$parsed_blocks = ! empty( $block ) && is_array( $block ) ? parse_blocks( $block['content'] ) : array();
				if ( ! empty( $parsed_blocks ) && $block_name === $parsed_blocks[0]['blockName'] ) {
					array_push( $acc, $parsed_blocks[0] );
					return $acc;
				}
				return $acc;
			},
			array()
		);
	}

	/**
	 * Get all instances of the specified block on a specific template part.
	 *
	 * @param string $block_name The name (id) of a block, e.g. `woocommerce/mini-cart`.
	 * @param string $template_part_slug The woo page to search, e.g. `header`.
	 * @return array Array of blocks as returned by parse_blocks().
	 */
	public static function get_block_from_template_part( $block_name, $template_part_slug ) {
		$template = get_block_template( get_stylesheet() . '//' . $template_part_slug, 'wp_template_part' );
		$blocks   = parse_blocks( $template->content );

		$flatten_blocks = self::flatten_blocks( $blocks );

		return array_values(
			array_filter(
				$flatten_blocks,
				function ( $block ) use ( $block_name ) {
					return ( $block_name === $block['blockName'] );
				}
			)
		);
	}
}
COTMigrationUtil.php000064400000014072151542630320010417 0ustar00<?php
/**
 * Utility functions meant for helping in migration from posts tables to custom order tables.
 */

namespace Automattic\WooCommerce\Internal\Utilities;

use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\DataStores\Orders\{ DataSynchronizer, OrdersTableDataStore };
use WC_Order;
use WP_Post;

/**
 * Utility functions meant for helping in migration from posts tables to custom order tables.
 */
class COTMigrationUtil {

	/**
	 * Custom order table controller.
	 *
	 * @var CustomOrdersTableController
	 */
	private $table_controller;

	/**
	 * Data synchronizer.
	 *
	 * @var DataSynchronizer
	 */
	private $data_synchronizer;

	/**
	 * Initialize method, invoked by the DI container.
	 *
	 * @internal Automatically called by the container.
	 * @param CustomOrdersTableController $table_controller Custom order table controller.
	 * @param DataSynchronizer            $data_synchronizer Data synchronizer.
	 *
	 * @return void
	 */
	final public function init( CustomOrdersTableController $table_controller, DataSynchronizer $data_synchronizer ) {
		$this->table_controller  = $table_controller;
		$this->data_synchronizer = $data_synchronizer;
	}

	/**
	 * Helper function to get screen name of orders page in wp-admin.
	 *
	 * @throws \Exception If called from outside of wp-admin.
	 *
	 * @return string
	 */
	public function get_order_admin_screen() : string {
		if ( ! is_admin() ) {
			throw new \Exception( 'This function should only be called in admin.' );
		}
		return $this->custom_orders_table_usage_is_enabled() && function_exists( 'wc_get_page_screen_id' )
			? wc_get_page_screen_id( 'shop-order' )
			: 'shop_order';
	}

	/**
	 * Helper function to get whether custom order tables are enabled or not.
	 *
	 * @return bool
	 */
	private function custom_orders_table_usage_is_enabled() : bool {
		return $this->table_controller->custom_orders_table_usage_is_enabled();
	}

	/**
	 * Checks if posts and order custom table sync is enabled and there are no pending orders.
	 *
	 * @return bool
	 */
	public function is_custom_order_tables_in_sync() : bool {
		$sync_status = $this->data_synchronizer->get_sync_status();
		return 0 === $sync_status['current_pending_count'] && $this->data_synchronizer->data_sync_is_enabled();
	}

	/**
	 * Gets value of a meta key from WC_Data object if passed, otherwise from the post object.
	 * This helper function support backward compatibility for meta box functions, when moving from posts based store to custom tables.
	 *
	 * @param WP_Post|null  $post Post object, meta will be fetched from this only when `$data` is not passed.
	 * @param \WC_Data|null $data WC_Data object, will be preferred over post object when passed.
	 * @param string        $key Key to fetch metadata for.
	 * @param bool          $single Whether metadata is single.
	 *
	 * @return array|mixed|string Value of the meta key.
	 */
	public function get_post_or_object_meta( ?WP_Post $post, ?\WC_Data $data, string $key, bool $single ) {
		if ( isset( $data ) ) {
			if ( method_exists( $data, "get$key" ) ) {
				return $data->{"get$key"}();
			}
			return $data->get_meta( $key, $single );
		} else {
			return isset( $post->ID ) ? get_post_meta( $post->ID, $key, $single ) : false;
		}
	}

	/**
	 * Helper function to initialize the global $theorder object, mostly used during order meta boxes rendering.
	 *
	 * @param WC_Order|WP_Post $post_or_order_object Post or order object.
	 *
	 * @return bool|WC_Order|WC_Order_Refund WC_Order object.
	 */
	public function init_theorder_object( $post_or_order_object ) {
		global $theorder;
		if ( $theorder instanceof WC_Order ) {
			return $theorder;
		}

		if ( $post_or_order_object instanceof WC_Order ) {
			$theorder = $post_or_order_object;
		} else {
			$theorder = wc_get_order( $post_or_order_object->ID );
		}
		return $theorder;
	}

	/**
	 * Helper function to get ID from a post or order object.
	 *
	 * @param WP_Post/WC_Order $post_or_order_object WP_Post/WC_Order object to get ID for.
	 *
	 * @return int Order or post ID.
	 */
	public function get_post_or_order_id( $post_or_order_object ) : int {
		if ( is_numeric( $post_or_order_object ) ) {
			return (int) $post_or_order_object;
		} elseif ( $post_or_order_object instanceof WC_Order ) {
			return $post_or_order_object->get_id();
		} elseif ( $post_or_order_object instanceof WP_Post ) {
			return $post_or_order_object->ID;
		}
		return 0;
	}

	/**
	 * Checks if passed id, post or order object is a WC_Order object.
	 *
	 * @param int|WP_Post|WC_Order $order_id Order ID, post object or order object.
	 * @param string[]             $types    Types to match against.
	 *
	 * @return bool Whether the passed param is an order.
	 */
	public function is_order( $order_id, array $types = array( 'shop_order' ) ) : bool {
		$order_id         = $this->get_post_or_order_id( $order_id );
		$order_data_store = \WC_Data_Store::load( 'order' );
		return in_array( $order_data_store->get_order_type( $order_id ), $types, true );
	}

	/**
	 * Returns type pf passed id, post or order object.
	 *
	 * @param int|WP_Post|WC_Order $order_id Order ID, post object or order object.
	 *
	 * @return string|null Type of the order.
	 */
	public function get_order_type( $order_id ) {
		$order_id         = $this->get_post_or_order_id( $order_id );
		$order_data_store = \WC_Data_Store::load( 'order' );
		return $order_data_store->get_order_type( $order_id );
	}

	/**
	 * Get the name of the database table that's currently in use for orders.
	 *
	 * @return string
	 */
	public function get_table_for_orders() {
		if ( $this->custom_orders_table_usage_is_enabled() ) {
			$table_name = OrdersTableDataStore::get_orders_table_name();
		} else {
			global $wpdb;
			$table_name = $wpdb->posts;
		}

		return $table_name;
	}

	/**
	 * Get the name of the database table that's currently in use for orders.
	 *
	 * @return string
	 */
	public function get_table_for_order_meta() {
		if ( $this->custom_orders_table_usage_is_enabled() ) {
			$table_name = OrdersTableDataStore::get_meta_table_name();
		} else {
			global $wpdb;
			$table_name = $wpdb->postmeta;
		}

		return $table_name;
	}
}
DatabaseUtil.php000064400000023674151542630320007634 0ustar00<?php
/**
 * DatabaseUtil class file.
 */

namespace Automattic\WooCommerce\Internal\Utilities;

use DateTime;
use DateTimeZone;

/**
 * A class of utilities for dealing with the database.
 */
class DatabaseUtil {

	/**
	 * Wrapper for the WordPress dbDelta function, allows to execute a series of SQL queries.
	 *
	 * @param string $queries The SQL queries to execute.
	 * @param bool   $execute Ture to actually execute the queries, false to only simulate the execution.
	 * @return array The result of the execution (or simulation) from dbDelta.
	 */
	public function dbdelta( string $queries = '', bool $execute = true ): array {
		require_once ABSPATH . 'wp-admin/includes/upgrade.php';

		return dbDelta( $queries, $execute );
	}

	/**
	 * Given a set of table creation SQL statements, check which of the tables are currently missing in the database.
	 *
	 * @param string $creation_queries The SQL queries to execute ("CREATE TABLE" statements, same format as for dbDelta).
	 * @return array An array containing the names of the tables that currently don't exist in the database.
	 */
	public function get_missing_tables( string $creation_queries ): array {
		global $wpdb;
		$suppress_errors = $wpdb->suppress_errors( true );
		$dbdelta_output  = $this->dbdelta( $creation_queries, false );
		$wpdb->suppress_errors( $suppress_errors );
		$parsed_output = $this->parse_dbdelta_output( $dbdelta_output );
		return $parsed_output['created_tables'];
	}

	/**
	 * Parses the output given by dbdelta and returns information about it.
	 *
	 * @param array $dbdelta_output The output from the execution of dbdelta.
	 * @return array[] An array containing a 'created_tables' key whose value is an array with the names of the tables that have been (or would have been) created.
	 */
	public function parse_dbdelta_output( array $dbdelta_output ): array {
		$created_tables = array();

		foreach ( $dbdelta_output as $table_name => $result ) {
			if ( "Created table $table_name" === $result ) {
				$created_tables[] = str_replace( '(', '', $table_name );
			}
		}

		return array( 'created_tables' => $created_tables );
	}

	/**
	 * Drops a database table.
	 *
	 * @param string $table_name The name of the table to drop.
	 * @param bool   $add_prefix True if the table name passed needs to be prefixed with $wpdb->prefix before processing.
	 * @return bool True on success, false on error.
	 */
	public function drop_database_table( string $table_name, bool $add_prefix = false ) {
		global $wpdb;

		if ( $add_prefix ) {
			$table_name = $wpdb->prefix . $table_name;
		}

		//phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		return $wpdb->query( "DROP TABLE IF EXISTS `{$table_name}`" );
	}

	/**
	 * Drops a table index, if both the table and the index exist.
	 *
	 * @param string $table_name The name of the table that contains the index.
	 * @param string $index_name The name of the index to be dropped.
	 * @return bool True if the index has been dropped, false if either the table or the index don't exist.
	 */
	public function drop_table_index( string $table_name, string $index_name ): bool {
		global $wpdb;

		if ( empty( $this->get_index_columns( $table_name, $index_name ) ) ) {
			return false;
		}

		// phpcs:ignore WordPress.DB.PreparedSQL
		$wpdb->query( "ALTER TABLE $table_name DROP INDEX $index_name" );
		return true;
	}

	/**
	 * Create a primary key for a table, only if the table doesn't have a primary key already.
	 *
	 * @param string $table_name Table name.
	 * @param array  $columns An array with the index column names.
	 * @return bool True if the key has been created, false if the table already had a primary key.
	 */
	public function create_primary_key( string $table_name, array $columns ) {
		global $wpdb;

		if ( ! empty( $this->get_index_columns( $table_name ) ) ) {
			return false;
		}

		// phpcs:ignore WordPress.DB.PreparedSQL
		$wpdb->query( "ALTER TABLE $table_name ADD PRIMARY KEY(`" . join( '`,`', $columns ) . '`)' );
		return true;
	}

	/**
	 * Get the columns of a given table index, or of the primary key.
	 *
	 * @param string $table_name Table name.
	 * @param string $index_name Index name, empty string for the primary key.
	 * @return array The index columns. Empty array if the table or the index don't exist.
	 */
	public function get_index_columns( string $table_name, string $index_name = '' ): array {
		global $wpdb;

		if ( empty( $index_name ) ) {
			$index_name = 'PRIMARY';
		}

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$results = $wpdb->get_results( $wpdb->prepare( "SHOW INDEX FROM $table_name WHERE Key_name = %s", $index_name ) );

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

		return array_column( $results, 'Column_name' );
	}

	/**
	 * Formats an object value of type `$type` for inclusion in the database.
	 *
	 * @param mixed  $value Raw value.
	 * @param string $type  Data type.
	 * @return mixed
	 * @throws \Exception When an invalid type is passed.
	 */
	public function format_object_value_for_db( $value, string $type ) {
		switch ( $type ) {
			case 'decimal':
				$value = wc_format_decimal( $value, false, true );
				break;
			case 'int':
				$value = (int) $value;
				break;
			case 'bool':
				$value = wc_string_to_bool( $value );
				break;
			case 'string':
				$value = strval( $value );
				break;
			case 'date':
				// Date properties are converted to the WP timezone (see WC_Data::set_date_prop() method), however
				// for our own tables we persist dates in GMT.
				$value = $value ? ( new DateTime( $value ) )->setTimezone( new DateTimeZone( '+00:00' ) )->format( 'Y-m-d H:i:s' ) : null;
				break;
			case 'date_epoch':
				$value = $value ? ( new DateTime( "@{$value}" ) )->format( 'Y-m-d H:i:s' ) : null;
				break;
			default:
				throw new \Exception( 'Invalid type received: ' . $type );
		}

		return $value;
	}

	/**
	 * Returns the `$wpdb` placeholder to use for data type `$type`.
	 *
	 * @param string $type Data type.
	 * @return string
	 * @throws \Exception When an invalid type is passed.
	 */
	public function get_wpdb_format_for_type( string $type ) {
		static $wpdb_placeholder_for_type = array(
			'int'        => '%d',
			'decimal'    => '%f',
			'string'     => '%s',
			'date'       => '%s',
			'date_epoch' => '%s',
			'bool'       => '%d',
		);

		if ( ! isset( $wpdb_placeholder_for_type[ $type ] ) ) {
			throw new \Exception( 'Invalid column type: ' . $type );
		}

		return $wpdb_placeholder_for_type[ $type ];
	}

	/**
	 * Generates ON DUPLICATE KEY UPDATE clause to be used in migration.
	 *
	 * @param array $columns List of column names.
	 *
	 * @return string SQL clause for INSERT...ON DUPLICATE KEY UPDATE
	 */
	public function generate_on_duplicate_statement_clause( array $columns ): string {
		$update_value_statements = array();
		foreach ( $columns as $column ) {
			$update_value_statements[] = "`$column` = VALUES( `$column` )";
		}
		$update_value_clause = implode( ', ', $update_value_statements );

		return "ON DUPLICATE KEY UPDATE $update_value_clause";
	}

	/**
	 * Hybrid of $wpdb->update and $wpdb->insert. It will try to update a row, and if it doesn't exist, it will insert it. This needs unique constraints to be set on the table on all ID columns.
	 *
	 * You can use this function only when:
	 * 1. There is only one unique constraint on the table. The constraint can contain multiple columns, but it must be the only one unique constraint.
	 * 2. The complete unique constraint must be part of the $data array.
	 * 3. You do not need the LAST_INSERT_ID() value.
	 *
	 * @param string $table_name Table name.
	 * @param array  $data Unescaped data to update (in column => value pairs).
	 * @param array  $format An array of formats to be mapped to each of the values in $data.
	 *
	 * @return int Returns the value of DB's  ON DUPLICATE KEY UPDATE clause.
	 */
	public function insert_on_duplicate_key_update( $table_name, $data, $format ) : int {
		global $wpdb;
		if ( empty( $data ) ) {
			return 0;
		}

		$columns      = array_keys( $data );
		$value_format = array();
		$values       = array();
		$index        = 0;
		// Directly use NULL for placeholder if the value is NULL, since otherwise $wpdb->prepare will convert it to empty string.
		foreach ( $data as $key => $value ) {
			if ( is_null( $value ) ) {
				$value_format[] = 'NULL';
			} else {
				$values[]       = $value;
				$value_format[] = $format[ $index ];
			}
			$index++;
		}
		$column_clause       = '`' . implode( '`, `', $columns ) . '`';
		$value_format_clause = implode( ', ', $value_format );
		$on_duplicate_clause = $this->generate_on_duplicate_statement_clause( $columns );
		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Values are escaped in $wpdb->prepare.
		$sql = $wpdb->prepare(
			"
INSERT INTO $table_name ( $column_clause )
VALUES ( $value_format_clause )
$on_duplicate_clause
",
			$values
		);
		// phpcs:enable
		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $sql is prepared.
		return $wpdb->query( $sql );
	}

	/**
	 * Get max index length.
	 *
	 * @return int Max index length.
	 */
	public function get_max_index_length() : int {
		/**
		 * Filters the maximum index length in the database.
		 *
		 * Indexes have a maximum size of 767 bytes. Historically, we haven't need to be concerned about that.
		 * As of WP 4.2, however, they moved to utf8mb4, which uses 4 bytes per character. This means that an index which
		 * used to have room for floor(767/3) = 255 characters, now only has room for floor(767/4) = 191 characters.
		 *
		 * Additionally, MyISAM engine also limits the index size to 1000 bytes. We add this filter so that interested folks on InnoDB engine can increase the size till allowed 3071 bytes.
		 *
		 * @param int $max_index_length Maximum index length. Default 191.
		 *
		 * @since 8.0.0
		 */
		$max_index_length = apply_filters( 'woocommerce_database_max_index_length', 191 );
		// Index length cannot be more than 768, which is 3078 bytes in utf8mb4 and max allowed by InnoDB engine.
		return min( absint( $max_index_length ), 767 );
	}
}
HtmlSanitizer.php000064400000005321151542630320010054 0ustar00<?php

namespace Automattic\WooCommerce\Internal\Utilities;

/**
 * Utility for re-using WP Kses-based sanitization rules.
 */
class HtmlSanitizer {
	/**
	 * Rules for allowing minimal HTML (breaks, images, paragraphs and spans) without any links.
	 */
	public const LOW_HTML_BALANCED_TAGS_NO_LINKS = array(
		'pre_processors'  => array(
			'stripslashes',
			'force_balance_tags',
		),
		'wp_kses_rules'   => array(
			'br'   => true,
			'img'  => array(
				'alt'   => true,
				'class' => true,
				'src'   => true,
				'title' => true,
			),
			'p'    => array(
				'class' => true,
			),
			'span' => array(
				'class' => true,
				'title' => true,
			),
		),
	);

	/**
	 * Sanitizes the HTML according to the provided rules.
	 *
	 * @see wp_kses()
	 *
	 * @param string $html HTML string to be sanitized.
	 * @param array  $sanitizer_rules {
	 *     Optional and defaults to self::TRIMMED_BALANCED_LOW_HTML_NO_LINKS. Otherwise, one or more of the following
	 *     keys should be set.
	 *
	 *     @type array $pre_processors  Callbacks to run before invoking `wp_kses()`.
	 *     @type array $wp_kses_rules   Element names and attributes to allow, per `wp_kses()`.
	 * }
	 *
	 * @return string
	 */
	public function sanitize( string $html, array $sanitizer_rules = self::LOW_HTML_BALANCED_TAGS_NO_LINKS ): string {
		if ( isset( $sanitizer_rules['pre_processors'] ) && is_array( $sanitizer_rules['pre_processors'] ) ) {
			$html = $this->apply_string_callbacks( $sanitizer_rules['pre_processors'], $html );
		}

		// If no KSES rules are specified, assume all HTML should be stripped.
		$kses_rules = isset( $sanitizer_rules['wp_kses_rules'] ) && is_array( $sanitizer_rules['wp_kses_rules'] )
			? $sanitizer_rules['wp_kses_rules']
			: array();

		return wp_kses( $html, $kses_rules );
	}

	/**
	 * Applies callbacks used to process the string before and after wp_kses().
	 *
	 * If a callback is invalid we will short-circuit and return an empty string, on the grounds that it is better to
	 * output nothing than risky HTML. We also call the problem out via _doing_it_wrong() to highlight the problem (and
	 * increase the chances of this being caught during development).
	 *
	 * @param callable[] $callbacks The callbacks used to mutate the string.
	 * @param string     $string    The string being processed.
	 *
	 * @return string
	 */
	private function apply_string_callbacks( array $callbacks, string $string ): string {
		foreach ( $callbacks as $callback ) {
			if ( ! is_callable( $callback ) ) {
				_doing_it_wrong( __CLASS__ . '::apply', esc_html__( 'String processors must be an array of valid callbacks.', 'woocommerce' ), esc_html( WC()->version ) );
				return '';
			}

			$string = (string) $callback( $string );
		}

		return $string;
	}
}
URL.php000064400000032143151542630320005723 0ustar00<?php

namespace Automattic\WooCommerce\Internal\Utilities;

/**
 * Provides an easy method of assessing URLs, including filepaths (which will be silently
 * converted to a file:// URL if provided).
 */
class URL {
	/**
	 * Components of the URL being assessed.
	 *
	 * The keys match those potentially returned by the parse_url() function, except
	 * that they are always defined and 'drive' (Windows drive letter) has been added.
	 *
	 * @var string|null[]
	 */
	private $components = array(
		'drive'    => null,
		'fragment' => null,
		'host'     => null,
		'pass'     => null,
		'path'     => null,
		'port'     => null,
		'query'    => null,
		'scheme'   => null,
		'user'     => null,
	);

	/**
	 * If the URL (or filepath) is absolute.
	 *
	 * @var bool
	 */
	private $is_absolute;

	/**
	 * If the URL (or filepath) represents a directory other than the root directory.
	 *
	 * This is useful at different points in the process, when deciding whether to re-apply
	 * a trailing slash at the end of processing or when we need to calculate how many
	 * directory traversals are needed to form a (grand-)parent URL.
	 *
	 * @var bool
	 */
	private $is_non_root_directory;

	/**
	 * The components of the URL's path.
	 *
	 * For instance, in the case of "file:///srv/www/wp.site" (noting that a file URL has
	 * no host component) this would contain:
	 *
	 *     [ "srv", "www", "wp.site" ]
	 *
	 * In the case of a non-file URL such as "https://example.com/foo/bar/baz" (noting the
	 * host is not part of the path) it would contain:
	 *
	 *    [ "foo", "bar", "baz" ]
	 *
	 * @var array
	 */
	private $path_parts = array();

	/**
	 * The URL.
	 *
	 * @var string
	 */
	private $url;

	/**
	 * Creates and processes the provided URL (or filepath).
	 *
	 * @throws URLException If the URL (or filepath) is seriously malformed.
	 *
	 * @param string $url The URL (or filepath).
	 */
	public function __construct( string $url ) {
		$this->url = $url;
		$this->preprocess();
		$this->process_path();
	}

	/**
	 * Makes all slashes forward slashes, converts filepaths to file:// URLs, and
	 * other processing to help with comprehension of filepaths.
	 *
	 * @throws URLException If the URL is seriously malformed.
	 */
	private function preprocess() {
		// For consistency, all slashes should be forward slashes.
		$this->url = str_replace( '\\', '/', $this->url );

		// Windows: capture the drive letter if provided.
		if ( preg_match( '#^(file://)?([a-z]):/(?!/).*#i', $this->url, $matches ) ) {
			$this->components['drive'] = $matches[2];
		}

		/*
		 * If there is no scheme, assume and prepend "file://". An exception is made for cases where the URL simply
		 * starts with exactly two forward slashes, which indicates 'any scheme' (most commonly, that is used when
		 * there is freedom to switch between 'http' and 'https').
		 */
		if ( ! preg_match( '#^[a-z]+://#i', $this->url ) && ! preg_match( '#^//(?!/)#', $this->url ) ) {
			$this->url = 'file://' . $this->url;
		}

		$parsed_components = wp_parse_url( $this->url );

		// If we received a really badly formed URL, let's go no further.
		if ( false === $parsed_components ) {
			throw new URLException(
				sprintf(
				/* translators: %s is the URL. */
					__( '%s is not a valid URL.', 'woocommerce' ),
					$this->url
				)
			);
		}

		$this->components = array_merge( $this->components, $parsed_components );

		// File URLs cannot have a host. However, the initial path segment *or* the Windows drive letter
		// (if present) may be incorrectly be interpreted as the host name.
		if ( 'file' === $this->components['scheme'] && ! empty( $this->components['host'] ) ) {
			// If we do not have a drive letter, then simply merge the host and the path together.
			if ( null === $this->components['drive'] ) {
				$this->components['path'] = $this->components['host'] . ( $this->components['path'] ?? '' );
			}

			// Restore the host to null in this situation.
			$this->components['host'] = null;
		}
	}

	/**
	 * Simplifies the path if possible, by resolving directory traversals to the extent possible
	 * without touching the filesystem.
	 */
	private function process_path() {
		$segments                    = explode( '/', $this->components['path'] );
		$this->is_absolute           = substr( $this->components['path'], 0, 1 ) === '/' || ! empty( $this->components['host'] );
		$this->is_non_root_directory = substr( $this->components['path'], -1, 1 ) === '/' && strlen( $this->components['path'] ) > 1;
		$resolve_traversals          = 'file' !== $this->components['scheme'] || $this->is_absolute;
		$retain_traversals           = false;

		// Clean the path.
		foreach ( $segments as $part ) {
			// Drop empty segments.
			if ( strlen( $part ) === 0 || '.' === $part ) {
				continue;
			}

			// Directory traversals created with percent-encoding syntax should also be detected.
			$is_traversal = str_ireplace( '%2e', '.', $part ) === '..';

			// Resolve directory traversals (if allowed: see further comment relating to this).
			if ( $resolve_traversals && $is_traversal ) {
				if ( count( $this->path_parts ) > 0 && ! $retain_traversals ) {
					$this->path_parts = array_slice( $this->path_parts, 0, count( $this->path_parts ) - 1 );
					continue;
				} elseif ( $this->is_absolute ) {
					continue;
				}
			}

			/*
			 * Consider allowing directory traversals to be resolved (ie, the process that converts 'foo/bar/../baz' to
			 * 'foo/baz').
			 *
			 * 1. For this decision point, we are only concerned with relative filepaths (in all other cases,
			 *    $resolve_traversals will already be true).
			 * 2. This is a 'one time' and unidirectional operation. We only wish to flip from false to true, and we
			 *    never wish to do this more than once.
			 * 3. We only flip the switch after we have examined all leading '..' traversal segments.
			 */
			if ( false === $resolve_traversals && '..' !== $part && 'file' === $this->components['scheme'] && ! $this->is_absolute ) {
				$resolve_traversals = true;
			}

			/*
			 * Set a flag indicating that traversals should be retained. This is done to ensure we don't prematurely
			 * discard traversals at the start of the path.
			 */
			$retain_traversals = $resolve_traversals && '..' === $part;

			// Retain this part of the path.
			$this->path_parts[] = $part;
		}

		// Protect against empty relative paths.
		if ( count( $this->path_parts ) === 0 && ! $this->is_absolute ) {
			$this->path_parts = array( '.' );
			$this->is_non_root_directory = true;
		}

		// Reform the path from the processed segments, appending a leading slash if it is absolute and restoring
		// the Windows drive letter if we have one.
		$this->components['path'] = ( $this->is_absolute ? '/' : '' ) . implode( '/', $this->path_parts ) . ( $this->is_non_root_directory ? '/' : '' );
	}

	/**
	 * Returns the processed URL as a string.
	 *
	 * @return string
	 */
	public function __toString(): string {
		return $this->get_url();
	}

	/**
	 * Returns all possible parent URLs for the current URL.
	 *
	 * @return string[]
	 */
	public function get_all_parent_urls(): array {
		$max_parent = count( $this->path_parts );
		$parents    = array();

		/*
		 * If we are looking at a relative path that begins with at least one traversal (example: "../../foo")
		 * then we should only return one parent URL (otherwise, we'd potentially have to return an infinite
		 * number of parent URLs since we can't know how far the tree extends).
		 */
		if ( $max_parent > 0 && ! $this->is_absolute && '..' === $this->path_parts[0] ) {
			$max_parent = 1;
		}

		for ( $level = 1; $level <= $max_parent; $level++ ) {
			$parents[] = $this->get_parent_url( $level );
		}

		return $parents;
	}

	/**
	 * Outputs the parent URL.
	 *
	 * For example, if $this->get_url() returns "https://example.com/foo/bar/baz" then
	 * this method will return "https://example.com/foo/bar/".
	 *
	 * When a grand-parent is needed, the optional $level parameter can be used. By default
	 * this is set to 1 (parent). 2 will yield the grand-parent, 3 will yield the great
	 * grand-parent, etc.
	 *
	 * If a level is specified that exceeds the number of path segments, this method will
	 * return false.
	 *
	 * @param int $level Used to indicate the level of parent.
	 *
	 * @return string|false
	 */
	public function get_parent_url( int $level = 1 ) {
		if ( $level < 1 ) {
			$level = 1;
		}

		$parts_count               = count( $this->path_parts );
		$parent_path_parts_to_keep = $parts_count - $level;

		/*
		 * With the exception of file URLs, we do not allow obtaining (grand-)parent directories that require
		 * us to describe them using directory traversals. For example, given "http://hostname/foo/bar/baz.png" we do
		 * not permit determining anything more than 2 levels up (we cannot go beyond "http://hostname/").
		 */
		if ( 'file' !== $this->components['scheme'] && $parent_path_parts_to_keep < 0 ) {
			return false;
		}

		// In the specific case of an absolute filepath describing the root directory, there can be no parent.
		if ( 'file' === $this->components['scheme'] && $this->is_absolute && empty( $this->path_parts ) ) {
			return false;
		}

		// Handle cases where the path starts with one or more 'dot segments'. Since the path has already been
		// processed, we can be confident that any such segments are at the start of the path.
		if ( $parts_count > 0 && ( '.' === $this->path_parts[0] || '..' === $this->path_parts[0] ) ) {
			// Determine the index of the last dot segment (ex: given the path '/../../foo' it would be 1).
			$single_dots   = array_keys( $this->path_parts, '.', true );
			$double_dots   = array_keys( $this->path_parts, '..', true );
			$max_dot_index = max( array_merge( $single_dots, $double_dots ) );

			// Prepend the required number of traversals and discard unnessary trailing segments.
			$last_traversal = $max_dot_index + ( $this->is_non_root_directory ? 1 : 0 );
			$parent_path    = str_repeat( '../', $level ) . join( '/', array_slice( $this->path_parts, 0, $last_traversal ) );
		} elseif ( $parent_path_parts_to_keep < 0 ) {
			// For relative filepaths only, we use traversals to describe the requested parent.
			$parent_path = untrailingslashit( str_repeat( '../', $parent_path_parts_to_keep * -1 ) );
		} else {
			// Otherwise, in a very simple case, we just remove existing parts.
			$parent_path = implode( '/', array_slice( $this->path_parts, 0, $parent_path_parts_to_keep ) );
		}

		if ( $this->is_relative() && '' === $parent_path ) {
			$parent_path = '.';
		}

		// Append a trailing slash, since a parent is always a directory. The only exception is the current working directory.
		$parent_path .= '/';

		// For absolute paths, apply a leading slash (does not apply if we have a root path).
		if ( $this->is_absolute && 0 !== strpos( $parent_path, '/' ) ) {
			$parent_path = '/' . $parent_path;
		}

		// Form the parent URL (ditching the query and fragment, if set).
		$parent_url = $this->get_url(
			array(
				'path'     => $parent_path,
				'query'    => null,
				'fragment' => null,
			)
		);

		// We process the parent URL through a fresh instance of this class, for consistency.
		return ( new self( $parent_url ) )->get_url();
	}

	/**
	 * Outputs the processed URL.
	 *
	 * Borrows from https://www.php.net/manual/en/function.parse-url.php#106731
	 *
	 * @param array $component_overrides If provided, these will override values set in $this->components.
	 *
	 * @return string
	 */
	public function get_url( array $component_overrides = array() ): string {
		$components = array_merge( $this->components, $component_overrides );

		$scheme = null !== $components['scheme'] ? $components['scheme'] . '://' : '//';
		$host   = null !== $components['host'] ? $components['host'] : '';
		$port   = null !== $components['port'] ? ':' . $components['port'] : '';
		$path   = $this->get_path( $components['path'] );

		// Special handling for hostless URLs (typically, filepaths) referencing the current working directory.
		if ( '' === $host && ( '' === $path || '.' === $path ) ) {
			$path = './';
		}

		$user      = null !== $components['user'] ? $components['user'] : '';
		$pass      = null !== $components['pass'] ? ':' . $components['pass'] : '';
		$user_pass = ( ! empty( $user ) || ! empty( $pass ) ) ? $user . $pass . '@' : '';

		$query    = null !== $components['query'] ? '?' . $components['query'] : '';
		$fragment = null !== $components['fragment'] ? '#' . $components['fragment'] : '';

		return $scheme . $user_pass . $host . $port . $path . $query . $fragment;
	}

	/**
	 * Outputs the path. Especially useful if it was a a regular filepath that was passed in originally.
	 *
	 * @param string $path_override If provided this will be used as the URL path. Does not impact drive letter.
	 *
	 * @return string
	 */
	public function get_path( string $path_override = null ): string {
		return ( $this->components['drive'] ? $this->components['drive'] . ':' : '' ) . ( $path_override ?? $this->components['path'] );
	}

	/**
	 * Indicates if the URL or filepath was absolute.
	 *
	 * @return bool True if absolute, else false.
	 */
	public function is_absolute(): bool {
		return $this->is_absolute;
	}

	/**
	 * Indicates if the URL or filepath was relative.
	 *
	 * @return bool True if relative, else false.
	 */
	public function is_relative(): bool {
		return ! $this->is_absolute;
	}
}
URLException.php000064400000000277151542630320007605 0ustar00<?php

namespace Automattic\WooCommerce\Internal\Utilities;

use Exception;

/**
 * Used to represent a problem encountered when processing a URL.
 */
class URLException extends Exception {}
Users.php000064400000001460151542630320006360 0ustar00<?php

namespace Automattic\WooCommerce\Internal\Utilities;

/**
 * Helper functions for working with users.
 */
class Users {
	/**
	 * Indicates if the user qualifies as site administrator.
	 *
	 * In the context of multisite networks, this means that they must have the `manage_sites`
	 * capability. In all other cases, they must have the `manage_options` capability.
	 *
	 * @param int $user_id Optional, used to specify a specific user (otherwise we look at the current user).
	 *
	 * @return bool
	 */
	public static function is_site_administrator( int $user_id = 0 ): bool {
		$user = 0 === $user_id ? wp_get_current_user() : get_user_by( 'id', $user_id );

		if ( false === $user ) {
			return false;
		}

		return is_multisite() ? $user->has_cap( 'manage_sites' ) : $user->has_cap( 'manage_options' );
	}
}
WebhookUtil.php000064400000010777151542630320007526 0ustar00<?php
/**
 * WebhookUtil class file.
 */

namespace Automattic\WooCommerce\Internal\Utilities;

use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;

/**
 * Class with utility methods for dealing with webhooks.
 */
class WebhookUtil {

	use AccessiblePrivateMethods;

	/**
	 * Creates a new instance of the class.
	 */
	public function __construct() {
		self::add_action( 'deleted_user', array( $this, 'reassign_webhooks_to_new_user_id' ), 10, 2 );
		self::add_action( 'delete_user_form', array( $this, 'maybe_render_user_with_webhooks_warning' ), 10, 2 );
	}

	/**
	 * Whenever a user is deleted, re-assign their webhooks to the new user.
	 *
	 * If re-assignment isn't selected during deletion, assign the webhooks to user_id 0,
	 * so that an admin can edit and re-save them in order to get them to be assigned to a valid user.
	 *
	 * @param int      $old_user_id ID of the deleted user.
	 * @param int|null $new_user_id ID of the user to reassign existing data to, or null if no re-assignment is requested.
	 *
	 * @return void
	 * @since 7.8.0
	 */
	private function reassign_webhooks_to_new_user_id( int $old_user_id, ?int $new_user_id ): void {
		$webhook_ids = $this->get_webhook_ids_for_user( $old_user_id );

		foreach ( $webhook_ids as $webhook_id ) {
			$webhook = new \WC_Webhook( $webhook_id );
			$webhook->set_user_id( $new_user_id ?? 0 );
			$webhook->save();
		}
	}

	/**
	 * When users are about to be deleted show an informative text if they have webhooks assigned.
	 *
	 * @param \WP_User $current_user The current logged in user.
	 * @param array    $userids Array with the ids of the users that are about to be deleted.
	 * @return void
	 * @since 7.8.0
	 */
	private function maybe_render_user_with_webhooks_warning( \WP_User $current_user, array $userids ): void {
		global $wpdb;

		$at_least_one_user_with_webhooks = false;

		foreach ( $userids as $user_id ) {
			$webhook_ids = $this->get_webhook_ids_for_user( $user_id );
			if ( empty( $webhook_ids ) ) {
				continue;
			}

			$at_least_one_user_with_webhooks = true;

			$user_data      = get_userdata( $user_id );
			$user_login     = false === $user_data ? '' : $user_data->user_login;
			$webhooks_count = count( $webhook_ids );

			$text = sprintf(
				/* translators: 1 = user id, 2 = user login, 3 = webhooks count */
				_nx(
					'User #%1$s %2$s has created %3$d WooCommerce webhook.',
					'User #%1$s %2$s has created %3$d WooCommerce webhooks.',
					$webhooks_count,
					'user webhook count',
					'woocommerce'
				),
				$user_id,
				$user_login,
				$webhooks_count
			);

			echo '<p>' . esc_html( $text ) . '</p>';
		}

		if ( ! $at_least_one_user_with_webhooks ) {
			return;
		}

		$webhooks_settings_url = esc_url_raw( admin_url( 'admin.php?page=wc-settings&tab=advanced&section=webhooks' ) );

		// This block of code is copied from WordPress' users.php.
		// phpcs:disable WooCommerce.Commenting.CommentHooks, WordPress.DB.PreparedSQL.NotPrepared
		$users_have_content = (bool) apply_filters( 'users_have_additional_content', false, $userids );
		if ( ! $users_have_content ) {
			if ( $wpdb->get_var( "SELECT ID FROM {$wpdb->posts} WHERE post_author IN( " . implode( ',', $userids ) . ' ) LIMIT 1' ) ) {
				$users_have_content = true;
			} elseif ( $wpdb->get_var( "SELECT link_id FROM {$wpdb->links} WHERE link_owner IN( " . implode( ',', $userids ) . ' ) LIMIT 1' ) ) {
				$users_have_content = true;
			}
		}
		// phpcs:enable WooCommerce.Commenting.CommentHooks, WordPress.DB.PreparedSQL.NotPrepared

		if ( $users_have_content ) {
			$text = __( 'If the "Delete all content" option is selected, the affected WooCommerce webhooks will <b>not</b> be deleted and will be attributed to user id 0.<br/>', 'woocommerce' );
		} else {
			$text = __( 'The affected WooCommerce webhooks will <b>not</b> be deleted and will be attributed to user id 0.<br/>', 'woocommerce' );
		}

		$text .= sprintf(
			/* translators: 1 = url of the WooCommerce webhooks settings page */
			__( 'After that they can be reassigned to the logged-in user by going to the <a href="%1$s">WooCommerce webhooks settings page</a> and re-saving them.', 'woocommerce' ),
			$webhooks_settings_url
		);

		echo '<p>' . wp_kses_post( $text ) . '</p>';
	}

	/**
	 * Get the ids of the webhooks assigned to a given user.
	 *
	 * @param int $user_id User id.
	 * @return int[] Array of webhook ids.
	 */
	private function get_webhook_ids_for_user( int $user_id ): array {
		$data_store = \WC_Data_Store::load( 'webhook' );
		return $data_store->search_webhooks(
			array(
				'user_id' => $user_id,
			)
		);
	}
}
ImageAttachment.php000064400000003705151547770550010335 0ustar00<?php
/**
 * Helper to upload files via the REST API.
 *
 * @package WooCommerce\Utilities
 */

namespace Automattic\WooCommerce\RestApi\Utilities;

/**
 * ImageAttachment class.
 */
class ImageAttachment {

	/**
	 * Attachment ID.
	 *
	 * @var integer
	 */
	public $id = 0;

	/**
	 * Object attached to.
	 *
	 * @var integer
	 */
	public $object_id = 0;

	/**
	 * Constructor.
	 *
	 * @param integer $id Attachment ID.
	 * @param integer $object_id Object ID.
	 */
	public function __construct( $id = 0, $object_id = 0 ) {
		$this->id        = (int) $id;
		$this->object_id = (int) $object_id;
	}

	/**
	 * Upload an attachment file.
	 *
	 * @throws \WC_REST_Exception REST API exceptions.
	 * @param string $src URL to file.
	 */
	public function upload_image_from_src( $src ) {
		$upload = wc_rest_upload_image_from_url( esc_url_raw( $src ) );

		if ( is_wp_error( $upload ) ) {
			if ( ! apply_filters( 'woocommerce_rest_suppress_image_upload_error', false, $upload, $this->object_id, $images ) ) {
				throw new \WC_REST_Exception( 'woocommerce_product_image_upload_error', $upload->get_error_message(), 400 );
			} else {
				return;
			}
		}

		$this->id = wc_rest_set_uploaded_image_as_attachment( $upload, $this->object_id );

		if ( ! wp_attachment_is_image( $this->id ) ) {
			/* translators: %s: image ID */
			throw new \WC_REST_Exception( 'woocommerce_product_invalid_image_id', sprintf( __( '#%s is an invalid image ID.', 'woocommerce' ), $this->id ), 400 );
		}
	}

	/**
	 * Update attachment alt text.
	 *
	 * @param string $text Text to set.
	 */
	public function update_alt_text( $text ) {
		if ( ! $this->id ) {
			return;
		}
		update_post_meta( $this->id, '_wp_attachment_image_alt', wc_clean( $text ) );
	}

	/**
	 * Update attachment name.
	 *
	 * @param string $text Text to set.
	 */
	public function update_name( $text ) {
		if ( ! $this->id ) {
			return;
		}
		wp_update_post(
			array(
				'ID'         => $this->id,
				'post_title' => $text,
			)
		);
	}
}
SingletonTrait.php000064400000001537151547770550010251 0ustar00<?php
/**
 * Singleton class trait.
 *
 * @package WooCommerce\Utilities
 */

namespace Automattic\WooCommerce\RestApi\Utilities;

/**
 * Singleton trait.
 */
trait SingletonTrait {
	/**
	 * The single instance of the class.
	 *
	 * @var object
	 */
	protected static $instance = null;

	/**
	 * Constructor
	 *
	 * @return void
	 */
	protected function __construct() {}

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

	/**
	 * Prevent cloning.
	 */
	private function __clone() {}

	/**
	 * Prevent unserializing.
	 */
	final public function __wakeup() {
		wc_doing_it_wrong( __FUNCTION__, __( 'Unserializing instances of this class is forbidden.', 'woocommerce' ), '4.6' );
		die();
	}
}
ArrayIntersector.php000064400000003560151553205070010564 0ustar00<?php

declare(strict_types=1);

namespace Pelago\Emogrifier\Utilities;

/**
 * When computing many array intersections using the same array, it is more efficient to use `array_flip()` first and
 * then `array_intersect_key()`, than `array_intersect()`.  See the discussion at
 * {@link https://stackoverflow.com/questions/6329211/php-array-intersect-efficiency Stack Overflow} for more
 * information.
 *
 * Of course, this is only possible if the arrays contain integer or string values, and either don't contain duplicates,
 * or that fact that duplicates will be removed does not matter.
 *
 * This class takes care of the detail.
 *
 * @internal
 */
class ArrayIntersector
{
    /**
     * the array with which the object was constructed, with all its keys exchanged with their associated values
     *
     * @var array<array-key, array-key>
     */
    private $invertedArray;

    /**
     * Constructs the object with the array that will be reused for many intersection computations.
     *
     * @param array<array-key, array-key> $array
     */
    public function __construct(array $array)
    {
        $this->invertedArray = \array_flip($array);
    }

    /**
     * Computes the intersection of `$array` and the array with which this object was constructed.
     *
     * @param array<array-key, array-key> $array
     *
     * @return array<array-key, array-key>
     *         Returns an array containing all of the values in `$array` whose values exist in the array
     *         with which this object was constructed.  Note that keys are preserved, order is maintained, but
     *         duplicates are removed.
     */
    public function intersectWith(array $array): array
    {
        $invertedArray = \array_flip($array);

        $invertedIntersection = \array_intersect_key($invertedArray, $this->invertedArray);

        return \array_flip($invertedIntersection);
    }
}
CssConcatenator.php000064400000015116151553205070010355 0ustar00<?php

declare(strict_types=1);

namespace Pelago\Emogrifier\Utilities;

/**
 * Facilitates building a CSS string by appending rule blocks one at a time, checking whether the media query,
 * selectors, or declarations block are the same as those from the preceding block and combining blocks in such cases.
 *
 * Example:
 *  $concatenator = new CssConcatenator();
 *  $concatenator->append(['body'], 'color: blue;');
 *  $concatenator->append(['body'], 'font-size: 16px;');
 *  $concatenator->append(['p'], 'margin: 1em 0;');
 *  $concatenator->append(['ul', 'ol'], 'margin: 1em 0;');
 *  $concatenator->append(['body'], 'font-size: 14px;', '@media screen and (max-width: 400px)');
 *  $concatenator->append(['ul', 'ol'], 'margin: 0.75em 0;', '@media screen and (max-width: 400px)');
 *  $css = $concatenator->getCss();
 *
 * `$css` (if unminified) would contain the following CSS:
 * ` body {
 * `   color: blue;
 * `   font-size: 16px;
 * ` }
 * ` p, ul, ol {
 * `   margin: 1em 0;
 * ` }
 * ` @media screen and (max-width: 400px) {
 * `   body {
 * `     font-size: 14px;
 * `   }
 * `   ul, ol {
 * `     margin: 0.75em 0;
 * `   }
 * ` }
 *
 * @internal
 */
class CssConcatenator
{
    /**
     * Array of media rules in order.  Each element is an object with the following properties:
     * - string `media` - The media query string, e.g. "@media screen and (max-width:639px)", or an empty string for
     *   rules not within a media query block;
     * - object[] `ruleBlocks` - Array of rule blocks in order, where each element is an object with the following
     *   properties:
     *   - mixed[] `selectorsAsKeys` - Array whose keys are selectors for the rule block (values are of no
     *     significance);
     *   - string `declarationsBlock` - The property declarations, e.g. "margin-top: 0.5em; padding: 0".
     *
     * @var array<int, object{
     *   media: string,
     *   ruleBlocks: array<int, object{
     *     selectorsAsKeys: array<string, array-key>,
     *     declarationsBlock: string
     *   }>
     * }>
     */
    private $mediaRules = [];

    /**
     * Appends a declaration block to the CSS.
     *
     * @param array<array-key, string> $selectors
     *        array of selectors for the rule, e.g. ["ul", "ol", "p:first-child"]
     * @param string $declarationsBlock
     *        the property declarations, e.g. "margin-top: 0.5em; padding: 0"
     * @param string $media
     *        the media query for the rule, e.g. "@media screen and (max-width:639px)", or an empty string if none
     */
    public function append(array $selectors, string $declarationsBlock, string $media = ''): void
    {
        $selectorsAsKeys = \array_flip($selectors);

        $mediaRule = $this->getOrCreateMediaRuleToAppendTo($media);
        $ruleBlocks = $mediaRule->ruleBlocks;
        $lastRuleBlock = \end($ruleBlocks);

        $hasSameDeclarationsAsLastRule = \is_object($lastRuleBlock)
            && $declarationsBlock === $lastRuleBlock->declarationsBlock;
        if ($hasSameDeclarationsAsLastRule) {
            $lastRuleBlock->selectorsAsKeys += $selectorsAsKeys;
        } else {
            $lastRuleBlockSelectors = \is_object($lastRuleBlock) ? $lastRuleBlock->selectorsAsKeys : [];
            $hasSameSelectorsAsLastRule = \is_object($lastRuleBlock)
                && self::hasEquivalentSelectors($selectorsAsKeys, $lastRuleBlockSelectors);
            if ($hasSameSelectorsAsLastRule) {
                $lastDeclarationsBlockWithoutSemicolon = \rtrim(\rtrim($lastRuleBlock->declarationsBlock), ';');
                $lastRuleBlock->declarationsBlock = $lastDeclarationsBlockWithoutSemicolon . ';' . $declarationsBlock;
            } else {
                $mediaRule->ruleBlocks[] = (object)\compact('selectorsAsKeys', 'declarationsBlock');
            }
        }
    }

    /**
     * @return string
     */
    public function getCss(): string
    {
        return \implode('', \array_map([self::class, 'getMediaRuleCss'], $this->mediaRules));
    }

    /**
     * @param string $media The media query for rules to be appended, e.g. "@media screen and (max-width:639px)",
     *        or an empty string if none.
     *
     * @return object{
     *           media: string,
     *           ruleBlocks: array<int, object{
     *             selectorsAsKeys: array<string, array-key>,
     *             declarationsBlock: string
     *           }>
     *         }
     */
    private function getOrCreateMediaRuleToAppendTo(string $media): object
    {
        $lastMediaRule = \end($this->mediaRules);
        if (\is_object($lastMediaRule) && $media === $lastMediaRule->media) {
            return $lastMediaRule;
        }

        $newMediaRule = (object)[
            'media' => $media,
            'ruleBlocks' => [],
        ];
        $this->mediaRules[] = $newMediaRule;
        return $newMediaRule;
    }

    /**
     * Tests if two sets of selectors are equivalent (i.e. the same selectors, possibly in a different order).
     *
     * @param array<string, array-key> $selectorsAsKeys1
     *        array in which the selectors are the keys, and the values are of no significance
     * @param array<string, array-key> $selectorsAsKeys2 another such array
     *
     * @return bool
     */
    private static function hasEquivalentSelectors(array $selectorsAsKeys1, array $selectorsAsKeys2): bool
    {
        return \count($selectorsAsKeys1) === \count($selectorsAsKeys2)
            && \count($selectorsAsKeys1) === \count($selectorsAsKeys1 + $selectorsAsKeys2);
    }

    /**
     * @param object{
     *          media: string,
     *          ruleBlocks: array<int, object{
     *            selectorsAsKeys: array<string, array-key>,
     *            declarationsBlock: string
     *          }>
     *        } $mediaRule
     *
     * @return string CSS for the media rule.
     */
    private static function getMediaRuleCss(object $mediaRule): string
    {
        $ruleBlocks = $mediaRule->ruleBlocks;
        $css = \implode('', \array_map([self::class, 'getRuleBlockCss'], $ruleBlocks));
        $media = $mediaRule->media;
        if ($media !== '') {
            $css = $media . '{' . $css . '}';
        }
        return $css;
    }

    /**
     * @param object{selectorsAsKeys: array<string, array-key>, declarationsBlock: string} $ruleBlock
     *
     * @return string CSS for the rule block.
     */
    private static function getRuleBlockCss(object $ruleBlock): string
    {
        $selectorsAsKeys = $ruleBlock->selectorsAsKeys;
        $selectors = \array_keys($selectorsAsKeys);
        $declarationsBlock = $ruleBlock->declarationsBlock;
        return \implode(',', $selectors) . '{' . $declarationsBlock . '}';
    }
}
ArrayUtils.php000064400000002032151556130470007360 0ustar00<?php
namespace Automattic\WooCommerce\StoreApi\Utilities;

/**
 * ArrayUtils class used for custom functions to operate on arrays
 */
class ArrayUtils {
	/**
	 * Join a string with a natural language conjunction at the end.
	 *
	 * @param array $array  The array to join together with the natural language conjunction.
	 * @param bool  $enclose_items_with_quotes Whether each item in the array should be enclosed within quotation marks.
	 *
	 * @return string a string containing a list of items and a natural language conjuction.
	 */
	public static function natural_language_join( $array, $enclose_items_with_quotes = false ) {
		if ( true === $enclose_items_with_quotes ) {
			$array = array_map(
				function( $item ) {
					return '"' . $item . '"';
				},
				$array
			);
		}
		$last = array_pop( $array );
		if ( $array ) {
			return sprintf(
				/* translators: 1: The first n-1 items of a list 2: the last item in the list. */
				__( '%1$s and %2$s', 'woocommerce' ),
				implode( ', ', $array ),
				$last
			);
		}
		return $last;
	}
}
CartController.php000064400000130432151556130500010216 0ustar00<?php
namespace Automattic\WooCommerce\StoreApi\Utilities;

use Automattic\WooCommerce\Checkout\Helpers\ReserveStock;
use Automattic\WooCommerce\StoreApi\Exceptions\InvalidCartException;
use Automattic\WooCommerce\StoreApi\Exceptions\NotPurchasableException;
use Automattic\WooCommerce\StoreApi\Exceptions\OutOfStockException;
use Automattic\WooCommerce\StoreApi\Exceptions\PartialOutOfStockException;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use Automattic\WooCommerce\StoreApi\Exceptions\TooManyInCartException;
use Automattic\WooCommerce\StoreApi\Utilities\ArrayUtils;
use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait;
use Automattic\WooCommerce\StoreApi\Utilities\NoticeHandler;
use Automattic\WooCommerce\StoreApi\Utilities\QuantityLimits;
use Automattic\WooCommerce\Blocks\Package;
use WP_Error;

/**
 * Woo Cart Controller class.
 *
 * Helper class to bridge the gap between the cart API and Woo core.
 */
class CartController {
	use DraftOrderTrait;

	/**
	 * Makes the cart and sessions available to a route by loading them from core.
	 */
	public function load_cart() {
		if ( ! did_action( 'woocommerce_load_cart_from_session' ) && function_exists( 'wc_load_cart' ) ) {
			wc_load_cart();
		}
	}

	/**
	 * Recalculates the cart totals.
	 */
	public function calculate_totals() {
		$cart = $this->get_cart_instance();
		$cart->get_cart();
		$cart->calculate_fees();
		$cart->calculate_shipping();
		$cart->calculate_totals();
	}

	/**
	 * Based on the core cart class but returns errors rather than rendering notices directly.
	 *
	 * @todo Overriding the core add_to_cart method was necessary because core outputs notices when an item is added to
	 * the cart. For us this would cause notices to build up and output on the store, out of context. Core would need
	 * refactoring to split notices out from other cart actions.
	 *
	 * @throws RouteException Exception if invalid data is detected.
	 *
	 * @param array $request Add to cart request params.
	 * @return string
	 */
	public function add_to_cart( $request ) {
		$cart    = $this->get_cart_instance();
		$request = wp_parse_args(
			$request,
			[
				'id'             => 0,
				'quantity'       => 1,
				'variation'      => [],
				'cart_item_data' => [],
			]
		);

		$request = $this->filter_request_data( $this->parse_variation_data( $request ) );
		$product = $this->get_product_for_cart( $request );
		$cart_id = $cart->generate_cart_id(
			$this->get_product_id( $product ),
			$this->get_variation_id( $product ),
			$request['variation'],
			$request['cart_item_data']
		);

		$this->validate_add_to_cart( $product, $request );

		$quantity_limits  = new QuantityLimits();
		$existing_cart_id = $cart->find_product_in_cart( $cart_id );

		if ( $existing_cart_id ) {
			$cart_item           = $cart->cart_contents[ $existing_cart_id ];
			$quantity_validation = $quantity_limits->validate_cart_item_quantity( $request['quantity'] + $cart_item['quantity'], $cart_item );

			if ( is_wp_error( $quantity_validation ) ) {
				throw new RouteException( $quantity_validation->get_error_code(), $quantity_validation->get_error_message(), 400 );
			}

			$cart->set_quantity( $existing_cart_id, $request['quantity'] + $cart->cart_contents[ $existing_cart_id ]['quantity'], true );

			return $existing_cart_id;
		}

		// Normalize quantity.
		$add_to_cart_limits = $quantity_limits->get_add_to_cart_limits( $product );
		$request_quantity   = (int) $request['quantity'];

		if ( $add_to_cart_limits['maximum'] ) {
			$request_quantity = min( $request_quantity, $add_to_cart_limits['maximum'] );
		}

		$request_quantity = max( $request_quantity, $add_to_cart_limits['minimum'] );
		$request_quantity = $quantity_limits->limit_to_multiple( $request_quantity, $add_to_cart_limits['multiple_of'] );

		/**
		 * Filters the item being added to the cart.
		 *
		 * @since 2.5.0
		 *
		 * @internal Matches filter name in WooCommerce core.
		 *
		 * @param array $cart_item_data Array of cart item data being added to the cart.
		 * @param string $cart_id Id of the item in the cart.
		 * @return array Updated cart item data.
		 */
		$cart->cart_contents[ $cart_id ] = apply_filters(
			'woocommerce_add_cart_item',
			array_merge(
				$request['cart_item_data'],
				array(
					'key'          => $cart_id,
					'product_id'   => $this->get_product_id( $product ),
					'variation_id' => $this->get_variation_id( $product ),
					'variation'    => $request['variation'],
					'quantity'     => $request_quantity,
					'data'         => $product,
					'data_hash'    => wc_get_cart_item_data_hash( $product ),
				)
			),
			$cart_id
		);

		/**
		 * Filters the entire cart contents when the cart changes.
		 *
		 * @since 2.5.0
		 *
		 * @internal Matches filter name in WooCommerce core.
		 *
		 * @param array $cart_contents Array of all cart items.
		 * @return array Updated array of all cart items.
		 */
		$cart->cart_contents = apply_filters( 'woocommerce_cart_contents_changed', $cart->cart_contents );

		/**
		 * Fires when an item is added to the cart.
		 *
		 * This hook fires when an item is added to the cart. This is triggered from the Store API in this context, but
		 * WooCommerce core add to cart events trigger the same hook.
		 *
		 * @since 2.5.0
		 *
		 * @internal Matches action name in WooCommerce core.
		 *
		 * @param string $cart_id ID of the item in the cart.
		 * @param integer $product_id ID of the product added to the cart.
		 * @param integer $request_quantity Quantity of the item added to the cart.
		 * @param integer $variation_id Variation ID of the product added to the cart.
		 * @param array $variation Array of variation data.
		 * @param array $cart_item_data Array of other cart item data.
		 */
		do_action(
			'woocommerce_add_to_cart',
			$cart_id,
			$this->get_product_id( $product ),
			$request_quantity,
			$this->get_variation_id( $product ),
			$request['variation'],
			$request['cart_item_data']
		);

		return $cart_id;
	}

	/**
	 * Based on core `set_quantity` method, but validates if an item is sold individually first and enforces any limits in
	 * place.
	 *
	 * @throws RouteException Exception if invalid data is detected.
	 *
	 * @param string  $item_id Cart item id.
	 * @param integer $quantity Cart quantity.
	 */
	public function set_cart_item_quantity( $item_id, $quantity = 1 ) {
		$cart_item = $this->get_cart_item( $item_id );

		if ( empty( $cart_item ) ) {
			throw new RouteException( 'woocommerce_rest_cart_invalid_key', __( 'Cart item does not exist.', 'woocommerce' ), 409 );
		}

		$product = $cart_item['data'];

		if ( ! $product instanceof \WC_Product ) {
			throw new RouteException( 'woocommerce_rest_cart_invalid_product', __( 'Cart item is invalid.', 'woocommerce' ), 404 );
		}

		$quantity_validation = ( new QuantityLimits() )->validate_cart_item_quantity( $quantity, $cart_item );

		if ( is_wp_error( $quantity_validation ) ) {
			throw new RouteException( $quantity_validation->get_error_code(), $quantity_validation->get_error_message(), 400 );
		}

		$cart = $this->get_cart_instance();
		$cart->set_quantity( $item_id, $quantity );
	}

	/**
	 * Validate all items in the cart and check for errors.
	 *
	 * @throws RouteException Exception if invalid data is detected.
	 *
	 * @param \WC_Product $product Product object associated with the cart item.
	 * @param array       $request Add to cart request params.
	 */
	public function validate_add_to_cart( \WC_Product $product, $request ) {
		if ( ! $product->is_purchasable() ) {
			$this->throw_default_product_exception( $product );
		}

		if ( ! $product->is_in_stock() ) {
			throw new RouteException(
				'woocommerce_rest_product_out_of_stock',
				sprintf(
					/* translators: %s: product name */
					__( 'You cannot add &quot;%s&quot; to the cart because the product is out of stock.', 'woocommerce' ),
					$product->get_name()
				),
				400
			);
		}

		if ( $product->managing_stock() && ! $product->backorders_allowed() ) {
			$qty_remaining = $this->get_remaining_stock_for_product( $product );
			$qty_in_cart   = $this->get_product_quantity_in_cart( $product );

			if ( $qty_remaining < $qty_in_cart + $request['quantity'] ) {
				throw new RouteException(
					'woocommerce_rest_product_partially_out_of_stock',
					sprintf(
						/* translators: 1: product name 2: quantity in stock */
						__( 'You cannot add that amount of &quot;%1$s&quot; to the cart because there is not enough stock (%2$s remaining).', 'woocommerce' ),
						$product->get_name(),
						wc_format_stock_quantity_for_display( $qty_remaining, $product )
					),
					400
				);
			}
		}

		/**
		 * Filters if an item being added to the cart passed validation checks.
		 *
		 * Allow 3rd parties to validate if an item can be added to the cart. This is a legacy hook from Woo core.
		 * This filter will be deprecated because it encourages usage of wc_add_notice. For the API we need to capture
		 * notices and convert to exceptions instead.
		 *
		 * @since 7.2.0
		 *
		 * @deprecated
		 * @param boolean $passed_validation True if the item passed validation.
		 * @param integer $product_id Product ID being validated.
		 * @param integer $quantity Quantity added to the cart.
		 * @param integer $variation_id Variation ID being added to the cart.
		 * @param array $variation Variation data.
		 * @return boolean
		 */
		$passed_validation = apply_filters(
			'woocommerce_add_to_cart_validation',
			true,
			$this->get_product_id( $product ),
			$request['quantity'],
			$this->get_variation_id( $product ),
			$request['variation']
		);

		if ( ! $passed_validation ) {
			// Validation did not pass - see if an error notice was thrown.
			NoticeHandler::convert_notices_to_exceptions( 'woocommerce_rest_add_to_cart_error' );

			// If no notice was thrown, throw the default notice instead.
			$this->throw_default_product_exception( $product );
		}

		/**
		 * Fires during validation when adding an item to the cart via the Store API.
		 *
		 * @param \WC_Product $product Product object being added to the cart.
		 * @param array       $request Add to cart request params including id, quantity, and variation attributes.
		 * @deprecated 7.1.0 Use woocommerce_store_api_validate_add_to_cart instead.
		 */
		wc_do_deprecated_action(
			'wooocommerce_store_api_validate_add_to_cart',
			array(
				$product,
				$request,
			),
			'7.1.0',
			'woocommerce_store_api_validate_add_to_cart',
			'This action was deprecated in WooCommerce Blocks version 7.1.0. Please use woocommerce_store_api_validate_add_to_cart instead.'
		);

		/**
		 * Fires during validation when adding an item to the cart via the Store API.
		 *
		 * Fire action to validate add to cart. Functions hooking into this should throw an \Exception to prevent
		 * add to cart from happening.
		 *
		 * @since 7.1.0
		 *
		 * @param \WC_Product $product Product object being added to the cart.
		 * @param array       $request Add to cart request params including id, quantity, and variation attributes.
		 */
		do_action( 'woocommerce_store_api_validate_add_to_cart', $product, $request );
	}

	/**
	 * Generates the error message for out of stock products and adds product names to it.
	 *
	 * @param string $singular The message to use when only one product is in the list.
	 * @param string $plural The message to use when more than one product is in the list.
	 * @param array  $items The list of cart items whose names should be inserted into the message.
	 * @returns string The translated and correctly pluralised message.
	 */
	private function add_product_names_to_message( $singular, $plural, $items ) {
		$product_names = wc_list_pluck( $items, 'getProductName' );
		$message       = ( count( $items ) > 1 ) ? $plural : $singular;
		return sprintf(
			$message,
			ArrayUtils::natural_language_join( $product_names, true )
		);
	}

	/**
	 * Takes a string describing the type of stock extension, whether there is a single product or multiple products
	 * causing this exception and returns an appropriate error message.
	 *
	 * @param string $exception_type     The type of exception encountered.
	 * @param string $singular_or_plural Whether to get the error message for a single product or multiple.
	 *
	 * @return string
	 */
	private function get_error_message_for_stock_exception_type( $exception_type, $singular_or_plural ) {
		$stock_error_messages = [
			'out_of_stock'         => [
				/* translators: %s: product name. */
				'singular' => __(
					'%s is out of stock and cannot be purchased. Please remove it from your cart.',
					'woocommerce'
				),
				/* translators: %s: product names. */
				'plural'   => __(
					'%s are out of stock and cannot be purchased. Please remove them from your cart.',
					'woocommerce'
				),
			],
			'not_purchasable'      => [
				/* translators: %s: product name. */
				'singular' => __(
					'%s cannot be purchased. Please remove it from your cart.',
					'woocommerce'
				),
				/* translators: %s: product names. */
				'plural'   => __(
					'%s cannot be purchased. Please remove them from your cart.',
					'woocommerce'
				),
			],
			'too_many_in_cart'     => [
				/* translators: %s: product names. */
				'singular' => __(
					'There are too many %s in the cart. Only 1 can be purchased. Please reduce the quantity in your cart.',
					'woocommerce'
				),
				/* translators: %s: product names. */
				'plural'   => __(
					'There are too many %s in the cart. Only 1 of each can be purchased. Please reduce the quantities in your cart.',
					'woocommerce'
				),
			],
			'partial_out_of_stock' => [
				/* translators: %s: product names. */
				'singular' => __(
					'There is not enough %s in stock. Please reduce the quantity in your cart.',
					'woocommerce'
				),
				/* translators: %s: product names. */
				'plural'   => __(
					'There are not enough %s in stock. Please reduce the quantities in your cart.',
					'woocommerce'
				),
			],
		];

		if (
			isset( $stock_error_messages[ $exception_type ] ) &&
			isset( $stock_error_messages[ $exception_type ][ $singular_or_plural ] )
		) {
			return $stock_error_messages[ $exception_type ][ $singular_or_plural ];
		}

		return __( 'There was an error with an item in your cart.', 'woocommerce' );
	}

	/**
	 * Validate cart and check for errors.
	 *
	 * @throws InvalidCartException Exception if invalid data is detected in the cart.
	 */
	public function validate_cart() {
		$this->validate_cart_items();
		$this->validate_cart_coupons();

		$cart        = $this->get_cart_instance();
		$cart_errors = new WP_Error();

		/**
		 * Fires an action to validate the cart.
		 *
		 * Functions hooking into this should add custom errors using the provided WP_Error instance.
		 *
		 * @since 7.2.0
		 *
		 * @example See docs/examples/validate-cart.md
		 *
		 * @param \WP_Error $errors  WP_Error object.
		 * @param \WC_Cart  $cart    Cart object.
		 */
		do_action( 'woocommerce_store_api_cart_errors', $cart_errors, $cart );

		if ( $cart_errors->has_errors() ) {
			throw new InvalidCartException(
				'woocommerce_cart_error',
				$cart_errors,
				409
			);
		}

		// Before running the woocommerce_check_cart_items hook, unhook validation from the core cart.
		remove_action( 'woocommerce_check_cart_items', array( $cart, 'check_cart_items' ), 1 );
		remove_action( 'woocommerce_check_cart_items', array( $cart, 'check_cart_coupons' ), 1 );

		/**
		 * Fires when cart items are being validated.
		 *
		 * Allow 3rd parties to validate cart items. This is a legacy hook from Woo core.
		 * This filter will be deprecated because it encourages usage of wc_add_notice. For the API we need to capture
		 * notices and convert to wp errors instead.
		 *
		 * @since 7.2.0
		 *
		 * @deprecated
		 * @internal Matches action name in WooCommerce core.
		 */
		do_action( 'woocommerce_check_cart_items' );

		$cart_errors = NoticeHandler::convert_notices_to_wp_errors( 'woocommerce_rest_cart_item_error' );

		if ( $cart_errors->has_errors() ) {
			throw new InvalidCartException(
				'woocommerce_cart_error',
				$cart_errors,
				409
			);
		}
	}

	/**
	 * Validate all items in the cart and check for errors.
	 *
	 * @throws InvalidCartException Exception if invalid data is detected due to insufficient stock levels.
	 */
	public function validate_cart_items() {
		$cart       = $this->get_cart_instance();
		$cart_items = $this->get_cart_items();

		$errors                        = [];
		$out_of_stock_products         = [];
		$too_many_in_cart_products     = [];
		$partial_out_of_stock_products = [];
		$not_purchasable_products      = [];

		foreach ( $cart_items as $cart_item_key => $cart_item ) {
			try {
				$this->validate_cart_item( $cart_item );
			} catch ( RouteException $error ) {
				$errors[] = new WP_Error( $error->getErrorCode(), $error->getMessage(), $error->getAdditionalData() );
			} catch ( TooManyInCartException $error ) {
				$too_many_in_cart_products[] = $error;
			} catch ( NotPurchasableException $error ) {
				$not_purchasable_products[] = $error;
			} catch ( PartialOutOfStockException $error ) {
				$partial_out_of_stock_products[] = $error;
			} catch ( OutOfStockException $error ) {
				$out_of_stock_products[] = $error;
			}
		}

		if ( count( $errors ) > 0 ) {

			$error = new WP_Error();
			foreach ( $errors as $wp_error ) {
				$error->merge_from( $wp_error );
			}

			throw new InvalidCartException(
				'woocommerce_cart_error',
				$error,
				409
			);
		}

		$error = $this->stock_exceptions_to_wp_errors( $too_many_in_cart_products, $not_purchasable_products, $partial_out_of_stock_products, $out_of_stock_products );
		if ( $error->has_errors() ) {

			throw new InvalidCartException(
				'woocommerce_stock_availability_error',
				$error,
				409
			);
		}
	}

	/**
	 * This method will take arrays of exceptions relating to stock, and will convert them to a WP_Error object.
	 *
	 * @param TooManyInCartException[]     $too_many_in_cart_products     Array of TooManyInCartExceptions.
	 * @param NotPurchasableException[]    $not_purchasable_products      Array of NotPurchasableExceptions.
	 * @param PartialOutOfStockException[] $partial_out_of_stock_products Array of PartialOutOfStockExceptions.
	 * @param OutOfStockException[]        $out_of_stock_products         Array of OutOfStockExceptions.
	 *
	 * @return WP_Error  The WP_Error object returned. Will have errors if any exceptions were in the args. It will be empty if they do not.
	 */
	private function stock_exceptions_to_wp_errors( $too_many_in_cart_products, $not_purchasable_products, $partial_out_of_stock_products, $out_of_stock_products ) {
		$error = new WP_Error();

		if ( count( $out_of_stock_products ) > 0 ) {

			$singular_error = $this->get_error_message_for_stock_exception_type( 'out_of_stock', 'singular' );
			$plural_error   = $this->get_error_message_for_stock_exception_type( 'out_of_stock', 'plural' );

			$error->add(
				'woocommerce_rest_product_out_of_stock',
				$this->add_product_names_to_message( $singular_error, $plural_error, $out_of_stock_products )
			);
		}

		if ( count( $not_purchasable_products ) > 0 ) {
			$singular_error = $this->get_error_message_for_stock_exception_type( 'not_purchasable', 'singular' );
			$plural_error   = $this->get_error_message_for_stock_exception_type( 'not_purchasable', 'plural' );

			$error->add(
				'woocommerce_rest_product_not_purchasable',
				$this->add_product_names_to_message( $singular_error, $plural_error, $not_purchasable_products )
			);
		}

		if ( count( $too_many_in_cart_products ) > 0 ) {
			$singular_error = $this->get_error_message_for_stock_exception_type( 'too_many_in_cart', 'singular' );
			$plural_error   = $this->get_error_message_for_stock_exception_type( 'too_many_in_cart', 'plural' );

			$error->add(
				'woocommerce_rest_product_too_many_in_cart',
				$this->add_product_names_to_message( $singular_error, $plural_error, $too_many_in_cart_products )
			);
		}

		if ( count( $partial_out_of_stock_products ) > 0 ) {
			$singular_error = $this->get_error_message_for_stock_exception_type( 'partial_out_of_stock', 'singular' );
			$plural_error   = $this->get_error_message_for_stock_exception_type( 'partial_out_of_stock', 'plural' );

			$error->add(
				'woocommerce_rest_product_partially_out_of_stock',
				$this->add_product_names_to_message( $singular_error, $plural_error, $partial_out_of_stock_products )
			);
		}

		return $error;
	}

	/**
	 * Validates an existing cart item and returns any errors.
	 *
	 * @throws TooManyInCartException Exception if more than one product that can only be purchased individually is in
	 * the cart.
	 * @throws PartialOutOfStockException Exception if an item has a quantity greater than what is available in stock.
	 * @throws OutOfStockException Exception thrown when an item is entirely out of stock.
	 * @throws NotPurchasableException Exception thrown when an item is not purchasable.
	 * @param array $cart_item Cart item array.
	 */
	public function validate_cart_item( $cart_item ) {
		$product = $cart_item['data'];

		if ( ! $product instanceof \WC_Product ) {
			return;
		}

		if ( ! $product->is_purchasable() ) {
			throw new NotPurchasableException(
				'woocommerce_rest_product_not_purchasable',
				$product->get_name()
			);
		}

		if ( $product->is_sold_individually() && $cart_item['quantity'] > 1 ) {
			throw new TooManyInCartException(
				'woocommerce_rest_product_too_many_in_cart',
				$product->get_name()
			);
		}

		if ( ! $product->is_in_stock() ) {
			throw new OutOfStockException(
				'woocommerce_rest_product_out_of_stock',
				$product->get_name()
			);
		}

		if ( $product->managing_stock() && ! $product->backorders_allowed() ) {
			$qty_remaining = $this->get_remaining_stock_for_product( $product );
			$qty_in_cart   = $this->get_product_quantity_in_cart( $product );

			if ( $qty_remaining < $qty_in_cart ) {
				throw new PartialOutOfStockException(
					'woocommerce_rest_product_partially_out_of_stock',
					$product->get_name()
				);
			}
		}

		/**
		 * Fire action to validate add to cart. Functions hooking into this should throw an \Exception to prevent
		 * add to cart from occurring.
		 *
		 * @param \WC_Product $product Product object being added to the cart.
		 * @param array       $cart_item Cart item array.
		 * @deprecated 7.1.0 Use woocommerce_store_api_validate_cart_item instead.
		 */
		wc_do_deprecated_action(
			'wooocommerce_store_api_validate_cart_item',
			array(
				$product,
				$cart_item,
			),
			'7.1.0',
			'woocommerce_store_api_validate_cart_item',
			'This action was deprecated in WooCommerce Blocks version 7.1.0. Please use woocommerce_store_api_validate_cart_item instead.'
		);

		/**
		 * Fire action to validate add to cart. Functions hooking into this should throw an \Exception to prevent
		 * add to cart from occurring.
		 *
		 * @since 7.1.0
		 *
		 * @param \WC_Product $product Product object being added to the cart.
		 * @param array       $cart_item Cart item array.
		 */
		do_action( 'woocommerce_store_api_validate_cart_item', $product, $cart_item );
	}

	/**
	 * Validate all coupons in the cart and check for errors.
	 *
	 * @throws InvalidCartException Exception if invalid data is detected.
	 */
	public function validate_cart_coupons() {
		$cart_coupons = $this->get_cart_coupons();
		$errors       = [];

		foreach ( $cart_coupons as $code ) {
			$coupon = new \WC_Coupon( $code );
			try {
				$this->validate_cart_coupon( $coupon );
			} catch ( RouteException $error ) {
				$errors[] = new WP_Error( $error->getErrorCode(), $error->getMessage(), $error->getAdditionalData() );
			}
		}

		if ( ! empty( $errors ) ) {

			$error = new WP_Error();
			foreach ( $errors as $wp_error ) {
				$error->merge_from( $wp_error );
			}

			throw new InvalidCartException(
				'woocommerce_coupons_error',
				$error,
				409
			);
		}
	}

	/**
	 * Validate the cart and get a list of errors.
	 *
	 * @return WP_Error A WP_Error instance containing the cart's errors.
	 */
	public function get_cart_errors() {
		$errors = new WP_Error();

		try {
			$this->validate_cart();
		} catch ( RouteException $error ) {
			$errors->add( $error->getErrorCode(), $error->getMessage(), $error->getAdditionalData() );
		} catch ( InvalidCartException $error ) {
			$errors->merge_from( $error->getError() );
		} catch ( \Exception $error ) {
			$errors->add( $error->getCode(), $error->getMessage() );
		}

		return $errors;
	}

	/**
	 * Get main instance of cart class.
	 *
	 * @throws RouteException When cart cannot be loaded.
	 * @return \WC_Cart
	 */
	public function get_cart_instance() {
		$cart = wc()->cart;

		if ( ! $cart || ! $cart instanceof \WC_Cart ) {
			throw new RouteException( 'woocommerce_rest_cart_error', __( 'Unable to retrieve cart.', 'woocommerce' ), 500 );
		}

		return $cart;
	}

	/**
	 * Return a cart item from the woo core cart class.
	 *
	 * @param string $item_id Cart item id.
	 * @return array
	 */
	public function get_cart_item( $item_id ) {
		$cart = $this->get_cart_instance();
		return isset( $cart->cart_contents[ $item_id ] ) ? $cart->cart_contents[ $item_id ] : [];
	}

	/**
	 * Returns all cart items.
	 *
	 * @param callable $callback Optional callback to apply to the array filter.
	 * @return array
	 */
	public function get_cart_items( $callback = null ) {
		$cart = $this->get_cart_instance();
		return $callback ? array_filter( $cart->get_cart(), $callback ) : array_filter( $cart->get_cart() );
	}

	/**
	 * Get hashes for items in the current cart. Useful for tracking changes.
	 *
	 * @return array
	 */
	public function get_cart_hashes() {
		$cart = $this->get_cart_instance();
		return [
			'line_items' => $cart->get_cart_hash(),
			'shipping'   => md5( wp_json_encode( $cart->shipping_methods ) ),
			'fees'       => md5( wp_json_encode( $cart->get_fees() ) ),
			'coupons'    => md5( wp_json_encode( $cart->get_applied_coupons() ) ),
			'taxes'      => md5( wp_json_encode( $cart->get_taxes() ) ),
		];
	}

	/**
	 * Empty cart contents.
	 */
	public function empty_cart() {
		$cart = $this->get_cart_instance();
		$cart->empty_cart();
	}

	/**
	 * See if cart has applied coupon by code.
	 *
	 * @param string $coupon_code Cart coupon code.
	 * @return bool
	 */
	public function has_coupon( $coupon_code ) {
		$cart = $this->get_cart_instance();
		return $cart->has_discount( $coupon_code );
	}

	/**
	 * Returns all applied coupons.
	 *
	 * @param callable $callback Optional callback to apply to the array filter.
	 * @return array
	 */
	public function get_cart_coupons( $callback = null ) {
		$cart = $this->get_cart_instance();
		return $callback ? array_filter( $cart->get_applied_coupons(), $callback ) : array_filter( $cart->get_applied_coupons() );
	}

	/**
	 * Get shipping packages from the cart with calculated shipping rates.
	 *
	 * @todo this can be refactored once https://github.com/woocommerce/woocommerce/pull/26101 lands.
	 *
	 * @param bool $calculate_rates Should rates for the packages also be returned.
	 * @return array
	 */
	public function get_shipping_packages( $calculate_rates = true ) {
		$cart = $this->get_cart_instance();

		// See if we need to calculate anything.
		if ( ! $cart->needs_shipping() ) {
			return [];
		}

		$packages = $cart->get_shipping_packages();

		// Add extra package data to array.
		if ( count( $packages ) ) {
			$packages = array_map(
				function( $key, $package, $index ) {
					$package['package_id']   = isset( $package['package_id'] ) ? $package['package_id'] : $key;
					$package['package_name'] = isset( $package['package_name'] ) ? $package['package_name'] : $this->get_package_name( $package, $index );
					return $package;
				},
				array_keys( $packages ),
				$packages,
				range( 1, count( $packages ) )
			);
		}

		$packages = $calculate_rates ? wc()->shipping()->calculate_shipping( $packages ) : $packages;

		return $packages;
	}

	/**
	 * Creates a name for a package.
	 *
	 * @param array $package Shipping package from WooCommerce.
	 * @param int   $index Package number.
	 * @return string
	 */
	protected function get_package_name( $package, $index ) {
		/**
		 * Filters the shipping package name.
		 *
		 * @since 4.3.0
		 *
		 * @internal Matches filter name in WooCommerce core.
		 *
		 * @param string $shipping_package_name Shipping package name.
		 * @param string $package_id Shipping package ID.
		 * @param array $package Shipping package from WooCommerce.
		 * @return string Shipping package name.
		 */
		return apply_filters(
			'woocommerce_shipping_package_name',
			$index > 1 ?
				sprintf(
					/* translators: %d: shipping package number */
					_x( 'Shipment %d', 'shipping packages', 'woocommerce' ),
					$index
				) :
				_x( 'Shipment 1', 'shipping packages', 'woocommerce' ),
			$package['package_id'],
			$package
		);
	}

	/**
	 * Selects a shipping rate.
	 *
	 * @param int|string $package_id ID of the package to choose a rate for.
	 * @param string     $rate_id ID of the rate being chosen.
	 */
	public function select_shipping_rate( $package_id, $rate_id ) {
		$cart                        = $this->get_cart_instance();
		$session_data                = wc()->session->get( 'chosen_shipping_methods' ) ? wc()->session->get( 'chosen_shipping_methods' ) : [];
		$session_data[ $package_id ] = $rate_id;

		wc()->session->set( 'chosen_shipping_methods', $session_data );
	}

	/**
	 * Based on the core cart class but returns errors rather than rendering notices directly.
	 *
	 * @todo Overriding the core apply_coupon method was necessary because core outputs notices when a coupon gets
	 * applied. For us this would cause notices to build up and output on the store, out of context. Core would need
	 * refactoring to split notices out from other cart actions.
	 *
	 * @throws RouteException Exception if invalid data is detected.
	 *
	 * @param string $coupon_code Coupon code.
	 */
	public function apply_coupon( $coupon_code ) {
		$cart            = $this->get_cart_instance();
		$applied_coupons = $this->get_cart_coupons();
		$coupon          = new \WC_Coupon( $coupon_code );

		if ( $coupon->get_code() !== $coupon_code ) {
			throw new RouteException(
				'woocommerce_rest_cart_coupon_error',
				sprintf(
					/* translators: %s coupon code */
					__( '"%s" is an invalid coupon code.', 'woocommerce' ),
					esc_html( $coupon_code )
				),
				400
			);
		}

		if ( $this->has_coupon( $coupon_code ) ) {
			throw new RouteException(
				'woocommerce_rest_cart_coupon_error',
				sprintf(
					/* translators: %s coupon code */
					__( 'Coupon code "%s" has already been applied.', 'woocommerce' ),
					esc_html( $coupon_code )
				),
				400
			);
		}

		if ( ! $coupon->is_valid() ) {
			throw new RouteException(
				'woocommerce_rest_cart_coupon_error',
				wp_strip_all_tags( $coupon->get_error_message() ),
				400
			);
		}

		// Prevents new coupons being added if individual use coupons are already in the cart.
		$individual_use_coupons = $this->get_cart_coupons(
			function( $code ) {
				$coupon = new \WC_Coupon( $code );
				return $coupon->get_individual_use();
			}
		);

		foreach ( $individual_use_coupons as $code ) {
			$individual_use_coupon = new \WC_Coupon( $code );

			/**
			 * Filters if a coupon can be applied alongside other individual use coupons.
			 *
			 * @since 2.6.0
			 *
			 * @internal Matches filter name in WooCommerce core.
			 *
			 * @param boolean $apply_with_individual_use_coupon Defaults to false.
			 * @param \WC_Coupon $coupon Coupon object applied to the cart.
			 * @param \WC_Coupon $individual_use_coupon Individual use coupon already applied to the cart.
			 * @param array $applied_coupons Array of applied coupons already applied to the cart.
			 * @return boolean
			 */
			if ( false === apply_filters( 'woocommerce_apply_with_individual_use_coupon', false, $coupon, $individual_use_coupon, $applied_coupons ) ) {
				throw new RouteException(
					'woocommerce_rest_cart_coupon_error',
					sprintf(
						/* translators: %s: coupon code */
						__( '"%s" has already been applied and cannot be used in conjunction with other coupons.', 'woocommerce' ),
						$code
					),
					400
				);
			}
		}

		if ( $coupon->get_individual_use() ) {
			/**
			 * Filter coupons to remove when applying an individual use coupon.
			 *
			 * @since 2.6.0
			 *
			 * @internal Matches filter name in WooCommerce core.
			 *
			 * @param array $coupons Array of coupons to remove from the cart.
			 * @param \WC_Coupon $coupon Coupon object applied to the cart.
			 * @param array $applied_coupons Array of applied coupons already applied to the cart.
			 * @return array
			 */
			$coupons_to_remove = array_diff( $applied_coupons, apply_filters( 'woocommerce_apply_individual_use_coupon', array(), $coupon, $applied_coupons ) );

			foreach ( $coupons_to_remove as $code ) {
				$cart->remove_coupon( $code );
			}

			$applied_coupons = array_diff( $applied_coupons, $coupons_to_remove );
		}

		$applied_coupons[] = $coupon_code;
		$cart->set_applied_coupons( $applied_coupons );

		/**
		 * Fires after a coupon has been applied to the cart.
		 *
		 * @since 2.6.0
		 *
		 * @internal Matches action name in WooCommerce core.
		 *
		 * @param string $coupon_code The coupon code that was applied.
		 */
		do_action( 'woocommerce_applied_coupon', $coupon_code );
	}

	/**
	 * Validates an existing cart coupon and returns any errors.
	 *
	 * @throws RouteException Exception if invalid data is detected.
	 *
	 * @param \WC_Coupon $coupon Coupon object applied to the cart.
	 */
	protected function validate_cart_coupon( \WC_Coupon $coupon ) {
		if ( ! $coupon->is_valid() ) {
			$cart = $this->get_cart_instance();
			$cart->remove_coupon( $coupon->get_code() );
			$cart->calculate_totals();
			throw new RouteException(
				'woocommerce_rest_cart_coupon_error',
				sprintf(
					/* translators: %1$s coupon code, %2$s reason. */
					__( 'The "%1$s" coupon has been removed from your cart: %2$s', 'woocommerce' ),
					$coupon->get_code(),
					wp_strip_all_tags( $coupon->get_error_message() )
				),
				409
			);
		}
	}

	/**
	 * Gets the qty of a product across line items.
	 *
	 * @param \WC_Product $product Product object.
	 * @return int
	 */
	protected function get_product_quantity_in_cart( $product ) {
		$cart               = $this->get_cart_instance();
		$product_quantities = $cart->get_cart_item_quantities();
		$product_id         = $product->get_stock_managed_by_id();

		return isset( $product_quantities[ $product_id ] ) ? $product_quantities[ $product_id ] : 0;
	}

	/**
	 * Gets remaining stock for a product.
	 *
	 * @param \WC_Product $product Product object.
	 * @return int
	 */
	protected function get_remaining_stock_for_product( $product ) {
		$reserve_stock = new ReserveStock();
		$qty_reserved  = $reserve_stock->get_reserved_stock( $product, $this->get_draft_order_id() );

		return $product->get_stock_quantity() - $qty_reserved;
	}

	/**
	 * Get a product object to be added to the cart.
	 *
	 * @throws RouteException Exception if invalid data is detected.
	 *
	 * @param array $request Add to cart request params.
	 * @return \WC_Product|Error Returns a product object if purchasable.
	 */
	protected function get_product_for_cart( $request ) {
		$product = wc_get_product( $request['id'] );

		if ( ! $product || 'trash' === $product->get_status() ) {
			throw new RouteException(
				'woocommerce_rest_cart_invalid_product',
				__( 'This product cannot be added to the cart.', 'woocommerce' ),
				400
			);
		}

		return $product;
	}

	/**
	 * For a given product, get the product ID.
	 *
	 * @param \WC_Product $product Product object associated with the cart item.
	 * @return int
	 */
	protected function get_product_id( \WC_Product $product ) {
		return $product->is_type( 'variation' ) ? $product->get_parent_id() : $product->get_id();
	}

	/**
	 * For a given product, get the variation ID.
	 *
	 * @param \WC_Product $product Product object associated with the cart item.
	 * @return int
	 */
	protected function get_variation_id( \WC_Product $product ) {
		return $product->is_type( 'variation' ) ? $product->get_id() : 0;
	}

	/**
	 * Default exception thrown when an item cannot be added to the cart.
	 *
	 * @throws RouteException Exception with code woocommerce_rest_product_not_purchasable.
	 *
	 * @param \WC_Product $product Product object associated with the cart item.
	 */
	protected function throw_default_product_exception( \WC_Product $product ) {
		throw new RouteException(
			'woocommerce_rest_product_not_purchasable',
			sprintf(
				/* translators: %s: product name */
				__( '&quot;%s&quot; is not available for purchase.', 'woocommerce' ),
				$product->get_name()
			),
			400
		);
	}

	/**
	 * Filter data for add to cart requests.
	 *
	 * @param array $request Add to cart request params.
	 * @return array Updated request array.
	 */
	protected function filter_request_data( $request ) {
		$product_id   = $request['id'];
		$variation_id = 0;
		$product      = wc_get_product( $product_id );

		if ( $product->is_type( 'variation' ) ) {
			$product_id   = $product->get_parent_id();
			$variation_id = $product->get_id();
		}

		/**
		 * Filter cart item data for add to cart requests.
		 *
		 * @since 2.5.0
		 *
		 * @internal Matches filter name in WooCommerce core.
		 *
		 * @param array $cart_item_data Array of other cart item data.
		 * @param integer $product_id ID of the product added to the cart.
		 * @param integer $variation_id Variation ID of the product added to the cart.
		 * @param integer $quantity Quantity of the item added to the cart.
		 * @return array
		 */
		$request['cart_item_data'] = (array) apply_filters(
			'woocommerce_add_cart_item_data',
			$request['cart_item_data'],
			$product_id,
			$variation_id,
			$request['quantity']
		);

		if ( $product->is_sold_individually() ) {
			/**
			 * Filter sold individually quantity for add to cart requests.
			 *
			 * @since 2.5.0
			 *
			 * @internal Matches filter name in WooCommerce core.
			 *
			 * @param integer $sold_individually_quantity Defaults to 1.
			 * @param integer $quantity Quantity of the item added to the cart.
			 * @param integer $product_id ID of the product added to the cart.
			 * @param integer $variation_id Variation ID of the product added to the cart.
			 * @param array $cart_item_data Array of other cart item data.
			 * @return integer
			 */
			$request['quantity'] = apply_filters( 'woocommerce_add_to_cart_sold_individually_quantity', 1, $request['quantity'], $product_id, $variation_id, $request['cart_item_data'] );
		}

		return $request;
	}

	/**
	 * If variations are set, validate and format the values ready to add to the cart.
	 *
	 * @throws RouteException Exception if invalid data is detected.
	 *
	 * @param array $request Add to cart request params.
	 * @return array Updated request array.
	 */
	protected function parse_variation_data( $request ) {
		$product = $this->get_product_for_cart( $request );

		// Remove variation request if not needed.
		if ( ! $product->is_type( array( 'variation', 'variable' ) ) ) {
			$request['variation'] = [];
			return $request;
		}

		// Flatten data and format posted values.
		$variable_product_attributes = $this->get_variable_product_attributes( $product );
		$request['variation']        = $this->sanitize_variation_data( wp_list_pluck( $request['variation'], 'value', 'attribute' ), $variable_product_attributes );

		// If we have a parent product, find the variation ID.
		if ( $product->is_type( 'variable' ) ) {
			$request['id'] = $this->get_variation_id_from_variation_data( $request, $product );
		}

		// Now we have a variation ID, get the valid set of attributes for this variation. They will have an attribute_ prefix since they are from meta.
		$expected_attributes = wc_get_product_variation_attributes( $request['id'] );
		$missing_attributes  = [];

		foreach ( $variable_product_attributes as $attribute ) {
			if ( ! $attribute['is_variation'] ) {
				continue;
			}

			$prefixed_attribute_name = 'attribute_' . sanitize_title( $attribute['name'] );
			$expected_value          = isset( $expected_attributes[ $prefixed_attribute_name ] ) ? $expected_attributes[ $prefixed_attribute_name ] : '';
			$attribute_label         = wc_attribute_label( $attribute['name'] );

			if ( isset( $request['variation'][ wc_variation_attribute_name( $attribute['name'] ) ] ) ) {
				$given_value = $request['variation'][ wc_variation_attribute_name( $attribute['name'] ) ];

				if ( $expected_value === $given_value ) {
					continue;
				}

				// If valid values are empty, this is an 'any' variation so get all possible values.
				if ( '' === $expected_value && in_array( $given_value, $attribute->get_slugs(), true ) ) {
					continue;
				}

				throw new RouteException(
					'woocommerce_rest_invalid_variation_data',
					/* translators: %1$s: Attribute name, %2$s: Allowed values. */
					sprintf( __( 'Invalid value posted for %1$s. Allowed values: %2$s', 'woocommerce' ), $attribute_label, implode( ', ', $attribute->get_slugs() ) ),
					400
				);
			}

			// Fills request array with unspecified attributes that have default values. This ensures the variation always has full data.
			if ( '' !== $expected_value && ! isset( $request['variation'][ wc_variation_attribute_name( $attribute['name'] ) ] ) ) {
				$request['variation'][ wc_variation_attribute_name( $attribute['name'] ) ] = $expected_value;
			}

			// If no attribute was posted, only error if the variation has an 'any' attribute which requires a value.
			if ( '' === $expected_value ) {
				$missing_attributes[] = $attribute_label;
			}
		}

		if ( ! empty( $missing_attributes ) ) {
			throw new RouteException(
				'woocommerce_rest_missing_variation_data',
				/* translators: %s: Attribute name. */
				__( 'Missing variation data for variable product.', 'woocommerce' ) . ' ' . sprintf( _n( '%s is a required field', '%s are required fields', count( $missing_attributes ), 'woocommerce' ), wc_format_list_of_items( $missing_attributes ) ),
				400
			);
		}

		ksort( $request['variation'] );

		return $request;
	}

	/**
	 * Try to match request data to a variation ID and return the ID.
	 *
	 * @throws RouteException Exception if variation cannot be found.
	 *
	 * @param array       $request Add to cart request params.
	 * @param \WC_Product $product Product being added to the cart.
	 * @return int Matching variation ID.
	 */
	protected function get_variation_id_from_variation_data( $request, $product ) {
		$data_store       = \WC_Data_Store::load( 'product' );
		$match_attributes = $request['variation'];
		$variation_id     = $data_store->find_matching_product_variation( $product, $match_attributes );

		if ( empty( $variation_id ) ) {
			throw new RouteException(
				'woocommerce_rest_variation_id_from_variation_data',
				__( 'No matching variation found.', 'woocommerce' ),
				400
			);
		}

		return $variation_id;
	}

	/**
	 * Format and sanitize variation data posted to the API.
	 *
	 * Labels are converted to names (e.g. Size to pa_size), and values are cleaned.
	 *
	 * @throws RouteException Exception if variation cannot be found.
	 *
	 * @param array $variation_data Key value pairs of attributes and values.
	 * @param array $variable_product_attributes Product attributes we're expecting.
	 * @return array
	 */
	protected function sanitize_variation_data( $variation_data, $variable_product_attributes ) {
		$return = [];

		foreach ( $variable_product_attributes as $attribute ) {
			if ( ! $attribute['is_variation'] ) {
				continue;
			}
			$attribute_label          = wc_attribute_label( $attribute['name'] );
			$variation_attribute_name = wc_variation_attribute_name( $attribute['name'] );

			// Attribute labels e.g. Size.
			if ( isset( $variation_data[ $attribute_label ] ) ) {
				$return[ $variation_attribute_name ] =
					$attribute['is_taxonomy']
						?
						sanitize_title( $variation_data[ $attribute_label ] )
						:
						html_entity_decode(
							wc_clean( $variation_data[ $attribute_label ] ),
							ENT_QUOTES,
							get_bloginfo( 'charset' )
						);
				continue;
			}

			// Attribute slugs e.g. pa_size.
			if ( isset( $variation_data[ $attribute['name'] ] ) ) {
				$return[ $variation_attribute_name ] =
					$attribute['is_taxonomy']
						?
						sanitize_title( $variation_data[ $attribute['name'] ] )
						:
						html_entity_decode(
							wc_clean( $variation_data[ $attribute['name'] ] ),
							ENT_QUOTES,
							get_bloginfo( 'charset' )
						);
			}
		}
		return $return;
	}

	/**
	 * Get product attributes from the variable product (which may be the parent if the product object is a variation).
	 *
	 * @throws RouteException Exception if product is invalid.
	 *
	 * @param \WC_Product $product Product being added to the cart.
	 * @return array
	 */
	protected function get_variable_product_attributes( $product ) {
		if ( $product->is_type( 'variation' ) ) {
			$product = wc_get_product( $product->get_parent_id() );
		}

		if ( ! $product || 'trash' === $product->get_status() ) {
			throw new RouteException(
				'woocommerce_rest_cart_invalid_parent_product',
				__( 'This product cannot be added to the cart.', 'woocommerce' ),
				400
			);
		}

		return $product->get_attributes();
	}
}
CheckoutTrait.php000064400000014754151556130530010045 0ustar00<?php
namespace Automattic\WooCommerce\StoreApi\Utilities;

use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use Automattic\WooCommerce\StoreApi\Payments\PaymentContext;
use Automattic\WooCommerce\StoreApi\Payments\PaymentResult;

/**
 * CheckoutTrait
 *
 * Shared functionality for checkout route.
 */
trait CheckoutTrait {
	/**
	 * Prepare a single item for response. Handles setting the status based on the payment result.
	 *
	 * @param mixed            $item Item to format to schema.
	 * @param \WP_REST_Request $request Request object.
	 * @return \WP_REST_Response $response Response data.
	 */
	public function prepare_item_for_response( $item, \WP_REST_Request $request ) {
		$response     = parent::prepare_item_for_response( $item, $request );
		$status_codes = [
			'success' => 200,
			'pending' => 202,
			'failure' => 400,
			'error'   => 500,
		];

		if ( isset( $item->payment_result ) && $item->payment_result instanceof PaymentResult ) {
			$response->set_status( $status_codes[ $item->payment_result->status ] ?? 200 );
		}

		return $response;
	}

	/**
	 * For orders which do not require payment, just update status.
	 *
	 * @param \WP_REST_Request $request Request object.
	 * @param PaymentResult    $payment_result Payment result object.
	 */
	private function process_without_payment( \WP_REST_Request $request, PaymentResult $payment_result ) {
		// Transition the order to pending, and then completed. This ensures transactional emails fire for pending_to_complete events.
		$this->order->update_status( 'pending' );
		$this->order->payment_complete();

		// Mark the payment as successful.
		$payment_result->set_status( 'success' );
		$payment_result->set_redirect_url( $this->order->get_checkout_order_received_url() );
	}

	/**
	 * Fires an action hook instructing active payment gateways to process the payment for an order and provide a result.
	 *
	 * @throws RouteException On error.
	 *
	 * @param \WP_REST_Request $request Request object.
	 * @param PaymentResult    $payment_result Payment result object.
	 */
	private function process_payment( \WP_REST_Request $request, PaymentResult $payment_result ) {
		try {
			// Transition the order to pending before making payment.
			$this->order->update_status( 'pending' );

			// Prepare the payment context object to pass through payment hooks.
			$context = new PaymentContext();
			$context->set_payment_method( $this->get_request_payment_method_id( $request ) );
			$context->set_payment_data( $this->get_request_payment_data( $request ) );
			$context->set_order( $this->order );

			/**
			 * Process payment with context.
			 *
			 * @hook woocommerce_rest_checkout_process_payment_with_context
			 *
			 * @throws \Exception If there is an error taking payment, an \Exception object can be thrown with an error message.
			 *
			 * @param PaymentContext $context        Holds context for the payment, including order ID and payment method.
			 * @param PaymentResult  $payment_result Result object for the transaction.
			 */
			do_action_ref_array( 'woocommerce_rest_checkout_process_payment_with_context', [ $context, &$payment_result ] );

			if ( ! $payment_result instanceof PaymentResult ) {
				throw new RouteException( 'woocommerce_rest_checkout_invalid_payment_result', __( 'Invalid payment result received from payment method.', 'woocommerce' ), 500 );
			}
		} catch ( \Exception $e ) {
			throw new RouteException( 'woocommerce_rest_checkout_process_payment_error', $e->getMessage(), 402 );
		}
	}

	/**
	 * Gets the chosen payment method ID from the request.
	 *
	 * @throws RouteException On error.
	 * @param \WP_REST_Request $request Request object.
	 * @return string
	 */
	private function get_request_payment_method_id( \WP_REST_Request $request ) {
		$payment_method = $this->get_request_payment_method( $request );
		return is_null( $payment_method ) ? '' : $payment_method->id;
	}

	/**
	 * Gets and formats payment request data.
	 *
	 * @param \WP_REST_Request $request Request object.
	 * @return array
	 */
	private function get_request_payment_data( \WP_REST_Request $request ) {
		static $payment_data = [];
		if ( ! empty( $payment_data ) ) {
			return $payment_data;
		}
		if ( ! empty( $request['payment_data'] ) ) {
			foreach ( $request['payment_data'] as $data ) {
				$payment_data[ sanitize_key( $data['key'] ) ] = wc_clean( $data['value'] );
			}
		}

		return $payment_data;
	}

	/**
	 * Update the current order using the posted values from the request.
	 *
	 * @param \WP_REST_Request $request Full details about the request.
	 */
	private function update_order_from_request( \WP_REST_Request $request ) {
		$this->order->set_customer_note( $request['customer_note'] ?? '' );
		$this->order->set_payment_method( $this->get_request_payment_method_id( $request ) );
		$this->order->set_payment_method_title( $this->get_request_payment_method_title( $request ) );

		wc_do_deprecated_action(
			'__experimental_woocommerce_blocks_checkout_update_order_from_request',
			array(
				$this->order,
				$request,
			),
			'6.3.0',
			'woocommerce_store_api_checkout_update_order_from_request',
			'This action was deprecated in WooCommerce Blocks version 6.3.0. Please use woocommerce_store_api_checkout_update_order_from_request instead.'
		);

		wc_do_deprecated_action(
			'woocommerce_blocks_checkout_update_order_from_request',
			array(
				$this->order,
				$request,
			),
			'7.2.0',
			'woocommerce_store_api_checkout_update_order_from_request',
			'This action was deprecated in WooCommerce Blocks version 7.2.0. Please use woocommerce_store_api_checkout_update_order_from_request instead.'
		);

		/**
		 * Fires when the Checkout Block/Store API updates an order's from the API request data.
		 *
		 * This hook gives extensions the chance to update orders based on the data in the request. This can be used in
		 * conjunction with the ExtendSchema class to post custom data and then process it.
		 *
		 * @since 7.2.0
		 *
		 * @param \WC_Order $order Order object.
		 * @param \WP_REST_Request $request Full details about the request.
		 */
		do_action( 'woocommerce_store_api_checkout_update_order_from_request', $this->order, $request );

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

	/**
	 * Gets the chosen payment method title from the request.
	 *
	 * @throws RouteException On error.
	 * @param \WP_REST_Request $request Request object.
	 * @return string
	 */
	private function get_request_payment_method_title( \WP_REST_Request $request ) {
		$payment_method = $this->get_request_payment_method( $request );
		return is_null( $payment_method ) ? '' : $payment_method->get_title();
	}
}
DraftOrderTrait.php000064400000003417151556130530010326 0ustar00<?php
namespace Automattic\WooCommerce\StoreApi\Utilities;

/**
 * DraftOrderTrait
 *
 * Shared functionality for getting and setting draft order IDs from session.
 */
trait DraftOrderTrait {
	/**
	 * Gets draft order data from the customer session.
	 *
	 * @return integer
	 */
	protected function get_draft_order_id() {
		if ( ! wc()->session ) {
			wc()->initialize_session();
		}
		return wc()->session->get( 'store_api_draft_order', 0 );
	}

	/**
	 * Updates draft order data in the customer session.
	 *
	 * @param integer $order_id Draft order ID.
	 */
	protected function set_draft_order_id( $order_id ) {
		if ( ! wc()->session ) {
			wc()->initialize_session();
		}
		wc()->session->set( 'store_api_draft_order', $order_id );
	}

	/**
	 * Uses the draft order ID to return an order object, if valid.
	 *
	 * @return \WC_Order|null;
	 */
	protected function get_draft_order() {
		$draft_order_id = $this->get_draft_order_id();
		$draft_order    = $draft_order_id ? wc_get_order( $draft_order_id ) : false;

		return $this->is_valid_draft_order( $draft_order ) ? $draft_order : null;
	}

	/**
	 * Whether the passed argument is a draft order or an order that is
	 * pending/failed and the cart hasn't changed.
	 *
	 * @param \WC_Order $order_object Order object to check.
	 * @return boolean Whether the order is valid as a draft order.
	 */
	protected function is_valid_draft_order( $order_object ) {
		if ( ! $order_object instanceof \WC_Order ) {
			return false;
		}

		// Draft orders are okay.
		if ( $order_object->has_status( 'checkout-draft' ) ) {
			return true;
		}

		// Pending and failed orders can be retried if the cart hasn't changed.
		if ( $order_object->needs_payment() && $order_object->has_cart_hash( wc()->cart->get_cart_hash() ) ) {
			return true;
		}

		return false;
	}
}
JsonWebToken.php000064400000012040151556130530007626 0ustar00<?php

namespace Automattic\WooCommerce\StoreApi\Utilities;

/**
 * JsonWebToken class.
 *
 * Simple Json Web Token generator & verifier static utility class, currently supporting only HS256 signatures.
 */
final class JsonWebToken {

	/**
	 * JWT header type.
	 *
	 * @var string
	 */
	private static $type = 'JWT';

	/**
	 * JWT algorithm to generate signature.
	 *
	 * @var string
	 */
	private static $algorithm = 'HS256';

	/**
	 * Generates a token from provided data and secret.
	 *
	 * @param array  $payload Payload data.
	 * @param string $secret The secret used to generate the signature.
	 *
	 * @return string
	 */
	public static function create( array $payload, string $secret ) {
		$header    = self::to_base_64_url( self::generate_header() );
		$payload   = self::to_base_64_url( self::generate_payload( $payload ) );
		$signature = self::to_base_64_url( self::generate_signature( $header . '.' . $payload, $secret ) );

		return $header . '.' . $payload . '.' . $signature;
	}

	/**
	 * Validates a provided token against the provided secret.
	 * Checks for format, valid header for our class, expiration claim validity and signature.
	 * https://datatracker.ietf.org/doc/html/rfc7519#section-7.2
	 *
	 * @param string $token Full token string.
	 * @param string $secret The secret used to generate the signature.
	 *
	 * @return bool
	 */
	public static function validate( string $token, string $secret ) {
		/**
		 * Confirm the structure of a JSON Web Token, it has three parts separated
		 * by dots and complies with Base64URL standards.
		 */
		if ( preg_match( '/^[a-zA-Z\d\-_=]+\.[a-zA-Z\d\-_=]+\.[a-zA-Z\d\-_=]+$/', $token ) !== 1 ) {
			return false;
		}

		$parts = self::get_parts( $token );

		/**
		 * Check if header declares a supported JWT by this class.
		 */
		if (
			! is_object( $parts->header ) ||
			! property_exists( $parts->header, 'typ' ) ||
			! property_exists( $parts->header, 'alg' ) ||
			self::$type !== $parts->header->typ ||
			self::$algorithm !== $parts->header->alg
		) {
			return false;
		}

		/**
		 * Check if token is expired.
		 */
		if ( ! property_exists( $parts->payload, 'exp' ) || time() > (int) $parts->payload->exp ) {
			return false;
		}

		/**
		 * Check if the token is based on our secret.
		 */
		$encoded_regenerated_signature = self::to_base_64_url(
			self::generate_signature( $parts->header_encoded . '.' . $parts->payload_encoded, $secret )
		);

		return hash_equals( $encoded_regenerated_signature, $parts->signature_encoded );
	}

	/**
	 * Returns the decoded/encoded header, payload and signature from a token string.
	 *
	 * @param string $token Full token string.
	 *
	 * @return object
	 */
	public static function get_parts( string $token ) {
		$parts = explode( '.', $token );

		return (object) array(
			'header'            => json_decode( self::from_base_64_url( $parts[0] ) ),
			'header_encoded'    => $parts[0],
			'payload'           => json_decode( self::from_base_64_url( $parts[1] ) ),
			'payload_encoded'   => $parts[1],
			'signature'         => self::from_base_64_url( $parts[2] ),
			'signature_encoded' => $parts[2],

		);
	}

	/**
	 * Generates the json formatted header for our HS256 JWT token.
	 *
	 * @return string|bool
	 */
	private static function generate_header() {
		return wp_json_encode(
			array(
				'alg' => self::$algorithm,
				'typ' => self::$type,
			)
		);
	}

	/**
	 * Generates a sha256 signature for the provided string using the provided secret.
	 *
	 * @param string $string Header + Payload token substring.
	 * @param string $secret The secret used to generate the signature.
	 *
	 * @return false|string
	 */
	private static function generate_signature( string $string, string $secret ) {
		return hash_hmac(
			'sha256',
			$string,
			$secret,
			true
		);
	}

	/**
	 * Generates the payload in json formatted string.
	 *
	 * @param array $payload Payload data.
	 *
	 * @return string|bool
	 */
	private static function generate_payload( array $payload ) {
		return wp_json_encode( array_merge( $payload, [ 'iat' => time() ] ) );
	}

	/**
	 * Encodes a string to url safe base64.
	 *
	 * @param string $string The string to be encoded.
	 *
	 * @return string
	 */
	private static function to_base_64_url( string $string ) {
		return str_replace(
			array( '+', '/', '=' ),
			array( '-', '_', '' ),
			base64_encode( $string ) // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
		);
	}

	/**
	 * Decodes a string encoded using url safe base64, supporting auto padding.
	 *
	 * @param string $string the string to be decoded.
	 *
	 * @return string
	 */
	private static function from_base_64_url( string $string ) {
		/**
		 * Add padding to base64 strings which require it. Some base64 URL strings
		 * which are decoded will have missing padding which is represented by the
		 * equals sign.
		 */
		if ( strlen( $string ) % 4 !== 0 ) {
			return self::from_base_64_url( $string . '=' );
		}

		return base64_decode( // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
			str_replace(
				array( '-', '_' ),
				array( '+', '/' ),
				$string
			)
		);
	}
}
LocalPickupUtils.php000064400000002574151556130540010521 0ustar00<?php
namespace Automattic\WooCommerce\StoreApi\Utilities;

/**
 * Util class for local pickup related functionality, this contains methods that need to be accessed from places besides
 * the ShippingController, i.e. the OrderController.
 */
class LocalPickupUtils {

	/**
	 * Checks if WC Blocks local pickup is enabled.
	 *
	 * @return bool True if local pickup is enabled.
	 */
	public static function is_local_pickup_enabled() {
		$pickup_location_settings = get_option( 'woocommerce_pickup_location_settings', [] );
		return wc_string_to_bool( $pickup_location_settings['enabled'] ?? 'no' );
	}
	/**
	 * Gets a list of payment method ids that support the 'local-pickup' feature.
	 *
	 * @return string[] List of payment method ids that support the 'local-pickup' feature.
	 */
	public static function get_local_pickup_method_ids() {
		$all_methods_supporting_local_pickup = array_reduce(
			WC()->shipping()->get_shipping_methods(),
			function( $methods, $method ) {
				if ( $method->supports( 'local-pickup' ) ) {
					$methods[] = $method->id;
				}
				return $methods;
			},
			array()
		);

		// We use array_values because this will be used in JS, so we don't need the (numerical) keys.
		return array_values(
		// This array_unique is necessary because WC()->shipping()->get_shipping_methods() can return duplicates.
			array_unique(
				$all_methods_supporting_local_pickup
			)
		);
	}
}
NoticeHandler.php000064400000004165151556130540010007 0ustar00<?php
namespace Automattic\WooCommerce\StoreApi\Utilities;

use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use WP_Error;

/**
 * NoticeHandler class.
 * Helper class to handle notices.
 */
class NoticeHandler {

	/**
	 * Convert queued error notices into an exception.
	 *
	 * For example, Payment methods may add error notices during validate_fields call to prevent checkout.
	 * Since we're not rendering notices at all, we need to convert them to exceptions.
	 *
	 * This method will find the first error message and thrown an exception instead. Discards notices once complete.
	 *
	 * @throws RouteException If an error notice is detected, Exception is thrown.
	 *
	 * @param string $error_code Error code for the thrown exceptions.
	 */
	public static function convert_notices_to_exceptions( $error_code = 'unknown_server_error' ) {
		if ( 0 === wc_notice_count( 'error' ) ) {
			wc_clear_notices();
			return;
		}

		$error_notices = wc_get_notices( 'error' );

		// Prevent notices from being output later on.
		wc_clear_notices();

		foreach ( $error_notices as $error_notice ) {
			throw new RouteException( $error_code, wp_strip_all_tags( $error_notice['notice'] ), 400 );
		}
	}

	/**
	 * Collects queued error notices into a \WP_Error.
	 *
	 * For example, cart validation processes may add error notices to prevent checkout.
	 * Since we're not rendering notices at all, we need to catch them and group them in a single WP_Error instance.
	 *
	 * This method will discard notices once complete.
	 *
	 * @param string $error_code Error code for the thrown exceptions.
	 *
	 * @return \WP_Error The WP_Error object containing all error notices.
	 */
	public static function convert_notices_to_wp_errors( $error_code = 'unknown_server_error' ) {
		$errors = new WP_Error();

		if ( 0 === wc_notice_count( 'error' ) ) {
			wc_clear_notices();
			return $errors;
		}

		$error_notices = wc_get_notices( 'error' );

		// Prevent notices from being output later on.
		wc_clear_notices();

		foreach ( $error_notices as $error_notice ) {
			$errors->add( $error_code, wp_strip_all_tags( $error_notice['notice'] ) );
		}

		return $errors;
	}
}
OrderAuthorizationTrait.php000064400000004352151556130540012126 0ustar00<?php
namespace Automattic\WooCommerce\StoreApi\Utilities;

use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;

/**
 * OrderAuthorizationTrait
 *
 * Shared functionality for getting order authorization.
 */
trait OrderAuthorizationTrait {
	/**
	 * Check if authorized to get the order.
	 *
	 * @throws RouteException If the order is not found or the order key is invalid.
	 *
	 * @param \WP_REST_Request $request Request object.
	 * @return boolean|WP_Error
	 */
	public function is_authorized( \WP_REST_Request $request ) {
		$order_id      = absint( $request['id'] );
		$order_key     = sanitize_text_field( wp_unslash( $request->get_param( 'key' ) ) );
		$billing_email = sanitize_text_field( wp_unslash( $request->get_param( 'billing_email' ) ) );

		try {
			// In this context, pay_for_order capability checks that the current user ID matches the customer ID stored
			// within the order, or if the order was placed by a guest.
			// See https://github.com/woocommerce/woocommerce/blob/abcedbefe02f9e89122771100c42ff588da3e8e0/plugins/woocommerce/includes/wc-user-functions.php#L458.
			if ( ! current_user_can( 'pay_for_order', $order_id ) ) {
				throw new RouteException( 'woocommerce_rest_invalid_user', __( 'This order belongs to a different customer.', 'woocommerce' ), 403 );
			}
			if ( get_current_user_id() === 0 ) {
				$this->order_controller->validate_order_key( $order_id, $order_key );
				$this->validate_billing_email_matches_order( $order_id, $billing_email );
			}
		} catch ( RouteException $error ) {
			return new \WP_Error(
				$error->getErrorCode(),
				$error->getMessage(),
				array( 'status' => $error->getCode() )
			);
		}

		return true;
	}

	/**
	 * Validate a given billing email against an existing order.
	 *
	 * @throws RouteException Exception if invalid data is detected.
	 * @param integer $order_id Order ID.
	 * @param string  $billing_email Billing email.
	 */
	public function validate_billing_email_matches_order( $order_id, $billing_email ) {
		$order = wc_get_order( $order_id );

		if ( ! $order || ! $billing_email || $order->get_billing_email() !== $billing_email ) {
			throw new RouteException( 'woocommerce_rest_invalid_billing_email', __( 'Invalid billing email provided.', 'woocommerce' ), 401 );
		}
	}

}
OrderController.php000064400000056121151556130550010407 0ustar00<?php
namespace Automattic\WooCommerce\StoreApi\Utilities;

use \Exception;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;

/**
 * OrderController class.
 * Helper class which creates and syncs orders with the cart.
 */
class OrderController {

	/**
	 * Create order and set props based on global settings.
	 *
	 * @throws RouteException Exception if invalid data is detected.
	 *
	 * @return \WC_Order A new order object.
	 */
	public function create_order_from_cart() {
		if ( wc()->cart->is_empty() ) {
			throw new RouteException(
				'woocommerce_rest_cart_empty',
				__( 'Cannot create order from empty cart.', 'woocommerce' ),
				400
			);
		}

		add_filter( 'woocommerce_default_order_status', array( $this, 'default_order_status' ) );

		$order = new \WC_Order();
		$order->set_status( 'checkout-draft' );
		$order->set_created_via( 'store-api' );
		$this->update_order_from_cart( $order );

		remove_filter( 'woocommerce_default_order_status', array( $this, 'default_order_status' ) );

		return $order;
	}

	/**
	 * Update an order using data from the current cart.
	 *
	 * @param \WC_Order $order The order object to update.
	 * @param boolean   $update_totals Whether to update totals or not.
	 */
	public function update_order_from_cart( \WC_Order $order, $update_totals = true ) {
		/**
		 * This filter ensures that local pickup locations are still used for order taxes by forcing the address used to
		 * calculate tax for an order to match the current address of the customer.
		 *
		 * -    The method `$customer->get_taxable_address()` runs the filter `woocommerce_customer_taxable_address`.
		 * -    While we have a session, our `ShippingController::filter_taxable_address` function uses this hook to set
		 *      the customer address to the pickup location address if local pickup is the chosen method.
		 *
		 * Without this code in place, `$customer->get_taxable_address()` is not used when order taxes are calculated,
		 * resulting in the wrong taxes being applied with local pickup.
		 *
		 * The alternative would be to instead use `woocommerce_order_get_tax_location` to return the pickup location
		 * address directly, however since we have the customer filter in place we don't need to duplicate effort.
		 *
		 * @see \WC_Abstract_Order::get_tax_location()
		 */
		add_filter(
			'woocommerce_order_get_tax_location',
			function( $location ) {

				if ( ! is_null( wc()->customer ) ) {

					$taxable_address = wc()->customer->get_taxable_address();

					$location = array(
						'country'  => $taxable_address[0],
						'state'    => $taxable_address[1],
						'postcode' => $taxable_address[2],
						'city'     => $taxable_address[3],
					);
				}

				return $location;
			}
		);

		// Ensure cart is current.
		if ( $update_totals ) {
			wc()->cart->calculate_shipping();
			wc()->cart->calculate_totals();
		}

		// Update the current order to match the current cart.
		$this->update_line_items_from_cart( $order );
		$this->update_addresses_from_cart( $order );
		$order->set_currency( get_woocommerce_currency() );
		$order->set_prices_include_tax( 'yes' === get_option( 'woocommerce_prices_include_tax' ) );
		$order->set_customer_id( get_current_user_id() );
		$order->set_customer_ip_address( \WC_Geolocation::get_ip_address() );
		$order->set_customer_user_agent( wc_get_user_agent() );
		$order->update_meta_data( 'is_vat_exempt', wc()->cart->get_customer()->get_is_vat_exempt() ? 'yes' : 'no' );
		$order->calculate_totals();
	}

	/**
	 * Copies order data to customer object (not the session), so values persist for future checkouts.
	 *
	 * @param \WC_Order $order Order object.
	 */
	public function sync_customer_data_with_order( \WC_Order $order ) {
		if ( $order->get_customer_id() ) {
			$customer = new \WC_Customer( $order->get_customer_id() );
			$customer->set_props(
				[
					'billing_first_name'  => $order->get_billing_first_name(),
					'billing_last_name'   => $order->get_billing_last_name(),
					'billing_company'     => $order->get_billing_company(),
					'billing_address_1'   => $order->get_billing_address_1(),
					'billing_address_2'   => $order->get_billing_address_2(),
					'billing_city'        => $order->get_billing_city(),
					'billing_state'       => $order->get_billing_state(),
					'billing_postcode'    => $order->get_billing_postcode(),
					'billing_country'     => $order->get_billing_country(),
					'billing_email'       => $order->get_billing_email(),
					'billing_phone'       => $order->get_billing_phone(),
					'shipping_first_name' => $order->get_shipping_first_name(),
					'shipping_last_name'  => $order->get_shipping_last_name(),
					'shipping_company'    => $order->get_shipping_company(),
					'shipping_address_1'  => $order->get_shipping_address_1(),
					'shipping_address_2'  => $order->get_shipping_address_2(),
					'shipping_city'       => $order->get_shipping_city(),
					'shipping_state'      => $order->get_shipping_state(),
					'shipping_postcode'   => $order->get_shipping_postcode(),
					'shipping_country'    => $order->get_shipping_country(),
					'shipping_phone'      => $order->get_shipping_phone(),
				]
			);

			$customer->save();
		};
	}

	/**
	 * Final validation ran before payment is taken.
	 *
	 * By this point we have an order populated with customer data and items.
	 *
	 * @throws RouteException Exception if invalid data is detected.
	 * @param \WC_Order $order Order object.
	 */
	public function validate_order_before_payment( \WC_Order $order ) {
		$needs_shipping          = wc()->cart->needs_shipping();
		$chosen_shipping_methods = wc()->session->get( 'chosen_shipping_methods' );

		$this->validate_coupons( $order );
		$this->validate_email( $order );
		$this->validate_selected_shipping_methods( $needs_shipping, $chosen_shipping_methods );
		$this->validate_addresses( $order );
	}

	/**
	 * Convert a coupon code to a coupon object.
	 *
	 * @param string $coupon_code Coupon code.
	 * @return \WC_Coupon Coupon object.
	 */
	protected function get_coupon( $coupon_code ) {
		return new \WC_Coupon( $coupon_code );
	}

	/**
	 * Validate coupons applied to the order and remove those that are not valid.
	 *
	 * @throws RouteException Exception if invalid data is detected.
	 * @param \WC_Order $order Order object.
	 */
	protected function validate_coupons( \WC_Order $order ) {
		$coupon_codes  = $order->get_coupon_codes();
		$coupons       = array_filter( array_map( [ $this, 'get_coupon' ], $coupon_codes ) );
		$validators    = [ 'validate_coupon_email_restriction', 'validate_coupon_usage_limit' ];
		$coupon_errors = [];

		foreach ( $coupons as $coupon ) {
			try {
				array_walk(
					$validators,
					function( $validator, $index, $params ) {
						call_user_func_array( [ $this, $validator ], $params );
					},
					[ $coupon, $order ]
				);
			} catch ( Exception $error ) {
				$coupon_errors[ $coupon->get_code() ] = $error->getMessage();
			}
		}

		if ( $coupon_errors ) {
			// Remove all coupons that were not valid.
			foreach ( $coupon_errors as $coupon_code => $message ) {
				wc()->cart->remove_coupon( $coupon_code );
			}

			// Recalculate totals.
			wc()->cart->calculate_totals();

			// Re-sync order with cart.
			$this->update_order_from_cart( $order );

			// Return exception so customer can review before payment.
			throw new RouteException(
				'woocommerce_rest_cart_coupon_errors',
				sprintf(
					/* translators: %s Coupon codes. */
					__( 'Invalid coupons were removed from the cart: "%s"', 'woocommerce' ),
					implode( '", "', array_keys( $coupon_errors ) )
				),
				409,
				[
					'removed_coupons' => $coupon_errors,
				]
			);
		}
	}

	/**
	 * Validates the customer email. This is a required field.
	 *
	 * @throws RouteException Exception if invalid data is detected.
	 * @param \WC_Order $order Order object.
	 */
	protected function validate_email( \WC_Order $order ) {
		$email = $order->get_billing_email();

		if ( empty( $email ) ) {
			throw new RouteException(
				'woocommerce_rest_missing_email_address',
				__( 'A valid email address is required', 'woocommerce' ),
				400
			);
		}

		if ( ! is_email( $email ) ) {
			throw new RouteException(
				'woocommerce_rest_invalid_email_address',
				sprintf(
					/* translators: %s provided email. */
					__( 'The provided email address (%s) is not valid—please provide a valid email address', 'woocommerce' ),
					esc_html( $email )
				),
				400
			);
		}
	}

	/**
	 * Validates customer address data based on the locale to ensure required fields are set.
	 *
	 * @throws RouteException Exception if invalid data is detected.
	 * @param \WC_Order $order Order object.
	 */
	protected function validate_addresses( \WC_Order $order ) {
		$errors           = new \WP_Error();
		$needs_shipping   = wc()->cart->needs_shipping();
		$billing_address  = $order->get_address( 'billing' );
		$shipping_address = $order->get_address( 'shipping' );

		if ( $needs_shipping && ! $this->validate_allowed_country( $shipping_address['country'], (array) wc()->countries->get_shipping_countries() ) ) {
			throw new RouteException(
				'woocommerce_rest_invalid_address_country',
				sprintf(
					/* translators: %s country code. */
					__( 'Sorry, we do not ship orders to the provided country (%s)', 'woocommerce' ),
					$shipping_address['country']
				),
				400,
				[
					'allowed_countries' => array_keys( wc()->countries->get_shipping_countries() ),
				]
			);
		}

		if ( ! $this->validate_allowed_country( $billing_address['country'], (array) wc()->countries->get_allowed_countries() ) ) {
			throw new RouteException(
				'woocommerce_rest_invalid_address_country',
				sprintf(
					/* translators: %s country code. */
					__( 'Sorry, we do not allow orders from the provided country (%s)', 'woocommerce' ),
					$billing_address['country']
				),
				400,
				[
					'allowed_countries' => array_keys( wc()->countries->get_allowed_countries() ),
				]
			);
		}

		if ( $needs_shipping ) {
			$this->validate_address_fields( $shipping_address, 'shipping', $errors );
		}
		$this->validate_address_fields( $billing_address, 'billing', $errors );

		if ( ! $errors->has_errors() ) {
			return;
		}

		$errors_by_code = [];
		$error_codes    = $errors->get_error_codes();
		foreach ( $error_codes as $code ) {
			$errors_by_code[ $code ] = $errors->get_error_messages( $code );
		}

		// Surface errors from first code.
		foreach ( $errors_by_code as $code => $error_messages ) {
			throw new RouteException(
				'woocommerce_rest_invalid_address',
				sprintf(
					/* translators: %s Address type. */
					__( 'There was a problem with the provided %s:', 'woocommerce' ) . ' ' . implode( ', ', $error_messages ),
					'shipping' === $code ? __( 'shipping address', 'woocommerce' ) : __( 'billing address', 'woocommerce' )
				),
				400,
				[
					'errors' => $errors_by_code,
				]
			);
		}
	}

	/**
	 * Check all required address fields are set and return errors if not.
	 *
	 * @param string $country Country code.
	 * @param array  $allowed_countries List of valid country codes.
	 * @return boolean True if valid.
	 */
	protected function validate_allowed_country( $country, array $allowed_countries ) {
		return array_key_exists( $country, $allowed_countries );
	}

	/**
	 * Check all required address fields are set and return errors if not.
	 *
	 * @param array     $address Address array.
	 * @param string    $address_type billing or shipping address, used in error messages.
	 * @param \WP_Error $errors Error object.
	 */
	protected function validate_address_fields( $address, $address_type, \WP_Error $errors ) {
		$all_locales    = wc()->countries->get_country_locale();
		$current_locale = isset( $all_locales[ $address['country'] ] ) ? $all_locales[ $address['country'] ] : [];

		/**
		 * We are not using wc()->counties->get_default_address_fields() here because that is filtered. Instead, this array
		 * is based on assets/js/base/components/cart-checkout/address-form/default-address-fields.js
		 */
		$address_fields = [
			'first_name' => [
				'label'    => __( 'First name', 'woocommerce' ),
				'required' => true,
			],
			'last_name'  => [
				'label'    => __( 'Last name', 'woocommerce' ),
				'required' => true,
			],
			'company'    => [
				'label'    => __( 'Company', 'woocommerce' ),
				'required' => false,
			],
			'address_1'  => [
				'label'    => __( 'Address', 'woocommerce' ),
				'required' => true,
			],
			'address_2'  => [
				'label'    => __( 'Apartment, suite, etc.', 'woocommerce' ),
				'required' => false,
			],
			'country'    => [
				'label'    => __( 'Country/Region', 'woocommerce' ),
				'required' => true,
			],
			'city'       => [
				'label'    => __( 'City', 'woocommerce' ),
				'required' => true,
			],
			'state'      => [
				'label'    => __( 'State/County', 'woocommerce' ),
				'required' => true,
			],
			'postcode'   => [
				'label'    => __( 'Postal code', 'woocommerce' ),
				'required' => true,
			],
		];

		if ( $current_locale ) {
			foreach ( $current_locale as $key => $field ) {
				if ( isset( $address_fields[ $key ] ) ) {
					$address_fields[ $key ]['label']    = isset( $field['label'] ) ? $field['label'] : $address_fields[ $key ]['label'];
					$address_fields[ $key ]['required'] = isset( $field['required'] ) ? $field['required'] : $address_fields[ $key ]['required'];
				}
			}
		}

		foreach ( $address_fields as $address_field_key => $address_field ) {
			if ( empty( $address[ $address_field_key ] ) && $address_field['required'] ) {
				/* translators: %s Field label. */
				$errors->add( $address_type, sprintf( __( '%s is required', 'woocommerce' ), $address_field['label'] ), $address_field_key );
			}
		}
	}

	/**
	 * Check email restrictions of a coupon against the order.
	 *
	 * @throws Exception Exception if invalid data is detected.
	 * @param \WC_Coupon $coupon Coupon object applied to the cart.
	 * @param \WC_Order  $order Order object.
	 */
	protected function validate_coupon_email_restriction( \WC_Coupon $coupon, \WC_Order $order ) {
		$restrictions = $coupon->get_email_restrictions();

		if ( ! empty( $restrictions ) && $order->get_billing_email() && ! wc()->cart->is_coupon_emails_allowed( [ $order->get_billing_email() ], $restrictions ) ) {
			throw new Exception( $coupon->get_coupon_error( \WC_Coupon::E_WC_COUPON_NOT_YOURS_REMOVED ) );
		}
	}

	/**
	 * Check usage restrictions of a coupon against the order.
	 *
	 * @throws Exception Exception if invalid data is detected.
	 * @param \WC_Coupon $coupon Coupon object applied to the cart.
	 * @param \WC_Order  $order Order object.
	 */
	protected function validate_coupon_usage_limit( \WC_Coupon $coupon, \WC_Order $order ) {
		$coupon_usage_limit = $coupon->get_usage_limit_per_user();

		if ( $coupon_usage_limit > 0 ) {
			$data_store  = $coupon->get_data_store();
			$usage_count = $order->get_customer_id() ? $data_store->get_usage_by_user_id( $coupon, $order->get_customer_id() ) : $data_store->get_usage_by_email( $coupon, $order->get_billing_email() );

			if ( $usage_count >= $coupon_usage_limit ) {
				throw new Exception( $coupon->get_coupon_error( \WC_Coupon::E_WC_COUPON_USAGE_LIMIT_REACHED ) );
			}
		}
	}

	/**
	 * Check there is a shipping method if it requires shipping.
	 *
	 * @throws RouteException Exception if invalid data is detected.
	 * @param boolean $needs_shipping Current order needs shipping.
	 * @param array   $chosen_shipping_methods Array of shipping methods.
	 */
	public function validate_selected_shipping_methods( $needs_shipping, $chosen_shipping_methods = array() ) {
		if ( ! $needs_shipping || ! is_array( $chosen_shipping_methods ) ) {
			return;
		}

		foreach ( $chosen_shipping_methods as $chosen_shipping_method ) {
			if ( false === $chosen_shipping_method ) {
				throw new RouteException(
					'woocommerce_rest_invalid_shipping_option',
					__( 'Sorry, this order requires a shipping option.', 'woocommerce' ),
					400,
					[]
				);
			}
		}
	}

	/**
	 * Validate a given order key against an existing order.
	 *
	 * @throws RouteException Exception if invalid data is detected.
	 * @param integer $order_id Order ID.
	 * @param string  $order_key Order key.
	 */
	public function validate_order_key( $order_id, $order_key ) {
		$order = wc_get_order( $order_id );

		if ( ! $order || ! $order_key || $order->get_id() !== $order_id || ! hash_equals( $order->get_order_key(), $order_key ) ) {
			throw new RouteException( 'woocommerce_rest_invalid_order', __( 'Invalid order ID or key provided.', 'woocommerce' ), 401 );
		}
	}

	/**
	 * Get errors for order stock on failed orders.
	 *
	 * @throws RouteException Exception if invalid data is detected.
	 * @param integer $order_id Order ID.
	 */
	public function get_failed_order_stock_error( $order_id ) {
		$order = wc_get_order( $order_id );

		// Ensure order items are still stocked if paying for a failed order. Pending orders do not need this check because stock is held.
		if ( ! $order->has_status( wc_get_is_pending_statuses() ) ) {
			$quantities = array();

			foreach ( $order->get_items() as $item_key => $item ) {
				if ( $item && is_callable( array( $item, 'get_product' ) ) ) {
					$product = $item->get_product();

					if ( ! $product ) {
						continue;
					}

					$quantities[ $product->get_stock_managed_by_id() ] = isset( $quantities[ $product->get_stock_managed_by_id() ] ) ? $quantities[ $product->get_stock_managed_by_id() ] + $item->get_quantity() : $item->get_quantity();
				}
			}

			// Stock levels may already have been adjusted for this order (in which case we don't need to worry about checking for low stock).
			if ( ! $order->get_data_store()->get_stock_reduced( $order->get_id() ) ) {
				foreach ( $order->get_items() as $item_key => $item ) {
					if ( $item && is_callable( array( $item, 'get_product' ) ) ) {
						$product = $item->get_product();

						if ( ! $product ) {
							continue;
						}

						/**
						 * Filters whether or not the product is in stock for this pay for order.
						 *
						 * @param boolean True if in stock.
						 * @param \WC_Product $product Product.
						 * @param \WC_Order $order Order.
						 *
						 * @since 9.8.0-dev
						 */
						if ( ! apply_filters( 'woocommerce_pay_order_product_in_stock', $product->is_in_stock(), $product, $order ) ) {
							return array(
								'code'    => 'woocommerce_rest_out_of_stock',
								/* translators: %s: product name */
								'message' => sprintf( __( 'Sorry, "%s" is no longer in stock so this order cannot be paid for. We apologize for any inconvenience caused.', 'woocommerce' ), $product->get_name() ),
							);
						}

						// We only need to check products managing stock, with a limited stock qty.
						if ( ! $product->managing_stock() || $product->backorders_allowed() ) {
							continue;
						}

						// Check stock based on all items in the cart and consider any held stock within pending orders.
						$held_stock     = wc_get_held_stock_quantity( $product, $order->get_id() );
						$required_stock = $quantities[ $product->get_stock_managed_by_id() ];

						/**
						 * Filters whether or not the product has enough stock.
						 *
						 * @param boolean True if has enough stock.
						 * @param \WC_Product $product Product.
						 * @param \WC_Order $order Order.
						 *
						 * @since 9.8.0-dev
						 */
						if ( ! apply_filters( 'woocommerce_pay_order_product_has_enough_stock', ( $product->get_stock_quantity() >= ( $held_stock + $required_stock ) ), $product, $order ) ) {
							/* translators: 1: product name 2: quantity in stock */
							return array(
								'code'    => 'woocommerce_rest_out_of_stock',
								/* translators: %s: product name */
								'message' => sprintf( __( 'Sorry, we do not have enough "%1$s" in stock to fulfill your order (%2$s available). We apologize for any inconvenience caused.', 'woocommerce' ), $product->get_name(), wc_format_stock_quantity_for_display( $product->get_stock_quantity() - $held_stock, $product ) ),
							);
						}
					}
				}
			}
		}

		return null;
	}

	/**
	 * Changes default order status to draft for orders created via this API.
	 *
	 * @return string
	 */
	public function default_order_status() {
		return 'checkout-draft';
	}

	/**
	 * Create order line items.
	 *
	 * @param \WC_Order $order The order object to update.
	 */
	protected function update_line_items_from_cart( \WC_Order $order ) {
		$cart_controller = new CartController();
		$cart            = $cart_controller->get_cart_instance();
		$cart_hashes     = $cart_controller->get_cart_hashes();

		if ( $order->get_cart_hash() !== $cart_hashes['line_items'] ) {
			$order->set_cart_hash( $cart_hashes['line_items'] );
			$order->remove_order_items( 'line_item' );
			wc()->checkout->create_order_line_items( $order, $cart );
		}

		if ( $order->get_meta_data( '_shipping_hash' ) !== $cart_hashes['shipping'] ) {
			$order->update_meta_data( '_shipping_hash', $cart_hashes['shipping'] );
			$order->remove_order_items( 'shipping' );
			wc()->checkout->create_order_shipping_lines( $order, wc()->session->get( 'chosen_shipping_methods' ), wc()->shipping()->get_packages() );
		}

		if ( $order->get_meta_data( '_coupons_hash' ) !== $cart_hashes['coupons'] ) {
			$order->remove_order_items( 'coupon' );
			$order->update_meta_data( '_coupons_hash', $cart_hashes['coupons'] );
			wc()->checkout->create_order_coupon_lines( $order, $cart );
		}

		if ( $order->get_meta_data( '_fees_hash' ) !== $cart_hashes['fees'] ) {
			$order->update_meta_data( '_fees_hash', $cart_hashes['fees'] );
			$order->remove_order_items( 'fee' );
			wc()->checkout->create_order_fee_lines( $order, $cart );
		}

		if ( $order->get_meta_data( '_taxes_hash' ) !== $cart_hashes['taxes'] ) {
			$order->update_meta_data( '_taxes_hash', $cart_hashes['taxes'] );
			$order->remove_order_items( 'tax' );
			wc()->checkout->create_order_tax_lines( $order, $cart );
		}
	}

	/**
	 * Update address data from cart and/or customer session data.
	 *
	 * @param \WC_Order $order The order object to update.
	 */
	protected function update_addresses_from_cart( \WC_Order $order ) {
		$order->set_props(
			[
				'billing_first_name'  => wc()->customer->get_billing_first_name(),
				'billing_last_name'   => wc()->customer->get_billing_last_name(),
				'billing_company'     => wc()->customer->get_billing_company(),
				'billing_address_1'   => wc()->customer->get_billing_address_1(),
				'billing_address_2'   => wc()->customer->get_billing_address_2(),
				'billing_city'        => wc()->customer->get_billing_city(),
				'billing_state'       => wc()->customer->get_billing_state(),
				'billing_postcode'    => wc()->customer->get_billing_postcode(),
				'billing_country'     => wc()->customer->get_billing_country(),
				'billing_email'       => wc()->customer->get_billing_email(),
				'billing_phone'       => wc()->customer->get_billing_phone(),
				'shipping_first_name' => wc()->customer->get_shipping_first_name(),
				'shipping_last_name'  => wc()->customer->get_shipping_last_name(),
				'shipping_company'    => wc()->customer->get_shipping_company(),
				'shipping_address_1'  => wc()->customer->get_shipping_address_1(),
				'shipping_address_2'  => wc()->customer->get_shipping_address_2(),
				'shipping_city'       => wc()->customer->get_shipping_city(),
				'shipping_state'      => wc()->customer->get_shipping_state(),
				'shipping_postcode'   => wc()->customer->get_shipping_postcode(),
				'shipping_country'    => wc()->customer->get_shipping_country(),
				'shipping_phone'      => wc()->customer->get_shipping_phone(),
			]
		);
	}
}
Pagination.php000064400000004064151556130560007361 0ustar00<?php
namespace Automattic\WooCommerce\StoreApi\Utilities;

/**
 * Pagination class.
 */
class Pagination {

	/**
	 * Add pagination headers to a response object.
	 *
	 * @param \WP_REST_Response $response Reference to the response object.
	 * @param \WP_REST_Request  $request The request object.
	 * @param int               $total_items Total items found.
	 * @param int               $total_pages Total pages found.
	 * @return \WP_REST_Response
	 */
	public function add_headers( $response, $request, $total_items, $total_pages ) {
		$response->header( 'X-WP-Total', $total_items );
		$response->header( 'X-WP-TotalPages', $total_pages );

		$current_page = $this->get_current_page( $request );
		$link_base    = $this->get_link_base( $request );

		if ( $current_page > 1 ) {
			$previous_page = $current_page - 1;
			if ( $previous_page > $total_pages ) {
				$previous_page = $total_pages;
			}
			$this->add_page_link( $response, 'prev', $previous_page, $link_base );
		}

		if ( $total_pages > $current_page ) {
			$this->add_page_link( $response, 'next', ( $current_page + 1 ), $link_base );
		}

		return $response;
	}

	/**
	 * Get current page.
	 *
	 * @param \WP_REST_Request $request The request object.
	 * @return int Get the page from the request object.
	 */
	protected function get_current_page( $request ) {
		return (int) $request->get_param( 'page' );
	}

	/**
	 * Get base for links from the request object.
	 *
	 * @param \WP_REST_Request $request The request object.
	 * @return string
	 */
	protected function get_link_base( $request ) {
		return esc_url( add_query_arg( $request->get_query_params(), rest_url( $request->get_route() ) ) );
	}

	/**
	 * Add a page link.
	 *
	 * @param \WP_REST_Response $response Reference to the response object.
	 * @param string            $name Page link name. e.g. prev.
	 * @param int               $page Page number.
	 * @param string            $link_base Base URL.
	 */
	protected function add_page_link( &$response, $name, $page, $link_base ) {
		$response->link_header( $name, add_query_arg( 'page', $page, $link_base ) );
	}
}
ProductItemTrait.php000064400000005715151556130570010540 0ustar00<?php
namespace Automattic\WooCommerce\StoreApi\Utilities;

/**
 * ProductItemTrait
 *
 * Shared functionality for formating product item data.
 */
trait ProductItemTrait {
	/**
	 * Get an array of pricing data.
	 *
	 * @param \WC_Product $product Product instance.
	 * @param string      $tax_display_mode If returned prices are incl or excl of tax.
	 * @return array
	 */
	protected function prepare_product_price_response( \WC_Product $product, $tax_display_mode = '' ) {
		$tax_display_mode = $this->get_tax_display_mode( $tax_display_mode );
		$price_function   = $this->get_price_function_from_tax_display_mode( $tax_display_mode );
		$prices           = parent::prepare_product_price_response( $product, $tax_display_mode );

		// Add raw prices (prices with greater precision).
		$prices['raw_prices'] = [
			'precision'     => wc_get_rounding_precision(),
			'price'         => $this->prepare_money_response( $price_function( $product ), wc_get_rounding_precision() ),
			'regular_price' => $this->prepare_money_response( $price_function( $product, [ 'price' => $product->get_regular_price() ] ), wc_get_rounding_precision() ),
			'sale_price'    => $this->prepare_money_response( $price_function( $product, [ 'price' => $product->get_sale_price() ] ), wc_get_rounding_precision() ),
		];

		return $prices;
	}

	/**
	 * Format variation data, for example convert slugs such as attribute_pa_size to Size.
	 *
	 * @param array       $variation_data Array of data from the cart.
	 * @param \WC_Product $product Product data.
	 * @return array
	 */
	protected function format_variation_data( $variation_data, $product ) {
		$return = [];

		if ( ! is_iterable( $variation_data ) ) {
			return $return;
		}

		foreach ( $variation_data as $key => $value ) {
			$taxonomy = wc_attribute_taxonomy_name( str_replace( 'attribute_pa_', '', urldecode( $key ) ) );

			if ( taxonomy_exists( $taxonomy ) ) {
				// If this is a term slug, get the term's nice name.
				$term = get_term_by( 'slug', $value, $taxonomy );
				if ( ! is_wp_error( $term ) && $term && $term->name ) {
					$value = $term->name;
				}
				$label = wc_attribute_label( $taxonomy );
			} else {
				/**
				 * Filters the variation option name.
				 *
				 * Filters the variation option name for custom option slugs.
				 *
				 * @since 2.5.0
				 *
				 * @internal Matches filter name in WooCommerce core.
				 *
				 * @param string $value The name to display.
				 * @param null $unused Unused because this is not a variation taxonomy.
				 * @param string $taxonomy Taxonomy or product attribute name.
				 * @param \WC_Product $product Product data.
				 * @return string
				 */
				$value = apply_filters( 'woocommerce_variation_option_name', $value, null, $taxonomy, $product );
				$label = wc_attribute_label( str_replace( 'attribute_', '', $key ), $product );
			}

			$return[] = [
				'attribute' => $this->prepare_html_response( $label ),
				'value'     => $this->prepare_html_response( $value ),
			];
		}

		return $return;
	}
}
ProductQuery.php000064400000042477151556130570007751 0ustar00<?php
namespace Automattic\WooCommerce\StoreApi\Utilities;

use WC_Tax;

/**
 * Product Query class.
 *
 * Helper class to handle product queries for the API.
 */
class ProductQuery {
	/**
	 * Prepare query args to pass to WP_Query for a REST API request.
	 *
	 * @param \WP_REST_Request $request Request data.
	 * @return array
	 */
	public function prepare_objects_query( $request ) {
		$args = [
			'offset'              => $request['offset'],
			'order'               => $request['order'],
			'orderby'             => $request['orderby'],
			'paged'               => $request['page'],
			'post__in'            => $request['include'],
			'post__not_in'        => $request['exclude'],
			'posts_per_page'      => $request['per_page'] ? $request['per_page'] : -1,
			'post_parent__in'     => $request['parent'],
			'post_parent__not_in' => $request['parent_exclude'],
			'search'              => $request['search'], // This uses search rather than s intentionally to handle searches internally.
			'slug'                => $request['slug'],
			'fields'              => 'ids',
			'ignore_sticky_posts' => true,
			'post_status'         => 'publish',
			'date_query'          => [],
			'post_type'           => 'product',
		];

		// If searching for a specific SKU or slug, allow any post type.
		if ( ! empty( $request['sku'] ) || ! empty( $request['slug'] ) ) {
			$args['post_type'] = [ 'product', 'product_variation' ];
		}

		// Taxonomy query to filter products by type, category, tag, shipping class, and attribute.
		$tax_query = [];

		// Filter product type by slug.
		if ( ! empty( $request['type'] ) ) {
			if ( 'variation' === $request['type'] ) {
				$args['post_type'] = 'product_variation';
			} else {
				$args['post_type'] = 'product';
				$tax_query[]       = [
					'taxonomy' => 'product_type',
					'field'    => 'slug',
					'terms'    => $request['type'],
				];
			}
		}

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

		// Set before into date query. Date query must be specified as an array of an array.
		if ( isset( $request['before'] ) ) {
			$args['date_query'][0]['before'] = $request['before'];
		}

		// Set after into date query. Date query must be specified as an array of an array.
		if ( isset( $request['after'] ) ) {
			$args['date_query'][0]['after'] = $request['after'];
		}

		// Set date query column. Defaults to post_date.
		if ( isset( $request['date_column'] ) && ! empty( $args['date_query'][0] ) ) {
			$args['date_query'][0]['column'] = 'post_' . $request['date_column'];
		}

		// Set custom args to handle later during clauses.
		$custom_keys = [
			'sku',
			'min_price',
			'max_price',
			'stock_status',
		];

		foreach ( $custom_keys as $key ) {
			if ( ! empty( $request[ $key ] ) ) {
				$args[ $key ] = $request[ $key ];
			}
		}

		$operator_mapping = [
			'in'     => 'IN',
			'not_in' => 'NOT IN',
			'and'    => 'AND',
		];

		// Gets all registered product taxonomies and prefixes them with `tax_`.
		// This is needed to avoid situations where a user registers a new product taxonomy with the same name as default field.
		// eg an `sku` taxonomy will be mapped to `tax_sku`.
		$all_product_taxonomies = array_map(
			function ( $value ) {
				return '_unstable_tax_' . $value;
			},
			get_taxonomies( array( 'object_type' => array( 'product' ) ), 'names' )
		);

		// Map between taxonomy name and arg key.
		$default_taxonomies = [
			'product_cat' => 'category',
			'product_tag' => 'tag',
		];

		$taxonomies = array_merge( $all_product_taxonomies, $default_taxonomies );

		// Set tax_query for each passed arg.
		foreach ( $taxonomies as $taxonomy => $key ) {
			if ( ! empty( $request[ $key ] ) ) {
				$operator    = $request->get_param( $key . '_operator' ) && isset( $operator_mapping[ $request->get_param( $key . '_operator' ) ] ) ? $operator_mapping[ $request->get_param( $key . '_operator' ) ] : 'IN';
				$tax_query[] = [
					'taxonomy' => $taxonomy,
					'field'    => 'term_id',
					'terms'    => $request[ $key ],
					'operator' => $operator,
				];
			}
		}

		// Filter by attributes.
		if ( ! empty( $request['attributes'] ) ) {
			$att_queries = [];

			foreach ( $request['attributes'] as $attribute ) {
				if ( empty( $attribute['term_id'] ) && empty( $attribute['slug'] ) ) {
					continue;
				}
				if ( in_array( $attribute['attribute'], wc_get_attribute_taxonomy_names(), true ) ) {
					$operator      = isset( $attribute['operator'], $operator_mapping[ $attribute['operator'] ] ) ? $operator_mapping[ $attribute['operator'] ] : 'IN';
					$att_queries[] = [
						'taxonomy' => $attribute['attribute'],
						'field'    => ! empty( $attribute['term_id'] ) ? 'term_id' : 'slug',
						'terms'    => ! empty( $attribute['term_id'] ) ? $attribute['term_id'] : $attribute['slug'],
						'operator' => $operator,
					];
				}
			}

			if ( 1 < count( $att_queries ) ) {
				// Add relation arg when using multiple attributes.
				$relation    = $request->get_param( 'attribute_relation' ) && isset( $operator_mapping[ $request->get_param( 'attribute_relation' ) ] ) ? $operator_mapping[ $request->get_param( 'attribute_relation' ) ] : 'IN';
				$tax_query[] = [
					'relation' => $relation,
					$att_queries,
				];
			} else {
				$tax_query = array_merge( $tax_query, $att_queries );
			}
		}

		// Build tax_query if taxonomies are set.
		if ( ! empty( $tax_query ) ) {
			if ( ! empty( $args['tax_query'] ) ) {
				$args['tax_query'] = array_merge( $tax_query, $args['tax_query'] ); // phpcs:ignore
			} else {
				$args['tax_query'] = $tax_query; // phpcs:ignore
			}
		}

		// Filter featured.
		if ( is_bool( $request['featured'] ) ) {
			$args['tax_query'][] = [
				'taxonomy' => 'product_visibility',
				'field'    => 'name',
				'terms'    => 'featured',
				'operator' => true === $request['featured'] ? 'IN' : 'NOT IN',
			];
		}

		// Filter by on sale products.
		if ( is_bool( $request['on_sale'] ) ) {
			$on_sale_key = $request['on_sale'] ? 'post__in' : 'post__not_in';
			$on_sale_ids = wc_get_product_ids_on_sale();

			// Use 0 when there's no on sale products to avoid return all products.
			$on_sale_ids = empty( $on_sale_ids ) ? [ 0 ] : $on_sale_ids;

			$args[ $on_sale_key ] += $on_sale_ids;
		}

		$catalog_visibility = $request->get_param( 'catalog_visibility' );
		$rating             = $request->get_param( 'rating' );
		$visibility_options = wc_get_product_visibility_options();

		if ( in_array( $catalog_visibility, array_keys( $visibility_options ), true ) ) {
			$exclude_from_catalog = 'search' === $catalog_visibility ? '' : 'exclude-from-catalog';
			$exclude_from_search  = 'catalog' === $catalog_visibility ? '' : 'exclude-from-search';

			$args['tax_query'][] = [
				'taxonomy'      => 'product_visibility',
				'field'         => 'name',
				'terms'         => [ $exclude_from_catalog, $exclude_from_search ],
				'operator'      => 'hidden' === $catalog_visibility ? 'AND' : 'NOT IN',
				'rating_filter' => true,
			];
		}

		if ( $rating ) {
			$rating_terms = [];
			foreach ( $rating as $value ) {
				$rating_terms[] = 'rated-' . $value;
			}
			$args['tax_query'][] = [
				'taxonomy' => 'product_visibility',
				'field'    => 'name',
				'terms'    => $rating_terms,
			];
		}

		$orderby = $request->get_param( 'orderby' );
		$order   = $request->get_param( 'order' );

		$ordering_args   = wc()->query->get_catalog_ordering_args( $orderby, $order );
		$args['orderby'] = $ordering_args['orderby'];
		$args['order']   = $ordering_args['order'];

		if ( 'include' === $orderby ) {
			$args['orderby'] = 'post__in';
		} elseif ( 'id' === $orderby ) {
			$args['orderby'] = 'ID'; // ID must be capitalized.
		} elseif ( 'slug' === $orderby ) {
			$args['orderby'] = 'name';
		}

		if ( $ordering_args['meta_key'] ) {
			$args['meta_key'] = $ordering_args['meta_key']; // phpcs:ignore
		}

		return $args;
	}

	/**
	 * Get results of query.
	 *
	 * @param \WP_REST_Request $request Request data.
	 * @return array
	 */
	public function get_results( $request ) {
		$query_args = $this->prepare_objects_query( $request );

		add_filter( 'posts_clauses', [ $this, 'add_query_clauses' ], 10, 2 );

		$query       = new \WP_Query();
		$results     = $query->query( $query_args );
		$total_posts = $query->found_posts;

		// Out-of-bounds, run the query again without LIMIT for total count.
		if ( $total_posts < 1 && $query_args['paged'] > 1 ) {
			unset( $query_args['paged'] );
			$count_query = new \WP_Query();
			$count_query->query( $query_args );
			$total_posts = $count_query->found_posts;
		}

		remove_filter( 'posts_clauses', [ $this, 'add_query_clauses' ], 10 );

		return [
			'results' => $results,
			'total'   => (int) $total_posts,
			'pages'   => $query->query_vars['posts_per_page'] > 0 ? (int) ceil( $total_posts / (int) $query->query_vars['posts_per_page'] ) : 1,
		];
	}

	/**
	 * Get objects.
	 *
	 * @param \WP_REST_Request $request Request data.
	 * @return array
	 */
	public function get_objects( $request ) {
		$results = $this->get_results( $request );

		return [
			'objects' => array_map( 'wc_get_product', $results['results'] ),
			'total'   => $results['total'],
			'pages'   => $results['pages'],
		];
	}

	/**
	 * Get last modified date for all products.
	 *
	 * @return int timestamp.
	 */
	public function get_last_modified() {
		global $wpdb;

		return strtotime( $wpdb->get_var( "SELECT MAX( post_modified_gmt ) FROM {$wpdb->posts} WHERE post_type IN ( 'product', 'product_variation' );" ) );
	}

	/**
	 * Add in conditional search filters for products.
	 *
	 * @param array     $args Query args.
	 * @param \WC_Query $wp_query WC_Query object.
	 * @return array
	 */
	public function add_query_clauses( $args, $wp_query ) {
		global $wpdb;

		if ( $wp_query->get( 'search' ) ) {
			$search         = '%' . $wpdb->esc_like( $wp_query->get( 'search' ) ) . '%';
			$search_query   = wc_product_sku_enabled()
				? $wpdb->prepare( " AND ( $wpdb->posts.post_title LIKE %s OR wc_product_meta_lookup.sku LIKE %s ) ", $search, $search )
				: $wpdb->prepare( " AND $wpdb->posts.post_title LIKE %s ", $search );
			$args['where'] .= $search_query;
			$args['join']   = $this->append_product_sorting_table_join( $args['join'] );
		}

		if ( $wp_query->get( 'sku' ) ) {
			$skus = explode( ',', $wp_query->get( 'sku' ) );
			// Include the current string as a SKU too.
			if ( 1 < count( $skus ) ) {
				$skus[] = $wp_query->get( 'sku' );
			}
			$args['join']   = $this->append_product_sorting_table_join( $args['join'] );
			$args['where'] .= ' AND wc_product_meta_lookup.sku IN ("' . implode( '","', array_map( 'esc_sql', $skus ) ) . '")';
		}

		if ( $wp_query->get( 'slug' ) ) {
			$slugs = explode( ',', $wp_query->get( 'slug' ) );
			// Include the current string as a slug too.
			if ( 1 < count( $slugs ) ) {
				$slugs[] = $wp_query->get( 'slug' );
			}
			$args['join']   = $this->append_product_sorting_table_join( $args['join'] );
			$post_name__in  = implode( '","', array_map( 'esc_sql', $slugs ) );
			$args['where'] .= " AND $wpdb->posts.post_name IN (\"$post_name__in\")";
		}

		if ( $wp_query->get( 'stock_status' ) ) {
			$args['join']   = $this->append_product_sorting_table_join( $args['join'] );
			$args['where'] .= ' AND wc_product_meta_lookup.stock_status IN ("' . implode( '","', array_map( 'esc_sql', $wp_query->get( 'stock_status' ) ) ) . '")';
		} elseif ( 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ) ) {
			$args['join']   = $this->append_product_sorting_table_join( $args['join'] );
			$args['where'] .= ' AND wc_product_meta_lookup.stock_status NOT IN ("outofstock")';
		}

		if ( $wp_query->get( 'min_price' ) || $wp_query->get( 'max_price' ) ) {
			$args = $this->add_price_filter_clauses( $args, $wp_query );
		}

		return $args;
	}

	/**
	 * Add in conditional price filters.
	 *
	 * @param array     $args Query args.
	 * @param \WC_Query $wp_query WC_Query object.
	 * @return array
	 */
	protected function add_price_filter_clauses( $args, $wp_query ) {
		global $wpdb;

		$adjust_for_taxes = $this->adjust_price_filters_for_displayed_taxes();
		$args['join']     = $this->append_product_sorting_table_join( $args['join'] );

		if ( $wp_query->get( 'min_price' ) ) {
			$min_price_filter = $this->prepare_price_filter( $wp_query->get( 'min_price' ) );

			if ( $adjust_for_taxes ) {
				$args['where'] .= $this->get_price_filter_query_for_displayed_taxes( $min_price_filter, 'min_price', '>=' );
			} else {
				$args['where'] .= $wpdb->prepare( ' AND wc_product_meta_lookup.min_price >= %f ', $min_price_filter );
			}
		}

		if ( $wp_query->get( 'max_price' ) ) {
			$max_price_filter = $this->prepare_price_filter( $wp_query->get( 'max_price' ) );

			if ( $adjust_for_taxes ) {
				$args['where'] .= $this->get_price_filter_query_for_displayed_taxes( $max_price_filter, 'max_price', '<=' );
			} else {
				$args['where'] .= $wpdb->prepare( ' AND wc_product_meta_lookup.max_price <= %f ', $max_price_filter );
			}
		}

		return $args;
	}

	/**
	 * Get query for price filters when dealing with displayed taxes.
	 *
	 * @param float  $price_filter Price filter to apply.
	 * @param string $column Price being filtered (min or max).
	 * @param string $operator Comparison operator for column.
	 * @return string Constructed query.
	 */
	protected function get_price_filter_query_for_displayed_taxes( $price_filter, $column = 'min_price', $operator = '>=' ) {
		global $wpdb;

		// Select only used tax classes to avoid unwanted calculations.
		$product_tax_classes = $wpdb->get_col( "SELECT DISTINCT tax_class FROM {$wpdb->wc_product_meta_lookup};" );

		if ( empty( $product_tax_classes ) ) {
			return '';
		}

		$or_queries = [];

		// We need to adjust the filter for each possible tax class and combine the queries into one.
		foreach ( $product_tax_classes as $tax_class ) {
			$adjusted_price_filter = $this->adjust_price_filter_for_tax_class( $price_filter, $tax_class );
			$or_queries[]          = $wpdb->prepare(
				'( wc_product_meta_lookup.tax_class = %s AND wc_product_meta_lookup.`' . esc_sql( $column ) . '` ' . esc_sql( $operator ) . ' %f )',
				$tax_class,
				$adjusted_price_filter
			);
		}

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
		return $wpdb->prepare(
			' AND (
				wc_product_meta_lookup.tax_status = "taxable" AND ( 0=1 OR ' . implode( ' OR ', $or_queries ) . ')
				OR ( wc_product_meta_lookup.tax_status != "taxable" AND wc_product_meta_lookup.`' . esc_sql( $column ) . '` ' . esc_sql( $operator ) . ' %f )
			) ',
			$price_filter
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
	}

	/**
	 * If price filters need adjustment to work with displayed taxes, this returns true.
	 *
	 * This logic is used when prices are stored in the database differently to how they are being displayed, with regards
	 * to taxes.
	 *
	 * @return boolean
	 */
	protected function adjust_price_filters_for_displayed_taxes() {
		$display  = get_option( 'woocommerce_tax_display_shop' );
		$database = wc_prices_include_tax() ? 'incl' : 'excl';

		return $display !== $database;
	}

	/**
	 * Converts price filter from subunits to decimal.
	 *
	 * @param string|int $price_filter Raw price filter in subunit format.
	 * @return float Price filter in decimal format.
	 */
	protected function prepare_price_filter( $price_filter ) {
		return floatval( $price_filter / ( 10 ** wc_get_price_decimals() ) );
	}

	/**
	 * Adjusts a price filter based on a tax class and whether or not the amount includes or excludes taxes.
	 *
	 * This calculation logic is based on `wc_get_price_excluding_tax` and `wc_get_price_including_tax` in core.
	 *
	 * @param float  $price_filter Price filter amount as entered.
	 * @param string $tax_class Tax class for adjustment.
	 * @return float
	 */
	protected function adjust_price_filter_for_tax_class( $price_filter, $tax_class ) {
		$tax_display    = get_option( 'woocommerce_tax_display_shop' );
		$tax_rates      = WC_Tax::get_rates( $tax_class );
		$base_tax_rates = WC_Tax::get_base_tax_rates( $tax_class );

		// If prices are shown incl. tax, we want to remove the taxes from the filter amount to match prices stored excl. tax.
		if ( 'incl' === $tax_display ) {
			/**
			 * Filters if taxes should be removed from locations outside the store base location.
			 *
			 * The woocommerce_adjust_non_base_location_prices filter can stop base taxes being taken off when dealing
			 * with out of base locations. e.g. If a product costs 10 including tax, all users will pay 10
			 * regardless of location and taxes.
			 *
			 * @since 2.6.0
			 *
			 * @internal Matches filter name in WooCommerce core.
			 *
			 * @param boolean $adjust_non_base_location_prices True by default.
			 * @return boolean
			 */
			$taxes = apply_filters( 'woocommerce_adjust_non_base_location_prices', true ) ? WC_Tax::calc_tax( $price_filter, $base_tax_rates, true ) : WC_Tax::calc_tax( $price_filter, $tax_rates, true );
			return $price_filter - array_sum( $taxes );
		}

		// If prices are shown excl. tax, add taxes to match the prices stored in the DB.
		$taxes = WC_Tax::calc_tax( $price_filter, $tax_rates, false );

		return $price_filter + array_sum( $taxes );
	}

	/**
	 * Join wc_product_meta_lookup to posts if not already joined.
	 *
	 * @param string $sql SQL join.
	 * @return string
	 */
	protected 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;
	}
}
ProductQueryFilters.php000064400000036452151556130600011270 0ustar00<?php
namespace Automattic\WooCommerce\StoreApi\Utilities;

use Automattic\WooCommerce\StoreApi\Utilities\ProductQuery;
use Exception;
use WP_REST_Request;


/**
 * Product Query filters class.
 */
class ProductQueryFilters {
	/**
	 * Get filtered min price for current products.
	 *
	 * @param \WP_REST_Request $request The request object.
	 * @return object
	 */
	public function get_filtered_price( $request ) {
		global $wpdb;

		// Regenerate the products query without min/max price request params.
		unset( $request['min_price'], $request['max_price'] );

		// Grab the request from the WP Query object, and remove SQL_CALC_FOUND_ROWS and Limits so we get a list of all products.
		$product_query = new ProductQuery();

		add_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10, 2 );
		add_filter( 'posts_pre_query', '__return_empty_array' );

		$query_args                   = $product_query->prepare_objects_query( $request );
		$query_args['no_found_rows']  = true;
		$query_args['posts_per_page'] = -1;
		$query                        = new \WP_Query();
		$result                       = $query->query( $query_args );
		$product_query_sql            = $query->request;

		remove_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10 );
		remove_filter( 'posts_pre_query', '__return_empty_array' );

		$price_filter_sql = "
			SELECT min( min_price ) as min_price, MAX( max_price ) as max_price
			FROM {$wpdb->wc_product_meta_lookup}
			WHERE product_id IN ( {$product_query_sql} )
		";

		return $wpdb->get_row( $price_filter_sql ); // phpcs:ignore
	}

	/**
	 * Get stock status counts for the current products.
	 *
	 * @param \WP_REST_Request $request The request object.
	 * @return array status=>count pairs.
	 */
	public function get_stock_status_counts( $request ) {
		global $wpdb;
		$product_query         = new ProductQuery();
		$stock_status_options  = array_map( 'esc_sql', array_keys( wc_get_product_stock_status_options() ) );
		$hide_outofstock_items = get_option( 'woocommerce_hide_out_of_stock_items' );
		if ( 'yes' === $hide_outofstock_items ) {
			unset( $stock_status_options['outofstock'] );
		}

		add_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10, 2 );
		add_filter( 'posts_pre_query', '__return_empty_array' );

		$query_args = $product_query->prepare_objects_query( $request );
		unset( $query_args['stock_status'] );
		$query_args['no_found_rows']  = true;
		$query_args['posts_per_page'] = -1;
		$query                        = new \WP_Query();
		$result                       = $query->query( $query_args );
		$product_query_sql            = $query->request;

		remove_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10 );
		remove_filter( 'posts_pre_query', '__return_empty_array' );

		$stock_status_counts = array();

		foreach ( $stock_status_options as $status ) {
			$stock_status_count_sql = $this->generate_stock_status_count_query( $status, $product_query_sql, $stock_status_options );

			$result = $wpdb->get_row( $stock_status_count_sql ); // phpcs:ignore
			$stock_status_counts[ $status ] = $result->status_count;
		}

		return $stock_status_counts;
	}

	/**
	 * Generate calculate query by stock status.
	 *
	 * @param string $status status to calculate.
	 * @param string $product_query_sql product query for current filter state.
	 * @param array  $stock_status_options available stock status options.
	 *
	 * @return false|string
	 */
	private function generate_stock_status_count_query( $status, $product_query_sql, $stock_status_options ) {
		if ( ! in_array( $status, $stock_status_options, true ) ) {
			return false;
		}
		global $wpdb;
		$status = esc_sql( $status );
		return "
			SELECT COUNT( DISTINCT posts.ID ) as status_count
			FROM {$wpdb->posts} as posts
			INNER JOIN {$wpdb->postmeta} as postmeta ON posts.ID = postmeta.post_id
            AND postmeta.meta_key = '_stock_status'
            AND postmeta.meta_value = '{$status}'
			WHERE posts.ID IN ( {$product_query_sql} )
		";
	}

	/**
	 * Get terms list for a given taxonomy.
	 *
	 * @param string $taxonomy Taxonomy name.
	 *
	 * @return array
	 */
	public function get_terms_list( string $taxonomy ) {
		global $wpdb;

		return $wpdb->get_results(
			$wpdb->prepare(
				"SELECT term_id as term_count_id,
            count(DISTINCT product_or_parent_id) as term_count
			FROM {$wpdb->prefix}wc_product_attributes_lookup
			WHERE taxonomy = %s
			GROUP BY term_id",
				$taxonomy
			)
		);
	}

	/**
	 * Get the empty terms list for a given taxonomy.
	 *
	 * @param string $taxonomy Taxonomy name.
	 *
	 * @return array
	 */
	public function get_empty_terms_list( string $taxonomy ) {
		global $wpdb;

		return $wpdb->get_results(
			$wpdb->prepare(
				"SELECT DISTINCT term_id as term_count_id,
            0 as term_count
			FROM {$wpdb->prefix}wc_product_attributes_lookup
			WHERE taxonomy = %s",
				$taxonomy
			)
		);
	}

	/**
	 * Get attribute and meta counts.
	 *
	 * @param WP_REST_Request $request Request data.
	 * @param string          $filtered_attribute The attribute to count.
	 *
	 * @return array
	 */
	public function get_attribute_counts( $request, $filtered_attribute ) {
		if ( is_array( $filtered_attribute ) ) {
			wc_deprecated_argument( 'attributes', 'TBD', 'get_attribute_counts does not require an array of attributes as the second parameter anymore. Provide the filtered attribute as a string instead.' );

			$filtered_attribute = ! empty( $filtered_attribute[0] ) ? $filtered_attribute[0] : '';

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

		$attributes_data            = $request->get_param( 'attributes' );
		$calculate_attribute_counts = $request->get_param( 'calculate_attribute_counts' );
		$min_price                  = $request->get_param( 'min_price' );
		$max_price                  = $request->get_param( 'max_price' );
		$rating                     = $request->get_param( 'rating' );
		$stock_status               = $request->get_param( 'stock_status' );

		$transient_key = 'wc_get_attribute_and_meta_counts_' . md5(
			wp_json_encode(
				array(
					'attributes_data'            => $attributes_data,
					'calculate_attribute_counts' => $calculate_attribute_counts,
					'min_price'                  => $min_price,
					'max_price'                  => $max_price,
					'rating'                     => $rating,
					'stock_status'               => $stock_status,
					'filtered_attribute'         => $filtered_attribute,
				)
			)
		);

		$cached_results = get_transient( $transient_key );
		if ( ! empty( $cached_results ) && defined( 'WP_DEBUG' ) && ! WP_DEBUG ) {
			return $cached_results;
		}

		if ( empty( $attributes_data ) && empty( $min_price ) && empty( $max_price ) && empty( $rating ) && empty( $stock_status ) ) {
			$counts = $this->get_terms_list( $filtered_attribute );

			return array_map( 'absint', wp_list_pluck( $counts, 'term_count', 'term_count_id' ) );
		}

		$where_clause = '';
		if ( ! empty( $min_price ) || ! empty( $max_price ) || ! empty( $rating ) || ! empty( $stock_status ) ) {
			$product_metas = [
				'min_price'      => $min_price,
				'max_price'      => $max_price,
				'average_rating' => $rating,
				'stock_status'   => $stock_status,
			];

			$filtered_products_by_metas           = $this->get_product_by_metas( $product_metas );
			$formatted_filtered_products_by_metas = implode( ',', array_map( 'intval', $filtered_products_by_metas ) );

			if ( ! empty( $formatted_filtered_products_by_metas ) ) {
				if ( ! empty( $rating ) ) {
					$where_clause .= sprintf( ' AND product_attribute_lookup.product_or_parent_id IN (%1s)', $formatted_filtered_products_by_metas );
				} else {
					$where_clause .= sprintf( ' AND product_attribute_lookup.product_id IN (%1s)', $formatted_filtered_products_by_metas );
				}
			} else {
				$counts = $this->get_empty_terms_list( $filtered_attribute );

				return array_map( 'absint', wp_list_pluck( $counts, 'term_count', 'term_count_id' ) );
			}
		}

		$join_type = 'LEFT';
		foreach ( $attributes_data as $attribute ) {
			$filtered_terms = $attribute['slug'] ?? '';

			if ( empty( $filtered_terms ) ) {
				continue;
			}

			$taxonomy = $attribute['attribute'] ?? '';
			$term_ids = [];
			if ( in_array( $taxonomy, wc_get_attribute_taxonomy_names(), true ) ) {
				foreach ( $filtered_terms as $filtered_term ) {
					$term = get_term_by( 'slug', $filtered_term, $taxonomy );
					if ( is_object( $term ) ) {
						$term_ids[] = $term->term_id;
					}
				}
			}

			if ( empty( $term_ids ) ) {
				continue;
			}

			foreach ( $calculate_attribute_counts as $calculate_attribute_count ) {
				if ( ! isset( $calculate_attribute_count['taxonomy'] ) && ! isset( $calculate_attribute_count['query_type'] ) ) {
					continue;
				}

				$query_type                           = $calculate_attribute_count['query_type'];
				$filtered_products_by_terms           = $this->get_product_by_filtered_terms( $calculate_attribute_count['taxonomy'], $term_ids, $query_type );
				$formatted_filtered_products_by_terms = implode( ',', array_map( 'intval', $filtered_products_by_terms ) );

				if ( ! empty( $formatted_filtered_products_by_terms ) ) {
					$where_clause .= sprintf( ' AND product_attribute_lookup.product_or_parent_id IN (%1s)', $formatted_filtered_products_by_terms );
				}

				if ( $calculate_attribute_count['taxonomy'] === $filtered_attribute ) {
					$join_type = 'or' === $query_type ? 'LEFT' : 'INNER';
				}
			}
		}

		global $wpdb;
		$counts = $wpdb->get_results(
		// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder
			$wpdb->prepare(
				"
				SELECT attributes.term_id as term_count_id, coalesce(term_count, 0) as term_count
				FROM (SELECT DISTINCT term_id
					FROM {$wpdb->prefix}wc_product_attributes_lookup
						WHERE taxonomy = %s) as attributes %1s JOIN (
						SELECT COUNT(DISTINCT product_attribute_lookup.product_or_parent_id) as term_count, product_attribute_lookup.term_id
						FROM {$wpdb->prefix}wc_product_attributes_lookup product_attribute_lookup
							INNER JOIN {$wpdb->posts} posts
								ON posts.ID = product_attribute_lookup.product_id
						WHERE posts.post_type IN ('product', 'product_variation') AND posts.post_status = 'publish'%1s
						GROUP BY product_attribute_lookup.term_id
					) summarize
				ON attributes.term_id = summarize.term_id
				",
				$filtered_attribute,
				$join_type,
				$where_clause
			)
		);

		// phpcs:enable
		$results = array_map( 'absint', wp_list_pluck( $counts, 'term_count', 'term_count_id' ) );

		set_transient( $transient_key, $results, 24 * HOUR_IN_SECONDS );

		return $results;
	}

	/**
	 * Get rating counts for the current products.
	 *
	 * @param \WP_REST_Request $request The request object.
	 * @return array rating=>count pairs.
	 */
	public function get_rating_counts( $request ) {
		global $wpdb;

		// Regenerate the products query without rating request params.
		unset( $request['rating'] );

		// Grab the request from the WP Query object, and remove SQL_CALC_FOUND_ROWS and Limits so we get a list of all products.
		$product_query = new ProductQuery();

		add_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10, 2 );
		add_filter( 'posts_pre_query', '__return_empty_array' );

		$query_args                   = $product_query->prepare_objects_query( $request );
		$query_args['no_found_rows']  = true;
		$query_args['posts_per_page'] = -1;
		$query                        = new \WP_Query();
		$result                       = $query->query( $query_args );
		$product_query_sql            = $query->request;

		remove_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10 );
		remove_filter( 'posts_pre_query', '__return_empty_array' );

		$rating_count_sql = "
			SELECT COUNT( DISTINCT product_id ) as product_count, ROUND( average_rating, 0 ) as rounded_average_rating
			FROM {$wpdb->wc_product_meta_lookup}
			WHERE product_id IN ( {$product_query_sql} )
			AND average_rating > 0
			GROUP BY rounded_average_rating
			ORDER BY rounded_average_rating ASC
		";

		$results = $wpdb->get_results( $rating_count_sql ); // phpcs:ignore

		return array_map( 'absint', wp_list_pluck( $results, 'product_count', 'rounded_average_rating' ) );
	}

	/**
	 * Gets product by metas.
	 *
	 * @since TBD
	 * @param array $metas Array of metas to query.
	 * @return array $results
	 */
	public function get_product_by_metas( $metas = array() ) {
		global $wpdb;

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

		$where   = array();
		$results = array();
		$params  = array();
		foreach ( $metas as $column => $value ) {
			if ( empty( $value ) ) {
				continue;
			}

			if ( 'stock_status' === $column ) {
				$stock_product_ids = array();
				foreach ( $value as $stock_status ) {
					$stock_product_ids[] = $wpdb->get_col(
						$wpdb->prepare(
							"SELECT DISTINCT product_id FROM {$wpdb->prefix}wc_product_meta_lookup WHERE stock_status = %s",
							$stock_status
						)
					);
				}

				$where[] = 'product_id IN (' . implode( ',', array_merge( ...$stock_product_ids ) ) . ')';
				continue;
			}

			if ( 'min_price' === $column ) {
				$where[]  = "{$column} >= %d";
				$params[] = intval( $value ) / 100;
				continue;
			}

			if ( 'max_price' === $column ) {
				$where[]  = "{$column} <= %d";
				$params[] = intval( $value ) / 100;
				continue;
			}

			if ( 'average_rating' === $column ) {
				$where_rating = array();
				foreach ( $value as $rating ) {
					$where_rating[] = sprintf( '(average_rating >= %f - 0.5 AND average_rating < %f + 0.5)', $rating, $rating );
				}
				$where[] = '(' . implode( ' OR ', $where_rating ) . ')';
				continue;
			}

			$where[]  = sprintf( "%1s = '%s'", $column, $value );
			$params[] = $value;
		}

		if ( ! empty( $where ) ) {
			$where_clause = implode( ' AND ', $where );
			$where_clause = sprintf( $where_clause, ...$params );

		// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder
			$results = $wpdb->get_col(
				$wpdb->prepare(
					"SELECT DISTINCT product_id FROM {$wpdb->prefix}wc_product_meta_lookup WHERE %1s",
					$where_clause
				)
			);
		}
		// phpcs:enable

		return $results;
	}

	/**
	 * Gets product by filtered terms.
	 *
	 * @since TBD
	 * @param string $taxonomy Taxonomy name.
	 * @param array  $term_ids Term IDs.
	 * @param string $query_type or | and.
	 * @return array Product IDs.
	 */
	public function get_product_by_filtered_terms( $taxonomy = '', $term_ids = array(), $query_type = 'or' ) {
		global $wpdb;

		$term_count = count( $term_ids );
		$results    = array();
		$term_ids   = implode( ',', array_map( 'intval', $term_ids ) );

		if ( 'or' === $query_type ) {
			$results = $wpdb->get_col(
			// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder
				$wpdb->prepare(
					"
					SELECT DISTINCT `product_or_parent_id`
					FROM {$wpdb->prefix}wc_product_attributes_lookup
					WHERE `taxonomy` = %s
					AND `term_id` IN (%1s)
					",
					$taxonomy,
					$term_ids
				)
			// phpcs:enable
			);
		}

		if ( 'and' === $query_type ) {
			$results = $wpdb->get_col(
			// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder
				$wpdb->prepare(
					"
					SELECT DISTINCT `product_or_parent_id`
					FROM {$wpdb->prefix}wc_product_attributes_lookup
					WHERE `taxonomy` = %s
					AND `term_id` IN (%1s)
					GROUP BY `product_or_parent_id`
					HAVING COUNT( DISTINCT `term_id` ) >= %d
					",
					$taxonomy,
					$term_ids,
					$term_count
				)
			// phpcs:enable
			);
		}

		return $results;
	}
}
QuantityLimits.php000064400000015477151556130600010275 0ustar00<?php
namespace Automattic\WooCommerce\StoreApi\Utilities;

use Automattic\WooCommerce\Checkout\Helpers\ReserveStock;
use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait;

/**
 * QuantityLimits class.
 *
 * Returns limits for products and cart items when using the StoreAPI and supporting classes.
 */
final class QuantityLimits {
	use DraftOrderTrait;

	/**
	 * Get quantity limits (min, max, step/multiple) for a product or cart item.
	 *
	 * @param array $cart_item A cart item array.
	 * @return array
	 */
	public function get_cart_item_quantity_limits( $cart_item ) {
		$product = $cart_item['data'] ?? false;

		if ( ! $product instanceof \WC_Product ) {
			return [
				'minimum'     => 1,
				'maximum'     => 9999,
				'multiple_of' => 1,
				'editable'    => true,
			];
		}

		$multiple_of = (int) $this->filter_value( 1, 'multiple_of', $cart_item );
		$minimum     = (int) $this->filter_value( 1, 'minimum', $cart_item );
		$maximum     = (int) $this->filter_value( $this->get_product_quantity_limit( $product ), 'maximum', $cart_item );
		$editable    = (bool) $this->filter_value( ! $product->is_sold_individually(), 'editable', $cart_item );

		return [
			'minimum'     => $this->limit_to_multiple( $minimum, $multiple_of, 'ceil' ),
			'maximum'     => $this->limit_to_multiple( $maximum, $multiple_of, 'floor' ),
			'multiple_of' => $multiple_of,
			'editable'    => $editable,
		];
	}

	/**
	 * Get limits for product add to cart forms.
	 *
	 * @param \WC_Product $product Product instance.
	 * @return array
	 */
	public function get_add_to_cart_limits( \WC_Product $product ) {
		$multiple_of = $this->filter_value( 1, 'multiple_of', $product );
		$minimum     = $this->filter_value( 1, 'minimum', $product );
		$maximum     = $this->filter_value( $this->get_product_quantity_limit( $product ), 'maximum', $product );

		return [
			'minimum'     => $this->limit_to_multiple( $minimum, $multiple_of, 'ceil' ),
			'maximum'     => $this->limit_to_multiple( $maximum, $multiple_of, 'floor' ),
			'multiple_of' => $multiple_of,
		];
	}

	/**
	 * Return a number using the closest multiple of another number. Used to enforce step/multiple values.
	 *
	 * @param int    $number Number to round.
	 * @param int    $multiple_of The multiple.
	 * @param string $rounding_function ceil, floor, or round.
	 * @return int
	 */
	public function limit_to_multiple( int $number, int $multiple_of, string $rounding_function = 'round' ) {
		if ( $multiple_of <= 1 ) {
			return $number;
		}
		$rounding_function = in_array( $rounding_function, [ 'ceil', 'floor', 'round' ], true ) ? $rounding_function : 'round';
		return $rounding_function( $number / $multiple_of ) * $multiple_of;
	}

	/**
	 * Check that a given quantity is valid according to any limits in place.
	 *
	 * @param integer           $quantity Quantity to validate.
	 * @param \WC_Product|array $cart_item Cart item.
	 * @return \WP_Error|true
	 */
	public function validate_cart_item_quantity( $quantity, $cart_item ) {
		$limits = $this->get_cart_item_quantity_limits( $cart_item );

		if ( ! $limits['editable'] ) {
			return new \WP_Error(
				'readonly_quantity',
				__( 'This item is already in the cart and its quantity cannot be edited', 'woocommerce' )
			);
		}

		if ( $quantity < $limits['minimum'] ) {
			return new \WP_Error(
				'invalid_quantity',
				sprintf(
					// Translators: %s amount.
					__( 'The minimum quantity that can be added to the cart is %s', 'woocommerce' ),
					$limits['minimum']
				)
			);
		}

		if ( $quantity > $limits['maximum'] ) {
			return new \WP_Error(
				'invalid_quantity',
				sprintf(
					// Translators: %s amount.
					__( 'The maximum quantity that can be added to the cart is %s', 'woocommerce' ),
					$limits['maximum']
				)
			);
		}

		if ( $quantity % $limits['multiple_of'] ) {
			return new \WP_Error(
				'invalid_quantity',
				sprintf(
					// Translators: %s amount.
					__( 'The quantity added to the cart must be a multiple of %s', 'woocommerce' ),
					$limits['multiple_of']
				)
			);
		}

		return true;
	}

	/**
	 * Get the limit for the total number of a product allowed in the cart.
	 *
	 * This is based on product properties, including remaining stock, and defaults to a maximum of 9999 of any product
	 * in the cart at once.
	 *
	 * @param \WC_Product $product Product instance.
	 * @return int
	 */
	protected function get_product_quantity_limit( \WC_Product $product ) {
		$limits = [ 9999 ];

		if ( $product->is_sold_individually() ) {
			$limits[] = 1;
		} elseif ( ! $product->backorders_allowed() ) {
			$limits[] = $this->get_remaining_stock( $product );
		}

		/**
		 * Filters the quantity limit for a product being added to the cart via the Store API.
		 *
		 * Filters the variation option name for custom option slugs.
		 *
		 * @since 6.8.0
		 *
		 * @param integer $quantity_limit Quantity limit which defaults to 9999 unless sold individually.
		 * @param \WC_Product $product Product instance.
		 * @return integer
		 */
		return apply_filters( 'woocommerce_store_api_product_quantity_limit', max( min( array_filter( $limits ) ), 1 ), $product );
	}

	/**
	 * Returns the remaining stock for a product if it has stock.
	 *
	 * This also factors in draft orders.
	 *
	 * @param \WC_Product $product Product instance.
	 * @return integer|null
	 */
	protected function get_remaining_stock( \WC_Product $product ) {
		if ( is_null( $product->get_stock_quantity() ) ) {
			return null;
		}

		$reserve_stock  = new ReserveStock();
		$reserved_stock = $reserve_stock->get_reserved_stock( $product, $this->get_draft_order_id() );

		return $product->get_stock_quantity() - $reserved_stock;
	}

	/**
	 * Get a quantity for a product or cart item by running it through a filter hook.
	 *
	 * @param int|null          $value Value to filter.
	 * @param string            $value_type Type of value. Used for filter suffix.
	 * @param \WC_Product|array $cart_item_or_product Either a cart item or a product instance.
	 * @return mixed
	 */
	protected function filter_value( $value, string $value_type, $cart_item_or_product ) {
		$is_product = $cart_item_or_product instanceof \WC_Product;
		$product    = $is_product ? $cart_item_or_product : $cart_item_or_product['data'];
		$cart_item  = $is_product ? null : $cart_item_or_product;
		/**
		 * Filters the quantity minimum for a cart item in Store API. This allows extensions to control the minimum qty
		 * of items already within the cart.
		 *
		 * The suffix of the hook will vary depending on the value being filtered.
		 * For example, minimum, maximum, multiple_of, editable.
		 *
		 * @since 6.8.0
		 *
		 * @param mixed $value The value being filtered.
		 * @param \WC_Product $product The product object.
		 * @param array|null $cart_item The cart item if the product exists in the cart, or null.
		 * @return mixed
		 */
		return apply_filters( "woocommerce_store_api_product_quantity_{$value_type}", $value, $product, $cart_item );
	}
}
RateLimits.php000064400000013724151556130600007343 0ustar00<?php
namespace Automattic\WooCommerce\StoreApi\Utilities;

use WC_Rate_Limiter;
use WC_Cache_Helper;

/**
 * RateLimits class.
 */
class RateLimits extends WC_Rate_Limiter {

	/**
	 * Cache group.
	 */
	const CACHE_GROUP = 'store_api_rate_limit';

	/**
	 * Rate limiting enabled default value.
	 *
	 * @var boolean
	 */
	const ENABLED = false;

	/**
	 * Proxy support enabled default value.
	 *
	 * @var boolean
	 */
	const PROXY_SUPPORT = false;

	/**
	 * Default amount of max requests allowed for the defined timeframe.
	 *
	 * @var int
	 */
	const LIMIT = 25;

	/**
	 * Default time in seconds before rate limits are reset.
	 *
	 * @var int
	 */
	const SECONDS = 10;

	/**
	 * Gets a cache prefix.
	 *
	 * @param string $action_id Identifier of the action.
	 * @return string
	 */
	protected static function get_cache_key( $action_id ) {
		return WC_Cache_Helper::get_cache_prefix( 'store_api_rate_limit' . $action_id );
	}

	/**
	 * Get current rate limit row from DB and normalize types. This query is not cached, and returns
	 * a new rate limit row if none exists.
	 *
	 * @param string $action_id Identifier of the action.
	 * @return object Object containing reset and remaining.
	 */
	protected static function get_rate_limit_row( $action_id ) {
		global $wpdb;

		$row = $wpdb->get_row(
			$wpdb->prepare(
				"
					SELECT rate_limit_expiry as reset, rate_limit_remaining as remaining
					FROM {$wpdb->prefix}wc_rate_limits
					WHERE rate_limit_key = %s
					AND rate_limit_expiry > %s
				",
				$action_id,
				time()
			),
			'OBJECT'
		);

		if ( empty( $row ) ) {
			$options = self::get_options();

			return (object) [
				'reset'     => (int) $options->seconds + time(),
				'remaining' => (int) $options->limit,
			];
		}

		return (object) [
			'reset'     => (int) $row->reset,
			'remaining' => (int) $row->remaining,
		];
	}

	/**
	 * Returns current rate limit values using cache where possible.
	 *
	 * @param string $action_id Identifier of the action.
	 * @return object
	 */
	public static function get_rate_limit( $action_id ) {
		$current_limit = self::get_cached( $action_id );

		if ( false === $current_limit ) {
			$current_limit = self::get_rate_limit_row( $action_id );
			self::set_cache( $action_id, $current_limit );
		}

		return $current_limit;
	}

	/**
	 * If exceeded, seconds until reset.
	 *
	 * @param string $action_id Identifier of the action.
	 *
	 * @return bool|int
	 */
	public static function is_exceeded_retry_after( $action_id ) {
		$current_limit = self::get_rate_limit( $action_id );

		// Before the next run is allowed, retry forbidden.
		if ( time() <= $current_limit->reset && 0 === $current_limit->remaining ) {
			return (int) $current_limit->reset - time();
		}

		// After the next run is allowed, retry allowed.
		return false;
	}

	/**
	 * Sets the rate limit delay in seconds for action with identifier $id.
	 *
	 * @param string $action_id Identifier of the action.
	 * @return object Current rate limits.
	 */
	public static function update_rate_limit( $action_id ) {
		global $wpdb;

		$options = self::get_options();

		$rate_limit_expiry = time() + $options->seconds;

		$wpdb->query(
			$wpdb->prepare(
				"INSERT INTO {$wpdb->prefix}wc_rate_limits
					(`rate_limit_key`, `rate_limit_expiry`, `rate_limit_remaining`)
				VALUES
					(%s, %d, %d)
				ON DUPLICATE KEY UPDATE
					`rate_limit_remaining` = IF(`rate_limit_expiry` < %d, VALUES(`rate_limit_remaining`), GREATEST(`rate_limit_remaining` - 1, 0)),
					`rate_limit_expiry` = IF(`rate_limit_expiry` < %d, VALUES(`rate_limit_expiry`), `rate_limit_expiry`);
				",
				$action_id,
				$rate_limit_expiry,
				$options->limit - 1,
				time(),
				time()
			)
		);

		$current_limit = self::get_rate_limit_row( $action_id );

		self::set_cache( $action_id, $current_limit );

		return $current_limit;
	}

	/**
	 * Retrieve a cached store api rate limit.
	 *
	 * @param string $action_id Identifier of the action.
	 * @return bool|object
	 */
	protected static function get_cached( $action_id ) {
		return wp_cache_get( self::get_cache_key( $action_id ), self::CACHE_GROUP );
	}

	/**
	 * Cache a rate limit.
	 *
	 * @param string $action_id Identifier of the action.
	 * @param object $current_limit Current limit object with expiry and retries remaining.
	 * @return bool
	 */
	protected static function set_cache( $action_id, $current_limit ) {
		return wp_cache_set( self::get_cache_key( $action_id ), $current_limit, self::CACHE_GROUP );
	}

	/**
	 * Return options for Rate Limits, to be returned by the "woocommerce_store_api_rate_limit_options" filter.
	 *
	 * @return object Default options.
	 */
	public static function get_options() {
		$default_options = [
			/**
			 * Filters the Store API rate limit check, which is disabled by default.
			 *
			 * This can be used also to disable the rate limit check when testing API endpoints via a REST API client.
			 */
			'enabled'       => self::ENABLED,

			/**
			 * Filters whether proxy support is enabled for the Store API rate limit check. This is disabled by default.
			 *
			 * If the store is behind a proxy, load balancer, CDN etc. the user can enable this to properly obtain
			 * the client's IP address through standard transport headers.
			 */
			'proxy_support' => self::PROXY_SUPPORT,

			'limit'         => self::LIMIT,
			'seconds'       => self::SECONDS,
		];

		return (object) array_merge( // By using array_merge we ensure we get a properly populated options object.
			$default_options,
			/**
			 * Filters options for Rate Limits.
			 *
			 * @param array $rate_limit_options Array of option values.
			 * @return array
			 *
			 * @since 8.9.0
			 */
			apply_filters(
				'woocommerce_store_api_rate_limit_options',
				$default_options
			)
		);
	}

	/**
	 * Gets a single option through provided name.
	 *
	 * @param string $option Option name.
	 *
	 * @return mixed
	 */
	public static function get_option( $option ) {

		if ( ! is_string( $option ) || ! defined( 'RateLimits::' . strtoupper( $option ) ) ) {
			return null;
		}

		return self::get_options()[ $option ];
	}
}
ValidationUtils.php000064400000003352151556130600010375 0ustar00<?php
namespace Automattic\WooCommerce\StoreApi\Utilities;

/**
 * ValidationUtils class.
 * Helper class which validates and update customer info.
 */
class ValidationUtils {
	/**
	 * Get list of states for a country.
	 *
	 * @param string $country Country code.
	 * @return array Array of state names indexed by state keys.
	 */
	public function get_states_for_country( $country ) {
		return $country ? array_filter( (array) \wc()->countries->get_states( $country ) ) : [];
	}

	/**
	 * Validate provided state against a countries list of defined states.
	 *
	 * If there are no defined states for a country, any given state is valid.
	 *
	 * @param string $state State name or code (sanitized).
	 * @param string $country Country code.
	 * @return boolean Valid or not valid.
	 */
	public function validate_state( $state, $country ) {
		$states = $this->get_states_for_country( $country );

		if ( count( $states ) && ! in_array( \wc_strtoupper( $state ), array_map( '\wc_strtoupper', array_keys( $states ) ), true ) ) {
			return false;
		}

		return true;
	}


	/**
	 * Format a state based on the country. If country has defined states, will return a valid upper case state code.
	 *
	 * @param string $state State name or code (sanitized).
	 * @param string $country Country code.
	 * @return string
	 */
	public function format_state( $state, $country ) {
		$states = $this->get_states_for_country( $country );

		if ( count( $states ) ) {
			$state        = \wc_strtoupper( $state );
			$state_values = array_map( '\wc_strtoupper', array_flip( array_map( '\wc_strtoupper', $states ) ) );

			if ( isset( $state_values[ $state ] ) ) {
				// Convert to state code if a state name was provided.
				return $state_values[ $state ];
			}
		}

		return $state;
	}
}