zai_payment 1.0.2 → 1.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,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+
5
+ module ZaiPayment
6
+ # Base API client that handles HTTP requests to Zai API
7
+ class Client
8
+ attr_reader :config, :token_provider
9
+
10
+ def initialize(config: nil, token_provider: nil)
11
+ @config = config || ZaiPayment.config
12
+ @token_provider = token_provider || ZaiPayment.auth
13
+ end
14
+
15
+ # Perform a GET request
16
+ #
17
+ # @param path [String] the API endpoint path
18
+ # @param params [Hash] query parameters
19
+ # @return [Response] the API response
20
+ def get(path, params: {})
21
+ request(:get, path, params: params)
22
+ end
23
+
24
+ # Perform a POST request
25
+ #
26
+ # @param path [String] the API endpoint path
27
+ # @param body [Hash] request body
28
+ # @return [Response] the API response
29
+ def post(path, body: {})
30
+ request(:post, path, body: body)
31
+ end
32
+
33
+ # Perform a PATCH request
34
+ #
35
+ # @param path [String] the API endpoint path
36
+ # @param body [Hash] request body
37
+ # @return [Response] the API response
38
+ def patch(path, body: {})
39
+ request(:patch, path, body: body)
40
+ end
41
+
42
+ # Perform a DELETE request
43
+ #
44
+ # @param path [String] the API endpoint path
45
+ # @return [Response] the API response
46
+ def delete(path)
47
+ request(:delete, path)
48
+ end
49
+
50
+ private
51
+
52
+ def request(method, path, params: {}, body: {})
53
+ response = connection.public_send(method) do |req|
54
+ req.url path
55
+ req.params = params if params.any?
56
+ req.body = body if body.any?
57
+ end
58
+
59
+ Response.new(response)
60
+ rescue Faraday::Error => e
61
+ handle_faraday_error(e)
62
+ end
63
+
64
+ def connection
65
+ @connection ||= build_connection
66
+ end
67
+
68
+ def build_connection
69
+ Faraday.new do |faraday|
70
+ configure_connection(faraday)
71
+ end
72
+ end
73
+
74
+ def configure_connection(faraday)
75
+ faraday.url_prefix = base_url
76
+ apply_headers(faraday)
77
+ apply_middleware(faraday)
78
+ apply_timeouts(faraday)
79
+ faraday.adapter Faraday.default_adapter
80
+ end
81
+
82
+ def apply_headers(faraday)
83
+ faraday.headers['Authorization'] = token_provider.bearer_token
84
+ faraday.headers['Content-Type'] = 'application/json'
85
+ faraday.headers['Accept'] = 'application/json'
86
+ end
87
+
88
+ def apply_middleware(faraday)
89
+ faraday.request :json
90
+ faraday.response :json, content_type: /\bjson$/
91
+ end
92
+
93
+ def apply_timeouts(faraday)
94
+ faraday.options.timeout = config.timeout if config.timeout
95
+ faraday.options.open_timeout = config.open_timeout if config.open_timeout
96
+ end
97
+
98
+ def base_url
99
+ # Webhooks API uses va_base endpoint
100
+ config.endpoints[:va_base]
101
+ end
102
+
103
+ def handle_faraday_error(error)
104
+ case error
105
+ when Faraday::TimeoutError
106
+ raise Errors::TimeoutError, "Request timed out: #{error.message}"
107
+ when Faraday::ConnectionFailed
108
+ raise Errors::ConnectionError, "Connection failed: #{error.message}"
109
+ when Faraday::ClientError
110
+ raise Errors::ApiError, "Client error: #{error.message}"
111
+ else
112
+ raise Errors::ApiError, "Request failed: #{error.message}"
113
+ end
114
+ end
115
+ end
116
+ end
@@ -10,6 +10,8 @@ module ZaiPayment
10
10
  @client_id = nil
11
11
  @client_secret = nil
12
12
  @scope = nil
