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.
@@ -5,8 +5,9 @@ require_relative "resources/customers"
5
5
  require_relative "resources/transfer_recipients"
6
6
  require_relative "resources/transfers"
7
7
  require_relative "resources/banks"
8
- require_relative "resources/verification"
9
8
  require_relative "resources/charges"
9
+ require_relative "resources/miscellaneous"
10
+ require_relative "resources/refunds"
10
11
  require_relative "utils/connection_utils"
11
12
 
12
13
  module PaystackSdk
@@ -16,6 +17,10 @@ module PaystackSdk
16
17
  # Include connection utilities
17
18
  include Utils::ConnectionUtils
18
19
 
20
+ # Prefix of Paystack's test and live secret keys.
21
+ TEST_KEY_PREFIX = "sk_test_"
22
+ LIVE_KEY_PREFIX = "sk_live_"
23
+
19
24
  # @return [Faraday::Connection] The Faraday connection object used for API requests
20
25
  attr_reader :connection
21
26
 
@@ -25,6 +30,9 @@ module PaystackSdk
25
30
  # If nil, a new connection will be created using the default API key.
26
31
  # @param secret_key [String, nil] Optional API key to use for creating a new connection.
27
32
  # Only used if connection is nil.
33
+ # @param sandbox_only [Boolean] Refuse to build a client unless the key is a test key
34
+ # (`sk_test_...`). Set this in staging and CI so they can never charge real money.
35
+ # @raise [ArgumentError] If `sandbox_only` is true and the key is not a test key.
28
36
  #
29
37
  # @example With an existing connection
30
38
  # connection = Faraday.new(...)
@@ -35,8 +43,25 @@ module PaystackSdk
35
43
  #
36
44
  # @example With default connection (requires PAYSTACK_SECRET_KEY environment variable)
37
45
  # client = PaystackSdk::Client.new
38
- def initialize(connection = nil, secret_key: nil)
39
- @connection = initialize_connection(connection, secret_key: secret_key)
46
+ # @example Refuse live keys (staging, CI)
47
+ # client = PaystackSdk::Client.new(secret_key: ENV["PAYSTACK_SECRET_KEY"], sandbox_only: true)
48
+ def initialize(connection = nil, secret_key: nil, sandbox_only: false, **options)
49
+ if sandbox_only
50
+ key = connection ? key_from(connection) : (secret_key || ENV["PAYSTACK_SECRET_KEY"])
51
+ unless key.to_s.start_with?(TEST_KEY_PREFIX)
52
+ raise ArgumentError, "sandbox_only is set, but the secret key is not a test key (#{TEST_KEY_PREFIX}...)"
53
+ end
54
+ end
55
+
56
+ @connection = initialize_connection(connection, secret_key: secret_key, **options)
57
+ end
58
+
59
+ # Whether this client is using a live key (`sk_live_...`), so requests move real money.
60
+ # A test key, or a key in any other form, is not live.
61
+ #
62
+ # @return [Boolean]
63
+ def live?
64
+ key_from(@connection).to_s.start_with?(LIVE_KEY_PREFIX)
40
65
  end
41
66
 
42
67
  # Provides access to the `Transactions` resource.
@@ -47,7 +72,7 @@ module PaystackSdk
47
72
  # @example
48
73
  # ```ruby
49
74
  # transactions = client.transactions
50
- # response = transactions.initiate(params)
75
+ # response = transactions.initiate(email: "ama@example.com", amount: 10000)
51
76
  # ```
52
77
  def transactions
53
78
  @transactions ||= Resources::Transactions.new(@connection)
@@ -75,7 +100,7 @@ module PaystackSdk
75
100
  # @example
76
101
  # ```ruby
77
102
  # recipients = client.transfer_recipients
78
- # response = recipients.create(params)
103
+ # response = recipients.create(type: "nuban", name: "Ama Mensah", account_number: "0123456789", bank_code: "058")
79
104
  # ```
80
105
  def transfer_recipients
81
106
  @transfer_recipients ||= Resources::TransferRecipients.new(@connection)
@@ -89,7 +114,7 @@ module PaystackSdk
89
114
  # @example
90
115
  # ```ruby
91
116
  # transfers = client.transfers
92
- # response = transfers.create(params)
117
+ # response = transfers.create(source: "balance", amount: 100_000, recipient: "RCP_xxx", reference: "payout-2025-0001-ama")
93
118
  # ```
94
119
  def transfers
95
120
  @transfers ||= Resources::Transfers.new(@connection)
@@ -103,38 +128,56 @@ module PaystackSdk
103
128
  # @example
