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 +200 -5 3.0.9 → 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 {
@@ -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