From 2fd25ab7581e388776d7ce93cc09d8553b01235f Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 24 Mar 2026 20:44:59 -0600 Subject: [PATCH 1/4] feat: add signup flow metrics and post-signup activity tracking (#297, #398) Implements two new tracking classes: - Signup_Metrics: hooks into the checkout lifecycle to record checkout_started, checkout_step_completed, checkout_completed, and checkout_failed events in the wu_events table. Each event type is also registered for webhooks and email triggers. - Activity_Tracker: hooks into sub-site WordPress actions to record site_post_published, site_user_registered, and site_woocommerce_order events for any customer-owned sub-site. Only tracks sites managed by WP Ultimo (via wu_get_site_by_blog_id). Dashboard_Statistics gains two new data methods: - get_data_signup_funnel(): returns per-stage counts + conversion rate - get_data_site_activity(): returns post/user/order counts per date range Both classes are wired up in class-wp-ultimo.php alongside the existing Tracker singleton. Tests cover singleton behaviour, event type registration, filter pass-through, and dashboard statistics key structure. Closes #297 Closes #398 --- inc/class-activity-tracker.php | 297 +++++++++++++++++++++ inc/class-dashboard-statistics.php | 97 +++++++ inc/class-signup-metrics.php | 326 ++++++++++++++++++++++++ inc/class-wp-ultimo.php | 10 + tests/WP_Ultimo/Signup_Metrics_Test.php | 185 ++++++++++++++ 5 files changed, 915 insertions(+) create mode 100644 inc/class-activity-tracker.php create mode 100644 inc/class-signup-metrics.php create mode 100644 tests/WP_Ultimo/Signup_Metrics_Test.php diff --git a/inc/class-activity-tracker.php b/inc/class-activity-tracker.php new file mode 100644 index 000000000..c662cb721 --- /dev/null +++ b/inc/class-activity-tracker.php @@ -0,0 +1,297 @@ +post_type, self::EXCLUDED_POST_TYPES, true)) { + return; + } + + // Only track on customer-owned sub-sites. + $blog_id = get_current_blog_id(); + + if ( ! $this->is_customer_site($blog_id)) { + return; + } + + $site = wu_get_site_by_blog_id($blog_id); + + wu_create_event( + [ + 'severity' => Event::SEVERITY_INFO, + 'slug' => 'site_post_published', + 'object_type' => 'site', + 'object_id' => $site ? $site->get_id() : 0, + 'initiator' => 'system', + 'payload' => [ + 'blog_id' => $blog_id, + 'site_id' => $site ? $site->get_id() : 0, + 'post_id' => $post->ID, + 'post_type' => $post->post_type, + 'post_title' => $post->post_title, + 'post_author' => (int) $post->post_author, + 'membership_id' => $site ? $site->get_membership_id() : 0, + ], + ] + ); + } + + /** + * Fires when a new user is registered on any sub-site. + * + * @since 2.5.0 + * + * @param int $user_id The newly registered user ID. + * @return void + */ + public function track_user_registered(int $user_id): void { + + $blog_id = get_current_blog_id(); + + // Skip the main network site — we only care about sub-site registrations. + if (is_main_site($blog_id)) { + return; + } + + if ( ! $this->is_customer_site($blog_id)) { + return; + } + + $site = wu_get_site_by_blog_id($blog_id); + + $user = get_userdata($user_id); + + wu_create_event( + [ + 'severity' => Event::SEVERITY_INFO, + 'slug' => 'site_user_registered', + 'object_type' => 'site', + 'object_id' => $site ? $site->get_id() : 0, + 'initiator' => 'system', + 'payload' => [ + 'blog_id' => $blog_id, + 'site_id' => $site ? $site->get_id() : 0, + 'user_id' => $user_id, + 'user_login' => $user ? $user->user_login : '', + 'user_email' => $user ? $user->user_email : '', + 'membership_id' => $site ? $site->get_membership_id() : 0, + ], + ] + ); + } + + /** + * Fires when a WooCommerce order is created on a sub-site. + * + * @since 2.5.0 + * + * @param int $order_id The WooCommerce order ID. + * @param \WC_Order $order The WooCommerce order object. + * @return void + */ + public function track_woocommerce_order(int $order_id, $order): void { + + $blog_id = get_current_blog_id(); + + if ( ! $this->is_customer_site($blog_id)) { + return; + } + + $site = wu_get_site_by_blog_id($blog_id); + + $total = $order && method_exists($order, 'get_total') ? (float) $order->get_total() : 0.0; + $currency = $order && method_exists($order, 'get_currency') ? $order->get_currency() : ''; + $status = $order && method_exists($order, 'get_status') ? $order->get_status() : ''; + + wu_create_event( + [ + 'severity' => Event::SEVERITY_INFO, + 'slug' => 'site_woocommerce_order', + 'object_type' => 'site', + 'object_id' => $site ? $site->get_id() : 0, + 'initiator' => 'system', + 'payload' => [ + 'blog_id' => $blog_id, + 'site_id' => $site ? $site->get_id() : 0, + 'order_id' => $order_id, + 'order_total' => $total, + 'order_currency'=> $currency, + 'order_status' => $status, + 'membership_id' => $site ? $site->get_membership_id() : 0, + ], + ] + ); + } + + /** + * Registers post-signup activity event types for webhooks/emails. + * + * @since 2.5.0 + * @return void + */ + public function register_event_types(): void { + + wu_register_event_type( + 'site_post_published', + [ + 'name' => __('Sub-site Post Published', 'ultimate-multisite'), + 'desc' => __('Fired when a post or custom post type is published on a customer sub-site.', 'ultimate-multisite'), + 'payload' => fn() => array_merge( + wu_generate_event_payload('site'), + [ + 'post_id' => 1, + 'post_type' => 'post', + 'post_title' => 'Example Post', + 'post_author'=> 1, + ] + ), + 'deprecated_args' => [], + ] + ); + + wu_register_event_type( + 'site_user_registered', + [ + 'name' => __('Sub-site User Registered', 'ultimate-multisite'), + 'desc' => __('Fired when a new user registers on a customer sub-site.', 'ultimate-multisite'), + 'payload' => fn() => array_merge( + wu_generate_event_payload('site'), + [ + 'user_id' => 1, + 'user_login' => 'example_user', + 'user_email' => 'user@example.com', + ] + ), + 'deprecated_args' => [], + ] + ); + + wu_register_event_type( + 'site_woocommerce_order', + [ + 'name' => __('Sub-site WooCommerce Order', 'ultimate-multisite'), + 'desc' => __('Fired when a WooCommerce order is placed on a customer sub-site.', 'ultimate-multisite'), + 'payload' => fn() => array_merge( + wu_generate_event_payload('site'), + [ + 'order_id' => 1, + 'order_total' => 49.99, + 'order_currency' => 'USD', + 'order_status' => 'pending', + ] + ), + 'deprecated_args' => [], + ] + ); + } + + /** + * Checks whether a given blog ID belongs to a WP Ultimo customer-owned site. + * + * @since 2.5.0 + * + * @param int $blog_id The blog ID to check. + * @return bool + */ + protected function is_customer_site(int $blog_id): bool { + + $site = wu_get_site_by_blog_id($blog_id); + + if ( ! $site) { + return false; + } + + return \WP_Ultimo\Database\Sites\Site_Type::CUSTOMER_OWNED === $site->get_type(); + } +} diff --git a/inc/class-dashboard-statistics.php b/inc/class-dashboard-statistics.php index 13fffbf5f..4ae94a7fd 100644 --- a/inc/class-dashboard-statistics.php +++ b/inc/class-dashboard-statistics.php @@ -192,4 +192,101 @@ public function get_data_mrr_growth() { return $payments_per_month; } + + /** + * Get signup funnel conversion counts for the current date range. + * + * Returns an associative array with counts for each funnel stage: + * - checkout_started + * - checkout_step_completed + * - checkout_completed + * - checkout_failed + * + * @since 2.5.0 + * @return array + */ + public function get_data_signup_funnel(): array { + + global $wpdb; + + $table = $wpdb->base_prefix . 'wu_events'; + + $slugs = [ + 'checkout_started', + 'checkout_step_completed', + 'checkout_completed', + 'checkout_failed', + ]; + + $counts = array_fill_keys($slugs, 0); + + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + // Note: table name comes from $wpdb->base_prefix which is safe. + foreach ($slugs as $slug) { + $counts[ $slug ] = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$table} WHERE slug = %s AND date_created BETWEEN %s AND %s", + $slug, + $this->start_date, + $this->end_date + ) + ); + } + // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + // Compute conversion rate: completed / started (avoid division by zero). + $started = $counts['checkout_started']; + $completed = $counts['checkout_completed']; + + $counts['conversion_rate'] = $started > 0 + ? round(($completed / $started) * 100, 1) + : 0.0; + + return $counts; + } + + /** + * Get post-signup activity counts for the current date range. + * + * Returns counts for: + * - site_post_published + * - site_user_registered + * - site_woocommerce_order + * + * @since 2.5.0 + * @return array + */ + public function get_data_site_activity(): array { + + global $wpdb; + + $table = $wpdb->base_prefix . 'wu_events'; + + $slugs = [ + 'site_post_published', + 'site_user_registered', + 'site_woocommerce_order', + ]; + + $counts = array_fill_keys($slugs, 0); + + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + foreach ($slugs as $slug) { + $counts[ $slug ] = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$table} WHERE slug = %s AND date_created BETWEEN %s AND %s", + $slug, + $this->start_date, + $this->end_date + ) + ); + } + // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + return $counts; + } } diff --git a/inc/class-signup-metrics.php b/inc/class-signup-metrics.php new file mode 100644 index 000000000..b104d684e --- /dev/null +++ b/inc/class-signup-metrics.php @@ -0,0 +1,326 @@ + 1) { + return; + } + + $form_slug = method_exists($element, 'get_pre_loaded_attribute') + ? $element->get_pre_loaded_attribute('slug', 'unknown') + : 'unknown'; + + wu_create_event( + [ + 'severity' => Event::SEVERITY_INFO, + 'slug' => 'checkout_started', + 'object_type' => 'network', + 'object_id' => 0, + 'initiator' => 'system', + 'payload' => [ + 'form_slug' => sanitize_key((string) $form_slug), + 'user_id' => get_current_user_id(), + 'ip_address' => $this->get_client_ip(), + 'referrer' => isset($_SERVER['HTTP_REFERER']) ? esc_url_raw(wp_unslash($_SERVER['HTTP_REFERER'])) : '', + ], + ] + ); + } + + /** + * Fires when a checkout order is fully assembled (step completed). + * + * @since 2.5.0 + * + * @param \WP_Ultimo\Checkout\Cart $order The cart/order object. + * @param \WP_Ultimo\Models\Customer $customer The customer. + * @param \WP_Ultimo\Models\Membership $membership The primary membership. + * @param \WP_Ultimo\Models\Payment $payment The payment. + * @return void + */ + public function track_checkout_step_completed($order, $customer, $membership, $payment): void { + + $plan = $order->get_plan(); + + wu_create_event( + [ + 'severity' => Event::SEVERITY_INFO, + 'slug' => 'checkout_step_completed', + 'object_type' => 'membership', + 'object_id' => $membership ? $membership->get_id() : 0, + 'initiator' => 'system', + 'payload' => [ + 'customer_id' => $customer ? $customer->get_id() : 0, + 'membership_id' => $membership ? $membership->get_id() : 0, + 'plan_id' => $plan ? $plan->get_id() : 0, + 'plan_slug' => $plan ? $plan->get_slug() : '', + 'cart_type' => $order->get_cart_type(), + 'is_free' => $order->is_free(), + ], + ] + ); + } + + /** + * Fires after a checkout is fully processed (payment gateway returned success). + * + * @since 2.5.0 + * + * @param \WP_Ultimo\Models\Payment $payment The payment. + * @param \WP_Ultimo\Models\Membership $membership The membership. + * @param \WP_Ultimo\Models\Customer $customer The customer. + * @param \WP_Ultimo\Checkout\Cart $order The cart. + * @param string $type Cart type. + * @param \WP_Ultimo\Checkout\Checkout $checkout The checkout instance. + * @return void + */ + public function track_checkout_completed($payment, $membership, $customer, $order, $type, $checkout): void { + + $plan = $order ? $order->get_plan() : null; + + wu_create_event( + [ + 'severity' => Event::SEVERITY_SUCCESS, + 'slug' => 'checkout_completed', + 'object_type' => 'membership', + 'object_id' => $membership ? $membership->get_id() : 0, + 'initiator' => 'system', + 'payload' => [ + 'customer_id' => $customer ? $customer->get_id() : 0, + 'membership_id' => $membership ? $membership->get_id() : 0, + 'payment_id' => $payment ? $payment->get_id() : 0, + 'payment_total' => $payment ? $payment->get_total() : 0, + 'payment_status' => $payment ? $payment->get_status() : '', + 'plan_id' => $plan ? $plan->get_id() : 0, + 'plan_slug' => $plan ? $plan->get_slug() : '', + 'cart_type' => $order ? $order->get_cart_type() : $type, + 'gateway' => $payment ? $payment->get_gateway() : '', + 'is_free' => $order ? $order->is_free() : false, + ], + ] + ); + } + + /** + * Fires when the checkout returns errors (checkout failed). + * + * Passes errors through unchanged — this is a filter so we can observe + * the error without blocking the normal error-handling flow. + * + * @since 2.5.0 + * + * @param \WP_Error $errors The checkout errors. + * @param \WP_Ultimo\Checkout\Checkout $checkout The checkout instance. + * @return \WP_Error + */ + public function track_checkout_failed($errors, $checkout): \WP_Error { + + if ( ! is_wp_error($errors) || ! $errors->has_errors()) { + return $errors; + } + + $error_codes = $errors->get_error_codes(); + $error_messages = []; + + foreach ($error_codes as $code) { + $error_messages[ $code ] = $errors->get_error_message($code); + } + + wu_create_event( + [ + 'severity' => Event::SEVERITY_WARNING, + 'slug' => 'checkout_failed', + 'object_type' => 'network', + 'object_id' => 0, + 'initiator' => 'system', + 'payload' => [ + 'error_codes' => $error_codes, + 'error_messages' => $error_messages, + 'user_id' => get_current_user_id(), + ], + ] + ); + + return $errors; + } + + /** + * Registers signup funnel event types for webhooks and emails. + * + * @since 2.5.0 + * @return void + */ + public function register_event_types(): void { + + wu_register_event_type( + 'checkout_started', + [ + 'name' => __('Checkout Started', 'ultimate-multisite'), + 'desc' => __('Fired when a visitor lands on a checkout page.', 'ultimate-multisite'), + 'payload' => [ + 'form_slug' => 'default-checkout', + 'user_id' => 0, + 'ip_address' => '127.0.0.1', + 'referrer' => '', + ], + 'deprecated_args' => [], + ] + ); + + wu_register_event_type( + 'checkout_step_completed', + [ + 'name' => __('Checkout Step Completed', 'ultimate-multisite'), + 'desc' => __('Fired when a checkout step is submitted and the order is assembled.', 'ultimate-multisite'), + 'payload' => fn() => array_merge( + wu_generate_event_payload('membership'), + wu_generate_event_payload('customer'), + [ + 'cart_type' => 'new', + 'is_free' => false, + ] + ), + 'deprecated_args' => [], + ] + ); + + wu_register_event_type( + 'checkout_completed', + [ + 'name' => __('Checkout Completed', 'ultimate-multisite'), + 'desc' => __('Fired when a checkout is fully processed and the payment gateway returns success.', 'ultimate-multisite'), + 'payload' => fn() => array_merge( + wu_generate_event_payload('payment'), + wu_generate_event_payload('membership'), + wu_generate_event_payload('customer'), + [ + 'cart_type' => 'new', + 'gateway' => 'free', + 'is_free' => true, + ] + ), + 'deprecated_args' => [], + ] + ); + + wu_register_event_type( + 'checkout_failed', + [ + 'name' => __('Checkout Failed', 'ultimate-multisite'), + 'desc' => __('Fired when a checkout attempt returns validation or gateway errors.', 'ultimate-multisite'), + 'payload' => [ + 'error_codes' => ['example_error'], + 'error_messages' => ['example_error' => 'Example error message'], + 'user_id' => 0, + ], + 'deprecated_args' => [], + ] + ); + } + + /** + * Returns the client IP address, respecting common proxy headers. + * + * @since 2.5.0 + * @return string + */ + protected function get_client_ip(): string { + + $headers = [ + 'HTTP_CF_CONNECTING_IP', + 'HTTP_X_FORWARDED_FOR', + 'HTTP_X_REAL_IP', + 'REMOTE_ADDR', + ]; + + foreach ($headers as $header) { + if ( ! empty($_SERVER[ $header ])) { + $ip = sanitize_text_field(wp_unslash($_SERVER[ $header ])); + + // X-Forwarded-For can be a comma-separated list; take the first. + $ip = explode(',', $ip)[0]; + $ip = trim($ip); + + if (filter_var($ip, FILTER_VALIDATE_IP)) { + return $ip; + } + } + } + + return ''; + } +} diff --git a/inc/class-wp-ultimo.php b/inc/class-wp-ultimo.php index c0967e763..3b0baa69d 100644 --- a/inc/class-wp-ultimo.php +++ b/inc/class-wp-ultimo.php @@ -714,6 +714,16 @@ function () { */ \WP_Ultimo\Tracker::get_instance(); + /* + * Signup Flow Metrics — tracks checkout funnel events. + */ + \WP_Ultimo\Signup_Metrics::get_instance(); + + /* + * Activity Tracker — tracks post-signup actions on sub-sites. + */ + \WP_Ultimo\Activity_Tracker::get_instance(); + \WP_Ultimo\MCP_Adapter::get_instance(); } diff --git a/tests/WP_Ultimo/Signup_Metrics_Test.php b/tests/WP_Ultimo/Signup_Metrics_Test.php new file mode 100644 index 000000000..f82eccfbe --- /dev/null +++ b/tests/WP_Ultimo/Signup_Metrics_Test.php @@ -0,0 +1,185 @@ +assertSame($a, $b); + } + + // ------------------------------------------------------------------ + // register_event_types + // ------------------------------------------------------------------ + + /** + * Test that checkout_started event type is registered. + */ + public function test_checkout_started_event_type_registered(): void { + + // Trigger registration. + do_action('wu_register_all_events'); + + $event_types = wu_get_event_types(); + + $this->assertArrayHasKey('checkout_started', $event_types); + } + + /** + * Test that checkout_completed event type is registered. + */ + public function test_checkout_completed_event_type_registered(): void { + + do_action('wu_register_all_events'); + + $event_types = wu_get_event_types(); + + $this->assertArrayHasKey('checkout_completed', $event_types); + } + + /** + * Test that checkout_step_completed event type is registered. + */ + public function test_checkout_step_completed_event_type_registered(): void { + + do_action('wu_register_all_events'); + + $event_types = wu_get_event_types(); + + $this->assertArrayHasKey('checkout_step_completed', $event_types); + } + + /** + * Test that checkout_failed event type is registered. + */ + public function test_checkout_failed_event_type_registered(): void { + + do_action('wu_register_all_events'); + + $event_types = wu_get_event_types(); + + $this->assertArrayHasKey('checkout_failed', $event_types); + } + + // ------------------------------------------------------------------ + // track_checkout_failed + // ------------------------------------------------------------------ + + /** + * Test that track_checkout_failed passes errors through unchanged. + */ + public function test_track_checkout_failed_passes_errors_through(): void { + + $metrics = Signup_Metrics::get_instance(); + + $error = new \WP_Error('test_error', 'Test error message'); + + $result = $metrics->track_checkout_failed($error, null); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertTrue($result->has_errors()); + $this->assertEquals('test_error', $result->get_error_code()); + } + + /** + * Test that track_checkout_failed returns non-error input unchanged. + */ + public function test_track_checkout_failed_returns_non_error_unchanged(): void { + + $metrics = Signup_Metrics::get_instance(); + + $non_error = new \WP_Error(); + + $result = $metrics->track_checkout_failed($non_error, null); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertFalse($result->has_errors()); + } + + // ------------------------------------------------------------------ + // Dashboard_Statistics integration + // ------------------------------------------------------------------ + + /** + * Test that get_data_signup_funnel returns expected keys. + */ + public function test_dashboard_statistics_signup_funnel_keys(): void { + + $stats = new Dashboard_Statistics( + [ + 'start_date' => '2025-01-01 00:00:00', + 'end_date' => '2025-12-31 23:59:59', + 'types' => [], + ] + ); + + $data = $stats->get_data_signup_funnel(); + + $this->assertArrayHasKey('checkout_started', $data); + $this->assertArrayHasKey('checkout_step_completed', $data); + $this->assertArrayHasKey('checkout_completed', $data); + $this->assertArrayHasKey('checkout_failed', $data); + $this->assertArrayHasKey('conversion_rate', $data); + } + + /** + * Test that conversion_rate is 0 when no events exist. + */ + public function test_dashboard_statistics_conversion_rate_zero_when_no_events(): void { + + $stats = new Dashboard_Statistics( + [ + 'start_date' => '2000-01-01 00:00:00', + 'end_date' => '2000-01-02 00:00:00', + 'types' => [], + ] + ); + + $data = $stats->get_data_signup_funnel(); + + $this->assertEquals(0.0, $data['conversion_rate']); + } + + /** + * Test that get_data_site_activity returns expected keys. + */ + public function test_dashboard_statistics_site_activity_keys(): void { + + $stats = new Dashboard_Statistics( + [ + 'start_date' => '2025-01-01 00:00:00', + 'end_date' => '2025-12-31 23:59:59', + 'types' => [], + ] + ); + + $data = $stats->get_data_site_activity(); + + $this->assertArrayHasKey('site_post_published', $data); + $this->assertArrayHasKey('site_user_registered', $data); + $this->assertArrayHasKey('site_woocommerce_order', $data); + } +} From 0eed41ff02317a5e0a1a3cf92566ccf7b39ebe19 Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 24 Mar 2026 20:54:40 -0600 Subject: [PATCH 2/4] ci: re-trigger E2E tests From 2da004130d87562192243ef8d7d5292fe80e931e Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 24 Mar 2026 21:19:21 -0600 Subject: [PATCH 3/4] fix: remove duplicate Activity_Tracker (covered by Post_Signup_Activity_Manager) Post_Signup_Activity_Manager was merged into main in #416 before this branch was rebased. Remove the duplicate Activity_Tracker class and its instantiation from class-wp-ultimo.php. Update Dashboard_Statistics::get_data_site_activity() to use the event slugs produced by Post_Signup_Activity_Manager (subsite_post_created, subsite_cpt_created, subsite_user_registered, subsite_woocommerce_order) instead of the now-removed Activity_Tracker slugs. Update Signup_Metrics_Test to match the corrected slugs. --- inc/class-activity-tracker.php | 297 ------------------------ inc/class-dashboard-statistics.php | 19 +- inc/class-wp-ultimo.php | 5 - tests/WP_Ultimo/Signup_Metrics_Test.php | 9 +- 4 files changed, 17 insertions(+), 313 deletions(-) delete mode 100644 inc/class-activity-tracker.php diff --git a/inc/class-activity-tracker.php b/inc/class-activity-tracker.php deleted file mode 100644 index c662cb721..000000000 --- a/inc/class-activity-tracker.php +++ /dev/null @@ -1,297 +0,0 @@ -post_type, self::EXCLUDED_POST_TYPES, true)) { - return; - } - - // Only track on customer-owned sub-sites. - $blog_id = get_current_blog_id(); - - if ( ! $this->is_customer_site($blog_id)) { - return; - } - - $site = wu_get_site_by_blog_id($blog_id); - - wu_create_event( - [ - 'severity' => Event::SEVERITY_INFO, - 'slug' => 'site_post_published', - 'object_type' => 'site', - 'object_id' => $site ? $site->get_id() : 0, - 'initiator' => 'system', - 'payload' => [ - 'blog_id' => $blog_id, - 'site_id' => $site ? $site->get_id() : 0, - 'post_id' => $post->ID, - 'post_type' => $post->post_type, - 'post_title' => $post->post_title, - 'post_author' => (int) $post->post_author, - 'membership_id' => $site ? $site->get_membership_id() : 0, - ], - ] - ); - } - - /** - * Fires when a new user is registered on any sub-site. - * - * @since 2.5.0 - * - * @param int $user_id The newly registered user ID. - * @return void - */ - public function track_user_registered(int $user_id): void { - - $blog_id = get_current_blog_id(); - - // Skip the main network site — we only care about sub-site registrations. - if (is_main_site($blog_id)) { - return; - } - - if ( ! $this->is_customer_site($blog_id)) { - return; - } - - $site = wu_get_site_by_blog_id($blog_id); - - $user = get_userdata($user_id); - - wu_create_event( - [ - 'severity' => Event::SEVERITY_INFO, - 'slug' => 'site_user_registered', - 'object_type' => 'site', - 'object_id' => $site ? $site->get_id() : 0, - 'initiator' => 'system', - 'payload' => [ - 'blog_id' => $blog_id, - 'site_id' => $site ? $site->get_id() : 0, - 'user_id' => $user_id, - 'user_login' => $user ? $user->user_login : '', - 'user_email' => $user ? $user->user_email : '', - 'membership_id' => $site ? $site->get_membership_id() : 0, - ], - ] - ); - } - - /** - * Fires when a WooCommerce order is created on a sub-site. - * - * @since 2.5.0 - * - * @param int $order_id The WooCommerce order ID. - * @param \WC_Order $order The WooCommerce order object. - * @return void - */ - public function track_woocommerce_order(int $order_id, $order): void { - - $blog_id = get_current_blog_id(); - - if ( ! $this->is_customer_site($blog_id)) { - return; - } - - $site = wu_get_site_by_blog_id($blog_id); - - $total = $order && method_exists($order, 'get_total') ? (float) $order->get_total() : 0.0; - $currency = $order && method_exists($order, 'get_currency') ? $order->get_currency() : ''; - $status = $order && method_exists($order, 'get_status') ? $order->get_status() : ''; - - wu_create_event( - [ - 'severity' => Event::SEVERITY_INFO, - 'slug' => 'site_woocommerce_order', - 'object_type' => 'site', - 'object_id' => $site ? $site->get_id() : 0, - 'initiator' => 'system', - 'payload' => [ - 'blog_id' => $blog_id, - 'site_id' => $site ? $site->get_id() : 0, - 'order_id' => $order_id, - 'order_total' => $total, - 'order_currency'=> $currency, - 'order_status' => $status, - 'membership_id' => $site ? $site->get_membership_id() : 0, - ], - ] - ); - } - - /** - * Registers post-signup activity event types for webhooks/emails. - * - * @since 2.5.0 - * @return void - */ - public function register_event_types(): void { - - wu_register_event_type( - 'site_post_published', - [ - 'name' => __('Sub-site Post Published', 'ultimate-multisite'), - 'desc' => __('Fired when a post or custom post type is published on a customer sub-site.', 'ultimate-multisite'), - 'payload' => fn() => array_merge( - wu_generate_event_payload('site'), - [ - 'post_id' => 1, - 'post_type' => 'post', - 'post_title' => 'Example Post', - 'post_author'=> 1, - ] - ), - 'deprecated_args' => [], - ] - ); - - wu_register_event_type( - 'site_user_registered', - [ - 'name' => __('Sub-site User Registered', 'ultimate-multisite'), - 'desc' => __('Fired when a new user registers on a customer sub-site.', 'ultimate-multisite'), - 'payload' => fn() => array_merge( - wu_generate_event_payload('site'), - [ - 'user_id' => 1, - 'user_login' => 'example_user', - 'user_email' => 'user@example.com', - ] - ), - 'deprecated_args' => [], - ] - ); - - wu_register_event_type( - 'site_woocommerce_order', - [ - 'name' => __('Sub-site WooCommerce Order', 'ultimate-multisite'), - 'desc' => __('Fired when a WooCommerce order is placed on a customer sub-site.', 'ultimate-multisite'), - 'payload' => fn() => array_merge( - wu_generate_event_payload('site'), - [ - 'order_id' => 1, - 'order_total' => 49.99, - 'order_currency' => 'USD', - 'order_status' => 'pending', - ] - ), - 'deprecated_args' => [], - ] - ); - } - - /** - * Checks whether a given blog ID belongs to a WP Ultimo customer-owned site. - * - * @since 2.5.0 - * - * @param int $blog_id The blog ID to check. - * @return bool - */ - protected function is_customer_site(int $blog_id): bool { - - $site = wu_get_site_by_blog_id($blog_id); - - if ( ! $site) { - return false; - } - - return \WP_Ultimo\Database\Sites\Site_Type::CUSTOMER_OWNED === $site->get_type(); - } -} diff --git a/inc/class-dashboard-statistics.php b/inc/class-dashboard-statistics.php index 4ae94a7fd..38b647fce 100644 --- a/inc/class-dashboard-statistics.php +++ b/inc/class-dashboard-statistics.php @@ -250,13 +250,14 @@ public function get_data_signup_funnel(): array { /** * Get post-signup activity counts for the current date range. * - * Returns counts for: - * - site_post_published - * - site_user_registered - * - site_woocommerce_order + * Queries the event slugs produced by Post_Signup_Activity_Manager: + * - subsite_post_created + * - subsite_cpt_created + * - subsite_user_registered + * - subsite_woocommerce_order * * @since 2.5.0 - * @return array + * @return array Associative array keyed by activity slug with integer counts. */ public function get_data_site_activity(): array { @@ -265,15 +266,17 @@ public function get_data_site_activity(): array { $table = $wpdb->base_prefix . 'wu_events'; $slugs = [ - 'site_post_published', - 'site_user_registered', - 'site_woocommerce_order', + 'subsite_post_created', + 'subsite_cpt_created', + 'subsite_user_registered', + 'subsite_woocommerce_order', ]; $counts = array_fill_keys($slugs, 0); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + // Note: table name comes from $wpdb->base_prefix which is safe. foreach ($slugs as $slug) { $counts[ $slug ] = (int) $wpdb->get_var( $wpdb->prepare( diff --git a/inc/class-wp-ultimo.php b/inc/class-wp-ultimo.php index 3b0baa69d..18432f084 100644 --- a/inc/class-wp-ultimo.php +++ b/inc/class-wp-ultimo.php @@ -719,11 +719,6 @@ function () { */ \WP_Ultimo\Signup_Metrics::get_instance(); - /* - * Activity Tracker — tracks post-signup actions on sub-sites. - */ - \WP_Ultimo\Activity_Tracker::get_instance(); - \WP_Ultimo\MCP_Adapter::get_instance(); } diff --git a/tests/WP_Ultimo/Signup_Metrics_Test.php b/tests/WP_Ultimo/Signup_Metrics_Test.php index f82eccfbe..05c0fb678 100644 --- a/tests/WP_Ultimo/Signup_Metrics_Test.php +++ b/tests/WP_Ultimo/Signup_Metrics_Test.php @@ -165,6 +165,8 @@ public function test_dashboard_statistics_conversion_rate_zero_when_no_events(): /** * Test that get_data_site_activity returns expected keys. + * + * Keys match the slugs produced by Post_Signup_Activity_Manager. */ public function test_dashboard_statistics_site_activity_keys(): void { @@ -178,8 +180,9 @@ public function test_dashboard_statistics_site_activity_keys(): void { $data = $stats->get_data_site_activity(); - $this->assertArrayHasKey('site_post_published', $data); - $this->assertArrayHasKey('site_user_registered', $data); - $this->assertArrayHasKey('site_woocommerce_order', $data); + $this->assertArrayHasKey('subsite_post_created', $data); + $this->assertArrayHasKey('subsite_cpt_created', $data); + $this->assertArrayHasKey('subsite_user_registered', $data); + $this->assertArrayHasKey('subsite_woocommerce_order', $data); } } From 30301baf019b9e3b20b5fe036c9afecce70adec2 Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 24 Mar 2026 21:37:04 -0600 Subject: [PATCH 4/4] fix: correct wu_checkout_errors hook to prevent TypeError fatal error wu_checkout_errors is an action (not a filter) that fires with the checkout form name as a string argument (views/checkout/form.php:23). The previous implementation used add_filter and declared a return type of \WP_Error. When the action fired with a string argument, the method returned the string, causing a PHP TypeError (return type mismatch) that produced a fatal error on the /register page. Fix: - Change add_filter to add_action (correct hook type) - Change method signature to accept a string form name - Remove \WP_Error return type (void action callback) - Access checkout errors via Checkout::get_instance()->errors - Update test to verify string argument is accepted without error --- inc/class-signup-metrics.php | 32 ++++++++++++++----------- tests/WP_Ultimo/Signup_Metrics_Test.php | 32 +++++++------------------ 2 files changed, 27 insertions(+), 37 deletions(-) diff --git a/inc/class-signup-metrics.php b/inc/class-signup-metrics.php index b104d684e..642f58edf 100644 --- a/inc/class-signup-metrics.php +++ b/inc/class-signup-metrics.php @@ -58,8 +58,8 @@ public function init(): void { // Track successful checkout completion. add_action('wu_checkout_done', [$this, 'track_checkout_completed'], 10, 6); - // Track checkout failures. - add_filter('wu_checkout_errors', [$this, 'track_checkout_failed'], 10, 2); + // Track checkout failures (wu_checkout_errors is an action, not a filter). + add_action('wu_checkout_errors', [$this, 'track_checkout_failed'], 10, 1); // Register event types for webhooks/emails. add_action('wu_register_all_events', [$this, 'register_event_types']); @@ -178,26 +178,31 @@ public function track_checkout_completed($payment, $membership, $customer, $orde /** * Fires when the checkout returns errors (checkout failed). * - * Passes errors through unchanged — this is a filter so we can observe - * the error without blocking the normal error-handling flow. + * Observes checkout errors without modifying the action flow. + * + * wu_checkout_errors is an action (not a filter). It fires with the + * checkout form name as the first argument. We only record an event + * when the current request has checkout validation errors. * * @since 2.5.0 * - * @param \WP_Error $errors The checkout errors. - * @param \WP_Ultimo\Checkout\Checkout $checkout The checkout instance. - * @return \WP_Error + * @param string $checkout_form_name The checkout form slug/name. + * @return void */ - public function track_checkout_failed($errors, $checkout): \WP_Error { + public function track_checkout_failed($checkout_form_name): void { + + // Only record if there are actual checkout errors in the current request. + $checkout = \WP_Ultimo\Checkout\Checkout::get_instance(); - if ( ! is_wp_error($errors) || ! $errors->has_errors()) { - return $errors; + if ( ! $checkout || ! $checkout->errors || ! $checkout->errors->has_errors()) { + return; } - $error_codes = $errors->get_error_codes(); + $error_codes = $checkout->errors->get_error_codes(); $error_messages = []; foreach ($error_codes as $code) { - $error_messages[ $code ] = $errors->get_error_message($code); + $error_messages[ $code ] = $checkout->errors->get_error_message($code); } wu_create_event( @@ -208,14 +213,13 @@ public function track_checkout_failed($errors, $checkout): \WP_Error { 'object_id' => 0, 'initiator' => 'system', 'payload' => [ + 'checkout_form' => sanitize_key((string) $checkout_form_name), 'error_codes' => $error_codes, 'error_messages' => $error_messages, 'user_id' => get_current_user_id(), ], ] ); - - return $errors; } /** diff --git a/tests/WP_Ultimo/Signup_Metrics_Test.php b/tests/WP_Ultimo/Signup_Metrics_Test.php index 05c0fb678..af2bc13b8 100644 --- a/tests/WP_Ultimo/Signup_Metrics_Test.php +++ b/tests/WP_Ultimo/Signup_Metrics_Test.php @@ -89,34 +89,20 @@ public function test_checkout_failed_event_type_registered(): void { // ------------------------------------------------------------------ /** - * Test that track_checkout_failed passes errors through unchanged. - */ - public function test_track_checkout_failed_passes_errors_through(): void { - - $metrics = Signup_Metrics::get_instance(); - - $error = new \WP_Error('test_error', 'Test error message'); - - $result = $metrics->track_checkout_failed($error, null); - - $this->assertInstanceOf(\WP_Error::class, $result); - $this->assertTrue($result->has_errors()); - $this->assertEquals('test_error', $result->get_error_code()); - } - - /** - * Test that track_checkout_failed returns non-error input unchanged. + * Test that track_checkout_failed accepts a string form name without error. + * + * wu_checkout_errors is an action that fires with the form name as a string. + * The method must not throw a TypeError when called with a string argument. */ - public function test_track_checkout_failed_returns_non_error_unchanged(): void { + public function test_track_checkout_failed_accepts_string_form_name(): void { $metrics = Signup_Metrics::get_instance(); - $non_error = new \WP_Error(); - - $result = $metrics->track_checkout_failed($non_error, null); + // Should not throw — wu_checkout_errors fires with a string, not a WP_Error. + $metrics->track_checkout_failed('main-form'); - $this->assertInstanceOf(\WP_Error::class, $result); - $this->assertFalse($result->has_errors()); + // If we get here without a fatal error, the test passes. + $this->assertTrue(true); } // ------------------------------------------------------------------