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 +298 -46 1.8.12 → 1.8.13 View file →
@@ -175,8 +175,40 @@
175 175 $data['recommended_plugins_installed'] = get_option( 'charitable_recommended_plugins_installed', array() );
176 176 $data['recommended_plugins_activated'] = get_option( 'charitable_recommended_plugins_activated', array() );
177 177 $data['dashboard_enhance_section_views'] = get_option( 'charitable_dashboard_enhance_views', 0 );
178 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 +
179 211 // Retrieve current plugin information.
180 212 if ( ! function_exists( 'get_plugins' ) ) {
181 213 include ABSPATH . '/wp-admin/includes/plugin.php';
182 214 }
@@ -197,10 +229,219 @@
197 229
198 230 return $data;
199 231 }
200 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() {
201 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 +
202 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 + /**
203 444 * Fetch tracking data.
204 445 *
205 446 * @since 1.8.4
206 447 *
@@ -452,29 +693,41 @@
452 693 $where_sql = array();
453 694 $where_sql[] = 'WHERE 1=1';
454 695 $where_sql[] = 'p.post_type = "%s"';
455 696 $where_sql[] = 'p.post_status = "charitable-active"';
456 - $where_sql[] = 'pm1.meta_key = "donation_gateway"';
457 - $where_sql[] = 'pm2.meta_key = "donation_period"';
458 - $where_sql[] = 'pm3.meta_key = "_first_donation"';
459 - $where_sql[] = 'pm4.meta_key = "_expiration_date"';
460 697
461 698 // remove all empty values from the array.
462 699 $where_sql = array_filter( $where_sql );
463 700 $where_args = implode( ' AND ', $where_sql );
464 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 + */
465 709 $left_join = array();
466 - $left_join[] = $wpdb->prefix . 'postmeta pm1 ON p.ID = pm1.post_id';
467 - $left_join[] = $wpdb->prefix . 'postmeta pm2 ON p.ID = pm2.post_id';
468 - $left_join[] = $wpdb->prefix . 'postmeta pm3 ON p.ID = pm3.post_id';
469 - $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"';
470 713 $left_join = array_filter( $left_join );
471 714 $left_join_args = 'LEFT JOIN ' . implode( ' LEFT JOIN ', $left_join );
472 715
473 - $sql = "SELECT SUM(pm4.meta_value) AS total_recurring_amount,
474 - 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,
475 729 pm2.meta_value AS donation_period,
476 - pm3.meta_value AS first_donation,
477 730 pm4.meta_value AS expiration_date,
478 731 p.post_date AS post_date,
479 732 p.post_date_gmt AS post_date_gmt,
480 733 p.post_status AS donation_status,
@@ -499,21 +752,37 @@
499 752 *
500 753 * Extracted from get_recurring_donation_data() so it is testable without a
501 754 * $wpdb stub.
502 755 *
503 - * recurring_donations_count is NEW in 1.8.17.4 and is the point. Stage 5
504 - * Power leans on recurring activity, but the only recurring signal in the
505 - * payload was total_recurring_amount, a money figure. On the cloud side
506 - * subscription_analyses covers just 193 of 2,547 accounts and
507 - * RefreshSubscriptionAnalysisCommand never writes active_subscriptions at
508 - * all, so there was no reliable recurring COUNT anywhere.
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.
509 762 *
510 - * NOTE ON $donation->first_donation: in this query that is the
511 - * _first_donation POST META, which is a MONEY AMOUNT, not a date. It shares
512 - * a name with the charitable_first_donation option, which IS a date and is
513 - * reported separately as $data['first_donation']. Do not conflate them.
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.
514 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 + *
515 783 * @since 1.8.12
784 + * @version 1.8.12.1
516 785 *
517 786 * @param array $recurring_donations Rows from the recurring query.
518 787 * @return array
519 788 */
@@ -518,35 +787,21 @@
518 787 * @return array
519 788 */
520 789 private function summarise_recurring_donations( array $recurring_donations ) {
521 790
522 - $total_recurring_amount = 0;
523 791 $recurring_count = 0;
524 - $recurring_donations_7_days = 0;
525 - $recurring_donations_30_days = 0;
526 792 $recurring_donations_payment_gateway = array();
527 793 $recurring_donations_period = array();
528 794
529 - $cutoff_7 = strtotime( '-7 days' );
530 - $cutoff_30 = strtotime( '-30 days' );
531 -
532 795 foreach ( $recurring_donations as $donation ) {
533 796
534 - // A money amount, despite the name. See the docblock.
535 - $amount = isset( $donation->first_donation ) ? $donation->first_donation : 0;
536 - $when = isset( $donation->post_date ) ? strtotime( (string) $donation->post_date ) : false;
537 -
538 - $total_recurring_amount += $amount;
539 797 ++$recurring_count;
540 798
541 - if ( false !== $when && $when > $cutoff_7 ) {
542 - $recurring_donations_7_days += $amount;
543 - }
544 -
545 - if ( false !== $when && $when > $cutoff_30 ) {
546 - $recurring_donations_30_days += $amount;
547 - }
548 -
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.
549 804 $gateway = isset( $donation->payment_gateway ) ? (string) $donation->payment_gateway : '';
550 805
551 806 if ( ! isset( $recurring_donations_payment_gateway[ $gateway ] ) ) {
552 807 $recurring_donations_payment_gateway[ $gateway ] = 0;
@@ -551,9 +806,9 @@
551 806 if ( ! isset( $recurring_donations_payment_gateway[ $gateway ] ) ) {
552 807 $recurring_donations_payment_gateway[ $gateway ] = 0;
553 808 }
554 809
555 - $recurring_donations_payment_gateway[ $gateway ] += $amount;
810 + ++$recurring_donations_payment_gateway[ $gateway ];
556 811
557 812 $period = isset( $donation->donation_period ) ? (string) $donation->donation_period : '';
558 813
559 814 if ( ! isset( $recurring_donations_period[ $period ] ) ) {
@@ -559,18 +814,15 @@
559 814 if ( ! isset( $recurring_donations_period[ $period ] ) ) {
560 815 $recurring_donations_period[ $period ] = 0;
561 816 }
562 817
563 - $recurring_donations_period[ $period ] += $amount;
818 + ++$recurring_donations_period[ $period ];
564 819 }
565 820
566 821 return array(
567 - 'total_recurring_amount' => $total_recurring_amount,
822 + // Counts of ACTIVE subscriptions, keyed by gateway / by period. Amounts before 1.8.12.1.
568 823 'recurring_donations_payment_gateway' => $recurring_donations_payment_gateway,
569 824 'recurring_donations_period' => $recurring_donations_period,
570 - 'recurring_donations_7_days' => $recurring_donations_7_days,
571 - 'recurring_donations_30_days' => $recurring_donations_30_days,
572 - // New in 1.8.17.4.
573 825 'recurring_donations_count' => $recurring_count,
574 826 );
575 827 }
576 828