From 16bcb8b7697cb457d56428bc1c4d557e388ed132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Armando=20Rodr=C3=ADguez?= <127134616+armando-rodriguez-cko@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:11:31 +0200 Subject: [PATCH 1/6] deprecate(issuing): deprecate the update-card headers and make the revoke and suspend request optional The API no longer documents the return-encrypted-cvv and Encryption-Key headers on the card update operation, so CardUpdateHeaders and the updateCardDetails headers parameter are now deprecated. Headers set by the caller are still sent. The revoke and suspend request bodies are optional in the API, so revokeCard and suspendCard now accept a null request, which sends no body. --- .../Cards/Revoke/RevokeCardRequest.php | 6 +- .../Cards/Suspend/SuspendCardRequest.php | 6 +- .../Cards/Update/CardUpdateHeaders.php | 5 + lib/Checkout/Issuing/IssuingClient.php | 22 +-- .../Issuing/Cards/CardUpdateHeadersTest.php | 5 +- .../Tests/Issuing/Cards/CardsClientTest.php | 129 +++++++++++++++++- .../Issuing/Cards/CardsIntegrationTest.php | 5 +- 7 files changed, 160 insertions(+), 18 deletions(-) diff --git a/lib/Checkout/Issuing/Cards/Revoke/RevokeCardRequest.php b/lib/Checkout/Issuing/Cards/Revoke/RevokeCardRequest.php index f6e1e7cb..efbe3e4a 100644 --- a/lib/Checkout/Issuing/Cards/Revoke/RevokeCardRequest.php +++ b/lib/Checkout/Issuing/Cards/Revoke/RevokeCardRequest.php @@ -5,7 +5,11 @@ class RevokeCardRequest { /** - * @var string value of RevokeReason + * The reason why the card is being revoked. + * [Optional] + * Values: see RevokeReason. + * + * @var string|null value of RevokeReason */ public $reason; } diff --git a/lib/Checkout/Issuing/Cards/Suspend/SuspendCardRequest.php b/lib/Checkout/Issuing/Cards/Suspend/SuspendCardRequest.php index 77380996..42e2a990 100644 --- a/lib/Checkout/Issuing/Cards/Suspend/SuspendCardRequest.php +++ b/lib/Checkout/Issuing/Cards/Suspend/SuspendCardRequest.php @@ -5,7 +5,11 @@ class SuspendCardRequest { /** - * @var string value of SuspendReason + * The reason why the card is being suspended. + * [Optional] + * Values: see SuspendReason. + * + * @var string|null value of SuspendReason */ public $reason; } diff --git a/lib/Checkout/Issuing/Cards/Update/CardUpdateHeaders.php b/lib/Checkout/Issuing/Cards/Update/CardUpdateHeaders.php index 22634c80..59985dfc 100644 --- a/lib/Checkout/Issuing/Cards/Update/CardUpdateHeaders.php +++ b/lib/Checkout/Issuing/Cards/Update/CardUpdateHeaders.php @@ -8,6 +8,9 @@ * 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 supports these headers; use the method without headers. + * Will be removed in the next major version. */ class CardUpdateHeaders { @@ -19,6 +22,7 @@ class CardUpdateHeaders * Example: "true" * * @var string|null + * @deprecated The API no longer supports this header. Will be removed in the next major version. */ public $return_encrypted_cvv; @@ -31,6 +35,7 @@ class CardUpdateHeaders * Maps to HTTP header Encryption-Key. * * @var string|null + * @deprecated The API no longer supports this header. Will be removed in the next major version. */ public $encryption_key; diff --git a/lib/Checkout/Issuing/IssuingClient.php b/lib/Checkout/Issuing/IssuingClient.php index d565058d..14cc7bec 100644 --- a/lib/Checkout/Issuing/IssuingClient.php +++ b/lib/Checkout/Issuing/IssuingClient.php @@ -218,12 +218,14 @@ public function getCardCredentials(string $cardId, CardCredentialsQuery $query) } /** - * @param string $cardId - * @param RevokeCardRequest $revokeCardRequest + * Revoke a card. The request body is optional; pass null (or omit it) to send no body. + * + * @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), @@ -233,12 +235,14 @@ public function revokeCard(string $cardId, RevokeCardRequest $revokeCardRequest) } /** - * @param string $cardId - * @param SuspendCardRequest $suspendCardRequest + * Suspend a card. The request body is optional; pass null (or omit it) to send no body. + * + * @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), @@ -760,8 +764,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. The API no longer supports these + * headers; call this method without headers. The + * parameter will be removed in the next major + * version. Headers set here are still sent. (Optional) * @return array * @throws CheckoutApiException */ diff --git a/test/Checkout/Tests/Issuing/Cards/CardUpdateHeadersTest.php b/test/Checkout/Tests/Issuing/Cards/CardUpdateHeadersTest.php index d2e0f7b1..43868960 100644 --- a/test/Checkout/Tests/Issuing/Cards/CardUpdateHeadersTest.php +++ b/test/Checkout/Tests/Issuing/Cards/CardUpdateHeadersTest.php @@ -193,9 +193,8 @@ public function shouldSurfaceThe422WhenTheEncryptionKeyIsMissing() } /** - * 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 */ diff --git a/test/Checkout/Tests/Issuing/Cards/CardsClientTest.php b/test/Checkout/Tests/Issuing/Cards/CardsClientTest.php index 749d7a27..4bbba11d 100644 --- a/test/Checkout/Tests/Issuing/Cards/CardsClientTest.php +++ b/test/Checkout/Tests/Issuing/Cards/CardsClientTest.php @@ -5,7 +5,20 @@ use Checkout\CheckoutApiException; use Checkout\CheckoutArgumentException; use Checkout\CheckoutAuthorizationException; +use Checkout\ApiClient; +use Checkout\CheckoutConfiguration; use Checkout\CheckoutException; +use Checkout\Environment; +use Checkout\HttpClientBuilderInterface; +use Checkout\Issuing\Cards\Revoke\RevokeCardRequest; +use Checkout\Issuing\Cards\Revoke\RevokeReason; +use Checkout\Issuing\Cards\Suspend\SuspendReason; +use Checkout\SdkAuthorization; +use Checkout\SdkCredentialsInterface; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Psr7\Response; +use Monolog\Handler\NullHandler; +use Monolog\Logger; use Checkout\Issuing\IssuingClient; use Checkout\Issuing\Cards\Create\VirtualCardRequest; use Checkout\Issuing\Cards\Credentials\CardCredentialsQuery; @@ -106,8 +119,8 @@ public function shouldUpdateCardWithStatusAndScheduledRevocationDate() } /** - * 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 uses a virtual/physical discriminator; the virtual variant adds + * is_single_use. * * @test * @throws CheckoutApiException @@ -206,4 +219,116 @@ public function shouldSuspendCard() $this->assertArrayHasKey("id", $response); $this->assertEquals("crd_12345", $response["id"]); } + + /** + * @test + * @throws CheckoutApiException + */ + public function shouldRevokeCardWithoutRequestBody() + { + $this->apiClient + ->expects($this->once()) + ->method("post") + ->with($this->equalTo("issuing/cards/crd_12345/revoke"), $this->isNull()) + ->willReturn(["_links" => []]); + + $this->assertNotNull($this->client->revokeCard("crd_12345")); + } + + /** + * @test + * @throws CheckoutApiException + */ + public function shouldSuspendCardWithoutRequestBody() + { + $this->apiClient + ->expects($this->once()) + ->method("post") + ->with($this->equalTo("issuing/cards/crd_12345/suspend"), $this->isNull()) + ->willReturn(["_links" => []]); + + $this->assertNotNull($this->client->suspendCard("crd_12345", null)); + } + + /** + * With no request the real ApiClient sends a POST with an empty body. + * + * @test + * @throws CheckoutApiException + * @throws CheckoutArgumentException + * @throws CheckoutException + */ + public function shouldSendNoBodyOnTheWireWhenRevokeAndSuspendHaveNoRequest() + { + $captured = []; + $client = $this->buildCapturingClient($captured); + + $client->revokeCard("crd_12345"); + $client->suspendCard("crd_12345"); + + $this->assertCount(2, $captured); + $this->assertSame("POST", $captured[0]->getMethod()); + $this->assertStringEndsWith("issuing/cards/crd_12345/revoke", $captured[0]->getUri()->getPath()); + $this->assertSame("", (string) $captured[0]->getBody()); + $this->assertStringEndsWith("issuing/cards/crd_12345/suspend", $captured[1]->getUri()->getPath()); + $this->assertSame("", (string) $captured[1]->getBody()); + } + + /** + * @test + * @throws CheckoutApiException + * @throws CheckoutArgumentException + * @throws CheckoutException + */ + public function shouldSendTheReasonOnTheWireWhenRevokeAndSuspendHaveARequest() + { + $captured = []; + $client = $this->buildCapturingClient($captured); + + $revoke = new RevokeCardRequest(); + $revoke->reason = RevokeReason::$reported_lost; + $suspend = new SuspendCardRequest(); + $suspend->reason = SuspendReason::$suspected_lost; + + $client->revokeCard("crd_12345", $revoke); + $client->suspendCard("crd_12345", $suspend); + + $this->assertSame(["reason" => "reported_lost"], json_decode((string) $captured[0]->getBody(), true)); + $this->assertSame(["reason" => "suspected_lost"], json_decode((string) $captured[1]->getBody(), true)); + } + + /** + * Builds an IssuingClient on a real ApiClient whose HTTP layer records every request and + * answers 200 with a revoke/suspend style response. + * + * @param array $captured + * @return IssuingClient + * @throws CheckoutArgumentException + * @throws CheckoutException + */ + private function buildCapturingClient(array &$captured): IssuingClient + { + $stack = HandlerStack::create(); + $stack->setHandler(function ($request) use (&$captured) { + $captured[] = $request; + return \GuzzleHttp\Promise\Create::promiseFor( + new Response(200, ["Content-Type" => "application/json"], '{"_links":{}}') + ); + }); + + $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 NullHandler()); + + $configuration = new CheckoutConfiguration($sdkCredentials, Environment::sandbox(), $httpBuilder, $logger); + + return new IssuingClient(new ApiClient($configuration), $configuration); + } } diff --git a/test/Checkout/Tests/Issuing/Cards/CardsIntegrationTest.php b/test/Checkout/Tests/Issuing/Cards/CardsIntegrationTest.php index deb644ca..3197804b 100644 --- a/test/Checkout/Tests/Issuing/Cards/CardsIntegrationTest.php +++ b/test/Checkout/Tests/Issuing/Cards/CardsIntegrationTest.php @@ -300,9 +300,8 @@ public function shouldUpdateCardRevocationDate() $this->assertResponse($updateResponse, "last_modified_date"); } - // 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). + // encrypted_cvv is not part of update-card-response, so the deprecated return-encrypted-cvv + // and Encryption-Key headers do not make the update response carry it. /** * The next round hour in UTC, which is the earliest value the API accepts for a scheduled From eb6fc48055d599015111f52af227c2d8496237b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Armando=20Rodr=C3=ADguez?= <127134616+armando-rodriguez-cko@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:01:09 +0200 Subject: [PATCH 2/6] test(issuing): tidy the revoke and suspend tests and drop the old encrypted CVV wording --- .../Cards/Revoke/RevokeCardRequest.php | 2 +- .../Cards/Suspend/SuspendCardRequest.php | 2 +- .../Cards/Update/CardUpdateHeaders.php | 14 +-- .../Issuing/Cards/CardUpdateHeadersTest.php | 88 +++--------------- .../Tests/Issuing/Cards/CardsClientTest.php | 59 ++---------- ...okeSuspendCardRequestSerializationTest.php | 91 +++++++++++++++++++ .../Cards/StubbedHttpIssuingClientTrait.php | 64 +++++++++++++ 7 files changed, 186 insertions(+), 134 deletions(-) create mode 100644 test/Checkout/Tests/Issuing/Cards/RevokeSuspendCardRequestSerializationTest.php create mode 100644 test/Checkout/Tests/Issuing/Cards/StubbedHttpIssuingClientTrait.php diff --git a/lib/Checkout/Issuing/Cards/Revoke/RevokeCardRequest.php b/lib/Checkout/Issuing/Cards/Revoke/RevokeCardRequest.php index efbe3e4a..9602b1f3 100644 --- a/lib/Checkout/Issuing/Cards/Revoke/RevokeCardRequest.php +++ b/lib/Checkout/Issuing/Cards/Revoke/RevokeCardRequest.php @@ -7,7 +7,7 @@ class RevokeCardRequest /** * The reason why the card is being revoked. * [Optional] - * Values: see RevokeReason. + * Enum: expired, reported_lost, reported_stolen (see RevokeReason). * * @var string|null value of RevokeReason */ diff --git a/lib/Checkout/Issuing/Cards/Suspend/SuspendCardRequest.php b/lib/Checkout/Issuing/Cards/Suspend/SuspendCardRequest.php index 42e2a990..2a8613c0 100644 --- a/lib/Checkout/Issuing/Cards/Suspend/SuspendCardRequest.php +++ b/lib/Checkout/Issuing/Cards/Suspend/SuspendCardRequest.php @@ -7,7 +7,7 @@ class SuspendCardRequest /** * The reason why the card is being suspended. * [Optional] - * Values: see SuspendReason. + * Enum: suspected_lost, suspected_stolen (see SuspendReason). * * @var string|null value of SuspendReason */ diff --git a/lib/Checkout/Issuing/Cards/Update/CardUpdateHeaders.php b/lib/Checkout/Issuing/Cards/Update/CardUpdateHeaders.php index 59985dfc..d480e0cb 100644 --- a/lib/Checkout/Issuing/Cards/Update/CardUpdateHeaders.php +++ b/lib/Checkout/Issuing/Cards/Update/CardUpdateHeaders.php @@ -3,7 +3,8 @@ 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. The API no + * longer documents them and ignores them. * * 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 @@ -15,8 +16,9 @@ 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. The API no longer documents this header and ignores it: the update card + * response does not include encrypted credentials. Kept so existing callers keep working; + * when set, it is still sent. * [Optional] * Maps to HTTP header return-encrypted-cvv. * Example: "true" @@ -27,10 +29,8 @@ class CardUpdateHeaders 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. The API no longer documents this header and ignores it. Kept so existing + * callers keep working; when set, it is still sent. * [Optional] * Maps to HTTP header Encryption-Key. * diff --git a/test/Checkout/Tests/Issuing/Cards/CardUpdateHeadersTest.php b/test/Checkout/Tests/Issuing/Cards/CardUpdateHeadersTest.php index 43868960..9a72a5c5 100644 --- a/test/Checkout/Tests/Issuing/Cards/CardUpdateHeadersTest.php +++ b/test/Checkout/Tests/Issuing/Cards/CardUpdateHeadersTest.php @@ -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 */ @@ -164,32 +158,25 @@ 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 API ignores the deprecated headers: 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")); } /** @@ -200,7 +187,7 @@ public function shouldSurfaceThe422WhenTheEncryptionKeyIsMissing() */ 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"; @@ -212,53 +199,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. diff --git a/test/Checkout/Tests/Issuing/Cards/CardsClientTest.php b/test/Checkout/Tests/Issuing/Cards/CardsClientTest.php index 4bbba11d..faa997c8 100644 --- a/test/Checkout/Tests/Issuing/Cards/CardsClientTest.php +++ b/test/Checkout/Tests/Issuing/Cards/CardsClientTest.php @@ -5,30 +5,22 @@ use Checkout\CheckoutApiException; use Checkout\CheckoutArgumentException; use Checkout\CheckoutAuthorizationException; -use Checkout\ApiClient; -use Checkout\CheckoutConfiguration; use Checkout\CheckoutException; -use Checkout\Environment; -use Checkout\HttpClientBuilderInterface; -use Checkout\Issuing\Cards\Revoke\RevokeCardRequest; -use Checkout\Issuing\Cards\Revoke\RevokeReason; -use Checkout\Issuing\Cards\Suspend\SuspendReason; -use Checkout\SdkAuthorization; -use Checkout\SdkCredentialsInterface; -use GuzzleHttp\HandlerStack; -use GuzzleHttp\Psr7\Response; -use Monolog\Handler\NullHandler; -use Monolog\Logger; -use Checkout\Issuing\IssuingClient; use Checkout\Issuing\Cards\Create\VirtualCardRequest; use Checkout\Issuing\Cards\Credentials\CardCredentialsQuery; +use Checkout\Issuing\Cards\Revoke\RevokeCardRequest; +use Checkout\Issuing\Cards\Revoke\RevokeReason; use Checkout\Issuing\Cards\Suspend\SuspendCardRequest; +use Checkout\Issuing\Cards\Suspend\SuspendReason; use Checkout\Issuing\Cards\Update\UpdateCardRequest; +use Checkout\Issuing\IssuingClient; use Checkout\PlatformType; use Checkout\Tests\UnitTestFixture; class CardsClientTest extends UnitTestFixture { + use StubbedHttpIssuingClientTrait; + /** * @var IssuingClient */ @@ -261,7 +253,7 @@ public function shouldSuspendCardWithoutRequestBody() public function shouldSendNoBodyOnTheWireWhenRevokeAndSuspendHaveNoRequest() { $captured = []; - $client = $this->buildCapturingClient($captured); + $client = $this->buildStubbedClient(200, '{"_links":{}}', $captured); $client->revokeCard("crd_12345"); $client->suspendCard("crd_12345"); @@ -283,7 +275,7 @@ public function shouldSendNoBodyOnTheWireWhenRevokeAndSuspendHaveNoRequest() public function shouldSendTheReasonOnTheWireWhenRevokeAndSuspendHaveARequest() { $captured = []; - $client = $this->buildCapturingClient($captured); + $client = $this->buildStubbedClient(200, '{"_links":{}}', $captured); $revoke = new RevokeCardRequest(); $revoke->reason = RevokeReason::$reported_lost; @@ -296,39 +288,4 @@ public function shouldSendTheReasonOnTheWireWhenRevokeAndSuspendHaveARequest() $this->assertSame(["reason" => "reported_lost"], json_decode((string) $captured[0]->getBody(), true)); $this->assertSame(["reason" => "suspected_lost"], json_decode((string) $captured[1]->getBody(), true)); } - - /** - * Builds an IssuingClient on a real ApiClient whose HTTP layer records every request and - * answers 200 with a revoke/suspend style response. - * - * @param array $captured - * @return IssuingClient - * @throws CheckoutArgumentException - * @throws CheckoutException - */ - private function buildCapturingClient(array &$captured): IssuingClient - { - $stack = HandlerStack::create(); - $stack->setHandler(function ($request) use (&$captured) { - $captured[] = $request; - return \GuzzleHttp\Promise\Create::promiseFor( - new Response(200, ["Content-Type" => "application/json"], '{"_links":{}}') - ); - }); - - $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 NullHandler()); - - $configuration = new CheckoutConfiguration($sdkCredentials, Environment::sandbox(), $httpBuilder, $logger); - - return new IssuingClient(new ApiClient($configuration), $configuration); - } } diff --git a/test/Checkout/Tests/Issuing/Cards/RevokeSuspendCardRequestSerializationTest.php b/test/Checkout/Tests/Issuing/Cards/RevokeSuspendCardRequestSerializationTest.php new file mode 100644 index 00000000..488dd99d --- /dev/null +++ b/test/Checkout/Tests/Issuing/Cards/RevokeSuspendCardRequestSerializationTest.php @@ -0,0 +1,91 @@ +serializer = new JsonSerializer(); + } + + /** + * @test + */ + public function shouldRoundtripEveryRevokeReason() + { + foreach ([RevokeReason::$expired, RevokeReason::$reported_lost, RevokeReason::$reported_stolen] as $reason) { + $request = new RevokeCardRequest(); + $request->reason = $reason; + + $decoded = $this->serializer->deserialize($this->serializer->serialize($request)); + + $this->assertSame(["reason" => $reason], $decoded); + } + } + + /** + * @test + */ + public function shouldMatchTheRevokeSwaggerExample() + { + $request = new RevokeCardRequest(); + $request->reason = RevokeReason::$reported_lost; + + $this->assertJsonStringEqualsJsonString('{"reason":"reported_lost"}', $this->serializer->serialize($request)); + } + + /** + * @test + */ + public function shouldRoundtripEverySuspendReason() + { + foreach ([SuspendReason::$suspected_lost, SuspendReason::$suspected_stolen] as $reason) { + $request = new SuspendCardRequest(); + $request->reason = $reason; + + $decoded = $this->serializer->deserialize($this->serializer->serialize($request)); + + $this->assertSame(["reason" => $reason], $decoded); + } + } + + /** + * @test + */ + public function shouldMatchTheSuspendSwaggerExample() + { + $request = new SuspendCardRequest(); + $request->reason = SuspendReason::$suspected_lost; + + $this->assertJsonStringEqualsJsonString('{"reason":"suspected_lost"}', $this->serializer->serialize($request)); + } + + /** + * The body is optional, so a request without a reason sends no reason key. The serializer + * writes an empty request object as an empty JSON array. + * + * @test + */ + public function shouldSendNoReasonForAnEmptyRequest() + { + $this->assertSame([], $this->serializer->deserialize($this->serializer->serialize(new RevokeCardRequest()))); + $this->assertSame([], $this->serializer->deserialize($this->serializer->serialize(new SuspendCardRequest()))); + $this->assertSame("[]", $this->serializer->serialize(new RevokeCardRequest())); + } +} diff --git a/test/Checkout/Tests/Issuing/Cards/StubbedHttpIssuingClientTrait.php b/test/Checkout/Tests/Issuing/Cards/StubbedHttpIssuingClientTrait.php new file mode 100644 index 00000000..d34e40e3 --- /dev/null +++ b/test/Checkout/Tests/Issuing/Cards/StubbedHttpIssuingClientTrait.php @@ -0,0 +1,64 @@ +setHandler(function ($request) use ($status, $body, &$captured) { + $captured[] = $request; + $response = new Response($status, ["Content-Type" => "application/json"], $body); + if ($status >= 400) { + return Create::rejectionFor(new RequestException("error", $request, $response)); + } + return Create::promiseFor($response); + }); + + $httpBuilder = $this->createMock(HttpClientBuilderInterface::class); + $httpBuilder->method("getClient")->willReturn(new 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 NullHandler()); + + $configuration = new CheckoutConfiguration($sdkCredentials, Environment::sandbox(), $httpBuilder, $logger); + + return new IssuingClient(new ApiClient($configuration), $configuration); + } +} From efe56dbf8f9a410753467242dc8f6596bb62a751 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Armando=20Rodr=C3=ADguez?= <127134616+armando-rodriguez-cko@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:14:50 +0200 Subject: [PATCH 3/6] docs(issuing): use the no longer documented wording for the deprecated headers --- .../Issuing/Cards/Update/CardUpdateHeaders.php | 12 +++++------- .../Tests/Issuing/Cards/CardUpdateHeadersTest.php | 5 +++-- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/lib/Checkout/Issuing/Cards/Update/CardUpdateHeaders.php b/lib/Checkout/Issuing/Cards/Update/CardUpdateHeaders.php index d480e0cb..b8b18d6f 100644 --- a/lib/Checkout/Issuing/Cards/Update/CardUpdateHeaders.php +++ b/lib/Checkout/Issuing/Cards/Update/CardUpdateHeaders.php @@ -3,8 +3,8 @@ namespace Checkout\Issuing\Cards\Update; /** - * The optional HTTP headers formerly documented for updating a card's details. The API no - * longer documents them and ignores them. + * 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 @@ -16,9 +16,8 @@ class CardUpdateHeaders { /** - * Deprecated. The API no longer documents this header and ignores it: the update card - * response does not include encrypted credentials. Kept so existing callers keep working; - * when set, it is still sent. + * 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" @@ -29,8 +28,7 @@ class CardUpdateHeaders public $return_encrypted_cvv; /** - * Deprecated. The API no longer documents this header and ignores it. Kept so existing - * callers keep working; when set, it is still sent. + * Deprecated. This header is no longer documented by the API and is still sent when set. * [Optional] * Maps to HTTP header Encryption-Key. * diff --git a/test/Checkout/Tests/Issuing/Cards/CardUpdateHeadersTest.php b/test/Checkout/Tests/Issuing/Cards/CardUpdateHeadersTest.php index 9a72a5c5..d62882f4 100644 --- a/test/Checkout/Tests/Issuing/Cards/CardUpdateHeadersTest.php +++ b/test/Checkout/Tests/Issuing/Cards/CardUpdateHeadersTest.php @@ -158,8 +158,9 @@ public function shouldUpdateCardDetailsWithoutHeaders() } /** - * The API ignores the deprecated headers: setting only return-encrypted-cvv, without an - * Encryption-Key, still succeeds and the response carries no encrypted_cvv. + * 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 */ From 533aedf8cb788fb66bb28772d6ffc48680f8988c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Armando=20Rodr=C3=ADguez?= <127134616+armando-rodriguez-cko@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:15:41 +0200 Subject: [PATCH 4/6] fix(issuing): send no body for an empty revoke or suspend request --- lib/Checkout/Issuing/IssuingClient.php | 22 +++++++++++--- .../Tests/Issuing/Cards/CardsClientTest.php | 16 ++++++++++ ...okeSuspendCardRequestSerializationTest.php | 29 +++++++++++++++---- 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/lib/Checkout/Issuing/IssuingClient.php b/lib/Checkout/Issuing/IssuingClient.php index 14cc7bec..f5610da0 100644 --- a/lib/Checkout/Issuing/IssuingClient.php +++ b/lib/Checkout/Issuing/IssuingClient.php @@ -218,7 +218,8 @@ public function getCardCredentials(string $cardId, CardCredentialsQuery $query) } /** - * Revoke a card. The request body is optional; pass null (or omit it) to send no body. + * 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) @@ -229,13 +230,14 @@ public function revokeCard(string $cardId, ?RevokeCardRequest $revokeCardRequest { return $this->apiClient->post( $this->buildPath(self::ISSUING_PATH, self::CARDS_PATH, $cardId, self::REVOKE_PATH), - $revokeCardRequest, + $this->optionalReasonBody($revokeCardRequest), $this->sdkAuthorization() ); } /** - * Suspend a card. The request body is optional; pass null (or omit it) to send no body. + * 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) @@ -246,7 +248,7 @@ public function suspendCard(string $cardId, ?SuspendCardRequest $suspendCardRequ { return $this->apiClient->post( $this->buildPath(self::ISSUING_PATH, self::CARDS_PATH, $cardId, self::SUSPEND_PATH), - $suspendCardRequest, + $this->optionalReasonBody($suspendCardRequest), $this->sdkAuthorization() ); } @@ -899,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; + } } diff --git a/test/Checkout/Tests/Issuing/Cards/CardsClientTest.php b/test/Checkout/Tests/Issuing/Cards/CardsClientTest.php index faa997c8..418efcc2 100644 --- a/test/Checkout/Tests/Issuing/Cards/CardsClientTest.php +++ b/test/Checkout/Tests/Issuing/Cards/CardsClientTest.php @@ -242,6 +242,22 @@ public function shouldSuspendCardWithoutRequestBody() $this->assertNotNull($this->client->suspendCard("crd_12345", null)); } + /** + * @test + * @throws CheckoutApiException + */ + public function shouldPassNoBodyForAnEmptyRevokeOrSuspendRequestObject() + { + $this->apiClient + ->expects($this->exactly(2)) + ->method("post") + ->with($this->anything(), $this->isNull()) + ->willReturn(["_links" => []]); + + $this->client->revokeCard("crd_12345", new RevokeCardRequest()); + $this->client->suspendCard("crd_12345", new SuspendCardRequest()); + } + /** * With no request the real ApiClient sends a POST with an empty body. * diff --git a/test/Checkout/Tests/Issuing/Cards/RevokeSuspendCardRequestSerializationTest.php b/test/Checkout/Tests/Issuing/Cards/RevokeSuspendCardRequestSerializationTest.php index 488dd99d..0bdfa5e2 100644 --- a/test/Checkout/Tests/Issuing/Cards/RevokeSuspendCardRequestSerializationTest.php +++ b/test/Checkout/Tests/Issuing/Cards/RevokeSuspendCardRequestSerializationTest.php @@ -2,6 +2,9 @@ namespace Checkout\Tests\Issuing\Cards; +use Checkout\CheckoutApiException; +use Checkout\CheckoutArgumentException; +use Checkout\CheckoutException; use Checkout\Issuing\Cards\Revoke\RevokeCardRequest; use Checkout\Issuing\Cards\Revoke\RevokeReason; use Checkout\Issuing\Cards\Suspend\SuspendCardRequest; @@ -11,6 +14,8 @@ class RevokeSuspendCardRequestSerializationTest extends TestCase { + use StubbedHttpIssuingClientTrait; + /** * @var JsonSerializer */ @@ -77,15 +82,27 @@ public function shouldMatchTheSuspendSwaggerExample() } /** - * The body is optional, so a request without a reason sends no reason key. The serializer - * writes an empty request object as an empty JSON array. + * The body is optional. An empty request object would serialize to an empty JSON array, + * which the API rejects, so revokeCard and suspendCard send no body for it, the same as a + * null request. * * @test + * @throws CheckoutApiException + * @throws CheckoutArgumentException + * @throws CheckoutException */ - public function shouldSendNoReasonForAnEmptyRequest() + public function shouldSendNoBodyForAnEmptyRequestObject() { - $this->assertSame([], $this->serializer->deserialize($this->serializer->serialize(new RevokeCardRequest()))); - $this->assertSame([], $this->serializer->deserialize($this->serializer->serialize(new SuspendCardRequest()))); - $this->assertSame("[]", $this->serializer->serialize(new RevokeCardRequest())); + $captured = []; + $client = $this->buildStubbedClient(200, '{"_links":{}}', $captured); + + $client->revokeCard("crd_12345", new RevokeCardRequest()); + $client->suspendCard("crd_12345", new SuspendCardRequest()); + + $this->assertCount(2, $captured); + $this->assertStringEndsWith("issuing/cards/crd_12345/revoke", $captured[0]->getUri()->getPath()); + $this->assertSame("", (string) $captured[0]->getBody()); + $this->assertStringEndsWith("issuing/cards/crd_12345/suspend", $captured[1]->getUri()->getPath()); + $this->assertSame("", (string) $captured[1]->getBody()); } } From f823a3acb2dee546fc213cb64a01abc11e7eb2d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Armando=20Rodr=C3=ADguez?= <127134616+armando-rodriguez-cko@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:19:41 +0200 Subject: [PATCH 5/6] docs(issuing): align the header deprecation message --- lib/Checkout/Issuing/Cards/Update/CardUpdateHeaders.php | 7 +++---- lib/Checkout/Issuing/IssuingClient.php | 7 +++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/lib/Checkout/Issuing/Cards/Update/CardUpdateHeaders.php b/lib/Checkout/Issuing/Cards/Update/CardUpdateHeaders.php index b8b18d6f..8ad8790f 100644 --- a/lib/Checkout/Issuing/Cards/Update/CardUpdateHeaders.php +++ b/lib/Checkout/Issuing/Cards/Update/CardUpdateHeaders.php @@ -10,8 +10,7 @@ * 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 supports these headers; use the method without headers. - * Will be removed in the next major version. + * @deprecated The API no longer documents these headers; use the method without headers. Will be removed in the next major version. */ class CardUpdateHeaders { @@ -23,7 +22,7 @@ class CardUpdateHeaders * Example: "true" * * @var string|null - * @deprecated The API no longer supports this header. Will be removed in the next major version. + * @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; @@ -33,7 +32,7 @@ class CardUpdateHeaders * Maps to HTTP header Encryption-Key. * * @var string|null - * @deprecated The API no longer supports this header. Will be removed in the next major version. + * @deprecated The API no longer documents these headers; use the method without headers. Will be removed in the next major version. */ public $encryption_key; diff --git a/lib/Checkout/Issuing/IssuingClient.php b/lib/Checkout/Issuing/IssuingClient.php index f5610da0..d0c6a550 100644 --- a/lib/Checkout/Issuing/IssuingClient.php +++ b/lib/Checkout/Issuing/IssuingClient.php @@ -766,10 +766,9 @@ public function getSingleTransaction(string $transactionId) : array * * @param string $cardId - The card's unique identifier. (Required) * @param UpdateCardRequest $updateCardRequest (Required) - * @param CardUpdateHeaders|null $headers - Deprecated. The API no longer supports these - * headers; call this method without headers. The - * parameter will be removed in the next major - * version. Headers set here are still sent. (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 */ From 07430d73e070259822ca5064f13f5bdf015dfaa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Armando=20Rodr=C3=ADguez?= <127134616+armando-rodriguez-cko@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:20:11 +0200 Subject: [PATCH 6/6] docs(issuing): wrap the deprecation docblocks --- lib/Checkout/Issuing/Cards/Update/CardUpdateHeaders.php | 9 ++++++--- lib/Checkout/Issuing/IssuingClient.php | 3 ++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/Checkout/Issuing/Cards/Update/CardUpdateHeaders.php b/lib/Checkout/Issuing/Cards/Update/CardUpdateHeaders.php index 8ad8790f..03ca7c40 100644 --- a/lib/Checkout/Issuing/Cards/Update/CardUpdateHeaders.php +++ b/lib/Checkout/Issuing/Cards/Update/CardUpdateHeaders.php @@ -10,7 +10,8 @@ * 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. + * @deprecated The API no longer documents these headers; use the method without headers. + * Will be removed in the next major version. */ class CardUpdateHeaders { @@ -22,7 +23,8 @@ class CardUpdateHeaders * 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. + * @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; @@ -32,7 +34,8 @@ class CardUpdateHeaders * 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. + * @deprecated The API no longer documents these headers; use the method without headers. + * Will be removed in the next major version. */ public $encryption_key; diff --git a/lib/Checkout/Issuing/IssuingClient.php b/lib/Checkout/Issuing/IssuingClient.php index d0c6a550..21279eeb 100644 --- a/lib/Checkout/Issuing/IssuingClient.php +++ b/lib/Checkout/Issuing/IssuingClient.php @@ -767,7 +767,8 @@ public function getSingleTransaction(string $transactionId) : array * @param string $cardId - The card's unique identifier. (Required) * @param UpdateCardRequest $updateCardRequest (Required) * @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. + * 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