___TERMS_OF_SERVICE___ By creating or modifying this file you agree to Google Tag Manager's Community Template Gallery Developer Terms of Service available at https://developers.google.com/tag-manager/gallery-tos (or such other URL as Google may provide), as modified from time to time. ___INFO___ { "type": "MACRO", "id": "cvt_temp_public_id", "version": 1, "securityGroups": [], "displayName": "Duplicate Purchase Guard", "categories": [ "ANALYTICS", "CONVERSIONS", "UTILITY" ], "brand": { "id": "brand_dummy", "displayName": "Metric Owl", "thumbnail": "data:image/png;base64,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" }, "description": "Returns true when a transaction ID was already seen on an earlier page view in this browser, so a refreshed or revisited thank-you page doesn't count the sale twice in GA4, Google Ads or Meta. Add it as a trigger condition on your purchase tags: duplicate equals false.", "containerContexts": [ "WEB" ] } ___TEMPLATE_PARAMETERS___ [ { "type": "TEXT", "name": "transactionId", "displayName": "Transaction ID", "simpleValueType": true, "help": "The order's unique ID, for example a Data Layer Variable for ecommerce.transaction_id.", "valueValidators": [ { "type": "NON_EMPTY" } ] }, { "type": "TEXT", "name": "days", "displayName": "Remember transaction IDs for (days)", "simpleValueType": true, "help": "How long an ID counts as already sent. 30 days suits most shops.", "defaultValue": "30", "valueValidators": [ { "type": "POSITIVE_NUMBER" } ] } ] ___SANDBOXED_JS_FOR_WEB_TEMPLATE___ const localStorage = require('localStorage'); const JSON = require('JSON'); const Object = require('Object'); const copyFromWindow = require('copyFromWindow'); const setInWindow = require('setInWindow'); const generateRandom = require('generateRandom'); const getTimestampMillis = require('getTimestampMillis'); const makeString = require('makeString'); const makeNumber = require('makeNumber'); const getType = require('getType'); // The sandbox doesn't guarantee String.trim, so this removes spaces, tabs and carriage returns. const trim = function (value) { const str = makeString(value); let start = 0; let end = str.length; while (start < end && ' \t\r'.indexOf(str.charAt(start)) > -1) start++; while (end > start && ' \t\r'.indexOf(str.charAt(end - 1)) > -1) end--; return str.slice(start, end); }; const STORE = 'mo_purchase_guard'; const PAGE = 'mo_purchase_guard_page'; const MAX_IDS = 100; const id = trim(data.transactionId || ''); if (!id || id === 'undefined' || id === 'null') return false; // One ID per page view. A transaction first seen on this page view is never a duplicate here, // however many times or on whichever dataLayer message Tag Manager evaluates the variable. let pageId = copyFromWindow(PAGE); if (!pageId) { pageId = makeString(getTimestampMillis()) + '.' + makeString(generateRandom(100000, 999999)); setInWindow(PAGE, pageId, true); } const now = getTimestampMillis(); const days = makeNumber(data.days); const ttl = (days > 0 ? days : 30) * 86400000; let seen = JSON.parse(localStorage.getItem(STORE) || '{}'); if (getType(seen) !== 'object') seen = {}; const entry = seen[id]; if (entry && getType(entry.t) === 'number' && now - entry.t < ttl) return entry.p !== pageId; // First time this ID has been seen: remember it, keeping the newest MAX_IDS that haven't expired. seen[id] = {t: now, p: pageId}; const ids = []; const keys = Object.keys(seen); for (let i = 0; i < keys.length; i++) { const item = seen[keys[i]]; if (item && getType(item.t) === 'number' && now - item.t < ttl) ids.push(keys[i]); } const kept = {}; let count = 0; // Newest first, without relying on Array.sort. while (count < MAX_IDS && ids.length > 0) { let newest = 0; for (let i = 1; i < ids.length; i++) if (seen[ids[i]].t > seen[ids[newest]].t) newest = i; kept[ids[newest]] = seen[ids[newest]]; ids.splice(newest, 1); count++; } localStorage.setItem(STORE, JSON.stringify(kept)); return false; ___WEB_PERMISSIONS___ [ { "instance": { "key": { "publicId": "access_local_storage", "versionId": "1" }, "param": [ { "key": "keys", "value": { "type": 2, "listItem": [ { "type": 3, "mapKey": [ { "type": 1, "string": "key" }, { "type": 1, "string": "read" }, { "type": 1, "string": "write" } ], "mapValue": [ { "type": 1, "string": "mo_purchase_guard" }, { "type": 8, "boolean": true }, { "type": 8, "boolean": true } ] } ] } } ] }, "clientAnnotations": { "isEditedByUser": true }, "isRequired": true }, { "instance": { "key": { "publicId": "access_globals", "versionId": "1" }, "param": [ { "key": "keys", "value": { "type": 2, "listItem": [ { "type": 3, "mapKey": [ { "type": 1, "string": "key" }, { "type": 1, "string": "read" }, { "type": 1, "string": "write" }, { "type": 1, "string": "execute" } ], "mapValue": [ { "type": 1, "string": "mo_purchase_guard_page" }, { "type": 8, "boolean": true }, { "type": 8, "boolean": true }, { "type": 8, "boolean": false } ] } ] } } ] }, "clientAnnotations": { "isEditedByUser": true }, "isRequired": true } ] ___TESTS___ setup: |- const store = {}; const win = {}; mockObject('localStorage', {getItem: function (k) { return store[k] || null; }, setItem: function (k, v) { store[k] = v; }}); mock('copyFromWindow', function (k) { return win[k]; }); mock('setInWindow', function (k, v) { win[k] = v; return true; }); scenarios: - name: A new transaction isn't a duplicate code: |- assertThat(runCode({transactionId: 'T1', days: '30'})).isFalse(); - name: The same page view never counts as a duplicate code: |- assertThat(runCode({transactionId: 'T1', days: '30'})).isFalse(); assertThat(runCode({transactionId: 'T1', days: '30'})).isFalse(); assertThat(runCode({transactionId: 'T1', days: '30'})).isFalse(); - name: A refreshed thank-you page is a duplicate code: |- assertThat(runCode({transactionId: 'T1', days: '30'})).isFalse(); win.mo_purchase_guard_page = undefined; assertThat(runCode({transactionId: 'T1', days: '30'})).isTrue(); assertThat(runCode({transactionId: 'T1', days: '30'})).isTrue(); - name: A different transaction isn't a duplicate code: |- assertThat(runCode({transactionId: 'T1', days: '30'})).isFalse(); win.mo_purchase_guard_page = undefined; assertThat(runCode({transactionId: 'T3', days: '30'})).isFalse(); - name: Expired IDs count as new code: |- let now = 1000000000000; mock('getTimestampMillis', function () { return now; }); assertThat(runCode({transactionId: 'T4', days: '1'})).isFalse(); win.mo_purchase_guard_page = undefined; now = now + 2 * 86400000; assertThat(runCode({transactionId: 'T4', days: '1'})).isFalse(); - name: No transaction ID returns false and stores nothing code: |- assertThat(runCode({transactionId: '', days: '30'})).isFalse(); assertThat(runCode({transactionId: undefined, days: '30'})).isFalse(); assertThat(store.mo_purchase_guard).isUndefined(); - name: Storage keeps only the newest 100 IDs code: |- let now = 1000000000000; mock('getTimestampMillis', function () { return now; }); for (let i = 0; i < 105; i++) { now = now + 1000; runCode({transactionId: 'X' + i, days: '30'}); } const saved = store.mo_purchase_guard; assertThat(saved.split('"t":').length - 1).isEqualTo(100); assertThat(saved.indexOf('"X0"')).isEqualTo(-1); assertThat(saved.indexOf('"X104"') > -1).isTrue(); - name: Corrupt storage is ignored code: |- store.mo_purchase_guard = 'not json'; assertThat(runCode({transactionId: 'T5', days: '30'})).isFalse(); ___NOTES___ Built and maintained by Marc Alexander, Metric Owl Ltd (https://www.metricowl.co.uk). Licensed under the Apache License 2.0.