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 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