focus_nfe 1.0.0

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 (206) hide show
  1. checksums.yaml +7 -0
  2. data/.git-hooks/pre_push/steep.rb +18 -0
  3. data/.git-hooks/pre_push/yard_doc.rb +18 -0
  4. data/.gitattributes +1 -0
  5. data/.overcommit.yml +69 -0
  6. data/.rspec +3 -0
  7. data/.yardopts +11 -0
  8. data/CHANGELOG.md +77 -0
  9. data/CLAUDE.md +118 -0
  10. data/CODE_OF_CONDUCT.md +10 -0
  11. data/LICENSE.txt +21 -0
  12. data/README.md +348 -0
  13. data/Rakefile +105 -0
  14. data/data/schemas/schema_cte.json +2793 -0
  15. data/data/schemas/schema_cte_os.json +1335 -0
  16. data/data/schemas/schema_cte_os_transporte_rodoviario.json +109 -0
  17. data/data/schemas/schema_cte_transporte_aereo.json +115 -0
  18. data/data/schemas/schema_cte_transporte_aquaviario.json +174 -0
  19. data/data/schemas/schema_cte_transporte_dutoviario.json +65 -0
  20. data/data/schemas/schema_cte_transporte_ferroviario.json +144 -0
  21. data/data/schemas/schema_cte_transporte_multimodal.json +45 -0
  22. data/data/schemas/schema_cte_transporte_rodoviario.json +78 -0
  23. data/data/schemas/schema_dce.json +549 -0
  24. data/data/schemas/schema_mdfe.json +1102 -0
  25. data/data/schemas/schema_mdfe_transporte_aereo.json +44 -0
  26. data/data/schemas/schema_mdfe_transporte_aquaviario.json +209 -0
  27. data/data/schemas/schema_mdfe_transporte_ferroviario.json +99 -0
  28. data/data/schemas/schema_mdfe_transporte_rodoviario.json +628 -0
  29. data/data/schemas/schema_nfcom.json +1859 -0
  30. data/data/schemas/schema_nfe.json +4750 -0
  31. data/data/schemas/schema_nfe_forma_pagamento.json +97 -0
  32. data/data/schemas/schema_nfe_item.json +2574 -0
  33. data/data/schemas/schema_nfgas.json +2316 -0
  34. data/data/schemas/schema_nfse_nacional.json +1847 -0
  35. data/data/schemas/schema_nfse_recebida.json +548 -0
  36. data/lib/focus_nfe/client.rb +162 -0
  37. data/lib/focus_nfe/configuration.rb +104 -0
  38. data/lib/focus_nfe/errors.rb +123 -0
  39. data/lib/focus_nfe/esquemas/campo.rb +171 -0
  40. data/lib/focus_nfe/esquemas/catalogo.rb +34 -0
  41. data/lib/focus_nfe/esquemas/decimal.rb +66 -0
  42. data/lib/focus_nfe/esquemas/esquema.rb +72 -0
  43. data/lib/focus_nfe/esquemas/validador.rb +87 -0
  44. data/lib/focus_nfe/http/adapter.rb +25 -0
  45. data/lib/focus_nfe/http/adapters/net_http.rb +99 -0
  46. data/lib/focus_nfe/http/authentication.rb +23 -0
  47. data/lib/focus_nfe/http/connection.rb +118 -0
  48. data/lib/focus_nfe/http/logging.rb +100 -0
  49. data/lib/focus_nfe/http/response.rb +75 -0
  50. data/lib/focus_nfe/modelos/documento.rb +113 -0
  51. data/lib/focus_nfe/modelos/inutilizacao.rb +75 -0
  52. data/lib/focus_nfe/modelos/pagina.rb +54 -0
  53. data/lib/focus_nfe/recursos/backups.rb +17 -0
  54. data/lib/focus_nfe/recursos/base.rb +91 -0
  55. data/lib/focus_nfe/recursos/ceps.rb +16 -0
  56. data/lib/focus_nfe/recursos/cfops.rb +16 -0
  57. data/lib/focus_nfe/recursos/cnaes.rb +16 -0
  58. data/lib/focus_nfe/recursos/cnpjs.rb +13 -0
  59. data/lib/focus_nfe/recursos/concerns/baixavel.rb +41 -0
  60. data/lib/focus_nfe/recursos/concerns/baixavel_eventos.rb +34 -0
  61. data/lib/focus_nfe/recursos/concerns/cancelavel.rb +25 -0
  62. data/lib/focus_nfe/recursos/concerns/conciliavel.rb +66 -0
  63. data/lib/focus_nfe/recursos/concerns/consultavel.rb +26 -0
  64. data/lib/focus_nfe/recursos/concerns/corrigivel.rb +45 -0
  65. data/lib/focus_nfe/recursos/concerns/corrigivel_cte.rb +60 -0
  66. data/lib/focus_nfe/recursos/concerns/emitivel.rb +51 -0
  67. data/lib/focus_nfe/recursos/concerns/enviavel.rb +40 -0
  68. data/lib/focus_nfe/recursos/concerns/eventavel.rb +46 -0
  69. data/lib/focus_nfe/recursos/concerns/inutilizavel.rb +96 -0
  70. data/lib/focus_nfe/recursos/concerns/listavel.rb +22 -0
  71. data/lib/focus_nfe/recursos/concerns/localizavel.rb +22 -0
  72. data/lib/focus_nfe/recursos/concerns/notificavel.rb +23 -0
  73. data/lib/focus_nfe/recursos/concerns/removivel.rb +20 -0
  74. data/lib/focus_nfe/recursos/concerns/visualizavel.rb +28 -0
  75. data/lib/focus_nfe/recursos/cte.rb +35 -0
  76. data/lib/focus_nfe/recursos/cte_os.rb +29 -0
  77. data/lib/focus_nfe/recursos/ctes_recebidas.rb +38 -0
  78. data/lib/focus_nfe/recursos/dce.rb +16 -0
  79. data/lib/focus_nfe/recursos/emails_bloqueados.rb +31 -0
  80. data/lib/focus_nfe/recursos/empresas.rb +35 -0
  81. data/lib/focus_nfe/recursos/mdfe.rb +78 -0
  82. data/lib/focus_nfe/recursos/municipios.rb +60 -0
  83. data/lib/focus_nfe/recursos/ncms.rb +16 -0
  84. data/lib/focus_nfe/recursos/nfce.rb +19 -0
  85. data/lib/focus_nfe/recursos/nfcom.rb +16 -0
  86. data/lib/focus_nfe/recursos/nfe.rb +106 -0
  87. data/lib/focus_nfe/recursos/nfes_recebidas.rb +56 -0
  88. data/lib/focus_nfe/recursos/nfgas.rb +16 -0
  89. data/lib/focus_nfe/recursos/nfse.rb +18 -0
  90. data/lib/focus_nfe/recursos/nfse_nacional.rb +16 -0
  91. data/lib/focus_nfe/recursos/nfses_nacionais_recebidas.rb +21 -0
  92. data/lib/focus_nfe/recursos/webhooks.rb +22 -0
  93. data/lib/focus_nfe/version.rb +6 -0
  94. data/lib/focus_nfe/webhook.rb +68 -0
  95. data/lib/focus_nfe.rb +124 -0
  96. data/sig/focus_nfe/client.rbs +38 -0
  97. data/sig/focus_nfe/configuration.rbs +29 -0
  98. data/sig/focus_nfe/errors.rbs +59 -0
  99. data/sig/focus_nfe/esquemas/campo.rbs +47 -0
  100. data/sig/focus_nfe/esquemas/catalogo.rbs +8 -0
  101. data/sig/focus_nfe/esquemas/decimal.rbs +25 -0
  102. data/sig/focus_nfe/esquemas/esquema.rbs +30 -0
  103. data/sig/focus_nfe/esquemas/validador.rbs +20 -0
  104. data/sig/focus_nfe/http/adapter.rbs +7 -0
  105. data/sig/focus_nfe/http/adapters/net_http.rbs +25 -0
  106. data/sig/focus_nfe/http/authentication.rbs +9 -0
  107. data/sig/focus_nfe/http/connection.rbs +32 -0
  108. data/sig/focus_nfe/http/logging.rbs +30 -0
  109. data/sig/focus_nfe/http/response.rbs +28 -0
  110. data/sig/focus_nfe/modelos/documento.rbs +34 -0
  111. data/sig/focus_nfe/modelos/inutilizacao.rbs +24 -0
  112. data/sig/focus_nfe/modelos/pagina.rbs +21 -0
  113. data/sig/focus_nfe/recursos/backups.rbs +7 -0
  114. data/sig/focus_nfe/recursos/base.rbs +25 -0
  115. data/sig/focus_nfe/recursos/ceps.rbs +10 -0
  116. data/sig/focus_nfe/recursos/cfops.rbs +10 -0
  117. data/sig/focus_nfe/recursos/cnaes.rbs +10 -0
  118. data/sig/focus_nfe/recursos/cnpjs.rbs +7 -0
  119. data/sig/focus_nfe/recursos/concerns/baixavel.rbs +12 -0
  120. data/sig/focus_nfe/recursos/concerns/baixavel_eventos.rbs +14 -0
  121. data/sig/focus_nfe/recursos/concerns/cancelavel.rbs +9 -0
  122. data/sig/focus_nfe/recursos/concerns/conciliavel.rbs +15 -0
  123. data/sig/focus_nfe/recursos/concerns/consultavel.rbs +9 -0
  124. data/sig/focus_nfe/recursos/concerns/corrigivel.rbs +15 -0
  125. data/sig/focus_nfe/recursos/concerns/corrigivel_cte.rbs +17 -0
  126. data/sig/focus_nfe/recursos/concerns/emitivel.rbs +14 -0
  127. data/sig/focus_nfe/recursos/concerns/enviavel.rbs +15 -0
  128. data/sig/focus_nfe/recursos/concerns/eventavel.rbs +12 -0
  129. data/sig/focus_nfe/recursos/concerns/inutilizavel.rbs +20 -0
  130. data/sig/focus_nfe/recursos/concerns/listavel.rbs +9 -0
  131. data/sig/focus_nfe/recursos/concerns/localizavel.rbs +9 -0
  132. data/sig/focus_nfe/recursos/concerns/notificavel.rbs +9 -0
  133. data/sig/focus_nfe/recursos/concerns/removivel.rbs +9 -0
  134. data/sig/focus_nfe/recursos/concerns/visualizavel.rbs +9 -0
  135. data/sig/focus_nfe/recursos/cte.rbs +17 -0
  136. data/sig/focus_nfe/recursos/cte_os.rbs +17 -0
  137. data/sig/focus_nfe/recursos/ctes_recebidas.rbs +14 -0
  138. data/sig/focus_nfe/recursos/dce.rbs +10 -0
  139. data/sig/focus_nfe/recursos/emails_bloqueados.rbs +12 -0
  140. data/sig/focus_nfe/recursos/empresas.rbs +12 -0
  141. data/sig/focus_nfe/recursos/mdfe.rbs +21 -0
  142. data/sig/focus_nfe/recursos/municipios.rbs +19 -0
  143. data/sig/focus_nfe/recursos/ncms.rbs +10 -0
  144. data/sig/focus_nfe/recursos/nfce.rbs +12 -0
  145. data/sig/focus_nfe/recursos/nfcom.rbs +10 -0
  146. data/sig/focus_nfe/recursos/nfe.rbs +23 -0
  147. data/sig/focus_nfe/recursos/nfes_recebidas.rbs +16 -0
  148. data/sig/focus_nfe/recursos/nfgas.rbs +10 -0
  149. data/sig/focus_nfe/recursos/nfse.rbs +11 -0
  150. data/sig/focus_nfe/recursos/nfse_nacional.rbs +10 -0
  151. data/sig/focus_nfe/recursos/nfses_nacionais_recebidas.rbs +11 -0
  152. data/sig/focus_nfe/recursos/webhooks.rbs +11 -0
  153. data/sig/focus_nfe/webhook.rbs +11 -0
  154. data/sig/focus_nfe.rbs +10 -0
  155. data/spec/focus_nfe/client_spec.rb +208 -0
  156. data/spec/focus_nfe/configuration_spec.rb +121 -0
  157. data/spec/focus_nfe/errors_mapping_spec.rb +68 -0
  158. data/spec/focus_nfe/errors_spec.rb +107 -0
  159. data/spec/focus_nfe/esquemas/campo_spec.rb +291 -0
  160. data/spec/focus_nfe/esquemas/decimal_spec.rb +54 -0
  161. data/spec/focus_nfe/esquemas/esquema_spec.rb +73 -0
  162. data/spec/focus_nfe/esquemas/validador_spec.rb +167 -0
  163. data/spec/focus_nfe/esquemas_spec.rb +42 -0
  164. data/spec/focus_nfe/http/adapter_spec.rb +8 -0
  165. data/spec/focus_nfe/http/adapters/net_http_spec.rb +181 -0
  166. data/spec/focus_nfe/http/authentication_spec.rb +24 -0
  167. data/spec/focus_nfe/http/connection_spec.rb +255 -0
  168. data/spec/focus_nfe/http/logging_spec.rb +83 -0
  169. data/spec/focus_nfe/http/response_spec.rb +161 -0
  170. data/spec/focus_nfe/modelos/documento_spec.rb +150 -0
  171. data/spec/focus_nfe/modelos/inutilizacao_spec.rb +91 -0
  172. data/spec/focus_nfe/modelos/pagina_spec.rb +77 -0
  173. data/spec/focus_nfe/recursos/backups_spec.rb +29 -0
  174. data/spec/focus_nfe/recursos/base_spec.rb +56 -0
  175. data/spec/focus_nfe/recursos/ceps_spec.rb +16 -0
  176. data/spec/focus_nfe/recursos/cfops_spec.rb +16 -0
  177. data/spec/focus_nfe/recursos/cnaes_spec.rb +20 -0
  178. data/spec/focus_nfe/recursos/cnpjs_spec.rb +11 -0
  179. data/spec/focus_nfe/recursos/concerns/emitivel_validacao_spec.rb +158 -0
  180. data/spec/focus_nfe/recursos/cte_os_spec.rb +9 -0
  181. data/spec/focus_nfe/recursos/cte_spec.rb +9 -0
  182. data/spec/focus_nfe/recursos/ctes_recebidas_spec.rb +56 -0
  183. data/spec/focus_nfe/recursos/dce_spec.rb +8 -0
  184. data/spec/focus_nfe/recursos/emails_bloqueados_spec.rb +29 -0
  185. data/spec/focus_nfe/recursos/empresas_spec.rb +45 -0
  186. data/spec/focus_nfe/recursos/mdfe_spec.rb +100 -0
  187. data/spec/focus_nfe/recursos/municipios_spec.rb +58 -0
  188. data/spec/focus_nfe/recursos/ncms_spec.rb +16 -0
  189. data/spec/focus_nfe/recursos/nfce_spec.rb +10 -0
  190. data/spec/focus_nfe/recursos/nfcom_spec.rb +8 -0
  191. data/spec/focus_nfe/recursos/nfe_spec.rb +262 -0
  192. data/spec/focus_nfe/recursos/nfes_recebidas_spec.rb +87 -0
  193. data/spec/focus_nfe/recursos/nfgas_spec.rb +8 -0
  194. data/spec/focus_nfe/recursos/nfse_nacional_spec.rb +8 -0
  195. data/spec/focus_nfe/recursos/nfse_spec.rb +9 -0
  196. data/spec/focus_nfe/recursos/nfses_nacionais_recebidas_spec.rb +17 -0
  197. data/spec/focus_nfe/recursos/webhooks_spec.rb +22 -0
  198. data/spec/focus_nfe/webhook_spec.rb +66 -0
  199. data/spec/focus_nfe_global_configuration_spec.rb +70 -0
  200. data/spec/focus_nfe_require_spec.rb +87 -0
  201. data/spec/focus_nfe_spec.rb +11 -0
  202. data/spec/spec_helper.rb +58 -0
  203. data/spec/support/shared_examples/recurso_fiscal.rb +445 -0
  204. data/spec/support/shared_examples/recurso_leitura.rb +217 -0
  205. data/tools/pull_fields.rb +62 -0
  206. metadata +420 -0
