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.
@@ -0,0 +1,47 @@
1
+ require "paytree/mpesa/adapters/daraja/base"
2
+
3
+ module Paytree
4
+ module Mpesa
5
+ module Adapters
6
+ module Daraja
7
+ class C2B < Base
8
+ REGISTER_ENDPOINT = "/mpesa/c2b/v1/registerurl"
9
+ SIMULATE_ENDPOINT = "/mpesa/c2b/v1/simulate"
10
+
11
+ class << self
12
+ def register_urls(short_code:, confirmation_url:, validation_url:)
13
+ with_error_handling(context: :c2b_register) do
14
+ validate_for(:c2b_register, short_code:, confirmation_url:, validation_url:)
15
+
16
+ payload = {
17
+ ShortCode: short_code,
18
+ ResponseType: "Completed",
19
+ ConfirmationURL: confirmation_url,
20
+ ValidationURL: validation_url
21
+ }
22
+
23
+ post_to_mpesa(:c2b_register, REGISTER_ENDPOINT, payload)
24
+ end
25
+ end
26
+
27
+ def simulate(phone_number:, amount:, reference:)
28
+ with_error_handling(context: :c2b_simulate) do
29
+ validate_for(:c2b_simulate, phone_number:, amount:, reference:)
30
+
31
+ payload = {
32
+ ShortCode: config.shortcode,
33
+ CommandID: "CustomerPayBillOnline",
34
+ Amount: amount,
35
+ Msisdn: phone_number,
36
+ BillRefNumber: reference
37
+ }
38
+
39
+ post_to_mpesa(:c2b_simulate, SIMULATE_ENDPOINT, payload)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,37 @@
1
+ module Paytree
2
+ module Mpesa
3
+ module Adapters
4
+ module Daraja
5
+ module ResponseHelpers
6
+ def build_response(response, operation)
7
+ parsed = response.body
8
+
9
+ Paytree::Response.new(
10
+ provider: :mpesa,
11
+ operation:,
12
+ status: response.success? ? :success : :error,
13
+ message: response_message(parsed),
14
+ code: response_code(parsed),
15
+ data: parsed,
16
+ raw_response: response
17
+ )
18
+ end
19
+
20
+ private
21
+
22
+ def response_message(parsed)
23
+ parsed["ResponseDescription"] ||
24
+ parsed["ResultDesc"] ||
25
+ parsed["errorMessage"]
26
+ end
27
+
28
+ def response_code(parsed)
29
+ parsed["ResponseCode"] ||
30
+ parsed["ResultCode"] ||
31
+ parsed["errorCode"]
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,40 @@
1
+ require "paytree/mpesa/adapters/daraja/base"
2
+
3
+ module Paytree
4
+ module Mpesa
5
+ module Adapters
6
+ module Daraja
7
+ class StkPush < Base
8
+ ENDPOINT = "/mpesa/stkpush/v1/processrequest"
9
+
10
+ class << self
11
+ def call(phone_number:, amount:, reference:)
12
+ with_error_handling(context: :stk_push) do
13
+ validate_for(:stk_push, phone_number:, amount:, reference:)
14
+
15
+ timestamp = Time.now.strftime("%Y%m%d%H%M%S")
16
+ password = Base64.strict_encode64("#{config.shortcode}#{config.passkey}#{timestamp}")
17
+
18
+ payload = {
19
+ BusinessShortCode: config.shortcode,
20
+ Password: password,
21
+ Timestamp: timestamp,
22
+ TransactionType: "CustomerPayBillOnline",
23
+ Amount: amount,
24
+ PartyA: phone_number,
25
+ PartyB: config.shortcode,
26
+ PhoneNumber: phone_number,
27
+ CallBackURL: config.extras[:callback_url],
28
+ AccountReference: reference,
29
+ TransactionDesc: reference
30
+ }
31
+
32
+ post_to_mpesa(:stk_push, ENDPOINT, payload)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,33 @@
1
+ require "paytree/mpesa/adapters/daraja/base"
2
+
3
+ module Paytree
4
+ module Mpesa
5
+ module Adapters
6
+ module Daraja
7
+ class StkQuery < Base
8
+ ENDPOINT = "/mpesa/stkpushquery/v1/query"
9
+
10
+ class << self
11
+ def call(checkout_request_id:)
12
+ with_error_handling(context: :stk_query) do
13
+ config = self.config
14
+
15
+ timestamp = Time.now.strftime("%Y%m%d%H%M%S")
16
+ password = Base64.strict_encode64("#{config.shortcode}#{config.passkey}#{timestamp}")
17
+
18
+ payload = {
19
+ BusinessShortCode: config.shortcode,
20
+ Password: password,
21
+ Timestamp: timestamp,
22
+ CheckoutRequestID: checkout_request_id
23
+ }
24
+
25
+ post_to_mpesa(:stk_query, ENDPOINT, payload)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,17 @@
1
+ module Paytree
2
+ module Mpesa
3
+ module Adapters
4
+ module Daraja
5
+ extend Paytree::FeatureSet
6
+
7
+ supports :stk_push, :stk_query, :b2c, :c2b, :b2b
8
+
9
+ autoload :StkPush, "paytree/mpesa/adapters/daraja/stk_push"
10
+ autoload :StkQuery, "paytree/mpesa/adapters/daraja/stk_query"
11
+ autoload :B2C, "paytree/mpesa/adapters/daraja/b2c"
12
+ autoload :C2B, "paytree/mpesa/adapters/daraja/c2b"
13
+ autoload :B2B, "paytree/mpesa/adapters/daraja/b2b"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,7 @@
1
+ module Paytree
2
+ module Mpesa
3
+ module Adapters
4
+ autoload :Daraja, "paytree/mpesa/adapters/daraja"
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,15 @@
1
+ module Paytree
2
+ module Mpesa
3
+ class B2B
4
+ def self.call(**args)
5
+ adapter = Paytree::Mpesa.config.adapter
6
+
7
+ unless adapter.respond_to?(:supports?) && adapter.supports?(:b2b)
8
+ raise NotImplementedError, "B2B not supported by #{adapter}"
9
+ end
10
+
11
+ adapter::B2B.call(**args)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ module Paytree
2
+ module Mpesa
3
+ class B2C
4
+ def self.call(**args)
5
+ adapter = Paytree::Mpesa.config.adapter
6
+
7
+ unless adapter.respond_to?(:supports?) && adapter.supports?(:b2c)
8
+ raise NotImplementedError, "B2C not supported by #{adapter}"
9
+ end
10
+
11
+ adapter::B2C.call(**args)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,19 @@
1
+ module Paytree
2
+ module Mpesa
3
+ class C2B
4
+ def self.register_urls(**args)
5
+ adapter = Paytree::Mpesa.config.adapter
6
+ raise NotImplementedError unless adapter.supports?(:c2b)
7
+
8
+ adapter::C2B.register_urls(**args)
9
+ end
10
+
11
+ def self.simulate(**args)
12
+ adapter = Paytree::Mpesa.config.adapter
13
+ raise NotImplementedError unless adapter.supports?(:c2b)
14
+
15
+ adapter::C2B.simulate(**args)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,15 @@
1
+ module Paytree
2
+ module Mpesa
3
+ class StkPush
4
+ def self.call(**args)
5
+ adapter = Paytree::Mpesa.config.adapter || Adapters::Daraja
6
+
7
+ unless adapter.respond_to?(:supports?) && adapter.supports?(:stk_push)
8
+ raise NotImplementedError, "STK Push not supported by #{adapter}"
9
+ end
10
+
11
+ adapter::StkPush.call(**args)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ module Paytree
2
+ module Mpesa
3
+ class StkQuery
4
+ def self.call(**args)
5
+ adapter = Paytree::Mpesa.config.adapter
6
+
7
+ unless adapter.respond_to?(:supports?) && adapter.supports?(:stk_query)
8
+ raise NotImplementedError, "STK Query not supported by #{adapter}"
9
+ end
10
+
11
+ adapter::StkQuery.call(**args)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,9 @@
1
+ module Paytree
2
+ module Mpesa
3
+ autoload :Adapters, "paytree/mpesa/adapters"
4
+
5
+ def self.config
6
+ Paytree[:mpesa]
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,44 @@
1
+ module Paytree
2
+ class Response
3
+ attr_reader :provider, :operation, :status, :message, :code, :data, :raw_response
4
+
5
+ def initialize(provider:, operation:, status:, message:, code:, data:, raw_response:)
6
+ @provider = provider
7
+ @operation = operation
8
+ @status = status
9
+ @message = message
10
+ @code = code
11
+ @data = data
12
+ @raw_response = raw_response
13
+ end
14
+
15
+ def success?
16
+ status == :success
17
+ end
18
+
19
+ def error?
20
+ status == :error
21
+ end
22
+
23
+ def retryable?
24
+ return false unless error?
25
+ return false unless code
26
+
27
+ config = Paytree[provider]
28
+ return false unless config&.respond_to?(:retryable_errors)
29
+
30
+ config.retryable_errors.include?(code)
31
+ end
32
+
33
+ def to_h
34
+ {
35
+ provider:,
36
+ operation:,
37
+ status:,
38
+ message:,
39
+ code:,
40
+ data:
41
+ }
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,115 @@
1
+ module Paytree
2
+ module Utils
3
+ module ErrorHandling
4
+ def with_error_handling(context: nil)
5
+ yield
6
+ rescue Paytree::Errors::ValidationError,
7
+ Paytree::Errors::Base => e
8
+ emit_error(e, context)
9
+ raise
10
+ rescue Faraday::TimeoutError => e
11
+ handle_faraday_error(
12
+ e,
13
+ context,
14
+ error_class: Paytree::Errors::MpesaResponseError,
15
+ error_type: "Timeout"
16
+ )
17
+ rescue Faraday::ParsingError, JSON::ParserError => e
18
+ handle_faraday_error(
19
+ e,
20
+ context,
21
+ error_class: Paytree::Errors::MpesaMalformedResponse,
22
+ error_type: "Malformed response"
23
+ )
24
+ rescue Faraday::ClientError => e
25
+ handle_faraday_error(
26
+ e,
27
+ context,
28
+ error_class: Paytree::Errors::MpesaClientError,
29
+ error_type: "Client error",
30
+ extract_info: true
31
+ )
32
+ rescue Faraday::ServerError => e
33
+ handle_faraday_error(
34
+ e,
35
+ context,
36
+ error_class: Paytree::Errors::MpesaServerError,
37
+ error_type: "Server error",
38
+ extract_info: true
39
+ )
40
+ rescue => e
41
+ wrap_and_raise(
42
+ Paytree::Errors::Base,
43
+ "Unexpected error in #{context}: #{e.message}",
44
+ e, context
45
+ )
46
+ end
47
+
48
+ private
49
+
50
+ def handle_faraday_error(error, context, error_class:, error_type:, extract_info: false)
51
+ if extract_info
52
+ info = parse_faraday_error(error)
53
+ message = info[:message] || error.message
54
+ code = info[:code]
55
+ else
56
+ message = error.message
57
+ code = nil
58
+ end
59
+
60
+ wrap_and_raise(
61
+ error_class, "#{error_type} in #{context}: #{message}", error, context, code
62
+ )
63
+ end
64
+
65
+ def wrap_and_raise(klass, message, original, context, code = nil)
66
+ error = klass.new(message)
67
+ error.define_singleton_method(:code) { code } if code
68
+ emit_error(error, context)
69
+
70
+ raise error
71
+ end
72
+
73
+ def emit_error(error, context, **metadata)
74
+ config = get_config_for_context(context)
75
+ logger = config.respond_to?(:logger) ? config.logger : Logger.new($stdout)
76
+
77
+ logger.error format_error_message(error, context)
78
+ end
79
+
80
+ def get_config_for_context(context)
81
+ provider = extract_provider_from_context(context) || :mpesa
82
+ Paytree[provider]
83
+ end
84
+
85
+ def extract_provider_from_context(context)
86
+ return :mpesa if context.to_s.downcase.include?("mpesa")
87
+ # Can be extended to support other providers based on context
88
+ nil
89
+ end
90
+
91
+ def parse_faraday_error(faraday_error)
92
+ body = faraday_error.response&.dig(:body)
93
+ return {} unless body.is_a?(Hash)
94
+
95
+ {
96
+ message: body["errorMessage"] ||
97
+ body["ResponseDescription"] ||
98
+ body["ResultDesc"],
99
+ code: body["errorCode"] ||
100
+ body["ResponseCode"] ||
101
+ body["ResultCode"]
102
+ }
103
+ rescue NoMethodError, KeyError, TypeError
104
+ {}
105
+ end
106
+
107
+ def format_error_message(error, context)
108
+ provider = extract_provider_from_context(context) || :mpesa
109
+ code = (error.respond_to?(:code) && error.code) ? " (code: #{error.code})" : ""
110
+
111
+ "[#{provider.to_s.upcase}/#{context}] #{error.class}: #{error.message}#{code}"
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,3 @@
1
+ module Paytree
2
+ VERSION = "0.2.0"
3
+ end
data/lib/paytree.rb ADDED
@@ -0,0 +1,82 @@
1
+ require "json"
2
+ require "faraday"
3
+
4
+ Dir[File.join(__dir__, "paytree/**/*.rb")].sort.each { |file| require file }
5
+
6
+ module Paytree
7
+ class << self
8
+ def registry
9
+ @registry ||= ConfigurationRegistry.new
10
+ end
11
+
12
+ def configure(provider, config_class = nil)
13
+ raise ArgumentError, "Missing block" unless block_given?
14
+ raise ArgumentError, "Missing config_class" unless config_class
15
+
16
+ registry.configure(provider, config_class) { |hash| yield hash }
17
+ end
18
+
19
+ def configure_mpesa(**options)
20
+ config = Configs::Mpesa.new
21
+
22
+ # Auto-load from environment variables if not provided
23
+ options = auto_load_env_vars.merge(options)
24
+
25
+ # Set configuration values
26
+ options.each do |key, value|
27
+ if config.respond_to?("#{key}=")
28
+ config.send("#{key}=", value)
29
+ elsif key == :extras
30
+ config.extras.merge!(value)
31
+ end
32
+ end
33
+
34
+ # Set smart defaults
35
+ config.sandbox = true if config.sandbox.nil?
36
+
37
+ registry.store_config(:mpesa, config)
38
+ end
39
+
40
+ def [](provider)
41
+ registry[provider]
42
+ end
43
+
44
+ private
45
+
46
+ def auto_load_env_vars
47
+ env_mapping = {
48
+ key: "MPESA_CONSUMER_KEY",
49
+ secret: "MPESA_CONSUMER_SECRET",
50
+ shortcode: "MPESA_SHORTCODE",
51
+ passkey: "MPESA_PASSKEY",
52
+ initiator_name: "MPESA_INITIATOR_NAME",
53
+ initiator_password: "MPESA_INITIATOR_PASSWORD",
54
+ sandbox: "MPESA_SANDBOX"
55
+ }
56
+
57
+ config = {}
58
+ env_mapping.each do |config_key, env_var|
59
+ value = ENV[env_var]
60
+ next unless value
61
+
62
+ # Convert sandbox to boolean
63
+ if config_key == :sandbox
64
+ value = %w[true 1 yes].include?(value.downcase)
65
+ end
66
+
67
+ config[config_key] = value
68
+ end
69
+
70
+ # Load extras from environment
71
+ extras = {}
72
+ %w[callback_url result_url timeout_url].each do |extra|
73
+ env_var = "MPESA_#{extra.upcase}"
74
+ extras[extra.to_sym] = ENV[env_var] if ENV[env_var]
75
+ end
76
+
77
+ config[:extras] = extras unless extras.empty?
78
+
79
+ config
80
+ end
81
+ end
82
+ end
data/paytree.gemspec ADDED
@@ -0,0 +1,40 @@
1
+ require_relative "lib/paytree/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "paytree"
5
+ spec.version = Paytree::VERSION
6
+ spec.authors = ["Charles Chuck"]
7
+ spec.email = ["chalcchuck@gmail.com"]
8
+
9
+ spec.summary = "Rails-optional payments abstraction for M-Pesa (Daraja) and more."
10
+ spec.description = "Clean, adapter-based Ruby DSL for mobile money integrations like M-Pesa via Daraja, with future provider support (Tingg, Airtel, Cellulant)."
11
+ spec.homepage = "https://github.com/mundanecodes/paytree"
12
+ spec.license = "MIT"
13
+ spec.required_ruby_version = ">= 3.2.0"
14
+
15
+ spec.metadata["homepage_uri"] = spec.homepage
16
+ spec.metadata["source_code_uri"] = "https://github.com/mundanecodes/paytree"
17
+ spec.metadata["changelog_uri"] = "https://github.com/mundanecodes/paytree/blob/main/CHANGELOG.md"
18
+ spec.metadata["documentation_uri"] = "https://github.com/mundanecodes/paytree/blob/main/README.md"
19
+ spec.metadata["bug_tracker_uri"] = "https://github.com/mundanecodes/paytree/issues"
20
+ spec.metadata["wiki_uri"] = "https://github.com/mundanecodes/paytree/wiki"
21
+ spec.metadata["mailing_list_uri"] = "https://github.com/mundanecodes/paytree/discussions"
22
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
23
+
24
+ spec.files = Dir.chdir(__dir__) do
25
+ `git ls-files -z`.split("\x0").reject do |f|
26
+ f.match(%r{^(test|spec|features|bin|exe)/}) || f.include?(".git")
27
+ end
28
+ end
29
+
30
+ spec.require_paths = ["lib"]
31
+
32
+ # Runtime deps
33
+ spec.add_dependency "faraday", "~> 2.0"
34
+
35
+ # Dev/test deps
36
+ spec.add_development_dependency "rspec", "~> 3.12"
37
+ spec.add_development_dependency "webmock", "~> 3.18"
38
+ spec.add_development_dependency "standard"
39
+ spec.add_development_dependency "rubocop-rails-omakase"
40
+ end
data/sig/paytree.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Payments
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end