diff --git a/README.md b/README.md index 180723e..acb1d9b 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,27 @@ console.log(formattedDate); // DateTime(2020, 12, 31) You can read more about the Accounting API [here](https://developer.xero.com/documentation/api/accounting/overview). +### Attachments + +You can read more about the Attachments API [here](https://developer.xero.com/documentation/api/attachments/overview). + +#### Methods + +##### createInvoiceAttachment(client:XeroClient, tenantId:string, { invoiceId:string, fileName:string, contents:Buffer }) + +Creates an attachment for an invoice. + +```TypeScript +import { createInvoiceAttachment } from 'xero-hero'; + +const invoice = await getMyInvoice(); +const attachment = await createInvoiceAttachment(xero, tenantId, { + invoiceId: invoice.invoiceId, + fileName: 'my-attachment.pdf', + contents: fs.readFileSync('my-attachment.pdf'), +}); +``` + ### Contacts You can read more about the Contacts API [here](https://developer.xero.com/documentation/api/contacts/overview). diff --git a/package-lock.json b/package-lock.json index 7dca0d3..716957f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,23 @@ { "name": "xero-hero", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xero-hero", - "version": "0.4.0", + "version": "0.5.0", "license": "ISC", "dependencies": { "deep-cuts": "^2.8.0", - "qs": "^6.11.2" + "qs": "^6.11.2", + "tranquil-stream": "^0.1.0" }, "devDependencies": { "@types/jest": "^29.5.5", "@types/luxon": "^3.3.2", "@types/qs": "^6.9.8", + "@types/uuid": "^9.0.5", "eslint-config-ethang": "^11.0.0", "eslint-plugin-jest": "^27.4.0", "jest": "^29.7.0", @@ -23,6 +25,7 @@ "ts-jest": "^29.1.1", "tsup": "^7.2.0", "typescript": "^5.2.2", + "uuid": "^9.0.1", "xero-node": "^4.36.0" }, "peerDependencies": { @@ -1842,6 +1845,12 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "node_modules/@types/uuid": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.5.tgz", + "integrity": "sha512-xfHdwa1FMJ082prjSJpoEI57GZITiQz10r3vEJCHa2khEFQjKy91aWKz6+zybzssCvXUwE1LQWgWVwZ4nYUvHQ==", + "dev": true + }, "node_modules/@types/yargs": { "version": "17.0.24", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.24.tgz", @@ -8248,6 +8257,16 @@ "node": ">=0.6" } }, + "node_modules/request/node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "dev": true, + "bin": { + "uuid": "bin/uuid" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -9032,6 +9051,14 @@ "punycode": "^2.1.0" } }, + "node_modules/tranquil-stream": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/tranquil-stream/-/tranquil-stream-0.1.0.tgz", + "integrity": "sha512-1R7+lPwT8jXx9D9cgxbFRUjdM18ItfTdx6epgsNEA/2T4xI/w2rwv0QKvxr09RhpYaoixVXFyvzSQDxFf23ahw==", + "engines": { + "node": ">=10" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -9422,13 +9449,16 @@ "peer": true }, "node_modules/uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "bin": { - "uuid": "bin/uuid" + "uuid": "dist/bin/uuid" } }, "node_modules/v8-to-istanbul": { diff --git a/package.json b/package.json index 7476d7f..66cea99 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "xero-hero", - "version": "0.4.0", + "version": "0.5.0", "description": "", "files": [ "dist", @@ -45,6 +45,7 @@ "@types/jest": "^29.5.5", "@types/luxon": "^3.3.2", "@types/qs": "^6.9.8", + "@types/uuid": "^9.0.5", "eslint-config-ethang": "^11.0.0", "eslint-plugin-jest": "^27.4.0", "jest": "^29.7.0", @@ -52,10 +53,12 @@ "ts-jest": "^29.1.1", "tsup": "^7.2.0", "typescript": "^5.2.2", + "uuid": "^9.0.1", "xero-node": "^4.36.0" }, "dependencies": { "deep-cuts": "^2.8.0", - "qs": "^6.11.2" + "qs": "^6.11.2", + "tranquil-stream": "^0.1.0" } } diff --git a/src/accounting/attachments/__tests__/requests.test.ts b/src/accounting/attachments/__tests__/requests.test.ts new file mode 100644 index 0000000..77260d5 --- /dev/null +++ b/src/accounting/attachments/__tests__/requests.test.ts @@ -0,0 +1,36 @@ +import { Duplex } from 'node:stream'; + +import { v4 as uuid } from 'uuid'; +import type { XeroClient } from 'xero-node'; + +import { createInvoiceAttachment } from '../../../'; + +const getMockXeroClient = (): any => { + return { + accountingApi: { + createInvoiceAttachmentByFileName: jest.fn(), + }, + }; +}; + +describe('attachments/requests', () => { + describe('createInvoiceAttachment()', () => { + it('should call the createInvoiceAttachmentByFileName() method with the relevant arguments ', () => { + const client = getMockXeroClient() as unknown as XeroClient; + const tenantId = uuid(); + const invoiceId = uuid(); + const filename = 'test.pdf'; + const contents = Buffer.from('Test Content Here'); + + createInvoiceAttachment(client, tenantId, { + contents, + filename, + invoiceId, + }); + + expect( + client.accountingApi.createInvoiceAttachmentByFileName, + ).toHaveBeenCalledWith(tenantId, invoiceId, filename, expect.any(Duplex)); + }); + }); +}); diff --git a/src/accounting/attachments/index.ts b/src/accounting/attachments/index.ts new file mode 100644 index 0000000..604fa58 --- /dev/null +++ b/src/accounting/attachments/index.ts @@ -0,0 +1 @@ +export { createInvoiceAttachment } from './requests'; diff --git a/src/accounting/attachments/requests.ts b/src/accounting/attachments/requests.ts new file mode 100644 index 0000000..16e3dab --- /dev/null +++ b/src/accounting/attachments/requests.ts @@ -0,0 +1,27 @@ +import type { ReadStream } from 'node:fs'; +import type http from 'node:http'; + +import { bufferToStream } from 'tranquil-stream'; +import type { Attachments, XeroClient } from 'xero-node'; + +type ICreateInvoiceAttachmentParameters = { + contents: Buffer; + filename: string; + invoiceId: string; +}; + +export const createInvoiceAttachment = async ( + client: XeroClient, + tenantId: string, + { invoiceId, filename, contents }: ICreateInvoiceAttachmentParameters, +): Promise<{ + body: Attachments; + response: http.IncomingMessage; +}> => { + return client.accountingApi.createInvoiceAttachmentByFileName( + tenantId, + invoiceId, + filename, + bufferToStream(contents) as unknown as ReadStream, + ); +}; diff --git a/src/accounting/contacts/__tests__/links.test.ts b/src/accounting/contacts/__tests__/links.test.ts index 45b1ec2..3cc4a02 100644 --- a/src/accounting/contacts/__tests__/links.test.ts +++ b/src/accounting/contacts/__tests__/links.test.ts @@ -7,36 +7,36 @@ describe('contacts/links', () => { it('returns a null indicator if passed undefined', () => { // @ts-expect-error - This is an invalid type for the function. expect(getContactLink()).toBe( - 'https://go.xero.com/app/!kR4N6/contacts/contact/null-or-empty-contact-id', + 'https://go.xero.com/Contacts/View.aspx?contactID=null-or-empty-contact-id', ); }); it('returns a null indicator if passed null', () => { // @ts-expect-error - This is an invalid type for the function. expect(getContactLink(null)).toBe( - 'https://go.xero.com/app/!kR4N6/contacts/contact/null-or-empty-contact-id', + 'https://go.xero.com/Contacts/View.aspx?contactID=null-or-empty-contact-id', ); }); it('returns a null indicator if passed an empty string', () => { expect(getContactLink('')).toBe( - 'https://go.xero.com/app/!kR4N6/contacts/contact/null-or-empty-contact-id', + 'https://go.xero.com/Contacts/View.aspx?contactID=null-or-empty-contact-id', ); }); - it('returns the correct URL for an invoice object with an invoiceID property', () => { + it('returns the correct URL for an contact object with an contactID property', () => { const contact = new Contact(); /* eslint-disable functional/immutable-data */ contact.contactID = '25OR6TO4'; /* eslint-enable functional/immutable-data */ expect(getContactLink(contact)).toBe( - 'https://go.xero.com/app/!kR4N6/contacts/contact/25OR6TO4', + 'https://go.xero.com/Contacts/View.aspx?contactID=25OR6TO4', ); }); it('returns the correct URL for a string invoice ID', () => { expect(getContactLink('kooolaid-9086-t728')).toBe( - 'https://go.xero.com/app/!kR4N6/contacts/contact/kooolaid-9086-t728', + 'https://go.xero.com/Contacts/View.aspx?contactID=kooolaid-9086-t728', ); }); @@ -45,7 +45,7 @@ describe('contacts/links', () => { contactID: '25OR6TO4', }; expect(getContactLink(contact)).toBe( - 'https://go.xero.com/app/!kR4N6/contacts/contact/25OR6TO4', + 'https://go.xero.com/Contacts/View.aspx?contactID=25OR6TO4', ); }); }); diff --git a/src/accounting/contacts/links.ts b/src/accounting/contacts/links.ts index 0588b87..61f5aa5 100644 --- a/src/accounting/contacts/links.ts +++ b/src/accounting/contacts/links.ts @@ -1,11 +1,13 @@ +import qs from 'qs'; import type { Contact } from 'xero-node'; import { hasProperty } from '../../utils/properties'; export const getContactLink = (contact: Contact | string): string => { - return `https://go.xero.com/app/!kR4N6/contacts/contact/${ - (hasProperty(contact, 'contactID') - ? (contact as Contact).contactID - : contact) || 'null-or-empty-contact-id' - }`; + return `https://go.xero.com/Contacts/View.aspx?${qs.stringify({ + contactID: + (hasProperty(contact, 'contactID') + ? (contact as Contact).contactID + : contact) || 'null-or-empty-contact-id', + })}`; }; diff --git a/src/index.ts b/src/index.ts index 4b67702..5d2ffb8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ // Common export { dateInWhereFormat } from './common'; +// Attachments +export { createInvoiceAttachment } from './accounting/attachments'; // Contacts export { getContactLink } from './accounting/contacts'; // Invoices diff --git a/src/projects/__tests__/timeEntries.test.ts b/src/projects/__tests__/timeEntries.test.ts index 39e080e..ad41961 100644 --- a/src/projects/__tests__/timeEntries.test.ts +++ b/src/projects/__tests__/timeEntries.test.ts @@ -1,6 +1,5 @@ -import { TimeEntry } from 'xero-node/dist/gen/model/projects/timeEntry'; - import { hoursFromTimeEntries } from '../../'; +import type { TimeEntry } from '../shimTypes'; describe('projects/timeEntries', () => { describe('hoursFromTimeEntries()', () => { @@ -39,9 +38,9 @@ describe('projects/timeEntries', () => { it('should play nice with a non-default denominator for rounding', () => { /* eslint-disable functional/immutable-data */ - const timeEntryA = new TimeEntry(); + const timeEntryA = {} as unknown as TimeEntry; timeEntryA.duration = 60; - const timeEntryB = new TimeEntry(); + const timeEntryB = {} as unknown as TimeEntry; timeEntryB.duration = 23; /* eslint-enable functional/immutable-data */ const timeEntries = [timeEntryA, timeEntryB]; @@ -65,8 +64,8 @@ describe('projects/timeEntries', () => { it('should sub in 0 when a Time Entry is missing a duration', () => { /* eslint-disable functional/immutable-data */ - const timeEntryA = new TimeEntry(); - const timeEntryB = new TimeEntry(); + const timeEntryA = {} as unknown as TimeEntry; + const timeEntryB = {} as unknown as TimeEntry; timeEntryB.duration = 23; /* eslint-enable functional/immutable-data */ const timeEntries = [timeEntryA, timeEntryB]; diff --git a/src/projects/generators.ts b/src/projects/generators.ts index 802011d..bd54d0a 100644 --- a/src/projects/generators.ts +++ b/src/projects/generators.ts @@ -1,10 +1,9 @@ -import { Amount } from 'xero-node/dist/gen/model/projects/amount'; -import { CurrencyCode } from 'xero-node/dist/gen/model/projects/currencyCode'; +import type { Amount, CurrencyCode } from './shimTypes'; export const generateProjectAmountUSD = (value: number): Amount => { - const amount = new Amount(); + const amount = {} as Amount; /* eslint-disable functional/immutable-data */ - amount.currency = CurrencyCode.USD; + amount.currency = 'USD' as unknown as CurrencyCode; amount.value = value; /* eslint-enable functional/immutable-data */ return amount; diff --git a/src/projects/shimTypes.ts b/src/projects/shimTypes.ts new file mode 100644 index 0000000..fc209a9 --- /dev/null +++ b/src/projects/shimTypes.ts @@ -0,0 +1,247 @@ +/* eslint-disable @typescript-eslint/explicit-member-accessibility */ +export declare class TimeEntry { + /** + * Identifier of the time entry. + */ + 'timeEntryId'?: string; + /** + * The xero user identifier of the person who logged time. + */ + 'userId'?: string; + /** + * Identifier of the project, that the task (which the time entry is logged against) belongs to. + */ + 'projectId'?: string; + /** + * Identifier of the task that time entry is logged against. + */ + 'taskId'?: string; + /** + * The date time that time entry is logged on. UTC Date Time in ISO-8601 format. + */ + 'dateUtc'?: Date; + /** + * The date time that time entry is created. UTC Date Time in ISO-8601 format. By default it is set to server time. + */ + 'dateEnteredUtc'?: Date; + /** + * The duration of logged minutes. + */ + 'duration'?: number; + /** + * A description of the time entry. + */ + 'description'?: string; + /** + * Status of the time entry. By default a time entry is created with status of `ACTIVE`. A `LOCKED` state indicates that the time entry is currently changing state (for example being invoiced). Updates are not allowed when in this state. It will have a status of INVOICED once it is invoiced. + */ + 'status'?: TimeEntry.StatusEnum; + static discriminator: string | undefined; + static attributeTypeMap: Array<{ + baseName: string; + name: string; + type: string; + }>; + + static getAttributeTypeMap(): { + baseName: string; + name: string; + type: string; + }[]; +} +/* eslint-disable @typescript-eslint/no-namespace, no-redeclare */ +export declare namespace TimeEntry { + enum StatusEnum { + ACTIVE, + LOCKED, + INVOICED, + } +} +/* eslint-enable @typescript-eslint/no-namespace, no-redeclare */ +/** + * 3 letter alpha code for the ISO-4217 currency code, e.g. USD, AUD. + */ +export enum CurrencyCode { + AED, + AFN, + ALL, + AMD, + ANG, + AOA, + ARS, + AUD, + AWG, + AZN, + BAM, + BBD, + BDT, + BGN, + BHD, + BIF, + BMD, + BND, + BOB, + BRL, + BSD, + BTN, + BWP, + BYN, + BZD, + CAD, + CDF, + CHF, + CLP, + CNY, + COP, + CRC, + CUC, + CUP, + CVE, + CZK, + DJF, + DKK, + DOP, + DZD, + EGP, + ERN, + ETB, + EUR, + FJD, + FKP, + GBP, + GEL, + GGP, + GHS, + GIP, + GMD, + GNF, + GTQ, + GYD, + HKD, + HNL, + HRK, + HTG, + HUF, + IDR, + ILS, + IMP, + INR, + IQD, + IRR, + ISK, + JEP, + JMD, + JOD, + JPY, + KES, + KGS, + KHR, + KMF, + KPW, + KRW, + KWD, + KYD, + KZT, + LAK, + LBP, + LKR, + LRD, + LSL, + LYD, + MAD, + MDL, + MGA, + MKD, + MMK, + MNT, + MOP, + MRU, + MUR, + MVR, + MWK, + MXN, + MYR, + MZN, + NAD, + NGN, + NIO, + NOK, + NPR, + NZD, + OMR, + PAB, + PEN, + PGK, + PHP, + PKR, + PLN, + PYG, + QAR, + RON, + RSD, + RUB, + RWF, + SAR, + SBD, + SCR, + SDG, + SEK, + SGD, + SHP, + SLL, + SOS, + SPL, + SRD, + STN, + SVC, + SYP, + SZL, + THB, + TJS, + TMT, + TND, + TOP, + TRY, + TTD, + TVD, + TWD, + TZS, + UAH, + UGX, + USD, + UYU, + UZS, + VEF, + VND, + VUV, + WST, + XAF, + XCD, + XDR, + XOF, + XPF, + YER, + ZAR, + ZMW, + ZMK, + ZWD, + Empty, +} + +export declare class Amount { + 'currency'?: CurrencyCode; + 'value'?: number; + static discriminator: string | undefined; + static attributeTypeMap: Array<{ + baseName: string; + name: string; + type: string; + }>; + + static getAttributeTypeMap(): { + baseName: string; + name: string; + type: string; + }[]; +} +/* eslint-enable @typescript-eslint/explicit-member-accessibility */ diff --git a/src/projects/timeEntries.ts b/src/projects/timeEntries.ts index fe14460..26e8239 100644 --- a/src/projects/timeEntries.ts +++ b/src/projects/timeEntries.ts @@ -1,5 +1,6 @@ import { roundToNearestFraction } from 'deep-cuts'; -import type { TimeEntry } from 'xero-node/dist/gen/model/projects/timeEntry'; + +import type { TimeEntry } from './shimTypes'; export const hoursFromTimeEntries = ( timeEntries: TimeEntry[],