nfcom 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +115 -0
  3. data/.ruby-version +1 -0
  4. data/CHANGELOG.md +66 -0
  5. data/LICENSE +21 -0
  6. data/README.md +280 -0
  7. data/Rakefile +22 -0
  8. data/examples/.env.example +41 -0
  9. data/examples/emitir_nota.rb +91 -0
  10. data/examples/rails_initializer.rb +72 -0
  11. data/lib/nfcom/builder/danfe_com.rb +564 -0
  12. data/lib/nfcom/builder/qrcode.rb +68 -0
  13. data/lib/nfcom/builder/signature.rb +156 -0
  14. data/lib/nfcom/builder/xml_builder.rb +362 -0
  15. data/lib/nfcom/client.rb +106 -0
  16. data/lib/nfcom/configuration.rb +134 -0
  17. data/lib/nfcom/errors.rb +27 -0
  18. data/lib/nfcom/helpers/consulta.rb +28 -0
  19. data/lib/nfcom/models/assinante.rb +146 -0
  20. data/lib/nfcom/models/destinatario.rb +138 -0
  21. data/lib/nfcom/models/emitente.rb +105 -0
  22. data/lib/nfcom/models/endereco.rb +123 -0
  23. data/lib/nfcom/models/fatura/codigo_de_barras/formato_44.rb +52 -0
  24. data/lib/nfcom/models/fatura/codigo_de_barras.rb +57 -0
  25. data/lib/nfcom/models/fatura.rb +172 -0
  26. data/lib/nfcom/models/item.rb +353 -0
  27. data/lib/nfcom/models/nota.rb +398 -0
  28. data/lib/nfcom/models/total.rb +60 -0
  29. data/lib/nfcom/parsers/autorizacao.rb +28 -0
  30. data/lib/nfcom/parsers/base.rb +30 -0
  31. data/lib/nfcom/parsers/consulta.rb +34 -0
  32. data/lib/nfcom/parsers/inutilizacao.rb +23 -0
  33. data/lib/nfcom/parsers/status.rb +23 -0
  34. data/lib/nfcom/utils/certificate.rb +109 -0
  35. data/lib/nfcom/utils/compressor.rb +47 -0
  36. data/lib/nfcom/utils/helpers.rb +141 -0
  37. data/lib/nfcom/utils/response_decompressor.rb +47 -0
  38. data/lib/nfcom/utils/xml_authorized.rb +29 -0
  39. data/lib/nfcom/utils/xml_cleaner.rb +68 -0
  40. data/lib/nfcom/validators/business_rules.rb +45 -0
  41. data/lib/nfcom/validators/schema_validator.rb +316 -0
  42. data/lib/nfcom/validators/xml_validator.rb +29 -0
  43. data/lib/nfcom/version.rb +5 -0
  44. data/lib/nfcom/webservices/autorizacao.rb +36 -0
  45. data/lib/nfcom/webservices/base.rb +96 -0
  46. data/lib/nfcom/webservices/consulta.rb +59 -0
  47. data/lib/nfcom/webservices/inutilizacao.rb +71 -0
  48. data/lib/nfcom/webservices/status.rb +64 -0
  49. data/lib/nfcom.rb +98 -0
  50. data/nfcom.gemspec +42 -0
  51. metadata +242 -0
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ module Nfcom
6
+ module Utils
7
+ class Certificate
8
+ attr_reader :cert, :key
9
+
10
+ def initialize(path, password = nil)
11
+ @path = path
12
+ @password = password
13
+ carregar_certificado
14
+ end
15
+
16
+ def valido?
17
+ return false if @cert.nil? || @key.nil?
18
+ return false if expirado?
19
+
20
+ true
21
+ end
22
+
23
+ def expirado?
24
+ @cert.not_after < Time.now
25
+ end
26
+
27
+ def dias_para_vencer
28
+ return 0 if expirado?
29
+
30
+ ((@cert.not_after - Time.now) / 86_400).to_i
31
+ end
32
+
33
+ def cnpj
34
+ # Extrai CNPJ do subject do certificado
35
+ # Formato pode ser: CN=12345678000100 ou CN=EMPRESA:12345678000100
36
+ subject = @cert.subject.to_s
37
+
38
+ # Tenta formato direto: CN=12345678000100
39
+ match = subject.match(/CN=(\d{14})/)
40
+ return match[1] if match
41
+
42
+ # Tenta formato com nome: CN=EMPRESA:12345678000100
43
+ match = subject.match(/CN=[^:]+:(\d{14})/)
44
+ return match[1] if match
45
+
46
+ # Tenta buscar CNPJ em qualquer lugar do subject
47
+ match = subject.match(/(\d{14})/)
48
+ return match[1] if match
49
+
50
+ nil
51
+ end
52
+
53
+ def to_pem
54
+ {
55
+ cert: @cert.to_pem,
56
+ key: @key.to_pem
57
+ }
58
+ end
59
+
60
+ private
61
+
62
+ def carregar_certificado
63
+ raise Errors::CertificateError, "Arquivo de certificado não encontrado: #{@path}" unless File.exist?(@path)
64
+
65
+ begin
66
+ conteudo = File.read(@path)
67
+
68
+ # Tenta carregar como PKCS12 (.pfx)
69
+ if @path.end_with?('.pfx', '.p12')
70
+ pkcs12 = OpenSSL::PKCS12.new(conteudo, @password)
71
+ @cert = pkcs12.certificate
72
+ @key = pkcs12.key
73
+ else
74
+ # Tenta carregar como PEM
75
+ @cert = OpenSSL::X509::Certificate.new(conteudo)
76
+ @key = OpenSSL::PKey::RSA.new(conteudo, @password)
77
+ end
78
+
79
+ validar_certificado
80
+ rescue OpenSSL::PKCS12::PKCS12Error => e
81
+ raise Errors::CertificateError, "Erro ao carregar certificado PKCS12. Senha incorreta? #{e.message}"
82
+ rescue OpenSSL::X509::CertificateError => e
83
+ raise Errors::CertificateError, "Erro ao carregar certificado: #{e.message}"
84
+ rescue OpenSSL::PKey::RSAError => e
85
+ raise Errors::CertificateError, "Erro ao carregar chave privada: #{e.message}"
86
+ end
87
+ end
88
+
89
+ def validar_certificado
90
+ # Verifica se o certificado tem os componentes necessários
91
+ raise Errors::CertificateError, 'Certificado inválido: faltando certificado' if @cert.nil?
92
+ raise Errors::CertificateError, 'Certificado inválido: faltando chave privada' if @key.nil?
93
+
94
+ # Verifica se está expirado
95
+ raise Errors::CertificateError, 'Certificado está expirado' if expirado?
96
+
97
+ # Verifica se a chave privada corresponde ao certificado
98
+ unless @cert.check_private_key(@key)
99
+ raise Errors::CertificateError, 'Chave privada não corresponde ao certificado'
100
+ end
101
+
102
+ # Aviso de vencimento próximo
103
+ return unless dias_para_vencer <= 30
104
+
105
+ warn "ATENÇÃO: Certificado vence em #{dias_para_vencer} dias!"
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stringio'
4
+ require 'zlib'
5
+ require 'base64'
6
+
7
+ module Nfcom
8
+ module Utils
9
+ # Compressor/Decompressor para XML de NFCom
10
+ #
11
+ # Responsável por compactar e descompactar XMLs usando GZIP com
12
+ # nível máximo de compressão, conforme exigido pela SEFAZ.
13
+ class Compressor
14
+ # Compacta XML e retorna Base64
15
+ #
16
+ # @param xml [String] XML a ser compactado
17
+ # @return [String] String Base64 com XML compactado em GZIP
18
+ def self.gzip_base64(xml)
19
+ xml = xml.dup
20
+ xml.sub!("\uFEFF", '') # remove BOM se existir
21
+
22
+ io = StringIO.new
23
+ # Nível 9 = compressão máxima (FORCE_GZIP do PHP)
24
+ gz = Zlib::GzipWriter.new(io, Zlib::BEST_COMPRESSION)
25
+ gz.write(xml)
26
+ gz.close
27
+
28
+ Base64.strict_encode64(io.string)
29
+ end
30
+
31
+ # Descompacta Base64+GZIP e retorna XML
32
+ #
33
+ # @param base64_data [String] String Base64 contendo XML compactado
34
+ # @return [String] XML descompactado
35
+ def self.ungzip_base64(base64_data)
36
+ compressed_data = Base64.strict_decode64(base64_data)
37
+
38
+ io = StringIO.new(compressed_data)
39
+ gz = Zlib::GzipReader.new(io)
40
+ xml = gz.read
41
+ gz.close
42
+
43
+ xml
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nfcom
4
+ module Utils
5
+ module Helpers
6
+ module_function
7
+
8
+ # Formata valor decimal para uso no XML (2 casas decimais)
9
+ def formatar_decimal(valor, casas = 2)
10
+ format("%.#{casas}f", valor.to_f)
11
+ end
12
+
13
+ # Converte String ou Date para Date de forma segura (sem depender do Rails)
14
+ # @param value [String, Date, Time, DateTime, nil]
15
+ # @return [Date, nil]
16
+ def safe_to_date(value)
17
+ return nil if value.nil?
18
+ return value if value.is_a?(Date)
19
+ return value.to_date if value.respond_to?(:to_date) && !value.is_a?(String)
20
+
21
+ Date.parse(value.to_s)
22
+ rescue ArgumentError
23
+ nil
24
+ end
25
+
26
+ # Formata data/hora para padrão ISO 8601
27
+ def formatar_data_hora(datetime)
28
+ datetime.strftime('%Y-%m-%dT%H:%M:%S%:z')
29
+ end
30
+
31
+ # Formata data para padrão AAAA-MM-DD
32
+ def formatar_data(date)
33
+ date = safe_to_date(date)
34
+ return nil unless date
35
+
36
+ date.strftime('%Y-%m-%d')
37
+ end
38
+
39
+ # Remove caracteres não numéricos
40
+ def apenas_numeros(texto)
41
+ texto.to_s.gsub(/\D/, '')
42
+ end
43
+
44
+ # Limita texto ao tamanho máximo
45
+ def limitar_texto(texto, tamanho_max)
46
+ texto = texto.to_s.strip
47
+ texto = texto[0...tamanho_max].strip if texto.length > tamanho_max
48
+ texto
49
+ end
50
+
51
+ # Remove acentos e caracteres especiais
52
+ def remover_acentos(texto)
53
+ texto.to_s
54
+ .unicode_normalize(:nfkd)
55
+ .encode('ASCII', invalid: :replace, undef: :replace, replace: '')
56
+ .gsub(/[^\w\s-]/, '')
57
+ end
58
+
59
+ # Valida se uma string está vazia
60
+ def vazio?(texto)
61
+ texto.to_s.strip.empty?
62
+ end
63
+
64
+ # Formata CNPJ
65
+ def formatar_cnpj(cnpj)
66
+ numeros = apenas_numeros(cnpj)
67
+ return cnpj if numeros.length != 14
68
+
69
+ "#{numeros[0..1]}.#{numeros[2..4]}.#{numeros[5..7]}/#{numeros[8..11]}-#{numeros[12..13]}"
70
+ end
71
+
72
+ # Formata CPF
73
+ def formatar_cpf(cpf)
74
+ numeros = apenas_numeros(cpf)
75
+ return cpf if numeros.length != 11
76
+
77
+ "#{numeros[0..2]}.#{numeros[3..5]}.#{numeros[6..8]}-#{numeros[9..10]}"
78
+ end
79
+
80
+ # Formata CEP
81
+ def formatar_cep(cep)
82
+ numeros = apenas_numeros(cep)
83
+ return cep if numeros.length != 8
84
+
85
+ "#{numeros[0..4]}-#{numeros[5..7]}"
86
+ end
87
+
88
+ # Gera ID único para elementos XML
89
+ def gerar_id(prefixo = 'ID')
90
+ "#{prefixo}#{Time.now.to_i}#{SecureRandom.hex(4)}"
91
+ end
92
+
93
+ # Valida CNPJ
94
+ # @param cnpj [String] CNPJ com ou sem formatação
95
+ # @return [Boolean] true se válido, false caso contrário
96
+ def cnpj_valido?(cnpj) # rubocop:disable Metrics/AbcSize
97
+ return false if cnpj.nil?
98
+
99
+ cnpj_limpo = apenas_numeros(cnpj)
100
+ return false if cnpj_limpo.length != 14
101
+ return false if cnpj_limpo.chars.uniq.length == 1 # Todos dígitos iguais
102
+
103
+ calc_digito = lambda do |numeros|
104
+ multiplicadores = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]
105
+ soma = numeros.chars.each_with_index.sum { |d, i| d.to_i * multiplicadores[i + (13 - numeros.length)] }
106
+ resto = soma % 11
107
+ resto < 2 ? 0 : 11 - resto
108
+ end
109
+
110
+ base = cnpj_limpo[0..11]
111
+ digito1 = calc_digito.call(base)
112
+ digito2 = calc_digito.call(base + digito1.to_s)
113
+
114
+ cnpj_limpo[-2].to_i == digito1 && cnpj_limpo[-1].to_i == digito2
115
+ end
116
+
117
+ # Valida CPF
118
+ # @param cpf [String] CPF com ou sem formatação
119
+ # @return [Boolean] true se válido, false caso contrário
120
+ def cpf_valido?(cpf) # rubocop:disable Metrics/AbcSize
121
+ return false if cpf.nil?
122
+
123
+ cpf_limpo = apenas_numeros(cpf)
124
+ return false if cpf_limpo.length != 11
125
+ return false if cpf_limpo.chars.uniq.length == 1 # Todos dígitos iguais
126
+
127
+ calc_digito = lambda do |numeros, peso_inicial|
128
+ soma = numeros.chars.each_with_index.sum { |d, i| d.to_i * (peso_inicial - i) }
129
+ resto = soma % 11
130
+ resto < 2 ? 0 : 11 - resto
131
+ end
132
+
133
+ base = cpf_limpo[0..8]
134
+ digito1 = calc_digito.call(base, 10)
135
+ digito2 = calc_digito.call(base + digito1.to_s, 11)
136
+
137
+ cpf_limpo[-2].to_i == digito1 && cpf_limpo[-1].to_i == digito2
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nokogiri'
4
+
5
+ module Nfcom
6
+ module Utils
7
+ # Descompressor de respostas da SEFAZ
8
+ #
9
+ # Processa respostas SOAP da SEFAZ, extraindo e descompactando
10
+ # o XML de resposta quando necessário.
11
+ class ResponseDecompressor
12
+ # Extrai e descompacta resposta da SEFAZ
13
+ #
14
+ # @param soap_response [Nokogiri::XML::Document] Documento SOAP da resposta
15
+ # @return [Nokogiri::XML::Document] Documento XML descompactado
16
+ # @raise [Errors::SefazError] Se houver erro na resposta ou faltar nfcomResultMsg
17
+ def self.extract_and_decompress(soap_response)
18
+ doc = soap_response.dup
19
+ doc.remove_namespaces!
20
+
21
+ # Verificar se tem Fault
22
+ if (fault = doc.at_xpath('//Fault'))
23
+ error_msg = fault.at_xpath('.//Text')&.text || 'Erro desconhecido'
24
+ raise Errors::SefazError, "Erro SOAP: #{error_msg}"
25
+ end
26
+
27
+ # Extrair nfcomResultMsg
28
+ result_msg_node = doc.at_xpath('//nfcomResultMsg')
29
+ raise Errors::SefazError, 'Resposta SOAP não contém nfcomResultMsg' unless result_msg_node
30
+
31
+ # Verificar se tem retNFCom direto (resposta não comprimida - geralmente erros)
32
+ ret_nfcom_direto = result_msg_node.at_xpath('.//retNFCom')
33
+
34
+ xml_descomprimido = if ret_nfcom_direto
35
+ # Resposta NÃO está comprimida (erro de processamento)
36
+ result_msg_node.to_xml
37
+ else
38
+ # Resposta está comprimida (normal)
39
+ base64_comprimido = result_msg_node.text.strip
40
+ Compressor.ungzip_base64(base64_comprimido)
41
+ end
42
+
43
+ Nokogiri::XML(xml_descomprimido)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nfcom
4
+ module Utils
5
+ class XmlAuthorized
6
+ NFCOM_NAMESPACE = 'http://www.portalfiscal.inf.br/nfcom'
7
+
8
+ def self.build_nfcom_proc(xml_assinado:, xml_protocolo:)
9
+ nfcom_doc = Nokogiri::XML(xml_assinado, &:strict)
10
+ prot_doc = Nokogiri::XML(xml_protocolo, &:strict)
11
+
12
+ nfcom_node = nfcom_doc.at_xpath('/*[local-name()="NFCom"]')
13
+ prot_node = prot_doc.at_xpath('/*[local-name()="protNFCom"]')
14
+
15
+ raise Errors::XmlError, 'NFCom não encontrada no XML assinado' unless nfcom_node
16
+ raise Errors::XmlError, 'protNFCom não encontrada no XML de protocolo' unless prot_node
17
+
18
+ builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
19
+ xml.nfcomProc(xmlns: NFCOM_NAMESPACE, versao: '1.00') do
20
+ xml << nfcom_node.to_xml
21
+ xml << prot_node.to_xml
22
+ end
23
+ end
24
+
25
+ builder.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nfcom
4
+ module Utils
5
+ # Limpeza de XML para envio à SEFAZ
6
+ #
7
+ # Remove caracteres de controle, BOMs, declarações XML e formatação
8
+ # desnecessária do XML antes do envio para a SEFAZ.
9
+ class XmlCleaner
10
+ class << self
11
+ # Limpa XML removendo BOMs, declarações e formatação
12
+ #
13
+ # @param xml [String] XML a ser limpo
14
+ # @return [String] XML limpo e pronto para envio
15
+ def clean(xml) # rubocop:disable Metrics/AbcSize
16
+ xml = xml.dup
17
+
18
+ # Trabalha com encoding binário primeiro para remover BOMs
19
+ xml.force_encoding('BINARY')
20
+
21
+ # Remove BOM (Byte Order Mark) - UTF-8
22
+ bom_utf8 = "\xEF\xBB\xBF".dup.force_encoding('BINARY')
23
+ xml.sub!(bom_utf8, '') if xml.start_with?(bom_utf8)
24
+
25
+ # Remove BOM variants (UTF-16)
26
+ bom_utf16_le = "\xFF\xFE".dup.force_encoding('BINARY')
27
+ xml.sub!(bom_utf16_le, '') if xml.start_with?(bom_utf16_le)
28
+
29
+ bom_utf16_be = "\xFE\xFF".dup.force_encoding('BINARY')
30
+ xml.sub!(bom_utf16_be, '') if xml.start_with?(bom_utf16_be)
31
+
32
+ # Agora converte para UTF-8
33
+ xml.force_encoding('UTF-8')
34
+
35
+ # Remove declaração XML e espaços após ela
36
+ xml.sub!(/\A<\?xml[^?]*\?>\s*/, '')
37
+
38
+ # Remove carriage returns (Windows line endings)
39
+ xml.gsub!("\r", '')
40
+
41
+ # Remove espaços/tabs no início de cada linha (multiline mode)
42
+ xml.gsub!(/^[ \t]+/m, '')
43
+
44
+ # Remove espaços/tabs no fim de cada linha (multiline mode)
45
+ xml.gsub!(/[ \t]+$/m, '')
46
+
47
+ # Remove múltiplas linhas vazias, deixando apenas uma
48
+ xml.gsub!(/\n\n+/, "\n")
49
+
50
+ # Remove espaços entre tags (> <) mas mantém conteúdo
51
+ xml.gsub!(/>\s+</, '><')
52
+
53
+ # Trim geral (remove espaços início/fim)
54
+ xml.strip!
55
+
56
+ # Remove qualquer caracter de controle invisível (exceto \n)
57
+ control_chars_str = (0x00..0x08).map(&:chr).join +
58
+ (0x0B..0x0C).map(&:chr).join +
59
+ (0x0E..0x1F).map(&:chr).join +
60
+ 0x7F.chr
61
+ xml.delete!(control_chars_str)
62
+
63
+ xml
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nfcom
4
+ module Validators
5
+ class BusinessRules
6
+ def self.validar(nota) # rubocop:disable Metrics/AbcSize
7
+ erros = []
8
+
9
+ # Valida valores
10
+ erros << 'Valor total da nota não pode ser zero' if nota.total.valor_total.to_f <= 0
11
+
12
+ # Valida soma dos itens
13
+ soma_itens = nota.itens.sum { |i| i.valor_total.to_f }
14
+ if (soma_itens - nota.total.valor_servicos.to_f).abs > 0.01
15
+ erros << "Soma dos itens (#{soma_itens}) não confere com total de serviços (#{nota.total.valor_servicos})"
16
+ end
17
+
18
+ nota.itens.each do |item|
19
+ # Valida códigos de serviço para provedor de internet
20
+ unless codigo_servico_valido?(item.codigo_servico)
21
+ erros << "Código de serviço #{item.codigo_servico} inválido para item #{item.numero_item}"
22
+ end
23
+ # Valida CFOP
24
+ erros << "CFOP #{item.cfop} inválido para item #{item.numero_item}" unless cfop_valido?(item.cfop)
25
+ end
26
+
27
+ erros
28
+ end
29
+
30
+ def self.codigo_servico_valido?(codigo)
31
+ # Códigos válidos para telecomunicações/internet
32
+ validos = %w[0303 0304 0305]
33
+ validos.include?(codigo.to_s)
34
+ end
35
+
36
+ def self.cfop_valido?(cfop)
37
+ # CFOPs comuns para serviços de comunicação
38
+ # 5300-5399: Prestações de serviços dentro do estado
39
+ # 6300-6399: Prestações de serviços fora do estado
40
+ cfop_num = cfop.to_s.to_i
41
+ cfop_num.between?(5300, 5399) || cfop_num.between?(6300, 6399)
42
+ end
43
+ end
44
+ end
45
+ end