Skip to content

Commit 6d9e7a7

Browse files
duracelltomiclaude
andcommitted
Never arm the reliable-purchase fallback from a render that already fired the purchase
The standard order-received render consumes the pending-purchase session marker while emitting the purchase inline (wp_head), and woocommerce_thankyou fires remember_order() later in the same request - re-seeding the marker and re-arming the fallback for the very order the page just tracked. With "Do not flag orders as being tracked" on (which disables every tracked-order suppressor by design), the session endpoint then delivered the same purchase a second time after page load. remember_order() now bails when the request already pushed the purchase, and on_thankyou() raises that request-scoped flag after a successful push so the customized-page path is covered by the same guard. Also corrects the handlePendingPurchase comment that claimed the browser guard stops a re-push in the do-not-flag case - that branch skips the guard by design. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent ec34234 commit 6d9e7a7

5 files changed

Lines changed: 87 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ Major rewrite of the plugin - please read the announcement post on gtm4wp.com be
8484

8585
#### Purchase tracking
8686

87-
* Added: new "Reliable purchase tracking" option (WooCommerce → Purchase tracking) for the most common cause of a missing `purchase` event. When the customer lands on a heavily customized thank-you page, or on the order-pay page instead of the order received page, the purchase event is now emitted on the next page they view in the same browser session. The placed order is remembered server-side (on `woocommerce_payment_complete`, the order-status change and `woocommerce_thankyou`) and de-duplicated with the existing order-tracked flag, browser cookie and order-age guards, so it is never counted twice. Off by default (experimental). It cannot capture orders where the buyer pays via an asynchronous gateway and never returns to the site - that case needs server-side tracking.
87+
* Added: new "Reliable purchase tracking" option (WooCommerce → Purchase tracking) for the most common cause of a missing `purchase` event. When the customer lands on a heavily customized thank-you page, or on the order-pay page instead of the order received page, the purchase event is now emitted on the next page they view in the same browser session. The placed order is remembered server-side (on `woocommerce_payment_complete`, the order-status change and `woocommerce_thankyou`) and de-duplicated with the existing order-tracked flag, browser cookie and order-age guards, so it is never counted twice - and a page view that already carries the purchase event never arms the fallback in the first place, so the two cannot fire together on the same thank-you page even when the "Do not flag orders as being tracked" testing option has switched those guards off. Off by default (experimental). It cannot capture orders where the buyer pays via an asynchronous gateway and never returns to the site - that case needs server-side tracking.
8888
* Added: new "Order statuses that trigger the purchase event" setting. The purchase now fires at order *placement* for the configured statuses (default: Processing, On hold, Completed), so Cash on Delivery (Processing) and bank transfer (On hold) orders are tracked at checkout even though payment clears later. Filterable via the new `gtm4wp_purchase_trackable_statuses` filter.
8989
* Added: new "Custom order received (thank-you) page" option that fires the purchase event on a bespoke confirmation page - resolving the order from the current session - for themes or plugins that do not use the standard WooCommerce order received endpoint.
9090
* Added: GA4 / Google Ads Enhanced Conversions user data is now included on the `purchase` event - SHA-256 hashed email, phone and name plus the plaintext address components Google expects - built from the order so guest checkouts are covered too. The phone number is converted to E.164 against the order's billing country before hashing, as Google requires, and is left out entirely when it cannot be placed in E.164 with confidence rather than sent as a hash that could never match. Opt-in through the existing "Customer data in data layer" option.

