paytree 0.2.1 → 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: 03f7bf934f81e5f84019e8e2a20026e49db6f363433e7c00ce5b6d6ddbf6d177
4
- data.tar.gz: 6698c7711565529fcd2397120d40fad539e2df905832549f06a8b565f0576a85
3
+ metadata.gz: 00f6d817e282de2ec9b8b94a6ff632ddb957b2c52f67cefe89a7afbf7dd6aee9
4
+ data.tar.gz: 9815218a37b228ee2e5b75e85bf3a7d0378b47907436df845a9163f6ee303de1
5
5
  SHA512:
6
- metadata.gz: 3c50a67fe8d0d6b65a6e7268c010097a8c191b991c6bcc5f20ad13cbd227c0621e2fe658d1aec1cb646d1c1e6a95439165b618bf56ed8f6811f37cdfb56c0f5f
7
- data.tar.gz: b4379981cb0ba478550d60de21a74948ca5b7d7ae5d22210cd1cecf6a349a9bfa3bef32fa574d43483bb9bb1b770e432a621315522ac0eb798ef280fe88c9712
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.2.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,19 +131,22 @@ 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
149
+ x86_64-darwin-25
151
150
  x86_64-linux
152
151
 
153
152
  DEPENDENCIES
data/README.md CHANGED
@@ -18,6 +18,8 @@ Currently supports Kenya's M-Pesa via the Daraja API with plans for additional p
18
18
  - **Convention over Configuration**: One clear setup pattern, opinionated defaults
19
19
  - **Safe Defaults**: Sandbox mode, proper timeouts, comprehensive error handling
20
20
  - **Batteries Included**: STK Push, B2C, B2B, C2B operations out of the box
21
+ - **API Versioning**: Support for both Daraja API v1 and v3 with backward compatibility
22
+ - **Enhanced Reliability**: Automatic token retry with exponential backoff
21
23
  - **Security First**: Credential management, no hardcoded secrets
22
24
 
23
25
  ## Quick Start
