Repository navigation
Conversation
| * @return void | ||
| */ | ||
| public function upgrade_to_4_2_0() { | ||
| $is_coupon_table_updated = update_option( 'tutor_coupon_table_updated', false ); |
There was a problem hiding this comment.
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
}There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
We can simplify
$placed_order_id = (int) ( is_array( $order_data ) ? ( $order_data['id'] ?? 0 ) : 0 );| // 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", |
There was a problem hiding this comment.
This model does not have a coupons_table property.
| $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} ( |
There was a problem hiding this comment.
Instead of maintaining the same schema in two places, we can use a single source of truth:
tutor_lms()->get_coupon_usage_table_schema();| $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 ); |
There was a problem hiding this comment.
We can use $checkout_data->coupon_code for new order, for existing incomplete order, we have to use coupon_code from order_data
| 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 ) { |
There was a problem hiding this comment.
We don't need to reserve system resources for regular coupons that have no usage limits.
| ->first(); | ||
|
|
||
| if ( $existing ) { | ||
| $update_status = self::STATUS_CONSUMED === $existing->status ? self::STATUS_CONSUMED : self::STATUS_RESERVED; |
There was a problem hiding this comment.
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, ) ); }
| $total_usage_limit = (int) $coupon->total_usage_limit; | ||
| $user_usage_limit = (int) $coupon->per_user_usage_limit; | ||
|
|
||
| if ( $total_usage_limit > 0 ) { |
There was a problem hiding this comment.
- Problem: In
has_user_usage_limit(), when$user_idis 0 (guest user validating or applying a coupon before account creation),$this->get_user_usage_count( $coupon->coupon_code, 0, $exclude_order_id )queriesWHERE user_id = 0. Any existing usage recorded withuser_id = 0will 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' ) ); } }
Issues That Have Been Fixed
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.
Coupon usage is recorded only after an order is already completed and paid, with no reservation taken at order creation.
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.
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.
Zero-cost checkout enrolls the student before inserting the usage row, with no lock between the two (not in the original report).