PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.15
Yatra – Travel Booking & Tour Operator Software v3.0.15
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 2.0.10 All 83 releases
yatra / app / Services / BookingCronService.php

BookingCronService.php in Yatra – Travel Booking & Tour Operator Software 3.0.15, at app/Services/BookingCronService.php

443 lines 16.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Yatra\Services;
4
5 use Yatra\Repositories\BookingRepository;
6 use Yatra\Repositories\TripRepository;
7
8 /**
9 * Handles scheduled booking tasks:
10 * - Sending reminder emails before departure
11 * - Auto-cancelling expired pending bookings
12 */
13 class BookingCronService
14 {
15 /**
16 * Get BookingRepository instance
17 *
18 * @return BookingRepository
19 */
20 private static function getBookingRepository(): BookingRepository
21 {
22 static $repository = null;
23 if ($repository === null) {
24 $repository = new BookingRepository();
25 }
26 return $repository;
27 }
28
29 /**
30 * Get TripRepository instance
31 *
32 * @return TripRepository
33 */
34 private static function getTripRepository(): TripRepository
35 {
36 static $repository = null;
37 if ($repository === null) {
38 $repository = new TripRepository();
39 }
40 return $repository;
41 }
42
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 /**
63 * Register cron hooks
64 */
65 public static function register(): void
66 {
67 // Register cron hooks
68 add_action('yatra_booking_reminder', [self::class, 'sendBookingReminders']);
69 add_action('yatra_booking_expiry', [self::class, 'expirePendingBookings']);
70
71 // Schedule events if not already scheduled
72 self::scheduleEvents();
73 }
74
75 /**
76 * Schedule cron events
77 */
78 public static function scheduleEvents(): void
79 {
80 // Schedule reminder emails - run daily
81 if (!wp_next_scheduled('yatra_booking_reminder')) {
82 wp_schedule_event(time(), 'daily', 'yatra_booking_reminder');
83 }
84
85 // Schedule expiry check - run hourly
86 if (!wp_next_scheduled('yatra_booking_expiry')) {
87 wp_schedule_event(time(), 'hourly', 'yatra_booking_expiry');
88 }
89 }
90
91 /**
92 * Unschedule cron events (on plugin deactivation)
93 */
94 public static function unscheduleEvents(): void
95 {
96 $timestamp = wp_next_scheduled('yatra_booking_reminder');
97 if ($timestamp) {
98 wp_unschedule_event($timestamp, 'yatra_booking_reminder');
99 }
100
101 $timestamp = wp_next_scheduled('yatra_booking_expiry');
102 if ($timestamp) {
103 wp_unschedule_event($timestamp, 'yatra_booking_expiry');
104 }
105 }
106
107 /**
108 * Send reminder emails for upcoming trips
109 */
110 public static function sendBookingReminders(): void
111 {
112 $reminder_days = (int) SettingsService::get('booking_reminder_days', 3);
113
114 if ($reminder_days <= 0) {
115 return; // Reminders disabled
116 }
117
118 $bookingRepository = self::getBookingRepository();
119
120 // Calculate the target date (X days from now)
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));
125
126 // Confirmed bookings — plus pending ones that have paid a deposit (see
127 // BookingRepository::getBookingsForReminder) — travelling on the target date
128 $bookings = $bookingRepository->getBookingsForReminder($target_date);
129
130 if (empty($bookings)) {
131 return;
132 }
133
134 foreach ($bookings as $booking) {
135 if (self::sendReminderEmail($booking)) {
136 $bookingRepository->markReminderSent($booking->id);
137 }
138 }
139
140 // Log the operation
141 if (defined('WP_DEBUG') && WP_DEBUG) {
142 }
143 }
144
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 /**
256 * Send a reminder email to the customer
257 */
258 private static function sendReminderEmail(object $booking): bool
259 {
260 $customer_email = $booking->contact_email;
261
262 if (empty($customer_email)) {
263 return false;
264 }
265
266 $reminder_days = (int) SettingsService::get('booking_reminder_days', 3);
267 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
268 $vars['reminder_days'] = (string) $reminder_days;
269 $vars['days_until_trip'] = (string) $reminder_days;
270
271 $amount_due = (float) $booking->amount_due;
272 $extra = '';
273 if ($amount_due > 0) {
274 $extra = '<p><strong>' . esc_html__('Payment reminder', 'yatra') . '</strong></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>';
280 }
281 $extra .= '<p><strong>' . esc_html__('Preparation checklist', 'yatra') . '</strong></p><ul>'
282 . '<li>' . esc_html__('Valid government-issued ID', 'yatra') . '</li>'
283 . '<li>' . esc_html__('Travel insurance', 'yatra') . '</li>'
284 . '<li>' . esc_html__('Emergency contacts', 'yatra') . '</li>'
285 . '</ul>';
286 $vars['reminder_extra_html'] = $extra;
287
288 return TransactionalEmailTemplateService::sendIfEnabled(
289 TransactionalEmailTemplateService::TYPE_BOOKING_REMINDER,
290 $customer_email,
291 $vars
292 );
293 }
294
295 /**
296 * Expire pending bookings that have passed the expiry time
297 */
298 public static function expirePendingBookings(): void
299 {
300 $expiry_hours = (int) SettingsService::get('booking_expiry_hours', 24);
301
302 if ($expiry_hours <= 0) {
303 return; // Expiry disabled
304 }
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
327 $bookingRepository = self::getBookingRepository();
328 $tripRepository = self::getTripRepository();
329
330 // Calculate the expiry threshold
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));
337
338 // Get pending bookings that are older than the expiry threshold
339 $expired_bookings = $bookingRepository->getExpiredPendingBookings($expiry_threshold, $floor);
340
341 if (empty($expired_bookings)) {
342 return;
343 }
344
345 $departureService = self::getDepartureService();
346
347 foreach ($expired_bookings as $booking) {
348 // Update booking status to expired/cancelled
349 $bookingRepository->expireBooking(
350 $booking->id,
351 __('Booking expired due to non-payment', 'yatra')
352 );
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
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);
389
390 // Get trip title for email
391 $trip = $tripRepository->find($booking->trip_id);
392
393 // Send expiry notification email
394 self::sendExpiryEmail($booking, $trip);
395 }
396
397 // Log the operation
398 if (defined('WP_DEBUG') && WP_DEBUG) {
399 }
400 }
401
402 /**
403 * Send expiry notification email
404 */
405 private static function sendExpiryEmail(object $booking, ?object $trip): void
406 {
407 $customer_email = $booking->contact_email;
408
409 if (empty($customer_email)) {
410 return;
411 }
412
413 $expiry_hours = (int) SettingsService::get('booking_expiry_hours', 24);
414 $full = self::getBookingRepository()->findWithTrip((int) $booking->id) ?: $booking;
415
416 $vars = TransactionalEmailTemplateService::variablesFromBooking($full);
417 if ($trip && !empty($trip->title)) {
418 $vars['trip_name'] = (string) $trip->title;
419 }
420 $vars['expiry_policy_note'] = sprintf(
421 /* translators: %d: hours until unpaid booking expires */
422 __('Unpaid bookings are released after %d hours.', 'yatra'),
423 $expiry_hours
424 );
425
426 TransactionalEmailTemplateService::sendIfEnabled(
427 TransactionalEmailTemplateService::TYPE_BOOKING_EXPIRED_CUSTOMER,
428 (string) $customer_email,
429 $vars
430 );
431
432 $admin_email = sanitize_email((string) SettingsService::getString('admin_email', (string) get_option('admin_email', '')));
433 if ($admin_email !== '' && is_email($admin_email)) {
434 TransactionalEmailTemplateService::sendIfEnabled(
435 TransactionalEmailTemplateService::TYPE_ADMIN_BOOKING_EXPIRED,
436 $admin_email,
437 $vars
438 );
439 }
440 }
441 }
442
443