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 +205 -6 3.0.3 → 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 {
@@ -138,9 +271,13 @@
138 271 $amount_due = (float) $booking->amount_due;
139 272 $extra = '';
140 273 if ($amount_due > 0) {
141 274 $extra = '<p><strong>' . esc_html__('Payment reminder', 'yatra') . '</strong></p>'
142 - . '<p>' . esc_html(sprintf(__('Outstanding balance: %s — please pay before travel.', 'yatra'), yatra_format_price($amount_due))) . '</p>';
275 + . '<p>' . esc_html(sprintf(
276 + /* translators: %s: formatted outstanding balance amount. */
277 + __('Outstanding balance: %s — please pay before travel.', 'yatra'),
278 + yatra_format_price($amount_due)
279 + )) . '</p>';
143 280 }
144 281 $extra .= '<p><strong>' . esc_html__('Preparation checklist', 'yatra') . '</strong></p><ul>'
145 282 . '<li>' . esc_html__('Valid government-issued ID', 'yatra') . '</li>'
146 283 . '<li>' . esc_html__('Travel insurance', 'yatra') . '</li>'
@@ -165,21 +302,49 @@
165 302 if ($expiry_hours <= 0) {
166 303 return; // Expiry disabled
167 304 }
168 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 +
169 327 $bookingRepository = self::getBookingRepository();
170 328 $tripRepository = self::getTripRepository();
171 329
172 330 // Calculate the expiry threshold
173 - $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));
174 337
175 338 // Get pending bookings that are older than the expiry threshold
176 - $expired_bookings = $bookingRepository->getExpiredPendingBookings($expiry_threshold);
339 + $expired_bookings = $bookingRepository->getExpiredPendingBookings($expiry_threshold, $floor);
177 340
178 341 if (empty($expired_bookings)) {
179 342 return;
180 343 }
181 344
345 + $departureService = self::getDepartureService();
346 +
182 347 foreach ($expired_bookings as $booking) {
183 348 // Update booking status to expired/cancelled
184 349 $bookingRepository->expireBooking(
185 350 $booking->id,
@@ -185,9 +350,43 @@
185 350 $booking->id,
186 351 __('Booking expired due to non-payment', 'yatra')
187 352 );
188 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 +
189 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);
190 389
191 390 // Get trip title for email
192 391 $trip = $tripRepository->find($booking->trip_id);
193 392