PluginProbe
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) / 1.8.12.2
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) v1.8.12.2
1.8.13.1 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 All 212 releases
charitable / includes / admin / site-analysis / class-charitable-site-analysis.php

class-charitable-site-analysis.php in Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) 1.8.12.2, at includes/admin/site-analysis/class-charitable-site-analysis.php

801 lines 36.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Charitable Site Analysis - a "Site Analysis" subtab on the Reports page that calls the
4 * NonprofitScore config-fidelity endpoint and renders recommendations. Self-contained + portable.
5 *
6 * @package Charitable/Classes/Charitable_Site_Analysis
7 */
8
9 if ( ! defined( 'ABSPATH' ) ) {
10 exit;
11 }
12
13 if ( ! class_exists( 'Charitable_Site_Analysis' ) ) :
14
15 class Charitable_Site_Analysis {
16
17 /** @var Charitable_Site_Analysis|null */
18 private static $instance = null;
19
20 /** Option holding the runtime-minted per-site token (autoload off). */
21 const TOKEN_OPTION = 'charitable_analysis_token';
22
23 /** Transient holding the last result keyed by payload hash. */
24 const CACHE_TRANSIENT = 'charitable_analysis_cache';
25
26 /**
27 * Timestamp of the last successful API call, for the refresh rate limit (autoload off).
28 *
29 * NEW IN 1.8.12.1. This used to be read out of CACHE_TRANSIENT's own 'time' key - i.e. the rate
30 * limit lived inside the very cache that flush_cache() deletes. Every one of the five
31 * invalidation hooks therefore reset the 24h limit, and "Settings > Advanced > Clear cache" was
32 * a deliberate, repeatable, one-click reset. The cache is meant to be volatile; a rate limit
33 * must not be.
34 *
35 * Kept separate from the cache's 'time' on purpose: that still dates the *report* ("Last
36 * analyzed 2 hours ago"), which correctly disappears when the report does. This dates the
37 * last *network call*, which must not.
38 */
39 const LAST_RUN_OPTION = 'charitable_analysis_last_run';
40
41 /** Plugin-side cache lifetime. */
42 const CACHE_TTL = 7 * DAY_IN_SECONDS;
43
44 /** Minimum time between manual "Refresh" pulls (which bypass the cache), to prevent API abuse. */
45 const REFRESH_COOLDOWN = DAY_IN_SECONDS;
46
47 /** Addon folder slugs the engine's configSignal recognises (Phase-1 catalog). */
48 const ADDON_SLUGS = array(
49 'charitable-recurring',
50 'charitable-fee-relief',
51 'charitable-ambassadors',
52 'charitable-newsletter-connect',
53 'charitable-pdf-receipts',
54 'charitable-annual-receipts',
55 'charitable-anonymous',
56 'charitable-gift-aid',
57 'charitable-geolocation',
58 'charitable-videos',
59 );
60
61 public static function get_instance() {
62 if ( is_null( self::$instance ) ) {
63 self::$instance = new self();
64 }
65 return self::$instance;
66 }
67
68 /** Singletons are not cloneable. */
69 private function __clone() {}
70
71 /** Singletons are not unserializable. */
72 public function __wakeup() {}
73
74 private function __construct() {
75 add_filter( 'charitable_reports_tabs', array( $this, 'register_tab' ) );
76 add_action( 'charitable_reports_tab_site-analysis', array( $this, 'render_tab' ) );
77 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
78 add_action( 'wp_ajax_charitable_site_analysis', array( $this, 'ajax_run' ) );
79
80 // Invalidate the cached report when the analyzed configuration changes, or when the user
81 // clears Charitable's cache from Settings > Advanced, so the next visit recomputes fresh.
82 add_action( 'update_option_charitable_settings', array( $this, 'flush_cache' ) ); // test mode, gateways, receipts, spam, currency, plan.
83 add_action( 'activated_plugin', array( $this, 'flush_cache' ) ); // an addon was activated (recurring, fee relief, etc.).
84 add_action( 'deactivated_plugin', array( $this, 'flush_cache' ) ); // an addon was deactivated.
85 add_action( 'save_post_campaign', array( $this, 'flush_cache' ) ); // a campaign changed (e.g. a goal added).
86 add_action( 'charitable_after_clear_expired_options', array( $this, 'flush_cache' ) ); // user clicked "Clear cache" in Settings > Advanced.
87 }
88
89 /** Add the Site Analysis tab to the Reports nav. */
90 public function register_tab( $tabs ) {
91 $tabs['site-analysis'] = __( 'Site Analysis', 'charitable' );
92 return $tabs;
93 }
94
95 /** Render the tab body. */
96 public function render_tab() {
97 charitable_admin_view( 'reports/site-analysis' );
98 }
99
100 /**
101 * Delete the cached report so the next tab view recomputes (config changed or cache cleared).
102 *
103 * Deliberately does NOT touch LAST_RUN_OPTION. Invalidating the report is the point here;
104 * resetting the refresh rate limit is not, and used to be a side effect.
105 */
106 public function flush_cache() {
107 delete_transient( self::CACHE_TRANSIENT );
108 }
109
110 /** Timestamp of the last successful API call, or 0 if it has never run. */
111 private function last_run_time() {
112 return (int) get_option( self::LAST_RUN_OPTION, 0 );
113 }
114
115 /** Enqueue the tool's assets on the Reports > Site Analysis tab. */
116 public function enqueue_assets( $hook ) {
117 $screen = get_current_screen();
118 if ( is_null( $screen ) || 'charitable_page_charitable-reports' !== $screen->id ) {
119 return;
120 }
121
122 // Green "New" badge on the Site Analysis nav tab, shown on every Reports tab (mirrors the Pro badges).
123 wp_register_style( 'charitable-site-analysis-nav', false );
124 wp_enqueue_style( 'charitable-site-analysis-nav' );
125 wp_add_inline_style(
126 'charitable-site-analysis-nav',
127 'body.charitable_page_charitable-reports .nav-tab.site-analysis::after{content:"New";background-color:#5AA152;padding:3px 7px;font-size:11px;line-height:11px;text-transform:uppercase;color:#fff;font-weight:600;margin-left:5px;margin-right:5px;margin-top:0;position:relative;top:-7px;}'
128 );
129
130 $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
131 if ( 'site-analysis' !== $tab ) {
132 return;
133 }
134 $assets = charitable()->get_path( 'assets', false );
135 $ver = charitable()->get_version();
136 // Font Awesome (bundled) powers the category icons the JS renders for config-fix recommendations.
137 wp_enqueue_style( 'charitable-font-awesome', charitable()->get_path( 'directory', false ) . 'assets/lib/font-awesome/font-awesome.min.css', array(), '4.7.0' );
138 wp_enqueue_style( 'charitable-site-analysis', $assets . 'css/admin/charitable-site-analysis.css', array( 'charitable-font-awesome' ), $ver );
139 wp_enqueue_script( 'charitable-site-analysis', $assets . 'js/admin/charitable-site-analysis.js', array( 'jquery' ), $ver, true );
140
141 // Surface the last cached report so the JS can render it immediately on load (no re-run needed).
142 $cache = get_transient( self::CACHE_TRANSIENT );
143 $cached_result = ( is_array( $cache ) && isset( $cache['result'] ) ) ? $cache['result'] : null;
144 $cached_ago = ( is_array( $cache ) && ! empty( $cache['time'] ) )
145 /* translators: %s: human-readable time difference, e.g. "2 hours". */
146 ? sprintf( __( 'Last analyzed %s ago', 'charitable' ), human_time_diff( (int) $cache['time'] ) )
147 : '';
148
149 // Manual refresh is limited to once per cooldown window; tell the JS whether it's allowed yet
150 // (and, if not, how long until it is) so it can disable the link. The server enforces this too.
151 // Measured from LAST_RUN_OPTION rather than the cache's own timestamp: the cache is deleted on
152 // every config change and by "Clear cache", which used to silently reset this limit and leave
153 // the UI offering a live Refresh link seconds after an analysis ran.
154 $last_run = $this->last_run_time();
155 $refresh_ok = ( 0 === $last_run ) || ( time() - $last_run ) >= self::REFRESH_COOLDOWN;
156 $refresh_wait = $refresh_ok ? '' : human_time_diff( time(), $last_run + self::REFRESH_COOLDOWN );
157
158 wp_localize_script(
159 'charitable-site-analysis',
160 'charitable_site_analysis',
161 array(
162 'ajax_url' => admin_url( 'admin-ajax.php' ),
163 'nonce' => wp_create_nonce( 'charitable_site_analysis' ),
164 'upgrade_url' => function_exists( 'charitable_pro_upgrade_url' ) ? charitable_pro_upgrade_url( 'site-analysis', '' ) : 'https://wpcharitable.com/lite-upgrade/',
165 // Config-fix recommendations open the relevant in-plugin screen instead of external docs.
166 'internal' => array(
167 'test-mode-on' => admin_url( 'admin.php?page=charitable-settings&tab=gateways' ),
168 'no-gateway' => admin_url( 'admin.php?page=charitable-settings&tab=gateways' ),
169 'single-gateway' => admin_url( 'admin.php?page=charitable-settings&tab=gateways' ),
170 'recurring-no-capable-gateway' => admin_url( 'admin.php?page=charitable-settings&tab=gateways' ),
171 'offline-off' => admin_url( 'admin.php?page=charitable-settings&tab=gateways' ),
172 'receipt-email-off' => admin_url( 'admin.php?page=charitable-settings&tab=emails' ),
173 'campaigns-no-goal' => admin_url( 'edit.php?post_type=campaign' ),
174 'no-published-campaign' => admin_url( 'admin.php?page=charitable-campaign-builder&view=template' ),
175 'spam-off-with-donations' => admin_url( 'admin.php?page=charitable-settings&tab=security' ),
176 ),
177 // Growth/how-to recs have no in-plugin toggle. Override the engine's generic
178 // /documentation/ CTA with a specific article (keyed by the engine's check id).
179 // Recs left out here keep the engine's own CTA URL.
180 'docs' => array(
181 'no-donation-yet' => 'https://www.wpcharitable.com/documentation/promote-your-campaign/',
182 'low-avg-gift' => 'https://www.wpcharitable.com/documentation/set-suggested-donation-amounts/',
183 'stalled-momentum' => 'https://www.wpcharitable.com/documentation/re-engage-your-donors/',
184 'Offline Donations' => 'https://www.wpcharitable.com/documentation/setting-up-offline-donations/',
185 ),
186 // Addon recommendations show their official extension icon (bundled locally); other recs
187 // fall back to a category icon in the JS. Keyed by the engine's rec featureId.
188 'rec_icons' => array(
189 'recurring' => esc_url_raw( $assets . 'images/addons/addon-icon-recurring-donations.png' ),
190 'ambassadors' => esc_url_raw( $assets . 'images/addons/addon-icon-ambassadors.png' ),
191 'fee-relief' => esc_url_raw( $assets . 'images/addons/addon-icon-fee-relief.png' ),
192 'stripe' => esc_url_raw( $assets . 'images/addons/addon-icon-stripe.png' ),
193 'newsletter' => esc_url_raw( $assets . 'images/addons/addon-icon-newsletter-connect.png' ),
194 'pdf-receipts' => esc_url_raw( $assets . 'images/addons/addon-icon-pdf-receipts.png' ),
195 'annual-receipts' => esc_url_raw( $assets . 'images/addons/addon-icon-annual-receipts.png' ),
196 'anonymous' => esc_url_raw( $assets . 'images/addons/addon-icon-anonymous-donations.png' ),
197 'gift-aid' => esc_url_raw( $assets . 'images/addons/addon-icon-gift-aid.png' ),
198 'donor-comments' => esc_url_raw( $assets . 'images/addons/addon-icon-donor-comments.png' ),
199 'geolocation' => esc_url_raw( $assets . 'images/addons/addon-icon-geolocation.png' ),
200 'videos' => esc_url_raw( $assets . 'images/addons/addon-icon-videos.png' ),
201 ),
202 'cached' => $cached_result,
203 'cached_ago' => $cached_ago,
204 'refresh_ok' => $refresh_ok,
205 'refresh_wait' => $refresh_wait,
206 'i18n' => array(
207 'running' => __( 'Analyzing your site...', 'charitable' ),
208 'error' => __( 'Sorry, the analysis could not be completed. Please try again.', 'charitable' ),
209 'refresh' => __( 'Refresh', 'charitable' ),
210 /* translators: %s: human-readable time until refresh is available, e.g. "20 hours". */
211 'refresh_wait' => __( 'You can refresh again in %s', 'charitable' ),
212 'refresh_soon' => __( 'You can refresh again tomorrow', 'charitable' ),
213 ),
214 )
215 );
216 }
217
218 /** AJAX: serve cached or call the recommendations API with the per-site token. */
219 public function ajax_run() {
220 if ( ! check_ajax_referer( 'charitable_site_analysis', 'nonce', false ) || ! current_user_can( 'manage_charitable_settings' ) ) {
221 wp_send_json_error( array( 'message' => __( 'Permission denied.', 'charitable' ) ) );
222 }
223
224 $consent = isset( $_POST['consent'] ) && '1' === (string) $_POST['consent']; // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
225 $payload = $this->gather_config( $consent );
226 $hash = $this->payload_hash( $payload );
227 $force = ! empty( $_POST['refresh'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
228 $cache = get_transient( self::CACHE_TRANSIENT );
229
230 /*
231 * Rate-limit manual refresh: only honor a forced re-fetch once the cooldown has elapsed since
232 * the last successful API call. Within the window, ignore the force and serve the cache
233 * (no network, no quota). This is the authoritative check for the *forced refresh* path - the
234 * button is also disabled client-side, but that is only UX.
235 *
236 * Read from LAST_RUN_OPTION, not from $cache['time']: the cooldown must outlive the cache it
237 * limits. See LAST_RUN_OPTION.
238 *
239 * Note this gates the forced path only. A changed payload hash, or an absent cache, still
240 * reaches the network on the next run - deliberately, because a config change must be
241 * reflected in the score. The floor on that path is the server's own per-token daily quota,
242 * which returns 429 and is surfaced as the "analysis limit" message below.
243 */
244 $last_run = $this->last_run_time();
245
246 if ( $force && $last_run > 0 && ( time() - $last_run ) < self::REFRESH_COOLDOWN ) {
247 $force = false;
248 }
249
250 // 1) Plugin-side cache: unchanged config + fresh + not a forced refresh -> no network, no quota.
251 if ( ! $force && is_array( $cache ) && isset( $cache['payload_hash'], $cache['result'] ) && $cache['payload_hash'] === $hash ) {
252 wp_send_json_success( $cache['result'] );
253 }
254
255 // 2) Ensure a per-site token (lazy register on first run).
256 $token = $this->get_token();
257 if ( '' === $token ) {
258 $token = $this->register();
259 // A rate-limited registration is not an outage; say so, so the user waits instead of
260 // retrying into the same limit. See register().
261 if ( 'quota' === $token ) {
262 $this->fail_with_cache( __( "You've reached the analysis limit for now. Please try again later.", 'charitable' ), $cache );
263 }
264 if ( '' === $token ) {
265 $this->fail_with_cache( __( 'The analysis service is unavailable right now. Please try again.', 'charitable' ), $cache );
266 }
267 }
268
269 // 3) Call recommendations; on 401 (revoked/unknown token) re-register once and retry.
270 $report = $this->request_recommendations( $payload, $token );
271 if ( 'reauth' === $report ) {
272 $token = $this->register();
273 // Handle the rate limit here too: 'quota' is a status, not a token, and passing it
274 // through as one would produce a second 401 and report a misleading generic error.
275 if ( 'quota' === $token ) {
276 $this->fail_with_cache( __( "You've reached the analysis limit for now. Please try again later.", 'charitable' ), $cache );
277 }
278 $report = ( '' === $token ) ? 'error' : $this->request_recommendations( $payload, $token );
279 }
280
281 if ( 'quota' === $report ) {
282 $this->fail_with_cache( __( "You've reached the analysis limit for now. Please try again later.", 'charitable' ), $cache );
283 }
284 if ( ! is_array( $report ) ) {
285 $this->fail_with_cache( __( 'The analysis service is unavailable right now. Please try again.', 'charitable' ), $cache );
286 }
287
288 // 4) Success: cache, stamp the rate limit, consent-gated usage check-in, return.
289 set_transient( self::CACHE_TRANSIENT, array( 'payload_hash' => $hash, 'result' => $report, 'time' => time() ), self::CACHE_TTL );
290 // Stamped outside the transient so flush_cache() cannot reset the cooldown. Only successes
291 // count, so a failed call stays retryable - which is what fail_with_cache()'s stale-report
292 // path and the JS retry link both assume.
293 update_option( self::LAST_RUN_OPTION, time(), false ); // autoload = no
294 $this->fire_usage_tracking( $consent );
295 wp_send_json_success( $report );
296 }
297
298 /** Build the no-PII config payload (see SPEC §4.1). */
299 public function gather_config( $consent = false ) {
300 $gateways = charitable_get_helper( 'gateways' );
301 $emails = charitable_get_helper( 'emails' );
302 $active = array_values( array_keys( (array) $gateways->get_active_gateways() ) );
303 $published = (int) wp_count_posts( 'campaign' )->publish;
304 $plan_id = $this->get_plan_id();
305
306 $payload = array(
307 'schema_version' => 1,
308 'site_id' => hash( 'sha256', home_url() . $this->analysis_salt() ),
309 'product' => array(
310 'is_pro' => (bool) charitable_is_pro(),
311 'plan_id' => $plan_id,
312 'plan_label' => function_exists( 'charitable_get_license_label_from_plan_id' ) ? (string) charitable_get_license_label_from_plan_id( $plan_id ) : 'Lite',
313 'lite_version' => (string) charitable()->get_version(),
314 'pro_version' => charitable_is_pro() ? (string) charitable()->get_version() : null,
315 ),
316 'config' => array(
317 'active_gateways' => $active,
318 'default_gateway' => $gateways->get_default_gateway() ? (string) $gateways->get_default_gateway() : null,
319 'active_addons' => $this->active_addons(),
320 'features' => $this->feature_flags(),
321 'in_test_mode' => (bool) $gateways->in_test_mode(),
322 'donation_receipt_enabled' => (bool) $emails->is_enabled_email( 'donation_receipt' ),
323 'offline_receipt_enabled' => (bool) $emails->is_enabled_email( 'offline_donation_receipt' ),
324 'has_recurring_capable_gateway' => $this->has_recurring_capable_gateway( $active ),
325 'all_campaigns_have_goals' => $this->all_campaigns_have_goals(),
326 'currency' => (string) charitable_get_option( 'currency', 'USD' ),
327 'has_seo_plugin' => $this->has_seo_plugin(),
328 ),
329 'counts' => array(
330 'campaigns_published' => $published,
331 'donations_completed' => $this->completed_donation_count(),
332 ),
333 'activation' => array(
334 'has_published_campaign' => $published > 0,
335 'has_received_donation' => $this->has_real_donation(),
336 ),
337 );
338
339 if ( $consent ) {
340 $fin = $this->financials();
341 if ( is_array( $fin ) ) {
342 $payload['financials'] = $fin;
343 }
344 }
345
346 return $payload;
347 }
348
349 /** No-PII aggregate financials for the score (only sent on consent). Mirrors Charitable_Tracking aggregates. */
350 private function financials() {
351 if ( ! class_exists( 'Charitable_Tracking' ) ) {
352 return null;
353 }
354 $t = Charitable_Tracking::get_instance();
355 $donation = method_exists( $t, 'get_donation_data' ) ? (array) $t->get_donation_data() : array();
356 $charitable = method_exists( $t, 'get_charitable_data' ) ? (array) $t->get_charitable_data() : array();
357 $total = isset( $donation['total_donations'] ) ? (float) $donation['total_donations'] : 0.0;
358 return array(
359 'total_raised' => $total,
360 'avg_gift' => isset( $charitable['average'] ) ? (float) $charitable['average'] : 0.0,
361 'recurring_share' => $this->recurring_revenue_share(),
362 'donations_30d' => isset( $donation['donations_30_days'] ) ? (float) $donation['donations_30_days'] : 0.0,
363 'donor_count' => isset( $charitable['donor_count'] ) ? (int) $charitable['donor_count'] : 0,
364 'country' => (string) charitable_get_option( 'country', '' ),
365 );
366 }
367
368 /**
369 * Share of completed donation revenue that came from a recurring donation, 0..1.
370 *
371 * REPLACES A BROKEN DERIVATION. Until 1.8.12.1 this came from Charitable_Tracking's
372 * total_recurring_amount divided by total_donations and clamped with min( 1, ... ). That was
373 * wrong twice over: total_recurring_amount summed the _first_donation post meta, which is a
374 * donation POST ID and not an amount (the Recurring addon writes it as
375 * update_post_meta( $recurring_id, '_first_donation', $donation_id )), and even correctly
376 * summed it would count active pledges rather than revenue received, so dividing it by
377 * lifetime revenue was a category error. The clamp hid it: the numerator was large enough
378 * that the ratio pinned to exactly 1, so almost every site reported 100% recurring. Measured
379 * on a real site: 1.2% actual vs 100% reported. A value of exactly 1 is that bug's signature.
380 *
381 * Both halves below come from the same rows of the same table, so this is a true ratio and
382 * needs no clamp. Recurring child donations are themselves completed `donation` posts, so
383 * they are already inside the denominator. See
384 * _claude/addons/charitable-pro/1.8.18/2026-08-18-recurring-share-and-total-recurring-amount-bugs.md
385 *
386 * @since 1.8.12.1
387 *
388 * @return float
389 */
390 private function recurring_revenue_share() {
391 global $wpdb;
392
393 // Match on parent.post_type rather than post_parent > 0: post_parent is a generic column,
394 // and this stays correct if anything else ever parents a donation.
395 //
396 // A direct query is deliberate: this is a two-column aggregate over the whole donation
397 // table, computed once per analysis run, and there is no Charitable API that returns both
398 // halves from the same rows - which is the entire point (see the docblock). It is not
399 // cached because a stale ratio would silently misreport the score.
400 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
401 $row = $wpdb->get_row(
402 "SELECT SUM(cd.amount) AS total,
403 SUM( CASE WHEN parent.ID IS NOT NULL THEN cd.amount ELSE 0 END ) AS recurring
404 FROM {$wpdb->posts} p
405 INNER JOIN {$wpdb->prefix}charitable_campaign_donations cd
406 ON p.ID = cd.donation_id
407 LEFT JOIN {$wpdb->posts} parent
408 ON p.post_parent = parent.ID
409 AND parent.post_type = 'recurring_donation'
410 WHERE p.post_type = 'donation'
411 AND p.post_status = 'charitable-completed'"
412 ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
413
414 $total = ( $row && $row->total ) ? (float) $row->total : 0.0;
415
416 if ( $total <= 0 ) {
417 return 0.0;
418 }
419
420 return round( (float) $row->recurring / $total, 4 );
421 }
422
423 /**
424 * Resolve the site_id hashing salt lazily. wp_salt() lives in pluggable.php and is NOT available
425 * at plugin-construct time, so this is computed here (admin-ajax time) instead of as a load-time
426 * constant. A host may still pre-define CHARITABLE_ANALYSIS_SALT (e.g. in wp-config.php).
427 */
428 private function analysis_salt() {
429 if ( defined( 'CHARITABLE_ANALYSIS_SALT' ) ) {
430 return CHARITABLE_ANALYSIS_SALT;
431 }
432 return (string) apply_filters( 'charitable_analysis_salt', wp_salt( 'nonce' ) );
433 }
434
435 /** The stored per-site token, or '' if none. */
436 private function get_token() {
437 return (string) get_option( self::TOKEN_OPTION, '' );
438 }
439
440 /**
441 * Register this site with NonprofitScore and persist the minted per-site token.
442 * Returns the token on success, 'quota' on 429, or '' on failure (the caller surfaces a
443 * graceful error).
444 *
445 * CHANGED IN 1.8.12.1. This used to fold every non-201 into '', so the caller reported "The
446 * analysis service is unavailable right now" for a 429 as well as for a real outage. The
447 * register endpoint is rate-limited **by IP** with a window observed at over 15 minutes, which
448 * means a shared host, a multisite, or an agency running several client sites can trip it
449 * through no fault of the site in front of the user - and the message then invites exactly the
450 * pointless retry that keeps it tripped.
451 *
452 * Mirrors request_recommendations()'s existing convention of returning a status string, so both
453 * network paths report a quota the same way. A minted token can never collide with 'quota';
454 * they are prefixed 'nst_'.
455 *
456 * @since 1.8.12
457 * @version 1.8.12.1
458 *
459 * @return string
460 */
461 private function register() {
462 $response = wp_remote_post(
463 CHARITABLE_ANALYSIS_REGISTER_URL,
464 array(
465 'timeout' => 15,
466 'headers' => array( 'Content-Type' => 'application/json' ),
467 'body' => wp_json_encode(
468 array(
469 'site_id' => hash( 'sha256', home_url() . $this->analysis_salt() ),
470 'home_url' => home_url(),
471 'plugin_version' => (string) charitable()->get_version(),
472 )
473 ),
474 )
475 );
476
477 if ( is_wp_error( $response ) ) {
478 return '';
479 }
480
481 $code = (int) wp_remote_retrieve_response_code( $response );
482
483 if ( 429 === $code ) {
484 return 'quota';
485 }
486
487 if ( 201 !== $code ) {
488 return '';
489 }
490
491 $data = json_decode( wp_remote_retrieve_body( $response ), true );
492 $token = ( is_array( $data ) && ! empty( $data['token'] ) ) ? (string) $data['token'] : '';
493 if ( '' !== $token ) {
494 update_option( self::TOKEN_OPTION, $token, false ); // autoload = no
495 }
496 return $token;
497 }
498
499 /** Stable hash of the no-PII payload, for plugin-side caching. */
500 private function payload_hash( array $payload ) {
501 return hash( 'sha256', wp_json_encode( $payload ) );
502 }
503
504 /**
505 * POST the payload to the recommendations endpoint with the per-site token.
506 * Returns the decoded report array on success, or a status string:
507 * 'reauth' (401 - token revoked/unknown) | 'quota' (429) | 'error' (anything else).
508 */
509 private function request_recommendations( array $payload, $token ) {
510 $response = wp_remote_post(
511 CHARITABLE_ANALYSIS_API_URL,
512 array(
513 'timeout' => 15,
514 'headers' => array(
515 'Authorization' => 'Bearer ' . $token,
516 'Content-Type' => 'application/json',
517 ),
518 'body' => wp_json_encode( $payload ),
519 )
520 );
521
522 if ( is_wp_error( $response ) ) {
523 return 'error';
524 }
525 $code = (int) wp_remote_retrieve_response_code( $response );
526 if ( 401 === $code ) {
527 return 'reauth';
528 }
529 if ( 429 === $code ) {
530 return 'quota';
531 }
532 if ( 200 !== $code ) {
533 return 'error';
534 }
535 $report = json_decode( wp_remote_retrieve_body( $response ), true );
536 if ( ! is_array( $report ) || ( empty( $report['recommendations'] ) && empty( $report['recommendedPlan'] ) ) ) {
537 return 'error';
538 }
539 return $report;
540 }
541
542 /** Send a JSON error; attach a stale cached result (if any) so the UI can still show something. */
543 private function fail_with_cache( $message, $cache ) {
544 $payload = array( 'message' => $message );
545 if ( is_array( $cache ) && isset( $cache['result'] ) ) {
546 $payload['stale'] = $cache['result'];
547 }
548 wp_send_json_error( $payload );
549 }
550
551 /** License plan_id (0=Lite); Lite has no direct accessor. */
552 private function get_plan_id() {
553 $settings = get_option( 'charitable_settings' );
554 return ( ! empty( $settings['licenses']['charitable-v2']['plan_id'] ) ) ? (int) $settings['licenses']['charitable-v2']['plan_id'] : 0;
555 }
556
557 /** Active Charitable addons among the known slugs. */
558 private function active_addons() {
559 if ( ! function_exists( 'is_plugin_active' ) ) {
560 require_once ABSPATH . 'wp-admin/includes/plugin.php';
561 }
562 $active = array();
563 foreach ( self::ADDON_SLUGS as $slug ) {
564 if ( is_plugin_active( $slug . '/' . $slug . '.php' ) ) {
565 $active[] = $slug;
566 }
567 }
568 return $active;
569 }
570
571 /**
572 * Whether any well-known SEO plugin is active. The engine uses this to offer a score-neutral
573 * SEO cross-sell only when no SEO plugin is present, so a site already running one is never
574 * nudged. We report a boolean, never the plugin list, to keep the payload PII-free.
575 *
576 * @return bool
577 */
578 private function has_seo_plugin() {
579 if ( ! function_exists( 'is_plugin_active' ) ) {
580 require_once ABSPATH . 'wp-admin/includes/plugin.php';
581 }
582 $seo_plugins = array(
583 'all-in-one-seo-pack/all_in_one_seo_pack.php', // AIOSEO (free).
584 'all-in-one-seo-pack-pro/all_in_one_seo_pack.php', // AIOSEO (pro).
585 'wordpress-seo/wp-seo.php', // Yoast SEO (free).
586 'wordpress-seo-premium/wp-seo-premium.php', // Yoast SEO Premium.
587 'seo-by-rank-math/rank-math.php', // Rank Math (free).
588 'seo-by-rank-math-pro/rank-math-pro.php', // Rank Math Pro.
589 'wp-seopress/seopress.php', // SEOPress (free).
590 'wp-seopress-pro/seopress-pro.php', // SEOPress Pro.
591 'autodescription/autodescription.php', // The SEO Framework.
592 );
593 foreach ( $seo_plugins as $plugin ) {
594 if ( is_plugin_active( $plugin ) ) {
595 return true;
596 }
597 }
598 return false;
599 }
600
601 /**
602 * Feature on/off flags the engine reads to tell "installed-but-off" (enable) from "active"
603 * (suppress). The Pro-only addons (recurring, fee relief, donor comments) are absent on Lite,
604 * so they stay false and the Pro port computes their real state. Spam protection is NOT Pro-only:
605 * Lite ships CAPTCHA-based protection (Settings > Security), so it reflects the real toggle here.
606 */
607 private function feature_flags() {
608 return array(
609 'recurring_enabled' => false,
610 'fee_relief_enabled' => false,
611 // "On" when a CAPTCHA provider is selected; mirrors Charitable_Captcha::is_active().
612 'spam_protection_enabled' => ( 'disabled' !== charitable_get_option( 'captcha_provider', 'disabled' ) ),
613 'donor_comments_enabled' => false,
614 );
615 }
616
617 /** True if any active gateway supports recurring. */
618 private function has_recurring_capable_gateway( array $active_ids ) {
619 $gateways = charitable_get_helper( 'gateways' );
620 foreach ( $active_ids as $id ) {
621 $class = $gateways->get_gateway( $id );
622 if ( is_string( $class ) && class_exists( $class ) ) {
623 $obj = new $class();
624 if ( method_exists( $obj, 'supports' ) && $obj->supports( 'recurring' ) ) {
625 return true;
626 }
627 }
628 }
629 return false;
630 }
631
632 /** True if every published campaign has a goal (true when there are none). */
633 private function all_campaigns_have_goals() {
634 $ids = get_posts(
635 array(
636 'post_type' => 'campaign',
637 'post_status' => 'publish',
638 'fields' => 'ids',
639 'posts_per_page' => 200,
640 'no_found_rows' => true,
641 )
642 );
643 foreach ( $ids as $id ) {
644 $campaign = charitable_get_campaign( $id );
645 if ( $campaign && method_exists( $campaign, 'has_goal' ) && ! $campaign->has_goal() ) {
646 return false;
647 }
648 }
649 return true;
650 }
651
652 /** Count completed donations. */
653 private function completed_donation_count() {
654 $counts = wp_count_posts( 'donation' );
655 return isset( $counts->{'charitable-completed'} ) ? (int) $counts->{'charitable-completed'} : 0;
656 }
657
658 /** True if there's at least one real (non-test, > 1) completed donation. Computed locally. */
659 private function has_real_donation() {
660 $ids = get_posts(
661 array(
662 'post_type' => 'donation',
663 'post_status' => 'charitable-completed',
664 'fields' => 'ids',
665 'posts_per_page' => 25,
666 'no_found_rows' => true,
667 )
668 );
669 foreach ( $ids as $id ) {
670 $donation = charitable_get_donation( $id );
671 if ( ! $donation ) {
672 continue;
673 }
674 $is_test = method_exists( $donation, 'get_test_mode' ) ? (int) $donation->get_test_mode( false ) : (int) get_post_meta( $id, 'test_mode', true );
675 $amount = method_exists( $donation, 'get_total_donation_amount' ) ? (float) $donation->get_total_donation_amount() : 0.0;
676 if ( ! $is_test && $amount > 1 ) {
677 return true;
678 }
679 }
680 return false;
681 }
682
683 /**
684 * Decide which usage-tracking check-in to fire (pure; unit-testable).
685 *
686 * CHANGED IN 1.8.12.1. This used to return 'financial' when the user DECLINED the in-tool
687 * toggle and usage tracking was off, and the caller then invoked
688 * send_tracking_checkin( true, false ) - where the first argument is $override, which skips
689 * the consent gate outright:
690 *
691 * if ( ! $this->tracking_allowed() && ! $override ) {
692 * return false;
693 * }
694 *
695 * So declining still transmitted: a persistent sha256 site AID from
696 * charitable_site_tracking_aid, campaign counts, campaign data, donation data and recurring
697 * data. On Lite the licence key resolves to '', so unlike Pro the payload is not directly
698 * identifying - but the AID is stable, so it still links a site's history, and it was a
699 * decline being ignored either way.
700 *
701 * This was Lite's behaviour from its first commit (334730a553) rather than a regression -
702 * dd0c57264f only changed the tracking-ON case from 'none' to 'both' - so the intent was
703 * presumably "anonymous aggregates are fine". The premise does not hold: the user declined.
704 *
705 * Declining now sends nothing. The report itself is unaffected: this runs after the report is
706 * built, and the score comes from the separately consent-gated NonprofitScore payload, so with
707 * the toggle off the analysis and score still work in full.
708 *
709 * 'financial' is gone rather than left unreachable, so no dead branch remains below.
710 *
711 * The Advanced > Misc "Usage Tracking" permission is now the ONLY basis for transmitting
712 * anything, in Lite and Pro alike. In-tool consent is not an independent basis: ticking the
713 * toggle turns that permission ON (see enable_usage_tracking()), and the caller applies it
714 * before this runs, so by the time we get here $tracking_enabled already reflects it. That is
715 * what makes "nothing is submitted while usage tracking is off" true rather than aspirational,
716 * and it means a filter-based force-disable still wins over a ticked toggle.
717 *
718 * @since 1.8.12
719 * @version 1.8.12.1
720 *
721 * @param bool $tracking_enabled Whether usage-tracking permission is granted, read through
722 * the charitable_usage_tracking filter.
723 * @param bool $consent Whether the user ticked the in-tool toggle. Retained because
724 * this is a public, unit-tested contract; not decisive, since
725 * the caller has already converted it into the permission.
726 * @return string 'none' | 'both'
727 */
728 public static function decide_tracking_action( $tracking_enabled, $consent ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- $consent stays in the signature because this is a public, unit-tested contract; it is no longer decisive. See above.
729 // Fire an immediate full check-in on submit. The sender's once-per-week throttle prevents a
730 // double-send and stamps charitable_usage_tracking_last_checkin, so the weekly cron simply
731 // resumes from here.
732 return $tracking_enabled ? 'both' : 'none';
733 }
734
735 /**
736 * Grant usage-tracking permission persistently, because the user ticked the in-tool toggle.
737 *
738 * Ticking the Site Analysis toggle turns the Advanced > Misc "Usage Tracking" setting ON, in
739 * both Lite and Pro. Lite has no other mechanism for enabling it outside the settings screen,
740 * so routing consent through the setting is what lets one rule cover both products.
741 *
742 * ONE-WAY BY DESIGN. This never writes false: unticking the toggle must not turn off tracking
743 * that is already on. In practice the toggle is not even rendered once tracking is on (see
744 * views/reports/site-analysis.php), so there is nothing to untick - but the guarantee is
745 * enforced here rather than left to the view.
746 *
747 * @since 1.8.12.1
748 *
749 * @return void
750 */
751 private function enable_usage_tracking() {
752 if ( function_exists( 'charitable_update_usage_tracking_setting' ) ) {
753 charitable_update_usage_tracking_setting( true );
754 return;
755 }
756
757 // Same inline write Charitable_Upgrade uses, for contexts where that admin-only helper is
758 // not loaded. Keeps the charitable_settings mirror in sync.
759 update_option( 'charitable_usage_tracking', 1 );
760 $settings = (array) get_option( 'charitable_settings', array() );
761 $settings['charitable_usage_tracking'] = true;
762 update_option( 'charitable_settings', $settings );
763 }
764
765 /**
766 * Fire the appropriate one-time check-in, best-effort.
767 *
768 * As of 1.8.12.1 this DOES flip the Advanced setting - but only ever on, and only when the
769 * user ticked the toggle. See enable_usage_tracking().
770 */
771 private function fire_usage_tracking( $consent ) {
772 // Ticking the toggle grants permission persistently, so record it before deciding anything.
773 // Reads the raw setting, not the filtered value: a filter that force-enables is somebody
774 // else's runtime opinion and should not be persisted as the user's choice.
775 if ( $consent && ! charitable_get_usage_tracking_setting() ) {
776 $this->enable_usage_tracking();
777 }
778
779 // Resolve "may we transmit?" the same way core does, so a filter-based force-disable still
780 // wins over a ticked toggle, and a force-enable can't cause a double-send with the weekly cron.
781 $tracking_enabled = (bool) apply_filters( 'charitable_usage_tracking', charitable_get_usage_tracking_setting() );
782 $action = self::decide_tracking_action( $tracking_enabled, (bool) $consent );
783
784 // 'none' means the user declined and nothing may be transmitted. This guard existed before
785 // 1.8.12.1 but was unreachable, because decide_tracking_action() never returned 'none'.
786 if ( 'none' === $action || ! class_exists( 'Charitable_Tracking' ) ) {
787 return;
788 }
789
790 try {
791 // Consented (either by the Advanced setting or the in-tool toggle), so send both
792 // endpoints: the financial capture and the opt-in usage check-in.
793 Charitable_Tracking::get_instance()->send_checkins( true, false );
794 } catch ( \Throwable $e ) {
795 // Best-effort: a tracking failure must never affect the analysis.
796 }
797 }
798 }
799
800 endif;
801