Search by

noclick11 / click-nfse

Emissão de NFS-e no padrão nacional (Sistema Nacional NFS-e / Sefin Nacional) para aplicações Laravel: DPS assinada em XMLDSIG, envio com mTLS, cancelamento, análise fiscal e dados do DANFSe.

Maintainers

Package info

github.com/NoClick11/click-nfse

pkg:composer/noclick11/click-nfse

Transparency log

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-09-07 17:09 UTC

This package is auto-updated.

Last update: 2026-09-07 17:21:19 UTC


README

Emissão de NFS-e no padrão nacional para Laravel.
DPS assinada em XMLDSIG, envio com mTLS à Sefin Nacional, cancelamento, análise fiscal e dados do DANFSe.

testes PHP 8.2+ Laravel 12 | 13 Leiaute 1.01 MIT

Desde 1º/09/2026 a Resolução CGSN nº 189/2026 obriga ME e EPP do Simples Nacional a emitir NFS-e pelo Emissor Nacional. Integrar-se a ele é menos sobre HTTP e mais sobre detalhe fiscal: a DPS precisa sair na ordem exata do xs:sequence, assinada com parâmetros que não se negociam, com campos que são obrigatórios num regime tributário e proibidos em outro — e cada erro só aparece depois do envio, com o número da DPS já queimado.

Esta lib empacota esse conhecimento. O núcleo veio de um sistema em produção e foi extraído com as regras que ele aprendeu na prática, cada uma comentada com o código de rejeição que a motivou.

Warning

Pré-lançamento. O núcleo fiscal é rodado em produção, mas a API de integração ainda está sendo definida e pode mudar antes do 1.0. Emissão de documento fiscal tem efeito legal e quase sempre irreversível: homologue em produção restrita e valide com a sua contabilidade antes de habilitar.

Sumário

Recursos

DPS no leiaute v1.01 Montada com DOMDocument, na ordem do xs:sequence, com escaping a cargo do DOM
Assinatura XMLDSIG C14N inclusiva, RSA-SHA1, EndCertOnly, prefixo vazio — os parâmetros do Manual Integrado
Transporte mTLS Certificado A1 convertido para PEM sob demanda, GZip + base64 no corpo JSON
Numeração transacional nDPS reservado por ambiente e série, com avanço automático quando a Sefin já consumiu o número
Reconciliação Consulta a Sefin pelo id da DPS antes de reenviar — nunca emite o mesmo documento duas vezes
Cancelamento com escalonamento Evento 101101 e, se o prazo expirou, solicitação de análise fiscal (101103) com acompanhamento do desfecho
Nota INAPTA O que não pode ser emitido vira registro visível com o motivo, em vez de sumir
Dados do DANFSe XML autorizado achatado e formatado para impressão, sem impor motor de PDF
Diagnóstico do certificado Validade, titular e EKU de autenticação cliente, com alerta de expiração
Desacoplado do seu domínio Dois contratos e uma relação polimórfica: a lib não conhece as suas tabelas

Como funciona

flowchart TD
    A[Faturável confirmado] --> B{Elegível?}
    B -- não --> I[Nota INAPTA<br/>com o motivo]
    I -. cadastro corrigido .-> J[emitirInapta]
    J --> C
    B -- sim --> C[Reserva nDPS<br/>e persiste a nota]
    C --> D[Monta a DPS<br/>e assina]
    D --> E[POST à Sefin<br/>GZip + base64, mTLS]
    E -- 2xx --> F[AUTORIZADA<br/>chave + XML guardados]
    E -- E0014 --> C
    E -- rejeição --> G[REJEITADA<br/>erros gravados]
    E -- falha técnica --> H[ERRO<br/>retry com backoff]
    H -. reconciliação .-> K{Já existe na Sefin?}
    K -- sim --> F
    K -- não --> C
Loading

O ponto central: o número da DPS é reservado e a nota é persistida antes de qualquer chamada HTTP. A regra E0014 proíbe reutilizar um número, mas a legislação admite lacunas na sequência — então, se o envio falhar, a retentativa reaproveita o mesmo número em vez de queimar outro. Gerar número novo a cada tentativa arriscaria emitir duas notas para a mesma cobrança quando a Sefin processou o documento e apenas a resposta se perdeu.

Requisitos

