airwallex 0.3.0 → 0.7.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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +90 -8
  3. data/README.md +2 -2
  4. data/lib/airwallex/api_operations/delete.rb +3 -3
  5. data/lib/airwallex/api_operations/update.rb +4 -4
  6. data/lib/airwallex/client.rb +15 -11
  7. data/lib/airwallex/configuration.rb +6 -2
  8. data/lib/airwallex/middleware/auth_refresh.rb +21 -7
  9. data/lib/airwallex/resources/account_amendment.rb +29 -0
  10. data/lib/airwallex/resources/beneficiary.rb +96 -0
  11. data/lib/airwallex/resources/billing_customer.rb +48 -0
  12. data/lib/airwallex/resources/billing_price.rb +30 -0
  13. data/lib/airwallex/resources/billing_product.rb +23 -0
  14. data/lib/airwallex/resources/billing_subscription.rb +93 -0
  15. data/lib/airwallex/resources/billing_subscription_item.rb +7 -0
  16. data/lib/airwallex/resources/charge.rb +19 -0
  17. data/lib/airwallex/resources/connected_account.rb +136 -0
  18. data/lib/airwallex/resources/conversion.rb +1 -1
  19. data/lib/airwallex/resources/customer.rb +5 -2
  20. data/lib/airwallex/resources/dispute.rb +48 -28
  21. data/lib/airwallex/resources/funds_split.rb +37 -0
  22. data/lib/airwallex/resources/global_account.rb +137 -0
  23. data/lib/airwallex/resources/global_account_alias.rb +62 -0
  24. data/lib/airwallex/resources/global_account_mandate.rb +26 -0
  25. data/lib/airwallex/resources/global_account_transaction.rb +7 -0
  26. data/lib/airwallex/resources/payment_consent.rb +68 -0
  27. data/lib/airwallex/resources/payment_method.rb +13 -5
  28. data/lib/airwallex/resources/payment_source.rb +52 -0
  29. data/lib/airwallex/resources/quote.rb +5 -3
  30. data/lib/airwallex/resources/rate.rb +2 -7
  31. data/lib/airwallex/version.rb +1 -1
  32. data/lib/airwallex/webhook.rb +6 -2
  33. data/lib/airwallex.rb +15 -0
  34. metadata +18 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aff1a8f847f36f804c6ff9738480a8acd8e9aae867a9a3846311521337544c15
4
- data.tar.gz: af199726a6f9cdcf4eef3d47c1ac1c24fd3ce92f9dd564c57730e75a8e4230ce
3
+ metadata.gz: fad10ff0adfb96f6cb5e3a88ec1825a17b840d73658bd6fb6813f18e2746fea1
4
+ data.tar.gz: 9f59e5dfc64382838c7b1485b4ca28b19e9b71248ea51070ff106ad33cb1af32
5
5
  SHA512:
