ezacloud-sdk 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/README.md +57 -0
- data/lib/eza/client.rb +118 -0
- data/lib/eza/emails.rb +60 -0
- data/lib/eza/errors.rb +44 -0
- data/lib/eza/payedge.rb +42 -0
- data/lib/eza/version.rb +5 -0
- data/lib/eza/webhook.rb +58 -0
- data/lib/eza.rb +20 -0
- metadata +52 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1658a6b210b8eb35fe76419f6e663a32bf00e57608732cdc929df8bb7a3c4e82
|
|
4
|
+
data.tar.gz: '093119db377c253025950995267b620ff174e556ad00a0fde5f21ebb67184d07'
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ac1b28e9cf7b25e37feea948fb94c16d4cdc3c5d1cb8726881ee45b427d7c6a157255e65c79067730767fc87b738f6333a3c76622bad60e732c9d99c3f0bae9d
|
|
7
|
+
data.tar.gz: 95bdfd4f4d844a58634bcf918d601004da45345caeca1e6a50525022eca6522c3d6f6d25b69d3e9d4fc337f9c02fe06a9214dc45b282360bc7828bc7a5477b90
|
data/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Eza Cloud SDK for Ruby (`ezacloud-sdk`)
|
|
2
|
+
|
|
3
|
+
Official Ruby SDK for [Eza Cloud](https://eza.co.ke): PayEdge M-Pesa STK push, transactional emails, and cryptographically signed webhook verification.
|
|
4
|
+
|
|
5
|
+
Zero third-party runtime dependencies. Built entirely with the Ruby standard library (`net/http`, `openssl`, `json`, `securerandom`).
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
Add to your `Gemfile`:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
gem "ezacloud-sdk"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Or install directly:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
gem install ezacloud-sdk
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
require "eza"
|
|
29
|
+
|
|
30
|
+
# Auto-configures using EZA_API_KEY, EZA_EMAIL_KEY from ENV on Eza Cloud pods
|
|
31
|
+
eza = Eza.new(api_key: "eza_live_...")
|
|
32
|
+
|
|
33
|
+
# 1. Trigger an M-Pesa STK push
|
|
34
|
+
tx = eza.payedge.stk_push(
|
|
35
|
+
phone: "254712345678",
|
|
36
|
+
amount_kes: 1500,
|
|
37
|
+
account_reference: "INV1024"
|
|
38
|
+
)
|
|
39
|
+
puts "Transaction pending: #{tx['checkout_request_id']}"
|
|
40
|
+
|
|
41
|
+
# 2. Send a transactional email
|
|
42
|
+
mail = eza.emails.send(
|
|
43
|
+
from: "Shop <orders@mail.shop.co.ke>",
|
|
44
|
+
to: "wanjiku@gmail.com",
|
|
45
|
+
subject: "Your receipt",
|
|
46
|
+
html: "<p>Thank you for your order!</p>"
|
|
47
|
+
)
|
|
48
|
+
puts "Email sent: #{mail['id']}"
|
|
49
|
+
|
|
50
|
+
# 3. Verify incoming webhook signatures
|
|
51
|
+
event = Eza.verify_webhook(
|
|
52
|
+
request.raw_post,
|
|
53
|
+
request.headers["Eza-Signature"],
|
|
54
|
+
ENV["EZA_WEBHOOK_SECRET"]
|
|
55
|
+
)
|
|
56
|
+
puts "Verified event: #{event['type']}"
|
|
57
|
+
```
|
data/lib/eza/client.rb
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
|
|
8
|
+
module Eza
|
|
9
|
+
class Client
|
|
10
|
+
DEFAULT_EMAIL_URL = "https://email.eza.co.ke"
|
|
11
|
+
DEFAULT_PAYEDGE_URL = "https://payedge.eza.co.ke"
|
|
12
|
+
|
|
13
|
+
attr_reader :api_key, :email_key, :email_url, :payedge_url, :max_retries, :payedge, :emails
|
|
14
|
+
|
|
15
|
+
def initialize(
|
|
16
|
+
api_key: nil,
|
|
17
|
+
email_key: nil,
|
|
18
|
+
email_url: nil,
|
|
19
|
+
payedge_url: nil,
|
|
20
|
+
max_retries: 2,
|
|
21
|
+
adapter: nil
|
|
22
|
+
)
|
|
23
|
+
@api_key = api_key || ENV["EZA_API_KEY"]
|
|
24
|
+
@email_key = email_key || ENV["EZA_EMAIL_KEY"] || @api_key
|
|
25
|
+
@email_url = (email_url || ENV["EZA_EMAIL_URL"] || DEFAULT_EMAIL_URL).chomp("/")
|
|
26
|
+
@payedge_url = (payedge_url || ENV["EZA_PAYEDGE_URL"] || DEFAULT_PAYEDGE_URL).chomp("/")
|
|
27
|
+
@max_retries = max_retries
|
|
28
|
+
@adapter = adapter
|
|
29
|
+
|
|
30
|
+
@payedge = PayEdge.new(self)
|
|
31
|
+
@emails = Emails.new(self)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def request(method, url_str, service:, body: nil, idempotency_key: nil)
|
|
35
|
+
auth_key = service == :email ? @email_key : @api_key
|
|
36
|
+
if auth_key.nil? || auth_key.empty?
|
|
37
|
+
service_name = service == :email ? "Email" : "PayEdge"
|
|
38
|
+
env_var = service == :email ? "EZA_EMAIL_KEY or EZA_API_KEY" : "EZA_API_KEY"
|
|
39
|
+
raise ConfigurationError, "No API key configured for #{service_name}. Pass api_key/email_key or set #{env_var}."
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
uri = URI.parse(url_str)
|
|
43
|
+
idempotency_key ||= SecureRandom.uuid if method.to_sym == :post
|
|
44
|
+
|
|
45
|
+
attempts = 0
|
|
46
|
+
loop do
|
|
47
|
+
attempts += 1
|
|
48
|
+
headers = {
|
|
49
|
+
"Authorization" => "Bearer #{auth_key}",
|
|
50
|
+
"Content-Type" => "application/json",
|
|
51
|
+
"Accept" => "application/json",
|
|
52
|
+
"User-Agent" => "eza-ruby/#{VERSION}"
|
|
53
|
+
}
|
|
54
|
+
headers["Idempotency-Key"] = idempotency_key if idempotency_key
|
|
55
|
+
|
|
56
|
+
begin
|
|
57
|
+
res_status, res_headers, res_body = execute_http(method, uri, headers, body)
|
|
58
|
+
rescue StandardError => e
|
|
59
|
+
if attempts <= @max_retries
|
|
60
|
+
sleep(0.1 * (2**(attempts - 1)))
|
|
61
|
+
next
|
|
62
|
+
end
|
|
63
|
+
raise
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Handle 429 Rate Limit (honor Retry-After)
|
|
67
|
+
if res_status == 429 && attempts <= @max_retries
|
|
68
|
+
retry_after = res_headers["retry-after"]
|
|
69
|
+
delay = retry_after ? [retry_after.to_f, 0.05].max : 0.2 * (2**(attempts - 1))
|
|
70
|
+
sleep(delay)
|
|
71
|
+
next
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Handle 503 / 504 Service Unavailable / Gateway Timeout
|
|
75
|
+
if (res_status == 503 || res_status == 504) && attempts <= @max_retries
|
|
76
|
+
sleep(0.1 * (2**(attempts - 1)))
|
|
77
|
+
next
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
if res_status >= 200 && res_status < 300
|
|
81
|
+
return res_body.empty? ? {} : JSON.parse(res_body)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
raise Error.from_response(res_status, res_body, res_headers)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def execute_http(method, uri, headers, body)
|
|
91
|
+
if @adapter
|
|
92
|
+
return @adapter.call(method, uri, headers, body)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
96
|
+
http.use_ssl = (uri.scheme == "https")
|
|
97
|
+
http.open_timeout = 10
|
|
98
|
+
http.read_timeout = 30
|
|
99
|
+
|
|
100
|
+
req_class = case method.to_sym
|
|
101
|
+
when :post then Net::HTTP::Post
|
|
102
|
+
when :get then Net::HTTP::Get
|
|
103
|
+
when :delete then Net::HTTP::Delete
|
|
104
|
+
else Net::HTTP::Get
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
req = req_class.new(uri.request_uri)
|
|
108
|
+
headers.each { |k, v| req[k] = v }
|
|
109
|
+
req.body = JSON.generate(body) if body
|
|
110
|
+
|
|
111
|
+
res = http.request(req)
|
|
112
|
+
res_headers = {}
|
|
113
|
+
res.each_header { |k, v| res_headers[k.downcase] = v }
|
|
114
|
+
|
|
115
|
+
[res.code.to_i, res_headers, res.body || ""]
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
data/lib/eza/emails.rb
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Eza
|
|
7
|
+
class Emails
|
|
8
|
+
def initialize(client)
|
|
9
|
+
@client = client
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def send(from:, to:, subject:, text: nil, html: nil, cc: nil, bcc: nil, reply_to: nil, attachments: nil, idempotency_key: nil)
|
|
13
|
+
raise ArgumentError, "Must provide text or html content" if text.nil? && html.nil?
|
|
14
|
+
|
|
15
|
+
recipients = Array(to)
|
|
16
|
+
payload = {
|
|
17
|
+
from: from,
|
|
18
|
+
to: recipients,
|
|
19
|
+
subject: subject
|
|
20
|
+
}
|
|
21
|
+
payload[:text] = text if text
|
|
22
|
+
payload[:html] = html if html
|
|
23
|
+
payload[:cc] = Array(cc) if cc
|
|
24
|
+
payload[:bcc] = Array(bcc) if bcc
|
|
25
|
+
payload[:reply_to] = reply_to if reply_to
|
|
26
|
+
|
|
27
|
+
if attachments && !attachments.empty?
|
|
28
|
+
payload[:attachments] = attachments.map do |att|
|
|
29
|
+
content = att[:content]
|
|
30
|
+
encoded = if content.is_a?(String) && !att[:already_base64]
|
|
31
|
+
Base64.strict_encode64(content)
|
|
32
|
+
else
|
|
33
|
+
content
|
|
34
|
+
end
|
|
35
|
+
{
|
|
36
|
+
filename: att[:filename] || att["filename"],
|
|
37
|
+
content: encoded
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
@client.request(
|
|
43
|
+
:post,
|
|
44
|
+
"#{@client.email_url}/emails",
|
|
45
|
+
service: :email,
|
|
46
|
+
body: payload,
|
|
47
|
+
idempotency_key: idempotency_key
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def get(id)
|
|
52
|
+
escaped = URI.encode_www_form_component(id)
|
|
53
|
+
@client.request(
|
|
54
|
+
:get,
|
|
55
|
+
"#{@client.email_url}/emails/#{escaped}",
|
|
56
|
+
service: :email
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/eza/errors.rb
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Eza
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
attr_reader :status, :code, :request_id, :fields
|
|
6
|
+
|
|
7
|
+
def self.from_response(status, body, headers = {})
|
|
8
|
+
data = begin
|
|
9
|
+
JSON.parse(body)
|
|
10
|
+
rescue StandardError
|
|
11
|
+
{}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
request_id = headers["eza-request-id"] || headers["x-request-id"] || data["request_id"]
|
|
15
|
+
code = data["code"] || data["name"]
|
|
16
|
+
message = data["message"] || data["detail"] || "HTTP #{status}"
|
|
17
|
+
fields = data["fields"] || data["errors"]
|
|
18
|
+
|
|
19
|
+
new(message, status: status, code: code, request_id: request_id, fields: fields)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(message, status: nil, code: nil, request_id: nil, fields: nil)
|
|
23
|
+
@status = status
|
|
24
|
+
@code = code
|
|
25
|
+
@request_id = request_id
|
|
26
|
+
@fields = fields
|
|
27
|
+
super(build_message(message))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def build_message(base_message)
|
|
33
|
+
parts = []
|
|
34
|
+
parts << "[#{@code}]" if @code
|
|
35
|
+
parts << base_message
|
|
36
|
+
parts << "(status #{@status})" if @status
|
|
37
|
+
parts << "(request_id: #{@request_id})" if @request_id
|
|
38
|
+
parts.join(" ")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class SignatureError < StandardError; end
|
|
43
|
+
class ConfigurationError < StandardError; end
|
|
44
|
+
end
|
data/lib/eza/payedge.rb
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Eza
|
|
6
|
+
class PayEdge
|
|
7
|
+
def initialize(client)
|
|
8
|
+
@client = client
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def stk_push(phone:, amount_kes:, account_reference:, description: nil, config_id: nil, idempotency_key: nil)
|
|
12
|
+
raise ArgumentError, "amount_kes must be an integer, got #{amount_kes.inspect}" unless amount_kes.is_a?(Integer)
|
|
13
|
+
raise ArgumentError, "amount_kes must be at least 1 KES" if amount_kes < 1
|
|
14
|
+
|
|
15
|
+
payload = {
|
|
16
|
+
phone: phone,
|
|
17
|
+
amount_kes: amount_kes,
|
|
18
|
+
account_reference: account_reference
|
|
19
|
+
}
|
|
20
|
+
payload[:description] = description if description
|
|
21
|
+
payload[:config_id] = config_id if config_id
|
|
22
|
+
payload[:idempotency_key] = idempotency_key if idempotency_key
|
|
23
|
+
|
|
24
|
+
@client.request(
|
|
25
|
+
:post,
|
|
26
|
+
"#{@client.payedge_url}/v1/stk-push",
|
|
27
|
+
service: :payedge,
|
|
28
|
+
body: payload,
|
|
29
|
+
idempotency_key: idempotency_key
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def get_transaction(reference)
|
|
34
|
+
escaped = URI.encode_www_form_component(reference)
|
|
35
|
+
@client.request(
|
|
36
|
+
:get,
|
|
37
|
+
"#{@client.payedge_url}/v1/transactions/#{escaped}",
|
|
38
|
+
service: :payedge
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/eza/version.rb
ADDED
data/lib/eza/webhook.rb
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Eza
|
|
7
|
+
module Webhook
|
|
8
|
+
DEFAULT_TOLERANCE = 300
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def verify(raw_body, signature_header, secret, tolerance: DEFAULT_TOLERANCE, now: nil)
|
|
13
|
+
raise SignatureError, "Missing raw body or signature header" if raw_body.nil? || signature_header.nil?
|
|
14
|
+
raise SignatureError, "Missing webhook secret" if secret.nil? || secret.empty?
|
|
15
|
+
|
|
16
|
+
timestamp = nil
|
|
17
|
+
signature = nil
|
|
18
|
+
|
|
19
|
+
signature_header.to_s.split(",").each do |part|
|
|
20
|
+
key, value = part.strip.split("=", 2)
|
|
21
|
+
case key
|
|
22
|
+
when "t"
|
|
23
|
+
timestamp = Integer(value, 10) rescue nil
|
|
24
|
+
when "v1"
|
|
25
|
+
signature = value
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
raise SignatureError, "Malformed signature header: missing timestamp or signature" if timestamp.nil? || signature.nil?
|
|
30
|
+
|
|
31
|
+
current_time = now ? now.to_i : Time.now.to_i
|
|
32
|
+
if (current_time - timestamp).abs > tolerance
|
|
33
|
+
raise SignatureError, "Webhook timestamp outside tolerance zone (#{tolerance}s)"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
signed_payload = "t.#{timestamp}.#{raw_body}"
|
|
37
|
+
computed = OpenSSL::HMAC.hexdigest("SHA256", secret, signed_payload)
|
|
38
|
+
|
|
39
|
+
unless secure_compare(computed, signature)
|
|
40
|
+
raise SignatureError, "Webhook signature mismatch"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
JSON.parse(raw_body)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def secure_compare(a, b)
|
|
47
|
+
return false unless a.is_a?(String) && b.is_a?(String) && a.bytesize == b.bytesize
|
|
48
|
+
|
|
49
|
+
if OpenSSL.respond_to?(:secure_compare)
|
|
50
|
+
OpenSSL.secure_compare(a, b)
|
|
51
|
+
else
|
|
52
|
+
res = 0
|
|
53
|
+
a.bytes.zip(b.bytes) { |x, y| res |= x ^ y }
|
|
54
|
+
res.zero?
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/lib/eza.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "eza/version"
|
|
4
|
+
require_relative "eza/errors"
|
|
5
|
+
require_relative "eza/webhook"
|
|
6
|
+
require_relative "eza/payedge"
|
|
7
|
+
require_relative "eza/emails"
|
|
8
|
+
require_relative "eza/client"
|
|
9
|
+
|
|
10
|
+
module Eza
|
|
11
|
+
class << self
|
|
12
|
+
def new(**options)
|
|
13
|
+
Client.new(**options)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def verify_webhook(...)
|
|
17
|
+
Webhook.verify(...)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ezacloud-sdk
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Eza Cloud
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Zero-dependency Ruby client for Eza Cloud. Initiate M-Pesa STK pushes,
|
|
13
|
+
send transactional emails, and verify cryptographically signed webhooks.
|
|
14
|
+
email:
|
|
15
|
+
- support@eza.co.ke
|
|
16
|
+
executables: []
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- README.md
|
|
21
|
+
- lib/eza.rb
|
|
22
|
+
- lib/eza/client.rb
|
|
23
|
+
- lib/eza/emails.rb
|
|
24
|
+
- lib/eza/errors.rb
|
|
25
|
+
- lib/eza/payedge.rb
|
|
26
|
+
- lib/eza/version.rb
|
|
27
|
+
- lib/eza/webhook.rb
|
|
28
|
+
homepage: https://eza.co.ke/docs
|
|
29
|
+
licenses:
|
|
30
|
+
- MIT
|
|
31
|
+
metadata:
|
|
32
|
+
homepage_uri: https://eza.co.ke/docs
|
|
33
|
+
source_code_uri: https://github.com/ezacloud/eza/tree/main/sdks/ruby
|
|
34
|
+
documentation_uri: https://eza.co.ke/docs
|
|
35
|
+
rdoc_options: []
|
|
36
|
+
require_paths:
|
|
37
|
+
- lib
|
|
38
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
39
|
+
requirements:
|
|
40
|
+
- - ">="
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: 3.1.0
|
|
43
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '0'
|
|
48
|
+
requirements: []
|
|
49
|
+
rubygems_version: 3.6.9
|
|
50
|
+
specification_version: 4
|
|
51
|
+
summary: Official Ruby SDK for Eza Cloud (PayEdge M-Pesa, Transactional Email, Webhooks)
|
|
52
|
+
test_files: []
|