paytree 0.3.0 → 0.4.0
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/Gemfile.lock +35 -37
- data/README.md +1 -1
- data/lib/paytree/configs/mpesa.rb +2 -10
- data/lib/paytree/mpesa/adapters/daraja/base.rb +16 -89
- data/lib/paytree/mpesa/adapters/daraja/http_client_factory.rb +62 -0
- data/lib/paytree/mpesa/adapters/daraja/response_helpers.rb +12 -2
- data/lib/paytree/mpesa/adapters/daraja/token_manager.rb +74 -0
- data/lib/paytree/mpesa/adapters/daraja/validator.rb +89 -0
- data/lib/paytree/utils/error_handling.rb +54 -27
- data/lib/paytree/version.rb +1 -1
- data/lib/paytree.rb +1 -1
- data/paytree.gemspec +1 -1
- metadata +8 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 00f6d817e282de2ec9b8b94a6ff632ddb957b2c52f67cefe89a7afbf7dd6aee9
|
4
|
+
data.tar.gz: 9815218a37b228ee2e5b75e85bf3a7d0378b47907436df845a9163f6ee303de1
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: db7d90e5c0564309bb45394c66738f5e932172012c4f054417a2f7c357a08d5a7eb6dd2ce1b5bda6f8d32d80c044bc91dee633c14bab92475a2427466ed8d8b3
|
7
|
+
data.tar.gz: a9d0a1b6a51500501d8737edee17d7d56f10d21eab22135bd7a37a93fb49efe954f352106e0093937436daa0b8e6458499b2a635270bc1c90b88612b9510ec31
|
data/Gemfile.lock
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
paytree (0.
|
5
|
-
|
4
|
+
paytree (0.4.0)
|
5
|
+
httpx (~> 1.0)
|
6
6
|
|
7
7
|
GEM
|
8
8
|
remote: https://rubygems.org/
|
9
9
|
specs:
|
10
|
-
activesupport (8.0.
|
10
|
+
activesupport (8.0.3)
|
11
11
|
base64
|
12
12
|
benchmark (>= 0.3)
|
13
13
|
bigdecimal
|
@@ -25,23 +25,20 @@ GEM
|
|
25
25
|
ast (2.4.3)
|
26
26
|
base64 (0.3.0)
|
27
27
|
benchmark (0.4.1)
|
28
|
-
bigdecimal (3.
|
28
|
+
bigdecimal (3.3.1)
|
29
29
|
concurrent-ruby (1.3.5)
|
30
|
-
connection_pool (2.5.
|
30
|
+
connection_pool (2.5.4)
|
31
31
|
crack (1.0.0)
|
32
32
|
bigdecimal
|
33
33
|
rexml
|
34
34
|
date (3.4.1)
|
35
35
|
diff-lcs (1.6.2)
|
36
36
|
drb (2.2.3)
|
37
|
-
erb (5.
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
faraday-net_http (3.4.1)
|
43
|
-
net-http (>= 0.5.0)
|
44
|
-
hashdiff (1.2.0)
|
37
|
+
erb (5.1.1)
|
38
|
+
hashdiff (1.2.1)
|
39
|
+
http-2 (1.1.1)
|
40
|
+
httpx (1.6.2)
|
41
|
+
http-2 (>= 1.0.0)
|
45
42
|
i18n (1.14.7)
|
46
43
|
concurrent-ruby (~> 1.0)
|
47
44
|
io-console (0.8.1)
|
@@ -49,36 +46,35 @@ GEM
|
|
49
46
|
pp (>= 0.6.0)
|
50
47
|
rdoc (>= 4.0.0)
|
51
48
|
reline (>= 0.4.2)
|
52
|
-
json (2.
|
49
|
+
json (2.15.1)
|
53
50
|
language_server-protocol (3.17.0.5)
|
54
51
|
lint_roller (1.1.0)
|
55
52
|
logger (1.7.0)
|
56
|
-
minitest (5.
|
57
|
-
net-http (0.6.0)
|
58
|
-
uri
|
53
|
+
minitest (5.26.0)
|
59
54
|
parallel (1.27.0)
|
60
|
-
parser (3.3.
|
55
|
+
parser (3.3.9.0)
|
61
56
|
ast (~> 2.4.1)
|
62
57
|
racc
|
63
|
-
pp (0.6.
|
58
|
+
pp (0.6.3)
|
64
59
|
prettyprint
|
65
60
|
prettyprint (0.2.0)
|
66
|
-
prism (1.
|
61
|
+
prism (1.6.0)
|
67
62
|
psych (5.2.6)
|
68
63
|
date
|
69
64
|
stringio
|
70
65
|
public_suffix (6.0.2)
|
71
66
|
racc (1.8.1)
|
72
|
-
rack (3.
|
67
|
+
rack (3.2.3)
|
73
68
|
rainbow (3.1.1)
|
74
69
|
rake (13.3.0)
|
75
|
-
rdoc (6.
|
70
|
+
rdoc (6.15.0)
|
76
71
|
erb
|
77
72
|
psych (>= 4.0.0)
|
78
|
-
|
79
|
-
|
73
|
+
tsort
|
74
|
+
regexp_parser (2.11.3)
|
75
|
+
reline (0.6.2)
|
80
76
|
io-console (~> 0.5)
|
81
|
-
rexml (3.4.
|
77
|
+
rexml (3.4.4)
|
82
78
|
rspec (3.13.1)
|
83
79
|
rspec-core (~> 3.13.0)
|
84
80
|
rspec-expectations (~> 3.13.0)
|
@@ -88,11 +84,11 @@ GEM
|
|
88
84
|
rspec-expectations (3.13.5)
|
89
85
|
diff-lcs (>= 1.2.0, < 2.0)
|
90
86
|
rspec-support (~> 3.13.0)
|
91
|
-
rspec-mocks (3.13.
|
87
|
+
rspec-mocks (3.13.6)
|
92
88
|
diff-lcs (>= 1.2.0, < 2.0)
|
93
89
|
rspec-support (~> 3.13.0)
|
94
|
-
rspec-support (3.13.
|
95
|
-
rubocop (1.
|
90
|
+
rspec-support (3.13.6)
|
91
|
+
rubocop (1.80.2)
|
96
92
|
json (~> 2.3)
|
97
93
|
language_server-protocol (~> 3.17.0.2)
|
98
94
|
lint_roller (~> 1.1.0)
|
@@ -100,17 +96,17 @@ GEM
|
|
100
96
|
parser (>= 3.3.0.2)
|
101
97
|
rainbow (>= 2.2.2, < 4.0)
|
102
98
|
regexp_parser (>= 2.9.3, < 3.0)
|
103
|
-
rubocop-ast (>= 1.
|
99
|
+
rubocop-ast (>= 1.46.0, < 2.0)
|
104
100
|
ruby-progressbar (~> 1.7)
|
105
101
|
unicode-display_width (>= 2.4.0, < 4.0)
|
106
|
-
rubocop-ast (1.
|
102
|
+
rubocop-ast (1.47.1)
|
107
103
|
parser (>= 3.3.7.2)
|
108
104
|
prism (~> 1.4)
|
109
105
|
rubocop-performance (1.25.0)
|
110
106
|
lint_roller (~> 1.1)
|
111
107
|
rubocop (>= 1.75.0, < 2.0)
|
112
108
|
rubocop-ast (>= 1.38.0, < 2.0)
|
113
|
-
rubocop-rails (2.
|
109
|
+
rubocop-rails (2.33.4)
|
114
110
|
activesupport (>= 4.2.0)
|
115
111
|
lint_roller (~> 1.1)
|
116
112
|
rack (>= 1.1)
|
@@ -122,10 +118,10 @@ GEM
|
|
122
118
|
rubocop-rails (>= 2.30)
|
123
119
|
ruby-progressbar (1.13.0)
|
124
120
|
securerandom (0.4.1)
|
125
|
-
standard (1.
|
121
|
+
standard (1.51.1)
|
126
122
|
language_server-protocol (~> 3.17.0.2)
|
127
123
|
lint_roller (~> 1.0)
|
128
|
-
rubocop (~> 1.
|
124
|
+
rubocop (~> 1.80.2)
|
129
125
|
standard-custom (~> 1.0.0)
|
130
126
|
standard-performance (~> 1.8)
|
131
127
|
standard-custom (1.0.2)
|
@@ -135,18 +131,20 @@ GEM
|
|
135
131
|
lint_roller (~> 1.1)
|
136
132
|
rubocop-performance (~> 1.25.0)
|
137
133
|
stringio (3.1.7)
|
134
|
+
tsort (0.2.0)
|
138
135
|
tzinfo (2.0.6)
|
139
136
|
concurrent-ruby (~> 1.0)
|
140
|
-
unicode-display_width (3.
|
141
|
-
unicode-emoji (~> 4.
|
142
|
-
unicode-emoji (4.0
|
143
|
-
uri (1.0.
|
137
|
+
unicode-display_width (3.2.0)
|
138
|
+
unicode-emoji (~> 4.1)
|
139
|
+
unicode-emoji (4.1.0)
|
140
|
+
uri (1.0.4)
|
144
141
|
webmock (3.25.1)
|
145
142
|
addressable (>= 2.8.0)
|
146
143
|
crack (>= 0.3.2)
|
147
144
|
hashdiff (>= 0.4.0, < 2.0.0)
|
148
145
|
|
149
146
|
PLATFORMS
|
147
|
+
arm64-darwin-25
|
150
148
|
x86_64-darwin-24
|
151
149
|
x86_64-darwin-25
|
152
150
|
x86_64-linux
|
data/README.md
CHANGED
@@ -409,7 +409,7 @@ Paytree allows you to configure which error codes should be considered retryable
|
|
409
409
|
- `"503.001.01"` - Service temporarily unavailable
|
410
410
|
- `"timeout.connection"` - Network connection timeout (Net::OpenTimeout)
|
411
411
|
- `"timeout.read"` - Network read timeout (Net::ReadTimeout)
|
412
|
-
- `"timeout.request"` - HTTP request timeout (
|
412
|
+
- `"timeout.request"` - HTTP request timeout (HTTPX::TimeoutError)
|
413
413
|
|
414
414
|
Configure retryable errors during setup:
|
415
415
|
|
@@ -3,6 +3,7 @@ require "logger"
|
|
3
3
|
module Paytree
|
4
4
|
module Configs
|
5
5
|
class Mpesa
|
6
|
+
attr_writer :logger
|
6
7
|
attr_accessor :key, :secret, :shortcode, :passkey, :adapter,
|
7
8
|
:initiator_name, :initiator_password, :sandbox,
|
8
9
|
:extras, :timeout, :retryable_errors, :api_version
|
@@ -10,7 +11,6 @@ module Paytree
|
|
10
11
|
def initialize
|
11
12
|
@extras = {}
|
12
13
|
@logger = nil
|
13
|
-
@mutex = Mutex.new
|
14
14
|
@timeout = 30 # Default 30 second timeout
|
15
15
|
@retryable_errors = [] # Default empty array
|
16
16
|
@api_version = "v1" # Default to v1 for backward compatibility
|
@@ -21,15 +21,7 @@ module Paytree
|
|
21
21
|
end
|
22
22
|
|
23
23
|
def logger
|
24
|
-
@
|
25
|
-
@logger ||= Logger.new($stdout)
|
26
|
-
end
|
27
|
-
end
|
28
|
-
|
29
|
-
def logger=(new_logger)
|
30
|
-
@mutex.synchronize do
|
31
|
-
@logger = new_logger
|
32
|
-
end
|
24
|
+
@logger ||= Logger.new($stdout)
|
33
25
|
end
|
34
26
|
end
|
35
27
|
end
|
@@ -1,6 +1,9 @@
|
|
1
1
|
require "base64"
|
2
2
|
require "securerandom"
|
3
3
|
require_relative "response_helpers"
|
4
|
+
require_relative "http_client_factory"
|
5
|
+
require_relative "validator"
|
6
|
+
require_relative "token_manager"
|
4
7
|
require_relative "../../../utils/error_handling"
|
5
8
|
|
6
9
|
module Paytree
|
@@ -11,36 +14,32 @@ module Paytree
|
|
11
14
|
class << self
|
12
15
|
include Paytree::Utils::ErrorHandling
|
13
16
|
include Paytree::Mpesa::Adapters::Daraja::ResponseHelpers
|
17
|
+
include Paytree::Mpesa::Adapters::Daraja::HttpClientFactory
|
18
|
+
include Paytree::Mpesa::Adapters::Daraja::Validator
|
19
|
+
include Paytree::Mpesa::Adapters::Daraja::TokenManager
|
14
20
|
|
15
21
|
def config = Paytree[:mpesa]
|
16
22
|
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
23
|
+
# Thread-safe HTTP client for regular API calls
|
24
|
+
def http_client
|
25
|
+
thread_safe_client(:@http_client)
|
26
|
+
end
|
21
27
|
|
22
|
-
|
23
|
-
|
24
|
-
|
28
|
+
# Thread-safe HTTP client with retry logic for token fetching
|
29
|
+
# Retries on: timeouts, connection errors, and 5xx server errors
|
30
|
+
def token_http_client
|
31
|
+
thread_safe_client(:@token_http_client, plugins: [:retries], **retry_options)
|
25
32
|
end
|
26
33
|
|
27
34
|
def post_to_mpesa(operation, endpoint, payload)
|
28
|
-
|
29
|
-
|
30
|
-
operation
|
31
|
-
)
|
35
|
+
response = http_client.post(endpoint, json: payload, headers:)
|
36
|
+
build_response(response, operation)
|
32
37
|
end
|
33
38
|
|
34
39
|
def headers
|
35
40
|
{"Authorization" => "Bearer #{token}", "Content-Type" => "application/json"}
|
36
41
|
end
|
37
42
|
|
38
|
-
def token
|
39
|
-
return @token if token_valid?
|
40
|
-
|
41
|
-
fetch_token
|
42
|
-
end
|
43
|
-
|
44
43
|
def encrypt_credential(config)
|
45
44
|
cert_path = config.extras[:cert_path]
|
46
45
|
unless cert_path && File.exist?(cert_path)
|
@@ -56,81 +55,9 @@ module Paytree
|
|
56
55
|
"Failed to encrypt password with certificate #{cert_path}: #{e.message}"
|
57
56
|
end
|
58
57
|
|
59
|
-
# ------------------------------------------------------------------
|
60
|
-
# Validation rules
|
61
|
-
# ------------------------------------------------------------------
|
62
|
-
VALIDATIONS = {
|
63
|
-
c2b_register: {required: %i[short_code confirmation_url validation_url]},
|
64
|
-
c2b_simulate: {required: %i[phone_number amount reference]},
|
65
|
-
stk_push: {required: %i[phone_number amount reference]},
|
66
|
-
b2c: {required: %i[phone_number amount], config: %i[result_url]},
|
67
|
-
b2b: {
|
68
|
-
required: %i[short_code receiver_shortcode account_reference amount],
|
69
|
-
config: %i[result_url timeout_url],
|
70
|
-
command_id: %w[BusinessPayBill BusinessBuyGoods]
|
71
|
-
}
|
72
|
-
}.freeze
|
73
|
-
|
74
|
-
def validate_for(operation, params = {})
|
75
|
-
rules = VALIDATIONS[operation] ||
|
76
|
-
raise(Paytree::Errors::UnsupportedOperation, "Unknown operation: #{operation}")
|
77
|
-
|
78
|
-
Array(rules[:required]).each { |field| validate_field(field, params[field]) }
|
79
|
-
|
80
|
-
Array(rules[:config]).each do |key|
|
81
|
-
unless config.extras[key]
|
82
|
-
raise Paytree::Errors::ConfigurationError, "Missing `#{key}` in Mpesa extras config"
|
83
|
-
end
|
84
|
-
end
|
85
|
-
|
86
|
-
if (allowed = rules[:command_id]) && !allowed.include?(params[:command_id])
|
87
|
-
raise Paytree::Errors::ValidationError,
|
88
|
-
"command_id must be one of: #{allowed.join(", ")}"
|
89
|
-
end
|
90
|
-
end
|
91
|
-
|
92
|
-
def validate_field(field, value)
|
93
|
-
case field
|
94
|
-
when :amount
|
95
|
-
unless value.is_a?(Numeric) && value >= 1
|
96
|
-
raise Paytree::Errors::ValidationError,
|
97
|
-
"amount must be a positive number"
|
98
|
-
end
|
99
|
-
when :phone_number
|
100
|
-
phone_regex = /^254\d{9}$/
|
101
|
-
unless value.to_s.match?(phone_regex)
|
102
|
-
raise Paytree::Errors::ValidationError,
|
103
|
-
"phone_number must be a valid Kenyan format (254XXXXXXXXX)"
|
104
|
-
end
|
105
|
-
else
|
106
|
-
raise Paytree::Errors::ValidationError, "#{field} cannot be blank" if value.to_s.strip.empty?
|
107
|
-
end
|
108
|
-
end
|
109
|
-
|
110
58
|
def generate_conversation_id
|
111
59
|
SecureRandom.uuid
|
112
60
|
end
|
113
|
-
|
114
|
-
private
|
115
|
-
|
116
|
-
def fetch_token
|
117
|
-
cred = Base64.strict_encode64("#{config.key}:#{config.secret}")
|
118
|
-
|
119
|
-
response = connection.get("/oauth/v1/generate", grant_type: "client_credentials") do |r|
|
120
|
-
r.headers["Authorization"] = "Basic #{cred}"
|
121
|
-
end
|
122
|
-
|
123
|
-
data = response.body
|
124
|
-
@token = data["access_token"]
|
125
|
-
@token_expiry = Time.now + data["expires_in"].to_i
|
126
|
-
@token
|
127
|
-
rescue Faraday::Error => e
|
128
|
-
raise Paytree::Errors::MpesaTokenError, "Unable to fetch token: #{e.message}"
|
129
|
-
end
|
130
|
-
|
131
|
-
def token_valid?
|
132
|
-
@token && @token_expiry && Time.now < @token_expiry
|
133
|
-
end
|
134
61
|
end
|
135
62
|
end
|
136
63
|
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
module Paytree
|
2
|
+
module Mpesa
|
3
|
+
module Adapters
|
4
|
+
module Daraja
|
5
|
+
module HttpClientFactory
|
6
|
+
private
|
7
|
+
|
8
|
+
# Creates a thread-safe memoized HTTP client
|
9
|
+
# @param ivar_name [Symbol] Instance variable name (e.g., :@http_client)
|
10
|
+
# @param plugins [Array<Symbol>] HTTPX plugins to load (e.g., [:retries])
|
11
|
+
# @param options [Hash] Additional HTTPX options
|
12
|
+
# @return [HTTPX::Session] Configured HTTP client
|
13
|
+
def thread_safe_client(ivar_name, plugins: [], **options)
|
14
|
+
cached = instance_variable_get(ivar_name)
|
15
|
+
return cached if cached
|
16
|
+
|
17
|
+
mutex_name = :"#{ivar_name}_mutex"
|
18
|
+
mutex = instance_variable_get(mutex_name) || instance_variable_set(mutex_name, Mutex.new)
|
19
|
+
|
20
|
+
mutex.synchronize do
|
21
|
+
cached = instance_variable_get(ivar_name)
|
22
|
+
return cached if cached
|
23
|
+
|
24
|
+
client = plugins.reduce(HTTPX) { |c, plugin| c.plugin(plugin) }
|
25
|
+
|
26
|
+
instance_variable_set(
|
27
|
+
ivar_name,
|
28
|
+
client.with(base_http_options.merge(options))
|
29
|
+
)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def base_http_options
|
34
|
+
{
|
35
|
+
origin: config.base_url,
|
36
|
+
timeout: {
|
37
|
+
connect_timeout: config.timeout / 2,
|
38
|
+
operation_timeout: config.timeout
|
39
|
+
},
|
40
|
+
ssl: {
|
41
|
+
verify_mode: OpenSSL::SSL::VERIFY_NONE
|
42
|
+
}
|
43
|
+
}
|
44
|
+
end
|
45
|
+
|
46
|
+
def retry_options
|
47
|
+
{
|
48
|
+
max_retries: 3,
|
49
|
+
retry_change_requests: true,
|
50
|
+
retry_on: ->(response) {
|
51
|
+
# Retry on network errors (timeouts, connection failures)
|
52
|
+
return true if response.is_a?(HTTPX::ErrorResponse)
|
53
|
+
# Retry on 5xx server errors
|
54
|
+
[500, 502, 503, 504].include?(response.status)
|
55
|
+
}
|
56
|
+
}
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -4,12 +4,18 @@ module Paytree
|
|
4
4
|
module Daraja
|
5
5
|
module ResponseHelpers
|
6
6
|
def build_response(response, operation)
|
7
|
-
|
7
|
+
# Handle ErrorResponse from HTTPX
|
8
|
+
if response.is_a?(HTTPX::ErrorResponse)
|
9
|
+
raise response.error if response.error
|
10
|
+
raise HTTPX::Error, "Request failed"
|
11
|
+
end
|
12
|
+
|
13
|
+
parsed = response.json
|
8
14
|
|
9
15
|
Paytree::Response.new(
|
10
16
|
provider: :mpesa,
|
11
17
|
operation:,
|
12
|
-
status: response
|
18
|
+
status: successful_response?(response) ? :success : :error,
|
13
19
|
message: response_message(parsed),
|
14
20
|
code: response_code(parsed),
|
15
21
|
data: parsed,
|
@@ -19,6 +25,10 @@ module Paytree
|
|
19
25
|
|
20
26
|
private
|
21
27
|
|
28
|
+
def successful_response?(response)
|
29
|
+
response.status >= 200 && response.status < 300
|
30
|
+
end
|
31
|
+
|
22
32
|
def response_message(parsed)
|
23
33
|
parsed["ResponseDescription"] ||
|
24
34
|
parsed["ResultDesc"] ||
|
@@ -0,0 +1,74 @@
|
|
1
|
+
require "base64"
|
2
|
+
|
3
|
+
module Paytree
|
4
|
+
module Mpesa
|
5
|
+
module Adapters
|
6
|
+
module Daraja
|
7
|
+
# Thread-safe token management for M-Pesa Daraja API
|
8
|
+
# Handles OAuth token fetching, caching, and expiry validation
|
9
|
+
module TokenManager
|
10
|
+
# Returns a valid access token, fetching a new one if needed
|
11
|
+
# @return [String] Valid access token
|
12
|
+
def token
|
13
|
+
return @token if token_valid?
|
14
|
+
|
15
|
+
@token_mutex ||= Mutex.new
|
16
|
+
@token_mutex.synchronize do
|
17
|
+
return @token if token_valid?
|
18
|
+
|
19
|
+
fetch_token
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
# Fetches a new OAuth token from M-Pesa API
|
26
|
+
# Automatically retries on network errors and 5xx responses
|
27
|
+
# @return [String] Access token
|
28
|
+
# @raise [Paytree::Errors::MpesaTokenError] if token fetch fails
|
29
|
+
def fetch_token
|
30
|
+
credentials = encode_credentials
|
31
|
+
|
32
|
+
response = token_http_client.get(
|
33
|
+
"/oauth/v1/generate",
|
34
|
+
params: {grant_type: "client_credentials"},
|
35
|
+
headers: {"Authorization" => "Basic #{credentials}"}
|
36
|
+
)
|
37
|
+
|
38
|
+
validate_token_response(response)
|
39
|
+
parse_and_cache_token(response)
|
40
|
+
rescue HTTPX::Error => e
|
41
|
+
raise Paytree::Errors::MpesaTokenError, "Unable to fetch token: #{e.message}"
|
42
|
+
end
|
43
|
+
|
44
|
+
def encode_credentials
|
45
|
+
Base64.strict_encode64("#{config.key}:#{config.secret}")
|
46
|
+
end
|
47
|
+
|
48
|
+
def validate_token_response(response)
|
49
|
+
if response.is_a?(HTTPX::ErrorResponse) || response.error
|
50
|
+
error_msg = response.error ? response.error.message : "Request failed"
|
51
|
+
raise Paytree::Errors::MpesaTokenError, "Unable to fetch token: #{error_msg}"
|
52
|
+
end
|
53
|
+
|
54
|
+
unless response.status == 200
|
55
|
+
raise Paytree::Errors::MpesaTokenError,
|
56
|
+
"Token request failed with status #{response.status}"
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def parse_and_cache_token(response)
|
61
|
+
response_data = response.json
|
62
|
+
@token = response_data["access_token"]
|
63
|
+
@token_expiry = Time.now + response_data["expires_in"].to_i
|
64
|
+
@token
|
65
|
+
end
|
66
|
+
|
67
|
+
def token_valid?
|
68
|
+
@token && @token_expiry && Time.now < @token_expiry
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,89 @@
|
|
1
|
+
module Paytree
|
2
|
+
module Mpesa
|
3
|
+
module Adapters
|
4
|
+
module Daraja
|
5
|
+
# Validation module for M-Pesa API operations
|
6
|
+
# Validates required fields, config values, and operation-specific parameters
|
7
|
+
module Validator
|
8
|
+
VALIDATIONS = {
|
9
|
+
c2b_register: {required: %i[short_code confirmation_url validation_url]},
|
10
|
+
c2b_simulate: {required: %i[phone_number amount reference]},
|
11
|
+
stk_push: {required: %i[phone_number amount reference]},
|
12
|
+
b2c: {required: %i[phone_number amount], config: %i[result_url]},
|
13
|
+
b2b: {
|
14
|
+
required: %i[short_code receiver_shortcode account_reference amount],
|
15
|
+
config: %i[result_url timeout_url],
|
16
|
+
command_id: %w[BusinessPayBill BusinessBuyGoods]
|
17
|
+
}
|
18
|
+
}.freeze
|
19
|
+
|
20
|
+
# Validates parameters for a given operation
|
21
|
+
# @param operation [Symbol] The operation to validate for (e.g., :stk_push, :b2c)
|
22
|
+
# @param params [Hash] Parameters to validate
|
23
|
+
# @raise [Paytree::Errors::UnsupportedOperation] if operation is unknown
|
24
|
+
# @raise [Paytree::Errors::ValidationError] if validation fails
|
25
|
+
# @raise [Paytree::Errors::ConfigurationError] if required config is missing
|
26
|
+
def validate_for(operation, params = {})
|
27
|
+
rules = VALIDATIONS[operation] ||
|
28
|
+
raise(Paytree::Errors::UnsupportedOperation, "Unknown operation: #{operation}")
|
29
|
+
|
30
|
+
# Validate required fields
|
31
|
+
Array(rules[:required]).each { |field| validate_field(field, params[field]) }
|
32
|
+
|
33
|
+
# Validate required config values
|
34
|
+
Array(rules[:config]).each do |key|
|
35
|
+
unless config.extras[key]
|
36
|
+
raise Paytree::Errors::ConfigurationError, "Missing `#{key}` in Mpesa extras config"
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
# Validate command_id if specified
|
41
|
+
if (allowed = rules[:command_id]) && !allowed.include?(params[:command_id])
|
42
|
+
raise Paytree::Errors::ValidationError,
|
43
|
+
"command_id must be one of: #{allowed.join(", ")}"
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
# Validates a single field based on its type
|
48
|
+
# @param field [Symbol] Field name
|
49
|
+
# @param value [Object] Field value to validate
|
50
|
+
# @raise [Paytree::Errors::ValidationError] if validation fails
|
51
|
+
def validate_field(field, value)
|
52
|
+
case field
|
53
|
+
when :amount then validate_amount(value)
|
54
|
+
when :phone_number then validate_phone_number(value)
|
55
|
+
else
|
56
|
+
validate_presence(field, value)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
private
|
61
|
+
|
62
|
+
# Validates amount is a positive number
|
63
|
+
def validate_amount(value)
|
64
|
+
unless value.is_a?(Numeric) && value >= 1
|
65
|
+
raise Paytree::Errors::ValidationError,
|
66
|
+
"amount must be a positive number"
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
# Validates phone number is in valid Kenyan format (254XXXXXXXXX)
|
71
|
+
def validate_phone_number(value)
|
72
|
+
phone_regex = /^254\d{9}$/
|
73
|
+
unless value.to_s.match?(phone_regex)
|
74
|
+
raise Paytree::Errors::ValidationError,
|
75
|
+
"phone_number must be a valid Kenyan format (254XXXXXXXXX)"
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
# Validates field is not blank
|
80
|
+
def validate_presence(field, value)
|
81
|
+
if value.to_s.strip.empty?
|
82
|
+
raise Paytree::Errors::ValidationError, "#{field} cannot be blank"
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
@@ -7,35 +7,61 @@ module Paytree
|
|
7
7
|
Paytree::Errors::Base => e
|
8
8
|
emit_error(e, context)
|
9
9
|
raise
|
10
|
-
rescue
|
11
|
-
|
10
|
+
rescue HTTPX::TimeoutError => e
|
11
|
+
handle_http_error(
|
12
12
|
e,
|
13
13
|
context,
|
14
14
|
error_class: Paytree::Errors::MpesaResponseError,
|
15
|
-
error_type: "Timeout"
|
15
|
+
error_type: "Timeout",
|
16
|
+
code: "timeout.request"
|
16
17
|
)
|
17
|
-
rescue
|
18
|
-
|
18
|
+
rescue Net::OpenTimeout => e
|
19
|
+
handle_http_error(
|
19
20
|
e,
|
20
21
|
context,
|
21
|
-
error_class: Paytree::Errors::
|
22
|
-
error_type: "
|
22
|
+
error_class: Paytree::Errors::MpesaResponseError,
|
23
|
+
error_type: "Timeout",
|
24
|
+
code: "timeout.connection"
|
25
|
+
)
|
26
|
+
rescue Net::ReadTimeout => e
|
27
|
+
handle_http_error(
|
28
|
+
e,
|
29
|
+
context,
|
30
|
+
error_class: Paytree::Errors::MpesaResponseError,
|
31
|
+
error_type: "Timeout",
|
32
|
+
code: "timeout.read"
|
23
33
|
)
|
24
|
-
rescue
|
25
|
-
|
34
|
+
rescue JSON::ParserError => e
|
35
|
+
handle_http_error(
|
26
36
|
e,
|
27
37
|
context,
|
28
|
-
error_class: Paytree::Errors::
|
29
|
-
error_type: "
|
30
|
-
extract_info: true
|
38
|
+
error_class: Paytree::Errors::MpesaMalformedResponse,
|
39
|
+
error_type: "Malformed response"
|
31
40
|
)
|
32
|
-
rescue
|
33
|
-
|
41
|
+
rescue HTTPX::HTTPError => e
|
42
|
+
if e.response.status >= 500
|
43
|
+
handle_http_error(
|
44
|
+
e,
|
45
|
+
context,
|
46
|
+
error_class: Paytree::Errors::MpesaServerError,
|
47
|
+
error_type: "Server error",
|
48
|
+
extract_info: true
|
49
|
+
)
|
50
|
+
else
|
51
|
+
handle_http_error(
|
52
|
+
e,
|
53
|
+
context,
|
54
|
+
error_class: Paytree::Errors::MpesaClientError,
|
55
|
+
error_type: "Client error",
|
56
|
+
extract_info: true
|
57
|
+
)
|
58
|
+
end
|
59
|
+
rescue HTTPX::Error => e
|
60
|
+
handle_http_error(
|
34
61
|
e,
|
35
62
|
context,
|
36
|
-
error_class: Paytree::Errors::
|
37
|
-
error_type: "
|
38
|
-
extract_info: true
|
63
|
+
error_class: Paytree::Errors::MpesaResponseError,
|
64
|
+
error_type: "HTTP error"
|
39
65
|
)
|
40
66
|
rescue => e
|
41
67
|
wrap_and_raise(
|
@@ -47,18 +73,13 @@ module Paytree
|
|
47
73
|
|
48
74
|
private
|
49
75
|
|
50
|
-
def
|
76
|
+
def handle_http_error(error, context, error_class:, error_type:, extract_info: false, code: nil)
|
51
77
|
if extract_info
|
52
|
-
info =
|
78
|
+
info = parse_http_error(error)
|
53
79
|
message = info[:message] || error.message
|
54
|
-
code
|
80
|
+
code ||= info[:code]
|
55
81
|
else
|
56
82
|
message = error.message
|
57
|
-
code = case error
|
58
|
-
when Net::OpenTimeout then "timeout.connection"
|
59
|
-
when Net::ReadTimeout then "timeout.read"
|
60
|
-
when Faraday::TimeoutError then "timeout.request"
|
61
|
-
end
|
62
83
|
end
|
63
84
|
|
64
85
|
wrap_and_raise(
|
@@ -92,8 +113,14 @@ module Paytree
|
|
92
113
|
nil
|
93
114
|
end
|
94
115
|
|
95
|
-
def
|
96
|
-
|
116
|
+
def parse_http_error(http_error)
|
117
|
+
return {} unless http_error.respond_to?(:response)
|
118
|
+
|
119
|
+
body = begin
|
120
|
+
http_error.response.json
|
121
|
+
rescue
|
122
|
+
http_error.response.body.to_s
|
123
|
+
end
|
97
124
|
return {} unless body.is_a?(Hash)
|
98
125
|
|
99
126
|
{
|
data/lib/paytree/version.rb
CHANGED
data/lib/paytree.rb
CHANGED
data/paytree.gemspec
CHANGED
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: paytree
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.4.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Charles Chuck
|
@@ -10,19 +10,19 @@ cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
11
11
|
dependencies:
|
12
12
|
- !ruby/object:Gem::Dependency
|
13
|
-
name:
|
13
|
+
name: httpx
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|
15
15
|
requirements:
|
16
16
|
- - "~>"
|
17
17
|
- !ruby/object:Gem::Version
|
18
|
-
version: '
|
18
|
+
version: '1.0'
|
19
19
|
type: :runtime
|
20
20
|
prerelease: false
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
22
22
|
requirements:
|
23
23
|
- - "~>"
|
24
24
|
- !ruby/object:Gem::Version
|
25
|
-
version: '
|
25
|
+
version: '1.0'
|
26
26
|
- !ruby/object:Gem::Dependency
|
27
27
|
name: rspec
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
@@ -112,9 +112,12 @@ files:
|
|
112
112
|
- lib/paytree/mpesa/adapters/daraja/b2c.rb
|
113
113
|
- lib/paytree/mpesa/adapters/daraja/base.rb
|
114
114
|
- lib/paytree/mpesa/adapters/daraja/c2b.rb
|
115
|
+
- lib/paytree/mpesa/adapters/daraja/http_client_factory.rb
|
115
116
|
- lib/paytree/mpesa/adapters/daraja/response_helpers.rb
|
116
117
|
- lib/paytree/mpesa/adapters/daraja/stk_push.rb
|
117
118
|
- lib/paytree/mpesa/adapters/daraja/stk_query.rb
|
119
|
+
- lib/paytree/mpesa/adapters/daraja/token_manager.rb
|
120
|
+
- lib/paytree/mpesa/adapters/daraja/validator.rb
|
118
121
|
- lib/paytree/mpesa/b2b.rb
|
119
122
|
- lib/paytree/mpesa/b2c.rb
|
120
123
|
- lib/paytree/mpesa/c2b.rb
|
@@ -153,7 +156,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
153
156
|
- !ruby/object:Gem::Version
|
154
157
|
version: '0'
|
155
158
|
requirements: []
|
156
|
-
rubygems_version: 3.6.
|
159
|
+
rubygems_version: 3.6.9
|
157
160
|
specification_version: 4
|
158
161
|
summary: A Ruby wrapper for the Mpesa API in Kenya.
|
159
162
|
test_files: []
|