/** * ExpertVoice Order Reporter — GTM Server-Side Custom Tag Template * * ─── WHAT YOU'LL NEED BEFORE STARTING ──────────────────────────────────────── * This template assumes your checkout sends the standard GA4 e-commerce * "purchase" event shape: * transaction_id, value, tax, shipping, currency, coupon, * items: [{ item_id, item_name, price, quantity }, ...] * If your checkout instead pushes a custom dataLayer event with different * field names, the getEventData() key paths below will need to be adjusted * to match. Check your container's Preview mode to confirm your event's * real field names before wiring this up. * * ─── SETUP INSTRUCTIONS ────────────────────────────────────────────────────── * * STEP 1 — CONFIGURE * Fill in REPORTING_ID and (optionally) PREFIXES in the CODE section below * before creating the template. Look up your Reporting ID at: * https://advocacy.expertvoice.com/app/configure#pixel * * STEP 2 — CREATE THE CUSTOM TAG TEMPLATE * In your GTM Server-Side container: Templates → New (under "Tag * Templates") → this opens the Template Editor with four tabs: * Info, Fields, Code, Permissions. * * STEP 3 — INFO TAB * Name: "ExpertVoice Order Reporter" * (Fields tab can be left empty — this template has no configurable UI * fields; everything needed is hardcoded in the CODE section, so the * template stays fully self-contained.) * * STEP 4 — CODE TAB * Paste the contents of the CODE section below. * * STEP 5 — PERMISSIONS TAB * GTM will prompt for the required permissions automatically the first * time you click "Save" — accept them. You should see: * - send_http: scoped to expertvoice.com * - read_event_data * - logToConsole (for debugging only; safe to leave enabled) * * STEP 6 — CREATE THE TAG * Tags → New → choose the "ExpertVoice Order Reporter" custom template * you just created as the tag type. * Trigger: fire on your purchase/order-completed event trigger (whatever * trigger fires for a completed order in your server container). * * STEP 7 — TEST * Use GTM Server-Side's Preview mode, complete a test order, and confirm * in Preview that this tag fired successfully (green checkmark). Once * confirmed, let your ExpertVoice contact know so we can verify the order * was received correctly on our end. */ /* ═══════════════════════════════════════════════════════════════════════════ CODE — paste into the "Code" tab of the Template Editor ═══════════════════════════════════════════════════════════════════════════ */ const getEventData = require('getEventData'); const sendHttpRequest = require('sendHttpRequest'); const JSON = require('JSON'); const logToConsole = require('logToConsole'); const makeString = require('makeString'); // ─── Configuration ──────────────────────────────────────────────────────────── // Enter your ExpertVoice Reporting ID // (format: exp-123-12345 — look it up at // https://advocacy.expertvoice.com/app/configure#pixel) const REPORTING_ID = ''; // OPTIONAL. Comma-separated list of coupon code prefixes (case-insensitive) // used to filter which orders get sent. Leaving this as an empty array // sends ALL orders to ExpertVoice — that's safe to do either way, since // ExpertVoice only stores/counts orders whose code matches one it actually // issued. Filtering here just avoids sending noise for orders that would // be discarded on our end anyway. Example: const PREFIXES = ['EV-', 'EXP-']; const PREFIXES = []; // ─── Helpers ───────────────────────────────────────────────────────────────── function hasMatchingPrefix(code, prefixes) { if (prefixes.length === 0) return true; if (!code) return false; const lowerCode = code.toLowerCase(); for (let i = 0; i < prefixes.length; i++) { if (lowerCode.indexOf(prefixes[i].toLowerCase()) === 0) return true; } return false; } // ─── Main ───────────────────────────────────────────────────────────────────── const idParts = REPORTING_ID.split('-'); const PIXEL_ID = idParts[1]; const ORG_ID = idParts[2]; const couponCode = getEventData('coupon') || ''; if (!hasMatchingPrefix(couponCode, PREFIXES)) { logToConsole('ExpertVoice: no matching prefix, order not reported.'); data.gtmOnSuccess(); } else { const items = getEventData('items') || []; const products = items.map(function(item) { return { id: makeString(item.item_id || ''), name: item.item_name || '', sku: makeString(item.item_id || ''), upc: '', price: makeString(item.price || ''), quantity: item.quantity || 1 }; }); const tax = getEventData('tax') || 0; const shipping = getEventData('shipping') || 0; // Some GA4 implementations report "value" as the grand total, already // including shipping (rather than a pre-shipping subtotal). Back shipping // out here so "subtotal" reflects the post-discount, pre-tax/shipping // amount actually charged. If your dataLayer's "value" already excludes // shipping, remove the "- shipping" below. const subtotal = (getEventData('value') || 0) - shipping; // msrp_subtotal (pre-discount) is derived by summing the line items // directly, since GA4's standard purchase event doesn't include a // separate pre-discount subtotal field. discount is then whatever // difference exists between that sum and the actual post-discount // "subtotal" above. Clamped at 0 so rounding noise or an items/value // mismatch never produces a negative discount. const itemsTotal = items.reduce(function(sum, item) { return sum + (Number(item.price) || 0) * (item.quantity || 1); }, 0); const discount = Math.max(0, itemsTotal - subtotal); const payload = { action: 'REDIRECT-ORDERPLACED', appName: 'external-redirect', data: { custom: {}, eventDate: makeString(Date.now()), order: { code: [couponCode], currency: getEventData('currency') || 'USD', discount: makeString(discount.toFixed(2)), id: makeString(getEventData('transaction_id') || ''), msrp_subtotal: makeString(itemsTotal.toFixed(2)), shipping_address: { city: '', country: '', postal_code: '', state: '' }, subtotal: makeString(subtotal.toFixed(2)), total: makeString((subtotal + tax + shipping).toFixed(2)), tax: makeString(tax.toFixed(2)), shipping: makeString(shipping.toFixed(2)) }, orgId: ORG_ID, pageCategory: 'order-app-gtm', pixelId: PIXEL_ID, products: products, version: '3.0' }, mfgId: ORG_ID, referrer: null, url: 'gtm-server-side', userAgent: 'gtm-server-side', version: 1 }; sendHttpRequest( 'https://www.expertvoice.com/pixel-gateway/ext/1.0/gateway', { method: 'POST', headers: { 'Content-Type': 'application/json; charset=UTF-8', 'Accept': 'application/json', 'x-codingscape-dev': '1' }, timeout: 5000 }, JSON.stringify(payload) ).then((result) => { if (result.statusCode >= 200 && result.statusCode < 300) { data.gtmOnSuccess(); } else { logToConsole('ExpertVoice: non-2xx response', result.statusCode); data.gtmOnFailure(); } }).catch(() => { logToConsole('ExpertVoice: request failed or timed out'); data.gtmOnFailure(); }); } /* ═══════════════════════════════════════════════════════════════════════════ NOTES ON FIELD MAPPING / GAPS ═══════════════════════════════════════════════════════════════════════════ - shipping_address is left blank — GA4's standard purchase event schema does not carry a shipping address. If your dataLayer includes one under a custom key, add it via additional getEventData() calls. - discount is derived, not read directly — GA4's standard schema doesn't include a distinct discount field, so it's calculated as (sum of line items) - (post-shipping-adjusted "value"). This is an approximation: it will also capture any other gap between your items array and your "value" field (e.g. a line item that isn't reflected in "value" for some other reason), not strictly promo codes only. If your dataLayer has a real discount field, prefer wiring it in directly via getEventData('discount') instead. - id/sku both use item_id since GA4's schema doesn't have a distinct SKU field by default — adjust if your items array carries a separate sku property under a custom key. - Prefix filtering only applies to the order-level coupon field, not to individual line items. If you run into any issues, or your checkout event doesn't match the assumed schema above, contact your ExpertVoice partner representative — we're happy to help adjust the template for your specific setup. */