abacatepay-ruby 1.0.0 → 1.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8249cc166bb8f5534c7299d4da8aac7c08193e8a33df8e7a7618737adb44a08c
4
- data.tar.gz: f939f4979946f82d9e5a189d634ca4a7c99aa11e555646aec213b2f34dd4b817
3
+ metadata.gz: 604226a94737477c55324bf08cb81736cce65fdf482453bd2667572c8986b010
4
+ data.tar.gz: 4e292b8d7db21a444cc4348bd59bc2cf396849ff8d99db4d4463ab1b651962fe
5
5
  SHA512:
6
- metadata.gz: 4a14de54241b2901c7873becb95bab7bcceee8e7208b60b02114138eba6c72b1b6c16d5167c9f26f1b96fa9343bbe40097153f8b801b927b5c5dff02b3c0585c
7
- data.tar.gz: e70fc7b7efea2223fbe1093f6f792e394f697da22fe92f01c738bec6a016df8116d09c5d4eec56accd4dbd5cd63a4628a8b6bb721742b76695f1436edc1139e7
6
+ metadata.gz: 55eeee33fcf8a4b509c849f85d90ccb3f0fb4a2b4aad040325da4a6ed19bf2b60394190ce848ff0b03fa98ec56b0bddccf2fae677c19b1db1dfc7c744819286a
7
+ data.tar.gz: e8258c5ba01696d7d76fb85b4314a047052b04f13e0a90c4a826fe83f14ce967ea817cb445d3158a61be27a6ec4af99da9fc0c4dd477449c1bf1b60412f7f612
data/.rubocop.yml CHANGED
@@ -122,4 +122,15 @@ RSpec/SpecFilePathFormat:
122
122
  RSpec/DescribeClass:
123
123
  Exclude:
124
124
  - "spec/clients/request_contract_spec.rb"
125
+ - "spec/clients/pagination_spec.rb"
126
+ - "spec/clients/resilience_spec.rb"
127
+ - "spec/clients/logging_spec.rb"
128
+ - "spec/clients/boleto_spec.rb"
125
129
  - "spec/packaging_spec.rb"
130
+
131
+ # `has_more?` mirrors the API's own `hasMore` field and matches the convention
132
+ # other payment SDKs use for cursor pagination. `more?` would read as a
133
+ # different question.
134
+ Naming/PredicatePrefix:
135
+ AllowedMethods:
136
+ - has_more?
data/CHANGELOG.md CHANGED
@@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.1.0] - 2026-08-05
11
+
12
+ ### Added
13
+
14
+ - **BOLETO support.** The method was rejected outright by the `Billings::Methods`
15
+ enum even though it is a first-class payment method in the v2 API. Adds the
16
+ enum value, the boleto-only `due_date`/`interest`/`fine` fields on checkouts,
17
+ and a `method:` argument on `TransparentClient#create` (which previously
18
+ hard-coded PIX). Boleto responses now expose `bar_code`, `url`, `br_code`,
19
+ `br_code_base64` and `expires_at`.
20
+ - **Cursor pagination.** List endpoints cap at 100 items and report `hasMore`
21
+ plus a cursor; the SDK discarded that metadata, making record 101
22
+ unreachable. `list` now returns an `AbacatePay::Collection` — Enumerable and
23
+ Array-compatible, so existing code is unaffected — carrying `has_more?` and
24
+ `next_cursor`. `each_page` and `auto_paging_each` walk every page.
25
+ - **Retries with exponential backoff and jitter** on 429 and 5xx, configurable
26
+ via `config.max_retries` (default 2). Only idempotent methods are retried;
27
+ POST never is, because repeating `checkouts/create` after a timeout could
28
+ charge a customer twice and the API exposes no idempotency key.
29
+ - **Optional request logging** via `config.logger`, with the bearer token
30
+ redacted and bodies never logged.
31
+ - `SubscriptionClient#change_plan` and `#record_usage` — the last two of the 45
32
+ documented v2 endpoints. All 45 are now covered.
33
+ - `subscription.payment_failed` and `subscription.trial_started` webhook event
34
+ types. `payment_failed` is the dunning signal.
35
+ - Checkout fields the v2 API accepts but the SDK never sent: `max_installments`
36
+ (nested under `card`), `up_sell_product_id` and `custom_metadata`.
37
+ - A `User-Agent` identifying the SDK and Ruby version.
38
+
39
+ ### Fixed
40
+
41
+ - `Webhooks.parse` aside, malformed API responses raised `JSON::ParserError`
42
+ and a missing `data` field raised `KeyError`; both are now `ApiError`.
43
+
44
+ ### Changed
45
+
46
+ - `BillingClient`'s deprecation warning now states that its `/billings/*`
47
+ endpoints do not exist on either API version, so every call fails. It will be
48
+ removed in 2.0.0.
49
+
50
+ ### Corrected
51
+
52
+ - The 1.0.0 notes stated that the v1 API "has been retired and answers Not found
53
+ for every path". That is wrong: v1 is still served, under a different dialect
54
+ — singular paths (`/v1/billing/`, `/v1/customer/`) and different resource
55
+ names (`pixQrCode`). The original diagnosis tested v2-shaped paths against
56
+ `/v1`. The fix itself stands: this SDK only ever spoke v2's dialect, so 10 of
57
+ the 12 paths it calls do not exist on v1 and routing there produced 404s.
58
+
10
59
  ## [1.0.0] - 2026-08-05
