Files
wanderbooks/crates/ledger-mcp/src/tools/payroll.rs
T
swaits 3cab75c494 refactor(mcp): split server.rs into a file per tool group (U0a)
server.rs had grown to 12,566 lines: every tool handler, every helper and
the request machinery in one file, so every unit that adds a tool touched
it and parallel worktrees conflicted on every merge (plan/served.md 8.2
U0a, 8.0.3). The 98 handlers still in it now live one file per group under
tools/, following the pattern tools/kept.rs and tools/projects.rs already
use (`#[path]` declaration, `use super::*`, one
`#[tool_router(vis = "pub(super)")] impl` per file):

  documents (invoices, bills, payments, voids, books.documents,
  books.document, the control check, the agings), entries (draft, line,
  post, reverse, get, search, the bank CSV import), reports (the
  statements, books.show, verify, digest, export, the CPA package, the
  P&L by class, with the export builders), assets, schedules, periods,
  reconciliation, parties (parties, vendors, customers, W-9s, the TIN
  vault, 1099s, entry parties), attachments (receipts, filed documents,
  links), settings (the handbook, books.settings, FX rates), chart,
  payroll, payouts, tax; add_class and list_classes join the existing
  projects file, since a project is a class with its facts.

The helpers only one group uses moved into that group's file; the ones
several groups share moved to tools/helpers.rs, re-exported by server.rs
so every group reaches them through `use super::*` and no group file
reaches into another's (resolve_instrument and the attachment/statement
shapers moved there too, since the payment, entries and attachment tools
all use them). server.rs keeps BooksServer, the store and its lock, the
request machinery, call_tool/get_info, tool_router (which now composes
the group routers; core_router is gone), and the shared helpers the plan
names (as_of, hand_over, resolve_vendor, parse_doc_ref, preview_lines_for).

