Validate and present custom error for not in allowed emails coupons (#43872)

* Removed deprecated WC_COUPON::is_valid() method usage from CartController.php

* Reverted wrongly changed line.

* Added validate_coupon_allowed_emails() to WC_Discounts

* Added soft validation for allowed emails coupons, with custom notice via WC_Coupon::add_coupon_message()

* Fixed log warning

* Refactored add_coupon_message()

* Prevent duplicate coupon notices.

* Changed coupon soft validation notice type.

* Tweaks

* Run coupon soft validations only on cart validation.

* Reverted soft validation, and added email information for coupon validation

* Removed unused coupon message

* PHP lint fixes.

* Added changelog.

* PHP lint fix

* Updated allowed coupon validation error message

* Updated PW tests

* Updated PW tests

* Updated email restricted coupon message.

* Small change for readability.

* Different error messages for shortcode cart and shortcode checkout

* Simplified CartApplyCoupon::get_post_route_response()

* Revert "Simplified CartApplyCoupon::get_post_route_response()"

This reverts commit 43f185b59a.

* Expose additional error data in error API response

* Simplified AbstractCartRoute::get_route_error_response()

* Linting

* Restored comment deleted by mistake.

* Introduced API context based coupon errors

* Fixed Doc Block

* Linting

* Reverted deprecated method removal

* Reverted deprecated method removal

* WIP

* Display context based errors on cart and checkout for allowed emails coupons.

* Small code fixes.

* Removed coupon_error_code from api response.

* Tweaks and used 'details' on the API response

* Fixed indent.

* Set coupon errors using the validation store rather than local state

* Revert import to original state.

* Updated tests.

* Updated tests.

* Simplified comments

* Added testing for Cart page

* Lint fixes

---------

Co-authored-by: Alex Florisca <alex.florisca@automattic.com>
This commit is contained in:
Paulo Arromba 2024-03-20 10:19:06 +00:00 committed by GitHub
parent 211d39d453
commit 0a3cf74c06
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 205 additions and 74 deletions

View File

@ -3,9 +3,13 @@
*/
import { __, sprintf } from '@wordpress/i18n';
import { useDispatch, useSelect } from '@wordpress/data';
import { CART_STORE_KEY, VALIDATION_STORE_KEY } from '@woocommerce/block-data';
import {
CART_STORE_KEY,
VALIDATION_STORE_KEY,
CHECKOUT_STORE_KEY,
} from '@woocommerce/block-data';
import { decodeEntities } from '@wordpress/html-entities';
import type { StoreCartCoupon } from '@woocommerce/types';
import type { StoreCartCoupon, ApiErrorResponse } from '@woocommerce/types';
import { applyCheckoutFilter } from '@woocommerce/blocks-checkout';
/**
@ -41,6 +45,19 @@ export const useStoreCartCoupons = ( context = '' ): StoreCartCoupon => {
);
const { applyCoupon, removeCoupon } = useDispatch( CART_STORE_KEY );
const orderId = useSelect( ( select ) =>
select( CHECKOUT_STORE_KEY ).getOrderId()
);
// Return cart, checkout or generic error message.
const getCouponErrorMessage = ( error: ApiErrorResponse ) => {
if ( orderId && orderId > 0 && error?.data?.details?.checkout ) {
return error.data.details.checkout;
} else if ( error?.data?.details?.cart ) {
return error.data.details.cart;
}
return error.message;
};
const applyCouponWithNotices = ( couponCode: string ) => {
return applyCoupon( couponCode )
@ -72,9 +89,10 @@ export const useStoreCartCoupons = ( context = '' ): StoreCartCoupon => {
return Promise.resolve( true );
} )
.catch( ( error ) => {
const errorMessage = getCouponErrorMessage( error );
setValidationErrors( {
coupon: {
message: decodeEntities( error.message ),
message: decodeEntities( errorMessage ), // TODO fix the circular loop with ApiErrorResponseData and ApiErrorResponseDataDetails
hidden: false,
},
} );

View File

@ -0,0 +1,4 @@
Significance: minor
Type: enhancement
Validate coupons with email restrictions upfront and change user's feedback when a coupon is not valid for the user.

View File

@ -617,8 +617,9 @@ jQuery( function( $ ) {
});
var data = {
security: wc_checkout_params.apply_coupon_nonce,
coupon_code: $form.find( 'input[name="coupon_code"]' ).val()
security: wc_checkout_params.apply_coupon_nonce,
coupon_code: $form.find('input[name="coupon_code"]').val(),
billing_email: wc_checkout_form.$checkout_form.find('input[name="billing_email"]').val()
};
$.ajax({

View File

@ -245,7 +245,13 @@ class WC_AJAX {
check_ajax_referer( 'apply-coupon', 'security' );
$coupon_code = ArrayUtil::get_value_or_default( $_POST, 'coupon_code' );
$coupon_code = ArrayUtil::get_value_or_default( $_POST, 'coupon_code' );
$billing_email = ArrayUtil::get_value_or_default( $_POST, 'billing_email' );
if ( is_email( $billing_email ) ) {
wc()->customer->set_billing_email( $billing_email );
}
if ( ! StringUtil::is_null_or_whitespace( $coupon_code ) ) {
WC()->cart->add_discount( wc_format_coupon_code( wp_unslash( $coupon_code ) ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
} else {

View File

@ -944,20 +944,27 @@ class WC_Coupon extends WC_Legacy_Coupon {
* Converts one of the WC_Coupon message/error codes to a message string and.
* displays the message/error.
*
* @param int $msg_code Message/error code.
* @param int $msg_code Message/error code.
* @param string $notice_type Notice type.
*/
public function add_coupon_message( $msg_code ) {
$msg = $msg_code < 200 ? $this->get_coupon_error( $msg_code ) : $this->get_coupon_message( $msg_code );
public function add_coupon_message( $msg_code, $notice_type = 'success' ) {
if ( $msg_code < 200 ) {
$msg = $this->get_coupon_error( $msg_code );
$notice_type = 'error';
} else {
$msg = $this->get_coupon_message( $msg_code );
}
if ( ! $msg ) {
if ( empty( $msg ) ) {
return;
}
if ( $msg_code < 200 ) {
wc_add_notice( $msg, 'error' );
} else {
wc_add_notice( $msg );
// Since coupon validation is done multiple times (e.g. to ensure a valid cart), we need to check for dupes.
if ( wc_has_notice( $msg, $notice_type ) ) {
return;
}
wc_add_notice( $msg, $notice_type );
}
/**
@ -1001,8 +1008,15 @@ class WC_Coupon extends WC_Legacy_Coupon {
$err = sprintf( __( 'Sorry, it seems the coupon "%s" is invalid - it has now been removed from your order.', 'woocommerce' ), esc_html( $this->get_code() ) );
break;
case self::E_WC_COUPON_NOT_YOURS_REMOVED:
/* translators: %s: coupon code */
$err = sprintf( __( 'Sorry, it seems the coupon "%s" is not yours - it has now been removed from your order.', 'woocommerce' ), esc_html( $this->get_code() ) );
// We check for supplied billing email. On shortcode, this will be present for checkout requests.
$billing_email = \Automattic\WooCommerce\Utilities\ArrayUtil::get_value_or_default( $_POST, 'billing_email' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
if ( ! is_null( $billing_email ) ) {
/* translators: %s: coupon code */
$err = sprintf( __( 'Please enter a valid email to use coupon code "%s".', 'woocommerce' ), esc_html( $this->get_code() ) );
} else {
/* translators: %s: coupon code */
$err = sprintf( __( 'Please enter a valid email at checkout to use coupon code "%s".', 'woocommerce' ), esc_html( $this->get_code() ) );
}
break;
case self::E_WC_COUPON_ALREADY_APPLIED:
$err = __( 'Coupon code already applied!', 'woocommerce' );
@ -1152,4 +1166,26 @@ class WC_Coupon extends WC_Legacy_Coupon {
$this->set_amount( $info[3] ?? 0 );
$this->set_free_shipping( $info[4] ?? false );
}
/**
* Returns alternate error messages based on context (eg. Cart and Checkout).
*
* @param int $err_code Message/error code.
*
* @return array Context based alternate error messages.
*/
public function get_context_based_coupon_errors( $err_code = null ) {
switch ( $err_code ) {
case self::E_WC_COUPON_NOT_YOURS_REMOVED:
return array(
/* translators: %s: coupon code */
'cart' => sprintf( __( 'Please enter a valid email at checkout to use coupon code "%s".', 'woocommerce' ), esc_html( $this->get_code() ) ),
/* translators: %s: coupon code */
'checkout' => sprintf( __( 'Please enter a valid email to use coupon code "%s".', 'woocommerce' ), esc_html( $this->get_code() ) ),
);
default:
return array();
}
}
}

View File

@ -939,6 +939,50 @@ class WC_Discounts {
return true;
}
/**
* Ensure coupon is valid for allowed emails or throw exception.
*
* @since 8.6.0
* @throws Exception Error message.
* @param WC_Coupon $coupon Coupon data.
* @return bool
*/
protected function validate_coupon_allowed_emails( $coupon ) {
$restrictions = $coupon->get_email_restrictions();
if ( ! is_array( $restrictions ) || empty( $restrictions ) ) {
return true;
}
$user = wp_get_current_user();
$check_emails = array( $user->get_billing_email(), $user->get_email() );
if ( $this->object instanceof WC_Cart ) {
$check_emails[] = $this->object->get_customer()->get_billing_email();
} elseif ( $this->object instanceof WC_Order ) {
$check_emails[] = $this->object->get_billing_email();
}
$check_emails = array_unique(
array_filter(
array_map(
'strtolower',
array_map(
'sanitize_email',
$check_emails
)
)
)
);
if ( ! WC()->cart->is_coupon_emails_allowed( $check_emails, $restrictions ) ) {
throw new Exception( $coupon->get_coupon_error( WC_Coupon::E_WC_COUPON_NOT_YOURS_REMOVED ), WC_Coupon::E_WC_COUPON_NOT_YOURS_REMOVED ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
return true;
}
/**
* Get the object subtotal
*
@ -981,10 +1025,11 @@ class WC_Discounts {
* - 113: Excluded products.
* - 114: Excluded categories.
*
* @since 3.2.0
* @throws Exception Error message.
* @param WC_Coupon $coupon Coupon data.
* @param WC_Coupon $coupon Coupon data.
*
* @return bool|WP_Error
* @throws Exception Error message.
* @since 3.2.0
*/
public function is_coupon_valid( $coupon ) {
try {
@ -998,9 +1043,10 @@ class WC_Discounts {
$this->validate_coupon_product_categories( $coupon );
$this->validate_coupon_excluded_items( $coupon );
$this->validate_coupon_eligible_items( $coupon );
$this->validate_coupon_allowed_emails( $coupon );
if ( ! apply_filters( 'woocommerce_coupon_is_valid', true, $coupon, $this ) ) {
throw new Exception( __( 'Coupon is not valid.', 'woocommerce' ), 100 );
throw new Exception( __( 'Coupon is not valid.', 'woocommerce' ), WC_Coupon::E_WC_COUPON_INVALID_FILTERED );
}
} catch ( Exception $e ) {
/**
@ -1012,14 +1058,23 @@ class WC_Discounts {
*/
$message = apply_filters( 'woocommerce_coupon_error', is_numeric( $e->getMessage() ) ? $coupon->get_coupon_error( $e->getMessage() ) : $e->getMessage(), $e->getCode(), $coupon );
$additional_data = array(
'status' => 400,
);
$context_coupon_errors = $coupon->get_context_based_coupon_errors( $e->getCode() );
if ( ! empty( $context_coupon_errors ) ) {
$additional_data['details'] = $context_coupon_errors;
}
return new WP_Error(
'invalid_coupon',
$message,
array(
'status' => 400,
)
$additional_data,
);
}
return true;
}
}

View File

@ -332,24 +332,15 @@ abstract class AbstractCartRoute extends AbstractRoute {
* @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.
$cart = $this->cart_controller->get_cart_instance();
return new \WP_Error(
$error_code,
$error_message,
array_merge(
$additional_data,
[
'status' => $http_status_code,
'cart' => $this->cart_schema->get_item_response( $cart ),
]
)
);
$additional_data['status'] = $http_status_code;
// If there was a conflict, return the cart so the client can resolve it.
if ( 409 === $http_status_code ) {
$cart = $this->cart_controller->get_cart_instance();
$additional_data['cart'] = $this->cart_schema->get_item_response( $cart );
}
return new \WP_Error( $error_code, $error_message, [ 'status' => $http_status_code ] );
return new \WP_Error( $error_code, $error_message, $additional_data );
}
}

View File

@ -58,7 +58,6 @@ class CartApplyCoupon extends AbstractCartRoute {
throw new RouteException( 'woocommerce_rest_cart_coupon_disabled', __( 'Coupons are disabled.', 'woocommerce' ), 404 );
}
$cart = $this->cart_controller->get_cart_instance();
$coupon_code = wc_format_coupon_code( wp_unslash( $request['code'] ) );
try {
@ -67,6 +66,7 @@ class CartApplyCoupon extends AbstractCartRoute {
throw new RouteException( $e->getErrorCode(), $e->getMessage(), $e->getCode() );
}
$cart = $this->cart_controller->get_cart_instance();
return rest_ensure_response( $this->schema->get_item_response( $cart ) );
}
}

View File

@ -928,11 +928,15 @@ class CartController {
);
}
if ( ! $coupon->is_valid() ) {
$discounts = new \WC_Discounts( $this->get_cart_instance() );
$valid = $discounts->is_coupon_valid( $coupon );
if ( is_wp_error( $valid ) ) {
throw new RouteException(
'woocommerce_rest_cart_coupon_error',
wp_strip_all_tags( $coupon->get_error_message() ),
400
esc_html( wp_strip_all_tags( $valid->get_error_message() ) ),
400,
$valid->get_error_data() // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
);
}
@ -1013,9 +1017,9 @@ class CartController {
/**
* 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.
*
* @throws RouteException Exception if invalid data is detected.
*/
protected function validate_cart_coupon( \WC_Coupon $coupon ) {
if ( ! $coupon->is_valid() ) {

View File

@ -624,22 +624,30 @@ test.describe( 'Cart & Checkout Restricted Coupons', () => {
} );
} );
test( 'coupon cannot be used by any customer (email restricted)', async ( {
test( 'coupon cannot be used by any customer on cart (email restricted)', async ( {
page,
} ) => {
await page.goto( `/shop/?add-to-cart=${ firstProductId }` );
await page.waitForLoadState( 'networkidle' );
await page.goto( '/cart/' );
await page.getByPlaceholder( 'Coupon code' ).fill( 'email-restricted' );
await page.getByRole( 'button', { name: 'Apply coupon' } ).click();
await expect(
page.getByText(
'Please enter a valid email at checkout to use coupon code "email-restricted".'
)
).toBeVisible();
} );
test( 'coupon cannot be used by any customer on checkout (email restricted)', async ( {
page,
} ) => {
await page.goto( `/shop/?add-to-cart=${ firstProductId }` );
await page.waitForLoadState( 'networkidle' );
await page.goto( '/checkout/' );
await page
.getByRole( 'link', { name: 'Click here to enter your code' } )
.click();
await page.getByPlaceholder( 'Coupon code' ).fill( 'email-restricted' );
await page.getByRole( 'button', { name: 'Apply coupon' } ).click();
// succeeded so far because we don't know who the customr is
await expect(
page.getByText( 'Coupon code applied successfully.' )
).toBeVisible();
await page.getByLabel( 'First name' ).first().fill( 'Marge' );
await page.getByLabel( 'Last name' ).first().fill( 'Simpson' );
@ -654,11 +662,16 @@ test.describe( 'Cart & Checkout Restricted Coupons', () => {
.getByLabel( 'Email address' )
.first()
.fill( 'marge@example.com' );
await page.getByRole( 'button', { name: 'Place order' } ).click();
await page
.getByRole( 'link', { name: 'Click here to enter your code' } )
.click();
await page.getByPlaceholder( 'Coupon code' ).fill( 'email-restricted' );
await page.getByRole( 'button', { name: 'Apply coupon' } ).click();
await expect(
page.getByText(
'Sorry, it seems the coupon "email-restricted" is not yours - it has now been removed from your order.'
'Please enter a valid email to use coupon code "email-restricted".'
)
).toBeVisible();
} );
@ -678,14 +691,6 @@ test.describe( 'Cart & Checkout Restricted Coupons', () => {
await page.waitForLoadState( 'networkidle' );
await page.goto( '/checkout/' );
await page
.getByRole( 'link', { name: 'Click here to enter your code' } )
.click();
await page.getByPlaceholder( 'Coupon code' ).fill( 'email-restricted' );
await page.getByRole( 'button', { name: 'Apply coupon' } ).click();
await expect(
page.getByText( 'Coupon code applied successfully.' )
).toBeVisible();
await page.getByLabel( 'First name' ).first().fill( 'Homer' );
await page.getByLabel( 'Last name' ).first().fill( 'Simpson' );
@ -700,6 +705,16 @@ test.describe( 'Cart & Checkout Restricted Coupons', () => {
.getByLabel( 'Email address' )
.first()
.fill( 'homer@example.com' );
await page
.getByRole( 'link', { name: 'Click here to enter your code' } )
.click();
await page.getByPlaceholder( 'Coupon code' ).fill( 'email-restricted' );
await page.getByRole( 'button', { name: 'Apply coupon' } ).click();
await expect(
page.getByText( 'Coupon code applied successfully.' )
).toBeVisible();
await page.getByRole( 'button', { name: 'Place order' } ).click();
await expect(
@ -714,15 +729,6 @@ test.describe( 'Cart & Checkout Restricted Coupons', () => {
await page.waitForLoadState( 'networkidle' );
await page.goto( '/checkout/' );
await page
.getByRole( 'link', { name: 'Click here to enter your code' } )
.click();
await page.getByPlaceholder( 'Coupon code' ).fill( 'email-restricted' );
await page.getByRole( 'button', { name: 'Apply coupon' } ).click();
// succeeded so far because we don't know who the customr is
await expect(
page.getByText( 'Coupon code applied successfully.' )
).toBeVisible();
await page.getByLabel( 'First name' ).first().fill( 'Homer' );
await page.getByLabel( 'Last name' ).first().fill( 'Simpson' );
@ -737,6 +743,16 @@ test.describe( 'Cart & Checkout Restricted Coupons', () => {
.getByLabel( 'Email address' )
.first()
.fill( 'homer@example.com' );
await page
.getByRole( 'link', { name: 'Click here to enter your code' } )
.click();
await page.getByPlaceholder( 'Coupon code' ).fill( 'email-restricted' );
await page.getByRole( 'button', { name: 'Apply coupon' } ).click();
await expect(
page.getByText( 'Coupon code applied successfully.' )
).toBeVisible();
await page.getByRole( 'button', { name: 'Place order' } ).click();
await expect(