initial commit

This commit is contained in:
2021-12-10 12:03:04 +00:00
commit c46c7ddbf0
3643 changed files with 582794 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,75 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Utilities;
use WP_Error;
/**
* InvalidStockLevelsInCartException class.
*
* @internal This API is used internally by Blocks, this exception is thrown if any items are out of stock
* after each product on a draft order has been stock checked.
*/
class InvalidStockLevelsInCartException extends \Exception {
/**
* Sanitized error code.
*
* @var string
*/
public $error_code;
/**
* Additional error data.
*
* @var array
*/
public $additional_data = [];
/**
* All errors to display to the user.
*
* @var WP_Error
*/
public $error;
/**
* Setup exception.
*
* @param string $error_code Machine-readable error code, e.g `woocommerce_invalid_product_id`.
* @param WP_Error $error The WP_Error object containing all errors relating to stock availability.
* @param array $additional_data Extra data (key value pairs) to expose in the error response.
*/
public function __construct( $error_code, $error, $additional_data = [] ) {
$this->error_code = $error_code;
$this->error = $error;
$this->additional_data = array_filter( (array) $additional_data );
parent::__construct( '', 409 );
}
/**
* Returns the error code.
*
* @return string
*/
public function getErrorCode() {
return $this->error_code;
}
/**
* Returns the list of messages.
*
* @return WP_Error
*/
public function getError() {
return $this->error;
}
/**
* Returns additional error data.
*
* @return array
*/
public function getAdditionalData() {
return $this->additional_data;
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Utilities;
/**
* NotPurchasableException class.
*
* @internal This API is used internally by Blocks, this exception is thrown when an item in the cart is not able to be
* purchased.
*/
class NotPurchasableException extends StockAvailabilityException {
}

View File

@ -0,0 +1,40 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Utilities;
use Automattic\WooCommerce\Blocks\StoreApi\Routes\RouteException;
/**
* NoticeHandler class.
* Helper class to convert notices to exceptions.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
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' ) ) {
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 );
}
}
}

View File

@ -0,0 +1,526 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Utilities;
use \Exception;
use Automattic\WooCommerce\Blocks\StoreApi\Routes\RouteException;
/**
* OrderController class.
* Helper class which creates and syncs orders with the cart.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
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.
*/
public function update_order_from_cart( \WC_Order $order ) {
// Ensure cart is current.
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_value = is_callable( [ $order, 'get_shipping_phone' ] ) ? $order->get_shipping_phone() : $order->get_meta( '_shipping_phone', true );
if ( is_callable( [ $customer, 'set_shipping_phone' ] ) ) {
$customer->set_shipping_phone( $shipping_phone_value );
} else {
$customer->update_meta_data( 'shipping_phone', $shipping_phone_value );
}
$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,
[]
);
}
}
}
/**
* 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_value = is_callable( [ wc()->customer, 'get_shipping_phone' ] ) ? wc()->customer->get_shipping_phone() : wc()->customer->get_meta( 'shipping_phone', true );
if ( is_callable( [ $order, 'set_shipping_phone' ] ) ) {
$order->set_shipping_phone( $shipping_phone_value );
} else {
$order->update_meta_data( '_shipping_phone', $shipping_phone_value );
}
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Utilities;
/**
* OutOfStockException class.
*
* @internal This API is used internally by Blocks, this exception is thrown when an item in a draft order is out
* of stock completely.
*/
class OutOfStockException extends StockAvailabilityException {
}

View File

@ -0,0 +1,74 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Utilities;
/**
* Pagination class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
* @since 2.5.0
*/
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 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 ) );
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Utilities;
/**
* PartialOutOfStockException class.
*
* @internal This API is used internally by Blocks, this exception is thrown when an item in a draft order has a
* quantity greater than what is available in stock.
*/
class PartialOutOfStockException extends StockAvailabilityException {
}

View File