13
+ @timeout = 10
14
+ @open_timeout = 10
13
15
  end
14
16
 
15
17
  def validate!
@@ -2,8 +2,27 @@
2
2
 
3
3
  module ZaiPayment
4
4
  module Errors
5
+ # Base error class
5
6
  class Error < StandardError; end
7
+
8
+ # Authentication errors
6
9
  class AuthError < Error; end
10
+
11
+ # Configuration errors
7
12
  class ConfigurationError < Error; end
13
+
14
+ # API errors
15
+ class ApiError < Error; end
16
+ class BadRequestError < ApiError; end
17
+ class UnauthorizedError < ApiError; end
18
+ class ForbiddenError < ApiError; end
19
+ class NotFoundError < ApiError; end
20
+ class ValidationError < ApiError; end
21
+ class RateLimitError < ApiError; end
22
+ class ServerError < ApiError; end
23
+
24
+ # Network errors
25
+ class TimeoutError < Error; end
26
+ class ConnectionError < Error; end
8
27
  end
9
28
  end
@@ -0,0 +1,331 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'base64'
5
+
6
+ module ZaiPayment
7
+ module Resources
8
+ # Webhook resource for managing Zai webhooks
9
+ #
10
+ # @see https://developer.hellozai.com/reference/getallwebhooks
11
+ class Webhook
12
+ attr_reader :client
13
+
14
+ def initialize(client: nil)
15
+ @client = client || Client.new
16
+ end
17
+
18
+ # List all webhooks
19
+ #
20
+ # @param limit [Integer] number of records to return (default: 10)
21
+ # @param offset [Integer] number of records to skip (default: 0)
22
+ # @return [Response] the API response containing webhooks array
23
+ #
24
+ # @example
25
+ # webhooks = ZaiPayment::Resources::Webhook.new
26
+ # response = webhooks.list
27
+ # response.data # => [{"id" => "...", "url" => "..."}, ...]
28
+ #
29
+ # @see https://developer.hellozai.com/reference/getallwebhooks
30
+ def list(limit: 10, offset: 0)
31
+ params = {
32
+ limit: limit,
33
+ offset: offset
34
+ }
35
+
36
+ client.get('/webhooks', params: params)
37
+ end
38
+
39
+ # Get a specific webhook by ID
40
+ #
41
+ # @param webhook_id [String] the webhook ID
42
+ # @return [Response] the API response containing webhook details
43
+ #
44
+ # @example
45
+ # webhooks = ZaiPayment::Resources::Webhook.new
46
+ # response = webhooks.show("webhook_id")
47
+ # response.data # => {"id" => "webhook_id", "url" => "...", ...}
48
+ #
49
+ # @see https://developer.hellozai.com/reference/getwebhookbyid
50
+ def show(webhook_id)
51
+ validate_id!(webhook_id, 'webhook_id')
52
+ client.get("/webhooks/#{webhook_id}")
53
+ end
54
+
55
+ # Create a new webhook
56
+ #
57
+ # @param url [String] the webhook URL to receive notifications
58
+ # @param object_type [String] the type of object to watch (e.g., 'transactions', 'items')
59
+ # @param enabled [Boolean] whether the webhook is enabled (default: true)
60
+ # @param description [String] optional description of the webhook
61
+ # @return [Response] the API response containing created webhook
62
+ #
63
+ # @example
64
+ # webhooks = ZaiPayment::Resources::Webhook.new
65
+ # response = webhooks.create(
66
+ # url: "https://example.com/webhooks",
67
+ # object_type: "transactions",
68
+ # enabled: true
69
+ # )
70
+ #
71
+ # @see https://developer.hellozai.com/reference/createwebhook
72
+ def create(url: nil, object_type: nil, enabled: true, description: nil)
73
+ validate_presence!(url, 'url')
74
+ validate_presence!(object_type, 'object_type')
75
+ validate_url!(url)
76
+
77
+ body = {
78
+ url: url,
79
+ object_type: object_type,
80
+ enabled: enabled
81
+ }
82
+
83
+ body[:description] = description if description
84
+
85
+ client.post('/webhooks', body: body)
86
+ end
87
+
88
+ # Update an existing webhook
89
+ #
90
+ # @param webhook_id [String] the webhook ID
91
+ # @param url [String] optional new webhook URL
92
+ # @param object_type [String] optional new object type
93
+ # @param enabled [Boolean] optional enabled status
94
+ # @param description [String] optional description
95
+ # @return [Response] the API response containing updated webhook
96
+ #
97
+ # @example
98
+ # webhooks = ZaiPayment::Resources::Webhook.new
99
+ # response = webhooks.update(
100
+ # "webhook_id",
101
+ # enabled: false
102
+ # )
103
+ #
104
+ # @see https://developer.hellozai.com/reference/updatewebhook
105
+ def update(webhook_id, url: nil, object_type: nil, enabled: nil, description: nil)
106
+ validate_id!(webhook_id, 'webhook_id')
107
+
108
+ body = {}
109
+ body[:url] = url if url
110
+ body[:object_type] = object_type if object_type
111
+ body[:enabled] = enabled unless enabled.nil?
112
+ body[:description] = description if description
113
+
114
+ validate_url!(url) if url
115
+
116
+ raise Errors::ValidationError, 'At least one attribute must be provided for update' if body.empty?
117
+
118
+ client.patch("/webhooks/#{webhook_id}", body: body)
119
+ end
120
+
121
+ # Delete a webhook
122
+ #
123
+ # @param webhook_id [String] the webhook ID
124
+ # @return [Response] the API response
125
+ #
126
+ # @example
127
+ # webhooks = ZaiPayment::Resources::Webhook.new
128
+ # response = webhooks.delete("webhook_id")
129
+ #
130
+ # @see https://developer.hellozai.com/reference/deletewebhook
131
+ def delete(webhook_id)
132
+ validate_id!(webhook_id, 'webhook_id')
133
+ client.delete("/webhooks/#{webhook_id}")
134
+ end
135
+
136
+ # Create a secret key for webhook signature verification
137
+ #
138
+ # @param secret_key [String] the secret key to use for HMAC signature generation
139
+ # Must be ASCII characters and at least 32 bytes in size
140
+ # @return [Response] the API response
141
+ #
142
+ # @example
143
+ # webhooks = ZaiPayment::Resources::Webhook.new
144
+ # secret_key = SecureRandom.alphanumeric(32)
145
+ # response = webhooks.create_secret_key(secret_key: secret_key)
146
+ #
147
+ # @see https://developer.hellozai.com/reference/createsecretkey
148
+ # @see https://developer.hellozai.com/docs/verify-webhook-signatures
149
+ def create_secret_key(secret_key:)
150
+ validate_presence!(secret_key, 'secret_key')
151
+ validate_secret_key!(secret_key)
152
+
153
+ body = { secret_key: secret_key }
154
+ client.post('/webhooks/secret_key', body: body)
155
+ end
156
+
157
+ # Verify webhook signature
158
+ #
159
+ # This method verifies that a webhook request came from Zai by validating
160
+ # the HMAC SHA256 signature in the Webhooks-signature header.
161
+ #
162
+ # @param payload [String] the raw request body (JSON string)
163
+ # @param signature_header [String] the Webhooks-signature header value
164
+ # @param secret_key [String] your secret key used for signature generation
165
+ # @param tolerance [Integer] maximum age of webhook in seconds (default: 300 = 5 minutes)
166
+ # @return [Boolean] true if signature is valid and within tolerance
167
+ # @raise [Errors::ValidationError] if signature is invalid or timestamp is outside tolerance
168
+ #
169
+ # @example
170
+ # # In your webhook endpoint (e.g., Rails controller)
171
+ # def webhook
172
+ # payload = request.body.read
173
+ # signature_header = request.headers['Webhooks-signature']
174
+ # secret_key = ENV['ZAI_WEBHOOK_SECRET']
175
+ #
176
+ # if ZaiPayment.webhooks.verify_signature(
177
+ # payload: payload,
178
+ # signature_header: signature_header,
179
+ # secret_key: secret_key
180
+ # )
181
+ # # Process webhook
182
+ # render json: { status: 'success' }
183
+ # else
184
+ # render json: { error: 'Invalid signature' }, status: :unauthorized
185
+ # end
186
+ # end
187
+ #
188
+ # @see https://developer.hellozai.com/docs/verify-webhook-signatures
189
+ def verify_signature(payload:, signature_header:, secret_key:, tolerance: 300)
190
+ validate_presence!(payload, 'payload')
191
+ validate_presence!(signature_header, 'signature_header')
192
+ validate_presence!(secret_key, 'secret_key')
193
+
194
+ # Extract timestamp and signature from header
195
+ timestamp, signatures = parse_signature_header(signature_header)
196
+
197
+ # Verify timestamp is within tolerance (prevent replay attacks)
198
+ verify_timestamp!(timestamp, tolerance)
199
+
200
+ # Generate expected signature
201
+ expected_signature = generate_signature(payload, secret_key, timestamp)
202
+
203
+ # Compare signatures using constant-time comparison
204
+ signatures.any? { |sig| secure_compare(expected_signature, sig) }
205
+ end
206
+
207
+ # Generate a signature for webhook verification
208
+ #
209
+ # This is a utility method that can be used for testing or generating
210
+ # signatures for webhook simulation.
211
+ #
212
+ # @param payload [String] the request body (JSON string)
213
+ # @param secret_key [String] the secret key
214
+ # @param timestamp [Integer] the Unix timestamp (defaults to current time)
215
+ # @return [String] the base64url-encoded HMAC SHA256 signature
216
+ #
217
+ # @example
218
+ # webhooks = ZaiPayment::Resources::Webhook.new
219
+ # signature = webhooks.generate_signature(
220
+ # '{"event": "status_updated"}',
221
+ # 'my_secret_key'
222
+ # )
223
+ #
224
+ # @see https://developer.hellozai.com/docs/verify-webhook-signatures
225
+ def generate_signature(payload, secret_key, timestamp = Time.now.to_i)
226
+ signed_payload = "#{timestamp}.#{payload}"
227
+ digest = OpenSSL::Digest.new('sha256')
228
+ hash = OpenSSL::HMAC.digest(digest, secret_key, signed_payload)
229
+ Base64.urlsafe_encode64(hash, padding: false)
230
+ end
231
+
232
+ private
233
+
234
+ def validate_id!(value, field_name)
235
+ return unless value.nil? || value.to_s.strip.empty?
236
+
237
+ raise Errors::ValidationError, "#{field_name} is required and cannot be blank"
238
+ end
239
+
240
+ def validate_presence!(value, field_name)
241
+ return unless value.nil? || value.to_s.strip.empty?
242
+
243
+ raise Errors::ValidationError, "#{field_name} is required and cannot be blank"
244
+ end
245
+
246
+ def validate_url!(url)
247
+ uri = URI.parse(url)
248
+ unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
249
+ raise Errors::ValidationError, 'url must be a valid HTTP or HTTPS URL'
250
+ end
251
+ rescue URI::InvalidURIError
252
+ raise Errors::ValidationError, 'url must be a valid URL'
253
+ end
254
+
255
+ def validate_secret_key!(secret_key)
256
+ # Check if it's ASCII
257
+ raise Errors::ValidationError, 'secret_key must contain only ASCII characters' unless secret_key.ascii_only?
258
+
259
+ # Check minimum length (32 bytes)
260
+ return unless secret_key.bytesize < 32
261
+
262
+ raise Errors::ValidationError, 'secret_key must be at least 32 bytes in size'
263
+ end
264
+
265
+ def parse_signature_header(header)
266
+ # Format: "t=1257894000,v=signature1,v=signature2"
267
+ parts = header.split(',').map(&:strip)
268
+
269
+ timestamp, signatures = extract_timestamp_and_signatures(parts)
270
+
271
+ validate_timestamp_presence!(timestamp)
272
+ validate_signatures_presence!(signatures)
273
+
274
+ [timestamp, signatures]
275
+ end
276
+
277
+ def extract_timestamp_and_signatures(parts)
278
+ timestamp = nil
279
+ signatures = []
280
+
281
+ parts.each do |part|
282
+ key, value = part.split('=', 2)
283
+ case key
284
+ when 't'
285
+ timestamp = value.to_i
286
+ when 'v'
287
+ signatures << value
288
+ end
289
+ end
290
+
291
+ [timestamp, signatures]
292
+ end
293
+
294
+ def validate_timestamp_presence!(timestamp)
295
+ return unless timestamp.nil? || timestamp.zero?
296
+
297
+ raise Errors::ValidationError, 'Invalid signature header: missing or invalid timestamp'
298
+ end
299
+
300
+ def validate_signatures_presence!(signatures)
301
+ raise Errors::ValidationError, 'Invalid signature header: missing signature' if signatures.empty?
302
+ end
303
+
304
+ def verify_timestamp!(timestamp, tolerance)
305
+ current_time = Time.now.to_i
306
+ time_diff = (current_time - timestamp).abs
307
+
308
+ return unless time_diff > tolerance
309
+
310
+ raise Errors::ValidationError,
311
+ "Webhook timestamp is outside tolerance (#{time_diff}s vs #{tolerance}s max). " \
312
+ 'This may be a replay attack.'
313
+ end
314
+
315
+ # Constant-time string comparison to prevent timing attacks
316
+ # Uses OpenSSL's secure_compare if available, otherwise falls back to manual comparison
317
+ def secure_compare(str_a, str_b)
318
+ return false unless str_a.bytesize == str_b.bytesize
319
+
320
+ if defined?(OpenSSL.fixed_length_secure_compare)
321
+ OpenSSL.fixed_length_secure_compare(str_a, str_b)
322
+ else
323
+ # Fallback for older Ruby versions
324
+ result = 0
325
+ str_a.bytes.zip(str_b.bytes) { |x, y| result |= x ^ y }
326
+ result.zero?
327
+ end
328
+ end
329
+ end
330
+ end
331
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZaiPayment
4
+ # Wrapper for API responses
5
+ class Response
6
+ attr_reader :status, :body, :headers, :raw_response
7
+
8
+ def initialize(faraday_response)
9
+ @raw_response = faraday_response
10
+ @status = faraday_response.status
11
+ @body = faraday_response.body
12
+ @headers = faraday_response.headers
13
+
14
+ check_for_errors!
15
+ end
16
+
17
+ # Check if the response was successful (2xx status)
18
+ def success?
19
+ (200..299).cover?(status)
20
+ end
21
+
22
+ # Check if the response was a client error (4xx status)
23
+ def client_error?
24
+ (400..499).cover?(status)
25
+ end
26
+
27
+ # Check if the response was a server error (5xx status)
28
+ def server_error?
29
+ (500..599).cover?(status)
30
+ end
31
+
32
+ # Get the data from the response body
33
+ def data
34
+ body.is_a?(Hash) ? body['webhooks'] || body : body
35
+ end
36
+
37
+ # Get pagination or metadata info
38
+ def meta
39
+ body.is_a?(Hash) ? body['meta'] : nil
40
+ end
41
+
42
+ ERROR_STATUS_MAP = {
43
+ 400 => Errors::BadRequestError,
44
+ 401 => Errors::UnauthorizedError,
45
+ 403 => Errors::ForbiddenError,
46
+ 404 => Errors::NotFoundError,
47
+ 422 => Errors::ValidationError,
48
+ 429 => Errors::RateLimitError
49
+ }.merge((500..599).to_h { |code| [code, Errors::ServerError] }).freeze
50
+
51
+ private
52
+
53
+ def check_for_errors!
54
+ return if success?
55
+
56
+ raise_appropriate_error
57
+ end
58
+
59
+ def raise_appropriate_error
60
+ error_message = extract_error_message
61
+ error_class = error_class_for_status
62
+ raise error_class, error_message
63
+ end
64
+
65
+ def error_class_for_status
66
+ ERROR_STATUS_MAP.fetch(status, Errors::ApiError)
67
+ end
68
+
69
+ def extract_error_message
70
+ if body.is_a?(Hash)
71
+ body['error'] || body['message'] || body['errors']&.join(', ') || "HTTP #{status}"
72
+ else
73
+ "HTTP #{status}: #{body}"
74
+ end
75
+ end
76
+ end
77
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ZaiPayment
4
- VERSION = '1.0.2'
4
+ VERSION = '1.2.0'
5
5
  end