PHP 8.2 ou superior, com dom, json, libxml, openssl, simplexml, zlib
Laravel 12 ou 13
Certificado ICP-Brasil A1 (e-CNPJ) em PKCS#12, com EKU "Autenticação Cliente"
Município Aderente ao Emissor Nacional — verificável pela consulta de convênio embutida

Note

Laravel 11 ficou fora do range: todas as versões 11.x estão sob um aviso de segurança sem correção publicada, e o próprio Composer se recusa a instalá-las por padrão.

Instalação

composer require noclick11/click-nfse

php artisan vendor:publish --tag=nfse-config
php artisan vendor:publish --tag=nfse-migrations
php artisan migrate

Coloque o .pfx fora do diretório público e fora do versionamento:

mkdir -p storage/app/private/certificados
mv ~/Downloads/e-cnpj.pfx storage/app/private/certificados/nfse.pfx

Confira a instalação antes de qualquer emissão:

php artisan nfse:verificar-certificado

Configuração

O mínimo para começar:

NFSE_AMBIENTE=2                         # 1 = produção, 2 = homologação (produção restrita)
NFSE_HABILITADA=false                   # kill-switch da emissão automática
NFSE_CERT_PATH=storage/app/private/certificados/nfse.pfx
NFSE_CERT_SENHA=
NFSE_EMITENTE_CNPJ=
NFSE_EMITENTE_COD_MUNICIPIO=            # código IBGE, 7 dígitos
NFSE_SERIE=00001
Referência completa das variáveis

Ambiente e emissão

Variável Padrão Descrição
NFSE_AMBIENTE 2 1 = produção, 2 = produção restrita
NFSE_HABILITADA false Enquanto false, nada é emitido automaticamente
NFSE_SERIE 00001 Série da DPS, 5 posições
NFSE_VER_APLIC nfse-nacional-php Identificação do sistema emissor (verAplic)
NFSE_TIMEZONE America/Bahia Fuso em que dhEmi e dCompet são escritos
NFSE_MARGEM_DH_EMI 60 Segundos de recuo do dhEmi (ver E0008)

Certificado

Variável Padrão Descrição
NFSE_CERT_PATH storage/app/private/certificados/nfse.pfx Absoluto ou relativo à raiz do projeto
NFSE_CERT_SENHA Senha do PKCS#12
NFSE_CERT_ALERTA_DIAS 30 Antecedência do alerta de expiração

Emitente

Variável Descrição
NFSE_EMITENTE_CNPJ CNPJ do prestador
NFSE_EMITENTE_COD_MUNICIPIO Código IBGE do município, 7 dígitos
NFSE_EMITENTE_RAZAO_SOCIAL Razão social
NFSE_EMITENTE_IM Inscrição municipal (exibida no DANFSe)
NFSE_EMITENTE_ENVIAR_IM Deixe false até o município registrar o contribuinte no CNC (ver E0120)
NFSE_EMITENTE_EMAIL, NFSE_EMITENTE_FONE Contato do prestador
NFSE_EMITENTE_LOGRADOURO, _NUMERO, _COMPLEMENTO, _BAIRRO, _CEP, _UF Endereço, usado na impressão

Regime tributário

Variável Padrão Descrição
NFSE_OP_SIMP_NAC 3 1 = não optante, 2 = MEI, 3 = ME/EPP
NFSE_REG_AP_TRIB_SN 1 Obrigatório quando 3; proibido nos demais
NFSE_REG_ESP_TRIB 0 Regime especial municipal
NFSE_TP_RET_ISSQN 1 1 = não retido, 2 = tomador, 3 = intermediário
NFSE_P_ALIQ Alíquota; só enviada quando há retenção
NFSE_P_TOT_TRIB_SN Percentual do Simples (Lei 12.741/2012)

Cancelamento

Variável Descrição
NFSE_CODIGOS_PRAZO_EXPIRADO Lista separada por vírgula dos códigos de rejeição que significam "prazo esgotado". Vazio por padrão: sem ela, o cancelamento não escala sozinho para análise fiscal

Uso

1. Implemente os contratos

A lib não conhece as suas tabelas. Implemente Faturavel no model que representa o que gera a nota — um pagamento, uma cobrança, uma fatura — e a nota guarda uma relação polimórfica com ele:

use Carbon\CarbonInterface;
use ClickNfse\Contracts\Faturavel;
use ClickNfse\Contracts\Tomador;
use Illuminate\Database\Eloquent\Model;

