PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / trunk
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management vtrunk
1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
suredonation / inc / admin / analytics.php

analytics.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management trunk, at inc/admin/analytics.php

1,234 lines 42.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Analytics class helps to connect BSF Analytics.
4 *
5 * @package SureDonation
6 */
7
8 namespace SureDonation\Inc\Admin;
9
10 use SureDonation\Inc\Campaign_Templates\Campaign_Templates;
11 use SureDonation\Inc\Campaigns\Campaign_Cpt;
12 use SureDonation\Inc\Helper;
13 use SureDonation\Inc\Payments\Offline\Offline_Helper;
14 use SureDonation\Inc\Payments\Payment_Helper;
15 use SureDonation\Inc\Payments\PayPal\PayPal_Helper;
16 use SureDonation\Inc\Payments\Stripe\Stripe_Helper;
17 use SureDonation\Inc\Privacy\Privacy_Settings;
18 use SureDonation\Inc\Traits\Get_Instance;
19
20 // Exit if accessed directly.
21 if ( ! defined( 'ABSPATH' ) ) {
22 exit;
23 }
24
25 /**
26 * Analytics class.
27 *
28 * @since 1.0.0
29 */
30 class Analytics {
31 use Get_Instance;
32
33 /**
34 * Allowlist of React admin-notice / UI analytics events.
35 *
36 * Single source of truth for the track-notice-event REST endpoint: the notice
37 * registrations (Admin::register_react_notices) and the dashboard Quick Access
38 * item set an event to one of these values, and handle_track_notice_event()
39 * only records events present here. Add a new event here (and reference it on
40 * the notice/item) to track it end-to-end.
41 *
42 * @var array<string, string>
43 * @since 1.3.0
44 */
45 public const TRACKED_EVENTS = [
46 'configure_gateway' => 'configure_gateway_notice_react_cta',
47 'webhook' => 'webhook_notice_react_cta',
48 'test_mode' => 'test_mode_notice_react_cta',
49 'quick_access' => 'quick_access_configure_gateway_react_cta',
50 // The PayPal settings panel warns when PayPal has told us the connected
51 // account cannot be paid. The CTA sends the merchant to PayPal to finish
52 // setup; the dismiss says they saw it and moved on, which is worth
53 // separating from never having seen it.
54 'paypal_account' => 'paypal_account_warning_react_cta',
55 'paypal_account_x' => 'paypal_account_warning_react_dismiss',
56 'paypal_webhook_x' => 'paypal_webhook_error_react_dismiss',
57 // The Stripe settings panel warns when Stripe has told us the connected
58 // account cannot charge cards. The CTA sends the site owner to their
59 // Stripe dashboard to resolve it; the dismiss says they saw it and moved
60 // on, which is worth separating from never having seen it.
61 'stripe_account' => 'stripe_account_warning_react_cta',
62 'stripe_account_x' => 'stripe_account_warning_react_dismiss',
63 // The form-side capability notices link here with ?sd_notice=, so the
64 // settings screen records that one of them is what brought the admin
65 // over. Their own page is a public donation form, which is no place to
66 // be loading a tracker.
67 'stripe_capability' => 'stripe_capability_notice_cta',
68 // The other three donation-form notices reach the settings screen the
69 // same way. Their own page is a public donation form, which is no place
70 // to be loading a tracker, so the CTA carries a marker and the arrival
71 // is what gets recorded.
72 'frontend_test_mode' => 'frontend_test_mode_notice_cta',
73 'frontend_gateway_unavailable' => 'frontend_gateway_unavailable_notice_cta',
74 'frontend_gateway_setup' => 'frontend_gateway_setup_notice_cta',
75 ];
76
77 /**
78 * BSF_Analytics_Events instance for one-time event tracking.
79 *
80 * @var \BSF_Analytics_Events|null
81 * @since 1.0.0
82 */
83 private static $events = null;
84
85 /**
86 * Request-cached donation aggregates.
87 *
88 * @var array<string, int>|null
89 * @since 1.0.0
90 */
91 private static $donation_aggregates = null;
92
93 /**
94 * Class constructor.
95 *
96 * @return void
97 * @since 1.0.0
98 */
99 public function __construct() {
100 /*
101 * Entity registration is deferred to init priority 0 so that add-on
102 * plugins (e.g. SureDonation Pro) registering filters such as
103 * suredonation_deactivation_survey_data on plugins_loaded are in
104 * place before the deactivation survey data is filtered, and the
105 * entity is still set before the BSF Analytics loader consumes it on
106 * init priority 10.
107 */
108 add_action( 'init', [ $this, 'register_entity' ], 0 );
109
110 // REST route the React admin app calls to record notice/UI clicks.
111 add_action( 'rest_api_init', [ $this, 'register_routes' ] );
112
113 add_filter( 'bsf_core_stats', [ $this, 'add_suredonation_analytics_data' ] );
114
115 // Keep analytics sends (and their stat queries) off the frontend.
116 add_filter( 'suredonation_tracking_enabled', [ $this, 'restrict_tracking_to_admin' ] );
117
118 // Event tracking hooks. Registered outside is_admin() on purpose —
119 // onboarding completion and campaign publishes fire during REST requests.
120 add_action( 'suredonation_onboarding_user_details_saved', [ $this, 'track_onboarding_completed' ] );
121 add_action( 'suredonation_campaign_tour_shown', [ $this, 'track_campaign_tour_shown' ] );
122 add_action( 'suredonation_campaign_tour_outcome', [ $this, 'track_campaign_tour_outcome' ], 10, 2 );
123 add_action( 'suredonation_campaign_template_picker_opened', [ $this, 'track_campaign_template_picker_opened' ] );
124 add_action( 'suredonation_campaign_created_from_template', [ $this, 'track_campaign_created_from_template' ] );
125 add_action( 'transition_post_status', [ $this, 'track_first_campaign_published' ], 10, 3 );
126 add_action( 'current_screen', [ $this, 'track_first_campaign_editor_opened' ] );
127 add_action( 'suredonation_privacy_data_exported', [ $this, 'track_privacy_data_exported' ] );
128 add_action( 'suredonation_privacy_data_erased', [ $this, 'track_privacy_data_erased' ] );
129
130 // Detect state-based events (daily throttle; dedup prevents repeat
131 // tracking). Admin-only so the detection never runs on the frontend.
132 if ( is_admin() ) {
133 $this->detect_state_events();
134 }
135 }
136
137 /**
138 * Register the SureDonation entity with the BSF Analytics loader.
139 *
140 * Runs on init priority 0 — after add-on plugins have registered their
141 * filters on plugins_loaded, and before the loader's own init callback
142 * loads the analytics library.
143 *
144 * @return void
145 * @since 1.0.0
146 */
147 public function register_entity() {
148 if ( ! class_exists( 'BSF_Analytics_Loader' ) ) {
149 require_once SUREDONATION_DIR . 'inc/lib/bsf-analytics/class-bsf-analytics-loader.php';
150 }
151
152 if ( ! class_exists( 'BSF_Admin_Notices' ) ) {
153 require_once SUREDONATION_DIR . 'inc/lib/astra-notices/class-bsf-admin-notices.php';
154 }
155
156 /**
157 * The loader's get_instance() carries no return type.
158 *
159 * @var \BSF_Analytics_Loader $suredonation_bsf_analytics
160 */
161 $suredonation_bsf_analytics = \BSF_Analytics_Loader::get_instance();
162
163 $suredonation_bsf_analytics->set_entity(
164 [
165 'suredonation' => [
166 'product_name' => 'SureDonation',
167 'path' => SUREDONATION_DIR . 'inc/lib/bsf-analytics',
168 'author' => 'SureDonation',
169 'time_to_display' => '+24 hours',
170 'deactivation_survey' => apply_filters(
171 'suredonation_deactivation_survey_data',
172 [
173 [
174 'id' => 'deactivation-survey-suredonation',
175 'popup_logo' => SUREDONATION_URL . 'images/suredonation-icon.svg',
176 'plugin_slug' => 'suredonation',
177 'popup_title' => __( 'Quick Feedback', 'suredonation' ),
178 'support_url' => 'https://suredonation.com/support/',
179 'popup_description' => __( 'If you have a moment, please share why you are deactivating SureDonation:', 'suredonation' ),
180 'show_on_screens' => [ 'plugins' ],
181 'plugin_version' => SUREDONATION_VER,
182 ],
183 ]
184 ),
185 'hide_optin_checkbox' => true,
186 ],
187 ]
188 );
189 }
190
191 /**
192 * Get the shared BSF_Analytics_Events instance.
193 *
194 * Uses SureDonation's Helper option methods so the event data stays
195 * inside the consolidated suredonation_options row.
196 *
197 * @return \BSF_Analytics_Events|null Events instance, or null when the library is unavailable.
198 * @since 1.0.0
199 */
200 public static function events() {
201 if ( null === self::$events ) {
202 if ( ! class_exists( 'BSF_Analytics_Events' ) ) {
203 $events_file = SUREDONATION_DIR . 'inc/lib/bsf-analytics/class-bsf-analytics-events.php';
204 if ( file_exists( $events_file ) ) {
205 require_once $events_file;
206 }
207 }
208
209 if ( ! class_exists( 'BSF_Analytics_Events' ) ) {
210 return null;
211 }
212
213 self::$events = new \BSF_Analytics_Events(
214 'suredonation',
215 [
216 'get' => [ Helper::class, 'get_suredonation_option' ],
217 'update' => [ Helper::class, 'update_suredonation_option' ],
218 ]
219 );
220 }
221
222 return self::$events;
223 }
224
225 /**
226 * Register REST routes.
227 *
228 * Hooked - rest_api_init
229 *
230 * @return void
231 * @since 1.3.0
232 */
233 public function register_routes() {
234 register_rest_route(
235 'suredonation/v1',
236 '/track-notice-event',
237 [
238 'methods' => \WP_REST_Server::CREATABLE,
239 'callback' => [ $this, 'handle_track_notice_event' ],
240 // A named method rather than a closure: the capability guard in
241 // tests/unit/inc/test-rest-api.php introspects every write
242 // route's permission_callback, and a closure is opaque to it.
243 'permission_callback' => [ $this, 'check_permissions' ],
244 'args' => [
245 'event' => [
246 'type' => 'string',
247 'required' => true,
248 ],
249 ],
250 ]
251 );
252 }
253
254 /**
255 * Whether the current user may record notice events.
256 *
257 * Named check_permissions to match the other REST controllers, which is also
258 * what the write-route capability guard asserts on.
259 *
260 * @return bool True when the user can manage options.
261 * @since 1.4.0
262 */
263 public function check_permissions() {
264 return current_user_can( 'manage_options' );
265 }
266
267 /**
268 * Record a React notice/UI interaction event.
269 *
270 * Validates the event against an allowlist (so arbitrary events cannot be
271 * injected) and records it via the shared analytics events, respecting the
272 * usage-tracking opt-in. Event names are suffixed `_react` to keep them
273 * distinct from the wp-admin notice events.
274 *
275 * @param \WP_REST_Request<array<string, mixed>> $request REST request.
276 * @return \WP_REST_Response
277 * @since 1.3.0
278 */
279 public function handle_track_notice_event( $request ) {
280 $event = sanitize_key( (string) $request->get_param( 'event' ) );
281
282 if ( ! in_array( $event, self::TRACKED_EVENTS, true ) ) {
283 return new \WP_REST_Response( [ 'success' => false ], 400 );
284 }
285
286 $events = self::events();
287 if ( null !== $events ) {
288 $events->track( $event );
289 }
290
291 return new \WP_REST_Response( [ 'success' => true ], 200 );
292 }
293
294 /**
295 * Callback function to add SureDonation specific analytics data.
296 *
297 * @param array<string, mixed> $stats_data Existing stats data.
298 * @return array<string, mixed>
299 * @since 1.0.0
300 */
301 public function add_suredonation_analytics_data( $stats_data ) {
302 $aggregates = $this->get_donation_aggregates();
303 $campaign_counts = wp_count_posts( 'suredonation_cmpgn' );
304 $form_counts = wp_count_posts( 'suredonation_form' );
305
306 $bsf_internal_referrer = get_option( 'bsf_product_referers', [] );
307 $internal_referer = is_array( $bsf_internal_referrer ) && ! empty( $bsf_internal_referrer['suredonation'] )
308 ? sanitize_text_field( (string) $bsf_internal_referrer['suredonation'] )
309 : 'self';
310
311 $privacy_settings = Privacy_Settings::get_settings();
312
313 // Computed once: the headline total below is derived from the same rows.
314 $template_usage = $this->get_campaign_template_usage();
315
316 $plugin_data = [
317 'free_version' => SUREDONATION_VER,
318 'numeric_values' => [
319 'total_campaigns' => absint( $campaign_counts->publish ?? 0 ),
320 'total_donation_forms' => absint( $form_counts->publish ?? 0 ),
321 'total_donations' => $aggregates['total'],
322 'completed_donations' => $aggregates['completed'],
323 'recurring_donations' => $aggregates['recurring'],
324 'total_donors' => $this->get_total_donors(),
325 'forms_with_image_block' => $this->get_image_block_form_count(),
326 'posts_with_social_sharing_block' => $this->get_social_sharing_block_count(),
327 'stripe_accounts_count' => count( Stripe_Helper::get_all_accounts() ),
328 // Campaigns started from a gallery template — excludes both the
329 // scratch path and the `general` fallback, so this is the count
330 // of campaigns that actually adopted a cause template.
331 'campaigns_from_template' => array_sum(
332 array_diff_key(
333 $template_usage,
334 [
335 'scratch' => 0,
336 Campaign_Templates::GENERAL => 0,
337 ]
338 )
339 ),
340 ],
341 'boolean_values' => [
342 'stripe_enabled' => Stripe_Helper::is_stripe_connected(),
343 'paypal_enabled' => PayPal_Helper::is_paypal_connected(),
344 'offline_enabled' => Offline_Helper::is_offline_enabled(),
345 // True only when the OttoKit plugin is active AND authenticated,
346 // so this implies the plugin is active.
347 'ottokit_connected' => Helper::is_suretriggers_ready(),
348 'contact_consent_enabled' => ! empty( $privacy_settings['contact_consent_field'] ),
349 'privacy_policy_field_enabled' => ! empty( $privacy_settings['privacy_policy_field'] ),
350 'terms_field_enabled' => ! empty( $privacy_settings['terms_conditions_field'] ),
351 ],
352 'data_retention_period' => isset( $privacy_settings['minimum_data_retention_period'] ) ? Helper::get_string_value( $privacy_settings['minimum_data_retention_period'] ) : 'none',
353 'block_usage' => $this->get_block_usage(),
354 'campaign_template_usage' => $template_usage,
355 'elementor_widget_usage' => $this->get_elementor_widget_usage(),
356 'bricks_element_usage' => $this->get_bricks_element_usage(),
357 'internal_referer' => $internal_referer,
358 ];
359
360 // Add KPI tracking data.
361 $kpi_data = $this->get_kpi_tracking_data();
362 if ( ! empty( $kpi_data ) ) {
363 $plugin_data['kpi_records'] = $kpi_data;
364 }
365
366 // Flush pending events into payload (only if any exist).
367 $events = self::events();
368 if ( null !== $events ) {
369 $pending_events = $events->flush_pending();
370 if ( ! empty( $pending_events ) ) {
371 $plugin_data['events_record'] = $pending_events;
372 }
373 }
374
375 if ( ! isset( $stats_data['plugin_data'] ) || ! is_array( $stats_data['plugin_data'] ) ) {
376 $stats_data['plugin_data'] = [];
377 }
378
379 $stats_data['plugin_data']['suredonation'] = $plugin_data;
380
381 return $stats_data;
382 }
383
384 /**
385 * Keep analytics sends off the frontend.
386 *
387 * Filter callback for `suredonation_tracking_enabled`. The library
388 * evaluates this on every request via `is_tracking_enabled()`; gating on
389 * is_admin() means the stats queries never run on frontend page loads.
390 * Deliberately NOT narrowed further (e.g. to plugin screens): the library
391 * also consults this filter from `register_usage_tracking_setting()` on
392 * admin_init, where returning false aborts settings registration for all
393 * registered BSF products.
394 *
395 * @param bool $is_enabled Whether tracking is enabled (opt-in state).
396 * @return bool
397 * @since 1.0.0
398 */
399 public function restrict_tracking_to_admin( $is_enabled ) {
400 return $is_enabled && is_admin();
401 }
402
403 /**
404 * Track onboarding completion when lead-capture details are saved.
405 *
406 * The payload contains PII (name/email) — only the opt-in flag is
407 * forwarded to analytics.
408 *
409 * @param array<string, mixed> $payload Sanitized onboarding payload.
410 * @return void
411 * @since 1.0.0
412 */
413 public function track_onboarding_completed( $payload ) {
414 $events = self::events();
415 if ( null === $events ) {
416 return;
417 }
418
419 $payload = is_array( $payload ) ? $payload : [];
420
421 $events->track(
422 'onboarding_completed',
423 '',
424 [
425 'opted_in' => ! empty( $payload['opted_in'] ) ? 'yes' : 'no',
426 ]
427 );
428 }
429
430 /**
431 * Track the first time the campaign guided tour is shown to a user.
432 *
433 * Fired from the REST layer on the tour's first render. The events tracker
434 * dedups by name, so this is recorded once per site regardless of how many
435 * users see the tour or how often it re-triggers.
436 *
437 * @return void
438 * @since 1.5.0
439 */
440 public function track_campaign_tour_shown() {
441 $events = self::events();
442 if ( null === $events ) {
443 return;
444 }
445
446 $events->track( 'campaign_tour_shown' );
447 }
448
449 /**
450 * Track how a campaign guided-tour run ended.
451 *
452 * `campaign_tour_shown` tells us the tour was seen; these tell us whether it
453 * worked. Four outcomes, each recorded under its own event name:
454 *
455 * - `completed` — the user reached the final step.
456 * - `dismissed` — closed part-way; the step key rides along as the
457 * event value so we can see where runs are abandoned.
458 * - `opted_out` — ticked "Don't show this again".
459 * - `manual_started` — replayed deliberately via "Take a tour".
460 *
461 * `dismissed` is tracked with $force so the most recent drop-off step wins
462 * rather than only the first one ever recorded on the site; the others keep
463 * the default once-per-site semantics.
464 *
465 * @param string $outcome How the run ended. Unknown values are ignored.
466 * @param string $step Step key the run ended on. Only used for `dismissed`.
467 * @return void
468 * @since 1.5.0
469 */
470 public function track_campaign_tour_outcome( $outcome, $step = '' ) {
471 $events = self::events();
472 if ( null === $events ) {
473 return;
474 }
475
476 $outcome = Helper::get_string_value( $outcome );
477 $step = Helper::get_string_value( $step );
478
479 switch ( $outcome ) {
480 case 'completed':
481 $events->track( 'campaign_tour_completed' );
482 break;
483 case 'dismissed':
484 // Retrackable: the latest abandonment point is the useful one.
485 $events->track( 'campaign_tour_dismissed', $step, [], true );
486 break;
487 case 'opted_out':
488 $events->track( 'campaign_tour_opted_out', $step );
489 break;
490 case 'manual_started':
491 $events->track( 'campaign_tour_manual_started' );
492 break;
493 }
494 }
495
496 /**
497 * Track the first time the campaign template picker is opened.
498 *
499 * Deduped, so it answers "did this site ever discover the picker?" — the
500 * denominator for template adoption, since `campaign_template_usage` in the
501 * stats payload only counts campaigns that were actually created from one.
502 *
503 * @return void
504 * @since 1.5.0
505 */
506 public function track_campaign_template_picker_opened() {
507 $events = self::events();
508 if ( null === $events ) {
509 return;
510 }
511
512 $events->track( 'campaign_template_picker_opened' );
513 }
514
515 /**
516 * Track the first campaign a site creates from a template.
517 *
518 * Deduped, so the event value is the template the site reached for *first* —
519 * the running per-template totals live in `campaign_template_usage` on the
520 * stats payload, which is recomputed on every send.
521 *
522 * @param string $template_id Template the campaign was created from.
523 * @return void
524 * @since 1.5.0
525 */
526 public function track_campaign_created_from_template( $template_id ) {
527 $events = self::events();
528 if ( null === $events ) {
529 return;
530 }
531
532 $template_id = Helper::get_string_value( $template_id );
533 if ( '' === $template_id ) {
534 return;
535 }
536
537 $events->track( 'first_campaign_from_template', $template_id );
538 }
539
540 /**
541 * Track first personal-data export that included SureDonation data
542 * (adoption event — deduped, sent once).
543 *
544 * @since 1.2.0
545 * @return void
546 */
547 public function track_privacy_data_exported() {
548 $events = self::events();
549 if ( null === $events ) {
550 return;
551 }
552
553 $events->track( 'privacy_data_export_used' );
554 }
555
556 /**
557 * Track first personal-data erasure processed for SureDonation data
558 * (adoption event — deduped, sent once).
559 *
560 * @since 1.2.0
561 * @param array<string, mixed> $outcome Erasure outcome flags.
562 * @return void
563 */
564 public function track_privacy_data_erased( $outcome ) {
565 $events = self::events();
566 if ( null === $events ) {
567 return;
568 }
569
570 $outcome = is_array( $outcome ) ? $outcome : [];
571
572 $events->track(
573 'privacy_data_erasure_used',
574 '',
575 [
576 'items_removed' => ! empty( $outcome['items_removed'] ) ? 'yes' : 'no',
577 'items_retained' => ! empty( $outcome['items_retained'] ) ? 'yes' : 'no',
578 'erase_failed' => ! empty( $outcome['erase_failed'] ) ? 'yes' : 'no',
579 ]
580 );
581 }
582
583 /**
584 * Track first time a campaign is published (activation event).
585 *
586 * @param string $new_status New post status.
587 * @param string $old_status Old post status.
588 * @param \WP_Post $post Post object.
589 * @return void
590 * @since 1.0.0
591 */
592 public function track_first_campaign_published( $new_status, $old_status, $post ) {
593 if ( 'publish' !== $new_status || 'publish' === $old_status || ! $post instanceof \WP_Post || 'suredonation_cmpgn' !== $post->post_type ) {
594 return;
595 }
596
597 $events = self::events();
598 if ( null === $events ) {
599 return;
600 }
601
602 $meta = Helper::get_campaign_meta( $post->ID );
603 $goal_amount = isset( $meta['goal_amount'] ) && is_numeric( $meta['goal_amount'] ) ? (float) $meta['goal_amount'] : 0.0;
604 $goal_type = isset( $meta['goal_type'] ) && is_scalar( $meta['goal_type'] ) ? sanitize_text_field( (string) $meta['goal_type'] ) : '';
605
606 $events->track(
607 'first_campaign_published',
608 (string) $post->ID,
609 [
610 'goal_type' => $goal_type,
611 'has_goal' => $goal_amount > 0 ? '1' : '0',
612 ]
613 );
614 }
615
616 /**
617 * Track first time a user opens the campaign editor.
618 *
619 * @param \WP_Screen $screen Current screen object.
620 * @return void
621 * @since 1.0.0
622 */
623 public function track_first_campaign_editor_opened( $screen ) {
624 if ( ! $screen instanceof \WP_Screen || 'suredonation_cmpgn' !== $screen->post_type || 'post' !== $screen->base ) {
625 return;
626 }
627
628 $events = self::events();
629 if ( null === $events ) {
630 return;
631 }
632
633 $events->track( 'first_campaign_editor_opened' );
634 }
635
636 /**
637 * Get donation aggregates in a single request-cached query.
638 *
639 * Feeds both the stats numeric values and the state-event detection so
640 * the donations table is only ever hit once per request.
641 *
642 * @return array<string, int> Aggregate counts.
643 * @since 1.0.0
644 */
645 private function get_donation_aggregates() {
646 if ( null !== self::$donation_aggregates ) {
647 return self::$donation_aggregates;
648 }
649
650 global $wpdb;
651
652 $defaults = [
653 'total' => 0,
654 'completed' => 0,
655 'completed_live' => 0,
656 'recurring' => 0,
657 'anonymous_completed' => 0,
658 'fees_covered_completed' => 0,
659 'refunded' => 0,
660 ];
661
662 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Single aggregate query on a custom table, request-cached in a static.
663 $row = $wpdb->get_row(
664 $wpdb->prepare(
665 "SELECT COUNT(*) AS total,
666 COALESCE(SUM(payment_status = 'completed'),0) AS completed,
667 COALESCE(SUM(payment_status = 'completed' AND payment_mode = 'live'),0) AS completed_live,
668 COALESCE(SUM(subscription_id IS NOT NULL AND subscription_id <> ''),0) AS recurring,
669 COALESCE(SUM(is_anonymous = 1 AND payment_status = 'completed'),0) AS anonymous_completed,
670 COALESCE(SUM(fees_covered > 0 AND payment_status = 'completed'),0) AS fees_covered_completed,
671 COALESCE(SUM(payment_status IN ('refunded','partially_refunded')),0) AS refunded
672 FROM %i",
673 $wpdb->prefix . 'suredonation_donations'
674 ),
675 ARRAY_A
676 );
677
678 self::$donation_aggregates = is_array( $row )
679 ? array_map( 'absint', array_merge( $defaults, $row ) )
680 : $defaults;
681
682 return self::$donation_aggregates;
683 }
684
685 /**
686 * Get the total number of donors.
687 *
688 * @return int
689 * @since 1.0.0
690 */
691 private function get_total_donors() {
692 global $wpdb;
693
694 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Single COUNT query on a custom table, runs only at analytics send time.
695 $count = $wpdb->get_var(
696 $wpdb->prepare(
697 'SELECT COUNT(*) FROM %i',
698 $wpdb->prefix . 'suredonation_donors'
699 )
700 );
701
702 return absint( $count );
703 }
704
705 /**
706 * How many published posts use each SureDonation campaign/donation block.
707 *
708 * A privacy-preserving usage count (no content leaves the site) so we can see
709 * which blocks are actually adopted. One conditional-SUM query (a single table
710 * scan), run only at analytics send time. Elementor/Bricks placements are not
711 * counted here (they store their config outside the block grammar) — see
712 * get_elementor_widget_usage().
713 *
714 * @return array<string, int> Block key => number of published posts using it.
715 * @since 1.2.0
716 */
717 private function get_block_usage() {
718 global $wpdb;
719
720 $blocks = [
721 'campaign_goal' => 'suredonation/campaign-goal',
722 'campaign_stats' => 'suredonation/campaign-stats',
723 'campaign_donations' => 'suredonation/campaign-donations',
724 'campaign_donors' => 'suredonation/campaign-donors',
725 'campaign_donate_button' => 'suredonation/campaign-donate-button',
726 'campaign_social_sharing' => 'suredonation/campaign-social-sharing',
727 'donation_form' => 'suredonation/donation-form',
728 ];
729
730 // prepare() fills placeholders in SQL order: the SELECT-list %s LIKEs
731 // first, then the FROM %i, then the status %s.
732 $selects = [];
733 $values = [];
734 foreach ( array_keys( $blocks ) as $key ) {
735 $selects[] = "SUM(post_content LIKE %s) AS {$key}";
736 // The space after the block name is the delimiter the serializer always
737 // emits (before attrs JSON, "-->" or "/-->"), so a future
738 // "campaign-goal-x" block can't prefix-match campaign-goal.
739 $values[] = '%' . $wpdb->esc_like( '<!-- wp:' . $blocks[ $key ] . ' ' ) . '%';
740 }
741 $values[] = $wpdb->posts;
742 $values[] = 'publish';
743
744 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- Single aggregate scan at analytics send time; SELECT list is built from hardcoded keys and %s placeholders only.
745 $row = $wpdb->get_row(
746 $wpdb->prepare( 'SELECT ' . implode( ', ', $selects ) . ' FROM %i WHERE post_status = %s', $values ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- placeholders are built alongside $values; prepare() accepts the array form.
747 ARRAY_A
748 );
749
750 $usage = [];
751 foreach ( array_keys( $blocks ) as $key ) {
752 $usage[ $key ] = absint( is_array( $row ) ? ( $row[ $key ] ?? 0 ) : 0 );
753 }
754
755 return $usage;
756 }
757
758 /**
759 * How many published campaigns were created from each campaign template.
760 *
761 * The running answer to "which template gets used, and how often" — a
762 * snapshot rather than an event, because the stats payload is rebuilt on
763 * every send while events dedup by name and fire once per site.
764 *
765 * Every known template id is seeded to 0 so the payload keeps the same shape
766 * across sites (same contract as get_block_usage()). Campaigns with no
767 * template meta — anything created before templates shipped, or via "Start
768 * from scratch" — land in `scratch`. Ids that are no longer registered are
769 * dropped rather than passed through, so a stale or hand-edited meta value
770 * can never widen the payload.
771 *
772 * @return array<string, int> Template id => number of published campaigns.
773 * @since 1.5.0
774 */
775 private function get_campaign_template_usage() {
776 global $wpdb;
777
778 $registry = Campaign_Templates::get_instance();
779
780 // Seed the known ids, plus the two buckets that are not gallery cards:
781 // `general` (the built-in fallback) and `scratch` (no meta at all).
782 $usage = [ 'scratch' => 0 ];
783 foreach ( $registry->get_all() as $template ) {
784 $id = Helper::get_string_value( $template['id'] ?? '' );
785 if ( '' !== $id ) {
786 $usage[ $id ] = 0;
787 }
788 }
789 $usage[ Campaign_Templates::GENERAL ] = 0;
790
791 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Single grouped scan at analytics send time only.
792 $rows = $wpdb->get_results(
793 $wpdb->prepare(
794 'SELECT pm.meta_value AS template_id, COUNT(*) AS total
795 FROM %i AS p
796 LEFT JOIN %i AS pm ON pm.post_id = p.ID AND pm.meta_key = %s
797 WHERE p.post_type = %s AND p.post_status = %s
798 GROUP BY pm.meta_value',
799 $wpdb->posts,
800 $wpdb->postmeta,
801 Campaign_Cpt::META_TEMPLATE_ID,
802 SUREDONATION_POST_TYPE,
803 'publish'
804 ),
805 ARRAY_A
806 );
807
808 if ( ! is_array( $rows ) ) {
809 return $usage;
810 }
811
812 foreach ( $rows as $row ) {
813 $id = Helper::get_string_value( $row['template_id'] ?? '' );
814 $total = absint( $row['total'] ?? 0 );
815
816 // No meta (NULL from the LEFT JOIN, or an empty string) => scratch.
817 if ( '' === $id ) {
818 $usage['scratch'] += $total;
819 continue;
820 }
821
822 // Only report ids we still recognise.
823 if ( array_key_exists( $id, $usage ) ) {
824 $usage[ $id ] += $total;
825 }
826 }
827
828 return $usage;
829 }
830
831 /**
832 * How many published posts use each SureDonation Elementor widget.
833 *
834 * The Elementor counterpart of get_block_usage(): widgets live in the
835 * _elementor_data postmeta (JSON with a quoted "widgetType"), not in the
836 * block grammar. Same privacy-preserving single-scan shape, run only at
837 * analytics send time.
838 *
839 * @return array<string, int> Widget key => number of published posts using it.
840 * @since 1.2.0
841 */
842 private function get_elementor_widget_usage() {
843 global $wpdb;
844
845 $widgets = [
846 'campaign_goal' => 'suredonation-campaign-goal',
847 'campaign_stats' => 'suredonation-campaign-stats',
848 'campaign_donations' => 'suredonation-campaign-donations',
849 'campaign_donors' => 'suredonation-campaign-donors',
850 'campaign_donate_button' => 'suredonation-campaign-donate-button',
851 'campaign_social_sharing' => 'suredonation-campaign-social-sharing',
852 'donation_form' => 'suredonation-donation-form',
853 ];
854
855 // prepare() fills placeholders in SQL order: the SELECT-list %s LIKEs
856 // first, then the two FROM/JOIN %i tables, then meta_key and status.
857 $selects = [];
858 $values = [];
859 foreach ( array_keys( $widgets ) as $key ) {
860 $selects[] = "SUM(pm.meta_value LIKE %s) AS {$key}";
861 // Quoted as stored in the _elementor_data JSON ("widgetType":"…"),
862 // which bounds the match on both sides.
863 $values[] = '%' . $wpdb->esc_like( '"' . $widgets[ $key ] . '"' ) . '%';
864 }
865 $values[] = $wpdb->postmeta;
866 $values[] = $wpdb->posts;
867 $values[] = '_elementor_data';
868 $values[] = 'publish';
869
870 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- Single aggregate scan at analytics send time; SELECT list is built from hardcoded keys and %s placeholders only.
871 $row = $wpdb->get_row(
872 $wpdb->prepare( 'SELECT ' . implode( ', ', $selects ) . ' FROM %i AS pm INNER JOIN %i AS p ON p.ID = pm.post_id WHERE pm.meta_key = %s AND p.post_status = %s', $values ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- placeholders are built alongside $values; prepare() accepts the array form.
873 ARRAY_A
874 );
875
876 $usage = [];
877 foreach ( array_keys( $widgets ) as $key ) {
878 $usage[ $key ] = absint( is_array( $row ) ? ( $row[ $key ] ?? 0 ) : 0 );
879 }
880
881 return $usage;
882 }
883
884 /**
885 * How many published posts use each SureDonation Bricks element.
886 *
887 * A privacy-preserving usage count (no content leaves the site) so we can see
888 * which Bricks elements are actually adopted — the Bricks counterpart of the
889 * Gutenberg block_usage stat. Bricks stores builder data as serialized element
890 * arrays in postmeta, so each element name is matched inside its quotes.
891 * One conditional-SUM query (a single scan), run only at analytics send time.
892 *
893 * @return array<string, int> Element key => number of published posts using it.
894 * @since 1.2.0
895 */
896 private function get_bricks_element_usage() {
897 global $wpdb;
898
899 $elements = [
900 'campaign_goal' => 'suredonation-campaign-goal',
901 'campaign_stats' => 'suredonation-campaign-stats',
902 'campaign_donations' => 'suredonation-campaign-donations',
903 'campaign_donors' => 'suredonation-campaign-donors',
904 'campaign_donate_button' => 'suredonation-campaign-donate-button',
905 'campaign_social_sharing' => 'suredonation-campaign-social-sharing',
906 'donation_form' => 'suredonation-donation-form',
907 ];
908
909 // prepare() fills placeholders in SQL order: the SELECT-list %s LIKEs
910 // first, then the two FROM/JOIN %i tables, then meta keys and status.
911 $selects = [];
912 $values = [];
913 foreach ( array_keys( $elements ) as $key ) {
914 $selects[] = "SUM(pm.meta_value LIKE %s) AS {$key}";
915 // Quoted as stored in the serialized Bricks element data, which
916 // bounds the match on both sides.
917 $values[] = '%' . $wpdb->esc_like( '"' . $elements[ $key ] . '"' ) . '%';
918 }
919 $values[] = $wpdb->postmeta;
920 $values[] = $wpdb->posts;
921 $values[] = '_bricks_page_content_2';
922 $values[] = '_bricks_page_header_2';
923 $values[] = '_bricks_page_footer_2';
924 $values[] = 'publish';
925
926 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- Single aggregate scan at analytics send time; SELECT list is built from hardcoded keys and %s placeholders only.
927 $row = $wpdb->get_row(
928 $wpdb->prepare( 'SELECT ' . implode( ', ', $selects ) . ' FROM %i AS pm INNER JOIN %i AS p ON p.ID = pm.post_id WHERE pm.meta_key IN ( %s, %s, %s ) AND p.post_status = %s', $values ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- placeholders are built alongside $values; prepare() accepts the array form.
929 ARRAY_A
930 );
931
932 $usage = [];
933 foreach ( array_keys( $elements ) as $key ) {
934 $usage[ $key ] = absint( is_array( $row ) ? ( $row[ $key ] ?? 0 ) : 0 );
935 }
936
937 return $usage;
938 }
939
940 /**
941 * How many published posts use the Campaign Social Sharing block.
942 *
943 * A privacy-preserving adoption count (no content leaves the site), run only
944 * at analytics send time. The trailing space is the delimiter the block
945 * serializer always emits after the block name, so a future
946 * "campaign-social-sharing-x" block can't prefix-match.
947 *
948 * @return int Number of published posts containing the block.
949 * @since 1.2.0
950 */
951 private function get_social_sharing_block_count() {
952 global $wpdb;
953
954 $like = '%' . $wpdb->esc_like( '<!-- wp:suredonation/campaign-social-sharing ' ) . '%';
955
956 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Single aggregate COUNT run only at analytics send time.
957 $count = $wpdb->get_var(
958 $wpdb->prepare(
959 'SELECT COUNT(ID) FROM %i WHERE post_status = %s AND post_content LIKE %s',
960 $wpdb->posts,
961 'publish',
962 $like
963 )
964 );
965
966 return absint( $count );
967 }
968
969 /**
970 * How many published donation forms use the Image block.
971 *
972 * A privacy-preserving adoption count (no content leaves the site), run only
973 * at analytics send time. The trailing space is the delimiter the block
974 * serializer always emits after the block name, so a future
975 * "image-x" block can't prefix-match.
976 *
977 * @return int Number of published donation forms containing the block.
978 * @since 1.3.0
979 */
980 private function get_image_block_form_count() {
981 global $wpdb;
982
983 $like = '%' . $wpdb->esc_like( '<!-- wp:suredonation/image ' ) . '%';
984
985 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Single aggregate COUNT run only at analytics send time.
986 $count = $wpdb->get_var(
987 $wpdb->prepare(
988 'SELECT COUNT(ID) FROM %i WHERE post_type = %s AND post_status = %s AND post_content LIKE %s',
989 $wpdb->posts,
990 'suredonation_form',
991 'publish',
992 $like
993 )
994 );
995
996 return absint( $count );
997 }
998
999 /**
1000 * Get KPI tracking data for the last 2 full days (excluding today).
1001 *
1002 * Single grouped query; raw revenue never enters the payload — only
1003 * the donation count and a coarse revenue tier per day.
1004 *
1005 * Date boundaries use GMT because `created_at` is written with
1006 * current_time( 'mysql', true ).
1007 *
1008 * @return array<string, array<string, array<string, mixed>>> KPI data keyed by Y-m-d date.
1009 * @since 1.0.0
1010 */
1011 private function get_kpi_tracking_data() {
1012 global $wpdb;
1013
1014 $start = gmdate( 'Y-m-d', strtotime( '-2 days' ) ) . ' 00:00:00';
1015 $end = gmdate( 'Y-m-d' ) . ' 00:00:00';
1016
1017 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Single grouped query on a custom table, runs only at analytics send time.
1018 $rows = $wpdb->get_results(
1019 $wpdb->prepare(
1020 "SELECT DATE(created_at) AS day, COUNT(*) AS donations, COALESCE(SUM(amount),0) AS revenue
1021 FROM %i
1022 WHERE payment_status = 'completed' AND created_at >= %s AND created_at < %s
1023 GROUP BY day",
1024 $wpdb->prefix . 'suredonation_donations',
1025 $start,
1026 $end
1027 ),
1028 ARRAY_A
1029 );
1030
1031 $kpi_data = [];
1032
1033 // Seed both days so dates with zero donations are still reported.
1034 for ( $i = 2; $i >= 1; $i-- ) {
1035 $day = gmdate( 'Y-m-d', strtotime( '-' . $i . ' days' ) );
1036
1037 $kpi_data[ $day ] = [
1038 'numeric_values' => [
1039 'donations' => 0,
1040 ],
1041 'string_values' => [
1042 'donation_revenue_tier' => '0',
1043 ],
1044 ];
1045 }
1046
1047 $rows = is_array( $rows ) ? $rows : [];
1048
1049 foreach ( $rows as $row ) {
1050 if ( empty( $row['day'] ) || ! isset( $kpi_data[ $row['day'] ] ) ) {
1051 continue;
1052 }
1053
1054 $kpi_data[ $row['day'] ] = [
1055 'numeric_values' => [
1056 'donations' => absint( $row['donations'] ?? 0 ),
1057 ],
1058 'string_values' => [
1059 'donation_revenue_tier' => $this->get_revenue_tier( (float) ( $row['revenue'] ?? 0 ) ),
1060 ],
1061 ];
1062 }
1063
1064 return $kpi_data;
1065 }
1066
1067 /**
1068 * Map a raw daily revenue amount to a coarse reporting tier.
1069 *
1070 * @param float $revenue Daily revenue.
1071 * @return string Revenue tier label.
1072 * @since 1.0.0
1073 */
1074 private function get_revenue_tier( float $revenue ): string {
1075 if ( $revenue <= 0 ) {
1076 return '0';
1077 }
1078 if ( $revenue < 100 ) {
1079 return '1-100';
1080 }
1081 if ( $revenue < 500 ) {
1082 return '100-500';
1083 }
1084 if ( $revenue < 1000 ) {
1085 return '500-1000';
1086 }
1087 if ( $revenue < 5000 ) {
1088 return '1000-5000';
1089 }
1090 return '5000+';
1091 }
1092
1093 /**
1094 * Detect state-based events that can't use direct hooks.
1095 *
1096 * Throttled by a daily transient; uses the request-cached donation
1097 * aggregates plus option reads only — no extra queries. The events
1098 * tracker dedups, so repeated calls are safe.
1099 *
1100 * @return void
1101 * @since 1.0.0
1102 */
1103 private function detect_state_events() {
1104 if ( get_transient( 'suredonation_state_events_checked' ) ) {
1105 return;
1106 }
1107
1108 $events = self::events();
1109 if ( null === $events ) {
1110 return; // Tracker unavailable — retry on next admin load.
1111 }
1112
1113 // Set only after the tracker is confirmed available.
1114 set_transient( 'suredonation_state_events_checked', true, DAY_IN_SECONDS );
1115
1116 $aggregates = $this->get_donation_aggregates();
1117 $mode = Payment_Helper::get_payment_mode();
1118
1119 // plugin_activated: dedup ensures this fires only once.
1120 $bsf_referrers = get_option( 'bsf_product_referers', [] );
1121 $source = is_array( $bsf_referrers ) && ! empty( $bsf_referrers['suredonation'] )
1122 ? sanitize_text_field( (string) $bsf_referrers['suredonation'] )
1123 : 'self';
1124 $events->track( 'plugin_activated', SUREDONATION_VER, [ 'source' => $source ] );
1125
1126 // plugin_updated: re-track on every version change.
1127 $tracked_version = get_option( 'suredonation_tracked_version', '' );
1128 if ( SUREDONATION_VER !== $tracked_version ) {
1129 if ( ! empty( $tracked_version ) && is_string( $tracked_version ) ) {
1130 $events->flush_pushed( [ 'plugin_updated' ] );
1131 $events->track( 'plugin_updated', SUREDONATION_VER, [ 'from_version' => $tracked_version ] );
1132 }
1133 update_option( 'suredonation_tracked_version', SUREDONATION_VER, false );
1134 }
1135
1136 // stripe_connected: detect connection state.
1137 if ( Stripe_Helper::is_stripe_connected() ) {
1138 $events->track( 'stripe_connected', $mode );
1139 }
1140
1141 // stripe_card_capability_blocked: connected but Stripe will not let the
1142 // account charge cards, so the card form is hidden and donations are
1143 // being lost or diverted. Detected here rather than where the notices
1144 // render: this is site state, not a page event, and the render path is
1145 // a public request that should not be paying for analytics.
1146 $blocked_accounts = 0;
1147 foreach ( array_keys( Stripe_Helper::get_all_accounts() ) as $blocked_candidate ) {
1148 if ( Stripe_Helper::is_card_capability_blocked( (string) $blocked_candidate, $mode ) ) {
1149 ++$blocked_accounts;
1150 }
1151 }
1152
1153 if ( $blocked_accounts > 0 ) {
1154 $events->track(
1155 'stripe_card_capability_blocked',
1156 $mode,
1157 [ 'blocked_accounts' => $blocked_accounts ]
1158 );
1159 }
1160
1161 // paypal_connected: detect connection state.
1162 if ( PayPal_Helper::is_paypal_connected() ) {
1163 $events->track( 'paypal_connected', $mode );
1164 }
1165
1166 // payment_mode_live: site switched to live payments.
1167 if ( 'live' === $mode ) {
1168 $events->track( 'payment_mode_live' );
1169 }
1170
1171 // first_donation_received: time-to-value milestone.
1172 if ( $aggregates['completed'] > 0 ) {
1173 $install_time_raw = get_site_option( 'suredonation_usage_installed_time', 0 );
1174 $install_time = is_numeric( $install_time_raw ) ? (int) $install_time_raw : 0;
1175 $days_since_install = $install_time > 0 ? (int) floor( ( time() - $install_time ) / DAY_IN_SECONDS ) : 0;
1176
1177 $events->track(
1178 'first_donation_received',
1179 Payment_Helper::get_currency(),
1180 [
1181 'days_since_install' => (string) $days_since_install,
1182 'payment_mode' => $mode,
1183 ]
1184 );
1185
1186 // first_live_donation_received: first completed LIVE donation. A
1187 // separate event with its own dedup key — first_donation_received
1188 // almost always fires on a test donation (sites start in test
1189 // mode) and the name-only dedup then suppresses it forever, so
1190 // the live milestone would otherwise never be visible. Gated on
1191 // the donation rows' own payment_mode, not the mode at detection
1192 // time, so a later mode switch can't skew the signal.
1193 if ( $aggregates['completed_live'] > 0 ) {
1194 $events->track(
1195 'first_live_donation_received',
1196 Payment_Helper::get_currency(),
1197 [
1198 'days_since_install' => (string) $days_since_install,
1199 ]
1200 );
1201 }
1202 }
1203
1204 // anonymous_donation_submitted: at least one completed anonymous donation.
1205 if ( $aggregates['anonymous_completed'] > 0 ) {
1206 $events->track( 'anonymous_donation_submitted' );
1207 }
1208
1209 // cover_fees_used: at least one completed donation covered fees.
1210 if ( $aggregates['fees_covered_completed'] > 0 ) {
1211 $events->track( 'cover_fees_used' );
1212 }
1213
1214 // first_refund_processed: at least one (partially) refunded donation.
1215 if ( $aggregates['refunded'] > 0 ) {
1216 $events->track( 'first_refund_processed' );
1217 }
1218
1219 // webhook_configured: a Stripe webhook secret is stored for the current
1220 // mode on any connected account (multi-account aware — reading only the
1221 // default account would false-negative on sites using a non-default one).
1222 $webhook_configured = false;
1223 foreach ( array_keys( Stripe_Helper::get_all_accounts() ) as $wh_account_id ) {
1224 if ( '' !== Stripe_Helper::get_webhook_secret( $mode, (string) $wh_account_id ) ) {
1225 $webhook_configured = true;
1226 break;
1227 }
1228 }
1229 if ( $webhook_configured ) {
1230 $events->track( 'webhook_configured', $mode );
1231 }
1232 }
1233 }
1234