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 +7 -0
- data/LICENSE +21 -0
- data/README.md +87 -0
- data/lib/almztech/erros.rb +66 -0
- data/lib/almztech/filtros.rb +55 -0
- data/lib/almztech/log.rb +36 -0
- data/lib/almztech/recursos.rb +190 -0
- data/lib/almztech/transporte.rb +146 -0
- data/lib/almztech/versao.rb +5 -0
- data/lib/almztech/webhook.rb +42 -0
- data/lib/almztech.rb +30 -0
- metadata +57 -0
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
|
data/lib/almztech/log.rb
ADDED
|
@@ -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,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: []
|