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 / Repositories / BookingRepository.php

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

1,368 lines 46.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Repositories;
6
7 use Yatra\Constants\ClassificationTypes;
8 use Yatra\Database\Tables\BookingDeparturesTable;
9 use Yatra\Database\Tables\BookingsTable;
10 use Yatra\Database\Tables\ClassificationsTable;
11 use Yatra\Database\Tables\ReviewsTable;
12 use Yatra\Database\Tables\TripClassificationsTable;
13 use Yatra\Database\Tables\TripsTable;
14 use Yatra\Utils\Cache;
15
16 /**
17 * Booking Repository
18 *
19 * Handles all database operations for bookings.
20 * No business logic here - only database CRUD operations.
21 *
22 * @package Yatra\Repositories
23 */
24 class BookingRepository extends BaseRepository
25 {
26 /**
27 * Get full table name with prefix. Single source of truth =
28 * {@see BookingsTable::getTableName()}. A previous "try both names"
29 * resolver existed to bridge the 3.0.4 → 3.0.5 rename (when the
30 * physical table could be either `wp_yatra_new_bookings` or
31 * `wp_yatra_bookings`); now that
32 * {@see \Yatra\Upgrades\Versions\Upgrade_3_0_5} guarantees the
33 * canonical name, the probe is no longer needed.
34 */
35 protected function getTableName(): string
36 {
37 return BookingsTable::getTableName();
38 }
39
40 /**
41 * Public accessor for the bookings table name (e.g. joins from other repositories).
42 */
43 public function getBookingsTableName(): string
44 {
45 return BookingsTable::getTableName();
46 }
47
48 /**
49 * Get trips table name
50 */
51 protected function getTripsTable(): string
52 {
53 return TripsTable::getTableName();
54 }
55
56 /**
57 * Get paginated bookings with filters
58 *
59 * @param array $filters {
60 * @type int $page Page number (default: 1)
61 * @type int $per_page Items per page (default: 20)
62 * @type string $status Booking status filter
63 * @type string $payment_status Payment status filter
64 * @type int $trip_id Trip ID filter
65 * @type string $search Search term
66 * @type string $date_from Start date filter
67 * @type string $date_to End date filter
68 * }
69 * @return array {data: array, total: int, page: int, per_page: int, total_pages: int}
70 */
71 public function paginate(array $filters = []): array
72 {
73 $table = $this->getTableName();
74 $trips_table = $this->getTripsTable();
75 $customers_table = \Yatra\Database\Tables\CustomersTable::getTableName();
76
77 // Pagination
78 $page = max(1, (int)($filters['page'] ?? 1));
79 $per_page = max(1, min(100, (int)($filters['per_page'] ?? 20)));
80 $offset = ($page - 1) * $per_page;
81
82 // Build WHERE clause
83 $where_clauses = ['1=1'];
84 $where_values = [];
85
86 if (!empty($filters['status'])) {
87 $where_clauses[] = 'b.status = %s';
88 $where_values[] = sanitize_text_field($filters['status']);
89 }
90
91 if (!empty($filters['payment_status'])) {
92 $where_clauses[] = 'b.payment_status = %s';
93 $where_values[] = sanitize_text_field($filters['payment_status']);
94 }
95
96 if (!empty($filters['trip_id'])) {
97 $where_clauses[] = 'b.trip_id = %d';
98 $where_values[] = (int)$filters['trip_id'];
99 }
100
101 if (!empty($filters['search'])) {
102 $search_like = '%' . $this->wpdb->esc_like(sanitize_text_field($filters['search'])) . '%';
103 $where_clauses[] = '(b.reference LIKE %s OR b.contact_email LIKE %s OR b.contact_first_name LIKE %s OR b.contact_last_name LIKE %s OR b.contact_phone LIKE %s)';
104 $where_values = array_merge($where_values, [$search_like, $search_like, $search_like, $search_like, $search_like]);
105 }
106
107 if (!empty($filters['date_from'])) {
108 $where_clauses[] = 'b.travel_date >= %s';
109 $where_values[] = sanitize_text_field($filters['date_from']);
110 }
111
112 if (!empty($filters['date_to'])) {
113 $where_clauses[] = 'b.travel_date <= %s';
114 $where_values[] = sanitize_text_field($filters['date_to']);
115 }
116
117 $where_sql = implode(' AND ', $where_clauses);
118
119 // Get total count
120 $count_query = "SELECT COUNT(*) FROM {$table} b WHERE {$where_sql}";
121 if (!empty($where_values)) {
122 $count_query = $this->wpdb->prepare($count_query, ...$where_values);
123 }
124 $total = (int)$this->wpdb->get_var($count_query);
125
126 // Resolve the sort column from a strict whitelist. ORDER BY cannot be
127 // parameterised, so the column MUST come from this known-safe map and the
128 // direction is constrained to ASC/DESC — user input never touches the SQL.
129 $sortColumns = [
130 'booking_number' => 'b.id',
131 'reference' => 'b.reference',
132 'customer' => 'b.contact_first_name',
133 'trip' => 't.title',
134 'travelers' => 'b.travelers_count',
135 'booking_date' => 'b.created_at',
136 'created_at' => 'b.created_at',
137 'travel_date' => 'b.travel_date',
138 'amount' => 'b.total_amount',
139 'total_amount' => 'b.total_amount',
140 'payment_status' => 'b.payment_status',
141 'booking_status' => 'b.status',
142 'status' => 'b.status',
143 ];
144 $orderColumn = $sortColumns[(string) ($filters['orderby'] ?? '')] ?? 'b.created_at';
145 $orderDir = strtoupper((string) ($filters['order'] ?? '')) === 'ASC' ? 'ASC' : 'DESC';
146 $order_sql = $orderColumn . ' ' . $orderDir . ', b.id DESC';
147
148 // Get bookings with trip info and customer info
149 $query = "SELECT
150 b.*,
151 t.title as trip_title,
152 t.slug as trip_slug,
153 t.featured_image,
154 c.first_name AS customer_first_name,
155 c.last_name AS customer_last_name,
156 c.email AS customer_email
157 FROM {$table} b
158 LEFT JOIN {$trips_table} t ON b.trip_id = t.id
159 LEFT JOIN {$customers_table} c ON c.id = b.customer_id
160 WHERE {$where_sql}
161 ORDER BY {$order_sql}
162 LIMIT %d OFFSET %d";
163
164 $query_values = array_merge($where_values, [$per_page, $offset]);
165 $bookings = $this->wpdb->get_results($this->wpdb->prepare($query, ...$query_values));
166
167 return [
168 'data' => $bookings ?: [],
169 'total' => $total,
170 'page' => $page,
171 'per_page' => $per_page,
172 'total_pages' => (int)ceil($total / $per_page),
173 ];
174 }
175
176 /**
177 * Find booking by ID with trip info
178 *
179 * @param int $id Booking ID
180 * @return object|null
181 */
182 public function findWithTrip(int $id): ?object
183 {
184 $table = $this->getTableName();
185 $trips_table = $this->getTripsTable();
186
187 $query = $this->wpdb->prepare(
188 "SELECT b.*, t.title as trip_title, t.slug as trip_slug, t.featured_image
189 FROM {$table} b
190 LEFT JOIN {$trips_table} t ON b.trip_id = t.id
191 WHERE b.id = %d",
192 $id
193 );
194
195 return $this->wpdb->get_row($query) ?: null;
196 }
197
198 /**
199 * Find booking by reference code
200 *
201 * @param string $reference Booking reference
202 * @return object|null
203 */
204 public function findByReference(string $reference): ?object
205 {
206 $table = $this->getTableName();
207
208 $query = $this->wpdb->prepare(
209 "SELECT * FROM {$table} WHERE reference = %s",
210 sanitize_text_field($reference)
211 );
212
213 $row = $this->wpdb->get_row($query);
214 if (defined('WP_DEBUG') && WP_DEBUG) {
215 }
216 return $row ?: null;
217 }
218
219 /**
220 * Resolve a booking for the confirmation page: reference string, or numeric primary key (legacy ?booking_id= / Stripe fallback).
221 */
222 public function findByConfirmationSegment(string $segment): ?object
223 {
224 $segment = trim(sanitize_text_field($segment));
225 if ($segment === '') {
226 return null;
227 }
228
229 $byRef = $this->findByReferenceWithTrip($segment) ?: $this->findByReference($segment);
230 if ($byRef !== null) {
231 return $byRef;
232 }
233
234 if (ctype_digit($segment)) {
235 $id = (int) $segment;
236
237 return $this->findWithTrip($id) ?: $this->find($id);
238 }
239
240 return null;
241 }
242
243 /**
244 * Find booking by reference with trip data
245 *
246 * @param string $reference Booking reference
247 * @return object|null
248 */
249 public function findByReferenceWithTrip(string $reference): ?object
250 {
251 $table = $this->getTableName();
252
253 // Use TripRepository for trips table
254 $tripRepository = new \Yatra\Repositories\TripRepository();
255 $tripsTable = $tripRepository->getTableName();
256
257 $tripClassificationTable = TripClassificationsTable::getTableName();
258 $classificationTable = ClassificationsTable::getTableName();
259 $reviewsTable = ReviewsTable::getTableName();
260
261 $joins = [];
262 $selectParts = [
263 "b.*, t.title as trip_title, t.slug as trip_slug, t.featured_image,
264 t.duration_days, t.duration_nights, t.difficulty_level,
265 t.starting_location, t.ending_location"
266 ];
267
268 // Hour-based day tours (3.0.14+ column) — guarded so an install whose
269 // upgrade ALTER has not run yet keeps rendering the confirmation page.
270 if ($tripRepository->hasTripColumn('duration_hours')) {
271 $selectParts[] = 't.duration_hours';
272 }
273
274 $joins[] = "LEFT JOIN {$tripClassificationTable} tc ON tc.trip_id = t.id";
275 $joins[] = "LEFT JOIN {$classificationTable} cls ON cls.id = tc.classification_id";
276 $selectParts[] = "GROUP_CONCAT(DISTINCT cls.name ORDER BY tc.`sort_order` SEPARATOR ',') as trip_classifications";
277
278 $joins[] = "LEFT JOIN {$reviewsTable} rv ON rv.trip_id = t.id AND rv.status = 'approved'";
279 $selectParts[] = "AVG(rv.rating) as trip_average_rating";
280 $selectParts[] = "COUNT(DISTINCT CASE WHEN rv.status = 'approved' THEN rv.id END) as trip_review_count";
281
282 $selectSql = implode(",\n ", $selectParts);
283 $joinsSql = implode("\n ", $joins);
284
285 $query = $this->wpdb->prepare(
286 "SELECT {$selectSql}
287 FROM {$table} b
288 LEFT JOIN {$tripsTable} t ON b.trip_id = t.id
289 {$joinsSql}
290 WHERE b.reference = %s
291 GROUP BY b.id
292 LIMIT 1",
293 sanitize_text_field($reference)
294 );
295
296
297 $row = $this->wpdb->get_row($query);
298
299 return $row ?: null;
300 }
301
302 /**
303 * Find bookings by customer ID
304 *
305 * @param int $customerId Customer ID
306 * @param int $limit Limit results
307 * @return array
308 */
309 public function findByCustomerId(int $customerId, int $limit = 10): array
310 {
311 $table = $this->getTableName();
312 $trips_table = $this->getTripsTable();
313
314 $query = $this->wpdb->prepare(
315 "SELECT b.*, t.title as trip_title
316 FROM {$table} b
317 LEFT JOIN {$trips_table} t ON b.trip_id = t.id
318 WHERE b.customer_id = %d
319 ORDER BY b.created_at DESC
320 LIMIT %d",
321 $customerId,
322 $limit
323 );
324
325 return $this->wpdb->get_results($query) ?: [];
326 }
327
328 /**
329 * Find bookings by user ID (WordPress user)
330 *
331 * @param int $userId WordPress user ID
332 * @param int $limit Limit results
333 * @return array
334 */
335 public function findByUserId(int $userId, int $limit = 10): array
336 {
337 $table = $this->getTableName();
338 $trips_table = $this->getTripsTable();
339
340 $query = $this->wpdb->prepare(
341 "SELECT b.*, t.title as trip_title
342 FROM {$table} b
343 LEFT JOIN {$trips_table} t ON b.trip_id = t.id
344 WHERE b.user_id = %d
345 ORDER BY b.created_at DESC
346 LIMIT %d",
347 $userId,
348 $limit
349 );
350
351 return $this->wpdb->get_results($query) ?: [];
352 }
353
354 /**
355 * Find bookings by contact email
356 *
357 * @param string $email Contact email
358 * @param int $limit Limit results
359 * @return array
360 */
361 public function findByContactEmail(string $email, int $limit = 10): array
362 {
363 $table = $this->getTableName();
364 $trips_table = $this->getTripsTable();
365
366 $query = $this->wpdb->prepare(
367 "SELECT b.*, t.title as trip_title
368 FROM {$table} b
369 LEFT JOIN {$trips_table} t ON b.trip_id = t.id
370 WHERE b.contact_email = %s
371 ORDER BY b.created_at DESC
372 LIMIT %d",
373 sanitize_email($email),
374 $limit
375 );
376
377 return $this->wpdb->get_results($query) ?: [];
378 }
379
380 /**
381 * Create a new booking
382 *
383 * @param array $data Booking data
384 * @return int Booking ID on success
385 * @throws \Exception on failure
386 */
387 public function create(array $data): int
388 {
389 $table = $this->getTableName();
390
391 // Sanitize and prepare data
392 $insertData = $this->prepareBookingData($data);
393 $insertData['created_at'] = current_time('mysql');
394 $insertData['updated_at'] = current_time('mysql');
395
396 // Check which columns exist and remove non-existent ones
397 $columns = $this->wpdb->get_col("DESCRIBE {$table}");
398 $hasStartDate = in_array('start_date', $columns, true);
399 $hasEndDate = in_array('end_date', $columns, true);
400
401 if (!$hasStartDate && isset($insertData['start_date'])) {
402 unset($insertData['start_date']);
403 }
404 if (!$hasEndDate && isset($insertData['end_date'])) {
405 unset($insertData['end_date']);
406 }
407 if (!in_array('meta', $columns, true) && isset($insertData['meta'])) {
408 unset($insertData['meta']);
409 }
410
411 $result = $this->wpdb->insert($table, $insertData);
412
413 if ($result === false) {
414 throw new \Exception('Failed to create booking: ' . $this->wpdb->last_error);
415 }
416
417 $newId = (int) $this->wpdb->insert_id;
418 $this->afterWrite('create', $newId, []);
419
420 return $newId;
421 }
422
423 /**
424 * Update a booking
425 *
426 * @param int $id Booking ID
427 * @param array $data Booking data to update
428 * @return bool
429 */
430 public function update(int $id, array $data): bool
431 {
432 $table = $this->getTableName();
433
434 // Sanitize and prepare data
435 $updateData = $this->prepareBookingData($data);
436 $updateData['updated_at'] = current_time('mysql');
437
438 // Check which columns exist and remove non-existent ones
439 $columns = $this->wpdb->get_col("DESCRIBE {$table}");
440 $hasStartDate = in_array('start_date', $columns, true);
441 $hasEndDate = in_array('end_date', $columns, true);
442
443 if (!$hasStartDate && isset($updateData['start_date'])) {
444 unset($updateData['start_date']);
445 }
446 if (!$hasEndDate && isset($updateData['end_date'])) {
447 unset($updateData['end_date']);
448 }
449 if (!in_array('meta', $columns, true) && isset($updateData['meta'])) {
450 unset($updateData['meta']);
451 }
452
453 if (empty($updateData)) {
454 return false;
455 }
456
457 $result = $this->wpdb->update(
458 $table,
459 $updateData,
460 ['id' => $id],
461 null,
462 ['%d']
463 );
464
465 if ($result !== false) {
466 $this->afterWrite('update', $id, []);
467 }
468
469 return $result !== false;
470 }
471
472 /**
473 * Update booking status
474 *
475 * @param int $id Booking ID
476 * @param string $status New status
477 * @return bool
478 */
479 public function updateStatus(int $id, string $status): bool
480 {
481 $table = $this->getTableName();
482
483 $data = [
484 'status' => sanitize_text_field($status),
485 'updated_at' => current_time('mysql'),
486 ];
487
488 // Set confirmed_at if confirming
489 if ($status === 'confirmed') {
490 $data['confirmed_at'] = current_time('mysql');
491 }
492
493 // Set completed_at if completing
494 if ($status === 'completed') {
495 $data['completed_at'] = current_time('mysql');
496 }
497
498 // Set cancelled info if cancelling
499 if ($status === 'cancelled') {
500 $data['cancelled_at'] = current_time('mysql');
501 $data['cancelled_by'] = get_current_user_id();
502 }
503
504 $result = $this->wpdb->update($table, $data, ['id' => $id]);
505
506 if ($result !== false) {
507 $this->afterWrite('update', $id, []);
508 }
509
510 return $result !== false;
511 }
512
513 /**
514 * Update payment status
515 *
516 * @param int $id Booking ID
517 * @param string $status New payment status
518 * @return bool
519 */
520 public function updatePaymentStatus(int $id, string $status): bool
521 {
522 $table = $this->getTableName();
523
524 $result = $this->wpdb->update(
525 $table,
526 [
527 'payment_status' => sanitize_text_field($status),
528 'updated_at' => current_time('mysql'),
529 ],
530 ['id' => $id]
531 );
532
533 if ($result !== false) {
534 $this->afterWrite('update', $id, []);
535 }
536
537 return $result !== false;
538 }
539
540 /**
541 * Update amount paid
542 *
543 * @param int $id Booking ID
544 * @param float $amountPaid New amount paid
545 * @return bool
546 */
547 public function updateAmountPaid(int $id, float $amountPaid): bool
548 {
549 $table = $this->getTableName();
550
551 // Get booking to calculate amount due
552 $booking = $this->find($id);
553 if (!$booking) {
554 return false;
555 }
556
557 $amountDue = max(0, (float)$booking->total_amount - $amountPaid);
558 $paymentStatus = $amountDue <= 0 ? 'paid' : ($amountPaid > 0 ? 'partial' : 'pending');
559
560 $result = $this->wpdb->update(
561 $table,
562 [
563 'amount_paid' => $amountPaid,
564 'amount_due' => $amountDue,
565 'payment_status' => $paymentStatus,
566 'updated_at' => current_time('mysql'),
567 ],
568 ['id' => $id]
569 );
570
571 if ($result !== false) {
572 $this->afterWrite('update', $id, []);
573 }
574
575 return $result !== false;
576 }
577
578 /**
579 * Delete a booking
580 *
581 * @param int $id Booking ID
582 * @return bool
583 */
584 public function delete(int $id): bool
585 {
586 $table = $this->getTableName();
587
588 $result = $this->wpdb->delete($table, ['id' => $id], ['%d']);
589
590 if ($result !== false) {
591 $this->afterWrite('delete', $id, []);
592 }
593
594 return $result !== false;
595 }
596
597 /**
598 * Invalidate booking-related caches after repository writes.
599 *
600 * @param 'create'|'update'|'delete' $operation
601 */
602 protected function afterWrite(string $operation, int $id, array $context = []): void
603 {
604 Cache::invalidateAfterBookingWrite($id);
605 if ($operation === 'update') {
606 do_action('yatra_booking_updated', $id);
607 }
608 }
609
610 /**
611 * Get booking statistics
612 *
613 * @return array
614 */
615 public function getStats(): array
616 {
617 $table = $this->getTableName();
618
619 // Total bookings by status
620 $statusStatsRaw = $this->wpdb->get_results(
621 "SELECT status, COUNT(*) as count FROM {$table} GROUP BY status",
622 OBJECT_K
623 );
624
625 // Normalize by_status with integer counts and default buckets
626 $byStatus = [
627 'pending' => (object) ['status' => 'pending', 'count' => 0],
628 'confirmed' => (object) ['status' => 'confirmed', 'count' => 0],
629 'cancelled' => (object) ['status' => 'cancelled', 'count' => 0],
630 'completed' => (object) ['status' => 'completed', 'count' => 0],
631 'processing' => (object) ['status' => 'processing', 'count' => 0],
632 'refunded' => (object) ['status' => 'refunded', 'count' => 0],
633 'failed' => (object) ['status' => 'failed', 'count' => 0],
634 'on_hold' => (object) ['status' => 'on_hold', 'count' => 0],
635 'waitlist' => (object) ['status' => 'waitlist', 'count' => 0],
636 'trash' => (object) ['status' => 'trash', 'count' => 0],
637 ];
638
639 foreach ((array)$statusStatsRaw as $status => $row) {
640 $count = isset($row->count) ? (int)$row->count : 0;
641 if (isset($byStatus[$status])) {
642 $byStatus[$status]->count = $count;
643 } else {
644 // keep unexpected statuses too
645 $byStatus[$status] = (object) ['status' => $status, 'count' => $count];
646 }
647 }
648
649 // Total revenue (exclude non-revenue / non-active states)
650 $totalRevenue = (float)$this->wpdb->get_var(
651 "SELECT SUM(total_amount) FROM {$table} WHERE status NOT IN ('cancelled', 'refunded', 'failed', 'waitlist')"
652 );
653
654 // Total collected
655 $totalCollected = (float)$this->wpdb->get_var(
656 "SELECT SUM(amount_paid) FROM {$table} WHERE status NOT IN ('cancelled', 'refunded', 'failed', 'waitlist')"
657 );
658
659 // This month bookings
660 $thisMonth = $this->wpdb->get_var($this->wpdb->prepare(
661 "SELECT COUNT(*) FROM {$table} WHERE created_at >= %s",
662 date('Y-m-01 00:00:00')
663 ));
664
665 // Upcoming trips
666 $upcoming = $this->wpdb->get_var($this->wpdb->prepare(
667 "SELECT COUNT(*) FROM {$table} WHERE travel_date >= %s AND status IN ('confirmed', 'pending')",
668 date('Y-m-d')
669 ));
670
671 $allBookings = (int) array_sum(array_map(static function ($row) {
672 return isset($row->count) ? (int) $row->count : 0;
673 }, (array) $byStatus));
674
675 $byStatusForApi = [];
676 foreach ($byStatus as $key => $row) {
677 $byStatusForApi[$key] = [
678 'status' => $row->status ?? $key,
679 'count' => isset($row->count) ? (int) $row->count : 0,
680 ];
681 }
682
683 // Normalize counts for UI expectations (admin list + dashboard)
684 return [
685 'all' => $allBookings,
686 'total' => $allBookings,
687 'confirmed' => (int) ($byStatus['confirmed']->count ?? 0),
688 'pending' => (int) ($byStatus['pending']->count ?? 0),
689 'waitlist' => (int) ($byStatus['waitlist']->count ?? 0),
690 'trash' => (int) ($byStatus['trash']->count ?? 0),
691 'cancelled' => (int) ($byStatus['cancelled']->count ?? 0),
692 'completed' => (int) ($byStatus['completed']->count ?? 0),
693 'by_status' => $byStatusForApi,
694 'total_revenue' => $totalRevenue,
695 'total_collected' => $totalCollected,
696 'this_month' => (int) $thisMonth,
697 'upcoming' => (int) $upcoming,
698 ];
699 }
700
701 /**
702 * Generate unique booking reference
703 *
704 * @return string
705 */
706 public function generateReference(): string
707 {
708 $table = $this->getTableName();
709
710 do {
711 $reference = 'YTR-' . strtoupper(substr(md5(uniqid((string)mt_rand(), true)), 0, 8));
712 $exists = $this->wpdb->get_var($this->wpdb->prepare(
713 "SELECT COUNT(*) FROM {$table} WHERE reference = %s",
714 $reference
715 ));
716 } while ($exists > 0);
717
718 return $reference;
719 }
720
721 /**
722 * Update customer ID for all bookings (used for merging customers)
723 *
724 * @param int $fromCustomerId Source customer ID
725 * @param int $toCustomerId Target customer ID
726 * @return int Number of affected rows
727 */
728 public function updateCustomerBookings(int $fromCustomerId, int $toCustomerId): int
729 {
730 $table = $this->getTableName();
731
732 $this->wpdb->update(
733 $table,
734 ['customer_id' => $toCustomerId],
735 ['customer_id' => $fromCustomerId],
736 ['%d'],
737 ['%d']
738 );
739
740 return (int)$this->wpdb->rows_affected;
741 }
742
743 /**
744 * Get bookings for reminder emails
745 *
746 * @param string $travelDate Target travel date
747 * @return array
748 */
749 public function getBookingsForReminder(string $travelDate): array
750 {
751 $table = $this->getTableName();
752
753 // Use TripRepository for trips table
754 $tripRepository = new \Yatra\Repositories\TripRepository();
755 $tripsTable = $tripRepository->getTableName();
756
757 // Confirmed bookings, plus PENDING bookings that have paid something
758 // (a deposit). Under the Auto-Confirm `online` mode a deposit booking
759 // legitimately stays pending until the balance is paid; those customers
760 // are real travellers and must still get the pre-trip reminder — which
761 // already carries the "outstanding balance, please pay before travel"
762 // block for exactly this case. Unpaid pending bookings stay excluded.
763 //
764 // No `t.currency`: the trips table has never had that column, so the
765 // previous SELECT threw "Unknown column" — get_results() then returned
766 // nothing and the reminder cron silently sent zero emails. The booking's
767 // own currency arrives via b.* (and is no longer clobbered by the join).
768 return $this->wpdb->get_results($this->wpdb->prepare(
769 "SELECT b.*, t.title as trip_title
770 FROM {$table} b
771 LEFT JOIN {$tripsTable} t ON b.trip_id = t.id
772 WHERE (b.status = 'confirmed' OR (b.status = 'pending' AND b.amount_paid > 0))
773 AND b.travel_date = %s
774 AND b.reminder_sent = 0",
775 $travelDate
776 ));
777 }
778
779 /**
780 * Find IDs of confirmed bookings whose tour has already taken place, so the
781 * daily cron can transition them to 'completed' (which fires the
782 * booking.completed notification / Email Automation sequence).
783 *
784 * "Tour has taken place" = its effective end date is strictly before today.
785 * The effective date prefers end_date (multi-day itineraries), then
786 * start_date, then travel_date (which is NOT NULL). NULLIF guards against
787 * any legacy zero-dates so they fall through to the next non-empty date.
788 *
789 * The $floor lower bound (feature-activation date) ensures we never
790 * retroactively complete — and email the customers of — tours that ended
791 * before this automation existed. Only 'confirmed' bookings are eligible:
792 * pending/on_hold/waitlist never travelled, and cancelled/refunded/completed
793 * are terminal.
794 *
795 * @param string $today Today (WP-local, 'Y-m-d') — exclusive upper bound.
796 * @param string $floor Activation floor ('Y-m-d') — inclusive lower bound.
797 * @param int $limit Max rows per run (drains any backlog over days).
798 * @return int[]
799 */
800 public function getConfirmedBookingIdsPastTour(string $today, string $floor, int $limit = 500): array
801 {
802 $table = $this->getTableName();
803
804 $effectiveDate = "COALESCE(NULLIF(b.end_date, '0000-00-00'), "
805 . "NULLIF(b.start_date, '0000-00-00'), b.travel_date)";
806
807 $ids = $this->wpdb->get_col($this->wpdb->prepare(
808 "SELECT b.id
809 FROM {$table} b
810 WHERE b.status = 'confirmed'
811 AND {$effectiveDate} < %s
812 AND {$effectiveDate} >= %s
813 ORDER BY b.id ASC
814 LIMIT %d",
815 $today,
816 $floor,
817 $limit
818 ));
819
820 return array_map('intval', (array) $ids);
821 }
822
823 /**
824 * Mark booking reminder as sent
825 *
826 * @param int $bookingId Booking ID
827 * @return bool
828 */
829 public function markReminderSent(int $bookingId): bool
830 {
831 $table = $this->getTableName();
832
833 $result = $this->wpdb->update(
834 $table,
835 [
836 'reminder_sent' => 1,
837 'reminder_sent_at' => current_time('mysql'),
838 ],
839 ['id' => $bookingId],
840 ['%d', '%s'],
841 ['%d']
842 );
843
844 return $result !== false;
845 }
846
847 /**
848 * Get expired pending bookings
849 *
850 * @param string $expiryThreshold Datetime threshold
851 * @return array
852 */
853 /**
854 * Unpaid bookings past the expiry threshold.
855 *
856 * @param string $expiryThreshold Bookings created before this are expired.
857 * @param string $createdSince Activation floor: when set, bookings created
858 * before it are never expired. Keeps a site
859 * that switches the feature on from
860 * retroactively cancelling (and emailing about)
861 * its historical pending bookings.
862 * @param int $limit Batch size. The sweep emails each customer,
863 * so an unbounded run could try to send
864 * hundreds of emails in one cron request and
865 * time out half-way. It runs hourly, so a
866 * backlog simply drains over the next runs.
867 * @return array<int, object>
868 */
869 public function getExpiredPendingBookings(string $expiryThreshold, string $createdSince = '', int $limit = 200): array
870 {
871 $table = $this->getTableName();
872
873 $sql = "SELECT id, reference, contact_email, contact_first_name, contact_last_name, trip_id
874 FROM {$table}
875 WHERE status = 'pending'
876 AND payment_status = 'pending'
877 AND created_at < %s";
878 $params = [$expiryThreshold];
879
880 if ($createdSince !== '') {
881 $sql .= ' AND created_at >= %s';
882 $params[] = $createdSince;
883 }
884
885 $sql .= ' ORDER BY created_at ASC LIMIT %d';
886 $params[] = max(1, $limit);
887
888 return $this->wpdb->get_results($this->wpdb->prepare($sql, $params));
889 }
890
891 /**
892 * Expire a booking
893 *
894 * @param int $bookingId Booking ID
895 * @param string $reason Cancellation reason
896 * @return bool
897 */
898 public function expireBooking(int $bookingId, string $reason): bool
899 {
900 $table = $this->getTableName();
901
902 $result = $this->wpdb->update(
903 $table,
904 [
905 'status' => 'cancelled',
906 'cancellation_reason' => $reason,
907 'cancelled_at' => current_time('mysql'),
908 'updated_at' => current_time('mysql'),
909 ],
910 ['id' => $bookingId],
911 ['%s', '%s', '%s', '%s'],
912 ['%d']
913 );
914
915 return $result !== false;
916 }
917
918 /**
919 * Update payment session ID for a booking
920 *
921 * @param int $bookingId Booking ID
922 * @param string $sessionId Payment session ID from gateway
923 * @return bool
924 */
925 public function updatePaymentSessionId(int $bookingId, string $sessionId): bool
926 {
927 $table = $this->getTableName();
928
929 $result = $this->wpdb->update(
930 $table,
931 ['payment_session_id' => sanitize_text_field($sessionId)],
932 ['id' => $bookingId],
933 ['%s'],
934 ['%d']
935 );
936
937 return $result !== false;
938 }
939
940 /**
941 * Prepare booking data for insert/update
942 *
943 * @param array $data Raw data
944 * @return array Sanitized data
945 */
946 private function prepareBookingData(array $data): array
947 {
948 $prepared = [];
949
950 $stringFields = [
951 'reference', 'contact_first_name', 'contact_last_name', 'contact_email',
952 'contact_phone', 'contact_country', 'status', 'payment_status', 'payment_method',
953 'payment_gateway', 'currency', 'discount_code', 'special_requests', 'internal_notes',
954 'ip_address', 'payment_session_id', 'payment_transaction_id', 'cancellation_reason',
955 ];
956
957 $intFields = ['trip_id', 'customer_id', 'user_id', 'travelers_count', 'cancelled_by', 'availability_id'];
958
959 $floatFields = ['total_amount', 'amount_paid', 'amount_due', 'discount_amount', 'subtotal', 'tax_amount', 'tax_rate', 'itinerary_costs_total'];
960
961 $boolFields = ['newsletter_optin', 'terms_accepted', 'reminder_sent', 'tax_inclusive'];
962
963 $jsonFields = ['contact_data', 'emergency_contact', 'tax_details', 'itinerary_costs', 'meta'];
964
965 $dateFields = ['travel_date', 'start_date', 'end_date', 'payment_date', 'cancelled_at', 'confirmed_at', 'completed_at', 'reminder_sent_at'];
966
967 foreach ($stringFields as $field) {
968 if (array_key_exists($field, $data)) {
969 $prepared[$field] = sanitize_text_field((string)$data[$field]);
970 }
971 }
972
973 foreach ($intFields as $field) {
974 if (array_key_exists($field, $data)) {
975 $prepared[$field] = $data[$field] === null ? null : (int)$data[$field];
976 }
977 }
978
979 foreach ($floatFields as $field) {
980 if (array_key_exists($field, $data)) {
981 $prepared[$field] = (float)$data[$field];
982 }
983 }
984
985 foreach ($boolFields as $field) {
986 if (array_key_exists($field, $data)) {
987 $prepared[$field] = $data[$field] ? 1 : 0;
988 }
989 }
990
991 foreach ($jsonFields as $field) {
992 if (array_key_exists($field, $data)) {
993 $prepared[$field] = is_string($data[$field]) ? $data[$field] : wp_json_encode($data[$field]);
994 }
995 }
996
997 foreach ($dateFields as $field) {
998 if (array_key_exists($field, $data) && $data[$field]) {
999 $prepared[$field] = sanitize_text_field($data[$field]);
1000 }
1001 }
1002
1003 // Calculate end_date if start_date is provided but end_date is not
1004 if (isset($prepared['start_date']) && !isset($prepared['end_date']) && !empty($prepared['trip_id'])) {
1005 $prepared['end_date'] = $this->calculateEndDate($prepared['start_date'], (int)$prepared['trip_id']);
1006 }
1007
1008 // Sync travel_date with start_date if start_date is provided
1009 if (isset($prepared['start_date']) && !isset($prepared['travel_date'])) {
1010 $prepared['travel_date'] = $prepared['start_date'];
1011 }
1012
1013 // Check if start_date and end_date columns exist before including them
1014 // If columns don't exist, only use travel_date (backward compatibility)
1015 $table = $this->getTableName();
1016 $columns = $this->wpdb->get_col("DESCRIBE {$table}");
1017
1018 if (!in_array('start_date', $columns, true)) {
1019 unset($prepared['start_date']);
1020 }
1021 if (!in_array('end_date', $columns, true)) {
1022 unset($prepared['end_date']);
1023 }
1024 if (!in_array('meta', $columns, true)) {
1025 unset($prepared['meta']);
1026 }
1027
1028 if (array_key_exists('user_agent', $data)) {
1029 $prepared['user_agent'] = sanitize_textarea_field((string)$data['user_agent']);
1030 }
1031
1032 if (array_key_exists('payment_notes', $data)) {
1033 $prepared['payment_notes'] = sanitize_textarea_field((string)$data['payment_notes']);
1034 }
1035
1036 return $prepared;
1037 }
1038
1039 /**
1040 * Calculate end date from start date and trip duration
1041 *
1042 * @param string $startDate Start date (YYYY-MM-DD)
1043 * @param int $tripId Trip ID
1044 * @return string End date (YYYY-MM-DD)
1045 */
1046 public function calculateEndDate(string $startDate, int $tripId): string
1047 {
1048 // Use TripRepository for trips table
1049 $tripRepository = new \Yatra\Repositories\TripRepository();
1050 $tripsTable = $tripRepository->getTableName();
1051
1052 $durationDays = $this->wpdb->get_var($this->wpdb->prepare(
1053 "SELECT duration_days FROM {$tripsTable} WHERE id = %d LIMIT 1",
1054 $tripId
1055 ));
1056
1057 $durationDays = $durationDays ? (int)$durationDays : 1;
1058
1059 // end_date = start_date + (duration_days - 1) days
1060 // Example: 5-day trip starting Jan 1 = Jan 1 + 4 days = Jan 5
1061 $endDate = date('Y-m-d', strtotime($startDate . ' + ' . ($durationDays - 1) . ' days'));
1062
1063 return $endDate;
1064 }
1065
1066 /**
1067 * Get table columns for booking table
1068 *
1069 * @return array Array of column names
1070 */
1071 public function getTableColumns(): array
1072 {
1073 $table = $this->getTableName();
1074 return $this->wpdb->get_col("DESCRIBE {$table}");
1075 }
1076
1077 /**
1078 * Count discount code usage by customer
1079 *
1080 * @param int $customerId Customer ID
1081 * @param string $discountCode Discount code
1082 * @return int Number of times discount code has been used
1083 */
1084 public function countDiscountCodeUsage(int $customerId, string $discountCode): int
1085 {
1086 $table = $this->getTableName();
1087 return (int)$this->wpdb->get_var($this->wpdb->prepare(
1088 "SELECT COUNT(*) FROM {$table} WHERE customer_id = %d AND discount_code = %s AND status NOT IN ('cancelled', 'refunded', 'failed')",
1089 $customerId,
1090 $discountCode
1091 ));
1092 }
1093
1094 /**
1095 * Booking statuses that consume seats on a dated availability row (must match {@see AvailabilityInventoryHooks}).
1096 *
1097 * @return list<string>
1098 */
1099 public static function getCapacityConsumingBookingStatuses(): array
1100 {
1101 $default = ['pending', 'confirmed', 'processing', 'completed', 'on_hold'];
1102 /** @var list<string> $default */
1103 $filtered = apply_filters('yatra_capacity_consuming_booking_statuses', $default);
1104 return is_array($filtered) && $filtered !== [] ? array_values(array_unique(array_map('strval', $filtered))) : $default;
1105 }
1106
1107 /**
1108 * Count booked travelers for a virtual (rule-generated) slot.
1109 *
1110 * Rule-generated dates do not have a numeric availability_id, so capacity must be
1111 * computed from bookings by (trip_id, travel_date, departure_time) and the same
1112 * capacity-consuming statuses used for manual availability rows.
1113 */
1114 public function countActiveSeatsForSlot(int $tripId, string $travelDate, ?string $departureTime = null): int
1115 {
1116 if ($tripId <= 0 || $travelDate === '') {
1117 return 0;
1118 }
1119
1120 $bookingsTable = esc_sql($this->getTableName());
1121 $statuses = self::getCapacityConsumingBookingStatuses();
1122 $stPh = implode(',', array_fill(0, count($statuses), '%s'));
1123
1124 // Use booking_departures when we need time-slot precision.
1125 $relationTable = esc_sql(\Yatra\Database\Tables\BookingDeparturesTable::getTableName());
1126
1127 if ($departureTime !== null && $departureTime !== '') {
1128 // Normalize to match TIME storage in MySQL (HH:MM:SS).
1129 $ts = strtotime($departureTime);
1130 if ($ts !== false) {
1131 $departureTime = date('H:i:s', $ts);
1132 }
1133 $params = array_merge([$tripId, $travelDate, $departureTime], $statuses);
1134
1135 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1136 $count = (int) $this->wpdb->get_var($this->wpdb->prepare(
1137 "SELECT COALESCE(SUM(b.travelers_count), 0)
1138 FROM `{$bookingsTable}` b
1139 INNER JOIN `{$relationTable}` bd ON b.id = bd.booking_id
1140 WHERE b.trip_id = %d
1141 AND b.travel_date = %s
1142 AND bd.travel_date = %s
1143 AND bd.departure_time = %s
1144 AND b.status IN ({$stPh})",
1145 array_merge([$tripId, $travelDate, $travelDate, $departureTime], $statuses)
1146 ));
1147 /** @var int $count */
1148 $count = (int) apply_filters('yatra_virtual_availability_reserved_seats', $count, $tripId, $travelDate, $departureTime, $statuses);
1149 return max(0, $count);
1150 }
1151
1152 // No time-slot filter: sum all bookings for the trip/date.
1153 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1154 $count = (int) $this->wpdb->get_var($this->wpdb->prepare(
1155 "SELECT COALESCE(SUM(travelers_count), 0)
1156 FROM `{$bookingsTable}`
1157 WHERE trip_id = %d
1158 AND travel_date = %s
1159 AND status IN ({$stPh})",
1160 array_merge([$tripId, $travelDate], $statuses)
1161 ));
1162 $count = (int) apply_filters('yatra_virtual_availability_reserved_seats', $count, $tripId, $travelDate, null, $statuses);
1163 return max(0, $count);
1164 }
1165
1166 /**
1167 * Count booked travelers by availability ID
1168 *
1169 * @param int $availabilityId Availability ID
1170 * @return int Number of booked travelers
1171 */
1172 public function countBookedTravelersByAvailabilityId(int $availabilityId): int
1173 {
1174 $table = $this->getTableName();
1175 $statuses = self::getCapacityConsumingBookingStatuses();
1176 $ph = implode(',', array_fill(0, count($statuses), '%s'));
1177 $params = array_merge([$availabilityId], $statuses);
1178
1179 return (int) $this->wpdb->get_var($this->wpdb->prepare(
1180 "SELECT COALESCE(SUM(travelers_count), 0)
1181 FROM {$table}
1182 WHERE availability_id = %d AND status IN ({$ph})",
1183 $params
1184 ));
1185 }
1186
1187 /**
1188 * Get booking counts for multiple availability IDs
1189 *
1190 * @param array $availabilityIds Array of availability IDs
1191 * @return array Array of objects with availability_id and booked_count
1192 */
1193 public function getBookingCountsByAvailabilityIds(array $availabilityIds): array
1194 {
1195 $table = $this->getTableName();
1196
1197 if (empty($availabilityIds)) {
1198 return [];
1199 }
1200
1201 $idPlaceholders = implode(',', array_fill(0, count($availabilityIds), '%d'));
1202 $statuses = self::getCapacityConsumingBookingStatuses();
1203 $stPlaceholders = implode(',', array_fill(0, count($statuses), '%s'));
1204 $params = array_merge($availabilityIds, $statuses);
1205
1206 return $this->wpdb->get_results($this->wpdb->prepare(
1207 "SELECT availability_id, SUM(travelers_count) AS booked_count
1208 FROM {$table}
1209 WHERE availability_id IN ({$idPlaceholders}) AND status IN ({$stPlaceholders})
1210 GROUP BY availability_id",
1211 $params
1212 ));
1213 }
1214
1215 /**
1216 * Update availability ID by trip and date
1217 *
1218 * @param int $tripId Trip ID
1219 * @param string $date Travel date
1220 * @param int $availabilityId Availability ID
1221 * @return int|false Number of rows updated or false on failure
1222 */
1223 public function updateAvailabilityIdByTripAndDate(int $tripId, string $date, int $availabilityId)
1224 {
1225 $table = $this->getTableName();
1226 return $this->wpdb->query(
1227 $this->wpdb->prepare(
1228 "UPDATE {$table}
1229 SET availability_id = %d
1230 WHERE trip_id = %d
1231 AND travel_date = %s
1232 AND (availability_id IS NULL OR availability_id = 0)",
1233 $availabilityId,
1234 $tripId,
1235 $date
1236 )
1237 );
1238 }
1239
1240 /**
1241 * Find bookings by departure ID
1242 *
1243 * @param int $departureId Departure ID
1244 * @return array Array of booking objects
1245 */
1246 public function findByDepartureId(int $departureId): array
1247 {
1248 $table = $this->getTableName();
1249
1250 $relationTable = BookingDeparturesTable::getTableName();
1251
1252 $bookings = $this->wpdb->get_results($this->wpdb->prepare(
1253 "SELECT b.* FROM {$table} b
1254 INNER JOIN {$relationTable} bd ON b.id = bd.booking_id
1255 WHERE bd.departure_id = %d
1256 ORDER BY b.created_at DESC",
1257 $departureId
1258 ));
1259
1260 return $bookings ?: [];
1261 }
1262
1263 /**
1264 * Check if a user has made any previous bookings
1265 *
1266 * @param int $user_id User ID
1267 * @return bool True if user has made at least one booking
1268 */
1269 public function hasUserMadeBooking(int $user_id): bool
1270 {
1271 $count = $this->wpdb->get_var($this->wpdb->prepare(
1272 "SELECT COUNT(*) FROM {$this->table} WHERE customer_id = %d AND status NOT IN ('cancelled', 'refunded', 'failed')",
1273 $user_id
1274 ));
1275
1276 return (int)$count > 0;
1277 }
1278
1279 /**
1280 * Get recent bookings for cache warming
1281 *
1282 * @param int $days Number of days to look back
1283 * @param int $limit Maximum number of bookings to return
1284 * @return array Array of recent booking IDs
1285 */
1286 public function getRecentBookings(int $days = 7, int $limit = 50): array
1287 {
1288 return $this->wpdb->get_results("
1289 SELECT id
1290 FROM {$this->table}
1291 WHERE created_at >= DATE_SUB(NOW(), INTERVAL {$days} DAY)
1292 ORDER BY created_at DESC
1293 LIMIT {$limit}
1294 ") ?: [];
1295 }
1296
1297 /**
1298 * Get total travelers count for a trip and availability with specific statuses
1299 *
1300 * @param int $tripId Trip ID
1301 * @param int $availabilityId Availability ID
1302 * @param array $statuses Array of booking statuses to include
1303 * @return int Total travelers count
1304 */
1305 public function getTotalTravelersByTripAndAvailability(int $tripId, int $availabilityId, array $statuses = []): int
1306 {
1307 $table = esc_sql($this->table);
1308
1309 if (empty($statuses)) {
1310 $statuses = self::getCapacityConsumingBookingStatuses();
1311 }
1312
1313 $placeholders = implode(',', array_fill(0, count($statuses), '%s'));
1314 $params = array_merge([$tripId, $availabilityId], $statuses);
1315
1316 $count = (int)$this->wpdb->get_var($this->wpdb->prepare(
1317 "SELECT COALESCE(SUM(travelers_count), 0)
1318 FROM {$table}
1319 WHERE trip_id = %d
1320 AND availability_id = %d
1321 AND status IN ({$placeholders})",
1322 $params
1323 ));
1324
1325 return $count;
1326 }
1327
1328 /**
1329 * @return list<object>
1330 */
1331 public function findWaitlistBookingsForAvailability(int $availabilityId, int $limit = 20): array
1332 {
1333 if ($availabilityId <= 0) {
1334 return [];
1335 }
1336
1337 $table = esc_sql($this->table);
1338 $limit = max(1, min(100, $limit));
1339
1340 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1341 $rows = $this->wpdb->get_results(
1342 $this->wpdb->prepare(
1343 "SELECT * FROM `{$table}` WHERE availability_id = %d AND status = 'waitlist' ORDER BY created_at ASC, id ASC LIMIT %d",
1344 $availabilityId,
1345 $limit
1346 )
1347 );
1348
1349 return is_array($rows) ? $rows : [];
1350 }
1351
1352 public function getTotalWaitlistTravelersForAvailability(int $availabilityId): int
1353 {
1354 if ($availabilityId <= 0) {
1355 return 0;
1356 }
1357
1358 $table = esc_sql($this->table);
1359
1360 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1361 return (int) $this->wpdb->get_var($this->wpdb->prepare(
1362 "SELECT COALESCE(SUM(travelers_count), 0) FROM `{$table}` WHERE availability_id = %d AND status = 'waitlist'",
1363 $availabilityId
1364 ));
1365 }
1366 }
1367
1368