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,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
|
|
5
|
+
require_relative '../endpoint'
|
|
6
|
+
require_relative '../errors'
|
|
7
|
+
require_relative '../record'
|
|
8
|
+
require_relative '../validators'
|
|
9
|
+
|
|
10
|
+
module EnelX
|
|
11
|
+
module Endpoints
|
|
12
|
+
# History queries (PDF 5.6 to 5.10). Fields use the names of the PDF; the API has no
|
|
13
|
+
# webhooks, so this is the only way to learn what happened to a sale.
|
|
14
|
+
class History
|
|
15
|
+
# forms: name => [required fields, optional fields]
|
|
16
|
+
Query = Struct.new(:service, :path, :forms, :record, keyword_init: true)
|
|
17
|
+
|
|
18
|
+
BY_CUSTOMER = %w[empresa numeroCliente empresaParc].freeze
|
|
19
|
+
BY_DATE = %w[empresa dataOcorrencia empresaParc].freeze
|
|
20
|
+
|
|
21
|
+
QUERIES = {
|
|
22
|
+
subscription_history: Query.new(
|
|
23
|
+
service: :subscription_history, path: '/services/bdp/v1/subscription-history',
|
|
24
|
+
forms: { customer: [BY_CUSTOMER + %w[codProduto], []],
|
|
25
|
+
date: [%w[empresa empresaParc dataOcorrencia], %w[operacao]] },
|
|
26
|
+
record: Subscription
|
|
27
|
+
),
|
|
28
|
+
request_history: Query.new(
|
|
29
|
+
service: :request_history, path: '/services/bdp/v1/request-history',
|
|
30
|
+
forms: { customer: [BY_CUSTOMER, %w[codProduto]],
|
|
31
|
+
date: [BY_DATE, %w[codProduto]] },
|
|
32
|
+
record: RequestHistoryItem
|
|
33
|
+
),
|
|
34
|
+
invoice_history: Query.new(
|
|
35
|
+
service: :invoice_history, path: '/services/bdp/v1/invoice-history',
|
|
36
|
+
forms: { customer: [BY_CUSTOMER, %w[codProduto]],
|
|
37
|
+
date: [BY_DATE, %w[codProduto]] },
|
|
38
|
+
record: InvoiceHistoryItem
|
|
39
|
+
),
|
|
40
|
+
collections: Query.new(
|
|
41
|
+
service: :collections, path: '/services/bdp/v1/invoice',
|
|
42
|
+
forms: { date: [BY_DATE, %w[codProduto]] }, record: Collection
|
|
43
|
+
),
|
|
44
|
+
movements: Query.new(
|
|
45
|
+
service: :movements, path: '/services/bdp/v1/movement',
|
|
46
|
+
forms: { customer: [BY_CUSTOMER + %w[codProduto], []] }, record: Movement
|
|
47
|
+
),
|
|
48
|
+
}.freeze
|
|
49
|
+
|
|
50
|
+
OPERATIONS = %w[I C].freeze
|
|
51
|
+
|
|
52
|
+
def initialize(client, name)
|
|
53
|
+
@client = client
|
|
54
|
+
@query = QUERIES.fetch(name)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Returns an Array of records. Needs `empresaParc` or a configured `partner_code`.
|
|
58
|
+
# [ASSUMPTION p.26-33] the PDF fixes no ClientSystem/ServerSystem/Functionality for
|
|
59
|
+
# the bdp queries, only "Read" for GET (p.11): Read/Read, the configured client
|
|
60
|
+
# system and XCustomerB2CBR, as in the writes.
|
|
61
|
+
def call(fields, context: nil)
|
|
62
|
+
params = build_params(normalize(fields))
|
|
63
|
+
endpoint = Endpoint.new(
|
|
64
|
+
service: @query.service, verb: :get, path: @query.path, operation: 'Read',
|
|
65
|
+
functionality: 'Read', server_system: 'XCustomerB2CBR'
|
|
66
|
+
)
|
|
67
|
+
items(@client.request(endpoint, payload: params, context:).body)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def normalize(fields)
|
|
73
|
+
unless fields.respond_to?(:to_h)
|
|
74
|
+
Validators.invalid('fields', 'must be a Hash with the PDF field names')
|
|
75
|
+
end
|
|
76
|
+
fields = fields.to_h.transform_keys(&:to_s)
|
|
77
|
+
partner = @client.configuration.partner_code
|
|
78
|
+
fields['empresaParc'] ||= partner.to_s unless partner.nil?
|
|
79
|
+
fields
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def build_params(fields)
|
|
83
|
+
required, optional = @query.forms.fetch(form_for(fields))
|
|
84
|
+
unknown = fields.keys - required - optional
|
|
85
|
+
unless unknown.empty?
|
|
86
|
+
Validators.invalid(unknown.first,
|
|
87
|
+
'not a field of this query')
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
(required + optional).each_with_object({}) do |name, params|
|
|
91
|
+
next if optional.include?(name) && !fields.key?(name)
|
|
92
|
+
|
|
93
|
+
params[name] = value_for(name, fields[name])
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def form_for(fields)
|
|
98
|
+
forms = @query.forms
|
|
99
|
+
return forms.keys.first if forms.one?
|
|
100
|
+
|
|
101
|
+
fields.key?('numeroCliente') ? :customer : :date
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def value_for(name, value)
|
|
105
|
+
case name
|
|
106
|
+
when 'empresa' then Validators.company(name, value)
|
|
107
|
+
when 'numeroCliente' then Validators.text(name, value, max: 80)
|
|
108
|
+
when 'dataOcorrencia' then us_date(name, value)
|
|
109
|
+
when 'operacao' then Validators.one_of(name, value, OPERATIONS)
|
|
110
|
+
else Validators.text(name, value, max: 255)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The API wants MM/DD/YYYY. Accepts Date, Time or a YYYY-MM-DD string.
|
|
115
|
+
def us_date(name, value)
|
|
116
|
+
date = if value.respond_to?(:to_date)
|
|
117
|
+
value.to_date
|
|
118
|
+
else
|
|
119
|
+
Date.strptime(value.to_s,
|
|
120
|
+
'%Y-%m-%d')
|
|
121
|
+
end
|
|
122
|
+
date.strftime('%m/%d/%Y')
|
|
123
|
+
rescue Date::Error, NoMethodError
|
|
124
|
+
Validators.invalid(name, 'Date, Time or YYYY-MM-DD', value)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def items(body)
|
|
128
|
+
list = body.is_a?(Array) ? body : body.values.find { |value| value.is_a?(Array) }
|
|
129
|
+
unless list&.all?(Hash)
|
|
130
|
+
raise Error.new('unexpected history response', kind: :retryable)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
list.map { |item| @query.record.from_hash(item) }
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'order_helpers'
|
|
4
|
+
|
|
5
|
+
module EnelX
|
|
6
|
+
module Endpoints
|
|
7
|
+
# Alteração de Valores (PDF 5.4).
|
|
8
|
+
class OrderChange
|
|
9
|
+
include OrderHelpers
|
|
10
|
+
|
|
11
|
+
ENDPOINT = Endpoint.new(
|
|
12
|
+
service: :order_change, path: '/services/v1/third-party-billing/order/change',
|
|
13
|
+
**WRITE
|
|
14
|
+
)
|
|
15
|
+
ALLOWED = %w[
|
|
16
|
+
Nome Sobrenome Endereco EnderecoNumero Bairro Cidade CEP empresa numeroCliente
|
|
17
|
+
numParcelas valorParcela codProduto codOcorr dataOcorrencia codCanalVenda
|
|
18
|
+
numeroClienteEmpPar
|
|
19
|
+
].freeze
|
|
20
|
+
# Table 7.1: 61 is "Alteração de débito de produto".
|
|
21
|
+
DEFAULT_OCCURRENCE = Reference::PartnerCode::CHANGE_CODE
|
|
22
|
+
|
|
23
|
+
def initialize(client)
|
|
24
|
+
@client = client
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call(fields, context: nil)
|
|
28
|
+
fields = stringify(fields, ALLOWED)
|
|
29
|
+
send_order(ENDPOINT, 'OrderChange', build(fields), context)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def build(fields)
|
|
35
|
+
person(fields).merge(
|
|
36
|
+
customer(fields), charge(fields), product(fields), occurrence(fields),
|
|
37
|
+
occurrence_date(fields), address(fields), partner_optionals(fields)
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def charge(fields)
|
|
42
|
+
{
|
|
43
|
+
'numParcelas' => Validators.installments('numParcelas', fields['numParcelas']),
|
|
44
|
+
'valorParcela' => Validators.amount('valorParcela', fields['valorParcela']),
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# [ASSUMPTION p.22] the 5.4.4 example sends codOcorr "32"; table 7.1 defines 61 for
|
|
49
|
+
# changes, so 61 is the default and any other code is left to the caller.
|
|
50
|
+
def occurrence(fields)
|
|
51
|
+
value = fields.fetch('codOcorr', DEFAULT_OCCURRENCE)
|
|
52
|
+
{ 'codOcorr' => Validators.text('codOcorr', value, max: 255) }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'order_helpers'
|
|
4
|
+
|
|
5
|
+
module EnelX
|
|
6
|
+
module Endpoints
|
|
7
|
+
# Cancelamento (PDF 5.5). Every required field must carry a value, otherwise the API
|
|
8
|
+
# processes nothing and returns nothing.
|
|
9
|
+
class OrderDisconnect
|
|
10
|
+
include OrderHelpers
|
|
11
|
+
|
|
12
|
+
ENDPOINT = Endpoint.new(
|
|
13
|
+
service: :order_disconnect,
|
|
14
|
+
path: '/services/v1/third-party-billing/order/disconnect', **WRITE
|
|
15
|
+
)
|
|
16
|
+
ALLOWED = %w[
|
|
17
|
+
Nome Sobrenome empresa numeroCliente valorParcela codProduto codOcorr
|
|
18
|
+
dataOcorrencia codCanalVenda Endereco EnderecoNumero Bairro Cidade CEP
|
|
19
|
+
numeroClienteEmpPar
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
def initialize(client)
|
|
23
|
+
@client = client
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call(fields, context: nil)
|
|
27
|
+
fields = stringify(fields, ALLOWED)
|
|
28
|
+
send_order(ENDPOINT, 'OrderDisconnect', build(fields), context)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# [ASSUMPTION p.25] the 5.5.4 example sends numParcelas but 5.5.2 does not list it:
|
|
34
|
+
# the SDK does not send it. The same example uses codOcorr "20", which is an
|
|
35
|
+
# automatic reason of 7.2; table 7.1 only allows 22 to 32 from the partner.
|
|
36
|
+
def build(fields)
|
|
37
|
+
person(fields).merge(
|
|
38
|
+
customer(fields), 'valorParcela' => amount(fields), **product(fields),
|
|
39
|
+
'codOcorr' => reason(fields), **occurrence_date(fields),
|
|
40
|
+
**address(fields), **partner_optionals(fields)
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def amount(fields)
|
|
45
|
+
Validators.amount('valorParcela', fields['valorParcela'], allow_zero: true)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def reason(fields)
|
|
49
|
+
codes = Reference::PartnerCode.cancellation_reasons.map(&:code)
|
|
50
|
+
Validators.one_of('codOcorr', fields['codOcorr'], codes)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../endpoint'
|
|
4
|
+
require_relative '../reference/partner_code'
|
|
5
|
+
require_relative '../validators'
|
|
6
|
+
|
|
7
|
+
module EnelX
|
|
8
|
+
module Endpoints
|
|
9
|
+
# Field groups shared by sale, change and disconnect (PDF 5.3.2, 5.4.2, 5.5.2).
|
|
10
|
+
# Callers pass fields named as in the PDF; string or symbol keys both work.
|
|
11
|
+
module OrderHelpers
|
|
12
|
+
WRITE = { verb: :post, operation: 'Create', functionality: 'Create',
|
|
13
|
+
server_system: 'XCustomerB2CBR' }.freeze
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def stringify(fields, allowed)
|
|
18
|
+
unless fields.respond_to?(:to_h)
|
|
19
|
+
Validators.invalid('fields', 'must be a Hash with the PDF field names')
|
|
20
|
+
end
|
|
21
|
+
fields = fields.to_h.transform_keys(&:to_s)
|
|
22
|
+
unknown = fields.keys - allowed
|
|
23
|
+
unless unknown.empty?
|
|
24
|
+
Validators.invalid(unknown.first,
|
|
25
|
+
'not a field of this operation')
|
|
26
|
+
end
|
|
27
|
+
fields
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def person(fields)
|
|
31
|
+
{
|
|
32
|
+
'Nome' => Validators.text('Nome', fields['Nome'], max: 40),
|
|
33
|
+
'Sobrenome' => Validators.text('Sobrenome', fields['Sobrenome'], max: 40),
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def address(fields)
|
|
38
|
+
{
|
|
39
|
+
'Endereco' => Validators.text('Endereco', fields['Endereco'], max: 255),
|
|
40
|
+
'EnderecoNumero' =>
|
|
41
|
+
Validators.text('EnderecoNumero', fields['EnderecoNumero'], max: 100),
|
|
42
|
+
# [ASSUMPTION p.24] 5.5.2 lists Bairro as String(255); 40 applies everywhere
|
|
43
|
+
# because a longer value makes the sale return nothing (p.15).
|
|
44
|
+
'Bairro' => Validators.text('Bairro', fields['Bairro'], max: 40),
|
|
45
|
+
'Cidade' => Validators.text('Cidade', fields['Cidade'], max: 255),
|
|
46
|
+
'CEP' => Validators.cep('CEP', fields['CEP']),
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def customer(fields)
|
|
51
|
+
{
|
|
52
|
+
'empresa' => Validators.company('empresa', fields['empresa']),
|
|
53
|
+
'numeroCliente' =>
|
|
54
|
+
Validators.text('numeroCliente', fields['numeroCliente'], max: 80),
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def product(fields)
|
|
59
|
+
{ 'codProduto' => Validators.text('codProduto', fields['codProduto'], max: 255) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def occurrence_date(fields)
|
|
63
|
+
{ 'dataOcorrencia' => Validators.date('dataOcorrencia',
|
|
64
|
+
fields['dataOcorrencia']) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def partner_optionals(fields)
|
|
68
|
+
%w[codCanalVenda numeroClienteEmpPar].each_with_object({}) do |name, hash|
|
|
69
|
+
next unless fields.key?(name)
|
|
70
|
+
|
|
71
|
+
hash[name] = Validators.text(name, fields[name], max: 255, required: false)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def send_order(endpoint, root, body, context)
|
|
76
|
+
@client.request(endpoint, payload: { root => body }, context:)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
|
|
5
|
+
require_relative '../attachment'
|
|
6
|
+
require_relative 'order_helpers'
|
|
7
|
+
|
|
8
|
+
module EnelX
|
|
9
|
+
module Endpoints
|
|
10
|
+
# Solicitação de Venda (PDF 5.3), residential and business customers.
|
|
11
|
+
class OrderSale
|
|
12
|
+
include OrderHelpers
|
|
13
|
+
|
|
14
|
+
ENDPOINT = Endpoint.new(
|
|
15
|
+
service: :order_sale, path: '/services/v2/third-party-billing/order/sale',
|
|
16
|
+
**WRITE
|
|
17
|
+
)
|
|
18
|
+
ALLOWED = %w[
|
|
19
|
+
Nome Sobrenome dtNascimento Telefone Email Endereco EnderecoNumero Bairro Cidade
|
|
20
|
+
CEP empresa numeroCliente CPF numParcelas valorParcela codProduto dataOcorrencia
|
|
21
|
+
codCanalVenda numeroClienteEmpPar anexos CNPJ NomeEmpresa
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
def initialize(client)
|
|
25
|
+
@client = client
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Success only means "received": the sale is confirmed by occurrences 996 and 998
|
|
29
|
+
# (Consulta de Ocorrências). Cidade must be the exact IBGE name; the SDK cannot
|
|
30
|
+
# check that.
|
|
31
|
+
def call(fields, context: nil)
|
|
32
|
+
body = build(stringify(fields, ALLOWED))
|
|
33
|
+
warn_past_date(body['dataOcorrencia'])
|
|
34
|
+
send_order(ENDPOINT, 'OrderSale', body, context)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def build(fields)
|
|
40
|
+
business(fields).merge(person(fields), contact(fields), address(fields),
|
|
41
|
+
customer(fields), charge(fields), product(fields),
|
|
42
|
+
occurrence_date(fields), partner_optionals(fields),
|
|
43
|
+
'anexos' => attachments(fields['anexos']))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def business(fields)
|
|
47
|
+
return {} unless fields.key?('CNPJ')
|
|
48
|
+
|
|
49
|
+
{
|
|
50
|
+
'CNPJ' => Validators.digits('CNPJ', fields['CNPJ'], exactly: 14),
|
|
51
|
+
'NomeEmpresa' => Validators.text('NomeEmpresa', fields['NomeEmpresa'], max: 40),
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def contact(fields)
|
|
56
|
+
contact = {
|
|
57
|
+
'Telefone' => Validators.phone('Telefone', fields['Telefone']),
|
|
58
|
+
'Email' => Validators.email('Email', fields['Email']),
|
|
59
|
+
'CPF' => Validators.digits('CPF', fields['CPF']),
|
|
60
|
+
}
|
|
61
|
+
return contact unless present?(fields['dtNascimento'])
|
|
62
|
+
|
|
63
|
+
contact.merge('dtNascimento' => Validators.date('dtNascimento',
|
|
64
|
+
fields['dtNascimento']))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def charge(fields)
|
|
68
|
+
{
|
|
69
|
+
'numParcelas' => Validators.installments('numParcelas', fields['numParcelas']),
|
|
70
|
+
'valorParcela' => Validators.amount('valorParcela', fields['valorParcela']),
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def attachments(list)
|
|
75
|
+
items = Array(list).map { |item| Attachment.coerce(item) }
|
|
76
|
+
if items.empty?
|
|
77
|
+
Validators.invalid('anexos',
|
|
78
|
+
'at least one attachment is required')
|
|
79
|
+
end
|
|
80
|
+
unless items.any?(&:adhesion?)
|
|
81
|
+
Validators.invalid('anexos', 'one attachment must be of type Termo de Adesão')
|
|
82
|
+
end
|
|
83
|
+
items.map(&:to_h)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def present?(value)
|
|
87
|
+
!value.nil? && !value.to_s.strip.empty?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def warn_past_date(date)
|
|
91
|
+
return unless Date.strptime(date, '%Y-%m-%d') < Date.today
|
|
92
|
+
|
|
93
|
+
@client.configuration.logger.warn(
|
|
94
|
+
"EnelX order_sale: dataOcorrencia #{date} is in the past; billing periods " \
|
|
95
|
+
'before the start date may not be invoiced (PDF 5.3.2)',
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'errors'
|
|
4
|
+
|
|
5
|
+
module EnelX
|
|
6
|
+
# PDF 7.3 as data. Specific data errors come first because many of them also start
|
|
7
|
+
# with "ERROR." or "INTERNAL ERROR", which are retryable only when generic.
|
|
8
|
+
module ErrorCatalog
|
|
9
|
+
Entry = Struct.new(:pattern, :kind, :reason, :error_class)
|
|
10
|
+
|
|
11
|
+
def self.data(pattern, reason = nil, error_class = Error)
|
|
12
|
+
Entry.new(pattern, :data, reason, error_class)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.retryable(pattern)
|
|
16
|
+
Entry.new(pattern, :retryable, nil, Error)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.prefixes(lead, *tails)
|
|
20
|
+
/\A#{Regexp.escape(lead)}(?:#{tails.map { |tail| Regexp.escape(tail) }.join('|')})/
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
ENTRIES = [
|
|
24
|
+
data(/\AERROR[.:] Account not found/, :account_not_found, UcNotFound),
|
|
25
|
+
data(/\AERROR: POS not found/, :pos_not_found, UcNotFound),
|
|
26
|
+
# [ASSUMPTION p.37] the PDF does not say Contract/Asset not found mean a missing UC,
|
|
27
|
+
# so they are not UcNotFound.
|
|
28
|
+
data(/\AERROR\. Contract not found/, :contract_not_found),
|
|
29
|
+
data(/\AERROR\. Asset not found/, :asset_not_found),
|
|
30
|
+
data(/\AINTERNAL ERROR\. This opportunity already exists/, :opportunity_exists),
|
|
31
|
+
data(/\AINTERNAL ERROR: An Account with the provided document already exists/,
|
|
32
|
+
:document_in_use),
|
|
33
|
+
data(/\AINTERNAL ERROR: An Account with the provided email already exists/,
|
|
34
|
+
:email_in_use),
|
|
35
|
+
data(/\AINTERNAL ERROR: An Account with the provided email and document/,
|
|
36
|
+
:email_and_document_in_use),
|
|
37
|
+
data(/\AError on CartAPI with transactionID/),
|
|
38
|
+
data(prefixes('ERROR. ', 'Address ', 'Address.PointOfService.', 'BillingProfile.',
|
|
39
|
+
'CatalogItem', 'Contact', 'Format of Identity', 'Identity',
|
|
40
|
+
'Missing mandatory field', 'PointOfService', 'The Identity',
|
|
41
|
+
'There is not a valid contact')),
|
|
42
|
+
data(prefixes('ERROR: ', 'Address section', 'Market Account validation',
|
|
43
|
+
'Missing identityNumber', 'You must fill both')),
|
|
44
|
+
data(prefixes('INTERNAL ERROR: ', 'Address with id', 'In Address',
|
|
45
|
+
'In BillingProfile',
|
|
46
|
+
'In Contact', 'In Identity', 'In Update Billing', 'Missing Address',
|
|
47
|
+
'No Billing Profile')),
|
|
48
|
+
# [ASSUMPTION p.22, p.37] only a bare "Bad Request" is the generic retryable error;
|
|
49
|
+
# "Bad Request. <detail>" (example 2 of 5.4.4) names a data problem.
|
|
50
|
+
retryable(/\ABad Request\.?\z/),
|
|
51
|
+
retryable(/\AERROR\./),
|
|
52
|
+
retryable(/\AGateway Timeout/),
|
|
53
|
+
retryable(/\AINTERNAL ERROR/),
|
|
54
|
+
retryable(/\AResponse code 500 mapped as failure/),
|
|
55
|
+
retryable(/\AServer Error/),
|
|
56
|
+
].freeze
|
|
57
|
+
|
|
58
|
+
# Unknown messages are :data, so the consumer never loops on a data error.
|
|
59
|
+
UNKNOWN = Entry.new(nil, :data, nil, Error)
|
|
60
|
+
|
|
61
|
+
module_function
|
|
62
|
+
|
|
63
|
+
def classify(message)
|
|
64
|
+
text = message.to_s.gsub(/\s+/, ' ').strip
|
|
65
|
+
ENTRIES.find { |entry| entry.pattern.match?(text) } || UNKNOWN
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def build_error(message, code: nil, transaction_id: nil)
|
|
69
|
+
entry = classify(message)
|
|
70
|
+
entry.error_class.new(
|
|
71
|
+
message, kind: entry.kind, reason: entry.reason, code:,
|
|
72
|
+
transaction_id:
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EnelX
|
|
4
|
+
# kind: :retryable (resend as is), :data (fix the input) or :configuration.
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
attr_reader :kind, :code, :transaction_id, :reason
|
|
7
|
+
|
|
8
|
+
def initialize(message = nil, kind: :data, code: nil, transaction_id: nil,
|
|
9
|
+
reason: nil)
|
|
10
|
+
super(message)
|
|
11
|
+
@kind = kind
|
|
12
|
+
@code = code
|
|
13
|
+
@transaction_id = transaction_id
|
|
14
|
+
@reason = reason
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Bad credentials, host or persistent 401: the consumer must fix its setup.
|
|
19
|
+
class ConfigurationError < Error
|
|
20
|
+
def initialize(message = nil, **)
|
|
21
|
+
super(message, **, kind: :configuration)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Raised before any network call. value_preview is already filtered of personal data.
|
|
26
|
+
class ValidationError < Error
|
|
27
|
+
attr_reader :field, :limit, :value_preview
|
|
28
|
+
|
|
29
|
+
def initialize(message, field:, limit: nil, value_preview: nil)
|
|
30
|
+
super(message, kind: :data)
|
|
31
|
+
@field = field
|
|
32
|
+
@limit = limit
|
|
33
|
+
@value_preview = value_preview
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The distributor does not know the consumption unit (PDF 7.3: Account/POS not found).
|
|
38
|
+
class UcNotFound < Error; end
|
|
39
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EnelX
|
|
4
|
+
# Removes secrets, attachments and personal data before anything leaves the SDK.
|
|
5
|
+
module Filter
|
|
6
|
+
FILTERED = '[FILTERED]'
|
|
7
|
+
|
|
8
|
+
HIDDEN_KEYS = %w[
|
|
9
|
+
authorization clientsecret accesstoken conteudobase64 nome sobrenome bairro cidade
|
|
10
|
+
cep telefone email dtnascimento nomeempresa commoditycustomername
|
|
11
|
+
contractbillingaddress addressdata
|
|
12
|
+
].freeze
|
|
13
|
+
HIDDEN_PREFIXES = %w[endereco].freeze
|
|
14
|
+
MASKED_KEYS = %w[cpf cnpj identitynumber commodityidentitynumber].freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def apply(value, key = nil)
|
|
19
|
+
case value
|
|
20
|
+
when Hash then value.to_h { |name, item| [name, apply(item, name)] }
|
|
21
|
+
when Array then value.map { |item| apply(item, key) }
|
|
22
|
+
else scalar(value, normalize(key))
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def scalar(value, key)
|
|
27
|
+
return value if value.nil? || key.nil?
|
|
28
|
+
return FILTERED if hidden?(key)
|
|
29
|
+
|
|
30
|
+
MASKED_KEYS.include?(key) ? mask(value) : value
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def hidden?(key)
|
|
34
|
+
HIDDEN_KEYS.include?(key) || HIDDEN_PREFIXES.any? do |prefix|
|
|
35
|
+
key.start_with?(prefix)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Keeps only the last two digits (PDF documents are CPF or CNPJ).
|
|
40
|
+
def mask(value)
|
|
41
|
+
digits = value.to_s.gsub(/\D/, '')
|
|
42
|
+
case digits.length
|
|
43
|
+
when 11 then "***.***.***-#{digits[-2..]}"
|
|
44
|
+
when 14 then "**.***.***/****-#{digits[-2..]}"
|
|
45
|
+
else FILTERED
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def normalize(key)
|
|
50
|
+
key.to_s.downcase.gsub(/[^a-z0-9]/, '') unless key.nil?
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module EnelX
|
|
6
|
+
# Per-call headers from PDF 5.1. Authorization is added by the client.
|
|
7
|
+
module Headers
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def build(operation:, functionality:, client_system:, server_system:)
|
|
11
|
+
{
|
|
12
|
+
'Content-Type' => 'application/json; charset=UTF-8',
|
|
13
|
+
'cache-control' => 'no-cache',
|
|
14
|
+
'TransactionID' => SecureRandom.uuid,
|
|
15
|
+
'DateTimeMessage' => timestamp(Time.now),
|
|
16
|
+
'Operation' => operation,
|
|
17
|
+
'Functionality' => functionality,
|
|
18
|
+
'ClientSystem' => client_system,
|
|
19
|
+
'ServerSystem' => server_system,
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# YYYYMMDDHH24MISSmil: 17 characters, milliseconds last.
|
|
24
|
+
def timestamp(time)
|
|
25
|
+
time.strftime('%Y%m%d%H%M%S%L')
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
data/lib/enel_x/http.rb
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require 'timeout'
|
|
6
|
+
require 'uri'
|
|
7
|
+
|
|
8
|
+
require_relative 'errors'
|
|
9
|
+
|
|
10
|
+
module EnelX
|
|
11
|
+
# Thin net/http wrapper: network failures become retryable EnelX::Error.
|
|
12
|
+
class Http
|
|
13
|
+
Response = Struct.new(:status, :body)
|
|
14
|
+
|
|
15
|
+
NETWORK_ERRORS = [
|
|
16
|
+
Timeout::Error, SocketError, SystemCallError, IOError, EOFError, OpenSSL::SSL::SSLError,
|
|
17
|
+
Net::OpenTimeout, Net::ReadTimeout
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
def initialize(open_timeout:, read_timeout:)
|
|
21
|
+
@open_timeout = open_timeout
|
|
22
|
+
@read_timeout = read_timeout
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def request(method, url, headers: {}, body: nil)
|
|
26
|
+
uri = URI(url)
|
|
27
|
+
request = Net::HTTP.const_get(method.to_s.capitalize).new(uri)
|
|
28
|
+
headers.each { |name, value| request[name] = value }
|
|
29
|
+
request.body = body if body
|
|
30
|
+
|
|
31
|
+
response = connection(uri).request(request)
|
|
32
|
+
Response.new(response.code.to_i, response.body.to_s)
|
|
33
|
+
rescue *NETWORK_ERRORS => e
|
|
34
|
+
raise Error.new("network failure: #{e.class}", kind: :retryable)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def connection(uri)
|
|
40
|
+
Net::HTTP.new(uri.host, uri.port).tap do |http|
|
|
41
|
+
http.use_ssl = uri.scheme == 'https'
|
|
42
|
+
http.open_timeout = @open_timeout
|
|
43
|
+
http.read_timeout = @read_timeout
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|