104
129
  # ```ruby
105
130
  # banks = client.banks
106
- # response = banks.list
131
+ # response = banks.list(country: "nigeria")
107
132
  # ```
108
133
  def banks
109
134
  @banks ||= Resources::Banks.new(@connection)
110
135
  end
111
136
 
112
- # Provides access to the `Verification` resource.
137
+ # Provides access to the `Charges` resource.
113
138
  #
114
- # @return [PaystackSdk::Resources::Verification] An instance of the
115
- # `Verification` resource.
139
+ # @return [PaystackSdk::Resources::Charges] An instance of the
140
+ # `Charges` resource.
116
141
  #
117
142
  # @example
118
143
  # ```ruby
119
- # verification = client.verification
120
- # response = verification.resolve_account(account_number: ..., bank_code: ...)
144
+ # charges = client.charges
145
+ # response = charges.mobile_money(
146
+ # email: "ama@example.com",
147
+ # amount: 10000,
148
+ # mobile_money: {phone: "0551234987", provider: "mtn"}
149
+ # )
121
150
  # ```
122
- def verification
123
- @verification ||= Resources::Verification.new(@connection)
151
+ def charges
152
+ @charges ||= Resources::Charges.new(@connection)
124
153
  end
125
154
 
126
- # Provides access to the `Charges` resource.
155
+ # Provides access to the `Miscellaneous` resource.
127
156
  #
128
- # @return [PaystackSdk::Resources::Charges] An instance of the
129
- # `Charges` resource.
157
+ # @return [PaystackSdk::Resources::Miscellaneous] An instance of the
158
+ # `Miscellaneous` resource.
130
159
  #
131
160
  # @example
132
161
  # ```ruby
133
- # charges = client.charges
134
- # response = charges.mobile_money(payload)
162
+ # response = client.miscellaneous.resolve_card_bin(bin: "539983")
135
163
  # ```
136
- def charges
137
- @charges ||= Resources::Charges.new(@connection)
164
+ def miscellaneous
165
+ @miscellaneous ||= Resources::Miscellaneous.new(@connection)
166
+ end
167
+
168
+ # Provides access to the `Refunds` resource.
169
+ #
170
+ # @return [PaystackSdk::Resources::Refunds] An instance of the
171
+ # `Refunds` resource.
172
+ def refunds
173
+ @refunds ||= Resources::Refunds.new(@connection)
174
+ end
175
+
176
+ private
177
+
178
+ # The secret key a connection sends, read from its Authorization header.
179
+ def key_from(connection)
180
+ connection.headers["Authorization"].to_s.delete_prefix("Bearer ")
138
181
  end
139
182
  end
