Skip to content
6 changes: 5 additions & 1 deletion lib/Checkout/Issuing/Cards/Revoke/RevokeCardRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
class RevokeCardRequest
{
/**
* @var string value of RevokeReason
* The reason why the card is being revoked.
* [Optional]
* Enum: expired, reported_lost, reported_stolen (see RevokeReason).
*
* @var string|null value of RevokeReason
*/
public $reason;
}
6 changes: 5 additions & 1 deletion lib/Checkout/Issuing/Cards/Suspend/SuspendCardRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
class SuspendCardRequest
{
/**
* @var string value of SuspendReason
* The reason why the card is being suspended.
* [Optional]
* Enum: suspected_lost, suspected_stolen (see SuspendReason).
*
* @var string|null value of SuspendReason
*/
public $reason;
}
19 changes: 12 additions & 7 deletions lib/Checkout/Issuing/Cards/Update/CardUpdateHeaders.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,39 @@
namespace Checkout\Issuing\Cards\Update;

/**
* The optional HTTP headers accepted when updating a card's details.
* The optional HTTP headers formerly documented for updating a card's details. They are no
* longer documented by the API and are still sent when set.
*
* Header values are sent as strings, so declare the boolean header as the string "true"
* rather than a PHP boolean: a boolean true would be serialized as "1" and a boolean false
* would be serialized as an empty string and dropped from the request.
*
* @deprecated The API no longer documents these headers; use the method without headers.
* Will be removed in the next major version.
*/
class CardUpdateHeaders
{
/**
* 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. This header is no longer documented by the API and is still sent when set.
* The update card response does not include encrypted credentials.
* [Optional]
* Maps to HTTP header return-encrypted-cvv.
* Example: "true"
*
* @var string|null
* @deprecated The API no longer documents these headers; use the method without headers.
* Will be removed in the next major version.
*/
public $return_encrypted_cvv;

/**
* 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. This header is no longer documented by the API and is still sent when set.
* [Optional]
* Maps to HTTP header Encryption-Key.
*
* @var string|null
* @deprecated The API no longer documents these headers; use the method without headers.
* Will be removed in the next major version.
*/
public $encryption_key;

Expand Down
40 changes: 30 additions & 10 deletions lib/Checkout/Issuing/IssuingClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -218,31 +218,37 @@ public function getCardCredentials(string $cardId, CardCredentialsQuery $query)
}

/**
* @param string $cardId
* @param RevokeCardRequest $revokeCardRequest
* Revoke a card. The request body is optional: a null request, or a request with no reason
* set, sends no body. A request with a reason sends it as JSON.
*
* @param string $cardId - The card's unique identifier. (Required)
* @param RevokeCardRequest|null $revokeCardRequest - The optional reason for the request. (Optional)
* @return array
* @throws CheckoutApiException
*/
public function revokeCard(string $cardId, RevokeCardRequest $revokeCardRequest) : array
public function revokeCard(string $cardId, ?RevokeCardRequest $revokeCardRequest = null) : array
{
return $this->apiClient->post(
$this->buildPath(self::ISSUING_PATH, self::CARDS_PATH, $cardId, self::REVOKE_PATH),
$revokeCardRequest,
$this->optionalReasonBody($revokeCardRequest),
$this->sdkAuthorization()
);
}

/**
* @param string $cardId
* @param SuspendCardRequest $suspendCardRequest
* Suspend a card. The request body is optional: a null request, or a request with no reason
* set, sends no body. A request with a reason sends it as JSON.
*
* @param string $cardId - The card's unique identifier. (Required)
* @param SuspendCardRequest|null $suspendCardRequest - The optional reason for the request. (Optional)
* @return array
* @throws CheckoutApiException
*/
public function suspendCard(string $cardId, SuspendCardRequest $suspendCardRequest) : array
public function suspendCard(string $cardId, ?SuspendCardRequest $suspendCardRequest = null) : array
{
return $this->apiClient->post(
$this->buildPath(self::ISSUING_PATH, self::CARDS_PATH, $cardId, self::SUSPEND_PATH),
$suspendCardRequest,
$this->optionalReasonBody($suspendCardRequest),
$this->sdkAuthorization()
);
}
Expand Down Expand Up @@ -760,8 +766,10 @@ public function getSingleTransaction(string $transactionId) : array
*
* @param string $cardId - The card's unique identifier. (Required)
* @param UpdateCardRequest $updateCardRequest (Required)
* @param CardUpdateHeaders|null $headers - The optional return-encrypted-cvv and
* Encryption-Key HTTP headers. (Optional)
* @param CardUpdateHeaders|null $headers - Deprecated. (Optional)
* The API no longer documents these headers; use the method without headers.
* Will be removed in the next major version.
* Headers set here are still sent.
* @return array
* @throws CheckoutApiException
*/
Expand Down Expand Up @@ -893,4 +901,16 @@ public function updateCardholder(string $cardholderId, UpdateCardholderRequest $
$this->sdkAuthorization()
);
}

