conexa 0.1.0 → 0.2.0

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.
data/lib/conexa/model.rb CHANGED
@@ -15,16 +15,18 @@ module Conexa
15
15
  #
16
16
  # == Primary Key
17
17
  #
18
- # Each resource needs primary_key_attribute for :id alias and operations
19
- # that require the resource ID (destroy, save, fetch, etc.):
18
+ # Each resource declares primary_key_attribute, which defines #id and the
19
+ # operations that need the resource ID (destroy, save, fetch, etc.):
20
20
  #
21
21
  # class Charge < Model
22
22
  # primary_key_attribute :charge_id
23
23
  # end
24
24
  #
25
- # charge.id # => 123 (alias for charge_id)
26
25
  # charge.charge_id # => 123
27
26
  # charge.chargeId # => 123 (camelCase alias for backwards compat)
27
+ # charge.id # => 123 — the resource's own key, falling back to a
28
+ # # plain "id" attribute, which is what write endpoints
29
+ # # return and what Model#create reads back
28
30
  #
29
31
  # == Why explicit primary_key_attribute?
30
32
  #
@@ -34,17 +36,38 @@ module Conexa
34
36
  #
35
37
  class Model < ConexaObject
36
38
  def create
37
- set_primary_key Conexa::Request.post(self.class.show_url, params: to_hash).call(class_name).attributes['id']
39
+ created = Conexa::Request.post(self.class.show_url, params: to_hash).call(class_name)
40
+
41
+ # A create that answers with no usable body leaves us nothing to identify
42
+ # the new record by, so there is nothing to re-fetch. Returning the local
43
+ # object is honest; raising NoMethodError from `nil.attributes` was not.
44
+ return self unless created.respond_to?(:attributes)
45
+
46
+ set_primary_key created.attributes['id']
38
47
  fetch
39
48
  end
40
49
 
41
50
  def save
51
+ # #destroy has always guarded this; #save did not, so an object with no id
52
+ # silently issued `PATCH /customer/` instead of failing fast.
53
+ raise RequestError.new('Invalid ID') unless id.present?
54
+
42
55
  update Conexa::Request.patch(self.class.show_url(primary_key), params: unsaved_attributes).call(class_name)
43
56
  self
44
57
  end
45
58
 
46
59
  def fetch
47
- update self.class.find(primary_key)
60
+ fetched = self.class.find(primary_key)
61
+
62
+ # #update ignores anything with no attributes, which is right for a write
63
+ # that answers with no body — but a *refresh* that comes back empty must
64
+ # not quietly leave stale values in place reporting success.
65
+ unless fetched.respond_to?(:attributes)
66
+ raise ResponseError.new({ url: self.class.show_url(primary_key) }, nil,
67
+ "a API respondeu sem corpo: nada para atualizar")
68
+ end
69
+
70
+ update fetched
48
71
  self
49
72
  end
50
73
 
@@ -75,10 +98,10 @@ module Conexa
75
98
  end
76
99
 
77
100
  class << self
78
- # DSL for primary key attribute with :id alias
101
+ # DSL for the primary key attribute
79
102
  # @example
80
103
  # primary_key_attribute :charge_id
81
- # # Generates: charge_id method + chargeId alias + id alias
104
+ # # Generates: charge_id + chargeId alias + #id (with an "id" fallback)
82
105
  def primary_key_attribute(snake_name)
83
106
  camel_name = Util.camelize_str(snake_name.to_s)
84
107
 
@@ -87,7 +110,14 @@ module Conexa
87
110
  end
88
111
 
89
112
  alias_method camel_name.to_sym, snake_name
90
- alias_method :id, snake_name
113
+
114
+ # Not an alias: #id has to keep Model#id's documented fallback to a plain
115
+ # "id" attribute. Write endpoints answer with {"id": N} rather than the
116
+ # resource's own key — Model#create depends on exactly that — so aliasing
117
+ # #id straight to #charge_id silently made the fallback dead code.
118
+ define_method(:id) do
119
+ @attributes[snake_name.to_s] || @attributes["id"]
120
+ end
91
121
  end
92
122
 
93
123
  def create(*args)
@@ -95,19 +125,32 @@ module Conexa
95
125
  end
96
126
 
