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
@@ -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(
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { SALARY_OVERRIDE_MAX } from '@/lib/api/schemas'
|
||||
import { ok, noContent } from '@/lib/api/v1/response'
|
||||
import { dryRunPreview } from '@/lib/api/v1/dry-run'
|
||||
import { registerEndpoint, dataEnvelope, NoBodyResponse } from '@/lib/api/v1/registry'
|
||||
@@ -18,7 +19,7 @@ import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { maskPersonnummer } from '@/lib/api/v1/mask-personnummer'
|
||||
import { decryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
import { removeEmployeeFromRun } from '@/lib/salary/run-employees'
|
||||
import { removeEmployeeFromRun, setRunEmployeeSalary } from '@/lib/salary/run-employees'
|
||||
|
||||
const PayslipLineItem = z.object({
|
||||
/** Qualified id of the salary_line_items row. */
|
||||
@@ -233,6 +234,121 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string;
|
||||
},
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// PATCH: set this run's base salary for the employee (draft only)
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const SetRunSalaryBody = z.object({
|
||||
/** This month's gross base salary in SEK. 0 is a valid nollkörning. */
|
||||
monthly_salary: z.number().finite().nonnegative().max(SALARY_OVERRIDE_MAX),
|
||||
})
|
||||
|
||||
const SetRunSalaryResponse = z.object({
|
||||
salary_run_employee_id: z.string().uuid(),
|
||||
employee_id: z.string().uuid(),
|
||||
salary_type: z.string(),
|
||||
employment_degree: z.number(),
|
||||
previous_monthly_salary: z.number(),
|
||||
monthly_salary: z.number(),
|
||||
})
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'salary-runs.employees.set-salary',
|
||||
method: 'PATCH',
|
||||
path: '/api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId',
|
||||
summary: 'Set this run\'s base salary for one employee.',
|
||||
description:
|
||||
'Sets the per-run base salary (salary_run_employees.monthly_salary) that the calculation engine reads for this run. The employee master record is untouched, so each month\'s gross can differ from the employee\'s standard pay (variable owner salary). Draft-only; 0 is a valid nollkörning.',
|
||||
useWhen:
|
||||
'The employee\'s pay this month differs from their configured fixed salary: owners taking salary by need and capacity, one-off adjustments, or a deliberate zero month.',
|
||||
doNotUseFor:
|
||||
'Changing the employee\'s standard salary going forward: PATCH /employees/{id}. Editing individual payslip lines (tillägg/avdrag): the lines endpoints. Tax/avgifter overrides in review: not exposed on v1 yet.',
|
||||
pitfalls: [
|
||||
'Draft-only: 400 SALARY_RUN_EMPLOYEES_NOT_DRAFT once the run has advanced.',
|
||||
'Run POST /calculate afterwards: gross, tax and totals reflect the new salary only after recalculation.',
|
||||
'Do NOT edit the monthly_salary line item instead: recalculation rebuilds base salary lines from this per-run value.',
|
||||
'For hourly employees the value is stored but gross derives from hours worked; the salary_type field in the response tells you which applies.',
|
||||
],
|
||||
example: {
|
||||
request: { monthly_salary: 45000 },
|
||||
response: {
|
||||
data: {
|
||||
salary_run_employee_id: 'sre_a8f1…',
|
||||
employee_id: 'emp_77b2…',
|
||||
salary_type: 'monthly',
|
||||
employment_degree: 100,
|
||||
previous_monthly_salary: 30000,
|
||||
monthly_salary: 45000,
|
||||
},
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'payroll:write',
|
||||
// Matches the staged-operation tier for set_run_salary in risk-tiers.ts.
|
||||
risk: 'medium',
|
||||
idempotent: true,
|
||||
reversible: true,
|
||||
dryRunSupported: true,
|
||||
request: { body: SetRunSalaryBody },
|
||||
response: { success: dataEnvelope(SetRunSalaryResponse) },
|
||||
})
|
||||
|
||||
export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string; employeeId: string }> }>(
|
||||
'salary-runs.employees.set-salary',
|
||||
async (request, ctx, params) => {
|
||||
const { id, employeeId } = await params.params
|
||||
const runParse = z.string().uuid().safeParse(id)
|
||||
const empParse = z.string().uuid().safeParse(employeeId)
|
||||
if (!runParse.success || !empParse.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
field: runParse.success ? 'employeeId' : 'id',
|
||||
message: 'Path ids must be UUIDs.',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { message: 'Request body must be JSON.' },
|
||||
})
|
||||
}
|
||||
const parsed = SetRunSalaryBody.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { issues: parsed.error.issues },
|
||||
})
|
||||
}
|
||||
|
||||
const result = await setRunEmployeeSalary(ctx.supabase, {
|
||||
companyId: ctx.companyId!,
|
||||
salaryRunId: runParse.data,
|
||||
employeeId: empParse.data,
|
||||
monthlySalary: parsed.data.monthly_salary,
|
||||
dryRun: ctx.dryRun,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
return v1ErrorResponseFromCode(result.code, ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: result.details,
|
||||
})
|
||||
}
|
||||
|
||||
if (ctx.dryRun) {
|
||||
return dryRunPreview(result.data, { requestId: ctx.requestId, log: ctx.log })
|
||||
}
|
||||
return ok(result.data, { requestId: ctx.requestId })
|
||||
},
|
||||
{ requireIdempotencyKey: true },
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// DELETE: remove an employee from a draft run
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
+162
-1
@@ -36,7 +36,7 @@ vi.mock('@supabase/supabase-js', async () => {
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { GET as listRunEmployees, POST as attachEmployee } from '../route'
|
||||
import { GET as getPayslip, DELETE as removeEmployee } from '../[employeeId]/route'
|
||||
import { GET as getPayslip, DELETE as removeEmployee, PATCH as setRunSalary } from '../[employeeId]/route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
@@ -495,6 +495,167 @@ describe('POST /api/v1/companies/:companyId/salary-runs/:id/employees', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId', () => {
|
||||
const patchRequest = (url: string, body: unknown, extraHeaders: Record<string, string> = {}): Request =>
|
||||
new Request(url, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: 'Bearer test-fixture-not-a-real-key',
|
||||
'Idempotency-Key': 'b3aaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
||||
...extraHeaders,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const SRE_SALARY_ROW = {
|
||||
id: SRE_ID,
|
||||
employee_id: EMPLOYEE_ID,
|
||||
salary_type: 'monthly',
|
||||
employment_degree: 100,
|
||||
monthly_salary: 30000,
|
||||
}
|
||||
|
||||
it('sets this run\'s salary on a draft (happy path)', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
|
||||
salary_run_employees: [
|
||||
{ data: SRE_SALARY_ROW, error: null }, // select
|
||||
{ data: null, error: null }, // update
|
||||
],
|
||||
salary_line_items: { data: null, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await setRunSalary(
|
||||
patchRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}`,
|
||||
{ monthly_salary: 45000 },
|
||||
),
|
||||
detailParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.salary_run_employee_id).toBe(SRE_ID)
|
||||
expect(body.data.previous_monthly_salary).toBe(30000)
|
||||
expect(body.data.monthly_salary).toBe(45000)
|
||||
})
|
||||
|
||||
it('returns 400 SALARY_RUN_EMPLOYEES_NOT_DRAFT once the run advanced', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: { id: RUN_ID, status: 'review' }, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await setRunSalary(
|
||||
patchRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}`,
|
||||
{ monthly_salary: 45000 },
|
||||
),
|
||||
detailParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('SALARY_RUN_EMPLOYEES_NOT_DRAFT')
|
||||
})
|
||||
|
||||
it('returns 404 SALARY_RUN_EMPLOYEE_NOT_FOUND when the employee is not on the run', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
|
||||
salary_run_employees: { data: null, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await setRunSalary(
|
||||
patchRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}`,
|
||||
{ monthly_salary: 45000 },
|
||||
),
|
||||
detailParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('SALARY_RUN_EMPLOYEE_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('rejects a negative salary with 400 VALIDATION_ERROR', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await setRunSalary(
|
||||
patchRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}`,
|
||||
{ monthly_salary: -1 },
|
||||
),
|
||||
detailParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
|
||||
it('rejects keys without payroll:write scope', async () => {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'read only',
|
||||
scopes: ['payroll:read'],
|
||||
mode: 'live',
|
||||
})
|
||||
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
|
||||
|
||||
const res = await setRunSalary(
|
||||
patchRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}`,
|
||||
{ monthly_salary: 45000 },
|
||||
),
|
||||
detailParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('INSUFFICIENT_SCOPE')
|
||||
})
|
||||
|
||||
it('dry-run previews without writing', async () => {
|
||||
const supabaseMock = makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
|
||||
salary_run_employees: { data: SRE_SALARY_ROW, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
})
|
||||
mockServiceClient.mockReturnValue(supabaseMock)
|
||||
|
||||
const res = await setRunSalary(
|
||||
patchRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}?dry_run=true`,
|
||||
{ monthly_salary: 45000 },
|
||||
),
|
||||
detailParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.dry_run).toBe(true)
|
||||
expect(body.data.preview.monthly_salary).toBe(45000)
|
||||
// No write tables touched.
|
||||
const tables = (supabaseMock.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
|
||||
expect(tables).not.toContain('salary_line_items')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId', () => {
|
||||
const deleteRequest = (url: string): Request =>
|
||||
new Request(url, {
|
||||
|
||||
Reference in New Issue
Block a user