Skip to content

Commit

Permalink
Validate and present custom error for not in allowed emails coupons (#…
Browse files Browse the repository at this point in the history
…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 43f185b.

* 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>
  • Loading branch information
wavvves and alexflorisca committed Mar 20, 2024
1 parent 211d39d commit 0a3cf74
Show file tree
Hide file tree
Showing 10 changed files with 205 additions and 74 deletions.
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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 )
Expand Down Expand Up @@ -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,
},
} );
Expand Down
@@ -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.
5 changes: 3 additions & 2 deletions plugins/woocommerce/client/legacy/js/frontend/checkout.js
Expand Up @@ -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({
Expand Down
8 changes: 7 additions & 1 deletion plugins/woocommerce/includes/class-wc-ajax.php
Expand Up @@ -245,7 +245,13 @@ public static function apply_coupon() {

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 {
Expand Down
56 changes: 46 additions & 10 deletions plugins/woocommerce/includes/class-wc-coupon.php
Expand Up @@ -944,20 +944,27 @@ public function is_valid_for_product( $product, $values = array() ) {
* 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 );
}

/**
Expand Down Expand Up @@ -1001,8 +1008,15 @@ public function get_coupon_error( $err_code ) {
$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' );
Expand Down Expand Up @@ -1152,4 +1166,26 @@ public function set_short_info( string $info ) {
$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();
}
}
}
69 changes: 62 additions & 7 deletions plugins/woocommerce/includes/class-wc-discounts.php
Expand Up @@ -939,6 +939,50 @@ protected function validate_coupon_excluded_product_categories( $coupon ) {
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
*
Expand Down Expand Up @@ -981,10 +1025,11 @@ protected function get_object_subtotal() {
* - 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 {
Expand All @@ -998,9 +1043,10 @@ public function is_coupon_valid( $coupon ) {
$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 ) {
/**
Expand All @@ -1012,14 +1058,23 @@ public function is_coupon_valid( $coupon ) {
*/
$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;
}
}
25 changes: 8 additions & 17 deletions plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php
Expand Up @@ -332,24 +332,15 @@ protected function check_nonce( \WP_REST_Request $request ) {
* @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 );
}
}
Expand Up @@ -58,7 +58,6 @@ protected function get_route_post_response( \WP_REST_Request $request ) {
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 {
Expand All @@ -67,6 +66,7 @@ protected function get_route_post_response( \WP_REST_Request $request ) {
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 ) );
}
}
14 changes: 9 additions & 5 deletions plugins/woocommerce/src/StoreApi/Utilities/CartController.php
Expand Up @@ -928,11 +928,15 @@ public function apply_coupon( $coupon_code ) {
);
}

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
);
}

Expand Down Expand Up @@ -1013,9 +1017,9 @@ function( $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.
*
* @throws RouteException Exception if invalid data is detected.
*/
protected function validate_cart_coupon( \WC_Coupon $coupon ) {
if ( ! $coupon->is_valid() ) {
Expand Down

0 comments on commit 0a3cf74

Please sign in to comment.