PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.10
Yatra – Travel Booking & Tour Operator Software v3.0.10
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.10, at app/Services/CustomerService.php

1,229 lines 48.7 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 $profile = $this->getCustomerByUserId($userId);
169 if ($profile === null) {
170 $user = get_userdata($userId);
171 if (!$user instanceof \WP_User) {
172 return null;
173 }
174 $profile = $this->buildProfileArrayFromWpUser($user);
175 }
176
177 // Surface any pending (unconfirmed) email change so the account UI can
178 // show "awaiting confirmation" — WordPress stores it in the _new_email meta.
179 $pending = get_user_meta($userId, '_new_email', true);
180 $profile['pending_email'] = (is_array($pending) && !empty($pending['newemail']))
181 ? (string) $pending['newemail']
182 : '';
183
184 return $profile;
185 }
186
187 /**
188 * Request a change to the account's login email, following WordPress core's
189 * pending-change pattern ({@see send_confirmation_on_profile_email()}): the
190 * email is NOT changed directly. Validate, store the pending change in the
191 * `_new_email` user meta (the same shape core uses), and email a confirmation
192 * link to the NEW address; the change only applies when that link is clicked.
193 *
194 * @return array{success:bool, message:string, pending_email?:string}
195 */
196 public function requestEmailChange(int $userId, string $newEmail): array
197 {
198 $user = get_userdata($userId);
199 if (!$user instanceof \WP_User) {
200 return ['success' => false, 'message' => __('Account not found.', 'yatra')];
201 }
202
203 $newEmail = trim($newEmail);
204 if ($newEmail === '' || !is_email($newEmail)) {
205 return ['success' => false, 'message' => __('Please enter a valid email address.', 'yatra')];
206 }
207 if (strtolower($newEmail) === strtolower((string) $user->user_email)) {
208 return ['success' => false, 'message' => __('That is already your email address.', 'yatra')];
209 }
210 if (email_exists($newEmail)) {
211 delete_user_meta($userId, '_new_email');
212 return ['success' => false, 'message' => __('That email address is already in use.', 'yatra')];
213 }
214
215 // Identical meta shape + hash to WordPress core (wp-includes/user.php),
216 // so the pending change is fully compatible with core's own flow.
217 $hash = md5($newEmail . time() . wp_rand());
218 update_user_meta($userId, '_new_email', ['hash' => $hash, 'newemail' => $newEmail]);
219
220 $this->sendEmailChangeConfirmation($user, $newEmail, $hash);
221
222 return [
223 'success' => true,
224 'message' => sprintf(
225 /* translators: %s: the new email address. */
226 __('A confirmation link has been sent to %s. Your email address will change once you confirm it there.', 'yatra'),
227 $newEmail
228 ),
229 'pending_email' => $newEmail,
230 ];
231 }
232
233 /**
234 * Re-send the confirmation email for an already-pending email change. Reuses
235 * the stored hash + address, so the original link stays valid (this does not
236 * rotate the token or change any state). Returns an error if nothing is pending.
237 *
238 * @return array{success:bool, message:string, pending_email?:string}
239 */
240 public function resendEmailChangeConfirmation(int $userId): array
241 {
242 $user = get_userdata($userId);
243 if (!$user instanceof \WP_User) {
244 return ['success' => false, 'message' => __('Account not found.', 'yatra')];
245 }
246
247 $pending = get_user_meta($userId, '_new_email', true);
248 if (!is_array($pending) || empty($pending['hash']) || empty($pending['newemail'])) {
249 return ['success' => false, 'message' => __('There is no pending email change to confirm.', 'yatra')];
250 }
251
252 $newEmail = (string) $pending['newemail'];
253 $this->sendEmailChangeConfirmation($user, $newEmail, (string) $pending['hash']);
254
255 return [
256 'success' => true,
257 'message' => sprintf(
258 /* translators: %s: the pending new email address. */
259 __('We\'ve re-sent the confirmation link to %s.', 'yatra'),
260 $newEmail
261 ),
262 'pending_email' => $newEmail,
263 ];
264 }
265
266 /**
267 * Cancel a pending email change, discarding the stored token so the emailed
268 * link no longer works. Mirrors WordPress core's "dismiss" action
269 * (profile.php?dismiss=<id>_new_email), which simply deletes the `_new_email`
270 * user meta. Safe to call when nothing is pending.
271 *
272 * @return array{success:bool, message:string}
273 */
274 public function cancelEmailChange(int $userId): array
275 {
276 if (!get_userdata($userId) instanceof \WP_User) {
277 return ['success' => false, 'message' => __('Account not found.', 'yatra')];
278 }
279
280 delete_user_meta($userId, '_new_email');
281
282 return ['success' => true, 'message' => __('The pending email change has been cancelled.', 'yatra')];
283 }
284
285 /**
286 * Send the email-change confirmation to the NEW address. Mirrors WordPress
287 * core's message and reuses its `new_user_email_content` filter, but points
288 * the confirmation link at the frontend account endpoint (not wp-admin).
289 */
290 private function sendEmailChangeConfirmation(\WP_User $user, string $newEmail, string $hash): void
291 {
292 // Point at the front-end account page (a normal request with cookie auth),
293 // NOT a REST endpoint — a browser GET to REST carries no nonce and would be
294 // read as anonymous. AccountPageHandler consumes the token there.
295 $accountUrl = home_url('/' . trailingslashit(SettingsService::getAccountBase()));
296 $confirmUrl = add_query_arg('yatra_email_token', rawurlencode($hash), $accountUrl);
297 $firstName = trim((string) $user->first_name) ?: (trim((string) $user->display_name) ?: (string) $user->user_login);
298
299 // Send through the Yatra transactional-email template system (branded HTML,
300 // merge tags, operator-editable in Settings → Email Templates) rather than a
301 // raw wp_mail. {{verification_link}} is reused for the confirmation link.
302 TransactionalEmailTemplateService::sendIfEnabled(
303 TransactionalEmailTemplateService::TYPE_ACCOUNT_EMAIL_CHANGE_REQUEST,
304 $newEmail,
305 [
306 'customer_first_name' => $firstName,
307 'customer_name' => $firstName,
308 'customer_email' => (string) $user->user_email,
309 'new_email' => $newEmail,
310 'verification_link' => $confirmUrl,
311 'intro_paragraph' => __('You recently requested to change the email address on your account. To confirm this new address, click the button below.', 'yatra'),
312 'footer_note' => __('If you did not request this change, you can safely ignore this email — your address will not change.', 'yatra'),
313 ]
314 );
315 }
316
317 /**
318 * Notify the OLD address that the account email was changed (WordPress core
319 * sends an equivalent security notice). Uses the editable "Email changed"
320 * transactional template.
321 */
322 private function sendEmailChangedNotice(string $oldEmail, string $firstName, string $newEmail): void
323 {
324 TransactionalEmailTemplateService::sendIfEnabled(
325 TransactionalEmailTemplateService::TYPE_ACCOUNT_EMAIL_CHANGED,
326 $oldEmail,
327 [
328 'customer_first_name' => $firstName,
329 'customer_name' => $firstName,
330 'customer_email' => $oldEmail,
331 'new_email' => $newEmail,
332 'intro_paragraph' => __('The email address on your account was just changed. If this was you, no further action is needed.', 'yatra'),
333 'footer_note' => __('If you did not make this change, please contact us immediately — your account may have been accessed by someone else.', 'yatra'),
334 ]
335 );
336 }
337
338 /**
339 * Confirm a pending email change (WordPress core pattern): verify the hash
340 * against the `_new_email` meta, apply via wp_update_user, then clear the meta.
341 *
342 * @return array{success:bool, message:string}
343 */
344 public function confirmEmailChange(int $userId, string $hash): array
345 {
346 $pending = get_user_meta($userId, '_new_email', true);
347 if (!is_array($pending) || empty($pending['hash']) || empty($pending['newemail'])) {
348 return ['success' => false, 'message' => __('No pending email change was found.', 'yatra')];
349 }
350 if (!hash_equals((string) $pending['hash'], (string) $hash)) {
351 return ['success' => false, 'message' => __('This confirmation link is invalid or has expired.', 'yatra')];
352 }
353
354 $newEmail = trim((string) $pending['newemail']);
355 $existing = $newEmail !== '' ? email_exists($newEmail) : false;
356 if ($existing && (int) $existing !== $userId) {
357 delete_user_meta($userId, '_new_email');
358 return ['success' => false, 'message' => __('That email address is now in use. Please try again.', 'yatra')];
359 }
360
361 // Capture the OLD address + name before the update, so we can send the
362 // "email changed" security notice to it afterwards.
363 $preUser = get_userdata($userId);
364 $oldEmail = $preUser instanceof \WP_User ? (string) $preUser->user_email : '';
365 $firstName = $preUser instanceof \WP_User
366 ? (trim((string) $preUser->first_name) ?: (trim((string) $preUser->display_name) ?: (string) $preUser->user_login))
367 : '';
368
369 $result = wp_update_user(['ID' => $userId, 'user_email' => $newEmail]);
370 if (is_wp_error($result)) {
371 return ['success' => false, 'message' => wp_strip_all_tags($result->get_error_message())];
372 }
373
374 // Keep the linked Yatra customer record in step with the WP user email,
375 // otherwise the account page would keep showing the old address (it reads
376 // the customer table's own email column).
377 $customer = $this->customerRepository->findByUserId($userId);
378 if ($customer && strtolower((string) $customer->email) !== strtolower($newEmail)) {
379 $this->customerRepository->updateCustomer((int) $customer->id, ['email' => $newEmail]);
380 }
381
382 delete_user_meta($userId, '_new_email');
383
384 // Security notice to the old address (best-effort; never block the change).
385 if ($oldEmail !== '' && strtolower($oldEmail) !== strtolower($newEmail)) {
386 $this->sendEmailChangedNotice($oldEmail, $firstName, $newEmail);
387 }
388
389 return ['success' => true, 'message' => __('Your email address has been updated.', 'yatra')];
390 }
391
392 /**
393 * @return array<string, mixed>
394 */
395 private function buildProfileArrayFromWpUser(\WP_User $user): array
396 {
397 $first = trim((string) $user->first_name);
398 $last = trim((string) $user->last_name);
399 $fromParts = trim($first . ' ' . $last);
400 $display = trim((string) $user->display_name);
401 $name = $fromParts !== '' ? $fromParts : $display;
402 if ($name === '') {
403 $name = (string) $user->user_login;
404 }
405
406 return [
407 'id' => 0,
408 'user_id' => (int) $user->ID,
409 'name' => $name,
410 'first_name' => $first,
411 'last_name' => $last,
412 'email' => (string) $user->user_email,
413 'phone' => '',
414 'country' => '',
415 'city' => '',
416 'status' => 'active',
417 'total_bookings' => 0,
418 'total_spent' => 0.0,
419 'loyalty_tier' => '',
420 'created_at' => $user->user_registered,
421 'last_booking_date' => null,
422 'registered_at' => $user->user_registered,
423 ];
424 }
425
426 /**
427 * Create a new customer
428 *
429 * @param array $data Customer data
430 * @return array {success: bool, customer_id?: int, message: string}
431 */
432 public function createCustomer(array $data): array
433 {
434 // Validate required fields
435 if (empty($data['email'])) {
436 return ['success' => false, 'message' => __('Email is required.', 'yatra')];
437 }
438
439 // Validate email format
440 if (!is_email($data['email'])) {
441 return ['success' => false, 'message' => __('Please provide a valid email address.', 'yatra')];
442 }
443
444 // Check if customer already exists
445 $existingCustomer = $this->customerRepository->findByEmail($data['email']);
446 if ($existingCustomer) {
447 return [
448 'success' => false,
449 'message' => __('A customer with this email already exists.', 'yatra'),
450 'existing_id' => (int) $existingCustomer->id,
451 ];
452 }
453
454 // Create customer
455 $customerId = $this->customerRepository->findOrCreate($data);
456
457 if (!$customerId) {
458 return ['success' => false, 'message' => __('Failed to create customer.', 'yatra')];
459 }
460
461 return [
462 'success' => true,
463 'customer_id' => $customerId,
464 'message' => __('Customer created successfully.', 'yatra'),
465 ];
466 }
467
468 /**
469 * Update a customer
470 *
471 * @param int $id Customer ID
472 * @param array $data Customer data
473 * @return array {success: bool, message: string}
474 */
475 public function updateCustomer(int $id, array $data): array
476 {
477 $customer = $this->customerRepository->find($id);
478
479 if (!$customer) {
480 return ['success' => false, 'message' => __('Customer not found.', 'yatra')];
481 }
482
483 // Check email uniqueness if changing
484 if (!empty($data['email']) && $data['email'] !== $customer->email) {
485 $existingCustomer = $this->customerRepository->findByEmail($data['email']);
486 if ($existingCustomer && (int) $existingCustomer->id !== $id) {
487 return ['success' => false, 'message' => __('Email is already in use by another customer.', 'yatra')];
488 }
489 }
490
491 $updated = $this->customerRepository->updateCustomer($id, $data);
492
493 if (!$updated) {
494 return ['success' => false, 'message' => __('Failed to update customer.', 'yatra')];
495 }
496
497 return [
498 'success' => true,
499 'message' => __('Customer updated successfully.', 'yatra'),
500 ];
501 }
502
503 /**
504 * Update customer status
505 *
506 * @param int $id Customer ID
507 * @param string $status New status (active, inactive, blocked)
508 * @return array {success: bool, message: string}
509 */
510 public function updateStatus(int $id, string $status): array
511 {
512 $validStatuses = ['active', 'inactive', 'blocked'];
513
514 if (!in_array($status, $validStatuses, true)) {
515 return ['success' => false, 'message' => __('Invalid status.', 'yatra')];
516 }
517
518 $customer = $this->customerRepository->find($id);
519
520 if (!$customer) {
521 return ['success' => false, 'message' => __('Customer not found.', 'yatra')];
522 }
523
524 $updated = $this->customerRepository->updateCustomer($id, ['status' => $status]);
525
526 if (!$updated) {
527 return ['success' => false, 'message' => __('Failed to update status.', 'yatra')];
528 }
529
530 return [
531 'success' => true,
532 'message' => sprintf(
533 /* translators: %s: new customer status. */
534 __('Customer status updated to %s.', 'yatra'),
535 $status
536 ),
537 ];
538 }
539
540 /**
541 * Delete a customer
542 *
543 * @param int $id Customer ID
544 * @return array {success: bool, message: string}
545 */
546 public function deleteCustomer(int $id): array
547 {
548 $customer = $this->customerRepository->find($id);
549
550 if (!$customer) {
551 return ['success' => false, 'message' => __('Customer not found.', 'yatra')];
552 }
553
554 // Check for existing bookings
555 $bookings = $this->customerRepository->getCustomerBookings($id, 1);
556 if (!empty($bookings)) {
557 return [
558 'success' => false,
559 'message' => __('Cannot delete customer with existing bookings. Consider deactivating instead.', 'yatra'),
560 ];
561 }
562
563 $deleted = $this->customerRepository->deleteCustomer($id);
564
565 if (!$deleted) {
566 return ['success' => false, 'message' => __('Failed to delete customer.', 'yatra')];
567 }
568
569 return [
570 'success' => true,
571 'message' => __('Customer deleted successfully.', 'yatra'),
572 ];
573 }
574
575 /**
576 * Get customer's bookings
577 *
578 * @param int $customerId Customer ID
579 * @param int $limit Limit results
580 * @return array
581 */
582 public function getCustomerBookings(int $customerId, int $limit = 10): array
583 {
584 return $this->customerRepository->getCustomerBookings($customerId, $limit);
585 }
586
587 /**
588 * Get bookings by WordPress user ID (checks both customer_id and user_id)
589 *
590 * @param int $userId WordPress user ID
591 * @param int $limit Limit results
592 * @return array
593 */
594 public function getBookingsByUserId(int $userId, int $limit = 10): array
595 {
596 // First, try to get customer and bookings by customer_id
597 $customer = $this->getCustomerByUserId($userId);
598 $bookings = [];
599
600 if ($customer) {
601 $bookings = $this->getCustomerBookings((int) $customer['id'], $limit);
602 }
603
604 // Also get bookings directly by user_id (in case bookings were made before customer record was created)
605 $bookingsByUserId = $this->bookingRepository->findByUserId($userId, $limit);
606
607 // And include bookings made via the same email address
608 $emailBookings = [];
609 $user = get_userdata($userId);
610 if ($user && !empty($user->user_email)) {
611 $emailBookings = $this->bookingRepository->findByContactEmail($user->user_email, $limit);
612 }
613
614 // Merge and deduplicate by booking ID
615 $bookingIds = [];
616 $allBookings = [];
617 $sources = [$bookings, $bookingsByUserId, $emailBookings];
618
619 foreach ($sources as $collection) {
620 foreach ($collection as $booking) {
621 $bookingId = is_array($booking) ? ($booking['id'] ?? $booking['booking_id'] ?? null) : ($booking->id ?? null);
622 if ($bookingId && !in_array($bookingId, $bookingIds, true)) {
623 $bookingIds[] = $bookingId;
624 $allBookings[] = $booking;
625 }
626 }
627 }
628
629 // Limit results
630 if ($limit > 0 && count($allBookings) > $limit) {
631 $allBookings = array_slice($allBookings, 0, $limit);
632 }
633
634 return $allBookings;
635 }
636
637 public function getBookingDetailsForUser(int $userId, int $bookingId): ?array
638 {
639 if ($userId <= 0 || $bookingId <= 0) {
640 return null;
641 }
642
643 $booking = $this->bookingRepository->findWithTrip($bookingId);
644 if (!$booking) {
645 return null;
646 }
647
648 $user = get_userdata($userId);
649 $userEmail = ($user && !empty($user->user_email)) ? (string) $user->user_email : '';
650
651 $customer = $this->getCustomerByUserId($userId);
652 $customerId = $customer ? (int) ($customer['id'] ?? 0) : 0;
653
654 $bookingUserId = isset($booking->user_id) ? (int) $booking->user_id : 0;
655 $bookingCustomerId = isset($booking->customer_id) ? (int) $booking->customer_id : 0;
656 $bookingEmail = isset($booking->contact_email) ? (string) $booking->contact_email : '';
657
658 $allowed = false;
659 if ($bookingUserId > 0 && $bookingUserId === $userId) {
660 $allowed = true;
661 }
662 if (!$allowed && $customerId > 0 && $bookingCustomerId > 0 && $bookingCustomerId === $customerId) {
663 $allowed = true;
664 }
665 if (!$allowed && $userEmail !== '' && $bookingEmail !== '' && strtolower($userEmail) === strtolower($bookingEmail)) {
666 $allowed = true;
667 }
668
669 if (!$allowed) {
670 return null;
671 }
672
673 $emergencyContact = isset($booking->emergency_contact) ? maybe_unserialize($booking->emergency_contact) : null;
674 if (is_string($emergencyContact)) {
675 $decoded = json_decode($emergencyContact, true);
676 if (is_array($decoded)) {
677 $emergencyContact = $decoded;
678 }
679 }
680
681 // Load travellers from the normalized meta tables — the SAME source the
682 // admin booking screens use (TravellerRepository::getByBookingId, each
683 // row carrying its dynamic `fields`). The previous code read
684 // `$booking->travelers`, a column that does NOT exist (the schema only
685 // has `travelers_count`), so `travelers_data` was ALWAYS empty and the
686 // account-page "Travelers Information" card never rendered. Returns []
687 // for older bookings with no normalized rows (card simply hidden), so
688 // this is safe for existing bookings.
689 $travellerRepository = new \Yatra\Repositories\TravellerRepository();
690 $travelersList = $travellerRepository->getByBookingId($bookingId);
691 if (!is_array($travelersList)) {
692 $travelersList = [];
693 }
694
695 // contact_data is stored as JSON; decode to an array so the account
696 // page can read custom contact fields (matches emergency_contact above
697 // and BookingService::formatBookingWithDetails). maybe_unserialize is a
698 // no-op on a JSON string, so a fallback json_decode is required.
699 $contactData = isset($booking->contact_data) ? maybe_unserialize($booking->contact_data) : null;
700 if (is_string($contactData)) {
701 $decodedContact = json_decode($contactData, true);
702 if (is_array($decodedContact)) {
703 $contactData = $decodedContact;
704 }
705 }
706
707 // Derive customer_* convenience fields. The admin React maps
708 // contact_first_name + contact_last_name → customer_name at the
709 // page level (see ViewBooking.tsx), so we mirror the same shape
710 // server-side for the customer account view. Keeping ALL
711 // original contact_* fields too so any caller depending on the
712 // old shape (filters, integrations) stays unaffected.
713 $contactFirst = (string) ($booking->contact_first_name ?? '');
714 $contactLast = (string) ($booking->contact_last_name ?? '');
715 $customerName = trim($contactFirst . ' ' . $contactLast);
716
717 $details = [
718 'id' => (int) ($booking->id ?? 0),
719 'reference' => $booking->reference ?? null,
720 'trip_id' => (int) ($booking->trip_id ?? 0),
721 'trip_title' => $booking->trip_title ?? null,
722 'trip_slug' => $booking->trip_slug ?? null,
723 'trip_url' => function_exists('yatra_get_trip_permalink') ? yatra_get_trip_permalink((int) ($booking->trip_id ?? 0)) : '',
724 'featured_image' => $booking->featured_image ?? null,
725 'created_at' => $booking->created_at ?? null,
726 'updated_at' => $booking->updated_at ?? null,
727 'travel_date' => $booking->travel_date ?? null,
728 'start_date' => $booking->start_date ?? $booking->travel_date ?? null,
729 'end_date' => $booking->end_date ?? null,
730 'travelers_count' => (int) ($booking->travelers_count ?? 0),
731 'total_amount' => (float) ($booking->total_amount ?? 0),
732 'amount_paid' => (float) ($booking->amount_paid ?? 0),
733 'amount_due' => (float) ($booking->amount_due ?? 0),
734 'currency' => $booking->currency ?? null,
735 'payment_status' => $booking->payment_status ?? null,
736 'status' => $booking->status ?? null,
737 'payment_gateway' => $booking->payment_gateway ?? null,
738 'contact_first_name' => $booking->contact_first_name ?? null,
739 'contact_last_name' => $booking->contact_last_name ?? null,
740 'contact_email' => $booking->contact_email ?? null,
741 'contact_phone' => $booking->contact_phone ?? null,
742 'contact_country' => $booking->contact_country ?? null,
743 // Convenience aliases the React account page (BookingDetails.tsx)
744 // reads as `customer_name`/`customer_email`/`customer_phone`.
745 'customer_name' => $customerName !== '' ? $customerName : null,
746 'customer_email' => $booking->contact_email ?? null,
747 'customer_phone' => $booking->contact_phone ?? null,
748 'special_requests' => $booking->special_requests ?? null,
749 'emergency_contact' => $emergencyContact,
750 'contact_data' => $contactData,
751 'travelers' => $travelersList,
752 // React's BookingDetails reads `travelers_data` (same name
753 // the admin ViewBooking screen uses); alias it here so the
754 // "Travelers Information" card actually renders.
755 'travelers_data' => is_array($travelersList) ? $travelersList : [],
756 'payments' => [],
757 ];
758
759 return apply_filters('yatra_customer_booking_details', $details, $booking, $userId);
760 }
761
762 /**
763 * Get customer's payments
764 *
765 * @param int $customerId Customer ID
766 * @param int $limit Limit results
767 * @return array
768 */
769 public function getCustomerPayments(int $customerId, int $limit = 50): array
770 {
771 $bookings = $this->customerRepository->getCustomerBookings($customerId, 1000);
772 $bookingIds = array_map(static function($booking) {
773 if (is_object($booking)) {
774 return (int) ($booking->id ?? 0);
775 }
776 if (is_array($booking)) {
777 return (int) ($booking['id'] ?? 0);
778 }
779 return 0;
780 }, $bookings);
781
782 // Include bookings linked via user ID or email (older bookings may not have customer_id)
783 $customer = $this->customerRepository->find($customerId);
784 if ($customer) {
785 if (!empty($customer->user_id)) {
786 $userBookings = $this->bookingRepository->findByUserId((int) $customer->user_id, 1000);
787 $bookingIds = array_merge($bookingIds, array_map(static function($booking) {
788 if (is_object($booking)) {
789 return (int) ($booking->id ?? 0);
790 }
791 if (is_array($booking)) {
792 return (int) ($booking['id'] ?? 0);
793 }
794 return 0;
795 }, $userBookings));
796 }
797
798 if (!empty($customer->email)) {
799 $emailBookings = $this->bookingRepository->findByContactEmail($customer->email, 1000);
800 $bookingIds = array_merge($bookingIds, array_map(static function($booking) {
801 if (is_object($booking)) {
802 return (int) ($booking->id ?? 0);
803 }
804 if (is_array($booking)) {
805 return (int) ($booking['id'] ?? 0);
806 }
807 return 0;
808 }, $emailBookings));
809 }
810 }
811
812 $bookingIds = array_values(array_unique(array_filter($bookingIds)));
813
814 return $this->getPaymentsForBookingIds($bookingIds, $limit);
815 }
816
817 public function getPaymentsByUserId(int $userId, int $limit = 50): array
818 {
819 $bookings = $this->bookingRepository->findByUserId($userId, 1000);
820
821 $user = get_userdata($userId);
822 if ($user && !empty($user->user_email)) {
823 $bookingsByEmail = $this->bookingRepository->findByContactEmail($user->user_email, 1000);
824 $bookings = array_merge($bookings, $bookingsByEmail);
825 }
826
827 $bookingIds = array_map(static function($booking) {
828 if (is_object($booking)) {
829 return (int) ($booking->id ?? 0);
830 }
831 if (is_array($booking)) {
832 return (int) ($booking['id'] ?? 0);
833 }
834 return 0;
835 }, $bookings);
836
837 return $this->getPaymentsForBookingIds($bookingIds, $limit);
838 }
839
840 private function getPaymentsForBookingIds(array $bookingIds, int $limit = 50): array
841 {
842 $bookingIds = array_values(array_filter(array_map('intval', $bookingIds))); // ensure ints
843
844 if (empty($bookingIds)) {
845 return [];
846 }
847
848 $customerRepository = new \Yatra\Repositories\CustomerRepository();
849 $payments = $customerRepository->getPaymentsForBookingIds($bookingIds, $limit);
850
851 // Route the customer-facing payments through the shared formatter so
852 // they emit the same field shape the rest of the app uses — most
853 // importantly the React Account → Payments tab's aliases
854 // (`date`, `method`, `reference`, `type`, `booking_number`,
855 // `payment_date`, `payment_number`). The previous inline formatter
856 // omitted those keys, which is why the Payments cards rendered
857 // "N/A" for the date, blank for the method, and an empty space
858 // above the "Booking:" label.
859 $paymentService = new \Yatra\Services\PaymentService();
860
861 return array_map(static function ($payment) use ($paymentService) {
862 $row = $paymentService->formatPayment($payment);
863 // Preserve the booking-amount summary fields used by the React
864 // payments tab to decide whether to render a "Pay Remaining" CTA.
865 // formatPayment doesn't know about these (they come from the
866 // CustomerRepository JOIN); attach them here so we keep the
867 // canonical shape AND the extra context.
868 $row['booking_amount_due'] = (float) ($payment->booking_amount_due ?? 0);
869 $row['booking_amount_paid'] = (float) ($payment->booking_amount_paid ?? 0);
870 $row['booking_total_amount'] = (float) ($payment->booking_total_amount ?? 0);
871 return $row;
872 }, $payments);
873 }
874
875 public function getDocumentsForBookings(array $bookings, int $customerId = 0): array
876 {
877 $documents = [];
878
879 // Process each booking individually for vouchers and itineraries
880 // but group by trip for downloads
881 $tripsWithBookings = [];
882
883 foreach ($bookings as $booking) {
884 $bookingId = is_object($booking) ? (int) ($booking->id ?? 0) : (int) ($booking['id'] ?? $booking['booking_id'] ?? 0);
885 $tripId = is_object($booking) ? (int) ($booking->trip_id ?? 0) : (int) ($booking['trip_id'] ?? 0);
886 $tripTitle = is_object($booking) ? (string) ($booking->trip_title ?? '') : (string) ($booking['trip_title'] ?? '');
887 $reference = is_object($booking) ? ($booking->reference ?? null) : ($booking['reference'] ?? null);
888 $status = is_object($booking) ? (string) ($booking->status ?? '') : (string) ($booking['status'] ?? '');
889 $createdAt = is_object($booking) ? (string) ($booking->created_at ?? '') : (string) ($booking['created_at'] ?? '');
890
891 if ($bookingId <= 0) {
892 continue;
893 }
894
895 // Store trip info for downloads (grouped by trip)
896 if ($tripId > 0 && !isset($tripsWithBookings[$tripId])) {
897 $tripsWithBookings[$tripId] = [
898 'booking_id' => $bookingId,
899 'trip_title' => $tripTitle,
900 'reference' => $reference,
901 'status' => $status,
902 'created_at' => $createdAt,
903 ];
904 }
905
906 // Get payments for this booking (invoices per payment)
907 $payments = $this->paymentRepository->findByBookingId($bookingId);
908 $hasPaidInvoice = false;
909 foreach ($payments as $payment) {
910 $paymentId = (int) ($payment->id ?? 0);
911 if ($paymentId <= 0) {
912 continue;
913 }
914
915 $paymentStatus = (string) ($payment->status ?? '');
916 if (!in_array($paymentStatus, ['paid', 'completed', 'success'], true)) {
917 continue;
918 }
919
920 $docRef = $reference ?: $bookingId;
921
922 // Invoice per payment
923 $invoiceUrl = rest_url('yatra/v1/payment/' . $paymentId . '/invoice');
924 $invoiceUrl = add_query_arg('_wpnonce', wp_create_nonce('wp_rest'), $invoiceUrl);
925
926 $documents[] = [
927 'id' => 'invoice-payment-' . $paymentId,
928 'name' => sprintf(
929 /* translators: %s: booking reference or ID. */
930 __('Invoice #%s.pdf', 'yatra'),
931 $docRef
932 ),
933 'trip_title' => $tripTitle,
934 'category' => 'invoice',
935 'updated_at' => $payment->created_at ?? $createdAt ?: date('Y-m-d H:i:s'),
936 'url' => $invoiceUrl,
937 'booking_id' => $bookingId,
938 'payment_id' => $paymentId,
939 ];
940 $hasPaidInvoice = true;
941 }
942
943 // Pro-forma invoice for offline / unpaid bookings (e.g. Bank Transfer):
944 // no completed payment yet, but there is a balance due. Carries the
945 // gateway's payment instructions so the customer knows how to pay.
946 $amountDue = is_object($booking)
947 ? (float) ($booking->amount_due ?? $booking->booking_amount_due ?? 0)
948 : (float) ($booking['amount_due'] ?? $booking['booking_amount_due'] ?? 0);
949 if (!$hasPaidInvoice && $amountDue > 0) {
950 $proformaToken = \Yatra\Controllers\PaymentGatewayController::issueInvoiceToken(0, $bookingId);
951 $proformaUrl = add_query_arg(
952 ['invoice_token' => $proformaToken, '_wpnonce' => wp_create_nonce('wp_rest')],
953 rest_url('yatra/v1/booking/' . $bookingId . '/invoice')
954 );
955 $documents[] = [
956 'id' => 'invoice-booking-' . $bookingId,
957 'name' => sprintf(
958 /* translators: %s: booking reference or ID. */
959 __('Invoice #%s.pdf', 'yatra'),
960 $reference ?: $bookingId
961 ),
962 'trip_title' => $tripTitle,
963 'category' => 'invoice',
964 'updated_at' => $createdAt ?: date('Y-m-d H:i:s'),
965 'url' => $proformaUrl,
966 'booking_id' => $bookingId,
967 ];
968 }
969
970 // Voucher per booking
971 if ($status === 'confirmed') {
972 $docRef = $reference ?: $bookingId;
973
974 $voucherUrl = rest_url('yatra/v1/bookings/' . $bookingId . '/voucher');
975 $voucherUrl = add_query_arg('_wpnonce', wp_create_nonce('wp_rest'), $voucherUrl);
976
977 $documents[] = [
978 'id' => 'voucher-' . $bookingId, // Booking-based ID
979 'name' => sprintf(
980 /* translators: %s: booking reference or ID. */
981 __('Travel Voucher #%s.pdf', 'yatra'),
982 $docRef
983 ),
984 'trip_title' => $tripTitle,
985 'category' => 'voucher',
986 'updated_at' => $createdAt ?: date('Y-m-d H:i:s'),
987 'url' => $voucherUrl,
988 'booking_id' => $bookingId,
989 ];
990
991 // Itinerary per booking
992 $itineraryUrl = rest_url('yatra/v1/bookings/' . $bookingId . '/itinerary');
993 $itineraryUrl = add_query_arg('_wpnonce', wp_create_nonce('wp_rest'), $itineraryUrl);
994
995 $documents[] = [
996 'id' => 'itinerary-' . $bookingId, // Booking-based ID
997 'name' => sprintf(
998 /* translators: %s: booking reference or ID. */
999 __('Travel Itinerary #%s.pdf', 'yatra'),
1000 $docRef
1001 ),
1002 'trip_title' => $tripTitle,
1003 'category' => 'itinerary',
1004 'updated_at' => $createdAt ?: date('Y-m-d H:i:s'),
1005 'url' => $itineraryUrl,
1006 'booking_id' => $bookingId,
1007 ];
1008 }
1009 }
1010
1011 usort($documents, function ($a, $b) {
1012 return strtotime($b['updated_at']) - strtotime($a['updated_at']);
1013 });
1014
1015 // Apply downloads filter (which groups by trip)
1016 $documents = apply_filters('yatra_customer_documents', $documents, $bookings, $customerId);
1017
1018 return is_array($documents) ? $documents : [];
1019 }
1020
1021 /**
1022 * Get customer's documents (invoices, vouchers, itineraries)
1023 *
1024 * @param int $customerId Customer ID
1025 * @return array
1026 */
1027 public function getCustomerDocuments(int $customerId): array
1028 {
1029 // Get customer's bookings
1030 $bookings = $this->customerRepository->getCustomerBookings($customerId, 1000);
1031
1032 // Also include bookings linked via user_id/email (older bookings may not have customer_id)
1033 $customer = $this->customerRepository->find($customerId);
1034 if ($customer) {
1035 if (!empty($customer->user_id)) {
1036 $userBookings = $this->bookingRepository->findByUserId((int) $customer->user_id, 1000);
1037 $bookings = array_merge($bookings, $userBookings);
1038 }
1039
1040 if (!empty($customer->email)) {
1041 $emailBookings = $this->bookingRepository->findByContactEmail((string) $customer->email, 1000);
1042 $bookings = array_merge($bookings, $emailBookings);
1043 }
1044 }
1045
1046 // Deduplicate by booking id
1047 $seen = [];
1048 $unique = [];
1049 foreach ($bookings as $b) {
1050 $id = is_object($b) ? ($b->id ?? null) : ($b['id'] ?? $b['booking_id'] ?? null);
1051 if ($id && !isset($seen[$id])) {
1052 $seen[$id] = true;
1053 $unique[] = $b;
1054 }
1055 }
1056
1057 return $this->getDocumentsForBookings($unique, $customerId);
1058 }
1059
1060 /**
1061 * Get customer's support tickets
1062 *
1063 * @param int $customerId Customer ID
1064 * @return array
1065 */
1066 public function getCustomerSupportTickets(int $customerId): array
1067 {
1068 // For now, return empty array as support tickets system may not be implemented yet
1069 // This can be extended when support ticket system is added
1070 return [];
1071 }
1072
1073 /**
1074 * Merge two customer records
1075 *
1076 * @param int $sourceId Source customer ID (will be deleted)
1077 * @param int $targetId Target customer ID (will be kept)
1078 * @return array {success: bool, message: string}
1079 */
1080 public function mergeCustomers(int $sourceId, int $targetId): array
1081 {
1082 if ($sourceId === $targetId) {
1083 return ['success' => false, 'message' => __('Cannot merge customer with itself.', 'yatra')];
1084 }
1085
1086 $source = $this->customerRepository->find($sourceId);
1087 $target = $this->customerRepository->find($targetId);
1088
1089 if (!$source || !$target) {
1090 return ['success' => false, 'message' => __('One or both customers not found.', 'yatra')];
1091 }
1092
1093 // Update all bookings to point to target customer
1094 $this->bookingRepository->updateCustomerBookings($sourceId, $targetId);
1095
1096 // Update target customer stats
1097 $this->customerRepository->updateCustomer($targetId, [
1098 'total_bookings' => (int) $target->total_bookings + (int) $source->total_bookings,
1099 'total_spent' => (float) $target->total_spent + (float) $source->total_spent,
1100 ]);
1101
1102 // Delete source customer
1103 $this->customerRepository->deleteCustomer($sourceId);
1104
1105 return [
1106 'success' => true,
1107 'message' => __('Customers merged successfully.', 'yatra'),
1108 ];
1109 }
1110
1111 /**
1112 * Format customer for API response
1113 *
1114 * @param object $customer Raw customer data
1115 * @return array
1116 */
1117 private function formatCustomer(object $customer): array
1118 {
1119 $name = trim((string) ($customer->first_name ?? '') . ' ' . (string) ($customer->last_name ?? ''));
1120 if ($name === '') {
1121 $uid = (int) ($customer->user_id ?? 0);
1122 if ($uid > 0) {
1123 $u = get_userdata($uid);
1124 if ($u instanceof \WP_User) {
1125 $name = trim((string) $u->display_name);
1126 if ($name === '') {
1127 $name = trim($u->first_name . ' ' . $u->last_name);
1128 }
1129 if ($name === '') {
1130 $name = (string) $u->user_login;
1131 }
1132 }
1133 }
1134 }
1135 if ($name === '' && !empty($customer->email)) {
1136 $local = explode('@', (string) $customer->email)[0] ?? '';
1137 $name = $local !== '' ? $local : $name;
1138 }
1139
1140 $created = $customer->created_at ?? '';
1141
1142 return [
1143 'id' => (int) $customer->id,
1144 'user_id' => $customer->user_id ? (int) $customer->user_id : null,
1145 'name' => $name,
1146 'first_name' => $customer->first_name ?? '',
1147 'last_name' => $customer->last_name ?? '',
1148 'email' => $customer->email,
1149 'phone' => $customer->phone ?? '',
1150 'country' => $customer->country ?? '',
1151 'city' => $customer->city ?? '',
1152 // Address belongs to the account profile too. Without it here the
1153 // account page never received the saved value — which is exactly why
1154 // city/country updated but address didn't.
1155 'address' => $customer->address ?? '',
1156 'status' => $customer->status ?? 'active',
1157 'total_bookings' => (int) ($customer->total_bookings ?? 0),
1158 'total_spent' => (float) ($customer->total_spent ?? 0),
1159 'loyalty_tier' => $customer->loyalty_tier ?? 'bronze',
1160 'created_at' => $created,
1161 'registered_at' => $created,
1162 'last_booking_date' => $customer->last_booking_date ?? null,
1163 ];
1164 }
1165
1166 /**
1167 * Format customer with all details
1168 *
1169 * @param object $customer Raw customer data
1170 * @return array
1171 */
1172 private function formatCustomerWithDetails(object $customer): array
1173 {
1174 $formatted = $this->formatCustomer($customer);
1175
1176 // Add additional fields
1177 $formatted['secondary_phone'] = $customer->secondary_phone ?? null;
1178 $formatted['address'] = $customer->address ?? null;
1179 $formatted['state'] = $customer->state ?? null;
1180 $formatted['postal_code'] = $customer->postal_code ?? null;
1181 $formatted['date_of_birth'] = $customer->date_of_birth ?? null;
1182 $formatted['gender'] = $customer->gender ?? null;
1183 $formatted['nationality'] = $customer->nationality ?? null;
1184
1185 // Emergency contact
1186 $formatted['emergency_contact'] = [
1187 'name' => $customer->emergency_name ?? null,
1188 'phone' => $customer->emergency_phone ?? null,
1189 'relationship' => $customer->emergency_relationship ?? null,
1190 ];
1191
1192 // Preferences
1193 $formatted['dietary_requirements'] = $customer->dietary_requirements ?? null;
1194 $formatted['medical_conditions'] = $customer->medical_conditions ?? null;
1195 $formatted['special_needs'] = $customer->special_needs ?? null;
1196 $formatted['preferred_language'] = $customer->preferred_language ?? 'en';
1197 $formatted['preferred_currency'] = $customer->preferred_currency ?? 'USD';
1198
1199 // Marketing
1200 $formatted['newsletter_optin'] = (bool) ($customer->newsletter_optin ?? false);
1201 $formatted['marketing_optin'] = (bool) ($customer->marketing_optin ?? false);
1202 $formatted['source'] = $customer->source ?? null;
1203
1204 // Stats
1205 $formatted['total_travelers'] = (int) ($customer->total_travelers ?? 0);
1206 $formatted['last_travel_date'] = $customer->last_travel_date ?? null;
1207 $formatted['loyalty_points'] = (int) ($customer->loyalty_points ?? 0);
1208
1209 // Gateway IDs
1210 $formatted['stripe_customer_id'] = $customer->stripe_customer_id ?? null;
1211 $formatted['paypal_customer_id'] = $customer->paypal_customer_id ?? null;
1212 $formatted['razorpay_customer_id'] = $customer->razorpay_customer_id ?? null;
1213
1214 // Notes
1215 $formatted['notes'] = $customer->notes ?? null;
1216
1217 // Recent bookings
1218 $formatted['recent_bookings'] = $customer->recent_bookings ?? [];
1219
1220 // Timestamps
1221 $formatted['updated_at'] = $customer->updated_at ?? null;
1222 $formatted['last_login_at'] = $customer->last_login_at ?? null;
1223 $formatted['verified_at'] = $customer->verified_at ?? null;
1224
1225 return $formatted;
1226 }
1227 }
1228
1229