11
60
 
12
61
  First stable release. The public surface is now covered by CI on four Ruby
@@ -121,5 +170,6 @@ versions and will not change without a major bump.
121
170
 
122
171
  - Initial release
123
172
 
173
+ [1.1.0]: https://github.com/AbacatePay/abacatepay-ruby-sdk/releases/tag/v1.1.0
124
174
  [1.0.0]: https://github.com/AbacatePay/abacatepay-ruby-sdk/releases/tag/v1.0.0
125
175
  [0.1.0]: https://github.com/AbacatePay/abacatepay-ruby-sdk/releases/tag/v0.1.0
data/README.md CHANGED
@@ -42,8 +42,10 @@ gem 'abacatepay-ruby'
42
42
 
43
43
  ```ruby
44
44
  AbacatePay.configure do |config|
45
- config.api_token = ENV['ABACATEPAY_TOKEN']
46
- config.timeout = 30 # opcional, em segundos
45
+ config.api_token = ENV['ABACATEPAY_TOKEN']
46
+ config.timeout = 30 # opcional, segundos (default 30)
47
+ config.max_retries = 2 # opcional, retry em 429/5xx (default 2, 0 desliga)
48
+ config.logger = Rails.logger # opcional, token é redigido
47
49
  end
48
50
  ```
49
51
 
@@ -101,9 +103,26 @@ AbacatePay.checkouts.list(status: 'PAID', email: 'user@example.com')
101
103
 
102
104
  <div align="center">
103
105
 
106
+ Listas retornam no máximo 100 itens. O resultado é uma `Collection` — funciona como Array, e ainda carrega o cursor:
107
+
108
+ </div>
109
+
110
+ ```ruby
111
+ page = AbacatePay.customers.list
112
+ page.first.id # funciona como Array
113
+ page.has_more? # => true
114
+ page.next_cursor # => "cust_abc123"
115
+
116
+ # Para percorrer tudo sem lidar com cursor:
117
+ AbacatePay.customers.auto_paging_each { |customer| puts customer.id }
118
+ AbacatePay.customers.each_page { |page| puts page.size }
119
+ ```
120
+
121
+ <div align="center">
122
+
104
123
  ## Versionamento
105
124
 