@@ -89,12 +91,59 @@ Paytree.configure_mpesa(
89
91
  # shortcode: "YOUR_PRODUCTION_SHORTCODE",
90
92
  # passkey: Rails.application.credentials.mpesa[:passkey],
91
93
  # sandbox: false,
92
- # retryable_errors: ["429.001.01", "500.001.02", "503.001.01"] # Optional: errors to retry
94
+ # api_version: "v1", # Optional: "v1" (default) or "v3"
95
+ # retryable_errors: ["429.001.01", "500.001.02", "503.001.01"] # Optional: errors to retry
93
96
  # )
94
97
  ```
95
98
 
96
99
  ---
97
100
 
101
+ ## API Version Support
102
+
103
+ Paytree supports both M-Pesa Daraja API v1 and v3 endpoints. The API version can be configured globally or via environment variables.
104
+
105
+ ### Configuration Options
106
+
107
+ ```ruby
108
+ # Use v1 API (default - backward compatible)
109
+ Paytree.configure_mpesa(
110
+ key: "YOUR_KEY",
111
+ secret: "YOUR_SECRET",
112
+ api_version: "v1" # Default
113
+ )
114
+
115
+ # Use v3 API (latest features)
116
+ Paytree.configure_mpesa(
117
+ key: "YOUR_KEY",
118
+ secret: "YOUR_SECRET",
119
+ api_version: "v3"
120
+ )
121
+
122
+ # Or via environment variable
123
+ ENV['MPESA_API_VERSION'] = 'v3'
124
+ Paytree.configure_mpesa(
125
+ key: "YOUR_KEY",
126
+ secret: "YOUR_SECRET"
127
+ # api_version automatically picked up from ENV
128
+ )
129
+ ```
130
+
131
+ ### Differences Between v1 and v3
132
+
133
+ | Feature | v1 | v3 |
134
+ |---------|----|----|
135
+ | **Endpoints** | `/mpesa/b2c/v1/paymentrequest` | `/mpesa/b2c/v3/paymentrequest` |
136
+ | **OriginatorConversationID** | Not required | Auto-generated UUID |
137
+ | **Reliability** | Standard | Enhanced with better tracking |
138
+
139
+
140
+ **Backward Compatibility:**
141
+ - Existing code continues to work unchanged (defaults to v1)
142
+ - No breaking changes when upgrading Paytree versions
143
+ - Can switch between v1/v3 by changing configuration only
144
+
145
+ ---
146
+
98
147
  ## Usage Examples
99
148
 
100
149
  ### STK Push (Customer Payment)
@@ -168,7 +217,7 @@ end
168
217
 
169
218
  Send funds directly to a customer’s M-Pesa wallet via the B2C API.
170
219
 
171
- ### Example
220
+ ### Basic Example
172
221
  ```ruby
173
222
  response = Paytree::Mpesa::B2C.call(
174
223
  phone_number: "254712345678",
@@ -186,6 +235,43 @@ else
186
235
  end
187
236
  ```
188
237
 
238
+ ### v3 API Features
239
+
240
+ When using `api_version: "v3"`, B2C calls automatically include an `OriginatorConversationID` for enhanced tracking:
241
+
242
+ ```ruby
243
+ # Configure for v3 API
244
+ Paytree.configure_mpesa(
245
+ key: "YOUR_KEY",
246
+ secret: "YOUR_SECRET",
247
+ api_version: "v3"
248
+ )
249
+
250
+ # Same call, but now uses v3 endpoint with auto-generated OriginatorConversationID
251
+ response = Paytree::Mpesa::B2C.call(
252
+ phone_number: "254712345678",
253
+ amount: 100
254
+ )
255
+
256
+ # v3 response includes additional tracking data
257
+ if response.success?
258
+ puts "Conversation ID: #{response.data["ConversationID"]}"
259
+ puts "Originator ID: #{response.data["OriginatorConversationID"]}" # Auto-generated UUID
260
+ end
261
+ ```
262
+
263
+ ### Custom OriginatorConversationID (v3 only)
264
+
265
+ You can provide your own tracking ID for v3 API calls:
266
+
267
+ ```ruby
268
+ response = Paytree::Mpesa::B2C.call(
269
+ phone_number: "254712345678",
270
+ amount: 100,
271
+ originator_conversation_id: "CUSTOM-TRACK-#{Time.now.to_i}"
272
+ )
273
+ ```
274
+
189
275
  ---
190
276
 
191
277
  ## C2B (Customer to Business)
@@ -321,6 +407,9 @@ Paytree allows you to configure which error codes should be considered retryable
321
407
  - `"429.001.01"` - Rate limit exceeded
322
408
  - `"500.001.02"` - Temporary server error
323
409
  - `"503.001.01"` - Service temporarily unavailable
410
+ - `"timeout.connection"` - Network connection timeout (Net::OpenTimeout)
411
+ - `"timeout.read"` - Network read timeout (Net::ReadTimeout)
412
+ - `"timeout.request"` - HTTP request timeout (HTTPX::TimeoutError)
324
413
 
325
414
  Configure retryable errors during setup:
326
415
 
@@ -328,7 +417,14 @@ Configure retryable errors during setup:
328
417
  Paytree.configure_mpesa(
329
418
  key: "YOUR_KEY",
330
419
  secret: "YOUR_SECRET",
331
- retryable_errors: ["429.001.01", "500.001.02", "503.001.01"]
420
+ retryable_errors: [
421
+ "429.001.01", # Rate limit
422
+ "500.001.02", # Server error
423
+ "503.001.01", # Service unavailable
424
+ "timeout.connection", # Connection timeout
425
+ "timeout.read", # Read timeout
426
+ "timeout.request" # Request timeout
427
+ ]
332
428
  )
333
429
  ```
334
430
 
@@ -3,16 +3,17 @@ 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
- :extras, :timeout, :retryable_errors
9
+ :extras, :timeout, :retryable_errors, :api_version
9
10
 
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
+ @api_version = "v1" # Default to v1 for backward compatibility
16
17
  end
17
18
 
18
19
  def base_url
@@ -20,15 +21,7 @@ module Paytree
20
21
  end
21
22
 
22
23
  def logger
23
- @mutex.synchronize do
24
- @logger ||= Logger.new($stdout)
25
- end
26
- end
27
-
28
- def logger=(new_logger)
29
- @mutex.synchronize do
30
- @logger = new_logger
31
- end
24
+ @logger ||= Logger.new($stdout)
32
25
  end
33
26
  end
34
27
  end
@@ -5,7 +5,9 @@ module Paytree
5
5
  module Adapters
6
6
  module Daraja
7
7
  class B2B < Base
8
- ENDPOINT = "/mpesa/b2b/v1/paymentrequest"
8
+ def self.endpoint
9
+ "/mpesa/b2b/#{config.api_version}/paymentrequest"
10
+ end
9
11
 
10
12
  class << self
11
13
  def call(short_code:, receiver_shortcode:, amount:, account_reference:, **opts)
@@ -28,7 +30,7 @@ module Paytree
28
30
  ResultURL: config.extras[:result_url]
29
31
  }.compact
30
32
 
31
- post_to_mpesa(:b2b, ENDPOINT, payload)
33
+ post_to_mpesa(:b2b, endpoint, payload)
32
34
  end
33
35
  end
34
36
  end
@@ -5,7 +5,9 @@ module Paytree
5
5
  module Adapters
6
6
  module Daraja
7
7
  class B2C < Base
8
- ENDPOINT = "/mpesa/b2c/v1/paymentrequest"
8
+ def self.endpoint
9
+ "/mpesa/b2c/#{config.api_version}/paymentrequest"
10
+ end
9
11
 
10
12
  class << self
11
13
  def call(phone_number:, amount:, **opts)
@@ -23,9 +25,14 @@ module Paytree
23
25
  CommandID: opts[:command_id] || "BusinessPayment",
24
26
  Remarks: opts[:remarks] || "OK",
25
27
  Occasion: opts[:occasion] || "Payment"
26
- }.compact
28
+ }
29
+
30
+ # Add OriginatorConversationID for v3
31
+ if config.api_version == "v3"
32
+ payload[:OriginatorConversationID] = opts[:originator_conversation_id] || generate_conversation_id
33
+ end
27
34
 
