diff --git a/checkout_sdk/balances/balances.py b/checkout_sdk/balances/balances.py index 32b1be4c..3a7c14e5 100644 --- a/checkout_sdk/balances/balances.py +++ b/checkout_sdk/balances/balances.py @@ -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 diff --git a/checkout_sdk/balances/balances_client.py b/checkout_sdk/balances/balances_client.py index 1b4ce8c7..086b0d60 100644 --- a/checkout_sdk/balances/balances_client.py +++ b/checkout_sdk/balances/balances_client.py @@ -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): @@ -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()) diff --git a/checkout_sdk/oauth_scopes.py b/checkout_sdk/oauth_scopes.py index 163fdf55..ddc6cb51 100644 --- a/checkout_sdk/oauth_scopes.py +++ b/checkout_sdk/oauth_scopes.py @@ -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' diff --git a/checkout_sdk/payments/sessions/sessions.py b/checkout_sdk/payments/sessions/sessions.py index d41c4b99..87b1b861 100644 --- a/checkout_sdk/payments/sessions/sessions.py +++ b/checkout_sdk/payments/sessions/sessions.py @@ -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 diff --git a/tests/balances/balances_client_test.py b/tests/balances/balances_client_test.py index bc6cba70..71110be2 100644 --- a/tests/balances/balances_client_test.py +++ b/tests/balances/balances_client_test.py @@ -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') @@ -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() diff --git a/tests/balances/balances_integration_test.py b/tests/balances/balances_integration_test.py index 7b484c06..0dfe386d 100644 --- a/tests/balances/balances_integration_test.py +++ b/tests/balances/balances_integration_test.py @@ -2,14 +2,17 @@ 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: @@ -17,3 +20,56 @@ def test_should_retrieve_entity_balances(oauth_api): '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' diff --git a/tests/balances/balances_serialization_test.py b/tests/balances/balances_serialization_test.py new file mode 100644 index 00000000..c3ba6601 --- /dev/null +++ b/tests/balances/balances_serialization_test.py @@ -0,0 +1,152 @@ +"""Serialization tests for the balances query filter. + +BalancesQuery's attributes are snake_case, but the swagger declares the two query parameters in +camelCase (withCurrencyAccountId, balancesAt). The mapping lives in +JsonSerializer._KEYS_TRANSFORMATIONS, so these tests pin the exact wire names: if an entry is +removed from that table, the API silently ignores the parameters and these tests fail instead. +""" +import json +from datetime import datetime, timezone + +from checkout_sdk.balances.balances import BalancesQuery +from checkout_sdk.checkout_response import ResponseWrapper +from checkout_sdk.json_serializer import JsonSerializer + + +def _serialize(obj): + return json.loads(json.dumps(obj, cls=JsonSerializer)) + + +class TestBalancesQuerySerialization: + + def test_should_map_with_currency_account_id_to_camel_case(self): + query = BalancesQuery() + query.with_currency_account_id = True + + encoded = _serialize(query) + + assert encoded['withCurrencyAccountId'] is True + assert 'with_currency_account_id' not in encoded + + def test_should_map_balances_at_to_camel_case_and_iso_format(self): + query = BalancesQuery() + query.balances_at = datetime(2026, 5, 6, 13, 59, 59, tzinfo=timezone.utc) + + encoded = _serialize(query) + + assert 'balances_at' not in encoded + assert encoded['balancesAt'].startswith('2026-05-06T13:59:59') + + def test_should_serialize_query_unchanged(self): + query = BalancesQuery() + query.query = 'currency:GBP' + + assert _serialize(query)['query'] == 'currency:GBP' + + def test_should_serialize_all_three_parameters(self): + query = BalancesQuery() + query.query = 'currency:GBP' + query.with_currency_account_id = True + query.balances_at = datetime(2026, 5, 6, 13, 59, 59, tzinfo=timezone.utc) + + encoded = _serialize(query) + + assert encoded['query'] == 'currency:GBP' + assert encoded['withCurrencyAccountId'] is True + assert 'balancesAt' in encoded + + +class TestTopUpInstructionsResponseShape: + """Response-shape tests for GET .../top-up-instructions. + + Python has no typed response classes; ApiClient wraps parsed JSON in ResponseWrapper, which + recursively wraps nested dicts (see ResponseWrapper._wrap). These tests build a wrapper from + the spec's payloads and assert the attribute surface callers actually get. + + They cover plan tests 2, 3, 4 and 6. The plan assigned Python only tests 1 and 7 on the + assumption that a response layer was needed for them, but ResponseWrapper provides exactly the + present/absent semantics the rail-optionality criterion requires -- and without these the + "both rails optional" acceptance criterion has no Python coverage at all, because the sandbox + returns 403 so the integration test's success branch never executes. + + Every value is a field-level "example" from shared/swagger-latest.json. + """ + + FULL_RAIL = { + 'beneficiary_account_name': 'Acme Inc', + 'beneficiary_address': '1 Example Street, Exampleville, EX, 00000, US', + 'bank_name': 'Example Bank', + 'bank_address': '1 Example Street, Exampleville, EX, 00000, US', + 'account_number': '1234567890', + 'sort_code': '000000', + 'routing_number': '000000000', + 'iban': 'GB00EXAM00000000000000', + 'swift_code': 'TESTUS00XXX', + } + + @staticmethod + def _wrap(bank_details): + return ResponseWrapper(None, { + 'currency_account_id': 'ca_g5y7d6jo4e2urgforcbf2ey5jm', + 'currency': 'USD', + 'payment_reference': 'TP-ABC123', + 'bank_details': bank_details, + }) + + def _assert_full_rail(self, rail): + assert rail.beneficiary_account_name == 'Acme Inc' + assert rail.beneficiary_address == '1 Example Street, Exampleville, EX, 00000, US' + assert rail.bank_name == 'Example Bank' + assert rail.bank_address == '1 Example Street, Exampleville, EX, 00000, US' + assert rail.account_number == '1234567890' + assert rail.sort_code == '000000' + assert rail.routing_number == '000000000' + assert rail.iban == 'GB00EXAM00000000000000' + assert rail.swift_code == 'TESTUS00XXX' + + def test_should_expose_both_rails_and_all_fields(self): + response = self._wrap({'domestic': dict(self.FULL_RAIL), + 'international': dict(self.FULL_RAIL)}) + + assert response.currency_account_id == 'ca_g5y7d6jo4e2urgforcbf2ey5jm' + assert response.currency == 'USD' + assert response.payment_reference == 'TP-ABC123' + self._assert_full_rail(response.bank_details.domestic) + self._assert_full_rail(response.bank_details.international) + + def test_should_expose_domestic_only(self): + # A United States domestic rail, per "Returned for United States domestic transfers". + response = self._wrap({'domestic': { + 'beneficiary_account_name': 'Acme Inc', + 'bank_name': 'Example Bank', + 'account_number': '1234567890', + 'routing_number': '000000000', + }}) + + assert not hasattr(response.bank_details, 'international') + assert response.bank_details.domestic.routing_number == '000000000' + assert not hasattr(response.bank_details.domestic, 'sort_code') + assert not hasattr(response.bank_details.domestic, 'iban') + assert not hasattr(response.bank_details.domestic, 'swift_code') + + def test_should_expose_international_only(self): + # An international rail, per "Returned for international transfers". + response = self._wrap({'international': { + 'beneficiary_account_name': 'Acme Inc', + 'bank_name': 'Example Bank', + 'iban': 'GB00EXAM00000000000000', + 'swift_code': 'TESTUS00XXX', + }}) + + assert not hasattr(response.bank_details, 'domestic') + assert response.bank_details.international.swift_code == 'TESTUS00XXX' + assert not hasattr(response.bank_details.international, 'account_number') + assert not hasattr(response.bank_details.international, 'routing_number') + + def test_should_accept_empty_bank_details(self): + # TopUpBankDetails declares no required properties, so an empty object is a legal 200 body. + response = self._wrap({}) + + assert response.payment_reference == 'TP-ABC123' + assert not hasattr(response.bank_details, 'domestic') + assert not hasattr(response.bank_details, 'international') diff --git a/tests/conftest.py b/tests/conftest.py index d81cd715..d5ff78ee 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -50,6 +50,7 @@ def oauth_api(): .scopes([OAuthScopes.GATEWAY, OAuthScopes.VAULT, OAuthScopes.PAYOUTS_BANK_DETAILS, OAuthScopes.SESSIONS_APP, OAuthScopes.SESSIONS_BROWSER, OAuthScopes.FX, OAuthScopes.ACCOUNTS, OAuthScopes.FILES, OAuthScopes.TRANSFERS, OAuthScopes.BALANCES_VIEW, + OAuthScopes.BALANCES_TOP_UP_INSTRUCTIONS, OAuthScopes.VAULT_CARD_METADATA, OAuthScopes.FINANCIAL_ACTIONS, OAuthScopes.VAULT_REAL_TIME_ACCOUNT_UPDATER, OAuthScopes.PAYMENTS_SEARCH, OAuthScopes.GATEWAY_PAYMENT_CANCELLATIONS]) diff --git a/tests/oauth_scopes_test.py b/tests/oauth_scopes_test.py new file mode 100644 index 00000000..e4d7a7bd --- /dev/null +++ b/tests/oauth_scopes_test.py @@ -0,0 +1,18 @@ +from checkout_sdk.oauth_scopes import OAuthScopes + + +class TestOAuthScopes: + """The enum members are the only place the wire value of a scope is written down. + + A typo would only surface at the token endpoint, which rejects an undefined scope for the + whole request, so an OAuth-configured caller would lose every scope it asked for alongside + the bad one. + + Values come from components.securitySchemes.OAuth.flows.clientCredentials.scopes in + shared/swagger-latest.json. + """ + + def test_should_expose_documented_balances_scope_values(self): + assert OAuthScopes.BALANCES.value == 'balances' + assert OAuthScopes.BALANCES_VIEW.value == 'balances:view' + assert OAuthScopes.BALANCES_TOP_UP_INSTRUCTIONS.value == 'balances:top-up-instructions' diff --git a/tests/payments/sessions/payment_sessions_serialization_test.py b/tests/payments/sessions/payment_sessions_serialization_test.py new file mode 100644 index 00000000..67b658bb --- /dev/null +++ b/tests/payments/sessions/payment_sessions_serialization_test.py @@ -0,0 +1,71 @@ +import json + +from checkout_sdk.json_serializer import JsonSerializer +from checkout_sdk.common.common import AmountAllocations, Commission +from checkout_sdk.payments.sessions.sessions import SubmitPaymentSessionRequest + + +def _serialize(obj): + return json.loads(json.dumps(obj, cls=JsonSerializer)) + + +class TestSubmitPaymentSessionRequestSerialization: + """amount_allocations was added to SubmitPaymentSessionsRequest by the 2026-08-21 spec change. + + These tests are the only thing that proves the wire name is amount_allocations and that an + unset field stays out of the body. The SDK serializes by reflecting over instance attributes, + so a declared-but-unset attribute can leak as null depending on the serializer's null + handling; the API rejects an explicit null on this field. + + Values are the field-level examples from shared/swagger-latest.json. + """ + + def test_should_serialize_amount_allocations_with_all_item_fields(self): + commission = Commission() + commission.amount = 10 + commission.percentage = 12.5 + + allocation = AmountAllocations() + allocation.id = 'ent_w4jelhppmfiufdnatam37wrfc4' + allocation.amount = 1 + allocation.reference = 'ORD-123A' + allocation.commission = commission + + request = SubmitPaymentSessionRequest() + request.session_data = 'session_data_token' + request.amount_allocations = [allocation] + + assert _serialize(request) == { + 'session_data': 'session_data_token', + 'amount_allocations': [{ + 'id': 'ent_w4jelhppmfiufdnatam37wrfc4', + 'amount': 1, + 'reference': 'ORD-123A', + 'commission': {'amount': 10, 'percentage': 12.5}, + }] + } + + def test_should_serialize_amount_allocations_with_only_required_item_fields(self): + # The item's `required` list is [id, amount]; reference and commission must not appear + # as nulls when they are not set. + allocation = AmountAllocations() + allocation.id = 'ent_w4jelhppmfiufdnatam37wrfc4' + allocation.amount = 1 + + request = SubmitPaymentSessionRequest() + request.amount_allocations = [allocation] + + assert _serialize(request) == { + 'amount_allocations': [{'id': 'ent_w4jelhppmfiufdnatam37wrfc4', 'amount': 1}] + } + + def test_should_omit_amount_allocations_when_unset(self): + # The field is optional with minItems 1: sending `"amount_allocations": null` or an empty + # array is not the same as omitting it, and the API rejects both. + request = SubmitPaymentSessionRequest() + request.session_data = 'session_data_token' + + serialized = _serialize(request) + + assert serialized == {'session_data': 'session_data_token'} + assert 'amount_allocations' not in serialized