Skip to content

feat(apps): Integração com Bling ERP na plataforma - #802

Merged
leomp12 merged 34 commits into
mainfrom
bling
Aug 25, 2026
Merged

feat(apps): Integração com Bling ERP na plataforma#802
leomp12 merged 34 commits into
mainfrom
bling

Conversation

@vitorrgg

@vitorrgg vitorrgg commented Aug 5, 2026

Copy link
Copy Markdown
Member

Porta o app Bling ERP (app_id 102418) do repositório app-bling-erp-v2 para o monorepo, usando a API v3 do Bling.

Funções

Função Papel
blingerp-onStoreEvent Eventos da loja: exporta pedidos e produtos, e processa a fila manual em applications-dataSet
blingerp-callback Callbacks de estoque e pedidos configurados no Bling
blingerp-authCallback Recebe o code do fluxo OAuth e grava os tokens
blingerp-cronRefreshToken Renova o access_token antes de expirar

Mudanças de arquitetura em relação ao app v1

  • A fila em Firestore (queue/{storeId}/events + running_events + handle-queue) deu lugar ao PubSub de eventos do monorepo (maxInstances: 1) com a fila em data do app;
  • appSdk multi-loja substituído por @cloudcommerce/api com as credenciais da própria loja;
  • Tokens OAuth em blingTokens/{storeId} e cache das situações de venda em blingStatuses/{storeId}, no projeto Firebase da loja;
  • Busca de produto por SKU usa products/skus:{sku} no lugar do ElasticSearch.

Correções sobre o comportamento do v1

Encontradas ao portar e ao validar contra a API real:

  1. Atualização de produto com variações falhava com HTTP 400 — a listagem /produtos?codigo= devolve o produto resumido, sem variacoes, então o PUT ia sem os IDs e o Bling rejeitava como se fossem novas variações;
  2. Preço por variação era perdido na exportação (o Bling aplica o preço do produto pai a todas) — corrigido com PUT /produtos/{idVariacao} apenas para as divergentes;
  3. Callback de estoque de variação sem SKU no Bling era descartado em silêncio — agora usa o ID do Bling como referência, que é o SKU gravado na importação;
  4. Configuração "Importar produto" não tinha efeito — o callback forçava canCreateNew: false;
  5. Falhas em exportação automática não apareciam no painel do lojista, só no log da função;
  6. Status "Devolvido" era enviado como fulfillment_status inválido;
  7. Limite diário da API gravava a flag invertida, liberando novas chamadas em vez de bloquear;
  8. other_config era lido como outher_config (typo), então o tipo de contato nunca era aplicado;
  9. Comparação de estoque usava um campo inexistente no Bling (quantity), causando lançamento redundante a cada exportação;
  10. Sem situação correspondente no Bling, o pedido falhava — agora registra aviso e segue exportado.

Grades de variação importadas passam a mapear para size/age_group/gender (antes só Cor era normalizada), mantendo o round-trip estável com a exportação.

Testes

packages/apps/bling-erp/tests/ — 71 testes com node --test, offline, sem credenciais: pedido e produto nos dois sentidos, mapeamento de status (incluindo parse_status customizado), endereço/CEP, parcelamento, prazo de entrega com dias úteis e feriados, variações sem SKU e normalização de grades.

scripts/bling-smoke.mjs faz uma varredura read-only na API do Bling validando credenciais e todos os endpoints usados.

Validação em produção

Loja de teste (1011) + conta Bling de teste, com as funções deployadas em um projeto Firebase real:

  • Fluxo OAuth completo: autorização no Bling → blingerp-authCallback → tokens no Firestore;
  • Callback público: estoque alterado no Bling refletiu na loja (99 → 10);
  • Pedido pago na loja → evento → PubSub → blingerp-onStoreEvent → pedido criado no Bling com contato, item vinculado, frete, etiqueta e parcela;
  • Atualização de situação (delivered → "Atendido") e round-trip de status;
  • Produto com variações nos dois sentidos, incluindo um criado pela interface do Bling (sem SKU nas variações);
  • Importação de produto com imagem migrada do S3 do Bling para o storage da e-com.plus, e categoria criada na loja;
  • blingerp-cronRefreshToken executando e validando o token.

Notas

  • O registro do app no marketplace (título e admin_settings) continua no repositório app-bling-erp-v2; este pacote traz apenas o runtime;
  • Ao migrar uma loja que já usa o app v1, definir ignore_triggers nas configurações do app para o app central parar de processá-la;
  • pnpm-lock.yaml não foi atualizado neste PR — o CI instala com --no-frozen-lockfile.

🤖 Generated with Claude Code

vitorrgg and others added 2 commits August 4, 2026 14:31
Porta o app Bling ERP (app_id 102418) do repositório app-bling-erp-v2 para
o monorepo, usando a API v3 do Bling: exportação de pedidos e produtos,
importação de estoque, pedidos e categorias, e renovação automática dos
tokens OAuth.

Funções: `blingerp-onStoreEvent` (eventos da loja), `blingerp-callback`
(callbacks de estoque/pedidos do Bling), `blingerp-authCallback` (fluxo de
autorização OAuth) e `blingerp-cronRefreshToken`.

A fila do app v1 em Firestore (`queue/{storeId}/events` + `running_events`)
foi substituída pelo PubSub de eventos do monorepo, e o `appSdk` multi-loja
pelo `@cloudcommerce/api`. Tokens ficam em `blingTokens/{storeId}` e o cache
de situações de venda em `blingStatuses/{storeId}`.

Correções sobre o comportamento do app v1:
- atualização de produto com variações falhava com 400 no Bling, porque as
  variações eram enviadas sem ID (a listagem `/produtos?codigo=` devolve o
  produto resumido);
- preço por variação era perdido na exportação (o Bling aplica o preço do
  produto pai), agora corrigido com PUT por variação divergente;
- callback de estoque de variação sem SKU no Bling era descartado, agora usa
  o ID do Bling como referência;
- configuração "Importar produto" não tinha efeito, pois o callback forçava
  `canCreateNew: false`;
- status "Devolvido" era enviado como fulfillment inválido;
- limite diário da API gravava a flag invertida, liberando novas chamadas;
- grades de variação importadas viram `size`/`age_group`/`gender` como no
  sentido inverso, em vez de slug do rótulo;
- sem situação correspondente no Bling, o pedido não falha mais: registra
  aviso e segue exportado.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Erros em exportações disparadas por evento da loja (não pela fila manual) só
apareciam no log do Cloud Functions, ficando invisíveis para o lojista no
painel. Sucessos de importação continuam fora do log para não inundá-lo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vitorrgg

Copy link
Copy Markdown
Member Author

🤖 Revisão adversarial — /review-pr (Opus, revisão multi-agente)

Revisão adversarial do diff (4 revisores paralelos, cada um num grupo de risco), comparando com tiny-erp/melhor-envio como integração de referência. Meta: refutar a mudança e achar o colateral, não aprovar. O caminho feliz claramente funciona (validado em produção na loja 1011) — os achados abaixo são o que testes manuais não pegariam.

Veredito: 🔴 request changes

Boa notícia: os pacotes compartilhados (firebase/config.ts, events/firebase.ts) estão limpos — sem colisão de appId, sem colisão de export no barrel, escopo de eventos confinado. Sem regressão cross-app em tiny-erp/melhor-envio. O risco é todo dentro do próprio app + deploy.


🔴 Critical

C1 · Race no refresh do token desativa a integração permanentementesrc/bling-auth/create-access.ts:63-101, bling-erp.ts:22-31
O Bling v3 rotaciona o refresh_token a cada uso. O fluxo é read-modify-write sem transação/lock em blingTokens/{storeId}, com atores concorrentes (cron de refresh + função callback, que não tem maxInstances). Duas chamadas leem o mesmo token antigo; a 2ª recebe invalid_grant → grava isBloqued:true e mata a integração até re-auth manual, mesmo após um refresh bem-sucedido.
Correção: runTransaction no read+refresh+write; só bloquear em invalid_grant genuíno relendo o doc.

C2 · Callback público processa sem autenticação (fail-open)src/bling-callback.ts:44-54
callbackToken = env || appData.callback_token. Sem nenhum setado (default), só loga um warning e segue processando. tiny-webhook.ts faz 403. Qualquer um faz POST forjado com {retorno:{pedidos:[...]}} / {estoques:[...]} e dispara importação de pedidos/produtos na loja.
Correção: falhar fechado (exigir token), como no tiny.

C3 · OAuth state nunca é validado → account-mixup / sobrescrita de tokenssrc/bling-auth-callback.ts:16-21
O state é só logado; não há nonce armazenado. Um code de outra conta Bling injetado no callback sobrescreve os tokens da loja.
Correção: gerar/persistir state na iniciação e validar aqui.

C4 · Pedido devolvido (returned) nunca é cancelado no Bling — fica "Aprovado"src/integration/parsers/status-to-bling.ts
Não há case 'returned' no switch de fulfillment, e returned nem existe em parseStatusTitle. Pedido pago e devolvido cai no fall-through → ['aprovado','em aberto']. Referência tiny-erp trata returned → 'cancelado'. Consequência: estoque não retorna, financeiro segue como venda válida, NF não é cancelada.
Teste de regressão incluído. Correção incluída.

C5 · Round-trip de status regride "Entregue → NF emitida" em contas Bling padrãostatus-to-bling.ts × status-from-bling.ts
As situações padrão do Bling Vendas não têm "Enviado"/"Entregue"/"Faturado", então shipped/delivered/invoice_issued caem no fallback "Atendido" → na volta "Atendido" → invoice_issued. Um pedido delivered reimporta como invoice_issued (regride). Idem in_production → in_separation.
Teste de regressão incluído.

C6 · Fix #9 usa base de comparação de estoque erradasrc/integration/export-product-to-bling.ts:191
Compara contra estoque.saldoVirtualTotal (físico − reservado, todos os depósitos), mas o balanço ajusta o saldo físico e a importação usa o saldo do depósito configurado. Loja com reserva/multi-depósito → posta balanço em toda exportação (movimentação infinita) ou não corrige o físico errado.


🟠 Required

  • R1 export-product-to-bling.ts:99-103.catch(() => originalBlingProduct) engole 429/500 no re-fetch de variações → PUT sem IDs → volta o HTTP 400 do fix System design #1.
  • R2 product-to-bling.ts:146 + product-from-bling.ts:236-241 — round-trip duplica variações sem SKU (codigo sintético PAI-1 não gravado de volta → não casa → cria nova).
  • R3 parsers/order-to-bling.ts:~205-245amount.tax/amount.extra ignorados no total e parcelas → Bling recebe valor abaixo do pago; conciliação quebra. tiny-erp soma amount.tax.
  • R4 parsers/order-from-bling.ts:78-96 — update de access_key da NF: else if (invoiceIndex && ...) pula o índice 0, e não seta shipping_linesapi.patch nunca envia.
  • R5 create-access.ts:88-98 — 3 erros transitórios (5xx/timeout) no /oauth/token gravam isBloqued:true permanente; countErr read-then-set (usar FieldValue.increment).
  • R6 bling-callback.ts:61-125 — erro transitório na importação retorna 200 sem retry (entradas isNotQueued) → Bling não re-notifica → evento perdido.
  • R7 check-enable-api.ts:17-19 (24h) vs create-access.ts:34-36 (12h) — janela do rate-limit diário inconsistente.
  • R8 after-bling-queue.ts:76-108 — lost-update na fila entre callback (instâncias ilimitadas) e onStoreEvent: ids reaparecem (pedido/etiqueta duplicados) ou somem.
  • R9 try-image-upload.ts:16,29-35 — token ecom em cache de módulo nunca revalidado → ao expirar, grava a URL temporária do S3 do Bling como imagem (quebra depois).
  • R10 get-products-bling.ts:9, import-product-from-bling.ts:143,187 — SKU não URL-encoded; SKU com espaço/+/# gera query malformada.
  • R11 status-to-bling.tspartially_delivered não mapeado (mesma classe do C4).
  • R12 pnpm-lock.yaml não atualizado — o CI mascara com --no-frozen-lockfile, mas release/produção com --frozen-lockfile quebra (novo pacote de workspace + deps). Rebasear sobre a main (branch está 22 commits atrás) e commitar o lock.
  • R13 (cobertura) — dos 8 fixes do PR, só [RFC] Deploy #5 e [RFC] Freemium #7 têm teste; os 37 testes cobrem só parsers puros. Sem teste: System design #1, Configure Renovate - autoclosed #2, Sign up #3, Conventional commits #4, [RFC] Automatic updates #6 (a flag de rate-limit invertida — bug booleano silencioso), Automatic releases #8 e o log de falhas do commit System design #1.

