Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/apps/asaas/src/asaas-create-transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
84 changes: 72 additions & 12 deletions packages/ssr/src/lib/analytics/send-to-awin.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand All @@ -41,19 +70,50 @@ const sendToAwin = async ({
const purchaseEvents = events.filter((ev) => ev.name === 'purchase');
if (!purchaseEvents.length) return;
const awinOrders: Array<Record<string, any>> = [];
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<string, any> = {
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<string, any>) => ({
id: stripPipes(item.object_id || item.item_id),
Expand All @@ -66,7 +126,7 @@ const sendToAwin = async ({
})),
};
awinOrders.push(awinOrder);
});
}
if (awinOrders.length) {
const data = { orders: awinOrders };
if (DEBUG_SERVER_ANALYTICS?.toLowerCase() === 'true') {
Expand Down
1 change: 1 addition & 0 deletions packages/storefront/client.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>;
CMS_SSO_URL?: string | null;
CMS_REPO_BASE_DIR?: string;
Expand Down
7 changes: 6 additions & 1 deletion packages/storefront/src/analytics/set-tracking-ids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand Down
1 change: 1 addition & 0 deletions packages/storefront/src/lib/layouts/BaseHead.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
24 changes: 19 additions & 5 deletions packages/storefront/src/lib/scripts/vbeta-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading