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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +20 -0
- data/CHANGELOG.md +32 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +163 -0
- data/LICENSE.txt +21 -0
- data/README.md +341 -0
- data/Rakefile +12 -0
- data/lib/paytree/concerns/feature_set.rb +15 -0
- data/lib/paytree/configs/mpesa.rb +35 -0
- data/lib/paytree/configuration_registry.rb +39 -0
- data/lib/paytree/errors.rb +17 -0
- data/lib/paytree/mpesa/adapters/daraja/b2b.rb +39 -0
- data/lib/paytree/mpesa/adapters/daraja/b2c.rb +36 -0
- data/lib/paytree/mpesa/adapters/daraja/base.rb +134 -0
- data/lib/paytree/mpesa/adapters/daraja/c2b.rb +47 -0
- data/lib/paytree/mpesa/adapters/daraja/response_helpers.rb +37 -0
- data/lib/paytree/mpesa/adapters/daraja/stk_push.rb +40 -0
- data/lib/paytree/mpesa/adapters/daraja/stk_query.rb +33 -0
- data/lib/paytree/mpesa/adapters/daraja.rb +17 -0
- data/lib/paytree/mpesa/adapters.rb +7 -0
- data/lib/paytree/mpesa/b2b.rb +15 -0
- data/lib/paytree/mpesa/b2c.rb +15 -0
- data/lib/paytree/mpesa/c2b.rb +19 -0
- data/lib/paytree/mpesa/stk_push.rb +15 -0
- data/lib/paytree/mpesa/stk_query.rb +15 -0
- data/lib/paytree/mpesa.rb +9 -0
- data/lib/paytree/response.rb +44 -0
- data/lib/paytree/utils/error_handling.rb +115 -0
- data/lib/paytree/version.rb +3 -0
- data/lib/paytree.rb +82 -0
- data/paytree.gemspec +40 -0
- data/sig/paytree.rbs +4 -0
- metadata +156 -0
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,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
|