Skip to content

Commit 187fb57

Browse files
feature/INT-1692 - Top-up instructions and amount allocations (#234)
* Top-up instructions and amount allocations * flake8 format fixes * Balances client guards + more tests * retrieve_top_up_instructions coment
1 parent 3c84dd0 commit 187fb57

10 files changed

Lines changed: 430 additions & 3 deletions

File tree

‎checkout_sdk/balances/balances.py‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,23 @@
1+
from datetime import datetime
2+
3+
14
class BalancesQuery:
5+
"""Query filter for GET /balances/{id}.
6+
7+
The swagger declares withCurrencyAccountId and balancesAt in camelCase. The mapping to those
8+
wire names is handled centrally by JsonSerializer._KEYS_TRANSFORMATIONS, so the attributes
9+
stay snake_case here.
10+
"""
11+
# A query to filter the balances, for example "currency:GBP".
12+
# [Optional]
213
query: str
3-
with_currency_account_id: str
4-
balances_at: str
14+
# Specifies if the response should include the sub-account ID that corresponds to each set of
15+
# balances.
16+
# [Optional]
17+
# Default: False
18+
with_currency_account_id: bool
19+
# A UTC datetime to retrieve historical balances at a specific point in time. Must be in the
20+
# past. If omitted, the response returns live balances.
21+
# [Optional]
22+
# Format: date-time (RFC 3339)
23+
balances_at: datetime

‎checkout_sdk/balances/balances_client.py‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,14 @@
55
from checkout_sdk.balances.balances import BalancesQuery
66
from checkout_sdk.checkout_configuration import CheckoutConfiguration
77
from checkout_sdk.client import Client
8+
from checkout_sdk.exception import CheckoutArgumentException
89

910

1011
class BalancesClient(Client):
1112
__BALANCES_PATH = 'balances'
13+
__ENTITIES_PATH = 'entities'
14+
__CURRENCY_ACCOUNTS_PATH = 'currency-accounts'
15+
__TOP_UP_INSTRUCTIONS_PATH = 'top-up-instructions'
1216

1317
def __init__(self, api_client: ApiClient,
1418
configuration: CheckoutConfiguration):
@@ -19,3 +23,76 @@ def __init__(self, api_client: ApiClient,
1923
def retrieve_entity_balances(self, entity_id: str, balances_query: BalancesQuery):
2024
return self._api_client.get(self.build_path(self.__BALANCES_PATH, entity_id), self._sdk_authorization(),
2125
balances_query)
26+
27+
def retrieve_top_up_instructions(self, entity_id: str, currency_account_id: str):
28+
"""Retrieves the bank details required to top up a sub-account, along with the payment
29+
reference that attributes an incoming payment to that sub-account.
30+
31+
Note: The sub-account is referred to as currency account in the API.
32+
33+
Args:
34+
entity_id: The ID of the entity that owns the sub-account, or of an entity above it in
35+
your hierarchy. A platform can use its own entity ID to reach the sub-accounts of
36+
any entity beneath it.
37+
currency_account_id: The ID of the sub-account to retrieve top-up instructions for.
38+
39+
Returns:
40+
The decoded JSON response. This SDK has no response classes for balances, so the keys
41+
below are the wire names:
42+
43+
- currency_account_id str [Required] The unique identifier of the sub-account that
44+
the instructions apply to.
45+
- currency str [Required] The currency that funds must be sent in, as a
46+
three-letter ISO 4217 currency code. This is the
47+
sub-account's holding currency, returned as
48+
holding_currency by retrieve_entity_balances.
49+
- payment_reference str [Required] The reference that must be quoted on the
50+
payment. It is how an incoming payment is attributed to
51+
the sub-account. A payment sent without this reference
52+
may not be credited.
53+
- bank_details dict [Required] The bank details for each available funding
54+
rail:
55+
- domestic dict [Optional] Funding details for the domestic rail.
56+
- international dict [Optional] Funding details for the international rail.
57+
58+
Both rails are optional and their availability depends on the sub-account's holding
59+
currency, jurisdiction, and banking partner. Do not assume that both rails are always
60+
available; bank_details may contain neither.
61+
62+
Each rail, when present, has the following keys. Only beneficiary_account_name and
63+
bank_name are always returned; the rest vary by rail and the receiving bank's
64+
jurisdiction, and are omitted when they do not apply.
65+
66+
- beneficiary_account_name str [Required] The name of the account that receives the
67+
funds.
68+
- beneficiary_address str [Optional] The address of the beneficiary, if the rail
69+
requires it.
70+
- bank_name str [Required] The name of the bank that receives the
71+
funds.
72+
- bank_address str [Optional] The address of the receiving bank, if the
73+
rail requires it.
74+
- account_number str [Optional] The account number of the receiving
75+
account.
76+
- sort_code str [Optional] The sort code of the receiving bank.
77+
Returned for United Kingdom domestic transfers.
78+
- routing_number str [Optional] The routing number of the receiving bank.
79+
Returned for United States domestic transfers.
80+
- iban str [Optional] The International Bank Account Number of
81+
the receiving account.
82+
- swift_code str [Optional] The SWIFT or BIC code of the receiving
83+
bank. Returned for international transfers.
84+
85+
Raises:
86+
CheckoutArgumentException: If either path parameter is None, empty or blank. Both
87+
segments are interpolated straight into the request path, so a blank value would
88+
build a malformed URL and be rejected by the API rather than by the SDK.
89+
"""
90+
if not entity_id or not entity_id.strip():
91+
raise CheckoutArgumentException('entity_id cannot be blank')
92+
if not currency_account_id or not currency_account_id.strip():
93+
raise CheckoutArgumentException('currency_account_id cannot be blank')
94+
95+
return self._api_client.get(
96+
self.build_path(self.__ENTITIES_PATH, entity_id, self.__CURRENCY_ACCOUNTS_PATH, currency_account_id,
97+
self.__TOP_UP_INSTRUCTIONS_PATH),
98+
self._sdk_authorization())

‎checkout_sdk/oauth_scopes.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ class OAuthScopes(str, Enum):
77
ACCOUNTS = 'accounts'
88
BALANCES = 'balances'
99
BALANCES_VIEW = 'balances:view'
10+
BALANCES_TOP_UP_INSTRUCTIONS = 'balances:top-up-instructions'
1011
CARD_MANAGEMENT = 'card-management'
1112
DISPUTES = 'disputes'
1213
DISPUTES_ACCEPT = 'disputes:accept'

‎checkout_sdk/payments/sessions/sessions.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,7 @@ class SubmitPaymentSessionRequest:
297297
currency: Currency
298298
reference: str
299299
items: list # Item
300+
amount_allocations: list # AmountAllocations
300301
three_ds: ThreeDsRequest
301302
ip_address: str
302303
payment_type: PaymentType

‎tests/balances/balances_client_test.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from tests._assertions import assert_api_call
44
from checkout_sdk.balances.balances import BalancesQuery
55
from checkout_sdk.balances.balances_client import BalancesClient
6+
from checkout_sdk.exception import CheckoutArgumentException
67

78

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

1920
assert client.retrieve_entity_balances('entity_id', query) == 'response'
2021
assert_api_call(mock, 'balances/entity_id', query)
22+
23+
def test_should_retrieve_top_up_instructions(self, mocker, client: BalancesClient):
24+
mock = mocker.patch('checkout_sdk.api_client.ApiClient.get', return_value='response')
25+
26+
assert client.retrieve_top_up_instructions(
27+
'ent_w4jelhppmfiufdnatam37wrfc4', 'ca_g5y7d6jo4e2urgforcbf2ey5jm') == 'response'
28+
assert_api_call(
29+
mock,
30+
'entities/ent_w4jelhppmfiufdnatam37wrfc4/currency-accounts/'
31+
'ca_g5y7d6jo4e2urgforcbf2ey5jm/top-up-instructions')
32+
33+
# Both values are interpolated straight into the path, so a blank one would build a malformed
34+
# URL. The guard must reject it before any request is made.
35+
@pytest.mark.parametrize('entity_id, currency_account_id, expected', [
36+
(None, 'ca_g5y7d6jo4e2urgforcbf2ey5jm', 'entity_id cannot be blank'),
37+
('', 'ca_g5y7d6jo4e2urgforcbf2ey5jm', 'entity_id cannot be blank'),
38+
(' ', 'ca_g5y7d6jo4e2urgforcbf2ey5jm', 'entity_id cannot be blank'),
39+
('ent_w4jelhppmfiufdnatam37wrfc4', None, 'currency_account_id cannot be blank'),
40+
('ent_w4jelhppmfiufdnatam37wrfc4', '', 'currency_account_id cannot be blank'),
41+
('ent_w4jelhppmfiufdnatam37wrfc4', ' ', 'currency_account_id cannot be blank'),
42+
])
43+
def test_should_reject_blank_path_parameters(self, mocker, client: BalancesClient,
44+
entity_id, currency_account_id, expected):
45+
mock = mocker.patch('checkout_sdk.api_client.ApiClient.get', return_value='response')
46+
47+
with pytest.raises(CheckoutArgumentException) as error:
48+
client.retrieve_top_up_instructions(entity_id, currency_account_id)
49+
50+
assert str(error.value) == expected
51+
mock.assert_not_called()

‎tests/balances/balances_integration_test.py‎

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,74 @@
22

33
from checkout_sdk.balances.balances import BalancesQuery
44
from checkout_sdk.common.enums import Currency
5+
from checkout_sdk.exception import CheckoutApiException
56
from tests.checkout_test_utils import assert_response
67

8+
ENTITY_ID = 'ent_kidtcgc3ge5unf4a5i6enhnr5m'
9+
710

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

12-
response = oauth_api.balances.retrieve_entity_balances('ent_kidtcgc3ge5unf4a5i6enhnr5m', query)
15+
response = oauth_api.balances.retrieve_entity_balances(ENTITY_ID, query)
1316
assert_response(response, 'data')
1417
assert response.data.__len__() > 0
1518
for balance in response.data:
1619
assert_response(balance,
1720
'descriptor',
1821
'holding_currency',
1922
'balances')
23+
24+
25+
def test_should_retrieve_top_up_instructions(oauth_api):
26+
"""GET /entities/{entityId}/currency-accounts/{currencyAccountId}/top-up-instructions
27+
28+
Top-ups are not enabled on the sandbox sub-accounts this suite has access to, so the endpoint
29+
answers 403 ("top-ups aren't enabled for the sub-account") rather than 200. Verified live on
30+
2026-09-07 with the balances:top-up-instructions scope granted, which the sandbox IdP issues.
31+
32+
The test accepts either outcome, but only the outcomes the spec documents as "not available
33+
here": 403 and 404. It still fails on 400 (malformed identifiers, i.e. the SDK built the path
34+
wrongly) and on 401 (wrong authorization type), which are the two ways this endpoint could
35+
actually be broken in the SDK.
36+
"""
37+
query = BalancesQuery()
38+
query.with_currency_account_id = True
39+
40+
balances = oauth_api.balances.retrieve_entity_balances(ENTITY_ID, query)
41+
assert_response(balances, 'data')
42+
43+
# Take the first sub-account that reports an id. Requiring the entity to always have one would
44+
# fail this test for a reason unrelated to top-up instructions.
45+
currency_account_id = next(
46+
(b.currency_account_id for b in balances.data
47+
if getattr(b, 'currency_account_id', None)),
48+
None)
49+
if currency_account_id is None:
50+
return
51+
52+
try:
53+
instructions = oauth_api.balances.retrieve_top_up_instructions(ENTITY_ID, currency_account_id)
54+
55+
assert_response(instructions,
56+
'currency_account_id',
57+
'currency',
58+
'payment_reference',
59+
'bank_details')
60+
assert instructions.currency_account_id == currency_account_id
61+
62+
# Assert only what the spec guarantees. bank_details declares no required properties, so
63+
# an empty object is a legal 200 body -- do not require a rail to be present. Where a rail
64+
# IS returned, its two required fields must be.
65+
for rail_name in ('domestic', 'international'):
66+
rail = getattr(instructions.bank_details, rail_name, None)
67+
if rail is None:
68+
continue
69+
assert_response(rail, 'beneficiary_account_name', 'bank_name')
70+
except CheckoutApiException as err:
71+
# 403 = top-ups not enabled for the sub-account, or the credential lacks access.
72+
# 404 = sub-account not found, or it has no top-up instructions available.
73+
# Anything else means the SDK, not the environment, is at fault.
74+
assert err.http_metadata.status_code in (403, 404), \
75+
f'unexpected status {err.http_metadata.status_code} from top-up instructions'

0 commit comments

Comments
 (0)