Skip to content
This repository was archived by the owner on Feb 23, 2024. It is now read-only.

Commit 00f478c

Browse files
authored
Improve error displayed to customers when an item's stock status changes during checkout. (#3656)
* Add new exceptions for out of stock scenarios These are needed to differentiate between the different stock validation errors, and so we can create the correct error message. * Catch new out of stock exceptions when checking the cart for errors This is so we can get the cart sent back to the client, if we don't catch these, then the route will just return a 500 error and crash. * Add ArrayUtils class This will contain methods used to operate on arrays that don't fit anywhere else. * Handle the case in Checkout where the error is already a WP_Error This will happen when the cart fails validation. * Handle StockAvailabilityException in AbstractRoute This will happen when an item or number of items in the cart are out of stock/insufficient stock. * Throw exceptions for each type of invalid stock in validate_cart_items This will allow us to create an error message for each type of violation to display to the user. * Display additional error notices returned by the API * Fix wording when throwing exceptions relating to stock * Handle TooManyInCartException in CartController * Abstract the merging of cart, status, and additional data into new fn This allows us to simplify the way errors are returned from the API. The reason we have to add all of the data at once is because of how WP_Error works with the additional data, if there is already existing data in a WP_Error object, it gets moved into additional_data. By adding all of the data in one place, we stop this from happening. Also since we're only adding status and/or cart explicitly, it makes sense to just do it in one place. * Add get_route_error_response_from_object method This is so we can differentiate between a string and WP_Error object. * Remove unnecessary slashes from WP_Error instantiation * Add option to enclose each item in quotes in natural_language_join * Abstract adding error messages to error object into single function A lot of code was repeated, so doing this cuts down on that and ensures any changes only need to be made in one place. * Create new parent exception for each type of out of stock exception This is so we don't have to repeat code inside each different exception and we can simply inherit StockAvailabilityException. * Catch the generic StockAvailabilityException in get_cart_item_errors * No longer recalculate totals in validate function It is not needed, the totals are recalculated elsewhere. This call was superfluous. * Reduce nesting, and only throw exception if error object has errors * Improve comment on get_route_error_response_from_object method * Fix nesting when throwing the InvalidStockLevelsInCartException
1 parent 6ebd620 commit 00f478c

File tree

11 files changed

+448
-61
lines changed

11 files changed

+448
-61
lines changed

