diff --git a/CHANGELOG.md b/CHANGELOG.md index b8413d5..834b8d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.4.0 + +- 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 - 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..57f31aa 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 @@ -128,6 +128,23 @@ console.log(accessToken); // } ``` +### 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.groundEndOfDayClose({ + 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 194500e..aaea905 100644 --- a/index.js +++ b/index.js @@ -139,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, including `output.closeDocuments[]`. + * @see https://developer.fedex.com/api/en-us/catalog/close/v1/docs.html + */ + this.groundEndOfDayClose = 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 7aac9a6..a612a8a 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, ground end of day close, OAuth tokens, rate quotes, shipment cancellation, and shipment creation.", "devDependencies": { "@eslint/js": "*", "async": "~3.2.0", @@ -14,6 +14,7 @@ "cancel", "create", "fedex", + "ground-end-of-day-close", "logistics", "rates", "ship", @@ -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..4a906a6 100644 --- a/test/index.js +++ b/test/index.js @@ -404,6 +404,171 @@ test('getAccessToken', { 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, + secret_key: process.env.FEDEX_SECRET_KEY, + url: process.env.FEDEX_URL + }); + + const body = await async.retry(async () => fedex.groundEndOfDayClose({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + closeDate: new Date().toISOString().slice(0, 10), + closeReqType: 'GCDR', + groundServiceCategory: 'GROUND' + })); + + assert(body); + assert(body.transactionId); + }); +}); + +test('groundEndOfDayClose (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.groundEndOfDayClose({ + 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.groundEndOfDayClose({ + 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.groundEndOfDayClose({ + 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.groundEndOfDayClose({ + 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({