PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
← All changes | app/Services/BookingCronService.php +113 -4 3.0.14.2 → 3.0.16 View file →
@@ -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