PluginProbe
Booktics – Appointment Booking Calendar for Service Businesses / 1.0.22
Booktics – Appointment Booking Calendar for Service Businesses v1.0.22
1.0.29 1.0.28 1.0.27 1.0.26 1.0.25 1.0.24 1.0.23 1.0.22 1.0.21 1.0.20 1.0.19 trunk 1.0.0 1.0.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 1.0.16 1.0.17 1.0.18 1.0.2 1.0.3 All 31 releases
booktics / core / order / controllers / order-controller.php

order-controller.php in Booktics – Appointment Booking Calendar for Service Businesses 1.0.22, at core/order/controllers/order-controller.php

1,598 lines 64.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Booktics\Order\Controllers;
4
5 use Booktics\Abstracts\Base_Rest_Controller;
6 use Booktics\Abstracts\Booktics_Database;
7 use Booktics\Abstracts\User_Model;
8 use Booktics\Cart\Cart_Manager;
9 use Booktics\Customer\Handlers\Customer_Event_Handler;
10 use Booktics\Models\Appointment_Model;
11 use Booktics\Models\Customer_Model;
12 use Booktics\Models\Guest_Model;
13 use Booktics\Models\Order_Model;
14 use Booktics\Models\Payment_Model;
15 use Booktics\Models\Service_Model;
16 use Booktics\Models\Team_Member_Model;
17 use Booktics\Order\Order_Validator;
18 use Booktics\Payment\Payment_Method_Manager;
19 use Booktics\Payment\Payment_Status;
20 use Booktics\Services\Time_Zone_Handler;
21 use DateTime;
22 use Exception;
23 use WP_Error;
24 use WP_HTTP_Response;
25 use WP_REST_Request;
26 use WP_REST_Server;
27
28 /**
29 * Order controller
30 *
31 * @package Booktics/Order
32 */
33 class Order_Controller extends Base_Rest_Controller {
34
35 /**
36 * Endpoint namespace
37 *
38 * @var string
39 */
40 protected $namespace = 'booktics/v1';
41
42 /**
43 * Route name
44 *
45 * @var string
46 */
47 protected $base = 'orders';
48
49 /**
50 * Register REST API routes for orders.
51 *
52 * @return void
53 */
54 public function register_routes(): void {
55 register_rest_route(
56 $this->namespace, $this->base, array(
57 array(
58 'methods' => WP_REST_Server::CREATABLE,
59 'callback' => array( $this, 'create_item' ),
60 'permission_callback' => array(
61 $this,
62 'create_order_permission',
63 ),
64 ),
65 array(
66 'methods' => WP_REST_Server::READABLE,
67 'callback' => array( $this, 'get_items' ),
68 'permission_callback' => array(
69 $this,
70 'get_items_permission',
71 ),
72 'args' => array(
73 'page' => array( 'default' => 1 ),
74 'per_page' => array( 'default' => 10 ),
75 ),
76 ),
77 array(
78 'methods' => WP_REST_Server::DELETABLE,
79 'callback' => array(
80 $this,
81 'bulk_delete_items',
82 ),
83 'permission_callback' => array(
84 $this,
85 'bulk_delete_items_permission',
86 ),
87 ),
88 )
89 );
90
91 register_rest_route(
92 $this->namespace, $this->base . '/(?P<id>[\d]+)', array(
93 array(
94 'methods' => WP_REST_Server::EDITABLE,
95 'callback' => array( $this, 'update_item' ),
96 'permission_callback' => array(
97 $this,
98 'update_item_permission',
99 ),
100 'args' => array(
101 'id' => array( 'required' => true ),
102 'status' => array( 'type' => 'string' ),
103 ),
104 ),
105 array(
106 'methods' => WP_REST_Server::READABLE,
107 'callback' => array( $this, 'get_item' ),
108 'permission_callback' => array(
109 $this,
110 'get_item_permission',
111 ),
112 'args' => array(
113 'id' => array( 'required' => true ),
114 'order_key' => array(
115 'description' => __( 'Secret key that proves the client may view this order without logging in (returned when the order is created).', 'booktics' ),
116 'type' => 'string',
117 'required' => false,
118 ),
119 ),
120 ),
121 array(
122 'methods' => WP_REST_Server::DELETABLE,
123 'callback' => array( $this, 'delete_item' ),
124 'permission_callback' => array(
125 $this,
126 'delete_item_permission',
127 ),
128 'args' => array(
129 'id' => array( 'required' => true ),
130 ),
131 ),
132 )
133 );
134
135 register_rest_route(
136 $this->namespace, $this->base . '/(?P<id>[\d]+)/payment', array(
137 array(
138 'methods' => WP_REST_Server::EDITABLE,
139 'callback' => array(
140 $this,
141 'update_payment_status',
142 ),
143 'permission_callback' => array(
144 $this,
145 'update_payment_status_permission',
146 ),
147 ),
148 )
149 );
150
151 register_rest_route(
152 $this->namespace, $this->base . '/(?P<id>[\d]+)/refund', array(
153 array(
154 'methods' => WP_REST_Server::EDITABLE,
155 'callback' => array( $this, 'refund_payment' ),
156 'permission_callback' => array(
157 $this,
158 'update_payment_status_permission',
159 ),
160 ),
161 )
162 );
163 }
164
165 /**
166 * Check if the current user has permission to create orders.
167 *
168 * @param WP_REST_Request $request
169 *
170 * @return bool
171 */
172 public function create_order_permission( $request ) {
173 return true;
174 }
175
176 /**
177 * Create a new order with the provided data
178 *
179 * @param WP_REST_Request $request
180 *
181 * @return WP_Error|WP_HTTP_Response
182 * @throws Exception
183 */
184 public function create_item( $request ) {
185 $input_data = json_decode( $request->get_body(), true );
186
187 // Single comprehensive validation and preparation
188 $validation_result = $this->validate_and_prepare_order_data( $input_data );
189 if ( is_wp_error( $validation_result ) ) {
190 $validation_error_msg = $validation_result->get_error_message() ?? __( 'Order validation failed', 'booktics' );
191
192 return $this->error(
193 $validation_error_msg,
194 422,
195 'Validation'
196 );
197 }
198
199 $db = new Booktics_Database();
200
201 // Resolve customer ID
202 $customer_id_result = $this->resolve_customer_id( $input_data );
203 if ( is_wp_error( $customer_id_result ) ) {
204 return $this->error( $customer_id_result->get_error_message() );
205 }
206 $input_data['customer_id'] = $customer_id_result;
207
208 try {
209 $db->begin_transaction();
210 list( $order_model, $appointments ) = $this->create_order_and_appointments( $input_data, $db );
211 $payment_intent = $this->handle_payment_if_needed( $input_data, $order_model, $db );
212 $db->commit();
213 } catch ( Exception $e ) {
214 $db->roll_back();
215 return $this->error( $e->getMessage() );
216 }
217
218 if ( empty( $input_data['pay_now'] ) ) {
219 $this->send_notifications( $db, $order_model, $input_data['customer_id'], $appointments );
220 }
221 $response = $this->format_order_response( $db, $order_model, $input_data, $payment_intent );
222 do_action('booktics_after_order_created', $order_model, $appointments);
223
224 $response_message = 'Order created successfully';
225 if ( isset( $input_data['pay_now']) && $input_data['pay_now'] ) {
226 $response_message = 'Order pending, please proceed payment';
227 }
228
229 return $this->response(
230 $response,
231 sprintf(
232 /* translators: %s: dynamic response message */
233 __( 'Response %s', 'booktics' ),
234 $response_message
235 ),
236 201
237 );
238 }
239
240 /**
241 * Comprehensive validation and preparation of order data
242 *
243 * @param array $input_data
244 *
245 * @return array|WP_Error
246 */
247 private function validate_and_prepare_order_data( &$input_data ) {
248 // Step 1: Basic validation using Order_Validator
249 $validator = new Order_Validator();
250 $validation_result = $validator->validate( $input_data );
251 if ( is_wp_error( $validation_result ) ) {
252 return $validation_result;
253 }
254
255 // Step 2: Preserve original timezone for appointment creation, then validate and setup working timezone
256 $input_data['original_timezone'] = $input_data['timezone'] ?? wp_timezone_string();
257 if ( ! isset( $input_data['timezone'] ) || ! Time_Zone_Handler::validate_time_zone( $input_data['timezone'] ) ) {
258 $input_data['timezone'] = 'UTC';
259 }
260
261 // Step 3: Payment validation if pay_now is true
262 if ( isset( $input_data['pay_now'] ) && $input_data['pay_now'] ) {
263 $validate = booktics_validate(
264 $input_data, array(
265 'payment_method' => array( 'required' ),
266 )
267 );
268 if ( is_wp_error( $validate ) ) {
269 return $validate;
270 }
271 }
272
273 // Step 4: Process and validate each item
274 if ( ! is_array( $input_data['items'] ) || count( $input_data['items'] ) == 0 ) {
275 return new WP_Error( 'items', __( 'Items are required', 'booktics' ) );
276 }
277
278 foreach ( $input_data['items'] as $key => $item ) {
279 // Validate required fields for each item
280 $item_validate = booktics_validate(
281 $item, array(
282 'date' => array( 'required' ),
283 'start_time' => array( 'required' ),
284 'end_time' => array( 'required' ),
285 'team_member_id' => array( 'required' ),
286 'service_id' => array( 'required' ),
287 )
288 );
289
290 if ( is_wp_error( $item_validate ) ) {
291 return $item_validate;
292 }
293
294 // Convert times to UTC
295 $converted_start_time = Time_Zone_Handler::convert_to_utc(
296 $item['start_time'],
297 $item['date'],
298 $input_data['timezone']
299 );
300 $converted_end_time = Time_Zone_Handler::convert_to_utc(
301 $item['end_time'],
302 $item['date'],
303 $input_data['timezone']
304 );
305
306 // Validate time range
307 $start_time = strtotime( $converted_start_time['time'] );
308 $end_time = strtotime( $converted_end_time['time'] );
309
310 if ( ! $start_time || ! $end_time || $start_time >= $end_time ) {
311 return new WP_Error( 'time_invalid', __( 'Invalid time range provided', 'booktics' ) );
312 }
313
314 // In validate_and_prepare_order_data(), after converting times:
315 $item_with_converted_times = array_merge($item, [
316 'start_time' => $converted_start_time['time'],
317 'end_time' => $converted_end_time['time'],
318 'date' => gmdate('Y-m-d', strtotime($converted_start_time['date']))
319 ]);
320
321 $item_timet_slot_validate = $this->validate_appointment_time_is_free($item_with_converted_times);
322
323 if ( is_wp_error( $item_timet_slot_validate ) ) {
324 return $item_timet_slot_validate;
325 }
326
327 // Sanitize and prepare item data
328 $input_data['items'][ $key ] = array(
329 'start_time' => $converted_start_time['time'],
330 'end_time' => $converted_end_time['time'],
331 'date' => gmdate( 'Y-m-d', strtotime( $item['date'] ) ),
332 'customer_id' => isset( $item['customer_id'] ) ? absint( $item['customer_id'] ) : null,
333 'team_member_id' => absint( $item['team_member_id'] ),
334 'service_id' => absint( $item['service_id'] ),
335 'additional_duration_id' => booktics_sanitize_id_string( $item['additional_duration_id'] ?? '' ),
336 'price' => floatval( $item['price'] ?? 0 ),
337 'subtotal' => floatval( $item['subtotal'] ?? 0 ),
338 'quantity' => absint( $item['quantity'] ?? 1 ),
339 'total_attendees' => absint( $item['total_attendees'] ?? 1 ),
340 );
341 $input_data['items'][$key] = apply_filters('booktics_pro_appointment_validate', $input_data['items'][$key], $item);
342 }
343
344 $input_data = apply_filters( 'booktics_pro_order_validate', $input_data );
345
346 return $input_data;
347 }
348
349 /**
350 * Check if the requested time slot has available capacity for the given team member
351 * Works by checking all appointments for this team member on this date and calculating remaining capacity
352 *
353 * @param array $item Appointment item data
354 * @return bool|WP_Error True if time slot has capacity, WP_Error if not
355 */
356 private function validate_appointment_time_is_free( $item ) {
357 try {
358 // Convert times to DateTime objects
359 $requested_start = new \DateTime( $item['date'] . ' ' . $item['start_time'] );
360 $requested_end = new \DateTime( $item['date'] . ' ' . $item['end_time'] );
361
362 // Get the requested attendee count (default to 1 for backward compatibility)
363 $requested_attendees = isset( $item['total_attendees'] ) ? (int) $item['total_attendees'] : 1;
364
365 // Get service to check if group booking is enabled and get max capacity
366 $service = Service_Model::find($item['service_id']);
367 if ( ! $service ) {
368 return new \WP_Error( 'service_not_found', __( 'Service not found', 'booktics' ) );
369 }
370
371 $max_group_size = $this->get_service_max_group_size( $service->id );
372
373
374 $args = [
375 'team_member_id' => $item['team_member_id'],
376 'date' => $item['date'],
377 'post_status' => booktics_get_option('appointment_statuses_for_blocked_slot', []),
378 ];
379
380 // Get all non-cancelled appointments for this team member on this date
381 $appointments = Appointment_Model::all( $args );
382
383 if ( ! is_array( $appointments ) || empty( $appointments ) ) {
384 // No existing appointments, check if requested attendees fit within max capacity
385 if ( $requested_attendees > $max_group_size ) {
386 /* translators: %1$d = requested attendees, %2$d = maximum group size */
387 return new \WP_Error( 'exceeds_max_capacity', sprintf( __( 'Requested attendees (%1$d) exceeds maximum group size (%2$d) for this service.', 'booktics' ), $requested_attendees, $max_group_size ) );
388 }
389
390 return true;
391 }
392
393 // Calculate total attendees for overlapping time slots
394 $overlapping_attendees = 0;
395
396 foreach ( $appointments as $appointment ) {
397 // Skip if this is the same appointment (for updates)
398 if ( isset($item['id']) && $appointment->ID == $item['id'] ) {
399 continue;
400 }
401
402 // Guard against legacy/localized records whose stored date/time cannot be
403 // parsed; skipping them keeps a single bad row from blocking every booking.
404 try {
405 $appt_start = new \DateTime( $appointment->date . ' ' . $appointment->start_time );
406 $appt_end = new \DateTime( $appointment->date . ' ' . $appointment->end_time );
407 } catch ( \Throwable $e ) {
408 if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
409 error_log( 'Booktics: skipping appointment ' . ( $appointment->ID ?? '?' ) . ' with unparseable date/time during slot validation: ' . $e->getMessage() );
410 }
411 continue;
412 }
413
414 // Check for time overlap
415 if ( ! ( $requested_end <= $appt_start || $requested_start >= $appt_end ) ) {
416 // Times overlap, add this appointment's attendees to the count
417 $appointment_attendees = $appointment->total_attendees ?? 1;
418 $overlapping_attendees += $appointment_attendees;
419 }
420 }
421
422 // Check if adding the requested attendees would exceed capacity
423 $total_after_booking = $overlapping_attendees + $requested_attendees;
424
425 if ($total_after_booking > $max_group_size) {
426 return new \WP_Error( 'insufficient_capacity', __( 'Insufficient capacity' , 'booktics' ) );
427 }
428
429 return true;
430 } catch ( \Throwable $e ) {
431 return new \WP_Error(
432 'time_validation_error',
433 __( 'Error validating appointment time. Please try another slot.', 'booktics' )
434 );
435 }
436 }
437
438 /**
439 * Get max group size for a specific service
440 *
441 * @param int $service_id Service ID
442 *
443 * @return int Max group size
444 */
445 private function get_service_max_group_size( $service_id ) {
446 $default_max_size = 1;
447 $group_booking_meta = get_post_meta( $service_id, 'group_booking', true );
448
449 if ( ! $this->is_group_booking_enabled( $group_booking_meta ) ) {
450 return $default_max_size;
451 }
452
453 return $this->extract_max_booking_size( $group_booking_meta, $default_max_size );
454 }
455
456 /**
457 * Checks if group booking is enabled for a services group booking meta data
458 *
459 * @param array $group_booking_meta Group booking meta data
460 *
461 * @return bool True if group booking is enabled, false otherwise
462 */
463 private function is_group_booking_enabled( $group_booking_meta ) {
464 return !empty( $group_booking_meta ) && is_array( $group_booking_meta ) && !empty( $group_booking_meta['enable_group_booking'] );
465 }
466
467 /**
468 * Extracts the max booking size from the group booking meta data of a service
469 *
470 * @param array $group_booking_meta Group booking meta data
471 * @param int $default_size Default max booking size
472 *
473 * @return int Max booking size
474 */
475 private function extract_max_booking_size( $group_booking_meta, $default_size ) {
476 if ( isset( $group_booking_meta['max_booking'] ) && is_numeric( $group_booking_meta['max_booking'] ) ) {
477 return (int) $group_booking_meta['max_booking'];
478 }
479
480 return $default_size;
481 }
482
483 /**
484 * Resolve the customer ID from input data, creating or finding the customer as needed
485 *
486 * @param array $input_data
487 *
488 * @return int|WP_Error
489 */
490 private function resolve_customer_id( &$input_data ) {
491 if ( isset( $input_data['customer_id'] ) ) {
492 return $input_data['customer_id'];
493 }
494 if ( ! isset( $input_data['customer'] ) ) {
495 return new WP_Error( 'missing_customer', __( 'Customer information is required', 'booktics' ) );
496 }
497
498 return $this->find_or_create_customer( $input_data['customer'] );
499 }
500
501 /**
502 * Create the order and its associated appointments
503 *
504 * @param array $input_data
505 * @param Booktics_Database $db
506 *
507 * @return array [Order_Model $order_model, array $appointments]
508 */
509 private function create_order_and_appointments( $input_data, $db ) {
510 $input_data['currency'] = ! empty( $input_data['currency'] ) ? strtolower( $input_data['currency'] ) : strtolower( booktics_get_option( 'currency', 'USD' ) );
511 $order_model = new Order_Model( $db );
512
513 $input_timezone_string = $input_data['timezone'] ?? 'UTC';
514 $input_timezone = Time_Zone_Handler::get_timezone_from_string( $input_timezone_string );
515 $original_timezone = $input_data['original_timezone'] ?? $input_timezone_string;
516
517 $order_data = array(
518 'customer_id' => $input_data['customer_id'],
519 'payment_status' => Payment_Status::FAILED,
520 'price_breakdown' => $input_data['price_breakdown'] ?? array(),
521 'status' => 'open',
522 'tax_total' => $input_data['tax_total'] ?? 0,
523 'coupon_code' => $input_data['coupon_code'] ?? '',
524 'coupon_discount' => $input_data['coupon_discount'] ?? 0,
525 'subtotal' => $input_data['subtotal'],
526 'timezone' => $input_timezone,
527 'total' => $input_data['total'],
528 'currency' => $input_data['currency'],
529 'payment_method' => $input_data['payment_method'] ?? null,
530 );
531
532 // This is to only allow setting payment status when creating order from admin panel as admin
533 if ( isset( $input_data['payment_status'] ) && current_user_can( 'manage_options' ) ) {
534 $order_data['payment_status'] = $input_data['payment_status'];
535 }
536
537 $order_data = apply_filters( 'booktics_pro_order_columns', $order_data, $input_data );
538 $order_model = $order_model->create( $order_data );
539 $appointments = array();
540 foreach ( $input_data['items'] as $item ) {
541 if ( isset( $item['uuid'] ) ) {
542 ( new Cart_Manager() )->remove_cart_item( $item['uuid'] );
543 }
544 $total_duration_min = $this->calculate_total_duration( $item );
545
546 // Store date/time in a locale-independent format. wp_date() localizes digits and
547 // am/pm markers (e.g. Bengali "১১:০০ সকাল"), and such values cannot be parsed back
548 // by DateTime, breaking slot validation and notifications. DateTime::format() keeps
549 // the same timezone-aware value but is never localized.
550 $time_format = get_option( 'time_format' );
551 $start_local = ( new \DateTime( '@' . strtotime( $item['start_time'] ) ) )->setTimezone( $input_timezone );
552 $end_local = ( new \DateTime( '@' . strtotime( $item['end_time'] ) ) )->setTimezone( $input_timezone );
553 $date_local = ( new \DateTime( '@' . strtotime( $item['date'] ) ) )->setTimezone( $input_timezone );
554
555 $appointment_data = array(
556 'start_time' => $start_local->format( $time_format ),
557 'end_time' => $end_local->format( $time_format ),
558 'date' => $date_local->format( 'Y-m-d' ),
559 'customer_id' => $input_data['customer_id'] ?? '',
560 'team_member_id' => $item['team_member_id'] ?? '',
561 'service_id' => $item['service_id'] ?? '',
562 'additional_duration_id' => booktics_sanitize_id_string( $item['additional_duration_id'] ?? '' ),
563 'duration' => $total_duration_min,
564 'order_id' => $order_model->id,
565 'quantity' => $item['quantity'] ?? 1,
566 'price' => $item['price'] ?? 0,
567 'subtotal' => $item['subtotal'] ?? 0,
568 'total' => $item['total'] ?? $item['subtotal'] ?? 0,
569 'status' => booktics_get_option( 'appointment_status', 'pending' ),
570 'total_attendees' => $input_data['total_attendees'] ?? 1,
571 'timezone' => $original_timezone,
572 );
573
574
575
576 $appointment_data = apply_filters( 'booktics_appointment_pro_format', $appointment_data, $item );
577
578 $appointment = Appointment_Model::create( $appointment_data );
579 if ( is_wp_error( $appointment ) ) {
580 throw new Exception( esc_html( $appointment->get_error_message() ) );
581 }
582
583 do_action( 'booktics_after_appointment_created', $appointment );
584 $appointments[] = $appointment;
585 }
586
587 return array( $order_model, $appointments );
588 }
589
590
591 /**
592 * Process payment for an order if immediate payment is requested
593 *
594 * @param array $input_data Order input data containing payment details
595 * @param Order_Model $order_model Order model instance
596 * @param Booktics_Database $db Database instance
597 *
598 * @return array
599 * @throws Exception
600 */
601 private function handle_payment_if_needed( $input_data, $order_model, $db ) {
602 if ( ! isset( $input_data['pay_now'] ) || ! $input_data['pay_now'] ) {
603 return array();
604 }
605
606 $payment_method = ( new Payment_Method_Manager() )->get_payment_method( $input_data['payment_method'] );
607 if ( ! $payment_method ) {
608 throw new Exception( esc_html__( 'Payment method cannot be found!', 'booktics' ) );
609 }
610
611 $payment_intent = $payment_method->process_payment( $order_model );
612
613 if ( $payment_intent['status'] === 'error' ) {
614 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message is not directly output; escaping is handled at the display layer
615 throw new Exception( $payment_intent['message'] );
616 }
617
618 if ( $payment_intent['status'] === 'success' ) {
619 $this->update_payment_records( $db, $order_model, $input_data, $payment_intent );
620 // Do NOT set fully_paid here — PaymentIntent created ≠ payment collected.
621 // Status is updated after client-side confirmation via update_payment_status endpoint.
622 }
623
624 return $payment_intent;
625 }
626
627
628 /**
629 * Update payment records after successful payment processing
630 *
631 * @param Booktics_Database $db Database instance
632 * @param Order_Model $order_model Order model instance
633 * @param array $input_data Order input data containing payment details
634 * @param array $payment_intent Payment intent data from payment processor
635 *
636 * @return void
637 */
638 private function update_payment_records( $db, $order_model, $input_data, $payment_intent ) {
639 ( new Order_Model( $db ) )->update(
640 $order_model->id,
641 array( 'payment_intent_id' => $payment_intent['payment_intent'] )
642 );
643
644 ( new Payment_Model( $db ) )->create(
645 array(
646 'order_id' => $order_model->id,
647 'customer_id' => $input_data['customer_id'],
648 'amount' => $input_data['total'] * 100,
649 'currency' => $input_data['currency'] ?? booktics_get_option( 'currency', 'USD' ),
650 'intent_id' => $payment_intent['payment_intent'],
651 'transaction_id' => null,
652 'payment_method' => $input_data['payment_method'],
653 'status' => $payment_intent['status'] == 'success' ? 'completed' : 'pending',
654 'date' => gmdate( 'Y-m-d' ),
655 'date_time' => gmdate( 'Y-m-d H:i:s' ),
656 )
657 );
658 }
659
660 /**
661 * Resolve team member email with WP user fallback.
662 *
663 * Model hydration may miss user_email if the team-member WP user row is
664 * stale or if find() returned null. Fall back to get_userdata so the
665 * notification SDK does not skip the team email node.
666 */
667 private function resolve_team_member_email( string $primary, $team_member_id ): string {
668 if ( ! empty( $primary ) ) {
669 return $primary;
670 }
671 $team_member_id = absint( $team_member_id );
672 if ( ! $team_member_id ) {
673 return '';
674 }
675 $user = get_userdata( $team_member_id );
676 return $user && ! empty( $user->user_email ) ? $user->user_email : '';
677 }
678
679 /**
680 * Helper: Format a single appointment for API output
681 *
682 * @param Appointment_Model|array $appointment
683 *
684 * @return array
685 */
686 private function format_appointment( $appointment ): array {
687 if ( is_object( $appointment ) && method_exists( $appointment, 'to_array' ) ) {
688 $appointment = $appointment->to_array();
689 }
690 $team_member = ( new Team_Member_Model() )->find( $appointment['team_member_id'] );
691 $service = ( new Service_Model() )->find( $appointment['service_id'] );
692 $service = $service ? $service->to_array() : array();
693 $additional_durations = $service['additional_durations'] ?? array();
694 $additional_durations = ! empty( $additional_durations )
695 ? array_filter(
696 $additional_durations, function ( $duration ) use ( $appointment ) {
697 return $duration['id'] === $appointment['additional_duration_id'];
698 }
699 ) : array();
700 $additional_duration = ! empty( $additional_durations ) ? reset( $additional_durations ) : array();
701 $formatted_appointment = array(
702 'id' => $appointment['id'] ?? 0,
703 'date' => $appointment['date'] ?? '',
704 'start_time' => $appointment['start_time'] ?? '',
705 'end_time' => $appointment['end_time'] ?? '',
706 'additional_duration_id' => $appointment['additional_duration_id'] ?? '',
707 'team' => array(
708 'id' => $team_member->id ?? null,
709 'display_name' => $team_member->display_name ?? '',
710 'image' => $team_member->image ?? '',
711 'user_email' => $team_member->user_email ?? '',
712 ),
713 'service' => array(
714 'id' => $appointment['service_id'] ?? '',
715 'title' => $service['title'] ?? '',
716 'additional_duration' => $additional_duration,
717 'enable_google_meet' => $service['enable_google_meet'] ?? false,
718 ),
719 'duration' => $appointment['duration'] ?? null,
720 'quantity' => $appointment['quantity'] ?? 1,
721 'price' => $appointment['price'] ?? 0,
722 'subtotal' => $appointment['subtotal'] ?? 0,
723 'total' => $appointment['total'] ?? 0,
724 'status' => $appointment['status'] ?? '',
725 'total_attendees' => $appointment['total_attendees'] ?? 1,
726 'appointment_token' => $appointment['security_token'] ?? '',
727 'cancel_note' => $appointment['cancel_note'] ?? '',
728 );
729 $formatted_appointment = apply_filters( 'booktics_appointment_pro_format', $formatted_appointment, $appointment );
730 return $formatted_appointment;
731 }
732
733 /**
734 * Helper: Format order for API output
735 *
736 * @param Order_Model $order_model
737 * @param array $input_data
738 * @param array $payment_intent
739 *
740 * @return array
741 */
742 private function format_order_response( $db, $order_model, $input_data, $payment_intent ): array {
743 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $input_data['customer_id'] ) );
744 // Ensure $order_model is an object before accessing properties
745 $order_id = is_object( $order_model ) ? $order_model->id : ( is_array( $order_model ) ? $order_model['id'] : null );
746 $appointments = ( new Appointment_Model() )->where( 'order_id', $order_id );
747 $appointment_details = array();
748 foreach ( $appointments as $appointment ) {
749 $appointment_details[] = $this->format_appointment( $appointment );
750 }
751
752 $order_no_for_key = is_object( $order_model ) ? $order_model->order_no : ( $order_model['order_no'] ?? '' );
753
754 $order_data = array(
755 'id' => $order_id,
756 'order_no' => is_object( $order_model ) ? $order_model->order_no : ( $order_model['order_no'] ?? null ),
757 'order_key' => $this->get_order_view_key( (int) $order_id, (string) $order_no_for_key ),
758 'customer' => array(
759 'id' => $customer->id ?? null,
760 'name' => $customer->name ?? '',
761 'phone' => $customer->phone ?? '',
762 'email' => $customer->email ?? '',
763 ),
764 'status' => is_object( $order_model ) ? $order_model->status : ( $order_model['status'] ?? null ),
765 'subtotal' => is_object( $order_model ) ? $order_model->subtotal : ( $order_model['subtotal'] ?? null ),
766 'total' => is_object( $order_model ) ? $order_model->total : ( $order_model['total'] ?? null ),
767 'payment_status' => is_object( $order_model ) ? $order_model->payment_status : ( $order_model['payment_status'] ?? null ),
768 'payment_method' => is_object( $order_model ) ? $order_model->payment_method : $order_model['payment_method'],
769 'items' => $appointment_details,
770 'payment_intent_id' => $payment_intent['payment_intent'] ?? '',
771 'client_secret' => $payment_intent['client_secret'] ?? '',
772 'total_services' => $input_data['total_services'] ?? 1,
773 );
774
775 $order_data = apply_filters( 'booktics_pro_order_format', $order_data, $order_model );
776
777 return $order_data;
778 }
779
780 /**
781 * Helper: Send notifications for order and appointment creation
782 *
783 * @param Order_Model $order_model
784 * @param int $customer_id
785 * @param array $appointments
786 *
787 * @return void
788 */
789 private function send_notifications( $db, $order_model, $customer_id, $appointments ): void {
790 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $customer_id ) );
791 foreach ( $appointments as $appointment ) {
792 $formatted_appointment = $this->format_appointment( $appointment );
793 $appointment_time = $formatted_appointment['date'] . ' ' . $formatted_appointment['start_time'];
794 $dt = new DateTime( $appointment_time, wp_timezone() );
795 $timestamp = $dt->getTimestamp();
796 $appointment_datetime = wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $timestamp, wp_timezone() );
797 $appointment_array = is_object( $appointment ) && method_exists( $appointment, 'to_array' ) ? $appointment->to_array() : (array) $appointment;
798 $team_member_email = $this->resolve_team_member_email(
799 $formatted_appointment['team']['user_email'] ?? '',
800 $appointment_array['team_member_id'] ?? 0
801 );
802 $notification_flow_data_for_appointment = array(
803 'appointment_service' => $formatted_appointment['service']['title'] ?? '',
804 'appointment_team_member' => $formatted_appointment['team']['display_name'] ?? '',
805 'start_time' => $formatted_appointment['start_time'],
806 'appointment_date' => $formatted_appointment['date'],
807 'appointment_time' => $appointment_datetime,
808 'attendee_count' => $formatted_appointment['attendee_count'] ?? null,
809 'customer_name' => $customer->name ?? '',
810 'user_email' => $customer->email ?? '',
811 'appointment_team_member_email' => $team_member_email,
812 );
813 $notification_flow_data_for_appointment = apply_filters( 'booktics_appointment_scheduled_notification_flow_data', $notification_flow_data_for_appointment, $formatted_appointment );
814
815 do_action( 'booktics_gln_hook', 'appointment_scheduled', $notification_flow_data_for_appointment );
816 }
817
818 $appointment = $appointments[0]; // As currently only one appointment is created for each user
819 $formatted_appointment = $this->format_appointment( $appointment );
820 $notification_flow_data_for_order = [
821 'order_no' => $order_model->order_no ?? '',
822 'order_date' => $order_model->created_at ?? '',
823 'order_total' => $order_model->total ?? 0,
824 'customer_name' => $customer->name ?? '',
825 'user_email' => $customer->email ?? '',
826 'order_status' => $order_model->status ?? '',
827 'service_name' => $formatted_appointment['service']['title'] ?? '',
828 'team_name' => $formatted_appointment['team']['display_name'] ?? '',
829 'start_time' => $formatted_appointment['start_time'] ?? '',
830 'end_time' => $formatted_appointment['end_time'] ?? '',
831 'appointment_date' => $formatted_appointment['date'] ?? '',
832 'location_address' => $formatted_appointment['location']['address'] ?? '',
833 'appointment_status' => $formatted_appointment['status'] ?? '',
834 'appointment_cancel_url' => $appointment->get_cancel_link(),
835 'appointment_reschedule_url' => $appointment->get_reschedule_link(),
836 ];
837
838 $notification_flow_data_for_order = apply_filters( 'booktics_order_created_notification_flow_data', $notification_flow_data_for_order, $formatted_appointment, $order_model );
839
840 do_action( 'booktics_gln_hook', 'order_created', $notification_flow_data_for_order );
841 }
842
843 /**
844 * Find existing customer by email or create new customer
845 *
846 * @param array $customer
847 *
848 * @return int|WP_Error
849 */
850 private function find_or_create_customer( array $customer ) {
851 $db = new Booktics_Database();
852 if ( ! isset( $customer['email'] ) ) {
853 return new WP_Error( 'invalid_email', __( 'Customer email is required', 'booktics' ) );
854 }
855 $user_login = User_Model::generate_username( $customer['email'] );
856 $allow_customer_as_wp_user = booktics_get_option( 'allow_customer_as_wp_user', false );
857 $wp_user_id = null;
858 if ( $allow_customer_as_wp_user ) {
859 $wp_user_id = $this->get_or_create_wp_user( $customer, $user_login );
860 }
861 $guest = $this->find_and_update_guest( $db, $customer, $wp_user_id );
862 if ( $guest ) {
863 return $guest->id;
864 }
865 $guest = $this->create_guest( $db, $customer, $user_login, $wp_user_id );
866
867 return $guest->id;
868 }
869
870 /**
871 * Get or create a WordPress user for the customer
872 *
873 * @param array $customer
874 * @param string $user_login
875 *
876 * @return int WP user ID
877 */
878 private function get_or_create_wp_user( $customer, $user_login ) {
879 $wp_user = get_user_by( 'email', $customer['email'] );
880 if ( $wp_user ) {
881 return $wp_user->ID;
882 }
883 $userdata = array(
884 'user_login' => $user_login,
885 'user_email' => $customer['email'],
886 'first_name' => $customer['name'] ?? '',
887 'display_name' => $customer['name'] ?? '',
888 'phone' => $customer['phone'] ?? '',
889 );
890 $customer_model = new Customer_Model();
891 $customer_id = $customer_model->save( $userdata );
892 ( new Customer_Event_Handler() )->on_customer_create( $customer_id );
893
894 return $customer_id;
895 }
896
897 /**
898 * Find and update a guest record by email
899 *
900 * @param Booktics_Database $db
901 * @param array $customer
902 *
903 * @return object|null Guest_Model instance or null
904 */
905 private function find_and_update_guest( $db, $customer, $wp_user_id = null ) {
906 $guest = ( new Guest_Model( $db ) )->find( array( 'email' => $customer['email'] ) );
907 if ( $guest && $this->caller_owns_customer_email( $guest, $customer['email'] ) ) {
908 ( new Guest_Model( $db ) )->update(
909 $guest->id, array(
910 'name' => $customer['name'] ?? '',
911 'phone' => $customer['phone'] ?? '',
912 'wp_user_id' => $wp_user_id,
913 )
914 );
915 }
916
917 return $guest;
918 }
919
920 /**
921 * Determine whether the current request is authorized to overwrite an
922 * existing customer's stored contact details (name, phone, wp_user_id).
923 *
924 * @param object $guest Existing Guest_Model record.
925 * @param string $email Submitted customer email.
926 *
927 * @return bool True when the caller may overwrite the stored record.
928 */
929 private function caller_owns_customer_email( $guest, $email ) {
930 // Admin or team member managing a booking on the customer's behalf.
931 if ( booktics_is_admin_or_team_member() ) {
932 return true;
933 }
934
935 if ( ! is_user_logged_in() ) {
936 return false;
937 }
938
939 // The record is already linked to the logged-in user.
940 if ( ! empty( $guest->wp_user_id ) && intval( $guest->wp_user_id ) === get_current_user_id() ) {
941 return true;
942 }
943
944 // The logged-in user's own account email matches the submitted email.
945 $current_user = wp_get_current_user();
946
947 return $current_user
948 && ! empty( $current_user->user_email )
949 && strtolower( $current_user->user_email ) === strtolower( (string) $email );
950 }
951
952 /**
953 * Create a new guest record
954 *
955 * @param Booktics_Database $db
956 * @param array $customer
957 * @param string $user_login
958 * @param int|null $wp_user_id
959 *
960 * @return object Guest_Model instance
961 */
962 private function create_guest( $db, $customer, $user_login, $wp_user_id ) {
963 return ( new Guest_Model( $db ) )->create(
964 array(
965 'wp_user_id' => $wp_user_id,
966 'email' => $customer['email'],
967 'name' => $customer['name'] ?? '',
968 'user_login' => $user_login,
969 'phone' => $customer['phone'] ?? '',
970 )
971 );
972 }
973
974 /**
975 * Check if user has permission to list orders.
976 *
977 * Anonymous access is denied: the collection handler scopes to the current customer or team member;
978 * unauthenticated callers would otherwise receive unscoped results. Single-order GET is allowed for
979 * guests when {@see get_item_permission()} validates a matching order_key.
980 *
981 * @param WP_REST_Request $request
982 *
983 * @return bool|WP_Error
984 */
985 public function get_items_permission( $request ) {
986 if ( is_user_logged_in() ) {
987 return true;
988 }
989
990 return new WP_Error(
991 'booktics_forbidden',
992 __( 'You are not allowed to view orders.', 'booktics' ),
993 array( 'status' => 403 )
994 );
995 }
996
997 /**
998 * Get paginated list of orders.
999 *
1000 * @param WP_REST_Request $request
1001 *
1002 * @return WP_Error|WP_HTTP_Response
1003 */
1004 public function get_items( $request ) {
1005 $page = (int) $request->get_param( 'paged' ) === 0 ? 1 : absint( $request->get_param( 'paged' ) );
1006 $per_page = (int) $request->get_param( 'per_page' ) === 0 ? 10 : absint( $request->get_param( 'per_page' ) );
1007 $payment_status = sanitize_text_field( $request->get_param( 'payment_status' ) );
1008 $payment_method = sanitize_text_field( $request->get_param( 'payment_method' ) );
1009 $order_date = sanitize_text_field( $request->get_param( 'order_date' ) );
1010 $team_member = absint( $request->get_param( 'team_member' ) );
1011 $service = absint( $request->get_param( 'service' ) );
1012 $search = sanitize_text_field( $request->get_param( 'search' ) );
1013
1014 $db = new Booktics_Database();
1015 $order_model = new Order_Model( $db );
1016 $conditions = array();
1017
1018 if ( $payment_status ) {
1019 $conditions['payment_status'] = $payment_status;
1020 }
1021
1022 if ( $payment_method ) {
1023 $conditions['payment_method'] = $payment_method;
1024 }
1025
1026 if ( $order_date ) {
1027 $conditions['DATE(created_at)'] = array( '=', $order_date );
1028 }
1029
1030 if ( $search ) {
1031 $conditions['order_no'] = array( 'LIKE', '%' . $search . '%' );
1032 }
1033
1034 $conditions = apply_filters( 'booktics_order_filters', $conditions );
1035
1036 // Scope to customer's own orders when not admin/team-member
1037 if ( ! booktics_is_admin_or_team_member() && is_user_logged_in() ) {
1038 $guest = ( new Guest_Model( $db ) )->find( array( 'wp_user_id' => get_current_user_id() ) );
1039 if ( ! $guest ) {
1040 return $this->response(
1041 array( 'items' => array(), 'total' => 0, 'total_pages' => 0 ),
1042 __( 'Orders fetched successfully', 'booktics' )
1043 );
1044 }
1045 $conditions['customer_id'] = $guest->id;
1046 }
1047
1048 // Scope to team member's assigned orders - only return orders that contain
1049 // at least one appointment where team_member_id equals the current user's team member ID
1050 $is_team_member_scope = false;
1051 $team_member_filter_id = null;
1052 if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1053 $current_user_id = get_current_user_id();
1054 $team_member_model = new Team_Member_Model();
1055 $team_member = $team_member_model->find( array( 'wp_user_id' => $current_user_id ) );
1056 if ( $team_member ) {
1057 $is_team_member_scope = true;
1058 $team_member_filter_id = $team_member->id;
1059 }
1060 }
1061
1062 $orders = $order_model->get(
1063 $conditions, $page, $per_page, array(
1064 'created_at',
1065 'DESC',
1066 )
1067 );
1068
1069 if ( $team_member || $service ) {
1070 foreach ( $orders['items'] as $key => $order ) {
1071 $appointments = ( new Appointment_Model() )->where( 'order_id', $order->id );
1072 $keep_order = false;
1073
1074 foreach ( $appointments as $appointment ) {
1075 if ( $team_member && $appointment->team_member_id == $team_member ) {
1076 $keep_order = true;
1077 }
1078 if ( $service && $appointment->service_id == $service ) {
1079 $keep_order = true;
1080 }
1081 }
1082
1083 if ( ! $keep_order && ( $team_member || $service ) ) {
1084 unset( $orders['items'][ $key ] );
1085 --$orders['total'];
1086 }
1087 }
1088
1089 $orders['items'] = array_values( $orders['items'] );
1090 $orders['total_pages'] = ceil( $orders['total'] / $per_page );
1091 }
1092
1093 foreach ( $orders['items'] as &$order ) {
1094 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $order->customer_id ) );
1095 $customer_data_for_order = [
1096 'id' => $customer ? $customer->id : null,
1097 'name' => $customer ? $customer->name : '',
1098 'email' => '',
1099 'phone' => '',
1100 ];
1101 // Admins and team members see email and phone
1102 if ( booktics_is_admin_or_team_member() ) {
1103 $customer_data_for_order['email'] = $customer ? $customer->email : '';
1104 $customer_data_for_order['phone'] = $customer ? $customer->phone : '';
1105 }
1106 $order->customer = $customer_data_for_order;
1107 $order->total_services = ( new Appointment_Model() )->get_appointments_count_by_order_id( $order->id );
1108
1109 // Hide payment intent fields from team members (not admins)
1110 if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1111 $order->payment_intent_id = '';
1112 $order->client_secret = '';
1113 }
1114 }
1115
1116 return $this->response( $orders, __( 'Orders fetched successfully', 'booktics' ) );
1117 }
1118
1119 /**
1120 * Check if user has permission to update order.
1121 *
1122 * @param WP_REST_Request $request
1123 *
1124 * @return bool
1125 */
1126 public function update_item_permission( $request ) {
1127 return current_user_can( 'manage_options' );
1128 }
1129
1130 /**
1131 * Update an existing order.
1132 *
1133 * @param WP_REST_Request $request
1134 *
1135 * @return WP_Error|WP_HTTP_Response
1136 */
1137 public function update_item( $request ) {
1138 $order_id = absint( $request->get_param( 'id' ) );
1139 $data = json_decode( $request->get_body(), true );
1140 $db = new Booktics_Database();
1141 $order_model = new Order_Model( $db );
1142 $order = $order_model->find( array( 'id' => $order_id ) );
1143
1144 if ( ! $order ) {
1145 return $this->error( __( 'Order not found', 'booktics' ) );
1146 }
1147
1148 $status = isset( $data['status'] ) ? sanitize_text_field( $data['status'] ) : $order->status;
1149 $payment_status = isset( $data['payment_status'] ) ? sanitize_text_field( $data['payment_status'] ) : $order->payment_status;
1150
1151 $order_model->update(
1152 $order_id, array(
1153 'status' => $status,
1154 'payment_status' => $payment_status,
1155 )
1156 );
1157
1158 if ( isset( $data['customer'] ) && isset( $data['customer']['id'] ) ) {
1159 $customer_id = absint( $data['customer']['id'] );
1160 $customer_data = $data['customer'];
1161 $customer_data['id'] = $customer_id;
1162 if ( isset( $customer_data['name'] ) ) {
1163 $customer_data['name'] = sanitize_text_field( $customer_data['name'] );
1164 }
1165 if ( isset( $customer_data['email'] ) ) {
1166 $customer_data['email'] = sanitize_email( $customer_data['email'] );
1167 }
1168 if ( isset( $customer_data['phone'] ) ) {
1169 $customer_data['phone'] = sanitize_text_field( $customer_data['phone'] );
1170 }
1171 ( new Guest_Model( $db ) )->update( $customer_id, $customer_data );
1172 }
1173
1174 $order_model = $order_model->find( array( 'id' => $order_id ) );
1175 $data['customer_id'] = $order_model->customer_id;
1176
1177 $response = $this->format_order_response($db, $order_model, $data, null);
1178
1179 // Hide payment intent fields from team members (not admins)
1180 if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1181 $response['payment_intent_id'] = '';
1182 $response['client_secret'] = '';
1183 }
1184
1185 return $this->response( $response, __( 'Order updated successfully', 'booktics' ) );
1186 }
1187
1188 /**
1189 * Check if user has permission to get order.
1190 *
1191 * Staff always allowed. Logged-in customers allowed when the order belongs to their guest record.
1192 * Anonymous users allowed only when the request includes a valid order_key matching the order (same
1193 * value returned when the order was created).
1194 *
1195 * @param WP_REST_Request $request
1196 *
1197 * @return bool|WP_Error
1198 */
1199 public function get_item_permission( $request ) {
1200 if ( booktics_is_admin_or_team_member() ) {
1201 return true;
1202 }
1203
1204 $order_id = absint( $request->get_param( 'id' ) );
1205 $db = new Booktics_Database();
1206 $order = ( new Order_Model( $db ) )->find( array( 'id' => $order_id ) );
1207
1208 if ( ! $order ) {
1209 return true;
1210 }
1211
1212 if ( is_user_logged_in() ) {
1213 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $order->customer_id ) );
1214
1215 if ( $customer && intval( $customer->wp_user_id ) === get_current_user_id() ) {
1216 return true;
1217 }
1218 }
1219
1220 $order_key = sanitize_text_field( (string) $request->get_param( 'order_key' ) );
1221 if ( $order_key && $this->verify_order_view_key( $order_id, (string) $order->order_no, $order_key ) ) {
1222 return true;
1223 }
1224
1225 return new WP_Error(
1226 'booktics_forbidden',
1227 __( 'You are not allowed to view this order.', 'booktics' ),
1228 array( 'status' => 403 )
1229 );
1230 }
1231
1232 /**
1233 * Non-secret, unguessable key for viewing an order without logging in (returned on create and with GET responses).
1234 *
1235 * @param int $order_id
1236 * @param string $order_no
1237 *
1238 * @return string
1239 */
1240 private function get_order_view_key( $order_id, $order_no ) {
1241 return hash_hmac( 'sha256', (string) $order_id . '|' . $order_no, wp_salt( 'booktics_order_view' ) );
1242 }
1243
1244 /**
1245 * @param int $order_id
1246 * @param string $order_no
1247 * @param string $provided_key
1248 *
1249 * @return bool
1250 */
1251 private function verify_order_view_key( $order_id, $order_no, $provided_key ) {
1252 if ( '' === $provided_key || strlen( $provided_key ) > 128 ) {
1253 return false;
1254 }
1255
1256 $expected = $this->get_order_view_key( $order_id, $order_no );
1257
1258 return hash_equals( $expected, $provided_key );
1259 }
1260
1261 /**
1262 * Get single order.
1263 *
1264 * @param WP_REST_Request $request
1265 *
1266 * @return WP_Error|WP_HTTP_Response
1267 */
1268 public function get_item( $request ) {
1269 $order_id = absint( $request->get_param( 'id' ) );
1270 $db = new Booktics_Database();
1271 $order_model = new Order_Model( $db );
1272 $order = $order_model->find( array( 'id' => $order_id ) );
1273
1274 if ( ! $order ) {
1275 return $this->error( __( 'Order not found', 'booktics' ) );
1276 }
1277
1278 // Use the format_order_response helper for consistency
1279 $input_data = array( 'customer_id' => $order->customer_id );
1280 $response = $this->format_order_response( $db, $order, $input_data, array() );
1281 // Add extra fields specific to GET if needed
1282 $response['status'] = $order->status;
1283 $response['created_at'] = $this->format_datetime_to_dayjs_format( $order->created_at );
1284 $response['payment_gateway'] = $order->payment_method;
1285
1286 // Hide payment intent fields from team members (not admins)
1287 if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1288 $response['payment_intent_id'] = '';
1289 $response['client_secret'] = '';
1290 }
1291
1292 return $this->response( $response, __( 'Order fetched successfully', 'booktics' ) );
1293 }
1294
1295 /**
1296 * Check if user has permission to update payment status.
1297 *
1298 * @param WP_REST_Request $request
1299 *
1300 * @return bool
1301 */
1302 public function update_payment_status_permission( $request ) {
1303 if ( current_user_can( 'manage_options' ) ) {
1304 return true;
1305 }
1306
1307 // Allow order owner with a valid order_key (guest checkout support).
1308 $order_id = absint( $request->get_param( 'id' ) );
1309 $order_key = sanitize_text_field( (string) $request->get_param( 'order_key' ) );
1310
1311 if ( ! $order_id || ! $order_key ) {
1312 return false;
1313 }
1314
1315 $db = new Booktics_Database();
1316 $order = ( new Order_Model( $db ) )->find( array( 'id' => $order_id ) );
1317
1318 if ( ! $order ) {
1319 return false;
1320 }
1321
1322 return $this->verify_order_view_key( $order_id, (string) $order->order_no, $order_key );
1323 }
1324
1325 /**
1326 * Update payment status for an order.
1327 *
1328 * @param WP_REST_Request $request
1329 *
1330 * @return WP_Error|WP_HTTP_Response
1331 */
1332 public function update_payment_status( $request ) {
1333 $data = json_decode( $request->get_body(), true );
1334 $order_id = ! empty( $data['order_id'] ) ? intval( $data['order_id'] ) : 0;
1335 $payment_status = ! empty( $data['payment_status'] ) ? $data['payment_status'] : 0;
1336 $payment_method = ! empty( $data['payment_method'] ) ? $data['payment_method'] : null;
1337
1338 $db = new Booktics_Database();
1339 $order_model = new Order_Model( $db );
1340 $order = $order_model->find( array( 'id' => $order_id ) );
1341
1342 if ( ! $order ) {
1343 return $this->error( __( 'Order not found', 'booktics' ) );
1344 }
1345
1346 if ( ! array_key_exists( $payment_method, ( new Payment_Method_Manager() )->get_payment_methods() ) ) {
1347 return $this->error( __( 'Unauthorized payment method', 'booktics' ) );
1348 }
1349
1350 try {
1351 $payment_handler = ( new Payment_Method_Manager() )->get_payment_method( $payment_method );
1352 if ( ! $payment_handler ) {
1353 return $this->error( __( 'Payment method not found', 'booktics' ) );
1354 }
1355
1356 $status = $payment_handler->get_payment_status( $order );
1357 if ( is_wp_error( $status ) ) {
1358 return $this->error( $status->get_error_message() );
1359 }
1360
1361 $order_model->update(
1362 $order_id, array(
1363 'payment_status' => $status,
1364 'transaction_id' => $data['transaction_id'] ?? null,
1365 )
1366 );
1367
1368 if (
1369 $status === Payment_Status::COMPLETED
1370 && $order->payment_status !== Payment_Status::COMPLETED
1371 ) {
1372 // The payment status is already persisted above. Sending notifications must
1373 // not fail the request, otherwise a confirmed booking shows an error toast.
1374 try {
1375 $appointments = ( new Appointment_Model() )->where( 'order_id', $order_id );
1376 $this->send_notifications( $db, $order, $order->customer_id, $appointments );
1377 } catch ( \Throwable $e ) {
1378 if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
1379 error_log( 'Booktics: failed to send notifications after payment for order ' . $order_id . ': ' . $e->getMessage() );
1380 }
1381 }
1382 }
1383
1384 // Payment model status and transaction id update
1385 return $this->response(
1386 array(
1387 'payment_status' => $status,
1388 ), __( 'Payment status updated successfully', 'booktics' )
1389 );
1390 } catch ( \Exception $e ) {
1391
1392 if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
1393 error_log( 'An error occurred while updating payment status: ' . $e->getMessage() );
1394 }
1395 return $this->error( __( 'An error occurred while updating payment status.', 'booktics' ) );
1396 }
1397 }
1398
1399 /**
1400 * Check if user has permission to delete order.
1401 *
1402 * @param WP_REST_Request $request
1403 *
1404 * @return bool
1405 */
1406 public function delete_item_permission( $request ) {
1407 return current_user_can( 'manage_options' );
1408 }
1409
1410 /**
1411 * Delete an order.
1412 *
1413 * @param WP_REST_Request $request
1414 *
1415 * @return WP_Error|WP_HTTP_Response
1416 */
1417 public function delete_item( $request ) {
1418 $order_id = absint( $request->get_param( 'id' ) );
1419 $db = new Booktics_Database();
1420 $order_model = new Order_Model( $db );
1421 $order = $order_model->find( array( 'id' => $order_id ) );
1422
1423 if ( ! $order ) {
1424 return $this->error( __( 'Order not found', 'booktics' ) );
1425 }
1426
1427 try {
1428 $db->begin_transaction();
1429
1430 // Delete related appointments
1431 $appointment_model = new Appointment_Model();
1432 $appointments = $appointment_model->where( 'order_id', $order_id );
1433 foreach ( $appointments as $appointment ) {
1434 $appointment->delete();
1435 }
1436
1437 // Delete related payments
1438 $payment_model = new Payment_Model( $db );
1439 $payments = $payment_model->get( array( 'order_id' => $order_id ) );
1440 foreach ( $payments['items'] as $payment ) {
1441 ( new Payment_Model( $db ) )->delete( $payment->id );
1442 }
1443 // Delete the order
1444 $order_model->delete( $order_id );
1445
1446 $db->commit();
1447
1448 return $this->response( array(), __( 'Order deleted successfully', 'booktics' ) );
1449 } catch ( Exception $e ) {
1450 $db->roll_back();
1451
1452 return $this->error( $e->getMessage() );
1453 }
1454 }
1455
1456 /**
1457 * Check if user has permission to bulk delete orders.
1458 *
1459 * @param WP_REST_Request $request
1460 *
1461 * @return bool
1462 */
1463 public function bulk_delete_items_permission( $request ) {
1464 return current_user_can( 'manage_options' );
1465 }
1466
1467 /**
1468 * Bulk delete orders.
1469 *
1470 * @param WP_REST_Request $request
1471 *
1472 * @return WP_Error|WP_HTTP_Response
1473 */
1474 public function bulk_delete_items( $request ) {
1475 $ids_param = $request->get_param( 'ids' );
1476 $order_ids = is_array( $ids_param ) ? array_map( 'absint', $ids_param ) : array();
1477 $db = new Booktics_Database();
1478
1479 try {
1480 $db->begin_transaction();
1481
1482 foreach ( $order_ids as $order_id ) {
1483 $order_model = new Order_Model( $db );
1484 $order = $order_model->find( array( 'id' => $order_id ) );
1485
1486 if ( ! $order ) {
1487 continue;
1488 }
1489
1490 // Delete related appointments
1491 $appointment_model = new Appointment_Model();
1492 $appointments = $appointment_model->where( 'order_id', $order_id );
1493 foreach ( $appointments as $appointment ) {
1494 $appointment->delete();
1495 }
1496
1497 // Delete related payments
1498 $payment_model = new Payment_Model( $db );
1499 $payments = $payment_model->get( array( 'order_id' => $order_id ) );
1500 foreach ( $payments['items'] as $payment ) {
1501 ( new Payment_Model( $db ) )->delete( $payment->id );
1502 }
1503
1504 // Delete the order
1505 $order_model->delete( $order_id );
1506 }
1507
1508 $db->commit();
1509
1510 return $this->response( array(), __( 'Orders deleted successfully', 'booktics' ) );
1511 } catch ( Exception $e ) {
1512 $db->roll_back();
1513
1514 return $this->error( $e->getMessage() );
1515 }
1516 }
1517
1518 /**
1519 * Process payment refund for an order.
1520 *
1521 * @param WP_REST_Request $request
1522 *
1523 * @return WP_Error|WP_HTTP_Response
1524 */
1525 public function refund_payment( $request ) {
1526 $order_id = absint( $request->get_param( 'id' ) );
1527 $db = new Booktics_Database();
1528 $order_model = new Order_Model( $db );
1529 $order = $order_model->find( array( 'id' => $order_id ) );
1530
1531 $payment_method = ( new Payment_Method_Manager() )->get_payment_method( $order->payment_method );
1532 if ( ! $payment_method ) {
1533 return $this->error( __( 'Invalid payment method!', 'booktics' ) );
1534 }
1535 $response = $payment_method->process_refund( $order );
1536 if ( $response['status'] === 'error' ) {
1537 return $this->error( $response['message'] );
1538 }
1539
1540 $order_model->update(
1541 $order_id, array(
1542 'payment_status' => Payment_Status::REFUNDED,
1543 )
1544 );
1545
1546 return $this->response(
1547 $order_model->find( array( 'id' => $order_id ) ),
1548 __( 'Refund successful', 'booktics' )
1549 );
1550 }
1551
1552 /**
1553 * Calculate total duration in minutes for a service appointment.
1554 *
1555 * @param array $item
1556 *
1557 * @return int
1558 */
1559 private function calculate_total_duration( $item ) {
1560 $service_model = Service_Model::find( $item['service_id'] );
1561 if ( ! $service_model ) {
1562 return 0;
1563 }
1564 $service_duration = array_filter(
1565 $service_model->additional_durations, function ( $duration ) use ( $item ) {
1566 return $item['additional_duration_id'] === $duration['id'];
1567 }
1568 );
1569 if ( empty( $service_duration ) ) {
1570 return 0;
1571 }
1572 $service_duration = reset( $service_duration );
1573 $total_duration_min = $service_duration['duration_unit'] === 'min'
1574 ? $service_duration['duration_time']
1575 : ( $service_duration['duration_time'] * 60 );
1576 if ( ! empty( $item['extra_services'] ) ) {
1577 $total_duration_min = apply_filters( 'booktics_extra_service_duration', $total_duration_min, $item['extra_services'] );
1578 }
1579
1580 $total_duration_min = apply_filters( 'booktics_calculate_total_duration', $total_duration_min, $item );
1581
1582 return $total_duration_min;
1583 }
1584
1585 /**
1586 * Converts a datetime string to ISO 8601 format with WordPress timezone for dayjs compatibility
1587 *
1588 * @param string $datetime Datetime string from database (e.g., "2024-01-15 14:00:00")
1589 *
1590 * @return string ISO 8601 formatted datetime with timezone offset (e.g., "2024-01-15T14:00:00-05:00")
1591 */
1592 private function format_datetime_to_dayjs_format( $datetime ) {
1593 $dt = new DateTime( $datetime );
1594 $dt->setTimezone( wp_timezone() );
1595 return $dt->format( 'Y-m-d H:i:s' );
1596 }
1597 }
1598