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 +526 -18 3.0.5 → 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 *
@@ -164,22 +165,233 @@
164 165 if ($userId <= 0) {
165 166 return null;
166 167 }
167 168
168 - $customer = $this->getCustomerByUserId($userId);
169 - if ($customer !== null) {
170 - 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);
171 176 }
172 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 + {
173 199 $user = get_userdata($userId);
174 200 if (!$user instanceof \WP_User) {
175 - return null;
201 + return ['success' => false, 'message' => __('Account not found.', 'yatra')];
176 202 }
177 203
178 - 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 + ];
179 232 }
180 233
181 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 + /**
182 394 * @return array<string, mixed>
183 395 */
184 396 private function buildProfileArrayFromWpUser(\WP_User $user): array
185 397 {
@@ -239,8 +451,31 @@
239 451 'existing_id' => (int) $existingCustomer->id,
240 452 ];
241 453 }
242 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 +
243 478 // Create customer
244 479 $customerId = $this->customerRepository->findOrCreate($data);
245 480
246 481 if (!$customerId) {
@@ -246,16 +481,120 @@
246 481 if (!$customerId) {
247 482 return ['success' => false, 'message' => __('Failed to create customer.', 'yatra')];
248 483 }
249 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 +
250 499 return [
251 500 'success' => true,
252 501 'customer_id' => $customerId,
253 - '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,
254 506 ];
255 507 }
256 508
257 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 + /**
258 597 * Update a customer
259 598 *
260 599 * @param int $id Customer ID
261 600 * @param array $data Customer data
@@ -268,16 +607,97 @@
268 607 if (!$customer) {
269 608 return ['success' => false, 'message' => __('Customer not found.', 'yatra')];
270 609 }
271 610
611 + $previousEmail = (string) $customer->email;
612 + $linkedUserId = (int) ($customer->user_id ?? 0);
613 +
272 614 // Check email uniqueness if changing
615 + $emailChanged = false;
616 + $newEmail = '';
273 617 if (!empty($data['email']) && $data['email'] !== $customer->email) {
274 - $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);
275 625 if ($existingCustomer && (int) $existingCustomer->id !== $id) {
276 626 return ['success' => false, 'message' => __('Email is already in use by another customer.', 'yatra')];
277 627 }
628 +
629 + $data['email'] = $newEmail;
630 + $emailChanged = true;
278 631 }
279 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 +
280 700 $updated = $this->customerRepository->updateCustomer($id, $data);
281 701
282 702 if (!$updated) {
283 703 return ['success' => false, 'message' => __('Failed to update customer.', 'yatra')];
@@ -282,11 +702,52 @@
282 702 if (!$updated) {
283 703 return ['success' => false, 'message' => __('Failed to update customer.', 'yatra')];
284 704 }
285 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 +
286 744 return [
287 745 'success' => true,
288 - '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'),
289 750 ];
290 751 }
291 752
292 753 /**
@@ -466,17 +927,31 @@
466 927 $emergencyContact = $decoded;
467 928 }
468 929 }
469 930
470 - // Unserialise the travelers payload here so both the legacy
471 - // `travelers` key AND the React-side `travelers_data` alias
472 - // share the same in-memory value — otherwise we'd unserialise
473 - // twice and drift if one consumer mutates the array.
474 - $travelersList = isset($booking->travelers) ? maybe_unserialize($booking->travelers) : null;
475 - if (is_string($travelersList)) {
476 - $decoded = json_decode($travelersList, true);
477 - if (is_array($decoded)) {
478 - $travelersList = $decoded;
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;
479 954 }
480 955 }
481 956
482 957 // Derive customer_* convenience fields. The admin React maps
@@ -521,9 +996,9 @@
521 996 'customer_email' => $booking->contact_email ?? null,
522 997 'customer_phone' => $booking->contact_phone ?? null,
523 998 'special_requests' => $booking->special_requests ?? null,
524 999 'emergency_contact' => $emergencyContact,
525 - 'contact_data' => isset($booking->contact_data) ? maybe_unserialize($booking->contact_data) : null,
1000 + 'contact_data' => $contactData,
526 1001 'travelers' => $travelersList,
527 1002 // React's BookingDetails reads `travelers_data` (same name
528 1003 // the admin ViewBooking screen uses); alias it here so the
529 1004 // "Travelers Information" card actually renders.
@@ -679,8 +1154,9 @@
679 1154 }
680 1155
681 1156 // Get payments for this booking (invoices per payment)
682 1157 $payments = $this->paymentRepository->findByBookingId($bookingId);
1158 + $hasPaidInvoice = false;
683 1159 foreach ($payments as $payment) {
684 1160 $paymentId = (int) ($payment->id ?? 0);
685 1161 if ($paymentId <= 0) {
686 1162 continue;
@@ -710,10 +1186,38 @@
710 1186 'url' => $invoiceUrl,
711 1187 'booking_id' => $bookingId,
712 1188 'payment_id' => $paymentId,
713 1189 ];
1190 + $hasPaidInvoice = true;
714 1191 }
715 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 +
716 1220 // Voucher per booking
717 1221 if ($status === 'confirmed') {
718 1222 $docRef = $reference ?: $bookingId;
719 1223
@@ -894,8 +1398,12 @@
894 1398 'email' => $customer->email,
895 1399 'phone' => $customer->phone ?? '',
896 1400 'country' => $customer->country ?? '',
897 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 ?? '',
898 1406 'status' => $customer->status ?? 'active',
899 1407 'total_bookings' => (int) ($customer->total_bookings ?? 0),
900 1408 'total_spent' => (float) ($customer->total_spent ?? 0),
901 1409 'loyalty_tier' => $customer->loyalty_tier ?? 'bronze',