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,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ # config/initializers/nfcom.rb
4
+ # Configuração da gem Nfcom para emissão de NF-COM
5
+
6
+ require 'nfcom'
7
+
8
+ Nfcom.configure do |config|
9
+ # ============================================
10
+ # AMBIENTE
11
+ # ============================================
12
+ # Sempre começar em homologação!
13
+ # Mude para :producao apenas após testar tudo
14
+ config.ambiente = Rails.env.production? ? :producao : :homologacao
15
+ config.estado = 'PE'
16
+
17
+ # ============================================
18
+ # CERTIFICADO DIGITAL (OBRIGATÓRIO)
19
+ # ============================================
20
+ # Use variáveis de ambiente para não commitar senhas
21
+ config.certificado_path = ENV['NFCOM_CERTIFICADO_PATH'] ||
22
+ Rails.root.join('config', 'certificados', 'certificado.pfx').to_s
23
+ config.certificado_senha = ENV.fetch('NFCOM_CERTIFICADO_SENHA', nil)
24
+
25
+ # ============================================
26
+ # DADOS DO EMITENTE (OBRIGATÓRIO)
27
+ # ============================================
28
+ config.cnpj = ENV.fetch('NFCOM_CNPJ', nil)
29
+ config.razao_social = ENV.fetch('NFCOM_RAZAO_SOCIAL', nil)
30
+ config.inscricao_estadual = ENV.fetch('NFCOM_INSCRICAO_ESTADUAL', nil)
31
+ config.regime_tributario = ENV.fetch('NFCOM_REGIME_TRIBUTARIO', 1).to_i
32
+ # 1 = Simples Nacional
33
+ # 2 = Simples Nacional - Excesso de sublimite de receita bruta
34
+ # 3 = Regime Normal
35
+
36
+ # ============================================
37
+ # CONFIGURAÇÕES OPCIONAIS
38
+ # ============================================
39
+ config.serie_padrao = ENV.fetch('NFCOM_SERIE_PADRAO', 1).to_i
40
+ config.timeout = 30
41
+ config.max_tentativas = 3
42
+
43
+ # Logging
44
+ config.log_level = Rails.env.production? ? :info : :debug
45
+ config.logger = Rails.logger
46
+ end
47
+
48
+ # ============================================
49
+ # VALIDAÇÃO DA CONFIGURAÇÃO
50
+ # ============================================
51
+ # Valida se as configurações obrigatórias estão presentes
52
+ Rails.application.config.after_initialize do
53
+ config = Nfcom.configuration
54
+
55
+ erros = []
56
+ erros << 'NFCOM_CERTIFICADO_PATH não configurado' if config.certificado_path.nil?
57
+ erros << 'NFCOM_CERTIFICADO_SENHA não configurado' if config.certificado_senha.nil?
58
+ erros << 'NFCOM_CNPJ não configurado' if config.cnpj.nil?
59
+ erros << 'NFCOM_RAZAO_SOCIAL não configurado' if config.razao_social.nil?
60
+ erros << 'NFCOM_INSCRICAO_ESTADUAL não configurado' if config.inscricao_estadual.nil?
61
+
62
+ if erros.any? && Rails.env.production?
63
+ Rails.logger.error 'NFCOM: Configuração incompleta!'
64
+ erros.each { |erro| Rails.logger.error " - #{erro}" }
65
+ raise 'NFCOM não está configurado corretamente. Verifique as variáveis de ambiente.'
66
+ elsif erros.any?
67
+ Rails.logger.warn "NFCOM: Configuração incompleta (#{Rails.env}):"
68
+ erros.each { |erro| Rails.logger.warn " - #{erro}" }
69
+ else
70
+ Rails.logger.info "NFCOM: Configurado com sucesso! (#{config.ambiente} - #{config.estado})"
71
+ end
72
+ end
@@ -0,0 +1,564 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'prawn'
5
+ require 'prawn/measurement_extensions'
6
+ require 'prawn/table'
7
+ require 'prawn-svg'
8
+
9
+ module Nfcom
10
+ module Builder
11
+ class DanfeCom
12
+ include Utils::Helpers
13
+
14
+ Prawn::Fonts::AFM.hide_m17n_warning = true
15
+
16
+ attr_reader :xml_doc, :ns, :logo_path
17
+
18
+ def initialize(xml_autorizado, logo_path: nil)
19
+ raise Errors::XmlError, 'XML não pode ser vazio' if xml_autorizado.nil? || xml_autorizado.strip.empty?
20
+
21
+ @logo_path = logo_path
22
+ @xml_doc = Nokogiri::XML(xml_autorizado)
23
+ @ns = 'http://www.portalfiscal.inf.br/nfcom'
24
+
25
+ raise Errors::XmlError, 'XML inválido: não contém elemento NFCom' unless xml_doc.at_xpath('//xmlns:NFCom',
26
+ 'xmlns' => ns)
27
+ rescue Nokogiri::XML::SyntaxError => e
28
+ raise Errors::XmlError, "Erro ao fazer parse do XML: #{e.message}"
29
+ end
30
+
31
+ # ----------------------------
32
+ # MAIN PUBLIC METHODS
33
+ # ----------------------------
34
+ def gerar
35
+ Prawn::Document.new(page_size: 'A4', margin: [5.mm] * 4) do |pdf|
36
+ setup_fonts(pdf)
37
+ gerar_conteudo(pdf)
38
+ end.render
39
+ end
40
+
41
+ def gerar_arquivo(filepath)
42
+ File.write(filepath, gerar)
43
+ end
44
+
45
+ # ----------------------------
46
+ # PRIVATE METHODS
47
+ # ----------------------------
48
+ private
49
+
50
+ def setup_fonts(pdf)
51
+ pdf.font 'Helvetica'
52
+ end
53
+
54
+ def gerar_conteudo(pdf)
55
+ ide = xml_doc.at_xpath('//xmlns:ide', 'xmlns' => ns)
56
+ emit = xml_doc.at_xpath('//xmlns:emit', 'xmlns' => ns)
57
+ dest = xml_doc.at_xpath('//xmlns:dest', 'xmlns' => ns)
58
+ total = xml_doc.at_xpath('//xmlns:total', 'xmlns' => ns)
59
+ itens = xml_doc.xpath('//xmlns:det', 'xmlns' => ns)
60
+ gfat = xml_doc.at_xpath('//xmlns:gFat', 'xmlns' => ns)
61
+ assinante = xml_doc.at_xpath('//xmlns:assinante', 'xmlns' => ns)
62
+ prot = xml_doc.at_xpath('//xmlns:protNFCom', 'xmlns' => ns)
63
+ inf_adic = xml_doc.at_xpath('//xmlns:infAdic', 'xmlns' => ns)
64
+
65
+ y_pos = pdf.cursor
66
+ y_pos = gerar_cabecalho(pdf, y_pos)
67
+ y_pos = gerar_info_emitente_documento(pdf, emit, ide, y_pos)
68
+ y_pos = gerar_chave_protocolo(pdf, prot, y_pos)
69
+ y_pos = gerar_destinatario(pdf, dest, y_pos)
70
+ y_pos = gerar_assinante(pdf, assinante, y_pos) if assinante
71
+ y_pos = gerar_faturamento(pdf, gfat, y_pos) if gfat
72
+ y_pos = gerar_itens(pdf, itens, y_pos)
73
+ gerar_totais(pdf, total, y_pos)
74
+ gerar_info_adicional(pdf, inf_adic) if inf_adic
75
+ gerar_rodape(pdf, ide, prot)
76
+ end
77
+
78
+ # ----------------------------
79
+ # HEADER / LOGO
80
+ # ----------------------------
81
+ def gerar_cabecalho(pdf, y_pos)
82
+ if logo_path && File.exist?(logo_path)
83
+ begin
84
+ if logo_path.to_s.downcase.end_with?('.svg')
85
+ renderizar_logo_svg(pdf, y_pos)
86
+ else
87
+ pdf.image logo_path.to_s, at: [0, y_pos - 2.mm], fit: [40.mm, 18.mm]
88
+ end
89
+ rescue StandardError => e
90
+ warn "Logo ignorado: #{e.message}"
91
+ end
92
+ end
93
+
94
+ title_x = logo_path && File.exist?(logo_path) ? 45.mm : 0
95
+ title_w = logo_path && File.exist?(logo_path) ? 155.mm : 200.mm
96
+
97
+ pdf.text_box 'DANFE-COM', at: [title_x, y_pos - 5.mm], size: 18, style: :bold, align: :center, width: title_w
98
+ pdf.text_box 'Documento Auxiliar da Nota Fiscal de Comunicação',
99
+ at: [title_x, y_pos - 12.mm], size: 10, align: :center, width: title_w
100
+
101
+ y_pos - 22.mm
102
+ end
103
+
104
+ def renderizar_logo_svg(pdf, y_pos)
105
+ raise 'prawn-svg não instalado' unless defined?(Prawn::Svg)
106
+
107
+ pdf.svg File.read(logo_path.to_s), at: [0, y_pos - 2.mm], width: 40.mm, height: 18.mm,
108
+ enable_web_requests: false
109
+ end
110
+
111
+ # ----------------------------
112
+ # EMITENTE / DESTINATARIO
113
+ # ----------------------------
114
+ def gerar_info_emitente_documento(pdf, emit, ide, y_pos)
115
+ pdf.stroke_rectangle [0, y_pos], 200.mm, 30.mm
116
+ emit_info = extrair_emitente(emit)
117
+ pdf.text_box 'EMITENTE', at: [2.mm, y_pos - 2.mm], size: 10, style: :bold
118
+ pdf.text_box emit_info[:nome], at: [2.mm, y_pos - 6.mm], size: 10, style: :bold
119
+ pdf.text_box "CNPJ: #{formatar_cnpj(emit_info[:cnpj])}", at: [2.mm, y_pos - 11.mm], size: 10
120
+ pdf.text_box "IE: #{emit_info[:ie]}", at: [2.mm, y_pos - 16.mm], size: 10
121
+ pdf.text_box montar_endereco_linha(emit_info), at: [2.mm, y_pos - 21.mm], size: 10, width: 120.mm
122
+ pdf.text_box "#{emit_info[:municipio]}/#{emit_info[:uf]} - CEP: #{formatar_cep(emit_info[:cep])}",
123
+ at: [2.mm, y_pos - 26.mm], size: 10
124
+
125
+ pdf.stroke_vertical_line y_pos, y_pos - 30.mm, at: 130.mm
126
+
127
+ numero = ide.at_xpath('xmlns:nNF', 'xmlns' => ns)&.text
128
+ serie = ide.at_xpath('xmlns:serie', 'xmlns' => ns)&.text
129
+ dh_em = ide.at_xpath('xmlns:dhEmi', 'xmlns' => ns)&.text
130
+ fin_nf = ide.at_xpath('xmlns:finNFCom', 'xmlns' => ns)&.text
131
+
132
+ pdf.text_box 'NÚMERO', at: [132.mm, y_pos - 2.mm], size: 10, style: :bold
133
+ pdf.text_box numero.to_s.rjust(6, '0'), at: [132.mm, y_pos - 7.mm], size: 14, style: :bold
134
+ pdf.text_box 'SÉRIE', at: [155.mm, y_pos - 2.mm], size: 10, style: :bold
135
+ pdf.text_box serie, at: [155.mm, y_pos - 7.mm], size: 14, style: :bold
136
+ pdf.text_box 'DATA DE EMISSÃO', at: [132.mm, y_pos - 14.mm], size: 10, style: :bold
137
+ pdf.text_box formatar_data_hora_xml(dh_em), at: [132.mm, y_pos - 19.mm], size: 10
138
+ pdf.text_box tipo_documento(fin_nf), at: [132.mm, y_pos - 25.mm], size: 10, style: :bold
139
+
140
+ y_pos - 33.mm
141
+ end
142
+
143
+ def gerar_destinatario(pdf, dest, y_pos)
144
+ pdf.stroke_rectangle [0, y_pos], 200.mm, 28.mm
145
+ dest_info = extrair_destinatario(dest)
146
+
147
+ pdf.text_box 'DESTINATÁRIO / TOMADOR DO SERVIÇO', at: [2.mm, y_pos - 2.mm], size: 10, style: :bold
148
+ pdf.text_box dest_info[:nome], at: [2.mm, y_pos - 7.mm], size: 12, style: :bold
149
+
150
+ doc_label = dest_info[:cnpj] ? 'CNPJ' : 'CPF'
151
+ doc_val = dest_info[:cnpj] || dest_info[:cpf]
152
+ pdf.text_box "#{doc_label}: #{formatar_cnpj_cpf(doc_val)}", at: [2.mm, y_pos - 13.mm], size: 10
153
+
154
+ ie_text = case dest_info[:ind_ie_dest]
155
+ when '1' then dest_info[:ie]
156
+ when '2' then 'ISENTO'
157
+ else 'NÃO CONTRIBUINTE'
158
+ end
159
+ pdf.text_box "IE: #{ie_text}", at: [70.mm, y_pos - 13.mm], size: 10
160
+
161
+ pdf.text_box montar_endereco_linha(dest_info), at: [2.mm, y_pos - 18.mm], size: 10, width: 195.mm
162
+
163
+ cidade_cep = "#{dest_info[:bairro]} - #{dest_info[:municipio]}/#{dest_info[:uf]} - " \
164
+ "CEP: #{formatar_cep(dest_info[:cep])}"
165
+ pdf.text_box cidade_cep, at: [2.mm, y_pos - 23.mm], size: 10
166
+
167
+ y_pos - 32.mm
168
+ end
169
+
170
+ # ----------------------------
171
+ # QR CODE
172
+ # ----------------------------
173
+ def gerar_chave_protocolo(pdf, prot, y_pos)
174
+ pdf.stroke_rectangle [0, y_pos], 200.mm, 30.mm
175
+
176
+ chave = extrair_chave_acesso(prot)
177
+ renderizar_qrcode(pdf, chave, y_pos)
178
+ renderizar_info_chave(pdf, chave, y_pos)
179
+ renderizar_info_protocolo(pdf, prot, y_pos)
180
+ renderizar_texto_consulta(pdf, y_pos)
181
+
182
+ y_pos - 33.mm
183
+ end
184
+
185
+ def extrair_chave_acesso(prot)
186
+ # Try to get chave from protocol (inside infProt)
187
+ chave = prot&.at_xpath('xmlns:infProt/xmlns:chNFCom', 'xmlns' => ns)&.text
188
+ # Fallback to extracting from infNFCom Id attribute
189
+ chave ||= xml_doc.at_xpath('//xmlns:infNFCom', 'xmlns' => ns)&.[]('Id')&.gsub('NFCom', '')
190
+ chave
191
+ end
192
+
193
+ def renderizar_qrcode(pdf, chave, y_pos)
194
+ return unless chave
195
+
196
+ # Use Qrcode builder to generate SVG
197
+ ambiente_symbol = tipo_ambiente == 1 ? :producao : :homologacao
198
+ qr_builder = Nfcom::Builder::Qrcode.new(chave, ambiente_symbol)
199
+ qr_svg = qr_builder.gerar_qrcode_svg
200
+
201
+ # Render SVG (prawn-svg converts string to SVG)
202
+ pdf.svg qr_svg, at: [2.mm, y_pos - 2.mm], width: 26.mm, height: 26.mm, enable_web_requests: false
203
+ rescue StandardError => e
204
+ warn "QR Code error: #{e.message}"
205
+ pdf.stroke_rectangle [2.mm, y_pos - 2.mm], 22.mm, 22.mm
206
+ pdf.text_box 'QR', at: [6.mm, y_pos - 9.mm], size: 10, style: :bold
207
+ end
208
+
209
+ def renderizar_info_chave(pdf, chave, y_pos)
210
+ pdf.text_box 'CHAVE DE ACESSO', at: [30.mm, y_pos - 2.mm], size: 10, style: :bold
211
+ pdf.text_box formatar_chave_acesso(chave), at: [30.mm, y_pos - 7.mm], size: 10, width: 165.mm
212
+ end
213
+
214
+ def renderizar_info_protocolo(pdf, prot, y_pos)
215
+ # Protocol data is nested inside infProt
216
+ protocolo = prot&.at_xpath('xmlns:infProt/xmlns:nProt', 'xmlns' => ns)&.text
217
+ dh_recbto = prot&.at_xpath('xmlns:infProt/xmlns:dhRecbto', 'xmlns' => ns)&.text
218
+
219
+ return unless protocolo
220
+
221
+ pdf.text_box 'PROTOCOLO DE AUTORIZAÇÃO', at: [30.mm, y_pos - 13.mm], size: 10, style: :bold
222
+ pdf.text_box "#{protocolo} - #{formatar_data_hora_xml(dh_recbto)}", at: [30.mm, y_pos - 18.mm], size: 10
223
+ end
224
+
225
+ def renderizar_texto_consulta(pdf, y_pos)
226
+ texto = 'Consulte a autenticidade deste documento através do QR Code ou da ' \
227
+ 'chave de acesso no site da SEFAZ'
228
+ pdf.text_box texto, at: [30.mm, y_pos - 24.mm], size: 8
229
+ end
230
+
231
+ # ----------------------------
232
+ # EXTRACTION METHODS
233
+ # ----------------------------
234
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
235
+ def extrair_emitente(emit)
236
+ return {} unless emit
237
+
238
+ {
239
+ cnpj: emit.at_xpath('xmlns:CNPJ', 'xmlns' => ns)&.text,
240
+ nome: emit.at_xpath('xmlns:xNome', 'xmlns' => ns)&.text,
241
+ ie: emit.at_xpath('xmlns:IE', 'xmlns' => ns)&.text,
242
+ logradouro: emit.at_xpath('xmlns:enderEmit/xmlns:xLgr', 'xmlns' => ns)&.text,
243
+ numero: emit.at_xpath('xmlns:enderEmit/xmlns:nro', 'xmlns' => ns)&.text,
244
+ complemento: emit.at_xpath('xmlns:enderEmit/xmlns:xCpl', 'xmlns' => ns)&.text,
245
+ bairro: emit.at_xpath('xmlns:enderEmit/xmlns:xBairro', 'xmlns' => ns)&.text,
246
+ municipio: emit.at_xpath('xmlns:enderEmit/xmlns:xMun', 'xmlns' => ns)&.text,
247
+ uf: emit.at_xpath('xmlns:enderEmit/xmlns:UF', 'xmlns' => ns)&.text,
248
+ cep: emit.at_xpath('xmlns:enderEmit/xmlns:CEP', 'xmlns' => ns)&.text
249
+ }
250
+ end
251
+
252
+ def extrair_destinatario(dest)
253
+ return {} unless dest
254
+
255
+ {
256
+ nome: dest.at_xpath('xmlns:xNome', 'xmlns' => ns)&.text,
257
+ cnpj: dest.at_xpath('xmlns:CNPJ', 'xmlns' => ns)&.text,
258
+ cpf: dest.at_xpath('xmlns:CPF', 'xmlns' => ns)&.text,
259
+ ie: dest.at_xpath('xmlns:IE', 'xmlns' => ns)&.text,
260
+ ind_ie_dest: dest.at_xpath('xmlns:indIEDest', 'xmlns' => ns)&.text,
261
+ logradouro: dest.at_xpath('xmlns:enderDest/xmlns:xLgr', 'xmlns' => ns)&.text,
262
+ numero: dest.at_xpath('xmlns:enderDest/xmlns:nro', 'xmlns' => ns)&.text,
263
+ complemento: dest.at_xpath('xmlns:enderDest/xmlns:xCpl', 'xmlns' => ns)&.text,
264
+ bairro: dest.at_xpath('xmlns:enderDest/xmlns:xBairro', 'xmlns' => ns)&.text,
265
+ municipio: dest.at_xpath('xmlns:enderDest/xmlns:xMun', 'xmlns' => ns)&.text,
266
+ uf: dest.at_xpath('xmlns:enderDest/xmlns:UF', 'xmlns' => ns)&.text,
267
+ cep: dest.at_xpath('xmlns:enderDest/xmlns:CEP', 'xmlns' => ns)&.text
268
+ }
269
+ end
270
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
271
+
272
+ # ----------------------------
273
+ # HELPER METHODS
274
+ # ----------------------------
275
+ def montar_endereco_linha(info)
276
+ return '' unless info
277
+
278
+ line = "#{info[:logradouro]}, #{info[:numero]}"
279
+ line += " - #{info[:complemento]}" if info[:complemento] && !info[:complemento].to_s.strip.empty?
280
+ line += " - #{info[:bairro]}" if info[:bairro] && !info[:bairro].to_s.strip.empty?
281
+ line
282
+ end
283
+
284
+ def formatar_cnpj(cnpj)
285
+ return '' unless cnpj
286
+
287
+ cnpj.gsub!(/\D/, '')
288
+ return cnpj unless cnpj.length == 14
289
+
290
+ "#{cnpj[0..1]}.#{cnpj[2..4]}.#{cnpj[5..7]}/#{cnpj[8..11]}-#{cnpj[12..13]}"
291
+ end
292
+
293
+ def formatar_cpf(cpf)
294
+ return '' unless cpf
295
+
296
+ cpf.gsub!(/\D/, '')
297
+ return cpf unless cpf.length == 11
298
+
299
+ "#{cpf[0..2]}.#{cpf[3..5]}.#{cpf[6..8]}-#{cpf[9..10]}"
300
+ end
301
+
302
+ def formatar_cnpj_cpf(value)
303
+ return '' unless value
304
+
305
+ value.length == 14 ? formatar_cnpj(value) : formatar_cpf(value)
306
+ end
307
+
308
+ def apenas_numeros(str)
309
+ str.to_s.gsub(/\D/, '')
310
+ end
311
+
312
+ def formatar_cep(cep)
313
+ return '' unless cep
314
+
315
+ cep.gsub!(/\D/, '')
316
+ return cep unless cep.length == 8
317
+
318
+ "#{cep[0..1]}.#{cep[2..4]}-#{cep[5..7]}"
319
+ end
320
+
321
+ def tipo_documento(fin_nfcom)
322
+ case fin_nfcom
323
+ when '0' then 'NFCom Normal'
324
+ when '3' then 'NFCom de Substituição'
325
+ when '4' then 'NFCom de Ajuste'
326
+ else 'NFCom'
327
+ end
328
+ end
329
+
330
+ def formatar_chave_acesso(chave)
331
+ return '' unless chave
332
+
333
+ chave.scan(/.{4}/).join(' ')
334
+ end
335
+
336
+ def tipo_ambiente
337
+ @tipo_ambiente ||= xml_doc.at_xpath('//xmlns:ide/xmlns:tpAmb', 'xmlns' => ns)&.text&.to_i || 2
338
+ end
339
+
340
+ def formatar_data_hora_xml(datetime)
341
+ return '' unless datetime
342
+
343
+ DateTime.parse(datetime).strftime('%d/%m/%Y %H:%M:%S')
344
+ rescue ArgumentError
345
+ datetime
346
+ end
347
+
348
+ # ----------------------------
349
+ # ASSINANTE
350
+ # ----------------------------
351
+ def gerar_assinante(pdf, assinante, y_pos)
352
+ pdf.stroke_rectangle [0, y_pos], 200.mm, 18.mm
353
+ pdf.text_box 'DADOS DO ASSINANTE', at: [2.mm, y_pos - 2.mm], size: 10, style: :bold
354
+
355
+ cod = assinante.at_xpath('xmlns:iCodAssinante', 'xmlns' => ns)&.text
356
+ tipo_serv = assinante.at_xpath('xmlns:tpServUtil', 'xmlns' => ns)&.text
357
+ contrato = assinante.at_xpath('xmlns:nContrato', 'xmlns' => ns)&.text
358
+
359
+ pdf.text_box "Código: #{cod}", at: [2.mm, y_pos - 7.mm], size: 10
360
+ pdf.text_box "Tipo Serviço: #{tipo_servico_texto(tipo_serv)}", at: [50.mm, y_pos - 7.mm], size: 10
361
+ pdf.text_box "Contrato: #{contrato}", at: [2.mm, y_pos - 12.mm], size: 10 if contrato
362
+
363
+ y_pos - 21.mm
364
+ end
365
+
366
+ def tipo_servico_texto(codigo)
367
+ tipos = {
368
+ '1' => 'Telefonia',
369
+ '2' => 'Comunicação de dados',
370
+ '3' => 'TV por Assinatura',
371
+ '4' => 'Provimento de acesso à Internet',
372
+ '5' => 'Multimídia',
373
+ '6' => 'Outros',
374
+ '7' => 'Vários'
375
+ }
376
+ tipos[codigo] || codigo
377
+ end
378
+
379
+ # ----------------------------
380
+ # FATURAMENTO
381
+ # ----------------------------
382
+ def gerar_faturamento(pdf, gfat, y_pos)
383
+ pdf.stroke_rectangle [0, y_pos], 200.mm, 22.mm
384
+ pdf.text_box 'INFORMAÇÕES DE FATURAMENTO', at: [2.mm, y_pos - 2.mm], size: 10, style: :bold
385
+
386
+ compet = gfat.at_xpath('xmlns:CompetFat', 'xmlns' => ns)&.text
387
+ venc = gfat.at_xpath('xmlns:dVencFat', 'xmlns' => ns)&.text
388
+ periodo_ini = gfat.at_xpath('xmlns:dPerUsoIni', 'xmlns' => ns)&.text
389
+ periodo_fim = gfat.at_xpath('xmlns:dPerUsoFim', 'xmlns' => ns)&.text
390
+ cod_barras = gfat.at_xpath('xmlns:codBarras', 'xmlns' => ns)&.text
391
+
392
+ pdf.text_box "Competência: #{formatar_competencia(compet)}", at: [2.mm, y_pos - 7.mm], size: 10
393
+ pdf.text_box "Vencimento: #{formatar_data_simples(venc)}", at: [50.mm, y_pos - 7.mm], size: 10
394
+
395
+ # Service period (if provided)
396
+ if periodo_ini && periodo_fim
397
+ periodo_texto = "#{formatar_data_simples(periodo_ini)} a #{formatar_data_simples(periodo_fim)}"
398
+ pdf.text_box "Período de Uso: #{periodo_texto}", at: [100.mm, y_pos - 7.mm], size: 10
399
+ end
400
+
401
+ if cod_barras
402
+ pdf.text_box 'Código de Barras:', at: [2.mm, y_pos - 13.mm], size: 10
403
+ pdf.text_box formatar_codigo_barras(cod_barras), at: [2.mm, y_pos - 18.mm], size: 8
404
+ end
405
+
406
+ y_pos - 25.mm
407
+ end
408
+
409
+ def formatar_competencia(comp)
410
+ return '' unless comp
411
+
412
+ "#{comp[4..5]}/#{comp[0..3]}"
413
+ end
414
+
415
+ def formatar_data_simples(data)
416
+ return '' unless data
417
+
418
+ Date.parse(data).strftime('%d/%m/%Y')
419
+ rescue ArgumentError
420
+ data
421
+ end
422
+
423
+ def formatar_codigo_barras(codigo)
424
+ Nfcom::Models::Fatura::CodigoDeBarras.new(codigo).linha_digitavel
425
+ rescue StandardError
426
+ ''
427
+ end
428
+
429
+ # ----------------------------
430
+ # ITENS
431
+ # ----------------------------
432
+ def gerar_itens(pdf, itens, y_pos)
433
+ y_pos -= 5.mm
434
+ pdf.move_cursor_to(y_pos)
435
+
436
+ pdf.text 'DISCRIMINAÇÃO DOS SERVIÇOS', size: 10, style: :bold
437
+ pdf.move_down 4.mm
438
+
439
+ table_data = [['Item', 'Código', 'Descrição', 'Classe', 'CFOP', 'Unid', 'Qtd', 'Vl Unit', 'Vl Total']]
440
+
441
+ itens.each do |item|
442
+ prod = item.at_xpath('xmlns:prod', 'xmlns' => ns)
443
+ table_data << [
444
+ item['nItem'],
445
+ prod.at_xpath('xmlns:cProd', 'xmlns' => ns)&.text,
446
+ prod.at_xpath('xmlns:xProd', 'xmlns' => ns)&.text,
447
+ prod.at_xpath('xmlns:cClass', 'xmlns' => ns)&.text,
448
+ prod.at_xpath('xmlns:CFOP', 'xmlns' => ns)&.text,
449
+ unidade_texto(prod.at_xpath('xmlns:uMed', 'xmlns' => ns)&.text),
450
+ formatar_numero(prod.at_xpath('xmlns:qFaturada', 'xmlns' => ns)&.text, 2),
451
+ formatar_moeda(prod.at_xpath('xmlns:vItem', 'xmlns' => ns)&.text),
452
+ formatar_moeda(prod.at_xpath('xmlns:vProd', 'xmlns' => ns)&.text)
453
+ ]
454
+ end
455
+
456
+ pdf.table(table_data, header: true, width: 200.mm,
457
+ cell_style: { size: 9, padding: [3, 4], border_width: 0.5 }) do |t|
458
+ t.row(0).font_style = :bold
459
+ t.row(0).background_color = 'EEEEEE'
460
+ t.columns(6..8).align = :right
461
+ end
462
+
463
+ pdf.cursor
464
+ end
465
+
466
+ def unidade_texto(codigo)
467
+ { '1' => 'Min', '2' => 'MB', '3' => 'GB', '4' => 'UN' }[codigo] || codigo
468
+ end
469
+
470
+ def formatar_numero(valor, decimais = 2)
471
+ return '0,00' unless valor
472
+
473
+ format("%.#{decimais}f", valor.to_f).gsub('.', ',')
474
+ end
475
+
476
+ def formatar_moeda(valor)
477
+ return 'R$ 0,00' unless valor
478
+
479
+ "R$ #{format('%.2f', valor.to_f).gsub('.', ',')}"
480
+ end
481
+
482
+ # ----------------------------
483
+ # TOTAIS
484
+ # ----------------------------
485
+ def gerar_totais(pdf, total, y_pos)
486
+ pdf.move_cursor_to(y_pos)
487
+ pdf.move_down 3.mm
488
+
489
+ icms_tot = total.at_xpath('xmlns:ICMSTot', 'xmlns' => ns)
490
+
491
+ totals_data = [
492
+ ['Base Cálculo ICMS', formatar_moeda(icms_tot&.at_xpath('xmlns:vBC', 'xmlns' => ns)&.text)],
493
+ ['Valor ICMS', formatar_moeda(icms_tot&.at_xpath('xmlns:vICMS', 'xmlns' => ns)&.text)],
494
+ ['Valor PIS', formatar_moeda(total.at_xpath('xmlns:vPIS', 'xmlns' => ns)&.text)],
495
+ ['Valor COFINS', formatar_moeda(total.at_xpath('xmlns:vCOFINS', 'xmlns' => ns)&.text)],
496
+ ['Desconto', formatar_moeda(total.at_xpath('xmlns:vDesc', 'xmlns' => ns)&.text)],
497
+ ['Outras Despesas', formatar_moeda(total.at_xpath('xmlns:vOutro', 'xmlns' => ns)&.text)],
498
+ ['VALOR TOTAL', formatar_moeda(total.at_xpath('xmlns:vNF', 'xmlns' => ns)&.text)]
499
+ ]
500
+
501
+ pdf.float do
502
+ pdf.table(totals_data, position: :right, width: 75.mm,
503
+ cell_style: { size: 10, padding: [3, 5], border_width: 0.5 },
504
+ column_widths: { 0 => 37.mm, 1 => 38.mm }) do |t|
505
+ t.column(1).align = :right
506
+ t.row(-1).font_style = :bold
507
+ t.row(-1).background_color = 'EEEEEE'
508
+ t.row(-1).size = 11
509
+ end
510
+ end
511
+
512
+ pdf.move_down 40.mm
513
+ pdf.cursor
514
+ end
515
+
516
+ # ----------------------------
517
+ # INFORMAÇÕES ADICIONAIS
518
+ # ----------------------------
519
+ def gerar_info_adicional(pdf, inf_adic)
520
+ inf_cpl = inf_adic.xpath('xmlns:infCpl', 'xmlns' => ns)
521
+ return unless inf_cpl.any?
522
+
523
+ pdf.move_down 3.mm
524
+ pdf.text 'INFORMAÇÕES COMPLEMENTARES', size: 10, style: :bold
525
+ pdf.move_down 2.mm
526
+
527
+ inf_cpl.each do |node|
528
+ text = node.text.to_s.strip
529
+ next if text.empty?
530
+
531
+ pdf.text text, size: 9
532
+ pdf.move_down 2.mm
533
+ end
534
+ end
535
+
536
+ # ----------------------------
537
+ # RODAPÉ
538
+ # ----------------------------
539
+ def gerar_rodape(pdf, ide, prot)
540
+ pdf.move_down 5.mm
541
+
542
+ c_stat = prot&.at_xpath('xmlns:cStat', 'xmlns' => ns)&.text
543
+ if c_stat == '100'
544
+ pdf.fill_color '006400'
545
+ pdf.text 'NOTA FISCAL AUTORIZADA', size: 11, style: :bold, align: :center
546
+ pdf.fill_color '000000'
547
+ elsif c_stat
548
+ pdf.fill_color 'FF0000'
549
+ pdf.text "STATUS: #{c_stat}", size: 11, style: :bold, align: :center
550
+ pdf.fill_color '000000'
551
+ end
552
+
553
+ tp_amb = ide.at_xpath('xmlns:tpAmb', 'xmlns' => ns)&.text
554
+ return unless tp_amb == '2'
555
+
556
+ pdf.move_down 3.mm
557
+ pdf.fill_color 'FF0000'
558
+ pdf.text 'DOCUMENTO EMITIDO EM AMBIENTE DE HOMOLOGAÇÃO - SEM VALOR FISCAL',
559
+ size: 10, style: :bold, align: :center
560
+ pdf.fill_color '000000'
561
+ end
562
+ end
563
+ end
564
+ end