This is a move, not a change: no accounting behaviour, tool name,
description, annotation or schema changed. A dump of
tool_router().list_all() — every tool's name, title, description, input
and output schema and annotations, in order — is identical to main's
(122 tools; the plan's "118" is stale). server.rs is 1,909 lines and has
no #[tool] handler. The server's cross-group tests moved to
server_tests.rs beside it (a #[path] module; fixtures in
server::test_support) and each group's own tests into its group file, so
server.rs stays under the 3,000-line bar. cargo test -p ledger-mcp: 99
passed; cargo test -p ledger-cli: 25 suites, 0 failed.
2026-10-27 04:15:47 -06:00

165 lines
7.0 KiB
Rust

//! Payroll through the server: accounting only, the caller supplies every
//! number (no tax tables). Split whole out of `server.rs`
//! (plan/served.md 8.2 U0a).
use super::*;
#[tool_router(router = payroll_router, vis = "pub(super)")]
impl BooksServer {
#[tool(
name = "books.run_payroll",
title = "Run payroll",
description = "Run payroll for a pay date. Each line carries one employee's gross pay and every withholding/deduction as decimal strings — the engine cuts one balanced entry (Dr Salary & Wage Expense, Dr Payroll Tax Expense for the employer share, Cr the withholding payables, Cr cash for net pay). Auto-creates the 61xx/21xx accounts. All amounts are decimal strings, never floats. Name each line's employee party (employee: id or name, a party with the employee role; add one with books.add_party and record its W-4 with books.set_w4 first): new runs should, so the run shows on the employee's books.party.",
annotations(
read_only_hint = false,
destructive_hint = true,
open_world_hint = false
)
)]
async fn run_payroll(
&self,
params: Parameters<RunPayrollParams>,
) -> Result<Json<RunPayrollOut>, String> {
let p = params.0;
let mut store = self.lock()?;
let cash = resolve_account(&store, &p.cash_account)?;
let lines: Vec<ledger_store::PayrollLineInput> = p
.lines
.into_iter()
.map(|l| {
let employee = l
.employee
.as_deref()
.map(|r| resolve_party(&store, r))
.transpose()?;
let employee_name = match (l.employee_name, employee) {
(Some(n), _) => n,
(None, Some(id)) => store
.ledger()
.get_party(id)
.map(|p| p.name.clone())
.unwrap_or_default(),
(None, None) => {
return Err(
"each payroll line names its employee: employee (a party) or employee_name"
.to_string(),
);
}
};
Ok(ledger_core::PayrollLine {
employee_name,
employee,
gross_pay: parse_amount(&l.gross_pay)?,
federal_withholding: parse_amount(&l.federal_withholding)?,
state_withholding: parse_amount(&l.state_withholding)?,
ss_employee: parse_amount(&l.ss_employee)?,
medicare_employee: parse_amount(&l.medicare_employee)?,
ss_employer: parse_amount(&l.ss_employer)?,
medicare_employer: parse_amount(&l.medicare_employer)?,
other_deductions: parse_amount(&l.other_deductions)?,
})
})
.collect::<Result<_, String>>()?;
if self.settings().parties == ledger_config::PartyPolicy::Require
&& lines
.iter()
.any(|l| l.employee.is_none() && l.net_pay().is_ok_and(|n| !n.is_zero()))
{
return Err(party_required(
"name each payroll line's employee party (employee), so the entry names who was paid",
));
}
let first_new = store.ledger().next_account_id();
let run_id = store
.run_payroll(parse_date(&p.date)?, lines, cash)
.map_err(|e| e.to_string())?;
let run = store
.ledger()
.get_payroll_run(run_id)
.ok_or_else(|| format!("payroll run {run_id} not found after creation"))?;
let total_gross = checked_sum(run.lines.iter().map(|l| Ok(l.gross_pay)), "gross pay")?;
let total_net = checked_sum(run.lines.iter().map(|l| l.net_pay()), "net pay")?;
Ok(Json(RunPayrollOut {
run_id,
entry_id: run.entry_id,
total_gross: money(total_gross),
total_net: money(total_net),
warnings: created_uncategorized(store.ledger(), first_new),
}))
}
#[tool(
name = "books.remit_payroll_taxes",
title = "Remit payroll taxes",
description = "Remit all outstanding payroll tax payables (federal/state withholding, SS, Medicare, other deductions) to cash in one balanced entry. Name the tax agency paid (agency: a party id or name, e.g. the IRS): it is the entry's party, and [books] parties = \"require\" refuses without it.",
annotations(
read_only_hint = false,
destructive_hint = true,
open_world_hint = false
)
)]
async fn remit_payroll_taxes(
&self,
params: Parameters<RemitPayrollTaxesParams>,
) -> Result<Json<RemitPayrollTaxesOut>, String> {
let p = params.0;
let mut store = self.lock()?;
let cash = resolve_account(&store, &p.cash_account)?;
let agency = p
.agency
.as_deref()
.map(|r| resolve_party(&store, r))
.transpose()?;
if agency.is_none() && self.settings().parties == ledger_config::PartyPolicy::Require {
return Err(party_required("pass agency: the tax agency paid"));
}
// Total before remitting, for the response.
let total: Decimal = store
.ledger()
.payroll_tax_payables()
.iter()
.map(|(_, b)| *b)
.sum();
let entry_id = store
.remit_payroll_taxes(parse_date(&p.date)?, cash)
.map_err(|e| e.to_string())?;
if let Some(a) = agency {
store
.set_entry_party(entry_id, Some(a))
.map_err(|e| e.to_string())?;
}
Ok(Json(RemitPayrollTaxesOut {
entry_id,
total: money(total),
}))
}
#[tool(
name = "books.list_payroll_runs",
title = "List payroll runs",
description = "List payroll runs in id order: date, employee count, totals, and the posted entry id.",
annotations(read_only_hint = true, open_world_hint = false)
)]
async fn list_payroll_runs(&self) -> Result<Json<ListPayrollRunsOut>, String> {
let store = self.lock()?;
let runs = store
.list_payroll_runs()
.into_iter()
.map(|r| {
// A run is as journaled (rules.md B-28): a total past exact
// arithmetic refuses the list, never a panic.
let total_gross =
checked_sum(r.lines.iter().map(|l| Ok(l.gross_pay)), "gross pay")?;
Ok(PayrollRunOut {
id: r.id,
date: r.date.to_string(),
employee_count: r.lines.len(),
total_gross: money(total_gross),
entry_id: r.entry_id,
})
})
.collect::<Result<_, String>>()?;
Ok(Json(ListPayrollRunsOut { runs }))
}
}