diff --git a/lib/Checkout/Issuing/Cards/Revoke/RevokeCardRequest.php b/lib/Checkout/Issuing/Cards/Revoke/RevokeCardRequest.php index f6e1e7cb..9602b1f3 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] + * Enum: expired, reported_lost, reported_stolen (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..2a8613c0 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] + * Enum: suspected_lost, suspected_stolen (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..03ca7c40 100644 --- a/lib/Checkout/Issuing/Cards/Update/CardUpdateHeaders.php +++ b/lib/Checkout/Issuing/Cards/Update/CardUpdateHeaders.php @@ -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; diff --git a/lib/Checkout/Issuing/IssuingClient.php b/lib/Checkout/Issuing/IssuingClient.php index d565058d..21279eeb 100644 --- a/lib/Checkout/Issuing/IssuingClient.php +++ b/lib/Checkout/Issuing/IssuingClient.php @@ -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() ); } @@ -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 */ @@ -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; + } } diff --git a/test/Checkout/Tests/Issuing/Cards/CardUpdateHeadersTest.php b/test/Checkout/Tests/Issuing/Cards/CardUpdateHeadersTest.php index d2e0f7b1..d62882f4 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,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"; @@ -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. diff --git a/test/Checkout/Tests/Issuing/Cards/CardsClientTest.php b/test/Checkout/Tests/Issuing/Cards/CardsClientTest.php index 749d7a27..418efcc2 100644 --- a/test/Checkout/Tests/Issuing/Cards/CardsClientTest.php +++ b/test/Checkout/Tests/Issuing/Cards/CardsClientTest.php @@ -6,16 +6,21 @@ use Checkout\CheckoutArgumentException; use Checkout\CheckoutAuthorizationException; use Checkout\CheckoutException; -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 */ @@ -106,8 +111,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 +211,97 @@ 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)); + } + + /** + * @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. + * + * @test + * @throws CheckoutApiException + * @throws CheckoutArgumentException + * @throws CheckoutException + */ + public function shouldSendNoBodyOnTheWireWhenRevokeAndSuspendHaveNoRequest() + { + $captured = []; + $client = $this->buildStubbedClient(200, '{"_links":{}}', $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->buildStubbedClient(200, '{"_links":{}}', $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)); + } } 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 diff --git a/test/Checkout/Tests/Issuing/Cards/RevokeSuspendCardRequestSerializationTest.php b/test/Checkout/Tests/Issuing/Cards/RevokeSuspendCardRequestSerializationTest.php new file mode 100644 index 00000000..0bdfa5e2 --- /dev/null +++ b/test/Checkout/Tests/Issuing/Cards/RevokeSuspendCardRequestSerializationTest.php @@ -0,0 +1,108 @@ +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. 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 shouldSendNoBodyForAnEmptyRequestObject() + { + $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()); + } +} 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); + } +}