getBookingsForReminder($target_date);
if (empty($bookings)) {
return;
}
foreach ($bookings as $booking) {
if (self::sendReminderEmail($booking)) {
$bookingRepository->markReminderSent($booking->id);
}
}
// Log the operation
if (defined('WP_DEBUG') && WP_DEBUG) {
}
}
/**
* Ensure the daily booking-completion sweep is scheduled.
*
* Wired from CronHooks (the plugin's live cron bootstrap) rather than the
* legacy register()/scheduleEvents() path above, which is not invoked. Only
* the completion event is scheduled here — the reminder/expiry events are
* intentionally left as-is to avoid changing their (separate) behavior.
*/
public static function registerCompletionCron(): void
{
if (!wp_next_scheduled('yatra_booking_completion')) {
wp_schedule_event(time(), 'daily', 'yatra_booking_completion');
}
}
/**
* Ensure the unpaid-booking expiry and pre-trip reminder sweeps are scheduled.
*
* Both events existed but nothing ever scheduled them or attached a
* callback: register() — which does both — is not called anywhere, so
* `Settings → Booking → Booking Expiry (hours)` never expired anything and
* the reminder email never went out on its own. Wired from CronHooks
* alongside the completion sweep.
*
* Expiry is guarded by an activation floor (see expirePendingBookings), so
* switching this on cannot retroactively cancel a site's existing pending
* bookings.
*/
public static function registerMaintenanceCrons(): void
{
if (!wp_next_scheduled('yatra_booking_expiry')) {
wp_schedule_event(time(), 'hourly', 'yatra_booking_expiry');
}
if (!wp_next_scheduled('yatra_booking_reminder')) {
wp_schedule_event(time(), 'daily', 'yatra_booking_reminder');
}
}
/**
* Unschedule the booking-completion sweep (plugin deactivation).
*/
public static function unregisterCompletionCron(): void
{
$timestamp = wp_next_scheduled('yatra_booking_completion');
if ($timestamp) {
wp_unschedule_event($timestamp, 'yatra_booking_completion');
}
}
/**
* Mark confirmed bookings 'completed' once their tour has taken place.
*
* Nothing previously transitioned a booking to 'completed' automatically —
* the status (and therefore the booking.completed email / Email Automation
* sequence) only changed when an operator edited each booking by hand. So
* the post-tour email was effectively never sent. This daily sweep does
* what an operator would: for every confirmed booking whose tour date has
* passed, it calls the same updateStatus() path the admin UI uses, which
* fires the notification, the yatra_booking_status_changed action (Pro
* sequences), and schedules the review reminder.
*
* Backward-compat: an activation floor (yatra_booking_autocomplete_since) is
* stamped on the first run so we never retroactively complete — and email
* the customers of — tours that ended before this automation shipped. Only
* tours finishing from activation onward are auto-completed. Operators can
* disable the sweep entirely via the yatra_auto_complete_bookings filter,
* and the email itself still respects its own template on/off setting.
*/
public static function completeFinishedBookings(): void
{
/**
* Allow disabling automatic booking completion entirely.
*
* @param bool $enabled Default true.
*/
if (!apply_filters('yatra_auto_complete_bookings', true)) {
return;
}
$floorOption = 'yatra_booking_autocomplete_since';
$today = current_time('Y-m-d');
$floor = (string) get_option($floorOption, '');
if ($floor === '') {
// First run on this site: establish the floor at today so historical
// bookings are never retroactively completed/emailed. Tours finishing
// from now on are picked up on subsequent runs.
update_option($floorOption, $today);
return;
}
$bookingRepository = self::getBookingRepository();
$ids = $bookingRepository->getConfirmedBookingIdsPastTour($today, $floor, 500);
if (empty($ids)) {
return;
}
$bookingService = new BookingService();
foreach ($ids as $id) {
// Same entry point the admin "change status" action uses, so all
// side effects (notification, status-changed hook, review reminder,
// departure booked_count handling) stay identical to a manual mark.
$bookingService->updateStatus((int) $id, 'completed');
}
}
/**
* Send a reminder email to the customer
*/
private static function sendReminderEmail(object $booking): bool
{
$customer_email = $booking->contact_email;
if (empty($customer_email)) {
return false;
}
$reminder_days = (int) SettingsService::get('booking_reminder_days', 3);
$vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
$vars['reminder_days'] = (string) $reminder_days;
$vars['days_until_trip'] = (string) $reminder_days;
$amount_due = (float) $booking->amount_due;
$extra = '';
if ($amount_due > 0) {
$extra = '
' . esc_html__('Payment reminder', 'yatra') . '
'
. '' . esc_html(sprintf(
/* translators: %s: formatted outstanding balance amount. */
__('Outstanding balance: %s — please pay before travel.', 'yatra'),
yatra_format_price($amount_due)
)) . '
';
}
$extra .= '' . esc_html__('Preparation checklist', 'yatra') . '
'
. '- ' . esc_html__('Valid government-issued ID', 'yatra') . '
'
. '- ' . esc_html__('Travel insurance', 'yatra') . '
'
. '- ' . esc_html__('Emergency contacts', 'yatra') . '
'
. '
';
$vars['reminder_extra_html'] = $extra;
return TransactionalEmailTemplateService::sendIfEnabled(
TransactionalEmailTemplateService::TYPE_BOOKING_REMINDER,
$customer_email,
$vars
);
}
/**
* Expire pending bookings that have passed the expiry time
*/
public static function expirePendingBookings(): void
{
$expiry_hours = (int) SettingsService::get('booking_expiry_hours', 24);
if ($expiry_hours <= 0) {
return; // Expiry disabled
}
/**
* Allow disabling automatic expiry of unpaid bookings entirely.
*
* @param bool $enabled Default true.
*/
if (!apply_filters('yatra_auto_expire_bookings', true)) {
return;
}
// Activation floor, mirroring the completion sweep: the first run only
// records "from here on". Without it, a site whose expiry cron starts
// running would cancel — and email about — every historical unpaid
// booking in one go.
$floorOption = 'yatra_booking_expiry_since';
$floor = (string) get_option($floorOption, '');
if ($floor === '') {
update_option($floorOption, current_time('mysql'));
return;
}
$bookingRepository = self::getBookingRepository();
$tripRepository = self::getTripRepository();
// Calculate the expiry threshold
// Site-local, because `created_at` is written with current_time('mysql').
// Deriving the threshold from PHP's clock (UTC in WordPress) compared a
// local timestamp against a UTC one, so a site at UTC-5 expired bookings
// five hours EARLY and a site at UTC+2 two hours late. Matches the
// current_time() basis the completion sweep above already uses.
$expiry_threshold = date('Y-m-d H:i:s', current_time('timestamp') - ($expiry_hours * HOUR_IN_SECONDS));
// Get pending bookings that are older than the expiry threshold
$expired_bookings = $bookingRepository->getExpiredPendingBookings($expiry_threshold, $floor);
if (empty($expired_bookings)) {
return;
}
$departureService = self::getDepartureService();
foreach ($expired_bookings as $booking) {
// Update booking status to expired/cancelled
$bookingRepository->expireBooking(
$booking->id,
__('Booking expired due to non-payment', 'yatra')
);
// Give the seat back. expireBooking() writes the row directly rather
// than going through BookingService::updateStatus(), which is what
// normally unlinks the departure and decrements its booked_count —
// so without this an expired booking held its seat forever and the
// departure slowly "sold out" to bookings nobody ever paid for.
try {
$departure = $departureService->getDepartureForBooking((int) $booking->id);
if ($departure && !empty($departure->id)) {
$departureService->unlinkBookingFromDeparture((int) $booking->id, (int) $departure->id);
}
} catch (\Throwable $e) {
// Never let inventory bookkeeping stop the sweep.
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('[Yatra] expiry: releasing the departure seat failed - ' . $e->getMessage());
}
}
do_action('yatra_booking_status_changed', (int) $booking->id, 'pending', 'cancelled');
// An expiry IS a cancellation, so announce it like one (Google
// Calendar, WhatsApp and the `booking.cancelled` webhook all listen
// here) …
if (function_exists('yatra_trigger_booking_cancelled')) {
\yatra_trigger_booking_cancelled((int) $booking->id, 'pending');
}
/**
* … and separately, that this particular cancellation was an
* automatic expiry. Distinct from `yatra_booking_cancelled` so an
* integration can tell "the customer never paid" apart from "someone
* cancelled this booking".
*
* @param int $bookingId Booking ID.
*/
do_action('yatra_booking_expired', (int) $booking->id);
// Get trip title for email
$trip = $tripRepository->find($booking->trip_id);
// Send expiry notification email
self::sendExpiryEmail($booking, $trip);
}
// Log the operation
if (defined('WP_DEBUG') && WP_DEBUG) {
}
}
/**
* Send expiry notification email
*/
private static function sendExpiryEmail(object $booking, ?object $trip): void
{
$customer_email = $booking->contact_email;
if (empty($customer_email)) {
return;
}
$expiry_hours = (int) SettingsService::get('booking_expiry_hours', 24);
$full = self::getBookingRepository()->findWithTrip((int) $booking->id) ?: $booking;
$vars = TransactionalEmailTemplateService::variablesFromBooking($full);
if ($trip && !empty($trip->title)) {
$vars['trip_name'] = (string) $trip->title;
}
$vars['expiry_policy_note'] = sprintf(
/* translators: %d: hours until unpaid booking expires */
__('Unpaid bookings are released after %d hours.', 'yatra'),
$expiry_hours
);
TransactionalEmailTemplateService::sendIfEnabled(
TransactionalEmailTemplateService::TYPE_BOOKING_EXPIRED_CUSTOMER,
(string) $customer_email,
$vars
);
$admin_email = sanitize_email((string) SettingsService::getString('admin_email', (string) get_option('admin_email', '')));
if ($admin_email !== '' && is_email($admin_email)) {
TransactionalEmailTemplateService::sendIfEnabled(
TransactionalEmailTemplateService::TYPE_ADMIN_BOOKING_EXPIRED,
$admin_email,
$vars
);
}
}
}