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