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
5 changes: 3 additions & 2 deletions lib/checkout_sdk/api_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,9 @@ def default_headers(authorization)

# Attribute name to HTTP header name, for every typed headers container the SDK supports.
#
# The header names are case sensitive in the spec: return-encrypted-cvv is lower case where a
# naive conversion would produce Return-Encrypted-Cvv.
# The header names are emitted with an exact spelling: return-encrypted-cvv (sent by the
# deprecated CardUpdateHeaders) is lower case where a naive conversion would produce
# Return-Encrypted-Cvv.
#
# The values are Strings, not Symbols, and that is load bearing. Faraday::Utils::Headers
# preserves a String key verbatim but rewrites a Symbol key as
Expand Down
23 changes: 11 additions & 12 deletions lib/checkout_sdk/issuing/card_update_headers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,25 @@ module CheckoutSdk
module Issuing
# 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. Headers that are set are still sent unchanged.
#
# Modelled as its own class rather than added to {CheckoutSdk::Common::Headers} because these
# two headers belong to one endpoint, and Common::Headers is shared by every client.
#
# @!attribute return_encrypted_cvv
# Set to "true" to retrieve the card's encrypted credentials in the response. Requires an
# RSA public key in the Encryption-Key header, otherwise the API answers 422 with error code
# encryption_key_required.
#
# A String rather than a boolean: the value is emitted verbatim as an HTTP header, and "true"
# is what the spec shows.
# @deprecated The API no longer documents these headers; use the method without headers.
# Will be removed in the next major version.
# No longer documented by the API. Still sent unchanged as the return-encrypted-cvv HTTP
# header when set.
# [Optional]
# Maps to HTTP header return-encrypted-cvv.
# Example: "true"
# @return [String]
# @!attribute encryption_key
# The RSA public key used to encrypt returned credentials. Required when
# return_encrypted_cvv is set. Provide the public key with the BEGIN PUBLIC KEY and END
# PUBLIC KEY headers and any newline characters removed, encoded as Base64.
# @deprecated The API no longer documents these headers; use the method without headers.
# Will be removed in the next major version.
# No longer documented by the API. Still sent unchanged as the Encryption-Key HTTP header
# when set.
# [Optional]
# Maps to HTTP header Encryption-Key.
# @return [String]
class CardUpdateHeaders
attr_accessor :return_encrypted_cvv, :encryption_key
Expand Down
31 changes: 20 additions & 11 deletions lib/checkout_sdk/issuing/issuing_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,13 @@ def get_card_details(card_id)
#
# @param [String] card_id
# @param [Hash, UpdateCardRequest] update_card_request
# @param [CardUpdateHeaders, nil] headers the optional return-encrypted-cvv and
# Encryption-Key HTTP headers. Setting return_encrypted_cvv without encryption_key returns
# a 422 with error code encryption_key_required.
# @return [OpenStruct] the update response, carrying encrypted_cvv when requested. For a
# virtual card the response may also carry is_single_use (boolean, default false),
# specifying whether the card is set to expire after a single use. Physical cards never
# send it.
# @param [CardUpdateHeaders, nil] 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 return-encrypted-cvv and Encryption-Key headers are still sent unchanged.
# @return [OpenStruct] the update-card-response: the updated card details and _links. The
# type field discriminates the variant; a virtual card response also carries
# is_single_use (boolean, default false), whether the card expires after a single use.
# Physical card responses never carry it.
def update_card(card_id, update_card_request, headers = nil)
api_client.invoke_patch(
build_path(ISSUING, CARDS, card_id),
Expand Down Expand Up @@ -134,15 +134,24 @@ def get_card_credentials(card_id, query)
api_client.invoke_get(build_path(ISSUING, CARDS, card_id, CREDENTIALS), sdk_authorization, query)
end

# Revoke a card. POST /issuing/cards/{cardId}/revoke. The request body is optional; when
# omitted an empty JSON object is sent.
# @param [String] card_id
# @param [Hash] revoke_request
def revoke_card(card_id, revoke_request)
# @param [Hash, nil] revoke_request optional, may carry reason (expired, reported_lost,
# reported_stolen)
# @return [OpenStruct] the revoke-card-response, carrying _links with self and controls.
def revoke_card(card_id, revoke_request = nil)
api_client.invoke_post(build_path(ISSUING, CARDS, card_id, REVOKE), sdk_authorization, revoke_request)
end

# Suspend a card. POST /issuing/cards/{cardId}/suspend. The request body is optional; when
# omitted an empty JSON object is sent.
# @param [String] card_id
# @param [Hash] suspend_request
def suspend_card(card_id, suspend_request)
# @param [Hash, nil] suspend_request optional, may carry reason (suspected_lost,
# suspected_stolen)
# @return [OpenStruct] the suspend-card-response, carrying _links with self, activate,
# revoke and controls.
def suspend_card(card_id, suspend_request = nil)
api_client.invoke_post(build_path(ISSUING, CARDS, card_id, SUSPEND), sdk_authorization, suspend_request)
end

Expand Down
15 changes: 14 additions & 1 deletion spec/checkout_sdk/api_client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,8 @@

api_client.invoke_patch('issuing/cards/crd_1', auth, {}, extra)

# return-encrypted-cvv is lower case in the spec and Encryption-Key is title case. String
# The deprecated headers are still sent when set: return-encrypted-cvv is lower case and
# Encryption-Key is title case. String
# keys are what preserves both; Symbol keys would have become Return-encrypted-cvv and
# Encryption-key by the time Faraday wrote the request.
expect(captured['return-encrypted-cvv']).to eq('true')
Expand Down Expand Up @@ -254,6 +255,18 @@
expect(captured['return-encrypted-cvv']).to eq('true')
end

it 'sends an empty JSON object when a POST has no body, as revoke and suspend card do' do
captured = nil
allow(http_client).to receive(:run_request) do |_method, _path, body, _headers|
captured = body
response
end

api_client.invoke_post('issuing/cards/crd_1/revoke', auth)

expect(captured).to eq('{}')
end

it 'omits the If-Match header when no headers container is passed' do
captured = nil
allow(http_client).to receive(:run_request) do |_method, _path, _body, headers|
Expand Down
35 changes: 15 additions & 20 deletions spec/checkout_sdk/identities/identities_response_shape_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -158,14 +158,14 @@ def parse(body, status: 200)
end
end

# Part D: the API answers 422 with error code encryption_key_required when return-encrypted-cvv
# is set to "true" without an Encryption-Key header. parse_response raises for status >= 400, so
# the error body has to survive into the exception for a caller to act on it.
describe 'the card update 422' do
it 'raises CheckoutApiException carrying the encryption_key_required code' do
# Part D: an API error body has to survive into the exception for a caller to act on it.
# transition_invalid is what the API answers when activating a revoked card. The error_type
# invalid_request matches what the live API returns.
describe 'a card management 422' do
it 'raises CheckoutApiException carrying the error codes' do
body = '{"request_id":"0HLHPN8802NUF:00000003",' \
'"error_type":"request_invalid",' \
'"error_codes":["encryption_key_required"]}'
'"error_type":"invalid_request",' \
'"error_codes":["transition_invalid"]}'
response = double('Response', status: 422, body: body,
headers: { 'Content-Type' => 'application/json' })
allow(CheckoutSdk::CheckoutUtils).to receive(:map_to_http_metadata).with(response).and_return(
Expand All @@ -175,27 +175,22 @@ def parse(body, status: 200)
expect { api_client.send(:parse_response, response) }
.to raise_error(CheckoutSdk::CheckoutApiException) { |error|
expect(error.http_metadata.status_code).to eq(422)
expect(error.error_details.error_type).to eq('request_invalid')
expect(error.error_details.error_codes).to include('encryption_key_required')
expect(error.error_details.error_type).to eq('invalid_request')
expect(error.error_details.error_codes).to include('transition_invalid')
}
end
end

# The success counterpart: encrypted_cvv is returned only when the headers ask for it, so it is
# absent on an ordinary update.
# The update card response carries no encrypted credentials, with or without the deprecated
# headers.
describe 'the card update response' do
it 'exposes encrypted_cvv when the headers requested it' do
response = parse('{"last_modified_date":"2026-06-01T10:00:00Z","encrypted_cvv":"oJMoNMEEUiQKYOsQ4Zd"}')
it 'parses the update response fields' do
response = parse('{"id":"crd_1","status":"active","last_modified_date":"2026-06-01T10:00:00Z"}')

expect(response.encrypted_cvv).to eq('oJMoNMEEUiQKYOsQ4Zd')
expect(response.id).to eq('crd_1')
expect(response.status).to eq('active')
expect(response.last_modified_date).to eq('2026-06-01T10:00:00Z')
end

it 'leaves encrypted_cvv nil on an ordinary update' do
response = parse('{"last_modified_date":"2026-06-01T10:00:00Z"}')

expect(response.encrypted_cvv).to be_nil
end
end

# Part C is a no-op in ruby because responses are OpenStruct, but the key still changed, so the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def serialize(request)
end

describe CheckoutSdk::Issuing::CardUpdateHeaders do
it 'exposes the two headers the endpoint accepts' do
it 'still exposes the two deprecated headers' do
headers = described_class.new
expect(headers).to respond_to(:return_encrypted_cvv=)
expect(headers).to respond_to(:encryption_key=)
Expand Down
30 changes: 30 additions & 0 deletions spec/checkout_sdk/issuing/issuing_extras_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,36 @@
end
end

describe '#revoke_card' do
it 'POSTs issuing/cards/{id}/revoke with no body' do
expect(api_client_mock).to receive(:invoke_post)
.with('issuing/cards/crd_1/revoke', 'secret_key', nil).and_return('r')
expect(client.revoke_card('crd_1')).to eq('r')
end

it 'POSTs issuing/cards/{id}/revoke with a reason' do
req = { reason: 'reported_lost' }
expect(api_client_mock).to receive(:invoke_post)
.with('issuing/cards/crd_1/revoke', 'secret_key', req).and_return('r')
expect(client.revoke_card('crd_1', req)).to eq('r')
end
end

describe '#suspend_card' do
it 'POSTs issuing/cards/{id}/suspend with no body' do
expect(api_client_mock).to receive(:invoke_post)
.with('issuing/cards/crd_1/suspend', 'secret_key', nil).and_return('r')
expect(client.suspend_card('crd_1')).to eq('r')
end

it 'POSTs issuing/cards/{id}/suspend with a reason' do
req = { reason: 'suspected_stolen' }
expect(api_client_mock).to receive(:invoke_post)
.with('issuing/cards/crd_1/suspend', 'secret_key', req).and_return('r')
expect(client.suspend_card('crd_1', req)).to eq('r')
end
end

describe '#renew_card' do
it 'POSTs issuing/cards/{id}/renew' do
expect(api_client_mock).to receive(:invoke_post)
Expand Down
Loading