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
23 changes: 21 additions & 2 deletions checkout_sdk/balances/balances.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,23 @@
from datetime import datetime


class BalancesQuery:
"""Query filter for GET /balances/{id}.

The swagger declares withCurrencyAccountId and balancesAt in camelCase. The mapping to those
wire names is handled centrally by JsonSerializer._KEYS_TRANSFORMATIONS, so the attributes
stay snake_case here.
"""
# A query to filter the balances, for example "currency:GBP".
# [Optional]
query: str
with_currency_account_id: str
balances_at: str
# Specifies if the response should include the sub-account ID that corresponds to each set of
# balances.
# [Optional]
# Default: False
with_currency_account_id: bool
# A UTC datetime to retrieve historical balances at a specific point in time. Must be in the
# past. If omitted, the response returns live balances.
# [Optional]
# Format: date-time (RFC 3339)
balances_at: datetime
77 changes: 77 additions & 0 deletions checkout_sdk/balances/balances_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@
from checkout_sdk.balances.balances import BalancesQuery
from checkout_sdk.checkout_configuration import CheckoutConfiguration
from checkout_sdk.client import Client
from checkout_sdk.exception import CheckoutArgumentException


class BalancesClient(Client):
__BALANCES_PATH = 'balances'
__ENTITIES_PATH = 'entities'
__CURRENCY_ACCOUNTS_PATH = 'currency-accounts'
__TOP_UP_INSTRUCTIONS_PATH = 'top-up-instructions'