106
- O SDK fala **exclusivamente a v2** — `https://api.abacatepay.com/v2`. A v1 foi desligada pela AbacatePay e responde `{"error":"Not found"}` em toda rota, então não o que negociar.
125
+ O SDK fala **exclusivamente a v2** — `https://api.abacatepay.com/v2`. A v1 ainda existe para integrações legadas, mas usa outro dialeto (caminhos no singular como `/v1/billing/`, `/v1/customer/`) que este SDK nunca implementou. Se você precisa da v1, chame a API diretamente.
107
126
 
108
127
  O ambiente (dev mode x produção) é definido **pela chave de API**, não por configuração: chaves de Dev mode geram transações simuladas. Por isso `config.environment` não faz nada — ela continua aceita para não quebrar initializers existentes, mas emite aviso de depreciação.
109
128
 
@@ -214,7 +233,7 @@ Todos os recursos são acessíveis pela fachada `AbacatePay.<recurso>`.
214
233
  | `products` | `list` `get` `create` `delete` |
215
234
  | `coupons` | `list` `get` `create` `delete` `toggle` |
216
235
  | `checkouts` | `list` `get` `create` `refund` |
217
- | `subscriptions` | `list` `create` `cancel` |
236
+ | `subscriptions` | `list` `create` `cancel` `change_plan` `record_usage` |
218
237
  | `transparents` | `list` `create` `check` `simulate_payment` `refund` |
219
238
  | `pix` | `list` `get` `send_pix` |
220
239
  | `payouts` | `list` `get` `create` |
