PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.7
Yatra – Travel Booking & Tour Operator Software v3.0.7
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 2.0.11 All 82 releases
yatra / app / Repositories / BookingRepository.php

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

1,261 lines 40.9 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 // Get bookings with trip info and customer info
127 $query = "SELECT
128 b.*,
129 t.title as trip_title,
130 t.slug as trip_slug,
131 t.featured_image,
132 c.first_name AS customer_first_name,
133 c.last_name AS customer_last_name,
134 c.email AS customer_email
135 FROM {$table} b
136 LEFT JOIN {$trips_table} t ON b.trip_id = t.id
137 LEFT JOIN {$customers_table} c ON c.id = b.customer_id
138 WHERE {$where_sql}
139 ORDER BY b.created_at DESC
140 LIMIT %d OFFSET %d";
141
142 $query_values = array_merge($where_values, [$per_page, $offset]);
143 $bookings = $this->wpdb->get_results($this->wpdb->prepare($query, ...$query_values));
144
145 return [
146 'data' => $bookings ?: [],
147 'total' => $total,
148 'page' => $page,
149 'per_page' => $per_page,
150 'total_pages' => (int)ceil($total / $per_page),
151 ];
152 }
153
154 /**
155 * Find booking by ID with trip info
156 *
157 * @param int $id Booking ID
158 * @return object|null
159 */
160 public function findWithTrip(int $id): ?object
161 {
162 $table = $this->getTableName();
163 $trips_table = $this->getTripsTable();
164
165 $query = $this->wpdb->prepare(
166 "SELECT b.*, t.title as trip_title, t.slug as trip_slug, t.featured_image
167 FROM {$table} b
168 LEFT JOIN {$trips_table} t ON b.trip_id = t.id
169 WHERE b.id = %d",
170 $id
171 );
172
173 return $this->wpdb->get_row($query) ?: null;
174 }
175
176 /**
177 * Find booking by reference code
178 *
179 * @param string $reference Booking reference
180 * @return object|null
181 */
182 public function findByReference(string $reference): ?object
183 {
184 $table = $this->getTableName();
185
186 $query = $this->wpdb->prepare(
187 "SELECT * FROM {$table} WHERE reference = %s",
188 sanitize_text_field($reference)
189 );
190
191 $row = $this->wpdb->get_row($query);
192 if (defined('WP_DEBUG') && WP_DEBUG) {
193 }
194 return $row ?: null;
195 }
196
197 /**
198 * Resolve a booking for the confirmation page: reference string, or numeric primary key (legacy ?booking_id= / Stripe fallback).
199 */
200 public function findByConfirmationSegment(string $segment): ?object
201 {
202 $segment = trim(sanitize_text_field($segment));
203 if ($segment === '') {
204 return null;
205 }
206
207 $byRef = $this->findByReferenceWithTrip($segment) ?: $this->findByReference($segment);
208 if ($byRef !== null) {
209 return $byRef;
210 }
211
212 if (ctype_digit($segment)) {
213 $id = (int) $segment;
214
215 return $this->findWithTrip($id) ?: $this->find($id);
216 }
217
218 return null;
219 }
220
221 /**
222 * Find booking by reference with trip data
223 *
224 * @param string $reference Booking reference
225 * @return object|null
226 */
227 public function findByReferenceWithTrip(string $reference): ?object
228 {
229 $table = $this->getTableName();
230
231 // Use TripRepository for trips table
232 $tripRepository = new \Yatra\Repositories\TripRepository();
233 $tripsTable = $tripRepository->getTableName();
234
235 $tripClassificationTable = TripClassificationsTable::getTableName();
236 $classificationTable = ClassificationsTable::getTableName();
237 $reviewsTable = ReviewsTable::getTableName();
238
239 $joins = [];
240 $selectParts = [
241 "b.*, t.title as trip_title, t.slug as trip_slug, t.featured_image,
242 t.duration_days, t.duration_nights, t.difficulty_level,
243 t.starting_location, t.ending_location"
244 ];
245
246 $joins[] = "LEFT JOIN {$tripClassificationTable} tc ON tc.trip_id = t.id";
247 $joins[] = "LEFT JOIN {$classificationTable} cls ON cls.id = tc.classification_id";
248 $selectParts[] = "GROUP_CONCAT(DISTINCT cls.name ORDER BY tc.`sort_order` SEPARATOR ',') as trip_classifications";
249
250 $joins[] = "LEFT JOIN {$reviewsTable} rv ON rv.trip_id = t.id AND rv.status = 'approved'";
251 $selectParts[] = "AVG(rv.rating) as trip_average_rating";
252 $selectParts[] = "COUNT(DISTINCT CASE WHEN rv.status = 'approved' THEN rv.id END) as trip_review_count";
253
254 $selectSql = implode(",\n ", $selectParts);
255 $joinsSql = implode("\n ", $joins);
256
257 $query = $this->wpdb->prepare(
258 "SELECT {$selectSql}
259 FROM {$table} b
260 LEFT JOIN {$tripsTable} t ON b.trip_id = t.id
261 {$joinsSql}
262 WHERE b.reference = %s
263 GROUP BY b.id
264 LIMIT 1",
265 sanitize_text_field($reference)
266 );
267
268
269 $row = $this->wpdb->get_row($query);
270
271 return $row ?: null;
272 }
273
274 /**
275 * Find bookings by customer ID
276 *
277 * @param int $customerId Customer ID
278 * @param int $limit Limit results
279 * @return array
280 */
281 public function findByCustomerId(int $customerId, int $limit = 10): array
282 {
283 $table = $this->getTableName();
284 $trips_table = $this->getTripsTable();
285
286 $query = $this->wpdb->prepare(
287 "SELECT b.*, t.title as trip_title
288 FROM {$table} b
289 LEFT JOIN {$trips_table} t ON b.trip_id = t.id
290 WHERE b.customer_id = %d
291 ORDER BY b.created_at DESC
292 LIMIT %d",
293 $customerId,
294 $limit
295 );
296
297 return $this->wpdb->get_results($query) ?: [];
298 }
299
300 /**
301 * Find bookings by user ID (WordPress user)
302 *
303 * @param int $userId WordPress user ID
304 * @param int $limit Limit results
305 * @return array
306 */
307 public function findByUserId(int $userId, int $limit = 10): array
308 {
309 $table = $this->getTableName();
310 $trips_table = $this->getTripsTable();
311
312 $query = $this->wpdb->prepare(
313 "SELECT b.*, t.title as trip_title
314 FROM {$table} b
315 LEFT JOIN {$trips_table} t ON b.trip_id = t.id
316 WHERE b.user_id = %d
317 ORDER BY b.created_at DESC
318 LIMIT %d",
319 $userId,
320 $limit
321 );
322
323 return $this->wpdb->get_results($query) ?: [];
324 }
325
326 /**
327 * Find bookings by contact email
328 *
329 * @param string $email Contact email
330 * @param int $limit Limit results
331 * @return array
332 */
333 public function findByContactEmail(string $email, int $limit = 10): array
334 {
335 $table = $this->getTableName();
336 $trips_table = $this->getTripsTable();
337
338 $query = $this->wpdb->prepare(
339 "SELECT b.*, t.title as trip_title
340 FROM {$table} b
341 LEFT JOIN {$trips_table} t ON b.trip_id = t.id
342 WHERE b.contact_email = %s
343 ORDER BY b.created_at DESC
344 LIMIT %d",
345 sanitize_email($email),
346 $limit
347 );
348
349 return $this->wpdb->get_results($query) ?: [];
350 }
351
352 /**
353 * Create a new booking
354 *
355 * @param array $data Booking data
356 * @return int Booking ID on success
357 * @throws \Exception on failure
358 */
359 public function create(array $data): int
360 {
361 $table = $this->getTableName();
362
363 // Sanitize and prepare data
364 $insertData = $this->prepareBookingData($data);
365 $insertData['created_at'] = current_time('mysql');
366 $insertData['updated_at'] = current_time('mysql');
367
368 // Check which columns exist and remove non-existent ones
369 $columns = $this->wpdb->get_col("DESCRIBE {$table}");
370 $hasStartDate = in_array('start_date', $columns, true);
371 $hasEndDate = in_array('end_date', $columns, true);
372
373 if (!$hasStartDate && isset($insertData['start_date'])) {
374 unset($insertData['start_date']);
375 }
376 if (!$hasEndDate && isset($insertData['end_date'])) {
377 unset($insertData['end_date']);
378 }
379 if (!in_array('meta', $columns, true) && isset($insertData['meta'])) {
380 unset($insertData['meta']);
381 }
382
383 $result = $this->wpdb->insert($table, $insertData);
384
385 if ($result === false) {
386 throw new \Exception('Failed to create booking: ' . $this->wpdb->last_error);
387 }
388
389 $newId = (int) $this->wpdb->insert_id;
390 $this->afterWrite('create', $newId, []);
391
392 return $newId;
393 }
394
395 /**
396 * Update a booking
397 *
398 * @param int $id Booking ID
399 * @param array $data Booking data to update
400 * @return bool
401 */
402 public function update(int $id, array $data): bool
403 {
404 $table = $this->getTableName();
405
406 // Sanitize and prepare data
407 $updateData = $this->prepareBookingData($data);
408 $updateData['updated_at'] = current_time('mysql');
409
410 // Check which columns exist and remove non-existent ones
411 $columns = $this->wpdb->get_col("DESCRIBE {$table}");
412 $hasStartDate = in_array('start_date', $columns, true);
413 $hasEndDate = in_array('end_date', $columns, true);
414
415 if (!$hasStartDate && isset($updateData['start_date'])) {
416 unset($updateData['start_date']);
417 }
418 if (!$hasEndDate && isset($updateData['end_date'])) {
419 unset($updateData['end_date']);
420 }
421 if (!in_array('meta', $columns, true) && isset($updateData['meta'])) {
422 unset($updateData['meta']);
423 }
424
425 if (empty($updateData)) {
426 return false;
427 }
428
429 $result = $this->wpdb->update(
430 $table,
431 $updateData,
432 ['id' => $id],
433 null,
434 ['%d']
435 );
436
437 if ($result !== false) {
438 $this->afterWrite('update', $id, []);
439 }
440
441 return $result !== false;
442 }
443
444 /**
445 * Update booking status
446 *
447 * @param int $id Booking ID
448 * @param string $status New status
449 * @return bool
450 */
451 public function updateStatus(int $id, string $status): bool
452 {
453 $table = $this->getTableName();
454
455 $data = [
456 'status' => sanitize_text_field($status),
457 'updated_at' => current_time('mysql'),
458 ];
459
460 // Set confirmed_at if confirming
461 if ($status === 'confirmed') {
462 $data['confirmed_at'] = current_time('mysql');
463 }
464
465 // Set completed_at if completing
466 if ($status === 'completed') {
467 $data['completed_at'] = current_time('mysql');
468 }
469
470 // Set cancelled info if cancelling
471 if ($status === 'cancelled') {
472 $data['cancelled_at'] = current_time('mysql');
473 $data['cancelled_by'] = get_current_user_id();
474 }
475
476 $result = $this->wpdb->update($table, $data, ['id' => $id]);
477
478 if ($result !== false) {
479 $this->afterWrite('update', $id, []);
480 }
481
482 return $result !== false;
483 }
484
485 /**
486 * Update payment status
487 *
488 * @param int $id Booking ID
489 * @param string $status New payment status
490 * @return bool
491 */
492 public function updatePaymentStatus(int $id, string $status): bool
493 {
494 $table = $this->getTableName();
495
496 $result = $this->wpdb->update(
497 $table,
498 [
499 'payment_status' => sanitize_text_field($status),
500 'updated_at' => current_time('mysql'),
501 ],
502 ['id' => $id]
503 );
504
505 if ($result !== false) {
506 $this->afterWrite('update', $id, []);
507 }
508
509 return $result !== false;
510 }
511
512 /**
513 * Update amount paid
514 *
515 * @param int $id Booking ID
516 * @param float $amountPaid New amount paid
517 * @return bool
518 */
519 public function updateAmountPaid(int $id, float $amountPaid): bool
520 {
521 $table = $this->getTableName();
522
523 // Get booking to calculate amount due
524 $booking = $this->find($id);
525 if (!$booking) {
526 return false;
527 }
528
529 $amountDue = max(0, (float)$booking->total_amount - $amountPaid);
530 $paymentStatus = $amountDue <= 0 ? 'paid' : ($amountPaid > 0 ? 'partial' : 'pending');
531
532 $result = $this->wpdb->update(
533 $table,
534 [
535 'amount_paid' => $amountPaid,
536 'amount_due' => $amountDue,
537 'payment_status' => $paymentStatus,
538 'updated_at' => current_time('mysql'),
539 ],
540 ['id' => $id]
541 );
542
543 if ($result !== false) {
544 $this->afterWrite('update', $id, []);
545 }
546
547 return $result !== false;
548 }
549
550 /**
551 * Delete a booking
552 *
553 * @param int $id Booking ID
554 * @return bool
555 */
556 public function delete(int $id): bool
557 {
558 $table = $this->getTableName();
559
560 $result = $this->wpdb->delete($table, ['id' => $id], ['%d']);
561
562 if ($result !== false) {
563 $this->afterWrite('delete', $id, []);
564 }
565
566 return $result !== false;
567 }
568
569 /**
570 * Invalidate booking-related caches after repository writes.
571 *
572 * @param 'create'|'update'|'delete' $operation
573 */
574 protected function afterWrite(string $operation, int $id, array $context = []): void
575 {
576 Cache::invalidateAfterBookingWrite($id);
577 if ($operation === 'update') {
578 do_action('yatra_booking_updated', $id);
579 }
580 }
581
582 /**
583 * Get booking statistics
584 *
585 * @return array
586 */
587 public function getStats(): array
588 {
589 $table = $this->getTableName();
590
591 // Total bookings by status
592 $statusStatsRaw = $this->wpdb->get_results(
593 "SELECT status, COUNT(*) as count FROM {$table} GROUP BY status",
594 OBJECT_K
595 );
596
597 // Normalize by_status with integer counts and default buckets
598 $byStatus = [
599 'pending' => (object) ['status' => 'pending', 'count' => 0],
600 'confirmed' => (object) ['status' => 'confirmed', 'count' => 0],
601 'cancelled' => (object) ['status' => 'cancelled', 'count' => 0],
602 'completed' => (object) ['status' => 'completed', 'count' => 0],
603 'processing' => (object) ['status' => 'processing', 'count' => 0],
604 'refunded' => (object) ['status' => 'refunded', 'count' => 0],
605 'failed' => (object) ['status' => 'failed', 'count' => 0],
606 'on_hold' => (object) ['status' => 'on_hold', 'count' => 0],
607 'waitlist' => (object) ['status' => 'waitlist', 'count' => 0],
608 'trash' => (object) ['status' => 'trash', 'count' => 0],
609 ];
610
611 foreach ((array)$statusStatsRaw as $status => $row) {
612 $count = isset($row->count) ? (int)$row->count : 0;
613 if (isset($byStatus[$status])) {
614 $byStatus[$status]->count = $count;
615 } else {
616 // keep unexpected statuses too
617 $byStatus[$status] = (object) ['status' => $status, 'count' => $count];
618 }
619 }
620
621 // Total revenue (exclude non-revenue / non-active states)
622 $totalRevenue = (float)$this->wpdb->get_var(
623 "SELECT SUM(total_amount) FROM {$table} WHERE status NOT IN ('cancelled', 'refunded', 'failed', 'waitlist')"
624 );
625
626 // Total collected
627 $totalCollected = (float)$this->wpdb->get_var(
628 "SELECT SUM(amount_paid) FROM {$table} WHERE status NOT IN ('cancelled', 'refunded', 'failed', 'waitlist')"
629 );
630
631 // This month bookings
632 $thisMonth = $this->wpdb->get_var($this->wpdb->prepare(
633 "SELECT COUNT(*) FROM {$table} WHERE created_at >= %s",
634 date('Y-m-01 00:00:00')
635 ));
636
637 // Upcoming trips
638 $upcoming = $this->wpdb->get_var($this->wpdb->prepare(
639 "SELECT COUNT(*) FROM {$table} WHERE travel_date >= %s AND status IN ('confirmed', 'pending')",
640 date('Y-m-d')
641 ));
642
643 $allBookings = (int) array_sum(array_map(static function ($row) {
644 return isset($row->count) ? (int) $row->count : 0;
645 }, (array) $byStatus));
646
647 $byStatusForApi = [];
648 foreach ($byStatus as $key => $row) {
649 $byStatusForApi[$key] = [
650 'status' => $row->status ?? $key,
651 'count' => isset($row->count) ? (int) $row->count : 0,
652 ];
653 }
654
655 // Normalize counts for UI expectations (admin list + dashboard)
656 return [
657 'all' => $allBookings,
658 'total' => $allBookings,
659 'confirmed' => (int) ($byStatus['confirmed']->count ?? 0),
660 'pending' => (int) ($byStatus['pending']->count ?? 0),
661 'waitlist' => (int) ($byStatus['waitlist']->count ?? 0),
662 'trash' => (int) ($byStatus['trash']->count ?? 0),
663 'cancelled' => (int) ($byStatus['cancelled']->count ?? 0),
664 'completed' => (int) ($byStatus['completed']->count ?? 0),
665 'by_status' => $byStatusForApi,
666 'total_revenue' => $totalRevenue,
667 'total_collected' => $totalCollected,
668 'this_month' => (int) $thisMonth,
669 'upcoming' => (int) $upcoming,
670 ];
671 }
672
673 /**
674 * Generate unique booking reference
675 *
676 * @return string
677 */
678 public function generateReference(): string
679 {
680 $table = $this->getTableName();
681
682 do {
683 $reference = 'YTR-' . strtoupper(substr(md5(uniqid((string)mt_rand(), true)), 0, 8));
684 $exists = $this->wpdb->get_var($this->wpdb->prepare(
685 "SELECT COUNT(*) FROM {$table} WHERE reference = %s",
686 $reference
687 ));
688 } while ($exists > 0);
689
690 return $reference;
691 }
692
693 /**
694 * Update customer ID for all bookings (used for merging customers)
695 *
696 * @param int $fromCustomerId Source customer ID
697 * @param int $toCustomerId Target customer ID
698 * @return int Number of affected rows
699 */
700 public function updateCustomerBookings(int $fromCustomerId, int $toCustomerId): int
701 {
702 $table = $this->getTableName();
703
704 $this->wpdb->update(
705 $table,
706 ['customer_id' => $toCustomerId],
707 ['customer_id' => $fromCustomerId],
708 ['%d'],
709 ['%d']
710 );
711
712 return (int)$this->wpdb->rows_affected;
713 }
714
715 /**
716 * Get bookings for reminder emails
717 *
718 * @param string $travelDate Target travel date
719 * @return array
720 */
721 public function getBookingsForReminder(string $travelDate): array
722 {
723 $table = $this->getTableName();
724
725 // Use TripRepository for trips table
726 $tripRepository = new \Yatra\Repositories\TripRepository();
727 $tripsTable = $tripRepository->getTableName();
728
729 return $this->wpdb->get_results($this->wpdb->prepare(
730 "SELECT b.*, t.title as trip_title, t.currency
731 FROM {$table} b
732 LEFT JOIN {$tripsTable} t ON b.trip_id = t.id
733 WHERE b.status = 'confirmed'
734 AND b.travel_date = %s
735 AND b.reminder_sent = 0",
736 $travelDate
737 ));
738 }
739
740 /**
741 * Mark booking reminder as sent
742 *
743 * @param int $bookingId Booking ID
744 * @return bool
745 */
746 public function markReminderSent(int $bookingId): bool
747 {
748 $table = $this->getTableName();
749
750 $result = $this->wpdb->update(
751 $table,
752 [
753 'reminder_sent' => 1,
754 'reminder_sent_at' => current_time('mysql'),
755 ],
756 ['id' => $bookingId],
757 ['%d', '%s'],
758 ['%d']
759 );
760
761 return $result !== false;
762 }
763
764 /**
765 * Get expired pending bookings
766 *
767 * @param string $expiryThreshold Datetime threshold
768 * @return array
769 */
770 public function getExpiredPendingBookings(string $expiryThreshold): array
771 {
772 $table = $this->getTableName();
773
774 return $this->wpdb->get_results($this->wpdb->prepare(
775 "SELECT id, reference, contact_email, contact_first_name, contact_last_name, trip_id
776 FROM {$table}
777 WHERE status = 'pending'
778 AND payment_status = 'pending'
779 AND created_at < %s",
780 $expiryThreshold
781 ));
782 }
783
784 /**
785 * Expire a booking
786 *
787 * @param int $bookingId Booking ID
788 * @param string $reason Cancellation reason
789 * @return bool
790 */
791 public function expireBooking(int $bookingId, string $reason): bool
792 {
793 $table = $this->getTableName();
794
795 $result = $this->wpdb->update(
796 $table,
797 [
798 'status' => 'cancelled',
799 'cancellation_reason' => $reason,
800 'cancelled_at' => current_time('mysql'),
801 'updated_at' => current_time('mysql'),
802 ],
803 ['id' => $bookingId],
804 ['%s', '%s', '%s', '%s'],
805 ['%d']
806 );
807
808 return $result !== false;
809 }
810
811 /**
812 * Update payment session ID for a booking
813 *
814 * @param int $bookingId Booking ID
815 * @param string $sessionId Payment session ID from gateway
816 * @return bool
817 */
818 public function updatePaymentSessionId(int $bookingId, string $sessionId): bool
819 {
820 $table = $this->getTableName();
821
822 $result = $this->wpdb->update(
823 $table,
824 ['payment_session_id' => sanitize_text_field($sessionId)],
825 ['id' => $bookingId],
826 ['%s'],
827 ['%d']
828 );
829
830 return $result !== false;
831 }
832
833 /**
834 * Prepare booking data for insert/update
835 *
836 * @param array $data Raw data
837 * @return array Sanitized data
838 */
839 private function prepareBookingData(array $data): array
840 {
841 $prepared = [];
842
843 $stringFields = [
844 'reference', 'contact_first_name', 'contact_last_name', 'contact_email',
845 'contact_phone', 'contact_country', 'status', 'payment_status', 'payment_method',
846 'payment_gateway', 'currency', 'discount_code', 'special_requests', 'internal_notes',
847 'ip_address', 'payment_session_id', 'payment_transaction_id', 'cancellation_reason',
848 ];
849
850 $intFields = ['trip_id', 'customer_id', 'user_id', 'travelers_count', 'cancelled_by', 'availability_id'];
851
852 $floatFields = ['total_amount', 'amount_paid', 'amount_due', 'discount_amount', 'subtotal', 'tax_amount', 'tax_rate', 'itinerary_costs_total'];
853
854 $boolFields = ['newsletter_optin', 'terms_accepted', 'reminder_sent', 'tax_inclusive'];
855
856 $jsonFields = ['contact_data', 'emergency_contact', 'tax_details', 'itinerary_costs', 'meta'];
857
858 $dateFields = ['travel_date', 'start_date', 'end_date', 'payment_date', 'cancelled_at', 'confirmed_at', 'completed_at', 'reminder_sent_at'];
859
860 foreach ($stringFields as $field) {
861 if (array_key_exists($field, $data)) {
862 $prepared[$field] = sanitize_text_field((string)$data[$field]);
863 }
864 }
865
866 foreach ($intFields as $field) {
867 if (array_key_exists($field, $data)) {
868 $prepared[$field] = $data[$field] === null ? null : (int)$data[$field];
869 }
870 }
871
872 foreach ($floatFields as $field) {
873 if (array_key_exists($field, $data)) {
874 $prepared[$field] = (float)$data[$field];
875 }
876 }
877
878 foreach ($boolFields as $field) {
879 if (array_key_exists($field, $data)) {
880 $prepared[$field] = $data[$field] ? 1 : 0;
881 }
882 }
883
884 foreach ($jsonFields as $field) {
885 if (array_key_exists($field, $data)) {
886 $prepared[$field] = is_string($data[$field]) ? $data[$field] : wp_json_encode($data[$field]);
887 }
888 }
889
890 foreach ($dateFields as $field) {
891 if (array_key_exists($field, $data) && $data[$field]) {
892 $prepared[$field] = sanitize_text_field($data[$field]);
893 }
894 }
895
896 // Calculate end_date if start_date is provided but end_date is not
897 if (isset($prepared['start_date']) && !isset($prepared['end_date']) && !empty($prepared['trip_id'])) {
898 $prepared['end_date'] = $this->calculateEndDate($prepared['start_date'], (int)$prepared['trip_id']);
899 }
900
901 // Sync travel_date with start_date if start_date is provided
902 if (isset($prepared['start_date']) && !isset($prepared['travel_date'])) {
903 $prepared['travel_date'] = $prepared['start_date'];
904 }
905
906 // Check if start_date and end_date columns exist before including them
907 // If columns don't exist, only use travel_date (backward compatibility)
908 $table = $this->getTableName();
909 $columns = $this->wpdb->get_col("DESCRIBE {$table}");
910
911 if (!in_array('start_date', $columns, true)) {
912 unset($prepared['start_date']);
913 }
914 if (!in_array('end_date', $columns, true)) {
915 unset($prepared['end_date']);
916 }
917 if (!in_array('meta', $columns, true)) {
918 unset($prepared['meta']);
919 }
920
921 if (array_key_exists('user_agent', $data)) {
922 $prepared['user_agent'] = sanitize_textarea_field((string)$data['user_agent']);
923 }
924
925 if (array_key_exists('payment_notes', $data)) {
926 $prepared['payment_notes'] = sanitize_textarea_field((string)$data['payment_notes']);
927 }
928
929 return $prepared;
930 }
931
932 /**
933 * Calculate end date from start date and trip duration
934 *
935 * @param string $startDate Start date (YYYY-MM-DD)
936 * @param int $tripId Trip ID
937 * @return string End date (YYYY-MM-DD)
938 */
939 public function calculateEndDate(string $startDate, int $tripId): string
940 {
941 // Use TripRepository for trips table
942 $tripRepository = new \Yatra\Repositories\TripRepository();
943 $tripsTable = $tripRepository->getTableName();
944
945 $durationDays = $this->wpdb->get_var($this->wpdb->prepare(
946 "SELECT duration_days FROM {$tripsTable} WHERE id = %d LIMIT 1",
947 $tripId
948 ));
949
950 $durationDays = $durationDays ? (int)$durationDays : 1;
951
952 // end_date = start_date + (duration_days - 1) days
953 // Example: 5-day trip starting Jan 1 = Jan 1 + 4 days = Jan 5
954 $endDate = date('Y-m-d', strtotime($startDate . ' + ' . ($durationDays - 1) . ' days'));
955
956 return $endDate;
957 }
958
959 /**
960 * Get table columns for booking table
961 *
962 * @return array Array of column names
963 */
964 public function getTableColumns(): array
965 {
966 $table = $this->getTableName();
967 return $this->wpdb->get_col("DESCRIBE {$table}");
968 }
969
970 /**
971 * Count discount code usage by customer
972 *
973 * @param int $customerId Customer ID
974 * @param string $discountCode Discount code
975 * @return int Number of times discount code has been used
976 */
977 public function countDiscountCodeUsage(int $customerId, string $discountCode): int
978 {
979 $table = $this->getTableName();
980 return (int)$this->wpdb->get_var($this->wpdb->prepare(
981 "SELECT COUNT(*) FROM {$table} WHERE customer_id = %d AND discount_code = %s AND status NOT IN ('cancelled', 'refunded', 'failed')",
982 $customerId,
983 $discountCode
984 ));
985 }
986
987 /**
988 * Booking statuses that consume seats on a dated availability row (must match {@see AvailabilityInventoryHooks}).
989 *
990 * @return list<string>
991 */
992 public static function getCapacityConsumingBookingStatuses(): array
993 {
994 $default = ['pending', 'confirmed', 'processing', 'completed', 'on_hold'];
995 /** @var list<string> $default */
996 $filtered = apply_filters('yatra_capacity_consuming_booking_statuses', $default);
997 return is_array($filtered) && $filtered !== [] ? array_values(array_unique(array_map('strval', $filtered))) : $default;
998 }
999
1000 /**
1001 * Count booked travelers for a virtual (rule-generated) slot.
1002 *
1003 * Rule-generated dates do not have a numeric availability_id, so capacity must be
1004 * computed from bookings by (trip_id, travel_date, departure_time) and the same
1005 * capacity-consuming statuses used for manual availability rows.
1006 */
1007 public function countActiveSeatsForSlot(int $tripId, string $travelDate, ?string $departureTime = null): int
1008 {
1009 if ($tripId <= 0 || $travelDate === '') {
1010 return 0;
1011 }
1012
1013 $bookingsTable = esc_sql($this->getTableName());
1014 $statuses = self::getCapacityConsumingBookingStatuses();
1015 $stPh = implode(',', array_fill(0, count($statuses), '%s'));
1016
1017 // Use booking_departures when we need time-slot precision.
1018 $relationTable = esc_sql(\Yatra\Database\Tables\BookingDeparturesTable::getTableName());
1019
1020 if ($departureTime !== null && $departureTime !== '') {
1021 // Normalize to match TIME storage in MySQL (HH:MM:SS).
1022 $ts = strtotime($departureTime);
1023 if ($ts !== false) {
1024 $departureTime = date('H:i:s', $ts);
1025 }
1026 $params = array_merge([$tripId, $travelDate, $departureTime], $statuses);
1027
1028 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1029 $count = (int) $this->wpdb->get_var($this->wpdb->prepare(
1030 "SELECT COALESCE(SUM(b.travelers_count), 0)
1031 FROM `{$bookingsTable}` b
1032 INNER JOIN `{$relationTable}` bd ON b.id = bd.booking_id
1033 WHERE b.trip_id = %d
1034 AND b.travel_date = %s
1035 AND bd.travel_date = %s
1036 AND bd.departure_time = %s
1037 AND b.status IN ({$stPh})",
1038 array_merge([$tripId, $travelDate, $travelDate, $departureTime], $statuses)
1039 ));
1040 /** @var int $count */
1041 $count = (int) apply_filters('yatra_virtual_availability_reserved_seats', $count, $tripId, $travelDate, $departureTime, $statuses);
1042 return max(0, $count);
1043 }
1044
1045 // No time-slot filter: sum all bookings for the trip/date.
1046 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1047 $count = (int) $this->wpdb->get_var($this->wpdb->prepare(
1048 "SELECT COALESCE(SUM(travelers_count), 0)
1049 FROM `{$bookingsTable}`
1050 WHERE trip_id = %d
1051 AND travel_date = %s
1052 AND status IN ({$stPh})",
1053 array_merge([$tripId, $travelDate], $statuses)
1054 ));
1055 $count = (int) apply_filters('yatra_virtual_availability_reserved_seats', $count, $tripId, $travelDate, null, $statuses);
1056 return max(0, $count);
1057 }
1058
1059 /**
1060 * Count booked travelers by availability ID
1061 *
1062 * @param int $availabilityId Availability ID
1063 * @return int Number of booked travelers
1064 */
1065 public function countBookedTravelersByAvailabilityId(int $availabilityId): int
1066 {
1067 $table = $this->getTableName();
1068 $statuses = self::getCapacityConsumingBookingStatuses();
1069 $ph = implode(',', array_fill(0, count($statuses), '%s'));
1070 $params = array_merge([$availabilityId], $statuses);
1071
1072 return (int) $this->wpdb->get_var($this->wpdb->prepare(
1073 "SELECT COALESCE(SUM(travelers_count), 0)
1074 FROM {$table}
1075 WHERE availability_id = %d AND status IN ({$ph})",
1076 $params
1077 ));
1078 }
1079
1080 /**
1081 * Get booking counts for multiple availability IDs
1082 *
1083 * @param array $availabilityIds Array of availability IDs
1084 * @return array Array of objects with availability_id and booked_count
1085 */
1086 public function getBookingCountsByAvailabilityIds(array $availabilityIds): array
1087 {
1088 $table = $this->getTableName();
1089
1090 if (empty($availabilityIds)) {
1091 return [];
1092 }
1093
1094 $idPlaceholders = implode(',', array_fill(0, count($availabilityIds), '%d'));
1095 $statuses = self::getCapacityConsumingBookingStatuses();
1096 $stPlaceholders = implode(',', array_fill(0, count($statuses), '%s'));
1097 $params = array_merge($availabilityIds, $statuses);
1098
1099 return $this->wpdb->get_results($this->wpdb->prepare(
1100 "SELECT availability_id, SUM(travelers_count) AS booked_count
1101 FROM {$table}
1102 WHERE availability_id IN ({$idPlaceholders}) AND status IN ({$stPlaceholders})
1103 GROUP BY availability_id",
1104 $params
1105 ));
1106 }
1107
1108 /**
1109 * Update availability ID by trip and date
1110 *
1111 * @param int $tripId Trip ID
1112 * @param string $date Travel date
1113 * @param int $availabilityId Availability ID
1114 * @return int|false Number of rows updated or false on failure
1115 */
1116 public function updateAvailabilityIdByTripAndDate(int $tripId, string $date, int $availabilityId)
1117 {
1118 $table = $this->getTableName();
1119 return $this->wpdb->query(
1120 $this->wpdb->prepare(
1121 "UPDATE {$table}
1122 SET availability_id = %d
1123 WHERE trip_id = %d
1124 AND travel_date = %s
1125 AND (availability_id IS NULL OR availability_id = 0)",
1126 $availabilityId,
1127 $tripId,
1128 $date
1129 )
1130 );
1131 }
1132
1133 /**
1134 * Find bookings by departure ID
1135 *
1136 * @param int $departureId Departure ID
1137 * @return array Array of booking objects
1138 */
1139 public function findByDepartureId(int $departureId): array
1140 {
1141 $table = $this->getTableName();
1142
1143 $relationTable = BookingDeparturesTable::getTableName();
1144
1145 $bookings = $this->wpdb->get_results($this->wpdb->prepare(
1146 "SELECT b.* FROM {$table} b
1147 INNER JOIN {$relationTable} bd ON b.id = bd.booking_id
1148 WHERE bd.departure_id = %d
1149 ORDER BY b.created_at DESC",
1150 $departureId
1151 ));
1152
1153 return $bookings ?: [];
1154 }
1155
1156 /**
1157 * Check if a user has made any previous bookings
1158 *
1159 * @param int $user_id User ID
1160 * @return bool True if user has made at least one booking
1161 */
1162 public function hasUserMadeBooking(int $user_id): bool
1163 {
1164 $count = $this->wpdb->get_var($this->wpdb->prepare(
1165 "SELECT COUNT(*) FROM {$this->table} WHERE customer_id = %d AND status NOT IN ('cancelled', 'refunded', 'failed')",
1166 $user_id
1167 ));
1168
1169 return (int)$count > 0;
1170 }
1171
1172 /**
1173 * Get recent bookings for cache warming
1174 *
1175 * @param int $days Number of days to look back
1176 * @param int $limit Maximum number of bookings to return
1177 * @return array Array of recent booking IDs
1178 */
1179 public function getRecentBookings(int $days = 7, int $limit = 50): array
1180 {
1181 return $this->wpdb->get_results("
1182 SELECT id
1183 FROM {$this->table}
1184 WHERE created_at >= DATE_SUB(NOW(), INTERVAL {$days} DAY)
1185 ORDER BY created_at DESC
1186 LIMIT {$limit}
1187 ") ?: [];
1188 }
1189
1190 /**
1191 * Get total travelers count for a trip and availability with specific statuses
1192 *
1193 * @param int $tripId Trip ID
1194 * @param int $availabilityId Availability ID
1195 * @param array $statuses Array of booking statuses to include
1196 * @return int Total travelers count
1197 */
1198 public function getTotalTravelersByTripAndAvailability(int $tripId, int $availabilityId, array $statuses = []): int
1199 {
1200 $table = esc_sql($this->table);
1201
1202 if (empty($statuses)) {
1203 $statuses = self::getCapacityConsumingBookingStatuses();
1204 }
1205
1206 $placeholders = implode(',', array_fill(0, count($statuses), '%s'));
1207 $params = array_merge([$tripId, $availabilityId], $statuses);
1208
1209 $count = (int)$this->wpdb->get_var($this->wpdb->prepare(
1210 "SELECT COALESCE(SUM(travelers_count), 0)
1211 FROM {$table}
1212 WHERE trip_id = %d
1213 AND availability_id = %d
1214 AND status IN ({$placeholders})",
1215 $params
1216 ));
1217
1218 return $count;
1219 }
1220
1221 /**
1222 * @return list<object>
1223 */
1224 public function findWaitlistBookingsForAvailability(int $availabilityId, int $limit = 20): array
1225 {
1226 if ($availabilityId <= 0) {
1227 return [];
1228 }
1229
1230 $table = esc_sql($this->table);
1231 $limit = max(1, min(100, $limit));
1232
1233 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1234 $rows = $this->wpdb->get_results(
1235 $this->wpdb->prepare(
1236 "SELECT * FROM `{$table}` WHERE availability_id = %d AND status = 'waitlist' ORDER BY created_at ASC, id ASC LIMIT %d",
1237 $availabilityId,
1238 $limit
1239 )
1240 );
1241
1242 return is_array($rows) ? $rows : [];
1243 }
1244
1245 public function getTotalWaitlistTravelersForAvailability(int $availabilityId): int
1246 {
1247 if ($availabilityId <= 0) {
1248 return 0;
1249 }
1250
1251 $table = esc_sql($this->table);
1252
1253 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1254 return (int) $this->wpdb->get_var($this->wpdb->prepare(
1255 "SELECT COALESCE(SUM(travelers_count), 0) FROM `{$table}` WHERE availability_id = %d AND status = 'waitlist'",
1256 $availabilityId
1257 ));
1258 }
1259 }
1260
1261