From 7ab623b6de8b432f8cf126299c7e71a9a2da218c Mon Sep 17 00:00:00 2001 From: david ruiz Date: Tue, 8 Sep 2026 12:02:32 +0200 Subject: [PATCH 1/2] Top-up instructions and amount allocations --- lib/checkout_sdk/balances/balances.rb | 3 + lib/checkout_sdk/balances/balances_client.rb | 34 +++++++- .../balances/top_up_bank_details.rb | 22 ++++++ .../balances/top_up_funding_details.rb | 53 +++++++++++++ .../balances/top_up_instructions.rb | 34 ++++++++ lib/checkout_sdk/json_serializer.rb | 4 +- lib/checkout_sdk/oauth_scopes.rb | 1 + lib/checkout_sdk/payments/flow/flow_client.rb | 6 ++ .../balances/balances_client_spec.rb | 77 +++++++++++++++++++ .../balances/balances_integration_spec.rb | 47 +++++++++++ 10 files changed, 277 insertions(+), 4 deletions(-) create mode 100644 lib/checkout_sdk/balances/top_up_bank_details.rb create mode 100644 lib/checkout_sdk/balances/top_up_funding_details.rb create mode 100644 lib/checkout_sdk/balances/top_up_instructions.rb create mode 100644 spec/checkout_sdk/balances/balances_client_spec.rb diff --git a/lib/checkout_sdk/balances/balances.rb b/lib/checkout_sdk/balances/balances.rb index fd105d8..21181ab 100644 --- a/lib/checkout_sdk/balances/balances.rb +++ b/lib/checkout_sdk/balances/balances.rb @@ -4,4 +4,7 @@ require 'checkout_sdk/balances/collateral_breakdown' require 'checkout_sdk/balances/balance_values' require 'checkout_sdk/balances/currency_account_balance' +require 'checkout_sdk/balances/top_up_funding_details' +require 'checkout_sdk/balances/top_up_bank_details' +require 'checkout_sdk/balances/top_up_instructions' require 'checkout_sdk/balances/balances_client' diff --git a/lib/checkout_sdk/balances/balances_client.rb b/lib/checkout_sdk/balances/balances_client.rb index 87a3d05..1686e57 100644 --- a/lib/checkout_sdk/balances/balances_client.rb +++ b/lib/checkout_sdk/balances/balances_client.rb @@ -4,7 +4,10 @@ module CheckoutSdk module Balances class BalancesClient < Client BALANCES = 'balances' - private_constant :BALANCES + ENTITIES = 'entities' + CURRENCY_ACCOUNTS = 'currency-accounts' + TOP_UP_INSTRUCTIONS = 'top-up-instructions' + private_constant :BALANCES, :ENTITIES, :CURRENCY_ACCOUNTS, :TOP_UP_INSTRUCTIONS # @param [ApiClient] api_client # @param [CheckoutConfiguration] configuration @@ -12,11 +15,36 @@ def initialize(api_client, configuration) super(api_client, configuration, CheckoutSdk::AuthorizationType::SECRET_KEY_OR_OAUTH) end - # @param [String] entity_id - # @param [String] balances_query + # Retrieves the balances for each sub-account belonging to an entity. + # + # @param [String] entity_id the ID of the entity + # @param [String, BalancesQuery] balances_query a raw query string + # (e.g. "currency:GBP") or a {BalancesQuery} + # @return [Hash] the balances response def retrieve_entity_balances(entity_id, balances_query) api_client.invoke_get(build_path(BALANCES, entity_id), sdk_authorization, balances_query) end + + # Retrieves the bank details required to top up a sub-account, along with the payment + # reference that attributes an incoming payment to that sub-account. + # + # Note: The sub-account is referred to as currency account in the API. + # + # The response maps to {TopUpInstructions}, whose `bank_details` is a {TopUpBankDetails} + # holding up to two {TopUpFundingDetails} rails. Neither rail is guaranteed. + # + # @param [String] entity_id the ID of the entity that owns the sub-account, or of an entity + # above it in your hierarchy; a platform can use its own entity ID to reach the + # sub-accounts of any entity beneath it + # @param [String] currency_account_id the ID of the sub-account to retrieve top-up + # instructions for + # @return [Hash] the top-up instructions response + def retrieve_top_up_instructions(entity_id, currency_account_id) + api_client.invoke_get( + build_path(ENTITIES, entity_id, CURRENCY_ACCOUNTS, currency_account_id, TOP_UP_INSTRUCTIONS), + sdk_authorization + ) + end end end end diff --git a/lib/checkout_sdk/balances/top_up_bank_details.rb b/lib/checkout_sdk/balances/top_up_bank_details.rb new file mode 100644 index 0000000..bacf417 --- /dev/null +++ b/lib/checkout_sdk/balances/top_up_bank_details.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module CheckoutSdk + module Balances + # The bank details for each available funding rail (swagger schema: TopUpBankDetails). + # + # Both `domestic` and `international` are optional, and their availability depends on the + # sub-account's holding currency, jurisdiction, and banking partner. Do not assume that both + # rails are always available; neither may be present. + # + # @!attribute domestic + # [Optional] + # @return [TopUpFundingDetails] The bank details for the domestic funding rail. + # @!attribute international + # [Optional] + # @return [TopUpFundingDetails] The bank details for the international funding rail. + class TopUpBankDetails + attr_accessor :domestic, + :international + end + end +end diff --git a/lib/checkout_sdk/balances/top_up_funding_details.rb b/lib/checkout_sdk/balances/top_up_funding_details.rb new file mode 100644 index 0000000..83c8489 --- /dev/null +++ b/lib/checkout_sdk/balances/top_up_funding_details.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module CheckoutSdk + module Balances + # The bank details for a single funding rail (swagger schema: TopUpFundingDetails). + # + # `beneficiary_account_name` and `bank_name` are the only fields always returned. The + # remaining fields vary by rail and the receiving bank's jurisdiction, and are omitted when + # they do not apply. + # + # @!attribute beneficiary_account_name + # [Required] + # @return [String] The name of the account that receives the funds. + # @!attribute beneficiary_address + # [Optional] + # @return [String] The address of the beneficiary, if the rail requires it. + # @!attribute bank_name + # [Required] + # @return [String] The name of the bank that receives the funds. + # @!attribute bank_address + # [Optional] + # @return [String] The address of the receiving bank, if the rail requires it. + # @!attribute account_number + # [Optional] + # @return [String] The account number of the receiving account. + # @!attribute sort_code + # [Optional] + # @return [String] The sort code of the receiving bank. Returned for United Kingdom + # domestic transfers. + # @!attribute routing_number + # [Optional] + # @return [String] The routing number of the receiving bank. Returned for United States + # domestic transfers. + # @!attribute iban + # [Optional] + # @return [String] The International Bank Account Number of the receiving account. + # @!attribute swift_code + # [Optional] + # @return [String] The SWIFT or BIC code of the receiving bank. Returned for + # international transfers. + class TopUpFundingDetails + attr_accessor :beneficiary_account_name, + :beneficiary_address, + :bank_name, + :bank_address, + :account_number, + :sort_code, + :routing_number, + :iban, + :swift_code + end + end +end diff --git a/lib/checkout_sdk/balances/top_up_instructions.rb b/lib/checkout_sdk/balances/top_up_instructions.rb new file mode 100644 index 0000000..a078ecf --- /dev/null +++ b/lib/checkout_sdk/balances/top_up_instructions.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module CheckoutSdk + module Balances + # The bank details and payment reference used to top up a sub-account + # (swagger schema: TopUpInstructionsResponse). + # + # Returned by + # GET /entities/{entityId}/currency-accounts/{currencyAccountId}/top-up-instructions. + # + # @!attribute currency_account_id + # [Required] + # @return [String] The unique identifier of the sub-account that the instructions apply to. + # @!attribute currency + # [Required] + # @return [String] {CheckoutSdk::Common::Currency} The currency that funds must be sent in, + # as a three-letter ISO 4217 currency code. This is the sub-account's holding currency, + # returned as `holding_currency` by the Retrieve entity balances endpoint. + # @!attribute payment_reference + # [Required] + # @return [String] The reference that must be quoted on the payment. It is how an incoming + # payment is attributed to the sub-account. A payment sent without this reference may not + # be credited. + # @!attribute bank_details + # [Required] + # @return [TopUpBankDetails] The bank details for each available funding rail. + class TopUpInstructions + attr_accessor :currency_account_id, + :currency, + :payment_reference, + :bank_details + end + end +end diff --git a/lib/checkout_sdk/json_serializer.rb b/lib/checkout_sdk/json_serializer.rb index d34b2d6..91e0a07 100644 --- a/lib/checkout_sdk/json_serializer.rb +++ b/lib/checkout_sdk/json_serializer.rb @@ -6,7 +6,9 @@ class JsonSerializer account_holder_type: 'account-holder-type', payment_network: 'payment-network', from_: 'from', - if_match: 'if-match' }.freeze + if_match: 'if-match', + with_currency_account_id: 'withCurrencyAccountId', + balances_at: 'balancesAt' }.freeze def self.to_custom_hash(object) hash = {} diff --git a/lib/checkout_sdk/oauth_scopes.rb b/lib/checkout_sdk/oauth_scopes.rb index 472b5c1..34bc31a 100644 --- a/lib/checkout_sdk/oauth_scopes.rb +++ b/lib/checkout_sdk/oauth_scopes.rb @@ -36,6 +36,7 @@ module OAuthScopes TRANSFERS_VIEW = 'transfers:view' BALANCES = 'balances' BALANCES_VIEW = 'balances:view' + BALANCES_TOP_UP_INSTRUCTIONS = 'balances:top-up-instructions' MIDDLEWARE = 'middleware' MIDDLEWARE_MERCHANTS_SECRET = 'middleware:merchants-secret' MIDDLEWARE_MERCHANTS_PUBLIC = 'middleware:merchants-public' diff --git a/lib/checkout_sdk/payments/flow/flow_client.rb b/lib/checkout_sdk/payments/flow/flow_client.rb index ce22dc3..e33f3cd 100644 --- a/lib/checkout_sdk/payments/flow/flow_client.rb +++ b/lib/checkout_sdk/payments/flow/flow_client.rb @@ -32,8 +32,14 @@ def request_payment_session(request_payment_session_request) # Use this endpoint to submit payment details and process the payment for an existing session. # [Beta] # + # The request accepts `amount_allocations`: the sub-entities the payment is being + # processed on behalf of, min 1 max 50 items. Each entry maps to + # {CheckoutSdk::Common::AmountAllocations} (id, amount, reference, commission), where `id` + # and `amount` are required. + # # @param [String] id - The unique identifier of the Payment Session # @param [Hash] submit_payment_session_request + # @return [Hash] the payment submission response def submit_payment_session(id, submit_payment_session_request) api_client.invoke_post( build_path(PAYMENT_SESSIONS_PATH, id, SUBMIT_PATH), diff --git a/spec/checkout_sdk/balances/balances_client_spec.rb b/spec/checkout_sdk/balances/balances_client_spec.rb new file mode 100644 index 0000000..645bd74 --- /dev/null +++ b/spec/checkout_sdk/balances/balances_client_spec.rb @@ -0,0 +1,77 @@ +RSpec.describe CheckoutSdk::Balances do + let(:credentials_mock) { double('credentials') } + let(:api_client_mock) { double('api_client') } + let(:configuration_mock) { double('configuration') } + let(:client) { CheckoutSdk::Balances::BalancesClient.new(api_client_mock, configuration_mock) } + + let(:entity_id) { 'ent_w4jelhppmfiufdnatam37wrfc4' } + let(:currency_account_id) { 'ca_g5y7d6jo4e2urgforcbf2ey5jm' } + + before do + allow(configuration_mock).to receive(:credentials).and_return(credentials_mock) + allow(credentials_mock).to receive(:get_authorization).and_return('oauth') + end + + describe '#retrieve_entity_balances' do + it 'GETs balances/{entity_id} with the query' do + query = CheckoutSdk::Balances::BalancesQuery.new + expect(api_client_mock).to receive(:invoke_get) + .with('balances/ent_w4jelhppmfiufdnatam37wrfc4', 'oauth', query) + .and_return('response') + expect(client.retrieve_entity_balances(entity_id, query)).to eq('response') + end + end + + describe '#retrieve_top_up_instructions' do + it 'GETs the exact top-up instructions path' do + expect(api_client_mock).to receive(:invoke_get) + .with( + 'entities/ent_w4jelhppmfiufdnatam37wrfc4/currency-accounts/' \ + 'ca_g5y7d6jo4e2urgforcbf2ey5jm/top-up-instructions', + 'oauth' + ) + .and_return('response') + expect(client.retrieve_top_up_instructions(entity_id, currency_account_id)).to eq('response') + end + + it 'passes no query parameters' do + # The endpoint declares none; invoke_get must be called with exactly two arguments. + expect(api_client_mock).to receive(:invoke_get) do |*args| + expect(args.length).to eq(2) + 'response' + end + client.retrieve_top_up_instructions(entity_id, currency_account_id) + end + end + + describe 'BalancesQuery serialization' do + # The swagger declares these two parameters in camelCase. Ruby emits instance-variable names + # verbatim unless JsonSerializer::KEYS_TRANSFORMATIONS maps them, so these pin the wire + # names: without the mapping the API silently ignores both parameters and returns no + # currency_account_id, with no error. + it 'maps with_currency_account_id and balances_at to camelCase' do + query = CheckoutSdk::Balances::BalancesQuery.new + query.query = 'currency:GBP' + query.with_currency_account_id = true + query.balances_at = '2026-05-06T13:59:59Z' + + hash = CheckoutSdk::JsonSerializer.to_custom_hash(query) + + expect(hash['withCurrencyAccountId']).to be true + expect(hash['balancesAt']).to eq('2026-05-06T13:59:59Z') + expect(hash['query']).to eq('currency:GBP') + expect(hash).not_to have_key('with_currency_account_id') + expect(hash).not_to have_key('balances_at') + end + + it 'agrees with BalancesQuery#to_h' do + query = CheckoutSdk::Balances::BalancesQuery.new + query.with_currency_account_id = true + + serialized = CheckoutSdk::JsonSerializer.to_custom_hash(query) + + # to_h is a convenience that must not drift from what the serializer produces. + expect(query.to_h.transform_keys(&:to_s)).to eq(serialized) + end + end +end diff --git a/spec/checkout_sdk/balances/balances_integration_spec.rb b/spec/checkout_sdk/balances/balances_integration_spec.rb index 512abbc..d7c676c 100644 --- a/spec/checkout_sdk/balances/balances_integration_spec.rb +++ b/spec/checkout_sdk/balances/balances_integration_spec.rb @@ -15,4 +15,51 @@ end end end + + describe '.retrieve_top_up_instructions' do + # GET /entities/{entityId}/currency-accounts/{currencyAccountId}/top-up-instructions + # + # Top-ups are not enabled on the sandbox sub-accounts this suite has access to, so the + # endpoint answers 403 ("top-ups aren't enabled for the sub-account") rather than 200. + # Verified live on 2026-09-08 with the balances:top-up-instructions scope granted. + # + # The example accepts either outcome, but only the outcomes the spec documents as "not + # available here": 403 and 404. It still fails on 400 (malformed identifiers, i.e. the SDK + # built the path wrongly) and on 401 (wrong authorization type). + context 'when requesting top-up instructions for a sub-account' do + it 'returns the instructions, or a documented not-available status' do + entity_id = 'ent_kidtcgc3ge5unf4a5i6enhnr5m' + + query = CheckoutSdk::Balances::BalancesQuery.new + query.with_currency_account_id = true + balances = oauth_sdk.balances.retrieve_entity_balances(entity_id, query) + + # Take the first sub-account that reports an id. Requiring the entity to always have one + # would fail this example for a reason unrelated to top-up instructions. + accounts = balances.data || [] + currency_account_id = accounts.map(&:currency_account_id).compact.first + skip 'no sub-account reported a currency_account_id' if currency_account_id.nil? + + begin + response = oauth_sdk.balances.retrieve_top_up_instructions(entity_id, currency_account_id) + + expect(response).not_to be_nil + expect(response.currency_account_id).to eq(currency_account_id) + expect(response.currency).not_to be_nil + expect(response.payment_reference).not_to be_nil + expect(response.bank_details).not_to be_nil + + # Assert only what the spec guarantees: bank_details declares no required properties, + # so an empty object is a legal 200 body. Where a rail IS returned, its two required + # fields must be present. + [response.bank_details.domestic, response.bank_details.international].compact.each do |rail| + expect(rail.beneficiary_account_name).not_to be_nil + expect(rail.bank_name).not_to be_nil + end + rescue CheckoutSdk::CheckoutApiException => e + expect([403, 404]).to include(e.http_metadata.status_code) + end + end + end + end end From c5ebdcf1a131045f0f727abdd21bc5fb019279de Mon Sep 17 00:00:00 2001 From: david ruiz Date: Wed, 9 Sep 2026 12:07:52 +0200 Subject: [PATCH 2/2] Top up param check + more tests --- lib/checkout_sdk/balances/balances_client.rb | 6 ++++++ .../balances/balances_client_spec.rb | 20 +++++++++++++++++++ spec/checkout_sdk/oauth_scopes_spec.rb | 16 +++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 spec/checkout_sdk/oauth_scopes_spec.rb diff --git a/lib/checkout_sdk/balances/balances_client.rb b/lib/checkout_sdk/balances/balances_client.rb index 1686e57..b1f86cc 100644 --- a/lib/checkout_sdk/balances/balances_client.rb +++ b/lib/checkout_sdk/balances/balances_client.rb @@ -39,7 +39,13 @@ def retrieve_entity_balances(entity_id, balances_query) # @param [String] currency_account_id the ID of the sub-account to retrieve top-up # instructions for # @return [Hash] the top-up instructions response + # @raise [CheckoutArgumentException] if either path parameter is nil, empty or blank. Both + # segments are interpolated straight into the request path, so a blank value would build a + # malformed URL and be rejected by the API rather than by the SDK. def retrieve_top_up_instructions(entity_id, currency_account_id) + raise CheckoutArgumentException, 'entity_id cannot be blank' if entity_id.to_s.strip.empty? + raise CheckoutArgumentException, 'currency_account_id cannot be blank' if currency_account_id.to_s.strip.empty? + api_client.invoke_get( build_path(ENTITIES, entity_id, CURRENCY_ACCOUNTS, currency_account_id, TOP_UP_INSTRUCTIONS), sdk_authorization diff --git a/spec/checkout_sdk/balances/balances_client_spec.rb b/spec/checkout_sdk/balances/balances_client_spec.rb index 645bd74..6690d90 100644 --- a/spec/checkout_sdk/balances/balances_client_spec.rb +++ b/spec/checkout_sdk/balances/balances_client_spec.rb @@ -42,6 +42,26 @@ end client.retrieve_top_up_instructions(entity_id, currency_account_id) end + + # Both values are interpolated straight into the path, so a blank one would build a malformed + # URL. The guard must reject it before any request is made. + [ + ['nil entity_id', nil, 'ca_g5y7d6jo4e2urgforcbf2ey5jm', 'entity_id cannot be blank'], + ['empty entity_id', '', 'ca_g5y7d6jo4e2urgforcbf2ey5jm', 'entity_id cannot be blank'], + ['blank entity_id', ' ', 'ca_g5y7d6jo4e2urgforcbf2ey5jm', 'entity_id cannot be blank'], + ['nil currency_account_id', 'ent_w4jelhppmfiufdnatam37wrfc4', nil, + 'currency_account_id cannot be blank'], + ['empty currency_account_id', 'ent_w4jelhppmfiufdnatam37wrfc4', '', + 'currency_account_id cannot be blank'], + ['blank currency_account_id', 'ent_w4jelhppmfiufdnatam37wrfc4', ' ', + 'currency_account_id cannot be blank'] + ].each do |label, given_entity_id, given_currency_account_id, message| + it "rejects #{label} without calling the API" do + expect(api_client_mock).not_to receive(:invoke_get) + expect { client.retrieve_top_up_instructions(given_entity_id, given_currency_account_id) } + .to raise_error(CheckoutSdk::CheckoutArgumentException, message) + end + end end describe 'BalancesQuery serialization' do diff --git a/spec/checkout_sdk/oauth_scopes_spec.rb b/spec/checkout_sdk/oauth_scopes_spec.rb new file mode 100644 index 0000000..bcd0e9a --- /dev/null +++ b/spec/checkout_sdk/oauth_scopes_spec.rb @@ -0,0 +1,16 @@ +RSpec.describe CheckoutSdk::OAuthScopes do + # The scope constants are the only place their wire values are written down, and no spec + # requests BALANCES_TOP_UP_INSTRUCTIONS against the sandbox, so a typo would ship silently: + # an OAuth-configured caller of #retrieve_top_up_instructions would be rejected at the token + # endpoint with no clue that the SDK sent a scope the authorization server never defined. + # + # Values come from components.securitySchemes.OAuth.flows.clientCredentials.scopes in + # shared/swagger-latest.json. + describe 'balances scopes' do + it 'exposes the documented wire values' do + expect(described_class::BALANCES).to eq 'balances' + expect(described_class::BALANCES_VIEW).to eq 'balances:view' + expect(described_class::BALANCES_TOP_UP_INSTRUCTIONS).to eq 'balances:top-up-instructions' + end + end +end