lipwa 0.1.1
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 +7 -0
- data/CHANGELOG.md +16 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +288 -0
- data/Rakefile +12 -0
- data/lib/lipwa/auth_strategies/base.rb +18 -0
- data/lib/lipwa/auth_strategies/bearer_token.rb +27 -0
- data/lib/lipwa/auth_strategies/none.rb +14 -0
- data/lib/lipwa/auth_strategies.rb +5 -0
- data/lib/lipwa/capabilities/c2b.rb +102 -0
- data/lib/lipwa/capabilities/disbursement.rb +129 -0
- data/lib/lipwa/capabilities/refund.rb +102 -0
- data/lib/lipwa/capabilities/status_query.rb +101 -0
- data/lib/lipwa/capabilities/stk_push.rb +86 -0
- data/lib/lipwa/capability.rb +37 -0
- data/lib/lipwa/configuration.rb +13 -0
- data/lib/lipwa/contracts/c2b_register_urls_contract.rb +32 -0
- data/lib/lipwa/contracts/c2b_simulate_contract.rb +41 -0
- data/lib/lipwa/contracts/disbursement_contract.rb +66 -0
- data/lib/lipwa/contracts/refund_contract.rb +39 -0
- data/lib/lipwa/contracts/status_query_contract.rb +30 -0
- data/lib/lipwa/contracts/stk_push_contract.rb +40 -0
- data/lib/lipwa/errors.rb +48 -0
- data/lib/lipwa/gateway.rb +74 -0
- data/lib/lipwa/gateways/mpesa/auth.rb +98 -0
- data/lib/lipwa/gateways/mpesa/security_credential.rb +30 -0
- data/lib/lipwa/gateways/mpesa.rb +69 -0
- data/lib/lipwa/gateways.rb +25 -0
- data/lib/lipwa/http_adapter.rb +110 -0
- data/lib/lipwa/money.rb +36 -0
- data/lib/lipwa/response.rb +21 -0
- data/lib/lipwa/types.rb +18 -0
- data/lib/lipwa/version.rb +5 -0
- data/lib/lipwa/webhook.rb +68 -0
- data/lib/lipwa/webhooks/mpesa.rb +101 -0
- data/lib/lipwa.rb +32 -0
- data/plan.md +282 -0
- data/sig/lipwa.rbs +4 -0
- metadata +212 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/monads"
|
|
4
|
+
require_relative "../capability"
|
|
5
|
+
require_relative "../contracts/refund_contract"
|
|
6
|
+
|
|
7
|
+
module Lipwa
|
|
8
|
+
module Capabilities
|
|
9
|
+
# Reverses a completed M-Pesa transaction (Daraja's Transaction
|
|
10
|
+
# Reversal API) by its TransactionID. Like Disbursement and
|
|
11
|
+
# StatusQuery, Daraja requires a SecurityCredential, and the reversal
|
|
12
|
+
# call itself only acknowledges receipt; the actual outcome arrives
|
|
13
|
+
# later at result_url as a third callback envelope
|
|
14
|
+
# (`{"Result" => {...}}`), parsed generically by
|
|
15
|
+
# Lipwa::Webhooks::Mpesa alongside StatusQuery/Disbursement results.
|
|
16
|
+
module Refund
|
|
17
|
+
extend Lipwa::Capability
|
|
18
|
+
include Dry::Monads[:result]
|
|
19
|
+
self.capability_name = :refund
|
|
20
|
+
|
|
21
|
+
PATH = "/mpesa/reversal/v1/request"
|
|
22
|
+
RECEIVER_IDENTIFIER_TYPE = "11" # organization shortcode, per Daraja's Reversal API spec
|
|
23
|
+
|
|
24
|
+
CONTRACT = Lipwa::Contracts::RefundContract.new
|
|
25
|
+
|
|
26
|
+
# rubocop:disable Metrics/ParameterLists
|
|
27
|
+
def refund(transaction_id:, amount:, remarks:, result_url:, queue_timeout_url:, occasion: nil)
|
|
28
|
+
validate_and_refund(
|
|
29
|
+
transaction_id: transaction_id, amount: amount, remarks: remarks,
|
|
30
|
+
result_url: result_url, queue_timeout_url: queue_timeout_url, occasion: occasion
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
# rubocop:enable Metrics/ParameterLists
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def validate_and_refund(args)
|
|
38
|
+
validation = CONTRACT.call(args)
|
|
39
|
+
return Failure(Lipwa::ValidationError.new(validation)) if validation.failure?
|
|
40
|
+
|
|
41
|
+
perform_refund(validation.to_h)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def perform_refund(params)
|
|
45
|
+
ensure_refund_config_present!
|
|
46
|
+
|
|
47
|
+
response = http.post(PATH, body: refund_body(params))
|
|
48
|
+
|
|
49
|
+
build_refund_response(response.body)
|
|
50
|
+
rescue Lipwa::GatewayError => e
|
|
51
|
+
Failure(e)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def refund_body(params)
|
|
55
|
+
{
|
|
56
|
+
Initiator: self.class.config.initiator_name,
|
|
57
|
+
SecurityCredential: security_credential,
|
|
58
|
+
CommandID: "TransactionReversal",
|
|
59
|
+
TransactionID: params[:transaction_id],
|
|
60
|
+
Amount: params[:amount].amount,
|
|
61
|
+
ReceiverParty: self.class.config.shortcode,
|
|
62
|
+
RecieverIdentifierType: RECEIVER_IDENTIFIER_TYPE
|
|
63
|
+
}.merge(shared_refund_fields(params))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def shared_refund_fields(params)
|
|
67
|
+
{
|
|
68
|
+
ResultURL: params[:result_url],
|
|
69
|
+
QueueTimeOutURL: params[:queue_timeout_url],
|
|
70
|
+
Remarks: params[:remarks],
|
|
71
|
+
Occasion: params[:occasion]
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def security_credential
|
|
76
|
+
Lipwa::Gateways::Mpesa::SecurityCredential.encrypt(
|
|
77
|
+
self.class.config.initiator_password,
|
|
78
|
+
cert: self.class.config.security_credential_cert
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def build_refund_response(body)
|
|
83
|
+
Success(Lipwa::Response.new(
|
|
84
|
+
success: body["ResponseCode"] == "0",
|
|
85
|
+
provider_reference: body["ConversationID"] || body["OriginatorConversationID"],
|
|
86
|
+
message: body["ResponseDescription"] || body["errorMessage"],
|
|
87
|
+
code: (body["ResponseCode"] || body["errorCode"])&.to_s,
|
|
88
|
+
raw: body
|
|
89
|
+
))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def ensure_refund_config_present!
|
|
93
|
+
config = self.class.config
|
|
94
|
+
return if config.initiator_name && config.initiator_password && config.security_credential_cert
|
|
95
|
+
|
|
96
|
+
raise Lipwa::ConfigurationError,
|
|
97
|
+
"#{self.class} is missing initiator_name/initiator_password/security_credential_cert " \
|
|
98
|
+
"— set them via .configure to use #refund"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/monads"
|
|
4
|
+
require_relative "../capability"
|
|
5
|
+
require_relative "../contracts/status_query_contract"
|
|
6
|
+
|
|
7
|
+
module Lipwa
|
|
8
|
+
module Capabilities
|
|
9
|
+
# Actively queries Daraja for the outcome of a prior transaction
|
|
10
|
+
# (STK Push, C2B, B2C/B2B) by its TransactionID, for the "all async
|
|
11
|
+
# flows" case described in plan.md — a complement to, not a
|
|
12
|
+
# replacement for, the passive webhook/result-callback path. Like
|
|
13
|
+
# Disbursement, Daraja requires a SecurityCredential, and the query
|
|
14
|
+
# call itself only acknowledges receipt; the actual status arrives
|
|
15
|
+
# later at result_url as a third callback envelope
|
|
16
|
+
# (`{"Result" => {...}}`), parsed by Lipwa::Webhooks::Mpesa as a
|
|
17
|
+
# :transaction_status event.
|
|
18
|
+
module StatusQuery
|
|
19
|
+
extend Lipwa::Capability
|
|
20
|
+
include Dry::Monads[:result]
|
|
21
|
+
self.capability_name = :status_query
|
|
22
|
+
|
|
23
|
+
PATH = "/mpesa/transactionstatus/v1/query"
|
|
24
|
+
IDENTIFIER_TYPE = "4" # shortcode
|
|
25
|
+
|
|
26
|
+
CONTRACT = Lipwa::Contracts::StatusQueryContract.new
|
|
27
|
+
|
|
28
|
+
def status(transaction_id:, remarks:, result_url:, queue_timeout_url:, occasion: nil)
|
|
29
|
+
validate_and_query(
|
|
30
|
+
transaction_id: transaction_id, remarks: remarks, result_url: result_url,
|
|
31
|
+
queue_timeout_url: queue_timeout_url, occasion: occasion
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def validate_and_query(args)
|
|
38
|
+
validation = CONTRACT.call(args)
|
|
39
|
+
return Failure(Lipwa::ValidationError.new(validation)) if validation.failure?
|
|
40
|
+
|
|
41
|
+
perform_status_query(validation.to_h)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def perform_status_query(params)
|
|
45
|
+
ensure_status_query_config_present!
|
|
46
|
+
|
|
47
|
+
response = http.post(PATH, body: status_query_body(params))
|
|
48
|
+
|
|
49
|
+
build_status_query_response(response.body)
|
|
50
|
+
rescue Lipwa::GatewayError => e
|
|
51
|
+
Failure(e)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def status_query_body(params)
|
|
55
|
+
{
|
|
56
|
+
Initiator: self.class.config.initiator_name,
|
|
57
|
+
SecurityCredential: security_credential,
|
|
58
|
+
CommandID: "TransactionStatusQuery",
|
|
59
|
+
TransactionID: params[:transaction_id],
|
|
60
|
+
PartyA: self.class.config.shortcode,
|
|
61
|
+
IdentifierType: IDENTIFIER_TYPE
|
|
62
|
+
}.merge(shared_status_query_fields(params))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def shared_status_query_fields(params)
|
|
66
|
+
{
|
|
67
|
+
ResultURL: params[:result_url],
|
|
68
|
+
QueueTimeOutURL: params[:queue_timeout_url],
|
|
69
|
+
Remarks: params[:remarks],
|
|
70
|
+
Occasion: params[:occasion]
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def security_credential
|
|
75
|
+
Lipwa::Gateways::Mpesa::SecurityCredential.encrypt(
|
|
76
|
+
self.class.config.initiator_password,
|
|
77
|
+
cert: self.class.config.security_credential_cert
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def build_status_query_response(body)
|
|
82
|
+
Success(Lipwa::Response.new(
|
|
83
|
+
success: body["ResponseCode"] == "0",
|
|
84
|
+
provider_reference: body["ConversationID"] || body["OriginatorConversationID"],
|
|
85
|
+
message: body["ResponseDescription"] || body["errorMessage"],
|
|
86
|
+
code: (body["ResponseCode"] || body["errorCode"])&.to_s,
|
|
87
|
+
raw: body
|
|
88
|
+
))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def ensure_status_query_config_present!
|
|
92
|
+
config = self.class.config
|
|
93
|
+
return if config.initiator_name && config.initiator_password && config.security_credential_cert
|
|
94
|
+
|
|
95
|
+
raise Lipwa::ConfigurationError,
|
|
96
|
+
"#{self.class} is missing initiator_name/initiator_password/security_credential_cert " \
|
|
97
|
+
"— set them via .configure to use #status"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "dry/monads"
|
|
5
|
+
require_relative "../capability"
|
|
6
|
+
require_relative "../contracts/stk_push_contract"
|
|
7
|
+
|
|
8
|
+
module Lipwa
|
|
9
|
+
module Capabilities
|
|
10
|
+
# Lipa Na M-Pesa Online (STK Push): pushes a payment prompt to the
|
|
11
|
+
# payer's phone. The actual result arrives later via the merchant's
|
|
12
|
+
# callback URL — this call only confirms Daraja accepted the request
|
|
13
|
+
# (CheckoutRequestID), it is not itself the payment result.
|
|
14
|
+
module StkPush
|
|
15
|
+
extend Lipwa::Capability
|
|
16
|
+
include Dry::Monads[:result]
|
|
17
|
+
self.capability_name = :stk_push
|
|
18
|
+
|
|
19
|
+
PATH = "/mpesa/stkpush/v1/processrequest"
|
|
20
|
+
TRANSACTION_TYPE = "CustomerPayBillOnline"
|
|
21
|
+
|
|
22
|
+
CONTRACT = Lipwa::Contracts::StkPushContract.new
|
|
23
|
+
|
|
24
|
+
def stk_push(amount:, phone_number:, account_reference:, callback_url:, transaction_desc: nil)
|
|
25
|
+
validation = CONTRACT.call(
|
|
26
|
+
amount: amount,
|
|
27
|
+
phone_number: phone_number,
|
|
28
|
+
account_reference: account_reference,
|
|
29
|
+
callback_url: callback_url,
|
|
30
|
+
transaction_desc: transaction_desc || account_reference
|
|
31
|
+
)
|
|
32
|
+
return Failure(Lipwa::ValidationError.new(validation)) if validation.failure?
|
|
33
|
+
|
|
34
|
+
perform_stk_push(validation.to_h)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def perform_stk_push(params)
|
|
40
|
+
response = http.post(PATH, body: stk_push_body(params))
|
|
41
|
+
|
|
42
|
+
build_stk_push_response(response.body)
|
|
43
|
+
rescue Lipwa::GatewayError => e
|
|
44
|
+
Failure(e)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def stk_push_body(params)
|
|
48
|
+
config = self.class.config
|
|
49
|
+
timestamp = Time.now.strftime("%Y%m%d%H%M%S")
|
|
50
|
+
|
|
51
|
+
{
|
|
52
|
+
BusinessShortCode: config.shortcode,
|
|
53
|
+
Password: stk_push_password(config, timestamp),
|
|
54
|
+
Timestamp: timestamp,
|
|
55
|
+
TransactionType: TRANSACTION_TYPE,
|
|
56
|
+
PartyA: params[:phone_number],
|
|
57
|
+
PartyB: config.shortcode
|
|
58
|
+
}.merge(stk_push_transaction_fields(params))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def stk_push_transaction_fields(params)
|
|
62
|
+
{
|
|
63
|
+
Amount: params[:amount].amount,
|
|
64
|
+
PhoneNumber: params[:phone_number],
|
|
65
|
+
CallBackURL: params[:callback_url],
|
|
66
|
+
AccountReference: params[:account_reference],
|
|
67
|
+
TransactionDesc: params[:transaction_desc]
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def stk_push_password(config, timestamp)
|
|
72
|
+
Base64.strict_encode64("#{config.shortcode}#{config.passkey}#{timestamp}")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def build_stk_push_response(body)
|
|
76
|
+
Success(Lipwa::Response.new(
|
|
77
|
+
success: body["ResponseCode"] == "0",
|
|
78
|
+
provider_reference: body["CheckoutRequestID"],
|
|
79
|
+
message: body["ResponseDescription"] || body["errorMessage"],
|
|
80
|
+
code: (body["ResponseCode"] || body["errorCode"])&.to_s,
|
|
81
|
+
raw: body
|
|
82
|
+
))
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module Lipwa
|
|
6
|
+
# Mixin for capability modules (Lipwa::Capabilities::StkPush,
|
|
7
|
+
# Lipwa::Capabilities::Disbursement, ...). A capability module extends
|
|
8
|
+
# this and declares its `capability_name`; when the module is included
|
|
9
|
+
# into a Gateway subclass, that name is auto-registered so
|
|
10
|
+
# `gateway.capability?(:stk_push)` reflects reality — the method
|
|
11
|
+
# existing — instead of a hand-maintained list that can drift.
|
|
12
|
+
#
|
|
13
|
+
# module Lipwa
|
|
14
|
+
# module Capabilities
|
|
15
|
+
# module StkPush
|
|
16
|
+
# extend Lipwa::Capability
|
|
17
|
+
# self.capability_name = :stk_push
|
|
18
|
+
#
|
|
19
|
+
# def stk_push(...); end
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
module Capability
|
|
24
|
+
def self.extended(capability_module)
|
|
25
|
+
capability_module.singleton_class.attr_accessor :capability_name
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def included(base)
|
|
29
|
+
super
|
|
30
|
+
unless capability_name
|
|
31
|
+
raise Lipwa::ConfigurationError, "#{self} must set `self.capability_name` before being included"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
base.register_capability(capability_name)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "dry/configurable"
|
|
5
|
+
|
|
6
|
+
# Unified payment gateway abstraction for African payment providers.
|
|
7
|
+
module Lipwa
|
|
8
|
+
extend Dry::Configurable
|
|
9
|
+
|
|
10
|
+
setting :logger
|
|
11
|
+
setting :default_timeout, default: 10
|
|
12
|
+
setting :adapter, default: Faraday.default_adapter
|
|
13
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/validation"
|
|
4
|
+
|
|
5
|
+
module Lipwa
|
|
6
|
+
module Contracts
|
|
7
|
+
# Validates the kwargs Lipwa::Capabilities::C2B#register_urls is
|
|
8
|
+
# called with, before any network call is made. Validates the
|
|
9
|
+
# capability's own params, not the raw Daraja wire payload.
|
|
10
|
+
class C2bRegisterUrlsContract < Dry::Validation::Contract
|
|
11
|
+
RESPONSE_TYPES = %w[Completed Cancelled].freeze
|
|
12
|
+
|
|
13
|
+
schema do
|
|
14
|
+
required(:validation_url).filled(:string)
|
|
15
|
+
required(:confirmation_url).filled(:string)
|
|
16
|
+
required(:response_type).filled(:string)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
rule(:validation_url) do
|
|
20
|
+
key.failure("must be an https:// URL") unless value.start_with?("https://")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
rule(:confirmation_url) do
|
|
24
|
+
key.failure("must be an https:// URL") unless value.start_with?("https://")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
rule(:response_type) do
|
|
28
|
+
key.failure("must be one of #{RESPONSE_TYPES.join(", ")}") unless RESPONSE_TYPES.include?(value)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/validation"
|
|
4
|
+
require_relative "../money"
|
|
5
|
+
|
|
6
|
+
module Lipwa
|
|
7
|
+
module Contracts
|
|
8
|
+
# Validates the kwargs Lipwa::Capabilities::C2B#simulate is called
|
|
9
|
+
# with, before any network call is made. Validates the capability's
|
|
10
|
+
# own params, not the raw Daraja wire payload.
|
|
11
|
+
class C2bSimulateContract < Dry::Validation::Contract
|
|
12
|
+
COMMAND_IDS = %w[CustomerPayBillOnline CustomerBuyGoodsOnline].freeze
|
|
13
|
+
|
|
14
|
+
schema do
|
|
15
|
+
required(:amount).filled
|
|
16
|
+
required(:phone_number).filled(:string)
|
|
17
|
+
required(:bill_ref_number).filled(:string)
|
|
18
|
+
required(:command_id).filled(:string)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
rule(:amount) do
|
|
22
|
+
next key.failure("must be a Lipwa::Money") unless value.is_a?(Lipwa::Money)
|
|
23
|
+
|
|
24
|
+
key.failure("must be KES") if value.currency != "KES"
|
|
25
|
+
key.failure("must be greater than zero") if value.amount <= 0
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
rule(:phone_number) do
|
|
29
|
+
key.failure("must be a Safaricom MSISDN, e.g. 254712345678") unless value.match?(/\A254[17]\d{8}\z/)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
rule(:bill_ref_number) do
|
|
33
|
+
key.failure("must be 20 characters or fewer") if value.length > 20
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
rule(:command_id) do
|
|
37
|
+
key.failure("must be one of #{COMMAND_IDS.join(", ")}") unless COMMAND_IDS.include?(value)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/validation"
|
|
4
|
+
require_relative "../money"
|
|
5
|
+
|
|
6
|
+
module Lipwa
|
|
7
|
+
module Contracts
|
|
8
|
+
# Validates the kwargs Lipwa::Capabilities::Disbursement#disburse is
|
|
9
|
+
# called with, before any network call is made. Covers both B2C
|
|
10
|
+
# (PartyB is a phone number) and B2B (PartyB is a business shortcode,
|
|
11
|
+
# AccountReference required) shapes under one contract, mirroring how
|
|
12
|
+
# #disburse itself dispatches on command_id rather than exposing two
|
|
13
|
+
# methods.
|
|
14
|
+
class DisbursementContract < Dry::Validation::Contract
|
|
15
|
+
B2C_COMMAND_IDS = %w[SalaryPayment BusinessPayment PromotionPayment].freeze
|
|
16
|
+
B2B_COMMAND_IDS = %w[BusinessPayBill BusinessBuyGoods MerchantToMerchantTransfer].freeze
|
|
17
|
+
COMMAND_IDS = (B2C_COMMAND_IDS + B2B_COMMAND_IDS).freeze
|
|
18
|
+
|
|
19
|
+
schema do
|
|
20
|
+
required(:command_id).filled(:string)
|
|
21
|
+
required(:amount).filled
|
|
22
|
+
required(:party_b).filled(:string)
|
|
23
|
+
required(:remarks).filled(:string)
|
|
24
|
+
required(:result_url).filled(:string)
|
|
25
|
+
required(:queue_timeout_url).filled(:string)
|
|
26
|
+
optional(:occasion).maybe(:string)
|
|
27
|
+
optional(:account_reference).maybe(:string)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
rule(:amount) do
|
|
31
|
+
next key.failure("must be a Lipwa::Money") unless value.is_a?(Lipwa::Money)
|
|
32
|
+
|
|
33
|
+
key.failure("must be KES") if value.currency != "KES"
|
|
34
|
+
key.failure("must be greater than zero") if value.amount <= 0
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
rule(:command_id) do
|
|
38
|
+
key.failure("must be one of #{COMMAND_IDS.join(", ")}") unless COMMAND_IDS.include?(value)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
rule(:party_b, :command_id) do
|
|
42
|
+
next unless B2C_COMMAND_IDS.include?(values[:command_id])
|
|
43
|
+
|
|
44
|
+
unless values[:party_b].match?(/\A254[17]\d{8}\z/)
|
|
45
|
+
key(:party_b).failure("must be a Safaricom MSISDN, e.g. 254712345678")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
rule(:account_reference, :command_id) do
|
|
50
|
+
next unless B2B_COMMAND_IDS.include?(values[:command_id])
|
|
51
|
+
|
|
52
|
+
if values[:account_reference].nil? || values[:account_reference].empty?
|
|
53
|
+
key(:account_reference).failure("is required for B2B command IDs")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
rule(:result_url) do
|
|
58
|
+
key.failure("must be an https:// URL") unless value.start_with?("https://")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
rule(:queue_timeout_url) do
|
|
62
|
+
key.failure("must be an https:// URL") unless value.start_with?("https://")
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/validation"
|
|
4
|
+
require_relative "../money"
|
|
5
|
+
|
|
6
|
+
module Lipwa
|
|
7
|
+
module Contracts
|
|
8
|
+
# Validates the kwargs Lipwa::Capabilities::Refund#refund is called
|
|
9
|
+
# with, before any network call is made. ReceiverParty and
|
|
10
|
+
# RecieverIdentifierType aren't part of the schema — like
|
|
11
|
+
# StatusQuery's PartyA, they're always the gateway's own shortcode,
|
|
12
|
+
# not a per-call param.
|
|
13
|
+
class RefundContract < Dry::Validation::Contract
|
|
14
|
+
schema do
|
|
15
|
+
required(:transaction_id).filled(:string)
|
|
16
|
+
required(:amount).filled
|
|
17
|
+
required(:remarks).filled(:string)
|
|
18
|
+
required(:result_url).filled(:string)
|
|
19
|
+
required(:queue_timeout_url).filled(:string)
|
|
20
|
+
optional(:occasion).maybe(:string)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
rule(:amount) do
|
|
24
|
+
next key.failure("must be a Lipwa::Money") unless value.is_a?(Lipwa::Money)
|
|
25
|
+
|
|
26
|
+
key.failure("must be KES") if value.currency != "KES"
|
|
27
|
+
key.failure("must be greater than zero") if value.amount <= 0
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
rule(:result_url) do
|
|
31
|
+
key.failure("must be an https:// URL") unless value.start_with?("https://")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
rule(:queue_timeout_url) do
|
|
35
|
+
key.failure("must be an https:// URL") unless value.start_with?("https://")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/validation"
|
|
4
|
+
|
|
5
|
+
module Lipwa
|
|
6
|
+
module Contracts
|
|
7
|
+
# Validates the kwargs Lipwa::Capabilities::StatusQuery#status is
|
|
8
|
+
# called with, before any network call is made. PartyA and
|
|
9
|
+
# IdentifierType aren't part of the schema — like Disbursement's
|
|
10
|
+
# PartyA, they're always the gateway's own shortcode, not a
|
|
11
|
+
# per-call param.
|
|
12
|
+
class StatusQueryContract < Dry::Validation::Contract
|
|
13
|
+
schema do
|
|
14
|
+
required(:transaction_id).filled(:string)
|
|
15
|
+
required(:remarks).filled(:string)
|
|
16
|
+
required(:result_url).filled(:string)
|
|
17
|
+
required(:queue_timeout_url).filled(:string)
|
|
18
|
+
optional(:occasion).maybe(:string)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
rule(:result_url) do
|
|
22
|
+
key.failure("must be an https:// URL") unless value.start_with?("https://")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
rule(:queue_timeout_url) do
|
|
26
|
+
key.failure("must be an https:// URL") unless value.start_with?("https://")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/validation"
|
|
4
|
+
require_relative "../money"
|
|
5
|
+
|
|
6
|
+
module Lipwa
|
|
7
|
+
module Contracts
|
|
8
|
+
# Validates the kwargs Lipwa::Capabilities::StkPush#stk_push is
|
|
9
|
+
# called with, before any network call is made. Validates the
|
|
10
|
+
# capability's own params, not the raw Daraja wire payload.
|
|
11
|
+
class StkPushContract < Dry::Validation::Contract
|
|
12
|
+
schema do
|
|
13
|
+
required(:amount).filled
|
|
14
|
+
required(:phone_number).filled(:string)
|
|
15
|
+
required(:account_reference).filled(:string)
|
|
16
|
+
required(:callback_url).filled(:string)
|
|
17
|
+
optional(:transaction_desc).maybe(:string)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
rule(:amount) do
|
|
21
|
+
next key.failure("must be a Lipwa::Money") unless value.is_a?(Lipwa::Money)
|
|
22
|
+
|
|
23
|
+
key.failure("must be KES") if value.currency != "KES"
|
|
24
|
+
key.failure("must be greater than zero") if value.amount <= 0
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
rule(:phone_number) do
|
|
28
|
+
key.failure("must be a Safaricom MSISDN, e.g. 254712345678") unless value.match?(/\A254[17]\d{8}\z/)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
rule(:account_reference) do
|
|
32
|
+
key.failure("must be 12 characters or fewer") if value.length > 12
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
rule(:callback_url) do
|
|
36
|
+
key.failure("must be an https:// URL") unless value.start_with?("https://")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/lipwa/errors.rb
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lipwa
|
|
4
|
+
# Base class for everything the gem raises or wraps in a Failure.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised (not wrapped) when a gateway is misconfigured — missing
|
|
8
|
+
# credentials, invalid environment, etc. A programmer/ops mistake,
|
|
9
|
+
# not something callers should route through Result handling.
|
|
10
|
+
class ConfigurationError < Error; end
|
|
11
|
+
|
|
12
|
+
# Raised (not wrapped) when calling a capability method a gateway
|
|
13
|
+
# doesn't include. A programmer mistake caught at call time.
|
|
14
|
+
class UnsupportedCapabilityError < Error; end
|
|
15
|
+
|
|
16
|
+
# Raised (not wrapped) when Lipwa::Webhook.parse_webhook is called for
|
|
17
|
+
# a provider with no registered parser. A programmer/config mistake,
|
|
18
|
+
# not something callers should route through Result handling.
|
|
19
|
+
class UnsupportedProviderError < Error; end
|
|
20
|
+
|
|
21
|
+
# Wrapped in Failure(...). The inbound webhook body could not be
|
|
22
|
+
# parsed (e.g. invalid JSON) — a runtime condition on untrusted
|
|
23
|
+
# network input, not a programmer mistake.
|
|
24
|
+
class WebhookParseError < Error; end
|
|
25
|
+
|
|
26
|
+
# Wrapped in Failure(...). Request params failed contract validation
|
|
27
|
+
# before any network call was made.
|
|
28
|
+
class ValidationError < Error
|
|
29
|
+
attr_reader :validation_result
|
|
30
|
+
|
|
31
|
+
def initialize(validation_result)
|
|
32
|
+
@validation_result = validation_result
|
|
33
|
+
super(validation_result.errors.to_h.to_s)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Wrapped in Failure(...). The provider's API returned an error, or
|
|
38
|
+
# the HTTP call itself failed (timeout, connection error, etc).
|
|
39
|
+
class GatewayError < Error
|
|
40
|
+
attr_reader :code, :raw
|
|
41
|
+
|
|
42
|
+
def initialize(message, code: nil, raw: nil)
|
|
43
|
+
@code = code
|
|
44
|
+
@raw = raw
|
|
45
|
+
super(message)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|