| @@ -40,8 +40,27 @@ | ||
| 40 | 40 | return $repository; |
| 41 | 41 | } |
| 42 | 42 | |
| 43 | 43 | /** |
| 44 | + * Departure service, wired the same way BookingService wires it, so the | |
| 45 | + * expiry sweep releases inventory through exactly the same code path an | |
| 46 | + * admin cancellation uses. | |
| 47 | + */ | |
| 48 | + private static function getDepartureService(): \Yatra\Services\DepartureService | |
| 49 | + { | |
| 50 | + static $service = null; | |
| 51 | + if ($service === null) { | |
| 52 | + $service = new \Yatra\Services\DepartureService( | |
| 53 | + new \Yatra\Repositories\DepartureRepository(), | |
| 54 | + new \Yatra\Repositories\BookingDepartureRepository(), | |
| 55 | + self::getBookingRepository(), | |
| 56 | + self::getTripRepository() | |
| 57 | + ); | |
| 58 | + } | |
| 59 | + return $service; | |
| 60 | + } | |
| 61 | + | |
| 62 | + /** | |
| 44 | 63 | * Register cron hooks |
| 45 | 64 | */ |
| 46 | 65 | public static function register(): void |
| 47 | 66 | { |
| @@ -98,11 +117,15 @@ | ||
| 98 | 117 | |
| 99 | 118 | $bookingRepository = self::getBookingRepository(); |
| 100 | 119 | |
| 101 | 120 | // Calculate the target date (X days from now) |
| 102 | - $target_date = date('Y-m-d', strtotime("+{$reminder_days} days")); | |
| 121 | + // Site-local for the same reason as the expiry threshold: travel dates | |
| 122 | + // are the operator's local dates, so near midnight a UTC-derived target | |
| 123 | + // picked the wrong day on any site with an offset. | |
| 124 | + $target_date = date('Y-m-d', current_time('timestamp') + ($reminder_days * DAY_IN_SECONDS)); | |
| 103 | 125 | |
| 104 | - // Get confirmed bookings with travel date matching the target | |
| 126 | + // Confirmed bookings — plus pending ones that have paid a deposit (see | |
| 127 | + // BookingRepository::getBookingsForReminder) — travelling on the target date | |
| 105 | 128 | $bookings = $bookingRepository->getBookingsForReminder($target_date); |
| 106 | 129 | |
| 107 | 130 | if (empty($bookings)) { |
| 108 | 131 | return; |
| @@ -134,8 +157,32 @@ | ||
| 134 | 157 | } |
| 135 | 158 | } |
| 136 | 159 | |
| 137 | 160 | /** |
| 161 | + * Ensure the unpaid-booking expiry and pre-trip reminder sweeps are scheduled. | |
| 162 | + * | |
| 163 | + * Both events existed but nothing ever scheduled them or attached a | |
| 164 | + * callback: register() — which does both — is not called anywhere, so | |
| 165 | + * `Settings → Booking → Booking Expiry (hours)` never expired anything and | |
| 166 | + * the reminder email never went out on its own. Wired from CronHooks | |
| 167 | + * alongside the completion sweep. | |
| 168 | + * | |
| 169 | + * Expiry is guarded by an activation floor (see expirePendingBookings), so | |
| 170 | + * switching this on cannot retroactively cancel a site's existing pending | |
| 171 | + * bookings. | |
| 172 | + */ | |
| 173 | + public static function registerMaintenanceCrons(): void | |
| 174 | + { | |
| 175 | + if (!wp_next_scheduled('yatra_booking_expiry')) { | |
| 176 | + wp_schedule_event(time(), 'hourly', 'yatra_booking_expiry'); | |
| 177 | + } | |
| 178 | + | |
| 179 | + if (!wp_next_scheduled('yatra_booking_reminder')) { | |
| 180 | + wp_schedule_event(time(), 'daily', 'yatra_booking_reminder'); | |
| 181 | + } | |
| 182 | + } | |
| 183 | + | |
| 184 | + /** | |
| 138 | 185 | * Unschedule the booking-completion sweep (plugin deactivation). |
| 139 | 186 | */ |
| 140 | 187 | public static function unregisterCompletionCron(): void |
| 141 | 188 | { |
| @@ -255,21 +302,49 @@ | ||
| 255 | 302 | if ($expiry_hours <= 0) { |
| 256 | 303 | return; // Expiry disabled |
| 257 | 304 | } |
| 258 | 305 | |
| 306 | + /** | |
| 307 | + * Allow disabling automatic expiry of unpaid bookings entirely. | |
| 308 | + * | |
| 309 | + * @param bool $enabled Default true. | |
| 310 | + */ | |
| 311 | + if (!apply_filters('yatra_auto_expire_bookings', true)) { | |
| 312 | + return; | |
| 313 | + } | |
| 314 | + | |
| 315 | + // Activation floor, mirroring the completion sweep: the first run only | |
| 316 | + // records "from here on". Without it, a site whose expiry cron starts | |
| 317 | + // running would cancel — and email about — every historical unpaid | |
| 318 | + // booking in one go. | |
| 319 | + $floorOption = 'yatra_booking_expiry_since'; | |
| 320 | + $floor = (string) get_option($floorOption, ''); | |
| 321 | + if ($floor === '') { | |
| 322 | + update_option($floorOption, current_time('mysql')); | |
| 323 | + | |
| 324 | + return; | |
| 325 | + } | |
| 326 | + | |
| 259 | 327 | $bookingRepository = self::getBookingRepository(); |
| 260 | 328 | $tripRepository = self::getTripRepository(); |
| 261 | 329 | |
| 262 | 330 | // Calculate the expiry threshold |
| 263 | - $expiry_threshold = date('Y-m-d H:i:s', strtotime("-{$expiry_hours} hours")); | |
| 331 | + // Site-local, because `created_at` is written with current_time('mysql'). | |
| 332 | + // Deriving the threshold from PHP's clock (UTC in WordPress) compared a | |
| 333 | + // local timestamp against a UTC one, so a site at UTC-5 expired bookings | |
| 334 | + // five hours EARLY and a site at UTC+2 two hours late. Matches the | |
| 335 | + // current_time() basis the completion sweep above already uses. | |
| 336 | + $expiry_threshold = date('Y-m-d H:i:s', current_time('timestamp') - ($expiry_hours * HOUR_IN_SECONDS)); | |
| 264 | 337 | |
| 265 | 338 | // Get pending bookings that are older than the expiry threshold |
| 266 | - $expired_bookings = $bookingRepository->getExpiredPendingBookings($expiry_threshold); | |
| 339 | + $expired_bookings = $bookingRepository->getExpiredPendingBookings($expiry_threshold, $floor); | |
| 267 | 340 | |
| 268 | 341 | if (empty($expired_bookings)) { |
| 269 | 342 | return; |
| 270 | 343 | } |
| 271 | 344 | |
| 345 | + $departureService = self::getDepartureService(); | |
| 346 | + | |
| 272 | 347 | foreach ($expired_bookings as $booking) { |
| 273 | 348 | // Update booking status to expired/cancelled |
| 274 | 349 | $bookingRepository->expireBooking( |
| 275 | 350 | $booking->id, |
| @@ -275,9 +350,43 @@ | ||
| 275 | 350 | $booking->id, |
| 276 | 351 | __('Booking expired due to non-payment', 'yatra') |
| 277 | 352 | ); |
| 278 | 353 | |
| 354 | + // Give the seat back. expireBooking() writes the row directly rather | |
| 355 | + // than going through BookingService::updateStatus(), which is what | |
| 356 | + // normally unlinks the departure and decrements its booked_count — | |
| 357 | + // so without this an expired booking held its seat forever and the | |
| 358 | + // departure slowly "sold out" to bookings nobody ever paid for. | |
| 359 | + try { | |
| 360 | + $departure = $departureService->getDepartureForBooking((int) $booking->id); | |
| 361 | + if ($departure && !empty($departure->id)) { | |
| 362 | + $departureService->unlinkBookingFromDeparture((int) $booking->id, (int) $departure->id); | |
| 363 | + } | |
| 364 | + } catch (\Throwable $e) { | |
| 365 | + // Never let inventory bookkeeping stop the sweep. | |
| 366 | + if (defined('WP_DEBUG') && WP_DEBUG) { | |
| 367 | + error_log('[Yatra] expiry: releasing the departure seat failed - ' . $e->getMessage()); | |
| 368 | + } | |
| 369 | + } | |
| 370 | + | |
| 279 | 371 | do_action('yatra_booking_status_changed', (int) $booking->id, 'pending', 'cancelled'); |
| 372 | + | |
| 373 | + // An expiry IS a cancellation, so announce it like one (Google | |
| 374 | + // Calendar, WhatsApp and the `booking.cancelled` webhook all listen | |
| 375 | + // here) … | |
| 376 | + if (function_exists('yatra_trigger_booking_cancelled')) { | |
| 377 | + \yatra_trigger_booking_cancelled((int) $booking->id, 'pending'); | |
| 378 | + } | |
| 379 | + | |
| 380 | + /** | |
| 381 | + * … and separately, that this particular cancellation was an | |
| 382 | + * automatic expiry. Distinct from `yatra_booking_cancelled` so an | |
| 383 | + * integration can tell "the customer never paid" apart from "someone | |
| 384 | + * cancelled this booking". | |
| 385 | + * | |
| 386 | + * @param int $bookingId Booking ID. | |
| 387 | + */ | |
| 388 | + do_action('yatra_booking_expired', (int) $booking->id); | |
| 280 | 389 | |
| 281 | 390 | // Get trip title for email |
| 282 | 391 | $trip = $tripRepository->find($booking->trip_id); |
| 283 | 392 | |