@@ -363,6 +382,64 @@ AbacatePay.payouts.create(
363
382
 
364
383
  <div align="center">
365
384
 
385
+ ### Boleto
386
+
387
+ Boleto tem vencimento, juros e multa próprios. Todos os valores em centavos.
388
+
389
+ </div>
390
+
391
+ ```ruby
392
+ AbacatePay.checkouts.create(
393
+ AbacatePay::Resources::Checkouts.new(
394
+ methods: ['BOLETO'],
395
+ due_date: '2026-08-15', # opcional; default 3 dias úteis
396
+ interest: { value: 100 }, # juros ao mês
397
+ fine: { value: 200, type: 'PERCENTAGE' }, # ou type: 'FIXED'
398
+ products: [
399
+ AbacatePay::Resources::Billings::Product.new(external_id: 'prod_123', quantity: 1)
400
+ ]
401
+ )
402
+ )
403
+ ```
404
+
405
+ <div align="center">
406
+
407
+ No checkout transparente, o boleto exige nome e CPF/CNPJ do pagador — o SDK valida antes de chamar a API:
408
+
409
+ </div>
410
+
411
+ ```ruby
412
+ charge = AbacatePay::Resources::Transparents.new(amount: 25_000, due_date: '2026-08-15')
413
+ # charge.customer precisa ter metadata.name e metadata.tax_id
414
+
415
+ boleto = AbacatePay.transparents.create(charge, method: 'BOLETO')
416
+ boleto.bar_code # linha digitável
417
+ boleto.url # PDF para impressão
418
+ boleto.br_code # PIX alternativo da mesma cobrança
419
+ ```
420
+
421
+ <div align="center">
422
+
423
+ ### Parcelamento e order bump
424
+
425
+ </div>
426
+
427
+ ```ruby
428
+ AbacatePay.checkouts.create(
429
+ AbacatePay::Resources::Checkouts.new(
430
+ methods: ['CARD'],
431
+ max_installments: 12,
432
+ up_sell_product_id: 'prod_bump',
433
+ custom_metadata: { origem: 'app-mobile' },
434
+ products: [
435
+ AbacatePay::Resources::Billings::Product.new(external_id: 'prod_123', quantity: 1)
436
+ ]
437
+ )
438
+ )
439
+ ```
440
+
441
+ <div align="center">
442
+
366
443
  ### Links de pagamento
367
444
 
368
445
  Um link reutilizável, pago por vários clientes de forma independente — vendas em massa, rifas, formulários de inscrição. Para uma cobrança por cliente, use `checkouts`.
@@ -407,6 +484,12 @@ Cancela imediatamente; parcelas futuras pendentes são canceladas junto.
407
484
 
408
485
  ```ruby
409
486
  AbacatePay.subscriptions.cancel('subs_abc123xyz')
487
+
488
+ # Upgrade/downgrade — vale a partir do próximo ciclo
489
+ AbacatePay.subscriptions.change_plan('subs_abc123xyz', product_id: 'prod_pro', quantity: 1)
490
+
491
+ # Cobrança por uso — produto sem ciclo
492
+ AbacatePay.subscriptions.record_usage('subs_abc123xyz', product_id: 'prod_api', units: 50)
410
493
  ```
411
494
 
412
495
  <div align="center">
@@ -41,6 +41,7 @@ Gem::Specification.new do |spec|
41
41
  # NestedParamsEncoder). The lockfile only protects this repo — consumers are
42
42
  # protected by the constraint here.
43
43
  spec.add_dependency "faraday", "~> 2.14", ">= 2.14.3"
44
+ spec.add_dependency "faraday-retry", "~> 2.3"
44
45
 
45
46
  # Development dependencies
46
47
  spec.add_development_dependency "bundler-audit", "~> 0.9"
@@ -2,7 +2,15 @@
2
2
 
3
3
  module AbacatePay
4
4
  module Clients
5
- # Client class for managing billing-related operations in the AbacatePay API.
5
+ # Deprecated client for the v1 billing endpoints.
6
+ #
7
+ # The endpoints this class calls (`/billings/create`, `/billings/list`) do
8
+ # not exist on either API version — v2 replaced them with `/checkouts/*`,
9
+ # and v1 uses the singular `/billing/*`. Every call raises ApiError.
10
+ #
11
+ # Kept only so existing code keeps loading; it will be removed in 2.0.0.
12
+ #
13
+ # @deprecated Use {CheckoutClient} instead.
6
14
  class BillingClient < Client
7
15
  # API endpoint for billing-related operations
8
16
  URI = "billings"
@@ -10,7 +18,9 @@ module AbacatePay
10
18
  # @param client [Faraday::Connection, nil] Optional Faraday client for custom configurations
11
19
  # @deprecated Use {CheckoutClient} instead
12
20
  def initialize(client = nil)
13
- warn "[DEPRECATION] BillingClient is deprecated. Use CheckoutClient instead."
21
+ warn "[DEPRECATION] BillingClient calls /billings/* endpoints that do not exist on the " \
22
+ "AbacatePay API — every request will fail. Use AbacatePay.checkouts instead. " \
23
+ "This class will be removed in 2.0.0."
14
24
  super(URI, client)
15
25
  end
16
26
 
@@ -19,7 +29,7 @@ module AbacatePay
19
29
  # @return [Array<Resources::Billing>] Array of Billing objects
20
30
  def list
21
31
  response = request("GET", "list")
22
- Array(response).map { |data| Resources::Billings.new(data) }
32
+ build_list(response, Resources::Billings)
23
33
  end
24
34
 
25
35
  # Creates a new billing
@@ -17,7 +17,7 @@ module AbacatePay
17
17
  # @return [Array<Resources::Checkouts>]
18
18
  def list(**params)
19
19
  response = request("GET", "list", params: params.empty? ? nil : params)
20
- Array(response).map { |data| Resources::Checkouts.new(data) }
20
+ build_list(response, Resources::Checkouts)
21
21
  end
22
22
 
23
23
  # @param id [String] Checkout ID
@@ -62,8 +62,29 @@ module AbacatePay
62
62
  items: data.products&.map { |product| { id: product.external_id, quantity: product.quantity } },
63
63
  externalId: data.external_id,
64
64
  coupons: data.coupons,
65
- customerId: customer_id.to_s.empty? ? nil : customer_id
66
- }.compact
65
+ customerId: customer_id.to_s.empty? ? nil : customer_id,
66
+ upSellProductId: data.up_sell_product_id,
67
+ metadata: data.custom_metadata
68
+ }.merge(boleto_options(data)).merge(card_options(data)).compact
69
+ end
70
+
71
+ # BOLETO-only fields. The API rejects them for other methods, so they are
72
+ # only sent when the caller actually set them.
73
+ #
74
+ # @param data [Resources::Checkouts] The checkout to serialize
75
+ # @return [Hash] The boleto payload fragment
76
+ def boleto_options(data)
77
+ { dueDate: data.due_date, interest: data.interest, fine: data.fine }
78
+ end
79
+
80
+ # CARD-only instalment cap, which the API nests under `card`.
81
+ #
82
+ # @param data [Resources::Checkouts] The checkout to serialize
83
+ # @return [Hash] The card payload fragment
84
+ def card_options(data)
85
+ return {} unless data.max_installments
86
+
87
+ { card: { maxInstallments: data.max_installments } }
67
88
  end
