* 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>
381 lines
13 KiB
TypeScript
381 lines
13 KiB
TypeScript
/**
|
|
* Unit tests for lib/salary/run-employees.ts (payroll gap-closure 1.3).
|
|
*
|
|
* Attach/remove employees on a draft salary run: draft gate, active-employee
|
|
* check, duplicate 409, snapshot semantics, base-line seeding with roundOre.
|
|
*/
|
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { createQueuedMockSupabase } from '@/tests/helpers'
|
|
import { addEmployeeToRun, removeEmployeeFromRun, setRunEmployeeSalary } from '@/lib/salary/run-employees'
|
|
|
|
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
|
const RUN_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
|
|
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
|
const SRE_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'
|
|
|
|
const MONTHLY_EMPLOYEE = {
|
|
id: EMPLOYEE_ID,
|
|
employment_degree: 80,
|
|
monthly_salary: 35000,
|
|
hourly_rate: null,
|
|
salary_type: 'monthly',
|
|
employment_type: 'employee',
|
|
tax_table_number: 33,
|
|
tax_column: 1,
|
|
}
|
|
|
|
let mock: ReturnType<typeof createQueuedMockSupabase>
|
|
let supabase: SupabaseClient
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mock = createQueuedMockSupabase()
|
|
supabase = mock.supabase as unknown as SupabaseClient
|
|
})
|
|
|
|
describe('addEmployeeToRun', () => {
|
|
it('returns SALARY_RUN_NOT_FOUND for a missing run', async () => {
|
|
mock.enqueue({ data: null })
|
|
const result = await addEmployeeToRun(supabase, {
|
|
companyId: COMPANY_ID,
|
|
salaryRunId: RUN_ID,
|
|
employeeId: EMPLOYEE_ID,
|
|
})
|
|
expect(result).toEqual({ ok: false, code: 'SALARY_RUN_NOT_FOUND' })
|
|
})
|
|
|
|
it('returns SALARY_RUN_EMPLOYEES_NOT_DRAFT once the run has advanced', async () => {
|
|
mock.enqueue({ data: { id: RUN_ID, status: 'approved' } })
|
|
const result = await addEmployeeToRun(supabase, {
|
|
companyId: COMPANY_ID,
|
|
salaryRunId: RUN_ID,
|
|
employeeId: EMPLOYEE_ID,
|
|
})
|
|
expect(result.ok).toBe(false)
|
|
if (!result.ok) {
|
|
expect(result.code).toBe('SALARY_RUN_EMPLOYEES_NOT_DRAFT')
|
|
expect(result.details).toEqual({ current_status: 'approved' })
|
|
}
|
|
})
|
|
|
|
it('returns EMPLOYEE_NOT_FOUND for an inactive or unknown employee', async () => {
|
|
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
|
|
mock.enqueue({ data: null })
|
|
const result = await addEmployeeToRun(supabase, {
|
|
companyId: COMPANY_ID,
|
|
salaryRunId: RUN_ID,
|
|
employeeId: EMPLOYEE_ID,
|
|
})
|
|
expect(result).toEqual({ ok: false, code: 'EMPLOYEE_NOT_FOUND' })
|
|
})
|
|
|
|
it('returns SALARY_RUN_EMPLOYEE_DUPLICATE when already attached', async () => {
|
|
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
|
|
mock.enqueue({ data: MONTHLY_EMPLOYEE })
|
|
mock.enqueue({ data: { id: SRE_ID } })
|
|
const result = await addEmployeeToRun(supabase, {
|
|
companyId: COMPANY_ID,
|
|
salaryRunId: RUN_ID,
|
|
employeeId: EMPLOYEE_ID,
|
|
})
|
|
expect(result.ok).toBe(false)
|
|
if (!result.ok) expect(result.code).toBe('SALARY_RUN_EMPLOYEE_DUPLICATE')
|
|
})
|
|
|
|
it('attaches with a pay snapshot and seeds the base line (happy path)', async () => {
|
|
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
|
|
mock.enqueue({ data: MONTHLY_EMPLOYEE })
|
|
mock.enqueue({ data: null }) // duplicate check: none
|
|
mock.enqueue({
|
|
data: {
|
|
id: SRE_ID,
|
|
salary_run_id: RUN_ID,
|
|
employee_id: EMPLOYEE_ID,
|
|
company_id: COMPANY_ID,
|
|
employment_degree: 80,
|
|
monthly_salary: 35000,
|
|
salary_type: 'monthly',
|
|
hours_worked: null,
|
|
tax_table_number: 33,
|
|
tax_column: 1,
|
|
created_at: '2026-05-01T08:00:00Z',
|
|
updated_at: '2026-05-01T08:00:00Z',
|
|
},
|
|
})
|
|
mock.enqueue({ data: null }) // line insert
|
|
|
|
const result = await addEmployeeToRun(supabase, {
|
|
companyId: COMPANY_ID,
|
|
salaryRunId: RUN_ID,
|
|
employeeId: EMPLOYEE_ID,
|
|
})
|
|
|
|
expect(result.ok).toBe(true)
|
|
const fromCalls = (mock.supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
|
|
expect(fromCalls).toEqual([
|
|
'salary_runs',
|
|
'employees',
|
|
'salary_run_employees',
|
|
'salary_run_employees',
|
|
'salary_line_items',
|
|
])
|
|
})
|
|
|
|
it('dry-run returns the would-be snapshot without inserting', async () => {
|
|
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
|
|
mock.enqueue({ data: MONTHLY_EMPLOYEE })
|
|
mock.enqueue({ data: null }) // duplicate check: none
|
|
|
|
const result = await addEmployeeToRun(supabase, {
|
|
companyId: COMPANY_ID,
|
|
salaryRunId: RUN_ID,
|
|
employeeId: EMPLOYEE_ID,
|
|
dryRun: true,
|
|
})
|
|
|
|
expect(result.ok).toBe(true)
|
|
if (result.ok) {
|
|
expect(result.data.id).toBeNull()
|
|
expect(result.data.employee_id).toBe(EMPLOYEE_ID)
|
|
expect(result.data.employment_degree).toBe(80)
|
|
}
|
|
const fromCalls = (mock.supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
|
|
// Gate, employee lookup, duplicate check: no inserts.
|
|
expect(fromCalls).toEqual(['salary_runs', 'employees', 'salary_run_employees'])
|
|
})
|
|
|
|
it('maps a 23505 insert race to SALARY_RUN_EMPLOYEE_DUPLICATE', async () => {
|
|
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
|
|
mock.enqueue({ data: MONTHLY_EMPLOYEE })
|
|
mock.enqueue({ data: null }) // duplicate check passes...
|
|
mock.enqueue({ data: null, error: { code: '23505', message: 'duplicate key' } }) // ...but insert races
|
|
|
|
const result = await addEmployeeToRun(supabase, {
|
|
companyId: COMPANY_ID,
|
|
salaryRunId: RUN_ID,
|
|
employeeId: EMPLOYEE_ID,
|
|
})
|
|
expect(result.ok).toBe(false)
|
|
if (!result.ok) expect(result.code).toBe('SALARY_RUN_EMPLOYEE_DUPLICATE')
|
|
})
|
|
})
|
|
|
|
describe('removeEmployeeFromRun', () => {
|
|
it('removes an attached employee from a draft run', async () => {
|
|
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
|
|
mock.enqueue({ data: { id: SRE_ID } })
|
|
mock.enqueue({ data: null }) // delete
|
|
|
|
const result = await removeEmployeeFromRun(supabase, {
|
|
companyId: COMPANY_ID,
|
|
salaryRunId: RUN_ID,
|
|
employeeId: EMPLOYEE_ID,
|
|
})
|
|
|
|
expect(result.ok).toBe(true)
|
|
if (result.ok) expect(result.data.deleted).toBe(true)
|
|
})
|
|
|
|
it('returns SALARY_RUN_EMPLOYEE_NOT_FOUND when not attached', async () => {
|
|
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
|
|
mock.enqueue({ data: null })
|
|
|
|
const result = await removeEmployeeFromRun(supabase, {
|
|
companyId: COMPANY_ID,
|
|
salaryRunId: RUN_ID,
|
|
employeeId: EMPLOYEE_ID,
|
|
})
|
|
expect(result).toEqual({ ok: false, code: 'SALARY_RUN_EMPLOYEE_NOT_FOUND' })
|
|
})
|
|
|
|
it('gates on draft status', async () => {
|
|
mock.enqueue({ data: { id: RUN_ID, status: 'booked' } })
|
|
const result = await removeEmployeeFromRun(supabase, {
|
|
companyId: COMPANY_ID,
|
|
salaryRunId: RUN_ID,
|
|
employeeId: EMPLOYEE_ID,
|
|
})
|
|
expect(result.ok).toBe(false)
|
|
if (!result.ok) expect(result.code).toBe('SALARY_RUN_EMPLOYEES_NOT_DRAFT')
|
|
})
|
|
|
|
it('dry-run verifies without deleting', async () => {
|
|
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
|
|
mock.enqueue({ data: { id: SRE_ID } })
|
|
|
|
const result = await removeEmployeeFromRun(supabase, {
|
|
companyId: COMPANY_ID,
|
|
salaryRunId: RUN_ID,
|
|
employeeId: EMPLOYEE_ID,
|
|
dryRun: true,
|
|
})
|
|
|
|
expect(result.ok).toBe(true)
|
|
const fromCalls = (mock.supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
|
|
expect(fromCalls).toEqual(['salary_runs', 'salary_run_employees'])
|
|
})
|
|
})
|
|
|
|
describe('setRunEmployeeSalary', () => {
|
|
const SRE_ROW = {
|
|
id: SRE_ID,
|
|
employee_id: EMPLOYEE_ID,
|
|
salary_type: 'monthly',
|
|
employment_degree: 80,
|
|
monthly_salary: 35000,
|
|
}
|
|
|
|
it('rejects a negative salary without touching the DB', async () => {
|
|
const result = await setRunEmployeeSalary(supabase, {
|
|
companyId: COMPANY_ID,
|
|
salaryRunId: RUN_ID,
|
|
employeeId: EMPLOYEE_ID,
|
|
monthlySalary: -1,
|
|
})
|
|
expect(result.ok).toBe(false)
|
|
if (!result.ok) expect(result.code).toBe('VALIDATION_ERROR')
|
|
expect((mock.supabase.from as ReturnType<typeof vi.fn>).mock.calls.length).toBe(0)
|
|
})
|
|
|
|
it('rejects a salary above SALARY_OVERRIDE_MAX without touching the DB', async () => {
|
|
const result = await setRunEmployeeSalary(supabase, {
|
|
companyId: COMPANY_ID,
|
|
salaryRunId: RUN_ID,
|
|
employeeId: EMPLOYEE_ID,
|
|
monthlySalary: 10_000_001,
|
|
})
|
|
expect(result.ok).toBe(false)
|
|
if (!result.ok) expect(result.code).toBe('VALIDATION_ERROR')
|
|
expect((mock.supabase.from as ReturnType<typeof vi.fn>).mock.calls.length).toBe(0)
|
|
})
|
|
|
|
it('rejects an overflow-scale salary (1e307) instead of writing Infinity', async () => {
|
|
const result = await setRunEmployeeSalary(supabase, {
|
|
companyId: COMPANY_ID,
|
|
salaryRunId: RUN_ID,
|
|
employeeId: EMPLOYEE_ID,
|
|
monthlySalary: 1e307,
|
|
})
|
|
expect(result.ok).toBe(false)
|
|
if (!result.ok) expect(result.code).toBe('VALIDATION_ERROR')
|
|
})
|
|
|
|
it('returns SALARY_RUN_NOT_FOUND for a missing run', async () => {
|
|
mock.enqueue({ data: null })
|
|
const result = await setRunEmployeeSalary(supabase, {
|
|
companyId: COMPANY_ID,
|
|
salaryRunId: RUN_ID,
|
|
employeeId: EMPLOYEE_ID,
|
|
monthlySalary: 45000,
|
|
})
|
|
expect(result).toEqual({ ok: false, code: 'SALARY_RUN_NOT_FOUND' })
|
|
})
|
|
|
|
it('gates on draft status', async () => {
|
|
mock.enqueue({ data: { id: RUN_ID, status: 'review' } })
|
|
const result = await setRunEmployeeSalary(supabase, {
|
|
companyId: COMPANY_ID,
|
|
salaryRunId: RUN_ID,
|
|
employeeId: EMPLOYEE_ID,
|
|
monthlySalary: 45000,
|
|
})
|
|
expect(result.ok).toBe(false)
|
|
if (!result.ok) expect(result.code).toBe('SALARY_RUN_EMPLOYEES_NOT_DRAFT')
|
|
})
|
|
|
|
it('returns SALARY_RUN_EMPLOYEE_NOT_FOUND when the employee is not on the run', async () => {
|
|
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
|
|
mock.enqueue({ data: null })
|
|
const result = await setRunEmployeeSalary(supabase, {
|
|
companyId: COMPANY_ID,
|
|
salaryRunId: RUN_ID,
|
|
employeeId: EMPLOYEE_ID,
|
|
monthlySalary: 45000,
|
|
})
|
|
expect(result).toEqual({ ok: false, code: 'SALARY_RUN_EMPLOYEE_NOT_FOUND' })
|
|
})
|
|
|
|
it('updates the per-run salary and refreshes the display line (happy path)', async () => {
|
|
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
|
|
mock.enqueue({ data: SRE_ROW })
|
|
mock.enqueue({ data: null }) // sre update
|
|
mock.enqueue({ data: null }) // line update
|
|
|
|
const result = await setRunEmployeeSalary(supabase, {
|
|
companyId: COMPANY_ID,
|
|
salaryRunId: RUN_ID,
|
|
employeeId: EMPLOYEE_ID,
|
|
monthlySalary: 45000.005,
|
|
})
|
|
|
|
expect(result.ok).toBe(true)
|
|
if (result.ok) {
|
|
expect(result.data.previous_monthly_salary).toBe(35000)
|
|
expect(result.data.monthly_salary).toBe(45000.01) // roundOre applied
|
|
expect(result.data.salary_run_employee_id).toBe(SRE_ID)
|
|
}
|
|
const fromCalls = (mock.supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
|
|
expect(fromCalls).toEqual([
|
|
'salary_runs',
|
|
'salary_run_employees',
|
|
'salary_run_employees',
|
|
'salary_line_items',
|
|
])
|
|
})
|
|
|
|
it('accepts 0 as an intentional nollkörning', async () => {
|
|
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
|
|
mock.enqueue({ data: SRE_ROW })
|
|
mock.enqueue({ data: null })
|
|
mock.enqueue({ data: null })
|
|
|
|
const result = await setRunEmployeeSalary(supabase, {
|
|
companyId: COMPANY_ID,
|
|
salaryRunId: RUN_ID,
|
|
employeeId: EMPLOYEE_ID,
|
|
monthlySalary: 0,
|
|
})
|
|
expect(result.ok).toBe(true)
|
|
if (result.ok) expect(result.data.monthly_salary).toBe(0)
|
|
})
|
|
|
|
it('skips the display-line refresh for hourly employees', async () => {
|
|
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
|
|
mock.enqueue({ data: { ...SRE_ROW, salary_type: 'hourly' } })
|
|
mock.enqueue({ data: null }) // sre update only
|
|
|
|
const result = await setRunEmployeeSalary(supabase, {
|
|
companyId: COMPANY_ID,
|
|
salaryRunId: RUN_ID,
|
|
employeeId: EMPLOYEE_ID,
|
|
monthlySalary: 45000,
|
|
})
|
|
expect(result.ok).toBe(true)
|
|
const fromCalls = (mock.supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
|
|
expect(fromCalls).toEqual(['salary_runs', 'salary_run_employees', 'salary_run_employees'])
|
|
})
|
|
|
|
it('dry-run resolves old and new salary without writing', async () => {
|
|
mock.enqueue({ data: { id: RUN_ID, status: 'draft' } })
|
|
mock.enqueue({ data: SRE_ROW })
|
|
|
|
const result = await setRunEmployeeSalary(supabase, {
|
|
companyId: COMPANY_ID,
|
|
salaryRunId: RUN_ID,
|
|
employeeId: EMPLOYEE_ID,
|
|
monthlySalary: 45000,
|
|
dryRun: true,
|
|
})
|
|
|
|
expect(result.ok).toBe(true)
|
|
if (result.ok) {
|
|
expect(result.data.previous_monthly_salary).toBe(35000)
|
|
expect(result.data.monthly_salary).toBe(45000)
|
|
}
|
|
const fromCalls = (mock.supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
|
|
expect(fromCalls).toEqual(['salary_runs', 'salary_run_employees'])
|
|
})
|
|
})
|