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
898 changes: 495 additions & 403 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"@modelcontextprotocol/sdk": "^1.23.4",
"dotenv": "^16.4.7",
"openid-client": "^6.8.1",
"xero-node": "^13.3.0",
"xero-node": "^15.0.1",
"zod": "3.25"
},
"devDependencies": {
Expand Down
29 changes: 28 additions & 1 deletion src/clients/xero-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
TokenSet,
XeroClient,
} from "xero-node";
import { PayrollAuV2Api } from "xero-node/dist/gen/api/payrollAUV2Api.js";

import { ensureError } from "../helpers/ensure-error.js";

Expand All @@ -20,9 +21,20 @@ if (!bearer_token && (!client_id || !client_secret)) {
throw Error("Environment Variables not set - please check your .env file");
}

export type PayrollRegion = "AU" | "NZ" | "UK";

abstract class MCPXeroClient extends XeroClient {
public tenantId: string;
private shortCode: string;
private _region: PayrollRegion | null = null;
private _payrollAUV2Api: PayrollAuV2Api | null = null;

get payrollAUV2Api(): PayrollAuV2Api {
if (!this._payrollAUV2Api) {
this._payrollAUV2Api = new PayrollAuV2Api();
}
return this._payrollAUV2Api;
}

protected constructor(config?: IXeroClientConfig) {
super(config);
Expand All @@ -41,7 +53,7 @@ abstract class MCPXeroClient extends XeroClient {
return this.tenants;
}

private async getOrganisation(): Promise<Organisation> {
public async getOrganisation(): Promise<Organisation> {
await this.authenticate();

const organisationResponse = await this.accountingApi.getOrganisations(
Expand Down Expand Up @@ -72,6 +84,17 @@ abstract class MCPXeroClient extends XeroClient {
}
return this.shortCode;
}

public async getRegion(): Promise<PayrollRegion> {
if (!this._region) {
const org = await this.getOrganisation();
const code = String(org.countryCode ?? "");
if (code === "AU") this._region = "AU";
else if (code === "GB") this._region = "UK";
else this._region = "NZ";
}
return this._region;
}
}

class CustomConnectionsXeroClient extends MCPXeroClient {
Expand Down Expand Up @@ -200,6 +223,8 @@ class CustomConnectionsXeroClient extends MCPXeroClient {
expires_in: tokenResponse.expires_in,
token_type: tokenResponse.token_type,
});

this.payrollAUV2Api.accessToken = tokenResponse.access_token ?? "";
}
}

Expand All @@ -216,6 +241,8 @@ class BearerTokenXeroClient extends MCPXeroClient {
access_token: this.bearerToken,
});

this.payrollAUV2Api.accessToken = this.bearerToken;

await this.updateTenants();
}
}
Expand Down
112 changes: 112 additions & 0 deletions src/handlers/__tests__/add-timesheet-line.handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { vi, describe, it, expect, beforeEach } from "vitest";

const { mockXeroClient } = vi.hoisted(() => ({
mockXeroClient: {
authenticate: vi.fn(),
getRegion: vi.fn(),
tenantId: "test-tenant-id",
payrollAUV2Api: { createTimesheetLine: vi.fn() },
payrollNZApi: { createTimesheetLine: vi.fn() },
payrollUKApi: { createTimesheetLine: vi.fn() },
},
}));

vi.mock("../../clients/xero-client.js", () => ({
xeroClient: mockXeroClient,
}));

import { updateXeroPayrollTimesheetAddLine } from "../update-xero-payroll-timesheet-add-line.handler.js";

beforeEach(() => {
vi.clearAllMocks();
});

