paystack_sdk 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +67 -0
- data/README.md +624 -86
- data/lib/paystack_sdk/client.rb +72 -14
- data/lib/paystack_sdk/middleware/transport_errors.rb +23 -0
- data/lib/paystack_sdk/request_helpers.rb +117 -0
- data/lib/paystack_sdk/resources/banks.rb +163 -13
- data/lib/paystack_sdk/resources/base.rb +4 -2
- data/lib/paystack_sdk/resources/charges.rb +219 -0
- data/lib/paystack_sdk/resources/customers.rb +262 -145
- data/lib/paystack_sdk/resources/extensions/charges.rb +65 -0
- data/lib/paystack_sdk/resources/miscellaneous.rb +58 -0
- data/lib/paystack_sdk/resources/refunds.rb +116 -0
- data/lib/paystack_sdk/resources/transactions.rb +365 -238
- data/lib/paystack_sdk/resources/transfer_recipients.rb +135 -27
- data/lib/paystack_sdk/resources/transfers.rb +260 -25
- data/lib/paystack_sdk/response.rb +95 -18
- data/lib/paystack_sdk/utils/connection_utils.rb +100 -5
- data/lib/paystack_sdk/validations.rb +24 -20
- data/lib/paystack_sdk/version.rb +1 -1
- data/lib/paystack_sdk/webhook.rb +152 -0
- data/lib/paystack_sdk.rb +27 -2
- metadata +70 -16
- data/lib/paystack_sdk/resources/verification.rb +0 -36
|
@@ -60,6 +60,19 @@ module PaystackSdk
|
|
|
60
60
|
# @return [Integer] The status code of the API response
|
|
61
61
|
attr_reader :status_code
|
|
62
62
|
|
|
63
|
+
# Pagination metadata that Paystack returns with list responses
|
|
64
|
+
# (e.g. `total`, `page`, `pageCount`, `perPage`).
|
|
65
|
+
#
|
|
66
|
+
# @return [Response, nil] The wrapped `meta` object, or nil if the response has none
|
|
67
|
+
#
|
|
68
|
+
# @example
|
|
69
|
+
# response = transactions.list
|
|
70
|
+
# response.meta.total # => 40
|
|
71
|
+
# response.meta.pageCount # => 2
|
|
72
|
+
def meta
|
|
73
|
+
@meta ||= wrap_value(@raw_meta) if @raw_meta
|
|
74
|
+
end
|
|
75
|
+
|
|
63
76
|
# Initializes a new Response object
|
|
64
77
|
#
|
|
65
78
|
# @param response [Faraday::Response, Hash, Array] The raw API response or data
|
|
@@ -75,23 +88,22 @@ module PaystackSdk
|
|
|
75
88
|
@api_message = extract_api_message(@body)
|
|
76
89
|
@message = @api_message
|
|
77
90
|
@raw_data = extract_data_from_body(@body)
|
|
91
|
+
@raw_meta = @body["meta"] if @body.is_a?(Hash) && @body["meta"].is_a?(Hash)
|
|
78
92
|
|
|
79
93
|
case @status_code
|
|
80
94
|
when 200..299
|
|
81
95
|
@success = true
|
|
96
|
+
when 429
|
|
97
|
+
# Rate limiting - raise so callers can back off (the connection
|
|
98
|
+
# already retries automatically unless max_retries is 0)
|
|
99
|
+
raise RateLimitError.new(rate_limit_reset(response))
|
|
82
100
|
when 400..499
|
|
83
101
|
# Client errors - return unsuccessful response for user to handle
|
|
84
102
|
@success = false
|
|
85
103
|
@error_message = @api_message || "Client error"
|
|
86
104
|
|
|
87
105
|
# Still raise for authentication issues as these are usually config problems
|
|
88
|
-
if @status_code == 401
|
|
89
|
-
raise AuthenticationError.new(@api_message || "Authentication failed")
|
|
90
|
-
end
|
|
91
|
-
when 429
|
|
92
|
-
# Rate limiting - raise as users need to implement retry logic
|
|
93
|
-
retry_after = response.headers["Retry-After"]
|
|
94
|
-
raise RateLimitError.new(retry_after || 30)
|
|
106
|
+
raise AuthenticationError.new(@api_message || "Authentication failed") if @status_code == 401
|
|
95
107
|
when 500..599
|
|
96
108
|
# Server errors - raise as these indicate Paystack infrastructure issues
|
|
97
109
|
raise ServerError.new(@status_code, @api_message)
|
|
@@ -104,6 +116,7 @@ module PaystackSdk
|
|
|
104
116
|
@error_message = response.error_message
|
|
105
117
|
@api_message = response.api_message
|
|
106
118
|
@raw_data = response.raw_data
|
|
119
|
+
@raw_meta = response.raw_meta
|
|
107
120
|
else
|
|
108
121
|
@success = true
|
|
109
122
|
@raw_data = response
|
|
@@ -125,6 +138,34 @@ module PaystackSdk
|
|
|
125
138
|
@success
|
|
126
139
|
end
|
|
127
140
|
|
|
141
|
+
# Whether this response is a successful payment: the call succeeded and the transaction's
|
|
142
|
+
# `status` is "success". Paystack's docs say to confirm the amount and currency as well, so
|
|
143
|
+
# pass the ones you expect and they are compared too.
|
|
144
|
+
#
|
|
145
|
+
# @param amount [Integer, nil] The amount (in the currency's subunit) you expect
|
|
146
|
+
# @param currency [String, nil] The currency you expect, e.g. "GHS"
|
|
147
|
+
# @return [Boolean]
|
|
148
|
+
#
|
|
149
|
+
# @example
|
|
150
|
+
# response = client.transactions.verify(reference: ref)
|
|
151
|
+
# response.paid?(amount: 5000, currency: "GHS")
|
|
152
|
+
def paid?(amount: nil, currency: nil)
|
|
153
|
+
return false unless success? && status?(:success)
|
|
154
|
+
return false if amount && field(:amount) != amount
|
|
155
|
+
return false if currency && field(:currency).to_s.upcase != currency.to_s.upcase
|
|
156
|
+
|
|
157
|
+
true
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Whether the `status` field in the response data equals the given value, e.g.
|
|
161
|
+
# `response.status?(:send_pin)` on a charge. Paystack names the values; none are assumed here.
|
|
162
|
+
#
|
|
163
|
+
# @param value [String, Symbol]
|
|
164
|
+
# @return [Boolean]
|
|
165
|
+
def status?(value)
|
|
166
|
+
field(:status).to_s == value.to_s
|
|
167
|
+
end
|
|
168
|
+
|
|
128
169
|
# Check if the response failed
|
|
129
170
|
#
|
|
130
171
|
# @return [Boolean] true if the API request failed
|
|
@@ -153,6 +194,14 @@ module PaystackSdk
|
|
|
153
194
|
@body
|
|
154
195
|
end
|
|
155
196
|
|
|
197
|
+
# One field of a Hash response body, whichever way it is keyed; nil otherwise.
|
|
198
|
+
def field(name)
|
|
199
|
+
return nil unless @raw_data.is_a?(Hash)
|
|
200
|
+
|
|
201
|
+
@raw_data.key?(name) ? @raw_data[name] : @raw_data[name.to_s]
|
|
202
|
+
end
|
|
203
|
+
private :field
|
|
204
|
+
|
|
156
205
|
# Access hash values via methods (dot notation)
|
|
157
206
|
# Allows accessing data attributes directly: response.attribute_name
|
|
158
207
|
#
|
|
@@ -183,7 +232,8 @@ module PaystackSdk
|
|
|
183
232
|
super
|
|
184
233
|
end
|
|
185
234
|
|
|
186
|
-
# Access data via hash/array notation
|
|
235
|
+
# Access data via hash/array notation.
|
|
236
|
+
# Hash keys can be given as strings or symbols, whichever way the data is keyed.
|
|
187
237
|
#
|
|
188
238
|
# @param key [Object] The key or index to access
|
|
189
239
|
# @return [Object, Response] The value for the given key or index
|
|
@@ -191,19 +241,19 @@ module PaystackSdk
|
|
|
191
241
|
return nil unless @raw_data
|
|
192
242
|
|
|
193
243
|
if @raw_data.is_a?(Hash)
|
|
194
|
-
|
|
195
|
-
wrap_value(
|
|
244
|
+
actual_key = lookup_key(key)
|
|
245
|
+
wrap_value(@raw_data[actual_key]) unless actual_key.nil?
|
|
196
246
|
elsif @raw_data.is_a?(Array) && key.is_a?(Integer)
|
|
197
247
|
wrap_value(@raw_data[key])
|
|
198
248
|
end
|
|
199
249
|
end
|
|
200
250
|
|
|
201
|
-
# Check if key exists in hash
|
|
251
|
+
# Check if key exists in hash (as a string or a symbol)
|
|
202
252
|
#
|
|
203
253
|
# @param key [Symbol, String] The key to check
|
|
204
254
|
# @return [Boolean] Whether the key exists
|
|
205
255
|
def key?(key)
|
|
206
|
-
@raw_data.is_a?(Hash) &&
|
|
256
|
+
@raw_data.is_a?(Hash) && !lookup_key(key).nil?
|
|
207
257
|
end
|
|
208
258
|
|
|
209
259
|
# Iterate through hash entries or array items
|
|
@@ -211,7 +261,7 @@ module PaystackSdk
|
|
|
211
261
|
# @yield [key, value] For hashes, passes each key-value pair
|
|
212
262
|
# @yield [value] For arrays, passes each item
|
|
213
263
|
# @return [Response, Enumerator] Self for chaining or Enumerator if no block given
|
|
214
|
-
def each
|
|
264
|
+
def each
|
|
215
265
|
return enum_for(:each) unless block_given?
|
|
216
266
|
|
|
217
267
|
if @raw_data.is_a?(Hash)
|
|
@@ -231,7 +281,7 @@ module PaystackSdk
|
|
|
231
281
|
# @return [Integer] The number of items
|
|
232
282
|
# @!method empty?
|
|
233
283
|
# @return [Boolean] Whether the collection is empty
|
|
234
|
-
[
|
|
284
|
+
%i[size length count empty?].each do |method_name|
|
|
235
285
|
define_method(method_name) do
|
|
236
286
|
@raw_data.send(method_name) if @raw_data.respond_to?(method_name)
|
|
237
287
|
end
|
|
@@ -242,15 +292,43 @@ module PaystackSdk
|
|
|
242
292
|
# @return [Object, Response] The first item, wrapped if necessary
|
|
243
293
|
# @!method last
|
|
244
294
|
# @return [Object, Response] The last item, wrapped if necessary
|
|
245
|
-
[
|
|
295
|
+
%i[first last].each do |method_name|
|
|
246
296
|
define_method(method_name) do
|
|
247
297
|
return nil unless @raw_data.is_a?(Array)
|
|
298
|
+
|
|
248
299
|
wrap_value(@raw_data.send(method_name))
|
|
249
300
|
end
|
|
250
301
|
end
|
|
251
302
|
|
|
303
|
+
protected
|
|
304
|
+
|
|
305
|
+
# @return [Hash, nil] The unwrapped `meta` from the response body
|
|
306
|
+
attr_reader :raw_meta
|
|
307
|
+
|
|
252
308
|
private
|
|
253
309
|
|
|
310
|
+
# Finds the key actually used in the data for a string or symbol lookup.
|
|
311
|
+
#
|
|
312
|
+
# @return [Object, nil] The key present in the data, or nil if there is none
|
|
313
|
+
def lookup_key(key)
|
|
314
|
+
return key if @raw_data.key?(key)
|
|
315
|
+
|
|
316
|
+
alternate = case key
|
|
317
|
+
when String then key.to_sym
|
|
318
|
+
when Symbol then key.to_s
|
|
319
|
+
end
|
|
320
|
+
alternate if !alternate.nil? && @raw_data.key?(alternate)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Seconds until the rate-limit window ends, from Paystack's
|
|
324
|
+
# `x-ratelimit-reset` header (nil if absent, not numeric, negative or not finite).
|
|
325
|
+
def rate_limit_reset(response)
|
|
326
|
+
seconds = Float(response.headers["x-ratelimit-reset"])
|
|
327
|
+
(seconds.finite? && seconds >= 0) ? seconds.ceil : nil
|
|
328
|
+
rescue ArgumentError, TypeError
|
|
329
|
+
nil
|
|
330
|
+
end
|
|
331
|
+
|
|
254
332
|
# Extract the identifier from an error response
|
|
255
333
|
# This looks for common patterns in error messages to find resource identifiers
|
|
256
334
|
#
|
|
@@ -261,9 +339,7 @@ module PaystackSdk
|
|
|
261
339
|
|
|
262
340
|
# First try to get identifier from the message
|
|
263
341
|
message = body["message"].to_s.downcase
|
|
264
|
-
if message =~ /with (id|code|reference|email): ([^\s]+)/i
|
|
265
|
-
return $2
|
|
266
|
-
end
|
|
342
|
+
return ::Regexp.last_match(2) if message =~ /with (id|code|reference|email): ([^\s]+)/i
|
|
267
343
|
|
|
268
344
|
# If not found in message, try to extract from error code
|
|
269
345
|
if body["code"]&.match?(/^(transaction|customer)_/)
|
|
@@ -288,6 +364,7 @@ module PaystackSdk
|
|
|
288
364
|
# @return [Hash, Array, nil] The data from the response
|
|
289
365
|
def extract_data_from_body(body)
|
|
290
366
|
return body unless body.is_a?(Hash)
|
|
367
|
+
|
|
291
368
|
body["data"] || body
|
|
292
369
|
end
|
|
293
370
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../middleware/transport_errors"
|
|
4
|
+
|
|
3
5
|
module PaystackSdk
|
|
4
6
|
module Utils
|
|
5
7
|
# The `ConnectionUtils` module provides shared functionality for creating
|
|
@@ -9,32 +11,92 @@ module PaystackSdk
|
|
|
9
11
|
# The base URL for the Paystack API.
|
|
10
12
|
BASE_URL = "https://api.paystack.co"
|
|
11
13
|
|
|
14
|
+
# Seconds to wait for the connection to open.
|
|
15
|
+
DEFAULT_OPEN_TIMEOUT = 5
|
|
16
|
+
|
|
17
|
+
# Seconds to wait for a response.
|
|
18
|
+
DEFAULT_TIMEOUT = 30
|
|
19
|
+
|
|
20
|
+
# Retries performed after the initial attempt.
|
|
21
|
+
DEFAULT_MAX_RETRIES = 2
|
|
22
|
+
|
|
23
|
+
# Base delay in seconds before the first retry (doubles each attempt).
|
|
24
|
+
DEFAULT_RETRY_INTERVAL = 0.5
|
|
25
|
+
|
|
26
|
+
# HTTP methods that only read data and are always safe to repeat.
|
|
27
|
+
SAFE_METHODS = %i[get head options].freeze
|
|
28
|
+
|
|
29
|
+
# Statuses worth retrying. 500 is excluded because it usually means a
|
|
30
|
+
# bug, not a transient fault.
|
|
31
|
+
RETRY_STATUSES = [429, 502, 503, 504].freeze
|
|
32
|
+
|
|
33
|
+
# Longest pause (seconds) between retries. If Paystack asks for a longer
|
|
34
|
+
# wait via `x-ratelimit-reset`, the request is not retried and the
|
|
35
|
+
# error is raised so the caller can decide.
|
|
36
|
+
MAX_RETRY_WAIT = 10
|
|
37
|
+
|
|
38
|
+
# Response header Paystack uses to say when the rate-limit window ends.
|
|
39
|
+
# @see https://paystack.com/docs/api/rate-limits/
|
|
40
|
+
RATE_LIMIT_RESET_HEADER = "x-ratelimit-reset"
|
|
41
|
+
|
|
12
42
|
# Initializes a connection based on the provided parameters.
|
|
13
43
|
#
|
|
14
44
|
# @param connection [Faraday::Connection, nil] An existing connection object.
|
|
15
45
|
# @param secret_key [String, nil] Optional API key to use for creating a new connection.
|
|
46
|
+
# @param options [Hash] Connection options, see {#create_connection}.
|
|
16
47
|
# @return [Faraday::Connection] A connection object for API requests.
|
|
17
48
|
# @raise [PaystackSdk::Error] If no connection or API key can be found.
|
|
18
|
-
def initialize_connection(connection = nil, secret_key: nil)
|
|
49
|
+
def initialize_connection(connection = nil, secret_key: nil, **options)
|
|
19
50
|
if connection
|
|
51
|
+
unless options.empty?
|
|
52
|
+
raise ArgumentError,
|
|
53
|
+
"#{options.keys.join(", ")} cannot be used with a pre-built connection; configure the connection itself"
|
|
54
|
+
end
|
|
55
|
+
|
|
20
56
|
connection
|
|
21
57
|
elsif secret_key
|
|
22
|
-
create_connection(secret_key
|
|
58
|
+
create_connection(secret_key:, **options)
|
|
23
59
|
else
|
|
24
60
|
# Try to get API key from environment variable
|
|
25
61
|
env_secret_key = ENV["PAYSTACK_SECRET_KEY"]
|
|
26
62
|
raise AuthenticationError, "No connection or API key provided" unless env_secret_key
|
|
27
63
|
|
|
28
|
-
create_connection(secret_key: env_secret_key)
|
|
64
|
+
create_connection(secret_key: env_secret_key, **options)
|
|
29
65
|
end
|
|
30
66
|
end
|
|
31
67
|
|
|
32
68
|
# Creates a new Faraday connection with the Paystack API.
|
|
33
69
|
#
|
|
70
|
+
# Requests time out and are retried with exponential backoff on network
|
|
71
|
+
# failures and on 429/502/503/504 responses. Because this SDK moves
|
|
72
|
+
# money, retries are deliberately conservative:
|
|
73
|
+
#
|
|
74
|
+
# * Read-only requests (GET/HEAD/OPTIONS) are retried on any of the above.
|
|
75
|
+
# * Every other request is retried only on 429, where Paystack has
|
|
76
|
+
# rejected it for exceeding the rate limit. A timeout or 5xx on a
|
|
77
|
+
# write could mean it was processed, so it is never retried unless you
|
|
78
|
+
# pass `retry_non_idempotent: true`.
|
|
79
|
+
#
|
|
80
|
+
# On a 429 the wait honours Paystack's `x-ratelimit-reset` header.
|
|
81
|
+
#
|
|
34
82
|
# @param secret_key [String] The secret API key for authenticating with the Paystack API.
|
|
83
|
+
# @param timeout [Numeric] Seconds to wait for a response.
|
|
84
|
+
# @param open_timeout [Numeric] Seconds to wait for the connection to open.
|
|
85
|
+
# @param max_retries [Integer] Retries after the first attempt (0 disables retrying).
|
|
86
|
+
# @param retry_interval [Numeric] Base delay in seconds before the first retry.
|
|
87
|
+
# @param retry_non_idempotent [Boolean] Also retry writes on network failures and 5xx.
|
|
88
|
+
# Only enable this if you supply your own deduplication (e.g. verify by reference).
|
|
35
89
|
# @return [Faraday::Connection] A configured Faraday connection.
|
|
36
|
-
def create_connection(secret_key:
|
|
37
|
-
|
|
90
|
+
def create_connection(secret_key:, timeout: DEFAULT_TIMEOUT, open_timeout: DEFAULT_OPEN_TIMEOUT,
|
|
91
|
+
max_retries: DEFAULT_MAX_RETRIES, retry_interval: DEFAULT_RETRY_INTERVAL,
|
|
92
|
+
retry_non_idempotent: false)
|
|
93
|
+
validate_connection_options!(timeout:, open_timeout:, max_retries:, retry_interval:)
|
|
94
|
+
|
|
95
|
+
Faraday.new(url: BASE_URL, request: {timeout:, open_timeout:}) do |conn|
|
|
96
|
+
conn.use Middleware::TransportErrors
|
|
97
|
+
if max_retries > 0
|
|
98
|
+
conn.request :retry, retry_options(max_retries, retry_interval, retry_non_idempotent)
|
|
99
|
+
end
|
|
38
100
|
conn.request :json
|
|
39
101
|
conn.response :json, content_type: /\bjson$/
|
|
40
102
|
conn.headers["Authorization"] = "Bearer #{secret_key}"
|
|
@@ -43,6 +105,39 @@ module PaystackSdk
|
|
|
43
105
|
conn.adapter Faraday.default_adapter
|
|
44
106
|
end
|
|
45
107
|
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def validate_connection_options!(timeout:, open_timeout:, max_retries:, retry_interval:)
|
|
112
|
+
{timeout:, open_timeout:}.each do |name, value|
|
|
113
|
+
raise ArgumentError, "#{name} must be a positive number" unless value.is_a?(Numeric) && value > 0
|
|
114
|
+
end
|
|
115
|
+
raise ArgumentError, "max_retries must be a non-negative Integer" unless max_retries.is_a?(Integer) && max_retries >= 0
|
|
116
|
+
raise ArgumentError, "retry_interval must be a non-negative number" unless retry_interval.is_a?(Numeric) && retry_interval >= 0
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def retry_options(max_retries, retry_interval, retry_non_idempotent)
|
|
120
|
+
{
|
|
121
|
+
max: max_retries,
|
|
122
|
+
interval: retry_interval,
|
|
123
|
+
interval_randomness: 0.5,
|
|
124
|
+
backoff_factor: 2,
|
|
125
|
+
max_interval: MAX_RETRY_WAIT,
|
|
126
|
+
rate_limit_reset_header: RATE_LIMIT_RESET_HEADER,
|
|
127
|
+
retry_statuses: RETRY_STATUSES,
|
|
128
|
+
methods: [],
|
|
129
|
+
exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [Faraday::ConnectionFailed],
|
|
130
|
+
retry_if: ->(env, exception) { retryable_request?(env, exception, retry_non_idempotent) }
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def retryable_request?(env, exception, retry_non_idempotent)
|
|
135
|
+
return true if SAFE_METHODS.include?(env.method)
|
|
136
|
+
return true if retry_non_idempotent
|
|
137
|
+
|
|
138
|
+
# Writes: only when Paystack rejected the request for rate limiting.
|
|
139
|
+
exception.is_a?(Faraday::RetriableResponse) && env.status == 429
|
|
140
|
+
end
|
|
46
141
|
end
|
|
47
142
|
end
|
|
48
143
|
end
|
|
@@ -37,9 +37,9 @@ module PaystackSdk
|
|
|
37
37
|
# @param name [String] Name of the parameter for error messages
|
|
38
38
|
# @raise [PaystackSdk::InvalidFormatError] If input is not a hash
|
|
39
39
|
def validate_hash!(input:, name: "Payload")
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
return if input.is_a?(Hash)
|
|
41
|
+
|
|
42
|
+
raise PaystackSdk::InvalidFormatError.new(name, "Hash")
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
# Validates that required parameters are present in a payload.
|
|
@@ -53,10 +53,10 @@ module PaystackSdk
|
|
|
53
53
|
!payload.key?(param) && !payload.key?(param.to_s)
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
56
|
+
return if missing_params.empty?
|
|
57
|
+
|
|
58
|
+
param = missing_params.first
|
|
59
|
+
raise PaystackSdk::MissingParamError.new(param)
|
|
60
60
|
end
|
|
61
61
|
|
|
62
62
|
# Validates that a value is present (not nil or empty).
|
|
@@ -91,9 +91,9 @@ module PaystackSdk
|
|
|
91
91
|
# @param name [String] Name of the parameter for error messages
|
|
92
92
|
# @raise [PaystackSdk::InvalidFormatError] If reference format is invalid
|
|
93
93
|
def validate_reference_format!(reference:, name: "Reference")
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
return if reference.to_s.match?(/^[a-zA-Z0-9._=-]+$/)
|
|
95
|
+
|
|
96
|
+
raise PaystackSdk::InvalidFormatError.new(name, "alphanumeric characters and the following: -, ., =")
|
|
97
97
|
end
|
|
98
98
|
|
|
99
99
|
# Validates a date string format.
|
|
@@ -106,6 +106,7 @@ module PaystackSdk
|
|
|
106
106
|
def validate_date_format!(date_str:, name: "Date", allow_nil: true)
|
|
107
107
|
if date_str.nil?
|
|
108
108
|
raise PaystackSdk::MissingParamError.new(name) unless allow_nil
|
|
109
|
+
|
|
109
110
|
return
|
|
110
111
|
end
|
|
111
112
|
|
|
@@ -136,13 +137,14 @@ module PaystackSdk
|
|
|
136
137
|
def validate_allowed_values!(value:, allowed_values:, name: "Parameter", allow_nil: true)
|
|
137
138
|
if value.nil?
|
|
138
139
|
raise PaystackSdk::MissingParamError.new(name) unless allow_nil
|
|
140
|
+
|
|
139
141
|
return
|
|
140
142
|
end
|
|
141
143
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
144
|
+
return if allowed_values.include?(value)
|
|
145
|
+
|
|
146
|
+
allowed_list = allowed_values.join(", ")
|
|
147
|
+
raise PaystackSdk::InvalidValueError.new(name, "must be one of: #{allowed_list}")
|
|
146
148
|
end
|
|
147
149
|
|
|
148
150
|
# Validates an email format.
|
|
@@ -154,12 +156,13 @@ module PaystackSdk
|
|
|
154
156
|
def validate_email!(email:, name: "Email", allow_nil: false)
|
|
155
157
|
if email.nil?
|
|
156
158
|
raise PaystackSdk::MissingParamError.new(name) unless allow_nil
|
|
159
|
+
|
|
157
160
|
return
|
|
158
161
|
end
|
|
159
162
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
+
return if email.to_s.match?(/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/)
|
|
164
|
+
|
|
165
|
+
raise PaystackSdk::InvalidFormatError.new(name, "valid email address")
|
|
163
166
|
end
|
|
164
167
|
|
|
165
168
|
# Validates a currency code format.
|
|
@@ -171,12 +174,13 @@ module PaystackSdk
|
|
|
171
174
|
def validate_currency!(currency:, name: "Currency", allow_nil: true)
|
|
172
175
|
if currency.nil?
|
|
173
176
|
raise PaystackSdk::MissingParamError.new(name) unless allow_nil
|
|
177
|
+
|
|
174
178
|
return
|
|
175
179
|
end
|
|
176
180
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
181
|
+
return if currency.to_s.match?(/\A[A-Z]{3}\z/)
|
|
182
|
+
|
|
183
|
+
raise PaystackSdk::InvalidFormatError.new(name, "3-letter ISO code (e.g., NGN, USD, GHS)")
|
|
180
184
|
end
|
|
181
185
|
|
|
182
186
|
# Validates multiple fields at once.
|
data/lib/paystack_sdk/version.rb
CHANGED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require_relative "response"
|
|
6
|
+
|
|
7
|
+
module PaystackSdk
|
|
8
|
+
# Helpers for receiving Paystack webhooks, independent of any web framework.
|
|
9
|
+
#
|
|
10
|
+
# Paystack signs every event with the `x-paystack-signature` header: a
|
|
11
|
+
# lowercase hex HMAC SHA512 of the raw request body, using your secret key.
|
|
12
|
+
# Verify it before doing anything with the event, and return a `200 OK`
|
|
13
|
+
# quickly (do long work in a background job): events that are not
|
|
14
|
+
# acknowledged are retried for 72 hours in live mode.
|
|
15
|
+
#
|
|
16
|
+
# @see https://paystack.com/docs/payments/webhooks/
|
|
17
|
+
#
|
|
18
|
+
# @example In a Rails controller
|
|
19
|
+
# ```ruby
|
|
20
|
+
# def create
|
|
21
|
+
# event = PaystackSdk::Webhook.construct_event(
|
|
22
|
+
# payload: request.raw_post,
|
|
23
|
+
# signature: request.headers["X-Paystack-Signature"],
|
|
24
|
+
# secret: ENV.fetch("PAYSTACK_SECRET_KEY")
|
|
25
|
+
# )
|
|
26
|
+
# HandlePaystackEventJob.perform_later(event.payload)
|
|
27
|
+
# head :ok
|
|
28
|
+
# rescue PaystackSdk::WebhookError
|
|
29
|
+
# head :bad_request
|
|
30
|
+
# end
|
|
31
|
+
# ```
|
|
32
|
+
module Webhook
|
|
33
|
+
# Header Paystack puts the signature in. Rack and Rails expose it as
|
|
34
|
+
# `HTTP_X_PAYSTACK_SIGNATURE` or `request.headers["X-Paystack-Signature"]`.
|
|
35
|
+
SIGNATURE_HEADER = "x-paystack-signature"
|
|
36
|
+
|
|
37
|
+
# The only addresses Paystack sends webhooks from, for both test and live.
|
|
38
|
+
IP_ADDRESSES = %w[52.31.139.75 52.49.173.169 52.214.14.220].freeze
|
|
39
|
+
|
|
40
|
+
# Events Paystack documents. Paystack adds events over time, so an event
|
|
41
|
+
# outside this list is still returned by {Webhook.construct_event}.
|
|
42
|
+
EVENTS = %w[
|
|
43
|
+
charge.dispute.create charge.dispute.remind charge.dispute.resolve charge.success
|
|
44
|
+
customeridentification.failed customeridentification.success
|
|
45
|
+
dedicatedaccount.assign.failed dedicatedaccount.assign.success
|
|
46
|
+
invoice.create invoice.payment_failed invoice.update
|
|
47
|
+
paymentrequest.pending paymentrequest.success
|
|
48
|
+
refund.failed refund.pending refund.processed refund.processing
|
|
49
|
+
subscription.create subscription.disable subscription.expiring_cards subscription.not_renew
|
|
50
|
+
transfer.failed transfer.reversed transfer.success
|
|
51
|
+
].freeze
|
|
52
|
+
|
|
53
|
+
# A verified webhook event.
|
|
54
|
+
class Event
|
|
55
|
+
# @return [String] The event name, e.g. "charge.success"
|
|
56
|
+
attr_reader :event
|
|
57
|
+
|
|
58
|
+
# @return [Hash] The parsed JSON body, string-keyed
|
|
59
|
+
attr_reader :payload
|
|
60
|
+
|
|
61
|
+
def initialize(payload)
|
|
62
|
+
@payload = payload
|
|
63
|
+
@event = payload["event"]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [PaystackSdk::Response, nil] The event's `data`, wrapped for dot and hash access
|
|
67
|
+
def data
|
|
68
|
+
return @data if defined?(@data)
|
|
69
|
+
|
|
70
|
+
@data = payload.key?("data") ? Response.new(payload["data"]) : nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [Boolean] Whether Paystack documents this event name
|
|
74
|
+
def known?
|
|
75
|
+
EVENTS.include?(event)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
class << self
|
|
80
|
+
# Computes the signature Paystack would send for a payload.
|
|
81
|
+
# Mostly useful in your own tests.
|
|
82
|
+
#
|
|
83
|
+
# @param payload [String] The raw request body
|
|
84
|
+
# @param secret [String] Your secret key
|
|
85
|
+
# @return [String] Lowercase hex HMAC SHA512
|
|
86
|
+
def sign(payload, secret)
|
|
87
|
+
OpenSSL::HMAC.hexdigest("SHA512", secret, payload)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Checks a webhook's signature in constant time.
|
|
91
|
+
#
|
|
92
|
+
# @param payload [String] The raw request body, byte for byte. Not a parsed
|
|
93
|
+
# or re-serialised version of it, which would not match.
|
|
94
|
+
# @param signature [String, nil] The `x-paystack-signature` header value
|
|
95
|
+
# @param secret [String] Your secret key
|
|
96
|
+
# @return [Boolean]
|
|
97
|
+
# @raise [ArgumentError] If the payload is not a String or the secret is blank
|
|
98
|
+
def valid_signature?(payload:, signature:, secret:)
|
|
99
|
+
unless payload.is_a?(String)
|
|
100
|
+
raise ArgumentError, "payload must be the raw request body (a String), not a parsed or re-serialised copy"
|
|
101
|
+
end
|
|
102
|
+
raise ArgumentError, "secret must not be blank" if secret.nil? || secret.to_s.empty?
|
|
103
|
+
|
|
104
|
+
expected = sign(payload, secret)
|
|
105
|
+
return false unless signature.is_a?(String) && signature.bytesize == expected.bytesize
|
|
106
|
+
|
|
107
|
+
OpenSSL.fixed_length_secure_compare(expected, signature)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Like {valid_signature?} but raises when the signature is wrong.
|
|
111
|
+
#
|
|
112
|
+
# @return [true]
|
|
113
|
+
# @raise [PaystackSdk::InvalidSignatureError]
|
|
114
|
+
def verify!(payload:, signature:, secret:)
|
|
115
|
+
raise InvalidSignatureError unless valid_signature?(payload: payload, signature: signature, secret: secret)
|
|
116
|
+
|
|
117
|
+
true
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Verifies the signature, then parses the event. Nothing is parsed if the
|
|
121
|
+
# signature is wrong.
|
|
122
|
+
#
|
|
123
|
+
# @return [PaystackSdk::Webhook::Event]
|
|
124
|
+
# @raise [PaystackSdk::InvalidSignatureError] If the signature does not match
|
|
125
|
+
# @raise [PaystackSdk::InvalidPayloadError] If the signed body is not a JSON event
|
|
126
|
+
def construct_event(payload:, signature:, secret:)
|
|
127
|
+
verify!(payload: payload, signature: signature, secret: secret)
|
|
128
|
+
|
|
129
|
+
parsed = begin
|
|
130
|
+
JSON.parse(payload)
|
|
131
|
+
rescue JSON::ParserError
|
|
132
|
+
raise InvalidPayloadError, "Webhook body is not valid JSON"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
unless parsed.is_a?(Hash) && parsed["event"].is_a?(String) && !parsed["event"].empty?
|
|
136
|
+
raise InvalidPayloadError, "Webhook body is not an event (missing \"event\")"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
Event.new(parsed)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Whether an address is one Paystack documents for webhooks. A second
|
|
143
|
+
# check alongside the signature, not a replacement for it.
|
|
144
|
+
#
|
|
145
|
+
# @param ip [String, nil]
|
|
146
|
+
# @return [Boolean]
|
|
147
|
+
def trusted_ip?(ip)
|
|
148
|
+
IP_ADDRESSES.include?(ip)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
data/lib/paystack_sdk.rb
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "faraday"
|
|
4
|
+
require "faraday/retry"
|
|
4
5
|
require_relative "paystack_sdk/version"
|
|
5
6
|
require_relative "paystack_sdk/client"
|
|
7
|
+
require_relative "paystack_sdk/webhook"
|
|
6
8
|
|
|
7
9
|
module PaystackSdk
|
|
8
10
|
# Base error class for all Paystack SDK errors.
|
|
@@ -71,14 +73,23 @@ module PaystackSdk
|
|
|
71
73
|
|
|
72
74
|
# Raised when rate limiting is encountered
|
|
73
75
|
class RateLimitError < APIError
|
|
76
|
+
# @return [Integer, nil] Seconds until the rate-limit window ends, from the
|
|
77
|
+
# `x-ratelimit-reset` header, or nil if Paystack did not send it
|
|
74
78
|
attr_reader :retry_after
|
|
75
79
|
|
|
76
|
-
def initialize(retry_after)
|
|
80
|
+
def initialize(retry_after = nil)
|
|
77
81
|
@retry_after = retry_after
|
|
78
|
-
super("Rate limit exceeded. Retry after #{retry_after} seconds")
|
|
82
|
+
super(retry_after ? "Rate limit exceeded. Retry after #{retry_after} seconds" : "Rate limit exceeded")
|
|
79
83
|
end
|
|
80
84
|
end
|
|
81
85
|
|
|
86
|
+
# Raised when a request could not reach Paystack (DNS, refused, reset, ...)
|
|
87
|
+
# after all retries were exhausted.
|
|
88
|
+
class ConnectionError < Error; end
|
|
89
|
+
|
|
90
|
+
# Raised when a request to Paystack timed out after all retries were exhausted.
|
|
91
|
+
class TimeoutError < ConnectionError; end
|
|
92
|
+
|
|
82
93
|
# Raised when the server returns a 5xx error
|
|
83
94
|
class ServerError < APIError
|
|
84
95
|
attr_reader :status_code
|
|
@@ -88,4 +99,18 @@ module PaystackSdk
|
|
|
88
99
|
super("#{message} (Status: #{status_code})")
|
|
89
100
|
end
|
|
90
101
|
end
|
|
102
|
+
|
|
103
|
+
# Base class for webhook errors.
|
|
104
|
+
class WebhookError < Error; end
|
|
105
|
+
|
|
106
|
+
# Raised when a webhook's signature does not match its payload.
|
|
107
|
+
# Treat the request as not coming from Paystack.
|
|
108
|
+
class InvalidSignatureError < WebhookError
|
|
109
|
+
def initialize(message = "Webhook signature does not match the payload")
|
|
110
|
+
super
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Raised when a correctly signed webhook body is not a JSON event.
|
|
115
|
+
class InvalidPayloadError < WebhookError; end
|
|
91
116
|
end
|