/**
* The revoke and suspend bodies are optional and an empty request object would serialize
* to an empty JSON array, which the API rejects. Send no body unless a reason is set.
*
* @param RevokeCardRequest|SuspendCardRequest|null $request
* @return RevokeCardRequest|SuspendCardRequest|null
*/
private function optionalReasonBody($request)
{
return ($request !== null && $request->reason !== null) ? $request : null;
}
}
94 changes: 17 additions & 77 deletions test/Checkout/Tests/Issuing/Cards/CardUpdateHeadersTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,19 @@
use Checkout\CheckoutApiException;
use Checkout\CheckoutArgumentException;
use Checkout\CheckoutAuthorizationException;
use Checkout\CheckoutConfiguration;
use Checkout\CheckoutException;
use Checkout\Environment;
use Checkout\HttpClientBuilderInterface;
use Checkout\Issuing\Cards\Update\CardUpdateHeaders;
use Checkout\Issuing\Cards\Update\UpdateCardRequest;
use Checkout\Issuing\IssuingClient;
use Checkout\PlatformType;
use Checkout\SdkAuthorization;
use Checkout\SdkCredentialsInterface;
use Checkout\Tests\UnitTestFixture;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use ReflectionMethod;

class CardUpdateHeadersTest extends UnitTestFixture
{
use StubbedHttpIssuingClientTrait;

/**
* @var IssuingClient
*/
Expand Down Expand Up @@ -164,44 +158,37 @@ public function shouldUpdateCardDetailsWithoutHeaders()
}

/**
* The API answers 422 with error code encryption_key_required when return-encrypted-cvv is set
* to true without an Encryption-Key header. Asserted through a stubbed Guzzle handler rather
* than a mocked ApiClient, so the status code and the error body travel the real path into
* CheckoutApiException.
* The deprecated headers are no longer documented by the API and are still sent when set:
* setting only return-encrypted-cvv, without an Encryption-Key, still succeeds and the
* response carries no encrypted_cvv.
*
* @test
*/
public function shouldSurfaceThe422WhenTheEncryptionKeyIsMissing()
public function shouldSucceedWhenOnlyTheDeprecatedCvvFlagIsSet()
{
$client = $this->buildClientReturning(422, '{'
. '"request_id":"0HLHPN8802NUF:00000003",'
. '"error_type":"request_invalid",'
. '"error_codes":["encryption_key_required"]'
. '}');
$captured = [];
$client = $this->buildStubbedClient(200, '{"last_modified_date":"2026-06-01T10:00:00Z"}', $captured);

$headers = new CardUpdateHeaders();
$headers->return_encrypted_cvv = "true";

try {
$client->updateCardDetails("crd_12345", new UpdateCardRequest(), $headers);
$this->fail("Expected a CheckoutApiException for the 422 response");
} catch (CheckoutApiException $e) {
$this->assertSame(422, $e->http_metadata->getStatusCode());
$this->assertSame("request_invalid", $e->error_details["error_type"]);
$this->assertContains("encryption_key_required", $e->error_details["error_codes"]);
}
$response = $client->updateCardDetails("crd_12345", new UpdateCardRequest(), $headers);

$this->assertSame(200, $response["http_metadata"]->getStatusCode());
$this->assertArrayNotHasKey("encrypted_cvv", $response);
$this->assertSame("true", $captured[0]->getHeaderLine("return-encrypted-cvv"));
$this->assertFalse($captured[0]->hasHeader("Encryption-Key"));
}

/**
* 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).
* encrypted_cvv is not part of update-card-response and the headers are deprecated, but
* headers set by the caller are still sent and the call still succeeds.
*
* @test
*/
public function shouldSucceedWhenBothHeadersAreSupplied()
{
$client = $this->buildClientReturning(200, '{"last_modified_date":"2026-06-01T10:00:00Z"}');
$client = $this->buildStubbedClient(200, '{"last_modified_date":"2026-06-01T10:00:00Z"}');

$headers = new CardUpdateHeaders();
$headers->return_encrypted_cvv = "true";
Expand All @@ -213,53 +200,6 @@ public function shouldSucceedWhenBothHeadersAreSupplied()
$this->assertSame(200, $response["http_metadata"]->getStatusCode());
}

/**
* Builds an IssuingClient whose HTTP layer always answers with the given status and body.
*
* @param int $status
* @param string $body
* @return IssuingClient
* @throws CheckoutArgumentException
* @throws CheckoutException
*/
private function buildClientReturning(int $status, string $body): IssuingClient
{
$stack = HandlerStack::create();
$stack->setHandler(function ($request, array $options) use ($status, $body) {
$response = new Response($status, ['Content-Type' => 'application/json'], $body);
if ($status >= 400) {
return \GuzzleHttp\Promise\Create::rejectionFor(
new \GuzzleHttp\Exception\RequestException(
"error",
$request,
$response
)
);
}
return \GuzzleHttp\Promise\Create::promiseFor($response);
});

$httpBuilder = $this->createMock(HttpClientBuilderInterface::class);
$httpBuilder->method("getClient")->willReturn(new \GuzzleHttp\Client(["handler" => $stack]));

$sdkCredentials = $this->createMock(SdkCredentialsInterface::class);
$sdkCredentials
->method("getAuthorization")
->willReturn(new SdkAuthorization(PlatformType::$default, "sk_sbox_key"));

$logger = new Logger("checkout-sdk-test-php");
$logger->pushHandler(new StreamHandler("php://stderr"));

$configuration = new CheckoutConfiguration(
$sdkCredentials,
Environment::sandbox(),
$httpBuilder,
$logger
);

return new IssuingClient(new ApiClient($configuration), $configuration);
}

/**
* Exercises the private header builder on a real ApiClient, so the assertions pin the names
* that actually reach the wire rather than the mapping declaration alone.
Expand Down
Loading
Loading