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

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