From 38ed53e97d87250e9e2499e3784e57508c2a7cb1 Mon Sep 17 00:00:00 2001 From: Elliot Chisholm Date: Fri, 24 Apr 2026 20:33:35 +1200 Subject: [PATCH 01/13] Add import shim layer for payroll types Centralize all deep-path xero-node imports (xero-node/dist/gen/model/...) into two shim files: payroll-nz-types.ts and payroll-au-types.ts. This isolates fragile import sites to 2 files, de-risking the upcoming SDK upgrade from v13 to v15. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../list-xero-payroll-employee-leave-balances.handler.ts | 2 +- .../list-xero-payroll-employee-leave-types.handler.ts | 2 +- src/handlers/list-xero-payroll-employee-leave.handler.ts | 2 +- src/handlers/list-xero-payroll-employees.handler.ts | 2 +- src/handlers/list-xero-payroll-leave-periods.handler.ts | 2 +- src/handlers/list-xero-payroll-leave-types.handler.ts | 2 +- .../list/list-payroll-employee-leave-balances.tool.ts | 2 +- src/tools/list/list-payroll-employee-leave-types.tool.ts | 6 +++--- src/tools/list/list-payroll-employee-leave.tool.ts | 2 +- src/tools/list/list-payroll-employees.tool.ts | 2 +- src/tools/list/list-payroll-leave-periods.tool.ts | 2 +- src/tools/list/list-payroll-leave-types.tool.ts | 2 +- src/types/payroll-au-types.ts | 3 +++ src/types/payroll-nz-types.ts | 8 ++++++++ 14 files changed, 25 insertions(+), 14 deletions(-) create mode 100644 src/types/payroll-au-types.ts create mode 100644 src/types/payroll-nz-types.ts diff --git a/src/handlers/list-xero-payroll-employee-leave-balances.handler.ts b/src/handlers/list-xero-payroll-employee-leave-balances.handler.ts index 17bc709c..05b9f922 100644 --- a/src/handlers/list-xero-payroll-employee-leave-balances.handler.ts +++ b/src/handlers/list-xero-payroll-employee-leave-balances.handler.ts @@ -2,7 +2,7 @@ import { xeroClient } from "../clients/xero-client.js"; import { XeroClientResponse } from "../types/tool-response.js"; import { formatError } from "../helpers/format-error.js"; import { getClientHeaders } from "../helpers/get-client-headers.js"; -import { EmployeeLeaveBalance } from "xero-node/dist/gen/model/payroll-nz/employeeLeaveBalance.js"; +import { EmployeeLeaveBalance } from "../types/payroll-nz-types.js"; /** * Internal function to fetch employee leave balances from Xero diff --git a/src/handlers/list-xero-payroll-employee-leave-types.handler.ts b/src/handlers/list-xero-payroll-employee-leave-types.handler.ts index 00461431..df62fd26 100644 --- a/src/handlers/list-xero-payroll-employee-leave-types.handler.ts +++ b/src/handlers/list-xero-payroll-employee-leave-types.handler.ts @@ -2,7 +2,7 @@ import { xeroClient } from "../clients/xero-client.js"; import { XeroClientResponse } from "../types/tool-response.js"; import { formatError } from "../helpers/format-error.js"; import { getClientHeaders } from "../helpers/get-client-headers.js"; -import { EmployeeLeaveType } from "xero-node/dist/gen/model/payroll-nz/employeeLeaveType.js"; +import { EmployeeLeaveType } from "../types/payroll-nz-types.js"; /** * Internal function to fetch employee leave types from Xero diff --git a/src/handlers/list-xero-payroll-employee-leave.handler.ts b/src/handlers/list-xero-payroll-employee-leave.handler.ts index 2c9247e9..40b0aecb 100644 --- a/src/handlers/list-xero-payroll-employee-leave.handler.ts +++ b/src/handlers/list-xero-payroll-employee-leave.handler.ts @@ -3,7 +3,7 @@ import { XeroClientResponse } from "../types/tool-response.js"; import { formatError } from "../helpers/format-error.js"; import { getClientHeaders } from "../helpers/get-client-headers.js"; // Import the correct types - using the proper namespace -import { EmployeeLeave } from "xero-node/dist/gen/model/payroll-nz/employeeLeave.js"; +import { EmployeeLeave } from "../types/payroll-nz-types.js"; interface FetchEmployeeLeaveParams { employeeId?: string; diff --git a/src/handlers/list-xero-payroll-employees.handler.ts b/src/handlers/list-xero-payroll-employees.handler.ts index 8c6040fb..7ba40885 100644 --- a/src/handlers/list-xero-payroll-employees.handler.ts +++ b/src/handlers/list-xero-payroll-employees.handler.ts @@ -2,7 +2,7 @@ import { xeroClient } from "../clients/xero-client.js"; import { XeroClientResponse } from "../types/tool-response.js"; import { formatError } from "../helpers/format-error.js"; import { getClientHeaders } from "../helpers/get-client-headers.js"; -import { Employee } from "xero-node/dist/gen/model/payroll-nz/employee.js"; +import { Employee } from "../types/payroll-nz-types.js"; async function getPayrollEmployees(): Promise { await xeroClient.authenticate(); diff --git a/src/handlers/list-xero-payroll-leave-periods.handler.ts b/src/handlers/list-xero-payroll-leave-periods.handler.ts index 23ab5e55..81824e10 100644 --- a/src/handlers/list-xero-payroll-leave-periods.handler.ts +++ b/src/handlers/list-xero-payroll-leave-periods.handler.ts @@ -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 { LeavePeriod } from "xero-node/dist/gen/model/payroll-nz/leavePeriod.js"; +import { LeavePeriod } from "../types/payroll-nz-types.js"; interface FetchLeavePeriodParams { employeeId?: string; diff --git a/src/handlers/list-xero-payroll-leave-types.handler.ts b/src/handlers/list-xero-payroll-leave-types.handler.ts index a4b23f8b..833fb2e2 100644 --- a/src/handlers/list-xero-payroll-leave-types.handler.ts +++ b/src/handlers/list-xero-payroll-leave-types.handler.ts @@ -2,7 +2,7 @@ import { xeroClient } from "../clients/xero-client.js"; import { XeroClientResponse } from "../types/tool-response.js"; import { formatError } from "../helpers/format-error.js"; import { getClientHeaders } from "../helpers/get-client-headers.js"; -import { LeaveType } from "xero-node/dist/gen/model/payroll-nz/leaveType.js"; +import { LeaveType } from "../types/payroll-nz-types.js"; /** * Internal function to fetch leave types from Xero diff --git a/src/tools/list/list-payroll-employee-leave-balances.tool.ts b/src/tools/list/list-payroll-employee-leave-balances.tool.ts index 1717a8c8..b728626d 100644 --- a/src/tools/list/list-payroll-employee-leave-balances.tool.ts +++ b/src/tools/list/list-payroll-employee-leave-balances.tool.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { listXeroPayrollEmployeeLeaveBalances } from "../../handlers/list-xero-payroll-employee-leave-balances.handler.js"; import { CreateXeroTool } from "../../helpers/create-xero-tool.js"; -import { EmployeeLeaveBalance } from "xero-node/dist/gen/model/payroll-nz/employeeLeaveBalance.js"; +import { EmployeeLeaveBalance } from "../../types/payroll-nz-types.js"; const ListPayrollEmployeeLeaveBalancesTool = CreateXeroTool( "list-payroll-employee-leave-balances", diff --git a/src/tools/list/list-payroll-employee-leave-types.tool.ts b/src/tools/list/list-payroll-employee-leave-types.tool.ts index 4ab96449..df5864b4 100644 --- a/src/tools/list/list-payroll-employee-leave-types.tool.ts +++ b/src/tools/list/list-payroll-employee-leave-types.tool.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { listXeroPayrollEmployeeLeaveTypes } from "../../handlers/list-xero-payroll-employee-leave-types.handler.js"; import { CreateXeroTool } from "../../helpers/create-xero-tool.js"; -import { EmployeeLeaveType } from "xero-node/dist/gen/model/payroll-nz/employeeLeaveType.js"; +import { EmployeeLeaveType } from "../../types/payroll-nz-types.js"; const ListPayrollEmployeeLeaveTypesTool = CreateXeroTool( "list-payroll-employee-leave-types", @@ -37,8 +37,8 @@ const ListPayrollEmployeeLeaveTypesTool = CreateXeroTool( text: [ `Leave Type ID: ${leaveType.leaveTypeID || "Unknown"}`, `Schedule of Accrual: ${leaveType.scheduleOfAccrual || "Unknown"}`, - leaveType.hoursAccruedAnnually - ? `Hours Accrued Annually: ${leaveType.hoursAccruedAnnually}` + leaveType.unitsAccruedAnnually + ? `Hours Accrued Annually: ${leaveType.unitsAccruedAnnually}` : null, leaveType.maximumToAccrue ? `Maximum To Accrue: ${leaveType.maximumToAccrue}` diff --git a/src/tools/list/list-payroll-employee-leave.tool.ts b/src/tools/list/list-payroll-employee-leave.tool.ts index 15d5dce1..16e118ba 100644 --- a/src/tools/list/list-payroll-employee-leave.tool.ts +++ b/src/tools/list/list-payroll-employee-leave.tool.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { listXeroPayrollEmployeeLeave } from "../../handlers/list-xero-payroll-employee-leave.handler.js"; import { CreateXeroTool } from "../../helpers/create-xero-tool.js"; -import { EmployeeLeave } from "xero-node/dist/gen/model/payroll-nz/employeeLeave.js"; +import { EmployeeLeave } from "../../types/payroll-nz-types.js"; const ListPayrollEmployeeLeaveTool = CreateXeroTool( "list-payroll-employee-leave", diff --git a/src/tools/list/list-payroll-employees.tool.ts b/src/tools/list/list-payroll-employees.tool.ts index 7a3b8e6a..0ff49a3c 100644 --- a/src/tools/list/list-payroll-employees.tool.ts +++ b/src/tools/list/list-payroll-employees.tool.ts @@ -1,4 +1,4 @@ -import { Employee } from "xero-node/dist/gen/model/payroll-nz/employee.js"; +import { Employee } from "../../types/payroll-nz-types.js"; import { listXeroPayrollEmployees } from "../../handlers/list-xero-payroll-employees.handler.js"; import { CreateXeroTool } from "../../helpers/create-xero-tool.js"; diff --git a/src/tools/list/list-payroll-leave-periods.tool.ts b/src/tools/list/list-payroll-leave-periods.tool.ts index 60e73b2e..ff767862 100644 --- a/src/tools/list/list-payroll-leave-periods.tool.ts +++ b/src/tools/list/list-payroll-leave-periods.tool.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { listXeroPayrollLeavePeriods } from "../../handlers/list-xero-payroll-leave-periods.handler.js"; import { CreateXeroTool } from "../../helpers/create-xero-tool.js"; -import { LeavePeriod } from "xero-node/dist/gen/model/payroll-nz/leavePeriod.js"; +import { LeavePeriod } from "../../types/payroll-nz-types.js"; const ListPayrollLeavePeriodsToolTool = CreateXeroTool( "list-payroll-leave-periods", diff --git a/src/tools/list/list-payroll-leave-types.tool.ts b/src/tools/list/list-payroll-leave-types.tool.ts index 213f8803..750fb7a1 100644 --- a/src/tools/list/list-payroll-leave-types.tool.ts +++ b/src/tools/list/list-payroll-leave-types.tool.ts @@ -1,6 +1,6 @@ import { listXeroPayrollLeaveTypes } from "../../handlers/list-xero-payroll-leave-types.handler.js"; import { CreateXeroTool } from "../../helpers/create-xero-tool.js"; -import { LeaveType } from "xero-node/dist/gen/model/payroll-nz/leaveType.js"; +import { LeaveType } from "../../types/payroll-nz-types.js"; const ListPayrollLeaveTypesTool = CreateXeroTool( "list-payroll-leave-types", diff --git a/src/types/payroll-au-types.ts b/src/types/payroll-au-types.ts new file mode 100644 index 00000000..a9613b06 --- /dev/null +++ b/src/types/payroll-au-types.ts @@ -0,0 +1,3 @@ +export { Timesheet as AuTimesheet } from "xero-node/dist/gen/model/payroll-au/timesheet.js"; +export { TimesheetLine as AuTimesheetLine } from "xero-node/dist/gen/model/payroll-au/timesheetLine.js"; +export { TimesheetStatus } from "xero-node/dist/gen/model/payroll-au/timesheetStatus.js"; diff --git a/src/types/payroll-nz-types.ts b/src/types/payroll-nz-types.ts new file mode 100644 index 00000000..8bcdf093 --- /dev/null +++ b/src/types/payroll-nz-types.ts @@ -0,0 +1,8 @@ +export { Timesheet as NzTimesheet } from "xero-node/dist/gen/model/payroll-nz/timesheet.js"; +export { TimesheetLine as NzTimesheetLine } from "xero-node/dist/gen/model/payroll-nz/timesheetLine.js"; +export { Employee } from "xero-node/dist/gen/model/payroll-nz/employee.js"; +export { EmployeeLeave } from "xero-node/dist/gen/model/payroll-nz/employeeLeave.js"; +export { EmployeeLeaveBalance } from "xero-node/dist/gen/model/payroll-nz/employeeLeaveBalance.js"; +export { EmployeeLeaveType } from "xero-node/dist/gen/model/payroll-nz/employeeLeaveType.js"; +export { LeavePeriod } from "xero-node/dist/gen/model/payroll-nz/leavePeriod.js"; +export { LeaveType } from "xero-node/dist/gen/model/payroll-nz/leaveType.js"; From 5d7b9b57e246d6d83d5f739fc5a492843b8edcf9 Mon Sep 17 00:00:00 2001 From: Elliot Chisholm Date: Mon, 25 May 2026 21:53:51 +1200 Subject: [PATCH 02/13] fix: address review feedback on list-payroll-employee-leave-types tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename label 'Hours Accrued Annually' → 'Units Accrued Annually' - Add typeOfUnitsToAccrue field so consumers know what unit type the value represents - Remove duplicate Leave Type ID output Co-Authored-By: Claude Sonnet 4.6 --- src/tools/list/list-payroll-employee-leave-types.tool.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/tools/list/list-payroll-employee-leave-types.tool.ts b/src/tools/list/list-payroll-employee-leave-types.tool.ts index df5864b4..bd9edcfb 100644 --- a/src/tools/list/list-payroll-employee-leave-types.tool.ts +++ b/src/tools/list/list-payroll-employee-leave-types.tool.ts @@ -37,8 +37,11 @@ const ListPayrollEmployeeLeaveTypesTool = CreateXeroTool( text: [ `Leave Type ID: ${leaveType.leaveTypeID || "Unknown"}`, `Schedule of Accrual: ${leaveType.scheduleOfAccrual || "Unknown"}`, + leaveType.typeOfUnitsToAccrue + ? `Type of Units: ${leaveType.typeOfUnitsToAccrue}` + : null, leaveType.unitsAccruedAnnually - ? `Hours Accrued Annually: ${leaveType.unitsAccruedAnnually}` + ? `Units Accrued Annually: ${leaveType.unitsAccruedAnnually}` : null, leaveType.maximumToAccrue ? `Maximum To Accrue: ${leaveType.maximumToAccrue}` @@ -49,9 +52,6 @@ const ListPayrollEmployeeLeaveTypesTool = CreateXeroTool( leaveType.rateAccruedHourly ? `Rate Accrued Hourly: ${leaveType.rateAccruedHourly}` : null, - leaveType.leaveTypeID - ? `Leave Type ID: ${leaveType.leaveTypeID}` - : null, leaveType.scheduleOfAccrualDate ? `Accrual Date: ${leaveType.scheduleOfAccrualDate}` : null, From fbca59e275d7e2d67fac2ad63cbf0dea79d63919 Mon Sep 17 00:00:00 2001 From: Elliot Chisholm Date: Fri, 24 Apr 2026 20:34:04 +1200 Subject: [PATCH 03/13] Upgrade xero-node to v15 and add AU v2 payroll client - Upgrade xero-node from ^13.3.0 to ^15.0.1 - Add PayrollAuV2Api client to xero-client.ts with lazy init and token sync - Add payroll-au-v2-types.ts shim for AU v2 Timesheet/TimesheetLine types - Fix SDK breaking change: hoursAccruedAnnually renamed to unitsAccruedAnnually - Add vitest for test infrastructure The PayrollAuV2Api is a separate class not wired into XeroClient, so it requires manual instantiation and access token management. Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 1218 +++++++++++++++++++++++++++++- package.json | 7 +- src/clients/xero-client.ts | 29 +- src/types/payroll-au-v2-types.ts | 2 + vitest.config.ts | 7 + 5 files changed, 1243 insertions(+), 20 deletions(-) create mode 100644 src/types/payroll-au-v2-types.ts create mode 100644 vitest.config.ts diff --git a/package-lock.json b/package-lock.json index 37d01526..84c8e9af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,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" }, "bin": { @@ -27,7 +27,42 @@ "prettier": "3.7.4", "shx": "^0.3.4", "typescript": "^5.9.3", - "typescript-eslint": "^8.48.1" + "typescript-eslint": "^8.48.1", + "vitest": "^4.1.5" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@eslint-community/eslint-utils": { @@ -289,6 +324,13 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.26.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", @@ -329,6 +371,335 @@ } } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -399,7 +770,6 @@ "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", @@ -611,6 +981,119 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -630,7 +1113,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -704,6 +1186,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -811,6 +1303,16 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -889,6 +1391,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -976,6 +1485,16 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -1035,6 +1554,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -1087,7 +1613,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -1266,6 +1791,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -1306,12 +1841,21 @@ "node": ">=18.0.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -1576,6 +2120,21 @@ "dev": true, "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -1743,7 +2302,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -1990,6 +2548,267 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2025,6 +2844,16 @@ "node": ">=10" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2109,6 +2938,25 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -2164,6 +3012,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/oidc-token-hash": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", @@ -2325,13 +3184,26 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, "node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2348,6 +3220,35 @@ "node": ">=16.20.0" } }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2497,6 +3398,40 @@ "node": ">=4" } }, + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -2711,6 +3646,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2720,6 +3679,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -2759,15 +3725,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -2776,6 +3759,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -2798,6 +3791,14 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -2831,7 +3832,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2899,6 +3899,174 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2914,6 +4082,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -2931,9 +4116,9 @@ "license": "ISC" }, "node_modules/xero-node": { - "version": "13.3.0", - "resolved": "https://registry.npmjs.org/xero-node/-/xero-node-13.3.0.tgz", - "integrity": "sha512-D7qZrOPkN1crFx8VRnoe7UI403t/uhYugggF1jQHlUTYBNiEeVYJVPCQkYdYcy0yD8sxTEsdOCfw9CSZsd2Tvw==", + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/xero-node/-/xero-node-15.0.1.tgz", + "integrity": "sha512-hqx7jA8iB5Al3G7fPA4qOHJUX+FJl7yChJEmlw2E7xWf41Md30sj7NFlvabDxPqVBK+Wm/yn/xlRTlYRDCB/6A==", "license": "MIT", "dependencies": { "axios": "^1.7.7", @@ -2988,7 +4173,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index f9c9fb36..6e500823 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "build": "tsc && shx chmod +x dist/*.js", "prepare": "npm run build", "watch": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest", "lint:fix": "eslint --fix", "lint": "eslint ." }, @@ -25,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": { @@ -37,6 +39,7 @@ "prettier": "3.7.4", "shx": "^0.3.4", "typescript": "^5.9.3", - "typescript-eslint": "^8.48.1" + "typescript-eslint": "^8.48.1", + "vitest": "^4.1.5" } } diff --git a/src/clients/xero-client.ts b/src/clients/xero-client.ts index 24ece6e6..d1c5fc4b 100644 --- a/src/clients/xero-client.ts +++ b/src/clients/xero-client.ts @@ -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"; @@ -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); @@ -41,7 +53,7 @@ abstract class MCPXeroClient extends XeroClient { return this.tenants; } - private async getOrganisation(): Promise { + public async getOrganisation(): Promise { await this.authenticate(); const organisationResponse = await this.accountingApi.getOrganisations( @@ -72,6 +84,17 @@ abstract class MCPXeroClient extends XeroClient { } return this.shortCode; } + + public async getRegion(): Promise { + 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 { @@ -141,6 +164,8 @@ class CustomConnectionsXeroClient extends MCPXeroClient { expires_in: tokenResponse.expires_in, token_type: tokenResponse.token_type, }); + + this.payrollAUV2Api.accessToken = tokenResponse.access_token ?? ""; } } @@ -157,6 +182,8 @@ class BearerTokenXeroClient extends MCPXeroClient { access_token: this.bearerToken, }); + this.payrollAUV2Api.accessToken = this.bearerToken; + await this.updateTenants(); } } diff --git a/src/types/payroll-au-v2-types.ts b/src/types/payroll-au-v2-types.ts new file mode 100644 index 00000000..b166e645 --- /dev/null +++ b/src/types/payroll-au-v2-types.ts @@ -0,0 +1,2 @@ +export { Timesheet as AuV2Timesheet } from "xero-node/dist/gen/model/payroll-au-v2/timesheet.js"; +export { TimesheetLine as AuV2TimesheetLine } from "xero-node/dist/gen/model/payroll-au-v2/timesheetLine.js"; diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..ae847ff6 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); From 5ad9523d67b74747834529d954ac85512218dd80 Mon Sep 17 00:00:00 2001 From: Elliot Chisholm Date: Fri, 24 Apr 2026 20:35:00 +1200 Subject: [PATCH 04/13] Migrate AU timesheet handlers from v1 to v2 API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace all AU-specific workaround code with v2 API calls via PayrollAuV2Api. This aligns AU with NZ/UK — all three regions now use the same v2 endpoint patterns. Changes per handler: - approve: remove GET-then-PUT status workaround, call approveTimesheet - revert: remove GET-then-PUT status workaround, call revertTimesheet - delete: remove AU error block, call deleteTimesheet (previously unsupported) - create: remove array wrapping, use single-object createTimesheet - add-line: remove GET-append-PUT workaround, call createTimesheetLine - update-line: remove earningsRateID matching, call updateTimesheetLine by ID - get: call getTimesheet via v2 client - list: remove AU-only params (where/order/ifModifiedSince), use v2 params Breaking input changes for AU callers: - numberOfUnits is now a single number (not array) with a date per line - payrollCalendarID is now required on create - timesheetLineID is now required on update-line Co-Authored-By: Claude Opus 4.6 (1M context) --- .../add-timesheet-line.handler.test.ts | 112 ++++++++++++++ ...ove-xero-payroll-timesheet.handler.test.ts | 78 ++++++++++ ...ate-xero-payroll-timesheet.handler.test.ts | 145 ++++++++++++++++++ ...ete-xero-payroll-timesheet.handler.test.ts | 71 +++++++++ ...get-xero-payroll-timesheet.handler.test.ts | 89 +++++++++++ .../list-xero-timesheets.handler.test.ts | 138 +++++++++++++++++ ...ert-xero-payroll-timesheet.handler.test.ts | 78 ++++++++++ .../update-timesheet-line.handler.test.ts | 123 +++++++++++++++ .../approve-xero-payroll-timesheet.handler.ts | 52 +++---- .../create-xero-payroll-timesheet.handler.ts | 75 +++++---- .../delete-xero-payroll-timesheet.handler.ts | 43 ++---- .../get-xero-payroll-timesheet.handler.ts | 52 +++---- src/handlers/list-xero-timesheets.handler.ts | 70 +++++---- .../revert-xero-payroll-timesheet.handler.ts | 52 +++---- ...xero-payroll-timesheet-add-line.handler.ts | 67 ++++---- ...o-payroll-timesheet-update-line.handler.ts | 73 +++++---- .../create/create-payroll-timesheet.tool.ts | 11 +- .../delete/delete-payroll-timesheet.tool.ts | 3 +- src/tools/get/get-payroll-timesheet.tool.ts | 17 +- .../list/list-payroll-timesheets.tool.ts | 27 +++- .../update/approve-payroll-timesheet.tool.ts | 3 +- .../update/revert-payroll-timesheet.tool.ts | 3 +- .../update-payroll-timesheet-add-line.tool.ts | 29 ++-- ...date-payroll-timesheet-update-line.tool.ts | 30 ++-- 24 files changed, 1147 insertions(+), 294 deletions(-) create mode 100644 src/handlers/__tests__/add-timesheet-line.handler.test.ts create mode 100644 src/handlers/__tests__/approve-xero-payroll-timesheet.handler.test.ts create mode 100644 src/handlers/__tests__/create-xero-payroll-timesheet.handler.test.ts create mode 100644 src/handlers/__tests__/delete-xero-payroll-timesheet.handler.test.ts create mode 100644 src/handlers/__tests__/get-xero-payroll-timesheet.handler.test.ts create mode 100644 src/handlers/__tests__/list-xero-timesheets.handler.test.ts create mode 100644 src/handlers/__tests__/revert-xero-payroll-timesheet.handler.test.ts create mode 100644 src/handlers/__tests__/update-timesheet-line.handler.test.ts diff --git a/src/handlers/__tests__/add-timesheet-line.handler.test.ts b/src/handlers/__tests__/add-timesheet-line.handler.test.ts new file mode 100644 index 00000000..dbeb04c8 --- /dev/null +++ b/src/handlers/__tests__/add-timesheet-line.handler.test.ts @@ -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"); + }); +}); diff --git a/src/handlers/__tests__/approve-xero-payroll-timesheet.handler.test.ts b/src/handlers/__tests__/approve-xero-payroll-timesheet.handler.test.ts new file mode 100644 index 00000000..e909f427 --- /dev/null +++ b/src/handlers/__tests__/approve-xero-payroll-timesheet.handler.test.ts @@ -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"); + }); +}); diff --git a/src/handlers/__tests__/create-xero-payroll-timesheet.handler.test.ts b/src/handlers/__tests__/create-xero-payroll-timesheet.handler.test.ts new file mode 100644 index 00000000..d90796b7 --- /dev/null +++ b/src/handlers/__tests__/create-xero-payroll-timesheet.handler.test.ts @@ -0,0 +1,145 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; + +const { mockXeroClient } = vi.hoisted(() => ({ + mockXeroClient: { + authenticate: vi.fn(), + getRegion: vi.fn(), + tenantId: "test-tenant-id", + payrollAUV2Api: { createTimesheet: vi.fn() }, + payrollNZApi: { createTimesheet: vi.fn() }, + payrollUKApi: { createTimesheet: vi.fn() }, + }, +})); + +vi.mock("../../clients/xero-client.js", () => ({ + xeroClient: mockXeroClient, +})); + +import { createXeroPayrollTimesheet } from "../create-xero-payroll-timesheet.handler.js"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("createXeroPayrollTimesheet", () => { + describe("AU region", () => { + beforeEach(() => { + mockXeroClient.getRegion.mockResolvedValue("AU"); + }); + + it("calls payrollAUV2Api.createTimesheet with single object", async () => { + mockXeroClient.payrollAUV2Api.createTimesheet.mockResolvedValue({ + body: { timesheet: { timesheetID: "ts-new" } }, + }); + + await createXeroPayrollTimesheet({ + employeeID: "emp-1", + startDate: "2024-01-01", + endDate: "2024-01-07", + payrollCalendarID: "cal-1", + }); + + const call = mockXeroClient.payrollAUV2Api.createTimesheet.mock.calls[0]; + expect(call[0]).toBe("test-tenant-id"); + expect(call[1].payrollCalendarID).toBe("cal-1"); + expect(call[1].employeeID).toBe("emp-1"); + }); + + it("passes timesheetLines with date and single numberOfUnits", async () => { + mockXeroClient.payrollAUV2Api.createTimesheet.mockResolvedValue({ + body: { timesheet: { timesheetID: "ts-new" } }, + }); + + await createXeroPayrollTimesheet({ + employeeID: "emp-1", + startDate: "2024-01-01", + endDate: "2024-01-07", + payrollCalendarID: "cal-1", + timesheetLines: [ + { earningsRateID: "rate-1", numberOfUnits: 8, date: "2024-01-01" }, + ], + }); + + const timesheet = mockXeroClient.payrollAUV2Api.createTimesheet.mock.calls[0][1]; + expect(timesheet.timesheetLines[0].numberOfUnits).toBe(8); + expect(timesheet.timesheetLines[0].date).toBe("2024-01-01"); + }); + }); + + describe("NZ region", () => { + beforeEach(() => { + mockXeroClient.getRegion.mockResolvedValue("NZ"); + }); + + it("calls payrollNZApi.createTimesheet with payrollCalendarID", async () => { + mockXeroClient.payrollNZApi.createTimesheet.mockResolvedValue({ + body: { timesheet: { timesheetID: "ts-new" } }, + }); + + await createXeroPayrollTimesheet({ + employeeID: "emp-1", + startDate: "2024-01-01", + endDate: "2024-01-07", + payrollCalendarID: "cal-1", + }); + + const timesheet = mockXeroClient.payrollNZApi.createTimesheet.mock.calls[0][1]; + expect(timesheet.payrollCalendarID).toBe("cal-1"); + expect(timesheet.employeeID).toBe("emp-1"); + }); + + it("maps timesheetLines with date and single numberOfUnits", async () => { + mockXeroClient.payrollNZApi.createTimesheet.mockResolvedValue({ + body: { timesheet: { timesheetID: "ts-new" } }, + }); + + await createXeroPayrollTimesheet({ + employeeID: "emp-1", + startDate: "2024-01-01", + endDate: "2024-01-07", + payrollCalendarID: "cal-1", + timesheetLines: [ + { earningsRateID: "rate-1", numberOfUnits: 8, date: "2024-01-01" }, + ], + }); + + const timesheet = mockXeroClient.payrollNZApi.createTimesheet.mock.calls[0][1]; + expect(timesheet.timesheetLines[0].date).toBe("2024-01-01"); + expect(timesheet.timesheetLines[0].numberOfUnits).toBe(8); + }); + }); + + describe("UK region", () => { + it("calls payrollUKApi.createTimesheet", async () => { + mockXeroClient.getRegion.mockResolvedValue("UK"); + mockXeroClient.payrollUKApi.createTimesheet.mockResolvedValue({ + body: { timesheet: { timesheetID: "ts-new" } }, + }); + + await createXeroPayrollTimesheet({ + employeeID: "emp-1", + startDate: "2024-01-01", + endDate: "2024-01-07", + payrollCalendarID: "cal-1", + }); + + expect(mockXeroClient.payrollUKApi.createTimesheet).toHaveBeenCalled(); + expect(mockXeroClient.payrollNZApi.createTimesheet).not.toHaveBeenCalled(); + }); + }); + + it("returns error response on API failure", async () => { + mockXeroClient.getRegion.mockResolvedValue("AU"); + mockXeroClient.payrollAUV2Api.createTimesheet.mockRejectedValue(new Error("Validation failed")); + + const result = await createXeroPayrollTimesheet({ + employeeID: "emp-1", + startDate: "2024-01-01", + endDate: "2024-01-07", + payrollCalendarID: "cal-1", + }); + + expect(result.isError).toBe(true); + expect(result.error).toBe("Validation failed"); + }); +}); diff --git a/src/handlers/__tests__/delete-xero-payroll-timesheet.handler.test.ts b/src/handlers/__tests__/delete-xero-payroll-timesheet.handler.test.ts new file mode 100644 index 00000000..488d13a3 --- /dev/null +++ b/src/handlers/__tests__/delete-xero-payroll-timesheet.handler.test.ts @@ -0,0 +1,71 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; + +const { mockXeroClient } = vi.hoisted(() => ({ + mockXeroClient: { + authenticate: vi.fn(), + getRegion: vi.fn(), + tenantId: "test-tenant-id", + payrollAUV2Api: { deleteTimesheet: vi.fn() }, + payrollNZApi: { deleteTimesheet: vi.fn() }, + payrollUKApi: { deleteTimesheet: vi.fn() }, + }, +})); + +vi.mock("../../clients/xero-client.js", () => ({ + xeroClient: mockXeroClient, +})); + +import { deleteXeroPayrollTimesheet } from "../delete-xero-payroll-timesheet.handler.js"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("deleteXeroPayrollTimesheet", () => { + describe("AU region", () => { + it("calls payrollAUV2Api.deleteTimesheet", async () => { + mockXeroClient.getRegion.mockResolvedValue("AU"); + mockXeroClient.payrollAUV2Api.deleteTimesheet.mockResolvedValue({}); + + const result = await deleteXeroPayrollTimesheet("ts-1"); + + expect(mockXeroClient.payrollAUV2Api.deleteTimesheet).toHaveBeenCalledWith("test-tenant-id", "ts-1"); + expect(result.isError).toBe(false); + expect(result.result).toBe(true); + }); + }); + + describe("NZ region", () => { + it("calls payrollNZApi.deleteTimesheet", async () => { + mockXeroClient.getRegion.mockResolvedValue("NZ"); + mockXeroClient.payrollNZApi.deleteTimesheet.mockResolvedValue({}); + + const result = await deleteXeroPayrollTimesheet("ts-1"); + + expect(mockXeroClient.payrollNZApi.deleteTimesheet).toHaveBeenCalledWith("test-tenant-id", "ts-1"); + expect(result.isError).toBe(false); + expect(result.result).toBe(true); + }); + }); + + describe("UK region", () => { + it("calls payrollUKApi.deleteTimesheet", async () => { + mockXeroClient.getRegion.mockResolvedValue("UK"); + mockXeroClient.payrollUKApi.deleteTimesheet.mockResolvedValue({}); + + const result = await deleteXeroPayrollTimesheet("ts-1"); + + expect(mockXeroClient.payrollUKApi.deleteTimesheet).toHaveBeenCalledWith("test-tenant-id", "ts-1"); + expect(mockXeroClient.payrollNZApi.deleteTimesheet).not.toHaveBeenCalled(); + }); + }); + + it("returns error response on API failure", async () => { + mockXeroClient.getRegion.mockResolvedValue("NZ"); + mockXeroClient.payrollNZApi.deleteTimesheet.mockRejectedValue(new Error("Cannot delete")); + + const result = await deleteXeroPayrollTimesheet("ts-1"); + expect(result.isError).toBe(true); + expect(result.error).toBe("Cannot delete"); + }); +}); diff --git a/src/handlers/__tests__/get-xero-payroll-timesheet.handler.test.ts b/src/handlers/__tests__/get-xero-payroll-timesheet.handler.test.ts new file mode 100644 index 00000000..cfdb5cfe --- /dev/null +++ b/src/handlers/__tests__/get-xero-payroll-timesheet.handler.test.ts @@ -0,0 +1,89 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; + +const { mockXeroClient } = vi.hoisted(() => ({ + mockXeroClient: { + authenticate: vi.fn(), + getRegion: vi.fn(), + tenantId: "test-tenant-id", + payrollAUV2Api: { getTimesheet: vi.fn() }, + payrollNZApi: { getTimesheet: vi.fn() }, + payrollUKApi: { getTimesheet: vi.fn() }, + }, +})); + +vi.mock("../../clients/xero-client.js", () => ({ + xeroClient: mockXeroClient, +})); + +import { getXeroPayrollTimesheet } from "../get-xero-payroll-timesheet.handler.js"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("getXeroPayrollTimesheet", () => { + describe("AU region", () => { + beforeEach(() => { + mockXeroClient.getRegion.mockResolvedValue("AU"); + }); + + it("calls payrollAUV2Api.getTimesheet", async () => { + mockXeroClient.payrollAUV2Api.getTimesheet.mockResolvedValue({ + body: { timesheet: { timesheetID: "ts-1", employeeID: "emp-1", startDate: "2024-01-01", endDate: "2024-01-07" } }, + }); + + const result = await getXeroPayrollTimesheet("ts-1"); + + expect(mockXeroClient.payrollAUV2Api.getTimesheet).toHaveBeenCalledWith("test-tenant-id", "ts-1"); + expect(result.isError).toBe(false); + expect(result.result?.timesheetID).toBe("ts-1"); + }); + }); + + describe("NZ region", () => { + it("calls payrollNZApi.getTimesheet", async () => { + mockXeroClient.getRegion.mockResolvedValue("NZ"); + mockXeroClient.payrollNZApi.getTimesheet.mockResolvedValue({ + body: { timesheet: { timesheetID: "ts-1" } }, + }); + + await getXeroPayrollTimesheet("ts-1"); + + expect(mockXeroClient.payrollNZApi.getTimesheet).toHaveBeenCalledWith("test-tenant-id", "ts-1"); + }); + }); + + describe("UK region", () => { + it("calls payrollUKApi.getTimesheet", async () => { + mockXeroClient.getRegion.mockResolvedValue("UK"); + mockXeroClient.payrollUKApi.getTimesheet.mockResolvedValue({ + body: { timesheet: { timesheetID: "ts-1" } }, + }); + + await getXeroPayrollTimesheet("ts-1"); + + expect(mockXeroClient.payrollUKApi.getTimesheet).toHaveBeenCalledWith("test-tenant-id", "ts-1"); + expect(mockXeroClient.payrollNZApi.getTimesheet).not.toHaveBeenCalled(); + }); + }); + + it("returns null when timesheet not found", async () => { + mockXeroClient.getRegion.mockResolvedValue("AU"); + mockXeroClient.payrollAUV2Api.getTimesheet.mockResolvedValue({ + body: { timesheet: undefined }, + }); + + const result = await getXeroPayrollTimesheet("nonexistent"); + expect(result.isError).toBe(false); + expect(result.result).toBeNull(); + }); + + it("returns error response on API failure", async () => { + mockXeroClient.getRegion.mockResolvedValue("NZ"); + mockXeroClient.payrollNZApi.getTimesheet.mockRejectedValue(new Error("Not found")); + + const result = await getXeroPayrollTimesheet("ts-1"); + expect(result.isError).toBe(true); + expect(result.error).toBe("Not found"); + }); +}); diff --git a/src/handlers/__tests__/list-xero-timesheets.handler.test.ts b/src/handlers/__tests__/list-xero-timesheets.handler.test.ts new file mode 100644 index 00000000..3db0fae0 --- /dev/null +++ b/src/handlers/__tests__/list-xero-timesheets.handler.test.ts @@ -0,0 +1,138 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; + +const { mockXeroClient } = vi.hoisted(() => ({ + mockXeroClient: { + authenticate: vi.fn(), + getRegion: vi.fn(), + tenantId: "test-tenant-id", + payrollAUV2Api: { getTimesheets: vi.fn() }, + payrollNZApi: { getTimesheets: vi.fn() }, + payrollUKApi: { getTimesheets: vi.fn() }, + }, +})); + +vi.mock("../../clients/xero-client.js", () => ({ + xeroClient: mockXeroClient, +})); + +import { listXeroPayrollTimesheets } from "../list-xero-timesheets.handler.js"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("listXeroPayrollTimesheets", () => { + describe("AU region", () => { + beforeEach(() => { + mockXeroClient.getRegion.mockResolvedValue("AU"); + }); + + it("calls payrollAUV2Api.getTimesheets with v2 params", async () => { + mockXeroClient.payrollAUV2Api.getTimesheets.mockResolvedValue({ + body: { timesheets: [] }, + }); + + await listXeroPayrollTimesheets({ + page: 1, + filter: "employeeId==abc-123", + status: "Draft", + startDate: "2024-01-01", + endDate: "2024-01-31", + sort: "startDate", + }); + + expect(mockXeroClient.payrollAUV2Api.getTimesheets).toHaveBeenCalledWith( + "test-tenant-id", + 1, + "employeeId==abc-123", + "Draft", + "2024-01-01", + "2024-01-31", + "startDate", + ); + }); + + it("returns AU timesheets on success", async () => { + const auTimesheets = [ + { timesheetID: "ts-1", employeeID: "emp-1", startDate: "2024-01-01", endDate: "2024-01-07", totalHours: 40 }, + ]; + mockXeroClient.payrollAUV2Api.getTimesheets.mockResolvedValue({ + body: { timesheets: auTimesheets }, + }); + + const result = await listXeroPayrollTimesheets({}); + expect(result.isError).toBe(false); + expect(result.result).toEqual(auTimesheets); + }); + }); + + describe("NZ region", () => { + beforeEach(() => { + mockXeroClient.getRegion.mockResolvedValue("NZ"); + }); + + it("calls payrollNZApi.getTimesheets with correct params", async () => { + mockXeroClient.payrollNZApi.getTimesheets.mockResolvedValue({ + body: { timesheets: [] }, + }); + + await listXeroPayrollTimesheets({ + page: 1, + filter: "employeeId==abc-123", + status: "Draft", + startDate: "2024-01-01", + endDate: "2024-01-31", + sort: "startDate", + }); + + expect(mockXeroClient.payrollNZApi.getTimesheets).toHaveBeenCalledWith( + "test-tenant-id", + 1, + "employeeId==abc-123", + "Draft", + "2024-01-01", + "2024-01-31", + "startDate", + ); + }); + + it("returns NZ timesheets on success", async () => { + const nzTimesheets = [ + { timesheetID: "ts-1", employeeID: "emp-1", startDate: "2024-01-01", endDate: "2024-01-07", totalHours: 40 }, + ]; + mockXeroClient.payrollNZApi.getTimesheets.mockResolvedValue({ + body: { timesheets: nzTimesheets }, + }); + + const result = await listXeroPayrollTimesheets({}); + expect(result.isError).toBe(false); + expect(result.result).toEqual(nzTimesheets); + }); + }); + + describe("UK region", () => { + it("calls payrollUKApi.getTimesheets", async () => { + mockXeroClient.getRegion.mockResolvedValue("UK"); + mockXeroClient.payrollUKApi.getTimesheets.mockResolvedValue({ + body: { timesheets: [] }, + }); + + await listXeroPayrollTimesheets({ page: 1 }); + + expect(mockXeroClient.payrollUKApi.getTimesheets).toHaveBeenCalled(); + expect(mockXeroClient.payrollNZApi.getTimesheets).not.toHaveBeenCalled(); + }); + }); + + describe("error handling", () => { + it("returns error response on API failure", async () => { + mockXeroClient.getRegion.mockResolvedValue("NZ"); + mockXeroClient.payrollNZApi.getTimesheets.mockRejectedValue(new Error("API down")); + + const result = await listXeroPayrollTimesheets({}); + expect(result.isError).toBe(true); + expect(result.error).toBe("API down"); + expect(result.result).toBeNull(); + }); + }); +}); diff --git a/src/handlers/__tests__/revert-xero-payroll-timesheet.handler.test.ts b/src/handlers/__tests__/revert-xero-payroll-timesheet.handler.test.ts new file mode 100644 index 00000000..c5023b73 --- /dev/null +++ b/src/handlers/__tests__/revert-xero-payroll-timesheet.handler.test.ts @@ -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: { revertTimesheet: vi.fn() }, + payrollNZApi: { revertTimesheet: vi.fn() }, + payrollUKApi: { revertTimesheet: vi.fn() }, + }, +})); + +vi.mock("../../clients/xero-client.js", () => ({ + xeroClient: mockXeroClient, +})); + +import { revertXeroPayrollTimesheet } from "../revert-xero-payroll-timesheet.handler.js"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("revertXeroPayrollTimesheet", () => { + describe("AU region", () => { + beforeEach(() => { + mockXeroClient.getRegion.mockResolvedValue("AU"); + }); + + it("calls payrollAUV2Api.revertTimesheet", async () => { + mockXeroClient.payrollAUV2Api.revertTimesheet.mockResolvedValue({ + body: { timesheet: { timesheetID: "ts-1", status: "Draft" } }, + }); + + const result = await revertXeroPayrollTimesheet("ts-1"); + + expect(mockXeroClient.payrollAUV2Api.revertTimesheet).toHaveBeenCalledWith("test-tenant-id", "ts-1"); + expect(result.isError).toBe(false); + expect(result.result?.timesheetID).toBe("ts-1"); + }); + }); + + describe("NZ region", () => { + it("calls payrollNZApi.revertTimesheet", async () => { + mockXeroClient.getRegion.mockResolvedValue("NZ"); + mockXeroClient.payrollNZApi.revertTimesheet.mockResolvedValue({ + body: { timesheet: { timesheetID: "ts-1" } }, + }); + + await revertXeroPayrollTimesheet("ts-1"); + + expect(mockXeroClient.payrollNZApi.revertTimesheet).toHaveBeenCalledWith("test-tenant-id", "ts-1"); + }); + }); + + describe("UK region", () => { + it("calls payrollUKApi.revertTimesheet", async () => { + mockXeroClient.getRegion.mockResolvedValue("UK"); + mockXeroClient.payrollUKApi.revertTimesheet.mockResolvedValue({ + body: { timesheet: { timesheetID: "ts-1" } }, + }); + + await revertXeroPayrollTimesheet("ts-1"); + + expect(mockXeroClient.payrollUKApi.revertTimesheet).toHaveBeenCalledWith("test-tenant-id", "ts-1"); + expect(mockXeroClient.payrollNZApi.revertTimesheet).not.toHaveBeenCalled(); + }); + }); + + it("returns error response on API failure", async () => { + mockXeroClient.getRegion.mockResolvedValue("NZ"); + mockXeroClient.payrollNZApi.revertTimesheet.mockRejectedValue(new Error("Cannot revert")); + + const result = await revertXeroPayrollTimesheet("ts-1"); + expect(result.isError).toBe(true); + expect(result.error).toBe("Cannot revert"); + }); +}); diff --git a/src/handlers/__tests__/update-timesheet-line.handler.test.ts b/src/handlers/__tests__/update-timesheet-line.handler.test.ts new file mode 100644 index 00000000..1d9cb776 --- /dev/null +++ b/src/handlers/__tests__/update-timesheet-line.handler.test.ts @@ -0,0 +1,123 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; + +const { mockXeroClient } = vi.hoisted(() => ({ + mockXeroClient: { + authenticate: vi.fn(), + getRegion: vi.fn(), + tenantId: "test-tenant-id", + payrollAUV2Api: { updateTimesheetLine: vi.fn() }, + payrollNZApi: { updateTimesheetLine: vi.fn() }, + payrollUKApi: { updateTimesheetLine: vi.fn() }, + }, +})); + +vi.mock("../../clients/xero-client.js", () => ({ + xeroClient: mockXeroClient, +})); + +import { updateXeroPayrollTimesheetUpdateLine } from "../update-xero-payroll-timesheet-update-line.handler.js"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("updateXeroPayrollTimesheetUpdateLine", () => { + describe("AU region", () => { + beforeEach(() => { + mockXeroClient.getRegion.mockResolvedValue("AU"); + }); + + it("calls payrollAUV2Api.updateTimesheetLine with timesheetLineID", async () => { + mockXeroClient.payrollAUV2Api.updateTimesheetLine.mockResolvedValue({ + body: { timesheetLine: { timesheetLineID: "line-1", earningsRateID: "rate-1" } }, + }); + + const result = await updateXeroPayrollTimesheetUpdateLine({ + timesheetID: "ts-1", + timesheetLineID: "line-1", + earningsRateID: "rate-1", + numberOfUnits: 6, + date: "2024-01-01", + }); + + expect(mockXeroClient.payrollAUV2Api.updateTimesheetLine).toHaveBeenCalledWith( + "test-tenant-id", + "ts-1", + "line-1", + expect.objectContaining({ + earningsRateID: "rate-1", + numberOfUnits: 6, + date: "2024-01-01", + }), + ); + expect(result.isError).toBe(false); + }); + }); + + describe("NZ region", () => { + beforeEach(() => { + mockXeroClient.getRegion.mockResolvedValue("NZ"); + }); + + it("calls payrollNZApi.updateTimesheetLine with timesheetLineID", async () => { + mockXeroClient.payrollNZApi.updateTimesheetLine.mockResolvedValue({ + body: { timesheetLine: { timesheetLineID: "line-1" } }, + }); + + await updateXeroPayrollTimesheetUpdateLine({ + timesheetID: "ts-1", + timesheetLineID: "line-1", + earningsRateID: "rate-1", + numberOfUnits: 8, + date: "2024-01-01", + }); + + expect(mockXeroClient.payrollNZApi.updateTimesheetLine).toHaveBeenCalledWith( + "test-tenant-id", + "ts-1", + "line-1", + expect.objectContaining({ + earningsRateID: "rate-1", + numberOfUnits: 8, + date: "2024-01-01", + }), + ); + }); + }); + + describe("UK region", () => { + it("calls payrollUKApi.updateTimesheetLine", async () => { + mockXeroClient.getRegion.mockResolvedValue("UK"); + mockXeroClient.payrollUKApi.updateTimesheetLine.mockResolvedValue({ + body: { timesheetLine: { timesheetLineID: "line-1" } }, + }); + + await updateXeroPayrollTimesheetUpdateLine({ + timesheetID: "ts-1", + timesheetLineID: "line-1", + earningsRateID: "rate-1", + numberOfUnits: 8, + date: "2024-01-01", + }); + + expect(mockXeroClient.payrollUKApi.updateTimesheetLine).toHaveBeenCalled(); + expect(mockXeroClient.payrollNZApi.updateTimesheetLine).not.toHaveBeenCalled(); + }); + }); + + it("returns error response on API failure", async () => { + mockXeroClient.getRegion.mockResolvedValue("NZ"); + mockXeroClient.payrollNZApi.updateTimesheetLine.mockRejectedValue(new Error("Line not found")); + + const result = await updateXeroPayrollTimesheetUpdateLine({ + timesheetID: "ts-1", + timesheetLineID: "line-1", + earningsRateID: "rate-1", + numberOfUnits: 8, + date: "2024-01-01", + }); + + expect(result.isError).toBe(true); + expect(result.error).toBe("Line not found"); + }); +}); diff --git a/src/handlers/approve-xero-payroll-timesheet.handler.ts b/src/handlers/approve-xero-payroll-timesheet.handler.ts index 5d07eeff..9f3e0cc9 100644 --- a/src/handlers/approve-xero-payroll-timesheet.handler.ts +++ b/src/handlers/approve-xero-payroll-timesheet.handler.ts @@ -1,40 +1,32 @@ -import { Timesheet } from "xero-node/dist/gen/model/payroll-nz/timesheet.js"; +import { NzTimesheet } from "../types/payroll-nz-types.js"; +import { AuV2Timesheet } from "../types/payroll-au-v2-types.js"; import { xeroClient } from "../clients/xero-client.js"; import { formatError } from "../helpers/format-error.js"; import { XeroClientResponse } from "../types/tool-response.js"; -async function approveTimesheet(timesheetID: string): Promise { - await xeroClient.authenticate(); +export async function approveXeroPayrollTimesheet( + timesheetID: string, +): Promise> { + try { + await xeroClient.authenticate(); + const region = await xeroClient.getRegion(); - // Call the approveTimesheet endpoint from the PayrollNZApi - const approvedTimesheet = await xeroClient.payrollNZApi.approveTimesheet( - xeroClient.tenantId, - timesheetID, - ); + let result: NzTimesheet | AuV2Timesheet | null; - return approvedTimesheet.body.timesheet ?? null; -} + if (region === "AU") { + const res = await xeroClient.payrollAUV2Api.approveTimesheet(xeroClient.tenantId, timesheetID); + result = res.body.timesheet ?? null; + } else if (region === "UK") { + const res = await xeroClient.payrollUKApi.approveTimesheet(xeroClient.tenantId, timesheetID); + result = (res.body.timesheet ?? null) as unknown as NzTimesheet | null; + } else { + const res = await xeroClient.payrollNZApi.approveTimesheet(xeroClient.tenantId, timesheetID); + result = res.body.timesheet ?? null; + } -/** - * Approve a payroll timesheet in Xero - */ -export async function approveXeroPayrollTimesheet(timesheetID: string): Promise< - XeroClientResponse -> { - try { - const approvedTimesheet = await approveTimesheet(timesheetID); - - return { - result: approvedTimesheet, - isError: false, - error: null, - }; + return { result, isError: false, error: null }; } catch (error) { - return { - result: null, - isError: true, - error: formatError(error), - }; + return { result: null, isError: true, error: formatError(error) }; } -} \ No newline at end of file +} diff --git a/src/handlers/create-xero-payroll-timesheet.handler.ts b/src/handlers/create-xero-payroll-timesheet.handler.ts index d5dcd7d2..8c5f58c7 100644 --- a/src/handlers/create-xero-payroll-timesheet.handler.ts +++ b/src/handlers/create-xero-payroll-timesheet.handler.ts @@ -1,40 +1,59 @@ -import { Timesheet } from "xero-node/dist/gen/model/payroll-nz/timesheet.js"; +import { NzTimesheet } from "../types/payroll-nz-types.js"; +import { AuV2Timesheet } from "../types/payroll-au-v2-types.js"; import { xeroClient } from "../clients/xero-client.js"; import { formatError } from "../helpers/format-error.js"; import { XeroClientResponse } from "../types/tool-response.js"; -async function createTimesheet(timesheet: Timesheet): Promise { - await xeroClient.authenticate(); - - // Call the createTimesheet endpoint from the PayrollNZApi - const createdTimesheet = await xeroClient.payrollNZApi.createTimesheet( - xeroClient.tenantId, - timesheet, - ); - - return createdTimesheet.body.timesheet ?? null; +export interface CreateTimesheetParams { + employeeID: string; + startDate: string; + endDate: string; + payrollCalendarID: string; + timesheetLines?: Array<{ + earningsRateID: string; + numberOfUnits: number; + date: string; + trackingItemID?: string; + }>; } -/** - * Create a payroll timesheet in Xero - */ -export async function createXeroPayrollTimesheet(timesheet: Timesheet): Promise< - XeroClientResponse -> { +export async function createXeroPayrollTimesheet( + params: CreateTimesheetParams, +): Promise> { try { - const newTimesheet = await createTimesheet(timesheet); + await xeroClient.authenticate(); + const region = await xeroClient.getRegion(); - return { - result: newTimesheet, - isError: false, - error: null, + const timesheet = { + payrollCalendarID: params.payrollCalendarID, + employeeID: params.employeeID, + startDate: params.startDate, + endDate: params.endDate, + timesheetLines: params.timesheetLines?.map((line) => ({ + earningsRateID: line.earningsRateID, + numberOfUnits: line.numberOfUnits, + date: line.date, + trackingItemID: line.trackingItemID, + })), }; + + let result: NzTimesheet | AuV2Timesheet | null; + + if (region === "AU") { + const res = await xeroClient.payrollAUV2Api.createTimesheet(xeroClient.tenantId, timesheet as AuV2Timesheet); + result = res.body.timesheet ?? null; + } else if (region === "UK") { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const res = await xeroClient.payrollUKApi.createTimesheet(xeroClient.tenantId, timesheet as any); + result = (res.body.timesheet ?? null) as unknown as NzTimesheet | null; + } else { + const res = await xeroClient.payrollNZApi.createTimesheet(xeroClient.tenantId, timesheet as NzTimesheet); + result = res.body.timesheet ?? null; + } + + return { result, isError: false, error: null }; } catch (error) { - return { - result: null, - isError: true, - error: formatError(error), - }; + return { result: null, isError: true, error: formatError(error) }; } -} \ No newline at end of file +} diff --git a/src/handlers/delete-xero-payroll-timesheet.handler.ts b/src/handlers/delete-xero-payroll-timesheet.handler.ts index 0b90058a..09f13124 100644 --- a/src/handlers/delete-xero-payroll-timesheet.handler.ts +++ b/src/handlers/delete-xero-payroll-timesheet.handler.ts @@ -2,34 +2,23 @@ import { xeroClient } from "../clients/xero-client.js"; import { formatError } from "../helpers/format-error.js"; import { XeroClientResponse } from "../types/tool-response.js"; -async function deleteTimesheet(timesheetID: string): Promise { - await xeroClient.authenticate(); - - // Call the deleteTimesheet endpoint from the PayrollNZApi - await xeroClient.payrollNZApi.deleteTimesheet(xeroClient.tenantId, timesheetID); - - return true; -} - -/** - * Delete an existing payroll timesheet in Xero - */ -export async function deleteXeroPayrollTimesheet(timesheetID: string): Promise< - XeroClientResponse -> { +export async function deleteXeroPayrollTimesheet( + timesheetID: string, +): Promise> { try { - await deleteTimesheet(timesheetID); + await xeroClient.authenticate(); + const region = await xeroClient.getRegion(); + + if (region === "AU") { + await xeroClient.payrollAUV2Api.deleteTimesheet(xeroClient.tenantId, timesheetID); + } else if (region === "UK") { + await xeroClient.payrollUKApi.deleteTimesheet(xeroClient.tenantId, timesheetID); + } else { + await xeroClient.payrollNZApi.deleteTimesheet(xeroClient.tenantId, timesheetID); + } - return { - result: true, - isError: false, - error: null, - }; + return { result: true, isError: false, error: null }; } catch (error) { - return { - result: null, - isError: true, - error: formatError(error), - }; + return { result: null, isError: true, error: formatError(error) }; } -} \ No newline at end of file +} diff --git a/src/handlers/get-xero-payroll-timesheet.handler.ts b/src/handlers/get-xero-payroll-timesheet.handler.ts index 66e46ee3..b32d10b9 100644 --- a/src/handlers/get-xero-payroll-timesheet.handler.ts +++ b/src/handlers/get-xero-payroll-timesheet.handler.ts @@ -1,40 +1,32 @@ -import { Timesheet } from "xero-node/dist/gen/model/payroll-nz/timesheet.js"; +import { NzTimesheet } from "../types/payroll-nz-types.js"; +import { AuV2Timesheet } from "../types/payroll-au-v2-types.js"; import { xeroClient } from "../clients/xero-client.js"; import { formatError } from "../helpers/format-error.js"; import { XeroClientResponse } from "../types/tool-response.js"; -async function getTimesheet(timesheetID: string): Promise { - await xeroClient.authenticate(); +export async function getXeroPayrollTimesheet( + timesheetID: string, +): Promise> { + try { + await xeroClient.authenticate(); + const region = await xeroClient.getRegion(); - // Call the Timesheet endpoint from the PayrollNZApi - const timesheet = await xeroClient.payrollNZApi.getTimesheet( - xeroClient.tenantId, - timesheetID, - ); + let result: NzTimesheet | AuV2Timesheet | null; - return timesheet.body.timesheet ?? null; -} + if (region === "AU") { + const res = await xeroClient.payrollAUV2Api.getTimesheet(xeroClient.tenantId, timesheetID); + result = res.body.timesheet ?? null; + } else if (region === "UK") { + const res = await xeroClient.payrollUKApi.getTimesheet(xeroClient.tenantId, timesheetID); + result = (res.body.timesheet ?? null) as unknown as NzTimesheet | null; + } else { + const res = await xeroClient.payrollNZApi.getTimesheet(xeroClient.tenantId, timesheetID); + result = res.body.timesheet ?? null; + } -/** - * Get a single payroll timesheet from Xero - */ -export async function getXeroPayrollTimesheet(timesheetID: string): Promise< - XeroClientResponse -> { - try { - const timesheet = await getTimesheet(timesheetID); - - return { - result: timesheet, - isError: false, - error: null, - }; + return { result, isError: false, error: null }; } catch (error) { - return { - result: null, - isError: true, - error: formatError(error), - }; + return { result: null, isError: true, error: formatError(error) }; } -} \ No newline at end of file +} diff --git a/src/handlers/list-xero-timesheets.handler.ts b/src/handlers/list-xero-timesheets.handler.ts index 39d198a6..a99b3648 100644 --- a/src/handlers/list-xero-timesheets.handler.ts +++ b/src/handlers/list-xero-timesheets.handler.ts @@ -1,41 +1,51 @@ -import { Timesheet } from "xero-node/dist/gen/model/payroll-nz/timesheet.js"; +import { NzTimesheet } from "../types/payroll-nz-types.js"; +import { AuV2Timesheet } from "../types/payroll-au-v2-types.js"; import { xeroClient } from "../clients/xero-client.js"; import { formatError } from "../helpers/format-error.js"; import { XeroClientResponse } from "../types/tool-response.js"; -async function getTimesheets(): Promise { - await xeroClient.authenticate(); - - // Call the Timesheets endpoint from the PayrollNZApi - const timesheets = await xeroClient.payrollNZApi.getTimesheets( - xeroClient.tenantId, - undefined, // page - undefined, // filter - ); - - return timesheets.body.timesheets ?? []; +export interface ListTimesheetsParams { + page?: number; + filter?: string; + status?: string; + startDate?: string; + endDate?: string; + sort?: string; } -/** - * List all payroll timesheets from Xero - */ -export async function listXeroPayrollTimesheets(): Promise< - XeroClientResponse -> { +export async function listXeroPayrollTimesheets( + params: ListTimesheetsParams = {}, +): Promise> { try { - const timesheets = await getTimesheets(); + await xeroClient.authenticate(); + const region = await xeroClient.getRegion(); + + const args = [ + xeroClient.tenantId, + params.page, + params.filter, + params.status, + params.startDate, + params.endDate, + params.sort, + ] as const; - return { - result: timesheets, - isError: false, - error: null, - }; + let timesheets: (NzTimesheet | AuV2Timesheet)[]; + + if (region === "AU") { + const res = await xeroClient.payrollAUV2Api.getTimesheets(...args); + timesheets = res.body.timesheets ?? []; + } else if (region === "UK") { + const res = await xeroClient.payrollUKApi.getTimesheets(...args); + timesheets = (res.body.timesheets ?? []) as unknown as NzTimesheet[]; + } else { + const res = await xeroClient.payrollNZApi.getTimesheets(...args); + timesheets = res.body.timesheets ?? []; + } + + return { result: timesheets, isError: false, error: null }; } catch (error) { - return { - result: null, - isError: true, - error: formatError(error), - }; + return { result: null, isError: true, error: formatError(error) }; } -} \ No newline at end of file +} diff --git a/src/handlers/revert-xero-payroll-timesheet.handler.ts b/src/handlers/revert-xero-payroll-timesheet.handler.ts index db61214f..bf14fadf 100644 --- a/src/handlers/revert-xero-payroll-timesheet.handler.ts +++ b/src/handlers/revert-xero-payroll-timesheet.handler.ts @@ -1,40 +1,32 @@ -import { Timesheet } from "xero-node/dist/gen/model/payroll-nz/timesheet.js"; +import { NzTimesheet } from "../types/payroll-nz-types.js"; +import { AuV2Timesheet } from "../types/payroll-au-v2-types.js"; import { xeroClient } from "../clients/xero-client.js"; import { formatError } from "../helpers/format-error.js"; import { XeroClientResponse } from "../types/tool-response.js"; -async function revertTimesheet(timesheetID: string): Promise { - await xeroClient.authenticate(); +export async function revertXeroPayrollTimesheet( + timesheetID: string, +): Promise> { + try { + await xeroClient.authenticate(); + const region = await xeroClient.getRegion(); - // Call the revertTimesheet endpoint from the PayrollNZApi - const revertedTimesheet = await xeroClient.payrollNZApi.revertTimesheet( - xeroClient.tenantId, - timesheetID, - ); + let result: NzTimesheet | AuV2Timesheet | null; - return revertedTimesheet.body.timesheet ?? null; -} + if (region === "AU") { + const res = await xeroClient.payrollAUV2Api.revertTimesheet(xeroClient.tenantId, timesheetID); + result = res.body.timesheet ?? null; + } else if (region === "UK") { + const res = await xeroClient.payrollUKApi.revertTimesheet(xeroClient.tenantId, timesheetID); + result = (res.body.timesheet ?? null) as unknown as NzTimesheet | null; + } else { + const res = await xeroClient.payrollNZApi.revertTimesheet(xeroClient.tenantId, timesheetID); + result = res.body.timesheet ?? null; + } -/** - * Revert a payroll timesheet to draft in Xero - */ -export async function revertXeroPayrollTimesheet(timesheetID: string): Promise< - XeroClientResponse -> { - try { - const revertedTimesheet = await revertTimesheet(timesheetID); - - return { - result: revertedTimesheet, - isError: false, - error: null, - }; + return { result, isError: false, error: null }; } catch (error) { - return { - result: null, - isError: true, - error: formatError(error), - }; + return { result: null, isError: true, error: formatError(error) }; } -} \ No newline at end of file +} diff --git a/src/handlers/update-xero-payroll-timesheet-add-line.handler.ts b/src/handlers/update-xero-payroll-timesheet-add-line.handler.ts index 78a3e6e6..c4affcd7 100644 --- a/src/handlers/update-xero-payroll-timesheet-add-line.handler.ts +++ b/src/handlers/update-xero-payroll-timesheet-add-line.handler.ts @@ -1,43 +1,48 @@ -import { - TimesheetLine, -} from "xero-node/dist/gen/model/payroll-nz/timesheetLine.js"; +import { NzTimesheetLine } from "../types/payroll-nz-types.js"; +import { AuV2TimesheetLine } from "../types/payroll-au-v2-types.js"; import { xeroClient } from "../clients/xero-client.js"; import { formatError } from "../helpers/format-error.js"; import { XeroClientResponse } from "../types/tool-response.js"; -async function addTimesheetLine(timesheetID: string, timesheetLine: TimesheetLine): Promise { - await xeroClient.authenticate(); - - // Call the createTimesheetLine endpoint from the PayrollNZApi - const createdLine = await xeroClient.payrollNZApi.createTimesheetLine( - xeroClient.tenantId, - timesheetID, - timesheetLine, - ); - - return createdLine.body.timesheetLine ?? null; +export interface AddTimesheetLineParams { + timesheetID: string; + earningsRateID: string; + numberOfUnits: number; + date: string; + trackingItemID?: string; } -/** - * Add a timesheet line to an existing payroll timesheet in Xero - */ -export async function updateXeroPayrollTimesheetAddLine(timesheetID: string, timesheetLine: TimesheetLine): Promise< - XeroClientResponse -> { +export async function updateXeroPayrollTimesheetAddLine( + params: AddTimesheetLineParams, +): Promise> { try { - const newLine = await addTimesheetLine(timesheetID, timesheetLine); + await xeroClient.authenticate(); + const region = await xeroClient.getRegion(); - return { - result: newLine, - isError: false, - error: null, + const line = { + earningsRateID: params.earningsRateID, + numberOfUnits: params.numberOfUnits, + date: params.date, + trackingItemID: params.trackingItemID, }; + + let result: NzTimesheetLine | AuV2TimesheetLine | null; + + if (region === "AU") { + const res = await xeroClient.payrollAUV2Api.createTimesheetLine(xeroClient.tenantId, params.timesheetID, line as AuV2TimesheetLine); + result = res.body.timesheetLine ?? null; + } else if (region === "UK") { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const res = await xeroClient.payrollUKApi.createTimesheetLine(xeroClient.tenantId, params.timesheetID, line as any); + result = (res.body.timesheetLine ?? null) as unknown as NzTimesheetLine | null; + } else { + const res = await xeroClient.payrollNZApi.createTimesheetLine(xeroClient.tenantId, params.timesheetID, line as NzTimesheetLine); + result = res.body.timesheetLine ?? null; + } + + return { result, isError: false, error: null }; } catch (error) { - return { - result: null, - isError: true, - error: formatError(error), - }; + return { result: null, isError: true, error: formatError(error) }; } -} \ No newline at end of file +} diff --git a/src/handlers/update-xero-payroll-timesheet-update-line.handler.ts b/src/handlers/update-xero-payroll-timesheet-update-line.handler.ts index 23b575eb..17f37d0b 100644 --- a/src/handlers/update-xero-payroll-timesheet-update-line.handler.ts +++ b/src/handlers/update-xero-payroll-timesheet-update-line.handler.ts @@ -1,50 +1,49 @@ -import { - TimesheetLine, -} from "xero-node/dist/gen/model/payroll-nz/timesheetLine.js"; +import { NzTimesheetLine } from "../types/payroll-nz-types.js"; +import { AuV2TimesheetLine } from "../types/payroll-au-v2-types.js"; import { xeroClient } from "../clients/xero-client.js"; import { formatError } from "../helpers/format-error.js"; import { XeroClientResponse } from "../types/tool-response.js"; -async function updateTimesheetLine( - timesheetID: string, - timesheetLineID: string, - timesheetLine: TimesheetLine -): Promise { - await xeroClient.authenticate(); - - // Call the updateTimesheetLine endpoint from the PayrollNZApi - const updatedLine = await xeroClient.payrollNZApi.updateTimesheetLine( - xeroClient.tenantId, - timesheetID, - timesheetLineID, - timesheetLine, - ); - - return updatedLine.body.timesheetLine ?? null; +export interface UpdateTimesheetLineParams { + timesheetID: string; + timesheetLineID: string; + earningsRateID: string; + numberOfUnits: number; + date: string; + trackingItemID?: string; } -/** - * Update an existing timesheet line in a payroll timesheet in Xero - */ export async function updateXeroPayrollTimesheetUpdateLine( - timesheetID: string, - timesheetLineID: string, - timesheetLine: TimesheetLine -): Promise> { + params: UpdateTimesheetLineParams, +): Promise> { try { - const updatedLine = await updateTimesheetLine(timesheetID, timesheetLineID, timesheetLine); + await xeroClient.authenticate(); + const region = await xeroClient.getRegion(); - return { - result: updatedLine, - isError: false, - error: null, + const line = { + earningsRateID: params.earningsRateID, + numberOfUnits: params.numberOfUnits, + date: params.date, + trackingItemID: params.trackingItemID, }; + + let result: NzTimesheetLine | AuV2TimesheetLine | null; + + if (region === "AU") { + const res = await xeroClient.payrollAUV2Api.updateTimesheetLine(xeroClient.tenantId, params.timesheetID, params.timesheetLineID, line as AuV2TimesheetLine); + result = res.body.timesheetLine ?? null; + } else if (region === "UK") { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const res = await xeroClient.payrollUKApi.updateTimesheetLine(xeroClient.tenantId, params.timesheetID, params.timesheetLineID, line as any); + result = (res.body.timesheetLine ?? null) as unknown as NzTimesheetLine | null; + } else { + const res = await xeroClient.payrollNZApi.updateTimesheetLine(xeroClient.tenantId, params.timesheetID, params.timesheetLineID, line as NzTimesheetLine); + result = res.body.timesheetLine ?? null; + } + + return { result, isError: false, error: null }; } catch (error) { - return { - result: null, - isError: true, - error: formatError(error), - }; + return { result: null, isError: true, error: formatError(error) }; } -} \ No newline at end of file +} diff --git a/src/tools/create/create-payroll-timesheet.tool.ts b/src/tools/create/create-payroll-timesheet.tool.ts index 59ec3f98..f4b6ad88 100644 --- a/src/tools/create/create-payroll-timesheet.tool.ts +++ b/src/tools/create/create-payroll-timesheet.tool.ts @@ -1,15 +1,15 @@ -import { Timesheet } from "xero-node/dist/gen/model/payroll-nz/timesheet.js"; import { z } from "zod"; import { createXeroPayrollTimesheet, + CreateTimesheetParams, } from "../../handlers/create-xero-payroll-timesheet.handler.js"; import { CreateXeroTool } from "../../helpers/create-xero-tool.js"; const CreatePayrollTimesheetTool = CreateXeroTool( "create-timesheet", `Create a new payroll timesheet in Xero. -This allows you to specify details such as the employee ID, payroll calendar ID, start and end dates, and timesheet lines.`, +Supports AU, NZ, and UK payroll regions. The organisation's region is auto-detected.`, { payrollCalendarID: z.string().describe("The ID of the payroll calendar."), employeeID: z.string().describe("The ID of the employee."), @@ -19,14 +19,15 @@ This allows you to specify details such as the employee ID, payroll calendar ID, .array( z.object({ earningsRateID: z.string().describe("The ID of the earnings rate."), - numberOfUnits: z.number().describe("The number of units for the timesheet line."), + numberOfUnits: z.number().describe("The number of units (hours) for this line."), date: z.string().describe("The date for the timesheet line (YYYY-MM-DD)."), + trackingItemID: z.string().optional().describe("Optional tracking category item ID."), }) ) .optional() .describe("The lines of the timesheet."), }, - async (params: Timesheet) => { + async (params: CreateTimesheetParams) => { const response = await createXeroPayrollTimesheet(params); if (response.isError) { @@ -53,4 +54,4 @@ This allows you to specify details such as the employee ID, payroll calendar ID, }, ); -export default CreatePayrollTimesheetTool; \ No newline at end of file +export default CreatePayrollTimesheetTool; diff --git a/src/tools/delete/delete-payroll-timesheet.tool.ts b/src/tools/delete/delete-payroll-timesheet.tool.ts index 8529a29c..c4935e3b 100644 --- a/src/tools/delete/delete-payroll-timesheet.tool.ts +++ b/src/tools/delete/delete-payroll-timesheet.tool.ts @@ -7,7 +7,8 @@ import { CreateXeroTool } from "../../helpers/create-xero-tool.js"; const DeletePayrollTimesheetTool = CreateXeroTool( "delete-timesheet", - `Delete an existing payroll timesheet in Xero by its ID.`, + `Delete an existing payroll timesheet in Xero by its ID. +Supports AU, NZ, and UK payroll regions. The organisation's region is auto-detected.`, { timesheetID: z.string().describe("The ID of the timesheet to delete."), }, diff --git a/src/tools/get/get-payroll-timesheet.tool.ts b/src/tools/get/get-payroll-timesheet.tool.ts index 034c29c3..1bf4b7c2 100644 --- a/src/tools/get/get-payroll-timesheet.tool.ts +++ b/src/tools/get/get-payroll-timesheet.tool.ts @@ -8,7 +8,8 @@ import { CreateXeroTool } from "../../helpers/create-xero-tool.js"; const GetPayrollTimesheetTool = CreateXeroTool( "get-timesheet", `Retrieve a single payroll timesheet from Xero by its ID. -This provides details such as the timesheet ID, employee ID, start and end dates, total hours, and the last updated date.`, +This provides details such as the timesheet ID, employee ID, start and end dates, total hours, and the last updated date. +Supports AU, NZ, and UK payroll regions. The organisation's region is auto-detected.`, { timesheetID: z.string().describe("The ID of the timesheet to retrieve."), }, @@ -40,20 +41,14 @@ This provides details such as the timesheet ID, employee ID, start and end dates }; } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ts = timesheet as any; + return { content: [ { type: "text" as const, - text: [ - `Timesheet ID: ${timesheet.timesheetID}`, - `Employee ID: ${timesheet.employeeID}`, - `Start Date: ${timesheet.startDate}`, - `End Date: ${timesheet.endDate}`, - `Total Hours: ${timesheet.totalHours}`, - `Last Updated: ${timesheet.updatedDateUTC}`, - ] - .filter(Boolean) - .join("\n"), + text: JSON.stringify(ts, null, 2), }, ], }; diff --git a/src/tools/list/list-payroll-timesheets.tool.ts b/src/tools/list/list-payroll-timesheets.tool.ts index 5ffd7695..0393517f 100644 --- a/src/tools/list/list-payroll-timesheets.tool.ts +++ b/src/tools/list/list-payroll-timesheets.tool.ts @@ -1,17 +1,26 @@ -import { Timesheet } from "xero-node/dist/gen/model/payroll-nz/timesheet.js"; +import { z } from "zod"; import { listXeroPayrollTimesheets, + ListTimesheetsParams, } from "../../handlers/list-xero-timesheets.handler.js"; import { CreateXeroTool } from "../../helpers/create-xero-tool.js"; const ListPayrollTimesheetsTool = CreateXeroTool( "list-timesheets", `List all payroll timesheets in Xero. -This retrieves comprehensive timesheet details including timesheet IDs, employee IDs, start and end dates, total hours, and the last updated date.`, - {}, - async () => { - const response = await listXeroPayrollTimesheets(); +This retrieves comprehensive timesheet details including timesheet IDs, employee IDs, start and end dates, total hours, and the last updated date. +Supports AU, NZ, and UK payroll regions. The organisation's region is auto-detected.`, + { + page: z.number().optional().describe("Page number for pagination."), + filter: z.string().optional().describe("Filter by employeeId and/or payrollCalendarId."), + status: z.string().optional().describe("Filter by timesheet status (Draft, Approved, Completed)."), + startDate: z.string().optional().describe("Return timesheets with startDate on or after this date (YYYY-MM-DD)."), + endDate: z.string().optional().describe("Return timesheets with endDate on or before this date (YYYY-MM-DD)."), + sort: z.string().optional().describe("Sort order: createdDate (default) or startDate."), + }, + async (params: ListTimesheetsParams) => { + const response = await listXeroPayrollTimesheets(params); if (response.isError) { return { @@ -32,14 +41,16 @@ This retrieves comprehensive timesheet details including timesheet IDs, employee type: "text" as const, text: `Found ${timesheets?.length || 0} timesheets:`, }, - ...(timesheets?.map((timesheet: Timesheet) => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...(timesheets?.map((timesheet: any) => ({ type: "text" as const, text: [ `Timesheet ID: ${timesheet.timesheetID}`, `Employee ID: ${timesheet.employeeID}`, `Start Date: ${timesheet.startDate}`, `End Date: ${timesheet.endDate}`, - `Total Hours: ${timesheet.totalHours}`, + `Status: ${timesheet.status}`, + `Total Hours: ${timesheet.totalHours ?? "N/A"}`, `Last Updated: ${timesheet.updatedDateUTC}`, ] .filter(Boolean) @@ -50,4 +61,4 @@ This retrieves comprehensive timesheet details including timesheet IDs, employee }, ); -export default ListPayrollTimesheetsTool; \ No newline at end of file +export default ListPayrollTimesheetsTool; diff --git a/src/tools/update/approve-payroll-timesheet.tool.ts b/src/tools/update/approve-payroll-timesheet.tool.ts index a9daf330..9a9ffbbd 100644 --- a/src/tools/update/approve-payroll-timesheet.tool.ts +++ b/src/tools/update/approve-payroll-timesheet.tool.ts @@ -7,7 +7,8 @@ import { CreateXeroTool } from "../../helpers/create-xero-tool.js"; const ApprovePayrollTimesheetTool = CreateXeroTool( "approve-timesheet", - `Approve a payroll timesheet in Xero by its ID.`, + `Approve a payroll timesheet in Xero by its ID. +Supports AU, NZ, and UK payroll regions. The organisation's region is auto-detected.`, { timesheetID: z.string().describe("The ID of the timesheet to approve."), }, diff --git a/src/tools/update/revert-payroll-timesheet.tool.ts b/src/tools/update/revert-payroll-timesheet.tool.ts index 17ac5f4a..adc94179 100644 --- a/src/tools/update/revert-payroll-timesheet.tool.ts +++ b/src/tools/update/revert-payroll-timesheet.tool.ts @@ -7,7 +7,8 @@ import { CreateXeroTool } from "../../helpers/create-xero-tool.js"; const RevertPayrollTimesheetTool = CreateXeroTool( "revert-timesheet", - `Revert a payroll timesheet to draft in Xero by its ID.`, + `Revert a payroll timesheet to draft in Xero by its ID. +Supports AU, NZ, and UK payroll regions. The organisation's region is auto-detected.`, { timesheetID: z.string().describe("The ID of the timesheet to revert."), }, diff --git a/src/tools/update/update-payroll-timesheet-add-line.tool.ts b/src/tools/update/update-payroll-timesheet-add-line.tool.ts index 498efecf..7a5b2dc1 100644 --- a/src/tools/update/update-payroll-timesheet-add-line.tool.ts +++ b/src/tools/update/update-payroll-timesheet-add-line.tool.ts @@ -1,27 +1,34 @@ -import { - TimesheetLine, -} from "xero-node/dist/gen/model/payroll-nz/timesheetLine.js"; import { z } from "zod"; import { updateXeroPayrollTimesheetAddLine, + AddTimesheetLineParams, } from "../../handlers/update-xero-payroll-timesheet-add-line.handler.js"; import { CreateXeroTool } from "../../helpers/create-xero-tool.js"; const AddTimesheetLineTool = CreateXeroTool( "add-timesheet-line", - `Add a new timesheet line to an existing payroll timesheet in Xero.`, + `Add a new timesheet line to an existing payroll timesheet in Xero. +Supports AU, NZ, and UK payroll regions. The organisation's region is auto-detected.`, { timesheetID: z.string().describe("The ID of the timesheet to update."), timesheetLine: z.object({ earningsRateID: z.string().describe("The ID of the earnings rate."), - numberOfUnits: z.number().describe("The number of units for the timesheet line."), + numberOfUnits: z.number().describe("The number of units (hours) for this line."), date: z.string().describe("The date for the timesheet line (YYYY-MM-DD)."), + trackingItemID: z.string().optional().describe("Optional tracking category item ID."), }).describe("The details of the timesheet line to add."), }, - async (params: { timesheetID: string; timesheetLine: TimesheetLine }) => { - const { timesheetID, timesheetLine } = params; - const response = await updateXeroPayrollTimesheetAddLine(timesheetID, timesheetLine); + async (params: { timesheetID: string; timesheetLine: { earningsRateID: string; numberOfUnits: number; date: string; trackingItemID?: string } }) => { + const addParams: AddTimesheetLineParams = { + timesheetID: params.timesheetID, + earningsRateID: params.timesheetLine.earningsRateID, + numberOfUnits: params.timesheetLine.numberOfUnits, + date: params.timesheetLine.date, + trackingItemID: params.timesheetLine.trackingItemID, + }; + + const response = await updateXeroPayrollTimesheetAddLine(addParams); if (response.isError) { return { @@ -34,17 +41,15 @@ const AddTimesheetLineTool = CreateXeroTool( }; } - const newLine = response.result; - return { content: [ { type: "text" as const, - text: `Successfully added timesheet line with date: ${newLine?.date}`, + text: `Successfully added timesheet line for earnings rate: ${params.timesheetLine.earningsRateID}`, }, ], }; }, ); -export default AddTimesheetLineTool; \ No newline at end of file +export default AddTimesheetLineTool; diff --git a/src/tools/update/update-payroll-timesheet-update-line.tool.ts b/src/tools/update/update-payroll-timesheet-update-line.tool.ts index 58afbb0c..23999a59 100644 --- a/src/tools/update/update-payroll-timesheet-update-line.tool.ts +++ b/src/tools/update/update-payroll-timesheet-update-line.tool.ts @@ -1,28 +1,36 @@ -import { - TimesheetLine, -} from "xero-node/dist/gen/model/payroll-nz/timesheetLine.js"; import { z } from "zod"; import { updateXeroPayrollTimesheetUpdateLine, + UpdateTimesheetLineParams, } from "../../handlers/update-xero-payroll-timesheet-update-line.handler.js"; import { CreateXeroTool } from "../../helpers/create-xero-tool.js"; const UpdatePayrollTimesheetLineTool = CreateXeroTool( "update-timesheet-line", - `Update an existing timesheet line in a payroll timesheet in Xero.`, + `Update an existing timesheet line in a payroll timesheet in Xero. +Supports AU, NZ, and UK payroll regions. The organisation's region is auto-detected.`, { timesheetID: z.string().describe("The ID of the timesheet to update."), timesheetLineID: z.string().describe("The ID of the timesheet line to update."), timesheetLine: z.object({ earningsRateID: z.string().describe("The ID of the earnings rate."), - numberOfUnits: z.number().describe("The number of units for the timesheet line."), + numberOfUnits: z.number().describe("The number of units (hours) for this line."), date: z.string().describe("The date for the timesheet line (YYYY-MM-DD)."), + trackingItemID: z.string().optional().describe("Optional tracking category item ID."), }).describe("The details of the timesheet line to update."), }, - async (params: { timesheetID: string; timesheetLineID: string; timesheetLine: TimesheetLine }) => { - const { timesheetID, timesheetLineID, timesheetLine } = params; - const response = await updateXeroPayrollTimesheetUpdateLine(timesheetID, timesheetLineID, timesheetLine); + async (params: { timesheetID: string; timesheetLineID: string; timesheetLine: { earningsRateID: string; numberOfUnits: number; date: string; trackingItemID?: string } }) => { + const updateParams: UpdateTimesheetLineParams = { + timesheetID: params.timesheetID, + timesheetLineID: params.timesheetLineID, + earningsRateID: params.timesheetLine.earningsRateID, + numberOfUnits: params.timesheetLine.numberOfUnits, + date: params.timesheetLine.date, + trackingItemID: params.timesheetLine.trackingItemID, + }; + + const response = await updateXeroPayrollTimesheetUpdateLine(updateParams); if (response.isError) { return { @@ -35,17 +43,15 @@ const UpdatePayrollTimesheetLineTool = CreateXeroTool( }; } - const updatedLine = response.result; - return { content: [ { type: "text" as const, - text: `Successfully updated timesheet line with date: ${updatedLine?.date}`, + text: `Successfully updated timesheet line for earnings rate: ${params.timesheetLine.earningsRateID}`, }, ], }; }, ); -export default UpdatePayrollTimesheetLineTool; \ No newline at end of file +export default UpdatePayrollTimesheetLineTool; From 1d9d00fbebb75a10a642c0f17cd43f0c771c81b6 Mon Sep 17 00:00:00 2001 From: Elliot Chisholm Date: Fri, 24 Apr 2026 20:35:20 +1200 Subject: [PATCH 05/13] Add delete-timesheet-line operation for all regions New handler, tool, and tests for DELETE /timesheets/{id}/lines/{lineId}. Previously this operation had no MCP tool. Supports AU (via PayrollAuV2Api), NZ, and UK. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...ero-payroll-timesheet-line.handler.test.ts | 71 +++++++++++++++++++ ...ete-xero-payroll-timesheet-line.handler.ts | 25 +++++++ .../delete-payroll-timesheet-line.tool.ts | 41 +++++++++++ src/tools/delete/index.ts | 4 +- 4 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 src/handlers/__tests__/delete-xero-payroll-timesheet-line.handler.test.ts create mode 100644 src/handlers/delete-xero-payroll-timesheet-line.handler.ts create mode 100644 src/tools/delete/delete-payroll-timesheet-line.tool.ts diff --git a/src/handlers/__tests__/delete-xero-payroll-timesheet-line.handler.test.ts b/src/handlers/__tests__/delete-xero-payroll-timesheet-line.handler.test.ts new file mode 100644 index 00000000..ecc7cd69 --- /dev/null +++ b/src/handlers/__tests__/delete-xero-payroll-timesheet-line.handler.test.ts @@ -0,0 +1,71 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; + +const { mockXeroClient } = vi.hoisted(() => ({ + mockXeroClient: { + authenticate: vi.fn(), + getRegion: vi.fn(), + tenantId: "test-tenant-id", + payrollAUV2Api: { deleteTimesheetLine: vi.fn() }, + payrollNZApi: { deleteTimesheetLine: vi.fn() }, + payrollUKApi: { deleteTimesheetLine: vi.fn() }, + }, +})); + +vi.mock("../../clients/xero-client.js", () => ({ + xeroClient: mockXeroClient, +})); + +import { deleteXeroPayrollTimesheetLine } from "../delete-xero-payroll-timesheet-line.handler.js"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("deleteXeroPayrollTimesheetLine", () => { + describe("AU region", () => { + it("calls payrollAUV2Api.deleteTimesheetLine", async () => { + mockXeroClient.getRegion.mockResolvedValue("AU"); + mockXeroClient.payrollAUV2Api.deleteTimesheetLine.mockResolvedValue({}); + + const result = await deleteXeroPayrollTimesheetLine("ts-1", "line-1"); + + expect(mockXeroClient.payrollAUV2Api.deleteTimesheetLine).toHaveBeenCalledWith("test-tenant-id", "ts-1", "line-1"); + expect(result.isError).toBe(false); + expect(result.result).toBe(true); + }); + }); + + describe("NZ region", () => { + it("calls payrollNZApi.deleteTimesheetLine", async () => { + mockXeroClient.getRegion.mockResolvedValue("NZ"); + mockXeroClient.payrollNZApi.deleteTimesheetLine.mockResolvedValue({}); + + const result = await deleteXeroPayrollTimesheetLine("ts-1", "line-1"); + + expect(mockXeroClient.payrollNZApi.deleteTimesheetLine).toHaveBeenCalledWith("test-tenant-id", "ts-1", "line-1"); + expect(result.isError).toBe(false); + expect(result.result).toBe(true); + }); + }); + + describe("UK region", () => { + it("calls payrollUKApi.deleteTimesheetLine", async () => { + mockXeroClient.getRegion.mockResolvedValue("UK"); + mockXeroClient.payrollUKApi.deleteTimesheetLine.mockResolvedValue({}); + + const result = await deleteXeroPayrollTimesheetLine("ts-1", "line-1"); + + expect(mockXeroClient.payrollUKApi.deleteTimesheetLine).toHaveBeenCalledWith("test-tenant-id", "ts-1", "line-1"); + expect(mockXeroClient.payrollNZApi.deleteTimesheetLine).not.toHaveBeenCalled(); + }); + }); + + it("returns error response on API failure", async () => { + mockXeroClient.getRegion.mockResolvedValue("NZ"); + mockXeroClient.payrollNZApi.deleteTimesheetLine.mockRejectedValue(new Error("Cannot delete line")); + + const result = await deleteXeroPayrollTimesheetLine("ts-1", "line-1"); + expect(result.isError).toBe(true); + expect(result.error).toBe("Cannot delete line"); + }); +}); diff --git a/src/handlers/delete-xero-payroll-timesheet-line.handler.ts b/src/handlers/delete-xero-payroll-timesheet-line.handler.ts new file mode 100644 index 00000000..45464818 --- /dev/null +++ b/src/handlers/delete-xero-payroll-timesheet-line.handler.ts @@ -0,0 +1,25 @@ +import { xeroClient } from "../clients/xero-client.js"; +import { formatError } from "../helpers/format-error.js"; +import { XeroClientResponse } from "../types/tool-response.js"; + +export async function deleteXeroPayrollTimesheetLine( + timesheetID: string, + timesheetLineID: string, +): Promise> { + try { + await xeroClient.authenticate(); + const region = await xeroClient.getRegion(); + + if (region === "AU") { + await xeroClient.payrollAUV2Api.deleteTimesheetLine(xeroClient.tenantId, timesheetID, timesheetLineID); + } else if (region === "UK") { + await xeroClient.payrollUKApi.deleteTimesheetLine(xeroClient.tenantId, timesheetID, timesheetLineID); + } else { + await xeroClient.payrollNZApi.deleteTimesheetLine(xeroClient.tenantId, timesheetID, timesheetLineID); + } + + return { result: true, isError: false, error: null }; + } catch (error) { + return { result: null, isError: true, error: formatError(error) }; + } +} diff --git a/src/tools/delete/delete-payroll-timesheet-line.tool.ts b/src/tools/delete/delete-payroll-timesheet-line.tool.ts new file mode 100644 index 00000000..62f4614d --- /dev/null +++ b/src/tools/delete/delete-payroll-timesheet-line.tool.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; + +import { + deleteXeroPayrollTimesheetLine, +} from "../../handlers/delete-xero-payroll-timesheet-line.handler.js"; +import { CreateXeroTool } from "../../helpers/create-xero-tool.js"; + +const DeletePayrollTimesheetLineTool = CreateXeroTool( + "delete-timesheet-line", + `Delete a specific line from a payroll timesheet in Xero. +Supports AU, NZ, and UK payroll regions. The organisation's region is auto-detected.`, + { + timesheetID: z.string().describe("The ID of the timesheet containing the line."), + timesheetLineID: z.string().describe("The ID of the timesheet line to delete."), + }, + async (params: { timesheetID: string; timesheetLineID: string }) => { + const response = await deleteXeroPayrollTimesheetLine(params.timesheetID, params.timesheetLineID); + + if (response.isError) { + return { + content: [ + { + type: "text" as const, + text: `Error deleting timesheet line: ${response.error}`, + }, + ], + }; + } + + return { + content: [ + { + type: "text" as const, + text: `Successfully deleted timesheet line with ID: ${params.timesheetLineID}`, + }, + ], + }; + }, +); + +export default DeletePayrollTimesheetLineTool; diff --git a/src/tools/delete/index.ts b/src/tools/delete/index.ts index b3d11429..7425b980 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 DeletePayrollTimesheetLineTool from "./delete-payroll-timesheet-line.tool.js"; export const DeleteTools = [ - DeletePayrollTimesheetTool + DeletePayrollTimesheetTool, + DeletePayrollTimesheetLineTool, ]; From 2be4430724d261eba217a63426edf1c85cc89b58 Mon Sep 17 00:00:00 2001 From: Elliot Chisholm Date: Fri, 24 Apr 2026 21:14:22 +1200 Subject: [PATCH 06/13] Add payroll calendar and earnings rate listing tools New tools: - list-payroll-calendars: shows pay calendar names, types, and upcoming payment dates. AU uses payrollAUApi.getPayrollCalendars (v1), NZ/UK use getPayRunCalendars. - list-earnings-rates: shows earnings rate IDs, names, and types needed for creating timesheet lines. AU uses payrollAUApi.getPayItems (v1) and extracts the earningsRates array, NZ/UK use getEarningsRates. Both tools support AU, NZ, and UK with region auto-detection. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../list-xero-earnings-rates.handler.test.ts | 113 ++++++++++++++++++ ...ist-xero-payroll-calendars.handler.test.ts | 110 +++++++++++++++++ .../list-xero-earnings-rates.handler.ts | 68 +++++++++++ .../list-xero-payroll-calendars.handler.ts | 64 ++++++++++ src/tools/list/index.ts | 6 +- src/tools/list/list-earnings-rates.tool.ts | 60 ++++++++++ src/tools/list/list-payroll-calendars.tool.ts | 58 +++++++++ src/types/payroll-au-types.ts | 3 + src/types/payroll-nz-types.ts | 2 + 9 files changed, 483 insertions(+), 1 deletion(-) create mode 100644 src/handlers/__tests__/list-xero-earnings-rates.handler.test.ts create mode 100644 src/handlers/__tests__/list-xero-payroll-calendars.handler.test.ts create mode 100644 src/handlers/list-xero-earnings-rates.handler.ts create mode 100644 src/handlers/list-xero-payroll-calendars.handler.ts create mode 100644 src/tools/list/list-earnings-rates.tool.ts create mode 100644 src/tools/list/list-payroll-calendars.tool.ts diff --git a/src/handlers/__tests__/list-xero-earnings-rates.handler.test.ts b/src/handlers/__tests__/list-xero-earnings-rates.handler.test.ts new file mode 100644 index 00000000..c7cd898d --- /dev/null +++ b/src/handlers/__tests__/list-xero-earnings-rates.handler.test.ts @@ -0,0 +1,113 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; + +const { mockXeroClient } = vi.hoisted(() => ({ + mockXeroClient: { + authenticate: vi.fn(), + getRegion: vi.fn(), + tenantId: "test-tenant-id", + payrollAUApi: { getPayItems: vi.fn() }, + payrollNZApi: { getEarningsRates: vi.fn() }, + payrollUKApi: { getEarningsRates: vi.fn() }, + }, +})); + +vi.mock("../../clients/xero-client.js", () => ({ + xeroClient: mockXeroClient, +})); + +import { listXeroEarningsRates } from "../list-xero-earnings-rates.handler.js"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("listXeroEarningsRates", () => { + describe("AU region", () => { + beforeEach(() => { + mockXeroClient.getRegion.mockResolvedValue("AU"); + }); + + it("calls payrollAUApi.getPayItems and extracts earningsRates", async () => { + mockXeroClient.payrollAUApi.getPayItems.mockResolvedValue({ + body: { + payItems: { + earningsRates: [ + { + earningsRateID: "rate-1", + name: "Regular Hours", + earningsType: "ORDINARYTIMEEARNINGS", + rateType: "RATEPERUNIT", + ratePerUnit: 25.0, + }, + { + earningsRateID: "rate-2", + name: "Overtime", + earningsType: "OVERTIMEEARNINGS", + rateType: "RATEPERUNIT", + ratePerUnit: 37.5, + }, + ], + deductionTypes: [], + leaveTypes: [], + }, + }, + }); + + const result = await listXeroEarningsRates({}); + + expect(mockXeroClient.payrollAUApi.getPayItems).toHaveBeenCalled(); + expect(result.isError).toBe(false); + expect(result.result).toHaveLength(2); + expect(result.result![0].name).toBe("Regular Hours"); + expect(result.result![1].name).toBe("Overtime"); + }); + }); + + describe("NZ region", () => { + it("calls payrollNZApi.getEarningsRates", async () => { + mockXeroClient.getRegion.mockResolvedValue("NZ"); + mockXeroClient.payrollNZApi.getEarningsRates.mockResolvedValue({ + body: { + earningsRates: [ + { + earningsRateID: "rate-1", + name: "Ordinary Time", + earningsType: "RegularEarnings", + rateType: "RatePerUnit", + ratePerUnit: 30.0, + }, + ], + }, + }); + + const result = await listXeroEarningsRates({}); + + expect(mockXeroClient.payrollNZApi.getEarningsRates).toHaveBeenCalledWith("test-tenant-id", undefined); + expect(result.isError).toBe(false); + expect(result.result).toHaveLength(1); + }); + }); + + describe("UK region", () => { + it("calls payrollUKApi.getEarningsRates", async () => { + mockXeroClient.getRegion.mockResolvedValue("UK"); + mockXeroClient.payrollUKApi.getEarningsRates.mockResolvedValue({ + body: { earningsRates: [] }, + }); + + await listXeroEarningsRates({}); + + expect(mockXeroClient.payrollUKApi.getEarningsRates).toHaveBeenCalled(); + expect(mockXeroClient.payrollNZApi.getEarningsRates).not.toHaveBeenCalled(); + }); + }); + + it("returns error response on API failure", async () => { + mockXeroClient.getRegion.mockResolvedValue("NZ"); + mockXeroClient.payrollNZApi.getEarningsRates.mockRejectedValue(new Error("API error")); + + const result = await listXeroEarningsRates({}); + expect(result.isError).toBe(true); + expect(result.error).toBe("API error"); + }); +}); diff --git a/src/handlers/__tests__/list-xero-payroll-calendars.handler.test.ts b/src/handlers/__tests__/list-xero-payroll-calendars.handler.test.ts new file mode 100644 index 00000000..03fda15d --- /dev/null +++ b/src/handlers/__tests__/list-xero-payroll-calendars.handler.test.ts @@ -0,0 +1,110 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; + +const { mockXeroClient } = vi.hoisted(() => ({ + mockXeroClient: { + authenticate: vi.fn(), + getRegion: vi.fn(), + tenantId: "test-tenant-id", + payrollAUApi: { getPayrollCalendars: vi.fn() }, + payrollNZApi: { getPayRunCalendars: vi.fn() }, + payrollUKApi: { getPayRunCalendars: vi.fn() }, + }, +})); + +vi.mock("../../clients/xero-client.js", () => ({ + xeroClient: mockXeroClient, +})); + +import { listXeroPayrollCalendars } from "../list-xero-payroll-calendars.handler.js"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("listXeroPayrollCalendars", () => { + describe("AU region", () => { + beforeEach(() => { + mockXeroClient.getRegion.mockResolvedValue("AU"); + }); + + it("calls payrollAUApi.getPayrollCalendars and maps fields", async () => { + mockXeroClient.payrollAUApi.getPayrollCalendars.mockResolvedValue({ + body: { + payrollCalendars: [ + { + payrollCalendarID: "cal-1", + name: "Weekly", + calendarType: "WEEKLY", + startDate: "2024-01-15", + paymentDate: "2024-01-22", + }, + ], + }, + }); + + const result = await listXeroPayrollCalendars({}); + + expect(mockXeroClient.payrollAUApi.getPayrollCalendars).toHaveBeenCalledWith( + "test-tenant-id", + undefined, + undefined, + undefined, + undefined, + ); + expect(result.isError).toBe(false); + expect(result.result).toHaveLength(1); + expect(result.result![0].periodStartDate).toBe("2024-01-15"); + expect(result.result![0].paymentDate).toBe("2024-01-22"); + }); + }); + + describe("NZ region", () => { + it("calls payrollNZApi.getPayRunCalendars", async () => { + mockXeroClient.getRegion.mockResolvedValue("NZ"); + mockXeroClient.payrollNZApi.getPayRunCalendars.mockResolvedValue({ + body: { + payRunCalendars: [ + { + payrollCalendarID: "cal-1", + name: "Monthly", + calendarType: "MONTHLY", + periodStartDate: "2024-01-01", + periodEndDate: "2024-01-31", + paymentDate: "2024-02-01", + }, + ], + }, + }); + + const result = await listXeroPayrollCalendars({}); + + expect(mockXeroClient.payrollNZApi.getPayRunCalendars).toHaveBeenCalledWith("test-tenant-id", undefined); + expect(result.isError).toBe(false); + expect(result.result![0].periodStartDate).toBe("2024-01-01"); + expect(result.result![0].periodEndDate).toBe("2024-01-31"); + }); + }); + + describe("UK region", () => { + it("calls payrollUKApi.getPayRunCalendars", async () => { + mockXeroClient.getRegion.mockResolvedValue("UK"); + mockXeroClient.payrollUKApi.getPayRunCalendars.mockResolvedValue({ + body: { payRunCalendars: [] }, + }); + + await listXeroPayrollCalendars({}); + + expect(mockXeroClient.payrollUKApi.getPayRunCalendars).toHaveBeenCalled(); + expect(mockXeroClient.payrollNZApi.getPayRunCalendars).not.toHaveBeenCalled(); + }); + }); + + it("returns error response on API failure", async () => { + mockXeroClient.getRegion.mockResolvedValue("AU"); + mockXeroClient.payrollAUApi.getPayrollCalendars.mockRejectedValue(new Error("Unauthorized")); + + const result = await listXeroPayrollCalendars({}); + expect(result.isError).toBe(true); + expect(result.error).toBe("Unauthorized"); + }); +}); diff --git a/src/handlers/list-xero-earnings-rates.handler.ts b/src/handlers/list-xero-earnings-rates.handler.ts new file mode 100644 index 00000000..7cc7ed31 --- /dev/null +++ b/src/handlers/list-xero-earnings-rates.handler.ts @@ -0,0 +1,68 @@ +import { NzEarningsRate } from "../types/payroll-nz-types.js"; +import { AuEarningsRate } from "../types/payroll-au-types.js"; + +import { xeroClient } from "../clients/xero-client.js"; +import { formatError } from "../helpers/format-error.js"; +import { XeroClientResponse } from "../types/tool-response.js"; + +export interface EarningsRateResult { + earningsRateID: string; + name: string; + earningsType: string; + rateType: string; + ratePerUnit?: number; + fixedAmount?: number; + currentRecord?: boolean; +} + +export interface ListEarningsRatesParams { + page?: number; +} + +export async function listXeroEarningsRates( + params: ListEarningsRatesParams = {}, +): Promise> { + try { + await xeroClient.authenticate(); + const region = await xeroClient.getRegion(); + + let rates: EarningsRateResult[]; + + if (region === "AU") { + const res = await xeroClient.payrollAUApi.getPayItems( + xeroClient.tenantId, + undefined, + undefined, + undefined, + params.page, + ); + + const earningsRates = res.body.payItems?.earningsRates ?? []; + rates = earningsRates.map((r: AuEarningsRate) => ({ + earningsRateID: r.earningsRateID ?? "", + name: r.name ?? "", + earningsType: String(r.earningsType ?? ""), + rateType: String(r.rateType ?? ""), + ratePerUnit: r.ratePerUnit ? Number(r.ratePerUnit) : undefined, + currentRecord: r.currentRecord, + })); + } else { + const api = region === "UK" ? xeroClient.payrollUKApi : xeroClient.payrollNZApi; + const res = await api.getEarningsRates(xeroClient.tenantId, params.page); + + rates = ((res.body.earningsRates ?? []) as NzEarningsRate[]).map((r) => ({ + earningsRateID: r.earningsRateID ?? "", + name: r.name ?? "", + earningsType: String(r.earningsType ?? ""), + rateType: String(r.rateType ?? ""), + ratePerUnit: r.ratePerUnit, + fixedAmount: r.fixedAmount, + currentRecord: r.currentRecord, + })); + } + + return { result: rates, isError: false, error: null }; + } catch (error) { + return { result: null, isError: true, error: formatError(error) }; + } +} diff --git a/src/handlers/list-xero-payroll-calendars.handler.ts b/src/handlers/list-xero-payroll-calendars.handler.ts new file mode 100644 index 00000000..a5d97a16 --- /dev/null +++ b/src/handlers/list-xero-payroll-calendars.handler.ts @@ -0,0 +1,64 @@ +import { PayRunCalendar } from "../types/payroll-nz-types.js"; +import { AuPayrollCalendar } from "../types/payroll-au-types.js"; + +import { xeroClient } from "../clients/xero-client.js"; +import { formatError } from "../helpers/format-error.js"; +import { XeroClientResponse } from "../types/tool-response.js"; + +export interface PayrollCalendarResult { + payrollCalendarID: string; + name: string; + calendarType: string; + periodStartDate: string; + periodEndDate?: string; + paymentDate: string; +} + +export interface ListPayrollCalendarsParams { + page?: number; +} + +export async function listXeroPayrollCalendars( + params: ListPayrollCalendarsParams = {}, +): Promise> { + try { + await xeroClient.authenticate(); + const region = await xeroClient.getRegion(); + + let calendars: PayrollCalendarResult[]; + + if (region === "AU") { + const res = await xeroClient.payrollAUApi.getPayrollCalendars( + xeroClient.tenantId, + undefined, + undefined, + undefined, + params.page, + ); + + calendars = (res.body.payrollCalendars ?? []).map((c: AuPayrollCalendar) => ({ + payrollCalendarID: c.payrollCalendarID ?? "", + name: c.name ?? "", + calendarType: String(c.calendarType ?? ""), + periodStartDate: c.startDate ?? "", + paymentDate: c.paymentDate ?? "", + })); + } else { + const api = region === "UK" ? xeroClient.payrollUKApi : xeroClient.payrollNZApi; + const res = await api.getPayRunCalendars(xeroClient.tenantId, params.page); + + calendars = ((res.body.payRunCalendars ?? []) as PayRunCalendar[]).map((c) => ({ + payrollCalendarID: c.payrollCalendarID ?? "", + name: c.name ?? "", + calendarType: String(c.calendarType ?? ""), + periodStartDate: c.periodStartDate ?? "", + periodEndDate: c.periodEndDate, + paymentDate: c.paymentDate ?? "", + })); + } + + return { result: calendars, 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..b89f4bd2 100644 --- a/src/tools/list/index.ts +++ b/src/tools/list/index.ts @@ -28,6 +28,8 @@ 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 ListPayrollCalendarsTool from "./list-payroll-calendars.tool.js"; +import ListEarningsRatesTool from "./list-earnings-rates.tool.js"; export const ListTools = [ ListAccountsTool, @@ -54,5 +56,7 @@ export const ListTools = [ ListAgedPayablesByContact, ListPayrollTimesheetsTool, ListContactGroupsTool, - ListTrackingCategoriesTool + ListTrackingCategoriesTool, + ListPayrollCalendarsTool, + ListEarningsRatesTool, ]; diff --git a/src/tools/list/list-earnings-rates.tool.ts b/src/tools/list/list-earnings-rates.tool.ts new file mode 100644 index 00000000..4be0353a --- /dev/null +++ b/src/tools/list/list-earnings-rates.tool.ts @@ -0,0 +1,60 @@ +import { z } from "zod"; + +import { + listXeroEarningsRates, + ListEarningsRatesParams, + EarningsRateResult, +} from "../../handlers/list-xero-earnings-rates.handler.js"; +import { CreateXeroTool } from "../../helpers/create-xero-tool.js"; + +const ListEarningsRatesTool = CreateXeroTool( + "list-earnings-rates", + `List all earnings rates in Xero Payroll. +This shows the earnings rate IDs, names, types, and rates needed when creating timesheet lines. +For AU: retrieves earnings rates from Pay Items. For NZ/UK: retrieves from the dedicated earnings rates endpoint. +Supports AU, NZ, and UK payroll regions. The organisation's region is auto-detected.`, + { + page: z.number().optional().describe("Page number for pagination."), + }, + async (params: ListEarningsRatesParams) => { + const response = await listXeroEarningsRates(params); + + if (response.isError) { + return { + content: [ + { + type: "text" as const, + text: `Error listing earnings rates: ${response.error}`, + }, + ], + }; + } + + const rates = response.result; + + return { + content: [ + { + type: "text" as const, + text: `Found ${rates?.length || 0} earnings rates:`, + }, + ...(rates?.map((rate: EarningsRateResult) => ({ + type: "text" as const, + text: [ + `Name: ${rate.name}`, + `Earnings Rate ID: ${rate.earningsRateID}`, + `Earnings Type: ${rate.earningsType}`, + `Rate Type: ${rate.rateType}`, + rate.ratePerUnit !== undefined ? `Rate Per Unit: ${rate.ratePerUnit}` : null, + rate.fixedAmount !== undefined ? `Fixed Amount: ${rate.fixedAmount}` : null, + rate.currentRecord !== undefined ? `Active: ${rate.currentRecord ? "Yes" : "No"}` : null, + ] + .filter(Boolean) + .join("\n"), + })) || []), + ], + }; + }, +); + +export default ListEarningsRatesTool; diff --git a/src/tools/list/list-payroll-calendars.tool.ts b/src/tools/list/list-payroll-calendars.tool.ts new file mode 100644 index 00000000..0194bfb0 --- /dev/null +++ b/src/tools/list/list-payroll-calendars.tool.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; + +import { + listXeroPayrollCalendars, + ListPayrollCalendarsParams, + PayrollCalendarResult, +} from "../../handlers/list-xero-payroll-calendars.handler.js"; +import { CreateXeroTool } from "../../helpers/create-xero-tool.js"; + +const ListPayrollCalendarsTool = CreateXeroTool( + "list-payroll-calendars", + `List all payroll calendars in Xero. +This shows pay calendar names, types, upcoming period start dates, and payment dates. +Supports AU, NZ, and UK payroll regions. The organisation's region is auto-detected.`, + { + page: z.number().optional().describe("Page number for pagination."), + }, + async (params: ListPayrollCalendarsParams) => { + const response = await listXeroPayrollCalendars(params); + + if (response.isError) { + return { + content: [ + { + type: "text" as const, + text: `Error listing payroll calendars: ${response.error}`, + }, + ], + }; + } + + const calendars = response.result; + + return { + content: [ + { + type: "text" as const, + text: `Found ${calendars?.length || 0} payroll calendars:`, + }, + ...(calendars?.map((cal: PayrollCalendarResult) => ({ + type: "text" as const, + text: [ + `Calendar: ${cal.name}`, + `Calendar ID: ${cal.payrollCalendarID}`, + `Type: ${cal.calendarType}`, + `Next Period Start: ${cal.periodStartDate}`, + cal.periodEndDate ? `Period End: ${cal.periodEndDate}` : null, + `Payment Date: ${cal.paymentDate}`, + ] + .filter(Boolean) + .join("\n"), + })) || []), + ], + }; + }, +); + +export default ListPayrollCalendarsTool; diff --git a/src/types/payroll-au-types.ts b/src/types/payroll-au-types.ts index a9613b06..5007216c 100644 --- a/src/types/payroll-au-types.ts +++ b/src/types/payroll-au-types.ts @@ -1,3 +1,6 @@ export { Timesheet as AuTimesheet } from "xero-node/dist/gen/model/payroll-au/timesheet.js"; export { TimesheetLine as AuTimesheetLine } from "xero-node/dist/gen/model/payroll-au/timesheetLine.js"; export { TimesheetStatus } from "xero-node/dist/gen/model/payroll-au/timesheetStatus.js"; +export { PayrollCalendar as AuPayrollCalendar } from "xero-node/dist/gen/model/payroll-au/payrollCalendar.js"; +export { PayItem as AuPayItem } from "xero-node/dist/gen/model/payroll-au/payItem.js"; +export { EarningsRate as AuEarningsRate } from "xero-node/dist/gen/model/payroll-au/earningsRate.js"; diff --git a/src/types/payroll-nz-types.ts b/src/types/payroll-nz-types.ts index 8bcdf093..df78a48f 100644 --- a/src/types/payroll-nz-types.ts +++ b/src/types/payroll-nz-types.ts @@ -6,3 +6,5 @@ export { EmployeeLeaveBalance } from "xero-node/dist/gen/model/payroll-nz/employ export { EmployeeLeaveType } from "xero-node/dist/gen/model/payroll-nz/employeeLeaveType.js"; export { LeavePeriod } from "xero-node/dist/gen/model/payroll-nz/leavePeriod.js"; export { LeaveType } from "xero-node/dist/gen/model/payroll-nz/leaveType.js"; +export { PayRunCalendar } from "xero-node/dist/gen/model/payroll-nz/payRunCalendar.js"; +export { EarningsRate as NzEarningsRate } from "xero-node/dist/gen/model/payroll-nz/earningsRate.js"; From 9b79979847824d015691b50525489ada8b3af2ba Mon Sep 17 00:00:00 2001 From: Elliot Chisholm Date: Fri, 24 Apr 2026 21:54:03 +1200 Subject: [PATCH 07/13] Document AU timesheet line creation best practice in tool descriptions For AU payroll, timesheets should be created empty then lines added individually via add-timesheet-line. Passing lines at creation time creates separate rows per day in the Xero UI instead of consolidating them by earnings rate. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tools/create/create-payroll-timesheet.tool.ts | 3 ++- src/tools/update/update-payroll-timesheet-add-line.tool.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/tools/create/create-payroll-timesheet.tool.ts b/src/tools/create/create-payroll-timesheet.tool.ts index f4b6ad88..8d8b0949 100644 --- a/src/tools/create/create-payroll-timesheet.tool.ts +++ b/src/tools/create/create-payroll-timesheet.tool.ts @@ -9,7 +9,8 @@ import { CreateXeroTool } from "../../helpers/create-xero-tool.js"; const CreatePayrollTimesheetTool = CreateXeroTool( "create-timesheet", `Create a new payroll timesheet in Xero. -Supports AU, NZ, and UK payroll regions. The organisation's region is auto-detected.`, +Supports AU, NZ, and UK payroll regions. The organisation's region is auto-detected. +IMPORTANT: For AU payroll, create the timesheet WITHOUT timesheetLines, then use add-timesheet-line to add lines one at a time. Passing lines at creation time creates separate rows per day in the UI instead of consolidating them by earnings rate.`, { payrollCalendarID: z.string().describe("The ID of the payroll calendar."), employeeID: z.string().describe("The ID of the employee."), diff --git a/src/tools/update/update-payroll-timesheet-add-line.tool.ts b/src/tools/update/update-payroll-timesheet-add-line.tool.ts index 7a5b2dc1..9981d9ea 100644 --- a/src/tools/update/update-payroll-timesheet-add-line.tool.ts +++ b/src/tools/update/update-payroll-timesheet-add-line.tool.ts @@ -9,7 +9,8 @@ import { CreateXeroTool } from "../../helpers/create-xero-tool.js"; const AddTimesheetLineTool = CreateXeroTool( "add-timesheet-line", `Add a new timesheet line to an existing payroll timesheet in Xero. -Supports AU, NZ, and UK payroll regions. The organisation's region is auto-detected.`, +Supports AU, NZ, and UK payroll regions. The organisation's region is auto-detected. +For AU payroll, this is the recommended way to add hours. Lines added this way with the same earnings rate are consolidated into a single row in the UI. Add one line per day per earnings rate.`, { timesheetID: z.string().describe("The ID of the timesheet to update."), timesheetLine: z.object({ From e16458da1ed097ad1c8a30fd4ea605dffef793f7 Mon Sep 17 00:00:00 2001 From: Elliot Chisholm Date: Sun, 17 May 2026 14:52:17 +1200 Subject: [PATCH 08/13] fix: add region routing to employee and leave list handlers Route list-payroll-employees, list-payroll-employee-leave, list-payroll-employee-leave-balances, list-payroll-employee-leave-types, and list-payroll-leave-periods to the correct regional API using xeroClient.getRegion(), following the same pattern as list-payroll-calendars and list-earnings-rates. AU orgs: list-payroll-employees now calls payrollAUApi.getEmployees (v1). The 4 leave handlers short-circuit with a clear isError message pointing at the unbuilt AU leave-applications gap, rather than calling the NZ v2 endpoints and receiving a 405 MethodNotAllowed from Xero. UK/NZ orgs: calls the correct regional API (payrollUKApi vs payrollNZApi). All 5 handlers now export normalized result interfaces (EmployeeResult, EmployeeLeaveResult, etc.) so tool response shape is consistent across regions. 18 new vitest cases added across the 5 handlers. Co-Authored-By: Claude Sonnet 4.6 --- ...ll-employee-leave-balances.handler.test.ts | 64 ++++++++++ ...yroll-employee-leave-types.handler.test.ts | 73 +++++++++++ ...ero-payroll-employee-leave.handler.test.ts | 66 ++++++++++ ...ist-xero-payroll-employees.handler.test.ts | 114 +++++++++++++++++ ...xero-payroll-leave-periods.handler.test.ts | 67 ++++++++++ ...payroll-employee-leave-balances.handler.ts | 82 +++++++------ ...ro-payroll-employee-leave-types.handler.ts | 93 ++++++++------ ...ist-xero-payroll-employee-leave.handler.ts | 89 +++++++------- .../list-xero-payroll-employees.handler.ts | 115 ++++++++++++++---- ...list-xero-payroll-leave-periods.handler.ts | 101 ++++++++------- ...st-payroll-employee-leave-balances.tool.ts | 8 +- .../list-payroll-employee-leave-types.tool.ts | 8 +- .../list/list-payroll-employee-leave.tool.ts | 8 +- src/tools/list/list-payroll-employees.tool.ts | 8 +- .../list/list-payroll-leave-periods.tool.ts | 8 +- 15 files changed, 708 insertions(+), 196 deletions(-) create mode 100644 src/handlers/__tests__/list-xero-payroll-employee-leave-balances.handler.test.ts create mode 100644 src/handlers/__tests__/list-xero-payroll-employee-leave-types.handler.test.ts create mode 100644 src/handlers/__tests__/list-xero-payroll-employee-leave.handler.test.ts create mode 100644 src/handlers/__tests__/list-xero-payroll-employees.handler.test.ts create mode 100644 src/handlers/__tests__/list-xero-payroll-leave-periods.handler.test.ts diff --git a/src/handlers/__tests__/list-xero-payroll-employee-leave-balances.handler.test.ts b/src/handlers/__tests__/list-xero-payroll-employee-leave-balances.handler.test.ts new file mode 100644 index 00000000..23234a72 --- /dev/null +++ b/src/handlers/__tests__/list-xero-payroll-employee-leave-balances.handler.test.ts @@ -0,0 +1,64 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; + +const { mockXeroClient } = vi.hoisted(() => ({ + mockXeroClient: { + authenticate: vi.fn(), + getRegion: vi.fn(), + tenantId: "test-tenant-id", + payrollNZApi: { getEmployeeLeaveBalances: vi.fn() }, + payrollUKApi: { getEmployeeLeaveBalances: vi.fn() }, + }, +})); + +vi.mock("../../clients/xero-client.js", () => ({ + xeroClient: mockXeroClient, +})); + +import { listXeroPayrollEmployeeLeaveBalances } from "../list-xero-payroll-employee-leave-balances.handler.js"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("listXeroPayrollEmployeeLeaveBalances", () => { + it("returns isError for AU region", async () => { + mockXeroClient.getRegion.mockResolvedValue("AU"); + + const result = await listXeroPayrollEmployeeLeaveBalances("emp-1"); + + expect(result.isError).toBe(true); + expect(result.error).toMatch(/not supported for AU/); + expect(mockXeroClient.payrollNZApi.getEmployeeLeaveBalances).not.toHaveBeenCalled(); + expect(mockXeroClient.payrollUKApi.getEmployeeLeaveBalances).not.toHaveBeenCalled(); + }); + + it("calls payrollUKApi for UK region", async () => { + mockXeroClient.getRegion.mockResolvedValue("UK"); + mockXeroClient.payrollUKApi.getEmployeeLeaveBalances.mockResolvedValue({ + body: { leaveBalances: [{ leaveTypeID: "lt-1", name: "Annual", balance: 40 }] }, + }); + + const result = await listXeroPayrollEmployeeLeaveBalances("emp-1"); + + expect(mockXeroClient.payrollUKApi.getEmployeeLeaveBalances).toHaveBeenCalled(); + expect(result.isError).toBe(false); + expect(result.result![0].balance).toBe(40); + }); + + it("calls payrollNZApi for NZ region", async () => { + mockXeroClient.getRegion.mockResolvedValue("NZ"); + mockXeroClient.payrollNZApi.getEmployeeLeaveBalances.mockResolvedValue({ + body: { leaveBalances: [] }, + }); + + const result = await listXeroPayrollEmployeeLeaveBalances("emp-1"); + + expect(mockXeroClient.payrollNZApi.getEmployeeLeaveBalances).toHaveBeenCalled(); + expect(result.isError).toBe(false); + }); + + it("returns error when employeeId is empty", async () => { + const result = await listXeroPayrollEmployeeLeaveBalances(""); + expect(result.isError).toBe(true); + }); +}); diff --git a/src/handlers/__tests__/list-xero-payroll-employee-leave-types.handler.test.ts b/src/handlers/__tests__/list-xero-payroll-employee-leave-types.handler.test.ts new file mode 100644 index 00000000..05feb1cb --- /dev/null +++ b/src/handlers/__tests__/list-xero-payroll-employee-leave-types.handler.test.ts @@ -0,0 +1,73 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; + +const { mockXeroClient } = vi.hoisted(() => ({ + mockXeroClient: { + authenticate: vi.fn(), + getRegion: vi.fn(), + tenantId: "test-tenant-id", + payrollNZApi: { getEmployeeLeaveTypes: vi.fn() }, + payrollUKApi: { getEmployeeLeaveTypes: vi.fn() }, + }, +})); + +vi.mock("../../clients/xero-client.js", () => ({ + xeroClient: mockXeroClient, +})); + +import { listXeroPayrollEmployeeLeaveTypes } from "../list-xero-payroll-employee-leave-types.handler.js"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("listXeroPayrollEmployeeLeaveTypes", () => { + it("returns isError for AU region", async () => { + mockXeroClient.getRegion.mockResolvedValue("AU"); + + const result = await listXeroPayrollEmployeeLeaveTypes("emp-1"); + + expect(result.isError).toBe(true); + expect(result.error).toMatch(/not supported for AU/); + }); + + it("normalises UK hoursAccruedAnnually to unitsAccruedAnnually", async () => { + mockXeroClient.getRegion.mockResolvedValue("UK"); + mockXeroClient.payrollUKApi.getEmployeeLeaveTypes.mockResolvedValue({ + body: { + leaveTypes: [ + { + leaveTypeID: "lt-1", + scheduleOfAccrual: "BeginningOfCalendarYear", + hoursAccruedAnnually: 160, + }, + ], + }, + }); + + const result = await listXeroPayrollEmployeeLeaveTypes("emp-1"); + + expect(mockXeroClient.payrollUKApi.getEmployeeLeaveTypes).toHaveBeenCalled(); + expect(result.isError).toBe(false); + expect(result.result![0].unitsAccruedAnnually).toBe(160); + }); + + it("preserves NZ unitsAccruedAnnually", async () => { + mockXeroClient.getRegion.mockResolvedValue("NZ"); + mockXeroClient.payrollNZApi.getEmployeeLeaveTypes.mockResolvedValue({ + body: { + leaveTypes: [ + { + leaveTypeID: "lt-1", + scheduleOfAccrual: "AnnuallyAfter6Months", + unitsAccruedAnnually: 152, + }, + ], + }, + }); + + const result = await listXeroPayrollEmployeeLeaveTypes("emp-1"); + + expect(mockXeroClient.payrollNZApi.getEmployeeLeaveTypes).toHaveBeenCalled(); + expect(result.result![0].unitsAccruedAnnually).toBe(152); + }); +}); diff --git a/src/handlers/__tests__/list-xero-payroll-employee-leave.handler.test.ts b/src/handlers/__tests__/list-xero-payroll-employee-leave.handler.test.ts new file mode 100644 index 00000000..0b4535d4 --- /dev/null +++ b/src/handlers/__tests__/list-xero-payroll-employee-leave.handler.test.ts @@ -0,0 +1,66 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; + +const { mockXeroClient } = vi.hoisted(() => ({ + mockXeroClient: { + authenticate: vi.fn(), + getRegion: vi.fn(), + tenantId: "test-tenant-id", + payrollNZApi: { getEmployeeLeaves: vi.fn() }, + payrollUKApi: { getEmployeeLeaves: vi.fn() }, + }, +})); + +vi.mock("../../clients/xero-client.js", () => ({ + xeroClient: mockXeroClient, +})); + +import { listXeroPayrollEmployeeLeave } from "../list-xero-payroll-employee-leave.handler.js"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("listXeroPayrollEmployeeLeave", () => { + it("returns isError for AU region without calling any API", async () => { + mockXeroClient.getRegion.mockResolvedValue("AU"); + + const result = await listXeroPayrollEmployeeLeave("emp-1"); + + expect(result.isError).toBe(true); + expect(result.error).toMatch(/not supported for AU/); + expect(mockXeroClient.payrollNZApi.getEmployeeLeaves).not.toHaveBeenCalled(); + expect(mockXeroClient.payrollUKApi.getEmployeeLeaves).not.toHaveBeenCalled(); + }); + + it("calls payrollUKApi for UK region", async () => { + mockXeroClient.getRegion.mockResolvedValue("UK"); + mockXeroClient.payrollUKApi.getEmployeeLeaves.mockResolvedValue({ + body: { leave: [{ leaveID: "l-1", leaveTypeID: "lt-1", description: "Annual" }] }, + }); + + const result = await listXeroPayrollEmployeeLeave("emp-1"); + + expect(mockXeroClient.payrollUKApi.getEmployeeLeaves).toHaveBeenCalled(); + expect(mockXeroClient.payrollNZApi.getEmployeeLeaves).not.toHaveBeenCalled(); + expect(result.isError).toBe(false); + expect(result.result).toHaveLength(1); + }); + + it("calls payrollNZApi for NZ region", async () => { + mockXeroClient.getRegion.mockResolvedValue("NZ"); + mockXeroClient.payrollNZApi.getEmployeeLeaves.mockResolvedValue({ + body: { leave: [] }, + }); + + const result = await listXeroPayrollEmployeeLeave("emp-1"); + + expect(mockXeroClient.payrollNZApi.getEmployeeLeaves).toHaveBeenCalled(); + expect(result.isError).toBe(false); + }); + + it("returns error when employeeId is empty", async () => { + const result = await listXeroPayrollEmployeeLeave(""); + expect(result.isError).toBe(true); + expect(result.error).toMatch(/Employee ID is required/); + }); +}); diff --git a/src/handlers/__tests__/list-xero-payroll-employees.handler.test.ts b/src/handlers/__tests__/list-xero-payroll-employees.handler.test.ts new file mode 100644 index 00000000..ddbe5f16 --- /dev/null +++ b/src/handlers/__tests__/list-xero-payroll-employees.handler.test.ts @@ -0,0 +1,114 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; + +const { mockXeroClient } = vi.hoisted(() => ({ + mockXeroClient: { + authenticate: vi.fn(), + getRegion: vi.fn(), + tenantId: "test-tenant-id", + payrollAUApi: { getEmployees: vi.fn() }, + payrollNZApi: { getEmployees: vi.fn() }, + payrollUKApi: { getEmployees: vi.fn() }, + }, +})); + +vi.mock("../../clients/xero-client.js", () => ({ + xeroClient: mockXeroClient, +})); + +import { listXeroPayrollEmployees } from "../list-xero-payroll-employees.handler.js"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("listXeroPayrollEmployees", () => { + describe("AU region", () => { + it("calls payrollAUApi.getEmployees and maps phone -> phoneNumber", async () => { + mockXeroClient.getRegion.mockResolvedValue("AU"); + mockXeroClient.payrollAUApi.getEmployees.mockResolvedValue({ + body: { + employees: [ + { + employeeID: "au-1", + firstName: "Alice", + lastName: "Anderson", + email: "alice@example.com", + phone: "+61 400 000 000", + }, + ], + }, + }); + + const result = await listXeroPayrollEmployees(); + + expect(mockXeroClient.payrollAUApi.getEmployees).toHaveBeenCalled(); + expect(mockXeroClient.payrollNZApi.getEmployees).not.toHaveBeenCalled(); + expect(mockXeroClient.payrollUKApi.getEmployees).not.toHaveBeenCalled(); + expect(result.isError).toBe(false); + expect(result.result).toHaveLength(1); + expect(result.result![0].phoneNumber).toBe("+61 400 000 000"); + }); + }); + + describe("UK region", () => { + it("calls payrollUKApi.getEmployees", async () => { + mockXeroClient.getRegion.mockResolvedValue("UK"); + mockXeroClient.payrollUKApi.getEmployees.mockResolvedValue({ + body: { + employees: [ + { + employeeID: "uk-1", + firstName: "Bob", + lastName: "Brown", + phoneNumber: "+44 20 7946 0000", + }, + ], + }, + }); + + const result = await listXeroPayrollEmployees(); + + expect(mockXeroClient.payrollUKApi.getEmployees).toHaveBeenCalled(); + expect(mockXeroClient.payrollNZApi.getEmployees).not.toHaveBeenCalled(); + expect(mockXeroClient.payrollAUApi.getEmployees).not.toHaveBeenCalled(); + expect(result.isError).toBe(false); + expect(result.result![0].phoneNumber).toBe("+44 20 7946 0000"); + }); + }); + + describe("NZ region", () => { + it("calls payrollNZApi.getEmployees and preserves engagementType", async () => { + mockXeroClient.getRegion.mockResolvedValue("NZ"); + mockXeroClient.payrollNZApi.getEmployees.mockResolvedValue({ + body: { + employees: [ + { + employeeID: "nz-1", + firstName: "Carol", + lastName: "Carter", + engagementType: "Permanent", + phoneNumber: "+64 9 000 0000", + }, + ], + }, + }); + + const result = await listXeroPayrollEmployees(); + + expect(mockXeroClient.payrollNZApi.getEmployees).toHaveBeenCalled(); + expect(result.isError).toBe(false); + expect(result.result![0].engagementType).toBe("Permanent"); + }); + }); + + it("returns error response on API failure", async () => { + mockXeroClient.getRegion.mockResolvedValue("NZ"); + mockXeroClient.payrollNZApi.getEmployees.mockRejectedValue( + new Error("API error"), + ); + + const result = await listXeroPayrollEmployees(); + expect(result.isError).toBe(true); + expect(result.error).toBe("API error"); + }); +}); diff --git a/src/handlers/__tests__/list-xero-payroll-leave-periods.handler.test.ts b/src/handlers/__tests__/list-xero-payroll-leave-periods.handler.test.ts new file mode 100644 index 00000000..542925d9 --- /dev/null +++ b/src/handlers/__tests__/list-xero-payroll-leave-periods.handler.test.ts @@ -0,0 +1,67 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; + +const { mockXeroClient } = vi.hoisted(() => ({ + mockXeroClient: { + authenticate: vi.fn(), + getRegion: vi.fn(), + tenantId: "test-tenant-id", + payrollNZApi: { getEmployeeLeavePeriods: vi.fn() }, + payrollUKApi: { getEmployeeLeavePeriods: vi.fn() }, + }, +})); + +vi.mock("../../clients/xero-client.js", () => ({ + xeroClient: mockXeroClient, +})); + +import { listXeroPayrollLeavePeriods } from "../list-xero-payroll-leave-periods.handler.js"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("listXeroPayrollLeavePeriods", () => { + it("returns isError for AU region", async () => { + mockXeroClient.getRegion.mockResolvedValue("AU"); + + const result = await listXeroPayrollLeavePeriods("emp-1"); + + expect(result.isError).toBe(true); + expect(result.error).toMatch(/not supported for AU/); + }); + + it("calls payrollUKApi for UK region with date range", async () => { + mockXeroClient.getRegion.mockResolvedValue("UK"); + mockXeroClient.payrollUKApi.getEmployeeLeavePeriods.mockResolvedValue({ + body: { periods: [{ periodStatus: "Approved", periodStartDate: "2026-01-01" }] }, + }); + + const result = await listXeroPayrollLeavePeriods( + "emp-1", + "2026-01-01", + "2026-12-31", + ); + + expect(mockXeroClient.payrollUKApi.getEmployeeLeavePeriods).toHaveBeenCalledWith( + "test-tenant-id", + "emp-1", + "2026-01-01", + "2026-12-31", + expect.anything(), + ); + expect(result.isError).toBe(false); + expect(result.result).toHaveLength(1); + }); + + it("calls payrollNZApi for NZ region", async () => { + mockXeroClient.getRegion.mockResolvedValue("NZ"); + mockXeroClient.payrollNZApi.getEmployeeLeavePeriods.mockResolvedValue({ + body: { periods: [] }, + }); + + const result = await listXeroPayrollLeavePeriods("emp-1"); + + expect(mockXeroClient.payrollNZApi.getEmployeeLeavePeriods).toHaveBeenCalled(); + expect(result.isError).toBe(false); + }); +}); diff --git a/src/handlers/list-xero-payroll-employee-leave-balances.handler.ts b/src/handlers/list-xero-payroll-employee-leave-balances.handler.ts index 05b9f922..830c6a43 100644 --- a/src/handlers/list-xero-payroll-employee-leave-balances.handler.ts +++ b/src/handlers/list-xero-payroll-employee-leave-balances.handler.ts @@ -2,55 +2,67 @@ import { xeroClient } from "../clients/xero-client.js"; import { XeroClientResponse } from "../types/tool-response.js"; import { formatError } from "../helpers/format-error.js"; import { getClientHeaders } from "../helpers/get-client-headers.js"; -import { EmployeeLeaveBalance } from "../types/payroll-nz-types.js"; +import { EmployeeLeaveBalance as NzEmployeeLeaveBalance } from "../types/payroll-nz-types.js"; +import { EmployeeLeaveBalance as UkEmployeeLeaveBalance } from "xero-node/dist/gen/model/payroll-uk/employeeLeaveBalance.js"; -/** - * Internal function to fetch employee leave balances from Xero - */ -async function fetchEmployeeLeaveBalances(employeeId: string): Promise { - await xeroClient.authenticate(); - - if (!employeeId) { - throw new Error("Employee ID is required to fetch employee leave balances"); - } - - const response = await xeroClient.payrollNZApi.getEmployeeLeaveBalances( - xeroClient.tenantId, - employeeId, - getClientHeaders(), - ); +export interface EmployeeLeaveBalanceResult { + name?: string; + leaveTypeID?: string; + balance?: number; + typeOfUnits?: string; +} - return response.body.leaveBalances ?? null; +function mapBalance( + b: NzEmployeeLeaveBalance | UkEmployeeLeaveBalance, +): EmployeeLeaveBalanceResult { + return { + name: b.name, + leaveTypeID: b.leaveTypeID, + balance: b.balance, + typeOfUnits: b.typeOfUnits, + }; } /** - * List employee leave balances from Xero Payroll - * @param employeeId The ID of the employee to retrieve leave balances for + * List employee leave balances from Xero Payroll, routing to the correct + * regional API. AU orgs use leave applications (a different model) and are + * not supported by this tool. */ export async function listXeroPayrollEmployeeLeaveBalances( employeeId: string, -): Promise> { +): Promise> { try { - const leaveBalances = await fetchEmployeeLeaveBalances(employeeId); + if (!employeeId) { + throw new Error( + "Employee ID is required to fetch employee leave balances", + ); + } + + await xeroClient.authenticate(); + const region = await xeroClient.getRegion(); - if (!leaveBalances) { + if (region === "AU") { return { - result: [], - isError: false, - error: null, + result: null, + isError: true, + error: + "Listing employee leave balances is not supported for AU orgs. AU payroll exposes leave balances on the employee record (via the AU employee API), which is not yet available in this MCP server.", }; } - return { - result: leaveBalances, - isError: false, - error: null, - }; + const api = + region === "UK" ? xeroClient.payrollUKApi : xeroClient.payrollNZApi; + + const response = await api.getEmployeeLeaveBalances( + xeroClient.tenantId, + employeeId, + getClientHeaders(), + ); + + const balances = (response.body.leaveBalances ?? []).map(mapBalance); + + return { result: balances, isError: false, error: null }; } catch (error) { - return { - result: null, - isError: true, - error: formatError(error), - }; + return { result: null, isError: true, error: formatError(error) }; } } diff --git a/src/handlers/list-xero-payroll-employee-leave-types.handler.ts b/src/handlers/list-xero-payroll-employee-leave-types.handler.ts index df62fd26..58b7be51 100644 --- a/src/handlers/list-xero-payroll-employee-leave-types.handler.ts +++ b/src/handlers/list-xero-payroll-employee-leave-types.handler.ts @@ -2,55 +2,80 @@ import { xeroClient } from "../clients/xero-client.js"; import { XeroClientResponse } from "../types/tool-response.js"; import { formatError } from "../helpers/format-error.js"; import { getClientHeaders } from "../helpers/get-client-headers.js"; -import { EmployeeLeaveType } from "../types/payroll-nz-types.js"; +import { EmployeeLeaveType as NzEmployeeLeaveType } from "../types/payroll-nz-types.js"; +import { EmployeeLeaveType as UkEmployeeLeaveType } from "xero-node/dist/gen/model/payroll-uk/employeeLeaveType.js"; -/** - * Internal function to fetch employee leave types from Xero - */ -async function fetchEmployeeLeaveTypes(employeeId: string): Promise { - await xeroClient.authenticate(); - - if (!employeeId) { - throw new Error("Employee ID is required to fetch employee leave types"); - } +export interface EmployeeLeaveTypeResult { + leaveTypeID?: string; + scheduleOfAccrual?: string; + unitsAccruedAnnually?: number; + maximumToAccrue?: number; + openingBalance?: number; + rateAccruedHourly?: number; + scheduleOfAccrualDate?: string; +} - const response = await xeroClient.payrollNZApi.getEmployeeLeaveTypes( - xeroClient.tenantId, - employeeId, - getClientHeaders(), - ); +function mapLeaveType( + t: NzEmployeeLeaveType | UkEmployeeLeaveType, +): EmployeeLeaveTypeResult { + // UK exposes hoursAccruedAnnually; NZ exposes unitsAccruedAnnually. Normalise to a single field. + const unitsAccruedAnnually = + "unitsAccruedAnnually" in t + ? t.unitsAccruedAnnually + : (t as UkEmployeeLeaveType).hoursAccruedAnnually; - return response.body.leaveTypes ?? null; + return { + leaveTypeID: t.leaveTypeID, + scheduleOfAccrual: + t.scheduleOfAccrual !== undefined + ? String(t.scheduleOfAccrual) + : undefined, + unitsAccruedAnnually, + maximumToAccrue: t.maximumToAccrue, + openingBalance: t.openingBalance, + rateAccruedHourly: t.rateAccruedHourly, + scheduleOfAccrualDate: t.scheduleOfAccrualDate, + }; } /** - * List employee leave types from Xero Payroll - * @param employeeId The ID of the employee to retrieve leave types for + * List employee leave types from Xero Payroll, routing to the correct regional + * API. AU orgs use leave applications (a different model) and are not + * supported by this tool. */ export async function listXeroPayrollEmployeeLeaveTypes( employeeId: string, -): Promise> { +): Promise> { try { - const leaveTypes = await fetchEmployeeLeaveTypes(employeeId); + if (!employeeId) { + throw new Error("Employee ID is required to fetch employee leave types"); + } + + await xeroClient.authenticate(); + const region = await xeroClient.getRegion(); - if (!leaveTypes) { + if (region === "AU") { return { - result: [], - isError: false, - error: null, + result: null, + isError: true, + error: + "Listing employee leave types is not supported for AU orgs. AU payroll exposes leave types as pay items, which are not yet available in this MCP server.", }; } - return { - result: leaveTypes, - isError: false, - error: null, - }; + const api = + region === "UK" ? xeroClient.payrollUKApi : xeroClient.payrollNZApi; + + const response = await api.getEmployeeLeaveTypes( + xeroClient.tenantId, + employeeId, + getClientHeaders(), + ); + + const leaveTypes = (response.body.leaveTypes ?? []).map(mapLeaveType); + + return { result: leaveTypes, isError: false, error: null }; } catch (error) { - return { - result: null, - isError: true, - error: formatError(error), - }; + return { result: null, isError: true, error: formatError(error) }; } } diff --git a/src/handlers/list-xero-payroll-employee-leave.handler.ts b/src/handlers/list-xero-payroll-employee-leave.handler.ts index 40b0aecb..3c81a45a 100644 --- a/src/handlers/list-xero-payroll-employee-leave.handler.ts +++ b/src/handlers/list-xero-payroll-employee-leave.handler.ts @@ -2,62 +2,69 @@ import { xeroClient } from "../clients/xero-client.js"; import { XeroClientResponse } from "../types/tool-response.js"; import { formatError } from "../helpers/format-error.js"; import { getClientHeaders } from "../helpers/get-client-headers.js"; -// Import the correct types - using the proper namespace -import { EmployeeLeave } from "../types/payroll-nz-types.js"; +import { EmployeeLeave as NzEmployeeLeave } from "../types/payroll-nz-types.js"; +import { EmployeeLeave as UkEmployeeLeave } from "xero-node/dist/gen/model/payroll-uk/employeeLeave.js"; -interface FetchEmployeeLeaveParams { - employeeId?: string; +export interface EmployeeLeaveResult { + leaveID?: string; + leaveTypeID?: string; + description?: string; + startDate?: string; + endDate?: string; + periods?: unknown[]; + updatedDateUTC?: Date; } -/** - * Internal function to fetch employee leave from Xero - */ -async function fetchEmployeeLeave({ employeeId }: FetchEmployeeLeaveParams): Promise { - await xeroClient.authenticate(); - - if (!employeeId) { - throw new Error("Employee ID is required to fetch employee leave"); - } - - const response = await xeroClient.payrollNZApi.getEmployeeLeaves( - xeroClient.tenantId, - employeeId, - { - headers: getClientHeaders().headers - } - ); - - return response.body.leave ?? null; +function mapLeave(l: NzEmployeeLeave | UkEmployeeLeave): EmployeeLeaveResult { + return { + leaveID: l.leaveID, + leaveTypeID: l.leaveTypeID, + description: l.description, + startDate: l.startDate, + endDate: l.endDate, + periods: l.periods, + updatedDateUTC: l.updatedDateUTC, + }; } /** - * List employee leave from Xero Payroll - * @param employeeId The ID of the employee to retrieve leave for + * List employee leave records from Xero Payroll, routing to the correct + * regional API. AU orgs use leave applications (a different model) and are + * not supported by this tool. */ export async function listXeroPayrollEmployeeLeave( employeeId: string, -): Promise> { +): Promise> { try { - const leave = await fetchEmployeeLeave({ employeeId }); + if (!employeeId) { + throw new Error("Employee ID is required to fetch employee leave"); + } + + await xeroClient.authenticate(); + const region = await xeroClient.getRegion(); - if (!leave) { + if (region === "AU") { return { - result: [], - isError: false, - error: null, + result: null, + isError: true, + error: + "Listing employee leave is not supported for AU orgs. AU payroll uses leave applications, which are not yet available in this MCP server.", }; } - return { - result: leave, - isError: false, - error: null, - }; + const api = + region === "UK" ? xeroClient.payrollUKApi : xeroClient.payrollNZApi; + + const response = await api.getEmployeeLeaves( + xeroClient.tenantId, + employeeId, + { headers: getClientHeaders().headers }, + ); + + const leave = (response.body.leave ?? []).map(mapLeave); + + return { result: leave, isError: false, error: null }; } catch (error) { - return { - result: null, - isError: true, - error: formatError(error), - }; + return { result: null, isError: true, error: formatError(error) }; } } diff --git a/src/handlers/list-xero-payroll-employees.handler.ts b/src/handlers/list-xero-payroll-employees.handler.ts index 7ba40885..d60b6e78 100644 --- a/src/handlers/list-xero-payroll-employees.handler.ts +++ b/src/handlers/list-xero-payroll-employees.handler.ts @@ -2,41 +2,108 @@ import { xeroClient } from "../clients/xero-client.js"; import { XeroClientResponse } from "../types/tool-response.js"; import { formatError } from "../helpers/format-error.js"; import { getClientHeaders } from "../helpers/get-client-headers.js"; -import { Employee } from "../types/payroll-nz-types.js"; +import { Employee as NzEmployee } from "../types/payroll-nz-types.js"; +import { Employee as AuEmployee } from "xero-node/dist/gen/model/payroll-au/employee.js"; +import { Employee as UkEmployee } from "xero-node/dist/gen/model/payroll-uk/employee.js"; -async function getPayrollEmployees(): Promise { - await xeroClient.authenticate(); +export interface EmployeeResult { + employeeID?: string; + firstName?: string; + lastName?: string; + email?: string; + title?: string; + gender?: string; + phoneNumber?: string; + startDate?: string; + engagementType?: string; + updatedDateUTC?: Date; +} + +function mapAuEmployee(e: AuEmployee): EmployeeResult { + return { + employeeID: e.employeeID, + firstName: e.firstName, + lastName: e.lastName, + email: e.email, + title: e.title, + gender: e.gender !== undefined ? String(e.gender) : undefined, + phoneNumber: e.phone, + startDate: e.startDate, + updatedDateUTC: e.updatedDateUTC, + }; +} - // Call the Employees endpoint from the PayrollNZApi - const employees = await xeroClient.payrollNZApi.getEmployees( - xeroClient.tenantId, - undefined, // page - undefined, // pageSize - getClientHeaders(), - ); +function mapNzEmployee(e: NzEmployee): EmployeeResult { + return { + employeeID: e.employeeID, + firstName: e.firstName, + lastName: e.lastName, + email: e.email, + title: e.title, + gender: e.gender !== undefined ? String(e.gender) : undefined, + phoneNumber: e.phoneNumber, + startDate: e.startDate, + engagementType: e.engagementType, + updatedDateUTC: e.updatedDateUTC, + }; +} - return employees.body.employees ?? []; +function mapUkEmployee(e: UkEmployee): EmployeeResult { + return { + employeeID: e.employeeID, + firstName: e.firstName, + lastName: e.lastName, + email: e.email, + title: e.title, + gender: e.gender !== undefined ? String(e.gender) : undefined, + phoneNumber: e.phoneNumber, + startDate: e.startDate, + updatedDateUTC: e.updatedDateUTC, + }; } /** - * List all payroll employees from Xero + * List all payroll employees from Xero, routing to the correct regional API. */ export async function listXeroPayrollEmployees(): Promise< - XeroClientResponse + XeroClientResponse > { try { - const employees = await getPayrollEmployees(); + await xeroClient.authenticate(); + const region = await xeroClient.getRegion(); + + let employees: EmployeeResult[]; + + if (region === "AU") { + const res = await xeroClient.payrollAUApi.getEmployees( + xeroClient.tenantId, + undefined, + undefined, + undefined, + undefined, + getClientHeaders(), + ); + employees = (res.body.employees ?? []).map(mapAuEmployee); + } else if (region === "UK") { + const res = await xeroClient.payrollUKApi.getEmployees( + xeroClient.tenantId, + undefined, + undefined, + getClientHeaders(), + ); + employees = (res.body.employees ?? []).map(mapUkEmployee); + } else { + const res = await xeroClient.payrollNZApi.getEmployees( + xeroClient.tenantId, + undefined, + undefined, + getClientHeaders(), + ); + employees = (res.body.employees ?? []).map(mapNzEmployee); + } - return { - result: employees, - isError: false, - error: null, - }; + return { result: employees, isError: false, error: null }; } catch (error) { - return { - result: null, - isError: true, - error: formatError(error), - }; + return { result: null, isError: true, error: formatError(error) }; } } diff --git a/src/handlers/list-xero-payroll-leave-periods.handler.ts b/src/handlers/list-xero-payroll-leave-periods.handler.ts index 81824e10..f0d07260 100644 --- a/src/handlers/list-xero-payroll-leave-periods.handler.ts +++ b/src/handlers/list-xero-payroll-leave-periods.handler.ts @@ -1,70 +1,77 @@ import { xeroClient } from "../clients/xero-client.js"; import { XeroClientResponse } from "../types/tool-response.js"; import { formatError } from "../helpers/format-error.js"; -import { LeavePeriod } from "../types/payroll-nz-types.js"; +import { getClientHeaders } from "../helpers/get-client-headers.js"; +import { LeavePeriod as NzLeavePeriod } from "../types/payroll-nz-types.js"; +import { LeavePeriod as UkLeavePeriod } from "xero-node/dist/gen/model/payroll-uk/leavePeriod.js"; -interface FetchLeavePeriodParams { - employeeId?: string; - startDate?: string; - endDate?: string; +export interface LeavePeriodResult { + periodStatus?: string; + periodStartDate?: string; + periodEndDate?: string; + numberOfUnits?: number; + numberOfUnitsTaken?: number; + typeOfUnits?: string; + typeOfUnitsTaken?: string; } -/** - * Internal function to fetch employee leave periods from Xero - */ -async function fetchLeavePeriods({ - employeeId, - startDate, - endDate, -}: FetchLeavePeriodParams): Promise { - await xeroClient.authenticate(); - - if (!employeeId) { - throw new Error("Employee ID is required to fetch leave periods"); - } // After reviewing the SDK documentation, it appears this API call requires different parameters - // Use parameters that match the SDK's expectations - const response = await xeroClient.payrollNZApi.getEmployeeLeavePeriods( - xeroClient.tenantId, - employeeId, - startDate, - endDate, - ); - - return response.body.periods ?? null; +function mapPeriod(p: NzLeavePeriod | UkLeavePeriod): LeavePeriodResult { + // UK LeavePeriod omits numberOfUnitsTaken, typeOfUnits and typeOfUnitsTaken. + const nz = p as NzLeavePeriod; + return { + periodStatus: + p.periodStatus !== undefined ? String(p.periodStatus) : undefined, + periodStartDate: p.periodStartDate, + periodEndDate: p.periodEndDate, + numberOfUnits: p.numberOfUnits, + numberOfUnitsTaken: nz.numberOfUnitsTaken, + typeOfUnits: nz.typeOfUnits, + typeOfUnitsTaken: nz.typeOfUnitsTaken, + }; } /** - * List employee leave periods from Xero Payroll - * @param employeeId The ID of the employee to retrieve leave periods for - * @param startDate Optional start date in YYYY-MM-DD format - * @param endDate Optional end date in YYYY-MM-DD format + * List employee leave periods from Xero Payroll, routing to the correct + * regional API. AU orgs use leave applications (a different model) and are + * not supported by this tool. */ export async function listXeroPayrollLeavePeriods( employeeId: string, startDate?: string, endDate?: string, -): Promise> { +): Promise> { try { - const periods = await fetchLeavePeriods({ employeeId, startDate, endDate }); + if (!employeeId) { + throw new Error("Employee ID is required to fetch leave periods"); + } - if (!periods) { + await xeroClient.authenticate(); + const region = await xeroClient.getRegion(); + + if (region === "AU") { return { - result: [], - isError: false, - error: null, + result: null, + isError: true, + error: + "Listing leave periods is not supported for AU orgs. AU payroll uses leave applications, which are not yet available in this MCP server.", }; } - return { - result: periods, - isError: false, - error: null, - }; + const api = + region === "UK" ? xeroClient.payrollUKApi : xeroClient.payrollNZApi; + + const response = await api.getEmployeeLeavePeriods( + xeroClient.tenantId, + employeeId, + startDate, + endDate, + getClientHeaders(), + ); + + const periods = (response.body.periods ?? []).map(mapPeriod); + + return { result: periods, isError: false, error: null }; } catch (error) { - return { - result: null, - isError: true, - error: formatError(error), - }; + return { result: null, isError: true, error: formatError(error) }; } } diff --git a/src/tools/list/list-payroll-employee-leave-balances.tool.ts b/src/tools/list/list-payroll-employee-leave-balances.tool.ts index b728626d..e4df4b5d 100644 --- a/src/tools/list/list-payroll-employee-leave-balances.tool.ts +++ b/src/tools/list/list-payroll-employee-leave-balances.tool.ts @@ -1,7 +1,9 @@ import { z } from "zod"; -import { listXeroPayrollEmployeeLeaveBalances } from "../../handlers/list-xero-payroll-employee-leave-balances.handler.js"; +import { + EmployeeLeaveBalanceResult, + listXeroPayrollEmployeeLeaveBalances, +} from "../../handlers/list-xero-payroll-employee-leave-balances.handler.js"; import { CreateXeroTool } from "../../helpers/create-xero-tool.js"; -import { EmployeeLeaveBalance } from "../../types/payroll-nz-types.js"; const ListPayrollEmployeeLeaveBalancesTool = CreateXeroTool( "list-payroll-employee-leave-balances", @@ -30,7 +32,7 @@ const ListPayrollEmployeeLeaveBalancesTool = CreateXeroTool( type: "text" as const, text: `Found ${leaveBalances?.length || 0} leave balances for employee ${employeeId}:`, }, - ...(leaveBalances?.map((balance: EmployeeLeaveBalance) => ({ + ...(leaveBalances?.map((balance: EmployeeLeaveBalanceResult) => ({ type: "text" as const, text: [ `Leave Type ID: ${balance.leaveTypeID || "Unknown"}`, diff --git a/src/tools/list/list-payroll-employee-leave-types.tool.ts b/src/tools/list/list-payroll-employee-leave-types.tool.ts index bd9edcfb..6687783e 100644 --- a/src/tools/list/list-payroll-employee-leave-types.tool.ts +++ b/src/tools/list/list-payroll-employee-leave-types.tool.ts @@ -1,7 +1,9 @@ import { z } from "zod"; -import { listXeroPayrollEmployeeLeaveTypes } from "../../handlers/list-xero-payroll-employee-leave-types.handler.js"; +import { + EmployeeLeaveTypeResult, + listXeroPayrollEmployeeLeaveTypes, +} from "../../handlers/list-xero-payroll-employee-leave-types.handler.js"; import { CreateXeroTool } from "../../helpers/create-xero-tool.js"; -import { EmployeeLeaveType } from "../../types/payroll-nz-types.js"; const ListPayrollEmployeeLeaveTypesTool = CreateXeroTool( "list-payroll-employee-leave-types", @@ -32,7 +34,7 @@ const ListPayrollEmployeeLeaveTypesTool = CreateXeroTool( type: "text" as const, text: `Found ${leaveTypes?.length || 0} leave types for employee ${employeeId}:`, }, - ...(leaveTypes?.map((leaveType: EmployeeLeaveType) => ({ + ...(leaveTypes?.map((leaveType: EmployeeLeaveTypeResult) => ({ type: "text" as const, text: [ `Leave Type ID: ${leaveType.leaveTypeID || "Unknown"}`, diff --git a/src/tools/list/list-payroll-employee-leave.tool.ts b/src/tools/list/list-payroll-employee-leave.tool.ts index 16e118ba..81d0801b 100644 --- a/src/tools/list/list-payroll-employee-leave.tool.ts +++ b/src/tools/list/list-payroll-employee-leave.tool.ts @@ -1,7 +1,9 @@ import { z } from "zod"; -import { listXeroPayrollEmployeeLeave } from "../../handlers/list-xero-payroll-employee-leave.handler.js"; +import { + EmployeeLeaveResult, + listXeroPayrollEmployeeLeave, +} from "../../handlers/list-xero-payroll-employee-leave.handler.js"; import { CreateXeroTool } from "../../helpers/create-xero-tool.js"; -import { EmployeeLeave } from "../../types/payroll-nz-types.js"; const ListPayrollEmployeeLeaveTool = CreateXeroTool( "list-payroll-employee-leave", @@ -30,7 +32,7 @@ const ListPayrollEmployeeLeaveTool = CreateXeroTool( type: "text" as const, text: `Found ${leave?.length || 0} leave records for employee ${employeeId}:`, }, - ...(leave?.map((leaveItem: EmployeeLeave) => ({ + ...(leave?.map((leaveItem: EmployeeLeaveResult) => ({ type: "text" as const, text: [ `Leave ID: ${leaveItem.leaveID || "Unknown"}`, diff --git a/src/tools/list/list-payroll-employees.tool.ts b/src/tools/list/list-payroll-employees.tool.ts index 0ff49a3c..59980682 100644 --- a/src/tools/list/list-payroll-employees.tool.ts +++ b/src/tools/list/list-payroll-employees.tool.ts @@ -1,5 +1,7 @@ -import { Employee } from "../../types/payroll-nz-types.js"; -import { listXeroPayrollEmployees } from "../../handlers/list-xero-payroll-employees.handler.js"; +import { + EmployeeResult, + listXeroPayrollEmployees, +} from "../../handlers/list-xero-payroll-employees.handler.js"; import { CreateXeroTool } from "../../helpers/create-xero-tool.js"; const ListPayrollEmployeesTool = CreateXeroTool( @@ -30,7 +32,7 @@ The response presents a complete overview of all staff currently registered in y type: "text" as const, text: `Found ${employees?.length || 0} payroll employees:`, }, - ...(employees?.map((employee: Employee) => ({ + ...(employees?.map((employee: EmployeeResult) => ({ type: "text" as const, text: [ `Employee: ${employee.employeeID}`, diff --git a/src/tools/list/list-payroll-leave-periods.tool.ts b/src/tools/list/list-payroll-leave-periods.tool.ts index ff767862..8bed998b 100644 --- a/src/tools/list/list-payroll-leave-periods.tool.ts +++ b/src/tools/list/list-payroll-leave-periods.tool.ts @@ -1,7 +1,9 @@ import { z } from "zod"; -import { listXeroPayrollLeavePeriods } from "../../handlers/list-xero-payroll-leave-periods.handler.js"; +import { + LeavePeriodResult, + listXeroPayrollLeavePeriods, +} from "../../handlers/list-xero-payroll-leave-periods.handler.js"; import { CreateXeroTool } from "../../helpers/create-xero-tool.js"; -import { LeavePeriod } from "../../types/payroll-nz-types.js"; const ListPayrollLeavePeriodsToolTool = CreateXeroTool( "list-payroll-leave-periods", @@ -32,7 +34,7 @@ const ListPayrollLeavePeriodsToolTool = CreateXeroTool( type: "text" as const, text: `Found ${periods?.length || 0} leave periods for employee ${employeeId}:`, }, - ...(periods?.map((period: LeavePeriod) => ({ + ...(periods?.map((period: LeavePeriodResult) => ({ type: "text" as const, text: [ `Period Status: ${period.periodStatus || "Unknown"}`, From 6b50c2e109af4b757a0656f195d14015805c7c4c Mon Sep 17 00:00:00 2001 From: Elliot Chisholm Date: Mon, 25 May 2026 22:17:00 +1200 Subject: [PATCH 09/13] fix: add typeOfUnitsToAccrue to EmployeeLeaveTypeResult The tool layer surfaces typeOfUnitsToAccrue alongside unitsAccruedAnnually so consumers know whether the accrual value is in hours, days, etc. NZ exposes this field directly; UK only accrues in hours so we default to "Hours" when normalising the result interface. Co-Authored-By: Claude Sonnet 4.6 --- .../list-xero-payroll-employee-leave-types.handler.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/handlers/list-xero-payroll-employee-leave-types.handler.ts b/src/handlers/list-xero-payroll-employee-leave-types.handler.ts index 58b7be51..7365e42f 100644 --- a/src/handlers/list-xero-payroll-employee-leave-types.handler.ts +++ b/src/handlers/list-xero-payroll-employee-leave-types.handler.ts @@ -8,6 +8,7 @@ import { EmployeeLeaveType as UkEmployeeLeaveType } from "xero-node/dist/gen/mod export interface EmployeeLeaveTypeResult { leaveTypeID?: string; scheduleOfAccrual?: string; + typeOfUnitsToAccrue?: string; unitsAccruedAnnually?: number; maximumToAccrue?: number; openingBalance?: number; @@ -24,12 +25,17 @@ function mapLeaveType( ? t.unitsAccruedAnnually : (t as UkEmployeeLeaveType).hoursAccruedAnnually; + // UK only accrues in hours; NZ exposes typeOfUnitsToAccrue (e.g. "Hours", "Days"). + const typeOfUnitsToAccrue = + "typeOfUnitsToAccrue" in t ? t.typeOfUnitsToAccrue : "Hours"; + return { leaveTypeID: t.leaveTypeID, scheduleOfAccrual: t.scheduleOfAccrual !== undefined ? String(t.scheduleOfAccrual) : undefined, + typeOfUnitsToAccrue, unitsAccruedAnnually, maximumToAccrue: t.maximumToAccrue, openingBalance: t.openingBalance, From 999966c4c8754f064469dd0a46351a86e84e2e83 Mon Sep 17 00:00:00 2001 From: Elliot Chisholm Date: Sun, 17 May 2026 14:52:34 +1200 Subject: [PATCH 10/13] fix: prevent token leak in formatError for xero-node SDK errors The xero-node SDK rejects failed API calls with a plain object (not an Error or AxiosError) whose request.headers.authorization field contains the caller's Bearer token. formatError's previous fallback path used template string interpolation which serialised the entire object, leaking the token into LLM context. Fix: add an isXeroSdkError type guard that detects the SDK's {response: {statusCode, body}} shape and extracts only problem.title/detail and httpStatusCode. The unknown-error fallback now returns a fixed string rather than stringifying the object. The 405 MethodNotAllowed response against AU tenants (previously a 4KB JSON blob with a Bearer token) now produces: "405 MethodNotAllowed: Method not allowed for the current customer jurisdiction." 14 new vitest cases including explicit assertions that no Bearer token or cookie values appear in any error output path. Co-Authored-By: Claude Sonnet 4.6 --- src/helpers/__tests__/format-error.test.ts | 158 +++++++++++++++++++++ src/helpers/format-error.ts | 82 +++++++++-- 2 files changed, 225 insertions(+), 15 deletions(-) create mode 100644 src/helpers/__tests__/format-error.test.ts diff --git a/src/helpers/__tests__/format-error.test.ts b/src/helpers/__tests__/format-error.test.ts new file mode 100644 index 00000000..63cc36b1 --- /dev/null +++ b/src/helpers/__tests__/format-error.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect } from "vitest"; +import { AxiosError, AxiosHeaders } from "axios"; +import { formatError } from "../format-error.js"; + +function makeAxiosError(status: number, detail?: string): AxiosError { + const headers = new AxiosHeaders(); + const config = { headers }; + const err = new AxiosError( + "Request failed", + String(status), + config as never, + null, + { + status, + data: detail ? { Detail: detail } : {}, + statusText: "", + headers: {}, + config, + } as never, + ); + return err; +} + +describe("formatError", () => { + describe("AxiosError mapping", () => { + it("maps 401 to authentication message", () => { + expect(formatError(makeAxiosError(401))).toBe( + "Authentication failed. Please check your Xero credentials.", + ); + }); + + it("maps 403 to permission message", () => { + expect(formatError(makeAxiosError(403))).toBe( + "You don't have permission to access this resource in Xero.", + ); + }); + + it("maps 404 to not-found message", () => { + expect(formatError(makeAxiosError(404))).toBe( + "The requested resource was not found in Xero.", + ); + }); + + it("maps 429 to rate-limit message", () => { + expect(formatError(makeAxiosError(429))).toBe( + "Too many requests to Xero. Please try again in a moment.", + ); + }); + + it("returns response.data.Detail for non-mapped statuses", () => { + expect(formatError(makeAxiosError(400, "Field is required"))).toBe( + "Field is required", + ); + }); + + it("returns generic message when no Detail is provided", () => { + expect(formatError(makeAxiosError(500))).toBe( + "An error occurred while communicating with Xero.", + ); + }); + }); + + describe("xero-node SDK error shape", () => { + it("extracts problem.detail and title without leaking request headers", () => { + const sdkError = { + response: { + statusCode: 405, + body: { + httpStatusCode: "MethodNotAllowed", + problem: { + title: "MethodNotAllowed", + detail: "Method not allowed for the current customer jurisdiction.", + status: 405, + }, + }, + headers: { "set-cookie": "ak_bmsc=secret" }, + }, + request: { + headers: { authorization: "Bearer eyJSECRET" }, + }, + }; + + const result = formatError(sdkError); + + expect(result).toBe( + "405 MethodNotAllowed: Method not allowed for the current customer jurisdiction.", + ); + expect(result).not.toContain("Bearer"); + expect(result).not.toContain("eyJSECRET"); + expect(result).not.toContain("set-cookie"); + }); + + it("maps 401 SDK error to the standard auth message", () => { + const sdkError = { + response: { statusCode: 401, body: {} }, + request: { headers: { authorization: "Bearer leaky" } }, + }; + + const result = formatError(sdkError); + expect(result).toBe( + "Authentication failed. Please check your Xero credentials.", + ); + expect(result).not.toContain("Bearer"); + }); + + it("falls back to status code + title when detail is missing", () => { + const sdkError = { + response: { + statusCode: 502, + body: { httpStatusCode: "BadGateway" }, + }, + }; + + expect(formatError(sdkError)).toBe("502 BadGateway"); + }); + + it("falls back to a generic title when neither problem nor httpStatusCode is present", () => { + const sdkError = { response: { statusCode: 502 } }; + expect(formatError(sdkError)).toBe("502 HTTP error"); + }); + }); + + describe("plain Error", () => { + it("returns the error message", () => { + expect(formatError(new Error("Employee ID is required"))).toBe( + "Employee ID is required", + ); + }); + }); + + describe("unknown error shapes", () => { + it("returns a generic message and never stringifies the object", () => { + const leakyUnknown = { + request: { headers: { authorization: "Bearer LEAKY_TOKEN" } }, + }; + + const result = formatError(leakyUnknown); + + expect(result).toBe( + "An unexpected error occurred while communicating with Xero.", + ); + expect(result).not.toContain("Bearer"); + expect(result).not.toContain("LEAKY_TOKEN"); + }); + + it("handles string errors safely", () => { + expect(formatError("something blew up")).toBe( + "An unexpected error occurred while communicating with Xero.", + ); + }); + + it("handles null safely", () => { + expect(formatError(null)).toBe( + "An unexpected error occurred while communicating with Xero.", + ); + }); + }); +}); diff --git a/src/helpers/format-error.ts b/src/helpers/format-error.ts index 18519347..e45cdb0f 100644 --- a/src/helpers/format-error.ts +++ b/src/helpers/format-error.ts @@ -1,27 +1,79 @@ import { AxiosError } from "axios"; +interface XeroSdkProblem { + title?: string; + detail?: string; + status?: number; +} + +interface XeroSdkError { + response: { + statusCode: number; + body?: { + httpStatusCode?: string; + problem?: XeroSdkProblem; + Detail?: string; + }; + }; +} + +function isXeroSdkError(error: unknown): error is XeroSdkError { + if (typeof error !== "object" || error === null) return false; + const response = (error as { response?: unknown }).response; + if (typeof response !== "object" || response === null) return false; + return typeof (response as { statusCode?: unknown }).statusCode === "number"; +} + +function formatHttpStatus(status: number): string { + switch (status) { + case 401: + return "Authentication failed. Please check your Xero credentials."; + case 403: + return "You don't have permission to access this resource in Xero."; + case 404: + return "The requested resource was not found in Xero."; + case 429: + return "Too many requests to Xero. Please try again in a moment."; + default: + return ""; + } +} + /** - * Format error messages in a user-friendly way + * Format error messages for return to the LLM. + * + * Never stringify unknown error objects — the xero-node SDK rejects with a + * plain object whose `request.headers.authorization` field contains the + * caller's Bearer token. Whitelist the fields we extract so secrets never + * reach the response. */ export function formatError(error: unknown): string { if (error instanceof AxiosError) { const status = error.response?.status; const detail = error.response?.data?.Detail; - switch (status) { - case 401: - return "Authentication failed. Please check your Xero credentials."; - case 403: - return "You don't have permission to access this resource in Xero."; - case 404: - return "The requested resource was not found in Xero."; - case 429: - return "Too many requests to Xero. Please try again in a moment."; - default: - return detail || "An error occurred while communicating with Xero."; + if (status !== undefined) { + const mapped = formatHttpStatus(status); + if (mapped) return mapped; } + return detail || "An error occurred while communicating with Xero."; + } + + if (isXeroSdkError(error)) { + const status = error.response.statusCode; + const mapped = formatHttpStatus(status); + if (mapped) return mapped; + + const body = error.response.body; + const problem = body?.problem; + const title = problem?.title ?? body?.httpStatusCode ?? "HTTP error"; + const detail = problem?.detail ?? body?.Detail; + return detail ? `${status} ${title}: ${detail}` : `${status} ${title}`; } - return error instanceof Error - ? error.message - : `An unexpected error occurred: ${error}`; + + if (error instanceof Error) { + return error.message; + } + + return "An unexpected error occurred while communicating with Xero."; } From 91b8216751191d3471e624482587bb75374d0e4f Mon Sep 17 00:00:00 2001 From: Elliot Chisholm Date: Mon, 25 May 2026 22:23:43 +1200 Subject: [PATCH 11/13] test: add tool-layer tests for list-payroll-employee-leave-types Asserts label strings, null-filter behaviour, and error-path rendering on the tool that formats employee leave types. Handler-layer tests already cover the data shape; this catches presentation bugs (wrong labels, duplicate fields) that data-shape tests can't see. Co-Authored-By: Claude Sonnet 4.6 --- ...-payroll-employee-leave-types.tool.test.ts | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 src/tools/list/__tests__/list-payroll-employee-leave-types.tool.test.ts diff --git a/src/tools/list/__tests__/list-payroll-employee-leave-types.tool.test.ts b/src/tools/list/__tests__/list-payroll-employee-leave-types.tool.test.ts new file mode 100644 index 00000000..48d09844 --- /dev/null +++ b/src/tools/list/__tests__/list-payroll-employee-leave-types.tool.test.ts @@ -0,0 +1,108 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; + +vi.mock( + "../../../handlers/list-xero-payroll-employee-leave-types.handler.js", + () => ({ + listXeroPayrollEmployeeLeaveTypes: vi.fn(), + }), +); + +import ListPayrollEmployeeLeaveTypesTool from "../list-payroll-employee-leave-types.tool.js"; +import { listXeroPayrollEmployeeLeaveTypes } from "../../../handlers/list-xero-payroll-employee-leave-types.handler.js"; + +const handlerMock = vi.mocked(listXeroPayrollEmployeeLeaveTypes); + +async function invokeTool(employeeId = "emp-1") { + const tool = ListPayrollEmployeeLeaveTypesTool(); + return tool.handler({ employeeId }, {} as never); +} + +function bodyTextOf( + result: Awaited>, + index: number, +): string { + const item = result.content[index]; + if (item.type !== "text") throw new Error("expected text content"); + return item.text; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("list-payroll-employee-leave-types tool", () => { + it("renders error text when handler returns isError", async () => { + handlerMock.mockResolvedValue({ + result: null, + isError: true, + error: "Not supported for AU orgs", + }); + + const result = await invokeTool(); + + expect(bodyTextOf(result, 0)).toContain("Error listing employee leave types"); + expect(bodyTextOf(result, 0)).toContain("Not supported for AU orgs"); + }); + + it("labels accrual as 'Units Accrued Annually' (not 'Hours')", async () => { + handlerMock.mockResolvedValue({ + result: [{ leaveTypeID: "lt-1", unitsAccruedAnnually: 160 }], + isError: false, + error: null, + }); + + const body = bodyTextOf(await invokeTool(), 1); + + expect(body).toContain("Units Accrued Annually: 160"); + expect(body).not.toContain("Hours Accrued Annually"); + }); + + it("renders typeOfUnitsToAccrue as 'Type of Units' when present", async () => { + handlerMock.mockResolvedValue({ + result: [ + { + leaveTypeID: "lt-1", + typeOfUnitsToAccrue: "Days", + unitsAccruedAnnually: 20, + }, + ], + isError: false, + error: null, + }); + + const body = bodyTextOf(await invokeTool(), 1); + + expect(body).toContain("Type of Units: Days"); + expect(body).toContain("Units Accrued Annually: 20"); + }); + + it("renders Leave Type ID exactly once", async () => { + handlerMock.mockResolvedValue({ + result: [{ leaveTypeID: "lt-1", unitsAccruedAnnually: 160 }], + isError: false, + error: null, + }); + + const body = bodyTextOf(await invokeTool(), 1); + + expect(body.match(/Leave Type ID:/g)?.length).toBe(1); + }); + + it("omits null/undefined fields from rendered output", async () => { + handlerMock.mockResolvedValue({ + result: [{ leaveTypeID: "lt-1" }], + isError: false, + error: null, + }); + + const body = bodyTextOf(await invokeTool(), 1); + + expect(body).toContain("Leave Type ID: lt-1"); + expect(body).not.toContain("Units Accrued Annually"); + expect(body).not.toContain("Maximum To Accrue"); + expect(body).not.toContain("Opening Balance"); + expect(body).not.toContain("Rate Accrued Hourly"); + expect(body).not.toContain("Accrual Date"); + expect(body).not.toContain("Type of Units"); + }); +}); From 80d4d17c45944daf5226f78a8f296fdeb945cec3 Mon Sep 17 00:00:00 2001 From: Elliot Chisholm Date: Mon, 25 May 2026 22:41:07 +1200 Subject: [PATCH 12/13] test: extend tool-layer tests to 5 more payroll/timesheet tools Adds focused tool-layer tests for tools with complex conditional rendering: - list-payroll-employees (fallback strings, optional fields) - list-payroll-employee-leave (default labels, optional fields) - list-payroll-employee-leave-balances (balance: 0 edge case, fallbacks) - list-payroll-timesheets (totalHours zero vs N/A, empty result) - get-payroll-timesheet (error / no-result / JSON paths) Pattern matches the existing list-payroll-employee-leave-types tool test: mock the handler import, invoke tool().handler({...}), assert on rendered text. Where the handler's typed return value uses Date / enum types, an asResult cast helper keeps fixtures readable without losing type safety elsewhere. 22 new tests, 108/108 total passing. Co-Authored-By: Claude Sonnet 4.6 --- .../get-payroll-timesheet.tool.test.ts | 78 +++++++++++ ...yroll-employee-leave-balances.tool.test.ts | 105 ++++++++++++++ .../list-payroll-employee-leave.tool.test.ts | 104 ++++++++++++++ .../list-payroll-employees.tool.test.ts | 118 ++++++++++++++++ .../list-payroll-timesheets.tool.test.ts | 128 ++++++++++++++++++ 5 files changed, 533 insertions(+) create mode 100644 src/tools/get/__tests__/get-payroll-timesheet.tool.test.ts create mode 100644 src/tools/list/__tests__/list-payroll-employee-leave-balances.tool.test.ts create mode 100644 src/tools/list/__tests__/list-payroll-employee-leave.tool.test.ts create mode 100644 src/tools/list/__tests__/list-payroll-employees.tool.test.ts create mode 100644 src/tools/list/__tests__/list-payroll-timesheets.tool.test.ts diff --git a/src/tools/get/__tests__/get-payroll-timesheet.tool.test.ts b/src/tools/get/__tests__/get-payroll-timesheet.tool.test.ts new file mode 100644 index 00000000..78f5bb6b --- /dev/null +++ b/src/tools/get/__tests__/get-payroll-timesheet.tool.test.ts @@ -0,0 +1,78 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; + +vi.mock("../../../handlers/get-xero-payroll-timesheet.handler.js", () => ({ + getXeroPayrollTimesheet: vi.fn(), +})); + +import GetPayrollTimesheetTool from "../get-payroll-timesheet.tool.js"; +import { getXeroPayrollTimesheet } from "../../../handlers/get-xero-payroll-timesheet.handler.js"; + +const handlerMock = vi.mocked(getXeroPayrollTimesheet); + +type HandlerResult = Awaited>; +const asResult = (r: unknown) => r as HandlerResult; + +async function invokeTool(timesheetID = "ts-1") { + const tool = GetPayrollTimesheetTool(); + return tool.handler({ timesheetID }, {} as never); +} + +function bodyTextOf( + result: Awaited>, + index: number, +): string { + const item = result.content[index]; + if (item.type !== "text") throw new Error("expected text content"); + return item.text; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("get-payroll-timesheet tool", () => { + it("renders error text when handler returns isError", async () => { + handlerMock.mockResolvedValue(asResult({ + result: null, + isError: true, + error: "Not found", + })); + + const body = bodyTextOf(await invokeTool(), 0); + + expect(body).toContain("Error retrieving timesheet"); + expect(body).toContain("Not found"); + }); + + it("renders 'No timesheet found' when handler returns null result", async () => { + handlerMock.mockResolvedValue(asResult({ + result: null, + isError: false, + error: null, + })); + + const body = bodyTextOf(await invokeTool("missing-id"), 0); + + expect(body).toContain("No timesheet found with ID: missing-id"); + }); + + it("renders timesheet as JSON when found", async () => { + handlerMock.mockResolvedValue(asResult({ + result: { + timesheetID: "ts-1", + employeeID: "emp-1", + status: "Approved", + totalHours: 40, + }, + isError: false, + error: null, + })); + + const body = bodyTextOf(await invokeTool(), 0); + const parsed = JSON.parse(body); + + expect(parsed.timesheetID).toBe("ts-1"); + expect(parsed.status).toBe("Approved"); + expect(parsed.totalHours).toBe(40); + }); +}); diff --git a/src/tools/list/__tests__/list-payroll-employee-leave-balances.tool.test.ts b/src/tools/list/__tests__/list-payroll-employee-leave-balances.tool.test.ts new file mode 100644 index 00000000..15992254 --- /dev/null +++ b/src/tools/list/__tests__/list-payroll-employee-leave-balances.tool.test.ts @@ -0,0 +1,105 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; + +vi.mock( + "../../../handlers/list-xero-payroll-employee-leave-balances.handler.js", + () => ({ + listXeroPayrollEmployeeLeaveBalances: vi.fn(), + }), +); + +import ListPayrollEmployeeLeaveBalancesTool from "../list-payroll-employee-leave-balances.tool.js"; +import { listXeroPayrollEmployeeLeaveBalances } from "../../../handlers/list-xero-payroll-employee-leave-balances.handler.js"; + +const handlerMock = vi.mocked(listXeroPayrollEmployeeLeaveBalances); + +async function invokeTool(employeeId = "emp-1") { + const tool = ListPayrollEmployeeLeaveBalancesTool(); + return tool.handler({ employeeId }, {} as never); +} + +function bodyTextOf( + result: Awaited>, + index: number, +): string { + const item = result.content[index]; + if (item.type !== "text") throw new Error("expected text content"); + return item.text; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("list-payroll-employee-leave-balances tool", () => { + it("renders error text when handler returns isError", async () => { + handlerMock.mockResolvedValue({ + result: null, + isError: true, + error: "Not supported for AU orgs", + }); + + const body = bodyTextOf(await invokeTool(), 0); + + expect(body).toContain("Error listing employee leave balances"); + }); + + it("renders all populated balance fields", async () => { + handlerMock.mockResolvedValue({ + result: [ + { + leaveTypeID: "lt-1", + name: "Annual Leave", + typeOfUnits: "Hours", + balance: 152, + }, + ], + isError: false, + error: null, + }); + + const body = bodyTextOf(await invokeTool(), 1); + + expect(body).toContain("Leave Type ID: lt-1"); + expect(body).toContain("Name: Annual Leave"); + expect(body).toContain("Type Of Units: Hours"); + expect(body).toContain("Current Balance: 152"); + }); + + it("renders balance of 0 (not omitted by null-filter)", async () => { + handlerMock.mockResolvedValue({ + result: [{ leaveTypeID: "lt-1", name: "Annual", balance: 0 }], + isError: false, + error: null, + }); + + const body = bodyTextOf(await invokeTool(), 1); + + expect(body).toContain("Current Balance: 0"); + }); + + it("renders 'Unknown' / 'Unnamed' fallbacks for missing core fields", async () => { + handlerMock.mockResolvedValue({ + result: [{}], + isError: false, + error: null, + }); + + const body = bodyTextOf(await invokeTool(), 1); + + expect(body).toContain("Leave Type ID: Unknown"); + expect(body).toContain("Name: Unnamed"); + }); + + it("omits typeOfUnits and balance when undefined", async () => { + handlerMock.mockResolvedValue({ + result: [{ leaveTypeID: "lt-1", name: "Annual" }], + isError: false, + error: null, + }); + + const body = bodyTextOf(await invokeTool(), 1); + + expect(body).not.toContain("Type Of Units:"); + expect(body).not.toContain("Current Balance:"); + }); +}); diff --git a/src/tools/list/__tests__/list-payroll-employee-leave.tool.test.ts b/src/tools/list/__tests__/list-payroll-employee-leave.tool.test.ts new file mode 100644 index 00000000..2b4dc03b --- /dev/null +++ b/src/tools/list/__tests__/list-payroll-employee-leave.tool.test.ts @@ -0,0 +1,104 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; + +vi.mock( + "../../../handlers/list-xero-payroll-employee-leave.handler.js", + () => ({ + listXeroPayrollEmployeeLeave: vi.fn(), + }), +); + +import ListPayrollEmployeeLeaveTool from "../list-payroll-employee-leave.tool.js"; +import { listXeroPayrollEmployeeLeave } from "../../../handlers/list-xero-payroll-employee-leave.handler.js"; + +const handlerMock = vi.mocked(listXeroPayrollEmployeeLeave); + +type HandlerResult = Awaited>; +const asResult = (r: unknown) => r as HandlerResult; + +async function invokeTool(employeeId = "emp-1") { + const tool = ListPayrollEmployeeLeaveTool(); + return tool.handler({ employeeId }, {} as never); +} + +function bodyTextOf( + result: Awaited>, + index: number, +): string { + const item = result.content[index]; + if (item.type !== "text") throw new Error("expected text content"); + return item.text; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("list-payroll-employee-leave tool", () => { + it("renders error text when handler returns isError", async () => { + handlerMock.mockResolvedValue(asResult({ + result: null, + isError: true, + error: "Not supported for AU orgs", + })); + + const body = bodyTextOf(await invokeTool(), 0); + + expect(body).toContain("Error listing employee leave"); + expect(body).toContain("Not supported for AU orgs"); + }); + + it("renders all populated leave fields", async () => { + handlerMock.mockResolvedValue(asResult({ + result: [ + { + leaveID: "lv-1", + leaveTypeID: "lt-1", + description: "Annual leave", + startDate: "2024-12-23", + endDate: "2024-12-27", + periods: [{}, {}], + updatedDateUTC: "2024-12-01", + }, + ], + isError: false, + error: null, + })); + + const body = bodyTextOf(await invokeTool(), 1); + + expect(body).toContain("Leave ID: lv-1"); + expect(body).toContain("Leave Type: lt-1"); + expect(body).toContain("Description: Annual leave"); + expect(body).toContain("Periods: 2"); + expect(body).toContain("Start Date: 2024-12-23"); + }); + + it("renders 'Unknown' / 'No description' fallbacks for missing core fields", async () => { + handlerMock.mockResolvedValue(asResult({ + result: [{}], + isError: false, + error: null, + })); + + const body = bodyTextOf(await invokeTool(), 1); + + expect(body).toContain("Leave ID: Unknown"); + expect(body).toContain("Leave Type: Unknown"); + expect(body).toContain("Description: No description"); + }); + + it("omits optional fields when undefined", async () => { + handlerMock.mockResolvedValue(asResult({ + result: [{ leaveID: "lv-1", leaveTypeID: "lt-1", description: "X" }], + isError: false, + error: null, + })); + + const body = bodyTextOf(await invokeTool(), 1); + + expect(body).not.toContain("Start Date:"); + expect(body).not.toContain("End Date:"); + expect(body).not.toContain("Periods:"); + expect(body).not.toContain("Last Updated:"); + }); +}); diff --git a/src/tools/list/__tests__/list-payroll-employees.tool.test.ts b/src/tools/list/__tests__/list-payroll-employees.tool.test.ts new file mode 100644 index 00000000..3fe05505 --- /dev/null +++ b/src/tools/list/__tests__/list-payroll-employees.tool.test.ts @@ -0,0 +1,118 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; + +vi.mock( + "../../../handlers/list-xero-payroll-employees.handler.js", + () => ({ + listXeroPayrollEmployees: vi.fn(), + }), +); + +import ListPayrollEmployeesTool from "../list-payroll-employees.tool.js"; +import { listXeroPayrollEmployees } from "../../../handlers/list-xero-payroll-employees.handler.js"; + +const handlerMock = vi.mocked(listXeroPayrollEmployees); + +type HandlerResult = Awaited>; +const asResult = (r: unknown) => r as HandlerResult; + +async function invokeTool() { + const tool = ListPayrollEmployeesTool(); + return tool.handler({}, {} as never); +} + +function bodyTextOf( + result: Awaited>, + index: number, +): string { + const item = result.content[index]; + if (item.type !== "text") throw new Error("expected text content"); + return item.text; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("list-payroll-employees tool", () => { + it("renders error text when handler returns isError", async () => { + handlerMock.mockResolvedValue(asResult({ + result: null, + isError: true, + error: "Authentication failed", + })); + + const body = bodyTextOf(await invokeTool(), 0); + + expect(body).toContain("Error listing payroll employees"); + expect(body).toContain("Authentication failed"); + }); + + it("renders all employee fields when populated", async () => { + handlerMock.mockResolvedValue(asResult({ + result: [ + { + employeeID: "emp-1", + email: "alice@example.com", + gender: "F", + phoneNumber: "555-1234", + startDate: "2024-01-01", + engagementType: "Permanent", + title: "Engineer", + firstName: "Alice", + lastName: "Smith", + updatedDateUTC: "2024-06-01", + }, + ], + isError: false, + error: null, + })); + + const body = bodyTextOf(await invokeTool(), 1); + + expect(body).toContain("Employee: emp-1"); + expect(body).toContain("Email: alice@example.com"); + expect(body).toContain("Engagement Type: Permanent"); + expect(body).toContain("First Name: Alice"); + expect(body).toContain("Last Name: Smith"); + }); + + it("renders 'No email' fallback when email is missing", async () => { + handlerMock.mockResolvedValue(asResult({ + result: [{ employeeID: "emp-1", engagementType: "Permanent" }], + isError: false, + error: null, + })); + + const body = bodyTextOf(await invokeTool(), 1); + + expect(body).toContain("No email"); + }); + + it("renders 'No status' fallback when engagementType is missing", async () => { + handlerMock.mockResolvedValue(asResult({ + result: [{ employeeID: "emp-1", email: "a@b.com" }], + isError: false, + error: null, + })); + + const body = bodyTextOf(await invokeTool(), 1); + + expect(body).toContain("No status"); + }); + + it("omits truly optional fields when undefined", async () => { + handlerMock.mockResolvedValue(asResult({ + result: [{ employeeID: "emp-1" }], + isError: false, + error: null, + })); + + const body = bodyTextOf(await invokeTool(), 1); + + expect(body).not.toContain("Gender:"); + expect(body).not.toContain("Phone:"); + expect(body).not.toContain("Start Date:"); + expect(body).not.toContain("Title:"); + expect(body).not.toContain("Last Updated:"); + }); +}); diff --git a/src/tools/list/__tests__/list-payroll-timesheets.tool.test.ts b/src/tools/list/__tests__/list-payroll-timesheets.tool.test.ts new file mode 100644 index 00000000..29d3658f --- /dev/null +++ b/src/tools/list/__tests__/list-payroll-timesheets.tool.test.ts @@ -0,0 +1,128 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; + +vi.mock("../../../handlers/list-xero-timesheets.handler.js", () => ({ + listXeroPayrollTimesheets: vi.fn(), +})); + +import ListPayrollTimesheetsTool from "../list-payroll-timesheets.tool.js"; +import { listXeroPayrollTimesheets } from "../../../handlers/list-xero-timesheets.handler.js"; + +const handlerMock = vi.mocked(listXeroPayrollTimesheets); + +// Handler typed return uses Date and StatusEnum; tests render via `timesheet: any` +// in the tool, so we cast fixtures to the resolved type to skip strict subtyping. +type HandlerResult = Awaited>; +const asResult = (r: unknown) => r as HandlerResult; + +async function invokeTool() { + const tool = ListPayrollTimesheetsTool(); + return tool.handler({}, {} as never); +} + +function bodyTextOf( + result: Awaited>, + index: number, +): string { + const item = result.content[index]; + if (item.type !== "text") throw new Error("expected text content"); + return item.text; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("list-payroll-timesheets tool", () => { + it("renders error text when handler returns isError", async () => { + handlerMock.mockResolvedValue(asResult({ + result: null, + isError: true, + error: "Region not supported", + })); + + const body = bodyTextOf(await invokeTool(), 0); + + expect(body).toContain("Error listing timesheets"); + expect(body).toContain("Region not supported"); + }); + + it("renders all timesheet fields", async () => { + handlerMock.mockResolvedValue(asResult({ + result: [ + { + timesheetID: "ts-1", + employeeID: "emp-1", + startDate: "2024-01-01", + endDate: "2024-01-07", + status: "Approved", + totalHours: 40, + updatedDateUTC: "2024-01-08", + }, + ], + isError: false, + error: null, + })); + + const body = bodyTextOf(await invokeTool(), 1); + + expect(body).toContain("Timesheet ID: ts-1"); + expect(body).toContain("Status: Approved"); + expect(body).toContain("Total Hours: 40"); + }); + + it("renders Total Hours: 0 (not 'N/A') when explicitly zero", async () => { + handlerMock.mockResolvedValue(asResult({ + result: [ + { + timesheetID: "ts-1", + employeeID: "emp-1", + startDate: "2024-01-01", + endDate: "2024-01-07", + status: "Draft", + totalHours: 0, + updatedDateUTC: "2024-01-08", + }, + ], + isError: false, + error: null, + })); + + const body = bodyTextOf(await invokeTool(), 1); + + expect(body).toContain("Total Hours: 0"); + expect(body).not.toContain("Total Hours: N/A"); + }); + + it("renders 'N/A' when totalHours is undefined", async () => { + handlerMock.mockResolvedValue(asResult({ + result: [ + { + timesheetID: "ts-1", + employeeID: "emp-1", + startDate: "2024-01-01", + endDate: "2024-01-07", + status: "Draft", + updatedDateUTC: "2024-01-08", + }, + ], + isError: false, + error: null, + })); + + const body = bodyTextOf(await invokeTool(), 1); + + expect(body).toContain("Total Hours: N/A"); + }); + + it("renders 'Found 0 timesheets:' when result is empty", async () => { + handlerMock.mockResolvedValue(asResult({ + result: [], + isError: false, + error: null, + })); + + const body = bodyTextOf(await invokeTool(), 0); + + expect(body).toContain("Found 0 timesheets:"); + }); +}); From 1136230655dcfab65a12a0ee82141598338ec99e Mon Sep 17 00:00:00 2001 From: Elliot Chisholm Date: Thu, 18 Jun 2026 15:16:10 +1200 Subject: [PATCH 13/13] Surface payrollCalendarID on list-payroll-employees Add payrollCalendarID to EmployeeResult, map it from the AU employee payload, and include it in the tool output and description so callers can see which payroll calendar each employee is assigned to. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/list-xero-payroll-employees.handler.test.ts | 2 ++ src/handlers/list-xero-payroll-employees.handler.ts | 2 ++ src/tools/list/list-payroll-employees.tool.ts | 5 ++++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/handlers/__tests__/list-xero-payroll-employees.handler.test.ts b/src/handlers/__tests__/list-xero-payroll-employees.handler.test.ts index ddbe5f16..2b14451b 100644 --- a/src/handlers/__tests__/list-xero-payroll-employees.handler.test.ts +++ b/src/handlers/__tests__/list-xero-payroll-employees.handler.test.ts @@ -34,6 +34,7 @@ describe("listXeroPayrollEmployees", () => { lastName: "Anderson", email: "alice@example.com", phone: "+61 400 000 000", + payrollCalendarID: "cal-123", }, ], }, @@ -47,6 +48,7 @@ describe("listXeroPayrollEmployees", () => { expect(result.isError).toBe(false); expect(result.result).toHaveLength(1); expect(result.result![0].phoneNumber).toBe("+61 400 000 000"); + expect(result.result![0].payrollCalendarID).toBe("cal-123"); }); }); diff --git a/src/handlers/list-xero-payroll-employees.handler.ts b/src/handlers/list-xero-payroll-employees.handler.ts index d60b6e78..286b5f54 100644 --- a/src/handlers/list-xero-payroll-employees.handler.ts +++ b/src/handlers/list-xero-payroll-employees.handler.ts @@ -17,6 +17,7 @@ export interface EmployeeResult { startDate?: string; engagementType?: string; updatedDateUTC?: Date; + payrollCalendarID?: string; } function mapAuEmployee(e: AuEmployee): EmployeeResult { @@ -30,6 +31,7 @@ function mapAuEmployee(e: AuEmployee): EmployeeResult { phoneNumber: e.phone, startDate: e.startDate, updatedDateUTC: e.updatedDateUTC, + payrollCalendarID: e.payrollCalendarID, }; } diff --git a/src/tools/list/list-payroll-employees.tool.ts b/src/tools/list/list-payroll-employees.tool.ts index 59980682..6231a31f 100644 --- a/src/tools/list/list-payroll-employees.tool.ts +++ b/src/tools/list/list-payroll-employees.tool.ts @@ -7,7 +7,7 @@ import { CreateXeroTool } from "../../helpers/create-xero-tool.js"; const ListPayrollEmployeesTool = CreateXeroTool( "list-payroll-employees", `List all payroll employees in Xero. -This retrieves comprehensive employee details including names, User IDs, dates of birth, email addresses, gender, phone numbers, start dates, engagement types (Permanent, FixedTerm, or Casual), titles, and when records were last updated. +This retrieves comprehensive employee details including names, User IDs, dates of birth, email addresses, gender, phone numbers, start dates, engagement types (Permanent, FixedTerm, or Casual), titles, payroll calendar assignments, and when records were last updated. The response presents a complete overview of all staff currently registered in your Xero payroll, with their personal and employment information. If there are many employees, ask the user if they would like to see more detailed information about specific employees before proceeding.`, {}, async () => { @@ -46,6 +46,9 @@ The response presents a complete overview of all staff currently registered in y employee.title ? `Title: ${employee.title}` : null, employee.firstName ? `First Name: ${employee.firstName}` : null, employee.lastName ? `Last Name: ${employee.lastName}` : null, + employee.payrollCalendarID + ? `Payroll Calendar ID: ${employee.payrollCalendarID}` + : "No payroll calendar assigned", employee.updatedDateUTC ? `Last Updated: ${employee.updatedDateUTC}` : null,