airwallex 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '018417ed2b3091ca1c5eafdf99df79c1a81be1d13ddda2524a917d2bc9a2c3ff'
4
- data.tar.gz: 0abdb483ebe91136353cc77efb5023cb1d0a05b3fd986c8c3ba59750dfeccfdd
3
+ metadata.gz: 20abbd73aa43d82620559c0f067a6236619d3dad597b778b41b75af6d87fb225
4
+ data.tar.gz: 6a477ebe40ec72d267d42f9b98669d783dd7db32c9cf9d30ed0b671c07810252
5
5
  SHA512:
6
- metadata.gz: 1b48a815ca9ca948b4695cfa28b12e70c5dc21a7c6ff37781d20fe0b2f219b12daf205ec997eaaf51eca19bce49d3ffd0fbb890773cd99f5573c4733680e1ec6
7
- data.tar.gz: ff66015473fee32cd8ec4bd7cdbb03072b340111d1c354db181c07e0722c936d7b14565210eabd6583f8cd223be9f4c96889b1e329a79b41709ed4626b474427
6
+ metadata.gz: ac0aa15ebc45b61bbd7004010bbdfc261c1243d373cbd59b4dbb141e680b0d46ca079ab25f2d4c4e9017f5d4a4730fff4950f632e5415c82de0728cd3e3837b8
7
+ data.tar.gz: 948df17c305f0ab1c88791b29afe1df2218197c12512cbb52c7f80cb0524c9e1903a7e4b1b25597c2ed447d010fed846df94e48ec988ccdf85019913318fa561
data/CHANGELOG.md CHANGED
@@ -1,5 +1,59 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.12.0] - 2026-10-06
4
+
5
+ ### Added
6
+
7
+ - `RFI.respond(rfi_id, params, opts)` and `RFI#respond(params, opts)` for
8
+ `POST /api/v1/rfis/{id}/respond`. Params are sent as-is
9
+ (`questions: [{ id:, answer: { type:, ... } }]`) and take `headers:` for
10
+ `x-on-behalf-of`. No `request_id` is added to this body, because the
11
+ endpoint doesn't document one
12
+ - `UploadedFile.upload(io, filename:, content_type:, notes: nil, opts: {})` for
13
+ `POST /api/v1/files/upload` on the files host (`config.files_url`). It sends
14
+ multipart/form-data with `file` and optional `notes` form fields, and
15
+ returns an `UploadedFile` with `#file_id` (`#id` mirrors it)
16
+ - `Client#files_connection` and `Client#upload`: a separate connection to the
17
+ files host with no JSON Content-Type default, no JSON encoding, no
18
+ Idempotency middleware, and no logging of request bodies
19
+
20
+ ### Fixed
21
+
22
+ - `AuthRefresh` now re-sends the original request body when it retries after
23
+ a 401. It previously re-sent the parsed 401 response body, so any POST that
24
+ hit an expired token was retried with the wrong body. Multipart stream
25
+ bodies are rewound before the retry
26
+ - With `config.logger` set, the logger no longer writes credentials: the
27
+ `x-api-key` header, the `Authorization` bearer token, and the `token` in the
28
+ login response are logged as `[FILTERED]`. They were previously logged in
29
+ plain text
30
+
31
+ ## [0.11.0] - 2026-10-01
32
+
33
+ ### Added
34
+
35
+ - `FundsSplitReversal` resource (`.create`, `.retrieve`, `.list`) for
36
+ `/api/v1/pa/funds_split_reversals`, which moves split funds back from a
37
+ connected account to the platform. `.list` takes the required `funds_split_id:`.
38
+ Partial reversals are allowed until the full split is reversed
39
+ - `ConnectedAccountTransfer` resource (`.create`, `.retrieve`, `.list`) for
40
+ `/api/v1/connected_account_transfers`, which moves funds between the platform
41
+ and a connected account. Pass `headers: { "x-on-behalf-of" => ... }` to
42
+ debit a connected account instead of the platform
43
+ - `Transfer.simulate_transition`, `Transfer#simulate_transition` and
44
+ `Transfer#cancel` accept `headers:`. A transfer created `x-on-behalf-of` a
45
+ connected account can only be found with that same header
46
+
47
+ ### Fixed
48
+
49
+ - `FundsSplit` docs (README and class comment) now show the real create body:
50
+ `request_id`, `source_id`, `source_type: "PAYMENT_INTENT"`, `amount` (string),
51
+ `destination`, `auto_release`, `metadata`, with one split per destination.
52
+ The docs previously showed `payment_intent_id:` and `splits: [...]`
53
+ - Documented that `funds_split.*` webhook payloads use `split_id` (not `id`)
54
+ and carry no `request_id`, and that the sandbox rejects a PROCESSING -> PAID
55
+ transfer transition (go through SENT)
56
+
3
57
  ## [0.10.0] - 2026-09-29
4
58
 
5
59
  ### Added
