| 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 |
|