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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +219 -1
- data/README.md +46 -3
- data/README_pt-BR.md +50 -1
- data/REFERENCE.md +41 -3
- data/lib/conexa/configuration.rb +48 -2
- data/lib/conexa/errors.rb +79 -7
- data/lib/conexa/model.rb +77 -14
- data/lib/conexa/object.rb +24 -3
- data/lib/conexa/request.rb +90 -11
- data/lib/conexa/resources/charge.rb +13 -2
- data/lib/conexa/resources/company.rb +20 -1
- data/lib/conexa/resources/contract.rb +89 -15
- data/lib/conexa/resources/credit_card.rb +46 -0
- data/lib/conexa/resources/customer.rb +21 -3
- data/lib/conexa/resources/recurring_sale.rb +16 -5
- data/lib/conexa/resources/result.rb +23 -5
- data/lib/conexa/util.rb +46 -9
- data/lib/conexa/version.rb +1 -1
- data/lib/conexa.rb +38 -3
- metadata +27 -70
- data/.editorconfig +0 -30
- data/.rspec +0 -3
- data/.rubocop.yml +0 -13
- data/.solargraph.yml +0 -2
- data/.vscode/launch.json +0 -30
- data/Gemfile +0 -18
- data/Gemfile.lock +0 -159
- data/Rakefile +0 -12
- data/bin/console +0 -61
- data/bin/setup +0 -8
- data/docs/postman-collection.json +0 -30785
- data/lib/conexa/authenticator.rb +0 -116
- data/lib/conexa/order_common.rb +0 -44
- data/lib/conexa/token_manager.rb +0 -136
- data/scripts/extract_fixtures.rb +0 -35
data/lib/conexa/model.rb
CHANGED
|
@@ -15,16 +15,18 @@ module Conexa
|
|
|
15
15
|
#
|
|
16
16
|
# == Primary Key
|
|
17
17
|
#
|
|
18
|
-
# Each resource
|
|
19
|
-
# that
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
101
|
+
# DSL for the primary key attribute
|
|
79
102
|
# @example
|
|
80
103
|
# primary_key_attribute :charge_id
|
|
81
|
-
# # Generates: charge_id
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
172
|
-
params
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
144
|
+
def convert(response, resource_name = nil)
|
|
124
145
|
case response
|
|
125
146
|
when Array
|
|
126
147
|
response.map{ |i| convert i, resource_name }
|
data/lib/conexa/request.rb
CHANGED
|
@@ -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
|
-
|
|
36
|
+
enforce_read_only!
|
|
28
37
|
|
|
29
|
-
response =
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
#
|
|
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.
|
|
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
|
-
|
|
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
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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.
|
|
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
|
-
#
|
|
52
|
-
#
|
|
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
|
|
55
|
-
|
|
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
|
-
#
|
|
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
|
|
65
|
-
find(id).
|
|
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)
|