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
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EnelX
|
|
4
|
+
# Default token cache: same read/write/delete contract as ActiveSupport::Cache.
|
|
5
|
+
class MemoryCache
|
|
6
|
+
def initialize
|
|
7
|
+
@entries = {}
|
|
8
|
+
@mutex = Mutex.new
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def read(key)
|
|
12
|
+
@mutex.synchronize do
|
|
13
|
+
value, expires_at = @entries[key]
|
|
14
|
+
return value if value && (expires_at.nil? || expires_at > Time.now)
|
|
15
|
+
|
|
16
|
+
@entries.delete(key)
|
|
17
|
+
nil
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def write(key, value, expires_in: nil)
|
|
22
|
+
@mutex.synchronize do
|
|
23
|
+
@entries[key] = [value, expires_in && (Time.now + expires_in)]
|
|
24
|
+
end
|
|
25
|
+
value
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def delete(key)
|
|
29
|
+
@mutex.synchronize { @entries.delete(key)&.first }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'reference/document_state'
|
|
4
|
+
require_relative 'reference/occurrence'
|
|
5
|
+
|
|
6
|
+
module EnelX
|
|
7
|
+
# Item of a history query (PDF 5.6 to 5.10). Attributes are the lowercase keys of the
|
|
8
|
+
# PDF; null, absent and empty text become nil. Dates and numbers keep the text the API
|
|
9
|
+
# sent, because the PDF does not specify their format.
|
|
10
|
+
module Record
|
|
11
|
+
# Class-level builder: keys are matched ignoring case (novaUC => novauc).
|
|
12
|
+
module Builder
|
|
13
|
+
def from_hash(hash)
|
|
14
|
+
lowered = hash.to_h { |key, value| [key.to_s.downcase, value] }
|
|
15
|
+
new(**members.to_h { |name| [name, Record.clean(lowered[name.to_s])] })
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Unknown codes still yield an object that keeps the original code.
|
|
20
|
+
module OccurrenceReader
|
|
21
|
+
def occurrence
|
|
22
|
+
Reference::Occurrence.fetch(codocorr) if codocorr
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The raw value stays available in #estadodocumento.
|
|
27
|
+
module DocumentStateReader
|
|
28
|
+
def document_state
|
|
29
|
+
Reference::DocumentState.from(estadodocumento)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
def define(*members)
|
|
36
|
+
klass = Class.new(Struct.new(*members, keyword_init: true))
|
|
37
|
+
klass.extend(Builder)
|
|
38
|
+
klass.include(OccurrenceReader) if members.include?(:codocorr)
|
|
39
|
+
klass.include(DocumentStateReader) if members.include?(:estadodocumento)
|
|
40
|
+
klass
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def clean(value)
|
|
44
|
+
return nil if value.nil?
|
|
45
|
+
|
|
46
|
+
value.is_a?(String) && value.strip.empty? ? nil : value
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
SUBSCRIPTION_FIELDS = %i[
|
|
51
|
+
empresaparc codproduto numerocliente dvnumerocliente dataadesao datacancelamento
|
|
52
|
+
estado dataprocessamento codcanalvenda usuarioadesao numeroclienteempresaparceira
|
|
53
|
+
usuariocancelamento obscancelamento quantparcelas parcelasfat headerpartnercode
|
|
54
|
+
valor novauc
|
|
55
|
+
].freeze
|
|
56
|
+
REQUEST_FIELDS = %i[
|
|
57
|
+
numerocliente dataocorrencia dvnumerocliente empresaparc codproduto empresa codocorr
|
|
58
|
+
valor numparcelas referencia datavencimento datapagamento lotefaturamento
|
|
59
|
+
numeroclienteemppar databaixapagamento codcanalvenda descricaoretorno
|
|
60
|
+
correlativodocumento headerpartnercode novauc
|
|
61
|
+
].freeze
|
|
62
|
+
COLLECTION_FIELDS = %i[
|
|
63
|
+
estadodocumento numerocliente dvnumerocliente dataocorrencia valor numparcelas
|
|
64
|
+
referencia datavencimento datapagamento lotefaturamento numeroclienteemppar
|
|
65
|
+
codproduto empresaparc databaixapagamento codcanalvenda correlativodocumento
|
|
66
|
+
headerpartnercode novauc
|
|
67
|
+
].freeze
|
|
68
|
+
# novauc is always empty in this query (PDF 5.10.3).
|
|
69
|
+
MOVEMENT_FIELDS = %i[
|
|
70
|
+
numerocliente dvnumerocliente codocorr dataocorrencia valorparcela numparcelas
|
|
71
|
+
codproduto empresaparc datarecebimento headerpartnercode status novauc
|
|
72
|
+
].freeze
|
|
73
|
+
|
|
74
|
+
Subscription = Record.define(*SUBSCRIPTION_FIELDS)
|
|
75
|
+
RequestHistoryItem = Record.define(*REQUEST_FIELDS)
|
|
76
|
+
InvoiceHistoryItem = Record.define(*(REQUEST_FIELDS + [:estadodocumento]))
|
|
77
|
+
Collection = Record.define(*COLLECTION_FIELDS)
|
|
78
|
+
Movement = Record.define(*MOVEMENT_FIELDS)
|
|
79
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EnelX
|
|
4
|
+
module Reference
|
|
5
|
+
# Distributors accepted by the API (PDF 5.2.2 LegalEntity and 5.3.2 empresa).
|
|
6
|
+
class Distributor < Data.define(:code, :name, :state, :legal_entity)
|
|
7
|
+
ALL = [
|
|
8
|
+
new('2003', 'Ceará', 'CE', 'EXBRA_SA_Fortaleza_CE'),
|
|
9
|
+
new('2005', 'Rio de Janeiro', 'RJ', 'EXBRA_SA_Rio_de_Janeiro_RJ'),
|
|
10
|
+
new('MT10', 'São Paulo', 'SP', 'EXBRA_SA_Paulo_SP'),
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def all = ALL
|
|
15
|
+
|
|
16
|
+
def find_by_code(code)
|
|
17
|
+
ALL.find { |item| item.code == code.to_s }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def find_by_state(state)
|
|
21
|
+
ALL.find { |item| item.state.casecmp?(state.to_s) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def find_by_legal_entity(legal_entity)
|
|
25
|
+
ALL.find { |item| item.legal_entity == legal_entity.to_s }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EnelX
|
|
4
|
+
module Reference
|
|
5
|
+
# EstadoDocumento values of ConsultaFaturamento (PDF 7.4).
|
|
6
|
+
module DocumentState
|
|
7
|
+
VALUES = {
|
|
8
|
+
'S' => :paid, # Fatura Paga
|
|
9
|
+
'N' => :open, # Fatura em Aberto
|
|
10
|
+
'C' => :canceled, # Fatura Cancelada
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def from(value)
|
|
16
|
+
VALUES.fetch(value.to_s.strip.upcase, :unknown)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Layout/LineLength -- tables copied from PDF 7.2
|
|
4
|
+
|
|
5
|
+
module EnelX
|
|
6
|
+
module Reference
|
|
7
|
+
# Reason attached to occurrence 909 (second table of PDF 7.2).
|
|
8
|
+
CancellationReason = Data.define(:code, :description, :automatic, :note) do
|
|
9
|
+
def automatic? = automatic
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Codes the Enel X returns to the partner (PDF 7.2). Read-only table: it does not
|
|
13
|
+
# decide anything for the consumer.
|
|
14
|
+
class Occurrence < Data.define(:code, :description, :ok, :legacy_code, :note, :stage)
|
|
15
|
+
STAGES = {
|
|
16
|
+
intake: %w[996 997], audit: %w[998 999],
|
|
17
|
+
billing: %w[901 902 903 904 905 907 908 909 910 913 914 915 916 917 918 920]
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
ROWS = [
|
|
21
|
+
['901', 'Recebido o Pagamento do produto (arrecadação)', true, '11',
|
|
22
|
+
'Informe de arrecadação da fatura.'],
|
|
23
|
+
['902', 'Inclusão de Cobrança na fatura (faturamento)', true, '12',
|
|
24
|
+
'Informe de faturamento da cobrança'],
|
|
25
|
+
['903', 'Cancelamento de débito após faturamento', false, '14 / 19',
|
|
26
|
+
'Retirada do valor da conta do cliente, ocorre quando o cliente realiza um ' \
|
|
27
|
+
'refaturamento retirando um produto da conta'],
|
|
28
|
+
['904', 'Ajuste na fatura de energia', true, '15',
|
|
29
|
+
'Ocorreu um ajuste no faturamento do cliente na distribuidora'],
|
|
30
|
+
['905', 'Cliente não faturado / em estado de corte', false, '18',
|
|
31
|
+
'Informa que o cliente não foi faturado, devido o mesmo está sem fornecimento ' \
|
|
32
|
+
'no momento do faturamento. No mês seguinte será realizada a nova tentativa ' \
|
|
33
|
+
'de faturamento'],
|
|
34
|
+
['907', 'Crédito por Devolução', true, '13/34',
|
|
35
|
+
'Inclusão de valor de crédito a título de devolução na fatura'],
|
|
36
|
+
['908', 'Cancelamento de faturamento pendente', false, nil,
|
|
37
|
+
'Cancelamento de uma fatura que que ainda não havia sido emita na distribuidora'],
|
|
38
|
+
['909', 'Cancelamento', true, nil,
|
|
39
|
+
'Para cancelamentos também é enviado a razão do cancelamento conforme tabela abaixo'],
|
|
40
|
+
['910', 'Cliente inexistente', false, '36', 'Retorna quando cliente não existe'],
|
|
41
|
+
['913', 'Não incluído - Cliente não está ativo no sistema da distribuidora.', false,
|
|
42
|
+
'06', 'A operação não foi efetuada devido ao cliente não estar ativo na distribuidora'],
|
|
43
|
+
['914', 'Não incluído - Cliente já possui o produto ativo', false, '07',
|
|
44
|
+
'A operação não foi efetuada devido ao cliente já possuir uma adesão vigente. ' \
|
|
45
|
+
'Só é permitido uma cobrança por vez.'],
|
|
46
|
+
['915', 'Cobrança Incluída para o Cliente', true, '10/64',
|
|
47
|
+
'Confirmação de inclusão de cobrança'],
|
|
48
|
+
['916', 'Não Incluído - Cliente sem Fornecimento', false, '63',
|
|
49
|
+
'Operação não realizada devido ao cliente estar sem fornecimento'],
|
|
50
|
+
['917', 'Não incluído - Cliente de classe não permitida', false, '73',
|
|
51
|
+
'Não é permito incluir para as seguintes classes de clientes: Poderes Públicos, ' \
|
|
52
|
+
'Iluminação Pública, Serviços Públicos, Consumo Próprio'],
|
|
53
|
+
['918', 'Cliente com problema cadastral na distribuidora', false, '98',
|
|
54
|
+
'O cliente encontra-se com problemas cadastrais que impedem a operação.'],
|
|
55
|
+
['920', 'Falha no cancelamento de fatura não emitida', false, nil,
|
|
56
|
+
'Falha no cancelamento de fatura não emitida devido ao cliente já estar em ' \
|
|
57
|
+
'processo de faturamento'],
|
|
58
|
+
['996', 'Retorno OK para novas vendas / Alteração de valores / Cancelamentos', true,
|
|
59
|
+
nil, 'Confirmação do ingresso da solicitação na fila de auditoria/processamento. ' \
|
|
60
|
+
'Retorno após as validações automáticas do sistema.'],
|
|
61
|
+
['997', 'Retorno KO para novas vendas / Alteração de valores / Cancelamentos', false,
|
|
62
|
+
nil, 'Retorno de informação de falha/erro da tentativa de ingresso da solicitação ' \
|
|
63
|
+
'na fila de auditoria/processamento. Retorno após as validações ' \
|
|
64
|
+
'automáticas do sistema.'],
|
|
65
|
+
['998', 'Retorno OK para auditoria', true, nil,
|
|
66
|
+
'Retorno de auditoria bem-sucedida'],
|
|
67
|
+
['999', 'Retorno KO para auditoria', false, nil,
|
|
68
|
+
'Retorno de rejeição pela mesa de auditoria'],
|
|
69
|
+
].freeze
|
|
70
|
+
|
|
71
|
+
ALL = ROWS.map do |code, description, ok, legacy, note|
|
|
72
|
+
stage = STAGES.find { |_, codes| codes.include?(code) }&.first || :unknown
|
|
73
|
+
new(code, description, ok, legacy, note, stage)
|
|
74
|
+
end.freeze
|
|
75
|
+
|
|
76
|
+
REASON_TAIL = ' É cancelado a adesão e futuros faturamentos, entretanto os ' \
|
|
77
|
+
'faturamentos em aberto permanecem'
|
|
78
|
+
REQUESTED = 'Cancelamento a pedido do cliente na distribuidora devido '
|
|
79
|
+
|
|
80
|
+
REASON_ROWS = [
|
|
81
|
+
['02', 'Cancelamento por Mudança de Classe', true,
|
|
82
|
+
'Cancelamento automático efetuado pela distribuidora, quando é realizada a ' \
|
|
83
|
+
'mudança de classe (ex: cliente residencial muda para comercial).'],
|
|
84
|
+
['20', 'Cancelamento de produto por retirada do cliente do sistema.', true,
|
|
85
|
+
'Cancelamento automático efetuado pela distribuidora, quando o cliente é ' \
|
|
86
|
+
'retirado do sistema.'],
|
|
87
|
+
['22', 'Cancelado - Fora da faixa etária', false,
|
|
88
|
+
"#{REQUESTED}estar fora da faixa etária."],
|
|
89
|
+
['23', 'Cancelado - Contenção de despesas', false,
|
|
90
|
+
"#{REQUESTED}a contenção de despesas."],
|
|
91
|
+
['24', 'Cancelado - Insatisfação com o produto', false,
|
|
92
|
+
"#{REQUESTED}insatisfação do produto."],
|
|
93
|
+
['25', 'Cancelado - Morte do segurado', false,
|
|
94
|
+
"#{REQUESTED}a morte do segurado."],
|
|
95
|
+
['26', 'Cancelado - Não há interesse', false,
|
|
96
|
+
"#{REQUESTED}não haver mais interesse."],
|
|
97
|
+
['27', 'Cancelado - Adesão indevida', false,
|
|
98
|
+
"#{REQUESTED}a uma adesão indevida."],
|
|
99
|
+
['28', 'Cancelado - Falta de rede credenciada', false,
|
|
100
|
+
"#{REQUESTED}a falta de rede credenciada."],
|
|
101
|
+
['29', 'Cancelado - Não recebimento de certificado / carteirinha', false,
|
|
102
|
+
"#{REQUESTED}ao motivo não recebimento do certificado / carteirinha."],
|
|
103
|
+
['30', 'Cancelado - Determinação judicial', false,
|
|
104
|
+
"#{REQUESTED}ao motivo determinação judicial."],
|
|
105
|
+
['31', 'Cancelado - Cancelamento solicitado e não atendido', false,
|
|
106
|
+
"#{REQUESTED}ao motivo cancelamento solicitado e não atendido."],
|
|
107
|
+
['32', 'Cancelado - Contratação de outro produto', false,
|
|
108
|
+
"#{REQUESTED}ao motivo contratação de outro produto."],
|
|
109
|
+
['33', 'Cancelado - Cancelado por troca de titularidade', false,
|
|
110
|
+
"#{REQUESTED}ao motivo cancelado por troca de titularidade."],
|
|
111
|
+
].freeze
|
|
112
|
+
|
|
113
|
+
CANCELLATION_REASONS = REASON_ROWS.map do |code, description, automatic, text|
|
|
114
|
+
CancellationReason.new(code, description, automatic, "#{text}#{REASON_TAIL}")
|
|
115
|
+
end.freeze
|
|
116
|
+
|
|
117
|
+
def ok? = ok
|
|
118
|
+
|
|
119
|
+
class << self
|
|
120
|
+
def all = ALL
|
|
121
|
+
|
|
122
|
+
def find(code)
|
|
123
|
+
ALL.find { |item| item.code == code.to_s }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def cancellation_reason(code)
|
|
127
|
+
CANCELLATION_REASONS.find { |item| item.code == code.to_s }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Unknown codes still get an object, so callers can keep the raw code.
|
|
131
|
+
def fetch(code)
|
|
132
|
+
find(code) || new(code.to_s, nil, nil, nil, nil, :unknown)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
# rubocop:enable Layout/LineLength
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EnelX
|
|
4
|
+
module Reference
|
|
5
|
+
# Codes the partner sends in codOcorr (PDF 7.1).
|
|
6
|
+
class PartnerCode < Data.define(:code, :description, :note)
|
|
7
|
+
CANCELLATION_NOTE = 'Cancelamento a pedido. É cancelado a adesão e futuros ' \
|
|
8
|
+
'faturamentos, entretanto os faturamentos em aberto permanecem'
|
|
9
|
+
CHANGE_CODE = '61'
|
|
10
|
+
|
|
11
|
+
ALL = [
|
|
12
|
+
new('22', 'Cancelamento - Fora da faixa etária', CANCELLATION_NOTE),
|
|
13
|
+
new('23', 'Cancelamento - Contenção de despesas', CANCELLATION_NOTE),
|
|
14
|
+
new('24', 'Cancelamento - Insatisfação com produto', CANCELLATION_NOTE),
|
|
15
|
+
new('25', 'Cancelamento - Morte do segurado', CANCELLATION_NOTE),
|
|
16
|
+
new('26', 'Cancelamento - Não há interesse', CANCELLATION_NOTE),
|
|
17
|
+
new('27', 'Cancelamento - Adesão indevida', CANCELLATION_NOTE),
|
|
18
|
+
new('28', 'Cancelamento - Falta de rede credenciada', CANCELLATION_NOTE),
|
|
19
|
+
new('29', 'Cancelamento - Não recebimento de certificado / carteirinha',
|
|
20
|
+
CANCELLATION_NOTE),
|
|
21
|
+
new('30', 'Cancelamento - Determinação judicial', CANCELLATION_NOTE),
|
|
22
|
+
new('31', 'Cancelamento - Cancelamento solicitado e não atendido',
|
|
23
|
+
CANCELLATION_NOTE),
|
|
24
|
+
new('32', 'Cancelamento - Contratação de outro seguro', CANCELLATION_NOTE),
|
|
25
|
+
new(CHANGE_CODE, 'Alteração de débito de produto',
|
|
26
|
+
'Altera valor e parcelas de uma cobrança ativa. É necessário existir ' \
|
|
27
|
+
'uma cobrança vigente ativa.'),
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
def all = ALL
|
|
32
|
+
|
|
33
|
+
def find(code)
|
|
34
|
+
ALL.find { |item| item.code == code.to_s }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def cancellation_reasons
|
|
38
|
+
ALL.select { |item| item.code.to_i.between?(22, 32) }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'reference/distributor'
|
|
4
|
+
require_relative 'reference/document_state'
|
|
5
|
+
require_relative 'reference/occurrence'
|
|
6
|
+
require_relative 'reference/partner_code'
|
|
7
|
+
|
|
8
|
+
module EnelX
|
|
9
|
+
# The spec names these at the top level; they live under Reference.
|
|
10
|
+
Distributor = Reference::Distributor
|
|
11
|
+
Occurrence = Reference::Occurrence
|
|
12
|
+
DocumentState = Reference::DocumentState
|
|
13
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
require_relative 'error_catalog'
|
|
6
|
+
|
|
7
|
+
module EnelX
|
|
8
|
+
# The API answers HTTP 200 even on failure: the body decides (PDF 5.1 and 7.3).
|
|
9
|
+
module ResponseParser
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Returns the parsed body (Hash or Array) or raises EnelX::Error.
|
|
13
|
+
def parse(response, transaction_id: nil)
|
|
14
|
+
body = decode(response, transaction_id)
|
|
15
|
+
return body if body.is_a?(Array)
|
|
16
|
+
|
|
17
|
+
result = fetch_ci(body, 'result')
|
|
18
|
+
return check_result(result, body, transaction_id) if result.is_a?(Hash)
|
|
19
|
+
return body if body.values.any?(Array)
|
|
20
|
+
|
|
21
|
+
raise retryable('unexpected response shape', response, transaction_id)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def decode(response, transaction_id)
|
|
25
|
+
body = JSON.parse(response.body.to_s)
|
|
26
|
+
return body if body.is_a?(Hash) || body.is_a?(Array)
|
|
27
|
+
|
|
28
|
+
raise retryable('unreadable response body', response, transaction_id)
|
|
29
|
+
rescue JSON::ParserError
|
|
30
|
+
raise retryable('unreadable response body', response, transaction_id)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def check_result(result, body, transaction_id)
|
|
34
|
+
code = fetch_ci(result, 'error_code', 'errorcode').to_s
|
|
35
|
+
return body if code == '0'
|
|
36
|
+
|
|
37
|
+
raise ErrorCatalog.build_error(
|
|
38
|
+
fetch_ci(result, 'message').to_s, code:, transaction_id:
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def retryable(text, response, transaction_id)
|
|
43
|
+
Error.new("#{text} (HTTP #{response.status})", kind: :retryable,
|
|
44
|
+
transaction_id:)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Key lookup ignoring case and underscores: result/Result, error_code/ErrorCode.
|
|
48
|
+
def fetch_ci(hash, *names)
|
|
49
|
+
wanted = names.map { |name| name.delete('_').downcase }
|
|
50
|
+
hash.each do |key, value|
|
|
51
|
+
return value if wanted.include?(key.to_s.delete('_').downcase)
|
|
52
|
+
end
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'response_parser'
|
|
4
|
+
|
|
5
|
+
module EnelX
|
|
6
|
+
# Outcome of a successful business call. For writes, success only means "received":
|
|
7
|
+
# confirmation arrives later as occurrences (PDF 2.3). transaction_id correlates the
|
|
8
|
+
# call with the logs of the consumer and of the Enel X.
|
|
9
|
+
Result = Data.define(:transaction_id, :error_code, :message, :body) do
|
|
10
|
+
def self.build(body, transaction_id)
|
|
11
|
+
result = body.is_a?(Hash) ? ResponseParser.fetch_ci(body, 'result') : nil
|
|
12
|
+
return new(transaction_id, nil, nil, body) unless result.is_a?(Hash)
|
|
13
|
+
|
|
14
|
+
new(transaction_id, ResponseParser.fetch_ci(result, 'error_code').to_s,
|
|
15
|
+
ResponseParser.fetch_ci(result, 'message').to_s.strip, body)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
require_relative 'errors'
|
|
6
|
+
|
|
7
|
+
module EnelX
|
|
8
|
+
# OAuth2 client_credentials token (PDF 3.1 and 3.2), cached until shortly before expiry.
|
|
9
|
+
class TokenProvider
|
|
10
|
+
PATH = '/services/token/oauth2'
|
|
11
|
+
EXPIRY_MARGIN = 120
|
|
12
|
+
|
|
13
|
+
def initialize(configuration, http)
|
|
14
|
+
@configuration = configuration
|
|
15
|
+
@http = http
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def token
|
|
19
|
+
cache.read(cache_key) || fetch
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def invalidate
|
|
23
|
+
cache.delete(cache_key)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def cache
|
|
29
|
+
@configuration.cache
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def cache_key
|
|
33
|
+
"enel_x:token:#{@configuration.host}:#{@configuration.client_id}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def fetch
|
|
37
|
+
response = @http.request(
|
|
38
|
+
:post, "#{@configuration.host}#{PATH}",
|
|
39
|
+
headers:, body: 'grant_type=client_credentials'
|
|
40
|
+
)
|
|
41
|
+
access_token, expires_in = parse(response)
|
|
42
|
+
cache.write(cache_key, access_token,
|
|
43
|
+
expires_in: [expires_in - EXPIRY_MARGIN, 1].max)
|
|
44
|
+
access_token
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def headers
|
|
48
|
+
credentials = "#{@configuration.client_id}:#{@configuration.client_secret}"
|
|
49
|
+
{
|
|
50
|
+
'Authorization' => "Basic #{[credentials].pack('m0')}",
|
|
51
|
+
'Content-Type' => 'application/x-www-form-urlencoded',
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def parse(response)
|
|
56
|
+
if response.status >= 500
|
|
57
|
+
raise Error.new("token endpoint failed (HTTP #{response.status})",
|
|
58
|
+
kind: :retryable)
|
|
59
|
+
end
|
|
60
|
+
unless response.status == 200
|
|
61
|
+
raise ConfigurationError,
|
|
62
|
+
"token endpoint rejected credentials (HTTP #{response.status})"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
body = JSON.parse(response.body)
|
|
66
|
+
[body.fetch('access_token'), Integer(body.fetch('expires_in'))]
|
|
67
|
+
rescue JSON::ParserError, KeyError, TypeError, ArgumentError
|
|
68
|
+
raise ConfigurationError, 'token endpoint returned an unexpected response'
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
|
|
5
|
+
require_relative 'errors'
|
|
6
|
+
require_relative 'filter'
|
|
7
|
+
require_relative 'reference/distributor'
|
|
8
|
+
|
|
9
|
+
module EnelX
|
|
10
|
+
# Field rules from PDF 5.3 to 5.5. Each method returns the value to send or raises
|
|
11
|
+
# ValidationError naming the field and the limit, before any network call.
|
|
12
|
+
module Validators
|
|
13
|
+
EMAIL = /\A[A-Za-z0-9_.-]+@[A-Za-z0-9_.-]+\.[A-Za-z]{1,3}\z/
|
|
14
|
+
MAX_INSTALLMENTS = 400
|
|
15
|
+
INFINITE_INSTALLMENTS = -1
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def invalid(field, limit, value = nil)
|
|
20
|
+
raise ValidationError.new(
|
|
21
|
+
"invalid #{field}: #{limit}", field:, limit:,
|
|
22
|
+
value_preview: Filter.apply({ field => value })[field]
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def text(field, value, max:, required: true)
|
|
27
|
+
string = value.to_s
|
|
28
|
+
invalid(field, 'required', value) if required && string.strip.empty?
|
|
29
|
+
invalid(field, "at most #{max} characters", value) if string.length > max
|
|
30
|
+
string
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def phone(field, value)
|
|
34
|
+
digits = value.to_s.gsub(/\D/, '')
|
|
35
|
+
invalid(field, '10 or 11 digits', value) unless digits.match?(/\A\d{10,11}\z/)
|
|
36
|
+
digits
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# An empty e-mail is valid: the PDF advises it when the address may be in use.
|
|
40
|
+
def email(field, value)
|
|
41
|
+
string = value.to_s
|
|
42
|
+
return string if string.empty?
|
|
43
|
+
|
|
44
|
+
invalid(field, 'at most 80 characters', value) if string.length > 80
|
|
45
|
+
invalid(field, 'format XXX@YYY.ZZZ without # & * or accents', value) unless
|
|
46
|
+
string.match?(EMAIL)
|
|
47
|
+
string
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def cep(field, value)
|
|
51
|
+
digits = value.to_s.gsub(/\D/, '')
|
|
52
|
+
invalid(field, 'format 99999-999', value) unless digits.length == 8
|
|
53
|
+
"#{digits[0, 5]}-#{digits[5, 3]}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Documents travel as digits only, keeping leading zeros.
|
|
57
|
+
def digits(field, value, max: 20, exactly: nil)
|
|
58
|
+
string = value.to_s.gsub(/\D/, '')
|
|
59
|
+
invalid(field, 'digits only', value) if string.empty?
|
|
60
|
+
invalid(field, "#{exactly} digits", value) if exactly && string.length != exactly
|
|
61
|
+
invalid(field, "at most #{max} digits", value) if string.length > max
|
|
62
|
+
string
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def company(field, value)
|
|
66
|
+
return value.to_s if Reference::Distributor.find_by_code(value)
|
|
67
|
+
|
|
68
|
+
codes = Reference::Distributor.all.map(&:code).join(', ')
|
|
69
|
+
invalid(field, "one of #{codes}", value)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def installments(field, value)
|
|
73
|
+
number = value.is_a?(Integer) ? value : Integer(value.to_s, 10)
|
|
74
|
+
return number if number == INFINITE_INSTALLMENTS || number.between?(1,
|
|
75
|
+
MAX_INSTALLMENTS)
|
|
76
|
+
|
|
77
|
+
invalid(field, "-1 or 1 to #{MAX_INSTALLMENTS}", value)
|
|
78
|
+
rescue ArgumentError, TypeError
|
|
79
|
+
invalid(field, "-1 or 1 to #{MAX_INSTALLMENTS}", value)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Dot as decimal separator, at most two places. Strings with comma are refused.
|
|
83
|
+
# Pure string arithmetic: bigdecimal is a bundled gem since Ruby 3.4, not stdlib.
|
|
84
|
+
def amount(field, value, allow_zero: false)
|
|
85
|
+
whole, fraction = decimal_text(field, value).split('.')
|
|
86
|
+
if whole.match?(/\A0+\z/) && fraction.to_s.match?(/\A0*\z/) && !allow_zero
|
|
87
|
+
invalid(field, 'positive', value)
|
|
88
|
+
end
|
|
89
|
+
"#{Integer(whole, 10)}.#{fraction.to_s.ljust(2, '0')}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# A BigDecimal prints as plain text with to_s('F'); any other value uses to_s.
|
|
93
|
+
def decimal_text(field, value)
|
|
94
|
+
text = bigdecimal?(value) ? value.to_s('F') : value.to_s
|
|
95
|
+
return text if text.match?(/\A\d+(\.\d{1,2})?\z/)
|
|
96
|
+
|
|
97
|
+
invalid(field, 'positive number with dot and up to 2 decimals', value)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# By name, so the constant is not needed (and the gem not required) here.
|
|
101
|
+
def bigdecimal?(value)
|
|
102
|
+
value.class.name == 'BigDecimal' # rubocop:disable Style/ClassEqualityComparison
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def date(field, value)
|
|
106
|
+
return value.strftime('%Y-%m-%d') if value.is_a?(Date)
|
|
107
|
+
|
|
108
|
+
string = value.to_s
|
|
109
|
+
unless string.match?(/\A\d{4}-\d{2}-\d{2}\z/)
|
|
110
|
+
invalid(field, 'format YYYY-MM-DD',
|
|
111
|
+
value)
|
|
112
|
+
end
|
|
113
|
+
Date.strptime(string, '%Y-%m-%d')
|
|
114
|
+
string
|
|
115
|
+
rescue Date::Error
|
|
116
|
+
invalid(field, 'format YYYY-MM-DD', value)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def one_of(field, value, allowed)
|
|
120
|
+
return value.to_s if allowed.include?(value.to_s)
|
|
121
|
+
|
|
122
|
+
invalid(field, "one of #{allowed.join(', ')}", value)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
data/lib/enel_x.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'enel_x/version'
|
|
4
|
+
require_relative 'enel_x/errors'
|
|
5
|
+
require_relative 'enel_x/error_catalog'
|
|
6
|
+
require_relative 'enel_x/response_parser'
|
|
7
|
+
require_relative 'enel_x/configuration'
|
|
8
|
+
require_relative 'enel_x/filter'
|
|
9
|
+
require_relative 'enel_x/endpoint'
|
|
10
|
+
require_relative 'enel_x/headers'
|
|
11
|
+
require_relative 'enel_x/reference'
|
|
12
|
+
require_relative 'enel_x/validators'
|
|
13
|
+
require_relative 'enel_x/attachment'
|
|
14
|
+
require_relative 'enel_x/record'
|
|
15
|
+
require_relative 'enel_x/result'
|
|
16
|
+
require_relative 'enel_x/client'
|
|
17
|
+
|
|
18
|
+
module EnelX
|
|
19
|
+
class << self
|
|
20
|
+
attr_writer :configuration
|
|
21
|
+
|
|
22
|
+
# Restores global state; specs call it before and after each example.
|
|
23
|
+
def reset!
|
|
24
|
+
@configuration = nil
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|