diff --git a/plugins/woocommerce-blocks/assets/js/base/context/cart-checkout/checkout/processor/index.js b/plugins/woocommerce-blocks/assets/js/base/context/cart-checkout/checkout/processor/index.js index 337c24021fa..69b8708e943 100644 --- a/plugins/woocommerce-blocks/assets/js/base/context/cart-checkout/checkout/processor/index.js +++ b/plugins/woocommerce-blocks/assets/js/base/context/cart-checkout/checkout/processor/index.js @@ -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 ); diff --git a/plugins/woocommerce-blocks/assets/js/base/hooks/cart/test/use-store-cart.js b/plugins/woocommerce-blocks/assets/js/base/hooks/cart/test/use-store-cart.js index 9ea6d3b31c9..09a29ab20e6 100644 --- a/plugins/woocommerce-blocks/assets/js/base/hooks/cart/test/use-store-cart.js +++ b/plugins/woocommerce-blocks/assets/js/base/hooks/cart/test/use-store-cart.js @@ -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
; + const { receiveCart, ...results } = useStoreCart( options ); + return
; }; 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 ); } ); } ); } ); diff --git a/plugins/woocommerce-blocks/assets/js/base/hooks/cart/use-store-cart.js b/plugins/woocommerce-blocks/assets/js/base/hooks/cart/use-store-cart.js index ba82afcca5e..07e59bdc05b 100644 --- a/plugins/woocommerce-blocks/assets/js/base/hooks/cart/use-store-cart.js +++ b/plugins/woocommerce-blocks/assets/js/base/hooks/cart/use-store-cart.js @@ -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 ] diff --git a/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/cart/full-cart/index.js b/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/cart/full-cart/index.js index 0c75122ff36..512c689d957 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/cart/full-cart/index.js +++ b/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/cart/full-cart/index.js @@ -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 ] ); diff --git a/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/checkout/checkout-order-error/constants.js b/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/checkout/checkout-order-error/constants.js index ed87f876931..e564d2bdb5f 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/checkout/checkout-order-error/constants.js +++ b/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/checkout/checkout-order-error/constants.js @@ -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'; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/checkout/checkout-order-error/index.js b/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/checkout/checkout-order-error/index.js index 11d577d3a25..d4d3e8f8975 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/checkout/checkout-order-error/index.js +++ b/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/checkout/checkout-order-error/index.js @@ -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; } diff --git a/plugins/woocommerce-blocks/assets/js/payment-method-extensions/payment-methods/stripe/credit-card/use-checkout-subscriptions.js b/plugins/woocommerce-blocks/assets/js/payment-method-extensions/payment-methods/stripe/credit-card/use-checkout-subscriptions.js index 5973c66c709..14d98f5d937 100644 --- a/plugins/woocommerce-blocks/assets/js/payment-method-extensions/payment-methods/stripe/credit-card/use-checkout-subscriptions.js +++ b/plugins/woocommerce-blocks/assets/js/payment-method-extensions/payment-methods/stripe/credit-card/use-checkout-subscriptions.js @@ -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 diff --git a/plugins/woocommerce-blocks/assets/js/type-defs/hooks.js b/plugins/woocommerce-blocks/assets/js/type-defs/hooks.js index 98259bc5b21..bf0ebaa1fcf 100644 --- a/plugins/woocommerce-blocks/assets/js/type-defs/hooks.js +++ b/plugins/woocommerce-blocks/assets/js/type-defs/hooks.js @@ -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. */ /** diff --git a/plugins/woocommerce-blocks/src/Library.php b/plugins/woocommerce-blocks/src/Library.php index d66e2268cd0..c153f1dbdcd 100644 --- a/plugins/woocommerce-blocks/src/Library.php +++ b/plugins/woocommerce-blocks/src/Library.php @@ -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'] ); - } - } } diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Routes/AbstractCartRoute.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Routes/AbstractCartRoute.php index 0cb659cf294..4a6f94056c1 100644 --- a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Routes/AbstractCartRoute.php +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Routes/AbstractCartRoute.php @@ -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 ] ); diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Routes/AbstractRoute.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Routes/AbstractRoute.php index 2b0da4a7064..624190d4913 100644 --- a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Routes/AbstractRoute.php +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Routes/AbstractRoute.php @@ -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 ] ) ); } /** diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Routes/Checkout.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Routes/Checkout.php index efbb53b0223..70d8d02ad62 100644 --- a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Routes/Checkout.php +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Routes/Checkout.php @@ -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; diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Routes/RouteException.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Routes/RouteException.php index 33eba7ce61b..fbc736c63f5 100644 --- a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Routes/RouteException.php +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Routes/RouteException.php @@ -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; + } } diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/CartSchema.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/CartSchema.php index 528db5bad12..313a09836f0 100644 --- a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/CartSchema.php +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/CartSchema.php @@ -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 ) ) ); } } diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/CartController.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/CartController.php index b7a401fc18b..06d36160653 100644 --- a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/CartController.php +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/CartController.php @@ -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 */ - __( '"%s" 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 "%s" 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 "%1$s" 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 "%s" 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 "%1$s" 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 */ - __( '"%s" 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 */ + __( '"%s" is not available for purchase.', 'woo-gutenberg-products-block' ), + $product->get_name() + ), + 403 + ); + } + /** * Filter data for add to cart requests. * diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/NoticeHandler.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/NoticeHandler.php new file mode 100644 index 00000000000..451d18140cc --- /dev/null +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/NoticeHandler.php @@ -0,0 +1,45 @@ +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 ); } }