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

1,313 lines 51.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Booktics\Order\Controllers;
4
5 use Booktics\Abstracts\Base_Rest_Controller;
6 use Booktics\Abstracts\Booktics_Database;
7 use Booktics\Abstracts\User_Model;
8 use Booktics\Cart\Cart_Manager;
9 use Booktics\Customer\Handlers\Customer_Event_Handler;
10 use Booktics\Models\Appointment_Model;
11 use Booktics\Models\Customer_Model;
12 use Booktics\Models\Guest_Model;
13 use Booktics\Models\Order_Model;
14 use Booktics\Models\Payment_Model;
15 use Booktics\Models\Service_Model;
16 use Booktics\Models\Team_Member_Model;
17 use Booktics\Order\Order_Validator;
18 use Booktics\Payment\Payment_Method_Manager;
19 use Booktics\Payment\Payment_Status;
20 use Booktics\Services\Time_Zone_Handler;
21 use DateTime;
22 use Exception;
23 use WP_Error;
24 use WP_HTTP_Response;
25 use WP_REST_Request;
26 use WP_REST_Server;
27
28 /**
29 * Order controller
30 *
31 * @package Booktics/Order
32 */
33 class Order_Controller extends Base_Rest_Controller {
34
35 /**
36 * Endpoint namespace
37 *
38 * @var string
39 */
40 protected $namespace = 'booktics/v1';
41
42 /**
43 * Route name
44 *
45 * @var string
46 */
47 protected $base = 'orders';
48
49 /**
50 * Register REST API routes for orders.
51 *
52 * @return void
53 */
54 public function register_routes(): void {
55 register_rest_route(
56 $this->namespace, $this->base, array(
57 array(
58 'methods' => WP_REST_Server::CREATABLE,
59 'callback' => array( $this, 'create_item' ),
60 'permission_callback' => array(
61 $this,
62 'create_order_permission',
63 ),
64 ),
65 array(
66 'methods' => WP_REST_Server::READABLE,
67 'callback' => array( $this, 'get_items' ),
68 'permission_callback' => array(
69 $this,
70 'get_items_permission',
71 ),
72 'args' => array(
73 'page' => array( 'default' => 1 ),
74 'per_page' => array( 'default' => 10 ),
75 ),
76 ),
77 array(
78 'methods' => WP_REST_Server::DELETABLE,
79 'callback' => array(
80 $this,
81 'bulk_delete_items',
82 ),
83 'permission_callback' => array(
84 $this,
85 'bulk_delete_items_permission',
86 ),
87 ),
88 )
89 );
90
91 register_rest_route(
92 $this->namespace, $this->base . '/(?P<id>[\d]+)', array(
93 array(
94 'methods' => WP_REST_Server::EDITABLE,
95 'callback' => array( $this, 'update_item' ),
96 'permission_callback' => array(
97 $this,
98 'update_item_permission',
99 ),
100 'args' => array(
101 'id' => array( 'required' => true ),
102 'status' => array( 'type' => 'string' ),
103 ),
104 ),
105 array(
106 'methods' => WP_REST_Server::READABLE,
107 'callback' => array( $this, 'get_item' ),
108 'permission_callback' => array(
109 $this,
110 'get_item_permission',
111 ),
112 'args' => array(
113 'id' => array( 'required' => true ),
114 ),
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' => absint( $item['customer_id'] ),
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'] = $input_data['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 'payment_method' => $input_data['payment_method'] ?? null,
504 );
505
506 // This is to only allow setting payment status when creating order from admin panel as admin
507 if ( isset( $input_data['payment_status'] ) && current_user_can( 'manage_options' ) ) {
508 $order_data['payment_status'] = $input_data['payment_status'];
509 }
510
511 $order_data = apply_filters( 'booktics_pro_order_columns', $order_data, $input_data );
512 $order_model = $order_model->create( $order_data );
513 $appointments = array();
514 foreach ( $input_data['items'] as $item ) {
515 if ( isset( $item['uuid'] ) ) {
516 ( new Cart_Manager() )->remove_cart_item( $item['uuid'] );
517 }
518 $total_duration_min = $this->calculate_total_duration( $item );
519
520 $appointment_data = array(
521 'start_time' => wp_date('g:i A', strtotime($item['start_time']), $input_timezone) ?? '',
522 'end_time' => wp_date('g:i A', strtotime($item['end_time']), $input_timezone) ?? '',
523 'date' => wp_date( 'Y-m-d', strtotime( $item['date'] ), $input_timezone ),
524 'customer_id' => $input_data['customer_id'] ?? '',
525 'team_member_id' => $item['team_member_id'] ?? '',
526 'service_id' => $item['service_id'] ?? '',
527 'additional_duration_id' => $item['additional_duration_id'] ?? '',
528 'duration' => $total_duration_min,
529 'order_id' => $order_model->id,
530 'quantity' => $item['quantity'] ?? 1,
531 'price' => $item['price'] ?? 0,
532 'subtotal' => $item['subtotal'] ?? 0,
533 'total' => $item['total'] ?? $item['subtotal'] ?? 0,
534 'status' => booktics_get_option( 'appointment_status', 'pending' ),
535 'total_attendees' => $input_data['total_attendees'] ?? 1,
536 'timezone' => $original_timezone,
537 );
538
539
540
541 $appointment_data = apply_filters( 'booktics_appointment_pro_format', $appointment_data, $item );
542
543 $appointment = Appointment_Model::create( $appointment_data );
544 if ( is_wp_error( $appointment ) ) {
545 throw new Exception( $appointment->get_error_message() );
546 }
547
548 do_action( 'booktics_after_appointment_created', $appointment );
549 $appointments[] = $appointment;
550 }
551
552 return array( $order_model, $appointments );
553 }
554
555
556 /**
557 * Process payment for an order if immediate payment is requested
558 *
559 * @param array $input_data Order input data containing payment details
560 * @param Order_Model $order_model Order model instance
561 * @param Booktics_Database $db Database instance
562 *
563 * @return array
564 * @throws Exception
565 */
566 private function handle_payment_if_needed( $input_data, $order_model, $db ) {
567 if ( ! isset( $input_data['pay_now'] ) || ! $input_data['pay_now'] ) {
568 return array();
569 }
570
571 $payment_method = ( new Payment_Method_Manager() )->get_payment_method( $input_data['payment_method'] );
572 if ( ! $payment_method ) {
573 throw new Exception( __( 'Payment method cannot be found!', 'booktics' ) );
574 }
575
576 $payment_intent = $payment_method->process_payment( $order_model );
577
578 if ( $payment_intent['status'] === 'error' ) {
579 throw new Exception( $payment_intent['message'] );
580 }
581
582 if ( $payment_intent['status'] === 'success' ) {
583 $this->update_payment_records( $db, $order_model, $input_data, $payment_intent );
584
585 // Update orders payment status on successfull payment completion
586 $order_model_id = is_object( $order_model ) ? $order_model->id : $order_model['id'];
587 ( new Order_Model($db) )->update( $order_model_id, [ 'payment_status' => 'fully_paid' ] );
588 }
589
590 return $payment_intent;
591 }
592
593
594 /**
595 * Update payment records after successful payment processing
596 *
597 * @param Booktics_Database $db Database instance
598 * @param Order_Model $order_model Order model instance
599 * @param array $input_data Order input data containing payment details
600 * @param array $payment_intent Payment intent data from payment processor
601 *
602 * @return void
603 */
604 private function update_payment_records( $db, $order_model, $input_data, $payment_intent ) {
605 ( new Order_Model( $db ) )->update(
606 $order_model->id,
607 array( 'payment_intent_id' => $payment_intent['payment_intent'] )
608 );
609
610 ( new Payment_Model( $db ) )->create(
611 array(
612 'order_id' => $order_model->id,
613 'customer_id' => $input_data['customer_id'],
614 'amount' => $input_data['total'] * 100,
615 'currency' => $input_data['currency'],
616 'intent_id' => $payment_intent['payment_intent'],
617 'transaction_id' => null,
618 'payment_method' => $input_data['payment_method'],
619 'status' => $payment_intent['status'] == 'success' ? 'completed' : 'pending',
620 'date' => gmdate( 'Y-m-d' ),
621 'date_time' => gmdate( 'Y-m-d H:i:s' ),
622 )
623 );
624 }
625
626 /**
627 * Helper: Format a single appointment for API output
628 *
629 * @param Appointment_Model|array $appointment
630 *
631 * @return array
632 */
633 private function format_appointment( $appointment ): array {
634 if ( is_object( $appointment ) && method_exists( $appointment, 'to_array' ) ) {
635 $appointment = $appointment->to_array();
636 }
637 $team_member = ( new Team_Member_Model() )->find( $appointment['team_member_id'] );
638 $service = ( new Service_Model() )->find( $appointment['service_id'] );
639 $service = $service ? $service->to_array() : array();
640 $additional_durations = $service['additional_durations'] ?? array();
641 $additional_durations = ! empty( $additional_durations )
642 ? array_filter(
643 $additional_durations, function ( $duration ) use ( $appointment ) {
644 return $duration['id'] === $appointment['additional_duration_id'];
645 }
646 ) : array();
647 $additional_duration = ! empty( $additional_durations ) ? reset( $additional_durations ) : array();
648 $formatted_appointment = array(
649 'id' => $appointment['id'] ?? 0,
650 'date' => $appointment['date'] ?? '',
651 'start_time' => $appointment['start_time'] ?? '',
652 'end_time' => $appointment['end_time'] ?? '',
653 'additional_duration_id' => $appointment['additional_duration_id'] ?? '',
654 'team' => array(
655 'id' => $team_member->id ?? null,
656 'display_name' => $team_member->display_name ?? '',
657 'image' => $team_member->image ?? '',
658 'user_email' => $team_member->user_email ?? '',
659 ),
660 'service' => array(
661 'id' => $appointment['service_id'] ?? '',
662 'title' => $service['title'] ?? '',
663 'additional_duration' => $additional_duration,
664 'enable_google_meet' => $service['enable_google_meet'] ?? false,
665 ),
666 'duration' => $appointment['duration'] ?? null,
667 'quantity' => $appointment['quantity'] ?? 1,
668 'price' => $appointment['price'] ?? 0,
669 'subtotal' => $appointment['subtotal'] ?? 0,
670 'total' => $appointment['total'] ?? 0,
671 'status' => $appointment['status'] ?? '',
672 'total_attendees' => $appointment['total_attendees'] ?? 1,
673 'appointment_token' => $appointment['security_token'] ?? '',
674 'cancel_note' => $appointment['cancel_note'] ?? '',
675 );
676 $formatted_appointment = apply_filters( 'booktics_appointment_pro_format', $formatted_appointment, $appointment );
677 return $formatted_appointment;
678 }
679
680 /**
681 * Helper: Format order for API output
682 *
683 * @param Order_Model $order_model
684 * @param array $input_data
685 * @param array $payment_intent
686 *
687 * @return array
688 */
689 private function format_order_response( $db, $order_model, $input_data, $payment_intent ): array {
690 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $input_data['customer_id'] ) );
691 // Ensure $order_model is an object before accessing properties
692 $order_id = is_object( $order_model ) ? $order_model->id : ( is_array( $order_model ) ? $order_model['id'] : null );
693 $appointments = ( new Appointment_Model() )->where( 'order_id', $order_id );
694 $appointment_details = array();
695 foreach ( $appointments as $appointment ) {
696 $appointment_details[] = $this->format_appointment( $appointment );
697 }
698
699 $order_data = array(
700 'id' => $order_id,
701 'order_no' => is_object( $order_model ) ? $order_model->order_no : ( $order_model['order_no'] ?? null ),
702 'customer' => array(
703 'id' => $customer->id ?? null,
704 'name' => $customer->name ?? '',
705 'phone' => $customer->phone ?? '',
706 'email' => $customer->email ?? '',
707 ),
708 'status' => is_object( $order_model ) ? $order_model->status : ( $order_model['status'] ?? null ),
709 'subtotal' => is_object( $order_model ) ? $order_model->subtotal : ( $order_model['subtotal'] ?? null ),
710 'total' => is_object( $order_model ) ? $order_model->total : ( $order_model['total'] ?? null ),
711 'payment_status' => is_object( $order_model ) ? $order_model->payment_status : ( $order_model['payment_status'] ?? null ),
712 'payment_method' => is_object( $order_model ) ? $order_model->payment_method : $order_model['payment_method'],
713 'items' => $appointment_details,
714 'payment_intent_id' => $payment_intent['payment_intent'] ?? '',
715 'client_secret' => $payment_intent['client_secret'] ?? '',
716 'total_services' => $input_data['total_services'] ?? 1,
717 );
718
719 $order_data = apply_filters( 'booktics_pro_order_format', $order_data, $order_model );
720
721 return $order_data;
722 }
723
724 /**
725 * Helper: Send notifications for order and appointment creation
726 *
727 * @param Order_Model $order_model
728 * @param int $customer_id
729 * @param array $appointments
730 *
731 * @return void
732 */
733 private function send_notifications( $db, $order_model, $customer_id, $appointments ): void {
734 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $customer_id ) );
735 foreach ( $appointments as $appointment ) {
736 $formatted_appointment = $this->format_appointment( $appointment );
737 $appointment_time = $formatted_appointment['date'] . ' ' . $formatted_appointment['start_time'];
738 $dt = new DateTime( $appointment_time, wp_timezone() );
739 $timestamp = $dt->getTimestamp();
740 $appointment_datetime = wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $timestamp, wp_timezone() );
741 $notification_flow_data_for_appointment = array(
742 'appointment_service' => $formatted_appointment['service']['title'] ?? '',
743 'appointment_team_member' => $formatted_appointment['team']['display_name'] ?? '',
744 'start_time' => $formatted_appointment['start_time'],
745 'appointment_date' => $formatted_appointment['date'],
746 'appointment_time' => $appointment_datetime,
747 'attendee_count' => $formatted_appointment['attendee_count'] ?? null,
748 'customer_name' => $customer->name ?? '',
749 'user_email' => $customer->email ?? '',
750 'appointment_team_member_email' => $formatted_appointment['team']['user_email'] ?? '',
751 );
752 $notification_flow_data_for_appointment = apply_filters( 'booktics_appointment_scheduled_notification_flow_data', $notification_flow_data_for_appointment, $formatted_appointment );
753
754 do_action( 'global_notification_hook', 'appointment_scheduled', $notification_flow_data_for_appointment );
755 }
756
757 $appointment = $appointments[0]; // As currently only one appointment is created for each user
758 $formatted_appointment = $this->format_appointment( $appointment );
759 $notification_flow_data_for_order = [
760 'order_no' => $order_model->order_no ?? '',
761 'order_date' => $order_model->created_at ?? '',
762 'order_total' => $order_model->total ?? 0,
763 'customer_name' => $customer->name ?? '',
764 'user_email' => $customer->email ?? '',
765 'order_status' => $order_model->status ?? '',
766 'service_name' => $formatted_appointment['service']['title'] ?? '',
767 'team_name' => $formatted_appointment['team']['display_name'] ?? '',
768 'start_time' => $formatted_appointment['start_time'] ?? '',
769 'end_time' => $formatted_appointment['end_time'] ?? '',
770 'appointment_date' => $formatted_appointment['date'] ?? '',
771 'location_address' => $formatted_appointment['location']['address'] ?? '',
772 'appointment_status' => $formatted_appointment['status'] ?? '',
773 'appointment_cancel_url' => $appointment->get_cancel_link(),
774 'appointment_reschedule_url' => $appointment->get_reschedule_link(),
775 ];
776
777 $notification_flow_data_for_order = apply_filters( 'booktics_order_created_notification_flow_data', $notification_flow_data_for_order, $formatted_appointment, $order_model );
778
779 do_action( 'global_notification_hook', 'order_created', $notification_flow_data_for_order );
780 }
781
782 /**
783 * Find existing customer by email or create new customer
784 *
785 * @param array $customer
786 *
787 * @return int|WP_Error
788 */
789 private function find_or_create_customer( array $customer ) {
790 $db = new Booktics_Database();
791 if ( ! isset( $customer['email'] ) ) {
792 return new WP_Error( 'invalid_email', __( 'Customer email is required', 'booktics' ) );
793 }
794 $user_login = User_Model::generate_username( $customer['email'] );
795 $allow_customer_as_wp_user = booktics_get_option( 'allow_customer_as_wp_user', false );
796 $wp_user_id = null;
797 if ( $allow_customer_as_wp_user ) {
798 $wp_user_id = $this->get_or_create_wp_user( $customer, $user_login );
799 }
800 $guest = $this->find_and_update_guest( $db, $customer, $wp_user_id );
801 if ( $guest ) {
802 return $guest->id;
803 }
804 $guest = $this->create_guest( $db, $customer, $user_login, $wp_user_id );
805
806 return $guest->id;
807 }
808
809 /**
810 * Get or create a WordPress user for the customer
811 *
812 * @param array $customer
813 * @param string $user_login
814 *
815 * @return int WP user ID
816 */
817 private function get_or_create_wp_user( $customer, $user_login ) {
818 $wp_user = get_user_by( 'email', $customer['email'] );
819 if ( $wp_user ) {
820 return $wp_user->ID;
821 }
822 $userdata = array(
823 'user_login' => $user_login,
824 'user_email' => $customer['email'],
825 'first_name' => $customer['name'] ?? '',
826 'display_name' => $customer['name'] ?? '',
827 'phone' => $customer['phone'] ?? '',
828 );
829 $customer_model = new Customer_Model();
830 $customer_id = $customer_model->save( $userdata );
831 ( new Customer_Event_Handler() )->on_customer_create( $customer_id );
832
833 return $customer_id;
834 }
835
836 /**
837 * Find and update a guest record by email
838 *
839 * @param Booktics_Database $db
840 * @param array $customer
841 *
842 * @return object|null Guest_Model instance or null
843 */
844 private function find_and_update_guest( $db, $customer, $wp_user_id = null ) {
845 $guest = ( new Guest_Model( $db ) )->find( array( 'email' => $customer['email'] ) );
846 if ( $guest ) {
847 ( new Guest_Model( $db ) )->update(
848 $guest->id, array(
849 'name' => $customer['name'] ?? '',
850 'phone' => $customer['phone'] ?? '',
851 'wp_user_id' => $wp_user_id,
852 )
853 );
854 }
855
856 return $guest;
857 }
858
859 /**
860 * Create a new guest record
861 *
862 * @param Booktics_Database $db
863 * @param array $customer
864 * @param string $user_login
865 * @param int|null $wp_user_id
866 *
867 * @return object Guest_Model instance
868 */
869 private function create_guest( $db, $customer, $user_login, $wp_user_id ) {
870 return ( new Guest_Model( $db ) )->create(
871 array(
872 'wp_user_id' => $wp_user_id,
873 'email' => $customer['email'],
874 'name' => $customer['name'] ?? '',
875 'user_login' => $user_login,
876 'phone' => $customer['phone'] ?? '',
877 )
878 );
879 }
880
881 /**
882 * Check if user has permission to get orders.
883 *
884 * @param WP_REST_Request $request
885 *
886 * @return bool
887 */
888 public function get_items_permission( $request ) {
889 return true;
890 }
891
892 /**
893 * Get paginated list of orders.
894 *
895 * @param WP_REST_Request $request
896 *
897 * @return WP_Error|WP_HTTP_Response
898 */
899 public function get_items( $request ) {
900 $page = (int) $request->get_param( 'paged' ) == 0 ? 1 : $request->get_param( 'paged' );
901 $per_page = (int) $request->get_param( 'per_page' ) == 0 ? 10 : $request->get_param( 'per_page' );
902 $payment_status = $request->get_param( 'payment_status' );
903 $payment_method = $request->get_param( 'payment_method' );
904 $order_date = $request->get_param( 'order_date' );
905 $team_member = (int) $request->get_param( 'team_member' );
906 $service = (int) $request->get_param( 'service' );
907 $search = $request->get_param( 'search' );
908
909 $db = new Booktics_Database();
910 $order_model = new Order_Model( $db );
911 $conditions = array();
912
913 if ( $payment_status ) {
914 $conditions['payment_status'] = $payment_status;
915 }
916
917 if ( $payment_method ) {
918 $conditions['payment_method'] = $payment_method;
919 }
920
921 if ( $order_date ) {
922 $conditions['DATE(created_at)'] = array( '=', $order_date );
923 }
924
925 if ( $search ) {
926 $conditions['order_no'] = array( 'LIKE', '%' . $search . '%' );
927 }
928
929 $conditions = apply_filters( 'booktics_order_filters', $conditions );
930
931 $orders = $order_model->get(
932 $conditions, $page, $per_page, array(
933 'created_at',
934 'DESC',
935 )
936 );
937
938 if ( $team_member || $service ) {
939 foreach ( $orders['items'] as $key => $order ) {
940 $appointments = ( new Appointment_Model() )->where( 'order_id', $order->id );
941 $keep_order = false;
942
943 foreach ( $appointments as $appointment ) {
944 if ( $team_member && $appointment->team_member_id == $team_member ) {
945 $keep_order = true;
946 }
947 if ( $service && $appointment->service_id == $service ) {
948 $keep_order = true;
949 }
950 }
951
952 if ( ! $keep_order && ( $team_member || $service ) ) {
953 unset( $orders['items'][ $key ] );
954 --$orders['total'];
955 }
956 }
957
958 $orders['items'] = array_values( $orders['items'] );
959 $orders['total_pages'] = ceil( $orders['total'] / $per_page );
960 }
961
962 foreach ( $orders['items'] as &$order ) {
963 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $order->customer_id ) );
964 $order->customer = array(
965 'id' => $customer ? $customer->id : null,
966 'email' => $customer ? $customer->email : '',
967 'name' => $customer ? $customer->name : '',
968 'phone' => $customer ? $customer->phone : '',
969 );
970 $order->total_services = ( new Appointment_Model() )->get_appointments_count_by_order_id( $order->id );
971 }
972
973 return $this->response( $orders, __( 'Orders fetched successfully', 'booktics' ) );
974 }
975
976 /**
977 * Check if user has permission to update order.
978 *
979 * @param WP_REST_Request $request
980 *
981 * @return bool
982 */
983 public function update_item_permission( $request ) {
984 return current_user_can( 'manage_options' );
985 }
986
987 /**
988 * Update an existing order.
989 *
990 * @param WP_REST_Request $request
991 *
992 * @return WP_Error|WP_HTTP_Response
993 */
994 public function update_item( $request ) {
995 $order_id = (int) $request->get_param( 'id' );
996 $data = json_decode( $request->get_body(), true );
997 $db = new Booktics_Database();
998 $order_model = new Order_Model( $db );
999 $order = $order_model->find( array( 'id' => $order_id ) );
1000
1001 if ( ! $order ) {
1002 return $this->error( __( 'Order not found', 'booktics' ) );
1003 }
1004
1005 $order_model->update(
1006 $order_id, array(
1007 'status' => $data['status'] ?? $order->status,
1008 'payment_status' => $data['payment_status'] ?? $order->payment_status,
1009 )
1010 );
1011
1012 if ( isset( $data['customer'] ) && isset( $data['customer']['id']) ) {
1013 ( new Guest_Model( $db ) )->update( $data['customer']['id'], $data['customer'] );
1014 }
1015
1016 $order_model = $order_model->find( array( 'id' => $order_id ) );
1017 $data['customer_id'] = $order_model->customer_id;
1018
1019 $response = $this->format_order_response($db, $order_model, $data, null);
1020
1021 return $this->response( $response, __( 'Order updated successfully', 'booktics' ) );
1022 }
1023
1024 /**
1025 * Check if user has permission to get order.
1026 *
1027 * @param WP_REST_Request $request
1028 *
1029 * @return bool
1030 */
1031 public function get_item_permission( $request ) {
1032 return true;
1033 }
1034
1035 /**
1036 * Get single order.
1037 *
1038 * @param WP_REST_Request $request
1039 *
1040 * @return WP_Error|WP_HTTP_Response
1041 */
1042 public function get_item( $request ) {
1043 $order_id = (int) $request->get_param( 'id' );
1044 $db = new Booktics_Database();
1045 $order_model = new Order_Model( $db );
1046 $order = $order_model->find( array( 'id' => $order_id ) );
1047
1048 if ( ! $order ) {
1049 return $this->error( __( 'Order not found', 'booktics' ) );
1050 }
1051
1052 // Use the format_order_response helper for consistency
1053 $input_data = array( 'customer_id' => $order->customer_id );
1054 $response = $this->format_order_response( $db, $order, $input_data, array() );
1055 // Add extra fields specific to GET if needed
1056 $response['status'] = $order->status;
1057 $response['created_at'] = $order->created_at;
1058 $response['payment_gateway'] = $order->payment_method;
1059
1060 return $this->response( $response, __( 'Order fetched successfully', 'booktics' ) );
1061 }
1062
1063 /**
1064 * Check if user has permission to update payment status.
1065 *
1066 * @param WP_REST_Request $request
1067 *
1068 * @return bool
1069 */
1070 public function update_payment_status_permission( $request ) {
1071 return current_user_can( 'manage_options' );
1072 }
1073
1074 /**
1075 * Update payment status for an order.
1076 *
1077 * @param WP_REST_Request $request
1078 *
1079 * @return WP_Error|WP_HTTP_Response
1080 */
1081 public function update_payment_status( $request ) {
1082 $data = json_decode( $request->get_body(), true );
1083 $order_id = ! empty( $data['order_id'] ) ? intval( $data['order_id'] ) : 0;
1084 $payment_status = ! empty( $data['payment_status'] ) ? $data['payment_status'] : 0;
1085 $payment_method = ! empty( $data['payment_method'] ) ? $data['payment_method'] : null;
1086
1087 $db = new Booktics_Database();
1088 $order_model = new Order_Model( $db );
1089 $order = $order_model->find( array( 'id' => $order_id ) );
1090
1091 if ( ! $order ) {
1092 return $this->error( __( 'Order not found', 'booktics' ) );
1093 }
1094
1095 if ( ! array_key_exists( $payment_method, ( new Payment_Method_Manager() )->get_payment_methods() ) ) {
1096 return $this->error( __( 'Unauthorized payment method', 'booktics' ) );
1097 }
1098
1099 try {
1100 $payment_handler = ( new Payment_Method_Manager() )->get_payment_method( $payment_method );
1101 if ( ! $payment_handler ) {
1102 return $this->error( __( 'Payment method not found', 'booktics' ) );
1103 }
1104
1105 $status = $payment_handler->get_payment_status( $order );
1106 if ( is_wp_error( $status ) ) {
1107 return $this->error( $status->get_error_message() );
1108 }
1109
1110 $order_model->update(
1111 $order_id, array(
1112 'payment_status' => $status,
1113 'transaction_id' => $data['transaction_id'] ?? null,
1114 )
1115 );
1116
1117 // Payment model status and transaction id update
1118 return $this->response(
1119 array(
1120 'payment_status' => $status,
1121 ), __( 'Payment status updated successfully', 'booktics' )
1122 );
1123 } catch ( \Exception $e ) {
1124 return $this->error( $e->getMessage() );
1125 }
1126 }
1127
1128 /**
1129 * Check if user has permission to delete order.
1130 *
1131 * @param WP_REST_Request $request
1132 *
1133 * @return bool
1134 */
1135 public function delete_item_permission( $request ) {
1136 return current_user_can( 'manage_options' );
1137 }
1138
1139 /**
1140 * Delete an order.
1141 *
1142 * @param WP_REST_Request $request
1143 *
1144 * @return WP_Error|WP_HTTP_Response
1145 */
1146 public function delete_item( $request ) {
1147 $order_id = (int) $request->get_param( 'id' );
1148 $db = new Booktics_Database();
1149 $order_model = new Order_Model( $db );
1150 $order = $order_model->find( array( 'id' => $order_id ) );
1151
1152 if ( ! $order ) {
1153 return $this->error( __( 'Order not found', 'booktics' ) );
1154 }
1155
1156 try {
1157 $db->begin_transaction();
1158
1159 // Delete related appointments
1160 $appointment_model = new Appointment_Model();
1161 $appointments = $appointment_model->where( 'order_id', $order_id );
1162 foreach ( $appointments as $appointment ) {
1163 $appointment->delete();
1164 }
1165
1166 // Delete related payments
1167 $payment_model = new Payment_Model( $db );
1168 $payments = $payment_model->get( array( 'order_id' => $order_id ) );
1169 foreach ( $payments['items'] as $payment ) {
1170 ( new Payment_Model( $db ) )->delete( $payment->id );
1171 }
1172 // Delete the order
1173 $order_model->delete( $order_id );
1174
1175 $db->commit();
1176
1177 return $this->response( array(), __( 'Order deleted successfully', 'booktics' ) );
1178 } catch ( Exception $e ) {
1179 $db->roll_back();
1180
1181 return $this->error( $e->getMessage() );
1182 }
1183 }
1184
1185 /**
1186 * Check if user has permission to bulk delete orders.
1187 *
1188 * @param WP_REST_Request $request
1189 *
1190 * @return bool
1191 */
1192 public function bulk_delete_items_permission( $request ) {
1193 return current_user_can( 'manage_options' );
1194 }
1195
1196 /**
1197 * Bulk delete orders.
1198 *
1199 * @param WP_REST_Request $request
1200 *
1201 * @return WP_Error|WP_HTTP_Response
1202 */
1203 public function bulk_delete_items( $request ) {
1204 $order_ids = array_map( 'intval', $request->get_param( 'ids' ) );
1205 $db = new Booktics_Database();
1206
1207 try {
1208 $db->begin_transaction();
1209
1210 foreach ( $order_ids as $order_id ) {
1211 $order_model = new Order_Model( $db );
1212 $order = $order_model->find( array( 'id' => $order_id ) );
1213
1214 if ( ! $order ) {
1215 continue;
1216 }
1217
1218 // Delete related appointments
1219 $appointment_model = new Appointment_Model();
1220 $appointments = $appointment_model->where( 'order_id', $order_id );
1221 foreach ( $appointments as $appointment ) {
1222 $appointment->delete();
1223 }
1224
1225 // Delete related payments
1226 $payment_model = new Payment_Model( $db );
1227 $payments = $payment_model->get( array( 'order_id' => $order_id ) );
1228 foreach ( $payments['items'] as $payment ) {
1229 ( new Payment_Model( $db ) )->delete( $payment->id );
1230 }
1231
1232 // Delete the order
1233 $order_model->delete( $order_id );
1234 }
1235
1236 $db->commit();
1237
1238 return $this->response( array(), __( 'Orders deleted successfully', 'booktics' ) );
1239 } catch ( Exception $e ) {
1240 $db->roll_back();
1241
1242 return $this->error( $e->getMessage() );
1243 }
1244 }
1245
1246 /**
1247 * Process payment refund for an order.
1248 *
1249 * @param WP_REST_Request $request
1250 *
1251 * @return WP_Error|WP_HTTP_Response
1252 */
1253 public function refund_payment( $request ) {
1254 $order_id = (int) $request->get_param( 'id' );
1255 $db = new Booktics_Database();
1256 $order_model = new Order_Model( $db );
1257 $order = $order_model->find( array( 'id' => $order_id ) );
1258
1259 $payment_method = ( new Payment_Method_Manager() )->get_payment_method( $order->payment_method );
1260 if ( ! $payment_method ) {
1261 return $this->error( __( 'Invalid payment method!', 'booktics' ) );
1262 }
1263 $response = $payment_method->process_refund( $order );
1264 if ( $response['status'] === 'error' ) {
1265 return $this->error( $response['message'] );
1266 }
1267
1268 $order_model->update(
1269 $order_id, array(
1270 'payment_status' => Payment_Status::REFUNDED,
1271 )
1272 );
1273
1274 return $this->response(
1275 $order_model->find( array( 'id' => $order_id ) ),
1276 __( 'Refund successful', 'booktics' )
1277 );
1278 }
1279
1280 /**
1281 * Calculate total duration in minutes for a service appointment.
1282 *
1283 * @param array $item
1284 *
1285 * @return int
1286 */
1287 private function calculate_total_duration( $item ) {
1288 $service_model = Service_Model::find( $item['service_id'] );
1289 if ( ! $service_model ) {
1290 return 0;
1291 }
1292 $service_duration = array_filter(
1293 $service_model->additional_durations, function ( $duration ) use ( $item ) {
1294 return $item['additional_duration_id'] === $duration['id'];
1295 }
1296 );
1297 if ( empty( $service_duration ) ) {
1298 return 0;
1299 }
1300 $service_duration = reset( $service_duration );
1301 $total_duration_min = $service_duration['duration_unit'] === 'min'
1302 ? $service_duration['duration_time']
1303 : ( $service_duration['duration_time'] * 60 );
1304 if ( ! empty( $item['extra_services'] ) ) {
1305 $total_duration_min = apply_filters( 'booktics_extra_service_duration', $total_duration_min, $item['extra_services'] );
1306 }
1307
1308 $total_duration_min = apply_filters( 'booktics_calculate_total_duration', $total_duration_min, $item );
1309
1310 return $total_duration_min;
1311 }
1312 }
1313