abacatepay-ruby 1.0.0 → 1.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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +18 -0
  3. data/CHANGELOG.md +132 -12
  4. data/README.md +122 -20
  5. data/abacatepay-ruby.gemspec +2 -1
  6. data/lib/abacate_pay/clients/billing_client.rb +13 -3
  7. data/lib/abacate_pay/clients/checkout_client.rb +25 -4
  8. data/lib/abacate_pay/clients/client.rb +171 -16
  9. data/lib/abacate_pay/clients/coupon_client.rb +1 -1
  10. data/lib/abacate_pay/clients/customer_client.rb +1 -1
  11. data/lib/abacate_pay/clients/payment_link_client.rb +3 -3
  12. data/lib/abacate_pay/clients/payout_client.rb +1 -1
  13. data/lib/abacate_pay/clients/pix_client.rb +1 -1
  14. data/lib/abacate_pay/clients/product_client.rb +1 -1
  15. data/lib/abacate_pay/clients/store_client.rb +3 -1
  16. data/lib/abacate_pay/clients/subscription_client.rb +36 -1
  17. data/lib/abacate_pay/clients/transparent_client.rb +78 -24
  18. data/lib/abacate_pay/clients/webhook_client.rb +2 -2
  19. data/lib/abacate_pay/clients.rb +1 -1
  20. data/lib/abacate_pay/collection.rb +98 -0
  21. data/lib/abacate_pay/configuration.rb +20 -4
  22. data/lib/abacate_pay/enums/billings/methods.rb +8 -1
  23. data/lib/abacate_pay/enums/webhooks/event_types.rb +7 -0
  24. data/lib/abacate_pay/resources/checkouts.rb +10 -2
  25. data/lib/abacate_pay/resources/customers.rb +62 -21
  26. data/lib/abacate_pay/resources/resource.rb +1 -1
  27. data/lib/abacate_pay/resources/transparents.rb +29 -5
  28. data/lib/abacate_pay/resources/webhook_endpoints.rb +1 -1
  29. data/lib/abacate_pay/resources.rb +1 -1
  30. data/lib/abacate_pay/version.rb +1 -1
  31. data/lib/abacate_pay/webhooks.rb +53 -7
  32. data/lib/abacate_pay.rb +1 -0
  33. data/lib/abacatepay-ruby.rb +14 -0
  34. metadata +19 -3
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "faraday"
4
+ require "faraday/retry"
4
5
 
5
6
  module AbacatePay
6
7
  module Clients
@@ -9,12 +10,64 @@ module AbacatePay
9
10
  # This class handles API requests using Faraday and provides a way to manage
10
11
  # authentication and communication with the AbacatePay service.
11
12
  class Client
13
+ # Statuses worth retrying. 429 is rate limiting and 5xx are transient -
14
+ # AbacatePay's own reference tells integrators to back off on both.
15
+ RETRIABLE_STATUSES = [429, 500, 502, 503, 504].freeze
16
+
17
+ # Only methods that are safe to repeat. POST is excluded: retrying
18
+ # `checkouts/create` after a timeout could charge a customer twice, and
19
+ # the API exposes no idempotency key to make that safe.
20
+ RETRIABLE_METHODS = %i[get head options].freeze
21
+
22
+ # Passing `exceptions` replaces faraday-retry's defaults rather than
23
+ # adding to them, and Faraday::RetriableResponse is what the middleware
24
+ # raises internally for a retriable status. Omitting it silently disables
25
+ # status-code retries altogether.
26
+ RETRIABLE_EXCEPTIONS = [
27
+ Faraday::RetriableResponse,
28
+ Faraday::TimeoutError,
29
+ Faraday::ConnectionFailed,
30
+ Errno::ETIMEDOUT
31
+ ].freeze
32
+
12
33
  # @param uri [String] The specific API endpoint to interact with
13
34
  # @param client [Faraday::Connection, nil] Optional Faraday client for custom configurations
14
35
  def initialize(uri, client = nil)
15
36
  @client = client || build_client(uri)
16
37
  end
17
38
 
