lipwa 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -0
- data/README.md +219 -204
- data/lib/lipwa/auth_strategies/api_key.rb +28 -0
- data/lib/lipwa/auth_strategies/base.rb +4 -0
- data/lib/lipwa/auth_strategies/bearer_token.rb +5 -0
- data/lib/lipwa/auth_strategies/jenga.rb +58 -0
- data/lib/lipwa/auth_strategies.rb +2 -0
- data/lib/lipwa/capabilities/authorize.rb +26 -0
- data/lib/lipwa/capabilities/bank_transfer.rb +66 -0
- data/lib/lipwa/capabilities/c2b.rb +11 -10
- data/lib/lipwa/capabilities/capture.rb +25 -0
- data/lib/lipwa/capabilities/disbursement.rb +16 -17
- data/lib/lipwa/capabilities/lightning_invoice.rb +75 -0
- data/lib/lipwa/capabilities/lightning_payment.rb +71 -0
- data/lib/lipwa/capabilities/purchase.rb +26 -0
- data/lib/lipwa/capabilities/refund.rb +14 -14
- data/lib/lipwa/capabilities/status_query.rb +21 -12
- data/lib/lipwa/capabilities/stk_push.rb +8 -5
- data/lib/lipwa/capabilities/void.rb +25 -0
- data/lib/lipwa/configuration_snapshot.rb +50 -0
- data/lib/lipwa/context.rb +60 -0
- data/lib/lipwa/contracts/bank_transfer_contract.rb +61 -0
- data/lib/lipwa/contracts/card_payment_contract.rb +59 -0
- data/lib/lipwa/contracts/card_transaction_contract.rb +21 -0
- data/lib/lipwa/contracts/lightning_invoice_check_contract.rb +18 -0
- data/lib/lipwa/contracts/lightning_invoice_contract.rb +27 -0
- data/lib/lipwa/contracts/lightning_payment_check_contract.rb +18 -0
- data/lib/lipwa/contracts/lightning_payment_contract.rb +14 -0
- data/lib/lipwa/contracts/status_query_contract.rb +29 -6
- data/lib/lipwa/errors.rb +4 -2
- data/lib/lipwa/gateway.rb +24 -5
- data/lib/lipwa/gateways/coop_bank/auth.rb +68 -0
- data/lib/lipwa/gateways/coop_bank.rb +115 -0
- data/lib/lipwa/gateways/flutterwave.rb +160 -0
- data/lib/lipwa/gateways/jenga/auth.rb +76 -0
- data/lib/lipwa/gateways/jenga.rb +282 -0
- data/lib/lipwa/gateways/lnbits.rb +67 -0
- data/lib/lipwa/gateways/mpesa/auth.rb +18 -2
- data/lib/lipwa/gateways/mpesa.rb +16 -11
- data/lib/lipwa/gateways/paystack.rb +119 -0
- data/lib/lipwa/gateways/pesapal/auth.rb +72 -0
- data/lib/lipwa/gateways/pesapal.rb +123 -0
- data/lib/lipwa/gateways.rb +7 -1
- data/lib/lipwa/http_adapter.rb +56 -17
- data/lib/lipwa/logging.rb +100 -0
- data/lib/lipwa/money.rb +33 -4
- data/lib/lipwa/types.rb +3 -3
- data/lib/lipwa/version.rb +1 -1
- data/lib/lipwa/webhooks/coop_bank.rb +28 -0
- data/lib/lipwa/webhooks/jenga.rb +55 -0
- data/lib/lipwa/webhooks/lnbits.rb +46 -0
- data/lib/lipwa.rb +29 -0
- metadata +32 -4
- data/CODE_OF_CONDUCT.md +0 -10
- data/Rakefile +0 -12
- data/sig/lipwa.rbs +0 -4
|
@@ -21,7 +21,9 @@ module Lipwa
|
|
|
21
21
|
|
|
22
22
|
CONTRACT = Lipwa::Contracts::StkPushContract.new
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
# rubocop:disable Metrics/ParameterLists
|
|
25
|
+
def stk_push(amount:, phone_number:, account_reference:, callback_url:, transaction_desc: nil,
|
|
26
|
+
idempotency_key: nil)
|
|
25
27
|
validation = CONTRACT.call(
|
|
26
28
|
amount: amount,
|
|
27
29
|
phone_number: phone_number,
|
|
@@ -31,13 +33,14 @@ module Lipwa
|
|
|
31
33
|
)
|
|
32
34
|
return Failure(Lipwa::ValidationError.new(validation)) if validation.failure?
|
|
33
35
|
|
|
34
|
-
perform_stk_push(validation.to_h)
|
|
36
|
+
perform_stk_push(validation.to_h, idempotency_key)
|
|
35
37
|
end
|
|
38
|
+
# rubocop:enable Metrics/ParameterLists
|
|
36
39
|
|
|
37
40
|
private
|
|
38
41
|
|
|
39
|
-
def perform_stk_push(params)
|
|
40
|
-
response = http.post(PATH, body: stk_push_body(params))
|
|
42
|
+
def perform_stk_push(params, idempotency_key)
|
|
43
|
+
response = http.post(PATH, body: stk_push_body(params), idempotency_key: idempotency_key)
|
|
41
44
|
|
|
42
45
|
build_stk_push_response(response.body)
|
|
43
46
|
rescue Lipwa::GatewayError => e
|
|
@@ -45,7 +48,7 @@ module Lipwa
|
|
|
45
48
|
end
|
|
46
49
|
|
|
47
50
|
def stk_push_body(params)
|
|
48
|
-
config = self.
|
|
51
|
+
config = self.config
|
|
49
52
|
timestamp = Time.now.strftime("%Y%m%d%H%M%S")
|
|
50
53
|
|
|
51
54
|
{
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/monads"
|
|
4
|
+
require_relative "../capability"
|
|
5
|
+
require_relative "../contracts/card_transaction_contract"
|
|
6
|
+
|
|
7
|
+
module Lipwa
|
|
8
|
+
module Capabilities
|
|
9
|
+
# Cancels an authorization which has not completed.
|
|
10
|
+
module Void
|
|
11
|
+
extend Lipwa::Capability
|
|
12
|
+
include Dry::Monads[:result]
|
|
13
|
+
self.capability_name = :void
|
|
14
|
+
|
|
15
|
+
CONTRACT = Lipwa::Contracts::CardTransactionContract.new
|
|
16
|
+
|
|
17
|
+
def void(authorization:, idempotency_key: nil)
|
|
18
|
+
validation = CONTRACT.call(authorization: authorization)
|
|
19
|
+
return Failure(Lipwa::ValidationError.new(validation)) if validation.failure?
|
|
20
|
+
|
|
21
|
+
perform_void(validation.to_h, idempotency_key)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "configuration"
|
|
4
|
+
require_relative "logging"
|
|
5
|
+
|
|
6
|
+
module Lipwa
|
|
7
|
+
# Copies setting data while preserving service objects such as loggers.
|
|
8
|
+
module ConfigurationSnapshot
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def finalize(config)
|
|
12
|
+
config.values.transform_values! { |value| freeze_value(copy_value(value)) }
|
|
13
|
+
config.define_singleton_method(:inspect) do
|
|
14
|
+
values = Lipwa::Logging::Redactor.call(to_h)
|
|
15
|
+
"#<#{self.class} values=#{values.inspect}>"
|
|
16
|
+
end
|
|
17
|
+
config.finalize!
|
|
18
|
+
config.values.freeze
|
|
19
|
+
config
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def freeze_value(value)
|
|
23
|
+
case value
|
|
24
|
+
when Dry::Configurable::Config then finalize(value)
|
|
25
|
+
when Array then value.each { |item| freeze_value(item) }.freeze
|
|
26
|
+
when Hash then value.each_value { |item| freeze_value(item) }.freeze
|
|
27
|
+
when String then value.freeze
|
|
28
|
+
end
|
|
29
|
+
value
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def snapshot(source)
|
|
33
|
+
source.dup.tap do |copy|
|
|
34
|
+
copy.values.transform_values! { |value| copy_value(value) }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Copy configuration data without duplicating services such as loggers or
|
|
39
|
+
# callable clocks, and without running setting constructors a second time.
|
|
40
|
+
def copy_value(value)
|
|
41
|
+
case value
|
|
42
|
+
when Dry::Configurable::Config then snapshot(value)
|
|
43
|
+
when String then value.dup
|
|
44
|
+
when Array then value.map { |item| copy_value(item) }
|
|
45
|
+
when Hash then value.transform_values { |item| copy_value(item) }
|
|
46
|
+
else value
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "delegate"
|
|
4
|
+
require_relative "gateways"
|
|
5
|
+
require_relative "configuration_snapshot"
|
|
6
|
+
|
|
7
|
+
module Lipwa
|
|
8
|
+
# An explicit, immutable configuration scope with its own gateway instances.
|
|
9
|
+
class Context
|
|
10
|
+
# Delegates settings to Dry::Configurable so constructors and errors survive.
|
|
11
|
+
class Builder < SimpleDelegator
|
|
12
|
+
def initialize(config, gateway_configs)
|
|
13
|
+
super(config)
|
|
14
|
+
@gateway_configs = gateway_configs
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def gateway(name)
|
|
18
|
+
config = @gateway_configs[name]
|
|
19
|
+
yield config if block_given?
|
|
20
|
+
config
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
private_constant :Builder
|
|
24
|
+
|
|
25
|
+
attr_reader :config
|
|
26
|
+
|
|
27
|
+
def initialize
|
|
28
|
+
@config = snapshot(Lipwa.config)
|
|
29
|
+
classes = Gateways.gateway_classes
|
|
30
|
+
gateway_configs = snapshot_gateways(classes)
|
|
31
|
+
yield Builder.new(config, gateway_configs) if block_given?
|
|
32
|
+
finalize(config)
|
|
33
|
+
@gateways = build_gateways(classes, gateway_configs)
|
|
34
|
+
freeze
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def gateway(name)
|
|
38
|
+
@gateways[name]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
include ConfigurationSnapshot
|
|
44
|
+
|
|
45
|
+
def snapshot_gateways(classes)
|
|
46
|
+
Dry::Container.new.tap do |configs|
|
|
47
|
+
classes.each { |name, klass| configs.register(name, snapshot(klass.config)) }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def build_gateways(classes, gateway_configs)
|
|
52
|
+
Dry::Container.new.tap do |gateways|
|
|
53
|
+
classes.each do |name, klass|
|
|
54
|
+
provider_config = finalize(gateway_configs[name])
|
|
55
|
+
gateways.register(name, memoize: true) { klass.new(config: provider_config, global_config: config) }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/validation"
|
|
4
|
+
require_relative "../money"
|
|
5
|
+
module Lipwa
|
|
6
|
+
module Contracts
|
|
7
|
+
class BankTransferContract < Dry::Validation::Contract
|
|
8
|
+
RAILS = %i[internal rtgs pesalink swift bill_payment].freeze
|
|
9
|
+
schema do
|
|
10
|
+
required(:rail).filled(:symbol)
|
|
11
|
+
required(:amount).filled
|
|
12
|
+
required(:source_account).filled(:string)
|
|
13
|
+
required(:destination_account).filled(:string)
|
|
14
|
+
required(:reference).filled(:string)
|
|
15
|
+
optional(:narration).maybe(:string)
|
|
16
|
+
optional(:callback_url).maybe(:string)
|
|
17
|
+
optional(:destination_bank_code).maybe(:string)
|
|
18
|
+
optional(:destination_name).maybe(:string)
|
|
19
|
+
optional(:biller_code).maybe(:string)
|
|
20
|
+
end
|
|
21
|
+
rule(:rail) { key.failure("must be one of #{RAILS.join(", ")}") unless RAILS.include?(value) }
|
|
22
|
+
rule(:amount) do
|
|
23
|
+
next key.failure("must be a Lipwa::Money") unless value.is_a?(Lipwa::Money)
|
|
24
|
+
|
|
25
|
+
key.failure("must be greater than zero") unless value.amount.positive?
|
|
26
|
+
end
|
|
27
|
+
rule(:destination_bank_code, :rail) do
|
|
28
|
+
if %i[rtgs swift].include?(values[:rail]) && values[:destination_bank_code].to_s.empty?
|
|
29
|
+
key(:destination_bank_code).failure("is required for RTGS transfers")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
rule(:biller_code, :rail) do
|
|
33
|
+
if values[:rail] == :bill_payment && values[:biller_code].to_s.empty?
|
|
34
|
+
key(:biller_code).failure("is required for bill payments")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
rule(:callback_url) { key.failure("must be an https:// URL") if value && !value.start_with?("https://") }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class BankAccountContract < Dry::Validation::Contract
|
|
41
|
+
schema do
|
|
42
|
+
required(:account_number).filled(:string)
|
|
43
|
+
optional(:message_reference).maybe(:string)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class BankStatementContract < Dry::Validation::Contract
|
|
48
|
+
schema do
|
|
49
|
+
required(:account_number).filled(:string)
|
|
50
|
+
optional(:from_date).maybe(:date)
|
|
51
|
+
optional(:to_date).maybe(:date)
|
|
52
|
+
optional(:message_reference).maybe(:string)
|
|
53
|
+
end
|
|
54
|
+
rule(:from_date, :to_date) do
|
|
55
|
+
if values[:from_date] && values[:to_date] && values[:to_date] < values[:from_date]
|
|
56
|
+
key(:to_date).failure("must be on or after from_date")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/validation"
|
|
4
|
+
require_relative "../money"
|
|
5
|
+
|
|
6
|
+
module Lipwa
|
|
7
|
+
module Contracts
|
|
8
|
+
# Validates the shared hosted-checkout request shape.
|
|
9
|
+
class CardPaymentContract < Dry::Validation::Contract
|
|
10
|
+
schema do
|
|
11
|
+
required(:amount).filled
|
|
12
|
+
required(:reference).filled(:string)
|
|
13
|
+
required(:description).filled(:string)
|
|
14
|
+
required(:callback_url).filled(:string)
|
|
15
|
+
required(:notification_id).filled(:string)
|
|
16
|
+
required(:billing_address).hash
|
|
17
|
+
optional(:cancellation_url).maybe(:string)
|
|
18
|
+
optional(:redirect_mode).maybe(:string)
|
|
19
|
+
optional(:branch).maybe(:string)
|
|
20
|
+
optional(:card_number).maybe(:string)
|
|
21
|
+
optional(:cvv).maybe(:string)
|
|
22
|
+
optional(:expiry_month).maybe(:string)
|
|
23
|
+
optional(:expiry_year).maybe(:string)
|
|
24
|
+
optional(:fullname).maybe(:string)
|
|
25
|
+
optional(:phone_number).maybe(:string)
|
|
26
|
+
optional(:usesecureauth).maybe(:bool)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
rule(:amount) do
|
|
30
|
+
next key.failure("must be a Lipwa::Money") unless value.is_a?(Lipwa::Money)
|
|
31
|
+
|
|
32
|
+
key.failure("must be greater than zero") unless value.amount.positive?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
rule(:reference) do
|
|
36
|
+
key.failure("must be 50 characters or fewer") if value.length > 50
|
|
37
|
+
key.failure("contains unsupported characters") unless value.match?(/\A[A-Za-z0-9_.:-]+\z/)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
rule(:description) { key.failure("must be 100 characters or fewer") if value.length > 100 }
|
|
41
|
+
|
|
42
|
+
rule(:callback_url) do
|
|
43
|
+
key.failure("must be an https:// URL") unless value.start_with?("https://")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
rule(:cancellation_url) do
|
|
47
|
+
key.failure("must be an https:// URL") if value && !value.start_with?("https://")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
rule(:billing_address) do
|
|
51
|
+
address = value
|
|
52
|
+
unless address[:email_address] || address["email_address"] ||
|
|
53
|
+
address[:phone_number] || address["phone_number"]
|
|
54
|
+
key.failure("must contain an email_address or phone_number")
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/validation"
|
|
4
|
+
|
|
5
|
+
module Lipwa
|
|
6
|
+
module Contracts
|
|
7
|
+
# Validates operations addressing an existing provider authorization.
|
|
8
|
+
class CardTransactionContract < Dry::Validation::Contract
|
|
9
|
+
schema do
|
|
10
|
+
required(:authorization).filled(:string)
|
|
11
|
+
optional(:amount).filled
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
rule(:authorization) do
|
|
15
|
+
unless value.match?(/\A[A-Za-z0-9_.:-]+\z/) && !%w[. ..].include?(value)
|
|
16
|
+
key.failure("contains unsupported characters")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/validation"
|
|
4
|
+
|
|
5
|
+
module Lipwa
|
|
6
|
+
module Contracts
|
|
7
|
+
# Validates the LNbits payment hash used to look up an invoice.
|
|
8
|
+
class LightningInvoiceCheckContract < Dry::Validation::Contract
|
|
9
|
+
schema do
|
|
10
|
+
required(:payment_hash).filled(:string)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
rule(:payment_hash) do
|
|
14
|
+
key.failure("contains unsupported characters") unless value.match?(/\A[A-Za-z0-9_-]+\z/)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/validation"
|
|
4
|
+
|
|
5
|
+
module Lipwa
|
|
6
|
+
module Contracts
|
|
7
|
+
# Validates invoice creation parameters before any network call is
|
|
8
|
+
# made. Amounts are integer satoshis, not Lipwa::Money: Bitcoin is
|
|
9
|
+
# not an ISO-4217 currency and this capability does no currency math.
|
|
10
|
+
class LightningInvoiceContract < Dry::Validation::Contract
|
|
11
|
+
schema do
|
|
12
|
+
required(:amount_sats).filled(:integer)
|
|
13
|
+
optional(:memo).maybe(:string)
|
|
14
|
+
optional(:expiry).maybe(:integer)
|
|
15
|
+
optional(:webhook_url).maybe(:string)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
rule(:amount_sats) do
|
|
19
|
+
key.failure("must be greater than zero") unless value.positive?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
rule(:webhook_url) do
|
|
23
|
+
key.failure("must be an https:// URL") if value && !value.start_with?("https://")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/validation"
|
|
4
|
+
|
|
5
|
+
module Lipwa
|
|
6
|
+
module Contracts
|
|
7
|
+
# Validates the payment hash used to reconcile an outbound payment.
|
|
8
|
+
class LightningPaymentCheckContract < Dry::Validation::Contract
|
|
9
|
+
schema do
|
|
10
|
+
required(:payment_hash).filled(:string)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
rule(:payment_hash) do
|
|
14
|
+
key.failure("contains unsupported characters") unless value.match?(/\A[A-Za-z0-9_-]+\z/)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/validation"
|
|
4
|
+
|
|
5
|
+
module Lipwa
|
|
6
|
+
module Contracts
|
|
7
|
+
# Validates an outbound BOLT11 invoice before wallet funds can be spent.
|
|
8
|
+
class LightningPaymentContract < Dry::Validation::Contract
|
|
9
|
+
schema do
|
|
10
|
+
required(:bolt11).filled(:string)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -11,19 +11,42 @@ module Lipwa
|
|
|
11
11
|
# per-call param.
|
|
12
12
|
class StatusQueryContract < Dry::Validation::Contract
|
|
13
13
|
schema do
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
optional(:transaction_id).maybe(:string)
|
|
15
|
+
optional(:message_reference).maybe(:string)
|
|
16
|
+
optional(:remarks).maybe(:string)
|
|
17
|
+
optional(:result_url).maybe(:string)
|
|
18
|
+
optional(:queue_timeout_url).maybe(:string)
|
|
18
19
|
optional(:occasion).maybe(:string)
|
|
19
20
|
end
|
|
20
21
|
|
|
22
|
+
rule(:transaction_id, :message_reference) do
|
|
23
|
+
if values[:transaction_id].to_s.empty? && values[:message_reference].to_s.empty?
|
|
24
|
+
key(:transaction_id).failure("or message_reference must be filled")
|
|
25
|
+
elsif values[:transaction_id] && values[:message_reference]
|
|
26
|
+
key(:message_reference).failure("cannot be used with transaction_id")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
rule(:remarks, :transaction_id) do
|
|
31
|
+
key(:remarks).failure("must be filled") if values[:transaction_id] && values[:remarks].to_s.empty?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
rule(:result_url, :transaction_id) do
|
|
35
|
+
key(:result_url).failure("must be filled") if values[:transaction_id] && values[:result_url].to_s.empty?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
rule(:queue_timeout_url, :transaction_id) do
|
|
39
|
+
if values[:transaction_id] && values[:queue_timeout_url].to_s.empty?
|
|
40
|
+
key(:queue_timeout_url).failure("must be filled")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
21
44
|
rule(:result_url) do
|
|
22
|
-
key.failure("must be an https:// URL")
|
|
45
|
+
key.failure("must be an https:// URL") if value && !value.start_with?("https://")
|
|
23
46
|
end
|
|
24
47
|
|
|
25
48
|
rule(:queue_timeout_url) do
|
|
26
|
-
key.failure("must be an https:// URL")
|
|
49
|
+
key.failure("must be an https:// URL") if value && !value.start_with?("https://")
|
|
27
50
|
end
|
|
28
51
|
end
|
|
29
52
|
end
|
data/lib/lipwa/errors.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "logging"
|
|
4
|
+
|
|
3
5
|
module Lipwa
|
|
4
6
|
# Base class for everything the gem raises or wraps in a Failure.
|
|
5
7
|
class Error < StandardError; end
|
|
@@ -41,8 +43,8 @@ module Lipwa
|
|
|
41
43
|
|
|
42
44
|
def initialize(message, code: nil, raw: nil)
|
|
43
45
|
@code = code
|
|
44
|
-
@raw = raw
|
|
45
|
-
super(message)
|
|
46
|
+
@raw = Logging::Redactor.call(raw)
|
|
47
|
+
super(Logging::Redactor.redact_string(message.to_s))
|
|
46
48
|
end
|
|
47
49
|
end
|
|
48
50
|
end
|
data/lib/lipwa/gateway.rb
CHANGED
|
@@ -6,7 +6,7 @@ require_relative "types"
|
|
|
6
6
|
require_relative "capability"
|
|
7
7
|
require_relative "auth_strategies"
|
|
8
8
|
require_relative "http_adapter"
|
|
9
|
-
require_relative "
|
|
9
|
+
require_relative "configuration_snapshot"
|
|
10
10
|
|
|
11
11
|
module Lipwa
|
|
12
12
|
# Abstract base every provider gateway (Mpesa, CoopBank, Jenga, ...)
|
|
@@ -41,6 +41,19 @@ module Lipwa
|
|
|
41
41
|
end
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
attr_reader :config, :global_config
|
|
45
|
+
|
|
46
|
+
# Explicit snapshots win over defaults captured at construction time.
|
|
47
|
+
def initialize(config: nil, global_config: nil)
|
|
48
|
+
@config = ConfigurationSnapshot.snapshot(config || self.class.config)
|
|
49
|
+
strategy = @config.auth_strategy
|
|
50
|
+
@config.auth_strategy = strategy.respond_to?(:apply) ? strategy.dup : strategy&.call
|
|
51
|
+
ConfigurationSnapshot.finalize(@config)
|
|
52
|
+
@global_config = ConfigurationSnapshot.finalize(
|
|
53
|
+
ConfigurationSnapshot.snapshot(global_config || Lipwa.config)
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
44
57
|
def capability?(name)
|
|
45
58
|
self.class.capabilities.include?(name.to_sym)
|
|
46
59
|
end
|
|
@@ -49,19 +62,25 @@ module Lipwa
|
|
|
49
62
|
@http ||= build_http_adapter
|
|
50
63
|
end
|
|
51
64
|
|
|
65
|
+
# Configuration contains credentials, so the default object inspection is
|
|
66
|
+
# deliberately replaced with a diagnostic that cannot serialize them.
|
|
67
|
+
def inspect
|
|
68
|
+
"#<#{self.class}:0x#{object_id.to_s(16)}>"
|
|
69
|
+
end
|
|
70
|
+
|
|
52
71
|
private
|
|
53
72
|
|
|
54
73
|
def build_http_adapter
|
|
55
|
-
config = self.
|
|
74
|
+
config = self.config
|
|
56
75
|
ensure_base_url_configured!(config)
|
|
57
76
|
|
|
58
77
|
HttpAdapter.new(
|
|
59
78
|
base_url: config.base_url,
|
|
60
79
|
auth_strategy: config.auth_strategy || AuthStrategies::None.new,
|
|
61
|
-
timeout: config.timeout ||
|
|
80
|
+
timeout: config.timeout || global_config.default_timeout,
|
|
62
81
|
open_timeout: config.open_timeout,
|
|
63
|
-
logger: config.logger ||
|
|
64
|
-
adapter:
|
|
82
|
+
logger: config.logger || global_config.logger,
|
|
83
|
+
adapter: global_config.adapter
|
|
65
84
|
)
|
|
66
85
|
end
|
|
67
86
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "faraday"
|
|
5
|
+
require "json"
|
|
6
|
+
require_relative "../../errors"
|
|
7
|
+
module Lipwa
|
|
8
|
+
module Gateways
|
|
9
|
+
class CoopBank
|
|
10
|
+
class Auth
|
|
11
|
+
EXPIRY_BUFFER = 60
|
|
12
|
+
def initialize(client_id:, client_secret:, token_url:, clock: -> { Time.now })
|
|
13
|
+
unless client_id && client_secret
|
|
14
|
+
raise Lipwa::ConfigurationError,
|
|
15
|
+
"CoopBank::Auth requires client_id and client_secret"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
@client_id = client_id
|
|
19
|
+
@client_secret = client_secret
|
|
20
|
+
@token_url = token_url
|
|
21
|
+
@clock = clock
|
|
22
|
+
@mutex = Mutex.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize_copy(source)
|
|
26
|
+
super
|
|
27
|
+
@token = nil
|
|
28
|
+
@expires_at = nil
|
|
29
|
+
@mutex = Mutex.new
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def inspect
|
|
33
|
+
"#<#{self.class}:0x#{object_id.to_s(16)}>"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def call
|
|
37
|
+
return @token if fresh?
|
|
38
|
+
|
|
39
|
+
@mutex.synchronize { refresh! unless fresh? }
|
|
40
|
+
@token
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def fresh? = @token && @expires_at && @clock.call < @expires_at
|
|
46
|
+
|
|
47
|
+
def refresh!
|
|
48
|
+
response = Faraday.post(@token_url) do |request|
|
|
49
|
+
request.headers["Authorization"] = "Basic #{Base64.strict_encode64("#{@client_id}:#{@client_secret}")}"
|
|
50
|
+
request.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
51
|
+
request.body = "grant_type=client_credentials"
|
|
52
|
+
end
|
|
53
|
+
body = JSON.parse(response.body)
|
|
54
|
+
unless response.success?
|
|
55
|
+
raise Lipwa::GatewayError.new("Co-op OAuth failed", code: response.status.to_s,
|
|
56
|
+
raw: body)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
@token = body.fetch("access_token")
|
|
60
|
+
@expires_at = @clock.call + body.fetch("expires_in", 3600).to_i - EXPIRY_BUFFER
|
|
61
|
+
rescue Faraday::Error, JSON::ParserError, KeyError => e
|
|
62
|
+
raise Lipwa::GatewayError.new("unexpected Co-op OAuth response: #{e.message}",
|
|
63
|
+
raw: defined?(body) ? body : nil)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|