js/frontend/gtm4wp-visitor-data.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -475,8 +475,10 @@ import {
475475
// no state change, so this authenticated POST is what performs it. Only when the
476476
// browser guard is in use (flag !== false); the "Do not flag orders as being
477477
// tracked" case writes no order-tracked state anywhere, so it also sends no
478-
// beacon — the server marker then lingers, but its only reader is this same
479-
// resolver and the client's per-order guard already stops a re-push.
478+
// beacon — the server marker then lingers with NO guard against a re-push
479+
// (this branch skips the browser guard too). That is the option's contract:
480+
// it asks for purchases to stay re-trackable, and the marker's only reader
481+
// is this resolver, reached only while an event cookie is present.
480482
if ( useGuard && confirmUrl ) {
481483
fireConfirmBeacon( confirmUrl );
482484
}

readme.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,7 @@ Major rewrite of the plugin. Please read the announcement post on gtm4wp.com bef
332332

333333
**WooCommerce - Purchase tracking**
334334

335-
* Added: new "Reliable purchase tracking" option (WooCommerce → Purchase tracking) for the most common cause of a missing `purchase` event. When the customer lands on a heavily customized thank-you page, or on the order-pay page instead of the order received page, the purchase event is now emitted on the next page they view in the same browser session. The placed order is remembered server-side (on `woocommerce_payment_complete`, the order-status change and `woocommerce_thankyou`) and de-duplicated with the existing order-tracked flag, browser cookie and order-age guards, so it is never counted twice. Off by default (experimental). It cannot capture orders where the buyer pays via an asynchronous gateway and never returns to the site - that case needs server-side tracking.
335+
* Added: new "Reliable purchase tracking" option (WooCommerce → Purchase tracking) for the most common cause of a missing `purchase` event. When the customer lands on a heavily customized thank-you page, or on the order-pay page instead of the order received page, the purchase event is now emitted on the next page they view in the same browser session. The placed order is remembered server-side (on `woocommerce_payment_complete`, the order-status change and `woocommerce_thankyou`) and de-duplicated with the existing order-tracked flag, browser cookie and order-age guards, so it is never counted twice - and a page view that already carries the purchase event never arms the fallback in the first place, so the two cannot fire together on the same thank-you page even when the "Do not flag orders as being tracked" testing option has switched those guards off. Off by default (experimental). It cannot capture orders where the buyer pays via an asynchronous gateway and never returns to the site - that case needs server-side tracking.
336336
* Added: new "Order statuses that trigger the purchase event" setting. The purchase now fires at order *placement* for the configured statuses (default: Processing, On hold, Completed), so Cash on Delivery (Processing) and bank transfer (On hold) orders are tracked at checkout even though payment clears later. Filterable via the new `gtm4wp_purchase_trackable_statuses` filter.
337337
* Added: new "Custom order received (thank-you) page" option that fires the purchase event on a bespoke confirmation page - resolving the order from the current session - for themes or plugins that do not use the standard WooCommerce order received endpoint.
338338
* Added: GA4 / Google Ads Enhanced Conversions user data is now included on the `purchase` event - SHA-256 hashed email, phone and name plus the plaintext address components Google expects - built from the order so guest checkouts are covered too. The phone number is converted to E.164 against the order's billing country before hashing, as Google requires, and is left out entirely when it cannot be placed in E.164 with confidence rather than sent as a hash that could never match. Opt-in through the existing "Customer data in data layer" option.

src/Modules/WooCommerce/PurchaseTracking.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,13 @@ public function on_thankyou( $order_id ): void {
133133
$this->script_tag->print_script_block( $script_tag );
134134

135135
$this->product_data->flag_order_tracked( $order );
136+
137+
// Mark the purchase as handled for THIS request, exactly like the
138+
// order-received page path does. remember_order() runs on this same
139+
// woocommerce_thankyou hook right after this callback, and without the
140+
// flag it would re-arm the reliable-purchase fallback for the very
141+
// order whose purchase this page already carries.
142+
$GLOBALS['gtm4wp_woocommerce_purchase_data_pushed'] = true;
136143
}
137144
}
138145

