From c71eba22d49c86889a66a4c42d12c4ba723dcc3f Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:20:53 +0200 Subject: [PATCH 1/2] Stop translating goal, event and funnel names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The names identify the goals and funnels in Plausible, but were wrapped in __(). When the French translations of these strings were updated on translate.wordpress.org (17 Sep 2026), fr_FR sites started sending e.g. "Achat WooCommerce finalisé" instead of "Woo Complete Purchase": each goal's data was split in two and revenue silently stopped being recorded, as the translated purchase goal isn't a Revenue goal. It was already inconsistent before that: the goals for cloaked links, search queries and query parameters were created with the translated names, while the JS sends their events under the English names, so on a translated site those goals never converted. And goals are provisioned in the admin's (user) locale, while events are sent in the site's locale. Use the English names as plain strings for the WooCommerce and EDD events, the custom event goals, the form completions event and the funnel names. French sites then send their events to the original (English) goals again, which still exist, including the Revenue goal. Fixes #326 --- readme.txt | 1 + src/Admin/Provisioning.php | 14 ++++++--- src/Admin/Provisioning/Integrations.php | 2 +- src/Admin/Provisioning/Integrations/EDD.php | 3 +- .../Provisioning/Integrations/WooCommerce.php | 3 +- src/Integrations/EDD.php | 16 ++++++---- src/Integrations/FormSubmit.php | 5 +-- src/Integrations/WooCommerce.php | 16 ++++++---- tests/integration/Admin/ProvisioningTest.php | 27 ++++++++++++++++ .../Integrations/WooCommerceTest.php | 31 +++++++++++++++++++ 10 files changed, 96 insertions(+), 22 deletions(-) diff --git a/readme.txt b/readme.txt index 44231e01..07bb4efb 100644 --- a/readme.txt +++ b/readme.txt @@ -286,6 +286,7 @@ Please make sure you make a backup of your database before updating any version * 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. +* Fixed: goal, event and funnel names (e.g. "Woo Complete Purchase") were translatable, so a translation update could start sending events under new names, splitting each goal's data and stopping revenue from being recorded. They're now always sent in English. On French sites, where these names were translated in September 2026, events are sent to the original goals again; goals created with the French names can be removed from your Plausible dashboard. * Tested with WP 7.1. = 2.6.1 = diff --git a/src/Admin/Provisioning.php b/src/Admin/Provisioning.php index 412dfd39..72afb5b3 100644 --- a/src/Admin/Provisioning.php +++ b/src/Admin/Provisioning.php @@ -89,12 +89,16 @@ class Provisioning { public function __construct( $client = null ) { $this->client = $client; + /** + * These names identify the goals in Plausible Analytics and must match the names the events are sent under, so + * they're deliberately not translatable. @see https://github.com/plausible/wordpress/issues/326 + */ $this->custom_event_goals = [ - EnhancedMeasurements::FOUR_O_FOUR => __( '404', 'plausible-analytics' ), - EnhancedMeasurements::CLOAKED_AFFILIATE_LINKS => __( 'Cloaked Link: Click', 'plausible-analytics' ), - EnhancedMeasurements::FORM_COMPLETIONS => __( 'WP Form Completions', 'plausible-analytics' ), - EnhancedMeasurements::QUERY_PARAMS => __( 'WP Query Parameters', 'plausible-analytics' ), - EnhancedMeasurements::SEARCH_QUERIES => __( 'WP Search Queries', 'plausible-analytics' ), + EnhancedMeasurements::FOUR_O_FOUR => '404', + EnhancedMeasurements::CLOAKED_AFFILIATE_LINKS => 'Cloaked Link: Click', + EnhancedMeasurements::FORM_COMPLETIONS => 'WP Form Completions', + EnhancedMeasurements::QUERY_PARAMS => 'WP Query Parameters', + EnhancedMeasurements::SEARCH_QUERIES => 'WP Search Queries', ]; $this->init(); diff --git a/src/Admin/Provisioning/Integrations.php b/src/Admin/Provisioning/Integrations.php index d2d58c0a..b71eed8a 100644 --- a/src/Admin/Provisioning/Integrations.php +++ b/src/Admin/Provisioning/Integrations.php @@ -174,7 +174,7 @@ private function maybe_dismantle_outdated_funnel( $funnel_name, $view_product_pa $first_step = (string) ( $steps[0]['goal']['display_name'] ?? '' ); - // Only a view-product step is checked: other steps are named after the (translatable) event goals. + // Only the view-product step is checked: it's the only step whose goal changes, with the languages served. if ( strpos( $first_step, 'Visit ' ) !== 0 || $first_step === sprintf( 'Visit %s', $view_product_path ) ) { return $all_ids; } diff --git a/src/Admin/Provisioning/Integrations/EDD.php b/src/Admin/Provisioning/Integrations/EDD.php index 2ba35df1..da3809ec 100644 --- a/src/Admin/Provisioning/Integrations/EDD.php +++ b/src/Admin/Provisioning/Integrations/EDD.php @@ -58,7 +58,8 @@ 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' ), $edd->post_type ); + // Plausible Analytics identifies funnels by name, so it's deliberately not translatable, like the goal names. + $this->integrations->create_integration_funnel( $edd->event_goals, 'EDD Purchase Funnel', $edd->post_type ); } /** diff --git a/src/Admin/Provisioning/Integrations/WooCommerce.php b/src/Admin/Provisioning/Integrations/WooCommerce.php index 55155af4..20d56d51 100644 --- a/src/Admin/Provisioning/Integrations/WooCommerce.php +++ b/src/Admin/Provisioning/Integrations/WooCommerce.php @@ -59,7 +59,8 @@ 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' ), $woocommerce->post_type ); + // Plausible Analytics identifies funnels by name, so it's deliberately not translatable, like the goal names. + $this->integrations->create_integration_funnel( $woocommerce->event_goals, 'Woo Purchase Funnel', $woocommerce->post_type ); } /** diff --git a/src/Integrations/EDD.php b/src/Integrations/EDD.php index b20d3937..954c4ed9 100644 --- a/src/Integrations/EDD.php +++ b/src/Integrations/EDD.php @@ -38,13 +38,17 @@ public function __construct( $init = true ) { $uri = '/' . $uri; } + /** + * These names identify the goals in Plausible Analytics, so they must never change: they're deliberately not + * translatable, because a translation (update) would start sending events under new names, splitting each + * goal's data and leaving e.g. the Revenue goal behind. @see https://github.com/plausible/wordpress/issues/326 + */ $this->event_goals = [ - // translators: %s: Product page URI pattern. - 'view-product' => sprintf( __( 'Visit %s*', 'plausible-analytics' ), $uri ), - 'add-to-cart' => __( 'EDD Add to Cart', 'plausible-analytics' ), - 'remove-from-cart' => __( 'EDD Remove from Cart', 'plausible-analytics' ), - 'checkout' => __( 'EDD Start Checkout', 'plausible-analytics' ), - 'purchase' => __( 'EDD Complete Purchase', 'plausible-analytics' ), + 'view-product' => sprintf( 'Visit %s*', $uri ), + 'add-to-cart' => 'EDD Add to Cart', + 'remove-from-cart' => 'EDD Remove from Cart', + 'checkout' => 'EDD Start Checkout', + 'purchase' => 'EDD Complete Purchase', ]; $this->init( $init ); diff --git a/src/Integrations/FormSubmit.php b/src/Integrations/FormSubmit.php index 534a2cd7..61b1d9fb 100644 --- a/src/Integrations/FormSubmit.php +++ b/src/Integrations/FormSubmit.php @@ -65,7 +65,8 @@ public function add_js() { wp_localize_script( 'plausible-form-submit-integration', 'plausible_analytics_i18n', - [ 'form_completions' => __( 'WP Form Completions', 'plausible-analytics' ), ] + // The goal's name, which is deliberately not translatable. @see Provisioning::__construct() + [ 'form_completions' => 'WP Form Completions', ] ); wp_enqueue_script( 'plausible-form-submit-integration' ); @@ -109,7 +110,7 @@ private function track_submission( $uri ) { $proxy = new Proxy( false ); $proxy->do_request( - __( 'WP Form Completions', 'plausible-analytics' ), + 'WP Form Completions', null, null, [ 'path' => $uri ] diff --git a/src/Integrations/WooCommerce.php b/src/Integrations/WooCommerce.php index 4297ceb3..cf46e5cc 100644 --- a/src/Integrations/WooCommerce.php +++ b/src/Integrations/WooCommerce.php @@ -41,13 +41,17 @@ public function __construct( $init = true ) { $uri = '/' . $uri; } + /** + * These names identify the goals in Plausible Analytics, so they must never change: they're deliberately not + * translatable, because a translation (update) would start sending events under new names, splitting each + * goal's data and leaving e.g. the Revenue goal behind. @see https://github.com/plausible/wordpress/issues/326 + */ $this->event_goals = [ - // translators: %s: Product page URI pattern. - 'view-product' => sprintf( __( 'Visit %s*', 'plausible-analytics' ), $uri ), - 'add-to-cart' => __( 'Woo Add to Cart', 'plausible-analytics' ), - 'remove-from-cart' => __( 'Woo Remove from Cart', 'plausible-analytics' ), - 'checkout' => __( 'Woo Start Checkout', 'plausible-analytics' ), - 'purchase' => __( 'Woo Complete Purchase', 'plausible-analytics' ), + 'view-product' => sprintf( 'Visit %s*', $uri ), + 'add-to-cart' => 'Woo Add to Cart', + 'remove-from-cart' => 'Woo Remove from Cart', + 'checkout' => 'Woo Start Checkout', + 'purchase' => 'Woo Complete Purchase', ]; $this->init( $init ); diff --git a/tests/integration/Admin/ProvisioningTest.php b/tests/integration/Admin/ProvisioningTest.php index 667b739d..ca9ef06d 100644 --- a/tests/integration/Admin/ProvisioningTest.php +++ b/tests/integration/Admin/ProvisioningTest.php @@ -16,6 +16,33 @@ use function Brain\Monkey\Functions\when; class ProvisioningTest extends TestCase { + /** + * The goal names must match the names the events are sent under, so a translation must never change them. + * + * @see https://github.com/plausible/wordpress/issues/326 + * @return void + * @throws \ReflectionException + */ + public function testCustomEventGoalsAreNotTranslated() { + $translate = function ( $translation, $text, $domain ) { + return $domain === 'plausible-analytics' ? "Traduit : $text" : $translation; + }; + + add_filter( 'gettext', $translate, 10, 3 ); + + try { + $goals = new \ReflectionProperty( Provisioning::class, 'custom_event_goals' ); + $goals->setAccessible( true ); + + $this->assertEquals( + [ '404', 'Cloaked Link: Click', 'WP Form Completions', 'WP Query Parameters', 'WP Search Queries' ], + array_values( $goals->getValue( new Provisioning( false ) ) ) + ); + } finally { + remove_filter( 'gettext', $translate ); + } + } + /** * @see Provisioning::create_goal_request() * @return void diff --git a/tests/integration/Integrations/WooCommerceTest.php b/tests/integration/Integrations/WooCommerceTest.php index 00604fda..85a736f9 100644 --- a/tests/integration/Integrations/WooCommerceTest.php +++ b/tests/integration/Integrations/WooCommerceTest.php @@ -12,6 +12,37 @@ #[AllowDynamicProperties] class WooCommerceTest extends TestCase { + /** + * The event names identify the goals in Plausible, so a translation must never change them. + * + * @see https://github.com/plausible/wordpress/issues/326 + * @return void + */ + public function testEventGoalsAreNotTranslated() { + when( 'wc_get_permalink_structure' )->justReturn( [ 'product_base' => 'product' ] ); + + $translate = function ( $translation, $text, $domain ) { + return $domain === 'plausible-analytics' ? "Traduit : $text" : $translation; + }; + + add_filter( 'gettext', $translate, 10, 3 ); + + try { + $this->assertEquals( + [ + 'view-product' => 'Visit /product*', + 'add-to-cart' => 'Woo Add to Cart', + 'remove-from-cart' => 'Woo Remove from Cart', + 'checkout' => 'Woo Start Checkout', + 'purchase' => 'Woo Complete Purchase', + ], + ( new WooCommerce( false ) )->event_goals + ); + } finally { + remove_filter( 'gettext', $translate ); + } + } + /** * @see WooCommerce::track_entered_checkout() * @return void From b1a47e45c42895fcb17cd66a453edf77b2a1e5b0 Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:34:32 +0200 Subject: [PATCH 2/2] Recreate the goals and funnels for every install in the 2.6.2 upgrade Sites whose goals were created under translated names (French 2.6.1 sites that connected after 17 Sep 2026 only have French goals) send their events under the English names as of 2.6.2, but the plugin only created those goals on the next settings save, leaving e.g. purchases without a Revenue goal until then. upgrade_to_262() now (re)creates the custom event goals for every install, and the funnels for every ecommerce install, not only multilingual ones. Both are get-or-create, so existing goals and funnels are left unchanged. Also read the base currency for a new Revenue goal from WooCommerce's setting instead of get_woocommerce_currency(): multicurrency plugins filter the latter to the currency of the current request (WCML returns the visitor's currency in AJAX requests, e.g. the Action Scheduler requests that can run the upgrade), so the goal's currency depended on whichever request happened to provision it. --- src/Admin/Upgrades.php | 48 +++++++++++++++++-------------- src/Helpers.php | 6 +++- tests/integration/HelpersTest.php | 7 +++-- 3 files changed, 36 insertions(+), 25 deletions(-) diff --git a/src/Admin/Upgrades.php b/src/Admin/Upgrades.php index 70e95145..dbe6ae07 100644 --- a/src/Admin/Upgrades.php +++ b/src/Admin/Upgrades.php @@ -432,13 +432,17 @@ 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 Custom Property and the per-language - * Pageview goals (e.g. /es/producto*) without having to save their settings first. + * After updating to 2.6.2, (re)create the goals, the Custom Properties and the integration funnels, so existing + * installs pick them up 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. + * - Goal and funnel names are no longer translated (@see https://github.com/plausible/wordpress/issues/326). Sites + * whose goals were created under translated names (e.g. French, since September 2026) now send their events under + * the English names, so those goals, including the Revenue goal, must exist. + * - The currency Custom Property applies to every WooCommerce/EDD install with Ecommerce Revenue enabled. + * - When a multilingual plugin (WPML, with or without WooCommerce Multilingual & Multicurrency, or TranslatePress) + * is active, the view-product goals are localized per language (e.g. /es/producto*). + * + * Goals and funnels are created with get-or-create requests, so existing ones are left unchanged. * * 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 @@ -452,33 +456,33 @@ private function upgrade_to_260() { * @codeCoverageIgnore because all we'd be doing is testing the Plugins API. */ public function upgrade_to_262() { + $provisioning = new Provisioning(); + $settings = Helpers::get_settings(); + + /** + * (Re)create the goals of the enabled Enhanced Measurements under their (untranslated) names. Bails when no Plugin + * Token is entered yet; the goals are created as soon as one is. + * + * @see Provisioning::maybe_provision_on_connect() + */ + $provisioning->maybe_create_goals( [], $settings ); + $is_ecommerce = \Plausible\Analytics\WP\Integrations::is_wc_active() || \Plausible\Analytics\WP\Integrations::is_edd_active(); if ( $is_ecommerce && EnhancedMeasurements::is_enabled( EnhancedMeasurements::ECOMMERCE_REVENUE ) ) { - $provisioning = new Provisioning(); - $settings = Helpers::get_settings(); - - /** - * 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. - */ + // The currency Custom Property (@see Provisioning::CUSTOM_PROPERTIES) applies to every ecommerce install. $provisioning->maybe_create_custom_properties( [], $settings ); /** - * 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. + * (Re)create the funnels and their goals. When a multilingual plugin is active but doesn't serve any languages + * yet (e.g. WPML's setup hasn't been completed), skip them 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 the Plugin Token is saved. * @see Provisioning\Integrations\EDD::init() */ - if ( Helpers::get_multilang_plugin() && ! empty( Helpers::get_active_languages() ) ) { + if ( ! Helpers::get_multilang_plugin() || ! empty( Helpers::get_active_languages() ) ) { $integrations = new Integrations( $provisioning ); ( new Provisioning\Integrations\WooCommerce( $integrations ) )->maybe_create_woocommerce_funnel( [], $settings ); diff --git a/src/Helpers.php b/src/Helpers.php index 483516db..2b6fcec9 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -67,6 +67,10 @@ public static function get_active_languages() { * language) the store's base currency is used, and Plausible converts foreign-currency purchases into * it automatically. * + * The base currency is read from WooCommerce's setting instead of get_woocommerce_currency(), because multicurrency + * plugins filter the latter to the currency of the current request (e.g. WCML returns the visitor's currency in + * AJAX requests), which would make the goal's currency depend on whichever request happens to provision it. + * * @since 2.6.2 * * @param string $domain_key 'default' or a WPML language code (e.g. 'es'). @@ -74,7 +78,7 @@ public static function get_active_languages() { * @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(); + $base = Integrations::is_edd_active() ? edd_get_currency() : (string) get_option( 'woocommerce_currency', 'USD' ); // 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 ) { diff --git a/tests/integration/HelpersTest.php b/tests/integration/HelpersTest.php index 10de9191..002711e4 100644 --- a/tests/integration/HelpersTest.php +++ b/tests/integration/HelpersTest.php @@ -462,13 +462,15 @@ public function testGetActiveLanguagesTranslatePressSkipsUnpublishedLanguages() /** * 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. + * should fall back to the store's base currency setting, not the (filtered) currency of the current request. * * @see Helpers::get_currency_for_language() * @return void */ public function testGetCurrencyForLanguage() { - when( 'get_woocommerce_currency' )->justReturn( 'USD' ); + // Multicurrency plugins filter this to the currency of the current request. + when( 'get_woocommerce_currency' )->justReturn( 'JPY' ); + update_option( 'woocommerce_currency', 'USD' ); $plugin = function () { return Helpers::MULTILANG_PLUGIN_WPML; @@ -511,6 +513,7 @@ public function testGetCurrencyForLanguage() { remove_filter( 'plausible_analytics_multilang_plugin', $plugin ); remove_filter( 'wpml_default_language', $default ); delete_option( '_wcml_settings' ); + delete_option( 'woocommerce_currency' ); } }