140
183
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaystackSdk
4
+ module Middleware
5
+ # Translates low-level Faraday transport failures into SDK errors so that
6
+ # `rescue PaystackSdk::Error` catches them. It sits outside the retry
7
+ # middleware, so it only sees failures that survived all retries.
8
+ class TransportErrors < Faraday::Middleware
9
+ def call(env)
10
+ @app.call(env)
11
+ rescue Faraday::TimeoutError => e
12
+ raise PaystackSdk::TimeoutError, "Request to Paystack timed out: #{e.message}"
13
+ rescue Faraday::ConnectionFailed, Faraday::SSLError => e
14
+ # Connect-phase timeouts (Net::OpenTimeout) surface as ConnectionFailed.
15
+ if e.cause.is_a?(::Timeout::Error)
16
+ raise PaystackSdk::TimeoutError, "Connecting to Paystack timed out: #{e.message}"
17
+ end
18
+
19
+ raise PaystackSdk::ConnectionError, "Could not connect to Paystack: #{e.message}"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "erb"
5
+ require "json"
6
+
7
+ module PaystackSdk
8
+ # Small helpers resource classes use to turn idiomatic Ruby arguments into exactly what Paystack
9
+ # documents on the wire. They are private to the classes that include them.
10
+ #
11
+ # Ruby code uses snake_case keywords (`per_page:`, `terminal_id:`); the request carries Paystack's
12
+ # names (`perPage`, `terminalid`). Each method names its mapping in one place, with {#to_wire}.
13
+ module RequestHelpers
14
+ private
15
+
16
+ # Escapes a value for use as one segment of a URL path, so a reference or code containing `/`,
17
+ # `?`, `#` or a space cannot change which endpoint is called.
18
+ #
19
+ # `.` and `..` are refused: the URL builder resolves them as relative path steps, so
20
+ # `/customer/..` would call `/`. No Paystack identifier is ever one of them. An empty value is
21
+ # refused too, since it would collapse the segment out of the path.
22
+ #
23
+ # @param segment [#to_s]
24
+ # @param name [String] parameter name, for the error message
25
+ # @return [String]
26
+ # @raise [PaystackSdk::InvalidValueError] If the segment is empty, `.` or `..`
27
+ def escape_path(segment, name: "path segment")
28
+ value = segment.to_s
29
+ raise InvalidValueError.new(name, "must not be empty") if value.empty?
30
+ raise InvalidValueError.new(name, %(must not be "#{value}")) if %w[. ..].include?(value)
31
+
32
+ ERB::Util.url_encode(value)
33
+ end
34
+
35
+ # Drops nil values, keeping `false`, `0` and empty strings, which are real values.
36
+ #
37
+ # @param params [Hash]
38
+ # @return [Hash]
39
+ def compact_params(params)
40
+ params.compact
41
+ end
42
+
43
+ # Renames Ruby-style keys to the parameter names Paystack documents, drops nils and returns
44
+ # string keys. Keys without a mapping are sent as given.
45
+ #
46
+ # @param params [Hash] e.g. `{per_page: 20, status: "success"}`
47
+ # @param mapping [Hash{Symbol => String}] e.g. `{per_page: "perPage"}`
48
+ # @return [Hash{String => Object}]
49
+ def to_wire(params, mapping)
50
+ compact_params(params).each_with_object({}) do |(key, value), wire|
51
+ wire[(mapping[key] || key).to_s] = value
52
+ end
53
+ end
54
+
55
+ # Formats a Date, Time or ISO 8601 String for a Paystack date-time parameter.
56
+ #
57
+ # @param value [Date, Time, String, nil]
58
+ # @param name [String] parameter name, for the error message
59
+ # @return [String, nil]
60
+ # @raise [PaystackSdk::InvalidFormatError] If a String is not ISO 8601
61
+ def format_datetime(value, name: "date")
62
+ case value
63
+ when nil then nil
64
+ when Time, DateTime then value.getutc.strftime("%Y-%m-%dT%H:%M:%SZ")
65
+ when Date then value.iso8601
66
+ when String
67
+ iso8601?(value) ? value : raise(InvalidFormatError.new(name, "ISO 8601 date or date-time (e.g. 2026-01-31 or 2026-01-31T09:00:00Z)"))
68
+ else raise InvalidFormatError.new(name, "Date, Time or ISO 8601 String")
69
+ end
70
+ end
71
+
72
+ # Formats a Date, Time or YYYY-MM-DD String for a Paystack date parameter.
73
+ #
74
+ # @param value [Date, Time, String, nil]
75
+ # @param name [String] parameter name, for the error message
76
+ # @return [String, nil]
77
+ # @raise [PaystackSdk::InvalidFormatError] If a String is not a real YYYY-MM-DD date
78
+ def format_date(value, name: "date")
79
+ case value
80
+ when nil then nil
81
+ when Time, Date then value.strftime("%Y-%m-%d")
82
+ when String
83
+ valid = value.match?(/\A\d{4}-\d{2}-\d{2}\z/) && begin
84
+ Date.strptime(value, "%Y-%m-%d")
85
+ rescue
86
+ nil
87
+ end
88
+ valid ? value : raise(InvalidFormatError.new(name, "YYYY-MM-DD"))
89
+ else raise InvalidFormatError.new(name, "Date, Time or YYYY-MM-DD String")
90
+ end
91
+ end
92
+
93
+ # For the few fields Paystack documents as "stringified JSON" (for example customer metadata):
94
+ # accepts a Hash or Array and sends the JSON string. Strings pass through unchanged.
95
+ #
96
+ # @param value [Hash, Array, String, nil]
97
+ # @return [String, nil]
98
+ def stringify_json(value)
99
+ case value
100
+ when nil, String then value
101
+ else JSON.generate(value)
102
+ end
103
+ end
104
+
105
+ def iso8601?(value)
106
+ Time.iso8601(value)
107
+ true
108
+ rescue ArgumentError
109
+ begin
110
+ Date.iso8601(value)
111
+ true
112
+ rescue ArgumentError
113
+ false
114
+ end
115
+ end
116
+ end
117
+ end
@@ -1,20 +1,170 @@
1
- require_relative "../validations"
1
+ # frozen_string_literal: true
2
+
3
+ # Generated by bin/paystack-scaffold from Paystack's OpenAPI spec (PaystackOSS/openapi@d9d444d).
4
+ # Do not edit by hand: regenerate it with `bin/paystack-scaffold Bank`. Hand-written extras go in lib/paystack_sdk/resources/extensions/banks.rb.
5
+ # scaffold-digest: 530fc74fc57bba755180a37c395576bd9fc13c76500eae0fce6da7b1a6740ff9
6
+
7
+ require_relative "base"
2
8
 
