namespace, $this->base, array( array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_item' ), 'permission_callback' => array( $this, 'create_order_permission', ), ), array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), 'permission_callback' => array( $this, 'get_items_permission', ), 'args' => array( 'page' => array( 'default' => 1 ), 'per_page' => array( 'default' => 10 ), ), ), array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'bulk_delete_items', ), 'permission_callback' => array( $this, 'bulk_delete_items_permission', ), ), ) ); register_rest_route( $this->namespace, $this->base . '/(?P[\d]+)', array( array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'update_item' ), 'permission_callback' => array( $this, 'update_item_permission', ), 'args' => array( 'id' => array( 'required' => true ), 'status' => array( 'type' => 'string' ), ), ), array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_item' ), 'permission_callback' => array( $this, 'get_item_permission', ), 'args' => array( 'id' => array( 'required' => true ), 'order_key' => array( 'description' => __( 'Secret key that proves the client may view this order without logging in (returned when the order is created).', 'booktics' ), 'type' => 'string', 'required' => false, ), ), ), array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'delete_item' ), 'permission_callback' => array( $this, 'delete_item_permission', ), 'args' => array( 'id' => array( 'required' => true ), ), ), ) ); register_rest_route( $this->namespace, $this->base . '/(?P[\d]+)/payment', array( array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'update_payment_status', ), 'permission_callback' => array( $this, 'update_payment_status_permission', ), ), ) ); register_rest_route( $this->namespace, $this->base . '/(?P[\d]+)/refund', array( array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'refund_payment' ), 'permission_callback' => array( $this, 'update_payment_status_permission', ), ), ) ); } /** * Check if the current user has permission to create orders. * * @param WP_REST_Request $request * * @return bool */ public function create_order_permission( $request ) { return true; } /** * Create a new order with the provided data * * @param WP_REST_Request $request * * @return WP_Error|WP_HTTP_Response * @throws Exception */ public function create_item( $request ) { $input_data = json_decode( $request->get_body(), true ); // Single comprehensive validation and preparation $validation_result = $this->validate_and_prepare_order_data( $input_data ); if ( is_wp_error( $validation_result ) ) { $validation_error_msg = $validation_result->get_error_message() ?? __( 'Order validation failed', 'booktics' ); return $this->error( $validation_error_msg, 422, 'Validation' ); } $db = new Booktics_Database(); // Resolve customer ID $customer_id_result = $this->resolve_customer_id( $input_data ); if ( is_wp_error( $customer_id_result ) ) { return $this->error( $customer_id_result->get_error_message() ); } $input_data['customer_id'] = $customer_id_result; try { $db->begin_transaction(); list( $order_model, $appointments ) = $this->create_order_and_appointments( $input_data, $db ); $payment_intent = $this->handle_payment_if_needed( $input_data, $order_model, $db ); $db->commit(); } catch ( Exception $e ) { $db->roll_back(); return $this->error( $e->getMessage() ); } if ( empty( $input_data['pay_now'] ) ) { $this->send_notifications( $db, $order_model, $input_data['customer_id'], $appointments ); } $response = $this->format_order_response( $db, $order_model, $input_data, $payment_intent ); do_action('booktics_after_order_created', $order_model, $appointments); $response_message = 'Order created successfully'; if ( isset( $input_data['pay_now']) && $input_data['pay_now'] ) { $response_message = 'Order pending, please proceed payment'; } return $this->response( $response, sprintf( /* translators: %s: dynamic response message */ __( 'Response %s', 'booktics' ), $response_message ), 201 ); } /** * Comprehensive validation and preparation of order data * * @param array $input_data * * @return array|WP_Error */ private function validate_and_prepare_order_data( &$input_data ) { // Step 1: Basic validation using Order_Validator $validator = new Order_Validator(); $validation_result = $validator->validate( $input_data ); if ( is_wp_error( $validation_result ) ) { return $validation_result; } // Step 2: Preserve original timezone for appointment creation, then validate and setup working timezone $input_data['original_timezone'] = $input_data['timezone'] ?? wp_timezone_string(); if ( ! isset( $input_data['timezone'] ) || ! Time_Zone_Handler::validate_time_zone( $input_data['timezone'] ) ) { $input_data['timezone'] = 'UTC'; } // Step 3: Payment validation if pay_now is true if ( isset( $input_data['pay_now'] ) && $input_data['pay_now'] ) { $validate = booktics_validate( $input_data, array( 'payment_method' => array( 'required' ), ) ); if ( is_wp_error( $validate ) ) { return $validate; } } // Step 4: Process and validate each item if ( ! is_array( $input_data['items'] ) || count( $input_data['items'] ) == 0 ) { return new WP_Error( 'items', __( 'Items are required', 'booktics' ) ); } foreach ( $input_data['items'] as $key => $item ) { // Validate required fields for each item $item_validate = booktics_validate( $item, array( 'date' => array( 'required' ), 'start_time' => array( 'required' ), 'end_time' => array( 'required' ), 'team_member_id' => array( 'required' ), 'service_id' => array( 'required' ), ) ); if ( is_wp_error( $item_validate ) ) { return $item_validate; } // Convert times to UTC $converted_start_time = Time_Zone_Handler::convert_to_utc( $item['start_time'], $item['date'], $input_data['timezone'] ); $converted_end_time = Time_Zone_Handler::convert_to_utc( $item['end_time'], $item['date'], $input_data['timezone'] ); // Validate time range $start_time = strtotime( $converted_start_time['time'] ); $end_time = strtotime( $converted_end_time['time'] ); if ( ! $start_time || ! $end_time || $start_time >= $end_time ) { return new WP_Error( 'time_invalid', __( 'Invalid time range provided', 'booktics' ) ); } // In validate_and_prepare_order_data(), after converting times: $item_with_converted_times = array_merge($item, [ 'start_time' => $converted_start_time['time'], 'end_time' => $converted_end_time['time'], 'date' => gmdate('Y-m-d', strtotime($converted_start_time['date'])) ]); $item_timet_slot_validate = $this->validate_appointment_time_is_free($item_with_converted_times); if ( is_wp_error( $item_timet_slot_validate ) ) { return $item_timet_slot_validate; } // Sanitize and prepare item data $input_data['items'][ $key ] = array( 'start_time' => $converted_start_time['time'], 'end_time' => $converted_end_time['time'], 'date' => gmdate( 'Y-m-d', strtotime( $item['date'] ) ), 'customer_id' => isset( $item['customer_id'] ) ? absint( $item['customer_id'] ) : null, 'team_member_id' => absint( $item['team_member_id'] ), 'service_id' => absint( $item['service_id'] ), 'additional_duration_id' => booktics_sanitize_id_string( $item['additional_duration_id'] ?? '' ), 'price' => floatval( $item['price'] ?? 0 ), 'subtotal' => floatval( $item['subtotal'] ?? 0 ), 'quantity' => absint( $item['quantity'] ?? 1 ), 'total_attendees' => absint( $item['total_attendees'] ?? 1 ), ); $input_data['items'][$key] = apply_filters('booktics_pro_appointment_validate', $input_data['items'][$key], $item); } $input_data = apply_filters( 'booktics_pro_order_validate', $input_data ); return $input_data; } /** * Check if the requested time slot has available capacity for the given team member * Works by checking all appointments for this team member on this date and calculating remaining capacity * * @param array $item Appointment item data * @return bool|WP_Error True if time slot has capacity, WP_Error if not */ private function validate_appointment_time_is_free( $item ) { try { // Convert times to DateTime objects $requested_start = new \DateTime( $item['date'] . ' ' . $item['start_time'] ); $requested_end = new \DateTime( $item['date'] . ' ' . $item['end_time'] ); // Get the requested attendee count (default to 1 for backward compatibility) $requested_attendees = isset( $item['total_attendees'] ) ? (int) $item['total_attendees'] : 1; // Get service to check if group booking is enabled and get max capacity $service = Service_Model::find($item['service_id']); if ( ! $service ) { return new \WP_Error( 'service_not_found', __( 'Service not found', 'booktics' ) ); } $max_group_size = $this->get_service_max_group_size( $service->id ); $args = [ 'team_member_id' => $item['team_member_id'], 'date' => $item['date'], 'post_status' => booktics_get_option('appointment_statuses_for_blocked_slot', []), ]; // Get all non-cancelled appointments for this team member on this date $appointments = Appointment_Model::all( $args ); if ( ! is_array( $appointments ) || empty( $appointments ) ) { // No existing appointments, check if requested attendees fit within max capacity if ( $requested_attendees > $max_group_size ) { /* translators: %1$d = requested attendees, %2$d = maximum group size */ 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 ) ); } return true; } // Calculate total attendees for overlapping time slots $overlapping_attendees = 0; foreach ( $appointments as $appointment ) { // Skip if this is the same appointment (for updates) if ( isset($item['id']) && $appointment->ID == $item['id'] ) { continue; } // Guard against legacy/localized records whose stored date/time cannot be // parsed; skipping them keeps a single bad row from blocking every booking. try { $appt_start = new \DateTime( $appointment->date . ' ' . $appointment->start_time ); $appt_end = new \DateTime( $appointment->date . ' ' . $appointment->end_time ); } catch ( \Throwable $e ) { if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { error_log( 'Booktics: skipping appointment ' . ( $appointment->ID ?? '?' ) . ' with unparseable date/time during slot validation: ' . $e->getMessage() ); } continue; } // Check for time overlap if ( ! ( $requested_end <= $appt_start || $requested_start >= $appt_end ) ) { // Times overlap, add this appointment's attendees to the count $appointment_attendees = $appointment->total_attendees ?? 1; $overlapping_attendees += $appointment_attendees; } } // Check if adding the requested attendees would exceed capacity $total_after_booking = $overlapping_attendees + $requested_attendees; if ($total_after_booking > $max_group_size) { return new \WP_Error( 'insufficient_capacity', __( 'Insufficient capacity' , 'booktics' ) ); } return true; } catch ( \Throwable $e ) { return new \WP_Error( 'time_validation_error', __( 'Error validating appointment time. Please try another slot.', 'booktics' ) ); } } /** * Get max group size for a specific service * * @param int $service_id Service ID * * @return int Max group size */ private function get_service_max_group_size( $service_id ) { $default_max_size = 1; $group_booking_meta = get_post_meta( $service_id, 'group_booking', true ); if ( ! $this->is_group_booking_enabled( $group_booking_meta ) ) { return $default_max_size; } return $this->extract_max_booking_size( $group_booking_meta, $default_max_size ); } /** * Checks if group booking is enabled for a services group booking meta data * * @param array $group_booking_meta Group booking meta data * * @return bool True if group booking is enabled, false otherwise */ private function is_group_booking_enabled( $group_booking_meta ) { return !empty( $group_booking_meta ) && is_array( $group_booking_meta ) && !empty( $group_booking_meta['enable_group_booking'] ); } /** * Extracts the max booking size from the group booking meta data of a service * * @param array $group_booking_meta Group booking meta data * @param int $default_size Default max booking size * * @return int Max booking size */ private function extract_max_booking_size( $group_booking_meta, $default_size ) { if ( isset( $group_booking_meta['max_booking'] ) && is_numeric( $group_booking_meta['max_booking'] ) ) { return (int) $group_booking_meta['max_booking']; } return $default_size; } /** * Resolve the customer ID from input data, creating or finding the customer as needed * * @param array $input_data * * @return int|WP_Error */ private function resolve_customer_id( &$input_data ) { if ( isset( $input_data['customer_id'] ) ) { return $input_data['customer_id']; } if ( ! isset( $input_data['customer'] ) ) { return new WP_Error( 'missing_customer', __( 'Customer information is required', 'booktics' ) ); } return $this->find_or_create_customer( $input_data['customer'] ); } /** * Create the order and its associated appointments * * @param array $input_data * @param Booktics_Database $db * * @return array [Order_Model $order_model, array $appointments] */ private function create_order_and_appointments( $input_data, $db ) { $input_data['currency'] = ! empty( $input_data['currency'] ) ? strtolower( $input_data['currency'] ) : strtolower( booktics_get_option( 'currency', 'USD' ) ); $order_model = new Order_Model( $db ); $input_timezone_string = $input_data['timezone'] ?? 'UTC'; $input_timezone = Time_Zone_Handler::get_timezone_from_string( $input_timezone_string ); $original_timezone = $input_data['original_timezone'] ?? $input_timezone_string; $order_data = array( 'customer_id' => $input_data['customer_id'], 'payment_status' => Payment_Status::FAILED, 'price_breakdown' => $input_data['price_breakdown'] ?? array(), 'status' => 'open', 'tax_total' => $input_data['tax_total'] ?? 0, 'coupon_code' => $input_data['coupon_code'] ?? '', 'coupon_discount' => $input_data['coupon_discount'] ?? 0, 'subtotal' => $input_data['subtotal'], 'timezone' => $input_timezone, 'total' => $input_data['total'], 'currency' => $input_data['currency'], 'payment_method' => $input_data['payment_method'] ?? null, ); // This is to only allow setting payment status when creating order from admin panel as admin if ( isset( $input_data['payment_status'] ) && current_user_can( 'manage_options' ) ) { $order_data['payment_status'] = $input_data['payment_status']; } $order_data = apply_filters( 'booktics_pro_order_columns', $order_data, $input_data ); $order_model = $order_model->create( $order_data ); $appointments = array(); foreach ( $input_data['items'] as $item ) { if ( isset( $item['uuid'] ) ) { ( new Cart_Manager() )->remove_cart_item( $item['uuid'] ); } $total_duration_min = $this->calculate_total_duration( $item ); // Store date/time in a locale-independent format. wp_date() localizes digits and // am/pm markers (e.g. Bengali "১১:০০ সকাল"), and such values cannot be parsed back // by DateTime, breaking slot validation and notifications. DateTime::format() keeps // the same timezone-aware value but is never localized. $time_format = get_option( 'time_format' ); $start_local = ( new \DateTime( '@' . strtotime( $item['start_time'] ) ) )->setTimezone( $input_timezone ); $end_local = ( new \DateTime( '@' . strtotime( $item['end_time'] ) ) )->setTimezone( $input_timezone ); $date_local = ( new \DateTime( '@' . strtotime( $item['date'] ) ) )->setTimezone( $input_timezone ); $appointment_data = array( 'start_time' => $start_local->format( $time_format ), 'end_time' => $end_local->format( $time_format ), 'date' => $date_local->format( 'Y-m-d' ), 'customer_id' => $input_data['customer_id'] ?? '', 'team_member_id' => $item['team_member_id'] ?? '', 'service_id' => $item['service_id'] ?? '', 'additional_duration_id' => booktics_sanitize_id_string( $item['additional_duration_id'] ?? '' ), 'duration' => $total_duration_min, 'order_id' => $order_model->id, 'quantity' => $item['quantity'] ?? 1, 'price' => $item['price'] ?? 0, 'subtotal' => $item['subtotal'] ?? 0, 'total' => $item['total'] ?? $item['subtotal'] ?? 0, 'status' => booktics_get_option( 'appointment_status', 'pending' ), 'total_attendees' => $input_data['total_attendees'] ?? 1, 'timezone' => $original_timezone, ); $appointment_data = apply_filters( 'booktics_appointment_pro_format', $appointment_data, $item ); $appointment = Appointment_Model::create( $appointment_data ); if ( is_wp_error( $appointment ) ) { throw new Exception( esc_html( $appointment->get_error_message() ) ); } do_action( 'booktics_after_appointment_created', $appointment ); $appointments[] = $appointment; } return array( $order_model, $appointments ); } /** * Process payment for an order if immediate payment is requested * * @param array $input_data Order input data containing payment details * @param Order_Model $order_model Order model instance * @param Booktics_Database $db Database instance * * @return array * @throws Exception */ private function handle_payment_if_needed( $input_data, $order_model, $db ) { if ( ! isset( $input_data['pay_now'] ) || ! $input_data['pay_now'] ) { return array(); } $payment_method = ( new Payment_Method_Manager() )->get_payment_method( $input_data['payment_method'] ); if ( ! $payment_method ) { throw new Exception( esc_html__( 'Payment method cannot be found!', 'booktics' ) ); } $payment_intent = $payment_method->process_payment( $order_model ); if ( $payment_intent['status'] === 'error' ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message is not directly output; escaping is handled at the display layer throw new Exception( $payment_intent['message'] ); } if ( $payment_intent['status'] === 'success' ) { $this->update_payment_records( $db, $order_model, $input_data, $payment_intent ); // Do NOT set fully_paid here — PaymentIntent created ≠ payment collected. // Status is updated after client-side confirmation via update_payment_status endpoint. } return $payment_intent; } /** * Update payment records after successful payment processing * * @param Booktics_Database $db Database instance * @param Order_Model $order_model Order model instance * @param array $input_data Order input data containing payment details * @param array $payment_intent Payment intent data from payment processor * * @return void */ private function update_payment_records( $db, $order_model, $input_data, $payment_intent ) { ( new Order_Model( $db ) )->update( $order_model->id, array( 'payment_intent_id' => $payment_intent['payment_intent'] ) ); ( new Payment_Model( $db ) )->create( array( 'order_id' => $order_model->id, 'customer_id' => $input_data['customer_id'], 'amount' => $input_data['total'] * 100, 'currency' => $input_data['currency'] ?? booktics_get_option( 'currency', 'USD' ), 'intent_id' => $payment_intent['payment_intent'], 'transaction_id' => null, 'payment_method' => $input_data['payment_method'], 'status' => $payment_intent['status'] == 'success' ? 'completed' : 'pending', 'date' => gmdate( 'Y-m-d' ), 'date_time' => gmdate( 'Y-m-d H:i:s' ), ) ); } /** * Resolve team member email with WP user fallback. * * Model hydration may miss user_email if the team-member WP user row is * stale or if find() returned null. Fall back to get_userdata so the * notification SDK does not skip the team email node. */ private function resolve_team_member_email( string $primary, $team_member_id ): string { if ( ! empty( $primary ) ) { return $primary; } $team_member_id = absint( $team_member_id ); if ( ! $team_member_id ) { return ''; } $user = get_userdata( $team_member_id ); return $user && ! empty( $user->user_email ) ? $user->user_email : ''; } /** * Helper: Format a single appointment for API output * * @param Appointment_Model|array $appointment * * @return array */ private function format_appointment( $appointment ): array { if ( is_object( $appointment ) && method_exists( $appointment, 'to_array' ) ) { $appointment = $appointment->to_array(); } $team_member = ( new Team_Member_Model() )->find( $appointment['team_member_id'] ); $service = ( new Service_Model() )->find( $appointment['service_id'] ); $service = $service ? $service->to_array() : array(); $additional_durations = $service['additional_durations'] ?? array(); $additional_durations = ! empty( $additional_durations ) ? array_filter( $additional_durations, function ( $duration ) use ( $appointment ) { return $duration['id'] === $appointment['additional_duration_id']; } ) : array(); $additional_duration = ! empty( $additional_durations ) ? reset( $additional_durations ) : array(); $formatted_appointment = array( 'id' => $appointment['id'] ?? 0, 'date' => $appointment['date'] ?? '', 'start_time' => $appointment['start_time'] ?? '', 'end_time' => $appointment['end_time'] ?? '', 'additional_duration_id' => $appointment['additional_duration_id'] ?? '', 'team' => array( 'id' => $team_member->id ?? null, 'display_name' => $team_member->display_name ?? '', 'image' => $team_member->image ?? '', 'user_email' => $team_member->user_email ?? '', ), 'service' => array( 'id' => $appointment['service_id'] ?? '', 'title' => $service['title'] ?? '', 'additional_duration' => $additional_duration, 'enable_google_meet' => $service['enable_google_meet'] ?? false, ), 'duration' => $appointment['duration'] ?? null, 'quantity' => $appointment['quantity'] ?? 1, 'price' => $appointment['price'] ?? 0, 'subtotal' => $appointment['subtotal'] ?? 0, 'total' => $appointment['total'] ?? 0, 'status' => $appointment['status'] ?? '', 'total_attendees' => $appointment['total_attendees'] ?? 1, 'appointment_token' => $appointment['security_token'] ?? '', 'cancel_note' => $appointment['cancel_note'] ?? '', ); $formatted_appointment = apply_filters( 'booktics_appointment_pro_format', $formatted_appointment, $appointment ); return $formatted_appointment; } /** * Helper: Format order for API output * * @param Order_Model $order_model * @param array $input_data * @param array $payment_intent * * @return array */ private function format_order_response( $db, $order_model, $input_data, $payment_intent ): array { $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $input_data['customer_id'] ) ); // Ensure $order_model is an object before accessing properties $order_id = is_object( $order_model ) ? $order_model->id : ( is_array( $order_model ) ? $order_model['id'] : null ); $appointments = ( new Appointment_Model() )->where( 'order_id', $order_id ); $appointment_details = array(); foreach ( $appointments as $appointment ) { $appointment_details[] = $this->format_appointment( $appointment ); } $order_no_for_key = is_object( $order_model ) ? $order_model->order_no : ( $order_model['order_no'] ?? '' ); $order_data = array( 'id' => $order_id, 'order_no' => is_object( $order_model ) ? $order_model->order_no : ( $order_model['order_no'] ?? null ), 'order_key' => $this->get_order_view_key( (int) $order_id, (string) $order_no_for_key ), 'customer' => array( 'id' => $customer->id ?? null, 'name' => $customer->name ?? '', 'phone' => $customer->phone ?? '', 'email' => $customer->email ?? '', ), 'status' => is_object( $order_model ) ? $order_model->status : ( $order_model['status'] ?? null ), 'subtotal' => is_object( $order_model ) ? $order_model->subtotal : ( $order_model['subtotal'] ?? null ), 'total' => is_object( $order_model ) ? $order_model->total : ( $order_model['total'] ?? null ), 'payment_status' => is_object( $order_model ) ? $order_model->payment_status : ( $order_model['payment_status'] ?? null ), 'payment_method' => is_object( $order_model ) ? $order_model->payment_method : $order_model['payment_method'], 'items' => $appointment_details, 'payment_intent_id' => $payment_intent['payment_intent'] ?? '', 'client_secret' => $payment_intent['client_secret'] ?? '', 'total_services' => $input_data['total_services'] ?? 1, ); $order_data = apply_filters( 'booktics_pro_order_format', $order_data, $order_model ); return $order_data; } /** * Helper: Send notifications for order and appointment creation * * @param Order_Model $order_model * @param int $customer_id * @param array $appointments * * @return void */ private function send_notifications( $db, $order_model, $customer_id, $appointments ): void { $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $customer_id ) ); foreach ( $appointments as $appointment ) { $formatted_appointment = $this->format_appointment( $appointment ); $appointment_time = $formatted_appointment['date'] . ' ' . $formatted_appointment['start_time']; $dt = new DateTime( $appointment_time, wp_timezone() ); $timestamp = $dt->getTimestamp(); $appointment_datetime = wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $timestamp, wp_timezone() ); $appointment_array = is_object( $appointment ) && method_exists( $appointment, 'to_array' ) ? $appointment->to_array() : (array) $appointment; $team_member_email = $this->resolve_team_member_email( $formatted_appointment['team']['user_email'] ?? '', $appointment_array['team_member_id'] ?? 0 ); $notification_flow_data_for_appointment = array( 'appointment_service' => $formatted_appointment['service']['title'] ?? '', 'appointment_team_member' => $formatted_appointment['team']['display_name'] ?? '', 'start_time' => $formatted_appointment['start_time'], 'appointment_date' => $formatted_appointment['date'], 'appointment_time' => $appointment_datetime, 'attendee_count' => $formatted_appointment['attendee_count'] ?? null, 'customer_name' => $customer->name ?? '', 'user_email' => $customer->email ?? '', 'appointment_team_member_email' => $team_member_email, ); $notification_flow_data_for_appointment = apply_filters( 'booktics_appointment_scheduled_notification_flow_data', $notification_flow_data_for_appointment, $formatted_appointment ); do_action( 'booktics_gln_hook', 'appointment_scheduled', $notification_flow_data_for_appointment ); } $appointment = $appointments[0]; // As currently only one appointment is created for each user $formatted_appointment = $this->format_appointment( $appointment ); $notification_flow_data_for_order = [ 'order_no' => $order_model->order_no ?? '', 'order_date' => $order_model->created_at ?? '', 'order_total' => $order_model->total ?? 0, 'customer_name' => $customer->name ?? '', 'user_email' => $customer->email ?? '', 'order_status' => $order_model->status ?? '', 'service_name' => $formatted_appointment['service']['title'] ?? '', 'team_name' => $formatted_appointment['team']['display_name'] ?? '', 'start_time' => $formatted_appointment['start_time'] ?? '', 'end_time' => $formatted_appointment['end_time'] ?? '', 'appointment_date' => $formatted_appointment['date'] ?? '', 'location_address' => $formatted_appointment['location']['address'] ?? '', 'appointment_status' => $formatted_appointment['status'] ?? '', 'appointment_cancel_url' => $appointment->get_cancel_link(), 'appointment_reschedule_url' => $appointment->get_reschedule_link(), ]; $notification_flow_data_for_order = apply_filters( 'booktics_order_created_notification_flow_data', $notification_flow_data_for_order, $formatted_appointment, $order_model ); do_action( 'booktics_gln_hook', 'order_created', $notification_flow_data_for_order ); } /** * Find existing customer by email or create new customer * * @param array $customer * * @return int|WP_Error */ private function find_or_create_customer( array $customer ) { $db = new Booktics_Database(); if ( ! isset( $customer['email'] ) ) { return new WP_Error( 'invalid_email', __( 'Customer email is required', 'booktics' ) ); } $user_login = User_Model::generate_username( $customer['email'] ); $allow_customer_as_wp_user = booktics_get_option( 'allow_customer_as_wp_user', false ); $wp_user_id = null; if ( $allow_customer_as_wp_user ) { $wp_user_id = $this->get_or_create_wp_user( $customer, $user_login ); } $guest = $this->find_and_update_guest( $db, $customer, $wp_user_id ); if ( $guest ) { return $guest->id; } $guest = $this->create_guest( $db, $customer, $user_login, $wp_user_id ); return $guest->id; } /** * Get or create a WordPress user for the customer * * @param array $customer * @param string $user_login * * @return int WP user ID */ private function get_or_create_wp_user( $customer, $user_login ) { $wp_user = get_user_by( 'email', $customer['email'] ); if ( $wp_user ) { return $wp_user->ID; } $userdata = array( 'user_login' => $user_login, 'user_email' => $customer['email'], 'first_name' => $customer['name'] ?? '', 'display_name' => $customer['name'] ?? '', 'phone' => $customer['phone'] ?? '', ); $customer_model = new Customer_Model(); $customer_id = $customer_model->save( $userdata ); ( new Customer_Event_Handler() )->on_customer_create( $customer_id ); return $customer_id; } /** * Find and update a guest record by email * * @param Booktics_Database $db * @param array $customer * * @return object|null Guest_Model instance or null */ private function find_and_update_guest( $db, $customer, $wp_user_id = null ) { $guest = ( new Guest_Model( $db ) )->find( array( 'email' => $customer['email'] ) ); if ( $guest && $this->caller_owns_customer_email( $guest, $customer['email'] ) ) { ( new Guest_Model( $db ) )->update( $guest->id, array( 'name' => $customer['name'] ?? '', 'phone' => $customer['phone'] ?? '', 'wp_user_id' => $wp_user_id, ) ); } return $guest; } /** * Determine whether the current request is authorized to overwrite an * existing customer's stored contact details (name, phone, wp_user_id). * * @param object $guest Existing Guest_Model record. * @param string $email Submitted customer email. * * @return bool True when the caller may overwrite the stored record. */ private function caller_owns_customer_email( $guest, $email ) { // Admin or team member managing a booking on the customer's behalf. if ( booktics_is_admin_or_team_member() ) { return true; } if ( ! is_user_logged_in() ) { return false; } // The record is already linked to the logged-in user. if ( ! empty( $guest->wp_user_id ) && intval( $guest->wp_user_id ) === get_current_user_id() ) { return true; } // The logged-in user's own account email matches the submitted email. $current_user = wp_get_current_user(); return $current_user && ! empty( $current_user->user_email ) && strtolower( $current_user->user_email ) === strtolower( (string) $email ); } /** * Create a new guest record * * @param Booktics_Database $db * @param array $customer * @param string $user_login * @param int|null $wp_user_id * * @return object Guest_Model instance */ private function create_guest( $db, $customer, $user_login, $wp_user_id ) { return ( new Guest_Model( $db ) )->create( array( 'wp_user_id' => $wp_user_id, 'email' => $customer['email'], 'name' => $customer['name'] ?? '', 'user_login' => $user_login, 'phone' => $customer['phone'] ?? '', ) ); } /** * Check if user has permission to list orders. * * Anonymous access is denied: the collection handler scopes to the current customer or team member; * unauthenticated callers would otherwise receive unscoped results. Single-order GET is allowed for * guests when {@see get_item_permission()} validates a matching order_key. * * @param WP_REST_Request $request * * @return bool|WP_Error */ public function get_items_permission( $request ) { if ( is_user_logged_in() ) { return true; } return new WP_Error( 'booktics_forbidden', __( 'You are not allowed to view orders.', 'booktics' ), array( 'status' => 403 ) ); } /** * Get paginated list of orders. * * @param WP_REST_Request $request * * @return WP_Error|WP_HTTP_Response */ public function get_items( $request ) { $page = (int) $request->get_param( 'paged' ) === 0 ? 1 : absint( $request->get_param( 'paged' ) ); $per_page = (int) $request->get_param( 'per_page' ) === 0 ? 10 : absint( $request->get_param( 'per_page' ) ); $payment_status = sanitize_text_field( $request->get_param( 'payment_status' ) ); $payment_method = sanitize_text_field( $request->get_param( 'payment_method' ) ); $order_date = sanitize_text_field( $request->get_param( 'order_date' ) ); $team_member = absint( $request->get_param( 'team_member' ) ); $service = absint( $request->get_param( 'service' ) ); $search = sanitize_text_field( $request->get_param( 'search' ) ); $db = new Booktics_Database(); $order_model = new Order_Model( $db ); $conditions = array(); if ( $payment_status ) { $conditions['payment_status'] = $payment_status; } if ( $payment_method ) { $conditions['payment_method'] = $payment_method; } if ( $order_date ) { $conditions['DATE(created_at)'] = array( '=', $order_date ); } if ( $search ) { $conditions['order_no'] = array( 'LIKE', '%' . $search . '%' ); } $conditions = apply_filters( 'booktics_order_filters', $conditions ); // Scope to customer's own orders when not admin/team-member if ( ! booktics_is_admin_or_team_member() && is_user_logged_in() ) { $guest = ( new Guest_Model( $db ) )->find( array( 'wp_user_id' => get_current_user_id() ) ); if ( ! $guest ) { return $this->response( array( 'items' => array(), 'total' => 0, 'total_pages' => 0 ), __( 'Orders fetched successfully', 'booktics' ) ); } $conditions['customer_id'] = $guest->id; } // Scope to team member's assigned orders - only return orders that contain // at least one appointment where team_member_id equals the current user's team member ID $is_team_member_scope = false; $team_member_filter_id = null; if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) { $current_user_id = get_current_user_id(); $team_member_model = new Team_Member_Model(); $team_member = $team_member_model->find( array( 'wp_user_id' => $current_user_id ) ); if ( $team_member ) { $is_team_member_scope = true; $team_member_filter_id = $team_member->id; } } $orders = $order_model->get( $conditions, $page, $per_page, array( 'created_at', 'DESC', ) ); if ( $team_member || $service ) { foreach ( $orders['items'] as $key => $order ) { $appointments = ( new Appointment_Model() )->where( 'order_id', $order->id ); $keep_order = false; foreach ( $appointments as $appointment ) { if ( $team_member && $appointment->team_member_id == $team_member ) { $keep_order = true; } if ( $service && $appointment->service_id == $service ) { $keep_order = true; } } if ( ! $keep_order && ( $team_member || $service ) ) { unset( $orders['items'][ $key ] ); --$orders['total']; } } $orders['items'] = array_values( $orders['items'] ); $orders['total_pages'] = ceil( $orders['total'] / $per_page ); } foreach ( $orders['items'] as &$order ) { $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $order->customer_id ) ); $customer_data_for_order = [ 'id' => $customer ? $customer->id : null, 'name' => $customer ? $customer->name : '', 'email' => '', 'phone' => '', ]; // Admins and team members see email and phone if ( booktics_is_admin_or_team_member() ) { $customer_data_for_order['email'] = $customer ? $customer->email : ''; $customer_data_for_order['phone'] = $customer ? $customer->phone : ''; } $order->customer = $customer_data_for_order; $order->total_services = ( new Appointment_Model() )->get_appointments_count_by_order_id( $order->id ); // Hide payment intent fields from team members (not admins) if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) { $order->payment_intent_id = ''; $order->client_secret = ''; } } return $this->response( $orders, __( 'Orders fetched successfully', 'booktics' ) ); } /** * Check if user has permission to update order. * * @param WP_REST_Request $request * * @return bool */ public function update_item_permission( $request ) { return current_user_can( 'manage_options' ); } /** * Update an existing order. * * @param WP_REST_Request $request * * @return WP_Error|WP_HTTP_Response */ public function update_item( $request ) { $order_id = absint( $request->get_param( 'id' ) ); $data = json_decode( $request->get_body(), true ); $db = new Booktics_Database(); $order_model = new Order_Model( $db ); $order = $order_model->find( array( 'id' => $order_id ) ); if ( ! $order ) { return $this->error( __( 'Order not found', 'booktics' ) ); } $status = isset( $data['status'] ) ? sanitize_text_field( $data['status'] ) : $order->status; $payment_status = isset( $data['payment_status'] ) ? sanitize_text_field( $data['payment_status'] ) : $order->payment_status; $order_model->update( $order_id, array( 'status' => $status, 'payment_status' => $payment_status, ) ); if ( isset( $data['customer'] ) && isset( $data['customer']['id'] ) ) { $customer_id = absint( $data['customer']['id'] ); $customer_data = $data['customer']; $customer_data['id'] = $customer_id; if ( isset( $customer_data['name'] ) ) { $customer_data['name'] = sanitize_text_field( $customer_data['name'] ); } if ( isset( $customer_data['email'] ) ) { $customer_data['email'] = sanitize_email( $customer_data['email'] ); } if ( isset( $customer_data['phone'] ) ) { $customer_data['phone'] = sanitize_text_field( $customer_data['phone'] ); } ( new Guest_Model( $db ) )->update( $customer_id, $customer_data ); } $order_model = $order_model->find( array( 'id' => $order_id ) ); $data['customer_id'] = $order_model->customer_id; $response = $this->format_order_response($db, $order_model, $data, null); // Hide payment intent fields from team members (not admins) if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) { $response['payment_intent_id'] = ''; $response['client_secret'] = ''; } return $this->response( $response, __( 'Order updated successfully', 'booktics' ) ); } /** * Check if user has permission to get order. * * Staff always allowed. Logged-in customers allowed when the order belongs to their guest record. * Anonymous users allowed only when the request includes a valid order_key matching the order (same * value returned when the order was created). * * @param WP_REST_Request $request * * @return bool|WP_Error */ public function get_item_permission( $request ) { if ( booktics_is_admin_or_team_member() ) { return true; } $order_id = absint( $request->get_param( 'id' ) ); $db = new Booktics_Database(); $order = ( new Order_Model( $db ) )->find( array( 'id' => $order_id ) ); if ( ! $order ) { return true; } if ( is_user_logged_in() ) { $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $order->customer_id ) ); if ( $customer && intval( $customer->wp_user_id ) === get_current_user_id() ) { return true; } } $order_key = sanitize_text_field( (string) $request->get_param( 'order_key' ) ); if ( $order_key && $this->verify_order_view_key( $order_id, (string) $order->order_no, $order_key ) ) { return true; } return new WP_Error( 'booktics_forbidden', __( 'You are not allowed to view this order.', 'booktics' ), array( 'status' => 403 ) ); } /** * Non-secret, unguessable key for viewing an order without logging in (returned on create and with GET responses). * * @param int $order_id * @param string $order_no * * @return string */ private function get_order_view_key( $order_id, $order_no ) { return hash_hmac( 'sha256', (string) $order_id . '|' . $order_no, wp_salt( 'booktics_order_view' ) ); } /** * @param int $order_id * @param string $order_no * @param string $provided_key * * @return bool */ private function verify_order_view_key( $order_id, $order_no, $provided_key ) { if ( '' === $provided_key || strlen( $provided_key ) > 128 ) { return false; } $expected = $this->get_order_view_key( $order_id, $order_no ); return hash_equals( $expected, $provided_key ); } /** * Get single order. * * @param WP_REST_Request $request * * @return WP_Error|WP_HTTP_Response */ public function get_item( $request ) { $order_id = absint( $request->get_param( 'id' ) ); $db = new Booktics_Database(); $order_model = new Order_Model( $db ); $order = $order_model->find( array( 'id' => $order_id ) ); if ( ! $order ) { return $this->error( __( 'Order not found', 'booktics' ) ); } // Use the format_order_response helper for consistency $input_data = array( 'customer_id' => $order->customer_id ); $response = $this->format_order_response( $db, $order, $input_data, array() ); // Add extra fields specific to GET if needed $response['status'] = $order->status; $response['created_at'] = $this->format_datetime_to_dayjs_format( $order->created_at ); $response['payment_gateway'] = $order->payment_method; // Hide payment intent fields from team members (not admins) if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) { $response['payment_intent_id'] = ''; $response['client_secret'] = ''; } return $this->response( $response, __( 'Order fetched successfully', 'booktics' ) ); } /** * Check if user has permission to update payment status. * * @param WP_REST_Request $request * * @return bool */ public function update_payment_status_permission( $request ) { if ( current_user_can( 'manage_options' ) ) { return true; } // Allow order owner with a valid order_key (guest checkout support). $order_id = absint( $request->get_param( 'id' ) ); $order_key = sanitize_text_field( (string) $request->get_param( 'order_key' ) ); if ( ! $order_id || ! $order_key ) { return false; } $db = new Booktics_Database(); $order = ( new Order_Model( $db ) )->find( array( 'id' => $order_id ) ); if ( ! $order ) { return false; } return $this->verify_order_view_key( $order_id, (string) $order->order_no, $order_key ); } /** * Update payment status for an order. * * @param WP_REST_Request $request * * @return WP_Error|WP_HTTP_Response */ public function update_payment_status( $request ) { $data = json_decode( $request->get_body(), true ); $order_id = ! empty( $data['order_id'] ) ? intval( $data['order_id'] ) : 0; $payment_status = ! empty( $data['payment_status'] ) ? $data['payment_status'] : 0; $payment_method = ! empty( $data['payment_method'] ) ? $data['payment_method'] : null; $db = new Booktics_Database(); $order_model = new Order_Model( $db ); $order = $order_model->find( array( 'id' => $order_id ) ); if ( ! $order ) { return $this->error( __( 'Order not found', 'booktics' ) ); } if ( ! array_key_exists( $payment_method, ( new Payment_Method_Manager() )->get_payment_methods() ) ) { return $this->error( __( 'Unauthorized payment method', 'booktics' ) ); } try { $payment_handler = ( new Payment_Method_Manager() )->get_payment_method( $payment_method ); if ( ! $payment_handler ) { return $this->error( __( 'Payment method not found', 'booktics' ) ); } $status = $payment_handler->get_payment_status( $order ); if ( is_wp_error( $status ) ) { return $this->error( $status->get_error_message() ); } $order_model->update( $order_id, array( 'payment_status' => $status, 'transaction_id' => $data['transaction_id'] ?? null, ) ); if ( $status === Payment_Status::COMPLETED && $order->payment_status !== Payment_Status::COMPLETED ) { // The payment status is already persisted above. Sending notifications must // not fail the request, otherwise a confirmed booking shows an error toast. try { $appointments = ( new Appointment_Model() )->where( 'order_id', $order_id ); $this->send_notifications( $db, $order, $order->customer_id, $appointments ); } catch ( \Throwable $e ) { if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { error_log( 'Booktics: failed to send notifications after payment for order ' . $order_id . ': ' . $e->getMessage() ); } } } elseif ( $status === Payment_Status::FAILED ) { // Payment was declined/canceled on the processor. Release the held slot // immediately by canceling the pending appointments for this order, so the // customer does not have to wait for the admin_init sweep. ( new Abandoned_Booking_Cleanup() )->release_appointments( $order_id ); } // Payment model status and transaction id update return $this->response( array( 'payment_status' => $status, ), __( 'Payment status updated successfully', 'booktics' ) ); } catch ( \Exception $e ) { if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { error_log( 'An error occurred while updating payment status: ' . $e->getMessage() ); } return $this->error( __( 'An error occurred while updating payment status.', 'booktics' ) ); } } /** * Check if user has permission to delete order. * * @param WP_REST_Request $request * * @return bool */ public function delete_item_permission( $request ) { return current_user_can( 'manage_options' ); } /** * Delete an order. * * @param WP_REST_Request $request * * @return WP_Error|WP_HTTP_Response */ public function delete_item( $request ) { $order_id = absint( $request->get_param( 'id' ) ); $db = new Booktics_Database(); $order_model = new Order_Model( $db ); $order = $order_model->find( array( 'id' => $order_id ) ); if ( ! $order ) { return $this->error( __( 'Order not found', 'booktics' ) ); } try { $db->begin_transaction(); // Delete related appointments $appointment_model = new Appointment_Model(); $appointments = $appointment_model->where( 'order_id', $order_id ); foreach ( $appointments as $appointment ) { $appointment->delete(); } // Delete related payments $payment_model = new Payment_Model( $db ); $payments = $payment_model->get( array( 'order_id' => $order_id ) ); foreach ( $payments['items'] as $payment ) { ( new Payment_Model( $db ) )->delete( $payment->id ); } // Delete the order $order_model->delete( $order_id ); $db->commit(); return $this->response( array(), __( 'Order deleted successfully', 'booktics' ) ); } catch ( Exception $e ) { $db->roll_back(); return $this->error( $e->getMessage() ); } } /** * Check if user has permission to bulk delete orders. * * @param WP_REST_Request $request * * @return bool */ public function bulk_delete_items_permission( $request ) { return current_user_can( 'manage_options' ); } /** * Bulk delete orders. * * @param WP_REST_Request $request * * @return WP_Error|WP_HTTP_Response */ public function bulk_delete_items( $request ) { $ids_param = $request->get_param( 'ids' ); $order_ids = is_array( $ids_param ) ? array_map( 'absint', $ids_param ) : array(); $db = new Booktics_Database(); try { $db->begin_transaction(); foreach ( $order_ids as $order_id ) { $order_model = new Order_Model( $db ); $order = $order_model->find( array( 'id' => $order_id ) ); if ( ! $order ) { continue; } // Delete related appointments $appointment_model = new Appointment_Model(); $appointments = $appointment_model->where( 'order_id', $order_id ); foreach ( $appointments as $appointment ) { $appointment->delete(); } // Delete related payments $payment_model = new Payment_Model( $db ); $payments = $payment_model->get( array( 'order_id' => $order_id ) ); foreach ( $payments['items'] as $payment ) { ( new Payment_Model( $db ) )->delete( $payment->id ); } // Delete the order $order_model->delete( $order_id ); } $db->commit(); return $this->response( array(), __( 'Orders deleted successfully', 'booktics' ) ); } catch ( Exception $e ) { $db->roll_back(); return $this->error( $e->getMessage() ); } } /** * Process payment refund for an order. * * @param WP_REST_Request $request * * @return WP_Error|WP_HTTP_Response */ public function refund_payment( $request ) { $order_id = absint( $request->get_param( 'id' ) ); $db = new Booktics_Database(); $order_model = new Order_Model( $db ); $order = $order_model->find( array( 'id' => $order_id ) ); $payment_method = ( new Payment_Method_Manager() )->get_payment_method( $order->payment_method ); if ( ! $payment_method ) { return $this->error( __( 'Invalid payment method!', 'booktics' ) ); } $response = $payment_method->process_refund( $order ); if ( $response['status'] === 'error' ) { return $this->error( $response['message'] ); } $order_model->update( $order_id, array( 'payment_status' => Payment_Status::REFUNDED, ) ); return $this->response( $order_model->find( array( 'id' => $order_id ) ), __( 'Refund successful', 'booktics' ) ); } /** * Calculate total duration in minutes for a service appointment. * * @param array $item * * @return int */ private function calculate_total_duration( $item ) { $service_model = Service_Model::find( $item['service_id'] ); if ( ! $service_model ) { return 0; } $service_duration = array_filter( $service_model->additional_durations, function ( $duration ) use ( $item ) { return $item['additional_duration_id'] === $duration['id']; } ); if ( empty( $service_duration ) ) { return 0; } $service_duration = reset( $service_duration ); $total_duration_min = $service_duration['duration_unit'] === 'min' ? $service_duration['duration_time'] : ( $service_duration['duration_time'] * 60 ); if ( ! empty( $item['extra_services'] ) ) { $total_duration_min = apply_filters( 'booktics_extra_service_duration', $total_duration_min, $item['extra_services'] ); } $total_duration_min = apply_filters( 'booktics_calculate_total_duration', $total_duration_min, $item ); return $total_duration_min; } /** * Converts a datetime string to ISO 8601 format with WordPress timezone for dayjs compatibility * * @param string $datetime Datetime string from database (e.g., "2024-01-15 14:00:00") * * @return string ISO 8601 formatted datetime with timezone offset (e.g., "2024-01-15T14:00:00-05:00") */ private function format_datetime_to_dayjs_format( $datetime ) { $dt = new DateTime( $datetime ); $dt->setTimezone( wp_timezone() ); return $dt->format( 'Y-m-d H:i:s' ); } }