name: 04-ca-integracao-core description: "🚨 NÚCLEO CONTA AZUL E OAUTH. Obrigatório ler antes de mexer em qualquer comunicação HTTP, Tokens 401 ou Sincronização Massiva. Ensina a usar o _axiosGet obrigatório."
04 CA INTEGRACAO CORE
⚠️ DOCUMENTO MESTRE: Este documento é a consolidação das antigas skills: contaazul-autenticacao, fluxo-dados-sync.
CONTEÚDO ORIGINAL DE: contaazul-autenticacao
Conta Azul OAuth 2.0 - Guia de Implementação Robusta
Este documento detalha como manter uma integração "viva" com a Conta Azul, focando na renovação de tokens para evitar desconexões.
1. Visão Geral do Fluxo
O fluxo OAuth 2.0 da Conta Azul funciona em três etapas principais:
- Solicitação de Código (Authorization Code): O usuário é redirecionado para a Conta Azul, faz login e autoriza o app.
- Troca de Código por Tokens (Access & Refresh): O app recebe um
codetemporário e o troca por umaccess_token(curta duração) e umrefresh_token(longa duração). - Renovação de Acesso (Refresh Token Flow): Antes do
access_tokenexpirar, o app usa orefresh_tokenpara obter um novo par de tokens.
2. Tipos de Token e Validade
| Token | Validade Típica | Função |
|---|---|---|
| Access Token | 60 minutos (1 hora) | Usado no Header Authorization: Bearer <token> para chamar a API. |
| Refresh Token | 60 dias (aprox.)* | Usado EXCLUSIVAMENTE para gerar novos Access Tokens. |
> Nota: A validade do Refresh Token é reiniciada a cada uso se a opção "Refresh Token Rotation" estiver ativa (padrão em muitos provedores OAuth modernos, verificar comportamento específico da CA).
3. Renovando o Access Token (O segredo da conexão eterna)
Para que a conexão nunca se perca, o sistema deve renovar o token automaticamente antes de ele expirar, sem intervenção do usuário.
Quando renovar?
O ideal é implementar uma margem de segurança. Se o token dura 60 minutos, renove-o aos 50 minutos ou 55 minutos.
Como renovar (Endpoint V2 COGNITO - OBRIGATÓRIO)
CRÍTICO [FEV/2026]: Os tokens atuais do Conta Azul operam sob o padrão JWT (Cognito). A API legada (api.contaazul.com) NÃO aceitará renovar esses tokens e retornará invalid_client. Sempre use auth.contaazul.com.
POST https://auth.contaazul.com/oauth2/token
Headers Obrigatórios:
Authorization:Basic base64(client_id + ":" + client_secret)-> Exigência exclusiva do novo endpoint auth!Content-Type:application/x-www-form-urlencoded
Body:
grant_type=refresh_token
refresh_token=<SEU_REFRESH_TOKEN_SALVO_NO_BANCO>
Resposta de Sucesso (HTTP 200):
{
"access_token": "novo_access_token_xyz...",
"refresh_token": "novo_refresh_token_abc...", // IMPORTANTE: Algumas APIs giram esse token também!
"expires_in": 3600
}
⚠️ Regra de Ouro: Salvar o NOVO Refresh Token
A cada renovação, a Conta Azul pode retornar um novo refresh_token. Você DEVE salvar esse novo token no banco, substituindo o antigo. Se você tentar usar o token antigo novamente, a API rejeitará e a conexão será perdida.
4. Estratégia de Implementação no Código
A. Armazenamento Seguro (Banco de Dados)
Tabela: ContaAzulConfig (ou integracao_tokens)
access_token(Text)refresh_token(Text)expires_in(Int) - Segundos, ex: 3600updated_at(DateTime) - Hora que o token foi gerado.
B. Lógica de "Get Token" (Middleware ou Service)
Sempre que for fazer uma chamada à API (ex: syncProdutos), NÃO use o token do banco diretamente. Chame uma função getValidToken() que faz o seguinte:
- Busca token e
updated_atno banco. - Calcula:
TempoDecorrido = Agora - updated_at. - Se
TempoDecorrido > 55 minutos(3300 segundos):- Chama endpoint de Refresh.
- Se der sucesso: Atualiza
access_tokenErefresh_tokenno banco. Retorna novo token. - Se der erro (400/401): O refresh expirou ou foi revogado. Loga erro crítico e alerta admin (aqui sim o botão "Conectar" é necessário).
- Se
TempoDecorrido <= 55 minutos:- Retorna token do banco.
5. Configuração VERIFICADA e Funcionando (IMPORTANTE)
⚠️ ESTA É A CONFIGURAÇÃO QUE FUNCIONA PARA ESTE PROJETO. NÃO ALTERAR.
Credenciais
- Client ID:
6f6gpe5la4bvg6oehqjh2ugp97 - Client Secret:
1fvmga9ikj9dk4mkctoqvm2nfna7ht2t60p2qmg7kq04le0gb1ls
Endpoints (Legacy/Cognito)
- Authorization URL:
https://auth.contaazul.com/login - Token URL:
https://auth.contaazul.com/oauth2/token - Redirect URI:
https://cahardt-hardt-backend.xrqvlq.easypanel.host/api/auth/callback
URL Completa de Autorização
https://auth.contaazul.com/login?response_type=code&client_id=6f6gpe5la4bvg6oehqjh2ugp97&redirect_uri=https://cahardt-hardt-backend.xrqvlq.easypanel.host/api/auth/callback&state=ESTADO&scope=openid+profile+aws.cognito.signin.user.admin
Scope Correto
openid+profile+aws.cognito.signin.user.admin
API Base URL (CRÍTICO)
URL Base da API: https://api-v2.contaazul.com
IMPORTANTE [FEV/2026]: A API base é EXCLUSIVAMENTE api-v2.contaazul.com. A API legada (api.contaazul.com) foi descontinuada para contas novas (Cognito). Usar o endpoint legado com tokens novos resultará em erro 401 Unauthorized imediato e impossível de reverter.
Endpoints OBRIGATÓRIOS (V2):
- Produtos:
https://api-v2.contaazul.com/v1/produtos(paginação:?pagina=X&tamanho_pagina=Y) - Clientes (Pessoas):
https://api-v2.contaazul.com/v1/pessoas(paginação:?pagina=X&tamanho_pagina=Y) - Vendas (Busca):
https://api-v2.contaazul.com/v1/venda/busca-> NÃO USE/v1/vendasda API antiga. - Vendas (Criar):
https://api-v2.contaazul.com/v1/venda
6. Governança: Estabilidade e Segurança (Token Rotation)
CRÍTICO: A Conta Azul utiliza Refresh Token Rotation. Isso significa que a cada refresh, o token antigo é invalidado.
Regras de Ouro:
- Mutex / Locking: É OBRIGATÓRIO usar um mecanismo de trava (mutex) para impedir que múltiplas requisições tentem renovar o token simultaneamente. Se dois requests tentarem usar o mesmo
refresh_tokenao mesmo tempo, um deles falhará e invalidará o token para todos. - Safe Rotation: Nem sempre a API retorna um novo
refresh_token. O código deve checar: se viernull, MANTENHA o antigo. Se vier novo, SUBSTITUA. - Logs Detalhados: Todo fluxo de auth deve logar o status, e qualquer erro 401 deve desencadear um log de erro crítico com o corpo da resposta.
6. Referências Oficiais & Endpoints (Documentação V2)
- Authorize URL:
https://auth.contaazul.com/login - Token URL:
https://auth.contaazul.com/oauth2/token - Escopos:
openid profile aws.cognito.signin.user.admin
Parâmetros de URL Autorização
O endpoint de login Cognito exige parâmetros estritos:
https://auth.contaazul.com/login?response_type=code&client_id={CLIENT_ID}&redirect_uri={REDIRECT_URI}&state={RANDOM_STRING}&scope=openid+profile+aws.cognito.signin.user.admin
Checklist de Robustez
- Lógica de renovação verifica o tempo antes de cada chamada.
- O novo
refresh_tokenretornado na renovação é salvo no banco (sobrescrevendo o anterior). - Tratamento de erro: Se o refresh falhar, marcar status como "Desconectado" no banco para a UI saber.
- Cron Job (Opcional): Um job a cada 45min que apenas chama
getValidToken()para garantir que o token esteja sempre fresco, mesmo se ninguém usar o sistema.
CONTEÚDO ORIGINAL DE: fluxo-dados-sync
Fluxo de Sincronização (Sync)
O sistema mantém uma cópia local dos dados do ERP (Conta Azul) para performance e funcionamento offline/híbrido.
Estrutura
- Origem: API Real do Conta Azul (
contaAzulService.js). - Destino: Banco de Dados PostgreSQL (Prisma).
- Lógica:
Upsert(Update or Insert) com comparação de timestamps.
Regras de Negócio
Produtos - Sincronização Incremental ⚡
Estratégia de Performance:
O sistema implementa sincronização incremental para otimizar chamadas de API e reduzir tempo de sync em ~96%:
Busca lista completa de produtos (
GET /v1/produtos)- Retorna apenas:
id,nome,codigo_sku,status,categoria,ultima_atualizacao
- Retorna apenas:
Compara timestamps localmente
ultima_atualizacao(Conta Azul) vscontaAzulUpdatedAt(banco local)- Usa
Mappara lookup O(1)
Busca detalhes apenas quando necessário
- Produto novo (não existe localmente)
- Produto alterado (timestamps diferentes)
- Ignora produtos com timestamp igual
Salva timestamp para próxima comparação
- Usa timestamp da lista, não dos detalhes
- Garante consistência entre comparação e salvamento
Performance:
- Sem mudanças: ~5 segundos (0 chamadas de detalhes)
- Com mudanças: ~1-2 minutos (N chamadas, onde N = produtos alterados)
- Ganho: 96% mais rápido quando não há alterações
Chave de Ligação: contaAzulId (UUID vindo do ERP)
Campos Sincronizados:
- Identificação:
nome,codigo,ean,ncm - Preços:
valorVenda,custoMedio - Estoques:
estoqueDisponivel,estoqueReservado,estoqueTotal,estoqueMinimo - Detalhes:
unidade,categoria,descricao,status,pesoLiquido - Timestamp:
contaAzulUpdatedAt⚠️ CRÍTICO - para sync incremental
Campos Locais (Não sobrescritos):
ativo- Controle local de visibilidade no appimagens- Imagens customizadas localmente
Mapeamento Crítico (API v2):
// ⚠️ ATENÇÃO: Campos estão DENTRO de objetos aninhados
const dadosProduto = {
valorVenda: p.estoque.valor_venda, // NÃO p.valor_venda
custoMedio: p.estoque.custo_medio, // NÃO p.custo_medio
estoqueMinimo: p.estoque.minimumStock, // camelCase! NÃO estoque_minimo
ncm: p.fiscal?.ncm?.codigo, // Aninhado em fiscal.ncm
unidade: p.fiscal?.unidade_medida?.descricao,
contaAzulUpdatedAt: itemList.ultima_atualizacao // Da LISTA, não dos detalhes
};
4. Logging Robusto (OBRIGATÓRIO)
Todo processo de sincronização DEVE implementar logs detalhados salvos no banco de dados (SyncLog).
Regras de Log:
- Request/Response: Em caso de erro, salvar URL, Método, Status Code e Body da resposta.
- Transparência: O usuário final deve conseguir ver o motivo Exato da falha no painel (ex: mensagem de erro da API).
- Auditoria: Manter histórico de execução com duração e contagem de registros.
Clientes
- Chave de Ligação:
Documento(CPF/CNPJ) ouUUID(se disponível). - Campos Sincronizados: Nome, Fantasia, Endereço, Contatos.
- Campos Locais:
- Dados Operacionais: Dia de Entrega, Dia de Venda, GPS, Observações.
- Estes campos NÃO devem ser sobrescritos pelo Sync se estiverem vazios na origem.
Scripts de Sync
- Localizados em
backend/scripts/. - Ex:
sync_clientes_manual.js,populate_manual.js. - Atenção: Scripts manuais rodam no contexto da máquina host. Certifique-se que o banco está acessível (localhost vs docker service name).
SINCRONIZAÇÃO BIDIRECIONAL DE PEDIDOS (CA ↔ App)
🚨 CRÍTICO: Esta seção foi consolidada em FEV/2026 após bugs graves. Leia antes de tocar em
contaAzulService.js.
Arquitetura do Sync de Pedidos
Há 3 componentes independentes que trabalham juntos:
| Componente | Arquivo | Frequência | Função |
|---|---|---|---|
syncPedidosModificados |
contaAzulService.js |
Auto 15min | Detecta alterações/exclusões no CA via busca por data_alteracao |
| Garbage Collector (GC) | contaAzulService.js |
Dentro do sync | Pinga individualmente pedidos RECEBIDO para confirmar existência no CA |
| Worker de Fila | syncPedidosService.js |
A cada 30s | Envia para o CA pedidos locais com statusEnvio='ENVIAR' |
Auto-sync configurado em index.js:
// Roda a cada 15 minutos automaticamente (não precisa de botão)
setTimeout(_runSyncPedidos, 120000); // 2min após start
setInterval(_runSyncPedidos, 900000); // depois a cada 15min
🚨 ARMADILHA CRÍTICA: Estrutura diferente entre endpoints CA
O GET individual de venda e o endpoint de busca retornam estruturas diferentes. Confundir isso causa marcar todos os pedidos como EXCLUIDO erroneamente.
Endpoint de Busca (/v1/venda/busca)
{
"id": "87464009-...",
"situacao": { "nome": "APROVADO", "descricao": "Aprovado" },
"total": 470,
"numero": 12
}
Endpoint GET por ID (/v1/venda/{id}) — ESTRUTURA DIFERENTE!
{
"cliente": { "uuid": "...", "nome": "..." },
"venda": {
"id": "87464009-...",
"situacao": { "nome": "APROVADO", "descricao": "Aprovado" },
"numero": 12
},
"vendedor": { "id": "...", "nome": "..." }
}
CÓDIGO CORRETO no GC para ler situacao:
// ✅ CORRETO — situacao está dentro de resCA.data.venda
const vendaObj = resCA.data?.venda || resCA.data; // fallback compatibilidade
const situacaoRaw = vendaObj?.situacao;
const situacaoNome = (typeof situacaoRaw === 'object' ? situacaoRaw?.nome : situacaoRaw) || null;
// ❌ ERRADO — retorna undefined no GET /venda/{id}
const situacaoNome = resCA.data?.situacao?.nome; // NUNCA FAZER ISSO NO GC
Garbage Collector (GC) — Regras
O GC detecta pedidos excluídos ou cancelados silenciosamente no CA.
Configuração Atual (Escalável)
const pedidosLocaisAtivos = await prisma.pedido.findMany({
where: { statusEnvio: 'RECEBIDO', idVendaContaAzul: { not: null } },
orderBy: { contaAzulUpdatedAt: 'asc' }, // Mais antigos primeiro (rotação)
take: 20 // MÁXIMO 20 por ciclo — não alterar para cima sem análise
});
Por que 20? Com rate limit de 10 req/s, 20 pings = ~3s. Com sync a cada 15min → 2.880 pedidos/dia verificados. Suficiente para qualquer volume.
Lógica de Detecção de Excluído
| Resposta CA | Situação | Ação |
|---|---|---|
404 ou 400 |
Pedido deletado definitivamente | Marcar EXCLUIDO |
200 com situacao.nome = null |
Soft-delete via interface CA | Marcar EXCLUIDO |
200 com situacao.nome = 'CANCELADO' |
Cancelado explicitamente | Marcar EXCLUIDO |
200 com situacao válida (APROVADO etc.) |
Ativo | Não fazer nada |
401 |
Token expirado | Refresh e continuar |
findFirst no syncPedidosModificados — REGRA CRÍTICA
NUNCA usar OR [idVendaContaAzul, numero] em uma única query. Isso causa falsos positivos quando há vários pedidos com o mesmo número (pedidos de teste + pedido real).
CORRETO — em duas etapas:
// PRIORIDADE 1: Match exato pelo CA ID (sem ambiguidade)
let pedidoLocal = await prisma.pedido.findFirst({
where: { idVendaContaAzul: venda.id },
include: { itens: true }
});
// PRIORIDADE 2: Fallback por numero SOMENTE se o pedido nunca foi ao CA
if (!pedidoLocal && venda.numero) {
pedidoLocal = await prisma.pedido.findFirst({
where: { numero: venda.numero, idVendaContaAzul: null },
include: { itens: true }
});
}
Restauração de Status (EXCLUIDO → RECEBIDO)
Quando o GC tem bug e marca pedido ativo como EXCLUIDO, o syncPedidosModificados restaura automaticamente:
// Se CA diz que está ativo mas local está EXCLUIDO (ex: GC bugado)
if (pedidoLocal.statusEnvio === 'EXCLUIDO' && !isCanceladoV2) {
await prisma.pedido.update({
where: { id: pedidoLocal.id },
data: {
statusEnvio: 'RECEBIDO',
situacaoCA: venda.situacao?.nome || null,
revisaoPendente: true,
contaAzulUpdatedAt: dataAtualizacaoCA
}
});
console.log(`🔄 [Sync CA] Pedido #${pedidoLocal.numero} RESTAURADO: EXCLUIDO → RECEBIDO`);
}
Rate Limits da CA (Verificado 2026)
- 600 chamadas por minuto por conta conectada
- 10 por segundo por conta conectada
- Sem webhook disponível — usar polling com
data_alteracao_de(estratégia oficial da CA)
📥 IMPORTAÇÃO DE PEDIDOS ÓRFÃOS (CA → App) — MAR/2026
Quando um pedido existe no CA mas não tem correspondente local, o syncPedidosModificados cria o pedido localmente com enriquecimento completo:
| Dado | Origem | Comportamento |
|---|---|---|
| Vendedor | GET /v1/venda/{id} → campo id_vendedor |
Vinculado pelo UUID local se existir |
| Condição de Pagamento | condicao_pagamento.tipo_pagamento + opcao_condicao_pagamento |
Mapeado na TabelaPreco local → salva nomeCondicaoPagamento |
| Itens | GET /v1/venda/{id}/itens |
Produto vinculado por contaAzulId. flexGerado calculado vs valorVenda local |
| Histórico do Cliente | clienteInsightService.recalcularCliente() |
Disparado via setImmediate após criação |
Atualização Mar/2026 — Natureza de Operação e Parcelas no envio
- O envio do pedido permanece com
POST /v1/vendapara criar a venda. - A natureza de operação é aplicada em seguida com
PUT /v1/venda/{id}usandoid_natureza_operacaoconforme tipo de cliente:- CNPJ → Natureza de Mercadorias/Produtos
- CPF → Natureza de Não Contribuinte
- Para condição de pagamento com dias explícitos (
"7, 14"), as parcelas são montadas com vencimentos individuais por offset.
Campo nomeCondicaoPagamento (CRÍTICO — MAR/2026)
O modelo Pedido agora persiste o nome completo da condição (nome_condicao_pagamento) no momento da criação. Isso evita lookup reverso ambíguo quando múltiplas condições têm o mesmo opcaoCondicao.
Prioridade de exibição (caixa, embarques, entregas):
// 1º: nome salvo direto no pedido (novos pedidos pós-mar/2026)
// 2º: lookup por chave composta tipoPagamento|opcaoCondicao (pedidos antigos)
// 3º: opcaoCondicaoPagamento bruto como fallback
const nomeCondicao = e.nomeCondicaoPagamento
|| mapaCondicoes[`${e.tipoPagamento}|${e.opcaoCondicaoPagamento}`]
|| e.opcaoCondicaoPagamento;
Migration SQL (Update 33 do migrationService):
ALTER TABLE "pedidos" ADD COLUMN IF NOT EXISTS "nome_condicao_pagamento" TEXT;
EDIÇÃO DE CADASTRO DO CLIENTE (App → CA) — JUN/2026
Permite editar pelo app email, celular, observação e inscrição estadual do cliente, refletindo no Conta Azul. A integração de clientes deixou de ser só leitura: agora é bidirecional para esses campos.
Funções novas em contaAzulService.js
| Função | O que faz |
|---|---|
atualizarPessoaCA(idCliente, payload) |
PATCH genérico em /v1/pessoas/{id} (com retry 401 e log CLIENTE_PATCH). Aceita payload parcial: { email }, { telefone_celular }, { inscricoes: [...] }, etc. ⚠️ Era atualizarIndicadorIECliente — renomeada. O envio de pedidos (syncPedidosService) usa ela para o indicador de IE. |
sincronizarClienteUnico(uuid) |
Sincroniza UM cadastro (GET /v1/pessoas/{uuid}) e atualiza o registro local. Chamada pelo detalhar ao abrir o cliente, para mostrar dados atuais do CA antes de editar. Best-effort (se o CA cair, segue com o local). Não mexe em campos do app (dias de rota, GPS, etc.). |
atualizarDadosClienteCA(uuid, campos) |
Envia ao CA os campos editados (email/celular/observação/IE/indicador). Estratégia GET → merge → PATCH: lê a pessoa atual para usar as chaves reais do CA e preservar as demais inscrições, evitando depender de nomes de campo "chutados". |
Fluxo (controller clienteController.js)
detalhar: chamasincronizarClienteUnico(uuid)(best-effort) →findUnique(cominclude: { fiscal: true }) → achatacliente.fiscal.inscricaoEstadualemcliente.Inscricao_Estadualpara o front.Cliente.UUIDé o id da pessoa no CA (criado comUUID: c.idno sync), por isso o GET usa a URL/v1/pessoas/{UUID}.atualizar: valida formato (email; celular só dígitos 10–11; IE 9 dígitos SC) → compara com o atual e monta só os campos que mudaram → envia ao CA ANTES de gravar local (se o PATCH falhar, responde 502 e não grava nada → evita divergência app↔CA) → grava local. Gate de permissãopodeEditarCadastroCA = admin || clientes.edit || Pode_Editar_GPS(espelhado no frontend).
⚠️ Inscrição Estadual fica em tabela SEPARADA (cliente_fiscal)
Por quê: a tabela clientes atingiu o teto de 1600 colunas do Postgres (o limite conta colunas já apagadas, que prisma db push acumula a cada deploy ao dropar/recriar colunas). Adicionar QUALQUER coluna nova em clientes quebra o db push no deploy com:
ERROR: tables can have at most 1600 columns
Por isso o número da IE vive em cliente_fiscal (relação 1-1; a FK fica nessa tabela, então clientes não ganha coluna). O indicador (Indicador_Inscricao_Estadual) já era coluna existente em clientes e continua lá.
model ClienteFiscal {
clienteUuid String @id @map("cliente_uuid")
inscricaoEstadual String? @map("inscricao_estadual")
cliente Cliente @relation(fields: [clienteUuid], references: [UUID], onDelete: Cascade)
@@map("cliente_fiscal")
}
🚨 REGRA: NÃO adicionar colunas novas à tabela
clientes. Use tabela 1-1 separada (comocliente_fiscal) até aclientesser reconstruída para purgar as colunas mortas.
Frontend (DetalheCliente.jsx)
- Seção "Contato / Fiscal" na aba de edição (gated por
podeEditarCadastroCA): email, celular (máscara some, grava só dígitos), IE (9 dígitos) + indicador (Contribuinte / Isento / Não contribuinte). - Botão "Consultar no Sintegra SC": copia o CNPJ para a área de transferência e abre
https://sat.sef.sc.gov.br/.../ComprovanteIE/Consulta.aspx(a página.aspxnão aceita pré-preencher por URL — por isso copia+cola). - Validação espelha o backend; trata 502 (erro do CA) com alerta.
Verificação no 1º teste real
Confirme nos logs a forma real do CA antes de confiar nos nomes de campo:
[Sync único <uuid>] inscricoes: [...](chave do número/indicador da IE)CLIENTE_PATCH SUCESSOcom o body enviado e a resposta