39
+ # Yields every page of a list endpoint, following the cursor.
40
+ #
41
+ # @param params [Hash] Params forwarded to each `list` call
42
+ # @yield [AbacatePay::Collection] Each page in order
43
+ # @return [void]
44
+ def each_page(**params)
45
+ return to_enum(:each_page, **params) unless block_given?
46
+
47
+ cursor = params.delete(:after)
48
+ loop do
49
+ page = list(**params, **(cursor ? { after: cursor } : {}))
50
+ yield page
51
+ break unless page.respond_to?(:has_more?) && page.has_more? && page.next_cursor
52
+
53
+ cursor = page.next_cursor
54
+ end
55
+ end
56
+
57
+ # Yields every record across every page.
58
+ #
59
+ # Prefer this over `list` when the result set can exceed the 100-item
60
+ # page limit.
61
+ #
62
+ # @param params [Hash] Params forwarded to each `list` call
63
+ # @yield [Object] Each resource
64
+ # @return [void]
65
+ def auto_paging_each(**params, &)
66
+ return to_enum(:auto_paging_each, **params) unless block_given?
67
+
68
+ each_page(**params) { |page| page.each(&) }
69
+ end
70
+
18
71
  private
19
72
 
20
73
  # Sends an HTTP request to the API
@@ -22,23 +75,74 @@ module AbacatePay
22
75
  # @param method [String] The HTTP method (e.g., GET, POST)
23
76
  # @param uri [String] The endpoint URI relative to the base URI
24
77
  # @param options [Hash] Optional settings and parameters for the request
25
- # @return [Hash] The response data
78
+ # @return [Hash, AbacatePay::Collection] The response data, a Collection
79
+ # when the API reports pagination, the raw data otherwise
26
80
  # @raise [ApiError] If an error occurs during the request
27
81
  def request(method, uri, options = {})
28
- response = @client.public_send(method.downcase) do |req|
29
- req.url uri
30
- req.params = options[:params] if options[:params]
31
- req.body = options[:json].to_json if options[:json]
32
- end
33
-
82
+ response = send_request(method, uri, options)
34
83
  parsed = JSON.parse(response.body)
35
84
  raise ApiError, "API error: #{parsed["error"]}" if parsed["error"]
36
85
 
37
- parsed.fetch("data")
86
+ data = parsed.fetch("data")
87
+ # Preserve the cursor when the API sends one; dropping it made paging
88
+ # past the first 100 records impossible.
89
+ parsed["pagination"] ? Collection.new(data, parsed["pagination"]) : data
38
90
  rescue Faraday::Error => e
39
91
  handle_request_error(e)
40
- rescue StandardError => e
41
- raise ApiError, "Unexpected error: #{e.message}"
92
+ rescue JSON::ParserError => e
93
+ raise ApiError, "Malformed API response: #{e.message}"
94
+ rescue KeyError
95
+ raise ApiError, "API response is missing the 'data' field"
96
+ end
97
+
98
+ # Issues the HTTP call.
99
+ #
100
+ # @param method [String] The HTTP method
101
+ # @param uri [String] The endpoint URI relative to the base URI
102
+ # @param options [Hash] Params and JSON body
103
+ # @return [Faraday::Response]
104
+ def send_request(method, uri, options)
105
+ @client.public_send(method.downcase) do |req|
106
+ req.url uri
107
+ req.params = options[:params] if options[:params]
108
+ req.body = compact_payload(options[:json]).to_json if options[:json]
109
+ end
110
+ end
111
+
112
+ # Removes nil values from an outgoing payload, at every depth.
113
+ #
114
+ # The API rejects explicit nulls: `{"cellphone": null}` comes back as
115
+ # HTTP 400 "Expected property 'cellphone' to be string but found: null",
116
+ # and payload builders naturally produce them for optional fields the
117
+ # caller left unset. Doing this at the boundary means no endpoint, present
118
+ # or future, can forget it.
119
+ #
120
+ # @param payload [Object] The payload about to be serialised
121
+ # @return [Object] The payload without nil entries
122
+ def compact_payload(payload)
123
+ case payload
124
+ when Hash
125
+ payload.each_with_object({}) do |(key, value), result|
126
+ next if value.nil?
127
+
128
+ result[key] = compact_payload(value)
129
+ end
130
+ when Array
131
+ payload.compact.map { |item| compact_payload(item) }
132
+ else
133
+ payload
134
+ end
135
+ end
136
+
137
+ # Maps a list response into resources without losing the page cursor.
138
+ #
139
+ # @param response [Array, AbacatePay::Collection] The raw list response
140
+ # @param resource_class [Class] The resource to instantiate per item
141
+ # @return [Array, AbacatePay::Collection] Mapped items, still paginated
142
+ # when the API reported pagination
143
+ def build_list(response, resource_class)
144
+ items = Array(response).map { |data| resource_class.new(data) }
145
+ response.is_a?(Collection) ? response.with_items(items) : items
42
146
  end
