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

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

1,479 lines 60.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Services;
6
7 use Yatra\Repositories\CustomerRepository;
8 use Yatra\Repositories\BookingRepository;
9 use Yatra\Repositories\PaymentRepository;
10 use Yatra\Utils\Logger;
11
12 /**
13 * Customer Service
14 *
15 * Contains business logic for customer management.
16 *
17 * @package Yatra\Services
18 */
19 class CustomerService
20 {
21 private CustomerRepository $customerRepository;
22 private BookingRepository $bookingRepository;
23 private PaymentRepository $paymentRepository;
24
25 public function __construct()
26 {
27 $this->customerRepository = new CustomerRepository();
28 $this->bookingRepository = new BookingRepository();
29 $this->paymentRepository = new PaymentRepository();
30 }
31
32 /**
33 * Link any prior guest bookings made under a customer's email
34 * to their newly-created WordPress user account.
35 *
36 * Without this, a customer who books as a guest first and only
37 * registers later will never see those earlier bookings in My
38 * Account — the rows persist with user_id=0 and the My Account
39 * query filters by user_id. Wired to the `user_register` hook
40 * (see Bootstrap::setupWordPressHooks).
41 *
42 * Returns the number of bookings that were linked. Returns 0
43 * silently on any failure — registration should never break on
44 * a reconciliation glitch, and the operator can re-run the
45 * reconciliation later via an admin tool if needed.
46 */
47 public function linkGuestBookingsToUser(int $user_id): int
48 {
49 if ($user_id <= 0) {
50 return 0;
51 }
52 $user = get_userdata($user_id);
53 if (!$user || empty($user->user_email)) {
54 return 0;
55 }
56
57 global $wpdb;
58 $table = \Yatra\Database\Tables\BookingsTable::getTableName();
59
60 // Match by exact email + user_id IS NULL/0. Limited to bookings
61 // not yet linked to any user so we never re-assign someone
62 // else's account.
63 $updated = $wpdb->query(
64 $wpdb->prepare(
65 "UPDATE `{$table}` SET user_id = %d, updated_at = %s
66 WHERE contact_email = %s
67 AND (user_id IS NULL OR user_id = 0)",
68 $user_id,
69 current_time('mysql'),
70 $user->user_email
71 )
72 );
73
74 if ($updated && $updated > 0) {
75 // Side-effect hook so other modules (Pro: Channel Manager,
76 // notifications, audit log) can react. Fires once per
77 // registration with the count + the user object.
78 do_action('yatra_guest_bookings_linked', (int) $user_id, (int) $updated, $user);
79 }
80
81 return (int) max(0, (int) $updated);
82 }
83
84 /**
85 * Get customer statistics
86 *
87 * @return array
88 */
89 public function getStats(): array
90 {
91 return $this->customerRepository->getStats();
92 }
93
94 /**
95 * Get paginated customers
96 *
97 * @param array $filters Filters
98 * @return array
99 */
100 public function getCustomers(array $filters = []): array
101 {
102 $result = $this->customerRepository->paginate($filters);
103
104 $result['data'] = array_map([$this, 'formatCustomer'], $result['data']);
105
106 return $result;
107 }
108
109 /**
110 * Get single customer with details
111 *
112 * @param int $id Customer ID
113 * @return array|null
114 */
115 public function getCustomer(int $id): ?array
116 {
117 $customer = $this->customerRepository->find($id);
118
119 if (!$customer) {
120 return null;
121 }
122
123 return $this->formatCustomerWithDetails($customer);
124 }
125
126 /**
127 * Get customer by email
128 *
129 * @param string $email Customer email
130 * @return array|null
131 */
132 public function getCustomerByEmail(string $email): ?array
133 {
134 $customer = $this->customerRepository->findByEmail($email);
135
136 if (!$customer) {
137 return null;
138 }
139
140 return $this->formatCustomer($customer);
141 }
142
143 /**
144 * Get customer by WordPress user ID
145 *
146 * @param int $userId WordPress user ID
147 * @return array|null
148 */
149 public function getCustomerByUserId(int $userId): ?array
150 {
151 $customer = $this->customerRepository->findByUserId($userId);
152
153 if (!$customer) {
154 return null;
155 }
156
157 return $this->formatCustomer($customer);
158 }
159
160 /**
161 * Account page /customers/me: Yatra customer when linked, otherwise WordPress user (display name, email).
162 */
163 public function getAccountProfileForUser(int $userId): ?array
164 {
165 if ($userId <= 0) {
166 return null;
167 }
168
169 $profile = $this->getCustomerByUserId($userId);
170 if ($profile === null) {
171 $user = get_userdata($userId);
172 if (!$user instanceof \WP_User) {
173 return null;
174 }
175 $profile = $this->buildProfileArrayFromWpUser($user);
176 }
177
178 // Surface any pending (unconfirmed) email change so the account UI can
179 // show "awaiting confirmation" — WordPress stores it in the _new_email meta.
180 $pending = get_user_meta($userId, '_new_email', true);
181 $profile['pending_email'] = (is_array($pending) && !empty($pending['newemail']))
182 ? (string) $pending['newemail']
183 : '';
184
185 return $profile;
186 }
187
188 /**
189 * Request a change to the account's login email, following WordPress core's
190 * pending-change pattern ({@see send_confirmation_on_profile_email()}): the
191 * email is NOT changed directly. Validate, store the pending change in the
192 * `_new_email` user meta (the same shape core uses), and email a confirmation
193 * link to the NEW address; the change only applies when that link is clicked.
194 *
195 * @return array{success:bool, message:string, pending_email?:string}
196 */
197 public function requestEmailChange(int $userId, string $newEmail): array
198 {
199 $user = get_userdata($userId);
200 if (!$user instanceof \WP_User) {
201 return ['success' => false, 'message' => __('Account not found.', 'yatra')];
202 }
203
204 $newEmail = trim($newEmail);
205 if ($newEmail === '' || !is_email($newEmail)) {
206 return ['success' => false, 'message' => __('Please enter a valid email address.', 'yatra')];
207 }
208 if (strtolower($newEmail) === strtolower((string) $user->user_email)) {
209 return ['success' => false, 'message' => __('That is already your email address.', 'yatra')];
210 }
211 if (email_exists($newEmail)) {
212 delete_user_meta($userId, '_new_email');
213 return ['success' => false, 'message' => __('That email address is already in use.', 'yatra')];
214 }
215
216 // Identical meta shape + hash to WordPress core (wp-includes/user.php),
217 // so the pending change is fully compatible with core's own flow.
218 $hash = md5($newEmail . time() . wp_rand());
219 update_user_meta($userId, '_new_email', ['hash' => $hash, 'newemail' => $newEmail]);
220
221 $this->sendEmailChangeConfirmation($user, $newEmail, $hash);
222
223 return [
224 'success' => true,
225 'message' => sprintf(
226 /* translators: %s: the new email address. */
227 __('A confirmation link has been sent to %s. Your email address will change once you confirm it there.', 'yatra'),
228 $newEmail
229 ),
230 'pending_email' => $newEmail,
231 ];
232 }
233
234 /**
235 * Re-send the confirmation email for an already-pending email change. Reuses
236 * the stored hash + address, so the original link stays valid (this does not
237 * rotate the token or change any state). Returns an error if nothing is pending.
238 *
239 * @return array{success:bool, message:string, pending_email?:string}
240 */
241 public function resendEmailChangeConfirmation(int $userId): array
242 {
243 $user = get_userdata($userId);
244 if (!$user instanceof \WP_User) {
245 return ['success' => false, 'message' => __('Account not found.', 'yatra')];
246 }
247
248 $pending = get_user_meta($userId, '_new_email', true);
249 if (!is_array($pending) || empty($pending['hash']) || empty($pending['newemail'])) {
250 return ['success' => false, 'message' => __('There is no pending email change to confirm.', 'yatra')];
251 }
252
253 $newEmail = (string) $pending['newemail'];
254 $this->sendEmailChangeConfirmation($user, $newEmail, (string) $pending['hash']);
255
256 return [
257 'success' => true,
258 'message' => sprintf(
259 /* translators: %s: the pending new email address. */
260 __('We\'ve re-sent the confirmation link to %s.', 'yatra'),
261 $newEmail
262 ),
263 'pending_email' => $newEmail,
264 ];
265 }
266
267 /**
268 * Cancel a pending email change, discarding the stored token so the emailed
269 * link no longer works. Mirrors WordPress core's "dismiss" action
270 * (profile.php?dismiss=<id>_new_email), which simply deletes the `_new_email`
271 * user meta. Safe to call when nothing is pending.
272 *
273 * @return array{success:bool, message:string}
274 */
275 public function cancelEmailChange(int $userId): array
276 {
277 if (!get_userdata($userId) instanceof \WP_User) {
278 return ['success' => false, 'message' => __('Account not found.', 'yatra')];
279 }
280
281 delete_user_meta($userId, '_new_email');
282
283 return ['success' => true, 'message' => __('The pending email change has been cancelled.', 'yatra')];
284 }
285
286 /**
287 * Send the email-change confirmation to the NEW address. Mirrors WordPress
288 * core's message and reuses its `new_user_email_content` filter, but points
289 * the confirmation link at the frontend account endpoint (not wp-admin).
290 */
291 private function sendEmailChangeConfirmation(\WP_User $user, string $newEmail, string $hash): void
292 {
293 // Point at the front-end account page (a normal request with cookie auth),
294 // NOT a REST endpoint — a browser GET to REST carries no nonce and would be
295 // read as anonymous. AccountPageHandler consumes the token there.
296 $accountUrl = home_url('/' . trailingslashit(SettingsService::getAccountBase()));
297 $confirmUrl = add_query_arg('yatra_email_token', rawurlencode($hash), $accountUrl);
298 $firstName = trim((string) $user->first_name) ?: (trim((string) $user->display_name) ?: (string) $user->user_login);
299
300 // Send through the Yatra transactional-email template system (branded HTML,
301 // merge tags, operator-editable in Settings → Email Templates) rather than a
302 // raw wp_mail. {{verification_link}} is reused for the confirmation link.
303 TransactionalEmailTemplateService::sendIfEnabled(
304 TransactionalEmailTemplateService::TYPE_ACCOUNT_EMAIL_CHANGE_REQUEST,
305 $newEmail,
306 [
307 'customer_first_name' => $firstName,
308 'customer_name' => $firstName,
309 'customer_email' => (string) $user->user_email,
310 'new_email' => $newEmail,
311 'verification_link' => $confirmUrl,
312 'intro_paragraph' => __('You recently requested to change the email address on your account. To confirm this new address, click the button below.', 'yatra'),
313 'footer_note' => __('If you did not request this change, you can safely ignore this email — your address will not change.', 'yatra'),
314 ]
315 );
316 }
317
318 /**
319 * Notify the OLD address that the account email was changed (WordPress core
320 * sends an equivalent security notice). Uses the editable "Email changed"
321 * transactional template.
322 */
323 private function sendEmailChangedNotice(string $oldEmail, string $firstName, string $newEmail): void
324 {
325 TransactionalEmailTemplateService::sendIfEnabled(
326 TransactionalEmailTemplateService::TYPE_ACCOUNT_EMAIL_CHANGED,
327 $oldEmail,
328 [
329 'customer_first_name' => $firstName,
330 'customer_name' => $firstName,
331 'customer_email' => $oldEmail,
332 'new_email' => $newEmail,
333 'intro_paragraph' => __('The email address on your account was just changed. If this was you, no further action is needed.', 'yatra'),
334 'footer_note' => __('If you did not make this change, please contact us immediately — your account may have been accessed by someone else.', 'yatra'),
335 ]
336 );
337 }
338
339 /**
340 * Confirm a pending email change (WordPress core pattern): verify the hash
341 * against the `_new_email` meta, apply via wp_update_user, then clear the meta.
342 *
343 * @return array{success:bool, message:string}
344 */
345 public function confirmEmailChange(int $userId, string $hash): array
346 {
347 $pending = get_user_meta($userId, '_new_email', true);
348 if (!is_array($pending) || empty($pending['hash']) || empty($pending['newemail'])) {
349 return ['success' => false, 'message' => __('No pending email change was found.', 'yatra')];
350 }
351 if (!hash_equals((string) $pending['hash'], (string) $hash)) {
352 return ['success' => false, 'message' => __('This confirmation link is invalid or has expired.', 'yatra')];
353 }
354
355 $newEmail = trim((string) $pending['newemail']);
356 $existing = $newEmail !== '' ? email_exists($newEmail) : false;
357 if ($existing && (int) $existing !== $userId) {
358 delete_user_meta($userId, '_new_email');
359 return ['success' => false, 'message' => __('That email address is now in use. Please try again.', 'yatra')];
360 }
361
362 // Capture the OLD address + name before the update, so we can send the
363 // "email changed" security notice to it afterwards.
364 $preUser = get_userdata($userId);
365 $oldEmail = $preUser instanceof \WP_User ? (string) $preUser->user_email : '';
366 $firstName = $preUser instanceof \WP_User
367 ? (trim((string) $preUser->first_name) ?: (trim((string) $preUser->display_name) ?: (string) $preUser->user_login))
368 : '';
369
370 $result = wp_update_user(['ID' => $userId, 'user_email' => $newEmail]);
371 if (is_wp_error($result)) {
372 return ['success' => false, 'message' => wp_strip_all_tags($result->get_error_message())];
373 }
374
375 // Keep the linked Yatra customer record in step with the WP user email,
376 // otherwise the account page would keep showing the old address (it reads
377 // the customer table's own email column).
378 $customer = $this->customerRepository->findByUserId($userId);
379 if ($customer && strtolower((string) $customer->email) !== strtolower($newEmail)) {
380 $this->customerRepository->updateCustomer((int) $customer->id, ['email' => $newEmail]);
381 }
382
383 delete_user_meta($userId, '_new_email');
384
385 // Security notice to the old address (best-effort; never block the change).
386 if ($oldEmail !== '' && strtolower($oldEmail) !== strtolower($newEmail)) {
387 $this->sendEmailChangedNotice($oldEmail, $firstName, $newEmail);
388 }
389
390 return ['success' => true, 'message' => __('Your email address has been updated.', 'yatra')];
391 }
392
393 /**
394 * @return array<string, mixed>
395 */
396 private function buildProfileArrayFromWpUser(\WP_User $user): array
397 {
398 $first = trim((string) $user->first_name);
399 $last = trim((string) $user->last_name);
400 $fromParts = trim($first . ' ' . $last);
401 $display = trim((string) $user->display_name);
402 $name = $fromParts !== '' ? $fromParts : $display;
403 if ($name === '') {
404 $name = (string) $user->user_login;
405 }
406
407 return [
408 'id' => 0,
409 'user_id' => (int) $user->ID,
410 'name' => $name,
411 'first_name' => $first,
412 'last_name' => $last,
413 'email' => (string) $user->user_email,
414 'phone' => '',
415 'country' => '',
416 'city' => '',
417 'status' => 'active',
418 'total_bookings' => 0,
419 'total_spent' => 0.0,
420 'loyalty_tier' => '',
421 'created_at' => $user->user_registered,
422 'last_booking_date' => null,
423 'registered_at' => $user->user_registered,
424 ];
425 }
426
427 /**
428 * Create a new customer
429 *
430 * @param array $data Customer data
431 * @return array {success: bool, customer_id?: int, message: string}
432 */
433 public function createCustomer(array $data): array
434 {
435 // Validate required fields
436 if (empty($data['email'])) {
437 return ['success' => false, 'message' => __('Email is required.', 'yatra')];
438 }
439
440 // Validate email format
441 if (!is_email($data['email'])) {
442 return ['success' => false, 'message' => __('Please provide a valid email address.', 'yatra')];
443 }
444
445 // Check if customer already exists
446 $existingCustomer = $this->customerRepository->findByEmail($data['email']);
447 if ($existingCustomer) {
448 return [
449 'success' => false,
450 'message' => __('A customer with this email already exists.', 'yatra'),
451 'existing_id' => (int) $existingCustomer->id,
452 ];
453 }
454
455 // Optional: also give the customer a WordPress login account. This is
456 // opt-in (the operator ticks "Create a login account"); left off, the
457 // customer stays a CRM-only record with user_id = NULL exactly as before.
458 // Resolved before the row is inserted so the customer is stored already
459 // linked to its user in a single write.
460 $accountResult = null;
461 if (!empty($data['create_account'])) {
462 $accountResult = $this->createOrLinkLoginAccount($data, !empty($data['confirm_link_existing']));
463 // The email belongs to an existing account — return the confirmation
464 // request WITHOUT writing anything, so no customer is created until the
465 // operator agrees to link (or changes the email).
466 if (!empty($accountResult['needs_link_confirmation'])) {
467 return $accountResult;
468 }
469 if (empty($accountResult['success'])) {
470 return [
471 'success' => false,
472 'message' => $accountResult['message'] ?? __('Failed to create the login account.', 'yatra'),
473 ];
474 }
475 $data['user_id'] = (int) $accountResult['user_id'];
476 }
477
478 // Create customer
479 $customerId = $this->customerRepository->findOrCreate($data);
480
481 if (!$customerId) {
482 return ['success' => false, 'message' => __('Failed to create customer.', 'yatra')];
483 }
484
485 // A newly created account may already have guest bookings under the same
486 // email — link them so they show in My Account (mirrors the user_register
487 // reconciliation used for self-registrations).
488 if ($accountResult !== null && !empty($accountResult['user_id'])) {
489 $this->linkGuestBookingsToUser((int) $accountResult['user_id']);
490 }
491
492 $message = __('Customer created successfully.', 'yatra');
493 if ($accountResult !== null) {
494 $message = !empty($accountResult['linked'])
495 ? __('Customer created and linked to the existing login account.', 'yatra')
496 : __('Customer created and a login account was set up. They will receive an email to choose a password.', 'yatra');
497 }
498
499 return [
500 'success' => true,
501 'customer_id' => $customerId,
502 'user_id' => $accountResult['user_id'] ?? null,
503 'account_created' => $accountResult !== null && empty($accountResult['linked']),
504 'account_linked' => $accountResult !== null && !empty($accountResult['linked']),
505 'message' => $message,
506 ];
507 }
508
509 /**
510 * Create a WordPress login account for a customer being added in the admin,
511 * or link an existing account when one already uses that email.
512 *
513 * Mirrors the account creation used by self-registration and guest checkout
514 * (yatra_customer role + billing meta), so an admin-created login behaves
515 * identically to one the customer made themselves. The password is random and
516 * never shown; WordPress emails the customer a set-your-password link.
517 *
518 * @param array $data Customer data (email required; name/phone optional)
519 * @return array{success:bool, user_id?:int, linked?:bool, message?:string}
520 */
521 private function createOrLinkLoginAccount(array $data, bool $confirmLink = false): array
522 {
523 $email = sanitize_email((string) ($data['email'] ?? ''));
524 if ($email === '' || !is_email($email)) {
525 return ['success' => false, 'message' => __('A valid email is required to create a login account.', 'yatra')];
526 }
527
528 $firstName = sanitize_text_field((string) ($data['first_name'] ?? ''));
529 $lastName = sanitize_text_field((string) ($data['last_name'] ?? ''));
530 $phone = sanitize_text_field((string) ($data['phone'] ?? ''));
531
532 // Email already has an account. Linking connects this customer — and any
533 // past bookings made with that email — to a real, possibly unrelated
534 // account, so it must be confirmed first (guards a mistyped address). Once
535 // the operator confirms, link rather than create a duplicate.
536 $existingUser = get_user_by('email', $email);
537 if ($existingUser instanceof \WP_User) {
538 if (!$confirmLink) {
539 return [
540 'success' => false,
541 'needs_link_confirmation' => true,
542 'existing_user_login' => $existingUser->user_login,
543 'message' => sprintf(
544 /* translators: 1: email address, 2: existing account username. */
545 __('A login account already exists for %1$s (username: %2$s). Linking will connect this customer — and any past bookings made with that email — to that account. Confirm to link, or use a different email.', 'yatra'),
546 $email,
547 $existingUser->user_login
548 ),
549 ];
550 }
551 return ['success' => true, 'user_id' => (int) $existingUser->ID, 'linked' => true];
552 }
553
554 // Derive a unique username from the email local-part (same as registration).
555 $baseUsername = sanitize_user(current(explode('@', $email)), true);
556 if ($baseUsername === '') {
557 $baseUsername = 'customer';
558 }
559 $username = $baseUsername;
560 $counter = 1;
561 while (username_exists($username)) {
562 $username = $baseUsername . $counter;
563 $counter++;
564 }
565
566 $userId = wp_insert_user([
567 'user_login' => $username,
568 'user_email' => $email,
569 'user_pass' => wp_generate_password(24, true),
570 'first_name' => $firstName,
571 'last_name' => $lastName,
572 'display_name' => trim($firstName . ' ' . $lastName) !== '' ? trim($firstName . ' ' . $lastName) : $username,
573 'role' => 'yatra_customer',
574 ]);
575
576 if (is_wp_error($userId)) {
577 return ['success' => false, 'message' => wp_strip_all_tags($userId->get_error_message())];
578 }
579
580 if ($phone !== '') {
581 update_user_meta($userId, 'billing_phone', $phone);
582 update_user_meta($userId, 'phone', $phone);
583 }
584
585 // Admin-created accounts are trusted (the operator vouches for the email),
586 // so they are pre-verified — unlike self-registration, which starts at '0'.
587 update_user_meta($userId, 'yatra_email_verified', '1');
588
589 // WordPress emails the customer a "set your password" link so they choose
590 // their own password; the random one above is never disclosed.
591 wp_new_user_notification($userId, null, 'user');
592
593 return ['success' => true, 'user_id' => (int) $userId, 'linked' => false];
594 }
595
596 /**
597 * Update a customer
598 *
599 * @param int $id Customer ID
600 * @param array $data Customer data
601 * @return array {success: bool, message: string}
602 */
603 public function updateCustomer(int $id, array $data): array
604 {
605 $customer = $this->customerRepository->find($id);
606
607 if (!$customer) {
608 return ['success' => false, 'message' => __('Customer not found.', 'yatra')];
609 }
610
611 $previousEmail = (string) $customer->email;
612 $linkedUserId = (int) ($customer->user_id ?? 0);
613
614 // Check email uniqueness if changing
615 $emailChanged = false;
616 $newEmail = '';
617 if (!empty($data['email']) && $data['email'] !== $customer->email) {
618 $newEmail = sanitize_email((string) $data['email']);
619
620 if (!is_email($newEmail)) {
621 return ['success' => false, 'message' => __('Please enter a valid email address.', 'yatra')];
622 }
623
624 $existingCustomer = $this->customerRepository->findByEmail($newEmail);
625 if ($existingCustomer && (int) $existingCustomer->id !== $id) {
626 return ['success' => false, 'message' => __('Email is already in use by another customer.', 'yatra')];
627 }
628
629 $data['email'] = $newEmail;
630 $emailChanged = true;
631 }
632
633 // Decide up-front whether this customer signs in, so a rejection happens
634 // BEFORE anything is written.
635 //
636 // An account is only recognised when this customer row unambiguously
637 // represents it — that is, the account currently carries this very same
638 // address. Several customer rows can legitimately share one user_id (an
639 // operator booking on behalf of guests while logged in links every row to
640 // their own account), and acting on the account from one of those rows
641 // would touch the WRONG person's login — including an administrator's.
642 $accountUserId = 0;
643 if ($emailChanged && $linkedUserId > 0) {
644 $linkedUser = get_userdata($linkedUserId);
645
646 if ($linkedUser && strtolower((string) $linkedUser->user_email) === strtolower($previousEmail)) {
647 $ownerId = email_exists($data['email']);
648 if ($ownerId && (int) $ownerId !== $linkedUserId) {
649 return [
650 'success' => false,
651 'message' => __('That email address already belongs to another user account.', 'yatra'),
652 ];
653 }
654
655 $accountUserId = $linkedUserId;
656 }
657 }
658
659 // A customer who can sign in keeps ownership of their own login address:
660 // the new address must confirm the change before it takes effect, exactly
661 // as it does when the customer edits it themselves. Nothing is written
662 // here — the stored email stays put until that link is clicked.
663 //
664 // A customer WITHOUT an account has no login and no inbox to confirm
665 // from, so their record is corrected immediately.
666 $pendingEmail = '';
667 if ($accountUserId > 0) {
668 unset($data['email']);
669 }
670
671 // Add a login account to a customer that doesn't have one yet, when the
672 // operator ticked "Create a login account" on the edit form. This ONLY
673 // ADDS an account — it never removes one: a customer who already signs in
674 // never reaches here ($linkedUserId > 0), and the form hides the option
675 // for them, so unchecking is always a no-op. Runs before the write so the
676 // new user_id is persisted in the same update; the repository only accepts
677 // user_id when the row has none, a second guard against reassignment.
678 $accountAdded = false;
679 if (!empty($data['create_account']) && $linkedUserId <= 0) {
680 $accountResult = $this->createOrLinkLoginAccount([
681 'email' => $data['email'] ?? $customer->email,
682 'first_name' => $data['first_name'] ?? $customer->first_name,
683 'last_name' => $data['last_name'] ?? $customer->last_name,
684 'phone' => $data['phone'] ?? $customer->phone,
685 ], !empty($data['confirm_link_existing']));
686 // Existing account for this email → ask before linking; return here so
687 // the edit is not written until the operator confirms.
688 if (!empty($accountResult['needs_link_confirmation'])) {
689 return $accountResult;
690 }
691 if (empty($accountResult['success'])) {
692 return [
693 'success' => false,
694 'message' => $accountResult['message'] ?? __('Failed to create the login account.', 'yatra'),
695 ];
696 }
697 $accountAdded = true;
698 }
699
700 $updated = $this->customerRepository->updateCustomer($id, $data);
701
702 if (!$updated) {
703 return ['success' => false, 'message' => __('Failed to update customer.', 'yatra')];
704 }
705
706 // Persist the link and reconcile prior guest bookings. Uses the dedicated
707 // linkUserIfUnlinked (updateCustomer does not write user_id), which only
708 // sets it when the row has none — so it adds, never reassigns.
709 if ($accountAdded && !empty($accountResult['user_id'])) {
710 $newUserId = (int) $accountResult['user_id'];
711 $this->customerRepository->linkUserIfUnlinked($id, $newUserId);
712 $this->linkGuestBookingsToUser($newUserId);
713 }
714
715 if ($accountUserId > 0) {
716 $requested = $this->requestEmailChange($accountUserId, $newEmail);
717
718 if (empty($requested['success'])) {
719 Logger::warning('Admin-requested customer email change could not be sent', [
720 'customer_id' => $id,
721 'user_id' => $accountUserId,
722 'reason' => $requested['message'] ?? '',
723 ]);
724
725 return [
726 'success' => false,
727 'message' => $requested['message'] ?? __('The email change could not be requested.', 'yatra'),
728 ];
729 }
730
731 $pendingEmail = (string) ($requested['pending_email'] ?? $newEmail);
732
733 return [
734 'success' => true,
735 'message' => sprintf(
736 /* translators: %s: the new email address awaiting confirmation. */
737 __('Customer updated. A confirmation link was sent to %s — their email address changes once it is confirmed there.', 'yatra'),
738 $pendingEmail
739 ),
740 'pending_email' => $pendingEmail,
741 ];
742 }
743
744 return [
745 'success' => true,
746 'account_created' => $accountAdded,
747 'message' => $accountAdded
748 ? __('Customer updated and a login account was set up. They will receive an email to choose a password.', 'yatra')
749 : __('Customer updated successfully.', 'yatra'),
750 ];
751 }
752
753 /**
754 * Update customer status
755 *
756 * @param int $id Customer ID
757 * @param string $status New status (active, inactive, blocked)
758 * @return array {success: bool, message: string}
759 */
760 public function updateStatus(int $id, string $status): array
761 {
762 $validStatuses = ['active', 'inactive', 'blocked'];
763
764 if (!in_array($status, $validStatuses, true)) {
765 return ['success' => false, 'message' => __('Invalid status.', 'yatra')];
766 }
767
768 $customer = $this->customerRepository->find($id);
769
770 if (!$customer) {
771 return ['success' => false, 'message' => __('Customer not found.', 'yatra')];
772 }
773
774 $updated = $this->customerRepository->updateCustomer($id, ['status' => $status]);
775
776 if (!$updated) {
777 return ['success' => false, 'message' => __('Failed to update status.', 'yatra')];
778 }
779
780 return [
781 'success' => true,
782 'message' => sprintf(
783 /* translators: %s: new customer status. */
784 __('Customer status updated to %s.', 'yatra'),
785 $status
786 ),
787 ];
788 }
789
790 /**
791 * Delete a customer
792 *
793 * @param int $id Customer ID
794 * @return array {success: bool, message: string}
795 */
796 public function deleteCustomer(int $id): array
797 {
798 $customer = $this->customerRepository->find($id);
799
800 if (!$customer) {
801 return ['success' => false, 'message' => __('Customer not found.', 'yatra')];
802 }
803
804 // Check for existing bookings
805 $bookings = $this->customerRepository->getCustomerBookings($id, 1);
806 if (!empty($bookings)) {
807 return [
808 'success' => false,
809 'message' => __('Cannot delete customer with existing bookings. Consider deactivating instead.', 'yatra'),
810 ];
811 }
812
813 $deleted = $this->customerRepository->deleteCustomer($id);
814
815 if (!$deleted) {
816 return ['success' => false, 'message' => __('Failed to delete customer.', 'yatra')];
817 }
818
819 return [
820 'success' => true,
821 'message' => __('Customer deleted successfully.', 'yatra'),
822 ];
823 }
824
825 /**
826 * Get customer's bookings
827 *
828 * @param int $customerId Customer ID
829 * @param int $limit Limit results
830 * @return array
831 */
832 public function getCustomerBookings(int $customerId, int $limit = 10): array
833 {
834 return $this->customerRepository->getCustomerBookings($customerId, $limit);
835 }
836
837 /**
838 * Get bookings by WordPress user ID (checks both customer_id and user_id)
839 *
840 * @param int $userId WordPress user ID
841 * @param int $limit Limit results
842 * @return array
843 */
844 public function getBookingsByUserId(int $userId, int $limit = 10): array
845 {
846 // First, try to get customer and bookings by customer_id
847 $customer = $this->getCustomerByUserId($userId);
848 $bookings = [];
849
850 if ($customer) {
851 $bookings = $this->getCustomerBookings((int) $customer['id'], $limit);
852 }
853
854 // Also get bookings directly by user_id (in case bookings were made before customer record was created)
855 $bookingsByUserId = $this->bookingRepository->findByUserId($userId, $limit);
856
857 // And include bookings made via the same email address
858 $emailBookings = [];
859 $user = get_userdata($userId);
860 if ($user && !empty($user->user_email)) {
861 $emailBookings = $this->bookingRepository->findByContactEmail($user->user_email, $limit);
862 }
863
864 // Merge and deduplicate by booking ID
865 $bookingIds = [];
866 $allBookings = [];
867 $sources = [$bookings, $bookingsByUserId, $emailBookings];
868
869 foreach ($sources as $collection) {
870 foreach ($collection as $booking) {
871 $bookingId = is_array($booking) ? ($booking['id'] ?? $booking['booking_id'] ?? null) : ($booking->id ?? null);
872 if ($bookingId && !in_array($bookingId, $bookingIds, true)) {
873 $bookingIds[] = $bookingId;
874 $allBookings[] = $booking;
875 }
876 }
877 }
878
879 // Limit results
880 if ($limit > 0 && count($allBookings) > $limit) {
881 $allBookings = array_slice($allBookings, 0, $limit);
882 }
883
884 return $allBookings;
885 }
886
887 public function getBookingDetailsForUser(int $userId, int $bookingId): ?array
888 {
889 if ($userId <= 0 || $bookingId <= 0) {
890 return null;
891 }
892
893 $booking = $this->bookingRepository->findWithTrip($bookingId);
894 if (!$booking) {
895 return null;
896 }
897
898 $user = get_userdata($userId);
899 $userEmail = ($user && !empty($user->user_email)) ? (string) $user->user_email : '';
900
901 $customer = $this->getCustomerByUserId($userId);
902 $customerId = $customer ? (int) ($customer['id'] ?? 0) : 0;
903
904 $bookingUserId = isset($booking->user_id) ? (int) $booking->user_id : 0;
905 $bookingCustomerId = isset($booking->customer_id) ? (int) $booking->customer_id : 0;
906 $bookingEmail = isset($booking->contact_email) ? (string) $booking->contact_email : '';
907
908 $allowed = false;
909 if ($bookingUserId > 0 && $bookingUserId === $userId) {
910 $allowed = true;
911 }
912 if (!$allowed && $customerId > 0 && $bookingCustomerId > 0 && $bookingCustomerId === $customerId) {
913 $allowed = true;
914 }
915 if (!$allowed && $userEmail !== '' && $bookingEmail !== '' && strtolower($userEmail) === strtolower($bookingEmail)) {
916 $allowed = true;
917 }
918
919 if (!$allowed) {
920 return null;
921 }
922
923 $emergencyContact = isset($booking->emergency_contact) ? maybe_unserialize($booking->emergency_contact) : null;
924 if (is_string($emergencyContact)) {
925 $decoded = json_decode($emergencyContact, true);
926 if (is_array($decoded)) {
927 $emergencyContact = $decoded;
928 }
929 }
930
931 // Load travellers from the normalized meta tables — the SAME source the
932 // admin booking screens use (TravellerRepository::getByBookingId, each
933 // row carrying its dynamic `fields`). The previous code read
934 // `$booking->travelers`, a column that does NOT exist (the schema only
935 // has `travelers_count`), so `travelers_data` was ALWAYS empty and the
936 // account-page "Travelers Information" card never rendered. Returns []
937 // for older bookings with no normalized rows (card simply hidden), so
938 // this is safe for existing bookings.
939 $travellerRepository = new \Yatra\Repositories\TravellerRepository();
940 $travelersList = $travellerRepository->getByBookingId($bookingId);
941 if (!is_array($travelersList)) {
942 $travelersList = [];
943 }
944
945 // contact_data is stored as JSON; decode to an array so the account
946 // page can read custom contact fields (matches emergency_contact above
947 // and BookingService::formatBookingWithDetails). maybe_unserialize is a
948 // no-op on a JSON string, so a fallback json_decode is required.
949 $contactData = isset($booking->contact_data) ? maybe_unserialize($booking->contact_data) : null;
950 if (is_string($contactData)) {
951 $decodedContact = json_decode($contactData, true);
952 if (is_array($decodedContact)) {
953 $contactData = $decodedContact;
954 }
955 }
956
957 // Derive customer_* convenience fields. The admin React maps
958 // contact_first_name + contact_last_name → customer_name at the
959 // page level (see ViewBooking.tsx), so we mirror the same shape
960 // server-side for the customer account view. Keeping ALL
961 // original contact_* fields too so any caller depending on the
962 // old shape (filters, integrations) stays unaffected.
963 $contactFirst = (string) ($booking->contact_first_name ?? '');
964 $contactLast = (string) ($booking->contact_last_name ?? '');
965 $customerName = trim($contactFirst . ' ' . $contactLast);
966
967 $details = [
968 'id' => (int) ($booking->id ?? 0),
969 'reference' => $booking->reference ?? null,
970 'trip_id' => (int) ($booking->trip_id ?? 0),
971 'trip_title' => $booking->trip_title ?? null,
972 'trip_slug' => $booking->trip_slug ?? null,
973 'trip_url' => function_exists('yatra_get_trip_permalink') ? yatra_get_trip_permalink((int) ($booking->trip_id ?? 0)) : '',
974 'featured_image' => $booking->featured_image ?? null,
975 'created_at' => $booking->created_at ?? null,
976 'updated_at' => $booking->updated_at ?? null,
977 'travel_date' => $booking->travel_date ?? null,
978 'start_date' => $booking->start_date ?? $booking->travel_date ?? null,
979 'end_date' => $booking->end_date ?? null,
980 'travelers_count' => (int) ($booking->travelers_count ?? 0),
981 'total_amount' => (float) ($booking->total_amount ?? 0),
982 'amount_paid' => (float) ($booking->amount_paid ?? 0),
983 'amount_due' => (float) ($booking->amount_due ?? 0),
984 'currency' => $booking->currency ?? null,
985 'payment_status' => $booking->payment_status ?? null,
986 'status' => $booking->status ?? null,
987 'payment_gateway' => $booking->payment_gateway ?? null,
988 'contact_first_name' => $booking->contact_first_name ?? null,
989 'contact_last_name' => $booking->contact_last_name ?? null,
990 'contact_email' => $booking->contact_email ?? null,
991 'contact_phone' => $booking->contact_phone ?? null,
992 'contact_country' => $booking->contact_country ?? null,
993 // Convenience aliases the React account page (BookingDetails.tsx)
994 // reads as `customer_name`/`customer_email`/`customer_phone`.
995 'customer_name' => $customerName !== '' ? $customerName : null,
996 'customer_email' => $booking->contact_email ?? null,
997 'customer_phone' => $booking->contact_phone ?? null,
998 'special_requests' => $booking->special_requests ?? null,
999 'emergency_contact' => $emergencyContact,
1000 'contact_data' => $contactData,
1001 'travelers' => $travelersList,
1002 // React's BookingDetails reads `travelers_data` (same name
1003 // the admin ViewBooking screen uses); alias it here so the
1004 // "Travelers Information" card actually renders.
1005 'travelers_data' => is_array($travelersList) ? $travelersList : [],
1006 'payments' => [],
1007 ];
1008
1009 return apply_filters('yatra_customer_booking_details', $details, $booking, $userId);
1010 }
1011
1012 /**
1013 * Get customer's payments
1014 *
1015 * @param int $customerId Customer ID
1016 * @param int $limit Limit results
1017 * @return array
1018 */
1019 public function getCustomerPayments(int $customerId, int $limit = 50): array
1020 {
1021 $bookings = $this->customerRepository->getCustomerBookings($customerId, 1000);
1022 $bookingIds = array_map(static function($booking) {
1023 if (is_object($booking)) {
1024 return (int) ($booking->id ?? 0);
1025 }
1026 if (is_array($booking)) {
1027 return (int) ($booking['id'] ?? 0);
1028 }
1029 return 0;
1030 }, $bookings);
1031
1032 // Include bookings linked via user ID or 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 $bookingIds = array_merge($bookingIds, array_map(static function($booking) {
1038 if (is_object($booking)) {
1039 return (int) ($booking->id ?? 0);
1040 }
1041 if (is_array($booking)) {
1042 return (int) ($booking['id'] ?? 0);
1043 }
1044 return 0;
1045 }, $userBookings));
1046 }
1047
1048 if (!empty($customer->email)) {
1049 $emailBookings = $this->bookingRepository->findByContactEmail($customer->email, 1000);
1050 $bookingIds = array_merge($bookingIds, array_map(static function($booking) {
1051 if (is_object($booking)) {
1052 return (int) ($booking->id ?? 0);
1053 }
1054 if (is_array($booking)) {
1055 return (int) ($booking['id'] ?? 0);
1056 }
1057 return 0;
1058 }, $emailBookings));
1059 }
1060 }
1061
1062 $bookingIds = array_values(array_unique(array_filter($bookingIds)));
1063
1064 return $this->getPaymentsForBookingIds($bookingIds, $limit);
1065 }
1066
1067 public function getPaymentsByUserId(int $userId, int $limit = 50): array
1068 {
1069 $bookings = $this->bookingRepository->findByUserId($userId, 1000);
1070
1071 $user = get_userdata($userId);
1072 if ($user && !empty($user->user_email)) {
1073 $bookingsByEmail = $this->bookingRepository->findByContactEmail($user->user_email, 1000);
1074 $bookings = array_merge($bookings, $bookingsByEmail);
1075 }
1076
1077 $bookingIds = array_map(static function($booking) {
1078 if (is_object($booking)) {
1079 return (int) ($booking->id ?? 0);
1080 }
1081 if (is_array($booking)) {
1082 return (int) ($booking['id'] ?? 0);
1083 }
1084 return 0;
1085 }, $bookings);
1086
1087 return $this->getPaymentsForBookingIds($bookingIds, $limit);
1088 }
1089
1090 private function getPaymentsForBookingIds(array $bookingIds, int $limit = 50): array
1091 {
1092 $bookingIds = array_values(array_filter(array_map('intval', $bookingIds))); // ensure ints
1093
1094 if (empty($bookingIds)) {
1095 return [];
1096 }
1097
1098 $customerRepository = new \Yatra\Repositories\CustomerRepository();
1099 $payments = $customerRepository->getPaymentsForBookingIds($bookingIds, $limit);
1100
1101 // Route the customer-facing payments through the shared formatter so
1102 // they emit the same field shape the rest of the app uses — most
1103 // importantly the React Account → Payments tab's aliases
1104 // (`date`, `method`, `reference`, `type`, `booking_number`,
1105 // `payment_date`, `payment_number`). The previous inline formatter
1106 // omitted those keys, which is why the Payments cards rendered
1107 // "N/A" for the date, blank for the method, and an empty space
1108 // above the "Booking:" label.
1109 $paymentService = new \Yatra\Services\PaymentService();
1110
1111 return array_map(static function ($payment) use ($paymentService) {
1112 $row = $paymentService->formatPayment($payment);
1113 // Preserve the booking-amount summary fields used by the React
1114 // payments tab to decide whether to render a "Pay Remaining" CTA.
1115 // formatPayment doesn't know about these (they come from the
1116 // CustomerRepository JOIN); attach them here so we keep the
1117 // canonical shape AND the extra context.
1118 $row['booking_amount_due'] = (float) ($payment->booking_amount_due ?? 0);
1119 $row['booking_amount_paid'] = (float) ($payment->booking_amount_paid ?? 0);
1120 $row['booking_total_amount'] = (float) ($payment->booking_total_amount ?? 0);
1121 return $row;
1122 }, $payments);
1123 }
1124
1125 public function getDocumentsForBookings(array $bookings, int $customerId = 0): array
1126 {
1127 $documents = [];
1128
1129 // Process each booking individually for vouchers and itineraries
1130 // but group by trip for downloads
1131 $tripsWithBookings = [];
1132
1133 foreach ($bookings as $booking) {
1134 $bookingId = is_object($booking) ? (int) ($booking->id ?? 0) : (int) ($booking['id'] ?? $booking['booking_id'] ?? 0);
1135 $tripId = is_object($booking) ? (int) ($booking->trip_id ?? 0) : (int) ($booking['trip_id'] ?? 0);
1136 $tripTitle = is_object($booking) ? (string) ($booking->trip_title ?? '') : (string) ($booking['trip_title'] ?? '');
1137 $reference = is_object($booking) ? ($booking->reference ?? null) : ($booking['reference'] ?? null);
1138 $status = is_object($booking) ? (string) ($booking->status ?? '') : (string) ($booking['status'] ?? '');
1139 $createdAt = is_object($booking) ? (string) ($booking->created_at ?? '') : (string) ($booking['created_at'] ?? '');
1140
1141 if ($bookingId <= 0) {
1142 continue;
1143 }
1144
1145 // Store trip info for downloads (grouped by trip)
1146 if ($tripId > 0 && !isset($tripsWithBookings[$tripId])) {
1147 $tripsWithBookings[$tripId] = [
1148 'booking_id' => $bookingId,
1149 'trip_title' => $tripTitle,
1150 'reference' => $reference,
1151 'status' => $status,
1152 'created_at' => $createdAt,
1153 ];
1154 }
1155
1156 // Get payments for this booking (invoices per payment)
1157 $payments = $this->paymentRepository->findByBookingId($bookingId);
1158 $hasPaidInvoice = false;
1159 foreach ($payments as $payment) {
1160 $paymentId = (int) ($payment->id ?? 0);
1161 if ($paymentId <= 0) {
1162 continue;
1163 }
1164
1165 $paymentStatus = (string) ($payment->status ?? '');
1166 if (!in_array($paymentStatus, ['paid', 'completed', 'success'], true)) {
1167 continue;
1168 }
1169
1170 $docRef = $reference ?: $bookingId;
1171
1172 // Invoice per payment
1173 $invoiceUrl = rest_url('yatra/v1/payment/' . $paymentId . '/invoice');
1174 $invoiceUrl = add_query_arg('_wpnonce', wp_create_nonce('wp_rest'), $invoiceUrl);
1175
1176 $documents[] = [
1177 'id' => 'invoice-payment-' . $paymentId,
1178 'name' => sprintf(
1179 /* translators: %s: booking reference or ID. */
1180 __('Invoice #%s.pdf', 'yatra'),
1181 $docRef
1182 ),
1183 'trip_title' => $tripTitle,
1184 'category' => 'invoice',
1185 'updated_at' => $payment->created_at ?? $createdAt ?: date('Y-m-d H:i:s'),
1186 'url' => $invoiceUrl,
1187 'booking_id' => $bookingId,
1188 'payment_id' => $paymentId,
1189 ];
1190 $hasPaidInvoice = true;
1191 }
1192
1193 // Pro-forma invoice for offline / unpaid bookings (e.g. Bank Transfer):
1194 // no completed payment yet, but there is a balance due. Carries the
1195 // gateway's payment instructions so the customer knows how to pay.
1196 $amountDue = is_object($booking)
1197 ? (float) ($booking->amount_due ?? $booking->booking_amount_due ?? 0)
1198 : (float) ($booking['amount_due'] ?? $booking['booking_amount_due'] ?? 0);
1199 if (!$hasPaidInvoice && $amountDue > 0) {
1200 $proformaToken = \Yatra\Controllers\PaymentGatewayController::issueInvoiceToken(0, $bookingId);
1201 $proformaUrl = add_query_arg(
1202 ['invoice_token' => $proformaToken, '_wpnonce' => wp_create_nonce('wp_rest')],
1203 rest_url('yatra/v1/booking/' . $bookingId . '/invoice')
1204 );
1205 $documents[] = [
1206 'id' => 'invoice-booking-' . $bookingId,
1207 'name' => sprintf(
1208 /* translators: %s: booking reference or ID. */
1209 __('Invoice #%s.pdf', 'yatra'),
1210 $reference ?: $bookingId
1211 ),
1212 'trip_title' => $tripTitle,
1213 'category' => 'invoice',
1214 'updated_at' => $createdAt ?: date('Y-m-d H:i:s'),
1215 'url' => $proformaUrl,
1216 'booking_id' => $bookingId,
1217 ];
1218 }
1219
1220 // Voucher per booking
1221 if ($status === 'confirmed') {
1222 $docRef = $reference ?: $bookingId;
1223
1224 $voucherUrl = rest_url('yatra/v1/bookings/' . $bookingId . '/voucher');
1225 $voucherUrl = add_query_arg('_wpnonce', wp_create_nonce('wp_rest'), $voucherUrl);
1226
1227 $documents[] = [
1228 'id' => 'voucher-' . $bookingId, // Booking-based ID
1229 'name' => sprintf(
1230 /* translators: %s: booking reference or ID. */
1231 __('Travel Voucher #%s.pdf', 'yatra'),
1232 $docRef
1233 ),
1234 'trip_title' => $tripTitle,
1235 'category' => 'voucher',
1236 'updated_at' => $createdAt ?: date('Y-m-d H:i:s'),
1237 'url' => $voucherUrl,
1238 'booking_id' => $bookingId,
1239 ];
1240
1241 // Itinerary per booking
1242 $itineraryUrl = rest_url('yatra/v1/bookings/' . $bookingId . '/itinerary');
1243 $itineraryUrl = add_query_arg('_wpnonce', wp_create_nonce('wp_rest'), $itineraryUrl);
1244
1245 $documents[] = [
1246 'id' => 'itinerary-' . $bookingId, // Booking-based ID
1247 'name' => sprintf(
1248 /* translators: %s: booking reference or ID. */
1249 __('Travel Itinerary #%s.pdf', 'yatra'),
1250 $docRef
1251 ),
1252 'trip_title' => $tripTitle,
1253 'category' => 'itinerary',
1254 'updated_at' => $createdAt ?: date('Y-m-d H:i:s'),
1255 'url' => $itineraryUrl,
1256 'booking_id' => $bookingId,
1257 ];
1258 }
1259 }
1260
1261 usort($documents, function ($a, $b) {
1262 return strtotime($b['updated_at']) - strtotime($a['updated_at']);
1263 });
1264
1265 // Apply downloads filter (which groups by trip)
1266 $documents = apply_filters('yatra_customer_documents', $documents, $bookings, $customerId);
1267
1268 return is_array($documents) ? $documents : [];
1269 }
1270
1271 /**
1272 * Get customer's documents (invoices, vouchers, itineraries)
1273 *
1274 * @param int $customerId Customer ID
1275 * @return array
1276 */
1277 public function getCustomerDocuments(int $customerId): array
1278 {
1279 // Get customer's bookings
1280 $bookings = $this->customerRepository->getCustomerBookings($customerId, 1000);
1281
1282 // Also include bookings linked via user_id/email (older bookings may not have customer_id)
1283 $customer = $this->customerRepository->find($customerId);
1284 if ($customer) {
1285 if (!empty($customer->user_id)) {
1286 $userBookings = $this->bookingRepository->findByUserId((int) $customer->user_id, 1000);
1287 $bookings = array_merge($bookings, $userBookings);
1288 }
1289
1290 if (!empty($customer->email)) {
1291 $emailBookings = $this->bookingRepository->findByContactEmail((string) $customer->email, 1000);
1292 $bookings = array_merge($bookings, $emailBookings);
1293 }
1294 }
1295
1296 // Deduplicate by booking id
1297 $seen = [];
1298 $unique = [];
1299 foreach ($bookings as $b) {
1300 $id = is_object($b) ? ($b->id ?? null) : ($b['id'] ?? $b['booking_id'] ?? null);
1301 if ($id && !isset($seen[$id])) {
1302 $seen[$id] = true;
1303 $unique[] = $b;
1304 }
1305 }
1306
1307 return $this->getDocumentsForBookings($unique, $customerId);
1308 }
1309
1310 /**
1311 * Get customer's support tickets
1312 *
1313 * @param int $customerId Customer ID
1314 * @return array
1315 */
1316 public function getCustomerSupportTickets(int $customerId): array
1317 {
1318 // For now, return empty array as support tickets system may not be implemented yet
1319 // This can be extended when support ticket system is added
1320 return [];
1321 }
1322
1323 /**
1324 * Merge two customer records
1325 *
1326 * @param int $sourceId Source customer ID (will be deleted)
1327 * @param int $targetId Target customer ID (will be kept)
1328 * @return array {success: bool, message: string}
1329 */
1330 public function mergeCustomers(int $sourceId, int $targetId): array
1331 {
1332 if ($sourceId === $targetId) {
1333 return ['success' => false, 'message' => __('Cannot merge customer with itself.', 'yatra')];
1334 }
1335
1336 $source = $this->customerRepository->find($sourceId);
1337 $target = $this->customerRepository->find($targetId);
1338
1339 if (!$source || !$target) {
1340 return ['success' => false, 'message' => __('One or both customers not found.', 'yatra')];
1341 }
1342
1343 // Update all bookings to point to target customer
1344 $this->bookingRepository->updateCustomerBookings($sourceId, $targetId);
1345
1346 // Update target customer stats
1347 $this->customerRepository->updateCustomer($targetId, [
1348 'total_bookings' => (int) $target->total_bookings + (int) $source->total_bookings,
1349 'total_spent' => (float) $target->total_spent + (float) $source->total_spent,
1350 ]);
1351
1352 // Delete source customer
1353 $this->customerRepository->deleteCustomer($sourceId);
1354
1355 return [
1356 'success' => true,
1357 'message' => __('Customers merged successfully.', 'yatra'),
1358 ];
1359 }
1360
1361 /**
1362 * Format customer for API response
1363 *
1364 * @param object $customer Raw customer data
1365 * @return array
1366 */
1367 private function formatCustomer(object $customer): array
1368 {
1369 $name = trim((string) ($customer->first_name ?? '') . ' ' . (string) ($customer->last_name ?? ''));
1370 if ($name === '') {
1371 $uid = (int) ($customer->user_id ?? 0);
1372 if ($uid > 0) {
1373 $u = get_userdata($uid);
1374 if ($u instanceof \WP_User) {
1375 $name = trim((string) $u->display_name);
1376 if ($name === '') {
1377 $name = trim($u->first_name . ' ' . $u->last_name);
1378 }
1379 if ($name === '') {
1380 $name = (string) $u->user_login;
1381 }
1382 }
1383 }
1384 }
1385 if ($name === '' && !empty($customer->email)) {
1386 $local = explode('@', (string) $customer->email)[0] ?? '';
1387 $name = $local !== '' ? $local : $name;
1388 }
1389
1390 $created = $customer->created_at ?? '';
1391
1392 return [
1393 'id' => (int) $customer->id,
1394 'user_id' => $customer->user_id ? (int) $customer->user_id : null,
1395 'name' => $name,
1396 'first_name' => $customer->first_name ?? '',
1397 'last_name' => $customer->last_name ?? '',
1398 'email' => $customer->email,
1399 'phone' => $customer->phone ?? '',
1400 'country' => $customer->country ?? '',
1401 'city' => $customer->city ?? '',
1402 // Address belongs to the account profile too. Without it here the
1403 // account page never received the saved value — which is exactly why
1404 // city/country updated but address didn't.
1405 'address' => $customer->address ?? '',
1406 'status' => $customer->status ?? 'active',
1407 'total_bookings' => (int) ($customer->total_bookings ?? 0),
1408 'total_spent' => (float) ($customer->total_spent ?? 0),
1409 'loyalty_tier' => $customer->loyalty_tier ?? 'bronze',
1410 'created_at' => $created,
1411 'registered_at' => $created,
1412 'last_booking_date' => $customer->last_booking_date ?? null,
1413 ];
1414 }
1415
1416 /**
1417 * Format customer with all details
1418 *
1419 * @param object $customer Raw customer data
1420 * @return array
1421 */
1422 private function formatCustomerWithDetails(object $customer): array
1423 {
1424 $formatted = $this->formatCustomer($customer);
1425
1426 // Add additional fields
1427 $formatted['secondary_phone'] = $customer->secondary_phone ?? null;
1428 $formatted['address'] = $customer->address ?? null;
1429 $formatted['state'] = $customer->state ?? null;
1430 $formatted['postal_code'] = $customer->postal_code ?? null;
1431 $formatted['date_of_birth'] = $customer->date_of_birth ?? null;
1432 $formatted['gender'] = $customer->gender ?? null;
1433 $formatted['nationality'] = $customer->nationality ?? null;
1434
1435 // Emergency contact
1436 $formatted['emergency_contact'] = [
1437 'name' => $customer->emergency_name ?? null,
1438 'phone' => $customer->emergency_phone ?? null,
1439 'relationship' => $customer->emergency_relationship ?? null,
1440 ];
1441
1442 // Preferences
1443 $formatted['dietary_requirements'] = $customer->dietary_requirements ?? null;
1444 $formatted['medical_conditions'] = $customer->medical_conditions ?? null;
1445 $formatted['special_needs'] = $customer->special_needs ?? null;
1446 $formatted['preferred_language'] = $customer->preferred_language ?? 'en';
1447 $formatted['preferred_currency'] = $customer->preferred_currency ?? 'USD';
1448
1449 // Marketing
1450 $formatted['newsletter_optin'] = (bool) ($customer->newsletter_optin ?? false);
1451 $formatted['marketing_optin'] = (bool) ($customer->marketing_optin ?? false);
1452 $formatted['source'] = $customer->source ?? null;
1453
1454 // Stats
1455 $formatted['total_travelers'] = (int) ($customer->total_travelers ?? 0);
1456 $formatted['last_travel_date'] = $customer->last_travel_date ?? null;
1457 $formatted['loyalty_points'] = (int) ($customer->loyalty_points ?? 0);
1458
1459 // Gateway IDs
1460 $formatted['stripe_customer_id'] = $customer->stripe_customer_id ?? null;
1461 $formatted['paypal_customer_id'] = $customer->paypal_customer_id ?? null;
1462 $formatted['razorpay_customer_id'] = $customer->razorpay_customer_id ?? null;
1463
1464 // Notes
1465 $formatted['notes'] = $customer->notes ?? null;
1466
1467 // Recent bookings
1468 $formatted['recent_bookings'] = $customer->recent_bookings ?? [];
1469
1470 // Timestamps
1471 $formatted['updated_at'] = $customer->updated_at ?? null;
1472 $formatted['last_login_at'] = $customer->last_login_at ?? null;
1473 $formatted['verified_at'] = $customer->verified_at ?? null;
1474
1475 return $formatted;
1476 }
1477 }
1478
1479