zai_payment 2.3.1 → 2.4.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 +54 -0
- data/docs/items.md +575 -0
- data/examples/items.md +2115 -0
- data/examples/rails_card_payment.md +550 -14
- data/lib/zai_payment/client.rb +13 -2
- data/lib/zai_payment/config.rb +4 -3
- data/lib/zai_payment/resources/item.rb +257 -0
- data/lib/zai_payment/version.rb +1 -1
- data/readme.md +5 -0
- metadata +1 -2
- data/token_auth_implementation_summary.md +0 -249
data/examples/items.md
CHANGED
|
@@ -17,6 +17,10 @@ This document provides examples of how to use the Items resource in the Zai Paym
|
|
|
17
17
|
- [List Item Transactions](#list-item-transactions)
|
|
18
18
|
- [List Item Batch Transactions](#list-item-batch-transactions)
|
|
19
19
|
- [Show Item Status](#show-item-status)
|
|
20
|
+
- [Make Payment](#make-payment)
|
|
21
|
+
- [Make Async Payment](#make-async-payment)
|
|
22
|
+
- [Cancel Item](#cancel-item)
|
|
23
|
+
- [Refund Item](#refund-item)
|
|
20
24
|
|
|
21
25
|
## Setup
|
|
22
26
|
|
|
@@ -445,6 +449,409 @@ else
|
|
|
445
449
|
end
|
|
446
450
|
```
|
|
447
451
|
|
|
452
|
+
## Make Payment
|
|
453
|
+
|
|
454
|
+
Process a payment for an item using a card account. This method charges the buyer's card and initiates the payment flow.
|
|
455
|
+
|
|
456
|
+
### Basic Payment
|
|
457
|
+
|
|
458
|
+
```ruby
|
|
459
|
+
# Make a payment with just the required parameters
|
|
460
|
+
response = items.make_payment(
|
|
461
|
+
"item-123",
|
|
462
|
+
account_id: "card_account-456" # Required
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
if response.success?
|
|
466
|
+
item = response.data
|
|
467
|
+
puts "Payment initiated for item: #{item['id']}"
|
|
468
|
+
puts "State: #{item['state']}"
|
|
469
|
+
puts "Payment State: #{item['payment_state']}"
|
|
470
|
+
else
|
|
471
|
+
puts "Payment failed: #{response.error_message}"
|
|
472
|
+
end
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
### Payment with Device Information
|
|
476
|
+
|
|
477
|
+
For enhanced fraud protection, include device and IP address information:
|
|
478
|
+
|
|
479
|
+
```ruby
|
|
480
|
+
response = items.make_payment(
|
|
481
|
+
"item-123",
|
|
482
|
+
account_id: "card_account-456", # Required
|
|
483
|
+
device_id: "device_789",
|
|
484
|
+
ip_address: request.remote_ip # In a Rails controller
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
if response.success?
|
|
488
|
+
puts "Payment processed with device tracking"
|
|
489
|
+
end
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
### Payment with CVV
|
|
493
|
+
|
|
494
|
+
Some card payments may require CVV verification:
|
|
495
|
+
|
|
496
|
+
```ruby
|
|
497
|
+
response = items.make_payment(
|
|
498
|
+
"item-123",
|
|
499
|
+
account_id: "card_account-456", # Required
|
|
500
|
+
cvv: "123" # CVV from secure form
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
if response.success?
|
|
504
|
+
puts "Payment processed with CVV verification"
|
|
505
|
+
end
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
### Payment with All Optional Parameters
|
|
509
|
+
|
|
510
|
+
Maximum fraud protection with all available parameters:
|
|
511
|
+
|
|
512
|
+
```ruby
|
|
513
|
+
response = items.make_payment(
|
|
514
|
+
"item-123",
|
|
515
|
+
account_id: "card_account-456", # Required
|
|
516
|
+
device_id: "device_789",
|
|
517
|
+
ip_address: "192.168.1.1",
|
|
518
|
+
cvv: "123",
|
|
519
|
+
merchant_phone: "+61412345678"
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
if response.success?
|
|
523
|
+
item = response.data
|
|
524
|
+
puts "Payment initiated successfully"
|
|
525
|
+
puts "Item State: #{item['state']}"
|
|
526
|
+
puts "Payment State: #{item['payment_state']}"
|
|
527
|
+
puts "Amount: #{item['amount']}"
|
|
528
|
+
else
|
|
529
|
+
puts "Payment failed: #{response.error_message}"
|
|
530
|
+
end
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
### Error Handling for Payments
|
|
534
|
+
|
|
535
|
+
```ruby
|
|
536
|
+
begin
|
|
537
|
+
response = items.make_payment(
|
|
538
|
+
"item-123",
|
|
539
|
+
account_id: "card_account-456"
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
if response.success?
|
|
543
|
+
puts "Payment successful"
|
|
544
|
+
else
|
|
545
|
+
# Handle API errors
|
|
546
|
+
case response.status
|
|
547
|
+
when 422
|
|
548
|
+
puts "Validation error: #{response.error_message}"
|
|
549
|
+
# Common: Insufficient funds, card declined, etc.
|
|
550
|
+
when 404
|
|
551
|
+
puts "Item or card account not found"
|
|
552
|
+
when 401
|
|
553
|
+
puts "Authentication failed"
|
|
554
|
+
else
|
|
555
|
+
puts "Payment error: #{response.error_message}"
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
rescue ZaiPayment::Errors::ValidationError => e
|
|
559
|
+
puts "Validation error: #{e.message}"
|
|
560
|
+
# Example: "account_id is required and cannot be blank"
|
|
561
|
+
rescue ZaiPayment::Errors::NotFoundError => e
|
|
562
|
+
puts "Resource not found: #{e.message}"
|
|
563
|
+
rescue ZaiPayment::Errors::ApiError => e
|
|
564
|
+
puts "API error: #{e.message}"
|
|
565
|
+
end
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
### Real-World Payment Flow Example
|
|
569
|
+
|
|
570
|
+
Complete example showing item creation through payment:
|
|
571
|
+
|
|
572
|
+
```ruby
|
|
573
|
+
require 'zai_payment'
|
|
574
|
+
|
|
575
|
+
# Configure
|
|
576
|
+
ZaiPayment.configure do |config|
|
|
577
|
+
config.client_id = ENV['ZAI_CLIENT_ID']
|
|
578
|
+
config.client_secret = ENV['ZAI_CLIENT_SECRET']
|
|
579
|
+
config.scope = ENV['ZAI_SCOPE']
|
|
580
|
+
config.environment = :prelive
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
items = ZaiPayment.items
|
|
584
|
+
|
|
585
|
+
# Step 1: Create an item
|
|
586
|
+
create_response = items.create(
|
|
587
|
+
name: "Product Purchase",
|
|
588
|
+
amount: 10000, # $100.00
|
|
589
|
+
payment_type: 2,
|
|
590
|
+
buyer_id: "buyer-123",
|
|
591
|
+
seller_id: "seller-456",
|
|
592
|
+
description: "Purchase of premium widget"
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
if create_response.success?
|
|
596
|
+
item_id = create_response.data['id']
|
|
597
|
+
puts "✓ Item created: #{item_id}"
|
|
598
|
+
|
|
599
|
+
# Step 2: Make the payment
|
|
600
|
+
payment_response = items.make_payment(
|
|
601
|
+
item_id,
|
|
602
|
+
account_id: "card_account-789", # Buyer's card account (Required)
|
|
603
|
+
ip_address: "192.168.1.1",
|
|
604
|
+
device_id: "device_abc123"
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
if payment_response.success?
|
|
608
|
+
puts "✓ Payment initiated"
|
|
609
|
+
puts " State: #{payment_response.data['state']}"
|
|
610
|
+
puts " Payment State: #{payment_response.data['payment_state']}"
|
|
611
|
+
|
|
612
|
+
# Step 3: Check payment status
|
|
613
|
+
sleep 2 # Wait for processing
|
|
614
|
+
|
|
615
|
+
status_response = items.show_status(item_id)
|
|
616
|
+
if status_response.success?
|
|
617
|
+
status = status_response.data
|
|
618
|
+
puts "✓ Current status:"
|
|
619
|
+
puts " State: #{status['state']}"
|
|
620
|
+
puts " Payment State: #{status['payment_state']}"
|
|
621
|
+
end
|
|
622
|
+
else
|
|
623
|
+
puts "✗ Payment failed: #{payment_response.error_message}"
|
|
624
|
+
end
|
|
625
|
+
else
|
|
626
|
+
puts "✗ Item creation failed: #{create_response.error_message}"
|
|
627
|
+
end
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
### Payment States
|
|
631
|
+
|
|
632
|
+
After calling `make_payment`, the item will go through several states:
|
|
633
|
+
|
|
634
|
+
| State | Description |
|
|
635
|
+
|-------|-------------|
|
|
636
|
+
| `payment_pending` | Payment has been initiated |
|
|
637
|
+
| `payment_processing` | Card is being charged |
|
|
638
|
+
| `completed` | Payment successful, funds held in escrow |
|
|
639
|
+
| `payment_held` | Payment succeeded but held for review |
|
|
640
|
+
| `payment_failed` | Payment failed (card declined, insufficient funds, etc.) |
|
|
641
|
+
|
|
642
|
+
### Webhook Integration
|
|
643
|
+
|
|
644
|
+
After making a payment, listen for webhook events to track the payment status:
|
|
645
|
+
|
|
646
|
+
```ruby
|
|
647
|
+
# In your webhook handler
|
|
648
|
+
def handle_transaction_webhook(payload)
|
|
649
|
+
if payload['type'] == 'payment' && payload['status'] == 'successful'
|
|
650
|
+
item_id = payload['related_items'].first
|
|
651
|
+
puts "Payment successful for item: #{item_id}"
|
|
652
|
+
|
|
653
|
+
# Update your database
|
|
654
|
+
Order.find_by(zai_item_id: item_id).update(status: 'paid')
|
|
655
|
+
elsif payload['status'] == 'failed'
|
|
656
|
+
puts "Payment failed: #{payload['failure_reason']}"
|
|
657
|
+
end
|
|
658
|
+
end
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
## Authorize Payment
|
|
662
|
+
|
|
663
|
+
Authorize a payment without immediately capturing funds. This is useful for pre-authorization scenarios where you want to verify the card and hold funds before completing the transaction.
|
|
664
|
+
|
|
665
|
+
### Basic Authorization
|
|
666
|
+
|
|
667
|
+
```ruby
|
|
668
|
+
# Authorize a payment with required parameters
|
|
669
|
+
response = items.authorize_payment(
|
|
670
|
+
"item-123",
|
|
671
|
+
account_id: "card_account-456" # Required
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
if response.success?
|
|
675
|
+
item = response.data
|
|
676
|
+
puts "Payment authorized for item: #{item['id']}"
|
|
677
|
+
puts "State: #{item['state']}"
|
|
678
|
+
puts "Payment State: #{item['payment_state']}"
|
|
679
|
+
else
|
|
680
|
+
puts "Authorization failed: #{response.error_message}"
|
|
681
|
+
end
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
### Authorization with CVV
|
|
685
|
+
|
|
686
|
+
For additional security, include CVV verification:
|
|
687
|
+
|
|
688
|
+
```ruby
|
|
689
|
+
response = items.authorize_payment(
|
|
690
|
+
"item-123",
|
|
691
|
+
account_id: "card_account-456", # Required
|
|
692
|
+
cvv: "123" # CVV from secure form
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
if response.success?
|
|
696
|
+
puts "Payment authorized with CVV verification"
|
|
697
|
+
end
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
### Authorization with All Optional Parameters
|
|
701
|
+
|
|
702
|
+
```ruby
|
|
703
|
+
response = items.authorize_payment(
|
|
704
|
+
"item-123",
|
|
705
|
+
account_id: "card_account-456", # Required
|
|
706
|
+
cvv: "123",
|
|
707
|
+
merchant_phone: "+61412345678"
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
if response.success?
|
|
711
|
+
item = response.data
|
|
712
|
+
puts "Payment authorized successfully"
|
|
713
|
+
puts "Item State: #{item['state']}"
|
|
714
|
+
puts "Payment State: #{item['payment_state']}"
|
|
715
|
+
puts "Amount: #{item['amount']}"
|
|
716
|
+
else
|
|
717
|
+
puts "Authorization failed: #{response.error_message}"
|
|
718
|
+
end
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
### Error Handling for Authorization
|
|
722
|
+
|
|
723
|
+
```ruby
|
|
724
|
+
begin
|
|
725
|
+
response = items.authorize_payment(
|
|
726
|
+
"item-123",
|
|
727
|
+
account_id: "card_account-456"
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
if response.success?
|
|
731
|
+
puts "Authorization successful"
|
|
732
|
+
else
|
|
733
|
+
# Handle API errors
|
|
734
|
+
case response.status
|
|
735
|
+
when 422
|
|
736
|
+
puts "Validation error: #{response.error_message}"
|
|
737
|
+
# Common: Invalid card, card declined, etc.
|
|
738
|
+
when 404
|
|
739
|
+
puts "Item or card account not found"
|
|
740
|
+
when 401
|
|
741
|
+
puts "Authentication failed"
|
|
742
|
+
else
|
|
743
|
+
puts "Authorization error: #{response.error_message}"
|
|
744
|
+
end
|
|
745
|
+
end
|
|
746
|
+
rescue ZaiPayment::Errors::ValidationError => e
|
|
747
|
+
puts "Validation error: #{e.message}"
|
|
748
|
+
# Example: "account_id is required and cannot be blank"
|
|
749
|
+
rescue ZaiPayment::Errors::NotFoundError => e
|
|
750
|
+
puts "Resource not found: #{e.message}"
|
|
751
|
+
rescue ZaiPayment::Errors::ApiError => e
|
|
752
|
+
puts "API error: #{e.message}"
|
|
753
|
+
end
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
### Real-World Authorization Flow Example
|
|
757
|
+
|
|
758
|
+
Complete example showing item creation through authorization:
|
|
759
|
+
|
|
760
|
+
```ruby
|
|
761
|
+
require 'zai_payment'
|
|
762
|
+
|
|
763
|
+
# Configure
|
|
764
|
+
ZaiPayment.configure do |config|
|
|
765
|
+
config.client_id = ENV['ZAI_CLIENT_ID']
|
|
766
|
+
config.client_secret = ENV['ZAI_CLIENT_SECRET']
|
|
767
|
+
config.scope = ENV['ZAI_SCOPE']
|
|
768
|
+
config.environment = :prelive
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
items = ZaiPayment.items
|
|
772
|
+
|
|
773
|
+
# Step 1: Create an item
|
|
774
|
+
create_response = items.create(
|
|
775
|
+
name: "Hotel Reservation",
|
|
776
|
+
amount: 50000, # $500.00
|
|
777
|
+
payment_type: 2,
|
|
778
|
+
buyer_id: "buyer-123",
|
|
779
|
+
seller_id: "seller-456",
|
|
780
|
+
description: "Hotel booking - Hold authorization"
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
if create_response.success?
|
|
784
|
+
item_id = create_response.data['id']
|
|
785
|
+
puts "✓ Item created: #{item_id}"
|
|
786
|
+
|
|
787
|
+
# Step 2: Authorize the payment (hold funds without capturing)
|
|
788
|
+
auth_response = items.authorize_payment(
|
|
789
|
+
item_id,
|
|
790
|
+
account_id: "card_account-789",
|
|
791
|
+
cvv: "123",
|
|
792
|
+
merchant_phone: "+61412345678"
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
if auth_response.success?
|
|
796
|
+
puts "✓ Payment authorized (funds on hold)"
|
|
797
|
+
puts " State: #{auth_response.data['state']}"
|
|
798
|
+
puts " Payment State: #{auth_response.data['payment_state']}"
|
|
799
|
+
|
|
800
|
+
# Step 3: Check authorization status
|
|
801
|
+
status_response = items.show_status(item_id)
|
|
802
|
+
if status_response.success?
|
|
803
|
+
status = status_response.data
|
|
804
|
+
puts "✓ Current status:"
|
|
805
|
+
puts " State: #{status['state']}"
|
|
806
|
+
puts " Payment State: #{status['payment_state']}"
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
# Note: Funds are now held. You would later either:
|
|
810
|
+
# - Capture the payment (via make_payment or complete the item)
|
|
811
|
+
# - Cancel the authorization (via cancel)
|
|
812
|
+
else
|
|
813
|
+
puts "✗ Authorization failed: #{auth_response.error_message}"
|
|
814
|
+
end
|
|
815
|
+
else
|
|
816
|
+
puts "✗ Item creation failed: #{create_response.error_message}"
|
|
817
|
+
end
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
### Authorization States
|
|
821
|
+
|
|
822
|
+
After calling `authorize_payment`, the item will go through several states:
|
|
823
|
+
|
|
824
|
+
| State | Description |
|
|
825
|
+
|-------|-------------|
|
|
826
|
+
| `payment_authorized` | Payment has been authorized, funds are on hold |
|
|
827
|
+
| `payment_held` | Payment authorized but held for review |
|
|
828
|
+
| `authorization_failed` | Authorization failed (card declined, insufficient funds, etc.) |
|
|
829
|
+
|
|
830
|
+
**Important Notes:**
|
|
831
|
+
- Authorized funds are typically held for 7 days before being automatically released
|
|
832
|
+
- To complete the transaction, you need to capture the payment separately
|
|
833
|
+
- You can cancel an authorization to release the held funds immediately
|
|
834
|
+
- Not all payment processors support separate authorization and capture
|
|
835
|
+
|
|
836
|
+
### Webhook Integration
|
|
837
|
+
|
|
838
|
+
After authorizing a payment, listen for webhook events:
|
|
839
|
+
|
|
840
|
+
```ruby
|
|
841
|
+
# In your webhook handler
|
|
842
|
+
def handle_authorization_webhook(payload)
|
|
843
|
+
if payload['type'] == 'authorization' && payload['status'] == 'successful'
|
|
844
|
+
item_id = payload['related_items'].first
|
|
845
|
+
puts "Payment authorized for item: #{item_id}"
|
|
846
|
+
|
|
847
|
+
# Update your database
|
|
848
|
+
Order.find_by(zai_item_id: item_id).update(status: 'authorized')
|
|
849
|
+
elsif payload['status'] == 'failed'
|
|
850
|
+
puts "Authorization failed: #{payload['failure_reason']}"
|
|
851
|
+
end
|
|
852
|
+
end
|
|
853
|
+
```
|
|
854
|
+
|
|
448
855
|
## Complete Workflow Example
|
|
449
856
|
|
|
450
857
|
Here's a complete example of creating an item and performing various operations on it:
|
|
@@ -554,6 +961,1711 @@ else
|
|
|
554
961
|
end
|
|
555
962
|
```
|
|
556
963
|
|
|
964
|
+
## Capture Payment
|
|
965
|
+
|
|
966
|
+
Capture a previously authorized payment to complete the transaction. This is the second step in the authorize → capture workflow, allowing you to finalize a payment after authorization.
|
|
967
|
+
|
|
968
|
+
### Basic Capture (Full Amount)
|
|
969
|
+
|
|
970
|
+
Capture the full authorized amount:
|
|
971
|
+
|
|
972
|
+
```ruby
|
|
973
|
+
response = items.capture_payment("item-123")
|
|
974
|
+
|
|
975
|
+
if response.success?
|
|
976
|
+
item = response.data
|
|
977
|
+
puts "Payment captured successfully"
|
|
978
|
+
puts "Item ID: #{item['id']}"
|
|
979
|
+
puts "Amount: $#{item['amount'] / 100.0}"
|
|
980
|
+
puts "State: #{item['state']}"
|
|
981
|
+
puts "Payment State: #{item['payment_state']}"
|
|
982
|
+
else
|
|
983
|
+
puts "Capture failed: #{response.error_message}"
|
|
984
|
+
end
|
|
985
|
+
```
|
|
986
|
+
|
|
987
|
+
### Partial Capture
|
|
988
|
+
|
|
989
|
+
Capture only a portion of the authorized amount:
|
|
990
|
+
|
|
991
|
+
```ruby
|
|
992
|
+
# Capture $50 of a $100 authorization
|
|
993
|
+
response = items.capture_payment("item-123", amount: 5000)
|
|
994
|
+
|
|
995
|
+
if response.success?
|
|
996
|
+
item = response.data
|
|
997
|
+
puts "Partial payment captured: $#{item['amount'] / 100.0}"
|
|
998
|
+
puts "State: #{item['state']}"
|
|
999
|
+
puts "Payment State: #{item['payment_state']}"
|
|
1000
|
+
else
|
|
1001
|
+
puts "Capture failed: #{response.error_message}"
|
|
1002
|
+
end
|
|
1003
|
+
```
|
|
1004
|
+
|
|
1005
|
+
### Capture with Error Handling
|
|
1006
|
+
|
|
1007
|
+
```ruby
|
|
1008
|
+
begin
|
|
1009
|
+
response = items.capture_payment("item-123", amount: 10_000)
|
|
1010
|
+
|
|
1011
|
+
if response.success?
|
|
1012
|
+
item = response.data
|
|
1013
|
+
puts "✓ Payment captured: #{item['id']}"
|
|
1014
|
+
puts " Amount: $#{item['amount'] / 100.0}"
|
|
1015
|
+
puts " State: #{item['state']}"
|
|
1016
|
+
puts " Payment State: #{item['payment_state']}"
|
|
1017
|
+
else
|
|
1018
|
+
# Handle API errors
|
|
1019
|
+
case response.status
|
|
1020
|
+
when 422
|
|
1021
|
+
puts "Cannot capture: #{response.error_message}"
|
|
1022
|
+
# Common: Payment not authorized, authorization expired, or invalid amount
|
|
1023
|
+
when 404
|
|
1024
|
+
puts "Item not found"
|
|
1025
|
+
when 401
|
|
1026
|
+
puts "Authentication failed"
|
|
1027
|
+
else
|
|
1028
|
+
puts "Capture error: #{response.error_message}"
|
|
1029
|
+
end
|
|
1030
|
+
end
|
|
1031
|
+
rescue ZaiPayment::Errors::ValidationError => e
|
|
1032
|
+
puts "Validation error: #{e.message}"
|
|
1033
|
+
rescue ZaiPayment::Errors::NotFoundError => e
|
|
1034
|
+
puts "Item not found: #{e.message}"
|
|
1035
|
+
rescue ZaiPayment::Errors::ApiError => e
|
|
1036
|
+
puts "API error: #{e.message}"
|
|
1037
|
+
end
|
|
1038
|
+
```
|
|
1039
|
+
|
|
1040
|
+
### Capture with Status Check
|
|
1041
|
+
|
|
1042
|
+
Check authorization status before attempting to capture:
|
|
1043
|
+
|
|
1044
|
+
```ruby
|
|
1045
|
+
# Check current status
|
|
1046
|
+
status_response = items.show_status("item-123")
|
|
1047
|
+
|
|
1048
|
+
if status_response.success?
|
|
1049
|
+
status = status_response.data
|
|
1050
|
+
payment_state = status['payment_state']
|
|
1051
|
+
|
|
1052
|
+
puts "Current payment state: #{payment_state}"
|
|
1053
|
+
|
|
1054
|
+
# Only capture if payment is authorized
|
|
1055
|
+
if payment_state == 'authorized' || payment_state == 'payment_authorized'
|
|
1056
|
+
capture_response = items.capture_payment("item-123")
|
|
1057
|
+
|
|
1058
|
+
if capture_response.success?
|
|
1059
|
+
puts "✓ Payment captured successfully"
|
|
1060
|
+
else
|
|
1061
|
+
puts "✗ Capture failed: #{capture_response.error_message}"
|
|
1062
|
+
end
|
|
1063
|
+
else
|
|
1064
|
+
puts "Payment cannot be captured - current state: #{payment_state}"
|
|
1065
|
+
end
|
|
1066
|
+
end
|
|
1067
|
+
```
|
|
1068
|
+
|
|
1069
|
+
### Complete Authorization and Capture Workflow
|
|
1070
|
+
|
|
1071
|
+
Full example demonstrating the two-step payment process:
|
|
1072
|
+
|
|
1073
|
+
```ruby
|
|
1074
|
+
# Step 1: Authorize the payment
|
|
1075
|
+
puts "Step 1: Authorizing payment..."
|
|
1076
|
+
auth_response = items.authorize_payment(
|
|
1077
|
+
"item-123",
|
|
1078
|
+
account_id: "card_account-456",
|
|
1079
|
+
cvv: "123"
|
|
1080
|
+
)
|
|
1081
|
+
|
|
1082
|
+
if auth_response.success?
|
|
1083
|
+
puts "✓ Payment authorized"
|
|
1084
|
+
auth_data = auth_response.data
|
|
1085
|
+
puts " Item ID: #{auth_data['id']}"
|
|
1086
|
+
puts " Amount: $#{auth_data['amount'] / 100.0}"
|
|
1087
|
+
puts " State: #{auth_data['state']}"
|
|
1088
|
+
|
|
1089
|
+
# Step 2: Verify authorization status
|
|
1090
|
+
puts "\nStep 2: Verifying authorization..."
|
|
1091
|
+
status_response = items.show_status("item-123")
|
|
1092
|
+
|
|
1093
|
+
if status_response.success?
|
|
1094
|
+
status = status_response.data
|
|
1095
|
+
puts "✓ Status verified"
|
|
1096
|
+
puts " Payment State: #{status['payment_state']}"
|
|
1097
|
+
|
|
1098
|
+
# Step 3: Capture the payment (can be done immediately or later)
|
|
1099
|
+
puts "\nStep 3: Capturing payment..."
|
|
1100
|
+
|
|
1101
|
+
# Wait a moment (optional - simulate real-world delay)
|
|
1102
|
+
sleep 1
|
|
1103
|
+
|
|
1104
|
+
capture_response = items.capture_payment("item-123")
|
|
1105
|
+
|
|
1106
|
+
if capture_response.success?
|
|
1107
|
+
capture_data = capture_response.data
|
|
1108
|
+
puts "✓ Payment captured successfully"
|
|
1109
|
+
puts " Item ID: #{capture_data['id']}"
|
|
1110
|
+
puts " Final State: #{capture_data['state']}"
|
|
1111
|
+
puts " Final Payment State: #{capture_data['payment_state']}"
|
|
1112
|
+
else
|
|
1113
|
+
puts "✗ Capture failed: #{capture_response.error_message}"
|
|
1114
|
+
end
|
|
1115
|
+
else
|
|
1116
|
+
puts "✗ Status check failed: #{status_response.error_message}"
|
|
1117
|
+
end
|
|
1118
|
+
else
|
|
1119
|
+
puts "✗ Authorization failed: #{auth_response.error_message}"
|
|
1120
|
+
end
|
|
1121
|
+
```
|
|
1122
|
+
|
|
1123
|
+
### Capture with Retry Logic
|
|
1124
|
+
|
|
1125
|
+
Implement retry logic for transient errors:
|
|
1126
|
+
|
|
1127
|
+
```ruby
|
|
1128
|
+
def capture_payment_with_retry(item_id, amount: nil, max_retries: 3)
|
|
1129
|
+
retries = 0
|
|
1130
|
+
|
|
1131
|
+
begin
|
|
1132
|
+
response = items.capture_payment(item_id, amount: amount)
|
|
1133
|
+
|
|
1134
|
+
if response.success?
|
|
1135
|
+
puts "✓ Payment captured successfully"
|
|
1136
|
+
return response
|
|
1137
|
+
elsif response.status == 422
|
|
1138
|
+
# Validation error - don't retry
|
|
1139
|
+
puts "✗ Capture failed: #{response.error_message}"
|
|
1140
|
+
return response
|
|
1141
|
+
else
|
|
1142
|
+
# Other errors - maybe retry
|
|
1143
|
+
raise "Capture error: #{response.error_message}"
|
|
1144
|
+
end
|
|
1145
|
+
|
|
1146
|
+
rescue => e
|
|
1147
|
+
retries += 1
|
|
1148
|
+
|
|
1149
|
+
if retries < max_retries
|
|
1150
|
+
puts "⚠ Capture attempt #{retries} failed: #{e.message}"
|
|
1151
|
+
puts " Retrying in #{retries * 2} seconds..."
|
|
1152
|
+
sleep(retries * 2)
|
|
1153
|
+
retry
|
|
1154
|
+
else
|
|
1155
|
+
puts "✗ Capture failed after #{max_retries} attempts"
|
|
1156
|
+
raise e
|
|
1157
|
+
end
|
|
1158
|
+
end
|
|
1159
|
+
end
|
|
1160
|
+
|
|
1161
|
+
# Usage
|
|
1162
|
+
begin
|
|
1163
|
+
response = capture_payment_with_retry("item-123", amount: 10_000)
|
|
1164
|
+
puts "Final state: #{response.data['payment_state']}" if response.success?
|
|
1165
|
+
rescue => e
|
|
1166
|
+
puts "Capture ultimately failed: #{e.message}"
|
|
1167
|
+
end
|
|
1168
|
+
```
|
|
1169
|
+
|
|
1170
|
+
### Real-World Capture Flow Example
|
|
1171
|
+
|
|
1172
|
+
A complete example showing how to handle the authorize and capture workflow in a real application:
|
|
1173
|
+
|
|
1174
|
+
```ruby
|
|
1175
|
+
class PaymentProcessor
|
|
1176
|
+
def initialize
|
|
1177
|
+
@items = ZaiPayment.items
|
|
1178
|
+
end
|
|
1179
|
+
|
|
1180
|
+
def process_two_step_payment(order)
|
|
1181
|
+
# Step 1: Authorize
|
|
1182
|
+
puts "Processing order ##{order[:id]} - Amount: $#{order[:amount] / 100.0}"
|
|
1183
|
+
|
|
1184
|
+
auth_response = authorize_payment(order)
|
|
1185
|
+
return { success: false, error: "Authorization failed" } unless auth_response
|
|
1186
|
+
|
|
1187
|
+
# Step 2: Perform additional checks (inventory, fraud, etc.)
|
|
1188
|
+
return { success: false, error: "Fraud check failed" } unless verify_order(order)
|
|
1189
|
+
|
|
1190
|
+
# Step 3: Capture
|
|
1191
|
+
capture_response = capture_payment(order[:item_id], order[:capture_amount])
|
|
1192
|
+
return { success: false, error: "Capture failed" } unless capture_response
|
|
1193
|
+
|
|
1194
|
+
# Step 4: Update order and notify
|
|
1195
|
+
finalize_order(order, capture_response)
|
|
1196
|
+
|
|
1197
|
+
{ success: true, data: capture_response.data }
|
|
1198
|
+
end
|
|
1199
|
+
|
|
1200
|
+
private
|
|
1201
|
+
|
|
1202
|
+
def authorize_payment(order)
|
|
1203
|
+
puts "→ Authorizing payment..."
|
|
1204
|
+
|
|
1205
|
+
response = @items.authorize_payment(
|
|
1206
|
+
order[:item_id],
|
|
1207
|
+
account_id: order[:account_id],
|
|
1208
|
+
cvv: order[:cvv]
|
|
1209
|
+
)
|
|
1210
|
+
|
|
1211
|
+
if response.success?
|
|
1212
|
+
puts "✓ Payment authorized: #{order[:item_id]}"
|
|
1213
|
+
response
|
|
1214
|
+
else
|
|
1215
|
+
puts "✗ Authorization failed: #{response.error_message}"
|
|
1216
|
+
nil
|
|
1217
|
+
end
|
|
1218
|
+
end
|
|
1219
|
+
|
|
1220
|
+
def verify_order(order)
|
|
1221
|
+
puts "→ Verifying order..."
|
|
1222
|
+
|
|
1223
|
+
# Check inventory
|
|
1224
|
+
return false unless check_inventory(order[:items])
|
|
1225
|
+
|
|
1226
|
+
# Check fraud score
|
|
1227
|
+
return false unless check_fraud_score(order[:buyer_id])
|
|
1228
|
+
|
|
1229
|
+
puts "✓ Order verified"
|
|
1230
|
+
true
|
|
1231
|
+
end
|
|
1232
|
+
|
|
1233
|
+
def capture_payment(item_id, amount = nil)
|
|
1234
|
+
puts "→ Capturing payment..."
|
|
1235
|
+
|
|
1236
|
+
response = @items.capture_payment(item_id, amount: amount)
|
|
1237
|
+
|
|
1238
|
+
if response.success?
|
|
1239
|
+
puts "✓ Payment captured: #{item_id}"
|
|
1240
|
+
response
|
|
1241
|
+
else
|
|
1242
|
+
puts "✗ Capture failed: #{response.error_message}"
|
|
1243
|
+
nil
|
|
1244
|
+
end
|
|
1245
|
+
end
|
|
1246
|
+
|
|
1247
|
+
def finalize_order(order, capture_response)
|
|
1248
|
+
puts "→ Finalizing order..."
|
|
1249
|
+
|
|
1250
|
+
# Update order status in database
|
|
1251
|
+
# update_order_status(order[:id], 'paid')
|
|
1252
|
+
|
|
1253
|
+
# Send confirmation email
|
|
1254
|
+
# send_confirmation_email(order[:buyer_email])
|
|
1255
|
+
|
|
1256
|
+
# Update inventory
|
|
1257
|
+
# reduce_inventory(order[:items])
|
|
1258
|
+
|
|
1259
|
+
puts "✓ Order finalized: ##{order[:id]}"
|
|
1260
|
+
end
|
|
1261
|
+
|
|
1262
|
+
def check_inventory(items)
|
|
1263
|
+
# Implement inventory check
|
|
1264
|
+
true
|
|
1265
|
+
end
|
|
1266
|
+
|
|
1267
|
+
def check_fraud_score(buyer_id)
|
|
1268
|
+
# Implement fraud check
|
|
1269
|
+
true
|
|
1270
|
+
end
|
|
1271
|
+
end
|
|
1272
|
+
|
|
1273
|
+
# Usage
|
|
1274
|
+
processor = PaymentProcessor.new
|
|
1275
|
+
|
|
1276
|
+
order = {
|
|
1277
|
+
id: 12345,
|
|
1278
|
+
item_id: "item-abc123",
|
|
1279
|
+
account_id: "card_account-456",
|
|
1280
|
+
cvv: "123",
|
|
1281
|
+
amount: 10_000,
|
|
1282
|
+
capture_amount: 10_000, # Can be less for partial capture
|
|
1283
|
+
buyer_id: "buyer-789",
|
|
1284
|
+
buyer_email: "customer@example.com",
|
|
1285
|
+
items: ["product-1", "product-2"]
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
result = processor.process_two_step_payment(order)
|
|
1289
|
+
|
|
1290
|
+
if result[:success]
|
|
1291
|
+
puts "\n✓ Payment completed successfully!"
|
|
1292
|
+
puts " Item: #{result[:data]['id']}"
|
|
1293
|
+
puts " State: #{result[:data]['payment_state']}"
|
|
1294
|
+
else
|
|
1295
|
+
puts "\n✗ Payment failed: #{result[:error]}"
|
|
1296
|
+
end
|
|
1297
|
+
```
|
|
1298
|
+
|
|
1299
|
+
### Capture States and Conditions
|
|
1300
|
+
|
|
1301
|
+
Payments can be captured when in these states:
|
|
1302
|
+
|
|
1303
|
+
| State | Can Capture? | Description |
|
|
1304
|
+
|-------|-------------|-------------|
|
|
1305
|
+
| `authorized` | ✓ Yes | Payment authorized and ready to capture |
|
|
1306
|
+
| `payment_authorized` | ✓ Yes | Payment authorized and ready to capture |
|
|
1307
|
+
| `pending` | ✗ No | Payment not authorized yet |
|
|
1308
|
+
| `payment_pending` | ✗ No | Payment processing, not authorized |
|
|
1309
|
+
| `completed` | ✗ No | Already captured |
|
|
1310
|
+
| `payment_deposited` | ✗ No | Already captured and deposited |
|
|
1311
|
+
| `cancelled` | ✗ No | Authorization cancelled |
|
|
1312
|
+
| `refunded` | ✗ No | Payment refunded |
|
|
1313
|
+
|
|
1314
|
+
### Best Practices for Capture
|
|
1315
|
+
|
|
1316
|
+
1. **Timely Captures**: Capture authorized payments within 7 days (typical authorization expiration)
|
|
1317
|
+
2. **Status Verification**: Always check payment state before attempting capture
|
|
1318
|
+
3. **Partial Captures**: Use for order adjustments or split fulfillment
|
|
1319
|
+
4. **Error Handling**: Implement robust error handling and retry logic
|
|
1320
|
+
5. **Logging**: Log all authorization and capture attempts for audit trails
|
|
1321
|
+
6. **Notifications**: Notify customers when payment is captured
|
|
1322
|
+
7. **Timeouts**: Set appropriate timeout values for capture requests
|
|
1323
|
+
|
|
1324
|
+
### Common Capture Errors
|
|
1325
|
+
|
|
1326
|
+
| Error | Cause | Solution |
|
|
1327
|
+
|-------|-------|----------|
|
|
1328
|
+
| "Payment not authorized" | Trying to capture non-authorized payment | Authorize first, then capture |
|
|
1329
|
+
| "Authorization expired" | Authorization older than 7 days | Create new item and authorize again |
|
|
1330
|
+
| "Amount exceeds authorized amount" | Capture amount > authorized amount | Reduce capture amount or re-authorize |
|
|
1331
|
+
| "Item not found" | Invalid item ID | Verify item ID is correct |
|
|
1332
|
+
| "Payment already captured" | Duplicate capture attempt | Check payment state before capture |
|
|
1333
|
+
|
|
1334
|
+
### Capture Integration with Rails
|
|
1335
|
+
|
|
1336
|
+
#### In a Controller
|
|
1337
|
+
|
|
1338
|
+
```ruby
|
|
1339
|
+
class PaymentsController < ApplicationController
|
|
1340
|
+
def capture
|
|
1341
|
+
@order = Order.find(params[:order_id])
|
|
1342
|
+
|
|
1343
|
+
# Verify order can be captured
|
|
1344
|
+
unless @order.payment_state == 'authorized'
|
|
1345
|
+
flash[:error] = "Payment not authorized"
|
|
1346
|
+
redirect_to @order and return
|
|
1347
|
+
end
|
|
1348
|
+
|
|
1349
|
+
# Capture the payment
|
|
1350
|
+
response = ZaiPayment.items.capture_payment(
|
|
1351
|
+
@order.zai_item_id,
|
|
1352
|
+
amount: params[:amount] # Optional for partial capture
|
|
1353
|
+
)
|
|
1354
|
+
|
|
1355
|
+
if response.success?
|
|
1356
|
+
@order.update(
|
|
1357
|
+
payment_state: response.data['payment_state'],
|
|
1358
|
+
captured_at: Time.current
|
|
1359
|
+
)
|
|
1360
|
+
|
|
1361
|
+
flash[:success] = "Payment captured successfully"
|
|
1362
|
+
redirect_to @order
|
|
1363
|
+
else
|
|
1364
|
+
flash[:error] = "Capture failed: #{response.error_message}"
|
|
1365
|
+
render :show
|
|
1366
|
+
end
|
|
1367
|
+
end
|
|
1368
|
+
end
|
|
1369
|
+
```
|
|
1370
|
+
|
|
1371
|
+
#### In a Background Job
|
|
1372
|
+
|
|
1373
|
+
```ruby
|
|
1374
|
+
class CapturePaymentJob < ApplicationJob
|
|
1375
|
+
queue_as :payments
|
|
1376
|
+
|
|
1377
|
+
def perform(order_id)
|
|
1378
|
+
order = Order.find(order_id)
|
|
1379
|
+
|
|
1380
|
+
# Check if order is ready to capture
|
|
1381
|
+
return unless order.ready_to_capture?
|
|
1382
|
+
|
|
1383
|
+
# Capture the payment
|
|
1384
|
+
response = ZaiPayment.items.capture_payment(order.zai_item_id)
|
|
1385
|
+
|
|
1386
|
+
if response.success?
|
|
1387
|
+
order.update(
|
|
1388
|
+
payment_state: 'captured',
|
|
1389
|
+
captured_at: Time.current
|
|
1390
|
+
)
|
|
1391
|
+
|
|
1392
|
+
# Send confirmation email
|
|
1393
|
+
OrderMailer.payment_captured(order).deliver_later
|
|
1394
|
+
|
|
1395
|
+
# Update inventory
|
|
1396
|
+
order.reduce_inventory!
|
|
1397
|
+
else
|
|
1398
|
+
# Log error and retry or alert
|
|
1399
|
+
Rails.logger.error("Capture failed for order #{order_id}: #{response.error_message}")
|
|
1400
|
+
|
|
1401
|
+
# Retry if appropriate
|
|
1402
|
+
raise "Capture failed" if response.status >= 500
|
|
1403
|
+
end
|
|
1404
|
+
end
|
|
1405
|
+
end
|
|
1406
|
+
```
|
|
1407
|
+
|
|
1408
|
+
## Make Async Payment
|
|
1409
|
+
|
|
1410
|
+
Initiate a card payment with 3D Secure 2.0 (3DS2) authentication support. This endpoint initiates the payment process and returns a `payment_token` that is required for initialising the 3DS2 web component on the client side.
|
|
1411
|
+
|
|
1412
|
+
This method is specifically designed for payments that require 3D Secure verification, providing enhanced security for card transactions.
|
|
1413
|
+
|
|
1414
|
+
### Basic Async Payment
|
|
1415
|
+
|
|
1416
|
+
```ruby
|
|
1417
|
+
# Make an async payment with just the required parameters
|
|
1418
|
+
response = items.make_payment_async(
|
|
1419
|
+
"item-123",
|
|
1420
|
+
account_id: "card_account-456" # Required
|
|
1421
|
+
)
|
|
1422
|
+
|
|
1423
|
+
if response.success?
|
|
1424
|
+
payment_id = response.data['payment_id']
|
|
1425
|
+
payment_token = response.data['payment_token']
|
|
1426
|
+
item = response.data['items']
|
|
1427
|
+
|
|
1428
|
+
puts "Payment initiated: #{payment_id}"
|
|
1429
|
+
puts "Payment token for 3DS2: #{payment_token}"
|
|
1430
|
+
puts "Item state: #{item['state']}"
|
|
1431
|
+
puts "Amount: $#{item['amount'] / 100.0}"
|
|
1432
|
+
|
|
1433
|
+
# Use the payment_token to initialise the 3DS2 web component
|
|
1434
|
+
# on the client side (JavaScript)
|
|
1435
|
+
else
|
|
1436
|
+
puts "Payment failed: #{response.error_message}"
|
|
1437
|
+
end
|
|
1438
|
+
```
|
|
1439
|
+
|
|
1440
|
+
### Async Payment with 3DS Challenge
|
|
1441
|
+
|
|
1442
|
+
To explicitly request a 3D Secure challenge:
|
|
1443
|
+
|
|
1444
|
+
```ruby
|
|
1445
|
+
response = items.make_payment_async(
|
|
1446
|
+
"item-123",
|
|
1447
|
+
account_id: "card_account-456",
|
|
1448
|
+
request_three_d_secure: "challenge"
|
|
1449
|
+
)
|
|
1450
|
+
|
|
1451
|
+
if response.success?
|
|
1452
|
+
payment_token = response.data['payment_token']
|
|
1453
|
+
payment_id = response.data['payment_id']
|
|
1454
|
+
|
|
1455
|
+
puts "Payment initiated with 3DS challenge: #{payment_id}"
|
|
1456
|
+
puts "Payment token: #{payment_token}"
|
|
1457
|
+
|
|
1458
|
+
# Send the payment_token to the client to initialise 3DS2 component
|
|
1459
|
+
# The component will display the 3DS challenge to the user
|
|
1460
|
+
else
|
|
1461
|
+
puts "Payment initiation failed: #{response.error_message}"
|
|
1462
|
+
end
|
|
1463
|
+
```
|
|
1464
|
+
|
|
1465
|
+
### Automatic 3DS Determination
|
|
1466
|
+
|
|
1467
|
+
When using the default 'automatic' mode, the system determines whether 3DS is required:
|
|
1468
|
+
|
|
1469
|
+
```ruby
|
|
1470
|
+
response = items.make_payment_async(
|
|
1471
|
+
"item-123",
|
|
1472
|
+
account_id: "card_account-456",
|
|
1473
|
+
request_three_d_secure: "automatic" # This is the default
|
|
1474
|
+
)
|
|
1475
|
+
|
|
1476
|
+
if response.success?
|
|
1477
|
+
item = response.data['items']
|
|
1478
|
+
payment_token = response.data['payment_token']
|
|
1479
|
+
|
|
1480
|
+
puts "3DS handled automatically"
|
|
1481
|
+
puts "Item state: #{item['state']}"
|
|
1482
|
+
|
|
1483
|
+
# The payment_token will be provided if 3DS is required
|
|
1484
|
+
if payment_token && !payment_token.empty?
|
|
1485
|
+
puts "3DS verification required - use token: #{payment_token}"
|
|
1486
|
+
# Send token to client for 3DS2 component initialisation
|
|
1487
|
+
else
|
|
1488
|
+
puts "3DS verification not required - payment processed"
|
|
1489
|
+
end
|
|
1490
|
+
end
|
|
1491
|
+
```
|
|
1492
|
+
|
|
1493
|
+
### Complete Rails Example with 3DS2
|
|
1494
|
+
|
|
1495
|
+
Complete example showing how to implement async payment in a Rails application:
|
|
1496
|
+
|
|
1497
|
+
```ruby
|
|
1498
|
+
# app/controllers/payments_controller.rb
|
|
1499
|
+
class PaymentsController < ApplicationController
|
|
1500
|
+
def create_async_payment
|
|
1501
|
+
items = ZaiPayment.items
|
|
1502
|
+
|
|
1503
|
+
# Step 1: Initiate async payment
|
|
1504
|
+
response = items.make_payment_async(
|
|
1505
|
+
params[:item_id],
|
|
1506
|
+
account_id: params[:account_id],
|
|
1507
|
+
request_three_d_secure: "automatic"
|
|
1508
|
+
)
|
|
1509
|
+
|
|
1510
|
+
if response.success?
|
|
1511
|
+
payment_id = response.data['payment_id']
|
|
1512
|
+
payment_token = response.data['payment_token']
|
|
1513
|
+
item_data = response.data['items']
|
|
1514
|
+
|
|
1515
|
+
# Store payment_id for tracking
|
|
1516
|
+
@payment = Payment.create!(
|
|
1517
|
+
zai_payment_id: payment_id,
|
|
1518
|
+
zai_item_id: item_data['id'],
|
|
1519
|
+
amount: item_data['amount'],
|
|
1520
|
+
state: item_data['state'],
|
|
1521
|
+
payment_token: payment_token
|
|
1522
|
+
)
|
|
1523
|
+
|
|
1524
|
+
# Return payment_token to client for 3DS2 initialisation
|
|
1525
|
+
render json: {
|
|
1526
|
+
success: true,
|
|
1527
|
+
payment_id: payment_id,
|
|
1528
|
+
payment_token: payment_token,
|
|
1529
|
+
requires_3ds: payment_token.present?
|
|
1530
|
+
}
|
|
1531
|
+
else
|
|
1532
|
+
render json: {
|
|
1533
|
+
success: false,
|
|
1534
|
+
error: response.error_message
|
|
1535
|
+
}, status: :unprocessable_entity
|
|
1536
|
+
end
|
|
1537
|
+
end
|
|
1538
|
+
end
|
|
1539
|
+
```
|
|
1540
|
+
|
|
1541
|
+
```javascript
|
|
1542
|
+
// app/javascript/payments/three_d_secure.js
|
|
1543
|
+
// Client-side 3DS2 component initialisation
|
|
1544
|
+
|
|
1545
|
+
async function initiateAsyncPayment(itemId, accountId) {
|
|
1546
|
+
try {
|
|
1547
|
+
// Call your Rails backend to initiate the payment
|
|
1548
|
+
const response = await fetch('/payments/create_async', {
|
|
1549
|
+
method: 'POST',
|
|
1550
|
+
headers: {
|
|
1551
|
+
'Content-Type': 'application/json',
|
|
1552
|
+
'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
|
|
1553
|
+
},
|
|
1554
|
+
body: JSON.stringify({
|
|
1555
|
+
item_id: itemId,
|
|
1556
|
+
account_id: accountId
|
|
1557
|
+
})
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
const data = await response.json();
|
|
1561
|
+
|
|
1562
|
+
if (data.success && data.requires_3ds) {
|
|
1563
|
+
// Initialize 3DS2 component with the payment_token
|
|
1564
|
+
await initialize3DS2Component(data.payment_token);
|
|
1565
|
+
} else if (data.success) {
|
|
1566
|
+
// Payment completed without 3DS
|
|
1567
|
+
window.location.href = '/payments/success';
|
|
1568
|
+
} else {
|
|
1569
|
+
alert('Payment failed: ' + data.error);
|
|
1570
|
+
}
|
|
1571
|
+
} catch (error) {
|
|
1572
|
+
console.error('Payment error:', error);
|
|
1573
|
+
alert('Payment failed. Please try again.');
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
async function initialize3DS2Component(paymentToken) {
|
|
1578
|
+
// Use Zai's 3DS2 SDK to initialize the component
|
|
1579
|
+
// This is a simplified example - refer to Zai documentation for actual implementation
|
|
1580
|
+
|
|
1581
|
+
const threeDSComponent = new ZaiThreeDSecure({
|
|
1582
|
+
paymentToken: paymentToken,
|
|
1583
|
+
onSuccess: function(result) {
|
|
1584
|
+
console.log('3DS verification successful', result);
|
|
1585
|
+
window.location.href = '/payments/success';
|
|
1586
|
+
},
|
|
1587
|
+
onError: function(error) {
|
|
1588
|
+
console.error('3DS verification failed', error);
|
|
1589
|
+
alert('Payment verification failed. Please try again.');
|
|
1590
|
+
}
|
|
1591
|
+
});
|
|
1592
|
+
|
|
1593
|
+
threeDSComponent.mount('#three-ds-container');
|
|
1594
|
+
}
|
|
1595
|
+
```
|
|
1596
|
+
|
|
1597
|
+
### Error Handling for Async Payments
|
|
1598
|
+
|
|
1599
|
+
```ruby
|
|
1600
|
+
begin
|
|
1601
|
+
response = items.make_payment_async(
|
|
1602
|
+
"item-123",
|
|
1603
|
+
account_id: "card_account-456"
|
|
1604
|
+
)
|
|
1605
|
+
|
|
1606
|
+
if response.success?
|
|
1607
|
+
payment_id = response.data['payment_id']
|
|
1608
|
+
payment_token = response.data['payment_token']
|
|
1609
|
+
|
|
1610
|
+
puts "✓ Payment initiated: #{payment_id}"
|
|
1611
|
+
|
|
1612
|
+
if payment_token
|
|
1613
|
+
puts " 3DS verification required"
|
|
1614
|
+
puts " Token: #{payment_token}"
|
|
1615
|
+
else
|
|
1616
|
+
puts " 3DS not required, payment processing"
|
|
1617
|
+
end
|
|
1618
|
+
else
|
|
1619
|
+
# Handle API errors
|
|
1620
|
+
case response.status
|
|
1621
|
+
when 422
|
|
1622
|
+
puts "Validation error: #{response.error_message}"
|
|
1623
|
+
# Common: Invalid account, insufficient funds
|
|
1624
|
+
when 404
|
|
1625
|
+
puts "Item or account not found"
|
|
1626
|
+
when 401
|
|
1627
|
+
puts "Authentication failed"
|
|
1628
|
+
else
|
|
1629
|
+
puts "Payment error: #{response.error_message}"
|
|
1630
|
+
end
|
|
1631
|
+
end
|
|
1632
|
+
rescue ZaiPayment::Errors::ValidationError => e
|
|
1633
|
+
puts "Validation error: #{e.message}"
|
|
1634
|
+
# Example: "account_id is required and cannot be blank"
|
|
1635
|
+
rescue ZaiPayment::Errors::NotFoundError => e
|
|
1636
|
+
puts "Resource not found: #{e.message}"
|
|
1637
|
+
rescue ZaiPayment::Errors::BadRequestError => e
|
|
1638
|
+
puts "Bad request: #{e.message}"
|
|
1639
|
+
rescue ZaiPayment::Errors::ApiError => e
|
|
1640
|
+
puts "API error: #{e.message}"
|
|
1641
|
+
end
|
|
1642
|
+
```
|
|
1643
|
+
|
|
1644
|
+
### Full E-commerce Flow with Async Payment
|
|
1645
|
+
|
|
1646
|
+
Complete example showing item creation through async payment with 3DS:
|
|
1647
|
+
|
|
1648
|
+
```ruby
|
|
1649
|
+
require 'zai_payment'
|
|
1650
|
+
|
|
1651
|
+
# Configure
|
|
1652
|
+
ZaiPayment.configure do |config|
|
|
1653
|
+
config.client_id = ENV['ZAI_CLIENT_ID']
|
|
1654
|
+
config.client_secret = ENV['ZAI_CLIENT_SECRET']
|
|
1655
|
+
config.scope = ENV['ZAI_SCOPE']
|
|
1656
|
+
config.environment = :prelive
|
|
1657
|
+
end
|
|
1658
|
+
|
|
1659
|
+
items = ZaiPayment.items
|
|
1660
|
+
|
|
1661
|
+
# Step 1: Create an item
|
|
1662
|
+
create_response = items.create(
|
|
1663
|
+
name: "Premium Product with 3DS",
|
|
1664
|
+
amount: 25000, # $250.00
|
|
1665
|
+
payment_type: 2,
|
|
1666
|
+
buyer_id: "buyer-123",
|
|
1667
|
+
seller_id: "seller-456",
|
|
1668
|
+
description: "High-value product requiring 3DS verification"
|
|
1669
|
+
)
|
|
1670
|
+
|
|
1671
|
+
if create_response.success?
|
|
1672
|
+
item_id = create_response.data['id']
|
|
1673
|
+
puts "✓ Item created: #{item_id}"
|
|
1674
|
+
|
|
1675
|
+
# Step 2: Initiate async payment with 3DS
|
|
1676
|
+
payment_response = items.make_payment_async(
|
|
1677
|
+
item_id,
|
|
1678
|
+
account_id: "card_account-789",
|
|
1679
|
+
request_three_d_secure: "automatic"
|
|
1680
|
+
)
|
|
1681
|
+
|
|
1682
|
+
if payment_response.success?
|
|
1683
|
+
payment_id = payment_response.data['payment_id']
|
|
1684
|
+
payment_token = payment_response.data['payment_token']
|
|
1685
|
+
item_data = payment_response.data['items']
|
|
1686
|
+
|
|
1687
|
+
puts "✓ Async payment initiated: #{payment_id}"
|
|
1688
|
+
puts " Item state: #{item_data['state']}"
|
|
1689
|
+
puts " Amount: $#{item_data['amount'] / 100.0}"
|
|
1690
|
+
|
|
1691
|
+
if payment_token && !payment_token.empty?
|
|
1692
|
+
puts " 3DS verification required"
|
|
1693
|
+
puts " Payment token: #{payment_token}"
|
|
1694
|
+
puts " → Send this token to client for 3DS2 component"
|
|
1695
|
+
|
|
1696
|
+
# In a real application:
|
|
1697
|
+
# 1. Send payment_token to the client
|
|
1698
|
+
# 2. Client initializes 3DS2 component
|
|
1699
|
+
# 3. User completes 3DS challenge
|
|
1700
|
+
# 4. Listen for webhook to confirm payment status
|
|
1701
|
+
else
|
|
1702
|
+
puts " 3DS not required - payment processing automatically"
|
|
1703
|
+
|
|
1704
|
+
# Step 3: Monitor payment via webhook or polling
|
|
1705
|
+
# In production, use webhooks for real-time updates
|
|
1706
|
+
end
|
|
1707
|
+
else
|
|
1708
|
+
puts "✗ Payment failed: #{payment_response.error_message}"
|
|
1709
|
+
end
|
|
1710
|
+
else
|
|
1711
|
+
puts "✗ Item creation failed: #{create_response.error_message}"
|
|
1712
|
+
end
|
|
1713
|
+
```
|
|
1714
|
+
|
|
1715
|
+
### Understanding the Response
|
|
1716
|
+
|
|
1717
|
+
The `make_payment_async` response includes several important fields:
|
|
1718
|
+
|
|
1719
|
+
```ruby
|
|
1720
|
+
response = items.make_payment_async("item-123", account_id: "account-456")
|
|
1721
|
+
|
|
1722
|
+
if response.success?
|
|
1723
|
+
# Top-level payment information
|
|
1724
|
+
payment_id = response.data['payment_id'] # Unique payment identifier
|
|
1725
|
+
account_id = response.data['account_id'] # Account used for payment
|
|
1726
|
+
payment_token = response.data['payment_token'] # Token for 3DS2 initialization
|
|
1727
|
+
|
|
1728
|
+
# Item details
|
|
1729
|
+
item = response.data['items']
|
|
1730
|
+
item_id = item['id'] # Item/transaction ID
|
|
1731
|
+
state = item['state'] # Current state (e.g., 'pending')
|
|
1732
|
+
amount = item['amount'] # Amount in cents
|
|
1733
|
+
currency = item['currency'] # Currency code (e.g., 'AUD')
|
|
1734
|
+
payment_method = item['payment_method'] # Payment method used
|
|
1735
|
+
|
|
1736
|
+
# Related resources
|
|
1737
|
+
related = item['related']
|
|
1738
|
+
buyer_id = related['buyers'] # Buyer user ID
|
|
1739
|
+
seller_id = related['sellers'] # Seller user ID
|
|
1740
|
+
|
|
1741
|
+
# Useful links
|
|
1742
|
+
links = item['links']
|
|
1743
|
+
status_url = links['status'] # URL to check status
|
|
1744
|
+
transactions_url = links['transactions'] # URL for transactions
|
|
1745
|
+
end
|
|
1746
|
+
```
|
|
1747
|
+
|
|
1748
|
+
### 3DS Preference Options
|
|
1749
|
+
|
|
1750
|
+
The `request_three_d_secure` parameter accepts three values:
|
|
1751
|
+
|
|
1752
|
+
| Value | Description | Use Case |
|
|
1753
|
+
|-------|-------------|----------|
|
|
1754
|
+
| `'automatic'` | System determines if 3DS is required (default) | Recommended for most cases - balances security and UX |
|
|
1755
|
+
| `'challenge'` | Always request 3DS challenge | High-value transactions, compliance requirements |
|
|
1756
|
+
| `'any'` | Request 3DS regardless of challenge flow | Maximum security, regulatory requirements |
|
|
1757
|
+
|
|
1758
|
+
```ruby
|
|
1759
|
+
# Automatic (recommended)
|
|
1760
|
+
items.make_payment_async("item-123",
|
|
1761
|
+
account_id: "account-456",
|
|
1762
|
+
request_three_d_secure: "automatic"
|
|
1763
|
+
)
|
|
1764
|
+
|
|
1765
|
+
# Force challenge for high-value transactions
|
|
1766
|
+
items.make_payment_async("item-123",
|
|
1767
|
+
account_id: "account-456",
|
|
1768
|
+
request_three_d_secure: "challenge"
|
|
1769
|
+
)
|
|
1770
|
+
|
|
1771
|
+
# Maximum security
|
|
1772
|
+
items.make_payment_async("item-123",
|
|
1773
|
+
account_id: "account-456",
|
|
1774
|
+
request_three_d_secure: "any"
|
|
1775
|
+
)
|
|
1776
|
+
```
|
|
1777
|
+
|
|
1778
|
+
### Important Notes
|
|
1779
|
+
|
|
1780
|
+
1. **Payment Token**: The `payment_token` must be sent to the client to initialize the 3DS2 web component
|
|
1781
|
+
2. **Webhooks**: After 3DS authentication completes, listen for webhooks to get the final payment status
|
|
1782
|
+
3. **Timeout**: 3DS challenges typically expire after 10-15 minutes of inactivity
|
|
1783
|
+
4. **Failed Authentication**: If 3DS verification fails, the payment will be automatically cancelled
|
|
1784
|
+
5. **Testing**: Use Zai test cards to simulate different 3DS scenarios in prelive environment
|
|
1785
|
+
|
|
1786
|
+
### Webhook Integration
|
|
1787
|
+
|
|
1788
|
+
After initiating an async payment, monitor webhooks for status updates:
|
|
1789
|
+
|
|
1790
|
+
```ruby
|
|
1791
|
+
# In your webhook handler (e.g., Rails controller)
|
|
1792
|
+
def handle_payment_webhook
|
|
1793
|
+
# Verify webhook signature first
|
|
1794
|
+
webhooks = ZaiPayment.webhooks
|
|
1795
|
+
|
|
1796
|
+
begin
|
|
1797
|
+
if webhooks.verify_signature(request.body.read, request.headers['X-Signature'])
|
|
1798
|
+
payload = JSON.parse(request.body.read)
|
|
1799
|
+
|
|
1800
|
+
case payload['type']
|
|
1801
|
+
when 'transaction'
|
|
1802
|
+
if payload['status'] == 'successful'
|
|
1803
|
+
payment_id = payload['payment_id']
|
|
1804
|
+
item_id = payload['item_id']
|
|
1805
|
+
|
|
1806
|
+
# Update your database
|
|
1807
|
+
payment = Payment.find_by(zai_payment_id: payment_id)
|
|
1808
|
+
payment.update!(status: 'completed', completed_at: Time.current)
|
|
1809
|
+
|
|
1810
|
+
# Send confirmation email, fulfill order, etc.
|
|
1811
|
+
OrderFulfillmentJob.perform_later(payment.order_id)
|
|
1812
|
+
|
|
1813
|
+
puts "✓ Payment completed: #{payment_id}"
|
|
1814
|
+
elsif payload['status'] == 'failed'
|
|
1815
|
+
# Handle failed payment
|
|
1816
|
+
payment = Payment.find_by(zai_payment_id: payload['payment_id'])
|
|
1817
|
+
payment.update!(status: 'failed', failure_reason: payload['message'])
|
|
1818
|
+
|
|
1819
|
+
# Notify customer
|
|
1820
|
+
PaymentFailureMailer.notify(payment.user).deliver_later
|
|
1821
|
+
end
|
|
1822
|
+
end
|
|
1823
|
+
|
|
1824
|
+
head :ok
|
|
1825
|
+
else
|
|
1826
|
+
head :unauthorized
|
|
1827
|
+
end
|
|
1828
|
+
rescue => e
|
|
1829
|
+
Rails.logger.error "Webhook error: #{e.message}"
|
|
1830
|
+
head :internal_server_error
|
|
1831
|
+
end
|
|
1832
|
+
end
|
|
1833
|
+
```
|
|
1834
|
+
|
|
1835
|
+
## Cancel Item
|
|
1836
|
+
|
|
1837
|
+
Cancel an existing item/payment. This operation is typically used to cancel a pending payment before it has been processed or completed.
|
|
1838
|
+
|
|
1839
|
+
### Basic Cancel
|
|
1840
|
+
|
|
1841
|
+
```ruby
|
|
1842
|
+
response = items.cancel("item-123")
|
|
1843
|
+
|
|
1844
|
+
if response.success?
|
|
1845
|
+
item = response.data
|
|
1846
|
+
puts "Item cancelled successfully"
|
|
1847
|
+
puts "Item ID: #{item['id']}"
|
|
1848
|
+
puts "State: #{item['state']}"
|
|
1849
|
+
puts "Payment State: #{item['payment_state']}"
|
|
1850
|
+
else
|
|
1851
|
+
puts "Cancel failed: #{response.error_message}"
|
|
1852
|
+
end
|
|
1853
|
+
```
|
|
1854
|
+
|
|
1855
|
+
### Cancel with Error Handling
|
|
1856
|
+
|
|
1857
|
+
```ruby
|
|
1858
|
+
begin
|
|
1859
|
+
response = items.cancel("item-123")
|
|
1860
|
+
|
|
1861
|
+
if response.success?
|
|
1862
|
+
item = response.data
|
|
1863
|
+
puts "✓ Item cancelled: #{item['id']}"
|
|
1864
|
+
puts " State: #{item['state']}"
|
|
1865
|
+
puts " Payment State: #{item['payment_state']}"
|
|
1866
|
+
else
|
|
1867
|
+
# Handle API errors
|
|
1868
|
+
case response.status
|
|
1869
|
+
when 422
|
|
1870
|
+
puts "Cannot cancel: #{response.error_message}"
|
|
1871
|
+
# Common: Item already completed or in a state that can't be cancelled
|
|
1872
|
+
when 404
|
|
1873
|
+
puts "Item not found"
|
|
1874
|
+
when 401
|
|
1875
|
+
puts "Authentication failed"
|
|
1876
|
+
else
|
|
1877
|
+
puts "Cancellation error: #{response.error_message}"
|
|
1878
|
+
end
|
|
1879
|
+
end
|
|
1880
|
+
rescue ZaiPayment::Errors::ValidationError => e
|
|
1881
|
+
puts "Validation error: #{e.message}"
|
|
1882
|
+
rescue ZaiPayment::Errors::NotFoundError => e
|
|
1883
|
+
puts "Item not found: #{e.message}"
|
|
1884
|
+
rescue ZaiPayment::Errors::ApiError => e
|
|
1885
|
+
puts "API error: #{e.message}"
|
|
1886
|
+
end
|
|
1887
|
+
```
|
|
1888
|
+
|
|
1889
|
+
### Cancel with Status Check
|
|
1890
|
+
|
|
1891
|
+
Check item status before attempting to cancel:
|
|
1892
|
+
|
|
1893
|
+
```ruby
|
|
1894
|
+
# Check current status
|
|
1895
|
+
status_response = items.show_status("item-123")
|
|
1896
|
+
|
|
1897
|
+
if status_response.success?
|
|
1898
|
+
status = status_response.data
|
|
1899
|
+
current_state = status['state']
|
|
1900
|
+
payment_state = status['payment_state']
|
|
1901
|
+
|
|
1902
|
+
puts "Current state: #{current_state}"
|
|
1903
|
+
puts "Payment state: #{payment_state}"
|
|
1904
|
+
|
|
1905
|
+
# Only cancel if in a cancellable state
|
|
1906
|
+
if ['pending', 'payment_pending'].include?(current_state)
|
|
1907
|
+
cancel_response = items.cancel("item-123")
|
|
1908
|
+
|
|
1909
|
+
if cancel_response.success?
|
|
1910
|
+
puts "✓ Item cancelled successfully"
|
|
1911
|
+
else
|
|
1912
|
+
puts "✗ Cancel failed: #{cancel_response.error_message}"
|
|
1913
|
+
end
|
|
1914
|
+
else
|
|
1915
|
+
puts "Item cannot be cancelled - current state: #{current_state}"
|
|
1916
|
+
end
|
|
1917
|
+
end
|
|
1918
|
+
```
|
|
1919
|
+
|
|
1920
|
+
### Real-World Cancel Flow Example
|
|
1921
|
+
|
|
1922
|
+
Complete example showing item creation, payment, and cancellation:
|
|
1923
|
+
|
|
1924
|
+
```ruby
|
|
1925
|
+
require 'zai_payment'
|
|
1926
|
+
|
|
1927
|
+
# Configure
|
|
1928
|
+
ZaiPayment.configure do |config|
|
|
1929
|
+
config.client_id = ENV['ZAI_CLIENT_ID']
|
|
1930
|
+
config.client_secret = ENV['ZAI_CLIENT_SECRET']
|
|
1931
|
+
config.scope = ENV['ZAI_SCOPE']
|
|
1932
|
+
config.environment = :prelive
|
|
1933
|
+
end
|
|
1934
|
+
|
|
1935
|
+
items = ZaiPayment.items
|
|
1936
|
+
|
|
1937
|
+
# Step 1: Create an item
|
|
1938
|
+
create_response = items.create(
|
|
1939
|
+
name: "Product Purchase",
|
|
1940
|
+
amount: 10000, # $100.00
|
|
1941
|
+
payment_type: 2,
|
|
1942
|
+
buyer_id: "buyer-123",
|
|
1943
|
+
seller_id: "seller-456",
|
|
1944
|
+
description: "Purchase of premium widget"
|
|
1945
|
+
)
|
|
1946
|
+
|
|
1947
|
+
if create_response.success?
|
|
1948
|
+
item_id = create_response.data['id']
|
|
1949
|
+
puts "✓ Item created: #{item_id}"
|
|
1950
|
+
|
|
1951
|
+
# Step 2: Customer decides to cancel before payment
|
|
1952
|
+
puts "\nCustomer requested cancellation..."
|
|
1953
|
+
|
|
1954
|
+
# Step 3: Check if item can be cancelled
|
|
1955
|
+
status_response = items.show_status(item_id)
|
|
1956
|
+
|
|
1957
|
+
if status_response.success?
|
|
1958
|
+
current_state = status_response.data['state']
|
|
1959
|
+
puts "Current item state: #{current_state}"
|
|
1960
|
+
|
|
1961
|
+
# Step 4: Cancel the item
|
|
1962
|
+
if ['pending', 'payment_pending'].include?(current_state)
|
|
1963
|
+
cancel_response = items.cancel(item_id)
|
|
1964
|
+
|
|
1965
|
+
if cancel_response.success?
|
|
1966
|
+
cancelled_item = cancel_response.data
|
|
1967
|
+
puts "✓ Item cancelled successfully"
|
|
1968
|
+
puts " Final state: #{cancelled_item['state']}"
|
|
1969
|
+
puts " Payment state: #{cancelled_item['payment_state']}"
|
|
1970
|
+
|
|
1971
|
+
# Notify customer
|
|
1972
|
+
# CustomerMailer.order_cancelled(customer_email, item_id).deliver_later
|
|
1973
|
+
else
|
|
1974
|
+
puts "✗ Cancellation failed: #{cancel_response.error_message}"
|
|
1975
|
+
end
|
|
1976
|
+
else
|
|
1977
|
+
puts "✗ Item cannot be cancelled - current state: #{current_state}"
|
|
1978
|
+
end
|
|
1979
|
+
end
|
|
1980
|
+
else
|
|
1981
|
+
puts "✗ Item creation failed: #{create_response.error_message}"
|
|
1982
|
+
end
|
|
1983
|
+
```
|
|
1984
|
+
|
|
1985
|
+
### Cancel States and Conditions
|
|
1986
|
+
|
|
1987
|
+
Items can typically be cancelled when in these states:
|
|
1988
|
+
|
|
1989
|
+
| State | Can Cancel? | Description |
|
|
1990
|
+
|-------|-------------|-------------|
|
|
1991
|
+
| `pending` | ✓ Yes | Item created but no payment initiated |
|
|
1992
|
+
| `payment_pending` | ✓ Yes | Payment initiated but not yet processed |
|
|
1993
|
+
| `payment_processing` | Maybe | Depends on payment processor |
|
|
1994
|
+
| `completed` | ✗ No | Payment completed, must refund instead |
|
|
1995
|
+
| `payment_held` | Maybe | May require admin approval |
|
|
1996
|
+
| `cancelled` | ✗ No | Already cancelled |
|
|
1997
|
+
| `refunded` | ✗ No | Already refunded |
|
|
1998
|
+
|
|
1999
|
+
**Note:** If an item is already completed or funds have been disbursed, you cannot cancel it. In those cases, you may need to process a refund instead.
|
|
2000
|
+
|
|
2001
|
+
## Refund Item
|
|
2002
|
+
|
|
2003
|
+
Process a refund for a completed payment. This operation returns funds to the buyer and is typically used for customer returns, disputes, or service issues.
|
|
2004
|
+
|
|
2005
|
+
### Basic Refund
|
|
2006
|
+
|
|
2007
|
+
```ruby
|
|
2008
|
+
response = items.refund("item-123")
|
|
2009
|
+
|
|
2010
|
+
if response.success?
|
|
2011
|
+
item = response.data
|
|
2012
|
+
puts "Item refunded successfully"
|
|
2013
|
+
puts "Item ID: #{item['id']}"
|
|
2014
|
+
puts "State: #{item['state']}"
|
|
2015
|
+
puts "Payment State: #{item['payment_state']}"
|
|
2016
|
+
else
|
|
2017
|
+
puts "Refund failed: #{response.error_message}"
|
|
2018
|
+
end
|
|
2019
|
+
```
|
|
2020
|
+
|
|
2021
|
+
### Partial Refund
|
|
2022
|
+
|
|
2023
|
+
Process a partial refund for a specific amount:
|
|
2024
|
+
|
|
2025
|
+
```ruby
|
|
2026
|
+
# Refund $50.00 out of the original $100.00 transaction
|
|
2027
|
+
response = items.refund(
|
|
2028
|
+
"item-123",
|
|
2029
|
+
refund_amount: 5000 # Amount in cents
|
|
2030
|
+
)
|
|
2031
|
+
|
|
2032
|
+
if response.success?
|
|
2033
|
+
item = response.data
|
|
2034
|
+
puts "Partial refund processed"
|
|
2035
|
+
puts "Refund Amount: $50.00"
|
|
2036
|
+
puts "State: #{item['state']}"
|
|
2037
|
+
else
|
|
2038
|
+
puts "Partial refund failed: #{response.error_message}"
|
|
2039
|
+
end
|
|
2040
|
+
```
|
|
2041
|
+
|
|
2042
|
+
### Refund with Message
|
|
2043
|
+
|
|
2044
|
+
Provide a reason for the refund:
|
|
2045
|
+
|
|
2046
|
+
```ruby
|
|
2047
|
+
response = items.refund(
|
|
2048
|
+
"item-123",
|
|
2049
|
+
refund_message: "Customer returned defective product"
|
|
2050
|
+
)
|
|
2051
|
+
|
|
2052
|
+
if response.success?
|
|
2053
|
+
puts "Refund processed with message"
|
|
2054
|
+
else
|
|
2055
|
+
puts "Refund failed: #{response.error_message}"
|
|
2056
|
+
end
|
|
2057
|
+
```
|
|
2058
|
+
|
|
2059
|
+
### Refund to Specific Account
|
|
2060
|
+
|
|
2061
|
+
Specify which account to refund to:
|
|
2062
|
+
|
|
2063
|
+
```ruby
|
|
2064
|
+
response = items.refund(
|
|
2065
|
+
"item-123",
|
|
2066
|
+
account_id: "account_789"
|
|
2067
|
+
)
|
|
2068
|
+
|
|
2069
|
+
if response.success?
|
|
2070
|
+
puts "Refund sent to specified account"
|
|
2071
|
+
else
|
|
2072
|
+
puts "Refund failed: #{response.error_message}"
|
|
2073
|
+
end
|
|
2074
|
+
```
|
|
2075
|
+
|
|
2076
|
+
### Refund with All Parameters
|
|
2077
|
+
|
|
2078
|
+
Process a partial refund with a message and specific account:
|
|
2079
|
+
|
|
2080
|
+
```ruby
|
|
2081
|
+
response = items.refund(
|
|
2082
|
+
"item-123",
|
|
2083
|
+
refund_amount: 5000,
|
|
2084
|
+
refund_message: "Partial refund for shipping damage",
|
|
2085
|
+
account_id: "account_789"
|
|
2086
|
+
)
|
|
2087
|
+
|
|
2088
|
+
if response.success?
|
|
2089
|
+
item = response.data
|
|
2090
|
+
puts "✓ Partial refund processed"
|
|
2091
|
+
puts " Amount: $50.00"
|
|
2092
|
+
puts " Message: Partial refund for shipping damage"
|
|
2093
|
+
puts " State: #{item['state']}"
|
|
2094
|
+
puts " Payment State: #{item['payment_state']}"
|
|
2095
|
+
else
|
|
2096
|
+
puts "✗ Refund failed: #{response.error_message}"
|
|
2097
|
+
end
|
|
2098
|
+
```
|
|
2099
|
+
|
|
2100
|
+
### Refund with Error Handling
|
|
2101
|
+
|
|
2102
|
+
```ruby
|
|
2103
|
+
begin
|
|
2104
|
+
response = items.refund("item-123")
|
|
2105
|
+
|
|
2106
|
+
if response.success?
|
|
2107
|
+
item = response.data
|
|
2108
|
+
puts "✓ Item refunded: #{item['id']}"
|
|
2109
|
+
puts " State: #{item['state']}"
|
|
2110
|
+
puts " Payment State: #{item['payment_state']}"
|
|
2111
|
+
else
|
|
2112
|
+
# Handle API errors
|
|
2113
|
+
case response.status
|
|
2114
|
+
when 422
|
|
2115
|
+
puts "Cannot refund: #{response.error_message}"
|
|
2116
|
+
# Common: Item already refunded, not in refundable state, etc.
|
|
2117
|
+
when 404
|
|
2118
|
+
puts "Item not found"
|
|
2119
|
+
when 401
|
|
2120
|
+
puts "Authentication failed"
|
|
2121
|
+
else
|
|
2122
|
+
puts "Refund error: #{response.error_message}"
|
|
2123
|
+
end
|
|
2124
|
+
end
|
|
2125
|
+
rescue ZaiPayment::Errors::ValidationError => e
|
|
2126
|
+
puts "Validation error: #{e.message}"
|
|
2127
|
+
rescue ZaiPayment::Errors::NotFoundError => e
|
|
2128
|
+
puts "Item not found: #{e.message}"
|
|
2129
|
+
rescue ZaiPayment::Errors::ApiError => e
|
|
2130
|
+
puts "API error: #{e.message}"
|
|
2131
|
+
end
|
|
2132
|
+
```
|
|
2133
|
+
|
|
2134
|
+
### Refund with Status Check
|
|
2135
|
+
|
|
2136
|
+
Check item status before attempting to refund:
|
|
2137
|
+
|
|
2138
|
+
```ruby
|
|
2139
|
+
# Check current status
|
|
2140
|
+
status_response = items.show_status("item-123")
|
|
2141
|
+
|
|
2142
|
+
if status_response.success?
|
|
2143
|
+
status = status_response.data
|
|
2144
|
+
current_state = status['state']
|
|
2145
|
+
payment_state = status['payment_state']
|
|
2146
|
+
|
|
2147
|
+
puts "Current state: #{current_state}"
|
|
2148
|
+
puts "Payment state: #{payment_state}"
|
|
2149
|
+
|
|
2150
|
+
# Only refund if in a refundable state
|
|
2151
|
+
if ['completed', 'payment_deposited', 'work_completed'].include?(payment_state)
|
|
2152
|
+
refund_response = items.refund("item-123")
|
|
2153
|
+
|
|
2154
|
+
if refund_response.success?
|
|
2155
|
+
puts "✓ Item refunded successfully"
|
|
2156
|
+
else
|
|
2157
|
+
puts "✗ Refund failed: #{refund_response.error_message}"
|
|
2158
|
+
end
|
|
2159
|
+
else
|
|
2160
|
+
puts "Item cannot be refunded - payment state: #{payment_state}"
|
|
2161
|
+
end
|
|
2162
|
+
end
|
|
2163
|
+
```
|
|
2164
|
+
|
|
2165
|
+
### Real-World Refund Flow Example
|
|
2166
|
+
|
|
2167
|
+
Complete example showing item creation, payment, and refund:
|
|
2168
|
+
|
|
2169
|
+
```ruby
|
|
2170
|
+
require 'zai_payment'
|
|
2171
|
+
|
|
2172
|
+
# Configure
|
|
2173
|
+
ZaiPayment.configure do |config|
|
|
2174
|
+
config.client_id = ENV['ZAI_CLIENT_ID']
|
|
2175
|
+
config.client_secret = ENV['ZAI_CLIENT_SECRET']
|
|
2176
|
+
config.scope = ENV['ZAI_SCOPE']
|
|
2177
|
+
config.environment = :prelive
|
|
2178
|
+
end
|
|
2179
|
+
|
|
2180
|
+
items = ZaiPayment.items
|
|
2181
|
+
|
|
2182
|
+
# Step 1: Create an item
|
|
2183
|
+
create_response = items.create(
|
|
2184
|
+
name: "Product Purchase",
|
|
2185
|
+
amount: 10000, # $100.00
|
|
2186
|
+
payment_type: 2,
|
|
2187
|
+
buyer_id: "buyer-123",
|
|
2188
|
+
seller_id: "seller-456",
|
|
2189
|
+
description: "Purchase of premium widget"
|
|
2190
|
+
)
|
|
2191
|
+
|
|
2192
|
+
if create_response.success?
|
|
2193
|
+
item_id = create_response.data['id']
|
|
2194
|
+
puts "✓ Item created: #{item_id}"
|
|
2195
|
+
|
|
2196
|
+
# Step 2: Make the payment
|
|
2197
|
+
payment_response = items.make_payment(
|
|
2198
|
+
item_id,
|
|
2199
|
+
account_id: "card_account-789"
|
|
2200
|
+
)
|
|
2201
|
+
|
|
2202
|
+
if payment_response.success?
|
|
2203
|
+
puts "✓ Payment processed"
|
|
2204
|
+
|
|
2205
|
+
# Step 3: Customer requests refund
|
|
2206
|
+
puts "\nCustomer requested refund..."
|
|
2207
|
+
|
|
2208
|
+
# Wait for payment to complete
|
|
2209
|
+
sleep 2
|
|
2210
|
+
|
|
2211
|
+
# Step 4: Check if item can be refunded
|
|
2212
|
+
status_response = items.show_status(item_id)
|
|
2213
|
+
|
|
2214
|
+
if status_response.success?
|
|
2215
|
+
payment_state = status_response.data['payment_state']
|
|
2216
|
+
puts "Current payment state: #{payment_state}"
|
|
2217
|
+
|
|
2218
|
+
# Step 5: Process the refund
|
|
2219
|
+
if ['completed', 'payment_deposited'].include?(payment_state)
|
|
2220
|
+
refund_response = items.refund(
|
|
2221
|
+
item_id,
|
|
2222
|
+
refund_message: "Customer return - changed mind"
|
|
2223
|
+
)
|
|
2224
|
+
|
|
2225
|
+
if refund_response.success?
|
|
2226
|
+
refunded_item = refund_response.data
|
|
2227
|
+
puts "✓ Refund processed successfully"
|
|
2228
|
+
puts " Final state: #{refunded_item['state']}"
|
|
2229
|
+
puts " Payment state: #{refunded_item['payment_state']}"
|
|
2230
|
+
|
|
2231
|
+
# Notify customer
|
|
2232
|
+
# CustomerMailer.refund_processed(customer_email, item_id).deliver_later
|
|
2233
|
+
else
|
|
2234
|
+
puts "✗ Refund failed: #{refund_response.error_message}"
|
|
2235
|
+
end
|
|
2236
|
+
else
|
|
2237
|
+
puts "✗ Item cannot be refunded - payment state: #{payment_state}"
|
|
2238
|
+
end
|
|
2239
|
+
end
|
|
2240
|
+
else
|
|
2241
|
+
puts "✗ Payment failed: #{payment_response.error_message}"
|
|
2242
|
+
end
|
|
2243
|
+
else
|
|
2244
|
+
puts "✗ Item creation failed: #{create_response.error_message}"
|
|
2245
|
+
end
|
|
2246
|
+
```
|
|
2247
|
+
|
|
2248
|
+
### Refund States and Conditions
|
|
2249
|
+
|
|
2250
|
+
Items can typically be refunded when in these states:
|
|
2251
|
+
|
|
2252
|
+
| State | Can Refund? | Description |
|
|
2253
|
+
|-------|-------------|-------------|
|
|
2254
|
+
| `pending` | ✗ No | Item not yet paid, cancel instead |
|
|
2255
|
+
| `payment_pending` | ✗ No | Payment not completed, cancel instead |
|
|
2256
|
+
| `completed` | ✓ Yes | Payment completed successfully |
|
|
2257
|
+
| `payment_deposited` | ✓ Yes | Payment received and deposited |
|
|
2258
|
+
| `work_completed` | ✓ Yes | Work completed, funds can be refunded |
|
|
2259
|
+
| `cancelled` | ✗ No | Already cancelled |
|
|
2260
|
+
| `refunded` | ✗ No | Already refunded |
|
|
2261
|
+
| `payment_held` | Maybe | May require admin approval |
|
|
2262
|
+
|
|
2263
|
+
**Note:** Full refunds return the entire item amount. Partial refunds return a specified amount less than the total. Multiple partial refunds may be possible depending on your Zai configuration.
|
|
2264
|
+
|
|
2265
|
+
### Integration with Rails - Refunds
|
|
2266
|
+
|
|
2267
|
+
#### In a Controller
|
|
2268
|
+
|
|
2269
|
+
```ruby
|
|
2270
|
+
class RefundsController < ApplicationController
|
|
2271
|
+
def create
|
|
2272
|
+
@order = Order.find(params[:order_id])
|
|
2273
|
+
|
|
2274
|
+
# Ensure order belongs to current user or is admin
|
|
2275
|
+
unless @order.user == current_user || current_user.admin?
|
|
2276
|
+
redirect_to root_path, alert: 'Unauthorized'
|
|
2277
|
+
return
|
|
2278
|
+
end
|
|
2279
|
+
|
|
2280
|
+
# Process refund in Zai
|
|
2281
|
+
response = ZaiPayment.items.refund(
|
|
2282
|
+
@order.zai_item_id,
|
|
2283
|
+
refund_amount: params[:refund_amount]&.to_i,
|
|
2284
|
+
refund_message: params[:refund_message]
|
|
2285
|
+
)
|
|
2286
|
+
|
|
2287
|
+
if response.success?
|
|
2288
|
+
@order.update(
|
|
2289
|
+
status: 'refunded',
|
|
2290
|
+
refunded_at: Time.current,
|
|
2291
|
+
refund_amount: params[:refund_amount]&.to_i,
|
|
2292
|
+
refund_reason: params[:refund_message]
|
|
2293
|
+
)
|
|
2294
|
+
|
|
2295
|
+
redirect_to @order, notice: 'Refund processed successfully'
|
|
2296
|
+
else
|
|
2297
|
+
flash[:error] = "Cannot process refund: #{response.error_message}"
|
|
2298
|
+
redirect_to @order
|
|
2299
|
+
end
|
|
2300
|
+
rescue ZaiPayment::Errors::ValidationError => e
|
|
2301
|
+
flash[:error] = "Refund error: #{e.message}"
|
|
2302
|
+
redirect_to @order
|
|
2303
|
+
end
|
|
2304
|
+
end
|
|
2305
|
+
```
|
|
2306
|
+
|
|
2307
|
+
#### In a Service Object
|
|
2308
|
+
|
|
2309
|
+
```ruby
|
|
2310
|
+
class RefundService
|
|
2311
|
+
def initialize(order, refund_params = {})
|
|
2312
|
+
@order = order
|
|
2313
|
+
@refund_amount = refund_params[:amount]
|
|
2314
|
+
@refund_message = refund_params[:message]
|
|
2315
|
+
@account_id = refund_params[:account_id]
|
|
2316
|
+
end
|
|
2317
|
+
|
|
2318
|
+
def process_refund
|
|
2319
|
+
# Validate order is refundable
|
|
2320
|
+
unless refundable?
|
|
2321
|
+
return { success: false, error: 'Order cannot be refunded' }
|
|
2322
|
+
end
|
|
2323
|
+
|
|
2324
|
+
# Validate refund amount
|
|
2325
|
+
if @refund_amount && @refund_amount > @order.total_amount
|
|
2326
|
+
return { success: false, error: 'Refund amount exceeds order total' }
|
|
2327
|
+
end
|
|
2328
|
+
|
|
2329
|
+
# Process refund in Zai
|
|
2330
|
+
response = ZaiPayment.items.refund(
|
|
2331
|
+
@order.zai_item_id,
|
|
2332
|
+
refund_amount: @refund_amount,
|
|
2333
|
+
refund_message: @refund_message,
|
|
2334
|
+
account_id: @account_id
|
|
2335
|
+
)
|
|
2336
|
+
|
|
2337
|
+
if response.success?
|
|
2338
|
+
# Update local database
|
|
2339
|
+
@order.update(
|
|
2340
|
+
status: @refund_amount == @order.total_amount ? 'refunded' : 'partially_refunded',
|
|
2341
|
+
zai_state: response.data['state'],
|
|
2342
|
+
zai_payment_state: response.data['payment_state'],
|
|
2343
|
+
refunded_at: Time.current,
|
|
2344
|
+
refund_amount: @refund_amount || @order.total_amount,
|
|
2345
|
+
refund_reason: @refund_message
|
|
2346
|
+
)
|
|
2347
|
+
|
|
2348
|
+
# Send notification
|
|
2349
|
+
OrderMailer.refund_processed(@order).deliver_later
|
|
2350
|
+
|
|
2351
|
+
# Log the refund
|
|
2352
|
+
@order.refund_logs.create(
|
|
2353
|
+
amount: @refund_amount || @order.total_amount,
|
|
2354
|
+
reason: @refund_message,
|
|
2355
|
+
processed_at: Time.current
|
|
2356
|
+
)
|
|
2357
|
+
|
|
2358
|
+
{ success: true, order: @order }
|
|
2359
|
+
else
|
|
2360
|
+
{ success: false, error: response.error_message }
|
|
2361
|
+
end
|
|
2362
|
+
rescue ZaiPayment::Errors::ApiError => e
|
|
2363
|
+
{ success: false, error: e.message }
|
|
2364
|
+
end
|
|
2365
|
+
|
|
2366
|
+
private
|
|
2367
|
+
|
|
2368
|
+
def refundable?
|
|
2369
|
+
# Check local status
|
|
2370
|
+
return false unless @order.status.in?(['completed', 'paid'])
|
|
2371
|
+
|
|
2372
|
+
# Check Zai status
|
|
2373
|
+
status_response = ZaiPayment.items.show_status(@order.zai_item_id)
|
|
2374
|
+
return false unless status_response.success?
|
|
2375
|
+
|
|
2376
|
+
payment_state = status_response.data['payment_state']
|
|
2377
|
+
payment_state.in?(['completed', 'payment_deposited', 'work_completed'])
|
|
2378
|
+
rescue
|
|
2379
|
+
false
|
|
2380
|
+
end
|
|
2381
|
+
end
|
|
2382
|
+
|
|
2383
|
+
# Usage:
|
|
2384
|
+
# service = RefundService.new(order, amount: 5000, message: 'Customer return')
|
|
2385
|
+
# result = service.process_refund
|
|
2386
|
+
# if result[:success]
|
|
2387
|
+
# # Handle success
|
|
2388
|
+
# else
|
|
2389
|
+
# # Handle error: result[:error]
|
|
2390
|
+
# end
|
|
2391
|
+
```
|
|
2392
|
+
|
|
2393
|
+
### Webhook Integration for Refunds
|
|
2394
|
+
|
|
2395
|
+
After processing a refund, you may receive webhook notifications:
|
|
2396
|
+
|
|
2397
|
+
```ruby
|
|
2398
|
+
# In your webhook handler
|
|
2399
|
+
def handle_refund_webhook(payload)
|
|
2400
|
+
if payload['type'] == 'refund' && payload['status'] == 'successful'
|
|
2401
|
+
item_id = payload['related_items'].first
|
|
2402
|
+
puts "Refund successful for item: #{item_id}"
|
|
2403
|
+
|
|
2404
|
+
# Update your database
|
|
2405
|
+
Order.find_by(zai_item_id: item_id)&.update(
|
|
2406
|
+
status: 'refunded',
|
|
2407
|
+
zai_state: payload['state'],
|
|
2408
|
+
refunded_at: Time.current,
|
|
2409
|
+
refund_amount: payload['amount']
|
|
2410
|
+
)
|
|
2411
|
+
|
|
2412
|
+
# Notify customer
|
|
2413
|
+
order = Order.find_by(zai_item_id: item_id)
|
|
2414
|
+
OrderMailer.refund_confirmed(order).deliver_later if order
|
|
2415
|
+
elsif payload['status'] == 'failed'
|
|
2416
|
+
puts "Refund failed: #{payload['failure_reason']}"
|
|
2417
|
+
end
|
|
2418
|
+
end
|
|
2419
|
+
```
|
|
2420
|
+
|
|
2421
|
+
### Testing Refund Functionality
|
|
2422
|
+
|
|
2423
|
+
```ruby
|
|
2424
|
+
# spec/services/refund_service_spec.rb
|
|
2425
|
+
RSpec.describe RefundService do
|
|
2426
|
+
let(:order) { create(:order, status: 'completed', zai_item_id: 'item-123', total_amount: 10000) }
|
|
2427
|
+
let(:service) { described_class.new(order, amount: 5000, message: 'Customer return') }
|
|
2428
|
+
|
|
2429
|
+
describe '#process_refund' do
|
|
2430
|
+
context 'when order can be refunded' do
|
|
2431
|
+
before do
|
|
2432
|
+
allow(ZaiPayment.items).to receive(:show_status).and_return(
|
|
2433
|
+
double(success?: true, data: { 'payment_state' => 'completed' })
|
|
2434
|
+
)
|
|
2435
|
+
|
|
2436
|
+
allow(ZaiPayment.items).to receive(:refund).and_return(
|
|
2437
|
+
double(
|
|
2438
|
+
success?: true,
|
|
2439
|
+
data: { 'id' => 'item-123', 'state' => 'refunded', 'payment_state' => 'refunded' }
|
|
2440
|
+
)
|
|
2441
|
+
)
|
|
2442
|
+
end
|
|
2443
|
+
|
|
2444
|
+
it 'successfully processes the refund' do
|
|
2445
|
+
result = service.process_refund
|
|
2446
|
+
|
|
2447
|
+
expect(result[:success]).to be true
|
|
2448
|
+
expect(order.reload.status).to eq('partially_refunded')
|
|
2449
|
+
expect(order.refund_amount).to eq(5000)
|
|
2450
|
+
expect(order.refunded_at).to be_present
|
|
2451
|
+
end
|
|
2452
|
+
|
|
2453
|
+
it 'marks as fully refunded when refund amount equals total' do
|
|
2454
|
+
service = described_class.new(order, amount: 10000)
|
|
2455
|
+
result = service.process_refund
|
|
2456
|
+
|
|
2457
|
+
expect(order.reload.status).to eq('refunded')
|
|
2458
|
+
end
|
|
2459
|
+
end
|
|
2460
|
+
|
|
2461
|
+
context 'when order cannot be refunded' do
|
|
2462
|
+
before do
|
|
2463
|
+
order.update(status: 'pending')
|
|
2464
|
+
end
|
|
2465
|
+
|
|
2466
|
+
it 'returns error' do
|
|
2467
|
+
result = service.process_refund
|
|
2468
|
+
|
|
2469
|
+
expect(result[:success]).to be false
|
|
2470
|
+
expect(result[:error]).to include('cannot be refunded')
|
|
2471
|
+
end
|
|
2472
|
+
end
|
|
2473
|
+
|
|
2474
|
+
context 'when refund amount exceeds order total' do
|
|
2475
|
+
let(:service) { described_class.new(order, amount: 20000) }
|
|
2476
|
+
|
|
2477
|
+
it 'returns error' do
|
|
2478
|
+
result = service.process_refund
|
|
2479
|
+
|
|
2480
|
+
expect(result[:success]).to be false
|
|
2481
|
+
expect(result[:error]).to include('exceeds order total')
|
|
2482
|
+
end
|
|
2483
|
+
end
|
|
2484
|
+
end
|
|
2485
|
+
end
|
|
2486
|
+
```
|
|
2487
|
+
|
|
2488
|
+
### Cancel Integration with Rails
|
|
2489
|
+
|
|
2490
|
+
#### In a Controller
|
|
2491
|
+
|
|
2492
|
+
```ruby
|
|
2493
|
+
class OrdersController < ApplicationController
|
|
2494
|
+
def cancel
|
|
2495
|
+
@order = Order.find(params[:id])
|
|
2496
|
+
|
|
2497
|
+
# Ensure order belongs to current user
|
|
2498
|
+
unless @order.user == current_user
|
|
2499
|
+
redirect_to root_path, alert: 'Unauthorized'
|
|
2500
|
+
return
|
|
2501
|
+
end
|
|
2502
|
+
|
|
2503
|
+
# Cancel in Zai
|
|
2504
|
+
response = ZaiPayment.items.cancel(@order.zai_item_id)
|
|
2505
|
+
|
|
2506
|
+
if response.success?
|
|
2507
|
+
@order.update(
|
|
2508
|
+
status: 'cancelled',
|
|
2509
|
+
cancelled_at: Time.current
|
|
2510
|
+
)
|
|
2511
|
+
|
|
2512
|
+
redirect_to @order, notice: 'Order cancelled successfully'
|
|
2513
|
+
else
|
|
2514
|
+
flash[:error] = "Cannot cancel order: #{response.error_message}"
|
|
2515
|
+
redirect_to @order
|
|
2516
|
+
end
|
|
2517
|
+
rescue ZaiPayment::Errors::ValidationError => e
|
|
2518
|
+
flash[:error] = "Cancellation error: #{e.message}"
|
|
2519
|
+
redirect_to @order
|
|
2520
|
+
end
|
|
2521
|
+
end
|
|
2522
|
+
```
|
|
2523
|
+
|
|
2524
|
+
#### In a Service Object
|
|
2525
|
+
|
|
2526
|
+
```ruby
|
|
2527
|
+
class OrderCancellationService
|
|
2528
|
+
def initialize(order)
|
|
2529
|
+
@order = order
|
|
2530
|
+
end
|
|
2531
|
+
|
|
2532
|
+
def cancel
|
|
2533
|
+
# Check if order can be cancelled
|
|
2534
|
+
unless cancellable?
|
|
2535
|
+
return { success: false, error: 'Order cannot be cancelled' }
|
|
2536
|
+
end
|
|
2537
|
+
|
|
2538
|
+
# Cancel in Zai
|
|
2539
|
+
response = ZaiPayment.items.cancel(@order.zai_item_id)
|
|
2540
|
+
|
|
2541
|
+
if response.success?
|
|
2542
|
+
# Update local database
|
|
2543
|
+
@order.update(
|
|
2544
|
+
status: 'cancelled',
|
|
2545
|
+
zai_state: response.data['state'],
|
|
2546
|
+
zai_payment_state: response.data['payment_state'],
|
|
2547
|
+
cancelled_at: Time.current,
|
|
2548
|
+
cancelled_by: @order.user_id
|
|
2549
|
+
)
|
|
2550
|
+
|
|
2551
|
+
# Send notification
|
|
2552
|
+
OrderMailer.order_cancelled(@order).deliver_later
|
|
2553
|
+
|
|
2554
|
+
# Refund any processing fees if applicable
|
|
2555
|
+
process_fee_refund if @order.processing_fee.present?
|
|
2556
|
+
|
|
2557
|
+
{ success: true, order: @order }
|
|
2558
|
+
else
|
|
2559
|
+
{ success: false, error: response.error_message }
|
|
2560
|
+
end
|
|
2561
|
+
rescue ZaiPayment::Errors::ApiError => e
|
|
2562
|
+
{ success: false, error: e.message }
|
|
2563
|
+
end
|
|
2564
|
+
|
|
2565
|
+
private
|
|
2566
|
+
|
|
2567
|
+
def cancellable?
|
|
2568
|
+
# Check local status
|
|
2569
|
+
return false unless @order.status.in?(['pending', 'payment_pending'])
|
|
2570
|
+
|
|
2571
|
+
# Check Zai status
|
|
2572
|
+
status_response = ZaiPayment.items.show_status(@order.zai_item_id)
|
|
2573
|
+
return false unless status_response.success?
|
|
2574
|
+
|
|
2575
|
+
status_response.data['state'].in?(['pending', 'payment_pending'])
|
|
2576
|
+
rescue
|
|
2577
|
+
false
|
|
2578
|
+
end
|
|
2579
|
+
|
|
2580
|
+
def process_fee_refund
|
|
2581
|
+
# Custom logic for refunding processing fees
|
|
2582
|
+
# ...
|
|
2583
|
+
end
|
|
2584
|
+
end
|
|
2585
|
+
|
|
2586
|
+
# Usage:
|
|
2587
|
+
# service = OrderCancellationService.new(order)
|
|
2588
|
+
# result = service.cancel
|
|
2589
|
+
# if result[:success]
|
|
2590
|
+
# # Handle success
|
|
2591
|
+
# else
|
|
2592
|
+
# # Handle error: result[:error]
|
|
2593
|
+
# end
|
|
2594
|
+
```
|
|
2595
|
+
|
|
2596
|
+
### Webhook Integration
|
|
2597
|
+
|
|
2598
|
+
After cancelling an item, you may receive webhook notifications:
|
|
2599
|
+
|
|
2600
|
+
```ruby
|
|
2601
|
+
# In your webhook handler
|
|
2602
|
+
def handle_item_webhook(payload)
|
|
2603
|
+
if payload['type'] == 'item' && payload['status'] == 'cancelled'
|
|
2604
|
+
item_id = payload['id']
|
|
2605
|
+
puts "Item cancelled: #{item_id}"
|
|
2606
|
+
|
|
2607
|
+
# Update your database
|
|
2608
|
+
Order.find_by(zai_item_id: item_id)&.update(
|
|
2609
|
+
status: 'cancelled',
|
|
2610
|
+
zai_state: payload['state'],
|
|
2611
|
+
cancelled_at: Time.current
|
|
2612
|
+
)
|
|
2613
|
+
|
|
2614
|
+
# Notify customer
|
|
2615
|
+
order = Order.find_by(zai_item_id: item_id)
|
|
2616
|
+
OrderMailer.cancellation_confirmed(order).deliver_later if order
|
|
2617
|
+
end
|
|
2618
|
+
end
|
|
2619
|
+
```
|
|
2620
|
+
|
|
2621
|
+
### Testing Cancel Functionality
|
|
2622
|
+
|
|
2623
|
+
```ruby
|
|
2624
|
+
# spec/services/order_cancellation_service_spec.rb
|
|
2625
|
+
RSpec.describe OrderCancellationService do
|
|
2626
|
+
let(:order) { create(:order, status: 'pending', zai_item_id: 'item-123') }
|
|
2627
|
+
let(:service) { described_class.new(order) }
|
|
2628
|
+
|
|
2629
|
+
describe '#cancel' do
|
|
2630
|
+
context 'when order can be cancelled' do
|
|
2631
|
+
before do
|
|
2632
|
+
allow(ZaiPayment.items).to receive(:show_status).and_return(
|
|
2633
|
+
double(success?: true, data: { 'state' => 'pending' })
|
|
2634
|
+
)
|
|
2635
|
+
|
|
2636
|
+
allow(ZaiPayment.items).to receive(:cancel).and_return(
|
|
2637
|
+
double(
|
|
2638
|
+
success?: true,
|
|
2639
|
+
data: { 'id' => 'item-123', 'state' => 'cancelled', 'payment_state' => 'cancelled' }
|
|
2640
|
+
)
|
|
2641
|
+
)
|
|
2642
|
+
end
|
|
2643
|
+
|
|
2644
|
+
it 'successfully cancels the order' do
|
|
2645
|
+
result = service.cancel
|
|
2646
|
+
|
|
2647
|
+
expect(result[:success]).to be true
|
|
2648
|
+
expect(order.reload.status).to eq('cancelled')
|
|
2649
|
+
expect(order.cancelled_at).to be_present
|
|
2650
|
+
end
|
|
2651
|
+
end
|
|
2652
|
+
|
|
2653
|
+
context 'when order cannot be cancelled' do
|
|
2654
|
+
before do
|
|
2655
|
+
order.update(status: 'completed')
|
|
2656
|
+
end
|
|
2657
|
+
|
|
2658
|
+
it 'returns error' do
|
|
2659
|
+
result = service.cancel
|
|
2660
|
+
|
|
2661
|
+
expect(result[:success]).to be false
|
|
2662
|
+
expect(result[:error]).to include('cannot be cancelled')
|
|
2663
|
+
end
|
|
2664
|
+
end
|
|
2665
|
+
end
|
|
2666
|
+
end
|
|
2667
|
+
```
|
|
2668
|
+
|
|
557
2669
|
## Payment Types
|
|
558
2670
|
|
|
559
2671
|
When creating items, you can specify different payment types:
|
|
@@ -595,4 +2707,7 @@ For more information about the Zai Items API, see:
|
|
|
595
2707
|
- [List Item Transactions](https://developer.hellozai.com/reference/listitemtransactions)
|
|
596
2708
|
- [List Item Batch Transactions](https://developer.hellozai.com/reference/listitembatchtransactions)
|
|
597
2709
|
- [Show Item Status](https://developer.hellozai.com/reference/showitemstatus)
|
|
2710
|
+
- [Make Payment](https://developer.hellozai.com/reference/makepayment)
|
|
2711
|
+
- [Cancel Item](https://developer.hellozai.com/reference/cancelitem)
|
|
2712
|
+
- [Refund Item](https://developer.hellozai.com/reference/refund)
|
|
598
2713
|
|