paytree 0.2.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.
data/README.md ADDED
@@ -0,0 +1,341 @@
1
+ # Paytree
2
+
3
+ A simple, highly opinionated Rails-optional Ruby gem for mobile money integrations. Currently supports Kenya's M-Pesa via the Daraja API with plans for additional providers.
4
+
5
+ ## Features
6
+
7
+ - **Simple & Minimal**: Clean API with sensible defaults
8
+ - **Convention over Configuration**: One clear setup pattern, opinionated defaults
9
+ - **Safe Defaults**: Sandbox mode, proper timeouts, comprehensive error handling
10
+ - **Batteries Included**: STK Push, B2C, B2B, C2B operations out of the box
11
+ - **Security First**: Credential management, no hardcoded secrets
12
+
13
+ ## Quick Start
14
+
15
+ ### 1. Installation
16
+
17
+ Add to your Gemfile:
18
+
19
+ ```ruby
20
+ gem 'paytree'
21
+ ```
22
+
23
+ Or install directly:
24
+
25
+ ```bash
26
+ gem install paytree
27
+ ```
28
+
29
+ ### 2. Get M-Pesa API Credentials
30
+
31
+ 1. Register at [Safaricom Developer Portal](https://developer.safaricom.co.ke/)
32
+ 2. Create a new app to get your Consumer Key and Secret
33
+ 3. For testing, use the sandbox environment
34
+
35
+ ### 3. Basic Setup
36
+
37
+ ```ruby
38
+ # For quick testing (defaults to sandbox)
39
+ Paytree.configure_mpesa(
40
+ key: "your_consumer_key",
41
+ secret: "your_consumer_secret",
42
+ passkey: "your_passkey"
43
+ )
44
+
45
+ # Make your first payment request
46
+ response = Paytree::Mpesa::StkPush.call(
47
+ phone_number: "254712345678",
48
+ amount: 100,
49
+ reference: "ORDER-001"
50
+ )
51
+
52
+ puts response.success? ? "Payment initiated!" : "Error: #{response.message}"
53
+ ```
54
+
55
+ ---
56
+
57
+ ## Configuration
58
+
59
+ Paytree uses a single `configure_mpesa` method that defaults to sandbox mode for safety.
60
+
61
+ ### Rails Applications (Recommended)
62
+
63
+ Create `config/initializers/paytree.rb`:
64
+
65
+ ```ruby
66
+ # config/initializers/paytree.rb
67
+
68
+ # Development/Testing (defaults to sandbox)
69
+ Paytree.configure_mpesa(
70
+ key: Rails.application.credentials.mpesa[:consumer_key],
71
+ secret: Rails.application.credentials.mpesa[:consumer_secret],
72
+ passkey: Rails.application.credentials.mpesa[:passkey]
73
+ )
74
+
75
+ # Production (explicitly set sandbox: false)
76
+ # Paytree.configure_mpesa(
77
+ # key: Rails.application.credentials.mpesa[:consumer_key],
78
+ # secret: Rails.application.credentials.mpesa[:consumer_secret],
79
+ # shortcode: "YOUR_PRODUCTION_SHORTCODE",
80
+ # passkey: Rails.application.credentials.mpesa[:passkey],
81
+ # sandbox: false,
82
+ # retryable_errors: ["429.001.01", "500.001.02", "503.001.01"] # Optional: errors to retry
83
+ # )
84
+ ```
85
+
86
+ ---
87
+
88
+ ## Usage Examples
89
+
90
+ ### STK Push (Customer Payment)
91
+
92
+ Initiate an M-Pesa STK Push (Lipa na M-Pesa Online) request.
93
+
94
+ #### Basic STK Push
95
+
96
+ ```ruby
97
+ # Initiate payment request - customer receives prompt on their phone
98
+ response = Paytree::Mpesa::StkPush.call(
99
+ phone_number: "254712345678", # Must be in 254XXXXXXXXX format
100
+ amount: 100, # Amount in KES (Kenyan Shillings)
101
+ reference: "ORDER-001" # Your internal reference
102
+ )
103
+
104
+ # Handle the response
105
+ if response.success?
106
+ puts "Payment request sent! Customer will receive STK prompt."
107
+ puts "Checkout Request ID: #{response.data['CheckoutRequestID']}"
108
+
109
+ # Store the CheckoutRequestID to query status later
110
+ order.update(mpesa_checkout_id: response.data['CheckoutRequestID'])
111
+ else
112
+ puts "Payment request failed: #{response.message}"
113
+ Rails.logger.error "STK Push failed for order #{order.id}: #{response.message}"
114
+ end
115
+ ```
116
+
117
+ **Important**: STK Push only initiates the payment request. The customer must complete payment on their phone. Use STK Query or webhooks to get the final status.
118
+
119
+ ### STK Query (Check Payment Status)
120
+
121
+ Query the status of a previously initiated STK Push to see if the customer completed payment.
122
+
123
+ ```ruby
124
+ # Check payment status using the CheckoutRequestID from STK Push
125
+ response = Paytree::Mpesa::StkQuery.call(
126
+ checkout_request_id: "ws_CO_123456789"
127
+ )
128
+
129
+ if response.success?
130
+ result_code = response.data["ResultCode"]
131
+
132
+ case result_code
133
+ when "0"
134
+ puts "Payment completed successfully!"
135
+ puts "Amount: #{response.data['Amount']}"
136
+ puts "Receipt: #{response.data['MpesaReceiptNumber']}"
137
+ puts "Transaction Date: #{response.data['TransactionDate']}"
138
+
139
+ # Update your order as paid
140
+ order.update(status: 'paid', mpesa_receipt: response.data['MpesaReceiptNumber'])
141
+ when "1032"
142
+ puts "Payment cancelled by user"
143
+ when "1037"
144
+ puts "Payment timed out (user didn't respond)"
145
+ else
146
+ puts "Payment failed: #{response.data['ResultDesc']}"
147
+ end
148
+ else
149
+ puts "Query failed: #{response.message}"
150
+ end
151
+ ```
152
+
153
+ ---
154
+
155
+ ## B2C Payment (Business to Customer)
156
+
157
+ ### Initiate B2C Payment
158
+
159
+ Send funds directly to a customer’s M-Pesa wallet via the B2C API.
160
+
161
+ ### Example
162
+ ```ruby
163
+ response = Paytree::Mpesa::B2C.call(
164
+ phone_number: "254712345678",
165
+ amount: 100,
166
+ reference: "SALAARY2023JULY",
167
+ remarks: "Monthly salary",
168
+ occasion: "Payout",
169
+ command_id: "BusinessPayment" # optional – defaults to "BusinessPayment"
170
+ )
171
+
172
+ if response.success?
173
+ puts "B2C payment initiated: #{response.data["ConversationID"]}"
174
+ else
175
+ puts "Failed to initiate B2C payment: #{response.message}"
176
+ end
177
+ ```
178
+
179
+ ---
180
+
181
+ ## C2B (Customer to Business)
182
+
183
+ ### 1 Register Validation & Confirmation URLs
184
+
185
+ ```ruby
186
+ Paytree::Mpesa::C2B.register_urls(
187
+ short_code: Payments[:mpesa].shortcode,
188
+ confirmation_url: "https://your-app.com/mpesa/confirm",
189
+ validation_url: "https://your-app.com/mpesa/validate"
190
+ )
191
+
192
+ response = Paytree::Mpesa::C2B.simulate(
193
+ phone_number: "254712345678",
194
+ amount: 75,
195
+ reference: "INV-42"
196
+ )
197
+
198
+ if response.success?
199
+ puts "Simulation OK: #{response.data["CustomerMessage"]}"
200
+ else
201
+ puts "Simulation failed: #{response.message}"
202
+ end
203
+ ```
204
+
205
+ ---
206
+
207
+ ## B2B Payment (Business to Business)
208
+
209
+ Send funds from one PayBill or BuyGoods shortcode to another.
210
+
211
+ ### Example
212
+
213
+ ```ruby
214
+ response = Paytree::Mpesa::B2B.call(
215
+ short_code: "174379", # Sender shortcode (use your actual shortcode)
216
+ receiver_shortcode: "600111", # Receiver shortcode
217
+ amount: 1500,
218
+ account_reference: "UTIL-APRIL", # Appears in recipient's statement
219
+
220
+ # Optional
221
+ remarks: "Utility Settlement",
222
+ command_id: "BusinessPayBill" # or "BusinessBuyGoods"
223
+ )
224
+
225
+ if response.success?
226
+ puts "B2B payment accepted: #{response.message}"
227
+ else
228
+ puts "B2B failed: #{response.message}"
229
+ end
230
+ ```
231
+
232
+ ---
233
+
234
+ ## Response Format
235
+
236
+ All Paytree operations return a consistent response object with these attributes:
237
+
238
+ ### Response Attributes
239
+
240
+ ```ruby
241
+ response.success? # Boolean - true if operation succeeded
242
+ response.message # String - human-readable message
243
+ response.data # Hash - response data from M-Pesa API
244
+ response.code # String - M-Pesa response code (if available)
245
+ response.retryable? # Boolean - true if error is configured as retryable
246
+ ```
247
+
248
+ ### Success Response Example
249
+
250
+ ```ruby
251
+ response = Paytree::Mpesa::StkPush.call(
252
+ phone_number: "254712345678",
253
+ amount: 100,
254
+ reference: "ORDER-001"
255
+ )
256
+
257
+ if response.success?
258
+ puts response.message # "STK Push request successful"
259
+ puts response.data # {"MerchantRequestID"=>"29115-34620561-1", "CheckoutRequestID"=>"ws_CO_191220191020363925"...}
260
+ end
261
+ ```
262
+
263
+ ### Error Response Example
264
+
265
+ ```ruby
266
+ unless response.success?
267
+ puts response.message # "Invalid Access Token"
268
+ puts response.code # "404.001.03" (if available)
269
+ puts response.data # {
270
+ # "requestId" => "",
271
+ # "errorCode" => "404.001.03",
272
+ # "errorMessage" => "Invalid Access Token"
273
+ # }
274
+
275
+ # Check if error is retryable (based on configuration)
276
+ if response.retryable?
277
+ puts "This error can be retried"
278
+ # Implement your retry logic here
279
+ else
280
+ puts "This error should not be retried"
281
+ end
282
+ end
283
+ ```
284
+
285
+
286
+
287
+ ### Common Response Data Fields
288
+
289
+ **STK Push Response:**
290
+ - `CheckoutRequestID` - Use this to query payment status
291
+ - `MerchantRequestID` - Internal M-Pesa tracking ID
292
+ - `CustomerMessage` - Message shown to customer
293
+
294
+ **STK Query Response:**
295
+ - `ResultCode` - "0" = success, "1032" = cancelled, "1037" = timeout
296
+ - `ResultDesc` - Human-readable result description
297
+ - `MpesaReceiptNumber` - M-Pesa transaction receipt (on success)
298
+ - `Amount` - Transaction amount
299
+ - `TransactionDate` - When payment was completed
300
+
301
+ **B2C/B2B Response:**
302
+ - `ConversationID` - Transaction tracking ID
303
+ - `OriginatorConversationID` - Your internal tracking ID
304
+ - `ResponseDescription` - Status message
305
+
306
+ ### Retryable Errors
307
+
308
+ Paytree allows you to configure which error codes should be considered retryable. This is useful for building resilient payment systems that can automatically retry transient errors.
309
+
310
+ **Common retryable errors:**
311
+ - `"429.001.01"` - Rate limit exceeded
312
+ - `"500.001.02"` - Temporary server error
313
+ - `"503.001.01"` - Service temporarily unavailable
314
+
315
+ Configure retryable errors during setup:
316
+
317
+ ```ruby
318
+ Paytree.configure_mpesa(
319
+ key: "YOUR_KEY",
320
+ secret: "YOUR_SECRET",
321
+ retryable_errors: ["429.001.01", "500.001.02", "503.001.01"]
322
+ )
323
+ ```
324
+
325
+ Then check if an error response can be retried:
326
+
327
+ ```ruby
328
+ response = Paytree::Mpesa::StkPush.call(...)
329
+
330
+ unless response.success?
331
+ if response.retryable?
332
+ # Implement exponential backoff retry logic
333
+ retry_with_backoff
334
+ else
335
+ # Handle permanent error
336
+ handle_permanent_failure(response)
337
+ end
338
+ end
339
+ ```
340
+
341
+ ---
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,15 @@
1
+ module Paytree
2
+ module FeatureSet
3
+ def supports(*list)
4
+ @features = list.map(&:to_sym)
5
+ end
6
+
7
+ def features
8
+ @features || []
9
+ end
10
+
11
+ def supports?(feature)
12
+ features.include?(feature.to_sym)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,35 @@
1
+ require "logger"
2
+
3
+ module Paytree
4
+ module Configs
5
+ class Mpesa
6
+ attr_accessor :key, :secret, :shortcode, :passkey, :adapter,
7
+ :initiator_name, :initiator_password, :sandbox,
8
+ :extras, :timeout, :retryable_errors
9
+
10
+ def initialize
11
+ @extras = {}
12
+ @logger = nil
13
+ @mutex = Mutex.new
14
+ @timeout = 30 # Default 30 second timeout
15
+ @retryable_errors = [] # Default empty array
16
+ end
17
+
18
+ def base_url
19
+ sandbox ? "https://sandbox.safaricom.co.ke" : "https://api.safaricom.co.ke"
20
+ end
21
+
22
+ 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
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,39 @@
1
+ module Paytree
2
+ class ConfigurationRegistry
3
+ def initialize
4
+ @configs = {}
5
+ @mutex = Mutex.new
6
+ end
7
+
8
+ def configure(provider, config_class)
9
+ raise ArgumentError, "config_class must be a Class" unless config_class.is_a?(Class)
10
+
11
+ config = config_class.new
12
+ yield config if block_given?
13
+
14
+ @mutex.synchronize do
15
+ @configs[provider] = config
16
+ end
17
+ end
18
+
19
+ def store_config(provider, config_instance)
20
+ @mutex.synchronize do
21
+ @configs[provider] = config_instance
22
+ end
23
+ end
24
+
25
+ def [](provider)
26
+ @mutex.synchronize do
27
+ @configs.fetch(provider) do
28
+ raise ArgumentError, "No config registered for provider: #{provider}"
29
+ end
30
+ end
31
+ end
32
+
33
+ def to_h
34
+ @mutex.synchronize do
35
+ @configs.dup
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,17 @@
1
+ module Paytree
2
+ module Errors
3
+ Base = Class.new(StandardError)
4
+
5
+ MpesaCertMissing = Class.new(Base)
6
+ MpesaTokenError = Class.new(Base)
7
+ MpesaMalformedResponse = Class.new(Base)
8
+ MpesaResponseError = Class.new(Base)
9
+ MpesaClientError = Class.new(Base)
10
+ MpesaServerError = Class.new(Base)
11
+ MpesaHttpError = Class.new(Base)
12
+
13
+ ConfigurationError = Class.new(Base)
14
+ UnsupportedOperation = Class.new(Base)
15
+ ValidationError = Class.new(Base)
16
+ end
17
+ end
@@ -0,0 +1,39 @@
1
+ require "paytree/mpesa/adapters/daraja/base"
2
+
3
+ module Paytree
4
+ module Mpesa
5
+ module Adapters
6
+ module Daraja
7
+ class B2B < Base
8
+ ENDPOINT = "/mpesa/b2b/v1/paymentrequest"
9
+
10
+ class << self
11
+ def call(short_code:, receiver_shortcode:, amount:, account_reference:, **opts)
12
+ with_error_handling(context: :b2b) do
13
+ command_id = opts[:command_id] || "BusinessPayBill"
14
+ validate_for(:b2b, short_code:, receiver_shortcode:, account_reference:, amount:, command_id:)
15
+
16
+ payload = {
17
+ Initiator: config.initiator_name,
18
+ SecurityCredential: encrypt_credential(config),
19
+ SenderIdentifierType: "4",
20
+ ReceiverIdentifierType: "4",
21
+ Amount: amount,
22
+ PartyA: short_code,
23
+ PartyB: receiver_shortcode,
24
+ AccountReference: account_reference,
25
+ CommandID: command_id,
26
+ Remarks: opts[:remarks] || "B2B Payment",
27
+ QueueTimeOutURL: config.extras[:timeout_url],
28
+ ResultURL: config.extras[:result_url]
29
+ }.compact
30
+
31
+ post_to_mpesa(:b2b, ENDPOINT, payload)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,36 @@
1
+ require "paytree/mpesa/adapters/daraja/base"
2
+
3
+ module Paytree
4
+ module Mpesa
5
+ module Adapters
6
+ module Daraja
7
+ class B2C < Base
8
+ ENDPOINT = "/mpesa/b2c/v1/paymentrequest"
9
+
10
+ class << self
11
+ def call(phone_number:, amount:, **opts)
12
+ with_error_handling(context: :b2c) do
13
+ validate_for(:b2c, phone_number:, amount:)
14
+
15
+ payload = {
16
+ InitiatorName: config.initiator_name,
17
+ SecurityCredential: encrypt_credential(config),
18
+ Amount: amount,
19
+ PartyA: config.shortcode,
20
+ PartyB: phone_number,
21
+ QueueTimeOutURL: config.extras[:timeout_url],
22
+ ResultURL: config.extras[:result_url],
23
+ CommandID: opts[:command_id] || "BusinessPayment",
24
+ Remarks: opts[:remarks] || "OK",
25
+ Occasion: opts[:occasion] || "Payment"
26
+ }.compact
27
+
28
+ post_to_mpesa(:b2c, ENDPOINT, payload)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,134 @@
1
+ require "base64"
2
+ require_relative "response_helpers"
3
+ require_relative "../../../utils/error_handling"
4
+
5
+ module Paytree
6
+ module Mpesa
7
+ module Adapters
8
+ module Daraja
9
+ class Base
10
+ class << self
11
+ include Paytree::Utils::ErrorHandling
12
+ include Paytree::Mpesa::Adapters::Daraja::ResponseHelpers
13
+
14
+ def config = Paytree[:mpesa]
15
+
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
20
+
21
+ conn.request :json
22
+ conn.response :json, content_type: "application/json"
23
+ end
24
+ end
25
+
26
+ def post_to_mpesa(operation, endpoint, payload)
27
+ build_response(
28
+ connection.post(endpoint, payload.to_json, headers),
29
+ operation
30
+ )
31
+ end
32
+
33
+ def headers
34
+ {"Authorization" => "Bearer #{token}", "Content-Type" => "application/json"}
35
+ end
36
+
37
+ def token
38
+ return @token if token_valid?
39
+
40
+ fetch_token
41
+ end
42
+
43
+ def encrypt_credential(config)
44
+ cert_path = config.extras[:cert_path]
45
+ unless cert_path && File.exist?(cert_path)
46
+ raise Paytree::Errors::MpesaCertMissing,
47
+ "Missing or unreadable certificate at #{cert_path}"
48
+ end
49
+
50
+ certificate = OpenSSL::X509::Certificate.new(File.read(cert_path))
51
+ encrypted = certificate.public_key.public_encrypt(config.initiator_password)
52
+ Base64.strict_encode64(encrypted)
53
+ rescue OpenSSL::OpenSSLError => e
54
+ raise Paytree::Errors::MpesaCertMissing,
55
+ "Failed to encrypt password with certificate #{cert_path}: #{e.message}"
56
+ end
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
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end