abacatepay-ruby 1.1.0 → 1.2.2

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +8 -0
  3. data/CHANGELOG.md +137 -19
  4. data/README.md +43 -24
  5. data/abacatepay-ruby.gemspec +1 -1
  6. data/lib/abacate_pay/clients/billing_client.rb +2 -2
  7. data/lib/abacate_pay/clients/checkout_client.rb +1 -1
  8. data/lib/abacate_pay/clients/client.rb +66 -15
  9. data/lib/abacate_pay/clients/coupon_client.rb +4 -1
  10. data/lib/abacate_pay/clients/customer_client.rb +4 -1
  11. data/lib/abacate_pay/clients/payment_link_client.rb +2 -2
  12. data/lib/abacate_pay/clients/payout_client.rb +7 -1
  13. data/lib/abacate_pay/clients/pix_client.rb +11 -3
  14. data/lib/abacate_pay/clients/product_client.rb +4 -1
  15. data/lib/abacate_pay/clients/store_client.rb +3 -1
  16. data/lib/abacate_pay/clients/subscription_client.rb +5 -10
  17. data/lib/abacate_pay/clients/transparent_client.rb +4 -2
  18. data/lib/abacate_pay/clients/webhook_client.rb +21 -4
  19. data/lib/abacate_pay/clients.rb +1 -1
  20. data/lib/abacate_pay/configuration.rb +3 -3
  21. data/lib/abacate_pay/enums/billings/methods.rb +1 -1
  22. data/lib/abacate_pay/enums/checkouts/statuses.rb +4 -1
  23. data/lib/abacate_pay/enums/coupons/statuses.rb +4 -1
  24. data/lib/abacate_pay/enums/webhooks/event_types.rb +1 -1
  25. data/lib/abacate_pay/resources/coupons.rb +29 -3
  26. data/lib/abacate_pay/resources/customers.rb +62 -21
  27. data/lib/abacate_pay/resources/payouts.rb +6 -0
  28. data/lib/abacate_pay/resources/resource.rb +21 -5
  29. data/lib/abacate_pay/resources/transparents.rb +21 -4
  30. data/lib/abacate_pay/resources/webhook_endpoints.rb +1 -1
  31. data/lib/abacate_pay/resources.rb +1 -1
  32. data/lib/abacate_pay/version.rb +1 -1
  33. data/lib/abacate_pay/webhooks.rb +53 -7
  34. data/lib/abacatepay-ruby.rb +14 -0
  35. metadata +4 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 604226a94737477c55324bf08cb81736cce65fdf482453bd2667572c8986b010
4
- data.tar.gz: 4e292b8d7db21a444cc4348bd59bc2cf396849ff8d99db4d4463ab1b651962fe
3
+ metadata.gz: 6049a0d508a75825d5bd94835dd27ec3241a0040129299165cb122de1af4636c
4
+ data.tar.gz: 8d2e2a858ac296de096f69ebf9acbab31c220d127631bd7a9534f30aa5caf5a7
5
5
  SHA512:
6
- metadata.gz: 55eeee33fcf8a4b509c849f85d90ccb3f0fb4a2b4aad040325da4a6ed19bf2b60394190ce848ff0b03fa98ec56b0bddccf2fae677c19b1db1dfc7c744819286a
7
- data.tar.gz: e8258c5ba01696d7d76fb85b4314a047052b04f13e0a90c4a826fe83f14ce967ea817cb445d3158a61be27a6ec4af99da9fc0c4dd477449c1bf1b60412f7f612
6
+ metadata.gz: 7733ebca9a9176869461cdbf726933ff32836f8953df5cd4a4a4c64c1021261c0ec6d5a75e753b382071f68b93928a928db8364c0dc280e050aed59a0b9a534e
7
+ data.tar.gz: f90e64c56b1681d41ee820ba3ba05e9e7fa0b9ff61afd2d9adf1daf8d6debf68d62f9865f49f5d956744843f1eb51fecc9cfbc49146a7717c7bbbf7a39a7d078
data/.rubocop.yml CHANGED
@@ -58,6 +58,7 @@ Naming/MethodParameterName:
58
58
  Naming/PredicateMethod:
59
59
  AllowedMethods:
60
60
  - verify!
61
+ - verify_secret!
61
62
 
62
63
  # Faraday errors carry a response payload, not just a message; the compact
63
64
  # form is how Faraday itself documents constructing them.
@@ -119,14 +120,21 @@ RSpec/SpecFilePathFormat:
119
120
 
120
121
  # These specs deliberately cut across the whole library rather than describing
121
122
  # a single class.
123
+ # The file name must match the gem name so Bundler's default require works.
124
+ Naming/FileName:
125
+ Exclude:
126
+ - "lib/abacatepay-ruby.rb"
127
+
122
128
  RSpec/DescribeClass:
123
129
  Exclude:
130
+ - "spec/load_spec.rb"
124
131
  - "spec/clients/request_contract_spec.rb"
