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/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/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 00f5068..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", @@ -97,6 +99,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/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. 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') + }) +})