PluginProbe
Booktics – Appointment Booking Calendar for Service Businesses / 1.0.23
Booktics – Appointment Booking Calendar for Service Businesses v1.0.23
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.23, at core/order/controllers/order-controller.php

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