Skip to content

fix(analytics): Ensure Awin conversions carry customer-facing order number and net amount - #811

Merged
leomp12 merged 6 commits into
mainfrom
fix/awin-net-amount
Aug 25, 2026
Merged

fix(analytics): Ensure Awin conversions carry customer-facing order number and net amount#811
leomp12 merged 6 commits into
mainfrom
fix/awin-net-amount

Conversation

@vitorrgg

@vitorrgg vitorrgg commented Aug 18, 2026

Copy link
Copy Markdown
Member

Fixes the issues reported by Awin's affiliate team on Tia Sônia's integration testing:

Wrong order reference (internal _id instead of customer-facing number)

Awin's test order (nº 3044575, 2026-08-13) was received with the internal order ID. Traced via Firebase logs (DEBUG_SERVER_ANALYTICS): the purchase event reached the server without order_number. Two gaps allowed this:

  • The order number is assigned asynchronously by the API, and checkout's single 400ms-delayed order read could respond before it was set — so the confirmation route, purchase event and Awin S2S all fell back to _id. Now the checkout retries the order read (up to 3 more times with backoff) until the number is available.
  • As an authoritative safety net, the SSR analytics handler now fetches the order from the API whenever the purchase event misses order_number or the exact amounts, resolving the customer-facing number, exact amounts and coupon before posting to Awin's Conversion API.
  • The client-side fallback pixel (sread.img) only fires, when the S2S call is configured, if the page has both the customer-facing number and the real order amounts — a pixel with a mismatched ref or amount could win Awin's dedup by reference over the accurate S2S conversion. Stores without the S2S API key keep the pixel as their only channel with legacy behavior.
  • Asaas successUrl now carries the order number so customers returning from external payment pages land on the confirmation route with the full reference.

Amount sent with freight included

