PluginProbe
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions / 261011
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions v261011
261011 261001 260927 260917 260913 260909 260829 260814 260805 110710 110731 110812 110815 110912 110913 110915 110926 110927 111002 111003 111011 111017 111029 111105 111206 All 192 releases
← All changes | src/includes/classes/auto-eots.inc.php +6 -1332 260913 → 261011 View file →
@@ -1,8 +1,8 @@
1 1 <?php
2 2 // @codingStandardsIgnoreFile
3 3 /**
4 - * s2Member's Auto-EOT System *(EOT = End Of Term)*.
4 + * Backward compatibility for s2Member's historical Auto-EOT class.
5 5 *
6 6 * Copyright: © 2009-2011
7 7 * {@link http://websharks-inc.com/ WebSharks, Inc.}
8 8 * (coded in the USA)
@@ -17,1336 +17,10 @@
17 17 */
18 18 if(!defined('WPINC')) // MUST have WordPress.
19 19 exit ('Do not access this file directly.');
20 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());
21 +//261010.0545 Preserve the historical Auto-EOT class/file for third-party integrations while the EOT implementation lives in eot.inc.php.
22 +if(!class_exists('c_ws_plugin__s2member_eot'))
23 + require_once dirname(__FILE__).'/eot.inc.php';
42 24
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 - {
352 - //260829.0618 Keep EOT audit notes consistent with the administrator's forced s2Member labels, while retaining concise Level N names when label translation is disabled.
353 - $_level = (int)$_matches[1];
354 - $_label_key = 'level'.$_level.'_label';
355 - $role_labels[$_role] = !empty($GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["apply_label_translations"]) && !empty($GLOBALS["WS_PLUGIN__"]["s2member"]["o"][$_label_key])
356 - ? $GLOBALS["WS_PLUGIN__"]["s2member"]["o"][$_label_key]
357 - : 'Level '.$_level;
358 - }
359 - else if($_role === 's2member_pending_deletion')
360 - $role_labels[$_role] = 'Pending Deletion';
361 - else if($_role && isset($wp_roles->roles[$_role]['name']))
362 - $role_labels[$_role] = translate_user_role($wp_roles->roles[$_role]['name']);
363 - else
364 - $role_labels[$_role] = $_role ? ucwords(str_replace(array('-', '_'), ' ', $_role)) : 'Unknown Role';
365 - }
366 - unset($_role, $_matches, $_level, $_label_key);
367 -
368 - $gateway_labels = array('paypal' => 'PayPal', 'authnet' => 'Authorize.Net', 'clickbank' => 'ClickBank', 'ccbill' => 'ccBill', 'alipay' => 'AliPay', 'google' => 'Google Wallet', 'stripe' => 'Stripe');
369 - $gateway_key = strtolower((string)$subscr_gateway);
370 - $gateway_label = isset($gateway_labels[$gateway_key]) ? $gateway_labels[$gateway_key] : ucwords(str_replace(array('-', '_'), ' ', $gateway_key));
371 - $removed_ccaps = array_values(array_unique(array_filter(array_map('strval', (array)$removed_ccaps), 'strlen')));
372 - sort($removed_ccaps, SORT_STRING);
373 -
374 - //260829.0618 Avoid recording a misleading role transition when EOT processing finds the user already in the configured demotion role.
375 - if($original_role === $destination_role)
376 - $note = $processed_display.' s2Member: EOT processed, already '.$role_labels[(string)$original_role];
377 - else
378 - $note = $processed_display.' s2Member: Demoted from '.$role_labels[(string)$original_role].' to '.$role_labels[(string)$destination_role];
379 - if($removed_ccaps)
380 - $note .= ' (removed ccaps: '.implode(', ', $removed_ccaps).')';
381 - $note .= '.';
382 - if($subscr_gateway && $subscr_id)
383 - $note .= ' '.$gateway_label.' '.$subscr_id.'.';
384 - $note .= ' EOT '.$eot_display.'.';
385 -
386 - //260822.0653 Keep the action timestamp independent from the triggering EOT timestamp; delayed processing can make these materially different.
387 - update_user_option($user_id, 's2member_last_auto_eot_processed_time', $processed_at);
388 - c_ws_plugin__s2member_user_notes::append_user_notes($user_id, $note);
389 - }
390 -
391 - /**
392 - * Starts a best-effort upgrade backfill of historical EOT processing times.
393 - *
394 - * @package s2Member\Auto_EOT_System
395 - * @since 260822.2048
396 - *
397 - * @return null
398 - */
399 - public static function start_eot_processed_time_backfill()
400 - {
401 - $state_option = 'ws_plugin__s2member_auto_eot_state';
402 - $state = get_option($state_option);
403 - $state = is_array($state) ? $state : array();
404 -
405 - //260822.2048 Reuse Auto-EOT's operational state for this temporary migration cursor; no separate migration option or table is needed.
406 - if(!array_key_exists('processed_time_backfill_cursor_umeta_id', $state))
407 - {
408 - $state['processed_time_backfill_cursor_umeta_id'] = 0;
409 - update_option($state_option, $state, FALSE);
410 - }
411 - self::ensure_eot_processed_time_backfill();
412 - }
413 -
414 - /**
415 - * Ensures that an unfinished historical EOT processing-time backfill has a continuation event.
416 - *
417 - * @package s2Member\Auto_EOT_System
418 - * @since 260822.2048
419 - *
420 - * @return null
421 - */
422 - public static function ensure_eot_processed_time_backfill()
423 - {
424 - $state = get_option('ws_plugin__s2member_auto_eot_state');
425 - $hook = 'ws_plugin__s2member_eot_processed_time_backfill';
426 -
427 - if(is_array($state) && array_key_exists('processed_time_backfill_cursor_umeta_id', $state) && !wp_next_scheduled($hook))
428 - wp_schedule_single_event(time() + 5, $hook);
429 - }
430 -
431 - /**
432 - * Backfills EOT processing times that can be recovered from legacy Administrative Notes.
433 - *
434 - * @package s2Member\Auto_EOT_System
435 - * @since 260822.2048
436 - *
437 - * @return null
438 - */
439 - public static function backfill_eot_processed_times()
440 - {
441 - global $wpdb;
442 -
443 - $state_option = 'ws_plugin__s2member_auto_eot_state';
444 - $state = get_option($state_option);
445 - $state = is_array($state) ? $state : array();
446 - if(!array_key_exists('processed_time_backfill_cursor_umeta_id', $state))
447 - return;
448 -
449 - $cursor_umeta_id = (int)$state['processed_time_backfill_cursor_umeta_id'];
450 - $last_key = $wpdb->prefix.'s2member_last_auto_eot_time';
451 - $processed_key = $wpdb->prefix.'s2member_last_auto_eot_processed_time';
452 - $notes_key = $wpdb->prefix.'s2member_notes';
453 - $rows = $wpdb->get_results($wpdb->prepare(
454 - "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",
455 - $notes_key, $processed_key, $last_key, $cursor_umeta_id, '%Demoted by s2Member:%'
456 - ));
457 - $rows = is_array($rows) ? $rows : array();
458 -
459 - foreach($rows as $row)
460 - {
461 - $cursor_umeta_id = (int)$row->umeta_id;
462 - $lines = preg_split('/\r\n|\r|\n/', (string)$row->notes);
463 - foreach(array_reverse((array)$lines) as $line)
464 - if(preg_match('/^Demoted by s2Member:\s*(.+)$/', trim($line), $matches))
465 - {
466 - $processed_at = strtotime($matches[1]);
467 - //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.
468 - if($processed_at && $processed_at + MINUTE_IN_SECONDS >= (int)$row->eot_time)
469 - {
470 - $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));
471 - $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));
472 - //260822.2259 Revalidate before writing legacy history; a newly processed EOT always wins over this best-effort upgrade backfill.
473 - if($current_last_eot !== NULL && (int)$current_last_eot === (int)$row->eot_time && !$processed_exists)
474 - add_user_meta((int)$row->user_id, $processed_key, $processed_at, TRUE);
475 - break;
476 - }
477 - }
478 - }
479 - unset($row, $lines, $line, $matches, $processed_at);
480 -
481 - $state = get_option($state_option);
482 - $state = is_array($state) ? $state : array();
483 - if(count($rows) === 100)
484 - $state['processed_time_backfill_cursor_umeta_id'] = $cursor_umeta_id;
485 - else
486 - unset($state['processed_time_backfill_cursor_umeta_id']);
487 - update_option($state_option, $state, FALSE);
488 -
489 - if(count($rows) === 100)
490 - self::ensure_eot_processed_time_backfill();
491 - }
492 -
493 - /**
494 - * Applies the effective `delete` End-of-Term behavior.
495 - *
496 - * @package s2Member\Auto_EOT_System
497 - * @since 260822.0535
498 - *
499 - * @param int $user_id WordPress user ID being processed.
500 - * @param string $eot_del_type EOT/deletion event type.
501 - * @param int $eot_time Unix timestamp that triggered this End-of-Term action.
502 - *
503 - * @return string `pending_deletion`, `deleted`, `removed`, or an empty string when no user was processed.
504 - */
505 - public static function process_eot_deletion($user_id = 0, $eot_del_type = '', $eot_time = 0)
506 - {
507 - $user_id = (int)$user_id;
508 - $eot_time = (int)$eot_time;
509 - if(!$user_id || !is_object($user = new WP_User($user_id)) || !$user->ID)
510 - return '';
511 -
512 - if(self::allow_eot_user_deletion($user_id, $eot_del_type))
513 - {
514 - //260822.0535 True deletion is deliberately opt-in; preserve the historical deletion/removal path only after the developer filter explicitly allows it.
515 - $GLOBALS['ws_plugin__s2member_eot_del_type'] = (string)$eot_del_type;
516 - if(is_multisite())
517 - {
518 - $blog_id = get_current_blog_id();
519 - remove_user_from_blog($user_id, $blog_id);
520 - c_ws_plugin__s2member_user_deletions::handle_ms_user_deletions($user_id, $blog_id, 's2says');
521 - return 'removed';
522 - }
523 - include_once ABSPATH.'wp-admin/includes/admin.php';
524 - wp_delete_user($user_id);
525 - return 'deleted';
526 - }
527 -
528 - $pending_role = 's2member_pending_deletion';
529 - $pending_meta = get_user_option('s2member_eot_pending_deletion', $user_id);
530 - $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']);
531 - $original_role = $already_pending ? (string)$pending_meta['original_role'] : c_ws_plugin__s2member_user_access::user_access_role($user);
532 - $processed_at = time();
533 - $removed_ccaps = $already_pending ? array() : c_ws_plugin__s2member_user_access::user_access_ccaps($user);
534 - $subscr_gateway = $already_pending ? '' : get_user_option('s2member_subscr_gateway', $user_id);
535 - $subscr_id = $already_pending ? '' : get_user_option('s2member_subscr_id', $user_id);
536 -
537 - //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.
538 - if(!$already_pending)
539 - update_user_option($user_id, 's2member_eot_pending_deletion', array(
540 - 'eot_time' => $eot_time ?: $processed_at,
541 - 'processed_at' => $processed_at,
542 - 'original_role' => $original_role,
543 - ));
544 - delete_user_option($user_id, 's2member_auto_eot_time');
545 - delete_user_option($user_id, 's2member_auto_eot_details');
546 -
547 - //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.
548 - if(!get_role($pending_role))
549 - add_role($pending_role, 'Pending Deletion', array('read' => TRUE));
550 - if(!in_array($pending_role, (array)$user->roles, TRUE))
551 - $user->set_role($pending_role);
552 -
553 - //260822.0535 Pending Deletion must never retain user-specific s2Member Level or Custom Capability grants after the role change.
554 - foreach($user->allcaps as $cap => $cap_enabled)
555 - if($cap_enabled && preg_match('/^access_s2member_(?:level[0-9]+|ccap_)/', $cap))
556 - $user->remove_cap($cap);
557 -
558 - if(!$already_pending)
559 - {
560 - //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.
561 - update_user_option($user_id, 's2member_last_auto_eot_time', $eot_time ?: $processed_at);
562 - self::record_eot_history($user_id, array(
563 - 'eot_time' => $eot_time ?: $processed_at,
564 - 'processed_at' => $processed_at,
565 - 'original_role' => $original_role,
566 - 'destination_role' => $pending_role,
567 - 'removed_ccaps' => $removed_ccaps,
568 - 'subscr_gateway' => $subscr_gateway,
569 - 'subscr_id' => $subscr_id,
570 - ));
571 - //260822.0535 A preserved account never reaches WordPress' deletion hook, so send the configured EOT/Deletion notifications explicitly instead of silently dropping them.
572 - self::pending_deletion_notifications($user_id, $eot_del_type);
573 - }
574 -
575 - return 'pending_deletion';
576 - }
577 -
578 - /**
579 - * Sends configured EOT/Deletion notifications for an account preserved in Pending Deletion.
580 - *
581 - * @package s2Member\Auto_EOT_System
582 - * @since 260822.0535
583 - *
584 - * @param int $user_id WordPress user ID being preserved.
585 - * @param string $eot_del_type EOT/deletion event type.
586 - *
587 - * @return null
588 - */
589 - public static function pending_deletion_notifications($user_id = 0, $eot_del_type = '')
590 - {
591 - $user_id = (int)$user_id;
592 - if(!$user_id || !is_object($user = new WP_User($user_id)) || !$user->ID)
593 - return;
594 -
595 - $custom = get_user_option('s2member_custom', $user_id);
596 - $subscr_id = get_user_option('s2member_subscr_id', $user_id);
597 - $subscr_baid = get_user_option('s2member_subscr_baid', $user_id);
598 - $subscr_cid = get_user_option('s2member_subscr_cid', $user_id);
599 - $fields = get_user_option('s2member_custom_fields', $user_id);
600 - $user_reg_ip = get_user_option('s2member_registration_ip', $user_id);
601 -
602 - if($GLOBALS['WS_PLUGIN__']['s2member']['o']['eot_del_notification_urls'])
603 - {
604 - foreach(preg_split("/[\r\n\t]+/", $GLOBALS['WS_PLUGIN__']['s2member']['o']['eot_del_notification_urls']) as $url)
605 - 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)))
606 - 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)))
607 - 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)))
608 - 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)))
609 - if(($url = preg_replace('/%%user_email%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(urlencode($user->user_email)), $url)))
610 - if(($url = preg_replace('/%%user_login%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(urlencode($user->user_login)), $url)))
611 - if(($url = preg_replace('/%%user_ip%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(urlencode($user_reg_ip)), $url)))
612 - if(($url = preg_replace('/%%user_id%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(urlencode($user_id)), $url)))
613 - {
614 - if(is_array($fields) && !empty($fields))
615 - foreach($fields as $var => $val)
616 - if(!($url = preg_replace('/%%'.preg_quote($var, '/').'%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(urlencode(maybe_serialize($val))), $url)))
617 - break;
618 -
619 - if(($url = trim(preg_replace('/%%(.+?)%%/i', '', $url))))
620 - c_ws_plugin__s2member_utils_urls::remote($url);
621 - }
622 - }
623 - if($GLOBALS['WS_PLUGIN__']['s2member']['o']['eot_del_notification_recipients'])
624 - {
625 - $email_configs_were_on = c_ws_plugin__s2member_email_configs::email_config_status();
626 - c_ws_plugin__s2member_email_configs::email_config_release();
627 -
628 - $msg = $sbj = '(s2Member / API Notification Email) - EOT/Deletion';
629 - $msg .= "\n\n";
630 -
631 - $msg .= 'eot_del_type: %%eot_del_type%%'."\n";
632 - $msg .= 'subscr_id: %%subscr_id%%'."\n";
633 - $msg .= 'subscr_baid: %%subscr_baid%%'."\n";
634 - $msg .= 'subscr_cid: %%subscr_cid%%'."\n";
635 - $msg .= 'user_first_name: %%user_first_name%%'."\n";
636 - $msg .= 'user_last_name: %%user_last_name%%'."\n";
637 - $msg .= 'user_full_name: %%user_full_name%%'."\n";
638 - $msg .= 'user_email: %%user_email%%'."\n";
639 - $msg .= 'user_login: %%user_login%%'."\n";
640 - $msg .= 'user_ip: %%user_ip%%'."\n";
641 - $msg .= 'user_id: %%user_id%%'."\n";
642 -
643 - if(is_array($fields) && !empty($fields))
644 - foreach($fields as $var => $val)
645 - $msg .= $var.': %%'.$var.'%%'."\n";
646 -
647 - $msg .= 'cv0: %%cv0%%'."\n";
648 - $msg .= 'cv1: %%cv1%%'."\n";
649 - $msg .= 'cv2: %%cv2%%'."\n";
650 - $msg .= 'cv3: %%cv3%%'."\n";
651 - $msg .= 'cv4: %%cv4%%'."\n";
652 - $msg .= 'cv5: %%cv5%%'."\n";
653 - $msg .= 'cv6: %%cv6%%'."\n";
654 - $msg .= 'cv7: %%cv7%%'."\n";
655 - $msg .= 'cv8: %%cv8%%'."\n";
656 - $msg .= 'cv9: %%cv9%%';
657 -
658 - 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)))
659 - 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)))
660 - 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)))
661 - if(($msg = preg_replace('/%%user_full_name%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(trim($user->first_name.' '.$user->last_name)), $msg)))
662 - if(($msg = preg_replace('/%%user_email%%/i', c_ws_plugin__s2member_utils_strings::esc_refs($user->user_email), $msg)))
663 - if(($msg = preg_replace('/%%user_login%%/i', c_ws_plugin__s2member_utils_strings::esc_refs($user->user_login), $msg)))
664 - if(($msg = preg_replace('/%%user_ip%%/i', c_ws_plugin__s2member_utils_strings::esc_refs($user_reg_ip), $msg)))
665 - if(($msg = preg_replace('/%%user_id%%/i', c_ws_plugin__s2member_utils_strings::esc_refs($user_id), $msg)))
666 - {
667 - if(is_array($fields) && !empty($fields))
668 - foreach($fields as $var => $val)
669 - if(!($msg = preg_replace('/%%'.preg_quote($var, '/').'%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(maybe_serialize($val)), $msg)))
670 - break;
671 -
672 - if($sbj && ($msg = trim(preg_replace('/%%(.+?)%%/i', '', $msg))))
673 - foreach(c_ws_plugin__s2member_utils_strings::parse_emails($GLOBALS['WS_PLUGIN__']['s2member']['o']['eot_del_notification_recipients']) as $recipient)
674 - 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');
675 - }
676 - if($email_configs_were_on)
677 - c_ws_plugin__s2member_email_configs::email_config();
678 - }
679 - }
680 -
681 - /**
682 - * Returns a cached health snapshot for the Auto-EOT system.
683 - *
684 - * @package s2Member\Auto_EOT_System
685 - * @since 260820.0149
686 - *
687 - * @param bool $force_refresh Force a fresh usermeta/schedule check.
688 - *
689 - * @return array Auto-EOT health information for diagnostics and UI.
690 - */
691 - public static function auto_eot_system_health($force_refresh = FALSE)
692 - {
693 - global $wpdb;
694 - /** @var $wpdb \wpdb */
695 -
696 - $cache_key = 'ws_plugin__s2member_auto_eot_health';
697 - if(!$force_refresh && is_array($health = get_transient($cache_key)))
698 - return $health;
699 -
700 - $now = time();
701 - $mode = (string)$GLOBALS['WS_PLUGIN__']['s2member']['o']['auto_eot_system_enabled'];
702 - $state = get_option('ws_plugin__s2member_auto_eot_state');
703 - $state = is_array($state) ? $state : array();
704 - $lock = get_option('ws_plugin__s2member_auto_eot_lock');
705 - $lock = is_array($lock) ? $lock : array();
706 - $meta_key = $wpdb->prefix.'s2member_auto_eot_time';
707 -
708 - //260820.0149 One exact-meta-key aggregate supplies both pending volume and oldest overdue age without loading EOT rows into PHP.
709 - $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));
710 - $pending_count = ($pending && !empty($pending->pending_count)) ? (int)$pending->pending_count : 0;
711 - $oldest_due_at = ($pending && !empty($pending->oldest_due_at)) ? (int)$pending->oldest_due_at : 0;
712 - $oldest_overdue_seconds = $oldest_due_at ? max(0, $now - $oldest_due_at) : 0;
713 -
714 - $recurring_at = ($mode === '1' && function_exists('wp_cron')) ? wp_next_scheduled('ws_plugin__s2member_auto_eot_system__schedule') : FALSE;
715 - $continuation_at = ($mode === '1' && function_exists('wp_cron')) ? wp_next_scheduled('ws_plugin__s2member_auto_eot_system__continuation') : FALSE;
716 - $issues = array();
717 - $critical = FALSE;
718 - $last_completed_at = !empty($state['last_completed_at']) ? (int)$state['last_completed_at'] : 0;
719 - $last_processed = isset($state['last_processed']) ? (int)$state['last_processed'] : 0;
720 - $last_more_due_work = !empty($state['last_more_due_work']);
721 - $runtime_budget = self::auto_eot_system_runtime_budget($mode === '2');
722 - $lock_stale_after = max(120, (int)ceil(($runtime_budget * 2) + 30));
723 - //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.
724 - $is_running = !empty($lock['heartbeat_at']) && $now - (int)$lock['heartbeat_at'] <= $lock_stale_after;
725 - $catchup_fresh_after = ($mode === '2') ? 2 * HOUR_IN_SECONDS : 30 * MINUTE_IN_SECONDS;
726 - //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.
727 - $catching_up = $pending_count && $last_more_due_work && $last_processed > 0 && $last_completed_at && $now - $last_completed_at < $catchup_fresh_after;
728 -
729 - //260820.0149 Escalate scheduler failures independently of pending EOTs so a broken cron can be noticed before months of expirations accumulate.
730 - if($mode === '1')
731 - {
732 - if(!function_exists('wp_cron') || !$recurring_at)
733 - $issues['cron_missing'] = $critical = TRUE;
734 - else if((int)$recurring_at < $now - HOUR_IN_SECONDS)
735 - //260908.2031 An overdue WP-Cron event can be normal on a quiet site; keep it visible as Attention, while missing cron or an actual overdue EOT backlog remain critical.
736 - $issues['cron_overdue'] = TRUE;
737 - }
738 - else if($mode === '2' && !empty($state['last_external_completed_at']) && $now - (int)$state['last_external_completed_at'] >= 2 * HOUR_IN_SECONDS)
739 - $issues['external_cron_stale'] = $critical = TRUE;
740 -
741 - if(($mode === '1' || $mode === '2') && $pending_count)
742 - {
743 - if($catching_up)
744 - $issues['catching_up'] = TRUE;
745 - else if($oldest_overdue_seconds >= 2 * HOUR_IN_SECONDS)
746 - $issues['eot_overdue'] = $critical = TRUE;
747 - else if($oldest_overdue_seconds >= 30 * MINUTE_IN_SECONDS)
748 - $issues['eot_delayed'] = TRUE;
749 - }
750 -
751 - $consecutive_abandoned = !empty($state['consecutive_abandoned_runs']) ? (int)$state['consecutive_abandoned_runs'] : 0;
752 - if(($mode === '1' || $mode === '2') && $consecutive_abandoned >= 2)
753 - $issues['repeated_abandoned'] = $critical = TRUE;
754 - else if(($mode === '1' || $mode === '2') && $consecutive_abandoned === 1)
755 - $issues['abandoned'] = TRUE;
756 -
757 - $health = array(
758 - 'generated_at' => $now,
759 - 'mode' => $mode,
760 - 'status' => !$mode ? 'disabled' : ($critical ? 'error' : ($is_running ? 'processing' : (isset($issues['catching_up']) && count($issues) === 1 ? 'catching_up' : ($issues ? 'attention' : 'healthy')))),
761 - 'needs_admin_notice' => $critical ? 1 : 0,
762 - 'issues' => array_keys($issues),
763 - 'pending_count' => $pending_count,
764 - 'oldest_due_at' => $oldest_due_at,
765 - 'oldest_overdue_seconds' => $oldest_overdue_seconds,
766 - 'recurring_at' => $recurring_at ? (int)$recurring_at : 0,
767 - 'continuation_at' => $continuation_at ? (int)$continuation_at : 0,
768 - 'is_running' => $is_running ? 1 : 0,
769 - 'last_started_at' => !empty($state['last_started_at']) ? (int)$state['last_started_at'] : 0,
770 - 'last_completed_at' => $last_completed_at,
771 - 'last_runtime' => isset($state['last_runtime']) ? (float)$state['last_runtime'] : 0.0,
772 - 'last_processed' => $last_processed,
773 - 'last_more_due_work' => $last_more_due_work ? 1 : 0,
774 - 'last_stop_reason' => !empty($state['last_stop_reason']) ? (string)$state['last_stop_reason'] : '',
775 - 'last_abandoned_at' => !empty($state['last_abandoned_at']) ? (int)$state['last_abandoned_at'] : 0,
776 - 'consecutive_abandoned_runs' => $consecutive_abandoned,
777 - 'last_schedule_failure_at' => !empty($state['last_schedule_failure_at']) ? (int)$state['last_schedule_failure_at'] : 0,
778 - 'schedule_failure_count' => !empty($state['schedule_failure_count']) ? (int)$state['schedule_failure_count'] : 0,
779 - 'last_external_completed_at' => !empty($state['last_external_completed_at']) ? (int)$state['last_external_completed_at'] : 0,
780 - );
781 - $health = apply_filters('ws_plugin__s2member_auto_eot_system_health', $health, get_defined_vars());
782 -
783 - //260820.0149 Cache the admin-facing aggregate briefly; processing itself never relies on this snapshot.
784 - set_transient($cache_key, $health, 5 * MINUTE_IN_SECONDS);
785 -
786 - return $health;
787 - }
788 -
789 - /**
790 - * Displays a site-wide administrative warning when Auto-EOT health becomes materially unsafe.
791 - *
792 - * @package s2Member\Auto_EOT_System
793 - * @since 260820.0149
794 - *
795 - * @return null
796 - */
797 - public static function auto_eot_system_admin_notice()
798 - {
799 - if(!is_admin() || !current_user_can('manage_options'))
800 - return;
801 -
802 - $health = self::auto_eot_system_health();
803 - if(empty($health['needs_admin_notice']))
804 - return;
805 -
806 - $reasons = array();
807 - if(in_array('cron_missing', $health['issues'], TRUE))
808 - $reasons[] = 'The recurring WP-Cron event is missing and s2Member could not restore it.';
809 - if(in_array('cron_overdue', $health['issues'], TRUE))
810 - $reasons[] = 'The recurring WP-Cron event is more than an hour overdue.';
811 - if(in_array('external_cron_stale', $health['issues'], TRUE))
812 - $reasons[] = 'The configured external cron has not completed an Auto-EOT pass in more than two hours.';
813 - if(in_array('eot_overdue', $health['issues'], TRUE))
814 - $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()).'.';
815 - if(in_array('repeated_abandoned', $health['issues'], TRUE))
816 - $reasons[] = number_format_i18n($health['consecutive_abandoned_runs']).' consecutive Automatic End-of-Term workers ended without reaching normal completion.';
817 -
818 - //260908.2031 Open the collapsed EOT panel before scrolling to its setting; a hash alone targets a hidden control.
819 - $settings_url = admin_url('/admin.php?page=ws-plugin--s2member-paypal-ops&s2member-open-panel=auto-eot').'#ws-plugin--s2member-auto-eot-system-enabled';
820 - $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>.';
821 - c_ws_plugin__s2member_admin_notices::display_admin_notice($notice, TRUE);
822 - }
823 -
824 - /**
825 - * Runs an Auto-EOT catch-up continuation.
826 - *
827 - * Catch-up passes drain overdue EOTs promptly while remaining separate from the historical
828 - * collective after-hook, so Pro reminder/gateway polling is not multiplied during catch-up.
829 - *
830 - * @package s2Member\Auto_EOT_System
831 - * @since 260820.0056
832 - *
833 - * @return null
834 - */
835 - public static function auto_eot_system_continuation()
836 - {
837 - self::auto_eot_system(10, TRUE);
838 - }
839 -
840 -
841 - /**
842 - * Processed by WP_Cron; this handles Auto-EOTs *(EOT = End Of Term)*.
843 - *
844 - * Normal processing is runtime-adaptive. The historical `$per_process` argument/filter remains
845 - * available as a legacy hard item cap when a caller supplies it explicitly or a filter is attached.
846 - *
847 - * This function makes an important Hook available: `ws_plugin__s2member_after_auto_eot_system`.
848 - * This Hook is used by some of s2Member Pro's Gateway integrations; allowing CRON processing
849 - * to run for important communications; which poll Payment Gateway APIs for possible EOTs.
850 - * Internal catch-up continuations intentionally do not fire that collective after-hook.
851 - *
852 - * 260821.0626 `ws_plugin__s2member_auto_eot_lock` is a short-lived non-autoloaded option containing
853 - * `token`, `started_at`, `heartbeat_at`, `processed`, and `current_user_id`. Timestamps are Unix timestamps;
854 - * counters/IDs are integers. A surviving stale lock is evidence that a worker did not reach normal cleanup.
855 - *
856 - * `ws_plugin__s2member_auto_eot_state` is non-autoloaded operational state. Fields are added when relevant:
857 - * - Run: `last_started_at`, `active_run_token`, `last_completed_at`, `last_runtime`, `last_runtime_budget`,
858 - * `last_processed`, `last_stop_reason`, `last_invocation`, `last_external_completed_at`.
859 - * Stop reasons are `queue_empty`, `runtime_budget`, or `legacy_item_cap`; invocation is `continuation`,
860 - * `external_cron`, `wp_cron`, or `direct`.
861 - * - Pending work: `last_more_due_work`, `last_pending_count`, `last_oldest_due_at`, `last_oldest_overdue_seconds`.
862 - * - Legacy cap: `last_hard_cap` (int|null), `last_hard_cap_source` (`filter` or `explicit`),
863 - * `legacy_cap_estimated_additional`.
864 - * - Abandoned run: `last_abandoned_at`, `last_abandoned_started_at`, `last_abandoned_heartbeat_at`,
865 - * `last_abandoned_processed`, `last_abandoned_user_id`, `consecutive_abandoned_runs`.
866 - * - Scheduler repair: `last_schedule_repaired_at`, `last_schedule_failure_at`, `schedule_failure_count`.
867 - * 260822.0614 Catch-up health is derived from ordinary pending/run state; there is no separate incident, cutoff,
868 - * backlog audit, or review-role state that can change how overdue users are processed.
869 - * Performance timing is descriptive for the last pass only; it is never persistent runtime-learning input.
870 - *
871 - * @package s2Member\Auto_EOT_System
872 - * @since 3.5
873 - *
874 - * @param int $per_process Legacy maximum database records to process in this pass when explicitly supplied or filtered.
875 - * @param bool $is_continuation Internal catch-up continuation; skips the collective after-hook.
876 - *
877 - * @return null
878 - */
879 - public static function auto_eot_system($per_process = 10, $is_continuation = FALSE)
880 - {
881 - global $wpdb;
882 - /** @var $wpdb \wpdb */
883 - global $current_site, $current_blog;
884 -
885 - include_once ABSPATH.'wp-admin/includes/admin.php';
886 -
887 - //260820.0056 Do not disable PHP's execution limit here; the adaptive engine deliberately works inside a measured wall-clock budget.
888 - @ini_set('memory_limit', apply_filters('admin_memory_limit', WP_MAX_MEMORY_LIMIT));
889 -
890 - foreach(array_keys(get_defined_vars()) as $__v) $__refs[$__v] =& $$__v;
891 - do_action('ws_plugin__s2member_before_auto_eot_system', get_defined_vars());
892 - unset($__refs, $__v); // Housekeeping.
893 -
894 - //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.
895 - if($GLOBALS['WS_PLUGIN__']['s2member']['o']['auto_eot_system_enabled'] /* Enabled? */)
896 - {
897 - //260820.0056 Count the budget from the request start, not merely this callback, so WordPress bootstrap/earlier cron work consumes its share too.
898 - $runtime_budget = self::auto_eot_system_runtime_budget();
899 - $request_started = isset($_SERVER['REQUEST_TIME_FLOAT']) && is_numeric($_SERVER['REQUEST_TIME_FLOAT']) ? (float)$_SERVER['REQUEST_TIME_FLOAT'] : microtime(TRUE);
900 - $run_started = microtime(TRUE);
901 - $deadline = $request_started + $runtime_budget;
902 -
903 - //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.
904 - $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())));
905 -
906 - //260820.0056 A small non-autoloaded lock detects overlap and leaves evidence when a worker dies before reaching normal cleanup.
907 - $run_token = function_exists('wp_generate_uuid4') ? wp_generate_uuid4() : uniqid('s2-eot-', TRUE);
908 - $lock_option = 'ws_plugin__s2member_auto_eot_lock';
909 - $state_option = 'ws_plugin__s2member_auto_eot_state';
910 - $lock_stale_after = max(120, (int)ceil(($runtime_budget * 2) + 30));
911 - $existing_lock = get_option($lock_option);
912 -
913 - //260820.0149 Discard malformed leftover state before evaluating whether another worker is active.
914 - if($existing_lock !== FALSE && (!is_array($existing_lock) || empty($existing_lock['heartbeat_at'])))
915 - {
916 - delete_option($lock_option);
917 - delete_transient('ws_plugin__s2member_auto_eot_health');
918 - $existing_lock = FALSE;
919 - }
920 -
921 - //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.
922 - if(is_array($existing_lock) && !empty($existing_lock['heartbeat_at']) && time() - (int)$existing_lock['heartbeat_at'] > $lock_stale_after)
923 - {
924 - $state = get_option($state_option);
925 - $state = is_array($state) ? $state : array();
926 - $state['last_abandoned_at'] = time();
927 - $state['last_abandoned_started_at'] = !empty($existing_lock['started_at']) ? (int)$existing_lock['started_at'] : 0;
928 - $state['last_abandoned_heartbeat_at'] = !empty($existing_lock['heartbeat_at']) ? (int)$existing_lock['heartbeat_at'] : 0;
929 - $state['last_abandoned_processed'] = !empty($existing_lock['processed']) ? (int)$existing_lock['processed'] : 0;
930 - $state['last_abandoned_user_id'] = !empty($existing_lock['current_user_id']) ? (int)$existing_lock['current_user_id'] : 0;
931 - $state['consecutive_abandoned_runs'] = !empty($state['consecutive_abandoned_runs']) ? (int)$state['consecutive_abandoned_runs'] + 1 : 1;
932 - update_option($state_option, $state, FALSE);
933 - delete_option($lock_option);
934 - delete_transient('ws_plugin__s2member_auto_eot_health');
935 - $existing_lock = FALSE;
936 - }
937 -
938 - //260820.0056 A fresh marker belongs to another worker that should still be alive; never process the same overdue population concurrently.
939 - if(is_array($existing_lock) && !empty($existing_lock['heartbeat_at']))
940 - return;
941 -
942 - //260820.0056 Use add_option() for lock acquisition so two workers racing here cannot both believe they acquired it.
943 - $lock = array('token' => $run_token, 'started_at' => time(), 'heartbeat_at' => time(), 'processed' => 0, 'current_user_id' => 0);
944 - if(!add_option($lock_option, $lock, '', FALSE))
945 - return; // Another worker acquired the lock between our read and add.
946 -
947 - //260820.0056 Persist only operational health between runs; performance timing remains local to each pass so it adapts organically to current conditions.
948 - $state = get_option($state_option);
949 - $state = is_array($state) ? $state : array();
950 - $state['last_started_at'] = time();
951 - $state['active_run_token'] = $run_token;
952 - update_option($state_option, $state, FALSE);
953 - delete_transient('ws_plugin__s2member_auto_eot_health'); //260820.0149 Invalidate any cached pre-run status.
954 -
955 - //260820.0056 The historical count becomes a hard cap only when code explicitly supplies/filters it; the untouched default no longer throttles normal installations.
956 - $per_process_filter_attached = has_filter('ws_plugin__s2member_auto_eot_system_per_process') !== FALSE;
957 - $per_process_was_explicit = func_num_args() > 0 && !$is_continuation;
958 - $per_process = apply_filters('ws_plugin__s2member_auto_eot_system_per_process', $per_process, get_defined_vars());
959 - $hard_cap = ($per_process_filter_attached || $per_process_was_explicit) ? max(0, (int)$per_process) : NULL;
960 - $hard_cap_source = $per_process_filter_attached ? 'filter' : ($per_process_was_explicit ? 'explicit' : '');
961 -
962 - //260820.0056 Fetch modest ordered chunks from MySQL; 100 is only a query-buffer size, never the normal processing throttle.
963 - $chunk_size = 100;
964 - $processed_count = 0;
965 - $item_total_duration = 0.0;
966 - $last_item_duration = 0.0;
967 - $last_heartbeat = microtime(TRUE);
968 - $cursor_time = 0;
969 - $cursor_umeta_id = 0;
970 - $stop_reason = 'queue_empty';
971 - $meta_key = $wpdb->prefix.'s2member_auto_eot_time';
972 -
973 - while(TRUE)
974 - {
975 - //260820.0056 Honor an intentional legacy ceiling before doing another query or user operation.
976 - if($hard_cap !== NULL && $processed_count >= $hard_cap)
977 - {
978 - $stop_reason = 'legacy_item_cap';
979 - break;
980 - }
981 -
982 - //260820.0056 Near the deadline, use only this run's last/average item times to decide whether another EOT is likely to fit safely.
983 - $remaining_runtime = $deadline - microtime(TRUE);
984 - $average_item_duration = $processed_count ? $item_total_duration / $processed_count : 0.0;
985 - $estimated_next_duration = max($last_item_duration, $average_item_duration);
986 - if($remaining_runtime <= $safety_buffer + $estimated_next_duration)
987 - {
988 - $stop_reason = 'runtime_budget';
989 - break;
990 - }
991 -
992 - //260820.0056 A legacy hard cap may make the final SQL chunk smaller, but otherwise query size and processing capacity remain independent.
993 - $query_limit = $chunk_size;
994 - if($hard_cap !== NULL)
995 - $query_limit = min($query_limit, max(0, $hard_cap - $processed_count));
996 - if($query_limit < 1)
997 - {
998 - $stop_reason = 'legacy_item_cap';
999 - break;
1000 - }
1001 -
1002 - //260820.0056 Query only due EOT metadata, oldest timestamp first; `umeta_id` makes equal timestamps deterministic and provides cursor pagination without OFFSET.
1003 - $now = time();
1004 - $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";
1005 - $sql_args = array($meta_key, $now);
1006 -
1007 - //260820.0056 Continue strictly after the previous timestamp/umeta_id pair, avoiding increasingly expensive SQL OFFSET pagination.
1008 - if($cursor_time || $cursor_umeta_id)
1009 - {
1010 - $sql .= " AND (CAST(`meta_value` AS UNSIGNED) > %d OR (CAST(`meta_value` AS UNSIGNED) = %d AND `umeta_id` > %d))";
1011 - $sql_args[] = $cursor_time;
1012 - $sql_args[] = $cursor_time;
1013 - $sql_args[] = $cursor_umeta_id;
1014 - }
1015 - $sql .= " ORDER BY CAST(`meta_value` AS UNSIGNED) ASC, `umeta_id` ASC LIMIT ".(int)$query_limit;
1016 - $eots = $wpdb->get_results($wpdb->prepare($sql, $sql_args));
1017 -
1018 - if(!is_array($eots) || !$eots)
1019 - break;
1020 -
1021 - foreach($eots as $eot) // Oldest overdue EOT first; equal timestamps are deterministic by `umeta_id`.
1022 - {
1023 - $cursor_time = (int)$eot->auto_eot_time;
1024 - $cursor_umeta_id = (int)$eot->umeta_id;
1025 -
1026 - //260820.0056 Recheck both stopping conditions inside the chunk because each user's hooks/notifications can materially change elapsed time.
1027 - if($hard_cap !== NULL && $processed_count >= $hard_cap)
1028 - {
1029 - $stop_reason = 'legacy_item_cap';
1030 - break 2;
1031 - }
1032 - $remaining_runtime = $deadline - microtime(TRUE);
1033 - $average_item_duration = $processed_count ? $item_total_duration / $processed_count : 0.0;
1034 - $estimated_next_duration = max($last_item_duration, $average_item_duration);
1035 - if($remaining_runtime <= $safety_buffer + $estimated_next_duration)
1036 - {
1037 - $stop_reason = 'runtime_budget';
1038 - break 2;
1039 - }
1040 -
1041 - //260820.0056 Re-read only the exact selected row immediately before destructive work; skip it if its EOT was changed/deleted after selection.
1042 - $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));
1043 - 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())
1044 - continue;
1045 -
1046 - //260820.0056 Time the complete per-user EOT operation, including hooks/notifications, because extension work may dominate the actual cost.
1047 - $item_started = microtime(TRUE);
1048 - $user_id = (int)$eot->ID;
1049 - $auto_eot_time = (int)$current_eot->meta_value;
1050 - if($user_id && is_object($user = new WP_User ($user_id)) && $user->ID)
1051 - {
1052 - $log_entry = array('user' => (array)$user); // Intialize.
1053 - $log_entry['auto_eot_time'] = $auto_eot_time; // Record EOT time.
1054 -
1055 - //260414 Keep a minimal pre-demotion subscription snapshot in the log so we can tell later
1056 - // whether this member still had subscription metadata before anything was cleared.
1057 - $log_entry['subscr_gateway'] = get_user_option('s2member_subscr_gateway', $user_id);
1058 - $log_entry['subscr_id'] = get_user_option('s2member_subscr_id', $user_id);
1059 - $log_entry['has_ipn_signup_vars'] = is_array(get_user_option('s2member_ipn_signup_vars', $user_id)) ? 'yes' : 'no';
1060 -
1061 - //260414 Defense in depth. A bad stored value of `0` caused false demotions in the wild.
1062 - // If one still reaches this loop for any reason, log it and skip instead of clearing fields.
1063 - if($auto_eot_time <= 0)
1064 - {
1065 - $log_entry['auto_eot_skip_reason'] = 'Skipped. Stored `s2member_auto_eot_time` was <= 0.';
1066 - c_ws_plugin__s2member_utils_logs::log_entry('auto-eot-system', $log_entry);
1067 - continue;
1068 - }
1069 -
1070 - //260821.0626 `s2member_auto_eot_details` and `s2member_last_auto_eot_details` share the provenance format
1071 - // `array('time' => EOT Unix timestamp, 'source' => string, 'updated_at' => Unix timestamp)`. `time` must
1072 - // exactly match the corresponding current/archived EOT; otherwise the details are stale and ignored.
1073 - // `source` currently uses `refund_reversal` for payment exceptions that must not be treated as renewal opportunities.
1074 - $auto_eot_details = get_user_option('s2member_auto_eot_details', $user_id);
1075 - if(!is_array($auto_eot_details) || empty($auto_eot_details['time']) || (int)$auto_eot_details['time'] !== $auto_eot_time)
1076 - $auto_eot_details = array();
1077 -
1078 - delete_user_option($user_id, 's2member_last_auto_eot_time');
1079 - delete_user_option($user_id, 's2member_last_auto_eot_details');
1080 - delete_user_option($user_id, 's2member_auto_eot_time');
1081 - delete_user_option($user_id, 's2member_auto_eot_details');
1082 -
1083 - if(!$user->has_cap('administrator') /* Do NOT process Administrator accounts. */)
1084 - {
1085 - if($GLOBALS['WS_PLUGIN__']['s2member']['o']['membership_eot_behavior'] === 'demote')
1086 - {
1087 - $eot_del_type = 'auto-eot-cancellation-expiration-demotion'; // Set EOT/Del type.
1088 - $log_entry['eot_del_type'] = $eot_del_type; // Deleting user in this case.
1089 -
1090 - $custom = get_user_option('s2member_custom', $user_id);
1091 - $subscr_gateway = get_user_option('s2member_subscr_gateway', $user_id);
1092 - $subscr_id = get_user_option('s2member_subscr_id', $user_id);
1093 - $subscr_baid = get_user_option('s2member_subscr_baid', $user_id);
1094 - $subscr_cid = get_user_option('s2member_subscr_cid', $user_id);
1095 - $fields = get_user_option('s2member_custom_fields', $user_id);
1096 - $user_reg_ip = get_user_option('s2member_registration_ip', $user_id);
1097 - $ipn_signup_vars = get_user_option('s2member_ipn_signup_vars', $user_id);
1098 -
1099 - $demotion_role = c_ws_plugin__s2member_option_forces::force_demotion_role('subscriber');
1100 - $existing_role = c_ws_plugin__s2member_user_access::user_access_role($user);
1101 - $removed_ccaps = array();
1102 -
1103 - foreach(array_keys(get_defined_vars()) as $__v) $__refs[$__v] =& $$__v;
1104 - do_action('ws_plugin__s2member_during_auto_eot_system_during_before_demote', get_defined_vars());
1105 - do_action('ws_plugin__s2member_during_collective_mods', $user_id, get_defined_vars(), $eot_del_type, 'modification', $demotion_role);
1106 - do_action('ws_plugin__s2member_during_collective_eots', $user_id, get_defined_vars(), $eot_del_type, 'modification');
1107 - unset($__refs, $__v); // Housekeeping.
1108 -
1109 - if($existing_role !== $demotion_role /* Only if NOT the existing Role. */)
1110 - $user->set_role($demotion_role /* Give User the demotion Role. */);
1111 -
1112 - if(apply_filters('ws_plugin__s2member_remove_ccaps_during_eot_events', (bool)$GLOBALS['WS_PLUGIN__']['s2member']['o']['eots_remove_ccaps'], get_defined_vars()))
1113 - foreach($user->allcaps as $cap => $cap_enabled)
1114 - if(preg_match('/^access_s2member_ccap_/', $cap))
1115 - {
1116 - $removed_ccaps[] = preg_replace('/^access_s2member_ccap_/', '', $cap);
1117 - $user->remove_cap($ccap = $cap);
1118 - }
1119 -
1120 - delete_user_option($user_id, 's2member_subscr_gateway');
1121 - delete_user_option($user_id, 's2member_subscr_id');
1122 - delete_user_option($user_id, 's2member_subscr_baid');
1123 - delete_user_option($user_id, 's2member_subscr_cid');
1124 -
1125 - delete_user_option($user_id, 's2member_ipn_signup_vars');
1126 - if(!apply_filters('ws_plugin__s2member_preserve_paid_registration_times', TRUE))
1127 - delete_user_option($user_id, 's2member_paid_registration_times');
1128 -
1129 - delete_user_option($user_id, 's2member_last_status_scan');
1130 - delete_user_option($user_id, 's2member_first_payment_txn_id');
1131 - delete_user_option($user_id, 's2member_last_payment_time');
1132 - delete_user_option($user_id, 's2member_last_auto_eot_time');
1133 - delete_user_option($user_id, 's2member_last_auto_eot_details');
1134 - delete_user_option($user_id, 's2member_auto_eot_time');
1135 - delete_user_option($user_id, 's2member_auto_eot_details');
1136 -
1137 - delete_user_option($user_id, 's2member_file_download_access_log');
1138 - delete_user_option($user_id, 's2member_authnet_payment_failures');
1139 -
1140 - $processed_at = time();
1141 - update_user_option($user_id, 's2member_last_auto_eot_time', $auto_eot_time);
1142 - //260821.0057 Preserve only matching provenance (e.g., refund/reversal) alongside the archived EOT.
1143 - if($auto_eot_details)
1144 - update_user_option($user_id, 's2member_last_auto_eot_details', $auto_eot_details);
1145 -
1146 - //260822.0653 Record the triggering EOT separately from when this worker actually completed the demotion, using the pre-cleanup role/payment snapshot above.
1147 - self::record_eot_history($user_id, array(
1148 - 'eot_time' => $auto_eot_time,
1149 - 'processed_at' => $processed_at,
1150 - 'original_role' => $existing_role,
1151 - 'destination_role' => $demotion_role,
1152 - 'removed_ccaps' => $removed_ccaps,
1153 - 'subscr_gateway' => $subscr_gateway,
1154 - 'subscr_id' => $subscr_id,
1155 - ));
1156 -
1157 - if($GLOBALS['WS_PLUGIN__']['s2member']['o']['eot_del_notification_urls'])
1158 - {
1159 - foreach(preg_split('/['."\r\n\t".']+/', $GLOBALS['WS_PLUGIN__']['s2member']['o']['eot_del_notification_urls']) as $url) // Handle EOT Notifications.
1160 -
1161 - 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)))
1162 - 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)))
1163 - 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)))
1164 - if(($url = preg_replace('/%%user_email%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(urlencode($user->user_email)), $url)))
1165 - if(($url = preg_replace('/%%user_login%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(urlencode($user->user_login)), $url)))
1166 - if(($url = preg_replace('/%%user_ip%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(urlencode($user_reg_ip)), $url)))
1167 - if(($url = preg_replace('/%%user_id%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(urlencode($user_id)), $url)))
1168 - {
1169 - if(is_array($fields) && !empty($fields))
1170 - foreach($fields as $var => $val /* Custom Registration/Profile Fields. */)
1171 - if(!($url = preg_replace('/%%'.preg_quote($var, '/').'%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(urlencode(maybe_serialize($val))), $url)))
1172 - break;
1173 -
1174 - if(($url = trim(preg_replace('/%%(.+?)%%/i', '', $url))))
1175 - c_ws_plugin__s2member_utils_urls::remote($url);
1176 - }
1177 - }
1178 - if($GLOBALS['WS_PLUGIN__']['s2member']['o']['eot_del_notification_recipients'])
1179 - {
1180 - $email_configs_were_on = c_ws_plugin__s2member_email_configs::email_config_status();
1181 - c_ws_plugin__s2member_email_configs::email_config_release();
1182 -
1183 - $msg = $sbj = '(s2Member / API Notification Email) - EOT/Deletion';
1184 - $msg .= "\n\n"; // Spacing in the message body.
1185 -
1186 - $msg .= 'eot_del_type: %%eot_del_type%%'."\n";
1187 - $msg .= 'subscr_id: %%subscr_id%%'."\n";
1188 - $msg .= 'subscr_baid: %%subscr_baid%%'."\n";
1189 - $msg .= 'subscr_cid: %%subscr_cid%%'."\n";
1190 - $msg .= 'user_first_name: %%user_first_name%%'."\n";
1191 - $msg .= 'user_last_name: %%user_last_name%%'."\n";
1192 - $msg .= 'user_full_name: %%user_full_name%%'."\n";
1193 - $msg .= 'user_email: %%user_email%%'."\n";
1194 - $msg .= 'user_login: %%user_login%%'."\n";
1195 - $msg .= 'user_ip: %%user_ip%%'."\n";
1196 - $msg .= 'user_id: %%user_id%%'."\n";
1197 -
1198 - if(is_array($fields) && !empty($fields))
1199 - foreach($fields as $var => $val)
1200 - $msg .= $var.': %%'.$var.'%%'."\n";
1201 -
1202 - $msg .= 'cv0: %%cv0%%'."\n";
1203 - $msg .= 'cv1: %%cv1%%'."\n";
1204 - $msg .= 'cv2: %%cv2%%'."\n";
1205 - $msg .= 'cv3: %%cv3%%'."\n";
1206 - $msg .= 'cv4: %%cv4%%'."\n";
1207 - $msg .= 'cv5: %%cv5%%'."\n";
1208 - $msg .= 'cv6: %%cv6%%'."\n";
1209 - $msg .= 'cv7: %%cv7%%'."\n";
1210 - $msg .= 'cv8: %%cv8%%'."\n";
1211 - $msg .= 'cv9: %%cv9%%';
1212 -
1213 - 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)))
1214 - 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)))
1215 - 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)))
1216 - if(($msg = preg_replace('/%%user_full_name%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(trim($user->first_name.' '.$user->last_name)), $msg)))
1217 - if(($msg = preg_replace('/%%user_email%%/i', c_ws_plugin__s2member_utils_strings::esc_refs($user->user_email), $msg)))
1218 - if(($msg = preg_replace('/%%user_login%%/i', c_ws_plugin__s2member_utils_strings::esc_refs($user->user_login), $msg)))
1219 - if(($msg = preg_replace('/%%user_ip%%/i', c_ws_plugin__s2member_utils_strings::esc_refs($user_reg_ip), $msg)))
1220 - if(($msg = preg_replace('/%%user_id%%/i', c_ws_plugin__s2member_utils_strings::esc_refs($user_id), $msg)))
1221 - {
1222 - if(is_array($fields) && !empty($fields))
1223 - foreach($fields as $var => $val /* Custom Registration/Profile Fields. */)
1224 - if(!($msg = preg_replace('/%%'.preg_quote($var, '/').'%%/i', c_ws_plugin__s2member_utils_strings::esc_refs(maybe_serialize($val)), $msg)))
1225 - break;
1226 -
1227 - if($sbj && ($msg = trim(preg_replace('/%%(.+?)%%/i', '', $msg))) /* Still have a ``$sbj`` and a ``$msg``? */)
1228 -
1229 - foreach(c_ws_plugin__s2member_utils_strings::parse_emails($GLOBALS['WS_PLUGIN__']['s2member']['o']['eot_del_notification_recipients']) as $recipient)
1230 - 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');
1231 - }
1232 - if($email_configs_were_on) c_ws_plugin__s2member_email_configs::email_config();
1233 - }
1234 - foreach(array_keys(get_defined_vars()) as $__v) $__refs[$__v] =& $$__v;
1235 - do_action('ws_plugin__s2member_during_auto_eot_system_during_demote', get_defined_vars());
1236 - unset($__refs, $__v); // Housekeeping.
1237 - }
1238 - else if($GLOBALS['WS_PLUGIN__']['s2member']['o']['membership_eot_behavior'] === 'delete')
1239 - {
1240 - $eot_del_type = 'auto-eot-cancellation-expiration-deletion';
1241 - $log_entry['eot_del_type'] = $eot_del_type;
1242 -
1243 - foreach(array_keys(get_defined_vars()) as $__v) $__refs[$__v] =& $$__v;
1244 - do_action('ws_plugin__s2member_during_auto_eot_system_during_before_delete', get_defined_vars());
1245 - do_action('ws_plugin__s2member_during_collective_eots', $user_id, get_defined_vars(), $eot_del_type, 'removal-deletion');
1246 - unset($__refs, $__v); // Housekeeping.
1247 -
1248 - //260822.0535 One operation now owns both safe Pending Deletion and the explicit developer opt-in for historical irreversible deletion.
1249 - $eot_delete_action = self::process_eot_deletion($user_id, $eot_del_type, $auto_eot_time);
1250 - $log_entry['eot_delete_action'] = $eot_delete_action;
1251 -
1252 - foreach(array_keys(get_defined_vars()) as $__v) $__refs[$__v] =& $$__v;
1253 - do_action('ws_plugin__s2member_during_auto_eot_system_during_delete', get_defined_vars());
1254 - unset($__refs, $__v); // Housekeeping.
1255 - }
1256 - foreach(array_keys(get_defined_vars()) as $__v) $__refs[$__v] =& $$__v;
1257 - do_action('ws_plugin__s2member_during_auto_eot_system', get_defined_vars());
1258 - unset($__refs, $__v); // Housekeeping.
1259 -
1260 - c_ws_plugin__s2member_utils_logs::log_entry('auto-eot-system', $log_entry);
1261 - }
1262 -
1263 - }
1264 -
1265 - //260820.0056 Feed the completed item's wall-clock cost into this pass only; no timing average is persisted between runs.
1266 - $last_item_duration = max(0, microtime(TRUE) - $item_started);
1267 - $item_total_duration += $last_item_duration;
1268 - $processed_count++;
1269 -
1270 - //260820.0056 Refresh the lock periodically rather than per user, preserving useful crash evidence without creating unnecessary option writes.
1271 - if($processed_count % 5 === 0 || microtime(TRUE) - $last_heartbeat >= 5)
1272 - {
1273 - $lock['heartbeat_at'] = time();
1274 - $lock['processed'] = $processed_count;
1275 - $lock['current_user_id'] = $user_id;
1276 - update_option($lock_option, $lock, FALSE);
1277 - $last_heartbeat = microtime(TRUE);
1278 - }
1279 - }
1280 -
1281 - //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.
1282 - if(count($eots) < $query_limit)
1283 - break;
1284 - }
1285 -
1286 - //260820.0149 One aggregate gives both catch-up state and the pending/oldest values needed by diagnostics.
1287 - $run_runtime = max(0, microtime(TRUE) - $run_started);
1288 - $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()));
1289 - $pending_count = ($pending && !empty($pending->pending_count)) ? (int)$pending->pending_count : 0;
1290 - $oldest_due_at = ($pending && !empty($pending->oldest_due_at)) ? (int)$pending->oldest_due_at : 0;
1291 - $more_due_work = $pending_count > 0;
1292 -
1293 - //260820.0056 Preserve enough current-run timing information to explain when a legacy item cap, rather than runtime, unnecessarily constrained throughput.
1294 - $average_item_duration = $processed_count ? $item_total_duration / $processed_count : 0.0;
1295 - $estimated_next_duration = max($last_item_duration, $average_item_duration);
1296 - $remaining_safe_runtime = max(0, ($deadline - microtime(TRUE)) - $safety_buffer);
1297 - $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;
1298 -
1299 - //260820.0056 Save compact operational health for diagnostics/UI; these are run results, not persistent performance-learning values.
1300 - $state = get_option($state_option);
1301 - $state = is_array($state) ? $state : array();
1302 - $state['last_completed_at'] = time();
1303 - $state['last_runtime'] = $run_runtime;
1304 - $state['last_runtime_budget'] = $runtime_budget;
1305 - $state['last_processed'] = $processed_count;
1306 - $state['last_stop_reason'] = $stop_reason;
1307 - $state['last_hard_cap'] = $hard_cap;
1308 - $state['last_hard_cap_source'] = $hard_cap_source;
1309 - $state['last_more_due_work'] = $more_due_work ? 1 : 0;
1310 - $state['last_pending_count'] = $pending_count;
1311 - $state['last_oldest_due_at'] = $oldest_due_at;
1312 - $state['last_oldest_overdue_seconds'] = $oldest_due_at ? max(0, time() - $oldest_due_at) : 0;
1313 - $state['legacy_cap_estimated_additional'] = $legacy_cap_estimated_additional;
1314 - $state['last_invocation'] = $is_continuation ? 'continuation' : (!empty($_GET['s2member_auto_eot_system_via_cron']) ? 'external_cron' : ((defined('DOING_CRON') && DOING_CRON) ? 'wp_cron' : 'direct'));
1315 - if($state['last_invocation'] === 'external_cron')
1316 - $state['last_external_completed_at'] = time();
1317 - $state['consecutive_abandoned_runs'] = 0; //260820.0149 A clean completion breaks the abandoned-run sequence.
1318 - $state['active_run_token'] = '';
1319 -
1320 - update_option($state_option, $state, FALSE);
1321 -
1322 - //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.
1323 - delete_option($lock_option);
1324 -
1325 - //260820.0056 In WP-Cron mode, continue soon while overdue EOTs remain; external-cron installations already control their own invocation cadence.
1326 - if((string)$GLOBALS['WS_PLUGIN__']['s2member']['o']['auto_eot_system_enabled'] === '1' && $more_due_work && ($hard_cap === NULL || $hard_cap > 0))
1327 - {
1328 - if(!wp_next_scheduled('ws_plugin__s2member_auto_eot_system__continuation'))
1329 - wp_schedule_single_event(time() + 60, 'ws_plugin__s2member_auto_eot_system__continuation');
1330 - }
1331 - else if(!$more_due_work)
1332 - wp_clear_scheduled_hook('ws_plugin__s2member_auto_eot_system__continuation');
1333 -
1334 - delete_transient('ws_plugin__s2member_auto_eot_health'); //260820.0149 Run completion changes the health snapshot.
1335 - }
1336 - c_ws_plugin__s2member_utils_logs::cleanup_expired_s2m_transients();
1337 -
1338 - //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.
1339 - if(!$is_continuation)
1340 - {
1341 - foreach(array_keys(get_defined_vars()) as $__v) $__refs[$__v] =& $$__v;
1342 - do_action('ws_plugin__s2member_after_auto_eot_system', get_defined_vars());
1343 - unset($__refs, $__v); // Housekeeping.
1344 - }
1345 - else
1346 - {
1347 - //260820.0056 Continuations still repair the recurring Auto-EOT event directly because they deliberately skip the collective after-hook that normally performs this check.
1348 - self::ensure_auto_eot_system();
1349 - }
1350 - }
1351 - }
1352 -}
25 +if(!class_exists('c_ws_plugin__s2member_auto_eots', FALSE))
26 + class_alias('c_ws_plugin__s2member_eot', 'c_ws_plugin__s2member_auto_eots');