Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ payroll.timesheets
- `create-tracking-category`: Create a new tracking category
- `create-tracking-option`: Create a new tracking option
- `update-bank-transaction`: Update an existing bank transaction
- `add-invoice-note`: Add a history note to an invoice
- `update-contact`: Update an existing contact
- `update-invoice`: Update an existing draft invoice
- `update-item`: Update an existing item
Expand Down
49 changes: 49 additions & 0 deletions src/handlers/add-xero-invoice-note.handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const authenticateMock = vi.fn();
const createInvoiceHistoryMock = vi.fn();
const getClientHeadersMock = vi.fn(() => ({ "x-test": "header" }));

vi.mock("../clients/xero-client.js", () => ({
xeroClient: {
tenantId: "tenant-123",
authenticate: authenticateMock,
accountingApi: {
createInvoiceHistory: createInvoiceHistoryMock,
},
},
}));

vi.mock("../helpers/get-client-headers.js", () => ({
getClientHeaders: getClientHeadersMock,
}));

describe("addXeroInvoiceNote", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("creates an invoice history record with the provided note", async () => {
createInvoiceHistoryMock.mockResolvedValue({
body: {
historyRecords: [{ details: "Audit note" }],
},
});

const { addXeroInvoiceNote } = await import("./add-xero-invoice-note.handler.js");

const result = await addXeroInvoiceNote("invoice-1", "Audit note");

expect(result.isError).toBe(false);
expect(result.result).toBe("Audit note");
expect(createInvoiceHistoryMock).toHaveBeenCalledWith(
"tenant-123",
"invoice-1",
{
historyRecords: [{ details: "Audit note" }],
},
undefined,
{ "x-test": "header" },
);
});
});
48 changes: 48 additions & 0 deletions src/handlers/add-xero-invoice-note.handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { xeroClient } from "../clients/xero-client.js";
import { formatError } from "../helpers/format-error.js";
import { getClientHeaders } from "../helpers/get-client-headers.js";
import { XeroClientResponse } from "../types/tool-response.js";

async function addInvoiceNote(
invoiceId: string,
note: string,
): Promise<string | undefined> {
await xeroClient.authenticate();

const response = await xeroClient.accountingApi.createInvoiceHistory(
xeroClient.tenantId,
invoiceId,
{
historyRecords: [{ details: note }],
},
undefined,
getClientHeaders(),
);

return response.body.historyRecords?.[0]?.details;
}

export async function addXeroInvoiceNote(
invoiceId: string,
note: string,
): Promise<XeroClientResponse<string>> {
try {
const createdNote = await addInvoiceNote(invoiceId, note);

if (!createdNote) {
throw new Error("Invoice note creation failed.");
}

return {
result: createdNote,
isError: false,
error: null,
};
} catch (error) {
return {
result: null,
isError: true,
error: formatError(error),
};
}
}
71 changes: 71 additions & 0 deletions src/handlers/create-xero-invoice.handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CurrencyCode, Invoice } from "xero-node";

const authenticateMock = vi.fn();
const createInvoicesMock = vi.fn();
const getClientHeadersMock = vi.fn(() => ({ "x-test": "header" }));

vi.mock("../clients/xero-client.js", () => ({
xeroClient: {
tenantId: "tenant-123",
authenticate: authenticateMock,
accountingApi: {
createInvoices: createInvoicesMock,
},
},
}));

vi.mock("../helpers/get-client-headers.js", () => ({
getClientHeaders: getClientHeadersMock,
}));

describe("createXeroInvoice", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("passes currencyCode and currencyRate through to the created invoice", async () => {
createInvoicesMock.mockResolvedValue({
body: {
invoices: [{ invoiceID: "inv-1", currencyCode: "EUR", currencyRate: 1.67 }],
},
});

const { createXeroInvoice } = await import("./create-xero-invoice.handler.js");

const result = await createXeroInvoice(
"contact-1",
[
{
description: "Consulting",
quantity: 1,
unitAmount: 120,
accountCode: "200",
taxType: "NONE",
},
],
Invoice.TypeEnum.ACCPAY,
"BILL-001",
"2026-06-15",
"EUR" as unknown as CurrencyCode,
1.67,
);

expect(result.isError).toBe(false);
expect(createInvoicesMock).toHaveBeenCalledWith(
"tenant-123",
{
invoices: [
expect.objectContaining({
currencyCode: "EUR",
currencyRate: 1.67,
}),
],
},
true,
undefined,
undefined,
{ "x-test": "header" },
);
});
});
10 changes: 9 additions & 1 deletion src/handlers/create-xero-invoice.handler.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { xeroClient } from "../clients/xero-client.js";
import { XeroClientResponse } from "../types/tool-response.js";
import { formatError } from "../helpers/format-error.js";
import { Invoice, LineItemTracking } from "xero-node";
import { CurrencyCode, Invoice, LineItemTracking } from "xero-node";
import { getClientHeaders } from "../helpers/get-client-headers.js";

