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

1,566 lines 61.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\Order\Services\Order_Key;
19 use Booktics\Order\Services\Order_Notifier;
20 use Booktics\Order\Services\Abandoned_Booking_Cleanup;
21 use Booktics\Payment\Payment_Method_Manager;
22 use Booktics\Payment\Payment_Status;
23 use Booktics\Services\Time_Zone_Handler;
24 use DateTime;
25 use Exception;
26 use WP_Error;
27 use WP_HTTP_Response;
28 use WP_REST_Request;
29 use WP_REST_Server;
30
31 /**
32 * Order controller
33 *
34 * @package Booktics/Order
35 */
36 class Order_Controller extends Base_Rest_Controller {
37
38 /**
39 * Endpoint namespace
40 *
41 * @var string
42 */
43 protected $namespace = 'booktics/v1';
44
45 /**
46 * Route name
47 *
48 * @var string
49 */
50 protected $base = 'orders';
51
52 /**
53 * Register REST API routes for orders.
54 *
55 * @return void
56 */
57 public function register_routes(): void {
58 register_rest_route(
59 $this->namespace, $this->base, array(
60 array(
61 'methods' => WP_REST_Server::CREATABLE,
62 'callback' => array( $this, 'create_item' ),
63 'permission_callback' => array(
64 $this,
65 'create_order_permission',
66 ),
67 ),
68 array(
69 'methods' => WP_REST_Server::READABLE,
70 'callback' => array( $this, 'get_items' ),
71 'permission_callback' => array(
72 $this,
73 'get_items_permission',
74 ),
75 'args' => array(
76 'page' => array( 'default' => 1 ),
77 'per_page' => array( 'default' => 10 ),
78 ),
79 ),
80 array(
81 'methods' => WP_REST_Server::DELETABLE,
82 'callback' => array(
83 $this,
84 'bulk_delete_items',
85 ),
86 'permission_callback' => array(
87 $this,
88 'bulk_delete_items_permission',
89 ),
90 ),
91 )
92 );
93
94 register_rest_route(
95 $this->namespace, $this->base . '/(?P<id>[\d]+)', array(
96 array(
97 'methods' => WP_REST_Server::EDITABLE,
98 'callback' => array( $this, 'update_item' ),
99 'permission_callback' => array(
100 $this,
101 'update_item_permission',
102 ),
103 'args' => array(
104 'id' => array( 'required' => true ),
105 'status' => array( 'type' => 'string' ),
106 ),
107 ),
108 array(
109 'methods' => WP_REST_Server::READABLE,
110 'callback' => array( $this, 'get_item' ),
111 'permission_callback' => array(
112 $this,
113 'get_item_permission',
114 ),
115 'args' => array(
116 'id' => array( 'required' => true ),
117 'order_key' => array(
118 'description' => __( 'Secret key that proves the client may view this order without logging in (returned when the order is created).', 'booktics' ),
119 'type' => 'string',
120 'required' => false,
121 ),
122 ),
123 ),
124 array(
125 'methods' => WP_REST_Server::DELETABLE,
126 'callback' => array( $this, 'delete_item' ),
127 'permission_callback' => array(
128 $this,
129 'delete_item_permission',
130 ),
131 'args' => array(
132 'id' => array( 'required' => true ),
133 ),
134 ),
135 )
136 );
137
138 register_rest_route(
139 $this->namespace, $this->base . '/(?P<id>[\d]+)/payment', array(
140 array(
141 'methods' => WP_REST_Server::EDITABLE,
142 'callback' => array(
143 $this,
144 'update_payment_status',
145 ),
146 'permission_callback' => array(
147 $this,
148 'update_payment_status_permission',
149 ),
150 ),
151 )
152 );
153
154 register_rest_route(
155 $this->namespace, $this->base . '/(?P<id>[\d]+)/refund', array(
156 array(
157 'methods' => WP_REST_Server::EDITABLE,
158 'callback' => array( $this, 'refund_payment' ),
159 'permission_callback' => array(
160 $this,
161 'refund_payment_permission',
162 ),
163 ),
164 )
165 );
166 }
167
168 /**
169 * Check if the current user has permission to create orders.
170 *
171 * @param WP_REST_Request $request
172 *
173 * @return bool
174 */
175 public function create_order_permission( $request ) {
176 return true;
177 }
178
179 /**
180 * Create a new order with the provided data
181 *
182 * @param WP_REST_Request $request
183 *
184 * @return WP_Error|WP_HTTP_Response
185 * @throws Exception
186 */
187 public function create_item( $request ) {
188 $input_data = json_decode( $request->get_body(), true );
189
190 // Single comprehensive validation and preparation
191 $validation_result = $this->validate_and_prepare_order_data( $input_data );
192 if ( is_wp_error( $validation_result ) ) {
193 $validation_error_msg = $validation_result->get_error_message() ?? __( 'Order validation failed', 'booktics' );
194
195 return $this->error(
196 $validation_error_msg,
197 422,
198 'Validation'
199 );
200 }
201
202 $db = new Booktics_Database();
203
204 // Resolve customer ID
205 $customer_id_result = $this->resolve_customer_id( $input_data );
206 if ( is_wp_error( $customer_id_result ) ) {
207 return $this->error( $customer_id_result->get_error_message() );
208 }
209 $input_data['customer_id'] = $customer_id_result;
210
211 try {
212 $db->begin_transaction();
213 list( $order_model, $appointments ) = $this->create_order_and_appointments( $input_data, $db );
214 $payment_intent = $this->handle_payment_if_needed( $input_data, $order_model, $db );
215 $db->commit();
216 } catch ( Exception $e ) {
217 $db->roll_back();
218 return $this->error( $e->getMessage() );
219 }
220
221 if ( empty( $input_data['pay_now'] ) ) {
222 $this->send_notifications( $db, $order_model, $input_data['customer_id'], $appointments );
223 }
224 $response = $this->format_order_response( $db, $order_model, $input_data, $payment_intent );
225 do_action('booktics_after_order_created', $order_model, $appointments);
226
227 $response_message = 'Order created successfully';
228 if ( isset( $input_data['pay_now']) && $input_data['pay_now'] ) {
229 $response_message = 'Order pending, please proceed payment';
230 }
231
232 return $this->response(
233 $response,
234 sprintf(
235 /* translators: %s: dynamic response message */
236 __( 'Response %s', 'booktics' ),
237 $response_message
238 ),
239 201
240 );
241 }
242
243 /**
244 * Comprehensive validation and preparation of order data
245 *
246 * @param array $input_data
247 *
248 * @return array|WP_Error
249 */
250 private function validate_and_prepare_order_data( &$input_data ) {
251 // Step 1: Basic validation using Order_Validator
252 $validator = new Order_Validator();
253 $validation_result = $validator->validate( $input_data );
254 if ( is_wp_error( $validation_result ) ) {
255 return $validation_result;
256 }
257
258 // Step 2: Preserve original timezone for appointment creation, then validate and setup working timezone
259 $input_data['original_timezone'] = $input_data['timezone'] ?? wp_timezone_string();
260 if ( ! isset( $input_data['timezone'] ) || ! Time_Zone_Handler::validate_time_zone( $input_data['timezone'] ) ) {
261 $input_data['timezone'] = 'UTC';
262 }
263
264 // Step 3: Payment validation if pay_now is true
265 if ( isset( $input_data['pay_now'] ) && $input_data['pay_now'] ) {
266 $validate = booktics_validate(
267 $input_data, array(
268 'payment_method' => array( 'required' ),
269 )
270 );
271 if ( is_wp_error( $validate ) ) {
272 return $validate;
273 }
274
275 // WooCommerce checkout carries one booking at a time: the cart is
276 // emptied and a single line item added, so a multi-item order would
277 // silently lose everything but the first appointment.
278 if (
279 'woocommerce' === strtolower( (string) $input_data['payment_method'] )
280 && is_array( $input_data['items'] ?? null )
281 && count( $input_data['items'] ) > 1
282 ) {
283 return new WP_Error(
284 'items',
285 __( 'WooCommerce checkout supports one service at a time. Please book them separately.', 'booktics' )
286 );
287 }
288 }
289
290 // Step 4: Process and validate each item
291 if ( ! is_array( $input_data['items'] ) || count( $input_data['items'] ) == 0 ) {
292 return new WP_Error( 'items', __( 'Items are required', 'booktics' ) );
293 }
294
295 foreach ( $input_data['items'] as $key => $item ) {
296 // Validate required fields for each item
297 $item_validate = booktics_validate(
298 $item, array(
299 'date' => array( 'required' ),
300 'start_time' => array( 'required' ),
301 'end_time' => array( 'required' ),
302 'team_member_id' => array( 'required' ),
303 'service_id' => array( 'required' ),
304 )
305 );
306
307 if ( is_wp_error( $item_validate ) ) {
308 return $item_validate;
309 }
310
311 // Convert times to UTC
312 $converted_start_time = Time_Zone_Handler::convert_to_utc(
313 $item['start_time'],
314 $item['date'],
315 $input_data['timezone']
316 );
317 $converted_end_time = Time_Zone_Handler::convert_to_utc(
318 $item['end_time'],
319 $item['date'],
320 $input_data['timezone']
321 );
322
323 // Validate time range
324 $start_time = strtotime( $converted_start_time['time'] );
325 $end_time = strtotime( $converted_end_time['time'] );
326
327 if ( ! $start_time || ! $end_time || $start_time >= $end_time ) {
328 return new WP_Error( 'time_invalid', __( 'Invalid time range provided', 'booktics' ) );
329 }
330
331 // In validate_and_prepare_order_data(), after converting times:
332 $item_with_converted_times = array_merge($item, [
333 'start_time' => $converted_start_time['time'],
334 'end_time' => $converted_end_time['time'],
335 'date' => gmdate('Y-m-d', strtotime($converted_start_time['date']))
336 ]);
337
338 $item_timet_slot_validate = $this->validate_appointment_time_is_free($item_with_converted_times);
339
340 if ( is_wp_error( $item_timet_slot_validate ) ) {
341 return $item_timet_slot_validate;
342 }
343
344 // Sanitize and prepare item data
345 $input_data['items'][ $key ] = array(
346 'start_time' => $converted_start_time['time'],
347 'end_time' => $converted_end_time['time'],
348 'date' => gmdate( 'Y-m-d', strtotime( $item['date'] ) ),
349 'customer_id' => isset( $item['customer_id'] ) ? absint( $item['customer_id'] ) : null,
350 'team_member_id' => absint( $item['team_member_id'] ),
351 'service_id' => absint( $item['service_id'] ),
352 'additional_duration_id' => booktics_sanitize_id_string( $item['additional_duration_id'] ?? '' ),
353 'price' => floatval( $item['price'] ?? 0 ),
354 'subtotal' => floatval( $item['subtotal'] ?? 0 ),
355 'quantity' => absint( $item['quantity'] ?? 1 ),
356 'total_attendees' => absint( $item['total_attendees'] ?? 1 ),
357 );
358 $input_data['items'][$key] = apply_filters('booktics_pro_appointment_validate', $input_data['items'][$key], $item);
359 }
360
361 $input_data = apply_filters( 'booktics_pro_order_validate', $input_data );
362
363 return $input_data;
364 }
365
366 /**
367 * Check if the requested time slot has available capacity for the given team member
368 * Works by checking all appointments for this team member on this date and calculating remaining capacity
369 *
370 * @param array $item Appointment item data
371 * @return bool|WP_Error True if time slot has capacity, WP_Error if not
372 */
373 private function validate_appointment_time_is_free( $item ) {
374 try {
375 // Convert times to DateTime objects
376 $requested_start = new \DateTime( $item['date'] . ' ' . $item['start_time'] );
377 $requested_end = new \DateTime( $item['date'] . ' ' . $item['end_time'] );
378
379 // Get the requested attendee count (default to 1 for backward compatibility)
380 $requested_attendees = isset( $item['total_attendees'] ) ? (int) $item['total_attendees'] : 1;
381
382 // Get service to check if group booking is enabled and get max capacity
383 $service = Service_Model::find($item['service_id']);
384 if ( ! $service ) {
385 return new \WP_Error( 'service_not_found', __( 'Service not found', 'booktics' ) );
386 }
387
388 $max_group_size = $this->get_service_max_group_size( $service->id );
389
390
391 $args = [
392 'team_member_id' => $item['team_member_id'],
393 'date' => $item['date'],
394 'post_status' => booktics_get_option('appointment_statuses_for_blocked_slot', []),
395 ];
396
397 // Get all non-cancelled appointments for this team member on this date
398 $appointments = Appointment_Model::all( $args );
399
400 if ( ! is_array( $appointments ) || empty( $appointments ) ) {
401 // No existing appointments, check if requested attendees fit within max capacity
402 if ( $requested_attendees > $max_group_size ) {
403 /* translators: %1$d = requested attendees, %2$d = maximum group size */
404 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 ) );
405 }
406
407 return true;
408 }
409
410 // Calculate total attendees for overlapping time slots
411 $overlapping_attendees = 0;
412
413 foreach ( $appointments as $appointment ) {
414 // Skip if this is the same appointment (for updates)
415 if ( isset($item['id']) && $appointment->ID == $item['id'] ) {
416 continue;
417 }
418
419 // Guard against legacy/localized records whose stored date/time cannot be
420 // parsed; skipping them keeps a single bad row from blocking every booking.
421 try {
422 $appt_start = new \DateTime( $appointment->date . ' ' . $appointment->start_time );
423 $appt_end = new \DateTime( $appointment->date . ' ' . $appointment->end_time );
424 } catch ( \Throwable $e ) {
425 if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
426 error_log( 'Booktics: skipping appointment ' . ( $appointment->ID ?? '?' ) . ' with unparseable date/time during slot validation: ' . $e->getMessage() );
427 }
428 continue;
429 }
430
431 // Check for time overlap
432 if ( ! ( $requested_end <= $appt_start || $requested_start >= $appt_end ) ) {
433 // Times overlap, add this appointment's attendees to the count
434 $appointment_attendees = $appointment->total_attendees ?? 1;
435 $overlapping_attendees += $appointment_attendees;
436 }
437 }
438
439 // Check if adding the requested attendees would exceed capacity
440 $total_after_booking = $overlapping_attendees + $requested_attendees;
441
442 if ($total_after_booking > $max_group_size) {
443 return new \WP_Error( 'insufficient_capacity', __( 'Insufficient capacity' , 'booktics' ) );
444 }
445
446 return true;
447 } catch ( \Throwable $e ) {
448 return new \WP_Error(
449 'time_validation_error',
450 __( 'Error validating appointment time. Please try another slot.', 'booktics' )
451 );
452 }
453 }
454
455 /**
456 * Get max group size for a specific service
457 *
458 * @param int $service_id Service ID
459 *
460 * @return int Max group size
461 */
462 private function get_service_max_group_size( $service_id ) {
463 $default_max_size = 1;
464 $group_booking_meta = get_post_meta( $service_id, 'group_booking', true );
465
466 if ( ! $this->is_group_booking_enabled( $group_booking_meta ) ) {
467 return $default_max_size;
468 }
469
470 return $this->extract_max_booking_size( $group_booking_meta, $default_max_size );
471 }
472
473 /**
474 * Checks if group booking is enabled for a services group booking meta data
475 *
476 * @param array $group_booking_meta Group booking meta data
477 *
478 * @return bool True if group booking is enabled, false otherwise
479 */
480 private function is_group_booking_enabled( $group_booking_meta ) {
481 return !empty( $group_booking_meta ) && is_array( $group_booking_meta ) && !empty( $group_booking_meta['enable_group_booking'] );
482 }
483
484 /**
485 * Extracts the max booking size from the group booking meta data of a service
486 *
487 * @param array $group_booking_meta Group booking meta data
488 * @param int $default_size Default max booking size
489 *
490 * @return int Max booking size
491 */
492 private function extract_max_booking_size( $group_booking_meta, $default_size ) {
493 if ( isset( $group_booking_meta['max_booking'] ) && is_numeric( $group_booking_meta['max_booking'] ) ) {
494 return (int) $group_booking_meta['max_booking'];
495 }
496
497 return $default_size;
498 }
499
500 /**
501 * Resolve the customer ID from input data, creating or finding the customer as needed
502 *
503 * @param array $input_data
504 *
505 * @return int|WP_Error
506 */
507 private function resolve_customer_id( &$input_data ) {
508 if ( isset( $input_data['customer_id'] ) ) {
509 return $input_data['customer_id'];
510 }
511 if ( ! isset( $input_data['customer'] ) ) {
512 return new WP_Error( 'missing_customer', __( 'Customer information is required', 'booktics' ) );
513 }
514
515 return $this->find_or_create_customer( $input_data['customer'] );
516 }
517
518 /**
519 * Create the order and its associated appointments
520 *
521 * @param array $input_data
522 * @param Booktics_Database $db
523 *
524 * @return array [Order_Model $order_model, array $appointments]
525 */
526 private function create_order_and_appointments( $input_data, $db ) {
527 $input_data['currency'] = ! empty( $input_data['currency'] ) ? strtolower( $input_data['currency'] ) : strtolower( booktics_get_option( 'currency', 'USD' ) );
528 $order_model = new Order_Model( $db );
529
530 $input_timezone_string = $input_data['timezone'] ?? 'UTC';
531 $input_timezone = Time_Zone_Handler::get_timezone_from_string( $input_timezone_string );
532 $original_timezone = $input_data['original_timezone'] ?? $input_timezone_string;
533
534 $order_data = array(
535 'customer_id' => $input_data['customer_id'],
536 'payment_status' => Payment_Status::FAILED,
537 'price_breakdown' => $input_data['price_breakdown'] ?? array(),
538 'status' => 'open',
539 'tax_total' => $input_data['tax_total'] ?? 0,
540 'coupon_code' => $input_data['coupon_code'] ?? '',
541 'coupon_discount' => $input_data['coupon_discount'] ?? 0,
542 'subtotal' => $input_data['subtotal'],
543 'timezone' => $input_timezone,
544 'total' => $input_data['total'],
545 'currency' => $input_data['currency'],
546 'payment_method' => $input_data['payment_method'] ?? null,
547 );
548
549 // This is to only allow setting payment status when creating order from admin panel as admin
550 if ( isset( $input_data['payment_status'] ) && current_user_can( 'manage_options' ) ) {
551 $order_data['payment_status'] = $input_data['payment_status'];
552 }
553
554 $order_data = apply_filters( 'booktics_pro_order_columns', $order_data, $input_data );
555 $order_model = $order_model->create( $order_data );
556 $appointments = array();
557 foreach ( $input_data['items'] as $item ) {
558 if ( isset( $item['uuid'] ) ) {
559 ( new Cart_Manager() )->remove_cart_item( $item['uuid'] );
560 }
561 $total_duration_min = $this->calculate_total_duration( $item );
562
563 // Store date/time in a locale-independent format. wp_date() localizes digits and
564 // am/pm markers (e.g. Bengali "১১:০০ সকাল"), and such values cannot be parsed back
565 // by DateTime, breaking slot validation and notifications. DateTime::format() keeps
566 // the same timezone-aware value but is never localized.
567 $time_format = get_option( 'time_format' );
568 $start_local = ( new \DateTime( '@' . strtotime( $item['start_time'] ) ) )->setTimezone( $input_timezone );
569 $end_local = ( new \DateTime( '@' . strtotime( $item['end_time'] ) ) )->setTimezone( $input_timezone );
570 $date_local = ( new \DateTime( '@' . strtotime( $item['date'] ) ) )->setTimezone( $input_timezone );
571
572 $appointment_data = array(
573 'start_time' => $start_local->format( $time_format ),
574 'end_time' => $end_local->format( $time_format ),
575 'date' => $date_local->format( 'Y-m-d' ),
576 'customer_id' => $input_data['customer_id'] ?? '',
577 'team_member_id' => $item['team_member_id'] ?? '',
578 'service_id' => $item['service_id'] ?? '',
579 'additional_duration_id' => booktics_sanitize_id_string( $item['additional_duration_id'] ?? '' ),
580 'duration' => $total_duration_min,
581 'order_id' => $order_model->id,
582 'quantity' => $item['quantity'] ?? 1,
583 'price' => $item['price'] ?? 0,
584 'subtotal' => $item['subtotal'] ?? 0,
585 'total' => $item['total'] ?? $item['subtotal'] ?? 0,
586 'status' => booktics_get_option( 'appointment_status', 'pending' ),
587 'total_attendees' => $input_data['total_attendees'] ?? 1,
588 'timezone' => $original_timezone,
589 );
590
591
592
593 $appointment_data = apply_filters( 'booktics_appointment_pro_format', $appointment_data, $item );
594
595 $appointment = Appointment_Model::create( $appointment_data );
596 if ( is_wp_error( $appointment ) ) {
597 throw new Exception( esc_html( $appointment->get_error_message() ) );
598 }
599
600 do_action( 'booktics_after_appointment_created', $appointment );
601 $appointments[] = $appointment;
602 }
603
604 return array( $order_model, $appointments );
605 }
606
607
608 /**
609 * Process payment for an order if immediate payment is requested
610 *
611 * @param array $input_data Order input data containing payment details
612 * @param Order_Model $order_model Order model instance
613 * @param Booktics_Database $db Database instance
614 *
615 * @return array
616 * @throws Exception
617 */
618 private function handle_payment_if_needed( $input_data, $order_model, $db ) {
619 if ( ! isset( $input_data['pay_now'] ) || ! $input_data['pay_now'] ) {
620 return array();
621 }
622
623 $payment_method = ( new Payment_Method_Manager() )->get_payment_method( $input_data['payment_method'] );
624 if ( ! $payment_method ) {
625 throw new Exception( esc_html__( 'Payment method cannot be found!', 'booktics' ) );
626 }
627
628 $payment_intent = $payment_method->process_payment( $order_model );
629
630 if ( $payment_intent['status'] === 'error' ) {
631 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message is not directly output; escaping is handled at the display layer
632 throw new Exception( $payment_intent['message'] );
633 }
634
635 if ( $payment_intent['status'] === 'success' ) {
636 $this->update_payment_records( $db, $order_model, $input_data, $payment_intent );
637 // Do NOT set fully_paid here — PaymentIntent created ≠ payment collected.
638 // Status is updated after client-side confirmation via update_payment_status endpoint.
639 }
640
641 return $payment_intent;
642 }
643
644
645 /**
646 * Update payment records after successful payment processing
647 *
648 * @param Booktics_Database $db Database instance
649 * @param Order_Model $order_model Order model instance
650 * @param array $input_data Order input data containing payment details
651 * @param array $payment_intent Payment intent data from payment processor
652 *
653 * @return void
654 */
655 private function update_payment_records( $db, $order_model, $input_data, $payment_intent ) {
656 ( new Order_Model( $db ) )->update(
657 $order_model->id,
658 array( 'payment_intent_id' => $payment_intent['payment_intent'] ?? null )
659 );
660
661 ( new Payment_Model( $db ) )->create(
662 array(
663 'order_id' => $order_model->id,
664 'customer_id' => $input_data['customer_id'],
665 'amount' => $input_data['total'] * 100,
666 'currency' => $input_data['currency'] ?? booktics_get_option( 'currency', 'USD' ),
667 'intent_id' => $payment_intent['payment_intent'] ?? null,
668 'transaction_id' => null,
669 'payment_method' => $input_data['payment_method'],
670 'status' => $payment_intent['status'] == 'success' ? 'completed' : 'pending',
671 'date' => gmdate( 'Y-m-d' ),
672 'date_time' => gmdate( 'Y-m-d H:i:s' ),
673 )
674 );
675 }
676
677 /**
678 * Resolve team member email with WP user fallback.
679 *
680 * @param string $primary
681 * @param mixed $team_member_id
682 *
683 * @return string
684 */
685 private function resolve_team_member_email( string $primary, $team_member_id ): string {
686 return Order_Notifier::resolve_team_member_email( $primary, $team_member_id );
687 }
688
689 /**
690 * Helper: Format a single appointment for API output
691 *
692 * @param Appointment_Model|array $appointment
693 *
694 * @return array
695 */
696 private function format_appointment( $appointment ): array {
697 return Order_Notifier::format_appointment( $appointment );
698 }
699
700 /**
701 * Helper: Format order for API output
702 *
703 * @param Order_Model $order_model
704 * @param array $input_data
705 * @param array $payment_intent
706 *
707 * @return array
708 */
709 private function format_order_response( $db, $order_model, $input_data, $payment_intent ): array {
710 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $input_data['customer_id'] ) );
711 // Ensure $order_model is an object before accessing properties
712 $order_id = is_object( $order_model ) ? $order_model->id : ( is_array( $order_model ) ? $order_model['id'] : null );
713 $appointments = ( new Appointment_Model() )->where( 'order_id', $order_id );
714 $appointment_details = array();
715 foreach ( $appointments as $appointment ) {
716 $appointment_details[] = $this->format_appointment( $appointment );
717 }
718
719 $order_no_for_key = is_object( $order_model ) ? $order_model->order_no : ( $order_model['order_no'] ?? '' );
720
721 $order_data = array(
722 'id' => $order_id,
723 'order_no' => is_object( $order_model ) ? $order_model->order_no : ( $order_model['order_no'] ?? null ),
724 'order_key' => $this->get_order_view_key( (int) $order_id, (string) $order_no_for_key ),
725 'customer' => array(
726 'id' => $customer->id ?? null,
727 'name' => $customer->name ?? '',
728 'phone' => $customer->phone ?? '',
729 'email' => $customer->email ?? '',
730 ),
731 'status' => is_object( $order_model ) ? $order_model->status : ( $order_model['status'] ?? null ),
732 'subtotal' => is_object( $order_model ) ? $order_model->subtotal : ( $order_model['subtotal'] ?? null ),
733 // Tax and coupons are settled at checkout (WooCommerce owns them for
734 // those bookings), so the invoice has to show them, not just the
735 // subtotal. Missing here is what left the order looking untaxed.
736 'tax_total' => is_object( $order_model ) ? $order_model->tax_total : ( $order_model['tax_total'] ?? null ),
737 'coupon_discount' => is_object( $order_model ) ? $order_model->coupon_discount : ( $order_model['coupon_discount'] ?? null ),
738 'coupon_code' => is_object( $order_model ) ? $order_model->coupon_code : ( $order_model['coupon_code'] ?? null ),
739 'total' => is_object( $order_model ) ? $order_model->total : ( $order_model['total'] ?? null ),
740 'payment_status' => is_object( $order_model ) ? $order_model->payment_status : ( $order_model['payment_status'] ?? null ),
741 'payment_method' => is_object( $order_model ) ? $order_model->payment_method : $order_model['payment_method'],
742 'items' => $appointment_details,
743 'payment_intent_id' => $payment_intent['payment_intent'] ?? '',
744 'client_secret' => $payment_intent['client_secret'] ?? '',
745 // Gateways that collect payment on their own page (WooCommerce
746 // checkout) return where to send the customer instead of a secret.
747 'redirect_url' => $payment_intent['redirect_url'] ?? '',
748 'total_services' => $input_data['total_services'] ?? 1,
749 );
750
751 $order_data = apply_filters( 'booktics_pro_order_format', $order_data, $order_model );
752
753 return $order_data;
754 }
755
756 /**
757 * Helper: Send notifications for order and appointment creation
758 *
759 * Delegates to Order_Notifier so gateways that confirm a booking outside a
760 * REST request (WooCommerce) fire exactly the same events.
761 *
762 * @param Booktics_Database $db
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 Order_Notifier::order_created( $db, $order_model, $customer_id, $appointments );
771 }
772
773 /**
774 * Find existing customer by email or create new customer
775 *
776 * @param array $customer
777 *
778 * @return int|WP_Error
779 */
780 private function find_or_create_customer( array $customer ) {
781 $db = new Booktics_Database();
782 if ( ! isset( $customer['email'] ) ) {
783 return new WP_Error( 'invalid_email', __( 'Customer email is required', 'booktics' ) );
784 }
785 $user_login = User_Model::generate_username( $customer['email'] );
786 $allow_customer_as_wp_user = booktics_get_option( 'allow_customer_as_wp_user', false );
787 $wp_user_id = null;
788 if ( $allow_customer_as_wp_user ) {
789 $wp_user_id = $this->get_or_create_wp_user( $customer, $user_login );
790 }
791 $guest = $this->find_and_update_guest( $db, $customer, $wp_user_id );
792 if ( $guest ) {
793 return $guest->id;
794 }
795 $guest = $this->create_guest( $db, $customer, $user_login, $wp_user_id );
796
797 return $guest->id;
798 }
799
800 /**
801 * Get or create a WordPress user for the customer
802 *
803 * @param array $customer
804 * @param string $user_login
805 *
806 * @return int WP user ID
807 */
808 private function get_or_create_wp_user( $customer, $user_login ) {
809 $wp_user = get_user_by( 'email', $customer['email'] );
810 if ( $wp_user ) {
811 return $wp_user->ID;
812 }
813 $userdata = array(
814 'user_login' => $user_login,
815 'user_email' => $customer['email'],
816 'first_name' => $customer['name'] ?? '',
817 'display_name' => $customer['name'] ?? '',
818 'phone' => $customer['phone'] ?? '',
819 );
820 $customer_model = new Customer_Model();
821 $customer_id = $customer_model->save( $userdata );
822 ( new Customer_Event_Handler() )->on_customer_create( $customer_id );
823
824 return $customer_id;
825 }
826
827 /**
828 * Find and update a guest record by email
829 *
830 * @param Booktics_Database $db
831 * @param array $customer
832 *
833 * @return object|null Guest_Model instance or null
834 */
835 private function find_and_update_guest( $db, $customer, $wp_user_id = null ) {
836 $guest = ( new Guest_Model( $db ) )->find( array( 'email' => $customer['email'] ) );
837 if ( $guest && $this->caller_owns_customer_email( $guest, $customer['email'] ) ) {
838 ( new Guest_Model( $db ) )->update(
839 $guest->id, array(
840 'name' => $customer['name'] ?? '',
841 'phone' => $customer['phone'] ?? '',
842 'wp_user_id' => $wp_user_id,
843 )
844 );
845 }
846
847 return $guest;
848 }
849
850 /**
851 * Determine whether the current request is authorized to overwrite an
852 * existing customer's stored contact details (name, phone, wp_user_id).
853 *
854 * @param object $guest Existing Guest_Model record.
855 * @param string $email Submitted customer email.
856 *
857 * @return bool True when the caller may overwrite the stored record.
858 */
859 private function caller_owns_customer_email( $guest, $email ) {
860 // Admin or team member managing a booking on the customer's behalf.
861 if ( booktics_is_admin_or_team_member() ) {
862 return true;
863 }
864
865 if ( ! is_user_logged_in() ) {
866 return false;
867 }
868
869 // The record is already linked to the logged-in user.
870 if ( ! empty( $guest->wp_user_id ) && intval( $guest->wp_user_id ) === get_current_user_id() ) {
871 return true;
872 }
873
874 // The logged-in user's own account email matches the submitted email.
875 $current_user = wp_get_current_user();
876
877 return $current_user
878 && ! empty( $current_user->user_email )
879 && strtolower( $current_user->user_email ) === strtolower( (string) $email );
880 }
881
882 /**
883 * Create a new guest record
884 *
885 * @param Booktics_Database $db
886 * @param array $customer
887 * @param string $user_login
888 * @param int|null $wp_user_id
889 *
890 * @return object Guest_Model instance
891 */
892 private function create_guest( $db, $customer, $user_login, $wp_user_id ) {
893 return ( new Guest_Model( $db ) )->create(
894 array(
895 'wp_user_id' => $wp_user_id,
896 'email' => $customer['email'],
897 'name' => $customer['name'] ?? '',
898 'user_login' => $user_login,
899 'phone' => $customer['phone'] ?? '',
900 )
901 );
902 }
903
904 /**
905 * Check if user has permission to list orders.
906 *
907 * Anonymous access is denied: the collection handler scopes to the current customer or team member;
908 * unauthenticated callers would otherwise receive unscoped results. Single-order GET is allowed for
909 * guests when {@see get_item_permission()} validates a matching order_key.
910 *
911 * @param WP_REST_Request $request
912 *
913 * @return bool|WP_Error
914 */
915 public function get_items_permission( $request ) {
916 if ( is_user_logged_in() ) {
917 return true;
918 }
919
920 return new WP_Error(
921 'booktics_forbidden',
922 __( 'You are not allowed to view orders.', 'booktics' ),
923 array( 'status' => 403 )
924 );
925 }
926
927 /**
928 * Get paginated list of orders.
929 *
930 * @param WP_REST_Request $request
931 *
932 * @return WP_Error|WP_HTTP_Response
933 */
934 public function get_items( $request ) {
935 $page = (int) $request->get_param( 'paged' ) === 0 ? 1 : absint( $request->get_param( 'paged' ) );
936 $per_page = (int) $request->get_param( 'per_page' ) === 0 ? 10 : absint( $request->get_param( 'per_page' ) );
937 $payment_status = sanitize_text_field( $request->get_param( 'payment_status' ) );
938 $payment_method = sanitize_text_field( $request->get_param( 'payment_method' ) );
939 $order_date = sanitize_text_field( $request->get_param( 'order_date' ) );
940 $team_member = absint( $request->get_param( 'team_member' ) );
941 $service = absint( $request->get_param( 'service' ) );
942 $search = sanitize_text_field( $request->get_param( 'search' ) );
943
944 $db = new Booktics_Database();
945 $order_model = new Order_Model( $db );
946 $conditions = array();
947
948 if ( $payment_status ) {
949 $conditions['payment_status'] = $payment_status;
950 }
951
952 if ( $payment_method ) {
953 $conditions['payment_method'] = $payment_method;
954 }
955
956 if ( $order_date ) {
957 $conditions['DATE(created_at)'] = array( '=', $order_date );
958 }
959
960 if ( $search ) {
961 $conditions['order_no'] = array( 'LIKE', '%' . $search . '%' );
962 }
963
964 $conditions = apply_filters( 'booktics_order_filters', $conditions );
965
966 // Scope to customer's own orders when not admin/team-member
967 if ( ! booktics_is_admin_or_team_member() && is_user_logged_in() ) {
968 $guest = ( new Guest_Model( $db ) )->find( array( 'wp_user_id' => get_current_user_id() ) );
969 if ( ! $guest ) {
970 return $this->response(
971 array( 'items' => array(), 'total' => 0, 'total_pages' => 0 ),
972 __( 'Orders fetched successfully', 'booktics' )
973 );
974 }
975 $conditions['customer_id'] = $guest->id;
976 }
977
978 // Scope to team member's assigned orders - only return orders that contain
979 // at least one appointment where team_member_id equals the current user's team member ID
980 $is_team_member_scope = false;
981 $team_member_filter_id = null;
982 if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
983 $current_user_id = get_current_user_id();
984 $team_member_model = new Team_Member_Model();
985 $team_member = $team_member_model->find( array( 'wp_user_id' => $current_user_id ) );
986 if ( $team_member ) {
987 $is_team_member_scope = true;
988 $team_member_filter_id = $team_member->id;
989 }
990 }
991
992 $orders = $order_model->get(
993 $conditions, $page, $per_page, array(
994 'created_at',
995 'DESC',
996 )
997 );
998
999 if ( $team_member || $service ) {
1000 foreach ( $orders['items'] as $key => $order ) {
1001 $appointments = ( new Appointment_Model() )->where( 'order_id', $order->id );
1002 $keep_order = false;
1003
1004 foreach ( $appointments as $appointment ) {
1005 if ( $team_member && $appointment->team_member_id == $team_member ) {
1006 $keep_order = true;
1007 }
1008 if ( $service && $appointment->service_id == $service ) {
1009 $keep_order = true;
1010 }
1011 }
1012
1013 if ( ! $keep_order && ( $team_member || $service ) ) {
1014 unset( $orders['items'][ $key ] );
1015 --$orders['total'];
1016 }
1017 }
1018
1019 $orders['items'] = array_values( $orders['items'] );
1020 $orders['total_pages'] = ceil( $orders['total'] / $per_page );
1021 }
1022
1023 foreach ( $orders['items'] as &$order ) {
1024 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $order->customer_id ) );
1025 $customer_data_for_order = [
1026 'id' => $customer ? $customer->id : null,
1027 'name' => $customer ? $customer->name : '',
1028 'email' => '',
1029 'phone' => '',
1030 ];
1031 // Admins and team members see email and phone
1032 if ( booktics_is_admin_or_team_member() ) {
1033 $customer_data_for_order['email'] = $customer ? $customer->email : '';
1034 $customer_data_for_order['phone'] = $customer ? $customer->phone : '';
1035 }
1036 $order->customer = $customer_data_for_order;
1037 $order->total_services = ( new Appointment_Model() )->get_appointments_count_by_order_id( $order->id );
1038
1039 // Hide payment intent fields from team members (not admins)
1040 if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1041 $order->payment_intent_id = '';
1042 $order->client_secret = '';
1043 }
1044 }
1045
1046 return $this->response( $orders, __( 'Orders fetched successfully', 'booktics' ) );
1047 }
1048
1049 /**
1050 * Check if user has permission to update order.
1051 *
1052 * @param WP_REST_Request $request
1053 *
1054 * @return bool
1055 */
1056 public function update_item_permission( $request ) {
1057 return current_user_can( 'manage_options' );
1058 }
1059
1060 /**
1061 * Update an existing order.
1062 *
1063 * @param WP_REST_Request $request
1064 *
1065 * @return WP_Error|WP_HTTP_Response
1066 */
1067 public function update_item( $request ) {
1068 $order_id = absint( $request->get_param( 'id' ) );
1069 $data = json_decode( $request->get_body(), true );
1070 $db = new Booktics_Database();
1071 $order_model = new Order_Model( $db );
1072 $order = $order_model->find( array( 'id' => $order_id ) );
1073
1074 if ( ! $order ) {
1075 return $this->error( __( 'Order not found', 'booktics' ) );
1076 }
1077
1078 $status = isset( $data['status'] ) ? sanitize_text_field( $data['status'] ) : $order->status;
1079 $payment_status = isset( $data['payment_status'] ) ? sanitize_text_field( $data['payment_status'] ) : $order->payment_status;
1080
1081 $order_model->update(
1082 $order_id, array(
1083 'status' => $status,
1084 'payment_status' => $payment_status,
1085 )
1086 );
1087
1088 if ( $status !== $order->status ) {
1089 /** This action is documented in core/appointment/services/appointment-service.php */
1090 do_action( 'booktics_order_status_updated', $order_id, $status, $order->status );
1091 }
1092
1093 if ( isset( $data['customer'] ) && isset( $data['customer']['id'] ) ) {
1094 $customer_id = absint( $data['customer']['id'] );
1095 $customer_data = $data['customer'];
1096 $customer_data['id'] = $customer_id;
1097 if ( isset( $customer_data['name'] ) ) {
1098 $customer_data['name'] = sanitize_text_field( $customer_data['name'] );
1099 }
1100 if ( isset( $customer_data['email'] ) ) {
1101 $customer_data['email'] = sanitize_email( $customer_data['email'] );
1102 }
1103 if ( isset( $customer_data['phone'] ) ) {
1104 $customer_data['phone'] = sanitize_text_field( $customer_data['phone'] );
1105 }
1106 ( new Guest_Model( $db ) )->update( $customer_id, $customer_data );
1107 }
1108
1109 $order_model = $order_model->find( array( 'id' => $order_id ) );
1110 $data['customer_id'] = $order_model->customer_id;
1111
1112 $response = $this->format_order_response($db, $order_model, $data, null);
1113
1114 // Hide payment intent fields from team members (not admins)
1115 if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1116 $response['payment_intent_id'] = '';
1117 $response['client_secret'] = '';
1118 }
1119
1120 return $this->response( $response, __( 'Order updated successfully', 'booktics' ) );
1121 }
1122
1123 /**
1124 * Check if user has permission to get order.
1125 *
1126 * Staff always allowed. Logged-in customers allowed when the order belongs to their guest record.
1127 * Anonymous users allowed only when the request includes a valid order_key matching the order (same
1128 * value returned when the order was created).
1129 *
1130 * @param WP_REST_Request $request
1131 *
1132 * @return bool|WP_Error
1133 */
1134 public function get_item_permission( $request ) {
1135 if ( booktics_is_admin_or_team_member() ) {
1136 return true;
1137 }
1138
1139 $order_id = absint( $request->get_param( 'id' ) );
1140 $db = new Booktics_Database();
1141 $order = ( new Order_Model( $db ) )->find( array( 'id' => $order_id ) );
1142
1143 if ( ! $order ) {
1144 return true;
1145 }
1146
1147 if ( is_user_logged_in() ) {
1148 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $order->customer_id ) );
1149
1150 if ( $customer && intval( $customer->wp_user_id ) === get_current_user_id() ) {
1151 return true;
1152 }
1153 }
1154
1155 $order_key = sanitize_text_field( (string) $request->get_param( 'order_key' ) );
1156 if ( $order_key && $this->verify_order_view_key( $order_id, (string) $order->order_no, $order_key ) ) {
1157 return true;
1158 }
1159
1160 return new WP_Error(
1161 'booktics_forbidden',
1162 __( 'You are not allowed to view this order.', 'booktics' ),
1163 array( 'status' => 403 )
1164 );
1165 }
1166
1167 /**
1168 * Non-secret, unguessable key for viewing an order without logging in (returned on create and with GET responses).
1169 *
1170 * @param int $order_id
1171 * @param string $order_no
1172 *
1173 * @return string
1174 */
1175 private function get_order_view_key( $order_id, $order_no ) {
1176 return Order_Key::make( $order_id, $order_no );
1177 }
1178
1179 /**
1180 * @param int $order_id
1181 * @param string $order_no
1182 * @param string $provided_key
1183 *
1184 * @return bool
1185 */
1186 private function verify_order_view_key( $order_id, $order_no, $provided_key ) {
1187 return Order_Key::verify( $order_id, $order_no, $provided_key );
1188 }
1189
1190 /**
1191 * Get single order.
1192 *
1193 * @param WP_REST_Request $request
1194 *
1195 * @return WP_Error|WP_HTTP_Response
1196 */
1197 public function get_item( $request ) {
1198 $order_id = absint( $request->get_param( 'id' ) );
1199 $db = new Booktics_Database();
1200 $order_model = new Order_Model( $db );
1201 $order = $order_model->find( array( 'id' => $order_id ) );
1202
1203 if ( ! $order ) {
1204 return $this->error( __( 'Order not found', 'booktics' ) );
1205 }
1206
1207 // Use the format_order_response helper for consistency
1208 $input_data = array( 'customer_id' => $order->customer_id );
1209 $response = $this->format_order_response( $db, $order, $input_data, array() );
1210 // Add extra fields specific to GET if needed
1211 $response['status'] = $order->status;
1212 $response['created_at'] = $this->format_datetime_to_dayjs_format( $order->created_at );
1213 $response['payment_gateway'] = $order->payment_method;
1214 $response['payment_method'] = $order->payment_method;
1215
1216 // format_order_response() builds this from a freshly created payment
1217 // intent, which a GET does not have, so read the stored one instead.
1218 // The team-member guard below still strips it for non-admins.
1219 $response['payment_intent_id'] = $order->payment_intent_id ?? '';
1220
1221 // Hide payment intent fields from team members (not admins)
1222 if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1223 $response['payment_intent_id'] = '';
1224 $response['client_secret'] = '';
1225 }
1226
1227 return $this->response( $response, __( 'Order fetched successfully', 'booktics' ) );
1228 }
1229
1230 /**
1231 * Check if user has permission to update payment status.
1232 *
1233 * @param WP_REST_Request $request
1234 *
1235 * @return bool
1236 */
1237 public function update_payment_status_permission( $request ) {
1238 if ( current_user_can( 'manage_options' ) ) {
1239 return true;
1240 }
1241
1242 // Allow order owner with a valid order_key (guest checkout support).
1243 $order_id = absint( $request->get_param( 'id' ) );
1244 $order_key = sanitize_text_field( (string) $request->get_param( 'order_key' ) );
1245
1246 if ( ! $order_id || ! $order_key ) {
1247 return false;
1248 }
1249
1250 $db = new Booktics_Database();
1251 $order = ( new Order_Model( $db ) )->find( array( 'id' => $order_id ) );
1252
1253 if ( ! $order ) {
1254 return false;
1255 }
1256
1257 return $this->verify_order_view_key( $order_id, (string) $order->order_no, $order_key );
1258 }
1259
1260 /**
1261 * Update payment status for an order.
1262 *
1263 * @param WP_REST_Request $request
1264 *
1265 * @return WP_Error|WP_HTTP_Response
1266 */
1267 public function update_payment_status( $request ) {
1268 $data = json_decode( $request->get_body(), true );
1269 $order_id = ! empty( $data['order_id'] ) ? intval( $data['order_id'] ) : 0;
1270 $payment_status = ! empty( $data['payment_status'] ) ? $data['payment_status'] : 0;
1271 $payment_method = ! empty( $data['payment_method'] ) ? $data['payment_method'] : null;
1272
1273 $db = new Booktics_Database();
1274 $order_model = new Order_Model( $db );
1275 $order = $order_model->find( array( 'id' => $order_id ) );
1276
1277 if ( ! $order ) {
1278 return $this->error( __( 'Order not found', 'booktics' ) );
1279 }
1280
1281 if ( ! array_key_exists( $payment_method, ( new Payment_Method_Manager() )->get_payment_methods() ) ) {
1282 return $this->error( __( 'Unauthorized payment method', 'booktics' ) );
1283 }
1284
1285 try {
1286 $payment_handler = ( new Payment_Method_Manager() )->get_payment_method( $payment_method );
1287 if ( ! $payment_handler ) {
1288 return $this->error( __( 'Payment method not found', 'booktics' ) );
1289 }
1290
1291 $status = $payment_handler->get_payment_status( $order );
1292 if ( is_wp_error( $status ) ) {
1293 return $this->error( $status->get_error_message() );
1294 }
1295
1296 $order_model->update(
1297 $order_id, array(
1298 'payment_status' => $status,
1299 'transaction_id' => $data['transaction_id'] ?? null,
1300 )
1301 );
1302
1303 if (
1304 $status === Payment_Status::COMPLETED
1305 && $order->payment_status !== Payment_Status::COMPLETED
1306 ) {
1307 // The payment status is already persisted above. Sending notifications must
1308 // not fail the request, otherwise a confirmed booking shows an error toast.
1309 try {
1310 $appointments = ( new Appointment_Model() )->where( 'order_id', $order_id );
1311 $this->send_notifications( $db, $order, $order->customer_id, $appointments );
1312 } catch ( \Throwable $e ) {
1313 if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
1314 error_log( 'Booktics: failed to send notifications after payment for order ' . $order_id . ': ' . $e->getMessage() );
1315 }
1316 }
1317 } elseif ( $status === Payment_Status::FAILED ) {
1318 // Payment was declined/canceled on the processor. Release the held slot
1319 // immediately by canceling the pending appointments for this order, so the
1320 // customer does not have to wait for the admin_init sweep.
1321 ( new Abandoned_Booking_Cleanup() )->release_appointments( $order_id );
1322 }
1323
1324 // Payment model status and transaction id update
1325 return $this->response(
1326 array(
1327 'payment_status' => $status,
1328 ), __( 'Payment status updated successfully', 'booktics' )
1329 );
1330 } catch ( \Exception $e ) {
1331
1332 if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
1333 error_log( 'An error occurred while updating payment status: ' . $e->getMessage() );
1334 }
1335 return $this->error( __( 'An error occurred while updating payment status.', 'booktics' ) );
1336 }
1337 }
1338
1339 /**
1340 * Check if user has permission to delete order.
1341 *
1342 * @param WP_REST_Request $request
1343 *
1344 * @return bool
1345 */
1346 public function delete_item_permission( $request ) {
1347 return current_user_can( 'manage_options' );
1348 }
1349
1350 /**
1351 * Delete an order.
1352 *
1353 * @param WP_REST_Request $request
1354 *
1355 * @return WP_Error|WP_HTTP_Response
1356 */
1357 public function delete_item( $request ) {
1358 $order_id = absint( $request->get_param( 'id' ) );
1359 $db = new Booktics_Database();
1360 $order_model = new Order_Model( $db );
1361 $order = $order_model->find( array( 'id' => $order_id ) );
1362
1363 if ( ! $order ) {
1364 return $this->error( __( 'Order not found', 'booktics' ) );
1365 }
1366
1367 try {
1368 $db->begin_transaction();
1369
1370 // Delete related appointments
1371 $appointment_model = new Appointment_Model();
1372 $appointments = $appointment_model->where( 'order_id', $order_id );
1373 foreach ( $appointments as $appointment ) {
1374 $appointment->delete();
1375 }
1376
1377 // Delete related payments
1378 $payment_model = new Payment_Model( $db );
1379 $payments = $payment_model->get( array( 'order_id' => $order_id ) );
1380 foreach ( $payments['items'] as $payment ) {
1381 ( new Payment_Model( $db ) )->delete( $payment->id );
1382 }
1383 // Delete the order
1384 $order_model->delete( $order_id );
1385
1386 $db->commit();
1387
1388 return $this->response( array(), __( 'Order deleted successfully', 'booktics' ) );
1389 } catch ( Exception $e ) {
1390 $db->roll_back();
1391
1392 return $this->error( $e->getMessage() );
1393 }
1394 }
1395
1396 /**
1397 * Check if user has permission to bulk delete orders.
1398 *
1399 * @param WP_REST_Request $request
1400 *
1401 * @return bool
1402 */
1403 public function bulk_delete_items_permission( $request ) {
1404 return current_user_can( 'manage_options' );
1405 }
1406
1407 /**
1408 * Bulk delete orders.
1409 *
1410 * @param WP_REST_Request $request
1411 *
1412 * @return WP_Error|WP_HTTP_Response
1413 */
1414 public function bulk_delete_items( $request ) {
1415 $ids_param = $request->get_param( 'ids' );
1416 $order_ids = is_array( $ids_param ) ? array_map( 'absint', $ids_param ) : array();
1417 $db = new Booktics_Database();
1418
1419 try {
1420 $db->begin_transaction();
1421
1422 foreach ( $order_ids as $order_id ) {
1423 $order_model = new Order_Model( $db );
1424 $order = $order_model->find( array( 'id' => $order_id ) );
1425
1426 if ( ! $order ) {
1427 continue;
1428 }
1429
1430 // Delete related appointments
1431 $appointment_model = new Appointment_Model();
1432 $appointments = $appointment_model->where( 'order_id', $order_id );
1433 foreach ( $appointments as $appointment ) {
1434 $appointment->delete();
1435 }
1436
1437 // Delete related payments
1438 $payment_model = new Payment_Model( $db );
1439 $payments = $payment_model->get( array( 'order_id' => $order_id ) );
1440 foreach ( $payments['items'] as $payment ) {
1441 ( new Payment_Model( $db ) )->delete( $payment->id );
1442 }
1443
1444 // Delete the order
1445 $order_model->delete( $order_id );
1446 }
1447
1448 $db->commit();
1449
1450 return $this->response( array(), __( 'Orders deleted successfully', 'booktics' ) );
1451 } catch ( Exception $e ) {
1452 $db->roll_back();
1453
1454 return $this->error( $e->getMessage() );
1455 }
1456 }
1457
1458 /**
1459 * Check if the current user may refund an order.
1460 *
1461 * Refunding moves money and must never be reachable by the customer. This
1462 * route previously reused update_payment_status_permission, which also
1463 * accepts a valid order_key so that a guest can confirm their own payment —
1464 * meaning any customer holding their own order key could refund themselves.
1465 *
1466 * @param WP_REST_Request $request
1467 *
1468 * @return bool
1469 */
1470 public function refund_payment_permission( $request ) {
1471 return current_user_can( 'manage_options' );
1472 }
1473
1474 /**
1475 * Process payment refund for an order.
1476 *
1477 * @param WP_REST_Request $request
1478 *
1479 * @return WP_Error|WP_HTTP_Response
1480 */
1481 public function refund_payment( $request ) {
1482 $order_id = absint( $request->get_param( 'id' ) );
1483 $db = new Booktics_Database();
1484 $order_model = new Order_Model( $db );
1485 $order = $order_model->find( array( 'id' => $order_id ) );
1486
1487 if ( ! $order || is_wp_error( $order ) ) {
1488 return $this->error( __( 'Order not found', 'booktics' ), 404 );
1489 }
1490
1491 $payment_method = ( new Payment_Method_Manager() )->get_payment_method( $order->payment_method );
1492 if ( ! $payment_method ) {
1493 return $this->error( __( 'Invalid payment method!', 'booktics' ) );
1494 }
1495 $response = $payment_method->process_refund( $order );
1496
1497 // A gateway may refuse outright — WooCommerce-paid orders are refunded
1498 // from WooCommerce admin — and must not leave the order marked refunded
1499 // when no money moved.
1500 if ( is_wp_error( $response ) ) {
1501 return $this->error( $response->get_error_message() );
1502 }
1503
1504 if ( ! is_array( $response ) || ( $response['status'] ?? '' ) === 'error' ) {
1505 return $this->error( $response['message'] ?? __( 'Refund failed', 'booktics' ) );
1506 }
1507
1508 $order_model->update(
1509 $order_id, array(
1510 'payment_status' => Payment_Status::REFUNDED,
1511 )
1512 );
1513
1514 return $this->response(
1515 $order_model->find( array( 'id' => $order_id ) ),
1516 __( 'Refund successful', 'booktics' )
1517 );
1518 }
1519
1520 /**
1521 * Calculate total duration in minutes for a service appointment.
1522 *
1523 * @param array $item
1524 *
1525 * @return int
1526 */
1527 private function calculate_total_duration( $item ) {
1528 $service_model = Service_Model::find( $item['service_id'] );
1529 if ( ! $service_model ) {
1530 return 0;
1531 }
1532 $service_duration = array_filter(
1533 $service_model->additional_durations, function ( $duration ) use ( $item ) {
1534 return $item['additional_duration_id'] === $duration['id'];
1535 }
1536 );
1537 if ( empty( $service_duration ) ) {
1538 return 0;
1539 }
1540 $service_duration = reset( $service_duration );
1541 $total_duration_min = $service_duration['duration_unit'] === 'min'
1542 ? $service_duration['duration_time']
1543 : ( $service_duration['duration_time'] * 60 );
1544 if ( ! empty( $item['extra_services'] ) ) {
1545 $total_duration_min = apply_filters( 'booktics_extra_service_duration', $total_duration_min, $item['extra_services'] );
1546 }
1547
1548 $total_duration_min = apply_filters( 'booktics_calculate_total_duration', $total_duration_min, $item );
1549
1550 return $total_duration_min;
1551 }
1552
1553 /**
1554 * Converts a datetime string to ISO 8601 format with WordPress timezone for dayjs compatibility
1555 *
1556 * @param string $datetime Datetime string from database (e.g., "2024-01-15 14:00:00")
1557 *
1558 * @return string ISO 8601 formatted datetime with timezone offset (e.g., "2024-01-15T14:00:00-05:00")
1559 */
1560 private function format_datetime_to_dayjs_format( $datetime ) {
1561 $dt = new DateTime( $datetime );
1562 $dt->setTimezone( wp_timezone() );
1563 return $dt->format( 'Y-m-d H:i:s' );
1564 }
1565 }
1566