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