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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EnelX
4
+ VERSION = File.read(File.expand_path('../../REVISION', __dir__)).strip
5
+ 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