conexa 0.1.1 → 0.2.1

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.
@@ -3,16 +3,71 @@
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'
14
+ # )
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 }
12
43
  # )
13
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')
62
+ #
63
+ # == Attributes
64
+ #
65
+ # Checked against a live response, not only the collection — which omits
66
+ # +isActive+, +extraFields+ and +firstDueDate+ from its `GET /contract/:id`
67
+ # examples even though the API returns them.
68
+ #
69
+ # **There is no +status+ field on a contract.** +is_active+ is how you tell an
70
+ # open contract from a closed one.
16
71
  #
17
72
  # @!attribute [r] contract_id
18
73
  # @return [Integer] Contract ID (also accessible as #id)
@@ -20,49 +75,111 @@ module Conexa
20
75
  # @return [Integer] Customer ID
21
76
  # @!attribute [r] plan_id
22
77
  # @return [Integer, nil] Plan ID
23
- # @!attribute [r] status
24
- # @return [String] Status: active, ended, cancelled
78
+ # @!attribute [r] is_active
79
+ # @return [Boolean] whether the contract is open
25
80
  # @!attribute [r] start_date
26
81
  # @return [String] Start date
27
82
  # @!attribute [r] end_date
28
- # @return [String, nil] End date
29
- # @!attribute [r] payment_day
30
- # @return [Integer] Payment day (1-28)
31
- # @!attribute [r] value
32
- # @return [Float] Contract value
33
- # @!attribute [r] billing_day
34
- # @return [Integer] Billing day
83
+ # @return [String, nil] closing date. May be in the future on an **active**
84
+ # contract a scheduled close is not a closed contract.
85
+ # @!attribute [r] end_reason_id
86
+ # @return [Integer, nil] closing-reason id
87
+ # @!attribute [r] due_day
88
+ # @return [Integer] day of the month the contract falls due
89
+ # @!attribute [r] first_due_date
90
+ # @return [String, nil] due date of the first instalment
91
+ # @!attribute [r] amount
92
+ # @return [Float] contract value
93
+ # @!attribute [r] payment_frequency
94
+ # @return [String] monthly, bimonthly, quarterly, semester or yearly
95
+ # @!attribute [r] date_sales_generation
96
+ # @return [String, nil] when sales are generated from the contract
97
+ # @!attribute [r] cost_center_id
98
+ # @return [Integer, nil] cost centre — present on read, rejected on create
99
+ # @!attribute [r] seller_id
100
+ # @return [Integer, nil] seller (user) id
101
+ # @!attribute [r] contract_summary
102
+ # @return [String, nil] short description
103
+ # @!attribute [r] fidelity_date
104
+ # @return [String, nil] loyalty date
105
+ # @!attribute [r] had_prorata
106
+ # @return [Boolean] whether pro rata was applied
35
107
  #
36
108
  class Contract < Model
37
109
  primary_key_attribute :contract_id
38
110
 
39
- # Check if contract is active
40
- # @return [Boolean]
111
+ # Is this contract open?
112
+ #
113
+ # Reads +is_active+, which is what the API sends. It used to compare a
114
+ # +status+ field that contracts have never had, so it answered +false+ for an
115
+ # active contract — the answer that makes a caller create a second one.
116
+ #
117
+ # Deliberately not derived from +end_date+: an active contract can carry a
118
+ # future closing date, so a present +end_date+ does not mean closed.
119
+ #
120
+ # **Prefer {#ended?} over `!active?`.** Ruby cannot tell +nil+ from +false+
121
+ # through `!`, so `!active?` reads an *unknown* contract as closed — the
122
+ # same "treat unknown as inactive" that this fix exists to remove. `ended?`
123
+ # preserves the nil.
124
+ #
125
+ # @return [Boolean, nil] nil when the response did not carry +is_active+,
126
+ # rather than a guess
41
127
  def active?
42
- status == 'active'
128
+ value = is_active
129
+ value.nil? ? nil : !!value
43
130
  end
44
131
 
45
- # Check if contract is cancelled/ended
46
- # @return [Boolean]
132
+ # Is this contract closed?
133
+ # @see #active?
134
+ # @return [Boolean, nil] nil when the response did not carry +is_active+
47
135
  def ended?
