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
35 changes: 33 additions & 2 deletions src/api/issuing/cards.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<Object>} A promise to the card details response.
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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<Object>} A promise to the card response.
Expand Down
19 changes: 3 additions & 16 deletions test/issuing/cards/cards-it.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
116 changes: 107 additions & 9 deletions test/issuing/cards/cards-unit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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', {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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`;
Expand Down Expand Up @@ -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}`,
Expand All @@ -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)
Expand Down
Loading