PluginProbe
Booktics – Appointment Booking Calendar for Service Businesses / 1.0.19
Booktics – Appointment Booking Calendar for Service Businesses v1.0.19
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 1.0.4 1.0.5 1.0.6 1.0.7 All 27 releases
booktics / core / order / controllers / order-controller.php

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

1,506 lines 59.9 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( get_option( 'time_format' ), strtotime( $item['start_time'] ), $input_timezone ) ?? '',
537 'end_time' => wp_date( get_option( 'time_format' ), 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 // Do NOT set fully_paid here — PaymentIntent created ≠ payment collected.
601 // Status is updated after client-side confirmation via update_payment_status endpoint.
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_no_for_key = is_object( $order_model ) ? $order_model->order_no : ( $order_model['order_no'] ?? '' );
714
715 $order_data = array(
716 'id' => $order_id,
717 'order_no' => is_object( $order_model ) ? $order_model->order_no : ( $order_model['order_no'] ?? null ),
718 'order_key' => $this->get_order_view_key( (int) $order_id, (string) $order_no_for_key ),
719 'customer' => array(
720 'id' => $customer->id ?? null,
721 'name' => $customer->name ?? '',
722 'phone' => $customer->phone ?? '',
723 'email' => $customer->email ?? '',
724 ),
725 'status' => is_object( $order_model ) ? $order_model->status : ( $order_model['status'] ?? null ),
726 'subtotal' => is_object( $order_model ) ? $order_model->subtotal : ( $order_model['subtotal'] ?? null ),
727 'total' => is_object( $order_model ) ? $order_model->total : ( $order_model['total'] ?? null ),
728 'payment_status' => is_object( $order_model ) ? $order_model->payment_status : ( $order_model['payment_status'] ?? null ),
729 'payment_method' => is_object( $order_model ) ? $order_model->payment_method : $order_model['payment_method'],
730 'items' => $appointment_details,
731 'payment_intent_id' => $payment_intent['payment_intent'] ?? '',
732 'client_secret' => $payment_intent['client_secret'] ?? '',
733 'total_services' => $input_data['total_services'] ?? 1,
734 );
735
736 $order_data = apply_filters( 'booktics_pro_order_format', $order_data, $order_model );
737
738 return $order_data;
739 }
740
741 /**
742 * Helper: Send notifications for order and appointment creation
743 *
744 * @param Order_Model $order_model
745 * @param int $customer_id
746 * @param array $appointments
747 *
748 * @return void
749 */
750 private function send_notifications( $db, $order_model, $customer_id, $appointments ): void {
751 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $customer_id ) );
752 foreach ( $appointments as $appointment ) {
753 $formatted_appointment = $this->format_appointment( $appointment );
754 $appointment_time = $formatted_appointment['date'] . ' ' . $formatted_appointment['start_time'];
755 $dt = new DateTime( $appointment_time, wp_timezone() );
756 $timestamp = $dt->getTimestamp();
757 $appointment_datetime = wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $timestamp, wp_timezone() );
758 $notification_flow_data_for_appointment = array(
759 'appointment_service' => $formatted_appointment['service']['title'] ?? '',
760 'appointment_team_member' => $formatted_appointment['team']['display_name'] ?? '',
761 'start_time' => $formatted_appointment['start_time'],
762 'appointment_date' => $formatted_appointment['date'],
763 'appointment_time' => $appointment_datetime,
764 'attendee_count' => $formatted_appointment['attendee_count'] ?? null,
765 'customer_name' => $customer->name ?? '',
766 'user_email' => $customer->email ?? '',
767 'appointment_team_member_email' => $formatted_appointment['team']['user_email'] ?? '',
768 );
769 $notification_flow_data_for_appointment = apply_filters( 'booktics_appointment_scheduled_notification_flow_data', $notification_flow_data_for_appointment, $formatted_appointment );
770
771 do_action( 'booktics_gln_hook', 'appointment_scheduled', $notification_flow_data_for_appointment );
772 }
773
774 $appointment = $appointments[0]; // As currently only one appointment is created for each user
775 $formatted_appointment = $this->format_appointment( $appointment );
776 $notification_flow_data_for_order = [
777 'order_no' => $order_model->order_no ?? '',
778 'order_date' => $order_model->created_at ?? '',
779 'order_total' => $order_model->total ?? 0,
780 'customer_name' => $customer->name ?? '',
781 'user_email' => $customer->email ?? '',
782 'order_status' => $order_model->status ?? '',
783 'service_name' => $formatted_appointment['service']['title'] ?? '',
784 'team_name' => $formatted_appointment['team']['display_name'] ?? '',
785 'start_time' => $formatted_appointment['start_time'] ?? '',
786 'end_time' => $formatted_appointment['end_time'] ?? '',
787 'appointment_date' => $formatted_appointment['date'] ?? '',
788 'location_address' => $formatted_appointment['location']['address'] ?? '',
789 'appointment_status' => $formatted_appointment['status'] ?? '',
790 'appointment_cancel_url' => $appointment->get_cancel_link(),
791 'appointment_reschedule_url' => $appointment->get_reschedule_link(),
792 ];
793
794 $notification_flow_data_for_order = apply_filters( 'booktics_order_created_notification_flow_data', $notification_flow_data_for_order, $formatted_appointment, $order_model );
795
796 do_action( 'booktics_gln_hook', 'order_created', $notification_flow_data_for_order );
797 }
798
799 /**
800 * Find existing customer by email or create new customer
801 *
802 * @param array $customer
803 *
804 * @return int|WP_Error
805 */
806 private function find_or_create_customer( array $customer ) {
807 $db = new Booktics_Database();
808 if ( ! isset( $customer['email'] ) ) {
809 return new WP_Error( 'invalid_email', __( 'Customer email is required', 'booktics' ) );
810 }
811 $user_login = User_Model::generate_username( $customer['email'] );
812 $allow_customer_as_wp_user = booktics_get_option( 'allow_customer_as_wp_user', false );
813 $wp_user_id = null;
814 if ( $allow_customer_as_wp_user ) {
815 $wp_user_id = $this->get_or_create_wp_user( $customer, $user_login );
816 }
817 $guest = $this->find_and_update_guest( $db, $customer, $wp_user_id );
818 if ( $guest ) {
819 return $guest->id;
820 }
821 $guest = $this->create_guest( $db, $customer, $user_login, $wp_user_id );
822
823 return $guest->id;
824 }
825
826 /**
827 * Get or create a WordPress user for the customer
828 *
829 * @param array $customer
830 * @param string $user_login
831 *
832 * @return int WP user ID
833 */
834 private function get_or_create_wp_user( $customer, $user_login ) {
835 $wp_user = get_user_by( 'email', $customer['email'] );
836 if ( $wp_user ) {
837 return $wp_user->ID;
838 }
839 $userdata = array(
840 'user_login' => $user_login,
841 'user_email' => $customer['email'],
842 'first_name' => $customer['name'] ?? '',
843 'display_name' => $customer['name'] ?? '',
844 'phone' => $customer['phone'] ?? '',
845 );
846 $customer_model = new Customer_Model();
847 $customer_id = $customer_model->save( $userdata );
848 ( new Customer_Event_Handler() )->on_customer_create( $customer_id );
849
850 return $customer_id;
851 }
852
853 /**
854 * Find and update a guest record by email
855 *
856 * @param Booktics_Database $db
857 * @param array $customer
858 *
859 * @return object|null Guest_Model instance or null
860 */
861 private function find_and_update_guest( $db, $customer, $wp_user_id = null ) {
862 $guest = ( new Guest_Model( $db ) )->find( array( 'email' => $customer['email'] ) );
863 if ( $guest ) {
864 ( new Guest_Model( $db ) )->update(
865 $guest->id, array(
866 'name' => $customer['name'] ?? '',
867 'phone' => $customer['phone'] ?? '',
868 'wp_user_id' => $wp_user_id,
869 )
870 );
871 }
872
873 return $guest;
874 }
875
876 /**
877 * Create a new guest record
878 *
879 * @param Booktics_Database $db
880 * @param array $customer
881 * @param string $user_login
882 * @param int|null $wp_user_id
883 *
884 * @return object Guest_Model instance
885 */
886 private function create_guest( $db, $customer, $user_login, $wp_user_id ) {
887 return ( new Guest_Model( $db ) )->create(
888 array(
889 'wp_user_id' => $wp_user_id,
890 'email' => $customer['email'],
891 'name' => $customer['name'] ?? '',
892 'user_login' => $user_login,
893 'phone' => $customer['phone'] ?? '',
894 )
895 );
896 }
897
898 /**
899 * Check if user has permission to list orders.
900 *
901 * Anonymous access is denied: the collection handler scopes to the current customer or team member;
902 * unauthenticated callers would otherwise receive unscoped results. Single-order GET is allowed for
903 * guests when {@see get_item_permission()} validates a matching order_key.
904 *
905 * @param WP_REST_Request $request
906 *
907 * @return bool|WP_Error
908 */
909 public function get_items_permission( $request ) {
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 * Staff always allowed. Logged-in customers allowed when the order belongs to their guest record.
1116 * Anonymous users allowed only when the request includes a valid order_key matching the order (same
1117 * value returned when the order was created).
1118 *
1119 * @param WP_REST_Request $request
1120 *
1121 * @return bool|WP_Error
1122 */
1123 public function get_item_permission( $request ) {
1124 if ( booktics_is_admin_or_team_member() ) {
1125 return true;
1126 }
1127
1128 $order_id = absint( $request->get_param( 'id' ) );
1129 $db = new Booktics_Database();
1130 $order = ( new Order_Model( $db ) )->find( array( 'id' => $order_id ) );
1131
1132 if ( ! $order ) {
1133 return true;
1134 }
1135
1136 if ( is_user_logged_in() ) {
1137 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $order->customer_id ) );
1138
1139 if ( $customer && intval( $customer->wp_user_id ) === get_current_user_id() ) {
1140 return true;
1141 }
1142 }
1143
1144 $order_key = sanitize_text_field( (string) $request->get_param( 'order_key' ) );
1145 if ( $order_key && $this->verify_order_view_key( $order_id, (string) $order->order_no, $order_key ) ) {
1146 return true;
1147 }
1148
1149 return new WP_Error(
1150 'booktics_forbidden',
1151 __( 'You are not allowed to view this order.', 'booktics' ),
1152 array( 'status' => 403 )
1153 );
1154 }
1155
1156 /**
1157 * Non-secret, unguessable key for viewing an order without logging in (returned on create and with GET responses).
1158 *
1159 * @param int $order_id
1160 * @param string $order_no
1161 *
1162 * @return string
1163 */
1164 private function get_order_view_key( $order_id, $order_no ) {
1165 return hash_hmac( 'sha256', (string) $order_id . '|' . $order_no, wp_salt( 'booktics_order_view' ) );
1166 }
1167
1168 /**
1169 * @param int $order_id
1170 * @param string $order_no
1171 * @param string $provided_key
1172 *
1173 * @return bool
1174 */
1175 private function verify_order_view_key( $order_id, $order_no, $provided_key ) {
1176 if ( '' === $provided_key || strlen( $provided_key ) > 128 ) {
1177 return false;
1178 }
1179
1180 $expected = $this->get_order_view_key( $order_id, $order_no );
1181
1182 return hash_equals( $expected, $provided_key );
1183 }
1184
1185 /**
1186 * Get single order.
1187 *
1188 * @param WP_REST_Request $request
1189 *
1190 * @return WP_Error|WP_HTTP_Response
1191 */
1192 public function get_item( $request ) {
1193 $order_id = absint( $request->get_param( 'id' ) );
1194 $db = new Booktics_Database();
1195 $order_model = new Order_Model( $db );
1196 $order = $order_model->find( array( 'id' => $order_id ) );
1197
1198 if ( ! $order ) {
1199 return $this->error( __( 'Order not found', 'booktics' ) );
1200 }
1201
1202 // Use the format_order_response helper for consistency
1203 $input_data = array( 'customer_id' => $order->customer_id );
1204 $response = $this->format_order_response( $db, $order, $input_data, array() );
1205 // Add extra fields specific to GET if needed
1206 $response['status'] = $order->status;
1207 $response['created_at'] = $this->format_datetime_to_dayjs_format( $order->created_at );
1208 $response['payment_gateway'] = $order->payment_method;
1209
1210 // Hide payment intent fields from team members (not admins)
1211 if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1212 $response['payment_intent_id'] = '';
1213 $response['client_secret'] = '';
1214 }
1215
1216 return $this->response( $response, __( 'Order fetched successfully', 'booktics' ) );
1217 }
1218
1219 /**
1220 * Check if user has permission to update payment status.
1221 *
1222 * @param WP_REST_Request $request
1223 *
1224 * @return bool
1225 */
1226 public function update_payment_status_permission( $request ) {
1227 if ( current_user_can( 'manage_options' ) ) {
1228 return true;
1229 }
1230
1231 // Allow order owner with a valid order_key (guest checkout support).
1232 $order_id = absint( $request->get_param( 'id' ) );
1233 $order_key = sanitize_text_field( (string) $request->get_param( 'order_key' ) );
1234
1235 if ( ! $order_id || ! $order_key ) {
1236 return false;
1237 }
1238
1239 $db = new Booktics_Database();
1240 $order = ( new Order_Model( $db ) )->find( array( 'id' => $order_id ) );
1241
1242 if ( ! $order ) {
1243 return false;
1244 }
1245
1246 return $this->verify_order_view_key( $order_id, (string) $order->order_no, $order_key );
1247 }
1248
1249 /**
1250 * Update payment status for an order.
1251 *
1252 * @param WP_REST_Request $request
1253 *
1254 * @return WP_Error|WP_HTTP_Response
1255 */
1256 public function update_payment_status( $request ) {
1257 $data = json_decode( $request->get_body(), true );
1258 $order_id = ! empty( $data['order_id'] ) ? intval( $data['order_id'] ) : 0;
1259 $payment_status = ! empty( $data['payment_status'] ) ? $data['payment_status'] : 0;
1260 $payment_method = ! empty( $data['payment_method'] ) ? $data['payment_method'] : null;
1261
1262 $db = new Booktics_Database();
1263 $order_model = new Order_Model( $db );
1264 $order = $order_model->find( array( 'id' => $order_id ) );
1265
1266 if ( ! $order ) {
1267 return $this->error( __( 'Order not found', 'booktics' ) );
1268 }
1269
1270 if ( ! array_key_exists( $payment_method, ( new Payment_Method_Manager() )->get_payment_methods() ) ) {
1271 return $this->error( __( 'Unauthorized payment method', 'booktics' ) );
1272 }
1273
1274 try {
1275 $payment_handler = ( new Payment_Method_Manager() )->get_payment_method( $payment_method );
1276 if ( ! $payment_handler ) {
1277 return $this->error( __( 'Payment method not found', 'booktics' ) );
1278 }
1279
1280 $status = $payment_handler->get_payment_status( $order );
1281 if ( is_wp_error( $status ) ) {
1282 return $this->error( $status->get_error_message() );
1283 }
1284
1285 $order_model->update(
1286 $order_id, array(
1287 'payment_status' => $status,
1288 'transaction_id' => $data['transaction_id'] ?? null,
1289 )
1290 );
1291
1292 // Payment model status and transaction id update
1293 return $this->response(
1294 array(
1295 'payment_status' => $status,
1296 ), __( 'Payment status updated successfully', 'booktics' )
1297 );
1298 } catch ( \Exception $e ) {
1299
1300 if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
1301 error_log( 'An error occurred while deleting orders:' . $e->getMessage() );
1302 }
1303 return $this->error( __( 'An error occurred while deleting orders.', 'booktics' ) );
1304 }
1305 }
1306
1307 /**
1308 * Check if user has permission to delete order.
1309 *
1310 * @param WP_REST_Request $request
1311 *
1312 * @return bool
1313 */
1314 public function delete_item_permission( $request ) {
1315 return current_user_can( 'manage_options' );
1316 }
1317
1318 /**
1319 * Delete an order.
1320 *
1321 * @param WP_REST_Request $request
1322 *
1323 * @return WP_Error|WP_HTTP_Response
1324 */
1325 public function delete_item( $request ) {
1326 $order_id = absint( $request->get_param( 'id' ) );
1327 $db = new Booktics_Database();
1328 $order_model = new Order_Model( $db );
1329 $order = $order_model->find( array( 'id' => $order_id ) );
1330
1331 if ( ! $order ) {
1332 return $this->error( __( 'Order not found', 'booktics' ) );
1333 }
1334
1335 try {
1336 $db->begin_transaction();
1337
1338 // Delete related appointments
1339 $appointment_model = new Appointment_Model();
1340 $appointments = $appointment_model->where( 'order_id', $order_id );
1341 foreach ( $appointments as $appointment ) {
1342 $appointment->delete();
1343 }
1344
1345 // Delete related payments
1346 $payment_model = new Payment_Model( $db );
1347 $payments = $payment_model->get( array( 'order_id' => $order_id ) );
1348 foreach ( $payments['items'] as $payment ) {
1349 ( new Payment_Model( $db ) )->delete( $payment->id );
1350 }
1351 // Delete the order
1352 $order_model->delete( $order_id );
1353
1354 $db->commit();
1355
1356 return $this->response( array(), __( 'Order deleted successfully', 'booktics' ) );
1357 } catch ( Exception $e ) {
1358 $db->roll_back();
1359
1360 return $this->error( $e->getMessage() );
1361 }
1362 }
1363
1364 /**
1365 * Check if user has permission to bulk delete orders.
1366 *
1367 * @param WP_REST_Request $request
1368 *
1369 * @return bool
1370 */
1371 public function bulk_delete_items_permission( $request ) {
1372 return current_user_can( 'manage_options' );
1373 }
1374
1375 /**
1376 * Bulk delete orders.
1377 *
1378 * @param WP_REST_Request $request
1379 *
1380 * @return WP_Error|WP_HTTP_Response
1381 */
1382 public function bulk_delete_items( $request ) {
1383 $ids_param = $request->get_param( 'ids' );
1384 $order_ids = is_array( $ids_param ) ? array_map( 'absint', $ids_param ) : array();
1385 $db = new Booktics_Database();
1386
1387 try {
1388 $db->begin_transaction();
1389
1390 foreach ( $order_ids as $order_id ) {
1391 $order_model = new Order_Model( $db );
1392 $order = $order_model->find( array( 'id' => $order_id ) );
1393
1394 if ( ! $order ) {
1395 continue;
1396 }
1397
1398 // Delete related appointments
1399 $appointment_model = new Appointment_Model();
1400 $appointments = $appointment_model->where( 'order_id', $order_id );
1401 foreach ( $appointments as $appointment ) {
1402 $appointment->delete();
1403 }
1404
1405 // Delete related payments
1406 $payment_model = new Payment_Model( $db );
1407 $payments = $payment_model->get( array( 'order_id' => $order_id ) );
1408 foreach ( $payments['items'] as $payment ) {
1409 ( new Payment_Model( $db ) )->delete( $payment->id );
1410 }
1411
1412 // Delete the order
1413 $order_model->delete( $order_id );
1414 }
1415
1416 $db->commit();
1417
1418 return $this->response( array(), __( 'Orders deleted successfully', 'booktics' ) );
1419 } catch ( Exception $e ) {
1420 $db->roll_back();
1421
1422 return $this->error( $e->getMessage() );
1423 }
1424 }
1425
1426 /**
1427 * Process payment refund for an order.
1428 *
1429 * @param WP_REST_Request $request
1430 *
1431 * @return WP_Error|WP_HTTP_Response
1432 */
1433 public function refund_payment( $request ) {
1434 $order_id = absint( $request->get_param( 'id' ) );
1435 $db = new Booktics_Database();
1436 $order_model = new Order_Model( $db );
1437 $order = $order_model->find( array( 'id' => $order_id ) );
1438
1439 $payment_method = ( new Payment_Method_Manager() )->get_payment_method( $order->payment_method );
1440 if ( ! $payment_method ) {
1441 return $this->error( __( 'Invalid payment method!', 'booktics' ) );
1442 }
1443 $response = $payment_method->process_refund( $order );
1444 if ( $response['status'] === 'error' ) {
1445 return $this->error( $response['message'] );
1446 }
1447
1448 $order_model->update(
1449 $order_id, array(
1450 'payment_status' => Payment_Status::REFUNDED,
1451 )
1452 );
1453
1454 return $this->response(
1455 $order_model->find( array( 'id' => $order_id ) ),
1456 __( 'Refund successful', 'booktics' )
1457 );
1458 }
1459
1460 /**
1461 * Calculate total duration in minutes for a service appointment.
1462 *
1463 * @param array $item
1464 *
1465 * @return int
1466 */
1467 private function calculate_total_duration( $item ) {
1468 $service_model = Service_Model::find( $item['service_id'] );
1469 if ( ! $service_model ) {
1470 return 0;
1471 }
1472 $service_duration = array_filter(
1473 $service_model->additional_durations, function ( $duration ) use ( $item ) {
1474 return $item['additional_duration_id'] === $duration['id'];
1475 }
1476 );
1477 if ( empty( $service_duration ) ) {
1478 return 0;
1479 }
1480 $service_duration = reset( $service_duration );
1481 $total_duration_min = $service_duration['duration_unit'] === 'min'
1482 ? $service_duration['duration_time']
1483 : ( $service_duration['duration_time'] * 60 );
1484 if ( ! empty( $item['extra_services'] ) ) {
1485 $total_duration_min = apply_filters( 'booktics_extra_service_duration', $total_duration_min, $item['extra_services'] );
1486 }
1487
1488 $total_duration_min = apply_filters( 'booktics_calculate_total_duration', $total_duration_min, $item );
1489
1490 return $total_duration_min;
1491 }
1492
1493 /**
1494 * Converts a datetime string to ISO 8601 format with WordPress timezone for dayjs compatibility
1495 *
1496 * @param string $datetime Datetime string from database (e.g., "2024-01-15 14:00:00")
1497 *
1498 * @return string ISO 8601 formatted datetime with timezone offset (e.g., "2024-01-15T14:00:00-05:00")
1499 */
1500 private function format_datetime_to_dayjs_format( $datetime ) {
1501 $dt = new DateTime( $datetime );
1502 $dt->setTimezone( wp_timezone() );
1503 return $dt->format( 'Y-m-d H:i:s' );
1504 }
1505 }
1506