PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 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 All 84 releases
← All changes | app/Services/CustomerService.php +644 -32 3.0.2.8 → 3.0.16 View file →
@@ -6,8 +6,9 @@
6 6
7 7 use Yatra\Repositories\CustomerRepository;
8 8 use Yatra\Repositories\BookingRepository;
9 9 use Yatra\Repositories\PaymentRepository;
10 +use Yatra\Utils\Logger;
10 11
11 12 /**
12 13 * Customer Service
13 14 *
@@ -28,8 +29,60 @@
28 29 $this->paymentRepository = new PaymentRepository();
29 30 }
30 31
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 + /**
32 85 * Get customer statistics
33 86 *
34 87 * @return array
35 88 */
@@ -112,22 +165,233 @@
112 165 if ($userId <= 0) {
113 166 return null;
114 167 }
115 168
116 - $customer = $this->getCustomerByUserId($userId);
117 - if ($customer !== null) {
118 - return $customer;
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);
119 176 }
120 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 + {
121 199 $user = get_userdata($userId);
122 200 if (!$user instanceof \WP_User) {
123 - return null;
201 + return ['success' => false, 'message' => __('Account not found.', 'yatra')];
124 202 }
125 203
126 - return $this->buildProfileArrayFromWpUser($user);
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 + ];
127 232 }
128 233
129 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 + /**
130 394 * @return array<string, mixed>
131 395 */
132 396 private function buildProfileArrayFromWpUser(\WP_User $user): array
133 397 {
@@ -187,8 +451,31 @@
187 451 'existing_id' => (int) $existingCustomer->id,
188 452 ];
189 453 }
190 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 +
191 478 // Create customer
192 479 $customerId = $this->customerRepository->findOrCreate($data);
193 480
194 481 if (!$customerId) {
@@ -194,16 +481,120 @@
194 481 if (!$customerId) {
195 482 return ['success' => false, 'message' => __('Failed to create customer.', 'yatra')];
196 483 }
197 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 +
198 499 return [
199 500 'success' => true,
200 501 'customer_id' => $customerId,
201 - 'message' => __('Customer created successfully.', 'yatra'),
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,
202 506 ];
203 507 }
204 508
205 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 + /**
206 597 * Update a customer
207 598 *
208 599 * @param int $id Customer ID
209 600 * @param array $data Customer data
@@ -216,16 +607,97 @@
216 607 if (!$customer) {
217 608 return ['success' => false, 'message' => __('Customer not found.', 'yatra')];
218 609 }
219 610
611 + $previousEmail = (string) $customer->email;
612 + $linkedUserId = (int) ($customer->user_id ?? 0);
613 +
220 614 // Check email uniqueness if changing
615 + $emailChanged = false;
616 + $newEmail = '';
221 617 if (!empty($data['email']) && $data['email'] !== $customer->email) {
222 - $existingCustomer = $this->customerRepository->findByEmail($data['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);
223 625 if ($existingCustomer && (int) $existingCustomer->id !== $id) {
224 626 return ['success' => false, 'message' => __('Email is already in use by another customer.', 'yatra')];
225 627 }
628 +
629 + $data['email'] = $newEmail;
630 + $emailChanged = true;
226 631 }
227 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 +
228 700 $updated = $this->customerRepository->updateCustomer($id, $data);
229 701
230 702 if (!$updated) {
231 703 return ['success' => false, 'message' => __('Failed to update customer.', 'yatra')];
@@ -230,11 +702,52 @@
230 702 if (!$updated) {
231 703 return ['success' => false, 'message' => __('Failed to update customer.', 'yatra')];
232 704 }
233 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 +
234 744 return [
235 745 'success' => true,
236 - 'message' => __('Customer updated successfully.', 'yatra'),
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'),
237 750 ];
238 751 }
239 752
240 753 /**
@@ -265,9 +778,13 @@
265 778 }
266 779
267 780 return [
268 781 'success' => true,
269 - 'message' => sprintf(__('Customer status updated to %s.', 'yatra'), $status),
782 + 'message' => sprintf(
783 + /* translators: %s: new customer status. */
784 + __('Customer status updated to %s.', 'yatra'),
785 + $status
786 + ),
270 787 ];
271 788 }
272 789
273 790 /**
@@ -410,8 +927,44 @@
410 927 $emergencyContact = $decoded;
411 928 }
412 929 }
413 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 +
414 967 $details = [
415 968 'id' => (int) ($booking->id ?? 0),
416 969 'reference' => $booking->reference ?? null,
417 970 'trip_id' => (int) ($booking->trip_id ?? 0),
@@ -421,8 +974,10 @@
421 974 'featured_image' => $booking->featured_image ?? null,
422 975 'created_at' => $booking->created_at ?? null,
423 976 'updated_at' => $booking->updated_at ?? null,
424 977 'travel_date' => $booking->travel_date ?? null,
978 + 'start_date' => $booking->start_date ?? $booking->travel_date ?? null,
979 + 'end_date' => $booking->end_date ?? null,
425 980 'travelers_count' => (int) ($booking->travelers_count ?? 0),
426 981 'total_amount' => (float) ($booking->total_amount ?? 0),
427 982 'amount_paid' => (float) ($booking->amount_paid ?? 0),
428 983 'amount_due' => (float) ($booking->amount_due ?? 0),
@@ -434,12 +989,21 @@
434 989 'contact_last_name' => $booking->contact_last_name ?? null,
435 990 'contact_email' => $booking->contact_email ?? null,
436 991 'contact_phone' => $booking->contact_phone ?? null,
437 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,
438 998 'special_requests' => $booking->special_requests ?? null,
439 999 'emergency_contact' => $emergencyContact,
440 - 'contact_data' => isset($booking->contact_data) ? maybe_unserialize($booking->contact_data) : null,
441 - 'travelers' => isset($booking->travelers) ? maybe_unserialize($booking->travelers) : null,
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 : [],
442 1006 'payments' => [],
443 1007 ];
444 1008
445 1009 return apply_filters('yatra_customer_booking_details', $details, $booking, $userId);
@@ -533,26 +1097,29 @@
533 1097
534 1098 $customerRepository = new \Yatra\Repositories\CustomerRepository();
535 1099 $payments = $customerRepository->getPaymentsForBookingIds($bookingIds, $limit);
536 1100
537 - return array_map(static function($payment) {
538 - return [
539 - 'id' => (int) $payment->id,
540 - 'booking_id' => (int) $payment->booking_id,
541 - 'booking_reference' => $payment->booking_reference,
542 - 'amount' => (float) $payment->amount,
543 - 'currency' => $payment->currency,
544 - 'status' => $payment->status,
545 - 'payment_method' => $payment->payment_method,
546 - 'gateway' => $payment->gateway,
547 - 'transaction_id' => $payment->transaction_id,
548 - 'created_at' => $payment->created_at,
549 - 'updated_at' => $payment->updated_at,
550 - 'trip_title' => $payment->trip_title,
551 - 'booking_amount_due' => (float) $payment->booking_amount_due,
552 - 'booking_amount_paid' => (float) $payment->booking_amount_paid,
553 - 'booking_total_amount' => (float) $payment->booking_total_amount,
554 - ];
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;
555 1122 }, $payments);
556 1123 }
557 1124
558 1125 public function getDocumentsForBookings(array $bookings, int $customerId = 0): array
@@ -587,8 +1154,9 @@
587 1154 }
588 1155
589 1156 // Get payments for this booking (invoices per payment)
590 1157 $payments = $this->paymentRepository->findByBookingId($bookingId);
1158 + $hasPaidInvoice = false;
591 1159 foreach ($payments as $payment) {
592 1160 $paymentId = (int) ($payment->id ?? 0);
593 1161 if ($paymentId <= 0) {
594 1162 continue;
@@ -606,9 +1174,13 @@
606 1174 $invoiceUrl = add_query_arg('_wpnonce', wp_create_nonce('wp_rest'), $invoiceUrl);
607 1175
608 1176 $documents[] = [
609 1177 'id' => 'invoice-payment-' . $paymentId,
610 - 'name' => sprintf(__('Invoice #%s.pdf', 'yatra'), $docRef),
1178 + 'name' => sprintf(
1179 + /* translators: %s: booking reference or ID. */
1180 + __('Invoice #%s.pdf', 'yatra'),
1181 + $docRef
1182 + ),
611 1183 'trip_title' => $tripTitle,
612 1184 'category' => 'invoice',
613 1185 'updated_at' => $payment->created_at ?? $createdAt ?: date('Y-m-d H:i:s'),
614 1186 'url' => $invoiceUrl,
@@ -614,10 +1186,38 @@
614 1186 'url' => $invoiceUrl,
615 1187 'booking_id' => $bookingId,
616 1188 'payment_id' => $paymentId,
617 1189 ];
1190 + $hasPaidInvoice = true;
618 1191 }
619 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 +
620 1220 // Voucher per booking
621 1221 if ($status === 'confirmed') {
622 1222 $docRef = $reference ?: $bookingId;
623 1223
@@ -625,9 +1225,13 @@
625 1225 $voucherUrl = add_query_arg('_wpnonce', wp_create_nonce('wp_rest'), $voucherUrl);
626 1226
627 1227 $documents[] = [
628 1228 'id' => 'voucher-' . $bookingId, // Booking-based ID
629 - 'name' => sprintf(__('Travel Voucher #%s.pdf', 'yatra'), $docRef),
1229 + 'name' => sprintf(
1230 + /* translators: %s: booking reference or ID. */
1231 + __('Travel Voucher #%s.pdf', 'yatra'),
1232 + $docRef
1233 + ),
630 1234 'trip_title' => $tripTitle,
631 1235 'category' => 'voucher',
632 1236 'updated_at' => $createdAt ?: date('Y-m-d H:i:s'),
633 1237 'url' => $voucherUrl,
@@ -639,9 +1243,13 @@
639 1243 $itineraryUrl = add_query_arg('_wpnonce', wp_create_nonce('wp_rest'), $itineraryUrl);
640 1244
641 1245 $documents[] = [
642 1246 'id' => 'itinerary-' . $bookingId, // Booking-based ID
643 - 'name' => sprintf(__('Travel Itinerary #%s.pdf', 'yatra'), $docRef),
1247 + 'name' => sprintf(
1248 + /* translators: %s: booking reference or ID. */
1249 + __('Travel Itinerary #%s.pdf', 'yatra'),
1250 + $docRef
1251 + ),
644 1252 'trip_title' => $tripTitle,
645 1253 'category' => 'itinerary',
646 1254 'updated_at' => $createdAt ?: date('Y-m-d H:i:s'),
647 1255 'url' => $itineraryUrl,
@@ -790,8 +1398,12 @@
790 1398 'email' => $customer->email,
791 1399 'phone' => $customer->phone ?? '',
792 1400 'country' => $customer->country ?? '',
793 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 ?? '',
794 1406 'status' => $customer->status ?? 'active',
795 1407 'total_bookings' => (int) ($customer->total_bookings ?? 0),
796 1408 'total_spent' => (float) ($customer->total_spent ?? 0),
797 1409 'loyalty_tier' => $customer->loyalty_tier ?? 'bronze',