diff --git a/DataLayer/Event/Purchase.php b/DataLayer/Event/Purchase.php index a6427a69..3b936c11 100644 --- a/DataLayer/Event/Purchase.php +++ b/DataLayer/Event/Purchase.php @@ -9,6 +9,7 @@ use Tagging\GTM\Api\NewCustomerResolverInterface; use Tagging\GTM\Config\Config; use Tagging\GTM\DataLayer\Tag\Order\OrderItems; +use Tagging\GTM\Util\EventIdGenerator; use Tagging\GTM\Util\PriceFormatter; class Purchase implements EventInterface @@ -18,17 +19,20 @@ class Purchase implements EventInterface private Config $config; private PriceFormatter $priceFormatter; private NewCustomerResolverInterface $newCustomerResolver; + private EventIdGenerator $eventIdGenerator; public function __construct( OrderItems $orderItems, Config $config, PriceFormatter $priceFormatter, - NewCustomerResolverInterface $newCustomerResolver + NewCustomerResolverInterface $newCustomerResolver, + EventIdGenerator $eventIdGenerator ) { $this->orderItems = $orderItems; $this->config = $config; $this->priceFormatter = $priceFormatter; $this->newCustomerResolver = $newCustomerResolver; + $this->eventIdGenerator = $eventIdGenerator; } /** @@ -39,6 +43,7 @@ public function get(): array $order = $this->order; return [ 'event' => 'trytagging_purchase', + 'event_id' => $this->eventIdGenerator->forOrder($order), 'ecommerce' => [ 'transaction_id' => $order->getIncrementId(), 'affiliation' => $this->config->getStoreName(), diff --git a/DataLayer/Event/PurchaseWebhookEvent.php b/DataLayer/Event/PurchaseWebhookEvent.php index 42e8a25b..6142ccca 100644 --- a/DataLayer/Event/PurchaseWebhookEvent.php +++ b/DataLayer/Event/PurchaseWebhookEvent.php @@ -9,6 +9,7 @@ use Tagging\GTM\Api\NewCustomerResolverInterface; use Tagging\GTM\DataLayer\Tag\Order\OrderItems; use Magento\Sales\Api\Data\OrderInterface; +use Tagging\GTM\Util\EventIdGenerator; use Tagging\GTM\Util\PriceFormatter; use Tagging\GTM\Config\Config; use Psr\Log\LoggerInterface; @@ -24,6 +25,7 @@ class PurchaseWebhookEvent private LoggerInterface $logger; private Debugger $debugger; private NewCustomerResolverInterface $newCustomerResolver; + private EventIdGenerator $eventIdGenerator; public function __construct( Json $json, @@ -33,7 +35,8 @@ public function __construct( PriceFormatter $priceFormatter, LoggerInterface $logger, Debugger $debugger, - NewCustomerResolverInterface $newCustomerResolver + NewCustomerResolverInterface $newCustomerResolver, + EventIdGenerator $eventIdGenerator ) { $this->json = $json; $this->clientFactory = $clientFactory; @@ -43,6 +46,7 @@ public function __construct( $this->logger = $logger; $this->debugger = $debugger; $this->newCustomerResolver = $newCustomerResolver; + $this->eventIdGenerator = $eventIdGenerator; } public function purchase(OrderInterface $order) @@ -87,6 +91,8 @@ public function purchase(OrderInterface $order) $data = [ 'event' => 'trytagging_purchase', + // Shared with the browser event so Meta can deduplicate browser and CAPI + 'event_id' => $this->eventIdGenerator->forOrder($order), 'marketing' => $marketingData, 'store_domain' => $this->config->getStoreDomain(), 'plugin_version' => $this->config->getVersion(), diff --git a/DataLayer/Tag/Order/EventId.php b/DataLayer/Tag/Order/EventId.php new file mode 100644 index 00000000..6c3707f2 --- /dev/null +++ b/DataLayer/Tag/Order/EventId.php @@ -0,0 +1,35 @@ +checkoutSession = $checkoutSession; + $this->eventIdGenerator = $eventIdGenerator; + } + + /** + * @return string + */ + public function get(): string + { + // The increment ID is already on the session order, so there is no need + // to load the order again through the repository + return $this->eventIdGenerator->forOrder($this->checkoutSession->getLastRealOrder()); + } +} diff --git a/USAGE.md b/USAGE.md index 797b16b8..27d21a13 100644 --- a/USAGE.md +++ b/USAGE.md @@ -11,6 +11,16 @@ The extension has the following configuration options: - **Debug**: Enable this for additional debugging in a logfile and the browser console. - **Choose script placement**: Setting this option to YES will remove the tracking script from the page. Only use this option if you want to choose where the script is placed in the page. This option is not recommended for most users. +# Purchase deduplication +Every purchase carries an `event_id` (`purchase.`) in the dataLayer event *and* in the +`order_created` webhook, with the exact same value on both sides. Map that variable onto the Event ID field of +your Meta tag in GTM: Meta deduplicates browser and Conversions API events on `event_id`, not on +`transaction_id`, so without it the same order is counted twice. + +The extension itself pushes a purchase only once per transaction. The transactions that were already pushed are +kept in `localStorage` under `tagging_gtm_pushed_transactions`, so a reload of the success page does not +retrigger the event. + # Tip: Browser extension Use the [DataLayer Checker](https://chrome.google.com/webstore/detail/datalayer-checker/ffljdddodmkedhkcjhpmdajhjdbkogke) for Chrome to diff --git a/Util/EventIdGenerator.php b/Util/EventIdGenerator.php new file mode 100644 index 00000000..73f64981 --- /dev/null +++ b/Util/EventIdGenerator.php @@ -0,0 +1,25 @@ +getIncrementId(); + } +} diff --git a/composer.json b/composer.json index 21b4288c..c29da440 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "tagginggroup/gtm", - "version": "1.7.0", + "version": "1.8.0", "license": "OSL-3.0", "type": "magento2-module", "description": "AdPage tagging integration for Magento 2", diff --git a/view/frontend/layout/checkout_onepage_success.xml b/view/frontend/layout/checkout_onepage_success.xml index d12697fb..515c8da5 100644 --- a/view/frontend/layout/checkout_onepage_success.xml +++ b/view/frontend/layout/checkout_onepage_success.xml @@ -20,6 +20,7 @@ trytagging_purchase + Tagging\GTM\DataLayer\Tag\Order\EventId Tagging\GTM\DataLayer\Tag\Order\Order Tagging\GTM\DataLayer\Tag\Order\OrderItems diff --git a/view/frontend/templates/hyva/script-pusher.phtml b/view/frontend/templates/hyva/script-pusher.phtml index aceb3c4e..e040ed29 100644 --- a/view/frontend/templates/hyva/script-pusher.phtml +++ b/view/frontend/templates/hyva/script-pusher.phtml @@ -11,6 +11,73 @@ declare(strict_types=1); */ ?> registerInlineScript() ?> \ No newline at end of file +registerInlineScript() ?> diff --git a/view/frontend/web/js/push.js b/view/frontend/web/js/push.js index 9e461a3c..4891e5ef 100644 --- a/view/frontend/web/js/push.js +++ b/view/frontend/web/js/push.js @@ -1,4 +1,78 @@ define(["googleTagManagerLogger"], function (logger) { + const PURCHASE_EVENT = "trytagging_purchase"; + const PUSHED_TRANSACTIONS_KEY = "tagging_gtm_pushed_transactions"; + const PUSHED_TRANSACTIONS_MAX = 50; + + /** + * Serialize with the object keys sorted, so that two events holding the same + * values hash to the same string even when the keys were assembled in a + * different order server-side. + */ + const stableStringify = function (value) { + if (Array.isArray(value)) { + return "[" + value.map(stableStringify).join(",") + "]"; + } + + if (value && typeof value === "object") { + return ( + "{" + + Object.keys(value) + .sort() + .map(function (key) { + return JSON.stringify(key) + ":" + stableStringify(value[key]); + }) + .join(",") + + "}" + ); + } + + const serialized = JSON.stringify(value); + + return typeof serialized === "undefined" ? "null" : serialized; + }; + + const getPushedTransactions = function () { + try { + const stored = JSON.parse( + window.localStorage.getItem(PUSHED_TRANSACTIONS_KEY) || "[]" + ); + + return Array.isArray(stored) ? stored : []; + } catch (error) { + // No storage available, fall back to the in-memory guard only + return []; + } + }; + + const rememberTransaction = function (transactionId) { + try { + const transactions = getPushedTransactions(); + transactions.push(transactionId); + + window.localStorage.setItem( + PUSHED_TRANSACTIONS_KEY, + JSON.stringify(transactions.slice(-PUSHED_TRANSACTIONS_MAX)) + ); + } catch (error) { + // No storage available, fall back to the in-memory guard only + } + }; + + /** + * The transaction a purchase event belongs to, or null for any other event. + * The in-memory guard is reset on every page load, so purchases are also + * deduplicated on their transaction to survive a reload of the success page. + */ + const getTransactionId = function (eventData) { + if (eventData.event !== PURCHASE_EVENT || !eventData.ecommerce) { + return null; + } + + const transactionId = eventData.ecommerce.transaction_id; + + return transactionId ? String(transactionId) : null; + }; + return function (eventData, message) { window.Tagging_GTM_PAST_EVENTS = window.Tagging_GTM_PAST_EVENTS || []; @@ -30,8 +104,20 @@ define(["googleTagManagerLogger"], function (logger) { return; } + // Prevent the same purchase from being triggered twice, across page loads + const transactionId = getTransactionId(cleanEventData); + if (transactionId && getPushedTransactions().includes(transactionId)) { + logger( + 'Warning: Purchase already triggered for transaction "' + + transactionId + + '"', + eventData + ); + return; + } + // Prevent the same event from being triggered twice, when containing the same data - const eventHash = btoa(encodeURIComponent(JSON.stringify(cleanEventData))); + const eventHash = btoa(encodeURIComponent(stableStringify(cleanEventData))); if (window.Tagging_GTM_PAST_EVENTS.includes(eventHash)) { logger("Warning: Event already triggered", eventData); return; @@ -80,5 +166,9 @@ define(["googleTagManagerLogger"], function (logger) { window.dataLayer.push(cleanEventData); window.Tagging_GTM_PAST_EVENTS.push(eventHash); + + if (transactionId) { + rememberTransaction(transactionId); + } }; });