diff --git a/packages/apps/asaas/src/asaas-create-transaction.ts b/packages/apps/asaas/src/asaas-create-transaction.ts index de36e20e4..57fb62306 100644 --- a/packages/apps/asaas/src/asaas-create-transaction.ts +++ b/packages/apps/asaas/src/asaas-create-transaction.ts @@ -95,7 +95,10 @@ export default async (modBody: AppModuleBody<'create_transaction'>) => { 'externalReference': orderId, 'description': `Pedido ${orderNumber} ${domain}`, 'callback': domain - ? { 'successUrl': `https://${domain}/app/#/confirmation/${orderId}/` } + ? { + 'successUrl': `https://${domain}/app/#/confirmation/${orderId}` + + (orderNumber ? `/${orderNumber}` : ''), + } : undefined, }; if (paymentMethod.code === 'account_deposit') { diff --git a/packages/modules/src/firebase/functions-checkout/handle-order-transaction.ts b/packages/modules/src/firebase/functions-checkout/handle-order-transaction.ts index 3ff3525ab..5a5c5dd1b 100644 --- a/packages/modules/src/firebase/functions-checkout/handle-order-transaction.ts +++ b/packages/modules/src/firebase/functions-checkout/handle-order-transaction.ts @@ -13,17 +13,56 @@ const newOrder = async (orderBody: OrderSet) => { try { const orderId = (await api.post('orders', orderBody)).data._id; return new Promise<{ order: Orders | null, err?: any }>((resolve) => { - setTimeout(async () => { + let attempts = 0; + let lastOrderRead: Orders | null = null; + const readFullOrder = async () => { + attempts += 1; try { const { data: order } = await api.get(`orders/${orderId}`, { headers: { 'x-primary-db': 'true' }, + // Rereads are best-effort just for the order number, only the first + // read decides whether the checkout can go on, so it keeps the + // client defaults and the reread cost can't pile up on a slow API + ...(attempts > 1 ? { timeout: 1500, maxRetries: 0 } : null), }); + lastOrderRead = order; + // The order number is set asynchronously and may not be ready yet, + // without it the storefront can't reference the order for the customer + if (!order.number && attempts <= 3) { + setTimeout(readFullOrder, 400 * attempts); + return; + } + if (!order.number) { + logger.warn(`Order ${orderId} number still unset after ${attempts} reads`, { + orderId, + attempts, + }); + } else { + // Logged on every order to tell whether the rereads are the rule + // or the tail, the number of attempts must be tuned with data + logger.info(`Order ${orderId} number read on attempt ${attempts}`, { + orderId, + attempts, + }); + } resolve({ order }); } catch (err: any) { + // The order does exist, don't fail the checkout over a transient + // read error when a previous read already succeeded + if (lastOrderRead) { + logger.warn(`Failed rereading order ${orderId} on attempt ${attempts}`, { + orderId, + attempts, + err, + }); + resolve({ order: lastOrderRead }); + return; + } logger.error(err); resolve({ order: null, err }); } - }, 400); + }; + setTimeout(readFullOrder, 400); }); } catch (err: any) { if (err.message === 'fetch failed') { diff --git a/packages/ssr/src/lib/analytics/send-to-awin.ts b/packages/ssr/src/lib/analytics/send-to-awin.ts index 88eb57580..606cb958d 100644 --- a/packages/ssr/src/lib/analytics/send-to-awin.ts +++ b/packages/ssr/src/lib/analytics/send-to-awin.ts @@ -1,5 +1,7 @@ +import type { Orders } from '@cloudcommerce/types'; import type { AnalyticsEvent } from './send-analytics-events'; import config, { logger } from '@cloudcommerce/firebase/lib/config'; +import api from '@cloudcommerce/api'; import axios from 'axios'; // https://help.awin.com/apidocs/conversion-api @@ -18,16 +20,43 @@ const awinAxios = AWIN_ADVERTISER_ID && AWIN_API_KEY }) : null; -// Expected AwinChannelCookie values — Awin docs mandate 'aw' as the fallback -const validChannels = new Set([ - 'aw', 'ppcgeneric', 'ppcbrand', 'display', 'social', 'Other', 'Organic', 'direct', -]); +// Awin doesn't enumerate accepted channels, the value is free-form and must +// match the `ch` the fallback pixel sends for the same order reference, +// 'aw' is the mandated fallback +// https://help.awin.com/developers/docs/channel-parameter +const parseChannel = (channel: unknown) => { + if (typeof channel !== 'string') return 'aw'; + return channel.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 20) || 'aw'; +}; // Awin forbids the pipe character in id/name/sku/category basket fields const stripPipes = (value: unknown) => ( typeof value === 'string' ? value.replace(/\|/g, '') : value ); +// Customers coming back from external payment pages land on the confirmation +// route without the order number, so the purchase event may miss it +const fetchOrderFallback = async (orderId: string) => { + if (!/^[0-9a-f]{24}$/.test(orderId)) return null; + try { + const { data: order } = await api.get(`orders/${orderId as Orders['_id']}`, { + fields: ['number', 'amount', 'extra_discount'] as const, + headers: { 'x-primary-db': 'true' }, + // Keep this read within the SSR function time budget, it must not + // delay or starve the other analytics providers in the same batch + timeout: 3000, + maxRetries: 0, + }); + return order; + } catch (err: any) { + logger.warn(`Failed reading order ${orderId} for Awin conversion`, { + err, + status: err.statusCode, + }); + return null; + } +}; + const sendToAwin = async ({ events, awc, @@ -41,19 +70,50 @@ const sendToAwin = async ({ const purchaseEvents = events.filter((ev) => ev.name === 'purchase'); if (!purchaseEvents.length) return; const awinOrders: Array> = []; - purchaseEvents.forEach(({ params }) => { - if (!params?.transaction_id) return; - const voucher = params.coupon; + for (let i = 0; i < purchaseEvents.length; i++) { + const { params } = purchaseEvents[i]; + if (!params?.transaction_id) continue; + let orderNumber = params.order_number; + let voucher = params.coupon; + // Awin expects the commissionable amount without freight and taxes + let grossValue = Number(params.value) || 0; + let shipping = Number(params.shipping) || 0; + let tax = Number(params.tax) || 0; + // The client event may miss the order number (external payment redirects) + // and/or the exact amounts (no order body on the confirmation route, value + // falls back to cart subtotal), read the order whenever any is absent + if (!orderNumber || params.shipping === undefined) { + // eslint-disable-next-line no-await-in-loop + const order = await fetchOrderFallback(`${params.transaction_id}`); + if (order) { + if (order.number) orderNumber = order.number; + if (order.amount) { + grossValue = Number(order.amount.total) || grossValue; + shipping = Number(order.amount.freight) || 0; + tax = Number(order.amount.tax) || 0; + } + if (!voucher) voucher = order.extra_discount?.discount_coupon; + } + } + if (!orderNumber) { + logger.warn( + `Awin conversion falling back to internal ID for order ${params.transaction_id}`, + ); + } + const netAmount = Math.max( + Math.round((grossValue - shipping - tax) * 100) / 100, + 0, + ); const awinOrder: Record = { - orderReference: String(params.order_number || params.transaction_id), - channel: validChannels.has(channel) ? channel : 'aw', + orderReference: String(orderNumber || params.transaction_id), + channel: parseChannel(channel), awc, voucher, - amount: params.value, + amount: netAmount, currency: params.currency || config.get().currency, commissionGroups: [{ code: 'DEFAULT', - amount: params.value, + amount: netAmount, }], basket: params.items?.map((item: Record) => ({ id: stripPipes(item.object_id || item.item_id), @@ -66,7 +126,7 @@ const sendToAwin = async ({ })), }; awinOrders.push(awinOrder); - }); + } if (awinOrders.length) { const data = { orders: awinOrders }; if (DEBUG_SERVER_ANALYTICS?.toLowerCase() === 'true') { diff --git a/packages/storefront/client.d.ts b/packages/storefront/client.d.ts index f9285b264..6751d1956 100644 --- a/packages/storefront/client.d.ts +++ b/packages/storefront/client.d.ts @@ -35,6 +35,7 @@ interface Window { GA_TRACKING_ID?: string; GOOGLE_ADS_ID?: string; AWIN_ADVERTISER_ID?: string; + AWIN_S2S_ENABLED?: boolean; CMS_CUSTOM_CONFIG?: Record; CMS_SSO_URL?: string | null; CMS_REPO_BASE_DIR?: string; diff --git a/packages/storefront/src/analytics/set-tracking-ids.ts b/packages/storefront/src/analytics/set-tracking-ids.ts index 87f603f66..19c0fa4ed 100644 --- a/packages/storefront/src/analytics/set-tracking-ids.ts +++ b/packages/storefront/src/analytics/set-tracking-ids.ts @@ -76,7 +76,12 @@ export const getTrackingIds = ( switch (cookieName) { case '_fbp': trackingIds.fbp = value; return; case '_fbc': trackingIds.fbc = value; return; - case 'AwinChannelCookie': trackingIds.awin_channel = value; return; + case 'AwinChannelCookie': + // Normalized at the source so the fallback pixel and the server + // side conversion send the exact same channel for one order + trackingIds.awin_channel = value + .toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 20) || undefined; + return; default: } if (cookieName.startsWith('_ga')) { diff --git a/packages/storefront/src/lib/layouts/BaseHead.astro b/packages/storefront/src/lib/layouts/BaseHead.astro index e404500c9..9a2d15e7a 100644 --- a/packages/storefront/src/lib/layouts/BaseHead.astro +++ b/packages/storefront/src/lib/layouts/BaseHead.astro @@ -95,6 +95,7 @@ window.GIT_BRANCH = '${import.meta.env.GIT_BRANCH || ''}'; window.GIT_REPO = '${import.meta.env.GIT_REPO || import.meta.env.GITHUB_REPO || ''}'; window.GCLOUD_PROJECT = '${import.meta.env.GCLOUD_PROJECT || ''}'; window.AWIN_ADVERTISER_ID = '${import.meta.env.AWIN_ADVERTISER_ID || ''}'; +window.AWIN_S2S_ENABLED = ${!!(import.meta.env.AWIN_ADVERTISER_ID && import.meta.env.AWIN_API_KEY)}; window.$storefront = ${JSON.stringify({ settings, data: {} })};`; if (apiContext.error) { const { message, statusCode } = apiContext.error; diff --git a/packages/storefront/src/lib/scripts/vbeta-app.ts b/packages/storefront/src/lib/scripts/vbeta-app.ts index 0a113d5cd..d01e1f6ae 100644 --- a/packages/storefront/src/lib/scripts/vbeta-app.ts +++ b/packages/storefront/src/lib/scripts/vbeta-app.ts @@ -167,11 +167,25 @@ const watchAppRoutes = () => { params.shipping_delivery_days = days; } emitGtagEvent('purchase', params, paramsToHash); - emitAwinFallbackPixel( - params.order_number ? String(params.order_number) : orderId, - params.value as number, - params.coupon, - ); + // 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) + const canEmitAwinPixel = !window.AWIN_S2S_ENABLED + || (!!params.order_number && params.shipping !== undefined); + if (canEmitAwinPixel) { + // Awin expects the commissionable amount without freight and taxes + const awinAmount = Math.max(fixMoneyValue( + Number(params.value) - (Number(params.shipping) || 0) - (Number(params.tax) || 0), + ), 0); + emitAwinFallbackPixel( + params.order_number ? String(params.order_number) : orderId, + awinAmount, + params.coupon, + ); + } localStorage.setItem('gtag.orderIdSent', orderId); } isPurchaseSent = true;