Awin expects the commissionable amount without freight and taxes. The S2S payload and the fallback pixel now send value − shipping − tax (falling back to the order's amount fields when fetched server-side).


Scope note — the checkout order read retry fixes more than Awin: order.number becomes transactionBody.order_number and feeds every payment gateway. Without it, Asaas/Woovi/GalaxPay/Pagar.me/Pagaleve rendered "Pedido undefined" on customer-facing charge descriptions (e.g. boleto). The retry (up to ~2.8s worst case) closes that silent degradation for all of them, not just Awin.

Review follow-up (cea8b1b):

  • Server-side order fetch now also runs when the event misses the amounts (external payment returns carried the right ref with cart-subtotal values);
  • That read uses timeout: 3000 + maxRetries: 0 + x-primary-db to stay within the SSR function's 10s budget without starving GA4/Meta/TikTok;
  • Checkout retry keeps the last successfully-read order and resolves it on transient read errors, instead of failing (CKT701) an order that exists;
  • Fallbacks to the internal order ID now logger.warn instead of degrading silently;
  • Asaas successUrl omits the number segment when unset (no literal undefined); unused eslint-disable no-continue removed; fetch error logged with the error object.

Vitor Rocha goncalves and others added 3 commits August 14, 2026 09:43
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 <noreply@anthropic.com>
…ayment 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 <noreply@anthropic.com>
…kout

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 <noreply@anthropic.com>
@vitorrgg
vitorrgg requested a review from leomp12 August 18, 2026 17:44

@leomp12 leomp12 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Revisei os quatro arquivos e o entorno. Antes dos problemas, o que está certo e não precisa ser revisitado:

A aritmética do valor líquido bate nos dois caminhos. fixMoneyValue (vbeta-app.ts:54) é literalmente Math.round(v*100)/100, idêntico ao de send-to-awin.ts:87, e os dois clampam em zero. O desconto não entra em dobro: amount.total já vem com amount.discount subtraído, que inclui o extra_discount.value (packages/api/types/orders.d.ts:204-233), e o voucher é só rótulo. O for com await não derruba o lote — o fetchOrderFallback engole os erros e devolve null. A rota de dois segmentos existe: baixei o bundle pinado em vbeta-app.ts:300 e o @ecomplus/storefront-app@2.0.0-beta.228 declara confirmation/:id?/:number?/:json?, então a URL do Asaas está na ordem certa. E a troca do forEach por laço indexado alinha o arquivo com os três irmãos send-to-*.

O que trava são duas coisas, e a primeira é uma interação entre dois commits desta mesma PR.

🏗️ Arquitetural — é a quarta PR no mesmo defeito, e o mecanismo que faz recorrer continua lá

quando PR o que prometeu
21/07 #784 adapta os dados da conversão e adiciona o pixel de fallback
01/08 #789 persiste o awc em cookie
06/08 #803 "Send customer-facing order number to Awin, not internal id"
06/08 4ab02f5 manda o orderReference como string
14/08 #811 de novo: garantir que o Awin receba o número do pedido

O #803 fez o óbvio — leu number do corpo que a rota de confirmação já tinha e passou adiante com params.order_number || params.transaction_id. Não segurou porque o corpo frequentemente não tem number: o checkout lê o pedido 400ms depois do POST e a API atribui o número de forma assíncrona. O || caiu no _id, em silêncio, e só se soube quando a Awin reclamou do pedido nº 3044575.

O padrão é o ||. Cada rodada acrescentou uma camada de fallback em vez de tratar a origem, e o fallback degrada sem erro, sem log, sem sinal — o dado errado sai com cara de certo. E o #811 mantém o mesmo || em send-to-awin.ts:100: se o retry do checkout e o fetchOrderFallback falharem os dois, volta a sair o _id calado, e a quinta rodada é o mesmo ticket. Um logger.warn nesse ponto — e no handle-order-transaction.ts, quando o laço se esgota sem number — é o que transforma isso em algo que a gente descobre antes do parceiro.

Vale registrar por que agora: esta é a primeira das quatro que sai do analytics e entra no checkout compartilhado e num app de pagamento. A integração hoje mora em cinco arquivos e quatro pacotes.

E uma correção de crédito: o retry do handle-order-transaction.ts é justificado, mas não por causa da Awin. O order.number vira transactionBody.order_number em new-order.ts:58 e alimenta todos os gateways — asaas-create-transaction.ts:83,96 (Pedido ${orderNumber}, que é o que o cliente lê no boleto), woovi-create-transaction.ts:70, galaxpay-create-transaction.ts:93-94, pagarme-create-transaction.ts:68, pagaleve-create-transaction.ts:67. Nenhum tem guarda: sem número, o boleto sai "Pedido undefined". Isso é um defeito de plataforma que já degradava silenciosamente para todo mundo, e a PR o corrige de passagem sem dizer. Quem olhar depois não vai saber por que existem 2,8s ali — vale o commit e o corpo da PR dizerem isso.

🔴 Bloqueante — os dois commits se cancelam no retorno do Asaas, e a conversão sai com valor errado carimbada com a referência certa

send-to-awin.ts:73 só busca o pedido if (!orderNumber). O commit do Asaas põe o número na URL (asaas-create-transaction.ts:98), então params.order_number vem preenchido e o fetchOrderFallback nunca roda justamente na rota que ele existiria para salvar.

E nessa rota não há corpo do pedido. O successUrl é /confirmation/${orderId}/${orderNumber} — sem o terceiro segmento :json?. Em vbeta-app.ts:206-212, sem params.json o emitPurchase é chamado só com id e número, então orderJson é undefined, o order é undefined, e vbeta-app.ts:99-105 cai no window.storefrontApp, que não carrega o pedido. Com amount indefinido:

  • vbeta-app.ts:108params.value = fixMoneyValue(shoppingCart.subtotal || 0)
  • vbeta-app.ts:117-124params.shipping e params.tax nem chegam a ser setados

E shoppingCart.subtotal (shopping-cart.ts:185-192) é quantity * price dos itens: sem frete, sem imposto e sem o cupom.

Pedido de R$ 300 — R$ 250 de itens, R$ 30 de cupom, R$ 80 de frete — tem comissionável de R$ 220. A Awin recebe R$ 250. E se o cliente voltar do Asaas em outro navegador ou dispositivo, com o carrinho vazio, subtotal = 0 e a conversão vai como R$ 0,00.

O que torna isso pior que o estado atual: antes o valor errado ia com ref = _id, uma referência que a Awin não reconcilia com nada. Agora ele vai carimbado com o orderReference real — e como a Awin desduplica por referência, a conversão correta não entra mais depois. A PR troca "dado inútil" por "dado errado com aparência de autoritativo".

O caminho mais curto é o inverso do que está: deixar o fetchOrderFallback rodar sempre que faltar amount, não só quando faltar orderNumber.

🔴 Bloqueante — o retry joga fora uma leitura que já tinha dado certo, e o cliente vê erro num pedido que existe

handle-order-transaction.ts:19-33:

const readFullOrder = async () => {
  attempts += 1;
  try {
    const { data: order } = await api.get(`orders/${orderId}`, { headers: { 'x-primary-db': 'true' } });
    if (!order.number && attempts <= 3) {
      setTimeout(readFullOrder, 400 * attempts);
      return;
    }
    resolve({ order });
  } catch (err: any) {
    logger.error(err);
    resolve({ order: null, err });
  }
};

Na tentativa 1 o pedido volta íntegro — só sem number. Se a tentativa 2, 3 ou 4 pegar falha transitória (timeout, 502, 429 depois dos três retries internos do próprio client), o catch resolve { order: null, err } e o new-order.ts:265 responde CKT701, "There was a problem saving your order".

Só que o pedido existe na API. Nenhuma transação foi criada, o cliente vê erro e refaz o checkout: pedido duplicado e estoque preso no primeiro. Antes desta PR havia uma leitura e uma janela de falha; agora são quatro, e três delas sobre um dado que já está na mão.

Guardar o último order lido com sucesso e resolvê-lo no catch fecha isso — o pior caso volta a ser "pedido sem número", que é o que o laço já aceita na saída normal.

🟠 Estruturais

A leitura da Store API entrou no caminho da resposta do /_analytics, que tem orçamento de 10s. serve-storefront.ts:194 faz await sendAnalyticsEvents(...) antes do res.sendStatus(201), e o ssrFunctionOptions.timeoutSeconds é 10 por padrão (packages/firebase/src/config.ts:183). O api.get do fetchOrderFallback herda timeout = 20000 e maxRetries = 3, com backoff de 5s em 429/5xx (packages/api/src/api.ts:174-175,238-246) — ou seja, a chamada sozinha pode passar do orçamento da function inteira. E ela está no mesmo Promise.allSettled do GA4, Meta e TikTok: Orders API lenta derruba o lote todo, não só a Awin. Os três irmãos send-to-ga4, send-to-meta e send-to-tiktok são transform puro mais um POST de saída; este virou o único com I/O de entrada. Passar timeout curto e maxRetries: 0 nessa chamada resolve.

Pular o pixel sem order_number remove o único canal de algumas lojas. O S2S exige AWIN_ADVERTISER_ID e AWIN_API_KEY no runtime do SSR (send-to-awin.ts:13,59 — sem os dois, awinAxios é null e a função retorna). O pixel exige só o AWIN_ADVERTISER_ID, injetado em build (BaseHead.astro:97). São duas configurações independentes: numa loja que tem só o advertiser ID, o pixel era o único canal, e agora a conversão sem número não é registrada em lugar nenhum. Antes ia com referência errada, mas ia. Vale condicionar o skip a ter o S2S de fato ativo.

O || silencioso, nos dois lugares. send-to-awin.ts:100 e o resolve({ order }) sem number no handle-order-transaction.ts. Um logger.warn em cada um é o que faz a próxima recorrência aparecer no log em vez de num e-mail do parceiro.

🟢 Minors

  • send-to-awin.ts:65 — o eslint-disable-next-line no-continue é diretiva não usada: no-continue já está 'off' em packages/eslint/base.eslintrc.cjs:67, e o repo usa continue; puro em ~10 lugares (apps/emails/src/event-to-emails.ts:61). Não há script lint na raiz nem passo de lint no test-apps.yml, então só o CodeFactor pega — e ele reporta exatamente 1 issue nesta PR. O no-await-in-loop da linha 74, esse está correto.
  • send-to-awin.ts:43-45 — o catch loga só { status } e descarta mensagem e stack. Em falha de rede o ApiError não seta statusCode (packages/api/src/api.ts:30-38), então o log vira { status: undefined } e não sobra nada para diagnosticar. A convenção do repo é levar o erro junto — apps/woovi/src/woovi-create-transaction.ts:164-172, e o próprio orquestrador em send-analytics-events.ts:128-139. Sub-item: err.response?.status é axios-ismo; este client é fetch-based e expõe .statusCode.
  • asaas-create-transaction.ts:98 — sem guarda, a URL vira literalmente /confirmation/<id>/undefined quando o retry se esgota. A rota degrada bem (Number('undefined') → NaN), mas aí o pixel é pulado e a referência cai pro _id.
  • send-to-awin.ts:38 — não manda x-primary-db: 'true', ao contrário da leitura equivalente em handle-order-transaction.ts:21. Risco baixo porque essa leitura acontece minutos depois, mas é a mesma premissa de "número recém-gravado".
  • packages/ssr não tem script de teste nem um único arquivo de teste, o que explica três reincidências sem nada segurando. Já packages/modules, onde entra o retry, tem vitest e tests/1-modules.test.ts — o laço é testável no harness que já existe.
  • /_analytics é não autenticado e o transaction_id vem do chamador, então o servidor passou a resolver número e total reais de um pedido por ID. Nada volta para o chamador, mas forjar conversão deixou de exigir saber o valor do pedido.
  • O corpo da PR abre com "the three issues reported by Awin's affiliate team" e descreve duas. Falta a terceira, ou o texto ficou incompleto.

Pra entrar antes do merge

  1. Fazer o fetchOrderFallback rodar quando faltar amount, não só quando faltar orderNumber — hoje o commit do Asaas desliga a rede de segurança na única rota que precisa dela.
  2. Guardar o último order lido com sucesso e resolvê-lo no catch do retry, para o cliente não receber CKT701 num pedido que existe.
  3. Limitar timeout/maxRetries na leitura do /_analytics, que hoje pode sozinha exceder os 10s da function e derrubar GA4, Meta e TikTok junto.
  4. logger.warn nos dois fallbacks silenciosos, e condicionar o skip do pixel a ter o S2S ativo.
  5. Tirar o eslint-disable não usado (provável issue do CodeFactor) e levar o erro junto no logger.warn do catch.

Uma nota de escopo, mais para o corpo da PR do que para o código: o retry no checkout é a melhor coisa daqui e está vendida como detalhe de Awin. Ela conserta "Pedido undefined" no boleto de cinco gateways. Vale dizer isso em algum lugar, senão a latência vira mistério para quem mexer depois.

… 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 <noreply@anthropic.com>
@vitorrgg

Copy link
Copy Markdown
Member Author

Valeu pela revisão @leomp12, pontos ajustados em cea8b1b:

Bloqueante 1 (Asaas return sem amount): o fetch do pedido no send-to-awin.ts agora roda quando falta order_number ou quando faltam os valores (params.shipping === undefined, que é o sinal de evento sem corpo do pedido — nessa rota o value cai pro subtotal do carrinho). E fechei o mesmo furo do lado do pixel, que você tangenciou na dedup: nessa rota ele disparava com o ref certo e valor do carrinho, podendo vencer a dedup por referência sobre o S2S correto. Com S2S ativo o pixel agora só dispara quando o cliente tem número e valores reais.

Bloqueante 2 (CKT701 em pedido existente): o retry guarda o último order lido com sucesso e o resolve no catch — falha transitória na releitura volta a degradar para "pedido sem número", que o laço já aceitava.

Estruturais:

  • A leitura no /_analytics vai com timeout: 3000 + maxRetries: 0 (e x-primary-db, mesma premissa de número recém-gravado), então não estoura sozinha o orçamento de 10s nem segura GA4/Meta/TikTok no mesmo lote;
  • logger.warn nos dois fallbacks silenciosos (S2S caindo pro _id e retry esgotado sem número);
  • O skip do pixel ficou condicionado ao S2S estar de fato configurado: BaseHead.astro injeta um booleano window.AWIN_S2S_ENABLED (advertiser ID + API key presentes — só o booleano vai pro HTML). Loja só com advertiser ID mantém o pixel como canal único com o comportamento anterior.

Minors: eslint-disable no-continue removido; o catch do fetch loga o erro junto e usa só err.statusCode; guarda no successUrl do Asaas (sem /undefined). Corpo da PR atualizado com a nota de escopo do retry ("Pedido undefined" nas cobranças dos 5 gateways) e sem a contagem "three issues" — as duas descritas são as que geraram código; a terceira interação com a Awin foi o "Conversion Tag didn't fire", que é esperado (usamos S2S + pixel, não o MasterTag).

Sobre teste pro newOrder: o harness do packages/modules é integração contra o emulador e não exercita a releitura isoladamente — dá pra cobrir num follow-up sem segurar esta PR.

@leomp12 leomp12 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Segunda rodada, em cima do cea8b1bb1. Os cinco pedidos do review anterior estão fechados — confirmei um por um:

# Pedido Onde ficou
1 fetchOrderFallback rodar quando faltar amount, não só orderNumber send-to-awin.ts:81if (!orderNumber || params.shipping === undefined)
2 Guardar o último order lido e resolvê-lo no catch (CKT701 em pedido que existe) handle-order-transaction.ts:17,37-41lastOrderRead
3 Limitar timeout/maxRetries na leitura do /_analytics send-to-awin.ts:43-44
4 logger.warn nos dois fallbacks + skip do pixel condicionado ao S2S ativo send-to-awin.ts:94-98, handle-order-transaction.ts:31-33, BaseHead.astro:98 + vbeta-app.ts:177-178
5 Tirar o eslint-disable não usado; levar o erro junto no catch eslint limpo; logger.warn(..., { err, status: err.statusCode })

Minors também: guarda no successUrl do Asaas, x-primary-db na leitura da Awin, e a nota de escopo do retry no corpo da PR.

Verificação mecânica: tsc --noEmit limpo em packages/ssr, packages/modules e packages/apps/asaas; eslint limpo nos 4 arquivos; workflow test verde.

Três premissas novas que eram o risco natural das heurísticas, todas conferidas e corretas:

  • params.shipping === undefined é sinal seguro. O checkout sempre inicializa amount = { subtotal, discount, freight: 0, total } (checkout.ts:49-54), então pedido da plataforma sempre tem freight definido — a heurística não dispara à toa nem pula o pixel em pedido normal.
  • AWIN_S2S_ENABLED sai preenchido de verdade. Conferi no HTML de produção da tia sônia que window.AWIN_ADVERTISER_ID = '128977', ou seja var não-PUBLIC_ resolve em import.meta.env no build do Astro. E numa página em cache antiga o undefined cai em !undefined → pixel dispara = comportamento legado. Default seguro nos dois lados.
  • O guard /^[0-9a-f]{24}$/ antes do fetch (send-to-awin.ts:36) é o instinto certo — /_analytics é não autenticado e o transaction_id vem do chamador.

Não-regressão em GA4, Meta e TikTok

Como a PR passou a fazer I/O dentro do lote de analytics, fui verificar o que poderia respingar nos outros três:

O que poderia quebrar Verificação Resultado
Awin mutar o params compartilhado send-to-awin.ts tudo é cópia local (orderNumber, grossValue, shipping, tax, voucher); o objeto entregue aos irmãos é idêntico ao de antes
Mudança na forma do params diff do vbeta-app.ts só o bloco do pixel; params.shipping/tax/order_number são pré-existentes
Irmãos lendo os campos tocados send-to-ga4/meta/tiktok.ts nenhum dos três lê shipping, tax ou order_number
emitGtagEvent cair dentro do condicional vbeta-app.ts:168 continua antes e fora do if (canEmitAwinPixel)
Dedup de purchase quebrar vbeta-app.ts:190 localStorage.setItem('gtag.orderIdSent', orderId) continua fora do condicional — se tivesse entrado junto, purchase re-emitiria para todos os providers
Falha da Awin derrubar o lote try/catch próprio no fetchOrderFallback + Promise.allSettled no orquestrador sendToAwin não rejeita para fora
Custo imposto a tráfego não-Awin send-to-awin.ts:65 if (!awinAxios || !awc) return é antes do primeiro await — sessão sem awc sai com custo zero; a leitura extra só existe em tráfego atribuído à Awin
Dependência nova no bundle SSR packages/ssr/package.json:36, cron-ssr-save-views.ts:3 @cloudcommerce/api já era dep declarada e já era importado
Outros gateways diff só o Asaas mudou; a rota confirmation/:id?/:number?/:json? aceita com e sem o segmento

Está limpo. O único acoplamento que sobra é o orçamento de 10s da SSR function: a perna da Awin agora começa até 3s mais tarde e o awinAxios (send-to-awin.ts:14) não tem timeout. Nenhum dos três irmãos define timeout também, então é padrão pré-existente do arquivo — mas a Awin passou a ser a única que entra atrasada, e um timeout no axios.create fecha isso sem tocar em ninguém.

Registrando sem pedir: no caminho lento da confirmação, GA4/Meta/TikTok recebem value = shoppingCart.subtotal. É anterior a esta PR e não piora com ela — a única mudança que chega neles é order_number presente com mais frequência, param aditivo que o GA4 ignora. Fica anotado, não é escopo daqui.

🟠 O retry multiplica por 4 uma leitura bloqueante do checkout

Primeiro o crédito, porque o corpo da PR agora diz certo: este retry não é pela Awin. order.number vira transactionBody.order_number (new-order.ts:58) e alimenta Pedido ${orderNumber} na descrição da cobrança de Asaas, Woovi, GalaxPay, Pagar.me e Pagaleve — nenhum com guarda. Sem ele o boleto sai "Pedido undefined". É conserto de plataforma, e não dá pra evitar a releitura porque o POST orders devolve só { _id }.

O problema não é ele existir, é o teto. A linha do tempo:

t=0       POST orders   →  _id
t=400ms   leitura #1 (attempts=1)  →  tem number? resolve : agenda +400ms
t=800ms   leitura #2 (attempts=2)  →  tem number? resolve : agenda +800ms
t=1600ms  leitura #3 (attempts=3)  →  tem number? resolve : agenda +1200ms
t=2800ms  leitura #4 (attempts=4)  →  resolve de qualquer jeito (+ warn)

E isso mora em new-order.ts:39, antes de qualquer transação ser criada: o cliente segura o POST /checkout os 2,8s inteiros com nenhuma cobrança na mão. Não é background job, é o spinner do checkout.

Cada leitura usa os defaults do client (packages/api/src/api.ts:174-175): timeout: 20000, maxRetries: 3. Confirmei que timeout e erro de rede não disparam retry interno (api.ts:205-215; só 429/5xx entram no laço em :238-246) e que o catch resolve na hora com lastOrderRead, então a cadeia para na primeira falha — o pior caso teórico é bem menor do que parece. O que sobra, e é o que importa: as 4 leituras são pagas em série quando todas respondem 200 devagar. API em 300ms → +1,2s. API em 3s sob carga → +12s. O custo se multiplica por 4 exatamente no momento em que a Store API já está ruim, que é quando o checkout menos pode pagar.

O caminho que não devolve o CKT701 que a PR acabou de consertar: a leitura #1 e as releituras têm papéis diferentes. A #1 decide se o checkout continua — encurtar o timeout dela faz uma API lenta-mas-funcionando devolver erro num pedido que existe. As #2–4 são best-effort só pelo número, e se falharem já existe lastOrderRead.

const { data: order } = await api.get(`orders/${orderId}`, {
  headers: { 'x-primary-db': 'true' },
  // Rereads are best-effort for the order number, the first read is the
  // one that decides whether the checkout can go on
  ...(attempts > 1 ? { timeout: 1500, maxRetries: 0 } : null),
});

Com isso o teto que a PR acrescenta fica em ~2,8s de espera + ~4,5s de leituras, e o comportamento de CKT701 fica idêntico ao do main. O maxRetries: 0 nas releituras também é coerente: o laço externo já é a política de retry, o interno é redundância escondida.

E o número de tentativas em si é discutível — attempts <= 2 (3 leituras, 1,6s) provavelmente pega quase tudo. Para decidir isso com dado em vez de palpite, o attempts precisa aparecer no log do resolve, não só no esgotamento: hoje o logger.warn só dispara quando as 4 falham, então não dá pra saber se o custo é cauda ou regra. Uma semana de log responde.

Não consegui verificar se o @ecomplus/storefront-app tem timeout no fetch do POST /checkout — é bundle externo a este repo. Se tiver e for menor que o novo pior caso, o cliente vê erro de rede com o pedido criado e sem transação, que é o cenário de pedido duplicado com estoque preso. Vale alguém que conheça o bundle confirmar, porque isso muda a urgência do cap.

🟠 Pixel e S2S divergem no channel

O pixel manda ch=${trackingIds.awin_channel || 'aw'} cru (vbeta-app.ts:42); o servidor manda validChannels.has(channel) ? channel : 'aw' (send-to-awin.ts:105). Como o template oficial da Awin grava o AwinChannelCookie em minúsculas e a whitelist tem 'Other'/'Organic' capitalizados (:25), para organic e other o pixel diz organic e o S2S diz aw, no mesmo orderReference — e quem vencer a dedup decide se a comissão vai para a Awin num last click que não foi deles.

É a terceira PR seguida que edita este arquivo passando ao lado dessa linha (levantei no #803 como adjacente), e é a mesma classe de defeito que esta PR está consertando: precisão do que a Awin recebe para calcular comissão. O caminho é repassar o valor do cookie em minúsculas e sanitizado em vez de manter whitelist — a doc da Awin não enumera valores aceitos, o campo é livre (≤20 alfanuméricos) com aw só como fallback obrigatório.

🟢 Minors

  • fields em GET por id não tem precedente no repo. Todos os outros fields: [...] são em listagem, e o ResponseBody (packages/api/types.d.ts:394-399) só aplica ListFields a list endpoints — o TS entrega Orders inteiro de qualquer jeito. Se a Store API ignorar o param no single-resource, o timeout: 3000 foi dimensionado para 3 campos mas puxa o documento completo. Um curl confirma.
  • awinAxios sem timeout (send-to-awin.ts:14), conforme a seção de não-regressão acima.
  • CodeFactor vermelho com a página vazia. O status no cea8b1bb1 diz "1 issue found", a página da PR lista "No issues found" e o eslint local está limpo. Provavelmente stale, mas trava o gate — vale um re-run.

Pra entrar antes do merge

  1. Capar as releituras (timeout + maxRetries: 0 a partir da segunda), mantendo a primeira como está. É o que impede o checkout inteiro de multiplicar por 4 quando a Store API está lenta.
  2. Logar attempts no resolve, não só no esgotamento — sem isso não dá pra calibrar o número de tentativas depois.
  3. Passar o channel em minúsculas em vez da whitelist capitalizada. Uma linha, no arquivo que a PR já edita, e fecha um furo de atribuição de comissão que está aberto desde a #784.

O resto é opcional. A não-regressão nos outros providers de analytics está verificada e limpa, e os dois bloqueantes da rodada anterior estão de fato resolvidos.

leomp12 and others added 2 commits August 24, 2026 22:17
…sion

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019suoRjQ2zxAJNARnwoopsh
… 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019suoRjQ2zxAJNARnwoopsh

@leomp12 leomp12 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Terceira rodada, em cima do cd3ca7b40. Os três pedidos do review anterior estão fechados, e eu mesmo empurrei os commits — então vale explicitar o que foi conferido antes de aprovar.

# Pedido Onde ficou
1 Capar as releituras (timeout + maxRetries: 0 a partir da 2ª) handle-order-transaction.ts:23-26
2 Logar attempts no resolve, não só no esgotamento handle-order-transaction.ts:35-48, com payload estruturado { orderId, attempts } nos dois ramos
3 channel em minúsculas em vez da whitelist capitalizada send-to-awin.ts:27-30 (parseChannel) + set-tracking-ids.ts:79-85

Duas coisas entraram além do pedido literal, ambas por premissa que não fechava:

  • Guard typeof no parseChannel. awin_channel vem de req.body (serve-storefront.ts:194) e o default channel = 'aw' do parâmetro só cobre undefinednull ou objeto passariam, e um .toLowerCase() cru lançaria TypeError engolido pelo Promise.allSettled de send-analytics-events.ts:117, descartando a conversão inteira em silêncio.
  • Normalização também na fonte (set-tracking-ids.ts, 7º arquivo). Só o servidor não dá paridade: com cookie capitalizado o pixel mandaria Organic e o S2S organic. trackingIds.awin_channel é o ponto único que alimenta os dois canais (só 2 consumidores no repo: vbeta-app.ts:42 e send-analytics-events.ts:88), então normalizar ali fecha os dois de uma vez.

Não-regressão

Tabela antes→depois do canal: aw/ppcgeneric/ppcbrand/display/social/direct inalterados; Other/Organic passam a chegar lowercase; other/organic deixam de virar aw (é o fix); undefined/null/''/objeto continuam em aw. Nenhum caso piora.

No checkout: enumerados os cinco caminhos do Promise — exatamente um resolve em cada, sem caminho órfão. A 1ª leitura fica byte-idêntica ao main (spread de null é no-op), então CKT701 dispara exatamente nos mesmos casos de antes: falha do POST ou falha da 1ª leitura. As releituras nunca produzem order: null por causa do guard lastOrderRead. O pior caso das releituras cai de ~288s (4 leituras × timeout 20s + retries de 5s — que estourava o deadline da function) para ~7,4s.

Mudanças observáveis intencionais: ERROR→WARNING no caminho recuperado do catch (deixa de abrir incidente no Error Reporting numa situação benigna) e +1 log INFO por checkout, que é o propósito do pedido 2.

tsc --noEmit limpo em modules, ssr e apps/asaas; eslint 0 problemas nos 7 arquivos com --max-warnings 0 --no-ignore; workflow test verde.

CodeFactor

Não é stale e não é required check (required_status_checks.contexts: [] na proteção do main), então não trava o merge. Identifiquei a causa: é Complex Method em sendToAwin. Medido com a regra complexity do eslint, a função vai de 6 (47 linhas) no merge-base para 23 (78 linhas) — cruza qualquer threshold default. Nem o config do repo nem o eslint:recommended habilitam complexity, que é porque não reproduzia localmente. Fica como follow-up: extrair a montagem do awinOrder derruba o número sem tocar em comportamento.

Segue para merge.

@leomp12
leomp12 merged commit fafcf92 into main Aug 25, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants