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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ Run `tp --help` for full command list. Key commands:
- `tp leave cancel ID --reason "..." --yes` - Cancel leave
- `tp leave list --filter UPCOMING --json` - List leave
- `tp leave balance --emp-id JEK` - Leave-usage signal (days since last leave + hours taken in last 12 months)
- `tp leave balances status` - When leave balances were last imported from Xero, and whether they are stale
- `tp leave balances import ./LeaveBalances.csv --yes` - Import company-wide leave balances from a Xero CSV export (leave admins only)
- `tp feature accounting enable` - Enable accounting skills and accounting MCP tools
- `tp feature developer enable` - Enable developer diagnostics/environment comparison skills and timesheet/finance bug diagnostic skills
- `tp mcp [--tenant NAME]` - Start MCP server (optional per-session tenant binding)
Expand Down Expand Up @@ -98,6 +100,33 @@ validation and payload preparation, returns the proposed request, and must not c

The cancel endpoint (`PUT /api/leave/{id}/cancel`) requires `LeaveId` (Guid) and `CancellationReason` in the request body.

## Leave Balances (Xero CSV Sync)

`POST /api/leave/balances/import` takes the raw Xero "Leave Balances" CSV export as the
request body with content type `text/csv`. It is **not** a JSON endpoint and **not** a
multipart upload, so it must go through `PostRawAsync`, never the `PostAsync`/`PutAsync`
JSON helpers - `JsonContent` would send an escaped string literal and the server-side
parser would reject it. `LeaveBalancesApiTests` asserts the body and content type for
exactly this reason.

The endpoint is leave-admin only (`403` otherwise) and returns `422` with the CSV parser's
message as a bare JSON string when the file cannot be read. Both are translated into
readable messages by `LeaveBalanceImportService`, which owns file reading and validation
for the CLI and MCP alike. Import replaces stored balances for every matched employee;
there is no server-side dry-run, so the CLI confirms unless `--yes` is passed.

CLI and MCP surfaces take a **path** to the CSV, not its contents. Tool arguments travel
through an agent's context, where a large CSV is expensive and liable to be silently
truncated into a partial import that still looks successful. `GET /api/leave/balances/status`
is the cheap read used to decide whether a re-import is due.

The read-only MCP status tool is on the default leave surface. The destructive MCP import
tool is available only when the accounting feature pack is enabled.

Rows whose Xero employee name matches no TimePro employee, or matches several, are returned
in `unmatchedEmployees` and skipped rather than guessed at; implausible balances are returned
in `warnings`. The import succeeds regardless, so both lists must be surfaced to the user.

The list endpoint (`GET /api/leave/`) returns per-entry `daysAway`, `updatedAt`, `optionalEmp`, `timeLessOverride`, `cancellationReason` (all bound on `LeaveEntry`) plus a top-level `cancelledCount` on the list envelope. These surface in `tp leave list --json`.

## Testing
Expand Down
25 changes: 22 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ SSW TimePro is a time tracking and invoicing system. This CLI makes it fast to v
- **Timesheet CRUD** — Create, update, delete timesheets with rate checking and lock detection
- **Suggested Timesheets** — View and accept suggested timesheets to keep accuracy stats high
- **CRM Bookings** — See your appointments from the CRM calendar
- **Leave Management** — Create, list, update, and cancel EasyLeave requests
- **Leave Management** — Create, list, update, and cancel EasyLeave requests; leave admins can sync company-wide leave balances from a Xero CSV export
- **Repo Mapping** — Map git repos to clients/projects; auto-detects via path or remote URL, with git worktree support; optional `--issues-repo` for projects whose issues live in a different GitHub repo than the code
- **Daily Scrum** — Generate an SSW-format daily scrum email from timesheets, CRM bookings and GitHub activity, with AutoScrum-inspired `--smart` selection, overridable per-tenant/client templates, rich-text / markdown / plain clipboard support and an interactive copy mode
- **Location Defaults** — Set WFH days so location is auto-applied when creating timesheets
Expand Down Expand Up @@ -132,6 +132,8 @@ tp ts get 2026-03-12 # Specific date
| `tp leave create` | Create a leave request (see options and `--dry-run` below) |
| `tp leave update ID` | Update a leave request while preserving unspecified API-returned fields; supports `--dry-run` |
| `tp leave cancel ID` | Cancel a leave request (`--reason`) |
| `tp leave balances status` | Show when leave balances were last imported from Xero and whether they are stale |
| `tp leave balances import PATH` | Import leave balances for all employees from a Xero CSV export (leave admins only) |
| `tp cl search QUERY` | Search for clients |
| `tp proj list --client ID` | List projects for a client |
| `tp proj recent` | Surface projects you've recently logged time against (likely picks for new entries) |
Expand Down Expand Up @@ -244,6 +246,12 @@ tp leave update <ID> --start 2026-04-01 --end 2026-04-01 \

# Cancel a leave request
tp leave cancel <ID> --reason "Plans changed" --yes

# Check whether the stored leave balances are still current
tp leave balances status --json

# Import the latest Xero leave balances export (leave admins only)
tp leave balances import ~/Downloads/LeaveBalances.csv --yes
```

Leave create options:
Expand All @@ -266,6 +274,16 @@ explicit. Use `--clear-approved-by` or `--clear-cc` to remove those values, and
`--dry-run`; combine it with `--json` to inspect the exact API payload without creating
or changing leave.

**Leave balances (Xero sync).** `tp leave balances import` uploads the raw Xero "Leave
Balances" CSV export and replaces the balances stored in TimePro for every employee it can
match. It requires leave admin rights, has no dry-run and no undo, so it prompts unless you
pass `--yes`. Rows whose Xero name matches no TimePro employee — or matches more than one —
are reported as skipped rather than guessed at, and implausibly large balances are flagged
as warnings; the import still succeeds, so check both lists afterwards. Run
`tp leave balances status` first to see whether a re-import is actually due. The accounting
MCP tool takes the path to the CSV rather than its contents, so the file never has to pass
through an agent's context.

### Week View

Compact view shows one line per timesheet with totals:
Expand Down Expand Up @@ -569,15 +587,15 @@ Current default tool groups include:
|-------|----------|
| Timesheets | Get, create, update, delete, suggested timesheets, accept suggestions, list iterations, `check_week` (leave-aware weekly coverage) |
| Lookup | Search clients, list projects, get client rate, CRM bookings, location and repo mapping |
| Leave | List EasyLeave entries (optionally filtered by `empId`), create and safely update EasyLeave requests with dry-run previews, `get_leave_balance` (days since last leave + 12-month hours) |
| Leave | List EasyLeave entries (optionally filtered by `empId`), create and safely update EasyLeave requests with dry-run previews, `get_leave_balance` (days since last leave + 12-month hours), and `get_leave_balance_status` (Xero balance sync status) |

Optional accounting MCP tools are enabled with:

```bash
tp feature accounting enable
```

That adds invoices, receipts, credit notes, products/SKUs, client rates, unbilled time, timesheet queries, current user/reference-code reporting, recurring invoices, and prepaid drawdown status. More complex accounting diagnostics live in guide-backed Markdown skills so teams can extend the collection without adding a dedicated command for every report.
That adds invoices, receipts, credit notes, products/SKUs, client rates, unbilled time, timesheet queries, current user/reference-code reporting, recurring invoices, prepaid drawdown status, and the leave-admin-only `import_leave_balances` tool. More complex accounting diagnostics live in guide-backed Markdown skills so teams can extend the collection without adding a dedicated command for every report.

Developer diagnostics are CLI/skill workflows. Enable the generated developer skills with:

Expand Down Expand Up @@ -608,6 +626,7 @@ Then ask Claude things like:
- "Accept the suggested timesheet for Monday"
- "What's my billing rate for Northwind?"
- "Move my upcoming leave to Wednesday and keep its other details"
- "Are the leave balances up to date? If not, import ~/Downloads/LeaveBalances.csv"

### VS Code (Copilot / Continue)

Expand Down
4 changes: 3 additions & 1 deletion docs/skill-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ The timesheets skill keeps:
- `tp info --json` as the first health/update check, preferred over `tp --version`
- `tp project recent --json` as the first project-selection step
- the existing timesheet, booking, leave, repo mapping, scrum, and troubleshooting guidance
- read-only EasyLeave balance freshness via CLI and the default MCP status tool
- Northwind-only examples (`NWIND`, `1I776Q`, `Northwind/traders-app`)

The tenant setup skill keeps:
Expand All @@ -119,7 +120,8 @@ The tenant setup skill keeps:
The accounting skill keeps:

- `allowed-tools: Bash(tp *)`
- instruction-only read-only accounting workflows
- instruction-only accounting workflows, read-only except for explicitly approved Xero leave-balance imports
- default-MCP balance status plus accounting-gated MCP import guidance
- client billable-work threshold report guidance, including the `.rows` JSON envelope shape
- deeper reconciliation diagnostics for Excel, CSV, Xero MCP, bank-feed MCP, or another external source
- guidance to check `tp accounting guide` first, then use specific recipes under `guides/accounting/`
Expand Down
122 changes: 122 additions & 0 deletions src/SSW.TimePro.Cli/Features/Leave/BalancesImportCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
using System.ComponentModel;
using SSW.TimePro.Cli.Infrastructure.ApiClient;
using SSW.TimePro.Cli.Infrastructure.Config;
using SSW.TimePro.Cli.Infrastructure.Output;
using Spectre.Console;
using Spectre.Console.Cli;

namespace SSW.TimePro.Cli.Features.Leave;

[Description("Import leave balances for all employees from a Xero leave balances CSV export")]
public class BalancesImportCommand : AsyncCommand<BalancesImportCommand.Settings>
{
private readonly LeaveBalanceImportService _importService;
private readonly IConfigService _config;

public class Settings : CommandSettings
{
[CommandArgument(0, "<PATH>")]
[Description("Path to the Xero 'Leave Balances' CSV export")]
public string CsvPath { get; set; } = string.Empty;

[CommandOption("--yes")]
[Description("Skip confirmation")]
public bool Yes { get; set; }

[CommandOption("--json")]
[Description("Output as JSON")]
public bool Json { get; set; }
}

public BalancesImportCommand(LeaveBalanceImportService importService, IConfigService config)
{
_importService = importService;
_config = config;
}

protected override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
{
if (_config.LoadActiveTenantConfig() is null)
{
WriteError(settings.Json, "Not logged in. Run 'tp login --tenant <id>' first.");
return 1;
}

// Validate the file before prompting so an unusable path fails immediately.
try
{
_importService.ReadCsv(settings.CsvPath);
}
catch (LeaveBalanceImportValidationException ex)
{
WriteError(settings.Json, ex.Message);
return 1;
}

// The import replaces stored balances for every matched employee and TimePro offers no
// dry-run, so confirm unless the caller has opted out.
if (!settings.Yes && !settings.Json)
{
AnsiConsole.MarkupLine(
$"About to import leave balances for [bold]all employees[/] from {Markup.Escape(settings.CsvPath)}.");
if (!AnsiConsole.Confirm("This replaces the balances currently stored in TimePro. Continue?", false))
return 1;
}

try
{
var result = await _importService.ImportAsync(settings.CsvPath, cancellationToken);

OutputHelper.Render(result, settings.Json, r =>
{
var table = new Table().NoBorder().HideHeaders().AddColumn("Key").AddColumn("Value");
table.AddRow("[bold]Balances as at[/]", r.AsAtDate.ToString("yyyy-MM-dd"));
table.AddRow("[bold]Created[/]", r.Created.ToString());
table.AddRow("[bold]Updated[/]", r.Updated.ToString());
AnsiConsole.Write(table);

foreach (var warning in r.Warnings ?? [])
OutputHelper.WriteWarning(warning);

var unmatchedEmployees = r.UnmatchedEmployees ?? [];
if (unmatchedEmployees.Count > 0)
{
OutputHelper.WriteWarning(
$"{unmatchedEmployees.Count} row(s) were skipped because the name did not match exactly one TimePro employee:");
foreach (var name in unmatchedEmployees)
AnsiConsole.MarkupLine($" - {Markup.Escape(name)}");
}

OutputHelper.WriteSuccess($"Imported leave balances ({r.Created} created, {r.Updated} updated)");
});

return 0;
}
catch (LeaveBalanceImportValidationException ex)
{
WriteError(settings.Json, ex.Message);
return 1;
}
catch (LeaveBalanceImportUncertainException ex)
{
WriteError(settings.Json, ex.Message);
return 1;
}
catch (ApiException ex)
{
var detail = ApiErrorParser.ExtractDetail(ex.ResponseBody);
if (settings.Json)
OutputHelper.WriteJsonError($"API error: {ex.Message}", ex.StatusCode, detail);
OutputHelper.WriteError($"API error ({ex.StatusCode}): {ex.Message}"
+ (detail is not null ? $" — {detail}" : ""));
return 1;
}
}

private static void WriteError(bool json, string message)
{
if (json)
OutputHelper.WriteJsonError(message);
OutputHelper.WriteError(message);
}
}
80 changes: 80 additions & 0 deletions src/SSW.TimePro.Cli/Features/Leave/BalancesStatusCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using System.ComponentModel;
using SSW.TimePro.Cli.Infrastructure.ApiClient;
using SSW.TimePro.Cli.Infrastructure.Config;
using SSW.TimePro.Cli.Infrastructure.Output;
using Spectre.Console;
using Spectre.Console.Cli;

namespace SSW.TimePro.Cli.Features.Leave;

[Description("Show when leave balances were last imported from Xero and whether they are stale")]
public class BalancesStatusCommand : AsyncCommand<BalancesStatusCommand.Settings>
{
private readonly ITimeProApiClient _api;
private readonly IConfigService _config;

public class Settings : CommandSettings
{
[CommandOption("--json")]
[Description("Output as JSON")]
public bool Json { get; set; }
}

public BalancesStatusCommand(ITimeProApiClient api, IConfigService config)
{
_api = api;
_config = config;
}

protected override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
{
if (_config.LoadActiveTenantConfig() is null)
{
const string message = "Not logged in. Run 'tp login --tenant <id>' first.";
if (settings.Json)
OutputHelper.WriteJsonError(message);
else
OutputHelper.WriteError(message);
return 1;
}

try
{
var status = await _api.GetLeaveBalanceStatusAsync(cancellationToken);
if (status?.LastImportedAt is null)
{
// Nothing imported yet is a valid state, not a failure.
if (settings.Json)
OutputHelper.WriteJson(new { imported = false });
else
OutputHelper.WriteWarning("No leave balances have been imported yet.");
return 0;
}

OutputHelper.Render(status, settings.Json, s =>
{
var table = new Table().NoBorder().HideHeaders().AddColumn("Key").AddColumn("Value");
table.AddRow("[bold]Balances as at[/]", s.AsAtDate?.ToString("yyyy-MM-dd") ?? "-");
table.AddRow("[bold]Last imported[/]",
s.LastImportedAt?.ToLocalTime().ToString("yyyy-MM-dd HH:mm") ?? "never");
table.AddRow("[bold]Employees[/]", s.EmployeeCount.ToString());
table.AddRow("[bold]Stale[/]", s.IsStale ? "[yellow]yes[/]" : "no");
AnsiConsole.Write(table);

if (s.IsStale)
OutputHelper.WriteWarning("Balances are stale. Re-import the latest Xero export with 'tp leave balances import <path>'.");
});

return 0;
}
catch (ApiException ex)
{
var detail = ApiErrorParser.ExtractDetail(ex.ResponseBody);
if (settings.Json)
OutputHelper.WriteJsonError($"API error: {ex.Message}", ex.StatusCode, detail);
OutputHelper.WriteError($"API error ({ex.StatusCode}): {ex.Message}"
+ (detail is not null ? $" — {detail}" : ""));
return 1;
}
}
}
Loading