43
147
 
44
148
  # Builds a new Faraday client with default configuration
@@ -51,17 +155,66 @@ module AbacatePay
51
155
 
52
156
  Faraday.new(
53
157
  url: base_url,
54
- headers: {
55
- "Content-Type" => "application/json",
56
- "Authorization" => "Bearer #{configuration.api_token}"
57
- },
158
+ headers: build_headers(configuration),
58
159
  # Without an explicit timeout a hung gateway blocks the caller's
59
- # thread indefinitely inside a Rails request, that is an outage.
160
+ # thread indefinitely, inside a Rails request, that is an outage.
60
161
  request: {
61
162
  timeout: configuration.timeout,
62
163
  open_timeout: configuration.timeout
63
164
  }
64
- )
165
+ ) do |builder|
166
+ configure_retries(builder, configuration)
167
+ configure_logging(builder, configuration)
168
+ builder.adapter Faraday.default_adapter
169
+ end
170
+ end
171
+
172
+ # @param configuration [AbacatePay::Configuration] The active configuration
173
+ # @return [Hash] Request headers
174
+ def build_headers(configuration)
175
+ {
176
+ "Content-Type" => "application/json",
177
+ "Authorization" => "Bearer #{configuration.api_token}",
178
+ "User-Agent" => "abacatepay-ruby/#{AbacatePay::VERSION} ruby/#{RUBY_VERSION}"
179
+ }
180
+ end
181
+
182
+ # @param builder [Faraday::Connection] The connection being built
183
+ # @param configuration [AbacatePay::Configuration] The active configuration
184
+ # @return [void]
185
+ def configure_retries(builder, configuration)
186
+ return if configuration.max_retries.to_i <= 0
187
+
188
+ # `retry_if` is deliberately left at its default (never retry outside
189
+ # `methods`). Overriding it would re-enable retries for POST, which is
190
+ # exactly what must not happen for charge creation.
191
+ builder.request :retry,
192
+ max: configuration.max_retries,
193
+ interval: 0.5,
194
+ backoff_factor: 2,
195
+ max_interval: 8,
196
+ # Jitter: without it, every client that hit the same
197
+ # rate limit retries in lockstep and hits it again.
198
+ interval_randomness: 0.5,
199
+ retry_statuses: RETRIABLE_STATUSES,
200
+ methods: RETRIABLE_METHODS,
201
+ exceptions: RETRIABLE_EXCEPTIONS
202
+ end
203
+
204
+ # @param builder [Faraday::Connection] The connection being built
205
+ # @param configuration [AbacatePay::Configuration] The active configuration
206
+ # @return [void]
207
+ def configure_logging(builder, configuration)
208
+ return unless configuration.logger
209
+
210
+ builder.response :logger, configuration.logger, headers: true, bodies: false do |logger|
211
+ # Faraday renders header values inspected, so the token appears as
212
+ # Authorization: "Bearer abc_live_..."
213
+ # Both spellings are filtered so a change in that formatting cannot
214
+ # silently start leaking the credential.
215
+ logger.filter(/(Authorization:\s*")Bearer\s+[^"]*(")/i, '\1Bearer [REDACTED]\2')
216
+ logger.filter(/(Authorization:\s*)Bearer\s+\S+/i, '\1Bearer [REDACTED]')
217
+ end
65
218
  end
66
219
 
67
220
  # Handles API request errors
@@ -75,6 +228,8 @@ module AbacatePay
75
228
  end
76
229
 
77
230
  raise ApiError, "Request error: #{error_message || error.message}"
231
+ rescue JSON::ParserError
232
+ raise ApiError, "Request error: #{error.message}"
78
233
  end
79
234
  end
80
235
  end
@@ -17,7 +17,7 @@ module AbacatePay
17
17
  # @return [Array<Resources::Coupons>]
18
18
  def list(**params)
19
19
  response = request("GET", "list", params: params.empty? ? nil : params)
20
- Array(response).map { |data| Resources::Coupons.new(data) }
20
+ build_list(response, Resources::Coupons)
21
21
  end
22
22
 
23
23
  # @param id [String] Coupon ID
@@ -18,7 +18,7 @@ module AbacatePay
18
18
  # @return [Array<Resources::Customers>] Array of Customer objects
19
19
  def list(**params)
20
20
  response = request("GET", "list", params: params.empty? ? nil : params)
21
- Array(response).map { |data| Resources::Customers.new(data) }
21
+ build_list(response, Resources::Customers)
22
22
  end
23
23
 
24
24
  # Retrieves a customer by ID
@@ -4,8 +4,8 @@ module AbacatePay
4
4
  module Clients
5
5
  # Client for reusable payment links in the AbacatePay API.
6
6
  #
7
- # A payment link can be paid by many customers independently mass sales,
8
- # raffles, sign-up forms without creating one checkout per customer.
7
+ # A payment link can be paid by many customers independently, mass sales,
8
+ # raffles, sign-up forms, without creating one checkout per customer.
9
9
  # Use CheckoutClient when each customer needs their own charge.
10
10
  class PaymentLinkClient < Client
11
11
  URI = "payment-links"
@@ -23,7 +23,7 @@ module AbacatePay
23
23
  # @return [Array<Resources::Checkouts>]
24
24
  def list(**params)
25
25
  response = request("GET", "list", params: params.empty? ? nil : params)
26
- Array(response).map { |data| Resources::Checkouts.new(data) }
26
+ build_list(response, Resources::Checkouts)
27
27
  end
28
28
 
29
29
  # @param id [String] The payment link ID
@@ -16,7 +16,7 @@ module AbacatePay
16
16
  # @return [Array<Resources::Payouts>]
17
17
  def list(**params)
18
18
  response = request("GET", "list", params: params.empty? ? nil : params)
19
- Array(response).map { |data| Resources::Payouts.new(data) }
19
+ build_list(response, Resources::Payouts)
20
20
  end
21
21
 
22
22
  # @param id [String] Payout ID
@@ -17,7 +17,7 @@ module AbacatePay
17
17
  # @return [Array<Resources::PixTransfers>]
18
18
  def list(**params)
19
19
  response = request("GET", "list", params: params.empty? ? nil : params)
20
- Array(response).map { |data| Resources::PixTransfers.new(data) }
20
+ build_list(response, Resources::PixTransfers)
21
21
  end
22
22
 
23
23
  # @param id [String] PIX transfer ID
@@ -16,7 +16,7 @@ module AbacatePay
16
16
  # @return [Array<Resources::Products>]
17
17
  def list(**params)
18
18
  response = request("GET", "list", params: params.empty? ? nil : params)
19
- Array(response).map { |data| Resources::Products.new(data) }
19
+ build_list(response, Resources::Products)
20
20
  end
21
21
 
22
22
  # @param id [String] Product ID or externalId
@@ -12,7 +12,9 @@ module AbacatePay
12
12
 
13
13
  # @return [Resources::Store]
14
14
  def get
15
- response = request("GET", "store/get")
15
+ # The API serves this as `stores/get`, plural, even though the reference
16
+ # documents `store/get`. The singular path answers HTTP 400.
17
+ response = request("GET", "stores/get")
16
18
  Resources::Store.new(response)
17
19
  end
18
20
 
@@ -16,7 +16,7 @@ module AbacatePay
16
16
  # @return [Array<Resources::Subscriptions>]
17
17
  def list(**params)
18
18
  response = request("GET", "list", params: params.empty? ? nil : params)
19
- Array(response).map { |data| Resources::Subscriptions.new(data) }
19
+ build_list(response, Resources::Subscriptions)
20
20
  end
21
21
 
22
22
  # @param data [Resources::Subscriptions]
@@ -50,6 +50,41 @@ module AbacatePay
50
50
  response = request("POST", "cancel", json: { id: id })
51
51
  Resources::Subscriptions.new(response)
52
52
  end
53
+
54
+ # Changes the main product of an active subscription. The new price takes
55
+ # effect on the next billing cycle, the current cycle is untouched.
56
+ #
57
+ # @param id [String] The subscription ID (`subs_...`)
58
+ # @param product_id [String] The new product ID (`prod_...`), which must have a cycle
59
+ # @param quantity [Integer] Quantity of the product, minimum 1
60
+ # @return [Hash] The pending update object (`status: "PENDING"`)
61
+ # @raise [ArgumentError] if quantity is below 1
62
+ def change_plan(id, product_id:, quantity: 1)
63
+ raise ArgumentError, "quantity must be at least 1, got #{quantity.inspect}" if quantity.to_i < 1
64
+
65
+ request("POST", "change-plan", json: { id: id, productId: product_id, quantity: quantity.to_i })
66
+ end
67
+
68
+ # Records usage of a pay-as-you-go product on an active subscription. The
69
+ # amount is added to the next pending instalment of the cycle.
70
+ #
71
+ # @param id [String] The subscription ID (`subs_...`)
72
+ # @param product_id [String] The usage product ID (`prod_...`), which must NOT have a cycle
73
+ # @param units [Integer] Number of units, minimum 1
74
+ # @param action [String] "add" to add units, "subtract" to reverse units
75
+ # already recorded in the same cycle
76
+ # @return [Hash] The recorded usage
77
+ # @raise [ArgumentError] if units is below 1 or action is not add/subtract
78
+ def record_usage(id, product_id:, units:, action: "add")
79
+ raise ArgumentError, "units must be at least 1, got #{units.inspect}" if units.to_i < 1
80
+
81
+ unless %w[add subtract].include?(action.to_s)
82
+ raise ArgumentError, "action must be \"add\" or \"subtract\", got #{action.inspect}"
83
+ end
84
+
85
+ request("POST", "record-usage",
86
+ json: { id: id, productId: product_id, units: units.to_i, action: action.to_s })
87
+ end
53
88
  end
54
89
  end
55
90
  end
@@ -17,31 +17,21 @@ module AbacatePay
17
17
  # @return [Array<Resources::Transparents>]
18
18
  def list(**params)
19
19
  response = request("GET", "list", params: params.empty? ? nil : params)
20
- Array(response).map { |data| Resources::Transparents.new(data) }
20
+ build_list(response, Resources::Transparents)
21
21
  end
22
22
 
23
- # @param data [Resources::Transparents]
23
+ # Creates a transparent charge.
24
+ #
25
+ # @param data [Resources::Transparents] The charge to create
26
+ # @param method [String] "PIX" or "BOLETO"
24
27
  # @return [Resources::Transparents]
25
- def create(data)
26
- request_data = {
27
- method: "PIX",
28
- data: {
29
- amount: data.amount
30
- },
31
- expiresIn: data.expires_in,
32
- description: data.description
33
- }
34
-
35
- if data.customer
36
- request_data[:customer] = {
37
- name: data.customer.metadata&.name,
38
- email: data.customer.metadata&.email,
39
- cellphone: data.customer.metadata&.cellphone,
40
- taxId: data.customer.metadata&.tax_id
41
- }
42
- end
43
-
44
- response = request("POST", "create", json: request_data)
28
+ # @raise [ArgumentError] if the method is not supported, or if a BOLETO
29
+ # charge is missing the payer name/taxId the API requires
30
+ def create(data, method: Enums::Billings::Methods::PIX)
31
+ validate_transparent_method!(method)
32
+ validate_boleto_payer!(data) if method == Enums::Billings::Methods::BOLETO
33
+
34
+ response = request("POST", "create", json: build_create_payload(data, method))
45
35
  Resources::Transparents.new(response)
46
36
  end
47
37
 
@@ -55,12 +45,14 @@ module AbacatePay
55
45
  # @param id [String] QR code ID (dev mode only)
56
46
  # @return [Resources::Transparents]
57
47
  def simulate_payment(id)
58
- response = request("POST", "simulate-payment", json: { id: id })
48
+ # The API reads the id from the query string here, like #check. Sending
49
+ # it only in the body fails with "Expected property 'id'".
50
+ response = request("POST", "simulate-payment", params: { id: id }, json: {})
59
51
  Resources::Transparents.new(response)
60
52
  end
61
53
 
62
54
  # Refunds a transparent payment in full. AbacatePay does not support
63
- # partial refunds the original amount is always returned.
55
+ # partial refunds, the original amount is always returned.
64
56
  #
65
57
  # @param id [String] Public charge ID (`pix_char_...`, `card_...`, `char_...`)
66
58
  # @return [Resources::Transparents] The refunded charge
@@ -68,6 +60,68 @@ module AbacatePay
68
60
  response = request("POST", "refund", json: { id: id })
69
61
  Resources::Transparents.new(response)
70
62
  end
63
+
64
+ private
65
+
66
+ # Only PIX and BOLETO are transparent-checkout methods; CARD goes through
67
+ # the hosted checkout.
68
+ #
69
+ # @param method [String] The requested method
70
+ # @return [void]
71
+ def validate_transparent_method!(method)
72
+ supported = [Enums::Billings::Methods::PIX, Enums::Billings::Methods::BOLETO]
73
+ return if supported.include?(method)
74
+
75
+ raise ArgumentError, "Transparent checkout supports #{supported.join(" and ")}, got #{method.inspect}"
76
+ end
77
+
78
+ # The API requires the payer's name and taxId for boleto. Failing here
79
+ # names the missing field instead of returning a generic 422.
80
+ #
81
+ # @param data [Resources::Transparents] The charge to validate
82
+ # @return [void]
83
+ def validate_boleto_payer!(data)
84
+ metadata = data.customer&.metadata
85
+ missing = []
86
+ missing << "customer.metadata.name" if metadata&.name.to_s.strip.empty?
87
+ missing << "customer.metadata.tax_id" if metadata&.tax_id.to_s.strip.empty?
88
+ return if missing.empty?
89
+
90
+ raise ArgumentError, "BOLETO requires #{missing.join(" and ")}"
91
+ end
92
+
93
+ # @param data [Resources::Transparents] The charge to serialize
94
+ # @param method [String] "PIX" or "BOLETO"
95
+ # @return [Hash] The request payload
96
+ def build_create_payload(data, method)
97
+ payload = {
98
+ method: method,
99
+ data: {
100
+ amount: data.amount,
101
+ dueDate: data.due_date
102
+ }.compact,
103
+ expiresIn: data.expires_in,
104
+ description: data.description
105
+ }.compact
106
+
107
+ customer = serialize_customer(data.customer)
108
+ payload[:data][:customer] = customer if customer
109
+
110
+ payload
111
+ end
112
+
113
+ # @param customer [Resources::Customers, nil] The payer
114
+ # @return [Hash, nil] The customer payload, or nil when absent
115
+ def serialize_customer(customer)
116
+ return nil unless customer
117
+
118
+ {
119
+ name: customer.metadata&.name,
120
+ email: customer.metadata&.email,
121
+ cellphone: customer.metadata&.cellphone,
122
+ taxId: customer.metadata&.tax_id
123
+ }.compact
124
+ end
71
125
  end
72
126
  end
73
127
  end
@@ -20,7 +20,7 @@ module AbacatePay
20
20
  # @return [Array<Resources::WebhookEndpoints>]
21
21
  def list(**params)
22
22
  response = request("GET", "list", params: params.empty? ? nil : params)
23
- Array(response).map { |data| Resources::WebhookEndpoints.new(data) }
23
+ build_list(response, Resources::WebhookEndpoints)
24
24
  end
25
25
 
26
26
  # @param id [String] The webhook ID
@@ -32,7 +32,7 @@ module AbacatePay
32
32
 
33
33
  # Registers a new webhook endpoint.
34
34
  #
35
- # The secret is what AbacatePay signs deliveries with pass the same
35
+ # The secret is what AbacatePay signs deliveries with, pass the same
36
36
  # value to {AbacatePay::Webhooks.construct_event} when handling them.
37
37
  #
38
38
  # @param name [String] Identifying name
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Client must load first every other client inherits from it.
3
+ # Client must load first: every other client inherits from it.
4
4
  require "abacate_pay/clients/client"
5
5
 
6
6
  require "abacate_pay/clients/billing_client"
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AbacatePay
4
+ # A page of results plus the cursor needed to fetch the next one.
5
+ #
6
+ # List endpoints return at most 100 items and report whether more exist. The
7
+ # SDK used to return a bare Array and drop that metadata, which made it
8
+ # impossible to page past the first 100 records.
9
+ #
10
+ # Behaves like an Array everywhere an Array was returned before, so existing
11
+ # code keeps working:
12
+ #
13
+ # customers = AbacatePay.customers.list
14
+ # customers.each { |c| puts c.id } # Enumerable
15
+ # customers.size # items on this page
16
+ # customers.has_more? # is there another page?
17
+ #
18
+ # To walk every page without handling cursors:
19
+ #
20
+ # AbacatePay.customers.each_page { |page| page.each { |c| puts c.id } }
21
+ # AbacatePay.customers.auto_paging_each { |customer| puts customer.id }
22
+ class Collection
23
+ include Enumerable
24
+
25
+ # @return [Array] The items on this page
26
+ attr_reader :items
27
+
28
+ # @return [String, nil] Cursor for the next page, passed back as `after`
29
+ attr_reader :next_cursor
30
+
31
+ # @return [String, nil] Cursor for the previous page, passed back as `before`
32
+ attr_reader :before_cursor
33
+
34
+ # @param items [Array] The items on this page
35
+ # @param pagination [Hash, nil] The raw `pagination` object from the API
36
+ def initialize(items, pagination = nil)
37
+ @items = Array(items)
38
+ pagination = {} unless pagination.is_a?(Hash)
39
+ @has_more = pagination["hasMore"] || false
40
+ @next_cursor = pagination["next"]
41
+ @before_cursor = pagination["before"]
42
+ end
43
+
44
+ # @yield [Object] Each item on this page
45
+ # @return [Enumerator, self]
46
+ def each(&)
47
+ return to_enum(:each) unless block_given?
48
+
49
+ items.each(&)
50
+ self
51
+ end
52
+
53
+ # Whether the API reported further pages after this one.
54
+ #
55
+ # @return [Boolean]
56
+ def has_more?
57
+ @has_more
58
+ end
59
+
60
+ # @return [Integer] Number of items on this page
61
+ def size
62
+ items.size
63
+ end
64
+ alias length size
65
+ alias count size
66
+
67
+ # @return [Boolean]
68
+ def empty?
69
+ items.empty?
70
+ end
71
+
72
+ # @param index [Integer, Range] Index into this page
73
+ # @return [Object, Array, nil]
74
+ def [](index)
75
+ items[index]
76
+ end
77
+
78
+ # @return [Array] A plain Array copy of this page
79
+ def to_a
80
+ items.dup
81
+ end
82
+ alias to_ary to_a
83
+
84
+ # Returns a new Collection with different items and the same cursor.
85
+ # Used to turn raw hashes into resources without losing pagination.
86
+ #
87
+ # @param new_items [Array] The mapped items
88
+ # @return [Collection]
89
+ def with_items(new_items)
90
+ self.class.new(new_items, "hasMore" => has_more?, "next" => next_cursor, "before" => before_cursor)
91
+ end
92
+
93
+ # @return [String]
94
+ def inspect
95
+ "#<#{self.class.name} size=#{size} has_more=#{has_more?} next=#{next_cursor.inspect}>"
96
+ end
97
+ end
98
+ end
@@ -8,9 +8,11 @@ module AbacatePay
8
8
  #
9
9
  # @api public
10
10
  class Configuration
11
- # The only base URL AbacatePay serves. The v1 prefix was retired and now
12
- # answers `{"error":"Not found"}` for every path, so there is nothing to
13
- # negotiate between.
11
+ # The only base URL this SDK speaks. v1 still exists, but under a
12
+ # different dialect, singular paths (`/v1/billing/`, `/v1/customer/`) and
13
+ # different resource names (`pixQrCode`), which this SDK has never
14
+ # implemented. Deriving a base URL from the token prefix only produced 404s
15
+ # against v1 while sending v2-shaped paths.
14
16
  API_BASE_URL = "https://api.abacatepay.com/v2"
15
17
 
16
18
  # @return [String] API token for authentication
@@ -19,15 +21,29 @@ module AbacatePay
19
21
  # @return [Integer] Request timeout in seconds
20
22
  attr_accessor :timeout
21
23
 
24
+ # Retries apply to idempotent requests only (GET/HEAD/OPTIONS) on 429 and
25
+ # 5xx, with exponential backoff and jitter. Set to 0 to disable.
26
+ #
27
+ # @return [Integer] Maximum retry attempts
28
+ attr_accessor :max_retries
29
+
30
+ # Optional logger. Request and response headers are logged with the bearer
31
+ # token redacted; bodies are never logged, since they carry customer PII.
32
+ #
33
+ # @return [Logger, nil]
34
+ attr_accessor :logger
35
+
22
36
  # Initialize a new configuration with default values
23
37
  #
24
38
  # @api public
25
39
  def initialize
26
40
  @timeout = 30
41
+ @max_retries = 2
42
+ @logger = nil
27
43
  @api_token = nil
28
44
  end
29
45
 
30
- # @deprecated The environment is determined by the API key itself keys
46
+ # @deprecated The environment is determined by the API key itself, keys
31
47
  # created in Dev mode produce simulated transactions, production keys
32
48
  # produce real ones. This setting has never had any effect and is kept
33
49
  # only so existing initializers keep loading.