findWithTrip($bookingId); if (!$booking || empty($booking->contact_email)) { return; } // Check if customer has already reviewed if (self::hasCustomerReviewed($bookingId, (int) ($booking->customer_id ?? 0))) { return; } // Get trip details $tripRepository = new \Yatra\Repositories\TripRepository(); $trip = $tripRepository->find((int) ($booking->trip_id ?? 0)); if (!$trip) { return; } $review_url = get_permalink($trip->id) . '#reviews'; $vars = TransactionalEmailTemplateService::variablesFromBooking($booking); $vars['review_url'] = esc_url($review_url); $vars['completion_date'] = date_i18n(get_option('date_format')); TransactionalEmailTemplateService::sendIfEnabled( TransactionalEmailTemplateService::TYPE_REVIEW_REQUEST, (string) $booking->contact_email, $vars ); } /** * When the trip ended, as a timestamp, or null when it cannot be told. * * Mirrors the COALESCE in * {@see \Yatra\Repositories\BookingRepository::getConfirmedBookingIdsPastTour()} * so the sweep that completes a booking and the reminder it schedules agree * on which date the trip ended. Day trips carry no end_date, hence the * fallbacks. */ private static function tourEndTimestamp(int $bookingId): ?int { global $wpdb; $table = \Yatra\Database\Tables\BookingsTable::getTableName(); $row = $wpdb->get_row($wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from a constant. "SELECT end_date, start_date, travel_date FROM {$table} WHERE id = %d", $bookingId )); if (!$row) { return null; } foreach ([$row->end_date ?? null, $row->start_date ?? null, $row->travel_date ?? null] as $value) { $value = (string) $value; if ($value === '' || strpos($value, '0000-00-00') === 0) { continue; } // End of that day: a trip finishing today is not "over" at 00:00. $ts = strtotime($value . ' 23:59:59'); if ($ts !== false) { return $ts; } } return null; } /** * Is the trip still recent enough to ask about? */ private static function tourIsRecentEnough(int $tourEnd, int $now): bool { $maxAge = self::maxTourAgeDays(); if ($maxAge <= 0) { return false; } return ($now - $tourEnd) <= ($maxAge * DAY_IN_SECONDS); } /** * Nudge the due time to a quiet, spread-out moment. * * Every trip ending on the same day resolves to the same tour_end + N, so * without this a busy departure day still lands its whole batch on one * timestamp — a volume spike from a domain with no history of one, which is * what mail providers throttle. The offset is derived from the booking id * rather than randomised so a reschedule of the same booking is stable. */ private static function spreadWithinHour(int $due, int $bookingId): int { return $due + (($bookingId % 60) * MINUTE_IN_SECONDS); } /** * Drop queued reminders for trips that are now too old to ask about. * * Reminders are individual one-off cron events, so changing the rules above * does nothing for events an earlier release already queued. Without this, * the sites that hit the mass send would simply receive it again. * * @return int number of events unscheduled */ public static function purgeStaleScheduledReminders(): int { $crons = _get_cron_array(); if (!is_array($crons)) { return 0; } $now = time(); $removed = 0; foreach ($crons as $timestamp => $hooks) { if (!isset($hooks[self::CRON_HOOK]) || !is_array($hooks[self::CRON_HOOK])) { continue; } foreach ($hooks[self::CRON_HOOK] as $event) { $args = $event['args'] ?? []; $bookingId = (int) ($args[0] ?? 0); if ($bookingId <= 0) { continue; } $tourEnd = self::tourEndTimestamp($bookingId); if ($tourEnd !== null && self::tourIsRecentEnough($tourEnd, $now)) { continue; } wp_unschedule_event((int) $timestamp, self::CRON_HOOK, $args); $removed++; } } return $removed; } /** * Check if customer has already reviewed the trip * * @param int $bookingId Booking ID * @param int $customerId Customer ID * @return bool */ private static function hasCustomerReviewed(int $bookingId, int $customerId): bool { global $wpdb; $reviewsTable = \Yatra\Database\Tables\ReviewsTable::getTableName(); $count = $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$reviewsTable} WHERE booking_id = %d OR customer_id = %d", $bookingId, $customerId )); return $count > 0; } /** * Initialize review reminder cron */ public static function init(): void { add_action(self::CRON_HOOK, [self::class, 'sendReminder']); } }