6
- metadata.gz: 41ddfe80904d71772a0ed87a6f6f26ff3f857b8502bfde57c6e8b42bd53a162d6235ac6ae99ca52a11f2e4688dbe0682a96806ec2e97f1e2e3f617c038766c45
7
- data.tar.gz: 907a5e84b3a38f3b4664ccf41d5ac4d62e04804073034845dfd9503b880113f2bc32d8c365ca9cca96dd6870ac4ebc1283ca93da93fb59128789b001b556ef47
6
+ metadata.gz: de57501742d42048dd6a9286672c18050a17734ac2e21780f85e2d23e16e856bf406df20842352d55cd46aff22bfb0ade9676e1e6578865a7c966eba833880eb
7
+ data.tar.gz: 809fce3bea1462cecb398388b34863f65edf4ef00a5742ce0c30776c0dcbf942b474daef0551e64cd81b79ddb8a44fb01ef210fd2cec8470dbe844ce4f5968c6
data/CHANGELOG.md CHANGED
@@ -1,15 +1,104 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.7.0] - 2026-08-28
4
+
5
+ ### Added
6
+ - GlobalAccount resource (create, retrieve, list, update, `#close`, `#generate_statement_letter`,
7
+ `#transactions`)
8
+ - GlobalAccountAlias nested resource (`#create_alias`, `#alias`, `#aliases`, `#initiate_port`,
9
+ `#submit_verification_code`, `#request_new_verification_code`, `#cancel`)
10
+ - GlobalAccountMandate nested resource (`#mandate`, `#mandates`, `#cancel`)
11
+ - Billing resources: BillingCustomer, BillingProduct, BillingPrice, BillingSubscription (with `#items`/`#item`)
12
+ - PaymentConsent resource (create, retrieve, list, update, `#verify`, `#verify_continue`, `#disable`)
13
+ - PaymentSource resource (create, retrieve, list)
14
+ - ConnectedAccount resource (create, retrieve, list, update, `#submit`, `#agree_to_terms_and_conditions`,
15
+ `#suspend`, `#reactivate`, `.current`, `.wallet_info`, `#legal_entity_id`)
16
+ - AccountAmendment resource (create, retrieve)
17
+ - FundsSplit resource (create, retrieve, list, `#release`)
18
+ - Charge resource (create, retrieve, list)
19
+ - Beneficiary gained `.update`/`#update`, `.validate`, `.verify_account`, `.api_schema`, `.form_schema`, and
20
+ `.supported_financial_institutions`
21
+ - Dispute gained `.update`, `#challenge` (replacing `#submit_evidence`), and `#related_payment_intents`
22
+ - PaymentMethod gained `#disable`
23
+ - New tests covering all of the above (378 total)
24
+
25
+ ### Fixed
26
+ - `APIOperations::Update` now sends `POST #{resource_path}/{id}/update` instead of `PUT #{resource_path}/{id}`
27
+ - `APIOperations::Delete` now sends `POST #{resource_path}/{id}/delete` instead of `DELETE #{resource_path}/{id}`,
28
+ and checks the response instead of always returning `true`
29
+ - `Dispute`'s resource path corrected from `/api/v1/disputes` to `/api/v1/pa/payment_disputes`
30
+ - `Conversion`'s resource path corrected from `/api/v1/conversions` to `/api/v1/fx/conversions`
31
+ - `Configuration#api_version` now defaults to `nil` instead of a hardcoded date, and `x-api-version` is only
32
+ sent when explicitly set
33
+ - `Beneficiary.create`/`.validate`/`.update` payloads corrected: wrapped under a top-level `beneficiary` key,
34
+ with `nickname`/`payer_entity_type`/`transfer_methods`/`transfer_reason` as top-level siblings;
35
+ `entity_type` replaces the nonexistent `beneficiary_type`
36
+ - `Beneficiary#update`/`.update` requires the full payload, not a partial patch
37
+ - `Beneficiary.supported_financial_institutions` requires `account_currency`, `entity_type`,
38
+ `transfer_method`, and `keyword` in addition to `bank_country_code`
39
+ - `Customer.create` requires `merchant_customer_id`
40
+ - `GlobalAccount#generate_statement_letter` requires `account_statement_type` and `registration_info`
41
+ - `ConnectedAccount.create` payload corrected: `account_details` is a required top-level wrapper;
42
+ `legal_entity_type` is `"BUSINESS"`/`"INDIVIDUAL"`, not `"COMPANY"`
43
+ - `BillingCustomer.create` payload corrected to match the real flat schema (`name`/`email`/`type`/
44
+ `default_billing_currency`/`address`/`default_legal_entity_id`)
45
+ - `BillingPrice.create` requires `pricing_model` and `recurring`
46
+ - `BillingSubscription.create` payload corrected: prices attach via an `items:` array, not a flat
47
+ `price_id`; `starts_at` replaces `start_date`; added `legal_entity_id` and `payment_source_id`
48
+ - `AccountAmendment.create` payload corrected to use `target` plus the changed section as a top-level
49
+ sibling, not a generic `changes:` wrapper
50
+ - `PaymentSource.create`'s `external_id` corrected to reference a `PaymentMethod` id, not a `PaymentConsent` id
51
+ - `ConnectedAccount#legal_entity_id` added as the source for `BillingCustomer`/`BillingSubscription`'s
52
+ `legal_entity_id`, rather than a separate resource
53
+ - `Rate`/`Quote` use `sell_currency`/`buy_currency`, not `from_currency`/`to_currency`
54
+ - `Quote.create` requires `validity` (`MIN_1`, `MIN_15`, `MIN_30`, `HR_1`, `HR_4`, `HR_8`, or `HR_24`)
55
+
56
+ ### Removed
57
+ - `Rate.list` — the endpoint has no "list all rates" concept, only `.retrieve(sell_currency:, buy_currency:)`
58
+ - `PaymentMethod.delete` and `PaymentMethod#detach` — replaced by `#disable`, the real lifecycle operation
59
+
60
+ ## [0.6.0] - 2026-08-28
61
+
62
+ ### Fixed
63
+ - `Webhook::Event` now correctly exposes the event type. Airwallex sends this field as `"name"` in
64
+ the webhook payload, not `"type"` — the previous `Event#type` reader was reading a key that doesn't
65
+ exist and always returned `nil`. The accessor is renamed to `Event#name` to match Airwallex's actual
66
+ field. **Breaking change:** `event.type` → `event.name`.
67
+
68
+ ## [0.5.0] - 2026-08-28
69
+
70
+ ### Fixed
71
+ - Webhook signature verification no longer rejects valid webhooks when Airwallex sends
72
+ millisecond-precision timestamps. `verify_timestamp` now detects millisecond values (anything at or
73
+ above `MS_THRESHOLD = 10_000_000_000`, which safely distinguishes seconds from milliseconds until the
74
+ year 2286) and normalizes them to seconds before comparing against the tolerance window.
75
+
76
+ ## [0.4.0] - 2026-08-27
77
+
78
+ ### Fixed
79
+ - `Idempotency` and `AuthRefresh` middleware are now actually registered on the Faraday connection.
80
+ Previously both classes existed but were never wired in, so the "automatic `request_id` generation"
81
+ and "retry once after a 401" behavior documented in the README did not happen at runtime.
82
+ - Fixed a bug in `AuthRefresh` where the 401-retry guard used `env[:request][:auth_retry]`, a key that
83
+ doesn't exist on `Faraday::RequestOptions` and would have raised `NoMethodError` the first time it ran.
84
+ - CI now triggers on pushes to `main` (previously configured for `master`, so pushes never ran CI).
85
+
86
+ ### Changed
87
+ - `Client#request` no longer manually manages the `Authorization` header or calls
88
+ `ensure_authenticated!` directly; this is now owned by the `AuthRefresh` middleware.
89
+
3
90
  ## [0.3.0] - 2025-11-25
