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
s2member / src / includes / classes / eot.inc.php

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

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