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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +219 -204
  4. data/Rakefile +11 -0
  5. data/docs/README.md +15 -0
  6. data/docs/_config.yml +11 -0
  7. data/docs/_layouts/default.html +37 -0
  8. data/docs/assets/css/style.css +8 -0
  9. data/docs/capabilities.md +116 -0
  10. data/docs/gateways.md +158 -0
  11. data/docs/getting-started.md +64 -0
  12. data/docs/index.md +46 -0
  13. data/docs/reliability.md +50 -0
  14. data/docs/webhooks.md +72 -0
  15. data/lib/lipwa/auth_strategies/api_key.rb +28 -0
  16. data/lib/lipwa/auth_strategies/base.rb +4 -0
  17. data/lib/lipwa/auth_strategies/bearer_token.rb +5 -0
  18. data/lib/lipwa/auth_strategies/jenga.rb +58 -0
  19. data/lib/lipwa/auth_strategies.rb +2 -0
  20. data/lib/lipwa/capabilities/authorize.rb +26 -0
  21. data/lib/lipwa/capabilities/bank_transfer.rb +66 -0
  22. data/lib/lipwa/capabilities/c2b.rb +11 -10
  23. data/lib/lipwa/capabilities/capture.rb +25 -0
  24. data/lib/lipwa/capabilities/disbursement.rb +16 -17
  25. data/lib/lipwa/capabilities/lightning_invoice.rb +75 -0
  26. data/lib/lipwa/capabilities/lightning_payment.rb +71 -0
  27. data/lib/lipwa/capabilities/purchase.rb +26 -0
  28. data/lib/lipwa/capabilities/refund.rb +14 -14
  29. data/lib/lipwa/capabilities/status_query.rb +21 -12
  30. data/lib/lipwa/capabilities/stk_push.rb +8 -5
  31. data/lib/lipwa/capabilities/void.rb +25 -0
  32. data/lib/lipwa/configuration_snapshot.rb +50 -0
  33. data/lib/lipwa/context.rb +60 -0
  34. data/lib/lipwa/contracts/bank_transfer_contract.rb +61 -0
  35. data/lib/lipwa/contracts/card_payment_contract.rb +59 -0
  36. data/lib/lipwa/contracts/card_transaction_contract.rb +21 -0
  37. data/lib/lipwa/contracts/lightning_invoice_check_contract.rb +18 -0
  38. data/lib/lipwa/contracts/lightning_invoice_contract.rb +27 -0
  39. data/lib/lipwa/contracts/lightning_payment_check_contract.rb +18 -0
  40. data/lib/lipwa/contracts/lightning_payment_contract.rb +14 -0
  41. data/lib/lipwa/contracts/status_query_contract.rb +29 -6
  42. data/lib/lipwa/errors.rb +4 -2
  43. data/lib/lipwa/gateway.rb +24 -5
  44. data/lib/lipwa/gateways/coop_bank/auth.rb +68 -0
  45. data/lib/lipwa/gateways/coop_bank.rb +115 -0
  46. data/lib/lipwa/gateways/flutterwave.rb +160 -0
  47. data/lib/lipwa/gateways/jenga/auth.rb +76 -0
  48. data/lib/lipwa/gateways/jenga.rb +282 -0
  49. data/lib/lipwa/gateways/lnbits.rb +67 -0
  50. data/lib/lipwa/gateways/mpesa/auth.rb +18 -2
  51. data/lib/lipwa/gateways/mpesa.rb +16 -11
  52. data/lib/lipwa/gateways/paystack.rb +119 -0
  53. data/lib/lipwa/gateways/pesapal/auth.rb +72 -0
  54. data/lib/lipwa/gateways/pesapal.rb +123 -0
  55. data/lib/lipwa/gateways.rb +7 -1
  56. data/lib/lipwa/http_adapter.rb +56 -17
  57. data/lib/lipwa/logging.rb +100 -0
  58. data/lib/lipwa/money.rb +33 -4
  59. data/lib/lipwa/types.rb +3 -3
  60. data/lib/lipwa/version.rb +1 -1
  61. data/lib/lipwa/webhooks/coop_bank.rb +28 -0
  62. data/lib/lipwa/webhooks/jenga.rb +55 -0
  63. data/lib/lipwa/webhooks/lnbits.rb +46 -0
  64. data/lib/lipwa.rb +29 -0
  65. metadata +42 -1
@@ -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)
@@ -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"