initial commit
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||
}
|
||||
|
||||
}
|
@ -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 {
|
||||
|
||||
}
|
@ -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 );
|
||||
}
|
||||
}
|
||||
}
|
@ -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 );
|
||||
}
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
||||
}
|
@ -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 ) );
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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' ) );
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
@ -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 {
|
||||
|
||||
}
|
Reference in New Issue
Block a user