@@ -0,0 +1,445 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.shared_examples "um recurso emitível" do |caminho|
4
+ subject(:recurso) { described_class.new(client.connection) }
5
+
6
+ let(:client) { FocusNfe::Client.new(token_empresa: "tok", environment: environment) }
7
+ let(:environment) { :homologacao }
8
+ let(:json) { { "Content-Type" => "application/json" } }
9
+ let(:dados) { { "natureza_operacao" => "Venda" } }
10
+ let(:processando) { '{"status":"processando_autorizacao"}' }
11
+
12
+ let(:json) { { "Content-Type" => "application/json" } }
13
+
14
+ def homologacao = "https://homologacao.focusnfe.com.br"
15
+ def producao = "https://api.focusnfe.com.br"
16
+
17
+ def stub_recurso(verb, path, host: homologacao, status: 200, body: "{}")
18
+ stub_request(verb, "#{host}/v2/#{path}").to_return(status: status, body: body, headers: json)
19
+ end
20
+
21
+ describe "#emitir" do
22
+ before { stub_recurso(:post, "#{caminho}?ref=pedido-42", status: 202, body: processando) }
23
+
24
+ it "envia POST em /v2/#{caminho}?ref= com o JSON dos dados" do
25
+ recurso.emitir(ref: "pedido-42", dados: dados)
26
+
27
+ url = "#{homologacao}/v2/#{caminho}?ref=pedido-42"
28
+
29
+ expect(a_request(:post, url).with(body: JSON.generate(dados))).to have_been_made
30
+ end
31
+
32
+ it "devolve um Documento processando com a ref", :aggregate_failures do
33
+ doc = recurso.emitir(ref: "pedido-42", dados: dados)
34
+
35
+ expect(doc).to be_a(FocusNfe::Modelos::Documento)
36
+ expect(doc).to be_processando
37
+ expect(doc.ref).to eq("pedido-42")
38
+ end
39
+
40
+ context "quando o ambiente é produção" do
41
+ let(:environment) { :producao }
42
+
43
+ it "usa o host de produção" do
44
+ stub = stub_recurso(:post, "#{caminho}?ref=pedido-42", host: producao, status: 202, body: processando)
45
+
46
+ recurso.emitir(ref: "pedido-42", dados: dados)
47
+
48
+ expect(stub).to have_been_requested
49
+ end
50
+ end
51
+
52
+ it "propaga erro tipado da API (422)" do
53
+ stub_recurso(:post, "#{caminho}?ref=pedido-42", status: 422, body: '{"erros":[]}')
54
+
55
+ expect { recurso.emitir(ref: "pedido-42", dados: dados) }.to raise_error(FocusNfe::Errors::ValidationError)
56
+ end
57
+
58
+ it "rejeita ref inválida sem requisição", :aggregate_failures do
59
+ expect { recurso.emitir(ref: "pedido 42", dados: dados) }.to raise_error(ArgumentError)
60
+ expect(a_request(:post, "#{homologacao}/v2/#{caminho}")).not_to have_been_made
61
+ end
62
+ end
63
+ end
64
+
65
+ RSpec.shared_examples "um recurso consultável" do |caminho|
66
+ subject(:recurso) { described_class.new(client.connection) }
67
+
68
+ let(:client) { FocusNfe::Client.new(token_empresa: "tok", environment: :homologacao) }
69
+ let(:json) { { "Content-Type" => "application/json" } }
70
+
71
+ def homologacao = "https://homologacao.focusnfe.com.br"
72
+
73
+ def stub_recurso(verb, path, status: 200, body: "{}")
74
+ stub_request(verb, "#{homologacao}/v2/#{path}").to_return(status: status, body: body, headers: json)
75
+ end
76
+
77
+ describe "#consultar" do
78
+ it "faz GET em /v2/#{caminho}/{ref} e devolve o Documento autorizado", :aggregate_failures do
79
+ stub_recurso(:get, "#{caminho}/pedido-42", body: '{"status":"autorizado","chave_nfe":"3520"}')
80
+ doc = recurso.consultar("pedido-42")
81
+
82
+ expect(doc).to be_autorizado
83
+ expect(doc.chave_nfe).to eq("3520")
84
+ end
85
+
86
+ it "envia ?completa=1 quando completa: true" do
87
+ stub = stub_recurso(:get, "#{caminho}/pedido-42?completa=1", body: '{"status":"autorizado"}')
88
+
89
+ recurso.consultar("pedido-42", completa: true)
90
+
91
+ expect(stub).to have_been_requested
92
+ end
93
+
94
+ it "não envia completa por padrão" do
95
+ stub = stub_recurso(:get, "#{caminho}/pedido-42", body: '{"status":"autorizado"}')
96
+
97
+ recurso.consultar("pedido-42")
98
+
99
+ expect(stub).to have_been_requested
100
+ end
101
+
102
+ it "rejeita ref inválida sem requisição" do
103
+ expect { recurso.consultar("pedido 42") }.to raise_error(ArgumentError)
104
+ end
105
+ end
106
+ end
107
+
108
+ RSpec.shared_examples "um recurso visualizável" do |caminho_previa|
109
+ subject(:recurso) { described_class.new(client.connection) }
110
+
111
+ let(:client) { FocusNfe::Client.new(token_empresa: "tok", environment: environment) }
112
+ let(:environment) { :homologacao }
113
+ let(:dados) { { "natureza_operacao" => "Venda" } }
114
+ let(:pdf) { { "Content-Type" => "application/pdf" } }
115
+
116
+ def homologacao = "https://homologacao.focusnfe.com.br"
117
+ def producao = "https://api.focusnfe.com.br"
118
+
119
+ def stub_previa(caminho_previa, host: homologacao, status: 200, body: "%PDF-1.4 previa")
120
+ stub_request(:post, "#{host}/v2/#{caminho_previa}").to_return(status: status, body: body, headers: pdf)
121
+ end
122
+
123
+ describe "#previa" do
124
+ it "faz POST em /v2/#{caminho_previa} com o JSON dos dados e devolve os bytes do PDF", :aggregate_failures do
125
+ stub_previa(caminho_previa)
126
+ bytes = recurso.previa(dados: dados)
127
+
128
+ url = "#{homologacao}/v2/#{caminho_previa}"
129
+
130
+ expect(a_request(:post, url).with(body: JSON.generate(dados))).to have_been_made
131
+ expect(bytes).to eq("%PDF-1.4 previa")
132
+ end
133
+
134
+ it "não valida por padrão" do
135
+ stub_previa(caminho_previa)
136
+
137
+ recurso.previa(dados: {})
138
+
139
+ expect(a_request(:post, "#{homologacao}/v2/#{caminho_previa}")).to have_been_made
140
+ end
141
+
142
+ context "quando o ambiente é produção" do
143
+ let(:environment) { :producao }
144
+
145
+ it "usa o host de produção" do
146
+ stub = stub_previa(caminho_previa, host: producao)
147
+
148
+ recurso.previa(dados: dados)
149
+
150
+ expect(stub).to have_been_requested
151
+ end
152
+ end
153
+ end
154
+ end
155
+
156
+ RSpec.shared_examples "um recurso corrigível" do |caminho|
157
+ subject(:recurso) { described_class.new(client.connection) }
158
+
159
+ let(:client) { FocusNfe::Client.new(token_empresa: "tok", environment: environment) }
160
+ let(:environment) { :homologacao }
161
+ let(:json) { { "Content-Type" => "application/json" } }
162
+ let(:correcao) { "corrigindo o endereco de entrega do destinatario" }
163
+
164
+ def homologacao = "https://homologacao.focusnfe.com.br"
165
+ def producao = "https://api.focusnfe.com.br"
166
+
167
+ def stub_recurso(verb, path, host: homologacao, status: 200, body: "{}")
168
+ stub_request(verb, "#{host}/v2/#{path}").to_return(status: status, body: body, headers: json)
169
+ end
170
+
171
+ describe "#corrigir" do
172
+ let(:autorizada) { '{"status":"autorizado","numero_carta_correcao":"1","caminho_xml_carta_correcao":"/cce.xml"}' }
173
+
174
+ before { stub_recurso(:post, "#{caminho}/pedido-42/carta_correcao", body: autorizada) }
175
+
176
+ it "envia POST em /v2/#{caminho}/{ref}/carta_correcao só com a correção" do
177
+ recurso.corrigir("pedido-42", correcao: correcao)
178
+
179
+ url = "#{homologacao}/v2/#{caminho}/pedido-42/carta_correcao"
180
+
181
+ expect(a_request(:post, url).with(body: JSON.generate(correcao: correcao))).to have_been_made
182
+ end
183
+
184
+ it "devolve um Documento com os dados da carta de correção", :aggregate_failures do
185
+ doc = recurso.corrigir("pedido-42", correcao: correcao)
186
+
187
+ expect(doc).to be_a(FocusNfe::Modelos::Documento)
188
+ expect(doc).to be_autorizado
189
+ expect(doc.numero_carta_correcao).to eq("1")
190
+ expect(doc.caminho_xml_carta_correcao).to eq("/cce.xml")
191
+ end
192
+
193
+ it "inclui data_evento no corpo quando informado" do
194
+ recurso.corrigir("pedido-42", correcao: correcao, data_evento: "2026-06-13T10:00:00-03:00")
195
+
196
+ url = "#{homologacao}/v2/#{caminho}/pedido-42/carta_correcao"
197
+ corpo = JSON.generate(correcao: correcao, data_evento: "2026-06-13T10:00:00-03:00")
198
+
199
+ expect(a_request(:post, url).with(body: corpo)).to have_been_made
200
+ end
201
+
202
+ it "rejeita correção com menos de 15 caracteres sem requisição", :aggregate_failures do
203
+ expect { recurso.corrigir("pedido-42", correcao: "curta") }.to raise_error(ArgumentError)
204
+ expect(a_request(:post, "#{homologacao}/v2/#{caminho}/pedido-42/carta_correcao")).not_to have_been_made
205
+ end
206
+
207
+ it "rejeita correção com mais de 1000 caracteres sem requisição", :aggregate_failures do
208
+ expect { recurso.corrigir("pedido-42", correcao: "a" * 1001) }.to raise_error(ArgumentError)
209
+ expect(a_request(:post, "#{homologacao}/v2/#{caminho}/pedido-42/carta_correcao")).not_to have_been_made
210
+ end
211
+
212
+ it "rejeita ref inválida sem requisição" do
213
+ expect { recurso.corrigir("pedido 42", correcao: correcao) }.to raise_error(ArgumentError)
214
+ end
215
+
216
+ context "quando o ambiente é produção" do
217
+ let(:environment) { :producao }
218
+
219
+ it "usa o host de produção" do
220
+ stub = stub_recurso(:post, "#{caminho}/pedido-42/carta_correcao", host: producao, body: autorizada)
221
+
222
+ recurso.corrigir("pedido-42", correcao: correcao)
223
+
224
+ expect(stub).to have_been_requested
225
+ end
226
+ end
227
+ end
228
+ end
229
+
230
+ RSpec.shared_examples "um recurso corrigível por campo" do |caminho|
231
+ subject(:recurso) { described_class.new(client.connection) }
232
+
233
+ let(:client) { FocusNfe::Client.new(token_empresa: "tok", environment: environment) }
234
+ let(:environment) { :homologacao }
235
+ let(:json) { { "Content-Type" => "application/json" } }
236
+
237
+ def homologacao = "https://homologacao.focusnfe.com.br"
238
+ def producao = "https://api.focusnfe.com.br"
239
+
240
+ def stub_recurso(verb, path, host: homologacao, status: 200, body: "{}")
241
+ stub_request(verb, "#{host}/v2/#{path}").to_return(status: status, body: body, headers: json)
242
+ end
243
+
244
+ describe "#corrigir" do
245
+ let(:autorizada) { '{"status":"autorizado","numero_carta_correcao":"1","caminho_xml":"/cce.xml"}' }
246
+
247
+ before { stub_recurso(:post, "#{caminho}/pedido-42/carta_correcao", body: autorizada) }
248
+
249
+ it "envia POST em /v2/#{caminho}/{ref}/carta_correcao com o campo e o valor" do
250
+ recurso.corrigir("pedido-42", campo_corrigido: "observacoes", valor_corrigido: "Nova observação")
251
+
252
+ url = "#{homologacao}/v2/#{caminho}/pedido-42/carta_correcao"
253
+ corpo = JSON.generate(campo_corrigido: "observacoes", valor_corrigido: "Nova observação")
254
+
255
+ expect(a_request(:post, url).with(body: corpo)).to have_been_made
256
+ end
257
+
258
+ it "devolve um Documento com os dados da carta de correção", :aggregate_failures do
259
+ doc = recurso.corrigir("pedido-42", campo_corrigido: "observacoes", valor_corrigido: "Nova observação")
260
+
261
+ expect(doc).to be_a(FocusNfe::Modelos::Documento)
262
+ expect(doc).to be_autorizado
263
+ expect(doc.numero_carta_correcao).to eq("1")
264
+ end
265
+
266
+ it "inclui grupo, número do item e campo_api no corpo quando informados" do
267
+ opcionais = { grupo_corrigido: "cargas", numero_item_grupo_corrigido: "1", campo_api: 0 }
268
+ recurso.corrigir("pedido-42", campo_corrigido: "peso", valor_corrigido: "1000", **opcionais)
269
+
270
+ url = "#{homologacao}/v2/#{caminho}/pedido-42/carta_correcao"
271
+ corpo = JSON.generate(campo_corrigido: "peso", valor_corrigido: "1000", **opcionais)
272
+
273
+ expect(a_request(:post, url).with(body: corpo)).to have_been_made
274
+ end
275
+
276
+ it "rejeita campo_corrigido vazio sem requisição", :aggregate_failures do
277
+ expect { recurso.corrigir("pedido-42", campo_corrigido: "", valor_corrigido: "x") }.to raise_error(ArgumentError)
278
+ expect(a_request(:post, "#{homologacao}/v2/#{caminho}/pedido-42/carta_correcao")).not_to have_been_made
279
+ end
280
+
281
+ it "rejeita valor_corrigido vazio sem requisição", :aggregate_failures do
282
+ expect do
283
+ recurso.corrigir("pedido-42", campo_corrigido: "peso", valor_corrigido: "")
284
+ end.to raise_error(ArgumentError)
285
+ expect(a_request(:post, "#{homologacao}/v2/#{caminho}/pedido-42/carta_correcao")).not_to have_been_made
286
+ end
287
+
288
+ it "rejeita ref inválida sem requisição" do
289
+ expect do
290
+ recurso.corrigir("pedido 42", campo_corrigido: "peso", valor_corrigido: "1000")
291
+ end.to raise_error(ArgumentError)
292
+ end
293
+
294
+ context "quando o ambiente é produção" do
295
+ let(:environment) { :producao }
296
+
297
+ it "usa o host de produção" do
298
+ stub = stub_recurso(:post, "#{caminho}/pedido-42/carta_correcao", host: producao, body: autorizada)
299
+
300
+ recurso.corrigir("pedido-42", campo_corrigido: "observacoes", valor_corrigido: "Nova observação")
301
+
302
+ expect(stub).to have_been_requested
303
+ end
304
+ end
305
+ end
306
+ end
307
+
308
+ RSpec.shared_examples "um recurso inutilizável" do |caminho|
309
+ subject(:recurso) { described_class.new(client.connection) }
310
+
311
+ let(:client) { FocusNfe::Client.new(token_empresa: "tok", environment: environment) }
312
+ let(:environment) { :homologacao }
313
+ let(:json) { { "Content-Type" => "application/json" } }
314
+ let(:dados) do
315
+ { cnpj: "12345678000190", serie: "1", numero_inicial: "10", numero_final: "20",
316
+ justificativa: "erro de digitacao no sistema" }
317
+ end
318
+
319
+ def homologacao = "https://homologacao.focusnfe.com.br"
320
+ def producao = "https://api.focusnfe.com.br"
321
+
322
+ def stub_recurso(verb, path, host: homologacao, status: 200, body: "{}")
323
+ stub_request(verb, "#{host}/v2/#{path}").to_return(status: status, body: body, headers: json)
324
+ end
325
+
326
+ describe "#inutilizar" do
327
+ let(:autorizada) { '{"status":"autorizado","protocolo_sefaz":"135200"}' }
328
+
329
+ before { stub_recurso(:post, "#{caminho}/inutilizacao", body: autorizada) }
330
+
331
+ it "envia POST em /v2/#{caminho}/inutilizacao com o JSON dos campos" do
332
+ recurso.inutilizar(**dados)
333
+
334
+ url = "#{homologacao}/v2/#{caminho}/inutilizacao"
335
+
336
+ expect(a_request(:post, url).with(body: JSON.generate(dados))).to have_been_made
337
+ end
338
+
339
+ it "devolve uma Inutilizacao autorizada com o protocolo", :aggregate_failures do
340
+ inut = recurso.inutilizar(**dados)
341
+
342
+ expect(inut).to be_a(FocusNfe::Modelos::Inutilizacao)
343
+ expect(inut).to be_autorizado
344
+ expect(inut.protocolo).to eq("135200")
345
+ end
346
+
347
+ it "rejeita justificativa com menos de 15 caracteres sem requisição", :aggregate_failures do
348
+ expect { recurso.inutilizar(**dados, justificativa: "curta") }.to raise_error(ArgumentError)
349
+ expect(a_request(:post, "#{homologacao}/v2/#{caminho}/inutilizacao")).not_to have_been_made
350
+ end
351
+
352
+ it "rejeita numero_inicial maior que numero_final sem requisição", :aggregate_failures do
353
+ expect { recurso.inutilizar(**dados, numero_inicial: "20", numero_final: "10") }.to raise_error(ArgumentError)
354
+ expect(a_request(:post, "#{homologacao}/v2/#{caminho}/inutilizacao")).not_to have_been_made
355
+ end
356
+
357
+ it "aceita faixa de um único número (inicial igual a final)" do
358
+ recurso.inutilizar(**dados, numero_inicial: "10", numero_final: "10")
359
+
360
+ expect(a_request(:post, "#{homologacao}/v2/#{caminho}/inutilizacao")).to have_been_made
361
+ end
362
+
363
+ it "aceita numero_inicial e numero_final como inteiros" do
364
+ recurso.inutilizar(**dados, numero_inicial: 10, numero_final: 20)
365
+
366
+ expect(a_request(:post, "#{homologacao}/v2/#{caminho}/inutilizacao")).to have_been_made
367
+ end
368
+
369
+ it "rejeita numero_inicial ausente sem requisição", :aggregate_failures do
370
+ expect { recurso.inutilizar(**dados, numero_inicial: nil) }.to raise_error(ArgumentError)
371
+ expect(a_request(:post, "#{homologacao}/v2/#{caminho}/inutilizacao")).not_to have_been_made
372
+ end
373
+
374
+ it "rejeita numero_final não numérico sem requisição", :aggregate_failures do
375
+ expect { recurso.inutilizar(**dados, numero_final: "abc") }.to raise_error(ArgumentError)
376
+ expect(a_request(:post, "#{homologacao}/v2/#{caminho}/inutilizacao")).not_to have_been_made
377
+ end
378
+
379
+ context "quando o ambiente é produção" do
380
+ let(:environment) { :producao }
381
+
382
+ it "usa o host de produção" do
383
+ stub = stub_recurso(:post, "#{caminho}/inutilizacao", host: producao, body: autorizada)
384
+
385
+ recurso.inutilizar(**dados)
386
+
387
+ expect(stub).to have_been_requested
388
+ end
389
+ end
390
+ end
391
+
392
+ describe "#consultar_inutilizacoes" do
393
+ it "faz GET em /v2/#{caminho}/inutilizacoes e devolve Inutilizacoes", :aggregate_failures do
394
+ corpo = '[{"status":"autorizado","protocolo_sefaz":"1"},{"status":"autorizado","protocolo_sefaz":"2"}]'
395
+ stub_recurso(:get, "#{caminho}/inutilizacoes", body: corpo)
396
+
397
+ lista = recurso.consultar_inutilizacoes
398
+
399
+ expect(lista.map(&:protocolo)).to eq(%w[1 2])
400
+ expect(lista).to all(be_a(FocusNfe::Modelos::Inutilizacao))
401
+ end
402
+
403
+ it "envia os filtros como query string" do
404
+ stub = stub_recurso(:get, "#{caminho}/inutilizacoes?cnpj=123&serie=1", body: "[]")
405
+
406
+ recurso.consultar_inutilizacoes(cnpj: "123", serie: "1")
407
+
408
+ expect(stub).to have_been_requested
409
+ end
410
+
411
+ it "devolve lista vazia quando o corpo não é um array" do
412
+ stub_recurso(:get, "#{caminho}/inutilizacoes", body: "{}")
413
+
414
+ expect(recurso.consultar_inutilizacoes).to eq([])
415
+ end
416
+ end
417
+ end
418
+
419
+ RSpec.shared_examples "um recurso cancelável" do |caminho|
420
+ subject(:recurso) { described_class.new(client.connection) }
421
+
422
+ let(:client) { FocusNfe::Client.new(token_empresa: "tok", environment: :homologacao) }
423
+ let(:json) { { "Content-Type" => "application/json" } }
424
+
425
+ def homologacao = "https://homologacao.focusnfe.com.br"
426
+
427
+ def stub_recurso(verb, path, status: 200, body: "{}")
428
+ stub_request(verb, "#{homologacao}/v2/#{path}").to_return(status: status, body: body, headers: json)
429
+ end
430
+
431
+ describe "#cancelar" do
432
+ it "faz DELETE em /v2/#{caminho}/{ref} com a justificativa no corpo", :aggregate_failures do
433
+ stub_recurso(:delete, "#{caminho}/pedido-42", body: '{"status":"cancelado"}')
434
+ doc = recurso.cancelar("pedido-42", justificativa: "erro")
435
+ enviado = a_request(:delete, "#{homologacao}/v2/#{caminho}/pedido-42").with(body: '{"justificativa":"erro"}')
436
+
437
+ expect(doc).to be_cancelado
438
+ expect(enviado).to have_been_made
439
+ end
440
+
441
+ it "rejeita ref inválida sem requisição" do
442
+ expect { recurso.cancelar("pedido 42", justificativa: "x") }.to raise_error(ArgumentError)
443
+ end
444
+ end
445
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.shared_context "com recurso conectado" do
4
+ subject(:recurso) { described_class.new(client.connection) }
5
+
6
+ let(:client) { FocusNfe::Client.new(token_empresa: "tok", environment: :homologacao) }
7
+ let(:json) { { "Content-Type" => "application/json" } }
8
+
9
+ def homologacao = "https://homologacao.focusnfe.com.br"
10
+
11
+ def stub_get(path, query: nil, status: 200, body: "{}", headers: nil)
12
+ stub = stub_request(:get, "#{homologacao}/v2/#{path}")
13
+ stub = stub.with(query: query) if query
14
+ stub.to_return(status: status, body: body, headers: headers || json)
15
+ end
16
+
17
+ def stub_envio(verbo, path, query: nil, body: nil, resposta: "{}")
18
+ stub = stub_request(verbo, "#{homologacao}/v2/#{path}")
19
+ stub = stub.with(query: query) if query
20
+ stub = stub.with(body: body) if body
21
+ stub.to_return(status: 200, body: resposta, headers: json)
22
+ end
23
+ end
24
+
25
+ RSpec.shared_examples "um recurso listável" do |caminho|
26
+ include_context "com recurso conectado"
27
+
28
+ describe "#listar" do
29
+ it "faz GET em /v2/#{caminho} e devolve uma Pagina" do
30
+ stub_get(caminho, body: "[]")
31
+
32
+ expect(recurso.listar).to be_a(FocusNfe::Modelos::Pagina)
33
+ end
34
+
35
+ it "repassa os filtros como query string" do
36
+ stub = stub_get(caminho, query: { cnpj: "123" }, body: "[]")
37
+
38
+ recurso.listar(cnpj: "123")
39
+
40
+ expect(stub).to have_been_requested
41
+ end
42
+
43
+ it "expõe total e versao_maxima dos headers", :aggregate_failures do
44
+ headers = json.merge("X-Total-Count" => "2", "X-Max-Version" => "7")
45
+ stub_get(caminho, body: "[]", headers: headers)
46
+
47
+ expect(recurso.listar).to have_attributes(total: 2, versao_maxima: 7)
48
+ end
49
+ end
50
+ end
51
+
52
+ RSpec.shared_examples "um recurso baixável" do |caminho|
53
+ include_context "com recurso conectado"
54
+
55
+ describe "#download" do
56
+ it "baixa o PDF seguindo o 302 para a URL pré-assinada" do
57
+ origem = "#{homologacao}/v2/#{caminho}/CHAVE.pdf"
58
+ destino = "https://arquivos.focusnfe.com.br/danfe.pdf"
59
+ stub_request(:get, origem).to_return(status: 302, headers: { "Location" => destino })
60
+ stub_request(:get, destino).to_return(status: 200, body: "%PDF")
61
+
62
+ expect(recurso.download_pdf("CHAVE")).to eq("%PDF")
63
+ end
64
+
65
+ it "baixa o XML cru" do
66
+ stub_get("#{caminho}/CHAVE.xml", body: "<nfe/>", headers: { "Content-Type" => "application/xml" })
67
+
68
+ expect(recurso.download_xml("CHAVE")).to eq("<nfe/>")
69
+ end
70
+
71
+ it "baixa o JSON cru, sem parsear (raw_body)" do
72
+ stub_get("#{caminho}/CHAVE.json", body: '{"a":1}')
73
+
74
+ expect(recurso.download_json("CHAVE")).to eq('{"a":1}')
75
+ end
76
+
77
+ it "escapa o formato, sem injetar query no path" do
78
+ stub_request(:get, /focusnfe/).to_return(status: 200, body: "ok", headers: json)
79
+
80
+ recurso.download("CHAVE", formato: "pdf?x=1")
81
+
82
+ enviado = a_request(:get, /focusnfe/)
83
+ .with { |req| req.uri.query.nil? && req.uri.path.include?("CHAVE.pdf%3F") }
84
+ expect(enviado).to have_been_made
85
+ end
86
+ end
87
+ end
88
+
89
+ RSpec.shared_examples "um recurso localizável" do |caminho|
90
+ include_context "com recurso conectado"
91
+
92
+ describe "#consultar" do
93
+ it "faz GET em /v2/#{caminho}/{id} e devolve o corpo cru" do
94
+ stub_get("#{caminho}/123", body: '{"codigo":"123"}')
95
+
96
+ expect(recurso.consultar("123")).to eq("codigo" => "123")
97
+ end
98
+ end
99
+ end
100
+
101
+ RSpec.shared_examples "um recurso notificável" do |caminho|
102
+ include_context "com recurso conectado"
103
+
104
+ describe "#reenviar_hook" do
105
+ it "faz POST em /v2/#{caminho}/{id}/hook" do
106
+ stub = stub_envio(:post, "#{caminho}/123/hook")
107
+
108
+ recurso.reenviar_hook("123")
109
+
110
+ expect(stub).to have_been_requested
111
+ end
112
+
113
+ it "rejeita ref inválida sem requisição" do
114
+ expect { recurso.reenviar_hook("pedido 42") }.to raise_error(ArgumentError)
115
+ end
116
+ end
117
+ end
118
+
119
+ RSpec.shared_examples "um recurso enviável por email" do |caminho|
120
+ include_context "com recurso conectado"
121
+
122
+ describe "#enviar_email" do
123
+ it "faz POST em /v2/#{caminho}/{ref}/email com a lista de emails" do
124
+ stub = stub_envio(:post, "#{caminho}/pedido-42/email", body: { emails: ["a@x.com", "b@x.com"] })
125
+
126
+ recurso.enviar_email("pedido-42", emails: ["a@x.com", "b@x.com"])
127
+
128
+ expect(stub).to have_been_requested
129
+ end
130
+
131
+ it "devolve o corpo cru da resposta" do
132
+ stub_envio(:post, "#{caminho}/pedido-42/email", resposta: '{"status":"enviado"}')
133
+
134
+ expect(recurso.enviar_email("pedido-42", emails: ["a@x.com"])).to eq("status" => "enviado")
135
+ end
136
+
137
+ it "rejeita ref inválida sem requisição" do
138
+ expect { recurso.enviar_email("pedido 42", emails: ["a@x.com"]) }.to raise_error(ArgumentError)
139
+ end
140
+
141
+ it "rejeita lista de emails vazia sem requisição", :aggregate_failures do
142
+ expect { recurso.enviar_email("pedido-42", emails: []) }.to raise_error(ArgumentError)
143
+ expect(a_request(:post, "#{homologacao}/v2/#{caminho}/pedido-42/email")).not_to have_been_made
144
+ end
145
+
146
+ it "rejeita mais de 10 emails sem requisição", :aggregate_failures do
147
+ emails = Array.new(11) { |i| "e#{i}@x.com" }
148
+
149
+ expect { recurso.enviar_email("pedido-42", emails: emails) }.to raise_error(ArgumentError)
150
+ expect(a_request(:post, "#{homologacao}/v2/#{caminho}/pedido-42/email")).not_to have_been_made
151
+ end
152
+ end
153
+ end
154
+
155
+ RSpec.shared_examples "um recurso conciliável" do |caminho|
156
+ include_context "com recurso conectado"
157
+
158
+ let(:detalhes) { [{ "forma_pagamento" => "01", "valor_pagamento" => 1, "data_pagamento" => "2025-02-10" }] }
159
+
160
+ describe "#registrar_econf" do
161
+ it "faz POST em /v2/#{caminho}/{ref}/econf com detalhes_pagamento", :aggregate_failures do
162
+ stub_envio(:post, "#{caminho}/pedido-42/econf", body: { detalhes_pagamento: detalhes })
163
+
164
+ doc = recurso.registrar_econf("pedido-42", detalhes_pagamento: detalhes)
165
+
166
+ expect(a_request(:post, "#{homologacao}/v2/#{caminho}/pedido-42/econf")
167
+ .with(body: JSON.generate(detalhes_pagamento: detalhes))).to have_been_made
168
+ expect(doc).to be_a(FocusNfe::Modelos::Documento)
169
+ end
170
+
171
+ it "rejeita ref inválida sem requisição" do
172
+ expect { recurso.registrar_econf("pedido 42", detalhes_pagamento: detalhes) }.to raise_error(ArgumentError)
173
+ end
174
+ end
175
+
176
+ describe "#consultar_econf" do
177
+ it "faz GET em /v2/#{caminho}/{ref}/econf/{protocolo}" do
178
+ stub = stub_get("#{caminho}/pedido-42/econf/335250000000445")
179
+
180
+ recurso.consultar_econf("pedido-42", "335250000000445")
181
+
182
+ expect(stub).to have_been_requested
183
+ end
184
+
185
+ it "rejeita ref inválida sem requisição" do
186
+ expect { recurso.consultar_econf("pedido 42", "1") }.to raise_error(ArgumentError)
187
+ end
188
+ end
189
+
190
+ describe "#cancelar_econf" do
191
+ it "faz DELETE em /v2/#{caminho}/{ref}/econf/{protocolo}" do
192
+ stub = stub_envio(:delete, "#{caminho}/pedido-42/econf/335250000000445")
193
+
194
+ recurso.cancelar_econf("pedido-42", "335250000000445")
195
+
196
+ expect(stub).to have_been_requested
197
+ end
198
+
199
+ it "rejeita ref inválida sem requisição" do
200
+ expect { recurso.cancelar_econf("pedido 42", "1") }.to raise_error(ArgumentError)
201
+ end
202
+ end
203
+ end
204
+
205
+ RSpec.shared_examples "um recurso removível" do |caminho|
206
+ include_context "com recurso conectado"
207
+
208
+ describe "#excluir" do
209
+ it "faz DELETE em /v2/#{caminho}/{id}" do
210
+ stub = stub_envio(:delete, "#{caminho}/123")
211
+
212
+ recurso.excluir("123")
213
+
214
+ expect(stub).to have_been_requested
215
+ end
216
+ end
217
+ end