mpesa_stk 1.3 → 3.0.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/Rakefile CHANGED
@@ -1,10 +1,12 @@
1
- require "bundler/gem_tasks"
2
- require "rake/testtask"
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rake/testtask'
3
5
 
4
6
  Rake::TestTask.new(:test) do |t|
5
- t.libs << "test"
6
- t.libs << "lib"
7
- t.test_files = FileList["test/**/*_test.rb"]
7
+ t.libs << 'test'
8
+ t.libs << 'lib'
9
+ t.test_files = FileList['test/**/*_test.rb']
8
10
  end
9
11
 
10
- task :default => :test
12
+ task default: :test
data/SECURITY.md ADDED
@@ -0,0 +1,21 @@
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
5
+ Use this section to tell people about which versions of your project are
6
+ currently being supported with security updates.
7
+
8
+ | Version | Supported |
9
+ | ------- | ------------------ |
10
+ | 5.1.x | :white_check_mark: |
11
+ | 5.0.x | :x: |
12
+ | 4.0.x | :white_check_mark: |
13
+ | < 4.0 | :x: |
14
+
15
+ ## Reporting a Vulnerability
16
+
17
+ Use this section to tell people how to report a vulnerability.
18
+
19
+ Tell them where to go, how often they can expect to get an update on a
20
+ reported vulnerability, what to expect if the vulnerability is accepted or
21
+ declined, etc.
data/bin/console CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
2
3
 
3
- require "bundler/setup"
4
- require "mpesa_stk"
4
+ require 'bundler/setup'
5
+ require 'mpesa_stk'
5
6
 
6
7
  # You can add fixtures and/or initialization code here to make experimenting
7
8
  # with your gem easier. You can also use a different console, if you like.
@@ -10,5 +11,5 @@ require "mpesa_stk"
10
11
  # require "pry"
11
12
  # Pry.start
12
13
 
13
- require "irb"
14
+ require 'irb'
14
15
  IRB.start(__FILE__)
data/bin/index.jpeg CHANGED
File without changes
@@ -1,7 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) 2018 mboya
4
+ #
5
+ # MIT License
6
+ #
7
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ # of this software and associated documentation files (the "Software"), to deal
9
+ # in the Software without restriction, including without limitation the rights
10
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ # copies of the Software, and to permit persons to whom the Software is
12
+ # furnished to do so, subject to the following conditions:
13
+ #
14
+ # The above copyright notice and this permission notice shall be included in
15
+ # all copies or substantial portions of the Software.
16
+ #
17
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
23
+ # THE SOFTWARE.
24
+
1
25
  require 'base64'
26
+ require 'json'
2
27
  require 'redis'
28
+ require 'mpesa_stk/config'
3
29
 
4
30
  module MpesaStk
31
+ # Handles OAuth access token generation, caching, and refreshing for M-Pesa APIs
5
32
  class AccessToken
6
33
  class << self
7
34
  def call(key = nil, secret = nil)
@@ -10,34 +37,38 @@ module MpesaStk
10
37
  end
11
38
 
12
39
  def initialize(key = nil, secret = nil)
13
- @key = key.nil? ? ENV['key'] : key
14
- @secret = secret.nil? ? ENV['secret'] : secret
15
- @redis = Redis.new
40
+ @key = key.nil? ? Config.fetch('key') : key
41
+ @secret = secret.nil? ? Config.fetch('secret') : secret
42
+ begin
43
+ @redis = Redis.new
44
+ rescue Redis::CannotConnectError, Redis::ConnectionError => e
45
+ raise StandardError, "Failed to connect to Redis: #{e.message}"
46
+ end
16
47
 
17
48
  load_from_redis
18
49
  end
19
50
 
20
- def is_valid?
21
- has_token? && !token_expired?
51
+ def valid?
52
+ token? && !token_expired?
22
53
  end
23
54
 
24
55
  def token_expired?
25
56
  expire_time = @timestamp.to_i + @expires_in.to_i
26
- return expire_time < Time.now.to_i + 58
57
+ expire_time < Time.now.to_i + 58
27
58
  end
28
59
 
29
- def has_token?
30
- return !@token.nil?
60
+ def token?
61
+ !@token.nil?
31
62
  end
32
63
 
33
64
  def refresh
34
- get_new_access_token
65
+ new_access_token
35
66
  load_from_redis
36
67
  end
37
68
 
38
69
  def load_from_redis
39
70
  data = @redis.get(@key)
