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

1,585 lines 61.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Booktics\Order\Controllers;
4
5 use Booktics\Abstracts\Base_Rest_Controller;
6 use Booktics\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 $input_data['currency'] = ! empty( $input_data['currency'] ) ? strtolower( $input_data['currency'] ) : strtolower( booktics_get_option( 'currency', 'USD' ) );
537 $order_model = new Order_Model( $db );
538
539 $input_timezone_string = $input_data['timezone'] ?? 'UTC';
540 $input_timezone = Time_Zone_Handler::get_timezone_from_string( $input_timezone_string );
541 $original_timezone = $input_data['original_timezone'] ?? $input_timezone_string;
542
543 $order_data = array(
544 'customer_id' => $input_data['customer_id'],
545 'payment_status' => Payment_Status::FAILED,
546 'price_breakdown' => $input_data['price_breakdown'] ?? array(),
547 'status' => 'open',
548 'tax_total' => $input_data['tax_total'] ?? 0,
549 'coupon_code' => $input_data['coupon_code'] ?? '',
550 'coupon_discount' => $input_data['coupon_discount'] ?? 0,
551 'subtotal' => $input_data['subtotal'],
552 'timezone' => $input_timezone,
553 'total' => $input_data['total'],
554 'currency' => $input_data['currency'],
555 'payment_method' => $input_data['payment_method'] ?? null,
556 );
557
558 // This is to only allow setting payment status when creating order from admin panel as admin
559 if ( isset( $input_data['payment_status'] ) && current_user_can( 'manage_options' ) ) {
560 $order_data['payment_status'] = $input_data['payment_status'];
561 }
562
563 $order_data = apply_filters( 'booktics_pro_order_columns', $order_data, $input_data );
564 $order_model = $order_model->create( $order_data );
565 $appointments = array();
566 foreach ( $input_data['items'] as $item ) {
567 if ( isset( $item['uuid'] ) ) {
568 ( new Cart_Manager() )->remove_cart_item( $item['uuid'] );
569 }
570 $total_duration_min = $this->calculate_total_duration( $item );
571
572 // Store date/time in a locale-independent format. wp_date() localizes digits and
573 // am/pm markers (e.g. Bengali "১১:০০ সকাল"), and such values cannot be parsed back
574 // by DateTime, breaking slot validation and notifications. DateTime::format() keeps
575 // the same timezone-aware value but is never localized.
576 $time_format = get_option( 'time_format' );
577 $start_local = ( new \DateTime( '@' . strtotime( $item['start_time'] ) ) )->setTimezone( $input_timezone );
578 $end_local = ( new \DateTime( '@' . strtotime( $item['end_time'] ) ) )->setTimezone( $input_timezone );
579 $date_local = ( new \DateTime( '@' . strtotime( $item['date'] ) ) )->setTimezone( $input_timezone );
580
581 $appointment_data = array(
582 'start_time' => $start_local->format( $time_format ),
583 'end_time' => $end_local->format( $time_format ),
584 'date' => $date_local->format( 'Y-m-d' ),
585 'customer_id' => $input_data['customer_id'] ?? '',
586 'team_member_id' => $item['team_member_id'] ?? '',
587 'service_id' => $item['service_id'] ?? '',
588 'additional_duration_id' => booktics_sanitize_id_string( $item['additional_duration_id'] ?? '' ),
589 'duration' => $total_duration_min,
590 'order_id' => $order_model->id,
591 'quantity' => $item['quantity'] ?? 1,
592 'price' => $item['price'] ?? 0,
593 'subtotal' => $item['subtotal'] ?? 0,
594 'total' => $item['total'] ?? $item['subtotal'] ?? 0,
595 'status' => booktics_get_option( 'appointment_status', 'pending' ),
596 'total_attendees' => $item['total_attendees'] ?? $input_data['total_attendees'] ?? 1,
597 'timezone' => $original_timezone,
598 );
599
600
601
602 $appointment_data = apply_filters( 'booktics_appointment_pro_format', $appointment_data, $item );
603
604 $conflict = Appointment_Validator::validate_slot_available( $appointment_data );
605 if ( $conflict ) {
606 throw new Exception( esc_html( $conflict->get_error_message() ) );
607 }
608
609 $appointment = Appointment_Model::create( $appointment_data );
610 if ( is_wp_error( $appointment ) ) {
611 throw new Exception( esc_html( $appointment->get_error_message() ) );
612 }
613
614 do_action( 'booktics_after_appointment_created', $appointment );
615 $appointments[] = $appointment;
616 }
617
618 return array( $order_model, $appointments );
619 }
620
621
622 /**
623 * Process payment for an order if immediate payment is requested
624 *
625 * @param array $input_data Order input data containing payment details
626 * @param Order_Model $order_model Order model instance
627 * @param Booktics_Database $db Database instance
628 *
629 * @return array
630 * @throws Exception
631 */
632 private function handle_payment_if_needed( $input_data, $order_model, $db ) {
633 if ( ! isset( $input_data['pay_now'] ) || ! $input_data['pay_now'] ) {
634 return array();
635 }
636
637 $payment_method = ( new Payment_Method_Manager() )->get_payment_method( $input_data['payment_method'] );
638 if ( ! $payment_method ) {
639 throw new Exception( esc_html__( 'Payment method cannot be found!', 'booktics' ) );
640 }
641
642 $payment_intent = $payment_method->process_payment( $order_model );
643
644 if ( $payment_intent['status'] === 'error' ) {
645 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message is not directly output; escaping is handled at the display layer
646 throw new Exception( $payment_intent['message'] );
647 }
648
649 if ( $payment_intent['status'] === 'success' ) {
650 $this->update_payment_records( $db, $order_model, $input_data, $payment_intent );
651 // Do NOT set fully_paid here — PaymentIntent created ≠ payment collected.
652 // Status is updated after client-side confirmation via update_payment_status endpoint.
653 }
654
655 return $payment_intent;
656 }
657
658
659 /**
660 * Update payment records after successful payment processing
661 *
662 * @param Booktics_Database $db Database instance
663 * @param Order_Model $order_model Order model instance
664 * @param array $input_data Order input data containing payment details
665 * @param array $payment_intent Payment intent data from payment processor
666 *
667 * @return void
668 */
669 private function update_payment_records( $db, $order_model, $input_data, $payment_intent ) {
670 ( new Order_Model( $db ) )->update(
671 $order_model->id,
672 array( 'payment_intent_id' => $payment_intent['payment_intent'] ?? null )
673 );
674
675 ( new Payment_Model( $db ) )->create(
676 array(
677 'order_id' => $order_model->id,
678 'customer_id' => $input_data['customer_id'],
679 'amount' => $input_data['total'] * 100,
680 'currency' => $input_data['currency'] ?? booktics_get_option( 'currency', 'USD' ),
681 'intent_id' => $payment_intent['payment_intent'] ?? null,
682 'transaction_id' => null,
683 'payment_method' => $input_data['payment_method'],
684 'status' => $payment_intent['status'] == 'success' ? 'completed' : 'pending',
685 'date' => gmdate( 'Y-m-d' ),
686 'date_time' => gmdate( 'Y-m-d H:i:s' ),
687 )
688 );
689 }
690
691 /**
692 * Resolve team member email with WP user fallback.
693 *
694 * @param string $primary
695 * @param mixed $team_member_id
696 *
697 * @return string
698 */
699 private function resolve_team_member_email( string $primary, $team_member_id ): string {
700 return Order_Notifier::resolve_team_member_email( $primary, $team_member_id );
701 }
702
703 /**
704 * Helper: Format a single appointment for API output
705 *
706 * @param Appointment_Model|array $appointment
707 *
708 * @return array
709 */
710 private function format_appointment( $appointment ): array {
711 return Order_Notifier::format_appointment( $appointment );
712 }
713
714 /**
715 * Helper: Format order for API output
716 *
717 * @param Order_Model $order_model
718 * @param array $input_data
719 * @param array $payment_intent
720 *
721 * @return array
722 */
723 private function format_order_response( $db, $order_model, $input_data, $payment_intent ): array {
724 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $input_data['customer_id'] ) );
725 // Ensure $order_model is an object before accessing properties
726 $order_id = is_object( $order_model ) ? $order_model->id : ( is_array( $order_model ) ? $order_model['id'] : null );
727 $appointments = ( new Appointment_Model() )->where( 'order_id', $order_id );
728 $appointment_details = array();
729 foreach ( $appointments as $appointment ) {
730 $appointment_details[] = $this->format_appointment( $appointment );
731 }
732
733 $order_no_for_key = is_object( $order_model ) ? $order_model->order_no : ( $order_model['order_no'] ?? '' );
734
735 $order_data = array(
736 'id' => $order_id,
737 'order_no' => is_object( $order_model ) ? $order_model->order_no : ( $order_model['order_no'] ?? null ),
738 'order_key' => $this->get_order_view_key( (int) $order_id, (string) $order_no_for_key ),
739 'customer' => array(
740 'id' => $customer->id ?? null,
741 'name' => $customer->name ?? '',
742 'phone' => $customer->phone ?? '',
743 'email' => $customer->email ?? '',
744 ),
745 'status' => is_object( $order_model ) ? $order_model->status : ( $order_model['status'] ?? null ),
746 'subtotal' => is_object( $order_model ) ? $order_model->subtotal : ( $order_model['subtotal'] ?? null ),
747 // Tax and coupons are settled at checkout (WooCommerce owns them for
748 // those bookings), so the invoice has to show them, not just the
749 // subtotal. Missing here is what left the order looking untaxed.
750 'tax_total' => is_object( $order_model ) ? $order_model->tax_total : ( $order_model['tax_total'] ?? null ),
751 'coupon_discount' => is_object( $order_model ) ? $order_model->coupon_discount : ( $order_model['coupon_discount'] ?? null ),
752 'coupon_code' => is_object( $order_model ) ? $order_model->coupon_code : ( $order_model['coupon_code'] ?? null ),
753 'total' => is_object( $order_model ) ? $order_model->total : ( $order_model['total'] ?? null ),
754 'payment_status' => is_object( $order_model ) ? $order_model->payment_status : ( $order_model['payment_status'] ?? null ),
755 'payment_method' => is_object( $order_model ) ? $order_model->payment_method : $order_model['payment_method'],
756 'items' => $appointment_details,
757 'payment_intent_id' => $payment_intent['payment_intent'] ?? '',
758 'client_secret' => $payment_intent['client_secret'] ?? '',
759 // Gateways that collect payment on their own page (WooCommerce
760 // checkout) return where to send the customer instead of a secret.
761 'redirect_url' => $payment_intent['redirect_url'] ?? '',
762 'total_services' => $input_data['total_services'] ?? 1,
763 );
764
765 $order_data = apply_filters( 'booktics_pro_order_format', $order_data, $order_model );
766
767 return $order_data;
768 }
769
770 /**
771 * Helper: Send notifications for order and appointment creation
772 *
773 * Delegates to Order_Notifier so gateways that confirm a booking outside a
774 * REST request (WooCommerce) fire exactly the same events.
775 *
776 * @param Booktics_Database $db
777 * @param Order_Model $order_model
778 * @param int $customer_id
779 * @param array $appointments
780 *
781 * @return void
782 */
783 private function send_notifications( $db, $order_model, $customer_id, $appointments ): void {
784 Order_Notifier::order_created( $db, $order_model, $customer_id, $appointments );
785 }
786
787 /**
788 * Find existing customer by email or create new customer
789 *
790 * @param array $customer
791 *
792 * @return int|WP_Error
793 */
794 private function find_or_create_customer( array $customer ) {
795 $db = new Booktics_Database();
796 if ( ! isset( $customer['email'] ) ) {
797 return new WP_Error( 'invalid_email', __( 'Customer email is required', 'booktics' ) );
798 }
799 $user_login = User_Model::generate_username( $customer['email'] );
800 $allow_customer_as_wp_user = booktics_get_option( 'allow_customer_as_wp_user', false );
801 $wp_user_id = null;
802 if ( $allow_customer_as_wp_user ) {
803 $wp_user_id = $this->get_or_create_wp_user( $customer, $user_login );
804 }
805 $guest = $this->find_and_update_guest( $db, $customer, $wp_user_id );
806 if ( $guest ) {
807 return $guest->id;
808 }
809 $guest = $this->create_guest( $db, $customer, $user_login, $wp_user_id );
810
811 return $guest->id;
812 }
813
814 /**
815 * Get or create a WordPress user for the customer
816 *
817 * @param array $customer
818 * @param string $user_login
819 *
820 * @return int WP user ID
821 */
822 private function get_or_create_wp_user( $customer, $user_login ) {
823 $wp_user = get_user_by( 'email', $customer['email'] );
824 if ( $wp_user ) {
825 return $wp_user->ID;
826 }
827 $userdata = array(
828 'user_login' => $user_login,
829 'user_email' => $customer['email'],
830 'first_name' => $customer['name'] ?? '',
831 'display_name' => $customer['name'] ?? '',
832 'phone' => $customer['phone'] ?? '',
833 );
834 $customer_model = new Customer_Model();
835 $customer_id = $customer_model->save( $userdata );
836 ( new Customer_Event_Handler() )->on_customer_create( $customer_id );
837
838 return $customer_id;
839 }
840
841 /**
842 * Find and update a guest record by email
843 *
844 * @param Booktics_Database $db
845 * @param array $customer
846 *
847 * @return object|null Guest_Model instance or null
848 */
849 private function find_and_update_guest( $db, $customer, $wp_user_id = null ) {
850 $guest = ( new Guest_Model( $db ) )->find( array( 'email' => $customer['email'] ) );
851 if ( $guest && $this->caller_owns_customer_email( $guest, $customer['email'] ) ) {
852 ( new Guest_Model( $db ) )->update(
853 $guest->id, array(
854 'name' => $customer['name'] ?? '',
855 'phone' => $customer['phone'] ?? '',
856 'wp_user_id' => $wp_user_id,
857 )
858 );
859 }
860
861 return $guest;
862 }
863
864 /**
865 * Determine whether the current request is authorized to overwrite an
866 * existing customer's stored contact details (name, phone, wp_user_id).
867 *
868 * @param object $guest Existing Guest_Model record.
869 * @param string $email Submitted customer email.
870 *
871 * @return bool True when the caller may overwrite the stored record.
872 */
873 private function caller_owns_customer_email( $guest, $email ) {
874 // Admin or team member managing a booking on the customer's behalf.
875 if ( booktics_is_admin_or_team_member() ) {
876 return true;
877 }
878
879 if ( ! is_user_logged_in() ) {
880 return false;
881 }
882
883 // The record is already linked to the logged-in user.
884 if ( ! empty( $guest->wp_user_id ) && intval( $guest->wp_user_id ) === get_current_user_id() ) {
885 return true;
886 }
887
888 // The logged-in user's own account email matches the submitted email.
889 $current_user = wp_get_current_user();
890
891 return $current_user
892 && ! empty( $current_user->user_email )
893 && strtolower( $current_user->user_email ) === strtolower( (string) $email );
894 }
895
896 /**
897 * Create a new guest record
898 *
899 * @param Booktics_Database $db
900 * @param array $customer
901 * @param string $user_login
902 * @param int|null $wp_user_id
903 *
904 * @return object Guest_Model instance
905 */
906 private function create_guest( $db, $customer, $user_login, $wp_user_id ) {
907 return ( new Guest_Model( $db ) )->create(
908 array(
909 'wp_user_id' => $wp_user_id,
910 'email' => $customer['email'],
911 'name' => $customer['name'] ?? '',
912 'user_login' => $user_login,
913 'phone' => $customer['phone'] ?? '',
914 )
915 );
916 }
917
918 /**
919 * Check if user has permission to list orders.
920 *
921 * Anonymous access is denied: the collection handler scopes to the current customer or team member;
922 * unauthenticated callers would otherwise receive unscoped results. Single-order GET is allowed for
923 * guests when {@see get_item_permission()} validates a matching order_key.
924 *
925 * @param WP_REST_Request $request
926 *
927 * @return bool|WP_Error
928 */
929 public function get_items_permission( $request ) {
930 if ( is_user_logged_in() ) {
931 return true;
932 }
933
934 return new WP_Error(
935 'booktics_forbidden',
936 __( 'You are not allowed to view orders.', 'booktics' ),
937 array( 'status' => 403 )
938 );
939 }
940
941 /**
942 * Get paginated list of orders.
943 *
944 * @param WP_REST_Request $request
945 *
946 * @return WP_Error|WP_HTTP_Response
947 */
948 public function get_items( $request ) {
949 $page = (int) $request->get_param( 'paged' ) === 0 ? 1 : absint( $request->get_param( 'paged' ) );
950 $per_page = (int) $request->get_param( 'per_page' ) === 0 ? 10 : absint( $request->get_param( 'per_page' ) );
951 $payment_status = sanitize_text_field( $request->get_param( 'payment_status' ) );
952 $payment_method = sanitize_text_field( $request->get_param( 'payment_method' ) );
953 $order_date = sanitize_text_field( $request->get_param( 'order_date' ) );
954 $team_member = absint( $request->get_param( 'team_member' ) );
955 $service = absint( $request->get_param( 'service' ) );
956 $search = sanitize_text_field( $request->get_param( 'search' ) );
957
958 $db = new Booktics_Database();
959 $order_model = new Order_Model( $db );
960 $conditions = array();
961
962 if ( $payment_status ) {
963 $conditions['payment_status'] = $payment_status;
964 }
965
966 if ( $payment_method ) {
967 $conditions['payment_method'] = $payment_method;
968 }
969
970 if ( $order_date ) {
971 $conditions['DATE(created_at)'] = array( '=', $order_date );
972 }
973
974 if ( $search ) {
975 $conditions['order_no'] = array( 'LIKE', '%' . $search . '%' );
976 }
977
978 $conditions = apply_filters( 'booktics_order_filters', $conditions );
979
980 // Scope to customer's own orders when not admin/team-member
981 if ( ! booktics_is_admin_or_team_member() && is_user_logged_in() ) {
982 $guest = ( new Guest_Model( $db ) )->find( array( 'wp_user_id' => get_current_user_id() ) );
983 if ( ! $guest ) {
984 return $this->response(
985 array( 'items' => array(), 'total' => 0, 'total_pages' => 0 ),
986 __( 'Orders fetched successfully', 'booktics' )
987 );
988 }
989 $conditions['customer_id'] = $guest->id;
990 }
991
992 // Scope to team member's assigned orders - only return orders that contain
993 // at least one appointment where team_member_id equals the current user's team member ID
994 $is_team_member_scope = false;
995 $team_member_filter_id = null;
996 if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
997 $current_user_id = get_current_user_id();
998 $team_member_model = new Team_Member_Model();
999 $team_member = $team_member_model->find( array( 'wp_user_id' => $current_user_id ) );
1000 if ( $team_member ) {
1001 $is_team_member_scope = true;
1002 $team_member_filter_id = $team_member->id;
1003 }
1004 }
1005
1006 $orders = $order_model->get(
1007 $conditions, $page, $per_page, array(
1008 'created_at',
1009 'DESC',
1010 )
1011 );
1012
1013 if ( $team_member || $service ) {
1014 foreach ( $orders['items'] as $key => $order ) {
1015 $appointments = ( new Appointment_Model() )->where( 'order_id', $order->id );
1016 $keep_order = false;
1017
1018 foreach ( $appointments as $appointment ) {
1019 if ( $team_member && $appointment->team_member_id == $team_member ) {
1020 $keep_order = true;
1021 }
1022 if ( $service && $appointment->service_id == $service ) {
1023 $keep_order = true;
1024 }
1025 }
1026
1027 if ( ! $keep_order && ( $team_member || $service ) ) {
1028 unset( $orders['items'][ $key ] );
1029 --$orders['total'];
1030 }
1031 }
1032
1033 $orders['items'] = array_values( $orders['items'] );
1034 $orders['total_pages'] = ceil( $orders['total'] / $per_page );
1035 }
1036
1037 foreach ( $orders['items'] as &$order ) {
1038 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $order->customer_id ) );
1039 $customer_data_for_order = [
1040 'id' => $customer ? $customer->id : null,
1041 'name' => $customer ? $customer->name : '',
1042 'email' => '',
1043 'phone' => '',
1044 ];
1045 // Admins and team members see email and phone
1046 if ( booktics_is_admin_or_team_member() ) {
1047 $customer_data_for_order['email'] = $customer ? $customer->email : '';
1048 $customer_data_for_order['phone'] = $customer ? $customer->phone : '';
1049 }
1050 $order->customer = $customer_data_for_order;
1051 $order->total_services = ( new Appointment_Model() )->get_appointments_count_by_order_id( $order->id );
1052
1053 // Hide payment intent fields from team members (not admins)
1054 if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1055 $order->payment_intent_id = '';
1056 $order->client_secret = '';
1057 }
1058 }
1059
1060 return $this->response( $orders, __( 'Orders fetched successfully', 'booktics' ) );
1061 }
1062
1063 /**
1064 * Check if user has permission to update order.
1065 *
1066 * @param WP_REST_Request $request
1067 *
1068 * @return bool
1069 */
1070 public function update_item_permission( $request ) {
1071 return current_user_can( 'manage_options' );
1072 }
1073
1074 /**
1075 * Update an existing order.
1076 *
1077 * @param WP_REST_Request $request
1078 *
1079 * @return WP_Error|WP_HTTP_Response
1080 */
1081 public function update_item( $request ) {
1082 $order_id = absint( $request->get_param( 'id' ) );
1083 $data = json_decode( $request->get_body(), true );
1084 $db = new Booktics_Database();
1085 $order_model = new Order_Model( $db );
1086 $order = $order_model->find( array( 'id' => $order_id ) );
1087
1088 if ( ! $order ) {
1089 return $this->error( __( 'Order not found', 'booktics' ) );
1090 }
1091
1092 $status = isset( $data['status'] ) ? sanitize_text_field( $data['status'] ) : $order->status;
1093 $payment_status = isset( $data['payment_status'] ) ? sanitize_text_field( $data['payment_status'] ) : $order->payment_status;
1094
1095 $order_model->update(
1096 $order_id, array(
1097 'status' => $status,
1098 'payment_status' => $payment_status,
1099 )
1100 );
1101
1102 if ( $status !== $order->status ) {
1103 /** This action is documented in core/appointment/services/appointment-service.php */
1104 do_action( 'booktics_order_status_updated', $order_id, $status, $order->status );
1105 }
1106
1107 if ( isset( $data['customer'] ) && isset( $data['customer']['id'] ) ) {
1108 $customer_id = absint( $data['customer']['id'] );
1109 $customer_data = $data['customer'];
1110 $customer_data['id'] = $customer_id;
1111 if ( isset( $customer_data['name'] ) ) {
1112 $customer_data['name'] = sanitize_text_field( $customer_data['name'] );
1113 }
1114 if ( isset( $customer_data['email'] ) ) {
1115 $customer_data['email'] = sanitize_email( $customer_data['email'] );
1116 }
1117 if ( isset( $customer_data['phone'] ) ) {
1118 $customer_data['phone'] = sanitize_text_field( $customer_data['phone'] );
1119 }
1120 ( new Guest_Model( $db ) )->update( $customer_id, $customer_data );
1121 }
1122
1123 $order_model = $order_model->find( array( 'id' => $order_id ) );
1124 $data['customer_id'] = $order_model->customer_id;
1125
1126 $response = $this->format_order_response($db, $order_model, $data, null);
1127
1128 // Hide payment intent fields from team members (not admins)
1129 if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1130 $response['payment_intent_id'] = '';
1131 $response['client_secret'] = '';
1132 }
1133
1134 return $this->response( $response, __( 'Order updated successfully', 'booktics' ) );
1135 }
1136
1137 /**
1138 * Check if user has permission to get order.
1139 *
1140 * Staff always allowed. Logged-in customers allowed when the order belongs to their guest record.
1141 * Anonymous users allowed only when the request includes a valid order_key matching the order (same
1142 * value returned when the order was created).
1143 *
1144 * @param WP_REST_Request $request
1145 *
1146 * @return bool|WP_Error
1147 */
1148 public function get_item_permission( $request ) {
1149 if ( booktics_is_admin_or_team_member() ) {
1150 return true;
1151 }
1152
1153 $order_id = absint( $request->get_param( 'id' ) );
1154 $db = new Booktics_Database();
1155 $order = ( new Order_Model( $db ) )->find( array( 'id' => $order_id ) );
1156
1157 if ( ! $order ) {
1158 return true;
1159 }
1160
1161 if ( is_user_logged_in() ) {
1162 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $order->customer_id ) );
1163
1164 if ( $customer && intval( $customer->wp_user_id ) === get_current_user_id() ) {
1165 return true;
1166 }
1167 }
1168
1169 $order_key = sanitize_text_field( (string) $request->get_param( 'order_key' ) );
1170 if ( $order_key && $this->verify_order_view_key( $order_id, (string) $order->order_no, $order_key ) ) {
1171 return true;
1172 }
1173
1174 return new WP_Error(
1175 'booktics_forbidden',
1176 __( 'You are not allowed to view this order.', 'booktics' ),
1177 array( 'status' => 403 )
1178 );
1179 }
1180
1181 /**
1182 * Non-secret, unguessable key for viewing an order without logging in (returned on create and with GET responses).
1183 *
1184 * @param int $order_id
1185 * @param string $order_no
1186 *
1187 * @return string
1188 */
1189 private function get_order_view_key( $order_id, $order_no ) {
1190 return Order_Key::make( $order_id, $order_no );
1191 }
1192
1193 /**
1194 * @param int $order_id
1195 * @param string $order_no
1196 * @param string $provided_key
1197 *
1198 * @return bool
1199 */
1200 private function verify_order_view_key( $order_id, $order_no, $provided_key ) {
1201 return Order_Key::verify( $order_id, $order_no, $provided_key );
1202 }
1203
1204 /**
1205 * Get single order.
1206 *
1207 * @param WP_REST_Request $request
1208 *
1209 * @return WP_Error|WP_HTTP_Response
1210 */
1211 public function get_item( $request ) {
1212 $order_id = absint( $request->get_param( 'id' ) );
1213 $db = new Booktics_Database();
1214 $order_model = new Order_Model( $db );
1215 $order = $order_model->find( array( 'id' => $order_id ) );
1216
1217 if ( ! $order ) {
1218 return $this->error( __( 'Order not found', 'booktics' ) );
1219 }
1220
1221 // Use the format_order_response helper for consistency
1222 $input_data = array( 'customer_id' => $order->customer_id );
1223 $response = $this->format_order_response( $db, $order, $input_data, array() );
1224 // Add extra fields specific to GET if needed
1225 $response['status'] = $order->status;
1226 $response['created_at'] = $this->format_datetime_to_dayjs_format( $order->created_at );
1227 $response['payment_gateway'] = $order->payment_method;
1228 $response['payment_method'] = $order->payment_method;
1229
1230 // format_order_response() builds this from a freshly created payment
1231 // intent, which a GET does not have, so read the stored one instead.
1232 // The team-member guard below still strips it for non-admins.
1233 $response['payment_intent_id'] = $order->payment_intent_id ?? '';
1234
1235 // Hide payment intent fields from team members (not admins)
1236 if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1237 $response['payment_intent_id'] = '';
1238 $response['client_secret'] = '';
1239 }
1240
1241 return $this->response( $response, __( 'Order fetched successfully', 'booktics' ) );
1242 }
1243
1244 /**
1245 * Check if user has permission to update payment status.
1246 *
1247 * @param WP_REST_Request $request
1248 *
1249 * @return bool
1250 */
1251 public function update_payment_status_permission( $request ) {
1252 if ( current_user_can( 'manage_options' ) ) {
1253 return true;
1254 }
1255
1256 // Allow order owner with a valid order_key (guest checkout support).
1257 $order_id = absint( $request->get_param( 'id' ) );
1258 $order_key = sanitize_text_field( (string) $request->get_param( 'order_key' ) );
1259
1260 if ( ! $order_id || ! $order_key ) {
1261 return false;
1262 }
1263
1264 $db = new Booktics_Database();
1265 $order = ( new Order_Model( $db ) )->find( array( 'id' => $order_id ) );
1266
1267 if ( ! $order ) {
1268 return false;
1269 }
1270
1271 return $this->verify_order_view_key( $order_id, (string) $order->order_no, $order_key );
1272 }
1273
1274 /**
1275 * Update payment status for an order.
1276 *
1277 * @param WP_REST_Request $request
1278 *
1279 * @return WP_Error|WP_HTTP_Response
1280 */
1281 public function update_payment_status( $request ) {
1282 $data = json_decode( $request->get_body(), true );
1283 $data = is_array( $data ) ? $data : array();
1284
1285 // Only the order in the route is touched. The permission callback
1286 // authorized that id against the order_key; an order id in the body
1287 // would let a valid key for one order act on somebody else's.
1288 $order_id = absint( $request->get_param( 'id' ) );
1289
1290 $db = new Booktics_Database();
1291 $order_model = new Order_Model( $db );
1292 $order = $order_model->find( array( 'id' => $order_id ) );
1293
1294 if ( ! $order ) {
1295 return $this->error( __( 'Order not found', 'booktics' ) );
1296 }
1297
1298 // The gateway that took the money is the one that may report on it, so
1299 // prefer what the order was created with over what the client claims.
1300 $payment_method = ! empty( $order->payment_method )
1301 ? $order->payment_method
1302 : ( ! empty( $data['payment_method'] ) ? sanitize_text_field( (string) $data['payment_method'] ) : '' );
1303
1304 if ( ! array_key_exists( $payment_method, ( new Payment_Method_Manager() )->get_payment_methods() ) ) {
1305 return $this->error( __( 'Unauthorized payment method', 'booktics' ) );
1306 }
1307
1308 try {
1309 $payment_handler = ( new Payment_Method_Manager() )->get_payment_method( $payment_method );
1310 if ( ! $payment_handler ) {
1311 return $this->error( __( 'Payment method not found', 'booktics' ) );
1312 }
1313
1314 $status = $payment_handler->get_payment_status( $order );
1315 if ( is_wp_error( $status ) ) {
1316 return $this->error( $status->get_error_message() );
1317 }
1318
1319 $order_model->update(
1320 $order_id, array(
1321 'payment_status' => $status,
1322 'transaction_id' => isset( $data['transaction_id'] ) ? sanitize_text_field( (string) $data['transaction_id'] ) : null,
1323 )
1324 );
1325
1326 if (
1327 $status === Payment_Status::COMPLETED
1328 && $order->payment_status !== Payment_Status::COMPLETED
1329 ) {
1330 // The payment status is already persisted above. Sending notifications must
1331 // not fail the request, otherwise a confirmed booking shows an error toast.
1332 try {
1333 $appointments = ( new Appointment_Model() )->where( 'order_id', $order_id );
1334 $this->send_notifications( $db, $order, $order->customer_id, $appointments );
1335 } catch ( \Throwable $e ) {
1336 booktics_log( 'failed to send notifications after payment for order ' . $order_id . ': ' . $e->getMessage() );
1337 }
1338 } elseif ( $status === Payment_Status::FAILED ) {
1339 // Payment was declined/canceled on the processor. Release the held slot
1340 // immediately by canceling the pending appointments for this order, so the
1341 // customer does not have to wait for the admin_init sweep.
1342 ( new Abandoned_Booking_Cleanup() )->release_appointments( $order_id );
1343 }
1344
1345 // Payment model status and transaction id update
1346 return $this->response(
1347 array(
1348 'payment_status' => $status,
1349 ), __( 'Payment status updated successfully', 'booktics' )
1350 );
1351 } catch ( \Exception $e ) {
1352
1353 booktics_log( 'An error occurred while updating payment status: ' . $e->getMessage() );
1354 return $this->error( __( 'An error occurred while updating payment status.', 'booktics' ) );
1355 }
1356 }
1357
1358 /**
1359 * Check if user has permission to delete order.
1360 *
1361 * @param WP_REST_Request $request
1362 *
1363 * @return bool
1364 */
1365 public function delete_item_permission( $request ) {
1366 return current_user_can( 'manage_options' );
1367 }
1368
1369 /**
1370 * Delete an order.
1371 *
1372 * @param WP_REST_Request $request
1373 *
1374 * @return WP_Error|WP_HTTP_Response
1375 */
1376 public function delete_item( $request ) {
1377 $order_id = absint( $request->get_param( 'id' ) );
1378 $db = new Booktics_Database();
1379 $order_model = new Order_Model( $db );
1380 $order = $order_model->find( array( 'id' => $order_id ) );
1381
1382 if ( ! $order ) {
1383 return $this->error( __( 'Order not found', 'booktics' ) );
1384 }
1385
1386 try {
1387 $db->begin_transaction();
1388
1389 // Delete related appointments
1390 $appointment_model = new Appointment_Model();
1391 $appointments = $appointment_model->where( 'order_id', $order_id );
1392 foreach ( $appointments as $appointment ) {
1393 $appointment->delete();
1394 }
1395
1396 // Delete related payments
1397 $payment_model = new Payment_Model( $db );
1398 $payments = $payment_model->get( array( 'order_id' => $order_id ) );
1399 foreach ( $payments['items'] as $payment ) {
1400 ( new Payment_Model( $db ) )->delete( $payment->id );
1401 }
1402 // Delete the order
1403 $order_model->delete( $order_id );
1404
1405 $db->commit();
1406
1407 return $this->response( array(), __( 'Order deleted successfully', 'booktics' ) );
1408 } catch ( Exception $e ) {
1409 $db->roll_back();
1410
1411 return $this->error( $e->getMessage() );
1412 }
1413 }
1414
1415 /**
1416 * Check if user has permission to bulk delete orders.
1417 *
1418 * @param WP_REST_Request $request
1419 *
1420 * @return bool
1421 */
1422 public function bulk_delete_items_permission( $request ) {
1423 return current_user_can( 'manage_options' );
1424 }
1425
1426 /**
1427 * Bulk delete orders.
1428 *
1429 * @param WP_REST_Request $request
1430 *
1431 * @return WP_Error|WP_HTTP_Response
1432 */
1433 public function bulk_delete_items( $request ) {
1434 $ids_param = $request->get_param( 'ids' );
1435 $order_ids = is_array( $ids_param ) ? array_map( 'absint', $ids_param ) : array();
1436 $db = new Booktics_Database();
1437
1438 try {
1439 $db->begin_transaction();
1440
1441 foreach ( $order_ids as $order_id ) {
1442 $order_model = new Order_Model( $db );
1443 $order = $order_model->find( array( 'id' => $order_id ) );
1444
1445 if ( ! $order ) {
1446 continue;
1447 }
1448
1449 // Delete related appointments
1450 $appointment_model = new Appointment_Model();
1451 $appointments = $appointment_model->where( 'order_id', $order_id );
1452 foreach ( $appointments as $appointment ) {
1453 $appointment->delete();
1454 }
1455
1456 // Delete related payments
1457 $payment_model = new Payment_Model( $db );
1458 $payments = $payment_model->get( array( 'order_id' => $order_id ) );
1459 foreach ( $payments['items'] as $payment ) {
1460 ( new Payment_Model( $db ) )->delete( $payment->id );
1461 }
1462
1463 // Delete the order
1464 $order_model->delete( $order_id );
1465 }
1466
1467 $db->commit();
1468
1469 return $this->response( array(), __( 'Orders deleted successfully', 'booktics' ) );
1470 } catch ( Exception $e ) {
1471 $db->roll_back();
1472
1473 return $this->error( $e->getMessage() );
1474 }
1475 }
1476
1477 /**
1478 * Check if the current user may refund an order.
1479 *
1480 * Refunding moves money and must never be reachable by the customer. This
1481 * route previously reused update_payment_status_permission, which also
1482 * accepts a valid order_key so that a guest can confirm their own payment —
1483 * meaning any customer holding their own order key could refund themselves.
1484 *
1485 * @param WP_REST_Request $request
1486 *
1487 * @return bool
1488 */
1489 public function refund_payment_permission( $request ) {
1490 return current_user_can( 'manage_options' );
1491 }
1492
1493 /**
1494 * Process payment refund for an order.
1495 *
1496 * @param WP_REST_Request $request
1497 *
1498 * @return WP_Error|WP_HTTP_Response
1499 */
1500 public function refund_payment( $request ) {
1501 $order_id = absint( $request->get_param( 'id' ) );
1502 $db = new Booktics_Database();
1503 $order_model = new Order_Model( $db );
1504 $order = $order_model->find( array( 'id' => $order_id ) );
1505
1506 if ( ! $order || is_wp_error( $order ) ) {
1507 return $this->error( __( 'Order not found', 'booktics' ), 404 );
1508 }
1509
1510 $payment_method = ( new Payment_Method_Manager() )->get_payment_method( $order->payment_method );
1511 if ( ! $payment_method ) {
1512 return $this->error( __( 'Invalid payment method!', 'booktics' ) );
1513 }
1514 $response = $payment_method->process_refund( $order );
1515
1516 // A gateway may refuse outright — WooCommerce-paid orders are refunded
1517 // from WooCommerce admin — and must not leave the order marked refunded
1518 // when no money moved.
1519 if ( is_wp_error( $response ) ) {
1520 return $this->error( $response->get_error_message() );
1521 }
1522
1523 if ( ! is_array( $response ) || ( $response['status'] ?? '' ) === 'error' ) {
1524 return $this->error( $response['message'] ?? __( 'Refund failed', 'booktics' ) );
1525 }
1526
1527 $order_model->update(
1528 $order_id, array(
1529 'payment_status' => Payment_Status::REFUNDED,
1530 )
1531 );
1532
1533 return $this->response(
1534 $order_model->find( array( 'id' => $order_id ) ),
1535 __( 'Refund successful', 'booktics' )
1536 );
1537 }
1538
1539 /**
1540 * Calculate total duration in minutes for a service appointment.
1541 *
1542 * @param array $item
1543 *
1544 * @return int
1545 */
1546 private function calculate_total_duration( $item ) {
1547 $service_model = Service_Model::find( $item['service_id'] );
1548 if ( ! $service_model ) {
1549 return 0;
1550 }
1551 $service_duration = array_filter(
1552 $service_model->additional_durations, function ( $duration ) use ( $item ) {
1553 return $item['additional_duration_id'] === $duration['id'];
1554 }
1555 );
1556 if ( empty( $service_duration ) ) {
1557 return 0;
1558 }
1559 $service_duration = reset( $service_duration );
1560 $total_duration_min = $service_duration['duration_unit'] === 'min'
1561 ? $service_duration['duration_time']
1562 : ( $service_duration['duration_time'] * 60 );
1563 if ( ! empty( $item['extra_services'] ) ) {
1564 $total_duration_min = apply_filters( 'booktics_extra_service_duration', $total_duration_min, $item['extra_services'] );
1565 }
1566
1567 $total_duration_min = apply_filters( 'booktics_calculate_total_duration', $total_duration_min, $item );
1568
1569 return $total_duration_min;
1570 }
1571
1572 /**
1573 * Converts a datetime string to ISO 8601 format with WordPress timezone for dayjs compatibility
1574 *
1575 * @param string $datetime Datetime string from database (e.g., "2024-01-15 14:00:00")
1576 *
1577 * @return string ISO 8601 formatted datetime with timezone offset (e.g., "2024-01-15T14:00:00-05:00")
1578 */
1579 private function format_datetime_to_dayjs_format( $datetime ) {
1580 $dt = new DateTime( $datetime );
1581 $dt->setTimezone( wp_timezone() );
1582 return $dt->format( 'Y-m-d H:i:s' );
1583 }
1584 }
1585