PluginProbe
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions / 260829
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions v260829
260917 260913 260909 260829 260814 260805 110710 110731 110812 110815 110912 110913 110915 110926 110927 111002 111003 111011 111017 111029 111105 111206 111216 111220 120213 All 189 releases
s2member / src / includes / classes / auto-eots.inc.php

auto-eots.inc.php in s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions 260829, at src/includes/classes/auto-eots.inc.php

1,340 lines 71.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // @codingStandardsIgnoreFile
3 /**
4 * s2Member's Auto-EOT System *(EOT = End Of Term)*.
5 *
6 * Copyright: © 2009-2011
7 * {@link http://websharks-inc.com/ WebSharks, Inc.}
8 * (coded in the USA)
9 *
10 * Released under the terms of the GNU General Public License.
11 * You should have received a copy of the GNU General Public License,
12 * along with this software. In the main directory, see: /licensing/
13 * If not, see: {@link http://www.gnu.org/licenses/}.
14 *
15 * @package s2Member\Auto_EOT_System
16 * @since 3.5
17 */
18 if(!defined('WPINC')) // MUST have WordPress.
19 exit ('Do not access this file directly.');
20
21 if(!class_exists('c_ws_plugin__s2member_auto_eots'))
22 {
23 /**
24 * s2Member's Auto-EOT System *(EOT = End Of Term)*.
25 *
26 * @package s2Member\Auto_EOT_System
27 * @since 3.5
28 */
29 class c_ws_plugin__s2member_auto_eots
30 {
31 /**
32 * Adds a scheduled task for s2Member's Auto-EOT System.
33 *
34 * @package s2Member\Auto_EOT_System
35 * @since 3.5
36 *
37 * @return bool True if able to add Auto-EOT System schedule, else false.
38 */
39 public static function add_auto_eot_system()
40 {
41 do_action('ws_plugin__s2member_before_add_auto_eot_system', get_defined_vars());
42
43 if(!c_ws_plugin__s2member_auto_eots::delete_auto_eot_system())
44 {
45 return apply_filters('ws_plugin__s2member_add_auto_eot_system', FALSE, get_defined_vars());
46 }
47 else if(function_exists('wp_cron') /* Otherwise, we can schedule? */)
48 {
49 //260823.1829 Verify the scheduled event itself because older WordPress versions supported by s2Member return no success value from wp_schedule_event().
50 wp_schedule_event(time(), 'every10m', 'ws_plugin__s2member_auto_eot_system__schedule');
51 $scheduled = (bool)wp_next_scheduled('ws_plugin__s2member_auto_eot_system__schedule') && wp_get_schedule('ws_plugin__s2member_auto_eot_system__schedule') === 'every10m';
52
53 return apply_filters('ws_plugin__s2member_add_auto_eot_system', $scheduled, get_defined_vars());
54 }
55 else // Otherwise, it would appear that WP-Cron is not available.
56 {
57 return apply_filters('ws_plugin__s2member_add_auto_eot_system', FALSE, get_defined_vars());
58 }
59 }
60
61 /**
62 * Recreates a missing recurring Auto-EOT event while preserving any queued catch-up pass.
63 *
64 * @package s2Member\Auto_EOT_System
65 * @since 260819.0613
66 *
67 * @return bool True when no repair is needed or the recurring event exists after repair; otherwise false.
68 */
69 public static function ensure_auto_eot_system()
70 {
71 if(empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['auto_eot_system_enabled']) || (string)$GLOBALS['WS_PLUGIN__']['s2member']['o']['auto_eot_system_enabled'] !== '1')
72 return TRUE;
73
74 if(!function_exists('wp_cron'))
75 return FALSE;
76
77 if(wp_next_scheduled('ws_plugin__s2member_auto_eot_system__schedule'))
78 return TRUE;
79
80 //260820.0149 Preserve a pending catch-up pass because add_auto_eot_system() clears all Auto-EOT schedules before rebuilding the recurring one.
81 $continuation_at = wp_next_scheduled('ws_plugin__s2member_auto_eot_system__continuation');
82 $scheduled = c_ws_plugin__s2member_auto_eots::add_auto_eot_system();
83 if($scheduled && $continuation_at && !wp_next_scheduled('ws_plugin__s2member_auto_eot_system__continuation'))
84 wp_schedule_single_event(max(time() + 1, (int)$continuation_at), 'ws_plugin__s2member_auto_eot_system__continuation');
85
86 //260820.0149 Retain self-heal results for diagnostics, but throttle repeated failure writes on sites where WordPress rejects scheduling every request.
87 $state = get_option('ws_plugin__s2member_auto_eot_state');
88 $state = is_array($state) ? $state : array();
89 if($scheduled)
90 {
91 $state['last_schedule_repaired_at'] = time();
92 $state['schedule_failure_count'] = 0;
93 update_option('ws_plugin__s2member_auto_eot_state', $state, FALSE);
94 }
95 else if(empty($state['last_schedule_failure_at']) || time() - (int)$state['last_schedule_failure_at'] >= 300)
96 {
97 $state['last_schedule_failure_at'] = time();
98 $state['schedule_failure_count'] = !empty($state['schedule_failure_count']) ? (int)$state['schedule_failure_count'] + 1 : 1;
99 update_option('ws_plugin__s2member_auto_eot_state', $state, FALSE);
100 }
101 delete_transient('ws_plugin__s2member_auto_eot_health');
102
103 return $scheduled;
104 }
105
106 /**
107 * Deletes all scheduled tasks for s2Member's Auto-EOT System.
108 *
109 * @package s2Member\Auto_EOT_System
110 * @since 3.5
111 *
112 * @return bool True if able to delete Auto-EOT System schedule, else false.
113 */
114 public static function delete_auto_eot_system()
115 {
116 do_action('ws_plugin__s2member_before_delete_auto_eot_system', get_defined_vars());
117
118 if(function_exists('wp_cron') /* Is `wp_cron()` even available? */)
119 {
120 wp_clear_scheduled_hook('ws_plugin__s2member_auto_eot_system__schedule' /* Since v3.0.3. */);
121 wp_clear_scheduled_hook('ws_plugin__s2member_auto_eot_system__continuation'); //260820.0056 Remove any pending catch-up pass when Auto-EOT scheduling is deleted.
122 delete_transient('ws_plugin__s2member_auto_eot_health'); //260820.0149 Invalidate schedule-dependent health data.
123
124 return apply_filters('ws_plugin__s2member_delete_auto_eot_system', TRUE, get_defined_vars());
125 }
126 else // Otherwise, it would appear that WP-Cron is not available.
127 {
128 return apply_filters('ws_plugin__s2member_delete_auto_eot_system', FALSE, get_defined_vars());
129 }
130 }
131
132 /**
133 * Determines a safe wall-clock budget for one Auto-EOT pass.
134 *
135 * Automatic mode leaves more headroom in shared WP-Cron than in a dedicated external-cron request.
136 * Custom mode is still bounded below PHP's finite execution limit; the developer filter remains final.
137 *
138 * @package s2Member\Auto_EOT_System
139 * @since 260820.0056
140 *
141 * @param bool|null $is_external_cron Optional explicit execution context; null auto-detects the external-cron endpoint.
142 *
143 * @return float Runtime budget in seconds.
144 */
145 public static function auto_eot_system_runtime_budget($is_external_cron = NULL)
146 {
147 $php_max_execution_time = (int)ini_get('max_execution_time');
148 if($is_external_cron === NULL)
149 $is_external_cron = !empty($_GET['s2member_auto_eot_system_via_cron']);
150 else
151 $is_external_cron = (bool)$is_external_cron;
152
153 $automatic_budget = $php_max_execution_time > 0 ? floor($php_max_execution_time * ($is_external_cron ? 0.80 : 0.60)) : ($is_external_cron ? 60 : 30);
154
155 //260820.0306 Custom runtime may raise/lower the automatic target, but keep 10% PHP headroom unless a developer deliberately overrides the final filter.
156 if((string)$GLOBALS['WS_PLUGIN__']['s2member']['o']['auto_eot_system_runtime_mode'] === 'custom')
157 {
158 $runtime_budget = max(1, (float)$GLOBALS['WS_PLUGIN__']['s2member']['o']['auto_eot_system_runtime_custom']);
159 if($php_max_execution_time > 0)
160 $runtime_budget = min($runtime_budget, max(1, floor($php_max_execution_time * 0.90)));
161 }
162 else
163 $runtime_budget = max(1, $automatic_budget);
164
165 $runtime_budget = (float)apply_filters('ws_plugin__s2member_auto_eot_system_runtime', $runtime_budget, get_defined_vars());
166
167 return max(1, $runtime_budget);
168 }
169
170 /**
171 * Describes legacy Auto-EOT per-process filters for diagnostics.
172 *
173 * This never executes the legacy filter. It only reports hooked callbacks and any effective cap
174 * recorded by the last Auto-EOT run, so an inherited customization is visible to site owners.
175 *
176 * @package s2Member\Auto_EOT_System
177 * @since 260820.0306
178 *
179 * @return array Legacy filter information.
180 */
181 public static function auto_eot_system_legacy_cap_info()
182 {
183 global $wp_filter;
184
185 $hook = 'ws_plugin__s2member_auto_eot_system_per_process';
186 $state = get_option('ws_plugin__s2member_auto_eot_state');
187 $state = is_array($state) ? $state : array();
188 $info = array(
189 'detected' => has_filter($hook) !== FALSE,
190 'sources' => array(),
191 'last_hard_cap' => isset($state['last_hard_cap']) && $state['last_hard_cap'] !== NULL ? (int)$state['last_hard_cap'] : NULL,
192 'last_hard_cap_source' => !empty($state['last_hard_cap_source']) ? (string)$state['last_hard_cap_source'] : '',
193 'estimated_additional' => !empty($state['legacy_cap_estimated_additional']) ? (int)$state['legacy_cap_estimated_additional'] : 0,
194 'last_stop_reason' => !empty($state['last_stop_reason']) ? (string)$state['last_stop_reason'] : '',
195 );
196
197 if(!$info['detected'] || empty($wp_filter[$hook]) || !is_object($wp_filter[$hook]) || empty($wp_filter[$hook]->callbacks))
198 return $info;
199
200 //260820.0306 Reflection is best-effort diagnostics only; unusual callback forms still count as detected even when their source cannot be identified.
201 foreach($wp_filter[$hook]->callbacks as $priority => $callbacks)
202 {
203 foreach((array)$callbacks as $callback_data)
204 {
205 if(empty($callback_data['function']))
206 continue;
207
208 $callback = $callback_data['function'];
209 $label = '';
210 $reflection = NULL;
211
212 try
213 {
214 if(is_string($callback))
215 {
216 $label = $callback;
217 if(strpos($callback, '::') !== FALSE)
218 {
219 $_callback_parts = explode('::', $callback, 2);
220 $reflection = new ReflectionMethod($_callback_parts[0], $_callback_parts[1]);
221 unset($_callback_parts);
222 }
223 else
224 $reflection = new ReflectionFunction($callback);
225 }
226 else if(is_array($callback) && count($callback) === 2)
227 {
228 $label = (is_object($callback[0]) ? get_class($callback[0]) : (string)$callback[0]).'::'.$callback[1];
229 $reflection = new ReflectionMethod($callback[0], $callback[1]);
230 }
231 else if($callback instanceof Closure)
232 {
233 $label = 'Closure';
234 $reflection = new ReflectionFunction($callback);
235 }
236 else if(is_object($callback) && is_callable($callback))
237 {
238 $label = get_class($callback).'::__invoke';
239 $reflection = new ReflectionMethod($callback, '__invoke');
240 }
241 }
242 catch(ReflectionException $e)
243 {
244 $reflection = NULL;
245 }
246
247 $file = ($reflection && $reflection->getFileName()) ? wp_normalize_path($reflection->getFileName()) : '';
248 if($file && strpos($file, wp_normalize_path(ABSPATH)) === 0)
249 $file = ltrim(substr($file, strlen(wp_normalize_path(ABSPATH))), '/');
250
251 $info['sources'][] = array(
252 'priority' => (int)$priority,
253 'callback' => $label,
254 'file' => $file,
255 'line' => ($reflection && $reflection->getStartLine()) ? (int)$reflection->getStartLine() : 0,
256 );
257 }
258 }
259 return $info;
260 }
261
262 /**
263 * Determines whether End-of-Term processing may irreversibly delete a WordPress user.
264 *
265 * The safe default is false: the stored `delete` behavior moves the account to Pending Deletion instead.
266 * Developers that intentionally require automatic account deletion can opt in through this filter. Keeping
267 * the decision centralized ensures scheduled Auto-EOT and immediate gateway-triggered EOTs use the same policy.
268 *
269 * @package s2Member\Auto_EOT_System
270 * @since 260822.0520
271 *
272 * @param int $user_id WordPress user ID being processed.
273 * @param string $eot_del_type Prospective irreversible-deletion event type.
274 *
275 * @return bool True only when a developer explicitly allows irreversible End-of-Term deletion.
276 */
277 public static function allow_eot_user_deletion($user_id = 0, $eot_del_type = '')
278 {
279 return (bool)apply_filters('ws_plugin__s2member_allow_eot_user_deletion', FALSE, get_defined_vars());
280 }
281
282 /**
283 * Records when an End-of-Term action was processed and appends one compact history note.
284 *
285 * @package s2Member\Auto_EOT_System
286 * @since 260822.0653
287 *
288 * @param int $user_id WordPress user ID that survived End-of-Term processing.
289 * @param array $details Named End-of-Term history details.
290 *
291 * @return null
292 */
293 public static function record_eot_history($user_id = 0, $details = array())
294 {
295 $user_id = (int)$user_id;
296 //260822.1458 Keep evolving EOT context named instead of positional so call sites cannot silently misorder history fields as this record grows.
297 $details = array_merge(array(
298 'eot_time' => 0,
299 'processed_at' => 0,
300 'original_role' => '',
301 'destination_role' => '',
302 'removed_ccaps' => array(),
303 'subscr_gateway' => '',
304 'subscr_id' => '',
305 ), (array)$details);
306 $eot_time = (int)$details['eot_time'];
307 $processed_at = (int)$details['processed_at'];
308 $original_role = (string)$details['original_role'];
309 $destination_role = (string)$details['destination_role'];
310 $removed_ccaps = (array)$details['removed_ccaps'];
311 $subscr_gateway = (string)$details['subscr_gateway'];
312 $subscr_id = (string)$details['subscr_id'];
313 if(!$user_id || !$processed_at)
314 return;
315
316 try
317 {
318 //260822.0653 Prefer WordPress' timezone object when available so historical EOT and processing timestamps each get the correct DST abbreviation.
319 if(function_exists('wp_timezone'))
320 $timezone = wp_timezone();
321 else if(($timezone_string = (string)get_option('timezone_string')))
322 $timezone = new DateTimeZone($timezone_string);
323 else
324 {
325 $offset = (float)get_option('gmt_offset', 0);
326 $offset_abs = abs($offset);
327 $timezone = new DateTimeZone(sprintf('%s%02d:%02d', $offset < 0 ? '-' : '+', floor($offset_abs), round(($offset_abs - floor($offset_abs)) * 60)));
328 }
329 $processed_date = new DateTime('@'.$processed_at);
330 $processed_date->setTimezone($timezone);
331 $eot_date = new DateTime('@'.($eot_time ?: $processed_at));
332 $eot_date->setTimezone($timezone);
333 $processed_display = $processed_date->format('Y-m-d H:i T');
334 $eot_display = $eot_date->format('Y-m-d H:i T');
335 }
336 catch(Exception $exception)
337 {
338 //260822.0653 Invalid legacy timezone settings must not block EOT processing; UTC is the deterministic fallback for the audit note.
339 $processed_display = gmdate('Y-m-d H:i', $processed_at).' UTC';
340 $eot_display = gmdate('Y-m-d H:i', $eot_time ?: $processed_at).' UTC';
341 }
342
343 global $wp_roles;
344 if(!is_object($wp_roles))
345 $wp_roles = new WP_Roles();
346 $role_labels = array();
347 foreach(array($original_role, $destination_role) as $_role)
348 {
349 $_role = (string)$_role;
350 if(preg_match('/^s2member_level([0-9]+)$/', $_role, $_matches))
351 $role_labels[$_role] = 'Level '.(int)$_matches[1];
352 else if($_role === 's2member_pending_deletion')
353 $role_labels[$_role] = 'Pending Deletion';
354 else if($_role && isset($wp_roles->roles[$_role]['name']))
355 $role_labels[$_role] = translate_user_role($wp_roles->roles[$_role]['name']);
356 else
357 $role_labels[$_role] = $_role ? ucwords(str_replace(array('-', '_'), ' ', $_role)) : 'Unknown Role';
358 }
359 unset($_role, $_matches);
360
361 $gateway_labels = array('paypal' => 'PayPal', 'authnet' => 'Authorize.Net', 'clickbank' => 'ClickBank', 'ccbill' => 'ccBill', 'alipay' => 'AliPay', 'google' => 'Google Wallet', 'stripe' => 'Stripe');
362 $gateway_key = strtolower((string)$subscr_gateway);
363 $gateway_label = isset($gateway_labels[$gateway_key]) ? $gateway_labels[$gateway_key] : ucwords(str_replace(array('-', '_'), ' ', $gateway_key));
364 $removed_ccaps = array_values(array_unique(array_filter(array_map('strval', (array)$removed_ccaps), 'strlen')));
365 sort($removed_ccaps, SORT_STRING);
366
367 $note = $processed_display.' s2Member: Demoted from '.$role_labels[(string)$original_role].' to '.$role_labels[(string)$destination_role];
368 if($removed_ccaps)
369 $note .= ' (removed ccaps: '.implode(', ', $removed_ccaps).')';
370 $note .= '.';
371 if($subscr_gateway && $subscr_id)
372 $note .= ' '.$gateway_label.' '.$subscr_id.'.';
373 $note .= ' EOT '.$eot_display.'.';
374
375 //260822.0653 Keep the action timestamp independent from the triggering EOT timestamp; delayed processing can make these materially different.
376 update_user_option($user_id, 's2member_last_auto_eot_processed_time', $processed_at);
377 c_ws_plugin__s2member_user_notes::append_user_notes($user_id, $note);
378 }
379
380 /**
381 * Starts a best-effort upgrade backfill of historical EOT processing times.
382 *
383 * @package s2Member\Auto_EOT_System
384 * @since 260822.2048
385 *
386 * @return null
387 */
388 public static function start_eot_processed_time_backfill()
389 {
390 $state_option = 'ws_plugin__s2member_auto_eot_state';
391 $state = get_option($state_option);
392 $state = is_array($state) ? $state : array();
393
394 //260822.2048 Reuse Auto-EOT's operational state for this temporary migration cursor; no separate migration option or table is needed.
395 if(!array_key_exists('processed_time_backfill_cursor_umeta_id', $state))
396 {
397 $state['processed_time_backfill_cursor_umeta_id'] = 0;
398 update_option($state_option, $state, FALSE);
399 }
400 self::ensure_eot_processed_time_backfill();
401 }
402
403 /**
404 * Ensures that an unfinished historical EOT processing-time backfill has a continuation event.
405 *
406 * @package s2Member\Auto_EOT_System
407 * @since 260822.2048
408 *
409 * @return null
410 */
411 public static function ensure_eot_processed_time_backfill()
412 {
413 $state = get_option('ws_plugin__s2member_auto_eot_state');
414 $hook = 'ws_plugin__s2member_eot_processed_time_backfill';
415
416 if(is_array($state) && array_key_exists('processed_time_backfill_cursor_umeta_id', $state) && !wp_next_scheduled($hook))
417 wp_schedule_single_event(time() + 5, $hook);
418 }
419
420 /**
421 * Backfills EOT processing times that can be recovered from legacy Administrative Notes.
422 *
423 * @package s2Member\Auto_EOT_System
424 * @since 260822.2048
425 *
426 * @return null
427 */
428 public static function backfill_eot_processed_times()
429 {
430 global $wpdb;
431
432 $state_option = 'ws_plugin__s2member_auto_eot_state';
433 $state = get_option($state_option);
434 $state = is_array($state) ? $state : array();
435 if(!array_key_exists('processed_time_backfill_cursor_umeta_id', $state))
436 return;
437
438 $cursor_umeta_id = (int)$state['processed_time_backfill_cursor_umeta_id'];
439 $last_key = $wpdb->prefix.'s2member_last_auto_eot_time';
440 $processed_key = $wpdb->prefix.'s2member_last_auto_eot_processed_time';
441 $notes_key = $wpdb->prefix.'s2member_notes';
442 $rows = $wpdb->get_results($wpdb->prepare(
443 "SELECT `last`.`umeta_id`, `last`.`user_id`, CAST(`last`.`meta_value` AS UNSIGNED) AS `eot_time`, `notes`.`meta_value` AS `notes` FROM `".$wpdb->usermeta."` `last` INNER JOIN `".$wpdb->usermeta."` `notes` ON `notes`.`user_id` = `last`.`user_id` AND `notes`.`meta_key` = %s LEFT JOIN `".$wpdb->usermeta."` `processed` ON `processed`.`user_id` = `last`.`user_id` AND `processed`.`meta_key` = %s WHERE `last`.`meta_key` = %s AND `last`.`umeta_id` > %d AND CAST(`last`.`meta_value` AS UNSIGNED) > 0 AND `processed`.`umeta_id` IS NULL AND `notes`.`meta_value` LIKE %s ORDER BY `last`.`umeta_id` ASC LIMIT 100",
444 $notes_key, $processed_key, $last_key, $cursor_umeta_id, '%Demoted by s2Member:%'
445 ));
446 $rows = is_array($rows) ? $rows : array();
447
448 foreach($rows as $row)
449 {
450 $cursor_umeta_id = (int)$row->umeta_id;
451 $lines = preg_split('/\r\n|\r|\n/', (string)$row->notes);
452 foreach(array_reverse((array)$lines) as $line)
453 if(preg_match('/^Demoted by s2Member:\s*(.+)$/', trim($line), $matches))
454 {
455 $processed_at = strtotime($matches[1]);
456 //260822.2048 Legacy notes have minute precision; accept up to 59 seconds before an immediate EOT timestamp, but never guess from an older unrelated demotion note.
457 if($processed_at && $processed_at + MINUTE_IN_SECONDS >= (int)$row->eot_time)
458 {
459 $current_last_eot = $wpdb->get_var($wpdb->prepare("SELECT CAST(`meta_value` AS UNSIGNED) FROM `".$wpdb->usermeta."` WHERE `umeta_id` = %d AND `user_id` = %d AND `meta_key` = %s LIMIT 1", (int)$row->umeta_id, (int)$row->user_id, $last_key));
460 $processed_exists = $wpdb->get_var($wpdb->prepare("SELECT 1 FROM `".$wpdb->usermeta."` WHERE `user_id` = %d AND `meta_key` = %s LIMIT 1", (int)$row->user_id, $processed_key));
461 //260822.2259 Revalidate before writing legacy history; a newly processed EOT always wins over this best-effort upgrade backfill.
462 if($current_last_eot !== NULL && (int)$current_last_eot === (int)$row->eot_time && !$processed_exists)
463 add_user_meta((int)$row->user_id, $processed_key, $processed_at, TRUE);
464 break;
465 }
466 }
467 }
468 unset($row, $lines, $line, $matches, $processed_at);
469
470 $state = get_option($state_option);
471 $state = is_array($state) ? $state : array();
472 if(count($rows) === 100)
473 $state['processed_time_backfill_cursor_umeta_id'] = $cursor_umeta_id;
474 else
475 unset($state['processed_time_backfill_cursor_umeta_id']);
476 update_option($state_option, $state, FALSE);
477
478 if(count($rows) === 100)
479 self::ensure_eot_processed_time_backfill();
480 }
481
482 /**
483 * Applies the effective `delete` End-of-Term behavior.
484 *
485 * @package s2Member\Auto_EOT_System
486 * @since 260822.0535
487 *
488 * @param int $user_id WordPress user ID being processed.
489 * @param string $eot_del_type EOT/deletion event type.
490 * @param int $eot_time Unix timestamp that triggered this End-of-Term action.
491 *
492 * @return string `pending_deletion`, `deleted`, `removed`, or an empty string when no user was processed.
493 */
494 public static function process_eot_deletion($user_id = 0, $eot_del_type = '', $eot_time = 0)
495 {
496 $user_id = (int)$user_id;
497 $eot_time = (int)$eot_time;
498 if(!$user_id || !is_object($user = new WP_User($user_id)) || !$user->ID)
499 return '';
500
501 if(self::allow_eot_user_deletion($user_id, $eot_del_type))
502 {
503 //260822.0535 True deletion is deliberately opt-in; preserve the historical deletion/removal path only after the developer filter explicitly allows it.
504 $GLOBALS['ws_plugin__s2member_eot_del_type'] = (string)$eot_del_type;
505 if(is_multisite())
506 {
507 $blog_id = get_current_blog_id();
508 remove_user_from_blog($user_id, $blog_id);
509 c_ws_plugin__s2member_user_deletions::handle_ms_user_deletions($user_id, $blog_id, 's2says');
510 return 'removed';
511 }
512 include_once ABSPATH.'wp-admin/includes/admin.php';
513 wp_delete_user($user_id);
514 return 'deleted';
515 }
516
517 $pending_role = 's2member_pending_deletion';
518 $pending_meta = get_user_option('s2member_eot_pending_deletion', $user_id);
519 $already_pending = in_array($pending_role, (array)$user->roles, TRUE) && is_array($pending_meta) && isset($pending_meta['eot_time'], $pending_meta['processed_at'], $pending_meta['original_role']);
520 $original_role = $already_pending ? (string)$pending_meta['original_role'] : c_ws_plugin__s2member_user_access::user_access_role($user);
521 $processed_at = time();
522 $removed_ccaps = $already_pending ? array() : c_ws_plugin__s2member_user_access::user_access_ccaps($user);
523 $subscr_gateway = $already_pending ? '' : get_user_option('s2member_subscr_gateway', $user_id);
524 $subscr_id = $already_pending ? '' : get_user_option('s2member_subscr_id', $user_id);
525
526 //260822.0549 A surviving account can receive a replayed gateway event; preserve the first transition record and avoid duplicate EOT history/notifications when it is already safely pending.
527 if(!$already_pending)
528 update_user_option($user_id, 's2member_eot_pending_deletion', array(
529 'eot_time' => $eot_time ?: $processed_at,
530 'processed_at' => $processed_at,
531 'original_role' => $original_role,
532 ));
533 delete_user_option($user_id, 's2member_auto_eot_time');
534 delete_user_option($user_id, 's2member_auto_eot_details');
535
536 //260822.0535 Activation normally creates this role; the fallback keeps an EOT safe if role configuration has not yet been refreshed after an in-place update.
537 if(!get_role($pending_role))
538 add_role($pending_role, 'Pending Deletion', array('read' => TRUE));
539 if(!in_array($pending_role, (array)$user->roles, TRUE))
540 $user->set_role($pending_role);
541
542 //260822.0535 Pending Deletion must never retain user-specific s2Member Level or Custom Capability grants after the role change.
543 foreach($user->allcaps as $cap => $cap_enabled)
544 if($cap_enabled && preg_match('/^access_s2member_(?:level[0-9]+|ccap_)/', $cap))
545 $user->remove_cap($cap);
546
547 if(!$already_pending)
548 {
549 //260822.0653 Pending Deletion survives the EOT, so archive the triggering timestamp just like an ordinary demotion; this keeps Last EOT/reporting complete without clearing gateway metadata needed for review.
550 update_user_option($user_id, 's2member_last_auto_eot_time', $eot_time ?: $processed_at);
551 self::record_eot_history($user_id, array(
552 'eot_time' => $eot_time ?: $processed_at,
553 'processed_at' => $processed_at,
554 'original_role' => $original_role,
555 'destination_role' => $pending_role,
556 'removed_ccaps' => $removed_ccaps,
557 'subscr_gateway' => $subscr_gateway,
558 'subscr_id' => $subscr_id,
559 ));
560 //260822.0535 A preserved account never reaches WordPress' deletion hook, so send the configured EOT/Deletion notifications explicitly instead of silently dropping them.
561 self::pending_deletion_notifications($user_id, $eot_del_type);
562 }
563
564 return 'pending_deletion';
565 }
566
567 /**
568 * Sends configured EOT/Deletion notifications for an account preserved in Pending Deletion.
569 *
570 * @package s2Member\Auto_EOT_System
571 * @since 260822.0535
572 *
573 * @param int $user_id WordPress user ID being preserved.
574 * @param string $eot_del_type EOT/deletion event type.
575 *
576 * @return null
577 */
578 public static function pending_deletion_notifications($user_id = 0, $eot_del_type = '')
579 {
580 $user_id = (int)$user_id;
581 if(!$user_id || !is_object($user = new WP_User($user_id)) || !$user->ID)
582 return;
583
584 $custom = get_user_option('s2member_custom', $user_id);
585 $subscr_id = get_user_option('s2member_subscr_id', $user_id);
586 $subscr_baid = get_user_option('s2member_subscr_baid', $user_id);
587 $subscr_cid = get_user_option('s2member_subscr_cid', $user_id);
588 $fields = get_user_option('s2member_custom_fields', $user_id);
589 $user_reg_ip = get_user_option('s2member_registration_ip', $user_id);
590
591 if($GLOBALS['WS_PLUGIN__']['s2member']['o']['eot_del_notification_urls'])
592 {
593 foreach(preg_split("/[\r\n\t]+/", $GLOBALS['WS_PLUGIN__']['s2member']['o']['eot_del_notification_urls']) as $url)
594 if(($url = c_ws_plugin__s2member_utils_strings::fill_cvs($url, $custom, true)) && ($url = preg_replace('/%%eot_del_type%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(urlencode($eot_del_type)), $url)) && ($url = preg_replace('/%%subscr_id%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(urlencode($subscr_id)), $url)))
595 if(($url = preg_replace('/%%subscr_baid%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(urlencode($subscr_baid)), $url)) && ($url = preg_replace('/%%subscr_cid%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(urlencode($subscr_cid)), $url)))
596 if(($url = preg_replace('/%%user_first_name%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(urlencode($user->first_name)), $url)) && ($url = preg_replace('/%%user_last_name%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(urlencode($user->last_name)), $url)))
597 if(($url = preg_replace('/%%user_full_name%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(urlencode(trim($user->first_name.' '.$user->last_name))), $url)))
598 if(($url = preg_replace('/%%user_email%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(urlencode($user->user_email)), $url)))
599 if(($url = preg_replace('/%%user_login%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(urlencode($user->user_login)), $url)))
600 if(($url = preg_replace('/%%user_ip%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(urlencode($user_reg_ip)), $url)))
601 if(($url = preg_replace('/%%user_id%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(urlencode($user_id)), $url)))
602 {
603 if(is_array($fields) && !empty($fields))
604 foreach($fields as $var => $val)
605 if(!($url = preg_replace('/%%'.preg_quote($var, '/').'%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(urlencode(maybe_serialize($val))), $url)))
606 break;
607
608 if(($url = trim(preg_replace('/%%(.+?)%%/i', '', $url))))
609 c_ws_plugin__s2member_utils_urls::remote($url);
610 }
611 }
612 if($GLOBALS['WS_PLUGIN__']['s2member']['o']['eot_del_notification_recipients'])
613 {
614 $email_configs_were_on = c_ws_plugin__s2member_email_configs::email_config_status();
615 c_ws_plugin__s2member_email_configs::email_config_release();
616
617 $msg = $sbj = '(s2Member / API Notification Email) - EOT/Deletion';
618 $msg .= "\n\n";
619
620 $msg .= 'eot_del_type: %%eot_del_type%%'."\n";
621 $msg .= 'subscr_id: %%subscr_id%%'."\n";
622 $msg .= 'subscr_baid: %%subscr_baid%%'."\n";
623 $msg .= 'subscr_cid: %%subscr_cid%%'."\n";
624 $msg .= 'user_first_name: %%user_first_name%%'."\n";
625 $msg .= 'user_last_name: %%user_last_name%%'."\n";
626 $msg .= 'user_full_name: %%user_full_name%%'."\n";
627 $msg .= 'user_email: %%user_email%%'."\n";
628 $msg .= 'user_login: %%user_login%%'."\n";
629 $msg .= 'user_ip: %%user_ip%%'."\n";
630 $msg .= 'user_id: %%user_id%%'."\n";
631
632 if(is_array($fields) && !empty($fields))
633 foreach($fields as $var => $val)
634 $msg .= $var.': %%'.$var.'%%'."\n";
635
636 $msg .= 'cv0: %%cv0%%'."\n";
637 $msg .= 'cv1: %%cv1%%'."\n";
638 $msg .= 'cv2: %%cv2%%'."\n";
639 $msg .= 'cv3: %%cv3%%'."\n";
640 $msg .= 'cv4: %%cv4%%'."\n";
641 $msg .= 'cv5: %%cv5%%'."\n";
642 $msg .= 'cv6: %%cv6%%'."\n";
643 $msg .= 'cv7: %%cv7%%'."\n";
644 $msg .= 'cv8: %%cv8%%'."\n";
645 $msg .= 'cv9: %%cv9%%';
646
647 if(($msg = c_ws_plugin__s2member_utils_strings::fill_cvs($msg, $custom)) && ($msg = preg_replace('/%%eot_del_type%%/i', c_ws_plugin__s2member_utils_strings::esc_refs($eot_del_type), $msg)) && ($msg = preg_replace('/%%subscr_id%%/i', c_ws_plugin__s2member_utils_strings::esc_refs($subscr_id), $msg)))
648 if(($msg = preg_replace('/%%subscr_baid%%/i', c_ws_plugin__s2member_utils_strings::esc_refs($subscr_baid), $msg)) && ($msg = preg_replace('/%%subscr_cid%%/i', c_ws_plugin__s2member_utils_strings::esc_refs($subscr_cid), $msg)))
649 if(($msg = preg_replace('/%%user_first_name%%/i', c_ws_plugin__s2member_utils_strings::esc_refs($user->first_name), $msg)) && ($msg = preg_replace('/%%user_last_name%%/i', c_ws_plugin__s2member_utils_strings::esc_refs($user->last_name), $msg)))
650 if(($msg = preg_replace('/%%user_full_name%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(trim($user->first_name.' '.$user->last_name)), $msg)))
651 if(($msg = preg_replace('/%%user_email%%/i', c_ws_plugin__s2member_utils_strings::esc_refs($user->user_email), $msg)))
652 if(($msg = preg_replace('/%%user_login%%/i', c_ws_plugin__s2member_utils_strings::esc_refs($user->user_login), $msg)))
653 if(($msg = preg_replace('/%%user_ip%%/i', c_ws_plugin__s2member_utils_strings::esc_refs($user_reg_ip), $msg)))
654 if(($msg = preg_replace('/%%user_id%%/i', c_ws_plugin__s2member_utils_strings::esc_refs($user_id), $msg)))
655 {
656 if(is_array($fields) && !empty($fields))
657 foreach($fields as $var => $val)
658 if(!($msg = preg_replace('/%%'.preg_quote($var, '/').'%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(maybe_serialize($val)), $msg)))
659 break;
660
661 if($sbj && ($msg = trim(preg_replace('/%%(.+?)%%/i', '', $msg))))
662 foreach(c_ws_plugin__s2member_utils_strings::parse_emails($GLOBALS['WS_PLUGIN__']['s2member']['o']['eot_del_notification_recipients']) as $recipient)
663 wp_mail($recipient, apply_filters('ws_plugin__s2member_eot_del_notification_email_sbj', $sbj, get_defined_vars()), apply_filters('ws_plugin__s2member_eot_del_notification_email_msg', $msg, get_defined_vars()), 'Content-Type: text/plain; charset=UTF-8');
664 }
665 if($email_configs_were_on)
666 c_ws_plugin__s2member_email_configs::email_config();
667 }
668 }
669
670 /**
671 * Returns a cached health snapshot for the Auto-EOT system.
672 *
673 * @package s2Member\Auto_EOT_System
674 * @since 260820.0149
675 *
676 * @param bool $force_refresh Force a fresh usermeta/schedule check.
677 *
678 * @return array Auto-EOT health information for diagnostics and UI.
679 */
680 public static function auto_eot_system_health($force_refresh = FALSE)
681 {
682 global $wpdb;
683 /** @var $wpdb \wpdb */
684
685 $cache_key = 'ws_plugin__s2member_auto_eot_health';
686 if(!$force_refresh && is_array($health = get_transient($cache_key)))
687 return $health;
688
689 $now = time();
690 $mode = (string)$GLOBALS['WS_PLUGIN__']['s2member']['o']['auto_eot_system_enabled'];
691 $state = get_option('ws_plugin__s2member_auto_eot_state');
692 $state = is_array($state) ? $state : array();
693 $lock = get_option('ws_plugin__s2member_auto_eot_lock');
694 $lock = is_array($lock) ? $lock : array();
695 $meta_key = $wpdb->prefix.'s2member_auto_eot_time';
696
697 //260820.0149 One exact-meta-key aggregate supplies both pending volume and oldest overdue age without loading EOT rows into PHP.
698 $pending = $wpdb->get_row($wpdb->prepare("SELECT COUNT(*) AS `pending_count`, MIN(CAST(`meta_value` AS UNSIGNED)) AS `oldest_due_at` FROM `".$wpdb->usermeta."` WHERE `meta_key` = %s AND CAST(`meta_value` AS UNSIGNED) > 0 AND CAST(`meta_value` AS UNSIGNED) <= %d", $meta_key, $now));
699 $pending_count = ($pending && !empty($pending->pending_count)) ? (int)$pending->pending_count : 0;
700 $oldest_due_at = ($pending && !empty($pending->oldest_due_at)) ? (int)$pending->oldest_due_at : 0;
701 $oldest_overdue_seconds = $oldest_due_at ? max(0, $now - $oldest_due_at) : 0;
702
703 $recurring_at = ($mode === '1' && function_exists('wp_cron')) ? wp_next_scheduled('ws_plugin__s2member_auto_eot_system__schedule') : FALSE;
704 $continuation_at = ($mode === '1' && function_exists('wp_cron')) ? wp_next_scheduled('ws_plugin__s2member_auto_eot_system__continuation') : FALSE;
705 $issues = array();
706 $critical = FALSE;
707 $last_completed_at = !empty($state['last_completed_at']) ? (int)$state['last_completed_at'] : 0;
708 $last_processed = isset($state['last_processed']) ? (int)$state['last_processed'] : 0;
709 $last_more_due_work = !empty($state['last_more_due_work']);
710 $runtime_budget = self::auto_eot_system_runtime_budget($mode === '2');
711 $lock_stale_after = max(120, (int)ceil(($runtime_budget * 2) + 30));
712 //260823.0021 A lock means active processing only while its heartbeat is inside the same stale window used by the worker; an abandoned lock must not mask health as current work.
713 $is_running = !empty($lock['heartbeat_at']) && $now - (int)$lock['heartbeat_at'] <= $lock_stale_after;
714 $catchup_fresh_after = ($mode === '2') ? 2 * HOUR_IN_SECONDS : 30 * MINUTE_IN_SECONDS;
715 //260822.0614 Catch-up is ordinary queue progress, not a separate incident: report it only while a recent productive pass says more due work remains.
716 $catching_up = $pending_count && $last_more_due_work && $last_processed > 0 && $last_completed_at && $now - $last_completed_at < $catchup_fresh_after;
717
718 //260820.0149 Escalate scheduler failures independently of pending EOTs so a broken cron can be noticed before months of expirations accumulate.
719 if($mode === '1')
720 {
721 if(!function_exists('wp_cron') || !$recurring_at)
722 $issues['cron_missing'] = $critical = TRUE;
723 else if((int)$recurring_at < $now - HOUR_IN_SECONDS)
724 $issues['cron_overdue'] = $critical = TRUE;
725 }
726 else if($mode === '2' && !empty($state['last_external_completed_at']) && $now - (int)$state['last_external_completed_at'] >= 2 * HOUR_IN_SECONDS)
727 $issues['external_cron_stale'] = $critical = TRUE;
728
729 if(($mode === '1' || $mode === '2') && $pending_count)
730 {
731 if($catching_up)
732 $issues['catching_up'] = TRUE;
733 else if($oldest_overdue_seconds >= 2 * HOUR_IN_SECONDS)
734 $issues['eot_overdue'] = $critical = TRUE;
735 else if($oldest_overdue_seconds >= 30 * MINUTE_IN_SECONDS)
736 $issues['eot_delayed'] = TRUE;
737 }
738
739 $consecutive_abandoned = !empty($state['consecutive_abandoned_runs']) ? (int)$state['consecutive_abandoned_runs'] : 0;
740 if(($mode === '1' || $mode === '2') && $consecutive_abandoned >= 2)
741 $issues['repeated_abandoned'] = $critical = TRUE;
742 else if(($mode === '1' || $mode === '2') && $consecutive_abandoned === 1)
743 $issues['abandoned'] = TRUE;
744
745 $health = array(
746 'generated_at' => $now,
747 'mode' => $mode,
748 'status' => !$mode ? 'disabled' : ($critical ? 'error' : ($is_running ? 'processing' : (isset($issues['catching_up']) && count($issues) === 1 ? 'catching_up' : ($issues ? 'attention' : 'healthy')))),
749 'needs_admin_notice' => $critical ? 1 : 0,
750 'issues' => array_keys($issues),
751 'pending_count' => $pending_count,
752 'oldest_due_at' => $oldest_due_at,
753 'oldest_overdue_seconds' => $oldest_overdue_seconds,
754 'recurring_at' => $recurring_at ? (int)$recurring_at : 0,
755 'continuation_at' => $continuation_at ? (int)$continuation_at : 0,
756 'is_running' => $is_running ? 1 : 0,
757 'last_started_at' => !empty($state['last_started_at']) ? (int)$state['last_started_at'] : 0,
758 'last_completed_at' => $last_completed_at,
759 'last_runtime' => isset($state['last_runtime']) ? (float)$state['last_runtime'] : 0.0,
760 'last_processed' => $last_processed,
761 'last_more_due_work' => $last_more_due_work ? 1 : 0,
762 'last_stop_reason' => !empty($state['last_stop_reason']) ? (string)$state['last_stop_reason'] : '',
763 'last_abandoned_at' => !empty($state['last_abandoned_at']) ? (int)$state['last_abandoned_at'] : 0,
764 'consecutive_abandoned_runs' => $consecutive_abandoned,
765 'last_schedule_failure_at' => !empty($state['last_schedule_failure_at']) ? (int)$state['last_schedule_failure_at'] : 0,
766 'schedule_failure_count' => !empty($state['schedule_failure_count']) ? (int)$state['schedule_failure_count'] : 0,
767 'last_external_completed_at' => !empty($state['last_external_completed_at']) ? (int)$state['last_external_completed_at'] : 0,
768 );
769 $health = apply_filters('ws_plugin__s2member_auto_eot_system_health', $health, get_defined_vars());
770
771 //260820.0149 Cache the admin-facing aggregate briefly; processing itself never relies on this snapshot.
772 set_transient($cache_key, $health, 5 * MINUTE_IN_SECONDS);
773
774 return $health;
775 }
776
777 /**
778 * Displays a site-wide administrative warning when Auto-EOT health becomes materially unsafe.
779 *
780 * @package s2Member\Auto_EOT_System
781 * @since 260820.0149
782 *
783 * @return null
784 */
785 public static function auto_eot_system_admin_notice()
786 {
787 if(!is_admin() || !current_user_can('manage_options'))
788 return;
789
790 $health = self::auto_eot_system_health();
791 if(empty($health['needs_admin_notice']))
792 return;
793
794 $reasons = array();
795 if(in_array('cron_missing', $health['issues'], TRUE))
796 $reasons[] = 'The recurring WP-Cron event is missing and s2Member could not restore it.';
797 if(in_array('cron_overdue', $health['issues'], TRUE))
798 $reasons[] = 'The recurring WP-Cron event is more than an hour overdue.';
799 if(in_array('external_cron_stale', $health['issues'], TRUE))
800 $reasons[] = 'The configured external cron has not completed an Auto-EOT pass in more than two hours.';
801 if(in_array('eot_overdue', $health['issues'], TRUE))
802 $reasons[] = number_format_i18n($health['pending_count']).' End-of-Term action'.($health['pending_count'] === 1 ? ' is' : 's are').' pending; the oldest has been overdue for '.human_time_diff($health['oldest_due_at'], time()).'.';
803 if(in_array('repeated_abandoned', $health['issues'], TRUE))
804 $reasons[] = number_format_i18n($health['consecutive_abandoned_runs']).' consecutive Automatic End-of-Term workers ended without reaching normal completion.';
805
806 $settings_url = admin_url('/admin.php?page=ws-plugin--s2member-paypal-ops').'#ws-plugin--s2member-auto-eot-system-enabled';
807 $notice = '<strong>s2Member Automatic End-of-Term needs attention.</strong> '.esc_html(implode(' ', $reasons)).' <a href="'.esc_url($settings_url).'">Review Automatic End-of-Term settings</a>.';
808 c_ws_plugin__s2member_admin_notices::display_admin_notice($notice, TRUE);
809 }
810
811 /**
812 * Runs an Auto-EOT catch-up continuation.
813 *
814 * Catch-up passes drain overdue EOTs promptly while remaining separate from the historical
815 * collective after-hook, so Pro reminder/gateway polling is not multiplied during catch-up.
816 *
817 * @package s2Member\Auto_EOT_System
818 * @since 260820.0056
819 *
820 * @return null
821 */
822 public static function auto_eot_system_continuation()
823 {
824 self::auto_eot_system(10, TRUE);
825 }
826
827
828 /**
829 * Processed by WP_Cron; this handles Auto-EOTs *(EOT = End Of Term)*.
830 *
831 * Normal processing is runtime-adaptive. The historical `$per_process` argument/filter remains
832 * available as a legacy hard item cap when a caller supplies it explicitly or a filter is attached.
833 *
834 * This function makes an important Hook available: `ws_plugin__s2member_after_auto_eot_system`.
835 * This Hook is used by some of s2Member Pro's Gateway integrations; allowing CRON processing
836 * to run for important communications; which poll Payment Gateway APIs for possible EOTs.
837 * Internal catch-up continuations intentionally do not fire that collective after-hook.
838 *
839 * 260821.0626 `ws_plugin__s2member_auto_eot_lock` is a short-lived non-autoloaded option containing
840 * `token`, `started_at`, `heartbeat_at`, `processed`, and `current_user_id`. Timestamps are Unix timestamps;
841 * counters/IDs are integers. A surviving stale lock is evidence that a worker did not reach normal cleanup.
842 *
843 * `ws_plugin__s2member_auto_eot_state` is non-autoloaded operational state. Fields are added when relevant:
844 * - Run: `last_started_at`, `active_run_token`, `last_completed_at`, `last_runtime`, `last_runtime_budget`,
845 * `last_processed`, `last_stop_reason`, `last_invocation`, `last_external_completed_at`.
846 * Stop reasons are `queue_empty`, `runtime_budget`, or `legacy_item_cap`; invocation is `continuation`,
847 * `external_cron`, `wp_cron`, or `direct`.
848 * - Pending work: `last_more_due_work`, `last_pending_count`, `last_oldest_due_at`, `last_oldest_overdue_seconds`.
849 * - Legacy cap: `last_hard_cap` (int|null), `last_hard_cap_source` (`filter` or `explicit`),
850 * `legacy_cap_estimated_additional`.
851 * - Abandoned run: `last_abandoned_at`, `last_abandoned_started_at`, `last_abandoned_heartbeat_at`,
852 * `last_abandoned_processed`, `last_abandoned_user_id`, `consecutive_abandoned_runs`.
853 * - Scheduler repair: `last_schedule_repaired_at`, `last_schedule_failure_at`, `schedule_failure_count`.
854 * 260822.0614 Catch-up health is derived from ordinary pending/run state; there is no separate incident, cutoff,
855 * backlog audit, or review-role state that can change how overdue users are processed.
856 * Performance timing is descriptive for the last pass only; it is never persistent runtime-learning input.
857 *
858 * @package s2Member\Auto_EOT_System
859 * @since 3.5
860 *
861 * @param int $per_process Legacy maximum database records to process in this pass when explicitly supplied or filtered.
862 * @param bool $is_continuation Internal catch-up continuation; skips the collective after-hook.
863 *
864 * @return null
865 */
866 public static function auto_eot_system($per_process = 10, $is_continuation = FALSE)
867 {
868 global $wpdb;
869 /** @var $wpdb \wpdb */
870 global $current_site, $current_blog;
871
872 include_once ABSPATH.'wp-admin/includes/admin.php';
873
874 //260820.0056 Do not disable PHP's execution limit here; the adaptive engine deliberately works inside a measured wall-clock budget.
875 @ini_set('memory_limit', apply_filters('admin_memory_limit', WP_MAX_MEMORY_LIMIT));
876
877 foreach(array_keys(get_defined_vars()) as $__v) $__refs[$__v] =& $$__v;
878 do_action('ws_plugin__s2member_before_auto_eot_system', get_defined_vars());
879 unset($__refs, $__v); // Housekeeping.
880
881 //260823.0421 !!! TO-DO: Revisit disabled Auto-EOT lifecycle semantics. Consider archiving an elapsed current EOT as Last EOT with an explicit skip/no-change outcome while leaving membership access untouched, instead of keeping it pending for later demotion/deletion when processing is re-enabled. This requires a safe lifecycle trigger while the action worker is disabled and must preserve reminder/provenance history correctly.
882 if($GLOBALS['WS_PLUGIN__']['s2member']['o']['auto_eot_system_enabled'] /* Enabled? */)
883 {
884 //260820.0056 Count the budget from the request start, not merely this callback, so WordPress bootstrap/earlier cron work consumes its share too.
885 $runtime_budget = self::auto_eot_system_runtime_budget();
886 $request_started = isset($_SERVER['REQUEST_TIME_FLOAT']) && is_numeric($_SERVER['REQUEST_TIME_FLOAT']) ? (float)$_SERVER['REQUEST_TIME_FLOAT'] : microtime(TRUE);
887 $run_started = microtime(TRUE);
888 $deadline = $request_started + $runtime_budget;
889
890 //260820.0056 Reserve padding beyond the predicted next user's cost; cap that reserve at 25% so short runtime budgets still retain useful processing time.
891 $safety_buffer = min($runtime_budget * 0.25, max(0.25, (float)apply_filters('ws_plugin__s2member_auto_eot_system_runtime_safety_buffer', 1.0, get_defined_vars())));
892
893 //260820.0056 A small non-autoloaded lock detects overlap and leaves evidence when a worker dies before reaching normal cleanup.
894 $run_token = function_exists('wp_generate_uuid4') ? wp_generate_uuid4() : uniqid('s2-eot-', TRUE);
895 $lock_option = 'ws_plugin__s2member_auto_eot_lock';
896 $state_option = 'ws_plugin__s2member_auto_eot_state';
897 $lock_stale_after = max(120, (int)ceil(($runtime_budget * 2) + 30));
898 $existing_lock = get_option($lock_option);
899
900 //260820.0149 Discard malformed leftover state before evaluating whether another worker is active.
901 if($existing_lock !== FALSE && (!is_array($existing_lock) || empty($existing_lock['heartbeat_at'])))
902 {
903 delete_option($lock_option);
904 delete_transient('ws_plugin__s2member_auto_eot_health');
905 $existing_lock = FALSE;
906 }
907
908 //260820.0056 A stale marker means the previous process never reached cleanup; preserve the useful evidence without guessing whether it was timeout, OOM, fatal error, etc.
909 if(is_array($existing_lock) && !empty($existing_lock['heartbeat_at']) && time() - (int)$existing_lock['heartbeat_at'] > $lock_stale_after)
910 {
911 $state = get_option($state_option);
912 $state = is_array($state) ? $state : array();
913 $state['last_abandoned_at'] = time();
914 $state['last_abandoned_started_at'] = !empty($existing_lock['started_at']) ? (int)$existing_lock['started_at'] : 0;
915 $state['last_abandoned_heartbeat_at'] = !empty($existing_lock['heartbeat_at']) ? (int)$existing_lock['heartbeat_at'] : 0;
916 $state['last_abandoned_processed'] = !empty($existing_lock['processed']) ? (int)$existing_lock['processed'] : 0;
917 $state['last_abandoned_user_id'] = !empty($existing_lock['current_user_id']) ? (int)$existing_lock['current_user_id'] : 0;
918 $state['consecutive_abandoned_runs'] = !empty($state['consecutive_abandoned_runs']) ? (int)$state['consecutive_abandoned_runs'] + 1 : 1;
919 update_option($state_option, $state, FALSE);
920 delete_option($lock_option);
921 delete_transient('ws_plugin__s2member_auto_eot_health');
922 $existing_lock = FALSE;
923 }
924
925 //260820.0056 A fresh marker belongs to another worker that should still be alive; never process the same overdue population concurrently.
926 if(is_array($existing_lock) && !empty($existing_lock['heartbeat_at']))
927 return;
928
929 //260820.0056 Use add_option() for lock acquisition so two workers racing here cannot both believe they acquired it.
930 $lock = array('token' => $run_token, 'started_at' => time(), 'heartbeat_at' => time(), 'processed' => 0, 'current_user_id' => 0);
931 if(!add_option($lock_option, $lock, '', FALSE))
932 return; // Another worker acquired the lock between our read and add.
933
934 //260820.0056 Persist only operational health between runs; performance timing remains local to each pass so it adapts organically to current conditions.
935 $state = get_option($state_option);
936 $state = is_array($state) ? $state : array();
937 $state['last_started_at'] = time();
938 $state['active_run_token'] = $run_token;
939 update_option($state_option, $state, FALSE);
940 delete_transient('ws_plugin__s2member_auto_eot_health'); //260820.0149 Invalidate any cached pre-run status.
941
942 //260820.0056 The historical count becomes a hard cap only when code explicitly supplies/filters it; the untouched default no longer throttles normal installations.
943 $per_process_filter_attached = has_filter('ws_plugin__s2member_auto_eot_system_per_process') !== FALSE;
944 $per_process_was_explicit = func_num_args() > 0 && !$is_continuation;
945 $per_process = apply_filters('ws_plugin__s2member_auto_eot_system_per_process', $per_process, get_defined_vars());
946 $hard_cap = ($per_process_filter_attached || $per_process_was_explicit) ? max(0, (int)$per_process) : NULL;
947 $hard_cap_source = $per_process_filter_attached ? 'filter' : ($per_process_was_explicit ? 'explicit' : '');
948
949 //260820.0056 Fetch modest ordered chunks from MySQL; 100 is only a query-buffer size, never the normal processing throttle.
950 $chunk_size = 100;
951 $processed_count = 0;
952 $item_total_duration = 0.0;
953 $last_item_duration = 0.0;
954 $last_heartbeat = microtime(TRUE);
955 $cursor_time = 0;
956 $cursor_umeta_id = 0;
957 $stop_reason = 'queue_empty';
958 $meta_key = $wpdb->prefix.'s2member_auto_eot_time';
959
960 while(TRUE)
961 {
962 //260820.0056 Honor an intentional legacy ceiling before doing another query or user operation.
963 if($hard_cap !== NULL && $processed_count >= $hard_cap)
964 {
965 $stop_reason = 'legacy_item_cap';
966 break;
967 }
968
969 //260820.0056 Near the deadline, use only this run's last/average item times to decide whether another EOT is likely to fit safely.
970 $remaining_runtime = $deadline - microtime(TRUE);
971 $average_item_duration = $processed_count ? $item_total_duration / $processed_count : 0.0;
972 $estimated_next_duration = max($last_item_duration, $average_item_duration);
973 if($remaining_runtime <= $safety_buffer + $estimated_next_duration)
974 {
975 $stop_reason = 'runtime_budget';
976 break;
977 }
978
979 //260820.0056 A legacy hard cap may make the final SQL chunk smaller, but otherwise query size and processing capacity remain independent.
980 $query_limit = $chunk_size;
981 if($hard_cap !== NULL)
982 $query_limit = min($query_limit, max(0, $hard_cap - $processed_count));
983 if($query_limit < 1)
984 {
985 $stop_reason = 'legacy_item_cap';
986 break;
987 }
988
989 //260820.0056 Query only due EOT metadata, oldest timestamp first; `umeta_id` makes equal timestamps deterministic and provides cursor pagination without OFFSET.
990 $now = time();
991 $sql = "SELECT `umeta_id`, `user_id` AS `ID`, CAST(`meta_value` AS UNSIGNED) AS `auto_eot_time` FROM `".$wpdb->usermeta."` WHERE `meta_key` = %s AND CAST(`meta_value` AS UNSIGNED) > 0 AND CAST(`meta_value` AS UNSIGNED) <= %d";
992 $sql_args = array($meta_key, $now);
993
994 //260820.0056 Continue strictly after the previous timestamp/umeta_id pair, avoiding increasingly expensive SQL OFFSET pagination.
995 if($cursor_time || $cursor_umeta_id)
996 {
997 $sql .= " AND (CAST(`meta_value` AS UNSIGNED) > %d OR (CAST(`meta_value` AS UNSIGNED) = %d AND `umeta_id` > %d))";
998 $sql_args[] = $cursor_time;
999 $sql_args[] = $cursor_time;
1000 $sql_args[] = $cursor_umeta_id;
1001 }
1002 $sql .= " ORDER BY CAST(`meta_value` AS UNSIGNED) ASC, `umeta_id` ASC LIMIT ".(int)$query_limit;
1003 $eots = $wpdb->get_results($wpdb->prepare($sql, $sql_args));
1004
1005 if(!is_array($eots) || !$eots)
1006 break;
1007
1008 foreach($eots as $eot) // Oldest overdue EOT first; equal timestamps are deterministic by `umeta_id`.
1009 {
1010 $cursor_time = (int)$eot->auto_eot_time;
1011 $cursor_umeta_id = (int)$eot->umeta_id;
1012
1013 //260820.0056 Recheck both stopping conditions inside the chunk because each user's hooks/notifications can materially change elapsed time.
1014 if($hard_cap !== NULL && $processed_count >= $hard_cap)
1015 {
1016 $stop_reason = 'legacy_item_cap';
1017 break 2;
1018 }
1019 $remaining_runtime = $deadline - microtime(TRUE);
1020 $average_item_duration = $processed_count ? $item_total_duration / $processed_count : 0.0;
1021 $estimated_next_duration = max($last_item_duration, $average_item_duration);
1022 if($remaining_runtime <= $safety_buffer + $estimated_next_duration)
1023 {
1024 $stop_reason = 'runtime_budget';
1025 break 2;
1026 }
1027
1028 //260820.0056 Re-read only the exact selected row immediately before destructive work; skip it if its EOT was changed/deleted after selection.
1029 $current_eot = $wpdb->get_row($wpdb->prepare("SELECT `user_id`, `meta_key`, `meta_value` FROM `".$wpdb->usermeta."` WHERE `umeta_id` = %d LIMIT 1", $cursor_umeta_id));
1030 if(!$current_eot || (int)$current_eot->user_id !== (int)$eot->ID || (string)$current_eot->meta_key !== $meta_key || (int)$current_eot->meta_value !== $cursor_time || (int)$current_eot->meta_value <= 0 || (int)$current_eot->meta_value > time())
1031 continue;
1032
1033 //260820.0056 Time the complete per-user EOT operation, including hooks/notifications, because extension work may dominate the actual cost.
1034 $item_started = microtime(TRUE);
1035 $user_id = (int)$eot->ID;
1036 $auto_eot_time = (int)$current_eot->meta_value;
1037 if($user_id && is_object($user = new WP_User ($user_id)) && $user->ID)
1038 {
1039 $log_entry = array('user' => (array)$user); // Intialize.
1040 $log_entry['auto_eot_time'] = $auto_eot_time; // Record EOT time.
1041
1042 //260414 Keep a minimal pre-demotion subscription snapshot in the log so we can tell later
1043 // whether this member still had subscription metadata before anything was cleared.
1044 $log_entry['subscr_gateway'] = get_user_option('s2member_subscr_gateway', $user_id);
1045 $log_entry['subscr_id'] = get_user_option('s2member_subscr_id', $user_id);
1046 $log_entry['has_ipn_signup_vars'] = is_array(get_user_option('s2member_ipn_signup_vars', $user_id)) ? 'yes' : 'no';
1047
1048 //260414 Defense in depth. A bad stored value of `0` caused false demotions in the wild.
1049 // If one still reaches this loop for any reason, log it and skip instead of clearing fields.
1050 if($auto_eot_time <= 0)
1051 {
1052 $log_entry['auto_eot_skip_reason'] = 'Skipped. Stored `s2member_auto_eot_time` was <= 0.';
1053 c_ws_plugin__s2member_utils_logs::log_entry('auto-eot-system', $log_entry);
1054 continue;
1055 }
1056
1057 //260821.0626 `s2member_auto_eot_details` and `s2member_last_auto_eot_details` share the provenance format
1058 // `array('time' => EOT Unix timestamp, 'source' => string, 'updated_at' => Unix timestamp)`. `time` must
1059 // exactly match the corresponding current/archived EOT; otherwise the details are stale and ignored.
1060 // `source` currently uses `refund_reversal` for payment exceptions that must not be treated as renewal opportunities.
1061 $auto_eot_details = get_user_option('s2member_auto_eot_details', $user_id);
1062 if(!is_array($auto_eot_details) || empty($auto_eot_details['time']) || (int)$auto_eot_details['time'] !== $auto_eot_time)
1063 $auto_eot_details = array();
1064
1065 delete_user_option($user_id, 's2member_last_auto_eot_time');
1066 delete_user_option($user_id, 's2member_last_auto_eot_details');
1067 delete_user_option($user_id, 's2member_auto_eot_time');
1068 delete_user_option($user_id, 's2member_auto_eot_details');
1069
1070 if(!$user->has_cap('administrator') /* Do NOT process Administrator accounts. */)
1071 {
1072 if($GLOBALS['WS_PLUGIN__']['s2member']['o']['membership_eot_behavior'] === 'demote')
1073 {
1074 $eot_del_type = 'auto-eot-cancellation-expiration-demotion'; // Set EOT/Del type.
1075 $log_entry['eot_del_type'] = $eot_del_type; // Deleting user in this case.
1076
1077 $custom = get_user_option('s2member_custom', $user_id);
1078 $subscr_gateway = get_user_option('s2member_subscr_gateway', $user_id);
1079 $subscr_id = get_user_option('s2member_subscr_id', $user_id);
1080 $subscr_baid = get_user_option('s2member_subscr_baid', $user_id);
1081 $subscr_cid = get_user_option('s2member_subscr_cid', $user_id);
1082 $fields = get_user_option('s2member_custom_fields', $user_id);
1083 $user_reg_ip = get_user_option('s2member_registration_ip', $user_id);
1084 $ipn_signup_vars = get_user_option('s2member_ipn_signup_vars', $user_id);
1085
1086 $demotion_role = c_ws_plugin__s2member_option_forces::force_demotion_role('subscriber');
1087 $existing_role = c_ws_plugin__s2member_user_access::user_access_role($user);
1088 $removed_ccaps = array();
1089
1090 foreach(array_keys(get_defined_vars()) as $__v) $__refs[$__v] =& $$__v;
1091 do_action('ws_plugin__s2member_during_auto_eot_system_during_before_demote', get_defined_vars());
1092 do_action('ws_plugin__s2member_during_collective_mods', $user_id, get_defined_vars(), $eot_del_type, 'modification', $demotion_role);
1093 do_action('ws_plugin__s2member_during_collective_eots', $user_id, get_defined_vars(), $eot_del_type, 'modification');
1094 unset($__refs, $__v); // Housekeeping.
1095
1096 if($existing_role !== $demotion_role /* Only if NOT the existing Role. */)
1097 $user->set_role($demotion_role /* Give User the demotion Role. */);
1098
1099 if(apply_filters('ws_plugin__s2member_remove_ccaps_during_eot_events', (bool)$GLOBALS['WS_PLUGIN__']['s2member']['o']['eots_remove_ccaps'], get_defined_vars()))
1100 foreach($user->allcaps as $cap => $cap_enabled)
1101 if(preg_match('/^access_s2member_ccap_/', $cap))
1102 {
1103 $removed_ccaps[] = preg_replace('/^access_s2member_ccap_/', '', $cap);
1104 $user->remove_cap($ccap = $cap);
1105 }
1106
1107 delete_user_option($user_id, 's2member_subscr_gateway');
1108 delete_user_option($user_id, 's2member_subscr_id');
1109 delete_user_option($user_id, 's2member_subscr_baid');
1110 delete_user_option($user_id, 's2member_subscr_cid');
1111
1112 delete_user_option($user_id, 's2member_ipn_signup_vars');
1113 if(!apply_filters('ws_plugin__s2member_preserve_paid_registration_times', TRUE))
1114 delete_user_option($user_id, 's2member_paid_registration_times');
1115
1116 delete_user_option($user_id, 's2member_last_status_scan');
1117 delete_user_option($user_id, 's2member_first_payment_txn_id');
1118 delete_user_option($user_id, 's2member_last_payment_time');
1119 delete_user_option($user_id, 's2member_last_auto_eot_time');
1120 delete_user_option($user_id, 's2member_last_auto_eot_details');
1121 delete_user_option($user_id, 's2member_auto_eot_time');
1122 delete_user_option($user_id, 's2member_auto_eot_details');
1123
1124 delete_user_option($user_id, 's2member_file_download_access_log');
1125 delete_user_option($user_id, 's2member_authnet_payment_failures');
1126
1127 $processed_at = time();
1128 update_user_option($user_id, 's2member_last_auto_eot_time', $auto_eot_time);
1129 //260821.0057 Preserve only matching provenance (e.g., refund/reversal) alongside the archived EOT.
1130 if($auto_eot_details)
1131 update_user_option($user_id, 's2member_last_auto_eot_details', $auto_eot_details);
1132
1133 //260822.0653 Record the triggering EOT separately from when this worker actually completed the demotion, using the pre-cleanup role/payment snapshot above.
1134 self::record_eot_history($user_id, array(
1135 'eot_time' => $auto_eot_time,
1136 'processed_at' => $processed_at,
1137 'original_role' => $existing_role,
1138 'destination_role' => $demotion_role,
1139 'removed_ccaps' => $removed_ccaps,
1140 'subscr_gateway' => $subscr_gateway,
1141 'subscr_id' => $subscr_id,
1142 ));
1143
1144 if($GLOBALS['WS_PLUGIN__']['s2member']['o']['eot_del_notification_urls'])
1145 {
1146 foreach(preg_split('/['."\r\n\t".']+/', $GLOBALS['WS_PLUGIN__']['s2member']['o']['eot_del_notification_urls']) as $url) // Handle EOT Notifications.
1147
1148 if(($url = c_ws_plugin__s2member_utils_strings::fill_cvs($url, $custom, true)) && ($url = preg_replace('/%%eot_del_type%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(urlencode('auto-eot-cancellation-expiration-demotion')), $url)) && ($url = preg_replace('/%%subscr_id%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(urlencode($subscr_id)), $url)))
1149 if(($url = preg_replace('/%%user_first_name%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(urlencode($user->first_name)), $url)) && ($url = preg_replace('/%%user_last_name%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(urlencode($user->last_name)), $url)))
1150 if(($url = preg_replace('/%%user_full_name%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(urlencode(trim($user->first_name.' '.$user->last_name))), $url)))
1151 if(($url = preg_replace('/%%user_email%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(urlencode($user->user_email)), $url)))
1152 if(($url = preg_replace('/%%user_login%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(urlencode($user->user_login)), $url)))
1153 if(($url = preg_replace('/%%user_ip%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(urlencode($user_reg_ip)), $url)))
1154 if(($url = preg_replace('/%%user_id%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(urlencode($user_id)), $url)))
1155 {
1156 if(is_array($fields) && !empty($fields))
1157 foreach($fields as $var => $val /* Custom Registration/Profile Fields. */)
1158 if(!($url = preg_replace('/%%'.preg_quote($var, '/').'%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(urlencode(maybe_serialize($val))), $url)))
1159 break;
1160
1161 if(($url = trim(preg_replace('/%%(.+?)%%/i', '', $url))))
1162 c_ws_plugin__s2member_utils_urls::remote($url);
1163 }
1164 }
1165 if($GLOBALS['WS_PLUGIN__']['s2member']['o']['eot_del_notification_recipients'])
1166 {
1167 $email_configs_were_on = c_ws_plugin__s2member_email_configs::email_config_status();
1168 c_ws_plugin__s2member_email_configs::email_config_release();
1169
1170 $msg = $sbj = '(s2Member / API Notification Email) - EOT/Deletion';
1171 $msg .= "\n\n"; // Spacing in the message body.
1172
1173 $msg .= 'eot_del_type: %%eot_del_type%%'."\n";
1174 $msg .= 'subscr_id: %%subscr_id%%'."\n";
1175 $msg .= 'subscr_baid: %%subscr_baid%%'."\n";
1176 $msg .= 'subscr_cid: %%subscr_cid%%'."\n";
1177 $msg .= 'user_first_name: %%user_first_name%%'."\n";
1178 $msg .= 'user_last_name: %%user_last_name%%'."\n";
1179 $msg .= 'user_full_name: %%user_full_name%%'."\n";
1180 $msg .= 'user_email: %%user_email%%'."\n";
1181 $msg .= 'user_login: %%user_login%%'."\n";
1182 $msg .= 'user_ip: %%user_ip%%'."\n";
1183 $msg .= 'user_id: %%user_id%%'."\n";
1184
1185 if(is_array($fields) && !empty($fields))
1186 foreach($fields as $var => $val)
1187 $msg .= $var.': %%'.$var.'%%'."\n";
1188
1189 $msg .= 'cv0: %%cv0%%'."\n";
1190 $msg .= 'cv1: %%cv1%%'."\n";
1191 $msg .= 'cv2: %%cv2%%'."\n";
1192 $msg .= 'cv3: %%cv3%%'."\n";
1193 $msg .= 'cv4: %%cv4%%'."\n";
1194 $msg .= 'cv5: %%cv5%%'."\n";
1195 $msg .= 'cv6: %%cv6%%'."\n";
1196 $msg .= 'cv7: %%cv7%%'."\n";
1197 $msg .= 'cv8: %%cv8%%'."\n";
1198 $msg .= 'cv9: %%cv9%%';
1199
1200 if(($msg = c_ws_plugin__s2member_utils_strings::fill_cvs($msg, $custom)) && ($msg = preg_replace('/%%eot_del_type%%/i', c_ws_plugin__s2member_utils_strings::esc_refs('auto-eot-cancellation-expiration-demotion'), $msg)) && ($msg = preg_replace('/%%subscr_id%%/i', c_ws_plugin__s2member_utils_strings::esc_refs($subscr_id), $msg)))
1201 if(($msg = preg_replace('/%%subscr_baid%%/i', c_ws_plugin__s2member_utils_strings::esc_refs($subscr_baid), $msg)) && ($msg = preg_replace('/%%subscr_cid%%/i', c_ws_plugin__s2member_utils_strings::esc_refs($subscr_cid), $msg)))
1202 if(($msg = preg_replace('/%%user_first_name%%/i', c_ws_plugin__s2member_utils_strings::esc_refs($user->first_name), $msg)) && ($msg = preg_replace('/%%user_last_name%%/i', c_ws_plugin__s2member_utils_strings::esc_refs($user->last_name), $msg)))
1203 if(($msg = preg_replace('/%%user_full_name%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(trim($user->first_name.' '.$user->last_name)), $msg)))
1204 if(($msg = preg_replace('/%%user_email%%/i', c_ws_plugin__s2member_utils_strings::esc_refs($user->user_email), $msg)))
1205 if(($msg = preg_replace('/%%user_login%%/i', c_ws_plugin__s2member_utils_strings::esc_refs($user->user_login), $msg)))
1206 if(($msg = preg_replace('/%%user_ip%%/i', c_ws_plugin__s2member_utils_strings::esc_refs($user_reg_ip), $msg)))
1207 if(($msg = preg_replace('/%%user_id%%/i', c_ws_plugin__s2member_utils_strings::esc_refs($user_id), $msg)))
1208 {
1209 if(is_array($fields) && !empty($fields))
1210 foreach($fields as $var => $val /* Custom Registration/Profile Fields. */)
1211 if(!($msg = preg_replace('/%%'.preg_quote($var, '/').'%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(maybe_serialize($val)), $msg)))
1212 break;
1213
1214 if($sbj && ($msg = trim(preg_replace('/%%(.+?)%%/i', '', $msg))) /* Still have a ``$sbj`` and a ``$msg``? */)
1215
1216 foreach(c_ws_plugin__s2member_utils_strings::parse_emails($GLOBALS['WS_PLUGIN__']['s2member']['o']['eot_del_notification_recipients']) as $recipient)
1217 wp_mail($recipient, apply_filters('ws_plugin__s2member_eot_del_notification_email_sbj', $sbj, get_defined_vars()), apply_filters('ws_plugin__s2member_eot_del_notification_email_msg', $msg, get_defined_vars()), 'Content-Type: text/plain; charset=UTF-8');
1218 }
1219 if($email_configs_were_on) c_ws_plugin__s2member_email_configs::email_config();
1220 }
1221 foreach(array_keys(get_defined_vars()) as $__v) $__refs[$__v] =& $$__v;
1222 do_action('ws_plugin__s2member_during_auto_eot_system_during_demote', get_defined_vars());
1223 unset($__refs, $__v); // Housekeeping.
1224 }
1225 else if($GLOBALS['WS_PLUGIN__']['s2member']['o']['membership_eot_behavior'] === 'delete')
1226 {
1227 $eot_del_type = 'auto-eot-cancellation-expiration-deletion';
1228 $log_entry['eot_del_type'] = $eot_del_type;
1229
1230 foreach(array_keys(get_defined_vars()) as $__v) $__refs[$__v] =& $$__v;
1231 do_action('ws_plugin__s2member_during_auto_eot_system_during_before_delete', get_defined_vars());
1232 do_action('ws_plugin__s2member_during_collective_eots', $user_id, get_defined_vars(), $eot_del_type, 'removal-deletion');
1233 unset($__refs, $__v); // Housekeeping.
1234
1235 //260822.0535 One operation now owns both safe Pending Deletion and the explicit developer opt-in for historical irreversible deletion.
1236 $eot_delete_action = self::process_eot_deletion($user_id, $eot_del_type, $auto_eot_time);
1237 $log_entry['eot_delete_action'] = $eot_delete_action;
1238
1239 foreach(array_keys(get_defined_vars()) as $__v) $__refs[$__v] =& $$__v;
1240 do_action('ws_plugin__s2member_during_auto_eot_system_during_delete', get_defined_vars());
1241 unset($__refs, $__v); // Housekeeping.
1242 }
1243 foreach(array_keys(get_defined_vars()) as $__v) $__refs[$__v] =& $$__v;
1244 do_action('ws_plugin__s2member_during_auto_eot_system', get_defined_vars());
1245 unset($__refs, $__v); // Housekeeping.
1246
1247 c_ws_plugin__s2member_utils_logs::log_entry('auto-eot-system', $log_entry);
1248 }
1249
1250 }
1251
1252 //260820.0056 Feed the completed item's wall-clock cost into this pass only; no timing average is persisted between runs.
1253 $last_item_duration = max(0, microtime(TRUE) - $item_started);
1254 $item_total_duration += $last_item_duration;
1255 $processed_count++;
1256
1257 //260820.0056 Refresh the lock periodically rather than per user, preserving useful crash evidence without creating unnecessary option writes.
1258 if($processed_count % 5 === 0 || microtime(TRUE) - $last_heartbeat >= 5)
1259 {
1260 $lock['heartbeat_at'] = time();
1261 $lock['processed'] = $processed_count;
1262 $lock['current_user_id'] = $user_id;
1263 update_option($lock_option, $lock, FALSE);
1264 $last_heartbeat = microtime(TRUE);
1265 }
1266 }
1267
1268 //260820.0056 A short chunk means the ordered query reached the end of the due rows visible during this pass; otherwise fetch the next cursor chunk.
1269 if(count($eots) < $query_limit)
1270 break;
1271 }
1272
1273 //260820.0149 One aggregate gives both catch-up state and the pending/oldest values needed by diagnostics.
1274 $run_runtime = max(0, microtime(TRUE) - $run_started);
1275 $pending = $wpdb->get_row($wpdb->prepare("SELECT COUNT(*) AS `pending_count`, MIN(CAST(`meta_value` AS UNSIGNED)) AS `oldest_due_at` FROM `".$wpdb->usermeta."` WHERE `meta_key` = %s AND CAST(`meta_value` AS UNSIGNED) > 0 AND CAST(`meta_value` AS UNSIGNED) <= %d", $meta_key, time()));
1276 $pending_count = ($pending && !empty($pending->pending_count)) ? (int)$pending->pending_count : 0;
1277 $oldest_due_at = ($pending && !empty($pending->oldest_due_at)) ? (int)$pending->oldest_due_at : 0;
1278 $more_due_work = $pending_count > 0;
1279
1280 //260820.0056 Preserve enough current-run timing information to explain when a legacy item cap, rather than runtime, unnecessarily constrained throughput.
1281 $average_item_duration = $processed_count ? $item_total_duration / $processed_count : 0.0;
1282 $estimated_next_duration = max($last_item_duration, $average_item_duration);
1283 $remaining_safe_runtime = max(0, ($deadline - microtime(TRUE)) - $safety_buffer);
1284 $legacy_cap_estimated_additional = ($stop_reason === 'legacy_item_cap' && $more_due_work && $estimated_next_duration > 0) ? (int)floor($remaining_safe_runtime / $estimated_next_duration) : 0;
1285
1286 //260820.0056 Save compact operational health for diagnostics/UI; these are run results, not persistent performance-learning values.
1287 $state = get_option($state_option);
1288 $state = is_array($state) ? $state : array();
1289 $state['last_completed_at'] = time();
1290 $state['last_runtime'] = $run_runtime;
1291 $state['last_runtime_budget'] = $runtime_budget;
1292 $state['last_processed'] = $processed_count;
1293 $state['last_stop_reason'] = $stop_reason;
1294 $state['last_hard_cap'] = $hard_cap;
1295 $state['last_hard_cap_source'] = $hard_cap_source;
1296 $state['last_more_due_work'] = $more_due_work ? 1 : 0;
1297 $state['last_pending_count'] = $pending_count;
1298 $state['last_oldest_due_at'] = $oldest_due_at;
1299 $state['last_oldest_overdue_seconds'] = $oldest_due_at ? max(0, time() - $oldest_due_at) : 0;
1300 $state['legacy_cap_estimated_additional'] = $legacy_cap_estimated_additional;
1301 $state['last_invocation'] = $is_continuation ? 'continuation' : (!empty($_GET['s2member_auto_eot_system_via_cron']) ? 'external_cron' : ((defined('DOING_CRON') && DOING_CRON) ? 'wp_cron' : 'direct'));
1302 if($state['last_invocation'] === 'external_cron')
1303 $state['last_external_completed_at'] = time();
1304 $state['consecutive_abandoned_runs'] = 0; //260820.0149 A clean completion breaks the abandoned-run sequence.
1305 $state['active_run_token'] = '';
1306
1307 update_option($state_option, $state, FALSE);
1308
1309 //260820.0056 Delete the lock only after state is safely recorded; if PHP dies earlier, the surviving lock is what lets a future pass detect the abandoned run.
1310 delete_option($lock_option);
1311
1312 //260820.0056 In WP-Cron mode, continue soon while overdue EOTs remain; external-cron installations already control their own invocation cadence.
1313 if((string)$GLOBALS['WS_PLUGIN__']['s2member']['o']['auto_eot_system_enabled'] === '1' && $more_due_work && ($hard_cap === NULL || $hard_cap > 0))
1314 {
1315 if(!wp_next_scheduled('ws_plugin__s2member_auto_eot_system__continuation'))
1316 wp_schedule_single_event(time() + 60, 'ws_plugin__s2member_auto_eot_system__continuation');
1317 }
1318 else if(!$more_due_work)
1319 wp_clear_scheduled_hook('ws_plugin__s2member_auto_eot_system__continuation');
1320
1321 delete_transient('ws_plugin__s2member_auto_eot_health'); //260820.0149 Run completion changes the health snapshot.
1322 }
1323 c_ws_plugin__s2member_utils_logs::cleanup_expired_s2m_transients();
1324
1325 //260820.0056 The historical collective after-hook runs only on normal passes; otherwise every one-minute catch-up pass would also multiply Pro reminders/gateway API polling.
1326 if(!$is_continuation)
1327 {
1328 foreach(array_keys(get_defined_vars()) as $__v) $__refs[$__v] =& $$__v;
1329 do_action('ws_plugin__s2member_after_auto_eot_system', get_defined_vars());
1330 unset($__refs, $__v); // Housekeeping.
1331 }
1332 else
1333 {
1334 //260820.0056 Continuations still repair the recurring Auto-EOT event directly because they deliberately skip the collective after-hook that normally performs this check.
1335 self::ensure_auto_eot_system();
1336 }
1337 }
1338 }
1339 }
1340