48
- status == 'ended' || status == 'cancelled'
136
+ value = active?
137
+ value.nil? ? nil : !value
49
138
  end
50
139
 
51
- # End/terminate this contract
52
- # @param params [Hash] options including :end_date, :reason
140
+ # Set this contract's end date — closing it, or amending an existing closure.
141
+ #
142
+ # The endpoint is documented as "encerra um contrato ativo **ou atualiza a
143
+ # data de encerramento**": it both closes and amends, and a future date on an
144
+ # already-closed contract **reopens** it. `end_contract` is kept as an alias,
145
+ # but the name understates what the call does.
146
+ #
147
+ # A contract cannot be closed retroactively past a day that already has
148
+ # invoiced sales (422 CONTRACT_RECURRING_SALE_23).
149
+ #
150
+ # The API may answer with an empty body on success.
151
+ #
152
+ # @param params [Hash]
153
+ # @option params [String] :date required, yyyy-MM-dd — the closing date
154
+ # @option params [Integer] :reason_id closing-reason id, from
155
+ # Listagem de Contratos > Outros Cadastros > Motivo de Encerramento de Contrato
156
+ # @option params [Boolean] :unlink_customer unlinks DDRs, mailboxes, extensions
157
+ # and recurring sales. Requires date <= today and no other active contracts;
158
+ # ends *all* the customer's recurring sales and cancels their uninvoiced sales.
159
+ # @option params [String] :end_date deprecated alias for +:date+
53
160
  # @return [self]
54
- def end_contract(params = {})
55
- Conexa::Request.post(self.class.show_url("end", primary_key), params: params).call(class_name)
161
+ def set_end_date(params = {})
162
+ params = self.class.normalize_end_params(params)
163
+ Conexa::Request.patch(self.class.show_url("end", primary_key), params: params).call(class_name)
56
164
  self
57
165
  end
166
+ alias_method :end_contract, :set_end_date
58
167
 
59
168
  class << self
60
- # End a contract by ID
169
+ # Set a contract's end date by ID
170
+ # @see #set_end_date
61
171
  # @param id [Integer, String] contract ID
62
- # @param params [Hash] options including :end_date, :reason
63
172
  # @return [Contract]
64
- def end_contract(id, params = {})
65
- find(id).end_contract(params)
173
+ def set_end_date(id, params = {})
174
+ find(id).set_end_date(params)
175
+ end
176
+ alias_method :end_contract, :set_end_date
177
+
178
+ # @deprecated Use {Conexa::Util.normalize_end_date_param}, which both "end"
179
+ # endpoints share.
180
+ # @api private
181
+ def normalize_end_params(params)
182
+ Util.normalize_end_date_param(params)
66
183
  end
67
184
 
68
185
  # 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
@@ -4,13 +4,23 @@ module Conexa
4
4
  class RecurringSale < Model
5
5
  primary_key_attribute :recurring_sale_id
6
6
 
7
- # End/terminate a recurring sale
8
- # @param params [Hash] optional parameters (e.g., endDate)
7
+ # Set this recurring sale's end date — closing it, or amending an existing
8
+ # closure.
9
+ #
10
+ # Like the contract endpoint, this one is documented as "encerra uma venda
11
+ # recorrente ativa **ou atualiza a data de encerramento**".
12
+ #
13
+ # @param params [Hash]
14
+ # @option params [String] :date required, yyyy-MM-dd — the closing date
15
+ # @option params [String] :end_date deprecated alias for +:date+; the API
16
+ # rejects the +endDate+ it camelizes to
9
17
  # @return [self]
10
18
  def end_recurring_sale(params = {})
11
- Conexa::Request.post(self.class.show_url("end", primary_key), params: params).call(class_name)
19
+ params = Util.normalize_end_date_param(params)
20
+ Conexa::Request.patch(self.class.show_url("end", primary_key), params: params).call(class_name)
12
21
  self
13
22
  end
23
+ alias_method :set_end_date, :end_recurring_sale
14
24
 
15
25
  class << self
16
26
  def url(*params)
@@ -21,13 +31,14 @@ module Conexa
21
31
  ["/recurringSale", *params].join '/'
22
32
  end
23
33
 
