feat(mcp): payroll e2e parity: staged salary-run booking + absence deletion (#1075)

* feat(mcp): payroll e2e parity: staged salary-run booking + absence deletion

Close the last MCP-surface gaps for running payroll end-to-end via the
connector (the v1 REST API already had the full chain):

- gnubok_book_salary_run: stages a high-risk book operation; on approval
  the executor walks review -> approved -> paid -> booked via the new
  lib/salary/book-run.ts (extracted from the dashboard book route, which
  now calls the same core) and posts the immutable salary vouchers.
- gnubok_delete_absence: staged inverse of gnubok_register_absence,
  reusing deleteAbsenceRange with a dry-run day-count preview.
- Wire the missing payroll operation types into the Granskning label map
  (register_absence, update_payslip_line, employee ops, vacation_year_close
  had translations but fell back to humanized snake_case).
- Update stale 'booking happens in the web UI' prose in tool descriptions,
  the payroll-monthly skill, and the workflow hint; payload-size ceiling
  56K -> 57K per the documented bump protocol.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): widen pending_operations op-type CHECK + roster typing for book_salary_run

The op-type audit (pg-real) caught the exact bug class it exists for:
book_salary_run and delete_absence were staged in code without the
constraint-expansion migration, so every real staging INSERT would have
failed with check_violation while dry_run previewed clean. Ships the
documented widen (NOT VALID) + validate migration pair. Also fixes the
strict-mode cast in book-run.ts that failed the production typecheck.