data/README.md CHANGED
@@ -16,7 +16,7 @@ This gem provides a Ruby interface to Airwallex's payment infrastructure, design
16
16
  - **Billing**: Products, prices, billing customers, and subscriptions for recurring/instalment billing
17
17
  - **Recurring Payments**: Payment consents and payment sources for merchant-initiated (off-session) charges
18
18
  - **Scale**: Connected accounts, funds splits, and charges for platforms onboarding sub-merchants
19
- - **Compliance**: Retrieve and list Requests for Information (RFIs), including for connected accounts
19
+ - **Compliance**: Retrieve, list and respond to Requests for Information (RFIs), and upload supporting documents, including for connected accounts
20
20
  - **Sandbox Simulations**: Deposits, issuing transactions, disputes, transfer status, Direct Debit mandates, connected account/offboarding review outcomes, RFIs, and POS terminals — see [Sandbox Simulations](#sandbox-simulations)
21
21
  - **Idempotency**: Automatic request deduplication for safe retries
22
22
  - **Pagination**: Unified interface over cursor-based and offset-based pagination
@@ -375,7 +375,7 @@ payment_source = Airwallex::PaymentSource.create(
375
375
  )
376
376
  ```
377
377
 
378
- ### Scale (Connected Accounts, Funds Splits, Charges)
378
+ ### Scale (Connected Accounts, Funds Splits, Reversals, Connected Account Transfers, Charges)
379
379
 
380
380
  For platforms onboarding sub-merchants:
381
381
 
@@ -407,17 +407,56 @@ connected_account.submit
407
407
  Airwallex::ConnectedAccount.current
408
408
  Airwallex::ConnectedAccount.wallet_info
409
409
 
410
- # Split a charge's funds between the platform and a connected account
410
+ # Split part of a PaymentIntent's funds to a connected account. One split per
411
+ # destination: create several splits to pay several connected accounts.
412
+ # A reused request_id raises BadRequestError (duplicate_request); splitting
413
+ # more than the PaymentIntent amount raises amount_above_limit.
411
414
  split = Airwallex::FundsSplit.create(
412
- payment_intent_id: payment_intent.id,
413
- splits: [{ account_id: connected_account.id, amount: 10.00 }]
415
+ request_id: 'split-order-123', # required, max 64
416
+ source_id: payment_intent.id, # required
417
+ source_type: 'PAYMENT_INTENT', # required
418
+ amount: '10.00', # required, string
419
+ destination: connected_account.id,
420
+ auto_release: false, # optional, default true
421
+ metadata: { order_id: '123' } # optional
422
+ )
423
+ split.release # only needed when auto_release: false
424
+
425
+ # Reverse split funds back to the platform. Partial reversals are allowed
426
+ # until the full split is reversed. Statuses: CREATED, RELEASED, SETTLED
427
+ reversal = Airwallex::FundsSplitReversal.create(
428
+ request_id: 'rev-order-123', # required, max 64
429
+ funds_split_id: split.id, # required
430
+ amount: '5.00', # required, string
431
+ metadata: { reason: 'refund' } # optional
432
+ )
433
+ Airwallex::FundsSplitReversal.retrieve(reversal.id)
434
+ Airwallex::FundsSplitReversal.list(funds_split_id: split.id) # funds_split_id is required
435
+
436
+ # Move funds between the platform and a connected account's wallet. Pass
437
+ # x-on-behalf-of to debit a connected account instead of the platform.
438
+ # Statuses: NEW, PENDING, SETTLED, SUSPENDED, FAILED
439
+ cat = Airwallex::ConnectedAccountTransfer.create(
440
+ request_id: 'top-up-123', # required, 1-50
441
+ amount: '100.00',
442
+ currency: 'AUD',
443
+ destination: connected_account.id,
444
+ reason: 'transfer_to_own_account',
445
+ reference: 'Ledger top-up' # required, 1-140
414
446
  )
415
- split.release
447
+ Airwallex::ConnectedAccountTransfer.retrieve(cat.id)
448
+ Airwallex::ConnectedAccountTransfer.list(destination: connected_account.id, status: 'SETTLED')
449
+
450
+ # Pay out from a connected account: create the Transfer on its behalf
451
+ Airwallex::Transfer.create(transfer_params, headers: { 'x-on-behalf-of' => connected_account.id })
416
452
 
417
453
  # Charges (create, retrieve, list)
418
454
  charge = Airwallex::Charge.retrieve('charge_id')
419
455
  ```
420
456
 
457
+ `funds_split.*` webhook payloads name the split `split_id`, not `id`, and carry
458
+ no `request_id`: `{ split_id, status, amount, currency, source_id, source_type, destination, ... }`.
459
+
421
460
  ### Requests for Information (RFIs)
422
461
 
423
462
  RFIs are compliance questions Airwallex raises against an account. They use
@@ -438,6 +477,50 @@ Airwallex::RFI.list({ statuses: 'ACTION_REQUIRED', types: 'KYC,KYC_ONGOING' }, h
438
477
  .auto_paging_each { |r| puts r.id }
439
478
  ```
440
479
 
480
+ #### Responding to an RFI
481
+
482
+ Upload any documents first, then answer every question in `active_request`
483
+ in one call. Files go to the separate files host (`files-demo.airwallex.com`
484
+ in sandbox, `files.airwallex.com` in production), up to 20MB each, and the
485
+ returned `file_id` is what the answer references.
486
+
487
+ ```ruby
488
+ passport = Airwallex::UploadedFile.upload(
489
+ File.open('passport.jpg', 'rb'),
490
+ filename: 'passport.jpg',
491
+ content_type: 'image/jpeg',
492
+ notes: 'passport-front',
493
+ opts: { headers: headers }
494
+ )
495
+
496
+ rfi = Airwallex::RFI.retrieve('rfi_123', headers: headers)
497
+ rfi.respond(
498
+ {
499
+ questions: [
500
+ { id: 'q1', answer: { type: 'TEXT', text: 'We only operate in Australia' } },
501
+ { id: 'q2', answer: { type: 'ATTACHMENT', attachments: [{ file_id: passport.file_id }] } },
502
+ { id: 'q3', answer: { type: 'CONFIRMATION', confirmed: true } }
503
+ ]
504
+ },
505
+ headers: headers
506
+ )
507
+ rfi.status # => "ANSWERED"
508
+ ```
509
+
510
+ Answer types are `TEXT` (`text:`), `ATTACHMENT` (`attachments: [{ file_id: }]`),
511
+ `CONFIRMATION` (`confirmed:`), `ADDRESS` (`address: { address_line1:, ... }`),
512
+ `IDENTITY_DOCUMENT` (`identity_document: { type:, number:, issuing_country:,
513
+ front_file_id:, back_file_id: }`) and `LIVENESS`. A `LIVENESS` question is
514
+ completed at its `liveness[:url]` rather than answered here, so do that first.
515
+ The status then goes `ACTION_REQUIRED` → `ANSWERED` → `CLOSED`.
516
+
517
+ Errors: `BadRequestError` with code `invalid_state_for_operation` (the RFI
518
+ isn't `ACTION_REQUIRED`) or `invalid_argument` (a question is unanswered or
519
+ has the wrong answer shape), and `PermissionError` with
520
+ `account_not_authorised_for_operation`. You also get that last one when
521
+ answering RFIs for connected accounts through the API hasn't been enabled
522
+ for your platform; ask your Airwallex Account Manager to turn it on.
523
+
441
524
  ### Sandbox Simulations
442
525
 
443
526
  Sandbox-only endpoints (`/api/v1/simulation/...`) that stand in for the bank,
@@ -555,8 +638,18 @@ transfer.simulate_transition(next_status: 'FAILED', failure_type: 'INSUFFICIENT_
555
638
 
556
639
  # Class-level equivalent takes a transfer_id directly instead of an instance
557
640
  Airwallex::Transfer.simulate_transition('tfr_123', next_status: 'CANCELLED')
641
+
642
+ # A transfer created x-on-behalf-of a connected account can only be found
643
+ # with the same header (cancel takes it too)
644
+ headers = { 'x-on-behalf-of' => 'acct_123' }
645
+ transfer.simulate_transition(next_status: 'SENT', headers: headers)
646
+ Airwallex::Transfer.simulate_transition('tfr_123', next_status: 'PAID', headers: headers)
647
+ transfer.cancel(headers: headers)
558
648
  ```
559
649
 
650
+ The sandbox rejects PROCESSING -> PAID with a 500 `operation_failed`. Move the
651
+ transfer to SENT first.
652
+
560
653
  #### Connected Accounts & Offboarding
561
654
 
562
655
  ```ruby
@@ -746,6 +839,10 @@ begin
746
839
  handle_successful_payment(event.data)
747
840
  when 'payout.transfer.failed'
748
841
  handle_failed_payout(event.data)
842
+ when 'funds_split.settled'
843
+ # funds_split.* payloads identify the split by split_id (not id) and
844
+ # carry no request_id — see the Scale section
845
+ handle_settled_split(event.data)
749
846
  end
750
847
  rescue Airwallex::SignatureVerificationError => e
751
848
  # Invalid signature
@@ -849,7 +946,7 @@ end
849
946
  - Customer (create, retrieve, list, update, delete)
850
947
  - Dispute (retrieve, list, accept, challenge, related_payment_intents, simulate_create, simulate_escalate, simulate_resolve)
851
948
  - **Payouts**:
852
- - Transfer (create, retrieve, list, cancel, simulate_transition)
949
+ - Transfer (create, retrieve, list, cancel, simulate_transition; cancel/simulate_transition take headers: for x-on-behalf-of)
853
950
  - Beneficiary (create, retrieve, list, update, delete, validate, verify_account, api_schema, form_schema, supported_financial_institutions)
854
951
  - BatchTransfer (create, retrieve, list)
855
952
  - **Foreign Exchange & Multi-Currency**:
@@ -876,6 +973,8 @@ end
876
973
  - AccountAmendment (create, retrieve, simulate_approve, simulate_reject) - requires Admin-level API key permissions
877
974
  - AccountOffboarding — sandbox Simulation only, scoped to a parent ConnectedAccount (simulate_complete, simulate_cancel)
878
975
  - FundsSplit (create, retrieve, list, release)
976
+ - FundsSplitReversal (create, retrieve, list)
977
+ - ConnectedAccountTransfer (create, retrieve, list)
879
978
  - Charge (create, retrieve, list)
880
979
  - **Issuing** (sandbox Simulation only — no live Card/Cardholder resources yet):
881
980
  - IssuingTransaction (simulate_create with single_phase, simulate_capture, simulate_reverse, simulate_refund, simulate_notify_three_ds)
@@ -883,7 +982,8 @@ end
883
982
  - **In-Person Payments** (sandbox Simulation only):
884
983
  - POSTerminal (simulate_turn_on, simulate_turn_off, simulate_generate_activation_code, simulate_confirm_payment_intent, simulate_payment_scenarios)
885
984
  - **Compliance**:
886
- - RFI (retrieve, list, plus sandbox simulate_create, simulate_close, simulate_follow_up)
985
+ - RFI (retrieve, list, respond, plus sandbox simulate_create, simulate_close, simulate_follow_up)
986
+ - UploadedFile (upload, to the files host)
887
987
  - **Webhooks**: Event handling, HMAC-SHA256 signature verification
888
988
 
889
989
  ### Coming in Future Versions
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airwallex
4
+ class Client
5
+ # Multipart uploads to the files host (config.files_url), which is
6
+ # separate from the main API host
7
+ module FileUploads
8
+ # Separate connection for the files host (config.files_url). It sends
9
+ # multipart bodies, so it deliberately has no JSON Content-Type default,
10
+ # no :json request encoding, and no Idempotency middleware (which would
11
+ # add a stray request_id form field). No :retry either: faraday-retry
12
+ # only retries GET/DELETE here, and uploads are POSTs.
13
+ def files_connection
14
+ @files_connection ||= Faraday.new(url: config.files_url) do |conn|
15
+ conn.request :multipart
16
+ conn.use Airwallex::Middleware::AuthRefresh, self
17
+ conn.response :json, content_type: /\bjson$/
18
+ # Never log bodies here: they're the uploaded file's bytes
19
+ configure_logger(conn, bodies: false)
20
+
21
+ conn.headers["User-Agent"] = user_agent
22
+ conn.headers["x-api-version"] = config.api_version if config.api_version
23
+
24
+ conn.adapter Faraday.default_adapter
25
+ end
26
+ end
27
+
28
+ # POST a multipart upload to the files host
29
+ #
30
+ # @param path [String] e.g. "/api/v1/files/upload"
31
+ # @param file_part [Faraday::Multipart::FilePart] sent as the "file" field
32
+ # @param params [Hash] extra form fields (e.g. notes:)
33
+ # @param headers [Hash] e.g. { "x-on-behalf-of" => account_id }
34
+ # @return [Hash] parsed response body
35
+ def upload(path, file_part, params = {}, headers = {})
36
+ response = files_connection.post(path) do |req|
37
+ req.headers.merge!(headers)
38
+ req.body = { file: file_part }.merge(params)
39
+ end
40
+
41
+ handle_response_errors(response)
42
+ response.body
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airwallex
4
+ class Client
5
+ module Logging
6
+ private
7
+
8
+ # Log requests/responses with credentials masked: the API key header,
9
+ # the bearer token, and the token in the login response body
10
+ def configure_logger(conn, bodies:)
11
+ return unless config.logger
12
+
13
+ conn.response :logger, config.logger, { headers: true, bodies: bodies } do |logger|
14
+ logger.filter(/(x-api-key: )"[^"]*"/i, '\1"[FILTERED]"')
15
+ logger.filter(/(Authorization: )"Bearer [^"]*"/i, '\1"Bearer [FILTERED]"')
16
+ logger.filter(/("token":\s*)"[^"]*"/, '\1"[FILTERED]"')
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -4,9 +4,14 @@ require "faraday"
4
4
  require "faraday/multipart"
5
5
  require "faraday/retry"
6
6
  require "json"
7
+ require_relative "client/file_uploads"
8
+ require_relative "client/logging"
7
9
 
8
10
  module Airwallex
9
11
  class Client
12
+ include FileUploads
13
+ include Logging
14
+
10
15
  LOGIN_PATH = "/api/v1/authentication/login"
11
16
 
12
17
  attr_reader :config, :access_token, :token_expires_at
@@ -105,7 +110,7 @@ module Airwallex
105
110
  conn.request :retry, retry_options
106
111
  conn.use Airwallex::Middleware::AuthRefresh, self
107
112
  conn.response :json, content_type: /\bjson$/
108
- conn.response :logger, config.logger, { headers: true, bodies: true } if config.logger
113
+ configure_logger(conn, bodies: true)
109
114
  end
110
115
 
111
116
  def handle_response_errors(response)
@@ -16,12 +16,19 @@ module Airwallex
16
16
  @client.ensure_authenticated! unless authentication_request?(env)
17
17
  authorize!(env)
18
18
 
19
+ # Once a response arrives, env[:body] holds the response body, so
20
+ # keep the request body to re-send it (same as faraday-retry)
21
+ request_body = env[:body]
19
22
  response = @app.call(env)
20
23
 
21
24
  # If we get a 401, try refreshing the token and retrying once
22
25
  if response.status == 401
23
26
  @client.authenticate!
24
27
  authorize!(env)
28
+ env[:body] = request_body
29
+ # A multipart upload body is a stream the first attempt already
30
+ # read; rewind it so the retry sends the file again
31
+ request_body.rewind if request_body.respond_to?(:rewind)
25
32
  response = @app.call(env)
26
33
  end
27
34
 
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airwallex
4
+ # Represents a Connected Account Transfer — moves funds between the platform
5
+ # account and a connected account's wallet. Send x-on-behalf-of to debit a
6
+ # connected account instead of the platform.
7
+ #
8
+ # Statuses: NEW, PENDING, SETTLED, SUSPENDED, FAILED. Create errors include
9
+ # insufficient_fund and request_id_duplicate.
10
+ #
11
+ # @example Fund a connected account from the platform
12
+ # transfer = Airwallex::ConnectedAccountTransfer.create(
13
+ # request_id: "cat-req-123", # required, 1-50
14
+ # amount: "100.00",
15
+ # currency: "AUD",
16
+ # destination: "acct_123", # connected account id
17
+ # reason: "transfer_to_own_account",
18
+ # reference: "Ledger top-up" # required, 1-140
19
+ # )
20
+ #
21
+ # @example List settled transfers to an account
22
+ # Airwallex::ConnectedAccountTransfer.list(destination: "acct_123", status: "SETTLED")
23
+ class ConnectedAccountTransfer < APIResource
24
+ extend APIOperations::Create
25
+ extend APIOperations::Retrieve
26
+ extend APIOperations::List
27
+
28
+ # @return [String] API resource path for connected account transfers
29
+ def self.resource_path
30
+ "/api/v1/connected_account_transfers"
31
+ end
32
+ end
33
+ end
@@ -1,18 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Airwallex
4
- # Represents a Funds Split — how a single PaymentIntent/inbound transaction
5
- # is divided between the platform and a Connected Account at collection
6
- # time (the Scale-product mechanism for splitting deposits).
4
+ # Represents a Funds Split — moves part of a PaymentIntent's funds from the
5
+ # platform account to a Connected Account. Each split has exactly one
6
+ # destination; create one split per connected account.
7
+ #
8
+ # Create errors include duplicate_request (reused request_id) and
9
+ # amount_above_limit (splits exceed the PaymentIntent amount).
10
+ #
11
+ # funds_split.* webhook payloads name the split split_id, not id, and carry
12
+ # no request_id: { split_id:, status:, amount:, currency:, source_id:,
13
+ # source_type:, destination:, ... }.
7
14
  #
8
15
  # @example Create a split
9
16
  # split = Airwallex::FundsSplit.create(
10
- # payment_intent_id: intent.id,
11
- # splits: [{ account_id: connected_account.id, amount: 50.00 }]
17
+ # request_id: "split-req-123", # required, max 64
18
+ # source_id: intent.id, # required, PaymentIntent id
19
+ # source_type: "PAYMENT_INTENT", # required
20
+ # amount: "50.00", # required, string
21
+ # destination: connected_account.id,
22
+ # auto_release: true, # optional, default true
23
+ # metadata: { order_id: "123" } # optional
12
24
  # )
13
25
  #
14
- # @example Release the split funds
26
+ # @example Release the split funds (when created with auto_release: false)
15
27
  # split.release
28
+ #
29
+ # @see FundsSplitReversal to move split funds back to the platform
16
30
  class FundsSplit < APIResource
17
31
  extend APIOperations::Create
18
32
  extend APIOperations::Retrieve
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airwallex
4
+ # Represents a Funds Split Reversal — moves previously split funds back from
5
+ # the connected account (the split's destination) to the platform account.
6
+ # Partial reversals are allowed until the full split amount is reversed.
7
+ #
8
+ # Statuses: CREATED, RELEASED, SETTLED.
9
+ #
10
+ # @example Reverse part of a split
11
+ # reversal = Airwallex::FundsSplitReversal.create(
12
+ # request_id: "rev-req-123", # required, max 64
13
+ # funds_split_id: split.id, # required
14
+ # amount: "5.00", # required, string
15
+ # metadata: { reason: "refund" }
16
+ # )
17
+ # reversal.status # => "CREATED"
18
+ #
19
+ # @example List the reversals of a split
20
+ # Airwallex::FundsSplitReversal.list(funds_split_id: split.id)
21
+ class FundsSplitReversal < APIResource
22
+ extend APIOperations::Create
23
+ extend APIOperations::Retrieve
24
+ extend APIOperations::List
25
+
26
+ # @return [String] API resource path for funds split reversals
27
+ def self.resource_path
28
+ "/api/v1/pa/funds_split_reversals"
29
+ end
30
+ end
31
+ end
@@ -5,7 +5,7 @@ module Airwallex
5
5
  # Airwallex raises against your account (KYC, an ongoing KYC review, a
6
6
  # cardholder, a transaction, payment enablement, or merchant risk).
7
7
  #
8
- # Supports retrieving and listing real RFIs, plus the sandbox Simulation
8
+ # Supports retrieving, listing and responding to real RFIs, plus the sandbox Simulation
9
9
  # endpoints for raising, closing and following up on them (there is no
10
10
  # live create — real RFIs originate from Airwallex's own compliance
11
11
  # review, same as Dispute). See
@@ -30,6 +30,41 @@ module Airwallex
30
30
  # headers: { "x-on-behalf-of" => "acct_123" }
31
31
  # ).auto_paging_each { |rfi| puts rfi.id }
32
32
  #
33
+ # @example Respond to an RFI (upload any documents first, see UploadedFile)
34
+ # # Every question in active_request must be answered. Answer types:
35
+ # # TEXT, ATTACHMENT, IDENTITY_DOCUMENT, CONFIRMATION, LIVENESS, ADDRESS.
36
+ # # Any answer can also carry a comment:. LIVENESS has no answer body:
37
+ # # complete the liveness check at question[:liveness][:url] first (an
38
+ # # RFI whose only question is LIVENESS resolves itself), then respond
39
+ # # to the rest.
40
+ # rfi.respond(
41
+ # {
42
+ # questions: [
43
+ # { id: "q1", answer: { type: "TEXT", text: "..." } },
44
+ # { id: "q2", answer: { type: "ATTACHMENT", attachments: [{ file_id: file.file_id }] } },
45
+ # { id: "q3", answer: { type: "CONFIRMATION", confirmed: true } },
46
+ # { id: "q4", answer: { type: "ADDRESS", address: { address_line1: "...", address_line2: "...",
47
+ # suburb: "...", state: "...", postcode: "...",
48
+ # country_code: "AU" } } },
49
+ # { id: "q5", answer: { type: "IDENTITY_DOCUMENT",
50
+ # identity_document: { type: "PASSPORT", number: "...", issuing_country: "AU",
51
+ # front_file_id: "...", back_file_id: "..." } } }
52
+ # ]
53
+ # },
54
+ # headers: { "x-on-behalf-of" => "acct_123" }
55
+ # )
56
+ # rfi.status # => "ANSWERED"
57
+ #
58
+ # Responding: the lifecycle is ACTION_REQUIRED -> ANSWERED -> CLOSED
59
+ # (an RFI also moves to CLOSED when active_request.expires_at passes).
60
+ # Responding needs the risk.rfi:write scope. Errors:
61
+ # - invalid_state_for_operation (400): the RFI isn't ACTION_REQUIRED
62
+ # - invalid_argument (400): a question is unanswered, or an answer has
63
+ # the wrong shape (e.g. TEXT vs ATTACHMENT)
64
+ # - account_not_authorised_for_operation (403): the RFI isn't linked to
65
+ # the account, or answering RFIs for connected accounts through the
66
+ # Native API hasn't been enabled (ask your Airwallex Account Manager)
67
+ #
33
68
  # @example Raise a KYC RFI, then close it
34
69
  # rfi = Airwallex::RFI.simulate_create(
35
70
  # type: "KYC",
@@ -75,6 +110,31 @@ module Airwallex
75
110
  )
76
111
  end
77
112
 
113
+ # Respond to an RFI. params are sent as-is (see the class docs for the
114
+ # body shape). Unlike other POSTs, no request_id is added: the endpoint
115
+ # doesn't document one.
116
+ #
117
+ # @param rfi_id [String]
118
+ # @param params [Hash] questions: (required) array of { id:, answer: { type:, ... } }
119
+ # @param opts [Hash] headers: (e.g. { "x-on-behalf-of" => account_id })
120
+ # @return [RFI]
121
+ def self.respond(rfi_id, params = {}, opts = {})
122
+ response = Airwallex.client.post("#{resource_path}/#{rfi_id}/respond", without_request_id(params),
123
+ opts[:headers] || {})
124
+ new(response)
125
+ end
126
+
127
+ # Pre-encode the body as JSON: the Idempotency middleware only adds
128
+ # request_id to Hash bodies, and the JSON middleware passes strings on
129
+ # unchanged
130
+ #
131
+ # @param params [Hash]
132
+ # @return [String]
133
+ def self.without_request_id(params)
134
+ JSON.generate(params)
135
+ end
136
+ private_class_method :without_request_id
137
+
78
138
  # Simulate Airwallex raising an RFI
79
139
  #
80
140
  # @param params [Hash] type: (required, one of "KYC", "KYC_ONGOING",
@@ -111,6 +171,16 @@ module Airwallex
111
171
  new(response)
112
172
  end
113
173
 
174
+ # Respond to this RFI. See .respond
175
+ #
176
+ # @param params [Hash] questions: (required)
177
+ # @param opts [Hash] headers: (e.g. { "x-on-behalf-of" => account_id })
178
+ # @return [RFI] self
179
+ def respond(params = {}, opts = {})
180
+ refresh_from(self.class.respond(id, params, opts).to_hash)
181
+ self
182
+ end
183
+
114
184
  # Simulate closing this RFI
115
185
  #
116
186
  # @param opts [Hash] headers: (e.g. { "x-on-behalf-of" => account_id })
@@ -14,10 +14,14 @@ module Airwallex
14
14
  end
15
15
 
16
16
  # Cancel a pending transfer
17
- def cancel
17
+ #
18
+ # @param opts [Hash] headers: (e.g. { "x-on-behalf-of" => account_id })
19
+ # @return [Transfer] self
20
+ def cancel(opts = {})
18
21
  response = Airwallex.client.post(
19
22
  "#{self.class.resource_path}/#{id}/cancel",
20
- {}
23
+ {},
24
+ opts[:headers] || {}
21
25
  )
22
26
  refresh_from(response)
23
27
  self
@@ -25,18 +29,21 @@ module Airwallex
25
29
 
26
30
  # Simulate this transfer's status advancing one step. The standard
27
31
  # lifecycle is SCHEDULED -> PROCESSING -> SENT -> PAID, progressed one
28
- # step at a time; OVERDUE, FAILED, and CANCELLED can be jumped to
29
- # directly.
32
+ # step at a time (the sandbox rejects PROCESSING -> PAID with a 500
33
+ # operation_failed; go through SENT); OVERDUE, FAILED, and CANCELLED can
34
+ # be jumped to directly.
30
35
  #
31
36
  # @param next_status [String] one of "OVERDUE", "PROCESSING", "SENT",
32
37
  # "PAID", "FAILED", "CANCELLED"
33
38
  # @param failure_type [String, nil] failure reason code, only
34
39
  # meaningful when next_status is "FAILED"
40
+ # @param headers [Hash] e.g. { "x-on-behalf-of" => account_id } — required
41
+ # for a transfer created on behalf of a connected account
35
42
  # @return [Transfer] self
36
- def simulate_transition(next_status:, failure_type: nil)
43
+ def simulate_transition(next_status:, failure_type: nil, headers: {})
37
44
  params = { next_status: next_status }
38
45
  params[:failure_type] = failure_type if failure_type
39
- response = Airwallex.client.post("#{SIMULATION_PATH}/#{id}/transition", params)
46
+ response = Airwallex.client.post("#{SIMULATION_PATH}/#{id}/transition", params, headers)
40
47
  refresh_from(response)
41
48
  self
42
49
  end
@@ -48,11 +55,13 @@ module Airwallex
48
55
  # "PAID", "FAILED", "CANCELLED"
49
56
  # @param failure_type [String, nil] failure reason code, only
50
57
  # meaningful when next_status is "FAILED"
58
+ # @param headers [Hash] e.g. { "x-on-behalf-of" => account_id } — required
59
+ # for a transfer created on behalf of a connected account
51
60
  # @return [Transfer]
52
- def self.simulate_transition(transfer_id, next_status:, failure_type: nil)
61
+ def self.simulate_transition(transfer_id, next_status:, failure_type: nil, headers: {})
53
62
  params = { next_status: next_status }
54
63
  params[:failure_type] = failure_type if failure_type
55
- response = Airwallex.client.post("#{SIMULATION_PATH}/#{transfer_id}/transition", params)
64
+ response = Airwallex.client.post("#{SIMULATION_PATH}/#{transfer_id}/transition", params, headers)
56
65
  new(response)
57
66
  end
58
67
  end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airwallex
4
+ # Represents a file uploaded to Airwallex's files host (config.files_url,
5
+ # not the main API host) — e.g. a KYC document. The returned file_id is
6
+ # what other endpoints reference, such as RFI answers
7
+ # (attachments[].file_id, identity_document front_file_id/back_file_id).
8
+ # See https://www.airwallex.com/docs/api/supporting_services/file_service/upload_files
9
+ #
10
+ # Limits: 20MB per file; filename and notes up to 50 characters each.
11
+ # The response has file_id, filename, notes, size (bytes), object_type
12
+ # and created (epoch seconds, not created_at).
13
+ #
14
+ # Named UploadedFile rather than File so that bare File.open/File.read
15
+ # inside the Airwallex module still resolve to ::File.
16
+ #
17
+ # @example Upload a PDF on behalf of a connected account
18
+ # file = Airwallex::UploadedFile.upload(
19
+ # File.open("smo_declaration.pdf", "rb"),
20
+ # filename: "smo_declaration.pdf",
21
+ # content_type: "application/pdf",
22
+ # notes: "SMO declaration",
23
+ # opts: { headers: { "x-on-behalf-of" => "acct_123" } }
24
+ # )
25
+ # file.file_id # => "..."
26
+ class UploadedFile < APIResource
27
+ # @return [String] API path for file uploads, on the files host
28
+ def self.resource_path
29
+ "/api/v1/files/upload"
30
+ end
31
+
32
+ # Upload a file as multipart/form-data (form field "file")
33
+ #
34
+ # @param io [IO, StringIO, String] an IO, or a local file path
35
+ # @param filename [String]
36
+ # @param content_type [String] e.g. "application/pdf", "image/jpeg"
37
+ # @param notes [String, nil] a label, sent as the notes form field
38
+ # @param opts [Hash] headers: (e.g. { "x-on-behalf-of" => account_id })
39
+ # @return [UploadedFile]
40
+ def self.upload(io, filename:, content_type:, notes: nil, opts: {})
41
+ file_part = Faraday::Multipart::FilePart.new(io, content_type, filename)
42
+ params = notes.nil? ? {} : { notes: notes }
43
+ response = Airwallex.client.upload(resource_path, file_part, params, opts[:headers] || {})
44
+ new(response)
45
+ end
46
+
47
+ # The upload response identifies the file by file_id, not id, so id
48
+ # mirrors file_id to behave like other resources
49
+ def initialize(attributes = {})
50
+ super
51
+ @id ||= @attributes[:file_id]
52
+ end
53
+
54
+ def refresh_from(data)
55
+ super
56
+ @id ||= @attributes[:file_id]
57
+ self
58
+ end
59
+
60
+ # @return [String, nil]
61
+ def file_id
62
+ @attributes[:file_id]
63
+ end
64
+ end
65
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Airwallex
4
- VERSION = "0.10.0"
4
+ VERSION = "0.12.0"
5
5
  end
data/lib/airwallex.rb CHANGED
@@ -47,6 +47,8 @@ require_relative "airwallex/resources/payment_source"
47
47
  require_relative "airwallex/resources/connected_account"
48
48
  require_relative "airwallex/resources/account_amendment"
49
49
  require_relative "airwallex/resources/funds_split"
50
+ require_relative "airwallex/resources/funds_split_reversal"
51
+ require_relative "airwallex/resources/connected_account_transfer"
50
52
  require_relative "airwallex/resources/charge"
51
53
  require_relative "airwallex/resources/deposit"
52
54
  require_relative "airwallex/resources/issuing_transaction"
@@ -54,6 +56,7 @@ require_relative "airwallex/resources/cardholder"
54
56
  require_relative "airwallex/resources/linked_account"
55
57
  require_relative "airwallex/resources/account_offboarding"
56
58
  require_relative "airwallex/resources/rfi"
59
+ require_relative "airwallex/resources/uploaded_file"
57
60
  require_relative "airwallex/resources/pos_terminal"
58
61
 
59
62
  module Airwallex
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: airwallex
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chayut Orapinpatipat
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-29 00:00:00.000000000 Z
11
+ date: 2026-10-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -75,6 +75,8 @@ files:
75
75
  - lib/airwallex/api_operations/update.rb
76
76
  - lib/airwallex/api_resource.rb
77
77
  - lib/airwallex/client.rb
78
+ - lib/airwallex/client/file_uploads.rb
79
+ - lib/airwallex/client/logging.rb
78
80
  - lib/airwallex/configuration.rb
79
81
  - lib/airwallex/errors.rb
80
82
  - lib/airwallex/list_object.rb
@@ -93,11 +95,13 @@ files:
93
95
  - lib/airwallex/resources/cardholder.rb
94
96
  - lib/airwallex/resources/charge.rb
95
97
  - lib/airwallex/resources/connected_account.rb
98
+ - lib/airwallex/resources/connected_account_transfer.rb
96
99
  - lib/airwallex/resources/conversion.rb
97
100
  - lib/airwallex/resources/customer.rb
98
101
  - lib/airwallex/resources/deposit.rb
99
102
  - lib/airwallex/resources/dispute.rb
100
103
  - lib/airwallex/resources/funds_split.rb
104
+ - lib/airwallex/resources/funds_split_reversal.rb
101
105
  - lib/airwallex/resources/global_account.rb
102
106
  - lib/airwallex/resources/global_account_alias.rb
103
107
  - lib/airwallex/resources/global_account_mandate.rb
@@ -114,6 +118,7 @@ files:
114
118
  - lib/airwallex/resources/refund.rb
115
119
  - lib/airwallex/resources/rfi.rb
116
120
  - lib/airwallex/resources/transfer.rb
121
+ - lib/airwallex/resources/uploaded_file.rb
117
122
  - lib/airwallex/util.rb
118
123
  - lib/airwallex/version.rb
119
124
  - lib/airwallex/webhook.rb