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.
- checksums.yaml +7 -0
- data/.rubocop.yml +115 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +66 -0
- data/LICENSE +21 -0
- data/README.md +280 -0
- data/Rakefile +22 -0
- data/examples/.env.example +41 -0
- data/examples/emitir_nota.rb +91 -0
- data/examples/rails_initializer.rb +72 -0
- data/lib/nfcom/builder/danfe_com.rb +564 -0
- data/lib/nfcom/builder/qrcode.rb +68 -0
- data/lib/nfcom/builder/signature.rb +156 -0
- data/lib/nfcom/builder/xml_builder.rb +362 -0
- data/lib/nfcom/client.rb +106 -0
- data/lib/nfcom/configuration.rb +134 -0
- data/lib/nfcom/errors.rb +27 -0
- data/lib/nfcom/helpers/consulta.rb +28 -0
- data/lib/nfcom/models/assinante.rb +146 -0
- data/lib/nfcom/models/destinatario.rb +138 -0
- data/lib/nfcom/models/emitente.rb +105 -0
- data/lib/nfcom/models/endereco.rb +123 -0
- data/lib/nfcom/models/fatura/codigo_de_barras/formato_44.rb +52 -0
- data/lib/nfcom/models/fatura/codigo_de_barras.rb +57 -0
- data/lib/nfcom/models/fatura.rb +172 -0
- data/lib/nfcom/models/item.rb +353 -0
- data/lib/nfcom/models/nota.rb +398 -0
- data/lib/nfcom/models/total.rb +60 -0
- data/lib/nfcom/parsers/autorizacao.rb +28 -0
- data/lib/nfcom/parsers/base.rb +30 -0
- data/lib/nfcom/parsers/consulta.rb +34 -0
- data/lib/nfcom/parsers/inutilizacao.rb +23 -0
- data/lib/nfcom/parsers/status.rb +23 -0
- data/lib/nfcom/utils/certificate.rb +109 -0
- data/lib/nfcom/utils/compressor.rb +47 -0
- data/lib/nfcom/utils/helpers.rb +141 -0
- data/lib/nfcom/utils/response_decompressor.rb +47 -0
- data/lib/nfcom/utils/xml_authorized.rb +29 -0
- data/lib/nfcom/utils/xml_cleaner.rb +68 -0
- data/lib/nfcom/validators/business_rules.rb +45 -0
- data/lib/nfcom/validators/schema_validator.rb +316 -0
- data/lib/nfcom/validators/xml_validator.rb +29 -0
- data/lib/nfcom/version.rb +5 -0
- data/lib/nfcom/webservices/autorizacao.rb +36 -0
- data/lib/nfcom/webservices/base.rb +96 -0
- data/lib/nfcom/webservices/consulta.rb +59 -0
- data/lib/nfcom/webservices/inutilizacao.rb +71 -0
- data/lib/nfcom/webservices/status.rb +64 -0
- data/lib/nfcom.rb +98 -0
- data/nfcom.gemspec +42 -0
- 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
|