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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +219 -204
  4. data/lib/lipwa/auth_strategies/api_key.rb +28 -0
  5. data/lib/lipwa/auth_strategies/base.rb +4 -0
  6. data/lib/lipwa/auth_strategies/bearer_token.rb +5 -0
  7. data/lib/lipwa/auth_strategies/jenga.rb +58 -0
  8. data/lib/lipwa/auth_strategies.rb +2 -0
  9. data/lib/lipwa/capabilities/authorize.rb +26 -0
  10. data/lib/lipwa/capabilities/bank_transfer.rb +66 -0
  11. data/lib/lipwa/capabilities/c2b.rb +11 -10
  12. data/lib/lipwa/capabilities/capture.rb +25 -0
  13. data/lib/lipwa/capabilities/disbursement.rb +16 -17
  14. data/lib/lipwa/capabilities/lightning_invoice.rb +75 -0
  15. data/lib/lipwa/capabilities/lightning_payment.rb +71 -0
  16. data/lib/lipwa/capabilities/purchase.rb +26 -0
  17. data/lib/lipwa/capabilities/refund.rb +14 -14
  18. data/lib/lipwa/capabilities/status_query.rb +21 -12
  19. data/lib/lipwa/capabilities/stk_push.rb +8 -5
  20. data/lib/lipwa/capabilities/void.rb +25 -0
  21. data/lib/lipwa/configuration_snapshot.rb +50 -0
  22. data/lib/lipwa/context.rb +60 -0
  23. data/lib/lipwa/contracts/bank_transfer_contract.rb +61 -0
  24. data/lib/lipwa/contracts/card_payment_contract.rb +59 -0
  25. data/lib/lipwa/contracts/card_transaction_contract.rb +21 -0
  26. data/lib/lipwa/contracts/lightning_invoice_check_contract.rb +18 -0
  27. data/lib/lipwa/contracts/lightning_invoice_contract.rb +27 -0
  28. data/lib/lipwa/contracts/lightning_payment_check_contract.rb +18 -0
  29. data/lib/lipwa/contracts/lightning_payment_contract.rb +14 -0
  30. data/lib/lipwa/contracts/status_query_contract.rb +29 -6
  31. data/lib/lipwa/errors.rb +4 -2
  32. data/lib/lipwa/gateway.rb +24 -5
  33. data/lib/lipwa/gateways/coop_bank/auth.rb +68 -0
  34. data/lib/lipwa/gateways/coop_bank.rb +115 -0
  35. data/lib/lipwa/gateways/flutterwave.rb +160 -0
  36. data/lib/lipwa/gateways/jenga/auth.rb +76 -0
  37. data/lib/lipwa/gateways/jenga.rb +282 -0
  38. data/lib/lipwa/gateways/lnbits.rb +67 -0
  39. data/lib/lipwa/gateways/mpesa/auth.rb +18 -2
  40. data/lib/lipwa/gateways/mpesa.rb +16 -11
  41. data/lib/lipwa/gateways/paystack.rb +119 -0
  42. data/lib/lipwa/gateways/pesapal/auth.rb +72 -0
  43. data/lib/lipwa/gateways/pesapal.rb +123 -0
  44. data/lib/lipwa/gateways.rb +7 -1
  45. data/lib/lipwa/http_adapter.rb +56 -17
  46. data/lib/lipwa/logging.rb +100 -0
  47. data/lib/lipwa/money.rb +33 -4
  48. data/lib/lipwa/types.rb +3 -3
  49. data/lib/lipwa/version.rb +1 -1
  50. data/lib/lipwa/webhooks/coop_bank.rb +28 -0
  51. data/lib/lipwa/webhooks/jenga.rb +55 -0
  52. data/lib/lipwa/webhooks/lnbits.rb +46 -0
  53. data/lib/lipwa.rb +29 -0
  54. metadata +32 -4
  55. data/CODE_OF_CONDUCT.md +0 -10
  56. data/Rakefile +0 -12
  57. 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
- def initialize(consumer_key:, consumer_secret:, env: :sandbox, http: nil, clock: -> { Time.now })
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
 
@@ -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 rather than set
15
- # directly, since Daraja's OAuth + sandbox/production hosts are fixed
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.class.config
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: Auth::BASE_URLS.fetch(config.env),
44
- auth_strategy: AuthStrategies::BearerToken.new(build_auth(config)),
45
- timeout: config.timeout || Lipwa.config.default_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 || Lipwa.config.logger,
48
- adapter: Lipwa.config.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, env: config.env)
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.consumer_key && config.consumer_secret && config.shortcode && config.passkey
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)
@@ -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
@@ -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
- retry_statuses: [429, 500, 502, 503, 504],
21
- methods: %i[get post put patch delete],
22
- exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS
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 post(path, body: nil, params: {}, headers: {})
47
- request(:post, path, body: body, params: params, headers: headers)
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
- def put(path, body: nil, params: {}, headers: {})
51
- request(:put, path, body: body, params: params, headers: headers)
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, @retry_options
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.response :logger, logger, headers: true, bodies: true do |l|
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)