28
- post_to_mpesa(:b2c, ENDPOINT, payload)
35
+ post_to_mpesa(:b2c, endpoint, payload.compact)
29
36
  end
30
37
  end
31
38
  end
@@ -1,5 +1,9 @@
1
1
  require "base64"
2
+ require "securerandom"
2
3
  require_relative "response_helpers"
4
+ require_relative "http_client_factory"
5
+ require_relative "validator"
6
+ require_relative "token_manager"
3
7
  require_relative "../../../utils/error_handling"
4
8
 
5
9
  module Paytree
@@ -10,36 +14,32 @@ module Paytree
10
14
  class << self
11
15
  include Paytree::Utils::ErrorHandling
12
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
13
20
 
14
21
  def config = Paytree[:mpesa]
15
22
 
16
- def connection
17
- @connection ||= Faraday.new(url: config.base_url) do |conn|
18
- conn.options.timeout = config.timeout
19
- 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
20
27
 
21
- conn.request :json
22
- conn.response :json, content_type: "application/json"
23
- 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)
24
32
  end
25
33
 
26
34
  def post_to_mpesa(operation, endpoint, payload)
27
- build_response(
28
- connection.post(endpoint, payload.to_json, headers),
29
- operation
30
- )
35
+ response = http_client.post(endpoint, json: payload, headers:)
36
+ build_response(response, operation)
31
37
  end
32
38
 
33
39
  def headers
34
40
  {"Authorization" => "Bearer #{token}", "Content-Type" => "application/json"}
35
41
  end
36
42
 
37
- def token
38
- return @token if token_valid?
39
-
40
- fetch_token
41
- end
42
-
43
43
  def encrypt_credential(config)
44
44
  cert_path = config.extras[:cert_path]
45
45
  unless cert_path && File.exist?(cert_path)
@@ -55,76 +55,8 @@ module Paytree
55
55
  "Failed to encrypt password with certificate #{cert_path}: #{e.message}"
56
56
  end
57
57
 