interface InvoiceLineItem {
Expand All @@ -20,6 +20,8 @@ async function createInvoice(
type: Invoice.TypeEnum,
reference: string | undefined,
date: string | undefined,
currencyCode: CurrencyCode | undefined,
currencyRate: number | undefined,
): Promise<Invoice | undefined> {
await xeroClient.authenticate();

Expand All @@ -37,6 +39,8 @@ async function createInvoice(
? { invoiceNumber: reference }
: { reference: reference }),
status: Invoice.StatusEnum.DRAFT,
currencyCode,
currencyRate,
};

const response = await xeroClient.accountingApi.createInvoices(
Expand All @@ -62,6 +66,8 @@ export async function createXeroInvoice(
type: Invoice.TypeEnum = Invoice.TypeEnum.ACCREC,
reference?: string,
date?: string,
currencyCode?: CurrencyCode,
currencyRate?: number,
): Promise<XeroClientResponse<Invoice>> {
try {
const createdInvoice = await createInvoice(
Expand All @@ -70,6 +76,8 @@ export async function createXeroInvoice(
type,
reference,
date,
currencyCode,
currencyRate,
);

if (!createdInvoice) {
Expand Down
63 changes: 63 additions & 0 deletions src/handlers/list-xero-invoices.handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const authenticateMock = vi.fn();
const getInvoicesMock = vi.fn();
const getClientHeadersMock = vi.fn(() => ({ "x-test": "header" }));

vi.mock("../clients/xero-client.js", () => ({
xeroClient: {
tenantId: "tenant-123",
authenticate: authenticateMock,
accountingApi: {
getInvoices: getInvoicesMock,
},
},
}));

vi.mock("../helpers/get-client-headers.js", () => ({
getClientHeaders: getClientHeadersMock,
}));

describe("listXeroInvoices", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("forwards statuses to the Xero invoices endpoint", async () => {
const invoices = [{ invoiceID: "inv-1", status: "AUTHORISED" }];

getInvoicesMock.mockResolvedValue({
body: { invoices },
});

const { listXeroInvoices } = await import("./list-xero-invoices.handler.js");

const result = await listXeroInvoices(
2,
["contact-1"],
["INV-001"],
["AUTHORISED", "PAID"],
);

expect(result.isError).toBe(false);
expect(result.result).toBe(invoices);
expect(getInvoicesMock).toHaveBeenCalledWith(
"tenant-123",
undefined,
undefined,
"UpdatedDateUTC DESC",
undefined,
["INV-001"],
["contact-1"],
["AUTHORISED", "PAID"],
2,
false,
false,
undefined,
false,
10,
undefined,
{ "x-test": "header" },
);
});
});
6 changes: 4 additions & 2 deletions src/handlers/list-xero-invoices.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getClientHeaders } from "../helpers/get-client-headers.js";
async function getInvoices(
invoiceNumbers: string[] | undefined,
contactIds: string[] | undefined,
statuses: string[] | undefined,
page: number,
): Promise<Invoice[]> {
await xeroClient.authenticate();
Expand All @@ -19,7 +20,7 @@ async function getInvoices(
undefined, // iDs
invoiceNumbers, // invoiceNumbers
contactIds, // contactIDs
undefined, // statuses
statuses, // statuses
page,
false, // includeArchived
false, // createdByMyApp
Expand All @@ -39,9 +40,10 @@ export async function listXeroInvoices(
page: number = 1,
contactIds?: string[],
invoiceNumbers?: string[],
statuses?: string[],
): Promise<XeroClientResponse<Invoice[]>> {
try {
const invoices = await getInvoices(invoiceNumbers, contactIds, page);
const invoices = await getInvoices(invoiceNumbers, contactIds, statuses, page);

return {
result: invoices,
Expand Down
70 changes: 70 additions & 0 deletions src/handlers/list-xero-profit-and-loss.handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const authenticateMock = vi.fn();
const getReportProfitAndLossMock = vi.fn();
const getClientHeadersMock = vi.fn(() => ({ "x-test": "header" }));

vi.mock("../clients/xero-client.js", () => ({
xeroClient: {
tenantId: "tenant-123",
authenticate: authenticateMock,
accountingApi: {
getReportProfitAndLoss: getReportProfitAndLossMock,
},
},
}));

vi.mock("../helpers/get-client-headers.js", () => ({
getClientHeaders: getClientHeadersMock,
}));

describe("listXeroProfitAndLoss", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("forwards tracking options, standardLayout, and paymentsOnly to the SDK in the correct positions", async () => {
const report = {
reportName: "Profit and Loss",
reportDate: "2026-06-12",
rows: [],
};

getReportProfitAndLossMock.mockResolvedValue({
body: { reports: [report] },
});

const { listXeroProfitAndLoss } = await import(
"./list-xero-profit-and-loss.handler.js"
);

const result = await listXeroProfitAndLoss(
"2026-01-01",
"2026-01-31",
1,
"MONTH",
"track-opt-1",
"track-opt-2",
true,
true,
);

expect(result.isError).toBe(false);
expect(result.result).toBe(report);
expect(authenticateMock).toHaveBeenCalledTimes(1);
expect(getReportProfitAndLossMock).toHaveBeenCalledWith(
"tenant-123",
"2026-01-01",
"2026-01-31",
1,
"MONTH",
undefined,
undefined,
"track-opt-1",
"track-opt-2",
true,
true,
{ "x-test": "header" },
);
});
});
Loading