describe("updateXeroPayrollTimesheetAddLine", () => {
describe("AU region", () => {
beforeEach(() => {
mockXeroClient.getRegion.mockResolvedValue("AU");
});

it("calls payrollAUV2Api.createTimesheetLine", async () => {
mockXeroClient.payrollAUV2Api.createTimesheetLine.mockResolvedValue({
body: { timesheetLine: { timesheetLineID: "line-1", earningsRateID: "rate-1" } },
});

const result = await updateXeroPayrollTimesheetAddLine({
timesheetID: "ts-1",
earningsRateID: "rate-1",
numberOfUnits: 8,
date: "2024-01-01",
});

expect(mockXeroClient.payrollAUV2Api.createTimesheetLine).toHaveBeenCalledWith(
"test-tenant-id",
"ts-1",
expect.objectContaining({
earningsRateID: "rate-1",
numberOfUnits: 8,
date: "2024-01-01",
}),
);
expect(result.isError).toBe(false);
});
});

describe("NZ region", () => {
beforeEach(() => {
mockXeroClient.getRegion.mockResolvedValue("NZ");
});

it("calls payrollNZApi.createTimesheetLine with date and single numberOfUnits", async () => {
mockXeroClient.payrollNZApi.createTimesheetLine.mockResolvedValue({
body: { timesheetLine: { timesheetLineID: "line-1" } },
});

await updateXeroPayrollTimesheetAddLine({
timesheetID: "ts-1",
earningsRateID: "rate-1",
numberOfUnits: 8,
date: "2024-01-01",
});

const line = mockXeroClient.payrollNZApi.createTimesheetLine.mock.calls[0][2];
expect(line.earningsRateID).toBe("rate-1");
expect(line.numberOfUnits).toBe(8);
expect(line.date).toBe("2024-01-01");
});
});

describe("UK region", () => {
it("calls payrollUKApi.createTimesheetLine", async () => {
mockXeroClient.getRegion.mockResolvedValue("UK");
mockXeroClient.payrollUKApi.createTimesheetLine.mockResolvedValue({
body: { timesheetLine: { timesheetLineID: "line-1" } },
});

await updateXeroPayrollTimesheetAddLine({
timesheetID: "ts-1",
earningsRateID: "rate-1",
numberOfUnits: 8,
date: "2024-01-01",
});

expect(mockXeroClient.payrollUKApi.createTimesheetLine).toHaveBeenCalled();
expect(mockXeroClient.payrollNZApi.createTimesheetLine).not.toHaveBeenCalled();
});
});

it("returns error response on API failure", async () => {
mockXeroClient.getRegion.mockResolvedValue("NZ");
mockXeroClient.payrollNZApi.createTimesheetLine.mockRejectedValue(new Error("Invalid line"));

const result = await updateXeroPayrollTimesheetAddLine({
timesheetID: "ts-1",
earningsRateID: "rate-1",
numberOfUnits: 8,
date: "2024-01-01",
});

expect(result.isError).toBe(true);
expect(result.error).toBe("Invalid line");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { vi, describe, it, expect, beforeEach } from "vitest";

const { mockXeroClient } = vi.hoisted(() => ({
mockXeroClient: {
authenticate: vi.fn(),
getRegion: vi.fn(),
tenantId: "test-tenant-id",
payrollAUV2Api: { approveTimesheet: vi.fn() },
payrollNZApi: { approveTimesheet: vi.fn() },
payrollUKApi: { approveTimesheet: vi.fn() },
},
}));

vi.mock("../../clients/xero-client.js", () => ({
xeroClient: mockXeroClient,
}));

import { approveXeroPayrollTimesheet } from "../approve-xero-payroll-timesheet.handler.js";

beforeEach(() => {
vi.clearAllMocks();
});

describe("approveXeroPayrollTimesheet", () => {
describe("AU region", () => {
beforeEach(() => {
mockXeroClient.getRegion.mockResolvedValue("AU");
});

it("calls payrollAUV2Api.approveTimesheet", async () => {
mockXeroClient.payrollAUV2Api.approveTimesheet.mockResolvedValue({
body: { timesheet: { timesheetID: "ts-1", status: "Approved" } },
});

const result = await approveXeroPayrollTimesheet("ts-1");

expect(mockXeroClient.payrollAUV2Api.approveTimesheet).toHaveBeenCalledWith("test-tenant-id", "ts-1");
expect(result.isError).toBe(false);
expect(result.result?.timesheetID).toBe("ts-1");
});
});

describe("NZ region", () => {
it("calls payrollNZApi.approveTimesheet", async () => {
mockXeroClient.getRegion.mockResolvedValue("NZ");
mockXeroClient.payrollNZApi.approveTimesheet.mockResolvedValue({
body: { timesheet: { timesheetID: "ts-1" } },
});

await approveXeroPayrollTimesheet("ts-1");

expect(mockXeroClient.payrollNZApi.approveTimesheet).toHaveBeenCalledWith("test-tenant-id", "ts-1");
});
});

describe("UK region", () => {
it("calls payrollUKApi.approveTimesheet", async () => {
mockXeroClient.getRegion.mockResolvedValue("UK");
mockXeroClient.payrollUKApi.approveTimesheet.mockResolvedValue({
body: { timesheet: { timesheetID: "ts-1" } },
});

await approveXeroPayrollTimesheet("ts-1");

expect(mockXeroClient.payrollUKApi.approveTimesheet).toHaveBeenCalledWith("test-tenant-id", "ts-1");
expect(mockXeroClient.payrollNZApi.approveTimesheet).not.toHaveBeenCalled();
});
});

it("returns error response on API failure", async () => {
mockXeroClient.getRegion.mockResolvedValue("NZ");
mockXeroClient.payrollNZApi.approveTimesheet.mockRejectedValue(new Error("Already approved"));

const result = await approveXeroPayrollTimesheet("ts-1");
expect(result.isError).toBe(true);
expect(result.error).toBe("Already approved");
});
});
Loading