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 / tracking / class-charitable-tracking.php

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

1,762 lines 64.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Tracking functions for reporting plugin usage (optin) and anonymous tracking (optin) to the Charitable site for users
4 *
5 * @access public
6 * @package Charitable
7 * @subpackage Admin
8 * @copyright Copyright (c) 2024, David Bisset
9 * @since 1.8.4
10 * @version 1.8.4.5
11 */
12
13 // Exit if accessed directly.
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit;
16 }
17
18 if ( ! class_exists( 'Charitable_Tracking' ) ) {
19 /**
20 * Usage tracking
21 */
22 class Charitable_Tracking {
23
24 /**
25 * The single instance of this class.
26 *
27 * @var Charitable_Tracking|null
28 */
29 private static $instance = null;
30
31 /**
32 * Class init function.
33 */
34 public function __construct() {
35 }
36
37 /**
38 * Starts the process of sending in the optin usage checking.
39 *
40 * @since 1.8.4.2
41 *
42 * @param boolean $override Override usage_optin_allowed.
43 * @param boolean $ignore_last_checkin Ignore last checkin flag.
44 *
45 * @return void
46 */
47 public function send_checkins( $override = false, $ignore_last_checkin = false ) {
48 $this->send_optin_usage_checkin( $override, $ignore_last_checkin );
49 $this->send_tracking_checkin( $override, $ignore_last_checkin );
50 }
51
52 /**
53 * Manually sends tracking data if optin is enabled. Testing purposes only.
54 *
55 * @since 1.8.4
56 *
57 * @return void
58 */
59 public function test_checkin() {
60 if ( is_admin() && current_user_can( 'manage_options' ) && defined( 'CHARITABLE_DEBUG_USAGE' ) && CHARITABLE_DEBUG_USAGE ) { // phpcs:ignore
61 // detect the query string in the admin url.
62 $send_checkin = isset( $_GET['charitable_send_checkin'] ) ? sanitize_text_field( wp_unslash( $_GET['charitable_send_checkin'] ) ) : false; // phpcs:ignore
63 if ( charitable_is_debug() ) {
64 error_log( 'charitable test checkin triggered' ); // phpcs:ignore
65 }
66 if ( 'usage' === $send_checkin ) {
67 if ( charitable_is_debug() ) {
68 error_log( 'charitable test checkin was run for usage' ); // phpcs:ignore
69 }
70 $this->send_optin_usage_checkin( true, true );
71 } elseif ( 'tracking' === $send_checkin ) {
72 if ( charitable_is_debug() ) {
73 error_log( 'charitable test checkin was run for tracking' ); // phpcs:ignore
74 }
75 $this->send_tracking_checkin( true, true );
76 } elseif ( 'both' === $send_checkin ) {
77 if ( charitable_is_debug() ) {
78 error_log( 'charitable test checkin was run for both' ); // phpcs:ignore
79 }
80 $this->send_optin_usage_checkin( true, true );
81 $this->send_tracking_checkin( true, true );
82 }
83 }
84 }
85
86 /**
87 * Fetch tracking data.
88 *
89 * @since 1.8.4
90 * @version 1.8.4.5
91 *
92 * @return array $data Tracked data.
93 */
94 private function get_optin_data() {
95
96 global $wpdb;
97
98 $data = array();
99
100 // get charitable settings.
101 $charitable_settings = get_option( 'charitable_settings' );
102
103 // Retrieve current theme info.
104 $theme_data = wp_get_theme();
105
106 $count_b = 1;
107 if ( is_multisite() ) {
108 if ( function_exists( 'get_blog_count' ) ) {
109 $count_b = get_blog_count();
110 } else {
111 $count_b = '0';
112 }
113 }
114
115 $charitable_object = charitable();
116
117 // Settings values reported individually. The raw licences array is
118 // deliberately NOT among them any more; see the flat licence key below.
119 $country = ! empty( $charitable_settings['country'] ) ? $charitable_settings['country'] : '';
120 $currency = ! empty( $charitable_settings['currency'] ) ? $charitable_settings['currency'] : '';
121 $default_gateway = ! empty( $charitable_settings['default_gateway'] ) ? $charitable_settings['default_gateway'] : '';
122
123 $data['php_version'] = phpversion();
124 $data['wpchar_version'] = $charitable_object !== null ? charitable()->get_version() : '';
125 $data['wp_version'] = get_bloginfo( 'version' );
126 $data['servertype'] = isset( $_SERVER['SERVER_SOFTWARE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) ) : '';
127 $data['upgraded_from'] = get_option( 'charitable_upgraded_from', '' );
128 $data['activated'] = get_option( 'charitable_activated', '' );
129 $data['activated_datetime'] = get_option( 'wpcharitable_activated_datetime', '' );
130 $data['first_campaign'] = get_option( 'charitable_first_campaign', '' );
131 $data['first_donation'] = get_option( 'charitable_first_donation', '' );
132 $data['multisite'] = is_multisite();
133 $data['url'] = home_url();
134 $data['id'] = $this->get_site_aid();
135 $data['themename'] = $theme_data->Name; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
136 $data['themeversion'] = $theme_data->Version; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
137 $data['email'] = get_bloginfo( 'admin_email' );
138 // The raw licences array was removed from this payload in 1.8.17.4. It
139 // shipped the site's ENTIRE
140 // licence array, keys included, on every check-in. Nothing stored it: the
141 // usage receiver's only use was a legacy fallback reading
142 // licenses['charitable-v2']['license'] when the flat key is absent, and
143 // there is no column for it; the tracking receiver never referenced it.
144 //
145 // The flat key below is a strict SUPERSET of that fallback. Both read the
146 // same charitable_settings['licenses'], but get_payload_license_key()
147 // prefers charitable-v2 and then falls back to the first available
148 // licence, whereas the nested path only ever read charitable-v2.
149 //
150 // The receiver keeps its fallback: sites on 1.8.17.2 and earlier still
151 // send the array and still need it.
152 $data['license_key'] = $this->get_payload_license_key();
153 $data['country'] = $country;
154 $data['currency'] = $currency;
155 $data['default_gateway'] = $default_gateway;
156 $data['gateways_connected'] = $this->get_gateways_connected( $charitable_settings );
157 $data['gateways_enabled'] = $this->get_gateways_enabled( $charitable_settings );
158 $data['environment_type'] = $this->get_environment_type();
159 $data['onboarding_status'] = $this->get_onboarding_status();
160 $data['pro'] = function_exists( 'charitable_is_pro' ) ? charitable_is_pro() : false;
161 $data['sites'] = $count_b;
162 $data['usagetracking'] = get_option( 'charitable_usage_tracking_config', false );
163 $data['usercount'] = function_exists( 'count_users' ) ? count_users()['total_users'] : '0';
164 $data['timezoneoffset'] = gmdate( 'P' );
165 $data['wc_active'] = $this->check_if_wc_active();
166 $data['usages'] = array(
167 'blocks' => $this->block_count_summation(),
168 'wp_pages' => $this->get_wp_pages(),
169 'wp_posts' => $this->get_wp_posts(),
170 );
171
172 // Add recommendation tracking data
173 $data['recommended_plugins_viewed'] = get_option( 'charitable_recommended_plugins_viewed', array() );
174 $data['recommended_plugins_clicked'] = get_option( 'charitable_recommended_plugins_clicked', array() );
175 $data['recommended_plugins_installed'] = get_option( 'charitable_recommended_plugins_installed', array() );
176 $data['recommended_plugins_activated'] = get_option( 'charitable_recommended_plugins_activated', array() );
177 $data['dashboard_enhance_section_views'] = get_option( 'charitable_dashboard_enhance_views', 0 );
178
179 /*
180 * Added BEFORE the two plugin lists, deliberately.
181 *
182 * The body is form-encoded and the receiver runs
183 * max_input_vars = 1000 (measured), which PHP enforces by silently
184 * DROPPING variables past the limit rather than erroring — so
185 * whatever is last in this array is first to be lost. active_plugins
186 * and inactive_plugins are the only unbounded part of this payload,
187 * and they are also the least valuable, so the bounded fields belong
188 * in front of them.
189 *
190 * @since 1.8.13
191 */
192 $data['abilities'] = $this->get_abilities_data();
193
194 /*
195 * Mirrors the cross-brand AM promo-attribution loop. The options are
196 * unprefixed; we claim only installs whose source names Charitable.
197 *
198 * @since 1.8.13
199 */
200 $promo = array( 'wpconsent', 'sugar-calendar', 'duplicator', 'uncannyautomator', 'activelayer', 'wpvibe' );
201
202 foreach ( $promo as $plugin ) {
203 $source = (string) get_option( $plugin . '_source', '' );
204 $date = (int) get_option( $plugin . '_date', 0 );
205
206 if ( $date && false !== strpos( $source, 'Charitable' ) ) {
207 $data[ 'charitable_' . $plugin . '_date' ] = $date;
208 }
209 }
210
211 // Retrieve current plugin information.
212 if ( ! function_exists( 'get_plugins' ) ) {
213 include ABSPATH . '/wp-admin/includes/plugin.php';
214 }
215
216 $plugins = array_keys( get_plugins() );
217 $active_plugins = get_option( 'active_plugins', array() );
218
219 foreach ( $plugins as $key => $plugin ) {
220 if ( in_array( $plugin, $active_plugins, true ) ) {
221 // Remove active plugins from list so we can show active and inactive separately.
222 unset( $plugins[ $key ] );
223 }
224 }
225
226 $data['active_plugins'] = $active_plugins;
227 $data['inactive_plugins'] = $plugins;
228 $data['locale'] = get_locale();
229
230 return $data;
231 }
232
233 /**
234 * Abilities / AI-assistant usage for the opt-in check-in.
235 *
236 * Fifteen fields: six integers, five booleans, two dates, one client
237 * slug and one JSON map. Nothing else, and in particular nothing that
238 * identifies a campaign, a donation, a donor or an administrator — the
239 * whole payload is counts drawn from Charitable_Abilities_Usage, whose
240 * stored keys are a closed vocabulary of registered ability slugs.
241 *
242 * A `scopes` map was briefly the sixteenth, reporting which of three
243 * write permissions a site had granted. Write access went back to one
244 * switch before 1.8.13 shipped, and `write` already carries that.
245 *
246 * PUBLIC on purpose, like get_charitable_data() and get_donation_data().
247 * get_optin_data() is private, so a block built inline there cannot be
248 * asserted by a test, and this is the payload where an untested field is
249 * most expensive: the usage receiver reads named keys one at a time, so a
250 * field that is misnamed or the wrong shape is not an error anywhere — it
251 * is silently dropped on arrival and cannot be backfilled.
252 *
253 * ⚠️ TWO FIELDS ARE DELIBERATELY ABSENT and must not be added here.
254 * `rest_visible` (the count of abilities a REST client can actually see)
255 * and application-password availability are both measured by seams that
256 * are invalid without an authenticated HTTPS request: the first needs
257 * rest_do_request(), which returns an empty list with no user, and the
258 * second consults is_ssl(), which is false with no request. THIS RUNS IN
259 * CRON. Both would report "0 visible" and "not available" across the
260 * entire fleet of healthy sites — and both already did exactly that under
261 * WP-CLI, which is why the two System Info lines that report them refuse
262 * to measure rather than answer. A false catastrophe in a dataset nobody
263 * thinks to distrust is worse than a missing column.
264 *
265 * `observation_complete` IS NEW HERE, added in Lite 1.8.13 and not present
266 * in Pro's version of this method. It reads
267 * Charitable_Abilities_Usage::get_counts() rather than ::get() for exactly
268 * that field: wp_after_execute_ability is core @since 6.9.0 but
269 * wp_ability_invoked is @since 7.1.0, so on WordPress 6.9 and 7.0.x
270 * `calls`/`total` read 0 while `ok` can still climb. Without this flag a
271 * quiet WP-7.0 site and an unused site are indistinguishable on the
272 * receiving end.
273 *
274 * @since 1.8.13
275 *
276 * @return array<string,int|string>
277 */
278 public function get_abilities_data() {
279
280 $usage = class_exists( 'Charitable_Abilities_Usage' )
281 ? Charitable_Abilities_Usage::get_counts()
282 : array(
283 'calls' => array(),
284 'total' => 0,
285 'ok' => 0,
286 'denied' => 0,
287 'refused' => 0,
288 'failed' => 0,
289 'first_at' => '',
290 'last_at' => '',
291 'observation_complete' => 0,
292 );
293
294 /*
295 * Integers, not booleans, for the flags. On the wire the two are
296 * identical — http_build_query() encodes true/false as 1/0 — and the
297 * receiver parses with filter_var( FILTER_VALIDATE_BOOLEAN ), which
298 * takes either. Casting here is so the shape is unambiguous to a test
299 * and to anyone reading a logged payload, where a bare `false` would
300 * render as an empty string.
301 */
302 return array(
303 'api' => (int) function_exists( 'wp_register_ability' ),
304 'registered' => $this->count_registered_abilities(),
305 'write' => (int) ( class_exists( 'Charitable_Abilities_Registrar' ) && Charitable_Abilities_Registrar::write_enabled() ),
306 'client' => $this->get_mcp_client_slug(),
307 'logs' => (int) ( ! class_exists( 'Charitable_Log' ) || Charitable_Log::is_enabled() ),
308
309 /*
310 * Passed the empty string as the second argument because the
311 * filter is per-ability and there is no ability in hand here.
312 * This reports the site's DEFAULT answer, which is the only
313 * meaningful one fleet-wide.
314 */
315 'read_logs' => (int) apply_filters( 'charitable_abilities_log_reads', false, '' ),
316
317 /*
318 * A JSON STRING, never a nested array, and this is load-bearing
319 * twice over. A nested abilities[calls][list-campaigns]=41 shape
320 * invites the `[]=x` payload that has twice forced a guard into
321 * the receiver (a (string) cast on an array raises a warning that
322 * Laravel rethrows as an ErrorException, 500ing an otherwise good
323 * check-in). And the body is form-encoded, so a 24-slug map is 24
324 * more PHP input variables against a max_input_vars budget this
325 * payload already spends heavily on the full active and inactive
326 * plugin lists. One string is one variable.
327 *
328 * Cast to object so an EMPTY map encodes as `{}` rather than
329 * `[]`. wp_json_encode( array() ) emits a JSON array, so without
330 * the cast this field is an object on a site that has used the
331 * feature and an array on one that has not — and most sites have
332 * not, so the array form would be the common case for a field
333 * documented as a map. The receiver tolerates both, but a type
334 * that changes with the data is the kind of thing that breaks a
335 * consumer written against the common case years later.
336 */
337 'calls' => (string) wp_json_encode( (object) (array) $usage['calls'] ),
338
339 /*
340 * Sent rather than recovered by summing `calls` receiver-side.
341 * get_counts() already computes it, so this passes along a value
342 * that exists; without it the headline usage number is only
343 * reachable by summing a JSON blob in every query, against no
344 * index.
345 */
346 'total' => (int) $usage['total'],
347 'ok' => (int) $usage['ok'],
348 'denied' => (int) $usage['denied'],
349 'refused' => (int) $usage['refused'],
350 'failed' => (int) $usage['failed'],
351 'first_at' => (string) $usage['first_at'],
352 'last_at' => (string) $usage['last_at'],
353 'observation_complete' => (int) $usage['observation_complete'],
354 );
355 }
356
357 /**
358 * How many Charitable abilities are registered.
359 *
360 * A weak signal deliberately kept: one smallint that reveals a build or
361 * a partial deploy where some abilities failed to register at all.
362 *
363 * ⚠️ NOTE THE SIDE EFFECT, which is the reason this is one line of code
364 * and a paragraph of comment. wp_get_abilities() reaches
365 * WP_Abilities_Registry::get_instance(), which fires
366 * `wp_abilities_api_init` lazily on first use — so asking for this count
367 * inside the check-in FORCES the whole abilities surface to register:
368 * four registrar objects and twenty-four wp_register_ability() calls
369 * with their full input and output schemas, in a weekly cron request
370 * that would otherwise never touch any of it.
371 *
372 * Accepted, because it is once a week and the alternative is worse. A
373 * hardcoded 24 drifts the moment an ability is added or removed, and
374 * this field exists precisely to catch a build where the real number is
375 * not 24 — a count that cannot disagree with reality reports nothing.
376 *
377 * Do NOT reach for a cached value here either: a cache populated during
378 * a normal admin request would report yesterday's build in today's
379 * check-in, which is the same failure in slower motion.
380 *
381 * @since 1.8.13
382 *
383 * @return int
384 */
385 private function count_registered_abilities() {
386
387 if ( ! function_exists( 'wp_get_abilities' ) ) {
388 return 0;
389 }
390
391 $count = 0;
392
393 foreach ( wp_get_abilities() as $ability ) {
394 if ( is_object( $ability ) && method_exists( $ability, 'get_name' ) && 0 === strpos( $ability->get_name(), 'charitable/' ) ) {
395 ++$count;
396 }
397 }
398
399 return $count;
400 }
401
402 /**
403 * The slug of the MCP client this site is running, or '' for none.
404 *
405 * Detection shares Charitable_Abilities' basename map with the System
406 * Info screen, so the two cannot disagree about whether a client is
407 * present. Almost every reporting site is expected to say `wpvibe`; the
408 * value of the field is the day one does not.
409 *
410 * First match wins where two are somehow active. A list would be a
411 * second JSON column to express a state that should not occur, and the
412 * question this answers is "which client", not "how many".
413 *
414 * DELIBERATELY NOT FILTERED, and the asymmetry is worth stating because
415 * it otherwise reads as an oversight: `charitable_abilities_mcp_clients`
416 * changes what System Info displays but has no effect here. That filter
417 * takes and returns human-readable LABELS for a pasted report, so it
418 * cannot supply the slug this field needs, and quietly running labels
419 * through here would write values the receiver sanitises away. A site
420 * using that filter to declare a bespoke client therefore shows it on
421 * screen and reports no client in telemetry, which is the right answer
422 * of the two available: the slug vocabulary is shared with the receiver,
423 * so it is not a site's to extend.
424 *
425 * @since 1.8.13
426 *
427 * @return string
428 */
429 private function get_mcp_client_slug() {
430
431 if ( ! class_exists( 'Charitable_Abilities' ) ) {
432 return '';
433 }
434
435 foreach ( Charitable_Abilities::get_active_mcp_clients() as $entry ) {
436 return (string) $entry['slug'];
437 }
438
439 return '';
440 }
441
442
443 /**
444 * Fetch tracking data.
445 *
446 * @since 1.8.4
447 *
448 * @return array $data Tracked data.
449 */
450 private function get_tracking_data() {
451
452 global $wpdb;
453
454 $data = array();
455
456 $data['id'] = $this->get_site_aid();
457 $data['license_key'] = $this->get_payload_license_key();
458
459 // get the total number of campaigns.
460 $data['campaign_counts'] = (array) wp_count_posts( 'campaign' );
461
462 // campaign data.
463 $data['campaign_data'] = (array) $this->get_charitable_data();
464
465 // donation data.
466 $data['donation_data'] = $this->get_donation_data();
467 $data['recurring_donation_data'] = $this->get_recurring_donation_data();
468
469 return $data;
470 }
471
472 /**
473 * Get Charitable data.
474 *
475 * @return array $data Charitable data.
476 */
477 public function get_charitable_data() {
478
479 global $wpdb;
480
481 $sql = "SELECT
482 SUM(subquery.total_amount) AS grand_total_amount,
483 AVG(subquery.total_amount) AS grand_average_amount,
484 SUM(subquery.total_count_donations) AS grand_total_count_donations
485 FROM (
486 SELECT
487 SUM(ccd.amount) AS total_amount,
488 COUNT(ccd.donation_id) AS total_count_donations,
489 cd.donor_id
490 FROM {$wpdb->prefix}charitable_donors cd
491 JOIN {$wpdb->prefix}charitable_campaign_donations ccd ON cd.donor_id = ccd.donor_id
492 GROUP BY cd.donor_id
493 ) AS subquery;
494 ";
495
496 $results = $wpdb->get_results( $sql, ARRAY_A ); // phpcs:ignore
497
498 $data = [];
499
500 if ( ! empty( $results ) ) {
501 // Ensure each amount is converted to a string before sanitization.
502 $data['total'] = 0 !== $results[0]['grand_total_amount']
503 ? charitable_sanitize_amount( (string) ( $results[0]['grand_total_amount'] ), true )
504 : 0;
505
506 $data['average'] = 0 !== $results[0]['grand_average_amount']
507 ? charitable_sanitize_amount( (string) ( $results[0]['grand_average_amount'] ), true )
508 : 0;
509 $data['donor_count'] = 0 !== $results[0]['grand_total_count_donations'] ? intval( $results[0]['grand_total_count_donations'] ) : 0;
510 }
511
512 if ( ! empty( $data ) ) {
513 return $data;
514 }
515 }
516
517 /**
518 * Get donation related data.
519 *
520 * @since 1.8.4
521 *
522 * @return array $data Donation data.
523 */
524 public function get_donation_data() {
525
526 global $wpdb;
527
528 $defaults = array(
529 'start_date' => false,
530 'end_date' => false,
531 'post_status' => 'charitable-completed',
532 'campaign_id' => false,
533 'category_id' => false,
534 );
535
536 // Extract individual variables from defaults array.
537 $start_date = $defaults['start_date'];
538 $end_date = $defaults['end_date'];
539 $post_status = $defaults['post_status'];
540 $campaign_id = $defaults['campaign_id'];
541 $category_id = $defaults['category_id'];
542
543 $where_sql = array();
544 $where_sql[] = 'WHERE 1=1';
545 $where_sql[] = 'p.post_type = "%s"';
546 $where_sql[] = 'p.post_status = "' . $post_status . '"';
547 $where_sql[] = 'pm1.meta_key = "donation_gateway"';
548
549 // remove all empty values from the array.
550 $where_sql = array_filter( $where_sql );
551 $where_args = implode( ' AND ', $where_sql );
552
553 $left_join = array();
554 $left_join[] = $wpdb->prefix . 'charitable_campaign_donations cd ON p.ID = cd.donation_id';
555 $left_join[] = $wpdb->prefix . 'charitable_donors cdonors ON cd.donor_id = cdonors.donor_id';
556 $left_join[] = $wpdb->prefix . 'postmeta pm1 ON p.ID = pm1.post_id';
557
558 // remove all empty values from the array.
559 $left_join = array_filter( $left_join );
560 $left_join_args = 'LEFT JOIN ' . implode( ' LEFT JOIN ', $left_join );
561
562 $sql = "SELECT SUM(cd.amount) AS total_amount,
563 cd.donation_id AS donation_id,
564 cd.campaign_id AS campaign_id,
565 cd.donor_id AS donor_id,
566 cd.amount AS amount,
567 pm1.meta_value AS payment_gateway,
568 p.post_date AS post_date,
569 p.post_date_gmt AS post_date_gmt,
570 p.post_status AS donation_status,
571 p.post_type AS donation_type,
572 p.post_parent AS donation_parent_id
573 FROM $wpdb->posts p
574 " . $left_join_args . '
575 ' . $where_args . '
576 GROUP BY p.ID';
577
578 $donations = $wpdb->get_results( // phpcs:ignore
579 $wpdb->prepare(
580 $sql, // phpcs:ignore
581 'donation'
582 )
583 );
584
585 return $this->summarise_donations( is_array( $donations ) ? $donations : array() );
586 }
587
588 /**
589 * Aggregate donation rows into the payload's donation_data block.
590 *
591 * Extracted from get_donation_data() so it is testable without a $wpdb
592 * stub; that method now does the SQL and delegates here.
593 *
594 * COUNTS ARE NEW IN 1.8.17.4 AND THE REASON THIS EXISTS.
595 * total_donations / donations_7_days / donations_30_days are MONEY
596 * AMOUNTS, but the Active User Intensity bands are defined in donation
597 * COUNTS ("1 to 2 donations", "3 to 10", "10+"). With only amounts
598 * available, the classifier's IntensityResolver compared a cloud count
599 * against a telemetry dollar figure via max(), so a single $50 donation
600 * would classify a customer as Engaged-Heavy. The amount keys keep their
601 * existing meaning; the count keys are additive.
602 *
603 * Window comparisons deliberately use post_date, matching the pre-existing
604 * amount windows, so a count and its amount always describe the same set
605 * of donations. last_donation_at uses post_date_gmt instead, because a
606 * timestamp leaving the site has to be unambiguous; post_date is
607 * site-local. That inconsistency is inherited, not introduced, and is not
608 * silently "fixed" here because changing the existing windows would move
609 * numbers the receiver has been storing for two years.
610 *
611 * @since 1.8.12
612 *
613 * @param array $donations Rows from get_donation_data()'s query.
614 * @return array
615 */
616 private function summarise_donations( array $donations ) {
617
618 $total_donations = 0;
619 $donations_7_days = 0;
620 $donations_30_days = 0;
621 $count_total = 0;
622 $count_7_days = 0;
623 $count_30_days = 0;
624 $last_donation_at = null;
625 $donations_payment_gateway = array();
626
627 $cutoff_7 = strtotime( '-7 days' );
628 $cutoff_30 = strtotime( '-30 days' );
629
630 foreach ( $donations as $donation ) {
631
632 $amount = isset( $donation->total_amount ) ? $donation->total_amount : 0;
633 $when = isset( $donation->post_date ) ? strtotime( (string) $donation->post_date ) : false;
634
635 $total_donations += $amount;
636 ++$count_total;
637
638 if ( false !== $when && $when > $cutoff_7 ) {
639 $donations_7_days += $amount;
640 ++$count_7_days;
641 }
642
643 if ( false !== $when && $when > $cutoff_30 ) {
644 $donations_30_days += $amount;
645 ++$count_30_days;
646 }
647
648 // GMT for the outbound timestamp; see the note above.
649 $gmt = isset( $donation->post_date_gmt ) ? strtotime( (string) $donation->post_date_gmt . ' UTC' ) : false;
650
651 if ( false !== $gmt && ( null === $last_donation_at || $gmt > $last_donation_at ) ) {
652 $last_donation_at = $gmt;
653 }
654
655 $gateway = isset( $donation->payment_gateway ) ? (string) $donation->payment_gateway : '';
656
657 if ( ! isset( $donations_payment_gateway[ $gateway ] ) ) {
658 $donations_payment_gateway[ $gateway ] = 0;
659 }
660
661 $donations_payment_gateway[ $gateway ] += $amount;
662 }
663
664 return array(
665 'total_donations' => $total_donations,
666 'donations_7_days' => $donations_7_days,
667 'donations_30_days' => $donations_30_days,
668 'donations_payment_gateway' => $donations_payment_gateway,
669 // New in 1.8.17.4.
670 'donation_count_total' => $count_total,
671 'donation_count_7_days' => $count_7_days,
672 'donation_count_30_days' => $count_30_days,
673 'last_donation_at' => $last_donation_at,
674 );
675 }
676
677 /**
678 * Get recurring donation related data.
679 *
680 * @since 1.8.4
681 *
682 * @return array $data Recurring donation data.
683 */
684 public function get_recurring_donation_data() {
685
686 global $wpdb;
687
688 // if the recurring donations plugin is not active, return an empty array.
689 if ( ! class_exists( 'Charitable_Recurring' ) ) {
690 return array();
691 }
692
693 $where_sql = array();
694 $where_sql[] = 'WHERE 1=1';
695 $where_sql[] = 'p.post_type = "%s"';
696 $where_sql[] = 'p.post_status = "charitable-active"';
697
698 // remove all empty values from the array.
699 $where_sql = array_filter( $where_sql );
700 $where_args = implode( ' AND ', $where_sql );
701
702 /*
703 * Each meta_key test belongs in its join's ON clause, NOT in the WHERE clause. A WHERE test
704 * against a left-joined row is an inner join, so until 1.8.12.1 any recurring donation missing
705 * even one of these four meta keys was dropped from the result set entirely - which silently
706 * undercounted recurring_donations_count and every amount derived from these rows. A
707 * subscription with no _expiration_date is perfectly normal, and used to vanish here.
708 */
709 $left_join = array();
710 $left_join[] = $wpdb->prefix . 'postmeta pm1 ON p.ID = pm1.post_id AND pm1.meta_key = "donation_gateway"';
711 $left_join[] = $wpdb->prefix . 'postmeta pm2 ON p.ID = pm2.post_id AND pm2.meta_key = "donation_period"';
712 $left_join[] = $wpdb->prefix . 'postmeta pm4 ON p.ID = pm4.post_id AND pm4.meta_key = "_expiration_date"';
713 $left_join = array_filter( $left_join );
714 $left_join_args = 'LEFT JOIN ' . implode( ' LEFT JOIN ', $left_join );
715
716 /*
717 * No SUM() here any more. Until 1.8.12.1 this selected `SUM(pm4.meta_value) AS
718 * total_recurring_amount` - i.e. it summed the _expiration_date meta, a DATE, which cast to
719 * '2026-06-15 23:59:59' -> 2026. Nothing ever read that alias (the aggregator re-sums in PHP),
720 * and with GROUP BY p.ID it was not even an aggregate. It is gone because it was the single
721 * most misleading line in this function: it implied the recurring amount came from a date.
722 *
723 * pm3 (_first_donation) is no longer selected either. It is a donation POST ID, not an amount
724 * - the Recurring addon writes it as update_post_meta( $recurring_id, '_first_donation',
725 * $donation_id ) - and it was previously summed as money. See
726 * summarise_recurring_donations() for what replaced it.
727 */
728 $sql = "SELECT pm1.meta_value AS payment_gateway,
729 pm2.meta_value AS donation_period,
730 pm4.meta_value AS expiration_date,
731 p.post_date AS post_date,
732 p.post_date_gmt AS post_date_gmt,
733 p.post_status AS donation_status,
734 p.post_parent AS donation_parent_id
735 FROM $wpdb->posts p
736 " . $left_join_args . '
737 ' . $where_args . '
738 GROUP BY p.ID';
739
740 $recurring_donations = $wpdb->get_results( // phpcs:ignore
741 $wpdb->prepare(
742 $sql, // phpcs:ignore
743 'recurring_donation'
744 )
745 );
746
747 return $this->summarise_recurring_donations( is_array( $recurring_donations ) ? $recurring_donations : array() );
748 }
749
750 /**
751 * Aggregate recurring rows into the payload's recurring_donation_data block.
752 *
753 * Extracted from get_recurring_donation_data() so it is testable without a
754 * $wpdb stub.
755 *
756 * recurring_donations_count is the point. Stage 5 Power leans on recurring
757 * activity, but the only recurring signal in the payload was
758 * total_recurring_amount. On the cloud side subscription_analyses covers just
759 * 193 of 2,547 accounts and RefreshSubscriptionAnalysisCommand never writes
760 * active_subscriptions at all, so there was no reliable recurring COUNT
761 * anywhere.
762 *
763 * CHANGED IN 1.8.12.1. This function used to report five money figures, all of
764 * which were sums of donation POST IDs, because it read the _first_donation
765 * post meta as an amount. It is not an amount: the Recurring addon writes it
766 * as update_post_meta( $recurring_id, '_first_donation', $donation_id ), and
767 * its accessor is get_first_donation_id(). Measured on a real site, a
768 * subscription whose first donation was 3.68 contributed 309, and one whose
769 * first-donation post had been deleted still contributed its stale ID.
770 *
771 * Removed: total_recurring_amount, recurring_donations_7_days,
772 * recurring_donations_30_days. There is no correct value to put in them here -
773 * the pledge amount lives only in the serialized `campaigns` post meta,
774 * recurring donations have no charitable_campaign_donations row, and some
775 * active subscriptions record no amount at all.
776 *
777 * Changed: recurring_donations_payment_gateway and recurring_donations_period
778 * are now COUNTS of active subscriptions rather than (bogus) amounts.
779 *
780 * Do not confuse _first_donation with the charitable_first_donation option,
781 * which IS a date and is reported separately as $data['first_donation'].
782 *
783 * @since 1.8.12
784 * @version 1.8.12.1
785 *
786 * @param array $recurring_donations Rows from the recurring query.
787 * @return array
788 */
789 private function summarise_recurring_donations( array $recurring_donations ) {
790
791 $recurring_count = 0;
792 $recurring_donations_payment_gateway = array();
793 $recurring_donations_period = array();
794
795 foreach ( $recurring_donations as $donation ) {
796
797 ++$recurring_count;
798
799 // Counts, not amounts. This query cannot produce a reliable recurring money figure: the
800 // pledge amount lives only inside the serialized `campaigns` post meta, recurring
801 // donations have no charitable_campaign_donations row, and some active subscriptions
802 // record no amount at all. A count of active subscriptions per gateway and per period is
803 // the useful thing it CAN compute correctly, so that is what these now report.
804 $gateway = isset( $donation->payment_gateway ) ? (string) $donation->payment_gateway : '';
805
806 if ( ! isset( $recurring_donations_payment_gateway[ $gateway ] ) ) {
807 $recurring_donations_payment_gateway[ $gateway ] = 0;
808 }
809
810 ++$recurring_donations_payment_gateway[ $gateway ];
811
812 $period = isset( $donation->donation_period ) ? (string) $donation->donation_period : '';
813
814 if ( ! isset( $recurring_donations_period[ $period ] ) ) {
815 $recurring_donations_period[ $period ] = 0;
816 }
817
818 ++$recurring_donations_period[ $period ];
819 }
820
821 return array(
822 // Counts of ACTIVE subscriptions, keyed by gateway / by period. Amounts before 1.8.12.1.
823 'recurring_donations_payment_gateway' => $recurring_donations_payment_gateway,
824 'recurring_donations_period' => $recurring_donations_period,
825 'recurring_donations_count' => $recurring_count,
826 );
827 }
828
829 /**
830 * Send optin usage data.
831 *
832 * @since 1.8.4
833 *
834 * @param boolean $override Override usage_optin_allowed.
835 * @param boolean $ignore_last_checkin Ignore last checkin flag.
836 * @return boolean
837 */
838 public function send_optin_usage_checkin( $override = false, $ignore_last_checkin = false ) {
839
840 if ( defined( 'CHARITABLE_DEBUG_USAGE' ) && CHARITABLE_DEBUG_USAGE ) { // phpcs:disable
841 // phpcs:disable
842 if ( charitable_is_debug() ) {
843 error_log('send_optin_usage_checkin');
844 }
845 // phpcs:enable
846 }
847
848 if ( ! $this->usage_optin_allowed() && ! $override ) {
849 if ( defined( 'CHARITABLE_DEBUG_USAGE' ) && CHARITABLE_DEBUG_USAGE ) { // phpcs:disable
850 // phpcs:disable
851 if ( charitable_is_debug() ) {
852 error_log('charitable usage tracking not allowed');
853 }
854 // phpcs:enable
855 }
856 return false;
857 }
858
859 // Send a maximum of once per week.
860 $last_send = get_option( 'charitable_usage_tracking_last_checkin' );
861 if ( $this->checkin_is_throttled( $last_send ) && ! $ignore_last_checkin ) {
862 if ( defined( 'CHARITABLE_DEBUG_USAGE' ) && CHARITABLE_DEBUG_USAGE ) { // phpcs:disable
863 // phpcs:disable
864 if ( charitable_is_debug() ) {
865 error_log('charitable usage tracking not allowed because of last checkin');
866 }
867 // phpcs:enable
868 }
869 return false;
870 }
871
872 $charitable_object = charitable();
873 $charitable_version = $charitable_object !== null ? charitable()->get_version() : '';
874
875 $request = wp_remote_post(
876 'https://usage.wpcharitable.com/capture',
877 array(
878 'method' => 'POST',
879 'timeout' => 15,
880 'redirection' => 5,
881 'httpversion' => '1.1',
882 'blocking' => true,
883 'sslverify' => false,
884 'body' => $this->get_optin_data(),
885 'user-agent' => 'CH/' . $charitable_version . '; ' . get_bloginfo( 'url' ),
886 )
887 );
888
889 if ( defined( 'CHARITABLE_DEBUG_USAGE' ) && CHARITABLE_DEBUG_USAGE ) { // phpcs:disable
890 // phpcs:disable
891 if ( charitable_is_debug() ) {
892 error_log( 'send_optin_usage_checkin' );
893 error_log( print_r( $request, true ) );
894 error_log( print_r( $this->get_optin_data(), true ) );
895 }
896 // phpcs:enable
897 }
898
899 return $this->handle_checkin_result( 'usage', $request, 'charitable_usage_tracking_last_checkin' );
900 }
901
902 /**
903 * Send tracking data.
904 *
905 * @since 1.8.4
906 *
907 * @param boolean $override Override usage_optin_allowed.
908 * @param boolean $ignore_last_checkin Ignore last checkin flag.
909 * @return boolean
910 */
911 public function send_tracking_checkin( $override = false, $ignore_last_checkin = false ) {
912
913 if ( defined( 'CHARITABLE_DEBUG_USAGE' ) && CHARITABLE_DEBUG_USAGE ) { // phpcs:disable
914 // phpcs:disable
915 if ( charitable_is_debug() ) {
916 error_log('send_tracking_checkin');
917 }
918 // phpcs:enable
919 }
920
921 if ( defined( 'CHARITABLE_DEBUG_USAGE' ) && CHARITABLE_DEBUG_USAGE ) { // phpcs:disable
922 // phpcs:disable
923 if ( charitable_is_debug() ) {
924 error_log( 'send_checkin' );
925 error_log( $override );
926 error_log( $ignore_last_checkin );
927 error_log( 'tracking allowed' );
928 error_log( $this->usage_optin_allowed() );
929 }
930 // phpcs:enable
931 }
932
933 if ( ! $this->tracking_allowed() && ! $override ) {
934 if ( defined( 'CHARITABLE_DEBUG_USAGE' ) && CHARITABLE_DEBUG_USAGE ) { // phpcs:disable
935 // phpcs:disable
936 if ( charitable_is_debug() ) {
937 error_log( 'charitable tracking not allowed' );
938 }
939 // phpcs:enable
940 }
941 return false;
942 }
943
944 // Send a maximum of once per week.
945 $last_send = get_option( 'charitable_tracking_last_checkin' );
946 if ( $this->checkin_is_throttled( $last_send ) && ! $ignore_last_checkin ) {
947 if ( defined( 'CHARITABLE_DEBUG_USAGE' ) && CHARITABLE_DEBUG_USAGE ) { // phpcs:disable
948 // phpcs:disable
949 if ( charitable_is_debug() ) {
950 error_log('charitable tracking not allowed because of last checkin');
951 }
952 // phpcs:enable
953 }
954 return false;
955 }
956
957 $charitable_object = charitable();
958 $charitable_version = $charitable_object !== null ? charitable()->get_version() : '';
959
960 $request = wp_remote_post(
961 'https://tracking.wpcharitable.com/capture',
962 array(
963 'method' => 'POST',
964 'timeout' => 15,
965 'redirection' => 5,
966 'httpversion' => '1.1',
967 'blocking' => true,
968 'sslverify' => false,
969 'body' => $this->get_tracking_data(),
970 'user-agent' => 'GenericTrackingClient/1.0',
971 )
972 );
973
974 if ( defined( 'CHARITABLE_DEBUG_USAGE' ) && CHARITABLE_DEBUG_USAGE ) { // phpcs:disable
975 // phpcs:disable
976 if ( charitable_is_debug() ) {
977 error_log( print_r( $request, true ) );
978 error_log( print_r( $last_send, true ) );
979 error_log( print_r( $charitable_version, true ) );
980 error_log( print_r( $this->get_tracking_data(), true ) );
981 }
982 }
983
984 // This endpoint's OWN throttle option, the same one read above. It used
985 // to read this one but write the usage endpoint's instead, so the option
986 // it tested was never written and this endpoint was never throttled at
987 // all. The two need separate throttles, so the write was corrected to
988 // match the read rather than the reverse. Passing the name through
989 // explicitly is what makes that mismatch impossible to reintroduce.
990 //
991 // The other endpoint's option name is deliberately NOT written out
992 // anywhere in this method: a source-level guard in
993 // Test_Charitable_Tracking_Throttle asserts it never appears here, and a
994 // comment quoting it would fail that guard.
995 return $this->handle_checkin_result( 'tracking', $request, 'charitable_tracking_last_checkin' );
996 }
997
998 /**
999 * Check if optin usage tracking is allowed.
1000 *
1001 * @since 1.8.4
1002 *
1003 * @return boolean
1004 */
1005 private function usage_optin_allowed() {
1006
1007 // DISABLE means disable. This branch used to `return true`, i.e. ALLOWED,
1008 // so a site owner defining this constant to opt out got tracking
1009 // force-ENABLED, and because the check precedes the consent read below it
1010 // bypassed the opt-out entirely. Harmless only because the weekly cron
1011 // never fired before 1.8.17.4; fixed in the release that makes it fire.
1012 //
1013 // Debug force-enable is unaffected: that path uses
1014 // send_checkins( $override = true ), not this constant.
1015 if ( defined( 'CHARITABLE_DISABLE_OPTIN_USAGE' ) && CHARITABLE_DISABLE_OPTIN_USAGE ) {
1016 return false;
1017 }
1018
1019 return (bool) apply_filters( 'charitable_usage_tracking', $this->get_usage_tracking_setting() );
1020 }
1021
1022 /**
1023 * The site's declared environment type.
1024 *
1025 * Added in 1.8.17.4 so the Active User denominator can EXCLUDE non-production
1026 * sites. This matters more than it looks: usage_optin_allowed() has no
1027 * environment gate at all (only the tracking path has one, and it excludes
1028 * 'staging' alone), and `aid` lives in a WordPress option rather than being
1029 * derived from the URL. So a cloned staging or local site reports a row
1030 * carrying the PRODUCTION aid and licence key, which then joins to real
1031 * donation data and inflates every figure.
1032 *
1033 * Reported rather than acted on: this deliberately does NOT change who
1034 * sends. Gating sends here would silently shrink the reporting population
1035 * and confound the 1.8.17.2 adoption trend. The receiver and classifier can
1036 * filter once the data exists.
1037 *
1038 * @since 1.8.12
1039 *
1040 * @return string 'production', 'staging', 'development', 'local', or ''.
1041 */
1042 private function get_environment_type() {
1043
1044 if ( ! function_exists( 'wp_get_environment_type' ) ) {
1045 return '';
1046 }
1047
1048 return (string) wp_get_environment_type();
1049 }
1050
1051 /**
1052 * The onboarding checklist status.
1053 *
1054 * Separates "installed and abandoned" from "installed and mid-setup", which
1055 * is the distinction the Installed-over-30-days stuck flag needs to avoid
1056 * firing on people who are simply still working through setup.
1057 *
1058 * Reads the raw option DELIBERATELY rather than calling
1059 * Charitable_Checklist::is_checklist_completed(). That method routes through
1060 * get_checklist_data(), which WRITES the default schema when the option is
1061 * empty, and is_step_completed() takes $update_option = true by default. A
1062 * payload build must never mutate site state, least of all from a cron
1063 * request.
1064 *
1065 * @since 1.8.12
1066 *
1067 * @return string 'init', 'completed', 'skipped', or '' when unknown.
1068 */
1069 private function get_onboarding_status() {
1070
1071 $checklist = get_option( 'charitable_onboarding_checklist' );
1072
1073 if ( ! is_array( $checklist ) || ! isset( $checklist['status'] ) || ! is_scalar( $checklist['status'] ) ) {
1074 return '';
1075 }
1076
1077 return (string) $checklist['status'];
1078 }
1079
1080 /**
1081 * Whether a check-in is inside the once-per-week throttle window.
1082 *
1083 * @since 1.8.12
1084 *
1085 * @param mixed $last_send Stored last-check-in timestamp.
1086 * @return bool
1087 */
1088 private function checkin_is_throttled( $last_send ) {
1089 return is_numeric( $last_send ) && $last_send > strtotime( '-1 week' );
1090 }
1091
1092 /**
1093 * Timestamp to store after a FAILED send, so it retries in ~24 hours.
1094 *
1095 * Fixes a long-standing bug. Both send methods used to store
1096 * time() + DAY_IN_SECONDS here, with the comment "recheck in 24 hours".
1097 * But the throttle test is `$last_send > strtotime( '-1 week' )`, and a
1098 * timestamp 24 hours in the FUTURE satisfies that for EIGHT days. So a
1099 * single failed send suppressed retries for eight days, not one.
1100 *
1101 * Storing a timestamp that is already six days old makes the one-week
1102 * test start failing 24 hours from now, which is what was intended.
1103 *
1104 * @since 1.8.12
1105 *
1106 * @return int
1107 */
1108 private function get_retry_timestamp() {
1109 return time() - ( WEEK_IN_SECONDS - DAY_IN_SECONDS );
1110 }
1111
1112 /**
1113 * Apply the outcome of a check-in POST: throttle, record, and report.
1114 *
1115 * EXTRACTED so it can be tested. The two send methods each build a payload
1116 * that needs wp_count_posts(), $wpdb, get_plugins() and a theme object, so
1117 * they cannot be driven from the standalone suite; the result handling can.
1118 * Without this, checkin_failure_reason() and record_checkin_failure() were
1119 * unit-tested in isolation while NOTHING asserted that the send methods
1120 * actually called them. A mutation test proved it: reverting the usage-send
1121 * wiring left the whole suite green.
1122 *
1123 * Extracting it also removes a duplication that has already caused one bug.
1124 * The two endpoints had near-identical copies of this logic, and the
1125 * tracking copy READ charitable_tracking_last_checkin while WRITING
1126 * charitable_usage_tracking_last_checkin, so that endpoint was never
1127 * throttled at all. The option name is now a parameter, which makes that
1128 * class of mismatch impossible rather than merely fixed.
1129 *
1130 * On success: clear any recorded failure and book the next check-in a week
1131 * out. On failure: record why, and set the option to a timestamp that
1132 * expires in 24 hours. See get_retry_timestamp() -- the old value was 24
1133 * hours in the FUTURE, which suppressed retries for eight days.
1134 *
1135 * @since 1.8.12
1136 *
1137 * @param string $endpoint 'usage' or 'tracking'.
1138 * @param array|WP_Error $request The wp_remote_post() return value.
1139 * @param string $option_name The throttle option for this endpoint.
1140 * @return bool True when the check-in succeeded.
1141 */
1142 private function handle_checkin_result( $endpoint, $request, $option_name ) {
1143
1144 $failure = $this->checkin_failure_reason( $request );
1145
1146 if ( null === $failure ) {
1147 $this->clear_checkin_failure( $endpoint );
1148 update_option( $option_name, time() );
1149
1150 return true;
1151 }
1152
1153 $this->record_checkin_failure( $endpoint, $failure );
1154 update_option( $option_name, $this->get_retry_timestamp() );
1155
1156 return false;
1157 }
1158
1159 /**
1160 * Why a check-in failed, or null when it succeeded.
1161 *
1162 * Two distinct failure modes, both of which used to pass silently.
1163 *
1164 * TRANSPORT (WP_Error): DNS, timeout, or TLS verification. This is the
1165 * mode that has to be visible before `sslverify` can be turned on: a
1166 * verification failure returns WP_Error, the caller stores a retry
1167 * timestamp and returns false, and until now nothing recorded it. Turning
1168 * verification on blind would silently drop a biased subset of sites
1169 * (TLS-intercepting corporate networks and hosts) with no signal saying
1170 * who or why, on a project whose whole purpose is a defensible
1171 * denominator. The cURL code and message are what distinguish a
1172 * certificate problem from a timeout, so both are kept.
1173 *
1174 * HTTP STATUS: a non-2xx response. This is the more serious of the two and
1175 * was found while building the first. `! is_wp_error( $request )` is TRUE
1176 * for any completed round trip, including a 500, so a receiver error
1177 * stamped a SUCCESSFUL check-in and suppressed this site for a full week.
1178 * The usage receiver has a live 500 on any payload missing `usages`, so
1179 * this is not hypothetical. It mattered little while the weekly cron was
1180 * broken and almost nothing was sent; from 1.8.17.4 on it matters weekly.
1181 *
1182 * The returned string is deliberately short and bounded. It is stored in
1183 * an option and may later be reported, so it must never become an open
1184 * channel for arbitrary content. A transport message is a cURL string and
1185 * carries no part of the payload.
1186 *
1187 * @since 1.8.12
1188 *
1189 * @param array|WP_Error $request The wp_remote_post() return value.
1190 * @return string|null Null on success, otherwise a short reason.
1191 */
1192 private function checkin_failure_reason( $request ) {
1193
1194 if ( is_wp_error( $request ) ) {
1195 $reason = 'transport:' . $request->get_error_code();
1196 $detail = trim( preg_replace( '/\s+/', ' ', (string) $request->get_error_message() ) );
1197
1198 if ( '' === $detail ) {
1199 return $reason;
1200 }
1201
1202 return substr( $reason . ': ' . $detail, 0, 200 );
1203 }
1204
1205 $code = (int) wp_remote_retrieve_response_code( $request );
1206
1207 // 2xx only. A 3xx is not a success either: redirection is already
1208 // handled by wp_remote_post's own 'redirection' option, so a 301
1209 // arriving here means it was not followed.
1210 if ( $code < 200 || $code > 299 ) {
1211 return 'http:' . $code;
1212 }
1213
1214 return null;
1215 }
1216
1217 /**
1218 * Record a failed check-in so it is not lost.
1219 *
1220 * Stored per endpoint, because the two have separate throttles and fail
1221 * independently; one failing must not read as both failing.
1222 *
1223 * The count is the useful part. A site whose telemetry is permanently
1224 * broken retries daily, and "failed 40 times since 12 June" is actionable
1225 * where a boolean is not. A CHANGED reason restarts the count, since that
1226 * is a different problem rather than more of the same one.
1227 *
1228 * Written with autoload false: this is diagnostic data read on demand, not
1229 * something to load into memory on every request of every page view.
1230 *
1231 * NOT TRANSMITTED. This option stays on the site. Sending it would be a
1232 * new field in the telemetry payload, and no new category of collected
1233 * data goes in without a privacy review, so that is a separate decision.
1234 * The consequence, worth being explicit about: this gives a support agent
1235 * something to ask for, but it does not by itself let us measure the
1236 * TLS-failure rate across the install base.
1237 *
1238 * @since 1.8.12
1239 *
1240 * @param string $endpoint 'usage' or 'tracking'.
1241 * @param string $reason From checkin_failure_reason().
1242 * @return void
1243 */
1244 private function record_checkin_failure( $endpoint, $reason ) {
1245
1246 $failures = get_option( 'charitable_telemetry_send_failures', array() );
1247
1248 if ( ! is_array( $failures ) ) {
1249 $failures = array();
1250 }
1251
1252 $previous = isset( $failures[ $endpoint ] ) && is_array( $failures[ $endpoint ] )
1253 ? $failures[ $endpoint ]
1254 : array();
1255
1256 $same_reason = isset( $previous['reason'] ) && $previous['reason'] === $reason;
1257
1258 $failures[ $endpoint ] = array(
1259 'reason' => $reason,
1260 'first' => $same_reason && isset( $previous['first'] ) ? $previous['first'] : time(),
1261 'last' => time(),
1262 'count' => $same_reason && isset( $previous['count'] ) ? (int) $previous['count'] + 1 : 1,
1263 );
1264
1265 update_option( 'charitable_telemetry_send_failures', $failures, false );
1266
1267 // error_log() only when the site has asked for debug output. The option
1268 // above is the durable record and is always written; an unconditional
1269 // error_log() from the telemetry subsystem would write to the logs of
1270 // sites that may not even have consented to tracking, and would buy
1271 // nothing we do not already have. Logged on the FIRST occurrence of each
1272 // distinct reason only, so a permanently broken site does not add an
1273 // identical line every day forever.
1274 $debugging = ( defined( 'CHARITABLE_DEBUG_USAGE' ) && CHARITABLE_DEBUG_USAGE )
1275 || ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG );
1276
1277 if ( $debugging && ! $same_reason ) {
1278 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1279 error_log( sprintf( 'Charitable telemetry: %s check-in failed (%s)', $endpoint, $reason ) );
1280 }
1281 }
1282
1283 /**
1284 * Clear a recorded failure after a successful check-in.
1285 *
1286 * Only this endpoint's entry, so a recovered usage send does not hide a
1287 * still-failing tracking send.
1288 *
1289 * @since 1.8.12
1290 *
1291 * @param string $endpoint 'usage' or 'tracking'.
1292 * @return void
1293 */
1294 private function clear_checkin_failure( $endpoint ) {
1295
1296 $failures = get_option( 'charitable_telemetry_send_failures', array() );
1297
1298 if ( ! is_array( $failures ) || ! array_key_exists( $endpoint, $failures ) ) {
1299 return;
1300 }
1301
1302 unset( $failures[ $endpoint ] );
1303
1304 update_option( 'charitable_telemetry_send_failures', $failures, false );
1305 }
1306
1307 /**
1308 * Read the usage-tracking consent setting from any request context.
1309 *
1310 * charitable_get_usage_tracking_setting() lives in
1311 * includes/admin/charitable-core-admin-functions.php, which is required
1312 * only by Charitable_Admin::load_dependencies(). That runs only when
1313 * is_admin() is true, so the function does NOT exist during a
1314 * wp-cron.php request. Calling it unguarded from the cron path, as the
1315 * consent gates used to, is a fatal error.
1316 *
1317 * The fallback is a byte-for-byte copy of that function's body rather
1318 * than a guessed default, so consent semantics are identical in both
1319 * contexts. Lite is opt-in only, so erring permissive here would send
1320 * telemetry from sites that never agreed to it.
1321 *
1322 * @since 1.8.12
1323 *
1324 * @return int
1325 */
1326 private function get_usage_tracking_setting() {
1327
1328 if ( function_exists( 'charitable_get_usage_tracking_setting' ) ) {
1329 return charitable_get_usage_tracking_setting();
1330 }
1331
1332 return (int) get_option( 'charitable_usage_tracking', false );
1333 }
1334
1335 /**
1336 * Check if tracking is allowed.
1337 *
1338 * @since 1.8.4
1339 *
1340 * @return boolean
1341 */
1342 private function tracking_allowed() {
1343
1344 // DISABLE means disable. See usage_optin_allowed() for the full note. This
1345 // one was worse: returning true here also bypassed
1346 // environment_allows_tracking(), so it defeated the staging exclusion too.
1347 if ( defined( 'CHARITABLE_DISABLE_TRACKING' ) && CHARITABLE_DISABLE_TRACKING ) {
1348 return false;
1349 }
1350
1351 if ( ! $this->environment_allows_tracking() ) {
1352 return false;
1353 }
1354
1355 return (bool) apply_filters( 'charitable_usage_tracking', $this->get_usage_tracking_setting() );
1356 }
1357
1358 /**
1359 * Whether the environment allows sending telemetry data.
1360 *
1361 * @since 1.8.4
1362 *
1363 * @return bool
1364 */
1365 private function environment_allows_tracking() {
1366
1367 if ( function_exists( 'wp_get_environment_type' ) && 'staging' === wp_get_environment_type() ) {
1368 return false;
1369 }
1370
1371 return true;
1372 }
1373
1374 /**
1375 * Schedule send tracking data event.
1376 *
1377 * @since 1.8.4
1378 *
1379 * @return void
1380 */
1381 public function schedule_send() {
1382 if ( ! wp_next_scheduled( 'charitable_usage_tracking_cron' ) ) {
1383 $tracking = array();
1384 $tracking['day'] = wp_rand( 0, 6 );
1385 $tracking['hour'] = wp_rand( 0, 23 );
1386 $tracking['minute'] = wp_rand( 0, 59 );
1387 $tracking['second'] = wp_rand( 0, 59 );
1388 $tracking['offset'] = ( $tracking['day'] * DAY_IN_SECONDS ) +
1389 ( $tracking['hour'] * HOUR_IN_SECONDS ) +
1390 ( $tracking['minute'] * MINUTE_IN_SECONDS ) +
1391 $tracking['second'];
1392 // First send within 1 to 24 hours, NOT anchored to a Sunday.
1393 //
1394 // This used to be strtotime( 'next sunday' ) + $tracking['offset'],
1395 // where offset is itself up to ~7 days, so a new install waited
1396 // between 1 and 14 days for its first report.
1397 //
1398 // Load spreading is not lost: installs and opt-ins are already
1399 // scattered across the week, so anchoring the weekly recurrence
1400 // to that moment spreads slots at least as well as anchoring to
1401 // Sunday, and the 23-hour jitter window prevents a herd.
1402 //
1403 // day/hour/minute/second/offset are still computed and stored:
1404 // the whole $tracking array ships in the payload as
1405 // $data['usagetracking'] and the receiver json_encodes it, so
1406 // the shape must not change.
1407 $tracking['initsend'] = time() + wp_rand( HOUR_IN_SECONDS, DAY_IN_SECONDS );
1408
1409 wp_schedule_event( $tracking['initsend'], 'weekly', 'charitable_usage_tracking_cron' );
1410 update_option( 'charitable_usage_tracking_config', $tracking );
1411 }
1412 }
1413
1414 /**
1415 * Add schedules.
1416 *
1417 * @since 1.8.4
1418 *
1419 * @param array $schedules Available/current schedules.
1420 * @return array $schedules Schedules array.
1421 */
1422 public function add_schedules( $schedules = array() ) {
1423 // Adds once weekly to the existing schedules.
1424 $schedules['weekly'] = array(
1425 'interval' => 604800,
1426 'display' => __( 'Once Weekly', 'charitable' ),
1427 );
1428 return $schedules;
1429 }
1430
1431 /**
1432 * Get WP Posts count.
1433 *
1434 * @since 1.8.4
1435 *
1436 * @return array $wp_post_count WP Posts count.
1437 */
1438 public function get_wp_posts() {
1439 global $wpdb;
1440
1441 $wp_post_count = 0;
1442
1443 $results = $wpdb->get_var( // phpcs:ignore
1444 "SELECT COUNT(`ID`) `hits`
1445 FROM {$wpdb->posts}
1446 WHERE `post_type` = 'post' AND `post_status` = 'publish';"
1447 );
1448 if ( ! empty( $results ) ) {
1449 $wp_post_count = $results;
1450 }
1451
1452 return $wp_post_count;
1453 }
1454
1455 /**
1456 * Get WP Pages count.
1457 *
1458 * @since 1.8.4
1459 *
1460 * @return array $wp_pages_count WP Pages count.
1461 */
1462 public function get_wp_pages() {
1463 global $wpdb;
1464
1465 $wp_pages_count = 0;
1466
1467 $results = $wpdb->get_var( // phpcs:ignore
1468 "SELECT COUNT(`ID`) `hits`
1469 FROM {$wpdb->posts}
1470 WHERE `post_type` = 'page' AND `post_status` = 'publish';"
1471 );
1472 if ( ! empty( $results ) ) {
1473 $wp_pages_count = $results;
1474 }
1475
1476 return $wp_pages_count;
1477 }
1478
1479 /**
1480 * Insert time for first campaign.
1481 *
1482 * @since 1.8.4.5
1483 *
1484 * @param string $insert_or_update Insert or update.
1485 * @param int $campaign_id Campaign ID.
1486 * @param array $data Data.
1487 * @param object $object Object.
1488 *
1489 * @return void
1490 */
1491 public function insert_time_to_first_campaign( $insert_or_update, $campaign_id, $data, $object ) {
1492 global $wpdb;
1493
1494 // Only do this for inserts.
1495 if ( 'insert' !== $insert_or_update ) {
1496 return;
1497 }
1498
1499 $first_campaign = get_option( 'charitable_first_campaign' );
1500
1501 if ( ! $first_campaign ) {
1502 $first_campaign = time();
1503 update_option( 'charitable_first_campaign', $first_campaign );
1504 }
1505 }
1506
1507 /**
1508 * Insert time for first donation.
1509 *
1510 * @since 1.8.4.5
1511 *
1512 * @param int $donation_id Donation ID.
1513 * @param object $object Charitable_Donation_Processor Object.
1514 *
1515 * @return void
1516 */
1517 public function insert_time_to_first_donation( $donation_id, $object ) {
1518 global $wpdb;
1519
1520 $first_donation = get_option( 'charitable_first_donation' );
1521
1522 if ( ! $first_donation ) {
1523 $first_donation = time();
1524 update_option( 'charitable_first_donation', $first_donation );
1525 }
1526 }
1527
1528 /**
1529 * Get block count summation.
1530 *
1531 * @since 1.8.4
1532 *
1533 * @return array $blocks_usage_sum Block usage summation.
1534 */
1535 public function block_count_summation() {
1536 // Get all _wpchar_block_usage data.
1537 global $wpdb;
1538
1539 $tablename = $wpdb->prefix . 'postmeta';
1540 $sql = "SELECT meta_value FROM $tablename";
1541 $sql .= ' WHERE meta_key = %s';
1542 $safe_sql = $wpdb->prepare( $sql, '_wpchar_block_usage' ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1543 $results = $wpdb->get_results( $safe_sql ); // phpcs:ignore
1544
1545 $blocks_usage_sum = array();
1546
1547 // Sum all block usage data.
1548 if ( $results ) {
1549 foreach ( $results as $result ) {
1550 if ( $result->meta_value ) {
1551 $page_usage_data = maybe_unserialize( $result->meta_value );
1552 if ( is_array( $page_usage_data ) ) {
1553 foreach ( $page_usage_data as $type => $value ) {
1554 if ( array_key_exists( $type, $blocks_usage_sum ) ) {
1555 // If set.
1556 $blocks_usage_sum[ $type ] = array(
1557 'name' => $blocks_usage_sum[ $type ]['name'],
1558 'count' => $blocks_usage_sum[ $type ]['count'] + $value['count'], // Sum count.
1559 );
1560 }
1561
1562 if ( ! array_key_exists( $type, $blocks_usage_sum ) ) {
1563 // If block type is not set.
1564 $blocks_usage_sum[ $type ] = $value;
1565 }
1566 }
1567 }
1568 }
1569 }
1570 }
1571
1572 return $blocks_usage_sum;
1573 }
1574
1575 /**
1576 * Check if WooCommerce is active or not.
1577 *
1578 * @since 1.8.4
1579 *
1580 * @return boolean true|false Return if WC active.
1581 */
1582 public function check_if_wc_active() {
1583 // Check if WooCommerce is active.
1584 return in_array( 'woocommerce/woocommerce.php', apply_filters( 'active_plugins', get_option( 'active_plugins' ) ) );
1585 }
1586
1587 /**
1588 * Get a map of payment gateways that hold a stored credential.
1589 *
1590 * Booleans only. No key, token, merchant ID or email is read into the
1591 * payload, only whether one is present, so this reports site
1592 * configuration state and adds no donor-level data (parent spec 8.4).
1593 *
1594 * Reads the settings array directly rather than going through
1595 * charitable_get_option() or a gateway object, for three reasons.
1596 * Charitable_Gateways::get_gateway_object() calls `new $class()` and
1597 * gateway constructors register hooks, which must not happen while
1598 * building a cron payload. The Stripe Connect settings filter nulls
1599 * stored keys out when the Connect addon is inactive, and the stored
1600 * fact is what we want to measure, not the currently retrievable
1601 * value. And the caller already holds this array, so this costs no
1602 * extra queries.
1603 *
1604 * 'offline' has no credential of any kind, so for that gateway being
1605 * enabled IS its whole configuration, and that is what is reported.
1606 *
1607 * @since 1.8.12
1608 *
1609 * @param array $settings The charitable_settings option.
1610 * @return array<string,bool>
1611 */
1612 private function get_gateways_connected( $settings ) {
1613
1614 $settings = is_array( $settings ) ? $settings : array();
1615
1616 $stripe = isset( $settings['gateways_stripe'] ) && is_array( $settings['gateways_stripe'] ) ? $settings['gateways_stripe'] : array();
1617 $paypal = isset( $settings['gateways_paypal'] ) && is_array( $settings['gateways_paypal'] ) ? $settings['gateways_paypal'] : array();
1618 $paypal_commerce = isset( $settings['gateways_paypal_commerce'] ) && is_array( $settings['gateways_paypal_commerce'] ) ? $settings['gateways_paypal_commerce'] : array();
1619 $square = isset( $settings['gateways_square'] ) && is_array( $settings['gateways_square'] ) ? $settings['gateways_square'] : array();
1620
1621 // Mirrors Charitable_Gateway_Stripe_AM::check_keys_exist(), which
1622 // requires BOTH the secret and the publishable key for a mode.
1623 $stripe_keyed = ( ! empty( $stripe['live_secret_key'] ) && ! empty( $stripe['live_public_key'] ) )
1624 || ( ! empty( $stripe['test_secret_key'] ) && ! empty( $stripe['test_public_key'] ) );
1625
1626 // Written on Connect authorise, deleted on disconnect. Covers
1627 // Connect sites whose stored keys the settings filter hides.
1628 $stripe_connect = (bool) get_option( 'charitable_using_stripe_connect', false );
1629
1630 $paypal_connected = ! empty( $paypal_commerce['live_seller_merchant_id'] )
1631 || ! empty( $paypal_commerce['sandbox_seller_merchant_id'] )
1632 || ! empty( $paypal['paypal_email'] )
1633 || ! empty( $paypal['sandbox_paypal_email'] );
1634
1635 // Square stores an OAuth token nested per mode. Presence only; the
1636 // value is encrypted on Connect sites and is never decrypted here.
1637 //
1638 // The nested level is is_array()-guarded like every parent above it,
1639 // and that is not defensive noise. empty() tolerates a string, int,
1640 // null or bool parent, but on an OBJECT parent it throws
1641 // "Cannot use object of type X as array", which would be a fatal
1642 // inside get_optin_data() during a cron run. charitable_settings is
1643 // an unserialised option, so an object there is unlikely but not
1644 // impossible, and this is the only two-level read in this method.
1645 $square_live = isset( $square['live'] ) && is_array( $square['live'] ) ? $square['live'] : array();
1646 $square_test = isset( $square['test'] ) && is_array( $square['test'] ) ? $square['test'] : array();
1647
1648 $square_connected = ! empty( $square_live['access_token'] )
1649 || ! empty( $square_test['access_token'] );
1650
1651 return array(
1652 'stripe' => (bool) ( $stripe_keyed || $stripe_connect ),
1653 'paypal' => (bool) $paypal_connected,
1654 'square' => (bool) $square_connected,
1655 'offline' => in_array( 'offline', $this->get_gateways_enabled( $settings ), true ),
1656 );
1657 }
1658
1659 /**
1660 * Get the list of enabled payment gateway IDs.
1661 *
1662 * Gateway IDs only, never the class names stored alongside them.
1663 *
1664 * Read raw from the settings array rather than via
1665 * Charitable_Gateways::get_active_gateways(), which additionally drops
1666 * gateways whose class is not currently loaded and, in Pro, applies
1667 * PayPal tier visibility. Raw is the site's own saved configuration,
1668 * which is what we want to measure, and it cannot shift depending on
1669 * which addons happen to be active when the cron fires.
1670 *
1671 * array_values() matters: the receiver json_decode()s this, and a
1672 * non-sequential array would encode as a JSON object, not an array.
1673 *
1674 * @since 1.8.12
1675 *
1676 * @param array $settings The charitable_settings option.
1677 * @return string[]
1678 */
1679 private function get_gateways_enabled( $settings ) {
1680
1681 if ( ! is_array( $settings ) || empty( $settings['active_gateways'] ) || ! is_array( $settings['active_gateways'] ) ) {
1682 return array();
1683 }
1684
1685 return array_values( array_map( 'strval', array_keys( $settings['active_gateways'] ) ) );
1686 }
1687
1688 /**
1689 * Derive the licence key for telemetry payloads.
1690 *
1691 * Prefers the charitable-v2 licence, falling back to the first licence
1692 * present so installs storing a licence under another key are not dropped.
1693 * Sanitised to alphanumerics so the value is byte-identical across both
1694 * the usage and tracking payloads, which is what makes them joinable.
1695 *
1696 * @since 1.8.12
1697 *
1698 * @return string Sanitised licence key, or an empty string.
1699 */
1700 private function get_payload_license_key() {
1701
1702 $settings = get_option( 'charitable_settings' );
1703 $licenses = ! empty( $settings['licenses'] ) ? $settings['licenses'] : array();
1704
1705 if ( empty( $licenses ) || ! is_array( $licenses ) ) {
1706 return '';
1707 }
1708
1709 if ( isset( $licenses['charitable-v2']['license'] ) ) {
1710 $license = $licenses['charitable-v2']['license'];
1711 } else {
1712 $first = reset( $licenses );
1713 $license = isset( $first['license'] ) ? $first['license'] : '';
1714 }
1715
1716 return (string) preg_replace( '/[^a-zA-Z0-9]/', '', (string) $license );
1717 }
1718
1719 /**
1720 * Get the AID for the site.
1721 *
1722 * @since 1.8.4
1723 *
1724 * @return string The AID for the site.
1725 */
1726 private function get_site_aid() {
1727 // Check if the AID already exists in the database.
1728 $aid = get_option( 'charitable_site_tracking_aid' );
1729
1730 if ( $aid === false ) {
1731 // If it doesn't exist, generate a new one.
1732 $site_url = get_site_url(); // Get the site URL.
1733 $random_salt = wp_generate_password( 20, false, false ); // Generate a random salt.
1734 // Create a hash based on the site URL and salt.
1735 $aid = hash( 'sha256', $site_url . $random_salt );
1736
1737 // Save the generated AID in the database.
1738 update_option( 'charitable_site_tracking_aid', $aid );
1739 }
1740
1741 return $aid;
1742 }
1743
1744 /**
1745 * Returns and/or create the single instance of this class.
1746 *
1747 * @since 1.8.4
1748 *
1749 * @return Charitable_Tracking
1750 */
1751 public static function get_instance() {
1752 if ( is_null( self::$instance ) ) {
1753 self::$instance = new self();
1754 }
1755
1756 return self::$instance;
1757 }
1758 }
1759
1760
1761 }
1762