PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.7
Yatra – Travel Booking & Tour Operator Software v3.0.7
3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 2.0.10 2.0.11 All 82 releases
yatra / app / Repositories / CustomerRepository.php

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

778 lines 25.1 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\Database\Tables\CustomersTable;
8 use Yatra\Database\Tables\BookingPaymentsTable;
9 use Yatra\Database\Tables\TripsTable;
10
11 /**
12 * Customer Repository
13 *
14 * Manages customer data in the yatra_customers table.
15 * Customers are separate from WordPress users - a customer may or may not have a WP account.
16 */
17 class CustomerRepository extends BaseRepository
18 {
19 /**
20 * Get full table name with prefix
21 */
22 protected function getTableName(): string
23 {
24 return CustomersTable::getTableName();
25 }
26
27 /**
28 * Find or create customer by email
29 *
30 * @param array $data Customer data
31 * @return int Customer ID
32 */
33 public function findOrCreate(array $data): int
34 {
35 $email = sanitize_email($data['email'] ?? '');
36
37 if (empty($email)) {
38 throw new \Exception('Customer email is required');
39 }
40
41 // Check if customer exists
42 $existing = $this->findByEmail($email);
43
44 if ($existing) {
45 // Update existing customer with new data
46 $this->updateFromBooking((int) $existing->id, $data);
47 return (int) $existing->id;
48 }
49
50 // Create new customer
51 return $this->createFromBooking($data);
52 }
53
54 /**
55 * Find customer by email
56 *
57 * @param string $email
58 * @return object|null
59 */
60 public function findByEmail(string $email): ?object
61 {
62 global $wpdb;
63
64 $table = $this->getTableName();
65 $email = sanitize_email($email);
66
67 $result = $wpdb->get_row($wpdb->prepare(
68 "SELECT * FROM {$table} WHERE email = %s",
69 $email
70 ));
71
72 return $result ?: null;
73 }
74
75 /**
76 * Find customer by WordPress user ID
77 *
78 * @param int $userId
79 * @return object|null
80 */
81 public function findByUserId(int $userId): ?object
82 {
83 global $wpdb;
84
85 $table = $this->getTableName();
86
87 $result = $wpdb->get_row($wpdb->prepare(
88 "SELECT * FROM {$table} WHERE user_id = %d",
89 $userId
90 ));
91
92 return $result ?: null;
93 }
94
95 /**
96 * Find customer by gateway customer ID
97 *
98 * @param string $gateway 'stripe', 'paypal', 'razorpay'
99 * @param string $gatewayCustomerId
100 * @return object|null
101 */
102 public function findByGatewayCustomerId(string $gateway, string $gatewayCustomerId): ?object
103 {
104 global $wpdb;
105
106 $table = $this->getTableName();
107 $column = $gateway . '_customer_id';
108
109 // Validate column name to prevent SQL injection
110 $allowedColumns = ['stripe_customer_id', 'paypal_customer_id', 'razorpay_customer_id'];
111 if (!in_array($column, $allowedColumns, true)) {
112 return null;
113 }
114
115 $result = $wpdb->get_row($wpdb->prepare(
116 "SELECT * FROM {$table} WHERE {$column} = %s",
117 $gatewayCustomerId
118 ));
119
120 return $result ?: null;
121 }
122
123 /**
124 * Create customer from booking data
125 *
126 * @param array $data
127 * @return int Customer ID
128 */
129 public function createFromBooking(array $data): int
130 {
131 global $wpdb;
132
133 $table = $this->getTableName();
134
135 $insertData = [
136 'user_id' => !empty($data['user_id']) ? (int) $data['user_id'] : null,
137 'first_name' => sanitize_text_field($data['first_name'] ?? ''),
138 'last_name' => sanitize_text_field($data['last_name'] ?? ''),
139 'email' => sanitize_email($data['email'] ?? ''),
140 'phone' => sanitize_text_field($data['phone'] ?? ''),
141 'address' => sanitize_text_field($data['address'] ?? ''),
142 'city' => sanitize_text_field($data['city'] ?? ''),
143 'country' => sanitize_text_field($data['country'] ?? ''),
144 'nationality' => sanitize_text_field($data['nationality'] ?? ''),
145 'newsletter_optin' => !empty($data['newsletter_optin']) ? 1 : 0,
146 'source' => sanitize_text_field($data['source'] ?? 'booking'),
147 'total_bookings' => 1,
148 'total_spent' => (float) ($data['total_spent'] ?? 0),
149 'last_booking_date' => current_time('mysql'),
150 'created_at' => current_time('mysql'),
151 'updated_at' => current_time('mysql'),
152 ];
153
154 // Handle emergency contact if provided
155 if (!empty($data['emergency_name'])) {
156 $insertData['emergency_name'] = sanitize_text_field($data['emergency_name']);
157 $insertData['emergency_phone'] = sanitize_text_field($data['emergency_phone'] ?? '');
158 $insertData['emergency_relationship'] = sanitize_text_field($data['emergency_relationship'] ?? '');
159 }
160
161 $result = $wpdb->insert($table, $insertData);
162
163 if ($result === false) {
164 throw new \Exception('Failed to create customer: ' . $wpdb->last_error);
165 }
166
167 return (int) $wpdb->insert_id;
168 }
169
170 /**
171 * Update customer from booking data
172 *
173 * @param int $customerId
174 * @param array $data
175 * @return bool
176 */
177 public function updateFromBooking(int $customerId, array $data): bool
178 {
179 global $wpdb;
180
181 $table = $this->getTableName();
182
183 // Get current customer data
184 $current = $this->find($customerId);
185 if (!$current) {
186 return false;
187 }
188
189 $updateData = [
190 'updated_at' => current_time('mysql'),
191 'last_booking_date' => current_time('mysql'),
192 'total_bookings' => ((int) $current->total_bookings) + 1,
193 'total_spent' => ((float) $current->total_spent) + ((float) ($data['total_spent'] ?? 0)),
194 ];
195
196 // Update phone if provided and not set
197 if (!empty($data['phone']) && empty($current->phone)) {
198 $updateData['phone'] = sanitize_text_field($data['phone']);
199 }
200
201 // Update address if provided and not set
202 if (!empty($data['address']) && empty($current->address)) {
203 $updateData['address'] = sanitize_text_field($data['address']);
204 }
205
206 // Update city if provided and not set
207 if (!empty($data['city']) && empty($current->city)) {
208 $updateData['city'] = sanitize_text_field($data['city']);
209 }
210
211 // Update country if provided and not set
212 if (!empty($data['country']) && empty($current->country)) {
213 $updateData['country'] = sanitize_text_field($data['country']);
214 }
215
216 // Update nationality if provided and not set
217 if (!empty($data['nationality']) && empty($current->nationality)) {
218 $updateData['nationality'] = sanitize_text_field($data['nationality']);
219 }
220
221 // Link WordPress user if not linked and provided
222 if (!empty($data['user_id']) && empty($current->user_id)) {
223 $updateData['user_id'] = (int) $data['user_id'];
224 }
225
226 // Update emergency contact if provided
227 if (!empty($data['emergency_name'])) {
228 $updateData['emergency_name'] = sanitize_text_field($data['emergency_name']);
229 $updateData['emergency_phone'] = sanitize_text_field($data['emergency_phone'] ?? '');
230 $updateData['emergency_relationship'] = sanitize_text_field($data['emergency_relationship'] ?? '');
231 }
232
233 // Update newsletter preference if opted in
234 if (!empty($data['newsletter_optin'])) {
235 $updateData['newsletter_optin'] = 1;
236 }
237
238 $result = $wpdb->update(
239 $table,
240 $updateData,
241 ['id' => $customerId],
242 null,
243 ['%d']
244 );
245
246 return $result !== false;
247 }
248
249 /**
250 * Update customer from admin form
251 *
252 * This is used by the CustomerService::updateCustomer method when saving
253 * changes from the admin Edit Customer screen.
254 *
255 * @param int $customerId Customer ID
256 * @param array $data Data to update
257 * @return bool
258 */
259 public function updateCustomer(int $customerId, array $data): bool
260 {
261 global $wpdb;
262
263 $table = $this->getTableName();
264
265 $updateData = [
266 'updated_at' => current_time('mysql'),
267 ];
268
269 // Simple text fields
270 if (array_key_exists('first_name', $data)) {
271 $updateData['first_name'] = sanitize_text_field($data['first_name']);
272 }
273 if (array_key_exists('last_name', $data)) {
274 $updateData['last_name'] = sanitize_text_field($data['last_name']);
275 }
276 if (array_key_exists('email', $data)) {
277 $updateData['email'] = sanitize_email($data['email']);
278 }
279 if (array_key_exists('phone', $data)) {
280 $updateData['phone'] = sanitize_text_field($data['phone']);
281 }
282 if (array_key_exists('secondary_phone', $data)) {
283 $updateData['secondary_phone'] = sanitize_text_field($data['secondary_phone']);
284 }
285 if (array_key_exists('country', $data)) {
286 $updateData['country'] = sanitize_text_field($data['country']);
287 }
288 if (array_key_exists('city', $data)) {
289 $updateData['city'] = sanitize_text_field($data['city']);
290 }
291 if (array_key_exists('state', $data)) {
292 $updateData['state'] = sanitize_text_field($data['state']);
293 }
294 if (array_key_exists('address', $data)) {
295 $updateData['address'] = sanitize_text_field($data['address']);
296 }
297 if (array_key_exists('postal_code', $data)) {
298 $updateData['postal_code'] = sanitize_text_field($data['postal_code']);
299 }
300 if (array_key_exists('nationality', $data)) {
301 $updateData['nationality'] = sanitize_text_field($data['nationality']);
302 }
303 if (array_key_exists('date_of_birth', $data)) {
304 $updateData['date_of_birth'] = sanitize_text_field($data['date_of_birth']);
305 }
306 if (array_key_exists('gender', $data)) {
307 $updateData['gender'] = sanitize_text_field($data['gender']);
308 }
309
310 // Emergency contact
311 if (array_key_exists('emergency_name', $data)) {
312 $updateData['emergency_name'] = sanitize_text_field($data['emergency_name']);
313 }
314 if (array_key_exists('emergency_phone', $data)) {
315 $updateData['emergency_phone'] = sanitize_text_field($data['emergency_phone']);
316 }
317 if (array_key_exists('emergency_relationship', $data)) {
318 $updateData['emergency_relationship'] = sanitize_text_field($data['emergency_relationship']);
319 }
320
321 // Special requirements & notes
322 if (array_key_exists('dietary_requirements', $data)) {
323 $updateData['dietary_requirements'] = sanitize_text_field($data['dietary_requirements']);
324 }
325 if (array_key_exists('medical_conditions', $data)) {
326 $updateData['medical_conditions'] = sanitize_textarea_field($data['medical_conditions']);
327 }
328 if (array_key_exists('special_needs', $data)) {
329 $updateData['special_needs'] = sanitize_textarea_field($data['special_needs']);
330 }
331 if (array_key_exists('notes', $data)) {
332 $updateData['notes'] = sanitize_textarea_field($data['notes']);
333 }
334
335 // Status & loyalty
336 if (array_key_exists('status', $data)) {
337 $updateData['status'] = sanitize_text_field($data['status']);
338 }
339 if (array_key_exists('loyalty_tier', $data)) {
340 $updateData['loyalty_tier'] = sanitize_text_field($data['loyalty_tier']);
341 }
342 if (array_key_exists('loyalty_points', $data)) {
343 $updateData['loyalty_points'] = (int) $data['loyalty_points'];
344 }
345
346 // Preferences
347 if (array_key_exists('newsletter_optin', $data)) {
348 $updateData['newsletter_optin'] = !empty($data['newsletter_optin']) ? 1 : 0;
349 }
350 if (array_key_exists('marketing_optin', $data)) {
351 $updateData['marketing_optin'] = !empty($data['marketing_optin']) ? 1 : 0;
352 }
353
354 // Nothing to update
355 if (count($updateData) === 1) {
356 return true;
357 }
358
359 $result = $wpdb->update(
360 $table,
361 $updateData,
362 ['id' => $customerId],
363 null,
364 ['%d']
365 );
366
367 return $result !== false;
368 }
369
370 /**
371 * Update gateway customer ID
372 *
373 * @param int $customerId
374 * @param string $gateway 'stripe', 'paypal', 'razorpay'
375 * @param string $gatewayCustomerId
376 * @return bool
377 */
378 public function updateGatewayCustomerId(int $customerId, string $gateway, string $gatewayCustomerId): bool
379 {
380 global $wpdb;
381
382 $table = $this->getTableName();
383 $column = $gateway . '_customer_id';
384
385 // Validate column name to prevent SQL injection
386 $allowedColumns = ['stripe_customer_id', 'paypal_customer_id', 'razorpay_customer_id'];
387 if (!in_array($column, $allowedColumns, true)) {
388 return false;
389 }
390
391 $result = $wpdb->update(
392 $table,
393 [
394 $column => sanitize_text_field($gatewayCustomerId),
395 'updated_at' => current_time('mysql'),
396 ],
397 ['id' => $customerId]
398 );
399
400 return $result !== false;
401 }
402
403 /**
404 * Delete a customer record
405 *
406 * CustomerService calls this method when deleting customers via the REST API.
407 * BaseRepository already implements delete(int $id), so this is a thin wrapper
408 * for backward/semantic compatibility.
409 *
410 * @param int $customerId
411 * @return bool
412 */
413 public function deleteCustomer(int $customerId): bool
414 {
415 return $this->delete($customerId);
416 }
417
418 /**
419 * Get gateway customer ID for a customer
420 *
421 * @param int $customerId
422 * @param string $gateway
423 * @return string|null
424 */
425 public function getGatewayCustomerId(int $customerId, string $gateway): ?string
426 {
427 $customer = $this->find($customerId);
428 if (!$customer) {
429 return null;
430 }
431
432 $column = $gateway . '_customer_id';
433 return !empty($customer->$column) ? $customer->$column : null;
434 }
435
436 /**
437 * Update customer stats (called after booking changes)
438 *
439 * @param int $customerId
440 * @return bool
441 */
442 public function recalculateStats(int $customerId): bool
443 {
444 global $wpdb;
445
446 $bookingRepository = new \Yatra\Repositories\BookingRepository();
447 $bookingsTable = $bookingRepository->getTableName();
448 $customersTable = $this->getTableName();
449
450 // Get stats from bookings
451 $stats = $wpdb->get_row($wpdb->prepare(
452 "SELECT
453 COUNT(*) as total_bookings,
454 COALESCE(SUM(total_amount), 0) as total_spent,
455 COALESCE(SUM(travelers_count), 0) as total_travelers,
456 MAX(created_at) as last_booking_date,
457 MAX(travel_date) as last_travel_date
458 FROM {$bookingsTable}
459 WHERE customer_id = %d AND status NOT IN ('cancelled', 'refunded', 'failed')",
460 $customerId
461 ));
462
463 if (!$stats) {
464 return false;
465 }
466
467 // Update customer
468 $result = $wpdb->update(
469 $customersTable,
470 [
471 'total_bookings' => (int) $stats->total_bookings,
472 'total_spent' => (float) $stats->total_spent,
473 'total_travelers' => (int) $stats->total_travelers,
474 'last_booking_date' => $stats->last_booking_date,
475 'last_travel_date' => $stats->last_travel_date,
476 'updated_at' => current_time('mysql'),
477 ],
478 ['id' => $customerId]
479 );
480
481 // Update loyalty tier based on total spent
482 $this->updateLoyaltyTier($customerId, (float) $stats->total_spent);
483
484 return $result !== false;
485 }
486
487 /**
488 * Update loyalty tier based on total spent
489 *
490 * @param int $customerId
491 * @param float $totalSpent
492 * @return void
493 */
494 private function updateLoyaltyTier(int $customerId, float $totalSpent): void
495 {
496 global $wpdb;
497
498 $table = $this->getTableName();
499
500 // Define tier thresholds (can be made configurable via settings)
501 $tier = 'bronze';
502 if ($totalSpent >= 10000) {
503 $tier = 'platinum';
504 } elseif ($totalSpent >= 5000) {
505 $tier = 'gold';
506 } elseif ($totalSpent >= 2000) {
507 $tier = 'silver';
508 }
509
510 // Tier expires 1 year from last update
511 $expiry = date('Y-m-d', strtotime('+1 year'));
512
513 $wpdb->update(
514 $table,
515 [
516 'loyalty_tier' => $tier,
517 'loyalty_tier_expiry' => $expiry,
518 ],
519 ['id' => $customerId]
520 );
521 }
522
523 /**
524 * Search customers
525 *
526 * @param string $search
527 * @param int $limit
528 * @param int $offset
529 * @return array
530 */
531 public function search(string $search, int $limit = 20, int $offset = 0): array
532 {
533 global $wpdb;
534
535 $table = $this->getTableName();
536 $search = '%' . $wpdb->esc_like($search) . '%';
537
538 return $wpdb->get_results($wpdb->prepare(
539 "SELECT * FROM {$table}
540 WHERE first_name LIKE %s
541 OR last_name LIKE %s
542 OR email LIKE %s
543 OR phone LIKE %s
544 ORDER BY created_at DESC
545 LIMIT %d OFFSET %d",
546 $search,
547 $search,
548 $search,
549 $search,
550 $limit,
551 $offset
552 )) ?: [];
553 }
554
555 /**
556 * Get customers with pagination
557 *
558 * @param array $args
559 * @return array ['data' => [], 'total' => int, 'pages' => int]
560 */
561 public function paginate(array $args = []): array
562 {
563 global $wpdb;
564
565 $table = $this->getTableName();
566
567 $page = max(1, (int) ($args['page'] ?? 1));
568 $perPage = max(1, min(100, (int) ($args['per_page'] ?? 20)));
569 $offset = ($page - 1) * $perPage;
570
571 $where = ['1=1'];
572 $params = [];
573
574 // Filter by status
575 if (!empty($args['status'])) {
576 $where[] = 'status = %s';
577 $params[] = sanitize_text_field($args['status']);
578 }
579
580 // Filter by loyalty tier
581 if (!empty($args['loyalty_tier'])) {
582 $where[] = 'loyalty_tier = %s';
583 $params[] = sanitize_text_field($args['loyalty_tier']);
584 }
585
586 // Filter by country
587 if (!empty($args['country'])) {
588 $where[] = 'country = %s';
589 $params[] = sanitize_text_field($args['country']);
590 }
591
592 // Search
593 if (!empty($args['search'])) {
594 $search = '%' . $wpdb->esc_like($args['search']) . '%';
595 $where[] = '(first_name LIKE %s OR last_name LIKE %s OR email LIKE %s OR phone LIKE %s)';
596 $params[] = $search;
597 $params[] = $search;
598 $params[] = $search;
599 $params[] = $search;
600 }
601
602 $whereClause = implode(' AND ', $where);
603
604 // Count total
605 $countQuery = "SELECT COUNT(*) FROM {$table} WHERE {$whereClause}";
606 $total = (int) $wpdb->get_var($wpdb->prepare($countQuery, $params));
607
608 // Get data
609 $orderBy = sanitize_sql_orderby($args['orderby'] ?? 'created_at') ?: 'created_at';
610 $order = strtoupper($args['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
611
612 $dataQuery = "SELECT * FROM {$table} WHERE {$whereClause} ORDER BY {$orderBy} {$order} LIMIT %d OFFSET %d";
613 $params[] = $perPage;
614 $params[] = $offset;
615
616 $data = $wpdb->get_results($wpdb->prepare($dataQuery, $params)) ?: [];
617
618 return [
619 'data' => $data,
620 'total' => $total,
621 'pages' => (int) ceil($total / $perPage),
622 'page' => $page,
623 'per_page' => $perPage,
624 ];
625 }
626
627 /**
628 * Get customer bookings
629 *
630 * @param int $customerId
631 * @return array
632 */
633 public function getBookings(int $customerId): array
634 {
635 return $this->getCustomerBookings($customerId, 1000);
636 }
637
638 /**
639 * Get customer bookings with limit
640 *
641 * @param int $customerId Customer ID
642 * @param int $limit Limit results
643 * @return array
644 */
645 public function getCustomerBookings(int $customerId, int $limit = 10): array
646 {
647 global $wpdb;
648
649 $bookingRepository = new \Yatra\Repositories\BookingRepository();
650 $tripRepository = new \Yatra\Repositories\TripRepository();
651 $bookingsTable = $bookingRepository->getTableName();
652 $tripsTable = $tripRepository->getTableName();
653
654 return $wpdb->get_results($wpdb->prepare(
655 "SELECT b.*, t.title as trip_title, t.slug as trip_slug, t.featured_image as trip_image
656 FROM {$bookingsTable} b
657 LEFT JOIN {$tripsTable} t ON b.trip_id = t.id
658 WHERE b.customer_id = %d
659 ORDER BY b.created_at DESC
660 LIMIT %d",
661 $customerId,
662 $limit
663 )) ?: [];
664 }
665
666 /**
667 * Merge duplicate customers (by email)
668 *
669 * @param int $keepId Customer ID to keep
670 * @param int $mergeId Customer ID to merge into keepId
671 * @return bool
672 */
673 public function mergeCustomers(int $keepId, int $mergeId): bool
674 {
675 global $wpdb;
676
677 $bookingRepository = new \Yatra\Repositories\BookingRepository();
678 $bookingsTable = $bookingRepository->getTableName();
679 $customersTable = $this->getTableName();
680
681 // Update all bookings from mergeId to keepId
682 $wpdb->update(
683 $bookingsTable,
684 ['customer_id' => $keepId],
685 ['customer_id' => $mergeId],
686 ['%d'],
687 ['%d']
688 );
689
690 // Delete the merged customer
691 $wpdb->delete($customersTable, ['id' => $mergeId], ['%d']);
692
693 // Recalculate stats
694 $this->recalculateStats($keepId);
695
696 return true;
697 }
698
699 /**
700 * Get customer statistics
701 *
702 * @return array
703 */
704 public function getStats(): array
705 {
706 $table = $this->getTableName();
707
708 // Total customers by status
709 $statusStatsRaw = $this->wpdb->get_results(
710 "SELECT status, COUNT(*) as count FROM {$table} GROUP BY status",
711 OBJECT_K
712 );
713
714 // Normalize by_status with integer counts and default buckets
715 $byStatus = [
716 'active' => (object) ['status' => 'active', 'count' => 0],
717 'inactive' => (object) ['status' => 'inactive', 'count' => 0],
718 'blocked' => (object) ['status' => 'blocked', 'count' => 0],
719 ];
720
721 foreach ((array)$statusStatsRaw as $status => $row) {
722 $count = isset($row->count) ? (int)$row->count : 0;
723 if (isset($byStatus[$status])) {
724 $byStatus[$status]->count = $count;
725 } else {
726 // keep unexpected statuses too
727 $byStatus[$status] = (object) ['status' => $status, 'count' => $count];
728 }
729 }
730 // Normalize counts for UI expectations
731 return [
732 'all' => array_sum(array_column((array)$byStatus, 'count')),
733 'active' => $byStatus['active']->count ?? 0,
734 'inactive' => $byStatus['inactive']->count ?? 0,
735 'blocked' => $byStatus['blocked']->count ?? 0,
736 ];
737 }
738
739 /**
740 * Get payments for specific booking IDs with related booking and trip information
741 *
742 * @param array $bookingIds Array of booking IDs
743 * @param int $limit Maximum number of payments to return
744 * @return array Array of payment objects with booking and trip details
745 */
746 public function getPaymentsForBookingIds(array $bookingIds, int $limit = 50): array
747 {
748 global $wpdb;
749 $bookingRepository = new BookingRepository();
750 $bookings_table = $bookingRepository->getBookingsTableName();
751 $trips_table = TripsTable::getTableName();
752
753 // Use BookingPaymentsTable for payments
754 $payments_table = BookingPaymentsTable::getTableName();
755
756 $placeholders = implode(',', array_fill(0, count($bookingIds), '%d'));
757
758 $query = $wpdb->prepare(
759 "SELECT p.*,
760 b.reference as booking_reference,
761 b.amount_due as booking_amount_due,
762 b.amount_paid as booking_amount_paid,
763 b.total_amount as booking_total_amount,
764 t.title as trip_title
765 FROM {$payments_table} p
766 LEFT JOIN {$bookings_table} b ON p.booking_id = b.id
767 LEFT JOIN {$trips_table} t ON b.trip_id = t.id
768 WHERE p.booking_id IN ($placeholders)
769 ORDER BY p.created_at DESC
770 LIMIT %d",
771 array_merge($bookingIds, [$limit])
772 );
773
774 return $wpdb->get_results($query) ?: [];
775 }
776 }
777
778