🟡 Optional (resumo)

Preço não exportado em loja multiloja (export-product-to-bling.ts:93) · estoque de variação sempre relançado sem comparação · /estoques/saldos sem paginação (>100 variações importam 0) · feriados hardcoded só até 2027 · datas em UTC → off-by-one de fuso (tiny-erp subtrai 3h) · numeroLoja numérico quebra a busca no import · fallback de forma de pagamento pega data[0] de qualquer tipo · recursão de categoria sem guarda de ciclo · pictureId pode apontar imagem de outra variação · potencial vazamento de client_secret em logger.error(err) cru sobre AxiosError (bling-auth-callback.ts:47-48).

✅ Confirmado OK (não são bugs)

Fix #7 (direção da flag), #8 (typo other_config, com fallback de leitura legado intencional), #5 (Devolvido→cancelado no parser), #2 (preço divergente no caminho feliz).

⚠️ Não verificado (precisa de API/ambiente real)

Forma real das respostas do Bling v3 (saldoVirtualTotal, variacoes[].id no PUT — base de C6/R1) · se o Bling reseta preço de todas as variações no PUT do pai · paginação real de /estoques/saldos · colisão de SKU em products/skus:{sku} · serialização final do logger do firebase · qual pipeline usa --frozen-lockfile.


Revisão gerada com Claude Code (Opus) via /review-pr — 4 agentes adversariais em paralelo. Achados de maior alavancagem: C1 (race de token), C2 (fail-open) e C4/C5 (regressão de status) — nenhum pego por teste manual.

vitorrgg and others added 8 commits August 10, 2026 12:13
Pedido devolvido era enviado ao Bling como "aprovado", então o estoque não
retornava e a nota fiscal não era cancelada; agora vai como "cancelado", no
mesmo padrão do tiny-erp.

O callback público aceitava requisições sem token quando nenhum estava
configurado, permitindo importação forjada de pedidos e produtos na loja;
agora exige token e rejeita quando não há nenhum configurado.

Inclui testes de regressão para os dois casos. O round-trip que regride
"entregue" para "nf emitida" em contas Bling padrão fica marcado como todo,
pois o conserto pertence ao import.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Um refresh concorrente (cron e callback ao mesmo tempo) fazia o perdedor da
corrida receber invalid_grant e gravar isBloqued, desativando a integração da
loja até re-autorização manual, mesmo tendo havido um refresh bem-sucedido ao
lado. O Bling rotaciona o refresh_token a cada uso, então essa corrida é
esperada. Agora, ao falhar, o doc é relido e o token renovado por outro
processo é reusado; só bloqueia em invalid_grant genuíno sem refresh concorrente.

A contagem de erros transitórios passa a usar FieldValue.increment, evitando a
corrida de read-then-set. A decisão fica isolada num módulo puro, com testes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Contas Bling padrão não têm situações de envio/entrega, então "enviado" e
"entregue" colapsam em "Atendido", que volta como nf emitida. O pedido regredia
de "entregue" para "nf emitida" a cada importação. Agora o import ignora a
transição para trás dentro da esteira de fulfillment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A exportação comparava a quantidade da loja com saldoVirtualTotal (virtual,
somado de todos os depósitos), mas ajusta o saldo físico e a importação lê o
saldo do depósito configurado. Em lojas com reserva ou múltiplos depósitos isso
movimentava o estoque a cada exportação ou deixava de corrigir o físico. Agora
compara contra o saldo físico do depósito usado, a mesma base do import.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adiciona um job que roda `pnpm install --frozen-lockfile` em PRs que mexem em
qualquer package.json ou no lock. Hoje o CI instala com --no-frozen-lockfile e
mascara um lock desatualizado; este check falha em vez de regenerar em silêncio.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…urado

O fail-closed anterior rejeitava toda loja que nunca configurou callback_token
(campo opcional, não auto-gerado). Como o corpo do callback só traz
identificadores e os handlers re-buscam o dado no Bling autenticado, o risco de
um callback forjado é apenas disparar importação dos próprios dados da loja
(sem injeção). Não justifica derrubar lojas em produção, então volta ao
comportamento anterior: exige token apenas quando a loja configurou um.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A exportação comparava o estoque sempre pelo saldo físico, mas a importação usa
o saldo virtual quando a loja tem reserva de estoque (has_stock_reserve). Lojas
com reserva divergiam em toda exportação, sobrescrevendo o físico do Bling com o
virtual. Extrai parseStockFromDeposits para um helper único usado pelos dois
lados, garantindo a mesma base (virtual/físico e soma por depósito).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…estore

A releitura do doc de tokens no tratamento de erro do refresh não tinha catch:
uma falha do Firestore substituiria o erro original e pularia a gravação de
estado. Envolve em catch devolvendo undefined, preservando a decisão.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vitorrgg

Copy link
Copy Markdown
Member Author

🔄 Status atualizado — fixes aplicados + re-review adversarial do delta

Seguindo a revisão adversarial acima, os 6 Criticals foram tratados e, depois, rodei um segundo review adversarial focado só nos commits de fix (para pegar regressão que o próprio fix pudesse introduzir — o autor tem ponto cego sobre o próprio código). Esse re-review pegou 2 regressões nos meus fixes e corrigiu um exagero do review original — tudo já remediado. Estado verificado abaixo.

Placar final dos Criticals

# Estado Commits
C1 — race no refresh do token desativa a integração ✅ corrigido + hardening 8db7b7206, 9ba686120
C2 — callback público sem autenticação ✅ tratado (ver nota) c51443af9 → revertido em eec45a191
C4 — pedido devolvido não cancela no Bling ✅ corrigido c51443af9
C5 — round-trip regride status ✅ corrigido 7738fee86
C6 — base de estoque errada ✅ corrigido + refix 2dcaa6a0d303a028a0
C3 — OAuth state não validado ⏸️ risco aceito (ver nota)

O que o re-review encontrou (e como foi resolvido)

  • C6 — regressão nova (Critical) no meu próprio fix. O primeiro fix comparava sempre o saldo físico, mas a importação usa o virtual quando a loja tem has_stock_reserve — lojas com reserva voltariam a divergir em todo export. Refix: extraí parseStockFromDeposits para um helper único usado por import e export, garantindo a mesma base (virtual/físico + soma por depósito). 303a028a0.
  • C2 — severidade corrigida + regressão evitada. O review original tratou como "vetor de escrita não autenticado", mas o corpo do callback só traz identificadores — os handlers re-buscam o dado no Bling autenticado. O risco real é só disparar importação dos próprios dados da loja (sem injeção). Meu fail-closed derrubaria toda loja sem callback_token (campo opcional). Como o custo supera o ganho, revertido para "exigir token só quando configurado". eec45a191.
  • C1 — verificado sólido. O reuso do token concorrente é barrado por um invariante forte (expiredAt futuro ⇒ refresh genuíno); FieldValue.increment eliminou a corrida do contador. Adicionado hardening: releitura do doc protegida com catch. 9ba686120.
  • C5 — verificado sólido. Guard só afeta fulfillment, não bloqueia avanço legítimo. ⚠️ Trade-off by-design: correção "para trás" de fulfillment via Bling deixa de propagar (numa conta padrão "Atendido" mapeia p/ nf emitida). Correção manual passa a ser na loja — vale nota p/ o suporte.

Pendências (não-Critical)

  • C3 — decidido manter o lojista autorizando pelo portal do Bling; como não iniciamos o fluxo, não há state nosso a validar. Risco residual documentado.
  • R12 — lock não atualizado. Adicionei um check de CI (d2645980f, workflow "Lockfile integrity") que falha de propósito neste PR até o pnpm-lock.yaml ser regenerado com pnpm 10.17.0 no rebase sobre a main (a branch está 22 commits atrás).
  • Optionals do review original seguem válidos (preço multiloja, paginação de /estoques/saldos >100 variações, feriados até 2027, datas UTC, etc.) — não bloqueiam.

Verificação

Todos os fixes verificados com build real (pnpm build: tsc + eslint) e suíte real (node --test): a cobertura foi de 37 → 58 testes, pass 58 · fail 0 · todo 0. Cada Critical tem teste de regressão.

Fixes e re-review gerados com Claude Code (Opus). O re-review do delta rodou 3 agentes adversariais sobre os commits de fix — pegou C6 e C2 antes do merge.

@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 o port inteiro contra o app publicado (app-bling-erp-v2) e contra o tiny-erp como integração de referência. Antes dos problemas, o que está certo e não precisa ser revisitado:

As dez correções sobre o v1 são reais — conferi as que dava para conferir por código, e a de variação sem variacoes no PUT e a do outher_config (com fallback pro nome errado, migração bem feita em get-customer-bling.ts:13-14) são achados de quem foi atrás do comportamento, não de quem só transcreveu. Os testes são ganho de padrão: o tiny não tem nenhum, e o teste unitário de função pura offline não existia em app nenhum do monorepo — os irmãos só têm e2e atrás de credencial. A config compartilhada está limpa: nenhum appId duplicado entre os 31 apps, o nome exportado não colide, e o merge com main resolve automático. E as duas rodadas adversariais que você rodou pegaram coisa de verdade — os achados abaixo são o que sobrou depois delas, quase tudo em caminho sem cobertura de teste.

Também confirmei que after-bling-queue, order-to-bling, get-customer-bling, get-products-bling, payment-method e product-to-bling são ports fiéis, e que o canCreateNew tri-state, os nomes de config e o gate de preço/quantidade reproduzem exatamente o webhook.js:92-121 do app publicado. Nada disso é para mexer.

O que trava são quatro coisas, e as duas primeiras se compõem.

🏗️ Arquitetural — o lockfile.yml não pertence a esta PR