3
9
  module PaystackSdk
4
10
  module Resources
5
- class Banks < Base
6
- # List banks
11
+ # Bank operations.
12
+ class Banks < PaystackSdk::Resources::Base
13
+ # Ruby keyword => the parameter name Paystack documents.
14
+ WIRE_NAMES = {per_page: "perPage", next_cursor: "next"}.freeze
15
+
16
+ # List Banks.
17
+ #
18
+ # List banks supported on Paystack
19
+ #
20
+ # @param country [String] The country from which to obtain the list of supported banks One of: ghana, kenya, nigeria, south africa.
21
+ # @param currency [String] The currency of the banks to list. One of: GHS, KES, NGN, ZAR, USD.
22
+ # @param use_cursor [Boolean] A flag to indicate if cursor based pagination should be used
23
+ # @param per_page [Integer] The number of records to fetch per request
24
+ # @param page [Integer] The offset to retrieve data from
25
+ # @param next_cursor [String] An alphanumeric value returned for every cursor based retrieval, used to retrieve the next set of data
26
+ # @param previous [String] An alphanumeric value returned for every cursor based retrieval, used to retrieve the previous set of data
27
+ # @param pay_with_bank_transfer [Boolean] A flag to filter for available banks a customer can make a transfer to complete a payment
28
+ # @param pay_with_bank [Boolean] A flag to filter for banks a customer can pay directly from
29
+ # @param enabled_for_verification [Boolean] A flag to filter the banks that are supported for account verification in South Africa.
30
+ # @param gateway [String] The type of gateway for a Nigerian bank One of: emandate, digitalbankmandate.
31
+ # @param type [String] Type of financial channel One of: ghipss, mobile_money, nuban, kepss, basa.
32
+ # @param include_nip_sort_code [Boolean] A flag that returns Nigerian banks with their NIP institution code.
33
+ # @return [PaystackSdk::Response] The response from the Paystack API.
34
+ # @raise [PaystackSdk::Error] If a parameter is invalid or the API request fails.
7
35
  # @see https://paystack.com/docs/api/miscellaneous/#bank
