D
P
0
← All articles Baca dalam Bahasa Indonesia

WordPress & PHP in Production

A Paid Booking Stuck at `approved`? `mark_booking_paid()` Only Knew One Starting Status

· · 4 min read
A Paid Booking Stuck at `approved`? `mark_booking_paid()` Only Knew One Starting Status

On a property booking platform I added a second way to book: request to book. Until then guests picked their dates and paid straight away. The new path put an approval step in the middle. The guest sends a request, the owner approves it, and only then is the guest asked to pay.

The whole flow ran smoothly right up to the last step. The guest paid, the gateway reported the payment as settled, the money landed. And the booking status stopped at approved. It never moved to confirmed.

The symptom: paid, but not actually booked

I walked the scenario through slowly. Create a request, approve it as the owner, pay as the guest. Once the payment cleared I read the booking meta:

_payment_status  = paid
_booking_status  = approved

The payment was recorded properly. The only thing missing was the transition itself. That is not cosmetic, because the booking status is what drives nearly everything downstream: whether the guest counts as holding a real reservation, whether the dates lock as a final booking, and how the owner sees their guest list. A booking that has been paid for but is still sitting at approved lives in a gap nobody recognizes.

My first guess was that the payment webhook never arrived. But _payment_status already read paid, so the webhook clearly arrived and the handler clearly ran. Something inside that handler had decided to do nothing.

The root cause: a state machine with only one entrance

The function handling the post payment transition was mark_booking_paid(). It was written back when the site had exactly one booking path, pay immediately. In that world, a booking waiting for payment was always pending. So the condition was written to match that assumption exactly:

// before
if ( $status === 'pending' ) {
    confirm();
}

One status, one entrance. As long as request to book did not exist, there was nothing wrong with that line.

What changed was the lifecycle. The new flow slipped an extra status into the middle, approved, meaning "the owner said yes, now it is the guest's turn to pay". On that path the status right before payment is no longer pending, it is approved.

And mark_booking_paid() knew nothing about it. The payment arrives, the function is called, it reads the status, sees approved, the condition is false, and the function returns silently. No error, no log line, nothing announcing "I refused this transition". The booking simply stays where it is.

This is a very typical failure when you add a state to a lifecycle that is already running. All the attention goes to the new status itself, making sure it can be created and displayed correctly, and it is easy to forget that older functions downstream have hardcoded the set of statuses they accept. Those functions are not broken. They are just still living in the previous version of the lifecycle.

The fix

The fix is small, which is exactly why it took a while to find. Change how the function recognizes a payable status, from a single value to a list:

// after
if ( in_array( $status, array( 'pending', 'approved', '' ), true ) ) {
    confirm();
}

Three things worth noting in that line.

First, approved belongs on the list because on the request-to-book path it is the normal state of a booking immediately before payment. It is not an edge case, it is the main road.

Second, the empty string is included too. That covers bookings whose status meta was never populated. Leave it out and those bookings fail in precisely the same way, paid but never confirmed.

Third, in_array() runs with the strict flag set to true. Without it, PHP's loose comparison can let non string values slip through, and in a function whose job is to finalize a payment, that kind of leniency is not something you want.

Then I replayed the scenario from scratch: create a request, approve it, pay. The result came out confirmed and paid, the way it should. I also retested the pay immediately path, since the condition it shares had changed, and pending still promoted to confirmed exactly as before.

What I took away