assets/js/base/context/cart-checkout/checkout/processor/index.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,17 @@ const CheckoutProcessor = () => {
232232
addErrorNotice( formatStoreApiErrorMessage( response ), {
233233
id: 'checkout',
234234
} );
235+
236+
if ( Array.isArray( response.additional_errors ) ) {
237+
response.additional_errors.forEach(
238+
( additionalError ) => {
239+
addErrorNotice( additionalError.message, {
240+
id: additionalError.error_code,
241+
} );
242+
}
243+
);
244+
}
245+
235246
dispatchActions.setHasError();
236247
dispatchActions.setAfterProcessing( response );
237248
setIsProcessingOrder( false );

src/StoreApi/Routes/AbstractRoute.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
namespace Automattic\WooCommerce\Blocks\StoreApi\Routes;
33

44
use Automattic\WooCommerce\Blocks\StoreApi\Schemas\AbstractSchema;
5+
use Automattic\WooCommerce\Blocks\StoreApi\Utilities\InvalidStockLevelsInCartException;
6+
use WP_Error;
57

68
/**
79
* AbstractRoute class.
@@ -72,6 +74,8 @@ public function get_response( \WP_REST_Request $request ) {
7274
}
7375
} catch ( RouteException $error ) {
7476
$response = $this->get_route_error_response( $error->getErrorCode(), $error->getMessage(), $error->getCode(), $error->getAdditionalData() );
77+
} catch ( InvalidStockLevelsInCartException $error ) {
78+
$response = $this->get_route_error_response_from_object( $error->getError(), $error->getCode(), $error->getAdditionalData() );
7579
} catch ( \Exception $error ) {
7680
$response = $this->get_route_error_response( 'unknown_server_error', $error->getMessage(), 500 );
7781
}
@@ -204,6 +208,21 @@ protected function get_route_error_response( $error_code, $error_message, $http_
204208
return new \WP_Error( $error_code, $error_message, array_merge( $additional_data, [ 'status' => $http_status_code ] ) );
205209
}
206210

211+
/**
212+
* Get route response when something went wrong and the supplied error is a WP_Error. This currently only happens
213+
* when an item in the cart is out of stock, partially out of stock, can only be bought individually, or when the
214+
* item is not purchasable.
215+
*
216+
* @param WP_Error $error_object The WP_Error object containing the error.
217+
* @param int $http_status_code HTTP status. Defaults to 500.
218+
* @param array $additional_data Extra data (key value pairs) to expose in the error response.
219+
* @return WP_Error WP Error object.
220+
*/
221+
protected function get_route_error_response_from_object( $error_object, $http_status_code = 500, $additional_data = [] ) {
222+
$error_object->add_data( array_merge( $additional_data, [ 'status' => $http_status_code ] ) );
223+
return $error_object;
224+
}
225+
207226
/**
208227
* Prepare a single item for response.
209228
*

src/StoreApi/Routes/Checkout.php

Lines changed: 46 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<?php
22
namespace Automattic\WooCommerce\Blocks\StoreApi\Routes;
33

4+
use Automattic\WooCommerce\Blocks\StoreApi\Utilities\InvalidStockLevelsInCartException;
45
use \Exception;
56
use \WP_Error;
67
use \WP_REST_Server;
@@ -190,7 +191,10 @@ protected function get_route_update_response( WP_REST_Request $request ) {
190191
* 5. Process Payment
191192
*
192193
* @throws RouteException On error.
194+
* @throws InvalidStockLevelsInCartException On error.
195+
*
193196
* @param WP_REST_Request $request Request object.
197+
*
194198
* @return WP_REST_Response
195199
*/
196200
protected function get_route_post_response( WP_REST_Request $request ) {
@@ -260,36 +264,51 @@ protected function get_route_post_response( WP_REST_Request $request ) {
260264
* @return WP_Error WP Error object.
261265
*/
262266
protected function get_route_error_response( $error_code, $error_message, $http_status_code = 500, $additional_data = [] ) {
267+
$error_from_message = new WP_Error(
268+
$error_code,
269+
$error_message
270+
);
263271
switch ( $http_status_code ) {
264-
case 400:
265-
return new WP_Error(
266-
$error_code,
267-
$error_message,
268-
array_merge(
269-
$additional_data,
270-
[
271-
'status' => $http_status_code,
272-
]
273-
)
274-
);
275272
case 409:
276-
// If there was a conflict, return the cart so the client can resolve it.
277-
$controller = new CartController();
278-
$cart = $controller->get_cart_instance();
279-
280-
return new WP_Error(
281-
$error_code,
282-
$error_message,
283-
array_merge(
284-
$additional_data,
285-
[
286-
'status' => $http_status_code,
287-
'cart' => wc()->api->get_endpoint_data( '/wc/store/cart' ),
288-
]
289-
)
290-
);
273+
// 409 is when there was a conflict, so we return the cart so the client can resolve it.
274+
return $this->add_data_to_error_object( $error_from_message, $additional_data, $http_status_code, true );
275+
}
276+
return $this->add_data_to_error_object( $error_from_message, $additional_data, $http_status_code );
277+
}
278+
279+
/**
280+
* Get route response when something went wrong.
281+
*
282+
* @param WP_Error $error_object User facing error message.
283+
* @param int $http_status_code HTTP status. Defaults to 500.
284+
* @param array $additional_data Extra data (key value pairs) to expose in the error response.
285+
* @return WP_Error WP Error object.
286+
*/
287+
protected function get_route_error_response_from_object( $error_object, $http_status_code = 500, $additional_data = [] ) {
288+
switch ( $http_status_code ) {
289+
case 409:
290+
// 409 is when there was a conflict, so we return the cart so the client can resolve it.
291+
return $this->add_data_to_error_object( $error_object, $additional_data, $http_status_code, true );
292+
}
293+
return $this->add_data_to_error_object( $error_object, $additional_data, $http_status_code );
294+
}
295+
296+
/**
297+
* Adds additional data to the WP_Error object.
298+
*
299+
* @param WP_Error $error The error object to add the cart to.
300+
* @param array $data The data to add to the error object.
301+
* @param int $http_status_code The HTTP status code this error should return.
302+
* @param bool $include_cart Whether the cart should be included in the error data.
303+
* @returns WP_Error The WP_Error with the cart added.
304+
*/
305+
private function add_data_to_error_object( $error, $data, $http_status_code, bool $include_cart = false ) {
306+
$data = array_merge( $data, [ 'status' => $http_status_code ] );
307+
if ( $include_cart ) {
308+
$data = array_merge( $data, [ 'cart' => wc()->api->get_endpoint_data( '/wc/store/cart' ) ] );
291309
}
292-
return new WP_Error( $error_code, $error_message, [ 'status' => $http_status_code ] );
310+
$error->add_data( $data );
311+
return $error;
293312
}
294313

295314
/**

src/StoreApi/Utilities/CartController.php

Lines changed: 140 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33

44
use Automattic\WooCommerce\Blocks\StoreApi\Routes\RouteException;
55
use Automattic\WooCommerce\Blocks\StoreApi\Utilities\NoticeHandler;
6+
use Automattic\WooCommerce\Blocks\Utils\ArrayUtils;
67
use Automattic\WooCommerce\Checkout\Helpers\ReserveStock;
8+
use WP_Error;
79

810
/**
911
* Woo Cart Controller class.
@@ -213,17 +215,130 @@ public function validate_add_to_cart( \WC_Product $product, $request ) {
213215
do_action( 'wooocommerce_store_api_validate_add_to_cart', $product, $request );
214216
}
215217

218+
/**
219+
* Generates the error message for out of stock products and adds product names to it.
220+
*
221+
* @param string $singular The message to use when only one product is in the list.
222+
* @param string $plural The message to use when more than one product is in the list.
223+
* @param array $items The list of cart items whose names should be inserted into the message.
224+
* @returns string The translated and correctly pluralised message.
225+
*/
226+
private function add_product_names_to_message( $singular, $plural, $items ) {
227+
$product_names = wc_list_pluck( $items, 'getProductName' );
228+
$message = ( count( $items ) > 1 ) ? $plural : $singular;
229+
return sprintf(
230+
$message,
231+
ArrayUtils::natural_language_join( $product_names, true )
232+
);
233+
}
234+
216235
/**
217236
* Validate all items in the cart and check for errors.
218237
*
219-
* @throws RouteException Exception if invalid data is detected.
238+
* @throws InvalidStockLevelsInCartException Exception if invalid data is detected due to insufficient stock levels.
220239
*/
221240
public function validate_cart_items() {
222241
$cart = $this->get_cart_instance();
223242
$cart_items = $this->get_cart_items();
224243

244+
$out_of_stock_products = [];
245+
$too_many_in_cart_products = [];
246+
$partial_out_of_stock_products = [];
247+
$not_purchasable_products = [];
248+
225249
foreach ( $cart_items as $cart_item_key => $cart_item ) {
226-
$this->validate_cart_item( $cart_item );
250+
try {
251+
$this->validate_cart_item( $cart_item );
252+
} catch ( TooManyInCartException $error ) {
253+
$too_many_in_cart_products[] = $error;
254+
} catch ( NotPurchasableException $error ) {
255+
$not_purchasable_products[] = $error;
256+
} catch ( PartialOutOfStockException $error ) {
257+
$partial_out_of_stock_products[] = $error;
258+
} catch ( OutOfStockException $error ) {
259+
$out_of_stock_products[] = $error;
260+
}
261+
}
262+
263+
$error = new WP_Error();
264+
265+
if ( count( $out_of_stock_products ) > 0 ) {
266+
// translators: %s: product names.
267+
$singular_error = __(
268+
'%s is out of stock and cannot be purchased. It has been removed from your cart.',
269+
'woo-gutenberg-products-block'
270+
);
271+
// translators: %s: product names.
272+
$plural_error = __(
273+
'%s are out of stock and cannot be purchased. They have been removed from your cart.',
274+
'woo-gutenberg-products-block'
275+
);
276+
277+
$error->add(
278+
409,
279+
$this->add_product_names_to_message( $singular_error, $plural_error, $out_of_stock_products )
280+
);
281+
}
282+
283+
if ( count( $not_purchasable_products ) > 0 ) {
284+
// translators: %s: product names.
285+
$singular_error = __(
286+
'%s cannot be purchased. It has been removed from your cart.',
287+
'woo-gutenberg-products-block'
288+
);
289+
// translators: %s: product names.
290+
$plural_error = __(
291+
'%s cannot be purchased. They have been removed from your cart.',
292+
'woo-gutenberg-products-block'
293+
);
294+
295+
$error->add(
296+
409,
297+
$this->add_product_names_to_message( $singular_error, $plural_error, $not_purchasable_products )
298+
);
299+
}
300+
301+
if ( count( $too_many_in_cart_products ) > 0 ) {
302+
// translators: %s: product names.
303+
$singular_error = __(
304+
'There are too many %s in the cart. Only 1 can be purchased. The quantity in your cart has been reduced.',
305+
'woo-gutenberg-products-block'
306+
);
307+
// translators: %s: product names.
308+
$plural_error = __(
309+
'There are too many %s in the cart. Only 1 of each can be purchased. The quantities in your cart have been reduced.',
310+
'woo-gutenberg-products-block'
311+
);
312+
313+
$error->add(
314+
409,
315+
$this->add_product_names_to_message( $singular_error, $plural_error, $too_many_in_cart_products )
316+
);
317+
}
318+
319+
if ( count( $partial_out_of_stock_products ) > 0 ) {
320+
// translators: %s: product names.
321+
$singular_error = __(
322+
'There is not enough %s in stock. The quantity in your cart has been reduced.',
323+
'woo-gutenberg-products-block'
324+
);
325+
// translators: %s: product names.
326+
$plural_error = __(
327+
'There are not enough %s in stock. The quantities in your cart have been reduced.',
328+
'woo-gutenberg-products-block'
329+
);
330+
331+
$error->add(
332+
409,
333+
$this->add_product_names_to_message( $singular_error, $plural_error, $partial_out_of_stock_products )
334+
);
335+
}
336+
337+
if ( $error->has_errors() ) {
338+
throw new InvalidStockLevelsInCartException(
339+
'woocommerce_stock_availability_error',
340+
$error
341+
);
227342
}
228343

229344
// Before running the woocommerce_check_cart_items hook, unhook validation from the core cart.
@@ -244,8 +359,11 @@ public function validate_cart_items() {
244359
/**
245360
* Validates an existing cart item and returns any errors.
246361
*
247-
* @throws RouteException Exception if invalid data is detected.
248-
*
362+
* @throws TooManyInCartException Exception if more than one product that can only be purchased individually is in
363+
* the cart.
364+
* @throws PartialOutOfStockException Exception if an item has a quantity greater than what is available in stock.
365+
* @throws OutOfStockException Exception thrown when an item is entirely out of stock.
366+
* @throws NotPurchasableException Exception thrown when an item is not purchasable.
249367
* @param array $cart_item Cart item array.
250368
*/
251369
public function validate_cart_item( $cart_item ) {
@@ -256,30 +374,25 @@ public function validate_cart_item( $cart_item ) {
256374
}
257375

258376
if ( ! $product->is_purchasable() ) {
259-
$this->throw_default_product_exception( $product );
377+
throw new NotPurchasableException(
378+
'woocommerce_rest_cart_product_not_purchasable',
379+
$product->get_name()
380+
);
260381
}
261382

262383
if ( $product->is_sold_individually() && $cart_item['quantity'] > 1 ) {
263-
throw new RouteException(
384+
WC()->cart->set_quantity( $cart_item['key'], 1, false );
385+
throw new TooManyInCartException(
264386
'woocommerce_rest_cart_product_sold_individually',
265-
sprintf(
266-
/* translators: %s: product name */
267-
__( 'There are too many &quot;%s&quot; in the cart. Only 1 can be purchased.', 'woo-gutenberg-products-block' ),
268-
$product->get_name()
269-
),
270-
400
387+
$product->get_name()
271388
);
272389
}
273390

274391
if ( ! $product->is_in_stock() ) {
275-
throw new RouteException(
392+
WC()->cart->remove_cart_item( $cart_item['key'] );
393+
throw new OutOfStockException(
276394
'woocommerce_rest_cart_product_no_stock',
277-
sprintf(
278-
/* translators: %s: product name */
279-
__( '&quot;%s&quot; is out of stock and cannot be purchased.', 'woo-gutenberg-products-block' ),
280-
$product->get_name()
281-
),
282-
400
395+
$product->get_name()
283396
);
284397
}
285398

@@ -288,20 +401,11 @@ public function validate_cart_item( $cart_item ) {
288401
$qty_in_cart = $this->get_product_quantity_in_cart( $product );
289402

290403
if ( $qty_remaining < $qty_in_cart ) {
291-
throw new RouteException(
292-
'woocommerce_rest_cart_product_no_stock',
293-
sprintf(
294-
/* translators: 1: quantity in stock, 2: product name */
295-
_n(
296-
'There is only %1$s unit of &quot;%2$s&quot; in stock.',
297-
'There are only %1$s units of &quot;%2$s&quot; in stock.',
298-
$qty_remaining,
299-
'woo-gutenberg-products-block'
300-
),
301-
wc_format_stock_quantity_for_display( $qty_remaining, $product ),
302-
$product->get_name()
303-
),
304-
400
404+
405+
WC()->cart->set_quantity( $cart_item['key'], $qty_remaining, false );
406+
throw new PartialOutOfStockException(
407+
'woocommerce_rest_cart_product_partially_no_stock',
408+
$product->get_name()
305409
);
306410
}
307411
}
@@ -343,7 +447,9 @@ public function get_cart_item_errors() {
343447
try {
344448
$this->validate_cart_item( $cart_item );
345449
} catch ( RouteException $error ) {
346-
$errors[] = new \WP_Error( $error->getErrorCode(), $error->getMessage() );
450+
$errors[] = new WP_Error( $error->getErrorCode(), $error->getMessage() );
451+
} catch ( StockAvailabilityException $error ) {
452+
$errors[] = new WP_Error( $error->getErrorCode(), $error->getMessage() );
347453
}
348454
}
349455

0 commit comments

Comments
 (0)