Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- 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