feat(salary): agent path to set this month's per-run salary (#2015)
* feat(salary): agent path to set this month's per-run salary
Agents could not do variable owner pay: the only per-run edit tool,
gnubok_update_payslip_line, edits the display-only Grundlon line that
every recalculation rebuilds from salary_run_employees.monthly_salary,
so the fixed employee salary silently won (user-reported).
- lib/salary/run-employees.ts: setRunEmployeeSalary() shared service
(draft gate, roundOre, 0 = nollkorning, display-line refresh); the
cookie route PATCH now delegates to it (behavior unchanged)
- MCP: gnubok_set_run_salary staged tool (search catalog: tools/list
budget at zero headroom), op type set_run_salary (medium risk),
commitSetRunSalary executor, payroll:write scope, payroll_month
loadout + payroll-monthly skill step; update_payslip_line description
now warns that recalc rebuilds base salary lines
- v1 REST: PATCH /salary-runs/{id}/employees/{employeeId} accepting
monthly_salary (draft only, dry-run, idempotency key)
- Migration pair (NOT VALID + VALIDATE) adds set_run_salary to the
pending_operations op-type CHECK; base list verified against prod live
- Tests: service, staged tool, executor, cookie route, v1 route; spec
snapshot updated
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MP37pE3zk667nP6S766iJG
* fix(salary): harden set_run_salary per skeptic + CI findings
- Clear calculation_breakdown when the per-run salary changes so the
existing book preflights force a recalculation: a run can no longer
be booked with gross/tax derived from the old salary (skeptic R1)
- Enforce SALARY_OVERRIDE_MAX (10 MSEK) in the shared service and the
v1 body schema: closes the unbounded/1e307-overflow path that wrote
Infinity -> NULL -> 500 (skeptic R2)
- Promote gnubok_set_run_salary to the default catalog: a search-only
WRITE is uncallable on Claude.ai (update_customer lesson) while three
surfaces pointed agents at it; payload ceiling bumped 63.8K -> 64.4K
with a ledger entry, read-demotion left as its own change (skeptic R3)
- Granskning label type_set_run_salary in vocabulary.ts + sv/en (R4)
- Display-line refresh is fire-and-forget again (write already
committed; matches pre-refactor route behavior) and DB error details
carry the SQLSTATE code for Swedish error mapping
- v1 risk metadata aligned to 'medium'; NOT_DRAFT message now covers
salary edits, not just roster changes
- npm run apiskill:generate committed (CI apiskill:check failure)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MP37pE3zk667nP6S766iJG
* chore(migrations): rename set_run_salary pair past main's newest versions
origin/main gained 20260828120000 and 20260828154800 after this branch
staged 20260828110000/1; out-of-order versions are skipped at merge, so
the pair moves to 20260828160000/1 (byte-identical SQL, reference in the
VALIDATE header updated).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MP37pE3zk667nP6S766iJG
* chore: retrigger Supabase preview after migration-version repair
The preview branch tracked 20260828110000/1 before the rename to
20260828160000/1; the orphan rows are deleted from the preview branch's
schema_migrations (preview only, prod never saw those versions) and this
empty commit re-runs the tasks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MP37pE3zk667nP6S766iJG
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
a4ceaafa4f
commit
e8aa0670ca
@@ -286,12 +286,21 @@ describe('tools/list payload size guard', () => {
|
||||
// the staging envelope; the description is already 152 chars.
|
||||
// Measured 63 761 on the accounted projection after merging #1993
|
||||
// (line_type and revenue_account declared on the create item schema).
|
||||
// * 63.8K to 64.4K with gnubok_set_run_salary in the default catalog
|
||||
// (variable owner pay, the payroll_month flagship flow). Same reason
|
||||
// as update_customer: a search-only WRITE is uncallable on Claude.ai,
|
||||
// and both update_payslip_line's description and the payroll_month
|
||||
// loadout point agents at this tool. Measured 64 315 on the accounted
|
||||
// projection. This bump skips the "demote a read first" rule below
|
||||
// deliberately: picking which read to demote needs prod usage data
|
||||
// (MCP usage profile), not a guess inside a payroll PR: do that
|
||||
// demotion as its own change and ratchet this ceiling back down.
|
||||
// Long-term answer to growth is no longer a ceiling bump. gnubok_call_tool
|
||||
// makes `catalogVisibility: 'search'` usable for READ tools on hosts that
|
||||
// can only invoke what tools/list showed them, which is the constraint that
|
||||
// forced gnubok_reconcile_match back into the default catalog on
|
||||
// 2026-08-26. Demote a read to search-only before proposing a bump.
|
||||
expect(approxTokens).toBeLessThan(63_800)
|
||||
expect(approxTokens).toBeLessThan(64_400)
|
||||
})
|
||||
|
||||
it('keeps the accounted_* namespace as the measured worst case', () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { tools } from '../server'
|
||||
import { decryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
|
||||
const updatePayslipLine = tools.find((t) => t.name === 'gnubok_update_payslip_line')!
|
||||
const setRunSalary = tools.find((t) => t.name === 'gnubok_set_run_salary')!
|
||||
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')!
|
||||
@@ -136,6 +137,66 @@ describe('gnubok_update_payslip_line', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_set_run_salary', () => {
|
||||
const SRE_ROW = {
|
||||
id: 'sre-1',
|
||||
employee_id: 'emp-1',
|
||||
salary_type: 'monthly',
|
||||
employment_degree: 100,
|
||||
monthly_salary: 30000,
|
||||
}
|
||||
|
||||
it('stages with old/new salary preview and a recalculate next-hint', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'run-1', status: 'draft' } }) // service draft gate (dry-run preflight)
|
||||
enqueue({ data: SRE_ROW }) // service sre lookup
|
||||
enqueue({ data: { payment_date: '2026-03-25', period_year: 2026, period_month: 3 } }) // run for period check
|
||||
enqueue({ data: { first_name: 'Anna', last_name: 'Andersson' } }) // name for preview
|
||||
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 setRunSalary.execute(
|
||||
{ salary_run_id: 'run-1', employee_id: 'emp-1', monthly_salary: 45000 },
|
||||
'company-1', 'user-1', supabase as never, { type: 'user' },
|
||||
)) as {
|
||||
staged: boolean
|
||||
risk_level: string
|
||||
preview: Record<string, unknown>
|
||||
next?: { tool: string }
|
||||
}
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
expect(result.risk_level).toBe('medium')
|
||||
expect(result.preview.previous_monthly_salary).toBe(30000)
|
||||
expect(result.preview.new_monthly_salary).toBe(45000)
|
||||
expect(result.preview.employee_name).toBe('Anna Andersson')
|
||||
expect(result.next?.tool).toBe('gnubok_calculate_salary_run')
|
||||
})
|
||||
|
||||
it('throws when the run has advanced past draft (preflight)', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'run-1', status: 'review' } })
|
||||
|
||||
await expect(
|
||||
setRunSalary.execute(
|
||||
{ salary_run_id: 'run-1', employee_id: 'emp-1', monthly_salary: 45000 },
|
||||
'company-1', 'user-1', supabase as never, { type: 'user' },
|
||||
),
|
||||
).rejects.toThrow(/SALARY_RUN_EMPLOYEES_NOT_DRAFT/)
|
||||
})
|
||||
|
||||
it('rejects a negative salary', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
await expect(
|
||||
setRunSalary.execute(
|
||||
{ salary_run_id: 'run-1', employee_id: 'emp-1', monthly_salary: -100 },
|
||||
'company-1', 'user-1', supabase as never, { type: 'user' },
|
||||
),
|
||||
).rejects.toThrow(/monthly_salary/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_register_absence', () => {
|
||||
it('stages with day-count preview and dateForPeriodCheck', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
|
||||
@@ -134,6 +134,7 @@ export const RECOMMENDED_WORKFLOW_LOADOUTS: readonly WorkflowLoadout[] = [
|
||||
tools: [
|
||||
'gnubok_list_employees',
|
||||
'gnubok_create_salary_run',
|
||||
'gnubok_set_run_salary',
|
||||
'gnubok_calculate_salary_run',
|
||||
'gnubok_get_salary_run',
|
||||
'gnubok_book_salary_run',
|
||||
|
||||
@@ -14075,7 +14075,7 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_update_payslip_line',
|
||||
title: 'Update Payslip Line',
|
||||
description: 'Stage an edit to one payslip line (amount, description, quantity, unit price) in a DRAFT salary run. Commit via gnubok_approve_pending_operation, then re-run gnubok_calculate_salary_run: line edits never recompute tax by themselves.',
|
||||
description: 'Stage an edit to one payslip line (amount, description, quantity, unit price) in a DRAFT salary run. Commit via gnubok_approve_pending_operation, then re-run gnubok_calculate_salary_run. NOTE: recalc rebuilds base salary lines: use gnubok_set_run_salary for this month\'s pay.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -14152,6 +14152,91 @@ export const tools: McpTool[] = [
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'gnubok_set_run_salary',
|
||||
title: 'Set This Month\'s Salary',
|
||||
description: 'Stage this run\'s base salary for one employee in a DRAFT salary run (per-run value; the employee\'s fixed salary is untouched). For variable pay, e.g. owner salary; 0 = nollkörning. Commit via gnubok_approve_pending_operation, then gnubok_calculate_salary_run.',
|
||||
// Default catalog: gnubok_call_tool only bridges READ tools, so a
|
||||
// search-only WRITE is uncallable on Claude.ai (the update_customer
|
||||
// lesson, #1876/#1986), and update_payslip_line's description plus the
|
||||
// payroll_month loadout point agents here. Budget accounted for in
|
||||
// payload-size.bench.test.ts's ledger.
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
salary_run_id: { type: 'string', description: 'UUID of the salary run (must be draft)' },
|
||||
employee_id: { type: 'string', description: 'UUID of the employee on the run' },
|
||||
monthly_salary: { type: 'number', description: 'This month\'s gross base salary (SEK, 0 to 10 000 000; 0 books a nollkörning). Monthly employees only: hourly gross derives from hours worked.' },
|
||||
},
|
||||
required: ['salary_run_id', 'employee_id', 'monthly_salary'],
|
||||
},
|
||||
outputSchema: STAGED_OPERATION_SCHEMA,
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
||||
async execute(args, companyId, userId, supabase, actor) {
|
||||
const { salary_run_id, employee_id, monthly_salary } = args as {
|
||||
salary_run_id: string; employee_id: string; monthly_salary: number
|
||||
}
|
||||
if (!salary_run_id || !employee_id) {
|
||||
throw new Error('salary_run_id and employee_id are required')
|
||||
}
|
||||
if (typeof monthly_salary !== 'number' || !Number.isFinite(monthly_salary) || monthly_salary < 0) {
|
||||
throw new Error('monthly_salary must be a number >= 0')
|
||||
}
|
||||
|
||||
// Preflight via the shared service in dry-run: verifies draft status and
|
||||
// that the employee is on the run, and yields old/new salary for the
|
||||
// preview. No writes here: the commit path re-runs the service for real.
|
||||
const { setRunEmployeeSalary } = await import('@/lib/salary/run-employees')
|
||||
const preflight = await setRunEmployeeSalary(supabase, {
|
||||
companyId,
|
||||
salaryRunId: salary_run_id,
|
||||
employeeId: employee_id,
|
||||
monthlySalary: monthly_salary,
|
||||
dryRun: true,
|
||||
})
|
||||
if (!preflight.ok) {
|
||||
throw new Error(`Cannot set run salary: ${preflight.code}`)
|
||||
}
|
||||
|
||||
const [{ data: run }, { data: emp }] = await Promise.all([
|
||||
supabase
|
||||
.from('salary_runs')
|
||||
.select('payment_date, period_year, period_month')
|
||||
.eq('id', salary_run_id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle(),
|
||||
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, 'set_run_salary',
|
||||
`Sätt månadens lön: ${employeeName} ${preflight.data.previous_monthly_salary} kr → ${preflight.data.monthly_salary} kr`,
|
||||
{ salary_run_id, employee_id, monthly_salary: preflight.data.monthly_salary },
|
||||
{
|
||||
salary_run_id,
|
||||
salary_run_employee_id: preflight.data.salary_run_employee_id,
|
||||
employee_id,
|
||||
employee_name: employeeName,
|
||||
previous_monthly_salary: preflight.data.previous_monthly_salary,
|
||||
new_monthly_salary: preflight.data.monthly_salary,
|
||||
salary_type: preflight.data.salary_type,
|
||||
},
|
||||
actor,
|
||||
{
|
||||
description: 'After approval, recalculate the run so gross, tax and totals reflect this month\'s salary.',
|
||||
tool: 'gnubok_calculate_salary_run',
|
||||
},
|
||||
run?.payment_date ? { dateForPeriodCheck: run.payment_date as string } : {},
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'gnubok_register_absence',
|
||||
title: 'Register Absence (Frånvaro)',
|
||||
|
||||
@@ -40,7 +40,11 @@ If anything is missing, the user fixes it in the web UI before running payroll.
|
||||
- Returns the run ID + employee count
|
||||
- Idempotent on \`(company_id, period_year, period_month)\`: re-calling errors with "Salary run already exists for this period"
|
||||
|
||||
### Step 3: Add OB-tillägg, traktamente, förmåner (if any)
|
||||
### Step 3: Set this month's salary (if it differs from the fixed pay)
|
||||
|
||||
\`gnubok_set_run_salary({ salary_run_id, employee_id, monthly_salary })\`: sets THIS run's base salary for one employee (draft only; 0 = nollkörning). This is the per-run value the engine reads at calculation: the employee's fixed salary is untouched. Common for owners taking salary by need and capacity. Do NOT edit the \`monthly_salary\` payslip line instead: recalculation rebuilds base salary lines from the per-run value.
|
||||
|
||||
### Step 3b: Add OB-tillägg, traktamente, förmåner (if any)
|
||||
|
||||
Variable lines (overtime, weekend supplement, milage, traktamente, förmåner) are added in the web UI per-employee. There's no MCP tool yet for these: guide the user there.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user