4
91
 
5
92
  ### Added
93
+ - BatchTransfer resource (create, retrieve, list) for bulk payout operations
94
+ - Dispute resource (retrieve, list, accept, submit_evidence) for chargeback management
6
95
  - Foreign Exchange resources:
7
96
  - Rate resource (retrieve, list) for real-time exchange rate queries
8
97
  - Quote resource (create, retrieve) for locking exchange rates with expiration helpers
9
98
  - Conversion resource (create, retrieve, list) for executing currency conversions
10
99
  - Balance resource (list, retrieve) for querying account balances across currencies
11
100
  - Enhanced List operation to handle both array responses and paginated responses
12
- - 38 new tests (278 total) covering FX and balance operations
101
+ - 63 new tests (278 total) covering batch transfers, disputes, FX, and balance operations
13
102
  - Comprehensive manual test suite for regression testing
14
103
 
15
104
  ### Changed
@@ -19,13 +108,6 @@
19
108
  ### Fixed
20
109
  - List operation now correctly handles Balance API's direct array response format
21
110
 
22
- ## [0.2.1] - 2025-11-25
23
-
24
- ### Added
25
- - BatchTransfer resource (create, retrieve, list) for bulk payout operations
26
- - Dispute resource (retrieve, list, accept, submit_evidence) for chargeback management
27
- - 25 new tests (240 total) covering batch transfers and disputes
28
-
29
111
  ## [0.2.0] - 2025-11-25
30
112
 
31
113
  ### Added
data/README.md CHANGED
@@ -319,8 +319,8 @@ begin
319
319
  timestamp,
320
320
  tolerance: 300 # 5 minutes
321
321
  )