97
127
  def find_by_id(id, **options)
128
+ # Surrounding whitespace is a copy-paste artefact, not a different id —
129
+ # strip it rather than failing. Anything still unusable in a URL is caught
130
+ # by Request#full_api_url and raised as a RequestError.
131
+ id = id.to_s.strip if id.is_a?(String)
98
132
  raise RequestError.new('Invalid ID') unless id.present?
133
+
99
134
  Conexa::Request.get(show_url(id), params: options).call underscored_class_name
100
135
  end
101
136
  alias :find :find_by_id
102
137
 
103
138
  def find_by(params = Hash.new, page = nil, size = nil)
139
+ # extract_page_size_or_params always returns limit/offset now, and
140
+ # validates them, so there is no page/size left here to guard.
104
141
  params = extract_page_size_or_params(page, size, **params)
105
- raise RequestError.new('Invalid page size') if (!params.key?(:limit)) && (params[:page] < 1 or params[:size] < 1)
106
142
 
107
- Conexa::Request.get(url, params: params).call(
143
+ result = Conexa::Request.get(url, params: params).call(
108
144
  underscored_class_name,
109
145
  query_context: { resource_class: self, params: params }
110
146
  )
147
+
148
+ # A listing always answers with a Result, as the READMEs promise. Without
149
+ # this, an empty body yielded nil and a bare-array body yielded an Array,
150
+ # so `.data` / `.pagination` / `.next_page` blew up far from the cause.
151
+ return result if result.is_a?(Conexa::Result)
152
+
153
+ Conexa::Result.new("data" => Array(result), "pagination" => nil)
111
154
  end
112
155
  alias :find_by_hash :find_by
113
156
 
@@ -166,11 +209,31 @@ module Conexa
166
209
  return params
167
210
  end
168
211
 
169
- # Explicit legacy pagination (page/size) — deprecated
212
+ # Legacy pagination (page/size) — deprecated, and broken upstream.
213
+ #
214
+ # The API validates `page` and then ignores it, always returning the
215
+ # first page with offset 0 and hasNext true, so a loop over `page` never
216
+ # terminates and silently re-yields the same batch. Converting to
217
+ # limit/offset fixes existing callers instead of leaving them with
218
+ # plausible wrong answers.
170
219
  if params.key?(:page) || params.key?(:size) || page_val.is_a?(Integer)
171
- warn "DEPRECATION WARNING: O modelo antigo de paginação (page/size) será removido em 01 de agosto de 2026. Utilize limit e offset."
172
- params[:page] ||= page_val || 1
173
- params[:size] ||= size_val || 100
220
+ page = params.delete(:page) || page_val || 1
221
+ size = params.delete(:size) || size_val || 100
222
+
223
+ unless page.is_a?(Integer) && page.positive?
224
+ raise RequestError, "page must be a positive integer"
225
+ end
226
+ unless size.is_a?(Integer) && size.positive?
227
+ raise RequestError, "size must be a positive integer"
228
+ end
229
+
230
+ warn "DEPRECATION WARNING: page/size foi substituído por limit/offset e será " \
231
+ "removido em conexa 0.3.0. A API v2 valida `page` e depois o ignora, " \
232
+ "devolvendo sempre a primeira página; os valores foram convertidos para " \
233
+ "limit=#{size}, offset=#{(page - 1) * size}."
234
+
235
+ params[:limit] = size
236
+ params[:offset] = (page - 1) * size
174
237
  return params
175
238
  end
176
239
 
data/lib/conexa/object.rb CHANGED
@@ -65,19 +65,40 @@ module Conexa
65
65
  end
66
66
 
67
67
  protected
68
+ # Merge a response into this object.
69
+ #
70
+ # Anything that carries no attributes is a no-op rather than an error. A
71
+ # write may answer with no body (Request#call then yields nil), with `{}`, or
72
+ # — for the top-level-array shape Request#run explicitly handles — with an
73
+ # Array or a scalar, which ConexaObject.convert passes straight through.
74
+ # None of those can update an object, and none of them should raise a bare
75
+ # NoMethodError at the caller: that is the failure this release exists to
76
+ # remove.
77
+ #
78
+ # The empty case matters beyond the crash. `removed_attributes` deletes every
79
+ # key absent from the incoming hash, which is right for a full refresh and
80
+ # destructive for a write that answers `{}` — that used to wipe the object,
81
+ # primary key included, and report success.
68
82
  def update(attributes)
69
- removed_attributes = @attributes.keys - attributes.to_hash.keys
83
+ return self unless attributes.respond_to?(:to_hash)
84
+
85
+ incoming = attributes.to_hash
86
+ return self if incoming.empty?
87
+
88
+ removed_attributes = @attributes.keys - incoming.keys
70
89
 
71
90
  removed_attributes.each do |key|
72
91
  @attributes.delete key
73
92
  end
74
93
 
75
- attributes.each do |key, value|
94
+ incoming.each do |key, value|
76
95
  key = Util.to_snake_case(key.to_s)
77
96
 
78
97
  @attributes[key] = ConexaObject.convert(value, Util.singularize(key))
79
98
  @unsaved_attributes.delete key
80
99
  end
100
+
101
+ self
81
102
  end
82
103
 
83
104
  def to_hash_value(value, type)
@@ -120,7 +141,7 @@ module Conexa
120
141
  end
121
142
 
122
143
  class << self
123
- def convert(response, resource_name = nil, client_key=nil)
144
+ def convert(response, resource_name = nil)
124
145
  case response
125
146
  when Array
126
147
  response.map{ |i| convert i, resource_name }
@@ -23,15 +23,57 @@ module Conexa
23
23
  @auth = options[:auth] || false
24
24
  end
25
25
 
26
+ # Verbs allowed while Conexa.read_only? — GET, plus authentication, without
27
+ # which read-only mode could not obtain a token in the first place.
28
+ READ_METHODS = %w(GET).freeze
29
+
30
+ # The authentication exemption is tied to these paths, not to the caller's
31
+ # `auth:` flag. `Request.auth` is public, so trusting the flag alone let any
32
+ # write opt out of the guard with `Request.auth("/charge/settle/1", …)`.
33
+ AUTH_PATHS = %w(/auth).freeze
34
+
26
35
  def run
27
- response = RestClient::Request.execute request_params
36
+ enforce_read_only!
28
37
 
29
- response = MultiJson.decode response.body
30
- return {data: response.dig("data") || response, pagination: response.dig("pagination")}
38
+ response = RestClient::Request.execute request_params
31
39
 
40
+ # A successful write may answer with no body at all: PATCH /charge/settle/:id
41
+ # documents 204 + empty body as its success response, and
42
+ # PATCH /contract/end/:id answers 200 with one. With the Oj adapter,
43
+ # MultiJson.decode("") returns nil *without* raising ParseError, so the
44
+ # nil has to be caught here rather than in a rescue.
45
+ body = response.body.to_s
46
+ return {} if body.strip.empty?
47
+
48
+ decoded = MultiJson.decode(body)
49
+ return {} if decoded.nil?
50
+
51
+ # A top-level array (some list endpoints) has no #dig(String).
52
+ return {data: decoded, pagination: nil} unless decoded.is_a?(Hash)
53
+
54
+ {data: decoded["data"] || decoded, pagination: decoded["pagination"]}
55
+
56
+ # Connection-level failures first. These subclass RestClient::Exception, so
57
+ # listing them after it made them unreachable — Ruby matches rescue clauses
58
+ # top-down. The broad clause then tried to decode their (nil) http_body and
59
+ # raised NoMethodError instead of the documented ConnectionError.
60
+ #
61
+ # All of these carry no response, so there is nothing to classify: they are
62
+ # failures to reach the API, not answers from it. Note that a real HTTP 408
63
+ # is RestClient::RequestTimeout, a *superclass* of Exceptions::Timeout, so
64
+ # it correctly stays in the response taxonomy below.
65
+ rescue SocketError, RestClient::ServerBrokeConnection,
66
+ RestClient::SSLCertificateNotVerified,
67
+ RestClient::Exceptions::Timeout => error
68
+ raise Conexa::ConnectionError.new error
32
69
  rescue RestClient::Exception => error
33
70
  begin
34
- parsed_error = MultiJson.decode error.http_body
71
+ # nil for an error carrying no body; MultiJson.decode(nil) returns nil
72
+ # rather than raising, so the guard has to be here. An error body that
73
+ # decodes to an array or a scalar has no #[](String) either, and used
74
+ # to raise TypeError from inside this handler.
75
+ parsed_error = MultiJson.decode(error.http_body.to_s)
76
+ parsed_error = {} unless parsed_error.is_a?(Hash)
35
77
 
36
78
  if error.is_a? RestClient::ResourceNotFound
37
79
  if parsed_error['message']
@@ -41,7 +83,8 @@ module Conexa
41
83
  end
42
84
  else
43
85
  if parsed_error['message']
44
- raise Conexa::ResponseError.new(request_params, error, parsed_error['message'] + "=> Erros: "+ parsed_error['errors'].to_s)
86
+ raise Conexa::ResponseError.new(request_params, error,
87
+ describe_api_error(parsed_error), parsed_error)
45
88
  else
46
89
  raise Conexa::ValidationError.new parsed_error
47
90
  end
@@ -50,13 +93,40 @@ module Conexa
50
93
  raise Conexa::ResponseError.new(request_params, error)
51
94
  end
52
95
  rescue MultiJson::ParseError
53
- return {} if response.code == 204
54
-
96
+ # Only genuinely malformed JSON reaches here — empty and null bodies are
97
+ # handled above, for every status.
55
98
  raise Conexa::ResponseError.new(request_params, response)
56
- rescue SocketError
57
- raise Conexa::ConnectionError.new $!
58
- rescue RestClient::ServerBrokeConnection
59
- raise Conexa::ConnectionError.new $!
99
+ end
100
+
101
+ # The API's `message` plus its `errors`, rendered as prose.
102
+ #
103
+ # This used to be `message + "=> Erros: " + errors.to_s`, which appended a
104
+ # dangling "=> Erros: " to the 75 documented responses that carry no `errors`
105
+ # array, and dumped Ruby's `#inspect` of an array of hashes for the ones that
106
+ # do. ResponseError#api_error_messages already normalises both shapes.
107
+ def describe_api_error(parsed_error)
108
+ message = parsed_error['message'].to_s
109
+ details = Conexa::ResponseError.new({}, nil, nil, parsed_error).api_error_messages
110
+ return message if details.empty?
111
+
112
+ "#{message} — #{details.join("; ")}"
113
+ end
114
+
115
+ # @raise [Conexa::ReadOnlyError] when a mutating verb is attempted while
116
+ # Conexa.read_only? — checked before the request is executed, so nothing
117
+ # reaches the tenant.
118
+ def enforce_read_only!
119
+ return unless Conexa.read_only?
120
+ return if READ_METHODS.include?(method.to_s.upcase)
121
+ return if @auth && AUTH_PATHS.include?(path)
122
+
123
+ # Deliberately `path`, not `full_api_url`: the latter validates the URL and
124
+ # can raise RequestError, which would win over this one purely because the
125
+ # message is interpolated first. Read-only is a policy — it applies whatever
126
+ # the path looks like.
127
+ raise Conexa::ReadOnlyError,
128
+ "Conexa is in read-only mode: refusing #{method.to_s.upcase} #{path}. " \
129
+ "Unset config.read_only (or CONEXA_READ_ONLY) to allow writes."
60
130
  end
61
131
 
62
132
  def call(resource_name, query_context: nil)
@@ -120,6 +190,15 @@ module Conexa
120
190
  url += '?' + URI.encode_www_form(query)
121
191
  end
122
192
 
193
+ # An unusable path (a stray space in an id, say) would otherwise surface as
194
+ # URI::InvalidURIError from inside RestClient — outside Conexa::ConexaError,
195
+ # so no caller could rescue it meaningfully.
196
+ begin
197
+ URI.parse(url)
198
+ rescue URI::InvalidURIError
199
+ raise Conexa::RequestError, "Invalid request path: #{path.inspect}"
200
+ end
201
+
123
202
  url
124
203
  end
125
204
  end
@@ -48,10 +48,21 @@ module Conexa
48
48
  end
49
49
 
50
50
  # Settle (pay) this charge
51
- # @param params [Hash] optional payment details
51
+ #
52
+ # Moves money and, on a configured tenant, issues an NF-e. Not safe to retry
53
+ # blindly: a second attempt on a settled charge answers 422 CHARGE_11.
54
+ #
55
+ # The API answers 204 with an empty body on success.
56
+ #
57
+ # @param params [Hash] settlement details
58
+ # @option params [String] :settlement_date required, yyyy-MM-dd
59
+ # @option params [Hash] :receiving_method required, {id:, installments_quantity:}
60
+ # @option params [Integer] :account_id required
61
+ # @option params [Float] :paid_amount defaults to the charge amount, without interest
62
+ # @option params [Boolean] :send_email defaults to false
52
63
  # @return [self]
53
64
  def settle(params = {})
54
- Conexa::Request.post(self.class.show_url("settle", primary_key), params: params).call(class_name)
65
+ Conexa::Request.patch(self.class.show_url("settle", primary_key), params: params).call(class_name)
55
66
  self
56
67
  end
57
68
 
@@ -1,7 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Conexa
4
- class Company < Model
4
+ # Company resource (Empresa / unidade)
5
+ #
6
+ # @example List companies
7
+ # Conexa::Company.all(limit: 50)
8
+ #
9
+ # @example Find a company
10
+ # Conexa::Company.find(3)
11
+ class Company < Model
12
+ class << self
13
+ # Model#url pluralizes by appending "s", which yields "/companys" and 404s.
14
+ # Any resource with an irregular English plural has to override this — see
15
+ # spec/contract/api_contract_spec.rb, which checks every resource's URL
16
+ # against the published collection.
17
+ def url(*params)
18
+ ["/companies", *params].join '/'
19
+ end
5
20
 
21
+ def show_url(*params)
22
+ ["/company", *params].join '/'
23
+ end
24
+ end
6
25
  end
7
26
  end
@@ -3,16 +3,62 @@
3
3
  module Conexa
4
4
  # Contract resource for recurring billing contracts
5
5
  #
6
- # @example Create a contract
7
- # contract = Conexa::Contract.create(
8
- # customer_id: 127,
9
- # plan_id: 5,
10
- # start_date: '2024-01-01',
11
- # payment_day: 10
6
+ # == Creating a contract
7
+ #
8
+ # Required: +plan_id+, +customer_id+, +payment_frequency+ (+monthly+,
9
+ # +bimonthly+, +quarterly+, +semester+ or +yearly+) and +start_date+.
10
+ #
11
+ # Conexa::Contract.create(
12
+ # plan_id: 5, customer_id: 127,
13
+ # payment_frequency: 'monthly', start_date: '2026-01-01'
12
14
  # )
13
15
  #
16
+ # === +due_day+ is conditionally required *and* conditionally forbidden
17
+ #
18
+ # Required on a customer's **first** contract (or when they use automatic
19
+ # invoicing); **rejected** on every later one, which inherits the customer's
20
+ # +defaultDueDay+:
21
+ #
22
+ # 422 CONTRACT_RECURRING_SALE_10
23
+ # "The due day can not be informed for customers who already have a contract"
24
+ #
25
+ # Code that always sends +due_day+ works at onboarding and fails forever after.
26
+ # Read the code off the exception rather than the message:
27
+ #
28
+ # rescue Conexa::ResponseError => e
29
+ # retry_without_due_day if e.api_error_codes.include?('CONTRACT_RECURRING_SALE_10')
30
+ # end
31
+ #
32
+ # === Creating, charging and settling in one call
33
+ #
34
+ # +generate_sales: 'firstOccurrenceSettleRetroactive'+ also generates *and
35
+ # settles* retroactive charges, and then requires +expense_settlement+. It
36
+ # replaces a three-call sequence in which each step can fail on its own.
37
+ #
38
+ # Conexa::Contract.create(
39
+ # plan_id: 5, customer_id: 127,
40
+ # payment_frequency: 'monthly', start_date: '2026-01-01',
41
+ # generate_sales: 'firstOccurrenceSettleRetroactive',
42
+ # expense_settlement: { receiving_method_id: 53, account_id: 1 }
43
+ # )
44
+ #
45
+ # Other documented values: +firstOccurrence+ (default), +currentOccurrence+,
46
+ # +nextOccurrence+.
47
+ #
48
+ # === Other documented fields
49
+ #
50
+ # +end_date+, +first_due_date+ (required when the customer uses automatic
51
+ # invoicing), +fidelity_date+, +amount+, +discount_value+, +seller_id+,
52
+ # +contract_summary+, +notes+, +membership_fee+, +nfse_description+,
53
+ # +prorata_type+ (+startOfMonth+ / +notCalculate+ / +perDueDate+), +refund+
54
+ # (an explicit +nil+ opts out even when the plan configures one),
55
+ # +complementary_services+ (array), +extra_fields+ (array).
56
+ #
57
+ # +cost_center_id+ is *not* accepted on create — it 400s — even though it is
58
+ # present when the contract is read back.
59
+ #
14
60
  # @example End a contract
15
- # Conexa::Contract.end_contract(456, end_date: '2024-12-31')
61
+ # Conexa::Contract.set_end_date(456, date: '2026-12-31')
16
62
  #
17
63
  # @!attribute [r] contract_id
18
64
  # @return [Integer] Contract ID (also accessible as #id)
@@ -48,21 +94,49 @@ module Conexa
48
94
  status == 'ended' || status == 'cancelled'
49
95
  end
50
96
 
51
- # End/terminate this contract
52
- # @param params [Hash] options including :end_date, :reason
97
+ # Set this contract's end date — closing it, or amending an existing closure.
98
+ #
99
+ # The endpoint is documented as "encerra um contrato ativo **ou atualiza a
100
+ # data de encerramento**": it both closes and amends, and a future date on an
101
+ # already-closed contract **reopens** it. `end_contract` is kept as an alias,
102
+ # but the name understates what the call does.
103
+ #
104
+ # A contract cannot be closed retroactively past a day that already has
105
+ # invoiced sales (422 CONTRACT_RECURRING_SALE_23).
106
+ #
107
+ # The API may answer with an empty body on success.
108
+ #
109
+ # @param params [Hash]
110
+ # @option params [String] :date required, yyyy-MM-dd — the closing date
111
+ # @option params [Integer] :reason_id closing-reason id, from
112
+ # Listagem de Contratos > Outros Cadastros > Motivo de Encerramento de Contrato
113
+ # @option params [Boolean] :unlink_customer unlinks DDRs, mailboxes, extensions
114
+ # and recurring sales. Requires date <= today and no other active contracts;
115
+ # ends *all* the customer's recurring sales and cancels their uninvoiced sales.
116
+ # @option params [String] :end_date deprecated alias for +:date+
53
117
  # @return [self]
54
- def end_contract(params = {})
55
- Conexa::Request.post(self.class.show_url("end", primary_key), params: params).call(class_name)
118
+ def set_end_date(params = {})
119
+ params = self.class.normalize_end_params(params)
120
+ Conexa::Request.patch(self.class.show_url("end", primary_key), params: params).call(class_name)
56
121
  self
57
122
  end
123
+ alias_method :end_contract, :set_end_date
58
124
 
59
125
  class << self
60
- # End a contract by ID
126
+ # Set a contract's end date by ID
127
+ # @see #set_end_date
61
128
  # @param id [Integer, String] contract ID
62
- # @param params [Hash] options including :end_date, :reason
63
129
  # @return [Contract]
64
- def end_contract(id, params = {})
65
- find(id).end_contract(params)
130
+ def set_end_date(id, params = {})
131
+ find(id).set_end_date(params)
132
+ end
133
+ alias_method :end_contract, :set_end_date
134
+
135
+ # @deprecated Use {Conexa::Util.normalize_end_date_param}, which both "end"
136
+ # endpoints share.
137
+ # @api private
138
+ def normalize_end_params(params)
139
+ Util.normalize_end_date_param(params)
66
140
  end
67
141
 
68
142
  # Create contract with custom product items
@@ -1,9 +1,49 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Conexa
4
+ # Credit card, registered against the customer through Cielo.
5
+ #
6
+ # **Write-only.** API v2 exposes `POST /creditCard` and nothing else — the card
7
+ # number and CVC live encrypted at Cielo, not in Conexa, so there is nothing to
8
+ # read back. Verified against a live tenant on 2026-08-12:
9
+ #
10
+ # GET /creditCard => 404 "Unable to resolve the request"
11
+ # GET /creditCard/:id => 404 "unable to find the requested action \"view\""
12
+ #
13
+ # Neither is the "does not exist or you have no permission to access it"
14
+ # wording the API uses for a resource an account cannot see, and the collection
15
+ # documents a 403 for authorization — so this is the shape of the API, not a
16
+ # feature switched off for one tenant.
17
+ #
18
+ # `#save` and `#destroy` are left in place but are **undocumented and
19
+ # unverified**: the collection describes only `POST`, and the documented
20
+ # behaviour of `default:` and `enable_recurring:` — re-registering a card
21
+ # changes which one is default, or turns recurrence off — reads as if there is
22
+ # no update path at all. They were not probed, because probing them means
23
+ # writing. Treat a 404 from either as expected rather than as a bug.
24
+ #
25
+ # @example Register a card
26
+ # Conexa::CreditCard.create(
27
+ # customer_id: 127, number: '4111111111111111',
28
+ # expiration_date: '12/26', cvc: '123', name: 'JOAO DA SILVA',
29
+ # default: true, enable_recurring: true
30
+ # )
4
31
  class CreditCard < Model
5
32
  primary_key_attribute :credit_card_id
6
33
 
34
+ NO_READ = "a API v2 não expõe leitura de cartão de crédito: só " \
35
+ "POST /creditCard é documentado, e GET /creditCard[/:id] responde 404. " \
36
+ "Os dados do cartão ficam na Cielo, não no Conexa."
37
+
38
+ # Model#create re-fetches the created record to pick up server-side defaults.
39
+ # There is no read here, so the id from the response is all there is.
40
+ # @return [self]
41
+ def create
42
+ created = Conexa::Request.post(self.class.show_url, params: to_hash).call(class_name)
43
+ set_primary_key created.attributes['id'] if created.respond_to?(:attributes)
44
+ self
45
+ end
46
+
7
47
  class << self
8
48
  def url(*params)
9
49
  ["/creditCard", *params].join '/'
@@ -12,6 +52,12 @@ module Conexa
12
52
  def show_url(*params)
13
53
  ["/creditCard", *params].join '/'
14
54
  end
55
+
56
+ # Every read name Model provides, refused with an explanation rather than
57
+ # left to return a bare Conexa::NotFound that reads as "no such card".
58
+ %i[all where find find_by find_by_hash find_by_id].each do |name|
59
+ define_method(name) { |*, **| raise RequestError, NO_READ }
60
+ end
15
61
  end
16
62
  end
17
63
  end
@@ -71,22 +71,40 @@ module Conexa
71
71
  Address.new(@attributes['address'])
72
72
  end
73
73
 
74
+ # List persons (requesters) for this customer
75
+ # @return [Result] List of persons
76
+ def persons
77
+ Conexa::Person.all(customer_id: id, limit: 100)
78
+ end
79
+
80
+ # List contracts for this customer
81
+ # @return [Result] List of contracts
82
+ def contracts
83
+ Conexa::Contract.all(customer_id: [id], limit: 100)
84
+ end
85
+
86
+ # List charges for this customer
87
+ # @return [Result] List of charges
88
+ def charges
89
+ Conexa::Charge.all(customer_id: [id], limit: 100)
90
+ end
91
+
74
92
  class << self
75
- # List persons (requesters) for a customer
93
+ # List persons (requesters) for a customer without fetching the customer first
76
94
  # @param customer_id [Integer] Customer ID
77
95
  # @return [Result] List of persons
78
96
  def persons(customer_id)
79
97
  Conexa::Person.all(customer_id: customer_id, limit: 100)
80
98
  end
81
99
 
82
- # List contracts for a customer
100
+ # List contracts for a customer without fetching the customer first
83
101
  # @param customer_id [Integer] Customer ID
84
102
  # @return [Result] List of contracts
85
103
  def contracts(customer_id)
86
104
  Conexa::Contract.all(customer_id: [customer_id], limit: 100)
87
105
  end
88
106
 
89
- # List charges for a customer
107
+ # List charges for a customer without fetching the customer first
90
108
  # @param customer_id [Integer] Customer ID
91
109
  # @return [Result] List of charges
92
110
  def charges(customer_id)