Skip to content

Updated coupon table & prevented race condition issue - #3106

Open
shewa12 wants to merge 4 commits into
4.2.0from
coupon-usage-refactor
Open

shewa12 wants to merge 4 commits into
4.2.0from
coupon-usage-refactor

Conversation

@shewa12

@shewa12 shewa12 commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator

Issues That Have Been Fixed

  1. The "pay incomplete order" action revalidates nothing about the coupon. It checks login, nonce and order ownership, then sends the order's stored discounted total to the gateway, so an order staged while a limited coupon was still valid can be paid after the coupon is exhausted, deactivated or expired.

  2. Coupon usage is recorded only after an order is already completed and paid, with no reservation taken at order creation.

  3. The tutor_coupon_usages table has no order ID, no unique key and no reservation state, so nothing at the database layer can stop the configured total or per-user ceiling from being exceeded.

  4. Usage limits are enforced by a plain count-then-compare over already-recorded rows, so any number of pending orders can pass the same check concurrently.

  5. Zero-cost checkout enrolls the student before inserting the usage row, with no lock between the two (not in the original report).

@shewa12
shewa12 requested a review from harunollyo October 19, 2026 09:19
Comment thread classes/Upgrader.php Outdated
* @return void
*/
public function upgrade_to_4_2_0() {
$is_coupon_table_updated = update_option( 'tutor_coupon_table_updated', false );

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of keeping track into option table. We can add check

if ( version_compare( TUTOR_VERSION, '4.2.0', '>=' )  &&  ! QueryHelper::column_exist( 'tutor_coupon_usages', 'status' ) ) {
  // Alter table
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Storing a boolean in an option is not ideal, as we would need to introduce another option key every time we update the table schema.

}

if ( ! empty( $order_data ) ) {
$placed_order_id = is_array( $order_data ) && isset( $order_data['id'] ) ? (int) ( $order_data['id'] ?? 0 ) : 0;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can simplify

$placed_order_id = (int) ( is_array( $order_data ) ? ( $order_data['id'] ?? 0 ) : 0 );

Comment thread models/CouponModel.php Outdated
// Lock the coupon row for update to prevent concurrent race conditions.
$coupon = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$this->coupons_table} WHERE coupon_code = %s FOR UPDATE",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This model does not have a coupons_table property.

Comment thread classes/Upgrader.php
$table_name = "{$wpdb->prefix}tutor_coupon_usages";
if ( QueryHelper::table_exists( $table_name ) ) {
$charset_collate = $wpdb->get_charset_collate();
$coupon_usage_table = "CREATE TABLE {$table_name} (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of maintaining the same schema in two places, we can use a single source of truth:

tutor_lms()->get_coupon_usage_table_schema();

Comment thread ecommerce/CheckoutController.php Outdated
$placed_order_id = is_array( $order_data ) && isset( $order_data['id'] ) ? (int) ( $order_data['id'] ?? 0 ) : 0;

if ( ! empty( $checkout_data->coupon_code ) && $placed_order_id ) {
$reserved = $this->coupon_model->reserve_coupon_usage( $placed_order_id, $checkout_data->coupon_code, (int) $current_user_id, 30 );

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can use $checkout_data->coupon_code for new order, for existing incomplete order, we have to use coupon_code from order_data

Comment thread ecommerce/CheckoutController.php Outdated
if ( ! empty( $order_data ) ) {
$placed_order_id = is_array( $order_data ) && isset( $order_data['id'] ) ? (int) ( $order_data['id'] ?? 0 ) : 0;

if ( ! empty( $checkout_data->coupon_code ) && $placed_order_id ) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need to reserve system resources for regular coupons that have no usage limits.

Comment thread models/CouponModel.php
->first();

if ( $existing ) {
$update_status = self::STATUS_CONSUMED === $existing->status ? self::STATUS_CONSUMED : self::STATUS_RESERVED;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Problem: When an existing reservation record has status consumed, reserve_coupon_usage() preserves status as consumed but still updates expires_at_gmt to +30 minutes. Furthermore, for free checkouts (100% discount), HooksHandler::handle_free_checkout() consumes the coupon during create_order(), after which CheckoutController::pay_now() immediately executes reserve_coupon_usage() a second time on the same order, attaching an expiration to an already completed transaction.

  • Fix:
    if ( $existing ) {
        if ( self::STATUS_CONSUMED === $existing->status ) {
            $wpdb->query( 'COMMIT' );
            return true;
        }
    
        DB::table( $this->coupon_usage_table )
            ->where( 'id', $existing->id )
            ->update(
                array(
                    'status'         => self::STATUS_RESERVED,
                    'expires_at_gmt' => $expires_gmt,
                )
            );
    }

Comment thread models/CouponModel.php
$total_usage_limit = (int) $coupon->total_usage_limit;
$user_usage_limit = (int) $coupon->per_user_usage_limit;

if ( $total_usage_limit > 0 ) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Problem: In has_user_usage_limit(), when $user_id is 0 (guest user validating or applying a coupon before account creation), $this->get_user_usage_count( $coupon->coupon_code, 0, $exclude_order_id ) queries WHERE user_id = 0. Any existing usage recorded with user_id = 0 will accumulate and prematurely block all future guest checkouts once the per-user limit is hit.
  • Fix:
    // In CouponModel::has_user_usage_limit (line 1080):
    if ( $user_usage_limit > 0 && $user_id > 0 ) {
        $user_usage_count = $this->get_user_usage_count( $coupon->coupon_code, $user_id, $exclude_order_id );
        if ( $user_usage_count >= $user_usage_limit ) {
            $has_limit = false;
            self::set_apply_coupon_error( $this->get_coupon_failed_error_msg( 'user_usage_limit_exceeded' ) );
        }
    }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants