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,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
require 'rqrcode'
|
|
5
|
+
require_relative '../validators/schema_validator'
|
|
6
|
+
|
|
7
|
+
module Nfcom
|
|
8
|
+
module Builder
|
|
9
|
+
# Gera a URL e SVG do QR Code para consulta da NF-COM.
|
|
10
|
+
#
|
|
11
|
+
# Esta classe cria a URL que será codificada em um QR Code, permitindo que os clientes
|
|
12
|
+
# validem a NF-COM diretamente no portal da SEFAZ.
|
|
13
|
+
#
|
|
14
|
+
# A URL gerada contém os seguintes parâmetros:
|
|
15
|
+
# - chNFCom: Chave de acesso da NF-COM
|
|
16
|
+
# - tpAmb: Código do ambiente (1 = produção, 2 = homologação)
|
|
17
|
+
class Qrcode
|
|
18
|
+
# @return [String] chave de acesso da NF-COM
|
|
19
|
+
attr_reader :chave
|
|
20
|
+
|
|
21
|
+
# @return [Symbol] ambiente :producao ou :homologacao
|
|
22
|
+
attr_reader :ambiente
|
|
23
|
+
|
|
24
|
+
# Inicializa o gerador de QR Code
|
|
25
|
+
#
|
|
26
|
+
# @param chave [String] chave de acesso da NF-COM
|
|
27
|
+
# @param ambiente [Symbol] :producao ou :homologacao
|
|
28
|
+
#
|
|
29
|
+
# @raise [ArgumentError] se algum argumento estiver ausente ou inválido
|
|
30
|
+
def initialize(chave, ambiente)
|
|
31
|
+
raise ArgumentError, 'Chave de acesso não pode ser vazia' if chave.nil? || chave.strip.empty?
|
|
32
|
+
unless Nfcom::Validators::SchemaValidator.valido_por_schema?(chave, :er3)
|
|
33
|
+
raise ArgumentError, "Chave de acesso inválida: #{chave}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
raise ArgumentError, 'Ambiente deve ser :producao ou :homologacao' unless %i[producao
|
|
37
|
+
homologacao].include?(ambiente)
|
|
38
|
+
|
|
39
|
+
@chave = chave
|
|
40
|
+
@ambiente = ambiente
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Gera a URL do QR Code
|
|
44
|
+
#
|
|
45
|
+
# @return [String] URL completa
|
|
46
|
+
def gerar_url
|
|
47
|
+
base_url = 'https://dfe-portal.svrs.rs.gov.br/nfcom/qrcode'
|
|
48
|
+
tp_amb = ambiente == :homologacao ? 2 : 1
|
|
49
|
+
|
|
50
|
+
"#{base_url}?#{URI.encode_www_form(chNFCom: chave, tpAmb: tp_amb)}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Gera a imagem SVG do QR Code
|
|
54
|
+
#
|
|
55
|
+
# @return [String] SVG do QR Code
|
|
56
|
+
def gerar_qrcode_svg
|
|
57
|
+
qr = RQRCode::QRCode.new(gerar_url)
|
|
58
|
+
qr.as_svg(
|
|
59
|
+
offset: 0,
|
|
60
|
+
color: '000',
|
|
61
|
+
shape_rendering: 'crispEdges',
|
|
62
|
+
module_size: 6,
|
|
63
|
+
standalone: true
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'nokogiri'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require 'base64'
|
|
6
|
+
|
|
7
|
+
module Nfcom
|
|
8
|
+
module Builder
|
|
9
|
+
class Signature
|
|
10
|
+
ALGORITHMS = {
|
|
11
|
+
c14n: 'http://www.w3.org/TR/2001/REC-xml-c14n-20010315',
|
|
12
|
+
rsa_sha1: 'http://www.w3.org/2000/09/xmldsig#rsa-sha1',
|
|
13
|
+
sha1: 'http://www.w3.org/2000/09/xmldsig#sha1',
|
|
14
|
+
enveloped: 'http://www.w3.org/2000/09/xmldsig#enveloped-signature'
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
DSIG_NS = 'http://www.w3.org/2000/09/xmldsig#'
|
|
18
|
+
|
|
19
|
+
def initialize(configuration)
|
|
20
|
+
@configuration = configuration
|
|
21
|
+
@certificate = Utils::Certificate.new(
|
|
22
|
+
configuration.certificado_path,
|
|
23
|
+
configuration.certificado_senha
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def assinar(xml_string)
|
|
28
|
+
# Parse XML
|
|
29
|
+
doc = Nokogiri::XML(xml_string)
|
|
30
|
+
|
|
31
|
+
# Find infNFCom element
|
|
32
|
+
inf_nfcom = doc.at_xpath(
|
|
33
|
+
'//nfcom:infNFCom',
|
|
34
|
+
'nfcom' => 'http://www.portalfiscal.inf.br/nfcom'
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
raise Errors::XmlError, 'infNFCom element not found' unless inf_nfcom
|
|
38
|
+
|
|
39
|
+
# Get reference URI
|
|
40
|
+
ref_uri = "##{inf_nfcom['Id']}"
|
|
41
|
+
|
|
42
|
+
# Calculate digest of canonicalized infNFCom
|
|
43
|
+
digest_value = calculate_digest(inf_nfcom)
|
|
44
|
+
|
|
45
|
+
# Build SignedInfo
|
|
46
|
+
signed_info = build_signed_info(ref_uri, digest_value)
|
|
47
|
+
|
|
48
|
+
# Calculate signature of canonicalized SignedInfo
|
|
49
|
+
signature_value = calculate_signature(signed_info)
|
|
50
|
+
|
|
51
|
+
# Build complete Signature element
|
|
52
|
+
signature_xml = build_signature_xml(signed_info, signature_value, certificate_value)
|
|
53
|
+
|
|
54
|
+
# Insert Signature into NFCom
|
|
55
|
+
insert_signature(doc, signature_xml)
|
|
56
|
+
|
|
57
|
+
# Return signed XML
|
|
58
|
+
doc.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def calculate_digest(element)
|
|
64
|
+
# Canonicalize using C14N 1.0
|
|
65
|
+
canonicalized = element.canonicalize(Nokogiri::XML::XML_C14N_1_0)
|
|
66
|
+
|
|
67
|
+
# Calculate SHA1 digest
|
|
68
|
+
digest = OpenSSL::Digest::SHA1.digest(canonicalized)
|
|
69
|
+
|
|
70
|
+
# Return Base64 encoded
|
|
71
|
+
Base64.strict_encode64(digest)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def build_signed_info(ref_uri, digest_value)
|
|
75
|
+
builder = Nokogiri::XML::Builder.new do |xml|
|
|
76
|
+
xml.SignedInfo(xmlns: DSIG_NS) do
|
|
77
|
+
# Use exact algorithm URIs required by NFCom
|
|
78
|
+
xml.CanonicalizationMethod(Algorithm: ALGORITHMS[:c14n])
|
|
79
|
+
xml.SignatureMethod(Algorithm: ALGORITHMS[:rsa_sha1])
|
|
80
|
+
|
|
81
|
+
xml.Reference(URI: ref_uri) do
|
|
82
|
+
xml.Transforms do
|
|
83
|
+
xml.Transform(Algorithm: ALGORITHMS[:enveloped])
|
|
84
|
+
xml.Transform(Algorithm: ALGORITHMS[:c14n])
|
|
85
|
+
end
|
|
86
|
+
xml.DigestMethod(Algorithm: ALGORITHMS[:sha1])
|
|
87
|
+
xml.DigestValue digest_value
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
builder.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def calculate_signature(signed_info_xml)
|
|
96
|
+
# Parse SignedInfo
|
|
97
|
+
signed_info_doc = Nokogiri::XML(signed_info_xml)
|
|
98
|
+
|
|
99
|
+
# Canonicalize using C14N 1.0
|
|
100
|
+
canonicalized = signed_info_doc.canonicalize(Nokogiri::XML::XML_C14N_1_0)
|
|
101
|
+
|
|
102
|
+
# Sign with RSA-SHA1
|
|
103
|
+
signature = @certificate.key.sign(OpenSSL::Digest.new('SHA1'), canonicalized)
|
|
104
|
+
|
|
105
|
+
# Return Base64 encoded
|
|
106
|
+
Base64.strict_encode64(signature)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def certificate_value
|
|
110
|
+
# Get certificate as DER, then Base64 encode
|
|
111
|
+
# Remove PEM headers/footers, just the certificate data
|
|
112
|
+
cert_der = @certificate.cert.to_der
|
|
113
|
+
Base64.strict_encode64(cert_der)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def build_signature_xml(signed_info_xml, signature_value, cert_value)
|
|
117
|
+
# Parse SignedInfo to get the element
|
|
118
|
+
signed_info_doc = Nokogiri::XML(signed_info_xml)
|
|
119
|
+
signed_info_element = signed_info_doc.root
|
|
120
|
+
|
|
121
|
+
builder = Nokogiri::XML::Builder.new do |xml|
|
|
122
|
+
xml.Signature(xmlns: DSIG_NS) do
|
|
123
|
+
# Insert SignedInfo element
|
|
124
|
+
xml.parent << signed_info_element
|
|
125
|
+
|
|
126
|
+
xml.SignatureValue signature_value
|
|
127
|
+
|
|
128
|
+
xml.KeyInfo do
|
|
129
|
+
xml.X509Data do
|
|
130
|
+
xml.X509Certificate cert_value
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
builder.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def insert_signature(doc, signature_xml)
|
|
140
|
+
# Parse signature
|
|
141
|
+
signature_doc = Nokogiri::XML(signature_xml)
|
|
142
|
+
signature_element = signature_doc.root
|
|
143
|
+
|
|
144
|
+
# Find NFCom root element
|
|
145
|
+
nfcom_element = doc.at_xpath(
|
|
146
|
+
'//nfcom:NFCom',
|
|
147
|
+
'nfcom' => 'http://www.portalfiscal.inf.br/nfcom'
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
raise Errors::XmlError, 'NFCom element not found' unless nfcom_element
|
|
151
|
+
|
|
152
|
+
nfcom_element.add_child(signature_element)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'nokogiri'
|
|
4
|
+
|
|
5
|
+
module Nfcom
|
|
6
|
+
module Builder
|
|
7
|
+
# Construtor de XML para NFCom (Nota Fiscal de Comunicação)
|
|
8
|
+
#
|
|
9
|
+
# Esta classe é responsável por gerar o XML completo da NFCom
|
|
10
|
+
# seguindo o layout 1.00 do schema oficial da SEFAZ.
|
|
11
|
+
class XmlBuilder
|
|
12
|
+
include Utils::Helpers
|
|
13
|
+
|
|
14
|
+
attr_reader :nota, :configuration
|
|
15
|
+
|
|
16
|
+
VERSAO_LAYOUT = '1.00'
|
|
17
|
+
NAMESPACE = 'http://www.portalfiscal.inf.br/nfcom'
|
|
18
|
+
MODELO_NFCOM = 62
|
|
19
|
+
|
|
20
|
+
# Indicadores de IE do destinatário
|
|
21
|
+
INDIEDEST_CONTRIBUINTE = 1
|
|
22
|
+
INDIEDEST_ISENTO = 2
|
|
23
|
+
INDIEDEST_NAO_CONTRIBUINTE = 9
|
|
24
|
+
|
|
25
|
+
def initialize(nota, configuration)
|
|
26
|
+
@nota = nota
|
|
27
|
+
@configuration = configuration
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Gera o XML completo da NFCom
|
|
31
|
+
# @return [String] XML formatado em UTF-8
|
|
32
|
+
def gerar
|
|
33
|
+
builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
|
|
34
|
+
xml.NFCom(xmlns: NAMESPACE) do
|
|
35
|
+
xml.infNFCom(versao: VERSAO_LAYOUT, Id: "NFCom#{nota.chave_acesso}") do
|
|
36
|
+
gerar_ide(xml)
|
|
37
|
+
gerar_emit(xml)
|
|
38
|
+
gerar_dest(xml)
|
|
39
|
+
gerar_assinante(xml) if nota.assinante
|
|
40
|
+
gerar_sub(xml) if nota.finalidade == :substituicao
|
|
41
|
+
# gCofat iria aqui (cofaturamento)
|
|
42
|
+
gerar_detalhes(xml)
|
|
43
|
+
gerar_total(xml)
|
|
44
|
+
# gFidelidade iria aqui (programa de fidelidade)
|
|
45
|
+
gerar_fatura(xml) if nota.fatura
|
|
46
|
+
# gFatCentral iria aqui (faturamento centralizado)
|
|
47
|
+
# autXML iria aqui (autorizados para download)
|
|
48
|
+
gerar_info_adicional(xml) if nota.informacoes_adicionais
|
|
49
|
+
# gRespTec iria aqui (responsável técnico)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
gerar_info_suplementar(xml)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
builder.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# Gera o grupo de identificação da NFCom (tag ide)
|
|
62
|
+
def gerar_ide(xml)
|
|
63
|
+
xml.ide do
|
|
64
|
+
xml.cUF configuration.codigo_uf
|
|
65
|
+
xml.tpAmb configuration.ambiente_codigo
|
|
66
|
+
xml.mod MODELO_NFCOM
|
|
67
|
+
xml.serie nota.serie
|
|
68
|
+
xml.nNF nota.numero
|
|
69
|
+
xml.cNF nota.codigo_verificacao
|
|
70
|
+
xml.cDV nota.chave_acesso[-1].to_i
|
|
71
|
+
xml.dhEmi formatar_data_hora(nota.data_emissao)
|
|
72
|
+
xml.tpEmis nota.tipo_emissao_codigo
|
|
73
|
+
xml.nSiteAutoriz 0 # Zero para autorizador com único site
|
|
74
|
+
xml.cMunFG nota.emitente.endereco.codigo_municipio
|
|
75
|
+
xml.finNFCom nota.finalidade_codigo
|
|
76
|
+
xml.tpFat nota.tipo_faturamento || 0 # 0=Normal, 1=Centralizado, 2=Cofaturamento
|
|
77
|
+
xml.verProc 'Nfcom-Ruby-1.0'
|
|
78
|
+
|
|
79
|
+
# Campos de contingência (se aplicável)
|
|
80
|
+
if nota.tipo_emissao == :contingencia
|
|
81
|
+
xml.dhCont formatar_data_hora(nota.data_contingencia) if nota.data_contingencia
|
|
82
|
+
xml.xJust limitar_texto(nota.justificativa_contingencia, 256) if nota.justificativa_contingencia
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Gera o grupo de dados do emitente (tag emit)
|
|
88
|
+
def gerar_emit(xml)
|
|
89
|
+
xml.emit do
|
|
90
|
+
xml.CNPJ apenas_numeros(nota.emitente.cnpj)
|
|
91
|
+
xml.IE apenas_numeros(nota.emitente.inscricao_estadual)
|
|
92
|
+
# IEUFDest - IE virtual na UF destino (opcional para partilha)
|
|
93
|
+
xml.CRT nota.emitente.regime_tributario_codigo || 3 # 1=Simples, 2=Simples excesso, 3=Normal
|
|
94
|
+
xml.xNome limitar_texto(nota.emitente.razao_social, 60)
|
|
95
|
+
xml.xFant limitar_texto(nota.emitente.nome_fantasia, 60) if nota.emitente.nome_fantasia
|
|
96
|
+
gerar_endereco(xml, nota.emitente.endereco, 'emit')
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Gera o grupo de dados do destinatário (tag dest)
|
|
101
|
+
def gerar_dest(xml)
|
|
102
|
+
xml.dest do
|
|
103
|
+
xml.xNome limitar_texto(nota.destinatario.razao_social, 60)
|
|
104
|
+
|
|
105
|
+
cpf = apenas_numeros(nota.destinatario.cpf)
|
|
106
|
+
cnpj = apenas_numeros(nota.destinatario.cnpj)
|
|
107
|
+
ie = apenas_numeros(nota.destinatario.inscricao_estadual)
|
|
108
|
+
|
|
109
|
+
# CPF ou CNPJ (mutuamente exclusivo)
|
|
110
|
+
if cnpj.to_s.strip == ''
|
|
111
|
+
xml.CPF cpf
|
|
112
|
+
else
|
|
113
|
+
xml.CNPJ cnpj
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
if cnpj.to_s.strip != '' && ie.to_s.strip != ''
|
|
117
|
+
# PJ com IE → Contribuinte
|
|
118
|
+
xml.indIEDest INDIEDEST_CONTRIBUINTE
|
|
119
|
+
xml.IE ie
|
|
120
|
+
else
|
|
121
|
+
# PF ou PJ sem IE → Não contribuinte
|
|
122
|
+
xml.indIEDest INDIEDEST_NAO_CONTRIBUINTE
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
gerar_endereco(xml, nota.destinatario.endereco, 'dest')
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Gera o grupo de dados do assinante (tag assinante)
|
|
130
|
+
def gerar_assinante(xml)
|
|
131
|
+
assinante = nota.assinante
|
|
132
|
+
return unless assinante
|
|
133
|
+
|
|
134
|
+
xml.assinante do
|
|
135
|
+
# Código único do assinante (1-30 caracteres)
|
|
136
|
+
xml.iCodAssinante limitar_texto(assinante.codigo, 30)
|
|
137
|
+
|
|
138
|
+
# Tipo: 1=Comercial, 2=Industrial, 3=Residencial, 4=Rural, 5=Público, 6=Telecom,
|
|
139
|
+
# 7=Diplomático, 8=Religioso, 99=Outros
|
|
140
|
+
xml.tpAssinante assinante.tipo
|
|
141
|
+
|
|
142
|
+
# Tipo serviço: 1=Telefonia, 2=Dados, 3=TV, 4=Internet, 5=Multimídia, 6=Outros, 7=Vários
|
|
143
|
+
xml.tpServUtil assinante.tipo_servico
|
|
144
|
+
|
|
145
|
+
# Dados do contrato (opcional)
|
|
146
|
+
xml.nContrato limitar_texto(assinante.numero_contrato, 20) if assinante.numero_contrato
|
|
147
|
+
xml.dContratoIni formatar_data(assinante.data_inicio_contrato) if assinante.data_inicio_contrato
|
|
148
|
+
xml.dContratoFim formatar_data(assinante.data_fim_contrato) if assinante.data_fim_contrato
|
|
149
|
+
|
|
150
|
+
# Terminal principal (condicional - se informar um, deve informar ambos)
|
|
151
|
+
if assinante.terminal_principal && assinante.uf_terminal_principal
|
|
152
|
+
xml.NroTermPrinc apenas_numeros(assinante.terminal_principal)
|
|
153
|
+
xml.cUFPrinc assinante.uf_terminal_principal
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Terminais adicionais (0-n ocorrências)
|
|
157
|
+
assinante.terminais_adicionais&.each do |terminal|
|
|
158
|
+
xml.NroTermAdic apenas_numeros(terminal[:numero])
|
|
159
|
+
xml.cUFAdic terminal[:uf]
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Gera o grupo de faturamento (tag gFat)
|
|
165
|
+
# Contém apenas informações de controle de cobrança, NÃO valores
|
|
166
|
+
def gerar_fatura(xml)
|
|
167
|
+
xml.gFat do
|
|
168
|
+
# Competência no formato AAAAMM (ex: 202601)
|
|
169
|
+
xml.CompetFat nota.fatura.competencia
|
|
170
|
+
|
|
171
|
+
# Data de vencimento no formato AAAA-MM-DD
|
|
172
|
+
xml.dVencFat nota.fatura.data_vencimento
|
|
173
|
+
|
|
174
|
+
# Período de uso do serviço (ambos ou nenhum)
|
|
175
|
+
if nota.fatura.periodo_uso_inicio && nota.fatura.periodo_uso_fim
|
|
176
|
+
xml.dPerUsoIni formatar_data(nota.fatura.periodo_uso_inicio)
|
|
177
|
+
xml.dPerUsoFim formatar_data(nota.fatura.periodo_uso_fim)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Linha digitável do código de barras (obrigatório)
|
|
181
|
+
xml.codBarras nota.fatura.codigo_barras
|
|
182
|
+
|
|
183
|
+
# Débito automático (opcional - se informar um, deve informar todos)
|
|
184
|
+
if nota.fatura.codigo_debito_automatico
|
|
185
|
+
xml.codDebAuto nota.fatura.codigo_debito_automatico
|
|
186
|
+
xml.codBanco nota.fatura.codigo_banco
|
|
187
|
+
xml.codAgencia nota.fatura.codigo_agencia
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# enderCorresp - Endereço de correspondência (opcional)
|
|
191
|
+
# gPIX - Informações do PIX (opcional)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Gera o endereço do emitente ou destinatário
|
|
196
|
+
# @param xml [Nokogiri::XML::Builder] Builder do XML
|
|
197
|
+
# @param endereco [Endereco] Objeto com dados do endereço
|
|
198
|
+
# @param tipo [String] 'emit' ou 'dest'
|
|
199
|
+
def gerar_endereco(xml, endereco, tipo)
|
|
200
|
+
tag_name = tipo == 'emit' ? 'enderEmit' : 'enderDest'
|
|
201
|
+
|
|
202
|
+
xml.send(tag_name) do
|
|
203
|
+
xml.xLgr limitar_texto(endereco.logradouro, 60)
|
|
204
|
+
xml.nro limitar_texto(endereco.numero, 60)
|
|
205
|
+
xml.xCpl limitar_texto(endereco.complemento, 60) if endereco.complemento
|
|
206
|
+
xml.xBairro limitar_texto(endereco.bairro, 60)
|
|
207
|
+
xml.cMun endereco.codigo_municipio
|
|
208
|
+
xml.xMun limitar_texto(endereco.municipio, 60)
|
|
209
|
+
xml.CEP apenas_numeros(endereco.cep)
|
|
210
|
+
xml.UF endereco.uf
|
|
211
|
+
|
|
212
|
+
# País apenas para destinatário
|
|
213
|
+
if tipo == 'dest'
|
|
214
|
+
xml.cPais endereco.codigo_pais || 1058 # 1058 = Brasil (tabela BACEN)
|
|
215
|
+
xml.xPais endereco.pais || 'Brasil'
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
xml.fone apenas_numeros(endereco.telefone) if endereco.telefone
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Gera os itens/serviços da NFCom (tag det)
|
|
223
|
+
def gerar_detalhes(xml)
|
|
224
|
+
nota.itens.each do |item|
|
|
225
|
+
xml.det(nItem: item.numero_item) do
|
|
226
|
+
gerar_produto(xml, item)
|
|
227
|
+
gerar_impostos_item(xml, item)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Gera os dados do produto/serviço (tag prod)
|
|
233
|
+
def gerar_produto(xml, item)
|
|
234
|
+
xml.prod do
|
|
235
|
+
xml.cProd item.codigo_servico
|
|
236
|
+
xml.xProd limitar_texto(item.descricao, 120)
|
|
237
|
+
xml.cClass item.classe_consumo # Código de classificação do item (tabela ANATEL)
|
|
238
|
+
xml.CFOP item.cfop
|
|
239
|
+
xml.uMed item.unidade # 1=Minuto, 2=MB, 3=GB, 4=UN
|
|
240
|
+
xml.qFaturada formatar_decimal(item.quantidade, 4)
|
|
241
|
+
|
|
242
|
+
# vItem = valor unitário, vProd = valor total do item
|
|
243
|
+
xml.vItem formatar_decimal(item.valor_unitario)
|
|
244
|
+
xml.vProd formatar_decimal(item.valor_total)
|
|
245
|
+
|
|
246
|
+
# Valores opcionais
|
|
247
|
+
xml.vDesc formatar_decimal(item.valor_desconto) if item.valor_desconto&.positive?
|
|
248
|
+
xml.vOutro formatar_decimal(item.valor_outras_despesas) if item.valor_outras_despesas&.positive?
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Gera os impostos do item (tag imposto)
|
|
253
|
+
def gerar_impostos_item(xml, _item)
|
|
254
|
+
xml.imposto do
|
|
255
|
+
# ICMS00 - Tributação normal do ICMS
|
|
256
|
+
# Para ISPs normalmente BC=0 (serviço isento ou não tributado)
|
|
257
|
+
xml.ICMS00 do
|
|
258
|
+
xml.CST '00' # 00 = Tributação normal
|
|
259
|
+
xml.vBC '0.00' # Base de cálculo (0.00 para isentos)
|
|
260
|
+
xml.pICMS '0.00' # Alíquota do ICMS
|
|
261
|
+
xml.vICMS '0.00' # Valor do ICMS
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# PIS - Programa de Integração Social (se aplicável)
|
|
265
|
+
# xml.PIS do
|
|
266
|
+
# xml.CST '01' # 01=Tributável com alíquota básica
|
|
267
|
+
# xml.vBC formatar_decimal(item.valor_total)
|
|
268
|
+
# xml.pPIS '0.65' # Alíquota padrão 0.65%
|
|
269
|
+
# xml.vPIS formatar_decimal(item.valor_total * 0.0065)
|
|
270
|
+
# end
|
|
271
|
+
|
|
272
|
+
# COFINS - Contribuição para Financiamento da Seguridade Social (se aplicável)
|
|
273
|
+
# xml.COFINS do
|
|
274
|
+
# xml.CST '01' # 01=Tributável com alíquota básica
|
|
275
|
+
# xml.vBC formatar_decimal(item.valor_total)
|
|
276
|
+
# xml.pCOFINS '3.00' # Alíquota padrão 3%
|
|
277
|
+
# xml.vCOFINS formatar_decimal(item.valor_total * 0.03)
|
|
278
|
+
# end
|
|
279
|
+
|
|
280
|
+
# FUST - Fundo de Universalização dos Serviços de Telecomunicações (se aplicável)
|
|
281
|
+
# FUNTTEL - Fundo para o Desenvolvimento Tecnológico (se aplicável)
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Gera os totalizadores da NFCom (tag total)
|
|
286
|
+
def gerar_total(xml)
|
|
287
|
+
xml.total do
|
|
288
|
+
# Valor total dos produtos/serviços
|
|
289
|
+
xml.vProd formatar_decimal(nota.total.valor_servicos)
|
|
290
|
+
|
|
291
|
+
# Totais de ICMS
|
|
292
|
+
xml.ICMSTot do
|
|
293
|
+
xml.vBC formatar_decimal(nota.total.icms_base_calculo)
|
|
294
|
+
xml.vICMS formatar_decimal(nota.total.icms_valor)
|
|
295
|
+
xml.vICMSDeson formatar_decimal(nota.total.icms_desonerado || 0)
|
|
296
|
+
xml.vFCP formatar_decimal(nota.total.fcp_valor || 0) # Fundo de Combate à Pobreza
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Tributos federais
|
|
300
|
+
xml.vCOFINS formatar_decimal(nota.total.cofins_valor)
|
|
301
|
+
xml.vPIS formatar_decimal(nota.total.pis_valor)
|
|
302
|
+
xml.vFUNTTEL formatar_decimal(nota.total.funttel_valor || 0)
|
|
303
|
+
xml.vFUST formatar_decimal(nota.total.fust_valor || 0)
|
|
304
|
+
|
|
305
|
+
# Retenções na fonte
|
|
306
|
+
xml.vRetTribTot do
|
|
307
|
+
xml.vRetPIS formatar_decimal(nota.total.pis_retido || 0)
|
|
308
|
+
xml.vRetCofins formatar_decimal(nota.total.cofins_retido || 0)
|
|
309
|
+
xml.vRetCSLL formatar_decimal(nota.total.csll_retido || 0)
|
|
310
|
+
xml.vIRRF formatar_decimal(nota.total.irrf_retido || 0)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Descontos e acréscimos
|
|
314
|
+
xml.vDesc formatar_decimal(nota.total.valor_desconto)
|
|
315
|
+
xml.vOutro formatar_decimal(nota.total.valor_outras_despesas)
|
|
316
|
+
|
|
317
|
+
# Valor total da NFCom (deve ser o último campo)
|
|
318
|
+
xml.vNF formatar_decimal(nota.total.valor_total)
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Gera informações adicionais (tag infAdic)
|
|
323
|
+
def gerar_info_adicional(xml)
|
|
324
|
+
return unless nota.informacoes_adicionais&.any?
|
|
325
|
+
|
|
326
|
+
xml.infAdic do
|
|
327
|
+
nota.informacoes_adicionais.each do |texto|
|
|
328
|
+
xml.infCpl texto
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Gera informações suplementares (tag infNFComSupl)
|
|
334
|
+
# Contém o QR Code para consulta da NFCom
|
|
335
|
+
def gerar_info_suplementar(xml)
|
|
336
|
+
xml.infNFComSupl do
|
|
337
|
+
xml.qrCodNFCom gerar_qrcode
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Gera a URL do QR Code para consulta da NFCom
|
|
342
|
+
# @return [String] URL completa do QR Code
|
|
343
|
+
def gerar_qrcode
|
|
344
|
+
base_url = 'https://dfe-portal.svrs.rs.gov.br/nfcom/qrcode'
|
|
345
|
+
|
|
346
|
+
# Formato: URL?chNFCom=CHAVE&tpAmb=AMBIENTE
|
|
347
|
+
"#{base_url}?chNFCom=#{nota.chave_acesso}&tpAmb=#{configuration.ambiente_codigo}"
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Gera o grupo de informações da substituição (tag gSub)
|
|
351
|
+
# Obrigatório quando finNFCom = 3 (Substituição)
|
|
352
|
+
def gerar_sub(xml)
|
|
353
|
+
return unless nota.chave_nfcom_substituida
|
|
354
|
+
|
|
355
|
+
xml.gSub do
|
|
356
|
+
xml.chNFCom nota.chave_nfcom_substituida
|
|
357
|
+
xml.motSub nota.motivo_substituicao.to_s.rjust(2, '0')
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|
data/lib/nfcom/client.rb
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nfcom
|
|
4
|
+
class Client
|
|
5
|
+
attr_reader :configuration
|
|
6
|
+
|
|
7
|
+
def initialize(config = nil)
|
|
8
|
+
@configuration = config || Nfcom.configuration
|
|
9
|
+
raise Errors::ConfigurationError, 'Nfcom não está configurado' if @configuration.nil?
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Autoriza uma nota fiscal
|
|
13
|
+
def autorizar(nota) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
|
|
14
|
+
validar_configuracao!
|
|
15
|
+
|
|
16
|
+
raise Errors::ValidationError, nota.erros.join(', ') unless nota.valida?
|
|
17
|
+
|
|
18
|
+
# Gera chave de acesso
|
|
19
|
+
nota.gerar_chave_acesso
|
|
20
|
+
|
|
21
|
+
# Constrói XML
|
|
22
|
+
xml_builder = Builder::XmlBuilder.new(nota, configuration)
|
|
23
|
+
xml = xml_builder.gerar
|
|
24
|
+
|
|
25
|
+
# Assina XML
|
|
26
|
+
signature = Builder::Signature.new(configuration)
|
|
27
|
+
xml_assinado = signature.assinar(xml)
|
|
28
|
+
|
|
29
|
+
tentativa = 0
|
|
30
|
+
begin
|
|
31
|
+
ws = Webservices::Autorizacao.new(configuration)
|
|
32
|
+
resposta = ws.enviar(xml_assinado)
|
|
33
|
+
|
|
34
|
+
parser = Parsers::Autorizacao.new(resposta)
|
|
35
|
+
resultado = parser.parse
|
|
36
|
+
|
|
37
|
+
nota.protocolo = resultado[:protocolo]
|
|
38
|
+
nota.data_autorizacao = resultado[:data_autorizacao]
|
|
39
|
+
nota.xml_autorizado = Utils::XmlAuthorized.build_nfcom_proc(
|
|
40
|
+
xml_assinado: xml_assinado,
|
|
41
|
+
xml_protocolo: resultado[:xml]
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
resultado
|
|
45
|
+
rescue Errors::SefazIndisponivel => e
|
|
46
|
+
tentativa += 1
|
|
47
|
+
raise e unless tentativa < configuration.max_tentativas
|
|
48
|
+
|
|
49
|
+
sleep(configuration.tempo_espera_retry**tentativa)
|
|
50
|
+
retry
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Consulta uma nota pela chave de acesso
|
|
55
|
+
def consultar_nota(chave:)
|
|
56
|
+
validar_configuracao!
|
|
57
|
+
|
|
58
|
+
ws = Webservices::Consulta.new(configuration)
|
|
59
|
+
resposta = ws.consultar(chave)
|
|
60
|
+
|
|
61
|
+
parser = Parsers::Consulta.new(resposta)
|
|
62
|
+
parser.parse
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Verifica status do serviço da SEFAZ
|
|
66
|
+
def status_servico
|
|
67
|
+
validar_configuracao!
|
|
68
|
+
|
|
69
|
+
ws = Webservices::Status.new(configuration)
|
|
70
|
+
resposta = ws.verificar
|
|
71
|
+
|
|
72
|
+
parser = Parsers::Status.new(resposta)
|
|
73
|
+
parser.parse
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Inutiliza uma numeração de nota
|
|
77
|
+
def inutilizar(serie:, numero_inicial:, numero_final:, justificativa:)
|
|
78
|
+
validar_configuracao!
|
|
79
|
+
|
|
80
|
+
raise Errors::ValidationError, 'Justificativa deve ter no mínimo 15 caracteres' if justificativa.length < 15
|
|
81
|
+
|
|
82
|
+
ws = Webservices::Inutilizacao.new(configuration)
|
|
83
|
+
resposta = ws.inutilizar(
|
|
84
|
+
serie: serie,
|
|
85
|
+
numero_inicial: numero_inicial,
|
|
86
|
+
numero_final: numero_final,
|
|
87
|
+
justificativa: justificativa
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
parser = Parsers::Inutilizacao.new(resposta)
|
|
91
|
+
parser.parse
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def validar_configuracao!
|
|
97
|
+
erros = []
|
|
98
|
+
erros << 'Certificado não configurado' if configuration.certificado_path.nil?
|
|
99
|
+
erros << 'CNPJ não configurado' if configuration.cnpj.nil?
|
|
100
|
+
erros << 'Inscrição Estadual não configurada' if configuration.inscricao_estadual.nil?
|
|
101
|
+
erros << 'Estado não configurado' if configuration.estado.nil?
|
|
102
|
+
|
|
103
|
+
raise Errors::ConfigurationError, erros.join(', ') unless erros.empty?
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|