Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/checkout_sdk/balances/balances.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
40 changes: 37 additions & 3 deletions lib/checkout_sdk/balances/balances_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,53 @@ 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
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
# @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
)
end
end
end
end
22 changes: 22 additions & 0 deletions lib/checkout_sdk/balances/top_up_bank_details.rb
Original file line number Diff line number Diff line change
@@ -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
53 changes: 53 additions & 0 deletions lib/checkout_sdk/balances/top_up_funding_details.rb
Original file line number Diff line number Diff line change
@@ -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
34 changes: 34 additions & 0 deletions lib/checkout_sdk/balances/top_up_instructions.rb
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion lib/checkout_sdk/json_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand Down
1 change: 1 addition & 0 deletions lib/checkout_sdk/oauth_scopes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
6 changes: 6 additions & 0 deletions lib/checkout_sdk/payments/flow/flow_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
97 changes: 97 additions & 0 deletions spec/checkout_sdk/balances/balances_client_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
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

# 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
# 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
47 changes: 47 additions & 0 deletions spec/checkout_sdk/balances/balances_integration_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
16 changes: 16 additions & 0 deletions spec/checkout_sdk/oauth_scopes_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading