* 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>
357 lines
11 KiB
TypeScript
357 lines
11 KiB
TypeScript
/**
|
|
* Shared salary-run roster commands: attach/remove an employee on a DRAFT run.
|
|
*
|
|
* Single source of truth consumed by the internal dashboard routes
|
|
* (app/api/salary/runs/[id]/employees/**) and the v1 REST routes. Attaching
|
|
* snapshots the employee's pay config onto salary_run_employees (so later
|
|
* employee edits don't retroactively change an open run) and seeds the base
|
|
* salary line item.
|
|
*
|
|
* Result-object convention mirrors lib/salary/run-calculation.ts.
|
|
*/
|
|
|
|
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> =
|
|
| { ok: true; data: T }
|
|
| { ok: false; code: string; details?: Record<string, unknown> }
|
|
|
|
export interface SalaryRunEmployeeRow {
|
|
id: string
|
|
salary_run_id: string
|
|
employee_id: string
|
|
company_id: string
|
|
employment_degree: number
|
|
monthly_salary: number
|
|
salary_type: string
|
|
hours_worked: number | null
|
|
tax_table_number: number | null
|
|
tax_column: number | null
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
async function assertRunDraftForRoster(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
salaryRunId: string,
|
|
): Promise<RunEmployeeResult<{ id: string; status: string }>> {
|
|
const { data: run, error } = await supabase
|
|
.from('salary_runs')
|
|
.select('id, status')
|
|
.eq('id', salaryRunId)
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
|
|
if (error) {
|
|
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
|
|
}
|
|
if (!run) {
|
|
return { ok: false, code: 'SALARY_RUN_NOT_FOUND' }
|
|
}
|
|
if ((run as { status: string }).status !== 'draft') {
|
|
return {
|
|
ok: false,
|
|
code: 'SALARY_RUN_EMPLOYEES_NOT_DRAFT',
|
|
details: { current_status: (run as { status: string }).status },
|
|
}
|
|
}
|
|
return { ok: true, data: run as { id: string; status: string } }
|
|
}
|
|
|
|
export async function addEmployeeToRun(
|
|
supabase: SupabaseClient,
|
|
args: {
|
|
companyId: string
|
|
salaryRunId: string
|
|
employeeId: string
|
|
hoursWorked?: number | null
|
|
/** Validate + resolve only; return the would-be snapshot without writing. */
|
|
dryRun?: boolean
|
|
},
|
|
): Promise<RunEmployeeResult<SalaryRunEmployeeRow | (Omit<SalaryRunEmployeeRow, 'id' | 'created_at' | 'updated_at'> & { id: null })>> {
|
|
const gate = await assertRunDraftForRoster(supabase, args.companyId, args.salaryRunId)
|
|
if (!gate.ok) return gate
|
|
|
|
const { data: employee, error: empError } = await supabase
|
|
.from('employees')
|
|
.select(
|
|
'id, employment_degree, monthly_salary, hourly_rate, salary_type, employment_type, tax_table_number, tax_column',
|
|
)
|
|
.eq('id', args.employeeId)
|
|
.eq('company_id', args.companyId)
|
|
.eq('is_active', true)
|
|
.maybeSingle()
|
|
|
|
if (empError) {
|
|
return { ok: false, code: 'INTERNAL_ERROR', details: { message: empError.message } }
|
|
}
|
|
if (!employee) {
|
|
return { ok: false, code: 'EMPLOYEE_NOT_FOUND' }
|
|
}
|
|
|
|
const { data: existing, error: dupError } = await supabase
|
|
.from('salary_run_employees')
|
|
.select('id')
|
|
.eq('salary_run_id', args.salaryRunId)
|
|
.eq('employee_id', args.employeeId)
|
|
.maybeSingle()
|
|
|
|
if (dupError) {
|
|
return { ok: false, code: 'INTERNAL_ERROR', details: { message: dupError.message } }
|
|
}
|
|
if (existing) {
|
|
return {
|
|
ok: false,
|
|
code: 'SALARY_RUN_EMPLOYEE_DUPLICATE',
|
|
details: { salary_run_employee_id: (existing as { id: string }).id },
|
|
}
|
|
}
|
|
|
|
const emp = employee as {
|
|
id: string
|
|
employment_degree: number
|
|
monthly_salary: number | null
|
|
hourly_rate: number | null
|
|
salary_type: string
|
|
employment_type: string
|
|
tax_table_number: number | null
|
|
tax_column: number | null
|
|
}
|
|
|
|
const snapshot = {
|
|
salary_run_id: args.salaryRunId,
|
|
employee_id: emp.id,
|
|
company_id: args.companyId,
|
|
employment_degree: emp.employment_degree,
|
|
monthly_salary: emp.monthly_salary || 0,
|
|
salary_type: emp.salary_type,
|
|
hours_worked: args.hoursWorked ?? null,
|
|
tax_table_number: emp.tax_table_number,
|
|
tax_column: emp.tax_column,
|
|
}
|
|
|
|
if (args.dryRun) {
|
|
return { ok: true, data: { ...snapshot, id: null } }
|
|
}
|
|
|
|
const { data: sre, error: sreError } = await supabase
|
|
.from('salary_run_employees')
|
|
.insert(snapshot)
|
|
.select()
|
|
.single()
|
|
|
|
if (sreError) {
|
|
// Race with a concurrent attach: the pre-flight passed but the insert
|
|
// tripped the unique constraint.
|
|
if ((sreError as { code?: string }).code === '23505') {
|
|
return { ok: false, code: 'SALARY_RUN_EMPLOYEE_DUPLICATE' }
|
|
}
|
|
return { ok: false, code: 'INTERNAL_ERROR', details: { message: sreError.message } }
|
|
}
|
|
|
|
// Seed the base salary line so the run displays a sensible gross before
|
|
// the first :calculate.
|
|
const baseSalaryType: SalaryLineItemType =
|
|
emp.salary_type === 'monthly' ? 'monthly_salary' : 'hourly_salary'
|
|
const baseAmount =
|
|
emp.salary_type === 'monthly'
|
|
? roundOre((emp.monthly_salary || 0) * (emp.employment_degree / 100))
|
|
: roundOre((emp.hourly_rate || 0) * (args.hoursWorked || 0))
|
|
|
|
const { error: lineError } = await supabase.from('salary_line_items').insert({
|
|
salary_run_employee_id: (sre as { id: string }).id,
|
|
company_id: args.companyId,
|
|
item_type: baseSalaryType,
|
|
description: emp.salary_type === 'monthly' ? 'Grundlön' : 'Timlön',
|
|
quantity: emp.salary_type === 'hourly' ? args.hoursWorked ?? null : null,
|
|
unit_price: emp.salary_type === 'hourly' ? emp.hourly_rate : null,
|
|
amount: baseAmount,
|
|
is_taxable: true,
|
|
is_avgift_basis: true,
|
|
is_vacation_basis: true,
|
|
account_number: getLineItemAccount(baseSalaryType, emp.employment_type as never),
|
|
sort_order: 0,
|
|
})
|
|
|
|
if (lineError) {
|
|
return { ok: false, code: 'INTERNAL_ERROR', details: { message: lineError.message } }
|
|
}
|
|
|
|
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: {
|
|
companyId: string
|
|
salaryRunId: string
|
|
employeeId: string
|
|
/** Validate + resolve only; do not delete. */
|
|
dryRun?: boolean
|
|
},
|
|
): Promise<RunEmployeeResult<{ deleted: true; employee_id: string }>> {
|
|
const gate = await assertRunDraftForRoster(supabase, args.companyId, args.salaryRunId)
|
|
if (!gate.ok) return gate
|
|
|
|
const { data: sre, error: sreError } = await supabase
|
|
.from('salary_run_employees')
|
|
.select('id')
|
|
.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 } }
|
|
}
|
|
if (!sre) {
|
|
return { ok: false, code: 'SALARY_RUN_EMPLOYEE_NOT_FOUND' }
|
|
}
|
|
|
|
if (args.dryRun) {
|
|
return { ok: true, data: { deleted: true, employee_id: args.employeeId } }
|
|
}
|
|
|
|
// Cascades to salary_line_items via ON DELETE CASCADE.
|
|
const { error } = await supabase
|
|
.from('salary_run_employees')
|
|
.delete()
|
|
.eq('salary_run_id', args.salaryRunId)
|
|
.eq('employee_id', args.employeeId)
|
|
.eq('company_id', args.companyId)
|
|
|
|
if (error) {
|
|
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
|
|
}
|
|
return { ok: true, data: { deleted: true, employee_id: args.employeeId } }
|
|
}
|