From 79add4b54941b922253701240c313797fd4653b7 Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:16:31 +0200 Subject: [PATCH 01/21] Added: WPML Multilingual & Multicurrency for WooCommerce (WooCommerce Multilingual) compatibility and fixed some bugs related to multilingual ecommerce. --- readme.txt | 7 + src/Admin/Provisioning.php | 2 + src/Admin/Provisioning/Integrations.php | 197 +++++++++++++++--- src/Admin/Provisioning/Integrations/EDD.php | 2 +- .../Provisioning/Integrations/WooCommerce.php | 2 +- src/Admin/Upgrades.php | 47 +++++ src/Helpers.php | 174 ++++++++++++++++ src/Integrations/EDD.php | 5 + src/Integrations/WooCommerce.php | 90 ++++++-- .../Admin/Provisioning/IntegrationsTest.php | 104 +++++++++ 10 files changed, 578 insertions(+), 52 deletions(-) diff --git a/readme.txt b/readme.txt index 435515cd..13d57114 100644 --- a/readme.txt +++ b/readme.txt @@ -280,6 +280,13 @@ Please make sure you make a backup of your database before updating any version = 2.6.2 = * Added: TranslatePress "different domain per language" (Multiple Domains) compatibility. Each language domain can be mapped to its own Plausible Analytics dashboard, just like WPML. +* Added: WPML Multilingual & Multicurrency for WooCommerce (WooCommerce Multilingual) compatibility. +* Fixed: the view product goal is now created for the URL each language is served under, e.g. /es/producto*, instead of for the default language's URL only. This covers both the language prefix (/es/) + and a translated product base (WooCommerce Multilingual's Store URLs), so product pageviews are no longer missed in any language but the default one. Because a funnel step holds a single goal, the default language's path remains the first step of the purchase funnel, and every other language gets a goal of its own. This applies to WooCommerce as well as Easy Digital Downloads, on WPML as well as TranslatePress. +* Improved: the WooCommerce add to cart, remove from cart, start checkout, and complete purchase events now contain a currency property. Multicurrency plugins convert all amounts to the currency the +visitor is shopping in, which Plausible Analytics would otherwise sum up as if they were all in the same currency. The complete purchase event uses the currency the order was placed in. +* Improved: on multilingual sites, those same WooCommerce events now contain a language property, because translated products are separate posts, i.e., each language has its own product ID and product + name. * Fixed: in "domain per language" mode, the proxy endpoint and the locally cached tracker script were loaded from the default domain, which could cause CORS errors on the other language domains. * Fixed: Plugin Tokens for language domains whose key contains an underscore (e.g., TranslatePress' nl_NL) were stored under a stripped key, which meant they were never read back. * Tested with WP 7.1. diff --git a/src/Admin/Provisioning.php b/src/Admin/Provisioning.php index bbfde3b2..e2f8967b 100644 --- a/src/Admin/Provisioning.php +++ b/src/Admin/Provisioning.php @@ -22,7 +22,9 @@ class Provisioning { const CUSTOM_PROPERTIES = [ 'cart_total', 'cart_total_items', + 'currency', 'id', + 'language', 'name', 'price', 'product_id', diff --git a/src/Admin/Provisioning/Integrations.php b/src/Admin/Provisioning/Integrations.php index b7723e25..c335d6e7 100644 --- a/src/Admin/Provisioning/Integrations.php +++ b/src/Admin/Provisioning/Integrations.php @@ -9,6 +9,7 @@ namespace Plausible\Analytics\WP\Admin\Provisioning; use Plausible\Analytics\WP\Admin\Provisioning; +use Plausible\Analytics\WP\Helpers; class Integrations { /** @@ -49,57 +50,169 @@ private function init() { } /** + * @since 2.6.2 Added $post_type, to allow translating the Pageview goal's path. + * * @param array $event_goals * @param string $funnel_name + * @param string $post_type The integration's product post type, e.g. 'product'. * * @return void * @codeCoverageIgnore We don't want to test the API. */ - public function create_integration_funnel( $event_goals, $funnel_name ) { - $goals = []; + public function create_integration_funnel( $event_goals, $funnel_name, $post_type = '' ) { $all_ids = $this->provisioning->normalize_option( get_option( 'plausible_analytics_enhanced_measurements_goal_ids', [] ) ); - foreach ( $event_goals as $event_key => $event_goal ) { - if ( $event_key === 'remove-from-cart' ) { - foreach ( $this->provisioning->get_clients() as $key => $client ) { - $all_ids = $this->provisioning->create_goals( - [ $this->provisioning->create_goal_request( $event_goal ) ], - $client, - $key, - $all_ids - ); + foreach ( $this->provisioning->get_clients() as $key => $client ) { + $goals = []; + /** + * Goals which shouldn't (or can't) be part of the funnel. + */ + $extra_goals = []; + + foreach ( $event_goals as $event_key => $event_goal ) { + if ( $event_key === 'remove-from-cart' ) { + $extra_goals[] = $this->provisioning->create_goal_request( $event_goal ); + + continue; } - continue; - } + if ( $event_key === 'purchase' ) { + $currency = \Plausible\Analytics\WP\Integrations::is_edd_active() ? edd_get_currency() : get_woocommerce_currency(); + $goals[] = $this->provisioning->create_goal_request( $event_goal, 'Revenue', $currency ); - if ( $event_key === 'purchase' ) { - $currency = \Plausible\Analytics\WP\Integrations::is_edd_active() ? edd_get_currency() : get_woocommerce_currency(); - $goals[] = $this->provisioning->create_goal_request( $event_goal, 'Revenue', $currency ); + continue; + } - continue; - } + if ( $event_key === 'view-product' ) { + $paths = $this->get_pageview_goal_paths( $this->get_goal_path( $event_goal ), $key, $post_type ); + + /** + * A funnel step holds one goal, so the default language's path is the one that ends up in the + * funnel. The other languages get a goal of their own. + */ + $goals[] = $this->provisioning->create_goal_request( $event_goal, 'Pageview', null, array_shift( $paths ) ); + + foreach ( $paths as $path ) { + $extra_goals[] = $this->provisioning->create_goal_request( $event_goal, 'Pageview', null, $path ); + } - if ( $event_key === 'view-product' ) { - $path = preg_replace( '/^.*?\//', '', $event_goal ); - $goals[] = $this->provisioning->create_goal_request( $event_goal, 'Pageview', null, '/' . $path ); + continue; + } - continue; + $goals[] = $this->provisioning->create_goal_request( $event_goal ); } - $goals[] = $this->provisioning->create_goal_request( $event_goal ); - } + if ( ! empty( $extra_goals ) ) { + $all_ids = $this->provisioning->create_goals( $extra_goals, $client, $key, $all_ids ); + } - foreach ( $this->provisioning->get_clients() as $key => $client ) { $all_ids = $this->provisioning->create_funnel( $funnel_name, $goals, $client, $key, $all_ids ); } } + /** + * Returns the path of a "Visit /some/path*" goal. + * + * @since 2.6.2 + * + * @param string $event_goal + * + * @return string + */ + private function get_goal_path( $event_goal ) { + return '/' . preg_replace( '/^.*?\//', '', $event_goal ); + } + + /** + * Returns the Pageview goal paths for $path: one for each language that's served on $domain_key's domain. + * + * Multilingual plugins serve translated content under a language prefix (/es/product/...) and, when the post type's + * base slug is translated (e.g. WooCommerce Multilingual's Store URLs), under a translated base (/producto/...). + * A goal for the default language's path would never match those pageviews. + * + * The default language's path is always the first element. + * + * @since 2.6.2 + * + * @param string $path E.g. /product* + * @param string $domain_key The Language Domain the goal is created for. + * @param string $post_type The post type $path's base slug belongs to. + * + * @return array + * + * @codeCoverageIgnore Because it depends on 3rd party plugins. + */ + private function get_pageview_goal_paths( $path, $domain_key, $post_type ) { + $languages = Helpers::get_active_languages(); + + if ( empty( $languages ) ) { + return [ $path ]; + } + + $default = Helpers::get_default_language(); + + if ( Helpers::is_language_per_domain_mode() ) { + // Each domain serves exactly one language, from its own root. + $languages = [ $domain_key === 'default' ? $default : $domain_key ]; + } else { + $languages = array_merge( [ $default ], array_diff( $languages, [ $default ] ) ); + } + + $paths = []; + + foreach ( array_filter( $languages ) as $language ) { + $paths[] = $this->localize_goal_path( $path, $language, $post_type ); + } + + $paths = array_values( array_unique( array_filter( $paths ) ) ); + + return ! empty( $paths ) ? $paths : [ $path ]; + } + + /** + * Rewrites $path to the URL the given language is served under, e.g. /product* > /es/producto*. + * + * @since 2.6.2 + * + * @param string $path + * @param string $language_code + * @param string $post_type + * + * @return string + * + * @codeCoverageIgnore Because it depends on 3rd party plugins. + */ + private function localize_goal_path( $path, $language_code, $post_type ) { + $home_path = trim( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ), '/' ); + $relative = ltrim( $path, '/' ); + + // On multisite subdirectory installs the site's path precedes the language prefix. + if ( $home_path !== '' && strpos( $relative, "$home_path/" ) === 0 ) { + $relative = substr( $relative, strlen( $home_path ) + 1 ); + } else { + $home_path = ''; + } + + $suffix = ''; + + if ( substr( $relative, -1 ) === '*' ) { + $suffix = '*'; + $relative = substr( $relative, 0, -1 ); + } + + $slug = Helpers::translate_url_slug( trim( $relative, '/' ), $language_code, $post_type ); + $parts = array_filter( [ $home_path, Helpers::get_language_url_prefix( $language_code ), $slug ] ); + + return '/' . implode( '/', $parts ) . $suffix; + } + /** * Deletes the integration-specific goals using the stored goal IDs. * + * @since 2.6.2 Also deletes the Pageview goals created for the other languages. + * * @param object $integration The integration object containing event goals to be deleted. * * @return void @@ -112,10 +225,11 @@ public function delete_integration_goals( $integration ) { ); foreach ( $this->provisioning->get_clients() as $domain_key => $client ) { - $goals = $all_ids[ $domain_key ] ?? []; + $goals = $all_ids[ $domain_key ] ?? []; + $event_goals = $this->add_localized_event_goals( (array) $integration->event_goals, $domain_key, $integration->post_type ?? '' ); foreach ( $goals as $id => $name ) { - $key = $this->provisioning->array_search_contains( $name, $integration->event_goals ); + $key = $this->provisioning->array_search_contains( $name, $event_goals ); if ( $key ) { $client->delete_goal( $id ); @@ -132,4 +246,33 @@ public function delete_integration_goals( $integration ) { update_option( 'plausible_analytics_enhanced_measurements_goal_ids', $all_ids ); } + + /** + * Adds the view-product goal of every other language to $event_goals, so the goals created for those languages + * are recognized (and deleted) too. + * + * @since 2.6.2 + * + * @param array $event_goals + * @param string $domain_key + * @param string $post_type + * + * @return array + * + * @codeCoverageIgnore Because it depends on 3rd party plugins. + */ + private function add_localized_event_goals( $event_goals, $domain_key, $post_type ) { + if ( empty( $event_goals['view-product'] ) ) { + return $event_goals; + } + + $event_goal = $event_goals['view-product']; + $path = $this->get_goal_path( $event_goal ); + + foreach ( $this->get_pageview_goal_paths( $path, $domain_key, $post_type ) as $i => $localized_path ) { + $event_goals[ "view-product-$i" ] = str_replace( $path, $localized_path, $event_goal ); + } + + return $event_goals; + } } diff --git a/src/Admin/Provisioning/Integrations/EDD.php b/src/Admin/Provisioning/Integrations/EDD.php index d5b9bf3c..2ba35df1 100644 --- a/src/Admin/Provisioning/Integrations/EDD.php +++ b/src/Admin/Provisioning/Integrations/EDD.php @@ -58,7 +58,7 @@ public function maybe_create_edd_funnel( $old_settings, $settings ) { $edd = new Integrations\EDD( false ); - $this->integrations->create_integration_funnel( $edd->event_goals, __( 'EDD Purchase Funnel', 'plausible-analytics' ) ); + $this->integrations->create_integration_funnel( $edd->event_goals, __( 'EDD Purchase Funnel', 'plausible-analytics' ), $edd->post_type ); } /** diff --git a/src/Admin/Provisioning/Integrations/WooCommerce.php b/src/Admin/Provisioning/Integrations/WooCommerce.php index 55e70b40..55155af4 100644 --- a/src/Admin/Provisioning/Integrations/WooCommerce.php +++ b/src/Admin/Provisioning/Integrations/WooCommerce.php @@ -59,7 +59,7 @@ public function maybe_create_woocommerce_funnel( $old_settings, $settings ) { $woocommerce = new Integrations\WooCommerce( false ); - $this->integrations->create_integration_funnel( $woocommerce->event_goals, __( 'Woo Purchase Funnel', 'plausible-analytics' ) ); + $this->integrations->create_integration_funnel( $woocommerce->event_goals, __( 'Woo Purchase Funnel', 'plausible-analytics' ), $woocommerce->post_type ); } /** diff --git a/src/Admin/Upgrades.php b/src/Admin/Upgrades.php index 481bb6b3..d1b54059 100644 --- a/src/Admin/Upgrades.php +++ b/src/Admin/Upgrades.php @@ -106,6 +106,10 @@ public function run() { $this->upgrade_to_260(); } + if ( version_compare( $plausible_analytics_version, '2.6.2', '<' ) ) { + $this->upgrade_to_262(); + } + // Add required upgrade routines for future versions here. } @@ -427,6 +431,49 @@ private function upgrade_to_260() { update_option( 'plausible_analytics_version', '2.6.0' ); } + /** + * If a supported multilingual plugin (WPML, with or without WPML Multilingual & Multicurrency for WooCommerce, or + * TranslatePress) is active alongside WooCommerce or Easy Digital Downloads, and Ecommerce Revenue is enabled, + * recreate the goals and custom properties after updating the plugin. + * + * That way, existing installs get the Pageview goals for the languages they serve, e.g. /es/producto*, and the + * currency and language custom properties, without having to save their settings first. + * + * @since v2.6.2 + * + * @return void + * + * @codeCoverageIgnore because all we'd be doing is testing the Plugins API. + */ + public function upgrade_to_262() { + $is_ecommerce = \Plausible\Analytics\WP\Integrations::is_wc_active() || \Plausible\Analytics\WP\Integrations::is_edd_active(); + + if ( Helpers::get_multilang_plugin() && $is_ecommerce && + EnhancedMeasurements::is_enabled( EnhancedMeasurements::ECOMMERCE_REVENUE ) ) { + $provisioning = new Provisioning(); + $integrations = new Integrations( $provisioning ); + $settings = Helpers::get_settings(); + + /** + * These routines bail when no Plugin Token is entered yet, and each funnel is only created for the + * ecommerce plugin that's actually active. If no token is entered, the goals are created as soon as one + * is, so there's no need to run this routine again. + * + * @see Provisioning::maybe_provision_on_connect() Creates the goals and custom properties as soon as a + * Plugin Token is entered. + * @see Provisioning\Integrations\WooCommerce::init() Both funnels are (re)created on + * update_option_plausible_analytics_settings, i.e. when that token is saved. + * @see Provisioning\Integrations\EDD::init() + */ + $provisioning->maybe_create_custom_properties( [], $settings ); + + ( new Provisioning\Integrations\WooCommerce( $integrations ) )->maybe_create_woocommerce_funnel( [], $settings ); + ( new Provisioning\Integrations\EDD( $integrations ) )->maybe_create_edd_funnel( [], $settings ); + } + + update_option( 'plausible_analytics_version', '2.6.2' ); + } + /** * Display a notice to CE users that haven't entered an API token yet. * diff --git a/src/Helpers.php b/src/Helpers.php index 68a17572..99c6c870 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -22,6 +22,36 @@ class Helpers { const MULTILANG_PLUGIN_TRANSLATEPRESS = 'translatepress'; + /** + * Returns the language codes of all languages served by the active multilingual plugin. + * + * @since v2.6.2 + * + * @return array Empty when no supported multilingual plugin is active. + * + * @codeCoverageIgnore Because it depends on 3rd party plugins. + */ + public static function get_active_languages() { + $languages = []; + + switch ( static::get_multilang_plugin() ) { + case static::MULTILANG_PLUGIN_WPML: + $active = apply_filters( 'wpml_active_languages', null, [ 'skip_missing' => 0 ] ); + + if ( is_array( $active ) ) { + $languages = array_keys( $active ); + } + break; + + case static::MULTILANG_PLUGIN_TRANSLATEPRESS: + $settings = get_option( 'trp_settings', [] ); + $languages = $settings['translation-languages'] ?? []; + break; + } + + return (array) apply_filters( 'plausible_analytics_active_languages', array_values( (array) $languages ) ); + } + /** * Returns the API token. * @@ -225,6 +255,31 @@ protected static function get_translatepress_current_language() { return ! empty( $TRP_LANGUAGE ) ? $TRP_LANGUAGE : null; } + /** + * Returns the language code that's currently being served. + * + * @since v2.6.2 + * + * @return string Empty when no supported multilingual plugin is active. + * + * @codeCoverageIgnore Because it depends on 3rd party plugins. + */ + public static function get_current_language() { + $language = ''; + + switch ( static::get_multilang_plugin() ) { + case static::MULTILANG_PLUGIN_WPML: + $language = apply_filters( 'wpml_current_language', null ); + break; + + case static::MULTILANG_PLUGIN_TRANSLATEPRESS: + $language = static::get_translatepress_current_language(); + break; + } + + return (string) apply_filters( 'plausible_analytics_current_language', (string) $language ); + } + /** * Returns the name of the current Plausible domain. * @@ -570,6 +625,98 @@ public static function get_language_domains() { return apply_filters( 'plausible_analytics_language_domains', $domains ); } + /** + * Returns the URL prefix the given language is served under e.g., 'es' for https://example.com/es/. + * + * Returns an empty string when the language isn't served under a prefix i.e., in "domain per language" mode, in + * "language as parameter" mode, or for the default language. + * + * @since v2.6.2 + * + * @param string $language_code + * + * @return string + * + * @codeCoverageIgnore Because it depends on 3rd party plugins. + */ + public static function get_language_url_prefix( $language_code ) { + if ( empty( $language_code ) || static::is_language_per_domain_mode() ) { + return ''; + } + + $prefix = ''; + + switch ( static::get_multilang_plugin() ) { + case static::MULTILANG_PLUGIN_WPML: + /** + * wpml_permalink converts a URL to the requested language, whichever negotiation type is in use, so + * whatever it adds in front of the home URL's path is the prefix we're after. + */ + $prefix = static::get_home_relative_path( apply_filters( 'wpml_permalink', home_url( '/' ), $language_code ) ); + break; + + case static::MULTILANG_PLUGIN_TRANSLATEPRESS: + $settings = get_option( 'trp_settings', [] ); + $slugs = $settings['url-slugs'] ?? []; + + if ( $language_code !== static::get_default_language() || + ( $settings['add-subdirectory-to-default-language'] ?? 'no' ) === 'yes' ) { + $prefix = $slugs[ $language_code ] ?? $language_code; + } + break; + } + + return (string) apply_filters( 'plausible_analytics_language_url_prefix', trim( (string) $prefix, '/' ), $language_code ); + } + + /** + * Returns $url's path, relative to the home URL's path, without leading/trailing slashes. + * + * @since v2.6.2 + * + * @param string $url + * + * @return string + * + * @codeCoverageIgnore Because it depends on 3rd party plugins. + */ + protected static function get_home_relative_path( $url ) { + $path = trim( (string) wp_parse_url( $url, PHP_URL_PATH ), '/' ); + $home = trim( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ), '/' ); + + if ( $home !== '' && ( $path === $home || strpos( $path, "$home/" ) === 0 ) ) { + $path = trim( substr( $path, strlen( $home ) ), '/' ); + } + + return $path; + } + + /** + * Returns the default language code of the active multilingual plugin. + * + * @since v2.6.2 + * + * @return string + * + * @codeCoverageIgnore Because it depends on 3rd party plugins. + */ + public static function get_default_language() { + $language = ''; + + switch ( static::get_multilang_plugin() ) { + case static::MULTILANG_PLUGIN_WPML: + $language = apply_filters( 'wpml_default_language', null ); + break; + + case static::MULTILANG_PLUGIN_TRANSLATEPRESS: + $settings = get_option( 'trp_settings', [] ); + $language = $settings['default-language'] ?? ''; + break; + } + + return (string) apply_filters( 'plausible_analytics_default_language', (string) $language ); + } + /** * Get the name of the active multilang plugin. * @@ -662,6 +809,33 @@ public static function main_script_is_registered() { return wp_script_is( 'plausible-analytics', 'registered' ); } + /** + * Translates a URL slug, e.g., WooCommerce's product base, to the given language. + * + * WPML's String Translation registers these bases as 'URL slug: {post type}' in the 'WordPress' domain, which is + * what WooCommerce Multilingual's Store URLs use too. TranslatePress doesn't translate post type bases. + * + * @since v2.6.2 + * + * @param string $slug The untranslated slug, e.g. 'product'. + * @param string $language_code + * @param string $post_type The post-type the slug belongs to, e.g. 'product'. + * + * @return string + * + * @codeCoverageIgnore Because it depends on 3rd party plugins. + */ + public static function translate_url_slug( $slug, $language_code, $post_type = '' ) { + $translated = $slug; + + if ( ! empty( $slug ) && ! empty( $language_code ) && ! empty( $post_type ) && + static::get_multilang_plugin() === static::MULTILANG_PLUGIN_WPML ) { + $translated = apply_filters( 'wpml_translate_single_string', $slug, 'WordPress', "URL slug: $post_type", $language_code ); + } + + return (string) apply_filters( 'plausible_analytics_translated_url_slug', $translated, $slug, $language_code, $post_type ); + } + /** * @param string $option_name * @param array|string|int $option_value diff --git a/src/Integrations/EDD.php b/src/Integrations/EDD.php index 58fa1497..b20d3937 100644 --- a/src/Integrations/EDD.php +++ b/src/Integrations/EDD.php @@ -19,6 +19,11 @@ class EDD { public $event_goals = []; + /** + * @var string The post type the view-product goal's path is built from. + */ + public $post_type = 'download'; + /** * Build class. * diff --git a/src/Integrations/WooCommerce.php b/src/Integrations/WooCommerce.php index 58f3a023..8da9f5b1 100644 --- a/src/Integrations/WooCommerce.php +++ b/src/Integrations/WooCommerce.php @@ -11,6 +11,7 @@ use Plausible\Analytics\WP\Admin\Provisioning; use Plausible\Analytics\WP\EnhancedMeasurements; +use Plausible\Analytics\WP\Helpers; use Plausible\Analytics\WP\Integrations; use Plausible\Analytics\WP\Proxy; use WC_Cart; @@ -22,6 +23,11 @@ class WooCommerce { */ public $event_goals = []; + /** + * @var string The post type the view-product goal's path is built from. + */ + public $post_type = 'product'; + /** * Build class. * @@ -162,15 +168,17 @@ public function track_add_to_cart( $product, $add_to_cart_data ) { $cart = $this->get_wc_cart(); $props = apply_filters( 'plausible_analytics_woocommerce_add_to_cart_custom_properties', - [ - 'product_name' => $product_data['name'], - 'product_id' => $added_to_cart['id'], - 'quantity' => $added_to_cart['quantity'], - 'price' => $product_data['price'], - 'tax_class' => $product_data['tax_class'], - 'cart_total_items' => count( $cart->get_cart_contents() ), - 'cart_total' => $cart->get_total( null ), - ] + $this->add_common_props( + [ + 'product_name' => $product_data['name'], + 'product_id' => $added_to_cart['id'], + 'quantity' => $added_to_cart['quantity'], + 'price' => $product_data['price'], + 'tax_class' => $product_data['tax_class'], + 'cart_total_items' => count( $cart->get_cart_contents() ), + 'cart_total' => $cart->get_total( null ), + ] + ) ); $proxy = new Proxy( false ); @@ -207,6 +215,36 @@ protected function get_wc_cart() { return WC()->cart; } + /** + * Adds the properties that apply to all events to $props. + * + * The currency is added because multicurrency plugins e.g., WPML Multilingual & Multicurrency for WooCommerce, + * convert all amounts to the currency the visitor is shopping in. Without it, Plausible Analytics would sum up + * amounts in different currencies as if they were the same. + * + * The language is only added on multilingual sites because translated products are separate posts, i.e. they have + * their own product ID and name. + * + * @since 2.6.2 + * + * @param array $props + * @param string $currency Defaults to the currency the visitor is shopping in. + * + * @return array + * + * @codeCoverageIgnore Because it depends on 3rd party plugins. + */ + private function add_common_props( $props, $currency = '' ) { + $props['currency'] = $currency ?: get_woocommerce_currency(); + $language = Helpers::get_current_language(); + + if ( ! empty( $language ) ) { + $props['language'] = $language; + } + + return $props; + } + /** * Track add to cart actions by direct link, e.g. ?product_type=download&add-to-cart=1&quantity=1 * @@ -244,12 +282,14 @@ public function track_entered_checkout() { $props = apply_filters( 'plausible_analytics_woocommerce_entered_checkout_custom_properties', [ - 'props' => [ - 'subtotal' => $cart->get_subtotal(), - 'shipping' => $cart->get_shipping_total(), - 'tax' => $cart->get_total_tax(), - 'total' => $cart->get_total( null ), - ], + 'props' => $this->add_common_props( + [ + 'subtotal' => $cart->get_subtotal(), + 'shipping' => $cart->get_shipping_total(), + 'tax' => $cart->get_total_tax(), + 'total' => $cart->get_total( null ), + ] + ), ] ); $props = wp_json_encode( $props ); @@ -279,6 +319,8 @@ public function track_purchase( $order_id ) { 'amount' => (string) $order->get_total(), 'currency' => $order->get_currency(), ], + // The order's currency can differ from the currency the visitor is browsing in right now. + 'props' => $this->add_common_props( [], $order->get_currency() ), ] ); $label = $this->event_goals['purchase']; @@ -314,14 +356,16 @@ public function track_remove_cart_item( $cart_item_key, $cart ) { $props = apply_filters( 'plausible_analytics_woocommerce_remove_cart_item_custom_properties', - [ - 'product_name' => $product->get_name(), - 'product_id' => $item_removed_from_cart['product_id'], - 'variation_id' => $item_removed_from_cart['variation_id'], - 'quantity' => $item_removed_from_cart['quantity'], - 'cart_total_items' => count( $cart_contents ), - 'cart_total' => $cart->get_total( null ), - ] + $this->add_common_props( + [ + 'product_name' => $product->get_name(), + 'product_id' => $item_removed_from_cart['product_id'], + 'variation_id' => $item_removed_from_cart['variation_id'], + 'quantity' => $item_removed_from_cart['quantity'], + 'cart_total_items' => count( $cart_contents ), + 'cart_total' => $cart->get_total( null ), + ] + ) ); $proxy = new Proxy( false ); diff --git a/tests/integration/Admin/Provisioning/IntegrationsTest.php b/tests/integration/Admin/Provisioning/IntegrationsTest.php index 94ffdce9..a58aa6ba 100644 --- a/tests/integration/Admin/Provisioning/IntegrationsTest.php +++ b/tests/integration/Admin/Provisioning/IntegrationsTest.php @@ -44,4 +44,108 @@ public function testDeleteIntegrationGoals() { $this->removeAction( 'update_option_plausible_analytics_settings', 'maybe_delete_', 11 ); } } + + /** + * Every language should get a Pageview goal for the URL it's served under, with the default language first, + * because that's the one that ends up in the funnel. + * + * @see Integrations::get_pageview_goal_paths() + * @return void + * @throws \ReflectionException + */ + public function testGetPageviewGoalPaths() { + $this->withLanguages( + function ( $paths ) { + $this->assertEquals( [ '/product*', '/es/producto*', '/nl/product*' ], $paths( '/product*', 'default' ) ); + } + ); + } + + /** + * In "domain per language" mode each domain serves exactly one language, from its own root, so no prefix should be + * added and only the goal for that domain's language should be created. + * + * @see Integrations::get_pageview_goal_paths() + * @return void + * @throws \ReflectionException + */ + public function testGetPageviewGoalPathsInLanguagePerDomainMode() { + $per_domain = '__return_true'; + + add_filter( 'plausible_analytics_language_per_domain_mode', $per_domain ); + + try { + $this->withLanguages( + function ( $paths ) { + $this->assertEquals( [ '/producto*' ], $paths( '/product*', 'es' ) ); + $this->assertEquals( [ '/product*' ], $paths( '/product*', 'default' ) ); + } + ); + } finally { + remove_filter( 'plausible_analytics_language_per_domain_mode', $per_domain ); + } + } + + /** + * Without a multilingual plugin, the path should be left alone. + * + * @see Integrations::get_pageview_goal_paths() + * @return void + * @throws \ReflectionException + */ + public function testGetPageviewGoalPathsWithoutLanguages() { + $this->assertEquals( [ '/product*' ], $this->getPageviewGoalPaths()( '/product*', 'default' ) ); + } + + /** + * Runs $test with a site that serves Spanish and Dutch besides its default language, of which Spanish has a + * translated product base. + * + * @param callable $test Receives a callable which returns the Pageview goal paths for a path/domain key. + * + * @return void + * @throws \ReflectionException + */ + private function withLanguages( $test ) { + $languages = function () { + return [ 'en', 'es', 'nl' ]; + }; + $default = function () { + return 'en'; + }; + $prefix = function ( $prefix, $language ) { + return $language === 'en' ? '' : $language; + }; + $slug = function ( $translated, $slug, $language ) { + return $language === 'es' && $slug === 'product' ? 'producto' : $translated; + }; + + add_filter( 'plausible_analytics_active_languages', $languages ); + add_filter( 'plausible_analytics_default_language', $default ); + add_filter( 'plausible_analytics_language_url_prefix', $prefix, 10, 2 ); + add_filter( 'plausible_analytics_translated_url_slug', $slug, 10, 3 ); + + try { + $test( $this->getPageviewGoalPaths() ); + } finally { + remove_filter( 'plausible_analytics_active_languages', $languages ); + remove_filter( 'plausible_analytics_default_language', $default ); + remove_filter( 'plausible_analytics_language_url_prefix', $prefix, 10 ); + remove_filter( 'plausible_analytics_translated_url_slug', $slug, 10 ); + } + } + + /** + * @return callable + * @throws \ReflectionException + */ + private function getPageviewGoalPaths() { + $integrations = ( new \ReflectionClass( Integrations::class ) )->newInstanceWithoutConstructor(); + $method = new \ReflectionMethod( Integrations::class, 'get_pageview_goal_paths' ); + $method->setAccessible( true ); + + return function ( $path, $domain_key ) use ( $method, $integrations ) { + return $method->invoke( $integrations, $path, $domain_key, 'product' ); + }; + } } From 1c2c5461d4034f5716fc1b7b240e6df5c7fd2d4b Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:26:11 +0200 Subject: [PATCH 02/21] Fixed tests. --- .../Integrations/WooCommerceTest.php | 46 ++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/tests/integration/Integrations/WooCommerceTest.php b/tests/integration/Integrations/WooCommerceTest.php index 8ba6830c..e982e9ae 100644 --- a/tests/integration/Integrations/WooCommerceTest.php +++ b/tests/integration/Integrations/WooCommerceTest.php @@ -20,6 +20,7 @@ public function testTrackEnteredCheckout() { when( 'is_checkout' )->justReturn( true ); when( 'is_wc_endpoint_url' )->justReturn( false ); when( 'wc_get_permalink_structure' )->justReturn( [ 'product_base' => 'product' ] ); + when( 'get_woocommerce_currency' )->justReturn( 'EUR' ); $cart_mock = $this->getMockBuilder( 'WC_Cart' )->setMethods( [ @@ -41,7 +42,7 @@ public function testTrackEnteredCheckout() { ->getMock(); $class->method( 'get_wc_cart' )->willReturn( $cart_mock ); - $this->expectOutputContains( '{"props":{"subtotal":10,"shipping":5,"tax":1,"total":"16.00"}}' ); + $this->expectOutputContains( '{"props":{"subtotal":10,"shipping":5,"tax":1,"total":"16.00","currency":"EUR"}}' ); $class->track_entered_checkout(); } @@ -69,8 +70,49 @@ public function testTrackPurchase() { when( 'wc_get_order' )->justReturn( $mock ); - $this->expectOutputContains( '{"revenue":{"amount":"10","currency":"EUR"}}' ); + $this->expectOutputContains( '{"revenue":{"amount":"10","currency":"EUR"},"props":{"currency":"EUR"}}' ); $class->track_purchase( 1 ); } + + /** + * On multilingual sites, the language the visitor is shopping in should be added to the event's properties, because + * translated products are separate posts, i.e., each language has its own product ID and product name. + * + * @see WooCommerce::track_purchase() + * @return void + */ + public function testTrackPurchaseAddsLanguage() { + when( 'wc_get_permalink_structure' )->justReturn( [ 'product_base' => 'product' ] ); + + $language = function () { + return 'nl'; + }; + + add_filter( 'plausible_analytics_current_language', $language ); + + try { + $class = new WooCommerce( false ); + $mock = $this->getMockBuilder( 'WC_Order' )->setMethods( + [ + 'get_meta', + 'get_total', + 'get_currency', + 'add_meta_data', + 'save', + ] + )->getMock(); + $mock->method( 'get_meta' )->willReturn( false ); + $mock->method( 'get_total' )->willReturn( 10 ); + $mock->method( 'get_currency' )->willReturn( 'EUR' ); + + when( 'wc_get_order' )->justReturn( $mock ); + + $this->expectOutputContains( '"props":{"currency":"EUR","language":"nl"}' ); + + $class->track_purchase( 1 ); + } finally { + remove_filter( 'plausible_analytics_current_language', $language ); + } + } } From 03b1da03f30a2e30568bc9edb7e1c6895783f46e Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:30:46 +0200 Subject: [PATCH 03/21] PHPDoc. --- src/Admin/Upgrades.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Admin/Upgrades.php b/src/Admin/Upgrades.php index d1b54059..86b56321 100644 --- a/src/Admin/Upgrades.php +++ b/src/Admin/Upgrades.php @@ -439,6 +439,9 @@ private function upgrade_to_260() { * That way, existing installs get the Pageview goals for the languages they serve, e.g. /es/producto*, and the * currency and language custom properties, without having to save their settings first. * + * This runs on init (@see Upgrades::__construct()), i.e. after WPML registered its language API on plugin load and + * booted on plugins_loaded, so the languages the Pageview goal paths are built from are available. + * * @since v2.6.2 * * @return void From 149c2fe6b682e68a6f98516591d8ef201ff9411b Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:17:49 +0200 Subject: [PATCH 04/21] Defer 2.6.2 multilingual provisioning until languages are available upgrade_to_262() runs on init. When a multilingual plugin is active but its language API hasn't returned any languages yet, bail without bumping the version so run() retries on a later request, instead of provisioning goals for the default language's path only and marking the upgrade complete. Also add a unit test for Integrations::get_goal_path(). --- src/Admin/Upgrades.php | 15 +++++++++++++-- .../Admin/Provisioning/IntegrationsTest.php | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/Admin/Upgrades.php b/src/Admin/Upgrades.php index 86b56321..3bacd6a3 100644 --- a/src/Admin/Upgrades.php +++ b/src/Admin/Upgrades.php @@ -439,8 +439,10 @@ private function upgrade_to_260() { * That way, existing installs get the Pageview goals for the languages they serve, e.g. /es/producto*, and the * currency and language custom properties, without having to save their settings first. * - * This runs on init (@see Upgrades::__construct()), i.e. after WPML registered its language API on plugin load and - * booted on plugins_loaded, so the languages the Pageview goal paths are built from are available. + * This runs on init (@see Upgrades::__construct()), i.e. after WPML/TranslatePress registered its language API on + * plugin load and booted on plugins_loaded, so the languages the Pageview goal paths are built from are typically + * available. As a safeguard for edge cases where they aren't yet, we bail without bumping the version so this + * routine retries on a later request rather than provisioning the default language's path only. * * @since v2.6.2 * @@ -453,6 +455,15 @@ public function upgrade_to_262() { if ( Helpers::get_multilang_plugin() && $is_ecommerce && EnhancedMeasurements::is_enabled( EnhancedMeasurements::ECOMMERCE_REVENUE ) ) { + /** + * A supported multilingual plugin is active, but its language API returned no languages (yet). Bail + * without bumping the version, so run() retries this on a later request once the languages are available, + * rather than provisioning goals for the default language's path only and marking the upgrade complete. + */ + if ( empty( Helpers::get_active_languages() ) ) { + return; + } + $provisioning = new Provisioning(); $integrations = new Integrations( $provisioning ); $settings = Helpers::get_settings(); diff --git a/tests/integration/Admin/Provisioning/IntegrationsTest.php b/tests/integration/Admin/Provisioning/IntegrationsTest.php index a58aa6ba..b5b8bac3 100644 --- a/tests/integration/Admin/Provisioning/IntegrationsTest.php +++ b/tests/integration/Admin/Provisioning/IntegrationsTest.php @@ -45,6 +45,23 @@ public function testDeleteIntegrationGoals() { } } + /** + * The goal path is the event goal's URI, i.e. everything after the "Visit " prefix, with a single leading slash. + * + * @see Integrations::get_goal_path() + * @return void + * @throws \ReflectionException + */ + public function testGetGoalPath() { + $integrations = ( new \ReflectionClass( Integrations::class ) )->newInstanceWithoutConstructor(); + $method = new \ReflectionMethod( Integrations::class, 'get_goal_path' ); + $method->setAccessible( true ); + + $this->assertEquals( '/product*', $method->invoke( $integrations, 'Visit /product*' ) ); + // A longer base (e.g. a multisite subdirectory install) is kept intact. + $this->assertEquals( '/shop/product*', $method->invoke( $integrations, 'Visit /shop/product*' ) ); + } + /** * Every language should get a Pageview goal for the URL it's served under, with the default language first, * because that's the one that ends up in the funnel. From 7cfb95d0cb6f25d96e11b7131c3272727d2ca173 Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:34:23 +0200 Subject: [PATCH 05/21] Provision 2.6.2 ecommerce custom properties regardless of multilingual; fix goal-name matching upgrade_to_262(): (re)create the currency/language Custom Properties for every WooCommerce/EDD install with Ecommerce Revenue enabled, not only multilingual ones (2.6.2 adds the 'currency' and 'language' properties). Funnels are still only (re)provisioned when a multilingual plugin is active and its languages are available; if they aren't yet, the funnels are skipped and the version is still bumped, so the upgrade completes in a single pass (a settings save recreates them). add_localized_event_goals(): match the display name Plausible assigns to Pageview goals ('Visit ') instead of the WP-translated event-goal template, so the goals are recognized and deleted on non-English admin locales too. --- src/Admin/Provisioning/Integrations.php | 12 +++--- src/Admin/Upgrades.php | 57 +++++++++++++------------ 2 files changed, 36 insertions(+), 33 deletions(-) diff --git a/src/Admin/Provisioning/Integrations.php b/src/Admin/Provisioning/Integrations.php index c335d6e7..78b33abd 100644 --- a/src/Admin/Provisioning/Integrations.php +++ b/src/Admin/Provisioning/Integrations.php @@ -248,8 +248,11 @@ public function delete_integration_goals( $integration ) { } /** - * Adds the view-product goal of every other language to $event_goals, so the goals created for those languages - * are recognized (and deleted) too. + * Adds the view-product goal for every language served on $domain_key's domain to $event_goals, so the Pageview + * goals created for those languages are recognized (and deleted) too. + * + * The names are built in the display-name format Plausible assigns to Pageview goals ("Visit "), not from + * the WP-translated event-goal template, so matching works regardless of the admin's language. * * @since 2.6.2 * @@ -266,11 +269,10 @@ private function add_localized_event_goals( $event_goals, $domain_key, $post_typ return $event_goals; } - $event_goal = $event_goals['view-product']; - $path = $this->get_goal_path( $event_goal ); + $path = $this->get_goal_path( $event_goals['view-product'] ); foreach ( $this->get_pageview_goal_paths( $path, $domain_key, $post_type ) as $i => $localized_path ) { - $event_goals[ "view-product-$i" ] = str_replace( $path, $localized_path, $event_goal ); + $event_goals[ "view-product-$i" ] = sprintf( 'Visit %s', $localized_path ); } return $event_goals; diff --git a/src/Admin/Upgrades.php b/src/Admin/Upgrades.php index 3bacd6a3..176a1e59 100644 --- a/src/Admin/Upgrades.php +++ b/src/Admin/Upgrades.php @@ -432,17 +432,18 @@ private function upgrade_to_260() { } /** - * If a supported multilingual plugin (WPML, with or without WPML Multilingual & Multicurrency for WooCommerce, or - * TranslatePress) is active alongside WooCommerce or Easy Digital Downloads, and Ecommerce Revenue is enabled, - * recreate the goals and custom properties after updating the plugin. + * After updating to 2.6.2, (re)create the Custom Properties and — for multilingual ecommerce installs — the + * integration goals, so existing installs pick up the currency/language Custom Properties and the per-language + * Pageview goals (e.g. /es/producto*) without having to save their settings first. * - * That way, existing installs get the Pageview goals for the languages they serve, e.g. /es/producto*, and the - * currency and language custom properties, without having to save their settings first. + * The Custom Properties apply to every WooCommerce/EDD install with Ecommerce Revenue enabled. The localized + * Pageview goals only apply when a multilingual plugin (WPML, with or without WooCommerce Multilingual & + * Multicurrency, or TranslatePress) is active. * - * This runs on init (@see Upgrades::__construct()), i.e. after WPML/TranslatePress registered its language API on - * plugin load and booted on plugins_loaded, so the languages the Pageview goal paths are built from are typically - * available. As a safeguard for edge cases where they aren't yet, we bail without bumping the version so this - * routine retries on a later request rather than provisioning the default language's path only. + * This runs on init (@see Upgrades::__construct()), i.e. after the multilingual plugin registered its language API + * on plugin load. In the rare case its languages aren't available yet, the funnels are skipped — they're recreated + * on the next settings save anyway (@see Provisioning\Integrations\WooCommerce::init()) — so the upgrade always + * completes in a single pass. * * @since v2.6.2 * @@ -453,36 +454,36 @@ private function upgrade_to_260() { public function upgrade_to_262() { $is_ecommerce = \Plausible\Analytics\WP\Integrations::is_wc_active() || \Plausible\Analytics\WP\Integrations::is_edd_active(); - if ( Helpers::get_multilang_plugin() && $is_ecommerce && - EnhancedMeasurements::is_enabled( EnhancedMeasurements::ECOMMERCE_REVENUE ) ) { - /** - * A supported multilingual plugin is active, but its language API returned no languages (yet). Bail - * without bumping the version, so run() retries this on a later request once the languages are available, - * rather than provisioning goals for the default language's path only and marking the upgrade complete. - */ - if ( empty( Helpers::get_active_languages() ) ) { - return; - } - + if ( $is_ecommerce && EnhancedMeasurements::is_enabled( EnhancedMeasurements::ECOMMERCE_REVENUE ) ) { $provisioning = new Provisioning(); - $integrations = new Integrations( $provisioning ); $settings = Helpers::get_settings(); /** - * These routines bail when no Plugin Token is entered yet, and each funnel is only created for the - * ecommerce plugin that's actually active. If no token is entered, the goals are created as soon as one - * is, so there's no need to run this routine again. + * The currency and language Custom Properties (@see Provisioning::CUSTOM_PROPERTIES) apply to every + * ecommerce install, so (re)create them whether or not a multilingual plugin is active. Bails when no + * Plugin Token is entered yet; the properties are created as soon as one is. * * @see Provisioning::maybe_provision_on_connect() Creates the goals and custom properties as soon as a * Plugin Token is entered. + */ + $provisioning->maybe_create_custom_properties( [], $settings ); + + /** + * The localized Pageview goals only matter when a multilingual plugin is active, and only once its + * language API has booted. If it hasn't returned any languages yet, skip the funnels rather than + * provisioning the default language's path only; they're (re)created with the right paths on the next + * settings save. + * * @see Provisioning\Integrations\WooCommerce::init() Both funnels are (re)created on - * update_option_plausible_analytics_settings, i.e. when that token is saved. + * update_option_plausible_analytics_settings, i.e. when the Plugin Token is saved. * @see Provisioning\Integrations\EDD::init() */ - $provisioning->maybe_create_custom_properties( [], $settings ); + if ( Helpers::get_multilang_plugin() && ! empty( Helpers::get_active_languages() ) ) { + $integrations = new Integrations( $provisioning ); - ( new Provisioning\Integrations\WooCommerce( $integrations ) )->maybe_create_woocommerce_funnel( [], $settings ); - ( new Provisioning\Integrations\EDD( $integrations ) )->maybe_create_edd_funnel( [], $settings ); + ( new Provisioning\Integrations\WooCommerce( $integrations ) )->maybe_create_woocommerce_funnel( [], $settings ); + ( new Provisioning\Integrations\EDD( $integrations ) )->maybe_create_edd_funnel( [], $settings ); + } } update_option( 'plausible_analytics_version', '2.6.2' ); From fdf83a18b5387525b7d2b0eedad6a3b4e8cf64e0 Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:31:36 +0200 Subject: [PATCH 06/21] Shorten 2.6.2 changelog entries --- readme.txt | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/readme.txt b/readme.txt index 13d57114..091cacbc 100644 --- a/readme.txt +++ b/readme.txt @@ -281,12 +281,9 @@ Please make sure you make a backup of your database before updating any version = 2.6.2 = * Added: TranslatePress "different domain per language" (Multiple Domains) compatibility. Each language domain can be mapped to its own Plausible Analytics dashboard, just like WPML. * Added: WPML Multilingual & Multicurrency for WooCommerce (WooCommerce Multilingual) compatibility. -* Fixed: the view product goal is now created for the URL each language is served under, e.g. /es/producto*, instead of for the default language's URL only. This covers both the language prefix (/es/) - and a translated product base (WooCommerce Multilingual's Store URLs), so product pageviews are no longer missed in any language but the default one. Because a funnel step holds a single goal, the default language's path remains the first step of the purchase funnel, and every other language gets a goal of its own. This applies to WooCommerce as well as Easy Digital Downloads, on WPML as well as TranslatePress. -* Improved: the WooCommerce add to cart, remove from cart, start checkout, and complete purchase events now contain a currency property. Multicurrency plugins convert all amounts to the currency the -visitor is shopping in, which Plausible Analytics would otherwise sum up as if they were all in the same currency. The complete purchase event uses the currency the order was placed in. -* Improved: on multilingual sites, those same WooCommerce events now contain a language property, because translated products are separate posts, i.e., each language has its own product ID and product - name. +* Fixed: the view-product goal now targets the URL each language is served under (e.g. /es/producto*), including a translated product base, so product pageviews are no longer missed in non-default languages. Works for WooCommerce and Easy Digital Downloads, on WPML and TranslatePress. +* Improved: the WooCommerce add-to-cart, remove-from-cart, checkout and purchase events now include a currency property, so multicurrency amounts aren't summed as if they were one currency. The purchase event uses the currency the order was placed in. +* Improved: on multilingual sites those same events also include a language property, since each language's translated product is a separate post with its own ID and name. * Fixed: in "domain per language" mode, the proxy endpoint and the locally cached tracker script were loaded from the default domain, which could cause CORS errors on the other language domains. * Fixed: Plugin Tokens for language domains whose key contains an underscore (e.g., TranslatePress' nl_NL) were stored under a stripped key, which meant they were never read back. * Tested with WP 7.1. From a416c37e5aea5d5055f328f0adbba1402feae1f2 Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:25:56 +0200 Subject: [PATCH 07/21] Delete orphaned localized view-product goals on cleanup delete_integration_goals() matched view-product goals by regenerating their localized names from the current languages, so a goal created for a language that was since removed could no longer be matched and was left behind. The plugin only stores its own goals and view-product goals are the only Pageview goals it creates, so delete every remaining 'Visit ...' goal when this integration owns the view-product goal and the other ecommerce integration isn't active, regardless of the current languages. --- src/Admin/Provisioning/Integrations.php | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/Admin/Provisioning/Integrations.php b/src/Admin/Provisioning/Integrations.php index 78b33abd..5edb50fb 100644 --- a/src/Admin/Provisioning/Integrations.php +++ b/src/Admin/Provisioning/Integrations.php @@ -211,7 +211,8 @@ private function localize_goal_path( $path, $language_code, $post_type ) { /** * Deletes the integration-specific goals using the stored goal IDs. * - * @since 2.6.2 Also deletes the Pageview goals created for the other languages. + * @since 2.6.2 Also deletes the Pageview goals created for the other languages, including languages that have since + * been removed (whose localized goal name can no longer be regenerated). * * @param object $integration The integration object containing event goals to be deleted. * @@ -224,14 +225,25 @@ public function delete_integration_goals( $integration ) { get_option( 'plausible_analytics_enhanced_measurements_goal_ids', [] ) ); + /** + * A view-product goal is a Pageview goal named "Visit *", whose localized path depends on the languages + * that were active when it was created. Those may since have changed (a language removed), so regenerating the + * name from the current languages no longer matches it, and it would be orphaned. The plugin only ever stores + * its own goals, and the view-product goals are the only Pageview goals it creates, so when this integration + * owns the view-product goal and the other ecommerce integration isn't active, every stored "Visit " goal is + * one of ours and can be removed regardless of the current languages. + */ + $delete_stale_view_product = ! empty( $integration->event_goals['view-product'] ) && + ! ( \Plausible\Analytics\WP\Integrations::is_wc_active() && \Plausible\Analytics\WP\Integrations::is_edd_active() ); + foreach ( $this->provisioning->get_clients() as $domain_key => $client ) { $goals = $all_ids[ $domain_key ] ?? []; $event_goals = $this->add_localized_event_goals( (array) $integration->event_goals, $domain_key, $integration->post_type ?? '' ); foreach ( $goals as $id => $name ) { - $key = $this->provisioning->array_search_contains( $name, $event_goals ); + $is_stale_view_product = $delete_stale_view_product && strpos( (string) $name, 'Visit ' ) === 0; - if ( $key ) { + if ( $this->provisioning->array_search_contains( $name, $event_goals ) || $is_stale_view_product ) { $client->delete_goal( $id ); unset( $goals[ $id ] ); } From fa1aeda827c2759b0be7d08758d8d4a0bb846bed Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:34:38 +0200 Subject: [PATCH 08/21] Simplify view-product goal cleanup Drop the WC+EDD both-active guard: running both WooCommerce and Easy Digital Downloads at once isn't a realistic setup (WooCommerce sells digital downloads itself), so every stored 'Visit ...' goal for the integration can be removed without checking the other integration. --- src/Admin/Provisioning/Integrations.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/Admin/Provisioning/Integrations.php b/src/Admin/Provisioning/Integrations.php index 5edb50fb..43a4fcb4 100644 --- a/src/Admin/Provisioning/Integrations.php +++ b/src/Admin/Provisioning/Integrations.php @@ -230,20 +230,19 @@ public function delete_integration_goals( $integration ) { * that were active when it was created. Those may since have changed (a language removed), so regenerating the * name from the current languages no longer matches it, and it would be orphaned. The plugin only ever stores * its own goals, and the view-product goals are the only Pageview goals it creates, so when this integration - * owns the view-product goal and the other ecommerce integration isn't active, every stored "Visit " goal is - * one of ours and can be removed regardless of the current languages. + * has a view-product goal every stored "Visit " goal is one of ours and can be removed regardless of the + * current languages. */ - $delete_stale_view_product = ! empty( $integration->event_goals['view-product'] ) && - ! ( \Plausible\Analytics\WP\Integrations::is_wc_active() && \Plausible\Analytics\WP\Integrations::is_edd_active() ); + $delete_view_product = ! empty( $integration->event_goals['view-product'] ); foreach ( $this->provisioning->get_clients() as $domain_key => $client ) { $goals = $all_ids[ $domain_key ] ?? []; $event_goals = $this->add_localized_event_goals( (array) $integration->event_goals, $domain_key, $integration->post_type ?? '' ); foreach ( $goals as $id => $name ) { - $is_stale_view_product = $delete_stale_view_product && strpos( (string) $name, 'Visit ' ) === 0; + $is_view_product = $delete_view_product && strpos( (string) $name, 'Visit ' ) === 0; - if ( $this->provisioning->array_search_contains( $name, $event_goals ) || $is_stale_view_product ) { + if ( $this->provisioning->array_search_contains( $name, $event_goals ) || $is_view_product ) { $client->delete_goal( $id ); unset( $goals[ $id ] ); } From ca844d4c6793cb2e8d2cc58b8e3c5c8fda58b4bd Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:46:20 +0200 Subject: [PATCH 09/21] Persist extra goal IDs before creating the funnel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create_goals() updates $all_ids only in memory and create_funnel() saves it only on a valid response, so localized Pageview goals created before a failing funnel request were left in Plausible without their IDs stored — untrackable for cleanup. Persist the option right after creating the extra goals. --- src/Admin/Provisioning/Integrations.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Admin/Provisioning/Integrations.php b/src/Admin/Provisioning/Integrations.php index 43a4fcb4..36473035 100644 --- a/src/Admin/Provisioning/Integrations.php +++ b/src/Admin/Provisioning/Integrations.php @@ -106,6 +106,10 @@ public function create_integration_funnel( $event_goals, $funnel_name, $post_typ if ( ! empty( $extra_goals ) ) { $all_ids = $this->provisioning->create_goals( $extra_goals, $client, $key, $all_ids ); + + // Persist immediately so the localized Pageview goals stay tracked (and thus cleanable) even if the + // funnel creation below fails and never gets to save them. + update_option( 'plausible_analytics_enhanced_measurements_goal_ids', $all_ids ); } $all_ids = $this->provisioning->create_funnel( $funnel_name, $goals, $client, $key, $all_ids ); From de3a720fa83ee5e6a77f6600f3f2d11e3ec15b4b Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:10:58 +0200 Subject: [PATCH 10/21] Reconcile stale view-product goals on (re)provisioning Provisioning is create-only, so an install updated from before this compatibility (which stored a non-localized 'Visit /product*' goal for every domain), or one whose served paths have since changed, kept those stale Pageview goals alongside the current localized ones. Before creating, delete every stored 'Visit ...' goal that isn't among the paths the domain currently serves. --- src/Admin/Provisioning/Integrations.php | 29 +++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/Admin/Provisioning/Integrations.php b/src/Admin/Provisioning/Integrations.php index 36473035..fabbeeba 100644 --- a/src/Admin/Provisioning/Integrations.php +++ b/src/Admin/Provisioning/Integrations.php @@ -65,6 +65,35 @@ public function create_integration_funnel( $event_goals, $funnel_name, $post_typ ); foreach ( $this->provisioning->get_clients() as $key => $client ) { + /** + * Reconcile the stored view-product goals before (re)creating them. Provisioning is otherwise create-only, + * so an install updated from before this compatibility — which stored a non-localized "Visit /product*" + * goal for every domain — or one whose served paths have since changed, would keep those stale Pageview + * goals alongside the current localized ones. View-product goals are the only Pageview ("Visit ") goals we + * create, so delete every stored one that isn't among the paths this domain currently serves. + */ + if ( ! empty( $event_goals['view-product'] ) ) { + $current_view_product = array_map( + static function ( $path ) { + return sprintf( 'Visit %s', $path ); + }, + $this->get_pageview_goal_paths( $this->get_goal_path( $event_goals['view-product'] ), $key, $post_type ) + ); + $deleted_stale = false; + + foreach ( $all_ids[ $key ] ?? [] as $id => $name ) { + if ( strpos( (string) $name, 'Visit ' ) === 0 && ! in_array( $name, $current_view_product, true ) ) { + $client->delete_goal( $id ); + unset( $all_ids[ $key ][ $id ] ); + $deleted_stale = true; + } + } + + if ( $deleted_stale ) { + update_option( 'plausible_analytics_enhanced_measurements_goal_ids', $all_ids ); + } + } + $goals = []; /** * Goals which shouldn't (or can't) be part of the funnel. From 49455e853d24e5a97c6434797357689a44715b34 Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:26:13 +0200 Subject: [PATCH 11/21] Make view-product goal reconciliation safe Move the stale-goal reconciliation into reconcile_view_product_goals(), run it after the goals are (re)created, and guard it so it never deletes good goals: - skip while a multilingual plugin is active but its language list is empty (get_pageview_goal_paths() would fall back to the unlocalized path, and the localized goals would be wrongly pruned); - only prune once every current goal is present, so a failed (re)create can't leave a domain without a view-product goal. --- src/Admin/Provisioning/Integrations.php | 89 +++++++++++++++++-------- 1 file changed, 60 insertions(+), 29 deletions(-) diff --git a/src/Admin/Provisioning/Integrations.php b/src/Admin/Provisioning/Integrations.php index fabbeeba..a82e2de3 100644 --- a/src/Admin/Provisioning/Integrations.php +++ b/src/Admin/Provisioning/Integrations.php @@ -65,35 +65,6 @@ public function create_integration_funnel( $event_goals, $funnel_name, $post_typ ); foreach ( $this->provisioning->get_clients() as $key => $client ) { - /** - * Reconcile the stored view-product goals before (re)creating them. Provisioning is otherwise create-only, - * so an install updated from before this compatibility — which stored a non-localized "Visit /product*" - * goal for every domain — or one whose served paths have since changed, would keep those stale Pageview - * goals alongside the current localized ones. View-product goals are the only Pageview ("Visit ") goals we - * create, so delete every stored one that isn't among the paths this domain currently serves. - */ - if ( ! empty( $event_goals['view-product'] ) ) { - $current_view_product = array_map( - static function ( $path ) { - return sprintf( 'Visit %s', $path ); - }, - $this->get_pageview_goal_paths( $this->get_goal_path( $event_goals['view-product'] ), $key, $post_type ) - ); - $deleted_stale = false; - - foreach ( $all_ids[ $key ] ?? [] as $id => $name ) { - if ( strpos( (string) $name, 'Visit ' ) === 0 && ! in_array( $name, $current_view_product, true ) ) { - $client->delete_goal( $id ); - unset( $all_ids[ $key ][ $id ] ); - $deleted_stale = true; - } - } - - if ( $deleted_stale ) { - update_option( 'plausible_analytics_enhanced_measurements_goal_ids', $all_ids ); - } - } - $goals = []; /** * Goals which shouldn't (or can't) be part of the funnel. @@ -142,7 +113,67 @@ static function ( $path ) { } $all_ids = $this->provisioning->create_funnel( $funnel_name, $goals, $client, $key, $all_ids ); + + $all_ids = $this->reconcile_view_product_goals( $event_goals, $key, $client, $post_type, $all_ids ); + } + } + + /** + * Removes stale localized view-product goals for $key's domain: a non-localized "Visit /product*" left by a + * pre-2.6.2 install, or a goal for a path no longer served. Provisioning is otherwise create-only, so those would + * linger alongside the current localized goals. + * + * This runs only after the current goals have been (re)created, and never when the current paths can't be trusted: + * it deletes nothing while a multilingual plugin is active but its language list is empty (the paths would fall + * back to the unlocalized "/product*" and the localized goals would be wrongly deleted), nor unless every current + * goal is already present (so a failed (re)create can't leave the domain without a view-product goal). View-product + * goals are the only Pageview ("Visit ") goals the plugin creates. + * + * @since 2.6.2 + * + * @param array $event_goals + * @param string $key + * @param Client|WP_Error $client + * @param string $post_type + * @param array $all_ids + * + * @return array The (possibly pruned) goal-ID map. + * + * @codeCoverageIgnore Because it depends on 3rd party plugins. + */ + private function reconcile_view_product_goals( $event_goals, $key, $client, $post_type, $all_ids ) { + if ( empty( $event_goals['view-product'] ) || + ( Helpers::get_multilang_plugin() && empty( Helpers::get_active_languages() ) ) ) { + return $all_ids; + } + + $current_view_product = array_map( + static function ( $path ) { + return sprintf( 'Visit %s', $path ); + }, + $this->get_pageview_goal_paths( $this->get_goal_path( $event_goals['view-product'] ), $key, $post_type ) + ); + + // Only prune once every current goal is present, so a failed (re)create can't leave the domain goalless. + if ( array_diff( $current_view_product, (array) ( $all_ids[ $key ] ?? [] ) ) ) { + return $all_ids; + } + + $deleted_stale = false; + + foreach ( $all_ids[ $key ] ?? [] as $id => $name ) { + if ( strpos( (string) $name, 'Visit ' ) === 0 && ! in_array( $name, $current_view_product, true ) ) { + $client->delete_goal( $id ); + unset( $all_ids[ $key ][ $id ] ); + $deleted_stale = true; + } + } + + if ( $deleted_stale ) { + update_option( 'plausible_analytics_enhanced_measurements_goal_ids', $all_ids ); } + + return $all_ids; } /** From 303f632a3dc99941790e8eab13f886a79a74ab2e Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:08:05 +0200 Subject: [PATCH 12/21] Create the purchase goal in each storeview's default currency When WooCommerce Multilingual & Multicurrency (WCML) pins a default currency per language, provision each per-language dashboard's purchase goal in that storeview's own currency, mirroring how its view-product goals are already localized. Falls back to the store's base currency when WCML is inactive, multi-currency is off, or no default is set for the language. --- src/Admin/Provisioning/Integrations.php | 2 +- src/Helpers.php | 38 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/Admin/Provisioning/Integrations.php b/src/Admin/Provisioning/Integrations.php index a82e2de3..f3ab793b 100644 --- a/src/Admin/Provisioning/Integrations.php +++ b/src/Admin/Provisioning/Integrations.php @@ -79,7 +79,7 @@ public function create_integration_funnel( $event_goals, $funnel_name, $post_typ } if ( $event_key === 'purchase' ) { - $currency = \Plausible\Analytics\WP\Integrations::is_edd_active() ? edd_get_currency() : get_woocommerce_currency(); + $currency = Helpers::get_currency_for_language( $key ); $goals[] = $this->provisioning->create_goal_request( $event_goal, 'Revenue', $currency ); continue; diff --git a/src/Helpers.php b/src/Helpers.php index 99c6c870..1145c341 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -52,6 +52,44 @@ public static function get_active_languages() { return (array) apply_filters( 'plausible_analytics_active_languages', array_values( (array) $languages ) ); } + /** + * Returns the currency a Revenue goal should be created in for a given language-domain. + * + * WooCommerce Multilingual & Multicurrency (WCML) lets a store pin a default currency per language + * ("WooCommerce → Multilingual → Currencies"). When one is set, the matching per-language dashboard's + * purchase goal is created in that storeview's own currency—mirroring how its view-product goals are + * already localized. Without WCML (or when multi-currency is off, or no default is set for this + * language) the store's base currency is used, and Plausible converts foreign-currency purchases into + * it automatically. + * + * @since 2.6.2 + * + * @param string $domain_key 'default' or a WPML language code (e.g. 'es'). + * + * @return string ISO 4217 currency code. + */ + public static function get_currency_for_language( $domain_key = 'default' ) { + $base = Integrations::is_edd_active() ? edd_get_currency() : get_woocommerce_currency(); + + // A default currency per language is a WCML (WooCommerce + WPML) concept only. + if ( Integrations::is_edd_active() || static::get_multilang_plugin() !== static::MULTILANG_PLUGIN_WPML ) { + return $base; + } + + $wcml = get_option( '_wcml_settings', [] ); + + if ( empty( $wcml['enable_multi_currency'] ) || empty( $wcml['default_currencies'] ) || ! is_array( $wcml['default_currencies'] ) ) { + return $base; + } + + // The 'default' dashboard tracks WPML's default language. + $language = 'default' === $domain_key ? (string) apply_filters( 'wpml_default_language', null ) : $domain_key; + $currency = $wcml['default_currencies'][ $language ] ?? false; + + // WCML stores boolean false for languages without a pinned default currency. + return is_string( $currency ) && '' !== $currency ? $currency : $base; + } + /** * Returns the API token. * From ce098792b4df6839184b6626ca1bebb7c5001e4a Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:21:48 +0200 Subject: [PATCH 13/21] Drop the language event property; reword the currency changelog entry The language property added little: per-language dashboards already separate languages, and in directory mode the event's page path does. The currency property stays, so events can be broken down by the currency they were placed in (Plausible doesn't sum custom property values). --- readme.txt | 3 +- src/Admin/Provisioning.php | 1 - src/Admin/Upgrades.php | 8 ++-- src/Helpers.php | 25 ----------- src/Integrations/WooCommerce.php | 13 +----- .../Integrations/WooCommerceTest.php | 41 ------------------- 6 files changed, 7 insertions(+), 84 deletions(-) diff --git a/readme.txt b/readme.txt index 091cacbc..2fe645a3 100644 --- a/readme.txt +++ b/readme.txt @@ -282,8 +282,7 @@ Please make sure you make a backup of your database before updating any version * Added: TranslatePress "different domain per language" (Multiple Domains) compatibility. Each language domain can be mapped to its own Plausible Analytics dashboard, just like WPML. * Added: WPML Multilingual & Multicurrency for WooCommerce (WooCommerce Multilingual) compatibility. * Fixed: the view-product goal now targets the URL each language is served under (e.g. /es/producto*), including a translated product base, so product pageviews are no longer missed in non-default languages. Works for WooCommerce and Easy Digital Downloads, on WPML and TranslatePress. -* Improved: the WooCommerce add-to-cart, remove-from-cart, checkout and purchase events now include a currency property, so multicurrency amounts aren't summed as if they were one currency. The purchase event uses the currency the order was placed in. -* Improved: on multilingual sites those same events also include a language property, since each language's translated product is a separate post with its own ID and name. +* Improved: the WooCommerce add-to-cart, remove-from-cart, checkout and purchase events now include a currency property, so they can be broken down by the currency they were placed in. The purchase event uses the currency the order was placed in. * Fixed: in "domain per language" mode, the proxy endpoint and the locally cached tracker script were loaded from the default domain, which could cause CORS errors on the other language domains. * Fixed: Plugin Tokens for language domains whose key contains an underscore (e.g., TranslatePress' nl_NL) were stored under a stripped key, which meant they were never read back. * Tested with WP 7.1. diff --git a/src/Admin/Provisioning.php b/src/Admin/Provisioning.php index e2f8967b..412dfd39 100644 --- a/src/Admin/Provisioning.php +++ b/src/Admin/Provisioning.php @@ -24,7 +24,6 @@ class Provisioning { 'cart_total_items', 'currency', 'id', - 'language', 'name', 'price', 'product_id', diff --git a/src/Admin/Upgrades.php b/src/Admin/Upgrades.php index 176a1e59..59d556c6 100644 --- a/src/Admin/Upgrades.php +++ b/src/Admin/Upgrades.php @@ -433,7 +433,7 @@ private function upgrade_to_260() { /** * After updating to 2.6.2, (re)create the Custom Properties and — for multilingual ecommerce installs — the - * integration goals, so existing installs pick up the currency/language Custom Properties and the per-language + * integration goals, so existing installs pick up the currency Custom Property and the per-language * Pageview goals (e.g. /es/producto*) without having to save their settings first. * * The Custom Properties apply to every WooCommerce/EDD install with Ecommerce Revenue enabled. The localized @@ -459,9 +459,9 @@ public function upgrade_to_262() { $settings = Helpers::get_settings(); /** - * The currency and language Custom Properties (@see Provisioning::CUSTOM_PROPERTIES) apply to every - * ecommerce install, so (re)create them whether or not a multilingual plugin is active. Bails when no - * Plugin Token is entered yet; the properties are created as soon as one is. + * The currency Custom Property (@see Provisioning::CUSTOM_PROPERTIES) applies to every ecommerce install, + * so (re)create the Custom Properties whether or not a multilingual plugin is active. Bails when no Plugin + * Token is entered yet; the properties are created as soon as one is. * * @see Provisioning::maybe_provision_on_connect() Creates the goals and custom properties as soon as a * Plugin Token is entered. diff --git a/src/Helpers.php b/src/Helpers.php index 1145c341..ad08843a 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -293,31 +293,6 @@ protected static function get_translatepress_current_language() { return ! empty( $TRP_LANGUAGE ) ? $TRP_LANGUAGE : null; } - /** - * Returns the language code that's currently being served. - * - * @since v2.6.2 - * - * @return string Empty when no supported multilingual plugin is active. - * - * @codeCoverageIgnore Because it depends on 3rd party plugins. - */ - public static function get_current_language() { - $language = ''; - - switch ( static::get_multilang_plugin() ) { - case static::MULTILANG_PLUGIN_WPML: - $language = apply_filters( 'wpml_current_language', null ); - break; - - case static::MULTILANG_PLUGIN_TRANSLATEPRESS: - $language = static::get_translatepress_current_language(); - break; - } - - return (string) apply_filters( 'plausible_analytics_current_language', (string) $language ); - } - /** * Returns the name of the current Plausible domain. * diff --git a/src/Integrations/WooCommerce.php b/src/Integrations/WooCommerce.php index 8da9f5b1..4297ceb3 100644 --- a/src/Integrations/WooCommerce.php +++ b/src/Integrations/WooCommerce.php @@ -11,7 +11,6 @@ use Plausible\Analytics\WP\Admin\Provisioning; use Plausible\Analytics\WP\EnhancedMeasurements; -use Plausible\Analytics\WP\Helpers; use Plausible\Analytics\WP\Integrations; use Plausible\Analytics\WP\Proxy; use WC_Cart; @@ -219,11 +218,8 @@ protected function get_wc_cart() { * Adds the properties that apply to all events to $props. * * The currency is added because multicurrency plugins e.g., WPML Multilingual & Multicurrency for WooCommerce, - * convert all amounts to the currency the visitor is shopping in. Without it, Plausible Analytics would sum up - * amounts in different currencies as if they were the same. - * - * The language is only added on multilingual sites because translated products are separate posts, i.e. they have - * their own product ID and name. + * convert all amounts to the currency the visitor is shopping in. It allows breaking down events (e.g. their price + * and cart total) by the currency they were placed in. * * @since 2.6.2 * @@ -236,11 +232,6 @@ protected function get_wc_cart() { */ private function add_common_props( $props, $currency = '' ) { $props['currency'] = $currency ?: get_woocommerce_currency(); - $language = Helpers::get_current_language(); - - if ( ! empty( $language ) ) { - $props['language'] = $language; - } return $props; } diff --git a/tests/integration/Integrations/WooCommerceTest.php b/tests/integration/Integrations/WooCommerceTest.php index e982e9ae..00604fda 100644 --- a/tests/integration/Integrations/WooCommerceTest.php +++ b/tests/integration/Integrations/WooCommerceTest.php @@ -74,45 +74,4 @@ public function testTrackPurchase() { $class->track_purchase( 1 ); } - - /** - * On multilingual sites, the language the visitor is shopping in should be added to the event's properties, because - * translated products are separate posts, i.e., each language has its own product ID and product name. - * - * @see WooCommerce::track_purchase() - * @return void - */ - public function testTrackPurchaseAddsLanguage() { - when( 'wc_get_permalink_structure' )->justReturn( [ 'product_base' => 'product' ] ); - - $language = function () { - return 'nl'; - }; - - add_filter( 'plausible_analytics_current_language', $language ); - - try { - $class = new WooCommerce( false ); - $mock = $this->getMockBuilder( 'WC_Order' )->setMethods( - [ - 'get_meta', - 'get_total', - 'get_currency', - 'add_meta_data', - 'save', - ] - )->getMock(); - $mock->method( 'get_meta' )->willReturn( false ); - $mock->method( 'get_total' )->willReturn( 10 ); - $mock->method( 'get_currency' )->willReturn( 'EUR' ); - - when( 'wc_get_order' )->justReturn( $mock ); - - $this->expectOutputContains( '"props":{"currency":"EUR","language":"nl"}' ); - - $class->track_purchase( 1 ); - } finally { - remove_filter( 'plausible_analytics_current_language', $language ); - } - } } From cfca0ae8660df32caa1c355f198f8256070865ce Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:29:20 +0200 Subject: [PATCH 14/21] Keep an existing purchase goal's currency when (re)provisioning A Revenue goal's name is unique per site and its currency can't be changed. When a dashboard already had the purchase goal in another currency (e.g. the store's base currency, created before 2.6.2), asking for the storeview's default currency was rejected with a 422, which aborted the funnel (and, on a settings save, the whole AJAX request). Deleting and recreating the goal isn't an option: the existing funnel loses its purchase step, and funnelGetOrCreate returns funnels by name unchanged, so it can't be repaired through the API. So look the goal up first (Client::get_goals(), decoding the raw response since the generated models drop a Revenue goal's currency) and keep its currency; the per-language default only applies to dashboards that don't have the goal yet. Also ignore WCML's per-language defaults in its "by location" currency mode. --- src/Admin/Provisioning/Integrations.php | 32 +++++++++++++++- src/Client.php | 50 ++++++++++++++++++++++++- src/Helpers.php | 5 +++ 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/Admin/Provisioning/Integrations.php b/src/Admin/Provisioning/Integrations.php index f3ab793b..631cfb21 100644 --- a/src/Admin/Provisioning/Integrations.php +++ b/src/Admin/Provisioning/Integrations.php @@ -79,7 +79,7 @@ public function create_integration_funnel( $event_goals, $funnel_name, $post_typ } if ( $event_key === 'purchase' ) { - $currency = Helpers::get_currency_for_language( $key ); + $currency = $this->get_purchase_goal_currency( $event_goal, $key, $client ); $goals[] = $this->provisioning->create_goal_request( $event_goal, 'Revenue', $currency ); continue; @@ -118,6 +118,36 @@ public function create_integration_funnel( $event_goals, $funnel_name, $post_typ } } + /** + * Returns the currency to create $key's purchase (Revenue) goal in. + * + * A Revenue goal's name is unique per site and its currency can't be changed. So, when $key's dashboard already + * has this goal (e.g. created in the store's base currency before 2.6.2), its currency is kept: requesting another + * currency is rejected (422), which would abort the funnel. Deleting and recreating the goal isn't an option either: + * the existing funnel would lose its purchase step, and funnels can't be updated through the API. + * + * @since 2.6.2 + * + * @param string $event_goal The purchase goal's name. + * @param string $key The Language Domain the goal is created for. + * @param Client $client + * + * @return string ISO 4217 currency code. + * + * @codeCoverageIgnore We don't want to test the API. + */ + private function get_purchase_goal_currency( $event_goal, $key, $client ) { + foreach ( (array) $client->get_goals() as $goal ) { + if ( ( $goal['goal_type'] ?? '' ) === 'Goal.Revenue' && + ( $goal['goal']['event_name'] ?? '' ) === $event_goal && + ! empty( $goal['goal']['currency'] ) ) { + return $goal['goal']['currency']; + } + } + + return Helpers::get_currency_for_language( $key ); + } + /** * Removes stale localized view-product goals for $key's domain: a non-localized "Visit /product*" left by a * pre-2.6.2 install, or a goal for a path no longer served. Provisioning is otherwise create-only, so those would diff --git a/src/Client.php b/src/Client.php index fef556e8..766ceb22 100644 --- a/src/Client.php +++ b/src/Client.php @@ -30,6 +30,11 @@ class Client { */ private $api_instance; + /** + * @var GuzzleClient $http_client + */ + private $http_client; + /** * @var string $domain_key */ @@ -49,7 +54,8 @@ public function __construct( $token = '', $domain_key = 'default' ) { $config->setUsername( 'WordPress' ) ->setPassword( $token ) ->setHost( Helpers::get_hosted_domain_url() ); - $this->api_instance = new DefaultApi( new GuzzleClient( [ 'timeout' => $timeout, 'connect_timeout' => $connect_timeout ] ), $config ); + $this->http_client = new GuzzleClient( [ 'timeout' => $timeout, 'connect_timeout' => $connect_timeout ] ); + $this->api_instance = new DefaultApi( $this->http_client, $config ); } /** @@ -222,6 +228,48 @@ public function create_goals( $goals ) { } } + /** + * Retrieves all Goals of this Client's site. + * + * The response is decoded as-is, because the generated models deserialize every goal as a Pageview goal, dropping + * e.g. a Revenue goal's event_name and currency. + * + * @since 2.6.2 + * + * @return array|false Each goal as ['goal_type' => 'Goal.Revenue', 'goal' => ['id' => 1, 'currency' => 'EUR', ...]], + * or false when the Goals couldn't be retrieved. + * + * @codeCoverageIgnore + */ + public function get_goals() { + $goals = []; + $after = null; + + try { + do { + $request = $this->api_instance->plausibleWebPluginsAPIControllersGoalsIndexRequest( 100, $after ); + $response = json_decode( (string) $this->http_client->send( $request )->getBody(), true ); + + if ( ! isset( $response['goals'] ) || ! is_array( $response['goals'] ) ) { + return false; + } + + $goals = array_merge( $goals, $response['goals'] ); + $after = null; + + if ( ! empty( $response['meta']['pagination']['has_next_page'] ) ) { + wp_parse_str( (string) wp_parse_url( $response['meta']['pagination']['links']['next']['url'] ?? '', PHP_URL_QUERY ), $query ); + + $after = $query['after'] ?? null; + } + } while ( $after ); + } catch ( \Throwable $e ) { + return false; + } + + return $goals; + } + /** * Create Shared Link in Plausible Dashboard. * diff --git a/src/Helpers.php b/src/Helpers.php index ad08843a..4b829fc8 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -82,6 +82,11 @@ public static function get_currency_for_language( $domain_key = 'default' ) { return $base; } + // In "by location" mode the currency follows the visitor's country, so the per-language defaults don't apply. + if ( ( $wcml['currency_mode'] ?? '' ) === 'by_location' ) { + return $base; + } + // The 'default' dashboard tracks WPML's default language. $language = 'default' === $domain_key ? (string) apply_filters( 'wpml_default_language', null ) : $domain_key; $currency = $wcml['default_currencies'][ $language ] ?? false; From 01c14a95e39ba633179c10a87f679f3ed19e42f3 Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:44:41 +0200 Subject: [PATCH 15/21] Skip a domain's funnel when its existing goals can't be read A failed goal lookup was treated as an empty goal list, falling back to the per-language currency, which could still clash with an existing purchase goal. Now the domain is skipped and provisioned on the next settings save. --- src/Admin/Provisioning/Integrations.php | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/Admin/Provisioning/Integrations.php b/src/Admin/Provisioning/Integrations.php index 631cfb21..d2a0bb22 100644 --- a/src/Admin/Provisioning/Integrations.php +++ b/src/Admin/Provisioning/Integrations.php @@ -65,6 +65,14 @@ public function create_integration_funnel( $event_goals, $funnel_name, $post_typ ); foreach ( $this->provisioning->get_clients() as $key => $client ) { + $currency = ! empty( $event_goals['purchase'] ) ? $this->get_purchase_goal_currency( $event_goals['purchase'], $key, $client ) : ''; + + // The existing goals couldn't be read, so the purchase goal's currency is unknown. Skip this domain rather + // than risk a rejected funnel; it's provisioned on the next settings save. + if ( $currency === null ) { + continue; + } + $goals = []; /** * Goals which shouldn't (or can't) be part of the funnel. @@ -79,8 +87,7 @@ public function create_integration_funnel( $event_goals, $funnel_name, $post_typ } if ( $event_key === 'purchase' ) { - $currency = $this->get_purchase_goal_currency( $event_goal, $key, $client ); - $goals[] = $this->provisioning->create_goal_request( $event_goal, 'Revenue', $currency ); + $goals[] = $this->provisioning->create_goal_request( $event_goal, 'Revenue', $currency ); continue; } @@ -132,12 +139,18 @@ public function create_integration_funnel( $event_goals, $funnel_name, $post_typ * @param string $key The Language Domain the goal is created for. * @param Client $client * - * @return string ISO 4217 currency code. + * @return string|null ISO 4217 currency code, or null when the existing goals couldn't be retrieved. * * @codeCoverageIgnore We don't want to test the API. */ private function get_purchase_goal_currency( $event_goal, $key, $client ) { - foreach ( (array) $client->get_goals() as $goal ) { + $goals = $client->get_goals(); + + if ( $goals === false ) { + return null; + } + + foreach ( $goals as $goal ) { if ( ( $goal['goal_type'] ?? '' ) === 'Goal.Revenue' && ( $goal['goal']['event_name'] ?? '' ) === $event_goal && ! empty( $goal['goal']['currency'] ) ) { From 638bac9c0583bb8310f3fc948b85e562e9060e37 Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:23:18 +0200 Subject: [PATCH 16/21] Harden multilingual provisioning edge cases and tidy up - get_currency_for_language(): only accept a valid ISO 4217 code. WCML stores a language's "Keep" default currency as false, 0 or '0'; the string '0' was passed on as the goal's currency, which the API rejects. - get_active_languages(): leave out WPML's hidden and TranslatePress' unpublished languages. WPML only lists hidden languages to users who opted to see them, so the reconciliation could delete (and a later admin save recreate) their goals depending on whose request ran it. - Compute the view-product paths once per domain and pass them to the reconciliation, and retrieve each domain's goals once per request, shared by the WooCommerce and EDD funnels. - Remove add_localized_event_goals(): every stored "Visit " goal is already deleted when the integration has a view-product goal. - Reuse Helpers::get_home_relative_path() (and the new get_home_path()) in localize_goal_path() instead of duplicating it. - Add tests for the above. --- src/Admin/Provisioning/Integrations.php | 105 ++++++-------- src/Helpers.php | 34 +++-- .../Admin/Provisioning/IntegrationsTest.php | 29 ++++ tests/integration/HelpersTest.php | 137 ++++++++++++++++++ 4 files changed, 237 insertions(+), 68 deletions(-) diff --git a/src/Admin/Provisioning/Integrations.php b/src/Admin/Provisioning/Integrations.php index d2a0bb22..5a7407fc 100644 --- a/src/Admin/Provisioning/Integrations.php +++ b/src/Admin/Provisioning/Integrations.php @@ -9,6 +9,7 @@ namespace Plausible\Analytics\WP\Admin\Provisioning; use Plausible\Analytics\WP\Admin\Provisioning; +use Plausible\Analytics\WP\Client; use Plausible\Analytics\WP\Helpers; class Integrations { @@ -17,6 +18,12 @@ class Integrations { */ private $provisioning; + /** + * @var array The goals of each Language Domain's site, keyed by domain key. Retrieved once per request and shared by + * the WooCommerce and EDD funnels. @see self::get_existing_goals() + */ + private $existing_goals = []; + /** * Build class. * @@ -77,7 +84,8 @@ public function create_integration_funnel( $event_goals, $funnel_name, $post_typ /** * Goals which shouldn't (or can't) be part of the funnel. */ - $extra_goals = []; + $extra_goals = []; + $view_product_paths = []; foreach ( $event_goals as $event_key => $event_goal ) { if ( $event_key === 'remove-from-cart' ) { @@ -93,15 +101,15 @@ public function create_integration_funnel( $event_goals, $funnel_name, $post_typ } if ( $event_key === 'view-product' ) { - $paths = $this->get_pageview_goal_paths( $this->get_goal_path( $event_goal ), $key, $post_type ); + $view_product_paths = $this->get_pageview_goal_paths( $this->get_goal_path( $event_goal ), $key, $post_type ); /** * A funnel step holds one goal, so the default language's path is the one that ends up in the * funnel. The other languages get a goal of their own. */ - $goals[] = $this->provisioning->create_goal_request( $event_goal, 'Pageview', null, array_shift( $paths ) ); + $goals[] = $this->provisioning->create_goal_request( $event_goal, 'Pageview', null, $view_product_paths[0] ); - foreach ( $paths as $path ) { + foreach ( array_slice( $view_product_paths, 1 ) as $path ) { $extra_goals[] = $this->provisioning->create_goal_request( $event_goal, 'Pageview', null, $path ); } @@ -121,7 +129,7 @@ public function create_integration_funnel( $event_goals, $funnel_name, $post_typ $all_ids = $this->provisioning->create_funnel( $funnel_name, $goals, $client, $key, $all_ids ); - $all_ids = $this->reconcile_view_product_goals( $event_goals, $key, $client, $post_type, $all_ids ); + $all_ids = $this->reconcile_view_product_goals( $view_product_paths, $key, $client, $all_ids ); } } @@ -144,7 +152,7 @@ public function create_integration_funnel( $event_goals, $funnel_name, $post_typ * @codeCoverageIgnore We don't want to test the API. */ private function get_purchase_goal_currency( $event_goal, $key, $client ) { - $goals = $client->get_goals(); + $goals = $this->get_existing_goals( $key, $client ); if ( $goals === false ) { return null; @@ -161,6 +169,26 @@ private function get_purchase_goal_currency( $event_goal, $key, $client ) { return Helpers::get_currency_for_language( $key ); } + /** + * Returns the goals of $key's site, retrieving them only once per request. + * + * @since 2.6.2 + * + * @param string $key + * @param Client $client + * + * @return array|false @see Client::get_goals() + * + * @codeCoverageIgnore We don't want to test the API. + */ + private function get_existing_goals( $key, $client ) { + if ( ! isset( $this->existing_goals[ $key ] ) ) { + $this->existing_goals[ $key ] = $client->get_goals(); + } + + return $this->existing_goals[ $key ]; + } + /** * Removes stale localized view-product goals for $key's domain: a non-localized "Visit /product*" left by a * pre-2.6.2 install, or a goal for a path no longer served. Provisioning is otherwise create-only, so those would @@ -174,18 +202,18 @@ private function get_purchase_goal_currency( $event_goal, $key, $client ) { * * @since 2.6.2 * - * @param array $event_goals - * @param string $key - * @param Client|WP_Error $client - * @param string $post_type - * @param array $all_ids + * @param array $view_product_paths The current view-product goal paths, @see self::get_pageview_goal_paths(). + * Empty when the integration has no view-product goal. + * @param string $key + * @param Client $client + * @param array $all_ids * * @return array The (possibly pruned) goal-ID map. * * @codeCoverageIgnore Because it depends on 3rd party plugins. */ - private function reconcile_view_product_goals( $event_goals, $key, $client, $post_type, $all_ids ) { - if ( empty( $event_goals['view-product'] ) || + private function reconcile_view_product_goals( $view_product_paths, $key, $client, $all_ids ) { + if ( empty( $view_product_paths ) || ( Helpers::get_multilang_plugin() && empty( Helpers::get_active_languages() ) ) ) { return $all_ids; } @@ -194,7 +222,7 @@ private function reconcile_view_product_goals( $event_goals, $key, $client, $pos static function ( $path ) { return sprintf( 'Visit %s', $path ); }, - $this->get_pageview_goal_paths( $this->get_goal_path( $event_goals['view-product'] ), $key, $post_type ) + $view_product_paths ); // Only prune once every current goal is present, so a failed (re)create can't leave the domain goalless. @@ -292,17 +320,10 @@ private function get_pageview_goal_paths( $path, $domain_key, $post_type ) { * @codeCoverageIgnore Because it depends on 3rd party plugins. */ private function localize_goal_path( $path, $language_code, $post_type ) { - $home_path = trim( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ), '/' ); - $relative = ltrim( $path, '/' ); - + $relative = Helpers::get_home_relative_path( $path ); // On multisite subdirectory installs the site's path precedes the language prefix. - if ( $home_path !== '' && strpos( $relative, "$home_path/" ) === 0 ) { - $relative = substr( $relative, strlen( $home_path ) + 1 ); - } else { - $home_path = ''; - } - - $suffix = ''; + $home_path = $relative !== trim( $path, '/' ) ? Helpers::get_home_path() : ''; + $suffix = ''; if ( substr( $relative, -1 ) === '*' ) { $suffix = '*'; @@ -343,13 +364,12 @@ public function delete_integration_goals( $integration ) { $delete_view_product = ! empty( $integration->event_goals['view-product'] ); foreach ( $this->provisioning->get_clients() as $domain_key => $client ) { - $goals = $all_ids[ $domain_key ] ?? []; - $event_goals = $this->add_localized_event_goals( (array) $integration->event_goals, $domain_key, $integration->post_type ?? '' ); + $goals = $all_ids[ $domain_key ] ?? []; foreach ( $goals as $id => $name ) { $is_view_product = $delete_view_product && strpos( (string) $name, 'Visit ' ) === 0; - if ( $this->provisioning->array_search_contains( $name, $event_goals ) || $is_view_product ) { + if ( $is_view_product || $this->provisioning->array_search_contains( $name, $integration->event_goals ) ) { $client->delete_goal( $id ); unset( $goals[ $id ] ); } @@ -364,35 +384,4 @@ public function delete_integration_goals( $integration ) { update_option( 'plausible_analytics_enhanced_measurements_goal_ids', $all_ids ); } - - /** - * Adds the view-product goal for every language served on $domain_key's domain to $event_goals, so the Pageview - * goals created for those languages are recognized (and deleted) too. - * - * The names are built in the display-name format Plausible assigns to Pageview goals ("Visit "), not from - * the WP-translated event-goal template, so matching works regardless of the admin's language. - * - * @since 2.6.2 - * - * @param array $event_goals - * @param string $domain_key - * @param string $post_type - * - * @return array - * - * @codeCoverageIgnore Because it depends on 3rd party plugins. - */ - private function add_localized_event_goals( $event_goals, $domain_key, $post_type ) { - if ( empty( $event_goals['view-product'] ) ) { - return $event_goals; - } - - $path = $this->get_goal_path( $event_goals['view-product'] ); - - foreach ( $this->get_pageview_goal_paths( $path, $domain_key, $post_type ) as $i => $localized_path ) { - $event_goals[ "view-product-$i" ] = sprintf( 'Visit %s', $localized_path ); - } - - return $event_goals; - } } diff --git a/src/Helpers.php b/src/Helpers.php index 4b829fc8..847a8477 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -23,7 +23,11 @@ class Helpers { const MULTILANG_PLUGIN_TRANSLATEPRESS = 'translatepress'; /** - * Returns the language codes of all languages served by the active multilingual plugin. + * Returns the language codes of all languages the active multilingual plugin serves to the public. + * + * Languages that aren't public yet (WPML's hidden languages, TranslatePress' unpublished languages) are left out, + * regardless of who's making the request: WPML only lists hidden languages to users who opted to see them, which + * would make the result depend on whether provisioning runs in an admin's or a visitor's request. * * @since v2.6.2 * @@ -39,13 +43,14 @@ public static function get_active_languages() { $active = apply_filters( 'wpml_active_languages', null, [ 'skip_missing' => 0 ] ); if ( is_array( $active ) ) { - $languages = array_keys( $active ); + $hidden = (array) apply_filters( 'wpml_setting', [], 'hidden_languages' ); + $languages = array_diff( array_keys( $active ), $hidden ); } break; case static::MULTILANG_PLUGIN_TRANSLATEPRESS: $settings = get_option( 'trp_settings', [] ); - $languages = $settings['translation-languages'] ?? []; + $languages = $settings['publish-languages'] ?? []; break; } @@ -91,8 +96,8 @@ public static function get_currency_for_language( $domain_key = 'default' ) { $language = 'default' === $domain_key ? (string) apply_filters( 'wpml_default_language', null ) : $domain_key; $currency = $wcml['default_currencies'][ $language ] ?? false; - // WCML stores boolean false for languages without a pinned default currency. - return is_string( $currency ) && '' !== $currency ? $currency : $base; + // Languages without a pinned default currency ("Keep") are stored as false, 0 or '0'. + return is_string( $currency ) && preg_match( '/^[A-Z]{3}$/', $currency ) ? $currency : $base; } /** @@ -687,20 +692,29 @@ public static function get_language_url_prefix( $language_code ) { return (string) apply_filters( 'plausible_analytics_language_url_prefix', trim( (string) $prefix, '/' ), $language_code ); } + /** + * Returns the home URL's path without leading/trailing slashes, e.g. 'site' for https://example.com/site/. + * + * @since v2.6.2 + * + * @return string Empty when the site is served from the domain's root. + */ + public static function get_home_path() { + return trim( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ), '/' ); + } + /** * Returns $url's path, relative to the home URL's path, without leading/trailing slashes. * * @since v2.6.2 * - * @param string $url + * @param string $url A URL or a path. * * @return string - * - * @codeCoverageIgnore Because it depends on 3rd party plugins. */ - protected static function get_home_relative_path( $url ) { + public static function get_home_relative_path( $url ) { $path = trim( (string) wp_parse_url( $url, PHP_URL_PATH ), '/' ); - $home = trim( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ), '/' ); + $home = static::get_home_path(); if ( $home !== '' && ( $path === $home || strpos( $path, "$home/" ) === 0 ) ) { $path = trim( substr( $path, strlen( $home ) ), '/' ); diff --git a/tests/integration/Admin/Provisioning/IntegrationsTest.php b/tests/integration/Admin/Provisioning/IntegrationsTest.php index b5b8bac3..fbbb6291 100644 --- a/tests/integration/Admin/Provisioning/IntegrationsTest.php +++ b/tests/integration/Admin/Provisioning/IntegrationsTest.php @@ -103,6 +103,35 @@ function ( $paths ) { } } + /** + * On (multisite) subdirectory installs the site's path should precede the language prefix. + * + * @see Integrations::get_pageview_goal_paths() + * @see Integrations::localize_goal_path() + * @return void + * @throws \ReflectionException + */ + public function testGetPageviewGoalPathsInSubdirectory() { + $home_url = function ( $url, $path ) { + return 'https://example.com/site/' . ltrim( $path, '/' ); + }; + + add_filter( 'home_url', $home_url, 10, 2 ); + + try { + $this->withLanguages( + function ( $paths ) { + $this->assertEquals( + [ '/site/product*', '/site/es/producto*', '/site/nl/product*' ], + $paths( '/site/product*', 'default' ) + ); + } + ); + } finally { + remove_filter( 'home_url', $home_url ); + } + } + /** * Without a multilingual plugin, the path should be left alone. * diff --git a/tests/integration/HelpersTest.php b/tests/integration/HelpersTest.php index 62550e27..10de9191 100644 --- a/tests/integration/HelpersTest.php +++ b/tests/integration/HelpersTest.php @@ -9,6 +9,7 @@ use Plausible\Analytics\Tests\TestableHelpers; use Plausible\Analytics\Tests\TestCase; use Plausible\Analytics\WP\Helpers; +use function Brain\Monkey\Functions\when; class HelpersTest extends TestCase { /** @@ -400,4 +401,140 @@ public function testGetTranslatePressLanguageDomainsEmpty() { $this->assertEquals( [], $method->invoke( null ) ); } + + /** + * WPML's hidden languages should be left out, whether or not WPML lists them for the current user. + * + * @see Helpers::get_active_languages() + * @return void + */ + public function testGetActiveLanguagesWpmlSkipsHiddenLanguages() { + $plugin = function () { + return Helpers::MULTILANG_PLUGIN_WPML; + }; + $active = function () { + return [ 'en' => [], 'es' => [], 'de' => [] ]; + }; + $hidden = function ( $value, $setting ) { + return $setting === 'hidden_languages' ? [ 'de' ] : $value; + }; + + add_filter( 'plausible_analytics_multilang_plugin', $plugin ); + add_filter( 'wpml_active_languages', $active ); + add_filter( 'wpml_setting', $hidden, 10, 2 ); + + try { + $this->assertEquals( [ 'en', 'es' ], Helpers::get_active_languages() ); + } finally { + remove_filter( 'plausible_analytics_multilang_plugin', $plugin ); + remove_filter( 'wpml_active_languages', $active ); + remove_filter( 'wpml_setting', $hidden ); + } + } + + /** + * TranslatePress' unpublished languages should be left out. + * + * @see Helpers::get_active_languages() + * @return void + */ + public function testGetActiveLanguagesTranslatePressSkipsUnpublishedLanguages() { + $plugin = function () { + return Helpers::MULTILANG_PLUGIN_TRANSLATEPRESS; + }; + + add_filter( 'plausible_analytics_multilang_plugin', $plugin ); + update_option( + 'trp_settings', + [ + 'translation-languages' => [ 'en_US', 'es_ES', 'nl_NL' ], + 'publish-languages' => [ 'en_US', 'es_ES' ], + ] + ); + + try { + $this->assertEquals( [ 'en_US', 'es_ES' ], Helpers::get_active_languages() ); + } finally { + remove_filter( 'plausible_analytics_multilang_plugin', $plugin ); + delete_option( 'trp_settings' ); + } + } + + /** + * A language's pinned WCML default currency should be used; anything else ("Keep", stored as false, 0 or '0') + * should fall back to the store's base currency. + * + * @see Helpers::get_currency_for_language() + * @return void + */ + public function testGetCurrencyForLanguage() { + when( 'get_woocommerce_currency' )->justReturn( 'USD' ); + + $plugin = function () { + return Helpers::MULTILANG_PLUGIN_WPML; + }; + $default = function () { + return 'en'; + }; + + add_filter( 'plausible_analytics_integrations_edd', '__return_false' ); + add_filter( 'plausible_analytics_multilang_plugin', $plugin ); + add_filter( 'wpml_default_language', $default ); + update_option( + '_wcml_settings', + [ + 'enable_multi_currency' => 2, + 'default_currencies' => [ 'en' => 'GBP', 'es' => 'EUR', 'nl' => '0', 'de' => false, 'fr' => 0 ], + ] + ); + + try { + $this->assertEquals( 'GBP', Helpers::get_currency_for_language() ); + $this->assertEquals( 'EUR', Helpers::get_currency_for_language( 'es' ) ); + $this->assertEquals( 'USD', Helpers::get_currency_for_language( 'nl' ) ); + $this->assertEquals( 'USD', Helpers::get_currency_for_language( 'de' ) ); + $this->assertEquals( 'USD', Helpers::get_currency_for_language( 'fr' ) ); + $this->assertEquals( 'USD', Helpers::get_currency_for_language( 'it' ) ); + + // The per-language defaults don't apply when the currency follows the visitor's location. + update_option( '_wcml_settings', array_merge( get_option( '_wcml_settings' ), [ 'currency_mode' => 'by_location' ] ) ); + + $this->assertEquals( 'USD', Helpers::get_currency_for_language( 'es' ) ); + + // A default currency per language is a WCML (i.e. WPML) concept only. + remove_filter( 'plausible_analytics_multilang_plugin', $plugin ); + update_option( '_wcml_settings', array_merge( get_option( '_wcml_settings' ), [ 'currency_mode' => 'by_language' ] ) ); + + $this->assertEquals( 'USD', Helpers::get_currency_for_language( 'es' ) ); + } finally { + remove_filter( 'plausible_analytics_integrations_edd', '__return_false' ); + remove_filter( 'plausible_analytics_multilang_plugin', $plugin ); + remove_filter( 'wpml_default_language', $default ); + delete_option( '_wcml_settings' ); + } + } + + /** + * @see Helpers::get_home_relative_path() + * @return void + */ + public function testGetHomeRelativePath() { + $home_url = function ( $url, $path ) { + return 'https://example.com/site/' . ltrim( $path, '/' ); + }; + + add_filter( 'home_url', $home_url, 10, 2 ); + + try { + $this->assertEquals( 'site', Helpers::get_home_path() ); + $this->assertEquals( 'product*', Helpers::get_home_relative_path( '/site/product*' ) ); + $this->assertEquals( 'es', Helpers::get_home_relative_path( 'https://example.com/site/es/' ) ); + $this->assertEquals( '', Helpers::get_home_relative_path( 'https://example.com/site/' ) ); + // A path outside the site's path is returned as-is. + $this->assertEquals( 'product*', Helpers::get_home_relative_path( '/product*' ) ); + $this->assertEquals( 'sites/product*', Helpers::get_home_relative_path( '/sites/product*' ) ); + } finally { + remove_filter( 'home_url', $home_url ); + } + } } From c39a2e55aa09b84c722a9cfb725451c07e433230 Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:50:55 +0200 Subject: [PATCH 17/21] Recreate funnels whose view-product step targets an outdated path Sites that had a purchase funnel before 2.6.2 keep a first step like "Visit /product*", also on a language domain that serves its products under /producto/. Funnels are sequential, so such a funnel never gets past its first step, and since creating a funnel returns an existing one of the same name unchanged, the localized goal was never created there. The API can't update or delete a funnel, but Plausible removes a funnel once fewer than two of its steps remain. So when the first step targets another path than the current one, delete every step's goal except the purchase goal (whose currency can't be changed); the funnel and its goals are then recreated with the current steps. Retrieving funnels shares its pagination with get_goals(). --- readme.txt | 2 +- src/Admin/Provisioning/Integrations.php | 60 +++++++++++++++++++++++++ src/Client.php | 46 +++++++++++++++---- 3 files changed, 99 insertions(+), 9 deletions(-) diff --git a/readme.txt b/readme.txt index 2fe645a3..dce0d621 100644 --- a/readme.txt +++ b/readme.txt @@ -281,7 +281,7 @@ Please make sure you make a backup of your database before updating any version = 2.6.2 = * Added: TranslatePress "different domain per language" (Multiple Domains) compatibility. Each language domain can be mapped to its own Plausible Analytics dashboard, just like WPML. * Added: WPML Multilingual & Multicurrency for WooCommerce (WooCommerce Multilingual) compatibility. -* Fixed: the view-product goal now targets the URL each language is served under (e.g. /es/producto*), including a translated product base, so product pageviews are no longer missed in non-default languages. Works for WooCommerce and Easy Digital Downloads, on WPML and TranslatePress. +* Fixed: the view-product goal now targets the URL each language is served under (e.g. /es/producto*), including a translated product base, so product pageviews are no longer missed in non-default languages. Existing purchase funnels whose first step targets a path that's no longer served are recreated. Works for WooCommerce and Easy Digital Downloads, on WPML and TranslatePress. * Improved: the WooCommerce add-to-cart, remove-from-cart, checkout and purchase events now include a currency property, so they can be broken down by the currency they were placed in. The purchase event uses the currency the order was placed in. * Fixed: in "domain per language" mode, the proxy endpoint and the locally cached tracker script were loaded from the default domain, which could cause CORS errors on the other language domains. * Fixed: Plugin Tokens for language domains whose key contains an underscore (e.g., TranslatePress' nl_NL) were stored under a stripped key, which meant they were never read back. diff --git a/src/Admin/Provisioning/Integrations.php b/src/Admin/Provisioning/Integrations.php index 5a7407fc..41d920f6 100644 --- a/src/Admin/Provisioning/Integrations.php +++ b/src/Admin/Provisioning/Integrations.php @@ -127,12 +127,72 @@ public function create_integration_funnel( $event_goals, $funnel_name, $post_typ update_option( 'plausible_analytics_enhanced_measurements_goal_ids', $all_ids ); } + if ( ! empty( $view_product_paths ) ) { + $all_ids = $this->maybe_dismantle_outdated_funnel( $funnel_name, $view_product_paths[0], $key, $client, $all_ids ); + } + $all_ids = $this->provisioning->create_funnel( $funnel_name, $goals, $client, $key, $all_ids ); $all_ids = $this->reconcile_view_product_goals( $view_product_paths, $key, $client, $all_ids ); } } + /** + * Makes Plausible remove $key's funnel when its view-product step targets a path that's no longer served there, e.g. + * "Visit /product*" on a domain that serves its products under /producto/, so it's recreated with the current steps. + * + * Funnels are sequential, so such a funnel never gets past its first step. The API can't update or delete a funnel, + * and creating it returns an existing funnel of the same name unchanged. But Plausible removes a funnel once fewer + * than two of its steps remain, which happens when their goals are deleted. So every step's goal is deleted except + * the last one, the purchase (Revenue) goal, whose currency can't be changed. create_funnel() then recreates the + * funnel and its goals. The goals' history is kept, as Plausible computes conversions from the events themselves. + * + * Note: a user-made funnel that shares one of these goals loses that step. + * + * @since 2.6.2 + * + * @param string $funnel_name + * @param string $view_product_path The path the funnel's view-product step should target, e.g. /producto*. + * @param string $key + * @param Client $client + * @param array $all_ids + * + * @return array The (possibly pruned) goal-ID map. + * + * @codeCoverageIgnore We don't want to test the API. + */ + private function maybe_dismantle_outdated_funnel( $funnel_name, $view_product_path, $key, $client, $all_ids ) { + foreach ( (array) $client->get_funnels() as $funnel ) { + $steps = $funnel['funnel']['steps'] ?? []; + + if ( ( $funnel['funnel']['name'] ?? '' ) !== $funnel_name || count( $steps ) < 2 ) { + continue; + } + + $first_step = (string) ( $steps[0]['goal']['display_name'] ?? '' ); + + // Only a view-product step is checked: other steps are named after the (translatable) event goals. + if ( strpos( $first_step, 'Visit ' ) !== 0 || $first_step === sprintf( 'Visit %s', $view_product_path ) ) { + return $all_ids; + } + + foreach ( array_slice( $steps, 0, -1 ) as $step ) { + $id = $step['goal']['id'] ?? null; + + if ( $id ) { + $client->delete_goal( $id ); + unset( $all_ids[ $key ][ $id ] ); + } + } + + update_option( 'plausible_analytics_enhanced_measurements_goal_ids', $all_ids ); + + break; + } + + return $all_ids; + } + /** * Returns the currency to create $key's purchase (Revenue) goal in. * diff --git a/src/Client.php b/src/Client.php index 766ceb22..5c443b34 100644 --- a/src/Client.php +++ b/src/Client.php @@ -231,9 +231,6 @@ public function create_goals( $goals ) { /** * Retrieves all Goals of this Client's site. * - * The response is decoded as-is, because the generated models deserialize every goal as a Pageview goal, dropping - * e.g. a Revenue goal's event_name and currency. - * * @since 2.6.2 * * @return array|false Each goal as ['goal_type' => 'Goal.Revenue', 'goal' => ['id' => 1, 'currency' => 'EUR', ...]], @@ -242,19 +239,52 @@ public function create_goals( $goals ) { * @codeCoverageIgnore */ public function get_goals() { - $goals = []; + return $this->get_all( 'plausibleWebPluginsAPIControllersGoalsIndexRequest', 'goals' ); + } + + /** + * Retrieves all Funnels of this Client's site. + * + * @since 2.6.2 + * + * @return array|false Each funnel as ['funnel' => ['id' => 1, 'name' => '...', 'steps' => [['goal' => [...]], ...]]], + * or false when the Funnels couldn't be retrieved. + * + * @codeCoverageIgnore + */ + public function get_funnels() { + return $this->get_all( 'plausibleWebPluginsAPIControllersFunnelsIndexRequest', 'funnels' ); + } + + /** + * Retrieves all pages of a paginated index endpoint. + * + * The responses are decoded as-is, because the generated models deserialize every goal as a Pageview goal, + * dropping e.g. a Revenue goal's event_name and currency. + * + * @since 2.6.2 + * + * @param string $request_method The DefaultApi method that builds the index request. + * @param string $key The response's key holding the items, e.g. 'goals'. + * + * @return array|false False when any of the pages couldn't be retrieved. + * + * @codeCoverageIgnore + */ + private function get_all( $request_method, $key ) { + $items = []; $after = null; try { do { - $request = $this->api_instance->plausibleWebPluginsAPIControllersGoalsIndexRequest( 100, $after ); + $request = $this->api_instance->$request_method( 100, $after ); $response = json_decode( (string) $this->http_client->send( $request )->getBody(), true ); - if ( ! isset( $response['goals'] ) || ! is_array( $response['goals'] ) ) { + if ( ! isset( $response[ $key ] ) || ! is_array( $response[ $key ] ) ) { return false; } - $goals = array_merge( $goals, $response['goals'] ); + $items = array_merge( $items, $response[ $key ] ); $after = null; if ( ! empty( $response['meta']['pagination']['has_next_page'] ) ) { @@ -267,7 +297,7 @@ public function get_goals() { return false; } - return $goals; + return $items; } /** From bc4df4c3e59185df897a729e5307493369bc5d5f Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:04:28 +0200 Subject: [PATCH 18/21] Mention the rebuilt purchase funnels in the 2.6.2 changelog --- readme.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/readme.txt b/readme.txt index dce0d621..44231e01 100644 --- a/readme.txt +++ b/readme.txt @@ -279,6 +279,7 @@ Please make sure you make a backup of your database before updating any version == Changelog == = 2.6.2 = +* *Important!* This release rebuilds the WooCommerce (and EDD) purchase funnel for sites using WPML with a translated product base. If you built your own funnels using the same goals, please rebuild them after installing this update. * Added: TranslatePress "different domain per language" (Multiple Domains) compatibility. Each language domain can be mapped to its own Plausible Analytics dashboard, just like WPML. * Added: WPML Multilingual & Multicurrency for WooCommerce (WooCommerce Multilingual) compatibility. * Fixed: the view-product goal now targets the URL each language is served under (e.g. /es/producto*), including a translated product base, so product pageviews are no longer missed in non-default languages. Existing purchase funnels whose first step targets a path that's no longer served are recreated. Works for WooCommerce and Easy Digital Downloads, on WPML and TranslatePress. From f6c45b8308f7bafe1871c0b464baa39e5fe34752 Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:16:30 +0200 Subject: [PATCH 19/21] Ignore an early return in code coverage --- src/Helpers.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Helpers.php b/src/Helpers.php index 847a8477..483516db 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -84,7 +84,7 @@ public static function get_currency_for_language( $domain_key = 'default' ) { $wcml = get_option( '_wcml_settings', [] ); if ( empty( $wcml['enable_multi_currency'] ) || empty( $wcml['default_currencies'] ) || ! is_array( $wcml['default_currencies'] ) ) { - return $base; + return $base; // @codeCoverageIgnore } // In "by location" mode the currency follows the visitor's country, so the per-language defaults don't apply. From 5c450699fdf792c0b3b2505c169e78e89b0fe3d5 Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:33:13 +0200 Subject: [PATCH 20/21] Keep a goal's stored ID when deleting it fails Client::delete_goal() now returns whether the goal is gone (deleting a goal that no longer exists succeeds). The funnel dismantling, the view- product reconciliation and delete_integration_goals() only drop a goal's stored ID once it's actually deleted, so a goal that couldn't be deleted stays tracked and can still be cleaned up later. There's nothing to roll back when recreating a dismantled funnel fails: deleted goals can't be restored, and the funnel, which no longer exists, is created on the next settings save. --- src/Admin/Provisioning/Integrations.php | 14 ++++--- src/Client.php | 8 ++++ .../Admin/Provisioning/IntegrationsTest.php | 38 +++++++++++++++++++ 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/src/Admin/Provisioning/Integrations.php b/src/Admin/Provisioning/Integrations.php index 41d920f6..d2d58c0a 100644 --- a/src/Admin/Provisioning/Integrations.php +++ b/src/Admin/Provisioning/Integrations.php @@ -147,6 +147,9 @@ public function create_integration_funnel( $event_goals, $funnel_name, $post_typ * the last one, the purchase (Revenue) goal, whose currency can't be changed. create_funnel() then recreates the * funnel and its goals. The goals' history is kept, as Plausible computes conversions from the events themselves. * + * Deleted goals can't be restored, so there's nothing to roll back if recreating the funnel fails: it no longer + * exists then, and the next settings save creates it. A goal that couldn't be deleted keeps its stored ID. + * * Note: a user-made funnel that shares one of these goals loses that step. * * @since 2.6.2 @@ -179,8 +182,7 @@ private function maybe_dismantle_outdated_funnel( $funnel_name, $view_product_pa foreach ( array_slice( $steps, 0, -1 ) as $step ) { $id = $step['goal']['id'] ?? null; - if ( $id ) { - $client->delete_goal( $id ); + if ( $id && $client->delete_goal( $id ) ) { unset( $all_ids[ $key ][ $id ] ); } } @@ -293,8 +295,8 @@ static function ( $path ) { $deleted_stale = false; foreach ( $all_ids[ $key ] ?? [] as $id => $name ) { - if ( strpos( (string) $name, 'Visit ' ) === 0 && ! in_array( $name, $current_view_product, true ) ) { - $client->delete_goal( $id ); + if ( strpos( (string) $name, 'Visit ' ) === 0 && ! in_array( $name, $current_view_product, true ) && + $client->delete_goal( $id ) ) { unset( $all_ids[ $key ][ $id ] ); $deleted_stale = true; } @@ -429,8 +431,8 @@ public function delete_integration_goals( $integration ) { foreach ( $goals as $id => $name ) { $is_view_product = $delete_view_product && strpos( (string) $name, 'Visit ' ) === 0; - if ( $is_view_product || $this->provisioning->array_search_contains( $name, $integration->event_goals ) ) { - $client->delete_goal( $id ); + if ( ( $is_view_product || $this->provisioning->array_search_contains( $name, $integration->event_goals ) ) && + $client->delete_goal( $id ) ) { unset( $goals[ $id ] ); } } diff --git a/src/Client.php b/src/Client.php index 5c443b34..97d78589 100644 --- a/src/Client.php +++ b/src/Client.php @@ -342,8 +342,12 @@ public function bulk_create_shared_links() { /** * Delete a Custom Event Goal by ID. * + * @since 2.6.2 Returns whether the goal is gone. Deleting a goal that doesn't exist (anymore) succeeds. + * * @param int $id * + * @return bool + * * @codeCoverageIgnore */ public function delete_goal( $id ) { @@ -358,7 +362,11 @@ public function delete_goal( $id ) { 'plausible-analytics' ) ); + + return false; } + + return true; } /** diff --git a/tests/integration/Admin/Provisioning/IntegrationsTest.php b/tests/integration/Admin/Provisioning/IntegrationsTest.php index fbbb6291..e9958a22 100644 --- a/tests/integration/Admin/Provisioning/IntegrationsTest.php +++ b/tests/integration/Admin/Provisioning/IntegrationsTest.php @@ -45,6 +45,44 @@ public function testDeleteIntegrationGoals() { } } + /** + * A goal that couldn't be deleted should keep its stored ID, so it can still be cleaned up later. + * + * @see Integrations::delete_integration_goals() + */ + public function testDeleteIntegrationGoalsKeepsFailedDeletions() { + $client = $this->getMockBuilder( Client::class ) + ->onlyMethods( [ 'delete_goal' ] ) + ->getMock(); + + $client->method( 'delete_goal' )->willReturnCallback( + function ( $id ) { + return $id !== 2; + } + ); + + $provisioning = $this->getMockBuilder( Provisioning::class ) + ->setConstructorArgs( [ $client ] ) + ->onlyMethods( [ 'array_search_contains' ] ) + ->getMock(); + + $provisioning->method( 'array_search_contains' ) + ->willReturn( 1 ); + + try { + update_option( 'plausible_analytics_enhanced_measurements_goal_ids', [ 'default' => [ 1 => 'a', 2 => 'b', 3 => 'c' ] ] ); + + $integration = new Integrations( $provisioning ); + $integration->delete_integration_goals( (object) [ 'event_goals' => [ '' ] ] ); + + $this->assertEquals( [ 'default' => [ 2 => 'b' ] ], get_option( 'plausible_analytics_enhanced_measurements_goal_ids' ) ); + } finally { + delete_option( 'plausible_analytics_enhanced_measurements_goal_ids' ); + $this->removeAction( 'update_option_plausible_analytics_settings', 'maybe_create_' ); + $this->removeAction( 'update_option_plausible_analytics_settings', 'maybe_delete_', 11 ); + } + } + /** * The goal path is the event goal's URI, i.e. everything after the "Visit " prefix, with a single leading slash. * From 29184dd95ed136f42830a67ede3792b0f0ba5179 Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:08:50 +0200 Subject: [PATCH 21/21] Clarify when the 2.6.2 upgrade skips the funnels WPML loads its languages on plugins_loaded and TranslatePress keeps them in an option, so they're available on init. The list is only empty when there are no languages yet, e.g. when WPML's setup hasn't been completed. --- src/Admin/Upgrades.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Admin/Upgrades.php b/src/Admin/Upgrades.php index 59d556c6..70e95145 100644 --- a/src/Admin/Upgrades.php +++ b/src/Admin/Upgrades.php @@ -440,10 +440,10 @@ private function upgrade_to_260() { * Pageview goals only apply when a multilingual plugin (WPML, with or without WooCommerce Multilingual & * Multicurrency, or TranslatePress) is active. * - * This runs on init (@see Upgrades::__construct()), i.e. after the multilingual plugin registered its language API - * on plugin load. In the rare case its languages aren't available yet, the funnels are skipped — they're recreated - * on the next settings save anyway (@see Provisioning\Integrations\WooCommerce::init()) — so the upgrade always - * completes in a single pass. + * This runs on init (@see Upgrades::__construct()), so the multilingual plugin's languages are available: WPML loads + * them on plugins_loaded and TranslatePress stores them in an option. When there are none (e.g. WPML's setup hasn't + * been completed yet), the funnels are skipped — they're recreated on the next settings save anyway + * (@see Provisioning\Integrations\WooCommerce::init()) — so the upgrade always completes in a single pass. * * @since v2.6.2 * @@ -469,8 +469,8 @@ public function upgrade_to_262() { $provisioning->maybe_create_custom_properties( [], $settings ); /** - * The localized Pageview goals only matter when a multilingual plugin is active, and only once its - * language API has booted. If it hasn't returned any languages yet, skip the funnels rather than + * The localized Pageview goals only matter when a multilingual plugin is active and serves languages. When + * it doesn't serve any yet (e.g. WPML's setup hasn't been completed), skip the funnels rather than * provisioning the default language's path only; they're (re)created with the right paths on the next * settings save. *