lipwa 0.1.2 → 0.1.3
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 +25 -0
- data/README.md +219 -204
- data/Rakefile +11 -0
- data/docs/README.md +15 -0
- data/docs/_config.yml +11 -0
- data/docs/_layouts/default.html +37 -0
- data/docs/assets/css/style.css +8 -0
- data/docs/capabilities.md +116 -0
- data/docs/gateways.md +158 -0
- data/docs/getting-started.md +64 -0
- data/docs/index.md +46 -0
- data/docs/reliability.md +50 -0
- data/docs/webhooks.md +72 -0
- 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 +42 -1
|
@@ -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)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Lipwa
|
|
7
|
+
module Logging
|
|
8
|
+
REDACTED = "[REDACTED]"
|
|
9
|
+
|
|
10
|
+
# Produces sanitized copies of nested payloads without mutating caller data.
|
|
11
|
+
module Redactor
|
|
12
|
+
SECRET_NAMES = "authorization|signature|cookie|api[_-]?key|access[_-]?token|" \
|
|
13
|
+
"refresh[_-]?token|token|consumer[_-]?key|client[_-]?id|" \
|
|
14
|
+
"secret|password|passkey|" \
|
|
15
|
+
"security[_-]?credential|private[_-]?key|admin[_-]?key|invoice[_-]?key|encryption[_-]?key"
|
|
16
|
+
SENSITIVE_KEY = /(?:#{SECRET_NAMES})/i
|
|
17
|
+
STRING_PATTERNS = [
|
|
18
|
+
/((?:authorization|proxy-authorization|signature)\s*:\s*)[^\r\n,]+/i,
|
|
19
|
+
/((?:^|[?&])(?:#{SECRET_NAMES})=)[^&#\s]+/i,
|
|
20
|
+
/(["']?(?:#{SECRET_NAMES})["']?\s*[:=]\s*["'])[^"']*(["'])/i
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def call(value, key: nil) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength
|
|
26
|
+
return REDACTED if sensitive_key?(key)
|
|
27
|
+
|
|
28
|
+
case value
|
|
29
|
+
when Hash
|
|
30
|
+
value.each_with_object({}) do |(child_key, child_value), copy|
|
|
31
|
+
copy[child_key] = call(child_value, key: child_key)
|
|
32
|
+
end
|
|
33
|
+
when Array then value.map { |item| call(item) }
|
|
34
|
+
when String then redact_string(value)
|
|
35
|
+
when Exception then { class: value.class.name, message: redact_string(value.message) }
|
|
36
|
+
else value
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def sensitive_key?(key) = key&.to_s&.match?(SENSITIVE_KEY)
|
|
41
|
+
|
|
42
|
+
def redact_string(value)
|
|
43
|
+
STRING_PATTERNS.reduce(value.dup) do |redacted, pattern|
|
|
44
|
+
redacted.gsub(pattern) { "#{Regexp.last_match(1)}#{REDACTED}#{Regexp.last_match(2)}" }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Faraday middleware that emits one structured, sanitized event per call.
|
|
50
|
+
class Middleware < Faraday::Middleware
|
|
51
|
+
def initialize(app, logger)
|
|
52
|
+
super(app)
|
|
53
|
+
@logger = logger
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def call(env)
|
|
57
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
58
|
+
@app.call(env).on_complete { |completed| log(completed, started_at) }
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
log(env, started_at, error: e)
|
|
61
|
+
raise
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def log(env, started_at, error: nil) # rubocop:disable Metrics/AbcSize
|
|
67
|
+
event = {
|
|
68
|
+
event: "lipwa.http", method: env.method&.to_s&.upcase,
|
|
69
|
+
url: sanitized_url(env.url), status: env.status,
|
|
70
|
+
duration_ms: elapsed_ms(started_at),
|
|
71
|
+
request: { headers: Redactor.call(env.request_headers.to_h), body: redact_body(env.request_body) },
|
|
72
|
+
response: { headers: Redactor.call(env.response_headers.to_h), body: redact_body(env.response_body) }
|
|
73
|
+
}
|
|
74
|
+
event[:error] = Redactor.call(error) if error
|
|
75
|
+
@logger.public_send(error || env.status.to_i >= 400 ? :error : :info, event)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def sanitized_url(url)
|
|
79
|
+
return unless url
|
|
80
|
+
|
|
81
|
+
copy = url.dup
|
|
82
|
+
copy.user = REDACTED if copy.user
|
|
83
|
+
copy.password = REDACTED if copy.password
|
|
84
|
+
copy.query = Redactor.redact_string(copy.query) if copy.query
|
|
85
|
+
copy.to_s
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def redact_body(body)
|
|
89
|
+
parsed = body.is_a?(String) ? JSON.parse(body) : body
|
|
90
|
+
Redactor.call(parsed)
|
|
91
|
+
rescue JSON::ParserError
|
|
92
|
+
Redactor.call(body)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def elapsed_ms(started_at)
|
|
96
|
+
((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(1)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
data/lib/lipwa/money.rb
CHANGED
|
@@ -4,10 +4,10 @@ require "dry/struct"
|
|
|
4
4
|
require_relative "types"
|
|
5
5
|
|
|
6
6
|
module Lipwa
|
|
7
|
-
# Immutable representation of an amount in
|
|
8
|
-
# KES has no subunit in practice, but we keep minor units as the
|
|
9
|
-
# storage form so other currencies fit the same shape later.
|
|
7
|
+
# Immutable, decimal representation of an amount in one currency.
|
|
10
8
|
class Money < Dry::Struct
|
|
9
|
+
include Comparable
|
|
10
|
+
|
|
11
11
|
attribute :amount, Types::Amount
|
|
12
12
|
attribute :currency, Types::Currency.default("KES")
|
|
13
13
|
|
|
@@ -21,16 +21,45 @@ module Lipwa
|
|
|
21
21
|
self.class.new(amount: amount - other.amount, currency: currency)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
def *(other)
|
|
25
|
+
self.class.new(amount: amount * decimal_scalar(other), currency: currency)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def /(other)
|
|
29
|
+
divisor = decimal_scalar(other)
|
|
30
|
+
raise ZeroDivisionError, "divided by 0" if divisor.zero?
|
|
31
|
+
|
|
32
|
+
self.class.new(amount: amount / divisor, currency: currency)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def <=>(other)
|
|
36
|
+
return nil unless other.is_a?(self.class)
|
|
37
|
+
|
|
38
|
+
assert_same_currency!(other)
|
|
39
|
+
amount <=> other.amount
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def zero?
|
|
43
|
+
amount.zero?
|
|
44
|
+
end
|
|
45
|
+
|
|
24
46
|
def to_s
|
|
25
|
-
"#{amount} #{currency}"
|
|
47
|
+
"#{amount.to_s("F")} #{currency}"
|
|
26
48
|
end
|
|
27
49
|
|
|
28
50
|
private
|
|
29
51
|
|
|
30
52
|
def assert_same_currency!(other)
|
|
53
|
+
raise TypeError, "expected #{self.class}, got #{other.class}" unless other.is_a?(self.class)
|
|
31
54
|
return if currency == other.currency
|
|
32
55
|
|
|
33
56
|
raise ArgumentError, "currency mismatch: #{currency} vs #{other.currency}"
|
|
34
57
|
end
|
|
58
|
+
|
|
59
|
+
def decimal_scalar(value)
|
|
60
|
+
Types::Coercible::Decimal[value]
|
|
61
|
+
rescue Dry::Types::CoercionError, Dry::Types::ConstraintError
|
|
62
|
+
raise TypeError, "expected a numeric scalar, got #{value.inspect}"
|
|
63
|
+
end
|
|
35
64
|
end
|
|
36
65
|
end
|
data/lib/lipwa/types.rb
CHANGED
|
@@ -7,9 +7,9 @@ module Lipwa
|
|
|
7
7
|
module Types
|
|
8
8
|
include Dry.Types()
|
|
9
9
|
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
Amount = Types::
|
|
10
|
+
# BigDecimal-backed amounts avoid binary floating-point errors while
|
|
11
|
+
# accepting the numeric and string inputs commonly received from APIs.
|
|
12
|
+
Amount = Types::Coercible::Decimal.constrained(gteq: 0)
|
|
13
13
|
|
|
14
14
|
Currency = Types::Strict::String.constrained(format: /\A[A-Z]{3}\z/)
|
|
15
15
|
|
data/lib/lipwa/version.rb
CHANGED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../webhook"
|
|
4
|
+
|
|
5
|
+
module Lipwa
|
|
6
|
+
module Webhooks
|
|
7
|
+
# Normalizes Co-op Bank transaction callbacks into WebhookEvent.
|
|
8
|
+
module CoopBank
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def call(body:, headers: {}) # rubocop:disable Lint/UnusedMethodArgument
|
|
12
|
+
payload = body.is_a?(String) ? JSON.parse(body) : body
|
|
13
|
+
code = payload["MessageCode"]
|
|
14
|
+
|
|
15
|
+
Lipwa::WebhookEvent.new(
|
|
16
|
+
provider: :coop_bank,
|
|
17
|
+
event_type: :transaction_status,
|
|
18
|
+
success: code.to_s == "0",
|
|
19
|
+
provider_reference: payload["MessageReference"]&.to_s,
|
|
20
|
+
message: payload["MessageDescription"]&.to_s,
|
|
21
|
+
raw: payload
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
Lipwa::Webhook.register(:coop_bank, Lipwa::Webhooks::CoopBank)
|