Plataforma para escritórios de contabilidade que elimina o download manual de XMLs na SEFAZ.
Cada empresa-cliente é cadastrada uma vez, com seu certificado digital A1. A partir daí a plataforma consulta a Distribuição DF-e, manifesta ciência quando necessário para liberar o XML completo, arquiva os XMLs em object storage e entrega ao contador uma consulta única, filtrável e auditável de tudo que foi emitido contra ou pelos seus clientes.
A cadência tem duas escalas que vale não confundir: o ciclo do agendador roda de 5 em 5 minutos, e é ele que enfileira quem está vencido; cada empresa é consultada uma vez por hora, que é o mínimo que a SEFAZ tolera depois de uma resposta sem novidade. A grade fina existe porque uma empresa que vencia poucos segundos depois do tique perdia a janela inteira e o intervalo real virava uma hora e meia.
Os eventos também são interpretados: cancelamento e carta de correção alteram o documento. Sem isso, a nota que o emitente desfez continuaria marcada como autorizada e somando nos totais — o XML da nota original não muda quando ela é cancelada, só o evento diz.
| Camada | Tecnologia |
|---|---|
| Backend | .NET 10 · ASP.NET Core · Clean Architecture + DDD + CQRS (MediatR 12) |
| Banco | PostgreSQL 17 · EF Core 10 (Npgsql) |
| Storage | MinIO (S3-compatible) |
| Agendamento | Hangfire |
| Resiliência | Polly 8 (nova tentativa no object storage) |
| Fiscal | ZeusFiscal (Hercules.NET.NFe.NFCe) |
| Frontend | Angular · Signals · NgRx SignalStore · Angular Material |
| Infra | Docker + Docker Compose |
As 14 etapas estão concluídas.
✅ 1. Estrutura da solução ✅ 8. Autenticação JWT + certificados
✅ 2. Docker + Docker Compose ✅ 9. Hangfire
✅ 3. Configuração inicial ✅ 10. ZeusFiscal
✅ 4. Domain ✅ 11. MinIO
✅ 5. Application ✅ 12. Frontend Angular
✅ 6. Infrastructure ✅ 13. Testes
✅ 7. API ✅ 14. Documentação
O backend está completo: captura automática de NF-e na SEFAZ, extração dos dados fiscais do XML (natureza da operação, totais de imposto e itens com CFOP, NCM e CST), manifestação de ciência, captura de eventos, cofre de certificados cifrado, autenticação com perfis, agendamento e a borda endurecida (CORS, limite de requisições, cabeçalhos de segurança e exceção não tratada virando ProblemDetails).
O frontend também: sessão com renovação silenciosa, rotas protegidas por perfil e as sete telas — painel, empresas, certificados, documentos, detalhe do documento, perfil e login.
O XML de uma NF-e não muda quando ela é cancelada — só o evento diz. Uma versão anterior arquivava o evento e descartava o conteúdo, então nota desfeita pelo emitente ficava marcada como autorizada para sempre e continuava somando nos totais. Erro de apuração, não de tela.
Hoje todo evento vira registro, e três deles alteram o documento:
| Código | Evento | Efeito |
|---|---|---|
110111 |
Cancelamento | situação passa a cancelada |
110112 |
Cancelamento por substituição | idem |
110110 |
Carta de correção | grava o texto vigente; a de maior sequência prevalece |
O resto — rastreamento de CT-e e MDF-e, passagem em posto fiscal, e o eco da própria ciência que a plataforma envia — aparece no histórico da nota e não a altera. É a esmagadora maioria do volume: no acervo de referência, 402 eventos e apenas um alterava documento.
Duas decisões que sustentam isso:
- O evento chega antes da nota. Já aconteceu em produção: um cancelamento cuja NF-e nunca foi capturada, porque a empresa não era parte dela. Por isso o evento é entidade própria, e não filha do documento — como filha, não haveria onde guardá-lo, e a informação seria descartada. Quando a nota aparece, o evento pendente é aplicado.
- O
cStatdo retorno é a guarda. Evento recusado pela SEFAZ é registrado e não altera nada. Aplicar cancelaria uma nota válida.
Nos números: documento cancelado continua listado — guarda fiscal de cinco anos —, mas sai de todo
somatório. O resumo da grade e o relatório por empresa expõem cancelados e valorCancelado à parte, para
a conta fechar na conferência.
Há um POST /api/v1/jobs/reprocessar-eventos para o acervo capturado antes de a interpretação existir. Não
fala com a SEFAZ — lê só o object storage —, então roda com o CNPJ bloqueado por consumo indevido.
Na rejeição 656 (consumo indevido) o ponteiro salta para o NSU que a SEFAZ informou, e tudo no
intervalo é perdido — documento e evento —, porque NSU consumido não é reoferecido. Já aconteceu: 188 NSU
descartados numa única rejeição.
A reconciliação por chave (NfeConsultaProtocolo) é o que fecha isso, e está especificada mas não
implementada. Ela é conferência sob demanda, nunca varredura periódica: é uma chamada por documento, contra
a mesma cota que gera o 656 — um recorrente viraria a causa do problema que conserta.
A captura já foi exercitada contra a SEFAZ de produção: NF-e reais entraram pela Distribuição DF-e, com XML completo arquivado no object storage e o ponteiro de NSU avançando entre as rodadas.
O XML é arquivado descompactado. A Distribuição DF-e entrega cada documento no elemento docZip —
o arquivo comprimido em gzip e codificado em base64 —, e a biblioteca fiscal decodifica o base64 mas
devolve os bytes ainda comprimidos. Arquivá-los como vieram produz .xml que nenhum leitor abre.
Uma ressalva que vale para qualquer instalação: o ponteiro de NSU pertence ao CNPJ, não à aplicação. Se outro sistema fiscal já consome a fila daquele CNPJ, a carga inicial não traz histórico — os NSUs anteriores já foram entregues a ele. Não é defeito, é como a SEFAZ define a fila.
A manifestação de ciência também já foi exercitada em produção: o evento 210210 foi aceito pela SEFAZ e o protocolo ficou gravado no documento.
As pendências conhecidas — todas registradas como decisão, não esquecimento — estão em
RELEASE.md.
Todas as rotas exigem autenticação. O primeiro administrador é criado no boot a partir de
ECONTABIL_ADMIN_EMAIL e ECONTABIL_ADMIN_SENHA.
# 0. autenticar (o refresh token vem em cookie httpOnly; o access token, no corpo)
curl -X POST http://localhost:8080/api/v1/auth/login -H "Content-Type: application/json" \
-d '{"email":"admin@escritorio.com.br","senha":"<senha>"}'
# nas chamadas seguintes: -H "Authorization: Bearer <accessToken>"
# 1. cadastrar a empresa-cliente
curl -X POST http://localhost:8080/api/v1/empresas -H "Content-Type: application/json" \
-d '{"razaoSocial":"EMPRESA LTDA","cnpj":"00000000000000","uf":"SP","ambiente":1}'
# 2. enviar o certificado A1 (o CNPJ do titular precisa bater com o da empresa)
curl -X POST http://localhost:8080/api/v1/certificados \
-F "empresaId=<id>" -F "senha=<senha>" -F "arquivo=@caminho/do/certificado.pfx"
# 3. sincronizar agora, sem esperar a janela
# (o passo 2 já enfileirou a carga inicial sozinho; isto é só para forçar na hora)
curl -X POST "http://localhost:8080/api/v1/jobs/sincronizar/<id>?modo=CargaInicial"
# ou em segundo plano, respondendo na hora com o id do trabalho
curl -X POST "http://localhost:8080/api/v1/jobs/enfileirar/<id>?modo=CargaInicial"
# 4. consultar o que veio
curl "http://localhost:8080/api/v1/documentos?empresaId=<id>"
curl "http://localhost:8080/api/v1/documentos/<documentoId>/detalhe"
curl -OJ "http://localhost:8080/api/v1/documentos/<documentoId>/xml"
# filtrar por dados fiscais: CFOP e NCM aceitam prefixo (5 traz as saídas, 3004 traz a família)
curl "http://localhost:8080/api/v1/documentos?cfop=5102&ncm=3004&comSubstituicaoTributaria=true"
# totais do mesmo recorte da listagem (aceita os mesmos filtros)
curl "http://localhost:8080/api/v1/documentos/resumo?empresaId=<id>"
# acervo consolidado por empresa, para conferência do escritório
curl "http://localhost:8080/api/v1/documentos/relatorio/por-empresa?de=2026-07-01&ate=2026-07-31"
# 5. ver o que cada execução fez
curl "http://localhost:8080/api/v1/jobs/execucoes?empresaId=<id>&quantidade=20"
# 6. registrar a ciência agora, sem esperar o ciclo
curl -X POST "http://localhost:8080/api/v1/jobs/manifestar/<id>"
# 7. criar um operador (só Admin pode)
curl -X POST http://localhost:8080/api/v1/usuarios -H "Content-Type: application/json" \
-d '{"nome":"Operador","email":"operador@escritorio.com.br","senha":"<12+ caracteres>","role":2}'Nem empresa nem usuário são excluídos: PATCH /api/v1/empresas/<id>/status e
PATCH /api/v1/usuarios/<id>/status inativam e reativam. Inativar uma empresa a tira do ciclo de captura
e preserva o acervo, que tem guarda fiscal de cinco anos; o ponteiro de NSU não é tocado, então reativar
retoma de onde parou. CNPJ e ambiente da SEFAZ não são editáveis — o ponteiro pertence ao CNPJ.
DELETE /api/v1/certificados/<id> apaga o arquivo cifrado do cofre e mantém o registro inativo: é ele
que explica qual identidade fiscal assinou as consultas já feitas.
Disparo manual para uma empresa que já tem captura em andamento responde 409 com o identificador do
trabalho, em vez de ficar esperando.
GET /health/ready lista PostgreSQL, MinIO e SEFAZ separadamente. A SEFAZ conta como degradação, não
como reprovação: sem ela a captura fica suspensa, mas a grade e os downloads seguem funcionando.
A inativação de usuário derruba
as sessões abertas na hora. O último administrador ativo não pode ser inativado nem rebaixado — sem ele
ninguém administra o sistema. Não há recuperação de senha por e-mail: o administrador redefine em
POST /api/v1/usuarios/<id>/senha, o que também encerra as sessões do usuário.
Nota de fornecedor chega como resumo: a SEFAZ só libera o XML completo depois que a ciência da operação é registrada. Isso acontece sozinho — a sincronização enfileira a manifestação quando traz documento novo, e um ciclo recorrente varre os pendentes. O XML não volta na resposta do evento: ele reaparece num NSU posterior e a sincronização seguinte o arquiva.
A captura roda sozinha: o ciclo avalia quem está vencido de 5 em 5 minutos, e cada empresa é consultada uma vez por hora. O disparo manual serve para não esperar a janela. O dashboard
do Hangfire fica em /hangfire, aberto no navegador depois do login — a sessão vai por cookie restrito
a essa rota, e só perfil Admin entra.
Empresa cujo CNPJ já é consultado por outro sistema fiscal responde 656 na primeira sincronização e
fica bloqueada por uma hora — é o comportamento esperado, e o ultimoNsu já sai realinhado pelo valor
que a própria SEFAZ informou.
Migrations são geradas e aplicadas com a ferramenta local do repositório:
dotnet tool restore
dotnet ef database update -p src/eContabil.Infrastructure -s src/eContabil.InfrastructureO app fica em src/econtabil-web (Angular 21, zoneless, NgRx SignalStore, Angular Material, Vitest).
cd src/econtabil-web
npm install
npm start # http://localhost:4200, contra a API em execução
npm run build
npm test # VitestOu pelo Compose, junto do resto — o nginx serve o app e faz proxy de /api/ para a API, o que mantém
tudo na mesma origem:
docker compose --profile frontend up -d --buildO access token vive só em memória; o refresh token é um cookie httpOnly que o JavaScript não lê. Uma
sessão expirada é renovada uma única vez, mesmo com várias requisições em voo.
eContabil/
├── src/
│ ├── eContabil.Shared/ Result, Notification, primitivos — zero dependências
│ ├── eContabil.Domain/ entidades, value objects, invariantes — zero dependências
│ ├── eContabil.Application/ commands, queries, handlers, behaviors
│ ├── eContabil.Infrastructure/ EF Core, MinIO, ZeusFiscal, Hangfire, JWT, criptografia
│ ├── eContabil.Api/ controllers, middlewares, Swagger, composition root
│ └── econtabil-web/ Angular 21 — core (auth, http, layout), features, shared
└── tests/
├── eContabil.Domain.Tests/
├── eContabil.Application.Tests/
├── eContabil.Infrastructure.Tests/
└── eContabil.Api.IntegrationTests/
Dependências apontam para dentro: Api → Application → Domain → Shared. Infrastructure implementa as
interfaces declaradas na Application; Api só a conhece para registrar a injeção de dependência.
- .NET SDK 10.0.100+
- Docker Desktop (a partir da etapa 2)
- Node 22+ (a partir da etapa 12)
dotnet build eContabil.slnx # deve terminar com 0 avisos e 0 erros
dotnet test eContabil.slnxcp .env.example .env # e troque TODOS os valores antes de qualquer uso real
docker compose up -d --buildSobem quatro serviços: postgres, minio, minio-init (cria os buckets e encerra) e api.
| Endereço | O quê |
|---|---|
| http://localhost:8080/swagger | Documentação interativa da API |
| http://localhost:8080/health | Health check da API — deve responder Healthy |
| http://localhost:9001 | Console do MinIO |
| localhost:5432 | PostgreSQL, publicado apenas para inspeção local |
O serviço do frontend existe no Compose sob o perfil frontend e passa a ser usado na etapa 12:
docker compose --profile frontend up.
docker compose ps # todos devem estar healthy
docker compose logs -f api
docker compose down # para tudo, preservando os dados
docker compose down -v # APAGA banco, XMLs e certificadosComentários descrevem o código — nada além disso. Explicam o quê e, principalmente, o porquê técnico de uma decisão no ponto em que ela existe.
Um comentário nunca referencia especificação, ADR, critério de aceite (AC-N), número de tarefa,
decisão datada ou caminho de documento. Esse tipo de rastreabilidade vive nos .md, não no código: o
documento muda de nome, é renumerado ou deixa de existir, e o comentário vira uma pista falsa que ninguém
consegue mais verificar. Quem lê o código precisa entender o código ali mesmo, sem abrir outro arquivo.
// ❌ referencia documento externo — apodrece e não ajuda quem está lendo o código
// valida o NSU conforme spec 010, AC-8
// ❌ descreve o óbvio
// incrementa o NSU
UltimoNSU = novoNsu;
// ✅ explica o porquê técnico, com informação que o código sozinho não dá
// NSU que retrocede faz o mesmo lote ser reprocessado para sempre e mascara nota faltando
if (novoNsu <= UltimoNSU) return Result.Falhou(...);
// ✅ explica uma escolha não óbvia
// EphemeralKeySet: sem ele o .NET grava material de chave privada no perfil do container
using var cert = X509CertificateLoader.LoadPkcs12(bytes, senha, X509KeyStorageFlags.EphemeralKeySet);Vale para comentários de linha e para XML docs. Comentário que só repete o nome do método deve ser apagado, não reescrito.
- Mapeamento manual. AutoMapper, Mapster e TinyMapper são proibidos.
- Regra de negócio nunca vira exceção — retorna
Resultcom notificações. Exceção é para o inesperado. - XML nunca entra no banco; certificado nunca sai do cofre em claro, nunca toca disco, nunca vai para log.
- Toda data é UTC no banco; conversão para
America/Sao_Paulosó na apresentação. - Nenhum segredo em
appsettingsversionado — só variável de ambiente. dotnet buildcom 0 avisos edotnet testverde são pré-requisito de todo commit.
Uma branch por etapa, nomeada pela spec que ela implementa:
feature/<NNN>-<slug-da-spec> feature/013-docker-compose
fix/<slug> fix/nsu-retrocede-em-lote-parcial
main só recebe trabalho concluído: build com 0 avisos, testes verdes e a spec correspondente atendida.
<tipo>(<escopo>): <resumo no imperativo>
O resumo se sustenta sozinho. Não há sufixo apontando para spec: elas são locais e não versionadas, e quem lê o histórico num clone não teria como abrir a referência. O corpo do commit é o lugar de explicar o porquê, com a informação inteira e não com um ponteiro.
Tipos: feat, fix, refactor, test, docs, chore, perf, build.
Escopos: domain, application, infra, api, auth, sefaz, minio, hangfire, web, docker, db.
A referência à spec fica na mensagem do commit, que é imutável e datada — nunca no código.
| Documento | Para quê |
|---|---|
RUNBOOK.md |
Operação: backup, restore, rotação da chave mestra, incidentes |
CONTRIBUTING.md |
Convenção de commits e regras do código |
RELEASE.md |
Checklist antes de marcar uma versão |
/swagger |
Contrato da API, gerado em tempo de execução |
A documentação de arquitetura (docs/) e as especificações (specs/) são locais e não versionadas,
por decisão do autor. Não estão neste repositório; o backup delas é responsabilidade do desenvolvedor.
Antes de subir em produção, leia o
RUNBOOK.md.ECONTABIL_CRYPTO_KEYcifra os certificados digitais dos clientes: perdê-la inutiliza todos eles, sem recuperação possível.
Este sistema armazena certificados digitais de terceiros. Antes de qualquer implantação, dois pontos inegociáveis:
- A chave mestra de criptografia (
ECONTABIL_CRYPTO_KEY) é a única coisa que torna os certificados recuperáveis. Perdê-la inutiliza todos eles. Backup obrigatório, em cofre separado do backup do banco. - Nenhum
.pfx,.p12,.keyou.envpode ser versionado. O.gitignorejá bloqueia todos eles.