From 94466f2f634c30161590cfbaec4850de65f71be4 Mon Sep 17 00:00:00 2001 From: Richard Davies Date: Mon, 8 Jun 2026 20:50:41 +0100 Subject: [PATCH 1/5] feat: add list-repeating-invoices tool --- .../list-xero-repeating-invoices.handler.ts | 45 +++++++++++ src/tools/list/index.ts | 4 +- .../list/list-repeating-invoices.tool.ts | 76 +++++++++++++++++++ 3 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 src/handlers/list-xero-repeating-invoices.handler.ts create mode 100644 src/tools/list/list-repeating-invoices.tool.ts diff --git a/src/handlers/list-xero-repeating-invoices.handler.ts b/src/handlers/list-xero-repeating-invoices.handler.ts new file mode 100644 index 00000000..50677ba3 --- /dev/null +++ b/src/handlers/list-xero-repeating-invoices.handler.ts @@ -0,0 +1,45 @@ +import { xeroClient } from "../clients/xero-client.js"; +import { XeroClientResponse } from "../types/tool-response.js"; +import { formatError } from "../helpers/format-error.js"; +import { RepeatingInvoice } from "xero-node"; +import { getClientHeaders } from "../helpers/get-client-headers.js"; + +async function getRepeatingInvoices( + where?: string, + order?: string, +): Promise { + await xeroClient.authenticate(); + + const response = await xeroClient.accountingApi.getRepeatingInvoices( + xeroClient.tenantId, + where, // where + order, // order + getClientHeaders(), + ); + + return response.body.repeatingInvoices ?? []; +} + +/** + * List repeating-invoice templates from Xero + */ +export async function listXeroRepeatingInvoices( + where?: string, + order?: string, +): Promise> { + try { + const repeatingInvoices = await getRepeatingInvoices(where, order); + + return { + result: repeatingInvoices, + isError: false, + error: null, + }; + } catch (error) { + return { + result: null, + isError: true, + error: formatError(error), + }; + } +} diff --git a/src/tools/list/index.ts b/src/tools/list/index.ts index b4d1b627..c544d7be 100644 --- a/src/tools/list/index.ts +++ b/src/tools/list/index.ts @@ -28,6 +28,7 @@ import ListTaxRatesTool from "./list-tax-rates.tool.js"; import ListTrackingCategoriesTool from "./list-tracking-categories.tool.js"; import ListTrialBalanceTool from "./list-trial-balance.tool.js"; import ListContactGroupsTool from "./list-contact-groups.tool.js"; +import ListRepeatingInvoicesTool from "./list-repeating-invoices.tool.js"; export const ListTools = [ ListAccountsTool, @@ -54,5 +55,6 @@ export const ListTools = [ ListAgedPayablesByContact, ListPayrollTimesheetsTool, ListContactGroupsTool, - ListTrackingCategoriesTool + ListTrackingCategoriesTool, + ListRepeatingInvoicesTool ]; diff --git a/src/tools/list/list-repeating-invoices.tool.ts b/src/tools/list/list-repeating-invoices.tool.ts new file mode 100644 index 00000000..c20ca1a6 --- /dev/null +++ b/src/tools/list/list-repeating-invoices.tool.ts @@ -0,0 +1,76 @@ +import { z } from "zod"; +import { listXeroRepeatingInvoices } from "../../handlers/list-xero-repeating-invoices.handler.js"; +import { CreateXeroTool } from "../../helpers/create-xero-tool.js"; + +const ListRepeatingInvoicesTool = CreateXeroTool( + "list-repeating-invoices", + `List repeating-invoice templates in Xero. + Repeating invoices are templates Xero uses to auto-generate invoices on a schedule. + Optionally filter with a 'where' clause or sort with 'order'. + Common 'where' patterns: Type=="ACCREC" (sales templates), Type=="ACCPAY" (bill templates), + Status=="AUTHORISED", Contact.Name=="ABC Ltd". Range operators: >, >=, <, <=. Logical: AND, OR. + To read a single template in full (including line items), use the get-repeating-invoice tool.`, + { + where: z + .string() + .optional() + .describe( + 'Filter clause, e.g. Type=="ACCREC", Status=="AUTHORISED", Contact.Name=="ABC Ltd".', + ), + order: z + .string() + .optional() + .describe("Order by field, e.g. 'Type', 'Status'."), + }, + async ({ where, order }) => { + const response = await listXeroRepeatingInvoices(where, order); + + if (response.isError) { + return { + content: [ + { + type: "text" as const, + text: `Error listing repeating invoices: ${response.error}`, + }, + ], + }; + } + + const repeatingInvoices = response.result; + + return { + content: [ + { + type: "text" as const, + text: `Found ${repeatingInvoices?.length || 0} repeating invoices:`, + }, + ...(repeatingInvoices?.map((ri) => ({ + type: "text" as const, + text: [ + `Repeating Invoice ID: ${ri.repeatingInvoiceID}`, + `Type: ${ri.type || "Unknown"}`, + `Status: ${ri.status || "Unknown"}`, + ri.contact + ? `Contact: ${ri.contact.name} (${ri.contact.contactID})` + : null, + ri.reference ? `Reference: ${ri.reference}` : null, + ri.schedule + ? `Schedule: every ${ri.schedule.period} ${ri.schedule.unit}` + : null, + ri.schedule?.startDate ? `Start Date: ${ri.schedule.startDate}` : null, + ri.schedule?.nextScheduledDate + ? `Next Scheduled: ${ri.schedule.nextScheduledDate}` + : null, + ri.schedule?.endDate ? `End Date: ${ri.schedule.endDate}` : null, + ri.currencyCode ? `Currency: ${ri.currencyCode}` : null, + ri.total != null ? `Total: ${ri.total}` : null, + ] + .filter(Boolean) + .join("\n"), + })) || []), + ], + }; + }, +); + +export default ListRepeatingInvoicesTool; From 3e3eeb930f213fc8e521187f87bac5d2f1012b4f Mon Sep 17 00:00:00 2001 From: Richard Davies Date: Mon, 8 Jun 2026 20:51:26 +0100 Subject: [PATCH 2/5] feat: add get-repeating-invoice tool --- .../get-xero-repeating-invoice.handler.ts | 46 +++++++++++++ src/tools/get/get-repeating-invoice.tool.ts | 69 +++++++++++++++++++ src/tools/get/index.ts | 2 + 3 files changed, 117 insertions(+) create mode 100644 src/handlers/get-xero-repeating-invoice.handler.ts create mode 100644 src/tools/get/get-repeating-invoice.tool.ts diff --git a/src/handlers/get-xero-repeating-invoice.handler.ts b/src/handlers/get-xero-repeating-invoice.handler.ts new file mode 100644 index 00000000..4eea88d6 --- /dev/null +++ b/src/handlers/get-xero-repeating-invoice.handler.ts @@ -0,0 +1,46 @@ +import { xeroClient } from "../clients/xero-client.js"; +import { XeroClientResponse } from "../types/tool-response.js"; +import { formatError } from "../helpers/format-error.js"; +import { RepeatingInvoice } from "xero-node"; +import { getClientHeaders } from "../helpers/get-client-headers.js"; + +async function getRepeatingInvoice( + repeatingInvoiceId: string, +): Promise { + await xeroClient.authenticate(); + + const response = await xeroClient.accountingApi.getRepeatingInvoice( + xeroClient.tenantId, + repeatingInvoiceId, // repeatingInvoiceID + getClientHeaders(), + ); + + return response.body.repeatingInvoices?.[0]; +} + +/** + * Get a single repeating-invoice template (incl. line items) from Xero + */ +export async function getXeroRepeatingInvoice( + repeatingInvoiceId: string, +): Promise> { + try { + const repeatingInvoice = await getRepeatingInvoice(repeatingInvoiceId); + + if (!repeatingInvoice) { + throw new Error("Repeating invoice not found."); + } + + return { + result: repeatingInvoice, + isError: false, + error: null, + }; + } catch (error) { + return { + result: null, + isError: true, + error: formatError(error), + }; + } +} diff --git a/src/tools/get/get-repeating-invoice.tool.ts b/src/tools/get/get-repeating-invoice.tool.ts new file mode 100644 index 00000000..9bdc2489 --- /dev/null +++ b/src/tools/get/get-repeating-invoice.tool.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; +import { CreateXeroTool } from "../../helpers/create-xero-tool.js"; +import { getXeroRepeatingInvoice } from "../../handlers/get-xero-repeating-invoice.handler.js"; +import { formatLineItem } from "../../helpers/format-line-item.js"; + +const GetRepeatingInvoiceTool = CreateXeroTool( + "get-repeating-invoice", + `Get a single repeating-invoice template in Xero by ID, including its line items and schedule. + Use this to read a template in full before updating it (update replaces the whole template).`, + { + repeatingInvoiceId: z + .string() + .describe("The Xero Repeating Invoice ID (UUID). Can be obtained from list-repeating-invoices."), + }, + async ({ repeatingInvoiceId }) => { + const response = await getXeroRepeatingInvoice(repeatingInvoiceId); + + if (response.isError) { + return { + content: [ + { + type: "text" as const, + text: `Error getting repeating invoice: ${response.error}`, + }, + ], + }; + } + + const ri = response.result; + + return { + content: [ + { + type: "text" as const, + text: [ + `Repeating Invoice ID: ${ri?.repeatingInvoiceID}`, + `Type: ${ri?.type || "Unknown"}`, + `Status: ${ri?.status || "Unknown"}`, + ri?.contact + ? `Contact: ${ri.contact.name} (${ri.contact.contactID})` + : null, + ri?.reference ? `Reference: ${ri.reference}` : null, + ri?.schedule + ? `Schedule: every ${ri.schedule.period} ${ri.schedule.unit}` + : null, + ri?.schedule?.startDate ? `Start Date: ${ri.schedule.startDate}` : null, + ri?.schedule?.dueDate != null + ? `Due Date: ${ri.schedule.dueDate} (${ri.schedule.dueDateType})` + : null, + ri?.schedule?.nextScheduledDate + ? `Next Scheduled: ${ri.schedule.nextScheduledDate}` + : null, + ri?.schedule?.endDate ? `End Date: ${ri.schedule.endDate}` : null, + ri?.lineAmountTypes ? `Line Amount Types: ${ri.lineAmountTypes}` : null, + ri?.currencyCode ? `Currency: ${ri.currencyCode}` : null, + ri?.total != null ? `Total: ${ri.total}` : null, + ri?.lineItems?.length + ? `Line Items:\n${ri.lineItems.map(formatLineItem).join("\n---\n")}` + : null, + ] + .filter(Boolean) + .join("\n"), + }, + ], + }; + }, +); + +export default GetRepeatingInvoiceTool; diff --git a/src/tools/get/index.ts b/src/tools/get/index.ts index c4bfbd55..60f538bd 100644 --- a/src/tools/get/index.ts +++ b/src/tools/get/index.ts @@ -1,5 +1,7 @@ import GetPayrollTimesheetTool from "./get-payroll-timesheet.tool.js"; +import GetRepeatingInvoiceTool from "./get-repeating-invoice.tool.js"; export const GetTools = [ GetPayrollTimesheetTool, + GetRepeatingInvoiceTool, ]; From 6d5be3dd1a7eba0c4b443a88d015f3db4b2772a4 Mon Sep 17 00:00:00 2001 From: Richard Davies Date: Mon, 8 Jun 2026 20:53:13 +0100 Subject: [PATCH 3/5] feat: add create-repeating-invoice tool --- .../create-xero-repeating-invoice.handler.ts | 145 ++++++++++++++++++ .../create/create-repeating-invoice.tool.ts | 101 ++++++++++++ src/tools/create/index.ts | 4 +- 3 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 src/handlers/create-xero-repeating-invoice.handler.ts create mode 100644 src/tools/create/create-repeating-invoice.tool.ts diff --git a/src/handlers/create-xero-repeating-invoice.handler.ts b/src/handlers/create-xero-repeating-invoice.handler.ts new file mode 100644 index 00000000..5c9cd0ef --- /dev/null +++ b/src/handlers/create-xero-repeating-invoice.handler.ts @@ -0,0 +1,145 @@ +import { xeroClient } from "../clients/xero-client.js"; +import { XeroClientResponse } from "../types/tool-response.js"; +import { formatError } from "../helpers/format-error.js"; +import { + RepeatingInvoice, + Schedule, + LineItemTracking, + CurrencyCode, + LineAmountTypes, +} from "xero-node"; +import { getClientHeaders } from "../helpers/get-client-headers.js"; + +export type LineAmountTypeInput = "EXCLUSIVE" | "INCLUSIVE" | "NO_TAX"; + +function mapLineAmountType( + input?: LineAmountTypeInput, +): LineAmountTypes | undefined { + switch (input) { + case "EXCLUSIVE": + return LineAmountTypes.Exclusive; + case "INCLUSIVE": + return LineAmountTypes.Inclusive; + case "NO_TAX": + return LineAmountTypes.NoTax; + default: + return undefined; + } +} + +export interface RepeatingInvoiceLineItem { + description: string; + quantity: number; + unitAmount: number; + accountCode: string; + taxType: string; + itemCode?: string; + tracking?: LineItemTracking[]; +} + +export interface RepeatingInvoiceScheduleInput { + period: number; + unit: "WEEKLY" | "MONTHLY"; + startDate: string; + dueDate?: number; + dueDateType?: string; + endDate?: string; +} + +export interface CreateRepeatingInvoiceInput { + contactId: string; + schedule: RepeatingInvoiceScheduleInput; + lineItems: RepeatingInvoiceLineItem[]; + type?: "ACCREC" | "ACCPAY"; + status?: "DRAFT" | "AUTHORISED"; + reference?: string; + brandingThemeId?: string; + currencyCode?: string; + lineAmountType?: LineAmountTypeInput; + approvedForSending?: boolean; + sendCopy?: boolean; + markAsSent?: boolean; + includePDF?: boolean; +} + +export function buildSchedule(input: RepeatingInvoiceScheduleInput): Schedule { + return { + period: input.period, + unit: Schedule.UnitEnum[input.unit as keyof typeof Schedule.UnitEnum], + startDate: input.startDate, + dueDate: input.dueDate, + dueDateType: input.dueDateType + ? Schedule.DueDateTypeEnum[ + input.dueDateType as keyof typeof Schedule.DueDateTypeEnum + ] + : undefined, + endDate: input.endDate, + }; +} + +export function buildRepeatingInvoice( + input: CreateRepeatingInvoiceInput & { repeatingInvoiceId?: string }, +): RepeatingInvoice { + return { + repeatingInvoiceID: input.repeatingInvoiceId, + type: + RepeatingInvoice.TypeEnum[ + (input.type ?? "ACCREC") as keyof typeof RepeatingInvoice.TypeEnum + ], + contact: { contactID: input.contactId }, + schedule: buildSchedule(input.schedule), + lineItems: input.lineItems, + status: + RepeatingInvoice.StatusEnum[ + (input.status ?? "DRAFT") as keyof typeof RepeatingInvoice.StatusEnum + ], + reference: input.reference, + brandingThemeID: input.brandingThemeId, + currencyCode: input.currencyCode + ? CurrencyCode[input.currencyCode as keyof typeof CurrencyCode] + : undefined, + lineAmountTypes: mapLineAmountType(input.lineAmountType), + approvedForSending: input.approvedForSending, + sendCopy: input.sendCopy, + markAsSent: input.markAsSent, + includePDF: input.includePDF, + }; +} + +/** + * Create a repeating-invoice template in Xero + */ +export async function createXeroRepeatingInvoice( + input: CreateRepeatingInvoiceInput, +): Promise> { + try { + await xeroClient.authenticate(); + + const repeatingInvoice = buildRepeatingInvoice(input); + + const response = await xeroClient.accountingApi.createRepeatingInvoices( + xeroClient.tenantId, + { repeatingInvoices: [repeatingInvoice] }, + true, // summarizeErrors + undefined, // idempotencyKey + getClientHeaders(), + ); + + const created = response.body.repeatingInvoices?.[0]; + if (!created) { + throw new Error("Repeating invoice creation failed."); + } + + return { + result: created, + isError: false, + error: null, + }; + } catch (error) { + return { + result: null, + isError: true, + error: formatError(error), + }; + } +} diff --git a/src/tools/create/create-repeating-invoice.tool.ts b/src/tools/create/create-repeating-invoice.tool.ts new file mode 100644 index 00000000..423c9160 --- /dev/null +++ b/src/tools/create/create-repeating-invoice.tool.ts @@ -0,0 +1,101 @@ +import { z } from "zod"; +import { createXeroRepeatingInvoice } from "../../handlers/create-xero-repeating-invoice.handler.js"; +import { CreateXeroTool } from "../../helpers/create-xero-tool.js"; + +const trackingSchema = z.object({ + name: z.string().describe("The name of the tracking category. Can be obtained from the list-tracking-categories tool"), + option: z.string().describe("The name of the tracking option. Can be obtained from the list-tracking-categories tool"), + trackingCategoryID: z.string().describe("The ID of the tracking category. \ + Can be obtained from the list-tracking-categories tool"), +}); + +const lineItemSchema = z.object({ + description: z.string().describe("The description of the line item"), + quantity: z.number().describe("The quantity of the line item"), + unitAmount: z.number().describe("The price per unit of the line item"), + accountCode: z.string().describe("The account code of the line item - can be obtained from the list-accounts tool"), + taxType: z.string().describe("The tax type of the line item - can be obtained from the list-tax-rates tool"), + itemCode: z.string().describe("The item code of the line item - can be obtained from the list-items tool \ + If the item is not listed, add without an item code and ask the user if they would like to add an item code.").optional(), + tracking: z.array(trackingSchema).describe("Up to 2 tracking categories and options can be added to the line item. \ + Can be obtained from the list-tracking-categories tool. \ + Only use if prompted by the user.").optional(), +}); + +const scheduleSchema = z.object({ + period: z.number().describe("How many units between invoices, e.g. 1 (every 1) or 2 (every 2)."), + unit: z.enum(["WEEKLY", "MONTHLY"]).describe("The repeat unit."), + startDate: z.string().describe("Date the first invoice is generated (YYYY-MM-DD)."), + dueDate: z.number().describe("Payment-terms value used with dueDateType, e.g. 20 or 31.").optional(), + dueDateType: z + .enum([ + "DAYSAFTERBILLDATE", + "DAYSAFTERBILLMONTH", + "DAYSAFTERINVOICEDATE", + "DAYSAFTERINVOICEMONTH", + "OFCURRENTMONTH", + "OFFOLLOWINGMONTH", + ]) + .describe("Payment-terms type used with dueDate.") + .optional(), + endDate: z.string().describe("Optional date the schedule ends (YYYY-MM-DD).").optional(), +}); + +const CreateRepeatingInvoiceTool = CreateXeroTool( + "create-repeating-invoice", + "Create a repeating-invoice template in Xero. The template auto-generates invoices on the \ +given schedule. Defaults to a DRAFT ACCREC (sales) template unless told otherwise.", + { + contactId: z.string().describe("The ID of the contact for the template. Can be obtained from the list-contacts tool."), + schedule: scheduleSchema, + lineItems: z.array(lineItemSchema), + type: z.enum(["ACCREC", "ACCPAY"]).describe("ACCREC for sales templates, ACCPAY for bill templates. Default ACCREC.").optional(), + status: z.enum(["DRAFT", "AUTHORISED"]).describe("DRAFT (default) or AUTHORISED.").optional(), + reference: z.string().describe("A reference for the generated invoices.").optional(), + brandingThemeId: z.string().describe("Optional branding theme ID.").optional(), + currencyCode: z.string().describe("Optional ISO currency code, e.g. GBP.").optional(), + lineAmountType: z.enum(["EXCLUSIVE", "INCLUSIVE", "NO_TAX"]).describe("Whether line amounts are tax exclusive, inclusive, or no tax.").optional(), + approvedForSending: z.boolean().describe("Whether Xero emails the generated invoice to the contact.").optional(), + sendCopy: z.boolean().describe("Whether to send a copy to the sender's email.").optional(), + markAsSent: z.boolean().describe("Whether to mark the generated invoice as sent.").optional(), + includePDF: z.boolean().describe("Whether to attach a PDF to the emailed invoice.").optional(), + }, + async (params) => { + const result = await createXeroRepeatingInvoice(params); + + if (result.isError) { + return { + content: [ + { + type: "text" as const, + text: `Error creating repeating invoice: ${result.error}`, + }, + ], + }; + } + + const ri = result.result; + + return { + content: [ + { + type: "text" as const, + text: [ + "Repeating invoice created successfully:", + `ID: ${ri?.repeatingInvoiceID}`, + `Type: ${ri?.type}`, + `Status: ${ri?.status}`, + `Contact: ${ri?.contact?.name}`, + ri?.schedule ? `Schedule: every ${ri.schedule.period} ${ri.schedule.unit}` : null, + ri?.schedule?.nextScheduledDate ? `Next Scheduled: ${ri.schedule.nextScheduledDate}` : null, + ri?.total != null ? `Total: ${ri.total}` : null, + ] + .filter(Boolean) + .join("\n"), + }, + ], + }; + }, +); + +export default CreateRepeatingInvoiceTool; diff --git a/src/tools/create/index.ts b/src/tools/create/index.ts index 1062a911..deebd19f 100644 --- a/src/tools/create/index.ts +++ b/src/tools/create/index.ts @@ -9,6 +9,7 @@ import CreatePayrollTimesheetTool from "./create-payroll-timesheet.tool.js"; import CreateQuoteTool from "./create-quote.tool.js"; import CreateTrackingCategoryTool from "./create-tracking-category.tool.js"; import CreateTrackingOptionsTool from "./create-tracking-options.tool.js"; +import CreateRepeatingInvoiceTool from "./create-repeating-invoice.tool.js"; export const CreateTools = [ CreateContactTool, @@ -21,5 +22,6 @@ export const CreateTools = [ CreateBankTransactionTool, CreatePayrollTimesheetTool, CreateTrackingCategoryTool, - CreateTrackingOptionsTool + CreateTrackingOptionsTool, + CreateRepeatingInvoiceTool ]; From 36bb0f1c27317f05721b8382334026a8ad9f0ab2 Mon Sep 17 00:00:00 2001 From: Richard Davies Date: Mon, 8 Jun 2026 20:54:15 +0100 Subject: [PATCH 4/5] feat: add update-repeating-invoice tool --- .../update-xero-repeating-invoice.handler.ts | 60 ++++++++++ src/tools/update/index.ts | 4 +- .../update/update-repeating-invoice.tool.ts | 105 ++++++++++++++++++ 3 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 src/handlers/update-xero-repeating-invoice.handler.ts create mode 100644 src/tools/update/update-repeating-invoice.tool.ts diff --git a/src/handlers/update-xero-repeating-invoice.handler.ts b/src/handlers/update-xero-repeating-invoice.handler.ts new file mode 100644 index 00000000..44e1b87f --- /dev/null +++ b/src/handlers/update-xero-repeating-invoice.handler.ts @@ -0,0 +1,60 @@ +import { xeroClient } from "../clients/xero-client.js"; +import { XeroClientResponse } from "../types/tool-response.js"; +import { formatError } from "../helpers/format-error.js"; +import { RepeatingInvoice } from "xero-node"; +import { getClientHeaders } from "../helpers/get-client-headers.js"; +import { + buildRepeatingInvoice, + CreateRepeatingInvoiceInput, +} from "./create-xero-repeating-invoice.handler.js"; + +export type UpdateRepeatingInvoiceInput = Omit< + CreateRepeatingInvoiceInput, + "status" +> & { + repeatingInvoiceId: string; + status?: "DRAFT" | "AUTHORISED" | "DELETED"; +}; + +/** + * Update a repeating-invoice template in Xero. Full replace (POST-with-ID): every field + * is re-sent, so line items and schedule not provided are wiped. Pass status="DELETED" to + * delete the template (there is no separate delete endpoint). + */ +export async function updateXeroRepeatingInvoice( + input: UpdateRepeatingInvoiceInput, +): Promise> { + try { + await xeroClient.authenticate(); + + const repeatingInvoice = buildRepeatingInvoice( + input as CreateRepeatingInvoiceInput & { repeatingInvoiceId: string }, + ); + + const response = + await xeroClient.accountingApi.updateOrCreateRepeatingInvoices( + xeroClient.tenantId, + { repeatingInvoices: [repeatingInvoice] }, + true, // summarizeErrors + undefined, // idempotencyKey + getClientHeaders(), + ); + + const updated = response.body.repeatingInvoices?.[0]; + if (!updated) { + throw new Error("Repeating invoice update failed."); + } + + return { + result: updated, + isError: false, + error: null, + }; + } catch (error) { + return { + result: null, + isError: true, + error: formatError(error), + }; + } +} diff --git a/src/tools/update/index.ts b/src/tools/update/index.ts index d198d69c..a9e6ff84 100644 --- a/src/tools/update/index.ts +++ b/src/tools/update/index.ts @@ -12,6 +12,7 @@ import UpdateManualJournalTool from "./update-manual-journal-tool.js"; import UpdateQuoteTool from "./update-quote.tool.js"; import UpdateTrackingCategoryTool from "./update-tracking-category.tool.js"; import UpdateTrackingOptionsTool from "./update-tracking-options.tool.js"; +import UpdateRepeatingInvoiceTool from "./update-repeating-invoice.tool.js"; export const UpdateTools = [ UpdateContactTool, @@ -26,5 +27,6 @@ export const UpdateTools = [ UpdatePayrollTimesheetLineTool, RevertPayrollTimesheetTool, UpdateTrackingCategoryTool, - UpdateTrackingOptionsTool + UpdateTrackingOptionsTool, + UpdateRepeatingInvoiceTool ]; diff --git a/src/tools/update/update-repeating-invoice.tool.ts b/src/tools/update/update-repeating-invoice.tool.ts new file mode 100644 index 00000000..0cc7e37a --- /dev/null +++ b/src/tools/update/update-repeating-invoice.tool.ts @@ -0,0 +1,105 @@ +import { z } from "zod"; +import { updateXeroRepeatingInvoice } from "../../handlers/update-xero-repeating-invoice.handler.js"; +import { CreateXeroTool } from "../../helpers/create-xero-tool.js"; + +const trackingSchema = z.object({ + name: z.string().describe("The name of the tracking category. Can be obtained from the list-tracking-categories tool"), + option: z.string().describe("The name of the tracking option. Can be obtained from the list-tracking-categories tool"), + trackingCategoryID: z.string().describe("The ID of the tracking category. \ + Can be obtained from the list-tracking-categories tool"), +}); + +const lineItemSchema = z.object({ + description: z.string().describe("The description of the line item"), + quantity: z.number().describe("The quantity of the line item"), + unitAmount: z.number().describe("The price per unit of the line item"), + accountCode: z.string().describe("The account code of the line item - can be obtained from the list-accounts tool"), + taxType: z.string().describe("The tax type of the line item - can be obtained from the list-tax-rates tool"), + itemCode: z.string().describe("The item code of the line item - can be obtained from the list-items tool \ + If the item was not populated in the original template, \ + add without an item code unless the user has told you to add an item code.").optional(), + tracking: z.array(trackingSchema).describe("Up to 2 tracking categories and options can be added to the line item. \ + Can be obtained from the list-tracking-categories tool. \ + Only use if prompted by the user.").optional(), +}); + +const scheduleSchema = z.object({ + period: z.number().describe("How many units between invoices, e.g. 1 (every 1) or 2 (every 2)."), + unit: z.enum(["WEEKLY", "MONTHLY"]).describe("The repeat unit."), + startDate: z.string().describe("Date the first invoice is generated (YYYY-MM-DD)."), + dueDate: z.number().describe("Payment-terms value used with dueDateType, e.g. 20 or 31.").optional(), + dueDateType: z + .enum([ + "DAYSAFTERBILLDATE", + "DAYSAFTERBILLMONTH", + "DAYSAFTERINVOICEDATE", + "DAYSAFTERINVOICEMONTH", + "OFCURRENTMONTH", + "OFFOLLOWINGMONTH", + ]) + .describe("Payment-terms type used with dueDate.") + .optional(), + endDate: z.string().describe("Optional date the schedule ends (YYYY-MM-DD).").optional(), +}); + +const UpdateRepeatingInvoiceTool = CreateXeroTool( + "update-repeating-invoice", + "Update a repeating-invoice template in Xero. This is a FULL REPLACE: all fields are re-sent. \ +All line items must be provided - any line items not provided will be removed, including existing ones. \ +Read the current template with get-repeating-invoice first, edit it, then resubmit the whole thing. \ +Pass status=\"DELETED\" to delete the template.", + { + repeatingInvoiceId: z.string().describe("The ID of the repeating invoice to update. Can be obtained from list-repeating-invoices."), + contactId: z.string().describe("The ID of the contact for the template. Can be obtained from the list-contacts tool."), + schedule: scheduleSchema, + lineItems: z.array(lineItemSchema).describe("All line items must be provided. Any not provided will be removed."), + type: z.enum(["ACCREC", "ACCPAY"]).describe("ACCREC for sales templates, ACCPAY for bill templates. Default ACCREC.").optional(), + status: z.enum(["DRAFT", "AUTHORISED", "DELETED"]).describe("DRAFT, AUTHORISED, or DELETED (to delete the template).").optional(), + reference: z.string().describe("A reference for the generated invoices.").optional(), + brandingThemeId: z.string().describe("Optional branding theme ID.").optional(), + currencyCode: z.string().describe("Optional ISO currency code, e.g. GBP.").optional(), + lineAmountType: z.enum(["EXCLUSIVE", "INCLUSIVE", "NO_TAX"]).describe("Whether line amounts are tax exclusive, inclusive, or no tax.").optional(), + approvedForSending: z.boolean().describe("Whether Xero emails the generated invoice to the contact.").optional(), + sendCopy: z.boolean().describe("Whether to send a copy to the sender's email.").optional(), + markAsSent: z.boolean().describe("Whether to mark the generated invoice as sent.").optional(), + includePDF: z.boolean().describe("Whether to attach a PDF to the emailed invoice.").optional(), + }, + async (params) => { + const result = await updateXeroRepeatingInvoice(params); + + if (result.isError) { + return { + content: [ + { + type: "text" as const, + text: `Error updating repeating invoice: ${result.error}`, + }, + ], + }; + } + + const ri = result.result; + + return { + content: [ + { + type: "text" as const, + text: [ + "Repeating invoice updated successfully:", + `ID: ${ri?.repeatingInvoiceID}`, + `Type: ${ri?.type}`, + `Status: ${ri?.status}`, + `Contact: ${ri?.contact?.name}`, + ri?.schedule ? `Schedule: every ${ri.schedule.period} ${ri.schedule.unit}` : null, + ri?.schedule?.nextScheduledDate ? `Next Scheduled: ${ri.schedule.nextScheduledDate}` : null, + ri?.total != null ? `Total: ${ri.total}` : null, + ] + .filter(Boolean) + .join("\n"), + }, + ], + }; + }, +); + +export default UpdateRepeatingInvoiceTool; From e0ef191ab504492ddf793c58fdc319e7299e00e8 Mon Sep 17 00:00:00 2001 From: Richard Davies Date: Mon, 8 Jun 2026 21:15:50 +0100 Subject: [PATCH 5/5] refactor: replace update-repeating-invoice with delete-repeating-invoice Xero's repeating-invoice API has no edit operation: a POST to /RepeatingInvoices with an existing RepeatingInvoiceID is only valid with status=DELETED (confirmed live against the tenant: 'Repeating invoice status must be set to DELETED'). Replace the impossible update tool with delete-repeating-invoice (POST id + status=DELETED; minimal body works). Editing a template = delete + recreate, which is composite logic left to higher-level tooling per the thin-wrapper rule. Also drop the now-unused repeatingInvoiceId path from the create handler's builder. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../create-xero-repeating-invoice.handler.ts | 7 +- .../delete-xero-repeating-invoice.handler.ts | 53 +++++++++ .../update-xero-repeating-invoice.handler.ts | 60 ---------- .../delete/delete-repeating-invoice.tool.ts | 47 ++++++++ src/tools/delete/index.ts | 4 +- src/tools/update/index.ts | 4 +- .../update/update-repeating-invoice.tool.ts | 105 ------------------ 7 files changed, 107 insertions(+), 173 deletions(-) create mode 100644 src/handlers/delete-xero-repeating-invoice.handler.ts delete mode 100644 src/handlers/update-xero-repeating-invoice.handler.ts create mode 100644 src/tools/delete/delete-repeating-invoice.tool.ts delete mode 100644 src/tools/update/update-repeating-invoice.tool.ts diff --git a/src/handlers/create-xero-repeating-invoice.handler.ts b/src/handlers/create-xero-repeating-invoice.handler.ts index 5c9cd0ef..61615dbe 100644 --- a/src/handlers/create-xero-repeating-invoice.handler.ts +++ b/src/handlers/create-xero-repeating-invoice.handler.ts @@ -62,7 +62,7 @@ export interface CreateRepeatingInvoiceInput { includePDF?: boolean; } -export function buildSchedule(input: RepeatingInvoiceScheduleInput): Schedule { +function buildSchedule(input: RepeatingInvoiceScheduleInput): Schedule { return { period: input.period, unit: Schedule.UnitEnum[input.unit as keyof typeof Schedule.UnitEnum], @@ -77,11 +77,10 @@ export function buildSchedule(input: RepeatingInvoiceScheduleInput): Schedule { }; } -export function buildRepeatingInvoice( - input: CreateRepeatingInvoiceInput & { repeatingInvoiceId?: string }, +function buildRepeatingInvoice( + input: CreateRepeatingInvoiceInput, ): RepeatingInvoice { return { - repeatingInvoiceID: input.repeatingInvoiceId, type: RepeatingInvoice.TypeEnum[ (input.type ?? "ACCREC") as keyof typeof RepeatingInvoice.TypeEnum diff --git a/src/handlers/delete-xero-repeating-invoice.handler.ts b/src/handlers/delete-xero-repeating-invoice.handler.ts new file mode 100644 index 00000000..6dc88c74 --- /dev/null +++ b/src/handlers/delete-xero-repeating-invoice.handler.ts @@ -0,0 +1,53 @@ +import { xeroClient } from "../clients/xero-client.js"; +import { XeroClientResponse } from "../types/tool-response.js"; +import { formatError } from "../helpers/format-error.js"; +import { RepeatingInvoice } from "xero-node"; +import { getClientHeaders } from "../helpers/get-client-headers.js"; + +/** + * Delete a repeating-invoice template in Xero. + * + * Xero has no DELETE endpoint for repeating invoices. Deletion is a POST to + * /RepeatingInvoices carrying the template's ID and status=DELETED. (The API also does + * not support editing an existing template — POST-with-ID is only valid for deletion — + * so create and delete are the only mutations available.) + */ +export async function deleteXeroRepeatingInvoice( + repeatingInvoiceId: string, +): Promise> { + try { + await xeroClient.authenticate(); + + const response = await xeroClient.accountingApi.updateOrCreateRepeatingInvoices( + xeroClient.tenantId, + { + repeatingInvoices: [ + { + repeatingInvoiceID: repeatingInvoiceId, + status: RepeatingInvoice.StatusEnum.DELETED, + }, + ], + }, + true, // summarizeErrors + undefined, // idempotencyKey + getClientHeaders(), + ); + + const deleted = response.body.repeatingInvoices?.[0]; + if (!deleted) { + throw new Error("Repeating invoice deletion failed."); + } + + return { + result: deleted, + isError: false, + error: null, + }; + } catch (error) { + return { + result: null, + isError: true, + error: formatError(error), + }; + } +} diff --git a/src/handlers/update-xero-repeating-invoice.handler.ts b/src/handlers/update-xero-repeating-invoice.handler.ts deleted file mode 100644 index 44e1b87f..00000000 --- a/src/handlers/update-xero-repeating-invoice.handler.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { xeroClient } from "../clients/xero-client.js"; -import { XeroClientResponse } from "../types/tool-response.js"; -import { formatError } from "../helpers/format-error.js"; -import { RepeatingInvoice } from "xero-node"; -import { getClientHeaders } from "../helpers/get-client-headers.js"; -import { - buildRepeatingInvoice, - CreateRepeatingInvoiceInput, -} from "./create-xero-repeating-invoice.handler.js"; - -export type UpdateRepeatingInvoiceInput = Omit< - CreateRepeatingInvoiceInput, - "status" -> & { - repeatingInvoiceId: string; - status?: "DRAFT" | "AUTHORISED" | "DELETED"; -}; - -/** - * Update a repeating-invoice template in Xero. Full replace (POST-with-ID): every field - * is re-sent, so line items and schedule not provided are wiped. Pass status="DELETED" to - * delete the template (there is no separate delete endpoint). - */ -export async function updateXeroRepeatingInvoice( - input: UpdateRepeatingInvoiceInput, -): Promise> { - try { - await xeroClient.authenticate(); - - const repeatingInvoice = buildRepeatingInvoice( - input as CreateRepeatingInvoiceInput & { repeatingInvoiceId: string }, - ); - - const response = - await xeroClient.accountingApi.updateOrCreateRepeatingInvoices( - xeroClient.tenantId, - { repeatingInvoices: [repeatingInvoice] }, - true, // summarizeErrors - undefined, // idempotencyKey - getClientHeaders(), - ); - - const updated = response.body.repeatingInvoices?.[0]; - if (!updated) { - throw new Error("Repeating invoice update failed."); - } - - return { - result: updated, - isError: false, - error: null, - }; - } catch (error) { - return { - result: null, - isError: true, - error: formatError(error), - }; - } -} diff --git a/src/tools/delete/delete-repeating-invoice.tool.ts b/src/tools/delete/delete-repeating-invoice.tool.ts new file mode 100644 index 00000000..58bbef09 --- /dev/null +++ b/src/tools/delete/delete-repeating-invoice.tool.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; +import { deleteXeroRepeatingInvoice } from "../../handlers/delete-xero-repeating-invoice.handler.js"; +import { CreateXeroTool } from "../../helpers/create-xero-tool.js"; + +const DeleteRepeatingInvoiceTool = CreateXeroTool( + "delete-repeating-invoice", + `Delete a repeating-invoice template in Xero by its ID (sets its status to DELETED). + Note: Xero does not support editing a repeating-invoice template via the API. To change a + template, delete it and create a new one with create-repeating-invoice.`, + { + repeatingInvoiceId: z + .string() + .describe( + "The ID of the repeating-invoice template to delete. Can be obtained from list-repeating-invoices.", + ), + }, + async ({ repeatingInvoiceId }) => { + const response = await deleteXeroRepeatingInvoice(repeatingInvoiceId); + + if (response.isError) { + return { + content: [ + { + type: "text" as const, + text: `Error deleting repeating invoice: ${response.error}`, + }, + ], + }; + } + + const ri = response.result; + + return { + content: [ + { + type: "text" as const, + text: [ + `Successfully deleted repeating invoice ${repeatingInvoiceId}`, + `Status: ${ri?.status}`, + ].join("\n"), + }, + ], + }; + }, +); + +export default DeleteRepeatingInvoiceTool; diff --git a/src/tools/delete/index.ts b/src/tools/delete/index.ts index b3d11429..8bf87b6c 100644 --- a/src/tools/delete/index.ts +++ b/src/tools/delete/index.ts @@ -1,5 +1,7 @@ import DeletePayrollTimesheetTool from "./delete-payroll-timesheet.tool.js"; +import DeleteRepeatingInvoiceTool from "./delete-repeating-invoice.tool.js"; export const DeleteTools = [ - DeletePayrollTimesheetTool + DeletePayrollTimesheetTool, + DeleteRepeatingInvoiceTool ]; diff --git a/src/tools/update/index.ts b/src/tools/update/index.ts index a9e6ff84..d198d69c 100644 --- a/src/tools/update/index.ts +++ b/src/tools/update/index.ts @@ -12,7 +12,6 @@ import UpdateManualJournalTool from "./update-manual-journal-tool.js"; import UpdateQuoteTool from "./update-quote.tool.js"; import UpdateTrackingCategoryTool from "./update-tracking-category.tool.js"; import UpdateTrackingOptionsTool from "./update-tracking-options.tool.js"; -import UpdateRepeatingInvoiceTool from "./update-repeating-invoice.tool.js"; export const UpdateTools = [ UpdateContactTool, @@ -27,6 +26,5 @@ export const UpdateTools = [ UpdatePayrollTimesheetLineTool, RevertPayrollTimesheetTool, UpdateTrackingCategoryTool, - UpdateTrackingOptionsTool, - UpdateRepeatingInvoiceTool + UpdateTrackingOptionsTool ]; diff --git a/src/tools/update/update-repeating-invoice.tool.ts b/src/tools/update/update-repeating-invoice.tool.ts deleted file mode 100644 index 0cc7e37a..00000000 --- a/src/tools/update/update-repeating-invoice.tool.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { z } from "zod"; -import { updateXeroRepeatingInvoice } from "../../handlers/update-xero-repeating-invoice.handler.js"; -import { CreateXeroTool } from "../../helpers/create-xero-tool.js"; - -const trackingSchema = z.object({ - name: z.string().describe("The name of the tracking category. Can be obtained from the list-tracking-categories tool"), - option: z.string().describe("The name of the tracking option. Can be obtained from the list-tracking-categories tool"), - trackingCategoryID: z.string().describe("The ID of the tracking category. \ - Can be obtained from the list-tracking-categories tool"), -}); - -const lineItemSchema = z.object({ - description: z.string().describe("The description of the line item"), - quantity: z.number().describe("The quantity of the line item"), - unitAmount: z.number().describe("The price per unit of the line item"), - accountCode: z.string().describe("The account code of the line item - can be obtained from the list-accounts tool"), - taxType: z.string().describe("The tax type of the line item - can be obtained from the list-tax-rates tool"), - itemCode: z.string().describe("The item code of the line item - can be obtained from the list-items tool \ - If the item was not populated in the original template, \ - add without an item code unless the user has told you to add an item code.").optional(), - tracking: z.array(trackingSchema).describe("Up to 2 tracking categories and options can be added to the line item. \ - Can be obtained from the list-tracking-categories tool. \ - Only use if prompted by the user.").optional(), -}); - -const scheduleSchema = z.object({ - period: z.number().describe("How many units between invoices, e.g. 1 (every 1) or 2 (every 2)."), - unit: z.enum(["WEEKLY", "MONTHLY"]).describe("The repeat unit."), - startDate: z.string().describe("Date the first invoice is generated (YYYY-MM-DD)."), - dueDate: z.number().describe("Payment-terms value used with dueDateType, e.g. 20 or 31.").optional(), - dueDateType: z - .enum([ - "DAYSAFTERBILLDATE", - "DAYSAFTERBILLMONTH", - "DAYSAFTERINVOICEDATE", - "DAYSAFTERINVOICEMONTH", - "OFCURRENTMONTH", - "OFFOLLOWINGMONTH", - ]) - .describe("Payment-terms type used with dueDate.") - .optional(), - endDate: z.string().describe("Optional date the schedule ends (YYYY-MM-DD).").optional(), -}); - -const UpdateRepeatingInvoiceTool = CreateXeroTool( - "update-repeating-invoice", - "Update a repeating-invoice template in Xero. This is a FULL REPLACE: all fields are re-sent. \ -All line items must be provided - any line items not provided will be removed, including existing ones. \ -Read the current template with get-repeating-invoice first, edit it, then resubmit the whole thing. \ -Pass status=\"DELETED\" to delete the template.", - { - repeatingInvoiceId: z.string().describe("The ID of the repeating invoice to update. Can be obtained from list-repeating-invoices."), - contactId: z.string().describe("The ID of the contact for the template. Can be obtained from the list-contacts tool."), - schedule: scheduleSchema, - lineItems: z.array(lineItemSchema).describe("All line items must be provided. Any not provided will be removed."), - type: z.enum(["ACCREC", "ACCPAY"]).describe("ACCREC for sales templates, ACCPAY for bill templates. Default ACCREC.").optional(), - status: z.enum(["DRAFT", "AUTHORISED", "DELETED"]).describe("DRAFT, AUTHORISED, or DELETED (to delete the template).").optional(), - reference: z.string().describe("A reference for the generated invoices.").optional(), - brandingThemeId: z.string().describe("Optional branding theme ID.").optional(), - currencyCode: z.string().describe("Optional ISO currency code, e.g. GBP.").optional(), - lineAmountType: z.enum(["EXCLUSIVE", "INCLUSIVE", "NO_TAX"]).describe("Whether line amounts are tax exclusive, inclusive, or no tax.").optional(), - approvedForSending: z.boolean().describe("Whether Xero emails the generated invoice to the contact.").optional(), - sendCopy: z.boolean().describe("Whether to send a copy to the sender's email.").optional(), - markAsSent: z.boolean().describe("Whether to mark the generated invoice as sent.").optional(), - includePDF: z.boolean().describe("Whether to attach a PDF to the emailed invoice.").optional(), - }, - async (params) => { - const result = await updateXeroRepeatingInvoice(params); - - if (result.isError) { - return { - content: [ - { - type: "text" as const, - text: `Error updating repeating invoice: ${result.error}`, - }, - ], - }; - } - - const ri = result.result; - - return { - content: [ - { - type: "text" as const, - text: [ - "Repeating invoice updated successfully:", - `ID: ${ri?.repeatingInvoiceID}`, - `Type: ${ri?.type}`, - `Status: ${ri?.status}`, - `Contact: ${ri?.contact?.name}`, - ri?.schedule ? `Schedule: every ${ri.schedule.period} ${ri.schedule.unit}` : null, - ri?.schedule?.nextScheduledDate ? `Next Scheduled: ${ri.schedule.nextScheduledDate}` : null, - ri?.total != null ? `Total: ${ri.total}` : null, - ] - .filter(Boolean) - .join("\n"), - }, - ], - }; - }, -); - -export default UpdateRepeatingInvoiceTool;