def __init__(self, api_client: ApiClient,
configuration: CheckoutConfiguration):
Expand All @@ -19,3 +23,76 @@ def __init__(self, api_client: ApiClient,
def retrieve_entity_balances(self, entity_id: str, balances_query: BalancesQuery):
return self._api_client.get(self.build_path(self.__BALANCES_PATH, entity_id), self._sdk_authorization(),
balances_query)

def retrieve_top_up_instructions(self, entity_id: str, currency_account_id: str):
"""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.

Args:
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.
currency_account_id: The ID of the sub-account to retrieve top-up instructions for.

Returns:
The decoded JSON response. This SDK has no response classes for balances, so the keys
below are the wire names:

- currency_account_id str [Required] The unique identifier of the sub-account that
the instructions apply to.
- currency str [Required] 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 retrieve_entity_balances.
- payment_reference str [Required] 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.
- bank_details dict [Required] The bank details for each available funding
rail:
- domestic dict [Optional] Funding details for the domestic rail.
- international dict [Optional] Funding details for the international rail.

Both rails 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; bank_details may contain neither.

Each rail, when present, has the following keys. Only beneficiary_account_name and
bank_name are always returned; the rest vary by rail and the receiving bank's
jurisdiction, and are omitted when they do not apply.

- beneficiary_account_name str [Required] The name of the account that receives the
funds.
- beneficiary_address str [Optional] The address of the beneficiary, if the rail
requires it.
- bank_name str [Required] The name of the bank that receives the
funds.
- bank_address str [Optional] The address of the receiving bank, if the
rail requires it.
- account_number str [Optional] The account number of the receiving
account.
- sort_code str [Optional] The sort code of the receiving bank.
Returned for United Kingdom domestic transfers.
- routing_number str [Optional] The routing number of the receiving bank.
Returned for United States domestic transfers.
- iban str [Optional] The International Bank Account Number of
the receiving account.
- swift_code str [Optional] The SWIFT or BIC code of the receiving
bank. Returned for international transfers.

Raises:
CheckoutArgumentException: If either path parameter is None, 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.
"""
if not entity_id or not entity_id.strip():
raise CheckoutArgumentException('entity_id cannot be blank')
if not currency_account_id or not currency_account_id.strip():
raise CheckoutArgumentException('currency_account_id cannot be blank')

return self._api_client.get(
self.build_path(self.__ENTITIES_PATH, entity_id, self.__CURRENCY_ACCOUNTS_PATH, currency_account_id,
self.__TOP_UP_INSTRUCTIONS_PATH),
self._sdk_authorization())
1 change: 1 addition & 0 deletions checkout_sdk/oauth_scopes.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class OAuthScopes(str, Enum):
ACCOUNTS = 'accounts'
BALANCES = 'balances'
BALANCES_VIEW = 'balances:view'
BALANCES_TOP_UP_INSTRUCTIONS = 'balances:top-up-instructions'
CARD_MANAGEMENT = 'card-management'
DISPUTES = 'disputes'
DISPUTES_ACCEPT = 'disputes:accept'
Expand Down
1 change: 1 addition & 0 deletions checkout_sdk/payments/sessions/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ class SubmitPaymentSessionRequest:
currency: Currency
reference: str
items: list # Item
amount_allocations: list # AmountAllocations
three_ds: ThreeDsRequest
ip_address: str
payment_type: PaymentType
Expand Down
31 changes: 31 additions & 0 deletions tests/balances/balances_client_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from tests._assertions import assert_api_call
from checkout_sdk.balances.balances import BalancesQuery
from checkout_sdk.balances.balances_client import BalancesClient
from checkout_sdk.exception import CheckoutArgumentException


@pytest.fixture(scope='class')
Expand All @@ -18,3 +19,33 @@ def test_should_retrieve_entity_balances(self, mocker, client: BalancesClient):

assert client.retrieve_entity_balances('entity_id', query) == 'response'
assert_api_call(mock, 'balances/entity_id', query)

def test_should_retrieve_top_up_instructions(self, mocker, client: BalancesClient):
mock = mocker.patch('checkout_sdk.api_client.ApiClient.get', return_value='response')

assert client.retrieve_top_up_instructions(
'ent_w4jelhppmfiufdnatam37wrfc4', 'ca_g5y7d6jo4e2urgforcbf2ey5jm') == 'response'
assert_api_call(
mock,
'entities/ent_w4jelhppmfiufdnatam37wrfc4/currency-accounts/'
'ca_g5y7d6jo4e2urgforcbf2ey5jm/top-up-instructions')

# 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.
@pytest.mark.parametrize('entity_id, currency_account_id, expected', [
(None, 'ca_g5y7d6jo4e2urgforcbf2ey5jm', 'entity_id cannot be blank'),
('', 'ca_g5y7d6jo4e2urgforcbf2ey5jm', 'entity_id cannot be blank'),
(' ', 'ca_g5y7d6jo4e2urgforcbf2ey5jm', 'entity_id cannot be blank'),
('ent_w4jelhppmfiufdnatam37wrfc4', None, 'currency_account_id cannot be blank'),
('ent_w4jelhppmfiufdnatam37wrfc4', '', 'currency_account_id cannot be blank'),
('ent_w4jelhppmfiufdnatam37wrfc4', ' ', 'currency_account_id cannot be blank'),
])
def test_should_reject_blank_path_parameters(self, mocker, client: BalancesClient,
entity_id, currency_account_id, expected):
mock = mocker.patch('checkout_sdk.api_client.ApiClient.get', return_value='response')

with pytest.raises(CheckoutArgumentException) as error:
client.retrieve_top_up_instructions(entity_id, currency_account_id)

assert str(error.value) == expected
mock.assert_not_called()
58 changes: 57 additions & 1 deletion tests/balances/balances_integration_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,74 @@

from checkout_sdk.balances.balances import BalancesQuery
from checkout_sdk.common.enums import Currency
from checkout_sdk.exception import CheckoutApiException
from tests.checkout_test_utils import assert_response

ENTITY_ID = 'ent_kidtcgc3ge5unf4a5i6enhnr5m'


def test_should_retrieve_entity_balances(oauth_api):
query = BalancesQuery()
query.query = "currency:" + Currency.GBP.value

response = oauth_api.balances.retrieve_entity_balances('ent_kidtcgc3ge5unf4a5i6enhnr5m', query)
response = oauth_api.balances.retrieve_entity_balances(ENTITY_ID, query)
assert_response(response, 'data')
assert response.data.__len__() > 0
for balance in response.data:
assert_response(balance,
'descriptor',
'holding_currency',
'balances')


def test_should_retrieve_top_up_instructions(oauth_api):
"""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-07 with the balances:top-up-instructions scope granted, which the sandbox IdP issues.

The test 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), which are the two ways this endpoint could
actually be broken in the SDK.
"""
query = BalancesQuery()
query.with_currency_account_id = True

balances = oauth_api.balances.retrieve_entity_balances(ENTITY_ID, query)
assert_response(balances, 'data')

# Take the first sub-account that reports an id. Requiring the entity to always have one would
# fail this test for a reason unrelated to top-up instructions.
currency_account_id = next(
(b.currency_account_id for b in balances.data
if getattr(b, 'currency_account_id', None)),
None)
if currency_account_id is None:
return

try:
instructions = oauth_api.balances.retrieve_top_up_instructions(ENTITY_ID, currency_account_id)

assert_response(instructions,
'currency_account_id',
'currency',
'payment_reference',
'bank_details')
assert instructions.currency_account_id == currency_account_id

# Assert only what the spec guarantees. bank_details declares no required properties, so
# an empty object is a legal 200 body -- do not require a rail to be present. Where a rail
# IS returned, its two required fields must be.
for rail_name in ('domestic', 'international'):
rail = getattr(instructions.bank_details, rail_name, None)
if rail is None:
continue
assert_response(rail, 'beneficiary_account_name', 'bank_name')
except CheckoutApiException as err:
# 403 = top-ups not enabled for the sub-account, or the credential lacks access.
# 404 = sub-account not found, or it has no top-up instructions available.
# Anything else means the SDK, not the environment, is at fault.
assert err.http_metadata.status_code in (403, 404), \
f'unexpected status {err.http_metadata.status_code} from top-up instructions'
Loading
Loading