paystack-gateway 0.1.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.
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/module/delegation'
4
+
5
+ # = PaystackGateway
6
+ module PaystackGateway
7
+ # Encapsulates the configuration options for PaystackGateway including the
8
+ # secret key, logger, logging_options, and log filter.
9
+ class Configuration
10
+ attr_accessor :secret_key, :logger, :logging_options, :log_filter
11
+
12
+ def initialize
13
+ @logger = Logger.new($stdout)
14
+ @log_filter = lambda(&:dup)
15
+ end
16
+ end
17
+
18
+ class << self
19
+ attr_writer :config
20
+
21
+ delegate :secret_key, :logger, :logging_options, :log_filter, to: :config
22
+
23
+ def config = @config ||= Configuration.new
24
+ def configure = yield(config)
25
+ end
26
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/isolated_execution_state'
4
+ require 'active_support/code_generator'
5
+ require 'active_support/current_attributes'
6
+
7
+ module PaystackGateway
8
+ # Global singleton providing thread isolated attributes, used and reset around
9
+ # each api method call.
10
+ class Current < ActiveSupport::CurrentAttributes
11
+ attribute :api_module, :api_method_name
12
+
13
+ def response_class
14
+ class_name = "#{api_method_name}_response".camelize.to_sym
15
+ api_module.const_defined?(class_name) ? api_module.const_get(class_name) : PaystackGateway::Response
16
+ end
17
+
18
+ def error_class
19
+ class_name = "#{api_method_name}_error".camelize
20
+ api_module.const_defined?(class_name) ? api_module.const_get(class_name) : PaystackGateway::ApiError
21
+ end
22
+
23
+ def qualified_api_method_name = "#{api_module.name}##{api_method_name}"
24
+ end
25
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaystackGateway
4
+ # Create and manage customers https://paystack.com/docs/api/customer/
5
+ module Customers
6
+ include PaystackGateway::RequestModule
7
+
8
+ # Common helpers for response from customer endpoints
9
+ module CustomerResponse
10
+ extend ActiveSupport::Concern
11
+
12
+ included do
13
+ delegate :id, :customer_code, to: :data
14
+ end
15
+ end
16
+
17
+ class CreateCustomerResponse < PaystackGateway::Response
18
+ include CustomerResponse
19
+ end
20
+
21
+ api_method def self.create_customer(email:, first_name:, last_name:)
22
+ use_connection do |connection|
23
+ connection.post('/customer', { email:, first_name:, last_name: })
24
+ end
25
+ end
26
+
27
+ # Response from GET /customer/:email_or_id
28
+ class FetchCustomerResponse < PaystackGateway::Response
29
+ include CustomerResponse
30
+
31
+ delegate :subscriptions, :authorizations, to: :data
32
+
33
+ def active_subscriptions = subscriptions.select { _1.status == 'active' }
34
+ def active_subscription_codes = active_subscriptions.map(&:subscription_code)
35
+ def reusable_authorizations = authorizations.select(&:reusable)
36
+ end
37
+
38
+ api_method def self.fetch_customer(email:)
39
+ use_connection do |connection|
40
+ connection.get("/customer/#{email}")
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaystackGateway
4
+ # The Dedicated Virtual Account API enables Nigerian merchants to manage unique
5
+ # payment accounts of their customers.
6
+ # https://paystack.com/docs/api/dedicated-virtual-account/
7
+ module DedicatedVirtualAccounts
8
+ include PaystackGateway::RequestModule
9
+
10
+ # Response from POST /dedicated_account endpoint.
11
+ class CreateDedicatedVirtualAccountResponse < PaystackGateway::Response; end
12
+
13
+ api_method def self.create_dedicated_virtual_account(customer_id_or_code:, subaccount_code:, preferred_bank:)
14
+ use_connection do |connection|
15
+ connection.post(
16
+ '/dedicated_account',
17
+ {
18
+ customer: customer_id_or_code,
19
+ preferred_bank:,
20
+ subaccount: subaccount_code,
21
+ phone: '+2348011111111', # phone number is required by paystack for some reason, use fake phone number
22
+ },
23
+ )
24
+ end
25
+ end
26
+
27
+ # Response from POST /dedicated_account/assign endpoint.
28
+ class AssignDedicatedVirtualAccountResponse < PaystackGateway::Response; end
29
+
30
+ api_method def self.assign_dedicated_virtual_account(
31
+ email:, first_name:, last_name:, subaccount_code:, preferred_bank:
32
+ )
33
+ use_connection do |connection|
34
+ connection.post(
35
+ '/dedicated_account/assign',
36
+ {
37
+ email:,
38
+ first_name:,
39
+ last_name:,
40
+ preferred_bank:,
41
+ country: :NG,
42
+ subaccount: subaccount_code,
43
+ },
44
+ )
45
+ end
46
+ end
47
+
48
+ # Response from POST /dedicated_account/split endpoint
49
+ class SplitDedicatedAccountTransactionResponse < PaystackGateway::Response; end
50
+
51
+ api_method def self.split_dedicated_account_transaction(customer_id_or_code:, subaccount_code:)
52
+ use_connection do |connection|
53
+ connection.post('/dedicated_account/split', { customer: customer_id_or_code, subaccount: subaccount_code })
54
+ end
55
+ end
56
+
57
+ # Response from GET /dedicated_account endpoint
58
+ class RequeryDedicatedAccountResponse < PaystackGateway::Response; end
59
+
60
+ api_method def self.requery_dedicated_account(account_number:, bank:)
61
+ use_connection do |connection|
62
+ connection.get('/dedicated_account', { account_number:, provider_slug: bank })
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaystackGateway
4
+ # Supporting APIs that can be used to provide more details to other APIs
5
+ # https://paystack.com/docs/api/#miscellaneous
6
+ module Miscellaneous
7
+ include PaystackGateway::RequestModule
8
+
9
+ # Response from GET /bank endpoint.
10
+ class ListBanksResponse < PaystackGateway::Response
11
+ def bank_names = data.map(&:name)
12
+ def bank_slugs = data.map(&:slug)
13
+
14
+ def bank_details(*attributes) = data.map { _1.slice(*attributes) }
15
+
16
+ def by_bank_names = data.index_by(&:name)
17
+ def by_bank_codes = data.index_by(&:code)
18
+ end
19
+
20
+ # https://paystack.com/docs/api/miscellaneous/#bank
21
+ api_method def self.list_banks(use_cache: true, pay_with_bank_transfer: false)
22
+ use_connection(cache_options: use_cache ? {} : nil) do |connection|
23
+ connection.get('/bank', pay_with_bank_transfer ? { pay_with_bank_transfer: } : {})
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaystackGateway
4
+ # Create and manage installment payment options
5
+ # https://paystack.com/docs/api/plan/#create
6
+ module Plans
7
+ include PaystackGateway::RequestModule
8
+
9
+ # Response from POST /plan endpoint.
10
+ class CreatePlanResponse < PaystackGateway::Response
11
+ delegate :id, to: :data, prefix: :plan
12
+ delegate :id, :plan_code, to: :data
13
+ end
14
+
15
+ api_method def self.create_plan(name:, amount:, interval:)
16
+ use_connection do |connection|
17
+ connection.post(
18
+ '/plan',
19
+ {
20
+ name:,
21
+ interval:,
22
+ amount: amount * 100,
23
+ send_invoices: false,
24
+ send_sms: false,
25
+ },
26
+ )
27
+ end
28
+ end
29
+
30
+ # Response from GET /plan endpoint.
31
+ class ListPlansResponse < PaystackGateway::Response
32
+ def active_plans = data.select { |plan| !plan.is_deleted && !plan.is_archived }
33
+
34
+ def find_active_plan_by_name(name)
35
+ active_plans.sort_by { -Time.parse(_1.createdAt).to_i }.find { _1.name == name }
36
+ end
37
+ end
38
+
39
+ api_method def self.list_plans
40
+ use_connection do |connection|
41
+ connection.get('/plan')
42
+ end
43
+ end
44
+
45
+ # Response from GET /plan/:code endpoint.
46
+ class FetchPlanResponse < PaystackGateway::Response
47
+ delegate :subscriptions, to: :data
48
+
49
+ def active_subscriptions = subscriptions.select { _1.status.to_sym == :active }
50
+
51
+ def active_subscription_codes(email: nil)
52
+ subscriptions =
53
+ if email
54
+ active_subscriptions.select { _1.customer.email.casecmp?(email) }
55
+ else
56
+ active_subscriptions
57
+ end
58
+ subscriptions.map(&:subscription_code)
59
+ end
60
+ end
61
+
62
+ api_method def self.fetch_plan(code:)
63
+ use_connection do |connection|
64
+ connection.get("/plan/#{code}")
65
+ end
66
+ end
67
+
68
+ class UpdatePlanResponse < PaystackGateway::Response; end
69
+
70
+ api_method def self.update_plan(code:, amount:, interval:)
71
+ use_connection do |connection|
72
+ connection.put(
73
+ "/plan/#{code}",
74
+ {
75
+ amount: amount * 100,
76
+ interval:,
77
+ },
78
+ )
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaystackGateway
4
+ # Create and manage transaction refunds.
5
+ # https://paystack.com/docs/api/refund
6
+ module Refunds
7
+ include PaystackGateway::RequestModule
8
+
9
+ # Common helpers for responses from refunds endpoints
10
+ module TransactionRefundResponse
11
+ def refund_success? = transaction_status == :processed
12
+ def refund_failed? = transaction_status == :failed
13
+ def refund_pending? = transaction_status.in?(%i[pending processing])
14
+ end
15
+
16
+ # Response from POST /refund endpoint.
17
+ class CreateResponse < PaystackGateway::Response
18
+ include TransactionResponse
19
+ include TransactionRefundResponse
20
+ end
21
+
22
+ api_method def self.create(transaction_reference_or_id:)
23
+ use_connection do |connection|
24
+ connection.post('/refund', { transaction: transaction_reference_or_id })
25
+ end
26
+ end
27
+
28
+ # Response from GET /refund endpoint.
29
+ class ListRefundsResponse < PaystackGateway::Response
30
+ def pending_or_successful
31
+ filtered = data.select { _1.status&.to_sym.in?(%i[processed pending processing]) }
32
+
33
+ ListRefundsResponse.new({ **self, data: filtered })
34
+ end
35
+
36
+ def with_amount(amount)
37
+ filtered = data.select { _1.amount == amount * 100 }
38
+
39
+ ListRefundsResponse.new({ **self, data: filtered })
40
+ end
41
+ end
42
+
43
+ api_method def self.list_refunds(transaction_id:)
44
+ use_connection do |connection|
45
+ connection.get('/refund', { transaction: transaction_id })
46
+ end
47
+ end
48
+
49
+ # Response from GET /refund/:id endpoint.
50
+ class FetchRefundResponse < PaystackGateway::Response
51
+ include TransactionResponse
52
+ include TransactionRefundResponse
53
+ end
54
+
55
+ api_method def self.fetch_refund(refund_id:)
56
+ use_connection do |connection|
57
+ connection.get("/refund/#{refund_id}")
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'faraday/response/caching'
5
+ require 'faraday/response/mashify'
6
+ require 'active_support/cache'
7
+ require 'active_support/notifications'
8
+ require 'active_support/concern'
9
+ require 'active_support/core_ext/string/inflections' # for #camelize
10
+ require 'active_support/core_ext/numeric/time' # for #days
11
+
12
+ module PaystackGateway
13
+ # This module provides methods for making API requests to the Paystack
14
+ # payment gateway, including handling responses and errors.
15
+ module RequestModule
16
+ extend ActiveSupport::Concern
17
+
18
+ BASE_URL = 'https://api.paystack.co'
19
+
20
+ # ClassMethods
21
+ module ClassMethods
22
+ def api_methods = (@api_method_names || Set.new).to_a
23
+
24
+ private
25
+
26
+ def use_connection(response_class = Current.response_class, cache_options: nil)
27
+ connection = Faraday.new(BASE_URL) do |conn|
28
+ conn.request :json
29
+ conn.request :authorization, 'Bearer', PaystackGateway.secret_key
30
+
31
+ conn.response :logger, PaystackGateway.logger, { headers: false, **(PaystackGateway.logging_options || {}) }
32
+ conn.response :mashify, mash_class: response_class
33
+
34
+ conn.response :raise_error
35
+ conn.response :json
36
+ conn.response :caching, cache_store(**cache_options) if cache_options
37
+ end
38
+
39
+ if block_given?
40
+ response = yield connection
41
+ response.body
42
+ else
43
+ connection
44
+ end
45
+ end
46
+
47
+ def cache_store(expires_in: 7.days.to_i, cache_key: nil)
48
+ cache_dir = File.join(ENV['TMPDIR'] || '/tmp', 'cache')
49
+ namespace = cache_key ? "#{name}_#{cache_key}" : name
50
+
51
+ ActiveSupport::Cache::FileStore.new cache_dir, namespace:, expires_in: expires_in.to_i
52
+ end
53
+
54
+ def api_method(method_name)
55
+ @api_method_names ||= Set.new
56
+ @api_method_names << method_name
57
+
58
+ decorate_api_methods(method_name)
59
+ end
60
+
61
+ def decorate_api_methods(*method_names)
62
+ singleton_class.class_exec do
63
+ prepend(Module.new do
64
+ method_names.flatten.each do |method_name|
65
+ define_method(method_name) do |*args, **kwargs, &block|
66
+ Current.with(api_module: self, api_method_name: method_name) do
67
+ super(*args, **kwargs, &block)
68
+ rescue Faraday::Error => e
69
+ handle_error(e)
70
+ end
71
+ end
72
+ end
73
+ end)
74
+ end
75
+ end
76
+
77
+ def handle_error(error)
78
+ PaystackGateway.logger.error "#{Current.qualified_api_method_name}: #{error.message}"
79
+ PaystackGateway.logger.error JSON.pretty_generate(filtered_response(error.response) || {}) if error.response
80
+
81
+ raise Current.error_class.new(
82
+ "Paystack error: #{error.message}, status: #{error.response_status}, response: #{error.response_body}",
83
+ original_error: error,
84
+ )
85
+ end
86
+
87
+ def filtered_response(response)
88
+ return unless response
89
+
90
+ {
91
+ request_method: response.dig(:request, :method),
92
+ request_url: response.dig(:request, :url),
93
+ request_headers: PaystackGateway.log_filter.call(response.dig(:request, :headers)),
94
+ request_body: PaystackGateway.log_filter.call(JSON.parse(response.dig(:request, :body) || '{}')),
95
+
96
+ response_status: response[:status],
97
+ response_headers: PaystackGateway.log_filter.call(response[:headers]),
98
+ response_body: PaystackGateway.log_filter.call(response[:body]),
99
+ }
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'hashie/dash'
4
+ require 'hashie/mash'
5
+ require 'hashie/extensions/coercion'
6
+
7
+ module PaystackGateway
8
+ # Wrapper for responses from Paystack.
9
+ class Response < Hashie::Dash
10
+ include Hashie::Extensions::Coercion
11
+
12
+ property :status
13
+ property :message
14
+ property :data
15
+ property :meta
16
+
17
+ coerce_key :data, ->(v) { coerce_data(v) }
18
+
19
+ def self.coerce_data(value)
20
+ case value
21
+ when Array
22
+ value.map { coerce_data(_1) }
23
+ when Hash
24
+ Hashie::Mash.new(value)
25
+ else
26
+ value
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaystackGateway
4
+ # Create and manage subaccounts https://paystack.com/docs/api/subaccount/
5
+ module Subaccounts
6
+ include PaystackGateway::RequestModule
7
+
8
+ # Response from POST /subaccount
9
+ class CreateSubaccountResponse < PaystackGateway::Response
10
+ delegate :subaccount_code, to: :data
11
+ end
12
+
13
+ api_method def self.create_subaccount(business_name:, settlement_bank:, account_number:, percentage_charge:)
14
+ use_connection do |connection|
15
+ connection.post(
16
+ '/subaccount',
17
+ { business_name:, settlement_bank:, account_number:, percentage_charge: }.compact,
18
+ )
19
+ end
20
+ end
21
+
22
+ # Response from PUT /subaccount/:id_or_code
23
+ class UpdateSubaccountResponse < CreateSubaccountResponse; end
24
+
25
+ api_method def self.update_subaccount(
26
+ subaccount_code,
27
+ business_name: nil, settlement_bank: nil, account_number: nil, percentage_charge: nil
28
+ )
29
+ use_connection do |connection|
30
+ connection.put(
31
+ "/subaccount/#{subaccount_code}",
32
+ { business_name:, settlement_bank:, account_number:, percentage_charge: }.compact,
33
+ )
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaystackGateway
4
+ # Common helpers for responses from transaction endpoints
5
+ module TransactionResponse
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ delegate :id, :amount, :subaccount, :fees_split, to: :data
10
+
11
+ attr_writer :completed_at
12
+ end
13
+
14
+ def transaction_success? = transaction_status.in?(%i[success reversed reversal_pending])
15
+ def transaction_abandoned? = transaction_status == :abandoned
16
+ def transaction_failed? = transaction_status == :failed
17
+ def transaction_pending? = transaction_status.in?(%i[pending ongoing])
18
+
19
+ def transaction_status = data.status.to_sym
20
+ def transaction_amount = amount / BigDecimal('100')
21
+ def transaction_completed_at = data[:updatedAt] || @completed_at
22
+
23
+ def subaccount_amount
24
+ return if !subaccount || !fees_split
25
+
26
+ fees_split.subaccount / BigDecimal('100')
27
+ end
28
+
29
+ def failure_reason
30
+ return if !transaction_failed? && !transaction_abandoned?
31
+
32
+ data.gateway_response || transaction_status || message
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaystackGateway
4
+ # Create and manage payments https://paystack.com/docs/api/#transaction
5
+ module Transactions
6
+ include PaystackGateway::RequestModule
7
+
8
+ # Response from POST /transaction/initialize endpoint.
9
+ class InitializeTransactionResponse < PaystackGateway::Response
10
+ delegate :authorization_url, to: :data
11
+
12
+ alias payment_url authorization_url
13
+ end
14
+
15
+ # Raised when an error occurs while calling POST /transaction/initialize
16
+ class InitializeTransactionError < ApiError
17
+ def cancellable? = super || network_error?
18
+ end
19
+
20
+ # Response looks like this.
21
+ # {
22
+ # "status": true,
23
+ # "message": "Authorization URL created",
24
+ # "data": {
25
+ # "authorization_url": "https://checkout.paystack.com/n4ysyedbuseog8c",
26
+ # "access_code": "n4ysyedbuseog8c",
27
+ # "reference": "hlr4bxhypt"
28
+ # }
29
+ # }
30
+ api_method def self.initialize_transaction(**transaction_data)
31
+ raise ApiError.new(:invalid_amount, cancellable: true) if transaction_data[:amount].blank?
32
+ raise ApiError.new(:invalid_reference, cancellable: true) if transaction_data[:reference].blank?
33
+ raise ApiError.new(:invalid_email, cancellable: true) if transaction_data[:email].blank?
34
+
35
+ transaction_data[:amount] = (transaction_data[:amount] * 100).to_i
36
+
37
+ use_connection do |connection|
38
+ connection.post('/transaction/initialize', transaction_data.compact)
39
+ end
40
+ end
41
+
42
+ # Response from GET /transaction/verify/:reference endpoint.
43
+ class VerifyTransactionResponse < PaystackGateway::Response
44
+ include TransactionResponse
45
+
46
+ delegate :paid_at, to: :data
47
+
48
+ def transaction_completed_at
49
+ paid_at || super
50
+ end
51
+ end
52
+
53
+ # Raised when an error occurs while calling /transactions/verify/:reference
54
+ class VerifyTransactionError < ApiError
55
+ def transaction_not_found?
56
+ return false if !response_body
57
+
58
+ response_body[:status] == false && response_body[:message].match?(/transaction reference not found/i)
59
+ end
60
+ end
61
+
62
+ api_method def self.verify_transaction(reference:)
63
+ raise VerifyTransactionError, :invalid_reference if reference.blank?
64
+
65
+ use_connection do |connection|
66
+ connection.get("/transaction/verify/#{reference}")
67
+ end
68
+ end
69
+
70
+ # Response from POST /transaction/charge_authorization endpoint.
71
+ class ChargeAuthorizationResponse < PaystackGateway::Response
72
+ include TransactionResponse
73
+ end
74
+
75
+ api_method def self.charge_authorization(**transaction_data)
76
+ raise ApiError.new(:invalid_amount, cancellable: true) if transaction_data[:amount].blank?
77
+ raise ApiError.new(:invalid_reference, cancellable: true) if transaction_data[:reference].blank?
78
+ raise ApiError.new(:invalid_authorization_code, cancellable: true) if transaction_data[:authorization_code].blank?
79
+ raise ApiError.new(:invalid_email, cancellable: true) if transaction_data[:email].blank?
80
+
81
+ transaction_data[:amount] = (transaction_data[:amount] * 100).to_i
82
+
83
+ use_connection do |connection|
84
+ connection.post('/transaction/charge_authorization', transaction_data.compact)
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaystackGateway
4
+ # Create and manage beneficiaries that you send money to
5
+ # https://paystack.com/docs/api/#transfer-recipient
6
+ module TransferRecipients
7
+ include PaystackGateway::RequestModule
8
+
9
+ # Response from POST /transferrecipient endpoint.
10
+ class CreateTransferRecipientResponse < PaystackGateway::Response
11
+ delegate :id, :recipient_code, to: :data
12
+ end
13
+
14
+ api_method def self.create_transfer_recipient(name:, account_number:, bank_code:)
15
+ use_connection do |connection|
16
+ connection.post(
17
+ '/transferrecipient',
18
+ { type: :nuban, name: name, account_number: account_number, bank_code: bank_code, currency: :NGN },
19
+ )
20
+ end
21
+ end
22
+ end
23
+ end