40
- if (data.nil? || data.empty?)
71
+ if data.nil? || data.empty?
41
72
  @token = nil
42
73
  @timestamp = nil
43
74
  @expires_in = nil
@@ -47,38 +78,40 @@ module MpesaStk
47
78
  @timestamp = parsed['time_stamp']
48
79
  @expires_in = parsed['expires_in']
49
80
  end
81
+ rescue Redis::BaseError => e
82
+ raise StandardError, "Redis error: #{e.message}"
50
83
  end
51
84
 
52
85
  def access_token
53
- if is_valid?
54
- return @token
55
- else
56
- refresh
57
- return @token
58
- end
86
+ refresh unless valid?
87
+ @token
59
88
  end
60
89
 
61
- def get_new_access_token
90
+ def new_access_token
62
91
  response = HTTParty.get(url, headers: headers)
63
92
 
64
- hash = JSON.parse(response.body).merge(Hash['time_stamp', Time.now.to_i])
93
+ raise StandardError, "Failed to get access token: #{response.code} - #{response.body}" unless response.success?
94
+
95
+ hash = JSON.parse(response.body).merge({ 'time_stamp' => Time.now.to_i })
65
96
  @redis.set @key, hash.to_json
97
+ rescue Redis::BaseError => e
98
+ raise StandardError, "Redis error while saving token: #{e.message}"
66
99
  end
67
100
 
68
101
  private
69
102
 
70
103
  def url
71
- "#{ENV['base_url']}#{ENV['token_generator_url']}"
104
+ "#{Config.fetch('base_url')}#{Config.fetch('token_generator_url')}"
72
105
  end
73
106
 
74
107
  def headers
75
108
  encode = encode_credentials @key, @secret
76
109
  {
77
- "Authorization" => "Basic #{encode}"
110
+ 'Authorization' => "Basic #{encode}"
78
111
  }
79
112
  end
80
113
 
81
- def encode_credentials key, secret
114
+ def encode_credentials(key, secret)
82
115
  credentials = "#{key}:#{secret}"
83
116
  Base64.encode64(credentials).split("\n").join
