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.
@@ -5,7 +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"
8
+ require_relative "resources/charges"
9
+ require_relative "resources/miscellaneous"
10
+ require_relative "resources/refunds"
9
11
  require_relative "utils/connection_utils"
10
12
 
11
13
  module PaystackSdk
@@ -15,6 +17,10 @@ module PaystackSdk
15
17
  # Include connection utilities
16
18
  include Utils::ConnectionUtils
17
19
 
20
+ # Prefix of Paystack's test and live secret keys.
21
+ TEST_KEY_PREFIX = "sk_test_"
22
+ LIVE_KEY_PREFIX = "sk_live_"
23
+
18
24
  # @return [Faraday::Connection] The Faraday connection object used for API requests
19
25
  attr_reader :connection
20
26
 
@@ -24,6 +30,9 @@ module PaystackSdk
24
30
  # If nil, a new connection will be created using the default API key.
25
31
  # @param secret_key [String, nil] Optional API key to use for creating a new connection.
26
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.
27
36
  #
28
37
  # @example With an existing connection
29
38
  # connection = Faraday.new(...)
@@ -34,8 +43,25 @@ module PaystackSdk
34
43
  #
35
44
  # @example With default connection (requires PAYSTACK_SECRET_KEY environment variable)
36
45
  # client = PaystackSdk::Client.new
37
- def initialize(connection = nil, secret_key: nil)
38
- @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)
39
65
  end
40
66
 
41
67
  # Provides access to the `Transactions` resource.
@@ -46,7 +72,7 @@ module PaystackSdk
46
72
  # @example
47
73
  # ```ruby
48
74
  # transactions = client.transactions
49
- # response = transactions.initiate(params)
75
+ # response = transactions.initiate(email: "ama@example.com", amount: 10000)
50
76
  # ```
51
77
  def transactions
52
78
  @transactions ||= Resources::Transactions.new(@connection)
@@ -74,7 +100,7 @@ module PaystackSdk
74
100
  # @example
75
101
  # ```ruby
76
102
  # recipients = client.transfer_recipients
77
- # response = recipients.create(params)
103
+ # response = recipients.create(type: "nuban", name: "Ama Mensah", account_number: "0123456789", bank_code: "058")
78
104
  # ```
79
105
  def transfer_recipients
80
106
  @transfer_recipients ||= Resources::TransferRecipients.new(@connection)
@@ -88,7 +114,7 @@ module PaystackSdk
88
114
  # @example
89
115
  # ```ruby
90
116
  # transfers = client.transfers
91
- # response = transfers.create(params)
117
+ # response = transfers.create(source: "balance", amount: 100_000, recipient: "RCP_xxx", reference: "payout-2025-0001-ama")
92
118
  # ```
93
119
  def transfers
94
120
  @transfers ||= Resources::Transfers.new(@connection)
@@ -102,24 +128,56 @@ module PaystackSdk
102
128
  # @example
103
129
  # ```ruby
104
130
  # banks = client.banks
105
- # response = banks.list
131
+ # response = banks.list(country: "nigeria")
106
132
  # ```
107
133
  def banks
108
134
  @banks ||= Resources::Banks.new(@connection)
109
135
  end
110
136
 
111
- # Provides access to the `Verification` resource.
137
+ # Provides access to the `Charges` resource.
138
+ #
139
+ # @return [PaystackSdk::Resources::Charges] An instance of the
140
+ # `Charges` resource.
141
+ #
142
+ # @example
143
+ # ```ruby
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
+ # )
150
+ # ```
151
+ def charges
152
+ @charges ||= Resources::Charges.new(@connection)
153
+ end
154
+
155
+ # Provides access to the `Miscellaneous` resource.
112
156
  #
113
- # @return [PaystackSdk::Resources::Verification] An instance of the
114
- # `Verification` resource.
157
+ # @return [PaystackSdk::Resources::Miscellaneous] An instance of the
158
+ # `Miscellaneous` resource.
115
159
  #
116
160
  # @example
117
161
  # ```ruby
118
- # verification = client.verification
119
- # response = verification.resolve_account(account_number: ..., bank_code: ...)
162
+ # response = client.miscellaneous.resolve_card_bin(bin: "539983")
120
163
  # ```
121
- def verification
122
- @verification ||= Resources::Verification.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 ")
123
181
  end
124
182
  end
125
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