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

1,530 lines 60.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 * Resolve team member email with WP user fallback.
642 *
643 * Model hydration may miss user_email if the team-member WP user row is
644 * stale or if find() returned null. Fall back to get_userdata so the
645 * notification SDK does not skip the team email node.
646 */
647 private function resolve_team_member_email( string $primary, $team_member_id ): string {
648 if ( ! empty( $primary ) ) {
649 return $primary;
650 }
651 $team_member_id = absint( $team_member_id );
652 if ( ! $team_member_id ) {
653 return '';
654 }
655 $user = get_userdata( $team_member_id );
656 return $user && ! empty( $user->user_email ) ? $user->user_email : '';
657 }
658
659 /**
660 * Helper: Format a single appointment for API output
661 *
662 * @param Appointment_Model|array $appointment
663 *
664 * @return array
665 */
666 private function format_appointment( $appointment ): array {
667 if ( is_object( $appointment ) && method_exists( $appointment, 'to_array' ) ) {
668 $appointment = $appointment->to_array();
669 }
670 $team_member = ( new Team_Member_Model() )->find( $appointment['team_member_id'] );
671 $service = ( new Service_Model() )->find( $appointment['service_id'] );
672 $service = $service ? $service->to_array() : array();
673 $additional_durations = $service['additional_durations'] ?? array();
674 $additional_durations = ! empty( $additional_durations )
675 ? array_filter(
676 $additional_durations, function ( $duration ) use ( $appointment ) {
677 return $duration['id'] === $appointment['additional_duration_id'];
678 }
679 ) : array();
680 $additional_duration = ! empty( $additional_durations ) ? reset( $additional_durations ) : array();
681 $formatted_appointment = array(
682 'id' => $appointment['id'] ?? 0,
683 'date' => $appointment['date'] ?? '',
684 'start_time' => $appointment['start_time'] ?? '',
685 'end_time' => $appointment['end_time'] ?? '',
686 'additional_duration_id' => $appointment['additional_duration_id'] ?? '',
687 'team' => array(
688 'id' => $team_member->id ?? null,
689 'display_name' => $team_member->display_name ?? '',
690 'image' => $team_member->image ?? '',
691 'user_email' => $team_member->user_email ?? '',
692 ),
693 'service' => array(
694 'id' => $appointment['service_id'] ?? '',
695 'title' => $service['title'] ?? '',
696 'additional_duration' => $additional_duration,
697 'enable_google_meet' => $service['enable_google_meet'] ?? false,
698 ),
699 'duration' => $appointment['duration'] ?? null,
700 'quantity' => $appointment['quantity'] ?? 1,
701 'price' => $appointment['price'] ?? 0,
702 'subtotal' => $appointment['subtotal'] ?? 0,
703 'total' => $appointment['total'] ?? 0,
704 'status' => $appointment['status'] ?? '',
705 'total_attendees' => $appointment['total_attendees'] ?? 1,
706 'appointment_token' => $appointment['security_token'] ?? '',
707 'cancel_note' => $appointment['cancel_note'] ?? '',
708 );
709 $formatted_appointment = apply_filters( 'booktics_appointment_pro_format', $formatted_appointment, $appointment );
710 return $formatted_appointment;
711 }
712
713 /**
714 * Helper: Format order for API output
715 *
716 * @param Order_Model $order_model
717 * @param array $input_data
718 * @param array $payment_intent
719 *
720 * @return array
721 */
722 private function format_order_response( $db, $order_model, $input_data, $payment_intent ): array {
723 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $input_data['customer_id'] ) );
724 // Ensure $order_model is an object before accessing properties
725 $order_id = is_object( $order_model ) ? $order_model->id : ( is_array( $order_model ) ? $order_model['id'] : null );
726 $appointments = ( new Appointment_Model() )->where( 'order_id', $order_id );
727 $appointment_details = array();
728 foreach ( $appointments as $appointment ) {
729 $appointment_details[] = $this->format_appointment( $appointment );
730 }
731
732 $order_no_for_key = is_object( $order_model ) ? $order_model->order_no : ( $order_model['order_no'] ?? '' );
733
734 $order_data = array(
735 'id' => $order_id,
736 'order_no' => is_object( $order_model ) ? $order_model->order_no : ( $order_model['order_no'] ?? null ),
737 'order_key' => $this->get_order_view_key( (int) $order_id, (string) $order_no_for_key ),
738 'customer' => array(
739 'id' => $customer->id ?? null,
740 'name' => $customer->name ?? '',
741 'phone' => $customer->phone ?? '',
742 'email' => $customer->email ?? '',
743 ),
744 'status' => is_object( $order_model ) ? $order_model->status : ( $order_model['status'] ?? null ),
745 'subtotal' => is_object( $order_model ) ? $order_model->subtotal : ( $order_model['subtotal'] ?? null ),
746 'total' => is_object( $order_model ) ? $order_model->total : ( $order_model['total'] ?? null ),
747 'payment_status' => is_object( $order_model ) ? $order_model->payment_status : ( $order_model['payment_status'] ?? null ),
748 'payment_method' => is_object( $order_model ) ? $order_model->payment_method : $order_model['payment_method'],
749 'items' => $appointment_details,
750 'payment_intent_id' => $payment_intent['payment_intent'] ?? '',
751 'client_secret' => $payment_intent['client_secret'] ?? '',
752 'total_services' => $input_data['total_services'] ?? 1,
753 );
754
755 $order_data = apply_filters( 'booktics_pro_order_format', $order_data, $order_model );
756
757 return $order_data;
758 }
759
760 /**
761 * Helper: Send notifications for order and appointment creation
762 *
763 * @param Order_Model $order_model
764 * @param int $customer_id
765 * @param array $appointments
766 *
767 * @return void
768 */
769 private function send_notifications( $db, $order_model, $customer_id, $appointments ): void {
770 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $customer_id ) );
771 foreach ( $appointments as $appointment ) {
772 $formatted_appointment = $this->format_appointment( $appointment );
773 $appointment_time = $formatted_appointment['date'] . ' ' . $formatted_appointment['start_time'];
774 $dt = new DateTime( $appointment_time, wp_timezone() );
775 $timestamp = $dt->getTimestamp();
776 $appointment_datetime = wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $timestamp, wp_timezone() );
777 $appointment_array = is_object( $appointment ) && method_exists( $appointment, 'to_array' ) ? $appointment->to_array() : (array) $appointment;
778 $team_member_email = $this->resolve_team_member_email(
779 $formatted_appointment['team']['user_email'] ?? '',
780 $appointment_array['team_member_id'] ?? 0
781 );
782 $notification_flow_data_for_appointment = array(
783 'appointment_service' => $formatted_appointment['service']['title'] ?? '',
784 'appointment_team_member' => $formatted_appointment['team']['display_name'] ?? '',
785 'start_time' => $formatted_appointment['start_time'],
786 'appointment_date' => $formatted_appointment['date'],
787 'appointment_time' => $appointment_datetime,
788 'attendee_count' => $formatted_appointment['attendee_count'] ?? null,
789 'customer_name' => $customer->name ?? '',
790 'user_email' => $customer->email ?? '',
791 'appointment_team_member_email' => $team_member_email,
792 );
793 $notification_flow_data_for_appointment = apply_filters( 'booktics_appointment_scheduled_notification_flow_data', $notification_flow_data_for_appointment, $formatted_appointment );
794
795 do_action( 'booktics_gln_hook', 'appointment_scheduled', $notification_flow_data_for_appointment );
796 }
797
798 $appointment = $appointments[0]; // As currently only one appointment is created for each user
799 $formatted_appointment = $this->format_appointment( $appointment );
800 $notification_flow_data_for_order = [
801 'order_no' => $order_model->order_no ?? '',
802 'order_date' => $order_model->created_at ?? '',
803 'order_total' => $order_model->total ?? 0,
804 'customer_name' => $customer->name ?? '',
805 'user_email' => $customer->email ?? '',
806 'order_status' => $order_model->status ?? '',
807 'service_name' => $formatted_appointment['service']['title'] ?? '',
808 'team_name' => $formatted_appointment['team']['display_name'] ?? '',
809 'start_time' => $formatted_appointment['start_time'] ?? '',
810 'end_time' => $formatted_appointment['end_time'] ?? '',
811 'appointment_date' => $formatted_appointment['date'] ?? '',
812 'location_address' => $formatted_appointment['location']['address'] ?? '',
813 'appointment_status' => $formatted_appointment['status'] ?? '',
814 'appointment_cancel_url' => $appointment->get_cancel_link(),
815 'appointment_reschedule_url' => $appointment->get_reschedule_link(),
816 ];
817
818 $notification_flow_data_for_order = apply_filters( 'booktics_order_created_notification_flow_data', $notification_flow_data_for_order, $formatted_appointment, $order_model );
819
820 do_action( 'booktics_gln_hook', 'order_created', $notification_flow_data_for_order );
821 }
822
823 /**
824 * Find existing customer by email or create new customer
825 *
826 * @param array $customer
827 *
828 * @return int|WP_Error
829 */
830 private function find_or_create_customer( array $customer ) {
831 $db = new Booktics_Database();
832 if ( ! isset( $customer['email'] ) ) {
833 return new WP_Error( 'invalid_email', __( 'Customer email is required', 'booktics' ) );
834 }
835 $user_login = User_Model::generate_username( $customer['email'] );
836 $allow_customer_as_wp_user = booktics_get_option( 'allow_customer_as_wp_user', false );
837 $wp_user_id = null;
838 if ( $allow_customer_as_wp_user ) {
839 $wp_user_id = $this->get_or_create_wp_user( $customer, $user_login );
840 }
841 $guest = $this->find_and_update_guest( $db, $customer, $wp_user_id );
842 if ( $guest ) {
843 return $guest->id;
844 }
845 $guest = $this->create_guest( $db, $customer, $user_login, $wp_user_id );
846
847 return $guest->id;
848 }
849
850 /**
851 * Get or create a WordPress user for the customer
852 *
853 * @param array $customer
854 * @param string $user_login
855 *
856 * @return int WP user ID
857 */
858 private function get_or_create_wp_user( $customer, $user_login ) {
859 $wp_user = get_user_by( 'email', $customer['email'] );
860 if ( $wp_user ) {
861 return $wp_user->ID;
862 }
863 $userdata = array(
864 'user_login' => $user_login,
865 'user_email' => $customer['email'],
866 'first_name' => $customer['name'] ?? '',
867 'display_name' => $customer['name'] ?? '',
868 'phone' => $customer['phone'] ?? '',
869 );
870 $customer_model = new Customer_Model();
871 $customer_id = $customer_model->save( $userdata );
872 ( new Customer_Event_Handler() )->on_customer_create( $customer_id );
873
874 return $customer_id;
875 }
876
877 /**
878 * Find and update a guest record by email
879 *
880 * @param Booktics_Database $db
881 * @param array $customer
882 *
883 * @return object|null Guest_Model instance or null
884 */
885 private function find_and_update_guest( $db, $customer, $wp_user_id = null ) {
886 $guest = ( new Guest_Model( $db ) )->find( array( 'email' => $customer['email'] ) );
887 if ( $guest ) {
888 ( new Guest_Model( $db ) )->update(
889 $guest->id, array(
890 'name' => $customer['name'] ?? '',
891 'phone' => $customer['phone'] ?? '',
892 'wp_user_id' => $wp_user_id,
893 )
894 );
895 }
896
897 return $guest;
898 }
899
900 /**
901 * Create a new guest record
902 *
903 * @param Booktics_Database $db
904 * @param array $customer
905 * @param string $user_login
906 * @param int|null $wp_user_id
907 *
908 * @return object Guest_Model instance
909 */
910 private function create_guest( $db, $customer, $user_login, $wp_user_id ) {
911 return ( new Guest_Model( $db ) )->create(
912 array(
913 'wp_user_id' => $wp_user_id,
914 'email' => $customer['email'],
915 'name' => $customer['name'] ?? '',
916 'user_login' => $user_login,
917 'phone' => $customer['phone'] ?? '',
918 )
919 );
920 }
921
922 /**
923 * Check if user has permission to list orders.
924 *
925 * Anonymous access is denied: the collection handler scopes to the current customer or team member;
926 * unauthenticated callers would otherwise receive unscoped results. Single-order GET is allowed for
927 * guests when {@see get_item_permission()} validates a matching order_key.
928 *
929 * @param WP_REST_Request $request
930 *
931 * @return bool|WP_Error
932 */
933 public function get_items_permission( $request ) {
934 if ( is_user_logged_in() ) {
935 return true;
936 }
937
938 return new WP_Error(
939 'booktics_forbidden',
940 __( 'You are not allowed to view orders.', 'booktics' ),
941 array( 'status' => 403 )
942 );
943 }
944
945 /**
946 * Get paginated list of orders.
947 *
948 * @param WP_REST_Request $request
949 *
950 * @return WP_Error|WP_HTTP_Response
951 */
952 public function get_items( $request ) {
953 $page = (int) $request->get_param( 'paged' ) === 0 ? 1 : absint( $request->get_param( 'paged' ) );
954 $per_page = (int) $request->get_param( 'per_page' ) === 0 ? 10 : absint( $request->get_param( 'per_page' ) );
955 $payment_status = sanitize_text_field( $request->get_param( 'payment_status' ) );
956 $payment_method = sanitize_text_field( $request->get_param( 'payment_method' ) );
957 $order_date = sanitize_text_field( $request->get_param( 'order_date' ) );
958 $team_member = absint( $request->get_param( 'team_member' ) );
959 $service = absint( $request->get_param( 'service' ) );
960 $search = sanitize_text_field( $request->get_param( 'search' ) );
961
962 $db = new Booktics_Database();
963 $order_model = new Order_Model( $db );
964 $conditions = array();
965
966 if ( $payment_status ) {
967 $conditions['payment_status'] = $payment_status;
968 }
969
970 if ( $payment_method ) {
971 $conditions['payment_method'] = $payment_method;
972 }
973
974 if ( $order_date ) {
975 $conditions['DATE(created_at)'] = array( '=', $order_date );
976 }
977
978 if ( $search ) {
979 $conditions['order_no'] = array( 'LIKE', '%' . $search . '%' );
980 }
981
982 $conditions = apply_filters( 'booktics_order_filters', $conditions );
983
984 // Scope to customer's own orders when not admin/team-member
985 if ( ! booktics_is_admin_or_team_member() && is_user_logged_in() ) {
986 $guest = ( new Guest_Model( $db ) )->find( array( 'wp_user_id' => get_current_user_id() ) );
987 if ( ! $guest ) {
988 return $this->response(
989 array( 'items' => array(), 'total' => 0, 'total_pages' => 0 ),
990 __( 'Orders fetched successfully', 'booktics' )
991 );
992 }
993 $conditions['customer_id'] = $guest->id;
994 }
995
996 // Scope to team member's assigned orders - only return orders that contain
997 // at least one appointment where team_member_id equals the current user's team member ID
998 $is_team_member_scope = false;
999 $team_member_filter_id = null;
1000 if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1001 $current_user_id = get_current_user_id();
1002 $team_member_model = new Team_Member_Model();
1003 $team_member = $team_member_model->find( array( 'wp_user_id' => $current_user_id ) );
1004 if ( $team_member ) {
1005 $is_team_member_scope = true;
1006 $team_member_filter_id = $team_member->id;
1007 }
1008 }
1009
1010 $orders = $order_model->get(
1011 $conditions, $page, $per_page, array(
1012 'created_at',
1013 'DESC',
1014 )
1015 );
1016
1017 if ( $team_member || $service ) {
1018 foreach ( $orders['items'] as $key => $order ) {
1019 $appointments = ( new Appointment_Model() )->where( 'order_id', $order->id );
1020 $keep_order = false;
1021
1022 foreach ( $appointments as $appointment ) {
1023 if ( $team_member && $appointment->team_member_id == $team_member ) {
1024 $keep_order = true;
1025 }
1026 if ( $service && $appointment->service_id == $service ) {
1027 $keep_order = true;
1028 }
1029 }
1030
1031 if ( ! $keep_order && ( $team_member || $service ) ) {
1032 unset( $orders['items'][ $key ] );
1033 --$orders['total'];
1034 }
1035 }
1036
1037 $orders['items'] = array_values( $orders['items'] );
1038 $orders['total_pages'] = ceil( $orders['total'] / $per_page );
1039 }
1040
1041 foreach ( $orders['items'] as &$order ) {
1042 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $order->customer_id ) );
1043 $customer_data_for_order = [
1044 'id' => $customer ? $customer->id : null,
1045 'name' => $customer ? $customer->name : '',
1046 'email' => '',
1047 'phone' => '',
1048 ];
1049 // Admins and team members see email and phone
1050 if ( booktics_is_admin_or_team_member() ) {
1051 $customer_data_for_order['email'] = $customer ? $customer->email : '';
1052 $customer_data_for_order['phone'] = $customer ? $customer->phone : '';
1053 }
1054 $order->customer = $customer_data_for_order;
1055 $order->total_services = ( new Appointment_Model() )->get_appointments_count_by_order_id( $order->id );
1056
1057 // Hide payment intent fields from team members (not admins)
1058 if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1059 $order->payment_intent_id = '';
1060 $order->client_secret = '';
1061 }
1062 }
1063
1064 return $this->response( $orders, __( 'Orders fetched successfully', 'booktics' ) );
1065 }
1066
1067 /**
1068 * Check if user has permission to update order.
1069 *
1070 * @param WP_REST_Request $request
1071 *
1072 * @return bool
1073 */
1074 public function update_item_permission( $request ) {
1075 return current_user_can( 'manage_options' );
1076 }
1077
1078 /**
1079 * Update an existing order.
1080 *
1081 * @param WP_REST_Request $request
1082 *
1083 * @return WP_Error|WP_HTTP_Response
1084 */
1085 public function update_item( $request ) {
1086 $order_id = absint( $request->get_param( 'id' ) );
1087 $data = json_decode( $request->get_body(), true );
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 $status = isset( $data['status'] ) ? sanitize_text_field( $data['status'] ) : $order->status;
1097 $payment_status = isset( $data['payment_status'] ) ? sanitize_text_field( $data['payment_status'] ) : $order->payment_status;
1098
1099 $order_model->update(
1100 $order_id, array(
1101 'status' => $status,
1102 'payment_status' => $payment_status,
1103 )
1104 );
1105
1106 if ( isset( $data['customer'] ) && isset( $data['customer']['id'] ) ) {
1107 $customer_id = absint( $data['customer']['id'] );
1108 $customer_data = $data['customer'];
1109 $customer_data['id'] = $customer_id;
1110 if ( isset( $customer_data['name'] ) ) {
1111 $customer_data['name'] = sanitize_text_field( $customer_data['name'] );
1112 }
1113 if ( isset( $customer_data['email'] ) ) {
1114 $customer_data['email'] = sanitize_email( $customer_data['email'] );
1115 }
1116 if ( isset( $customer_data['phone'] ) ) {
1117 $customer_data['phone'] = sanitize_text_field( $customer_data['phone'] );
1118 }
1119 ( new Guest_Model( $db ) )->update( $customer_id, $customer_data );
1120 }
1121
1122 $order_model = $order_model->find( array( 'id' => $order_id ) );
1123 $data['customer_id'] = $order_model->customer_id;
1124
1125 $response = $this->format_order_response($db, $order_model, $data, null);
1126
1127 // Hide payment intent fields from team members (not admins)
1128 if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1129 $response['payment_intent_id'] = '';
1130 $response['client_secret'] = '';
1131 }
1132
1133 return $this->response( $response, __( 'Order updated successfully', 'booktics' ) );
1134 }
1135
1136 /**
1137 * Check if user has permission to get order.
1138 *
1139 * Staff always allowed. Logged-in customers allowed when the order belongs to their guest record.
1140 * Anonymous users allowed only when the request includes a valid order_key matching the order (same
1141 * value returned when the order was created).
1142 *
1143 * @param WP_REST_Request $request
1144 *
1145 * @return bool|WP_Error
1146 */
1147 public function get_item_permission( $request ) {
1148 if ( booktics_is_admin_or_team_member() ) {
1149 return true;
1150 }
1151
1152 $order_id = absint( $request->get_param( 'id' ) );
1153 $db = new Booktics_Database();
1154 $order = ( new Order_Model( $db ) )->find( array( 'id' => $order_id ) );
1155
1156 if ( ! $order ) {
1157 return true;
1158 }
1159
1160 if ( is_user_logged_in() ) {
1161 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $order->customer_id ) );
1162
1163 if ( $customer && intval( $customer->wp_user_id ) === get_current_user_id() ) {
1164 return true;
1165 }
1166 }
1167
1168 $order_key = sanitize_text_field( (string) $request->get_param( 'order_key' ) );
1169 if ( $order_key && $this->verify_order_view_key( $order_id, (string) $order->order_no, $order_key ) ) {
1170 return true;
1171 }
1172
1173 return new WP_Error(
1174 'booktics_forbidden',
1175 __( 'You are not allowed to view this order.', 'booktics' ),
1176 array( 'status' => 403 )
1177 );
1178 }
1179
1180 /**
1181 * Non-secret, unguessable key for viewing an order without logging in (returned on create and with GET responses).
1182 *
1183 * @param int $order_id
1184 * @param string $order_no
1185 *
1186 * @return string
1187 */
1188 private function get_order_view_key( $order_id, $order_no ) {
1189 return hash_hmac( 'sha256', (string) $order_id . '|' . $order_no, wp_salt( 'booktics_order_view' ) );
1190 }
1191
1192 /**
1193 * @param int $order_id
1194 * @param string $order_no
1195 * @param string $provided_key
1196 *
1197 * @return bool
1198 */
1199 private function verify_order_view_key( $order_id, $order_no, $provided_key ) {
1200 if ( '' === $provided_key || strlen( $provided_key ) > 128 ) {
1201 return false;
1202 }
1203
1204 $expected = $this->get_order_view_key( $order_id, $order_no );
1205
1206 return hash_equals( $expected, $provided_key );
1207 }
1208
1209 /**
1210 * Get single order.
1211 *
1212 * @param WP_REST_Request $request
1213 *
1214 * @return WP_Error|WP_HTTP_Response
1215 */
1216 public function get_item( $request ) {
1217 $order_id = absint( $request->get_param( 'id' ) );
1218 $db = new Booktics_Database();
1219 $order_model = new Order_Model( $db );
1220 $order = $order_model->find( array( 'id' => $order_id ) );
1221
1222 if ( ! $order ) {
1223 return $this->error( __( 'Order not found', 'booktics' ) );
1224 }
1225
1226 // Use the format_order_response helper for consistency
1227 $input_data = array( 'customer_id' => $order->customer_id );
1228 $response = $this->format_order_response( $db, $order, $input_data, array() );
1229 // Add extra fields specific to GET if needed
1230 $response['status'] = $order->status;
1231 $response['created_at'] = $this->format_datetime_to_dayjs_format( $order->created_at );
1232 $response['payment_gateway'] = $order->payment_method;
1233
1234 // Hide payment intent fields from team members (not admins)
1235 if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1236 $response['payment_intent_id'] = '';
1237 $response['client_secret'] = '';
1238 }
1239
1240 return $this->response( $response, __( 'Order fetched successfully', 'booktics' ) );
1241 }
1242
1243 /**
1244 * Check if user has permission to update payment status.
1245 *
1246 * @param WP_REST_Request $request
1247 *
1248 * @return bool
1249 */
1250 public function update_payment_status_permission( $request ) {
1251 if ( current_user_can( 'manage_options' ) ) {
1252 return true;
1253 }
1254
1255 // Allow order owner with a valid order_key (guest checkout support).
1256 $order_id = absint( $request->get_param( 'id' ) );
1257 $order_key = sanitize_text_field( (string) $request->get_param( 'order_key' ) );
1258
1259 if ( ! $order_id || ! $order_key ) {
1260 return false;
1261 }
1262
1263 $db = new Booktics_Database();
1264 $order = ( new Order_Model( $db ) )->find( array( 'id' => $order_id ) );
1265
1266 if ( ! $order ) {
1267 return false;
1268 }
1269
1270 return $this->verify_order_view_key( $order_id, (string) $order->order_no, $order_key );
1271 }
1272
1273 /**
1274 * Update payment status for an order.
1275 *
1276 * @param WP_REST_Request $request
1277 *
1278 * @return WP_Error|WP_HTTP_Response
1279 */
1280 public function update_payment_status( $request ) {
1281 $data = json_decode( $request->get_body(), true );
1282 $order_id = ! empty( $data['order_id'] ) ? intval( $data['order_id'] ) : 0;
1283 $payment_status = ! empty( $data['payment_status'] ) ? $data['payment_status'] : 0;
1284 $payment_method = ! empty( $data['payment_method'] ) ? $data['payment_method'] : null;
1285
1286 $db = new Booktics_Database();
1287 $order_model = new Order_Model( $db );
1288 $order = $order_model->find( array( 'id' => $order_id ) );
1289
1290 if ( ! $order ) {
1291 return $this->error( __( 'Order not found', 'booktics' ) );
1292 }
1293
1294 if ( ! array_key_exists( $payment_method, ( new Payment_Method_Manager() )->get_payment_methods() ) ) {
1295 return $this->error( __( 'Unauthorized payment method', 'booktics' ) );
1296 }
1297
1298 try {
1299 $payment_handler = ( new Payment_Method_Manager() )->get_payment_method( $payment_method );
1300 if ( ! $payment_handler ) {
1301 return $this->error( __( 'Payment method not found', 'booktics' ) );
1302 }
1303
1304 $status = $payment_handler->get_payment_status( $order );
1305 if ( is_wp_error( $status ) ) {
1306 return $this->error( $status->get_error_message() );
1307 }
1308
1309 $order_model->update(
1310 $order_id, array(
1311 'payment_status' => $status,
1312 'transaction_id' => $data['transaction_id'] ?? null,
1313 )
1314 );
1315
1316 // Payment model status and transaction id update
1317 return $this->response(
1318 array(
1319 'payment_status' => $status,
1320 ), __( 'Payment status updated successfully', 'booktics' )
1321 );
1322 } catch ( \Exception $e ) {
1323
1324 if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
1325 error_log( 'An error occurred while deleting orders:' . $e->getMessage() );
1326 }
1327 return $this->error( __( 'An error occurred while deleting orders.', 'booktics' ) );
1328 }
1329 }
1330
1331 /**
1332 * Check if user has permission to delete order.
1333 *
1334 * @param WP_REST_Request $request
1335 *
1336 * @return bool
1337 */
1338 public function delete_item_permission( $request ) {
1339 return current_user_can( 'manage_options' );
1340 }
1341
1342 /**
1343 * Delete an order.
1344 *
1345 * @param WP_REST_Request $request
1346 *
1347 * @return WP_Error|WP_HTTP_Response
1348 */
1349 public function delete_item( $request ) {
1350 $order_id = absint( $request->get_param( 'id' ) );
1351 $db = new Booktics_Database();
1352 $order_model = new Order_Model( $db );
1353 $order = $order_model->find( array( 'id' => $order_id ) );
1354
1355 if ( ! $order ) {
1356 return $this->error( __( 'Order not found', 'booktics' ) );
1357 }
1358
1359 try {
1360 $db->begin_transaction();
1361
1362 // Delete related appointments
1363 $appointment_model = new Appointment_Model();
1364 $appointments = $appointment_model->where( 'order_id', $order_id );
1365 foreach ( $appointments as $appointment ) {
1366 $appointment->delete();
1367 }
1368
1369 // Delete related payments
1370 $payment_model = new Payment_Model( $db );
1371 $payments = $payment_model->get( array( 'order_id' => $order_id ) );
1372 foreach ( $payments['items'] as $payment ) {
1373 ( new Payment_Model( $db ) )->delete( $payment->id );
1374 }
1375 // Delete the order
1376 $order_model->delete( $order_id );
1377
1378 $db->commit();
1379
1380 return $this->response( array(), __( 'Order deleted successfully', 'booktics' ) );
1381 } catch ( Exception $e ) {
1382 $db->roll_back();
1383
1384 return $this->error( $e->getMessage() );
1385 }
1386 }
1387
1388 /**
1389 * Check if user has permission to bulk delete orders.
1390 *
1391 * @param WP_REST_Request $request
1392 *
1393 * @return bool
1394 */
1395 public function bulk_delete_items_permission( $request ) {
1396 return current_user_can( 'manage_options' );
1397 }
1398
1399 /**
1400 * Bulk delete orders.
1401 *
1402 * @param WP_REST_Request $request
1403 *
1404 * @return WP_Error|WP_HTTP_Response
1405 */
1406 public function bulk_delete_items( $request ) {
1407 $ids_param = $request->get_param( 'ids' );
1408 $order_ids = is_array( $ids_param ) ? array_map( 'absint', $ids_param ) : array();
1409 $db = new Booktics_Database();
1410
1411 try {
1412 $db->begin_transaction();
1413
1414 foreach ( $order_ids as $order_id ) {
1415 $order_model = new Order_Model( $db );
1416 $order = $order_model->find( array( 'id' => $order_id ) );
1417
1418 if ( ! $order ) {
1419 continue;
1420 }
1421
1422 // Delete related appointments
1423 $appointment_model = new Appointment_Model();
1424 $appointments = $appointment_model->where( 'order_id', $order_id );
1425 foreach ( $appointments as $appointment ) {
1426 $appointment->delete();
1427 }
1428
1429 // Delete related payments
1430 $payment_model = new Payment_Model( $db );
1431 $payments = $payment_model->get( array( 'order_id' => $order_id ) );
1432 foreach ( $payments['items'] as $payment ) {
1433 ( new Payment_Model( $db ) )->delete( $payment->id );
1434 }
1435
1436 // Delete the order
1437 $order_model->delete( $order_id );
1438 }
1439
1440 $db->commit();
1441
1442 return $this->response( array(), __( 'Orders deleted successfully', 'booktics' ) );
1443 } catch ( Exception $e ) {
1444 $db->roll_back();
1445
1446 return $this->error( $e->getMessage() );
1447 }
1448 }
1449
1450 /**
1451 * Process payment refund for an order.
1452 *
1453 * @param WP_REST_Request $request
1454 *
1455 * @return WP_Error|WP_HTTP_Response
1456 */
1457 public function refund_payment( $request ) {
1458 $order_id = absint( $request->get_param( 'id' ) );
1459 $db = new Booktics_Database();
1460 $order_model = new Order_Model( $db );
1461 $order = $order_model->find( array( 'id' => $order_id ) );
1462
1463 $payment_method = ( new Payment_Method_Manager() )->get_payment_method( $order->payment_method );
1464 if ( ! $payment_method ) {
1465 return $this->error( __( 'Invalid payment method!', 'booktics' ) );
1466 }
1467 $response = $payment_method->process_refund( $order );
1468 if ( $response['status'] === 'error' ) {
1469 return $this->error( $response['message'] );
1470 }
1471
1472 $order_model->update(
1473 $order_id, array(
1474 'payment_status' => Payment_Status::REFUNDED,
1475 )
1476 );
1477
1478 return $this->response(
1479 $order_model->find( array( 'id' => $order_id ) ),
1480 __( 'Refund successful', 'booktics' )
1481 );
1482 }
1483
1484 /**
1485 * Calculate total duration in minutes for a service appointment.
1486 *
1487 * @param array $item
1488 *
1489 * @return int
1490 */
1491 private function calculate_total_duration( $item ) {
1492 $service_model = Service_Model::find( $item['service_id'] );
1493 if ( ! $service_model ) {
1494 return 0;
1495 }
1496 $service_duration = array_filter(
1497 $service_model->additional_durations, function ( $duration ) use ( $item ) {
1498 return $item['additional_duration_id'] === $duration['id'];
1499 }
1500 );
1501 if ( empty( $service_duration ) ) {
1502 return 0;
1503 }
1504 $service_duration = reset( $service_duration );
1505 $total_duration_min = $service_duration['duration_unit'] === 'min'
1506 ? $service_duration['duration_time']
1507 : ( $service_duration['duration_time'] * 60 );
1508 if ( ! empty( $item['extra_services'] ) ) {
1509 $total_duration_min = apply_filters( 'booktics_extra_service_duration', $total_duration_min, $item['extra_services'] );
1510 }
1511
1512 $total_duration_min = apply_filters( 'booktics_calculate_total_duration', $total_duration_min, $item );
1513
1514 return $total_duration_min;
1515 }
1516
1517 /**
1518 * Converts a datetime string to ISO 8601 format with WordPress timezone for dayjs compatibility
1519 *
1520 * @param string $datetime Datetime string from database (e.g., "2024-01-15 14:00:00")
1521 *
1522 * @return string ISO 8601 formatted datetime with timezone offset (e.g., "2024-01-15T14:00:00-05:00")
1523 */
1524 private function format_datetime_to_dayjs_format( $datetime ) {
1525 $dt = new DateTime( $datetime );
1526 $dt->setTimezone( wp_timezone() );
1527 return $dt->format( 'Y-m-d H:i:s' );
1528 }
1529 }
1530