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
booktics / core / order / controllers / order-controller.php

order-controller.php in Booktics – Appointment Booking Calendar for Service Businesses 1.0.29, at core/order/controllers/order-controller.php

1,587 lines 61.7 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 // 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 }
1587