enel-ruby-sdk 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +128 -0
- data/REVISION +1 -0
- data/lib/enel_x/attachment.rb +84 -0
- data/lib/enel_x/client.rb +155 -0
- data/lib/enel_x/configuration.rb +65 -0
- data/lib/enel_x/contract.rb +73 -0
- data/lib/enel_x/endpoint.rb +10 -0
- data/lib/enel_x/endpoints/contract.rb +88 -0
- data/lib/enel_x/endpoints/history.rb +137 -0
- data/lib/enel_x/endpoints/order_change.rb +56 -0
- data/lib/enel_x/endpoints/order_disconnect.rb +54 -0
- data/lib/enel_x/endpoints/order_helpers.rb +80 -0
- data/lib/enel_x/endpoints/order_sale.rb +100 -0
- data/lib/enel_x/error_catalog.rb +76 -0
- data/lib/enel_x/errors.rb +39 -0
- data/lib/enel_x/filter.rb +53 -0
- data/lib/enel_x/headers.rb +28 -0
- data/lib/enel_x/http.rb +47 -0
- data/lib/enel_x/memory_cache.rb +32 -0
- data/lib/enel_x/record.rb +79 -0
- data/lib/enel_x/reference/distributor.rb +30 -0
- data/lib/enel_x/reference/document_state.rb +20 -0
- data/lib/enel_x/reference/occurrence.rb +138 -0
- data/lib/enel_x/reference/partner_code.rb +43 -0
- data/lib/enel_x/reference.rb +13 -0
- data/lib/enel_x/response_parser.rb +56 -0
- data/lib/enel_x/result.rb +18 -0
- data/lib/enel_x/token_provider.rb +71 -0
- data/lib/enel_x/validators.rb +125 -0
- data/lib/enel_x/version.rb +5 -0
- data/lib/enel_x.rb +27 -0
- metadata +75 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: eab8ca3e3db55b2a8e37d6f16bdb0ddd5de50fc5df3cdebed3d5e50247544c7b
|
|
4
|
+
data.tar.gz: 3d8bde302a73e43bcc9d91a45c47306160b59136f6fb1f352a6ab56c6ce7b32b
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 3d1780c7bbe75da24c66168e6f2eea77ed714ab85e724b6702bf24773795ecb3d1787ad6fed3b01fbece03e893152b9d00b70ec5a0e80c015bd8d04167c56844
|
|
7
|
+
data.tar.gz: 878bb7c511258e5aed6729fda3cc2c5c0c2d8ee3fb50f8df300a509615936e4192cfc6ce18cf85ad81b4d55a5d37ad6846cb8a64c6500e4af2a96d951984460f
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Danilo Josino
|
|
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,128 @@
|
|
|
1
|
+
# enel-ruby-sdk
|
|
2
|
+
|
|
3
|
+
SDK Ruby não oficial para a API da Enel X (X-Customer, TPB). Sem dependências de runtime
|
|
4
|
+
além da stdlib do Ruby. Requer Ruby 3.3 ou mais novo.
|
|
5
|
+
|
|
6
|
+
Cobre os 10 endpoints do PDF `docs/enel_PTBR_v5.0.pdf` (X-Customer, TPB): Consulta Cliente,
|
|
7
|
+
venda, alteração de valores, cancelamento e as cinco consultas de histórico.
|
|
8
|
+
|
|
9
|
+
## Instalação
|
|
10
|
+
|
|
11
|
+
Repositório privado, sem publicação no RubyGems por padrão: use a tag git no `Gemfile`.
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
gem 'enel-ruby-sdk', require: 'enel_x', git: 'https://github.com/djosino/enel-ruby-sdk', tag: 'vX.Y.Z'
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Troque `vX.Y.Z` pela [versão mais recente](https://github.com/djosino/enel-ruby-sdk/releases)
|
|
18
|
+
e rode `bundle install`.
|
|
19
|
+
|
|
20
|
+
## Configuração
|
|
21
|
+
|
|
22
|
+
A autenticação é OAuth2 `client_credentials`: a Enel X fornece `client_id` e `client_secret`
|
|
23
|
+
do parceiro, mais o valor de `ClientSystem`. Mantenha-os fora do código, em variáveis de
|
|
24
|
+
ambiente ou no gerenciador de segredos do app. O PDF não traz o host: ele é configuração.
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
require 'enel_x'
|
|
28
|
+
|
|
29
|
+
client = EnelX::Client.new(
|
|
30
|
+
host: ENV.fetch('ENEL_HOST'),
|
|
31
|
+
client_id: ENV.fetch('ENEL_CLIENT_ID'),
|
|
32
|
+
client_secret: ENV.fetch('ENEL_CLIENT_SECRET'),
|
|
33
|
+
client_system: 'BRA-MEUPARCEIRO',
|
|
34
|
+
partner_code: 'PARC01', # usado como empresaParc nas consultas
|
|
35
|
+
# cache: Rails.cache, # opcional: read/write/delete; padrão em memória
|
|
36
|
+
# on_request: ->(entry) { ... }, # opcional: chamada já filtrada de segredos e PII
|
|
37
|
+
)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Há um cliente por ambiente e nenhum estado global. O cliente não repete chamadas: a política
|
|
41
|
+
de repetição é do app, guiada por `error.kind` (veja [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md)).
|
|
42
|
+
|
|
43
|
+
## Exemplos
|
|
44
|
+
|
|
45
|
+
Os campos usam os nomes do PDF, como Hash ou como argumentos nomeados. Toda validação roda
|
|
46
|
+
antes da rede e levanta `EnelX::ValidationError` com `field` e `limit`.
|
|
47
|
+
|
|
48
|
+
### Consulta Cliente (§5.2)
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
contracts = client.contract(
|
|
52
|
+
legal_entity: 'EXBRA_SA_Fortaleza_CE', identity_number: '123.456.789-09', pos: '0001',
|
|
53
|
+
)
|
|
54
|
+
contract = contracts.first
|
|
55
|
+
contract.customer_active? # => true
|
|
56
|
+
contract.customer_supplies? # => true
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`EnelX::UcNotFound` indica cliente inexistente. A decisão de cobrar ou não é do app.
|
|
60
|
+
|
|
61
|
+
### Venda (§5.3)
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
result = client.order_sale(
|
|
65
|
+
'Nome' => 'John', 'Sobrenome' => 'Doe', 'Telefone' => '21987654321', 'Email' => '',
|
|
66
|
+
'Endereco' => 'Av Lucio Costa', 'EnderecoNumero' => '0123', 'Bairro' => 'Barra da Tijuca',
|
|
67
|
+
'Cidade' => 'Rio de Janeiro', 'CEP' => '22630-011', 'empresa' => '2005',
|
|
68
|
+
'numeroCliente' => '123456', 'CPF' => '12345678909', 'numParcelas' => 10,
|
|
69
|
+
'valorParcela' => BigDecimal('32.23'), 'codProduto' => '12342005',
|
|
70
|
+
'dataOcorrencia' => Date.today, 'numeroClienteEmpPar' => 'MD2026ABC',
|
|
71
|
+
'anexos' => [{ type: 'Termo de Adesão', filename: 'termo.pdf', bytes: File.binread('termo.pdf') }],
|
|
72
|
+
context: { subscription_id: 7 },
|
|
73
|
+
)
|
|
74
|
+
result.transaction_id
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Sucesso significa só "recebido": a cobrança se confirma nas ocorrências 996 e 998. `Cidade`
|
|
78
|
+
precisa ser o nome exato do IBGE e o SDK não consegue conferir. Para empresa, acrescente
|
|
79
|
+
`'CNPJ'` e `'NomeEmpresa'`.
|
|
80
|
+
|
|
81
|
+
### Alteração de valores (§5.4) e cancelamento (§5.5)
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
client.order_change('Nome' => 'ALVARO', 'Sobrenome' => 'GOMES', 'empresa' => '2003',
|
|
85
|
+
'numeroCliente' => '7331365', 'numParcelas' => 4, 'valorParcela' => '32.23',
|
|
86
|
+
'codProduto' => '57062003', 'dataOcorrencia' => Date.today,
|
|
87
|
+
'Endereco' => 'Rua A', 'EnderecoNumero' => '1', 'Bairro' => 'Centro',
|
|
88
|
+
'Cidade' => 'Fortaleza', 'CEP' => '60000-000') # codOcorr padrão: "61"
|
|
89
|
+
|
|
90
|
+
client.order_disconnect('Nome' => 'John', 'Sobrenome' => 'Doe', 'empresa' => '2005',
|
|
91
|
+
'numeroCliente' => '000', 'valorParcela' => 0, 'codProduto' => '12',
|
|
92
|
+
'codOcorr' => '26', 'dataOcorrencia' => Date.today,
|
|
93
|
+
'Endereco' => 'Rua A', 'EnderecoNumero' => '20', 'Bairro' => 'Centro',
|
|
94
|
+
'Cidade' => 'Rio de Janeiro', 'CEP' => '00000-000')
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Consultas de histórico (§5.6 a §5.10)
|
|
98
|
+
|
|
99
|
+
A API não tem webhook: estas consultas são a única forma de saber o que houve com a venda.
|
|
100
|
+
`dataOcorrencia` aceita `Date`, `Time` ou `YYYY-MM-DD`.
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
client.subscription_history('empresa' => '2005', 'numeroCliente' => '123456', 'codProduto' => '12342005')
|
|
104
|
+
client.subscription_history('empresa' => '2005', 'dataOcorrencia' => '2026-09-16', 'operacao' => 'C')
|
|
105
|
+
|
|
106
|
+
item = client.request_history('empresa' => '2005', 'numeroCliente' => '123456').first
|
|
107
|
+
item.occurrence.description # => "Cobrança Incluída para o Cliente"
|
|
108
|
+
item.occurrence.ok? # => true
|
|
109
|
+
item.numeroclienteemppar # chave de correlação com a venda
|
|
110
|
+
|
|
111
|
+
client.invoice_history('empresa' => '2005', 'numeroCliente' => '123456').first.document_state # => :paid
|
|
112
|
+
client.collections('empresa' => '2005', 'dataOcorrencia' => Date.today)
|
|
113
|
+
client.movements('empresa' => '2005', 'numeroCliente' => '123456', 'codProduto' => '12342005')
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Datas e números voltam como a API enviou; `null` e texto vazio viram `nil`.
|
|
117
|
+
|
|
118
|
+
### Dados de referência
|
|
119
|
+
|
|
120
|
+
`EnelX::Distributor`, `EnelX::Occurrence` (tabela 7.2, com `stage`), `EnelX::Reference::PartnerCode`
|
|
121
|
+
(tabela 7.1) e `EnelX::DocumentState` (tabela 7.4).
|
|
122
|
+
|
|
123
|
+
## Documentação
|
|
124
|
+
|
|
125
|
+
- [Índice da documentação](docs/README.md)
|
|
126
|
+
- [Contribuir](CONTRIBUTING.md) e [Segurança](SECURITY.md)
|
|
127
|
+
- [Changelog](CHANGELOG.md)
|
|
128
|
+
- Fonte única da API: [`docs/enel_PTBR_v5.0.pdf`](docs/enel_PTBR_v5.0.pdf)
|
data/REVISION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.1.2
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'errors'
|
|
4
|
+
require_relative 'validators'
|
|
5
|
+
|
|
6
|
+
module EnelX
|
|
7
|
+
# Sale attachment (PDF 5.3.2 ANEXO). Pass either `content_base64:` (already encoded)
|
|
8
|
+
# or `bytes:` (raw, encoded here without line breaks).
|
|
9
|
+
class Attachment
|
|
10
|
+
TYPES = ['Documento', 'Termo de Adesão', 'Audio', 'Outros'].freeze
|
|
11
|
+
ADHESION = 'Termo de Adesão'
|
|
12
|
+
# [ASSUMPTION p.16] "8.5Mb" read as 8.5 MiB of decoded content.
|
|
13
|
+
MAX_BYTES = (8.5 * 1024 * 1024).to_i
|
|
14
|
+
|
|
15
|
+
attr_reader :type, :filename, :extension, :content_base64
|
|
16
|
+
|
|
17
|
+
def initialize(type:, filename:, content_base64: nil, bytes: nil, extension: nil)
|
|
18
|
+
@type = Validators.one_of('tipoAnexo', type, TYPES)
|
|
19
|
+
@filename = Validators.text('nomeArquivo', filename, max: 255)
|
|
20
|
+
@extension = check_extension(extension)
|
|
21
|
+
@content_base64 = check_content(content_base64, bytes)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def adhesion? = type == ADHESION
|
|
25
|
+
|
|
26
|
+
def to_h
|
|
27
|
+
{
|
|
28
|
+
'tipoAnexo' => type, 'conteudoBase64' => content_base64,
|
|
29
|
+
'nomeArquivo' => filename, 'extensao' => extension
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
PDF_KEYS = {
|
|
34
|
+
'tipoAnexo' => :type, 'nomeArquivo' => :filename,
|
|
35
|
+
'conteudoBase64' => :content_base64, 'extensao' => :extension
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
# Accepts an Attachment or a Hash named after #initialize or after the PDF keys.
|
|
39
|
+
def self.coerce(value)
|
|
40
|
+
return value if value.is_a?(self)
|
|
41
|
+
|
|
42
|
+
new(**value.to_h.transform_keys { |key| PDF_KEYS.fetch(key.to_s) { key.to_sym } })
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def check_extension(given)
|
|
48
|
+
from_name = File.extname(filename).delete_prefix('.').downcase
|
|
49
|
+
Validators.invalid('nomeArquivo', 'must include the file extension', filename) if
|
|
50
|
+
from_name.empty?
|
|
51
|
+
|
|
52
|
+
given = given.nil? ? from_name : given.to_s.delete_prefix('.').downcase
|
|
53
|
+
unless given == from_name
|
|
54
|
+
Validators.invalid('extensao', "must match the file name (#{from_name})",
|
|
55
|
+
given)
|
|
56
|
+
end
|
|
57
|
+
given
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def check_content(encoded, bytes)
|
|
61
|
+
if encoded.nil? == bytes.nil?
|
|
62
|
+
Validators.invalid('conteudoBase64',
|
|
63
|
+
'pass exactly one of content_base64 or bytes')
|
|
64
|
+
end
|
|
65
|
+
return check_size(encoded, strict_decode(encoded)) if encoded
|
|
66
|
+
|
|
67
|
+
check_size([bytes].pack('m0'), bytes.b)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def strict_decode(encoded)
|
|
71
|
+
encoded.to_s.unpack1('m0')
|
|
72
|
+
rescue ArgumentError
|
|
73
|
+
Validators.invalid('conteudoBase64', 'strict Base64 without line breaks')
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def check_size(encoded, decoded)
|
|
77
|
+
Validators.invalid('conteudoBase64', 'file is empty') if decoded.empty?
|
|
78
|
+
if decoded.bytesize > MAX_BYTES
|
|
79
|
+
Validators.invalid('conteudoBase64', 'at most 8.5 MB after decoding')
|
|
80
|
+
end
|
|
81
|
+
encoded
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
require_relative 'configuration'
|
|
7
|
+
require_relative 'endpoint'
|
|
8
|
+
require_relative 'endpoints/contract'
|
|
9
|
+
require_relative 'filter'
|
|
10
|
+
require_relative 'headers'
|
|
11
|
+
require_relative 'http'
|
|
12
|
+
require_relative 'endpoints/history'
|
|
13
|
+
require_relative 'endpoints/order_change'
|
|
14
|
+
require_relative 'endpoints/order_disconnect'
|
|
15
|
+
require_relative 'endpoints/order_sale'
|
|
16
|
+
require_relative 'response_parser'
|
|
17
|
+
require_relative 'result'
|
|
18
|
+
require_relative 'token_provider'
|
|
19
|
+
|
|
20
|
+
module EnelX
|
|
21
|
+
# One client per environment; there is no global state to share between them.
|
|
22
|
+
class Client
|
|
23
|
+
attr_reader :configuration
|
|
24
|
+
|
|
25
|
+
def initialize(**)
|
|
26
|
+
@configuration = Configuration.new(**)
|
|
27
|
+
@http = Http.new(open_timeout: configuration.open_timeout,
|
|
28
|
+
read_timeout: configuration.read_timeout)
|
|
29
|
+
@tokens = TokenProvider.new(configuration, @http)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Consulta Cliente (PDF 5.2); see EnelX::Endpoints::Contract#call.
|
|
33
|
+
def contract(**)
|
|
34
|
+
Endpoints::Contract.new(self).call(**)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Venda (PDF 5.3), Alteração de Valores (5.4) and Cancelamento (5.5). Fields use the
|
|
38
|
+
# names of the PDF. A success only means "received"; see EnelX::Result.
|
|
39
|
+
def order_sale(fields = nil, context: nil, **rest)
|
|
40
|
+
Endpoints::OrderSale.new(self).call(merge_fields(fields, rest), context:)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def order_change(fields = nil, context: nil, **rest)
|
|
44
|
+
Endpoints::OrderChange.new(self).call(merge_fields(fields, rest), context:)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def order_disconnect(fields = nil, context: nil, **rest)
|
|
48
|
+
Endpoints::OrderDisconnect.new(self).call(merge_fields(fields, rest), context:)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# History queries (PDF 5.6 to 5.10): subscription_history, request_history,
|
|
52
|
+
# invoice_history, collections and movements. Each returns an Array of records.
|
|
53
|
+
Endpoints::History::QUERIES.each_key do |name|
|
|
54
|
+
define_method(name) do |fields = nil, context: nil, **rest|
|
|
55
|
+
Endpoints::History.new(self, name).call(merge_fields(fields, rest), context:)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Runs one business call and returns an EnelX::Result. Never retries on its own,
|
|
60
|
+
# except the single renewal after a 401. The on_request hook sees every outcome.
|
|
61
|
+
def request(endpoint, payload: nil, context: nil)
|
|
62
|
+
headers = build_headers(endpoint)
|
|
63
|
+
id = headers['TransactionID']
|
|
64
|
+
base = base_entry(endpoint, id, payload, context)
|
|
65
|
+
response = error = nil
|
|
66
|
+
begin
|
|
67
|
+
response = call(endpoint.verb, target(endpoint, payload),
|
|
68
|
+
headers:, body: body_for(endpoint, payload))
|
|
69
|
+
Result.build(ResponseParser.parse(response, transaction_id: id), id)
|
|
70
|
+
rescue Error => e
|
|
71
|
+
error = e
|
|
72
|
+
raise
|
|
73
|
+
ensure
|
|
74
|
+
notify(base, response, error)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Sends an authenticated request. A 401 renews the token and retries exactly once.
|
|
79
|
+
def call(method, path, headers: {}, body: nil)
|
|
80
|
+
response = authorized(method, path, headers, body)
|
|
81
|
+
return response unless response.status == 401
|
|
82
|
+
|
|
83
|
+
@tokens.invalidate
|
|
84
|
+
response = authorized(method, path, headers, body)
|
|
85
|
+
return response unless response.status == 401
|
|
86
|
+
|
|
87
|
+
raise ConfigurationError, 'API rejected a freshly issued token (HTTP 401)'
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# Fields may come as one Hash or as plain keywords/string keys: both work.
|
|
93
|
+
def merge_fields(fields, rest)
|
|
94
|
+
return rest if fields.nil?
|
|
95
|
+
return fields if rest.empty? || !fields.respond_to?(:to_h)
|
|
96
|
+
|
|
97
|
+
fields.to_h.merge(rest)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def authorized(method, path, headers, body)
|
|
101
|
+
@http.request(
|
|
102
|
+
method, "#{configuration.host}#{path}",
|
|
103
|
+
headers: headers.merge('Authorization' => "Bearer #{@tokens.token}"), body:
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def build_headers(endpoint)
|
|
108
|
+
Headers.build(
|
|
109
|
+
operation: endpoint.operation, functionality: endpoint.functionality,
|
|
110
|
+
client_system: endpoint.client_system || configuration.client_system,
|
|
111
|
+
server_system: endpoint.server_system
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def get?(endpoint)
|
|
116
|
+
endpoint.verb.to_s.casecmp?('get')
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def body_for(endpoint, payload)
|
|
120
|
+
JSON.generate(payload) if payload && !get?(endpoint)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def target(endpoint, payload)
|
|
124
|
+
return endpoint.path unless get?(endpoint) && payload && !payload.empty?
|
|
125
|
+
|
|
126
|
+
"#{endpoint.path}?#{URI.encode_www_form(payload)}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def notify(base, response, error)
|
|
130
|
+
hook = configuration.on_request
|
|
131
|
+
return unless hook
|
|
132
|
+
|
|
133
|
+
hook.call(base.merge(response_status: response&.status,
|
|
134
|
+
response_body: filtered_body(response),
|
|
135
|
+
error_message: error&.message))
|
|
136
|
+
rescue StandardError => e
|
|
137
|
+
configuration.logger.warn("EnelX on_request hook failed: #{e.class}")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def base_entry(endpoint, id, payload, context)
|
|
141
|
+
{
|
|
142
|
+
service: endpoint.service, endpoint: endpoint.path, method: endpoint.verb,
|
|
143
|
+
transaction_id: id, request_payload: Filter.apply(payload), context:
|
|
144
|
+
}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def filtered_body(response)
|
|
148
|
+
return unless response
|
|
149
|
+
|
|
150
|
+
Filter.apply(JSON.parse(response.body.to_s))
|
|
151
|
+
rescue JSON::ParserError
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'errors'
|
|
4
|
+
require_relative 'memory_cache'
|
|
5
|
+
|
|
6
|
+
module EnelX
|
|
7
|
+
# Default logger. Logger is a bundled gem since Ruby 4.0: the SDK does not require it.
|
|
8
|
+
class NullLogger
|
|
9
|
+
def warn(*); end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class Configuration
|
|
13
|
+
REQUIRED = %i[host client_id client_secret].freeze
|
|
14
|
+
CACHE_METHODS = %i[read write delete].freeze
|
|
15
|
+
DEFAULT_OPEN_TIMEOUT = 5
|
|
16
|
+
DEFAULT_READ_TIMEOUT = 30
|
|
17
|
+
|
|
18
|
+
attr_reader :host, :client_id, :client_secret, :client_system, :partner_code,
|
|
19
|
+
:cache, :logger, :on_request, :open_timeout, :read_timeout
|
|
20
|
+
|
|
21
|
+
# rubocop:disable-next Metrics/ParameterLists
|
|
22
|
+
def initialize(host: nil, client_id: nil, client_secret: nil, client_system: nil,
|
|
23
|
+
partner_code: nil, cache: nil, logger: nil, on_request: nil,
|
|
24
|
+
open_timeout: DEFAULT_OPEN_TIMEOUT,
|
|
25
|
+
read_timeout: DEFAULT_READ_TIMEOUT)
|
|
26
|
+
@host = normalize_host(host)
|
|
27
|
+
@client_id = client_id.to_s
|
|
28
|
+
@client_secret = client_secret.to_s
|
|
29
|
+
@client_system = client_system
|
|
30
|
+
@partner_code = partner_code
|
|
31
|
+
@cache = cache || MemoryCache.new
|
|
32
|
+
@logger = logger || NullLogger.new
|
|
33
|
+
@on_request = on_request
|
|
34
|
+
@open_timeout = open_timeout
|
|
35
|
+
@read_timeout = read_timeout
|
|
36
|
+
validate!
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Never prints the secret: configurations end up in logs and error reports.
|
|
40
|
+
def inspect
|
|
41
|
+
"#<#{self.class.name} host=#{host.inspect} client_id=#{client_id.inspect}>"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def normalize_host(value)
|
|
47
|
+
text = value.to_s.strip
|
|
48
|
+
return text if text.empty?
|
|
49
|
+
|
|
50
|
+
text = "https://#{text}" unless text.match?(%r{\A[a-z][a-z0-9+.-]*://}i)
|
|
51
|
+
text.sub(%r{/+\z}, '')
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def validate!
|
|
55
|
+
missing = REQUIRED.select { |name| public_send(name).empty? }
|
|
56
|
+
unless missing.empty?
|
|
57
|
+
raise ConfigurationError, "missing configuration: #{missing.join(', ')}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
return if CACHE_METHODS.all? { |name| cache.respond_to?(name) }
|
|
61
|
+
|
|
62
|
+
raise ConfigurationError, 'cache must respond to read, write and delete'
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'response_parser'
|
|
4
|
+
|
|
5
|
+
module EnelX
|
|
6
|
+
# One entry of the Consulta Cliente answer (PDF 5.2.3). Read-only: whether the
|
|
7
|
+
# customer may be billed is a business decision that belongs to the consumer.
|
|
8
|
+
Contract = Data.define(
|
|
9
|
+
:address_data, :customer_name, :customer_identity_number, :customer_active,
|
|
10
|
+
:customer_supplies, :customer_bad_payer, :billing_address, :activation_date,
|
|
11
|
+
:deactivation_date, :business_partner_installation, :unpaid_invoices, :days_unpaid,
|
|
12
|
+
:invoice_installment, :nova_uc
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
class Contract
|
|
16
|
+
ATTRIBUTES = {
|
|
17
|
+
address_data: 'AddressData', customer_name: 'CommodityCustomerName',
|
|
18
|
+
customer_identity_number: 'CommodityIdentityNumber',
|
|
19
|
+
billing_address: 'ContractBillingAddress',
|
|
20
|
+
activation_date: 'ContractActivationDate',
|
|
21
|
+
deactivation_date: 'ContractDesactivationDate',
|
|
22
|
+
business_partner_installation: 'BusinessPartnerInstallation',
|
|
23
|
+
unpaid_invoices: 'NumberOfUnpaidInvoices', days_unpaid: 'NumberOfDaysUnpaid',
|
|
24
|
+
nova_uc: 'NovaUC'
|
|
25
|
+
}.freeze
|
|
26
|
+
INDICATORS = {
|
|
27
|
+
customer_active: 'IsCommodityCustomerActive',
|
|
28
|
+
customer_supplies: 'HasCommodityCustomerSupplies',
|
|
29
|
+
customer_bad_payer: 'IsCommodityCustomerBadPayer',
|
|
30
|
+
invoice_installment: 'HasInvoiceInstallment',
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
# Accepts "Contract" (any case) as an array or as a single object.
|
|
34
|
+
def self.list_from(body)
|
|
35
|
+
items = ResponseParser.fetch_ci(body, 'contract')
|
|
36
|
+
wrap(items).map { |item| from_hash(item) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.wrap(items)
|
|
40
|
+
case items
|
|
41
|
+
when Array then items
|
|
42
|
+
when Hash then [items]
|
|
43
|
+
else []
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.from_hash(hash)
|
|
48
|
+
texts = ATTRIBUTES.transform_values do |name|
|
|
49
|
+
text(ResponseParser.fetch_ci(hash, name))
|
|
50
|
+
end
|
|
51
|
+
flags = INDICATORS.transform_values do |name|
|
|
52
|
+
flag(ResponseParser.fetch_ci(hash, name))
|
|
53
|
+
end
|
|
54
|
+
new(**texts, **flags)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.text(value)
|
|
58
|
+
value.nil? || value.to_s.strip.empty? ? nil : value.to_s
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.flag(value)
|
|
62
|
+
case value.to_s.strip.downcase
|
|
63
|
+
when 'true' then true
|
|
64
|
+
when 'false' then false
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def customer_active? = customer_active
|
|
69
|
+
def customer_supplies? = customer_supplies
|
|
70
|
+
def customer_bad_payer? = customer_bad_payer
|
|
71
|
+
def invoice_installment? = invoice_installment
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EnelX
|
|
4
|
+
# One row of the endpoint table (PDF 5.1): path, verb and the per-endpoint headers.
|
|
5
|
+
# A nil client_system means "use the one configured on the client".
|
|
6
|
+
Endpoint = Struct.new(
|
|
7
|
+
:service, :verb, :path, :operation, :functionality, :client_system, :server_system,
|
|
8
|
+
keyword_init: true
|
|
9
|
+
)
|
|
10
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../contract'
|
|
4
|
+
require_relative '../endpoint'
|
|
5
|
+
require_relative '../errors'
|
|
6
|
+
require_relative '../validators'
|
|
7
|
+
|
|
8
|
+
module EnelX
|
|
9
|
+
module Endpoints
|
|
10
|
+
# Consulta Cliente (PDF 5.2): situation of the customer and supply at the distributor.
|
|
11
|
+
class Contract
|
|
12
|
+
# [ASSUMPTION p.12] table 5.1 and 5.2.2 disagree on ClientSystem; the specific
|
|
13
|
+
# section wins. This query has its own header values, not the configured ones.
|
|
14
|
+
ENDPOINT = Endpoint.new(
|
|
15
|
+
service: :contract, verb: :get, path: '/services/v1/contract',
|
|
16
|
+
operation: 'Read', functionality: 'getContract',
|
|
17
|
+
client_system: 'XCustomerB2CBR', server_system: 'CommodityBrazil'
|
|
18
|
+
)
|
|
19
|
+
LEGAL_ENTITIES = %w[
|
|
20
|
+
EXBRA_SA_Fortaleza_CE EXBRA_SA_Paulo_SP EXBRA_SA_Rio_de_Janeiro_RJ
|
|
21
|
+
].freeze
|
|
22
|
+
IDENTITY_TYPES = %w[CPF CNPJ].freeze
|
|
23
|
+
CNPJ_DIGITS = 14
|
|
24
|
+
|
|
25
|
+
def initialize(client)
|
|
26
|
+
@client = client
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns an Array of EnelX::Contract. Raises EnelX::UcNotFound for an unknown UC.
|
|
30
|
+
def call(legal_entity:, identity_number: nil, pos: nil, identity_type: nil,
|
|
31
|
+
context: nil)
|
|
32
|
+
params = build_params(legal_entity, identity_number, pos, identity_type)
|
|
33
|
+
result = @client.request(ENDPOINT, payload: params, context:)
|
|
34
|
+
EnelX::Contract.list_from(result.body)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def build_params(legal_entity, identity_number, pos, identity_type)
|
|
40
|
+
number = digits(identity_number)
|
|
41
|
+
pos = pos.to_s.strip
|
|
42
|
+
type = identity_type(identity_type, number)
|
|
43
|
+
check_legal_entity(legal_entity)
|
|
44
|
+
check_identifiers(number, pos)
|
|
45
|
+
|
|
46
|
+
{
|
|
47
|
+
'IdentityType' => type, 'IdentityNumber' => number,
|
|
48
|
+
'POS' => (pos unless pos.empty?), 'LegalEntity' => legal_entity
|
|
49
|
+
}.compact
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def digits(value)
|
|
53
|
+
return if value.nil? || value.to_s.strip.empty?
|
|
54
|
+
|
|
55
|
+
value.to_s.gsub(/\D/, '').tap do |number|
|
|
56
|
+
fail_field('IdentityNumber', value, 'digits only') if number.empty?
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def identity_type(value, number)
|
|
61
|
+
type = value&.to_s&.upcase
|
|
62
|
+
if type && !IDENTITY_TYPES.include?(type)
|
|
63
|
+
fail_field('IdentityType', value, IDENTITY_TYPES.join(' or '))
|
|
64
|
+
end
|
|
65
|
+
if number&.length == CNPJ_DIGITS && type != 'CNPJ'
|
|
66
|
+
fail_field('IdentityType', value, 'CNPJ is required for a 14-digit document')
|
|
67
|
+
end
|
|
68
|
+
type
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def check_legal_entity(value)
|
|
72
|
+
return if LEGAL_ENTITIES.include?(value)
|
|
73
|
+
|
|
74
|
+
fail_field('LegalEntity', value, LEGAL_ENTITIES.join(', '))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def check_identifiers(number, pos)
|
|
78
|
+
return if number || !pos.empty?
|
|
79
|
+
|
|
80
|
+
fail_field('IdentityNumber', nil, 'IdentityNumber or POS is required')
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def fail_field(field, value, limit)
|
|
84
|
+
Validators.invalid(field, limit, value)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|