From a7bb6ece13e79988702e513db2e6e05478a549e0 Mon Sep 17 00:00:00 2001 From: "Farhat R. Kabir" <148591416+farhatraiyan@users.noreply.github.com> Date: Thu, 14 May 2026 10:00:57 -0500 Subject: [PATCH 1/6] Add close method for Ground End of Day Close API Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 4 ++ README.md | 17 ++++- index.js | 45 ++++++++++++ package.json | 5 +- test/index.js | 184 ++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 252 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8413d5..63e382f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.4.0 + +- Add `close(closeRequest, options)` — calls the FedEx Ground End of Day Close API (`POST /ship/v1/endofday/close`). Same passthrough pattern as the other methods: caller supplies the full request body, the package forwards it verbatim. Supports `options.customer_transaction_id` and `options.timeout`. Non-2xx responses and 200-with-`errors[]` envelopes both reject with `HttpError`. + ## 0.3.0 - Add `createShipment(shipRequest, options)` — calls the FedEx Ship API to create a shipment (`POST /ship/v1/shipments`). Same passthrough pattern as the other methods: caller supplies the full request body, the package forwards it verbatim. Supports `options.customer_transaction_id` and `options.timeout`. Non-2xx responses and 200-with-`errors[]` envelopes both reject with `HttpError`. diff --git a/README.md b/README.md index 47d5930..ae509bc 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![npm version](https://img.shields.io/npm/v/@stores.com/fedex)](https://www.npmjs.com/package/@stores.com/fedex) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -FedEx REST API client for Address Validation, OAuth tokens, Rates and Transit Times, Shipment Cancellation, and Shipment Creation. +FedEx REST API client for Address Validation, Ground End of Day Close, OAuth tokens, Rates and Transit Times, Shipment Cancellation, and Shipment Creation. ## Installation @@ -57,6 +57,21 @@ const json = await fedex.cancelShipment({ Non-2xx responses reject with `HttpError`. If FedEx returns a 200 response carrying a non-empty `errors[]` envelope, the call rejects with an `HttpError` whose message is every `message` joined by `; ` and whose `.json` is the full response body (with the `errors[]` array, codes, and any other fields). +### close(closeRequest, options) + +Close out FedEx shipments via the Ground End of Day Close API. The caller supplies the full request body — `accountNumber`, `closeReqType`, `smartPostDetail` — and the package forwards it verbatim. + +See: https://developer.fedex.com/api/en-us/catalog/ship/v1/docs.html + +```javascript +const json = await fedex.close({ + accountNumber: { value: 'your_account_number' }, + closeReqType: 'GCCLOSE' +}); +``` + +Non-2xx responses reject with `HttpError`. If FedEx returns a 200 response carrying a non-empty `errors[]` envelope, the call rejects with an `HttpError` whose message is every `message` joined by `; ` and whose `.json` is the full response body (with the `errors[]` array, codes, and any other fields). + ### createShipment(shipRequest, options) Create a FedEx shipment via the Ship API. The caller supplies the full request body — `accountNumber`, `labelResponseOptions`, `requestedShipment` — and the package forwards it verbatim. diff --git a/index.js b/index.js index 194500e..0de0def 100644 --- a/index.js +++ b/index.js @@ -52,6 +52,51 @@ function FedEx(args) { return json; }; + /** + * Close out FedEx shipments via the Ground End of Day Close API. The caller + * supplies the full request body — `accountNumber`, `closeReqType`, + * `smartPostDetail` — and the package forwards it verbatim. + * + * @param {object} closeRequest - Full Ground End of Day Close request body. + * @param {object} [options] + * @param {string} [options.customer_transaction_id] - Sent as the `x-customer-transaction-id` + * request header. FedEx echoes this back so callers can correlate requests with responses. + * @param {number} [options.timeout=30000] - Request timeout in milliseconds. + * @returns {Promise} The parsed response body. + * @see https://developer.fedex.com/api/en-us/catalog/ship/v1/docs.html + */ + this.close = async (closeRequest, options = {}) => { + const accessToken = await this.getAccessToken(); + + const headers = { + Authorization: `Bearer ${accessToken.access_token}`, + 'Content-Type': 'application/json' + }; + + if (options.customer_transaction_id) { + headers['x-customer-transaction-id'] = options.customer_transaction_id; + } + + const response = await fetch(`${_options.url}/ship/v1/endofday/close`, { + body: JSON.stringify(closeRequest), + headers, + method: 'POST', + signal: AbortSignal.timeout(options.timeout || 30000) + }); + + if (!response.ok) { + throw await HttpError.from(response); + } + + const json = await response.json(); + + if (json.errors?.length) { + throw await HttpError.from(response); + } + + return json; + }; + /** * Create a FedEx shipment via the Ship API. The caller supplies the full * request body — `accountNumber`, `labelResponseOptions`, `requestedShipment` diff --git a/package.json b/package.json index 7aac9a6..e93bdd2 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "@stores.com/http-error": "~1.1.0", "memory-cache": "~0.2.0" }, - "description": "FedEx REST API client for address validation, OAuth tokens, rate quotes, shipment cancellation, and shipment creation.", + "description": "FedEx REST API client for address validation, end of day close, OAuth tokens, rate quotes, shipment cancellation, and shipment creation.", "devDependencies": { "@eslint/js": "*", "async": "~3.2.0", @@ -12,6 +12,7 @@ "keywords": [ "address-validation", "cancel", + "close", "create", "fedex", "logistics", @@ -33,5 +34,5 @@ "test": "node --test --test-force-exit --test-reporter=spec", "test:only": "node --test --test-force-exit --test-only --test-reporter=spec" }, - "version": "0.3.0" + "version": "0.4.0" } diff --git a/test/index.js b/test/index.js index b8f2abb..0a0e410 100644 --- a/test/index.js +++ b/test/index.js @@ -179,6 +179,190 @@ test('cancelShipment (mocked)', async (t) => { }); }); +test('close', { concurrency: true }, async (t) => { + t.test('should close ground shipments', async () => { + const fedex = new FedEx({ + api_key: process.env.FEDEX_API_KEY, + secret_key: process.env.FEDEX_SECRET_KEY, + url: process.env.FEDEX_URL + }); + + const body = await async.retry(async () => fedex.close({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + closeReqType: 'GCCLOSE' + })); + + assert(body); + assert(body.transactionId); + }); + + t.test('should close SmartPost shipments', async () => { + const fedex = new FedEx({ + api_key: process.env.FEDEX_API_KEY, + secret_key: process.env.FEDEX_SECRET_KEY, + url: process.env.FEDEX_URL + }); + + const body = await async.retry(async () => fedex.close({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + closeReqType: 'SPCLOSE', + smartPostDetail: { + destinationCountryCode: 'US', + hubId: process.env.FEDEX_SMART_POST_HUB_ID, + pickUpCarrier: 'FXSP' + } + })); + + assert(body); + assert(body.transactionId); + }); +}); + +test('close (mocked)', async (t) => { + t.test('should forward SmartPost close request body verbatim', async (t) => { + let sentBody; + + t.mock.method(globalThis, 'fetch', async (url, init) => { + if (url.endsWith('/oauth/token')) { + return new Response(JSON.stringify({ access_token: 'mock', expires_in: 3600, token_type: 'bearer' }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + if (url.endsWith('/ship/v1/endofday/close')) { + sentBody = JSON.parse(init.body); + return new Response(JSON.stringify({ output: { closeDocuments: [] }, transactionId: 'mock' }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); + + await fedex.close({ + accountNumber: { value: '510087941' }, + closeReqType: 'SPCLOSE', + smartPostDetail: { + destinationCountryCode: 'US', + hubId: '5531', + pickUpCarrier: 'FXSP' + } + }); + + assert.deepStrictEqual(sentBody, { + accountNumber: { value: '510087941' }, + closeReqType: 'SPCLOSE', + smartPostDetail: { + destinationCountryCode: 'US', + hubId: '5531', + pickUpCarrier: 'FXSP' + } + }); + }); + + t.test('should send options.customer_transaction_id as x-customer-transaction-id header and use POST method', async (t) => { + let sentHeader; + let sentMethod; + + t.mock.method(globalThis, 'fetch', async (url, init) => { + if (url.endsWith('/oauth/token')) { + return new Response(JSON.stringify({ access_token: 'mock', expires_in: 3600, token_type: 'bearer' }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + if (url.endsWith('/ship/v1/endofday/close')) { + sentHeader = init.headers['x-customer-transaction-id']; + sentMethod = init.method; + return new Response(JSON.stringify({ output: { closeDocuments: [] }, transactionId: 'mock' }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); + + await fedex.close({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + closeReqType: 'GCCLOSE' + }, { customer_transaction_id: 'abc-123' }); + + assert.strictEqual(sentHeader, 'abc-123'); + assert.strictEqual(sentMethod, 'POST'); + }); + + t.test('should throw HttpError for 200 response with errors envelope', async (t) => { + t.mock.method(globalThis, 'fetch', async (url) => { + if (url.endsWith('/oauth/token')) { + return new Response(JSON.stringify({ access_token: 'mock', expires_in: 3600, token_type: 'bearer' }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + if (url.endsWith('/ship/v1/endofday/close')) { + return new Response(JSON.stringify({ + errors: [ + { code: 'CLOSE.FAILURE', message: 'No shipments to close' } + ], + transactionId: 'mock' + }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); + + await assert.rejects(fedex.close({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + closeReqType: 'GCCLOSE' + }), (err) => { + assert.strictEqual(err.name, 'HttpError'); + return true; + }); + }); + + t.test('should throw HttpError for non 2xx response', async (t) => { + t.mock.method(globalThis, 'fetch', async (url) => { + if (url.endsWith('/oauth/token')) { + return new Response(JSON.stringify({ access_token: 'mock', expires_in: 3600, token_type: 'bearer' }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + if (url.endsWith('/ship/v1/endofday/close')) { + return new Response('', { status: 500, statusText: 'Internal Server Error' }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); + + await assert.rejects(fedex.close({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + closeReqType: 'GCCLOSE' + }), (err) => { + assert.strictEqual(err.name, 'HttpError'); + assert.match(err.message, /^500/); + return true; + }); + }); +}); + test('createShipment', { concurrency: true }, async (t) => { t.test('should create a FedEx Ground shipment', async () => { const fedex = new FedEx({ From ba1a5b41a2c38abe2abcaac48f6752c099cde8d5 Mon Sep 17 00:00:00 2001 From: "Farhat R. Kabir" <148591416+farhatraiyan@users.noreply.github.com> Date: Thu, 14 May 2026 13:56:30 -0500 Subject: [PATCH 2/6] Use /ship/v1/endofday endpoint for close Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 2 +- index.js | 2 +- test/index.js | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63e382f..9b42b4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 0.4.0 -- Add `close(closeRequest, options)` — calls the FedEx Ground End of Day Close API (`POST /ship/v1/endofday/close`). Same passthrough pattern as the other methods: caller supplies the full request body, the package forwards it verbatim. Supports `options.customer_transaction_id` and `options.timeout`. Non-2xx responses and 200-with-`errors[]` envelopes both reject with `HttpError`. +- Add `close(closeRequest, options)` — calls the FedEx Ground End of Day Close API (`POST /ship/v1/endofday`). Same passthrough pattern as the other methods: caller supplies the full request body, the package forwards it verbatim. Supports `options.customer_transaction_id` and `options.timeout`. Non-2xx responses and 200-with-`errors[]` envelopes both reject with `HttpError`. ## 0.3.0 diff --git a/index.js b/index.js index 0de0def..f7c70dc 100644 --- a/index.js +++ b/index.js @@ -77,7 +77,7 @@ function FedEx(args) { headers['x-customer-transaction-id'] = options.customer_transaction_id; } - const response = await fetch(`${_options.url}/ship/v1/endofday/close`, { + const response = await fetch(`${_options.url}/ship/v1/endofday`, { body: JSON.stringify(closeRequest), headers, method: 'POST', diff --git a/test/index.js b/test/index.js index 0a0e410..a71adc7 100644 --- a/test/index.js +++ b/test/index.js @@ -230,7 +230,7 @@ test('close (mocked)', async (t) => { }); } - if (url.endsWith('/ship/v1/endofday/close')) { + if (url.endsWith('/ship/v1/endofday')) { sentBody = JSON.parse(init.body); return new Response(JSON.stringify({ output: { closeDocuments: [] }, transactionId: 'mock' }), { headers: { 'Content-Type': 'application/json' }, @@ -276,7 +276,7 @@ test('close (mocked)', async (t) => { }); } - if (url.endsWith('/ship/v1/endofday/close')) { + if (url.endsWith('/ship/v1/endofday')) { sentHeader = init.headers['x-customer-transaction-id']; sentMethod = init.method; return new Response(JSON.stringify({ output: { closeDocuments: [] }, transactionId: 'mock' }), { @@ -308,7 +308,7 @@ test('close (mocked)', async (t) => { }); } - if (url.endsWith('/ship/v1/endofday/close')) { + if (url.endsWith('/ship/v1/endofday')) { return new Response(JSON.stringify({ errors: [ { code: 'CLOSE.FAILURE', message: 'No shipments to close' } @@ -343,7 +343,7 @@ test('close (mocked)', async (t) => { }); } - if (url.endsWith('/ship/v1/endofday/close')) { + if (url.endsWith('/ship/v1/endofday')) { return new Response('', { status: 500, statusText: 'Internal Server Error' }); } From 4e1581fe1639a8597a3940f7b733adae9dd6211d Mon Sep 17 00:00:00 2001 From: "Farhat R. Kabir" <148591416+farhatraiyan@users.noreply.github.com> Date: Thu, 14 May 2026 15:07:48 -0500 Subject: [PATCH 3/6] Correct close API per official OpenAPI spec: PUT not POST, GCDR not GCCLOSE, rename to groundClose The FedEx Ground End of Day Close API uses PUT /ship/v1/endofday/ for close operations (POST is for reprinting). The closeReqType is GCDR, and groundServiceCategory + closeDate are required fields. There is no SmartPost-specific close in the REST API. Renamed method from close to groundClose to match the API scope and align with fulfillment-service consumption patterns. All methods, tests, and docs are alphabetized. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 2 +- README.md | 32 ++--- index.js | 91 ++++++------- package.json | 4 +- test/index.js | 349 ++++++++++++++++++++++++-------------------------- 5 files changed, 231 insertions(+), 247 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b42b4d..f978fdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 0.4.0 -- Add `close(closeRequest, options)` — calls the FedEx Ground End of Day Close API (`POST /ship/v1/endofday`). Same passthrough pattern as the other methods: caller supplies the full request body, the package forwards it verbatim. Supports `options.customer_transaction_id` and `options.timeout`. Non-2xx responses and 200-with-`errors[]` envelopes both reject with `HttpError`. +- Add `groundClose(closeRequest, options)` — calls the FedEx Ground End of Day Close API (`PUT /ship/v1/endofday/`). Same passthrough pattern as the other methods: caller supplies the full request body, the package forwards it verbatim. Supports `options.customer_transaction_id` and `options.timeout`. Non-2xx responses and 200-with-`errors[]` envelopes both reject with `HttpError`. ## 0.3.0 diff --git a/README.md b/README.md index ae509bc..525e2b1 100644 --- a/README.md +++ b/README.md @@ -57,21 +57,6 @@ const json = await fedex.cancelShipment({ Non-2xx responses reject with `HttpError`. If FedEx returns a 200 response carrying a non-empty `errors[]` envelope, the call rejects with an `HttpError` whose message is every `message` joined by `; ` and whose `.json` is the full response body (with the `errors[]` array, codes, and any other fields). -### close(closeRequest, options) - -Close out FedEx shipments via the Ground End of Day Close API. The caller supplies the full request body — `accountNumber`, `closeReqType`, `smartPostDetail` — and the package forwards it verbatim. - -See: https://developer.fedex.com/api/en-us/catalog/ship/v1/docs.html - -```javascript -const json = await fedex.close({ - accountNumber: { value: 'your_account_number' }, - closeReqType: 'GCCLOSE' -}); -``` - -Non-2xx responses reject with `HttpError`. If FedEx returns a 200 response carrying a non-empty `errors[]` envelope, the call rejects with an `HttpError` whose message is every `message` joined by `; ` and whose `.json` is the full response body (with the `errors[]` array, codes, and any other fields). - ### createShipment(shipRequest, options) Create a FedEx shipment via the Ship API. The caller supplies the full request body — `accountNumber`, `labelResponseOptions`, `requestedShipment` — and the package forwards it verbatim. @@ -143,6 +128,23 @@ console.log(accessToken); // } ``` +### groundClose(closeRequest, options) + +Close out FedEx Ground shipments via the Ground End of Day Close API. The caller supplies the full request body — `accountNumber`, `closeDate`, `closeReqType`, `groundServiceCategory` — and the package forwards it verbatim. + +See: https://developer.fedex.com/api/en-us/catalog/close/v1/docs.html + +```javascript +const json = await fedex.groundClose({ + accountNumber: { value: 'your_account_number' }, + closeDate: '2026-05-14', + closeReqType: 'GCDR', + groundServiceCategory: 'GROUND' +}); +``` + +Non-2xx responses reject with `HttpError`. If FedEx returns a 200 response carrying a non-empty `errors[]` envelope, the call rejects with an `HttpError` whose message is every `message` joined by `; ` and whose `.json` is the full response body (with the `errors[]` array, codes, and any other fields). + ### rateAndTransitTimes(rateRequest, options) Request rate quotes and transit times from FedEx. The caller supplies the full request body — `accountNumber`, `requestedShipment`, and any of `rateRequestControlParameters`, `carrierCodes`, `processingOptions`, `version` — and the package forwards it verbatim. diff --git a/index.js b/index.js index f7c70dc..7ac0c76 100644 --- a/index.js +++ b/index.js @@ -52,51 +52,6 @@ function FedEx(args) { return json; }; - /** - * Close out FedEx shipments via the Ground End of Day Close API. The caller - * supplies the full request body — `accountNumber`, `closeReqType`, - * `smartPostDetail` — and the package forwards it verbatim. - * - * @param {object} closeRequest - Full Ground End of Day Close request body. - * @param {object} [options] - * @param {string} [options.customer_transaction_id] - Sent as the `x-customer-transaction-id` - * request header. FedEx echoes this back so callers can correlate requests with responses. - * @param {number} [options.timeout=30000] - Request timeout in milliseconds. - * @returns {Promise} The parsed response body. - * @see https://developer.fedex.com/api/en-us/catalog/ship/v1/docs.html - */ - this.close = async (closeRequest, options = {}) => { - const accessToken = await this.getAccessToken(); - - const headers = { - Authorization: `Bearer ${accessToken.access_token}`, - 'Content-Type': 'application/json' - }; - - if (options.customer_transaction_id) { - headers['x-customer-transaction-id'] = options.customer_transaction_id; - } - - const response = await fetch(`${_options.url}/ship/v1/endofday`, { - body: JSON.stringify(closeRequest), - headers, - method: 'POST', - signal: AbortSignal.timeout(options.timeout || 30000) - }); - - if (!response.ok) { - throw await HttpError.from(response); - } - - const json = await response.json(); - - if (json.errors?.length) { - throw await HttpError.from(response); - } - - return json; - }; - /** * Create a FedEx shipment via the Ship API. The caller supplies the full * request body — `accountNumber`, `labelResponseOptions`, `requestedShipment` @@ -184,6 +139,52 @@ function FedEx(args) { return json; }; + /** + * Close out FedEx Ground shipments via the Ground End of Day Close API. The + * caller supplies the full request body — `accountNumber`, `closeDate`, + * `closeReqType`, `groundServiceCategory` — and the package forwards it + * verbatim. + * + * @param {object} closeRequest - Full Ground End of Day Close request body. + * @param {object} [options] + * @param {string} [options.customer_transaction_id] - Sent as the `x-customer-transaction-id` + * request header. FedEx echoes this back so callers can correlate requests with responses. + * @param {number} [options.timeout=30000] - Request timeout in milliseconds. + * @returns {Promise} The parsed response body. + * @see https://developer.fedex.com/api/en-us/catalog/close/v1/docs.html + */ + this.groundClose = async (closeRequest, options = {}) => { + const accessToken = await this.getAccessToken(); + + const headers = { + Authorization: `Bearer ${accessToken.access_token}`, + 'Content-Type': 'application/json' + }; + + if (options.customer_transaction_id) { + headers['x-customer-transaction-id'] = options.customer_transaction_id; + } + + const response = await fetch(`${_options.url}/ship/v1/endofday/`, { + body: JSON.stringify(closeRequest), + headers, + method: 'PUT', + signal: AbortSignal.timeout(options.timeout || 30000) + }); + + if (!response.ok) { + throw await HttpError.from(response); + } + + const json = await response.json(); + + if (json.errors?.length) { + throw await HttpError.from(response); + } + + return json; + }; + /** * Call the FedEx Rates and Transit Times API. The caller supplies the full request body * — `accountNumber`, `requestedShipment`, and any of `rateRequestControlParameters`, diff --git a/package.json b/package.json index e93bdd2..6f6950a 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "@stores.com/http-error": "~1.1.0", "memory-cache": "~0.2.0" }, - "description": "FedEx REST API client for address validation, end of day close, OAuth tokens, rate quotes, shipment cancellation, and shipment creation.", + "description": "FedEx REST API client for address validation, ground end of day close, OAuth tokens, rate quotes, shipment cancellation, and shipment creation.", "devDependencies": { "@eslint/js": "*", "async": "~3.2.0", @@ -12,7 +12,7 @@ "keywords": [ "address-validation", "cancel", - "close", + "ground-close", "create", "fedex", "logistics", diff --git a/test/index.js b/test/index.js index a71adc7..a4a840a 100644 --- a/test/index.js +++ b/test/index.js @@ -179,190 +179,6 @@ test('cancelShipment (mocked)', async (t) => { }); }); -test('close', { concurrency: true }, async (t) => { - t.test('should close ground shipments', async () => { - const fedex = new FedEx({ - api_key: process.env.FEDEX_API_KEY, - secret_key: process.env.FEDEX_SECRET_KEY, - url: process.env.FEDEX_URL - }); - - const body = await async.retry(async () => fedex.close({ - accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, - closeReqType: 'GCCLOSE' - })); - - assert(body); - assert(body.transactionId); - }); - - t.test('should close SmartPost shipments', async () => { - const fedex = new FedEx({ - api_key: process.env.FEDEX_API_KEY, - secret_key: process.env.FEDEX_SECRET_KEY, - url: process.env.FEDEX_URL - }); - - const body = await async.retry(async () => fedex.close({ - accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, - closeReqType: 'SPCLOSE', - smartPostDetail: { - destinationCountryCode: 'US', - hubId: process.env.FEDEX_SMART_POST_HUB_ID, - pickUpCarrier: 'FXSP' - } - })); - - assert(body); - assert(body.transactionId); - }); -}); - -test('close (mocked)', async (t) => { - t.test('should forward SmartPost close request body verbatim', async (t) => { - let sentBody; - - t.mock.method(globalThis, 'fetch', async (url, init) => { - if (url.endsWith('/oauth/token')) { - return new Response(JSON.stringify({ access_token: 'mock', expires_in: 3600, token_type: 'bearer' }), { - headers: { 'Content-Type': 'application/json' }, - status: 200 - }); - } - - if (url.endsWith('/ship/v1/endofday')) { - sentBody = JSON.parse(init.body); - return new Response(JSON.stringify({ output: { closeDocuments: [] }, transactionId: 'mock' }), { - headers: { 'Content-Type': 'application/json' }, - status: 200 - }); - } - - throw new Error(`Unexpected fetch URL: ${url}`); - }); - - const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); - - await fedex.close({ - accountNumber: { value: '510087941' }, - closeReqType: 'SPCLOSE', - smartPostDetail: { - destinationCountryCode: 'US', - hubId: '5531', - pickUpCarrier: 'FXSP' - } - }); - - assert.deepStrictEqual(sentBody, { - accountNumber: { value: '510087941' }, - closeReqType: 'SPCLOSE', - smartPostDetail: { - destinationCountryCode: 'US', - hubId: '5531', - pickUpCarrier: 'FXSP' - } - }); - }); - - t.test('should send options.customer_transaction_id as x-customer-transaction-id header and use POST method', async (t) => { - let sentHeader; - let sentMethod; - - t.mock.method(globalThis, 'fetch', async (url, init) => { - if (url.endsWith('/oauth/token')) { - return new Response(JSON.stringify({ access_token: 'mock', expires_in: 3600, token_type: 'bearer' }), { - headers: { 'Content-Type': 'application/json' }, - status: 200 - }); - } - - if (url.endsWith('/ship/v1/endofday')) { - sentHeader = init.headers['x-customer-transaction-id']; - sentMethod = init.method; - return new Response(JSON.stringify({ output: { closeDocuments: [] }, transactionId: 'mock' }), { - headers: { 'Content-Type': 'application/json' }, - status: 200 - }); - } - - throw new Error(`Unexpected fetch URL: ${url}`); - }); - - const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); - - await fedex.close({ - accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, - closeReqType: 'GCCLOSE' - }, { customer_transaction_id: 'abc-123' }); - - assert.strictEqual(sentHeader, 'abc-123'); - assert.strictEqual(sentMethod, 'POST'); - }); - - t.test('should throw HttpError for 200 response with errors envelope', async (t) => { - t.mock.method(globalThis, 'fetch', async (url) => { - if (url.endsWith('/oauth/token')) { - return new Response(JSON.stringify({ access_token: 'mock', expires_in: 3600, token_type: 'bearer' }), { - headers: { 'Content-Type': 'application/json' }, - status: 200 - }); - } - - if (url.endsWith('/ship/v1/endofday')) { - return new Response(JSON.stringify({ - errors: [ - { code: 'CLOSE.FAILURE', message: 'No shipments to close' } - ], - transactionId: 'mock' - }), { - headers: { 'Content-Type': 'application/json' }, - status: 200 - }); - } - - throw new Error(`Unexpected fetch URL: ${url}`); - }); - - const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); - - await assert.rejects(fedex.close({ - accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, - closeReqType: 'GCCLOSE' - }), (err) => { - assert.strictEqual(err.name, 'HttpError'); - return true; - }); - }); - - t.test('should throw HttpError for non 2xx response', async (t) => { - t.mock.method(globalThis, 'fetch', async (url) => { - if (url.endsWith('/oauth/token')) { - return new Response(JSON.stringify({ access_token: 'mock', expires_in: 3600, token_type: 'bearer' }), { - headers: { 'Content-Type': 'application/json' }, - status: 200 - }); - } - - if (url.endsWith('/ship/v1/endofday')) { - return new Response('', { status: 500, statusText: 'Internal Server Error' }); - } - - throw new Error(`Unexpected fetch URL: ${url}`); - }); - - const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); - - await assert.rejects(fedex.close({ - accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, - closeReqType: 'GCCLOSE' - }), (err) => { - assert.strictEqual(err.name, 'HttpError'); - assert.match(err.message, /^500/); - return true; - }); - }); -}); - test('createShipment', { concurrency: true }, async (t) => { t.test('should create a FedEx Ground shipment', async () => { const fedex = new FedEx({ @@ -588,6 +404,171 @@ test('getAccessToken', { concurrency: true }, async (t) => { }); }); +test('groundClose', async (t) => { + t.test('should close ground shipments', async () => { + const fedex = new FedEx({ + api_key: process.env.FEDEX_API_KEY, + secret_key: process.env.FEDEX_SECRET_KEY, + url: process.env.FEDEX_URL + }); + + const body = await async.retry(async () => fedex.groundClose({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + closeDate: new Date().toISOString().slice(0, 10), + closeReqType: 'GCDR', + groundServiceCategory: 'GROUND' + })); + + assert(body); + assert(body.transactionId); + }); +}); + +test('groundClose (mocked)', async (t) => { + t.test('should forward close request body verbatim', async (t) => { + let sentBody; + + t.mock.method(globalThis, 'fetch', async (url, init) => { + if (url.endsWith('/oauth/token')) { + return new Response(JSON.stringify({ access_token: 'mock', expires_in: 3600, token_type: 'bearer' }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + if (url.endsWith('/ship/v1/endofday/')) { + sentBody = JSON.parse(init.body); + return new Response(JSON.stringify({ output: { closeDocuments: [] }, transactionId: 'mock' }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); + + await fedex.groundClose({ + accountNumber: { value: '123456789' }, + closeDate: '2026-05-14', + closeReqType: 'GCDR', + groundServiceCategory: 'GROUND' + }); + + assert.deepStrictEqual(sentBody, { + accountNumber: { value: '123456789' }, + closeDate: '2026-05-14', + closeReqType: 'GCDR', + groundServiceCategory: 'GROUND' + }); + }); + + t.test('should send options.customer_transaction_id as x-customer-transaction-id header and use PUT method', async (t) => { + let sentHeader; + let sentMethod; + + t.mock.method(globalThis, 'fetch', async (url, init) => { + if (url.endsWith('/oauth/token')) { + return new Response(JSON.stringify({ access_token: 'mock', expires_in: 3600, token_type: 'bearer' }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + if (url.endsWith('/ship/v1/endofday/')) { + sentHeader = init.headers['x-customer-transaction-id']; + sentMethod = init.method; + return new Response(JSON.stringify({ output: { closeDocuments: [] }, transactionId: 'mock' }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); + + await fedex.groundClose({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + closeDate: '2026-05-14', + closeReqType: 'GCDR', + groundServiceCategory: 'GROUND' + }, { customer_transaction_id: 'abc-123' }); + + assert.strictEqual(sentHeader, 'abc-123'); + assert.strictEqual(sentMethod, 'PUT'); + }); + + t.test('should throw HttpError for 200 response with errors envelope', async (t) => { + t.mock.method(globalThis, 'fetch', async (url) => { + if (url.endsWith('/oauth/token')) { + return new Response(JSON.stringify({ access_token: 'mock', expires_in: 3600, token_type: 'bearer' }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + if (url.endsWith('/ship/v1/endofday/')) { + return new Response(JSON.stringify({ + errors: [ + { code: 'CLOSE.FAILURE', message: 'No shipments to close' } + ], + transactionId: 'mock' + }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); + + await assert.rejects(fedex.groundClose({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + closeDate: '2026-05-14', + closeReqType: 'GCDR', + groundServiceCategory: 'GROUND' + }), (err) => { + assert.strictEqual(err.name, 'HttpError'); + return true; + }); + }); + + t.test('should throw HttpError for non 2xx response', async (t) => { + t.mock.method(globalThis, 'fetch', async (url) => { + if (url.endsWith('/oauth/token')) { + return new Response(JSON.stringify({ access_token: 'mock', expires_in: 3600, token_type: 'bearer' }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + if (url.endsWith('/ship/v1/endofday/')) { + return new Response('', { status: 500, statusText: 'Internal Server Error' }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); + + await assert.rejects(fedex.groundClose({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + closeDate: '2026-05-14', + closeReqType: 'GCDR', + groundServiceCategory: 'GROUND' + }), (err) => { + assert.strictEqual(err.name, 'HttpError'); + assert.match(err.message, /^500/); + return true; + }); + }); +}); + test('rateAndTransitTimes', { concurrency: true }, async (t) => { t.test('should return rate quotes for a Ground shipment', async () => { const fedex = new FedEx({ From a943b6ccd4fb10873504c07d855eeee5367f9855 Mon Sep 17 00:00:00 2001 From: "Farhat R. Kabir" <148591416+farhatraiyan@users.noreply.github.com> Date: Thu, 14 May 2026 15:25:21 -0500 Subject: [PATCH 4/6] Alphabetize keywords and add concurrency flag to groundClose test Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- test/index.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 6f6950a..7ae7425 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,9 @@ "keywords": [ "address-validation", "cancel", - "ground-close", "create", "fedex", + "ground-close", "logistics", "rates", "ship", diff --git a/test/index.js b/test/index.js index a4a840a..178ebb5 100644 --- a/test/index.js +++ b/test/index.js @@ -404,7 +404,7 @@ test('getAccessToken', { concurrency: true }, async (t) => { }); }); -test('groundClose', async (t) => { +test('groundClose', { concurrency: true }, async (t) => { t.test('should close ground shipments', async () => { const fedex = new FedEx({ api_key: process.env.FEDEX_API_KEY, From 5c1b8edf7428eb7577ad4d0c42c13b56a88c60dc Mon Sep 17 00:00:00 2001 From: "Farhat R. Kabir" <148591416+farhatraiyan@users.noreply.github.com> Date: Thu, 14 May 2026 16:17:09 -0500 Subject: [PATCH 5/6] Document output.closeDocuments[] in groundClose @returns Co-Authored-By: Claude Opus 4.6 --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index 7ac0c76..e468be1 100644 --- a/index.js +++ b/index.js @@ -150,7 +150,7 @@ function FedEx(args) { * @param {string} [options.customer_transaction_id] - Sent as the `x-customer-transaction-id` * request header. FedEx echoes this back so callers can correlate requests with responses. * @param {number} [options.timeout=30000] - Request timeout in milliseconds. - * @returns {Promise} The parsed response body. + * @returns {Promise} The parsed response body, including `output.closeDocuments[]`. * @see https://developer.fedex.com/api/en-us/catalog/close/v1/docs.html */ this.groundClose = async (closeRequest, options = {}) => { From e56cfd093ccc56ce814ea4e2ad1228a27dc1fc77 Mon Sep 17 00:00:00 2001 From: Shawn Miller Date: Thu, 14 May 2026 21:20:11 -0500 Subject: [PATCH 6/6] rename groundClose to groundEndOfDayClose Mirrors the FedEx API name ("Ground End of Day Close") in the method symbol the same way the other methods do (cancelShipment, createShipment, etc.). - index.js: this.groundClose -> this.groundEndOfDayClose - README.md / CHANGELOG.md: section heading + code examples - test/index.js: outer test() block names + fedex.* call sites - package.json: keyword "ground-close" -> "ground-end-of-day-close" Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 +- README.md | 4 ++-- index.js | 2 +- package.json | 2 +- test/index.js | 14 +++++++------- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f978fdf..834b8d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 0.4.0 -- Add `groundClose(closeRequest, options)` — calls the FedEx Ground End of Day Close API (`PUT /ship/v1/endofday/`). Same passthrough pattern as the other methods: caller supplies the full request body, the package forwards it verbatim. Supports `options.customer_transaction_id` and `options.timeout`. Non-2xx responses and 200-with-`errors[]` envelopes both reject with `HttpError`. +- Add `groundEndOfDayClose(closeRequest, options)` — calls the FedEx Ground End of Day Close API (`PUT /ship/v1/endofday/`). Same passthrough pattern as the other methods: caller supplies the full request body, the package forwards it verbatim. Supports `options.customer_transaction_id` and `options.timeout`. Non-2xx responses and 200-with-`errors[]` envelopes both reject with `HttpError`. ## 0.3.0 diff --git a/README.md b/README.md index 525e2b1..57f31aa 100644 --- a/README.md +++ b/README.md @@ -128,14 +128,14 @@ console.log(accessToken); // } ``` -### groundClose(closeRequest, options) +### groundEndOfDayClose(closeRequest, options) Close out FedEx Ground shipments via the Ground End of Day Close API. The caller supplies the full request body — `accountNumber`, `closeDate`, `closeReqType`, `groundServiceCategory` — and the package forwards it verbatim. See: https://developer.fedex.com/api/en-us/catalog/close/v1/docs.html ```javascript -const json = await fedex.groundClose({ +const json = await fedex.groundEndOfDayClose({ accountNumber: { value: 'your_account_number' }, closeDate: '2026-05-14', closeReqType: 'GCDR', diff --git a/index.js b/index.js index e468be1..aaea905 100644 --- a/index.js +++ b/index.js @@ -153,7 +153,7 @@ function FedEx(args) { * @returns {Promise} The parsed response body, including `output.closeDocuments[]`. * @see https://developer.fedex.com/api/en-us/catalog/close/v1/docs.html */ - this.groundClose = async (closeRequest, options = {}) => { + this.groundEndOfDayClose = async (closeRequest, options = {}) => { const accessToken = await this.getAccessToken(); const headers = { diff --git a/package.json b/package.json index 7ae7425..a612a8a 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "cancel", "create", "fedex", - "ground-close", + "ground-end-of-day-close", "logistics", "rates", "ship", diff --git a/test/index.js b/test/index.js index 178ebb5..4a906a6 100644 --- a/test/index.js +++ b/test/index.js @@ -404,7 +404,7 @@ test('getAccessToken', { concurrency: true }, async (t) => { }); }); -test('groundClose', { concurrency: true }, async (t) => { +test('groundEndOfDayClose', { concurrency: true }, async (t) => { t.test('should close ground shipments', async () => { const fedex = new FedEx({ api_key: process.env.FEDEX_API_KEY, @@ -412,7 +412,7 @@ test('groundClose', { concurrency: true }, async (t) => { url: process.env.FEDEX_URL }); - const body = await async.retry(async () => fedex.groundClose({ + const body = await async.retry(async () => fedex.groundEndOfDayClose({ accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, closeDate: new Date().toISOString().slice(0, 10), closeReqType: 'GCDR', @@ -424,7 +424,7 @@ test('groundClose', { concurrency: true }, async (t) => { }); }); -test('groundClose (mocked)', async (t) => { +test('groundEndOfDayClose (mocked)', async (t) => { t.test('should forward close request body verbatim', async (t) => { let sentBody; @@ -449,7 +449,7 @@ test('groundClose (mocked)', async (t) => { const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); - await fedex.groundClose({ + await fedex.groundEndOfDayClose({ accountNumber: { value: '123456789' }, closeDate: '2026-05-14', closeReqType: 'GCDR', @@ -490,7 +490,7 @@ test('groundClose (mocked)', async (t) => { const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); - await fedex.groundClose({ + await fedex.groundEndOfDayClose({ accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, closeDate: '2026-05-14', closeReqType: 'GCDR', @@ -527,7 +527,7 @@ test('groundClose (mocked)', async (t) => { const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); - await assert.rejects(fedex.groundClose({ + await assert.rejects(fedex.groundEndOfDayClose({ accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, closeDate: '2026-05-14', closeReqType: 'GCDR', @@ -556,7 +556,7 @@ test('groundClose (mocked)', async (t) => { const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); - await assert.rejects(fedex.groundClose({ + await assert.rejects(fedex.groundEndOfDayClose({ accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, closeDate: '2026-05-14', closeReqType: 'GCDR',