diff --git a/docs/virtual_accounts.md b/docs/virtual_accounts.md new file mode 100644 index 0000000..7d39d56 --- /dev/null +++ b/docs/virtual_accounts.md @@ -0,0 +1,916 @@ +# Virtual Account Management + +The VirtualAccount resource provides methods for creating and managing Zai virtual accounts for Australian payments. + +## Overview + +Virtual Accounts are bank account details that can be created for a wallet account, allowing users to receive funds via standard bank transfers. Each virtual account has unique BSB (routing number) and account number details that can be shared with customers or partners to receive payments. + +Virtual accounts are particularly useful for: +- Receiving payments from customers via direct bank transfer +- Creating unique account details for different payment purposes +- Enabling Confirmation of Payee (CoP) lookups with account name and AKA names +- Managing trust accounts in real estate or property management + +## Key Features + +- **Unique Banking Details**: Each virtual account gets unique BSB and account number +- **AKA Names**: Support for alternative names (up to 3) for CoP lookups +- **Automatic Linking**: Virtual accounts are automatically linked to wallet accounts +- **Status Tracking**: Monitor account status (pending_activation, active, etc.) +- **Multiple Currencies**: Support for different currencies (primarily AUD) + +## References + +- [Virtual Accounts API](https://developer.hellozai.com/reference) +- [Zai API Documentation](https://developer.hellozai.com/docs) + +## Usage + +### Initialize the VirtualAccount Resource + +```ruby +# Using a new instance +virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + +# Or use with custom client +client = ZaiPayment::Client.new(base_endpoint: :va_base) +virtual_accounts = ZaiPayment::Resources::VirtualAccount.new(client: client) + +# Or use the convenience method +ZaiPayment.virtual_accounts +``` + +## Methods + +### List Virtual Accounts + +List all Virtual Accounts for a given Wallet Account. This retrieves an array of all virtual accounts associated with the wallet account. + +#### Parameters + +- `wallet_account_id` (required) - The wallet account ID + +#### Example + +```ruby +# List all virtual accounts for a wallet +response = virtual_accounts.list('ae07556e-22ef-11eb-adc1-0242ac120002') + +# Access the list of virtual accounts +if response.success? + accounts = response.data # Array of virtual accounts + total = response.meta['total'] + + puts "Found #{accounts.length} virtual accounts" + + accounts.each do |account| + puts "ID: #{account['id']}" + puts "Name: #{account['account_name']}" + puts "BSB: #{account['routing_number']}" + puts "Account: #{account['account_number']}" + puts "Status: #{account['status']}" + end +end +``` + +#### Response + +```ruby +{ + "virtual_accounts" => [ + { + "id" => "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "routing_number" => "123456", + "account_number" => "100000017", + "wallet_account_id" => "ae07556e-22ef-11eb-adc1-0242ac120002", + "user_external_id" => "ca12346e-22ef-11eb-adc1-0242ac120002", + "currency" => "AUD", + "status" => "active", + "created_at" => "2020-04-27T20:28:22.378Z", + "updated_at" => "2020-04-27T20:28:22.378Z", + "account_type" => "NIND", + "full_legal_account_name" => "Prop Tech Marketplace", + "account_name" => "Real Estate Agency X", + "aka_names" => ["Realestate agency X"], + "merchant_id" => "46deb476c1a641eb8eb726a695bbe5bc" + }, + { + "id" => "aaaaaaaa-cccc-dddd-eeee-ffffffffffff", + "routing_number" => "123456", + "account_number" => "100000025", + "currency" => "AUD", + "wallet_account_id" => "ae07556e-22ef-11eb-adc1-0242ac120002", + "user_external_id" => "ca12346e-22ef-11eb-adc1-0242ac120002", + "status" => "pending_activation", + "created_at" => "2020-04-27T20:28:22.378Z", + "updated_at" => "2020-04-27T20:28:22.378Z", + "account_type" => "NIND", + "full_legal_account_name" => "Prop Tech Marketplace", + "account_name" => "Real Estate Agency X", + "aka_names" => ["Realestate agency X"], + "merchant_id" => "46deb476c1a641eb8eb726a695bbe5bc" + } + ], + "meta" => { + "total" => 2 + } +} +``` + +**Response Fields:** + +The response contains an array of virtual account objects. Each object has the same fields as described in the Create Virtual Account section. + +**Additional Response Data:** + +- `meta` - Contains pagination and metadata information + - `total` - Total number of virtual accounts + +**Use Cases:** + +- Retrieve all virtual accounts for auditing purposes +- Display available payment accounts to customers +- Filter accounts by status (active, pending_activation, etc.) +- Check if virtual accounts exist before creating new ones +- Monitor account statuses across multiple properties +- Generate reports on virtual account usage + +### Show Virtual Account + +Show details of a specific Virtual Account using the given virtual account ID. + +#### Parameters + +- `virtual_account_id` (required) - The virtual account ID + +#### Example + +```ruby +# Get specific virtual account details +response = virtual_accounts.show('46deb476-c1a6-41eb-8eb7-26a695bbe5bc') + +# Access virtual account details +if response.success? + account = response.data + + puts "Virtual Account: #{account['account_name']}" + puts "Status: #{account['status']}" + puts "BSB: #{account['routing_number']}" + puts "Account Number: #{account['account_number']}" + puts "Currency: #{account['currency']}" + + # Access AKA names + account['aka_names'].each do |aka_name| + puts "AKA: #{aka_name}" + end +end +``` + +#### Response + +```ruby +{ + "virtual_accounts" => { + "id" => "46deb476-c1a6-41eb-8eb7-26a695bbe5bc", + "routing_number" => "123456", + "account_number" => "100000017", + "currency" => "AUD", + "user_external_id" => "46deb476-c1a6-41eb-8eb7-26a695bbe5bc", + "wallet_account_id" => "46deb476-c1a6-41eb-8eb7-26a695bbe5bc", + "status" => "active", + "created_at" => "2020-04-27T20:28:22.378Z", + "updated_at" => "2020-04-27T20:28:22.378Z", + "account_type" => "NIND", + "full_legal_account_name" => "Prop Tech Marketplace", + "account_name" => "Real Estate Agency X", + "aka_names" => [ + "Realestate Agency X", + "Realestate Agency X of PropTech Marketplace" + ], + "merchant_id" => "46deb476c1a641eb8eb726a695bbe5bc" + } +} +``` + +**Response Fields:** + +The response contains a single virtual account object with all the fields described in the Create Virtual Account section. + +**Use Cases:** + +- Verify virtual account details before sharing with customers +- Check account status before processing payments +- Generate payment instructions for customers +- Audit specific virtual account configurations +- Validate account information +- Monitor individual account updates + +### Update AKA Names + +Update (replace) the list of AKA Names for a Virtual Account. This operation completely replaces the existing AKA names with the new list provided. + +#### Parameters + +- `virtual_account_id` (required) - The virtual account ID +- `aka_names` (required) - Array of AKA names (0 to 3 items) + +#### Example + +```ruby +# Update AKA names +response = virtual_accounts.update_aka_names( + '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + ['Updated Name 1', 'Updated Name 2'] +) + +# Access updated virtual account +if response.success? + account = response.data + + puts "Updated AKA names for: #{account['account_name']}" + account['aka_names'].each do |aka_name| + puts " - #{aka_name}" + end +end + +# Clear all AKA names +response = virtual_accounts.update_aka_names( + '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + [] +) +``` + +#### Response + +The response contains the complete updated virtual account object with the same structure as the Show Virtual Account response. + +**Use Cases:** + +- Update AKA names after business name changes +- Add or remove alternative names for better CoP matching +- Clear AKA names when no longer needed +- Standardize naming across virtual accounts +- Update names based on customer feedback +- Maintain up-to-date payment reference information + +### Update Account Name + +Update (change) the name of a Virtual Account. This is used in CoP lookups. + +#### Parameters + +- `virtual_account_id` (required) - The virtual account ID +- `account_name` (required) - The new account name (max 140 characters) + +#### Example + +```ruby +# Update account name +response = virtual_accounts.update_account_name( + '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + 'New Real Estate Agency Name' +) + +# Access updated virtual account +if response.success? + account = response.data + + puts "Account name updated successfully" + puts "New name: #{account['account_name']}" + puts "Virtual Account ID: #{account['id']}" +end +``` + +#### Response + +The response contains the complete updated virtual account object with the same structure as the Show Virtual Account response. + +**Use Cases:** + +- Update account name after business rebranding +- Change name to match legal business name changes +- Correct misspellings or formatting issues +- Update names for better CoP matching +- Align virtual account names with organizational changes +- Update names for regulatory compliance + +### Update Status + +Update the status of a Virtual Account. Currently, this endpoint only supports closing virtual accounts by setting the status to 'closed'. This is an asynchronous operation that returns a 202 Accepted response. + +**Important:** Once a virtual account is closed, it cannot be reopened and will no longer be able to receive payments. + +#### Parameters + +- `virtual_account_id` (required) - The virtual account ID +- `status` (required) - The new status (must be 'closed') + +#### Example + +```ruby +# Close a virtual account +response = virtual_accounts.update_status( + '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + 'closed' +) + +# Check the response +if response.success? + puts "Virtual account closure initiated" + puts "ID: #{response.data['id']}" + puts "Message: #{response.data['message']}" + puts "Link: #{response.data['links']['self']}" + + # Note: The operation is asynchronous + # Use the show method to check the current status + sleep(2) # Wait a moment + + show_response = virtual_accounts.show(response.data['id']) + puts "Current status: #{show_response.data['status']}" +end +``` + +#### Response + +```ruby +{ + "id" => "46deb476-c1a6-41eb-8eb7-26a695bbe5bc", + "message" => "Virtual Account update has been accepted for processing", + "links" => { + "self" => "/virtual_accounts/46deb476-c1a6-41eb-8eb7-26a695bbe5bc" + } +} +``` + +**Response Fields:** + +- `id` - The virtual account ID +- `message` - Confirmation message about the status update +- `links` - Object containing related resource links + - `self` - URL to the virtual account resource + +**Use Cases:** + +- Close unused virtual accounts to maintain clean account lists +- Deactivate accounts when a customer relationship ends +- Close accounts as part of account cleanup or migration +- Permanently disable accounts that should no longer receive payments +- Close test or temporary accounts after use +- Implement account lifecycle management +- Meet regulatory requirements for account closure + +**Important Notes:** + +- This operation returns 202 Accepted because it's processed asynchronously +- The actual status change may take a few moments to complete +- Use the `show` method to verify the status has been updated +- Only 'closed' is a valid status value; other values will raise a ValidationError +- Closed accounts cannot be reopened +- Ensure no pending transactions before closing an account + +### Create Virtual Account + +Create a Virtual Account for a given Wallet Account. This generates unique bank account details that can be used to receive funds. + +#### Parameters + +- `wallet_account_id` (required) - The wallet account ID +- `account_name` (required) - A name for the virtual account (max 140 characters) +- `aka_names` (optional) - Array of alternative names for CoP lookups (0 to 3 items) + +#### Example + +```ruby +# Basic creation with account name only +response = virtual_accounts.create( + 'ae07556e-22ef-11eb-adc1-0242ac120002', + account_name: 'Real Estate Agency X' +) + +# With AKA names for Confirmation of Payee +response = virtual_accounts.create( + 'ae07556e-22ef-11eb-adc1-0242ac120002', + account_name: 'Real Estate Agency X', + aka_names: ['Realestate agency X', 'RE Agency X', 'Agency X'] +) + +# Access virtual account details +if response.success? + virtual_account = response.data + puts "Virtual Account ID: #{virtual_account['id']}" + puts "BSB: #{virtual_account['routing_number']}" + puts "Account Number: #{virtual_account['account_number']}" + puts "Account Name: #{virtual_account['account_name']}" + puts "Status: #{virtual_account['status']}" +end +``` + +#### Response + +```ruby +{ + "virtual_accounts" => { + "id" => "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "routing_number" => "123456", + "account_number" => "100000017", + "currency" => "AUD", + "wallet_account_id" => "ae07556e-22ef-11eb-adc1-0242ac120002", + "user_external_id" => "ca12346e-22ef-11eb-adc1-0242ac120002", + "status" => "pending_activation", + "created_at" => "2020-04-27T20:28:22.378Z", + "updated_at" => "2020-04-27T20:28:22.378Z", + "account_type" => "NIND", + "full_legal_account_name" => "Prop Tech Marketplace", + "account_name" => "Real Estate Agency X", + "aka_names" => ["Realestate agency X"], + "merchant_id" => "46deb476c1a641eb8eb726a695bbe5bc" + } +} +``` + +**Response Fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `id` | String | Unique identifier for the virtual account | +| `routing_number` | String | BSB/routing number (6 digits) | +| `account_number` | String | Bank account number | +| `currency` | String | Account currency (e.g., "AUD") | +| `wallet_account_id` | String | Associated wallet account ID | +| `user_external_id` | String | Associated user's external ID | +| `status` | String | Account status (pending_activation, active, etc.) | +| `created_at` | String | ISO 8601 timestamp of creation | +| `updated_at` | String | ISO 8601 timestamp of last update | +| `account_type` | String | Type of account (e.g., "NIND") | +| `full_legal_account_name` | String | Full legal name of the account | +| `account_name` | String | Display name of the account | +| `aka_names` | Array | Alternative names for CoP lookups | +| `merchant_id` | String | Merchant identifier | + +**Use Cases:** + +- Create unique payment collection accounts for different properties or services +- Enable customers to pay via direct bank transfer +- Set up trust accounts for real estate transactions +- Configure multiple name variations for better Confirmation of Payee matching +- Generate dedicated account details for recurring payment arrangements + +## Validation Rules + +### account_name + +- **Required**: Yes +- **Type**: String +- **Max Length**: 140 characters +- **Description**: The display name for the virtual account. This is used in CoP lookups and shown to customers when confirming payments. + +### aka_names + +- **Required**: No +- **Type**: Array of Strings +- **Min Items**: 0 +- **Max Items**: 3 +- **Description**: Alternative names for the virtual account. These are used in Confirmation of Payee (CoP) lookups to improve matching when customers initiate transfers. + +### wallet_account_id + +- **Required**: Yes +- **Type**: String (UUID) +- **Description**: The ID of the wallet account that this virtual account will be linked to. The wallet account must exist before creating a virtual account. + +## Error Handling + +The virtual account methods can raise the following errors: + +### ZaiPayment::Errors::ValidationError + +Raised when input parameters fail validation: + +```ruby +begin + response = virtual_accounts.create( + '', # Empty wallet_account_id + account_name: 'Test Account' + ) +rescue ZaiPayment::Errors::ValidationError => e + puts "Validation failed: #{e.message}" + # Output: "wallet_account_id is required and cannot be blank" +end +``` + +**Common validation errors:** +- `wallet_account_id is required and cannot be blank` +- `account_name cannot be blank` +- `account_name must be 140 characters or less` +- `aka_names must be an array` +- `aka_names must contain between 0 and 3 items` + +### ZaiPayment::Errors::NotFoundError + +Raised when the wallet account doesn't exist: + +```ruby +begin + response = virtual_accounts.create( + 'invalid-wallet-id', + account_name: 'Test Account' + ) +rescue ZaiPayment::Errors::NotFoundError => e + puts "Not found: #{e.message}" +end +``` + +### ZaiPayment::Errors::UnauthorizedError + +Raised when authentication fails: + +```ruby +begin + response = virtual_accounts.create( + wallet_account_id, + account_name: 'Test Account' + ) +rescue ZaiPayment::Errors::UnauthorizedError => e + puts "Authentication failed: #{e.message}" + # Check your API credentials +end +``` + +### ZaiPayment::Errors::BadRequestError + +Raised when the request is malformed or contains invalid data: + +```ruby +begin + response = virtual_accounts.create( + wallet_account_id, + account_name: 'Test Account' + ) +rescue ZaiPayment::Errors::BadRequestError => e + puts "Bad request: #{e.message}" +end +``` + +## Complete Example + +Here's a complete workflow showing how to list and create virtual accounts with proper error handling: + +```ruby +require 'zai_payment' + +# Configure ZaiPayment +ZaiPayment.configure do |config| + config.environment = :prelive + config.client_id = ENV['ZAI_CLIENT_ID'] + config.client_secret = ENV['ZAI_CLIENT_SECRET'] + config.scope = ENV['ZAI_SCOPE'] +end + +# Initialize resource +virtual_accounts = ZaiPayment::Resources::VirtualAccount.new +wallet_account_id = 'ae07556e-22ef-11eb-adc1-0242ac120002' + +begin + # First, list existing virtual accounts + puts "Fetching existing virtual accounts..." + list_response = virtual_accounts.list(wallet_account_id) + + if list_response.success? + existing_accounts = list_response.data + puts "✓ Found #{existing_accounts.length} existing virtual account(s)" + + # Display existing accounts + existing_accounts.each do |account| + puts " - #{account['account_name']} (#{account['status']})" + puts " BSB: #{account['routing_number']} | Account: #{account['account_number']}" + end + + # Check if we need to create a new one + property_name = 'Property 123 Trust Account' + existing = existing_accounts.find { |a| a['account_name'] == property_name } + + if existing + puts "\n✓ Virtual account already exists for '#{property_name}'" + puts " ID: #{existing['id']}" + puts " Status: #{existing['status']}" + else + puts "\nCreating new virtual account for '#{property_name}'..." + + # Create new virtual account + create_response = virtual_accounts.create( + wallet_account_id, + account_name: property_name, + aka_names: ['Prop 123', 'Property Trust', 'Trust 123'] + ) + + if create_response.success? + virtual_account = create_response.data + + puts "✓ Virtual Account Created Successfully!" + puts "─" * 60 + puts "ID: #{virtual_account['id']}" + puts "Status: #{virtual_account['status']}" + puts "" + puts "Bank Details (share with customers):" + puts " BSB: #{virtual_account['routing_number']}" + puts " Account: #{virtual_account['account_number']}" + puts " Name: #{virtual_account['account_name']}" + puts "" + puts "Alternative Names for CoP:" + virtual_account['aka_names'].each do |aka_name| + puts " - #{aka_name}" + end + puts "─" * 60 + + # Store the details in your database for future reference + # YourDatabase.store_virtual_account(virtual_account) + else + puts "Failed to create virtual account" + end + end + else + puts "Failed to list virtual accounts" + end + +rescue ZaiPayment::Errors::ValidationError => e + puts "Validation Error: #{e.message}" + puts "Please check your input parameters" + +rescue ZaiPayment::Errors::NotFoundError => e + puts "Wallet Account Not Found: #{e.message}" + puts "Please verify the wallet_account_id exists" + +rescue ZaiPayment::Errors::UnauthorizedError => e + puts "Authentication Failed: #{e.message}" + puts "Please check your API credentials" + +rescue ZaiPayment::Errors::ApiError => e + puts "API Error: #{e.message}" +end +``` + +## Configuration + +Virtual accounts use the `va_base` endpoint, which is automatically configured based on your environment: + +### Prelive Environment + +```ruby +ZaiPayment.configure do |config| + config.environment = :prelive + # Uses: https://sandbox.au-0000.api.assemblypay.com +end +``` + +### Production Environment + +```ruby +ZaiPayment.configure do |config| + config.environment = :production + # Uses: https://secure.api.promisepay.com +end +``` + +The VirtualAccount resource automatically uses the correct endpoint based on your configuration. + +## Best Practices + +### 1. Meaningful Account Names + +Use descriptive account names that help identify the purpose: + +```ruby +# Good +account_name: 'Property 123 Main St Trust Account' +account_name: 'Client Settlement Fund - Smith' +account_name: 'Rent Collection - Building A' + +# Avoid +account_name: 'Account 1' +account_name: 'Test' +``` + +### 2. Effective AKA Names + +Add variations that customers might use when searching: + +```ruby +aka_names: [ + 'Smith Real Estate', # Full name + 'Smith RE', # Abbreviation + 'Smith Property Management' # Alternative name +] +``` + +### 3. Store Virtual Account Details + +Always store the virtual account details in your database: + +```ruby +response = virtual_accounts.create(wallet_account_id, account_name: name) + +if response.success? + virtual_account = response.data + + # Store in database + VirtualAccountRecord.create!( + external_id: virtual_account['id'], + routing_number: virtual_account['routing_number'], + account_number: virtual_account['account_number'], + account_name: virtual_account['account_name'], + wallet_account_id: virtual_account['wallet_account_id'], + status: virtual_account['status'] + ) +end +``` + +### 4. Handle Errors Gracefully + +Always implement proper error handling: + +```ruby +def create_virtual_account_safely(wallet_account_id, params) + virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + + begin + response = virtual_accounts.create(wallet_account_id, **params) + { success: true, data: response.data } + rescue ZaiPayment::Errors::ValidationError => e + { success: false, error: 'validation', message: e.message } + rescue ZaiPayment::Errors::NotFoundError => e + { success: false, error: 'not_found', message: e.message } + rescue ZaiPayment::Errors::ApiError => e + { success: false, error: 'api_error', message: e.message } + end +end +``` + +### 5. Validate Before Creating + +Pre-validate input to provide better user feedback: + +```ruby +def validate_virtual_account_params(account_name, aka_names) + errors = [] + + if account_name.nil? || account_name.strip.empty? + errors << 'Account name is required' + elsif account_name.length > 140 + errors << 'Account name must be 140 characters or less' + end + + if aka_names && !aka_names.is_a?(Array) + errors << 'AKA names must be an array' + elsif aka_names && aka_names.length > 3 + errors << 'Maximum 3 AKA names allowed' + end + + errors +end + +# Usage +errors = validate_virtual_account_params(account_name, aka_names) +if errors.empty? + # Proceed with creation +else + puts "Validation errors: #{errors.join(', ')}" +end +``` + +### 6. Monitor Virtual Account Status + +After creation, monitor the status of the virtual account: + +```ruby +virtual_account = response.data + +case virtual_account['status'] +when 'pending_activation' + puts "Account created, awaiting activation" +when 'active' + puts "Account is active and ready to receive funds" +when 'inactive' + puts "Account is inactive" +else + puts "Unknown status: #{virtual_account['status']}" +end +``` + +### 7. Secure Banking Details + +Treat virtual account details like real bank account information: + +```ruby +# Don't log sensitive details in production +if Rails.env.production? + logger.info "Virtual account created: #{virtual_account['id']}" +else + logger.debug "Virtual account details: #{virtual_account.inspect}" +end + +# Use HTTPS for all communications +# Store securely in your database +# Limit access to authorized personnel only +``` + +## Testing + +For testing in prelive environment: + +```ruby +# spec/services/virtual_account_service_spec.rb +require 'spec_helper' + +RSpec.describe VirtualAccountService do + let(:wallet_account_id) { 'test-wallet-id' } + + describe '#create_virtual_account' do + it 'creates a virtual account successfully' do + VCR.use_cassette('virtual_account_create') do + service = VirtualAccountService.new + result = service.create_virtual_account( + wallet_account_id, + account_name: 'Test Account', + aka_names: ['Test'] + ) + + expect(result[:success]).to be true + expect(result[:virtual_account]['id']).to be_present + expect(result[:virtual_account]['routing_number']).to be_present + expect(result[:virtual_account]['account_number']).to be_present + end + end + end +end +``` + +## Troubleshooting + +### Issue: ValidationError - "wallet_account_id is required" + +**Solution**: Ensure you're passing a valid wallet account ID: + +```ruby +# Wrong +virtual_accounts.create('', account_name: 'Test') + +# Correct +virtual_accounts.create('ae07556e-22ef-11eb-adc1-0242ac120002', account_name: 'Test') +``` + +### Issue: NotFoundError - "Wallet account not found" + +**Solution**: Verify the wallet account exists before creating a virtual account: + +```ruby +# Check wallet account exists first +wallet_accounts = ZaiPayment::Resources::WalletAccount.new +begin + wallet_response = wallet_accounts.show(wallet_account_id) + if wallet_response.success? + # Wallet exists, proceed with virtual account creation + virtual_accounts.create(wallet_account_id, account_name: 'Test') + end +rescue ZaiPayment::Errors::NotFoundError + puts "Wallet account does not exist" +end +``` + +### Issue: ValidationError - "account_name must be 140 characters or less" + +**Solution**: Truncate or shorten the account name: + +```ruby +account_name = "Very Long Account Name That Exceeds The Maximum Length" + +# Truncate to 140 characters +truncated_name = account_name[0, 140] + +virtual_accounts.create(wallet_account_id, account_name: truncated_name) +``` + +### Issue: ValidationError - "aka_names must contain between 0 and 3 items" + +**Solution**: Limit to maximum 3 AKA names: + +```ruby +# Wrong +aka_names = ['Name 1', 'Name 2', 'Name 3', 'Name 4'] + +# Correct - take first 3 +aka_names = ['Name 1', 'Name 2', 'Name 3'] + +virtual_accounts.create( + wallet_account_id, + account_name: 'Test', + aka_names: aka_names[0, 3] # Ensure max 3 items +) +``` + +## See Also + +- [Wallet Accounts Documentation](wallet_accounts.md) +- [Examples](../examples/virtual_accounts.md) +- [Zai API Documentation](https://developer.hellozai.com/docs) + diff --git a/examples/virtual_accounts.md b/examples/virtual_accounts.md new file mode 100644 index 0000000..5bb1593 --- /dev/null +++ b/examples/virtual_accounts.md @@ -0,0 +1,1530 @@ +# Virtual Account Management Examples + +This document provides practical examples for managing virtual accounts in Zai Payment. + +## Table of Contents + +- [Setup](#setup) +- [List Virtual Accounts Example](#list-virtual-accounts-example) +- [Show Virtual Account Example](#show-virtual-account-example) +- [Create Virtual Account Example](#create-virtual-account-example) +- [Update AKA Names Example](#update-aka-names-example) +- [Update Account Name Example](#update-account-name-example) +- [Update Status Example](#update-status-example) +- [Common Patterns](#common-patterns) + +## Setup + +```ruby +require 'zai_payment' + +# Configure ZaiPayment +ZaiPayment.configure do |config| + config.environment = :prelive # or :production + config.client_id = ENV['ZAI_CLIENT_ID'] + config.client_secret = ENV['ZAI_CLIENT_SECRET'] + config.scope = ENV['ZAI_SCOPE'] +end +``` + +## List Virtual Accounts Example + +### Example 1: List All Virtual Accounts + +List all virtual accounts for a given wallet account. + +```ruby +# List virtual accounts +virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + +response = virtual_accounts.list('ae07556e-22ef-11eb-adc1-0242ac120002') + +if response.success? + accounts = response.data + + puts "Found #{accounts.length} virtual account(s)" + puts "Total: #{response.meta['total']}" + puts "─" * 60 + + accounts.each_with_index do |account, index| + puts "\nVirtual Account ##{index + 1}:" + puts " ID: #{account['id']}" + puts " Account Name: #{account['account_name']}" + puts " BSB: #{account['routing_number']}" + puts " Account Number: #{account['account_number']}" + puts " Status: #{account['status']}" + puts " Currency: #{account['currency']}" + puts " Created: #{account['created_at']}" + end +else + puts "Failed to retrieve virtual accounts" + puts "Error: #{response.error}" +end +``` + +### Example 2: Check if Virtual Accounts Exist + +Check if a wallet account has any virtual accounts. + +```ruby +virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + +begin + response = virtual_accounts.list('ae07556e-22ef-11eb-adc1-0242ac120002') + + if response.success? + if response.data.empty? + puts "No virtual accounts found for this wallet" + puts "You can create one using the create method" + else + puts "Found #{response.data.length} virtual account(s)" + + # Check if any are active + active_accounts = response.data.select { |a| a['status'] == 'active' } + puts "#{active_accounts.length} active account(s)" + + # Check if any are pending + pending_accounts = response.data.select { |a| a['status'] == 'pending_activation' } + puts "#{pending_accounts.length} pending activation" + end + end + +rescue ZaiPayment::Errors::NotFoundError => e + puts "Wallet account not found: #{e.message}" +rescue ZaiPayment::Errors::ApiError => e + puts "API Error: #{e.message}" +end +``` + +### Example 3: Find Active Virtual Accounts + +Find and display only active virtual accounts. + +```ruby +virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + +response = virtual_accounts.list('ae07556e-22ef-11eb-adc1-0242ac120002') + +if response.success? + active_accounts = response.data.select { |account| account['status'] == 'active' } + + if active_accounts.any? + puts "Active Virtual Accounts:" + puts "─" * 60 + + active_accounts.each do |account| + puts "\n#{account['account_name']}" + puts " BSB: #{account['routing_number']} | Account: #{account['account_number']}" + puts " ID: #{account['id']}" + + if account['aka_names'] && account['aka_names'].any? + puts " AKA Names: #{account['aka_names'].join(', ')}" + end + end + else + puts "No active virtual accounts found" + end +end +``` + +### Example 4: Using Convenience Method + +Use the convenience method from ZaiPayment module. + +```ruby +# Using convenience accessor +response = ZaiPayment.virtual_accounts.list('ae07556e-22ef-11eb-adc1-0242ac120002') + +if response.success? + puts "Virtual Accounts: #{response.data.length}" + puts "Total from meta: #{response.meta['total']}" + + response.data.each do |account| + puts "- #{account['account_name']} (#{account['status']})" + end +end +``` + +### Example 5: Export Virtual Accounts to CSV + +Export virtual account details to CSV format. + +```ruby +require 'csv' + +virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + +response = virtual_accounts.list('ae07556e-22ef-11eb-adc1-0242ac120002') + +if response.success? + CSV.open('virtual_accounts.csv', 'w') do |csv| + # Header + csv << ['ID', 'Account Name', 'BSB', 'Account Number', 'Status', 'Currency', 'Created At'] + + # Data rows + response.data.each do |account| + csv << [ + account['id'], + account['account_name'], + account['routing_number'], + account['account_number'], + account['status'], + account['currency'], + account['created_at'] + ] + end + end + + puts "Exported #{response.data.length} virtual accounts to virtual_accounts.csv" +else + puts "Failed to retrieve virtual accounts" +end +``` + +## Show Virtual Account Example + +### Example 1: Get Virtual Account Details + +Retrieve details of a specific virtual account by its ID. + +```ruby +# Get virtual account details +virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + +response = virtual_accounts.show('46deb476-c1a6-41eb-8eb7-26a695bbe5bc') + +if response.success? + account = response.data + + puts "Virtual Account Details:" + puts "─" * 60 + puts "ID: #{account['id']}" + puts "Account Name: #{account['account_name']}" + puts "Status: #{account['status']}" + puts "" + puts "Banking Details:" + puts " BSB (Routing Number): #{account['routing_number']}" + puts " Account Number: #{account['account_number']}" + puts " Currency: #{account['currency']}" + puts "" + puts "Account Information:" + puts " Account Type: #{account['account_type']}" + puts " Full Legal Name: #{account['full_legal_account_name']}" + puts " Merchant ID: #{account['merchant_id']}" + puts "" + puts "Associated IDs:" + puts " Wallet Account ID: #{account['wallet_account_id']}" + puts " User External ID: #{account['user_external_id']}" + puts "" + puts "AKA Names:" + account['aka_names'].each do |aka_name| + puts " - #{aka_name}" + end + puts "" + puts "Timestamps:" + puts " Created: #{account['created_at']}" + puts " Updated: #{account['updated_at']}" + puts "─" * 60 +else + puts "Failed to retrieve virtual account" + puts "Error: #{response.error}" +end +``` + +### Example 2: Check Virtual Account Status + +Check if a virtual account is active before proceeding with operations. + +```ruby +virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + +begin + response = virtual_accounts.show('46deb476-c1a6-41eb-8eb7-26a695bbe5bc') + + if response.success? + account = response.data + + case account['status'] + when 'active' + puts "✓ Virtual account is active and ready to receive payments" + puts " BSB: #{account['routing_number']}" + puts " Account: #{account['account_number']}" + puts " Name: #{account['account_name']}" + when 'pending_activation' + puts "⏳ Virtual account is pending activation" + puts " Please wait for activation to complete" + when 'inactive' + puts "✗ Virtual account is inactive" + puts " Cannot receive payments at this time" + else + puts "⚠ Unknown status: #{account['status']}" + end + end + +rescue ZaiPayment::Errors::NotFoundError => e + puts "Virtual account not found: #{e.message}" +rescue ZaiPayment::Errors::ApiError => e + puts "API Error: #{e.message}" +end +``` + +### Example 3: Get Payment Instructions + +Generate payment instructions for customers based on virtual account details. + +```ruby +virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + +response = virtual_accounts.show('46deb476-c1a6-41eb-8eb7-26a695bbe5bc') + +if response.success? + account = response.data + + if account['status'] == 'active' + puts "Payment Instructions for #{account['account_name']}" + puts "=" * 60 + puts "" + puts "To make a payment, please transfer funds to:" + puts "" + puts " Account Name: #{account['account_name']}" + puts " BSB: #{account['routing_number']}" + puts " Account Number: #{account['account_number']}" + puts "" + puts "Please use one of the following names when making the transfer:" + account['aka_names'].each_with_index do |aka_name, index| + puts " #{index + 1}. #{aka_name}" + end + puts "" + puts "Currency: #{account['currency']}" + puts "=" * 60 + else + puts "This virtual account is not active yet." + puts "Status: #{account['status']}" + end +end +``` + +### Example 4: Using Convenience Method + +Use the convenience method from ZaiPayment module. + +```ruby +# Using convenience accessor +response = ZaiPayment.virtual_accounts.show('46deb476-c1a6-41eb-8eb7-26a695bbe5bc') + +if response.success? + account = response.data + puts "Account: #{account['account_name']}" + puts "Status: #{account['status']}" + puts "BSB: #{account['routing_number']} | Account: #{account['account_number']}" +end +``` + +### Example 5: Validate Virtual Account Before Payment + +Validate virtual account details before initiating a payment. + +```ruby +def validate_virtual_account(virtual_account_id) + virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + + begin + response = virtual_accounts.show(virtual_account_id) + + if response.success? + account = response.data + + # Validation checks + errors = [] + errors << "Account is not active" unless account['status'] == 'active' + errors << "Missing routing number" unless account['routing_number'] + errors << "Missing account number" unless account['account_number'] + errors << "Currency mismatch" unless account['currency'] == 'AUD' + + if errors.empty? + { + valid: true, + account: account, + payment_details: { + bsb: account['routing_number'], + account_number: account['account_number'], + account_name: account['account_name'] + } + } + else + { + valid: false, + errors: errors, + account: account + } + end + else + { + valid: false, + errors: ['Failed to retrieve virtual account'] + } + end + + rescue ZaiPayment::Errors::NotFoundError + { + valid: false, + errors: ['Virtual account not found'] + } + rescue ZaiPayment::Errors::ApiError => e + { + valid: false, + errors: ["API Error: #{e.message}"] + } + end +end + +# Usage +result = validate_virtual_account('46deb476-c1a6-41eb-8eb7-26a695bbe5bc') + +if result[:valid] + puts "✓ Virtual account is valid" + puts "Payment Details:" + puts " BSB: #{result[:payment_details][:bsb]}" + puts " Account: #{result[:payment_details][:account_number]}" + puts " Name: #{result[:payment_details][:account_name]}" +else + puts "✗ Virtual account validation failed:" + result[:errors].each { |error| puts " - #{error}" } +end +``` + +### Example 6: Compare Multiple Virtual Accounts + +Retrieve and compare multiple virtual accounts. + +```ruby +virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + +account_ids = [ + '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' +] + +puts "Virtual Account Comparison" +puts "=" * 80 + +account_ids.each do |account_id| + begin + response = virtual_accounts.show(account_id) + + if response.success? + account = response.data + puts "\n#{account['account_name']}" + puts " ID: #{account_id[0..7]}..." + puts " Status: #{account['status']}" + puts " BSB: #{account['routing_number']} | Account: #{account['account_number']}" + puts " Created: #{Date.parse(account['created_at']).strftime('%Y-%m-%d')}" + end + rescue ZaiPayment::Errors::NotFoundError + puts "\n#{account_id[0..7]}..." + puts " Status: Not Found" + end +end + +puts "\n#{'=' * 80}" +``` + +## Create Virtual Account Example + +### Example 1: Create a Basic Virtual Account + +Create a virtual account for a given wallet account with a name. + +```ruby +# Create virtual account +virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + +response = virtual_accounts.create( + 'ae07556e-22ef-11eb-adc1-0242ac120002', # wallet_account_id + account_name: 'Real Estate Agency X' +) + +if response.success? + virtual_account = response.data + puts "Virtual Account Created!" + puts "ID: #{virtual_account['id']}" + puts "Account Name: #{virtual_account['account_name']}" + puts "Routing Number: #{virtual_account['routing_number']}" + puts "Account Number: #{virtual_account['account_number']}" + puts "Currency: #{virtual_account['currency']}" + puts "Status: #{virtual_account['status']}" + puts "Created At: #{virtual_account['created_at']}" +else + puts "Failed to create virtual account" + puts "Error: #{response.error}" +end +``` + +### Example 2: Create Virtual Account with AKA Names + +Create a virtual account with alternative names (AKA names) for CoP lookups. + +```ruby +virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + +response = virtual_accounts.create( + 'ae07556e-22ef-11eb-adc1-0242ac120002', + account_name: 'Real Estate Agency X', + aka_names: ['Realestate agency X', 'RE Agency X', 'Agency X'] +) + +if response.success? + virtual_account = response.data + puts "Virtual Account Created!" + puts "ID: #{virtual_account['id']}" + puts "Account Name: #{virtual_account['account_name']}" + puts "AKA Names: #{virtual_account['aka_names'].join(', ')}" + puts "Routing Number: #{virtual_account['routing_number']}" + puts "Account Number: #{virtual_account['account_number']}" + puts "Merchant ID: #{virtual_account['merchant_id']}" +else + puts "Failed to create virtual account" + puts "Error: #{response.error}" +end +``` + +### Example 3: Using Convenience Method + +Use the convenience method from ZaiPayment module. + +```ruby +# Using convenience accessor +response = ZaiPayment.virtual_accounts.create( + 'ae07556e-22ef-11eb-adc1-0242ac120002', + account_name: 'Property Management Co', + aka_names: ['PropMgmt Co'] +) + +if response.success? + puts "Virtual Account ID: #{response.data['id']}" + puts "Status: #{response.data['status']}" +end +``` + +### Example 4: Complete Workflow + +Complete workflow showing user creation, wallet account reference, and virtual account creation. + +```ruby +begin + # Assume we already have a wallet account ID from previous steps + wallet_account_id = 'ae07556e-22ef-11eb-adc1-0242ac120002' + + # Create virtual account + virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + + response = virtual_accounts.create( + wallet_account_id, + account_name: 'Real Estate Trust Account', + aka_names: ['RE Trust', 'Trust Account'] + ) + + if response.success? + virtual_account = response.data + + # Store important information + virtual_account_id = virtual_account['id'] + routing_number = virtual_account['routing_number'] + account_number = virtual_account['account_number'] + + puts "✓ Virtual Account Created Successfully!" + puts "─" * 50 + puts "Virtual Account ID: #{virtual_account_id}" + puts "Wallet Account ID: #{virtual_account['wallet_account_id']}" + puts "User External ID: #{virtual_account['user_external_id']}" + puts "" + puts "Banking Details:" + puts " Account Name: #{virtual_account['account_name']}" + puts " Routing Number: #{routing_number}" + puts " Account Number: #{account_number}" + puts " Currency: #{virtual_account['currency']}" + puts "" + puts "Additional Information:" + puts " Status: #{virtual_account['status']}" + puts " Account Type: #{virtual_account['account_type']}" + puts " Full Legal Name: #{virtual_account['full_legal_account_name']}" + puts " AKA Names: #{virtual_account['aka_names'].join(', ')}" + puts " Merchant ID: #{virtual_account['merchant_id']}" + puts "─" * 50 + + # Now customers can transfer funds using these details + puts "\nCustomers can transfer funds to:" + puts " BSB: #{routing_number}" + puts " Account: #{account_number}" + puts " Name: #{virtual_account['account_name']}" + end + +rescue ZaiPayment::Errors::ValidationError => e + puts "Validation Error: #{e.message}" + puts "Please check your input parameters" +rescue ZaiPayment::Errors::NotFoundError => e + puts "Not Found: #{e.message}" + puts "The wallet account may not exist" +rescue ZaiPayment::Errors::UnauthorizedError => e + puts "Unauthorized: #{e.message}" + puts "Please check your API credentials" +rescue ZaiPayment::Errors::ApiError => e + puts "API Error: #{e.message}" +end +``` + +## Update AKA Names Example + +### Example 1: Update AKA Names for a Virtual Account + +Replace the list of AKA names for a virtual account. + +```ruby +# Update AKA names +virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + +response = virtual_accounts.update_aka_names( + '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + ['Updated Name 1', 'Updated Name 2', 'Updated Name 3'] +) + +if response.success? + account = response.data + puts "AKA Names Updated Successfully!" + puts "Virtual Account: #{account['account_name']}" + puts "New AKA Names:" + account['aka_names'].each_with_index do |aka_name, index| + puts " #{index + 1}. #{aka_name}" + end +else + puts "Failed to update AKA names" + puts "Error: #{response.error}" +end +``` + +### Example 2: Clear All AKA Names + +Remove all AKA names from a virtual account by passing an empty array. + +```ruby +virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + +response = virtual_accounts.update_aka_names( + '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + [] +) + +if response.success? + puts "All AKA names cleared successfully" + puts "Current AKA names: #{response.data['aka_names'].inspect}" +end +``` + +### Example 3: Set Single AKA Name + +Update to have just one AKA name. + +```ruby +virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + +response = virtual_accounts.update_aka_names( + '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + ['Preferred Name'] +) + +if response.success? + account = response.data + puts "✓ AKA names updated to single name" + puts " Account: #{account['account_name']}" + puts " AKA: #{account['aka_names'].first}" +end +``` + +### Example 4: Using Convenience Method + +Use the convenience method from ZaiPayment module. + +```ruby +# Using convenience accessor +response = ZaiPayment.virtual_accounts.update_aka_names( + '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + ['New Name 1', 'New Name 2'] +) + +if response.success? + puts "Updated AKA names: #{response.data['aka_names'].join(', ')}" +end +``` + +### Example 5: Update After Checking Current Names + +Check current AKA names before updating. + +```ruby +virtual_accounts = ZaiPayment::Resources::VirtualAccount.new +virtual_account_id = '46deb476-c1a6-41eb-8eb7-26a695bbe5bc' + +begin + # First, show current virtual account + show_response = virtual_accounts.show(virtual_account_id) + + if show_response.success? + current_account = show_response.data + + puts "Current AKA names:" + current_account['aka_names'].each { |name| puts " - #{name}" } + + # Update with new names + new_aka_names = [ + 'Real Estate Agency', + 'RE Agency', + 'Property Management' + ] + + update_response = virtual_accounts.update_aka_names(virtual_account_id, new_aka_names) + + if update_response.success? + puts "\n✓ Successfully updated AKA names" + puts "New AKA names:" + update_response.data['aka_names'].each { |name| puts " - #{name}" } + end + end + +rescue ZaiPayment::Errors::ValidationError => e + puts "Validation Error: #{e.message}" +rescue ZaiPayment::Errors::ApiError => e + puts "API Error: #{e.message}" +end +``` + +### Example 6: Bulk Update with Validation + +Update AKA names with pre-validation and error handling. + +```ruby +def safely_update_aka_names(virtual_account_id, new_aka_names) + # Pre-validate + errors = [] + errors << "aka_names must be an array" unless new_aka_names.is_a?(Array) + errors << "Maximum 3 AKA names allowed" if new_aka_names.length > 3 + errors << "AKA names cannot be empty strings" if new_aka_names.any? { |name| name.to_s.strip.empty? } + + if errors.any? + return { + success: false, + errors: errors + } + end + + virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + + begin + response = virtual_accounts.update_aka_names(virtual_account_id, new_aka_names) + + { + success: true, + account: response.data, + aka_names: response.data['aka_names'] + } + rescue ZaiPayment::Errors::ValidationError => e + { + success: false, + errors: [e.message] + } + rescue ZaiPayment::Errors::NotFoundError => e + { + success: false, + errors: ['Virtual account not found'] + } + rescue ZaiPayment::Errors::ApiError => e + { + success: false, + errors: ["API Error: #{e.message}"] + } + end +end + +# Usage +result = safely_update_aka_names( + '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + ['Agency A', 'Agency B'] +) + +if result[:success] + puts "✓ AKA names updated successfully" + puts "New names: #{result[:aka_names].join(', ')}" +else + puts "✗ Update failed:" + result[:errors].each { |error| puts " - #{error}" } +end +``` + +## Update Account Name Example + +### Example 1: Update Account Name for a Virtual Account + +Change the name of a virtual account. + +```ruby +# Update account name +virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + +response = virtual_accounts.update_account_name( + '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + 'New Real Estate Agency Name' +) + +if response.success? + account = response.data + puts "Account Name Updated Successfully!" + puts "Virtual Account ID: #{account['id']}" + puts "New Account Name: #{account['account_name']}" + puts "Status: #{account['status']}" +else + puts "Failed to update account name" + puts "Error: #{response.error}" +end +``` + +### Example 2: Update After Business Name Change + +Update account name after a business rebranding or name change. + +```ruby +virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + +begin + # Show current account first + show_response = virtual_accounts.show('46deb476-c1a6-41eb-8eb7-26a695bbe5bc') + + if show_response.success? + old_name = show_response.data['account_name'] + puts "Current account name: #{old_name}" + + # Update to new name + new_name = 'Premium Real Estate Partners' + update_response = virtual_accounts.update_account_name( + '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + new_name + ) + + if update_response.success? + puts "✓ Successfully updated account name" + puts " From: #{old_name}" + puts " To: #{update_response.data['account_name']}" + end + end + +rescue ZaiPayment::Errors::ValidationError => e + puts "Validation Error: #{e.message}" +rescue ZaiPayment::Errors::ApiError => e + puts "API Error: #{e.message}" +end +``` + +### Example 3: Using Convenience Method + +Use the convenience method from ZaiPayment module. + +```ruby +# Using convenience accessor +response = ZaiPayment.virtual_accounts.update_account_name( + '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + 'Updated Property Management Co' +) + +if response.success? + puts "Updated account name: #{response.data['account_name']}" +end +``` + +### Example 4: Update with Maximum Length Name + +Update with a name at the maximum allowed length (140 characters). + +```ruby +virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + +# Create a name at exactly 140 characters +long_name = 'A' * 140 + +response = virtual_accounts.update_account_name( + '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + long_name +) + +if response.success? + account = response.data + puts "✓ Account name updated" + puts " Length: #{account['account_name'].length} characters" + puts " Name: #{account['account_name'][0..50]}..." # Show first 50 chars +end +``` + +### Example 5: Validate Before Updating + +Pre-validate the account name before making the API call. + +```ruby +def safely_update_account_name(virtual_account_id, new_account_name) + # Pre-validate + errors = [] + + if new_account_name.nil? || new_account_name.strip.empty? + errors << "Account name cannot be blank" + elsif new_account_name.length > 140 + errors << "Account name must be 140 characters or less (currently #{new_account_name.length})" + end + + if errors.any? + return { + success: false, + errors: errors + } + end + + virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + + begin + response = virtual_accounts.update_account_name(virtual_account_id, new_account_name) + + { + success: true, + account: response.data, + account_name: response.data['account_name'] + } + rescue ZaiPayment::Errors::ValidationError => e + { + success: false, + errors: [e.message] + } + rescue ZaiPayment::Errors::NotFoundError => e + { + success: false, + errors: ['Virtual account not found'] + } + rescue ZaiPayment::Errors::ApiError => e + { + success: false, + errors: ["API Error: #{e.message}"] + } + end +end + +# Usage +result = safely_update_account_name( + '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + 'New Business Name' +) + +if result[:success] + puts "✓ Account name updated successfully" + puts "New name: #{result[:account_name]}" +else + puts "✗ Update failed:" + result[:errors].each { |error| puts " - #{error}" } +end +``` + +### Example 6: Update Multiple Virtual Accounts + +Update account names for multiple virtual accounts in bulk. + +```ruby +virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + +updates = [ + { id: '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', name: 'Property A Trust' }, + { id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', name: 'Property B Trust' } +] + +results = updates.map do |update| + begin + response = virtual_accounts.update_account_name(update[:id], update[:name]) + + { + id: update[:id], + success: true, + new_name: response.data['account_name'] + } + rescue ZaiPayment::Errors::ApiError => e + { + id: update[:id], + success: false, + error: e.message + } + end +end + +# Display results +results.each do |result| + if result[:success] + puts "✓ #{result[:id][0..7]}... → #{result[:new_name]}" + else + puts "✗ #{result[:id][0..7]}... → #{result[:error]}" + end +end + +successes = results.count { |r| r[:success] } +puts "\nUpdated #{successes} out of #{results.length} virtual accounts" +``` + +## Update Status Example + +### Example 1: Close a Virtual Account + +Close a virtual account by setting its status to 'closed'. This is an asynchronous operation. + +```ruby +virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + +begin + response = virtual_accounts.update_status( + '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + 'closed' + ) + + if response.success? + puts "Virtual account closure initiated" + puts "ID: #{response.data['id']}" + puts "Message: #{response.data['message']}" + puts "Link: #{response.data['links']['self']}" + puts "\nNote: The status update is being processed asynchronously." + puts "Use the show method to check the current status." + end + +rescue ZaiPayment::Errors::NotFoundError => e + puts "Virtual account not found: #{e.message}" +rescue ZaiPayment::Errors::ValidationError => e + puts "Validation error: #{e.message}" +rescue ZaiPayment::Errors::ApiError => e + puts "API Error: #{e.message}" +end +``` + +### Example 2: Close and Verify Status + +Close a virtual account and verify the status change. + +```ruby +virtual_accounts = ZaiPayment::Resources::VirtualAccount.new +virtual_account_id = '46deb476-c1a6-41eb-8eb7-26a695bbe5bc' + +begin + # Get current status + current_response = virtual_accounts.show(virtual_account_id) + current_status = current_response.data['status'] + + puts "Current status: #{current_status}" + + if current_status == 'closed' + puts "Virtual account is already closed" + elsif current_status == 'pending_activation' + puts "Virtual account is still pending activation. Cannot close yet." + else + # Close the account + close_response = virtual_accounts.update_status(virtual_account_id, 'closed') + + if close_response.success? + puts "✓ Closure request submitted successfully" + puts "Message: #{close_response.data['message']}" + + # Wait a moment for processing + sleep(2) + + # Check new status + updated_response = virtual_accounts.show(virtual_account_id) + new_status = updated_response.data['status'] + + puts "\nUpdated status: #{new_status}" + puts "Status changed: #{current_status} → #{new_status}" + end + end + +rescue ZaiPayment::Errors::ApiError => e + puts "Error: #{e.message}" +end +``` + +### Example 3: Close Multiple Virtual Accounts + +Close multiple virtual accounts in batch. + +```ruby +virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + +account_ids_to_close = [ + '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + 'cccccccc-dddd-eeee-ffff-000000000000' +] + +results = [] + +puts "Closing #{account_ids_to_close.length} virtual accounts..." +puts "─" * 60 + +account_ids_to_close.each_with_index do |account_id, index| + begin + response = virtual_accounts.update_status(account_id, 'closed') + + if response.success? + results << { + id: account_id, + success: true, + message: response.data['message'] + } + puts "✓ Account #{index + 1}: #{account_id[0..7]}... - Closure initiated" + end + + rescue ZaiPayment::Errors::NotFoundError => e + results << { id: account_id, success: false, error: 'Not found' } + puts "✗ Account #{index + 1}: #{account_id[0..7]}... - Not found" + rescue ZaiPayment::Errors::ApiError => e + results << { id: account_id, success: false, error: e.message } + puts "✗ Account #{index + 1}: #{account_id[0..7]}... - #{e.message}" + end +end + +puts "─" * 60 +successes = results.count { |r| r[:success] } +failures = results.count { |r| !r[:success] } + +puts "\nResults:" +puts " Successful closures: #{successes}" +puts " Failed closures: #{failures}" +``` + +### Example 4: Safe Close with Confirmation + +Close a virtual account with additional safety checks. + +```ruby +def close_virtual_account_safely(virtual_account_id) + virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + + begin + # First, retrieve the account details + account_response = virtual_accounts.show(virtual_account_id) + account = account_response.data + + puts "Virtual Account Details:" + puts " ID: #{account['id']}" + puts " Account Name: #{account['account_name']}" + puts " BSB: #{account['routing_number']}" + puts " Account Number: #{account['account_number']}" + puts " Current Status: #{account['status']}" + puts " Created: #{account['created_at']}" + + # Check if already closed + if account['status'] == 'closed' + puts "\n⚠ Account is already closed." + return { success: false, reason: 'already_closed' } + end + + # Check if pending activation + if account['status'] == 'pending_activation' + puts "\n⚠ Account is still pending activation." + puts "Consider waiting for activation before closing." + return { success: false, reason: 'pending_activation' } + end + + # Proceed with closing + puts "\nProceeding to close account..." + close_response = virtual_accounts.update_status(virtual_account_id, 'closed') + + if close_response.success? + puts "✓ Account closure initiated successfully" + puts "Message: #{close_response.data['message']}" + + return { + success: true, + id: close_response.data['id'], + message: close_response.data['message'] + } + end + + rescue ZaiPayment::Errors::NotFoundError => e + puts "✗ Virtual account not found: #{virtual_account_id}" + return { success: false, reason: 'not_found', error: e.message } + rescue ZaiPayment::Errors::ValidationError => e + puts "✗ Validation error: #{e.message}" + return { success: false, reason: 'validation_error', error: e.message } + rescue ZaiPayment::Errors::ApiError => e + puts "✗ API Error: #{e.message}" + return { success: false, reason: 'api_error', error: e.message } + end +end + +# Usage +result = close_virtual_account_safely('46deb476-c1a6-41eb-8eb7-26a695bbe5bc') +puts "\nFinal result: #{result.inspect}" +``` + +### Example 5: Close with Status Polling + +Close an account and poll for status confirmation. + +```ruby +def close_and_wait_for_confirmation(virtual_account_id, max_attempts = 10, wait_seconds = 3) + virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + + begin + # Initiate closure + puts "Initiating closure for virtual account #{virtual_account_id[0..7]}..." + close_response = virtual_accounts.update_status(virtual_account_id, 'closed') + + unless close_response.success? + puts "✗ Failed to initiate closure" + return { success: false, reason: 'closure_failed' } + end + + puts "✓ Closure request accepted" + puts "Message: #{close_response.data['message']}" + puts "\nPolling for status confirmation..." + + # Poll for status + max_attempts.times do |attempt| + sleep(wait_seconds) + + show_response = virtual_accounts.show(virtual_account_id) + current_status = show_response.data['status'] + + puts " Attempt #{attempt + 1}/#{max_attempts}: Status = #{current_status}" + + if current_status == 'closed' + puts "\n✓ Account successfully closed!" + return { + success: true, + status: current_status, + attempts: attempt + 1, + elapsed_time: (attempt + 1) * wait_seconds + } + end + end + + puts "\n⚠ Timeout: Status not confirmed as 'closed' after #{max_attempts} attempts" + puts "The account may still be processing. Check again later." + + return { + success: false, + reason: 'timeout', + max_attempts: max_attempts, + elapsed_time: max_attempts * wait_seconds + } + + rescue ZaiPayment::Errors::ApiError => e + puts "✗ Error: #{e.message}" + return { success: false, reason: 'api_error', error: e.message } + end +end + +# Usage +result = close_and_wait_for_confirmation('46deb476-c1a6-41eb-8eb7-26a695bbe5bc') +puts "\nResult: #{result.inspect}" +``` + +### Example 6: Validate Status Before Closing + +Ensure only valid status transitions. + +```ruby +virtual_accounts = ZaiPayment::Resources::VirtualAccount.new +virtual_account_id = '46deb476-c1a6-41eb-8eb7-26a695bbe5bc' + +# Note: Only 'closed' is a valid status value for the update_status method +valid_status = 'closed' + +begin + # Attempt to use an invalid status (for demonstration) + invalid_status = 'active' + + begin + virtual_accounts.update_status(virtual_account_id, invalid_status) + rescue ZaiPayment::Errors::ValidationError => e + puts "Expected validation error for invalid status:" + puts " Error: #{e.message}" + puts " Only 'closed' is allowed as a status value" + end + + # Now use the correct status + puts "\nUsing valid status: '#{valid_status}'" + response = virtual_accounts.update_status(virtual_account_id, valid_status) + + if response.success? + puts "✓ Status update successful" + puts "Message: #{response.data['message']}" + end + +rescue ZaiPayment::Errors::ApiError => e + puts "API Error: #{e.message}" +end +``` + +## Common Patterns + +### Pattern 1: Validate Parameters Before Creating + +```ruby +def create_virtual_account(wallet_account_id, account_name, aka_names = []) + # Validate inputs + raise ArgumentError, 'wallet_account_id cannot be empty' if wallet_account_id.nil? || wallet_account_id.empty? + raise ArgumentError, 'account_name cannot be empty' if account_name.nil? || account_name.empty? + raise ArgumentError, 'account_name too long (max 140 chars)' if account_name.length > 140 + raise ArgumentError, 'too many aka_names (max 3)' if aka_names.length > 3 + + virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + + params = { account_name: account_name } + params[:aka_names] = aka_names unless aka_names.empty? + + response = virtual_accounts.create(wallet_account_id, **params) + + if response.success? + response.data + else + nil + end +end + +# Usage +virtual_account = create_virtual_account( + 'ae07556e-22ef-11eb-adc1-0242ac120002', + 'My Business Account', + ['Business', 'Company Account'] +) + +puts "Created: #{virtual_account['id']}" if virtual_account +``` + +### Pattern 2: Store Virtual Account Details + +```ruby +class VirtualAccountManager + attr_reader :virtual_accounts + + def initialize + @virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + end + + def create_and_store(wallet_account_id, account_name, aka_names = []) + response = virtual_accounts.create( + wallet_account_id, + account_name: account_name, + aka_names: aka_names + ) + + return nil unless response.success? + + virtual_account = response.data + + # Store in your database + store_in_database(virtual_account) + + virtual_account + end + + private + + def store_in_database(virtual_account) + # Example: Store in your application database + # VirtualAccountRecord.create!( + # external_id: virtual_account['id'], + # wallet_account_id: virtual_account['wallet_account_id'], + # routing_number: virtual_account['routing_number'], + # account_number: virtual_account['account_number'], + # account_name: virtual_account['account_name'], + # status: virtual_account['status'] + # ) + puts "Storing virtual account #{virtual_account['id']} in database..." + end +end + +# Usage +manager = VirtualAccountManager.new +virtual_account = manager.create_and_store( + 'ae07556e-22ef-11eb-adc1-0242ac120002', + 'Client Trust Account' +) +``` + +### Pattern 3: Handle Different Response Scenarios + +```ruby +def create_virtual_account_with_handling(wallet_account_id, account_name, aka_names = []) + virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + + begin + response = virtual_accounts.create( + wallet_account_id, + account_name: account_name, + aka_names: aka_names + ) + + { + success: true, + virtual_account: response.data, + message: 'Virtual account created successfully' + } + rescue ZaiPayment::Errors::ValidationError => e + { + success: false, + error: 'validation_error', + message: e.message + } + rescue ZaiPayment::Errors::NotFoundError => e + { + success: false, + error: 'not_found', + message: 'Wallet account not found' + } + rescue ZaiPayment::Errors::BadRequestError => e + { + success: false, + error: 'bad_request', + message: e.message + } + rescue ZaiPayment::Errors::ApiError => e + { + success: false, + error: 'api_error', + message: e.message + } + end +end + +# Usage +result = create_virtual_account_with_handling( + 'ae07556e-22ef-11eb-adc1-0242ac120002', + 'Test Account' +) + +if result[:success] + puts "Success! Virtual Account ID: #{result[:virtual_account]['id']}" +else + puts "Error (#{result[:error]}): #{result[:message]}" +end +``` + +### Pattern 4: Batch Virtual Account Creation + +```ruby +def create_multiple_virtual_accounts(wallet_account_id, account_configs) + virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + results = [] + + account_configs.each do |config| + begin + response = virtual_accounts.create( + wallet_account_id, + account_name: config[:account_name], + aka_names: config[:aka_names] || [] + ) + + if response.success? + results << { + success: true, + name: config[:account_name], + virtual_account: response.data + } + else + results << { + success: false, + name: config[:account_name], + error: 'Creation failed' + } + end + rescue ZaiPayment::Errors::ApiError => e + results << { + success: false, + name: config[:account_name], + error: e.message + } + end + + # Be nice to the API - small delay between requests + sleep(0.5) + end + + results +end + +# Usage +configs = [ + { account_name: 'Property 123 Trust', aka_names: ['Prop 123'] }, + { account_name: 'Property 456 Trust', aka_names: ['Prop 456'] }, + { account_name: 'Property 789 Trust', aka_names: ['Prop 789'] } +] + +results = create_multiple_virtual_accounts( + 'ae07556e-22ef-11eb-adc1-0242ac120002', + configs +) + +successes = results.count { |r| r[:success] } +puts "Created #{successes} out of #{results.length} virtual accounts" + +results.each do |result| + if result[:success] + puts "✓ #{result[:name]}: #{result[:virtual_account]['id']}" + else + puts "✗ #{result[:name]}: #{result[:error]}" + end +end +``` + +## Error Handling + +### Common Errors and Solutions + +```ruby +begin + response = ZaiPayment.virtual_accounts.create( + wallet_account_id, + account_name: 'Test Account' + ) +rescue ZaiPayment::Errors::ValidationError => e + # Handle validation errors + # - wallet_account_id is blank + # - account_name is blank or too long + # - aka_names is not an array or has more than 3 items + puts "Validation Error: #{e.message}" +rescue ZaiPayment::Errors::NotFoundError => e + # Handle not found errors + # - wallet account does not exist + puts "Not Found: #{e.message}" +rescue ZaiPayment::Errors::UnauthorizedError => e + # Handle authentication errors + # - Invalid credentials + # - Expired token + puts "Unauthorized: #{e.message}" +rescue ZaiPayment::Errors::ForbiddenError => e + # Handle authorization errors + # - Insufficient permissions + puts "Forbidden: #{e.message}" +rescue ZaiPayment::Errors::BadRequestError => e + # Handle bad request errors + # - Invalid request format + puts "Bad Request: #{e.message}" +rescue ZaiPayment::Errors::TimeoutError => e + # Handle timeout errors + puts "Timeout: #{e.message}" +rescue ZaiPayment::Errors::ApiError => e + # Handle general API errors + puts "API Error: #{e.message}" +end +``` + +## Best Practices + +1. **Always validate input** before making API calls +2. **Handle errors gracefully** with proper error messages +3. **Store virtual account details** in your database for reference +4. **Use meaningful account names** that help identify the purpose +5. **Add AKA names** when you need multiple name variations for CoP lookups +6. **Monitor account status** after creation (should be `pending_activation`) +7. **Keep routing and account numbers secure** - they're like bank account details +8. **Use environment variables** for sensitive configuration +9. **Test in prelive environment** before using in production +10. **Implement proper logging** for audit trails + diff --git a/lib/zai_payment.rb b/lib/zai_payment.rb index 964cb8c..9d00c7a 100644 --- a/lib/zai_payment.rb +++ b/lib/zai_payment.rb @@ -18,6 +18,7 @@ require_relative 'zai_payment/resources/bpay_account' require_relative 'zai_payment/resources/batch_transaction' require_relative 'zai_payment/resources/wallet_account' +require_relative 'zai_payment/resources/virtual_account' module ZaiPayment class << self @@ -81,5 +82,10 @@ def batch_transactions def wallet_accounts @wallet_accounts ||= Resources::WalletAccount.new(client: Client.new(base_endpoint: :core_base)) end + + # @return [ZaiPayment::Resources::VirtualAccount] virtual_account resource instance + def virtual_accounts + @virtual_accounts ||= Resources::VirtualAccount.new + end end end diff --git a/lib/zai_payment/resources/virtual_account.rb b/lib/zai_payment/resources/virtual_account.rb new file mode 100644 index 0000000..1b16df9 --- /dev/null +++ b/lib/zai_payment/resources/virtual_account.rb @@ -0,0 +1,212 @@ +# frozen_string_literal: true + +module ZaiPayment + module Resources + # VirtualAccount resource for managing Zai virtual accounts + # + # @see https://developer.hellozai.com/reference/createvirtualaccount + class VirtualAccount + attr_reader :client + + # Map of attribute keys to API field names for create + CREATE_FIELD_MAPPING = { + account_name: :account_name, + aka_names: :aka_names + }.freeze + + def initialize(client: nil) + @client = client || Client.new(base_endpoint: :va_base) + end + + # List Virtual Accounts for a given Wallet Account + # + # @param wallet_account_id [String] the wallet account ID + # @return [Response] the API response containing array of virtual accounts + # + # @example List virtual accounts + # virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + # response = virtual_accounts.list('ae07556e-22ef-11eb-adc1-0242ac120002') + # response.data # => [{"id" => "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", ...}, ...] + # response.meta # => {"total" => 2} + # + # @see https://developer.hellozai.com/reference + def list(wallet_account_id) + validate_id!(wallet_account_id, 'wallet_account_id') + client.get("/wallet_accounts/#{wallet_account_id}/virtual_accounts") + end + + # Show a specific Virtual Account + # + # @param virtual_account_id [String] the virtual account ID + # @return [Response] the API response containing virtual account details + # + # @example Get virtual account details + # virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + # response = virtual_accounts.show('46deb476-c1a6-41eb-8eb7-26a695bbe5bc') + # response.data # => {"id" => "46deb476-c1a6-41eb-8eb7-26a695bbe5bc", ...} + # + # @see https://developer.hellozai.com/reference/showvirtualaccount + def show(virtual_account_id) + validate_id!(virtual_account_id, 'virtual_account_id') + client.get("/virtual_accounts/#{virtual_account_id}") + end + + # Create a Virtual Account for a given Wallet Account + # + # @param wallet_account_id [String] the wallet account ID + # @param attributes [Hash] virtual account attributes + # @option attributes [String] :account_name A name given for the Virtual Account (max 140 chars) + # @option attributes [Array] :aka_names A list of AKA Names (0 to 3 items) + # @return [Response] the API response containing virtual account details + # + # @example Create a virtual account + # virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + # response = virtual_accounts.create( + # 'ae07556e-22ef-11eb-adc1-0242ac120002', + # account_name: 'Real Estate Agency X', + # aka_names: ['Realestate agency X'] + # ) + # response.data # => {"id" => "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", ...} + # + # @see https://developer.hellozai.com/reference/listvirtualaccountbywalletaccount + def create(wallet_account_id, **attributes) + validate_id!(wallet_account_id, 'wallet_account_id') + validate_create_attributes!(attributes) + + body = build_create_body(attributes) + client.post("/wallet_accounts/#{wallet_account_id}/virtual_accounts", body: body) + end + + # Update AKA Names for a Virtual Account + # + # Replace the list of AKA Names for a Virtual Account. This completely replaces + # the existing AKA names with the new list provided. + # + # @param virtual_account_id [String] the virtual account ID + # @param aka_names [Array] array of AKA names (0 to 3 items) + # @return [Response] the API response containing updated virtual account details + # + # @example Update AKA names + # virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + # response = virtual_accounts.update_aka_names( + # '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + # ['New Name 1', 'New Name 2'] + # ) + # response.data # => {"id" => "46deb476-c1a6-41eb-8eb7-26a695bbe5bc", ...} + # + # @see https://developer.hellozai.com/reference/updatevirtualaccountakaname + def update_aka_names(virtual_account_id, aka_names) + validate_id!(virtual_account_id, 'virtual_account_id') + validate_aka_names!(aka_names) + + body = { aka_names: aka_names } + client.patch("/virtual_accounts/#{virtual_account_id}/aka_names", body: body) + end + + # Update Account Name for a Virtual Account + # + # Change the name of a Virtual Account. This is used in CoP lookups. + # + # @param virtual_account_id [String] the virtual account ID + # @param account_name [String] the new account name (max 140 characters) + # @return [Response] the API response containing updated virtual account details + # + # @example Update account name + # virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + # response = virtual_accounts.update_account_name( + # '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + # 'New Real Estate Agency Name' + # ) + # response.data # => {"id" => "46deb476-c1a6-41eb-8eb7-26a695bbe5bc", ...} + # + # @see https://developer.hellozai.com/reference/updatevirtualaccountaccountname + def update_account_name(virtual_account_id, account_name) + validate_id!(virtual_account_id, 'virtual_account_id') + validate_account_name!(account_name) + + body = { account_name: account_name } + client.patch("/virtual_accounts/#{virtual_account_id}/account_name", body: body) + end + + # Update Status for a Virtual Account + # + # Close a Virtual Account. Once closed, the account cannot be reopened and will + # no longer be able to receive payments. This operation is asynchronous and returns + # a 202 Accepted response. + # + # @param virtual_account_id [String] the virtual account ID + # @param status [String] the new status (must be 'closed') + # @return [Response] the API response containing the operation status + # + # @example Close a virtual account + # virtual_accounts = ZaiPayment::Resources::VirtualAccount.new + # response = virtual_accounts.update_status( + # '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + # 'closed' + # ) + # response.data # => {"id" => "46deb476-c1a6-41eb-8eb7-26a695bbe5bc", "message" => "...", ...} + # + # @see https://developer.hellozai.com/reference/updatevirtualaccount + def update_status(virtual_account_id, status) + validate_id!(virtual_account_id, 'virtual_account_id') + validate_status!(status) + + body = { status: status } + client.patch("/virtual_accounts/#{virtual_account_id}/status", body: body) + end + + private + + def validate_id!(value, field_name) + return unless value.nil? || value.to_s.strip.empty? + + raise Errors::ValidationError, "#{field_name} is required and cannot be blank" + end + + def validate_create_attributes!(attributes) + # Only validate if attributes are actually provided (not nil) + validate_account_name!(attributes[:account_name]) if attributes.key?(:account_name) + validate_aka_names!(attributes[:aka_names]) if attributes.key?(:aka_names) + end + + def validate_account_name!(account_name) + if account_name.nil? || account_name.to_s.strip.empty? + raise Errors::ValidationError, 'account_name cannot be blank' + end + + return unless account_name.to_s.length > 140 + + raise Errors::ValidationError, 'account_name must be 140 characters or less' + end + + def validate_aka_names!(aka_names) + raise Errors::ValidationError, 'aka_names must be an array' unless aka_names.is_a?(Array) + + return unless aka_names.length > 3 + + raise Errors::ValidationError, 'aka_names must contain between 0 and 3 items' + end + + def validate_status!(status) + raise Errors::ValidationError, 'status cannot be blank' if status.nil? || status.to_s.strip.empty? + + return if status.to_s == 'closed' + + raise Errors::ValidationError, "status must be 'closed', got '#{status}'" + end + + def build_create_body(attributes) + body = {} + + attributes.each do |key, value| + next if value.nil? || (value.respond_to?(:empty?) && value.empty?) + + api_field = CREATE_FIELD_MAPPING[key] + body[api_field] = value if api_field + end + + body + end + end + end +end diff --git a/lib/zai_payment/response.rb b/lib/zai_payment/response.rb index b809a80..d0a40fb 100644 --- a/lib/zai_payment/response.rb +++ b/lib/zai_payment/response.rb @@ -8,7 +8,7 @@ class Response RESPONSE_DATA_KEYS = %w[ webhooks users items fees transactions batch_transactions batches bpay_accounts bank_accounts card_accounts - wallet_accounts routing_number disbursements + wallet_accounts virtual_accounts routing_number disbursements ].freeze def initialize(faraday_response) diff --git a/spec/zai_payment/resources/virtual_account_spec.rb b/spec/zai_payment/resources/virtual_account_spec.rb new file mode 100644 index 0000000..93cf31e --- /dev/null +++ b/spec/zai_payment/resources/virtual_account_spec.rb @@ -0,0 +1,1103 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ZaiPayment::Resources::VirtualAccount do + let(:stubs) { Faraday::Adapter::Test::Stubs.new } + let(:virtual_account_resource) { described_class.new(client: test_client) } + + let(:test_client) do + config = ZaiPayment::Config.new.tap do |c| + c.environment = :prelive + c.client_id = 'test_client_id' + c.client_secret = 'test_client_secret' + c.scope = 'test_scope' + end + + token_provider = instance_double(ZaiPayment::Auth::TokenProvider, bearer_token: 'Bearer test_token') + client = ZaiPayment::Client.new(config: config, token_provider: token_provider, base_endpoint: :va_base) + + test_connection = Faraday.new do |faraday| + faraday.request :json + faraday.response :json, content_type: /\bjson$/ + faraday.adapter :test, stubs + end + + allow(client).to receive(:connection).and_return(test_connection) + client + end + + after do + stubs.verify_stubbed_calls + end + + describe '#list' do + let(:virtual_accounts_list_data) do + { + 'virtual_accounts' => [ + { + 'id' => 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + 'routing_number' => '123456', + 'account_number' => '100000017', + 'wallet_account_id' => 'ae07556e-22ef-11eb-adc1-0242ac120002', + 'user_external_id' => 'ca12346e-22ef-11eb-adc1-0242ac120002', + 'currency' => 'AUD', + 'status' => 'active', + 'created_at' => '2020-04-27T20:28:22.378Z', + 'updated_at' => '2020-04-27T20:28:22.378Z', + 'account_type' => 'NIND', + 'full_legal_account_name' => 'Prop Tech Marketplace', + 'account_name' => 'Real Estate Agency X', + 'aka_names' => ['Realestate agency X'], + 'merchant_id' => '46deb476c1a641eb8eb726a695bbe5bc' + }, + { + 'id' => 'aaaaaaaa-cccc-dddd-eeee-ffffffffffff', + 'routing_number' => '123456', + 'account_number' => '100000025', + 'currency' => 'AUD', + 'wallet_account_id' => 'ae07556e-22ef-11eb-adc1-0242ac120002', + 'user_external_id' => 'ca12346e-22ef-11eb-adc1-0242ac120002', + 'status' => 'pending_activation', + 'created_at' => '2020-04-27T20:28:22.378Z', + 'updated_at' => '2020-04-27T20:28:22.378Z', + 'account_type' => 'NIND', + 'full_legal_account_name' => 'Prop Tech Marketplace', + 'account_name' => 'Real Estate Agency X', + 'aka_names' => ['Realestate agency X'], + 'merchant_id' => '46deb476c1a641eb8eb726a695bbe5bc' + } + ], + 'meta' => { + 'total' => 2 + } + } + end + + context 'when virtual accounts exist' do + before do + stubs.get('/wallet_accounts/ae07556e-22ef-11eb-adc1-0242ac120002/virtual_accounts') do + [200, { 'Content-Type' => 'application/json' }, virtual_accounts_list_data] + end + end + + it 'returns the correct response type and list of virtual accounts' do + response = virtual_account_resource.list('ae07556e-22ef-11eb-adc1-0242ac120002') + + expect(response).to be_a(ZaiPayment::Response) + expect(response.success?).to be true + expect(response.data).to be_an(Array) + end + + it 'returns correct number of virtual accounts' do + response = virtual_account_resource.list('ae07556e-22ef-11eb-adc1-0242ac120002') + + expect(response.data.length).to eq(2) + end + + it 'returns virtual accounts with correct data' do + response = virtual_account_resource.list('ae07556e-22ef-11eb-adc1-0242ac120002') + + first_account = response.data[0] + expect(first_account['id']).to eq('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee') + expect(first_account['status']).to eq('active') + expect(first_account['account_name']).to eq('Real Estate Agency X') + end + + it 'includes meta information' do + response = virtual_account_resource.list('ae07556e-22ef-11eb-adc1-0242ac120002') + + expect(response.meta).to be_a(Hash) + expect(response.meta['total']).to eq(2) + end + end + + context 'when wallet account has no virtual accounts' do + before do + stubs.get('/wallet_accounts/ae07556e-22ef-11eb-adc1-0242ac120002/virtual_accounts') do + [200, { 'Content-Type' => 'application/json' }, { 'virtual_accounts' => [], 'meta' => { 'total' => 0 } }] + end + end + + it 'returns empty array' do + response = virtual_account_resource.list('ae07556e-22ef-11eb-adc1-0242ac120002') + + expect(response).to be_a(ZaiPayment::Response) + expect(response.success?).to be true + expect(response.data).to eq([]) + end + + it 'returns correct meta total for empty list' do + response = virtual_account_resource.list('ae07556e-22ef-11eb-adc1-0242ac120002') + + expect(response.meta['total']).to eq(0) + end + end + + context 'when wallet_account_id is blank' do + it 'raises a ValidationError for empty string' do + expect { virtual_account_resource.list('') } + .to raise_error(ZaiPayment::Errors::ValidationError, /wallet_account_id/) + end + + it 'raises a ValidationError for nil' do + expect { virtual_account_resource.list(nil) } + .to raise_error(ZaiPayment::Errors::ValidationError, /wallet_account_id/) + end + + it 'raises a ValidationError for whitespace only' do + expect { virtual_account_resource.list(' ') } + .to raise_error(ZaiPayment::Errors::ValidationError, /wallet_account_id/) + end + end + + context 'when wallet account does not exist' do + before do + stubs.get('/wallet_accounts/invalid_id/virtual_accounts') do + [404, { 'Content-Type' => 'application/json' }, { 'errors' => 'Not found' }] + end + end + + it 'raises a NotFoundError' do + expect { virtual_account_resource.list('invalid_id') } + .to raise_error(ZaiPayment::Errors::NotFoundError) + end + end + + context 'when API returns unauthorized' do + before do + stubs.get('/wallet_accounts/ae07556e-22ef-11eb-adc1-0242ac120002/virtual_accounts') do + [401, { 'Content-Type' => 'application/json' }, { 'errors' => 'Unauthorized' }] + end + end + + it 'raises an UnauthorizedError' do + expect { virtual_account_resource.list('ae07556e-22ef-11eb-adc1-0242ac120002') } + .to raise_error(ZaiPayment::Errors::UnauthorizedError) + end + end + + context 'when API returns forbidden' do + before do + stubs.get('/wallet_accounts/ae07556e-22ef-11eb-adc1-0242ac120002/virtual_accounts') do + [403, { 'Content-Type' => 'application/json' }, { 'errors' => 'Forbidden' }] + end + end + + it 'raises a ForbiddenError' do + expect { virtual_account_resource.list('ae07556e-22ef-11eb-adc1-0242ac120002') } + .to raise_error(ZaiPayment::Errors::ForbiddenError) + end + end + + context 'when API returns bad request' do + before do + stubs.get('/wallet_accounts/ae07556e-22ef-11eb-adc1-0242ac120002/virtual_accounts') do + [400, { 'Content-Type' => 'application/json' }, { 'errors' => 'Bad request' }] + end + end + + it 'raises a BadRequestError' do + expect { virtual_account_resource.list('ae07556e-22ef-11eb-adc1-0242ac120002') } + .to raise_error(ZaiPayment::Errors::BadRequestError) + end + end + end + + describe '#show' do + let(:virtual_account_data) do + { + 'virtual_accounts' => { + 'id' => '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + 'routing_number' => '123456', + 'account_number' => '100000017', + 'currency' => 'AUD', + 'user_external_id' => '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + 'wallet_account_id' => '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + 'status' => 'active', + 'created_at' => '2020-04-27T20:28:22.378Z', + 'updated_at' => '2020-04-27T20:28:22.378Z', + 'account_type' => 'NIND', + 'full_legal_account_name' => 'Prop Tech Marketplace', + 'account_name' => 'Real Estate Agency X', + 'aka_names' => ['Realestate Agency X', 'Realestate Agency X of PropTech Marketplace'], + 'merchant_id' => '46deb476c1a641eb8eb726a695bbe5bc' + } + } + end + + context 'when virtual account exists' do + before do + stubs.get('/virtual_accounts/46deb476-c1a6-41eb-8eb7-26a695bbe5bc') do + [200, { 'Content-Type' => 'application/json' }, virtual_account_data] + end + end + + it 'returns the correct response type and virtual account details' do + response = virtual_account_resource.show('46deb476-c1a6-41eb-8eb7-26a695bbe5bc') + + expect(response).to be_a(ZaiPayment::Response) + expect(response.success?).to be true + expect(response.data['id']).to eq('46deb476-c1a6-41eb-8eb7-26a695bbe5bc') + end + + it 'returns virtual account with correct details' do + response = virtual_account_resource.show('46deb476-c1a6-41eb-8eb7-26a695bbe5bc') + + expect(response.data['account_name']).to eq('Real Estate Agency X') + expect(response.data['status']).to eq('active') + expect(response.data['currency']).to eq('AUD') + end + + it 'includes banking details' do + response = virtual_account_resource.show('46deb476-c1a6-41eb-8eb7-26a695bbe5bc') + + expect(response.data['routing_number']).to eq('123456') + expect(response.data['account_number']).to eq('100000017') + end + + it 'includes aka_names array' do + response = virtual_account_resource.show('46deb476-c1a6-41eb-8eb7-26a695bbe5bc') + + expect(response.data['aka_names']).to be_an(Array) + expect(response.data['aka_names'].length).to eq(2) + end + end + + context 'when virtual_account_id is blank' do + it 'raises a ValidationError for empty string' do + expect { virtual_account_resource.show('') } + .to raise_error(ZaiPayment::Errors::ValidationError, /virtual_account_id/) + end + + it 'raises a ValidationError for nil' do + expect { virtual_account_resource.show(nil) } + .to raise_error(ZaiPayment::Errors::ValidationError, /virtual_account_id/) + end + + it 'raises a ValidationError for whitespace only' do + expect { virtual_account_resource.show(' ') } + .to raise_error(ZaiPayment::Errors::ValidationError, /virtual_account_id/) + end + end + + context 'when virtual account does not exist' do + before do + stubs.get('/virtual_accounts/invalid_id') do + [404, { 'Content-Type' => 'application/json' }, { 'errors' => 'Not found' }] + end + end + + it 'raises a NotFoundError' do + expect { virtual_account_resource.show('invalid_id') } + .to raise_error(ZaiPayment::Errors::NotFoundError) + end + end + + context 'when API returns bad request' do + before do + stubs.get('/virtual_accounts/46deb476-c1a6-41eb-8eb7-26a695bbe5bc') do + [400, { 'Content-Type' => 'application/json' }, { 'errors' => 'Bad request' }] + end + end + + it 'raises a BadRequestError' do + expect { virtual_account_resource.show('46deb476-c1a6-41eb-8eb7-26a695bbe5bc') } + .to raise_error(ZaiPayment::Errors::BadRequestError) + end + end + + context 'when API returns unauthorized' do + before do + stubs.get('/virtual_accounts/46deb476-c1a6-41eb-8eb7-26a695bbe5bc') do + [401, { 'Content-Type' => 'application/json' }, { 'errors' => 'Unauthorized' }] + end + end + + it 'raises an UnauthorizedError' do + expect { virtual_account_resource.show('46deb476-c1a6-41eb-8eb7-26a695bbe5bc') } + .to raise_error(ZaiPayment::Errors::UnauthorizedError) + end + end + + context 'when API returns forbidden' do + before do + stubs.get('/virtual_accounts/46deb476-c1a6-41eb-8eb7-26a695bbe5bc') do + [403, { 'Content-Type' => 'application/json' }, { 'errors' => 'Forbidden' }] + end + end + + it 'raises a ForbiddenError' do + expect { virtual_account_resource.show('46deb476-c1a6-41eb-8eb7-26a695bbe5bc') } + .to raise_error(ZaiPayment::Errors::ForbiddenError) + end + end + end + + describe '#update_aka_names' do + let(:updated_virtual_account_data) do + { + 'virtual_accounts' => { + 'id' => '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + 'routing_number' => '123456', + 'account_number' => '100000017', + 'currency' => 'AUD', + 'user_external_id' => '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + 'wallet_account_id' => '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + 'status' => 'active', + 'created_at' => '2020-04-27T20:28:22.378Z', + 'updated_at' => '2020-04-27T20:28:22.378Z', + 'account_type' => 'NIND', + 'full_legal_account_name' => 'Prop Tech Marketplace', + 'account_name' => 'Real Estate Agency X', + 'aka_names' => ['Updated Name 1', 'Updated Name 2'], + 'merchant_id' => '46deb476c1a641eb8eb726a695bbe5bc' + } + } + end + + context 'when successful' do + before do + stubs.patch('/virtual_accounts/46deb476-c1a6-41eb-8eb7-26a695bbe5bc/aka_names') do + [200, { 'Content-Type' => 'application/json' }, updated_virtual_account_data] + end + end + + it 'returns the correct response type and updates aka_names' do + response = virtual_account_resource.update_aka_names( + '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + ['Updated Name 1', 'Updated Name 2'] + ) + + expect(response).to be_a(ZaiPayment::Response) + expect(response.success?).to be true + expect(response.data['id']).to eq('46deb476-c1a6-41eb-8eb7-26a695bbe5bc') + end + + it 'returns updated aka_names' do + response = virtual_account_resource.update_aka_names( + '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + ['Updated Name 1', 'Updated Name 2'] + ) + + expect(response.data['aka_names']).to eq(['Updated Name 1', 'Updated Name 2']) + end + end + + context 'with empty aka_names array' do + before do + stubs.patch('/virtual_accounts/46deb476-c1a6-41eb-8eb7-26a695bbe5bc/aka_names') do + [200, { 'Content-Type' => 'application/json' }, + { 'virtual_accounts' => updated_virtual_account_data['virtual_accounts'].merge('aka_names' => []) }] + end + end + + it 'clears all aka_names' do + response = virtual_account_resource.update_aka_names( + '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + [] + ) + + expect(response).to be_a(ZaiPayment::Response) + expect(response.success?).to be true + expect(response.data['aka_names']).to eq([]) + end + end + + context 'with single aka_name' do + before do + stubs.patch('/virtual_accounts/46deb476-c1a6-41eb-8eb7-26a695bbe5bc/aka_names') do + updated_data = updated_virtual_account_data['virtual_accounts'].merge('aka_names' => ['Single Name']) + [200, { 'Content-Type' => 'application/json' }, { 'virtual_accounts' => updated_data }] + end + end + + it 'updates to single aka_name' do + response = virtual_account_resource.update_aka_names( + '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + ['Single Name'] + ) + + expect(response.data['aka_names']).to eq(['Single Name']) + end + end + + context 'with maximum 3 aka_names' do + before do + stubs.patch('/virtual_accounts/46deb476-c1a6-41eb-8eb7-26a695bbe5bc/aka_names') do + [200, { 'Content-Type' => 'application/json' }, + { 'virtual_accounts' => updated_virtual_account_data['virtual_accounts'] + .merge('aka_names' => ['Name 1', 'Name 2', 'Name 3']) }] + end + end + + it 'updates to 3 aka_names' do + response = virtual_account_resource.update_aka_names( + '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + ['Name 1', 'Name 2', 'Name 3'] + ) + + expect(response.data['aka_names'].length).to eq(3) + end + end + + context 'when virtual_account_id is blank' do + it 'raises a ValidationError for empty string' do + expect { virtual_account_resource.update_aka_names('', ['Name']) } + .to raise_error(ZaiPayment::Errors::ValidationError, /virtual_account_id/) + end + + it 'raises a ValidationError for nil' do + expect { virtual_account_resource.update_aka_names(nil, ['Name']) } + .to raise_error(ZaiPayment::Errors::ValidationError, /virtual_account_id/) + end + + it 'raises a ValidationError for whitespace only' do + expect { virtual_account_resource.update_aka_names(' ', ['Name']) } + .to raise_error(ZaiPayment::Errors::ValidationError, /virtual_account_id/) + end + end + + context 'when aka_names validation fails' do + it 'raises error for non-array aka_names' do + expect do + virtual_account_resource.update_aka_names('46deb476-c1a6-41eb-8eb7-26a695bbe5bc', 'not an array') + end.to raise_error(ZaiPayment::Errors::ValidationError, /aka_names must be an array/) + end + + it 'raises error for more than 3 aka_names' do + expect do + virtual_account_resource.update_aka_names( + '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + ['Name 1', 'Name 2', 'Name 3', 'Name 4'] + ) + end.to raise_error(ZaiPayment::Errors::ValidationError, /aka_names must contain between 0 and 3 items/) + end + end + + context 'when virtual account does not exist' do + before do + stubs.patch('/virtual_accounts/invalid_id/aka_names') do + [404, { 'Content-Type' => 'application/json' }, { 'errors' => 'Not found' }] + end + end + + it 'raises a NotFoundError' do + expect { virtual_account_resource.update_aka_names('invalid_id', ['Name']) } + .to raise_error(ZaiPayment::Errors::NotFoundError) + end + end + + context 'when API returns bad request' do + before do + stubs.patch('/virtual_accounts/46deb476-c1a6-41eb-8eb7-26a695bbe5bc/aka_names') do + [400, { 'Content-Type' => 'application/json' }, { 'errors' => 'Bad request' }] + end + end + + it 'raises a BadRequestError' do + expect { virtual_account_resource.update_aka_names('46deb476-c1a6-41eb-8eb7-26a695bbe5bc', ['Name']) } + .to raise_error(ZaiPayment::Errors::BadRequestError) + end + end + + context 'when API returns unauthorized' do + before do + stubs.patch('/virtual_accounts/46deb476-c1a6-41eb-8eb7-26a695bbe5bc/aka_names') do + [401, { 'Content-Type' => 'application/json' }, { 'errors' => 'Unauthorized' }] + end + end + + it 'raises an UnauthorizedError' do + expect { virtual_account_resource.update_aka_names('46deb476-c1a6-41eb-8eb7-26a695bbe5bc', ['Name']) } + .to raise_error(ZaiPayment::Errors::UnauthorizedError) + end + end + + context 'when API returns forbidden' do + before do + stubs.patch('/virtual_accounts/46deb476-c1a6-41eb-8eb7-26a695bbe5bc/aka_names') do + [403, { 'Content-Type' => 'application/json' }, { 'errors' => 'Forbidden' }] + end + end + + it 'raises a ForbiddenError' do + expect { virtual_account_resource.update_aka_names('46deb476-c1a6-41eb-8eb7-26a695bbe5bc', ['Name']) } + .to raise_error(ZaiPayment::Errors::ForbiddenError) + end + end + end + + describe '#update_account_name' do + let(:updated_account_data) do + { + 'virtual_accounts' => { + 'id' => '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + 'routing_number' => '123456', + 'account_number' => '100000017', + 'currency' => 'AUD', + 'user_external_id' => '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + 'wallet_account_id' => '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + 'status' => 'active', + 'created_at' => '2020-04-27T20:28:22.378Z', + 'updated_at' => '2020-04-27T20:28:22.378Z', + 'account_type' => 'NIND', + 'full_legal_account_name' => 'Prop Tech Marketplace', + 'account_name' => 'Updated Account Name', + 'aka_names' => ['Realestate Agency X'], + 'merchant_id' => '46deb476c1a641eb8eb726a695bbe5bc' + } + } + end + + context 'when successful' do + before do + stubs.patch('/virtual_accounts/46deb476-c1a6-41eb-8eb7-26a695bbe5bc/account_name') do + [202, { 'Content-Type' => 'application/json' }, updated_account_data] + end + end + + it 'returns the correct response type and updates account_name' do + response = virtual_account_resource.update_account_name( + '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + 'Updated Account Name' + ) + + expect(response).to be_a(ZaiPayment::Response) + expect(response.success?).to be true + expect(response.data['id']).to eq('46deb476-c1a6-41eb-8eb7-26a695bbe5bc') + end + + it 'returns updated account_name' do + response = virtual_account_resource.update_account_name( + '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + 'Updated Account Name' + ) + + expect(response.data['account_name']).to eq('Updated Account Name') + end + end + + context 'with exactly 140 characters' do + before do + stubs.patch('/virtual_accounts/46deb476-c1a6-41eb-8eb7-26a695bbe5bc/account_name') do + long_name = 'A' * 140 + updated_data = updated_account_data['virtual_accounts'].merge('account_name' => long_name) + [202, { 'Content-Type' => 'application/json' }, { 'virtual_accounts' => updated_data }] + end + end + + it 'accepts account_name with 140 characters' do + response = virtual_account_resource.update_account_name( + '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + 'A' * 140 + ) + + expect(response).to be_a(ZaiPayment::Response) + expect(response.success?).to be true + expect(response.data['account_name'].length).to eq(140) + end + end + + context 'when virtual_account_id is blank' do + it 'raises a ValidationError for empty string' do + expect { virtual_account_resource.update_account_name('', 'New Name') } + .to raise_error(ZaiPayment::Errors::ValidationError, /virtual_account_id/) + end + + it 'raises a ValidationError for nil' do + expect { virtual_account_resource.update_account_name(nil, 'New Name') } + .to raise_error(ZaiPayment::Errors::ValidationError, /virtual_account_id/) + end + + it 'raises a ValidationError for whitespace only' do + expect { virtual_account_resource.update_account_name(' ', 'New Name') } + .to raise_error(ZaiPayment::Errors::ValidationError, /virtual_account_id/) + end + end + + context 'when account_name validation fails' do + it 'raises error for blank account_name' do + expect do + virtual_account_resource.update_account_name('46deb476-c1a6-41eb-8eb7-26a695bbe5bc', '') + end.to raise_error(ZaiPayment::Errors::ValidationError, /account_name cannot be blank/) + end + + it 'raises error for nil account_name' do + expect do + virtual_account_resource.update_account_name('46deb476-c1a6-41eb-8eb7-26a695bbe5bc', nil) + end.to raise_error(ZaiPayment::Errors::ValidationError, /account_name cannot be blank/) + end + + it 'raises error for whitespace only account_name' do + expect do + virtual_account_resource.update_account_name('46deb476-c1a6-41eb-8eb7-26a695bbe5bc', ' ') + end.to raise_error(ZaiPayment::Errors::ValidationError, /account_name cannot be blank/) + end + + it 'raises error for account_name longer than 140 characters' do + long_name = 'A' * 141 + expect do + virtual_account_resource.update_account_name('46deb476-c1a6-41eb-8eb7-26a695bbe5bc', long_name) + end.to raise_error(ZaiPayment::Errors::ValidationError, /account_name must be 140 characters or less/) + end + end + + context 'when virtual account does not exist' do + before do + stubs.patch('/virtual_accounts/invalid_id/account_name') do + [404, { 'Content-Type' => 'application/json' }, { 'errors' => 'Not found' }] + end + end + + it 'raises a NotFoundError' do + expect { virtual_account_resource.update_account_name('invalid_id', 'New Name') } + .to raise_error(ZaiPayment::Errors::NotFoundError) + end + end + + context 'when API returns bad request' do + before do + stubs.patch('/virtual_accounts/46deb476-c1a6-41eb-8eb7-26a695bbe5bc/account_name') do + [400, { 'Content-Type' => 'application/json' }, { 'errors' => 'Bad request' }] + end + end + + it 'raises a BadRequestError' do + expect { virtual_account_resource.update_account_name('46deb476-c1a6-41eb-8eb7-26a695bbe5bc', 'New Name') } + .to raise_error(ZaiPayment::Errors::BadRequestError) + end + end + + context 'when API returns unauthorized' do + before do + stubs.patch('/virtual_accounts/46deb476-c1a6-41eb-8eb7-26a695bbe5bc/account_name') do + [401, { 'Content-Type' => 'application/json' }, { 'errors' => 'Unauthorized' }] + end + end + + it 'raises an UnauthorizedError' do + expect { virtual_account_resource.update_account_name('46deb476-c1a6-41eb-8eb7-26a695bbe5bc', 'New Name') } + .to raise_error(ZaiPayment::Errors::UnauthorizedError) + end + end + + context 'when API returns forbidden' do + before do + stubs.patch('/virtual_accounts/46deb476-c1a6-41eb-8eb7-26a695bbe5bc/account_name') do + [403, { 'Content-Type' => 'application/json' }, { 'errors' => 'Forbidden' }] + end + end + + it 'raises a ForbiddenError' do + expect { virtual_account_resource.update_account_name('46deb476-c1a6-41eb-8eb7-26a695bbe5bc', 'New Name') } + .to raise_error(ZaiPayment::Errors::ForbiddenError) + end + end + end + + describe '#update_status' do + let(:status_update_response_data) do + { + 'id' => '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + 'message' => 'Virtual Account update has been accepted for processing', + 'links' => { + 'self' => '/virtual_accounts/46deb476-c1a6-41eb-8eb7-26a695bbe5bc' + } + } + end + + context 'when successful' do + before do + stubs.patch('/virtual_accounts/46deb476-c1a6-41eb-8eb7-26a695bbe5bc/status') do + [202, { 'Content-Type' => 'application/json' }, status_update_response_data] + end + end + + it 'returns the correct response type' do + response = virtual_account_resource.update_status( + '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + 'closed' + ) + + expect(response).to be_a(ZaiPayment::Response) + expect(response.success?).to be true + end + + it 'returns the status update acceptance message' do + response = virtual_account_resource.update_status( + '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + 'closed' + ) + + expect(response.data['id']).to eq('46deb476-c1a6-41eb-8eb7-26a695bbe5bc') + expect(response.data['message']).to eq('Virtual Account update has been accepted for processing') + end + + it 'includes links in response' do + response = virtual_account_resource.update_status( + '46deb476-c1a6-41eb-8eb7-26a695bbe5bc', + 'closed' + ) + + expect(response.data['links']).to be_a(Hash) + expect(response.data['links']['self']).to eq('/virtual_accounts/46deb476-c1a6-41eb-8eb7-26a695bbe5bc') + end + end + + context 'when virtual_account_id is blank' do + it 'raises a ValidationError for empty string' do + expect { virtual_account_resource.update_status('', 'closed') } + .to raise_error(ZaiPayment::Errors::ValidationError, /virtual_account_id/) + end + + it 'raises a ValidationError for nil' do + expect { virtual_account_resource.update_status(nil, 'closed') } + .to raise_error(ZaiPayment::Errors::ValidationError, /virtual_account_id/) + end + + it 'raises a ValidationError for whitespace only' do + expect { virtual_account_resource.update_status(' ', 'closed') } + .to raise_error(ZaiPayment::Errors::ValidationError, /virtual_account_id/) + end + end + + context 'when status validation fails' do + it 'raises error for blank status' do + expect do + virtual_account_resource.update_status('46deb476-c1a6-41eb-8eb7-26a695bbe5bc', '') + end.to raise_error(ZaiPayment::Errors::ValidationError, /status cannot be blank/) + end + + it 'raises error for nil status' do + expect do + virtual_account_resource.update_status('46deb476-c1a6-41eb-8eb7-26a695bbe5bc', nil) + end.to raise_error(ZaiPayment::Errors::ValidationError, /status cannot be blank/) + end + + it 'raises error for whitespace only status' do + expect do + virtual_account_resource.update_status('46deb476-c1a6-41eb-8eb7-26a695bbe5bc', ' ') + end.to raise_error(ZaiPayment::Errors::ValidationError, /status cannot be blank/) + end + + it 'raises error for invalid status value' do + expect do + virtual_account_resource.update_status('46deb476-c1a6-41eb-8eb7-26a695bbe5bc', 'active') + end.to raise_error(ZaiPayment::Errors::ValidationError, /status must be 'closed'/) + end + + it 'raises error for invalid status value with descriptive message' do + expect do + virtual_account_resource.update_status('46deb476-c1a6-41eb-8eb7-26a695bbe5bc', 'pending') + end.to raise_error(ZaiPayment::Errors::ValidationError, /status must be 'closed', got 'pending'/) + end + end + + context 'when virtual account does not exist' do + before do + stubs.patch('/virtual_accounts/invalid_id/status') do + [404, { 'Content-Type' => 'application/json' }, { 'errors' => 'Not found' }] + end + end + + it 'raises a NotFoundError' do + expect { virtual_account_resource.update_status('invalid_id', 'closed') } + .to raise_error(ZaiPayment::Errors::NotFoundError) + end + end + + context 'when API returns bad request' do + before do + stubs.patch('/virtual_accounts/46deb476-c1a6-41eb-8eb7-26a695bbe5bc/status') do + [400, { 'Content-Type' => 'application/json' }, { 'errors' => 'Bad request' }] + end + end + + it 'raises a BadRequestError' do + expect { virtual_account_resource.update_status('46deb476-c1a6-41eb-8eb7-26a695bbe5bc', 'closed') } + .to raise_error(ZaiPayment::Errors::BadRequestError) + end + end + + context 'when API returns unauthorized' do + before do + stubs.patch('/virtual_accounts/46deb476-c1a6-41eb-8eb7-26a695bbe5bc/status') do + [401, { 'Content-Type' => 'application/json' }, { 'errors' => 'Unauthorized' }] + end + end + + it 'raises an UnauthorizedError' do + expect { virtual_account_resource.update_status('46deb476-c1a6-41eb-8eb7-26a695bbe5bc', 'closed') } + .to raise_error(ZaiPayment::Errors::UnauthorizedError) + end + end + + context 'when API returns forbidden' do + before do + stubs.patch('/virtual_accounts/46deb476-c1a6-41eb-8eb7-26a695bbe5bc/status') do + [403, { 'Content-Type' => 'application/json' }, { 'errors' => 'Forbidden' }] + end + end + + it 'raises a ForbiddenError' do + expect { virtual_account_resource.update_status('46deb476-c1a6-41eb-8eb7-26a695bbe5bc', 'closed') } + .to raise_error(ZaiPayment::Errors::ForbiddenError) + end + end + end + + describe '#create' do + let(:virtual_account_data) do + { + 'virtual_accounts' => { + 'id' => 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + 'routing_number' => '123456', + 'account_number' => '100000017', + 'currency' => 'AUD', + 'wallet_account_id' => 'ae07556e-22ef-11eb-adc1-0242ac120002', + 'user_external_id' => 'ca12346e-22ef-11eb-adc1-0242ac120002', + 'status' => 'pending_activation', + 'created_at' => '2020-04-27T20:28:22.378Z', + 'updated_at' => '2020-04-27T20:28:22.378Z', + 'account_type' => 'NIND', + 'full_legal_account_name' => 'Prop Tech Marketplace', + 'account_name' => 'Real Estate Agency X', + 'aka_names' => ['Realestate agency X'], + 'merchant_id' => '46deb476c1a641eb8eb726a695bbe5bc' + } + } + end + + let(:valid_params) do + { + account_name: 'Real Estate Agency X', + aka_names: ['Realestate agency X'] + } + end + + context 'when successful' do + before do + stubs.post('/wallet_accounts/ae07556e-22ef-11eb-adc1-0242ac120002/virtual_accounts') do + [202, { 'Content-Type' => 'application/json' }, virtual_account_data] + end + end + + it 'returns the correct response type and creates virtual account' do + response = virtual_account_resource.create('ae07556e-22ef-11eb-adc1-0242ac120002', **valid_params) + + expect(response).to be_a(ZaiPayment::Response) + expect(response.success?).to be true + expect(response.data['id']).to eq('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee') + end + + it 'includes account_name and aka_names in response' do + response = virtual_account_resource.create('ae07556e-22ef-11eb-adc1-0242ac120002', **valid_params) + + expect(response.data['account_name']).to eq('Real Estate Agency X') + expect(response.data['aka_names']).to eq(['Realestate agency X']) + end + end + + context 'with minimal params (only account_name)' do + before do + stubs.post('/wallet_accounts/ae07556e-22ef-11eb-adc1-0242ac120002/virtual_accounts') do + [202, { 'Content-Type' => 'application/json' }, virtual_account_data] + end + end + + it 'creates virtual account with only account_name' do + response = virtual_account_resource.create( + 'ae07556e-22ef-11eb-adc1-0242ac120002', + account_name: 'Real Estate Agency X' + ) + + expect(response).to be_a(ZaiPayment::Response) + expect(response.success?).to be true + expect(response.data['id']).to eq('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee') + end + end + + context 'with multiple aka_names' do + before do + stubs.post('/wallet_accounts/ae07556e-22ef-11eb-adc1-0242ac120002/virtual_accounts') do + [202, { 'Content-Type' => 'application/json' }, virtual_account_data] + end + end + + it 'creates virtual account with multiple aka_names' do + response = virtual_account_resource.create( + 'ae07556e-22ef-11eb-adc1-0242ac120002', + account_name: 'Real Estate Agency X', + aka_names: ['RE Agency', 'Real Estate X', 'Agency X'] + ) + + expect(response).to be_a(ZaiPayment::Response) + expect(response.success?).to be true + end + end + + context 'with empty aka_names array' do + before do + stubs.post('/wallet_accounts/ae07556e-22ef-11eb-adc1-0242ac120002/virtual_accounts') do + [202, { 'Content-Type' => 'application/json' }, virtual_account_data] + end + end + + it 'creates virtual account without aka_names' do + response = virtual_account_resource.create( + 'ae07556e-22ef-11eb-adc1-0242ac120002', + account_name: 'Real Estate Agency X', + aka_names: [] + ) + + expect(response).to be_a(ZaiPayment::Response) + expect(response.success?).to be true + end + end + + context 'when wallet_account_id is blank' do + it 'raises a ValidationError for empty string' do + expect { virtual_account_resource.create('', **valid_params) } + .to raise_error(ZaiPayment::Errors::ValidationError, /wallet_account_id/) + end + + it 'raises a ValidationError for nil' do + expect { virtual_account_resource.create(nil, **valid_params) } + .to raise_error(ZaiPayment::Errors::ValidationError, /wallet_account_id/) + end + + it 'raises a ValidationError for whitespace only' do + expect { virtual_account_resource.create(' ', **valid_params) } + .to raise_error(ZaiPayment::Errors::ValidationError, /wallet_account_id/) + end + end + + context 'when account_name validation fails' do + it 'raises error for blank account_name' do + expect do + virtual_account_resource.create('ae07556e-22ef-11eb-adc1-0242ac120002', account_name: '') + end.to raise_error(ZaiPayment::Errors::ValidationError, /account_name cannot be blank/) + end + + it 'raises error for nil account_name' do + expect do + virtual_account_resource.create('ae07556e-22ef-11eb-adc1-0242ac120002', account_name: nil) + end.to raise_error(ZaiPayment::Errors::ValidationError, /account_name cannot be blank/) + end + + it 'raises error for whitespace only account_name' do + expect do + virtual_account_resource.create('ae07556e-22ef-11eb-adc1-0242ac120002', account_name: ' ') + end.to raise_error(ZaiPayment::Errors::ValidationError, /account_name cannot be blank/) + end + + it 'raises error for account_name longer than 140 characters' do + long_name = 'A' * 141 + expect do + virtual_account_resource.create('ae07556e-22ef-11eb-adc1-0242ac120002', account_name: long_name) + end.to raise_error(ZaiPayment::Errors::ValidationError, /account_name must be 140 characters or less/) + end + + it 'accepts account_name with exactly 140 characters' do + stubs.post('/wallet_accounts/ae07556e-22ef-11eb-adc1-0242ac120002/virtual_accounts') do + [202, { 'Content-Type' => 'application/json' }, virtual_account_data] + end + + exact_length_name = 'A' * 140 + response = virtual_account_resource.create('ae07556e-22ef-11eb-adc1-0242ac120002', + account_name: exact_length_name) + + expect(response).to be_a(ZaiPayment::Response) + expect(response.success?).to be true + end + end + + context 'when aka_names validation fails' do + it 'raises error for non-array aka_names' do + expect do + virtual_account_resource.create( + 'ae07556e-22ef-11eb-adc1-0242ac120002', + account_name: 'Test', + aka_names: 'not an array' + ) + end.to raise_error(ZaiPayment::Errors::ValidationError, /aka_names must be an array/) + end + + it 'raises error for more than 3 aka_names' do + expect do + virtual_account_resource.create( + 'ae07556e-22ef-11eb-adc1-0242ac120002', + account_name: 'Test', + aka_names: ['Name 1', 'Name 2', 'Name 3', 'Name 4'] + ) + end.to raise_error(ZaiPayment::Errors::ValidationError, /aka_names must contain between 0 and 3 items/) + end + + it 'accepts exactly 3 aka_names' do + stubs.post('/wallet_accounts/ae07556e-22ef-11eb-adc1-0242ac120002/virtual_accounts') do + [202, { 'Content-Type' => 'application/json' }, virtual_account_data] + end + + response = virtual_account_resource.create('ae07556e-22ef-11eb-adc1-0242ac120002', + account_name: 'Test', + aka_names: ['Name 1', 'Name 2', 'Name 3']) + + expect(response).to be_a(ZaiPayment::Response) + expect(response.success?).to be true + end + end + + context 'when wallet account does not exist' do + before do + stubs.post('/wallet_accounts/invalid_id/virtual_accounts') do + [404, { 'Content-Type' => 'application/json' }, { 'errors' => 'Not found' }] + end + end + + it 'raises a NotFoundError' do + expect { virtual_account_resource.create('invalid_id', **valid_params) } + .to raise_error(ZaiPayment::Errors::NotFoundError) + end + end + + context 'when API returns bad request' do + before do + stubs.post('/wallet_accounts/ae07556e-22ef-11eb-adc1-0242ac120002/virtual_accounts') do + [400, { 'Content-Type' => 'application/json' }, { 'errors' => 'Bad request' }] + end + end + + it 'raises a BadRequestError' do + expect { virtual_account_resource.create('ae07556e-22ef-11eb-adc1-0242ac120002', **valid_params) } + .to raise_error(ZaiPayment::Errors::BadRequestError) + end + end + + context 'when API returns unauthorized' do + before do + stubs.post('/wallet_accounts/ae07556e-22ef-11eb-adc1-0242ac120002/virtual_accounts') do + [401, { 'Content-Type' => 'application/json' }, { 'errors' => 'Unauthorized' }] + end + end + + it 'raises an UnauthorizedError' do + expect { virtual_account_resource.create('ae07556e-22ef-11eb-adc1-0242ac120002', **valid_params) } + .to raise_error(ZaiPayment::Errors::UnauthorizedError) + end + end + + context 'when API returns forbidden' do + before do + stubs.post('/wallet_accounts/ae07556e-22ef-11eb-adc1-0242ac120002/virtual_accounts') do + [403, { 'Content-Type' => 'application/json' }, { 'errors' => 'Forbidden' }] + end + end + + it 'raises a ForbiddenError' do + expect { virtual_account_resource.create('ae07556e-22ef-11eb-adc1-0242ac120002', **valid_params) } + .to raise_error(ZaiPayment::Errors::ForbiddenError) + end + end + end +end