24
- # End a recurring sale by ID
34
+ # Set a recurring sale's end date by ID
35
+ # @see #end_recurring_sale
25
36
  # @param id [Integer, String] recurring sale ID
26
- # @param params [Hash] optional parameters (e.g., endDate)
27
37
  # @return [RecurringSale]
28
38
  def end_recurring_sale(id, params = {})
29
39
  find(id).end_recurring_sale(params)
30
40
  end
41
+ alias_method :set_end_date, :end_recurring_sale
31
42
  end
32
43
  end
33
44
  end
@@ -18,19 +18,37 @@ module Conexa
18
18
  @attributes["pagination"]
19
19
  end
20
20
 
21
+ # @return [Boolean] always a boolean — it used to yield nil when there was no
22
+ # pagination at all, which is falsy but not `false`, and leaks out of any
23
+ # caller that serialises or compares the result.
21
24
  def has_next?
22
- pagination && pagination.has_next == true
25
+ pagination.respond_to?(:has_next) && pagination.has_next == true
23
26
  end
24
27
 
25
28
  def next_page
26
29
  raise StopIteration, "No more pages" unless has_next?
27
- raise "No query context available for next_page" unless @query_context
30
+ raise Conexa::RequestError, "No query context available for next_page" unless @query_context
31
+
32
+ limit = pagination.limit
33
+ offset = pagination.offset
34
+ unless limit.is_a?(Integer) && offset.is_a?(Integer)
35
+ raise Conexa::ResponseError.new(@query_context[:params], nil,
36
+ "pagination is missing limit/offset " \
37
+ "(limit=#{limit.inspect}, offset=#{offset.inspect}), " \
38
+ "so the next page cannot be computed")
39
+ end
28
40
 
29
41
  resource_class = @query_context[:resource_class]
30
- next_params = Marshal.load(Marshal.dump(@query_context[:params]))
31
42
 
32
- next_params[:limit] = pagination.limit
33
- next_params[:offset] = pagination.offset + pagination.limit
43
+ begin
44
+ next_params = Marshal.load(Marshal.dump(@query_context[:params]))
45
+ rescue TypeError
46
+ # A non-marshallable filter (a Proc, an IO) in the original query.
47
+ next_params = @query_context[:params].dup
48
+ end
49
+
50
+ next_params[:limit] = limit
51
+ next_params[:offset] = offset + limit
34
52
  next_params.delete(:page)
35
53
  next_params.delete(:size)
36
54
 
data/lib/conexa/util.rb CHANGED
@@ -47,25 +47,65 @@ module Conexa
47
47
  string.to_s.strip.gsub(/[\s\-]+/, '_').to_sym
48
48
  end
49
49
 
50
+ # Both "end" endpoints — PATCH /contract/end/:id and
51
+ # PATCH /recurringSale/end/:id — document the closing date as `date`. The
52
+ # gem used to send `end_date`, which camelizes to `endDate` and is rejected:
53
+ # "endDate field does not exist or is not available in the company".
54
+ #
55
+ # Both key spellings are handled, and both are removed. Checking only the
56
+ # symbol `:date` would leave a caller's string `"date"` in place and add a
57
+ # second, colliding key — camelize_hash folds the two into one and the last
58
+ # one wins, so the deprecated value would silently replace the real one.
59
+ #
60
+ # @param params [Hash] caller params, possibly using the old name
61
+ # @return [Hash] a copy with end_date folded into date
62
+ def normalize_end_date_param(params)
63
+ params = params.dup
64
+
65
+ # Collapse both spellings of each key up front. Leaving one behind would
66
+ # let camelize_hash fold two `date` keys into one, last-write-wins — which
67
+ # is how the deprecated value once silently replaced the real one.
68
+ legacy = [params.delete(:end_date), params.delete("end_date")].compact.first
69
+ explicit = [params.delete(:date), params.delete("date")].compact.first
70
+
71
+ warn_end_date_renamed if legacy
72
+
73
+ # An explicit date wins; a key present with a nil value is not one.
74
+ date = explicit || legacy
75
+ params[:date] = date unless date.nil?
76
+ params
77
+ end
78
+
50
79
  def to_snake_case str
51
80
  str.gsub(/([A-Z])/, '_\1').downcase.sub(/^_/, '')
