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
data/README.md ADDED
@@ -0,0 +1,348 @@
1
+ # FocusNfe
2
+
3
+ Cliente Ruby **não-oficial** para a API da [Focus NFe](https://focusnfe.com.br) —
4
+ serviço brasileiro de emissão de documentos fiscais eletrônicos (NFe, NFCe, NFSe,
5
+ CTe, MDFe, NFCom, DCe e outros).
6
+
7
+ > ⚠️ **Não-oficial.** Esta gem não tem vínculo com a Focus NFe. A autoridade
8
+ > final sobre campos, regras e validações fiscais é sempre a API da Focus/SEFAZ.
9
+
10
+ A gem é uma camada fina sobre HTTP: transporta JSON, autentica, trata os status
11
+ HTTP em **erros tipados** e devolve objetos Ruby úteis. Não reimplementa regras
12
+ fiscais. Não tem dependências de runtime (usa apenas a stdlib).
13
+
14
+ Cobre:
15
+
16
+ - **Documentos emitidos** — `nfe`, `nfce`, `nfse`, `nfse_nacional`, `cte`,
17
+ `cte_os`, `mdfe`, `nfcom`, `dce`, `nfgas`.
18
+ - **Documentos recebidos** — `nfes_recebidas`, `ctes_recebidas`,
19
+ `nfses_nacionais_recebidas` (listagem com sincronização incremental,
20
+ consulta, downloads, manifestação e eventos).
21
+ - **APIs auxiliares** (somente leitura, autenticadas pelo **token da conta**) —
22
+ `ceps`, `municipios`, `cfops`, `cnaes`, `ncms`, `cnpjs`.
23
+ - **APIs de gestão** — `empresas` (token da conta); `webhooks`,
24
+ `emails_bloqueados`, `backups` (token da empresa).
25
+
26
+ ## Instalação
27
+
28
+ Adicione a gem ao `Gemfile` da aplicação:
29
+
30
+ ```bash
31
+ bundle add focus_nfe
32
+ ```
33
+
34
+ Ou instale diretamente:
35
+
36
+ ```bash
37
+ gem install focus_nfe
38
+ ```
39
+
40
+ ## Configuração
41
+
42
+ ### Os dois tokens da Focus NFe
43
+
44
+ A API usa **dois tokens distintos**, e a gem os separa:
45
+
46
+ - **`token_empresa`** — identifica a empresa que emite/consulta o documento.
47
+ Autentica todos os documentos (`nfe`, `nfce`, …, e as recebidas) e as APIs
48
+ de gestão por empresa (`webhooks`, `emails_bloqueados`, `backups`).
49
+ - **`token_conta`** — token da conta. Autentica as consultas auxiliares (`ceps`,
50
+ `municipios`, `cfops`, `cnaes`, `ncms`, `cnpjs`) e a gestão de empresas
51
+ (`empresas`).
52
+
53
+ Configure só o que for usar: um cliente só com `token_empresa` emite documentos;
54
+ acessar um recurso de conta sem `token_conta` levanta `ConfigurationError` (e
55
+ vice-versa), antes de qualquer ida à rede.
56
+
57
+ Há dois modos de uso, que coexistem.
58
+
59
+ ### Global — para aplicações de uma empresa só
60
+
61
+ ```ruby
62
+ FocusNfe.configure do |config|
63
+ config.token_empresa = ENV["FOCUS_NFE_TOKEN_EMPRESA"]
64
+ config.token_conta = ENV["FOCUS_NFE_TOKEN_CONTA"] # opcional (consultas auxiliares/empresas)
65
+ config.environment = :producao # ou :homologacao (padrão)
66
+ config.timeout = 30
67
+ config.logger = Rails.logger
68
+ end
69
+
70
+ client = FocusNfe.client # usa a config global
71
+ ```
72
+
73
+ ### Explícito — várias empresas no mesmo processo
74
+
75
+ O `token_empresa` é por empresa; cada `Client` carrega seus próprios tokens e
76
+ ambiente, sem estado compartilhado. O `token_conta`, quando usado, é o mesmo da
77
+ conta que agrupa as empresas.
78
+
79
+ ```ruby
80
+ loja = FocusNfe::Client.new(token_empresa: "TOKEN_LOJA", environment: :producao)
81
+ filial = FocusNfe::Client.new(token_empresa: "TOKEN_FILIAL", environment: :homologacao)
82
+
83
+ # Consultas auxiliares e gestão de empresas usam o token da conta:
84
+ conta = FocusNfe::Client.new(token_conta: "TOKEN_CONTA", environment: :producao)
85
+ conta.cnpjs.consultar("12345678000123")
86
+ conta.empresas.criar(dados: dados_empresa, dry_run: true)
87
+ ```
88
+
89
+ O ambiente resolve a URL base (o prefixo `/v2` é interno):
90
+
91
+ - `:producao` → `https://api.focusnfe.com.br`
92
+ - `:homologacao` → `https://homologacao.focusnfe.com.br`
93
+
94
+ ### Logger
95
+
96
+ Logging é opt-in: por padrão `config.logger` é `nil` e nada é emitido. Plugue qualquer
97
+ logger compatível com o `Logger` da stdlib (responde a `debug`/`info`/`warn`/`error`),
98
+ como `Rails.logger` ou `Logger.new($stdout)`:
99
+
100
+ ```ruby
101
+ config.logger = Logger.new($stdout)
102
+ ```
103
+
104
+ A gem registra cada requisição (`debug`), resposta (`info`/`warn`) e falha (`error`). O
105
+ `Authorization` é sempre redigido (`[FILTERED]`) e o corpo da requisição nunca é logado —
106
+ dados sensíveis não vazam.
107
+
108
+ ## Uso
109
+
110
+ ### Emissão e ciclo assíncrono
111
+
112
+ A emissão é assíncrona na maioria dos documentos. A `ref` é a referência única do
113
+ documento na sua aplicação (validada client-side como alfanumérica antes do
114
+ envio). As respostas de emissão e consulta são encapsuladas em
115
+ `FocusNfe::Modelos::Documento`.
116
+
117
+ ```ruby
118
+ doc = client.nfe.emitir(ref: "pedido-42", dados: payload_nfe)
119
+ doc.status # => "processando_autorizacao"
120
+ doc.processando? # => true
121
+ doc.ref # => "pedido-42"
122
+
123
+ # Acompanhamento por polling (ou via webhooks — ver Gestão).
124
+ doc = client.nfe.consultar("pedido-42")
125
+ if doc.autorizado?
126
+ doc.chave_nfe
127
+ doc.caminho_xml_nota_fiscal
128
+ doc.caminho_danfe
129
+ elsif doc.erro?
130
+ doc.status_sefaz
131
+ doc.mensagem_sefaz
132
+ end
133
+ ```
134
+
135
+ Predicados de status disponíveis: `autorizado?`, `cancelado?`, `processando?`,
136
+ `erro?`, `denegado?`. Campos não mapeados continuam acessíveis via `doc["campo"]`
137
+ ou `doc.dados`.
138
+
139
+ A NFC-e é **síncrona** — o resultado já vem na própria chamada de emissão:
140
+
141
+ ```ruby
142
+ nota = client.nfce.emitir(ref: "venda-1001", dados: payload_nfce)
143
+ nota.autorizado? # => true/false na mesma chamada
144
+ ```
145
+
146
+ ### Cancelamento
147
+
148
+ ```ruby
149
+ client.nfe.cancelar("pedido-42", justificativa: "Cliente desistiu da compra.")
150
+ ```
151
+
152
+ ### Documentos recebidos e sincronização incremental
153
+
154
+ `listar` devolve uma `FocusNfe::Modelos::Pagina` (enumerável). O cabeçalho
155
+ `X-Max-Version` é exposto em `versao_maxima`, para retomar a sincronização do
156
+ ponto onde parou.
157
+
158
+ ```ruby
159
+ pagina = client.nfes_recebidas.listar(cnpj: "12345678000123", versao: ultima_versao)
160
+ pagina.cada { |nfe| processar(nfe) }
161
+ proxima_versao = pagina.versao_maxima
162
+
163
+ # Consulta, downloads e manifestação do destinatário:
164
+ client.nfes_recebidas.consultar(chave, completa: true)
165
+ xml = client.nfes_recebidas.download_xml(chave)
166
+ pdf = client.nfes_recebidas.download_pdf(chave)
167
+ client.nfes_recebidas.manifestar(chave, tipo: "confirmacao")
168
+ ```
169
+
170
+ ### APIs auxiliares
171
+
172
+ Autenticadas pelo `token_conta` (ver [Configuração](#configuração)):
173
+
174
+ ```ruby
175
+ client.ceps.consultar("69909032")
176
+ client.cnpjs.consultar("12345678000123")
177
+ client.ncms.consultar("01012100")
178
+ ```
179
+
180
+ ### APIs de gestão
181
+
182
+ ```ruby
183
+ # Cadastro de empresa (apenas produção); dry_run valida sem persistir.
184
+ client.empresas.criar(dados: dados_empresa, dry_run: true)
185
+
186
+ # Webhooks (a gem registra o gatilho e processa a chamada de volta).
187
+ client.webhooks.criar(dados: {
188
+ event: "nfe",
189
+ url: "https://meu.app/hooks/nfe",
190
+ cnpj: "12345678000123",
191
+ authorization_header: "X-Focus-Authorization", # header que a Focus enviará no callback
192
+ authorization: "um-segredo-forte" # valor esperado nesse header
193
+ })
194
+ ```
195
+
196
+ ### Recebendo webhooks (inbound)
197
+
198
+ Quando a Focus muda o status de um documento, ela chama a URL cadastrada. A gem
199
+ converte o corpo recebido no mesmo `Modelos::Documento` de emissão/consulta e
200
+ autentica a chamada comparando o header com o `authorization` do gatilho:
201
+
202
+ ```ruby
203
+ # Em um controller Rails:
204
+ def focus_callback
205
+ autenticado = FocusNfe::Webhook.autenticado?(
206
+ headers: request.headers,
207
+ authorization: ENV.fetch("FOCUS_WEBHOOK_AUTH"),
208
+ authorization_header: "X-Focus-Authorization"
209
+ )
210
+ return head(:unauthorized) unless autenticado
211
+
212
+ documento = FocusNfe::Webhook.parse(request.raw_post)
213
+ AtualizaNota.call(ref: documento.ref) if documento.autorizado?
214
+ head :ok
215
+ end
216
+ ```
217
+
218
+ `parse` aceita a String crua ou um `Hash` já parseado e levanta
219
+ `FocusNfe::Errors::WebhookError` se o corpo não for JSON válido.
220
+
221
+ ## Erros tipados
222
+
223
+ Cada faixa de status HTTP vira uma exceção específica, todas descendentes de
224
+ `FocusNfe::Error`. Cada exceção carrega `status`, `body` (mensagens da API) e a
225
+ `response` original.
226
+
227
+ | Status | Exceção | Significado |
228
+ | ------ | ----------------------------------- | -------------------------------------- |
229
+ | 400 | `FocusNfe::Errors::BadRequest` | Requisição malformada |
230
+ | 401 | `FocusNfe::Errors::Unauthorized` | Token ausente ou inválido |
231
+ | 403 | `FocusNfe::Errors::Forbidden` | Sem permissão |
232
+ | 404 | `FocusNfe::Errors::NotFound` | Recurso inexistente |
233
+ | 409 | `FocusNfe::Errors::Conflict` | Conflito de estado (ex.: `ref` em uso) |
234
+ | 422 | `FocusNfe::Errors::ValidationError` | Erro de validação dos campos |
235
+ | 429 | `FocusNfe::Errors::RateLimited` | Limite de requisições excedido |
236
+ | 5xx | `FocusNfe::Errors::ServerError` | Falha no servidor da Focus/SEFAZ |
237
+
238
+ ```ruby
239
+ begin
240
+ client.nfe.emitir(ref: "pedido-42", dados: payload)
241
+ rescue FocusNfe::Errors::ValidationError => e
242
+ e.status # => 422
243
+ e.body # => mensagens de erro da API
244
+ rescue FocusNfe::Error => e
245
+ # captura qualquer falha da gem
246
+ end
247
+ ```
248
+
249
+ Há ainda `ConfigurationError` (token/ambiente inválidos, client-side) e
250
+ `ConnectionError` (timeout, conexão recusada, excesso de redirects).
251
+
252
+ ## Validação opt-in por schemas
253
+
254
+ Os campos de emissão derivam dos schemas documentados em
255
+ `campos.focusnfe.com.br` (empacotados em `data/schemas/`). A validação
256
+ client-side é **opcional e desligada por padrão** — a Focus é a autoridade final
257
+ e os campos mudam (ex.: Reforma Tributária em transição).
258
+
259
+ ```ruby
260
+ client.nfe.emitir(ref: "pedido-42", dados: payload, validar: true)
261
+ # => levanta FocusNfe::Esquemas::ErroDeValidacao se faltar obrigatório
262
+ # ou o tipo/tamanho de um campo escalar não bater.
263
+ ```
264
+
265
+ A validação é **recursiva**: campos de coleção (`Coleção[...]`, como `itens`) têm
266
+ cada item validado contra o schema da coleção, em qualquer profundidade. Os erros
267
+ vêm com o caminho até o campo — a posição do item é base 1:
268
+
269
+ ```ruby
270
+ payload = {
271
+ natureza_operacao: "Venda",
272
+ itens: [
273
+ { numero_item: 1, descricao: "Produto A" },
274
+ { numero_item: 2 } # falta a descrição obrigatória
275
+ ]
276
+ }
277
+
278
+ begin
279
+ client.nfe.emitir(ref: "pedido-42", dados: payload, validar: true)
280
+ rescue FocusNfe::Esquemas::ErroDeValidacao => e
281
+ e.erros # => ["itens[2].descricao: campo obrigatório ausente", ...]
282
+ end
283
+ ```
284
+
285
+ Documentos sem schema próprio são emitidos sem validar (pulam silenciosamente).
286
+
287
+ ### Introspecção dos schemas
288
+
289
+ Os mesmos schemas empacotados ficam acessíveis como dado, para você (ou uma
290
+ ferramenta automatizada) descobrir quais campos e tipos um documento aceita — sem
291
+ token nem conexão:
292
+
293
+ ```ruby
294
+ FocusNfe::Esquemas.disponiveis
295
+ # => ["cte", "cte_os", "dce", "mdfe", "nfcom", "nfe", "nfe_item", "nfgas", ...]
296
+
297
+ FocusNfe::Esquemas.descrever("nfe")
298
+ # => [
299
+ # { nome: "natureza_operacao", descricao: "Descrição da natureza de operação.",
300
+ # tipo: :string, tipo_bruto: "String[1-60]", obrigatorio: true,
301
+ # tamanho_minimo: 1, tamanho_maximo: 60, enum: nil, tag: "natOp", colecao: nil },
302
+ # ...
303
+ # ]
304
+ # => nil para documento sem schema
305
+ ```
306
+
307
+ Cada campo vira um `Hash` serializável. Campos de coleção (`Coleção[...]`) aninham
308
+ a descrição dos subcampos em `:colecao`, em qualquer profundidade; enums trazem os
309
+ valores aceitos em `:enum`. `disponiveis` também lista os sub-schemas auxiliares
310
+ (`nfe_item`, `cte_transporte_aereo`, …), que igualmente podem ser descritos.
311
+
312
+ ## Desenvolvimento
313
+
314
+ Após clonar o repositório, rode `bin/setup`: ele instala as dependências,
315
+ configura os git hooks ([overcommit](https://github.com/sds/overcommit) —
316
+ pre-commit/pre-push/commit-msg) e roda o `rake` default como verificação de
317
+ ambiente. `bin/console` abre um IRB com a gem carregada.
318
+
319
+ O projeto é desenvolvido **test-first (TDD)** com RSpec + WebMock — nenhuma
320
+ classe/método/branch nasce sem um spec falhando que o exija. O `rake` default
321
+ roda **RSpec + RuboCop** e precisa estar verde antes de cada commit:
322
+
323
+ ```bash
324
+ bundle exec rake # RSpec + RuboCop
325
+ bin/rspec # apenas a suíte
326
+ bin/rubocop -a # estilo, com auto-correção
327
+ bundle exec rake pull_fields # regenera data/schemas/ a partir de campos.focusnfe.com.br
328
+ ```
329
+
330
+ Os arquivos em `data/schemas/` são **gerados automaticamente** por
331
+ `rake pull_fields` — não os edite à mão. Para atualizá-los, rode o script e
332
+ faça commit do resultado. O CI verifica em cada PR se os schemas estão em dia.
333
+
334
+ Para instalar a gem localmente, rode `bundle exec rake install`. Para publicar
335
+ uma nova versão, atualize o número em `version.rb` e rode `bundle exec rake
336
+ release`, que cria a tag git, sobe os commits + tag e publica o `.gem` no
337
+ [rubygems.org](https://rubygems.org).
338
+
339
+ ## Contribuindo
340
+
341
+ Bug reports e pull requests são bem-vindos no GitHub em
342
+ https://github.com/wilfison/focus_nfe. Espera-se que os participantes sigam o
343
+ [código de conduta](https://github.com/wilfison/focus_nfe/blob/main/CODE_OF_CONDUCT.md).
344
+
345
+ ## Licença
346
+
347
+ Disponível como código aberto sob os termos da
348
+ [licença MIT](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+ require "rubocop/rake_task"
6
+ require "yard"
7
+
8
+ RSpec::Core::RakeTask.new(:spec)
9
+ RuboCop::RakeTask.new
10
+
11
+ YARD::Rake::YardocTask.new(:yard)
12
+
13
+ begin
14
+ require "steep/rake_task"
15
+ Steep::RakeTask.new(:steep)
16
+ rescue LoadError
17
+ # ambiente sem dependências de desenvolvimento
18
+ end
19
+
20
+ task default: %i[spec rubocop]
21
+
22
+ desc "Roda localmente as mesmas verificações do CI (.github/workflows/ci.yml)"
23
+ task :ci do
24
+ etapas = [
25
+ ["Specs (RSpec)", -> { Rake::Task["spec"].invoke }],
26
+ ["Lint (RuboCop)", -> { Rake::Task["rubocop"].invoke }],
27
+ ["Tipos (Steep)", -> { sh "bundle exec steep check" }],
28
+ ["Docs (YARD)", -> { sh "bundle exec yard doc --no-output --fail-on-warning" }],
29
+ ["Cobertura de docs", -> { Rake::Task["docs:coverage"].invoke }]
30
+ ]
31
+
32
+ falhas = []
33
+ etapas.each do |nome, acao|
34
+ puts "\n\e[1m▶ #{nome}\e[0m"
35
+ acao.call
36
+ puts "\e[32m✓ #{nome}\e[0m"
37
+ rescue SystemExit, StandardError => e
38
+ falhas << nome
39
+ puts "\e[31m✗ #{nome} (#{e.class}: #{e.message})\e[0m"
40
+ end
41
+
42
+ puts "\n\e[1mResumo do CI local\e[0m"
43
+ etapas.each do |etapa|
44
+ marcador = falhas.include?(etapa[0]) ? "\e[31m✗\e[0m" : "\e[32m✓\e[0m"
45
+ puts " #{marcador} #{etapa[0]}"
46
+ end
47
+
48
+ abort "\n#{falhas.size} verificação(ões) falharam: #{falhas.join(", ")}" if falhas.any?
49
+
50
+ puts "\n\e[32mTudo verde — pronto para enviar ao GitHub.\e[0m"
51
+ end
52
+
53
+ desc "Pull fields from FocusNFe API and save to JSON files"
54
+ task :pull_fields do
55
+ sh "ruby #{File.join(__dir__, "tools", "pull_fields.rb")}"
56
+ end
57
+
58
+ COBERTURA_DOCS_MINIMA = 93.0
59
+
60
+ namespace :docs do
61
+ desc "Gera a documentação YARD em docs/ e abre no navegador"
62
+ task open: :yard do
63
+ report = File.join(__dir__, "docs", "index.html")
64
+ abort "Documentação não encontrada. Rode `bundle exec rake yard` primeiro." unless File.exist?(report)
65
+
66
+ sh browser_opener, report
67
+ end
68
+
69
+ desc "Falha se a cobertura de documentação YARD ficar abaixo de #{COBERTURA_DOCS_MINIMA}%"
70
+ task :coverage do
71
+ saida = `yard stats --list-undoc`
72
+ puts saida
73
+
74
+ cobertura = saida[/([\d.]+)% documented/, 1]&.to_f
75
+ abort "Não foi possível ler a cobertura no resultado do `yard stats`." if cobertura.nil?
76
+
77
+ if cobertura < COBERTURA_DOCS_MINIMA
78
+ abort "Cobertura de documentação #{cobertura}% abaixo do mínimo de #{COBERTURA_DOCS_MINIMA}%."
79
+ end
80
+
81
+ puts "Cobertura de documentação: #{cobertura}% (mínimo #{COBERTURA_DOCS_MINIMA}%)."
82
+ end
83
+
84
+ desc "Sobe o servidor YARD (http://localhost:8808) com refresh automático"
85
+ task :serve do
86
+ sh "yard", "server", "--reload"
87
+ end
88
+ end
89
+
90
+ def browser_opener
91
+ if RUBY_PLATFORM.include?("darwin") then "open"
92
+ elsif RUBY_PLATFORM.match?(/mswin|mingw|cygwin/) then "start"
93
+ else "xdg-open"
94
+ end
95
+ end
96
+
97
+ namespace :coverage do
98
+ desc "Abre o relatório de cobertura do SimpleCov no navegador"
99
+ task :open do
100
+ report = File.join(__dir__, "coverage", "index.html")
101
+ abort "Relatório não encontrado. Rode `bundle exec rake spec` primeiro." unless File.exist?(report)
102
+
103
+ sh browser_opener, report
104
+ end
105
+ end