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.
@@ -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
- value = @raw_data[key.is_a?(String) ? key.to_sym : key]
195
- wrap_value(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) && @raw_data.key?(key.is_a?(String) ? key.to_sym : key)
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(&block)
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
- [:size, :length, :count, :empty?].each do |method_name|
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
- [:first, :last].each do |method_name|
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
- Faraday.new(url: BASE_URL) do |conn|
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
- unless input.is_a?(Hash)
41
- raise PaystackSdk::InvalidFormatError.new(name, "Hash")
42
- end
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
- unless missing_params.empty?
57
- param = missing_params.first
58
- raise PaystackSdk::MissingParamError.new(param)
59
- end
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
- unless reference.to_s.match?(/^[a-zA-Z0-9._=-]+$/)
95
- raise PaystackSdk::InvalidFormatError.new(name, "alphanumeric characters and the following: -, ., =")
96
- end
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
- unless allowed_values.include?(value)
143
- allowed_list = allowed_values.join(", ")
144
- raise PaystackSdk::InvalidValueError.new(name, "must be one of: #{allowed_list}")
145
- end
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
- unless email.to_s.match?(/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/)
161
- raise PaystackSdk::InvalidFormatError.new(name, "valid email address")
162
- end
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
- unless currency.to_s.match?(/\A[A-Z]{3}\z/)
178
- raise PaystackSdk::InvalidFormatError.new(name, "3-letter ISO code (e.g., NGN, USD, GHS)")
179
- end
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.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PaystackSdk
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -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