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

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

1,311 lines 52.2 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 // Create customer
456 $customerId = $this->customerRepository->findOrCreate($data);
457
458 if (!$customerId) {
459 return ['success' => false, 'message' => __('Failed to create customer.', 'yatra')];
460 }
461
462 return [
463 'success' => true,
464 'customer_id' => $customerId,
465 'message' => __('Customer created successfully.', 'yatra'),
466 ];
467 }
468
469 /**
470 * Update a customer
471 *
472 * @param int $id Customer ID
473 * @param array $data Customer data
474 * @return array {success: bool, message: string}
475 */
476 public function updateCustomer(int $id, array $data): array
477 {
478 $customer = $this->customerRepository->find($id);
479
480 if (!$customer) {
481 return ['success' => false, 'message' => __('Customer not found.', 'yatra')];
482 }
483
484 $previousEmail = (string) $customer->email;
485 $linkedUserId = (int) ($customer->user_id ?? 0);
486
487 // Check email uniqueness if changing
488 $emailChanged = false;
489 $newEmail = '';
490 if (!empty($data['email']) && $data['email'] !== $customer->email) {
491 $newEmail = sanitize_email((string) $data['email']);
492
493 if (!is_email($newEmail)) {
494 return ['success' => false, 'message' => __('Please enter a valid email address.', 'yatra')];
495 }
496
497 $existingCustomer = $this->customerRepository->findByEmail($newEmail);
498 if ($existingCustomer && (int) $existingCustomer->id !== $id) {
499 return ['success' => false, 'message' => __('Email is already in use by another customer.', 'yatra')];
500 }
501
502 $data['email'] = $newEmail;
503 $emailChanged = true;
504 }
505
506 // Decide up-front whether this customer signs in, so a rejection happens
507 // BEFORE anything is written.
508 //
509 // An account is only recognised when this customer row unambiguously
510 // represents it — that is, the account currently carries this very same
511 // address. Several customer rows can legitimately share one user_id (an
512 // operator booking on behalf of guests while logged in links every row to
513 // their own account), and acting on the account from one of those rows
514 // would touch the WRONG person's login — including an administrator's.
515 $accountUserId = 0;
516 if ($emailChanged && $linkedUserId > 0) {
517 $linkedUser = get_userdata($linkedUserId);
518
519 if ($linkedUser && strtolower((string) $linkedUser->user_email) === strtolower($previousEmail)) {
520 $ownerId = email_exists($data['email']);
521 if ($ownerId && (int) $ownerId !== $linkedUserId) {
522 return [
523 'success' => false,
524 'message' => __('That email address already belongs to another user account.', 'yatra'),
525 ];
526 }
527
528 $accountUserId = $linkedUserId;
529 }
530 }
531
532 // A customer who can sign in keeps ownership of their own login address:
533 // the new address must confirm the change before it takes effect, exactly
534 // as it does when the customer edits it themselves. Nothing is written
535 // here — the stored email stays put until that link is clicked.
536 //
537 // A customer WITHOUT an account has no login and no inbox to confirm
538 // from, so their record is corrected immediately.
539 $pendingEmail = '';
540 if ($accountUserId > 0) {
541 unset($data['email']);
542 }
543
544 $updated = $this->customerRepository->updateCustomer($id, $data);
545
546 if (!$updated) {
547 return ['success' => false, 'message' => __('Failed to update customer.', 'yatra')];
548 }
549
550 if ($accountUserId > 0) {
551 $requested = $this->requestEmailChange($accountUserId, $newEmail);
552
553 if (empty($requested['success'])) {
554 Logger::warning('Admin-requested customer email change could not be sent', [
555 'customer_id' => $id,
556 'user_id' => $accountUserId,
557 'reason' => $requested['message'] ?? '',
558 ]);
559
560 return [
561 'success' => false,
562 'message' => $requested['message'] ?? __('The email change could not be requested.', 'yatra'),
563 ];
564 }
565
566 $pendingEmail = (string) ($requested['pending_email'] ?? $newEmail);
567
568 return [
569 'success' => true,
570 'message' => sprintf(
571 /* translators: %s: the new email address awaiting confirmation. */
572 __('Customer updated. A confirmation link was sent to %s — their email address changes once it is confirmed there.', 'yatra'),
573 $pendingEmail
574 ),
575 'pending_email' => $pendingEmail,
576 ];
577 }
578
579 return [
580 'success' => true,
581 'message' => __('Customer updated successfully.', 'yatra'),
582 ];
583 }
584
585 /**
586 * Update customer status
587 *
588 * @param int $id Customer ID
589 * @param string $status New status (active, inactive, blocked)
590 * @return array {success: bool, message: string}
591 */
592 public function updateStatus(int $id, string $status): array
593 {
594 $validStatuses = ['active', 'inactive', 'blocked'];
595
596 if (!in_array($status, $validStatuses, true)) {
597 return ['success' => false, 'message' => __('Invalid status.', 'yatra')];
598 }
599
600 $customer = $this->customerRepository->find($id);
601
602 if (!$customer) {
603 return ['success' => false, 'message' => __('Customer not found.', 'yatra')];
604 }
605
606 $updated = $this->customerRepository->updateCustomer($id, ['status' => $status]);
607
608 if (!$updated) {
609 return ['success' => false, 'message' => __('Failed to update status.', 'yatra')];
610 }
611
612 return [
613 'success' => true,
614 'message' => sprintf(
615 /* translators: %s: new customer status. */
616 __('Customer status updated to %s.', 'yatra'),
617 $status
618 ),
619 ];
620 }
621
622 /**
623 * Delete a customer
624 *
625 * @param int $id Customer ID
626 * @return array {success: bool, message: string}
627 */
628 public function deleteCustomer(int $id): array
629 {
630 $customer = $this->customerRepository->find($id);
631
632 if (!$customer) {
633 return ['success' => false, 'message' => __('Customer not found.', 'yatra')];
634 }
635
636 // Check for existing bookings
637 $bookings = $this->customerRepository->getCustomerBookings($id, 1);
638 if (!empty($bookings)) {
639 return [
640 'success' => false,
641 'message' => __('Cannot delete customer with existing bookings. Consider deactivating instead.', 'yatra'),
642 ];
643 }
644
645 $deleted = $this->customerRepository->deleteCustomer($id);
646
647 if (!$deleted) {
648 return ['success' => false, 'message' => __('Failed to delete customer.', 'yatra')];
649 }
650
651 return [
652 'success' => true,
653 'message' => __('Customer deleted successfully.', 'yatra'),
654 ];
655 }
656
657 /**
658 * Get customer's bookings
659 *
660 * @param int $customerId Customer ID
661 * @param int $limit Limit results
662 * @return array
663 */
664 public function getCustomerBookings(int $customerId, int $limit = 10): array
665 {
666 return $this->customerRepository->getCustomerBookings($customerId, $limit);
667 }
668
669 /**
670 * Get bookings by WordPress user ID (checks both customer_id and user_id)
671 *
672 * @param int $userId WordPress user ID
673 * @param int $limit Limit results
674 * @return array
675 */
676 public function getBookingsByUserId(int $userId, int $limit = 10): array
677 {
678 // First, try to get customer and bookings by customer_id
679 $customer = $this->getCustomerByUserId($userId);
680 $bookings = [];
681
682 if ($customer) {
683 $bookings = $this->getCustomerBookings((int) $customer['id'], $limit);
684 }
685
686 // Also get bookings directly by user_id (in case bookings were made before customer record was created)
687 $bookingsByUserId = $this->bookingRepository->findByUserId($userId, $limit);
688
689 // And include bookings made via the same email address
690 $emailBookings = [];
691 $user = get_userdata($userId);
692 if ($user && !empty($user->user_email)) {
693 $emailBookings = $this->bookingRepository->findByContactEmail($user->user_email, $limit);
694 }
695
696 // Merge and deduplicate by booking ID
697 $bookingIds = [];
698 $allBookings = [];
699 $sources = [$bookings, $bookingsByUserId, $emailBookings];
700
701 foreach ($sources as $collection) {
702 foreach ($collection as $booking) {
703 $bookingId = is_array($booking) ? ($booking['id'] ?? $booking['booking_id'] ?? null) : ($booking->id ?? null);
704 if ($bookingId && !in_array($bookingId, $bookingIds, true)) {
705 $bookingIds[] = $bookingId;
706 $allBookings[] = $booking;
707 }
708 }
709 }
710
711 // Limit results
712 if ($limit > 0 && count($allBookings) > $limit) {
713 $allBookings = array_slice($allBookings, 0, $limit);
714 }
715
716 return $allBookings;
717 }
718
719 public function getBookingDetailsForUser(int $userId, int $bookingId): ?array
720 {
721 if ($userId <= 0 || $bookingId <= 0) {
722 return null;
723 }
724
725 $booking = $this->bookingRepository->findWithTrip($bookingId);
726 if (!$booking) {
727 return null;
728 }
729
730 $user = get_userdata($userId);
731 $userEmail = ($user && !empty($user->user_email)) ? (string) $user->user_email : '';
732
733 $customer = $this->getCustomerByUserId($userId);
734 $customerId = $customer ? (int) ($customer['id'] ?? 0) : 0;
735
736 $bookingUserId = isset($booking->user_id) ? (int) $booking->user_id : 0;
737 $bookingCustomerId = isset($booking->customer_id) ? (int) $booking->customer_id : 0;
738 $bookingEmail = isset($booking->contact_email) ? (string) $booking->contact_email : '';
739
740 $allowed = false;
741 if ($bookingUserId > 0 && $bookingUserId === $userId) {
742 $allowed = true;
743 }
744 if (!$allowed && $customerId > 0 && $bookingCustomerId > 0 && $bookingCustomerId === $customerId) {
745 $allowed = true;
746 }
747 if (!$allowed && $userEmail !== '' && $bookingEmail !== '' && strtolower($userEmail) === strtolower($bookingEmail)) {
748 $allowed = true;
749 }
750
751 if (!$allowed) {
752 return null;
753 }
754
755 $emergencyContact = isset($booking->emergency_contact) ? maybe_unserialize($booking->emergency_contact) : null;
756 if (is_string($emergencyContact)) {
757 $decoded = json_decode($emergencyContact, true);
758 if (is_array($decoded)) {
759 $emergencyContact = $decoded;
760 }
761 }
762
763 // Load travellers from the normalized meta tables — the SAME source the
764 // admin booking screens use (TravellerRepository::getByBookingId, each
765 // row carrying its dynamic `fields`). The previous code read
766 // `$booking->travelers`, a column that does NOT exist (the schema only
767 // has `travelers_count`), so `travelers_data` was ALWAYS empty and the
768 // account-page "Travelers Information" card never rendered. Returns []
769 // for older bookings with no normalized rows (card simply hidden), so
770 // this is safe for existing bookings.
771 $travellerRepository = new \Yatra\Repositories\TravellerRepository();
772 $travelersList = $travellerRepository->getByBookingId($bookingId);
773 if (!is_array($travelersList)) {
774 $travelersList = [];
775 }
776
777 // contact_data is stored as JSON; decode to an array so the account
778 // page can read custom contact fields (matches emergency_contact above
779 // and BookingService::formatBookingWithDetails). maybe_unserialize is a
780 // no-op on a JSON string, so a fallback json_decode is required.
781 $contactData = isset($booking->contact_data) ? maybe_unserialize($booking->contact_data) : null;
782 if (is_string($contactData)) {
783 $decodedContact = json_decode($contactData, true);
784 if (is_array($decodedContact)) {
785 $contactData = $decodedContact;
786 }
787 }
788
789 // Derive customer_* convenience fields. The admin React maps
790 // contact_first_name + contact_last_name → customer_name at the
791 // page level (see ViewBooking.tsx), so we mirror the same shape
792 // server-side for the customer account view. Keeping ALL
793 // original contact_* fields too so any caller depending on the
794 // old shape (filters, integrations) stays unaffected.
795 $contactFirst = (string) ($booking->contact_first_name ?? '');
796 $contactLast = (string) ($booking->contact_last_name ?? '');
797 $customerName = trim($contactFirst . ' ' . $contactLast);
798
799 $details = [
800 'id' => (int) ($booking->id ?? 0),
801 'reference' => $booking->reference ?? null,
802 'trip_id' => (int) ($booking->trip_id ?? 0),
803 'trip_title' => $booking->trip_title ?? null,
804 'trip_slug' => $booking->trip_slug ?? null,
805 'trip_url' => function_exists('yatra_get_trip_permalink') ? yatra_get_trip_permalink((int) ($booking->trip_id ?? 0)) : '',
806 'featured_image' => $booking->featured_image ?? null,
807 'created_at' => $booking->created_at ?? null,
808 'updated_at' => $booking->updated_at ?? null,
809 'travel_date' => $booking->travel_date ?? null,
810 'start_date' => $booking->start_date ?? $booking->travel_date ?? null,
811 'end_date' => $booking->end_date ?? null,
812 'travelers_count' => (int) ($booking->travelers_count ?? 0),
813 'total_amount' => (float) ($booking->total_amount ?? 0),
814 'amount_paid' => (float) ($booking->amount_paid ?? 0),
815 'amount_due' => (float) ($booking->amount_due ?? 0),
816 'currency' => $booking->currency ?? null,
817 'payment_status' => $booking->payment_status ?? null,
818 'status' => $booking->status ?? null,
819 'payment_gateway' => $booking->payment_gateway ?? null,
820 'contact_first_name' => $booking->contact_first_name ?? null,
821 'contact_last_name' => $booking->contact_last_name ?? null,
822 'contact_email' => $booking->contact_email ?? null,
823 'contact_phone' => $booking->contact_phone ?? null,
824 'contact_country' => $booking->contact_country ?? null,
825 // Convenience aliases the React account page (BookingDetails.tsx)
826 // reads as `customer_name`/`customer_email`/`customer_phone`.
827 'customer_name' => $customerName !== '' ? $customerName : null,
828 'customer_email' => $booking->contact_email ?? null,
829 'customer_phone' => $booking->contact_phone ?? null,
830 'special_requests' => $booking->special_requests ?? null,
831 'emergency_contact' => $emergencyContact,
832 'contact_data' => $contactData,
833 'travelers' => $travelersList,
834 // React's BookingDetails reads `travelers_data` (same name
835 // the admin ViewBooking screen uses); alias it here so the
836 // "Travelers Information" card actually renders.
837 'travelers_data' => is_array($travelersList) ? $travelersList : [],
838 'payments' => [],
839 ];
840
841 return apply_filters('yatra_customer_booking_details', $details, $booking, $userId);
842 }
843
844 /**
845 * Get customer's payments
846 *
847 * @param int $customerId Customer ID
848 * @param int $limit Limit results
849 * @return array
850 */
851 public function getCustomerPayments(int $customerId, int $limit = 50): array
852 {
853 $bookings = $this->customerRepository->getCustomerBookings($customerId, 1000);
854 $bookingIds = array_map(static function($booking) {
855 if (is_object($booking)) {
856 return (int) ($booking->id ?? 0);
857 }
858 if (is_array($booking)) {
859 return (int) ($booking['id'] ?? 0);
860 }
861 return 0;
862 }, $bookings);
863
864 // Include bookings linked via user ID or email (older bookings may not have customer_id)
865 $customer = $this->customerRepository->find($customerId);
866 if ($customer) {
867 if (!empty($customer->user_id)) {
868 $userBookings = $this->bookingRepository->findByUserId((int) $customer->user_id, 1000);
869 $bookingIds = array_merge($bookingIds, array_map(static function($booking) {
870 if (is_object($booking)) {
871 return (int) ($booking->id ?? 0);
872 }
873 if (is_array($booking)) {
874 return (int) ($booking['id'] ?? 0);
875 }
876 return 0;
877 }, $userBookings));
878 }
879
880 if (!empty($customer->email)) {
881 $emailBookings = $this->bookingRepository->findByContactEmail($customer->email, 1000);
882 $bookingIds = array_merge($bookingIds, array_map(static function($booking) {
883 if (is_object($booking)) {
884 return (int) ($booking->id ?? 0);
885 }
886 if (is_array($booking)) {
887 return (int) ($booking['id'] ?? 0);
888 }
889 return 0;
890 }, $emailBookings));
891 }
892 }
893
894 $bookingIds = array_values(array_unique(array_filter($bookingIds)));
895
896 return $this->getPaymentsForBookingIds($bookingIds, $limit);
897 }
898
899 public function getPaymentsByUserId(int $userId, int $limit = 50): array
900 {
901 $bookings = $this->bookingRepository->findByUserId($userId, 1000);
902
903 $user = get_userdata($userId);
904 if ($user && !empty($user->user_email)) {
905 $bookingsByEmail = $this->bookingRepository->findByContactEmail($user->user_email, 1000);
906 $bookings = array_merge($bookings, $bookingsByEmail);
907 }
908
909 $bookingIds = array_map(static function($booking) {
910 if (is_object($booking)) {
911 return (int) ($booking->id ?? 0);
912 }
913 if (is_array($booking)) {
914 return (int) ($booking['id'] ?? 0);
915 }
916 return 0;
917 }, $bookings);
918
919 return $this->getPaymentsForBookingIds($bookingIds, $limit);
920 }
921
922 private function getPaymentsForBookingIds(array $bookingIds, int $limit = 50): array
923 {
924 $bookingIds = array_values(array_filter(array_map('intval', $bookingIds))); // ensure ints
925
926 if (empty($bookingIds)) {
927 return [];
928 }
929
930 $customerRepository = new \Yatra\Repositories\CustomerRepository();
931 $payments = $customerRepository->getPaymentsForBookingIds($bookingIds, $limit);
932
933 // Route the customer-facing payments through the shared formatter so
934 // they emit the same field shape the rest of the app uses — most
935 // importantly the React Account → Payments tab's aliases
936 // (`date`, `method`, `reference`, `type`, `booking_number`,
937 // `payment_date`, `payment_number`). The previous inline formatter
938 // omitted those keys, which is why the Payments cards rendered
939 // "N/A" for the date, blank for the method, and an empty space
940 // above the "Booking:" label.
941 $paymentService = new \Yatra\Services\PaymentService();
942
943 return array_map(static function ($payment) use ($paymentService) {
944 $row = $paymentService->formatPayment($payment);
945 // Preserve the booking-amount summary fields used by the React
946 // payments tab to decide whether to render a "Pay Remaining" CTA.
947 // formatPayment doesn't know about these (they come from the
948 // CustomerRepository JOIN); attach them here so we keep the
949 // canonical shape AND the extra context.
950 $row['booking_amount_due'] = (float) ($payment->booking_amount_due ?? 0);
951 $row['booking_amount_paid'] = (float) ($payment->booking_amount_paid ?? 0);
952 $row['booking_total_amount'] = (float) ($payment->booking_total_amount ?? 0);
953 return $row;
954 }, $payments);
955 }
956
957 public function getDocumentsForBookings(array $bookings, int $customerId = 0): array
958 {
959 $documents = [];
960
961 // Process each booking individually for vouchers and itineraries
962 // but group by trip for downloads
963 $tripsWithBookings = [];
964
965 foreach ($bookings as $booking) {
966 $bookingId = is_object($booking) ? (int) ($booking->id ?? 0) : (int) ($booking['id'] ?? $booking['booking_id'] ?? 0);
967 $tripId = is_object($booking) ? (int) ($booking->trip_id ?? 0) : (int) ($booking['trip_id'] ?? 0);
968 $tripTitle = is_object($booking) ? (string) ($booking->trip_title ?? '') : (string) ($booking['trip_title'] ?? '');
969 $reference = is_object($booking) ? ($booking->reference ?? null) : ($booking['reference'] ?? null);
970 $status = is_object($booking) ? (string) ($booking->status ?? '') : (string) ($booking['status'] ?? '');
971 $createdAt = is_object($booking) ? (string) ($booking->created_at ?? '') : (string) ($booking['created_at'] ?? '');
972
973 if ($bookingId <= 0) {
974 continue;
975 }
976
977 // Store trip info for downloads (grouped by trip)
978 if ($tripId > 0 && !isset($tripsWithBookings[$tripId])) {
979 $tripsWithBookings[$tripId] = [
980 'booking_id' => $bookingId,
981 'trip_title' => $tripTitle,
982 'reference' => $reference,
983 'status' => $status,
984 'created_at' => $createdAt,
985 ];
986 }
987
988 // Get payments for this booking (invoices per payment)
989 $payments = $this->paymentRepository->findByBookingId($bookingId);
990 $hasPaidInvoice = false;
991 foreach ($payments as $payment) {
992 $paymentId = (int) ($payment->id ?? 0);
993 if ($paymentId <= 0) {
994 continue;
995 }
996
997 $paymentStatus = (string) ($payment->status ?? '');
998 if (!in_array($paymentStatus, ['paid', 'completed', 'success'], true)) {
999 continue;
1000 }
1001
1002 $docRef = $reference ?: $bookingId;
1003
1004 // Invoice per payment
1005 $invoiceUrl = rest_url('yatra/v1/payment/' . $paymentId . '/invoice');
1006 $invoiceUrl = add_query_arg('_wpnonce', wp_create_nonce('wp_rest'), $invoiceUrl);
1007
1008 $documents[] = [
1009 'id' => 'invoice-payment-' . $paymentId,
1010 'name' => sprintf(
1011 /* translators: %s: booking reference or ID. */
1012 __('Invoice #%s.pdf', 'yatra'),
1013 $docRef
1014 ),
1015 'trip_title' => $tripTitle,
1016 'category' => 'invoice',
1017 'updated_at' => $payment->created_at ?? $createdAt ?: date('Y-m-d H:i:s'),
1018 'url' => $invoiceUrl,
1019 'booking_id' => $bookingId,
1020 'payment_id' => $paymentId,
1021 ];
1022 $hasPaidInvoice = true;
1023 }
1024
1025 // Pro-forma invoice for offline / unpaid bookings (e.g. Bank Transfer):
1026 // no completed payment yet, but there is a balance due. Carries the
1027 // gateway's payment instructions so the customer knows how to pay.
1028 $amountDue = is_object($booking)
1029 ? (float) ($booking->amount_due ?? $booking->booking_amount_due ?? 0)
1030 : (float) ($booking['amount_due'] ?? $booking['booking_amount_due'] ?? 0);
1031 if (!$hasPaidInvoice && $amountDue > 0) {
1032 $proformaToken = \Yatra\Controllers\PaymentGatewayController::issueInvoiceToken(0, $bookingId);
1033 $proformaUrl = add_query_arg(
1034 ['invoice_token' => $proformaToken, '_wpnonce' => wp_create_nonce('wp_rest')],
1035 rest_url('yatra/v1/booking/' . $bookingId . '/invoice')
1036 );
1037 $documents[] = [
1038 'id' => 'invoice-booking-' . $bookingId,
1039 'name' => sprintf(
1040 /* translators: %s: booking reference or ID. */
1041 __('Invoice #%s.pdf', 'yatra'),
1042 $reference ?: $bookingId
1043 ),
1044 'trip_title' => $tripTitle,
1045 'category' => 'invoice',
1046 'updated_at' => $createdAt ?: date('Y-m-d H:i:s'),
1047 'url' => $proformaUrl,
1048 'booking_id' => $bookingId,
1049 ];
1050 }
1051
1052 // Voucher per booking
1053 if ($status === 'confirmed') {
1054 $docRef = $reference ?: $bookingId;
1055
1056 $voucherUrl = rest_url('yatra/v1/bookings/' . $bookingId . '/voucher');
1057 $voucherUrl = add_query_arg('_wpnonce', wp_create_nonce('wp_rest'), $voucherUrl);
1058
1059 $documents[] = [
1060 'id' => 'voucher-' . $bookingId, // Booking-based ID
1061 'name' => sprintf(
1062 /* translators: %s: booking reference or ID. */
1063 __('Travel Voucher #%s.pdf', 'yatra'),
1064 $docRef
1065 ),
1066 'trip_title' => $tripTitle,
1067 'category' => 'voucher',
1068 'updated_at' => $createdAt ?: date('Y-m-d H:i:s'),
1069 'url' => $voucherUrl,
1070 'booking_id' => $bookingId,
1071 ];
1072
1073 // Itinerary per booking
1074 $itineraryUrl = rest_url('yatra/v1/bookings/' . $bookingId . '/itinerary');
1075 $itineraryUrl = add_query_arg('_wpnonce', wp_create_nonce('wp_rest'), $itineraryUrl);
1076
1077 $documents[] = [
1078 'id' => 'itinerary-' . $bookingId, // Booking-based ID
1079 'name' => sprintf(
1080 /* translators: %s: booking reference or ID. */
1081 __('Travel Itinerary #%s.pdf', 'yatra'),
1082 $docRef
1083 ),
1084 'trip_title' => $tripTitle,
1085 'category' => 'itinerary',
1086 'updated_at' => $createdAt ?: date('Y-m-d H:i:s'),
1087 'url' => $itineraryUrl,
1088 'booking_id' => $bookingId,
1089 ];
1090 }
1091 }
1092
1093 usort($documents, function ($a, $b) {
1094 return strtotime($b['updated_at']) - strtotime($a['updated_at']);
1095 });
1096
1097 // Apply downloads filter (which groups by trip)
1098 $documents = apply_filters('yatra_customer_documents', $documents, $bookings, $customerId);
1099
1100 return is_array($documents) ? $documents : [];
1101 }
1102
1103 /**
1104 * Get customer's documents (invoices, vouchers, itineraries)
1105 *
1106 * @param int $customerId Customer ID
1107 * @return array
1108 */
1109 public function getCustomerDocuments(int $customerId): array
1110 {
1111 // Get customer's bookings
1112 $bookings = $this->customerRepository->getCustomerBookings($customerId, 1000);
1113
1114 // Also include bookings linked via user_id/email (older bookings may not have customer_id)
1115 $customer = $this->customerRepository->find($customerId);
1116 if ($customer) {
1117 if (!empty($customer->user_id)) {
1118 $userBookings = $this->bookingRepository->findByUserId((int) $customer->user_id, 1000);
1119 $bookings = array_merge($bookings, $userBookings);
1120 }
1121
1122 if (!empty($customer->email)) {
1123 $emailBookings = $this->bookingRepository->findByContactEmail((string) $customer->email, 1000);
1124 $bookings = array_merge($bookings, $emailBookings);
1125 }
1126 }
1127
1128 // Deduplicate by booking id
1129 $seen = [];
1130 $unique = [];
1131 foreach ($bookings as $b) {
1132 $id = is_object($b) ? ($b->id ?? null) : ($b['id'] ?? $b['booking_id'] ?? null);
1133 if ($id && !isset($seen[$id])) {
1134 $seen[$id] = true;
1135 $unique[] = $b;
1136 }
1137 }
1138
1139 return $this->getDocumentsForBookings($unique, $customerId);
1140 }
1141
1142 /**
1143 * Get customer's support tickets
1144 *
1145 * @param int $customerId Customer ID
1146 * @return array
1147 */
1148 public function getCustomerSupportTickets(int $customerId): array
1149 {
1150 // For now, return empty array as support tickets system may not be implemented yet
1151 // This can be extended when support ticket system is added
1152 return [];
1153 }
1154
1155 /**
1156 * Merge two customer records
1157 *
1158 * @param int $sourceId Source customer ID (will be deleted)
1159 * @param int $targetId Target customer ID (will be kept)
1160 * @return array {success: bool, message: string}
1161 */
1162 public function mergeCustomers(int $sourceId, int $targetId): array
1163 {
1164 if ($sourceId === $targetId) {
1165 return ['success' => false, 'message' => __('Cannot merge customer with itself.', 'yatra')];
1166 }
1167
1168 $source = $this->customerRepository->find($sourceId);
1169 $target = $this->customerRepository->find($targetId);
1170
1171 if (!$source || !$target) {
1172 return ['success' => false, 'message' => __('One or both customers not found.', 'yatra')];
1173 }
1174
1175 // Update all bookings to point to target customer
1176 $this->bookingRepository->updateCustomerBookings($sourceId, $targetId);
1177
1178 // Update target customer stats
1179 $this->customerRepository->updateCustomer($targetId, [
1180 'total_bookings' => (int) $target->total_bookings + (int) $source->total_bookings,
1181 'total_spent' => (float) $target->total_spent + (float) $source->total_spent,
1182 ]);
1183
1184 // Delete source customer
1185 $this->customerRepository->deleteCustomer($sourceId);
1186
1187 return [
1188 'success' => true,
1189 'message' => __('Customers merged successfully.', 'yatra'),
1190 ];
1191 }
1192
1193 /**
1194 * Format customer for API response
1195 *
1196 * @param object $customer Raw customer data
1197 * @return array
1198 */
1199 private function formatCustomer(object $customer): array
1200 {
1201 $name = trim((string) ($customer->first_name ?? '') . ' ' . (string) ($customer->last_name ?? ''));
1202 if ($name === '') {
1203 $uid = (int) ($customer->user_id ?? 0);
1204 if ($uid > 0) {
1205 $u = get_userdata($uid);
1206 if ($u instanceof \WP_User) {
1207 $name = trim((string) $u->display_name);
1208 if ($name === '') {
1209 $name = trim($u->first_name . ' ' . $u->last_name);
1210 }
1211 if ($name === '') {
1212 $name = (string) $u->user_login;
1213 }
1214 }
1215 }
1216 }
1217 if ($name === '' && !empty($customer->email)) {
1218 $local = explode('@', (string) $customer->email)[0] ?? '';
1219 $name = $local !== '' ? $local : $name;
1220 }
1221
1222 $created = $customer->created_at ?? '';
1223
1224 return [
1225 'id' => (int) $customer->id,
1226 'user_id' => $customer->user_id ? (int) $customer->user_id : null,
1227 'name' => $name,
1228 'first_name' => $customer->first_name ?? '',
1229 'last_name' => $customer->last_name ?? '',
1230 'email' => $customer->email,
1231 'phone' => $customer->phone ?? '',
1232 'country' => $customer->country ?? '',
1233 'city' => $customer->city ?? '',
1234 // Address belongs to the account profile too. Without it here the
1235 // account page never received the saved value — which is exactly why
1236 // city/country updated but address didn't.
1237 'address' => $customer->address ?? '',
1238 'status' => $customer->status ?? 'active',
1239 'total_bookings' => (int) ($customer->total_bookings ?? 0),
1240 'total_spent' => (float) ($customer->total_spent ?? 0),
1241 'loyalty_tier' => $customer->loyalty_tier ?? 'bronze',
1242 'created_at' => $created,
1243 'registered_at' => $created,
1244 'last_booking_date' => $customer->last_booking_date ?? null,
1245 ];
1246 }
1247
1248 /**
1249 * Format customer with all details
1250 *
1251 * @param object $customer Raw customer data
1252 * @return array
1253 */
1254 private function formatCustomerWithDetails(object $customer): array
1255 {
1256 $formatted = $this->formatCustomer($customer);
1257
1258 // Add additional fields
1259 $formatted['secondary_phone'] = $customer->secondary_phone ?? null;
1260 $formatted['address'] = $customer->address ?? null;
1261 $formatted['state'] = $customer->state ?? null;
1262 $formatted['postal_code'] = $customer->postal_code ?? null;
1263 $formatted['date_of_birth'] = $customer->date_of_birth ?? null;
1264 $formatted['gender'] = $customer->gender ?? null;
1265 $formatted['nationality'] = $customer->nationality ?? null;
1266
1267 // Emergency contact
1268 $formatted['emergency_contact'] = [
1269 'name' => $customer->emergency_name ?? null,
1270 'phone' => $customer->emergency_phone ?? null,
1271 'relationship' => $customer->emergency_relationship ?? null,
1272 ];
1273
1274 // Preferences
1275 $formatted['dietary_requirements'] = $customer->dietary_requirements ?? null;
1276 $formatted['medical_conditions'] = $customer->medical_conditions ?? null;
1277 $formatted['special_needs'] = $customer->special_needs ?? null;
1278 $formatted['preferred_language'] = $customer->preferred_language ?? 'en';
1279 $formatted['preferred_currency'] = $customer->preferred_currency ?? 'USD';
1280
1281 // Marketing
1282 $formatted['newsletter_optin'] = (bool) ($customer->newsletter_optin ?? false);
1283 $formatted['marketing_optin'] = (bool) ($customer->marketing_optin ?? false);
1284 $formatted['source'] = $customer->source ?? null;
1285
1286 // Stats
1287 $formatted['total_travelers'] = (int) ($customer->total_travelers ?? 0);
1288 $formatted['last_travel_date'] = $customer->last_travel_date ?? null;
1289 $formatted['loyalty_points'] = (int) ($customer->loyalty_points ?? 0);
1290
1291 // Gateway IDs
1292 $formatted['stripe_customer_id'] = $customer->stripe_customer_id ?? null;
1293 $formatted['paypal_customer_id'] = $customer->paypal_customer_id ?? null;
1294 $formatted['razorpay_customer_id'] = $customer->razorpay_customer_id ?? null;
1295
1296 // Notes
1297 $formatted['notes'] = $customer->notes ?? null;
1298
1299 // Recent bookings
1300 $formatted['recent_bookings'] = $customer->recent_bookings ?? [];
1301
1302 // Timestamps
1303 $formatted['updated_at'] = $customer->updated_at ?? null;
1304 $formatted['last_login_at'] = $customer->last_login_at ?? null;
1305 $formatted['verified_at'] = $customer->verified_at ?? null;
1306
1307 return $formatted;
1308 }
1309 }
1310
1311