PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.6
Yatra – Travel Booking & Tour Operator Software v3.0.6
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 / Services / CustomerService.php

CustomerService.php in Yatra – Travel Booking & Tour Operator Software 3.0.6, at app/Services/CustomerService.php

971 lines 35.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Services;
6
7 use Yatra\Repositories\CustomerRepository;
8 use Yatra\Repositories\BookingRepository;
9 use Yatra\Repositories\PaymentRepository;
10
11 /**
12 * Customer Service
13 *
14 * Contains business logic for customer management.
15 *
16 * @package Yatra\Services
17 */
18 class CustomerService
19 {
20 private CustomerRepository $customerRepository;
21 private BookingRepository $bookingRepository;
22 private PaymentRepository $paymentRepository;
23
24 public function __construct()
25 {
26 $this->customerRepository = new CustomerRepository();
27 $this->bookingRepository = new BookingRepository();
28 $this->paymentRepository = new PaymentRepository();
29 }
30
31 /**
32 * Link any prior guest bookings made under a customer's email
33 * to their newly-created WordPress user account.
34 *
35 * Without this, a customer who books as a guest first and only
36 * registers later will never see those earlier bookings in My
37 * Account — the rows persist with user_id=0 and the My Account
38 * query filters by user_id. Wired to the `user_register` hook
39 * (see Bootstrap::setupWordPressHooks).
40 *
41 * Returns the number of bookings that were linked. Returns 0
42 * silently on any failure — registration should never break on
43 * a reconciliation glitch, and the operator can re-run the
44 * reconciliation later via an admin tool if needed.
45 */
46 public function linkGuestBookingsToUser(int $user_id): int
47 {
48 if ($user_id <= 0) {
49 return 0;
50 }
51 $user = get_userdata($user_id);
52 if (!$user || empty($user->user_email)) {
53 return 0;
54 }
55
56 global $wpdb;
57 $table = \Yatra\Database\Tables\BookingsTable::getTableName();
58
59 // Match by exact email + user_id IS NULL/0. Limited to bookings
60 // not yet linked to any user so we never re-assign someone
61 // else's account.
62 $updated = $wpdb->query(
63 $wpdb->prepare(
64 "UPDATE `{$table}` SET user_id = %d, updated_at = %s
65 WHERE contact_email = %s
66 AND (user_id IS NULL OR user_id = 0)",
67 $user_id,
68 current_time('mysql'),
69 $user->user_email
70 )
71 );
72
73 if ($updated && $updated > 0) {
74 // Side-effect hook so other modules (Pro: Channel Manager,
75 // notifications, audit log) can react. Fires once per
76 // registration with the count + the user object.
77 do_action('yatra_guest_bookings_linked', (int) $user_id, (int) $updated, $user);
78 }
79
80 return (int) max(0, (int) $updated);
81 }
82
83 /**
84 * Get customer statistics
85 *
86 * @return array
87 */
88 public function getStats(): array
89 {
90 return $this->customerRepository->getStats();
91 }
92
93 /**
94 * Get paginated customers
95 *
96 * @param array $filters Filters
97 * @return array
98 */
99 public function getCustomers(array $filters = []): array
100 {
101 $result = $this->customerRepository->paginate($filters);
102
103 $result['data'] = array_map([$this, 'formatCustomer'], $result['data']);
104
105 return $result;
106 }
107
108 /**
109 * Get single customer with details
110 *
111 * @param int $id Customer ID
112 * @return array|null
113 */
114 public function getCustomer(int $id): ?array
115 {
116 $customer = $this->customerRepository->find($id);
117
118 if (!$customer) {
119 return null;
120 }
121
122 return $this->formatCustomerWithDetails($customer);
123 }
124
125 /**
126 * Get customer by email
127 *
128 * @param string $email Customer email
129 * @return array|null
130 */
131 public function getCustomerByEmail(string $email): ?array
132 {
133 $customer = $this->customerRepository->findByEmail($email);
134
135 if (!$customer) {
136 return null;
137 }
138
139 return $this->formatCustomer($customer);
140 }
141
142 /**
143 * Get customer by WordPress user ID
144 *
145 * @param int $userId WordPress user ID
146 * @return array|null
147 */
148 public function getCustomerByUserId(int $userId): ?array
149 {
150 $customer = $this->customerRepository->findByUserId($userId);
151
152 if (!$customer) {
153 return null;
154 }
155
156 return $this->formatCustomer($customer);
157 }
158
159 /**
160 * Account page /customers/me: Yatra customer when linked, otherwise WordPress user (display name, email).
161 */
162 public function getAccountProfileForUser(int $userId): ?array
163 {
164 if ($userId <= 0) {
165 return null;
166 }
167
168 $customer = $this->getCustomerByUserId($userId);
169 if ($customer !== null) {
170 return $customer;
171 }
172
173 $user = get_userdata($userId);
174 if (!$user instanceof \WP_User) {
175 return null;
176 }
177
178 return $this->buildProfileArrayFromWpUser($user);
179 }
180
181 /**
182 * @return array<string, mixed>
183 */
184 private function buildProfileArrayFromWpUser(\WP_User $user): array
185 {
186 $first = trim((string) $user->first_name);
187 $last = trim((string) $user->last_name);
188 $fromParts = trim($first . ' ' . $last);
189 $display = trim((string) $user->display_name);
190 $name = $fromParts !== '' ? $fromParts : $display;
191 if ($name === '') {
192 $name = (string) $user->user_login;
193 }
194
195 return [
196 'id' => 0,
197 'user_id' => (int) $user->ID,
198 'name' => $name,
199 'first_name' => $first,
200 'last_name' => $last,
201 'email' => (string) $user->user_email,
202 'phone' => '',
203 'country' => '',
204 'city' => '',
205 'status' => 'active',
206 'total_bookings' => 0,
207 'total_spent' => 0.0,
208 'loyalty_tier' => '',
209 'created_at' => $user->user_registered,
210 'last_booking_date' => null,
211 'registered_at' => $user->user_registered,
212 ];
213 }
214
215 /**
216 * Create a new customer
217 *
218 * @param array $data Customer data
219 * @return array {success: bool, customer_id?: int, message: string}
220 */
221 public function createCustomer(array $data): array
222 {
223 // Validate required fields
224 if (empty($data['email'])) {
225 return ['success' => false, 'message' => __('Email is required.', 'yatra')];
226 }
227
228 // Validate email format
229 if (!is_email($data['email'])) {
230 return ['success' => false, 'message' => __('Please provide a valid email address.', 'yatra')];
231 }
232
233 // Check if customer already exists
234 $existingCustomer = $this->customerRepository->findByEmail($data['email']);
235 if ($existingCustomer) {
236 return [
237 'success' => false,
238 'message' => __('A customer with this email already exists.', 'yatra'),
239 'existing_id' => (int) $existingCustomer->id,
240 ];
241 }
242
243 // Create customer
244 $customerId = $this->customerRepository->findOrCreate($data);
245
246 if (!$customerId) {
247 return ['success' => false, 'message' => __('Failed to create customer.', 'yatra')];
248 }
249
250 return [
251 'success' => true,
252 'customer_id' => $customerId,
253 'message' => __('Customer created successfully.', 'yatra'),
254 ];
255 }
256
257 /**
258 * Update a customer
259 *
260 * @param int $id Customer ID
261 * @param array $data Customer data
262 * @return array {success: bool, message: string}
263 */
264 public function updateCustomer(int $id, array $data): array
265 {
266 $customer = $this->customerRepository->find($id);
267
268 if (!$customer) {
269 return ['success' => false, 'message' => __('Customer not found.', 'yatra')];
270 }
271
272 // Check email uniqueness if changing
273 if (!empty($data['email']) && $data['email'] !== $customer->email) {
274 $existingCustomer = $this->customerRepository->findByEmail($data['email']);
275 if ($existingCustomer && (int) $existingCustomer->id !== $id) {
276 return ['success' => false, 'message' => __('Email is already in use by another customer.', 'yatra')];
277 }
278 }
279
280 $updated = $this->customerRepository->updateCustomer($id, $data);
281
282 if (!$updated) {
283 return ['success' => false, 'message' => __('Failed to update customer.', 'yatra')];
284 }
285
286 return [
287 'success' => true,
288 'message' => __('Customer updated successfully.', 'yatra'),
289 ];
290 }
291
292 /**
293 * Update customer status
294 *
295 * @param int $id Customer ID
296 * @param string $status New status (active, inactive, blocked)
297 * @return array {success: bool, message: string}
298 */
299 public function updateStatus(int $id, string $status): array
300 {
301 $validStatuses = ['active', 'inactive', 'blocked'];
302
303 if (!in_array($status, $validStatuses, true)) {
304 return ['success' => false, 'message' => __('Invalid status.', 'yatra')];
305 }
306
307 $customer = $this->customerRepository->find($id);
308
309 if (!$customer) {
310 return ['success' => false, 'message' => __('Customer not found.', 'yatra')];
311 }
312
313 $updated = $this->customerRepository->updateCustomer($id, ['status' => $status]);
314
315 if (!$updated) {
316 return ['success' => false, 'message' => __('Failed to update status.', 'yatra')];
317 }
318
319 return [
320 'success' => true,
321 'message' => sprintf(
322 /* translators: %s: new customer status. */
323 __('Customer status updated to %s.', 'yatra'),
324 $status
325 ),
326 ];
327 }
328
329 /**
330 * Delete a customer
331 *
332 * @param int $id Customer ID
333 * @return array {success: bool, message: string}
334 */
335 public function deleteCustomer(int $id): array
336 {
337 $customer = $this->customerRepository->find($id);
338
339 if (!$customer) {
340 return ['success' => false, 'message' => __('Customer not found.', 'yatra')];
341 }
342
343 // Check for existing bookings
344 $bookings = $this->customerRepository->getCustomerBookings($id, 1);
345 if (!empty($bookings)) {
346 return [
347 'success' => false,
348 'message' => __('Cannot delete customer with existing bookings. Consider deactivating instead.', 'yatra'),
349 ];
350 }
351
352 $deleted = $this->customerRepository->deleteCustomer($id);
353
354 if (!$deleted) {
355 return ['success' => false, 'message' => __('Failed to delete customer.', 'yatra')];
356 }
357
358 return [
359 'success' => true,
360 'message' => __('Customer deleted successfully.', 'yatra'),
361 ];
362 }
363
364 /**
365 * Get customer's bookings
366 *
367 * @param int $customerId Customer ID
368 * @param int $limit Limit results
369 * @return array
370 */
371 public function getCustomerBookings(int $customerId, int $limit = 10): array
372 {
373 return $this->customerRepository->getCustomerBookings($customerId, $limit);
374 }
375
376 /**
377 * Get bookings by WordPress user ID (checks both customer_id and user_id)
378 *
379 * @param int $userId WordPress user ID
380 * @param int $limit Limit results
381 * @return array
382 */
383 public function getBookingsByUserId(int $userId, int $limit = 10): array
384 {
385 // First, try to get customer and bookings by customer_id
386 $customer = $this->getCustomerByUserId($userId);
387 $bookings = [];
388
389 if ($customer) {
390 $bookings = $this->getCustomerBookings((int) $customer['id'], $limit);
391 }
392
393 // Also get bookings directly by user_id (in case bookings were made before customer record was created)
394 $bookingsByUserId = $this->bookingRepository->findByUserId($userId, $limit);
395
396 // And include bookings made via the same email address
397 $emailBookings = [];
398 $user = get_userdata($userId);
399 if ($user && !empty($user->user_email)) {
400 $emailBookings = $this->bookingRepository->findByContactEmail($user->user_email, $limit);
401 }
402
403 // Merge and deduplicate by booking ID
404 $bookingIds = [];
405 $allBookings = [];
406 $sources = [$bookings, $bookingsByUserId, $emailBookings];
407
408 foreach ($sources as $collection) {
409 foreach ($collection as $booking) {
410 $bookingId = is_array($booking) ? ($booking['id'] ?? $booking['booking_id'] ?? null) : ($booking->id ?? null);
411 if ($bookingId && !in_array($bookingId, $bookingIds, true)) {
412 $bookingIds[] = $bookingId;
413 $allBookings[] = $booking;
414 }
415 }
416 }
417
418 // Limit results
419 if ($limit > 0 && count($allBookings) > $limit) {
420 $allBookings = array_slice($allBookings, 0, $limit);
421 }
422
423 return $allBookings;
424 }
425
426 public function getBookingDetailsForUser(int $userId, int $bookingId): ?array
427 {
428 if ($userId <= 0 || $bookingId <= 0) {
429 return null;
430 }
431
432 $booking = $this->bookingRepository->findWithTrip($bookingId);
433 if (!$booking) {
434 return null;
435 }
436
437 $user = get_userdata($userId);
438 $userEmail = ($user && !empty($user->user_email)) ? (string) $user->user_email : '';
439
440 $customer = $this->getCustomerByUserId($userId);
441 $customerId = $customer ? (int) ($customer['id'] ?? 0) : 0;
442
443 $bookingUserId = isset($booking->user_id) ? (int) $booking->user_id : 0;
444 $bookingCustomerId = isset($booking->customer_id) ? (int) $booking->customer_id : 0;
445 $bookingEmail = isset($booking->contact_email) ? (string) $booking->contact_email : '';
446
447 $allowed = false;
448 if ($bookingUserId > 0 && $bookingUserId === $userId) {
449 $allowed = true;
450 }
451 if (!$allowed && $customerId > 0 && $bookingCustomerId > 0 && $bookingCustomerId === $customerId) {
452 $allowed = true;
453 }
454 if (!$allowed && $userEmail !== '' && $bookingEmail !== '' && strtolower($userEmail) === strtolower($bookingEmail)) {
455 $allowed = true;
456 }
457
458 if (!$allowed) {
459 return null;
460 }
461
462 $emergencyContact = isset($booking->emergency_contact) ? maybe_unserialize($booking->emergency_contact) : null;
463 if (is_string($emergencyContact)) {
464 $decoded = json_decode($emergencyContact, true);
465 if (is_array($decoded)) {
466 $emergencyContact = $decoded;
467 }
468 }
469
470 // Unserialise the travelers payload here so both the legacy
471 // `travelers` key AND the React-side `travelers_data` alias
472 // share the same in-memory value — otherwise we'd unserialise
473 // twice and drift if one consumer mutates the array.
474 $travelersList = isset($booking->travelers) ? maybe_unserialize($booking->travelers) : null;
475 if (is_string($travelersList)) {
476 $decoded = json_decode($travelersList, true);
477 if (is_array($decoded)) {
478 $travelersList = $decoded;
479 }
480 }
481
482 // Derive customer_* convenience fields. The admin React maps
483 // contact_first_name + contact_last_name → customer_name at the
484 // page level (see ViewBooking.tsx), so we mirror the same shape
485 // server-side for the customer account view. Keeping ALL
486 // original contact_* fields too so any caller depending on the
487 // old shape (filters, integrations) stays unaffected.
488 $contactFirst = (string) ($booking->contact_first_name ?? '');
489 $contactLast = (string) ($booking->contact_last_name ?? '');
490 $customerName = trim($contactFirst . ' ' . $contactLast);
491
492 $details = [
493 'id' => (int) ($booking->id ?? 0),
494 'reference' => $booking->reference ?? null,
495 'trip_id' => (int) ($booking->trip_id ?? 0),
496 'trip_title' => $booking->trip_title ?? null,
497 'trip_slug' => $booking->trip_slug ?? null,
498 'trip_url' => function_exists('yatra_get_trip_permalink') ? yatra_get_trip_permalink((int) ($booking->trip_id ?? 0)) : '',
499 'featured_image' => $booking->featured_image ?? null,
500 'created_at' => $booking->created_at ?? null,
501 'updated_at' => $booking->updated_at ?? null,
502 'travel_date' => $booking->travel_date ?? null,
503 'start_date' => $booking->start_date ?? $booking->travel_date ?? null,
504 'end_date' => $booking->end_date ?? null,
505 'travelers_count' => (int) ($booking->travelers_count ?? 0),
506 'total_amount' => (float) ($booking->total_amount ?? 0),
507 'amount_paid' => (float) ($booking->amount_paid ?? 0),
508 'amount_due' => (float) ($booking->amount_due ?? 0),
509 'currency' => $booking->currency ?? null,
510 'payment_status' => $booking->payment_status ?? null,
511 'status' => $booking->status ?? null,
512 'payment_gateway' => $booking->payment_gateway ?? null,
513 'contact_first_name' => $booking->contact_first_name ?? null,
514 'contact_last_name' => $booking->contact_last_name ?? null,
515 'contact_email' => $booking->contact_email ?? null,
516 'contact_phone' => $booking->contact_phone ?? null,
517 'contact_country' => $booking->contact_country ?? null,
518 // Convenience aliases the React account page (BookingDetails.tsx)
519 // reads as `customer_name`/`customer_email`/`customer_phone`.
520 'customer_name' => $customerName !== '' ? $customerName : null,
521 'customer_email' => $booking->contact_email ?? null,
522 'customer_phone' => $booking->contact_phone ?? null,
523 'special_requests' => $booking->special_requests ?? null,
524 'emergency_contact' => $emergencyContact,
525 'contact_data' => isset($booking->contact_data) ? maybe_unserialize($booking->contact_data) : null,
526 'travelers' => $travelersList,
527 // React's BookingDetails reads `travelers_data` (same name
528 // the admin ViewBooking screen uses); alias it here so the
529 // "Travelers Information" card actually renders.
530 'travelers_data' => is_array($travelersList) ? $travelersList : [],
531 'payments' => [],
532 ];
533
534 return apply_filters('yatra_customer_booking_details', $details, $booking, $userId);
535 }
536
537 /**
538 * Get customer's payments
539 *
540 * @param int $customerId Customer ID
541 * @param int $limit Limit results
542 * @return array
543 */
544 public function getCustomerPayments(int $customerId, int $limit = 50): array
545 {
546 $bookings = $this->customerRepository->getCustomerBookings($customerId, 1000);
547 $bookingIds = array_map(static function($booking) {
548 if (is_object($booking)) {
549 return (int) ($booking->id ?? 0);
550 }
551 if (is_array($booking)) {
552 return (int) ($booking['id'] ?? 0);
553 }
554 return 0;
555 }, $bookings);
556
557 // Include bookings linked via user ID or email (older bookings may not have customer_id)
558 $customer = $this->customerRepository->find($customerId);
559 if ($customer) {
560 if (!empty($customer->user_id)) {
561 $userBookings = $this->bookingRepository->findByUserId((int) $customer->user_id, 1000);
562 $bookingIds = array_merge($bookingIds, array_map(static function($booking) {
563 if (is_object($booking)) {
564 return (int) ($booking->id ?? 0);
565 }
566 if (is_array($booking)) {
567 return (int) ($booking['id'] ?? 0);
568 }
569 return 0;
570 }, $userBookings));
571 }
572
573 if (!empty($customer->email)) {
574 $emailBookings = $this->bookingRepository->findByContactEmail($customer->email, 1000);
575 $bookingIds = array_merge($bookingIds, array_map(static function($booking) {
576 if (is_object($booking)) {
577 return (int) ($booking->id ?? 0);
578 }
579 if (is_array($booking)) {
580 return (int) ($booking['id'] ?? 0);
581 }
582 return 0;
583 }, $emailBookings));
584 }
585 }
586
587 $bookingIds = array_values(array_unique(array_filter($bookingIds)));
588
589 return $this->getPaymentsForBookingIds($bookingIds, $limit);
590 }
591
592 public function getPaymentsByUserId(int $userId, int $limit = 50): array
593 {
594 $bookings = $this->bookingRepository->findByUserId($userId, 1000);
595
596 $user = get_userdata($userId);
597 if ($user && !empty($user->user_email)) {
598 $bookingsByEmail = $this->bookingRepository->findByContactEmail($user->user_email, 1000);
599 $bookings = array_merge($bookings, $bookingsByEmail);
600 }
601
602 $bookingIds = array_map(static function($booking) {
603 if (is_object($booking)) {
604 return (int) ($booking->id ?? 0);
605 }
606 if (is_array($booking)) {
607 return (int) ($booking['id'] ?? 0);
608 }
609 return 0;
610 }, $bookings);
611
612 return $this->getPaymentsForBookingIds($bookingIds, $limit);
613 }
614
615 private function getPaymentsForBookingIds(array $bookingIds, int $limit = 50): array
616 {
617 $bookingIds = array_values(array_filter(array_map('intval', $bookingIds))); // ensure ints
618
619 if (empty($bookingIds)) {
620 return [];
621 }
622
623 $customerRepository = new \Yatra\Repositories\CustomerRepository();
624 $payments = $customerRepository->getPaymentsForBookingIds($bookingIds, $limit);
625
626 // Route the customer-facing payments through the shared formatter so
627 // they emit the same field shape the rest of the app uses — most
628 // importantly the React Account → Payments tab's aliases
629 // (`date`, `method`, `reference`, `type`, `booking_number`,
630 // `payment_date`, `payment_number`). The previous inline formatter
631 // omitted those keys, which is why the Payments cards rendered
632 // "N/A" for the date, blank for the method, and an empty space
633 // above the "Booking:" label.
634 $paymentService = new \Yatra\Services\PaymentService();
635
636 return array_map(static function ($payment) use ($paymentService) {
637 $row = $paymentService->formatPayment($payment);
638 // Preserve the booking-amount summary fields used by the React
639 // payments tab to decide whether to render a "Pay Remaining" CTA.
640 // formatPayment doesn't know about these (they come from the
641 // CustomerRepository JOIN); attach them here so we keep the
642 // canonical shape AND the extra context.
643 $row['booking_amount_due'] = (float) ($payment->booking_amount_due ?? 0);
644 $row['booking_amount_paid'] = (float) ($payment->booking_amount_paid ?? 0);
645 $row['booking_total_amount'] = (float) ($payment->booking_total_amount ?? 0);
646 return $row;
647 }, $payments);
648 }
649
650 public function getDocumentsForBookings(array $bookings, int $customerId = 0): array
651 {
652 $documents = [];
653
654 // Process each booking individually for vouchers and itineraries
655 // but group by trip for downloads
656 $tripsWithBookings = [];
657
658 foreach ($bookings as $booking) {
659 $bookingId = is_object($booking) ? (int) ($booking->id ?? 0) : (int) ($booking['id'] ?? $booking['booking_id'] ?? 0);
660 $tripId = is_object($booking) ? (int) ($booking->trip_id ?? 0) : (int) ($booking['trip_id'] ?? 0);
661 $tripTitle = is_object($booking) ? (string) ($booking->trip_title ?? '') : (string) ($booking['trip_title'] ?? '');
662 $reference = is_object($booking) ? ($booking->reference ?? null) : ($booking['reference'] ?? null);
663 $status = is_object($booking) ? (string) ($booking->status ?? '') : (string) ($booking['status'] ?? '');
664 $createdAt = is_object($booking) ? (string) ($booking->created_at ?? '') : (string) ($booking['created_at'] ?? '');
665
666 if ($bookingId <= 0) {
667 continue;
668 }
669
670 // Store trip info for downloads (grouped by trip)
671 if ($tripId > 0 && !isset($tripsWithBookings[$tripId])) {
672 $tripsWithBookings[$tripId] = [
673 'booking_id' => $bookingId,
674 'trip_title' => $tripTitle,
675 'reference' => $reference,
676 'status' => $status,
677 'created_at' => $createdAt,
678 ];
679 }
680
681 // Get payments for this booking (invoices per payment)
682 $payments = $this->paymentRepository->findByBookingId($bookingId);
683 foreach ($payments as $payment) {
684 $paymentId = (int) ($payment->id ?? 0);
685 if ($paymentId <= 0) {
686 continue;
687 }
688
689 $paymentStatus = (string) ($payment->status ?? '');
690 if (!in_array($paymentStatus, ['paid', 'completed', 'success'], true)) {
691 continue;
692 }
693
694 $docRef = $reference ?: $bookingId;
695
696 // Invoice per payment
697 $invoiceUrl = rest_url('yatra/v1/payment/' . $paymentId . '/invoice');
698 $invoiceUrl = add_query_arg('_wpnonce', wp_create_nonce('wp_rest'), $invoiceUrl);
699
700 $documents[] = [
701 'id' => 'invoice-payment-' . $paymentId,
702 'name' => sprintf(
703 /* translators: %s: booking reference or ID. */
704 __('Invoice #%s.pdf', 'yatra'),
705 $docRef
706 ),
707 'trip_title' => $tripTitle,
708 'category' => 'invoice',
709 'updated_at' => $payment->created_at ?? $createdAt ?: date('Y-m-d H:i:s'),
710 'url' => $invoiceUrl,
711 'booking_id' => $bookingId,
712 'payment_id' => $paymentId,
713 ];
714 }
715
716 // Voucher per booking
717 if ($status === 'confirmed') {
718 $docRef = $reference ?: $bookingId;
719
720 $voucherUrl = rest_url('yatra/v1/bookings/' . $bookingId . '/voucher');
721 $voucherUrl = add_query_arg('_wpnonce', wp_create_nonce('wp_rest'), $voucherUrl);
722
723 $documents[] = [
724 'id' => 'voucher-' . $bookingId, // Booking-based ID
725 'name' => sprintf(
726 /* translators: %s: booking reference or ID. */
727 __('Travel Voucher #%s.pdf', 'yatra'),
728 $docRef
729 ),
730 'trip_title' => $tripTitle,
731 'category' => 'voucher',
732 'updated_at' => $createdAt ?: date('Y-m-d H:i:s'),
733 'url' => $voucherUrl,
734 'booking_id' => $bookingId,
735 ];
736
737 // Itinerary per booking
738 $itineraryUrl = rest_url('yatra/v1/bookings/' . $bookingId . '/itinerary');
739 $itineraryUrl = add_query_arg('_wpnonce', wp_create_nonce('wp_rest'), $itineraryUrl);
740
741 $documents[] = [
742 'id' => 'itinerary-' . $bookingId, // Booking-based ID
743 'name' => sprintf(
744 /* translators: %s: booking reference or ID. */
745 __('Travel Itinerary #%s.pdf', 'yatra'),
746 $docRef
747 ),
748 'trip_title' => $tripTitle,
749 'category' => 'itinerary',
750 'updated_at' => $createdAt ?: date('Y-m-d H:i:s'),
751 'url' => $itineraryUrl,
752 'booking_id' => $bookingId,
753 ];
754 }
755 }
756
757 usort($documents, function ($a, $b) {
758 return strtotime($b['updated_at']) - strtotime($a['updated_at']);
759 });
760
761 // Apply downloads filter (which groups by trip)
762 $documents = apply_filters('yatra_customer_documents', $documents, $bookings, $customerId);
763
764 return is_array($documents) ? $documents : [];
765 }
766
767 /**
768 * Get customer's documents (invoices, vouchers, itineraries)
769 *
770 * @param int $customerId Customer ID
771 * @return array
772 */
773 public function getCustomerDocuments(int $customerId): array
774 {
775 // Get customer's bookings
776 $bookings = $this->customerRepository->getCustomerBookings($customerId, 1000);
777
778 // Also include bookings linked via user_id/email (older bookings may not have customer_id)
779 $customer = $this->customerRepository->find($customerId);
780 if ($customer) {
781 if (!empty($customer->user_id)) {
782 $userBookings = $this->bookingRepository->findByUserId((int) $customer->user_id, 1000);
783 $bookings = array_merge($bookings, $userBookings);
784 }
785
786 if (!empty($customer->email)) {
787 $emailBookings = $this->bookingRepository->findByContactEmail((string) $customer->email, 1000);
788 $bookings = array_merge($bookings, $emailBookings);
789 }
790 }
791
792 // Deduplicate by booking id
793 $seen = [];
794 $unique = [];
795 foreach ($bookings as $b) {
796 $id = is_object($b) ? ($b->id ?? null) : ($b['id'] ?? $b['booking_id'] ?? null);
797 if ($id && !isset($seen[$id])) {
798 $seen[$id] = true;
799 $unique[] = $b;
800 }
801 }
802
803 return $this->getDocumentsForBookings($unique, $customerId);
804 }
805
806 /**
807 * Get customer's support tickets
808 *
809 * @param int $customerId Customer ID
810 * @return array
811 */
812 public function getCustomerSupportTickets(int $customerId): array
813 {
814 // For now, return empty array as support tickets system may not be implemented yet
815 // This can be extended when support ticket system is added
816 return [];
817 }
818
819 /**
820 * Merge two customer records
821 *
822 * @param int $sourceId Source customer ID (will be deleted)
823 * @param int $targetId Target customer ID (will be kept)
824 * @return array {success: bool, message: string}
825 */
826 public function mergeCustomers(int $sourceId, int $targetId): array
827 {
828 if ($sourceId === $targetId) {
829 return ['success' => false, 'message' => __('Cannot merge customer with itself.', 'yatra')];
830 }
831
832 $source = $this->customerRepository->find($sourceId);
833 $target = $this->customerRepository->find($targetId);
834
835 if (!$source || !$target) {
836 return ['success' => false, 'message' => __('One or both customers not found.', 'yatra')];
837 }
838
839 // Update all bookings to point to target customer
840 $this->bookingRepository->updateCustomerBookings($sourceId, $targetId);
841
842 // Update target customer stats
843 $this->customerRepository->updateCustomer($targetId, [
844 'total_bookings' => (int) $target->total_bookings + (int) $source->total_bookings,
845 'total_spent' => (float) $target->total_spent + (float) $source->total_spent,
846 ]);
847
848 // Delete source customer
849 $this->customerRepository->deleteCustomer($sourceId);
850
851 return [
852 'success' => true,
853 'message' => __('Customers merged successfully.', 'yatra'),
854 ];
855 }
856
857 /**
858 * Format customer for API response
859 *
860 * @param object $customer Raw customer data
861 * @return array
862 */
863 private function formatCustomer(object $customer): array
864 {
865 $name = trim((string) ($customer->first_name ?? '') . ' ' . (string) ($customer->last_name ?? ''));
866 if ($name === '') {
867 $uid = (int) ($customer->user_id ?? 0);
868 if ($uid > 0) {
869 $u = get_userdata($uid);
870 if ($u instanceof \WP_User) {
871 $name = trim((string) $u->display_name);
872 if ($name === '') {
873 $name = trim($u->first_name . ' ' . $u->last_name);
874 }
875 if ($name === '') {
876 $name = (string) $u->user_login;
877 }
878 }
879 }
880 }
881 if ($name === '' && !empty($customer->email)) {
882 $local = explode('@', (string) $customer->email)[0] ?? '';
883 $name = $local !== '' ? $local : $name;
884 }
885
886 $created = $customer->created_at ?? '';
887
888 return [
889 'id' => (int) $customer->id,
890 'user_id' => $customer->user_id ? (int) $customer->user_id : null,
891 'name' => $name,
892 'first_name' => $customer->first_name ?? '',
893 'last_name' => $customer->last_name ?? '',
894 'email' => $customer->email,
895 'phone' => $customer->phone ?? '',
896 'country' => $customer->country ?? '',
897 'city' => $customer->city ?? '',
898 'status' => $customer->status ?? 'active',
899 'total_bookings' => (int) ($customer->total_bookings ?? 0),
900 'total_spent' => (float) ($customer->total_spent ?? 0),
901 'loyalty_tier' => $customer->loyalty_tier ?? 'bronze',
902 'created_at' => $created,
903 'registered_at' => $created,
904 'last_booking_date' => $customer->last_booking_date ?? null,
905 ];
906 }
907
908 /**
909 * Format customer with all details
910 *
911 * @param object $customer Raw customer data
912 * @return array
913 */
914 private function formatCustomerWithDetails(object $customer): array
915 {
916 $formatted = $this->formatCustomer($customer);
917
918 // Add additional fields
919 $formatted['secondary_phone'] = $customer->secondary_phone ?? null;
920 $formatted['address'] = $customer->address ?? null;
921 $formatted['state'] = $customer->state ?? null;
922 $formatted['postal_code'] = $customer->postal_code ?? null;
923 $formatted['date_of_birth'] = $customer->date_of_birth ?? null;
924 $formatted['gender'] = $customer->gender ?? null;
925 $formatted['nationality'] = $customer->nationality ?? null;
926
927 // Emergency contact
928 $formatted['emergency_contact'] = [
929 'name' => $customer->emergency_name ?? null,
930 'phone' => $customer->emergency_phone ?? null,
931 'relationship' => $customer->emergency_relationship ?? null,
932 ];
933
934 // Preferences
935 $formatted['dietary_requirements'] = $customer->dietary_requirements ?? null;
936 $formatted['medical_conditions'] = $customer->medical_conditions ?? null;
937 $formatted['special_needs'] = $customer->special_needs ?? null;
938 $formatted['preferred_language'] = $customer->preferred_language ?? 'en';
939 $formatted['preferred_currency'] = $customer->preferred_currency ?? 'USD';
940
941 // Marketing
942 $formatted['newsletter_optin'] = (bool) ($customer->newsletter_optin ?? false);
943 $formatted['marketing_optin'] = (bool) ($customer->marketing_optin ?? false);
944 $formatted['source'] = $customer->source ?? null;
945
946 // Stats
947 $formatted['total_travelers'] = (int) ($customer->total_travelers ?? 0);
948 $formatted['last_travel_date'] = $customer->last_travel_date ?? null;
949 $formatted['loyalty_points'] = (int) ($customer->loyalty_points ?? 0);
950
951 // Gateway IDs
952 $formatted['stripe_customer_id'] = $customer->stripe_customer_id ?? null;
953 $formatted['paypal_customer_id'] = $customer->paypal_customer_id ?? null;
954 $formatted['razorpay_customer_id'] = $customer->razorpay_customer_id ?? null;
955
956 // Notes
957 $formatted['notes'] = $customer->notes ?? null;
958
959 // Recent bookings
960 $formatted['recent_bookings'] = $customer->recent_bookings ?? [];
961
962 // Timestamps
963 $formatted['updated_at'] = $customer->updated_at ?? null;
964 $formatted['last_login_at'] = $customer->last_login_at ?? null;
965 $formatted['verified_at'] = $customer->verified_at ?? null;
966
967 return $formatted;
968 }
969 }
970
971