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
59 changes: 48 additions & 11 deletions checkout_sdk/issuing/cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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):
Expand All @@ -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]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
37 changes: 29 additions & 8 deletions checkout_sdk/issuing/issuing_client.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
59 changes: 29 additions & 30 deletions tests/issuing/card_update_headers_wire_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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'
Expand All @@ -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'
Expand All @@ -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')

Expand All @@ -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",'
Expand All @@ -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)
4 changes: 0 additions & 4 deletions tests/issuing/cards_issuing_integration_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
28 changes: 26 additions & 2 deletions tests/issuing/issuing_client_test.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import warnings

import pytest

from tests._assertions import assert_api_call
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -129,13 +139,27 @@ 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()

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')

Expand Down
Loading
Loading