lipwa 0.1.2 → 0.1.4
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -0
- data/README.md +219 -204
- data/lib/lipwa/auth_strategies/api_key.rb +28 -0
- data/lib/lipwa/auth_strategies/base.rb +4 -0
- data/lib/lipwa/auth_strategies/bearer_token.rb +5 -0
- data/lib/lipwa/auth_strategies/jenga.rb +58 -0
- data/lib/lipwa/auth_strategies.rb +2 -0
- data/lib/lipwa/capabilities/authorize.rb +26 -0
- data/lib/lipwa/capabilities/bank_transfer.rb +66 -0
- data/lib/lipwa/capabilities/c2b.rb +11 -10
- data/lib/lipwa/capabilities/capture.rb +25 -0
- data/lib/lipwa/capabilities/disbursement.rb +16 -17
- data/lib/lipwa/capabilities/lightning_invoice.rb +75 -0
- data/lib/lipwa/capabilities/lightning_payment.rb +71 -0
- data/lib/lipwa/capabilities/purchase.rb +26 -0
- data/lib/lipwa/capabilities/refund.rb +14 -14
- data/lib/lipwa/capabilities/status_query.rb +21 -12
- data/lib/lipwa/capabilities/stk_push.rb +8 -5
- data/lib/lipwa/capabilities/void.rb +25 -0
- data/lib/lipwa/configuration_snapshot.rb +50 -0
- data/lib/lipwa/context.rb +60 -0
- data/lib/lipwa/contracts/bank_transfer_contract.rb +61 -0
- data/lib/lipwa/contracts/card_payment_contract.rb +59 -0
- data/lib/lipwa/contracts/card_transaction_contract.rb +21 -0
- data/lib/lipwa/contracts/lightning_invoice_check_contract.rb +18 -0
- data/lib/lipwa/contracts/lightning_invoice_contract.rb +27 -0
- data/lib/lipwa/contracts/lightning_payment_check_contract.rb +18 -0
- data/lib/lipwa/contracts/lightning_payment_contract.rb +14 -0
- data/lib/lipwa/contracts/status_query_contract.rb +29 -6
- data/lib/lipwa/errors.rb +4 -2
- data/lib/lipwa/gateway.rb +24 -5
- data/lib/lipwa/gateways/coop_bank/auth.rb +68 -0
- data/lib/lipwa/gateways/coop_bank.rb +115 -0
- data/lib/lipwa/gateways/flutterwave.rb +160 -0
- data/lib/lipwa/gateways/jenga/auth.rb +76 -0
- data/lib/lipwa/gateways/jenga.rb +282 -0
- data/lib/lipwa/gateways/lnbits.rb +67 -0
- data/lib/lipwa/gateways/mpesa/auth.rb +18 -2
- data/lib/lipwa/gateways/mpesa.rb +16 -11
- data/lib/lipwa/gateways/paystack.rb +119 -0
- data/lib/lipwa/gateways/pesapal/auth.rb +72 -0
- data/lib/lipwa/gateways/pesapal.rb +123 -0
- data/lib/lipwa/gateways.rb +7 -1
- data/lib/lipwa/http_adapter.rb +56 -17
- data/lib/lipwa/logging.rb +100 -0
- data/lib/lipwa/money.rb +33 -4
- data/lib/lipwa/types.rb +3 -3
- data/lib/lipwa/version.rb +1 -1
- data/lib/lipwa/webhooks/coop_bank.rb +28 -0
- data/lib/lipwa/webhooks/jenga.rb +55 -0
- data/lib/lipwa/webhooks/lnbits.rb +46 -0
- data/lib/lipwa.rb +29 -0
- metadata +32 -4
- data/CODE_OF_CONDUCT.md +0 -10
- data/Rakefile +0 -12
- data/sig/lipwa.rbs +0 -4
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require_relative "../gateway"
|
|
5
|
+
require_relative "../gateways"
|
|
6
|
+
require_relative "../capabilities/bank_transfer"
|
|
7
|
+
require_relative "../capabilities/status_query"
|
|
8
|
+
module Lipwa
|
|
9
|
+
module Gateways
|
|
10
|
+
class CoopBank < Lipwa::Gateway
|
|
11
|
+
include Lipwa::Capabilities::BankTransfer
|
|
12
|
+
include Lipwa::Capabilities::StatusQuery
|
|
13
|
+
BASE_URLS = { sandbox: "https://developer.co-opbank.co.ke:8243",
|
|
14
|
+
production: "https://developer.co-opbank.co.ke:8243" }.freeze
|
|
15
|
+
TOKEN_URLS = BASE_URLS.transform_values { |url| "#{url}/token" }.freeze
|
|
16
|
+
PATHS = { internal: "/FundsTransfer/Internal/A2A/2.0.0", rtgs: "/FundsTransfer/External/A2A/2.0.0",
|
|
17
|
+
pesalink: "/FundsTransfer/External/A2M/2.0.0", bill_payment: "/BillPayment/PayBill/1.0.0",
|
|
18
|
+
balance: "/Enquiry/AccountBalance/1.0.0", statement: "/Enquiry/MiniStatement/1.0.0" }.freeze
|
|
19
|
+
STATUS_PATH = "/QueryStatus/v1.0.0/query"
|
|
20
|
+
setting :api_key
|
|
21
|
+
setting :api_secret
|
|
22
|
+
setting :client_id
|
|
23
|
+
setting :client_secret
|
|
24
|
+
setting :token_url
|
|
25
|
+
setting :clock
|
|
26
|
+
|
|
27
|
+
def status(message_reference:, idempotency_key: nil)
|
|
28
|
+
response = http.post(STATUS_PATH, body: { MessageReference: message_reference },
|
|
29
|
+
idempotency_key: idempotency_key)
|
|
30
|
+
build_status_query_response(response.body)
|
|
31
|
+
rescue Lipwa::GatewayError => e
|
|
32
|
+
Failure(e)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def build_http_adapter
|
|
38
|
+
config = self.config
|
|
39
|
+
api_key = config.api_key || config.client_id
|
|
40
|
+
api_secret = config.api_secret || config.client_secret
|
|
41
|
+
unless config.auth_strategy || (api_key && api_secret)
|
|
42
|
+
raise Lipwa::ConfigurationError,
|
|
43
|
+
"#{self.class} is missing api_key/api_secret — set them via .configure"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
auth = config.auth_strategy || AuthStrategies::BearerToken.new(
|
|
47
|
+
Auth.new(client_id: api_key, client_secret: api_secret,
|
|
48
|
+
token_url: config.token_url || TOKEN_URLS.fetch(config.env),
|
|
49
|
+
clock: config.clock || -> { Time.now })
|
|
50
|
+
)
|
|
51
|
+
HttpAdapter.new(base_url: config.base_url || BASE_URLS.fetch(config.env),
|
|
52
|
+
auth_strategy: auth,
|
|
53
|
+
timeout: config.timeout || global_config.default_timeout,
|
|
54
|
+
open_timeout: config.open_timeout, logger: config.logger || global_config.logger,
|
|
55
|
+
adapter: global_config.adapter)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def bank_transfer_request(params, idempotency_key)
|
|
59
|
+
http.post(PATHS.fetch(params[:rail]), body: transfer_body(params), idempotency_key: idempotency_key)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def bank_balance_request(params, idempotency_key)
|
|
63
|
+
http.post(PATHS[:balance], body: inquiry_body(params), idempotency_key: idempotency_key)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def bank_statement_request(params, idempotency_key)
|
|
67
|
+
body = inquiry_body(params)
|
|
68
|
+
body[:StartDate] = params[:from_date].iso8601 if params[:from_date]
|
|
69
|
+
body[:EndDate] = params[:to_date].iso8601 if params[:to_date]
|
|
70
|
+
http.post(PATHS[:statement], body: body, idempotency_key: idempotency_key)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def status_query_request(params, idempotency_key)
|
|
74
|
+
http.post(STATUS_PATH, body: { MessageReference: params[:message_reference] },
|
|
75
|
+
idempotency_key: idempotency_key)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def build_status_query_response(body)
|
|
79
|
+
code = body["MessageCode"]
|
|
80
|
+
Success(Lipwa::Response.new(success: code.to_s == "0",
|
|
81
|
+
provider_reference: body["MessageReference"]&.to_s,
|
|
82
|
+
message: body["MessageDescription"]&.to_s,
|
|
83
|
+
code: code&.to_s, raw: body))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def ensure_status_query_config_present!
|
|
87
|
+
# Co-op status queries use the gateway's normal OAuth credentials.
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def transfer_body(params)
|
|
91
|
+
common = { MessageReference: params[:reference], CallBackUrl: params[:callback_url] }.compact
|
|
92
|
+
if params[:rail] == :bill_payment
|
|
93
|
+
return common.merge(AccountNumber: params[:source_account], BillerCode: params[:biller_code],
|
|
94
|
+
BillAccountNumber: params[:destination_account], Amount: params[:amount].amount, Currency: params[:amount].currency, Narration: params[:narration] || params[:reference])
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
common.merge(Source: account_payload(params[:source_account], params),
|
|
98
|
+
Destinations: [account_payload(params[:destination_account], params).merge(
|
|
99
|
+
ReferenceNumber: params[:reference], BankCode: params[:destination_bank_code], BeneficiaryName: params[:destination_name]
|
|
100
|
+
).compact])
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def account_payload(account, params)
|
|
104
|
+
{ AccountNumber: account, Amount: params[:amount].amount, TransactionCurrency: params[:amount].currency,
|
|
105
|
+
Narration: params[:narration] || params[:reference] }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def inquiry_body(params)
|
|
109
|
+
{ MessageReference: params[:message_reference] || SecureRandom.uuid, AccountNumber: params[:account_number] }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
require_relative "coop_bank/auth"
|
|
115
|
+
Lipwa::Gateways.register(:coop_bank, Lipwa::Gateways::CoopBank)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "json"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require_relative "../gateway"
|
|
7
|
+
require_relative "../gateways"
|
|
8
|
+
require_relative "../capabilities/purchase"
|
|
9
|
+
require_relative "../capabilities/authorize"
|
|
10
|
+
require_relative "../capabilities/capture"
|
|
11
|
+
require_relative "../capabilities/void"
|
|
12
|
+
|
|
13
|
+
module Lipwa
|
|
14
|
+
module Gateways
|
|
15
|
+
# Flutterwave v3 hosted payments and preauthorized card charges.
|
|
16
|
+
# rubocop:disable Metrics/ClassLength
|
|
17
|
+
class Flutterwave < Lipwa::Gateway
|
|
18
|
+
include Lipwa::Capabilities::Purchase
|
|
19
|
+
include Lipwa::Capabilities::Authorize
|
|
20
|
+
include Lipwa::Capabilities::Capture
|
|
21
|
+
include Lipwa::Capabilities::Void
|
|
22
|
+
|
|
23
|
+
BASE_URL = "https://api.flutterwave.com"
|
|
24
|
+
PAYMENTS_PATH = "/v3/payments"
|
|
25
|
+
CHARGES_PATH = "/v3/charges"
|
|
26
|
+
|
|
27
|
+
setting :secret_key
|
|
28
|
+
setting :encryption_key
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def perform_purchase(params, idempotency_key)
|
|
33
|
+
response = http.post(PAYMENTS_PATH, body: hosted_payment_body(params), idempotency_key: idempotency_key)
|
|
34
|
+
build_flutterwave_response(response.body, fallback_reference: params[:reference])
|
|
35
|
+
rescue Lipwa::GatewayError => e
|
|
36
|
+
Failure(e)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def perform_authorize(params, idempotency_key)
|
|
40
|
+
missing = %i[card_number cvv expiry_month expiry_year].reject { |key| params[key] }
|
|
41
|
+
return missing_card_failure(missing) unless missing.empty?
|
|
42
|
+
|
|
43
|
+
return missing_encryption_key_failure unless config.encryption_key
|
|
44
|
+
|
|
45
|
+
response = http.post(CHARGES_PATH, params: { type: "card" },
|
|
46
|
+
body: encrypted_card_body(params), idempotency_key: idempotency_key)
|
|
47
|
+
build_flutterwave_response(response.body, fallback_reference: params[:reference])
|
|
48
|
+
rescue Lipwa::GatewayError => e
|
|
49
|
+
Failure(e)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def perform_capture(params, idempotency_key)
|
|
53
|
+
unless params[:amount]
|
|
54
|
+
return Failure(Lipwa::GatewayError.new("amount is required to capture a Flutterwave charge"))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
response = http.post(charge_path(params[:authorization], "capture"),
|
|
58
|
+
body: { amount: params[:amount].amount }, idempotency_key: idempotency_key)
|
|
59
|
+
build_flutterwave_response(response.body, fallback_reference: params[:authorization])
|
|
60
|
+
rescue Lipwa::GatewayError => e
|
|
61
|
+
Failure(e)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def perform_void(params, idempotency_key)
|
|
65
|
+
response = http.post(charge_path(params[:authorization], "void"), body: {},
|
|
66
|
+
idempotency_key: idempotency_key)
|
|
67
|
+
build_flutterwave_response(response.body, fallback_reference: params[:authorization])
|
|
68
|
+
rescue Lipwa::GatewayError => e
|
|
69
|
+
Failure(e)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# rubocop:disable Metrics/MethodLength
|
|
73
|
+
def hosted_payment_body(params)
|
|
74
|
+
address = params[:billing_address]
|
|
75
|
+
{
|
|
76
|
+
tx_ref: params[:reference], amount: params[:amount].amount, currency: params[:amount].currency,
|
|
77
|
+
redirect_url: params[:callback_url], payment_options: "card",
|
|
78
|
+
customer: {
|
|
79
|
+
email: address_value(address, :email_address),
|
|
80
|
+
phonenumber: address_value(address, :phone_number),
|
|
81
|
+
name: params[:fullname] || customer_name(address)
|
|
82
|
+
}.compact,
|
|
83
|
+
customizations: { title: params[:description], description: params[:description] }
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# rubocop:enable Metrics/MethodLength
|
|
88
|
+
# rubocop:disable Metrics/AbcSize
|
|
89
|
+
def card_charge_body(params)
|
|
90
|
+
address = params[:billing_address]
|
|
91
|
+
{
|
|
92
|
+
card_number: params[:card_number], cvv: params[:cvv], expiry_month: params[:expiry_month],
|
|
93
|
+
expiry_year: params[:expiry_year], amount: params[:amount].amount,
|
|
94
|
+
currency: params[:amount].currency, email: address_value(address, :email_address),
|
|
95
|
+
fullname: params[:fullname] || customer_name(address),
|
|
96
|
+
phone_number: params[:phone_number] || address_value(address, :phone_number),
|
|
97
|
+
tx_ref: params[:reference], redirect_url: params[:callback_url],
|
|
98
|
+
preauthorize: true, usesecureauth: params.fetch(:usesecureauth, true)
|
|
99
|
+
}.compact
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def encrypted_card_body(params)
|
|
103
|
+
cipher = OpenSSL::Cipher.new("des-ede3")
|
|
104
|
+
cipher.encrypt
|
|
105
|
+
cipher.key = config.encryption_key
|
|
106
|
+
encrypted = cipher.update(JSON.generate(card_charge_body(params))) + cipher.final
|
|
107
|
+
{ client: Base64.strict_encode64(encrypted) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# rubocop:enable Metrics/AbcSize
|
|
111
|
+
def customer_name(address)
|
|
112
|
+
name = [address_value(address, :first_name), address_value(address, :last_name)].compact.join(" ")
|
|
113
|
+
name unless name.empty?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def address_value(address, key) = address[key] || address[key.to_s]
|
|
117
|
+
def charge_path(reference, operation) = "#{CHARGES_PATH}/#{reference}/#{operation}"
|
|
118
|
+
|
|
119
|
+
def build_flutterwave_response(body, fallback_reference:)
|
|
120
|
+
data = body["data"].is_a?(Hash) ? body["data"] : {}
|
|
121
|
+
Success(Lipwa::Response.new(
|
|
122
|
+
success: body["status"] == "success",
|
|
123
|
+
provider_reference: (data["flw_ref"] || data["id"] || fallback_reference)&.to_s,
|
|
124
|
+
message: body["message"] || data["processor_response"],
|
|
125
|
+
code: body["status"]&.to_s,
|
|
126
|
+
raw: body
|
|
127
|
+
))
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def missing_card_failure(missing)
|
|
131
|
+
Failure(Lipwa::GatewayError.new("missing #{missing.join(", ")}"))
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def missing_encryption_key_failure
|
|
135
|
+
Failure(Lipwa::GatewayError.new("encryption_key is required to authorize a Flutterwave card"))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# rubocop:disable Metrics/AbcSize
|
|
139
|
+
def build_http_adapter
|
|
140
|
+
config = self.config
|
|
141
|
+
ensure_flutterwave_config_present!(config)
|
|
142
|
+
auth = config.auth_strategy || AuthStrategies::BearerToken.new(-> { config.secret_key })
|
|
143
|
+
HttpAdapter.new(base_url: config.base_url || BASE_URL, auth_strategy: auth,
|
|
144
|
+
timeout: config.timeout || global_config.default_timeout,
|
|
145
|
+
open_timeout: config.open_timeout, logger: config.logger || global_config.logger,
|
|
146
|
+
adapter: global_config.adapter)
|
|
147
|
+
end
|
|
148
|
+
# rubocop:enable Metrics/AbcSize
|
|
149
|
+
|
|
150
|
+
def ensure_flutterwave_config_present!(config)
|
|
151
|
+
return if config.auth_strategy || config.secret_key
|
|
152
|
+
|
|
153
|
+
raise Lipwa::ConfigurationError, "#{self.class} is missing secret_key — set it via .configure"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# rubocop:enable Metrics/ClassLength
|
|
160
|
+
Lipwa::Gateways.register(:flutterwave, Lipwa::Gateways::Flutterwave)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require_relative "../../errors"
|
|
7
|
+
|
|
8
|
+
module Lipwa
|
|
9
|
+
module Gateways
|
|
10
|
+
class Jenga
|
|
11
|
+
# Fetches and caches Jenga's merchant OAuth token.
|
|
12
|
+
class Auth
|
|
13
|
+
EXPIRY_BUFFER = 60
|
|
14
|
+
|
|
15
|
+
def initialize(api_key:, merchant_code:, consumer_secret:, token_url:, clock: -> { Time.now })
|
|
16
|
+
unless api_key && merchant_code && consumer_secret
|
|
17
|
+
raise Lipwa::ConfigurationError,
|
|
18
|
+
"Jenga::Auth requires api_key, merchant_code, and consumer_secret"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
@api_key = api_key
|
|
22
|
+
@merchant_code = merchant_code
|
|
23
|
+
@consumer_secret = consumer_secret
|
|
24
|
+
@token_url = token_url
|
|
25
|
+
@clock = clock
|
|
26
|
+
@mutex = Mutex.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize_copy(source)
|
|
30
|
+
super
|
|
31
|
+
@token = nil
|
|
32
|
+
@expires_at = nil
|
|
33
|
+
@mutex = Mutex.new
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def inspect
|
|
37
|
+
"#<#{self.class}:0x#{object_id.to_s(16)}>"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def call
|
|
41
|
+
return @token if fresh?
|
|
42
|
+
|
|
43
|
+
@mutex.synchronize { refresh! unless fresh? }
|
|
44
|
+
@token
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def fresh? = @token && @expires_at && @clock.call < @expires_at
|
|
50
|
+
|
|
51
|
+
def refresh!
|
|
52
|
+
response = Faraday.post(@token_url) do |request|
|
|
53
|
+
request.headers["Api-Key"] = @api_key
|
|
54
|
+
request.headers["Content-Type"] = "application/json"
|
|
55
|
+
request.body = JSON.generate(merchantCode: @merchant_code, consumerSecret: @consumer_secret)
|
|
56
|
+
end
|
|
57
|
+
body = JSON.parse(response.body)
|
|
58
|
+
unless response.success?
|
|
59
|
+
raise Lipwa::GatewayError.new("Jenga OAuth failed", code: response.status.to_s, raw: body)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
@token = body.fetch("accessToken")
|
|
63
|
+
@expires_at = expiry_time(body.fetch("expiresIn", 900))
|
|
64
|
+
rescue Faraday::Error, JSON::ParserError, KeyError, ArgumentError => e
|
|
65
|
+
raise Lipwa::GatewayError.new("unexpected Jenga OAuth response: #{e.message}",
|
|
66
|
+
raw: defined?(body) ? body : nil)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def expiry_time(value)
|
|
70
|
+
expiry = value.is_a?(Numeric) || value.to_s.match?(/\A\d+\z/) ? @clock.call + value.to_i : Time.parse(value)
|
|
71
|
+
expiry - EXPIRY_BUFFER
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "date"
|
|
5
|
+
require "json"
|
|
6
|
+
require_relative "../gateway"
|
|
7
|
+
require_relative "../gateways"
|
|
8
|
+
require_relative "../capabilities/bank_transfer"
|
|
9
|
+
require_relative "../capabilities/disbursement"
|
|
10
|
+
|
|
11
|
+
module Lipwa
|
|
12
|
+
module Gateways
|
|
13
|
+
# Equity/Finserve Jenga HQ gateway.
|
|
14
|
+
class Jenga < Lipwa::Gateway
|
|
15
|
+
include Lipwa::Capabilities::BankTransfer
|
|
16
|
+
include Lipwa::Capabilities::Disbursement
|
|
17
|
+
|
|
18
|
+
BASE_URLS = {
|
|
19
|
+
sandbox: "https://uat.finserve.africa/v3-apis",
|
|
20
|
+
production: "https://api.finserve.africa/v3-apis"
|
|
21
|
+
}.freeze
|
|
22
|
+
TOKEN_URLS = {
|
|
23
|
+
sandbox: "https://uat.finserve.africa/authentication/api/v3/authenticate/merchant",
|
|
24
|
+
production: "https://api.finserve.africa/authentication/api/v3/authenticate/merchant"
|
|
25
|
+
}.freeze
|
|
26
|
+
PATHS = {
|
|
27
|
+
internal: "/transaction-api/v3.0/remittance/internalBankTransfer",
|
|
28
|
+
pesalink: "/transaction-api/v3.0/remittance/pesalinkacc",
|
|
29
|
+
rtgs: "/transaction-api/v3.0/remittance/rtgs",
|
|
30
|
+
swift: "/transaction-api/v3.0/remittance/swift",
|
|
31
|
+
mobile: "/transaction-api/v3.0/remittance/sendmobile",
|
|
32
|
+
bill_payment: "/transaction-api/v3.0/bills/pay",
|
|
33
|
+
balance: "/account-api/v3.0/accounts/balances",
|
|
34
|
+
statement: "/account-api/v3.0/accounts/fullStatement",
|
|
35
|
+
forex: "/transaction-api/v3.0/foreignexchangerates"
|
|
36
|
+
}.freeze
|
|
37
|
+
TRANSFER_TYPES = {
|
|
38
|
+
internal: "EFT", pesalink: "PesaLink", rtgs: "RTGS", swift: "SWIFT"
|
|
39
|
+
}.freeze
|
|
40
|
+
|
|
41
|
+
setting :api_key
|
|
42
|
+
setting :merchant_code
|
|
43
|
+
setting :consumer_secret
|
|
44
|
+
setting :private_key
|
|
45
|
+
setting :source_account
|
|
46
|
+
setting :source_name
|
|
47
|
+
setting :country_code, default: "KE"
|
|
48
|
+
setting :partner_id
|
|
49
|
+
setting :token_url
|
|
50
|
+
setting :clock
|
|
51
|
+
|
|
52
|
+
def forex_rates(currency_code:, amount:, to_currency:, account_number: nil, country_code: nil, idempotency_key: nil)
|
|
53
|
+
body = {
|
|
54
|
+
accountNumber: account_number,
|
|
55
|
+
countryCode: country_code || config.country_code,
|
|
56
|
+
currencyCode: currency_code,
|
|
57
|
+
amount: format_amount(amount),
|
|
58
|
+
toCurrency: to_currency
|
|
59
|
+
}.compact
|
|
60
|
+
response = http.post(PATHS[:forex], body: body, idempotency_key: idempotency_key)
|
|
61
|
+
build_jenga_response(response.body)
|
|
62
|
+
rescue Lipwa::GatewayError => e
|
|
63
|
+
Failure(e)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def build_http_adapter
|
|
69
|
+
config = self.config
|
|
70
|
+
ensure_jenga_config_present!(config)
|
|
71
|
+
auth_strategy = config.auth_strategy || AuthStrategies::Jenga.new(
|
|
72
|
+
token_provider: Auth.new(
|
|
73
|
+
api_key: config.api_key,
|
|
74
|
+
merchant_code: config.merchant_code,
|
|
75
|
+
consumer_secret: config.consumer_secret,
|
|
76
|
+
token_url: config.token_url || TOKEN_URLS.fetch(config.env),
|
|
77
|
+
clock: config.clock || -> { Time.now }
|
|
78
|
+
),
|
|
79
|
+
private_key: config.private_key,
|
|
80
|
+
signature_payload: method(:signature_payload)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
HttpAdapter.new(
|
|
84
|
+
base_url: config.base_url || BASE_URLS.fetch(config.env),
|
|
85
|
+
auth_strategy: auth_strategy,
|
|
86
|
+
timeout: config.timeout || global_config.default_timeout,
|
|
87
|
+
open_timeout: config.open_timeout,
|
|
88
|
+
logger: config.logger || global_config.logger,
|
|
89
|
+
adapter: global_config.adapter
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def ensure_jenga_config_present!(config)
|
|
94
|
+
required = %i[source_account source_name]
|
|
95
|
+
required.concat(%i[api_key merchant_code consumer_secret private_key]) unless config.auth_strategy
|
|
96
|
+
missing = required.reject { |name| config.public_send(name) }
|
|
97
|
+
return if missing.empty?
|
|
98
|
+
|
|
99
|
+
raise Lipwa::ConfigurationError,
|
|
100
|
+
"#{self.class} is missing #{missing.join("/")} — set them via .configure"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def bank_transfer_request(params, idempotency_key)
|
|
104
|
+
path = PATHS.fetch(params[:rail])
|
|
105
|
+
body = params[:rail] == :bill_payment ? bill_payment_body(params) : bank_transfer_body(params)
|
|
106
|
+
http.post(path, body: body, idempotency_key: idempotency_key)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def bank_balance_request(params, idempotency_key)
|
|
110
|
+
country = config.country_code
|
|
111
|
+
http.get("#{PATHS[:balance]}/#{country}/#{params[:account_number]}", idempotency_key: idempotency_key)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def bank_statement_request(params, idempotency_key)
|
|
115
|
+
date = request_date
|
|
116
|
+
body = {
|
|
117
|
+
countryCode: config.country_code,
|
|
118
|
+
accountNumber: params[:account_number],
|
|
119
|
+
fromDate: (params[:from_date] || date).iso8601,
|
|
120
|
+
toDate: (params[:to_date] || date).iso8601
|
|
121
|
+
}
|
|
122
|
+
http.post(PATHS[:statement], body: body, idempotency_key: idempotency_key)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def bank_transfer_body(params)
|
|
126
|
+
{
|
|
127
|
+
source: source_payload(params[:source_account]),
|
|
128
|
+
destination: {
|
|
129
|
+
type: "bank",
|
|
130
|
+
countryCode: config.country_code,
|
|
131
|
+
name: params[:destination_name],
|
|
132
|
+
accountNumber: params[:destination_account],
|
|
133
|
+
bankCode: params[:destination_bank_code]
|
|
134
|
+
}.compact,
|
|
135
|
+
transfer: transfer_payload(params, TRANSFER_TYPES.fetch(params[:rail]))
|
|
136
|
+
}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def bill_payment_body(params)
|
|
140
|
+
{
|
|
141
|
+
biller: {
|
|
142
|
+
billerCode: params[:biller_code],
|
|
143
|
+
countryCode: config.country_code
|
|
144
|
+
},
|
|
145
|
+
bill: {
|
|
146
|
+
reference: params[:destination_account],
|
|
147
|
+
amount: format_amount(params[:amount].amount),
|
|
148
|
+
currency: params[:amount].currency
|
|
149
|
+
},
|
|
150
|
+
payer: {
|
|
151
|
+
name: config.source_name,
|
|
152
|
+
accountNumber: params[:source_account],
|
|
153
|
+
reference: params[:reference]
|
|
154
|
+
},
|
|
155
|
+
partnerId: config.partner_id || config.merchant_code
|
|
156
|
+
}
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def transfer_payload(params, type)
|
|
160
|
+
{
|
|
161
|
+
type: type,
|
|
162
|
+
amount: format_amount(params[:amount].amount),
|
|
163
|
+
currencyCode: params[:amount].currency,
|
|
164
|
+
reference: params[:reference],
|
|
165
|
+
date: request_date.iso8601,
|
|
166
|
+
description: params[:narration] || params[:reference],
|
|
167
|
+
callbackUrl: params[:callback_url]
|
|
168
|
+
}.compact
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Provider-specific implementation behind the shared Disbursement API.
|
|
172
|
+
def perform_disburse(params, idempotency_key)
|
|
173
|
+
response = http.post(PATHS[:mobile], body: mobile_money_body(params), idempotency_key: idempotency_key)
|
|
174
|
+
build_jenga_response(response.body)
|
|
175
|
+
rescue Lipwa::GatewayError => e
|
|
176
|
+
Failure(e)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def ensure_disbursement_config_present!
|
|
180
|
+
ensure_jenga_config_present!(config)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def mobile_money_body(params)
|
|
184
|
+
reference = params[:account_reference] || params[:occasion] || SecureRandom.uuid
|
|
185
|
+
{
|
|
186
|
+
source: source_payload(config.source_account),
|
|
187
|
+
destination: {
|
|
188
|
+
type: "mobile",
|
|
189
|
+
countryCode: config.country_code,
|
|
190
|
+
name: params[:party_b],
|
|
191
|
+
mobileNumber: params[:party_b],
|
|
192
|
+
walletName: "Mpesa"
|
|
193
|
+
},
|
|
194
|
+
transfer: {
|
|
195
|
+
type: "MobileWallet",
|
|
196
|
+
amount: format_amount(params[:amount].amount),
|
|
197
|
+
currencyCode: params[:amount].currency,
|
|
198
|
+
reference: reference,
|
|
199
|
+
date: request_date.iso8601,
|
|
200
|
+
description: params[:remarks],
|
|
201
|
+
callbackUrl: params[:result_url]
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def source_payload(account)
|
|
207
|
+
{
|
|
208
|
+
countryCode: config.country_code,
|
|
209
|
+
name: config.source_name,
|
|
210
|
+
accountNumber: account
|
|
211
|
+
}
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def signature_payload(env)
|
|
215
|
+
body = env.body.to_s.empty? ? {} : JSON.parse(env.body)
|
|
216
|
+
path = env.url.path
|
|
217
|
+
case path
|
|
218
|
+
when /sendmobile\z/
|
|
219
|
+
join_fields(body, %w[transfer.amount transfer.currencyCode transfer.reference source.accountNumber])
|
|
220
|
+
when /internalBankTransfer\z/
|
|
221
|
+
join_fields(body, %w[source.accountNumber transfer.amount transfer.currencyCode transfer.reference])
|
|
222
|
+
when /pesalinkacc\z/
|
|
223
|
+
join_fields(body,
|
|
224
|
+
%w[transfer.amount transfer.currencyCode transfer.reference destination.name
|
|
225
|
+
source.accountNumber])
|
|
226
|
+
when /(?:rtgs|swift)\z/
|
|
227
|
+
join_fields(body,
|
|
228
|
+
%w[transfer.reference transfer.date source.accountNumber destination.accountNumber
|
|
229
|
+
transfer.amount])
|
|
230
|
+
when %r{bills/pay\z}
|
|
231
|
+
join_fields(body, %w[biller.billerCode bill.amount payer.reference partnerId])
|
|
232
|
+
when /fullStatement\z/
|
|
233
|
+
join_fields(body, %w[accountNumber countryCode toDate])
|
|
234
|
+
when /foreignexchangerates\z/
|
|
235
|
+
join_fields(body, %w[countryCode currencyCode amount toCurrency])
|
|
236
|
+
when %r{accounts/balances/([^/]+)/([^/]+)\z}
|
|
237
|
+
"#{Regexp.last_match(1)}#{Regexp.last_match(2)}"
|
|
238
|
+
else
|
|
239
|
+
raise Lipwa::ConfigurationError, "no Jenga signature formula for #{path}"
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def join_fields(body, fields)
|
|
244
|
+
fields.map do |field|
|
|
245
|
+
field.split(".").reduce(body) { |value, key| value.fetch(key) }
|
|
246
|
+
end.join
|
|
247
|
+
rescue KeyError => e
|
|
248
|
+
raise Lipwa::ConfigurationError, "missing Jenga signature field: #{e.key}"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def build_bank_response(body) = build_jenga_response(body)
|
|
252
|
+
|
|
253
|
+
def build_jenga_response(body)
|
|
254
|
+
data = body["data"].is_a?(Hash) ? body["data"] : {}
|
|
255
|
+
code = body["code"] || body["statusCode"] || data["code"]
|
|
256
|
+
reference = body["reference"] || body["transactionId"] ||
|
|
257
|
+
data["reference"] || data["transactionId"]
|
|
258
|
+
message = body["message"] || body["description"] || data["message"]
|
|
259
|
+
success = body["status"] != false && (code.nil? || %w[0 00 200 201 SUCCESS Success].include?(code.to_s))
|
|
260
|
+
Success(Lipwa::Response.new(
|
|
261
|
+
success: success,
|
|
262
|
+
provider_reference: reference&.to_s,
|
|
263
|
+
message: message&.to_s,
|
|
264
|
+
code: code&.to_s,
|
|
265
|
+
raw: body
|
|
266
|
+
))
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def request_date
|
|
270
|
+
value = config.clock&.call || Date.today
|
|
271
|
+
value.respond_to?(:to_date) ? value.to_date : value
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def format_amount(value)
|
|
275
|
+
format("%.2f", value)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
require_relative "jenga/auth"
|
|
282
|
+
Lipwa::Gateways.register(:jenga, Lipwa::Gateways::Jenga)
|