class Pagamento extends Model implements Faturavel
{
    public function nfseValorEmCentavos(): int          { return $this->valor; }
    public function nfseCodigoServico(): ?string        { return '010301'; }   // cTribNac
    public function nfseDescricaoServico(): ?string     { return 'Consultoria em TI'; }
    public function nfseCompetencia(): CarbonInterface  { return $this->pago_em; }
    public function nfseReferenciaExterna(): ?string    { return $this->gateway_id; }
    public function nfseTomador(): ?Tomador             { return $this->cliente; }
    public function nfseEstaPago(): bool                { return $this->status === 'pago'; }
    public function nfseEstaEstornado(): bool           { return (bool) $this->estornado; }
}

E Tomador em quem recebe o serviço — não precisa ser um model:

class Cliente extends Model implements Tomador
{
    public function nfseDocumento(): ?string { return $this->cpf ?? $this->cnpj; }
    public function nfseNome(): ?string      { return $this->nome; }
    public function nfseEmail(): ?string     { return $this->email; }
}

Tudo o que esses métodos devolvem é congelado na nota no momento da preparação: alterar o model depois não altera um documento já emitido, que é o comportamento correto para efeito fiscal.

2. Emita

Na fila, com retry e backoff próprios (fila nfse, 5 tentativas, backoff de 1 min a 3 h, ShouldBeUnique por faturável):

use ClickNfse\Jobs\EmitirNfseJob;

EmitirNfseJob::dispatch($pagamento);

Ou de forma síncrona, quando você quer o desfecho na hora:

use ClickNfse\Services\NfseEmissaoService;

$emissao = app(NfseEmissaoService::class);

$nota = $emissao->prepararNota($pagamento);   // idempotente
$nota = $emissao->emitir($nota);

prepararNota() é idempotente por design: chamadas repetidas para o mesmo faturável devolvem sempre a mesma nota, o que protege contra webhook reentregue.

3. Acompanhe

$nota->status;             // AUTORIZADA, REJEITADA, ...
$nota->chave_acesso;       // 50 dígitos
$nota->numero_nfse;
$nota->erros;              // [{codigo, descricao, complemento}] devolvidos pela Sefin
$nota->nfse_xml;           // XML autorizado — o documento com valor legal
$nota->faturavel;          // de volta ao seu domínio
Status Significado Reemite sozinha?
PENDENTE Número reservado, ainda não enviada
PROCESSANDO Envio em andamento
AUTORIZADA Documento existe na Sefin
REJEITADA Recusada por regra de negócio ❌ (só manual, após correção)
ERRO Falha técnica (rede, 5xx)
CANCELADA Cancelamento concluído
INAPTA Não pôde ser emitida; motivo registrado ❌ (via emitirInapta())

Cancelamento

use ClickNfse\Jobs\CancelarNfseJob;
use ClickNfse\Models\NotaFiscal;

CancelarNfseJob::dispatch($nota, NotaFiscal::MOTIVO_SERVICO_NAO_PRESTADO, 'Estorno do pagamento');

Códigos aceitos pelo schema: MOTIVO_ERRO_EMISSAO (1), MOTIVO_SERVICO_NAO_PRESTADO (2), MOTIVO_OUTROS (9). A justificativa exige 15 caracteres — textos curtos são complementados em vez de derrubar o pedido.

O serviço tenta o evento 101101 e, se o prazo municipal já expirou, escala para solicitação de análise fiscal (101103), agendando o acompanhamento do deferimento (105104) ou indeferimento (105105).

Important

O escalonamento só ocorre para os códigos de rejeição listados em NFSE_CODIGOS_PRAZO_EXPIRADO. A Sefin não publica um código único para esse cenário, e abrir pedido de análise indevido cria trabalho para a fiscalização municipal — por isso o padrão é não escalar.

cancelamento_status Significado
SOLICITADO Evento 101101 enviado
DEFERIDO Cancelamento efetivado
ANALISE_SOLICITADA Pedido 101103 registrado, aguardando a fiscalização
INDEFERIDO A fiscalização recusou — a nota continua válida
ERRO Falha no envio do evento

Quando o estorno ocorre antes de a nota ter sido autorizada, use cancelarLocalmente(): não há documento fiscal a cancelar, mas o número reservado continua queimado.

Nota INAPTA

Quando o faturável não pode gerar nota — tomador sem CPF/CNPJ válido, código de serviço ausente — a lib registra o caso como INAPTA com o motivo, em vez de falhar em silêncio. A nota não reserva número nem gera DPS: é um marcador visível para correção.

