From 2f59cb8165a4f0bccf97bc77a66ee8a2cd27dd7a Mon Sep 17 00:00:00 2001 From: Vitor Rocha goncalves Date: Fri, 14 Aug 2026 09:43:16 -0300 Subject: [PATCH 1/6] fix(analytics): Send order amount to Awin without freight and taxes Awin commission must be calculated over the order subtotal only, but we were sending the full order total (with freight) on both the server-side conversion API call and the fallback pixel. - Deduct params.shipping and params.tax from params.value on sendToAwin (orders amount and commission group amount) - Same deduction for the fallback conversion pixel amount - Resulting value equals subtotal minus discounts, per affiliate network convention Co-Authored-By: Claude Opus 5 --- packages/ssr/src/lib/analytics/send-to-awin.ts | 12 ++++++++++-- packages/storefront/src/lib/scripts/vbeta-app.ts | 6 +++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/ssr/src/lib/analytics/send-to-awin.ts b/packages/ssr/src/lib/analytics/send-to-awin.ts index 88eb57580..91517d8b4 100644 --- a/packages/ssr/src/lib/analytics/send-to-awin.ts +++ b/packages/ssr/src/lib/analytics/send-to-awin.ts @@ -44,16 +44,24 @@ const sendToAwin = async ({ purchaseEvents.forEach(({ params }) => { if (!params?.transaction_id) return; const voucher = params.coupon; + // Awin expects the commissionable amount without freight and taxes + const grossValue = Number(params.value) || 0; + const netAmount = Math.max( + Math.round( + (grossValue - (Number(params.shipping) || 0) - (Number(params.tax) || 0)) * 100, + ) / 100, + 0, + ); const awinOrder: Record = { orderReference: String(params.order_number || params.transaction_id), channel: validChannels.has(channel) ? channel : 'aw', 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), diff --git a/packages/storefront/src/lib/scripts/vbeta-app.ts b/packages/storefront/src/lib/scripts/vbeta-app.ts index 0a113d5cd..3560a8a80 100644 --- a/packages/storefront/src/lib/scripts/vbeta-app.ts +++ b/packages/storefront/src/lib/scripts/vbeta-app.ts @@ -167,9 +167,13 @@ const watchAppRoutes = () => { params.shipping_delivery_days = days; } emitGtagEvent('purchase', params, paramsToHash); + // Awin expects the commissionable amount without freight and taxes + const awinAmount = Math.max(fixMoneyValue( + (params.value as number) - (params.shipping || 0) - (params.tax || 0), + ), 0); emitAwinFallbackPixel( params.order_number ? String(params.order_number) : orderId, - params.value as number, + awinAmount, params.coupon, ); localStorage.setItem('gtag.orderIdSent', orderId); From c8d911f3b95d8e98d07238b317b3c4cd7bca1b33 Mon Sep 17 00:00:00 2001 From: Vitor Rocha goncalves Date: Fri, 14 Aug 2026 11:30:20 -0300 Subject: [PATCH 2/6] fix(analytics): Ensure Awin gets customer-facing order number after payment redirects Customers returning from external payment pages land on the confirmation route without the order number, so the purchase event reached Awin with the internal order ID as reference. Now the SSR analytics handler reads the order from the API to resolve the customer-facing number (and exact amounts) whenever the client event misses it, the client fallback pixel is skipped without the number to avoid double-counting with a mismatched reference, and the Asaas success URL carries the order number. Co-Authored-By: Claude Fable 5 --- .../asaas/src/asaas-create-transaction.ts | 2 +- .../ssr/src/lib/analytics/send-to-awin.ts | 53 +++++++++++++++---- .../storefront/src/lib/scripts/vbeta-app.ts | 22 ++++---- 3 files changed, 58 insertions(+), 19 deletions(-) diff --git a/packages/apps/asaas/src/asaas-create-transaction.ts b/packages/apps/asaas/src/asaas-create-transaction.ts index de36e20e4..894638a8c 100644 --- a/packages/apps/asaas/src/asaas-create-transaction.ts +++ b/packages/apps/asaas/src/asaas-create-transaction.ts @@ -95,7 +95,7 @@ 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}` } : undefined, }; if (paymentMethod.code === 'account_deposit') { diff --git a/packages/ssr/src/lib/analytics/send-to-awin.ts b/packages/ssr/src/lib/analytics/send-to-awin.ts index 91517d8b4..3f3783687 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 @@ -28,6 +30,23 @@ 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, + }); + return order; + } catch (err: any) { + logger.warn(`Failed reading order ${orderId} for Awin reference`, { + status: err.statusCode || err.response?.status, + }); + return null; + } +}; + const sendToAwin = async ({ events, awc, @@ -41,19 +60,35 @@ 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]; + // eslint-disable-next-line no-continue + if (!params?.transaction_id) continue; + let orderNumber = params.order_number; + let voucher = params.coupon; // Awin expects the commissionable amount without freight and taxes - const grossValue = Number(params.value) || 0; + let grossValue = Number(params.value) || 0; + let shipping = Number(params.shipping) || 0; + let tax = Number(params.tax) || 0; + if (!orderNumber) { + // eslint-disable-next-line no-await-in-loop + const order = await fetchOrderFallback(`${params.transaction_id}`); + if (order) { + 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; + } + } const netAmount = Math.max( - Math.round( - (grossValue - (Number(params.shipping) || 0) - (Number(params.tax) || 0)) * 100, - ) / 100, + Math.round((grossValue - shipping - tax) * 100) / 100, 0, ); const awinOrder: Record = { - orderReference: String(params.order_number || params.transaction_id), + orderReference: String(orderNumber || params.transaction_id), channel: validChannels.has(channel) ? channel : 'aw', awc, voucher, @@ -74,7 +109,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/src/lib/scripts/vbeta-app.ts b/packages/storefront/src/lib/scripts/vbeta-app.ts index 3560a8a80..b83085c53 100644 --- a/packages/storefront/src/lib/scripts/vbeta-app.ts +++ b/packages/storefront/src/lib/scripts/vbeta-app.ts @@ -167,15 +167,19 @@ const watchAppRoutes = () => { params.shipping_delivery_days = days; } emitGtagEvent('purchase', params, paramsToHash); - // Awin expects the commissionable amount without freight and taxes - const awinAmount = Math.max(fixMoneyValue( - (params.value as number) - (params.shipping || 0) - (params.tax || 0), - ), 0); - emitAwinFallbackPixel( - params.order_number ? String(params.order_number) : orderId, - awinAmount, - params.coupon, - ); + // Skip the pixel without the customer-facing number: the S2S call resolves + // it from the API, and a pixel with a mismatched ref would double-count + if (params.order_number) { + // 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( + String(params.order_number), + awinAmount, + params.coupon, + ); + } localStorage.setItem('gtag.orderIdSent', orderId); } isPurchaseSent = true; From 38ae0aec3a94599c48b473dbc13cec3d4351cc6b Mon Sep 17 00:00:00 2001 From: Vitor Rocha goncalves Date: Fri, 14 Aug 2026 15:06:08 -0300 Subject: [PATCH 3/6] fix(modules): Retry reading new order until its number is set on checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The order number is assigned asynchronously by the API and could still be missing 400ms after creation, making checkout respond without it — so the confirmation route, purchase events and Awin conversions fell back to the internal order ID. Retries the order read up to 3 more times (backoff) before responding. Co-Authored-By: Claude Fable 5 --- .../functions-checkout/handle-order-transaction.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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..331dd80e2 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,26 @@ 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; + const readFullOrder = async () => { + attempts += 1; try { const { data: order } = await api.get(`orders/${orderId}`, { headers: { 'x-primary-db': 'true' }, }); + // 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; + } resolve({ order }); } catch (err: any) { logger.error(err); resolve({ order: null, err }); } - }, 400); + }; + setTimeout(readFullOrder, 400); }); } catch (err: any) { if (err.message === 'fetch failed') { From cea8b1bb1a2308de01d4cbd6ebfd44de7e99c0a7 Mon Sep 17 00:00:00 2001 From: Vitor Rocha goncalves Date: Fri, 21 Aug 2026 09:56:17 -0300 Subject: [PATCH 4/6] fix(analytics): Keep Awin conversions accurate on payment returns and never fail existing orders Addresses PR review: the order fetch fallback now also covers external payment redirects where amounts were wrong, and checkout no longer responds with error for an order that was actually created. - SSR analytics reads the order from the API whenever the purchase event misses the number OR the exact amounts: on returns from external payment pages (Asaas success URL) the event carried the real order reference but cart-subtotal values, and Awin dedups by reference - That order read runs with short timeout and no retries to stay within the SSR function time budget without starving GA4/Meta/TikTok events - Checkout's order read retry keeps the last successful read and resolves it on transient errors instead of responding CKT701 (and risking a duplicated order) when the order exists - The client fallback pixel, with S2S active, only fires when the page has both the customer-facing number and the real amounts; stores without the S2S API key keep the pixel as their only channel with legacy behavior - Fallbacks to the internal order ID now log warnings instead of degrading silently - Asaas success URL omits the number segment when unset instead of sending a literal "undefined" Co-Authored-By: Claude Fable 5 --- .../asaas/src/asaas-create-transaction.ts | 5 +++- .../handle-order-transaction.ts | 11 +++++++++ .../ssr/src/lib/analytics/send-to-awin.ts | 23 +++++++++++++++---- packages/storefront/client.d.ts | 1 + .../storefront/src/lib/layouts/BaseHead.astro | 1 + .../storefront/src/lib/scripts/vbeta-app.ts | 14 +++++++---- 6 files changed, 45 insertions(+), 10 deletions(-) diff --git a/packages/apps/asaas/src/asaas-create-transaction.ts b/packages/apps/asaas/src/asaas-create-transaction.ts index 894638a8c..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}/${orderNumber}` } + ? { + '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 331dd80e2..57897a861 100644 --- a/packages/modules/src/firebase/functions-checkout/handle-order-transaction.ts +++ b/packages/modules/src/firebase/functions-checkout/handle-order-transaction.ts @@ -14,21 +14,32 @@ const newOrder = async (orderBody: OrderSet) => { const orderId = (await api.post('orders', orderBody)).data._id; return new Promise<{ order: Orders | null, err?: any }>((resolve) => { 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' }, }); + 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`); + } resolve({ order }); } catch (err: any) { logger.error(err); + // The order does exist, don't fail the checkout over a transient + // read error when a previous read already succeeded + if (lastOrderRead) { + resolve({ order: lastOrderRead }); + return; + } resolve({ order: null, err }); } }; diff --git a/packages/ssr/src/lib/analytics/send-to-awin.ts b/packages/ssr/src/lib/analytics/send-to-awin.ts index 3f3783687..4aa7b2c84 100644 --- a/packages/ssr/src/lib/analytics/send-to-awin.ts +++ b/packages/ssr/src/lib/analytics/send-to-awin.ts @@ -37,11 +37,17 @@ const fetchOrderFallback = async (orderId: string) => { 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 reference`, { - status: err.statusCode || err.response?.status, + logger.warn(`Failed reading order ${orderId} for Awin conversion`, { + err, + status: err.statusCode, }); return null; } @@ -62,7 +68,6 @@ const sendToAwin = async ({ const awinOrders: Array> = []; for (let i = 0; i < purchaseEvents.length; i++) { const { params } = purchaseEvents[i]; - // eslint-disable-next-line no-continue if (!params?.transaction_id) continue; let orderNumber = params.order_number; let voucher = params.coupon; @@ -70,11 +75,14 @@ const sendToAwin = async ({ let grossValue = Number(params.value) || 0; let shipping = Number(params.shipping) || 0; let tax = Number(params.tax) || 0; - if (!orderNumber) { + // 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) { - orderNumber = order.number; + if (order.number) orderNumber = order.number; if (order.amount) { grossValue = Number(order.amount.total) || grossValue; shipping = Number(order.amount.freight) || 0; @@ -83,6 +91,11 @@ const sendToAwin = async ({ 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, 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/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 b83085c53..d01e1f6ae 100644 --- a/packages/storefront/src/lib/scripts/vbeta-app.ts +++ b/packages/storefront/src/lib/scripts/vbeta-app.ts @@ -167,15 +167,21 @@ const watchAppRoutes = () => { params.shipping_delivery_days = days; } emitGtagEvent('purchase', params, paramsToHash); - // Skip the pixel without the customer-facing number: the S2S call resolves - // it from the API, and a pixel with a mismatched ref would double-count - if (params.order_number) { + // 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( - String(params.order_number), + params.order_number ? String(params.order_number) : orderId, awinAmount, params.coupon, ); From 77ae44bafd519bc20055aee5db1430c33492a514 Mon Sep 17 00:00:00 2001 From: Leonardo Matos Date: Mon, 24 Aug 2026 22:17:01 -0300 Subject: [PATCH 5/6] fix(analytics): Send Awin the same channel on pixel and server conversion Awin's `channel` is a free-form field, not an enumerated list: the capitalized whitelist turned the real lowercase cookie values ('other', 'organic') into 'aw' on the server while the fallback pixel sent them raw. Same `orderReference` reaching Awin with two different channels, and whichever wins their dedup decides if the commission is theirs on a last click that wasn't. Replaces the whitelist with sanitization (lowercase alphanumeric, capped at 20 chars, 'aw' as the mandated fallback) and normalizes the value where the cookie is read, so pixel and server-side conversion can't diverge. The `typeof` guard matters: `awin_channel` comes from the request body. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019suoRjQ2zxAJNARnwoopsh --- packages/ssr/src/lib/analytics/send-to-awin.ts | 14 +++++++++----- .../storefront/src/analytics/set-tracking-ids.ts | 7 ++++++- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/ssr/src/lib/analytics/send-to-awin.ts b/packages/ssr/src/lib/analytics/send-to-awin.ts index 4aa7b2c84..606cb958d 100644 --- a/packages/ssr/src/lib/analytics/send-to-awin.ts +++ b/packages/ssr/src/lib/analytics/send-to-awin.ts @@ -20,10 +20,14 @@ 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) => ( @@ -102,7 +106,7 @@ const sendToAwin = async ({ ); const awinOrder: Record = { orderReference: String(orderNumber || params.transaction_id), - channel: validChannels.has(channel) ? channel : 'aw', + channel: parseChannel(channel), awc, voucher, amount: netAmount, 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')) { From cd3ca7b403c6d1dc0ff27942575058b7e257259a Mon Sep 17 00:00:00 2001 From: Leonardo Matos Date: Mon, 24 Aug 2026 22:17:12 -0300 Subject: [PATCH 6/6] fix(modules): Don't multiply the checkout wait when reading the order is slow The retry that waits for the order number to be set can issue up to four reads in series, and each one used the API client defaults (timeout 20s, 3 internal retries). When the Store API is slow the cost multiplies by four exactly when the checkout can least afford it, with the customer holding the spinner and no charge created yet. Rereads now use `timeout: 1500` and `maxRetries: 0` from the second attempt on: they are best-effort just for the number and already resolve with the last successful read on failure. The first read keeps the client defaults untouched, since it is the one deciding whether the checkout can go on, so CKT701 behavior is identical to main. Also logs `attempts` on every resolved order instead of only on exhaustion, so the number of retries can be calibrated with data, and demotes a reread failure that was fully recovered from error to warn. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019suoRjQ2zxAJNARnwoopsh --- .../handle-order-transaction.ts | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) 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 57897a861..5a5c5dd1b 100644 --- a/packages/modules/src/firebase/functions-checkout/handle-order-transaction.ts +++ b/packages/modules/src/firebase/functions-checkout/handle-order-transaction.ts @@ -20,6 +20,10 @@ const newOrder = async (orderBody: OrderSet) => { 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, @@ -29,17 +33,32 @@ const newOrder = async (orderBody: OrderSet) => { return; } if (!order.number) { - logger.warn(`Order ${orderId} number still unset after ${attempts} reads`); + 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) { - logger.error(err); // 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 }); } };