zai_payment 2.1.0 → 2.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 +4 -4
- data/badges/coverage.json +1 -1
- data/changelog.md +47 -0
- data/docs/users.md +300 -3
- data/examples/rails_card_payment.md +716 -0
- data/lib/zai_payment/resources/user.rb +131 -3
- data/lib/zai_payment/response.rb +10 -4
- data/lib/zai_payment/version.rb +1 -1
- data/readme.md +24 -0
- metadata +3 -2
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
# Rails Card Payment Example
|
|
2
|
+
|
|
3
|
+
This guide demonstrates how to implement a complete card payment workflow in a Rails application using the `zai_payment` gem.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
1. [Setup](#setup)
|
|
8
|
+
2. [Configuration](#configuration)
|
|
9
|
+
3. [User Management](#user-management)
|
|
10
|
+
4. [Card Account Setup](#card-account-setup)
|
|
11
|
+
5. [Creating an Item](#creating-an-item)
|
|
12
|
+
6. [Making a Payment](#making-a-payment)
|
|
13
|
+
7. [Webhook Handling](#webhook-handling)
|
|
14
|
+
8. [Complete Flow Example](#complete-flow-example)
|
|
15
|
+
9. [Reference](#reference)
|
|
16
|
+
|
|
17
|
+
## Setup
|
|
18
|
+
|
|
19
|
+
Add the gem to your Gemfile:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
gem 'zai_payment'
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Then run:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
bundle install
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Configuration
|
|
32
|
+
|
|
33
|
+
Configure the gem in an initializer (`config/initializers/zai_payment.rb`):
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
ZaiPayment.configure do |config|
|
|
37
|
+
config.environment = Rails.env.production? ? 'production' : 'prelive'
|
|
38
|
+
config.client_id = ENV.fetch("ZAI_CLIENT_ID")
|
|
39
|
+
config.client_secret = ENV.fetch("ZAI_CLIENT_SECRET")
|
|
40
|
+
config.scope = ENV.fetch("ZAI_OAUTH_SCOPE")
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## User Management
|
|
45
|
+
|
|
46
|
+
### Creating Users
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
# In your controller or service object
|
|
50
|
+
class PaymentService
|
|
51
|
+
def initialize
|
|
52
|
+
@client = ZaiPayment::Client.new
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Create a buyer
|
|
56
|
+
def create_buyer(email:, first_name:, last_name:, mobile:)
|
|
57
|
+
response = @client.users.create(
|
|
58
|
+
id: "buyer_#{SecureRandom.hex(8)}", # Your internal user ID
|
|
59
|
+
first_name: first_name,
|
|
60
|
+
last_name: last_name,
|
|
61
|
+
email: email,
|
|
62
|
+
mobile: mobile,
|
|
63
|
+
country: 'AUS'
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Store zai_user_id in your database
|
|
67
|
+
response.data['id']
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Create a seller
|
|
71
|
+
def create_seller(email:, first_name:, last_name:, mobile:)
|
|
72
|
+
response = @client.users.create(
|
|
73
|
+
id: "seller_#{SecureRandom.hex(8)}",
|
|
74
|
+
first_name: first_name,
|
|
75
|
+
last_name: last_name,
|
|
76
|
+
email: email,
|
|
77
|
+
mobile: mobile,
|
|
78
|
+
country: 'AUS'
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
response.data['id']
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Example Controller
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
# app/controllers/users_controller.rb
|
|
90
|
+
class UsersController < ApplicationController
|
|
91
|
+
def create_zai_user
|
|
92
|
+
service = PaymentService.new
|
|
93
|
+
|
|
94
|
+
zai_user_id = service.create_buyer(
|
|
95
|
+
email: current_user.email,
|
|
96
|
+
first_name: current_user.first_name,
|
|
97
|
+
last_name: current_user.last_name,
|
|
98
|
+
mobile: current_user.phone
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Update your user record
|
|
102
|
+
current_user.update(zai_user_id: zai_user_id)
|
|
103
|
+
|
|
104
|
+
redirect_to dashboard_path, notice: 'User created successfully'
|
|
105
|
+
rescue ZaiPayment::Errors::ApiError => e
|
|
106
|
+
redirect_to dashboard_path, alert: "Error: #{e.message}"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Card Account Setup
|
|
112
|
+
|
|
113
|
+
### Generating a Card Auth Token
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
# app/controllers/card_accounts_controller.rb
|
|
117
|
+
class CardAccountsController < ApplicationController
|
|
118
|
+
def new
|
|
119
|
+
# Generate a card auth token for the hosted form
|
|
120
|
+
client = ZaiPayment::Client.new
|
|
121
|
+
|
|
122
|
+
response = client.token_auths.create(
|
|
123
|
+
token_type: 'card',
|
|
124
|
+
user_id: current_user.zai_user_id
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
@card_token = response.data['token']
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### View with Hosted Form
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
```erb
|
|
136
|
+
<!-- app/views/card_accounts/new.html.erb -->
|
|
137
|
+
<div class="card-form-container">
|
|
138
|
+
<h2>Add Your Card</h2>
|
|
139
|
+
|
|
140
|
+
<div id="card-container"></div>
|
|
141
|
+
|
|
142
|
+
<script src="https://hosted.assemblypay.com/assembly.js"></script>
|
|
143
|
+
<script>
|
|
144
|
+
let dropinHandler = DropIn.create({
|
|
145
|
+
cardTokenAuth: '<%= @card_token %>',
|
|
146
|
+
environment: '<%= Rails.env.production? ? 'production' : 'prelive' %>',
|
|
147
|
+
targetElementId: '#card-container',
|
|
148
|
+
cardAccountCreationCallback: function(cardAccountResult) {
|
|
149
|
+
// Send the card account ID to your server
|
|
150
|
+
fetch('/card_accounts', {
|
|
151
|
+
method: 'POST',
|
|
152
|
+
headers: {
|
|
153
|
+
'Content-Type': 'application/json',
|
|
154
|
+
'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
|
|
155
|
+
},
|
|
156
|
+
body: JSON.stringify({
|
|
157
|
+
card_account_id: cardAccountResult.id
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
.then(response => response.json())
|
|
161
|
+
.then(data => {
|
|
162
|
+
alert("Card saved successfully!");
|
|
163
|
+
window.location.href = '/payments/new';
|
|
164
|
+
})
|
|
165
|
+
.catch(error => {
|
|
166
|
+
alert("Error saving card: " + error);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}, function (error, instance) {
|
|
170
|
+
if(error) {
|
|
171
|
+
alert("Error: " + error);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
</script>
|
|
175
|
+
</div>
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
> **Note:** For more details on the Hosted Form integration, including PCI compliance, supported browsers, and error handling, see the [Zai Hosted Form Documentation](https://developer.hellozai.com/docs/integrating-drop-in-ui-for-capturing-a-credit-card).
|
|
179
|
+
|
|
180
|
+
### Storing Card Account ID
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
# app/controllers/card_accounts_controller.rb
|
|
184
|
+
class CardAccountsController < ApplicationController
|
|
185
|
+
def create
|
|
186
|
+
# Store the card account ID returned from the hosted form
|
|
187
|
+
current_user.update(
|
|
188
|
+
zai_card_account_id: params[:card_account_id]
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
render json: { success: true }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# List user's card accounts
|
|
195
|
+
def index
|
|
196
|
+
client = ZaiPayment::Client.new
|
|
197
|
+
|
|
198
|
+
response = client.card_accounts.list(
|
|
199
|
+
user_id: current_user.zai_user_id
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
@card_accounts = response.data['card_accounts']
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
## Creating an Item
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
# app/controllers/items_controller.rb
|
|
211
|
+
class ItemsController < ApplicationController
|
|
212
|
+
def create
|
|
213
|
+
client = ZaiPayment::Client.new
|
|
214
|
+
|
|
215
|
+
# Assuming you have a transaction/order model
|
|
216
|
+
transaction = current_user.transactions.create!(
|
|
217
|
+
amount: params[:amount],
|
|
218
|
+
seller_id: params[:seller_id],
|
|
219
|
+
description: params[:description]
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# Create item in Zai
|
|
223
|
+
response = client.items.create(
|
|
224
|
+
id: "order_#{transaction.id}", # Your internal order ID
|
|
225
|
+
name: params[:description],
|
|
226
|
+
amount: (params[:amount].to_f * 100).to_i, # Convert to cents
|
|
227
|
+
payment_type: 1, # 1 = Payin
|
|
228
|
+
buyer_id: current_user.zai_user_id,
|
|
229
|
+
seller_id: User.find(params[:seller_id]).zai_user_id,
|
|
230
|
+
fee_ids: '', # Optional: platform fees
|
|
231
|
+
description: params[:description]
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
zai_item_id = response.data['id']
|
|
235
|
+
transaction.update(zai_item_id: zai_item_id)
|
|
236
|
+
|
|
237
|
+
redirect_to payment_path(transaction), notice: 'Ready to make payment'
|
|
238
|
+
rescue ZaiPayment::Errors::ApiError => e
|
|
239
|
+
redirect_to new_item_path, alert: "Error: #{e.message}"
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
## Making a Payment
|
|
245
|
+
|
|
246
|
+
### Payment Controller
|
|
247
|
+
|
|
248
|
+
```ruby
|
|
249
|
+
# app/controllers/payments_controller.rb
|
|
250
|
+
class PaymentsController < ApplicationController
|
|
251
|
+
def show
|
|
252
|
+
@transaction = current_user.transactions.find(params[:id])
|
|
253
|
+
@card_accounts = fetch_card_accounts
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def create
|
|
257
|
+
transaction = current_user.transactions.find(params[:transaction_id])
|
|
258
|
+
client = ZaiPayment::Client.new
|
|
259
|
+
|
|
260
|
+
# Make payment using card account
|
|
261
|
+
response = client.items.make_payment(
|
|
262
|
+
id: transaction.zai_item_id,
|
|
263
|
+
account_id: params[:card_account_id] # The card account ID
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
transaction.update(
|
|
267
|
+
status: 'processing',
|
|
268
|
+
zai_transaction_id: response.data.dig('transactions', 0, 'id')
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
redirect_to transaction_path(transaction),
|
|
272
|
+
notice: 'Payment initiated successfully. You will be notified once completed.'
|
|
273
|
+
rescue ZaiPayment::Errors::ApiError => e
|
|
274
|
+
redirect_to payment_path(transaction), alert: "Payment failed: #{e.message}"
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
private
|
|
278
|
+
|
|
279
|
+
def fetch_card_accounts
|
|
280
|
+
client = ZaiPayment::Client.new
|
|
281
|
+
response = client.card_accounts.list(user_id: current_user.zai_user_id)
|
|
282
|
+
response.data['card_accounts'] || []
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
### Payment View
|
|
288
|
+
|
|
289
|
+
```erb
|
|
290
|
+
<!-- app/views/payments/show.html.erb -->
|
|
291
|
+
<div class="payment-page">
|
|
292
|
+
<h2>Complete Payment</h2>
|
|
293
|
+
|
|
294
|
+
<div class="transaction-details">
|
|
295
|
+
<p><strong>Amount:</strong> <%= number_to_currency(@transaction.amount) %></p>
|
|
296
|
+
<p><strong>Description:</strong> <%= @transaction.description %></p>
|
|
297
|
+
<p><strong>Seller:</strong> <%= @transaction.seller.name %></p>
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
<%= form_with url: payments_path, method: :post do |f| %>
|
|
301
|
+
<%= f.hidden_field :transaction_id, value: @transaction.id %>
|
|
302
|
+
|
|
303
|
+
<div class="form-group">
|
|
304
|
+
<%= f.label :card_account_id, "Select Card" %>
|
|
305
|
+
<%= f.select :card_account_id,
|
|
306
|
+
options_for_select(@card_accounts.map { |ca|
|
|
307
|
+
["#{ca['card']['type']} ending in #{ca['card']['number']}", ca['id']]
|
|
308
|
+
}),
|
|
309
|
+
{ include_blank: 'Choose a card' },
|
|
310
|
+
class: 'form-control' %>
|
|
311
|
+
</div>
|
|
312
|
+
|
|
313
|
+
<div class="actions">
|
|
314
|
+
<%= f.submit "Pay #{number_to_currency(@transaction.amount)}",
|
|
315
|
+
class: 'btn btn-primary' %>
|
|
316
|
+
<%= link_to 'Add New Card', new_card_account_path,
|
|
317
|
+
class: 'btn btn-secondary' %>
|
|
318
|
+
</div>
|
|
319
|
+
<% end %>
|
|
320
|
+
</div>
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
## Webhook Handling
|
|
324
|
+
|
|
325
|
+
### Setting Up Webhooks
|
|
326
|
+
|
|
327
|
+
```ruby
|
|
328
|
+
# One-time setup (can be done in Rails console or a rake task)
|
|
329
|
+
# lib/tasks/zai_setup.rake
|
|
330
|
+
|
|
331
|
+
namespace :zai do
|
|
332
|
+
desc "Setup Zai webhooks"
|
|
333
|
+
task setup_webhooks: :environment do
|
|
334
|
+
client = ZaiPayment::Client.new
|
|
335
|
+
|
|
336
|
+
# Item status changes
|
|
337
|
+
client.webhooks.create(
|
|
338
|
+
object_type: 'items',
|
|
339
|
+
url: "#{ENV['APP_URL']}/webhooks/zai/items"
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# Transaction events
|
|
343
|
+
client.webhooks.create(
|
|
344
|
+
object_type: 'transactions',
|
|
345
|
+
url: "#{ENV['APP_URL']}/webhooks/zai/transactions"
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
# Batch transaction events (settlement)
|
|
349
|
+
client.webhooks.create(
|
|
350
|
+
object_type: 'batch_transactions',
|
|
351
|
+
url: "#{ENV['APP_URL']}/webhooks/zai/batch_transactions"
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
puts "Webhooks configured successfully!"
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
### Webhook Controller
|
|
360
|
+
|
|
361
|
+
```ruby
|
|
362
|
+
# app/controllers/webhooks/zai_controller.rb
|
|
363
|
+
module Webhooks
|
|
364
|
+
class ZaiController < ApplicationController
|
|
365
|
+
skip_before_action :verify_authenticity_token
|
|
366
|
+
before_action :verify_webhook_signature
|
|
367
|
+
|
|
368
|
+
# Handle item status changes
|
|
369
|
+
def items
|
|
370
|
+
payload = JSON.parse(request.body.read)
|
|
371
|
+
|
|
372
|
+
# Item completed
|
|
373
|
+
if payload['status'] == 'completed'
|
|
374
|
+
item_id = payload['id']
|
|
375
|
+
transaction = Transaction.find_by(zai_item_id: item_id)
|
|
376
|
+
|
|
377
|
+
if transaction
|
|
378
|
+
transaction.update(
|
|
379
|
+
status: 'completed',
|
|
380
|
+
released_amount: payload['released_amount']
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
# Notify user
|
|
384
|
+
PaymentMailer.payment_completed(transaction).deliver_later
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
head :ok
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Handle transaction events (card authorized)
|
|
392
|
+
def transactions
|
|
393
|
+
payload = JSON.parse(request.body.read)
|
|
394
|
+
|
|
395
|
+
if payload['type'] == 'payment' && payload['type_method'] == 'credit_card'
|
|
396
|
+
transaction = Transaction.find_by(zai_transaction_id: payload['id'])
|
|
397
|
+
|
|
398
|
+
if transaction
|
|
399
|
+
if payload['status'] == 'successful'
|
|
400
|
+
transaction.update(status: 'authorized')
|
|
401
|
+
# Notify user
|
|
402
|
+
PaymentMailer.payment_authorized(transaction).deliver_later
|
|
403
|
+
elsif payload['status'] == 'failed'
|
|
404
|
+
transaction.update(status: 'failed', failure_reason: payload['failure_reason'])
|
|
405
|
+
PaymentMailer.payment_failed(transaction).deliver_later
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
head :ok
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# Handle batch transactions (funds settled)
|
|
414
|
+
def batch_transactions
|
|
415
|
+
payload = JSON.parse(request.body.read)
|
|
416
|
+
|
|
417
|
+
if payload['type'] == 'payment_funding' &&
|
|
418
|
+
payload['type_method'] == 'credit_card' &&
|
|
419
|
+
payload['status'] == 'successful'
|
|
420
|
+
|
|
421
|
+
# Find related item and mark as settled
|
|
422
|
+
batch_id = payload['id']
|
|
423
|
+
transaction = Transaction.find_by(zai_batch_transaction_id: batch_id)
|
|
424
|
+
|
|
425
|
+
if transaction
|
|
426
|
+
transaction.update(status: 'settled')
|
|
427
|
+
# Funds are now in seller's wallet
|
|
428
|
+
PaymentMailer.payment_settled(transaction).deliver_later
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
head :ok
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
private
|
|
436
|
+
|
|
437
|
+
def verify_webhook_signature
|
|
438
|
+
# Verify the webhook signature using the gem's helper
|
|
439
|
+
signature = request.headers['X-Webhook-Signature']
|
|
440
|
+
|
|
441
|
+
unless ZaiPayment::Webhook.verify_signature?(
|
|
442
|
+
payload: request.body.read,
|
|
443
|
+
signature: signature,
|
|
444
|
+
secret: ENV['ZAI_WEBHOOK_SECRET']
|
|
445
|
+
)
|
|
446
|
+
head :unauthorized
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
### Routes for Webhooks
|
|
454
|
+
|
|
455
|
+
```ruby
|
|
456
|
+
# config/routes.rb
|
|
457
|
+
Rails.application.routes.draw do
|
|
458
|
+
namespace :webhooks do
|
|
459
|
+
post 'zai/items', to: 'zai#items'
|
|
460
|
+
post 'zai/transactions', to: 'zai#transactions'
|
|
461
|
+
post 'zai/batch_transactions', to: 'zai#batch_transactions'
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
## Complete Flow Example
|
|
467
|
+
|
|
468
|
+
### Service Object for Complete Payment Flow
|
|
469
|
+
|
|
470
|
+
```ruby
|
|
471
|
+
# app/services/card_payment_flow.rb
|
|
472
|
+
class CardPaymentFlow
|
|
473
|
+
attr_reader :client, :errors
|
|
474
|
+
|
|
475
|
+
def initialize
|
|
476
|
+
@client = ZaiPayment::Client.new
|
|
477
|
+
@errors = []
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# Complete flow from creating users to payment
|
|
481
|
+
def execute(buyer_params:, seller_params:, payment_params:)
|
|
482
|
+
ActiveRecord::Base.transaction do
|
|
483
|
+
# Step 1: Create or get buyer
|
|
484
|
+
buyer_id = ensure_zai_user(buyer_params)
|
|
485
|
+
return false unless buyer_id
|
|
486
|
+
|
|
487
|
+
# Step 2: Create or get seller
|
|
488
|
+
seller_id = ensure_zai_user(seller_params)
|
|
489
|
+
return false unless seller_id
|
|
490
|
+
|
|
491
|
+
# Step 3: Create item
|
|
492
|
+
item_id = create_item(
|
|
493
|
+
buyer_id: buyer_id,
|
|
494
|
+
seller_id: seller_id,
|
|
495
|
+
amount: payment_params[:amount],
|
|
496
|
+
description: payment_params[:description]
|
|
497
|
+
)
|
|
498
|
+
return false unless item_id
|
|
499
|
+
|
|
500
|
+
# Step 4: Make payment
|
|
501
|
+
make_payment(
|
|
502
|
+
item_id: item_id,
|
|
503
|
+
card_account_id: payment_params[:card_account_id]
|
|
504
|
+
)
|
|
505
|
+
end
|
|
506
|
+
rescue ZaiPayment::Errors::ApiError => e
|
|
507
|
+
@errors << e.message
|
|
508
|
+
false
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
private
|
|
512
|
+
|
|
513
|
+
def ensure_zai_user(user_params)
|
|
514
|
+
# Check if user already exists in Zai
|
|
515
|
+
return user_params[:zai_user_id] if user_params[:zai_user_id].present?
|
|
516
|
+
|
|
517
|
+
# Create new user
|
|
518
|
+
response = @client.users.create(
|
|
519
|
+
id: user_params[:id],
|
|
520
|
+
first_name: user_params[:first_name],
|
|
521
|
+
last_name: user_params[:last_name],
|
|
522
|
+
email: user_params[:email],
|
|
523
|
+
mobile: user_params[:mobile],
|
|
524
|
+
country: 'AUS'
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
response.data['id']
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def create_item(buyer_id:, seller_id:, amount:, description:)
|
|
531
|
+
response = @client.items.create(
|
|
532
|
+
id: "item_#{SecureRandom.hex(8)}",
|
|
533
|
+
name: description,
|
|
534
|
+
amount: (amount.to_f * 100).to_i,
|
|
535
|
+
payment_type: 1,
|
|
536
|
+
buyer_id: buyer_id,
|
|
537
|
+
seller_id: seller_id,
|
|
538
|
+
description: description
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
response.data['id']
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def make_payment(item_id:, card_account_id:)
|
|
545
|
+
response = @client.items.make_payment(
|
|
546
|
+
id: item_id,
|
|
547
|
+
account_id: card_account_id
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
response.success?
|
|
551
|
+
end
|
|
552
|
+
end
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
### Usage Example
|
|
556
|
+
|
|
557
|
+
```ruby
|
|
558
|
+
# In a controller or background job
|
|
559
|
+
class ProcessPaymentJob < ApplicationJob
|
|
560
|
+
queue_as :default
|
|
561
|
+
|
|
562
|
+
def perform(transaction_id)
|
|
563
|
+
transaction = Transaction.find(transaction_id)
|
|
564
|
+
flow = CardPaymentFlow.new
|
|
565
|
+
|
|
566
|
+
success = flow.execute(
|
|
567
|
+
buyer_params: {
|
|
568
|
+
id: "buyer_#{transaction.buyer.id}",
|
|
569
|
+
zai_user_id: transaction.buyer.zai_user_id,
|
|
570
|
+
first_name: transaction.buyer.first_name,
|
|
571
|
+
last_name: transaction.buyer.last_name,
|
|
572
|
+
email: transaction.buyer.email,
|
|
573
|
+
mobile: transaction.buyer.phone
|
|
574
|
+
},
|
|
575
|
+
seller_params: {
|
|
576
|
+
id: "seller_#{transaction.seller.id}",
|
|
577
|
+
zai_user_id: transaction.seller.zai_user_id,
|
|
578
|
+
first_name: transaction.seller.first_name,
|
|
579
|
+
last_name: transaction.seller.last_name,
|
|
580
|
+
email: transaction.seller.email,
|
|
581
|
+
mobile: transaction.seller.phone
|
|
582
|
+
},
|
|
583
|
+
payment_params: {
|
|
584
|
+
amount: transaction.amount,
|
|
585
|
+
description: transaction.description,
|
|
586
|
+
card_account_id: transaction.buyer.zai_card_account_id
|
|
587
|
+
}
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
if success
|
|
591
|
+
transaction.update(status: 'processing')
|
|
592
|
+
PaymentMailer.payment_initiated(transaction).deliver_now
|
|
593
|
+
else
|
|
594
|
+
transaction.update(status: 'failed', error_message: flow.errors.join(', '))
|
|
595
|
+
PaymentMailer.payment_failed(transaction).deliver_now
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
## Pre-live Testing
|
|
602
|
+
|
|
603
|
+
For testing in the pre-live environment:
|
|
604
|
+
|
|
605
|
+
```ruby
|
|
606
|
+
# After making a payment, simulate settlement
|
|
607
|
+
class SimulateSettlement
|
|
608
|
+
def self.call(transaction_id)
|
|
609
|
+
transaction = Transaction.find(transaction_id)
|
|
610
|
+
client = ZaiPayment::Client.new
|
|
611
|
+
|
|
612
|
+
# Step 1: Export batch transactions
|
|
613
|
+
response = client.batch_transactions.export
|
|
614
|
+
batch_transactions = response.data['batch_transactions']
|
|
615
|
+
|
|
616
|
+
# Find the relevant batch transaction
|
|
617
|
+
batch_tx = batch_transactions.find do |bt|
|
|
618
|
+
bt['related_items']&.include?(transaction.zai_item_id)
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
return unless batch_tx
|
|
622
|
+
|
|
623
|
+
# Step 2: Move to batched state (12700)
|
|
624
|
+
client.batches.update_transaction_state(
|
|
625
|
+
id: batch_tx['batch_id'],
|
|
626
|
+
exported_ids: [batch_tx['id']],
|
|
627
|
+
state: 12700
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
# Step 3: Move to successful state (12000)
|
|
631
|
+
client.batches.update_transaction_state(
|
|
632
|
+
id: batch_tx['batch_id'],
|
|
633
|
+
exported_ids: [batch_tx['id']],
|
|
634
|
+
state: 12000
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
puts "Settlement simulated for transaction #{transaction_id}"
|
|
638
|
+
end
|
|
639
|
+
end
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
## Error Handling
|
|
643
|
+
|
|
644
|
+
```ruby
|
|
645
|
+
# app/services/payment_error_handler.rb
|
|
646
|
+
class PaymentErrorHandler
|
|
647
|
+
RETRY_ERRORS = [
|
|
648
|
+
ZaiPayment::Errors::TimeoutError,
|
|
649
|
+
ZaiPayment::Errors::ConnectionError,
|
|
650
|
+
ZaiPayment::Errors::ServerError
|
|
651
|
+
]
|
|
652
|
+
|
|
653
|
+
PERMANENT_ERRORS = [
|
|
654
|
+
ZaiPayment::Errors::ValidationError,
|
|
655
|
+
ZaiPayment::Errors::UnauthorizedError,
|
|
656
|
+
ZaiPayment::Errors::ForbiddenError,
|
|
657
|
+
ZaiPayment::Errors::NotFoundError
|
|
658
|
+
]
|
|
659
|
+
|
|
660
|
+
def self.handle(error, transaction)
|
|
661
|
+
case error
|
|
662
|
+
when *RETRY_ERRORS
|
|
663
|
+
# Retry the job
|
|
664
|
+
ProcessPaymentJob.set(wait: 5.minutes).perform_later(transaction.id)
|
|
665
|
+
when *PERMANENT_ERRORS
|
|
666
|
+
# Mark as failed permanently
|
|
667
|
+
transaction.update(
|
|
668
|
+
status: 'failed',
|
|
669
|
+
error_message: error.message
|
|
670
|
+
)
|
|
671
|
+
PaymentMailer.payment_error(transaction, error).deliver_now
|
|
672
|
+
when ZaiPayment::Errors::RateLimitError
|
|
673
|
+
# Retry after longer delay
|
|
674
|
+
ProcessPaymentJob.set(wait: 1.hour).perform_later(transaction.id)
|
|
675
|
+
else
|
|
676
|
+
# Log unknown error
|
|
677
|
+
Rails.logger.error("Unknown payment error: #{error.class} - #{error.message}")
|
|
678
|
+
Sentry.capture_exception(error) if defined?(Sentry)
|
|
679
|
+
end
|
|
680
|
+
end
|
|
681
|
+
end
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
## Summary
|
|
685
|
+
|
|
686
|
+
This example demonstrates:
|
|
687
|
+
|
|
688
|
+
1. **User Creation**: Creating buyer and seller users in Zai
|
|
689
|
+
2. **Card Capture**: Using the hosted form to securely capture card details
|
|
690
|
+
3. **Item Creation**: Creating a payment item between buyer and seller
|
|
691
|
+
4. **Payment Processing**: Making a payment using a card account
|
|
692
|
+
5. **Webhook Handling**: Responding to payment events (authorized, completed, settled)
|
|
693
|
+
6. **Error Handling**: Properly handling various error scenarios
|
|
694
|
+
7. **Testing**: Simulating settlements in pre-live environment
|
|
695
|
+
|
|
696
|
+
### Payment Flow States
|
|
697
|
+
|
|
698
|
+
1. **Item Created** → Item exists but no payment made
|
|
699
|
+
2. **Payment Initiated** → `make_payment` called
|
|
700
|
+
3. **Authorized** → Card transaction successful (webhook: `transactions`)
|
|
701
|
+
4. **Completed** → Item status changed to completed (webhook: `items`)
|
|
702
|
+
5. **Settled** → Funds moved to seller's wallet (webhook: `batch_transactions`)
|
|
703
|
+
|
|
704
|
+
### Important Notes
|
|
705
|
+
|
|
706
|
+
- Always store Zai IDs (`zai_user_id`, `zai_item_id`, `zai_card_account_id`) in your database
|
|
707
|
+
- Use webhooks for asynchronous updates rather than polling
|
|
708
|
+
- Implement proper error handling and retries
|
|
709
|
+
- Use background jobs for payment processing
|
|
710
|
+
- Test thoroughly in pre-live environment before production
|
|
711
|
+
- Verify webhook signatures for security
|
|
712
|
+
- Handle PCI compliance requirements (the hosted form helps with this)
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
## Reference
|
|
716
|
+
- [Zai Cards Payin Workflow](https://developer.hellozai.com/docs/cards-payin-workflow)
|