$nota->erro_mensagem;  // "Tomador sem CPF/CNPJ válido cadastrado: ..."

// Depois de corrigir o cadastro:
app(NfseEmissaoService::class)->emitirInapta($nota);

A reavaliação reusa o mesmo registro, preservando o histórico e o id já visível ao operador.

DANFSe

DanfseService::dadosParaView() devolve o XML autorizado já achatado e formatado — prestador, tomador, valores, chave, URL para o QR Code. A renderização é sua:

$dados = app(DanfseService::class)->dadosParaView($nota);

return Pdf::loadView('pdfs.danfse', $dados)
    ->download(app(DanfseService::class)->nomeArquivo($nota));

A lib não depende de motor de PDF nem de gerador de QR Code — essa escolha é do seu projeto. Em examples/laravel-app/ há uma view Blade completa e um controller que a imprime com dompdf.

O documento com valor legal é o XML autorizado. O PDF é só uma representação: gere sob demanda, não armazene.

Comandos

Comando Função
nfse:verificar-certificado Valida o A1: legibilidade, validade, titular e autenticação cliente. Aceita --json
nfse:reconciliar Consulta na Sefin o desfecho de notas travadas em PROCESSANDO. Aceita --minutos=15
nfse:acompanhar-analises Verifica se a fiscalização deferiu ou indeferiu os pedidos pendentes
// routes/console.php
Schedule::command('nfse:reconciliar')->everyThirtyMinutes()->withoutOverlapping();
Schedule::command('nfse:acompanhar-analises')->dailyAt('06:00');
Schedule::command('nfse:verificar-certificado')->dailyAt('06:30');

Tratamento de erros

Duas exceções, e a distinção entre elas é o que decide se vale reenviar:

use ClickNfse\Exceptions\NfseException;
use ClickNfse\Exceptions\NfseRejeicaoException;

try {
    $nota = $emissao->emitir($nota);
} catch (NfseRejeicaoException $e) {
    // Regra de negócio da Sefin. Reenviar sem corrigir só repete a rejeição.
    $e->erros;       // [{codigo, descricao, complemento}]
    $e->statusHttp;
} catch (NfseException $e) {
    // Falha técnica ou de configuração. Reprocessável.
}

NfseRejeicaoException estende NfseException, então capturar só a segunda pega as duas — a ordem dos catch importa.

Rejeições cobertas

O que a lib faz para não esbarrar em cada uma. É a parte que custa caro descobrir sozinho:

Código O que significa Como é tratado
E0004 / E1263 Identificador não confere com o corpo DpsIdGenerator monta e valida contra o pattern do schema
E0008 dhEmi no mesmo segundo do processamento Recuo configurável (NFSE_MARGEM_DH_EMI)
E0014 Série + número já utilizados Reserva o número seguinte e reenvia, até 25 avanços — reconciliando antes
E0015 Competência posterior à emissão dCompet e dhEmi derivados do mesmo fuso
E0120 IM do prestador não deve ser informada IM só entra na DPS se NFSE_EMITENTE_ENVIAR_IM=true
E0166 Falta regApTribSN no ME/EPP Enviado quando op_simp_nac = 3, omitido nos demais
E0234 Endereço do tomador exigido Não enviado: a incidência é no estabelecimento do prestador
E0621 / E0625 / E0635 Alíquota obrigatória ou proibida pAliq só é enviada quando há retenção
E0712 indTotTrib recusado no Simples Optante declara pTotTribSN; os demais, indTotTrib
E0714 Assinatura inválida O XML assinado nunca é reserializado nem reindentado
E0824 Cancelamento sem tomador identificado Emissão é segurada (INAPTA) se o documento do tomador não valida
E1235 Falha no esquema do DF-e Id do pedido sem nPedRegEvento; descrições exatamente como enumeradas

Referência da API

