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

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