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

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