diff --git a/checkout_sdk/issuing/cards.py b/checkout_sdk/issuing/cards.py index d517e36a..80541081 100644 --- a/checkout_sdk/issuing/cards.py +++ b/checkout_sdk/issuing/cards.py @@ -52,10 +52,31 @@ class ShippingInstructions: class CardMetadata: + """User's metadata.""" + # User defined field 1 + # [Optional] + # Pattern: ^.{0,255}$ + # Example: metadata1 udf1: str + # User defined field 2 + # [Optional] + # Pattern: ^.{0,255}$ + # Example: metadata2 udf2: str + # User defined field 3 + # [Optional] + # Pattern: ^.{0,255}$ + # Example: metadata3 udf3: str + # User defined field 4 + # [Optional] + # Pattern: ^.{0,255}$ + # Example: metadata4 udf4: str + # User defined field 5 + # [Optional] + # Pattern: ^.{0,255}$ + # Example: metadata5 udf5: str @@ -114,7 +135,10 @@ class CardRequest: # [Optional] # Example: 2026-06-01T10:00Z scheduled_activation_date: str - # yyyy-mm-dd. The card is revoked at midnight UTC on this date. (IssuingScheduledRevocationDate) + # The card will be revoked at midnight UTC on the date specified. + # [Optional] + # Format: date (YYYY-MM-DD) + # Example: 2027-03-12 scheduled_revocation_date: str def __init__(self, type_p: CardType): @@ -140,9 +164,12 @@ def __init__(self): class UpdateCardRequest: """Request body for PATCH /issuing/cards/{cardId}.""" - # Set to CardStatusUpdate.ACTIVE to reactivate an inactive or suspended card. - # Mutually exclusive with scheduled_activation_date (API returns - # scheduled_activation_date_conflicts_with_activation otherwise). + # Set the card's status to active to activate an inactive or suspended card. If you submit + # this field, you cannot specify a scheduled_activation_date. If you do, you receive a + # scheduled_activation_date_conflicts_with_activation error. + # [Optional] + # Enum: active + # Example: active status: CardStatusUpdate # Your reference. # [Optional] @@ -178,27 +205,29 @@ class UpdateCardRequest: # Format: yyyy-MM-dd (time is midnight UTC) # Example: 2027-03-12 revocation_date: str - # yyyy-mm-dd. The card is revoked at midnight UTC on this date. (IssuingScheduledRevocationDate) + # The card will be revoked at midnight UTC on the date specified. + # [Optional] + # Format: date (YYYY-MM-DD) + # Example: 2027-03-12 scheduled_revocation_date: str class CardUpdateHeaders: """The optional HTTP headers accepted when updating a card's details. + Deprecated: The API no longer documents these headers; use the method without headers. + Will be removed in the next major version. When set, they are still sent unchanged. + Header values are stringified by ApiClient, so declare the boolean header as the string "true" rather than a Python bool: str(True) is "True", which is not the value the spec shows. All three existing header classes in this SDK use str for the same reason. """ - # Set to "true" to retrieve the card's encrypted credentials in the response. Requires an RSA - # public key to be provided in the Encryption-Key header. + # Deprecated: no longer documented by the API. Still sent when set. # [Optional] # Maps to HTTP header return-encrypted-cvv. # Example: "true" return_encrypted_cvv: str - # The RSA public key used to encrypt returned credentials. Required when the - # return-encrypted-cvv header is set to "true". Provide the public key with the - # BEGIN PUBLIC KEY and END PUBLIC KEY headers and any newline characters removed, encoded as - # Base64. + # Deprecated: no longer documented by the API. Still sent when set. # [Optional] # Maps to HTTP header Encryption-Key. encryption_key: str @@ -254,8 +283,16 @@ class CardCredentialsQuery: class RevokeRequest: + """Optional request body for POST /issuing/cards/{cardId}/revoke.""" + # The reason why the card is being revoked. + # [Optional] + # Enum: expired, reported_lost, reported_stolen reason: RevokeReason class SuspendRequest: + """Optional request body for POST /issuing/cards/{cardId}/suspend.""" + # The reason why the card is being suspended. + # [Optional] + # Enum: suspected_lost, suspected_stolen reason: SuspendReason diff --git a/checkout_sdk/issuing/issuing_client.py b/checkout_sdk/issuing/issuing_client.py index 3a9cfd9a..60388ee1 100644 --- a/checkout_sdk/issuing/issuing_client.py +++ b/checkout_sdk/issuing/issuing_client.py @@ -1,5 +1,7 @@ from __future__ import absolute_import +import warnings + from checkout_sdk.api_client import ApiClient from checkout_sdk.authorization_type import AuthorizationType from checkout_sdk.checkout_configuration import CheckoutConfiguration @@ -88,14 +90,17 @@ def update_card(self, card_id: str, update_card_request: UpdateCardRequest, Args: card_id: The card's unique identifier. update_card_request: The card fields to update. - headers: Optional return-encrypted-cvv and Encryption-Key HTTP headers. Setting - return-encrypted-cvv to "true" without Encryption-Key returns a 422 with error - code encryption_key_required. + headers: Deprecated. The API no longer documents these headers; use the method + without headers. Will be removed in the next major version. When set, the + headers are still sent unchanged. Returns: - ResponseWrapper with the update response, including encrypted_cvv when requested. - For a virtual card, the response may also include is_single_use, specifying whether - the card is set to expire after a single use. Physical cards never send it. + ResponseWrapper with the update card response. For a virtual card, the response + may also include is_single_use, specifying whether the card is set to expire after + a single use. Physical cards never send it. """ + if headers is not None: + warnings.warn('The API no longer documents these headers; use the method without headers. ' + 'Will be removed in the next major version.', DeprecationWarning, stacklevel=2) return self._api_client.patch(self.build_path(self.__ISSUING, self.__CARDS, card_id), self._sdk_authorization(), update_card_request, @@ -129,12 +134,28 @@ def renew_card(self, card_id: str, renew_card_request: RenewCardRequest): self._sdk_authorization(), renew_card_request) - def revoke_card(self, card_id: str, revoke_request: RevokeRequest): + def revoke_card(self, card_id: str, revoke_request: RevokeRequest = None): + """Revoke an issued card. Revoking is permanent. + + Args: + card_id: The card's unique identifier. + revoke_request: Optional request body with the reason. When omitted, no body is sent. + Returns: + ResponseWrapper with the revoke card response. + """ return self._api_client.post(self.build_path(self.__ISSUING, self.__CARDS, card_id, self.__REVOKE), self._sdk_authorization(), revoke_request) - def suspend_card(self, card_id: str, suspend_request: SuspendRequest): + def suspend_card(self, card_id: str, suspend_request: SuspendRequest = None): + """Suspend an issued card. + + Args: + card_id: The card's unique identifier. + suspend_request: Optional request body with the reason. When omitted, no body is sent. + Returns: + ResponseWrapper with the suspend card response. + """ return self._api_client.post(self.build_path(self.__ISSUING, self.__CARDS, card_id, self.__SUSPEND), self._sdk_authorization(), suspend_request) diff --git a/tests/issuing/card_update_headers_wire_test.py b/tests/issuing/card_update_headers_wire_test.py index 41d4daa2..369b99e5 100644 --- a/tests/issuing/card_update_headers_wire_test.py +++ b/tests/issuing/card_update_headers_wire_test.py @@ -4,7 +4,6 @@ from requests import HTTPError from checkout_sdk.api_client import ApiClient -from checkout_sdk.exception import CheckoutApiException from checkout_sdk.issuing.cards import CardUpdateHeaders, UpdateCardRequest from checkout_sdk.issuing.issuing_client import IssuingClient @@ -43,13 +42,14 @@ def _sent_headers(http_client): class TestCardUpdateHeadersReachTheWire: - def test_both_headers_use_their_exact_swagger_names(self, mock_sdk_configuration): + def test_deprecated_headers_are_still_sent_with_their_exact_names(self, mock_sdk_configuration): client, http_client = _build(mock_sdk_configuration) headers = CardUpdateHeaders() headers.return_encrypted_cvv = 'true' headers.encryption_key = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A' - client.update_card('crd_123', UpdateCardRequest(), headers) + with pytest.warns(DeprecationWarning, match='next major version'): + client.update_card('crd_123', UpdateCardRequest(), headers) sent = _sent_headers(http_client) assert sent['return-encrypted-cvv'] == 'true' @@ -70,7 +70,8 @@ def test_the_key_can_be_sent_without_the_flag(self, mock_sdk_configuration): headers = CardUpdateHeaders() headers.encryption_key = 'MIIBIjAN' - client.update_card('crd_123', UpdateCardRequest(), headers) + with pytest.warns(DeprecationWarning, match='next major version'): + client.update_card('crd_123', UpdateCardRequest(), headers) sent = _sent_headers(http_client) assert sent['Encryption-Key'] == 'MIIBIjAN' @@ -85,39 +86,23 @@ def test_a_python_bool_would_send_the_capitalised_string(self, mock_sdk_configur headers = CardUpdateHeaders() headers.return_encrypted_cvv = True - client.update_card('crd_123', UpdateCardRequest(), headers) - - assert _sent_headers(http_client)['return-encrypted-cvv'] == 'True' - - def test_a_422_surfaces_the_encryption_key_required_error(self, mock_sdk_configuration): - """The API answers 422 with error code encryption_key_required when return-encrypted-cvv - is true without an Encryption-Key header.""" - client, _ = _build(mock_sdk_configuration, status=422, body='{' - '"request_id":"0HLHPN8802NUF:00000003",' - '"error_type":"request_invalid",' - '"error_codes":["encryption_key_required"]}') - headers = CardUpdateHeaders() - headers.return_encrypted_cvv = 'true' - - with pytest.raises(CheckoutApiException) as exc: + with pytest.warns(DeprecationWarning, match='next major version'): client.update_card('crd_123', UpdateCardRequest(), headers) - assert exc.value.error_type == 'request_invalid' - assert 'encryption_key_required' in exc.value.error_details - assert exc.value.request_id == '0HLHPN8802NUF:00000003' + assert _sent_headers(http_client)['return-encrypted-cvv'] == 'True' - def test_a_successful_update_with_the_headers_still_has_no_encrypted_cvv(self, mock_sdk_configuration): - """The 2026-09-17 spec (INT-1700) removed encrypted_cvv from update-card-response - entirely, so return-encrypted-cvv/Encryption-Key no longer make the response carry it. - This test previously asserted the opposite (added by INT-1695, when the field still - existed).""" + def test_the_deprecated_headers_do_not_change_the_response(self, mock_sdk_configuration): + """The headers are no longer documented by the API but are still sent when set: an update + that sends them gets the same update card response, with no encrypted credentials.""" client, _ = _build(mock_sdk_configuration, body='{"last_modified_date":"2026-06-01T10:00:00Z"}') headers = CardUpdateHeaders() headers.return_encrypted_cvv = 'true' headers.encryption_key = 'MIIBIjAN' - response = client.update_card('crd_123', UpdateCardRequest(), headers) + with pytest.warns(DeprecationWarning, match='next major version'): + response = client.update_card('crd_123', UpdateCardRequest(), headers) + assert response.http_metadata.status_code == 200 assert response.last_modified_date == '2026-06-01T10:00:00Z' assert not hasattr(response, 'encrypted_cvv') @@ -131,8 +116,7 @@ def test_a_successful_update_without_the_headers_has_no_encrypted_cvv(self, mock assert not hasattr(response, 'encrypted_cvv') def test_update_card_response_round_trips_is_single_use_for_virtual_cards(self, mock_sdk_configuration): - """The 2026-09-23 spec update split update-card-response into a virtual/physical - discriminator; the virtual variant adds is_single_use.""" + """update-card-response has a type discriminator; the virtual variant adds is_single_use.""" client, _ = _build(mock_sdk_configuration, body='{' '"type":"virtual",' '"last_modified_date":"2026-06-01T10:00:00Z",' @@ -141,3 +125,18 @@ def test_update_card_response_round_trips_is_single_use_for_virtual_cards(self, response = client.update_card('crd_123', UpdateCardRequest()) assert response.is_single_use is True + + +class TestRevokeAndSuspendWithoutBodyReachTheWire: + """The revoke and suspend request bodies are optional in the spec: omitting them sends no body.""" + + @pytest.mark.parametrize('operation, path', [('revoke_card', 'revoke'), ('suspend_card', 'suspend')]) + def test_no_body_is_sent_when_the_request_is_omitted(self, mock_sdk_configuration, operation, path): + client, http_client = _build(mock_sdk_configuration) + + getattr(client, operation)('crd_123') + + kwargs = http_client.request.call_args.kwargs + assert kwargs['data'] is None + assert kwargs['method'] == 'POST' + assert kwargs['url'].endswith('issuing/cards/crd_123/' + path) diff --git a/tests/issuing/cards_issuing_integration_test.py b/tests/issuing/cards_issuing_integration_test.py index 319954d6..ec00551c 100644 --- a/tests/issuing/cards_issuing_integration_test.py +++ b/tests/issuing/cards_issuing_integration_test.py @@ -68,10 +68,6 @@ def test_should_update_card_scheduled_activation_date(self, issuing_checkout_api assert_response(response) assert response.http_metadata.status_code == 200 - # The 2026-09-17 spec (INT-1700) removed encrypted_cvv from update-card-response entirely, - # so return-encrypted-cvv/Encryption-Key no longer make the update response carry it. This - # test previously asserted the opposite (added by INT-1695, when the field still existed). - def test_should_renew_card(self, issuing_checkout_api, card): request = VirtualCardRenewRequest() request.reference = 'RENEW-REF-123' diff --git a/tests/issuing/issuing_client_test.py b/tests/issuing/issuing_client_test.py index b87626d8..26891c3f 100644 --- a/tests/issuing/issuing_client_test.py +++ b/tests/issuing/issuing_client_test.py @@ -1,3 +1,5 @@ +import warnings + import pytest from tests._assertions import assert_api_call @@ -69,17 +71,25 @@ def test_should_update_card(self, mocker, client: IssuingClient): assert_api_call(mock, 'issuing/cards/card_id', body) assert mock.call_args.kwargs['headers'] is None - def test_should_update_card_with_the_encrypted_cvv_headers(self, mocker, client: IssuingClient): + def test_should_update_card_with_the_deprecated_headers(self, mocker, client: IssuingClient): mock = mocker.patch('checkout_sdk.api_client.ApiClient.patch', return_value='response') body = UpdateCardRequest() headers = CardUpdateHeaders() headers.return_encrypted_cvv = 'true' headers.encryption_key = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A' - assert client.update_card('card_id', body, headers) == 'response' + with pytest.warns(DeprecationWarning, match='next major version'): + assert client.update_card('card_id', body, headers) == 'response' assert_api_call(mock, 'issuing/cards/card_id', body) assert mock.call_args.kwargs['headers'] is headers + def test_should_not_warn_when_updating_card_without_headers(self, mocker, client: IssuingClient): + mocker.patch('checkout_sdk.api_client.ApiClient.patch', return_value='response') + + with warnings.catch_warnings(): + warnings.simplefilter('error', DeprecationWarning) + assert client.update_card('card_id', UpdateCardRequest()) == 'response' + def test_should_enroll_three_ds(self, mocker, client: IssuingClient): mock = mocker.patch('checkout_sdk.api_client.ApiClient.post', return_value='response') body = PasswordEnrollmentRequest() @@ -129,6 +139,13 @@ def test_should_revoke_card(self, mocker, client: IssuingClient): assert client.revoke_card('card_id', body) == 'response' assert_api_call(mock, 'issuing/cards/card_id/revoke', body) + def test_should_revoke_card_without_body(self, mocker, client: IssuingClient): + mock = mocker.patch('checkout_sdk.api_client.ApiClient.post', return_value='response') + + assert client.revoke_card('card_id') == 'response' + assert_api_call(mock, 'issuing/cards/card_id/revoke') + assert mock.call_args.args[2] is None + def test_should_suspend_card(self, mocker, client: IssuingClient): mock = mocker.patch('checkout_sdk.api_client.ApiClient.post', return_value='response') body = SuspendRequest() @@ -136,6 +153,13 @@ def test_should_suspend_card(self, mocker, client: IssuingClient): assert client.suspend_card('card_id', body) == 'response' assert_api_call(mock, 'issuing/cards/card_id/suspend', body) + def test_should_suspend_card_without_body(self, mocker, client: IssuingClient): + mock = mocker.patch('checkout_sdk.api_client.ApiClient.post', return_value='response') + + assert client.suspend_card('card_id') == 'response' + assert_api_call(mock, 'issuing/cards/card_id/suspend') + assert mock.call_args.args[2] is None + def test_should_get_digital_card(self, mocker, client: IssuingClient): mock = mocker.patch('checkout_sdk.api_client.ApiClient.get', return_value='response') diff --git a/tests/issuing/issuing_serialization_test.py b/tests/issuing/issuing_serialization_test.py index cf60934d..ca80c628 100644 --- a/tests/issuing/issuing_serialization_test.py +++ b/tests/issuing/issuing_serialization_test.py @@ -1,10 +1,12 @@ import json + +import pytest from datetime import date from checkout_sdk.json_serializer import JsonSerializer from checkout_sdk.api_client import ApiClient from checkout_sdk.issuing.cards import CardRequest, CardType, CardUpdateHeaders, UpdateCardRequest, \ - VirtualCardRequest, CardStatusUpdate + VirtualCardRequest, CardStatusUpdate, RevokeRequest, RevokeReason, SuspendRequest, SuspendReason from checkout_sdk.issuing.disputes import ( IssuingDisputeFraudType, IssuingDisputeFraudDetails, CreateDisputeRequest, EscalateDisputeRequest, AmendDisputeRequest, SubmitDisputeRequest, @@ -80,7 +82,7 @@ def test_create_card_serializes_scheduled_activation_date(self): def test_revocation_date_accepts_a_date_object_through_the_serializer(self): """revocation_date is `format: date`. The SDK convention is to declare it str with a - `# Format: yyyy-MM-dd` comment, and the JsonSerializer date branch added in INT-1699 is + `# Format: yyyy-MM-dd` comment, and the JsonSerializer date branch is the safety net for a caller who passes a real date instead.""" request = UpdateCardRequest() request.revocation_date = date(2027, 3, 12) @@ -132,7 +134,7 @@ def test_update_card_request_from_swagger_example(self): assert payload['scheduled_activation_date'] == '2026-06-01T10:00Z' assert payload['revocation_date'] == '2027-03-12' - def test_card_update_headers_map_to_the_exact_swagger_header_names(self): + def test_deprecated_card_update_headers_keep_their_exact_header_names(self): assert CardUpdateHeaders().get_header_mappings() == { 'return_encrypted_cvv': 'return-encrypted-cvv', 'encryption_key': 'Encryption-Key', @@ -225,3 +227,44 @@ def test_submit_dispute_serializes_fields(self): request.amount = 100 assert _serialize(request) == {'reason': '4807', 'amount': 100} + + +class TestRevokeAndSuspendRequestSerialization: + """revoke-card-request and suspend-card-request: one optional `reason` property each.""" + + @pytest.mark.parametrize('reason, wire', [(RevokeReason.EXPIRED, 'expired'), + (RevokeReason.REPORTED_LOST, 'reported_lost'), + (RevokeReason.REPORTED_STOLEN, 'reported_stolen')]) + def test_revoke_request_round_trips_every_reason(self, reason, wire): + request = RevokeRequest() + request.reason = reason + + payload = _serialize(request) + + assert payload == {'reason': wire} + assert RevokeReason(payload['reason']) is reason + + @pytest.mark.parametrize('reason, wire', [(SuspendReason.SUSPECTED_LOST, 'suspected_lost'), + (SuspendReason.SUSPECTED_STOLEN, 'suspected_stolen')]) + def test_suspend_request_round_trips_every_reason(self, reason, wire): + request = SuspendRequest() + request.reason = reason + + payload = _serialize(request) + + assert payload == {'reason': wire} + assert SuspendReason(payload['reason']) is reason + + def test_reason_enums_match_the_swagger_values_exactly(self): + assert [m.value for m in RevokeReason] == ['expired', 'reported_lost', 'reported_stolen'] + assert [m.value for m in SuspendReason] == ['suspected_lost', 'suspected_stolen'] + + def test_swagger_examples_deserialize_into_the_enums(self): + assert RevokeReason(json.loads('{"reason":"reported_lost"}')['reason']) is RevokeReason.REPORTED_LOST + assert SuspendReason(json.loads('{"reason":"suspected_lost"}')['reason']) is SuspendReason.SUSPECTED_LOST + + @pytest.mark.parametrize('request_type', [RevokeRequest, SuspendRequest]) + def test_an_empty_request_serializes_to_an_empty_object(self, request_type): + """An empty request object serializes to {}. Passing no request at all sends no body, + which the wire tests assert.""" + assert json.dumps(request_type(), cls=JsonSerializer) == '{}'