almztech-sdk 0.2.0.beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 82f60a2af5454b5669483a42bd14e79526754c05e973dd72b3917e4c78ddcc2a
4
+ data.tar.gz: a23ad001fc0de6a3f3f5a6bdcf371d329afcf62eccbda914813130cc8e2e2e65
5
+ SHA512:
6
+ metadata.gz: ec2bff67dcf4c97f98475ae14b7ce67175dd9e5b759612d4546b9b817c8b0350285fcae97c085b9f986842147f3a8047c17433e900be3982e23143c879c7f20c
7
+ data.tar.gz: 51c5c64972d301c5c2321527affc69224fe81e0093baaf0cb37aaa8d241d7fc989eeffa40fe18b6432292985809308d7cb6b7dc2a433d92406de3636fe7a521c
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ALMZTECH SOFTWARES LTDA
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # almztech-sdk (Ruby)
2
+
3
+ SDK oficial da [Almztech](https://almztech.com.br) para Ruby. Ele cobre a API de NFS-e:
4
+ - emissão, consulta, reconsulta e cancelamento de notas, inclusive por planilha;
5
+ - emitentes e certificados;
6
+ - webhooks;
7
+ - municípios homologados.
8
+
9
+ > Versão beta: a API pública do SDK ainda pode mudar até a 1.0.
10
+
11
+ ## Instalação
12
+
13
+ ```bash
14
+ gem install almztech-sdk --pre
15
+ ```
16
+
17
+ - Ruby 3.0+.
18
+ - **Sem dependências de runtime**: usa `net/http` e `json` da biblioteca padrão. Dá para injetar outro envio HTTP com `envio_http:`, qualquer objeto que responda a `call`.
19
+ - Os dados são `Hash` no formato exato do JSON da API, em camelCase. Na entrada aceita chaves símbolo ou string; as respostas vêm com chaves string. Valores `nil` não são enviados.
20
+
21
+ ## Uso
22
+
23
+ ```ruby
24
+ require "almztech"
25
+
26
+ # A api key está no Portal (portal.almztech.com.br), em Primeiros passos. Guarde-a na configuração do seu
27
+ # sistema, nunca no código.
28
+ client = Almztech::Client.new(ENV.fetch("ALMZTECH_API_KEY"))
29
+
30
+ begin
31
+ protocolo = client.notas_fiscais.emitir(
32
+ identificacaoEmitente: "12345678000190",
33
+ enviarParaProducao: false, # homologação (HML-...): não consome a franquia
34
+ notas: [{
35
+ rps: { numeroRPS: "1001", serie: "1", tipo: "1", dataEmissao: Date.today.iso8601 },
36
+ destinatario: { cpfCnpj: "11222333000181", razaoSocial: "Cliente Exemplo Ltda",
37
+ endereco: { cep: "50030170", codigoIBGEMunicipio: 2_611_606 } },
38
+ servicos: [{ codigo: "0105", descricao: "Licenciamento de software", quantidade: 1,
39
+ valorUnitario: 1500.0, aliquotaISS: 2, retemISS: 2 }]
40
+ }]
41
+ )
42
+
43
+ resultado = client.notas_fiscais.aguardar_conclusao(protocolo["protocolo"])
44
+ resultado.fetch("notas", []).each do |nota|
45
+ File.binwrite("nfse-#{nota['numero']}.pdf", client.notas_fiscais.baixar_pdf(nota["codigoNota"]))
46
+ end
47
+ rescue Almztech::ErroProtocoloRejeitado => e
48
+ puts "Rejeitada: #{e.protocolo['mensagem']}"
49
+ rescue Almztech::ErroValidacao => e
50
+ puts "Recusada: #{e.mensagem}"
51
+ end
52
+
53
+ # No seu endpoint de webhook (a API não assina o payload: proteja a URL):
54
+ evento = Almztech::Webhook.ler(request.body.read) # evento.tipo: :emissao, :cancelamento ou :rejeicao
55
+ ```
56
+
57
+ Consulta com filtros e paginação automática:
58
+
59
+ ```ruby
60
+ filtros = [Almztech::Filtros.periodo("2026-10-01", "2026-10-31"), Almztech::Filtros.com_status("EMITIDA")]
61
+ client.notas_fiscais.paginas(Almztech::Filtros::PRODUCAO, filtros: filtros).each do |nota|
62
+ puts "#{nota['numeroNota']} #{nota['valorTotal']}"
63
+ end
64
+ ```
65
+
66
+ Exceções:
67
+ - `Almztech::ErroValidacao` (422 e 400);
68
+ - `ErroAutenticacao` (401);
69
+ - `ErroAutorizacao` (403);
70
+ - `ErroLimiteDeRequisicoes` (429, com `retry_after`);
71
+ - `ErroServidor` (5xx);
72
+ - `ErroRede`;
73
+ - `ErroTempoDePollingEsgotado`;
74
+ - `ErroProtocoloRejeitado`;
75
+ - `ErroWebhook`.
76
+
77
+ Só as leituras são retentadas automaticamente. `emitir`, `cancelar` e `importar_planilha` nunca são.
78
+
79
+ ## Links
80
+
81
+ - [Documentação da API](https://docs.almztech.com.br)
82
+ - [Código-fonte](https://gitlab.com/almztech/sdks/-/tree/main/ruby). Os SDKs das outras linguagens (.NET, Node.js,
83
+ PHP, Java e Python) seguem o mesmo [contrato](https://gitlab.com/almztech/sdks/-/blob/main/DESIGN.md).
84
+
85
+ ## Licença
86
+
87
+ MIT, © 2026 ALMZTECH SOFTWARES LTDA. Veja [LICENSE](https://gitlab.com/almztech/sdks/-/blob/main/ruby/LICENSE).
@@ -0,0 +1,66 @@
1
+ module Almztech
2
+ # Base de todas as exceções do SDK (DESIGN.md seção 3).
3
+ class Erro < StandardError; end
4
+
5
+ # Erro devolvido pela API ({"mensagem": "..."}). Não há código estruturado: +mensagem+ é texto livre.
6
+ class ErroApi < Erro
7
+ attr_reader :status_code, :mensagem, :corpo_bruto
8
+
9
+ def initialize(status_code, mensagem, corpo_bruto = nil)
10
+ super("A API da Almztech retornou #{status_code}: #{mensagem}")
11
+ @status_code = status_code
12
+ @mensagem = mensagem
13
+ @corpo_bruto = corpo_bruto
14
+ end
15
+ end
16
+
17
+ # 422 (regra de negócio, inclusive franquia do plano esgotada) ou 400 (corpo recusado, com os erros por campo).
18
+ class ErroValidacao < ErroApi; end
19
+
20
+ # 401 — token ausente, inválido ou revogado, ou licença inválida para produção. Só a mensagem diferencia.
21
+ class ErroAutenticacao < ErroApi; end
22
+
23
+ # 403 — papel sem permissão para o endpoint.
24
+ class ErroAutorizacao < ErroApi; end
25
+
26
+ # 429 — limite de requisições. Não é retentado sozinho: +retry_after+ (segundos) diz quanto esperar.
27
+ class ErroLimiteDeRequisicoes < ErroApi
28
+ attr_reader :retry_after
29
+
30
+ def initialize(mensagem, retry_after, corpo_bruto = nil)
31
+ super(429, mensagem, corpo_bruto)
32
+ @retry_after = retry_after
33
+ end
34
+ end
35
+
36
+ # 5xx — erro interno; em produção a mensagem pode vir genérica.
37
+ class ErroServidor < ErroApi; end
38
+
39
+ # Falha de rede, timeout ou DNS antes de qualquer resposta HTTP.
40
+ class ErroRede < Erro; end
41
+
42
+ # O polling estourou o tempo total; +ultimo_protocolo+ permite continuar manualmente.
43
+ class ErroTempoDePollingEsgotado < Erro
44
+ attr_reader :protocolo, :ultimo_protocolo
45
+
46
+ def initialize(protocolo, ultimo_protocolo)
47
+ status = ultimo_protocolo && ultimo_protocolo["status"] || "desconhecido"
48
+ super("O protocolo #{protocolo} não concluiu no tempo de espera (último status: #{status}).")
49
+ @protocolo = protocolo
50
+ @ultimo_protocolo = ultimo_protocolo
51
+ end
52
+ end
53
+
54
+ # O protocolo terminou em REJEITADO ou ERRO_INTERNO; o motivo está em +protocolo["mensagem"]+.
55
+ class ErroProtocoloRejeitado < Erro
56
+ attr_reader :protocolo
57
+
58
+ def initialize(protocolo)
59
+ super("O protocolo #{protocolo['protocolo']} terminou em #{protocolo['status']}: #{protocolo['mensagem'] || '(sem mensagem)'}")
60
+ @protocolo = protocolo
61
+ end
62
+ end
63
+
64
+ # Payload de webhook não reconhecido.
65
+ class ErroWebhook < Erro; end
66
+ end
@@ -0,0 +1,55 @@
1
+ require "date"
2
+
3
+ module Almztech
4
+ # Filtros de +consultar_com_filtros+ (DESIGN.md seção 7). O valor vai sempre como texto: datas +yyyy-MM-dd+ e
5
+ # decimais com ponto. Campo desconhecido ou valor vazio é ignorado pela API (não filtra), por isso prefira as
6
+ # constantes e os construtores daqui.
7
+ module Filtros
8
+ PRODUCAO = "PRD".freeze
9
+ HOMOLOGACAO = "HML".freeze
10
+
11
+ module Campos
12
+ PROTOCOLO = "protocolo".freeze
13
+ CODIGO_NOTA = "codigoNota".freeze
14
+ NOME_EMITENTE = "nomeEmitente".freeze
15
+ CPF_CNPJ_EMITENTE = "cpfCnpjEmitente".freeze
16
+ NUMERO_RPS = "numeroRps".freeze
17
+ SERIE_RPS = "serieRps".freeze
18
+ LOTE_RPS = "loteRps".freeze
19
+ VALOR_TOTAL = "valorTotal".freeze
20
+ # Data yyyy-MM-dd.
21
+ DATA_EMISSAO = "dataEmissao".freeze
22
+ # Vários status separados por |; só com Igual.
23
+ STATUS = "status".freeze
24
+ end
25
+
26
+ # Operadores (OperadorFiltro da API, pelo nome).
27
+ OPERADORES = %w[Igual Diferente MaiorQue MaiorOuIgual MenorQue MenorOuIgual Entre Contem IniciaCom TerminaCom].freeze
28
+
29
+ module_function
30
+
31
+ def filtro(campo, operador, valor, valor_final = nil)
32
+ item = { "campo" => campo, "operador" => operador, "valor" => valor }
33
+ item["valorFinal"] = valor_final unless valor_final.nil?
34
+ item
35
+ end
36
+
37
+ def texto(campo, operador, valor) = filtro(campo, operador, valor.to_s)
38
+ def numero(campo, operador, valor) = filtro(campo, operador, decimal(valor))
39
+ def entre_valores(campo, inicio, fim) = filtro(campo, "Entre", decimal(inicio), decimal(fim))
40
+ def data(operador, valor) = filtro(Campos::DATA_EMISSAO, operador, data_iso(valor))
41
+ def periodo(inicio, fim) = filtro(Campos::DATA_EMISSAO, "Entre", data_iso(inicio), data_iso(fim))
42
+ def com_status(*status) = filtro(Campos::STATUS, "Igual", status.join("|"))
43
+
44
+ # Sempre ponto decimal, independente do locale, sem notação científica.
45
+ def decimal(valor)
46
+ return valor.to_s if valor.is_a?(Integer) || valor.is_a?(String)
47
+
48
+ format("%.10f", valor).sub(/0+\z/, "").sub(/\.\z/, "")
49
+ end
50
+
51
+ def data_iso(valor)
52
+ valor.respond_to?(:strftime) ? valor.strftime("%Y-%m-%d") : valor.to_s
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,36 @@
1
+ require "json"
2
+
3
+ module Almztech
4
+ # Mascaramento central de log (DESIGN.md seção 6). Cobre request e response: +senhaCertificado+ e
5
+ # +senhaPrefeitura+ vêm em claro inclusive nas leituras de emitente. Todo log automático do SDK passa por aqui.
6
+ module Log
7
+ CAMPOS_SENSIVEIS = %w[apikey senha senhacertificado senhaprefeitura base64 arquivobase64 login].freeze
8
+ MASCARA = "***".freeze
9
+
10
+ module_function
11
+
12
+ # Mostra só os últimos 4 caracteres da api key.
13
+ def mascarar_api_key(api_key)
14
+ return MASCARA if api_key.nil? || api_key.empty?
15
+
16
+ MASCARA + (api_key.length > 4 ? api_key[-4..] : api_key)
17
+ end
18
+
19
+ # Cópia do JSON com todo campo sensível mascarado. Corpo não-JSON nunca é devolvido cru.
20
+ def mascarar_corpo_json(corpo)
21
+ return "" if corpo.nil? || corpo.strip.empty?
22
+
23
+ JSON.generate(mascarar(JSON.parse(corpo)))
24
+ rescue JSON::ParserError
25
+ "<corpo não-JSON omitido da sanitização de log>"
26
+ end
27
+
28
+ def mascarar(valor)
29
+ case valor
30
+ when Array then valor.map { |item| mascarar(item) }
31
+ when Hash then valor.to_h { |chave, item| [chave, CAMPOS_SENSIVEIS.include?(chave.to_s.downcase) ? MASCARA : mascarar(item)] }
32
+ else valor
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,190 @@
1
+ require "erb"
2
+ require "uri"
3
+
4
+ module Almztech
5
+ # Conteúdo de .pfx/.xlsx: caminho de arquivo (Pathname), String binária ou IO aberto. Uso interno.
6
+ module Arquivo
7
+ module_function
8
+
9
+ def base64(arquivo)
10
+ conteudo =
11
+ if arquivo.respond_to?(:read) then arquivo.read
12
+ elsif arquivo.is_a?(Pathname) then File.binread(arquivo)
13
+ else arquivo.to_s
14
+ end
15
+ [conteudo].pack("m0")
16
+ end
17
+ end
18
+
19
+ def self.segmento(valor) = ERB::Util.url_encode(valor.to_s)
20
+
21
+ # Emissão, consulta, reconsulta, cancelamento, planilha e downloads de NFS-e (rota NFSe/*). Os dados são Hash com as
22
+ # chaves camelCase do JSON da API; chaves com nil não são enviadas.
23
+ class NotasFiscais
24
+ def initialize(transporte)
25
+ @t = transporte
26
+ end
27
+
28
+ # POST /NFSe/Emitir. Devolve só o protocolo em fila: acompanhe com +aguardar_conclusao+ ou pelo webhook. Produção
29
+ # exige licença válida e franquia disponível; homologação não consome franquia. *Nunca retentado automaticamente*:
30
+ # a API não tem chave de idempotência e um reenvio pode duplicar a nota.
31
+ def emitir(lote) = @t.json("POST", "NFSe/Emitir", lote)
32
+
33
+ # Consulta o protocolo com intervalos crescentes até um status terminal. REJEITADO/ERRO_INTERNO lançam
34
+ # ErroProtocoloRejeitado; estourar +timeout_total+ (segundos) lança ErroTempoDePollingEsgotado com o último protocolo.
35
+ def aguardar_conclusao(protocolo, intervalo_inicial: 2.0, multiplicador: 1.5, intervalo_maximo: 30.0, timeout_total: 300.0, &a_cada_tentativa)
36
+ inicio = Process.clock_gettime(Process::CLOCK_MONOTONIC)
37
+ atraso = intervalo_inicial
38
+ loop do
39
+ ultimo = consultar_protocolo(protocolo)
40
+ a_cada_tentativa&.call(ultimo)
41
+
42
+ case ultimo["status"]
43
+ when "PROCESSADO_COM_SUCESSO", "CANCELADO" then return ultimo
44
+ when "REJEITADO", "ERRO_INTERNO" then raise ErroProtocoloRejeitado.new(ultimo)
45
+ end
46
+ raise ErroTempoDePollingEsgotado.new(protocolo, ultimo) if Process.clock_gettime(Process::CLOCK_MONOTONIC) - inicio >= timeout_total
47
+
48
+ sleep(atraso)
49
+ atraso = [atraso * multiplicador, intervalo_maximo].min
50
+ end
51
+ end
52
+
53
+ def consultar_protocolo(protocolo) = ler("NFSe/Consultar/Protocolo/#{Almztech.segmento(protocolo)}")
54
+ def consultar_por_codigo(codigo_nota) = ler("NFSe/Consultar/#{Integer(codigo_nota)}")
55
+
56
+ # Pelo número da NFS-e na prefeitura; havendo mais de uma entre os seus emitentes, vem a mais recente.
57
+ def consultar_por_numero(numero_nota) = ler("NFSe/Consultar/Numero/#{Almztech.segmento(numero_nota)}")
58
+
59
+ # As 50 notas mais recentes do ambiente (Filtros::PRODUCAO/HOMOLOGACAO).
60
+ def consultar_lista(ambiente) = ler("NFSe/ConsultarLista/#{Almztech.segmento(ambiente)}")
61
+
62
+ # Busca por CNPJ, código, número, razão social, nome fantasia ou protocolo (até 50).
63
+ def consultar_lista_por_termo(ambiente, termo)
64
+ ler("NFSe/ConsultarListaPorTermo/#{Almztech.segmento(ambiente)}/#{Almztech.segmento(termo)}")
65
+ end
66
+
67
+ # POST por causa do corpo, mas é leitura (retentado como os GET). Limite real de 100 por página. Devolve "notas",
68
+ # "paginaAtual", "totalDeRegistros", os totais por status e "contagensPorAba".
69
+ def consultar_com_filtros(ambiente, filtros: nil, filtro_rapido: nil, pagina: 1, limite_da_pagina: 50)
70
+ corpo = { "ambiente" => ambiente, "filtroRapido" => filtro_rapido, "filtros" => filtros, "pagina" => pagina, "limiteDaPagina" => limite_da_pagina }
71
+ @t.json("POST", "NFSe/ConsultarComFiltros", corpo, permite_retry: true)
72
+ end
73
+
74
+ # Percorre todas as páginas de +consultar_com_filtros+, nota a nota (Enumerator sem bloco).
75
+ def paginas(ambiente, filtros: nil, filtro_rapido: nil, limite_da_pagina: 50)
76
+ return enum_for(:paginas, ambiente, filtros: filtros, filtro_rapido: filtro_rapido, limite_da_pagina: limite_da_pagina) unless block_given?
77
+
78
+ pagina = 1
79
+ loop do
80
+ resultado = consultar_com_filtros(ambiente, filtros: filtros, filtro_rapido: filtro_rapido, pagina: pagina, limite_da_pagina: limite_da_pagina)
81
+ notas = resultado["notas"] || []
82
+ notas.each { |nota| yield nota }
83
+ break if notas.empty? || pagina >= (resultado["totalDeRegistros"].to_i / [1, limite_da_pagina].max.to_f).ceil
84
+
85
+ pagina += 1
86
+ end
87
+ end
88
+
89
+ # Consulta a nota de novo na prefeitura e atualiza a situação local. Repetir é seguro.
90
+ def reconsultar(codigo_nota) = ler("NFSe/Reconsultar/#{Integer(codigo_nota)}")
91
+
92
+ # Reconsulta pela chave de acesso do Emissor Nacional; atualiza a nota local de mesmo número.
93
+ def reconsultar_por_chave_acesso(chave_acesso, enviar_para_producao)
94
+ @t.json("POST", "NFSe/Reconsultar/ChaveAcesso/#{Almztech.segmento(chave_acesso)}", { "enviarParaProducao" => enviar_para_producao }, permite_retry: true)
95
+ end
96
+
97
+ # POST /NFSe/Cancelar/{codigo}. Assíncrono como a emissão; não retentado.
98
+ def cancelar(codigo_nota, url_webhook: nil) = @t.json("POST", "NFSe/Cancelar/#{Integer(codigo_nota)}", { "urlWebhook" => url_webhook })
99
+
100
+ # Bytes do XML; gravar em disco fica com você.
101
+ def baixar_xml(codigo_nota) = @t.bytes("NFSe/XML/#{Integer(codigo_nota)}")
102
+ def baixar_pdf(codigo_nota) = @t.bytes("NFSe/PDF/#{Integer(codigo_nota)}")
103
+
104
+ # O .xlsx modelo de +importar_planilha+.
105
+ def baixar_modelo_planilha = @t.bytes("NFSe/ModeloPlanilha")
106
+
107
+ # Emite as notas de uma planilha no formato do modelo, com os padrões fiscais do emitente (que precisa do controle
108
+ # automático de RPS). Devolve "protocolos" (um por lote) e "erros" por linha. Não retentado (emite notas).
109
+ # +planilha+: Pathname, String binária ou IO aberto.
110
+ def importar_planilha(identificacao_emitente, planilha, enviar_para_producao, url_webhook: nil)
111
+ corpo = {
112
+ "arquivoBase64" => Arquivo.base64(planilha),
113
+ "identificacaoEmitente" => identificacao_emitente,
114
+ "enviarParaProducao" => enviar_para_producao,
115
+ "urlWebhook" => url_webhook
116
+ }
117
+ @t.json("POST", "NFSe/ImportarPlanilha", corpo)
118
+ end
119
+
120
+ private
121
+
122
+ def ler(caminho) = @t.json("GET", caminho, permite_retry: true)
123
+ end
124
+
125
+ # CRUD de emitente e upload do certificado A1 (rota Emitente/*). +atualizar+ grava todos os campos, inclusive os
126
+ # padrões fiscais: parta do emitente consultado. As leituras trazem senhaCertificado/senhaPrefeitura em claro —
127
+ # nunca logue esses hashes.
128
+ class Emitentes
129
+ def initialize(transporte)
130
+ @t = transporte
131
+ end
132
+
133
+ # Até 50 emitentes da conta.
134
+ def consultar = @t.json("GET", "Emitente/Consultar", permite_retry: true)
135
+ def consultar_por_codigo(codigo) = @t.json("GET", "Emitente/Consultar/#{Integer(codigo)}", permite_retry: true)
136
+ def consultar_por_cpf_cnpj(cpf_cnpj) = @t.json("GET", "Emitente/ConsultarCpfCnpj/#{Almztech.segmento(cpf_cnpj)}", permite_retry: true)
137
+ def consultar_por_termo(termo) = @t.json("GET", "Emitente/ConsultaPorTermo/#{Almztech.segmento(termo)}", permite_retry: true)
138
+
139
+ # Devolve "mensagem", "cpfCnpj" e o "codigo" do emitente criado.
140
+ def cadastrar(emitente) = @t.json("POST", "Emitente/Cadastrar", emitente)
141
+ def atualizar(emitente) = @t.json("PUT", "Emitente/Atualizar", emitente)
142
+ def excluir(codigo) = @t.json("DELETE", "Emitente/Excluir/#{Integer(codigo)}")
143
+
144
+ # Envia o .pfx (Pathname, String binária ou IO), convertido para Base64 aqui. Devolve "chaveCertificado",
145
+ # "vencimento" e "aviso".
146
+ def upload_certificado(cnpj_emissor, certificado_pfx, senha)
147
+ @t.json("POST", "Emitente/UploadCertificado", { "cnpjEmissor" => cnpj_emissor, "base64" => Arquivo.base64(certificado_pfx), "senha" => senha })
148
+ end
149
+ end
150
+
151
+ # CRUD e teste de webhook (fisicamente em Emitente/Webhook*). Um registro = um evento.
152
+ class Webhooks
153
+ # Eventos: a API os serializa como número.
154
+ EMISSAO = 0
155
+ CANCELAMENTO = 1
156
+ REJEICAO = 2
157
+
158
+ def initialize(transporte)
159
+ @t = transporte
160
+ end
161
+
162
+ def obter(codigo) = @t.json("GET", "Emitente/Webhook/#{Almztech.segmento(codigo)}", permite_retry: true)
163
+ def listar_por_emitente(codigo_emitente) = @t.json("GET", "Emitente/Webhooks/#{Integer(codigo_emitente)}", permite_retry: true)
164
+
165
+ # Não retentado: a API não barra URL duplicada, e um retry cego criaria dois registros.
166
+ def criar(codigo_emitente:, evento:, url:, ativo: true)
167
+ @t.json("POST", "Emitente/Webhook", { "codigoEmitente" => codigo_emitente, "evento" => evento, "url" => url, "ativo" => ativo })
168
+ end
169
+
170
+ def atualizar(webhook) = @t.json("PUT", "Emitente/Webhook", webhook)
171
+ def excluir(codigo) = @t.json("DELETE", "Emitente/Webhook/#{Almztech.segmento(codigo)}")
172
+
173
+ # Dispara um payload de exemplo do evento para a URL, sem precisar de webhook cadastrado.
174
+ def testar(url:, evento:) = @t.json("POST", "Emitente/Webhook/Teste", { "url" => url, "evento" => evento })
175
+ end
176
+
177
+ # Municípios já homologados (CoberturaNacional/MunicipiosHomologados).
178
+ class Municipios
179
+ def initialize(transporte)
180
+ @t = transporte
181
+ end
182
+
183
+ # Pelo código IBGE e/ou por um termo; sem nenhum dos dois vem vazio. Até 20 {"id" => <IBGE>, "text" => ...}.
184
+ def homologados(codigo_ibge: nil, termo_de_busca: nil)
185
+ parametros = { "codigo" => codigo_ibge, "termoDeBusca" => termo_de_busca }.reject { |_, valor| valor.nil? || valor.to_s.empty? }
186
+ sufixo = parametros.empty? ? "" : "?#{URI.encode_www_form(parametros)}"
187
+ @t.json("GET", "CoberturaNacional/MunicipiosHomologados#{sufixo}", permite_retry: true)
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,146 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "openssl"
4
+ require "time"
5
+ require "uri"
6
+
7
+ module Almztech
8
+ # Envio HTTP padrão, com net/http. Um envio injetável é qualquer objeto com
9
+ # +call(metodo, url, headers, corpo, timeout)+ que devolve +[status, headers, corpo]+ inclusive em 4xx/5xx e só
10
+ # lança exceção em falha de rede (proxy corporativo, instrumentação, testes).
11
+ ENVIO_NET_HTTP = lambda do |metodo, url, headers, corpo, timeout|
12
+ uri = URI(url)
13
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: timeout, read_timeout: timeout) do |http|
14
+ requisicao = Net::HTTPGenericRequest.new(metodo, !corpo.nil?, true, uri.request_uri, headers)
15
+ requisicao.body = corpo if corpo
16
+ resposta = http.request(requisicao)
17
+ [resposta.code.to_i, resposta.each_header.to_h, resposta.body.to_s]
18
+ end
19
+ end
20
+
21
+ # HTTP do SDK: header x-api-key, retry só em leituras (falha de rede ou 5xx), mapeamento de erros e JSON sem nulos.
22
+ class Transporte
23
+ ERROS_DE_REDE = [IOError, SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError].freeze
24
+
25
+ # +base_url+: padrão de produção (o ambiente fiscal é por chamada, +enviarParaProducao+). +timeout+: segundos de
26
+ # rede por chamada, não o tempo de processamento da prefeitura. +logger+: recebe só mensagens já mascaradas.
27
+ def initialize(api_key, base_url: "https://api.almztech.com.br", timeout: 30, tentativas: 3, intervalo_inicial: 0.5,
28
+ multiplicador: 2.0, intervalo_maximo: 10.0, envio_http: nil, logger: nil)
29
+ raise ArgumentError, "api_key é obrigatório." if api_key.nil? || api_key.strip.empty?
30
+
31
+ @api_key = api_key
32
+ @base_url = base_url.sub(%r{/+\z}, "")
33
+ @timeout = timeout
34
+ @tentativas = tentativas
35
+ @intervalo_inicial = intervalo_inicial
36
+ @multiplicador = multiplicador
37
+ @intervalo_maximo = intervalo_maximo
38
+ @envio = envio_http || ENVIO_NET_HTTP
39
+ @logger = logger
40
+ log("AlmztechClient configurado (apiKey=#{Log.mascarar_api_key(api_key)}, baseUrl=#{@base_url})")
41
+ end
42
+
43
+ def json(metodo, caminho, corpo = nil, permite_retry: false)
44
+ status, headers, texto = enviar(metodo, caminho, corpo, permite_retry)
45
+ texto = texto.to_s.dup.force_encoding(Encoding::UTF_8)
46
+ log("<- #{status} #{metodo} #{caminho} #{Log.mascarar_corpo_json(texto)}")
47
+ raise mapear_erro(status, headers, texto) if status >= 400
48
+
49
+ texto.strip.empty? ? nil : JSON.parse(texto)
50
+ end
51
+
52
+ def bytes(caminho)
53
+ status, headers, corpo = enviar("GET", caminho, nil, true)
54
+ raise mapear_erro(status, headers, corpo.to_s.dup.force_encoding(Encoding::UTF_8)) if status >= 400
55
+
56
+ corpo.to_s.b
57
+ end
58
+
59
+ private
60
+
61
+ def enviar(metodo, caminho, corpo, permite_retry)
62
+ tentativas = permite_retry ? [1, @tentativas].max : 1
63
+ json = corpo.nil? ? nil : JSON.generate(sem_nulos(corpo))
64
+ headers = { "x-api-key" => @api_key, "User-Agent" => "almztech-ruby-sdk/#{VERSAO_SDK}", "Accept" => "application/json" }
65
+ headers["Content-Type"] = "application/json" if json
66
+
67
+ tentativa = 1
68
+ loop do
69
+ log("-> #{metodo} #{caminho} #{Log.mascarar_corpo_json(json)}") if json
70
+ begin
71
+ resposta = @envio.call(metodo, "#{@base_url}/#{caminho}", headers, json, @timeout)
72
+ rescue *ERROS_DE_REDE => erro
73
+ raise ErroRede, "Falha de rede chamando #{metodo} #{caminho}: #{erro.message}" if tentativa >= tentativas
74
+
75
+ sleep(atraso(tentativa))
76
+ tentativa += 1
77
+ next
78
+ end
79
+
80
+ if resposta[0] >= 500 && tentativa < tentativas
81
+ sleep(atraso(tentativa))
82
+ tentativa += 1
83
+ next
84
+ end
85
+ return resposta
86
+ end
87
+ end
88
+
89
+ def atraso(tentativa)
90
+ base = [@intervalo_inicial * (@multiplicador**(tentativa - 1)), @intervalo_maximo].min
91
+ base + rand * base * 0.2
92
+ end
93
+
94
+ # Campo numérico da API (tipo não anulável) recusa null com 400: omitir deixa valer o padrão dela.
95
+ def sem_nulos(valor)
96
+ case valor
97
+ when Hash then valor.each_with_object({}) { |(chave, item), saida| saida[chave] = sem_nulos(item) unless item.nil? }
98
+ when Array then valor.map { |item| sem_nulos(item) }
99
+ else valor
100
+ end
101
+ end
102
+
103
+ def log(mensagem)
104
+ @logger&.call(mensagem)
105
+ end
106
+
107
+ def mapear_erro(status, headers, corpo)
108
+ mensagem = extrair_mensagem(corpo)
109
+ case status
110
+ when 400, 422 then ErroValidacao.new(status, mensagem, corpo)
111
+ when 401 then ErroAutenticacao.new(status, mensagem, corpo)
112
+ when 403 then ErroAutorizacao.new(status, mensagem, corpo)
113
+ when 429 then ErroLimiteDeRequisicoes.new(mensagem, ler_retry_after(headers), corpo)
114
+ else status >= 500 ? ErroServidor.new(status, mensagem, corpo) : ErroApi.new(status, mensagem, corpo)
115
+ end
116
+ end
117
+
118
+ def ler_retry_after(headers)
119
+ valor = headers.find { |nome, _| nome.to_s.casecmp?("retry-after") }&.last
120
+ valor = valor.first if valor.is_a?(Array)
121
+ return nil if valor.nil? || valor.to_s.strip.empty?
122
+ return valor.to_i if valor.to_s.match?(/\A\d+\z/)
123
+
124
+ [(Time.httpdate(valor) - Time.now).to_i, 0].max
125
+ rescue ArgumentError
126
+ nil
127
+ end
128
+
129
+ # {mensagem} é o formato de erro da API; o 400 do ASP.NET vem como ProblemDetails, com +errors+ por campo.
130
+ def extrair_mensagem(corpo)
131
+ return "(sem corpo de resposta)" if corpo.strip.empty?
132
+
133
+ dados = JSON.parse(corpo)
134
+ if dados.is_a?(Hash)
135
+ return dados["mensagem"] if dados["mensagem"].is_a?(String)
136
+
137
+ if dados["errors"].is_a?(Hash)
138
+ return dados["errors"].flat_map { |campo, erros| Array(erros).map { |erro| "#{campo}: #{erro}" } }.join("; ")
139
+ end
140
+ end
141
+ corpo
142
+ rescue JSON::ParserError
143
+ corpo
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,5 @@
1
+ module Almztech
2
+ VERSAO_SDK = "0.2.0-beta.1"
3
+ # RubyGems não aceita o hífen do pré-lançamento.
4
+ VERSAO_GEM = "0.2.0.beta.1"
5
+ end
@@ -0,0 +1,42 @@
1
+ require "json"
2
+
3
+ module Almztech
4
+ # Leitura do payload recebido no seu endpoint de webhook (DESIGN.md seção 5). Não verifica autenticidade: a API
5
+ # não assina o webhook. Proteger o endpoint (token secreto na URL, allowlist de IP, mTLS) é responsabilidade de quem
6
+ # o recebe.
7
+ module Webhook
8
+ EMISSAO = :emissao
9
+ CANCELAMENTO = :cancelamento
10
+ REJEICAO = :rejeicao
11
+
12
+ # Evento classificado: +tipo+ (EMISSAO, CANCELAMENTO ou REJEICAO) e o payload completo (mesmo schema do polling).
13
+ Evento = Struct.new(:tipo, :protocolo, keyword_init: true)
14
+
15
+ module_function
16
+
17
+ # "notas" não vazio → Emissão; senão "dadosCancelamento" → Cancelamento; senão status REJEITADO → Rejeição (motivo
18
+ # em "mensagem"); senão lança ErroWebhook. +corpo+: o corpo cru do POST ou um Hash já decodificado.
19
+ def ler(corpo)
20
+ protocolo =
21
+ if corpo.is_a?(Hash)
22
+ corpo
23
+ else
24
+ raise ErroWebhook, "Corpo do webhook vazio." if corpo.nil? || corpo.strip.empty?
25
+
26
+ begin
27
+ JSON.parse(corpo)
28
+ rescue JSON::ParserError
29
+ raise ErroWebhook, "Corpo do webhook não é um JSON válido."
30
+ end
31
+ end
32
+ raise ErroWebhook, "Corpo do webhook não é um objeto." unless protocolo.is_a?(Hash)
33
+
34
+ return Evento.new(tipo: EMISSAO, protocolo: protocolo) if protocolo["notas"].is_a?(Array) && !protocolo["notas"].empty?
35
+ return Evento.new(tipo: CANCELAMENTO, protocolo: protocolo) if protocolo["dadosCancelamento"]
36
+ return Evento.new(tipo: REJEICAO, protocolo: protocolo) if protocolo["status"] == "REJEITADO"
37
+
38
+ raise ErroWebhook, "Payload de webhook não reconhecido (protocolo='#{protocolo['protocolo']}', status='#{protocolo['status']}'): " \
39
+ "sem 'notas', sem 'dadosCancelamento' e status diferente de REJEITADO."
40
+ end
41
+ end
42
+ end
data/lib/almztech.rb ADDED
@@ -0,0 +1,30 @@
1
+ require "pathname"
2
+ require_relative "almztech/versao"
3
+ require_relative "almztech/erros"
4
+ require_relative "almztech/log"
5
+ require_relative "almztech/transporte"
6
+ require_relative "almztech/filtros"
7
+ require_relative "almztech/recursos"
8
+ require_relative "almztech/webhook"
9
+
10
+ # SDK oficial Ruby da Almztech (DESIGN.md).
11
+ #
12
+ # client = Almztech::Client.new(ENV.fetch("ALMZTECH_API_KEY"))
13
+ # protocolo = client.notas_fiscais.emitir(lote)
14
+ # resultado = client.notas_fiscais.aguardar_conclusao(protocolo["protocolo"])
15
+ module Almztech
16
+ # Client raiz. Crie um por aplicação e reaproveite. A api key está em "Primeiros passos" no Portal; nunca guarde
17
+ # login e senha no seu sistema. As opções (+base_url+, +timeout+, +tentativas+, +envio_http+, +logger+...) são as
18
+ # de Transporte.
19
+ class Client
20
+ attr_reader :notas_fiscais, :emitentes, :webhooks, :municipios
21
+
22
+ def initialize(api_key, **opcoes)
23
+ transporte = Transporte.new(api_key, **opcoes)
24
+ @notas_fiscais = NotasFiscais.new(transporte)
25
+ @emitentes = Emitentes.new(transporte)
26
+ @webhooks = Webhooks.new(transporte)
27
+ @municipios = Municipios.new(transporte)
28
+ end
29
+ end
30
+ end
metadata ADDED
@@ -0,0 +1,57 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: almztech-sdk
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0.beta.1
5
+ platform: ruby
6
+ authors:
7
+ - ALMZTECH SOFTWARES LTDA
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-11-08 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Emissão, consulta, reconsulta e cancelamento de NFS-e, importação por
14
+ planilha, Emitente e Webhook.
15
+ email:
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - LICENSE
21
+ - README.md
22
+ - lib/almztech.rb
23
+ - lib/almztech/erros.rb
24
+ - lib/almztech/filtros.rb
25
+ - lib/almztech/log.rb
26
+ - lib/almztech/recursos.rb
27
+ - lib/almztech/transporte.rb
28
+ - lib/almztech/versao.rb
29
+ - lib/almztech/webhook.rb
30
+ homepage: https://docs.almztech.com.br
31
+ licenses:
32
+ - MIT
33
+ metadata:
34
+ homepage_uri: https://docs.almztech.com.br
35
+ documentation_uri: https://docs.almztech.com.br
36
+ source_code_uri: https://gitlab.com/almztech/sdks/-/tree/main/ruby
37
+ rubygems_mfa_required: 'true'
38
+ post_install_message:
39
+ rdoc_options: []
40
+ require_paths:
41
+ - lib
42
+ required_ruby_version: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '3.0'
47
+ required_rubygems_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: '0'
52
+ requirements: []
53
+ rubygems_version: 3.5.22
54
+ signing_key:
55
+ specification_version: 4
56
+ summary: SDK oficial Ruby para a API da Almztech
57
+ test_files: []