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 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
← All changes | includes/admin/tracking/class-charitable-tracking.php +920 -91 1.8.11 → 1.8.13 View file →
@@ -113,10 +113,10 @@
113 113 }
114 114
115 115 $charitable_object = charitable();
116 116
117 - // get license info.
118 - $licenses = ! empty( $charitable_settings['licenses'] ) ? $charitable_settings['licenses'] : array();
117 + // Settings values reported individually. The raw licences array is
118 + // deliberately NOT among them any more; see the flat licence key below.
119 119 $country = ! empty( $charitable_settings['country'] ) ? $charitable_settings['country'] : '';
120 120 $currency = ! empty( $charitable_settings['currency'] ) ? $charitable_settings['currency'] : '';
121 121 $default_gateway = ! empty( $charitable_settings['default_gateway'] ) ? $charitable_settings['default_gateway'] : '';
122 122
@@ -130,16 +130,35 @@
130 130 $data['first_campaign'] = get_option( 'charitable_first_campaign', '' );
131 131 $data['first_donation'] = get_option( 'charitable_first_donation', '' );
132 132 $data['multisite'] = is_multisite();
133 133 $data['url'] = home_url();
134 + $data['id'] = $this->get_site_aid();
134 135 $data['themename'] = $theme_data->Name; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
135 136 $data['themeversion'] = $theme_data->Version; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
136 137 $data['email'] = get_bloginfo( 'admin_email' );
137 - $data['licenses'] = $licenses;
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();
138 153 $data['country'] = $country;
139 154 $data['currency'] = $currency;
140 155 $data['default_gateway'] = $default_gateway;
141 - $data['pro'] = function_exists( 'charitable_pro' ) ? charitable_is_pro() : false;
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;
142 161 $data['sites'] = $count_b;
143 162 $data['usagetracking'] = get_option( 'charitable_usage_tracking_config', false );
144 163 $data['usercount'] = function_exists( 'count_users' ) ? count_users()['total_users'] : '0';
145 164 $data['timezoneoffset'] = gmdate( 'P' );
@@ -156,8 +175,40 @@
156 175 $data['recommended_plugins_installed'] = get_option( 'charitable_recommended_plugins_installed', array() );
157 176 $data['recommended_plugins_activated'] = get_option( 'charitable_recommended_plugins_activated', array() );
158 177 $data['dashboard_enhance_section_views'] = get_option( 'charitable_dashboard_enhance_views', 0 );
159 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 +
160 211 // Retrieve current plugin information.
161 212 if ( ! function_exists( 'get_plugins' ) ) {
162 213 include ABSPATH . '/wp-admin/includes/plugin.php';
163 214 }
@@ -178,10 +229,219 @@
178 229
179 230 return $data;
180 231 }
181 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() {
182 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 +
183 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 + /**
184 444 * Fetch tracking data.
185 445 *
186 446 * @since 1.8.4
187 447 *
@@ -192,9 +452,10 @@
192 452 global $wpdb;
193 453
194 454 $data = array();
195 455
196 - $data['id'] = $this->get_site_aid();
456 + $data['id'] = $this->get_site_aid();
457 + $data['license_key'] = $this->get_payload_license_key();
197 458
198 459 // get the total number of campaigns.
199 460 $data['campaign_counts'] = (array) wp_count_posts( 'campaign' );
200 461
@@ -320,37 +581,85 @@
320 581 'donation'
321 582 )
322 583 );
323 584
324 - // get total all time donations.
325 - $total_donations = 0;
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 +
326 630 foreach ( $donations as $donation ) {
327 - $total_donations += $donation->total_amount;
328 - }
329 631
330 - // get total donations in the past 7 days.
331 - $donations_7_days = 0;
332 - foreach ( $donations as $donation ) {
333 - if ( strtotime( $donation->post_date ) > strtotime( '-7 days' ) ) {
334 - $donations_7_days += $donation->total_amount;
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;
335 641 }
336 - }
337 642
338 - // get total donations from the last 30 days.
339 - $donations_30_days = 0;
340 - foreach ( $donations as $donation ) {
341 - if ( strtotime( $donation->post_date ) > strtotime( '-30 days' ) ) {
342 - $donations_30_days += $donation->total_amount;
643 + if ( false !== $when && $when > $cutoff_30 ) {
644 + $donations_30_days += $amount;
645 + ++$count_30_days;
343 646 }
344 - }
345 647
346 - // get donations by payment gateway.
347 - $donations_payment_gateway = array();
348 - foreach ( $donations as $donation ) {
349 - if ( ! isset( $donations[ $donation->payment_gateway ] ) ) {
350 - $donations_payment_gateway[ $donation->payment_gateway ] = 0;
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;
351 653 }
352 - $donations_payment_gateway[ $donation->payment_gateway ] += $donation->total_amount;
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;
353 662 }
354 663
355 664 return array(
356 665 'total_donations' => $total_donations,
@@ -356,8 +665,13 @@
356 665 'total_donations' => $total_donations,
357 666 'donations_7_days' => $donations_7_days,
358 667 'donations_30_days' => $donations_30_days,
359 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,
360 674 );
361 675 }
362 676
363 677 /**
@@ -379,29 +693,41 @@
379 693 $where_sql = array();
380 694 $where_sql[] = 'WHERE 1=1';
381 695 $where_sql[] = 'p.post_type = "%s"';
382 696 $where_sql[] = 'p.post_status = "charitable-active"';
383 - $where_sql[] = 'pm1.meta_key = "donation_gateway"';
384 - $where_sql[] = 'pm2.meta_key = "donation_period"';
385 - $where_sql[] = 'pm3.meta_key = "_first_donation"';
386 - $where_sql[] = 'pm4.meta_key = "_expiration_date"';
387 697
388 698 // remove all empty values from the array.
389 699 $where_sql = array_filter( $where_sql );
390 700 $where_args = implode( ' AND ', $where_sql );
391 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 + */
392 709 $left_join = array();
393 - $left_join[] = $wpdb->prefix . 'postmeta pm1 ON p.ID = pm1.post_id';
394 - $left_join[] = $wpdb->prefix . 'postmeta pm2 ON p.ID = pm2.post_id';
395 - $left_join[] = $wpdb->prefix . 'postmeta pm3 ON p.ID = pm3.post_id';
396 - $left_join[] = $wpdb->prefix . 'postmeta pm4 ON p.ID = pm4.post_id';
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"';
397 713 $left_join = array_filter( $left_join );
398 714 $left_join_args = 'LEFT JOIN ' . implode( ' LEFT JOIN ', $left_join );
399 715
400 - $sql = "SELECT SUM(pm4.meta_value) AS total_recurring_amount,
401 - pm1.meta_value AS payment_gateway,
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,
402 729 pm2.meta_value AS donation_period,
403 - pm3.meta_value AS first_donation,
404 730 pm4.meta_value AS expiration_date,
405 731 p.post_date AS post_date,
406 732 p.post_date_gmt AS post_date_gmt,
407 733 p.post_status AS donation_status,
@@ -417,50 +743,87 @@
417 743 'recurring_donation'
418 744 )
419 745 );
420 746
421 - $total_recurring_amount = $recurring_donations ? array_sum( wp_list_pluck( $recurring_donations, 'first_donation' ) ) : 0;
747 + return $this->summarise_recurring_donations( is_array( $recurring_donations ) ? $recurring_donations : array() );
748 + }
422 749
423 - // get recurring donations by payment gateway.
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;
424 792 $recurring_donations_payment_gateway = array();
793 + $recurring_donations_period = array();
794 +
425 795 foreach ( $recurring_donations as $donation ) {
426 - if ( ! isset( $recurring_donations_payment_gateway[ $donation->payment_gateway ] ) ) {
427 - $recurring_donations_payment_gateway[ $donation->payment_gateway ] = 0;
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;
428 808 }
429 - $recurring_donations_payment_gateway[ $donation->payment_gateway ] += $donation->first_donation;
430 - }
431 809
432 - // get recurring donations by period.
433 - $recurring_donations_period = array();
434 - foreach ( $recurring_donations as $donation ) {
435 - if ( ! isset( $recurring_donations_period[ $donation->donation_period ] ) ) {
436 - $recurring_donations_period[ $donation->donation_period ] = 0;
437 - }
438 - $recurring_donations_period[ $donation->donation_period ] += $donation->first_donation;
439 - }
810 + ++$recurring_donations_payment_gateway[ $gateway ];
440 811
441 - // get total recurring donations in the past 7 days.
442 - $recurring_donations_7_days = 0;
443 - foreach ( $recurring_donations as $donation ) {
444 - if ( strtotime( $donation->post_date ) > strtotime( '-7 days' ) ) {
445 - $recurring_donations_7_days += $donation->first_donation;
812 + $period = isset( $donation->donation_period ) ? (string) $donation->donation_period : '';
813 +
814 + if ( ! isset( $recurring_donations_period[ $period ] ) ) {
815 + $recurring_donations_period[ $period ] = 0;
446 816 }
447 - }
448 817
449 - // get total recurring donations from the last 30 days.
450 - $recurring_donations_30_days = 0;
451 - foreach ( $recurring_donations as $donation ) {
452 - if ( strtotime( $donation->post_date ) > strtotime( '-30 days' ) ) {
453 - $recurring_donations_30_days += $donation->first_donation;
454 - }
818 + ++$recurring_donations_period[ $period ];
455 819 }
456 820
457 821 return array(
458 - 'total_recurring_amount' => $total_recurring_amount,
822 + // Counts of ACTIVE subscriptions, keyed by gateway / by period. Amounts before 1.8.12.1.
459 823 'recurring_donations_payment_gateway' => $recurring_donations_payment_gateway,
460 824 'recurring_donations_period' => $recurring_donations_period,
461 - 'recurring_donations_7_days' => $recurring_donations_7_days,
462 - 'recurring_donations_30_days' => $recurring_donations_30_days,
825 + 'recurring_donations_count' => $recurring_count,
463 826 );
464 827 }
465 828
466 829 /**
@@ -494,9 +857,9 @@
494 857 }
495 858
496 859 // Send a maximum of once per week.
497 860 $last_send = get_option( 'charitable_usage_tracking_last_checkin' );
498 - if ( is_numeric( $last_send ) && $last_send > strtotime( '-1 week' ) && ! $ignore_last_checkin ) {
861 + if ( $this->checkin_is_throttled( $last_send ) && ! $ignore_last_checkin ) {
499 862 if ( defined( 'CHARITABLE_DEBUG_USAGE' ) && CHARITABLE_DEBUG_USAGE ) { // phpcs:disable
500 863 // phpcs:disable
501 864 if ( charitable_is_debug() ) {
502 865 error_log('charitable usage tracking not allowed because of last checkin');
@@ -532,17 +895,9 @@
532 895 }
533 896 // phpcs:enable
534 897 }
535 898
536 - if ( ! is_wp_error( $request ) ) {
537 - // If we have completed successfully, recheck in 1 week.
538 - update_option( 'charitable_usage_tracking_last_checkin', time() );
539 - return true;
540 - } else {
541 - // If we have failed, recheck in 24 hours.
542 - update_option( 'charitable_usage_tracking_last_checkin', time() + 86400 );
543 - return false;
544 - }
899 + return $this->handle_checkin_result( 'usage', $request, 'charitable_usage_tracking_last_checkin' );
545 900 }
546 901
547 902 /**
548 903 * Send tracking data.
@@ -587,9 +942,9 @@
587 942 }
588 943
589 944 // Send a maximum of once per week.
590 945 $last_send = get_option( 'charitable_tracking_last_checkin' );
591 - if ( is_numeric( $last_send ) && $last_send > strtotime( '-1 week' ) && ! $ignore_last_checkin ) {
946 + if ( $this->checkin_is_throttled( $last_send ) && ! $ignore_last_checkin ) {
592 947 if ( defined( 'CHARITABLE_DEBUG_USAGE' ) && CHARITABLE_DEBUG_USAGE ) { // phpcs:disable
593 948 // phpcs:disable
594 949 if ( charitable_is_debug() ) {
595 950 error_log('charitable tracking not allowed because of last checkin');
@@ -625,17 +980,20 @@
625 980 error_log( print_r( $this->get_tracking_data(), true ) );
626 981 }
627 982 }
628 983
629 - if ( ! is_wp_error( $request ) ) {
630 - // If we have completed successfully, recheck in 1 week.
631 - update_option( 'charitable_usage_tracking_last_checkin', time() );
632 - return true;
633 - } else {
634 - // If we have failed, recheck in 24 hours.
635 - update_option( 'charitable_usage_tracking_last_checkin', time() + 86400 );
636 - return false;
637 - }
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' );
638 996 }
639 997
640 998 /**
641 999 * Check if optin usage tracking is allowed.
@@ -645,16 +1003,337 @@
645 1003 * @return boolean
646 1004 */
647 1005 private function usage_optin_allowed() {
648 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.
649 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 +
650 1150 return true;
651 1151 }
652 1152
653 - return (bool) apply_filters( 'charitable_usage_tracking', charitable_get_usage_tracking_setting() );
1153 + $this->record_checkin_failure( $endpoint, $failure );
1154 + update_option( $option_name, $this->get_retry_timestamp() );
1155 +
1156 + return false;
654 1157 }
655 1158
656 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 + /**
657 1336 * Check if tracking is allowed.
658 1337 *
659 1338 * @since 1.8.4
660 1339 *
@@ -661,10 +1340,13 @@
661 1340 * @return boolean
662 1341 */
663 1342 private function tracking_allowed() {
664 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.
665 1347 if ( defined( 'CHARITABLE_DISABLE_TRACKING' ) && CHARITABLE_DISABLE_TRACKING ) {
666 - return true;
1348 + return false;
667 1349 }
668 1350
669 1351 if ( ! $this->environment_allows_tracking() ) {
670 1352 return false;
@@ -669,9 +1351,9 @@
669 1351 if ( ! $this->environment_allows_tracking() ) {
670 1352 return false;
671 1353 }
672 1354
673 - return (bool) apply_filters( 'charitable_usage_tracking', charitable_get_usage_tracking_setting() );
1355 + return (bool) apply_filters( 'charitable_usage_tracking', $this->get_usage_tracking_setting() );
674 1356 }
675 1357
676 1358 /**
677 1359 * Whether the environment allows sending telemetry data.
@@ -706,9 +1388,24 @@
706 1388 $tracking['offset'] = ( $tracking['day'] * DAY_IN_SECONDS ) +
707 1389 ( $tracking['hour'] * HOUR_IN_SECONDS ) +
708 1390 ( $tracking['minute'] * MINUTE_IN_SECONDS ) +
709 1391 $tracking['second'];
710 - $tracking['initsend'] = strtotime( "next sunday" ) + $tracking['offset'];
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 );
711 1408
712 1409 wp_schedule_event( $tracking['initsend'], 'weekly', 'charitable_usage_tracking_cron' );
713 1410 update_option( 'charitable_usage_tracking_config', $tracking );
714 1411 }
@@ -884,8 +1581,140 @@
884 1581 */
885 1582 public function check_if_wc_active() {
886 1583 // Check if WooCommerce is active.
887 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 );
888 1717 }
889 1718
890 1719 /**
891 1720 * Get the AID for the site.