| @@ -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 | { |
| @@ -47,9 +66,9 @@ | ||
| 47 | 66 | { |
| 48 | 67 | // Register cron hooks |
| 49 | 68 | add_action('yatra_booking_reminder', [self::class, 'sendBookingReminders']); |
| 50 | 69 | add_action('yatra_booking_expiry', [self::class, 'expirePendingBookings']); |
| 51 | - | |
| 70 | + | |
| 52 | 71 | // Schedule events if not already scheduled |
| 53 | 72 | self::scheduleEvents(); |
| 54 | 73 | } |
| 55 | 74 | |
| @@ -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; |
| @@ -119,8 +142,118 @@ | ||
| 119 | 142 | } |
| 120 | 143 | } |
| 121 | 144 | |
| 122 | 145 | /** |
| 146 | + * Ensure the daily booking-completion sweep is scheduled. | |
| 147 | + * | |
| 148 | + * Wired from CronHooks (the plugin's live cron bootstrap) rather than the | |
| 149 | + * legacy register()/scheduleEvents() path above, which is not invoked. Only | |
| 150 | + * the completion event is scheduled here — the reminder/expiry events are | |
| 151 | + * intentionally left as-is to avoid changing their (separate) behavior. | |
| 152 | + */ | |
| 153 | + public static function registerCompletionCron(): void | |
| 154 | + { | |
| 155 | + if (!wp_next_scheduled('yatra_booking_completion')) { | |
| 156 | + wp_schedule_event(time(), 'daily', 'yatra_booking_completion'); | |
| 157 | + } | |
| 158 | + } | |
| 159 | + | |
| 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 | + /** | |
| 185 | + * Unschedule the booking-completion sweep (plugin deactivation). | |
| 186 | + */ | |
| 187 | + public static function unregisterCompletionCron(): void | |
| 188 | + { | |
| 189 | + $timestamp = wp_next_scheduled('yatra_booking_completion'); | |
| 190 | + if ($timestamp) { | |
| 191 | + wp_unschedule_event($timestamp, 'yatra_booking_completion'); | |
| 192 | + } | |
| 193 | + } | |
| 194 | + | |
| 195 | + /** | |
| 196 | + * Mark confirmed bookings 'completed' once their tour has taken place. | |
| 197 | + * | |
| 198 | + * Nothing previously transitioned a booking to 'completed' automatically — | |
| 199 | + * the status (and therefore the booking.completed email / Email Automation | |
| 200 | + * sequence) only changed when an operator edited each booking by hand. So | |
| 201 | + * the post-tour email was effectively never sent. This daily sweep does | |
| 202 | + * what an operator would: for every confirmed booking whose tour date has | |
| 203 | + * passed, it calls the same updateStatus() path the admin UI uses, which | |
| 204 | + * fires the notification, the yatra_booking_status_changed action (Pro | |
| 205 | + * sequences), and schedules the review reminder. | |
| 206 | + * | |
| 207 | + * Backward-compat: an activation floor (yatra_booking_autocomplete_since) is | |
| 208 | + * stamped on the first run so we never retroactively complete — and email | |
| 209 | + * the customers of — tours that ended before this automation shipped. Only | |
| 210 | + * tours finishing from activation onward are auto-completed. Operators can | |
| 211 | + * disable the sweep entirely via the yatra_auto_complete_bookings filter, | |
| 212 | + * and the email itself still respects its own template on/off setting. | |
| 213 | + */ | |
| 214 | + public static function completeFinishedBookings(): void | |
| 215 | + { | |
| 216 | + /** | |
| 217 | + * Allow disabling automatic booking completion entirely. | |
| 218 | + * | |
| 219 | + * @param bool $enabled Default true. | |
| 220 | + */ | |
| 221 | + if (!apply_filters('yatra_auto_complete_bookings', true)) { | |
| 222 | + return; | |
| 223 | + } | |
| 224 | + | |
| 225 | + $floorOption = 'yatra_booking_autocomplete_since'; | |
| 226 | + $today = current_time('Y-m-d'); | |
| 227 | + | |
| 228 | + $floor = (string) get_option($floorOption, ''); | |
| 229 | + if ($floor === '') { | |
| 230 | + // First run on this site: establish the floor at today so historical | |
| 231 | + // bookings are never retroactively completed/emailed. Tours finishing | |
| 232 | + // from now on are picked up on subsequent runs. | |
| 233 | + update_option($floorOption, $today); | |
| 234 | + | |
| 235 | + return; | |
| 236 | + } | |
| 237 | + | |
| 238 | + $bookingRepository = self::getBookingRepository(); | |
| 239 | + $ids = $bookingRepository->getConfirmedBookingIdsPastTour($today, $floor, 500); | |
| 240 | + | |
| 241 | + if (empty($ids)) { | |
| 242 | + return; | |
| 243 | + } | |
| 244 | + | |
| 245 | + $bookingService = new BookingService(); | |
| 246 | + | |
| 247 | + foreach ($ids as $id) { | |
| 248 | + // Same entry point the admin "change status" action uses, so all | |
| 249 | + // side effects (notification, status-changed hook, review reminder, | |
| 250 | + // departure booked_count handling) stay identical to a manual mark. | |
| 251 | + $bookingService->updateStatus((int) $id, 'completed'); | |
| 252 | + } | |
| 253 | + } | |
| 254 | + | |
| 255 | + /** | |
| 123 | 256 | * Send a reminder email to the customer |
| 124 | 257 | */ |
| 125 | 258 | private static function sendReminderEmail(object $booking): bool |
| 126 | 259 | { |
| @@ -169,21 +302,49 @@ | ||
| 169 | 302 | if ($expiry_hours <= 0) { |
| 170 | 303 | return; // Expiry disabled |
| 171 | 304 | } |
| 172 | 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 | + | |
| 173 | 327 | $bookingRepository = self::getBookingRepository(); |
| 174 | 328 | $tripRepository = self::getTripRepository(); |
| 175 | 329 | |
| 176 | 330 | // Calculate the expiry threshold |
| 177 | - $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)); | |
| 178 | 337 | |
| 179 | 338 | // Get pending bookings that are older than the expiry threshold |
| 180 | - $expired_bookings = $bookingRepository->getExpiredPendingBookings($expiry_threshold); | |
| 339 | + $expired_bookings = $bookingRepository->getExpiredPendingBookings($expiry_threshold, $floor); | |
| 181 | 340 | |
| 182 | 341 | if (empty($expired_bookings)) { |
| 183 | 342 | return; |
| 184 | 343 | } |
| 185 | 344 | |
| 345 | + $departureService = self::getDepartureService(); | |
| 346 | + | |
| 186 | 347 | foreach ($expired_bookings as $booking) { |
| 187 | 348 | // Update booking status to expired/cancelled |
| 188 | 349 | $bookingRepository->expireBooking( |
| 189 | 350 | $booking->id, |
| @@ -189,9 +350,43 @@ | ||
| 189 | 350 | $booking->id, |
| 190 | 351 | __('Booking expired due to non-payment', 'yatra') |
| 191 | 352 | ); |
| 192 | 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 | + | |
| 193 | 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); | |
| 194 | 389 | |
| 195 | 390 | // Get trip title for email |
| 196 | 391 | $trip = $tripRepository->find($booking->trip_id); |
| 197 | 392 | |