68
89
  end
69
90
  end
@@ -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,7 +75,8 @@ 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
82
  response = @client.public_send(method.downcase) do |req|
@@ -34,11 +88,27 @@ module AbacatePay
34
88
  parsed = JSON.parse(response.body)
35
89
  raise ApiError, "API error: #{parsed["error"]}" if parsed["error"]
36
90
 
37
- parsed.fetch("data")
91
+ data = parsed.fetch("data")
92
+ # Preserve the cursor when the API sends one; dropping it made paging
93
+ # past the first 100 records impossible.
94
+ parsed["pagination"] ? Collection.new(data, parsed["pagination"]) : data
38
95
  rescue Faraday::Error => e
39
96
  handle_request_error(e)
40
- rescue StandardError => e
41
- raise ApiError, "Unexpected error: #{e.message}"
97
+ rescue JSON::ParserError => e
98
+ raise ApiError, "Malformed API response: #{e.message}"
99
+ rescue KeyError
100
+ raise ApiError, "API response is missing the 'data' field"
101
+ end
102
+
103
+ # Maps a list response into resources without losing the page cursor.
104
+ #
105
+ # @param response [Array, AbacatePay::Collection] The raw list response
106
+ # @param resource_class [Class] The resource to instantiate per item
107
+ # @return [Array, AbacatePay::Collection] Mapped items, still paginated
108
+ # when the API reported pagination
109
+ def build_list(response, resource_class)
110
+ items = Array(response).map { |data| resource_class.new(data) }
111
+ response.is_a?(Collection) ? response.with_items(items) : items
42
112
  end
43
113
 
44
114
  # Builds a new Faraday client with default configuration
@@ -51,17 +121,66 @@ module AbacatePay
51
121
 
