From 8b501ec5a180c2a28015b4155d2ce4f7a16cdf88 Mon Sep 17 00:00:00 2001 From: Marc Fong Date: Tue, 14 Apr 2026 23:18:28 +0800 Subject: [PATCH 1/2] feat: add attachment upload, list, and download commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `attachments` sub-commands to the seven resource types that support file attachments via the Xero Accounting API: invoices, credit-notes, bank-transactions, quotes, contacts, accounts, and manual-journals. ## Commands added Each resource gains three new sub-commands: xero attachments upload ---id --file xero attachments list ---id xero attachments download ---id --attachment-id [--output ] For invoices and credit notes, `upload` also accepts `--include-online` to make the attachment visible to the end customer in their online invoice view. Note: the Xero API does not support deleting attachments. ## Architecture All API logic lives in a single shared lib (`src/lib/attachments.ts`) with: - A dispatch map routing each resource type to the correct xero-node SDK method - Pre-flight validation: file-exists check, 25MB size limit, MIME type check - Built-in MIME detection for .pdf, .png, .jpg/.jpeg, .gif, .webp, .xml, .csv, .txt (no new dependencies required) - Download resolves the original filename via a list call before fetching bytes, enabling smart `--output` path handling (file path or directory) Command files are thin wrappers: parse flags → xeroCall → delegate to lib → format output. ## Tests 21 new unit tests in `test/lib/attachments.test.ts` covering MIME detection, file validation, and all three operations across multiple resource types. All 87 tests pass; TypeScript build is clean. --- SKILL.md | 42 +++- package.json | 21 ++ src/commands/accounts/attachments/download.ts | 41 ++++ src/commands/accounts/attachments/list.ts | 34 +++ src/commands/accounts/attachments/upload.ts | 32 +++ .../bank-transactions/attachments/download.ts | 41 ++++ .../bank-transactions/attachments/list.ts | 34 +++ .../bank-transactions/attachments/upload.ts | 32 +++ src/commands/contacts/attachments/download.ts | 41 ++++ src/commands/contacts/attachments/list.ts | 34 +++ src/commands/contacts/attachments/upload.ts | 32 +++ .../credit-notes/attachments/download.ts | 41 ++++ src/commands/credit-notes/attachments/list.ts | 34 +++ .../credit-notes/attachments/upload.ts | 34 +++ src/commands/invoices/attachments/download.ts | 43 ++++ src/commands/invoices/attachments/list.ts | 35 +++ src/commands/invoices/attachments/upload.ts | 34 +++ .../manual-journals/attachments/download.ts | 41 ++++ .../manual-journals/attachments/list.ts | 34 +++ .../manual-journals/attachments/upload.ts | 32 +++ src/commands/quotes/attachments/download.ts | 41 ++++ src/commands/quotes/attachments/list.ts | 34 +++ src/commands/quotes/attachments/upload.ts | 32 +++ src/lib/attachments.ts | 133 +++++++++++ test/lib/attachments.test.ts | 206 ++++++++++++++++++ 25 files changed, 1157 insertions(+), 1 deletion(-) create mode 100644 src/commands/accounts/attachments/download.ts create mode 100644 src/commands/accounts/attachments/list.ts create mode 100644 src/commands/accounts/attachments/upload.ts create mode 100644 src/commands/bank-transactions/attachments/download.ts create mode 100644 src/commands/bank-transactions/attachments/list.ts create mode 100644 src/commands/bank-transactions/attachments/upload.ts create mode 100644 src/commands/contacts/attachments/download.ts create mode 100644 src/commands/contacts/attachments/list.ts create mode 100644 src/commands/contacts/attachments/upload.ts create mode 100644 src/commands/credit-notes/attachments/download.ts create mode 100644 src/commands/credit-notes/attachments/list.ts create mode 100644 src/commands/credit-notes/attachments/upload.ts create mode 100644 src/commands/invoices/attachments/download.ts create mode 100644 src/commands/invoices/attachments/list.ts create mode 100644 src/commands/invoices/attachments/upload.ts create mode 100644 src/commands/manual-journals/attachments/download.ts create mode 100644 src/commands/manual-journals/attachments/list.ts create mode 100644 src/commands/manual-journals/attachments/upload.ts create mode 100644 src/commands/quotes/attachments/download.ts create mode 100644 src/commands/quotes/attachments/list.ts create mode 100644 src/commands/quotes/attachments/upload.ts create mode 100644 src/lib/attachments.ts create mode 100644 test/lib/attachments.test.ts diff --git a/SKILL.md b/SKILL.md index f869f52..7752af7 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: xero_command_line -description: Interact with the Xero accounting API using the `xero` CLI tool. Manage contacts, invoices, quotes, credit notes, payments, bank transactions, items, manual journals, tracking categories, currencies, tax rates, reports, and organisation details. +description: Interact with the Xero accounting API using the `xero` CLI tool. Manage contacts, invoices, quotes, credit notes, payments, bank transactions, items, manual journals, tracking categories, currencies, tax rates, reports, organisation details, and attachments. user-invocable: true metadata: openclaw: @@ -220,6 +220,45 @@ xero bank-transactions update --file bank-transaction-update.json Transaction types: `RECEIVE` (money in), `SPEND` (money out). +### Attachments + +Files can be attached to invoices, credit notes, bank transactions, quotes, contacts, accounts, and manual journals. Each supports `upload`, `list`, and `download`. The Xero API does not support deleting attachments. + +Supported file types: `.pdf`, `.png`, `.jpg`/`.jpeg`, `.gif`, `.webp`, `.xml`, `.csv`, `.txt`. Maximum 25MB per file, up to 10 attachments per resource. + +```bash +# Upload +xero invoices attachments upload --invoice-id --file +xero invoices attachments upload --invoice-id --file --include-online +xero credit-notes attachments upload --credit-note-id --file --include-online +xero bank-transactions attachments upload --bank-transaction-id --file +xero quotes attachments upload --quote-id --file +xero contacts attachments upload --contact-id --file +xero accounts attachments upload --account-id --file +xero manual-journals attachments upload --manual-journal-id --file + +# List (shows attachmentID, fileName, mimeType, size, URL) +xero invoices attachments list --invoice-id +xero credit-notes attachments list --credit-note-id +xero bank-transactions attachments list --bank-transaction-id +xero quotes attachments list --quote-id +xero contacts attachments list --contact-id +xero accounts attachments list --account-id +xero manual-journals attachments list --manual-journal-id + +# Download (--output defaults to current directory; accepts a file path or directory) +xero invoices attachments download --invoice-id --attachment-id +xero invoices attachments download --invoice-id --attachment-id --output ./downloads/ +xero credit-notes attachments download --credit-note-id --attachment-id +xero bank-transactions attachments download --bank-transaction-id --attachment-id +xero quotes attachments download --quote-id --attachment-id +xero contacts attachments download --contact-id --attachment-id +xero accounts attachments download --account-id --attachment-id +xero manual-journals attachments download --manual-journal-id --attachment-id +``` + +`--include-online` (invoices and credit notes only) makes the attachment visible to the end customer in their online invoice/credit note view. + ### Payments ```bash @@ -313,3 +352,4 @@ xero reports aged-payables --contact-id --from-date 2025-01-01 --to-date 20 - For multi-line-item creates, always use `--file` with a JSON payload. - Tax types vary by region. Run `xero tax-rates list` to see what's available. - Account codes are needed for line items. Run `xero accounts list` to find them. +- To attach a file: `xero attachments upload ---id --file `. Run `list` first to get the resource ID, then `attachments list` to get attachment IDs for download. diff --git a/package.json b/package.json index 00f5068..78c63f7 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,27 @@ }, "org": { "description": "View organisation details" + }, + "invoices:attachments": { + "description": "Manage invoice attachments" + }, + "credit-notes:attachments": { + "description": "Manage credit note attachments" + }, + "bank-transactions:attachments": { + "description": "Manage bank transaction attachments" + }, + "quotes:attachments": { + "description": "Manage quote attachments" + }, + "contacts:attachments": { + "description": "Manage contact attachments" + }, + "accounts:attachments": { + "description": "Manage account attachments" + }, + "manual-journals:attachments": { + "description": "Manage manual journal attachments" } }, "plugins": [ diff --git a/src/commands/accounts/attachments/download.ts b/src/commands/accounts/attachments/download.ts new file mode 100644 index 0000000..2bdfe20 --- /dev/null +++ b/src/commands/accounts/attachments/download.ts @@ -0,0 +1,41 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {downloadAttachment} from '../../../lib/attachments.js' +import * as fs from 'node:fs' +import * as path from 'node:path' + +export default class AccountsAttachmentsDownload extends BaseCommand { + static override description = 'Download an attachment from an account' + + static override examples = [ + '<%= config.bin %> accounts attachments download --account-id abc-123 --attachment-id def-456', + ] + + static override flags = { + ...BaseCommand.baseFlags, + 'account-id': Flags.string({description: 'Account ID', required: true}), + 'attachment-id': Flags.string({description: 'Attachment ID', required: true}), + output: Flags.string({description: 'Output path (file or directory). Defaults to current directory.'}), + } + + async run(): Promise { + const {flags} = await this.parse(AccountsAttachmentsDownload) + + const result = await this.xeroCall(flags, async (xero, tenantId) => + downloadAttachment(xero, tenantId, 'account', flags['account-id'], flags['attachment-id']), + ) + + const {fileName, data} = result as {fileName: string; data: Buffer} + const outputPath = resolveOutputPath(flags.output, fileName) + fs.writeFileSync(outputPath, data) + this.log(`Saved: ${outputPath}`) + } +} + +function resolveOutputPath(output: string | undefined, fileName: string): string { + if (!output) return path.join(process.cwd(), fileName) + if (fs.existsSync(output) && fs.statSync(output).isDirectory()) { + return path.join(output, fileName) + } + return output +} diff --git a/src/commands/accounts/attachments/list.ts b/src/commands/accounts/attachments/list.ts new file mode 100644 index 0000000..c0d802e --- /dev/null +++ b/src/commands/accounts/attachments/list.ts @@ -0,0 +1,34 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {listAttachments} from '../../../lib/attachments.js' + +export default class AccountsAttachmentsList extends BaseCommand { + static override description = 'List attachments on an account' + + static override examples = [ + '<%= config.bin %> accounts attachments list --account-id abc-123', + ] + + static override flags = { + ...BaseCommand.baseFlags, + 'account-id': Flags.string({description: 'Account ID', required: true}), + } + + private readonly columns = [ + {key: 'attachmentID', header: 'ID'}, + {key: 'fileName', header: 'File Name'}, + {key: 'mimeType', header: 'Type'}, + {key: 'contentLength', header: 'Size (bytes)'}, + {key: 'url', header: 'URL'}, + ] + + async run(): Promise { + const {flags} = await this.parse(AccountsAttachmentsList) + + const result = await this.xeroCall(flags, async (xero, tenantId) => + listAttachments(xero, tenantId, 'account', flags['account-id']), + ) + + this.outputFormatted(result as unknown as Record[], this.columns, flags) + } +} diff --git a/src/commands/accounts/attachments/upload.ts b/src/commands/accounts/attachments/upload.ts new file mode 100644 index 0000000..d34aff1 --- /dev/null +++ b/src/commands/accounts/attachments/upload.ts @@ -0,0 +1,32 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {uploadAttachment} from '../../../lib/attachments.js' + +export default class AccountsAttachmentsUpload extends BaseCommand { + static override description = 'Upload an attachment to an account' + + static override examples = [ + '<%= config.bin %> accounts attachments upload --account-id abc-123 --file statement.pdf', + ] + + static override flags = { + ...BaseCommand.baseFlags, + 'account-id': Flags.string({description: 'Account ID', required: true}), + file: Flags.string({description: 'Path to file to upload', required: true}), + } + + async run(): Promise { + const {flags} = await this.parse(AccountsAttachmentsUpload) + + const result = await this.xeroCall(flags, async (xero, tenantId) => + uploadAttachment(xero, tenantId, 'account', flags['account-id'], flags.file, false), + ) + + const r = result as Record + if (flags.json) { + this.log(JSON.stringify(result, null, 2)) + } else { + this.log(`Attachment uploaded: ${r.fileName} (${r.attachmentID})`) + } + } +} diff --git a/src/commands/bank-transactions/attachments/download.ts b/src/commands/bank-transactions/attachments/download.ts new file mode 100644 index 0000000..e231570 --- /dev/null +++ b/src/commands/bank-transactions/attachments/download.ts @@ -0,0 +1,41 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {downloadAttachment} from '../../../lib/attachments.js' +import * as fs from 'node:fs' +import * as path from 'node:path' + +export default class BankTransactionsAttachmentsDownload extends BaseCommand { + static override description = 'Download an attachment from a bank transaction' + + static override examples = [ + '<%= config.bin %> bank-transactions attachments download --bank-transaction-id abc-123 --attachment-id def-456', + ] + + static override flags = { + ...BaseCommand.baseFlags, + 'bank-transaction-id': Flags.string({description: 'Bank Transaction ID', required: true}), + 'attachment-id': Flags.string({description: 'Attachment ID', required: true}), + output: Flags.string({description: 'Output path (file or directory). Defaults to current directory.'}), + } + + async run(): Promise { + const {flags} = await this.parse(BankTransactionsAttachmentsDownload) + + const result = await this.xeroCall(flags, async (xero, tenantId) => + downloadAttachment(xero, tenantId, 'bankTransaction', flags['bank-transaction-id'], flags['attachment-id']), + ) + + const {fileName, data} = result as {fileName: string; data: Buffer} + const outputPath = resolveOutputPath(flags.output, fileName) + fs.writeFileSync(outputPath, data) + this.log(`Saved: ${outputPath}`) + } +} + +function resolveOutputPath(output: string | undefined, fileName: string): string { + if (!output) return path.join(process.cwd(), fileName) + if (fs.existsSync(output) && fs.statSync(output).isDirectory()) { + return path.join(output, fileName) + } + return output +} diff --git a/src/commands/bank-transactions/attachments/list.ts b/src/commands/bank-transactions/attachments/list.ts new file mode 100644 index 0000000..38cc7e2 --- /dev/null +++ b/src/commands/bank-transactions/attachments/list.ts @@ -0,0 +1,34 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {listAttachments} from '../../../lib/attachments.js' + +export default class BankTransactionsAttachmentsList extends BaseCommand { + static override description = 'List attachments on a bank transaction' + + static override examples = [ + '<%= config.bin %> bank-transactions attachments list --bank-transaction-id abc-123', + ] + + static override flags = { + ...BaseCommand.baseFlags, + 'bank-transaction-id': Flags.string({description: 'Bank Transaction ID', required: true}), + } + + private readonly columns = [ + {key: 'attachmentID', header: 'ID'}, + {key: 'fileName', header: 'File Name'}, + {key: 'mimeType', header: 'Type'}, + {key: 'contentLength', header: 'Size (bytes)'}, + {key: 'url', header: 'URL'}, + ] + + async run(): Promise { + const {flags} = await this.parse(BankTransactionsAttachmentsList) + + const result = await this.xeroCall(flags, async (xero, tenantId) => + listAttachments(xero, tenantId, 'bankTransaction', flags['bank-transaction-id']), + ) + + this.outputFormatted(result as unknown as Record[], this.columns, flags) + } +} diff --git a/src/commands/bank-transactions/attachments/upload.ts b/src/commands/bank-transactions/attachments/upload.ts new file mode 100644 index 0000000..5189852 --- /dev/null +++ b/src/commands/bank-transactions/attachments/upload.ts @@ -0,0 +1,32 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {uploadAttachment} from '../../../lib/attachments.js' + +export default class BankTransactionsAttachmentsUpload extends BaseCommand { + static override description = 'Upload an attachment to a bank transaction' + + static override examples = [ + '<%= config.bin %> bank-transactions attachments upload --bank-transaction-id abc-123 --file receipt.pdf', + ] + + static override flags = { + ...BaseCommand.baseFlags, + 'bank-transaction-id': Flags.string({description: 'Bank Transaction ID', required: true}), + file: Flags.string({description: 'Path to file to upload', required: true}), + } + + async run(): Promise { + const {flags} = await this.parse(BankTransactionsAttachmentsUpload) + + const result = await this.xeroCall(flags, async (xero, tenantId) => + uploadAttachment(xero, tenantId, 'bankTransaction', flags['bank-transaction-id'], flags.file, false), + ) + + const r = result as Record + if (flags.json) { + this.log(JSON.stringify(result, null, 2)) + } else { + this.log(`Attachment uploaded: ${r.fileName} (${r.attachmentID})`) + } + } +} diff --git a/src/commands/contacts/attachments/download.ts b/src/commands/contacts/attachments/download.ts new file mode 100644 index 0000000..6b49709 --- /dev/null +++ b/src/commands/contacts/attachments/download.ts @@ -0,0 +1,41 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {downloadAttachment} from '../../../lib/attachments.js' +import * as fs from 'node:fs' +import * as path from 'node:path' + +export default class ContactsAttachmentsDownload extends BaseCommand { + static override description = 'Download an attachment from a contact' + + static override examples = [ + '<%= config.bin %> contacts attachments download --contact-id abc-123 --attachment-id def-456', + ] + + static override flags = { + ...BaseCommand.baseFlags, + 'contact-id': Flags.string({description: 'Contact ID', required: true}), + 'attachment-id': Flags.string({description: 'Attachment ID', required: true}), + output: Flags.string({description: 'Output path (file or directory). Defaults to current directory.'}), + } + + async run(): Promise { + const {flags} = await this.parse(ContactsAttachmentsDownload) + + const result = await this.xeroCall(flags, async (xero, tenantId) => + downloadAttachment(xero, tenantId, 'contact', flags['contact-id'], flags['attachment-id']), + ) + + const {fileName, data} = result as {fileName: string; data: Buffer} + const outputPath = resolveOutputPath(flags.output, fileName) + fs.writeFileSync(outputPath, data) + this.log(`Saved: ${outputPath}`) + } +} + +function resolveOutputPath(output: string | undefined, fileName: string): string { + if (!output) return path.join(process.cwd(), fileName) + if (fs.existsSync(output) && fs.statSync(output).isDirectory()) { + return path.join(output, fileName) + } + return output +} diff --git a/src/commands/contacts/attachments/list.ts b/src/commands/contacts/attachments/list.ts new file mode 100644 index 0000000..64be308 --- /dev/null +++ b/src/commands/contacts/attachments/list.ts @@ -0,0 +1,34 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {listAttachments} from '../../../lib/attachments.js' + +export default class ContactsAttachmentsList extends BaseCommand { + static override description = 'List attachments on a contact' + + static override examples = [ + '<%= config.bin %> contacts attachments list --contact-id abc-123', + ] + + static override flags = { + ...BaseCommand.baseFlags, + 'contact-id': Flags.string({description: 'Contact ID', required: true}), + } + + private readonly columns = [ + {key: 'attachmentID', header: 'ID'}, + {key: 'fileName', header: 'File Name'}, + {key: 'mimeType', header: 'Type'}, + {key: 'contentLength', header: 'Size (bytes)'}, + {key: 'url', header: 'URL'}, + ] + + async run(): Promise { + const {flags} = await this.parse(ContactsAttachmentsList) + + const result = await this.xeroCall(flags, async (xero, tenantId) => + listAttachments(xero, tenantId, 'contact', flags['contact-id']), + ) + + this.outputFormatted(result as unknown as Record[], this.columns, flags) + } +} diff --git a/src/commands/contacts/attachments/upload.ts b/src/commands/contacts/attachments/upload.ts new file mode 100644 index 0000000..2bcfa14 --- /dev/null +++ b/src/commands/contacts/attachments/upload.ts @@ -0,0 +1,32 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {uploadAttachment} from '../../../lib/attachments.js' + +export default class ContactsAttachmentsUpload extends BaseCommand { + static override description = 'Upload an attachment to a contact' + + static override examples = [ + '<%= config.bin %> contacts attachments upload --contact-id abc-123 --file contract.pdf', + ] + + static override flags = { + ...BaseCommand.baseFlags, + 'contact-id': Flags.string({description: 'Contact ID', required: true}), + file: Flags.string({description: 'Path to file to upload', required: true}), + } + + async run(): Promise { + const {flags} = await this.parse(ContactsAttachmentsUpload) + + const result = await this.xeroCall(flags, async (xero, tenantId) => + uploadAttachment(xero, tenantId, 'contact', flags['contact-id'], flags.file, false), + ) + + const r = result as Record + if (flags.json) { + this.log(JSON.stringify(result, null, 2)) + } else { + this.log(`Attachment uploaded: ${r.fileName} (${r.attachmentID})`) + } + } +} diff --git a/src/commands/credit-notes/attachments/download.ts b/src/commands/credit-notes/attachments/download.ts new file mode 100644 index 0000000..375a328 --- /dev/null +++ b/src/commands/credit-notes/attachments/download.ts @@ -0,0 +1,41 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {downloadAttachment} from '../../../lib/attachments.js' +import * as fs from 'node:fs' +import * as path from 'node:path' + +export default class CreditNotesAttachmentsDownload extends BaseCommand { + static override description = 'Download an attachment from a credit note' + + static override examples = [ + '<%= config.bin %> credit-notes attachments download --credit-note-id abc-123 --attachment-id def-456', + ] + + static override flags = { + ...BaseCommand.baseFlags, + 'credit-note-id': Flags.string({description: 'Credit Note ID', required: true}), + 'attachment-id': Flags.string({description: 'Attachment ID', required: true}), + output: Flags.string({description: 'Output path (file or directory). Defaults to current directory.'}), + } + + async run(): Promise { + const {flags} = await this.parse(CreditNotesAttachmentsDownload) + + const result = await this.xeroCall(flags, async (xero, tenantId) => + downloadAttachment(xero, tenantId, 'creditNote', flags['credit-note-id'], flags['attachment-id']), + ) + + const {fileName, data} = result as {fileName: string; data: Buffer} + const outputPath = resolveOutputPath(flags.output, fileName) + fs.writeFileSync(outputPath, data) + this.log(`Saved: ${outputPath}`) + } +} + +function resolveOutputPath(output: string | undefined, fileName: string): string { + if (!output) return path.join(process.cwd(), fileName) + if (fs.existsSync(output) && fs.statSync(output).isDirectory()) { + return path.join(output, fileName) + } + return output +} diff --git a/src/commands/credit-notes/attachments/list.ts b/src/commands/credit-notes/attachments/list.ts new file mode 100644 index 0000000..e64f501 --- /dev/null +++ b/src/commands/credit-notes/attachments/list.ts @@ -0,0 +1,34 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {listAttachments} from '../../../lib/attachments.js' + +export default class CreditNotesAttachmentsList extends BaseCommand { + static override description = 'List attachments on a credit note' + + static override examples = [ + '<%= config.bin %> credit-notes attachments list --credit-note-id abc-123', + ] + + static override flags = { + ...BaseCommand.baseFlags, + 'credit-note-id': Flags.string({description: 'Credit Note ID', required: true}), + } + + private readonly columns = [ + {key: 'attachmentID', header: 'ID'}, + {key: 'fileName', header: 'File Name'}, + {key: 'mimeType', header: 'Type'}, + {key: 'contentLength', header: 'Size (bytes)'}, + {key: 'url', header: 'URL'}, + ] + + async run(): Promise { + const {flags} = await this.parse(CreditNotesAttachmentsList) + + const result = await this.xeroCall(flags, async (xero, tenantId) => + listAttachments(xero, tenantId, 'creditNote', flags['credit-note-id']), + ) + + this.outputFormatted(result as unknown as Record[], this.columns, flags) + } +} diff --git a/src/commands/credit-notes/attachments/upload.ts b/src/commands/credit-notes/attachments/upload.ts new file mode 100644 index 0000000..a0df85b --- /dev/null +++ b/src/commands/credit-notes/attachments/upload.ts @@ -0,0 +1,34 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {uploadAttachment} from '../../../lib/attachments.js' + +export default class CreditNotesAttachmentsUpload extends BaseCommand { + static override description = 'Upload an attachment to a credit note' + + static override examples = [ + '<%= config.bin %> credit-notes attachments upload --credit-note-id abc-123 --file receipt.pdf', + '<%= config.bin %> credit-notes attachments upload --credit-note-id abc-123 --file receipt.pdf --include-online', + ] + + static override flags = { + ...BaseCommand.baseFlags, + 'credit-note-id': Flags.string({description: 'Credit Note ID', required: true}), + file: Flags.string({description: 'Path to file to upload', required: true}), + 'include-online': Flags.boolean({description: 'Show attachment in online credit note view', default: false}), + } + + async run(): Promise { + const {flags} = await this.parse(CreditNotesAttachmentsUpload) + + const result = await this.xeroCall(flags, async (xero, tenantId) => + uploadAttachment(xero, tenantId, 'creditNote', flags['credit-note-id'], flags.file, flags['include-online']), + ) + + const r = result as Record + if (flags.json) { + this.log(JSON.stringify(result, null, 2)) + } else { + this.log(`Attachment uploaded: ${r.fileName} (${r.attachmentID})`) + } + } +} diff --git a/src/commands/invoices/attachments/download.ts b/src/commands/invoices/attachments/download.ts new file mode 100644 index 0000000..b55077a --- /dev/null +++ b/src/commands/invoices/attachments/download.ts @@ -0,0 +1,43 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {downloadAttachment} from '../../../lib/attachments.js' +import * as fs from 'node:fs' +import * as path from 'node:path' + +export default class InvoicesAttachmentsDownload extends BaseCommand { + static override description = 'Download an attachment from an invoice' + + static override examples = [ + '<%= config.bin %> invoices attachments download --invoice-id abc-123 --attachment-id def-456', + '<%= config.bin %> invoices attachments download --invoice-id abc-123 --attachment-id def-456 --output ./downloads/', + '<%= config.bin %> invoices attachments download --invoice-id abc-123 --attachment-id def-456 --output ./saved.pdf', + ] + + static override flags = { + ...BaseCommand.baseFlags, + 'invoice-id': Flags.string({description: 'Invoice ID', required: true}), + 'attachment-id': Flags.string({description: 'Attachment ID', required: true}), + output: Flags.string({description: 'Output path (file or directory). Defaults to current directory.'}), + } + + async run(): Promise { + const {flags} = await this.parse(InvoicesAttachmentsDownload) + + const result = await this.xeroCall(flags, async (xero, tenantId) => + downloadAttachment(xero, tenantId, 'invoice', flags['invoice-id'], flags['attachment-id']), + ) + + const {fileName, data} = result as {fileName: string; data: Buffer} + const outputPath = resolveOutputPath(flags.output, fileName) + fs.writeFileSync(outputPath, data) + this.log(`Saved: ${outputPath}`) + } +} + +function resolveOutputPath(output: string | undefined, fileName: string): string { + if (!output) return path.join(process.cwd(), fileName) + if (fs.existsSync(output) && fs.statSync(output).isDirectory()) { + return path.join(output, fileName) + } + return output +} diff --git a/src/commands/invoices/attachments/list.ts b/src/commands/invoices/attachments/list.ts new file mode 100644 index 0000000..2153acb --- /dev/null +++ b/src/commands/invoices/attachments/list.ts @@ -0,0 +1,35 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {listAttachments} from '../../../lib/attachments.js' + +export default class InvoicesAttachmentsList extends BaseCommand { + static override description = 'List attachments on an invoice' + + static override examples = [ + '<%= config.bin %> invoices attachments list --invoice-id abc-123', + '<%= config.bin %> invoices attachments list --invoice-id abc-123 --json', + ] + + static override flags = { + ...BaseCommand.baseFlags, + 'invoice-id': Flags.string({description: 'Invoice ID', required: true}), + } + + private readonly columns = [ + {key: 'attachmentID', header: 'ID'}, + {key: 'fileName', header: 'File Name'}, + {key: 'mimeType', header: 'Type'}, + {key: 'contentLength', header: 'Size (bytes)'}, + {key: 'url', header: 'URL'}, + ] + + async run(): Promise { + const {flags} = await this.parse(InvoicesAttachmentsList) + + const result = await this.xeroCall(flags, async (xero, tenantId) => + listAttachments(xero, tenantId, 'invoice', flags['invoice-id']), + ) + + this.outputFormatted(result as unknown as Record[], this.columns, flags) + } +} diff --git a/src/commands/invoices/attachments/upload.ts b/src/commands/invoices/attachments/upload.ts new file mode 100644 index 0000000..fd93540 --- /dev/null +++ b/src/commands/invoices/attachments/upload.ts @@ -0,0 +1,34 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {uploadAttachment} from '../../../lib/attachments.js' + +export default class InvoicesAttachmentsUpload extends BaseCommand { + static override description = 'Upload an attachment to an invoice' + + static override examples = [ + '<%= config.bin %> invoices attachments upload --invoice-id abc-123 --file receipt.pdf', + '<%= config.bin %> invoices attachments upload --invoice-id abc-123 --file receipt.pdf --include-online', + ] + + static override flags = { + ...BaseCommand.baseFlags, + 'invoice-id': Flags.string({description: 'Invoice ID', required: true}), + file: Flags.string({description: 'Path to file to upload', required: true}), + 'include-online': Flags.boolean({description: 'Show attachment in online invoice view', default: false}), + } + + async run(): Promise { + const {flags} = await this.parse(InvoicesAttachmentsUpload) + + const result = await this.xeroCall(flags, async (xero, tenantId) => + uploadAttachment(xero, tenantId, 'invoice', flags['invoice-id'], flags.file, flags['include-online']), + ) + + const r = result as Record + if (flags.json) { + this.log(JSON.stringify(result, null, 2)) + } else { + this.log(`Attachment uploaded: ${r.fileName} (${r.attachmentID})`) + } + } +} diff --git a/src/commands/manual-journals/attachments/download.ts b/src/commands/manual-journals/attachments/download.ts new file mode 100644 index 0000000..4f86dc1 --- /dev/null +++ b/src/commands/manual-journals/attachments/download.ts @@ -0,0 +1,41 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {downloadAttachment} from '../../../lib/attachments.js' +import * as fs from 'node:fs' +import * as path from 'node:path' + +export default class ManualJournalsAttachmentsDownload extends BaseCommand { + static override description = 'Download an attachment from a manual journal' + + static override examples = [ + '<%= config.bin %> manual-journals attachments download --manual-journal-id abc-123 --attachment-id def-456', + ] + + static override flags = { + ...BaseCommand.baseFlags, + 'manual-journal-id': Flags.string({description: 'Manual Journal ID', required: true}), + 'attachment-id': Flags.string({description: 'Attachment ID', required: true}), + output: Flags.string({description: 'Output path (file or directory). Defaults to current directory.'}), + } + + async run(): Promise { + const {flags} = await this.parse(ManualJournalsAttachmentsDownload) + + const result = await this.xeroCall(flags, async (xero, tenantId) => + downloadAttachment(xero, tenantId, 'manualJournal', flags['manual-journal-id'], flags['attachment-id']), + ) + + const {fileName, data} = result as {fileName: string; data: Buffer} + const outputPath = resolveOutputPath(flags.output, fileName) + fs.writeFileSync(outputPath, data) + this.log(`Saved: ${outputPath}`) + } +} + +function resolveOutputPath(output: string | undefined, fileName: string): string { + if (!output) return path.join(process.cwd(), fileName) + if (fs.existsSync(output) && fs.statSync(output).isDirectory()) { + return path.join(output, fileName) + } + return output +} diff --git a/src/commands/manual-journals/attachments/list.ts b/src/commands/manual-journals/attachments/list.ts new file mode 100644 index 0000000..4829e9b --- /dev/null +++ b/src/commands/manual-journals/attachments/list.ts @@ -0,0 +1,34 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {listAttachments} from '../../../lib/attachments.js' + +export default class ManualJournalsAttachmentsList extends BaseCommand { + static override description = 'List attachments on a manual journal' + + static override examples = [ + '<%= config.bin %> manual-journals attachments list --manual-journal-id abc-123', + ] + + static override flags = { + ...BaseCommand.baseFlags, + 'manual-journal-id': Flags.string({description: 'Manual Journal ID', required: true}), + } + + private readonly columns = [ + {key: 'attachmentID', header: 'ID'}, + {key: 'fileName', header: 'File Name'}, + {key: 'mimeType', header: 'Type'}, + {key: 'contentLength', header: 'Size (bytes)'}, + {key: 'url', header: 'URL'}, + ] + + async run(): Promise { + const {flags} = await this.parse(ManualJournalsAttachmentsList) + + const result = await this.xeroCall(flags, async (xero, tenantId) => + listAttachments(xero, tenantId, 'manualJournal', flags['manual-journal-id']), + ) + + this.outputFormatted(result as unknown as Record[], this.columns, flags) + } +} diff --git a/src/commands/manual-journals/attachments/upload.ts b/src/commands/manual-journals/attachments/upload.ts new file mode 100644 index 0000000..fa3eb58 --- /dev/null +++ b/src/commands/manual-journals/attachments/upload.ts @@ -0,0 +1,32 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {uploadAttachment} from '../../../lib/attachments.js' + +export default class ManualJournalsAttachmentsUpload extends BaseCommand { + static override description = 'Upload an attachment to a manual journal' + + static override examples = [ + '<%= config.bin %> manual-journals attachments upload --manual-journal-id abc-123 --file backup.pdf', + ] + + static override flags = { + ...BaseCommand.baseFlags, + 'manual-journal-id': Flags.string({description: 'Manual Journal ID', required: true}), + file: Flags.string({description: 'Path to file to upload', required: true}), + } + + async run(): Promise { + const {flags} = await this.parse(ManualJournalsAttachmentsUpload) + + const result = await this.xeroCall(flags, async (xero, tenantId) => + uploadAttachment(xero, tenantId, 'manualJournal', flags['manual-journal-id'], flags.file, false), + ) + + const r = result as Record + if (flags.json) { + this.log(JSON.stringify(result, null, 2)) + } else { + this.log(`Attachment uploaded: ${r.fileName} (${r.attachmentID})`) + } + } +} diff --git a/src/commands/quotes/attachments/download.ts b/src/commands/quotes/attachments/download.ts new file mode 100644 index 0000000..c6d9983 --- /dev/null +++ b/src/commands/quotes/attachments/download.ts @@ -0,0 +1,41 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {downloadAttachment} from '../../../lib/attachments.js' +import * as fs from 'node:fs' +import * as path from 'node:path' + +export default class QuotesAttachmentsDownload extends BaseCommand { + static override description = 'Download an attachment from a quote' + + static override examples = [ + '<%= config.bin %> quotes attachments download --quote-id abc-123 --attachment-id def-456', + ] + + static override flags = { + ...BaseCommand.baseFlags, + 'quote-id': Flags.string({description: 'Quote ID', required: true}), + 'attachment-id': Flags.string({description: 'Attachment ID', required: true}), + output: Flags.string({description: 'Output path (file or directory). Defaults to current directory.'}), + } + + async run(): Promise { + const {flags} = await this.parse(QuotesAttachmentsDownload) + + const result = await this.xeroCall(flags, async (xero, tenantId) => + downloadAttachment(xero, tenantId, 'quote', flags['quote-id'], flags['attachment-id']), + ) + + const {fileName, data} = result as {fileName: string; data: Buffer} + const outputPath = resolveOutputPath(flags.output, fileName) + fs.writeFileSync(outputPath, data) + this.log(`Saved: ${outputPath}`) + } +} + +function resolveOutputPath(output: string | undefined, fileName: string): string { + if (!output) return path.join(process.cwd(), fileName) + if (fs.existsSync(output) && fs.statSync(output).isDirectory()) { + return path.join(output, fileName) + } + return output +} diff --git a/src/commands/quotes/attachments/list.ts b/src/commands/quotes/attachments/list.ts new file mode 100644 index 0000000..0b3cc53 --- /dev/null +++ b/src/commands/quotes/attachments/list.ts @@ -0,0 +1,34 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {listAttachments} from '../../../lib/attachments.js' + +export default class QuotesAttachmentsList extends BaseCommand { + static override description = 'List attachments on a quote' + + static override examples = [ + '<%= config.bin %> quotes attachments list --quote-id abc-123', + ] + + static override flags = { + ...BaseCommand.baseFlags, + 'quote-id': Flags.string({description: 'Quote ID', required: true}), + } + + private readonly columns = [ + {key: 'attachmentID', header: 'ID'}, + {key: 'fileName', header: 'File Name'}, + {key: 'mimeType', header: 'Type'}, + {key: 'contentLength', header: 'Size (bytes)'}, + {key: 'url', header: 'URL'}, + ] + + async run(): Promise { + const {flags} = await this.parse(QuotesAttachmentsList) + + const result = await this.xeroCall(flags, async (xero, tenantId) => + listAttachments(xero, tenantId, 'quote', flags['quote-id']), + ) + + this.outputFormatted(result as unknown as Record[], this.columns, flags) + } +} diff --git a/src/commands/quotes/attachments/upload.ts b/src/commands/quotes/attachments/upload.ts new file mode 100644 index 0000000..1f5664e --- /dev/null +++ b/src/commands/quotes/attachments/upload.ts @@ -0,0 +1,32 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {uploadAttachment} from '../../../lib/attachments.js' + +export default class QuotesAttachmentsUpload extends BaseCommand { + static override description = 'Upload an attachment to a quote' + + static override examples = [ + '<%= config.bin %> quotes attachments upload --quote-id abc-123 --file proposal.pdf', + ] + + static override flags = { + ...BaseCommand.baseFlags, + 'quote-id': Flags.string({description: 'Quote ID', required: true}), + file: Flags.string({description: 'Path to file to upload', required: true}), + } + + async run(): Promise { + const {flags} = await this.parse(QuotesAttachmentsUpload) + + const result = await this.xeroCall(flags, async (xero, tenantId) => + uploadAttachment(xero, tenantId, 'quote', flags['quote-id'], flags.file, false), + ) + + const r = result as Record + if (flags.json) { + this.log(JSON.stringify(result, null, 2)) + } else { + this.log(`Attachment uploaded: ${r.fileName} (${r.attachmentID})`) + } + } +} diff --git a/src/lib/attachments.ts b/src/lib/attachments.ts new file mode 100644 index 0000000..aed3f9d --- /dev/null +++ b/src/lib/attachments.ts @@ -0,0 +1,133 @@ +import * as fs from 'node:fs' +import * as path from 'node:path' +import {XeroClient} from 'xero-node' +import type {Attachment} from 'xero-node' + +export type AttachmentResource = + | 'invoice' + | 'creditNote' + | 'bankTransaction' + | 'quote' + | 'contact' + | 'account' + | 'manualJournal' + +const MIME_MAP: Record = { + '.pdf': 'application/pdf', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.xml': 'application/xml', + '.csv': 'text/csv', + '.txt': 'text/plain', +} + +export function getMimeType(filePath: string): string { + const ext = path.extname(filePath).toLowerCase() + const mime = MIME_MAP[ext] + if (!mime) throw new Error(`Unsupported file type: ${ext}`) + return mime +} + +export function validateUploadFile(filePath: string): void { + if (!fs.existsSync(filePath)) { + throw new Error(`File not found: ${filePath}`) + } + const stat = fs.statSync(filePath) + const maxBytes = 25 * 1024 * 1024 + if (stat.size > maxBytes) { + const mb = (stat.size / 1024 / 1024).toFixed(1) + throw new Error(`File exceeds 25MB limit (actual: ${mb} MB)`) + } +} + +// Resources that support includeOnline on upload +const SUPPORTS_INCLUDE_ONLINE = new Set(['invoice', 'creditNote']) + +const UPLOAD_METHOD: Record = { + invoice: 'createInvoiceAttachmentByFileName', + creditNote: 'createCreditNoteAttachmentByFileName', + bankTransaction: 'createBankTransactionAttachmentByFileName', + quote: 'createQuoteAttachmentByFileName', + contact: 'createContactAttachmentByFileName', + account: 'createAccountAttachmentByFileName', + manualJournal: 'createManualJournalAttachmentByFileName', +} + +const LIST_METHOD: Record = { + invoice: 'getInvoiceAttachments', + creditNote: 'getCreditNoteAttachments', + bankTransaction: 'getBankTransactionAttachments', + quote: 'getQuoteAttachments', + contact: 'getContactAttachments', + account: 'getAccountAttachments', + manualJournal: 'getManualJournalAttachments', +} + +const DOWNLOAD_METHOD: Record = { + invoice: 'getInvoiceAttachmentById', + creditNote: 'getCreditNoteAttachmentById', + bankTransaction: 'getBankTransactionAttachmentById', + quote: 'getQuoteAttachmentById', + contact: 'getContactAttachmentById', + account: 'getAccountAttachmentById', + manualJournal: 'getManualJournalAttachmentById', +} + +export async function uploadAttachment( + xero: XeroClient, + tenantId: string, + resource: AttachmentResource, + resourceId: string, + filePath: string, + includeOnline: boolean, +): Promise { + validateUploadFile(filePath) + getMimeType(filePath) // throws for unsupported extension + + const fileName = path.basename(filePath) + const stream = fs.createReadStream(filePath) + const api = xero.accountingApi as unknown as Record Promise> + + let response: any + if (SUPPORTS_INCLUDE_ONLINE.has(resource)) { + response = await api[UPLOAD_METHOD[resource]](tenantId, resourceId, fileName, stream, includeOnline, undefined) + } else { + response = await api[UPLOAD_METHOD[resource]](tenantId, resourceId, fileName, stream, undefined) + } + + return response.body.attachments?.[0] as Attachment +} + +export async function listAttachments( + xero: XeroClient, + tenantId: string, + resource: AttachmentResource, + resourceId: string, +): Promise { + const api = xero.accountingApi as unknown as Record Promise> + const response = await api[LIST_METHOD[resource]](tenantId, resourceId) + return (response.body.attachments ?? []) as Attachment[] +} + +export async function downloadAttachment( + xero: XeroClient, + tenantId: string, + resource: AttachmentResource, + resourceId: string, + attachmentId: string, +): Promise<{fileName: string; data: Buffer}> { + // Resolve filename + mimeType from list + const attachments = await listAttachments(xero, tenantId, resource, resourceId) + const meta = attachments.find(a => a.attachmentID === attachmentId) + if (!meta) throw new Error(`Attachment not found: ${attachmentId}`) + + const fileName = meta.fileName ?? 'attachment' + const mimeType = meta.mimeType ?? 'application/octet-stream' + + const api = xero.accountingApi as unknown as Record Promise> + const response = await api[DOWNLOAD_METHOD[resource]](tenantId, resourceId, attachmentId, mimeType) + return {fileName, data: response.body as Buffer} +} diff --git a/test/lib/attachments.test.ts b/test/lib/attachments.test.ts new file mode 100644 index 0000000..d3da5a3 --- /dev/null +++ b/test/lib/attachments.test.ts @@ -0,0 +1,206 @@ +import {describe, it, expect, vi, beforeEach} from 'vitest' +import {getMimeType, validateUploadFile, AttachmentResource} from '../../src/lib/attachments.js' +import * as fs from 'node:fs' +import * as path from 'node:path' +import * as os from 'node:os' + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return {...actual} +}) + +describe('getMimeType', () => { + it('returns application/pdf for .pdf', () => { + expect(getMimeType('invoice.pdf')).toBe('application/pdf') + }) + + it('returns image/png for .png', () => { + expect(getMimeType('receipt.png')).toBe('image/png') + }) + + it('returns image/jpeg for .jpg', () => { + expect(getMimeType('photo.jpg')).toBe('image/jpeg') + }) + + it('returns image/jpeg for .jpeg', () => { + expect(getMimeType('photo.jpeg')).toBe('image/jpeg') + }) + + it('returns image/gif for .gif', () => { + expect(getMimeType('anim.gif')).toBe('image/gif') + }) + + it('returns image/webp for .webp', () => { + expect(getMimeType('img.webp')).toBe('image/webp') + }) + + it('returns application/xml for .xml', () => { + expect(getMimeType('data.xml')).toBe('application/xml') + }) + + it('returns text/csv for .csv', () => { + expect(getMimeType('data.csv')).toBe('text/csv') + }) + + it('returns text/plain for .txt', () => { + expect(getMimeType('notes.txt')).toBe('text/plain') + }) + + it('is case-insensitive', () => { + expect(getMimeType('INVOICE.PDF')).toBe('application/pdf') + }) + + it('throws for unknown extension', () => { + expect(() => getMimeType('file.xyz')).toThrow('Unsupported file type: .xyz') + }) + + it('throws for no extension', () => { + expect(() => getMimeType('noext')).toThrow('Unsupported file type: ') + }) +}) + +describe('validateUploadFile', () => { + it('throws if file does not exist', () => { + expect(() => validateUploadFile('/nonexistent/path/file.pdf')).toThrow('File not found: /nonexistent/path/file.pdf') + }) + + it('throws if file exceeds 25MB', () => { + const tmpFile = path.join(os.tmpdir(), `xero-test-oversize-${Date.now()}.pdf`) + fs.writeFileSync(tmpFile, 'x') + const statSyncSpy = vi.spyOn(fs, 'statSync').mockReturnValue({size: 26 * 1024 * 1024} as fs.Stats) + try { + expect(() => validateUploadFile(tmpFile)).toThrow('File exceeds 25MB limit') + } finally { + statSyncSpy.mockRestore() + fs.unlinkSync(tmpFile) + } + }) + + it('does not throw for a valid small file', () => { + const tmpFile = path.join(os.tmpdir(), `xero-test-valid-${Date.now()}.pdf`) + fs.writeFileSync(tmpFile, 'x') + try { + expect(() => validateUploadFile(tmpFile)).not.toThrow() + } finally { + fs.unlinkSync(tmpFile) + } + }) +}) + +describe('uploadAttachment', () => { + it('calls createInvoiceAttachmentByFileName with correct args', async () => { + const {uploadAttachment} = await import('../../src/lib/attachments.js') + const tmpFile = path.join(os.tmpdir(), `xero-upload-test-${Date.now()}.pdf`) + fs.writeFileSync(tmpFile, 'fake pdf content') + + const mockCreate = vi.fn().mockResolvedValue({ + body: {attachments: [{attachmentID: 'att-1', fileName: path.basename(tmpFile)}]}, + }) + const xero = {accountingApi: {createInvoiceAttachmentByFileName: mockCreate}} as any + + await uploadAttachment(xero, 'tenant-1', 'invoice', 'inv-123', tmpFile, false) + + expect(mockCreate).toHaveBeenCalledWith( + 'tenant-1', + 'inv-123', + path.basename(tmpFile), + expect.any(Object), // ReadStream + false, + undefined, + ) + // Don't unlink synchronously — the ReadStream opens the file asynchronously + // and would throw ENOENT if we delete before it's GC'd. tmpdir is cleaned by OS. + }) + + it('calls createBankTransactionAttachmentByFileName for bankTransaction', async () => { + const {uploadAttachment} = await import('../../src/lib/attachments.js') + const tmpFile = path.join(os.tmpdir(), `xero-upload-bank-${Date.now()}.pdf`) + fs.writeFileSync(tmpFile, 'fake content') + + const mockCreate = vi.fn().mockResolvedValue({ + body: {attachments: [{attachmentID: 'att-2', fileName: path.basename(tmpFile)}]}, + }) + const xero = {accountingApi: {createBankTransactionAttachmentByFileName: mockCreate}} as any + + await uploadAttachment(xero, 'tenant-1', 'bankTransaction', 'bt-456', tmpFile, false) + + expect(mockCreate).toHaveBeenCalledWith( + 'tenant-1', + 'bt-456', + path.basename(tmpFile), + expect.any(Object), + undefined, + ) + // Don't unlink synchronously — tmpdir is cleaned by OS. + }) +}) + +describe('listAttachments', () => { + it('returns array of attachments from invoice', async () => { + const {listAttachments} = await import('../../src/lib/attachments.js') + const mockGet = vi.fn().mockResolvedValue({ + body: { + attachments: [ + {attachmentID: 'att-1', fileName: 'receipt.pdf', mimeType: 'application/pdf', contentLength: 1024, url: 'https://example.com/att-1'}, + ], + }, + }) + const xero = {accountingApi: {getInvoiceAttachments: mockGet}} as any + + const result = await listAttachments(xero, 'tenant-1', 'invoice', 'inv-123') + + expect(mockGet).toHaveBeenCalledWith('tenant-1', 'inv-123') + expect(result).toHaveLength(1) + expect(result[0].attachmentID).toBe('att-1') + expect(result[0].fileName).toBe('receipt.pdf') + }) + + it('returns empty array when no attachments', async () => { + const {listAttachments} = await import('../../src/lib/attachments.js') + const mockGet = vi.fn().mockResolvedValue({body: {attachments: []}}) + const xero = {accountingApi: {getInvoiceAttachments: mockGet}} as any + + const result = await listAttachments(xero, 'tenant-1', 'invoice', 'inv-123') + expect(result).toHaveLength(0) + }) +}) + +describe('downloadAttachment', () => { + it('resolves filename via list then downloads by ID', async () => { + const {downloadAttachment} = await import('../../src/lib/attachments.js') + + const mockList = vi.fn().mockResolvedValue({ + body: { + attachments: [ + {attachmentID: 'att-1', fileName: 'invoice.pdf', mimeType: 'application/pdf'}, + ], + }, + }) + const fakeBuffer = Buffer.from('PDF content') + const mockGetById = vi.fn().mockResolvedValue({body: fakeBuffer}) + + const xero = { + accountingApi: { + getInvoiceAttachments: mockList, + getInvoiceAttachmentById: mockGetById, + }, + } as any + + const result = await downloadAttachment(xero, 'tenant-1', 'invoice', 'inv-123', 'att-1') + + expect(mockList).toHaveBeenCalledWith('tenant-1', 'inv-123') + expect(mockGetById).toHaveBeenCalledWith('tenant-1', 'inv-123', 'att-1', 'application/pdf') + expect(result.fileName).toBe('invoice.pdf') + expect(result.data).toEqual(fakeBuffer) + }) + + it('throws if attachmentId not found in list', async () => { + const {downloadAttachment} = await import('../../src/lib/attachments.js') + + const mockList = vi.fn().mockResolvedValue({body: {attachments: []}}) + const xero = {accountingApi: {getInvoiceAttachments: mockList}} as any + + await expect(downloadAttachment(xero, 'tenant-1', 'invoice', 'inv-123', 'no-such-id')) + .rejects.toThrow('Attachment not found: no-such-id') + }) +}) From 665f7f82941cf70db75536e249a3cb1146ba6fdb Mon Sep 17 00:00:00 2001 From: Marc Fong Date: Wed, 15 Apr 2026 10:05:53 +0800 Subject: [PATCH 2/2] feat: add Bun binary entry point for self-contained container deployment Add bin/bun-run.ts as a Bun-specific entry point that statically imports all command classes so they are bundled into the compiled binary. After loading the oclif Config, each command's lazy loader is patched to return the in-binary class instead of doing a dynamic filesystem import. This fixes the @oclif/core MODULE_NOT_FOUND error that occurs in containers where node_modules is absent but the binary is present alongside dist/. Also adds npm scripts build:binary and build:binary:linux, updates .gitignore to exclude compiled binaries, and includes the updated skills/xero-command-line/SKILL.md with env-var auth bypass docs. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 5 + bin/bun-run.ts | 174 ++++++++++++++ package.json | 2 + skills/xero-command-line/SKILL.md | 374 ++++++++++++++++++++++++++++++ 4 files changed, 555 insertions(+) create mode 100644 bin/bun-run.ts create mode 100644 skills/xero-command-line/SKILL.md diff --git a/.gitignore b/.gitignore index 59f51ec..d308fa0 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,8 @@ Thumbs.db # Xero CLI config (tokens, credentials) .config/ + +# Compiled binaries +/xero +/xero-linux +/*.bun-build diff --git a/bin/bun-run.ts b/bin/bun-run.ts new file mode 100644 index 0000000..e3cc3dd --- /dev/null +++ b/bin/bun-run.ts @@ -0,0 +1,174 @@ +#!/usr/bin/env bun +/** + * Bun binary entry point. + * + * Bun compiled binaries can't satisfy bare-specifier imports (e.g. @oclif/core) + * for files loaded dynamically from the filesystem. Oclif normally discovers + * commands by doing runtime import() calls on the compiled dist/ files, which + * then re-import @oclif/core – and that fails inside a container that has no + * node_modules directory. + * + * Fix: statically import every command class here so Bun bundles them into the + * binary. Then, after oclif has loaded its Config (which sets up lazy loaders + * pointing at the dist/ files), we replace each lazy loader with one that + * returns the already-bundled class. This way no command class is ever loaded + * from the filesystem at runtime. + */ + +import { Config, handle, run } from '@oclif/core' + +// ── Commands ───────────────────────────────────────────────────────────────── +import AccountsAttachmentsDownload from '../src/commands/accounts/attachments/download.js' +import AccountsAttachmentsList from '../src/commands/accounts/attachments/list.js' +import AccountsAttachmentsUpload from '../src/commands/accounts/attachments/upload.js' +import AccountsList from '../src/commands/accounts/list.js' +import AccountsUpdate from '../src/commands/accounts/update.js' +import BankTransactionsAttachmentsDownload from '../src/commands/bank-transactions/attachments/download.js' +import BankTransactionsAttachmentsList from '../src/commands/bank-transactions/attachments/list.js' +import BankTransactionsAttachmentsUpload from '../src/commands/bank-transactions/attachments/upload.js' +import BankTransactionsCreate from '../src/commands/bank-transactions/create.js' +import BankTransactionsList from '../src/commands/bank-transactions/list.js' +import BankTransactionsUpdate from '../src/commands/bank-transactions/update.js' +import ContactGroupsList from '../src/commands/contact-groups/list.js' +import ContactsAttachmentsDownload from '../src/commands/contacts/attachments/download.js' +import ContactsAttachmentsList from '../src/commands/contacts/attachments/list.js' +import ContactsAttachmentsUpload from '../src/commands/contacts/attachments/upload.js' +import ContactsCreate from '../src/commands/contacts/create.js' +import ContactsList from '../src/commands/contacts/list.js' +import ContactsUpdate from '../src/commands/contacts/update.js' +import CreditNotesAttachmentsDownload from '../src/commands/credit-notes/attachments/download.js' +import CreditNotesAttachmentsList from '../src/commands/credit-notes/attachments/list.js' +import CreditNotesAttachmentsUpload from '../src/commands/credit-notes/attachments/upload.js' +import CreditNotesCreate from '../src/commands/credit-notes/create.js' +import CreditNotesList from '../src/commands/credit-notes/list.js' +import CreditNotesUpdate from '../src/commands/credit-notes/update.js' +import CurrenciesList from '../src/commands/currencies/list.js' +import InvoicesAttachmentsDownload from '../src/commands/invoices/attachments/download.js' +import InvoicesAttachmentsList from '../src/commands/invoices/attachments/list.js' +import InvoicesAttachmentsUpload from '../src/commands/invoices/attachments/upload.js' +import InvoicesCreate from '../src/commands/invoices/create.js' +import InvoicesList from '../src/commands/invoices/list.js' +import InvoicesUpdate from '../src/commands/invoices/update.js' +import ItemsCreate from '../src/commands/items/create.js' +import ItemsList from '../src/commands/items/list.js' +import ItemsUpdate from '../src/commands/items/update.js' +import Login from '../src/commands/login.js' +import Logout from '../src/commands/logout.js' +import ManualJournalsAttachmentsDownload from '../src/commands/manual-journals/attachments/download.js' +import ManualJournalsAttachmentsList from '../src/commands/manual-journals/attachments/list.js' +import ManualJournalsAttachmentsUpload from '../src/commands/manual-journals/attachments/upload.js' +import ManualJournalsCreate from '../src/commands/manual-journals/create.js' +import ManualJournalsList from '../src/commands/manual-journals/list.js' +import ManualJournalsUpdate from '../src/commands/manual-journals/update.js' +import OrgDetails from '../src/commands/org/details.js' +import PaymentsCreate from '../src/commands/payments/create.js' +import PaymentsList from '../src/commands/payments/list.js' +import ProfileAdd from '../src/commands/profile/add.js' +import ProfileList from '../src/commands/profile/list.js' +import ProfileRemove from '../src/commands/profile/remove.js' +import ProfileSetDefault from '../src/commands/profile/set-default.js' +import QuotesAttachmentsDownload from '../src/commands/quotes/attachments/download.js' +import QuotesAttachmentsList from '../src/commands/quotes/attachments/list.js' +import QuotesAttachmentsUpload from '../src/commands/quotes/attachments/upload.js' +import QuotesCreate from '../src/commands/quotes/create.js' +import QuotesList from '../src/commands/quotes/list.js' +import QuotesUpdate from '../src/commands/quotes/update.js' +import ReportsAgedPayables from '../src/commands/reports/aged-payables.js' +import ReportsAgedReceivables from '../src/commands/reports/aged-receivables.js' +import ReportsBalanceSheet from '../src/commands/reports/balance-sheet.js' +import ReportsProfitAndLoss from '../src/commands/reports/profit-and-loss.js' +import ReportsTrialBalance from '../src/commands/reports/trial-balance.js' +import TaxRatesList from '../src/commands/tax-rates/list.js' +import TrackingCategoriesCreate from '../src/commands/tracking/categories/create.js' +import TrackingCategoriesList from '../src/commands/tracking/categories/list.js' +import TrackingCategoriesUpdate from '../src/commands/tracking/categories/update.js' +import TrackingOptionsCreate from '../src/commands/tracking/options/create.js' +import TrackingOptionsUpdate from '../src/commands/tracking/options/update.js' + +// ── Command registry (oclif ID → bundled class) ─────────────────────────────── +// IDs must match what oclif.manifest.json uses (colon-separated topics). +const BUNDLED_COMMANDS: Record = { + 'accounts:attachments:download': AccountsAttachmentsDownload, + 'accounts:attachments:list': AccountsAttachmentsList, + 'accounts:attachments:upload': AccountsAttachmentsUpload, + 'accounts:list': AccountsList, + 'accounts:update': AccountsUpdate, + 'bank-transactions:attachments:download': BankTransactionsAttachmentsDownload, + 'bank-transactions:attachments:list': BankTransactionsAttachmentsList, + 'bank-transactions:attachments:upload': BankTransactionsAttachmentsUpload, + 'bank-transactions:create': BankTransactionsCreate, + 'bank-transactions:list': BankTransactionsList, + 'bank-transactions:update': BankTransactionsUpdate, + 'contact-groups:list': ContactGroupsList, + 'contacts:attachments:download': ContactsAttachmentsDownload, + 'contacts:attachments:list': ContactsAttachmentsList, + 'contacts:attachments:upload': ContactsAttachmentsUpload, + 'contacts:create': ContactsCreate, + 'contacts:list': ContactsList, + 'contacts:update': ContactsUpdate, + 'credit-notes:attachments:download': CreditNotesAttachmentsDownload, + 'credit-notes:attachments:list': CreditNotesAttachmentsList, + 'credit-notes:attachments:upload': CreditNotesAttachmentsUpload, + 'credit-notes:create': CreditNotesCreate, + 'credit-notes:list': CreditNotesList, + 'credit-notes:update': CreditNotesUpdate, + 'currencies:list': CurrenciesList, + 'invoices:attachments:download': InvoicesAttachmentsDownload, + 'invoices:attachments:list': InvoicesAttachmentsList, + 'invoices:attachments:upload': InvoicesAttachmentsUpload, + 'invoices:create': InvoicesCreate, + 'invoices:list': InvoicesList, + 'invoices:update': InvoicesUpdate, + 'items:create': ItemsCreate, + 'items:list': ItemsList, + 'items:update': ItemsUpdate, + login: Login, + logout: Logout, + 'manual-journals:attachments:download': ManualJournalsAttachmentsDownload, + 'manual-journals:attachments:list': ManualJournalsAttachmentsList, + 'manual-journals:attachments:upload': ManualJournalsAttachmentsUpload, + 'manual-journals:create': ManualJournalsCreate, + 'manual-journals:list': ManualJournalsList, + 'manual-journals:update': ManualJournalsUpdate, + 'org:details': OrgDetails, + 'payments:create': PaymentsCreate, + 'payments:list': PaymentsList, + 'profile:add': ProfileAdd, + 'profile:list': ProfileList, + 'profile:remove': ProfileRemove, + 'profile:set-default': ProfileSetDefault, + 'quotes:attachments:download': QuotesAttachmentsDownload, + 'quotes:attachments:list': QuotesAttachmentsList, + 'quotes:attachments:upload': QuotesAttachmentsUpload, + 'quotes:create': QuotesCreate, + 'quotes:list': QuotesList, + 'quotes:update': QuotesUpdate, + 'reports:aged-payables': ReportsAgedPayables, + 'reports:aged-receivables': ReportsAgedReceivables, + 'reports:balance-sheet': ReportsBalanceSheet, + 'reports:profit-and-loss': ReportsProfitAndLoss, + 'reports:trial-balance': ReportsTrialBalance, + 'tax-rates:list': TaxRatesList, + 'tracking:categories:create': TrackingCategoriesCreate, + 'tracking:categories:list': TrackingCategoriesList, + 'tracking:categories:update': TrackingCategoriesUpdate, + 'tracking:options:create': TrackingOptionsCreate, + 'tracking:options:update': TrackingOptionsUpdate, +} + +// ── Bootstrap ───────────────────────────────────────────────────────────────── +// Load oclif config (reads package.json + oclif.manifest.json from the +// filesystem directory that contains the binary). +const config = await Config.load(import.meta.url) + +// Patch every command's lazy loader to return the class bundled into this +// binary instead of doing a dynamic filesystem import. +const commandsMap = (config as Record)._commands as Map> +for (const [id, cmd] of commandsMap) { + const bundledClass = BUNDLED_COMMANDS[id] + if (bundledClass) { + commandsMap.set(id, { ...cmd, load: async () => bundledClass }) + } +} + +await run(process.argv.slice(2), config).catch(handle) diff --git a/package.json b/package.json index 78c63f7..042f9eb 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,8 @@ ], "scripts": { "build": "tsc -b", + "build:binary": "bun build --compile --minify bin/bun-run.ts --outfile xero", + "build:binary:linux": "bun build --compile --minify --target=bun-linux-x64 bin/bun-run.ts --outfile xero-linux", "dev": "node --loader ts-node/esm bin/dev.js", "test": "vitest run", "test:watch": "vitest", diff --git a/skills/xero-command-line/SKILL.md b/skills/xero-command-line/SKILL.md new file mode 100644 index 0000000..771f752 --- /dev/null +++ b/skills/xero-command-line/SKILL.md @@ -0,0 +1,374 @@ +--- +name: xero_command_line +description: Interact with the Xero accounting API using the `xero` CLI tool. Manage contacts, invoices, quotes, credit notes, payments, bank transactions, items, manual journals, tracking categories, currencies, tax rates, reports, organisation details, and attachments. +user-invocable: true +metadata: + openclaw: + requires: + bins: + - xero + install: "npm install -g @xeroapi/xero-command-line" + keywords: + - accounting + - xero + - invoices + - bookkeeping + - finance +--- + +# xero CLI + +You have access to the `xero` CLI — a command-line tool for the Xero accounting API using PKCE OAuth. Use it to read and write accounting data in the user's Xero organisation. + +## Authentication & Setup + +The CLI supports two authentication modes: + +### Option A: Environment variable bypass (no browser required) + +If all three of the following environment variables are set, the CLI uses them directly and skips the OAuth login flow entirely: + +```bash +export XERO_ACCESS_TOKEN="" +export XERO_REFRESH_TOKEN="" +export XERO_TENANT_ID="" +``` + +When these are set, **you do not need to run `xero login`** — just run commands directly. This is useful in CI/CD pipelines, agent environments, or anywhere a browser-based OAuth flow is impractical. + +To find existing token values on this machine, check `~/.config/xero-command-line/tokens.json` (stored as plaintext). The `tenantId` field maps to `XERO_TENANT_ID`. + +### Option B: Interactive OAuth login (standard) + +**Note for Agent:** If env vars are not set and the user is not logged in, you must instruct them to run `xero login` in their terminal manually, as it requires a browser-based OAuth flow that you cannot complete. + +```bash +# Check if logged in / check organization details +xero org details +``` + +## Global flags + +Every API command supports these flags: + +| Flag | Description | +|---|---| +| `-p, --profile ` | Use a specific named profile (defaults to the default profile) | +| `--client-id ` | Override the profile with an inline OAuth client ID | +| `--json` | Output raw JSON (useful for piping to `jq` or further processing) | +| `--csv` | Output as CSV | + +Environment variables `XERO_PROFILE` and `XERO_CLIENT_ID` are also recognised. + +## Auth & profiles + +```bash +# Log in (opens browser for OAuth consent) +xero login +xero login -p my-profile + +# Log out +xero logout + +# Manage profiles (each profile maps to a Xero OAuth app / organisation) +xero profile add # prompts for Client ID +xero profile add --client-id # inline +xero profile list +xero profile set-default +xero profile remove +``` + +## Key workflow: find IDs first + +Most create/update commands need Xero GUIDs. Always list first to find IDs: + +```bash +xero contacts list --search "Acme" # find a contact ID +xero accounts list # find account codes +xero invoices list # find invoice IDs +xero items list # find item codes +``` + +## JSON file input + +Any create or update command accepts `--file ` instead of inline flags. Use this for multi-line-item resources. All inputs are validated before being sent to the API. + +```bash +xero invoices create --file invoice.json +xero contacts update --file contact-update.json +``` + +## Commands + +### Contacts + +```bash +xero contacts list +xero contacts list --search "Acme" --page 2 +xero contacts create --name "Acme Corp" --email acme@example.com --phone "+1234567890" +xero contacts create --file contact.json +xero contacts update --contact-id --name "Acme Corporation" --email new@acme.com +xero contacts update --file contact-update.json +``` + +### Contact Groups + +```bash +xero contact-groups list +xero contact-groups list --group-id +``` + +### Accounts + +```bash +xero accounts list +xero accounts list --json +``` + +### Invoices + +```bash +xero invoices list +xero invoices list --contact-id +xero invoices list --invoice-number INV-0001 +xero invoices list --page 2 + +# Single line item inline +xero invoices create --contact-id --type ACCREC \ + --description "Consulting" --quantity 10 --unit-amount 150 \ + --account-code 200 --tax-type OUTPUT2 + +# Multiple line items via JSON file +xero invoices create --file invoice.json + +# Update a draft invoice +xero invoices update --invoice-id --reference "Updated ref" +xero invoices update --file invoice-update.json +``` + +Invoice types: `ACCREC` (sales/receivable), `ACCPAY` (purchase/payable). + +Example invoice.json: +```json +{ + "contactId": "", + "type": "ACCREC", + "date": "2025-06-15", + "reference": "REF-001", + "lineItems": [ + { + "description": "Consulting", + "quantity": 10, + "unitAmount": 150, + "accountCode": "200", + "taxType": "OUTPUT2" + } + ] +} +``` + +### Quotes + +```bash +xero quotes list +xero quotes list --contact-id +xero quotes list --quote-number QU-0001 + +xero quotes create --contact-id --title "Project Quote" \ + --date 2025-12-30 --description "Web design" --quantity 1 --unit-amount 5000 \ + --account-code 200 --tax-type OUTPUT2 +xero quotes create --file quote.json + +xero quotes update --file quote-update.json +``` + +> **Note:** Xero's API requires `contact` and `date` on quote updates even though the CLI allows them to be omitted. If you get a validation error, ensure your update payload includes both fields. + +### Credit Notes + +```bash +xero credit-notes list +xero credit-notes list --contact-id --page 2 + +xero credit-notes create --contact-id \ + --description "Refund" --quantity 1 --unit-amount 100 \ + --account-code 200 --tax-type OUTPUT2 +xero credit-notes create --file credit-note.json + +xero credit-notes update --file credit-note-update.json +``` + +### Manual Journals + +Manual journals require at least two journal lines (debit + credit). Always use `--file`. + +```bash +xero manual-journals list +xero manual-journals list --manual-journal-id +xero manual-journals list --modified-after 2025-01-01 + +xero manual-journals create --file journal.json +xero manual-journals update --file journal-update.json +``` + +Example journal.json: +```json +{ + "narration": "Reclassify office supplies", + "manualJournalLines": [ + { "accountCode": "200", "lineAmount": 100, "description": "Debit" }, + { "accountCode": "400", "lineAmount": -100, "description": "Credit" } + ] +} +``` + +### Bank Transactions + +```bash +xero bank-transactions list +xero bank-transactions list --bank-account-id + +xero bank-transactions create --type SPEND --bank-account-id \ + --contact-id --description "Office supplies" \ + --quantity 1 --unit-amount 50 --account-code 429 --tax-type INPUT2 +xero bank-transactions create --file bank-transaction.json + +xero bank-transactions update --file bank-transaction-update.json +``` + +Transaction types: `RECEIVE` (money in), `SPEND` (money out). + +### Attachments + +Files can be attached to invoices, credit notes, bank transactions, quotes, contacts, accounts, and manual journals. Each supports `upload`, `list`, and `download`. The Xero API does not support deleting attachments. + +Supported file types: `.pdf`, `.png`, `.jpg`/`.jpeg`, `.gif`, `.webp`, `.xml`, `.csv`, `.txt`. Maximum 25MB per file, up to 10 attachments per resource. + +```bash +# Upload +xero invoices attachments upload --invoice-id --file +xero invoices attachments upload --invoice-id --file --include-online +xero credit-notes attachments upload --credit-note-id --file --include-online +xero bank-transactions attachments upload --bank-transaction-id --file +xero quotes attachments upload --quote-id --file +xero contacts attachments upload --contact-id --file +xero accounts attachments upload --account-id --file +xero manual-journals attachments upload --manual-journal-id --file + +# List (shows attachmentID, fileName, mimeType, size, URL) +xero invoices attachments list --invoice-id +xero credit-notes attachments list --credit-note-id +xero bank-transactions attachments list --bank-transaction-id +xero quotes attachments list --quote-id +xero contacts attachments list --contact-id +xero accounts attachments list --account-id +xero manual-journals attachments list --manual-journal-id + +# Download (--output defaults to current directory; accepts a file path or directory) +xero invoices attachments download --invoice-id --attachment-id +xero invoices attachments download --invoice-id --attachment-id --output ./downloads/ +xero credit-notes attachments download --credit-note-id --attachment-id +xero bank-transactions attachments download --bank-transaction-id --attachment-id +xero quotes attachments download --quote-id --attachment-id +xero contacts attachments download --contact-id --attachment-id +xero accounts attachments download --account-id --attachment-id +xero manual-journals attachments download --manual-journal-id --attachment-id +``` + +`--include-online` (invoices and credit notes only) makes the attachment visible to the end customer in their online invoice/credit note view. + +### Payments + +```bash +xero payments list +xero payments list --invoice-id +xero payments list --invoice-number INV-0001 +xero payments list --reference "Payment ref" + +xero payments create --invoice-id --account-id --amount 500 +xero payments create --file payment.json +``` + +### Items + +```bash +xero items list +xero items list --page 2 + +xero items create --code WIDGET --name "Widget" --sale-price 29.99 +xero items create --file item.json + +xero items update --item-id --code WIDGET --name "Updated Widget" +xero items update --file item-update.json +``` + +### Currencies + +```bash +xero currencies list +xero currencies list --json +``` + +### Tax Rates + +```bash +xero tax-rates list +xero tax-rates list --json +``` + +### Tracking Categories & Options + +```bash +xero tracking categories list +xero tracking categories list --include-archived + +xero tracking categories create --name "Department" +xero tracking categories update --category-id --name "Region" +xero tracking categories update --category-id --status ARCHIVED + +xero tracking options create --category-id --names "Sales,Marketing,Engineering" +xero tracking options update --category-id --file tracking-options.json +``` + +### Organisation + +```bash +xero org details +xero org details --json +``` + +### Reports + +```bash +# Trial balance +xero reports trial-balance +xero reports trial-balance --date 2025-12-31 + +# Profit and loss +xero reports profit-and-loss +xero reports profit-and-loss --from 2025-01-01 --to 2025-12-31 +xero reports profit-and-loss --timeframe QUARTER --periods 4 + +# Balance sheet +xero reports balance-sheet +xero reports balance-sheet --date 2025-12-31 +xero reports balance-sheet --timeframe MONTH --periods 12 + +# Aged receivables (requires contact ID) +xero reports aged-receivables --contact-id +xero reports aged-receivables --contact-id --report-date 2025-12-31 + +# Aged payables (requires contact ID) +xero reports aged-payables --contact-id +xero reports aged-payables --contact-id --from-date 2025-01-01 --to-date 2025-12-31 +``` + +## Tips + +- Use `--json` and pipe to `jq` when you need to extract specific fields programmatically. +- Only draft invoices, quotes, and credit notes can be updated. +- For multi-line-item creates, always use `--file` with a JSON payload. +- Tax types vary by region. Run `xero tax-rates list` to see what's available. +- Account codes are needed for line items. Run `xero accounts list` to find them. +- To attach a file: `xero attachments upload ---id --file `. Run `list` first to get the resource ID, then `attachments list` to get attachment IDs for download. +- If `XERO_ACCESS_TOKEN`, `XERO_REFRESH_TOKEN`, and `XERO_TENANT_ID` are all set, the CLI bypasses OAuth entirely — no `xero login` needed.