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 / Services / CustomerService.php

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

989 lines 36.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 // Load travellers from the normalized meta tables — the SAME source the
471 // admin booking screens use (TravellerRepository::getByBookingId, each
472 // row carrying its dynamic `fields`). The previous code read
473 // `$booking->travelers`, a column that does NOT exist (the schema only
474 // has `travelers_count`), so `travelers_data` was ALWAYS empty and the
475 // account-page "Travelers Information" card never rendered. Returns []
476 // for older bookings with no normalized rows (card simply hidden), so
477 // this is safe for existing bookings.
478 $travellerRepository = new \Yatra\Repositories\TravellerRepository();
479 $travelersList = $travellerRepository->getByBookingId($bookingId);
480 if (!is_array($travelersList)) {
481 $travelersList = [];
482 }
483
484 // contact_data is stored as JSON; decode to an array so the account
485 // page can read custom contact fields (matches emergency_contact above
486 // and BookingService::formatBookingWithDetails). maybe_unserialize is a
487 // no-op on a JSON string, so a fallback json_decode is required.
488 $contactData = isset($booking->contact_data) ? maybe_unserialize($booking->contact_data) : null;
489 if (is_string($contactData)) {
490 $decodedContact = json_decode($contactData, true);
491 if (is_array($decodedContact)) {
492 $contactData = $decodedContact;
493 }
494 }
495
496 // Derive customer_* convenience fields. The admin React maps
497 // contact_first_name + contact_last_name → customer_name at the
498 // page level (see ViewBooking.tsx), so we mirror the same shape
499 // server-side for the customer account view. Keeping ALL
500 // original contact_* fields too so any caller depending on the
501 // old shape (filters, integrations) stays unaffected.
502 $contactFirst = (string) ($booking->contact_first_name ?? '');
503 $contactLast = (string) ($booking->contact_last_name ?? '');
504 $customerName = trim($contactFirst . ' ' . $contactLast);
505
506 $details = [
507 'id' => (int) ($booking->id ?? 0),
508 'reference' => $booking->reference ?? null,
509 'trip_id' => (int) ($booking->trip_id ?? 0),
510 'trip_title' => $booking->trip_title ?? null,
511 'trip_slug' => $booking->trip_slug ?? null,
512 'trip_url' => function_exists('yatra_get_trip_permalink') ? yatra_get_trip_permalink((int) ($booking->trip_id ?? 0)) : '',
513 'featured_image' => $booking->featured_image ?? null,
514 'created_at' => $booking->created_at ?? null,
515 'updated_at' => $booking->updated_at ?? null,
516 'travel_date' => $booking->travel_date ?? null,
517 'start_date' => $booking->start_date ?? $booking->travel_date ?? null,
518 'end_date' => $booking->end_date ?? null,
519 'travelers_count' => (int) ($booking->travelers_count ?? 0),
520 'total_amount' => (float) ($booking->total_amount ?? 0),
521 'amount_paid' => (float) ($booking->amount_paid ?? 0),
522 'amount_due' => (float) ($booking->amount_due ?? 0),
523 'currency' => $booking->currency ?? null,
524 'payment_status' => $booking->payment_status ?? null,
525 'status' => $booking->status ?? null,
526 'payment_gateway' => $booking->payment_gateway ?? null,
527 'contact_first_name' => $booking->contact_first_name ?? null,
528 'contact_last_name' => $booking->contact_last_name ?? null,
529 'contact_email' => $booking->contact_email ?? null,
530 'contact_phone' => $booking->contact_phone ?? null,
531 'contact_country' => $booking->contact_country ?? null,
532 // Convenience aliases the React account page (BookingDetails.tsx)
533 // reads as `customer_name`/`customer_email`/`customer_phone`.
534 'customer_name' => $customerName !== '' ? $customerName : null,
535 'customer_email' => $booking->contact_email ?? null,
536 'customer_phone' => $booking->contact_phone ?? null,
537 'special_requests' => $booking->special_requests ?? null,
538 'emergency_contact' => $emergencyContact,
539 'contact_data' => $contactData,
540 'travelers' => $travelersList,
541 // React's BookingDetails reads `travelers_data` (same name
542 // the admin ViewBooking screen uses); alias it here so the
543 // "Travelers Information" card actually renders.
544 'travelers_data' => is_array($travelersList) ? $travelersList : [],
545 'payments' => [],
546 ];
547
548 return apply_filters('yatra_customer_booking_details', $details, $booking, $userId);
549 }
550
551 /**
552 * Get customer's payments
553 *
554 * @param int $customerId Customer ID
555 * @param int $limit Limit results
556 * @return array
557 */
558 public function getCustomerPayments(int $customerId, int $limit = 50): array
559 {
560 $bookings = $this->customerRepository->getCustomerBookings($customerId, 1000);
561 $bookingIds = array_map(static function($booking) {
562 if (is_object($booking)) {
563 return (int) ($booking->id ?? 0);
564 }
565 if (is_array($booking)) {
566 return (int) ($booking['id'] ?? 0);
567 }
568 return 0;
569 }, $bookings);
570
571 // Include bookings linked via user ID or email (older bookings may not have customer_id)
572 $customer = $this->customerRepository->find($customerId);
573 if ($customer) {
574 if (!empty($customer->user_id)) {
575 $userBookings = $this->bookingRepository->findByUserId((int) $customer->user_id, 1000);
576 $bookingIds = array_merge($bookingIds, array_map(static function($booking) {
577 if (is_object($booking)) {
578 return (int) ($booking->id ?? 0);
579 }
580 if (is_array($booking)) {
581 return (int) ($booking['id'] ?? 0);
582 }
583 return 0;
584 }, $userBookings));
585 }
586
587 if (!empty($customer->email)) {
588 $emailBookings = $this->bookingRepository->findByContactEmail($customer->email, 1000);
589 $bookingIds = array_merge($bookingIds, array_map(static function($booking) {
590 if (is_object($booking)) {
591 return (int) ($booking->id ?? 0);
592 }
593 if (is_array($booking)) {
594 return (int) ($booking['id'] ?? 0);
595 }
596 return 0;
597 }, $emailBookings));
598 }
599 }
600
601 $bookingIds = array_values(array_unique(array_filter($bookingIds)));
602
603 return $this->getPaymentsForBookingIds($bookingIds, $limit);
604 }
605
606 public function getPaymentsByUserId(int $userId, int $limit = 50): array
607 {
608 $bookings = $this->bookingRepository->findByUserId($userId, 1000);
609
610 $user = get_userdata($userId);
611 if ($user && !empty($user->user_email)) {
612 $bookingsByEmail = $this->bookingRepository->findByContactEmail($user->user_email, 1000);
613 $bookings = array_merge($bookings, $bookingsByEmail);
614 }
615
616 $bookingIds = array_map(static function($booking) {
617 if (is_object($booking)) {
618 return (int) ($booking->id ?? 0);
619 }
620 if (is_array($booking)) {
621 return (int) ($booking['id'] ?? 0);
622 }
623 return 0;
624 }, $bookings);
625
626 return $this->getPaymentsForBookingIds($bookingIds, $limit);
627 }
628
629 private function getPaymentsForBookingIds(array $bookingIds, int $limit = 50): array
630 {
631 $bookingIds = array_values(array_filter(array_map('intval', $bookingIds))); // ensure ints
632
633 if (empty($bookingIds)) {
634 return [];
635 }
636
637 $customerRepository = new \Yatra\Repositories\CustomerRepository();
638 $payments = $customerRepository->getPaymentsForBookingIds($bookingIds, $limit);
639
640 // Route the customer-facing payments through the shared formatter so
641 // they emit the same field shape the rest of the app uses — most
642 // importantly the React Account → Payments tab's aliases
643 // (`date`, `method`, `reference`, `type`, `booking_number`,
644 // `payment_date`, `payment_number`). The previous inline formatter
645 // omitted those keys, which is why the Payments cards rendered
646 // "N/A" for the date, blank for the method, and an empty space
647 // above the "Booking:" label.
648 $paymentService = new \Yatra\Services\PaymentService();
649
650 return array_map(static function ($payment) use ($paymentService) {
651 $row = $paymentService->formatPayment($payment);
652 // Preserve the booking-amount summary fields used by the React
653 // payments tab to decide whether to render a "Pay Remaining" CTA.
654 // formatPayment doesn't know about these (they come from the
655 // CustomerRepository JOIN); attach them here so we keep the
656 // canonical shape AND the extra context.
657 $row['booking_amount_due'] = (float) ($payment->booking_amount_due ?? 0);
658 $row['booking_amount_paid'] = (float) ($payment->booking_amount_paid ?? 0);
659 $row['booking_total_amount'] = (float) ($payment->booking_total_amount ?? 0);
660 return $row;
661 }, $payments);
662 }
663
664 public function getDocumentsForBookings(array $bookings, int $customerId = 0): array
665 {
666 $documents = [];
667
668 // Process each booking individually for vouchers and itineraries
669 // but group by trip for downloads
670 $tripsWithBookings = [];
671
672 foreach ($bookings as $booking) {
673 $bookingId = is_object($booking) ? (int) ($booking->id ?? 0) : (int) ($booking['id'] ?? $booking['booking_id'] ?? 0);
674 $tripId = is_object($booking) ? (int) ($booking->trip_id ?? 0) : (int) ($booking['trip_id'] ?? 0);
675 $tripTitle = is_object($booking) ? (string) ($booking->trip_title ?? '') : (string) ($booking['trip_title'] ?? '');
676 $reference = is_object($booking) ? ($booking->reference ?? null) : ($booking['reference'] ?? null);
677 $status = is_object($booking) ? (string) ($booking->status ?? '') : (string) ($booking['status'] ?? '');
678 $createdAt = is_object($booking) ? (string) ($booking->created_at ?? '') : (string) ($booking['created_at'] ?? '');
679
680 if ($bookingId <= 0) {
681 continue;
682 }
683
684 // Store trip info for downloads (grouped by trip)
685 if ($tripId > 0 && !isset($tripsWithBookings[$tripId])) {
686 $tripsWithBookings[$tripId] = [
687 'booking_id' => $bookingId,
688 'trip_title' => $tripTitle,
689 'reference' => $reference,
690 'status' => $status,
691 'created_at' => $createdAt,
692 ];
693 }
694
695 // Get payments for this booking (invoices per payment)
696 $payments = $this->paymentRepository->findByBookingId($bookingId);
697 foreach ($payments as $payment) {
698 $paymentId = (int) ($payment->id ?? 0);
699 if ($paymentId <= 0) {
700 continue;
701 }
702
703 $paymentStatus = (string) ($payment->status ?? '');
704 if (!in_array($paymentStatus, ['paid', 'completed', 'success'], true)) {
705 continue;
706 }
707
708 $docRef = $reference ?: $bookingId;
709
710 // Invoice per payment
711 $invoiceUrl = rest_url('yatra/v1/payment/' . $paymentId . '/invoice');
712 $invoiceUrl = add_query_arg('_wpnonce', wp_create_nonce('wp_rest'), $invoiceUrl);
713
714 $documents[] = [
715 'id' => 'invoice-payment-' . $paymentId,
716 'name' => sprintf(
717 /* translators: %s: booking reference or ID. */
718 __('Invoice #%s.pdf', 'yatra'),
719 $docRef
720 ),
721 'trip_title' => $tripTitle,
722 'category' => 'invoice',
723 'updated_at' => $payment->created_at ?? $createdAt ?: date('Y-m-d H:i:s'),
724 'url' => $invoiceUrl,
725 'booking_id' => $bookingId,
726 'payment_id' => $paymentId,
727 ];
728 }
729
730 // Voucher per booking
731 if ($status === 'confirmed') {
732 $docRef = $reference ?: $bookingId;
733
734 $voucherUrl = rest_url('yatra/v1/bookings/' . $bookingId . '/voucher');
735 $voucherUrl = add_query_arg('_wpnonce', wp_create_nonce('wp_rest'), $voucherUrl);
736
737 $documents[] = [
738 'id' => 'voucher-' . $bookingId, // Booking-based ID
739 'name' => sprintf(
740 /* translators: %s: booking reference or ID. */
741 __('Travel Voucher #%s.pdf', 'yatra'),
742 $docRef
743 ),
744 'trip_title' => $tripTitle,
745 'category' => 'voucher',
746 'updated_at' => $createdAt ?: date('Y-m-d H:i:s'),
747 'url' => $voucherUrl,
748 'booking_id' => $bookingId,
749 ];
750
751 // Itinerary per booking
752 $itineraryUrl = rest_url('yatra/v1/bookings/' . $bookingId . '/itinerary');
753 $itineraryUrl = add_query_arg('_wpnonce', wp_create_nonce('wp_rest'), $itineraryUrl);
754
755 $documents[] = [
756 'id' => 'itinerary-' . $bookingId, // Booking-based ID
757 'name' => sprintf(
758 /* translators: %s: booking reference or ID. */
759 __('Travel Itinerary #%s.pdf', 'yatra'),
760 $docRef
761 ),
762 'trip_title' => $tripTitle,
763 'category' => 'itinerary',
764 'updated_at' => $createdAt ?: date('Y-m-d H:i:s'),
765 'url' => $itineraryUrl,
766 'booking_id' => $bookingId,
767 ];
768 }
769 }
770
771 usort($documents, function ($a, $b) {
772 return strtotime($b['updated_at']) - strtotime($a['updated_at']);
773 });
774
775 // Apply downloads filter (which groups by trip)
776 $documents = apply_filters('yatra_customer_documents', $documents, $bookings, $customerId);
777
778 return is_array($documents) ? $documents : [];
779 }
780
781 /**
782 * Get customer's documents (invoices, vouchers, itineraries)
783 *
784 * @param int $customerId Customer ID
785 * @return array
786 */
787 public function getCustomerDocuments(int $customerId): array
788 {
789 // Get customer's bookings
790 $bookings = $this->customerRepository->getCustomerBookings($customerId, 1000);
791
792 // Also include bookings linked via user_id/email (older bookings may not have customer_id)
793 $customer = $this->customerRepository->find($customerId);
794 if ($customer) {
795 if (!empty($customer->user_id)) {
796 $userBookings = $this->bookingRepository->findByUserId((int) $customer->user_id, 1000);
797 $bookings = array_merge($bookings, $userBookings);
798 }
799
800 if (!empty($customer->email)) {
801 $emailBookings = $this->bookingRepository->findByContactEmail((string) $customer->email, 1000);
802 $bookings = array_merge($bookings, $emailBookings);
803 }
804 }
805
806 // Deduplicate by booking id
807 $seen = [];
808 $unique = [];
809 foreach ($bookings as $b) {
810 $id = is_object($b) ? ($b->id ?? null) : ($b['id'] ?? $b['booking_id'] ?? null);
811 if ($id && !isset($seen[$id])) {
812 $seen[$id] = true;
813 $unique[] = $b;
814 }
815 }
816
817 return $this->getDocumentsForBookings($unique, $customerId);
818 }
819
820 /**
821 * Get customer's support tickets
822 *
823 * @param int $customerId Customer ID
824 * @return array
825 */
826 public function getCustomerSupportTickets(int $customerId): array
827 {
828 // For now, return empty array as support tickets system may not be implemented yet
829 // This can be extended when support ticket system is added
830 return [];
831 }
832
833 /**
834 * Merge two customer records
835 *
836 * @param int $sourceId Source customer ID (will be deleted)
837 * @param int $targetId Target customer ID (will be kept)
838 * @return array {success: bool, message: string}
839 */
840 public function mergeCustomers(int $sourceId, int $targetId): array
841 {
842 if ($sourceId === $targetId) {
843 return ['success' => false, 'message' => __('Cannot merge customer with itself.', 'yatra')];
844 }
845
846 $source = $this->customerRepository->find($sourceId);
847 $target = $this->customerRepository->find($targetId);
848
849 if (!$source || !$target) {
850 return ['success' => false, 'message' => __('One or both customers not found.', 'yatra')];
851 }
852
853 // Update all bookings to point to target customer
854 $this->bookingRepository->updateCustomerBookings($sourceId, $targetId);
855
856 // Update target customer stats
857 $this->customerRepository->updateCustomer($targetId, [
858 'total_bookings' => (int) $target->total_bookings + (int) $source->total_bookings,
859 'total_spent' => (float) $target->total_spent + (float) $source->total_spent,
860 ]);
861
862 // Delete source customer
863 $this->customerRepository->deleteCustomer($sourceId);
864
865 return [
866 'success' => true,
867 'message' => __('Customers merged successfully.', 'yatra'),
868 ];
869 }
870
871 /**
872 * Format customer for API response
873 *
874 * @param object $customer Raw customer data
875 * @return array
876 */
877 private function formatCustomer(object $customer): array
878 {
879 $name = trim((string) ($customer->first_name ?? '') . ' ' . (string) ($customer->last_name ?? ''));
880 if ($name === '') {
881 $uid = (int) ($customer->user_id ?? 0);
882 if ($uid > 0) {
883 $u = get_userdata($uid);
884 if ($u instanceof \WP_User) {
885 $name = trim((string) $u->display_name);
886 if ($name === '') {
887 $name = trim($u->first_name . ' ' . $u->last_name);
888 }
889 if ($name === '') {
890 $name = (string) $u->user_login;
891 }
892 }
893 }
894 }
895 if ($name === '' && !empty($customer->email)) {
896 $local = explode('@', (string) $customer->email)[0] ?? '';
897 $name = $local !== '' ? $local : $name;
898 }
899
900 $created = $customer->created_at ?? '';
901
902 return [
903 'id' => (int) $customer->id,
904 'user_id' => $customer->user_id ? (int) $customer->user_id : null,
905 'name' => $name,
906 'first_name' => $customer->first_name ?? '',
907 'last_name' => $customer->last_name ?? '',
908 'email' => $customer->email,
909 'phone' => $customer->phone ?? '',
910 'country' => $customer->country ?? '',
911 'city' => $customer->city ?? '',
912 // Address belongs to the account profile too. Without it here the
913 // account page never received the saved value — which is exactly why
914 // city/country updated but address didn't.
915 'address' => $customer->address ?? '',
916 'status' => $customer->status ?? 'active',
917 'total_bookings' => (int) ($customer->total_bookings ?? 0),
918 'total_spent' => (float) ($customer->total_spent ?? 0),
919 'loyalty_tier' => $customer->loyalty_tier ?? 'bronze',
920 'created_at' => $created,
921 'registered_at' => $created,
922 'last_booking_date' => $customer->last_booking_date ?? null,
923 ];
924 }
925
926 /**
927 * Format customer with all details
928 *
929 * @param object $customer Raw customer data
930 * @return array
931 */
932 private function formatCustomerWithDetails(object $customer): array
933 {
934 $formatted = $this->formatCustomer($customer);
935
936 // Add additional fields
937 $formatted['secondary_phone'] = $customer->secondary_phone ?? null;
938 $formatted['address'] = $customer->address ?? null;
939 $formatted['state'] = $customer->state ?? null;
940 $formatted['postal_code'] = $customer->postal_code ?? null;
941 $formatted['date_of_birth'] = $customer->date_of_birth ?? null;
942 $formatted['gender'] = $customer->gender ?? null;
943 $formatted['nationality'] = $customer->nationality ?? null;
944
945 // Emergency contact
946 $formatted['emergency_contact'] = [
947 'name' => $customer->emergency_name ?? null,
948 'phone' => $customer->emergency_phone ?? null,
949 'relationship' => $customer->emergency_relationship ?? null,
950 ];
951
952 // Preferences
953 $formatted['dietary_requirements'] = $customer->dietary_requirements ?? null;
954 $formatted['medical_conditions'] = $customer->medical_conditions ?? null;
955 $formatted['special_needs'] = $customer->special_needs ?? null;
956 $formatted['preferred_language'] = $customer->preferred_language ?? 'en';
957 $formatted['preferred_currency'] = $customer->preferred_currency ?? 'USD';
958
959 // Marketing
960 $formatted['newsletter_optin'] = (bool) ($customer->newsletter_optin ?? false);
961 $formatted['marketing_optin'] = (bool) ($customer->marketing_optin ?? false);
962 $formatted['source'] = $customer->source ?? null;
963
964 // Stats
965 $formatted['total_travelers'] = (int) ($customer->total_travelers ?? 0);
966 $formatted['last_travel_date'] = $customer->last_travel_date ?? null;
967 $formatted['loyalty_points'] = (int) ($customer->loyalty_points ?? 0);
968
969 // Gateway IDs
970 $formatted['stripe_customer_id'] = $customer->stripe_customer_id ?? null;
971 $formatted['paypal_customer_id'] = $customer->paypal_customer_id ?? null;
972 $formatted['razorpay_customer_id'] = $customer->razorpay_customer_id ?? null;
973
974 // Notes
975 $formatted['notes'] = $customer->notes ?? null;
976
977 // Recent bookings
978 $formatted['recent_bookings'] = $customer->recent_bookings ?? [];
979
980 // Timestamps
981 $formatted['updated_at'] = $customer->updated_at ?? null;
982 $formatted['last_login_at'] = $customer->last_login_at ?? null;
983 $formatted['verified_at'] = $customer->verified_at ?? null;
984
985 return $formatted;
986 }
987 }
988
989