O d2645980f adiciona .github/workflows/lockfile.yml, que não tem relação nenhuma com o Bling. Três motivos para sair:

  1. Ele falha na própria PR que o introduzERR_PNPM_OUTDATED_LOCKFILE ... not up to date with <ROOT>/packages/apps/bling-erp/package.json. Sobe vermelho por desenho.
  2. O filtro de bot não funciona em PR. O if testa github.event.head_commit.author.name, que é campo de evento push; em pull_request é nulo, o contains dá falso e o job roda assim mesmo.
  3. Ele briga com o ciclo do renovate que este repo já aceita. Todo PR do renovate merga com os 20 importers de loja fora do lock, e o chore: Fix package versions and submodules post-release restaura depois — o histórico do lock mostra isso em 35e33f14e (#798) e d47f33902 (#786). O workflow transforma um processo aceito em falha permanente de CI.

Some-se que ele usa actions/checkout@v7 sem submodules:, então validaria o lock contra uma árvore onde os 20 importers não existem em disco. A ideia é boa e eu quero ela — mas em PR própria, com o filtro corrigido e a decisão sobre submódulo explícita.

🔴 Bloqueante — o estoque anda para baixo até zerar, em loja com qualquer reserva

parse-stock-from-deposits.ts:1-6 documenta a invariante:

Usado pelos DOIS lados (importação e exportação) para que a base de comparação nunca divirja

Só que o call site da importação a pula justamente na configuração padrão. import-product-from-bling.ts:35-53:

if (typeof blingItem.estoqueAtual !== 'number'
  && typeof blingItem.estoque?.saldoVirtualTotal === 'number') {
  blingItem.estoqueAtual = Math.max(0, blingItem.estoque.saldoVirtualTotal);   // vira número aqui
}
if (Array.isArray(blingItem.depositos)
  && (blingDeposit || typeof blingItem.estoqueAtual !== 'number')) {           // ← falso com bling_deposit vazio
  blingItem.estoqueAtual = parseStockFromDeposits(...);
}

Com bling_deposit vazio e has_stock_reserve desligado — o padrão — a importação grava o saldo virtual e a exportação compara contra a soma do físico (export-product-to-bling.ts:194-196, que sempre chama parseStockFromDeposits). Havendo qualquer pedido reservando estoque, virtual < físico e as duas bases nunca batem.

A catraca, com físico 20 e 5 reservados:

passo Bling físico reservado virtual loja
import 20 5 15 15
export (operacao: 'B') 15 5 10 15
import 15 5 10 10
export 10 5 5 10

E assim até zerar. O motor que roda o ciclo é o bloqueante seguinte.

Colateral do mesmo ponto: com bling_deposit vazio a comparação soma todos os depósitos, mas a escrita vai só para depositos[0].id (:180). Em conta multi-depósito a soma nunca converge e o depósito 0 é sobrescrito com o total da loja.

O C6 (tests/parse-stock-from-deposits.test.mjs:26) afirma exatamente essa invariante e passa — porque testa o helper, não o call site. É o que faz o CI ficar verde em cima disso.

🔴 Bloqueante — o app não marca as próprias escritas, e o evento volta para ele

A plataforma tem supressão de auto-evento, e é central: EVENT_SKIP_FLAG = '_skip' (packages/firebase/src/const.ts:1), e o poller consulta a API com 'flag!': EVENT_SKIP_FLAG (check-store-events.ts:134), então evento marcado nunca chega a app nenhum. O updateAppData usa (update-app-data.ts:55), inclusive publicando direto no tópico antes, para a fila andar sem depender do poller.

O import de estoque não usa:

// import-product-from-bling.ts:75-79
endpoint += '/quantity';
// @ts-ignore
return api.put(endpoint, quantity);      // ← sem X-Event-Flag

E o app assina products-quantitySet (config.ts:154), que o tiny não assina. Então todo callback de estoque do Bling grava na loja, o evento volta, e event-to-bling.ts:79 reexporta pro Bling. Custo por callback recebido, ainda que a catraca acima não estivesse lá: +2 leituras de Firestore, +4 requisições ao Bling, +1 PATCH e +4 s de throttle. Dobra o custo de toda sincronização de estoque.

Vale notar que nenhum app de packages/apps/ usa o flag em escrita de recurso — o tiny tem a mesma omissão, mas não assina evento de estoque, então nunca dispara. O Bling é o primeiro a materializar.

Marcar a escrita fecha os dois bloqueantes de uma vez: sem o eco, a divergência de base do anterior deixa de ser realimentada. Ainda assim eu alinharia as bases, porque a divergência sozinha já produz um POST /estoques desnecessário por exportação.

🔴 Bloqueante — o callback lê o próprio segredo do documento que o chamador escolhe

bling-callback.ts:34-49:

const applicationId = req.query._id;                       // ← escolhido pelo chamador
const appEndpoint = applicationId && typeof applicationId === 'string'
  ? `applications/${applicationId}`
  : `applications/app_id:${appId}`;
const application = (await api.get(appEndpoint)).data;
const appData = { ...application.data, ...application.hidden_data };

const callbackToken = process.env.BLINGERP_CALLBACK_TOKEN || appData.callback_token;
if (callbackToken) {
  if (req.query.token !== callbackToken) { res.sendStatus(401); return; }
}

Sem BLINGERP_CALLBACK_TOKEN no ambiente, um ?_id=<outro app instalado na loja> carrega um doc sem callback_token, o if não entra e a requisição segue sem autenticação. A partir daí:

  • :76 — no erro, afterQueue(queueEntry, appData, application, err) recebe o application que o atacante escolheu.
  • after-bling-queue.ts:76 — o callback usa isNotQueued: true e action: 'importation', então a condição reduz a isError puro. E o erro é garantido, porque o app escolhido não tem credencial Bling.
  • Resultado: escrita não autenticada no hidden_data de um documento de aplicação arbitrário da loja — até 200 entradas, notes de até 5000 caracteres cada, e o logs.unshift empurra para fora os logs reais do app vítima. O laço de :81-96 itera sobre pedidos/estoques do corpo da requisição, então o atacante controla quantas escritas por request.

O BLINGERP_CALLBACK_TOKEN neutraliza tudo — mas a action.yml não tem input para ele. Tem tinyerp-tokenTINYERP_TOKEN (:57-58,307,365) e nenhum equivalente Bling, então no caminho oficial de deploy ele só entra via custom-dotenv genérico. O estado padrão do deploy é o vulnerável, e o README.md:28-33 manda definir a variável sem que exista o caminho para isso.

Duas correções, e as duas são pequenas: resolver sempre por applications/app_id:${appId} (o fallback que já está lá), e adicionar o input na Action.

🔴 Bloqueante — erro sem .response descarta o item da fila do lojista

create-access.ts:50 lança Error puro para o limite diário do Bling. O contrato de retry de after-bling-queue.ts:38 só entra se payload.response existir; sem isso cai no else (notes = payload.stack) e segue direto para o splice incondicional de :92-108, que remove o id da fila.

O irmão resolve no produtor, não no consumidor — post-tiny-erp.ts:44-56:

if (tinyErrorCode <= 2) response.status = 401;
else if (tinyErrorCode === 6) response.status = 503;
else if (tinyErrorCode === 20) response.status = 404;
const error: any = new Error(...);
error.response = response;      // ← é isto que faz o gate do consumidor funcionar

O port copiou o consumidor literalmente e não portou o contrato do produtor que o sustenta. Consequência: quando o limite diário estoura — evento esperado, não excepcional, e que se auto-limpa em 12h — a exportação manual do lojista perde itens em silêncio. E vale para todo estado terminal novo que bling-auth/ vier a lançar.

A correção certa é o client.ts normalizar os próprios erros como o post-tiny-erp faz, não somar mais um else if no after-bling-queue.

🟠 Estruturais

Variação criada nasce com estoque 0 e não se corrige. export-product-to-bling.ts:207-218newVariations de responseData?.variations?.saved || responseData?.variacoes; variations.saved é shape que não existe na v3 (herdado quebrado do legado) e o POST /produtos responde só com o id. Com newVariations vazio, isUpdateStockVariation nunca dispara — e com export_quantity desligado o produto fica zerado para sempre.

O rastreio real é descartado. bling-callback.ts:83 desestrutura só { numero } do corpo do callback. O legado guardava transporte/codigosRastreamento na entrada da fila e fundia no pedido relido, com comentário explícito de que GET /pedidos/vendas/{id} nunca devolve urlRastreamento. Sem isso, todo rastreio importado recebe o link genérico do Melhor Rastreio, e volume que o Bling só expõe com urlRastreamento não gera rastreio nenhum (order-from-bling.ts:25 sai cedo). É regressão funcional contra o app publicado.

O smoke script mata a integração da loja. scripts/bling-smoke.mjs:31-50 se anuncia como somente leitura, mas a primeira coisa que faz é grant_type=refresh_token — e o próprio PR documenta que o Bling rotaciona o refresh token a cada uso. O README.md:56-62 manda rodar com o token de "uma loja já autorizada". Feito isso, o próximo refresh recebe invalid_grant, decideRefreshFailure não vê updatedAt novo e vai para isBloqued: true — integração morta até re-autorização manual.

A política de bloqueio está em dois lugares que discordam. check-enable-api.ts:17 usa janela de 24h; create-access.ts:36 usa 12h. E só o createAccess tem o ramo que limpa a flag — o gate roda antes (event-to-bling.ts:92, bling-callback.ts:55) e retorna false, então na trilha de evento e de callback nada destrava; só o cron. Entre 12h e 24h a integração fica escura enquanto a política já liberou. Vale notar que isso é comportamento novo: no legado o ramo gravava isRateLimit: false, então a flag nunca foi persistida em produção.

Sem camada de reconciliação. O único cron é o refresh de token, e a recuperação é o setTimeout(reject) dentro da janela de eventMaxAgeMs = 60000 — e só para item isQueued, porque o gate conflaciona "erro transitório" com "veio da fila manual". Evento automático não tem retry algum. O tiny pareia o mesmo handler com cronSendOrders varrendo pedidos pendentes a cada 3h. Composto com os dois itens acima, o caminho de volta à consistência é o lojista reenfileirar na mão.

O dedupe de retry sumiu sem substituto. O legado mantinha integration_retries/{...} com janela de 5 min. Restou o redelivery do PubSub, e como o splice só acontece no fim do afterQueue, a janela de duplo processamento é o handler inteiro. POST /estoques é idempotente por usar operacao: 'B'; POST /pedidos/vendas não é.

O throttle tem o escopo invertido. client.ts:33 guarda lastRequest em campo de instância e createBlingClient() é chamado dentro de cada handler. No legado isso era correto (um processo, N contas, limite por conta); numa instância por loja o escopo certo virou nível de módulo. Como está, checkTime curto-circuita na primeira request de toda invocação e não espaça chamadas concorrentes — as Promise.all de :187-228 calculam o mesmo atraso e disparam juntas contra um limite de 3 req/s.

Chaves de Firestore por storeId num projeto de uma loja. blingTokens/{storeId} e blingStatuses/{storeId} são os únicos documentos do monorepo particionados assim, e o storeId vem de ECOM_STORE_ID, constante de deploy. A convenção aqui chaveia pelo que varia — paypalTokens/${PAYPAL_CLIENT_ID}, pixSetup/${clientId}:${clientSecret}. Não é cosmético: o PayPal ainda deleta o doc no 401, porque a chave carrega a identidade da credencial. Aqui a chave é constante, então trocar client_id/client_secret nas configurações não invalida nada — o refresh token velho vai com credencial nova, dá invalid_grant e vira isBloqued. Rotação de credencial fica indistinguível de autorização revogada.

Terceira cópia do upload para a Storage API. try-image-upload.ts:16 tem ecomAccessToken module-scoped que nunca revalida; expirado em instância quente, todo upload baixa a imagem inteira, falha 401 e cai no fallback que hotlinka a URL do Bling para sempre, sem sinal de degradação. As outras duas cópias estão em tiny-erp/.../product-from-tiny.ts:26-64 e cli/src/ext/import-feed.ts:50. E os uploads são sequenciais (product-from-bling.ts:280-283) numa função sem timeoutSeconds explícito, ou seja 60s — produto com muitas imagens estoura e o import inteiro é descartado.

Trabalho pago antes de saber se é necessário. import-product-from-bling.ts:186-223 busca preço multiloja e importa categoria antes de descobrir que o caminho é isStockOnly — que é o de todo callback de estoque em loja sem update_product. export-order-to-bling.ts:74-77 busca /formas-pagamentos antes de saber se o pedido será criado, desperdiçando 1-2 chamadas em cada mudança de status de pedido já exportado. E export-product-to-bling.ts:100-104 refaz um GET /produtos/{id} idêntico em toda exportação de produto simples, porque a guarda não distingue resposta de listagem de resposta de detalhe. Tudo isso contra a mesma cota diária que o app tem uma máquina inteira para sobreviver.

Todo erro automático vira escrita de até ~1 MB. after-bling-queue.ts:76 inverteu a condição do tiny para logar também falha de evento automático — decisão deliberada e documentada, mas quem chega ali são os erros persistentes (os 429/5xx retornam antes). Em modo de falha estável, cada callback gera um PATCH de até 1 MB, em série, numa função maxInstances: 1.

🟢 Minors

  • client.ts:61Promise<any> onde o TS inferiria Promise<AxiosResponse>; é a única superfície pública do contrato Bling e ~30 call sites fazem .data.data sem checagem. Uma linha tipa todos.
  • bling-callback.ts:63handler: any no runQueueEntry, que é o segundo entrypoint de fan-out para os mesmos handlers; chama com 5 argumentos, mas import-order-from-bling.ts:22-26 declara 3. Um tipo IntegrationHandler cobriria os dois.
  • order-from-bling.ts:10 — retorna Record<string, any> enquanto o parser irmão product-from-bling.ts:114 retorna ProductSet; OrderSet está exportado no mesmo @cloudcommerce/types.
  • export-product-to-bling.ts:10getBlingStockBalances(bling: any) enquanto 6 helpers do pacote importam o tipo do client.
  • order-from-bling.ts:91-93else if (invoiceIndex && ...): índice 0 é falsy, então o caso normal nunca recebe o back-fill; e o branch não seta shipping_lines, então nem persistiria. Morto nas duas pontas.
  • order-from-bling.ts:96-108/notafiscal/{numero}/{serie} é endpoint v2 sob baseURL v3: 404 sempre, engolido pelo .catch(() => null). Dívida herdada, mas promete uma feature que não funciona.
  • scripts/tests.sh:10-13exit 1 sem lib/, enquanto todo tests.sh irmão sai 0. Com turbo.json:19-21 declarando test sem dependsOn, pnpm test:apps num checkout limpo derruba o fan-out. Raiz: "test": { "dependsOn": ["build"] }.
  • tests/*.test.mjs — importam de ../lib/**, a saída de build, em vez do fonte; acopla a suíte ao build-lib.sh.
  • tests/decide-refresh-failure.test.mjs:52 — 2 erros de eslint e 3 warnings de max-len. Nada no repo linta .mjs, então é o primeiro a normalizar o resto.
  • Comentários misturam português e inglês dentro do mesmo pacote (PT em 7 arquivos, EN em quantidade parecida); nos outros 30 apps não há comentário em PT.
  • Prefixos [STOCK]/[PRICE_MULTILOJA]/[CATEGORY_IMPORT] em log são um terceiro dialeto — o repo usa >/>> e structured logging no 2º argumento, que vira label filtrável no Cloud Logging.
  • describe dos testes prefixado por C1/C4/C5/C6, que referenciam um documento fora do repo, e em PT enquanto os outros dois arquivos da mesma suíte estão em inglês.
  • guard-fulfillment-transition.ts exporta shouldAdvanceFulfillment — é o único helper cujo nome de arquivo não casa com o símbolo.
  • payment-method.ts:16,47getPaymentBling exportado named e default.
  • order-to-bling.ts:6-13 — feriados hardcoded cobrindo só 2026-2027; em jan/2028 o dataPrevista degrada sem log e sem teste que falhe.
  • bling-auth-callback.ts:40 grava expiredAt com expires_in - 3600 e create-access.ts:73 com expires_in - 300 — dois escritores do mesmo campo discordando em 55 minutos.
  • Crontab '36,51 * * * *' para token de ~6h: ~46 execuções no-op/dia, cada uma com um getAppData e uma leitura de Firestore. Veio verbatim de um fan-out multi-tenant onde os dois minutos ímpares faziam sentido.
  • get-products-bling.ts:5-13 — monta codigo[]= com todos os itens do pedido sem limite/pagina nem chunking; a v3 pagina em 100.
  • Falta CHANGELOG.md — é o único dos 31 apps sem, e o __skeleton já traz um.

Pra entrar antes do merge

  1. Marcar o api.put(.../quantity) com X-Event-Flag: _skip e alinhar a base de estoque entre importação e exportação. As duas juntas fecham a catraca; a primeira sozinha para a realimentação, mas a divergência continua gerando POST /estoques desnecessário.
  2. Resolver o callback sempre por applications/app_id:${appId} e adicionar o input de BLINGERP_CALLBACK_TOKEN na action.yml.
  3. Normalizar os erros no client.ts para a forma que o after-bling-queue sabe classificar, como o post-tiny-erp faz.
  4. Tirar o lockfile.yml para PR própria.
  5. Corrigir o newVariations das variações criadas, e o aviso do smoke script no README — ou fazer o script parar de dar refresh.

Os 🟠 restantes dão para tratar em follow-up, mas queria tua leitura sobre quais viram issue antes do merge; nenhum deles tem issue aberta hoje, nem os seis follow-ups de packages/modules que você listou no corpo.

Uma ressalva de método: não rodei a suíte localmente (exige pnpm build antes, e o CI já a cobre verde), e o caminho de imagem, o OAuth e os callbacks não têm cobertura nenhuma — a validação deles foi leitura e rastreamento de fluxo, mais comparação com o app publicado. Os quatro bloqueantes eu conferi na fonte um por um antes de escrever.

vitorrgg and others added 16 commits August 13, 2026 20:09
Marca as escritas de importação na Store API com `X-Event-Flag: _skip`
(mesmo flag do polling de eventos), fechando o loop importação ->
evento -> exportação de volta ao Bling, que dobrava o custo de toda
sincronização de estoque e reexportava status de pedido recém-importado.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…servados

A importação passa a ler a quantidade sempre de `depositos[]` via
`parseStockFromDeposits`, a mesma base que a exportação compara, em vez
do `saldoVirtualTotal` quando não há depósito configurado: bases
divergentes faziam o estoque descer a cada ciclo até zerar.

Também deixa de exportar estoque em conta multi-depósito sem
`bling_deposit` configurado (a soma nunca converge e sobrescreveria o
primeiro depósito com o total da loja), e relê o produto criado no Bling
para inicializar o estoque das variações — o `POST /produtos` da API v3
responde só com o id, então `newVariations` ficava sempre vazio.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ário

O documento da aplicação é sempre resolvido pelo `app_id` fixo do Bling:
aceitar `?_id=` da query permitia ao chamador escolher o doc de outro app
instalado (possivelmente sem `callback_token`), pular a autenticação e
fazer o log de erro da fila gravar no `hidden_data` do app escolhido.

Também adiciona o input `blingerp-callback-token` na GitHub Action de
deploy, que era o caminho que faltava para definir a variável
`BLINGERP_CALLBACK_TOKEN` recomendada no README.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… Bling estoura

Os erros de estado da autenticação são normalizados na forma que o
`after-bling-queue` sabe classificar, como o app Tiny faz no
`post-tiny-erp`: limite diário vira status 429 (mantém o item na fila
para retry via redelivery em vez de removê-lo em silêncio) e token
inválido/não autorizado vira `isConfigError` com mensagem clara.

Alinha também a janela de rate limit do `checkEnableApi` (24h) com a do
`createAccess` (12h), que é quem limpa a flag — a janela maior deixava a
integração parada esperando o cron mesmo com a política já liberada.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s concorrentes

O controle de intervalo entre requisições vai para o nível de módulo:
como uma instância do client é criada dentro de cada handler, o campo de
instância nunca espaçava nada, e chamadas concorrentes (`Promise.all`)
calculavam o mesmo atraso e disparavam juntas contra o limite de 3
req/s. Cada chamada agora reserva o próximo slot de 1s.

Tipa também o retorno do client como `AxiosResponse` em vez de `any`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
O script passa a aceitar `BLING_ACCESS_TOKEN` direto (do doc do
Firestore, válido ~6h) como caminho preferido, sem refresh: o
`grant_type=refresh_token` rotaciona o token, e rodado com o refresh
token de uma loja ativa bloqueava a integração no próximo refresh
(`invalid_grant` -> `isBloqued`). O fluxo com refresh continua aceito,
com aviso explícito no script e no README para gravar o novo token de
volta.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
O workflow não tem relação com o Bling, falhava na própria PR que o
introduz e o filtro de bot não funciona em `pull_request`. Vai para PR
própria com o filtro corrigido e a decisão sobre submódulos explícita.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… Bling

O corpo do callback é a única fonte de `transporte`/`codigosRastreamento`
com `urlRastreamento` — o `GET /pedidos/vendas/{id}` nunca o devolve — e
estava sendo descartado: todo rastreio importado recebia o link genérico
do Melhor Rastreio, e volume só com URL não gerava rastreio nenhum. Os
dados do callback agora seguem na entrada da fila e são fundidos no
pedido relido da API, como o app publicado fazia.

Também corrige o back-fill da chave de acesso da nota em índice 0 (o
`else if (invoiceIndex && ...)` nunca rodava para o caso normal e não
persistia), remove o endpoint `/notafiscal` da API v2 que sempre
respondia 404 sob a baseURL v3, e tipa os handlers de integração dos
dois entrypoints com `IntegrationHandler`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Os docs `blingTokens` e `blingStatuses` passam a ser chaveados pelo
`client_id` (a identidade da credencial, como `paypalTokens`), não pelo
`storeId`, constante no projeto: trocar `client_id`/`client_secret` nas
configurações deixava o refresh token antigo ser usado com a credencial
nova, resultando em `invalid_grant` e integração bloqueada — rotação de
credencial ficava indistinguível de autorização revogada.

Unifica também o desconto do `expiredAt` entre os dois escritores
(autorização gravava -3600s e refresh -300s) numa constante única.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
O token da Storage API era module-scoped e nunca revalidado: expirado em
instância quente, todo upload baixava a imagem inteira, falhava com 401
e caía para sempre no fallback que hotlinka a URL do Bling. Agora o
token tem TTL de 30min e o upload é retentado uma vez após 401.

A função de eventos ganha `timeoutSeconds: 300` (o padrão de 60s estoura
em produto com muitas imagens, baixadas e reenviadas sequencialmente) —
com o repasse de `timeoutSeconds` adicionado ao `createPubSubFunction`
do pacote firebase, sem mudança de padrão para os demais apps. O cron de
refresh do token cai de 2x para 1x por hora, suficiente para a janela de
renovação de 70min de um token de ~6h.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…am o resultado

- Importação só de estoque pula preço multiloja e categoria, que não
  seriam usados no `PUT .../quantity`;
- Mudança de status de pedido já exportado não busca mais
  `/formas-pagamentos` (fica para quando o pedido vai ser criado);
- Exportação de produto simples não repete o `GET /produtos/{id}`
  quando a resposta de detalhe já foi carregada;
- Falha persistente repetida no callback não regrava o mesmo log
  (cada ocorrência virava um `PATCH` de até ~1MB no `hidden_data`);
- Busca de produtos por SKU pagina em lotes com `limite` explícito
  (a listagem da API v3 corta em 100).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- `getPaymentBling` com um export só (era named e default);
- Aviso quando a tabela de feriados hardcoded (2026-2027) expirar, em
  vez de degradar o `dataPrevista` em silêncio;
- `describe` dos testes sem os prefixos C1/C4/C5/C6 (referência a
  documento fora do repo) e em inglês como o resto da suíte;
- Lint limpo nos `.mjs` de teste;
- Helper renomeado para `should-advance-fulfillment` casando com o
  símbolo exportado;
- `turbo.json` com `test` dependendo de `build`, então `pnpm test:apps`
  funciona em checkout limpo (o `tests.sh` sai com erro sem `lib/`);
- `CHANGELOG.md` presente como nos demais apps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e saldo

O `GET /produtos/{id}` da API v3 não traz estoque nas variações e a consulta a
`/estoques/saldos` é tolerada com `catch`, então uma falha transitória (429, 5xx
ou o limite diário) deixava as variações sem `estoqueAtual`. O parser assumia 0
nesse caso e a importação gravava quantidade zero no produto e em todas as
variações, tirando a loja do ar até o próximo callback bem-sucedido.

Agora a quantidade só entra no body quando o Bling realmente devolveu um saldo;
sem saldo o produto mantém o estoque atual. Produto novo continua nascendo com
quantidade zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Quando a renovação do token falhava (`invalid_grant`, por exemplo), o erro do
`POST /oauth/token` chegava ao registro de falhas com o corpo da requisição
junto, e o `refresh_token` do Bling acabava salvo em texto puro no `hidden_data`
da aplicação — visível no painel do lojista e para qualquer token com leitura de
aplicações.

O corpo das requisições de autenticação deixa de ser registrado e todo o texto
do log passa por uma redação de `refresh_token`/`access_token`/`client_secret`,
preservando os dados que ajudam a diagnosticar a falha.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…o Bling

Dois problemas na busca do pedido já exportado:

- com "número aleatório" ligado, a busca era montada a partir do metafield
  `bling:numero`, que nunca era gravado: a primeira exportação de todo pedido
  consultava `/pedidos/vendas?numero=undefined` e a API do Bling rejeitava a
  requisição, derrubando a exportação. Sem número a procurar a busca agora é
  pulada, e o número usado passa a ser guardado no pedido;
- quando o número da loja já existia no Bling (numeração compartilhada com outro
  canal), a exportação gerava um número alternativo que o parser descartava,
  reenviando o número duplicado e tendo o pedido recusado a cada mudança de
  status. O número resolvido pela exportação agora é respeitado.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`pnpm test:apps` executa a tarefa `test:apps` do turbo, que nenhum pacote de app
declara — os testes do Bling nunca chegavam a rodar no CI. O pacote passa a
declarar o script, e a dependência de `build` sai da tarefa `test` (que cobre os
demais pacotes) para `test:apps`, que é quem precisa do `lib` compilado.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@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 12 commits que vieram depois da review anterior. Os quatro bloqueantes fecharam, e o arquitetural também — conferi cada um no código, não na mensagem de commit:

  • A catraca de estoque acabou: export-product-to-bling.ts:209 compara pela mesma parseStockFromDeposits que a importação grava, e o colateral do multi-depósito virou guarda explícita em :190.
  • skipEventHeaders está nos cinco pontos de escrita na Store API.
  • O callback resolve sempre por applications/app_id:${appId}, com comentário explicando o vetor (bling-callback.ts:35-38).
  • newRateLimitError() carrega err.response = { status: 429 }, que é exatamente o contrato que o after-bling-queue.ts:36 sabe classificar — e o splice fica depois do return, então o item permanece na fila.

E quase todos os 🟠 também: newVariations ganhou releitura de /produtos/{id} quando o POST não devolve as variações; os tokens passaram a ser chaveados por client_id, seguindo o paypalTokens/${PAYPAL_CLIENT_ID}; a janela de bloqueio virou constante única de 12h; o throttle subiu para escopo de módulo; o upload de imagem ganhou TTL e as funções ganharam timeoutSeconds; e o log de erro ganhou dedupe. Os minors saíram quase todos — Promise<AxiosResponse>, IntegrationHandler, o rename do should-advance-fulfillment, o CHANGELOG, o EXPIRES_IN_GAP_SEC unificado, o crontab.

Também confirmei que o pnpm-lock.yaml sem entrada do bling-erp não bloqueia nada: nenhum pipeline usa --frozen-lockfile (o único pnpm install de CI é test-apps.yml:70, explicitamente --no-frozen-lockfile; o action.yml usa npm ci sobre o lock do repo da loja; e o release.mjs:46 publica com pnpm publish -r seguido de pnpm fix-install, que é o que regenera o lock). Tirar o lockfile.yml não deixou buraco.

O que sobrou são dois bloqueantes — e o primeiro é culpa da minha recomendação anterior, que nomeou o problema certo e o mecanismo errado.

🔴 Bloqueante — o X-Event-Flag: _skip não é por app: ele apaga o evento para a loja inteira

Eu pedi para marcar as escritas do app com o flag. O flag existe, funciona, e o commit 3bd3c33 aplicou certo. Só que ele não tem escopo por app, e eu não verifiquei isso antes de recomendar.

check-store-events.ts:133-137 monta um filtro único:

const baseApiEventsFilter = {
  'flag!': EVENT_SKIP_FLAG,
  ...
};

Esse filtro roda uma consulta por nome de evento, e o resultado é distribuído a todos os assinantes no fan-out de :262-266 (activeApps.map(...) com appConfig?.events.includes(listenedEventName)). Evento marcado não volta da consulta, logo não chega a app nenhum.

Quem assina orders-anyStatusSet, por config.ts: emails (1243), melhorEnvio (1236), loyaltyPoints (124890), tinyErp (105922), evendas (109851), affiliateProgram (119753) e webhooksApp (123113). Sete apps além do Bling.

O caminho concreto: o lojista marca o pedido como "Atendido"/"Enviado" no Bling → o callback importa → import-order-from-bling.ts:104 faz api.post('orders/{id}/fulfillments', ..., { headers: skipEventHeaders }) → o orders-anyStatusSet é suprimido → o cliente nunca recebe o e-mail de "pedido enviado". Junto vão o Melhor Envio, os pontos de fidelidade, a comissão do afiliado e os webhooks. Tudo em silêncio, sem erro em lugar nenhum.

O mesmo vale para products-quantitySet e products-priceSet (import-product-from-bling.ts:80,108,111), que o pagarMeV5 assina.

O eco que eu queria matar era blingErp → blingErp. A supressão pega todos.

A direção certa é filtrar o auto-evento no consumidor, não na origem: packages/api/types.d.ts:382 mostra que o evento carrega authentication_id, e o app conhece o próprio (getEnv().apiAuth.authenticationId). Ignorar em event-to-bling.ts os eventos de autoria do próprio app resolve o eco sem esconder nada dos outros sete. Isso também torna o skip-event-headers.ts desnecessário para escrita de recurso — o flag continua legítimo para applications, que é o uso do updateAppData.

🔴 Bloqueante — o rastreio vem do corpo do callback e é escrito sem confirmação

O callback aceita requisição sem token quando nenhum está configurado, e isso foi decidido conscientemente: o corpo só traz identificadores e os handlers rebuscam o dado autenticado no Bling. Fui conferir se a premissa se sustenta em todos os caminhos. No estoque, sim — import-product-from-bling.ts usa do corpo só a referência e busca quantidade em /produtos e /estoques/saldos. No pedido, não:

// import-order-from-bling.ts:46-56
const callbackOrder = queueEntry._callbackOrder;
if (callbackOrder) {
  if (callbackOrder.transporte?.volumes?.length) {
    blingOrder.transporte = { ...blingOrder.transporte, volumes: callbackOrder.transporte.volumes };
  }
  if (callbackOrder.codigosRastreamento && !blingOrder.codigosRastreamento) {
    blingOrder.codigosRastreamento = callbackOrder.codigosRastreamento;
  }
}

Esses dois campos vêm direto do corpo e não são confirmados. order-from-bling.ts:29-38 monta { code, link: volume.urlRastreamento || <fallback> } e grava em shipping_lines[].tracking_codes via api.patch. Um POST anônimo com o numero de um pedido real e um urlRastreamento arbitrário escreve o link de rastreio que o cliente vai clicar — na página do pedido e no e-mail. Número de pedido é sequencial.

Trava parcial: isGeneratedFallback (:15-21) só deixa sobrescrever quando não há rastreio ou quando o existente é o fallback gerado. Não dá para trocar um código real já vindo do Bling, mas qualquer pedido ainda não despachado está aberto.

E isso é estrutural, não descuido: entrou no c8caabd, o commit que corrigiu o meu 🟠 do rastreio descartado, e o comentário explica — GET /pedidos/vendas/{id} nunca devolve urlRastreamento, então o corpo é a única fonte. Corrigir aquele ponto exigiu confiar no corpo.

Some-se o custo de carga: a função HTTPS não tem maxInstances, cada callback de estoque custa até 4-7 chamadas ao Bling e o client.ts:21 serializa a 1 req/s. Um chamador anônimo queima a cota diária, e aí create-access.ts:16 levanta o limite e check-enable-api.ts:12 apaga a integração por 12h.

Agora que o input blingerp-callback-token existe na action.yml, exigir o token deixou de custar caro: deploy novo nasce coberto. Se preferir manter o fail-open, o mínimo é parar de escrever campo vindo do corpo sem confirmação.

🟠 Estruturais

timeoutSeconds: 300 sem subir o eventMaxAgeMs torna o retry inerte. pubsub.ts:26 mantém eventMaxAgeMs = 60000 e :45-49 descarta o evento cuja idade passa disso. O bling-erp.ts:21 passa timeoutSeconds: 300 e deixa o default — então, no exato cenário que motivou o timeout (importação com muitas imagens), a primeira tentativa falha, o failurePolicy reentrega, e a reentrega chega com idade acima de 60s e morre em "Dropping event". Pior: com maxInstances: 1 segurando a instância por até 5 minutos, todo evento publicado nesse intervalo envelhece e é descartado — um produto pesado apaga ~5 minutos de eventos de preço, estoque e pedido da loja. Como este é o único uso do knob no repo, o próximo app copia o par errado.

A guarda de múltiplos depósitos é larga demais. export-product-to-bling.ts:190 testa stockBalances.depositos?.length > 1, não "2+ depósitos com saldo". Numa conta com "Geral" + qualquer segundo depósito (devoluções, avaria) e sem bling_deposit configurado, o return acontece antes de todo o bloco de estoque: o produto é criado no Bling e fica com estoque 0 para sempre, porque toda exportação seguinte para no mesmo ponto. O único sinal é um logger.warn, que não entra em logs do hidden_data — o lojista não vê nada no painel. A premissa do comentário só vale quando o outro depósito carrega saldo.

bling_deposit configurado mas ausente no produto cai no caso pior. parse-stock-from-deposits.ts:16 faz const deposits = depositFind ? [depositFind] : blingItem.depositos; — id que não bate faz a função somar todos os depósitos, dos dois lados, em silêncio. A guarda de :190 só dispara com !blingDeposit, então essa combinação passa: a comparação usa a soma e a escrita vai para o id configurado. É exatamente a divergência que o fix se propôs a eliminar, e um id digitado errado é indistinguível de um correto.

O callback ficou com timeoutSeconds: 120 enquanto o throttle virou 1 req/s. bling-callback.ts:82-131 processa pedidos e estoques em laços sequenciais com await; cada importProduct faz 4-7 chamadas ao Bling e cada importOrder faz 3. A ~1s por chamada, isso dá ~4-7s por SKU. A partir de ~20 itens num mesmo callback a função estoura os 120s, devolve 500 e perde o resto do lote — as entradas são isNotQueued: true, então não há retry nem permanência em fila. O onStoreEvent ganhou 300s no mesmo delta; o callback não.

A troca de chave storeIdclientId não tem migração. tokens-doc.ts:30-33 é a escolha certa e eu pedi por ela, mas loja já autorizada antes do deploy passa a não ter doc: check-enable-api.ts:16-18 retorna falsebling-callback.ts:57 devolve 403 e event-to-bling.ts:98 retorna null, ambos só com logger.warn, nada em logs. A integração para sem sinal até reautorizar. Se a 1011 foi autorizada durante a PR, vale nota de release.

Seguem sem resposta, e tudo bem tratar em follow-up (os dois já eram follow-up aceitável na review anterior): não há camada de reconciliação — o único cron continua sendo o refresh de token, e evento automático não tem retry; e o dedupe de retry não voltou, então a janela de duplo processamento do redelivery do PubSub continua sendo o handler inteiro, com POST /pedidos/vendas não idempotente dentro dela.

🟢 Minors

  • try-image-upload.ts:16-32,67-75 — o fix do token quente entrou só aqui; o gêmeo tiny-erp/src/integration/parsers/product-from-tiny.ts:26-45 tem o mesmo bug latente em produção, sem correção. São ~15 linhas para portar, ou uma issue antes que ninguém lembre. Não vale extrair as duas cópias agora: não há pacote comum de apps e @cloudcommerce/firebase teria que ganhar axios e image-size para 2 apps.
  • export-product-to-bling.ts:161 e :226 — dois GET /produtos/{blingProductId} idênticos na mesma execução (produto novo com variações de preço divergente entra nos dois blocos, que não são exclusivos), num PR cujo eab62a6 é justamente sobre poupar a cota diária.
  • after-bling-queue.ts:77-86 — o dedupe compara só com logs[0], então dois recursos falhando alternadamente nunca casam e o flood volta; e quando deduplica, o timestamp da primeira ocorrência nunca é atualizado.
  • skip-event-headers.ts:9 — se o flag continuar sendo usado para applications, vale importar EVENT_SKIP_FLAG em vez do literal. Falta só uma linha no exports de packages/firebase/package.json ("./lib/const"); os dois lados do contrato na plataforma já usam a constante (update-app-data.ts:55, check-store-events.ts:134).
  • client.ts:12-15 — o comentário afirma que "um campo de instância nunca espaçaria nada", e isso é falso: o checkTime antigo espaçava as chamadas sequenciais da mesma instância. O que ele não cobria era concorrência. O trade-off real que o escopo de módulo introduziu — todo tráfego Bling do processo serializado a 1 req/s, entre handlers independentes — fica escondido.
  • check-enable-api.ts:12RATE_LIMIT_WINDOW_MS ficou no consumidor e é importado pelo create-access, que é quem implementa a política. O EXPIRES_IN_GAP_SEC do mesmo delta foi para o tokens-doc, módulo neutro. Duas convenções para o mesmo problema no mesmo commit.
  • check-enable-api.ts:5-11 — o bloco de doc do checkEnableApi ficou colado na constante inserida entre ele e a função.
  • order-to-bling.ts:16hasWarnedHolidays é estado mutável de módulo dentro do arquivo que o scripts/tests.sh:3 documenta como função pura, e a suíte importa esse parser direto: a ordem dos testes passa a importar.
  • import-product-from-bling.ts:29,66isStockOnly virou parâmetro morto: ele implica !update_product, então o primeiro disjunto do guard nunca muda o resultado, e o isQuantityOnly novo (:291) já escreve a forma reduzida. É a terceira cópia do mesmo predicado.
  • integration-handler.ts:14export default de um tipo, sem consumidor (os dois importadores usam import nomeado). O precedente do repo é pubsub.ts:96, export type { PubSubHandler, ApiEventHandler } no módulo dono.
  • export-product-to-bling.ts:190 — o stockBalances && é guarda morta: ter passado pelo if (!estoqueId) return com !blingDeposit já prova que depositos[0].id é truthy.
  • export-product-to-bling.ts:80,93,106isDetailLoaded é derivável de blingProducts, que segue em escopo e inalterado.
  • turbo.json — a aresta test → build resolve o caso do bling-erp, que é o único app cujos testes importam lib/ em vez de rodar contra o emulador. A task test:apps continua sem dependsOn, então as duas definições divergem. Encosta na issue #237, que segue aberta.
  • O corpo da PR ainda diz 37 testes; são 58.

Pra entrar antes do merge

  1. Trocar a supressão de auto-evento: filtrar por authentication_id no event-to-bling.ts em vez de marcar a escrita na origem. Do jeito atual, importar status do Bling apaga o e-mail de "pedido enviado" e mais seis integrações.
  2. Parar de escrever o rastreio vindo do corpo do callback sem confirmação — ou exigir o token, agora que a action.yml tem o input.
  3. Subir o eventMaxAgeMs junto com o timeoutSeconds, e alinhar o timeout do callback com o throttle novo.
  4. Estreitar a guarda de depósitos para "2+ com saldo", e tratar o bling_deposit que não bate como erro visível em vez de somar tudo.

Os demais 🟠 dão para tratar em follow-up; nenhum deles tem issue aberta hoje.

Uma ressalva de método, como da outra vez: não rodei a suíte (o worktree está sem node_modules e o CI a cobre verde). Os dois bloqueantes eu tracei na fonte um por um — o do flag inclusive contra a minha própria recomendação anterior, que estava incompleta.

vitorrgg and others added 4 commits August 21, 2026 09:58
…es do Bling

O eco importação -> exportação passa a ser cortado no consumidor, ignorando
em `event-to-bling.ts` os eventos com o `authentication_id` do próprio app.
O `X-Event-Flag: _skip` nas escritas de recurso saiu: o flag não tem escopo
por app — `check-store-events.ts` filtra `flag!=_skip` numa consulta única
antes do fan-out —, então marcar um status importado do Bling apagava o
`orders-anyStatusSet` para os outros sete assinantes (e-mail de "pedido
enviado", Melhor Envio, fidelidade, afiliados, webhooks) e o
`products-*Set` para o Pagar.me v5. Apontado na review de 19/08.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…alidado

O `GET /pedidos/vendas/{id}` nunca devolve `urlRastreamento`, então
`transporte`/`codigosRastreamento` vêm do corpo do callback — mas sem token
configurado o callback é anônimo, e um POST forjado com o número (sequencial)
de um pedido ainda não despachado gravava o link de rastreio que o cliente
clica na página do pedido e no e-mail. O `_callbackOrder` agora só entra na
fila quando o token foi validado; requisição sem token segue aceita apenas
para disparar importação por identificadores, com todo dado rebuscado
autenticado no Bling. Apontado na review de 19/08.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s longas

O `timeoutSeconds: 300` do `onStoreEvent` mantinha o `eventMaxAgeMs` padrão
de 60s: a reentrega do `failurePolicy` após um timeout chegava sempre velha
demais e morria em "Dropping event", e com `maxInstances: 1` uma importação
de produto pesado envelhecia e apagava os eventos publicados no intervalo.
A idade máxima sobe para 10min junto com o timeout. O callback HTTPS sobe de
120s para 300s: com o client Bling serializado a ~1 req/s e 3-7 chamadas por
item, um lote de ~20 itens estourava o timeout e perdia o resto sem retry
(entradas `isNotQueued`). Apontado na review de 19/08.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…pósito mal configurado

A guarda de múltiplos depósitos travava com `depositos.length > 1` mesmo
quando o segundo depósito (devolução/avaria) estava vazio, deixando o
produto criado no Bling com estoque 0 para sempre e só um `logger.warn` que
o lojista nunca vê. Agora ela só dispara com 2+ depósitos COM saldo e vira
`isConfigError`, visível nos `logs` do painel; sem depósito configurado a
escrita vai para o (único) depósito com saldo, convergindo com a base
comparada. E `bling_deposit` configurado mas ausente na resposta do Bling
deixou de somar todos os depósitos em silêncio — um id digitado errado era
indistinguível de um correto — e passa a falhar com erro de configuração
visível, nos dois lados (import e export). Apontado na review de 19/08.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vitorrgg

Copy link
Copy Markdown
Member Author

🔄 Review de 19/08 — os 4 pontos "pra entrar antes do merge" aplicados

Os dois 🔴 e os dois itens estruturais promovidos a pré-merge entraram em 4 commits, um por ponto:

🔴 1 · Supressão de auto-evento trocada pelo filtro no consumidor — cc9ee50c1

Você tinha razão nos dois níveis: o problema (eco) e o mecanismo errado (o flag não tem escopo por app). O corte agora é em event-to-bling.ts, ignorando eventos cujo authentication_id é o do próprio app (getEnv().apiAuth.authenticationId), e o X-Event-Flag: _skip saiu das 5 escritas de recurso — helpers/skip-event-headers.ts foi deletado, já que o uso legítimo do flag para applications vive dentro do updateAppData da plataforma. Status importado do Bling volta a disparar orders-anyStatusSet para os outros sete assinantes (e-mail de "pedido enviado", Melhor Envio, fidelidade, afiliados, webhooks), e products-*Set para o Pagar.me v5.

🔴 2 · Rastreio do corpo do callback só com token validado — 1c9926c18

Fiquei com o caminho do meio que você deixou aberto: o fail-open segue para disparar importação por identificadores (todo dado rebuscado autenticado no Bling), mas _callbackOrder — os únicos campos copiados do corpo — só entra na fila quando o token foi validado. POST anônimo não grava mais transporte/codigosRastreamento em pedido nenhum. O warn de "sem token" agora avisa explicitamente que o rastreio do corpo não será importado, empurrando a configuração do blingerp-callback-token que já existe na action.yml. Loja sem token perde só o urlRastreamento (cai no link de fallback gerado), não a importação de status.

🟠 3 · eventMaxAgeMs subiu junto com o timeoutSeconds27b8dda1a

onStoreEvent agora passa eventMaxAgeMs: 10min com o timeoutSeconds: 300 — a reentrega do failurePolicy após um timeout deixa de morrer em "Dropping event", e os eventos que envelhecem atrás do maxInstances: 1 durante uma importação pesada sobrevivem à fila. O comentário no código explica o par para o próximo app não copiar o knob pela metade. O callback HTTPS subiu de 120s para 300s, alinhado ao throttle de ~1 req/s × 3-7 chamadas por item.

🟠 4 · Guarda de depósitos estreitada + bling_deposit inválido visível — 7c92a2745

  • A guarda só dispara com 2+ depósitos COM saldo (hasDepositBalance, exportado do mesmo helper que dá a base de estoque): conta com "Geral" + depósito vazio de devolução/avaria não trava mais o produto em estoque 0 para sempre.
  • Quando dispara, virou isConfigError em vez de logger.warn — a falha aparece nos logs do painel dizendo para configurar o bling_deposit.
  • Sem depósito configurado, a escrita vai para o (único) depósito com saldo, não mais depositos[0] — a base comparada e a base escrita convergem (escrever no depósito vazio divergiria para sempre).
  • bling_deposit configurado mas ausente na resposta do Bling deixou de somar todos em silêncio: parseStockFromDeposits lança isConfigError nos dois lados (import e export), então um id digitado errado deixa de ser indistinguível de um correto.

De lambuja, dois minors seus caíram junto: o stockBalances && morto sumiu na reestruturação da guarda, e o corpo da PR foi corrigido de 37 para o total atual de testes.

Verificação

pnpm build (tsc + eslint) limpo e suíte real com 71 testes, pass 71 · fail 0 — incluindo regressão nova para o bling_deposit que não bate (erro visível, não soma) e para o hasDepositBalance da guarda.

Follow-ups (mantidos fora, como você classificou)

Sem issue aberta ainda para: migração/nota de release da chave storeId → clientId dos tokens, dedupe de log além de logs[0], camada de reconciliação + dedupe de redelivery do PubSub, port do fix do token quente para o tiny-erp, e os demais 🟢. Abro as issues antes do merge para nada se perder.

Fixes gerados com Claude Code (Opus) a partir da review de 19/08; cada ponto verificado com build e suíte reais antes do push.

@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, sobre os 4 commits que responderam aos pontos pré-merge de 19/08. Três fecharam e estão verificados no código:

  • Rastreio do corpo só com token validado (1c9926c18) — _callbackOrder: isTokenValidated ? … : undefined, e o isTokenValidated é confiável porque a comparação com 401 vem antes: passar dali significa "token exigido e conferido". De lambuja o degradê é auto-curável — isGeneratedFallback deixa o link real sobrescrever o fallback depois que o token for configurado.
  • eventMaxAgeMs junto com o timeoutSeconds (27b8dda1a) — a opção existe de verdade no pubsub.ts:16,26,45, e o comentário no código explica o par para o próximo app não copiar o knob pela metade. Callback de 120s para 300s.
  • Guarda de depósitos + bling_deposit inválido visível (7c92a2745) — conferi a convergência nos três casos (1 depósito com saldo / nenhum com saldo / 2+ com saldo), e o throw novo do parseStockFromDeposits é pego pelo runQueueEntry (bling-callback.ts:66-83) → afterQueueisConfigErrornotes nos logs do painel, sem derrubar o resto do lote. Bem feito.

O quarto trocou o mecanismo errado por outro que quebra mais.

🔴 Bloqueante — o filtro por authentication_id não separa o Bling dos outros apps, separa o deploy inteiro da loja

event-to-bling.ts:43-48 ignora eventos cujo authentication_id é o do próprio app:

if (
  apiEvent.authentication_id
  && apiEvent.authentication_id === getEnv().apiAuth.authenticationId
) {
  logger.info(`>> ${key} - Skipped self-caused event`);
  return null;
}

Só que getEnv().apiAuth.authenticationId é o ECOM_AUTHENTICATION_ID (packages/config/src/env.ts:32,38), e packages/api/src/api.ts:90 mostra que toda requisição do @cloudcommerce/api no deploy autentica com ela. É uma credencial por loja, escrita uma vez no functions/.env pela action.yml e compartilhada por todos os codebases — esse é o padrão da plataforma para todos os apps, não um detalhe do Bling. Nenhum app do monorepo consegue se identificar por authentication_id, porque todos escrevem com a mesma.

Então o authentication_id de um evento causado pelo Bling é idêntico ao de um evento causado pelo checkout, pelos webhooks de pagamento, pelo tiny-erp, pelo loyalty-points. A prova de que esses eventos chegam ao app é o próprio eco que o filtro quer matar: ele existe justamente porque escrita com ECOM_AUTHENTICATION_ID volta como evento.

O caminho concreto, que é o principal da integração:

  1. Cliente paga → o app de pagamento faz api.post('orders/{id}/payments_history')asaas-events.ts:73, pagarme-webhook.ts:108, woovi-events.ts:102, galaxpay/webhook.ts:316,409, mp-webhook.ts:81, paghiper/handle-webhook.ts:129, paypal-events.ts:135, braspag-functions.ts:108, pagaleve-webhook.ts:100, yapay-events.ts:98, loyalty-create-transaction.ts:35;
  2. financial_status muda → orders-anyStatusSet (check-store-events.ts:51-56);
  3. event-to-bling.ts retorna null.

Nenhum pedido é exportado automaticamente para o Bling.

O que sobrevive é exatamente o que um teste manual exercita: status mudado à mão pelo lojista no painel (autenticação de usuário, diferente) e a fila manual — updateAppData publica direto no tópico com authentication_id: null (update-app-data.ts:34), então o && do guard nem entra. O caminho automático morre em silêncio, com um logger.info de "Skipped self-caused event".

E se o campo vier ausente — ele é opcional no tipo (packages/api/types.d.ts:382) — o guard vira no-op e o eco volta inteiro. Nos dois casos o campo não resolve, porque não carrega a informação "qual app escreveu". Vale notar que o bling-erp é hoje o único leitor de authentication_id em packages/ — não há precedente, e não convém criar um, porque qualquer app que copiar isso quebra da mesma forma.

O que muda o tamanho do problema: o eco custa cota, não corrompe dado

Fui medir quanto o eco ainda dói depois que a catraca de estoque fechou na rodada anterior:

  • export-product-to-bling.ts:222 — só posta /estoques quando blingBalance !== productQuantity;
  • export-order-to-bling.ts:248 — só faz o PATCH de situação quando String(situacao?.id) !== String(newStatusBling.id);
  • as duas exportações só escrevem metafields de volta na loja (:144, :204), e metafields não casa com nenhum modified_fields dos eventos assinados (financial_status/fulfillment_status/status, price, quantity).

Ou seja: o eco termina em um salto — import → evento → export encontra igual → não escreve nada. Depois que as bases de estoque foram alinhadas, ele deixou de ser bug de dado e virou custo: ~4-7 chamadas ao Bling e alguns segundos de throttle por importação.

Trocar isso por "nenhum pedido é exportado automaticamente" é um mau negócio. Os caminhos, em ordem de custo:

  1. Tirar o filtro por autoria. O eco já é limitado, idempotente e termina sozinho. Isso destrava a exportação automática hoje, sem nada em troca além da cota.
  2. Se a cota incomodar, marcador por recurso com TTL curto{resourceId, timestamp} no Firestore, ignorar evento do mesmo id por ~60s. É o que o app v1 fazia com integration_retries, é preciso, e não depende de autoria.

Uma ressalva de método: é a segunda recomendação minha nesse mesmo ponto, e a primeira (o X-Event-Flag: _skip) errou o escopo. Desta vez conferi as duas pontas antes de escrever — a credencial única em api.ts:90 e as guardas de igualdade nos dois exports.

🟢 Minors novos

  • hasDepositBalance não considera has_stock_reserve, enquanto parseStockFromDeposits escolhe a base por ele. Depósito com saldo: 0 e saldoVirtual > 0 conta como "com saldo" na guarda mas soma 0 na base — uma linha para alinhar, num helper cujo comentário promete que "a base de comparação nunca divirja".
  • estoqueId = blingDeposit || depositsWithBalance[0]?.id || blingDeposits[0]?.id — sem bling_deposit e com o saldo num depósito secundário (devolução, avaria), a escrita passa a ir para ele em vez do "Geral". Converge, mas é semanticamente estranho e invisível para o lojista.
  • Callback em 300s continua sem maxInstances: se o Bling desistir e reenviar antes disso, o mesmo lote roda concorrente. Antes a janela era 120s.
  • CodeFactor marca 14 issues no head, mas a página não lista nenhuma e o eslint local acusa só um max-len (create-access.ts:131). Provavelmente ruído, mas trava o gate visual.
  • A suíte verde (71 testes) não cobre nada disso — são unitários de função pura, e o filtro de evento não tem teste nenhum. Mesmo padrão do C6 da primeira rodada, em que o CI ficou verde em cima do bloqueante.

Pra entrar antes do merge

Um item: trocar o filtro por autoria. Os outros três pontos da rodada anterior estão fechados e verificados.

E os follow-ups continuam sem issue aberta — migração/nota de release da chave storeId → clientId, dedupe de log além de logs[0], camada de reconciliação + dedupe de redelivery do PubSub, e o port do fix do token quente para o tiny-erp. Você disse que abriria antes do merge; enquanto não abre, some.

Ressalva de método, como nas outras rodadas: não rodei a suíte (este checkout está sem node_modules do pacote e o CI a cobre verde). O bloqueante eu tracei na fonte, dos dois lados.

vitorrgg and others added 2 commits August 24, 2026 15:26
…tidade

Com "exportar estoque" ligado, qualquer mudança de quantidade na loja
(`products-quantitySet`) reexportava o documento inteiro do produto com
`PUT /produtos/{id}`, sobrescrevendo no Bling edições feitas por lá — nome,
descrição, preço, dimensões, NCM — a cada movimentação de estoque. O mesmo
caminho é o eco de um callback de estoque do próprio Bling (importação ->
evento -> exportação), que assim desfazia no Bling o que o lojista acabou de
editar, além de custar 2-3 chamadas extras da cota por movimentação.

Evento de quantidade agora só compara e lança estoque: o corpo do produto
continua sendo montado (o lançamento por variação depende dele para casar
variação da loja com o id do Bling), mas nada além de `/estoques` é escrito.
Produto ainda não exportado não é criado a partir de evento de quantidade.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rigem

O filtro por `authentication_id` descartava muito mais que o eco do próprio
app: todos os apps do deploy autenticam na Store API com a mesma credencial
(`ECOM_AUTHENTICATION_ID`), então o pedido pago — gravado em
`payments_history` pelo app de pagamento — chegava como evento "de autoria
própria" e era ignorado. Nenhum pedido era exportado automaticamente ao
Bling; só mudanças manuais no painel e a fila manual (publicada com
`authentication_id: null`) passavam, que é exatamente o que um teste manual
exercita.

O filtro sai e o eco importação -> evento -> exportação passa a ser aceito:
ele termina em um salto porque a exportação compara antes de escrever —
evento de quantidade não reexporta o produto (commit anterior) e
estoque/situação iguais não geram requisição.

A decisão evento -> fila é extraída para `parse-event-config` (função pura,
como `decide-refresh-failure`) com a matriz de testes que faltou nas duas
tentativas anteriores de cortar o eco: todo evento assinado é processado,
nunca filtrado por autoria.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vitorrgg

Copy link
Copy Markdown
Member Author

Terceira rodada respondida em dois commits. Antes de mexer, confirmei a mecânica do bloqueante na fonte: api.ts:90 autentica toda requisição do deploy com o ECOM_AUTHENTICATION_ID, e update-app-data.ts:34 publica a fila manual com authentication_id: null — por isso o teste manual passava enquanto o caminho automático morria.

82b95d0 — evento de quantidade não reexporta o documento do produto. Antes de tirar o filtro, fui medir a premissa "o eco termina sem escrever" e ela não valia para produto: com export_quantity ligado, todo products-quantitySet fazia PUT /produtos/{id} com o documento inteiro da loja, sobrescrevendo no Bling edições feitas por lá (nome, descrição, preço, NCM) a cada movimentação de estoque — inclusive no eco de callback, e também como bug independente dele. O handler agora recebe isStockOnlyEvent e só compara/lança /estoques; o corpo do produto segue sendo montado porque o mapeamento variação → id do Bling depende dele. Produto ainda não exportado não é criado a partir de evento de quantidade.

c11dcea — o filtro por authentication_id saiu (caminho 1 da recomendação). A decisão evento → fila foi extraída para parse-event-config.ts — função pura, padrão do decide-refresh-failure — com a matriz de testes que faltou nas duas tentativas de cortar o eco: status de pedido sempre exporta independente de autoria, quantidade exige export_quantity e é só-estoque, preço não é só-estoque, products-new exige new_products, fila manual e o tri-state do new_orders. O comentário no helper registra por que filtro por autoria não funciona nesta plataforma, para a próxima tentativa não repetir nenhuma das duas.

A ordem dos commits mantém cada estado intermediário são: o corte do PUT entra antes da remoção do filtro, então em nenhum ponto o eco reexporta produto.

77 testes passando, build e eslint limpos. Ressalva: o desvio do PUT dentro do export-product-to-bling não tem teste unitário direto — o handler depende do api e do client Bling, que a suíte offline não mocka; está coberto pela classificação na matriz (isStockOnlyEvent) e verificado por leitura nos três caminhos (produto simples, com variações, ausente no Bling).

Os minors novos da rodada (hasDepositBalance sem considerar has_stock_reserve, estoqueId caindo em depósito secundário, maxInstances do callback) e as issues de follow-up seguem pendentes.

🤖 Generated with Claude Code

leomp12 and others added 2 commits August 25, 2026 01:50
…rno do Bling

O pedido estornado pelo gateway era exportado ao Bling como "Cancelado" — a
situação é o alvo de cinco status financeiros da loja (`status-to-bling`) —, o
Bling disparava o callback da própria mudança e a importação regravava o pedido
como `voided`, porque a volta devolve só esse (`status-from-bling`). O histórico
guardava o `refunded`, mas o status corrente degradava e o app de e-mails
mandava "seu pedido foi cancelado" logo depois do "pagamento estornado".

`shouldApplyFinancialStatus` é a contrapartida do `should-advance-fulfillment`,
que já cortava o mesmo colapso do lado do fulfillment ("Atendido" voltando como
`invoice_issued`). O corte é só no degradê de `refunded`/`partially_refunded`
para `voided`: cancelamento humano no Bling sobre pedido pago, em disputa ou não
autorizado continua propagando.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SJEqUap3HEdf5E8gG2iMD
…o de estoque

O `PATCH` de metafields saía sempre que o produto tivesse qualquer metafield —
ou seja, em toda exportação depois da primeira, mesmo sem nada a persistir. Com
o eco de callback aceito, isso virou uma escrita na Store API por movimentação
de estoque, por SKU.

Além do custo, o `PATCH` substitui o array inteiro a partir do documento que
veio no evento, então uma gravação de outro app entre o snapshot e a escrita era
descartada. Passa a gravar só quando `bling:id` ou `bling:codigo` mudaram de
fato.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SJEqUap3HEdf5E8gG2iMD

@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.

Quarta rodada, sobre 82b95d0d6 e c11dcea47. O bloqueante fechou, e fechou certo — conferi no código, não na mensagem:

  • O filtro por autoria saiu (c11dcea47) e a extração para parse-event-config.ts é refactor fiel: o diff é movimentação pura, sem delta de comportamento além do isStockOnlyEvent passar a vir do helper. A matriz de testes fixa o contrato certo, e o bloco que registra as duas tentativas erradas está onde a próxima pessoa vai olhar antes de tentar a terceira.
  • A premissa "o eco termina sem escrever" você mediu e ela não valia — o 82b95d0d6 é o achado da rodada. products-quantitySet chegava em export-product-to-bling.ts:129-131 com o documento inteiro, então toda movimentação de estoque sobrescrevia no Bling o que o lojista editou por lá. Bug independente do eco, e a ordem dos commits mantém todo estado intermediário são.
  • O eco termina mesmo em um salto: as únicas escritas do app na Store API são metafields (export-order-to-bling.ts:204, export-product-to-bling.ts:160), e check-store-events.ts:60-62 só casa produto por price/quantity. A fila manual não duplica: updateAppData carimba X-Event-Flag: _skip no PATCH e publica no tópico direto.
  • 77 testes verdes no CI (32767293495), eslint limpo nos arquivos tocados.

Uma peça de contexto que vale registrar no comentário do parse-event-config: o v1 filtrava por authentication_id também (webhook.js:99, trigger.authentication_id !== auth.myId) — e lá funcionava, porque no Store API v1 cada app tem autenticação própria (auth-callback.js:21). A 2ª tentativa não foi chute, foi port fiel de um mecanismo que deixa de valer em silêncio sob a credencial única do monorepo. Dito assim, o comentário fica à prova da 3ª tentativa.

Correção minha: eu disse que os follow-ups seguiam sem issue. Estão abertos desde 21/08 — #814, #815, #816, #817.

Veredito: nada bloqueia

Achei quatro coisas reais. Todas as quatro existem no app v1 em produção — fui atrás de cada uma no app-bling-erp-v2 e cito abaixo. Pelo critério de "defeito que já roda em produção não trava a migração", nenhuma é pré-merge. Registro porque três têm fix de uma linha e um deles a PR já escreveu para o gêmeo.

🟠 O round-trip de situação degrada o status financeiro do pedido — corrigido em 881404f24

O eco foi medido no caminho loja → evento → exportação. Existe um segundo laço, que não passa por evento nenhum: loja → exportação → Bling → callback → importação → loja. Esse escreve.

status-to-bling.ts:51-56 colapsa cinco status financeiros em um (voided|refunded|in_dispute|unauthorized|partially_refunded['cancelado']), e status-from-bling.ts:55-57 devolve só um ('cancelado'voided). Com o callback de pedido que o README.md:26-33 manda cadastrar:

  1. Gateway notifica estorno → app de pagamento faz POST orders/{id}/payments_history com refunded;
  2. orders-anyStatusSet → agora chega no app;
  3. export-order-to-bling.ts:228-249 resolve "Cancelado" ≠ atual → PATCH /pedidos/vendas/{id}/situacoes/{id};
  4. Bling dispara o callback → bling-callback.ts:86-111importOrder;
  5. import-order-from-bling.ts:94-105: refunded ≠ voidedPOST payments_history { status: 'voided' }.

O payments_history preserva o registro de refunded; o que degrada é o current e, principalmente, a notificação: emails/src/util/get-mail-templ.ts:96-118 mapeia voided → "Seu pedido foi cancelado" e refunded → "Pagamento estornado", e o dedupe de event-to-emails.ts:64-67 só pula quando o último status notificado é o mesmo. Então o cliente estornado recebe, em seguida, um "seu pedido foi cancelado". Termina em um salto (voided['cancelado'] → já cancelado → sem PATCH), sem oscilação, inclusive com parse_status customizado.

O elo que eu achei mais atacável — "o Bling dispara callback quando a mudança veio da própria API?" — sobrevive por evidência da própria branch: o 7738fee86 descreve o pedido regredindo de "entregue" para "nf emitida" a cada importação, o que só acontece se a sequência export-PATCH → callback → import rodou ao vivo em loja real; e o c51443af9 já registrava o round-trip como TODO.

Portado. O v1 tem a cadeia inteira: mesmo colapso (parsers/order-to-bling/status.js:46-51), mesma volta (parsers/order-to-ecomplus/status.js:49-50), mesma escrita sem guarda (import-order.js:58-76), mesmo compare-antes-do-PATCH (export-order.js:242-255) — e a entrada passava pelo filtro dele, porque o estorno chega com o authentication_id do app de pagamento, não do Bling. O tiny-erp deste monorepo roda a mesma cadeia em produção, sem filtro de autoria nenhum: status-to-tiny.ts:12-16, status-from-tiny.ts:52-54, import-order-from-tiny.ts:64-77.

Mas o gêmeo desse bug você já corrigiu nesta PR. should-advance-fulfillment.ts:5-11 existe exatamente para essa classe, e o comentário descreve o mesmo mecanismo. A guarda só foi aplicada a um dos dois subrecursos — import-order-from-bling.ts:96 testa subresource === 'fulfillments'. payments_history ficou sem nada. Como o gêmeo já estava escrito e o corte não tem colateral, apliquei direto: 881404f24 cria should-apply-financial-status.ts no mesmo formato do irmão (função pura + 7 testes) e o liga em import-order-from-bling.ts. Corta só o degradê refunded/partially_refundedvoided.

Retrato uma alternativa que eu ia recomendar e que é pior: checar identidade com parseStatusToBling(order, appData).includes(situacao). Funciona e é mais geral, mas tem três colaterais que o enum não tem — mata returnedvoided (status-to-bling.ts:76-79 também mapeia para cancelado), mata o cancelamento humano de pedido em disputa, e fica ambígua com parse_status em que uma situação é alvo de um estado e sinal de outro. Além de exigir financial_status,fulfillment_status no fields= de :67-70, que hoje não vêm. O enum é cirúrgico e cobre também a variante não-eco (lojista cancela no Bling depois do estorno).

Deixar in_dispute e unauthorized de fora do enum é proposital: nesses o cancelamento humano no Bling ainda deve propagar.

🟠 canCreateNew: false não segura a criação quando a busca volta vazia

export-product-to-bling.ts:86-89 é a única guarda de criação, e vive dentro do if (Array.isArray(blingProducts) && blingProducts.length). Quando a busca não acha nada — data: [] na listagem, ou o 404 que findBlingProducts converte em null (:73) — ela não dispara e o fluxo cai em :110:

if (canCreateNew || appData.export_quantity || !blingStore) {

Basta !blingStore (loja sem multiloja) ou export_quantity ligado — acontece com multiloja configurada também. :129-131 sem originalBlingProductPOST /produtos: cria o produto no Bling com new_products desligado. E a listagem vazia não é hipótese: o próprio v1 documenta que o Bling devolve lista vazia até para SKU existente (lib/events/handle-events.js:55-59).

Portado, linha a linha: guarda aninhada em lib/integration/export-product.js:72-80, mesmo fall-through em :82, mesmo POST em :94. A PR já fechou metade sozinha — o 82b95d0d6 cobriu products-quantitySet, que no v1 também criava.

O fix é generalizar a guarda que você acabou de escrever em :103-106 e apagar a de :86-89:

if (!canCreateNew && !originalBlingProduct) {
  logger.info(`${productId} not on Bling and cannot create new`);
  return null;
}

O lugar é esse mesmo, depois do if/else de :81-94 — antes dele originalBlingProduct ainda não foi atribuído e a guarda mataria todo export de produto existente. Tri-state confere: undefined só existe para pedido (parse-event-config.ts:53-75 sempre devolve boolean em produto), então !canCreateNew é seguro.

Trade-off a citar no commit: produto com bling:id apagado no Bling deixa de ser recriado por evento de preço/estoque — hoje, e no v1, é recriado.

🟠 Estoque de variação sem codigo no Bling — a raiz é do v1, o silêncio é novo

Com isStockOnlyEvent, response é nullnewVariations = [] (:249), e a releitura de :250-260 exige !originalBlingProduct, que a guarda :103-106 garante existir → nunca roda. O casamento variação → id do Bling passa a depender só de variationFind.id, que product-to-bling.ts:147-160 só preenche quando o codigo bate exato. Variação criada na interface do Bling sem SKU tem codigo vazio, e a importação grava o id do Bling como SKU da loja (product-from-bling.ts:262, dito com todas as letras no bling-callback.ts:117-120). Na volta, "12345" não casa com '' → sem id:270 retorna e nenhum /estoques é lançado.

Portado: no v1 nunca funcionou — parser casa igual, só por codigo (parsers/product-to-bling.js:122-124, id em :136-138); sem id ia o PUT assim mesmo e tomava 400 (o bug nº 1 do corpo desta PR), e o POST /estoques ia com id: NaN, engolido pelo .catch(logger.error) (export-product.js:167-176).

O que muda é a visibilidade: antes do 82b95d0d6 a falha era um 400 que subia para os logs do painel (a correção nº 5 da própria PR); agora é no-op sem log. Mínimo: um logger.warn no skip de :270.

O fix é espelhar o que o import-product-from-bling.ts:58 já faz, em duas passadas (primeiro codigo, depois String(id)), não num OR de uma passada, para não abrir chance de casar variação errada:

const blingVariationOriginal = originalBlingProduct?.variacoes?.find(
  ({ codigo: codigoFind }) => codigoFind === codigo,
) || originalBlingProduct?.variacoes?.find(({ id }) => String(id) === codigo);

Colateral positivo: em export completo a variação passa a ir com id, o que conserta também o 400 do PUT nesse caso. E :156 passa a gravar o SKU-id como codigo da variação no Bling — cicatriza o vínculo, mas é escrita nova, vale citar no commit.

🟠 PATCH de metafields incondicional — corrigido em ead0d3fb3

export-product-to-bling.ts:159-161 grava metafields sempre que o produto tiver algum metafield (depois do primeiro export, sempre), substituindo o array inteiro a partir do doc do evento (:32-33) — metafield que outro app escreveu entre o snapshot e o PATCH se perde.

Portado e pior no v1: mesmo PATCH incondicional de array inteiro (export-product.js:129-137), e lá empurrava bling:codigo duplicado a cada export, sem checar existência (:106-117). O que muda é a frequência — com o eco aceito de propósito, cada callback de estoque carrega um PATCH de carona.

Também sem colateral, então aplicado: flag isMetafieldsChanged nos pontos de mutação, PATCH só quando bling:id ou bling:codigo mudaram de fato. Em regime estável nada muda. A corrida do replace continua nas vezes em que patcha; eliminar exigiria reler e mesclar, que o v1 nunca teve.

🟢 Minors

  • Os três da rodada anterior seguem abertos, como você disse — hasDepositBalance sem has_stock_reserve (parse-stock-from-deposits.ts:8-13), estoqueId caindo em depósito secundário (:222), e o callback em 300s com maxInstances: 100 herdado do httpsFunctionOptions (config.ts:188).
  • Documento do produto agora só vai ao Bling em evento de preço e na fila manual — consequência certa do 82b95d0d6, mas vale uma linha no README: quem editar nome/descrição na loja não vê no Bling até mexer no preço.
  • Corpo da PR ainda diz "71 testes"; são 77.
  • CodeFactor segue vermelho com 14 issues e a página segue sem listar nenhuma; eslint local limpo nos arquivos tocados.

O que empurrei e o que deixei para você

Empurrei na branch os dois que não têm trade-off nenhum — 881404f24 e ead0d3fb3. Suíte em 84 verdes (os 77 + 7 do guard novo), build e eslint limpos. Desfaz sem cerimônia se discordar do recorte.

Os outros dois eu não apliquei porque cada um muda comportamento e a chamada é sua:

  • canCreateNew: fechar a guarda faz produto com bling:id apagado no Bling deixar de ser recriado por evento de preço/estoque. Hoje é recriado, no v1 também. É o comportamento certo pelo rótulo da config, mas é mudança de produção.
  • Casamento da variação por id: além de destravar o estoque, passa a gravar o SKU-id como codigo da variação no Bling, onde hoje está vazio. Cicatriza o vínculo, mas é escrita nova em dado do lojista.

Se topar os dois, aplico do mesmo jeito. O resto cabe na #817.

Ressalva de método: não rodei a suíte localmente (o build depende do lib do @cloudcommerce/firebase, que este checkout não tem); li os 77 verdes no log do CI. As quatro cadeias eu tracei na fonte, e a classificação "existe no v1" está ancorada em arquivo:linha do app-bling-erp-v2 em cada uma.

@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.

Aprovado. As três rodadas de bloqueantes fecharam e estão verificadas no código, e os quatro achados que sobraram na 4ª rodada existem todos no app-bling-erp-v2 em produção — port fiel, não regressão da migração, então não travam.

Dos quatro, os dois sem trade-off entraram na própria branch (881404f24 guard financeiro, ead0d3fb3 flag de metafields). Os outros dois viraram follow-up opcional na #817, com o trade-off de cada um nomeado.

Estado: test verde (84 testes), eslint limpo, sem conflito com a main. O CodeFactor segue vermelho com 14 issues que a página não lista e o eslint local não reproduz — não é check obrigatório e não bloqueia.

@leomp12
leomp12 merged commit f40bd3c into main Aug 25, 2026
3 of 4 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