Serviços
Classe Papel
NfseEmissaoService prepararNota(), emitir(), reemitir(), emitirInapta(), reconciliar()
NfseCancelamentoService cancelar(), solicitarAnaliseFiscal(), consultarResultadoAnalise(), cancelarLocalmente()
NfseElegibilidadeService deveEmitir(), deveProcessar(), motivoInelegibilidade()
SequenciaDpsService proximoNumero(), ultimoNumero() — reserva transacional do nDPS
DpsXmlBuilder / EventoXmlBuilder Montagem dos documentos
XmlDSigSigner assinar(), verificar()
CertificadoService documentoTitular(), validoAte(), diasParaExpirar(), caminhosMtls()
DanfseService dadosParaView(), nomeArquivo()
ConvenioMunicipalService situacao() — adesão do município ao Emissor Nacional, com cache
ValidadorDocumento identificar(), cpfValido(), cnpjValido()
DpsIdGenerator paraDps(), paraPedidoRegistroEvento(), paraEvento()
GzipBase64Codec codificar(), decodificar()
NfseNacionalGateway Transporte HTTP com mTLS — use os serviços acima, não este diretamente
Jobs
Job Fila Tentativas Backoff
EmitirNfseJob nfse 5 1 min → 3 h
CancelarNfseJob nfse 5 2 min → 3 h
AcompanharAnaliseFiscalNfseJob nfse 3 reconsulta a cada 12 h, por até 30 dias

Os dois primeiros são ShouldBeUnique, o que impede que uma reentrega de webhook enfileire o mesmo documento em paralelo. Passados os 30 dias sem decisão da fiscalização, o acompanhamento é encerrado com um aviso no log — o caso fica para tratamento humano em vez de ocupar a fila indefinidamente.

Esquemas XSD

resources/schemas/nfse/ traz o pacote oficial publicado pela Secretaria-Executiva do CGNFS-e, versões 1.00 e 1.01, sem modificação — inclusive nas quebras de linha. O caminho fica exposto por ClickNfseServiceProvider::caminhoSchemas().

Veja o README daquele diretório para as particularidades, inclusive um defeito conhecido no TSSerieDPS da v1.01 que produz falso positivo na validação local.

Antes de ir para produção

  • php artisan nfse:verificar-certificado passa, e o titular confere com NFSE_EMITENTE_CNPJ
  • O município aparece como aderente ao Emissor Nacional (ConvenioMunicipalService::situacao())
  • Emissão validada em NFSE_AMBIENTE=2 (produção restrita) de ponta a ponta
  • Cancelamento testado — inclusive o caso do prazo expirado
  • NFSE_P_TOT_TRIB_SN confirmado com a contabilidade (alíquota efetiva vigente)
  • NFSE_EMITENTE_ENVIAR_IM só ligado depois que o município registrar o contribuinte no CNC
  • NFSE_CODIGOS_PRAZO_EXPIRADO preenchido com o que você observou em produção restrita
  • Worker da fila nfse rodando, e os três comandos agendados
  • Alerta de expiração do certificado chegando a alguém que age

Testes

composer install
composer test      # phpunit
composer lint      # pint --test
composer format    # pint

A suíte roda sem rede e sem certificado real: a DPS e os eventos são validados contra o pacote oficial de XSD que acompanha o repositório, e a assinatura XMLDSIG é exercitada com um par autoassinado gerado na hora do teste. Nenhum material de certificado é versionado.

O CI cobre PHP 8.2, 8.3 e 8.4 contra Laravel 12 e 13.

Roadmap

  • Validação local contra o XSD antes do envio, ligável por configuração
  • Confirmar o formato da URL do QR Code do DANFSe conforme a NT 008/2026
  • Núcleo em PHP puro, sem Laravel, com o pacote atual como adaptador
  • Eventos de domínio (NotaAutorizada, NotaRejeitada, NotaCancelada)

Contribuindo

Issues e pull requests são bem-vindos. Antes de abrir um PR:

  1. composer test passando;
  2. composer format aplicado;
  3. mudança de comportamento acompanhada de teste.

Este é um pacote fiscal: alterações no caminho de emissão, assinatura ou cancelamento precisam citar a regra ou o código de rejeição que as justifica. "Achei mais limpo" não basta quando o custo de errar é um documento com efeito legal.

Segurança

  • Nunca versione o .pfx — o .gitignore já cobre *.pfx, *.p12, *.pem e *.key.
  • Os PEMs derivados para o mTLS são gravados com permissão 0600 num diretório 0700.
  • Encontrou uma vulnerabilidade? Escreva para manuel.baham.dev@gmail.com em vez de abrir issue pública.

Licença

MIT — veja LICENSE.

O software é fornecido sem garantias. Emissão de documento fiscal produz efeito legal e, em boa parte dos casos, irreversível.

Feito por Manuel Bahamonde