322
-
323
- case event.type
322
+
323
+ case event.name
324
324
  when 'payment_intent.succeeded'
325
325
  handle_successful_payment(event.data)
326
326
  when 'payout.transfer.failed'
@@ -4,12 +4,12 @@ module Airwallex
4
4
  module APIOperations
5
5
  module Delete
6
6
  def delete(id, opts = {})
7
- Airwallex.client.delete(
8
- "#{resource_path}/#{id}",
7
+ response = Airwallex.client.post(
8
+ "#{resource_path}/#{id}/delete",
9
9
  {},
10
10
  opts[:headers] || {}
11
11
  )
12
- true
12
+ response == true
13
13
  end
14
14
  end
15
15
  end
@@ -9,8 +9,8 @@ module Airwallex
9
9
 
10
10
  module ClassMethods
11
11
  def update(id, params = {}, opts = {})
12
- response = Airwallex.client.put(
13
- "#{resource_path}/#{id}",
12
+ response = Airwallex.client.post(
13
+ "#{resource_path}/#{id}/update",
14
14
  params,
15
15
  opts[:headers] || {}
16
16
  )
@@ -20,8 +20,8 @@ module Airwallex
20
20
 
21
21
  # Instance methods
22
22
  def update(params = {})
23
- response = Airwallex.client.put(
24
- "#{self.class.resource_path}/#{id}",
23
+ response = Airwallex.client.post(
24
+ "#{self.class.resource_path}/#{id}/update",
25
25
  params
26
26
  )
27
27
  refresh_from(response)
@@ -7,6 +7,8 @@ require "json"
7
7
 
8
8
  module Airwallex
9
9
  class Client
10
+ LOGIN_PATH = "/api/v1/authentication/login"
11
+
10
12
  attr_reader :config, :access_token, :token_expires_at
11
13
 
12
14
  def initialize(config = Airwallex.configuration)
@@ -19,15 +21,11 @@ module Airwallex
19
21
 
20
22
  def connection
21
23
  @connection ||= Faraday.new(url: config.api_url) do |conn|
22
- conn.request :json
23
- conn.request :multipart
24
- conn.request :retry, retry_options
25
- conn.response :json, content_type: /\bjson$/
26
- conn.response :logger, config.logger, { headers: true, bodies: true } if config.logger
24
+ configure_middleware(conn)
27
25
 
28
26
  conn.headers["Content-Type"] = "application/json"
29
27
  conn.headers["User-Agent"] = user_agent
30
- conn.headers["x-api-version"] = config.api_version
28
+ conn.headers["x-api-version"] = config.api_version if config.api_version
31
29
 
32
30
  conn.adapter Faraday.default_adapter
33
31
  end
@@ -55,10 +53,9 @@ module Airwallex
55
53
 
56
54
  def authenticate!
57
55
  @token_mutex.synchronize do
58
- response = connection.post("/api/v1/authentication/login") do |req|
56
+ response = connection.post(LOGIN_PATH) do |req|
59
57
  req.headers["x-client-id"] = config.client_id
60
58
  req.headers["x-api-key"] = config.api_key
61
- req.headers.delete("Authorization")
62
59
  end
63
60
 
64
61
  handle_response_errors(response)
@@ -85,12 +82,9 @@ module Airwallex
85
82
  private
86
83
 
87
84
  def request(method, path, data, headers)
88
- ensure_authenticated!
89
-
90
85
  response = connection.public_send(method) do |req|
91
86
  req.url(path)
92
87
  req.headers.merge!(headers)
93
- req.headers["Authorization"] = "Bearer #{access_token}"
94
88
 
95
89
  case method
96
90
  when :get, :delete
@@ -104,6 +98,16 @@ module Airwallex
104
98
  response.body
105
99
  end
106
100
 
101
+ def configure_middleware(conn)
102
+ conn.use Airwallex::Middleware::Idempotency
103
+ conn.request :json
104
+ conn.request :multipart
105
+ conn.request :retry, retry_options
106
+ conn.use Airwallex::Middleware::AuthRefresh, self
107
+ conn.response :json, content_type: /\bjson$/
108
+ conn.response :logger, config.logger, { headers: true, bodies: true } if config.logger
109
+ end
110
+
107
111
  def handle_response_errors(response)
108
112
  return if response.success?
109
113
 
@@ -10,12 +10,16 @@ module Airwallex
10
10
  SANDBOX_FILES_URL = "https://files-demo.airwallex.com"
11
11
  PRODUCTION_FILES_URL = "https://files.airwallex.com"
12
12
 
13
- DEFAULT_API_VERSION = "2024-09-27"
14
13
  VALID_ENVIRONMENTS = %i[sandbox production].freeze
15
14
 
15
+ # api_version is nil by default. Airwallex stores a version on your
16
+ # account and applies it automatically; the x-api-version header is only
17
+ # for exceptional per-call overrides (testing/migrating), not a
18
+ # permanent pin — see https://www.airwallex.com/docs/api/versioning.
19
+ # Set this explicitly if you want reproducible, pinned behavior instead.
16
20
  def initialize
17
21
  @environment = :sandbox
18
- @api_version = DEFAULT_API_VERSION
22
+ @api_version = nil
19
23
  @log_level = :info
20
24
  end
21
25
 
@@ -9,24 +9,38 @@ module Airwallex
9
9
  end
10
10
 
11
11
  def call(env)
12
- # Skip authentication refresh for login endpoint
13
- return @app.call(env) if env[:url].path.include?("/authentication/login")
12
+ # Skip authentication entirely for the login endpoint itself
13
+ return @app.call(env) if login_request?(env)
14
14
 
15
- # Ensure token is valid before making request
16
- @client.ensure_authenticated! unless env[:url].path.include?("/authentication/")
15
+ # Ensure token is valid before making the request, then attach it
16
+ @client.ensure_authenticated! unless authentication_request?(env)
17
+ authorize!(env)
17
18
 
18
19
  response = @app.call(env)
19
20
 
20
21
  # If we get a 401, try refreshing the token and retrying once
21
- if response.status == 401 && !env[:request].fetch(:auth_retry, false)
22
+ if response.status == 401
22
23
  @client.authenticate!
23
- env[:request][:auth_retry] = true
24
- env[:request_headers]["Authorization"] = "Bearer #{@client.access_token}"
24
+ authorize!(env)
25
25
  response = @app.call(env)
26
26
  end
27
27
 
28
28
  response
29
29
  end
30
+
31
+ private
32
+
33
+ def login_request?(env)
34
+ env[:url].path.include?(Client::LOGIN_PATH)
35
+ end
36
+
37
+ def authentication_request?(env)
38
+ env[:url].path.include?("/authentication/")
39
+ end
40
+
41
+ def authorize!(env)
42
+ env[:request_headers]["Authorization"] = "Bearer #{@client.access_token}"
43
+ end
30
44
  end
31
45
  end
32
46
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airwallex
4
+ # Represents an amendment to whichever account the request is
5
+ # authenticated as (the platform's own account, or a connected account via
6
+ # x-on-behalf-of). Not nested under /accounts/{id} — scoped implicitly by
7
+ # auth context, same as ConnectedAccount.current.
8
+ #
9
+ # .create requires Admin-level API key permissions, a separate tier from
10
+ # the normal per-resource Read/Write scopes.
11
+ #
12
+ # @example Submit an amendment
13
+ # # `target` identifies the dotted path being amended; the changed
14
+ # # section is a top-level sibling keyed by its own name (e.g.
15
+ # # store_details), not wrapped in a generic "changes" key.
16
+ # amendment = Airwallex::AccountAmendment.create(
17
+ # target: "account_details.store_details",
18
+ # store_details: { store_name: "New Store Name" }
19
+ # )
20
+ class AccountAmendment < APIResource
21
+ extend APIOperations::Create
22
+ extend APIOperations::Retrieve
23
+
24
+ # @return [String] API resource path for account amendments
25
+ def self.resource_path
26
+ "/api/v1/account/amendments"
27
+ end
28
+ end
29
+ end
@@ -1,14 +1,110 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Airwallex
4
+ # @example Create a beneficiary
5
+ # # nickname/payer_entity_type/transfer_methods are top-level siblings of
6
+ # # `beneficiary`, not nested inside it. request_id is optional (the
7
+ # # gem's Idempotency middleware injects one automatically). Call
8
+ # # .api_schema / .form_schema for the exact required fields per
9
+ # # entity_type + bank_country_code.
10
+ # beneficiary = Airwallex::Beneficiary.create(
11
+ # nickname: "Acme Corp",
12
+ # payer_entity_type: "COMPANY",
13
+ # transfer_methods: ["LOCAL"],
14
+ # beneficiary: {
15
+ # entity_type: "COMPANY",
16
+ # company_name: "Acme Corp",
17
+ # address: {
18
+ # country_code: "AU",
19
+ # city: "Melbourne",
20
+ # state: "VIC",
21
+ # postcode: "3000",
22
+ # street_address: "15 William Street"
23
+ # },
24
+ # bank_details: {
25
+ # account_name: "Acme Corp",
26
+ # account_number: "12750852",
27
+ # account_currency: "AUD",
28
+ # bank_country_code: "AU",
29
+ # bank_name: "National Australia Bank",
30
+ # account_routing_type1: "bsb",
31
+ # account_routing_value1: "083064",
32
+ # local_clearing_system: "BANK_TRANSFER" # country-specific enum
33
+ # }
34
+ # }
35
+ # )
36
+ #
37
+ # @example Update a beneficiary
38
+ # # .update doesn't support a partial patch — resend the full payload
39
+ # # with your change merged in.
40
+ # beneficiary.update(
41
+ # transfer_methods: ["LOCAL"],
42
+ # beneficiary: {
43
+ # entity_type: "COMPANY",
44
+ # company_name: "Acme Corp Ltd",
45
+ # bank_details: { account_currency: "AUD", bank_country_code: "AU", ... }
46
+ # }
47
+ # )
4
48
  class Beneficiary < APIResource
5
49
  extend APIOperations::Create
6
50
  extend APIOperations::Retrieve
7
51
  extend APIOperations::List
8
52
  extend APIOperations::Delete
53
+ include APIOperations::Update
54
+
55
+ API_SCHEMA_PATH = "/api/v1/beneficiary_api_schemas/generate"
56
+ FORM_SCHEMA_PATH = "/api/v1/beneficiary_form_schemas/generate"
57
+ SUPPORTED_FINANCIAL_INSTITUTIONS_PATH = "/api/v1/beneficiary_form_schemas/supported_financial_institutions"
9
58
 
10
59
  def self.resource_path
11
60
  "/api/v1/beneficiaries"
12
61
  end
62
+
63
+ # Validate beneficiary details (address, bank details, entity type, transfer
64
+ # method) before attempting to create the beneficiary.
65
+ #
66
+ # @param params [Hash] the same shape of params you'd pass to .create
67
+ # @return [Hash] raw validation result
68
+ def self.validate(params = {})
69
+ Airwallex.client.post("#{resource_path}/validate", params)
70
+ end
71
+
72
+ # Verify ownership of a beneficiary's bank account via Confirmation of
73
+ # Payee (CoP) before creation.
74
+ #
75
+ # @param params [Hash] beneficiary bank account details to verify
76
+ # @return [Hash] raw verification result (status, account_name_match_result)
77
+ def self.verify_account(params = {})
78
+ Airwallex.client.post("#{resource_path}/verify_account", params)
79
+ end
80
+
81
+ # Retrieve the API schema (field validation rules) for beneficiary bank
82
+ # details, keyed by beneficiary type / entity type / bank country.
83
+ #
84
+ # @param params [Hash] e.g. beneficiary_type:, bank_country_code:
85
+ # @return [Hash] raw schema response
86
+ def self.api_schema(params = {})
87
+ Airwallex.client.post(API_SCHEMA_PATH, params)
88
+ end
89
+
90
+ # Retrieve the dynamic form schema used to render beneficiary bank-detail
91
+ # forms in the UI, keyed by beneficiary type / entity type / bank country.
92
+ #
93
+ # @param params [Hash] e.g. beneficiary_type:, bank_country_code:
94
+ # @return [Hash] raw schema response
95
+ def self.form_schema(params = {})
96
+ Airwallex.client.post(FORM_SCHEMA_PATH, params)
97
+ end
98
+
99
+ # Search financial institutions supported for a given country, currency,
100
+ # entity type, and transfer method, by name keyword.
101
+ #
102
+ # @param params [Hash] required: bank_country_code:, account_currency:,
103
+ # entity_type:, transfer_method:, keyword: (a bank-name search term,
104
+ # min 3 chars)
105
+ # @return [Hash] raw response listing supported institutions
106
+ def self.supported_financial_institutions(params = {})
107
+ Airwallex.client.get(SUPPORTED_FINANCIAL_INSTITUTIONS_PATH, params)
108
+ end
13
109
  end
14
110
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airwallex
4
+ # Represents a Billing customer — the entity a BillingSubscription is
5
+ # scoped to. Distinct from Airwallex::Customer, which belongs to Payment
6
+ # Acceptance (/api/v1/pa/customers), not Billing.
7
+ #
8
+ # @example Create a billing customer
9
+ # # Flat payload, no wrapper key (unlike Beneficiary/ConnectedAccount).
10
+ # # type is "INDIVIDUAL" or "BUSINESS". default_legal_entity_id is not a
11
+ # # separate resource — it's account_details.legal_entity_id from a
12
+ # # ConnectedAccount's own retrieve response (see
13
+ # # ConnectedAccount#legal_entity_id).
14
+ # connected_account = Airwallex::ConnectedAccount.retrieve("acct_123")
15
+ # billing_customer = Airwallex::BillingCustomer.create(
16
+ # name: "Acme Corp",
17
+ # email: "billing@acme.example",
18
+ # type: "BUSINESS",
19
+ # default_billing_currency: "AUD",
20
+ # default_legal_entity_id: connected_account.legal_entity_id,
21
+ # address: {
22
+ # street: "200 Collins Street",
23
+ # city: "Melbourne",
24
+ # state: "VIC",
25
+ # postcode: "3000",
26
+ # country_code: "AU"
27
+ # }
28
+ # )
29
+ class BillingCustomer < APIResource
30
+ extend APIOperations::Create
31
+ extend APIOperations::Retrieve
32
+ extend APIOperations::List
33
+ include APIOperations::Update
34
+
35
+ # @return [String] API resource path for billing customers
36
+ def self.resource_path
37
+ "/api/v1/billing/billing_customers"
38
+ end
39
+
40
+ # Retrieve the bank transfer instructions for funding this customer's
41
+ # subscriptions (e.g. for a BT-funded instalment plan).
42
+ #
43
+ # @return [Hash] raw bank transfer instructions
44
+ def bank_transfer_instructions
45
+ Airwallex.client.get("#{self.class.resource_path}/#{id}/bank_transfer_instructions")
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airwallex
4
+ # Represents a Billing price — the amount/currency/cadence attached to a
5
+ # BillingProduct that a BillingSubscription is created against.
6
+ #
7
+ # @example Create a simple per-unit recurring price
8
+ # # Flat payload, no wrapper key. pricing_model is required (PER_UNIT /
9
+ # # FLAT / GRADUATED / VOLUME — GRADUATED uses tiers: instead of
10
+ # # unit_amount:). recurring: is what makes a price usable for a
11
+ # # subscription.
12
+ # price = Airwallex::BillingPrice.create(
13
+ # product_id: product.id,
14
+ # currency: "AUD",
15
+ # pricing_model: "PER_UNIT",
16
+ # unit_amount: 250.00,
17
+ # recurring: { period: 1, period_unit: "MONTH" }
18
+ # )
19
+ class BillingPrice < APIResource
20
+ extend APIOperations::Create
21
+ extend APIOperations::Retrieve
22
+ extend APIOperations::List
23
+ include APIOperations::Update
24
+
25
+ # @return [String] API resource path for billing prices
26
+ def self.resource_path
27
+ "/api/v1/billing/prices"
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airwallex
4
+ # Represents a Billing product — the thing being sold on a subscription
5
+ # (distinct from Payment Acceptance's Customer/PaymentMethod resources).
6
+ #
7
+ # @example Create a product
8
+ # product = Airwallex::BillingProduct.create(name: "Instalment Plan")
9
+ #
10
+ # @example Update a product
11
+ # product.update(name: "Instalment Plan (v2)")
12
+ class BillingProduct < APIResource
13
+ extend APIOperations::Create
14
+ extend APIOperations::Retrieve
15
+ extend APIOperations::List
16
+ include APIOperations::Update
17
+
18
+ # @return [String] API resource path for billing products
19
+ def self.resource_path
20
+ "/api/v1/billing/products"
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airwallex
4
+ # Represents a Billing subscription, e.g. a multi-part instalment plan.
5
+ #
6
+ # @example Create a subscription with a deferred start date
7
+ # # Flat payload, no wrapper key. Prices attach via an items: array of
8
+ # # { price_id:, quantity: }, not a flat price_id. legal_entity_id comes
9
+ # # from ConnectedAccount#legal_entity_id, not a separate resource.
10
+ # # payment_source_id references a PaymentSource (see
11
+ # # Airwallex::PaymentSource for the full consent -> source -> subscription
12
+ # # chain), not a PaymentConsent directly.
13
+ # connected_account = Airwallex::ConnectedAccount.retrieve("acct_123")
14
+ # subscription = Airwallex::BillingSubscription.create(
15
+ # billing_customer_id: billing_customer.id,
16
+ # currency: "AUD",
17
+ # collection_method: "AUTO_CHARGE",
18
+ # legal_entity_id: connected_account.legal_entity_id,
19
+ # payment_source_id: payment_source.id,
20
+ # starts_at: "2026-09-01T00:00:00+1000",
21
+ # items: [{ price_id: price.id, quantity: 1 }]
22
+ # )
23
+ # subscription.status #=> "PENDING"
24
+ #
25
+ # @example Cancel a subscription
26
+ # subscription.cancel
27
+ class BillingSubscription < APIResource
28
+ extend APIOperations::Create
29
+ extend APIOperations::Retrieve
30
+ extend APIOperations::List
31
+ include APIOperations::Update
32
+
33
+ # @return [String] API resource path for billing subscriptions
34
+ def self.resource_path
35
+ "/api/v1/billing/subscriptions"
36
+ end
37
+
38
+ # Cancel this subscription
39
+ #
40
+ # @param params [Hash] additional cancellation params
41
+ # @return [BillingSubscription] self
42
+ def cancel(params = {})
43
+ response = Airwallex.client.post("#{self.class.resource_path}/#{id}/cancel", params)
44
+ refresh_from(response)
45
+ self
46
+ end
47
+
48
+ # List the line items on this subscription
49
+ #
50
+ # @param params [Hash] additional query params (e.g. pagination)
51
+ # @return [ListObject<BillingSubscriptionItem>] list of subscription items
52
+ def items(params = {})
53
+ response = Airwallex.client.get("#{self.class.resource_path}/#{id}/items", params)
54
+
55
+ ListObject.new(
56
+ data: extract_item_rows(response),
57
+ has_more: extract_has_more(response),
58
+ next_cursor: extract_next_cursor(response),
59
+ resource_class: BillingSubscriptionItem,
60
+ params: params
61
+ )
62
+ end
63
+
64
+ # Retrieve a single line item on this subscription
65
+ #
66
+ # @param item_id [String] the subscription item id
67
+ # @return [BillingSubscriptionItem]
68
+ def item(item_id)
69
+ response = Airwallex.client.get("#{self.class.resource_path}/#{id}/items/#{item_id}")
70
+ BillingSubscriptionItem.new(response)
71
+ end
72
+
73
+ private
74
+
75
+ def extract_item_rows(response)
76
+ return response if response.is_a?(Array)
77
+
78
+ response[:items] || response["items"] || response[:data] || response["data"] || []
79
+ end
80
+
81
+ def extract_has_more(response)
82
+ return false unless response.is_a?(Hash)
83
+
84
+ response[:has_more] || response["has_more"] || false
85
+ end
86
+
87
+ def extract_next_cursor(response)
88
+ return nil unless response.is_a?(Hash)
89
+
90
+ response[:next_cursor] || response["next_cursor"]
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airwallex
4
+ # Read-only representation of a single Billing subscription line item
5
+ class BillingSubscriptionItem < APIResource
6
+ end
7
+ end