125
132
  - "spec/clients/pagination_spec.rb"
126
133
  - "spec/clients/resilience_spec.rb"
127
134
  - "spec/clients/logging_spec.rb"
128
135
  - "spec/clients/boleto_spec.rb"
129
136
  - "spec/packaging_spec.rb"
137
+ - "spec/api_conformance_spec.rb"
130
138
 
131
139
  # `has_more?` mirrors the API's own `hasMore` field and matches the convention
132
140
  # other payment SDKs use for cursor pagination. `more?` would read as a
data/CHANGELOG.md CHANGED
@@ -7,6 +7,122 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.2.2] - 2026-08-06
11
+
12
+ Found by exercising all 39 public calls against the live sandbox.
13
+
14
+ ### Fixed
15
+
16
+ - **`customers.delete`, `products.delete` and `coupons.delete` always failed.**
17
+ Every delete endpoint reads the id from the query string; sending it only in
18
+ the body fails with "Expected property 'id' to be string but found:
19
+ undefined". Same defect as `webhook_endpoints.delete`.
20
+ - **`pix.send_pix` and `payouts.create` always failed.** The API nests the
21
+ destination under `pix` as `{ key, type }`. The SDK sent `key`/`keyType` at
22
+ the top level, which fails with "Property 'pix' is missing". `Payouts` gained
23
+ `pix_key` and `pix_key_type`, without which a payout has no destination at
24
+ all.
25
+ - **`subscriptions.create` always failed.** Line items take the product `id`,
26
+ the same shape checkouts use; the SDK sent `externalId`, which fails with
27
+ "Expected property 'items.0.id' to be string".
28
+ - **Parsing a response could crash on a value the SDK did not know.**
29
+ `initialize_enum` validated while reading API responses, so any status
30
+ AbacatePay introduced turned into an `ArgumentError` inside `list` and `get`
31
+ for every integration at once. It happened with the coupon status `DISABLED`
32
+ and the payment-link status `ACTIVE`, which made `coupons.toggle` and
33
+ `payment_links.create` fail outright. Unknown values now pass through with a
34
+ warning; use the enum's `validate!` when you want a hard failure on a value
35
+ you are about to send.
36
+ - **`Coupons#code` and `#current_redeems` were always nil.** The API uses the
37
+ coupon code as the object's `id` and sends the counter as `redeemsCount`.
38
+ Adds `redeems_count`, `notes` and `dev_mode?`; `code` falls back to the id
39
+ and `current_redeems` keeps answering.
40
+ - **`webhook_endpoints.delete` always failed.** The API reads the id from the
41
+ query string on that endpoint, and answers `{"success":true,"error":null}`
42
+ with no `data` key, which the SDK then rejected as malformed. A successful
43
+ response carrying no payload is no longer an error.
44
+ - Building a resource from an empty payload raised `NoMethodError`.
45
+
46
+ ### Added
47
+
48
+ - `webhook_endpoints.create` refuses a secret shorter than 32 characters
49
+ locally, which is what the API requires.
50
+ - `pix.list` documents that the API requires an `id`; despite the name it
51
+ filters rather than lists.
52
+
53
+ ### Changed
54
+
55
+ - The coupon and checkout status enums gained `DISABLED` and `ACTIVE`.
56
+
57
+ ## [1.2.1] - 2026-08-06
58
+
59
+ Inclui tudo o que estava previsto para a 1.2.0, que nunca chegou a ser
60
+ publicada. Todas as correções abaixo foram encontradas exercitando o SDK
61
+ contra o sandbox da AbacatePay e contra uma aplicação Rails real, e não contra
62
+ a própria suíte de testes.
63
+
64
+ ### Fixed
65
+
66
+ - **Webhook signature verification rejected every genuine delivery.** The SDK
67
+ compared against `OpenSSL::HMAC.hexdigest`, but AbacatePay sends the HMAC
68
+ base64-encoded. The [security
69
+ spec](https://docs.abacatepay.com/pages/webhooks/security) and its Node,
70
+ Python and Go samples all use base64. `verify!` therefore failed every real
71
+ webhook while accepting a hex signature nobody sends, making webhook
72
+ verification inoperative since it was introduced. Reported by @danieldenis01
73
+ in #6, found while building a `pay-rails/pay` adapter against the sandbox and
74
+ production, along with the three fixes above.
75
+ - **`qr_code` and `qr_code_image` always returned nil for transparent PIX.** The
76
+ API sends the copy-and-paste payload as `brCode` and the image as
77
+ `brCodeBase64`; the readers looked for the older `qrCode`/`qrCodeImage`
78
+ spelling. Both are accepted now, preferring `qr*` when present. `platform_fee`
79
+ and `receipt_url` were missing entirely.
80
+ - **`simulate_payment` sent the id in the body.** The API reads it from the
81
+ query string on this endpoint, like `check`, and body-only requests fail with
82
+ "Expected property 'id'".
83
+ - **Optional fields were sent as explicit nulls.** The API rejects
84
+ `{"cellphone": null}` with HTTP 400 `Expected property 'cellphone' to be
85
+ string but found: null`, and payload builders emit nils for anything the
86
+ caller left unset. Payloads are now compacted recursively at the request
87
+ boundary, so this cannot be forgotten by a future endpoint. Also reported in
88
+ #6; the fix covers `products`, `coupons`, `payouts`, `pix` and
89
+ `subscriptions`, which had the same defect.
90
+
91
+ - **`gem "abacatepay-ruby"` did not load the SDK.** Bundler requires a gem by
92
+ its own name, so Rails called `require "abacatepay-ruby"`, and the entry
93
+ point was `lib/abacate_pay.rb`. The require failed, but the gemspec had
94
+ already defined `AbacatePay` with nothing but `VERSION` in it, so the first
95
+ call raised `undefined method 'configure' for module AbacatePay` instead of a
96
+ missing-constant error. Every Rails user had to discover `require:
97
+ "abacate_pay"` on their own. There is now an entry point matching the gem
98
+ name.
99
+ - **Customer responses dropped every field except the id.** The API returns
100
+ `name`, `email`, `cellphone` and `taxId` at the top level of the customer
101
+ object with an empty `metadata`; the resource only mapped a nested
102
+ `metadata`, so `customers.list` and `customers.get` returned objects with
103
+ nothing usable and `customer.metadata.name` was always nil. The fields are
104
+ exposed directly now, and `metadata` keeps answering for code written against
105
+ the previous interface.
106
+ - **`AbacatePay.store.get` always failed.** The API serves this as
107
+ `stores/get`, plural. The singular path documented in the reference answers
108
+ HTTP 400.
109
+
110
+ ### Added
111
+
112
+ - `AbacatePay::Webhooks::PUBLIC_KEY`, the fixed key AbacatePay signs with, now
113
+ the default for `secret:`. It is public and global, so it proves body
114
+ integrity only: anyone can compute a valid signature with it.
115
+ - `AbacatePay::Webhooks.verify_secret!(received:, expected:)` for the
116
+ `webhookSecret` query parameter, which is what actually authenticates the
117
+ origin. The docs instruct using both mechanisms together; the SDK previously
118
+ offered only the HMAC half.
119
+
120
+ ### Changed
121
+
122
+ - The README no longer implies `has_more?` is always available. List responses
123
+ only carry pagination metadata when the API sends it; otherwise they are
124
+ plain Arrays, exactly as before.
125
+
10
126
  ## [1.1.0] - 2026-08-05
11
127
 
12
128
  ### Added
@@ -19,16 +135,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
19
135
  `br_code_base64` and `expires_at`.
20
136
  - **Cursor pagination.** List endpoints cap at 100 items and report `hasMore`
21
137
  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.
138
+ unreachable. `list` now returns an `AbacatePay::Collection`. It is Enumerable and
139
+ Array-compatible, so existing code is unaffected, and it carries `has_more?`
140
+ and `next_cursor`. `each_page` and `auto_paging_each` walk every page.
25
141
  - **Retries with exponential backoff and jitter** on 429 and 5xx, configurable
26
142
  via `config.max_retries` (default 2). Only idempotent methods are retried;
27
143
  POST never is, because repeating `checkouts/create` after a timeout could
28
144
  charge a customer twice and the API exposes no idempotency key.
29
145
  - **Optional request logging** via `config.logger`, with the bearer token
30
146
  redacted and bodies never logged.
31
- - `SubscriptionClient#change_plan` and `#record_usage` the last two of the 45
147
+ - `SubscriptionClient#change_plan` and `#record_usage`: the last two of the 45
32
148
  documented v2 endpoints. All 45 are now covered.
33
149
  - `subscription.payment_failed` and `subscription.trial_started` webhook event
34
150
  types. `payment_failed` is the dunning signal.
@@ -50,9 +166,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
50
166
  ### Corrected
51
167
 
52
168
  - 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
169
+ for every path". That is wrong: v1 is still served, under a different dialect.
170
+ It uses singular paths (`/v1/billing/`, `/v1/customer/`) and different
171
+ resource names (`pixQrCode`). The original diagnosis tested v2-shaped paths against
56
172
  `/v1`. The fix itself stands: this SDK only ever spoke v2's dialect, so 10 of
57
173
  the 12 paths it calls do not exist on v1 and routing there produced 404s.
58
174
 
@@ -65,15 +181,15 @@ versions and will not change without a major bump.
65
181
 
66
182
  - Full API coverage: checkouts, coupons, customers, payouts, PIX transfers,
67
183
  products, store, subscriptions and transparent checkout.
68
- - `AbacatePay::Webhooks.construct_event` verifies the signature and parses the
184
+ - `AbacatePay::Webhooks.construct_event`, which verifies the signature and parses the
69
185
  body in a single call, so an unverified payload cannot be acted on.
70
186
  - `AbacatePay::Webhooks::PayloadError` for malformed or non-object webhook bodies.
71
- - `PaymentLinkClient` (`AbacatePay.payment_links`) reusable multi-payment links.
72
- - `WebhookClient` (`AbacatePay.webhook_endpoints`) webhook endpoint registration,
187
+ - `PaymentLinkClient` (`AbacatePay.payment_links`) for reusable multi-payment links.
188
+ - `WebhookClient` (`AbacatePay.webhook_endpoints`) for webhook endpoint registration,
73
189
  with local HTTPS validation so a bad endpoint fails before the round trip.
74
- - `CheckoutClient#refund`, `TransparentClient#refund`, `PaymentLinkClient#refund` —
75
- refunds were previously impossible through the SDK.
76
- - `SubscriptionClient#cancel` a subscription created through the SDK could not
190
+ - `CheckoutClient#refund`, `TransparentClient#refund`, `PaymentLinkClient#refund`.
191
+ Refunds were previously impossible through the SDK.
192
+ - `SubscriptionClient#cancel`. A subscription created through the SDK could not
77
193
  be cancelled through it.
78
194
  - CI workflow running the suite on Ruby 3.2, 3.3, 3.4 and 4.0, plus RuboCop, a
79
195
  dependency audit and a gem build check on every pull request.
@@ -111,7 +227,7 @@ versions and will not change without a major bump.
111
227
  without `X-Webhook-Signature` reached `secure_compare` as `nil` and raised
112
228
  `NoMethodError`, so `valid?` returned neither `true` nor `false` and the
113
229
  endpoint returned a server error instead of rejecting the request. Missing and
114
- empty signatures and a missing secret are now `SignatureError`.
230
+ empty signatures, plus a missing secret, are now `SignatureError`.
115
231
  - **`Webhooks.parse` leaked parser internals.** Malformed JSON raised
116
232
  `JSON::ParserError` and a non-object JSON body raised `TypeError`; both now
117
233
  raise `PayloadError`.
@@ -121,7 +237,7 @@ versions and will not change without a major bump.
121
237
  - **Changing the token at runtime had no effect.** Clients were memoized on first
122
238
  use and never rebuilt, so `AbacatePay.configure` after a first API call kept
123
239
  sending the previous bearer token. `configure` now discards memoized clients.
124
- - `Configuration#api_url` was declared twice as an `attr_reader` and as a
240
+ - `Configuration#api_url` was declared twice, as an `attr_reader` and as a
125
241
  method. The dead reader has been removed.
126
242
  - `CustomerClient#get` and `#delete` had no test coverage at all.
127
243
  - The `customer` and `billing` specs stubbed singular endpoint paths while the
@@ -142,13 +258,13 @@ versions and will not change without a major bump.
142
258
  ### Changed
143
259
 
144
260
  - **BREAKING: `required_ruby_version` is now `>= 3.2.0`** (was `>= 2.6.0`). The
145
- old floor was never installable faraday 2.x requires Ruby 3.0 or newer so
261
+ old floor was never installable: faraday 2.x requires Ruby 3.0 or newer, so
146
262
  no working installation is losing support, but a `bundle update` on Ruby 2.6
147
263
  or 3.1 will now refuse to resolve instead of failing later.
148
264
  - **`config.environment` is deprecated and now warns.** It was declared,
149
265
  defaulted, and validated, but read by nothing. Per AbacatePay's own
150
- documentation the environment is decided by the API key Dev mode keys
151
- simulate transactions so the setting could never have worked. It is kept as
266
+ documentation the environment is decided by the API key (Dev mode keys
267
+ simulate transactions), so the setting could never have worked. It is kept as
152
268
  an accepted no-op so existing initializers keep loading.
153
269
  - `validate!` no longer rejects unknown `environment` values, and now rejects an
154
270
  empty or whitespace-only token.
@@ -163,13 +279,15 @@ versions and will not change without a major bump.
163
279
  - Publishing to GitHub Packages. The step never executed successfully in any run
164
280
  and nothing consumed the gem from that registry.
165
281
  - `sig/abacatepay/rails.rbs`. Leftover `bundle gem` scaffolding declaring an
166
- `Abacatepay::Rails` module that does not exist in this codebase and it was
282
+ `Abacatepay::Rails` module that does not exist in this codebase, and that was
167
283
  shipping inside the published gem, where a type checker would read it.
168
284
 
169
285
  ## [0.1.0] - 2024-12-13
170
286
 
171
287
  - Initial release
172
288
 
289
+ [1.2.2]: https://github.com/AbacatePay/abacatepay-ruby-sdk/releases/tag/v1.2.2
290
+ [1.2.1]: https://github.com/AbacatePay/abacatepay-ruby-sdk/releases/tag/v1.2.1
173
291
  [1.1.0]: https://github.com/AbacatePay/abacatepay-ruby-sdk/releases/tag/v1.1.0
174
292
  [1.0.0]: https://github.com/AbacatePay/abacatepay-ruby-sdk/releases/tag/v1.0.0
175
293
  [0.1.0]: https://github.com/AbacatePay/abacatepay-ruby-sdk/releases/tag/v0.1.0
data/README.md CHANGED
@@ -8,7 +8,7 @@ O [`abacatepay-ruby`](https://rubygems.org/gems/abacatepay-ruby) é um **wrapper
8
8
 
9
9
  <img src="https://res.cloudinary.com/dkok1obj5/image/upload/v1767631413/avo_clhmaf.png" width="100%" alt="AbacatePay Open Source"/>
10
10
 
11
- Funciona em qualquer aplicação Ruby Rails, Sinatra, Hanami ou Ruby puro.
11
+ Funciona em qualquer aplicação Ruby: Rails, Sinatra, Hanami ou Ruby puro.
12
12
 
13
13
  Referência completa da API [aqui](https://abacatepay.readme.io/reference).
14
14
 
@@ -56,7 +56,7 @@ Nunca utilize sua API key diretamente no código.
56
56
 
57
57
  Em Rails, coloque isso em `config/initializers/abacatepay.rb`.
58
58
 
59
- Trocar o token em runtime tem efeito imediato os clients são reconstruídos a cada `configure`.
59
+ Trocar o token em runtime tem efeito imediato: os clients são reconstruídos a cada `configure`.
60
60
 
61
61
  ### Criando uma cobrança
62
62
 
@@ -103,42 +103,53 @@ AbacatePay.checkouts.list(status: 'PAID', email: 'user@example.com')
103
103
 
104
104
  <div align="center">
105
105
 
106
- Listas retornam no máximo 100 itens. O resultado é uma `Collection` — funciona como Array, e ainda carrega o cursor:
106
+ Listas retornam no máximo 100 itens.
107
107
 
108
108
  </div>
109
109
 
110
110
  ```ruby
111
111
  page = AbacatePay.customers.list
112
112
  page.first.id # funciona como Array
113
- page.has_more? # => true
114
- page.next_cursor # => "cust_abc123"
115
113
 
116
114
  # Para percorrer tudo sem lidar com cursor:
117
115
  AbacatePay.customers.auto_paging_each { |customer| puts customer.id }
118
- AbacatePay.customers.each_page { |page| puts page.size }
116
+ ```
117
+
118
+ <div align="center">
119
+
120
+ Quando a API envia metadados de paginação, o resultado é uma `Collection` que carrega o cursor. Quando não envia, é um Array puro, então cheque antes de usar:
121
+
122
+ </div>
123
+
124
+ ```ruby
125
+ if page.respond_to?(:has_more?) && page.has_more?
126
+ proxima = AbacatePay.customers.list(after: page.next_cursor)
127
+ end
119
128
  ```
120
129
 
121
130
  <div align="center">
122
131
 
123
132
  ## Versionamento
124
133
 
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.
134
+ O SDK fala **exclusivamente a v2**, em `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.
126
135
 
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.
136
+ 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.
128
137
 
129
- O `BillingClient` também está descontinuado, substituído pelo `CheckoutClient`. Ele emite um aviso ao ser instanciado, e seus endpoints `/billings/*` não existem na v2:
138
+ O `BillingClient` também está descontinuado, substituído pelo `CheckoutClient`. Seus endpoints `/billings/*` não existem em nenhuma versão da API: toda chamada falha. Será removido na 2.0.0:
130
139
 
131
140
  </div>
132
141
 
133
142
  ```
134
- [DEPRECATION] BillingClient is deprecated. Use CheckoutClient instead.
143
+ [DEPRECATION] BillingClient calls /billings/* endpoints that do not exist on the
144
+ AbacatePay API, every request will fail. Use AbacatePay.checkouts instead.
145
+ This class will be removed in 2.0.0.
135
146
  ```
136
147
 
137
148
  <div align="center">
138
149
 
139
150
  ## Tratamento de erros
140
151
 
141
- Diferente do SDK de Node, **este SDK levanta exceções** ele não retorna `{ data, error, success }`. Toda falha vira uma exceção tipada que herda de `AbacatePay::Error`, então você pode capturar tudo de uma vez ou tratar caso a caso.
152
+ Diferente do SDK de Node, **este SDK levanta exceções**. Ele não retorna `{ data, error, success }`. Toda falha vira uma exceção tipada que herda de `AbacatePay::Error`, então você pode capturar tudo de uma vez ou tratar caso a caso.
142
153
 
143
154
  </div>
144
155
 
@@ -166,18 +177,26 @@ Erros de rede e timeout são normalizados para `ApiError`, com a mensagem da API
166
177
 
167
178
  ## Webhooks
168
179
 
169
- Endpoints de webhook são públicos e não autenticados. Use `construct_event`, que **verifica a assinatura antes de fazer o parse** é o único ponto de entrada que não permite agir sobre um payload não verificado.
180
+ Endpoints de webhook são públicos. A AbacatePay usa **dois mecanismos**, e a documentação orienta usar os dois: o `webhookSecret` na query autentica a origem, e a assinatura HMAC garante que o corpo não foi alterado. A chave HMAC é pública e global: ela sozinha não prova origem.
170
181
 
171
182
  </div>
172
183
 
173
184
  ```ruby
174
185
  payload = request.body.read
175
186
  signature = request.headers['X-Webhook-Signature']
176
- secret = ENV['ABACATEPAY_WEBHOOK_SECRET']
177
187
 
178
188
  begin
189
+ # 1. Autentica a origem com o secret que você definiu ao criar o webhook,
190
+ # enviado pela AbacatePay como query parameter.
191
+ AbacatePay::Webhooks.verify_secret!(
192
+ received: params[:webhookSecret],
193
+ expected: ENV['ABACATEPAY_WEBHOOK_SECRET']
194
+ )
195
+
196
+ # 2. Verifica a integridade do corpo. A chave HMAC é pública, então este
197
+ # passo sozinho não prova origem. Por isso os dois juntos.
179
198
  event = AbacatePay::Webhooks.construct_event(
180
- payload: payload, signature: signature, secret: secret
199
+ payload: payload, signature: signature
181
200
  )
182
201
  rescue AbacatePay::Webhooks::SignatureError
183
202
  return head :unauthorized
@@ -194,7 +213,7 @@ end
194
213
 
195
214
  <div align="center">
196
215
 
197
- Header ausente, secret vazio, assinatura forjada e corpo malformado são todos tratados como casos esperados levantam erro tipado em vez de derrubar o endpoint. A comparação de assinatura é feita em tempo constante.
216
+ Header ausente, secret vazio, assinatura forjada e corpo malformado são todos tratados como casos esperados: levantam erro tipado em vez de derrubar o endpoint. A comparação de assinatura é feita em tempo constante.
198
217
 
199
218
  Os métodos de baixo nível continuam disponíveis:
200
219
 
@@ -204,7 +223,7 @@ Os métodos de baixo nível continuam disponíveis:
204
223
  # Levanta SignatureError se a assinatura estiver ausente ou inválida
205
224
  AbacatePay::Webhooks.verify!(payload: payload, signature: signature, secret: secret)
206
225
 
207
- # Contraparte booleana nunca levanta exceção
226
+ # Contraparte booleana. Nunca levanta exceção
208
227
  AbacatePay::Webhooks.valid?(payload: payload, signature: signature, secret: secret)
209
228
 
210
229
  # Faz parse de um corpo já verificado
@@ -219,7 +238,7 @@ AbacatePay::Webhooks.parse(payload)
219
238
  |---|---|
220
239
  | Checkout | `checkout.completed`, `checkout.refunded`, `checkout.disputed` |
221
240
  | Transparent | `transparent.completed`, `transparent.refunded`, `transparent.disputed` |
222
- | Subscription | `subscription.completed`, `subscription.renewed`, `subscription.cancelled` |
241
+ | Subscription | `subscription.completed`, `subscription.renewed`, `subscription.cancelled`, `subscription.payment_failed`, `subscription.trial_started` |
223
242
  | Transfer | `transfer.completed`, `transfer.failed` |
224
243
  | Payout | `payout.completed`, `payout.failed` |
225
244
 
@@ -404,7 +423,7 @@ AbacatePay.checkouts.create(
404
423
 
405
424
  <div align="center">
406
425
 
407
- No checkout transparente, o boleto exige nome e CPF/CNPJ do pagador o SDK valida antes de chamar a API:
426
+ No checkout transparente, o boleto exige nome e CPF/CNPJ do pagador, o SDK valida antes de chamar a API:
408
427
 
409
428
  </div>
410
429
 
@@ -442,7 +461,7 @@ AbacatePay.checkouts.create(
442
461
 
443
462
  ### Links de pagamento
444
463
 
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`.
464
+ 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`.
446
465
 
447
466
  </div>
448
467
 
@@ -464,7 +483,7 @@ puts link.url # compartilhe esta URL
464
483
 
465
484
  ### Estornos
466
485
 
467
- O estorno é sempre integral a AbacatePay não faz estorno parcial.
486
+ O estorno é sempre integral, a AbacatePay não faz estorno parcial.
468
487
 
469
488
  </div>
470
489
 
@@ -485,10 +504,10 @@ Cancela imediatamente; parcelas futuras pendentes são canceladas junto.
485
504
  ```ruby
486
505
  AbacatePay.subscriptions.cancel('subs_abc123xyz')
487
506
 
488
- # Upgrade/downgrade vale a partir do próximo ciclo
507
+ # Upgrade/downgrade, vale a partir do próximo ciclo
489
508
  AbacatePay.subscriptions.change_plan('subs_abc123xyz', product_id: 'prod_pro', quantity: 1)
490
509
 
491
- # Cobrança por uso produto sem ciclo
510
+ # Cobrança por uso, produto sem ciclo
492
511
  AbacatePay.subscriptions.record_usage('subs_abc123xyz', product_id: 'prod_api', units: 50)
493
512
  ```
494
513
 
@@ -531,7 +550,7 @@ AbacatePay.store.revenue(start_date: '2026-01-01', end_date: '2026-03-30')
531
550
 
532
551
  ## Enums
533
552
 
534
- Os valores são validados na construção do recurso um valor inválido levanta `ArgumentError` antes de qualquer chamada de rede.
553
+ Os valores são validados na construção do recurso, um valor inválido levanta `ArgumentError` antes de qualquer chamada de rede.
535
554
 
536
555
  | Enum | Valores |
537
556
  |---|---|
@@ -558,7 +577,7 @@ bundle exec rake # specs + rubocop
558
577
 
559
578
  <div align="center">
560
579
 
561
- Antes de abrir um PR, garanta que `bundle exec rake` passa e que a cobertura não caiu o CI roda os specs em Ruby 3.2, 3.3, 3.4 e 4.0, mais RuboCop, auditoria de dependências e build do gem.
580
+ Antes de abrir um PR, garanta que `bundle exec rake` passa e que a cobertura não caiu, o CI roda os specs em Ruby 3.2, 3.3, 3.4 e 4.0, mais RuboCop, auditoria de dependências e build do gem.
562
581
 
563
582
  ## Licença
564
583
 
@@ -48,7 +48,7 @@ Gem::Specification.new do |spec|
48
48
  spec.add_development_dependency "rspec", "~> 3.12"
49
49
  spec.add_development_dependency "rubocop", "~> 1.57"
50
50
  spec.add_development_dependency "rubocop-rspec", "~> 3.0"
51
- spec.add_development_dependency "simplecov", "~> 0.22"
51
+ spec.add_development_dependency "simplecov", "~> 1.0"
52
52
 
53
53
  # For more information and examples about making a new gem, check out our
54
54
  # guide at: https://bundler.io/guides/creating_gem.html
@@ -5,7 +5,7 @@ module AbacatePay
5
5
  # Deprecated client for the v1 billing endpoints.
6
6
  #
7
7
  # The endpoints this class calls (`/billings/create`, `/billings/list`) do
8
- # not exist on either API version v2 replaced them with `/checkouts/*`,
8
+ # not exist on either API version, v2 replaced them with `/checkouts/*`,
9
9
  # and v1 uses the singular `/billing/*`. Every call raises ApiError.
10
10
  #
11
11
  # Kept only so existing code keeps loading; it will be removed in 2.0.0.
@@ -19,7 +19,7 @@ module AbacatePay
19
19
  # @deprecated Use {CheckoutClient} instead
20
20
  def initialize(client = nil)
21
21
  warn "[DEPRECATION] BillingClient calls /billings/* endpoints that do not exist on the " \
22
- "AbacatePay API every request will fail. Use AbacatePay.checkouts instead. " \
22
+ "AbacatePay API, so every request fails. Use AbacatePay.checkouts instead. " \
23
23
  "This class will be removed in 2.0.0."
24
24
  super(URI, client)
25
25
  end
@@ -35,7 +35,7 @@ module AbacatePay
35
35
  end
36
36
 
37
37
  # Refunds a paid checkout in full. AbacatePay does not support partial
38
- # refunds the original amount is always returned.
38
+ # refunds, the original amount is always returned.
39
39
  #
40
40
  # @param id [String] Public checkout ID (`bill_...`) or charge ID
41
41
  # (`char_...`, `pix_char_...`, `card_...`)
@@ -10,7 +10,7 @@ module AbacatePay
10
10
  # This class handles API requests using Faraday and provides a way to manage
11
11
  # authentication and communication with the AbacatePay service.
12
12
  class Client
13
- # Statuses worth retrying. 429 is rate limiting and 5xx are transient
13
+ # Statuses worth retrying. 429 is rate limiting and 5xx are transient -
14
14
  # AbacatePay's own reference tells integrators to back off on both.
15
15
  RETRIABLE_STATUSES = [429, 500, 502, 503, 504].freeze
16
16
 
@@ -75,29 +75,80 @@ module AbacatePay
75
75
  # @param method [String] The HTTP method (e.g., GET, POST)
76
76
  # @param uri [String] The endpoint URI relative to the base URI
77
77
  # @param options [Hash] Optional settings and parameters for the request
78
- # @return [Hash, AbacatePay::Collection] The response data a Collection
78
+ # @return [Hash, AbacatePay::Collection] The response data, a Collection
79
79
  # when the API reports pagination, the raw data otherwise
80
80
  # @raise [ApiError] If an error occurs during the request
81
81
  def request(method, uri, options = {})
82
- response = @client.public_send(method.downcase) do |req|
83
- req.url uri
84
- req.params = options[:params] if options[:params]
85
- req.body = options[:json].to_json if options[:json]
86
- end
87
-
82
+ response = send_request(method, uri, options)
88
83
  parsed = JSON.parse(response.body)
89
84
  raise ApiError, "API error: #{parsed["error"]}" if parsed["error"]
90
85
 
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
86
+ extract_data(parsed)
95
87
  rescue Faraday::Error => e
96
88
  handle_request_error(e)
97
89
  rescue JSON::ParserError => e
98
90
  raise ApiError, "Malformed API response: #{e.message}"
99
- rescue KeyError
100
- raise ApiError, "API response is missing the 'data' field"
91
+ end
92
+
93
+ # Pulls the payload out of the `{data, error, success}` envelope.
94
+ #
95
+ # Some endpoints answer a successful call with no `data` at all, for
96
+ # example `webhooks/delete` returning `{"success":true,"error":null}`.
97
+ # Treating that as malformed turned a working call into an ApiError.
98
+ #
99
+ # @param parsed [Hash] The decoded response body
100
+ # @return [Object, nil] The payload, or nil when the call carries none
101
+ # @raise [ApiError] if the envelope has neither data nor a success flag
102
+ def extract_data(parsed)
103
+ unless parsed.key?("data")
104
+ return nil if parsed["success"]
105
+
106
+ raise ApiError, "Unexpected API response: #{parsed.inspect[0, 120]}"
107
+ end
108
+
109
+ data = parsed["data"]
110
+ # Preserve the cursor when the API sends one; dropping it made paging
111
+ # past the first 100 records impossible.
112
+ parsed["pagination"] ? Collection.new(data, parsed["pagination"]) : data
113
+ end
114
+
115
+ # Issues the HTTP call.
116
+ #
117
+ # @param method [String] The HTTP method
118
+ # @param uri [String] The endpoint URI relative to the base URI
119
+ # @param options [Hash] Params and JSON body
120
+ # @return [Faraday::Response]
121
+ def send_request(method, uri, options)
122
+ @client.public_send(method.downcase) do |req|
123
+ req.url uri
124
+ req.params = options[:params] if options[:params]
125
+ req.body = compact_payload(options[:json]).to_json if options[:json]
126
+ end
127
+ end
128
+
129
+ # Removes nil values from an outgoing payload, at every depth.
130
+ #
131
+ # The API rejects explicit nulls: `{"cellphone": null}` comes back as
132
+ # HTTP 400 "Expected property 'cellphone' to be string but found: null",
133
+ # and payload builders naturally produce them for optional fields the
134
+ # caller left unset. Doing this at the boundary means no endpoint, present
135
+ # or future, can forget it.
136
+ #
137
+ # @param payload [Object] The payload about to be serialised
138
+ # @return [Object] The payload without nil entries
139
+ def compact_payload(payload)
140
+ case payload
141
+ when Hash
142
+ payload.each_with_object({}) do |(key, value), result|
143
+ next if value.nil?
144
+
145
+ result[key] = compact_payload(value)
146
+ end
147
+ when Array
148
+ payload.compact.map { |item| compact_payload(item) }
149
+ else
150
+ payload
151
+ end
101
152
  end
102
153
 
103
154
  # Maps a list response into resources without losing the page cursor.
@@ -123,7 +174,7 @@ module AbacatePay
123
174
  url: base_url,
124
175
  headers: build_headers(configuration),
125
176
  # Without an explicit timeout a hung gateway blocks the caller's
126
- # thread indefinitely inside a Rails request, that is an outage.
177
+ # thread indefinitely, inside a Rails request, that is an outage.
127
178
  request: {
128
179
  timeout: configuration.timeout,
129
180
  open_timeout: configuration.timeout
@@ -42,7 +42,10 @@ module AbacatePay
42
42
  # @param id [String] Coupon ID
43
43
  # @return [Resources::Coupons]
44
44
  def delete(id)
45
- response = request("POST", "delete", json: { id: id })
45
+ # The API reads the id from the query string on delete endpoints.
46
+ # Sending it only in the body fails with
47
+ # "Expected property 'id' to be string but found: undefined".
48
+ response = request("POST", "delete", params: { id: id }, json: {})
46
49
  Resources::Coupons.new(response)
47
50
  end
48
51
 
@@ -50,7 +50,10 @@ module AbacatePay
50
50
  # @param id [String] The customer ID
51
51
  # @return [Resources::Customers] The deleted Customer object
52
52
  def delete(id)
53
- response = request("POST", "delete", json: { id: id })
53
+ # The API reads the id from the query string on delete endpoints.
54
+ # Sending it only in the body fails with
55
+ # "Expected property 'id' to be string but found: undefined".
56
+ response = request("POST", "delete", params: { id: id }, json: {})
54
57
  Resources::Customers.new(response)
55
58
  end
56
59
  end
@@ -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"