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:
co-authored by
Claude Fable 5
parent
378611a2dc
commit
30771b1619
@@ -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',
|
||||
|
||||
@@ -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
|
||||
`
|
||||
|
||||
Reference in New Issue
Block a user