PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.4
Yatra – Travel Booking & Tour Operator Software v3.0.4
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.4, at app/Repositories/BookingRepository.php

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