diff --git a/inc/class-dashboard-statistics.php b/inc/class-dashboard-statistics.php index 13fffbf5f..38b647fce 100644 --- a/inc/class-dashboard-statistics.php +++ b/inc/class-dashboard-statistics.php @@ -192,4 +192,104 @@ 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. + * + * 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 Associative array keyed by activity slug with integer counts. + */ + public function get_data_site_activity(): array { + + global $wpdb; + + $table = $wpdb->base_prefix . 'wu_events'; + + $slugs = [ + '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( + "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..642f58edf --- /dev/null +++ b/inc/class-signup-metrics.php @@ -0,0 +1,330 @@ + 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). + * + * 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 string $checkout_form_name The checkout form slug/name. + * @return void + */ + 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 ( ! $checkout || ! $checkout->errors || ! $checkout->errors->has_errors()) { + return; + } + + $error_codes = $checkout->errors->get_error_codes(); + $error_messages = []; + + foreach ($error_codes as $code) { + $error_messages[ $code ] = $checkout->errors->get_error_message($code); + } + + wu_create_event( + [ + 'severity' => Event::SEVERITY_WARNING, + 'slug' => 'checkout_failed', + 'object_type' => 'network', + '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(), + ], + ] + ); + } + + /** + * 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..18432f084 100644 --- a/inc/class-wp-ultimo.php +++ b/inc/class-wp-ultimo.php @@ -714,6 +714,11 @@ function () { */ \WP_Ultimo\Tracker::get_instance(); + /* + * Signup Flow Metrics — tracks checkout funnel events. + */ + \WP_Ultimo\Signup_Metrics::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..af2bc13b8 --- /dev/null +++ b/tests/WP_Ultimo/Signup_Metrics_Test.php @@ -0,0 +1,174 @@ +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 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_accepts_string_form_name(): void { + + $metrics = Signup_Metrics::get_instance(); + + // Should not throw — wu_checkout_errors fires with a string, not a WP_Error. + $metrics->track_checkout_failed('main-form'); + + // If we get here without a fatal error, the test passes. + $this->assertTrue(true); + } + + // ------------------------------------------------------------------ + // 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. + * + * Keys match the slugs produced by Post_Signup_Activity_Manager. + */ + 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('subsite_post_created', $data); + $this->assertArrayHasKey('subsite_cpt_created', $data); + $this->assertArrayHasKey('subsite_user_registered', $data); + $this->assertArrayHasKey('subsite_woocommerce_order', $data); + } +}