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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +16 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +288 -0
  6. data/Rakefile +12 -0
  7. data/lib/lipwa/auth_strategies/base.rb +18 -0
  8. data/lib/lipwa/auth_strategies/bearer_token.rb +27 -0
  9. data/lib/lipwa/auth_strategies/none.rb +14 -0
  10. data/lib/lipwa/auth_strategies.rb +5 -0
  11. data/lib/lipwa/capabilities/c2b.rb +102 -0
  12. data/lib/lipwa/capabilities/disbursement.rb +129 -0
  13. data/lib/lipwa/capabilities/refund.rb +102 -0
  14. data/lib/lipwa/capabilities/status_query.rb +101 -0
  15. data/lib/lipwa/capabilities/stk_push.rb +86 -0
  16. data/lib/lipwa/capability.rb +37 -0
  17. data/lib/lipwa/configuration.rb +13 -0
  18. data/lib/lipwa/contracts/c2b_register_urls_contract.rb +32 -0
  19. data/lib/lipwa/contracts/c2b_simulate_contract.rb +41 -0
  20. data/lib/lipwa/contracts/disbursement_contract.rb +66 -0
  21. data/lib/lipwa/contracts/refund_contract.rb +39 -0
  22. data/lib/lipwa/contracts/status_query_contract.rb +30 -0
  23. data/lib/lipwa/contracts/stk_push_contract.rb +40 -0
  24. data/lib/lipwa/errors.rb +48 -0
  25. data/lib/lipwa/gateway.rb +74 -0
  26. data/lib/lipwa/gateways/mpesa/auth.rb +98 -0
  27. data/lib/lipwa/gateways/mpesa/security_credential.rb +30 -0
  28. data/lib/lipwa/gateways/mpesa.rb +69 -0
  29. data/lib/lipwa/gateways.rb +25 -0
  30. data/lib/lipwa/http_adapter.rb +110 -0
  31. data/lib/lipwa/money.rb +36 -0
  32. data/lib/lipwa/response.rb +21 -0
  33. data/lib/lipwa/types.rb +18 -0
  34. data/lib/lipwa/version.rb +5 -0
  35. data/lib/lipwa/webhook.rb +68 -0
  36. data/lib/lipwa/webhooks/mpesa.rb +101 -0
  37. data/lib/lipwa.rb +32 -0
  38. data/plan.md +282 -0
  39. data/sig/lipwa.rbs +4 -0
  40. 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
@@ -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