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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: db5f3b07684338b0a9b63dabf3cc2a00d01adbfcb0c5e8511dd734fc3071d0e7
4
- data.tar.gz: aa49b4d56b4eba825fa81ef7a5ff9c75708b92b6036ee5e6439fc1b0821a5e15
3
+ metadata.gz: 00f6d817e282de2ec9b8b94a6ff632ddb957b2c52f67cefe89a7afbf7dd6aee9
4
+ data.tar.gz: 9815218a37b228ee2e5b75e85bf3a7d0378b47907436df845a9163f6ee303de1
5
5
  SHA512:
6
- metadata.gz: d56d06d79d80c8a26569cc9c51b3b55eee3b3ec376e69f40ea21a6e37e325e57a2877cf344be727a98b79614b594ca33feffbadc2591e37f3a5f3d3d5d1d788e
7
- data.tar.gz: d8dade7c6661f6ef505c5b25dbbacbbc744610f2333f9ba8d433bcbc01beea82b694abc7727c9c7a8ecf2d1eccd81e5c69caad380930fc162d3c49ad8f6730b0
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.3.0)
5
- faraday (~> 2.0)
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.2)
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.2.2)
28
+ bigdecimal (3.3.1)
29
29
  concurrent-ruby (1.3.5)
30
- connection_pool (2.5.3)
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.0.2)
38
- faraday (2.13.2)
39
- faraday-net_http (>= 2.0, < 3.5)
40
- json
41
- logger
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.12.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.25.5)
57
- net-http (0.6.0)
58
- uri
53
+ minitest (5.26.0)
59
54
  parallel (1.27.0)
60
- parser (3.3.8.0)
55
+ parser (3.3.9.0)
61
56
  ast (~> 2.4.1)
62
57
  racc
63
- pp (0.6.2)
58
+ pp (0.6.3)
64
59
  prettyprint
65
60
  prettyprint (0.2.0)
66
- prism (1.4.0)
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.1.16)
67
+ rack (3.2.3)
73
68
  rainbow (3.1.1)
74
69
  rake (13.3.0)
75
- rdoc (6.14.2)
70
+ rdoc (6.15.0)
76
71
  erb
77
72
  psych (>= 4.0.0)
78
- regexp_parser (2.10.0)
79
- reline (0.6.1)
73
+ tsort
74
+ regexp_parser (2.11.3)
75
+ reline (0.6.2)
80
76
  io-console (~> 0.5)
81
- rexml (3.4.1)
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.5)
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.4)
95
- rubocop (1.75.8)
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.44.0, < 2.0)
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.45.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.32.0)
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.50.0)
121
+ standard (1.51.1)
126
122
  language_server-protocol (~> 3.17.0.2)
127
123
  lint_roller (~> 1.0)
128
- rubocop (~> 1.75.5)
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.1.4)
141
- unicode-emoji (~> 4.0, >= 4.0.4)
142
- unicode-emoji (4.0.4)
143
- uri (1.0.3)
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 (Faraday::TimeoutError)
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
- @mutex.synchronize do
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
- def connection
18
- @connection ||= Faraday.new(url: config.base_url) do |conn|
19
- conn.options.timeout = config.timeout
20
- conn.options.open_timeout = config.timeout / 2
23
+ # Thread-safe HTTP client for regular API calls
24
+ def http_client
25
+ thread_safe_client(:@http_client)
26
+ end
21
27
 
22
- conn.request :json
23
- conn.response :json, content_type: "application/json"
24
- end
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
- build_response(
29
- connection.post(endpoint, payload.to_json, headers),
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
- parsed = response.body
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.success? ? :success : :error,
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 Faraday::TimeoutError, Net::OpenTimeout, Net::ReadTimeout => e
11
- handle_faraday_error(
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 Faraday::ParsingError, JSON::ParserError => e
18
- handle_faraday_error(
18
+ rescue Net::OpenTimeout => e
19
+ handle_http_error(
19
20
  e,
20
21
  context,
21
- error_class: Paytree::Errors::MpesaMalformedResponse,
22
- error_type: "Malformed response"
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 Faraday::ClientError => e
25
- handle_faraday_error(
34
+ rescue JSON::ParserError => e
35
+ handle_http_error(
26
36
  e,
27
37
  context,
28
- error_class: Paytree::Errors::MpesaClientError,
29
- error_type: "Client error",
30
- extract_info: true
38
+ error_class: Paytree::Errors::MpesaMalformedResponse,
39
+ error_type: "Malformed response"
31
40
  )
32
- rescue Faraday::ServerError => e
33
- handle_faraday_error(
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::MpesaServerError,
37
- error_type: "Server error",
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 handle_faraday_error(error, context, error_class:, error_type:, extract_info: false)
76
+ def handle_http_error(error, context, error_class:, error_type:, extract_info: false, code: nil)
51
77
  if extract_info
52
- info = parse_faraday_error(error)
78
+ info = parse_http_error(error)
53
79
  message = info[:message] || error.message
54
- code = info[: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 parse_faraday_error(faraday_error)
96
- body = faraday_error.response&.dig(:body)
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
  {
@@ -1,3 +1,3 @@
1
1
  module Paytree
2
- VERSION = "0.3.0"
2
+ VERSION = "0.4.0"
3
3
  end
data/lib/paytree.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  require "json"
2
- require "faraday"
2
+ require "httpx"
3
3
 
4
4
  Dir[File.join(__dir__, "paytree/**/*.rb")].sort.each { |file| require file }
5
5
 
data/paytree.gemspec CHANGED
@@ -34,7 +34,7 @@ Gem::Specification.new do |spec|
34
34
  spec.require_paths = ["lib"]
35
35
 
36
36
  # Runtime deps
37
- spec.add_dependency "faraday", "~> 2.0"
37
+ spec.add_dependency "httpx", "~> 1.0"
38
38
 
39
39
  # Dev/test deps
40
40
  spec.add_development_dependency "rspec", "~> 3.12"
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.3.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: faraday
13
+ name: httpx
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '2.0'
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: '2.0'
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.7
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: []