paystack_sdk 0.1.1 → 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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PaystackSdk
4
- VERSION = "0.1.1"
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: paystack_sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Maxwell Nana Forson (theLazyProgrammer)
@@ -11,18 +11,38 @@ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: faraday
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '2.13'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '3'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '2.13'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '3'
32
+ - !ruby/object:Gem::Dependency
33
+ name: faraday-retry
14
34
  requirement: !ruby/object:Gem::Requirement
15
35
  requirements:
16
36
  - - "~>"
17
37
  - !ruby/object:Gem::Version
18
- version: 2.13.1
38
+ version: '2.3'
19
39
  type: :runtime
20
40
  prerelease: false
21
41
  version_requirements: !ruby/object:Gem::Requirement
22
42
  requirements:
23
43
  - - "~>"
24
44
  - !ruby/object:Gem::Version
25
- version: 2.13.1
45
+ version: '2.3'
26
46
  - !ruby/object:Gem::Dependency
27
47
  name: debug
28
48
  requirement: !ruby/object:Gem::Requirement
@@ -79,6 +99,34 @@ dependencies:
79
99
  - - "~>"
80
100
  - !ruby/object:Gem::Version
81
101
  version: '3.13'
102
+ - !ruby/object:Gem::Dependency
103
+ name: json_schemer
104
+ requirement: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - "~>"
107
+ - !ruby/object:Gem::Version
108
+ version: '2.5'
109
+ type: :development
110
+ prerelease: false
111
+ version_requirements: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - "~>"
114
+ - !ruby/object:Gem::Version
115
+ version: '2.5'
116
+ - !ruby/object:Gem::Dependency
117
+ name: webmock
118
+ requirement: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - "~>"
121
+ - !ruby/object:Gem::Version
122
+ version: '3.25'
123
+ type: :development
124
+ prerelease: false
125
+ version_requirements: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - "~>"
128
+ - !ruby/object:Gem::Version
129
+ version: '3.25'
82
130
  - !ruby/object:Gem::Dependency
83
131
  name: standard
84
132
  requirement: !ruby/object:Gem::Requirement
@@ -115,18 +163,23 @@ files:
115
163
  - Rakefile
116
164
  - lib/paystack_sdk.rb
117
165
  - lib/paystack_sdk/client.rb
166
+ - lib/paystack_sdk/middleware/transport_errors.rb
167
+ - lib/paystack_sdk/request_helpers.rb
118
168
  - lib/paystack_sdk/resources/banks.rb
119
169
  - lib/paystack_sdk/resources/base.rb
120
170
  - lib/paystack_sdk/resources/charges.rb
121
171
  - lib/paystack_sdk/resources/customers.rb
172
+ - lib/paystack_sdk/resources/extensions/charges.rb
173
+ - lib/paystack_sdk/resources/miscellaneous.rb
174
+ - lib/paystack_sdk/resources/refunds.rb
122
175
  - lib/paystack_sdk/resources/transactions.rb
123
176
  - lib/paystack_sdk/resources/transfer_recipients.rb
124
177
  - lib/paystack_sdk/resources/transfers.rb
125
- - lib/paystack_sdk/resources/verification.rb
126
178
  - lib/paystack_sdk/response.rb
127
179
  - lib/paystack_sdk/utils/connection_utils.rb
128
180
  - lib/paystack_sdk/validations.rb
129
181
  - lib/paystack_sdk/version.rb
182
+ - lib/paystack_sdk/webhook.rb
130
183
  - mise.toml
131
184
  - sig/paystack_sdk.rbs
132
185
  homepage: https://github.com/nanafox/paystack_sdk
@@ -1,36 +0,0 @@
1
- require_relative "../validations"
2
- require_relative "base"
3
-
4
- module PaystackSdk
5
- module Resources
6
- class Verification < Base
7
- # Resolve Bank Account
8
- # @see https://paystack.com/docs/api/verification/#resolve-bank-account
9
- def resolve_account(account_number:, bank_code:)
10
- validate_presence!(value: account_number, name: "account_number")
11
- validate_presence!(value: bank_code, name: "bank_code")
12
- handle_response(@connection.get("/bank/resolve", {account_number: account_number, bank_code: bank_code}))
13
- end
14
-
15
- # Resolve Card BIN
16
- # @see https://paystack.com/docs/api/verification/#resolve-card-bin
17
- def resolve_card_bin(bin)
18
- validate_presence!(value: bin, name: "bin")
19
- handle_response(@connection.get("/decision/bin/#{bin}"))
20
- end
21
-
22
- # Validate Account
23
- # @see https://paystack.com/docs/api/verification/#validate-account
24
- # Required: account_number, account_name, account_type, bank_code, country_code, document_type
25
- # Optional: document_number
26
- def validate_account(params)
27
- validate_required_params!(
28
- payload: params,
29
- required_params: %i[account_number account_name account_type bank_code country_code document_type],
30
- operation_name: "Validate Account"
31
- )
32
- handle_response(@connection.post("/bank/validate", params))
33
- end
34
- end
35
- end
36
- end