From b8f4b7a03ca6f0b796069b110157cf5a4e916e99 Mon Sep 17 00:00:00 2001 From: Vitor Date: Tue, 8 Sep 2026 18:13:43 -0300 Subject: [PATCH] fix(analytics): Attribute Awin affiliate sales even when the server conversion fails Send the captured click id (`awc`) as `cks` on the `sread.img` fallback pixel, so a sale that misses the S2S call is still attributed deterministically instead of relying on Awin third-party cookies. Awin confirmed `sread.img` accepts `cks` the same way `sread.php` does. Add an opt-in test session, marked with `?awin_testmode=1` and kept on sessionStorage: the pixel then fires with `testmode=1` and the server skips the S2S conversion, which has no test flag on the REST API and would otherwise register a real order. On such a session the pixel always fires, as it becomes the only tracking channel. Co-Authored-By: Claude Opus 5 (1M context) --- .../ssr/src/lib/analytics/send-analytics-events.ts | 1 + packages/ssr/src/lib/analytics/send-to-awin.ts | 8 ++++++++ .../storefront/src/analytics/set-tracking-ids.ts | 11 +++++++++++ packages/storefront/src/lib/scripts/vbeta-app.ts | 13 +++++++++---- 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/ssr/src/lib/analytics/send-analytics-events.ts b/packages/ssr/src/lib/analytics/send-analytics-events.ts index f05b84017..72d16c27b 100644 --- a/packages/ssr/src/lib/analytics/send-analytics-events.ts +++ b/packages/ssr/src/lib/analytics/send-analytics-events.ts @@ -86,6 +86,7 @@ export const sendAnalyticsEvents = async ( events: gaEvents, awc: payload.awc, channel: payload.awin_channel, + isTestmode: payload.awin_testmode === '1', })); } if (metaEvents) { diff --git a/packages/ssr/src/lib/analytics/send-to-awin.ts b/packages/ssr/src/lib/analytics/send-to-awin.ts index 606cb958d..c32dd3420 100644 --- a/packages/ssr/src/lib/analytics/send-to-awin.ts +++ b/packages/ssr/src/lib/analytics/send-to-awin.ts @@ -61,12 +61,20 @@ const sendToAwin = async ({ events, awc, channel = 'aw', + isTestmode, }: { events: AnalyticsEvent[], awc?: string, channel?: string, + isTestmode?: boolean, }) => { if (!awinAxios || !awc) return; + if (isTestmode) { + // This API has no test flag, any order posted here counts as a real + // conversion, so a test session must be left to the fallback pixel + logger.info('Skipping Awin S2S conversion on test mode session'); + return; + } const purchaseEvents = events.filter((ev) => ev.name === 'purchase'); if (!purchaseEvents.length) return; const awinOrders: Array> = []; diff --git a/packages/storefront/src/analytics/set-tracking-ids.ts b/packages/storefront/src/analytics/set-tracking-ids.ts index 19c0fa4ed..5cd3d5e0b 100644 --- a/packages/storefront/src/analytics/set-tracking-ids.ts +++ b/packages/storefront/src/analytics/set-tracking-ids.ts @@ -9,6 +9,7 @@ export type TrackingIds = { ttclid?: string, awc?: string, awin_channel?: string, + awin_testmode?: '1', client_id?: string, session_id?: string, ip6?: string, @@ -64,6 +65,16 @@ export const getTrackingIds = ( } trackingIds[key] = value; }); + // The Awin conversion REST API has no test flag, every order it receives is + // real, so test transactions are marked on the session with + // `?awin_testmode=1`: the fallback pixel then fires with `testmode=1` and the + // server skips the S2S call, leaving the pixel as the only channel + if (url.searchParams.get('awin_testmode') === '1') { + sessionStorage.setItem('analytics_awin_testmode', '1'); + } + if (sessionStorage.getItem('analytics_awin_testmode') === '1') { + trackingIds.awin_testmode = '1'; + } const cookieNames = ['_fbp', 'AwinChannelCookie']; if (!trackingIds.fbc) cookieNames.push('_fbc'); if (!trackingIds.g_client_id) cookieNames.push('_ga'); diff --git a/packages/storefront/src/lib/scripts/vbeta-app.ts b/packages/storefront/src/lib/scripts/vbeta-app.ts index d01e1f6ae..239b149c0 100644 --- a/packages/storefront/src/lib/scripts/vbeta-app.ts +++ b/packages/storefront/src/lib/scripts/vbeta-app.ts @@ -40,7 +40,10 @@ const emitAwinFallbackPixel = (orderRef: string, amount: number, coupon?: string + `&parts=${encodeURIComponent(`DEFAULT:${amount}`)}` + `&vc=${encodeURIComponent(coupon || '')}` + `&ch=${encodeURIComponent(trackingIds.awin_channel || 'aw')}` - + '&testmode=0'; + // Attributes the sale from the click id instead of Awin's own cookies, + // keeping the fallback deterministic when the S2S call doesn't arrive + + `&cks=${encodeURIComponent(trackingIds.awc)}` + + `&testmode=${trackingIds.awin_testmode === '1' ? '1' : '0'}`; const img = new Image(0, 0); img.src = src; }; @@ -170,10 +173,12 @@ const watchAppRoutes = () => { // With the S2S call active the pixel only fires when the client // has the customer-facing number AND the real order amounts: a // pixel with mismatched ref or amount could win Awin's dedup by - // reference over the accurate S2S conversion. Without S2S the - // pixel is the only channel, so it always fires falling back to - // the internal order ID and cart subtotal (legacy behavior) + // reference over the accurate S2S conversion. Without S2S -- either + // unset on the store or skipped on a test session -- the pixel is + // the only channel, so it always fires falling back to the internal + // order ID and cart subtotal (legacy behavior) const canEmitAwinPixel = !window.AWIN_S2S_ENABLED + || trackingIds.awin_testmode === '1' || (!!params.order_number && params.shipping !== undefined); if (canEmitAwinPixel) { // Awin expects the commissionable amount without freight and taxes