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:
Mattsson
2026-08-28 18:14:31 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent a4ceaafa4f
commit e8aa0670ca
28 changed files with 1090 additions and 59 deletions
@@ -129,11 +129,12 @@ describe('PATCH /api/salary/runs/[id]/employees/[employeeId]: monthly salary edi
it('updates the per-run monthly salary while the run is a draft', async () => {
const { enqueueMany } = authed()
enqueueMany([
{ data: { id: 'run-1', status: 'draft' } }, // salary_runs lookup
{ data: { id: 'run-1', status: 'draft' } }, // service: salary_runs draft gate
{
data: { id: 'sre-1', employment_degree: 100, salary_type: 'monthly', monthly_salary: 30000 },
}, // salary_run_employees update
{ data: null }, // salary_line_items Grundlön refresh
data: { id: 'sre-1', employee_id: 'emp-1', employment_degree: 100, salary_type: 'monthly', monthly_salary: 25000 },
}, // service: salary_run_employees select
{ data: null }, // service: salary_run_employees update
{ data: null }, // service: salary_line_items Grundlön refresh
])
const request = createMockRequest('/api/salary/runs/run-1/employees/emp-1', {
@@ -154,7 +155,8 @@ describe('PATCH /api/salary/runs/[id]/employees/[employeeId]: monthly salary edi
const { enqueueMany } = authed()
enqueueMany([
{ data: { id: 'run-1', status: 'draft' } },
{ data: { id: 'sre-1', employment_degree: 100, salary_type: 'monthly', monthly_salary: 0 } },
{ data: { id: 'sre-1', employee_id: 'emp-1', employment_degree: 100, salary_type: 'monthly', monthly_salary: 25000 } },
{ data: null },
{ data: null },
])
@@ -4,7 +4,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { SalaryEmployeeOverrideSchema } from '@/lib/api/schemas'
import { maskEmployeeForResponse } from '@/lib/salary/personnummer'
import { removeEmployeeFromRun } from '@/lib/salary/run-employees'
import { removeEmployeeFromRun, setRunEmployeeSalary } from '@/lib/salary/run-employees'
import { getErrorEntry } from '@/lib/errors/structured-errors'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
@@ -91,6 +91,43 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string; employeeId
)
}
// ── Draft-stage edit of this month's base salary ──
// The shared service owns the run lookup + draft gate for this branch.
if (wantsSalaryEdit) {
const result = await setRunEmployeeSalary(supabase, {
companyId,
salaryRunId: id,
employeeId,
monthlySalary: parsed.data.monthly_salary as number,
})
if (!result.ok) {
if (result.code === 'SALARY_RUN_NOT_FOUND') {
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
}
if (result.code === 'SALARY_RUN_EMPLOYEES_NOT_DRAFT') {
return NextResponse.json(
{ error: 'Månadslönen kan bara redigeras medan lönekörningen är ett utkast.' },
{ status: 400 },
)
}
if (result.code === 'SALARY_RUN_EMPLOYEE_NOT_FOUND') {
return NextResponse.json({ error: 'Anställd hittades inte i lönekörningen' }, { status: 404 })
}
return NextResponse.json({ error: getUserErrorMessage(result.details) }, { status: 400 })
}
// Same response shape as before the lift into lib/salary/run-employees.
return NextResponse.json({
data: {
id: result.data.salary_run_employee_id,
employment_degree: result.data.employment_degree,
salary_type: result.data.salary_type,
monthly_salary: result.data.monthly_salary,
},
})
}
const { data: run } = await supabase
.from('salary_runs')
.select('id, status')
@@ -100,46 +137,6 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string; employeeId
if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
// ── Draft-stage edit of this month's base salary ──
if (wantsSalaryEdit) {
if (run.status !== 'draft') {
return NextResponse.json(
{ error: 'Månadslönen kan bara redigeras medan lönekörningen är ett utkast.' },
{ status: 400 },
)
}
const monthly = Math.round((parsed.data.monthly_salary as number) * 100) / 100
const { data: sre, error: sreErr } = await supabase
.from('salary_run_employees')
.update({ monthly_salary: monthly })
.eq('salary_run_id', id)
.eq('employee_id', employeeId)
.eq('company_id', companyId)
.select('id, employment_degree, salary_type, monthly_salary')
.maybeSingle()
if (sreErr) return NextResponse.json({ error: getUserErrorMessage(sreErr) }, { status: 400 })
if (!sre) {
return NextResponse.json({ error: 'Anställd hittades inte i lönekörningen' }, { status: 404 })
}
// Keep the displayed 'Grundlön' line consistent with the new salary. This is
// display-only: the engine recomputes baseSalary from monthly_salary at
// calc time, but it avoids a stale row before the user clicks Beräkna.
if (sre.salary_type === 'monthly') {
const baseAmount = Math.round(monthly * (sre.employment_degree / 100) * 100) / 100
await supabase
.from('salary_line_items')
.update({ amount: baseAmount })
.eq('salary_run_employee_id', sre.id)
.eq('company_id', companyId)
.eq('item_type', 'monthly_salary')
}
return NextResponse.json({ data: sre })
}
// ── Review-stage override of tax/avgifter ──
if (run.status !== 'review') {
return NextResponse.json(