Menjelang launch sebuah situs klien, QA menemukan satu bug yang langsung jadi blocker: integritas booking-nya bocor. Alurnya begini. Pengunjung mulai booking, memilih tanggal, lalu diarahkan ke halaman gateway pembayaran. Sebagian dari mereka tidak menyelesaikan pembayaran: browser ditutup, sesi habis, atau kartunya ditolak. Yang seharusnya terjadi, tanggal itu kembali tersedia untuk orang lain. Yang benar-benar terjadi, tanggal itu terkunci. Selamanya.
Saya coba re-book tanggal yang sama dan dapat "tidak tersedia". Saya tunggu sejam, lalu sehari, tetap "tidak tersedia". Tidak ada satu pun mekanisme yang pernah melepas tanggal itu. Untuk situs yang hidup dari ketersediaan tanggal, ini bukan bug kosmetik. Setiap checkout yang ditinggal, dan persentasenya tinggi di dunia nyata, menghapus satu slot dari inventori secara permanen.
Jalan buntu yang saya coba dulu
Refleks pertama saya menuduh query ketersediaan terlalu galak. Mungkin ada cache slot yang basi, atau logika overlap tanggalnya salah hitung. Saya buka fungsi is_available() dan telusuri baris demi baris. Query-nya benar. Booking yang tumpang tindih memang harus memblokir. Masalahnya bukan di sana.
Dugaan kedua: mungkin pengunjung sebenarnya bisa lanjut membayar dan status akan sembuh sendiri. Tapi tidak ada jalan retry yang mengubah apa pun, dan tanggal yang sudah terkunci tetap terkunci meski booking-nya jelas gagal bayar. Di titik ini saya berhenti menebak di sisi ketersediaan dan pindah ke sisi pembayaran: apa yang sebenarnya terjadi saat pembayaran gagal?
Menelusuri akar masalah
Gateway pembayaran mengirim notifikasi status lewat webhook, dan handler-nya punya cabang untuk kondisi terminal. Kurang lebih begini:
case 'canceled':
case 'expired':
case 'failed':
update_post_meta( $booking_id, '_payment_status', 'unpaid' );
do_action( 'booking_payment_failed', $booking_id );
break;Terlihat sehat. Saat pembayaran batal atau gagal, status pembayaran di-set unpaid dan sebuah hook booking_payment_failed di-fire, seolah ada yang akan menindaklanjuti. Jadi saya cari siapa yang mendengarkan hook itu:
grep -rn "booking_payment_failed" wp-content/themes/
# hanya satu baris: do_action(...) di webhook. Nol add_action.Satu baris. Itu situs do_action-nya sendiri, dan tidak ada satu pun add_action. Hook-nya nyala. Tidak ada yang mendengarkan. Sinyal "pembayaran ini gagal, tolong bebaskan tanggalnya" dikirim ke ruang kosong.
Lebih parah lagi, cabang webhook itu hanya menyentuh _payment_status. Status booking, _booking_status, tetap pending. Dan inilah yang menyatukan semuanya, karena is_available() memblokir slot berdasarkan status booking, bukan status pembayaran:
public static function is_available( $unit_id, $start, $end ) {
$overlapping = self::query_bookings(
$unit_id, $start, $end,
array( 'confirmed', 'pending' )
);
return empty( $overlapping );
}Booking yang ditinggal itu selamanya pending. is_available() selamanya menganggapnya menahan slot. Dan tidak ada satu pun cron pembersih yang pernah datang untuk merapikan hold basi. Tiga hal ini bertemu jadi kebocoran permanen: hook tanpa pendengar, status yang tidak pernah pindah dari pending, dan nol proses cleanup.
Perbaikannya
Saya perbaiki dari dua arah. Pertama, buat webhook menuntaskan urusannya sendiri untuk kondisi yang benar-benar terminal, alih-alih menitipkan pekerjaan ke hook yang tidak ada pendengarnya:
case 'canceled':
case 'expired':
update_post_meta( $booking_id, '_payment_status', 'unpaid' );
update_post_meta( $booking_id, '_booking_status', 'cancelled' ); // langsung bebaskan tanggal
break;
case 'failed':
update_post_meta( $booking_id, '_payment_status', 'unpaid' ); // biarkan pending, masih bisa retry
break;Batal dan kedaluwarsa itu final, jadi booking-nya langsung di-cancelled dan tanggalnya bebas seketika. Gagal bayar sengaja dibedakan: dibiarkan pending supaya pengunjung masih punya jendela untuk mengulang pembayaran.
Kedua, dan ini yang menutup kebocoran sesungguhnya, saya bangun pembersih hold basi yang berjalan sendiri lewat cron per jam:
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' );
}
}
}Tiap status punya jendela tenggangnya sendiri: pending yang belum dibayar dilepas setelah 1 jam, approved setelah 24 jam, requested setelah 48 jam, memakai _created_at atau _approved_at sebagai titik ukur. Wiring cron-nya:
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' );
}Terakhir, karena WP-Cron hanya jalan saat ada traffic dan bisa telat, saya panggil juga release_expired_holds() tepat sebelum pengecekan ketersediaan saat booking dibuat. Jadi walau cron belum sempat lewat, hold basi tetap dibersihkan sebelum ada yang divonis "tidak tersedia":
Availability::release_expired_holds();
if ( ! Availability::is_available( $unit_id, $start, $end ) ) {
// tolak, benar-benar bentrok
}Checklist
- Kalau sebuah
do_action()seolah "menyerahkan" pekerjaan penting,grepdulu nama hook-nya. Fire tanpaadd_actionmana pun artinya sinyal itu jatuh ke ruang kosong. - Bedakan status pembayaran dan status booking. Kalau ketersediaan diblokir oleh
_booking_status, mengubah_payment_statussaja tidak akan pernah membebaskan slot. - Perlakukan checkout yang ditinggal sebagai kondisi normal, bukan kasus langka. Persentasenya tinggi, jadi harus ada jalan otomatis yang melepas hold-nya.
- Bangun cron pembersih hold basi dengan jendela tenggang per status, dan panggil juga sebelum cek ketersediaan supaya tidak bergantung penuh pada jadwal WP-Cron.
- Buat kondisi terminal seperti batal dan kedaluwarsa menuntaskan dirinya di webhook, dan sisakan
pendinghanya untuk yang benar-benar masih bisa retry.
