D
P
0

WordPress & PHP

Abandoned Checkout Locks Booking Dates Forever? `booking_payment_failed` Fires With Zero Listeners

July 23, 2026·5 min read
Abandoned Checkout Locks Booking Dates Forever? `booking_payment_failed` Fires With Zero Listeners

Right before launch, QA on a client site found an instant blocker: booking integrity was leaking. A visitor starts a booking, picks their dates, then gets redirected to the payment gateway. Some never finish paying: they close the tab, the session expires, or the card gets declined. What should happen is the dates go back on the market. What actually happened is the dates got locked. Forever.

I tried to re-book the same dates and got "not available". I waited an hour, then a day, still "not available". Nothing ever released them. For a site that lives on date availability, this is not cosmetic. Every abandoned checkout, and in practice that is a high percentage, quietly deleted one slot from inventory for good.

The dead ends I chased first

My first instinct blamed the availability query for being too aggressive. Maybe a slot cache had gone stale, or the date-overlap math was miscounting. I opened is_available() and walked it line by line. The query was correct. Overlapping bookings genuinely should block. That was not it.

Second guess: maybe the visitor could come back and pay, and the status would heal itself. But no retry path changed anything, and a locked date stayed locked even when the booking had clearly failed. So I moved to the payment side: what actually happens when a payment fails?

Tracing the root cause

The gateway reports status through a webhook, and the handler has a branch for terminal states. Roughly this:

case 'canceled':
case 'expired':
case 'failed':
    update_post_meta( $booking_id, '_payment_status', 'unpaid' );
    do_action( 'booking_payment_failed', $booking_id );
    break;

It looks healthy. On cancel or failure the payment status is set to unpaid and a booking_payment_failed hook fires, as if something downstream would handle it. So I went looking for whoever listened to that hook:

grep -rn "booking_payment_failed" wp-content/themes/
# one line only: the do_action(...) in the webhook. Zero add_action.

One line. That was the do_action firing site itself, and not a single add_action. The hook fires. Nobody listens. The signal "this payment failed, please release its dates" was shouted into an empty room.

Worse, that branch only touched _payment_status. The booking status, _booking_status, stayed pending. That is what tied the whole thing together, because is_available() blocks a slot on booking status, not payment status:

public static function is_available( $unit_id, $start, $end ) {
    $overlapping = self::query_bookings(
        $unit_id, $start, $end,
        array( 'confirmed', 'pending' )
    );
    return empty( $overlapping );
}

An abandoned booking was pending forever, and is_available() treated it as holding the slot forever. No cleanup cron ever swept the stale holds either. Three things met to form a permanent leak: a hook with no listener, a status that never moved off pending, and zero cleanup.

The fix

I fixed it from two directions. First, make the webhook finish its own job for the truly terminal states instead of delegating to a hook nobody hears:

case 'canceled':
case 'expired':
    update_post_meta( $booking_id, '_payment_status', 'unpaid' );
    update_post_meta( $booking_id, '_booking_status', 'cancelled' ); // free the dates now
    break;
 
case 'failed':
    update_post_meta( $booking_id, '_payment_status', 'unpaid' );    // stay pending, retry still possible
    break;

Canceled and expired are final, so the booking flips straight to cancelled and the dates free immediately. Failed is treated differently on purpose: it stays pending so the visitor still has a window to retry.

Second, and this is what closes the real leak, I built a self-driving stale-hold sweeper on an hourly cron:

public static function release_expired_holds() {
    $now     = current_time( 'timestamp' );
    $windows = array(
        'requested' => 48 * HOUR_IN_SECONDS,
        'approved'  => 24 * HOUR_IN_SECONDS,
        'pending'   => 1  * HOUR_IN_SECONDS,
    );
 
    foreach ( self::query_unpaid_holds() as $booking_id ) {
        $status = get_post_meta( $booking_id, '_booking_status', true );
        if ( ! isset( $windows[ $status ] ) ) {
            continue;
        }
        $anchor = ( 'approved' === $status )
            ? (int) get_post_meta( $booking_id, '_approved_at', true )
            : (int) get_post_meta( $booking_id, '_created_at', true );
 
        if ( ( $now - $anchor ) > $windows[ $status ] ) {
            update_post_meta( $booking_id, '_booking_status', 'cancelled' );
        }
    }
}

Each status gets its own grace window: unpaid pending releases after 1 hour, approved after 24 hours, requested after 48 hours, measured against _created_at or _approved_at. The cron wiring:

add_action( 'release_stale_holds', array( 'Availability', 'release_expired_holds' ) );
 
if ( ! wp_next_scheduled( 'release_stale_holds' ) ) {
    wp_schedule_event( time(), 'hourly', 'release_stale_holds' );
}

Finally, because WP-Cron only runs on traffic and can lag, I also call release_expired_holds() right before the availability check at booking-create time. Even if the cron has not fired yet, stale holds get swept before anyone is told "not available":

Availability::release_expired_holds();
 
if ( ! Availability::is_available( $unit_id, $start, $end ) ) {
    // reject, a real conflict
}

Takeaways

  • When a do_action() looks like it "hands off" important work, grep the hook name first. A fire with no add_action anywhere means the signal drops into an empty room.
  • Separate payment status from booking status. If availability is blocked by _booking_status, changing _payment_status alone will never free the slot.
  • Treat abandoned checkouts as normal, not rare. The percentage is high, so there must be an automatic path that releases the hold.
  • Build a stale-hold sweeper cron with per-status grace windows, and also call it before the availability check so you do not depend entirely on WP-Cron timing.
  • Let terminal states like canceled and expired settle themselves in the webhook, and reserve pending only for what can still be retried.