Serviço em Node.js / TypeScript para emissão de NFS-e (modelo São Paulo) com:
- Normalização e validação (Zod)
- Idempotência e persistência (Prisma)
- Assinatura e verificação XML (xml-crypto v6.1.2)
- Testes (Jest)
Este projeto pode ser facilmente implantado em plataformas cloud. Recomendamos:
- Supabase + Vercel - Deploy completo e moderno (recomendado)
- Railway - Deploy mais simples, PostgreSQL incluído
- Render - Alternativa com free tier generoso
- Fly.io - Para máxima performance global
Para mais opções, consulte o Guia Completo de Deploy.
- Supabase: Crie projeto em https://supabase.com
- Vercel: Conecte repositório em https://vercel.com
- Execute migrações:
npx prisma migrate deploy - Configure variáveis de ambiente
- Deploy automático!
Vantagens:
- ✅ PostgreSQL gerenciado gratuito (500MB)
- ✅ Deploy automático do frontend
- ✅ Preview deployments
- ✅ Integração perfeita
| Camada | Descrição |
|---|---|
| Normalização | Converte payload externo para formato interno validado |
| Serviço NFSe | Orquestra emissão (normaliza, idempotência, persiste, assina) |
| Assinador XML | Gera assinatura enveloped e embute certificado X509 |
| Verificação | Valida assinatura e detecta adulteração |
- Recebe requisição no endpoint
POST /nfse/emitir. - Normaliza dados (
nfseNormalizedSchema). - Verifica/gera chave de idempotência (evita duplicação).
- Cria registro inicial (status pendente).
- Monta XML RPS e assina (SHA-256 padrão).
- (Futuro) Envia ao agente / prefeitura.
- Atualiza status (autorizado / rejeitado) e registra log.
Arquivo: src/core/xml/signer.ts
- Algoritmos padrão:
rsa-sha256+ digestsha256. - Fallback SHA-1 se variável
SIGN_LEGACY_SHA1=1. - Em ambiente de teste sem PFX: gera certificado efêmero em memória.
- Injeta
<KeyInfo><X509Data>automaticamente se ausente.
verifyXmlSignature(xml):
- Localiza nó
<Signature>. - Extrai primeiro
<X509Certificate>. - Reconstrói PEM e valida assinatura.
- Retorna boolean (false se parsing ou assinatura inválida).
- O campo
Aliquotano XML é representado em fração decimal com 4 casas.- Exemplos:
2%→0.0200;5%→0.0500;0%→0.0000.
- Exemplos:
- Valores monetários usam ponto (
.) como separador decimal e não possuem separador de milhar. - Caracteres especiais em campos textuais são devidamente escapados para XML.
- Arquivo relacionado:
src/core/xml/abrassf-generator.ts. - Quando
deductionsAmount > 0, o gerador inclui<ValorDeducoes>com 2 casas decimais. - Exemplo de XML:
examples/rps-sample.xml. - Quando
provider.municipalRegistrationfor informado, o XML inclui<Prestador><InscricaoMunicipal>. - Quando
additionalInfofor informado, o XML inclui<OutrasInformacoes>com o conteúdo escapado. - Quando
customer.emailfor informado, o XML inclui<Tomador><Email>. - Quando
municipalTaxCodefor informado, o XML inclui<CodigoTributacaoMunicipio>. - Quando
customer.addressfor informado, o XML inclui<Tomador><Endereco>com campos opcionais:<Endereco>,<Numero>,<Complemento>,<Bairro>,<CodigoMunicipio>,<Uf>,<Cep>.
Constantes exportadas:
ABRASF_NS:http://www.abrasf.org.br/nfse.xsdExtraAttrPair: alias de tipo[string, string|number|boolean]toExtraPairs(...): helper que normalizaRecord/Map/ArrayemArray<ExtraAttrPair>
Assinatura da função:
buildRpsXml(data, options?)
Observação: a função expõe overloads de tipos para melhorar a inferência quando extraRootAttributes for Record, Array<ExtraAttrPair> ou Map.
Parâmetros em options:
includeSchemaLocation?: boolean— incluixmlns:xsiexsi:schemaLocationno elemento root (default:false).schemaLocation?: string— conteúdo doxsi:schemaLocation(default:${ABRASF_NS} NFSe.xsd).extraRootAttributes?: Record<string,string|number|boolean> | Array<ExtraAttrPair> | Map<string,string|number|boolean>— atributos adicionais no root (ex.:{ versao: '2.03', ativo: true }).extraRootAttributes?: Record<string,string|number|boolean>— atributos adicionais no root (ex.:{ versao: '2.03', ativo: true }).preserveExtraOrder?: boolean— quandotrue, a emissão dos atributos extras preserva a ordem de inserção das chaves (default:false). Observação: usandoRecord(objeto plano), reatribuir a mesma chave não muda sua posição; apenas o valor final é considerado (last-wins).nsPrefix?: string— prefixo do namespace no root (ex.:'nfse'gera<nfse:Rps>exmlns:nfse=...).namespaceUri?: string— URI do namespace (default:ABRASF_NS).rootName?: string— nome do elemento root (default:'Rps').
Notas sobre o elemento root:
- Os atributos em
extraRootAttributessão emitidos em ordem alfabética por chave, garantindo determinismo. - Quando
preserveExtraOrder: true, a ordem dos atributos extras segue a ordem de inserção:Record(objeto): reatribuir a mesma chave não muda a posição; apenas o valor final é considerado (last-wins).Array<[k,v]>eMap: repetição da mesma chave é respeitada na sequência e a chave é reposicionada para o fim (last-wins com movimento de posição).
- Atributos reservados não podem ser sobrescritos via
extraRootAttributese serão ignorados se presentes:xmlnsouxmlns:<prefix>(dependendo densPrefix)xmlns:xsiexsi:schemaLocation(quandoincludeSchemaLocationestiver ativo)
- É permitido declarar namespaces adicionais (ex.:
xmlns:foo), que serão preservados. - Saneamento/dedup/validação de
extraRootAttributes:- As chaves são
trim()adas antes do processamento. - Chaves duplicadas são deduplicadas com política "última vence" (last-wins).
- Nomes de atributo inválidos são ignorados. São aceitos nomes simples (
[A-Za-z_][\w.-]*) e qualificados (prefix:namecom ambos válidos). Exemplos removidos:"1invalid","ns:". - Os valores (string/number/boolean) são convertidos para string, escapados para XML, e atributos reservados permanecem protegidos.
- Validação de nomes do root:
nsPrefixé validado como NCName (sem:). Se inválido, é ignorado (usaxmlnsdefault).rootNameé validado como NCName. Se inválido ou vazio, o nome do root cai paraRps.
- As chaves são
Exemplos:
- Root com prefixo e
schemaLocation:
import { buildRpsXml, ABRASF_NS } from './src/core/xml';
const xml = buildRpsXml(data, {
nsPrefix: 'nfse',
includeSchemaLocation: true,
// schemaLocation: `${ABRASF_NS} NFSe.xsd` // opcional
});
- Atributos extras no root (ex.: versão do layout):
const xml = buildRpsXml(data, {
extraRootAttributes: { versao: '2.03' }
});
2.1) Preservando ordem de inserção dos atributos extras:
const extras: Record<string,string|number|boolean> = {};
extras["a"] = 1;
extras["c"] = 3;
extras["b"] = 2;
extras["a"] = 9; // last-wins no valor; posição de "a" segue a primeira inserção
const xml = buildRpsXml(data, {
preserveExtraOrder: true,
extraRootAttributes: extras
});
2.2) Controlando ordem com Array de pares (move chave repetida para o fim):
import type { ExtraAttrPair } from './src/core/xml';
const pairs: Array<ExtraAttrPair> = [
['a', 1],
['c', 3],
['b', 2],
['a', 9], // reposiciona 'a' para o fim (last-wins)
];
const xml = buildRpsXml(data, {
preserveExtraOrder: true,
extraRootAttributes: pairs
});
2.3) Controlando ordem com Map (reordenação explícita via toExtraPairs):
import { toExtraPairs } from './src/core/xml';
const m = new Map<string,string|number|boolean>();
m.set('x', 1);
m.set('y', 2);
// Em Map, reatribuir a mesma chave não move a posição de inserção; para mover, converta em pares e duplique a chave desejada no fim
const pairs = toExtraPairs(m);
pairs.push(['x', 9]); // last-wins + reposiciona 'x' para o fim
const xml = buildRpsXml(data, {
preserveExtraOrder: true,
extraRootAttributes: pairs
});
- Namespace e nome do root customizados:
const xml = buildRpsXml(data, {
nsPrefix: 'nfse',
namespaceUri: 'http://www.abrasf.org.br/nfse.xsd',
rootName: 'Rps'
});
GET /health → { status, uptime, timestamp, version }
GET /version → { version }
GET /live → { status } (liveness)
GET /ready → { status, issues[], timestamp } (readiness com checagens de lag/heap/RSS/DB)
GET /health/deps → { db: { ok, error? }, cert: { ok, thumbprint?, notBefore?, notAfter?, daysToExpire?, error? }, status, timestamp }
Observação sobre desligamento gracioso:
- O serviço captura
SIGINT/SIGTERM, defineapp_ready=0eapp_live=0, e encerra o Fastify com timeout de 10s. Isso permite que o balanceador retire a instância antes de fechar conexões.
| Nome | Função |
|---|---|
PORT |
Porta HTTP (default 3000) |
CERT_PFX_PATH |
Caminho para arquivo .pfx (cert A1) |
CERT_PFX_PASSWORD |
Senha do PFX (se houver) |
SIGN_LEGACY_SHA1 |
Força uso de SHA-1 (compatibilidade) |
DATABASE_URL |
Conexão Prisma |
ALLOWED_ORIGINS |
Lista CSV de origins CORS (ex.: http://localhost:5173,https://app.example.com,*.corp.example) |
METRICS_ENABLED |
Ativa/desativa métricas e endpoint /metrics (1 padrão em dev/test; use 0 para desativar) |
HEALTH_MAX_EVENT_LOOP_LAG_MS |
Limite de lag do event loop aceito antes de degradar readiness (ms) |
HEALTH_MAX_HEAP_USED_BYTES |
Limite de heap utilizado (bytes) |
HEALTH_MAX_RSS_BYTES |
Limite de RSS (bytes) |
HEALTH_DB_TIMEOUT_MS |
Timeout do ping ao DB (ms) |
npm run dev # Desenvolvimento
npm run build # Build (tsup)
npm test # Testes
npm run coverage # Cobertura
Este projeto versiona os arquivos package-lock.json (raiz e ui/) para garantir builds reprodutíveis e consistentes entre desenvolvimento, CI e produção.
- Reprodutibilidade: Garante que todos os ambientes usem exatamente as mesmas versões de dependências
- Segurança: Evita surpresas com atualizações automáticas que podem quebrar funcionalidades
- Performance: Builds mais rápidos e previsíveis no CI/CD
- Consistência: Desenvolvimento local, CI e produção usam as mesmas versões
package.json # Dependências do backend (Node.js/Prisma/Fastify)
package-lock.json # Lockfile do backend
ui/package.json # Dependências do frontend (React/Vite/Tailwind)
ui/package-lock.json # Lockfile do frontend
# Instalar dependências (usa lockfiles existentes)
npm ci # Backend
cd ui && npm ci # Frontend
# Atualizar dependências (desenvolvimento)
npm update # Backend
cd ui && npm update # Frontend
# Adicionar nova dependência
npm install <pacote> # Backend
cd ui && npm install <pacote> # Frontend
# Após mudanças, commite os package-lock.json atualizados
git add package-lock.json ui/package-lock.json
- O GitHub Actions usa
npm cipara instalar dependências de forma determinística - Os lockfiles são validados em cada build
- Mudanças nos lockfiles devem ser commitadas junto com mudanças no
package.json
- ✅ Sempre use
npm ciem produção/CI (mais rápido e confiável quenpm install) - ✅ Versione os
package-lock.jsonno Git - ✅ Não edite manualmente os lockfiles
- ✅ Execute
npm auditregularmente para verificar vulnerabilidades - ✅ Use
npm updatecom cuidado e teste thoroughly após atualizações - ❌ Não delete
package-lock.jsonsem necessidade - ❌ Não use
npm installem produção/CI
- Dependências e Prisma Client:
npm ci
npm run prisma:generate
- Banco e migrações (ou use um comando só no passo 3):
# Subir Postgres (Docker)
npm run db:up
# Aplicar migrações Prisma
npm run prisma:migrate
- Subir API (dev):
npm run dev:local
Atalho (um comando só):
npm run dev:up
Esse script: sobe o DB via Docker, aguarda a porta 5432 responder, aplica migrações e inicia o servidor de desenvolvimento.
Modo sem banco (memória) — útil quando você não tem Docker/Postgres:
npm run dev:mem
Atalho Windows (libera a porta e inicia em memória):
npm run dev:mem:win
Start completo no Windows (libera porta, sobe servidor, espera /live e roda smoke):
npm run dev:win:start
Start completo no Windows salvando relatório JSON automaticamente:
npm run dev:win:start-report
Atalho Windows para teste rápido de emissão (sem baixar artefatos e sem cancelar):
npm run dev:win:emit-only
Atalho Windows (emit-only) salvando relatório JSON automaticamente:
npm run dev:win:emit-report
Observações do modo memória:
- Os dados não são persistidos (somem ao reiniciar).
- Idempotência e numeração RPS funcionam na sessão atual.
- O smoke funciona normalmente (agente em modo stub se
AGENT_BASE_URLnão estiver definido).
- Token de dev e chamadas (PowerShell):
# Opção A: gerar um token localmente (sem chamar a API)
$jwt = (npm run -s token -- --sub dev --roles tester | ConvertFrom-Json).token
# Opção B: obter token do endpoint de dev (não disponível em produção)
$jwt = Invoke-RestMethod -Method Post -Uri http://127.0.0.1:3000/auth/token -ContentType 'application/json' -Body '{}' | Select-Object -ExpandProperty token
Invoke-RestMethod -Method Get -Uri http://127.0.0.1:3000/live -Headers @{ Authorization = "Bearer $jwt" } | ConvertTo-Json
Diagnóstico rápido e smoke enxuto:
npm run health
# Usando token gerado localmente
$jwt = (npm run -s token -- --sub dev --roles tester | ConvertFrom-Json).token
npm run smoke:fast -- -Token $jwt
Smoke somente de emissão (sem artefatos e sem cancelamento):
npm run smoke:emit-only
Você também pode passar os parâmetros manualmente:
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\smoke.ps1 -BaseUrl http://127.0.0.1:3000 -SkipArtifacts -NoCancel
Notas do smoke:
- O script imprime
x-correlation-iddas respostas para facilitar a correlação com logs. - A chave padrão de idempotência é randomizada por execução e, se receber
409 Conflict, o script rotaciona a chave e reenvia.
Gerar relatório JSON do smoke (útil para CI/diagnóstico):
npm run smoke:report
# ou manualmente
$ts = Get-Date -Format yyyyMMdd_HHmmss
./scripts/smoke.ps1 -SkipArtifacts -NoCancel -JsonOut "./smoke_$ts.json"
Campos incluídos no JSON: baseUrl, startedAt, finishedAt, idempotencyKey,
emit{ id, nfseNumber, status, correlationId, httpStatus, httpStatusCode, durationMs },
list{ total, itemsCount, durationMs },
artifacts{ xmlLength, pdfLength, durationMs },
cancel{ status|skipped, canceledAt, correlationId, httpStatus, httpStatusCode, durationMs },
steps[] (cada passo contém step, ok, durationMs e, quando aplicável, error, httpStatusCode). O relatório também inclui totalDurationMs para o tempo total da execução.
- A API aceita o cabeçalho
x-correlation-id(oux-request-id) em qualquer requisição e o propaga no contexto. - Todas as respostas retornam o cabeçalho
x-correlation-id. Osmoke.ps1imprime esse valor para ajudar no trace. - O cliente TypeScript (
src/client/nfse-client.ts) injeta automaticamente umx-correlation-id(customizável por callback/string) e expõe o valor retornado noonResponse.
- Tasks adicionadas em
.vscode/tasks.json:Smoke: Emit Only→ rodanpm run smoke:emit-only.Dev: Start + Emit Only (Windows)→ rodanpm run dev:win:emit-only(libera porta, inicia em memória e executa smoke sem cancelar).Smoke: Report→ rodanpm run smoke:reporte salva um arquivosmoke_YYYYMMDD_HHMMSS.jsonno diretório raiz.
Resumo rápido do último relatório gerado:
npm run smoke:report:summary
Ou de um arquivo específico:
npm run smoke:summary -- ./smoke_20250917_170932.json
409 Conflictao emitir:- Causa comum: reutilização de
Idempotency-Keycom payload diferente (o serviço associa a chave ao hash do payload na primeira chamada). - Soluções:
- Gere uma nova chave (ex.: GUID) ou deixe o
smoke.ps1randomizar automaticamente. - Se a intenção é reprocessar a mesma emissão, garanta que o payload seja idêntico ao da primeira tentativa.
- Gere uma nova chave (ex.: GUID) ou deixe o
- Causa comum: reutilização de
- Token de dev indisponível (
/auth/token):- Gere localmente:
npm run -s token -- --sub dev --roles tester | ConvertFrom-Jsone passe-Tokenno smoke/CLI.
- Gere localmente:
- Serviço não fica live:
- Use
npm run dev:win:startpara iniciar + diagnosticar; veja o PID e verifique logs.
- Use
Demo fim-a-fim (gera token, emite, lista, baixa XML/PDF, cancela):
npm run demo:emit
Demo rápido (gera token, emite e consulta por ID):
npm run demo:quick
- Status: badge no topo deste README (Smoke Windows).
- O workflow roda o smoke com AutoStart (in-memory), salva o relatório
smoke_*.jsoncomo artefato e publica um resumo no Job Summary da execução. - Para acessar: Actions → "Smoke (Windows, AutoStart)" → última execução → aba Summary (resumo) e Artifacts (arquivo JSON).
Demo emissão real (usa AGENT_BASE_URL e PFX do ambiente):
$env:AGENT_BASE_URL="https://seu-agente..."
$env:CERT_PFX_PATH="C:\caminho\seu.pfx"
$env:CERT_PFX_PASSWORD="senha"
npm run demo:real
CLI de desenvolvimento (sem depender de curl/Postman):
# Preparar um token (gerado localmente)
$jwt = (npm run -s token -- --sub dev --roles tester | ConvertFrom-Json).token
# Emitir usando um arquivo JSON de exemplo (temos vários):
# - examples/emit.json (mínimo)
# - examples/emit-cpf.json (tomador CPF)
# - examples/emit-iss-retido.json (ISS retido)
# - examples/emit-address.json (endereço completo)
# - examples/emit-deductions.json (com deduções)
npm run -s cli -- emit --body examples/emit.json --idem idem-123 --token $jwt --pretty
# Exemplos individuais
npm run -s cli -- emit --body examples/emit-cpf.json --idem idem-cpf --token $jwt --pretty
npm run -s cli -- emit --body examples/emit-iss-retido.json --idem idem-iss --token $jwt --pretty
npm run -s cli -- emit --body examples/emit-address.json --idem idem-end --token $jwt --pretty
npm run -s cli -- emit --body examples/emit-deductions.json --idem idem-ded --token $jwt --pretty
# Consultar por ID
npm run -s cli -- get --id inv_1 --token $jwt --pretty
# Listar com filtros
npm run -s cli -- list --status SUCCESS --page 1 --pageSize 5 --token $jwt --pretty
# Baixar XML (base64 para arquivo)
npm run -s cli -- xml --id inv_1 --out xml.b64 --token $jwt
# Baixar PDF (decodificar base64 e salvar binário)
npm run -s cli -- pdf --id inv_1 --out nfse.pdf --decode --token $jwt
# Cancelar com motivo
npm run -s cli -- cancel --id inv_1 --reason "Erro de digitação" --token $jwt --pretty
Liberar porta (Windows):
npm run port:free
Observações de desenvolvimento:
- Quando
AGENT_BASE_URLnão está definido, o cliente do agente opera em modo stub: retorna NFS-e fake (status: SUCCESS), XML/PDF base64 e cancelamentoCANCELLED. DefinaAGENT_BASE_URL(e opcionalmenteCERT_PFX_PATH/CERT_PFX_PASSWORD) para usar o agente real. - O endpoint
POST /auth/tokensó existe quandoNODE_ENVnão éproduction.
- Endpoint:
GET /metrics(exposto quandoMETRICS_ENABLED != 0) - Métricas expostas:
http_requests_total{method,route,status}http_requests_in_flight{method,route}
http_request_duration_seconds_bucket{...,le="..."}/_sum/_count(commethod,route,status)http_request_duration_seconds_by_route_bucket{...,le="..."}/_sum/_count(commethod,route)app_live(1/0),app_ready(1/0),db_up(1/0),db_ping_seconds(histograma)process_start_time_seconds,process_resident_memory_bytes,process_heap_used_bytes,process_cpu_seconds_totalapp_info{version,node_version}nodejs_eventloop_lag_seconds{stat="mean|max"}
Exemplo de scrape (Prometheus):
scrape_configs:
- job_name: nfse-sp-service
metrics_path: /metrics
static_configs:
- targets: ["localhost:3000"]
Healthcheck em container:
- Dockerfile inclui
HEALTHCHECKque consulta/live. docker-compose.ymldefine umhealthcheckequivalente e forçaMETRICS_ENABLED=1para facilitar testes locais de métricas.
Dashboards e Alertas
-
Grafana: importe
ops/grafana/dashboard-nfse.jsone configure a fonte de dados Prometheus (uidprometheusou ajuste no JSON). -
Prometheus: carregue as regras em
ops/prometheus/rules/nfse-rules.ymlno seuprometheus.yml. Exemplo:rule_files: - ops/prometheus/rules/*.ymlKubernetes
- Manifests em
ops/k8s/deployment.yamlincluem Deployment, Service e ServiceMonitor (Prometheus Operator). - Probes configuradas:
livenessProbeem/liveereadinessProbeem/ready. - Ajuste
imagee referências a Secrets (JWT/DATABASE_URL) conforme seu ambiente.
Passo a passo (exemplo):
- Ajuste a imagem do container em
ops/k8s/deployment.yaml(your-registry/nfse-sp-service:latest). - Crie os secrets necessários:
apiVersion: v1 kind: Secret metadata: name: nfse-secrets type: Opaque stringData: jwt_secret: change_me database_url: postgresql://user:pass@host:5432/db?schema=publicAplicar:
kubectl apply -f nfse-secrets.yaml- Aplique Deployment/Service/ServiceMonitor:
kubectl apply -f ops/k8s/deployment.yaml kubectl get pods -l app=nfse-sp-service -w- (Opcional) Se usa Prometheus Operator, confirme a detecção do ServiceMonitor e o scrape de
/metrics. - Configure Ingress/Service externo conforme sua plataforma para expor a porta 80 do Service.
- Manifests em
- Suba Prometheus e Grafana com o compose auxiliar:
docker compose -f docker-compose.observability.yml up -d
- Acesse:
- Prometheus: http://localhost:9090
- Grafana: http://localhost:3001 (user: admin, senha: admin)
- Provisionamento automático:
- Data source Prometheus (http://prometheus:9090)
- Dashboard NFSe (carregado de
ops/grafana/dashboard-nfse.json)
Build da imagem e execução local:
docker build -t nfse-sp-service .
docker run --rm -p 3000:3000 ^
-e NODE_ENV=production ^
-e JWT_SECRET=change_me ^
-e LOG_LEVEL=info ^
nfse-sp-service
Compose com Postgres:
docker compose up --build
Usando a imagem publicada no GHCR:
docker pull ghcr.io/igordev99/emissao-de-nota-automatica:v0.1.1
docker run --rm -p 3000:3000 ^
-e NODE_ENV=production ^
-e JWT_SECRET=change_me ^
-e LOG_LEVEL=info ^
ghcr.io/igordev99/emissao-de-nota-automatica:v0.1.1
Observação sobre autenticação no GHCR:
- Para repositórios privados, faça login antes de dar pull:
echo $env:GHCR_PAT | docker login ghcr.io -u <seu-usuario-github> --password-stdin
- Gere um PAT (classic) com escopo
read:packagese exporte emGHCR_PAT.
Limiares mínimos definidos em package.json (coverageThreshold). Ajuste conforme evolução.
- Implementar endpoints REST completos
- Envio real para agente / prefeitura
- Criptografia de XML assinado armazenado (AES-256-GCM)
- Observabilidade (tracing + métricas)
- Política de rotação de certificados
Este repositório usa Release Please para automatizar o versionamento semântico, changelog, criação de tag e GitHub Release.
Fluxo:
- Ao fazer merge na
main, um PR de release é aberto/atualizado automaticamente (ex.:chore: release v0.1.2). - Revise e faça merge do PR de release.
- No merge, a action cria a tag
vX.Y.Ze um GitHub Release com notas. - O workflow de CI é disparado para a tag e publica a imagem no GHCR com a tag semântica, além de tags de branch e SHA.
Consumindo imagens:
- Semver fixo:
ghcr.io/igordev99/emissao-de-nota-automatica:v0.1.1 - Última de uma série (se configurado no futuro):
v0.1oulatest(consulte as tags disponíveis no GHCR)
Autenticação (JWT) — obtenha um token (fora do escopo deste README) e exporte:
$env:TOKEN = "<seu-jwt>"
Em desenvolvimento/teste, você pode obter um token diretamente do serviço (não disponível em produção):
curl -X POST http://localhost:3000/auth/token -H "Content-Type: application/json" -d '{"sub":"tester"}'
# copie o valor de .token do JSON retornado e exporte em $env:TOKEN
Emitir NFS-e (com idempotência):
curl -X POST http://localhost:3000/nfse/emitir \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: inv_123_$(date +%s)" \
-d '{
"rpsSeries":"A",
"issueDate":"2025-09-16T10:00:00.000Z",
"serviceCode":"101",
"serviceDescription":"Serviço de informática",
"serviceAmount":150.5,
"taxRate":0.02,
"issRetained":false,
"provider": { "cnpj":"12345678000199" },
"customer": { "cnpj":"99887766000155", "name":"Cliente Exemplo" }
}'
Consultar status por ID:
curl -H "Authorization: Bearer $TOKEN" http://localhost:3000/nfse/inv_123
Obter XML/PDF (base64):
curl -H "Authorization: Bearer $TOKEN" http://localhost:3000/nfse/inv_123/xml
curl -H "Authorization: Bearer $TOKEN" http://localhost:3000/nfse/inv_123/pdf
Listar com filtros (inclui nfseNumber):
curl -H "Authorization: Bearer $TOKEN" "http://localhost:3000/nfse?page=1&pageSize=20&status=SUCCESS"
curl -H "Authorization: Bearer $TOKEN" "http://localhost:3000/nfse?nfseNumber=2025XXXX"
- Não versionar PFX
- Restringir permissões de leitura do certificado
- Validar expiração antecipadamente (alarme 30 dias)
- Evitar log de dados sensíveis / XML completo
Prod:
npx prisma migrate deploy
Dev:
npx prisma migrate dev
Cobrem: normalização, auto-numeração de RPS, idempotência, assinatura e verificação (detecção de tampering).
- Postman: importe
examples/nfse-sp.postman_collection.json.- Variáveis:
baseUrl(defaulthttp://localhost:3000),jwt(token Bearer),idem(sufixo da idempotency-key),id(ex.:inv_1). - Inclui:
GET /ready,GET /metrics,POST /nfse/emitir,GET /nfse/:id/xml,GET /nfse/:id/pdf,GET /nfse,POST /nfse/:id/cancel.
- Variáveis:
Definir conforme política interna.
Documento consolidado.
A função verifyXmlSignature faz parsing do XML e valida a assinatura encontrada. Futuras melhorias previstas:
- O serviço registra a documentação de forma opcional e segura. Para habilitar a UI em
/docs, instale as dependências:
npm i -E @fastify/swagger @fastify/swagger-ui
- A documentação é carregada em tempo de execução via import dinâmico; se os pacotes não estiverem instalados, o serviço continua funcionando normalmente e loga um aviso: "Swagger não carregado (dependência ausente ou incompatível)".
- Rotas anotadas com schemas:
- Saúde:
/health,/live,/ready,/version,/health/deps,/health/cert - NFSe:
POST /nfse/emitirGET /nfse/:idGET /nfse/:id/pdfGET /nfse/:id/xmlGET /nfse(listagem com filtrosstatus,providerCnpj,customerDoc,from,toe paginaçãopage,pageSize)POST /nfse/:id/cancel(cancela via agente; retorna 200 com status, incluindoCANCELLEDquando aplicável)
- Saúde:
- As rotas incluem
tags,summarye esquemas deparams,headers,bodyeresponsequando aplicável.
Observação: os campos tags/summary e alguns esquemas são atribuídos via cast para any a fim de evitar conflitos de tipagem com FastifySchema quando os plugins Swagger não estão presentes em tempo de build. Isso não impacta a execução.
Autenticação e Components
- Endpoints de NFSe exigem autenticação Bearer (JWT) e estão marcados com
security: bearerAuthna especificação. - Os esquemas foram consolidados em
components.schemas(ex.:NfseEmitRequest,NfseEmitResponse,NfseListResponse,ErrorEnvelope, etc.) e referenciados nas rotas via$refpara facilitar reuso e consistência.
Obter o documento OpenAPI (JSON):
- Se os pacotes de Swagger estiverem instalados, o documento estará disponível em
GET /openapi.json. - Caso não estejam, esse endpoint responderá
503com a mensagemSwagger não instalado.
Dump via script (sem depender da rota HTTP):
# Imprime no stdout
npm run openapi:dump
# Salva no arquivo
npm run openapi:dump > openapi.json
Gerar tipos TypeScript a partir do OpenAPI (útil para clientes):
npm run openapi:types
# Tipos gerados em: src/types/openapi.d.ts
Verifique rapidamente se seu PFX e o endpoint do agente estão OK antes de habilitar a integração real.
- Checar PFX (thumbprint e validade):
$env:CERT_PFX_PATH="C:\caminho\seu.pfx"
$env:CERT_PFX_PASSWORD="senha-opcional"
npm run -s check:pfx
Saída JSON esperada (exemplo):
{"ok":true,"thumbprint":"ABC123...","notBefore":"2025-08-01T12:00:00.000Z","notAfter":"2026-08-01T12:00:00.000Z","daysToExpire":300}
- Checar agente (TLS/mTLS):
$env:AGENT_BASE_URL="https://seu-agente.exemplo.com/ping"
# opcional mTLS
$env:CERT_PFX_PATH="C:\caminho\seu.pfx"
$env:CERT_PFX_PASSWORD="senha"
npm run -s check:agent
Saída JSON esperada (exemplo):
{"ok":true,"status":200}
Passo a passo recomendado para ligar a integração real com mTLS:
- Pré-checagens
npm run -s check:pfx→ confirmethumbprintedaysToExpire> 0npm run -s check:agentcomAGENT_BASE_URLe PFX (se o agente exigir mTLS)
- Subir o serviço apontando para o agente (pode ser em memória):
$env:AGENT_BASE_URL="https://seu-agente..."
$env:CERT_PFX_PATH="C:\caminho\seu.pfx"
$env:CERT_PFX_PASSWORD="senha"
npm run dev:win:start
- Emissão real (via CLI ou cliente TS)
- CLI:
npm run -s cli -- emit --body examples/emit.json --idem idem-$(Get-Date -Format yyyyMMddHHmmss) --token (npm run -s token | ConvertFrom-Json).token --pretty - Cliente TS: use
createNfseCliente os métodosemit/get/list/...com timeouts/retries eonResponsepara logarx-correlation-id.
- Troubleshooting (erros comuns)
- "CERT_PFX_PATH not configured": defina as variáveis e valide com
check:pfx. - "certificate unknown/SELF_SIGNED_CERT_IN_CHAIN": verifique cadeia/intermediários no agente ou ajuste trust store conforme política da sua infra.
- TLS protocol/version: requer TLS 1.2+ (ajustado no
https.Agent). - SNI/Hostname mismatch: confirme que o
AGENT_BASE_URLcorresponde ao CN/SAN do certificado do servidor. - Timeout/conexão reset: ajuste
retryedefaultTimeoutMsno cliente; verifique firewall/proxy.
Cliente TypeScript (Node 18+)
import { createNfseClient } from './src/client';
const client = createNfseClient({
baseUrl: 'http://127.0.0.1:3000',
token: process.env.JWT_TOKEN // ou use getToken()
});
const emitted = await client.emit({
rpsSeries: 'A',
issueDate: new Date().toISOString(),
serviceCode: '101',
serviceDescription: 'Teste',
serviceAmount: 100,
taxRate: 0.02,
issRetained: false,
provider: { cnpj: '11111111000191' },
customer: { name: 'Cliente Teste', cpf: '12345678909' }
}, { idempotencyKey: 'demo-' + Date.now() });
const got = await client.get(emitted.id);
const list = await client.list({ page: 1, pageSize: 10 });
const xml = await client.xml(emitted.id);
const pdf = await client.pdf(emitted.id);
const cancelled = await client.cancel(emitted.id, 'Cancel demo');
Overrides por chamada (timeout/retry/correlation):
// Exemplo: aumentar timeout e tentativas apenas nesta chamada
const got = await client.get('inv_123', {
timeoutMs: 15000,
retry: { retries: 3, minDelayMs: 500 },
correlationId: 'get-inv-123'
});
Capturar headers/resposta (ex.: correlation-id) com callback:
const client = createNfseClient({
baseUrl: 'http://127.0.0.1:3000',
getToken: () => process.env.JWT_TOKEN!,
onResponse: ({ status, headers, correlationId, url }) => {
console.log('HTTP', status, 'cid=', correlationId, 'url=', url);
}
});
// Ou apenas nesta chamada
await client.emit(payload, {
idempotencyKey: 'idem-1',
onResponse: (meta) => console.log('emit meta:', meta)
});
Opções avançadas do cliente (timeouts, retries, correlation-id):
const client = createNfseClient({
baseUrl: 'http://127.0.0.1:3000',
getToken: () => process.env.JWT_TOKEN!,
// Timeout por requisição (ms). Padrão: 10000
defaultTimeoutMs: 10000,
// Política de retry para erros transitórios
retry: {
retries: 2, // tentativas adicionais (total = 1 + retries)
minDelayMs: 300, // backoff inicial
maxDelayMs: 2000, // teto do backoff
backoffFactor: 2, // multiplicador do delay
// opcional: personalizar quando fazer retry
// retryOn: (status) => status === 429 || (status >= 500 && status < 600)
},
// Correlation-id por request (aparece nos logs do servidor)
correlationId: () => `cli-${Date.now().toString(16)}-${Math.random().toString(16).slice(2,10)}`,
});
Demo do cliente:
npm run client:demo
Verificar assinatura XML (arquivo ou base64):
# De um arquivo XML assinado
npm run -s verify:xml -- --file examples/rps-sample.xml
# A partir de base64 (ex.: xml.b64)
$b64 = Get-Content xml.b64 -Raw
npm run -s verify:xml -- --b64 $b64
- As rotas
GET /nfse/:id,GET /nfse/:id/pdf,GET /nfse/:id/xmleGET /nfseexigem o cabeçalhoAuthorization: Bearer <token>. - Exemplo de requisição (PowerShell):
curl -s "http://localhost:3000/nfse/123" -H "Authorization: Bearer $env:JWT_TOKEN"
Dica: defina a variável
JWT_TOKENno ambiente antes de chamar.
Requer autenticação Bearer (JWT) e suporta idempotência via cabeçalho opcional.
- Autenticação:
Authorization: Bearer <token> - Idempotência (opcional): cabeçalho
idempotency-key: <string>
Corpo da requisição (JSON) — campos principais:
rpsNumber?: string— número do RPS (se ausente, o serviço auto-numera sequencialmente porprovider.cnpj+rpsSeries).rpsSeries: stringissueDate: string(ISO-8601)serviceCode: stringserviceDescription: stringserviceAmount: numbertaxRate: number(fração decimal: 0.02 = 2%)issRetained: booleancnae?: stringdeductionsAmount?: numberprovider: { cnpj: string }customer: { name: string; cpf?: string; cnpj?: string; email?: string }additionalInfo?: string
Respostas:
- 202 PENDING — quando o agente retorna processamento assíncrono. Exemplo:
{ id, status: "PENDING" }. - 200 SUCCESS — quando autorizado e com número retornado. Ex.:
{ id, status: "SUCCESS", nfseNumber: "2025" }. - 401 Unauthorized — sem token válido.
- 422 Unprocessable Entity — falha de normalização/validação do payload.
- 409 Conflict — conflito de idempotência (reserva para cenários de chave reutilizada com payload divergente).
Idempotência:
- Use o cabeçalho
idempotency-keypara garantir que reenvios retornem o mesmoid/resultado, evitando duplicações. - Com a mesma chave, o serviço retorna o estado atual da fatura original (pode ser
PENDINGouSUCCESS).
Exemplo (PowerShell) com arquivo JSON:
- Crie um arquivo
emit.jsoncom o payload mínimo válido:
{
"rpsNumber": "1",
"rpsSeries": "A",
"issueDate": "2025-09-16T10:00:00.000Z",
"serviceCode": "101",
"serviceDescription": "Serviço de informática",
"serviceAmount": 150.5,
"taxRate": 0.02,
"issRetained": false,
"provider": { "cnpj": "12345678000199" },
"customer": { "cnpj": "99887766000155", "name": "Cliente Exemplo" }
}
- Envie a requisição com JWT e, opcionalmente, a chave de idempotência:
curl -s -X POST "http://localhost:3000/nfse/emitir" `
-H "Authorization: Bearer $env:JWT_TOKEN" `
-H "Content-Type: application/json" `
-H "idempotency-key: idem-123" `
--data "@emit.json"
Notas:
- Se o agente responder
PENDING, o HTTP será 202 e o corpo conterá{ id, status: "PENDING" }. - Se o agente responder
SUCCESS, o HTTP será 200 e o corpo incluiránfseNumber.
Exemplo de erro 409 (conflito de idempotência):
{
"error": {
"code": "IDEMPOTENCY_CONFLICT",
"message": "Same idempotency-key used with a different payload"
}
}
Pré-requisitos:
- Docker Desktop (para banco local opcional)
- Node.js 20.x e npm
- Subir Postgres via Docker (opcional, se não tiver banco):
docker run --name nfse-postgres -e POSTGRES_USER=nfse -e POSTGRES_PASSWORD=nfse -e POSTGRES_DB=nfse -p 5432:5432 -d postgres:16-alpine
- Aplicar migrações do Prisma (gera também o Prisma Client):
npm install
npm run prisma:migrate
- Iniciar o servidor em modo desenvolvimento (com variáveis padrão):
npm run dev:local
Observações:
- O modo desenvolvimento expõe um endpoint
POST /auth/tokenpara obter um token JWT temporário. - Se a variável
AGENT_BASE_URLNÃO estiver definida, o cliente de agente roda em modo stub, permitindo testes end-to-end locais sem integrações externas.
- Smoke test automático (opcional):
./scripts/smoke.ps1 -BaseUrl "http://localhost:3000"
# ou
npm run smoke
Esse script realiza: emissão (stub), listagem (com filtro nfseNumber quando disponível), download de XML/PDF e cancelamento.
- Testes e build:
npm test
npm run build
Foi adicionada a coluna payloadHash à tabela IdempotencyKey. Após atualizar o código, aplique a migração:
npm run prisma:migrate
Em produção, use:
npx prisma migrate deploy