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

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