data/lib/zai_payment.rb CHANGED
@@ -1,12 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'faraday'
4
+ require 'uri'
4
5
  require_relative 'zai_payment/version'
5
6
  require_relative 'zai_payment/config'
6
7
  require_relative 'zai_payment/errors'
7
8
  require_relative 'zai_payment/auth/token_provider'
8
9
  require_relative 'zai_payment/auth/token_store'
9
10
  require_relative 'zai_payment/auth/token_stores/memory_store'
11
+ require_relative 'zai_payment/client'
12
+ require_relative 'zai_payment/response'
13
+ require_relative 'zai_payment/resources/webhook'
10
14
 
11
15
  module ZaiPayment
12
16
  class << self
@@ -29,5 +33,11 @@ module ZaiPayment
29
33
  def clear_token! = auth.clear_token
30
34
  def token_expiry = auth.token_expiry
31
35
  def token_type = auth.token_type
36
+
37
+ # --- Resource accessors ---
38
+ # @return [ZaiPayment::Resources::Webhook] webhook resource instance
39
+ def webhooks
40
+ @webhooks ||= Resources::Webhook.new
41
+ end
32
42
  end
33
43
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zai_payment
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.2
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eddy Jaga
@@ -9,6 +9,20 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 0.3.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: 0.3.0
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: faraday
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -23,6 +37,20 @@ dependencies:
23
37
  - - "~>"