58
- # ------------------------------------------------------------------
59
- # Validation rules
60
- # ------------------------------------------------------------------
61
- VALIDATIONS = {
62
- c2b_register: {required: %i[short_code confirmation_url validation_url]},
63
- c2b_simulate: {required: %i[phone_number amount reference]},
64
- stk_push: {required: %i[phone_number amount reference]},
65
- b2c: {required: %i[phone_number amount], config: %i[result_url]},
66
- b2b: {
67
- required: %i[short_code receiver_shortcode account_reference amount],
68
- config: %i[result_url timeout_url],
69
- command_id: %w[BusinessPayBill BusinessBuyGoods]
70
- }
71
- }.freeze
72
-
73
- def validate_for(operation, params = {})
74
- rules = VALIDATIONS[operation] ||
75
- raise(Paytree::Errors::UnsupportedOperation, "Unknown operation: #{operation}")
76
-
77
- Array(rules[:required]).each { |field| validate_field(field, params[field]) }
78
-
79
- Array(rules[:config]).each do |key|
80
- unless config.extras[key]
81
- raise Paytree::Errors::ConfigurationError, "Missing `#{key}` in Mpesa extras config"
82
- end
83
- end
84
-
85
- if (allowed = rules[:command_id]) && !allowed.include?(params[:command_id])
86
- raise Paytree::Errors::ValidationError,
87
- "command_id must be one of: #{allowed.join(", ")}"
88
- end
89
- end
90
-
91
- def validate_field(field, value)
92
- case field
93
- when :amount
94
- unless value.is_a?(Numeric) && value >= 1
95
- raise Paytree::Errors::ValidationError,
96
- "amount must be a positive number"
97
- end
98
- when :phone_number
99
- phone_regex = /^254\d{9}$/
100
- unless value.to_s.match?(phone_regex)
101
- raise Paytree::Errors::ValidationError,
102
- "phone_number must be a valid Kenyan format (254XXXXXXXXX)"
103
- end
104
- else
105
- raise Paytree::Errors::ValidationError, "#{field} cannot be blank" if value.to_s.strip.empty?
106
- end
107
- end
108
-
109
- private
110
-
111
- def fetch_token
112
- cred = Base64.strict_encode64("#{config.key}:#{config.secret}")
113
-
114
- response = connection.get("/oauth/v1/generate", grant_type: "client_credentials") do |r|
115
- r.headers["Authorization"] = "Basic #{cred}"
116
- end
117
-
118
- data = response.body
119
- @token = data["access_token"]
120
- @token_expiry = Time.now + data["expires_in"].to_i
121
- @token
122
- rescue Faraday::Error => e
123
- raise Paytree::Errors::MpesaTokenError, "Unable to fetch token: #{e.message}"
124
- end
125
-
126
- def token_valid?
127
- @token && @token_expiry && Time.now < @token_expiry
58
+ def generate_conversation_id
59
+ SecureRandom.uuid
128
60
  end
129
61
  end
130
62
  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 => 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,14 +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 = nil
58
83
  end
59
84
 
60
85
  wrap_and_raise(
@@ -88,8 +113,14 @@ module Paytree
88
113
  nil
89
114
  end
90
115
 
91
- def parse_faraday_error(faraday_error)
92
- 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
93
124
  return {} unless body.is_a?(Hash)
94
125
 
95
126
  {
@@ -1,3 +1,3 @@
1
1
  module Paytree
2
- VERSION = "0.2.1"
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
 
@@ -51,7 +51,8 @@ module Paytree
51
51
  passkey: "MPESA_PASSKEY",
52
52
  initiator_name: "MPESA_INITIATOR_NAME",
53
53
  initiator_password: "MPESA_INITIATOR_PASSWORD",
54
- sandbox: "MPESA_SANDBOX"
54
+ sandbox: "MPESA_SANDBOX",
55
+ api_version: "MPESA_API_VERSION"
55
56
  }
56
57
 
57
58
  config = {}
data/paytree.gemspec CHANGED
@@ -27,14 +27,14 @@ Gem::Specification.new do |spec|
27
27
 
28
28
  spec.files = Dir.chdir(__dir__) do
29
29
  `git ls-files -z`.split("\x0").reject do |f|
30
- f.match(%r{^(test|spec|features|bin|exe)/}) || f.include?(".git")
30
+ f.match(%r{^(test|spec|features|bin|exe)/}) || f.include?(".git") || f.end_with?(".gem")
31
31
  end
32
32
  end
33
33
 
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.2.1
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
@@ -123,7 +126,6 @@ files:
123
126
  - lib/paytree/response.rb
124
127
  - lib/paytree/utils/error_handling.rb
125
128
  - lib/paytree/version.rb
126
- - paytree-0.2.0.gem
127
129
  - paytree.gemspec
128
130
  - sig/paytree.rbs
129
131
  homepage: https://github.com/mundanecodes/paytree
data/paytree-0.2.0.gem DELETED
Binary file