PluginProbe
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) / 1.8.13
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) v1.8.13
1.8.13 1.8.12.3 1.8.12.2 1.8.12.1 1.8.12 1.8.11.3 1.8.11.2 1.8.11.1 1.8.11 1.6.6 1.6.60 1.6.7 1.6.8 1.6.9 1.7.0 1.7.0.1 1.7.0.11 1.7.0.12 1.7.0.14 1.7.0.2 1.7.0.3 1.7.0.5 1.7.0.6 1.7.0.7 1.7.0.9 All 211 releases
charitable / includes / admin / notifications / class-charitable-notification-cron.php

class-charitable-notification-cron.php in Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) 1.8.13, at includes/admin/notifications/class-charitable-notification-cron.php

364 lines 11.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Charitable Notification Cron.
4 *
5 * Schedules periodic notification checks (health, compliance, nudges)
6 * with a page-load fallback for sites where WP Cron is unreliable.
7 *
8 * @package Charitable/Classes/Charitable_Notification_Cron
9 * @since 1.8.12
10 */
11
12 if ( ! defined( 'ABSPATH' ) ) {
13 exit;
14 }
15
16 class Charitable_Notification_Cron {
17
18 private static $instance = null;
19
20 const CRON_HEALTH = 'charitable_notifications_cron_health';
21 const CRON_COMPLIANCE = 'charitable_notifications_cron_compliance';
22 const CRON_NUDGES = 'charitable_notifications_cron_nudges';
23 const CRON_NEW_FEED = 'charitable_notifications_cron_new_feed';
24
25 /** Transient key prefix for tracking last-run timestamps. */
26 const TRANSIENT_PREFIX = 'charitable_notif_cron_last_';
27
28 /** Transient key for consecutive miss counter per job. */
29 const MISS_PREFIX = 'charitable_notif_cron_miss_';
30
31 public static function get_instance() {
32 if ( is_null( self::$instance ) ) {
33 self::$instance = new self();
34 }
35 return self::$instance;
36 }
37
38 public function init() {
39 // Register custom cron interval if needed.
40 add_filter( 'cron_schedules', [ $this, 'add_cron_intervals' ] );
41
42 // Hook cron actions.
43 add_action( self::CRON_HEALTH, [ $this, 'run_health_check' ] );
44 add_action( self::CRON_COMPLIANCE, [ $this, 'run_compliance_check' ] );
45 add_action( self::CRON_NUDGES, [ $this, 'run_nudge_check' ] );
46 add_action( self::CRON_NEW_FEED, [ $this, 'run_new_feed_fetch' ] );
47
48 // Schedule events if not already scheduled.
49 add_action( 'admin_init', [ $this, 'maybe_schedule' ] );
50
51 // Page-load fallback on any Charitable admin page.
52 add_action( 'admin_init', [ $this, 'maybe_run_fallback' ], 20 );
53 }
54
55 /**
56 * Register WP Cron schedules.
57 */
58 public function add_cron_intervals( $schedules ) {
59 return $schedules; // Using built-in 'hourly' and 'daily' — no custom intervals needed yet.
60 }
61
62 /**
63 * Schedule all cron jobs if not already scheduled.
64 */
65 public function maybe_schedule() {
66 $jobs = [
67 self::CRON_HEALTH => apply_filters( 'charitable_notifications_health_interval', 'hourly' ),
68 self::CRON_COMPLIANCE => apply_filters( 'charitable_notifications_compliance_interval', 'daily' ),
69 self::CRON_NUDGES => apply_filters( 'charitable_notifications_nudges_interval', 'daily' ),
70 self::CRON_NEW_FEED => apply_filters( 'charitable_notifications_feed_interval', 'daily' ),
71 ];
72
73 foreach ( $jobs as $hook => $recurrence ) {
74 if ( ! wp_next_scheduled( $hook ) ) {
75 wp_schedule_event( time(), $recurrence, $hook );
76 }
77 }
78 }
79
80 /**
81 * Page-load fallback: run any job whose last-run timestamp is stale.
82 * Only runs on Charitable admin pages to avoid overhead on every admin load.
83 */
84 public function maybe_run_fallback() {
85 if ( ! $this->is_charitable_admin_page() ) {
86 return;
87 }
88
89 $now = time();
90 $hourly_threshold = (int) apply_filters( 'charitable_notifications_health_fallback_threshold', 2 * HOUR_IN_SECONDS );
91 $daily_threshold = (int) apply_filters( 'charitable_notifications_daily_fallback_threshold', 26 * HOUR_IN_SECONDS );
92 $miss_threshold = (int) apply_filters( 'charitable_notifications_cron_miss_threshold', 3 );
93
94 $jobs = [
95 self::CRON_HEALTH => $hourly_threshold,
96 self::CRON_COMPLIANCE => $daily_threshold,
97 self::CRON_NUDGES => $daily_threshold,
98 self::CRON_NEW_FEED => $daily_threshold,
99 ];
100
101 foreach ( $jobs as $hook => $threshold ) {
102 $last_run = (int) get_transient( self::TRANSIENT_PREFIX . $hook );
103 $stale = ( $last_run === 0 ) || ( ( $now - $last_run ) > $threshold );
104
105 if ( $stale ) {
106 $this->record_miss( $hook, $miss_threshold );
107 do_action( $hook ); // Trigger the cron callback inline.
108 }
109 }
110 }
111
112 /**
113 * Record a cron miss. If threshold exceeded, add a health notification.
114 *
115 * @param string $hook
116 * @param int $threshold
117 */
118 private function record_miss( $hook, $threshold ) {
119 $key = self::MISS_PREFIX . $hook;
120 $count = (int) get_transient( $key );
121 $count++;
122 set_transient( $key, $count, WEEK_IN_SECONDS );
123
124 if ( $count >= $threshold && self::CRON_HEALTH !== $hook ) {
125 Charitable_Local_Notifications::get_instance()->add(
126 'cron_not_running',
127 [
128 'title' => __( 'WordPress Cron may not be running', 'charitable' ),
129 'content' => '<p>' . __( 'Some Charitable notifications (health alerts, compliance reminders) may be delayed because WordPress Cron does not appear to be running on this site.', 'charitable' ) . '</p>',
130 'notification_type' => 'warning',
131 'dismissible' => true,
132 'badge_label' => 'Health Alert',
133 'badge_type' => 'critical',
134 'category' => 'health',
135 'btns' => [],
136 ]
137 );
138 }
139 }
140
141 /**
142 * Mark a cron job as successfully run.
143 *
144 * @param string $hook
145 */
146 private function mark_ran( $hook ) {
147 set_transient( self::TRANSIENT_PREFIX . $hook, time(), WEEK_IN_SECONDS );
148 delete_transient( self::MISS_PREFIX . $hook );
149 }
150
151 /**
152 * HEALTH CHECK — failed donations in last 24 hours.
153 *
154 * Queries charitable_donations post type for posts with status
155 * 'charitable-failed' modified in the last 24 hours (excluding test mode).
156 */
157 public function run_health_check() {
158 try {
159 $this->mark_ran( self::CRON_HEALTH );
160
161 $threshold = apply_filters( 'charitable_notifications_health_failed_threshold', 3 );
162 $since = date( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ); // phpcs:ignore
163
164 $failed = new WP_Query( [
165 'post_type' => 'donation',
166 'post_status' => 'charitable-failed',
167 'posts_per_page' => -1,
168 'fields' => 'ids',
169 'date_query' => [
170 [
171 'column' => 'post_modified',
172 'after' => $since,
173 ],
174 ],
175 'meta_query' => [ // phpcs:ignore
176 [
177 'key' => '_test_mode',
178 'compare' => 'NOT EXISTS',
179 ],
180 ],
181 ] );
182
183 $count = $failed->found_posts;
184 $local = Charitable_Local_Notifications::get_instance();
185
186 if ( $count >= $threshold ) {
187 $local->add(
188 'health_failed_donations',
189 [
190 'title' => sprintf(
191 /* translators: %d: number of failed donations */
192 _n( '%d failed donation in the last 24 hours', '%d failed donations in the last 24 hours', $count, 'charitable' ),
193 $count
194 ),
195 'content' => '<p>' . __( 'Donors may be experiencing checkout issues. Review failed transactions to identify the cause.', 'charitable' ) . '</p>',
196 'notification_type' => 'warning',
197 'dismissible' => true,
198 'badge_label' => __( 'Health Alert', 'charitable' ),
199 'badge_type' => 'critical',
200 'category' => 'health',
201 'btns' => [
202 'main' => [
203 'url' => admin_url( 'admin.php?page=charitable-donations&status=charitable-failed' ),
204 'text' => __( 'View Failed Donations', 'charitable' ),
205 ],
206 ],
207 ]
208 );
209 } else {
210 // Condition resolved — remove the notification.
211 $local->remove( 'health_failed_donations' );
212 }
213 } catch ( \Throwable $e ) {
214 if ( charitable_is_debug() ) { error_log( 'Charitable: run_health_check() failed: ' . $e->getMessage() ); } // phpcs:ignore
215 }
216 }
217
218 /**
219 * COMPLIANCE CHECK — Gift Aid settings incomplete.
220 *
221 * Fires only if 14+ days have passed since the first campaign was created.
222 */
223 public function run_compliance_check() {
224 try {
225 $this->mark_ran( self::CRON_COMPLIANCE );
226
227 $local = Charitable_Local_Notifications::get_instance();
228
229 // Only relevant if Gift Aid is enabled/applicable (UK sites).
230 // Check: first campaign created more than 14 days ago?
231 $first_campaign_ts = $this->get_first_campaign_timestamp();
232 $offset = (int) apply_filters( 'charitable_notifications_gift_aid_offset', 14 * DAY_IN_SECONDS );
233
234 if ( ! $first_campaign_ts || ( time() - $first_campaign_ts ) < $offset ) {
235 return;
236 }
237
238 // Check if Gift Aid settings are configured.
239 $gift_aid_enabled = get_option( 'charitable_gift_aid_enabled', false );
240 $gift_aid_configured = apply_filters( 'charitable_gift_aid_is_configured', $gift_aid_enabled );
241
242 if ( $gift_aid_configured ) {
243 $local->remove( 'compliance_gift_aid' );
244 return;
245 }
246
247 $local->add(
248 'compliance_gift_aid',
249 [
250 'title' => __( 'Gift Aid settings may need attention', 'charitable' ),
251 'content' => '<p>' . __( 'If you accept donations from UK taxpayers, completing your Gift Aid settings can increase donation values by 25% at no cost to donors.', 'charitable' ) . '</p>',
252 'notification_type' => 'warning',
253 'dismissible' => true,
254 'badge_label' => __( 'Compliance', 'charitable' ),
255 'badge_type' => 'warning',
256 'category' => 'legal',
257 'btns' => [
258 'main' => [
259 'url' => admin_url( 'admin.php?page=charitable-settings&tab=gateways&subtab=gift-aid' ),
260 'text' => __( 'Review Gift Aid Settings', 'charitable' ),
261 ],
262 ],
263 ]
264 );
265 } catch ( \Throwable $e ) {
266 if ( charitable_is_debug() ) { error_log( 'Charitable: run_compliance_check() failed: ' . $e->getMessage() ); } // phpcs:ignore
267 }
268 }
269
270 /**
271 * NUDGE CHECK — no campaign created yet.
272 */
273 public function run_nudge_check() {
274 try {
275 $this->mark_ran( self::CRON_NUDGES );
276
277 $local = Charitable_Local_Notifications::get_instance();
278
279 $campaigns = new WP_Query( [
280 'post_type' => 'campaign',
281 'post_status' => [ 'publish', 'draft', 'pending' ],
282 'posts_per_page' => 1,
283 'fields' => 'ids',
284 ] );
285
286 if ( $campaigns->found_posts > 0 ) {
287 $local->remove( 'nudge_no_campaign' );
288 return;
289 }
290
291 $local->add(
292 'nudge_no_campaign',
293 [
294 'title' => __( "You haven't created a campaign yet", 'charitable' ),
295 'content' => '<p>' . __( "You're all set up - the next step is creating your first fundraising campaign.", 'charitable' ) . '</p>',
296 'notification_type' => 'info',
297 'dismissible' => true,
298 'badge_label' => '',
299 'badge_type' => 'nudge',
300 'category' => 'nudge',
301 'btns' => [
302 'main' => [
303 'url' => admin_url( 'post-new.php?post_type=campaign' ),
304 'text' => __( 'Create a Campaign', 'charitable' ),
305 ],
306 ],
307 ]
308 );
309 } catch ( \Throwable $e ) {
310 if ( charitable_is_debug() ) { error_log( 'Charitable: run_nudge_check() failed: ' . $e->getMessage() ); } // phpcs:ignore
311 }
312 }
313
314 /**
315 * NEW FEED FETCH — delegates to Charitable_New_Feed.
316 */
317 public function run_new_feed_fetch() {
318 $this->mark_ran( self::CRON_NEW_FEED );
319 Charitable_New_Feed::get_instance()->fetch_and_store();
320 }
321
322 /**
323 * Get unix timestamp of first campaign ever created on this site.
324 *
325 * @return int|null
326 */
327 private function get_first_campaign_timestamp() {
328 $campaigns = new WP_Query( [
329 'post_type' => 'campaign',
330 'post_status' => 'any',
331 'posts_per_page' => 1,
332 'orderby' => 'date',
333 'order' => 'ASC',
334 'fields' => 'ids',
335 ] );
336
337 if ( empty( $campaigns->posts ) ) {
338 return null;
339 }
340
341 return strtotime( get_post( $campaigns->posts[0] )->post_date );
342 }
343
344 /**
345 * Check if the current page is a Charitable admin page.
346 *
347 * @return bool
348 */
349 private function is_charitable_admin_page() {
350 if ( ! is_admin() ) {
351 return false;
352 }
353 $screen = get_current_screen();
354 if ( ! $screen ) {
355 return false;
356 }
357 return (
358 strpos( $screen->id, 'charitable' ) !== false ||
359 strpos( $screen->post_type, 'campaign' ) !== false ||
360 strpos( $screen->post_type, 'donation' ) !== false
361 );
362 }
363 }
364