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

1,221 lines 38.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 return ['pending', 'confirmed', 'processing', 'completed', 'on_hold'];
1016 }
1017
1018 /**
1019 * Count booked travelers by availability ID
1020 *
1021 * @param int $availabilityId Availability ID
1022 * @return int Number of booked travelers
1023 */
1024 public function countBookedTravelersByAvailabilityId(int $availabilityId): int
1025 {
1026 $table = $this->getTableName();
1027 $statuses = self::getCapacityConsumingBookingStatuses();
1028 $ph = implode(',', array_fill(0, count($statuses), '%s'));
1029 $params = array_merge([$availabilityId], $statuses);
1030
1031 return (int) $this->wpdb->get_var($this->wpdb->prepare(
1032 "SELECT COALESCE(SUM(travelers_count), 0)
1033 FROM {$table}
1034 WHERE availability_id = %d AND status IN ({$ph})",
1035 $params
1036 ));
1037 }
1038
1039 /**
1040 * Get booking counts for multiple availability IDs
1041 *
1042 * @param array $availabilityIds Array of availability IDs
1043 * @return array Array of objects with availability_id and booked_count
1044 */
1045 public function getBookingCountsByAvailabilityIds(array $availabilityIds): array
1046 {
1047 $table = $this->getTableName();
1048
1049 if (empty($availabilityIds)) {
1050 return [];
1051 }
1052
1053 $idPlaceholders = implode(',', array_fill(0, count($availabilityIds), '%d'));
1054 $statuses = self::getCapacityConsumingBookingStatuses();
1055 $stPlaceholders = implode(',', array_fill(0, count($statuses), '%s'));
1056 $params = array_merge($availabilityIds, $statuses);
1057
1058 return $this->wpdb->get_results($this->wpdb->prepare(
1059 "SELECT availability_id, SUM(travelers_count) AS booked_count
1060 FROM {$table}
1061 WHERE availability_id IN ({$idPlaceholders}) AND status IN ({$stPlaceholders})
1062 GROUP BY availability_id",
1063 $params
1064 ));
1065 }
1066
1067 /**
1068 * Update availability ID by trip and date
1069 *
1070 * @param int $tripId Trip ID
1071 * @param string $date Travel date
1072 * @param int $availabilityId Availability ID
1073 * @return int|false Number of rows updated or false on failure
1074 */
1075 public function updateAvailabilityIdByTripAndDate(int $tripId, string $date, int $availabilityId)
1076 {
1077 $table = $this->getTableName();
1078 return $this->wpdb->query(
1079 $this->wpdb->prepare(
1080 "UPDATE {$table}
1081 SET availability_id = %d
1082 WHERE trip_id = %d
1083 AND travel_date = %s
1084 AND (availability_id IS NULL OR availability_id = 0)",
1085 $availabilityId,
1086 $tripId,
1087 $date
1088 )
1089 );
1090 }
1091
1092 /**
1093 * Find bookings by departure ID
1094 *
1095 * @param int $departureId Departure ID
1096 * @return array Array of booking objects
1097 */
1098 public function findByDepartureId(int $departureId): array
1099 {
1100 $table = $this->getTableName();
1101
1102 // Using hardcoded table name since there's no dedicated repository for this table
1103 $relationTable = $this->wpdb->prefix . 'yatra_booking_departures';
1104
1105 $bookings = $this->wpdb->get_results($this->wpdb->prepare(
1106 "SELECT b.* FROM {$table} b
1107 INNER JOIN {$relationTable} bd ON b.id = bd.booking_id
1108 WHERE bd.departure_id = %d
1109 ORDER BY b.created_at DESC",
1110 $departureId
1111 ));
1112
1113 return $bookings ?: [];
1114 }
1115
1116 /**
1117 * Check if a user has made any previous bookings
1118 *
1119 * @param int $user_id User ID
1120 * @return bool True if user has made at least one booking
1121 */
1122 public function hasUserMadeBooking(int $user_id): bool
1123 {
1124 $count = $this->wpdb->get_var($this->wpdb->prepare(
1125 "SELECT COUNT(*) FROM {$this->table} WHERE customer_id = %d AND status NOT IN ('cancelled', 'refunded', 'failed')",
1126 $user_id
1127 ));
1128
1129 return (int)$count > 0;
1130 }
1131
1132 /**
1133 * Get recent bookings for cache warming
1134 *
1135 * @param int $days Number of days to look back
1136 * @param int $limit Maximum number of bookings to return
1137 * @return array Array of recent booking IDs
1138 */
1139 public function getRecentBookings(int $days = 7, int $limit = 50): array
1140 {
1141 return $this->wpdb->get_results("
1142 SELECT id
1143 FROM {$this->table}
1144 WHERE created_at >= DATE_SUB(NOW(), INTERVAL {$days} DAY)
1145 ORDER BY created_at DESC
1146 LIMIT {$limit}
1147 ") ?: [];
1148 }
1149
1150 /**
1151 * Get total travelers count for a trip and availability with specific statuses
1152 *
1153 * @param int $tripId Trip ID
1154 * @param int $availabilityId Availability ID
1155 * @param array $statuses Array of booking statuses to include
1156 * @return int Total travelers count
1157 */
1158 public function getTotalTravelersByTripAndAvailability(int $tripId, int $availabilityId, array $statuses = []): int
1159 {
1160 $table = esc_sql($this->table);
1161
1162 if (empty($statuses)) {
1163 $statuses = self::getCapacityConsumingBookingStatuses();
1164 }
1165
1166 $placeholders = implode(',', array_fill(0, count($statuses), '%s'));
1167 $params = array_merge([$tripId, $availabilityId], $statuses);
1168
1169 $count = (int)$this->wpdb->get_var($this->wpdb->prepare(
1170 "SELECT COALESCE(SUM(travelers_count), 0)
1171 FROM {$table}
1172 WHERE trip_id = %d
1173 AND availability_id = %d
1174 AND status IN ({$placeholders})",
1175 $params
1176 ));
1177
1178 return $count;
1179 }
1180
1181 /**
1182 * @return list<object>
1183 */
1184 public function findWaitlistBookingsForAvailability(int $availabilityId, int $limit = 20): array
1185 {
1186 if ($availabilityId <= 0) {
1187 return [];
1188 }
1189
1190 $table = esc_sql($this->table);
1191 $limit = max(1, min(100, $limit));
1192
1193 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1194 $rows = $this->wpdb->get_results(
1195 $this->wpdb->prepare(
1196 "SELECT * FROM `{$table}` WHERE availability_id = %d AND status = 'waitlist' ORDER BY created_at ASC, id ASC LIMIT %d",
1197 $availabilityId,
1198 $limit
1199 )
1200 );
1201
1202 return is_array($rows) ? $rows : [];
1203 }
1204
1205 public function getTotalWaitlistTravelersForAvailability(int $availabilityId): int
1206 {
1207 if ($availabilityId <= 0) {
1208 return 0;
1209 }
1210
1211 $table = esc_sql($this->table);
1212
1213 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1214 return (int) $this->wpdb->get_var($this->wpdb->prepare(
1215 "SELECT COALESCE(SUM(travelers_count), 0) FROM `{$table}` WHERE availability_id = %d AND status = 'waitlist'",
1216 $availabilityId
1217 ));
1218 }
1219 }
1220
1221