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, 'refund_payment_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; } // WooCommerce checkout carries one booking at a time: the cart is // emptied and a single line item added, so a multi-item order would // silently lose everything but the first appointment. if ( 'woocommerce' === strtolower( (string) $input_data['payment_method'] ) && is_array( $input_data['items'] ?? null ) && count( $input_data['items'] ) > 1 ) { return new WP_Error( 'items', __( 'WooCommerce checkout supports one service at a time. Please book them separately.', 'booktics' ) ); } } // 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_rules = array( 'date' => array( 'required' ), 'start_time' => array( 'required' ), 'end_time' => array( 'required' ), 'team_member_id' => array( 'required' ), 'service_id' => array( 'required' ), ); /** * Filters the validation rules applied to a single order item. * * Extensions that assign a team member server-side (Booktics Pro * round robin) drop the team_member_id requirement here. * * @since 1.0.26 * * @param array $item_rules Validation rules. * @param array $item The order item being validated. * @param array $input_data The whole order payload. */ $item_rules = apply_filters( 'booktics_order_item_validation_rules', $item_rules, $item, $input_data ); $item_validate = booktics_validate( $item, $item_rules ); 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' ) ); } // Attendee count decides both capacity and price, so resolve it once // here and reuse the same number everywhere. Reading it separately // let a client book N seats while paying for one. $attendees = $this->resolve_item_attendees( $item, $input_data ); // Money is never taken from the request. Whatever price/subtotal/total // the client sent is discarded and rebuilt from the service record. $amount = $this->calculate_item_amount( $item, $attendees ); if ( is_wp_error( $amount ) ) { return $amount; } // 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' => $amount['price'], 'subtotal' => $amount['total'], 'total' => $amount['total'], 'quantity' => absint( $item['quantity'] ?? 1 ), 'total_attendees' => $attendees, ); $input_data['items'][$key] = apply_filters('booktics_pro_appointment_validate', $input_data['items'][$key], $item); } // Order totals are the sum of the recalculated lines. Tax and coupons are // settled by the gateway after checkout (WooCommerce writes the real // values back), so nothing the client sent for them is trusted here. $order_total = 0.0; foreach ( $input_data['items'] as $prepared_item ) { $order_total += (float) $prepared_item['total']; } $input_data['subtotal'] = round( $order_total, 2 ); $input_data['total'] = round( $order_total, 2 ); $input_data['tax_total'] = 0; $input_data['coupon_discount'] = 0; $input_data['coupon_code'] = ''; $input_data = apply_filters( 'booktics_pro_order_validate', $input_data ); return $input_data; } /** * Resolve how many attendees a single booking line is for. * * Group booking sends the count inside the item; older payloads put a single * count at the root. One resolved value is then used for the capacity check, * the price and the stored appointment. * * @param array $item Raw item from the request. * @param array $input_data Raw order payload. * * @return int */ private function resolve_item_attendees( $item, $input_data ) { $group_booking = is_array( $item['group_booking'] ?? null ) ? $item['group_booking'] : array(); $attendees = $group_booking['attendee_count'] ?? $item['total_attendees'] ?? $input_data['total_attendees'] ?? 1; return max( 1, absint( $attendees ) ); } /** * Calculate what a booking line actually costs, from the service record. * * @param array $item Raw item from the request. * @param int $attendees Resolved attendee count for this line. * * @return array|WP_Error array{price: float, total: float} */ private function calculate_item_amount( $item, $attendees ) { $service_id = absint( $item['service_id'] ?? 0 ); $service = Service_Model::find( $service_id ); if ( ! $service ) { return new WP_Error( 'service_not_found', __( 'Service not found', 'booktics' ) ); } $durations = is_array( $service->additional_durations ) ? $service->additional_durations : array(); if ( empty( $durations ) ) { return new WP_Error( 'invalid_duration', __( 'This service has no bookable duration.', 'booktics' ) ); } $requested_id = booktics_sanitize_id_string( $item['additional_duration_id'] ?? '' ); $selected = null; foreach ( $durations as $duration ) { if ( (string) ( $duration['id'] ?? '' ) === (string) $requested_id ) { $selected = $duration; break; } } // A single-duration service may be booked without naming the duration, // but an unknown id must never silently fall back to a cheaper one. if ( null === $selected ) { if ( '' !== $requested_id ) { return new WP_Error( 'invalid_duration', __( 'Invalid duration selected for this service.', 'booktics' ) ); } $selected = reset( $durations ); } $price = (float) ( $selected['charge_amount'] ?? 0 ); // Add-ons priced by booktics-pro (extra services). Runs before the // attendee multiplier so add-ons scale with the group, matching the // price the booking form showed. $total = (float) apply_filters( 'booktics_order_item_amount', $price, $item, $service, $attendees ); $total = $total * $this->get_attendee_multiplier( $service_id, $attendees ); return array( 'price' => round( max( 0, $price ), 2 ), 'total' => round( max( 0, $total ), 2 ), ); } /** * How many times a line price is charged for a group booking. * * Services that charge per attendee bill the flat price once; the rest * multiply it by the number of people, which is what the booking form does. * * @param int $service_id Service ID. * @param int $attendees Resolved attendee count. * * @return int */ private function get_attendee_multiplier( $service_id, $attendees ) { $group_booking = get_post_meta( $service_id, 'group_booking', true ); if ( ! $this->is_group_booking_enabled( $group_booking ) ) { return 1; } if ( ! empty( $group_booking['charge_per_attendee'] ) ) { return 1; } return max( 1, (int) $attendees ); } /** * 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'] ); } /** * 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' => $item['total_attendees'] ?? $input_data['total_attendees'] ?? 1, 'timezone' => $original_timezone, ); $appointment_data = apply_filters( 'booktics_appointment_pro_format', $appointment_data, $item ); $conflict = Appointment_Validator::validate_slot_available( $appointment_data ); if ( $conflict ) { throw new Exception( esc_html( $conflict->get_error_message() ) ); } $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'] ?? null ) ); ( 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'] ?? null, '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. * * @param string $primary * @param mixed $team_member_id * * @return string */ private function resolve_team_member_email( string $primary, $team_member_id ): string { return Order_Notifier::resolve_team_member_email( $primary, $team_member_id ); } /** * Helper: Format a single appointment for API output * * @param Appointment_Model|array $appointment * * @return array */ private function format_appointment( $appointment ): array { return Order_Notifier::format_appointment( $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 ), // Tax and coupons are settled at checkout (WooCommerce owns them for // those bookings), so the invoice has to show them, not just the // subtotal. Missing here is what left the order looking untaxed. 'tax_total' => is_object( $order_model ) ? $order_model->tax_total : ( $order_model['tax_total'] ?? null ), 'coupon_discount' => is_object( $order_model ) ? $order_model->coupon_discount : ( $order_model['coupon_discount'] ?? null ), 'coupon_code' => is_object( $order_model ) ? $order_model->coupon_code : ( $order_model['coupon_code'] ?? 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'] ?? '', // Gateways that collect payment on their own page (WooCommerce // checkout) return where to send the customer instead of a secret. 'redirect_url' => $payment_intent['redirect_url'] ?? '', '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 * * Delegates to Order_Notifier so gateways that confirm a booking outside a * REST request (WooCommerce) fire exactly the same events. * * @param Booktics_Database $db * @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 { Order_Notifier::order_created( $db, $order_model, $customer_id, $appointments ); } /** * 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 ( $status !== $order->status ) { /** This action is documented in core/appointment/services/appointment-service.php */ do_action( 'booktics_order_status_updated', $order_id, $status, $order->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 Order_Key::make( $order_id, $order_no ); } /** * @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 ) { return Order_Key::verify( $order_id, $order_no, $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; $response['payment_method'] = $order->payment_method; // format_order_response() builds this from a freshly created payment // intent, which a GET does not have, so read the stored one instead. // The team-member guard below still strips it for non-admins. $response['payment_intent_id'] = $order->payment_intent_id ?? ''; // 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 ); $data = is_array( $data ) ? $data : array(); // Only the order in the route is touched. The permission callback // authorized that id against the order_key; an order id in the body // would let a valid key for one order act on somebody else's. $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' ) ); } // The gateway that took the money is the one that may report on it, so // prefer what the order was created with over what the client claims. $payment_method = ! empty( $order->payment_method ) ? $order->payment_method : ( ! empty( $data['payment_method'] ) ? sanitize_text_field( (string) $data['payment_method'] ) : '' ); 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' => isset( $data['transaction_id'] ) ? sanitize_text_field( (string) $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 ) { booktics_log( '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 ) { booktics_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() ); } } /** * Check if the current user may refund an order. * * Refunding moves money and must never be reachable by the customer. This * route previously reused update_payment_status_permission, which also * accepts a valid order_key so that a guest can confirm their own payment — * meaning any customer holding their own order key could refund themselves. * * @param WP_REST_Request $request * * @return bool */ public function refund_payment_permission( $request ) { return current_user_can( 'manage_options' ); } /** * 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 ) ); if ( ! $order || is_wp_error( $order ) ) { return $this->error( __( 'Order not found', 'booktics' ), 404 ); } $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 ); // A gateway may refuse outright — WooCommerce-paid orders are refunded // from WooCommerce admin — and must not leave the order marked refunded // when no money moved. if ( is_wp_error( $response ) ) { return $this->error( $response->get_error_message() ); } if ( ! is_array( $response ) || ( $response['status'] ?? '' ) === 'error' ) { return $this->error( $response['message'] ?? __( 'Refund failed', 'booktics' ) ); } $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' ); } }