24
38
  - !ruby/object:Gem::Version
25
39
  version: '2.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: openssl
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.3'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.3'
26
54
  description: A Ruby gem for integrating with Zai payment platform APIs.
27
55
  email:
28
56
  - eddy.jaga@sentia.com.au
@@ -32,15 +60,26 @@ extra_rdoc_files: []
32
60
  files:
33
61
  - CHANGELOG.md
34
62
  - CODE_OF_CONDUCT.md
63
+ - IMPLEMENTATION.md
35
64
  - LICENSE.txt
36
65
  - README.md
37
66
  - Rakefile
67
+ - docs/ARCHITECTURE.md
68
+ - docs/AUTHENTICATION.md
69
+ - docs/README.md
70
+ - docs/WEBHOOKS.md
71
+ - docs/WEBHOOK_SECURITY_QUICKSTART.md
72
+ - docs/WEBHOOK_SIGNATURE.md
73
+ - examples/webhooks.md
38
74
  - lib/zai_payment.rb
39
75
  - lib/zai_payment/auth/token_provider.rb
40
76
  - lib/zai_payment/auth/token_store.rb
41
77
  - lib/zai_payment/auth/token_stores/memory_store.rb
78
+ - lib/zai_payment/client.rb
42
79
  - lib/zai_payment/config.rb
43
80
  - lib/zai_payment/errors.rb
81
+ - lib/zai_payment/resources/webhook.rb
82
+ - lib/zai_payment/response.rb
44
83
  - lib/zai_payment/version.rb
45
84
  - sig/zai_payment.rbs
46
85
  homepage: https://github.com/Sentia/zai-payment