84
117
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mpesa_stk/client'
4
+
5
+ module MpesaStk
6
+ # Query account balance for a PayBill or Till number.
7
+ class AccountBalance < Client
8
+ class << self
9
+ def call(**options)
10
+ new(**options).query_balance
11
+ end
12
+ end
13
+
14
+ def query_balance
15
+ post(
16
+ 'account_balance_url',
17
+ {
18
+ Initiator: option('initiator'),
19
+ SecurityCredential: option('security_credential'),
20
+ CommandID: 'AccountBalance',
21
+ PartyA: option('business_short_code', :party_a),
22
+ IdentifierType: @options.fetch(:identifier_type, '4'),
23
+ ResultURL: option('result_url'),
24
+ QueueTimeOutURL: option('queue_timeout_url')
25
+ },
26
+ error_message: 'Failed to query account balance'
27
+ )
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mpesa_stk/client'
4
+
5
+ module MpesaStk
6
+ # Business-to-business payments via Daraja B2B API.
7
+ class B2B < Client
8
+ class << self
9
+ def call(amount, receiver_party, **options)
10
+ new(amount, receiver_party, **options).send_payment
11
+ end
12
+ end
13
+
14
+ attr_reader :amount, :receiver_party
15
+
16
+ def initialize(amount, receiver_party, **options)
17
+ super(**options)
18
+ @amount = amount
19
+ @receiver_party = receiver_party
20
+ end
21
+
22
+ def send_payment
23
+ post('b2b_url', b2b_payload, error_message: 'Failed to send B2B payment')
24
+ end
25
+
26
+ private
27
+
28
+ def b2b_payload
29
+ {
30
+ Initiator: option('initiator'),
31
+ SecurityCredential: option('security_credential'),
32
+ CommandID: @options.fetch(:command_id, 'BusinessPayBill'),
33
+ SenderIdentifierType: @options.fetch(:sender_identifier_type, '4'),
34
+ RecieverIdentifierType: @options.fetch(:receiver_identifier_type, '4'),
35
+ Amount: amount.to_s,
36
+ PartyA: option('business_short_code', :party_a),
37
+ PartyB: receiver_party,
38
+ AccountReference: @options.fetch(:account_reference, ''),
39
+ QueueTimeOutURL: option('queue_timeout_url'),
40
+ ResultURL: option('result_url')
41
+ }
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mpesa_stk/client'
4
+
5
+ module MpesaStk
6
+ # Business-to-customer payments (salary, promotions, etc.).
7
+ class B2C < Client
8
+ class << self
9
+ def call(amount, phone_number, **options)
10
+ new(amount, phone_number, **options).send_payment
11
+ end
12
+ end
13
+
14
+ attr_reader :amount, :phone_number
15
+
16
+ def initialize(amount, phone_number, **options)
17
+ super(**options)
18
+ @amount = amount
19
+ @phone_number = phone_number
20
+ end
21
+
22
+ def send_payment
23
+ body = {
24
+ InitiatorName: option('initiator_name'),
25
+ SecurityCredential: option('security_credential'),
26
+ CommandID: @options.fetch(:command_id, 'BusinessPayment'),
27
+ Amount: amount.to_s,
28
+ PartyA: option('business_short_code', :party_a),
29
+ PartyB: phone_number,
30
+ Remarks: @options.fetch(:remarks, 'Payment'),
31
+ QueueTimeOutURL: option('queue_timeout_url'),
32
+ ResultURL: option('result_url')
33
+ }
34
+ body[:Occasion] = @options[:occasion] if @options[:occasion]
35
+
36
+ post('b2c_url', body, error_message: 'Failed to send B2C payment')
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mpesa_stk/client'
4
+
5
+ module MpesaStk
6
+ # Register C2B URLs and simulate customer-to-business payments.
7
+ class C2B < Client
8
+ class << self
9
+ def register(**options)
10
+ new(**options).register
11
+ end
12
+
13
+ def call(amount, phone_number, **options)
14
+ new(**options).simulate_payment(amount, phone_number)
15
+ end
16
+ end
17
+
18
+ def register
19
+ body = {
20
+ ShortCode: option('business_short_code', :short_code),
21
+ ResponseType: @options.fetch(:response_type, 'Completed'),
22
+ ConfirmationURL: option('confirmation_url')
23
+ }
24
+ validation_url = optional_option('validation_url')
25
+ body[:ValidationURL] = validation_url if validation_url
26
+
27
+ post('c2b_register_url', body, error_message: 'Failed to register C2B URL')
28
+ end
29
+
30
+ def simulate_payment(amount, phone_number)
31
+ post(
32
+ 'c2b_simulate_url',
33
+ {
34
+ ShortCode: option('business_short_code', :short_code),
35
+ CommandID: @options.fetch(:command_id, 'CustomerPayBillOnline'),
36
+ Amount: amount.to_s,
37
+ Msisdn: phone_number,
38
+ BillRefNumber: @options.fetch(:bill_ref_number, '')
39
+ },
40
+ error_message: 'Failed to simulate C2B payment'
41
+ )
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'date'
5
+ require 'json'
6
+ require 'mpesa_stk/config'
7
+ require 'mpesa_stk/access_token'
8
+
9
+ module MpesaStk
10
+ # Shared OAuth, HTTP, and configuration helpers for Daraja API clients
11
+ class Client
12
+ AUTH_KEYS = %i[key secret].freeze
13
+
14
+ def initialize(key: nil, secret: nil, **options)
15
+ @key = key
16
+ @secret = secret
17
+ @options = options
18
+ end
19
+
20
+ def token
21
+ @token ||= AccessToken.call(@key, @secret)
22
+ end
23
+
24
+ def post(path_env_key, body, error_message: 'Request failed')
25
+ url = "#{Config.fetch('base_url')}#{Config.fetch(path_env_key)}"
26
+ response = HTTParty.post(url, headers: json_headers, body: body.to_json)
27
+
28
+ raise StandardError, "#{error_message}: #{response.code} - #{response.body}" unless response.success?
29
+
30
+ JSON.parse(response.body)
31
+ end
32
+
33
+ def json_headers
34
+ {
35
+ 'Authorization' => "Bearer #{token}",
36
+ 'Content-Type' => 'application/json'
37
+ }
38
+ end
39
+
40
+ def stk_timestamp
41
+ DateTime.now.strftime('%Y%m%d%H%M%S').to_i
42
+ end
43
+
44
+ def stk_password(short_code, passkey, timestamp = stk_timestamp)
45
+ Base64.encode64("#{short_code}#{passkey}#{timestamp}").delete("\n")
46
+ end
47
+
48
+ def random_reference(length = 5)
49
+ charset = Array('A'..'Z') + Array('a'..'z')
50
+ Array.new(length) { charset.sample }.join
51
+ end
52
+
53
+ def option(env_key, keyword_key = nil)
54
+ keyword_key ||= env_key.to_s.tr('-', '_').to_sym
55
+ Config.fetch(env_key.to_s, @options[keyword_key])
56
+ end
57
+
58
+ def optional_option(env_key, keyword_key = nil)
59
+ keyword_key ||= env_key.to_s.tr('-', '_').to_sym
60
+ Config.env(env_key.to_s, @options[keyword_key])
61
+ end
62
+
63
+ def self.extract_auth_options(kwargs)
64
+ kwargs.slice(*AUTH_KEYS)
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MpesaStk
4
+ # Central configuration: ENV defaults with optional overrides via MpesaStk.configure
5
+ class Config
6
+ class << self
7
+ attr_writer :settings
8
+
9
+ def settings
10
+ @settings ||= {}
11
+ end
12
+
13
+ def configure
14
+ yield(settings)
15
+ end
16
+
17
+ def env(name, override = nil)
18
+ override = nil if override.respond_to?(:empty?) && override.empty?
19
+
20
+ value = settings[name.to_sym]
21
+ value = settings[name.to_s] if value.nil? && settings.key?(name.to_s)
22
+ value = override unless override.nil?
23
+ value = ENV.fetch(name.to_s, nil) if value.nil? || (value.respond_to?(:empty?) && value.empty?)
24
+
25
+ value
26
+ end
27
+
28
+ def fetch(name, override = nil, label: nil)
29
+ value = env(name, override)
30
+ raise ArgumentError, "#{label || name} is not defined" if value.nil? || value.to_s.empty?
31
+
32
+ value
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mpesa_stk/client'
4
+
5
+ module MpesaStk
6
+ # Check customer account type indicator (ATI) via IMSI API.
7
+ class IMSI < Client
8
+ class << self
9
+ def call(customer_number, version: 'v1', **options)
10
+ new(**options).check_ati(customer_number, version)
11
+ end
12
+ end
13
+
14
+ def check_ati(customer_number, version = 'v1')
15
+ endpoint = version.to_s == 'v2' ? '/imsi/v2/checkATI' : '/imsi/v1/checkATI'
16
+ url = "#{Config.fetch('base_url')}#{endpoint}"
17
+ response = HTTParty.post(
18
+ url,
19
+ headers: json_headers,
20
+ body: { customerNumber: customer_number }.to_json
21
+ )
22
+
23
+ raise StandardError, "Failed to check ATI: #{response.code} - #{response.body}" unless response.success?
24
+
25
+ JSON.parse(response.body)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mpesa_stk/client'
4
+ require 'securerandom'
5
+
6
+ module MpesaStk
7
+ # IoT SIM management and messaging via Safaricom SIM portal API.
8
+ class IoT < Client
9
+ class << self
10
+ def call(action, *args, **options)
11
+ new(**options).public_send(action, *args)
12
+ end
13
+
14
+ def list_sims(**options)
15
+ new(**options).get_all_sims(
16
+ start_at_index: options.fetch(:start_at_index, 0),
17
+ page_size: options.fetch(:page_size, 10)
18
+ )
19
+ end
20
+
21
+ def send_message(msisdn, message, **options)
22
+ new(**options).send_single_message(msisdn, message)
23
+ end
24
+ end
25
+
26
+ attr_reader :api_key, :vpn_group, :username
27
+
28
+ def initialize(key: nil, secret: nil, api_key: nil, vpn_group: nil, username: nil, **options)
29
+ super(key: key, secret: secret, **options)
30
+ @api_key = Config.env('iot_api_key', api_key) || 'Yl4S3KEcr173mbeUdYdjf147IuG3rJ824ArMkP6Z'
31
+ @vpn_group = Config.env('vpn_group', vpn_group) || ''
32
+ @username = Config.env('username', username) || ''
33
+ end
34
+
35
+ def get_all_sims(start_at_index: 0, page_size: 10)
36
+ post_request('/allsims', {
37
+ vpnGroup: [vpn_group],
38
+ startAtIndex: start_at_index.to_s,
39
+ pageSize: page_size.to_s,
40
+ username: username
41
+ })
42
+ end
43
+
44
+ def query_lifecycle_status(msisdn)
45
+ post_request('/queryLifeCycleStatus', { msisdn: msisdn, vpnGroup: vpn_group, username: username })
46
+ end
47
+
48
+ def query_customer_info(msisdn)
49
+ post_request('/querycustomerinfo', { msisdn: msisdn, vpnGroup: vpn_group, username: username })
50
+ end
51
+
52
+ def sim_activation(msisdn)
53
+ post_request('/simactivation', { msisdn: msisdn, vpnGroup: vpn_group, username: username })
54
+ end
55
+
56
+ def get_activation_trends(start_date:, stop_date:)
57
+ post_request('/getactivationtrends', {
58
+ vpnGroup: vpn_group,
59
+ startDate: start_date,
60
+ stopDate: stop_date,
61
+ username: username
62
+ })
63
+ end
64
+
65
+ def rename_asset(msisdn, asset_name)
66
+ post_request('/renameasset', {
67
+ msisdn: msisdn,
68
+ vpnGroup: vpn_group,
69
+ username: username,
70
+ assetName: asset_name
71
+ })
72
+ end
73
+
74
+ def get_location_info(msisdn)
75
+ post_request('/getlocationinfo', { msisdn: msisdn, vpnGroup: vpn_group, username: username })
76
+ end
77
+
78
+ def suspend_unsuspend_sub(msisdn, product, operation)
79
+ post_request('/suspend_unsuspend_sub', {
80
+ msisdn: msisdn,
81
+ username: username,
82
+ vpnGroup: vpn_group,
83
+ product: product,
84
+ operation: operation
85
+ })
86
+ end
87
+
88
+ def get_all_messages(page_no: 1, page_size: 10)
89
+ get_request("/getallmessages?pageNo=#{page_no}&pageSize=#{page_size}", { vpnGroup: vpn_group })
90
+ end
91
+
92
+ def search_messages(search_value, page_no: 1, page_size: 5)
93
+ get_request("/searchmessages?pageNo=#{page_no}&pageSize=#{page_size}", {
94
+ searchValue: search_value,
95
+ vpnGroup: vpn_group,
96
+ username: username
97
+ })
98
+ end
99
+
100
+ def filter_messages(start_date:, end_date:, status: '', page_no: 1, page_size: 10)
101
+ get_request("/filtermessages?pageNo=#{page_no}&pageSize=#{page_size}", {
102
+ startDate: start_date,
103
+ endDate: end_date,
104
+ status: status,
105
+ vpnGroup: vpn_group,
106
+ username: username
107
+ })
108
+ end
109
+
110
+ def send_single_message(msisdn, message)
111
+ post_request('/sendsinglemessage', {
112
+ msisdn: msisdn,
113
+ message: message,
114
+ vpnGroup: vpn_group,
115
+ username: username
116
+ })
117
+ end
118
+
119
+ def delete_message(message_id)
120
+ post_request('/deletemessage', { id: message_id, vpnGroup: vpn_group, username: username })
121
+ end
122
+
123
+ def delete_message_thread(msisdn)
124
+ post_request('/deleteMessageThread', { msisdn: msisdn, vpnGroup: vpn_group, username: username })
125
+ end
126
+
127
+ private
128
+
129
+ def base_url
130
+ "#{Config.fetch('base_url')}#{Config.fetch('iot_base_url')}"
131
+ end
132
+
133
+ def iot_headers(msisdn: nil)
134
+ headers_hash = {
135
+ 'Authorization' => "Bearer #{token}",
136
+ 'Content-Type' => 'application/json',
137
+ 'x-correlation-conversationid' => SecureRandom.uuid,
138
+ 'x-source-system' => 'web-portal',
139
+ 'x-api-key' => api_key,
140
+ 'X-App' => 'web-portal',
141
+ 'X-MessageID' => SecureRandom.uuid
142
+ }
143
+ headers_hash['X-MSISDN'] = msisdn if msisdn
144
+ headers_hash
145
+ end
146
+
147
+ def post_request(endpoint, body)
148
+ response = HTTParty.post(
149
+ "#{base_url}#{endpoint}",
150
+ headers: iot_headers(msisdn: body[:msisdn]),
151
+ body: body.to_json
152
+ )
153
+
154
+ raise StandardError, "Failed IoT request: #{response.code} - #{response.body}" unless response.success?
155
+
156
+ JSON.parse(response.body)
157
+ end
158
+
159
+ def get_request(endpoint, body)
160
+ response = HTTParty.post("#{base_url}#{endpoint}", headers: iot_headers, body: body.to_json)
161
+
162
+ raise StandardError, "Failed IoT request: #{response.code} - #{response.body}" unless response.success?
163
+
164
+ JSON.parse(response.body)
165
+ end
166
+ end
167
+ end