@ -0,0 +1,476 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Utilities;
use WC_Tax;
/**
* Product Query class.
* Helper class to handle product queries for the API.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
* @since 2.5.0
*/
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.
'fields' => 'ids',
'ignore_sticky_posts' => true,
'post_status' => 'publish',
'date_query' => [],
'post_type' => 'product',
];
// If searching for a specific SKU, allow any post type.
if ( ! empty( $request['sku'] ) ) {
$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',
];
// Map between taxonomy name and arg key.
$taxonomies = [
'product_cat' => 'category',
'product_tag' => 'tag',
];
// 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( '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 ) {
$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;
}
}

View File

@ -0,0 +1,217 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Utilities;
use Automattic\WooCommerce\Blocks\StoreApi\Utilities\ProductQuery;
/**
* Product Query filters class.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
* @since 2.5.0
*/
class ProductQueryFilters {
/**
* Get filtered min price for current products.
*
* @param \WP_REST_Request $request The request object.
* @return array
*/
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 attribute counts for the current products.
*
* @param \WP_REST_Request $request The request object.
* @param array $attributes Attributes to count, either names or ids.
* @return array termId=>count pairs.
*/
public function get_attribute_counts( $request, $attributes = [] ) {
global $wpdb;
// Remove paging and sorting params from the request.
$request->set_param( 'page', null );
$request->set_param( 'per_page', null );
$request->set_param( 'order', null );
$request->set_param( 'orderby', null );
// 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' );
if ( count( $attributes ) === count( array_filter( $attributes, 'is_numeric' ) ) ) {
$attributes = array_map( 'wc_attribute_taxonomy_name_by_id', wp_parse_id_list( $attributes ) );
}
$attributes_to_count = array_map(
function( $attribute ) {
$attribute = wc_sanitize_taxonomy_name( $attribute );
return esc_sql( $attribute );
},
$attributes
);
$attributes_to_count_sql = 'AND term_taxonomy.taxonomy IN ("' . implode( '","', $attributes_to_count ) . '")';
$attribute_count_sql = "
SELECT COUNT( DISTINCT posts.ID ) as term_count, terms.term_id as term_count_id
FROM {$wpdb->posts} AS posts
INNER JOIN {$wpdb->term_relationships} AS term_relationships ON posts.ID = term_relationships.object_id
INNER JOIN {$wpdb->term_taxonomy} AS term_taxonomy USING( term_taxonomy_id )
INNER JOIN {$wpdb->terms} AS terms USING( term_id )
WHERE posts.ID IN ( {$product_query_sql} )
{$attributes_to_count_sql}
GROUP BY terms.term_id
";
$results = $wpdb->get_results( $attribute_count_sql ); // phpcs:ignore
return array_map( 'absint', wp_list_pluck( $results, 'term_count', 'term_count_id' ) );
}
/**
* 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' ) );
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Utilities;
/**
* StockAvailabilityException class.
*
* @internal This API is used internally by Blocks, this exception is thrown when more than one of a product that
* can only be purchased individually is in a cart.
*/
class StockAvailabilityException extends \Exception {
/**
* Sanitized error code.
*
* @var string
*/
public $error_code;
/**
* The name of the product that can only be purchased individually.
*
* @var string
*/
public $product_name;
/**
* Additional error data.
*
* @var array
*/
public $additional_data = [];
/**
* Setup exception.
*
* @param string $error_code Machine-readable error code, e.g `woocommerce_invalid_product_id`.
* @param string $product_name The name of the product that can only be purchased individually.
* @param array $additional_data Extra data (key value pairs) to expose in the error response.
*/
public function __construct( $error_code, $product_name, $additional_data = [] ) {
$this->error_code = $error_code;
$this->product_name = $product_name;
$this->additional_data = array_filter( (array) $additional_data );
parent::__construct();
}
/**
* Returns the error code.
*
* @return string
*/
public function getErrorCode() {
return $this->error_code;
}
/**
* Returns additional error data.
*
* @return array
*/
public function getAdditionalData() {
return $this->additional_data;
}
/**
* Returns the product name.
*
* @return string
*/
public function getProductName() {
return $this->product_name;
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Utilities;
/**
* TooManyInCartException class.
*
* @internal This API is used internally by Blocks, this exception is thrown when more than one of a product that
* can only be purchased individually is in a cart.
*/
class TooManyInCartException extends StockAvailabilityException {
}