* Move validation calls to checkout API class

* Validate before payment

* Support additional error data

* Add coupon validation before payment, and hide hashes from user

* Implement validation and recalculation

* Abstract notice handler and implement legacy filters

* Handle generic cart item errors

* strip tags from coupon error messages woocommerce/woocommerce-blocks#2212

* Ensure item errors are surfaced when coupons are removed

* Fix wrong value passed to hook

* fix broken checkout when no payment method is there

* try fixing unit test errors

* if preview data has a receiveCart function use it, otherwise default to an anononymous function

* fix tests

Co-authored-by: Seghir Nadir <nadir.seghir@gmail.com>
Co-authored-by: Darren Ethier <darren@roughsmootheng.in>
This commit is contained in:
Mike Jolley 2020-04-17 21:18:54 +01:00 committed by GitHub
parent ae3a2dc7ad
commit 5b142b58ed
17 changed files with 556 additions and 206 deletions

View File

@ -57,7 +57,7 @@ const CheckoutProcessor = () => {
const { hasValidationErrors } = useValidationContext();
const { shippingAddress, shippingErrorStatus } = useShippingDataContext();
const { billingData } = useBillingDataContext();
const { cartNeedsPayment } = useStoreCart();
const { cartNeedsPayment, receiveCart } = useStoreCart();
const {
activePaymentMethod,
currentStatus: currentPaymentStatus,
@ -228,6 +228,10 @@ const CheckoutProcessor = () => {
} )
.catch( ( error ) => {
error.json().then( function( response ) {
// If updated cart state was returned, also update that.
if ( response.data?.cart ) {
receiveCart( response.data.cart );
}
dispatchActions.setHasError();
dispatchActions.setAfterProcessing( response );
setIsProcessingOrder( false );

View File

@ -24,6 +24,8 @@ jest.mock( '@woocommerce/block-data', () => ( {
describe( 'useStoreCart', () => {
let registry, renderer;
const receiveCartMock = () => {};
const previewCartData = {
cartCoupons: previewCart.coupons,
cartItems: previewCart.items,
@ -79,6 +81,7 @@ describe( 'useStoreCart', () => {
shippingRates: [],
shippingRatesLoading: false,
hasShippingAddress: false,
receiveCart: undefined,
};
const getWrappedComponents = ( Component ) => (
@ -88,8 +91,8 @@ describe( 'useStoreCart', () => {
);
const getTestComponent = ( options ) => () => {
const results = useStoreCart( options );
return <div results={ results } />;
const { receiveCart, ...results } = useStoreCart( options );
return <div results={ results } receiveCart={ receiveCart } />;
};
const setUpMocks = () => {
@ -136,9 +139,15 @@ describe( 'useStoreCart', () => {
);
} );
const { results } = renderer.root.findByType( 'div' ).props;
expect( results ).toEqual( defaultCartData );
const { results, receiveCart } = renderer.root.findByType(
'div'
).props;
const {
receiveCart: defaultReceiveCart,
...remaining
} = defaultCartData;
expect( results ).toEqual( remaining );
expect( receiveCart ).toEqual( defaultReceiveCart );
} );
it( 'return store data when shouldSelect is true', () => {
@ -150,9 +159,12 @@ describe( 'useStoreCart', () => {
);
} );
const { results } = renderer.root.findByType( 'div' ).props;
const { results, receiveCart } = renderer.root.findByType(
'div'
).props;
expect( results ).toEqual( mockStoreCartData );
expect( receiveCart ).toBeUndefined();
} );
} );
@ -160,7 +172,12 @@ describe( 'useStoreCart', () => {
beforeEach( () => {
mockBaseContext.useEditorContext.mockReturnValue( {
isEditor: true,
previewData: { previewCart },
previewData: {
previewCart: {
...previewCart,
receiveCart: receiveCartMock,
},
},
} );
} );
@ -173,9 +190,12 @@ describe( 'useStoreCart', () => {
);
} );
const { results } = renderer.root.findByType( 'div' ).props;
const { results, receiveCart } = renderer.root.findByType(
'div'
).props;
expect( results ).toEqual( previewCartData );
expect( receiveCart ).toEqual( receiveCartMock );
} );
} );
} );

View File

@ -36,6 +36,7 @@ export const defaultCartData = {
shippingRates: [],
shippingRatesLoading: false,
hasShippingAddress: false,
receiveCart: () => {},
};
/**
@ -56,7 +57,7 @@ export const useStoreCart = ( options = { shouldSelect: true } ) => {
const { shouldSelect } = options;
const results = useSelect(
( select ) => {
( select, { dispatch } ) => {
if ( ! shouldSelect ) {
return defaultCartData;
}
@ -82,6 +83,10 @@ export const useStoreCart = ( options = { shouldSelect: true } ) => {
shippingRates: previewCart.shipping_rates,
shippingRatesLoading: false,
hasShippingAddress: false,
receiveCart:
typeof previewCart?.receiveCart === 'function'
? previewCart.receiveCart
: () => {},
};
}
@ -93,6 +98,7 @@ export const useStoreCart = ( options = { shouldSelect: true } ) => {
'getCartData'
);
const shippingRatesLoading = store.areShippingRatesLoading();
const { receiveCart } = dispatch( storeKey );
return {
cartCoupons: cartData.coupons,
@ -109,6 +115,7 @@ export const useStoreCart = ( options = { shouldSelect: true } ) => {
shippingRates: cartData.shippingRates,
shippingRatesLoading,
hasShippingAddress: !! cartData.shippingAddress.country,
receiveCart,
};
},
[ shouldSelect ]

View File

@ -67,15 +67,14 @@ const Cart = ( { attributes } ) => {
appliedCoupons,
} = useStoreCartCoupons();
const { addErrorNotice, removeNotice } = useStoreNotices();
const { addErrorNotice } = useStoreNotices();
// Ensures any cart errors listed in the API response get shown.
useEffect( () => {
removeNotice( 'cart-item-error' );
cartItemErrors.forEach( ( error ) => {
addErrorNotice( decodeEntities( error.message ), {
isDismissible: false,
id: 'cart-item-error',
isDismissible: true,
id: error.code,
} );
} );
}, [ cartItemErrors ] );

View File

@ -5,3 +5,4 @@ export const PRODUCT_NOT_ENOUGH_STOCK =
'woocommerce_rest_cart_product_no_stock';
export const PRODUCT_SOLD_INDIVIDUALLY =
'woocommerce_rest_cart_product_sold_individually';
export const GENERIC_CART_ITEM_ERROR = 'woocommerce_rest_cart_item_error';

View File

@ -15,8 +15,17 @@ import {
PRODUCT_NOT_PURCHASABLE,
PRODUCT_NOT_ENOUGH_STOCK,
PRODUCT_SOLD_INDIVIDUALLY,
GENERIC_CART_ITEM_ERROR,
} from './constants';
const cartItemErrorCodes = [
PRODUCT_OUT_OF_STOCK,
PRODUCT_NOT_PURCHASABLE,
PRODUCT_NOT_ENOUGH_STOCK,
PRODUCT_SOLD_INDIVIDUALLY,
GENERIC_CART_ITEM_ERROR,
];
/**
* When an order was not created for the checkout, for example, when an item
* was out of stock, this component will be shown instead of the checkout form.
@ -59,12 +68,7 @@ const CheckoutError = () => {
const ErrorTitle = ( { errorData } ) => {
let heading = __( 'Checkout error', 'woo-gutenberg-products-block' );
if (
errorData.code === PRODUCT_NOT_ENOUGH_STOCK ||
errorData.code === PRODUCT_NOT_PURCHASABLE ||
errorData.code === PRODUCT_OUT_OF_STOCK ||
errorData.code === PRODUCT_SOLD_INDIVIDUALLY
) {
if ( cartItemErrorCodes.includes( errorData.code ) ) {
heading = __(
'There is a problem with your cart',
'woo-gutenberg-products-block'
@ -84,12 +88,7 @@ const ErrorTitle = ( { errorData } ) => {
const ErrorMessage = ( { errorData } ) => {
let message = errorData.message;
if (
errorData.code === PRODUCT_NOT_ENOUGH_STOCK ||
errorData.code === PRODUCT_NOT_PURCHASABLE ||
errorData.code === PRODUCT_OUT_OF_STOCK ||
errorData.code === PRODUCT_SOLD_INDIVIDUALLY
) {
if ( cartItemErrorCodes.includes( errorData.code ) ) {
message =
message +
' ' +
@ -111,12 +110,7 @@ const ErrorButton = ( { errorData } ) => {
let buttonText = __( 'Retry', 'woo-gutenberg-products-block' );
let buttonUrl = 'javascript:window.location.reload(true)';
if (
errorData.code === PRODUCT_NOT_ENOUGH_STOCK ||
errorData.code === PRODUCT_NOT_PURCHASABLE ||
errorData.code === PRODUCT_OUT_OF_STOCK ||
errorData.code === PRODUCT_SOLD_INDIVIDUALLY
) {
if ( cartItemErrorCodes.includes( errorData.code ) ) {
buttonText = __( 'Edit your cart', 'woo-gutenberg-products-block' );
buttonUrl = CART_URL;
}

View File

@ -158,8 +158,8 @@ export const useCheckoutSubscriptions = (
messageContext: emitResponse.noticeContexts.PAYMENTS,
};
}
// leave for checkout to handle.
return null;
// so we don't break the observers.
return true;
};
const unsubscribeProcessing = eventRegistration.onPaymentProcessing(
onSubmit

View File

@ -34,6 +34,8 @@
* being loaded.
* @property {boolean} hasShippingAddress Whether or not the cart
* has a shipping address yet.
* @property {function} receiveCart Dispatcher to receive
* updated cart.
*/
/**

View File

@ -12,6 +12,7 @@ defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Blocks\Payments\PaymentResult;
use Automattic\WooCommerce\Blocks\Payments\PaymentContext;
use Automattic\WooCommerce\Blocks\Payments\PaymentMethodRegistry;
use Automattic\WooCommerce\Blocks\RestApi\StoreApi\Utilities\NoticeHandler;
/**
* Library class.
@ -240,7 +241,7 @@ class Library {
$payment_method_object->validate_fields();
// If errors were thrown, we need to abort.
self::convert_notices_to_exceptions();
NoticeHandler::convert_notices_to_exceptions( 'woocommerce_rest_payment_error' );
// Process Payment.
$gateway_result = $payment_method_object->process_payment( $context->order->get_id() );
@ -259,29 +260,4 @@ class Library {
$result->set_payment_details( array_merge( $result->payment_details, $gateway_result ) );
$result->set_redirect_url( $gateway_result['redirect'] );
}
/**
* Convert notices to Exceptions.
*
* 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.
*
* @throws \Exception If an error notice is detected, Exception is thrown.
*/
protected static function convert_notices_to_exceptions() {
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 \Exception( $error_notice['notice'] );
}
}
}

View File

@ -30,9 +30,10 @@ abstract class AbstractCartRoute extends AbstractRoute {
* @param string $error_code String based error code.
* @param string $error_message User facing error message.
* @param int $http_status_code HTTP status. Defaults to 500.
* @param array $additional_data Extra data (key value pairs) to expose in the error response.
* @return \WP_Error WP Error object.
*/
protected function get_route_error_response( $error_code, $error_message, $http_status_code = 500 ) {
protected function get_route_error_response( $error_code, $error_message, $http_status_code = 500, $additional_data = [] ) {
switch ( $http_status_code ) {
case 409:
// If there was a conflict, return the cart so the client can resolve it.
@ -42,10 +43,13 @@ abstract class AbstractCartRoute extends AbstractRoute {
return new \WP_Error(
$error_code,
$error_message,
[
'status' => $http_status_code,
'cart' => $this->schema->get_item_response( $cart ),
]
array_merge(
$additional_data,
[
'status' => $http_status_code,
'cart' => $this->schema->get_item_response( $cart ),
]
)
);
}
return new \WP_Error( $error_code, $error_message, [ 'status' => $http_status_code ] );

View File

@ -72,7 +72,7 @@ abstract class AbstractRoute implements RouteInterface {
$response->header( 'X-WC-Store-API-Nonce', wp_create_nonce( 'wc_store_api' ) );
}
} catch ( RouteException $error ) {
$response = $this->get_route_error_response( $error->getErrorCode(), $error->getMessage(), $error->getCode() );
$response = $this->get_route_error_response( $error->getErrorCode(), $error->getMessage(), $error->getCode(), $error->getAdditionalData() );
} catch ( \Exception $error ) {
$response = $this->get_route_error_response( 'unknown_server_error', $error->getMessage(), 500 );
}
@ -161,10 +161,11 @@ abstract class AbstractRoute implements RouteInterface {
* @param string $error_code String based error code.
* @param string $error_message User facing error message.
* @param int $http_status_code HTTP status. Defaults to 500.
* @param array $additional_data Extra data (key value pairs) to expose in the error response.
* @return \WP_Error WP Error object.
*/
protected function get_route_error_response( $error_code, $error_message, $http_status_code = 500 ) {
return new \WP_Error( $error_code, $error_message, [ 'status' => $http_status_code ] );
protected function get_route_error_response( $error_code, $error_message, $http_status_code = 500, $additional_data = [] ) {
return new \WP_Error( $error_code, $error_message, array_merge( $additional_data, [ 'status' => $http_status_code ] ) );
}
/**

View File

@ -163,7 +163,16 @@ class Checkout extends AbstractRoute {
);
}
// Ensure order still matches cart.
$order_controller->update_order_from_cart( $order_object );
// If any form fields were posted, update the order.
$this->update_order_from_request( $order_object, $request );
// Check order is still valid.
$order_controller->validate_order_before_payment( $order_object );
// Persist customer address data to account.
$order_controller->sync_customer_data_with_order( $order_object );
if ( ! $order_object->needs_payment() ) {
@ -198,6 +207,37 @@ class Checkout extends AbstractRoute {
return $response;
}
/**
* Get route response when something went wrong.
*
* @param string $error_code String based error code.
* @param string $error_message User facing error message.
* @param int $http_status_code HTTP status. Defaults to 500.
* @param array $additional_data Extra data (key value pairs) to expose in the error response.
* @return \WP_Error WP Error object.
*/
protected function get_route_error_response( $error_code, $error_message, $http_status_code = 500, $additional_data = [] ) {
switch ( $http_status_code ) {
case 409:
// If there was a conflict, return the cart so the client can resolve it.
$controller = new CartController();
$cart = $controller->get_cart_instance();
return new \WP_Error(
$error_code,
$error_message,
array_merge(
$additional_data,
[
'status' => $http_status_code,
'cart' => wc()->api->get_endpoint_data( '/wc/store/cart' ),
]
)
);
}
return new \WP_Error( $error_code, $error_message, [ 'status' => $http_status_code ] );
}
/**
* Gets draft order data from the customer session.
*
@ -246,14 +286,20 @@ class Checkout extends AbstractRoute {
* Create or update a draft order based on the cart.
*
* @throws RouteException On error.
*
* @return \WC_Order Order object.
*/
protected function create_or_update_draft_order() {
$cart_controller = new CartController();
$order_controller = new OrderController();
$reserve_stock = \class_exists( '\Automattic\WooCommerce\Checkout\Helpers\ReserveStock' ) ? new \Automattic\WooCommerce\Checkout\Helpers\ReserveStock() : new ReserveStock();
$order_object = $this->get_draft_order_object( $this->get_draft_order_id() );
$created = false;
// Validate items etc are allowed in the order before it gets created.
$cart_controller->validate_cart_items();
$cart_controller->validate_cart_coupons();
if ( ! $order_object ) {
$order_object = $order_controller->create_order_from_cart();
$created = true;

View File

@ -18,16 +18,24 @@ class RouteException extends \Exception {
*/
public $error_code;
/**
* 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 $message User-friendly translated error message, e.g. 'Product ID is invalid'.
* @param int $http_status_code Proper HTTP status code to respond with, e.g. 400.
* @param array $additional_data Extra data (key value pairs) to expose in the error response.
*/
public function __construct( $error_code, $message, $http_status_code = 400 ) {
$this->error_code = $error_code;
public function __construct( $error_code, $message, $http_status_code = 400, $additional_data = [] ) {
$this->error_code = $error_code;
$this->additional_data = array_filter( (array) $additional_data );
parent::__construct( $message, $http_status_code );
}
@ -39,4 +47,13 @@ class RouteException extends \Exception {
public function getErrorCode() {
return $this->error_code;
}
/**
* Returns additional error data.
*
* @return array
*/
public function getAdditionalData() {
return $this->additional_data;
}
}

View File

@ -268,7 +268,9 @@ class CartSchema extends AbstractSchema {
*/
public function get_item_response( $cart ) {
$controller = new CartController();
$context = 'edit';
// Get cart errors first so if recalculations are performed, it's reflected in the response.
$cart_errors = $this->get_cart_errors( $cart );
return [
'coupons' => array_values( array_map( [ $this->coupon_schema, 'get_item_response' ], array_filter( $cart->get_applied_coupons() ) ) ),
@ -292,12 +294,12 @@ class CartSchema extends AbstractSchema {
'total_shipping_tax' => $this->prepare_money_response( $cart->get_shipping_tax(), wc_get_price_decimals() ),
// Explicitly request context='edit'; default ('view') will render total as markup.
'total_price' => $this->prepare_money_response( $cart->get_total( $context ), wc_get_price_decimals() ),
'total_price' => $this->prepare_money_response( $cart->get_total( 'edit' ), wc_get_price_decimals() ),
'total_tax' => $this->prepare_money_response( $cart->get_total_tax(), wc_get_price_decimals() ),
'tax_lines' => $this->get_tax_lines( $cart ),
]
),
'errors' => $this->get_cart_errors( $cart ),
'errors' => $cart_errors,
];
}
@ -328,9 +330,10 @@ class CartSchema extends AbstractSchema {
* @return array
*/
protected function get_cart_errors( $cart ) {
$controller = new CartController();
$errors = $controller->get_cart_item_errors();
$controller = new CartController();
$item_errors = $controller->get_cart_item_errors();
$coupon_errors = $controller->get_cart_coupon_errors();
return array_values( array_map( [ $this->error_schema, 'get_item_response' ], $errors ) );
return array_values( array_map( [ $this->error_schema, 'get_item_response' ], array_merge( $item_errors, $coupon_errors ) ) );
}
}

View File

@ -10,6 +10,7 @@ namespace Automattic\WooCommerce\Blocks\RestApi\StoreApi\Utilities;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Blocks\RestApi\StoreApi\Routes\RouteException;
use Automattic\WooCommerce\Blocks\RestApi\StoreApi\Utilities\NoticeHandler;
/**
* Woo Cart Controller class.
@ -43,65 +44,17 @@ class CartController {
$request = $this->filter_request_data( $this->parse_variation_data( $request ) );
$product = $this->get_product_for_cart( $request );
if ( $product->is_type( 'variation' ) ) {
$product_id = $product->get_parent_id();
$variation_id = $product->get_id();
} else {
$product_id = $product->get_id();
$variation_id = 0;
}
$cart_id = wc()->cart->generate_cart_id(
$product_id,
$variation_id,
$cart_id = wc()->cart->generate_cart_id(
$this->get_product_id( $product ),
$this->get_variation_id( $product ),
$request['variation'],
$request['cart_item_data']
);
$this->validate_add_to_cart( $product, $request );
$existing_cart_id = wc()->cart->find_product_in_cart( $cart_id );
if ( ! $product->is_purchasable() ) {
throw new RouteException(
'woocommerce_rest_cart_product_is_not_purchasable',
sprintf(
/* translators: %s: product name */
__( '&quot;%s&quot; is not available for purchase.', 'woo-gutenberg-products-block' ),
$product->get_name()
),
403
);
}
if ( ! $product->is_in_stock() ) {
throw new RouteException(
'woocommerce_rest_cart_product_no_stock',
sprintf(
/* translators: %s: product name */
__( 'You cannot add &quot;%s&quot; to the cart because the product is out of stock.', 'woo-gutenberg-products-block' ),
$product->get_name()
),
403
);
}
if ( $product->managing_stock() ) {
$qty_remaining = $this->get_remaining_stock_for_product( $product );
$qty_in_cart = $this->get_product_quantity_in_cart( $product );
if ( $qty_remaining < $qty_in_cart + $request['quantity'] ) {
throw new RouteException(
'woocommerce_rest_cart_product_no_stock',
sprintf(
/* translators: 1: product name 2: quantity in stock */
__( 'You cannot add that amount of &quot;%1$s&quot; to the cart because there is not enough stock (%2$s remaining).', 'woo-gutenberg-products-block' ),
$product->get_name(),
wc_format_stock_quantity_for_display( $product->get_stock_quantity(), $product )
),
403
);
}
}
if ( $existing_cart_id ) {
if ( $product->is_sold_individually() ) {
throw new RouteException(
@ -125,8 +78,8 @@ class CartController {
$request['cart_item_data'],
array(
'key' => $cart_id,
'product_id' => $product_id,
'variation_id' => $variation_id,
'product_id' => $this->get_product_id( $product ),
'variation_id' => $this->get_variation_id( $product ),
'variation' => $request['variation'],
'quantity' => $request['quantity'],
'data' => $product,
@ -141,9 +94,9 @@ class CartController {
do_action(
'woocommerce_add_to_cart',
$cart_id,
$product_id,
$this->get_product_id( $product ),
$request['quantity'],
$variation_id,
$this->get_variation_id( $product ),
$request['variation'],
$request['cart_item_data']
);
@ -187,6 +140,83 @@ class CartController {
wc()->cart->set_quantity( $item_id, $quantity );
}
/**
* Validate all items in the cart and check for errors.
*
* @throws RouteException Exception if invalid data is detected.
*
* @param \WC_Product $product Product object associated with the cart item.
* @param array $request Add to cart request params.
*/
public function validate_add_to_cart( \WC_Product $product, $request ) {
if ( ! $product->is_purchasable() ) {
$this->throw_default_product_exception( $product );
}
if ( ! $product->is_in_stock() ) {
throw new RouteException(
'woocommerce_rest_cart_product_no_stock',
sprintf(
/* translators: %s: product name */
__( 'You cannot add &quot;%s&quot; to the cart because the product is out of stock.', 'woo-gutenberg-products-block' ),
$product->get_name()
),
403
);
}
if ( $product->managing_stock() ) {
$qty_remaining = $this->get_remaining_stock_for_product( $product );
$qty_in_cart = $this->get_product_quantity_in_cart( $product );
if ( $qty_remaining < $qty_in_cart + $request['quantity'] ) {
throw new RouteException(
'woocommerce_rest_cart_product_no_stock',
sprintf(
/* translators: 1: product name 2: quantity in stock */
__( 'You cannot add that amount of &quot;%1$s&quot; to the cart because there is not enough stock (%2$s remaining).', 'woo-gutenberg-products-block' ),
$product->get_name(),
wc_format_stock_quantity_for_display( $product->get_stock_quantity(), $product )
),
403
);
}
}
/**
* Hook: woocommerce_add_to_cart_validation (legacy).
*
* Allow 3rd parties to validate if an item can be added to the cart. This is a legacy hook from Woo core.
* This filter will be deprecated because it encourages usage of wc_add_notice. For the API we need to capture
* notices and convert to exceptions instead.
*/
$passed_validation = apply_filters(
'woocommerce_add_to_cart_validation',
true,
$this->get_product_id( $product ),
$request['quantity'],
$this->get_variation_id( $product ),
$request['variation']
);
if ( ! $passed_validation ) {
// Validation did not pass - see if an error notice was thrown.
NoticeHandler::convert_notices_to_exceptions( 'woocommerce_rest_add_to_cart_error' );
// If no notice was thrown, throw the default notice instead.
$this->throw_default_product_exception( $product );
}
/**
* Fire action to validate add to cart. Functions hooking into this should throw an \Exception to prevent
* add to cart from occuring.
*
* @param \WC_Product $product Product object being added to the cart.
* @param array $request Add to cart request params including id, quantity, and variation attributes.
*/
do_action( 'wooocommerce_store_api_validate_add_to_cart', $product, $request );
}
/**
* Validate all items in the cart and check for errors.
*
@ -198,26 +228,16 @@ class CartController {
foreach ( $cart_items as $cart_item_key => $cart_item ) {
$this->validate_cart_item( $cart_item );
}
}
/**
* Validate all items in the cart and get a list of errors.
*
* @throws RouteException Exception if invalid data is detected.
*/
public function get_cart_item_errors() {
$errors = [];
$cart_items = $this->get_cart_items();
foreach ( $cart_items as $cart_item_key => $cart_item ) {
try {
$this->validate_cart_item( $cart_item );
} catch ( RouteException $error ) {
$errors[] = new \WP_Error( $error->getErrorCode(), $error->getMessage() );
}
}
return $errors;
/**
* Hook: woocommerce_check_cart_items
*
* Allow 3rd parties to validate cart items. This is a legacy hook from Woo core.
* This filter will be deprecated because it encourages usage of wc_add_notice. For the API we need to capture
* notices and convert to exceptions instead.
*/
do_action( 'woocommerce_check_cart_items' );
NoticeHandler::convert_notices_to_exceptions( 'woocommerce_rest_cart_item_error' );
}
/**
@ -225,9 +245,9 @@ class CartController {
*
* @throws RouteException Exception if invalid data is detected.
*
* @param array $cart_item Cart item data.
* @param array $cart_item Cart item array.
*/
protected function validate_cart_item( $cart_item ) {
public function validate_cart_item( $cart_item ) {
$product = $cart_item['data'];
if ( ! $product instanceof \WC_Product ) {
@ -235,15 +255,7 @@ class CartController {
}
if ( ! $product->is_purchasable() ) {
throw new RouteException(
'woocommerce_rest_cart_product_is_not_purchasable',
sprintf(
/* translators: %s: product name */
__( '&quot;%s&quot; is not available for purchase.', 'woo-gutenberg-products-block' ),
$product->get_name()
),
403
);
$this->throw_default_product_exception( $product );
}
if ( $product->is_sold_individually() && $cart_item['quantity'] > 1 ) {
@ -292,39 +304,70 @@ class CartController {
);
}
}
/**
* Fire action to validate add to cart. Functions hooking into this should throw an \Exception to prevent
* add to cart from occuring.
*
* @param \WC_Product $product Product object being added to the cart.
* @param array $cart_item Cart item array.
*/
do_action( 'wooocommerce_store_api_validate_cart_item', $product, $cart_item );
}
/**
* Gets the qty of a product across line items.
* Validate all coupons in the cart and check for errors.
*
* @param \WC_Product $product Product object.
* @return int
* @throws RouteException Exception if invalid data is detected.
*/
protected function get_product_quantity_in_cart( $product ) {
$product_quantities = wc()->cart->get_cart_item_quantities();
$product_id = $product->get_stock_managed_by_id();
public function validate_cart_coupons() {
$cart_coupons = $this->get_cart_coupons();
return isset( $product_quantities[ $product_id ] ) ? $product_quantities[ $product_id ] : 0;
foreach ( $cart_coupons as $code ) {
$coupon = new \WC_Coupon( $code );
$this->validate_cart_coupon( $coupon );
}
}
/**
* Gets remaining stock for a product.
* Validate all items in the cart and get a list of errors.
*
* @param \WC_Product $product Product object.
* @return int
* @throws RouteException Exception if invalid data is detected.
*/
protected function get_remaining_stock_for_product( $product ) {
// @todo Remove once min support for WC reaches 4.1.0.
if ( \class_exists( '\Automattic\WooCommerce\Checkout\Helpers\ReserveStock' ) ) {
$reserve_stock_controller = new \Automattic\WooCommerce\Checkout\Helpers\ReserveStock();
} else {
$reserve_stock_controller = new \Automattic\WooCommerce\Blocks\RestApi\StoreApi\Utilities\ReserveStock();
public function get_cart_item_errors() {
$errors = [];
$cart_items = $this->get_cart_items();
foreach ( $cart_items as $cart_item_key => $cart_item ) {
try {
$this->validate_cart_item( $cart_item );
} catch ( RouteException $error ) {
$errors[] = new \WP_Error( $error->getErrorCode(), $error->getMessage() );
}
}
$draft_order = WC()->session->get( 'store_api_draft_order', 0 );
$qty_reserved = $reserve_stock_controller->get_reserved_stock( $product, $draft_order );
return $errors;
}
return $product->get_stock_quantity() - $qty_reserved;
/**
* Validate all items in the cart and get a list of errors.
*
* @throws RouteException Exception if invalid data is detected.
*/
public function get_cart_coupon_errors() {
$errors = [];
$cart_coupons = $this->get_cart_coupons();
foreach ( $cart_coupons as $code ) {
try {
$coupon = new \WC_Coupon( $code );
$this->validate_cart_coupon( $coupon );
} catch ( RouteException $error ) {
$errors[] = new \WP_Error( $error->getErrorCode(), $error->getMessage() );
}
}
return $errors;
}
/**
@ -370,11 +413,11 @@ class CartController {
*/
public function get_cart_hashes() {
return [
'line_items' => WC()->cart->get_cart_hash(),
'shipping' => md5( wp_json_encode( WC()->cart->shipping_methods ) ),
'fees' => md5( wp_json_encode( WC()->cart->get_fees() ) ),
'coupons' => md5( wp_json_encode( WC()->cart->get_applied_coupons() ) ),
'taxes' => md5( wp_json_encode( WC()->cart->get_taxes() ) ),
'line_items' => wc()->cart->get_cart_hash(),
'shipping' => md5( wp_json_encode( wc()->cart->shipping_methods ) ),
'fees' => md5( wp_json_encode( wc()->cart->get_fees() ) ),
'coupons' => md5( wp_json_encode( wc()->cart->get_applied_coupons() ) ),
'taxes' => md5( wp_json_encode( wc()->cart->get_taxes() ) ),
];
}
@ -430,7 +473,7 @@ class CartController {
}
}
return $calculate_rates ? WC()->shipping()->calculate_shipping( $packages ) : $packages;
return $calculate_rates ? wc()->shipping()->calculate_shipping( $packages ) : $packages;
}
/**
@ -441,10 +484,10 @@ class CartController {
*/
public function select_shipping_rate( $package_id, $rate_id ) {
$cart = $this->get_cart_instance();
$session_data = WC()->session->get( 'chosen_shipping_methods' ) ? WC()->session->get( 'chosen_shipping_methods' ) : [];
$session_data = wc()->session->get( 'chosen_shipping_methods' ) ? wc()->session->get( 'chosen_shipping_methods' ) : [];
$session_data[ $package_id ] = $rate_id;
WC()->session->set( 'chosen_shipping_methods', $session_data );
wc()->session->set( 'chosen_shipping_methods', $session_data );
}
/**
@ -490,7 +533,7 @@ class CartController {
if ( ! $coupon->is_valid() ) {
throw new RouteException(
'woocommerce_rest_cart_coupon_error',
$coupon->get_error_message(),
wp_strip_all_tags( $coupon->get_error_message() ),
403
);
}
@ -533,6 +576,63 @@ class CartController {
do_action( 'woocommerce_applied_coupon', $coupon_code );
}
/**
* Validates an existing cart coupon and returns any errors.
*
* @throws RouteException Exception if invalid data is detected.
*
* @param \WC_Coupon $coupon Coupon object applied to the cart.
*/
protected function validate_cart_coupon( \WC_Coupon $coupon ) {
if ( ! $coupon->is_valid() ) {
wc()->cart->remove_coupon( $coupon->get_code() );
wc()->cart->calculate_totals();
throw new RouteException(
'woocommerce_rest_cart_coupon_error',
sprintf(
// translators: %1$s coupon code, %2$s reason.
__( 'The "%1$s" coupon has been removed from your cart: %2$s', 'woo-gutenberg-products-block' ),
$coupon->get_code(),
wp_strip_all_tags( $coupon->get_error_message() )
),
409
);
}
}
/**
* Gets the qty of a product across line items.
*
* @param \WC_Product $product Product object.
* @return int
*/
protected function get_product_quantity_in_cart( $product ) {
$product_quantities = wc()->cart->get_cart_item_quantities();
$product_id = $product->get_stock_managed_by_id();
return isset( $product_quantities[ $product_id ] ) ? $product_quantities[ $product_id ] : 0;
}
/**
* Gets remaining stock for a product.
*
* @param \WC_Product $product Product object.
* @return int
*/
protected function get_remaining_stock_for_product( $product ) {
// @todo Remove once min support for WC reaches 4.1.0.
if ( \class_exists( '\Automattic\WooCommerce\Checkout\Helpers\ReserveStock' ) ) {
$reserve_stock_controller = new \Automattic\WooCommerce\Checkout\Helpers\ReserveStock();
} else {
$reserve_stock_controller = new \Automattic\WooCommerce\Blocks\RestApi\StoreApi\Utilities\ReserveStock();
}
$draft_order = wc()->session->get( 'store_api_draft_order', 0 );
$qty_reserved = $reserve_stock_controller->get_reserved_stock( $product, $draft_order );
return $product->get_stock_quantity() - $qty_reserved;
}
/**
* Get a product object to be added to the cart.
*
@ -555,6 +655,45 @@ class CartController {
return $product;
}
/**
* For a given product, get the product ID.
*
* @param \WC_Product $product Product object associated with the cart item.
* @return int
*/
protected function get_product_id( \WC_Product $product ) {
return $product->is_type( 'variation' ) ? $product->get_parent_id() : $product->get_id();
}
/**
* For a given product, get the variation ID.
*
* @param \WC_Product $product Product object associated with the cart item.
* @return int
*/
protected function get_variation_id( \WC_Product $product ) {
return $product->is_type( 'variation' ) ? $product->get_id() : 0;
}
/**
* Default exception thrown when an item cannot be added to the cart.
*
* @throws RouteException Exception with code woocommerce_rest_cart_product_is_not_purchasable.
*
* @param \WC_Product $product Product object associated with the cart item.
*/
protected function throw_default_product_exception( \WC_Product $product ) {
throw new RouteException(
'woocommerce_rest_cart_product_is_not_purchasable',
sprintf(
/* translators: %s: product name */
__( '&quot;%s&quot; is not available for purchase.', 'woo-gutenberg-products-block' ),
$product->get_name()
),
403
);
}
/**
* Filter data for add to cart requests.
*

View File

@ -0,0 +1,45 @@
<?php
/**
* Helper class to convert notices to exceptions.
*
* @package WooCommerce/Blocks
*/
namespace Automattic\WooCommerce\Blocks\RestApi\StoreApi\Utilities;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Blocks\RestApi\StoreApi\Routes\RouteException;
/**
* NoticeHandler class.
*/
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, $error_notice['notice'], 400 );
}
}
}

View File

@ -97,6 +97,100 @@ class OrderController {
};
}
/**
* 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 ) {
$coupons = $order->get_coupon_codes();
$coupon_errors = [];
foreach ( $coupons as $coupon_code ) {
$coupon = new \WC_Coupon( $coupon_code );
try {
$this->validate_coupon_email_restriction( $coupon, $order );
} catch ( \Exception $error ) {
$coupon_errors[ $coupon_code ] = $error->getMessage();
}
try {
$this->validate_coupon_usage_limit( $coupon, $order );
} catch ( \Exception $error ) {
$coupon_errors[ $coupon_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"', 'woo-gutenberg-products-block' ),
implode( '", "', array_keys( $coupon_errors ) )
),
409,
[
'removed_coupons' => $coupon_errors,
]
);
}
}
/**
* 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, $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, $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 ) );
}
}
}
/**
* Changes default order status to draft for orders created via this API.
*
@ -113,38 +207,36 @@ class OrderController {
*/
protected function update_line_items_from_cart( \WC_Order $order ) {
$cart_controller = new CartController();
$cart_controller->validate_cart_items();
$cart = $cart_controller->get_cart_instance();
$cart_hashes = $cart_controller->get_cart_hashes();
$cart = $cart_controller->get_cart_instance();
$cart_hashes = $cart_controller->get_cart_hashes();
if ( $order->get_cart_hash() !== $cart_hashes['line_items'] ) {
$order->remove_order_items( 'line_item' );
$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'] ) {
if ( $order->get_meta_data( '_shipping_hash' ) !== $cart_hashes['shipping'] ) {
$order->update_meta_data( '_shipping_hash', $cart_hashes['shipping'] );
$order->remove_order_items( 'shipping' );
$order->update_meta_data( 'shipping_hash', $cart_hashes['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'] ) {
if ( $order->get_meta_data( '_coupons_hash' ) !== $cart_hashes['coupons'] ) {
$order->remove_order_items( 'coupon' );
$order->update_meta_data( 'coupons_hash', $cart_hashes['coupons'] );
$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'] ) {
if ( $order->get_meta_data( '_fees_hash' ) !== $cart_hashes['fees'] ) {
$order->update_meta_data( '_fees_hash', $cart_hashes['fees'] );
$order->remove_order_items( 'fee' );
$order->update_meta_data( 'fees_hash', $cart_hashes['fees'] );
WC()->checkout->create_order_fee_lines( $order, $cart );
}
if ( $order->get_meta_data( 'taxes_hash' ) !== $cart_hashes['taxes'] ) {
if ( $order->get_meta_data( '_taxes_hash' ) !== $cart_hashes['taxes'] ) {
$order->update_meta_data( '_taxes_hash', $cart_hashes['taxes'] );
$order->remove_order_items( 'tax' );
$order->update_meta_data( 'taxes_hash', $cart_hashes['taxes'] );
WC()->checkout->create_order_tax_lines( $order, $cart );
}
}