zai_payment 1.1.0 → 1.3.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 +4 -4
- data/CHANGELOG.md +108 -0
- data/CONTRIBUTING.md +383 -0
- data/IMPLEMENTATION.md +280 -177
- data/IMPLEMENTATION_SUMMARY.md +195 -0
- data/README.md +124 -44
- data/badges/.gitkeep +2 -0
- data/badges/coverage.json +1 -0
- data/docs/AUTHENTICATION.md +647 -0
- data/docs/README.md +81 -0
- data/docs/USERS.md +414 -0
- data/docs/USER_ID_FIELD.md +284 -0
- data/docs/USER_QUICK_REFERENCE.md +230 -0
- data/docs/WEBHOOKS.md +261 -1
- data/docs/WEBHOOK_SECURITY_QUICKSTART.md +136 -0
- data/docs/WEBHOOK_SIGNATURE.md +244 -0
- data/examples/users.md +746 -0
- data/examples/webhooks.md +489 -0
- data/lib/zai_payment/client.rb +10 -3
- data/lib/zai_payment/resources/user.rb +383 -0
- data/lib/zai_payment/resources/webhook.rb +174 -0
- data/lib/zai_payment/response.rb +1 -1
- data/lib/zai_payment/version.rb +1 -1
- data/lib/zai_payment.rb +6 -0
- metadata +42 -1
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZaiPayment
|
|
4
|
+
module Resources
|
|
5
|
+
# User resource for managing Zai users (payin and payout)
|
|
6
|
+
#
|
|
7
|
+
# @see https://developer.hellozai.com/docs/onboarding-a-payin-user
|
|
8
|
+
# @see https://developer.hellozai.com/docs/onboarding-a-payout-user
|
|
9
|
+
class User
|
|
10
|
+
attr_reader :client
|
|
11
|
+
|
|
12
|
+
# User types
|
|
13
|
+
USER_TYPE_PAYIN = 'payin'
|
|
14
|
+
USER_TYPE_PAYOUT = 'payout'
|
|
15
|
+
|
|
16
|
+
# Valid user types
|
|
17
|
+
VALID_USER_TYPES = [USER_TYPE_PAYIN, USER_TYPE_PAYOUT].freeze
|
|
18
|
+
|
|
19
|
+
# Map of attribute keys to API field names
|
|
20
|
+
FIELD_MAPPING = {
|
|
21
|
+
id: :id,
|
|
22
|
+
email: :email,
|
|
23
|
+
first_name: :first_name,
|
|
24
|
+
last_name: :last_name,
|
|
25
|
+
mobile: :mobile,
|
|
26
|
+
phone: :phone,
|
|
27
|
+
address_line1: :address_line1,
|
|
28
|
+
address_line2: :address_line2,
|
|
29
|
+
city: :city,
|
|
30
|
+
state: :state,
|
|
31
|
+
zip: :zip,
|
|
32
|
+
country: :country,
|
|
33
|
+
dob: :dob,
|
|
34
|
+
government_number: :government_number,
|
|
35
|
+
drivers_license_number: :drivers_license_number,
|
|
36
|
+
drivers_license_state: :drivers_license_state,
|
|
37
|
+
logo_url: :logo_url,
|
|
38
|
+
color_1: :color_1,
|
|
39
|
+
color_2: :color_2,
|
|
40
|
+
custom_descriptor: :custom_descriptor,
|
|
41
|
+
authorized_signer_title: :authorized_signer_title,
|
|
42
|
+
user_type: :user_type,
|
|
43
|
+
device_id: :device_id,
|
|
44
|
+
ip_address: :ip_address
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
# Map of company attribute keys to API field names
|
|
48
|
+
COMPANY_FIELD_MAPPING = {
|
|
49
|
+
name: :name,
|
|
50
|
+
legal_name: :legal_name,
|
|
51
|
+
tax_number: :tax_number,
|
|
52
|
+
business_email: :business_email,
|
|
53
|
+
charge_tax: :charge_tax,
|
|
54
|
+
address_line1: :address_line1,
|
|
55
|
+
address_line2: :address_line2,
|
|
56
|
+
city: :city,
|
|
57
|
+
state: :state,
|
|
58
|
+
zip: :zip,
|
|
59
|
+
country: :country,
|
|
60
|
+
phone: :phone
|
|
61
|
+
}.freeze
|
|
62
|
+
|
|
63
|
+
def initialize(client: nil)
|
|
64
|
+
@client = client || Client.new
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# List all users
|
|
68
|
+
#
|
|
69
|
+
# @param limit [Integer] number of records to return (default: 10)
|
|
70
|
+
# @param offset [Integer] number of records to skip (default: 0)
|
|
71
|
+
# @return [Response] the API response containing users array
|
|
72
|
+
#
|
|
73
|
+
# @example
|
|
74
|
+
# users = ZaiPayment::Resources::User.new
|
|
75
|
+
# response = users.list
|
|
76
|
+
# response.data # => [{"id" => "...", "email" => "..."}, ...]
|
|
77
|
+
#
|
|
78
|
+
# @see https://developer.hellozai.com/reference/getallusers
|
|
79
|
+
def list(limit: 10, offset: 0)
|
|
80
|
+
params = {
|
|
81
|
+
limit: limit,
|
|
82
|
+
offset: offset
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
client.get('/users', params: params)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Get a specific user by ID
|
|
89
|
+
#
|
|
90
|
+
# @param user_id [String] the user ID
|
|
91
|
+
# @return [Response] the API response containing user details
|
|
92
|
+
#
|
|
93
|
+
# @example
|
|
94
|
+
# users = ZaiPayment::Resources::User.new
|
|
95
|
+
# response = users.show("user_id")
|
|
96
|
+
# response.data # => {"id" => "user_id", "email" => "...", ...}
|
|
97
|
+
#
|
|
98
|
+
# @see https://developer.hellozai.com/reference/getuserbyid
|
|
99
|
+
def show(user_id)
|
|
100
|
+
validate_id!(user_id, 'user_id')
|
|
101
|
+
client.get("/users/#{user_id}")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Create a new user (payin or payout)
|
|
105
|
+
#
|
|
106
|
+
# @param attributes [Hash] user attributes
|
|
107
|
+
# @option attributes [String] :id Optional unique ID for the user. If not provided,
|
|
108
|
+
# Zai will generate one automatically. Cannot contain '.' character.
|
|
109
|
+
# Useful for mapping to your existing system's user IDs.
|
|
110
|
+
# @option attributes [String] :email (Required) user's email address
|
|
111
|
+
# @option attributes [String] :first_name (Required) user's first name
|
|
112
|
+
# @option attributes [String] :last_name (Required) user's last name
|
|
113
|
+
# @option attributes [String] :country (Required) user's country code (ISO 3166-1 alpha-3)
|
|
114
|
+
# @option attributes [String] :user_type Optional user type ('payin' or 'payout')
|
|
115
|
+
# @option attributes [String] :mobile user's mobile phone number (international format with '+')
|
|
116
|
+
# @option attributes [String] :phone user's phone number
|
|
117
|
+
# @option attributes [String] :address_line1 user's address line 1
|
|
118
|
+
# @option attributes [String] :address_line2 user's address line 2
|
|
119
|
+
# @option attributes [String] :city user's city
|
|
120
|
+
# @option attributes [String] :state user's state
|
|
121
|
+
# @option attributes [String] :zip user's postal/zip code
|
|
122
|
+
# @option attributes [String] :dob user's date of birth (DD/MM/YYYY)
|
|
123
|
+
# @option attributes [String] :government_number user's government ID number (SSN, TFN, etc.)
|
|
124
|
+
# @option attributes [String] :drivers_license_number driving license number
|
|
125
|
+
# @option attributes [String] :drivers_license_state state section of the user's driving license
|
|
126
|
+
# @option attributes [String] :logo_url URL link to the logo
|
|
127
|
+
# @option attributes [String] :color_1 color code number 1
|
|
128
|
+
# @option attributes [String] :color_2 color code number 2
|
|
129
|
+
# @option attributes [String] :custom_descriptor custom descriptor for bundle direct debit statements
|
|
130
|
+
# @option attributes [String] :authorized_signer_title job title for AMEX merchants (e.g., Director)
|
|
131
|
+
# @option attributes [Hash] :company company details (creates a company for the user)
|
|
132
|
+
# @option attributes [String] :device_id device ID for fraud prevention
|
|
133
|
+
# @option attributes [String] :ip_address IP address for fraud prevention
|
|
134
|
+
# @return [Response] the API response containing created user
|
|
135
|
+
#
|
|
136
|
+
# @example Create a payin user (buyer) with auto-generated ID
|
|
137
|
+
# users = ZaiPayment::Resources::User.new
|
|
138
|
+
# response = users.create(
|
|
139
|
+
# email: "buyer@example.com",
|
|
140
|
+
# first_name: "John",
|
|
141
|
+
# last_name: "Doe",
|
|
142
|
+
# country: "USA",
|
|
143
|
+
# mobile: "+1234567890",
|
|
144
|
+
# address_line1: "123 Main St",
|
|
145
|
+
# city: "New York",
|
|
146
|
+
# state: "NY",
|
|
147
|
+
# zip: "10001"
|
|
148
|
+
# )
|
|
149
|
+
#
|
|
150
|
+
# @example Create a payin user with custom ID
|
|
151
|
+
# users = ZaiPayment::Resources::User.new
|
|
152
|
+
# response = users.create(
|
|
153
|
+
# id: "buyer-#{your_user_id}",
|
|
154
|
+
# email: "buyer@example.com",
|
|
155
|
+
# first_name: "John",
|
|
156
|
+
# last_name: "Doe",
|
|
157
|
+
# country: "USA"
|
|
158
|
+
# )
|
|
159
|
+
#
|
|
160
|
+
# @example Create a payout user (seller/merchant)
|
|
161
|
+
# users = ZaiPayment::Resources::User.new
|
|
162
|
+
# response = users.create(
|
|
163
|
+
# email: "seller@example.com",
|
|
164
|
+
# first_name: "Jane",
|
|
165
|
+
# last_name: "Smith",
|
|
166
|
+
# country: "AUS",
|
|
167
|
+
# dob: "19900101",
|
|
168
|
+
# address_line1: "456 Market St",
|
|
169
|
+
# city: "Sydney",
|
|
170
|
+
# state: "NSW",
|
|
171
|
+
# zip: "2000",
|
|
172
|
+
# mobile: "+61412345678"
|
|
173
|
+
# )
|
|
174
|
+
#
|
|
175
|
+
# @example Create a user with company details
|
|
176
|
+
# users = ZaiPayment::Resources::User.new
|
|
177
|
+
# response = users.create(
|
|
178
|
+
# email: "business@example.com",
|
|
179
|
+
# first_name: "John",
|
|
180
|
+
# last_name: "Doe",
|
|
181
|
+
# country: "AUS",
|
|
182
|
+
# mobile: "+61412345678",
|
|
183
|
+
# authorized_signer_title: "Director",
|
|
184
|
+
# company: {
|
|
185
|
+
# name: "ABC Company",
|
|
186
|
+
# legal_name: "ABC Pty Ltd",
|
|
187
|
+
# tax_number: "123456789",
|
|
188
|
+
# business_email: "admin@abc.com",
|
|
189
|
+
# country: "AUS",
|
|
190
|
+
# charge_tax: true,
|
|
191
|
+
# address_line1: "123 Business St",
|
|
192
|
+
# city: "Melbourne",
|
|
193
|
+
# state: "VIC",
|
|
194
|
+
# zip: "3000",
|
|
195
|
+
# phone: "+61398765432"
|
|
196
|
+
# }
|
|
197
|
+
# )
|
|
198
|
+
#
|
|
199
|
+
# @see https://developer.hellozai.com/reference/createuser
|
|
200
|
+
# @see https://developer.hellozai.com/docs/onboarding-a-payin-user
|
|
201
|
+
# @see https://developer.hellozai.com/docs/onboarding-a-payout-user
|
|
202
|
+
def create(**attributes)
|
|
203
|
+
validate_create_attributes!(attributes)
|
|
204
|
+
|
|
205
|
+
body = build_user_body(attributes)
|
|
206
|
+
client.post('/users', body: body)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Update an existing user
|
|
210
|
+
#
|
|
211
|
+
# @param user_id [String] the user ID
|
|
212
|
+
# @param attributes [Hash] user attributes to update
|
|
213
|
+
# @option attributes [String] :email user's email address
|
|
214
|
+
# @option attributes [String] :first_name user's first name
|
|
215
|
+
# @option attributes [String] :last_name user's last name
|
|
216
|
+
# @option attributes [String] :mobile user's mobile phone number (international format with '+')
|
|
217
|
+
# @option attributes [String] :phone user's phone number
|
|
218
|
+
# @option attributes [String] :address_line1 user's address line 1
|
|
219
|
+
# @option attributes [String] :address_line2 user's address line 2
|
|
220
|
+
# @option attributes [String] :city user's city
|
|
221
|
+
# @option attributes [String] :state user's state
|
|
222
|
+
# @option attributes [String] :zip user's postal/zip code
|
|
223
|
+
# @option attributes [String] :dob user's date of birth (DD/MM/YYYY)
|
|
224
|
+
# @option attributes [String] :government_number user's government ID number (SSN, TFN, etc.)
|
|
225
|
+
# @option attributes [String] :drivers_license_number driving license number
|
|
226
|
+
# @option attributes [String] :drivers_license_state state section of the user's driving license
|
|
227
|
+
# @option attributes [String] :logo_url URL link to the logo
|
|
228
|
+
# @option attributes [String] :color_1 color code number 1
|
|
229
|
+
# @option attributes [String] :color_2 color code number 2
|
|
230
|
+
# @option attributes [String] :custom_descriptor custom descriptor for bundle direct debit statements
|
|
231
|
+
# @option attributes [String] :authorized_signer_title job title for AMEX merchants (e.g., Director)
|
|
232
|
+
# @return [Response] the API response containing updated user
|
|
233
|
+
#
|
|
234
|
+
# @example
|
|
235
|
+
# users = ZaiPayment::Resources::User.new
|
|
236
|
+
# response = users.update(
|
|
237
|
+
# "user_id",
|
|
238
|
+
# mobile: "+1234567890",
|
|
239
|
+
# address_line1: "789 New St"
|
|
240
|
+
# )
|
|
241
|
+
#
|
|
242
|
+
# @see https://developer.hellozai.com/reference/updateuser
|
|
243
|
+
def update(user_id, **attributes)
|
|
244
|
+
validate_id!(user_id, 'user_id')
|
|
245
|
+
|
|
246
|
+
body = build_user_body(attributes)
|
|
247
|
+
|
|
248
|
+
validate_email!(attributes[:email]) if attributes[:email]
|
|
249
|
+
validate_dob!(attributes[:dob]) if attributes[:dob]
|
|
250
|
+
|
|
251
|
+
raise Errors::ValidationError, 'At least one attribute must be provided for update' if body.empty?
|
|
252
|
+
|
|
253
|
+
client.patch("/users/#{user_id}", body: body)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
private
|
|
257
|
+
|
|
258
|
+
def validate_id!(value, field_name)
|
|
259
|
+
return unless value.nil? || value.to_s.strip.empty?
|
|
260
|
+
|
|
261
|
+
raise Errors::ValidationError, "#{field_name} is required and cannot be blank"
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def validate_presence!(value, field_name)
|
|
265
|
+
return unless value.nil? || value.to_s.strip.empty?
|
|
266
|
+
|
|
267
|
+
raise Errors::ValidationError, "#{field_name} is required and cannot be blank"
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def validate_create_attributes!(attributes) # rubocop:disable Metrics/AbcSize
|
|
271
|
+
validate_required_attributes!(attributes)
|
|
272
|
+
validate_user_type!(attributes[:user_type]) if attributes[:user_type]
|
|
273
|
+
validate_email!(attributes[:email])
|
|
274
|
+
validate_country!(attributes[:country])
|
|
275
|
+
validate_dob!(attributes[:dob]) if attributes[:dob]
|
|
276
|
+
validate_user_id!(attributes[:id]) if attributes[:id]
|
|
277
|
+
validate_company!(attributes[:company]) if attributes[:company]
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def validate_required_attributes!(attributes)
|
|
281
|
+
required_fields = %i[email first_name last_name country]
|
|
282
|
+
|
|
283
|
+
missing_fields = required_fields.select do |field|
|
|
284
|
+
attributes[field].nil? || attributes[field].to_s.strip.empty?
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
return if missing_fields.empty?
|
|
288
|
+
|
|
289
|
+
raise Errors::ValidationError,
|
|
290
|
+
"Missing required fields: #{missing_fields.join(', ')}"
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def validate_user_type!(user_type)
|
|
294
|
+
return if VALID_USER_TYPES.include?(user_type.to_s.downcase)
|
|
295
|
+
|
|
296
|
+
raise Errors::ValidationError,
|
|
297
|
+
"user_type must be one of: #{VALID_USER_TYPES.join(', ')}"
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def validate_email!(email)
|
|
301
|
+
# Basic email format validation
|
|
302
|
+
email_regex = /\A[^@\s]+@[^@\s]+\.[^@\s]+\z/
|
|
303
|
+
return if email&.match?(email_regex)
|
|
304
|
+
|
|
305
|
+
raise Errors::ValidationError, 'email must be a valid email address'
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def validate_country!(country)
|
|
309
|
+
# Country should be ISO 3166-1 alpha-3 code (3 letters)
|
|
310
|
+
return if country.to_s.match?(/\A[A-Z]{3}\z/i)
|
|
311
|
+
|
|
312
|
+
raise Errors::ValidationError, 'country must be a valid ISO 3166-1 alpha-3 code (e.g., USA, AUS, GBR)'
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def validate_dob!(dob)
|
|
316
|
+
# Date of birth should be in DD/MM/YYYY format
|
|
317
|
+
return if dob.to_s.match?(%r{\A\d{2}/\d{2}/\d{4}\z})
|
|
318
|
+
|
|
319
|
+
raise Errors::ValidationError, 'dob must be in DD/MM/YYYY format (e.g., 15/01/1990)'
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def validate_user_id!(user_id)
|
|
323
|
+
# User ID cannot contain '.' character
|
|
324
|
+
raise Errors::ValidationError, "id cannot contain '.' character" if user_id.to_s.include?('.')
|
|
325
|
+
|
|
326
|
+
# Check if empty
|
|
327
|
+
return unless user_id.nil? || user_id.to_s.strip.empty?
|
|
328
|
+
|
|
329
|
+
raise Errors::ValidationError, 'id cannot be blank if provided'
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def validate_company!(company)
|
|
333
|
+
return unless company.is_a?(Hash)
|
|
334
|
+
|
|
335
|
+
# Required company fields
|
|
336
|
+
required_company_fields = %i[name legal_name tax_number business_email country]
|
|
337
|
+
|
|
338
|
+
missing_fields = required_company_fields.select do |field|
|
|
339
|
+
company[field].nil? || company[field].to_s.strip.empty?
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
return if missing_fields.empty?
|
|
343
|
+
|
|
344
|
+
raise Errors::ValidationError,
|
|
345
|
+
"Company is missing required fields: #{missing_fields.join(', ')}"
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def build_user_body(attributes) # rubocop:disable Metrics/CyclomaticComplexity
|
|
349
|
+
body = {}
|
|
350
|
+
|
|
351
|
+
attributes.each do |key, value|
|
|
352
|
+
next if value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
353
|
+
|
|
354
|
+
# Handle company object separately
|
|
355
|
+
if key == :company
|
|
356
|
+
body[:company] = build_company_body(value) if value.is_a?(Hash)
|
|
357
|
+
next
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
api_field = FIELD_MAPPING[key]
|
|
361
|
+
body[api_field] = value if api_field
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
body
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def build_company_body(company_attributes)
|
|
368
|
+
company = {}
|
|
369
|
+
|
|
370
|
+
company_attributes.each do |key, value|
|
|
371
|
+
# Don't skip false values for charge_tax
|
|
372
|
+
next if value.nil?
|
|
373
|
+
next if key != :charge_tax && value.respond_to?(:empty?) && value.empty?
|
|
374
|
+
|
|
375
|
+
api_field = COMPANY_FIELD_MAPPING[key]
|
|
376
|
+
company[api_field] = value if api_field
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
company
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
end
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'openssl'
|
|
4
|
+
require 'base64'
|
|
5
|
+
|
|
3
6
|
module ZaiPayment
|
|
4
7
|
module Resources
|
|
5
8
|
# Webhook resource for managing Zai webhooks
|
|
@@ -130,6 +133,102 @@ module ZaiPayment
|
|
|
130
133
|
client.delete("/webhooks/#{webhook_id}")
|
|
131
134
|
end
|
|
132
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
|
+
|
|
133
232
|
private
|
|
134
233
|
|
|
135
234
|
def validate_id!(value, field_name)
|
|
@@ -152,6 +251,81 @@ module ZaiPayment
|
|
|
152
251
|
rescue URI::InvalidURIError
|
|
153
252
|
raise Errors::ValidationError, 'url must be a valid URL'
|
|
154
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
|
|
155
329
|
end
|
|
156
330
|
end
|
|
157
331
|
end
|
data/lib/zai_payment/response.rb
CHANGED
data/lib/zai_payment/version.rb
CHANGED
data/lib/zai_payment.rb
CHANGED
|
@@ -11,6 +11,7 @@ require_relative 'zai_payment/auth/token_stores/memory_store'
|
|
|
11
11
|
require_relative 'zai_payment/client'
|
|
12
12
|
require_relative 'zai_payment/response'
|
|
13
13
|
require_relative 'zai_payment/resources/webhook'
|
|
14
|
+
require_relative 'zai_payment/resources/user'
|
|
14
15
|
|
|
15
16
|
module ZaiPayment
|
|
16
17
|
class << self
|
|
@@ -39,5 +40,10 @@ module ZaiPayment
|
|
|
39
40
|
def webhooks
|
|
40
41
|
@webhooks ||= Resources::Webhook.new
|
|
41
42
|
end
|
|
43
|
+
|
|
44
|
+
# @return [ZaiPayment::Resources::User] user resource instance
|
|
45
|
+
def users
|
|
46
|
+
@users ||= Resources::User.new(client: Client.new(base_endpoint: :core_base))
|
|
47
|
+
end
|
|
42
48
|
end
|
|
43
49
|
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.
|
|
4
|
+
version: 1.3.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,12 +60,24 @@ extra_rdoc_files: []
|
|
|
32
60
|
files:
|
|
33
61
|
- CHANGELOG.md
|
|
34
62
|
- CODE_OF_CONDUCT.md
|
|
63
|
+
- CONTRIBUTING.md
|
|
35
64
|
- IMPLEMENTATION.md
|
|
65
|
+
- IMPLEMENTATION_SUMMARY.md
|
|
36
66
|
- LICENSE.txt
|
|
37
67
|
- README.md
|
|
38
68
|
- Rakefile
|
|
69
|
+
- badges/.gitkeep
|
|
70
|
+
- badges/coverage.json
|
|
39
71
|
- docs/ARCHITECTURE.md
|
|
72
|
+
- docs/AUTHENTICATION.md
|
|
73
|
+
- docs/README.md
|
|
74
|
+
- docs/USERS.md
|
|
75
|
+
- docs/USER_ID_FIELD.md
|
|
76
|
+
- docs/USER_QUICK_REFERENCE.md
|
|
40
77
|
- docs/WEBHOOKS.md
|
|
78
|
+
- docs/WEBHOOK_SECURITY_QUICKSTART.md
|
|
79
|
+
- docs/WEBHOOK_SIGNATURE.md
|
|
80
|
+
- examples/users.md
|
|
41
81
|
- examples/webhooks.md
|
|
42
82
|
- lib/zai_payment.rb
|
|
43
83
|
- lib/zai_payment/auth/token_provider.rb
|
|
@@ -46,6 +86,7 @@ files:
|
|
|
46
86
|
- lib/zai_payment/client.rb
|
|
47
87
|
- lib/zai_payment/config.rb
|
|
48
88
|
- lib/zai_payment/errors.rb
|
|
89
|
+
- lib/zai_payment/resources/user.rb
|
|
49
90
|
- lib/zai_payment/resources/webhook.rb
|
|
50
91
|
- lib/zai_payment/response.rb
|
|
51
92
|
- lib/zai_payment/version.rb
|