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

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

749 lines 26.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\Controllers;
6
7 use WP_REST_Request;
8 use WP_REST_Response;
9 use WP_Error;
10 use Yatra\Services\CustomerService;
11 use Yatra\Validators\CustomerValidator;
12 use Yatra\Exceptions\ValidationException;
13 use Yatra\Utils\Logger;
14
15 /**
16 * Customer REST API Controller
17 *
18 * Handles HTTP requests only - delegates business logic to CustomerService.
19 *
20 * NO DATABASE QUERIES OR BUSINESS LOGIC IN THIS FILE.
21 *
22 * @package Yatra\Controllers
23 */
24 class CustomerController extends BaseController
25 {
26 /**
27 * Customer service instance
28 */
29 private CustomerService $customerService;
30
31 /**
32 * Constructor
33 */
34 public function __construct()
35 {
36 $this->customerService = new CustomerService();
37 }
38
39 /**
40 * Register routes
41 */
42 public function register_routes(): void
43 {
44 $namespace = 'yatra/v1';
45 $base = 'customers';
46
47 // =====================
48 // CUSTOMER (FRONTEND) ROUTES
49 // =====================
50
51 // Get current customer profile
52 register_rest_route($namespace, '/' . $base . '/me', [
53 [
54 'methods' => \WP_REST_Server::READABLE,
55 'callback' => [$this, 'getMe'],
56 'permission_callback' => [$this, 'checkCustomerPermission'],
57 ],
58 [
59 'methods' => \WP_REST_Server::EDITABLE,
60 'callback' => [$this, 'updateMe'],
61 'permission_callback' => [$this, 'checkCustomerPermission'],
62 ],
63 ]);
64
65 // Change current customer's account password
66 register_rest_route($namespace, '/' . $base . '/me/password', [
67 [
68 'methods' => \WP_REST_Server::EDITABLE,
69 'callback' => [$this, 'updateMyPassword'],
70 'permission_callback' => [$this, 'checkCustomerPermission'],
71 ],
72 ]);
73
74 // Request an account email change (WordPress pending-change pattern:
75 // a confirmation link is emailed to the new address; nothing changes yet).
76 // The confirmation link itself is a normal front-end URL handled by
77 // AccountPageHandler — NOT a REST route — because a browser GET to REST
78 // carries no nonce and would be treated as anonymous.
79 register_rest_route($namespace, '/' . $base . '/me/email', [
80 [
81 'methods' => \WP_REST_Server::CREATABLE,
82 'callback' => [$this, 'updateMyEmail'],
83 'permission_callback' => [$this, 'checkCustomerPermission'],
84 ],
85 ]);
86
87 // Re-send the confirmation email for an already-pending email change.
88 register_rest_route($namespace, '/' . $base . '/me/email/resend', [
89 [
90 'methods' => \WP_REST_Server::CREATABLE,
91 'callback' => [$this, 'resendMyEmailConfirmation'],
92 'permission_callback' => [$this, 'checkCustomerPermission'],
93 ],
94 ]);
95
96 // Cancel (dismiss) a pending email change — deletes the stored token.
97 register_rest_route($namespace, '/' . $base . '/me/email', [
98 [
99 'methods' => \WP_REST_Server::DELETABLE,
100 'callback' => [$this, 'cancelMyEmailChange'],
101 'permission_callback' => [$this, 'checkCustomerPermission'],
102 ],
103 ]);
104
105 // Current customer's bookings
106 register_rest_route($namespace, '/' . $base . '/my-bookings', [
107 [
108 'methods' => \WP_REST_Server::READABLE,
109 'callback' => [$this, 'getMyBookings'],
110 'permission_callback' => [$this, 'checkCustomerPermission'],
111 ],
112 ]);
113
114 register_rest_route($namespace, '/' . $base . '/my-bookings/(?P<id>\d+)', [
115 [
116 'methods' => \WP_REST_Server::READABLE,
117 'callback' => [$this, 'getMyBooking'],
118 'permission_callback' => [$this, 'checkCustomerPermission'],
119 'args' => [
120 'id' => [
121 'required' => true,
122 'type' => 'integer',
123 'sanitize_callback' => 'absint',
124 ],
125 ],
126 ],
127 ]);
128
129 // Current customer's payments
130 register_rest_route($namespace, '/' . $base . '/my-payments', [
131 [
132 'methods' => \WP_REST_Server::READABLE,
133 'callback' => [$this, 'getMyPayments'],
134 'permission_callback' => [$this, 'checkCustomerPermission'],
135 ],
136 ]);
137
138 // Current customer's documents
139 register_rest_route($namespace, '/' . $base . '/my-documents', [
140 [
141 'methods' => \WP_REST_Server::READABLE,
142 'callback' => [$this, 'getMyDocuments'],
143 'permission_callback' => [$this, 'checkCustomerPermission'],
144 ],
145 ]);
146
147 // Current customer's support tickets
148 register_rest_route($namespace, '/' . $base . '/my-support-tickets', [
149 [
150 'methods' => \WP_REST_Server::READABLE,
151 'callback' => [$this, 'getMySupportTickets'],
152 'permission_callback' => [$this, 'checkCustomerPermission'],
153 ],
154 ]);
155
156 // =====================
157 // ADMIN ROUTES
158 // =====================
159
160 // List + create customers. List gates on view, create gates
161 // on edit (creating a customer is a write).
162 register_rest_route($namespace, '/' . $base, [
163 [
164 'methods' => \WP_REST_Server::READABLE,
165 'callback' => [$this, 'getCustomers'],
166 'permission_callback' => [$this, 'checkCanView'],
167 ],
168 [
169 'methods' => \WP_REST_Server::CREATABLE,
170 'callback' => [$this, 'createCustomer'],
171 'permission_callback' => [$this, 'checkCanEdit'],
172 ],
173 ]);
174
175 // Single customer — read / update / delete with distinct caps.
176 register_rest_route($namespace, '/' . $base . '/(?P<id>\d+)', [
177 [
178 'methods' => \WP_REST_Server::READABLE,
179 'callback' => [$this, 'getCustomer'],
180 'permission_callback' => [$this, 'checkCanView'],
181 ],
182 [
183 'methods' => \WP_REST_Server::EDITABLE,
184 'callback' => [$this, 'updateCustomer'],
185 'permission_callback' => [$this, 'checkCanEdit'],
186 ],
187 [
188 'methods' => \WP_REST_Server::DELETABLE,
189 'callback' => [$this, 'deleteCustomer'],
190 'permission_callback' => [$this, 'checkCanEdit'],
191 ],
192 ]);
193
194 // Customer bookings list — view cap.
195 register_rest_route($namespace, '/' . $base . '/(?P<id>\d+)/bookings', [
196 [
197 'methods' => \WP_REST_Server::READABLE,
198 'callback' => [$this, 'getCustomerBookings'],
199 'permission_callback' => [$this, 'checkCanView'],
200 ],
201 ]);
202
203 // Merge customers — destructive write. Edit cap.
204 register_rest_route($namespace, '/' . $base . '/merge', [
205 [
206 'methods' => \WP_REST_Server::CREATABLE,
207 'callback' => [$this, 'mergeCustomers'],
208 'permission_callback' => [$this, 'checkCanEdit'],
209 ],
210 ]);
211
212 // Customer statistics — view cap.
213 register_rest_route($namespace, '/' . $base . '/stats', [
214 [
215 'methods' => \WP_REST_Server::READABLE,
216 'callback' => [$this, 'getCustomerStats'],
217 'permission_callback' => [$this, 'checkCanView'],
218 ],
219 ]);
220 }
221
222 /**
223 * Check if user is logged in (for /me, /my-bookings etc. — these
224 * are customer-facing endpoints that work for any logged-in WP
225 * user, not just team members).
226 */
227 public function checkCustomerPermission(): bool
228 {
229 return is_user_logged_in();
230 }
231
232 /**
233 * Granular admin-side permission checks. WP administrators pass
234 * every cap via the Team module's admin-fallback filter, so an
235 * explicit `manage_options` check isn't needed here.
236 */
237 public function checkCanView(): bool
238 {
239 return current_user_can('yatra_view_customers');
240 }
241
242 public function checkCanEdit(): bool
243 {
244 return current_user_can('yatra_edit_customers');
245 }
246
247 /**
248 * @deprecated Kept for any external code referencing the old
249 * method name. The previous implementation OR-ed
250 * `yatra_manage_customers` which was never registered anywhere
251 * — that arm has been removed because it was always false in
252 * practice. Routes to view-only — admin users still pass via
253 * the admin-fallback layer.
254 */
255 public function checkAdminPermission(): bool
256 {
257 return $this->checkCanView();
258 }
259
260 // =========================================================================
261 // FRONTEND ENDPOINTS (Current User)
262 // =========================================================================
263
264 /**
265 * GET /customers/me - Get current customer profile
266 */
267 public function getMe(WP_REST_Request $request): WP_REST_Response
268 {
269 $userId = get_current_user_id();
270
271 $profile = $this->customerService->getAccountProfileForUser($userId);
272
273 if ($profile === null) {
274 return new WP_REST_Response([
275 'success' => false,
276 'message' => __('Customer profile not found.', 'yatra'),
277 ], 404);
278 }
279
280 return new WP_REST_Response([
281 'success' => true,
282 'data' => $profile,
283 ]);
284 }
285
286 /**
287 * PUT /customers/me - Update current customer profile
288 */
289 public function updateMe(WP_REST_Request $request)
290 {
291 try {
292 $userId = get_current_user_id();
293 $data = $request->get_json_params();
294
295 // Email is the account login and is intentionally NOT editable from
296 // the account profile. Strip it server-side so it can never be
297 // changed via this endpoint, regardless of what the client sends.
298 if (is_array($data)) {
299 unset($data['email'], $data['user_email']);
300 }
301
302 Logger::apiRequest('/customers/me', 'PUT', $data);
303
304 $customer = $this->customerService->getCustomerByUserId($userId);
305
306 if (!$customer) {
307 // No customer record yet (e.g. registered but never booked).
308 // Create one linked to this user so the profile persists instead
309 // of failing. Email stays the account login (never client-set).
310 $wpUser = get_user_by('id', $userId);
311 if (!$wpUser) {
312 return $this->not_found(__('Customer profile not found', 'yatra'));
313 }
314
315 $createData = CustomerValidator::sanitize($data);
316 $createData['user_id'] = $userId;
317 $createData['email'] = $wpUser->user_email;
318 if (empty($createData['first_name'])) {
319 $createData['first_name'] = $wpUser->first_name !== ''
320 ? $wpUser->first_name
321 : ($wpUser->display_name !== '' ? $wpUser->display_name : $wpUser->user_login);
322 }
323 if (empty($createData['last_name'])) {
324 $createData['last_name'] = (string) $wpUser->last_name;
325 }
326
327 $createResult = $this->customerService->createCustomer($createData);
328 if (empty($createResult['success'])) {
329 Logger::warning('Could not create customer profile on update', ['user_id' => $userId, 'result' => $createResult]);
330 return $this->error_response($createResult['message'] ?? __('Failed to save profile.', 'yatra'), 400);
331 }
332
333 return $this->success_response($this->customerService->getAccountProfileForUser($userId));
334 }
335
336 $customerId = (int) $customer['id'];
337
338 // Validate and sanitize input data
339 CustomerValidator::validateUpdate($data, $customerId);
340 $data = CustomerValidator::sanitize($data);
341
342 $result = $this->customerService->updateCustomer($customerId, $data);
343
344 if (!$result['success']) {
345 Logger::warning("Customer profile update failed", ['customer_id' => $customerId, 'user_id' => $userId, 'result' => $result]);
346 return $this->error_response($result['message'] ?? 'Failed to update customer profile', 400);
347 }
348
349 Logger::info("Customer profile updated successfully", ['customer_id' => $customerId, 'user_id' => $userId]);
350
351 // updateCustomer() returns success/message only — return the fresh
352 // profile so the response carries the updated values (and avoids an
353 // "undefined key data" warning).
354 return $this->success_response(
355 $this->customerService->getAccountProfileForUser($userId)
356 );
357
358 } catch (\Exception $e) {
359 Logger::error("Failed to update customer profile", ['user_id' => $userId ?? 0, 'data' => $data ?? [], 'error' => $e->getMessage()]);
360 return $this->handle_exception($e);
361 }
362 }
363
364 /**
365 * PUT /customers/me/password - Change the current customer's account password.
366 *
367 * Requires the correct current password, then sets the new one and refreshes
368 * the auth cookie so the customer stays logged in (wp_set_password otherwise
369 * invalidates the current session).
370 */
371 public function updateMyPassword(WP_REST_Request $request)
372 {
373 $userId = get_current_user_id();
374 if ($userId <= 0) {
375 return $this->error_response(__('Authentication required.', 'yatra'), 401);
376 }
377
378 $data = $request->get_json_params();
379 $current = isset($data['current_password']) ? (string) $data['current_password'] : '';
380 $newPassword = isset($data['new_password']) ? (string) $data['new_password'] : '';
381
382 if ($current === '' || $newPassword === '') {
383 return $this->error_response(__('Current and new password are required.', 'yatra'), 400);
384 }
385
386 $user = get_user_by('id', $userId);
387 if (!$user || !wp_check_password($current, $user->user_pass, $userId)) {
388 return $this->error_response(__('Your current password is incorrect.', 'yatra'), 400);
389 }
390
391 wp_set_password($newPassword, $userId);
392
393 // wp_set_password() invalidates the session token / logs the user out.
394 // Re-establish the current session so the account page stays authenticated.
395 wp_set_current_user($userId);
396 wp_set_auth_cookie($userId, true);
397
398 Logger::info('Customer changed account password', ['user_id' => $userId]);
399
400 return $this->success_response(['updated' => true]);
401 }
402
403 /**
404 * POST /customers/me/email - Request a change to the account login email.
405 *
406 * Follows WordPress core's pending-change pattern: the email is NOT changed
407 * here. A confirmation link is emailed to the NEW address; the change only
408 * applies once the customer clicks it (confirmed by AccountPageHandler).
409 */
410 public function updateMyEmail(WP_REST_Request $request)
411 {
412 $userId = get_current_user_id();
413 if ($userId <= 0) {
414 return $this->error_response(__('Authentication required.', 'yatra'), 401);
415 }
416
417 $data = $request->get_json_params();
418 $newEmail = '';
419 if (is_array($data)) {
420 $newEmail = (string) ($data['email'] ?? $data['new_email'] ?? '');
421 }
422
423 $result = $this->customerService->requestEmailChange($userId, $newEmail);
424
425 return new WP_REST_Response($result, empty($result['success']) ? 400 : 200);
426 }
427
428 /**
429 * POST /customers/me/email/resend - Re-send the confirmation link for a
430 * pending email change (reuses the existing token; nothing else changes).
431 */
432 public function resendMyEmailConfirmation(WP_REST_Request $request)
433 {
434 $userId = get_current_user_id();
435 if ($userId <= 0) {
436 return $this->error_response(__('Authentication required.', 'yatra'), 401);
437 }
438
439 $result = $this->customerService->resendEmailChangeConfirmation($userId);
440
441 return new WP_REST_Response($result, empty($result['success']) ? 400 : 200);
442 }
443
444 /**
445 * DELETE /customers/me/email - Cancel (dismiss) a pending email change,
446 * discarding the stored token so the emailed link stops working.
447 */
448 public function cancelMyEmailChange(WP_REST_Request $request)
449 {
450 $userId = get_current_user_id();
451 if ($userId <= 0) {
452 return $this->error_response(__('Authentication required.', 'yatra'), 401);
453 }
454
455 $result = $this->customerService->cancelEmailChange($userId);
456
457 return new WP_REST_Response($result, empty($result['success']) ? 400 : 200);
458 }
459
460 /**
461 * GET /customers/my-bookings - Get current customer's bookings
462 */
463 public function getMyBookings(WP_REST_Request $request): WP_REST_Response
464 {
465 $userId = get_current_user_id();
466
467 // Get bookings by user ID (checks both customer_id and user_id)
468 $bookings = $this->customerService->getBookingsByUserId($userId);
469
470 return new WP_REST_Response([
471 'success' => true,
472 'data' => $bookings,
473 ]);
474 }
475
476 public function getMyBooking(WP_REST_Request $request): WP_REST_Response
477 {
478 $userId = get_current_user_id();
479 $bookingId = (int) $request->get_param('id');
480
481 $booking = $this->customerService->getBookingDetailsForUser($userId, $bookingId);
482
483 if (!$booking) {
484 return new WP_REST_Response([
485 'success' => false,
486 'message' => __('Booking not found.', 'yatra'),
487 ], 404);
488 }
489
490 return new WP_REST_Response([
491 'success' => true,
492 'data' => $booking,
493 ]);
494 }
495
496 /**
497 * GET /customers/my-payments - Get current customer's payments
498 */
499 public function getMyPayments(WP_REST_Request $request): WP_REST_Response
500 {
501 $userId = get_current_user_id();
502
503 $customer = $this->customerService->getCustomerByUserId($userId);
504
505 if ($customer) {
506 $payments = $this->customerService->getCustomerPayments((int) $customer['id']);
507 } else {
508 $payments = $this->customerService->getPaymentsByUserId($userId);
509 }
510
511 return new WP_REST_Response([
512 'success' => true,
513 'data' => $payments,
514 ]);
515 }
516
517 /**
518 * GET /customers/my-documents - Get current customer's documents
519 */
520 public function getMyDocuments(WP_REST_Request $request): WP_REST_Response
521 {
522 $userId = get_current_user_id();
523
524 $customer = $this->customerService->getCustomerByUserId($userId);
525
526 if (!$customer) {
527 $bookings = $this->customerService->getBookingsByUserId($userId, 1000);
528 $documents = $this->customerService->getDocumentsForBookings($bookings, 0);
529
530 return new WP_REST_Response([
531 'success' => true,
532 'data' => is_array($documents) ? $documents : [],
533 ]);
534 }
535
536 $documents = $this->customerService->getCustomerDocuments((int) $customer['id']);
537
538 return new WP_REST_Response([
539 'success' => true,
540 'data' => $documents,
541 ]);
542 }
543
544 /**
545 * GET /customers/my-support-tickets - Get current customer's support tickets
546 */
547 public function getMySupportTickets(WP_REST_Request $request): WP_REST_Response
548 {
549 $userId = get_current_user_id();
550
551 $customer = $this->customerService->getCustomerByUserId($userId);
552
553 if (!$customer) {
554 return new WP_REST_Response([
555 'success' => true,
556 'data' => [],
557 ]);
558 }
559
560 $tickets = $this->customerService->getCustomerSupportTickets((int) $customer['id']);
561
562 return new WP_REST_Response([
563 'success' => true,
564 'data' => $tickets,
565 ]);
566 }
567
568 // =========================================================================
569 // ADMIN ENDPOINTS
570 // =========================================================================
571
572 /**
573 * GET /customers - List all customers
574 */
575 public function getCustomers(WP_REST_Request $request): WP_REST_Response
576 {
577 $filters = [
578 'page' => (int) ($request->get_param('page') ?: 1),
579 'per_page' => (int) ($request->get_param('per_page') ?: 20),
580 'status' => $request->get_param('status') ?: '',
581 'search' => $request->get_param('search') ?: '',
582 'orderby' => $request->get_param('orderby') ?: 'created_at',
583 'order' => $request->get_param('order') ?: 'desc',
584 ];
585
586 $result = $this->customerService->getCustomers($filters);
587
588 return new WP_REST_Response([
589 'success' => true,
590 'data' => $result['data'],
591 'total' => $result['total'],
592 'page' => $result['page'],
593 'per_page' => $result['per_page'],
594 'pages' => $result['pages'] ?? $result['total_pages'] ?? 1,
595 ]);
596 }
597
598 /**
599 * GET /customers/{id} - Get single customer
600 */
601 public function getCustomer(WP_REST_Request $request): WP_REST_Response
602 {
603 $id = (int) $request->get_param('id');
604
605 $customer = $this->customerService->getCustomer($id);
606
607 if (!$customer) {
608 return new WP_REST_Response([
609 'success' => false,
610 'message' => __('Customer not found.', 'yatra'),
611 ], 404);
612 }
613
614 // Return both legacy flat fields and wrapped data for UI compatibility
615 return new WP_REST_Response(array_merge([
616 'success' => true,
617 'data' => $customer,
618 ], is_array($customer) ? $customer : []));
619 }
620
621 /**
622 * POST /customers - Create customer
623 */
624 public function createCustomer(WP_REST_Request $request): WP_REST_Response
625 {
626 $data = $request->get_json_params();
627
628 // Creating a WordPress login account is a higher-privilege action than
629 // adding a CRM record, so it needs the WP user-creation capability. A
630 // staffer who can manage customers but not create users simply gets a
631 // CRM-only record — the request still succeeds.
632 if (!empty($data['create_account']) && !current_user_can('create_users')) {
633 unset($data['create_account']);
634 }
635
636 $result = $this->customerService->createCustomer($data);
637
638 // A confirmation request (email already has a login) is not an error — the
639 // client shows a prompt and re-submits with confirm_link_existing. Return
640 // 200 so it isn't treated as a failed request.
641 if (!empty($result['needs_link_confirmation'])) {
642 return new WP_REST_Response($result, 200);
643 }
644
645 if (!$result['success']) {
646 return new WP_REST_Response($result, 400);
647 }
648
649 return new WP_REST_Response($result, 201);
650 }
651
652 /**
653 * PUT /customers/{id} - Update customer
654 */
655 public function updateCustomer(WP_REST_Request $request): WP_REST_Response
656 {
657 $id = (int) $request->get_param('id');
658 $data = $request->get_json_params();
659
660 // Adding a login account from the edit form is the same higher-privilege
661 // action as on create, so it needs the WP user-creation capability. A
662 // staffer who can edit customers but not create users just saves the edit
663 // without an account being made.
664 if (is_array($data) && !empty($data['create_account']) && !current_user_can('create_users')) {
665 unset($data['create_account']);
666 }
667
668 $result = $this->customerService->updateCustomer($id, $data);
669
670 // Confirmation request (email already has a login) — not an error; 200 so
671 // the client can prompt and re-submit with confirm_link_existing.
672 if (!empty($result['needs_link_confirmation'])) {
673 return new WP_REST_Response($result, 200);
674 }
675
676 if (!$result['success']) {
677 return new WP_REST_Response($result, 400);
678 }
679
680 return new WP_REST_Response($result);
681 }
682
683 /**
684 * DELETE /customers/{id} - Delete customer
685 */
686 public function deleteCustomer(WP_REST_Request $request): WP_REST_Response
687 {
688 $id = (int) $request->get_param('id');
689
690 $result = $this->customerService->deleteCustomer($id);
691
692 if (!$result['success']) {
693 return new WP_REST_Response($result, 400);
694 }
695
696 return new WP_REST_Response($result);
697 }
698
699 /**
700 * GET /customers/{id}/bookings - Get customer's bookings (admin)
701 */
702 public function getCustomerBookings(WP_REST_Request $request): WP_REST_Response
703 {
704 $id = (int) $request->get_param('id');
705 $limit = (int) ($request->get_param('limit') ?: 10);
706
707 $bookings = $this->customerService->getCustomerBookings($id, $limit);
708
709 return new WP_REST_Response([
710 'success' => true,
711 'data' => $bookings,
712 ]);
713 }
714
715 /**
716 * POST /customers/merge - Merge two customers
717 */
718 public function mergeCustomers(WP_REST_Request $request): WP_REST_Response
719 {
720 $data = $request->get_json_params();
721 $sourceId = (int) ($data['source_id'] ?? 0);
722 $targetId = (int) ($data['target_id'] ?? 0);
723
724 if (!$sourceId || !$targetId) {
725 return new WP_REST_Response([
726 'success' => false,
727 'message' => __('Both source and target customer IDs are required.', 'yatra'),
728 ], 400);
729 }
730
731 $result = $this->customerService->mergeCustomers($sourceId, $targetId);
732
733 if (!$result['success']) {
734 return new WP_REST_Response($result, 400);
735 }
736
737 return new WP_REST_Response($result);
738 }
739
740 /**
741 * GET /customers/stats - Get customer statistics
742 */
743 public function getCustomerStats(WP_REST_Request $request): WP_REST_Response
744 {
745 $stats = $this->customerService->getStats();
746 return new WP_REST_Response($stats ?? []);
747 }
748 }
749