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..61615dbe --- /dev/null +++ b/src/handlers/create-xero-repeating-invoice.handler.ts @@ -0,0 +1,144 @@ +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; +} + +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, + }; +} + +function buildRepeatingInvoice( + input: CreateRepeatingInvoiceInput, +): RepeatingInvoice { + return { + 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/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/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/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/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 ]; 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/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, ]; 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;