Backend em Node.js + TypeScript que processa DANFEs (Documento Auxiliar da Nota Fiscal Eletrônica) brasileiras: extrai dados via IA, valida contra as regras do domínio fiscal, casa itens com o catálogo existente e atualiza o estoque com auditoria completa.
Este documento descreve a arquitetura real do sistema, tal como implementada. Para o histórico de decisões e o plano de engenharia que levou a este estado, veja docs/auditoria-tecnica.md e docs/backlog-engenharia.md.
Imagem/PDF → OCR (Gemini) → Extração estruturada → Validação → Matching de produtos → Upsert → Auditoria
A IA (Google Gemini) nunca é fonte de verdade. Ela apenas extrai dados do documento e sugere similaridade entre um item da nota e produtos já cadastrados. Toda decisão que altera a identidade de um produto passa por confirmação humana antes de afetar o estoque — ver Sugestões de produto abaixo.
Estrutura real de src/:
controllers/— Tradução HTTP ↔ use case. Sem lógica de negócio; lançamAppErrore deixam o handler global responder.use-cases/— Orquestração das regras de aplicação (ReadInvoiceUseCase,CreateCompanyUseCase,LoginUseCase, casos de uso de sugestão de produto, etc.).domain/— Regras de domínio puras e determinísticas (validação de CNPJ, coerência do DANFE). Sem I/O.providers/— Contratos e implementações para integrações externas:IStorageProvider(disco),IAiProvider(Gemini),IHashProvider(Argon2),ITokenProvider(JWT/jose).repositories/— Gateways de dados: interface + implementação Prisma + dublê in-memory (para testes) de cada agregado (User, Company, Stock, Product, AuditLog, ProductSuggestion, invoice persistence).mappers/— Tradução explícita entre o tipo gerado pelo Prisma e o tipo de domínio (ProductMapper,CompanyMapper,StockMapper,AuditLogMapper). Nenhum repositório Prisma faz cast (as) direto do retorno do client para o tipo de domínio.middlewares/— Autenticação, rate limiting, validação declarativa (zod), request ID, cabeçalhos de segurança, error handler global.schemas/— Schemaszodpara corpo de requisição HTTP e para a resposta estruturada do Gemini.infra/— Cliente Prisma único, logger estruturado, telemetria de IA, healthcheck.errors/—AppError(estendeError) e tradução de códigos de constraint do Prisma (P2002/P2003) para status HTTP.config/— Configuração centralizada e validada (env.ts), upload (multer),trust proxy.
Não existe camada de entities/ nem container de DI — ver Decisões arquiteturais deliberadas.
Todas as respostas seguem o envelope { status: 'success', data } ou { status: 'error', message }. Erros de negócio usam AppError e são traduzidos pelo error handler global para o status HTTP correspondente — nenhuma rota faz comparação de string de mensagem para decidir status.
| Método | Rota | Autenticação | Descrição |
|---|---|---|---|
GET |
/health |
pública | SELECT 1 real no Postgres; 503 se o banco estiver indisponível ou o processo em shutdown. |
POST |
/users |
pública, rate limit 5/hora por IP | Cadastro de usuário. Senha com Argon2. |
POST |
/login |
pública, rate limit 10/15min por IP | Autenticação; devolve JWT. |
POST |
/companies |
Bearer JWT | Cria empresa + estoque principal em uma transação atômica. |
GET |
/products |
Bearer JWT | Lista paginada (cursor) por stockId. Exige acesso ao estoque. |
POST |
/invoices/upload |
Bearer JWT, rate limit 5/min por usuário | Upload de DANFE (JPEG/PNG/PDF, até 10 MiB). Dispara o pipeline completo. |
GET |
/stocks/:stockId/suggestions |
Bearer JWT | Lista sugestões de produto pendentes do estoque. |
POST |
/suggestions/:suggestionId/confirm |
Bearer JWT | Confirma uma sugestão: aplica a entrada no produto sugerido. |
POST |
/suggestions/:suggestionId/reject |
Bearer JWT | Rejeita uma sugestão: cadastra o item como produto novo. |
Bearer JWT (Authorization: Bearer <token>), verificado via jose. req.user só existe depois do middleware de autenticação — é undefined em /login, /users e em qualquer ponto anterior a ele (refletido no tipo: Express.Request.user?: { id: string }).
Autorização é sempre derivada do recurso, nunca do corpo/query da requisição: companyId de um upload de invoice vem do stockId autorizado, não de um campo enviado pelo cliente. Owner da empresa tem acesso implícito a todos os estoques; colaboradores precisam de StockPermission explícita (canView/canCreate) por estoque.
multervalida tamanho (10 MiB) e MIME (image/jpeg,image/png,application/pdf) antes do controller.- Autorização de acesso ao estoque acontece antes de qualquer I/O — inclusive antes da leitura do arquivo do disco.
IStorageProvider.readFilelê os bytes (Buffer);IAiProvider.extractDanfeDatarecebe conteúdo + mimetype, nunca um caminho de arquivo.- Gemini extrai os dados com timeout de 30s, no máximo 2 tentativas (retry só para
429/5xx/erro de rede), e a resposta é validada por schemazod— nada do modelo é confiado sem validação de tipo/estrutura. - Coerência do DANFE é verificada (soma dos itens vs. total declarado, tolerância
max(R$0,02, 1%)) antes de qualquer persistência. - Matching: item com código exato atualiza o produto automaticamente. Sem código exato, um pré-filtro determinístico (léxico, sem IA) reduz o catálogo a no máximo 15 candidatos; só então o Gemini é chamado para similaridade — nunca com o estoque inteiro.
- Toda a persistência de uma nota (upsert de produtos com custo médio ponderado, criação de sugestões,
ProcessedInvoice) acontece em uma única transação Postgres. Reenvio da mesma chave de acesso é idempotente (409em duplicata).
Quando a IA identifica um candidato por similaridade (confiança configurável via SIMILARITY_CONFIDENCE_THRESHOLD, default 0.7), o item não altera o estoque. Uma ProductSimilaritySuggestion fica PENDING até decisão humana via /suggestions/:id/confirm ou /suggestions/:id/reject:
- Confirmar aplica a entrada (custo médio ponderado) no produto sugerido.
- Rejeitar preserva o candidato e cadastra o item recebido como produto novo.
A transição PENDING → CONFIRMED|REJECTED é condicional (WHERE status = PENDING) para impedir decisão dupla sob concorrência.
Um único PrismaClient/Pool (src/infra/prisma.ts, max: 10) compartilhado por todos os repositórios — não há uma pool por repositório. Migrations em prisma/migrations/, aplicadas com prisma migrate deploy; nenhuma migration histórica é editada. Índices compostos cobrem as consultas reais paginadas/de auditoria (products(stockId, createdAt, id), audit_logs(companyId, createdAt), audit_logs(userId, createdAt)).
- Logger estruturado (
src/infra/logger.ts) em JSON, comredactrecursivo de senha/hash/token/Authorization/API key, erequestIdde correlação em toda resposta (X-Request-Id). - Auditoria de domínio (
AuditLog) registra criação/atualização de produto, criação de empresa, tentativa de acesso não autorizado e decisão de sugestão, compreviousState/newState. Best-effort: falha ao gravar auditoria nunca reverte uma operação já persistida. - Telemetria de IA (
src/infra/ai-telemetry.ts) registra, por chamada ao Gemini, duração monotônica, tentativas, tokens reais (nunca estimados), custo em nanoUSD e categoria de falha; e por sugestão, decisão e faixa de confiança.
- Graceful shutdown:
SIGTERM/SIGINTdrenam requisições em voo, encerram o pool do Postgres e saem com código 0; timeout configurável força o fechamento e sai com código 1. unhandledRejection/uncaughtExceptionsão logados com contexto seguro e disparam o mesmo shutdown fatal.- CI (
.github/workflows/ci.yml, GitHub Actions,ubuntu-latest):prisma validate→prisma generate→typecheck→lint→ suíte unitária → gate de integração PostgreSQL real (Docker); job separado valida o build de produção com apenasdependenciesinstaladas (npm prune --omit=dev) e rodascripts/verify-production-runtime.mjs, que importa o grafo de dependências real do artefato compilado.
Ver .env.example. Todas são validadas e falham rápido no boot (src/config/env.ts) — nenhuma tem fallback silencioso além dos defaults documentados.
| Variável | Obrigatória | Default | Descrição |
|---|---|---|---|
DATABASE_URL |
sim | — | Postgres. |
JWT_SECRET |
sim | — | Assinatura dos tokens. |
GEMINI_API_KEY |
sim | — | Google Gemini. |
PORT |
não | 3333 |
Porta HTTP. |
TRUST_PROXY_HOPS |
não | 0 |
Número exato de proxies reversos confiáveis (0–10). Só alterar se a API estiver atrás de proxy conhecido. |
CORS_ALLOWED_ORIGINS |
não | vazio | Allowlist HTTP(S) separada por vírgula. Sem wildcard, sem credenciais. |
REQUEST_TIMEOUT_MS |
não | 120000 |
Timeout de requisição do servidor HTTP. |
SHUTDOWN_TIMEOUT_MS |
não | 30000 |
Prazo do graceful shutdown antes de forçar. |
GEMINI_TIMEOUT_MS |
não | 30000 |
Timeout por tentativa ao Gemini. |
GEMINI_MAX_ATTEMPTS |
não | 2 |
Tentativas totais (1–2) para erro transitório. |
SIMILARITY_CONFIDENCE_THRESHOLD |
não | 0.7 |
Limiar de confiança do matching por similaridade (0–1). Fonte única usada no prompt e no código — não alterar sem dado real de uso. |
npm test— suíte unitária (Vitest), dublês in-memory, sem rede nem Postgres real.npm run test:integration:postgres:docker— sobe um PostgreSQL 16 efêmero via Docker Compose, aplica as migrations do zero, roda os testes de integração real (transação, rollback, concorrência, constraints, idempotência) e desmonta tudo no final.npm run lint— ESLint (typescript-eslint, sem type-checking completo para evitar ruído em dublês de teste) com duas regras type-aware ligadas deliberadamente:no-floating-promiseseno-misused-promises— a classe exata de defeito que já derrubou o processo em produção antes da correção.npm run typecheck,npm run build,npm run prisma:validate,npm run verify:productioncompletam o Definition of Done local.
- Sem container de injeção de dependência. A composição de dependências é feita manualmente em
src/routes.ts. O projeto é pequeno o suficiente para que um container adicione indireção sem benefício claro. - Sem camada de
entities/. As regras de domínio que existem (CNPJ, coerência do DANFE) são funções puras emsrc/domain/; não há necessidade de objetos de entidade com identidade própria além do que os tipos de repositório (IProduct,ICompany, etc.) já expressam. - Match incerto nunca entra direto no estoque. Toda sugestão de similaridade da IA fica pendente até confirmação humana — não existe caminho de código que aplique uma sugestão automaticamente, mesmo com confiança alta.
- Auditoria não tem endpoint de leitura HTTP hoje.
IAuditLogRepository.findByCompanyId/findByUserIdexistem, são testados e indexados, mas não há rota que os exponha — decisão deliberada de manter o escopo da API restrito ao fluxo operacional até haver necessidade real de um endpoint de auditoria. AuditLognão tem política de retenção automática. Decisão conservadora: nenhuma exclusão automática até haver requisito legal/de negócio definido para o prazo de guarda de dado fiscal.- Artefatos de build (
.js/.d.ts) são commitados junto do.ts. Os testes importam por caminho.js(convençãonodenext); rodenpm run buildapós editar.tsantes de rodar a suíte, ou o Vitest pode resolver o arquivo compilado desatualizado em vez do fonte.
- Não há Dockerfile de produção — o build de produção é validado no CI instalando só
dependencies, mas não há imagem publicada. - A otimização de lote da chamada de similaridade (M5-02 do backlog) está bloqueada por ausência de dado real de uso — o sistema ainda não foi implantado em produção.
TRUST_PROXY_HOPSeCORS_ALLOWED_ORIGINStêm defaults seguros para instância única sem proxy; ajustar antes de colocar atrás de load balancer/CDN.
npm ci
cp .env.example .env # preencher DATABASE_URL, JWT_SECRET, GEMINI_API_KEY
npm run prisma:generate
npm run dev # tsx watch, recarrega em mudançasPara rodar a suíte de integração contra Postgres real é necessário Docker (docker-compose.test.yml sobe um banco efêmero isolado, nunca a DATABASE_URL de desenvolvimento).