Skip to content

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

Open
vitorrgg wants to merge 10 commits into
mainfrom
bling
Open

feat(apps): Integração com Bling ERP na plataforma#802
vitorrgg wants to merge 10 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/ — 37 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.

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.

1 participant