8
- def list(query = {})
9
- if query.key?(:currency)
10
- validate_allowed_values!(
11
- value: query[:currency],
12
- name: "currency",
13
- allowed_values: %w[NGN GHS ZAR KES USD]
14
- )
15
- end
16
-
17
- handle_response(@connection.get("/bank", query))
36
+ def list(
37
+ country: nil,
38
+ currency: nil,
39
+ use_cursor: nil,
40
+ per_page: nil,
41
+ page: nil,
42
+ next_cursor: nil,
43
+ previous: nil,
44
+ pay_with_bank_transfer: nil,
45
+ pay_with_bank: nil,
46
+ enabled_for_verification: nil,
47
+ gateway: nil,
48
+ type: nil,
49
+ include_nip_sort_code: nil
50
+ )
51
+ validate_allowed_values!(
52
+ value: country,
53
+ allowed_values: ["ghana", "kenya", "nigeria", "south africa"],
54
+ name: "country"
55
+ )
56
+ validate_allowed_values!(
57
+ value: currency,
58
+ allowed_values: %w[GHS KES NGN ZAR USD],
59
+ name: "currency"
60
+ )
61
+ validate_positive_integer!(value: per_page, name: "perPage")
62
+ validate_positive_integer!(value: page, name: "page")
63
+ validate_allowed_values!(
64
+ value: gateway,
65
+ allowed_values: %w[emandate digitalbankmandate],
66
+ name: "gateway"
67
+ )
68
+ validate_allowed_values!(
69
+ value: type,
70
+ allowed_values: %w[ghipss mobile_money nuban kepss basa],
71
+ name: "type"
72
+ )
73
+
74
+ wire_query = to_wire(
75
+ {
76
+ country:,
77
+ currency:,
78
+ use_cursor:,
79
+ per_page:,
80
+ page:,
81
+ next_cursor:,
82
+ previous:,
83
+ pay_with_bank_transfer:,
84
+ pay_with_bank:,
85
+ enabled_for_verification:,
86
+ gateway:,
87
+ type:,
88
+ include_nip_sort_code:
89
+ },
90
+ WIRE_NAMES
91
+ )
92
+
93
+ handle_response(@connection.get("/bank", wire_query))
94
+ end
95
+
96
+ # Resolve Account Number.
97
+ #
98
+ # Resolve an account number to confirm the name associated with it
99
+ #
100
+ # @param account_number [String] The account number of interest
101
+ # @param bank_code [String] The bank code associated with the account number
102
+ # @return [PaystackSdk::Response] The response from the Paystack API.
103
+ # @raise [PaystackSdk::Error] If a parameter is invalid or the API request fails.
104
+ # @see https://paystack.com/docs/api/verification/#resolve-account
105
+ def resolve_account_number(account_number:, bank_code:)
106
+ validate_presence!(value: account_number, name: "account_number")
107
+ validate_presence!(value: bank_code, name: "bank_code")
108
+
109
+ wire_query = to_wire({account_number:, bank_code:}, WIRE_NAMES)
110
+
111
+ handle_response(@connection.get("/bank/resolve", wire_query))
112
+ end
113
+
114
+ # Validate Bank Account.
115
+ #
116
+ # Confirm the authenticity of a customer's account number before sending money
117
+ #
118
+ # @param account_name [String] Customer's first and last name registered with their bank
119
+ # @param account_number [String] Customer's account number
120
+ # @param account_type [String] The type of the customer's account number One of: personal, business.
121
+ # @param bank_code [String] The bank code of the customer’s bank.
122
+ # @param country_code [String] The two digit ISO code of the customer’s bank
123
+ # @param document_type [String] Customer’s mode of identity One of: identityNumber, passportNumber, businessRegistrationNumber.
124
+ # @param document_number [String] Customer’s mode of identity number
125
+ # @return [PaystackSdk::Response] The response from the Paystack API.
126
+ # @raise [PaystackSdk::Error] If a parameter is invalid or the API request fails.
127
+ # @see https://paystack.com/docs/api/verification/#validate-account
128
+ def validate_account(
129
+ account_name:,
130
+ account_number:,
131
+ account_type:,
132
+ bank_code:,
133
+ country_code:,
134
+ document_type:,
135
+ document_number: nil
136
+ )
137
+ validate_presence!(value: account_name, name: "account_name")
138
+ validate_presence!(value: account_number, name: "account_number")
139
+ validate_presence!(value: account_type, name: "account_type")
140
+ validate_allowed_values!(
141
+ value: account_type,
142
+ allowed_values: %w[personal business],
143
+ name: "account_type"
144
+ )
145
+ validate_presence!(value: bank_code, name: "bank_code")
146
+ validate_presence!(value: country_code, name: "country_code")
147
+ validate_presence!(value: document_type, name: "document_type")
148
+ validate_allowed_values!(
149
+ value: document_type,
150
+ allowed_values: %w[identityNumber passportNumber businessRegistrationNumber],
151
+ name: "document_type"
152
+ )
153
+
154
+ wire_body = to_wire(
155
+ {
156
+ account_name:,
157
+ account_number:,
158
+ account_type:,
159
+ bank_code:,
160
+ country_code:,
161
+ document_type:,
162
+ document_number:
163
+ },
164
+ WIRE_NAMES
165
+ )
166
+
167
+ handle_response(@connection.post("/bank/validate", wire_body))
18
168
  end
19
169
  end
20
170
  end
@@ -3,6 +3,7 @@
3
3
  require_relative "../response"
4
4
  require_relative "../client"
5
5
  require_relative "../validations"
6
+ require_relative "../request_helpers"
6
7
  require_relative "../utils/connection_utils"
7
8
 
8
9
  module PaystackSdk
@@ -11,6 +12,7 @@ module PaystackSdk
11
12
  # It provides shared functionality, such as handling API responses.
12
13
  class Base
13
14
  include PaystackSdk::Validations
15
+ include PaystackSdk::RequestHelpers
14
16
  include PaystackSdk::Utils::ConnectionUtils
15
17
 
16
18
  # Initializes a new `Base` instance.
@@ -29,8 +31,8 @@ module PaystackSdk
29
31
  #
30
32
  # @example With default connection (requires PAYSTACK_SECRET_KEY environment variable)
31
33
  # resource = PaystackSdk::Resources::SomeResource.new
32
- def initialize(connection = nil, secret_key: nil)
33
- @connection = initialize_connection(connection, secret_key: secret_key)
34
+ def initialize(connection = nil, secret_key: nil, **options)
35
+ @connection = initialize_connection(connection, secret_key: secret_key, **options)
34
36
  end
35
37
 
36
38
  private