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

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

1,521 lines 54.1 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 // Retrieve current plugin information.
180 if ( ! function_exists( 'get_plugins' ) ) {
181 include ABSPATH . '/wp-admin/includes/plugin.php';
182 }
183
184 $plugins = array_keys( get_plugins() );
185 $active_plugins = get_option( 'active_plugins', array() );
186
187 foreach ( $plugins as $key => $plugin ) {
188 if ( in_array( $plugin, $active_plugins, true ) ) {
189 // Remove active plugins from list so we can show active and inactive separately.
190 unset( $plugins[ $key ] );
191 }
192 }
193
194 $data['active_plugins'] = $active_plugins;
195 $data['inactive_plugins'] = $plugins;
196 $data['locale'] = get_locale();
197
198 return $data;
199 }
200
201
202 /**
203 * Fetch tracking data.
204 *
205 * @since 1.8.4
206 *
207 * @return array $data Tracked data.
208 */
209 private function get_tracking_data() {
210
211 global $wpdb;
212
213 $data = array();
214
215 $data['id'] = $this->get_site_aid();
216 $data['license_key'] = $this->get_payload_license_key();
217
218 // get the total number of campaigns.
219 $data['campaign_counts'] = (array) wp_count_posts( 'campaign' );
220
221 // campaign data.
222 $data['campaign_data'] = (array) $this->get_charitable_data();
223
224 // donation data.
225 $data['donation_data'] = $this->get_donation_data();
226 $data['recurring_donation_data'] = $this->get_recurring_donation_data();
227
228 return $data;
229 }
230
231 /**
232 * Get Charitable data.
233 *
234 * @return array $data Charitable data.
235 */
236 public function get_charitable_data() {
237
238 global $wpdb;
239
240 $sql = "SELECT
241 SUM(subquery.total_amount) AS grand_total_amount,
242 AVG(subquery.total_amount) AS grand_average_amount,
243 SUM(subquery.total_count_donations) AS grand_total_count_donations
244 FROM (
245 SELECT
246 SUM(ccd.amount) AS total_amount,
247 COUNT(ccd.donation_id) AS total_count_donations,
248 cd.donor_id
249 FROM {$wpdb->prefix}charitable_donors cd
250 JOIN {$wpdb->prefix}charitable_campaign_donations ccd ON cd.donor_id = ccd.donor_id
251 GROUP BY cd.donor_id
252 ) AS subquery;
253 ";
254
255 $results = $wpdb->get_results( $sql, ARRAY_A ); // phpcs:ignore
256
257 $data = [];
258
259 if ( ! empty( $results ) ) {
260 // Ensure each amount is converted to a string before sanitization.
261 $data['total'] = 0 !== $results[0]['grand_total_amount']
262 ? charitable_sanitize_amount( (string) ( $results[0]['grand_total_amount'] ), true )
263 : 0;
264
265 $data['average'] = 0 !== $results[0]['grand_average_amount']
266 ? charitable_sanitize_amount( (string) ( $results[0]['grand_average_amount'] ), true )
267 : 0;
268 $data['donor_count'] = 0 !== $results[0]['grand_total_count_donations'] ? intval( $results[0]['grand_total_count_donations'] ) : 0;
269 }
270
271 if ( ! empty( $data ) ) {
272 return $data;
273 }
274 }
275
276 /**
277 * Get donation related data.
278 *
279 * @since 1.8.4
280 *
281 * @return array $data Donation data.
282 */
283 public function get_donation_data() {
284
285 global $wpdb;
286
287 $defaults = array(
288 'start_date' => false,
289 'end_date' => false,
290 'post_status' => 'charitable-completed',
291 'campaign_id' => false,
292 'category_id' => false,
293 );
294
295 // Extract individual variables from defaults array.
296 $start_date = $defaults['start_date'];
297 $end_date = $defaults['end_date'];
298 $post_status = $defaults['post_status'];
299 $campaign_id = $defaults['campaign_id'];
300 $category_id = $defaults['category_id'];
301
302 $where_sql = array();
303 $where_sql[] = 'WHERE 1=1';
304 $where_sql[] = 'p.post_type = "%s"';
305 $where_sql[] = 'p.post_status = "' . $post_status . '"';
306 $where_sql[] = 'pm1.meta_key = "donation_gateway"';
307
308 // remove all empty values from the array.
309 $where_sql = array_filter( $where_sql );
310 $where_args = implode( ' AND ', $where_sql );
311
312 $left_join = array();
313 $left_join[] = $wpdb->prefix . 'charitable_campaign_donations cd ON p.ID = cd.donation_id';
314 $left_join[] = $wpdb->prefix . 'charitable_donors cdonors ON cd.donor_id = cdonors.donor_id';
315 $left_join[] = $wpdb->prefix . 'postmeta pm1 ON p.ID = pm1.post_id';
316
317 // remove all empty values from the array.
318 $left_join = array_filter( $left_join );
319 $left_join_args = 'LEFT JOIN ' . implode( ' LEFT JOIN ', $left_join );
320
321 $sql = "SELECT SUM(cd.amount) AS total_amount,
322 cd.donation_id AS donation_id,
323 cd.campaign_id AS campaign_id,
324 cd.donor_id AS donor_id,
325 cd.amount AS amount,
326 pm1.meta_value AS payment_gateway,
327 p.post_date AS post_date,
328 p.post_date_gmt AS post_date_gmt,
329 p.post_status AS donation_status,
330 p.post_type AS donation_type,
331 p.post_parent AS donation_parent_id
332 FROM $wpdb->posts p
333 " . $left_join_args . '
334 ' . $where_args . '
335 GROUP BY p.ID';
336
337 $donations = $wpdb->get_results( // phpcs:ignore
338 $wpdb->prepare(
339 $sql, // phpcs:ignore
340 'donation'
341 )
342 );
343
344 return $this->summarise_donations( is_array( $donations ) ? $donations : array() );
345 }
346
347 /**
348 * Aggregate donation rows into the payload's donation_data block.
349 *
350 * Extracted from get_donation_data() so it is testable without a $wpdb
351 * stub; that method now does the SQL and delegates here.
352 *
353 * COUNTS ARE NEW IN 1.8.17.4 AND THE REASON THIS EXISTS.
354 * total_donations / donations_7_days / donations_30_days are MONEY
355 * AMOUNTS, but the Active User Intensity bands are defined in donation
356 * COUNTS ("1 to 2 donations", "3 to 10", "10+"). With only amounts
357 * available, the classifier's IntensityResolver compared a cloud count
358 * against a telemetry dollar figure via max(), so a single $50 donation
359 * would classify a customer as Engaged-Heavy. The amount keys keep their
360 * existing meaning; the count keys are additive.
361 *
362 * Window comparisons deliberately use post_date, matching the pre-existing
363 * amount windows, so a count and its amount always describe the same set
364 * of donations. last_donation_at uses post_date_gmt instead, because a
365 * timestamp leaving the site has to be unambiguous; post_date is
366 * site-local. That inconsistency is inherited, not introduced, and is not
367 * silently "fixed" here because changing the existing windows would move
368 * numbers the receiver has been storing for two years.
369 *
370 * @since 1.8.12
371 *
372 * @param array $donations Rows from get_donation_data()'s query.
373 * @return array
374 */
375 private function summarise_donations( array $donations ) {
376
377 $total_donations = 0;
378 $donations_7_days = 0;
379 $donations_30_days = 0;
380 $count_total = 0;
381 $count_7_days = 0;
382 $count_30_days = 0;
383 $last_donation_at = null;
384 $donations_payment_gateway = array();
385
386 $cutoff_7 = strtotime( '-7 days' );
387 $cutoff_30 = strtotime( '-30 days' );
388
389 foreach ( $donations as $donation ) {
390
391 $amount = isset( $donation->total_amount ) ? $donation->total_amount : 0;
392 $when = isset( $donation->post_date ) ? strtotime( (string) $donation->post_date ) : false;
393
394 $total_donations += $amount;
395 ++$count_total;
396
397 if ( false !== $when && $when > $cutoff_7 ) {
398 $donations_7_days += $amount;
399 ++$count_7_days;
400 }
401
402 if ( false !== $when && $when > $cutoff_30 ) {
403 $donations_30_days += $amount;
404 ++$count_30_days;
405 }
406
407 // GMT for the outbound timestamp; see the note above.
408 $gmt = isset( $donation->post_date_gmt ) ? strtotime( (string) $donation->post_date_gmt . ' UTC' ) : false;
409
410 if ( false !== $gmt && ( null === $last_donation_at || $gmt > $last_donation_at ) ) {
411 $last_donation_at = $gmt;
412 }
413
414 $gateway = isset( $donation->payment_gateway ) ? (string) $donation->payment_gateway : '';
415
416 if ( ! isset( $donations_payment_gateway[ $gateway ] ) ) {
417 $donations_payment_gateway[ $gateway ] = 0;
418 }
419
420 $donations_payment_gateway[ $gateway ] += $amount;
421 }
422
423 return array(
424 'total_donations' => $total_donations,
425 'donations_7_days' => $donations_7_days,
426 'donations_30_days' => $donations_30_days,
427 'donations_payment_gateway' => $donations_payment_gateway,
428 // New in 1.8.17.4.
429 'donation_count_total' => $count_total,
430 'donation_count_7_days' => $count_7_days,
431 'donation_count_30_days' => $count_30_days,
432 'last_donation_at' => $last_donation_at,
433 );
434 }
435
436 /**
437 * Get recurring donation related data.
438 *
439 * @since 1.8.4
440 *
441 * @return array $data Recurring donation data.
442 */
443 public function get_recurring_donation_data() {
444
445 global $wpdb;
446
447 // if the recurring donations plugin is not active, return an empty array.
448 if ( ! class_exists( 'Charitable_Recurring' ) ) {
449 return array();
450 }
451
452 $where_sql = array();
453 $where_sql[] = 'WHERE 1=1';
454 $where_sql[] = 'p.post_type = "%s"';
455 $where_sql[] = 'p.post_status = "charitable-active"';
456
457 // remove all empty values from the array.
458 $where_sql = array_filter( $where_sql );
459 $where_args = implode( ' AND ', $where_sql );
460
461 /*
462 * Each meta_key test belongs in its join's ON clause, NOT in the WHERE clause. A WHERE test
463 * against a left-joined row is an inner join, so until 1.8.12.1 any recurring donation missing
464 * even one of these four meta keys was dropped from the result set entirely - which silently
465 * undercounted recurring_donations_count and every amount derived from these rows. A
466 * subscription with no _expiration_date is perfectly normal, and used to vanish here.
467 */
468 $left_join = array();
469 $left_join[] = $wpdb->prefix . 'postmeta pm1 ON p.ID = pm1.post_id AND pm1.meta_key = "donation_gateway"';
470 $left_join[] = $wpdb->prefix . 'postmeta pm2 ON p.ID = pm2.post_id AND pm2.meta_key = "donation_period"';
471 $left_join[] = $wpdb->prefix . 'postmeta pm4 ON p.ID = pm4.post_id AND pm4.meta_key = "_expiration_date"';
472 $left_join = array_filter( $left_join );
473 $left_join_args = 'LEFT JOIN ' . implode( ' LEFT JOIN ', $left_join );
474
475 /*
476 * No SUM() here any more. Until 1.8.12.1 this selected `SUM(pm4.meta_value) AS
477 * total_recurring_amount` - i.e. it summed the _expiration_date meta, a DATE, which cast to
478 * '2026-06-15 23:59:59' -> 2026. Nothing ever read that alias (the aggregator re-sums in PHP),
479 * and with GROUP BY p.ID it was not even an aggregate. It is gone because it was the single
480 * most misleading line in this function: it implied the recurring amount came from a date.
481 *
482 * pm3 (_first_donation) is no longer selected either. It is a donation POST ID, not an amount
483 * - the Recurring addon writes it as update_post_meta( $recurring_id, '_first_donation',
484 * $donation_id ) - and it was previously summed as money. See
485 * summarise_recurring_donations() for what replaced it.
486 */
487 $sql = "SELECT pm1.meta_value AS payment_gateway,
488 pm2.meta_value AS donation_period,
489 pm4.meta_value AS expiration_date,
490 p.post_date AS post_date,
491 p.post_date_gmt AS post_date_gmt,
492 p.post_status AS donation_status,
493 p.post_parent AS donation_parent_id
494 FROM $wpdb->posts p
495 " . $left_join_args . '
496 ' . $where_args . '
497 GROUP BY p.ID';
498
499 $recurring_donations = $wpdb->get_results( // phpcs:ignore
500 $wpdb->prepare(
501 $sql, // phpcs:ignore
502 'recurring_donation'
503 )
504 );
505
506 return $this->summarise_recurring_donations( is_array( $recurring_donations ) ? $recurring_donations : array() );
507 }
508
509 /**
510 * Aggregate recurring rows into the payload's recurring_donation_data block.
511 *
512 * Extracted from get_recurring_donation_data() so it is testable without a
513 * $wpdb stub.
514 *
515 * recurring_donations_count is the point. Stage 5 Power leans on recurring
516 * activity, but the only recurring signal in the payload was
517 * total_recurring_amount. On the cloud side subscription_analyses covers just
518 * 193 of 2,547 accounts and RefreshSubscriptionAnalysisCommand never writes
519 * active_subscriptions at all, so there was no reliable recurring COUNT
520 * anywhere.
521 *
522 * CHANGED IN 1.8.12.1. This function used to report five money figures, all of
523 * which were sums of donation POST IDs, because it read the _first_donation
524 * post meta as an amount. It is not an amount: the Recurring addon writes it
525 * as update_post_meta( $recurring_id, '_first_donation', $donation_id ), and
526 * its accessor is get_first_donation_id(). Measured on a real site, a
527 * subscription whose first donation was 3.68 contributed 309, and one whose
528 * first-donation post had been deleted still contributed its stale ID.
529 *
530 * Removed: total_recurring_amount, recurring_donations_7_days,
531 * recurring_donations_30_days. There is no correct value to put in them here -
532 * the pledge amount lives only in the serialized `campaigns` post meta,
533 * recurring donations have no charitable_campaign_donations row, and some
534 * active subscriptions record no amount at all.
535 *
536 * Changed: recurring_donations_payment_gateway and recurring_donations_period
537 * are now COUNTS of active subscriptions rather than (bogus) amounts.
538 *
539 * Do not confuse _first_donation with the charitable_first_donation option,
540 * which IS a date and is reported separately as $data['first_donation'].
541 *
542 * @since 1.8.12
543 * @version 1.8.12.1
544 *
545 * @param array $recurring_donations Rows from the recurring query.
546 * @return array
547 */
548 private function summarise_recurring_donations( array $recurring_donations ) {
549
550 $recurring_count = 0;
551 $recurring_donations_payment_gateway = array();
552 $recurring_donations_period = array();
553
554 foreach ( $recurring_donations as $donation ) {
555
556 ++$recurring_count;
557
558 // Counts, not amounts. This query cannot produce a reliable recurring money figure: the
559 // pledge amount lives only inside the serialized `campaigns` post meta, recurring
560 // donations have no charitable_campaign_donations row, and some active subscriptions
561 // record no amount at all. A count of active subscriptions per gateway and per period is
562 // the useful thing it CAN compute correctly, so that is what these now report.
563 $gateway = isset( $donation->payment_gateway ) ? (string) $donation->payment_gateway : '';
564
565 if ( ! isset( $recurring_donations_payment_gateway[ $gateway ] ) ) {
566 $recurring_donations_payment_gateway[ $gateway ] = 0;
567 }
568
569 ++$recurring_donations_payment_gateway[ $gateway ];
570
571 $period = isset( $donation->donation_period ) ? (string) $donation->donation_period : '';
572
573 if ( ! isset( $recurring_donations_period[ $period ] ) ) {
574 $recurring_donations_period[ $period ] = 0;
575 }
576
577 ++$recurring_donations_period[ $period ];
578 }
579
580 return array(
581 // Counts of ACTIVE subscriptions, keyed by gateway / by period. Amounts before 1.8.12.1.
582 'recurring_donations_payment_gateway' => $recurring_donations_payment_gateway,
583 'recurring_donations_period' => $recurring_donations_period,
584 'recurring_donations_count' => $recurring_count,
585 );
586 }
587
588 /**
589 * Send optin usage data.
590 *
591 * @since 1.8.4
592 *
593 * @param boolean $override Override usage_optin_allowed.
594 * @param boolean $ignore_last_checkin Ignore last checkin flag.
595 * @return boolean
596 */
597 public function send_optin_usage_checkin( $override = false, $ignore_last_checkin = false ) {
598
599 if ( defined( 'CHARITABLE_DEBUG_USAGE' ) && CHARITABLE_DEBUG_USAGE ) { // phpcs:disable
600 // phpcs:disable
601 if ( charitable_is_debug() ) {
602 error_log('send_optin_usage_checkin');
603 }
604 // phpcs:enable
605 }
606
607 if ( ! $this->usage_optin_allowed() && ! $override ) {
608 if ( defined( 'CHARITABLE_DEBUG_USAGE' ) && CHARITABLE_DEBUG_USAGE ) { // phpcs:disable
609 // phpcs:disable
610 if ( charitable_is_debug() ) {
611 error_log('charitable usage tracking not allowed');
612 }
613 // phpcs:enable
614 }
615 return false;
616 }
617
618 // Send a maximum of once per week.
619 $last_send = get_option( 'charitable_usage_tracking_last_checkin' );
620 if ( $this->checkin_is_throttled( $last_send ) && ! $ignore_last_checkin ) {
621 if ( defined( 'CHARITABLE_DEBUG_USAGE' ) && CHARITABLE_DEBUG_USAGE ) { // phpcs:disable
622 // phpcs:disable
623 if ( charitable_is_debug() ) {
624 error_log('charitable usage tracking not allowed because of last checkin');
625 }
626 // phpcs:enable
627 }
628 return false;
629 }
630
631 $charitable_object = charitable();
632 $charitable_version = $charitable_object !== null ? charitable()->get_version() : '';
633
634 $request = wp_remote_post(
635 'https://usage.wpcharitable.com/capture',
636 array(
637 'method' => 'POST',
638 'timeout' => 15,
639 'redirection' => 5,
640 'httpversion' => '1.1',
641 'blocking' => true,
642 'sslverify' => false,
643 'body' => $this->get_optin_data(),
644 'user-agent' => 'CH/' . $charitable_version . '; ' . get_bloginfo( 'url' ),
645 )
646 );
647
648 if ( defined( 'CHARITABLE_DEBUG_USAGE' ) && CHARITABLE_DEBUG_USAGE ) { // phpcs:disable
649 // phpcs:disable
650 if ( charitable_is_debug() ) {
651 error_log( 'send_optin_usage_checkin' );
652 error_log( print_r( $request, true ) );
653 error_log( print_r( $this->get_optin_data(), true ) );
654 }
655 // phpcs:enable
656 }
657
658 return $this->handle_checkin_result( 'usage', $request, 'charitable_usage_tracking_last_checkin' );
659 }
660
661 /**
662 * Send tracking data.
663 *
664 * @since 1.8.4
665 *
666 * @param boolean $override Override usage_optin_allowed.
667 * @param boolean $ignore_last_checkin Ignore last checkin flag.
668 * @return boolean
669 */
670 public function send_tracking_checkin( $override = false, $ignore_last_checkin = false ) {
671
672 if ( defined( 'CHARITABLE_DEBUG_USAGE' ) && CHARITABLE_DEBUG_USAGE ) { // phpcs:disable
673 // phpcs:disable
674 if ( charitable_is_debug() ) {
675 error_log('send_tracking_checkin');
676 }
677 // phpcs:enable
678 }
679
680 if ( defined( 'CHARITABLE_DEBUG_USAGE' ) && CHARITABLE_DEBUG_USAGE ) { // phpcs:disable
681 // phpcs:disable
682 if ( charitable_is_debug() ) {
683 error_log( 'send_checkin' );
684 error_log( $override );
685 error_log( $ignore_last_checkin );
686 error_log( 'tracking allowed' );
687 error_log( $this->usage_optin_allowed() );
688 }
689 // phpcs:enable
690 }
691
692 if ( ! $this->tracking_allowed() && ! $override ) {
693 if ( defined( 'CHARITABLE_DEBUG_USAGE' ) && CHARITABLE_DEBUG_USAGE ) { // phpcs:disable
694 // phpcs:disable
695 if ( charitable_is_debug() ) {
696 error_log( 'charitable tracking not allowed' );
697 }
698 // phpcs:enable
699 }
700 return false;
701 }
702
703 // Send a maximum of once per week.
704 $last_send = get_option( 'charitable_tracking_last_checkin' );
705 if ( $this->checkin_is_throttled( $last_send ) && ! $ignore_last_checkin ) {
706 if ( defined( 'CHARITABLE_DEBUG_USAGE' ) && CHARITABLE_DEBUG_USAGE ) { // phpcs:disable
707 // phpcs:disable
708 if ( charitable_is_debug() ) {
709 error_log('charitable tracking not allowed because of last checkin');
710 }
711 // phpcs:enable
712 }
713 return false;
714 }
715
716 $charitable_object = charitable();
717 $charitable_version = $charitable_object !== null ? charitable()->get_version() : '';
718
719 $request = wp_remote_post(
720 'https://tracking.wpcharitable.com/capture',
721 array(
722 'method' => 'POST',
723 'timeout' => 15,
724 'redirection' => 5,
725 'httpversion' => '1.1',
726 'blocking' => true,
727 'sslverify' => false,
728 'body' => $this->get_tracking_data(),
729 'user-agent' => 'GenericTrackingClient/1.0',
730 )
731 );
732
733 if ( defined( 'CHARITABLE_DEBUG_USAGE' ) && CHARITABLE_DEBUG_USAGE ) { // phpcs:disable
734 // phpcs:disable
735 if ( charitable_is_debug() ) {
736 error_log( print_r( $request, true ) );
737 error_log( print_r( $last_send, true ) );
738 error_log( print_r( $charitable_version, true ) );
739 error_log( print_r( $this->get_tracking_data(), true ) );
740 }
741 }
742
743 // This endpoint's OWN throttle option, the same one read above. It used
744 // to read this one but write the usage endpoint's instead, so the option
745 // it tested was never written and this endpoint was never throttled at
746 // all. The two need separate throttles, so the write was corrected to
747 // match the read rather than the reverse. Passing the name through
748 // explicitly is what makes that mismatch impossible to reintroduce.
749 //
750 // The other endpoint's option name is deliberately NOT written out
751 // anywhere in this method: a source-level guard in
752 // Test_Charitable_Tracking_Throttle asserts it never appears here, and a
753 // comment quoting it would fail that guard.
754 return $this->handle_checkin_result( 'tracking', $request, 'charitable_tracking_last_checkin' );
755 }
756
757 /**
758 * Check if optin usage tracking is allowed.
759 *
760 * @since 1.8.4
761 *
762 * @return boolean
763 */
764 private function usage_optin_allowed() {
765
766 // DISABLE means disable. This branch used to `return true`, i.e. ALLOWED,
767 // so a site owner defining this constant to opt out got tracking
768 // force-ENABLED, and because the check precedes the consent read below it
769 // bypassed the opt-out entirely. Harmless only because the weekly cron
770 // never fired before 1.8.17.4; fixed in the release that makes it fire.
771 //
772 // Debug force-enable is unaffected: that path uses
773 // send_checkins( $override = true ), not this constant.
774 if ( defined( 'CHARITABLE_DISABLE_OPTIN_USAGE' ) && CHARITABLE_DISABLE_OPTIN_USAGE ) {
775 return false;
776 }
777
778 return (bool) apply_filters( 'charitable_usage_tracking', $this->get_usage_tracking_setting() );
779 }
780
781 /**
782 * The site's declared environment type.
783 *
784 * Added in 1.8.17.4 so the Active User denominator can EXCLUDE non-production
785 * sites. This matters more than it looks: usage_optin_allowed() has no
786 * environment gate at all (only the tracking path has one, and it excludes
787 * 'staging' alone), and `aid` lives in a WordPress option rather than being
788 * derived from the URL. So a cloned staging or local site reports a row
789 * carrying the PRODUCTION aid and licence key, which then joins to real
790 * donation data and inflates every figure.
791 *
792 * Reported rather than acted on: this deliberately does NOT change who
793 * sends. Gating sends here would silently shrink the reporting population
794 * and confound the 1.8.17.2 adoption trend. The receiver and classifier can
795 * filter once the data exists.
796 *
797 * @since 1.8.12
798 *
799 * @return string 'production', 'staging', 'development', 'local', or ''.
800 */
801 private function get_environment_type() {
802
803 if ( ! function_exists( 'wp_get_environment_type' ) ) {
804 return '';
805 }
806
807 return (string) wp_get_environment_type();
808 }
809
810 /**
811 * The onboarding checklist status.
812 *
813 * Separates "installed and abandoned" from "installed and mid-setup", which
814 * is the distinction the Installed-over-30-days stuck flag needs to avoid
815 * firing on people who are simply still working through setup.
816 *
817 * Reads the raw option DELIBERATELY rather than calling
818 * Charitable_Checklist::is_checklist_completed(). That method routes through
819 * get_checklist_data(), which WRITES the default schema when the option is
820 * empty, and is_step_completed() takes $update_option = true by default. A
821 * payload build must never mutate site state, least of all from a cron
822 * request.
823 *
824 * @since 1.8.12
825 *
826 * @return string 'init', 'completed', 'skipped', or '' when unknown.
827 */
828 private function get_onboarding_status() {
829
830 $checklist = get_option( 'charitable_onboarding_checklist' );
831
832 if ( ! is_array( $checklist ) || ! isset( $checklist['status'] ) || ! is_scalar( $checklist['status'] ) ) {
833 return '';
834 }
835
836 return (string) $checklist['status'];
837 }
838
839 /**
840 * Whether a check-in is inside the once-per-week throttle window.
841 *
842 * @since 1.8.12
843 *
844 * @param mixed $last_send Stored last-check-in timestamp.
845 * @return bool
846 */
847 private function checkin_is_throttled( $last_send ) {
848 return is_numeric( $last_send ) && $last_send > strtotime( '-1 week' );
849 }
850
851 /**
852 * Timestamp to store after a FAILED send, so it retries in ~24 hours.
853 *
854 * Fixes a long-standing bug. Both send methods used to store
855 * time() + DAY_IN_SECONDS here, with the comment "recheck in 24 hours".
856 * But the throttle test is `$last_send > strtotime( '-1 week' )`, and a
857 * timestamp 24 hours in the FUTURE satisfies that for EIGHT days. So a
858 * single failed send suppressed retries for eight days, not one.
859 *
860 * Storing a timestamp that is already six days old makes the one-week
861 * test start failing 24 hours from now, which is what was intended.
862 *
863 * @since 1.8.12
864 *
865 * @return int
866 */
867 private function get_retry_timestamp() {
868 return time() - ( WEEK_IN_SECONDS - DAY_IN_SECONDS );
869 }
870
871 /**
872 * Apply the outcome of a check-in POST: throttle, record, and report.
873 *
874 * EXTRACTED so it can be tested. The two send methods each build a payload
875 * that needs wp_count_posts(), $wpdb, get_plugins() and a theme object, so
876 * they cannot be driven from the standalone suite; the result handling can.
877 * Without this, checkin_failure_reason() and record_checkin_failure() were
878 * unit-tested in isolation while NOTHING asserted that the send methods
879 * actually called them. A mutation test proved it: reverting the usage-send
880 * wiring left the whole suite green.
881 *
882 * Extracting it also removes a duplication that has already caused one bug.
883 * The two endpoints had near-identical copies of this logic, and the
884 * tracking copy READ charitable_tracking_last_checkin while WRITING
885 * charitable_usage_tracking_last_checkin, so that endpoint was never
886 * throttled at all. The option name is now a parameter, which makes that
887 * class of mismatch impossible rather than merely fixed.
888 *
889 * On success: clear any recorded failure and book the next check-in a week
890 * out. On failure: record why, and set the option to a timestamp that
891 * expires in 24 hours. See get_retry_timestamp() -- the old value was 24
892 * hours in the FUTURE, which suppressed retries for eight days.
893 *
894 * @since 1.8.12
895 *
896 * @param string $endpoint 'usage' or 'tracking'.
897 * @param array|WP_Error $request The wp_remote_post() return value.
898 * @param string $option_name The throttle option for this endpoint.
899 * @return bool True when the check-in succeeded.
900 */
901 private function handle_checkin_result( $endpoint, $request, $option_name ) {
902
903 $failure = $this->checkin_failure_reason( $request );
904
905 if ( null === $failure ) {
906 $this->clear_checkin_failure( $endpoint );
907 update_option( $option_name, time() );
908
909 return true;
910 }
911
912 $this->record_checkin_failure( $endpoint, $failure );
913 update_option( $option_name, $this->get_retry_timestamp() );
914
915 return false;
916 }
917
918 /**
919 * Why a check-in failed, or null when it succeeded.
920 *
921 * Two distinct failure modes, both of which used to pass silently.
922 *
923 * TRANSPORT (WP_Error): DNS, timeout, or TLS verification. This is the
924 * mode that has to be visible before `sslverify` can be turned on: a
925 * verification failure returns WP_Error, the caller stores a retry
926 * timestamp and returns false, and until now nothing recorded it. Turning
927 * verification on blind would silently drop a biased subset of sites
928 * (TLS-intercepting corporate networks and hosts) with no signal saying
929 * who or why, on a project whose whole purpose is a defensible
930 * denominator. The cURL code and message are what distinguish a
931 * certificate problem from a timeout, so both are kept.
932 *
933 * HTTP STATUS: a non-2xx response. This is the more serious of the two and
934 * was found while building the first. `! is_wp_error( $request )` is TRUE
935 * for any completed round trip, including a 500, so a receiver error
936 * stamped a SUCCESSFUL check-in and suppressed this site for a full week.
937 * The usage receiver has a live 500 on any payload missing `usages`, so
938 * this is not hypothetical. It mattered little while the weekly cron was
939 * broken and almost nothing was sent; from 1.8.17.4 on it matters weekly.
940 *
941 * The returned string is deliberately short and bounded. It is stored in
942 * an option and may later be reported, so it must never become an open
943 * channel for arbitrary content. A transport message is a cURL string and
944 * carries no part of the payload.
945 *
946 * @since 1.8.12
947 *
948 * @param array|WP_Error $request The wp_remote_post() return value.
949 * @return string|null Null on success, otherwise a short reason.
950 */
951 private function checkin_failure_reason( $request ) {
952
953 if ( is_wp_error( $request ) ) {
954 $reason = 'transport:' . $request->get_error_code();
955 $detail = trim( preg_replace( '/\s+/', ' ', (string) $request->get_error_message() ) );
956
957 if ( '' === $detail ) {
958 return $reason;
959 }
960
961 return substr( $reason . ': ' . $detail, 0, 200 );
962 }
963
964 $code = (int) wp_remote_retrieve_response_code( $request );
965
966 // 2xx only. A 3xx is not a success either: redirection is already
967 // handled by wp_remote_post's own 'redirection' option, so a 301
968 // arriving here means it was not followed.
969 if ( $code < 200 || $code > 299 ) {
970 return 'http:' . $code;
971 }
972
973 return null;
974 }
975
976 /**
977 * Record a failed check-in so it is not lost.
978 *
979 * Stored per endpoint, because the two have separate throttles and fail
980 * independently; one failing must not read as both failing.
981 *
982 * The count is the useful part. A site whose telemetry is permanently
983 * broken retries daily, and "failed 40 times since 12 June" is actionable
984 * where a boolean is not. A CHANGED reason restarts the count, since that
985 * is a different problem rather than more of the same one.
986 *
987 * Written with autoload false: this is diagnostic data read on demand, not
988 * something to load into memory on every request of every page view.
989 *
990 * NOT TRANSMITTED. This option stays on the site. Sending it would be a
991 * new field in the telemetry payload, and no new category of collected
992 * data goes in without a privacy review, so that is a separate decision.
993 * The consequence, worth being explicit about: this gives a support agent
994 * something to ask for, but it does not by itself let us measure the
995 * TLS-failure rate across the install base.
996 *
997 * @since 1.8.12
998 *
999 * @param string $endpoint 'usage' or 'tracking'.
1000 * @param string $reason From checkin_failure_reason().
1001 * @return void
1002 */
1003 private function record_checkin_failure( $endpoint, $reason ) {
1004
1005 $failures = get_option( 'charitable_telemetry_send_failures', array() );
1006
1007 if ( ! is_array( $failures ) ) {
1008 $failures = array();
1009 }
1010
1011 $previous = isset( $failures[ $endpoint ] ) && is_array( $failures[ $endpoint ] )
1012 ? $failures[ $endpoint ]
1013 : array();
1014
1015 $same_reason = isset( $previous['reason'] ) && $previous['reason'] === $reason;
1016
1017 $failures[ $endpoint ] = array(
1018 'reason' => $reason,
1019 'first' => $same_reason && isset( $previous['first'] ) ? $previous['first'] : time(),
1020 'last' => time(),
1021 'count' => $same_reason && isset( $previous['count'] ) ? (int) $previous['count'] + 1 : 1,
1022 );
1023
1024 update_option( 'charitable_telemetry_send_failures', $failures, false );
1025
1026 // error_log() only when the site has asked for debug output. The option
1027 // above is the durable record and is always written; an unconditional
1028 // error_log() from the telemetry subsystem would write to the logs of
1029 // sites that may not even have consented to tracking, and would buy
1030 // nothing we do not already have. Logged on the FIRST occurrence of each
1031 // distinct reason only, so a permanently broken site does not add an
1032 // identical line every day forever.
1033 $debugging = ( defined( 'CHARITABLE_DEBUG_USAGE' ) && CHARITABLE_DEBUG_USAGE )
1034 || ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG );
1035
1036 if ( $debugging && ! $same_reason ) {
1037 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1038 error_log( sprintf( 'Charitable telemetry: %s check-in failed (%s)', $endpoint, $reason ) );
1039 }
1040 }
1041
1042 /**
1043 * Clear a recorded failure after a successful check-in.
1044 *
1045 * Only this endpoint's entry, so a recovered usage send does not hide a
1046 * still-failing tracking send.
1047 *
1048 * @since 1.8.12
1049 *
1050 * @param string $endpoint 'usage' or 'tracking'.
1051 * @return void
1052 */
1053 private function clear_checkin_failure( $endpoint ) {
1054
1055 $failures = get_option( 'charitable_telemetry_send_failures', array() );
1056
1057 if ( ! is_array( $failures ) || ! array_key_exists( $endpoint, $failures ) ) {
1058 return;
1059 }
1060
1061 unset( $failures[ $endpoint ] );
1062
1063 update_option( 'charitable_telemetry_send_failures', $failures, false );
1064 }
1065
1066 /**
1067 * Read the usage-tracking consent setting from any request context.
1068 *
1069 * charitable_get_usage_tracking_setting() lives in
1070 * includes/admin/charitable-core-admin-functions.php, which is required
1071 * only by Charitable_Admin::load_dependencies(). That runs only when
1072 * is_admin() is true, so the function does NOT exist during a
1073 * wp-cron.php request. Calling it unguarded from the cron path, as the
1074 * consent gates used to, is a fatal error.
1075 *
1076 * The fallback is a byte-for-byte copy of that function's body rather
1077 * than a guessed default, so consent semantics are identical in both
1078 * contexts. Lite is opt-in only, so erring permissive here would send
1079 * telemetry from sites that never agreed to it.
1080 *
1081 * @since 1.8.12
1082 *
1083 * @return int
1084 */
1085 private function get_usage_tracking_setting() {
1086
1087 if ( function_exists( 'charitable_get_usage_tracking_setting' ) ) {
1088 return charitable_get_usage_tracking_setting();
1089 }
1090
1091 return (int) get_option( 'charitable_usage_tracking', false );
1092 }
1093
1094 /**
1095 * Check if tracking is allowed.
1096 *
1097 * @since 1.8.4
1098 *
1099 * @return boolean
1100 */
1101 private function tracking_allowed() {
1102
1103 // DISABLE means disable. See usage_optin_allowed() for the full note. This
1104 // one was worse: returning true here also bypassed
1105 // environment_allows_tracking(), so it defeated the staging exclusion too.
1106 if ( defined( 'CHARITABLE_DISABLE_TRACKING' ) && CHARITABLE_DISABLE_TRACKING ) {
1107 return false;
1108 }
1109
1110 if ( ! $this->environment_allows_tracking() ) {
1111 return false;
1112 }
1113
1114 return (bool) apply_filters( 'charitable_usage_tracking', $this->get_usage_tracking_setting() );
1115 }
1116
1117 /**
1118 * Whether the environment allows sending telemetry data.
1119 *
1120 * @since 1.8.4
1121 *
1122 * @return bool
1123 */
1124 private function environment_allows_tracking() {
1125
1126 if ( function_exists( 'wp_get_environment_type' ) && 'staging' === wp_get_environment_type() ) {
1127 return false;
1128 }
1129
1130 return true;
1131 }
1132
1133 /**
1134 * Schedule send tracking data event.
1135 *
1136 * @since 1.8.4
1137 *
1138 * @return void
1139 */
1140 public function schedule_send() {
1141 if ( ! wp_next_scheduled( 'charitable_usage_tracking_cron' ) ) {
1142 $tracking = array();
1143 $tracking['day'] = wp_rand( 0, 6 );
1144 $tracking['hour'] = wp_rand( 0, 23 );
1145 $tracking['minute'] = wp_rand( 0, 59 );
1146 $tracking['second'] = wp_rand( 0, 59 );
1147 $tracking['offset'] = ( $tracking['day'] * DAY_IN_SECONDS ) +
1148 ( $tracking['hour'] * HOUR_IN_SECONDS ) +
1149 ( $tracking['minute'] * MINUTE_IN_SECONDS ) +
1150 $tracking['second'];
1151 // First send within 1 to 24 hours, NOT anchored to a Sunday.
1152 //
1153 // This used to be strtotime( 'next sunday' ) + $tracking['offset'],
1154 // where offset is itself up to ~7 days, so a new install waited
1155 // between 1 and 14 days for its first report.
1156 //
1157 // Load spreading is not lost: installs and opt-ins are already
1158 // scattered across the week, so anchoring the weekly recurrence
1159 // to that moment spreads slots at least as well as anchoring to
1160 // Sunday, and the 23-hour jitter window prevents a herd.
1161 //
1162 // day/hour/minute/second/offset are still computed and stored:
1163 // the whole $tracking array ships in the payload as
1164 // $data['usagetracking'] and the receiver json_encodes it, so
1165 // the shape must not change.
1166 $tracking['initsend'] = time() + wp_rand( HOUR_IN_SECONDS, DAY_IN_SECONDS );
1167
1168 wp_schedule_event( $tracking['initsend'], 'weekly', 'charitable_usage_tracking_cron' );
1169 update_option( 'charitable_usage_tracking_config', $tracking );
1170 }
1171 }
1172
1173 /**
1174 * Add schedules.
1175 *
1176 * @since 1.8.4
1177 *
1178 * @param array $schedules Available/current schedules.
1179 * @return array $schedules Schedules array.
1180 */
1181 public function add_schedules( $schedules = array() ) {
1182 // Adds once weekly to the existing schedules.
1183 $schedules['weekly'] = array(
1184 'interval' => 604800,
1185 'display' => __( 'Once Weekly', 'charitable' ),
1186 );
1187 return $schedules;
1188 }
1189
1190 /**
1191 * Get WP Posts count.
1192 *
1193 * @since 1.8.4
1194 *
1195 * @return array $wp_post_count WP Posts count.
1196 */
1197 public function get_wp_posts() {
1198 global $wpdb;
1199
1200 $wp_post_count = 0;
1201
1202 $results = $wpdb->get_var( // phpcs:ignore
1203 "SELECT COUNT(`ID`) `hits`
1204 FROM {$wpdb->posts}
1205 WHERE `post_type` = 'post' AND `post_status` = 'publish';"
1206 );
1207 if ( ! empty( $results ) ) {
1208 $wp_post_count = $results;
1209 }
1210
1211 return $wp_post_count;
1212 }
1213
1214 /**
1215 * Get WP Pages count.
1216 *
1217 * @since 1.8.4
1218 *
1219 * @return array $wp_pages_count WP Pages count.
1220 */
1221 public function get_wp_pages() {
1222 global $wpdb;
1223
1224 $wp_pages_count = 0;
1225
1226 $results = $wpdb->get_var( // phpcs:ignore
1227 "SELECT COUNT(`ID`) `hits`
1228 FROM {$wpdb->posts}
1229 WHERE `post_type` = 'page' AND `post_status` = 'publish';"
1230 );
1231 if ( ! empty( $results ) ) {
1232 $wp_pages_count = $results;
1233 }
1234
1235 return $wp_pages_count;
1236 }
1237
1238 /**
1239 * Insert time for first campaign.
1240 *
1241 * @since 1.8.4.5
1242 *
1243 * @param string $insert_or_update Insert or update.
1244 * @param int $campaign_id Campaign ID.
1245 * @param array $data Data.
1246 * @param object $object Object.
1247 *
1248 * @return void
1249 */
1250 public function insert_time_to_first_campaign( $insert_or_update, $campaign_id, $data, $object ) {
1251 global $wpdb;
1252
1253 // Only do this for inserts.
1254 if ( 'insert' !== $insert_or_update ) {
1255 return;
1256 }
1257
1258 $first_campaign = get_option( 'charitable_first_campaign' );
1259
1260 if ( ! $first_campaign ) {
1261 $first_campaign = time();
1262 update_option( 'charitable_first_campaign', $first_campaign );
1263 }
1264 }
1265
1266 /**
1267 * Insert time for first donation.
1268 *
1269 * @since 1.8.4.5
1270 *
1271 * @param int $donation_id Donation ID.
1272 * @param object $object Charitable_Donation_Processor Object.
1273 *
1274 * @return void
1275 */
1276 public function insert_time_to_first_donation( $donation_id, $object ) {
1277 global $wpdb;
1278
1279 $first_donation = get_option( 'charitable_first_donation' );
1280
1281 if ( ! $first_donation ) {
1282 $first_donation = time();
1283 update_option( 'charitable_first_donation', $first_donation );
1284 }
1285 }
1286
1287 /**
1288 * Get block count summation.
1289 *
1290 * @since 1.8.4
1291 *
1292 * @return array $blocks_usage_sum Block usage summation.
1293 */
1294 public function block_count_summation() {
1295 // Get all _wpchar_block_usage data.
1296 global $wpdb;
1297
1298 $tablename = $wpdb->prefix . 'postmeta';
1299 $sql = "SELECT meta_value FROM $tablename";
1300 $sql .= ' WHERE meta_key = %s';
1301 $safe_sql = $wpdb->prepare( $sql, '_wpchar_block_usage' ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1302 $results = $wpdb->get_results( $safe_sql ); // phpcs:ignore
1303
1304 $blocks_usage_sum = array();
1305
1306 // Sum all block usage data.
1307 if ( $results ) {
1308 foreach ( $results as $result ) {
1309 if ( $result->meta_value ) {
1310 $page_usage_data = maybe_unserialize( $result->meta_value );
1311 if ( is_array( $page_usage_data ) ) {
1312 foreach ( $page_usage_data as $type => $value ) {
1313 if ( array_key_exists( $type, $blocks_usage_sum ) ) {
1314 // If set.
1315 $blocks_usage_sum[ $type ] = array(
1316 'name' => $blocks_usage_sum[ $type ]['name'],
1317 'count' => $blocks_usage_sum[ $type ]['count'] + $value['count'], // Sum count.
1318 );
1319 }
1320
1321 if ( ! array_key_exists( $type, $blocks_usage_sum ) ) {
1322 // If block type is not set.
1323 $blocks_usage_sum[ $type ] = $value;
1324 }
1325 }
1326 }
1327 }
1328 }
1329 }
1330
1331 return $blocks_usage_sum;
1332 }
1333
1334 /**
1335 * Check if WooCommerce is active or not.
1336 *
1337 * @since 1.8.4
1338 *
1339 * @return boolean true|false Return if WC active.
1340 */
1341 public function check_if_wc_active() {
1342 // Check if WooCommerce is active.
1343 return in_array( 'woocommerce/woocommerce.php', apply_filters( 'active_plugins', get_option( 'active_plugins' ) ) );
1344 }
1345
1346 /**
1347 * Get a map of payment gateways that hold a stored credential.
1348 *
1349 * Booleans only. No key, token, merchant ID or email is read into the
1350 * payload, only whether one is present, so this reports site
1351 * configuration state and adds no donor-level data (parent spec 8.4).
1352 *
1353 * Reads the settings array directly rather than going through
1354 * charitable_get_option() or a gateway object, for three reasons.
1355 * Charitable_Gateways::get_gateway_object() calls `new $class()` and
1356 * gateway constructors register hooks, which must not happen while
1357 * building a cron payload. The Stripe Connect settings filter nulls
1358 * stored keys out when the Connect addon is inactive, and the stored
1359 * fact is what we want to measure, not the currently retrievable
1360 * value. And the caller already holds this array, so this costs no
1361 * extra queries.
1362 *
1363 * 'offline' has no credential of any kind, so for that gateway being
1364 * enabled IS its whole configuration, and that is what is reported.
1365 *
1366 * @since 1.8.12
1367 *
1368 * @param array $settings The charitable_settings option.
1369 * @return array<string,bool>
1370 */
1371 private function get_gateways_connected( $settings ) {
1372
1373 $settings = is_array( $settings ) ? $settings : array();
1374
1375 $stripe = isset( $settings['gateways_stripe'] ) && is_array( $settings['gateways_stripe'] ) ? $settings['gateways_stripe'] : array();
1376 $paypal = isset( $settings['gateways_paypal'] ) && is_array( $settings['gateways_paypal'] ) ? $settings['gateways_paypal'] : array();
1377 $paypal_commerce = isset( $settings['gateways_paypal_commerce'] ) && is_array( $settings['gateways_paypal_commerce'] ) ? $settings['gateways_paypal_commerce'] : array();
1378 $square = isset( $settings['gateways_square'] ) && is_array( $settings['gateways_square'] ) ? $settings['gateways_square'] : array();
1379
1380 // Mirrors Charitable_Gateway_Stripe_AM::check_keys_exist(), which
1381 // requires BOTH the secret and the publishable key for a mode.
1382 $stripe_keyed = ( ! empty( $stripe['live_secret_key'] ) && ! empty( $stripe['live_public_key'] ) )
1383 || ( ! empty( $stripe['test_secret_key'] ) && ! empty( $stripe['test_public_key'] ) );
1384
1385 // Written on Connect authorise, deleted on disconnect. Covers
1386 // Connect sites whose stored keys the settings filter hides.
1387 $stripe_connect = (bool) get_option( 'charitable_using_stripe_connect', false );
1388
1389 $paypal_connected = ! empty( $paypal_commerce['live_seller_merchant_id'] )
1390 || ! empty( $paypal_commerce['sandbox_seller_merchant_id'] )
1391 || ! empty( $paypal['paypal_email'] )
1392 || ! empty( $paypal['sandbox_paypal_email'] );
1393
1394 // Square stores an OAuth token nested per mode. Presence only; the
1395 // value is encrypted on Connect sites and is never decrypted here.
1396 //
1397 // The nested level is is_array()-guarded like every parent above it,
1398 // and that is not defensive noise. empty() tolerates a string, int,
1399 // null or bool parent, but on an OBJECT parent it throws
1400 // "Cannot use object of type X as array", which would be a fatal
1401 // inside get_optin_data() during a cron run. charitable_settings is
1402 // an unserialised option, so an object there is unlikely but not
1403 // impossible, and this is the only two-level read in this method.
1404 $square_live = isset( $square['live'] ) && is_array( $square['live'] ) ? $square['live'] : array();
1405 $square_test = isset( $square['test'] ) && is_array( $square['test'] ) ? $square['test'] : array();
1406
1407 $square_connected = ! empty( $square_live['access_token'] )
1408 || ! empty( $square_test['access_token'] );
1409
1410 return array(
1411 'stripe' => (bool) ( $stripe_keyed || $stripe_connect ),
1412 'paypal' => (bool) $paypal_connected,
1413 'square' => (bool) $square_connected,
1414 'offline' => in_array( 'offline', $this->get_gateways_enabled( $settings ), true ),
1415 );
1416 }
1417
1418 /**
1419 * Get the list of enabled payment gateway IDs.
1420 *
1421 * Gateway IDs only, never the class names stored alongside them.
1422 *
1423 * Read raw from the settings array rather than via
1424 * Charitable_Gateways::get_active_gateways(), which additionally drops
1425 * gateways whose class is not currently loaded and, in Pro, applies
1426 * PayPal tier visibility. Raw is the site's own saved configuration,
1427 * which is what we want to measure, and it cannot shift depending on
1428 * which addons happen to be active when the cron fires.
1429 *
1430 * array_values() matters: the receiver json_decode()s this, and a
1431 * non-sequential array would encode as a JSON object, not an array.
1432 *
1433 * @since 1.8.12
1434 *
1435 * @param array $settings The charitable_settings option.
1436 * @return string[]
1437 */
1438 private function get_gateways_enabled( $settings ) {
1439
1440 if ( ! is_array( $settings ) || empty( $settings['active_gateways'] ) || ! is_array( $settings['active_gateways'] ) ) {
1441 return array();
1442 }
1443
1444 return array_values( array_map( 'strval', array_keys( $settings['active_gateways'] ) ) );
1445 }
1446
1447 /**
1448 * Derive the licence key for telemetry payloads.
1449 *
1450 * Prefers the charitable-v2 licence, falling back to the first licence
1451 * present so installs storing a licence under another key are not dropped.
1452 * Sanitised to alphanumerics so the value is byte-identical across both
1453 * the usage and tracking payloads, which is what makes them joinable.
1454 *
1455 * @since 1.8.12
1456 *
1457 * @return string Sanitised licence key, or an empty string.
1458 */
1459 private function get_payload_license_key() {
1460
1461 $settings = get_option( 'charitable_settings' );
1462 $licenses = ! empty( $settings['licenses'] ) ? $settings['licenses'] : array();
1463
1464 if ( empty( $licenses ) || ! is_array( $licenses ) ) {
1465 return '';
1466 }
1467
1468 if ( isset( $licenses['charitable-v2']['license'] ) ) {
1469 $license = $licenses['charitable-v2']['license'];
1470 } else {
1471 $first = reset( $licenses );
1472 $license = isset( $first['license'] ) ? $first['license'] : '';
1473 }
1474
1475 return (string) preg_replace( '/[^a-zA-Z0-9]/', '', (string) $license );
1476 }
1477
1478 /**
1479 * Get the AID for the site.
1480 *
1481 * @since 1.8.4
1482 *
1483 * @return string The AID for the site.
1484 */
1485 private function get_site_aid() {
1486 // Check if the AID already exists in the database.
1487 $aid = get_option( 'charitable_site_tracking_aid' );
1488
1489 if ( $aid === false ) {
1490 // If it doesn't exist, generate a new one.
1491 $site_url = get_site_url(); // Get the site URL.
1492 $random_salt = wp_generate_password( 20, false, false ); // Generate a random salt.
1493 // Create a hash based on the site URL and salt.
1494 $aid = hash( 'sha256', $site_url . $random_salt );
1495
1496 // Save the generated AID in the database.
1497 update_option( 'charitable_site_tracking_aid', $aid );
1498 }
1499
1500 return $aid;
1501 }
1502
1503 /**
1504 * Returns and/or create the single instance of this class.
1505 *
1506 * @since 1.8.4
1507 *
1508 * @return Charitable_Tracking
1509 */
1510 public static function get_instance() {
1511 if ( is_null( self::$instance ) ) {
1512 self::$instance = new self();
1513 }
1514
1515 return self::$instance;
1516 }
1517 }
1518
1519
1520 }
1521