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

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