52
81
  end
53
82
 
54
83
 
84
+ def warn_end_date_renamed
85
+ Deprecation.warn_once(:end_date_param,
86
+ "`end_date:` foi renomeado para `date:` em conexa 0.2.0 " \
87
+ "(a API v2 rejeita `endDate`). O alias será removido em 0.3.0.")
88
+ end
89
+
90
+ # Convert a payload's keys to the camelCase the API expects, all the way
91
+ # down. Arrays of objects matter as much as nested hashes: ten documented
92
+ # endpoints take them (complementaryServices, productQuotas, devices,
93
+ # extraFields, bookingModels, visitors, costCenters, ...), and a snake_case
94
+ # key inside one is rejected outright.
55
95
  def camelize_hash(hash)
56
96
  return {} if hash.nil?
57
97
 
58
- new_hash = {}
59
-
60
- hash.each do |key, value|
61
- if value.is_a?(Hash)
62
- new_hash[camel_case_lower(key).to_sym] = camelize_hash(value)
63
- else
64
- new_hash[camel_case_lower(key).to_sym] = value
65
- end
98
+ hash.each_with_object({}) do |(key, value), new_hash|
99
+ new_hash[camel_case_lower(key).to_sym] = camelize_value(value)
66
100
  end
101
+ end
67
102
 
68
- new_hash
103
+ def camelize_value(value)
104
+ case value
105
+ when Hash then camelize_hash(value)
106
+ when Array then value.map { |element| camelize_value(element) }
107
+ else value
108
+ end
69
109
  end
70
110
 
71
111
  def camelize_str(str)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Conexa
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.1"
5
5
  end
data/lib/conexa.rb CHANGED
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "conexa/version"
4
- require_relative "conexa/authenticator"
4
+ # No dependencies of its own, and Model extends it at load time.
5
+ require_relative "conexa/deprecation"
5
6
  require_relative "conexa/request"
6
7
  require_relative "conexa/object"
7
8
  require_relative "conexa/model"
@@ -9,8 +10,6 @@ require_relative "conexa/core_ext"
9
10
  require_relative "conexa/errors"
10
11
  require_relative "conexa/util"
11
12
  require_relative "conexa/configuration"
12
- require_relative "conexa/order_common"
13
- require_relative "conexa/token_manager"
14
13
 
15
14
 
16
15
  Dir[File.expand_path('../conexa/resources/*.rb', __FILE__)].map do |path|
@@ -32,4 +31,42 @@ module Conexa
32
31
  def self.api_endpoint
33
32
  configuration.api_host + "/index.php/api/v2"
34
33
  end
34
+
35
+ # Key for the block-scoped read-only override. Thread-local so a guarded block
36
+ # in one thread cannot relax or tighten another.
37
+ READ_ONLY_KEY = :conexa_read_only
38
+
39
+ # Is writing currently forbidden?
40
+ #
41
+ # True inside a {read_only} block, or whenever `configuration.read_only` is set
42
+ # (which itself defaults from CONEXA_READ_ONLY).
43
+ #
44
+ # @return [Boolean]
45
+ def self.read_only?
46
+ scoped = Thread.current[READ_ONLY_KEY]
47
+ return scoped unless scoped.nil?
48
+
49
+ !!configuration&.read_only
50
+ end
51
+
52
+ # Run a block with writes forbidden, then restore the previous state.
53
+ #
54
+ # Useful for auditing or reporting code that should never mutate a tenant,
55
+ # without having to reconfigure the client globally.
56
+ #
57
+ # @example
58
+ # Conexa.read_only do
59
+ # Conexa::Charge.all(status: "pending") # fine
60
+ # Conexa::Charge.settle(555) # raises Conexa::ReadOnlyError
61
+ # end
62
+ #
63
+ # @param enabled [Boolean] pass false to explicitly allow writes in the block
64
+ # @return [Object] the block's return value
65
+ def self.read_only(enabled = true)
66
+ previous = Thread.current[READ_ONLY_KEY]
67
+ Thread.current[READ_ONLY_KEY] = enabled
68
+ yield
69
+ ensure
70
+ Thread.current[READ_ONLY_KEY] = previous
71
+ end
35
72
  end