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,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../gateway"
|
|
4
|
+
require_relative "../gateways"
|
|
5
|
+
require_relative "../capabilities/lightning_invoice"
|
|
6
|
+
require_relative "../capabilities/lightning_payment"
|
|
7
|
+
|
|
8
|
+
module Lipwa
|
|
9
|
+
module Gateways
|
|
10
|
+
# LNbits gateway. Each wallet supplies an instance URL and a scoped
|
|
11
|
+
# invoice/read key capable of creating and checking invoices.
|
|
12
|
+
class Lnbits < Lipwa::Gateway
|
|
13
|
+
include Lipwa::Capabilities::LightningInvoice
|
|
14
|
+
include Lipwa::Capabilities::LightningPayment
|
|
15
|
+
|
|
16
|
+
setting :invoice_key
|
|
17
|
+
setting :admin_key
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def lightning_payment_http
|
|
22
|
+
@lightning_payment_http ||= build_admin_http_adapter
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def build_admin_http_adapter
|
|
26
|
+
config = self.config
|
|
27
|
+
ensure_lnbits_admin_config_present!(config)
|
|
28
|
+
|
|
29
|
+
HttpAdapter.new(**http_adapter_options(config, config.admin_key))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def build_http_adapter
|
|
33
|
+
config = self.config
|
|
34
|
+
ensure_lnbits_config_present!(config)
|
|
35
|
+
|
|
36
|
+
HttpAdapter.new(**http_adapter_options(config, config.invoice_key))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def http_adapter_options(config, api_key)
|
|
40
|
+
{
|
|
41
|
+
base_url: config.base_url,
|
|
42
|
+
auth_strategy: AuthStrategies::ApiKey.new(api_key),
|
|
43
|
+
timeout: config.timeout || global_config.default_timeout,
|
|
44
|
+
open_timeout: config.open_timeout,
|
|
45
|
+
logger: config.logger || global_config.logger,
|
|
46
|
+
adapter: global_config.adapter
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def ensure_lnbits_config_present!(config)
|
|
51
|
+
return if config.base_url && config.invoice_key
|
|
52
|
+
|
|
53
|
+
raise Lipwa::ConfigurationError,
|
|
54
|
+
"#{self.class} is missing base_url/invoice_key — set them via .configure"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def ensure_lnbits_admin_config_present!(config)
|
|
58
|
+
return if config.base_url && config.admin_key
|
|
59
|
+
|
|
60
|
+
raise Lipwa::ConfigurationError,
|
|
61
|
+
"#{self.class} is missing base_url/admin_key — set them via .configure"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
Lipwa::Gateways.register(:lnbits, Lipwa::Gateways::Lnbits)
|
|
@@ -31,22 +31,38 @@ module Lipwa
|
|
|
31
31
|
# token that goes stale mid-flight.
|
|
32
32
|
EXPIRY_BUFFER = 60
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
# rubocop:disable Metrics/ParameterLists
|
|
35
|
+
def initialize(consumer_key:, consumer_secret:, env: :sandbox, base_url: nil, http: nil, clock: -> { Time.now })
|
|
35
36
|
ensure_credentials_present!(consumer_key, consumer_secret)
|
|
36
37
|
|
|
37
38
|
@consumer_key = consumer_key
|
|
38
39
|
@consumer_secret = consumer_secret
|
|
39
40
|
@clock = clock
|
|
40
|
-
@http = http || Lipwa::HttpAdapter.new(base_url: base_url_for(env))
|
|
41
|
+
@http = http || Lipwa::HttpAdapter.new(base_url: base_url || base_url_for(env))
|
|
41
42
|
@mutex = Mutex.new
|
|
42
43
|
@token = nil
|
|
43
44
|
@expires_at = nil
|
|
44
45
|
end
|
|
46
|
+
# rubocop:enable Metrics/ParameterLists
|
|
45
47
|
|
|
46
48
|
# Returns a cached access token, or fetches (and caches) a new
|
|
47
49
|
# one if there's none yet or the cached one is about to expire.
|
|
48
50
|
# Safe to call concurrently: only one refresh happens in flight,
|
|
49
51
|
# other callers block on it and reuse its result.
|
|
52
|
+
def initialize_copy(source)
|
|
53
|
+
super
|
|
54
|
+
@token = nil
|
|
55
|
+
@expires_at = nil
|
|
56
|
+
@mutex = Mutex.new
|
|
57
|
+
@http = Lipwa::HttpAdapter.new(base_url: @http.base_url, timeout: @http.timeout,
|
|
58
|
+
open_timeout: @http.open_timeout, logger: @http.logger,
|
|
59
|
+
adapter: @http.adapter)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def inspect
|
|
63
|
+
"#<#{self.class}:0x#{object_id.to_s(16)}>"
|
|
64
|
+
end
|
|
65
|
+
|
|
50
66
|
def call
|
|
51
67
|
return @token if fresh?
|
|
52
68
|
|
data/lib/lipwa/gateways/mpesa.rb
CHANGED
|
@@ -11,8 +11,8 @@ require_relative "../capabilities/refund"
|
|
|
11
11
|
module Lipwa
|
|
12
12
|
module Gateways
|
|
13
13
|
# Safaricom Daraja gateway. base_url and auth_strategy are derived
|
|
14
|
-
# automatically from env/consumer_key/consumer_secret
|
|
15
|
-
#
|
|
14
|
+
# automatically from env/consumer_key/consumer_secret unless explicitly
|
|
15
|
+
# overridden, since Daraja's OAuth + sandbox/production hosts are fixed
|
|
16
16
|
# per environment.
|
|
17
17
|
class Mpesa < Lipwa::Gateway
|
|
18
18
|
include Lipwa::Capabilities::C2B
|
|
@@ -28,33 +28,38 @@ module Lipwa
|
|
|
28
28
|
setting :initiator_name
|
|
29
29
|
setting :initiator_password
|
|
30
30
|
setting :security_credential_cert
|
|
31
|
+
setting :clock
|
|
31
32
|
|
|
32
33
|
private
|
|
33
34
|
|
|
34
35
|
def build_http_adapter
|
|
35
|
-
config = self.
|
|
36
|
+
config = self.config
|
|
36
37
|
ensure_mpesa_config_present!(config)
|
|
37
38
|
|
|
38
39
|
HttpAdapter.new(**http_adapter_options(config))
|
|
39
40
|
end
|
|
40
41
|
|
|
41
42
|
def http_adapter_options(config)
|
|
43
|
+
base_url = config.base_url || Auth::BASE_URLS.fetch(config.env)
|
|
42
44
|
{
|
|
43
|
-
base_url:
|
|
44
|
-
auth_strategy: AuthStrategies::BearerToken.new(build_auth(config)),
|
|
45
|
-
timeout: config.timeout ||
|
|
45
|
+
base_url: base_url,
|
|
46
|
+
auth_strategy: config.auth_strategy || AuthStrategies::BearerToken.new(build_auth(config, base_url)),
|
|
47
|
+
timeout: config.timeout || global_config.default_timeout,
|
|
46
48
|
open_timeout: config.open_timeout,
|
|
47
|
-
logger: config.logger ||
|
|
48
|
-
adapter:
|
|
49
|
+
logger: config.logger || global_config.logger,
|
|
50
|
+
adapter: global_config.adapter
|
|
49
51
|
}
|
|
50
52
|
end
|
|
51
53
|
|
|
52
|
-
def build_auth(config)
|
|
53
|
-
Auth.new(consumer_key: config.consumer_key, consumer_secret: config.consumer_secret,
|
|
54
|
+
def build_auth(config, base_url)
|
|
55
|
+
Auth.new(consumer_key: config.consumer_key, consumer_secret: config.consumer_secret,
|
|
56
|
+
env: config.env, base_url: base_url,
|
|
57
|
+
clock: config.clock || -> { Time.now })
|
|
54
58
|
end
|
|
55
59
|
|
|
56
60
|
def ensure_mpesa_config_present!(config)
|
|
57
|
-
return if config.
|
|
61
|
+
return if config.shortcode && config.passkey &&
|
|
62
|
+
(config.auth_strategy || (config.consumer_key && config.consumer_secret))
|
|
58
63
|
|
|
59
64
|
raise Lipwa::ConfigurationError,
|
|
60
65
|
"#{self.class} is missing consumer_key/consumer_secret/shortcode/passkey — set them via .configure"
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../gateway"
|
|
4
|
+
require_relative "../gateways"
|
|
5
|
+
require_relative "../capabilities/purchase"
|
|
6
|
+
require_relative "../capabilities/authorize"
|
|
7
|
+
require_relative "../capabilities/capture"
|
|
8
|
+
require_relative "../capabilities/void"
|
|
9
|
+
|
|
10
|
+
module Lipwa
|
|
11
|
+
module Gateways
|
|
12
|
+
# Paystack hosted card payments. Paystack captures card payments in its
|
|
13
|
+
# checkout flow, so capture verifies the transaction and void refunds it.
|
|
14
|
+
class Paystack < Lipwa::Gateway
|
|
15
|
+
include Lipwa::Capabilities::Purchase
|
|
16
|
+
include Lipwa::Capabilities::Authorize
|
|
17
|
+
include Lipwa::Capabilities::Capture
|
|
18
|
+
include Lipwa::Capabilities::Void
|
|
19
|
+
|
|
20
|
+
BASE_URL = "https://api.paystack.co"
|
|
21
|
+
INITIALIZE_PATH = "/transaction/initialize"
|
|
22
|
+
VERIFY_PATH = "/transaction/verify"
|
|
23
|
+
REFUND_PATH = "/refund"
|
|
24
|
+
SUBUNIT_FACTOR = 100
|
|
25
|
+
|
|
26
|
+
setting :secret_key
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def perform_purchase(params, idempotency_key) = initialize_transaction(params, idempotency_key)
|
|
31
|
+
def perform_authorize(params, idempotency_key) = initialize_transaction(params, idempotency_key)
|
|
32
|
+
|
|
33
|
+
def perform_capture(params, idempotency_key)
|
|
34
|
+
response = http.get("#{VERIFY_PATH}/#{params[:authorization]}", idempotency_key: idempotency_key)
|
|
35
|
+
build_response(response.body, fallback_reference: params[:authorization], require_completed: true)
|
|
36
|
+
rescue Lipwa::GatewayError => e
|
|
37
|
+
Failure(e)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def perform_void(params, idempotency_key)
|
|
41
|
+
response = http.post(REFUND_PATH, body: { transaction: params[:authorization] },
|
|
42
|
+
idempotency_key: idempotency_key)
|
|
43
|
+
build_response(response.body, fallback_reference: params[:authorization])
|
|
44
|
+
rescue Lipwa::GatewayError => e
|
|
45
|
+
Failure(e)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def initialize_transaction(params, idempotency_key)
|
|
49
|
+
email = address_value(params[:billing_address], :email_address)
|
|
50
|
+
return Failure(Lipwa::GatewayError.new("billing_address email_address is required by Paystack")) unless email
|
|
51
|
+
|
|
52
|
+
response = http.post(INITIALIZE_PATH, body: initialize_body(params, email),
|
|
53
|
+
idempotency_key: idempotency_key)
|
|
54
|
+
build_response(response.body, fallback_reference: params[:reference])
|
|
55
|
+
rescue Lipwa::GatewayError => e
|
|
56
|
+
Failure(e)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def initialize_body(params, email)
|
|
60
|
+
{
|
|
61
|
+
email: email,
|
|
62
|
+
amount: paystack_amount(params[:amount]),
|
|
63
|
+
currency: params[:amount].currency,
|
|
64
|
+
reference: params[:reference],
|
|
65
|
+
callback_url: params[:callback_url],
|
|
66
|
+
channels: ["card"],
|
|
67
|
+
metadata: { description: params[:description], notification_id: params[:notification_id] }
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def paystack_amount(money)
|
|
72
|
+
subunits = money.amount * SUBUNIT_FACTOR
|
|
73
|
+
raise Lipwa::GatewayError, "amount must resolve to a whole Paystack subunit" unless subunits.frac.zero?
|
|
74
|
+
|
|
75
|
+
subunits.to_i
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def address_value(address, key) = address[key] || address[key.to_s]
|
|
79
|
+
|
|
80
|
+
def build_response(body, fallback_reference:, require_completed: false)
|
|
81
|
+
data = body["data"].is_a?(Hash) ? body["data"] : {}
|
|
82
|
+
Success(Lipwa::Response.new(
|
|
83
|
+
success: response_successful?(body, data, require_completed),
|
|
84
|
+
provider_reference: (data["reference"] || fallback_reference)&.to_s,
|
|
85
|
+
message: body["message"] || data["status"],
|
|
86
|
+
code: (data["status"] || body["status"])&.to_s,
|
|
87
|
+
raw: body
|
|
88
|
+
))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def response_successful?(body, data, require_completed)
|
|
92
|
+
return false unless body["status"] == true
|
|
93
|
+
return true unless require_completed
|
|
94
|
+
|
|
95
|
+
data["status"].to_s.casecmp?("success")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# rubocop:disable Metrics/AbcSize
|
|
99
|
+
def build_http_adapter
|
|
100
|
+
config = self.config
|
|
101
|
+
ensure_paystack_config_present!(config)
|
|
102
|
+
auth = config.auth_strategy || AuthStrategies::BearerToken.new(-> { config.secret_key })
|
|
103
|
+
HttpAdapter.new(base_url: config.base_url || BASE_URL, auth_strategy: auth,
|
|
104
|
+
timeout: config.timeout || global_config.default_timeout,
|
|
105
|
+
open_timeout: config.open_timeout, logger: config.logger || global_config.logger,
|
|
106
|
+
adapter: global_config.adapter)
|
|
107
|
+
end
|
|
108
|
+
# rubocop:enable Metrics/AbcSize
|
|
109
|
+
|
|
110
|
+
def ensure_paystack_config_present!(config)
|
|
111
|
+
return if config.auth_strategy || config.secret_key
|
|
112
|
+
|
|
113
|
+
raise Lipwa::ConfigurationError, "#{self.class} is missing secret_key — set it via .configure"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
Lipwa::Gateways.register(:paystack, Lipwa::Gateways::Paystack)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "../../errors"
|
|
6
|
+
|
|
7
|
+
module Lipwa
|
|
8
|
+
module Gateways
|
|
9
|
+
class Pesapal
|
|
10
|
+
# Fetches and briefly caches Pesapal API bearer tokens.
|
|
11
|
+
class Auth
|
|
12
|
+
TOKEN_PATH = "/api/Auth/RequestToken"
|
|
13
|
+
|
|
14
|
+
def initialize(consumer_key:, consumer_secret:, base_url:, clock: -> { Time.now })
|
|
15
|
+
@consumer_key = consumer_key
|
|
16
|
+
@consumer_secret = consumer_secret
|
|
17
|
+
@base_url = base_url
|
|
18
|
+
@clock = clock
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize_copy(source)
|
|
23
|
+
super
|
|
24
|
+
@token = nil
|
|
25
|
+
@expires_at = nil
|
|
26
|
+
@mutex = Mutex.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def inspect
|
|
30
|
+
"#<#{self.class}:0x#{object_id.to_s(16)}>"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def call
|
|
34
|
+
return @token if @token && @expires_at && @clock.call < @expires_at
|
|
35
|
+
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
fetch_token unless @token && @expires_at && @clock.call < @expires_at
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
@token
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
46
|
+
def fetch_token
|
|
47
|
+
response = Faraday.post("#{@base_url}#{TOKEN_PATH}") do |request|
|
|
48
|
+
request.headers["Accept"] = "application/json"
|
|
49
|
+
request.headers["Content-Type"] = "application/json"
|
|
50
|
+
request.body = JSON.generate(consumer_key: @consumer_key, consumer_secret: @consumer_secret)
|
|
51
|
+
end
|
|
52
|
+
body = JSON.parse(response.body)
|
|
53
|
+
token = body["token"]
|
|
54
|
+
raise_auth_error(response, body) unless response.success? && token
|
|
55
|
+
|
|
56
|
+
@token = token
|
|
57
|
+
@expires_at = @clock.call + 270
|
|
58
|
+
@token
|
|
59
|
+
rescue Faraday::Error, JSON::ParserError => e
|
|
60
|
+
raise Lipwa::GatewayError.new("Pesapal authentication failed: #{e.message}", raw: e)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
64
|
+
def raise_auth_error(response, body)
|
|
65
|
+
error = body["error"].is_a?(Hash) ? body["error"] : {}
|
|
66
|
+
message = error["message"] || body["message"] || "Pesapal authentication failed"
|
|
67
|
+
raise Lipwa::GatewayError.new(message, code: (error["code"] || response.status).to_s, raw: body)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../gateway"
|
|
4
|
+
require_relative "../gateways"
|
|
5
|
+
require_relative "../capabilities/purchase"
|
|
6
|
+
require_relative "../capabilities/authorize"
|
|
7
|
+
require_relative "../capabilities/capture"
|
|
8
|
+
require_relative "../capabilities/void"
|
|
9
|
+
|
|
10
|
+
module Lipwa
|
|
11
|
+
module Gateways
|
|
12
|
+
# Pesapal API 3.0 hosted-checkout gateway.
|
|
13
|
+
class Pesapal < Lipwa::Gateway
|
|
14
|
+
include Lipwa::Capabilities::Purchase
|
|
15
|
+
include Lipwa::Capabilities::Authorize
|
|
16
|
+
include Lipwa::Capabilities::Capture
|
|
17
|
+
include Lipwa::Capabilities::Void
|
|
18
|
+
|
|
19
|
+
BASE_URLS = {
|
|
20
|
+
sandbox: "https://cybqa.pesapal.com/pesapalv3",
|
|
21
|
+
production: "https://pay.pesapal.com/v3"
|
|
22
|
+
}.freeze
|
|
23
|
+
SUBMIT_ORDER_PATH = "/api/Transactions/SubmitOrderRequest"
|
|
24
|
+
STATUS_PATH = "/api/Transactions/GetTransactionStatus"
|
|
25
|
+
CANCEL_PATH = "/api/Transactions/CancelOrder"
|
|
26
|
+
|
|
27
|
+
setting :consumer_key
|
|
28
|
+
setting :consumer_secret
|
|
29
|
+
setting :clock
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def perform_purchase(params, idempotency_key) = submit_order(params, idempotency_key)
|
|
34
|
+
def perform_authorize(params, idempotency_key) = submit_order(params, idempotency_key)
|
|
35
|
+
|
|
36
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
37
|
+
def perform_capture(params, idempotency_key)
|
|
38
|
+
response = http.get(STATUS_PATH, params: { orderTrackingId: params[:authorization] },
|
|
39
|
+
idempotency_key: idempotency_key)
|
|
40
|
+
body = response.body
|
|
41
|
+
completed = body["payment_status_description"].to_s.casecmp?("COMPLETED") ||
|
|
42
|
+
body["status_code"].to_i == 1
|
|
43
|
+
Success(Lipwa::Response.new(
|
|
44
|
+
success: completed,
|
|
45
|
+
provider_reference: body["confirmation_code"] || params[:authorization],
|
|
46
|
+
message: body["payment_status_description"] || pesapal_message(body),
|
|
47
|
+
code: (body["status_code"] || body["status"])&.to_s,
|
|
48
|
+
raw: body
|
|
49
|
+
))
|
|
50
|
+
rescue Lipwa::GatewayError => e
|
|
51
|
+
Failure(e)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
55
|
+
def perform_void(params, idempotency_key)
|
|
56
|
+
response = http.post(CANCEL_PATH, body: { order_tracking_id: params[:authorization] },
|
|
57
|
+
idempotency_key: idempotency_key)
|
|
58
|
+
build_response(response.body, fallback_reference: params[:authorization])
|
|
59
|
+
rescue Lipwa::GatewayError => e
|
|
60
|
+
Failure(e)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def submit_order(params, idempotency_key)
|
|
64
|
+
response = http.post(SUBMIT_ORDER_PATH, body: order_body(params), idempotency_key: idempotency_key)
|
|
65
|
+
build_response(response.body)
|
|
66
|
+
rescue Lipwa::GatewayError => e
|
|
67
|
+
Failure(e)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def order_body(params)
|
|
71
|
+
{
|
|
72
|
+
id: params[:reference], currency: params[:amount].currency, amount: params[:amount].amount,
|
|
73
|
+
description: params[:description], callback_url: params[:callback_url],
|
|
74
|
+
notification_id: params[:notification_id], billing_address: params[:billing_address],
|
|
75
|
+
cancellation_url: params[:cancellation_url], redirect_mode: params[:redirect_mode],
|
|
76
|
+
branch: params[:branch]
|
|
77
|
+
}.compact
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def build_response(body, fallback_reference: nil)
|
|
81
|
+
status = body["status"].to_s
|
|
82
|
+
error = pesapal_error(body)
|
|
83
|
+
Success(Lipwa::Response.new(
|
|
84
|
+
success: error.empty? && status.start_with?("2"),
|
|
85
|
+
provider_reference: body["order_tracking_id"] || fallback_reference,
|
|
86
|
+
message: body["message"] || error["message"],
|
|
87
|
+
code: status.empty? ? error["code"]&.to_s : status,
|
|
88
|
+
raw: body
|
|
89
|
+
))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def pesapal_error(body) = body["error"].is_a?(Hash) ? body["error"] : {}
|
|
93
|
+
def pesapal_message(body) = pesapal_error(body)["message"]
|
|
94
|
+
|
|
95
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
96
|
+
def build_http_adapter
|
|
97
|
+
config = self.config
|
|
98
|
+
ensure_pesapal_config_present!(config)
|
|
99
|
+
base_url = config.base_url || BASE_URLS.fetch(config.env)
|
|
100
|
+
auth = config.auth_strategy || AuthStrategies::BearerToken.new(
|
|
101
|
+
Auth.new(consumer_key: config.consumer_key, consumer_secret: config.consumer_secret,
|
|
102
|
+
base_url: base_url, clock: config.clock || -> { Time.now })
|
|
103
|
+
)
|
|
104
|
+
HttpAdapter.new(base_url: base_url, auth_strategy: auth,
|
|
105
|
+
timeout: config.timeout || global_config.default_timeout,
|
|
106
|
+
open_timeout: config.open_timeout, logger: config.logger || global_config.logger,
|
|
107
|
+
adapter: global_config.adapter)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
111
|
+
def ensure_pesapal_config_present!(config)
|
|
112
|
+
return if config.auth_strategy || (config.consumer_key && config.consumer_secret)
|
|
113
|
+
|
|
114
|
+
raise Lipwa::ConfigurationError,
|
|
115
|
+
"#{self.class} is missing consumer_key/consumer_secret — set them via .configure"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
require_relative "pesapal/auth"
|
|
122
|
+
|
|
123
|
+
Lipwa::Gateways.register(:pesapal, Lipwa::Gateways::Pesapal)
|
data/lib/lipwa/gateways.rb
CHANGED
|
@@ -18,7 +18,13 @@ module Lipwa
|
|
|
18
18
|
raise Lipwa::ConfigurationError, "#{gateway_class} must be a subclass of Lipwa::Gateway"
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
super(key.to_sym, memoize: true) { gateway_class.new }
|
|
21
|
+
super(key.to_sym, memoize: true) { gateway_class.new }.tap do
|
|
22
|
+
(@gateway_classes ||= {})[key.to_s] = gateway_class
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def gateway_classes
|
|
27
|
+
(@gateway_classes || {}).dup
|
|
22
28
|
end
|
|
23
29
|
end
|
|
24
30
|
end
|
data/lib/lipwa/http_adapter.rb
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "bigdecimal"
|
|
3
4
|
require "faraday"
|
|
4
5
|
require "faraday/retry"
|
|
6
|
+
require "json"
|
|
5
7
|
require_relative "errors"
|
|
6
8
|
require_relative "auth_strategies"
|
|
9
|
+
require_relative "logging"
|
|
7
10
|
|
|
8
11
|
module Lipwa
|
|
9
12
|
# Thin wrapper around Faraday shared by every gateway's HTTP calls:
|
|
@@ -12,17 +15,28 @@ module Lipwa
|
|
|
12
15
|
# bearer tokens (M-Pesa, Co-op) and request-signing (Jenga) plug into
|
|
13
16
|
# the same request path without HttpAdapter knowing which is which.
|
|
14
17
|
class HttpAdapter
|
|
18
|
+
IDEMPOTENCY_HEADER = "Idempotency-Key"
|
|
19
|
+
RETRYABLE_METHODS = %i[get head options].freeze
|
|
20
|
+
RETRYABLE_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
21
|
+
RETRYABLE_EXCEPTIONS = Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS.freeze
|
|
22
|
+
RETRY_IF_IDEMPOTENT = lambda do |env, _exception|
|
|
23
|
+
value = env.request_headers[IDEMPOTENCY_HEADER]
|
|
24
|
+
!value.nil? && !value.empty?
|
|
25
|
+
end
|
|
26
|
+
|
|
15
27
|
DEFAULT_RETRY_OPTIONS = {
|
|
16
28
|
max: 2,
|
|
17
29
|
interval: 0.5,
|
|
18
30
|
interval_randomness: 0.5,
|
|
19
31
|
backoff_factor: 2,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
32
|
+
max_interval: 5,
|
|
33
|
+
retry_statuses: RETRYABLE_STATUSES,
|
|
34
|
+
methods: RETRYABLE_METHODS,
|
|
35
|
+
exceptions: RETRYABLE_EXCEPTIONS,
|
|
36
|
+
retry_if: RETRY_IF_IDEMPOTENT
|
|
23
37
|
}.freeze
|
|
24
38
|
|
|
25
|
-
attr_reader :base_url, :auth_strategy, :timeout, :open_timeout, :logger, :adapter
|
|
39
|
+
attr_reader :base_url, :auth_strategy, :timeout, :open_timeout, :logger, :adapter, :retry_options
|
|
26
40
|
|
|
27
41
|
# rubocop:disable Metrics/ParameterLists
|
|
28
42
|
def initialize(base_url:, auth_strategy: AuthStrategies::None.new, timeout: 10,
|
|
@@ -33,22 +47,28 @@ module Lipwa
|
|
|
33
47
|
@timeout = timeout
|
|
34
48
|
@open_timeout = open_timeout
|
|
35
49
|
@logger = logger
|
|
36
|
-
@retry_options = DEFAULT_RETRY_OPTIONS.merge(retry_options)
|
|
50
|
+
@retry_options = DEFAULT_RETRY_OPTIONS.merge(retry_options).freeze
|
|
37
51
|
@stubs = stubs
|
|
38
52
|
@adapter = adapter
|
|
39
53
|
end
|
|
40
54
|
# rubocop:enable Metrics/ParameterLists
|
|
41
55
|
|
|
42
|
-
def get(path, params: {}, headers: {})
|
|
43
|
-
request(:get, path, params: params, headers: headers)
|
|
56
|
+
def get(path, params: {}, headers: {}, idempotency_key: nil)
|
|
57
|
+
request(:get, path, params: params, headers: idempotency_headers(headers, idempotency_key))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def post(path, body: nil, params: {}, headers: {}, idempotency_key: nil)
|
|
61
|
+
request(:post, path, body: body, params: params, headers: idempotency_headers(headers, idempotency_key))
|
|
44
62
|
end
|
|
45
63
|
|
|
46
|
-
def
|
|
47
|
-
request(:
|
|
64
|
+
def put(path, body: nil, params: {}, headers: {}, idempotency_key: nil)
|
|
65
|
+
request(:put, path, body: body, params: params, headers: idempotency_headers(headers, idempotency_key))
|
|
48
66
|
end
|
|
49
67
|
|
|
50
|
-
|
|
51
|
-
|
|
68
|
+
# Auth strategies may own API keys or OAuth clients. Avoid recursively
|
|
69
|
+
# exposing them through the adapter's default instance-variable dump.
|
|
70
|
+
def inspect
|
|
71
|
+
"#<#{self.class}:0x#{object_id.to_s(16)}>"
|
|
52
72
|
end
|
|
53
73
|
|
|
54
74
|
private
|
|
@@ -65,7 +85,28 @@ module Lipwa
|
|
|
65
85
|
req.url(path)
|
|
66
86
|
req.params.update(params) if params && !params.empty?
|
|
67
87
|
req.headers.update(headers)
|
|
68
|
-
req.body = body if body
|
|
88
|
+
req.body = json_safe_decimals(body) if body
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def idempotency_headers(headers, idempotency_key)
|
|
92
|
+
return headers unless idempotency_key
|
|
93
|
+
|
|
94
|
+
headers.merge(IDEMPOTENCY_HEADER => idempotency_key)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# JSON otherwise encodes BigDecimal as a quoted scientific-notation string.
|
|
98
|
+
# A fragment preserves the exact base-10 value as a JSON number.
|
|
99
|
+
def json_safe_decimals(value)
|
|
100
|
+
case value
|
|
101
|
+
when BigDecimal
|
|
102
|
+
JSON::Fragment.new(value.to_s("F"))
|
|
103
|
+
when Hash
|
|
104
|
+
value.transform_values { |item| json_safe_decimals(item) }
|
|
105
|
+
when Array
|
|
106
|
+
value.map { |item| json_safe_decimals(item) }
|
|
107
|
+
else
|
|
108
|
+
value
|
|
109
|
+
end
|
|
69
110
|
end
|
|
70
111
|
|
|
71
112
|
def connection
|
|
@@ -77,17 +118,15 @@ module Lipwa
|
|
|
77
118
|
end
|
|
78
119
|
|
|
79
120
|
def configure_middleware(conn)
|
|
80
|
-
conn.request :retry,
|
|
121
|
+
conn.request :retry, retry_options
|
|
81
122
|
conn.request :json
|
|
82
123
|
conn.response :json, content_type: /\bjson$/
|
|
83
|
-
configure_logger(conn) if logger
|
|
84
124
|
conn.use AuthMiddleware, auth_strategy
|
|
125
|
+
configure_logger(conn) if logger
|
|
85
126
|
end
|
|
86
127
|
|
|
87
128
|
def configure_logger(conn)
|
|
88
|
-
conn.
|
|
89
|
-
l.filter(/(Authorization: )(.+)/, '\1[REDACTED]')
|
|
90
|
-
end
|
|
129
|
+
conn.use Logging::Middleware, logger
|
|
91
130
|
end
|
|
92
131
|
|
|
93
132
|
def configure_timeouts(conn)
|