52
122
  Faraday.new(
53
123
  url: base_url,
54
- headers: {
55
- "Content-Type" => "application/json",
56
- "Authorization" => "Bearer #{configuration.api_token}"
57
- },
124
+ headers: build_headers(configuration),
58
125
  # Without an explicit timeout a hung gateway blocks the caller's
59
126
  # thread indefinitely — inside a Rails request, that is an outage.
60
127
  request: {
61
128
  timeout: configuration.timeout,
62
129
  open_timeout: configuration.timeout
63
130
  }
64
- )
131
+ ) do |builder|
132
+ configure_retries(builder, configuration)
133
+ configure_logging(builder, configuration)
134
+ builder.adapter Faraday.default_adapter
135
+ end
136
+ end
137
+
138
+ # @param configuration [AbacatePay::Configuration] The active configuration
139
+ # @return [Hash] Request headers
140
+ def build_headers(configuration)
141
+ {
142
+ "Content-Type" => "application/json",
143
+ "Authorization" => "Bearer #{configuration.api_token}",
144
+ "User-Agent" => "abacatepay-ruby/#{AbacatePay::VERSION} ruby/#{RUBY_VERSION}"
145
+ }
146
+ end
147
+
148
+ # @param builder [Faraday::Connection] The connection being built
149
+ # @param configuration [AbacatePay::Configuration] The active configuration
150
+ # @return [void]
151
+ def configure_retries(builder, configuration)
152
+ return if configuration.max_retries.to_i <= 0
153
+
154
+ # `retry_if` is deliberately left at its default (never retry outside
155
+ # `methods`). Overriding it would re-enable retries for POST, which is
156
+ # exactly what must not happen for charge creation.
157
+ builder.request :retry,
158
+ max: configuration.max_retries,
159
+ interval: 0.5,
160
+ backoff_factor: 2,
161
+ max_interval: 8,
162
+ # Jitter: without it, every client that hit the same
163
+ # rate limit retries in lockstep and hits it again.
164
+ interval_randomness: 0.5,
165
+ retry_statuses: RETRIABLE_STATUSES,
166
+ methods: RETRIABLE_METHODS,
167
+ exceptions: RETRIABLE_EXCEPTIONS
168
+ end
169
+
170
+ # @param builder [Faraday::Connection] The connection being built
171
+ # @param configuration [AbacatePay::Configuration] The active configuration
172
+ # @return [void]
173
+ def configure_logging(builder, configuration)
174
+ return unless configuration.logger
175
+
176
+ builder.response :logger, configuration.logger, headers: true, bodies: false do |logger|
177
+ # Faraday renders header values inspected, so the token appears as
178
+ # Authorization: "Bearer abc_live_..."
179
+ # Both spellings are filtered so a change in that formatting cannot
180
+ # silently start leaking the credential.
181
+ logger.filter(/(Authorization:\s*")Bearer\s+[^"]*(")/i, '\1Bearer [REDACTED]\2')
182
+ logger.filter(/(Authorization:\s*)Bearer\s+\S+/i, '\1Bearer [REDACTED]')
183
+ end
65
184
  end
66
185
 
67
186
  # Handles API request errors
@@ -75,6 +194,8 @@ module AbacatePay
75
194
  end
76
195
 
77
196
  raise ApiError, "Request error: #{error_message || error.message}"
197
+ rescue JSON::ParserError
198
+ raise ApiError, "Request error: #{error.message}"
78
199
  end
79
200
  end
80
201
  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
@@ -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
@@ -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
 
@@ -68,6 +58,68 @@ module AbacatePay
68
58
  response = request("POST", "refund", json: { id: id })
69
59
  Resources::Transparents.new(response)
70
60
  end
61
+
62
+ private
63
+
64
+ # Only PIX and BOLETO are transparent-checkout methods; CARD goes through
65
+ # the hosted checkout.
66
+ #
67
+ # @param method [String] The requested method
68
+ # @return [void]
69
+ def validate_transparent_method!(method)
70
+ supported = [Enums::Billings::Methods::PIX, Enums::Billings::Methods::BOLETO]
71
+ return if supported.include?(method)
72
+
73
+ raise ArgumentError, "Transparent checkout supports #{supported.join(" and ")}, got #{method.inspect}"
74
+ end
75
+
76
+ # The API requires the payer's name and taxId for boleto. Failing here
77
+ # names the missing field instead of returning a generic 422.
78
+ #
79
+ # @param data [Resources::Transparents] The charge to validate
80
+ # @return [void]
81
+ def validate_boleto_payer!(data)
82
+ metadata = data.customer&.metadata
83
+ missing = []
84
+ missing << "customer.metadata.name" if metadata&.name.to_s.strip.empty?
85
+ missing << "customer.metadata.tax_id" if metadata&.tax_id.to_s.strip.empty?
86
+ return if missing.empty?
87
+
88
+ raise ArgumentError, "BOLETO requires #{missing.join(" and ")}"
89
+ end
90
+
91
+ # @param data [Resources::Transparents] The charge to serialize
92
+ # @param method [String] "PIX" or "BOLETO"
93
+ # @return [Hash] The request payload
94
+ def build_create_payload(data, method)
95
+ payload = {
96
+ method: method,
97
+ data: {
98
+ amount: data.amount,
99
+ dueDate: data.due_date
100
+ }.compact,
101
+ expiresIn: data.expires_in,
102
+ description: data.description
103
+ }.compact
104
+
105
+ customer = serialize_customer(data.customer)
106
+ payload[:data][:customer] = customer if customer
107
+
108
+ payload
109
+ end
110
+
111
+ # @param customer [Resources::Customers, nil] The payer
112
+ # @return [Hash, nil] The customer payload, or nil when absent
113
+ def serialize_customer(customer)
114
+ return nil unless customer
115
+
116
+ {
117
+ name: customer.metadata&.name,
118
+ email: customer.metadata&.email,
119
+ cellphone: customer.metadata&.cellphone,
120
+ taxId: customer.metadata&.tax_id
121
+ }.compact
122
+ end
71
123
  end
72
124
  end
73
125
  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
@@ -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,11 +21,25 @@ 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
 
@@ -10,12 +10,19 @@ module AbacatePay
10
10
  # PIX payment method.
11
11
  # @return [String] Represents the PIX payment method, a popular instant payment system in Brazil
12
12
  PIX = "PIX"
13
+
14
+ # @return [String] Credit card payment
13
15
  CARD = "CARD"
14
16
 
17
+ # Boleto bancário. Supports a due date and late-payment interest/fine —
18
+ # see Resources::Checkouts#due_date, #interest and #fine.
19
+ # @return [String] Boleto payment method
20
+ BOLETO = "BOLETO"
21
+
15
22
  # Gets all valid method values
16
23
  # @return [Array<String>] List of all valid payment methods
17
24
  def self.values
18
- [PIX, CARD]
25
+ [PIX, CARD, BOLETO]
19
26
  end
20
27
 
21
28
  # Validates if a given value is a valid method
@@ -13,6 +13,12 @@ module AbacatePay
13
13
  SUBSCRIPTION_COMPLETED = "subscription.completed"
14
14
  SUBSCRIPTION_RENEWED = "subscription.renewed"
15
15
  SUBSCRIPTION_CANCELLED = "subscription.cancelled"
16
+
17
+ # Recurring charge failed — the dunning signal. Without handling this,
18
+ # a failing subscription looks identical to a healthy one.
19
+ SUBSCRIPTION_PAYMENT_FAILED = "subscription.payment_failed"
20
+
21
+ SUBSCRIPTION_TRIAL_STARTED = "subscription.trial_started"
16
22
  TRANSFER_COMPLETED = "transfer.completed"
17
23
  TRANSFER_FAILED = "transfer.failed"
18
24
  PAYOUT_COMPLETED = "payout.completed"
@@ -23,6 +29,7 @@ module AbacatePay
23
29
  CHECKOUT_COMPLETED, CHECKOUT_REFUNDED, CHECKOUT_DISPUTED,
24
30
  TRANSPARENT_COMPLETED, TRANSPARENT_REFUNDED, TRANSPARENT_DISPUTED,
25
31
  SUBSCRIPTION_COMPLETED, SUBSCRIPTION_RENEWED, SUBSCRIPTION_CANCELLED,
32
+ SUBSCRIPTION_PAYMENT_FAILED, SUBSCRIPTION_TRIAL_STARTED,
26
33
  TRANSFER_COMPLETED, TRANSFER_FAILED,
27
34
  PAYOUT_COMPLETED, PAYOUT_FAILED
28
35
  ]
@@ -20,7 +20,13 @@ module AbacatePay
20
20
 
21
21
  attr_reader :id, :url, :amount, :status, :dev_mode, :methods,
22
22
  :products, :metadata, :customer, :coupons, :external_id,
23
- :frequency, :created_at, :updated_at
23
+ :frequency, :created_at, :updated_at,
24
+ # BOLETO-only: due date (YYYY-MM-DD) plus late-payment charges.
25
+ :due_date, :interest, :fine,
26
+ # CARD-only: maximum number of instalments offered.
27
+ :max_installments,
28
+ # Order bump shown at checkout, and free-form merchant data.
29
+ :up_sell_product_id, :custom_metadata
24
30
 
25
31
  def initialize(data)
26
32
  fill(data)
@@ -70,7 +76,9 @@ module AbacatePay
70
76
 
71
77
  attr_writer :id, :url, :amount, :status, :dev_mode, :methods,
72
78
  :products, :metadata, :customer, :coupons, :external_id,
73
- :frequency, :created_at, :updated_at
79
+ :frequency, :created_at, :updated_at,
80
+ :due_date, :interest, :fine, :max_installments,
81
+ :up_sell_product_id, :custom_metadata
74
82
  end
75
83
  end
76
84
  end
@@ -2,17 +2,22 @@
2
2
 
3
3
  module AbacatePay
4
4
  module Resources
5
- # Represents a transparent PIX checkout in the AbacatePay system.
5
+ # Represents a transparent checkout (PIX or boleto) in the AbacatePay system.
6
6
  class Transparents < Resource
7
7
  RESOURCE_PROPERTIES = {
8
8
  customer: "AbacatePay::Resources::Customers"
9
9
  }.freeze
10
10
 
11
- DATETIME_PROPERTIES = %w[created_at updated_at].freeze
11
+ DATETIME_PROPERTIES = %w[created_at updated_at expires_at].freeze
12
12
 
13
13
  attr_reader :id, :amount, :status, :method, :description,
14
14
  :expires_in, :qr_code, :qr_code_image, :customer,
15
- :metadata, :dev_mode, :created_at, :updated_at
15
+ :metadata, :dev_mode, :created_at, :updated_at,
16
+ # Boleto: due date sent on create, plus the payment slip the
17
+ # API returns — digitable line, viewing URL, and the PIX
18
+ # fallback issued for the same charge.
19
+ :due_date, :bar_code, :url, :br_code, :br_code_base64,
20
+ :expires_at
16
21
 
17
22
  def initialize(data)
18
23
  fill(data)
@@ -40,7 +45,9 @@ module AbacatePay
40
45
 
41
46
  attr_writer :id, :amount, :status, :method, :description,
42
47
  :expires_in, :qr_code, :qr_code_image, :customer,
43
- :metadata, :dev_mode, :created_at, :updated_at
48
+ :metadata, :dev_mode, :created_at, :updated_at,
49
+ :due_date, :bar_code, :url, :br_code, :br_code_base64,
50
+ :expires_at
44
51
  end
45
52
  end
46
53
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AbacatePay
4
- VERSION = "1.0.0"
4
+ VERSION = "1.1.0"
5
5
  end
data/lib/abacate_pay.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "abacate_pay/version"
4
4
  require "abacate_pay/configuration"
5
+ require "abacate_pay/collection"
5
6
 
6
7
  # Main module for AbacatePay SDK integration
7
8
  module AbacatePay
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: abacatepay-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matheus Cardoso
@@ -29,6 +29,20 @@ dependencies:
29
29
  - - ">="
30
30
  - !ruby/object:Gem::Version
31
31
  version: 2.14.3
32
+ - !ruby/object:Gem::Dependency
33
+ name: faraday-retry
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - "~>"
37
+ - !ruby/object:Gem::Version
38
+ version: '2.3'
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - "~>"
44
+ - !ruby/object:Gem::Version
45
+ version: '2.3'
32
46
  - !ruby/object:Gem::Dependency
33
47
  name: bundler-audit
34
48
  requirement: !ruby/object:Gem::Requirement
@@ -130,6 +144,7 @@ files:
130
144
  - lib/abacate_pay/clients/subscription_client.rb
131
145
  - lib/abacate_pay/clients/transparent_client.rb
132
146
  - lib/abacate_pay/clients/webhook_client.rb
147
+ - lib/abacate_pay/collection.rb
133
148
  - lib/abacate_pay/configuration.rb
134
149
  - lib/abacate_pay/enums.rb
135
150
  - lib/abacate_pay/enums/billings/frequencies.rb