paystack-gateway 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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