@@ -151,6 +158,22 @@ public function on_thankyou( $order_id ): void {
151158
* @return void
152159
*/
153160
public function remember_order( $order_id ): void {
161+
/*
162+
The render that emits the purchase inline sets this flag (the standard
163+
order-received page in PageDataLayer::add_order_received_data(), the
164+
on_thankyou fallback above, and the any-page session fallback). Seeding
165+
the marker from such a request would re-arm the reliable-purchase
166+
fallback for an order whose purchase this very page already carries:
167+
the standard order-received render consumes the marker in wp_head and
168+
this hook fires later in the template body, so without this guard the
169+
session endpoint delivered the same purchase a second time after page
170+
load whenever nothing else suppressed it ("Do not flag orders as being
171+
tracked" disables every one of those suppressors by design).
172+
*/
173+
if ( ! empty( $GLOBALS['gtm4wp_woocommerce_purchase_data_pushed'] ) ) {
174+
return;
175+
}
176+
154177
$order_id = absint( $order_id );
155178
if ( $order_id <= 0 ) {
156179
return;

tests/unit/Modules/PurchaseTrackingTest.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,64 @@ public function test_remember_order_seeds_a_trackable_order_into_the_session():
395395
$this->assertSame( 1001, $session->sets[ ProductData::PENDING_PURCHASE_SESSION_KEY ] ?? null, 'A placed, trackable order must be remembered for the next-page fallback.' );
396396
}
397397

398+
/**
399+
* Double-purchase regression: the standard order-received render consumes the
400+
* pending-purchase marker while emitting the purchase inline (wp_head), and
401+
* woocommerce_thankyou fires remember_order() later in the same request (the
402+
* template body). Re-seeding there re-armed the reliable-purchase fallback for
403+
* the very order the page already tracked, and the session endpoint then
404+
* delivered the same purchase a second time after page load whenever the
405+
* "Do not flag orders as being tracked" option had disabled the tracked-order
406+
* suppressors. A request that already pushed the purchase must never seed.
407+
*/
408+
public function test_remember_order_skips_when_the_purchase_was_already_pushed_this_request(): void {
409+
$GLOBALS['gtm4wp_woocommerce_purchase_data_pushed'] = true;
410+
411+
$session = $this->stub_wc_with_session();
412+
Functions\when( 'wc_get_order' )->justReturn( $this->make_order() );
413+
414+
$this->make_tracking()->remember_order( 1001 );
415+
416+
$this->assertArrayNotHasKey( ProductData::PENDING_PURCHASE_SESSION_KEY, $session->sets, 'A request that already emitted the purchase inline must not re-arm the reliable-purchase fallback.' );
417+
}
418+
419+
/**
420+
* The customized-page half of the same regression: on_thankyou() pushes the
421+
* purchase inline, and remember_order() runs on the SAME woocommerce_thankyou
422+
* hook right after it. The push must raise the request-scoped flag so the
423+
* sequence cannot seed the fallback for the order it just tracked.
424+
*/
425+
public function test_on_thankyou_then_remember_order_does_not_seed_the_fallback(): void {
426+
$session = $this->stub_wc_with_session();
427+
$order = $this->make_order();
428+
Functions\when( 'wc_get_order' )->justReturn( $order );
429+
430+
$tracking = $this->make_tracking( array( GTM4WP_OPTION_INTEGRATE_WCTRACKECOMMERCE => true ) );
431+
432+
ob_start();
433+
$tracking->on_thankyou( 1001 );
434+
$output = (string) ob_get_clean();
435+
436+
$this->assertStringContainsString( '"event":"purchase"', $output, 'Precondition: the thank-you fallback must have pushed the purchase.' );
437+
438+
$tracking->remember_order( 1001 );
439+
440+
$this->assertArrayNotHasKey( ProductData::PENDING_PURCHASE_SESSION_KEY, $session->sets, 'The thank-you render that pushed the purchase must not re-arm the fallback on the same hook.' );
441+
}
442+
443+
/**
444+
* The negative branch: when on_thankyou() bails without pushing (here: the
445+
* eligibility gauntlet rejects an already-tracked order), the flag must stay
446+
* down so the checkout-time seed hooks keep working unchanged.
447+
*/
448+
public function test_on_thankyou_leaves_the_flag_down_when_nothing_was_pushed(): void {
449+
$order = $this->make_order( array( 'meta' => array( '_ga_tracked' => 1 ) ) );
450+
451+
$this->run_thankyou( array( GTM4WP_OPTION_INTEGRATE_WCTRACKECOMMERCE => true ), $order );
452+
453+
$this->assertArrayNotHasKey( 'gtm4wp_woocommerce_purchase_data_pushed', $GLOBALS, 'A bailed thank-you fallback must not claim the purchase was pushed.' );
454+
}
455+
398456
public function test_remember_order_skips_a_non_trackable_status(): void {
399457
$session = $this->stub_wc_with_session();
400458
Functions\when( 'wc_get_order' )->justReturn( $this->make_order( array( 'status' => 'pending' ) ) );

0 commit comments

Comments
 (0)