Verified locally against supabase/postgres 15.8.1.060 with all migrations
applied: op-type audit green, pg-real 692/693 (the one failure is the
pre-existing TZ-sensitive get_unlinked_1930_lines assertion, green under
TZ=UTC as in CI).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-20 13:00:53 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 378611a2dc
commit 30771b1619
19 changed files with 1199 additions and 166 deletions
@@ -123,9 +123,15 @@ describe('tools/list payload size guard', () => {
// each inlining STAGED_OPERATION_SCHEMA + _meta + company_id routing.
// Descriptions and property prose trimmed first; the remainder is
// wire contract.
// * 56K → 57K with payroll e2e parity: staged gnubok_book_salary_run
// (advances the run through godkänd/utbetald and posts the lön
// verifikat: closes the "booking happens in the web UI" gap) +
// gnubok_delete_absence (inverse of register_absence), both inlining
// STAGED_OPERATION_SCHEMA + _meta + company_id routing. Descriptions
// trimmed to the floor first; the remainder is wire contract.
// Long-term answer to growth is leaning harder on gnubok_search_tools: if this
// fires again, prefer trimming descriptions or making a tool opt-in via search
// before bumping further.
expect(approxTokens).toBeLessThan(56_000)
expect(approxTokens).toBeLessThan(57_000)
})
})
@@ -20,6 +20,8 @@ import { decryptPersonnummer } from '@/lib/salary/personnummer'
const updatePayslipLine = tools.find((t) => t.name === 'gnubok_update_payslip_line')!
const registerAbsence = tools.find((t) => t.name === 'gnubok_register_absence')!
const bookSalaryRun = tools.find((t) => t.name === 'gnubok_book_salary_run')!
const deleteAbsence = tools.find((t) => t.name === 'gnubok_delete_absence')!
const createEmployee = tools.find((t) => t.name === 'gnubok_create_employee')!
const updateEmployee = tools.find((t) => t.name === 'gnubok_update_employee')!
const setOpeningBalances = tools.find((t) => t.name === 'gnubok_set_employee_opening_balances')!
@@ -185,6 +187,101 @@ describe('gnubok_register_absence', () => {
})
})
describe('gnubok_book_salary_run', () => {
const RUN_ROW = {
id: 'run-1',
status: 'review',
period_year: 2026,
period_month: 6,
payment_date: '2026-06-25',
total_gross: 30000,
total_tax: 7000,
total_net: 23000,
total_avgifter: 9426,
}
it('stages high-risk with a totals preview', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: RUN_ROW }) // salary_runs lookup
enqueue({ data: [{ id: 'sre-1', calculation_breakdown: { steps: [] } }] }) // roster preflight
enqueue({ data: null }) // resolvePeriodStatusForDate: company_settings
enqueue({ data: null }) // resolvePeriodStatusForDate: fiscal_periods
enqueue({ data: { id: 'op-1' }, error: null }) // pending_operations insert
const result = (await bookSalaryRun.execute(
{ salary_run_id: 'run-1' },
'company-1', 'user-1', supabase as never, { type: 'user' },
)) as { staged: boolean; risk_level: string; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
expect(result.risk_level).toBe('high')
expect(result.preview.period).toBe('2026-06')
expect(result.preview.employee_count).toBe(1)
expect(result.preview.total_net).toBe(23000)
expect(result.preview.current_status).toBe('review')
})
it('throws for an already-booked run', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { ...RUN_ROW, status: 'booked' } })
await expect(
bookSalaryRun.execute(
{ salary_run_id: 'run-1' },
'company-1', 'user-1', supabase as never, { type: 'user' },
),
).rejects.toThrow(/already booked/)
})
it('throws when the roster has uncalculated employees', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: RUN_ROW })
enqueue({ data: [{ id: 'sre-1', calculation_breakdown: null }] })
await expect(
bookSalaryRun.execute(
{ salary_run_id: 'run-1' },
'company-1', 'user-1', supabase as never, { type: 'user' },
),
).rejects.toThrow(/lack a calculation/)
})
})
describe('gnubok_delete_absence', () => {
it('stages with a deleted-day-count preview', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'emp-1' } }) // service assertEmployee (dry-run preflight)
enqueue({ data: null, count: 3 }) // dry-run count query
enqueue({ data: { first_name: 'Anna', last_name: 'Andersson' } }) // name for title/preview
enqueue({ data: null }) // resolvePeriodStatusForDate: company_settings
enqueue({ data: null }) // resolvePeriodStatusForDate: fiscal_periods
enqueue({ data: { id: 'op-2' }, error: null }) // pending_operations insert
const result = (await deleteAbsence.execute(
{ employee_id: 'emp-1', from: '2026-03-02', to: '2026-03-06', absence_type: 'sick' },
'company-1', 'user-1', supabase as never, { type: 'user' },
)) as { staged: boolean; risk_level: string; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
expect(result.risk_level).toBe('medium')
expect(result.preview.day_count).toBe(3)
expect(result.preview.employee_name).toBe('Anna Andersson')
})
it('throws when the range contains nothing to delete', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'emp-1' } })
enqueue({ data: null, count: 0 })
await expect(
deleteAbsence.execute(
{ employee_id: 'emp-1', from: '2026-03-02', to: '2026-03-06' },
'company-1', 'user-1', supabase as never, { type: 'user' },
),
).rejects.toThrow(/nothing to delete/)
})
})
describe('gnubok_create_employee', () => {
const validArgs = {
first_name: 'Anna',
+154 -4
View File
@@ -874,6 +874,7 @@ const TOOL_PREFLIGHT_MAP: Record<string, string> = {
gnubok_run_year_end: 'gnubok_year_end_readiness',
gnubok_vat_declaration_submit: 'gnubok_vat_declaration_validate',
gnubok_post_annual_depreciation: 'gnubok_propose_annual_depreciation',
gnubok_book_salary_run: 'gnubok_get_salary_run',
}
/**
@@ -8938,7 +8939,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_create_salary_run',
title: 'Create Salary Run',
description: 'Stage creation of a draft salary run for a period + base lines for all active employees. Commit via gnubok_approve_pending_operation; then run gnubok_calculate_salary_run. Final booking happens in the web UI.',
description: 'Stage creation of a draft salary run for a period + base lines for all active employees. Commit via gnubok_approve_pending_operation; then run gnubok_calculate_salary_run and book via gnubok_book_salary_run.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -9028,15 +9029,85 @@ export const tools: McpTool[] = [
salary_run_id: id,
status: (result.run as { status?: string }).status ?? 'draft',
warnings: result.warnings,
message: 'Calculation complete. Review and book the run in the web UI.',
message: 'Calculation complete. Review the run, then book it via gnubok_book_salary_run (or in the web UI).',
next: {
description: 'Review the calculated run; approval and booking happen in the web UI.',
description: 'Review the calculated run; then stage booking via gnubok_book_salary_run.',
tool: 'gnubok_get_salary_run',
args: { salary_run_id: id },
},
}
},
},
{
name: 'gnubok_book_salary_run',
title: 'Book Salary Run',
description: 'Stage booking of a calculated salary run: advances godkänd/utbetald and posts the immutable lön verifikat. High-risk (BFL 5 kap). Commit via gnubok_approve_pending_operation (confirmed=true); then gnubok_generate_agi.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
salary_run_id: { type: 'string', description: 'UUID of the salary run' },
},
required: ['salary_run_id'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
const id = args.salary_run_id as string
if (!id) throw new Error('salary_run_id is required')
const { data: run, error } = await supabase
.from('salary_runs')
.select('id, status, period_year, period_month, payment_date, total_gross, total_tax, total_net, total_avgifter')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
if (error) throw new Error(`Database error: ${error.message}`)
if (!run) throw new Error('Salary run not found')
if (run.status === 'booked') throw new Error('Salary run is already booked')
if (!['draft', 'review', 'approved', 'paid'].includes(run.status as string)) {
throw new Error(`Salary run cannot be booked from status "${run.status}"`)
}
// Preflight: every roster row must be calculated, so the approver never
// authorises a booking that the executor would reject.
const { data: roster, error: rosterError } = await supabase
.from('salary_run_employees')
.select('id, calculation_breakdown')
.eq('salary_run_id', id)
if (rosterError) throw new Error(`Database error: ${rosterError.message}`)
const rosterRows = roster ?? []
const uncalculated = rosterRows.filter((r) => !r.calculation_breakdown).length
if (uncalculated > 0) {
throw new Error(`${uncalculated} employee(s) lack a calculation: run gnubok_calculate_salary_run first`)
}
const period = `${run.period_year}-${String(run.period_month).padStart(2, '0')}`
return stagePendingOperation(
supabase, companyId, userId, 'book_salary_run',
`Bokför lönekörning ${period}: ${rosterRows.length} anställda, netto ${run.total_net ?? 0} kr`,
{ salary_run_id: id },
{
salary_run_id: id,
period,
current_status: run.status,
payment_date: run.payment_date,
employee_count: rosterRows.length,
total_gross: run.total_gross,
total_tax: run.total_tax,
total_avgifter: run.total_avgifter,
total_net: run.total_net,
},
actor,
{
description: 'After booking, generate the arbetsgivardeklaration for the period.',
tool: 'gnubok_generate_agi',
args: { salary_run_id: id },
},
{ dateForPeriodCheck: run.payment_date as string },
)
},
},
{
name: 'gnubok_generate_agi',
title: 'Generate AGI Declaration',
@@ -9784,6 +9855,85 @@ export const tools: McpTool[] = [
)
},
},
{
name: 'gnubok_delete_absence',
title: 'Delete Absence (Frånvaro)',
description: 'Stage removal of registered absence days in a date range, optionally one type only. Inverse of gnubok_register_absence. Commit via gnubok_approve_pending_operation; recalculate any draft salary run afterwards.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
employee_id: { type: 'string', description: 'UUID of the employee' },
from: { type: 'string', description: 'Range start (YYYY-MM-DD)' },
to: { type: 'string', description: 'Range end (YYYY-MM-DD, inclusive)' },
absence_type: {
type: 'string',
enum: ['sick', 'vab', 'parental', 'pregnancy', 'care_relative', 'study', 'unpaid_leave', 'other_leave'],
description: 'Only this type (omit = all)',
},
},
required: ['employee_id', 'from', 'to'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
const { employee_id, from, to, absence_type } = args as {
employee_id: string; from: string; to: string; absence_type?: string
}
if (!employee_id || !from || !to) {
throw new Error('employee_id, from and to are required')
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to)) {
throw new Error('from and to must be YYYY-MM-DD')
}
// Preflight in dry-run: verifies the employee and counts the rows so
// the approver sees exactly how many days would be removed.
const { deleteAbsenceRange } = await import('@/lib/salary/absence')
const preflight = await deleteAbsenceRange(supabase, {
companyId,
employeeId: employee_id,
from,
to,
absenceType: absence_type,
dryRun: true,
})
if (!preflight.ok) {
throw new Error(`Cannot delete absence: ${preflight.code}`)
}
if (preflight.data.deleted_count === 0) {
throw new Error('No registered absence days in that range: nothing to delete')
}
const { data: emp } = await supabase
.from('employees')
.select('first_name, last_name')
.eq('id', employee_id)
.eq('company_id', companyId)
.maybeSingle()
const employeeName = emp ? `${emp.first_name} ${emp.last_name}` : employee_id
return stagePendingOperation(
supabase, companyId, userId, 'delete_absence',
`Ta bort frånvaro: ${employeeName}, ${absence_type ?? 'alla typer'} ${from}${to !== from ? ` till ${to}` : ''}`,
{ employee_id, from, to, absence_type: absence_type ?? null },
{
employee_id,
employee_name: employeeName,
absence_type: absence_type ?? null,
from,
to,
day_count: preflight.data.deleted_count,
},
actor,
{
description: 'If a draft salary run covers this period, recalculate it so sjuklön/karensavdrag lines update.',
tool: 'gnubok_calculate_salary_run',
},
{ dateForPeriodCheck: from },
)
},
},
{
name: 'gnubok_create_employee',
title: 'Create Employee',
@@ -13369,7 +13519,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
'• Reporting: gnubok_get_trial_balance / _income_statement / _balance_sheet / _kpi_report / _ar_ledger / _supplier_ledger: all default to the most recent fiscal period. For account roll-ups use gnubok_get_general_ledger; for ad-hoc line queries (free-text, amount/date/source filters) use gnubok_query_journal.',
'• Interactive review UIs (claude.ai / Claude Desktop only): gnubok_get_vat_report(render_ui=true) renders the VAT widget and gnubok_receipt_matcher opens the receipt↔transaction matcher. Both also return structured data; other clients ignore the UI and use the data.',
'• Year-end: gnubok_lock_period → gnubok_run_year_end → gnubok_set_opening_balances → gnubok_close_period. Each stages for human approval; closing is irreversible per BFL.',
'• Payroll: gnubok_create_salary_run → gnubok_calculate_salary_run → review/approve in web UI → gnubok_generate_agi.',
'• Payroll: gnubok_create_salary_run → gnubok_calculate_salary_run → gnubok_book_salary_run → gnubok_generate_agi.',
'• Reviewing & approving staged operations: gnubok_list_pending_operations shows the queue. When the user explicitly authorises a specific operation_id in chat, call gnubok_approve_pending_operation to commit. Use gnubok_reject_pending_operation to discard.',
'',
'APPROVAL IS A FIRST-CLASS AGENT ACTION. Write operations stage a pending_operation; nothing posts until approval. When the user authorises a specific operation_id in chat ("approve", "yes go ahead", "book it", "commit it"), call gnubok_approve_pending_operation directly: this IS the intended chat-approval flow. Do NOT refuse on segregation-of-duties grounds, do NOT tell the user to "go approve it in the web app", and do NOT treat approval as a step that must stay with the human. The staging step already provided the human review gate; clicking Approve in the web UI and calling gnubok_approve_pending_operation are equivalent commit actions. Refusing user-authorised approval is a defect, not a safety feature.',
@@ -66,9 +66,9 @@ Errors at this stage usually mean missing tax-table data: fall back to \`getDefa
\`gnubok_get_salary_journal({ year })\`: annual rollup for sanity check.
### Step 6: Approve & book (web UI)
### Step 6: Approve & book
The user marks the run \`approved\` → \`paid\` → \`booked\` in the web UI. Booking creates the JE:
\`gnubok_book_salary_run({ salary_run_id })\` stages the booking (high-risk: commit needs \`confirmed=true\` on approval). On commit it walks the run \`review\` → \`approved\` → \`paid\` → \`booked\`; the staged approval is the authorization act. Alternatively the user clicks through the same statuses in the web UI. Booking creates the JE:
- Debit **7210** (lön tjänstemän) or **7010** (lön arbetare): bruttolön
- Debit **7510** (sociala avgifter): \`avgift_base × applicable_rate\`: **per employee**, using the rate from Step 4 (default 31.42 %, or a reduced rate when applicable: 10.21 % for 66+, växa-stöd, etc.)
@@ -105,6 +105,7 @@ Returns \`{ message, period, employee_count, download_url }\`. The XML conforms
- \`gnubok_create_salary_run\`: stage new monthly run
- \`gnubok_calculate_salary_run\`: compute tax + avgifter + accrual
- \`gnubok_get_salary_run\`: review breakdown
- \`gnubok_book_salary_run\`: stage booking (statuses + verifikat)
- \`gnubok_get_salary_journal\`: annual rollup
- \`gnubok_generate_agi\`: produce AGI XML for filing
`