diff --git a/assets/js/event-providers/woocommerce.js b/assets/js/event-providers/woocommerce.js index 72b68afcd43..14af37629ca 100644 --- a/assets/js/event-providers/woocommerce.js +++ b/assets/js/event-providers/woocommerce.js @@ -21,6 +21,7 @@ const { currency: globalCurrency, + currency_minor_unit: globalCurrencyMinorUnit = 2, products: globalProducts, purchase, add_to_cart: addToCart, @@ -32,7 +33,15 @@ if ( addToCart && canTrackAddToCart ) { const { price } = addToCart; - const eventData = formatEventData( price, globalCurrency, addToCart ); + const eventData = formatEventData( + price, + globalCurrency, + addToCart, + null, + null, + null, + globalCurrencyMinorUnit + ); global._googlesitekit?.gtagEvent?.( 'add_to_cart', eventData ); } @@ -46,7 +55,8 @@ items, id, totals.shipping_total, - totals.tax_total + totals.tax_total, + globalCurrencyMinorUnit ); // User data is already normalized from WooCommerce.php. @@ -82,7 +92,11 @@ const eventData = formatEventData( price, globalCurrency, - productData + productData, + null, + null, + null, + globalCurrencyMinorUnit ); global._googlesitekit?.gtagEvent?.( 'add_to_cart', eventData ); } ); @@ -126,7 +140,11 @@ const eventData = formatEventData( price, globalCurrency, - productData + productData, + null, + null, + null, + globalCurrencyMinorUnit ); global._googlesitekit?.gtagEvent?.( 'add_to_cart', eventData ); @@ -140,10 +158,11 @@ products, transactionID = null, shipping = null, - tax = null + tax = null, + currencyMinorUnit = 2 ) { const formattedData = { - value: formatPrice( value ), + value: formatPrice( value, currencyMinorUnit ), currency, items: [], googlesitekit_event_provider: 'woocommerce', @@ -167,22 +186,26 @@ if ( products && products.length ) { for ( const product of products ) { - formattedData.items.push( formatProductData( product ) ); + formattedData.items.push( + formatProductData( product, currencyMinorUnit ) + ); } } else if ( products && products.id ) { - formattedData.items = [ formatProductData( products ) ]; + formattedData.items = [ + formatProductData( products, currencyMinorUnit ), + ]; } return formattedData; } - function formatProductData( product ) { + function formatProductData( product, currencyMinorUnit = 2 ) { const { id, name, price, variation, quantity, categories } = product; const mappedItem = { item_id: id, item_name: name, - price: formatPrice( price ), + price: formatPrice( price, currencyMinorUnit ), }; if ( quantity ) { @@ -213,9 +236,10 @@ * Returns the price of a product formatted with decimal places if necessary. * * @since 1.158.0 + * @since n.e.x.t Added the `currencyMinorUnit` parameter. * * @param {string} price The price to parse. - * @param {number} [currencyMinorUnit=2] The number decimals to show in the currency. + * @param {number} [currencyMinorUnit=2] The number of decimals used by the store currency, as configured in WooCommerce settings. * @return {number} The price of the product with decimals. */ function formatPrice( price, currencyMinorUnit = 2 ) { diff --git a/assets/js/event-providers/woocommerce.test.js b/assets/js/event-providers/woocommerce.test.js new file mode 100644 index 00000000000..62c576d55b9 --- /dev/null +++ b/assets/js/event-providers/woocommerce.test.js @@ -0,0 +1,252 @@ +/** + * WooCommerce event provider tests. + * + * Site Kit by Google, Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Internal dependencies + */ + +// The IIFE in `./woocommerce` runs at require time and synchronously calls +// `gtagEvent` for the `add_to_cart` and `purchase` events it finds on +// `window._googlesitekit.wcdata`. Tests must set up that global and a +// jQuery stub before requiring the file. + +const gtagEventCalls = []; + +function setupGlobals( { + currency = 'USD', + currencyMinorUnit = 2, + addToCart = null, + purchase = null, + products = [], + eventsToTrack = [ 'add_to_cart', 'purchase' ], +} = {} ) { + gtagEventCalls.length = 0; + + global._googlesitekit = { + ...global._googlesitekit, + wcdata: { + currency, + currency_minor_unit: currencyMinorUnit, + products, + add_to_cart: addToCart, + purchase, + eventsToTrack, + }, + gtagEvent: jest.fn( ( name, data ) => { + gtagEventCalls.push( { name, data } ); + } ), + }; + + // Minimal jQuery stub — the WC provider only uses `body.on` and `.each` + // against the products DOM, neither of which needs to fire in unit tests. + global.jQuery = jest.fn( () => ( { + on: jest.fn(), + each: jest.fn(), + find: jest.fn( () => ( { attr: () => null } ) ), + } ) ); + global.jQuery.fn = global.jQuery; +} + +describe( 'WooCommerce event provider', () => { + beforeEach( () => { + jest.resetModules(); + } ); + + afterEach( () => { + delete global._googlesitekit.wcdata; + delete global.jQuery; + } ); + + describe( 'add_to_cart value formatting', () => { + it( 'should divide by 100 when currency uses 2 decimal places', () => { + setupGlobals( { + currency: 'USD', + currencyMinorUnit: 2, + addToCart: { + id: 1, + name: 'Test Product', + price: 25075, + }, + } ); + + require( './woocommerce' ); + + expect( gtagEventCalls ).toHaveLength( 1 ); + expect( gtagEventCalls[ 0 ].name ).toBe( 'add_to_cart' ); + expect( gtagEventCalls[ 0 ].data.value ).toBe( 250.75 ); + expect( gtagEventCalls[ 0 ].data.items[ 0 ].price ).toBe( 250.75 ); + } ); + + it( 'should divide by 10000 when currency uses 4 decimal places', () => { + setupGlobals( { + currency: 'BHD', + currencyMinorUnit: 4, + addToCart: { + id: 2, + name: 'High Precision Product', + price: 12345, + }, + } ); + + require( './woocommerce' ); + + expect( gtagEventCalls ).toHaveLength( 1 ); + expect( gtagEventCalls[ 0 ].name ).toBe( 'add_to_cart' ); + expect( gtagEventCalls[ 0 ].data.value ).toBe( 1.2345 ); + expect( gtagEventCalls[ 0 ].data.items[ 0 ].price ).toBe( 1.2345 ); + } ); + + it( 'should pass the value through when currency uses 0 decimal places', () => { + setupGlobals( { + currency: 'JPY', + currencyMinorUnit: 0, + addToCart: { + id: 3, + name: 'Yen Product', + price: 12345, + }, + } ); + + require( './woocommerce' ); + + expect( gtagEventCalls[ 0 ].data.value ).toBe( 12345 ); + expect( gtagEventCalls[ 0 ].data.items[ 0 ].price ).toBe( 12345 ); + } ); + + it( 'should divide by 1000 when currency uses 3 decimal places', () => { + setupGlobals( { + currency: 'KWD', + currencyMinorUnit: 3, + addToCart: { + id: 4, + name: 'KWD Product', + price: 250750, + }, + } ); + + require( './woocommerce' ); + + expect( gtagEventCalls[ 0 ].data.value ).toBe( 250.75 ); + } ); + } ); + + describe( 'purchase value formatting', () => { + const purchaseFixture = { + id: 99, + totals: { + currency_code: 'USD', + total_price: 25075, + shipping_total: 500, + tax_total: 200, + }, + items: [ + { + id: 1, + name: 'Test Product', + price: 12345, + quantity: 2, + }, + ], + }; + + it( 'should divide purchase value by 100 with 2 decimal places', () => { + setupGlobals( { + currency: 'USD', + currencyMinorUnit: 2, + purchase: purchaseFixture, + } ); + + require( './woocommerce' ); + + expect( gtagEventCalls[ 0 ].name ).toBe( 'purchase' ); + expect( gtagEventCalls[ 0 ].data.value ).toBe( 250.75 ); + expect( gtagEventCalls[ 0 ].data.transaction_id ).toBe( 99 ); + expect( gtagEventCalls[ 0 ].data.items[ 0 ].price ).toBe( 123.45 ); + } ); + + it( 'should divide purchase value by 10000 with 4 decimal places', () => { + setupGlobals( { + currency: 'BHD', + currencyMinorUnit: 4, + purchase: { + ...purchaseFixture, + totals: { + ...purchaseFixture.totals, + total_price: 1234500, + }, + items: [ + { + id: 1, + name: 'Test Product', + price: 12345, + quantity: 2, + }, + ], + }, + } ); + + require( './woocommerce' ); + + expect( gtagEventCalls[ 0 ].name ).toBe( 'purchase' ); + expect( gtagEventCalls[ 0 ].data.value ).toBe( 123.45 ); + expect( gtagEventCalls[ 0 ].data.items[ 0 ].price ).toBe( 1.2345 ); + } ); + + it( 'should not fire purchase event when eventsToTrack excludes it', () => { + setupGlobals( { + currency: 'USD', + currencyMinorUnit: 2, + purchase: purchaseFixture, + eventsToTrack: [ 'add_to_cart' ], + } ); + + require( './woocommerce' ); + + expect( gtagEventCalls ).toHaveLength( 0 ); + } ); + } ); + + describe( 'fallback behavior', () => { + it( 'should default to 2 decimal places when currency_minor_unit is missing from wcdata', () => { + gtagEventCalls.length = 0; + global._googlesitekit = { + ...global._googlesitekit, + wcdata: { + currency: 'USD', + products: [], + add_to_cart: { id: 1, name: 'X', price: 10000 }, + purchase: null, + eventsToTrack: [ 'add_to_cart', 'purchase' ], + }, + gtagEvent: jest.fn( ( name, data ) => { + gtagEventCalls.push( { name, data } ); + } ), + }; + global.jQuery = jest.fn( () => ( { + on: jest.fn(), + each: jest.fn(), + find: jest.fn( () => ( { attr: () => null } ) ), + } ) ); + global.jQuery.fn = global.jQuery; + + require( './woocommerce' ); + + expect( gtagEventCalls[ 0 ].data.value ).toBe( 100 ); + } ); + } ); +} ); diff --git a/includes/Core/Conversion_Tracking/Conversion_Event_Providers/WooCommerce.php b/includes/Core/Conversion_Tracking/Conversion_Event_Providers/WooCommerce.php index 465604e94dc..2a5a2e64045 100644 --- a/includes/Core/Conversion_Tracking/Conversion_Event_Providers/WooCommerce.php +++ b/includes/Core/Conversion_Tracking/Conversion_Event_Providers/WooCommerce.php @@ -278,6 +278,7 @@ function () { sprintf( 'window._googlesitekit.wcdata.products = %s;', wp_json_encode( $this->products ) ), sprintf( 'window._googlesitekit.wcdata.add_to_cart = %s;', wp_json_encode( $this->add_to_cart ) ), sprintf( 'window._googlesitekit.wcdata.currency = "%s";', esc_js( get_woocommerce_currency() ) ), + sprintf( 'window._googlesitekit.wcdata.currency_minor_unit = %d;', absint( wc_get_price_decimals() ) ), sprintf( 'window._googlesitekit.wcdata.eventsToTrack = %s;', wp_json_encode( $events_to_track ) ), ) ); @@ -647,6 +648,7 @@ protected function maybe_add_purchase_inline_script( $order_id, $skip_meta_value "\n", array( 'window._googlesitekit.wcdata = window._googlesitekit.wcdata || {};', + sprintf( 'window._googlesitekit.wcdata.currency_minor_unit = %d;', absint( wc_get_price_decimals() ) ), sprintf( 'window._googlesitekit.wcdata.purchase = %s;', wp_json_encode( $this->get_formatted_order( $order ) ) ), ) ), diff --git a/tests/phpunit/includes/wc-functions-stub.php b/tests/phpunit/includes/wc-functions-stub.php new file mode 100644 index 00000000000..f9ed9f43532 --- /dev/null +++ b/tests/phpunit/includes/wc-functions-stub.php @@ -0,0 +1,70 @@ +assertEquals( '+94771770589', $result, 'Normalized phone should fall back when country is empty.' ); } + + /** + * @dataProvider formatted_price_decimals_provider + * @runInSeparateProcess + */ + public function test_get_formatted_price_returns_correct_minor_units( $decimals, $value, $expected ) { + require_once $this->wc_functions_stub_path(); + + update_option( 'woocommerce_price_decimals', $decimals ); + + $reflection = new \ReflectionClass( $this->woocommerce ); + $method = $reflection->getMethod( 'get_formatted_price' ); + $method->setAccessible( true ); + + $this->assertSame( + $expected, + $method->invoke( $this->woocommerce, $value ), + "get_formatted_price with {$decimals} decimals should produce the expected minor-unit count." + ); + + delete_option( 'woocommerce_price_decimals' ); + } + + public function formatted_price_decimals_provider() { + return array( + '2 decimals (USD) - whole units' => array( 2, 10, 1000 ), + '2 decimals (USD) - fractional units' => array( 2, '10.50', 1050 ), + '2 decimals (USD) - 4-decimal input' => array( 2, '10.5050', 1051 ), + '4 decimals (BHD) - fractional units' => array( 4, '1.2345', 12345 ), + '0 decimals (JPY) - whole units' => array( 0, 500, 500 ), + '0 decimals (JPY) - decimal input' => array( 0, '500.99', 501 ), + ); + } + + /** + * @runInSeparateProcess + * @expectedDeprecated the_block_template_skip_link + */ + public function test_inline_script_emits_currency_minor_unit() { + require_once $this->wc_functions_stub_path(); + + update_option( 'woocommerce_price_decimals', 4 ); + + // Register a stub for the `woocommerce` dependency so the WP test + // environment's strict enqueue checks do not flag the provider script. + wp_register_script( 'woocommerce', false, array(), '0.0.0', true ); + + // Register the script + hooks, then enqueue so the inline data added + // by `wp_add_inline_script()` lands in the WP_Scripts store. + $this->woocommerce->register_script(); + $this->woocommerce->register_hooks(); + wp_enqueue_script( 'googlesitekit-events-provider-woocommerce' ); + + // Capture the wp_footer output (which also emits block-theme deprecations, + // declared above as `@expectedDeprecated`). + ob_start(); + do_action( 'wp_footer' ); + $footer = ob_get_clean(); + + // The inline data added by `wp_add_inline_script( ..., 'before' )` lives + // under the "{$handle}_before" key in `WP_Scripts::$data`. Read it + // directly so the test does not need to render the full page. + $wp_scripts = wp_scripts(); + $inline_data = $wp_scripts->get_data( 'googlesitekit-events-provider-woocommerce', 'before' ); + $footer .= is_array( $inline_data ) ? implode( "\n", $inline_data ) : ''; + + $this->assertStringContainsString( + 'wcdata.currency_minor_unit = 4;', + $footer, + 'wp_footer inline script should expose currency_minor_unit to the WC event provider JS.' + ); + + delete_option( 'woocommerce_price_decimals' ); + } + + /** + * Path to the WooCommerce function stubs used when WooCommerce is not + * active in the test environment. + */ + private function wc_functions_stub_path() { + return dirname( __DIR__, 4 ) . '/includes/wc-functions-stub.php'; + } }