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

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