PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.4.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.4.0
1.6.0 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 1.4.0, at inc/admin/analytics.php

984 lines 33.2 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\Helper;
11 use SureDonation\Inc\Payments\Offline\Offline_Helper;
12 use SureDonation\Inc\Payments\Payment_Helper;
13 use SureDonation\Inc\Payments\PayPal\PayPal_Helper;
14 use SureDonation\Inc\Payments\Stripe\Stripe_Helper;
15 use SureDonation\Inc\Privacy\Privacy_Settings;
16 use SureDonation\Inc\Traits\Get_Instance;
17
18 // Exit if accessed directly.
19 if ( ! defined( 'ABSPATH' ) ) {
20 exit;
21 }
22
23 /**
24 * Analytics class.
25 *
26 * @since 1.0.0
27 */
28 class Analytics {
29 use Get_Instance;
30
31 /**
32 * Allowlist of React admin-notice / UI analytics events.
33 *
34 * Single source of truth for the track-notice-event REST endpoint: the notice
35 * registrations (Admin::register_react_notices) and the dashboard Quick Access
36 * item set an event to one of these values, and handle_track_notice_event()
37 * only records events present here. Add a new event here (and reference it on
38 * the notice/item) to track it end-to-end.
39 *
40 * @var array<string, string>
41 * @since 1.3.0
42 */
43 public const TRACKED_EVENTS = [
44 'configure_gateway' => 'configure_gateway_notice_react_cta',
45 'webhook' => 'webhook_notice_react_cta',
46 'test_mode' => 'test_mode_notice_react_cta',
47 'quick_access' => 'quick_access_configure_gateway_react_cta',
48 ];
49
50 /**
51 * BSF_Analytics_Events instance for one-time event tracking.
52 *
53 * @var \BSF_Analytics_Events|null
54 * @since 1.0.0
55 */
56 private static $events = null;
57
58 /**
59 * Request-cached donation aggregates.
60 *
61 * @var array<string, int>|null
62 * @since 1.0.0
63 */
64 private static $donation_aggregates = null;
65
66 /**
67 * Class constructor.
68 *
69 * @return void
70 * @since 1.0.0
71 */
72 public function __construct() {
73 /*
74 * Entity registration is deferred to init priority 0 so that add-on
75 * plugins (e.g. SureDonation Pro) registering filters such as
76 * suredonation_deactivation_survey_data on plugins_loaded are in
77 * place before the deactivation survey data is filtered, and the
78 * entity is still set before the BSF Analytics loader consumes it on
79 * init priority 10.
80 */
81 add_action( 'init', [ $this, 'register_entity' ], 0 );
82
83 // REST route the React admin app calls to record notice/UI clicks.
84 add_action( 'rest_api_init', [ $this, 'register_routes' ] );
85
86 add_filter( 'bsf_core_stats', [ $this, 'add_suredonation_analytics_data' ] );
87
88 // Keep analytics sends (and their stat queries) off the frontend.
89 add_filter( 'suredonation_tracking_enabled', [ $this, 'restrict_tracking_to_admin' ] );
90
91 // Event tracking hooks. Registered outside is_admin() on purpose —
92 // onboarding completion and campaign publishes fire during REST requests.
93 add_action( 'suredonation_onboarding_user_details_saved', [ $this, 'track_onboarding_completed' ] );
94 add_action( 'transition_post_status', [ $this, 'track_first_campaign_published' ], 10, 3 );
95 add_action( 'current_screen', [ $this, 'track_first_campaign_editor_opened' ] );
96 add_action( 'suredonation_privacy_data_exported', [ $this, 'track_privacy_data_exported' ] );
97 add_action( 'suredonation_privacy_data_erased', [ $this, 'track_privacy_data_erased' ] );
98
99 // Detect state-based events (daily throttle; dedup prevents repeat
100 // tracking). Admin-only so the detection never runs on the frontend.
101 if ( is_admin() ) {
102 $this->detect_state_events();
103 }
104 }
105
106 /**
107 * Register the SureDonation entity with the BSF Analytics loader.
108 *
109 * Runs on init priority 0 — after add-on plugins have registered their
110 * filters on plugins_loaded, and before the loader's own init callback
111 * loads the analytics library.
112 *
113 * @return void
114 * @since 1.0.0
115 */
116 public function register_entity() {
117 if ( ! class_exists( 'BSF_Analytics_Loader' ) ) {
118 require_once SUREDONATION_DIR . 'inc/lib/bsf-analytics/class-bsf-analytics-loader.php';
119 }
120
121 if ( ! class_exists( 'BSF_Admin_Notices' ) ) {
122 require_once SUREDONATION_DIR . 'inc/lib/astra-notices/class-bsf-admin-notices.php';
123 }
124
125 /**
126 * The loader's get_instance() carries no return type.
127 *
128 * @var \BSF_Analytics_Loader $suredonation_bsf_analytics
129 */
130 $suredonation_bsf_analytics = \BSF_Analytics_Loader::get_instance();
131
132 $suredonation_bsf_analytics->set_entity(
133 [
134 'suredonation' => [
135 'product_name' => 'SureDonation',
136 'path' => SUREDONATION_DIR . 'inc/lib/bsf-analytics',
137 'author' => 'SureDonation',
138 'time_to_display' => '+24 hours',
139 'deactivation_survey' => apply_filters(
140 'suredonation_deactivation_survey_data',
141 [
142 [
143 'id' => 'deactivation-survey-suredonation',
144 'popup_logo' => SUREDONATION_URL . 'images/suredonation-icon.svg',
145 'plugin_slug' => 'suredonation',
146 'popup_title' => __( 'Quick Feedback', 'suredonation' ),
147 'support_url' => 'https://suredonation.com/support/',
148 'popup_description' => __( 'If you have a moment, please share why you are deactivating SureDonation:', 'suredonation' ),
149 'show_on_screens' => [ 'plugins' ],
150 'plugin_version' => SUREDONATION_VER,
151 ],
152 ]
153 ),
154 'hide_optin_checkbox' => true,
155 ],
156 ]
157 );
158 }
159
160 /**
161 * Get the shared BSF_Analytics_Events instance.
162 *
163 * Uses SureDonation's Helper option methods so the event data stays
164 * inside the consolidated suredonation_options row.
165 *
166 * @return \BSF_Analytics_Events|null Events instance, or null when the library is unavailable.
167 * @since 1.0.0
168 */
169 public static function events() {
170 if ( null === self::$events ) {
171 if ( ! class_exists( 'BSF_Analytics_Events' ) ) {
172 $events_file = SUREDONATION_DIR . 'inc/lib/bsf-analytics/class-bsf-analytics-events.php';
173 if ( file_exists( $events_file ) ) {
174 require_once $events_file;
175 }
176 }
177
178 if ( ! class_exists( 'BSF_Analytics_Events' ) ) {
179 return null;
180 }
181
182 self::$events = new \BSF_Analytics_Events(
183 'suredonation',
184 [
185 'get' => [ Helper::class, 'get_suredonation_option' ],
186 'update' => [ Helper::class, 'update_suredonation_option' ],
187 ]
188 );
189 }
190
191 return self::$events;
192 }
193
194 /**
195 * Register REST routes.
196 *
197 * Hooked - rest_api_init
198 *
199 * @return void
200 * @since 1.3.0
201 */
202 public function register_routes() {
203 register_rest_route(
204 'suredonation/v1',
205 '/track-notice-event',
206 [
207 'methods' => \WP_REST_Server::CREATABLE,
208 'callback' => [ $this, 'handle_track_notice_event' ],
209 // A named method rather than a closure: the capability guard in
210 // tests/unit/inc/test-rest-api.php introspects every write
211 // route's permission_callback, and a closure is opaque to it.
212 'permission_callback' => [ $this, 'check_permissions' ],
213 'args' => [
214 'event' => [
215 'type' => 'string',
216 'required' => true,
217 ],
218 ],
219 ]
220 );
221 }
222
223 /**
224 * Whether the current user may record notice events.
225 *
226 * Named check_permissions to match the other REST controllers, which is also
227 * what the write-route capability guard asserts on.
228 *
229 * @return bool True when the user can manage options.
230 * @since 1.4.0
231 */
232 public function check_permissions() {
233 return current_user_can( 'manage_options' );
234 }
235
236 /**
237 * Record a React notice/UI interaction event.
238 *
239 * Validates the event against an allowlist (so arbitrary events cannot be
240 * injected) and records it via the shared analytics events, respecting the
241 * usage-tracking opt-in. Event names are suffixed `_react` to keep them
242 * distinct from the wp-admin notice events.
243 *
244 * @param \WP_REST_Request<array<string, mixed>> $request REST request.
245 * @return \WP_REST_Response
246 * @since 1.3.0
247 */
248 public function handle_track_notice_event( $request ) {
249 $event = sanitize_key( (string) $request->get_param( 'event' ) );
250
251 if ( ! in_array( $event, self::TRACKED_EVENTS, true ) ) {
252 return new \WP_REST_Response( [ 'success' => false ], 400 );
253 }
254
255 $events = self::events();
256 if ( null !== $events ) {
257 $events->track( $event );
258 }
259
260 return new \WP_REST_Response( [ 'success' => true ], 200 );
261 }
262
263 /**
264 * Callback function to add SureDonation specific analytics data.
265 *
266 * @param array<string, mixed> $stats_data Existing stats data.
267 * @return array<string, mixed>
268 * @since 1.0.0
269 */
270 public function add_suredonation_analytics_data( $stats_data ) {
271 $aggregates = $this->get_donation_aggregates();
272 $campaign_counts = wp_count_posts( 'suredonation_cmpgn' );
273 $form_counts = wp_count_posts( 'suredonation_form' );
274
275 $bsf_internal_referrer = get_option( 'bsf_product_referers', [] );
276 $internal_referer = is_array( $bsf_internal_referrer ) && ! empty( $bsf_internal_referrer['suredonation'] )
277 ? sanitize_text_field( (string) $bsf_internal_referrer['suredonation'] )
278 : 'self';
279
280 $privacy_settings = Privacy_Settings::get_settings();
281
282 $plugin_data = [
283 'free_version' => SUREDONATION_VER,
284 'numeric_values' => [
285 'total_campaigns' => absint( $campaign_counts->publish ?? 0 ),
286 'total_donation_forms' => absint( $form_counts->publish ?? 0 ),
287 'total_donations' => $aggregates['total'],
288 'completed_donations' => $aggregates['completed'],
289 'recurring_donations' => $aggregates['recurring'],
290 'total_donors' => $this->get_total_donors(),
291 'forms_with_image_block' => $this->get_image_block_form_count(),
292 'posts_with_social_sharing_block' => $this->get_social_sharing_block_count(),
293 'stripe_accounts_count' => count( Stripe_Helper::get_all_accounts() ),
294 ],
295 'boolean_values' => [
296 'stripe_enabled' => Stripe_Helper::is_stripe_connected(),
297 'paypal_enabled' => PayPal_Helper::is_paypal_connected(),
298 'offline_enabled' => Offline_Helper::is_offline_enabled(),
299 // True only when the OttoKit plugin is active AND authenticated,
300 // so this implies the plugin is active.
301 'ottokit_connected' => Helper::is_suretriggers_ready(),
302 'contact_consent_enabled' => ! empty( $privacy_settings['contact_consent_field'] ),
303 'privacy_policy_field_enabled' => ! empty( $privacy_settings['privacy_policy_field'] ),
304 'terms_field_enabled' => ! empty( $privacy_settings['terms_conditions_field'] ),
305 ],
306 'data_retention_period' => isset( $privacy_settings['minimum_data_retention_period'] ) ? Helper::get_string_value( $privacy_settings['minimum_data_retention_period'] ) : 'none',
307 'block_usage' => $this->get_block_usage(),
308 'elementor_widget_usage' => $this->get_elementor_widget_usage(),
309 'bricks_element_usage' => $this->get_bricks_element_usage(),
310 'internal_referer' => $internal_referer,
311 ];
312
313 // Add KPI tracking data.
314 $kpi_data = $this->get_kpi_tracking_data();
315 if ( ! empty( $kpi_data ) ) {
316 $plugin_data['kpi_records'] = $kpi_data;
317 }
318
319 // Flush pending events into payload (only if any exist).
320 $events = self::events();
321 if ( null !== $events ) {
322 $pending_events = $events->flush_pending();
323 if ( ! empty( $pending_events ) ) {
324 $plugin_data['events_record'] = $pending_events;
325 }
326 }
327
328 if ( ! isset( $stats_data['plugin_data'] ) || ! is_array( $stats_data['plugin_data'] ) ) {
329 $stats_data['plugin_data'] = [];
330 }
331
332 $stats_data['plugin_data']['suredonation'] = $plugin_data;
333
334 return $stats_data;
335 }
336
337 /**
338 * Keep analytics sends off the frontend.
339 *
340 * Filter callback for `suredonation_tracking_enabled`. The library
341 * evaluates this on every request via `is_tracking_enabled()`; gating on
342 * is_admin() means the stats queries never run on frontend page loads.
343 * Deliberately NOT narrowed further (e.g. to plugin screens): the library
344 * also consults this filter from `register_usage_tracking_setting()` on
345 * admin_init, where returning false aborts settings registration for all
346 * registered BSF products.
347 *
348 * @param bool $is_enabled Whether tracking is enabled (opt-in state).
349 * @return bool
350 * @since 1.0.0
351 */
352 public function restrict_tracking_to_admin( $is_enabled ) {
353 return $is_enabled && is_admin();
354 }
355
356 /**
357 * Track onboarding completion when lead-capture details are saved.
358 *
359 * The payload contains PII (name/email) — only the opt-in flag is
360 * forwarded to analytics.
361 *
362 * @param array<string, mixed> $payload Sanitized onboarding payload.
363 * @return void
364 * @since 1.0.0
365 */
366 public function track_onboarding_completed( $payload ) {
367 $events = self::events();
368 if ( null === $events ) {
369 return;
370 }
371
372 $payload = is_array( $payload ) ? $payload : [];
373
374 $events->track(
375 'onboarding_completed',
376 '',
377 [
378 'opted_in' => ! empty( $payload['opted_in'] ) ? 'yes' : 'no',
379 ]
380 );
381 }
382
383 /**
384 * Track first personal-data export that included SureDonation data
385 * (adoption event — deduped, sent once).
386 *
387 * @since 1.2.0
388 * @return void
389 */
390 public function track_privacy_data_exported() {
391 $events = self::events();
392 if ( null === $events ) {
393 return;
394 }
395
396 $events->track( 'privacy_data_export_used' );
397 }
398
399 /**
400 * Track first personal-data erasure processed for SureDonation data
401 * (adoption event — deduped, sent once).
402 *
403 * @since 1.2.0
404 * @param array<string, mixed> $outcome Erasure outcome flags.
405 * @return void
406 */
407 public function track_privacy_data_erased( $outcome ) {
408 $events = self::events();
409 if ( null === $events ) {
410 return;
411 }
412
413 $outcome = is_array( $outcome ) ? $outcome : [];
414
415 $events->track(
416 'privacy_data_erasure_used',
417 '',
418 [
419 'items_removed' => ! empty( $outcome['items_removed'] ) ? 'yes' : 'no',
420 'items_retained' => ! empty( $outcome['items_retained'] ) ? 'yes' : 'no',
421 'erase_failed' => ! empty( $outcome['erase_failed'] ) ? 'yes' : 'no',
422 ]
423 );
424 }
425
426 /**
427 * Track first time a campaign is published (activation event).
428 *
429 * @param string $new_status New post status.
430 * @param string $old_status Old post status.
431 * @param \WP_Post $post Post object.
432 * @return void
433 * @since 1.0.0
434 */
435 public function track_first_campaign_published( $new_status, $old_status, $post ) {
436 if ( 'publish' !== $new_status || 'publish' === $old_status || ! $post instanceof \WP_Post || 'suredonation_cmpgn' !== $post->post_type ) {
437 return;
438 }
439
440 $events = self::events();
441 if ( null === $events ) {
442 return;
443 }
444
445 $meta = Helper::get_campaign_meta( $post->ID );
446 $goal_amount = isset( $meta['goal_amount'] ) && is_numeric( $meta['goal_amount'] ) ? (float) $meta['goal_amount'] : 0.0;
447 $goal_type = isset( $meta['goal_type'] ) && is_scalar( $meta['goal_type'] ) ? sanitize_text_field( (string) $meta['goal_type'] ) : '';
448
449 $events->track(
450 'first_campaign_published',
451 (string) $post->ID,
452 [
453 'goal_type' => $goal_type,
454 'has_goal' => $goal_amount > 0 ? '1' : '0',
455 ]
456 );
457 }
458
459 /**
460 * Track first time a user opens the campaign editor.
461 *
462 * @param \WP_Screen $screen Current screen object.
463 * @return void
464 * @since 1.0.0
465 */
466 public function track_first_campaign_editor_opened( $screen ) {
467 if ( ! $screen instanceof \WP_Screen || 'suredonation_cmpgn' !== $screen->post_type || 'post' !== $screen->base ) {
468 return;
469 }
470
471 $events = self::events();
472 if ( null === $events ) {
473 return;
474 }
475
476 $events->track( 'first_campaign_editor_opened' );
477 }
478
479 /**
480 * Get donation aggregates in a single request-cached query.
481 *
482 * Feeds both the stats numeric values and the state-event detection so
483 * the donations table is only ever hit once per request.
484 *
485 * @return array<string, int> Aggregate counts.
486 * @since 1.0.0
487 */
488 private function get_donation_aggregates() {
489 if ( null !== self::$donation_aggregates ) {
490 return self::$donation_aggregates;
491 }
492
493 global $wpdb;
494
495 $defaults = [
496 'total' => 0,
497 'completed' => 0,
498 'completed_live' => 0,
499 'recurring' => 0,
500 'anonymous_completed' => 0,
501 'fees_covered_completed' => 0,
502 'refunded' => 0,
503 ];
504
505 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Single aggregate query on a custom table, request-cached in a static.
506 $row = $wpdb->get_row(
507 $wpdb->prepare(
508 "SELECT COUNT(*) AS total,
509 COALESCE(SUM(payment_status = 'completed'),0) AS completed,
510 COALESCE(SUM(payment_status = 'completed' AND payment_mode = 'live'),0) AS completed_live,
511 COALESCE(SUM(subscription_id IS NOT NULL AND subscription_id <> ''),0) AS recurring,
512 COALESCE(SUM(is_anonymous = 1 AND payment_status = 'completed'),0) AS anonymous_completed,
513 COALESCE(SUM(fees_covered > 0 AND payment_status = 'completed'),0) AS fees_covered_completed,
514 COALESCE(SUM(payment_status IN ('refunded','partially_refunded')),0) AS refunded
515 FROM %i",
516 $wpdb->prefix . 'suredonation_donations'
517 ),
518 ARRAY_A
519 );
520
521 self::$donation_aggregates = is_array( $row )
522 ? array_map( 'absint', array_merge( $defaults, $row ) )
523 : $defaults;
524
525 return self::$donation_aggregates;
526 }
527
528 /**
529 * Get the total number of donors.
530 *
531 * @return int
532 * @since 1.0.0
533 */
534 private function get_total_donors() {
535 global $wpdb;
536
537 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Single COUNT query on a custom table, runs only at analytics send time.
538 $count = $wpdb->get_var(
539 $wpdb->prepare(
540 'SELECT COUNT(*) FROM %i',
541 $wpdb->prefix . 'suredonation_donors'
542 )
543 );
544
545 return absint( $count );
546 }
547
548 /**
549 * How many published posts use each SureDonation campaign/donation block.
550 *
551 * A privacy-preserving usage count (no content leaves the site) so we can see
552 * which blocks are actually adopted. One conditional-SUM query (a single table
553 * scan), run only at analytics send time. Elementor/Bricks placements are not
554 * counted here (they store their config outside the block grammar) — see
555 * get_elementor_widget_usage().
556 *
557 * @return array<string, int> Block key => number of published posts using it.
558 * @since 1.2.0
559 */
560 private function get_block_usage() {
561 global $wpdb;
562
563 $blocks = [
564 'campaign_goal' => 'suredonation/campaign-goal',
565 'campaign_stats' => 'suredonation/campaign-stats',
566 'campaign_donations' => 'suredonation/campaign-donations',
567 'campaign_donors' => 'suredonation/campaign-donors',
568 'campaign_donate_button' => 'suredonation/campaign-donate-button',
569 'campaign_social_sharing' => 'suredonation/campaign-social-sharing',
570 'donation_form' => 'suredonation/donation-form',
571 ];
572
573 // prepare() fills placeholders in SQL order: the SELECT-list %s LIKEs
574 // first, then the FROM %i, then the status %s.
575 $selects = [];
576 $values = [];
577 foreach ( array_keys( $blocks ) as $key ) {
578 $selects[] = "SUM(post_content LIKE %s) AS {$key}";
579 // The space after the block name is the delimiter the serializer always
580 // emits (before attrs JSON, "-->" or "/-->"), so a future
581 // "campaign-goal-x" block can't prefix-match campaign-goal.
582 $values[] = '%' . $wpdb->esc_like( '<!-- wp:' . $blocks[ $key ] . ' ' ) . '%';
583 }
584 $values[] = $wpdb->posts;
585 $values[] = 'publish';
586
587 // 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.
588 $row = $wpdb->get_row(
589 $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.
590 ARRAY_A
591 );
592
593 $usage = [];
594 foreach ( array_keys( $blocks ) as $key ) {
595 $usage[ $key ] = absint( is_array( $row ) ? ( $row[ $key ] ?? 0 ) : 0 );
596 }
597
598 return $usage;
599 }
600
601 /**
602 * How many published posts use each SureDonation Elementor widget.
603 *
604 * The Elementor counterpart of get_block_usage(): widgets live in the
605 * _elementor_data postmeta (JSON with a quoted "widgetType"), not in the
606 * block grammar. Same privacy-preserving single-scan shape, run only at
607 * analytics send time.
608 *
609 * @return array<string, int> Widget key => number of published posts using it.
610 * @since 1.2.0
611 */
612 private function get_elementor_widget_usage() {
613 global $wpdb;
614
615 $widgets = [
616 'campaign_goal' => 'suredonation-campaign-goal',
617 'campaign_stats' => 'suredonation-campaign-stats',
618 'campaign_donations' => 'suredonation-campaign-donations',
619 'campaign_donors' => 'suredonation-campaign-donors',
620 'campaign_donate_button' => 'suredonation-campaign-donate-button',
621 'campaign_social_sharing' => 'suredonation-campaign-social-sharing',
622 'donation_form' => 'suredonation-donation-form',
623 ];
624
625 // prepare() fills placeholders in SQL order: the SELECT-list %s LIKEs
626 // first, then the two FROM/JOIN %i tables, then meta_key and status.
627 $selects = [];
628 $values = [];
629 foreach ( array_keys( $widgets ) as $key ) {
630 $selects[] = "SUM(pm.meta_value LIKE %s) AS {$key}";
631 // Quoted as stored in the _elementor_data JSON ("widgetType":"…"),
632 // which bounds the match on both sides.
633 $values[] = '%' . $wpdb->esc_like( '"' . $widgets[ $key ] . '"' ) . '%';
634 }
635 $values[] = $wpdb->postmeta;
636 $values[] = $wpdb->posts;
637 $values[] = '_elementor_data';
638 $values[] = 'publish';
639
640 // 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.
641 $row = $wpdb->get_row(
642 $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.
643 ARRAY_A
644 );
645
646 $usage = [];
647 foreach ( array_keys( $widgets ) as $key ) {
648 $usage[ $key ] = absint( is_array( $row ) ? ( $row[ $key ] ?? 0 ) : 0 );
649 }
650
651 return $usage;
652 }
653
654 /**
655 * How many published posts use each SureDonation Bricks element.
656 *
657 * A privacy-preserving usage count (no content leaves the site) so we can see
658 * which Bricks elements are actually adopted — the Bricks counterpart of the
659 * Gutenberg block_usage stat. Bricks stores builder data as serialized element
660 * arrays in postmeta, so each element name is matched inside its quotes.
661 * One conditional-SUM query (a single scan), run only at analytics send time.
662 *
663 * @return array<string, int> Element key => number of published posts using it.
664 * @since 1.2.0
665 */
666 private function get_bricks_element_usage() {
667 global $wpdb;
668
669 $elements = [
670 'campaign_goal' => 'suredonation-campaign-goal',
671 'campaign_stats' => 'suredonation-campaign-stats',
672 'campaign_donations' => 'suredonation-campaign-donations',
673 'campaign_donors' => 'suredonation-campaign-donors',
674 'campaign_donate_button' => 'suredonation-campaign-donate-button',
675 'campaign_social_sharing' => 'suredonation-campaign-social-sharing',
676 'donation_form' => 'suredonation-donation-form',
677 ];
678
679 // prepare() fills placeholders in SQL order: the SELECT-list %s LIKEs
680 // first, then the two FROM/JOIN %i tables, then meta keys and status.
681 $selects = [];
682 $values = [];
683 foreach ( array_keys( $elements ) as $key ) {
684 $selects[] = "SUM(pm.meta_value LIKE %s) AS {$key}";
685 // Quoted as stored in the serialized Bricks element data, which
686 // bounds the match on both sides.
687 $values[] = '%' . $wpdb->esc_like( '"' . $elements[ $key ] . '"' ) . '%';
688 }
689 $values[] = $wpdb->postmeta;
690 $values[] = $wpdb->posts;
691 $values[] = '_bricks_page_content_2';
692 $values[] = '_bricks_page_header_2';
693 $values[] = '_bricks_page_footer_2';
694 $values[] = 'publish';
695
696 // 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.
697 $row = $wpdb->get_row(
698 $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.
699 ARRAY_A
700 );
701
702 $usage = [];
703 foreach ( array_keys( $elements ) as $key ) {
704 $usage[ $key ] = absint( is_array( $row ) ? ( $row[ $key ] ?? 0 ) : 0 );
705 }
706
707 return $usage;
708 }
709
710 /**
711 * How many published posts use the Campaign Social Sharing block.
712 *
713 * A privacy-preserving adoption count (no content leaves the site), run only
714 * at analytics send time. The trailing space is the delimiter the block
715 * serializer always emits after the block name, so a future
716 * "campaign-social-sharing-x" block can't prefix-match.
717 *
718 * @return int Number of published posts containing the block.
719 * @since 1.2.0
720 */
721 private function get_social_sharing_block_count() {
722 global $wpdb;
723
724 $like = '%' . $wpdb->esc_like( '<!-- wp:suredonation/campaign-social-sharing ' ) . '%';
725
726 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Single aggregate COUNT run only at analytics send time.
727 $count = $wpdb->get_var(
728 $wpdb->prepare(
729 'SELECT COUNT(ID) FROM %i WHERE post_status = %s AND post_content LIKE %s',
730 $wpdb->posts,
731 'publish',
732 $like
733 )
734 );
735
736 return absint( $count );
737 }
738
739 /**
740 * How many published donation forms use the Image block.
741 *
742 * A privacy-preserving adoption count (no content leaves the site), run only
743 * at analytics send time. The trailing space is the delimiter the block
744 * serializer always emits after the block name, so a future
745 * "image-x" block can't prefix-match.
746 *
747 * @return int Number of published donation forms containing the block.
748 * @since 1.3.0
749 */
750 private function get_image_block_form_count() {
751 global $wpdb;
752
753 $like = '%' . $wpdb->esc_like( '<!-- wp:suredonation/image ' ) . '%';
754
755 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Single aggregate COUNT run only at analytics send time.
756 $count = $wpdb->get_var(
757 $wpdb->prepare(
758 'SELECT COUNT(ID) FROM %i WHERE post_type = %s AND post_status = %s AND post_content LIKE %s',
759 $wpdb->posts,
760 'suredonation_form',
761 'publish',
762 $like
763 )
764 );
765
766 return absint( $count );
767 }
768
769 /**
770 * Get KPI tracking data for the last 2 full days (excluding today).
771 *
772 * Single grouped query; raw revenue never enters the payload — only
773 * the donation count and a coarse revenue tier per day.
774 *
775 * Date boundaries use GMT because `created_at` is written with
776 * current_time( 'mysql', true ).
777 *
778 * @return array<string, array<string, array<string, mixed>>> KPI data keyed by Y-m-d date.
779 * @since 1.0.0
780 */
781 private function get_kpi_tracking_data() {
782 global $wpdb;
783
784 $start = gmdate( 'Y-m-d', strtotime( '-2 days' ) ) . ' 00:00:00';
785 $end = gmdate( 'Y-m-d' ) . ' 00:00:00';
786
787 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Single grouped query on a custom table, runs only at analytics send time.
788 $rows = $wpdb->get_results(
789 $wpdb->prepare(
790 "SELECT DATE(created_at) AS day, COUNT(*) AS donations, COALESCE(SUM(amount),0) AS revenue
791 FROM %i
792 WHERE payment_status = 'completed' AND created_at >= %s AND created_at < %s
793 GROUP BY day",
794 $wpdb->prefix . 'suredonation_donations',
795 $start,
796 $end
797 ),
798 ARRAY_A
799 );
800
801 $kpi_data = [];
802
803 // Seed both days so dates with zero donations are still reported.
804 for ( $i = 2; $i >= 1; $i-- ) {
805 $day = gmdate( 'Y-m-d', strtotime( '-' . $i . ' days' ) );
806
807 $kpi_data[ $day ] = [
808 'numeric_values' => [
809 'donations' => 0,
810 ],
811 'string_values' => [
812 'donation_revenue_tier' => '0',
813 ],
814 ];
815 }
816
817 $rows = is_array( $rows ) ? $rows : [];
818
819 foreach ( $rows as $row ) {
820 if ( empty( $row['day'] ) || ! isset( $kpi_data[ $row['day'] ] ) ) {
821 continue;
822 }
823
824 $kpi_data[ $row['day'] ] = [
825 'numeric_values' => [
826 'donations' => absint( $row['donations'] ?? 0 ),
827 ],
828 'string_values' => [
829 'donation_revenue_tier' => $this->get_revenue_tier( (float) ( $row['revenue'] ?? 0 ) ),
830 ],
831 ];
832 }
833
834 return $kpi_data;
835 }
836
837 /**
838 * Map a raw daily revenue amount to a coarse reporting tier.
839 *
840 * @param float $revenue Daily revenue.
841 * @return string Revenue tier label.
842 * @since 1.0.0
843 */
844 private function get_revenue_tier( float $revenue ): string {
845 if ( $revenue <= 0 ) {
846 return '0';
847 }
848 if ( $revenue < 100 ) {
849 return '1-100';
850 }
851 if ( $revenue < 500 ) {
852 return '100-500';
853 }
854 if ( $revenue < 1000 ) {
855 return '500-1000';
856 }
857 if ( $revenue < 5000 ) {
858 return '1000-5000';
859 }
860 return '5000+';
861 }
862
863 /**
864 * Detect state-based events that can't use direct hooks.
865 *
866 * Throttled by a daily transient; uses the request-cached donation
867 * aggregates plus option reads only — no extra queries. The events
868 * tracker dedups, so repeated calls are safe.
869 *
870 * @return void
871 * @since 1.0.0
872 */
873 private function detect_state_events() {
874 if ( get_transient( 'suredonation_state_events_checked' ) ) {
875 return;
876 }
877
878 $events = self::events();
879 if ( null === $events ) {
880 return; // Tracker unavailable — retry on next admin load.
881 }
882
883 // Set only after the tracker is confirmed available.
884 set_transient( 'suredonation_state_events_checked', true, DAY_IN_SECONDS );
885
886 $aggregates = $this->get_donation_aggregates();
887 $mode = Payment_Helper::get_payment_mode();
888
889 // plugin_activated: dedup ensures this fires only once.
890 $bsf_referrers = get_option( 'bsf_product_referers', [] );
891 $source = is_array( $bsf_referrers ) && ! empty( $bsf_referrers['suredonation'] )
892 ? sanitize_text_field( (string) $bsf_referrers['suredonation'] )
893 : 'self';
894 $events->track( 'plugin_activated', SUREDONATION_VER, [ 'source' => $source ] );
895
896 // plugin_updated: re-track on every version change.
897 $tracked_version = get_option( 'suredonation_tracked_version', '' );
898 if ( SUREDONATION_VER !== $tracked_version ) {
899 if ( ! empty( $tracked_version ) && is_string( $tracked_version ) ) {
900 $events->flush_pushed( [ 'plugin_updated' ] );
901 $events->track( 'plugin_updated', SUREDONATION_VER, [ 'from_version' => $tracked_version ] );
902 }
903 update_option( 'suredonation_tracked_version', SUREDONATION_VER, false );
904 }
905
906 // stripe_connected: detect connection state.
907 if ( Stripe_Helper::is_stripe_connected() ) {
908 $events->track( 'stripe_connected', $mode );
909 }
910
911 // paypal_connected: detect connection state.
912 if ( PayPal_Helper::is_paypal_connected() ) {
913 $events->track( 'paypal_connected', $mode );
914 }
915
916 // payment_mode_live: site switched to live payments.
917 if ( 'live' === $mode ) {
918 $events->track( 'payment_mode_live' );
919 }
920
921 // first_donation_received: time-to-value milestone.
922 if ( $aggregates['completed'] > 0 ) {
923 $install_time_raw = get_site_option( 'suredonation_usage_installed_time', 0 );
924 $install_time = is_numeric( $install_time_raw ) ? (int) $install_time_raw : 0;
925 $days_since_install = $install_time > 0 ? (int) floor( ( time() - $install_time ) / DAY_IN_SECONDS ) : 0;
926
927 $events->track(
928 'first_donation_received',
929 Payment_Helper::get_currency(),
930 [
931 'days_since_install' => (string) $days_since_install,
932 'payment_mode' => $mode,
933 ]
934 );
935
936 // first_live_donation_received: first completed LIVE donation. A
937 // separate event with its own dedup key — first_donation_received
938 // almost always fires on a test donation (sites start in test
939 // mode) and the name-only dedup then suppresses it forever, so
940 // the live milestone would otherwise never be visible. Gated on
941 // the donation rows' own payment_mode, not the mode at detection
942 // time, so a later mode switch can't skew the signal.
943 if ( $aggregates['completed_live'] > 0 ) {
944 $events->track(
945 'first_live_donation_received',
946 Payment_Helper::get_currency(),
947 [
948 'days_since_install' => (string) $days_since_install,
949 ]
950 );
951 }
952 }
953
954 // anonymous_donation_submitted: at least one completed anonymous donation.
955 if ( $aggregates['anonymous_completed'] > 0 ) {
956 $events->track( 'anonymous_donation_submitted' );
957 }
958
959 // cover_fees_used: at least one completed donation covered fees.
960 if ( $aggregates['fees_covered_completed'] > 0 ) {
961 $events->track( 'cover_fees_used' );
962 }
963
964 // first_refund_processed: at least one (partially) refunded donation.
965 if ( $aggregates['refunded'] > 0 ) {
966 $events->track( 'first_refund_processed' );
967 }
968
969 // webhook_configured: a Stripe webhook secret is stored for the current
970 // mode on any connected account (multi-account aware — reading only the
971 // default account would false-negative on sites using a non-default one).
972 $webhook_configured = false;
973 foreach ( array_keys( Stripe_Helper::get_all_accounts() ) as $wh_account_id ) {
974 if ( '' !== Stripe_Helper::get_webhook_secret( $mode, (string) $wh_account_id ) ) {
975 $webhook_configured = true;
976 break;
977 }
978 }
979 if ( $webhook_configured ) {
980 $events->track( 'webhook_configured', $mode );
981 }
982 }
983 }
984