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.
Requires
- php: ^8.2
- ext-dom: *
- ext-json: *
- ext-libxml: *
- ext-openssl: *
- ext-simplexml: *
- ext-zlib: *
- illuminate/bus: ^12.0|^13.0
- illuminate/console: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/queue: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- robrichards/xmlseclibs: ^3.1
Requires (Dev)
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^10.5|^11.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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.
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
- Como funciona
- Requisitos
- Instalação
- Configuração
- Uso
- Comandos
- Tratamento de erros
- Rejeições cobertas
- Referência da API
- Esquemas XSD
- Antes de ir para produção
- Testes
- Roadmap
- Contribuindo
- Segurança
- Licença
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-certificadopassa, e o titular confere comNFSE_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_SNconfirmado com a contabilidade (alíquota efetiva vigente) -
NFSE_EMITENTE_ENVIAR_IMsó ligado depois que o município registrar o contribuinte no CNC -
NFSE_CODIGOS_PRAZO_EXPIRADOpreenchido com o que você observou em produção restrita - Worker da fila
nfserodando, 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:
composer testpassando;composer formataplicado;- 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.gitignorejá cobre*.pfx,*.p12,*.peme*.key. - Os PEMs derivados para o mTLS são gravados com permissão
0600num diretório0700. - 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