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.
- checksums.yaml +4 -4
- data/.rubocop.yml +18 -0
- data/CHANGELOG.md +132 -12
- data/README.md +122 -20
- data/abacatepay-ruby.gemspec +2 -1
- data/lib/abacate_pay/clients/billing_client.rb +13 -3
- data/lib/abacate_pay/clients/checkout_client.rb +25 -4
- data/lib/abacate_pay/clients/client.rb +171 -16
- data/lib/abacate_pay/clients/coupon_client.rb +1 -1
- data/lib/abacate_pay/clients/customer_client.rb +1 -1
- data/lib/abacate_pay/clients/payment_link_client.rb +3 -3
- data/lib/abacate_pay/clients/payout_client.rb +1 -1
- data/lib/abacate_pay/clients/pix_client.rb +1 -1
- data/lib/abacate_pay/clients/product_client.rb +1 -1
- data/lib/abacate_pay/clients/store_client.rb +3 -1
- data/lib/abacate_pay/clients/subscription_client.rb +36 -1
- data/lib/abacate_pay/clients/transparent_client.rb +78 -24
- data/lib/abacate_pay/clients/webhook_client.rb +2 -2
- data/lib/abacate_pay/clients.rb +1 -1
- data/lib/abacate_pay/collection.rb +98 -0
- data/lib/abacate_pay/configuration.rb +20 -4
- data/lib/abacate_pay/enums/billings/methods.rb +8 -1
- data/lib/abacate_pay/enums/webhooks/event_types.rb +7 -0
- data/lib/abacate_pay/resources/checkouts.rb +10 -2
- data/lib/abacate_pay/resources/customers.rb +62 -21
- data/lib/abacate_pay/resources/resource.rb +1 -1
- data/lib/abacate_pay/resources/transparents.rb +29 -5
- data/lib/abacate_pay/resources/webhook_endpoints.rb +1 -1
- data/lib/abacate_pay/resources.rb +1 -1
- data/lib/abacate_pay/version.rb +1 -1
- data/lib/abacate_pay/webhooks.rb +53 -7
- data/lib/abacate_pay.rb +1 -0
- data/lib/abacatepay-ruby.rb +14 -0
- 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 =
|
|
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
|
|
41
|
-
raise ApiError, "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
8
|
-
# raffles, sign-up forms
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
+
build_list(response, Resources::Transparents)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
#
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
data/lib/abacate_pay/clients.rb
CHANGED
|
@@ -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
|
|
12
|
-
#
|
|
13
|
-
#
|
|
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
|
|
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.
|