url( [ 'account' => $this->number( 'account' ), 'order' => $this->number( 'order' ), 'paged' => max( 1, $this->number( 'paged' ) ) ] ) ); exit; } public function styles( $hook ) { if ( 'toplevel_page_activity-track-ai' === $hook && ATKAI_Admin_Menus::is_woocommerce_tab() ) { wp_enqueue_style( 'atk-security', plugins_url( 'assets/admin/css/atk.security.css', ATKAI_PLUGIN_FILE ), [], filemtime( ATKAI_ABSPATH . 'assets/admin/css/atk.security.css' ) ); wp_enqueue_script( 'atk-security-overview', plugins_url( 'assets/admin/js/atk.security.js', ATKAI_PLUGIN_FILE ), [], filemtime( ATKAI_ABSPATH . 'assets/admin/js/atk.security.js' ), false ); wp_localize_script( 'atk-security-overview', 'atkSecurityOverview', [ 'key' => 'atk-woocommerce-overview-' . get_current_blog_id() . '-' . get_current_user_id(), 'show' => __( 'Show overview', 'activity-track' ), 'hide' => __( 'Hide overview', 'activity-track' ), 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'atk_woocommerce_history' ), 'loading' => __( 'Loading recorded history...', 'activity-track' ), 'error' => __( 'History could not be loaded. Please try again.', 'activity-track' ), 'history' => __( 'Activity History', 'activity-track' ), 'details' => __( 'Event Details', 'activity-track' ), 'retry' => __( 'Retry', 'activity-track' ), ] ); } } private function number( $name ) { return isset( $_GET[ $name ] ) && is_scalar( $_GET[ $name ] ) ? absint( $_GET[ $name ] ) : 0; } private function url( $args = [] ) { return add_query_arg( array_merge( [ 'page' => 'activity-track-ai', 'tab' => 'woocommerce' ], $args ), admin_url( 'admin.php' ) ); } private function filters() { $text = static function( $key ) { return isset( $_GET[ $key ] ) && is_string( $_GET[ $key ] ) ? sanitize_key( wp_unslash( $_GET[ $key ] ) ) : ''; }; $event = $text( 'event' ); $result = $text( 'result' ); $days = $this->number( 'days' ); return [ 'account' => $this->number( 'account' ), 'order' => $this->number( 'order' ), 'days' => in_array( $days, [ 1, 7, 30 ], true ) ? $days : 0, 'event' => in_array( $event, self::event_types(), true ) ? $event : '', 'result' => array_key_exists( $result, self::outcomes() ) ? $result : '', 'group_orders' => defined( 'ATKAI_PRO_PLUGIN_ACTIVE' ) && ATKAI_PRO_PLUGIN_ACTIVE && 1 === $this->number( 'group_orders' ) ? 1 : 0 ]; } private static function event_types() { return [ 'account_created', 'profile_updated', 'checkout_submitted', 'order_paid', 'payment_error', 'payment_method_added', 'stripe_payment', 'card_setup' ]; } private function commerce_where() { return "(event_type IN ('checkout_submitted','order_paid','payment_error','payment_method_added','stripe_payment','card_setup') OR (event_type IN ('account_created','profile_updated') AND source IN ('Checkout','My account')))"; } private static function outcomes() { return [ 'recorded' => __( 'Recorded', 'activity-track' ), 'error' => __( 'Processing error', 'activity-track' ), 'failed' => __( 'Failed', 'activity-track' ), 'succeeded' => __( 'Succeeded', 'activity-track' ), 'requires_action' => __( 'Authentication needed', 'activity-track' ), 'processing' => __( 'Processing', 'activity-track' ) ]; } private function where( $filters ) { global $wpdb; $where = $this->commerce_where(); if ( $filters['account'] ) $where .= $wpdb->prepare( ' AND subject_id = %d', $filters['account'] ); if ( $filters['order'] ) $where .= $wpdb->prepare( ' AND order_id = %d', $filters['order'] ); if ( $filters['days'] ) $where .= $wpdb->prepare( ' AND occurred_at >= %s', gmdate( 'Y-m-d H:i:s', time() - $filters['days'] * DAY_IN_SECONDS ) ); if ( $filters['event'] ) $where .= $wpdb->prepare( ' AND event_type = %s', $filters['event'] ); if ( $filters['result'] ) $where .= $wpdb->prepare( ' AND outcome = %s', $filters['result'] ); return $where; } public static function label( $type ) { $labels = [ 'account_created' => __( 'Account created', 'activity-track' ), 'login' => __( 'Account signed in', 'activity-track' ), 'profile_updated' => __( 'Account profile updated', 'activity-track' ), 'role_changed' => __( 'Account role changed', 'activity-track' ), 'checkout_submitted' => __( 'Checkout submitted', 'activity-track' ), 'order_paid' => __( 'WooCommerce recorded payment complete', 'activity-track' ), 'payment_error' => __( 'Stripe checkout reported an error', 'activity-track' ), 'payment_method_added' => __( 'Saved payment method added', 'activity-track' ), 'stripe_payment' => __( 'Stripe payment outcome', 'activity-track' ), 'card_setup' => __( 'Stripe card setup outcome', 'activity-track' ), ]; return $labels[ $type ] ?? __( 'Account activity', 'activity-track' ); } public static function profile_description( $reason ) { $labels = [ 'contact' => __( 'Contact details', 'activity-track' ), 'name' => __( 'Name', 'activity-track' ), 'password' => __( 'Password', 'activity-track' ), 'billing_address' => __( 'Billing address', 'activity-track' ), 'shipping_address' => __( 'Shipping address', 'activity-track' ) ]; $changed = array_intersect_key( $labels, array_flip( explode( ',', $reason ) ) ); return $changed ? sprintf( /* translators: %s: comma-separated categories of changed profile fields. */ __( 'Changed: %s. Values are not stored.', 'activity-track' ), implode( ', ', $changed ) ) : __( 'Changed fields were not captured for this event.', 'activity-track' ); } /** Group only adjacent profile records for the same subject, actor, source and mode within one minute. */ public static function event_groups( $rows ) { $groups = []; foreach ( $rows as $row ) { $index = count( $groups ) - 1; $first = $index >= 0 ? $groups[ $index ][0] : null; if ( $first && empty( $row->order_event_count ) && empty( $first->order_event_count ) && 'profile_updated' === $row->event_type && $first->event_type === $row->event_type && $row->subject_id && $first->subject_id === $row->subject_id && $first->actor_id === $row->actor_id && $first->source === $row->source && $first->mode === $row->mode && $first->order_id === $row->order_id && abs( strtotime( $first->occurred_at ) - strtotime( $row->occurred_at ) ) <= 60 ) { $groups[ $index ][] = $row; } else { $groups[] = [ $row ]; } } return $groups; } private function render_user( $id ) { global $wp_roles; $user = $id ? get_user_by( 'id', $id ) : false; if ( $user && $user->ID ) { $role = isset( $user->roles[0], $wp_roles->role_names[ $user->roles[0] ] ) ? $wp_roles->role_names[ $user->roles[0] ] : __( 'Unknown', 'activity-track' ); echo '' . get_avatar( $user->ID, 30 ) . '' . esc_html( $user->display_name ) . '' . esc_html( sprintf( /* translators: %d: WordPress user ID. */ __( 'User ID %d', 'activity-track' ), $user->ID ) ) . '' . esc_html( $role ) . ''; } elseif ( $id ) { echo esc_html( sprintf( /* translators: %d: unavailable WordPress user ID. */ __( 'User ID %d', 'activity-track' ), $id ) ) . '' . esc_html__( 'Account unavailable', 'activity-track' ) . ''; } else echo esc_html__( 'Guest or unlinked', 'activity-track' ); } private function evidence( $group ) { foreach ( $group as $event ) { echo '

' . esc_html( self::label( $event->event_type ) ) . '

' . esc_html( '#' . $event->event_id . ' · ' . $event->source ) . '
' . esc_html( self::outcomes()[ $event->outcome ] ?? __( 'Unknown', 'activity-track' ) ) . '
'; if ( 'test' === $event->mode ) echo '' . esc_html__( 'Test mode', 'activity-track' ) . ''; echo '
' . esc_html__( 'Date & Time', 'activity-track' ) . '
' . esc_html( get_date_from_gmt( $event->occurred_at, 'M j, Y H:i:s' ) ) . '' . esc_html( wp_timezone_string() ) . '
' . esc_html__( 'Order', 'activity-track' ) . '
'; $order = $event->order_id && function_exists( 'wc_get_order' ) ? wc_get_order( $event->order_id ) : null; if ( $order && is_callable( [ $order, 'get_edit_order_url' ] ) ) echo '' . esc_html( '#' . $event->order_id ) . ''; else echo esc_html( $event->order_id ? '#' . $event->order_id : __( 'No linked order', 'activity-track' ) ); echo '
' . esc_html__( 'User', 'activity-track' ) . '
'; $this->render_user( $event->subject_id ); echo '
' . esc_html__( 'Acting user', 'activity-track' ) . '
'; if ( $event->actor_id ) $this->render_user( $event->actor_id ); else echo esc_html__( 'Acting user not recorded', 'activity-track' ); echo '
' . esc_html__( 'Recorded at', 'activity-track' ) . '
' . esc_html( get_date_from_gmt( $event->recorded_at, 'M j, Y H:i:s' ) ) . '
'; if ( $event->gateway_ref ) echo '
' . esc_html__( 'Stripe reference', 'activity-track' ) . '
' . esc_html( $event->gateway_ref ) . '
'; if ( 'profile_updated' === $event->event_type ) echo '
' . esc_html__( 'Profile changes', 'activity-track' ) . '
' . esc_html( self::profile_description( $event->reason ) ) . '
'; elseif ( $event->reason ) echo '
' . esc_html__( 'Stripe reason', 'activity-track' ) . '
' . esc_html( $event->reason ) . '
'; echo '
'; if ( 'payment_error' === $event->event_type ) echo '

' . esc_html__( 'The gateway reported an error. A bank decline or fraud is not confirmed by this event.', 'activity-track' ) . '

'; echo '
'; } } private function event_table( $rows, $history = false ) { echo '
'; foreach ( [ __( 'Date & Time', 'activity-track' ), __( 'User', 'activity-track' ), __( 'Order', 'activity-track' ), __( 'Result', 'activity-track' ), __( 'Event', 'activity-track' ) ] as $heading ) echo ''; echo ''; if ( ! $rows ) echo ''; foreach ( self::event_groups( $rows ) as $group ) { $row = $group[0]; $result = self::outcomes()[ $row->outcome ] ?? __( 'Unknown', 'activity-track' ); echo ''; } echo '
' . esc_html( $heading ) . '
' . esc_html__( 'No recorded events match this view.', 'activity-track' ) . '
' . esc_html( sprintf( /* translators: %s: human-readable time difference. */ __( '%s ago', 'activity-track' ), human_time_diff( strtotime( $row->occurred_at . ' UTC' ), time() ) ) ) . '
' . esc_html( get_date_from_gmt( $row->occurred_at, 'M j, Y H:i:s' ) ) . '
'; $this->render_user( $row->subject_id ); echo ''; $order = $row->order_id && function_exists( 'wc_get_order' ) ? wc_get_order( $row->order_id ) : null; if ( $order && is_callable( [ $order, 'get_edit_order_url' ] ) ) echo '' . esc_html( '#' . $row->order_id ) . ''; elseif ( $row->order_id ) echo esc_html( '#' . $row->order_id ); else echo '-'; echo '' . esc_html( $result ) . ''; if ( ! empty( $row->order_event_count ) ) echo '' . esc_html__( 'Latest matching result', 'activity-track' ) . ''; if ( 'test' === $row->mode ) echo '' . esc_html__( 'Test mode', 'activity-track' ) . ''; if ( $row->reason && 'profile_updated' !== $row->event_type ) echo '' . esc_html( ucfirst( str_replace( '_', ' ', $row->reason ) ) ) . ''; echo '
' . esc_html( self::label( $row->event_type ) ) . ''; if ( ! empty( $row->order_event_count ) ) echo '' . esc_html( sprintf( /* translators: %d: number of grouped order events. */ _n( '%d event', '%d events', $row->order_event_count, 'activity-track' ), $row->order_event_count ) ) . ''; elseif ( count( $group ) > 1 ) echo '' . esc_html( sprintf( /* translators: %d: number of grouped profile records. */ _n( '%d record', '%d records', count( $group ), 'activity-track' ), count( $group ) ) ) . ''; echo '
' . esc_html( $row->source ) . ''; if ( $history ) { echo '
' . esc_html__( 'Evidence details', 'activity-track' ) . ''; $this->evidence( $group ); echo '
'; } else { echo ''; } echo '
'; } public function history() { check_ajax_referer( 'atk_woocommerce_history', 'nonce' ); if ( ! current_user_can( 'manage_options' ) || ! ATKAI_Security_Events::enabled( 'woocommerce' ) ) wp_send_json_error( [ 'message' => __( 'You cannot view this history.', 'activity-track' ) ], 403 ); if ( ATKAI_Security_Events::VERSION !== get_option( 'atk_security_db_version' ) ) wp_send_json_error( [ 'message' => __( 'Event storage is unavailable.', 'activity-track' ) ], 503 ); global $wpdb; $table = ATKAI_Security_Events::table(); $number = static function( $key ) { return isset( $_POST[ $key ] ) && is_scalar( $_POST[ $key ] ) ? absint( $_POST[ $key ] ) : 0; }; $commerce_where = $this->commerce_where(); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- commerce_where() returns a constant event-type predicate; identifiers and request values are bound. $anchor = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM %i WHERE event_id = %d AND $commerce_where", $table, $number( 'event' ) ) ); if ( ! $anchor || ( ! $anchor->order_id && ! $anchor->subject_id ) ) wp_send_json_error( [ 'message' => __( 'This event has no retained order or customer history.', 'activity-track' ) ], 404 ); $scope = isset( $_POST['scope'] ) && 'customer' === $_POST['scope'] && $anchor->subject_id ? 'customer' : ( $anchor->order_id ? 'order' : 'customer' ); $snapshot = $number( 'snapshot' ) ?: absint( $wpdb->get_var( $wpdb->prepare( 'SELECT MAX(event_id) FROM %i', $table ) ) ); $mode_where = 'test' === $anchor->mode ? "mode = 'test'" : "mode <> 'test'"; $customer_scope_available = false; if ( $anchor->order_id && $anchor->subject_id ) { // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- commerce_where() and mode predicates are internal constants; all identifiers and values are bound. $customer_scope_available = (bool) $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM %i WHERE subject_id = %d AND order_id <> %d AND event_id <= %d AND $commerce_where AND $mode_where LIMIT 1", $table, $anchor->subject_id, $anchor->order_id, $snapshot ) ); if ( ! $customer_scope_available ) $scope = 'order'; } $where = 'order' === $scope ? $wpdb->prepare( 'order_id = %d AND event_id <= %d', $anchor->order_id, $snapshot ) : $wpdb->prepare( 'subject_id = %d AND event_id <= %d', $anchor->subject_id, $snapshot ); $where .= ' AND ' . $commerce_where; // WooCommerce events have no mode; include them alongside live, but never alongside test Stripe events. $where .= ' AND ' . $mode_where; $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE $where", $table ) ); $pages = max( 1, (int) ceil( $total / 50 ) ); $page = min( $pages, max( 1, $number( 'history_page' ) ) ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE $where ORDER BY occurred_at DESC, event_id DESC LIMIT 50 OFFSET %d", $table, ( $page - 1 ) * 50 ) ); ob_start(); echo '

' . esc_html( sprintf( /* translators: %s: number of retained history records. */ _n( '%s retained record', '%s retained records', $total, 'activity-track' ), number_format_i18n( $total ) ) . ' · ' . wp_timezone_string() ) . ( 'test' === $anchor->mode ? ' · ' . esc_html__( 'Test mode', 'activity-track' ) : '' ) . '

'; if ( 'order' === $scope ) { $paid = $wpdb->get_var( $wpdb->prepare( "SELECT MAX(occurred_at) FROM %i WHERE $where AND event_type = 'order_paid'", $table ) ); echo '

' . esc_html( $paid ? __( 'Payment completion recorded by WooCommerce in retained order history.', 'activity-track' ) : __( 'No WooCommerce payment-completion record in retained order history. This does not confirm that the order is unpaid.', 'activity-track' ) ) . '

'; } $this->event_table( $rows, true ); echo '
' . esc_html( sprintf( /* translators: 1: current page number, 2: total number of pages. */ __( 'Page %1$d of %2$d', 'activity-track' ), $page, $pages ) ) . '
'; $html = ob_get_clean(); wp_send_json_success( [ 'html' => $html, 'snapshot' => $snapshot, 'scope' => $scope, 'customer_scope_available' => $customer_scope_available, 'title' => 'order' === $scope ? sprintf( /* translators: %d: order or customer ID. */ __( 'Order #%d History', 'activity-track' ), $anchor->order_id ) : sprintf( /* translators: %d: order or customer ID. */ __( 'Customer #%d History', 'activity-track' ), $anchor->subject_id ) ] ); } private function page_link( $target, $label, $symbol, $disabled, $filters, $class ) { if ( $disabled ) echo ''; else echo '' . esc_html( $label ) . ''; } public function render( $embedded = false ) { if ( ! current_user_can( 'manage_options' ) ) wp_die( esc_html__( 'You cannot view this timeline.', 'activity-track' ) ); if ( ! ATKAI_Security_Events::enabled( 'woocommerce' ) ) wp_die( esc_html__( 'Enable WooCommerce under Activity Track settings to view WooCommerce Activity.', 'activity-track' ) ); global $wpdb; $page = max( 1, $this->number( 'paged' ) ); $table = ATKAI_Security_Events::table(); $heading = $embedded ? 'h2' : 'h1'; echo '
<' . esc_html( $heading ) . '>' . esc_html__( 'WooCommerce Activity', 'activity-track' ) . '

' . esc_html__( 'Follow customer accounts, checkout activity, and payment outcomes.', 'activity-track' ) . '

' . esc_html__( 'Monitoring only', 'activity-track' ) . '
'; if ( ATKAI_Security_Events::VERSION !== get_option( 'atk_security_db_version' ) ) { echo '

' . esc_html__( 'Security event storage is unavailable. Existing activity logging and checkout continue to work.', 'activity-track' ) . '

'; return; } $tracking_users = ATKAI_Security_Events::enabled( 'user' ); $tracking_woo = ATKAI_Security_Events::enabled( 'woocommerce' ); $woo_active = class_exists( 'WooCommerce' ); $woo_status = ! $woo_active ? __( 'plugin not active on this site', 'activity-track' ) : ( $tracking_woo ? __( 'tracking enabled', 'activity-track' ) : __( 'installed and active; tracking disabled in Activity Track settings', 'activity-track' ) ); if ( ! $woo_active ) { $stripe_status = __( 'requires active WooCommerce', 'activity-track' ); } elseif ( ! $tracking_woo ) { $stripe_status = __( 'tracking paused; enable WooCommerce tracking in Activity Track settings', 'activity-track' ); } elseif ( ! class_exists( 'WC_Stripe' ) ) { $stripe_status = __( 'supported WooCommerce Stripe gateway not detected; other Stripe plugins are not yet supported', 'activity-track' ); } else { $stripe_status = __( 'gateway detected; webhook delivery required', 'activity-track' ); } $stripe_detected = $woo_active && $tracking_woo && class_exists( 'WC_Stripe' ); $last_stripe_event = $stripe_detected ? $wpdb->get_var( $wpdb->prepare( "SELECT MAX(recorded_at) FROM %i WHERE source = 'Stripe webhook' AND mode = 'live'", $table ) ) : null; $coverage = [ [ __( 'WooCommerce account changes', 'activity-track' ), $tracking_users ? 'active' : 'inactive', $tracking_users ? __( 'Active', 'activity-track' ) : __( 'Inactive', 'activity-track' ), $tracking_users ? __( 'Account creation and profile changes from Checkout and My Account appear here.', 'activity-track' ) : __( 'Enable User under Activity Track settings.', 'activity-track' ) ], [ __( 'WooCommerce', 'activity-track' ), $woo_active && $tracking_woo ? 'active' : 'inactive', $woo_active && $tracking_woo ? __( 'Active', 'activity-track' ) : __( 'Inactive', 'activity-track' ), $woo_active && $tracking_woo ? __( 'Checkout and payment-completion tracking is enabled.', 'activity-track' ) : $woo_status ], [ __( 'Stripe', 'activity-track' ), $stripe_detected ? ( $last_stripe_event ? 'active' : 'pending' ) : 'inactive', $stripe_detected ? ( $last_stripe_event ? __( 'Events received', 'activity-track' ) : __( 'Webhook not yet confirmed', 'activity-track' ) ) : __( 'Inactive', 'activity-track' ), $last_stripe_event ? sprintf( /* translators: %s: date, time and timezone of the latest live webhook event. */ __( 'Latest live event received: %s. This is not a real-time connection check.', 'activity-track' ), get_date_from_gmt( $last_stripe_event, 'M j, Y H:i' ) . ' ' . wp_timezone_string() ) : ( $stripe_detected ? __( 'WooCommerce payment tracking is active. A live Stripe webhook has not yet been recorded.', 'activity-track' ) : $stripe_status ) ], ]; echo '
' . esc_html__( 'Recording coverage', 'activity-track' ) . '
'; foreach ( $coverage as [ $name, $state, $label, $description ] ) { echo '
' . esc_html( $name ) . '' . esc_html( $label ) . '

' . esc_html( $description ) . '

'; } echo '
'; if ( ! $tracking_users || ( $woo_active && ! $tracking_woo ) ) { echo '

' . esc_html__( 'Open Activity Track settings', 'activity-track' ) . ' — ' . esc_html__( 'Under Enable Activity Types, select User and WooCommerce as needed, then save settings.', 'activity-track' ) . '

'; } echo '

' . esc_html__( 'Monitoring only — no customers are blocked. Only activity captured since tracking was enabled appears here.', 'activity-track' ) . '

'; $filters = $this->filters(); echo '
'; $selects = [ 'days' => [ __( 'Date range', 'activity-track' ), [ 0 => __( 'All dates', 'activity-track' ), 1 => __( 'Last 24 hours', 'activity-track' ), 7 => __( 'Last 7 days', 'activity-track' ), 30 => __( 'Last 30 days', 'activity-track' ) ] ], 'event' => [ __( 'Event type', 'activity-track' ), [ '' => __( 'All events', 'activity-track' ) ] + array_combine( self::event_types(), array_map( [ self::class, 'label' ], self::event_types() ) ) ], 'result' => [ __( 'Result', 'activity-track' ), [ '' => __( 'All results', 'activity-track' ) ] + self::outcomes() ], ]; foreach ( $selects as $key => [ $label, $choices ] ) { echo ''; } foreach ( [ 'account' => __( 'User ID', 'activity-track' ), 'order' => __( 'Order ID', 'activity-track' ) ] as $key => $label ) echo ''; if ( defined( 'ATKAI_PRO_PLUGIN_ACTIVE' ) && ATKAI_PRO_PLUGIN_ACTIVE ) echo ''; echo ' ' . esc_html__( 'Reset Filters', 'activity-track' ) . '
'; $where = $this->where( $filters ); $signal_where = $this->where( array_merge( $filters, [ 'days' => 0, 'event' => '', 'result' => '' ] ) ); $cutoff = gmdate( 'Y-m-d H:i:s', time() - 600 ); // Alerts remain visible even when table filters hide failures. // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- signal_where contains only the internal commerce predicate and prepared numeric filters. $signals = $wpdb->get_results( $wpdb->prepare( "SELECT subject_id, COUNT(*) AS failures FROM %i WHERE $signal_where AND subject_id > 0 AND outcome = 'failed' AND mode = 'live' AND event_type IN ('stripe_payment','card_setup') AND occurred_at >= %s GROUP BY subject_id HAVING COUNT(*) >= 5 ORDER BY failures DESC LIMIT 5", $table, $cutoff ) ); foreach ( $signals as $signal ) echo '
' . esc_html__( 'Repeated payment failures - review recommended', 'activity-track' ) . '

' . esc_html( sprintf( /* translators: 1: WordPress user ID, 2: number of live Stripe failure events. */ __( 'User ID %1$d has %2$d Stripe failure events in the last 10 minutes. This may indicate card testing or legitimate retries.', 'activity-track' ), $signal->subject_id, $signal->failures ) ) . '

' . esc_html__( 'Review customer activity', 'activity-track' ) . '
'; if ( $filters['group_orders'] ) { // Aggregate before pagination; retain unlinked rows and isolate test from live/blank-mode events. $groups = $wpdb->prepare( "SELECT order_id, CASE WHEN mode = 'test' THEN 1 ELSE 0 END AS test_mode, CASE WHEN order_id > 0 THEN 0 ELSE event_id END AS unlinked_id, MAX(occurred_at) AS latest_at, COUNT(*) AS event_count FROM %i WHERE $where GROUP BY order_id, test_mode, unlinked_id", $table ); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- groups is prepared above with a bound table and previously prepared filter values. $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM ($groups) AS grouped_orders" ); } else { $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE $where", $table ) ); } $pages = max( 1, (int) ceil( $total / 50 ) ); $page = min( $page, $pages ); if ( $filters['group_orders'] ) { // Break equal occurrence timestamps by event ID, including out-of-order webhook delivery. $latest = $wpdb->prepare( "SELECT MAX(event_id) AS latest_id, grouped_orders.event_count FROM (SELECT * FROM %i WHERE $where) AS matching_events INNER JOIN ($groups) AS grouped_orders ON matching_events.order_id = grouped_orders.order_id AND CASE WHEN matching_events.mode = 'test' THEN 1 ELSE 0 END = grouped_orders.test_mode AND CASE WHEN matching_events.order_id > 0 THEN 0 ELSE matching_events.event_id END = grouped_orders.unlinked_id AND matching_events.occurred_at = grouped_orders.latest_at GROUP BY grouped_orders.order_id, grouped_orders.test_mode, grouped_orders.unlinked_id, grouped_orders.event_count", $table ); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- latest is a prepared subquery above; the outer table and offset use placeholders. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT events.*, CASE WHEN events.order_id > 0 THEN latest.event_count ELSE 0 END AS order_event_count FROM %i AS events INNER JOIN ($latest) AS latest ON events.event_id = latest.latest_id ORDER BY events.occurred_at DESC, events.event_id DESC LIMIT 50 OFFSET %d", $table, ( $page - 1 ) * 50 ) ); } else { $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE $where ORDER BY occurred_at DESC, event_id DESC LIMIT 50 OFFSET %d", $table, ( $page - 1 ) * 50 ) ); } echo '

' . esc_html( sprintf( /* translators: %s: site timezone. */ __( 'Timezone: %s. User identifies the affected account, not necessarily the acting user.', 'activity-track' ), wp_timezone_string() ) ) . '

'; $this->event_table( $rows ); echo '

' . esc_html__( 'Activity History', 'activity-track' ) . '

'; } }