diff --git a/src/api/issuing/cards.js b/src/api/issuing/cards.js index 5752f1a..f5ed70d 100644 --- a/src/api/issuing/cards.js +++ b/src/api/issuing/cards.js @@ -26,6 +26,13 @@ export default class Cards { /** * Creates a physical or virtual card and issues it to the specified cardholder. * + * `body.scheduled_revocation_date` (string, `YYYY-MM-DD`, optional) schedules + * the card to be revoked at midnight UTC on that date. Replaces the deprecated + * `revocation_date` field; if both are provided, `scheduled_revocation_date` wins. + * + * The response includes `last_activated_on` (string, date-time, nullable), the + * time the card was activated, or `null` if it was never activated. + * * @memberof Cards * @param {Object} body Card params. * @param {string} [idempotencyKey] Idempotency Key. @@ -50,6 +57,11 @@ export default class Cards { /** * Retrieves the details for a card you issued previously. * + * The response includes `last_activated_on` (string, date-time, nullable), the + * time the card was activated, or `null` if it was never activated, and + * `scheduled_revocation_date` (string, `YYYY-MM-DD`), the date the card is + * scheduled to be revoked at midnight UTC. + * * @memberof Cards * @param {string} id Card id. * @return {Promise} A promise to the card details response. @@ -82,10 +94,26 @@ export default class Cards { * getRequestHeaders picks them up for every verb. Putting them on the body would also send * them as JSON fields. * + * `body.status` (string enum, optional, only `active` allowed) reactivates an + * `inactive` or `suspended` card. Mutually exclusive with + * `scheduled_activation_date`; the API returns + * `scheduled_activation_date_conflicts_with_activation` if both are set. + * + * `body.scheduled_revocation_date` (string, `YYYY-MM-DD`, optional) schedules + * the card to be revoked at midnight UTC on that date. Replaces the deprecated + * `revocation_date` field; if both are provided, `scheduled_revocation_date` wins. + * + * The response has the same fields as the get-card response (including + * `last_activated_on` and `scheduled_revocation_date`), plus a required + * `last_modified_date`. It no longer includes `encrypted_cvv`. For a virtual card, the + * response may also include `is_single_use` (boolean), specifying whether the card is set + * to expire after a single use; physical cards never send it. + * * @memberof Cards * @param {string} id Card id. - * @param {Object} body Card params to update: `reference`, `metadata`, `expiry_month`, - * `expiry_year`, `revocation_date` and `scheduled_activation_date`. + * @param {Object} body Card params to update: `status`, `reference`, `metadata`, + * `expiry_month`, `expiry_year`, `scheduled_revocation_date`, `revocation_date` and + * `scheduled_activation_date`. * @param {Object} [headers] Optional HTTP headers. * @param {boolean} [headers.return-encrypted-cvv] Set to `true` to retrieve the card's * encrypted credentials in the response. Requires `Encryption-Key`. @@ -185,6 +213,9 @@ export default class Cards { /** * Activates an inactive or suspended card so that incoming authorizations can be approved. * + * The response includes a required `last_activated_on` (string, date-time), the + * time the card was activated. + * * @memberof Cards * @param {string} id Card id. * @return {Promise} A promise to the card response. diff --git a/test/issuing/cards/cards-it.js b/test/issuing/cards/cards-it.js index 693ce6d..4a7c9b5 100644 --- a/test/issuing/cards/cards-it.js +++ b/test/issuing/cards/cards-it.js @@ -150,22 +150,9 @@ describe.skip('Integration::Issuing::Cards - AuthenticationError: Requires CHECK expect(response._links.self.href).to.contain(card.id); }); - // return-encrypted-cvv plus an Encryption-Key returns the encrypted credentials. The - // key must be Base64 with the BEGIN and END PUBLIC KEY markers and every newline removed. - it('should update a card and return the encrypted cvv', async function () { - const publicKey = process.env.CHECKOUT_ISSUING_ENCRYPTION_PUBLIC_KEY; - if (!publicKey) { - this.skip(); - } - - const response = await cko_issuing.issuing.updateCard( - card.id, - { reference: 'X-123456-N11' }, - { 'return-encrypted-cvv': true, 'Encryption-Key': publicKey } - ); - - expect(response.encrypted_cvv).to.be.a('string'); - }); + // 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). // The flag without the key is a documented 422 carrying encryption_key_required. it('should throw ValidationError when requesting the cvv without an encryption key', async () => { diff --git a/test/issuing/cards/cards-unit.js b/test/issuing/cards/cards-unit.js index 9754f4c..aa7385d 100644 --- a/test/issuing/cards/cards-unit.js +++ b/test/issuing/cards/cards-unit.js @@ -33,6 +33,8 @@ describe('Unit::Issuing::Cards', () => { scheme: "VISA", reference: "X-123456-N11", created_date: "2019-09-10T10:11:12Z", + last_activated_on: null, + scheduled_revocation_date: "2026-12-31", _links: { self: { href: "https://123456789.api.checkout.com/issuing/cards/crd_fa6psq42dcdd6fdn5gifcq1491" @@ -76,11 +78,14 @@ describe('Unit::Issuing::Cards', () => { }, additional_comment: "string" }, + scheduled_revocation_date: "2026-12-31", activate_card: false }) expect(cardResponse.id).to.equal("crd_fa6psq242dcd6fdn5gifcq1491") expect(cardResponse.display_name).to.equal("JOHN KENNEDY") + expect(cardResponse.last_activated_on).to.equal(null) + expect(cardResponse.scheduled_revocation_date).to.equal("2026-12-31") }); it('should throw when creating a cardholder cards', async () => { @@ -140,6 +145,8 @@ describe('Unit::Issuing::Cards', () => { reference: "X-123456-N11", created_date: "2021-09-09T19:41:39Z", last_modified_date: "2021-09-09T19:41:39Z", + last_activated_on: "2021-09-09T19:41:39Z", + scheduled_revocation_date: "2026-12-31", _links: { self: { href: "https://123456789.api.checkout.com/issuing/cards/crd_fa6psq42dcdd6fdn5gifcq1491" @@ -167,6 +174,8 @@ describe('Unit::Issuing::Cards', () => { expect(cardholderResponse.cardholder_id).to.equal("crh_d3ozhf43pcq2xbldn2g45qnb44") expect(cardholderResponse.type).to.equal("physical") expect(cardholderResponse.status).to.equal("active") + expect(cardholderResponse.last_activated_on).to.equal("2021-09-09T19:41:39Z") + expect(cardholderResponse.scheduled_revocation_date).to.equal("2026-12-31") }); it('should throw when getting a card', async () => { @@ -213,7 +222,24 @@ describe('Unit::Issuing::Cards', () => { .reply(200, { id: "crd_fa6psq242dcd6fdn5gifcq1491", status: "inactive", - reference: "X-123456-N11-UPDATED" + reference: "X-123456-N11-UPDATED", + scheduled_revocation_date: "2026-12-31", + last_modified_date: "2021-09-09T19:41:39Z", + last_activated_on: null, + _links: { + self: { + href: "https://123456789.api.checkout.com/issuing/cards/crd_fa6psq42dcdd6fdn5gifcq1491" + }, + credentials: { + href: "https://123456789.api.checkout.com/issuing/cards/crd_fa6psq42dcdd6fdn5gifcq1491/credentials" + }, + revoke: { + href: "https://123456789.api.checkout.com/issuing/cards/crd_fa6psq42dcdd6fdn5gifcq1491/revoke" + }, + controls: { + href: "https://123456789.api.checkout.com/issuing/controls?target_id=crd_fa6psq42dcdd6fdn5gifcq1491" + } + } }); const cko = new Checkout('test_client_secret', { @@ -226,12 +252,65 @@ describe('Unit::Issuing::Cards', () => { const cardResponse = await cko.issuing.updateCard('crd_fa6psq242dcd6fdn5gifcq1491', { status: "inactive", - reference: "X-123456-N11-UPDATED" + reference: "X-123456-N11-UPDATED", + scheduled_revocation_date: "2026-12-31" }); expect(cardResponse.id).to.equal("crd_fa6psq242dcd6fdn5gifcq1491"); expect(cardResponse.status).to.equal("inactive"); expect(cardResponse.reference).to.equal("X-123456-N11-UPDATED"); + expect(cardResponse.scheduled_revocation_date).to.equal("2026-12-31"); + expect(cardResponse.last_modified_date).to.equal("2021-09-09T19:41:39Z"); + expect(cardResponse.encrypted_cvv).to.equal(undefined); + }); + + it('should reactivate a suspended card via update', async () => { + nock('https://123456789.access.sandbox.checkout.com') + .post('/connect/token') + .reply(200, { + access_token: 'test_access_token', + expires_in: 3600, + token_type: 'Bearer', + scope: 'issuing:card-management-write issuing:card-management-read' + }); + + nock('https://123456789.api.sandbox.checkout.com') + .patch('/issuing/cards/crd_fa6psq242dcd6fdn5gifcq1491') + .reply(200, { + id: "crd_fa6psq242dcd6fdn5gifcq1491", + status: "active", + last_modified_date: "2021-09-09T19:41:39Z", + last_activated_on: "2021-09-09T19:41:39Z", + _links: { + self: { + href: "https://123456789.api.checkout.com/issuing/cards/crd_fa6psq42dcdd6fdn5gifcq1491" + }, + credentials: { + href: "https://123456789.api.checkout.com/issuing/cards/crd_fa6psq42dcdd6fdn5gifcq1491/credentials" + }, + revoke: { + href: "https://123456789.api.checkout.com/issuing/cards/crd_fa6psq42dcdd6fdn5gifcq1491/revoke" + }, + controls: { + href: "https://123456789.api.checkout.com/issuing/controls?target_id=crd_fa6psq42dcdd6fdn5gifcq1491" + } + } + }); + + const cko = new Checkout('test_client_secret', { + client: 'ack_testclie123456', + scope: ['issuing:card-management-write', 'issuing:card-management-read'], + subdomain: 'test', + environment: 'sandbox', + subdomain: '123456789' + }); + + const cardResponse = await cko.issuing.updateCard('crd_fa6psq242dcd6fdn5gifcq1491', { + status: "active" + }); + + expect(cardResponse.status).to.equal("active"); + expect(cardResponse.last_activated_on).to.equal("2021-09-09T19:41:39Z"); }); it('should throw when updating a card', async () => { @@ -491,6 +570,7 @@ describe('Unit::Issuing::Cards', () => { nock('https://123456789.api.sandbox.checkout.com') .post('/issuing/cards/crd_fa6psq242dcd6fdn5gifcq1491/activate') .reply(200, { + last_activated_on: "2021-09-09T19:41:39Z", _links: { self: { href: "https://123456789.api.checkout.com/issuing/cards/crd_fa6psq42dcdd6fdn5gifcq1491" @@ -518,6 +598,7 @@ describe('Unit::Issuing::Cards', () => { const activationResponse = await cko.issuing.activateCard("crd_fa6psq242dcd6fdn5gifcq1491") expect(activationResponse).to.not.be.null + expect(activationResponse.last_activated_on).to.equal("2021-09-09T19:41:39Z") }); it('should throw when activating card', async () => { @@ -780,12 +861,13 @@ describe('Unit::Issuing::Cards', () => { } }); - // The return-encrypted-cvv and Encryption-Key headers - // on PATCH /issuing/cards/{cardId}, and the encrypted_cvv the response then carries. + // The return-encrypted-cvv and Encryption-Key headers on PATCH /issuing/cards/{cardId}. // // These assert the headers on the outgoing request, because the spec spells them case // sensitively (return-encrypted-cvv lower case, Encryption-Key title case) and because they - // travel on a copy of the config rather than on the body. + // travel on a copy of the config rather than on the body. The 2026-09-17 spec (INT-1700) + // removed encrypted_cvv from update-card-response entirely, so the response body is + // asserted without it regardless of whether these headers are sent. describe('update headers', () => { const SUBDOMAIN = "123456789"; const ACCESS_BASE = `https://${SUBDOMAIN}.access.sandbox.checkout.com`; @@ -821,7 +903,6 @@ describe('Unit::Issuing::Cards', () => { seen = this.req.headers; return { last_modified_date: '2026-06-01T10:00:00Z', - encrypted_cvv: 'oJMoNMEEUiQKYOsQ4Zd', _links: { self: { href: `https://${SUBDOMAIN}.api.checkout.com/issuing/cards/${CARD_ID}`, @@ -841,16 +922,33 @@ describe('Unit::Issuing::Cards', () => { // nock lowercases header names when it records them, which is what HTTP does on the wire. expect(seen['return-encrypted-cvv']).to.equal('true'); expect(seen['encryption-key']).to.equal('MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A'); - expect(result.encrypted_cvv).to.equal('oJMoNMEEUiQKYOsQ4Zd'); - // last_modified_date is the only required property of update-card-response; _links is - // a CardSelfLink, whose self carries href, actions and types. + // last_modified_date is required on update-card-response. The 2026-09-17 spec + // (INT-1700) removed encrypted_cvv entirely, so these headers no longer make the + // response carry it; _links is now the same CardLinks shape get-card-response uses. expect(result.last_modified_date).to.equal('2026-06-01T10:00:00Z'); + expect(result.encrypted_cvv).to.be.undefined; expect(result._links.self.href).to.contain(`/issuing/cards/${CARD_ID}`); expect(result._links.self.actions).to.deep.equal(['GET']); expect(result._links.self.types).to.deep.equal(['application/json']); }); + // The 2026-09-23 spec update split update-card-response into a virtual/physical + // discriminator; the virtual variant adds is_single_use. + it('should round-trip is_single_use for a virtual card response', async () => { + nock(BASE) + .patch(`/issuing/cards/${CARD_ID}`) + .reply(200, { + type: 'virtual', + last_modified_date: '2026-06-01T10:00:00Z', + is_single_use: true + }); + + const result = await client().issuing.cards.updateCard(CARD_ID, { reference: 'X-123456-N11' }); + + expect(result.is_single_use).to.equal(true); + }); + it('should not leak the headers into the request body', async () => { let body; nock(BASE)