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
+1
-1
@@ -3529,7 +3529,7 @@ export const UpdateShiftPremiumRuleSchema = z
|
||||
// Upper bound on per-employee override values. 10 MSEK is well above any
|
||||
// plausible single-period gross/tax/avgifter figure for a salary run and
|
||||
// catches typos (e.g. an extra zero) before they reach the ledger or AGI.
|
||||
const SALARY_OVERRIDE_MAX = 10_000_000
|
||||
export const SALARY_OVERRIDE_MAX = 10_000_000
|
||||
|
||||
export const SalaryEmployeeOverrideSchema = z
|
||||
.object({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `138`;
|
||||
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `139`;
|
||||
|
||||
exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-keys 1`] = `
|
||||
[
|
||||
@@ -76,6 +76,7 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
|
||||
"PATCH /api/v1/companies/:companyId/employees/:id",
|
||||
"PATCH /api/v1/companies/:companyId/invoices/:id",
|
||||
"PATCH /api/v1/companies/:companyId/salary-runs/:id",
|
||||
"PATCH /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId",
|
||||
"PATCH /api/v1/companies/:companyId/salary-runs/:id/lines/:lineId",
|
||||
"PATCH /api/v1/companies/:companyId/settings",
|
||||
"PATCH /api/v1/companies/:companyId/supplier-invoices/:id",
|
||||
|
||||
@@ -318,6 +318,7 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
|
||||
gnubok_get_payslip: 'payroll:read',
|
||||
gnubok_list_absence: 'payroll:read',
|
||||
gnubok_update_payslip_line: 'payroll:write',
|
||||
gnubok_set_run_salary: 'payroll:write',
|
||||
gnubok_register_absence: 'payroll:write',
|
||||
gnubok_delete_absence: 'payroll:write',
|
||||
gnubok_create_employee: 'payroll:write',
|
||||
|
||||
@@ -216,6 +216,9 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
|
||||
// detail endpoint is the identity drill-in.
|
||||
'GET /api/v1/companies/:companyId/salary-runs/:id/employees': 'payroll:read',
|
||||
'GET /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId': 'payroll:read',
|
||||
// Per-run base salary edit (variable owner pay): draft-only write of
|
||||
// salary_run_employees.monthly_salary; the employee master is untouched.
|
||||
'PATCH /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId': 'payroll:write',
|
||||
'GET /api/v1/companies/:companyId/salary-runs/:id/payslips/:employeeId/pdf': 'payroll:read',
|
||||
// Payroll gap-closure 1.2: payslip line writes (draft runs only).
|
||||
'POST /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId/lines': 'payroll:write',
|
||||
|
||||
@@ -2747,8 +2747,8 @@ const SALARY: Record<string, StructuredErrorEntry> = {
|
||||
},
|
||||
SALARY_RUN_EMPLOYEES_NOT_DRAFT: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Anställda kan bara läggas till eller tas bort medan lönekörningen är ett utkast.',
|
||||
message_en: 'Employees can only be added or removed while the salary run is a draft.',
|
||||
message_sv: 'Lönekörningen måste vara ett utkast för att ändra anställda eller månadens lön.',
|
||||
message_en: 'The salary run must be a draft to change its employees or this month\'s salary.',
|
||||
},
|
||||
ABSENCE_RANGE_TOO_LARGE: {
|
||||
httpStatus: 400,
|
||||
|
||||
@@ -131,6 +131,70 @@ describe('commitPendingOperation: update_payslip_line', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('commitPendingOperation: set_run_salary', () => {
|
||||
const SRE_ROW = {
|
||||
id: 'sre-1',
|
||||
employee_id: 'emp-1',
|
||||
salary_type: 'monthly',
|
||||
employment_degree: 100,
|
||||
monthly_salary: 30000,
|
||||
}
|
||||
|
||||
it('writes the per-run salary through the shared service (happy path)', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: { id: 'run-1', status: 'draft' } }) // service draft gate
|
||||
enqueue({ data: SRE_ROW }) // service sre lookup
|
||||
enqueue({ data: null }) // sre update
|
||||
enqueue({ data: null }) // display-line update
|
||||
enqueue({ data: null, error: null }) // finalize
|
||||
|
||||
const op = makePendingOp({
|
||||
operation_type: 'set_run_salary',
|
||||
params: { salary_run_id: 'run-1', employee_id: 'emp-1', monthly_salary: 45000 },
|
||||
})
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('committed')
|
||||
expect(result.data).toMatchObject({
|
||||
salary_run_id: 'run-1',
|
||||
employee_id: 'emp-1',
|
||||
previous_monthly_salary: 30000,
|
||||
monthly_salary: 45000,
|
||||
})
|
||||
})
|
||||
|
||||
it('fails cleanly when the run advanced between staging and approval', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: { id: 'run-1', status: 'review' } }) // draft gate trips
|
||||
enqueue({ data: null, error: null }) // finalize (failed)
|
||||
|
||||
const op = makePendingOp({
|
||||
operation_type: 'set_run_salary',
|
||||
params: { salary_run_id: 'run-1', employee_id: 'emp-1', monthly_salary: 45000 },
|
||||
})
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
})
|
||||
|
||||
it('rejects missing params with 400', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: null, error: null }) // finalize (failed)
|
||||
|
||||
const op = makePendingOp({
|
||||
operation_type: 'set_run_salary',
|
||||
params: { salary_run_id: 'run-1', employee_id: 'emp-1' },
|
||||
})
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.http_status).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('commitPendingOperation: register_absence', () => {
|
||||
it('upserts the expanded range through the shared service (happy path)', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
|
||||
@@ -5465,6 +5465,51 @@ async function commitUpdatePayslipLine(
|
||||
}
|
||||
}
|
||||
|
||||
async function commitSetRunSalary(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<ExecutorResult> {
|
||||
const salaryRunId = params.salary_run_id as string
|
||||
const employeeId = params.employee_id as string
|
||||
const monthlySalary = params.monthly_salary as number
|
||||
if (!salaryRunId || !employeeId || typeof monthlySalary !== 'number') {
|
||||
return { error: 'salary_run_id, employee_id and monthly_salary are required', status: 400 }
|
||||
}
|
||||
|
||||
try {
|
||||
const { setRunEmployeeSalary } = await import('@/lib/salary/run-employees')
|
||||
const { getErrorEntry } = await import('@/lib/errors/structured-errors')
|
||||
const result = await setRunEmployeeSalary(supabase, {
|
||||
companyId,
|
||||
salaryRunId,
|
||||
employeeId,
|
||||
monthlySalary,
|
||||
})
|
||||
if (!result.ok) {
|
||||
const entry = getErrorEntry(result.code)
|
||||
return {
|
||||
error: entry?.message_sv ?? `Kunde inte sätta månadens lön: ${result.code}`,
|
||||
status: entry?.httpStatus ?? 500,
|
||||
}
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
salary_run_id: salaryRunId,
|
||||
salary_run_employee_id: result.data.salary_run_employee_id,
|
||||
employee_id: result.data.employee_id,
|
||||
previous_monthly_salary: result.data.previous_monthly_salary,
|
||||
monthly_salary: result.data.monthly_salary,
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
error: err instanceof Error ? err.message : 'Failed to set run salary',
|
||||
status: 500,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function commitCreateEmployee(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
@@ -6525,6 +6570,9 @@ async function commitPendingOperationInner(
|
||||
case 'update_payslip_line':
|
||||
result = await commitUpdatePayslipLine(supabase, companyId, pendingOp.params)
|
||||
break
|
||||
case 'set_run_salary':
|
||||
result = await commitSetRunSalary(supabase, companyId, pendingOp.params)
|
||||
break
|
||||
case 'register_absence':
|
||||
result = await commitRegisterAbsence(supabase, companyId, pendingOp.params)
|
||||
break
|
||||
|
||||
@@ -154,6 +154,9 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
|
||||
// numbers feed a verifikation) and re-editable until then, but they change
|
||||
// a pay outcome: human review at medium, never silent.
|
||||
update_payslip_line: 'medium',
|
||||
// Draft-only edit of one employee's per-run base salary; no booking impact
|
||||
// until the run is calculated and booked (both separately staged).
|
||||
set_run_salary: 'medium',
|
||||
// Absence rows drive sjuklön math and the statutory AGI Frånvarouppgift.
|
||||
// Reversible via delete, but not audit-free: medium.
|
||||
register_absence: 'medium',
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { addEmployeeToRun, removeEmployeeFromRun } from '@/lib/salary/run-employees'
|
||||
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'
|
||||
@@ -217,3 +217,164 @@ describe('removeEmployeeFromRun', () => {
|
||||
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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { getLineItemAccount } from '@/lib/salary/account-mapping'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { SALARY_OVERRIDE_MAX } from '@/lib/api/schemas'
|
||||
import type { SalaryLineItemType } from '@/types'
|
||||
|
||||
export type RunEmployeeResult<T> =
|
||||
@@ -184,6 +185,130 @@ export async function addEmployeeToRun(
|
||||
return { ok: true, data: sre as unknown as SalaryRunEmployeeRow }
|
||||
}
|
||||
|
||||
export interface SetRunSalaryData {
|
||||
salary_run_employee_id: string
|
||||
employee_id: string
|
||||
salary_type: string
|
||||
employment_degree: number
|
||||
previous_monthly_salary: number
|
||||
monthly_salary: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Set THIS RUN's base salary for one employee (salary_run_employees.monthly_salary),
|
||||
* leaving the employee master record untouched. Draft runs only. 0 is valid
|
||||
* (an intentional nollkörning). The calculation engine reads this per-run value,
|
||||
* so the displayed 'Grundlön' line refresh here is display-only; a recalculation
|
||||
* derives gross from the column, never from the line.
|
||||
*/
|
||||
export async function setRunEmployeeSalary(
|
||||
supabase: SupabaseClient,
|
||||
args: {
|
||||
companyId: string
|
||||
salaryRunId: string
|
||||
employeeId: string
|
||||
monthlySalary: number
|
||||
/** Validate + resolve only; return the would-be change without writing. */
|
||||
dryRun?: boolean
|
||||
},
|
||||
): Promise<RunEmployeeResult<SetRunSalaryData>> {
|
||||
// Single enforcement point for the salary bound: the cookie route's Zod
|
||||
// schema, the v1 body schema and the MCP tool all funnel through here. The
|
||||
// cap also keeps roundOre far away from Infinity (1e307 * 100 overflows).
|
||||
if (
|
||||
!Number.isFinite(args.monthlySalary) ||
|
||||
args.monthlySalary < 0 ||
|
||||
args.monthlySalary > SALARY_OVERRIDE_MAX
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'VALIDATION_ERROR',
|
||||
details: { field: 'monthly_salary', max: SALARY_OVERRIDE_MAX },
|
||||
}
|
||||
}
|
||||
const gate = await assertRunDraftForRoster(supabase, args.companyId, args.salaryRunId)
|
||||
if (!gate.ok) return gate
|
||||
|
||||
const monthly = roundOre(args.monthlySalary)
|
||||
|
||||
const { data: sre, error: sreError } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.select('id, employee_id, salary_type, employment_degree, monthly_salary')
|
||||
.eq('salary_run_id', args.salaryRunId)
|
||||
.eq('employee_id', args.employeeId)
|
||||
.eq('company_id', args.companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (sreError) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'INTERNAL_ERROR',
|
||||
details: { message: sreError.message, code: sreError.code },
|
||||
}
|
||||
}
|
||||
if (!sre) {
|
||||
return { ok: false, code: 'SALARY_RUN_EMPLOYEE_NOT_FOUND' }
|
||||
}
|
||||
|
||||
const row = sre as {
|
||||
id: string
|
||||
employee_id: string
|
||||
salary_type: string
|
||||
employment_degree: number
|
||||
monthly_salary: number
|
||||
}
|
||||
|
||||
const data: SetRunSalaryData = {
|
||||
salary_run_employee_id: row.id,
|
||||
employee_id: row.employee_id,
|
||||
salary_type: row.salary_type,
|
||||
employment_degree: row.employment_degree,
|
||||
previous_monthly_salary: row.monthly_salary,
|
||||
monthly_salary: monthly,
|
||||
}
|
||||
|
||||
if (args.dryRun) {
|
||||
return { ok: true, data }
|
||||
}
|
||||
|
||||
// Clearing calculation_breakdown is what makes a stale booking impossible:
|
||||
// both book preflights (MCP gnubok_book_salary_run and the v1/UI path via
|
||||
// advanceAndBookSalaryRun) refuse roster rows without a breakdown, so a
|
||||
// salary change after a calculation forces a recalculation before booking
|
||||
// instead of silently booking gross/tax derived from the old salary.
|
||||
const { error: updError } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.update({ monthly_salary: monthly, calculation_breakdown: null })
|
||||
.eq('id', row.id)
|
||||
.eq('company_id', args.companyId)
|
||||
|
||||
if (updError) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'INTERNAL_ERROR',
|
||||
details: { message: updError.message, code: updError.code },
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the displayed 'Grundlön' line consistent before the next calculation.
|
||||
// Display-only: the engine recomputes baseSalary from monthly_salary at calc
|
||||
// time, and the next calculation rewrites this row anyway, so a failure here
|
||||
// must not fail the request: the salary write above has already committed,
|
||||
// and reporting failure for an applied change is worse than a briefly stale
|
||||
// display row (matches the pre-refactor route behavior).
|
||||
if (row.salary_type === 'monthly') {
|
||||
const baseAmount = roundOre(monthly * (row.employment_degree / 100))
|
||||
await supabase
|
||||
.from('salary_line_items')
|
||||
.update({ amount: baseAmount })
|
||||
.eq('salary_run_employee_id', row.id)
|
||||
.eq('company_id', args.companyId)
|
||||
.eq('item_type', 'monthly_salary')
|
||||
}
|
||||
|
||||
return { ok: true, data }
|
||||
}
|
||||
|
||||
export async function removeEmployeeFromRun(
|
||||
supabase: SupabaseClient,
|
||||
args: {
|
||||
|
||||
Reference in New Issue
Block a user